1import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
2import * as Cloudflare from "@/Cloudflare/index.ts";
3import { readPythonWorkerBundle } from "@/Cloudflare/Workers/Sources/Python";
4import * as Test from "@/Test/Alchemy";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import { MinimumLogLevel } from "effect/References";
8import * as Schedule from "effect/Schedule";
9import * as HttpClient from "effect/http/HttpClient";
10import * as HttpClientRequest from "effect/http/HttpClientRequest";
11import * as pathe from "pathe";
12import { expectUrlContains, HttpAssertionFailed } from "../Utils/Http.ts";
13import { waitForWorkerToBeDeleted } from "../Utils/Worker.ts";
14
15const { test } = Test.make({ providers: Cloudflare.providers() });
16
17const logLevel = Effect.provideService(
18 MinimumLogLevel,
19 process.env.DEBUG ? "Debug" : "Info",
20);
21
22const main = pathe.resolve(import.meta.dirname, "fixtures/python/worker.py");
23const depsMain = pathe.resolve(
24 import.meta.dirname,
25 "fixtures/python-deps/worker.py",
26);
27const fastapiMain = pathe.resolve(
28 import.meta.dirname,
29 "fixtures/python-fastapi/worker.py",
30);
31
32describe.concurrent(
33 "Cloudflare.Worker with a Python entrypoint",
34 { tags: ["provider:cloudflare", "provider:cloudflare:worker", "live"] },
35 () => {
36 test.provider(
37 "uploads python modules and serves from the runtime",
38 (stack) =>
39 Effect.gen(function* () {
40 const { accountId } = yield* yield* CloudflareEnvironment;
41
42 yield* stack.destroy();
43
44 // The bundle the provider must upload: the entry first (it becomes
45 // `main_module`), sibling `.py` sources named by their path
46 // relative to the entry's directory. No bundler runs — Python
47 // sources are interpreted directly by Pyodide.
48 const expected = yield* readPythonWorkerBundle({
49 id: "PythonWorker",
50 fqn: "PythonWorker",
51 main,
52 compatibility: { date: "2026-03-17", flags: ["python_workers"] },
53 });
54 const paths = expected.files.map((file) => file.path);
55 expect(paths.slice(0, 2)).toEqual(["worker.py", "util.py"]);
56 // Current workerd releases externalize the Python Workers SDK, so
57 // Alchemy vendors the managed runtime even without a pyproject.toml.
58 expect(paths).toContain("python_modules/workers/__init__.py");
59
60 const worker = yield* stack.deploy(
61 Effect.gen(function* () {
62 return yield* Cloudflare.Worker("PythonWorker", {
63 main,
64 workersDev: true,
65 env: { PY_SUFFIX: "42" },
66 });
67 }),
68 );
69
70 // The stored bundle hash covers the source and managed SDK bytes;
71 // Python modules are uploaded directly without JS bundling.
72 expect(worker.hash?.bundle).toEqual(expected.hash);
73
74 // End-to-end: the response interpolates a constant from the
75 // sibling `util.py` module and an env binding, so it only renders
76 // if the module graph and bindings survived the upload.
77 expect(worker.url).toBeDefined();
78 yield* expectUrlContains(
79 worker.url!,
80 "alchemy-python-worker-7c1f suffix=42",
81 );
82
83 yield* stack.destroy();
84 yield* waitForWorkerToBeDeleted(worker.workerName, accountId);
85 }).pipe(logLevel),
86 { timeout: 360_000 },
87 );
88
89 // Vendors `humanize` (plus the managed `workers-runtime-sdk`) with uv
90 // against the Pyodide wheel index and uploads it under
91 // `python_modules/`. Requires uv (>= 0.8.10) on PATH.
92 test.provider.skipIf(!!process.env.FAST)(
93 "vendors pyproject.toml dependencies with uv",
94 (stack) =>
95 Effect.gen(function* () {
96 const { accountId } = yield* yield* CloudflareEnvironment;
97
98 yield* stack.destroy();
99
100 const bundle = yield* readPythonWorkerBundle({
101 id: "PythonDepsWorker",
102 fqn: "PythonDepsWorker",
103 main: depsMain,
104 compatibility: { date: "2026-03-17", flags: ["python_workers"] },
105 });
106 const paths = bundle.files.map((file) => file.path);
107 expect(paths[0]).toEqual("worker.py");
108 // The vendored wheel contents ride along under python_modules/.
109 expect(
110 paths.some((p) => p.startsWith("python_modules/humanize/")),
111 ).toBe(true);
112 // pywrangler parity: the managed SDK package is always vendored.
113 expect(
114 paths.some((p) => p.startsWith("python_modules/workers/")),
115 ).toBe(true);
116
117 const worker = yield* stack.deploy(
118 Effect.gen(function* () {
119 return yield* Cloudflare.Worker("PythonDepsWorker", {
120 main: depsMain,
121 workersDev: true,
122 });
123 }),
124 );
125
126 // `humanize.intcomma` only resolves if the vendored package was
127 // uploaded and is importable at runtime.
128 expect(worker.url).toBeDefined();
129 yield* expectUrlContains(worker.url!, "vendored=1,234,567");
130
131 yield* stack.destroy();
132 yield* waitForWorkerToBeDeleted(worker.workerName, accountId);
133 }).pipe(logLevel),
134 { timeout: 360_000 },
135 );
136
137 // The supported-package surface of Python Workers, exercised end-to-end
138 // through one FastAPI app:
139 // - fastapi + pydantic (Pyodide-built-in packages) served via the
140 // runtime-provided `asgi` module
141 // - numpy — a compiled-extension wheel, proving vendored binary wheels
142 // match the deployed Pyodide runtime's emscripten-wasm32 ABI
143 // - httpx — async outbound HTTP (the only kind Workers support)
144 // - env bindings threaded into ASGI routes via `request.scope["env"]`
145 test.provider.skipIf(!!process.env.FAST)(
146 "serves a FastAPI app with pydantic, numpy, and httpx",
147 (stack) =>
148 Effect.gen(function* () {
149 const { accountId } = yield* yield* CloudflareEnvironment;
150
151 yield* stack.destroy();
152
153 const worker = yield* stack.deploy(
154 Effect.gen(function* () {
155 return yield* Cloudflare.Worker("PythonFastapiWorker", {
156 main: fastapiMain,
157 workersDev: true,
158 env: { DEPLOYMENT: "alchemy-fastapi-e2e" },
159 });
160 }),
161 );
162 expect(worker.url).toBeDefined();
163 const url = worker.url!.replace(/\/$/, "");
164
165 // ASGI routing (absorbs first-deploy propagation via retries).
166 yield* expectUrlContains(`${url}/`, '"framework":"fastapi"');
167 // Env bindings reach ASGI routes through request.scope["env"].
168 yield* expectUrlContains(`${url}/env`, "alchemy-fastapi-e2e");
169 // numpy: compiled wheel imports and computes (sum(0..9) = 45).
170 yield* expectUrlContains(`${url}/numpy`, '"sum":45');
171 // httpx: async outbound subrequest succeeds.
172 yield* expectUrlContains(`${url}/outbound`, '"status":200');
173
174 // pydantic: POST body is validated and parsed into the Item model.
175 // The POST rides the same first-deploy propagation as the GETs above
176 // (a cold POP can still 404 briefly), so retry it the same way
177 // instead of asserting a single shot.
178 const client = yield* HttpClient.HttpClient;
179 const body = (yield* HttpClientRequest.post(`${url}/items`).pipe(
180 HttpClientRequest.bodyJsonUnsafe({ name: "widget", quantity: 21 }),
181 client.execute,
182 Effect.flatMap((response) =>
183 response.status === 200
184 ? response.json
185 : response.text.pipe(
186 Effect.flatMap((text) =>
187 Effect.fail(
188 new HttpAssertionFailed({
189 url: `${url}/items`,
190 marker: "200",
191 status: response.status,
192 bodyExcerpt: text.slice(0, 240),
193 }),
194 ),
195 ),
196 ),
197 ),
198 Effect.retry({
199 while: (e) => e._tag === "HttpAssertionFailed",
200 schedule: Schedule.max([
201 Schedule.exponential("1 second"),
202 Schedule.recurs(8),
203 ]),
204 }),
205 )) as { name: string; total: number };
206 expect(body).toEqual({ name: "widget", total: 42 });
207
208 yield* stack.destroy();
209 yield* waitForWorkerToBeDeleted(worker.workerName, accountId);
210 }).pipe(logLevel),
211 { timeout: 360_000 },
212 );
213 },
214);