Skip to content
Draft
7 changes: 7 additions & 0 deletions .chronus/changes/python-browser-wheel-assets-2026-09-28.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/http-client-python"
---

Retry Playground Python generation with published runtime and CFS-built Python wheels when public package services are unavailable.
85 changes: 85 additions & 0 deletions packages/http-client-python/emitter/src/browser-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import pkgJson from "../../package.json" with { type: "json" };
import {
BLOB_STORAGE_BASE_URL,
PACKAGE_NAME,
PYGEN_WHEEL_FILENAME,
PYODIDE_VERSION,
} from "./constants.js";
import type { PyodideInterface } from "./pyodide-loader.js";
import { loadPyodide } from "./pyodide-loader.js";

function getBrowserPygenWheelUrl(): string {
return `${BLOB_STORAGE_BASE_URL}/${PACKAGE_NAME}/${pkgJson.version}/generator/dist/${PYGEN_WHEEL_FILENAME}`;
}

let browserPyodidePromise: Promise<PyodideInterface> | undefined;

/**
* Boot the Pyodide runtime lazily, on the first browser emit.
*
* This must not happen when the module is imported: hosts like the TypeSpec playground import every
* available emitter up front, and booting Pyodide downloads a full CPython WebAssembly runtime plus
* its wheels (~10MB, ~290MB of resident memory). Doing that eagerly pushed the playground past the
* per-tab memory budget on mobile browsers, which made the page fail to load.
*/
export function getBrowserPyodide(): Promise<PyodideInterface> | null {
if (typeof window === "undefined") {
return null;
}
if (browserPyodidePromise === undefined) {
browserPyodidePromise = setupPyodideCallBrowser().catch((error) => {
// Clear the cached promise so a later emit can retry after a transient failure.
browserPyodidePromise = undefined;
throw error;
});
}
return browserPyodidePromise;
}

async function setupPyodideCallBrowser(): Promise<PyodideInterface> {
let pyodide: PyodideInterface | undefined;
try {
pyodide = await loadPyodide({
indexURL: `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`,
});
pyodide.FS.mkdirTree("/generator");
await pyodide.loadPackage("micropip");
await pyodide.pyimport("micropip").install(getBrowserPygenWheelUrl());
return pyodide;
} catch (publicError) {
try {
const assetsUrl = `${BLOB_STORAGE_BASE_URL}/${PACKAGE_NAME}/${pkgJson.version}/generator/dist`;
pyodide ??= await loadPyodide({ indexURL: `${assetsUrl}/pyodide/` });
pyodide.FS.mkdirTree("/generator");
const response = await fetch(`${assetsUrl}/browser-wheels.json`);
if (!response.ok) {
throw new Error(`Failed to load browser Python wheels: ${response.status}`);

Check failure on line 56 in packages/http-client-python/emitter/src/browser-runtime.ts

View workflow job for this annotation

GitHub Actions / Lint

There is no cause error attached to this new thrown error.
}
const wheels: unknown = await response.json();
if (
!Array.isArray(wheels) ||
wheels.length < 3 ||
!wheels.every(
(name) =>
typeof name === "string" &&
/^[A-Za-z0-9_]+-[A-Za-z0-9_.-]+-(?:py3|py2\.py3)-none-any\.whl$/.test(name),
) ||
!wheels[0].startsWith("packaging-") ||
!wheels[1].startsWith("micropip-")
) {
throw new Error("Invalid browser Python wheel manifest");

Check failure on line 70 in packages/http-client-python/emitter/src/browser-runtime.ts

View workflow job for this annotation

GitHub Actions / Lint

There is no cause error attached to this new thrown error.
}
const urls = wheels.map((name) => `${assetsUrl}/browser-wheels/${name}`);
await pyodide.loadPackage(urls.slice(0, 2));
await pyodide
.pyimport("micropip")
.install.callKwargs([...urls.slice(2), getBrowserPygenWheelUrl()], { deps: false });
return pyodide;
} catch (hostedError) {
throw new AggregateError(

Check failure on line 79 in packages/http-client-python/emitter/src/browser-runtime.ts

View workflow job for this annotation

GitHub Actions / Lint

There is no cause error attached to this new thrown error.
[publicError, hostedError],
`Public Python runtime failed (${String(publicError)}); hosted fallback failed (${String(hostedError)})`,
);
}
}
}
51 changes: 1 addition & 50 deletions packages/http-client-python/emitter/src/emitter.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,15 @@
import { createSdkContext } from "@azure-tools/typespec-client-generator-core";
import type { EmitContext } from "@typespec/compiler";
import { emitFile, joinPaths, listServices, NoTarget } from "@typespec/compiler";
import pkgJson from "../../package.json" with { type: "json" };
import { getBrowserPyodide } from "./browser-runtime.js";
import { emitCodeModel } from "./code-model.js";
import {
BLOB_STORAGE_BASE_URL,
PACKAGE_NAME,
PYGEN_WHEEL_FILENAME,
PYODIDE_VERSION,
} from "./constants.js";
import type { PythonEmitterOptions, PythonSdkContext } from "./lib.js";
import { reportDiagnostic } from "./lib.js";
import { runNodeEmit } from "./node-runner.js";
import type { PyodideInterface } from "./pyodide-loader.js";
import { loadPyodide } from "./pyodide-loader.js";
import { getRootNamespace, md2Rst } from "./utils.js";
import { dumpCodeModelToYaml } from "./yaml-utils.js";

function getBrowserPygenWheelUrl(): string {
return `${BLOB_STORAGE_BASE_URL}/${PACKAGE_NAME}/${pkgJson.version}/generator/dist/${PYGEN_WHEEL_FILENAME}`;
}

function addDefaultOptions(sdkContext: PythonSdkContext) {
const defaultOptions = {
"package-version": "1.0.0b1",
Expand Down Expand Up @@ -258,30 +247,6 @@ async function onEmitMain(context: EmitContext<PythonEmitterOptions>) {
}
}

let browserPyodidePromise: Promise<PyodideInterface> | undefined;

/**
* Boot the Pyodide runtime lazily, on the first browser emit.
*
* This must not happen when the module is imported: hosts like the TypeSpec playground import every
* available emitter up front, and booting Pyodide downloads a full CPython WebAssembly runtime plus
* its wheels (~10MB, ~290MB of resident memory). Doing that eagerly pushed the playground past the
* per-tab memory budget on mobile browsers, which made the page fail to load.
*/
function getBrowserPyodide(): Promise<PyodideInterface> | null {
if (typeof window === "undefined") {
return null;
}
if (browserPyodidePromise === undefined) {
browserPyodidePromise = setupPyodideCallBrowser().catch((error) => {
// Clear the cached promise so a later emit can retry after a transient failure.
browserPyodidePromise = undefined;
throw error;
});
}
return browserPyodidePromise;
}

function clearMemfsDirectory(pyodide: PyodideInterface, dir: string): void {
const entries: string[] = pyodide.FS.readdir(dir).filter(
(entry: string) => entry !== "." && entry !== "..",
Expand All @@ -297,17 +262,3 @@ function clearMemfsDirectory(pyodide: PyodideInterface, dir: string): void {
}
}
}

async function setupPyodideCallBrowser() {
const pyodide = await loadPyodide({
indexURL: `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`,
});

// use default MEMFS for browser, since NODEFS is not supported
pyodide.FS.mkdirTree("/generator");
await pyodide.loadPackage("micropip");
const micropip = pyodide.pyimport("micropip");
await micropip.install(getBrowserPygenWheelUrl());

return pyodide;
}
143 changes: 134 additions & 9 deletions packages/http-client-python/emitter/test/browser-pyodide.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,150 @@
import { expectDiagnostics } from "@typespec/compiler/testing";
import { strictEqual } from "assert";
import { afterEach, describe, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import pkgJson from "../../package.json" with { type: "json" };

const loadPyodide = vi.hoisted(() => vi.fn());

vi.mock("../src/pyodide-loader.js", () => ({ loadPyodide }));

describe("typespec-python: browser pyodide bootstrap", () => {
afterEach(() => {
delete (globalThis as any).window;
vi.unstubAllGlobals();
loadPyodide.mockReset();
vi.resetModules();
});

// Hosts like the TypeSpec playground import every available emitter up front. Booting Pyodide at
// module scope downloaded a full CPython WebAssembly runtime on every page load, which pushed the
// page past the per-tab memory budget on mobile browsers and prevented it from loading.
it("does not boot pyodide when the emitter is imported in a browser", async () => {
(globalThis as any).window = globalThis;
it("does not load a browser runtime outside the browser", async () => {
const { getBrowserPyodide } = await import("../src/browser-runtime.js");

await import("../src/emitter.js");
strictEqual(getBrowserPyodide(), null);
expect(loadPyodide).not.toHaveBeenCalled();
});

const assetsUrl = `https://typespec.blob.core.windows.net/pkgs/@typespec/http-client-python/${pkgJson.version}/generator/dist`;
const wheels = [
"packaging-23.2-py3-none-any.whl",
"micropip-0.6.0-py3-none-any.whl",
"PyYAML-6.0.1-py3-none-any.whl",
"colorama-0.4.6-py2.py3-none-any.whl",
];

function runtime() {
return {
FS: {
mkdirTree: vi.fn(),
writeFile: vi.fn(),
readdir: vi.fn().mockReturnValue([".", ".."]),
},
loadPackage: vi.fn().mockResolvedValue(undefined),
pyimport: vi.fn().mockReturnValue({
install: Object.assign(vi.fn().mockResolvedValue(undefined), {
callKwargs: vi.fn().mockResolvedValue(undefined),
}),
}),
toPy: vi.fn().mockReturnValue({ destroy: vi.fn() }),
runPythonAsync: vi.fn().mockResolvedValue(undefined),
};
}

async function emit() {
vi.resetModules();
vi.stubGlobal("window", globalThis);
const { EmitterTester } = await import("./test-host.js");
const [, diagnostics] = await EmitterTester.compileAndDiagnose(
"@service namespace Service { op read(): string; }",
);
return diagnostics;
}

it("does not request fallback assets when public packages are available", async () => {
const pyodide = runtime();
loadPyodide.mockResolvedValue(pyodide);
const fetchAssets = vi.fn();
vi.stubGlobal("fetch", fetchAssets);

expectDiagnostics(await emit(), []);
expect(loadPyodide).toHaveBeenCalledExactlyOnceWith({
indexURL: "https://cdn.jsdelivr.net/pyodide/v0.26.2/full/",
});
expect(pyodide.loadPackage).toHaveBeenCalledExactlyOnceWith("micropip");
expect(pyodide.pyimport().install).toHaveBeenCalledExactlyOnceWith(
`${assetsUrl}/pygen-0.1.0-py3-none-any.whl`,
);
expect(fetchAssets).not.toHaveBeenCalled();
});

it("loads hosted runtime and CFS-built wheels after a CDN failure", async () => {
const pyodide = runtime();
loadPyodide.mockRejectedValueOnce(new Error("CDN blocked")).mockResolvedValueOnce(pyodide);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => wheels }));

expectDiagnostics(await emit(), []);
expect(loadPyodide).toHaveBeenNthCalledWith(2, { indexURL: `${assetsUrl}/pyodide/` });
expect(pyodide.loadPackage).toHaveBeenCalledWith([
`${assetsUrl}/browser-wheels/${wheels[0]}`,
`${assetsUrl}/browser-wheels/${wheels[1]}`,
]);
expect(pyodide.pyimport().install.callKwargs).toHaveBeenCalledExactlyOnceWith(
[
...wheels.slice(2).map((name) => `${assetsUrl}/browser-wheels/${name}`),
`${assetsUrl}/pygen-0.1.0-py3-none-any.whl`,
],
{ deps: false },
);
});

it("reuses the runtime when loading public micropip fails", async () => {
const pyodide = runtime();
pyodide.loadPackage.mockRejectedValueOnce(new Error("Package CDN blocked"));
loadPyodide.mockResolvedValue(pyodide);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => wheels }));

expectDiagnostics(await emit(), []);
expect(loadPyodide).toHaveBeenCalledOnce();
expect(pyodide.pyimport().install).not.toHaveBeenCalled();
expect(pyodide.pyimport().install.callKwargs).toHaveBeenCalledOnce();
});

it("reuses the runtime after public Python package installation fails", async () => {
const pyodide = runtime();
pyodide.pyimport().install.mockRejectedValueOnce(new Error("PyPI blocked"));
loadPyodide.mockResolvedValue(pyodide);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => wheels }));

expectDiagnostics(await emit(), []);
expect(loadPyodide).toHaveBeenCalledOnce();
expect(pyodide.pyimport().install.callKwargs).toHaveBeenLastCalledWith(
expect.arrayContaining([`${assetsUrl}/pygen-0.1.0-py3-none-any.whl`]),
{ deps: false },
);
});

it.each([
{ ok: false, status: 404 },
{ ok: true, json: async () => ["../invalid.whl"] },
{ ok: true, json: async () => [wheels[1]] },
])("reports invalid or missing fallback wheels", async (response) => {
const pyodide = runtime();
loadPyodide.mockRejectedValueOnce(new Error("CDN blocked")).mockResolvedValueOnce(pyodide);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));

expectDiagnostics(await emit(), {
code: "@typespec/http-client-python/unknown-error",
message: /CDN blocked.*(?:404|Invalid browser Python wheel manifest)/s,
});
expect(pyodide.pyimport().install).not.toHaveBeenCalled();
expect(pyodide.pyimport().install.callKwargs).not.toHaveBeenCalled();
});

it("reports both failures when the public and hosted runtimes cannot load", async () => {
loadPyodide
.mockRejectedValueOnce(new Error("CDN blocked"))
.mockRejectedValueOnce(new Error("Hosted runtime unavailable"));

strictEqual(loadPyodide.mock.calls.length, 0);
expectDiagnostics(await emit(), {
code: "@typespec/http-client-python/unknown-error",
message: /CDN blocked.*Hosted runtime unavailable/s,
});
});
});
1 change: 1 addition & 0 deletions packages/http-client-python/eng/pipeline/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ extends:
EnableCadlRanchReport: false
PythonVersion: "3.11"
UploadPlaygroundBundle: true
PlaygroundBundleBuildScript: build:playground
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { rm } from "node:fs/promises";
import { runPython3 } from "./run-python3.js";

try {
await runPython3("./eng/scripts/setup/build_playground_assets.py");
} finally {
await rm("./venv_build_wheel", { recursive: true, force: true });
}
Loading
Loading