@unlocalhosted/browsergrad-runtime
v0.1.2
Published
Pyodide-in-Worker runtime for browser-based Python execution. Same-origin assets, AbortSignal cancellation, structured assertion/artifact protocol. Platform-agnostic primitives — bring your own UI.
Downloads
25
Maintainers
Readme
@unlocalhosted/browsergrad-runtime
Pyodide-in-Worker runtime for browser-based Python execution. Same-origin assets, AbortSignal cancellation, cooperative cancel via SharedArrayBuffer, structured assertion + artifact protocols. Platform-agnostic primitives — bring your own UI.
Status: v0.1.1. Stable API in
src/types.ts— adding optional fields is non-breaking; everything else triggers a major bump. Adds the lab manifest validator + semver gate (see below).
Install
npm install @unlocalhosted/browsergrad-runtime pyodidePyodide is a peerDependency — you install it directly so you control the version and asset-sync story.
Pyodide assets (same-origin)
This library does not load Pyodide from a CDN. You point it at a same-origin URL that serves the runtime assets. The simplest pattern is a sync script in your build:
// scripts/sync-pyodide.mjs
import { copyFile, mkdir, readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const src = join(__dirname, "..", "node_modules", "pyodide");
const pkg = JSON.parse(await readFile(join(src, "package.json"), "utf8"));
const dest = join(__dirname, "..", "public", "pyodide", `v${pkg.version}`);
await mkdir(dest, { recursive: true });
for (const f of ["pyodide.mjs", "pyodide.asm.js", "pyodide.asm.wasm",
"python_stdlib.zip", "pyodide-lock.json"]) {
await copyFile(join(src, f), join(dest, f));
}Run at install time:
{ "scripts": { "postinstall": "node scripts/sync-pyodide.mjs" } }Usage
import { createSession } from "@unlocalhosted/browsergrad-runtime";
const session = await createSession({
pyodideIndexURL: "/pyodide/v0.26.4/",
packages: ["numpy"],
onPackageProgress: (e) => console.log(e.package, e.status),
});
const result = await session.exec({
code: `
import numpy as np
import browsergrad as bg
x = np.arange(10)
actual = int(x.sum())
expected = 45
if actual == expected:
bg.assert_pass("sum_of_0_to_9")
else:
bg.assert_fail("sum_of_0_to_9", "wrong sum",
expected=expected, actual=actual)
bg.emit_json("series", {"x": x.tolist()})
print("done")
`,
timeoutMs: 5000,
signal: someAbortController.signal,
onStdout: (chunk) => console.log("[py]", chunk),
onAssertion: (a) => console.log("[assert]", a),
onArtifact: (a) => console.log("[artifact]", a),
});
console.log(result.ok, result.durationMs);
console.log(result.assertions); // structured Assertion[] in arrival order
console.log(result.artifacts); // structured Artifact[] in arrival order
await session.dispose();A full runnable example is in examples/vanilla/.
Cancellation
Three ways to cancel an exec, from most cooperative to most blunt:
session.interrupt()— explicit call. Writes SIGINT to the SharedArrayBuffer-backed interrupt buffer. Python raisesKeyboardInterrupton the next bytecode boundary. RequirescrossOriginIsolatedpage.ExecOptions.signal: AbortSignal— standard abort. First tries the interrupt buffer, falls back toworker.terminate()after a 500 ms grace period.ExecOptions.timeoutMs: number— same flow assignal, just wall-clock-driven.
session.canInterrupt exposes whether cooperative cancel is available in the current document. Browsers without cross-origin isolation (no COOP: same-origin + COEP: require-corp) get terminate-only mode automatically — no error thrown.
The browsergrad Python module
Registered into the Pyodide runtime at session boot, so user code can import browsergrad as bg with zero setup. The helpers emit structured events that surface in ExecResult.assertions / ExecResult.artifacts and via the onAssertion / onArtifact callbacks.
import browsergrad as bg
bg.assert_pass(name, duration_ms=None)
bg.assert_fail(name, message, expected=None, actual=None, duration_ms=None)
bg.assert_error(name, message, exc=None, duration_ms=None)
bg.log(name, data, level="info") # → ArtifactLog
bg.emit_json(name, data) # → ArtifactJson (data must be JSON-able)
bg.emit_image(name, mime, data_base64) # → ArtifactImage
oracle = bg.oracle("_bg_tokenizers") # profile-registered JS oracle module
model = oracle.train_byte_bpe(corpus, 300)
ctx = bg.assignment_context() # id/root/fixtures/tests/gates
for gate in ctx["behavioral_gates"]:
...The library does not interpret assertion/artifact events — it just relays them.
bg.oracle(name) imports a JS module registered through
createSession({ jsModules }) and raises a clear error if the profile/runtime
forgot to register it. bg.assignment_context() parses the launcher-provided
assignment environment into a plain dict. Build your test framework /
visualizer / grader on top.
Lab manifest
Optional helper for platforms that ship versioned "labs" (or any other unit of executable Python content) and want a contract surface for pinning runtime versions. Hand-written validator + semver gate — no ajv dependency.
import {
parseManifest,
assertCompatibleRuntime,
LabRuntimeMismatch,
} from "@unlocalhosted/browsergrad-runtime";
const manifestJson = JSON.parse(await fetch("/labs/single-neuron/manifest.json").then((r) => r.text()));
const result = parseManifest(manifestJson);
if (!result.ok) {
throw new Error(`Invalid manifest: ${result.errors.join("; ")}`);
}
try {
assertCompatibleRuntime(result.manifest, "0.8.0");
} catch (e) {
if (e instanceof LabRuntimeMismatch) {
// Show "lab requires <pin>, runtime is <version>" with no fallback
}
}The schema is intentionally small (8 fields):
| Field | Purpose |
|---|---|
| id, version | Lab identity (kebab-case + semver) |
| requires_browsergrad | Semver range — ^0.8.0, ~0.8.1, or exact |
| required_ops | UOps the lab uses (≤ 64; informational for the dispatcher) |
| rubric_path, starter_path, reference_path | Where the lab's Python files live |
| datasets | Optional safetensors URLs (≤ 32) |
Hard-fail on mismatch is the v0 contract — no legacy CDN, no GH-Action mirror. When 5+ labs exist and an actual coexistence problem appears, we'll revisit.
Assignment profile preflight
Platforms can validate assignment profiles and decide whether a lab is runnable before launching a Worker:
import {
assignmentRubricKind,
assignmentRunReadiness,
assignmentRunnerRoute,
createAssignmentRequirementResolutionEnvironment,
createAssignmentCapabilityCatalog,
createAssignmentBenchmarkPreflightMatrix,
createAssignmentPlatformHandoff,
createAssignmentPlatformIssueDraft,
createVerifiedAssignmentPlatformHandoff,
createVerifiedAssignmentBenchmarkPreflightMatrix,
createAssignmentDatasetCachePlan,
createAssignmentExternalRunnerRequest,
createAssignmentMountPlan,
createAssignmentPreflightReport,
createAssignmentRunPlan,
evaluateAssignmentRequirementResolutions,
evaluateAssignmentMountContents,
parseAssignmentProfile,
requiredAssignmentCapabilities,
runAssignmentJavascriptProfile,
runAssignmentJavascriptRubric,
runAssignmentRubric,
runVerifiedAssignmentJavascriptProfile,
} from "@unlocalhosted/browsergrad-runtime";
const parsed = parseAssignmentProfile(profileJson);
if (!parsed.ok) throw new Error(parsed.errors.join("; "));
const catalog = createAssignmentCapabilityCatalog([parsed.profile]);
const requirementEnvironment =
createAssignmentRequirementResolutionEnvironment({
environmentId: "browser.local",
providers: [
{ requirementId: "pyodide", providerId: "worker.pyodide", mode: "browser" },
{ requirementId: "torch-compat", providerId: "grad.compat", mode: "browser" },
{ requirementId: "webgpu", providerId: "navigator.gpu", mode: "browser" },
{ requirementId: "wgsl-kernel", providerId: "kernels.wgsl", mode: "browser" },
{ requirementId: "worker-mesh", providerId: "worker.mesh", mode: "simulated" },
{ requirementId: "distributed-simulator", providerId: "distributed.simulator", mode: "simulated" },
{ requirementId: "native-cuda-external", providerId: "runner.native", mode: "external" },
],
});
const required = requiredAssignmentCapabilities(parsed.profile);
const preflight = evaluateAssignmentRequirementResolutions(
parsed.profile,
requirementEnvironment,
);
const plan = createAssignmentRunPlan(parsed.profile, requirementEnvironment);
const report = createAssignmentPreflightReport(
parsed.profile,
requirementEnvironment,
);
const handoff = createAssignmentPlatformHandoff(parsed.profile, report, {
files: { [report.plan.files.rubricPath]: rubricSource },
});
const verifiedHandoff = await createVerifiedAssignmentPlatformHandoff(
parsed.profile,
report,
{
files: { [report.plan.files.rubricPath]: rubricSource },
datasets: { tiny: tinyFixtureText },
},
);
const issueDraft = createAssignmentPlatformIssueDraft(
parsed.profile,
verifiedHandoff,
);
const matrix = createAssignmentBenchmarkPreflightMatrix(
[parsed.profile],
requirementEnvironment,
{ files: { [report.plan.files.rubricPath]: rubricSource } },
);
const verifiedMatrix = await createVerifiedAssignmentBenchmarkPreflightMatrix(
[parsed.profile],
requirementEnvironment,
{
files: { [report.plan.files.rubricPath]: rubricSource },
datasets: { tiny: tinyFixtureText },
},
);
const route = assignmentRunnerRoute(plan);
if (!plan.ok) {
throw new Error(`Missing capabilities: ${plan.capabilityEvaluation.missingCapabilities.join(", ")}`);
}
if (route.target === "external") {
const externalRequest = createAssignmentExternalRunnerRequest(plan);
// Send externalRequest to your native/hosted runner queue.
}
const mounts = createAssignmentMountPlan(plan);
const datasetCache = createAssignmentDatasetCachePlan(mounts);
const rubricKind = assignmentRubricKind(plan);
const readiness = assignmentRunReadiness(plan);
console.log(required, preflight.ok, preflight.missingCapabilities);
console.log(catalog.capabilities.map((entry) => [entry.capability, entry.profiles]));
console.log(plan.session.packages, plan.files.rubricPath, plan.execution.allowedTests);
console.log(readiness.status, rubricKind, mounts.files, mounts.datasets);
console.log(route.target, report.runnerRoute.target, report.mountPlan.files, datasetCache.datasets);
console.log(handoff.nextAction, handoff.launchable, handoff.messages);
console.log(verifiedHandoff.nextAction, verifiedHandoff.hashOk, verifiedHandoff.hashChecks);
console.log(issueDraft.title, issueDraft.labels);
console.log(matrix.rows[0]?.readinessStatus, matrix.rows[0]?.contentOk);
console.log(matrix.rows[0]?.gates.map((gate) => [gate.name, gate.status, gate.selectedCapabilities]));
console.log(verifiedMatrix.rows[0]?.hashOk, verifiedMatrix.rows[0]?.hashChecks);
const fileContents: Record<string, string | Uint8Array> = {
[plan.files.rubricPath]: rubricSource,
};
if (plan.files.starterPath) fileContents[plan.files.starterPath] = starterSource;
const contentReadiness = evaluateAssignmentMountContents(mounts, {
files: fileContents,
datasets: { tiny: tinyFixtureText },
});
if (!contentReadiness.ok) {
throw new Error(`Missing mount contents: ${[
...contentReadiness.missingRequiredFiles,
...contentReadiness.missingDatasets,
].join(", ")}`);
}
const run = await runAssignmentRubric(session, plan, {
files: fileContents,
datasets: { tiny: tinyFixtureText },
});
console.log(run.mount.writtenPaths, run.exec.assertions);
const jsRun = await runVerifiedAssignmentJavascriptProfile(
parsed.profile,
requirementEnvironment,
{
files: { [report.plan.files.rubricPath]: rubricSource },
datasets: { tiny: tinyFixtureText },
},
importedRubric,
{ oracles: { _bg_cpu_parallelism: cs149Oracle } },
);
console.log(jsRun.report.runnerRoute.target, jsRun.run.assertions);Capability gates support requires for all-of requirements and any_of for
alternative groups. Non-capability gates such as streaming and timeout remain
rubric/watchdog checks.
Use createAssignmentRequirementResolutionEnvironment() for new platform
readiness work. It resolves every generated definition exactly once and marks a
requirement available only when the caller supplies one named provider, mode,
and optional evidence IDs. Definition or method presence alone never grants
support. Run-plan, preflight, handoff, benchmark-matrix, external-runner, and
JavaScript profile-runner paths accept this environment directly and retain
the relevant provider/evidence records in their platform-facing outputs.
createAssignmentCapabilityEnvironment() remains the compatibility constructor
for existing callers. It de-duplicates and sorts browser, simulated, and
external labels, then attaches capabilityModes; if a label appears in
multiple groups, direct browser support wins over simulated and external modes.
assignmentRunReadiness(plan) turns the selected capabilities into a platform
status: runnable, simulated, external-only, or blocked. When multiple
any_of alternatives are available, BrowserGrad selects the strongest path by
mode: browser, then simulated, then external.
assignmentRunnerRoute(plan) maps preflight output to a runner target:
pyodide, javascript, external, unsupported, or blocked. Use this for
platform branching before calling runAssignmentRubric() or
runAssignmentJavascriptRubric().
Use createAssignmentCapabilityCatalog(profiles) when planning a curriculum or
dashboard across many profiles. It returns one sorted entry per capability with
the profiles/gates that require it and the gates where it appears as an
alternative, so platform teams can prioritize generic substrates instead of
scraping profile JSON.
Program capability support
Assignment requirement availability and program support are separate facts.
After a frontend or scheduler evaluates one concrete program or semantic
artifact, use createProgramCapabilitySupportView() to retain its lowering
decisions:
import {
createProgramCapabilitySupportView,
semanticBackendDefinitions,
semanticCapabilityDefinitions,
} from "@unlocalhosted/browsergrad-runtime";
const support = createProgramCapabilitySupportView({
viewId: "browsergrad.support.transpose",
subject: {
kind: "program",
programId: "browsergrad.program.transpose",
},
decisions: [
{
capabilityId: "browsergrad.layout.index-map",
backendId: "browsergrad.compiler.semantic-reference",
executionTier: "semantic-reference",
state: "supported",
preservationLevel: "observable-equivalent",
},
{
capabilityId: "browsergrad.layout.index-map",
backendId: "browsergrad.kernels.webgpu",
executionTier: "webgpu-core",
state: "conditional",
preservationLevel: "observable-equivalent",
requiredFeatures: ["webgpu"],
runtimeGuardIds: ["browsergrad.guard.device-features"],
},
],
});
console.log(
semanticCapabilityDefinitions(),
semanticBackendDefinitions(),
support.decisions,
);Definitions are generated byte-for-byte from the architecture vocabulary and contain no support state. A view must contain at least one actual decision for its exact program ID or artifact hash. Positive decisions name a registered preservation level; conditional decisions retain their guards; refusals and unknown decisions require a reason and cannot claim preservation. Execution evidence remains a separate terminal record.
Framework platform support
Use createFrameworkPlatformSupportView() when a platform needs one immutable
payload without flattening requirement, program, and framework facts:
import {
createAssignmentRequirementResolutionEnvironment,
createFrameworkPlatformSupportView,
} from "@unlocalhosted/browsergrad-runtime";
import {
frameworkPlatformSupportSource as jitFrameworkPlatformSupportSource,
} from "@unlocalhosted/browsergrad-jit";
import {
frameworkPlatformSupportSource as gradFrameworkPlatformSupportSource,
} from "@unlocalhosted/browsergrad-grad";
const requirements = createAssignmentRequirementResolutionEnvironment({
environmentId: "browser.local",
providers: [
{
requirementId: "webgpu",
providerId: "navigator.gpu",
mode: "browser",
},
],
});
const view = createFrameworkPlatformSupportView({
viewId: "browsergrad.support.platform.transpose",
requirements,
program: {
viewId: "browsergrad.support.program.transpose",
subject: {
kind: "program",
programId: "browsergrad.program.transpose",
},
decisions: [
{
capabilityId: "browsergrad.layout.index-map",
backendId: "browsergrad.kernels.webgpu",
executionTier: "webgpu-core",
state: "conditional",
preservationLevel: "observable-equivalent",
requiredFeatures: ["webgpu"],
runtimeGuardIds: ["browsergrad.guard.device-features"],
},
],
},
frameworks: [
gradFrameworkPlatformSupportSource(),
jitFrameworkPlatformSupportSource(),
],
});The view keeps the complete provider-bound environment, the exact
program/artifact subject and lowering decisions, and each framework's
operation contracts in separate fields. It validates the ten decision
categories, bounds source and operation counts, rejects duplicates/open
records, and never converts contract strings into a generic supported
boolean. Runtime does not import or depend on JIT; any framework can provide
the same structural source. The packed integration gate currently proves the
generated 22-record Grad and 36-record JIT sources together.
Use runAssignmentJavascriptProfile() when the platform has a full JS-routed
profile and wants BrowserGrad to own preflight, route validation, mount
collection, declared-oracle preflight, oracle/substrate wiring, and rubric
execution in one call. The runner rejects missing profile-declared JS oracles
before invoking the rubric, so broken platform wiring cannot silently pass.
Use runVerifiedAssignmentJavascriptProfile() for JS-routed labs with declared
dataset hashes. It runs the same profile path only after verified handoff says
nextAction: "run-javascript", so stale fixtures block before rubric code runs.
For external routes, createAssignmentExternalRunnerRequest(plan) returns the
native/hosted runner handoff: selected external capabilities, resolved files,
timeouts, behavioral gates, a BROWSERGRAD_* environment map, mount plan, and
dataset cache plan. Preflight reports include this as externalRunnerRequest
when applicable. BrowserGrad does not execute native code; the platform owns
that runner.
createAssignmentPreflightReport(profile, environment) bundles the common
platform preflight calls into { plan, rubricKind, readiness, runnerRoute,
requiredCapabilities, mountPlan, datasetCachePlan }. Use it when the UI needs
a single readonly object before fetching fixtures, mounting files, or launching
code.
createAssignmentPlatformHandoff(profile, report, contents?) turns that report
plus currently available file/dataset contents into the exact launch-panel
decision: install-capabilities, mount-content, run-pyodide,
verify-content, run-javascript, request-external-runner, or unsupported.
It also carries missing files/datasets, selected capabilities, cache
strategies, and concise messages so platform UI does not re-derive BrowserGrad
decisions.
Use createVerifiedAssignmentPlatformHandoff(profile, report, contents?) for
launch buttons after fixture contents are available; it adds hashOk and
hashChecks, and returns nextAction: "verify-content" when declared dataset
hashes fail.
Use createAssignmentPlatformIssueDraft(profile, handoff) to turn a launch
handoff into a deterministic issue title/body/labels payload for downstream
platform queues such as Crafting Attention. BrowserGrad only drafts content; it
does not call a tracker API. If the handoff is verified, the draft includes a
hash-check section so stale fixtures are visible in the tracker.
createAssignmentBenchmarkPreflightMatrix(profiles, environment, contents?)
batch-flattens those same preflight decisions into platform-ready rows:
readiness status, runner target, rubric kind, required/selected/missing
capabilities, mount-content gaps, dataset cache strategies, and whether an
external runner handoff is required. Each row also contains gates, a
capability-gate table with status, selectedAnyOf, selectedCapabilities,
missingRequired, missingAnyOf, and optional author message. Use it for
benchmark dashboards, PRD handoffs, and platform smoke tests that cover many
course profiles at once without reimplementing BrowserGrad's route selection.
Use createVerifiedAssignmentBenchmarkPreflightMatrix(profiles, environment,
contents) after file/dataset contents are fetched to run the same matrix plus
dataset SHA checks. Its rows add hashOk and hashChecks, and row ok stays
false until capability, content, and declared dataset hashes all pass.
createAssignmentRunPlan() does not execute student code. It produces the
platform handoff object: package preload list, JS oracle modules, resolved
profile file paths, allowed test ids, timeout hints, dataset declarations,
capability preflight, and behavioral gates.
createAssignmentMountPlan() turns a run plan into deterministic file and
dataset mount declarations. It does not fetch or write content; the platform
uses it to decide what to place into Session.fs before rubric execution.
createAssignmentDatasetCachePlan(mountPlan) turns dataset mount declarations
into deterministic cache metadata for platform fetch/OPFS layers. Valid
sha256:<64 hex> hashes become content-addressed cache entries; missing hashes
use source-addressed URL keys; malformed/unsupported hashes are marked
invalid-hash or unsupported-hash so preflight can block before trust.
evaluateAssignmentMountContents(mountPlan, contents) dry-runs that mount plan
against platform-provided file/dataset contents and returns missing required
files, missing datasets, optional skips, and writable paths without touching
Session.fs.
verifyAssignmentMountContentHashes(mountPlan, contents) hashes dataset contents
that declare sha256:<64 hex> and returns per-dataset match, mismatch,
missing, invalid, or unsupported checks before write.
createAssignmentMountPreflightReport(mountPlan, contents) combines content
readiness and hash checks into one { ok, content, hashes } object for platform
preflight panels.
Mount contents may be UTF-8 strings or Uint8Array bytes. Use bytes for
snapshots and small upstream fixtures such as .pt, .npz, or
.safetensors. Hosts can verify mounted or cached bytes with
Session.fs.readBytes(path) without decoding through UTF-8.
materializeAssignmentMountPlan() writes provided string contents into
Session.fs in mount-plan order, preserving byte contents for binary fixtures.
Required rubric content fails loudly when missing; optional starter/reference
files are skipped when absent. Dataset contents are keyed by dataset name.
createAssignmentRubricExecRequest() turns that plan into the minimal
Session.exec request for rubric execution. It sets assignment metadata in
environment variables, puts the assignment root on sys.path, runs the resolved
rubric path, and uses the profile's effective execution timeout. It refuses
plans whose capability preflight failed so unavailable labs do not accidentally
launch.
Profile parsing validates option shapes for streaming, forbidden-read, and
timeout behavioral gates before they reach rubrics.
When both test_ms and worker_ms are declared, the shorter value becomes the
exec watchdog; when only worker_ms is declared, it is used directly.
Rubrics can read:
BROWSERGRAD_ASSIGNMENT_IDBROWSERGRAD_ASSIGNMENT_ROOTBROWSERGRAD_FIXTURES_PATHwhen the profile declares oneBROWSERGRAD_ALLOWED_TESTS_JSONBROWSERGRAD_BEHAVIORAL_GATES_JSON
Prefer browsergrad.assignment_context() inside Python rubrics unless you need
the raw environment values.
For browser-safe streaming checks, Python rubrics can wrap chunk iterables with
browsergrad.streaming_gate(name, iterable). The helper reads
max_chunks_before_first_yield from the active behavioral gate when omitted and
raises StreamingGateViolation if student code consumes too much input before
the first wrapped output yield:
gate = bg.streaming_gate("encode_iterable_streaming", chunks)
output = gate.wrap_output(student.encode_iterable(gate.input))
first_token = next(iter(output))For browser-safe file behavior, Python rubrics can wrap text with
browsergrad.forbidden_read_gate(name, text). The helper reads the forbidden
methods list from the matching forbidden-read gate when omitted, permits
incremental iteration/readline(), and raises ForbiddenReadViolation for
eager read() or readlines() calls:
file_obj = bg.forbidden_read_gate("no_eager_file_read", fixture_text)
student.process(file_obj)runAssignmentRubric() is the one-call Pyodide path for platforms that do not
need to stage each step manually. It derives the mount plan, materializes files
and datasets, builds the rubric exec request, calls session.exec(), and
returns { mount, exec }. Use the lower-level helpers when the UI needs a
preflight preview before writing files or launching code.
assignmentRubricKind(plan) returns python, javascript, or unknown from
the resolved rubric path. Use it to route JS/WebGPU/native-style labs away from
the Pyodide rubric runner.
runAssignmentJavascriptRubric() is the browser-native JS path. It validates
the active run plan, prepares an in-memory read-only mount view, exposes
assignment context, declared oracles, and assertion/artifact helpers to the
rubric function, then returns { mount, assertions, artifacts }. This is the
starting point for GPU Puzzles and CS149-style JS/WebGPU rubrics. Pass browser
resources such as WebGPU adapters/devices through substrates, then read them
inside the rubric with ctx.substrate("webgpu"). JS rubrics can read mounted
text with ctx.readText(path) or mounted bytes with ctx.readBytes(path).
Use runAssignmentJavascriptProfile() for full assignment profiles when the
profile's declared oracle modules should be checked before rubric execution.
For JS/TS streaming rubrics, pair this with createStreamingGate() from
@unlocalhosted/browsergrad-primitives and wrap student input/output iterables.
What this is, and is not
This is: a small, well-typed primitive for running Python in a browser worker. ~1,000 LOC. Boots Pyodide, mounts files, exec, stream stdout/stderr, structured assert/artifact protocols, cooperative + hard cancel, persistent namespace.
This is not:
- A notebook UI
- A test framework (you compose one from
onAssertion) - A grading harness (those live in your platform, not in the library)
- A PyTorch / WebGPU library — those ship as separate packages:
browsergrad-grad(eager autograd, stable),browsergrad-jit(lazy IR + fusion + WebGPU seam),browsergrad-kernels(WGSL primitives + the production realizer bridge)
API reference
See src/types.ts for the full annotated type surface. Stability contract:
- Adding optional fields → minor version bump
- Removing fields or making them required → major version bump
- Anything not exported from
src/index.tsis private
Why not just use Pyodide directly?
You can. Pyodide is excellent. This library packages the boilerplate that every consumer rewrites:
- Worker host + RPC envelope
- Lazy boot with progress callbacks
- AbortSignal-based cancellation (Pyodide alone has no clean cancel path)
- Cooperative cancel via
setInterruptBufferwith feature detection - Persistent namespace across
execcalls - Typed errors for syntax / runtime / timeout / abort / interrupt
- Structured assertion + artifact protocols, so visible-test UIs don't have to parse
print()output
If you're booting Pyodide three times across two apps, copy the patterns. If you're booting it many times, install this.
License
MIT
