Python Plugins
bext can run Python plugins by loading the Pyodide CPython interpreter as a wasm module inside the existing bext-plugin-wasm runtime. That gives you the full CPython language, a meaningful slice of the standard library, and access to any pure-Python package — without bolting a second runtime onto the process.
This page is the shipping blueprint for Python on bext. The sub-mode is a skeleton today: the architectural seam is in place, the reference plugin crate exists, and calling the loader with a Python config returns a typed "not yet implemented" error. Full execution is blocked on the perf plan's P2 AOT snapshot cache — see Current state below for the why.
Where Python fits
bext ships several plugin runtimes. Each one is aimed at a different shape of work.
| Runtime | Best for | Cold start | Why |
|---|---|---|---|
| WASM (native) | Perf-critical compiled code (Rust, C/C++, AssemblyScript) | ~1 ms (AOT cached) | Fuel-metered, sandboxed, smallest overhead |
| V8 | JavaScript middleware on the hot path | ~15 ms (warm pool: 0) | Same engine as SSR, warm isolate pool |
| QuickJS | Tiny JS plugins, deterministic semantics | < 5 ms | Interpreter, minimal RAM |
| Lua | Scripting, config-driven decisions | < 5 ms | mlua, tiny footprint, great embedding story |
| Pyodide (Python) | Batch jobs, data pipelines, report generation | 1–3 s without AOT / low-ms with snapshots | Full CPython language + stdlib + pure-Python ecosystem |
Pyodide is not a request-path runtime. Even with aggressive tuning it is an order of magnitude heavier than V8 or QuickJS. Use it where Python's ecosystem wins — a scheduled nightly transform, a weekly report, a one-shot data cleanup — and reach for V8, QuickJS, Lua, or native wasm when latency matters.
Architecture
A Python plugin is not a raw wasm module that bext loads directly. It is a Python program that runs inside a shared Pyodide wasm interpreter, which in turn is loaded by the same bext-plugin-wasm runtime that already ships native wasm plugins. The plugin manifest distinguishes the two modes via the WasmGuestKind enum:
pub enum WasmGuestKind {
/// The historical default — wasm compiled from Rust/C++/AssemblyScript.
Native,
/// Python source executed inside a bundled Pyodide wasm blob.
Pyodide {
python_src: String,
pyodide_version: String,
},
}
The call graph for a request that reaches a Python plugin looks like this:
bext host (Rust)
│
├── bext-plugin-wasm runtime (wasmtime)
│ │
│ ├── Pyodide wasm blob (CPython compiled to wasm)
│ │ │
│ │ └── Your transform.py
│ │
│ └── Host function bridge (via Pyodide JS proxy layer)
│
└── Host capabilities (KV, queue, HTTP fetch, tracer, …)
Two things are worth calling out:
1. Pyodide is loaded once per plugin, not per request. The Python interpreter starts, parses your script, and lives inside a wasmtime store. Subsequent invocations reuse the store, so the cold-start cost is amortised across every call that hits that instance. 2. Host function calls cross three boundaries. Python → Pyodide C API → Pyodide's JS shim layer → wasmtime host function → bext. The extra hop is measurable (~tens of microseconds per call) but stable. The takeaway for authors: prefer coarse-grained host calls over chatty ones.
Current state
The Pyodide sub-mode is a skeleton. Concretely:
- WasmGuestKind::Pyodide { .. } exists in bext-plugin-wasm::runtime and is a visible, typed configuration option.
- WasmPluginRuntime::load_plugin dispatches on the variant. For Native, the historical path runs unchanged. For Pyodide, the runtime returns a typed error that mentions this page and the milestones plan.
- A reference Python plugin crate, bext-transform-py, lives in the workspace with the Python source and a Rust stub. Its one unit test asserts that constructing the plugin today returns a clean NotYetImplemented error rather than hanging or panicking.
- Full end-to-end execution is not implemented.
Why the wait
Pyodide cold start is 1–3 seconds for a minimal interpreter with no user wheels. That is acceptable for a scheduled job that runs hourly and nothing more, but it makes Pyodide actively hostile for any workload that might end up on the request path. The perf plan's P2 AOT snapshot cache (plan/performance/03-request-path.md) is the missing piece: per-plugin snapshots persisted to disk move the cost from "reinitialize the interpreter" to "mmap a few megabytes," which lands per-invocation overhead in the low-ms range.
We are shipping the skeleton now, before the AOT cache, on purpose:
- The seam is decoupled from the implementation. A future change can add the Pyodide loader without touching the outer runtime, the bext-server caller, or the manifest schema.
- The reference plugin crate gives authors a concrete type to import. When the runtime lands, activating existing Python plugins is a one-line bump.
- The error shape is final. Downstream code can match on Err from load_plugin today with no knowledge of whether the backing runtime is stubbed or real.
See plan/ecosystem/08-milestones.md §E4 for the full rollout plan.
When it's done — usage shape
This is how a Python plugin will look once the sub-mode is wired up. The shapes are final to the best of our knowledge today; the scheduling and invocation surface is described here so that authors can plan before the loader lands.
The Python side
Your plugin is a Python module that exposes one or more entry points:
def transform(input_json):
user_id = input_json.get("user_id")
email = (input_json.get("email") or "").strip().lower()
amount_cents = int(input_json.get("amount_cents", 0))
currency = input_json.get("currency", "USD").upper()
return {
"user_id": user_id,
"email": email,
"amount": {"cents": amount_cents, "currency": currency},
"is_high_value": amount_cents >= 100_000,
}
Per-record work looks like regular Python. Nothing about the wasm host leaks into the language — you read a dict, return a dict, and raise exceptions on error. The host serialises JSON at the boundary.
The Rust side
Loading the plugin looks like any other WasmPluginConfig, with the guest_kind field set to Pyodide:
use bext_plugin_wasm::runtime::{WasmGuestKind, WasmPluginConfig, WasmPluginRuntime};
let mut rt = WasmPluginRuntime::new(storage_root)?;
rt.load_plugin(WasmPluginConfig {
name: "nightly-transform".into(),
path: pyodide_wasm_blob_path(), // shared across all Python plugins
priority: 1000,
permissions,
config: serde_json::Value::Null,
guest_kind: WasmGuestKind::Pyodide {
python_src: bext_transform_py::PYTHON_SOURCE.into(),
pyodide_version: bext_transform_py::PYODIDE_VERSION.into(),
},
})?;
Pinning pyodide_version is mandatory. Different Pyodide builds ship different stdlib subsets and different precompiled wheels; an upgrade should be a visible config change, never a silent behaviour shift.
The scheduling shape
Because Python plugins are batch-oriented, the most natural wiring is through the Scheduled capability (@bext/cron) — register a cron entry that dispatches into the Python transform on every tick. The scheduled-job path is synchronous from bext's perspective: the scheduler hands a work item to the wasm runtime, the runtime calls into Pyodide, Pyodide runs transform(), the result bubbles back out. For long-running work, split it into chunks that each fit inside the per-call fuel budget.
Hot-path use is possible — nothing in the runtime forbids it — but only meaningful once AOT snapshots are in place. Until then, any Python plugin that handles a request will be the slowest thing in the request lifecycle by several orders of magnitude.
Known constraints
These are baked into the architecture and will still be true after the sub-mode is fully implemented. See crates/bext-plugin-wasm/KNOWN_DIFFERENCES.md for the authoritative list.
- No CPython native extensions by default. Pure-Python packages work. Anything that depends on a compiled C extension needs a Pyodide wheel or an Emscripten-built port. Most of the scientific Python stack has a Pyodide wheel; arbitrary wheels from PyPI do not.
- asyncio runs on Pyodide's internal scheduler. bext sees one synchronous Python execution per guest invocation; Python code that leans on await must complete within the fuel window.
- Host function calls go through the Pyodide JS proxy layer. Measurable per-call overhead. Coarse-grained calls are cheaper than many small ones.
- No subprocesses, no signal handlers. The Pyodide environment cannot spawn processes or install signal handlers. Shell out through a host function instead, or don't.
- Bundle size matters. A bare Pyodide blob is ~10 MB; adding scientific wheels pushes this into the hundreds of megabytes. Snapshots trade disk for cold-start latency. The registry UI (E10) will surface both numbers at install time.
- One Python execution per guest. The GIL is Pyodide-internal; bext runs one hook at a time per store. Plugins that want parallelism run multiple stores.
Comparison — when to reach for which runtime
A working heuristic for picking a runtime on bext:
- Rust/C++/AssemblyScript → WASM native. You want the fastest possible guest code, you're comfortable with a compiled toolchain, and the plugin sits on the request path.
- JavaScript → V8 or QuickJS. V8 for throughput-sensitive middleware (WAF, JSON-schema validation, token decoding), QuickJS for tiny utilities and deterministic semantics.
- Configurable scripting → Lua. Small rules, allow/deny decisions, user-editable rate-limiters. Tiny footprint, great embedding story.
- Batch data work → Python (Pyodide). Scheduled transforms, nightly reports, data cleanups, anything where "I already have a Python script that does this" is true. Not the request path, at least not until AOT snapshots land.
The runtime you pick at plugin-authoring time is not a permanent decision — bext's plugin manifest makes every runtime a peer, so a Python prototype can be ported to native wasm later if it outgrows the batch model.
See also
- crates/bext-plugin-wasm/KNOWN_DIFFERENCES.md — full list of Pyodide-specific caveats
- plan/ecosystem/08-milestones.md §E4 — Lua + Pyodide milestone plan
- plan/performance/03-request-path.md P2 — AOT snapshot cache
- WASM Plugins — the native sub-mode this builds on
- V8 Plugins — the hot-path JavaScript alternative