Deploying to Cloudflare Pages from pure Python — no Node, no wrangler
I run a Python-only content pipeline. It generates a static site, and it needs to put that site on Cloudflare Pages, unattended, on a schedule. The official way to do that is wrangler. Wrangler is Node. Adding a Node toolchain to a Python service just to copy files to a CDN felt like the wrong trade, so I went the other way: I read what wrangler actually sends over the wire and reimplemented it with http.client and the standard library. It works. It took longer than it should have, because three of the four stages have a failure mode that returns HTTP 200 and then silently does nothing. This is the map I wish I'd had. The four stages Cloudflare calls this "Direct Upload." It is four separate HTTP calls, and two of them talk to a different URL shape than the other two. # Method + path Auth 1 POST /client/v4/accounts//pages/projects//upload-token API token 2 POST /client/v4/pages/assets/upload JWT from step 1 3 POST /client/v4/pages/assets/upsert-hashes JWT from step 1 4 POST /client/v4/accounts//pages/projects//deployments API token Step 1 gives you a short-lived, account-scoped JWT. Steps 2 and 3 use that JWT — and in my testing those two endpoints take no /accounts/ prefix, presumably because the JWT already carries the account scope. Step 4 goes back to your normal API token and the full path. I lost an hour to that asymmetry alone. If you paste an account prefix into step 2, you get a 404 that reads like the endpoint doesn't exist. Trap 1: the asset hash is not what you think This is the one that cost me an evening, and the reason I'm writing this post. Every asset you upload is keyed by a hash. My first instinct was SHA-256 of the file bytes. That uploads fine. Step 3 confirms fine. The deployment is created fine. file_count comes back positive. And then every single asset 404s forever. Cloudflare's key is not SHA-256, and it is not a hash of the raw bytes. It is: blake3( base64(file_contents) + file_extension ).hex()[:32] Three things to get right, all of them easy to get wrong: BLAKE3, not SHA-256 or MD5. You hash the base64 text of the file, not the file bytes. You append the extension without the dot (index.html → html) to that base64 string before hashing. You take the first 32 hex characters — 128 bits, not the full 256. In Python: import base64, os def cf_hash(data: bytes, rel_path: str) -> str: ext = os.path.splitext(rel_path)[1][1:] # "index.html" -> "html" b64 = base64.b64encode(data) # ASCII bytes return blake3_hex(b64 + ext.encode("ascii"))[:32] You don't have to take my word for the shape — this is hashFile from wrangler's own source, in packages/deploy-helpers/src/deploy/helpers/hash.ts: export const hashFile = (filepath: string) => { const contents = readFileSync(filepath); const base64Contents = contents.toString("base64"); const extension = extname(filepath).substring(1); return blake3hash(base64Contents + extension) .toString("hex") .slice(0, 32); }; That's the whole thing. Port those four lines and you're done. The reason getting it wrong is so painful: in my testing, the API accepted a wrong key without complaint. It stored the blob under whatever key I gave it. The upload succeeded, the hash upsert succeeded, the deployment succeeded. The asset was simply never served, because the lookup derives the key independently. There was no error to search for. If your Pages deploy "works" but serves 404s for everything, this is almost certainly why. Trap 2: no BLAKE3 wheel? Write it. Small problem: I'm on CPython 3.14, and there was no prebuilt blake3 wheel for it. Compiling the Rust extension was not something I wanted in the pipeline. So I implemented BLAKE3 in pure Python — unkeyed, 32-byte output, which is all this use case needs. It is about 200 lines: the g mixing function, the 7-round compression with the message permutation, chunk state, and the parent-CV binary tree. MSG_PERMUTATION = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8] def _g(state, a, b, c, d, mx, my): state[a] = _add32(state[a], _add32(state[b], mx)) state[d] = _rotr(state[d] ^ state[a], 16) state[c] = _add32(state[c], state[d]) state[b] = _rotr(state[b] ^ state[c], 12) state[a] = _add32(state[a], _add32(state[b], my)) state[d] = _rotr(state[d] ^ state[a], 8) state[c] = _add32(state[c], state[d]) state[b] = _rotr(state[b] ^ state[c], 7) Verify it against the official test vectors. The BLAKE3 team publishes test_vectors.json; I checked 35 of them across input lengths from 0 to 102400 bytes. Do not skip this. A subtly wrong hash function in this position produces exactly the silent 404 above, and you will blame the API. Pure Python BLAKE3 is slow. For a few hundred small static files it is irrelevant — this runs once per deploy and the network dominates. Trap 3: the Worker bundle is multipart inside multipart If you use Pages Functions, they don't go up as static assets. In Advanced Mode they get bundled into one _worker.js and sent as a _worker.bundle field on the step-4 deployment request. That bundle is itself a multipart/form-data body: one field metadata containing main_module set to your entry module's name one part per ES module, each with Content-Type: application/javascript+module Both of those are confirmed in wrangler's create-worker-upload-form.ts: ESM workers set main_module: main.name in the metadata (CommonJS workers use body_part instead), and the MIME map contains "esm": "application/javascript+module". And here is the part that took me three failed deploys: the _worker.bundle part in the outer request must carry the inner multipart's Content-Type, boundary and all: inner_boundary, bundle_body = build_worker_bundle() bundle_ctype = "multipart/form-data; boundary=" + inner_boundary parts = [ ("field", "manifest", json.dumps(manifest, separators=(",", ":"))), ("field", "branch", "main"), ("file", "_redirects", "_redirects", redirects, "text/plain"), ("file", "_worker.bundle", "_worker.bundle", bundle_body, bundle_ctype), ] That makes sense once you picture how the JS side produces it: createWorkerUploadForm returns a FormData, and whatever turns that into a request body hands over something whose type is multipart/form-data; boundary=…. Reproducing it from Python means setting that Content-Type by hand on the outer part. Sending application/octet-stream instead got me a Worker that 500'd on every request, with nothing useful in the logs. Trap 4: two small ones that cost real time Manifest keys need a leading slash. The manifest maps path → hash, and the paths are absolute: manifest = {"/" + rel: h for rel, h in hashes.items()} _redirects and _worker.js are not assets. Don't include them in the file walk you upload in step 2 — they travel as their own form fields in step 4. Wrangler skips them at validation time, and the ignore list in packages/wrangler/src/pages/validate.ts is worth copying wholesale: const IGNORE_LIST = [ "_worker.js", "_redirects", "_headers", "_routes.json", "functions", "**/.DS_Store", "**/node_modules", "**/.git", ".wrangler", ].map((pattern) => new Minimatch(pattern)); I had only excluded the first two. _headers, _routes.json and functions/ belong on that list too. What you end up with Zero third-party dependencies. http.client, base64, mimetypes, json, plus the BLAKE3 module. No Node, no node_modules, no wrangler version to keep in step with. The whole deploy is a function you can call from anything: result = deploy() # {'ok': True, 'url': 'https://.pages.dev', 'file_count': 116, 'seconds': 41.3} Batch the asset upload — I use 500 files per request (wrangler's ceiling is 1000 per bucket) and cap individual assets at 25 MB, which is the Pages per-file limit. One last thing worth knowing: after step 4 returns, poll the deployment until file_count is greater than zero. A deployment can come back 200 OK with an empty file set, and that is your last chance to notice before it goes live. Written up from a working implementation. If you're doing the same and get stuck on the hash, that section is the whole reason this post exists.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to