diff --git a/.github/workflows/publish-python.yml b/.github/workflows/publish-python.yml index f9e944e8f..4aa03241e 100644 --- a/.github/workflows/publish-python.yml +++ b/.github/workflows/publish-python.yml @@ -111,10 +111,52 @@ jobs: name: pypi_files-${{ matrix.os }}-${{ matrix.target }}-${{ matrix.manylinux || 'manylinux' }} path: crates/bashkit-python/dist + # Reduced-feature Pyodide/Emscripten wheel (browser / JupyterLite). + # Separate job: distinct toolchain (nightly Rust + emsdk + pyodide-build) and a + # single Python version dictated by the pyodide-build ABI lockstep. + # See specs/emscripten-wheels.md. + build-emscripten: + name: Build wheel - emscripten (Pyodide) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + # Python 3.13 -> pyodide-build's modern config (Pyodide 0.29.x, Emscripten + # 4.0.9), whose binaryen understands modern LLVM's wasm target-features and + # whose runtime supports wasm exception handling. See specs/emscripten-wheels.md. + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + # nightly satisfies our deps' MSRV (monty needs 1.95) + edition 2024; + # its LLVM matches Emscripten 4.0.9's binaryen. pyodide-build manages its + # own matching emsdk via the cross-build env (no setup-emsdk needed). + - name: Install nightly Rust with the Emscripten target + uses: dtolnay/rust-toolchain@nightly + with: + targets: wasm32-unknown-emscripten + + - name: Install pyodide-build + run: pip install pyodide-build + + - name: Install pyodide cross-build environment + run: pyodide xbuildenv install + + - name: Build Pyodide wheel + working-directory: crates/bashkit-python + env: + RUSTUP_TOOLCHAIN: nightly + run: pyodide build --outdir dist + + - uses: actions/upload-artifact@v7 + with: + name: pypi_files-emscripten + path: crates/bashkit-python/dist + # Verify built artifacts inspect: name: Inspect artifacts - needs: [build, build-sdist] + needs: [build, build-sdist, build-emscripten] runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v8 diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 020c49906..fa4e69db6 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -181,11 +181,85 @@ jobs: path: crates/bashkit-python/dist retention-days: 5 + # Build the reduced-feature Pyodide/Emscripten wheel and smoke-test it in a + # Pyodide venv. See specs/emscripten-wheels.md for the feature matrix and the + # pyodide-build <-> Emscripten <-> Python <-> Rust version lockstep. + wasm: + name: Build wheel (Pyodide/Emscripten) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + # Python 3.13 selects pyodide-build's modern config (Pyodide 0.29.x, + # Emscripten 4.0.9), whose binaryen understands the wasm target-features + # modern LLVM emits and whose runtime supports wasm exception handling. + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + + # Pyodide passes `-Z link-native-libraries=no` (nightly-only). The nightly + # must satisfy our deps' MSRV (monty needs rustc 1.95) and edition 2024, + # so it has to be recent; its LLVM (19+) matches Emscripten 4.0.9's + # binaryen, which is what avoids the wasm-opt target-feature skew that + # older Emscripten (3.1.x) hit. See specs/emscripten-wheels.md. + - name: Install nightly Rust with the Emscripten target + uses: dtolnay/rust-toolchain@nightly + with: + targets: wasm32-unknown-emscripten + + - uses: Swatinem/rust-cache@v2 + + # pyodide-build manages its own matching emsdk via the cross-build env, so + # no separate setup-emsdk step is needed. + - name: Install pyodide-build + run: pip install pyodide-build + + - name: Install pyodide cross-build environment + run: pyodide xbuildenv install + + - name: Build Pyodide wheel + working-directory: crates/bashkit-python + env: + RUSTUP_TOOLCHAIN: nightly + run: pyodide build --outdir dist + + - name: Smoke test in a Pyodide venv + working-directory: crates/bashkit-python + # Run the import test from a scratch dir: the crate's own `bashkit/` + # source package would otherwise shadow the installed extension module. + run: | + pyodide venv .venv-pyodide + .venv-pyodide/bin/pip install dist/*.whl + venv_python="$(pwd)/.venv-pyodide/bin/python" + cd "$(mktemp -d)" + "$venv_python" -c " + from bashkit import Bash + b = Bash(python=True) + r = b.execute_sync('echo hello && echo 1 | jq .') + print(r.stdout) + assert r.exit_code == 0, r + assert r.stdout == 'hello\n1\n', r.stdout + # Reduced-feature build: unavailable config must fail loudly. + try: + Bash(sqlite=True) + except RuntimeError: + pass + else: + raise AssertionError('expected sqlite=True to raise on wasm') + print('wasm smoke test OK') + " + + - uses: actions/upload-artifact@v7 + with: + name: python-wheel-emscripten + path: crates/bashkit-python/dist + retention-days: 5 + # Gate job for branch protection python-check: name: Python Check if: always() - needs: [lint, test, examples, build-wheel] + needs: [lint, test, examples, build-wheel, wasm] runs-on: ubuntu-latest steps: - name: Verify all jobs passed @@ -193,7 +267,8 @@ jobs: if [[ "${{ needs.lint.result }}" != "success" ]] || \ [[ "${{ needs.test.result }}" != "success" ]] || \ [[ "${{ needs.examples.result }}" != "success" ]] || \ - [[ "${{ needs.build-wheel.result }}" != "success" ]]; then + [[ "${{ needs.build-wheel.result }}" != "success" ]] || \ + [[ "${{ needs.wasm.result }}" != "success" ]]; then echo "One or more Python CI jobs failed" exit 1 fi diff --git a/.gitignore b/.gitignore index 84f107485..6363ead63 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ node_modules/ # WASM build artifacts *.wasm + +# Pyodide / Emscripten cross-build environment (downloaded by `pyodide xbuildenv`) +.pyodide-xbuildenv/ diff --git a/AGENTS.md b/AGENTS.md index 932fa4b7e..8f7454c99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn | coreutils-args-port | Port uutils `uu_app()` clap definitions (args mode) and platform-clean uucore modules (module mode, manifest-driven) into bashkit via codegen | | credential-injection | Transparent per-host credential injection for outbound HTTP requests, without exposing secrets to sandboxed scripts | | performance-results | Benchmark/eval result locations and `/benches` site aggregation contract | +| emscripten-wheels | Reduced-feature Pyodide/Emscripten (`wasm32-unknown-emscripten`) Python wheel: feature gating, toolchain, CI/publish | ### Documentation diff --git a/crates/bashkit-python/Cargo.toml b/crates/bashkit-python/Cargo.toml index 1dc9c83f8..a009d4b03 100644 --- a/crates/bashkit-python/Cargo.toml +++ b/crates/bashkit-python/Cargo.toml @@ -16,19 +16,34 @@ name = "bashkit" doc = false # Python extension, no Rust docs needed [dependencies] -# Bashkit core -# realfs: always-on so Python callers can use mount_real_* APIs -bashkit = { path = "../bashkit", features = ["scripted_tool", "python", "realfs", "jq", "interop", "http_client", "sqlite"] } - # PyO3 native extension pyo3 = { workspace = true } -pyo3-async-runtimes = { workspace = true } - -# Async runtime -tokio = { workspace = true, features = ["rt-multi-thread"] } # Serialization serde_json = { workspace = true } # Big-integer support for py_to_monty BigInt extraction num-bigint = "^0.4.6" # must be compatible with the version pulled in by bashkit core + +# Native (non-wasm) targets: full feature set. +# realfs: always-on so Python callers can use mount_real_* APIs. +# http_client/sqlite/interop pull mio/tokio-net/rt-multi-thread/tokio::fs, +# none of which compile on wasm32-unknown-emscripten — see the wasm block +# below and specs/emscripten-wheels.md. +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +bashkit = { path = "../bashkit", features = ["scripted_tool", "python", "realfs", "jq", "interop", "http_client", "sqlite"] } +tokio = { workspace = true, features = ["rt-multi-thread"] } +# pyo3-async-runtimes hard-pulls tokio `rt-multi-thread` + `net` (mio), which +# `compile_error!`s on wasm. Native-only; the wasm build exposes `execute_sync` +# (current-thread runtime) and omits the async `execute()` coroutine bridge. +pyo3-async-runtimes = { workspace = true } + +# wasm32 (Pyodide/Emscripten) target: reduced feature set. +# Drops http_client, sqlite, realfs, interop — each requires threads, sockets, +# or host filesystem access that Pyodide does not provide. Keeps the in-VFS +# shell plus the embedded jq and Monty Python interpreters. tokio stays on its +# wasm-safe base features (sync, macros, io-util, rt, time) — current-thread +# runtime only. See specs/emscripten-wheels.md. +[target.'cfg(target_arch = "wasm32")'.dependencies] +bashkit = { path = "../bashkit", features = ["scripted_tool", "python", "jq"] } +tokio = { workspace = true } diff --git a/crates/bashkit-python/src/lib.rs b/crates/bashkit-python/src/lib.rs index fb4e2e48c..f43384c42 100644 --- a/crates/bashkit-python/src/lib.rs +++ b/crates/bashkit-python/src/lib.rs @@ -5,18 +5,32 @@ //! `help`, `system_prompt`, JSON schemas) on top of the core interpreter. //! Orchestration: `ScriptedTool` — composes Python callbacks as bash builtins. +// The WebAssembly (Pyodide/Emscripten) build is a reduced-feature variant: the +// async `execute()` family, network/credential config, host-FS mounts, the capsule +// interop bridge, sqlite, and external_handler are all native-only (they need +// threads, sockets, or host FS that Pyodide lacks — see specs/emscripten-wheels.md). +// That leaves a handful of imports and helper functions referenced only from the +// native-gated code paths; silence the resulting dead-code/unused-import lints on +// wasm rather than scattering per-item cfgs through shared conversion helpers. +#![cfg_attr(target_arch = "wasm32", allow(dead_code, unused_imports))] + +// interop (capsule FS handoff), realfs (host mounts) and the credential-injection +// network config require threads / host FS / sockets that wasm32-unknown-emscripten +// (Pyodide) does not provide — see specs/emscripten-wheels.md. They are native-only. +#[cfg(not(target_arch = "wasm32"))] use bashkit::interop::fs::{BashkitFsAbiOwnedHandleV1, export_filesystem, import_owned_filesystem}; use bashkit::tool::VERSION; use bashkit::{ - Bash, BashTool as RustBashTool, Builtin, BuiltinContext, BuiltinRegistry, Credential, + Bash, BashTool as RustBashTool, Builtin, BuiltinContext, BuiltinRegistry, DirEntry as FsDirEntry, ExcType, ExecResult as RustExecResult, ExecutionExtensions, ExecutionLimits, ExtFunctionResult, FileSystem, FileSystemExt, FileType as FsFileType, InMemoryFs, Metadata as FsMetadata, MontyException, MontyObject, NetworkAllowlist, OutputCallback as RustOutputCallback, OverlayFs, PosixFs, PythonExternalFnHandler, - PythonLimits, RealFs, RealFsMode, ScriptedTool as RustScriptedTool, - ShellStateView as RustShellStateView, SnapshotOptions as RustSnapshotOptions, Tool, ToolArgs, - ToolDef, ToolRequest, async_trait, + PythonLimits, ScriptedTool as RustScriptedTool, ShellStateView as RustShellStateView, + SnapshotOptions as RustSnapshotOptions, Tool, ToolArgs, ToolDef, ToolRequest, async_trait, }; +#[cfg(not(target_arch = "wasm32"))] +use bashkit::{Credential, RealFs, RealFsMode}; use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError}; use pyo3::prelude::*; use pyo3::sync::PyOnceLock; @@ -24,8 +38,23 @@ use pyo3::types::{ PyBytes, PyCapsule, PyCapsuleMethods, PyDict, PyFloat, PyFrozenSet, PyInt, PyList, PyModule, PySet, PyTuple, }; +// pyo3-async-runtimes bridges Rust futures to a Python asyncio loop; it hard-pulls +// multi-threaded tokio + mio sockets, which do not build on wasm. The async +// `execute()` family and caller-loop callback scheduling are therefore native-only; +// wasm exposes the blocking `execute_sync()` API. See specs/emscripten-wheels.md. +#[cfg(not(target_arch = "wasm32"))] use pyo3_async_runtimes::TaskLocals; +#[cfg(not(target_arch = "wasm32"))] use pyo3_async_runtimes::tokio::future_into_py; + +// `caller_loop_locals` is a captured asyncio-loop handle, only ever `Some` when an +// async `execute()` ran (native). On wasm there is no such loop, so the field is +// always `None`; aliasing the type to the uninhabited `Infallible` lets the shared +// session structs compile while making the caller-loop branches statically dead. +#[cfg(not(target_arch = "wasm32"))] +type CallerLoopLocals = TaskLocals; +#[cfg(target_arch = "wasm32")] +type CallerLoopLocals = std::convert::Infallible; use std::collections::HashMap; use std::future::Future; use std::path::{Path, PathBuf}; @@ -195,6 +224,9 @@ enum PyCredentialSpec { Headers { headers: Vec<(String, String)> }, } +// `Credential` is a core type gated behind `http_client`; the conversion is +// native-only (network injection is unavailable on wasm — see parse_network_config). +#[cfg(not(target_arch = "wasm32"))] impl PyCredentialSpec { fn into_credential(self) -> Credential { match self { @@ -206,6 +238,7 @@ impl PyCredentialSpec { } impl PyNetworkConfig { + #[cfg(not(target_arch = "wasm32"))] fn to_allowlist(&self) -> NetworkAllowlist { let base = if self.allow_all { NetworkAllowlist::allow_all() @@ -215,6 +248,11 @@ impl PyNetworkConfig { base.block_private_ips(self.block_private_ips) } + // `network()`, `credential()` and `credential_placeholder()` live behind the + // core `http_client` feature, which is off on wasm (no sockets in Pyodide). + // On wasm the whole network config is inert — `parse_network_config` rejects + // it up front (see that fn), so this is a native-only pass-through. + #[cfg(not(target_arch = "wasm32"))] fn apply(&self, mut builder: bashkit::BashBuilder) -> bashkit::BashBuilder { builder = builder.network(self.to_allowlist()); for inj in &self.credentials { @@ -237,74 +275,92 @@ impl PyNetworkConfig { /// must specify either `allow` (a list of URL patterns) or `allow_all=True`, /// not both. `block_private_ips` defaults to `True` to preserve the Rust /// default. Unknown keys raise `ValueError` so typos surface immediately. +// The Pyodide/Emscripten build has no outbound HTTP client (no sockets in the +// browser sandbox), so accepting a network allowlist would silently do nothing. +// Fail loudly instead. See specs/emscripten-wheels.md. +#[cfg(target_arch = "wasm32")] +fn parse_network_config(network: Option<&Bound<'_, PyDict>>) -> PyResult> { + if network.is_some() { + return Err(PyRuntimeError::new_err( + "network configuration is not available in the WebAssembly (Pyodide) build: \ + outbound HTTP is unsupported in this environment", + )); + } + Ok(None) +} + +#[cfg(not(target_arch = "wasm32"))] fn parse_network_config(network: Option<&Bound<'_, PyDict>>) -> PyResult> { let Some(dict) = network else { return Ok(None); }; - const KNOWN_KEYS: &[&str] = &[ - "allow", - "allow_all", - "block_private_ips", - "credentials", - "credential_placeholders", - ]; - for (key_obj, _) in dict.iter() { - let key: String = key_obj.extract()?; - if !KNOWN_KEYS.contains(&key.as_str()) { - return Err(PyValueError::new_err(format!( - "network: unknown key '{key}' (supported: allow, allow_all, block_private_ips, credentials, credential_placeholders)" - ))); + { + const KNOWN_KEYS: &[&str] = &[ + "allow", + "allow_all", + "block_private_ips", + "credentials", + "credential_placeholders", + ]; + for (key_obj, _) in dict.iter() { + let key: String = key_obj.extract()?; + if !KNOWN_KEYS.contains(&key.as_str()) { + return Err(PyValueError::new_err(format!( + "network: unknown key '{key}' (supported: allow, allow_all, block_private_ips, credentials, credential_placeholders)" + ))); + } } - } - let allow_all: bool = dict - .get_item("allow_all")? - .map(|v| v.extract()) - .transpose()? - .unwrap_or(false); - - let allow_obj = dict.get_item("allow")?; - if allow_all && allow_obj.is_some() { - return Err(PyValueError::new_err( - "network: 'allow' and 'allow_all' are mutually exclusive", - )); - } - if !allow_all && allow_obj.is_none() { - return Err(PyValueError::new_err( - "network: must provide 'allow' (list of URL patterns) or 'allow_all=True'", - )); - } + let allow_all: bool = dict + .get_item("allow_all")? + .map(|v| v.extract()) + .transpose()? + .unwrap_or(false); - let mut allow: Vec = Vec::new(); - if let Some(value) = allow_obj { - let list = value.cast::().map_err(|_| { - PyValueError::new_err("network['allow'] must be a list of URL pattern strings") - })?; - for item in list.iter() { - allow.push(item.extract()?); + let allow_obj = dict.get_item("allow")?; + if allow_all && allow_obj.is_some() { + return Err(PyValueError::new_err( + "network: 'allow' and 'allow_all' are mutually exclusive", + )); + } + if !allow_all && allow_obj.is_none() { + return Err(PyValueError::new_err( + "network: must provide 'allow' (list of URL patterns) or 'allow_all=True'", + )); } - } - - let block_private_ips: bool = dict - .get_item("block_private_ips")? - .map(|v| v.extract()) - .transpose()? - .unwrap_or(true); - let credentials = parse_credential_injections(dict.get_item("credentials")?.as_ref())?; - let credential_placeholders = - parse_credential_placeholders(dict.get_item("credential_placeholders")?.as_ref())?; + let mut allow: Vec = Vec::new(); + if let Some(value) = allow_obj { + let list = value.cast::().map_err(|_| { + PyValueError::new_err("network['allow'] must be a list of URL pattern strings") + })?; + for item in list.iter() { + allow.push(item.extract()?); + } + } - Ok(Some(PyNetworkConfig { - allow, - allow_all, - block_private_ips, - credentials, - credential_placeholders, - })) + let block_private_ips: bool = dict + .get_item("block_private_ips")? + .map(|v| v.extract()) + .transpose()? + .unwrap_or(true); + + let credentials = parse_credential_injections(dict.get_item("credentials")?.as_ref())?; + let credential_placeholders = + parse_credential_placeholders(dict.get_item("credential_placeholders")?.as_ref())?; + + Ok(Some(PyNetworkConfig { + allow, + allow_all, + block_private_ips, + credentials, + credential_placeholders, + })) + } } +#[cfg(not(target_arch = "wasm32"))] fn parse_credential_injections( value: Option<&Bound<'_, PyAny>>, ) -> PyResult> { @@ -329,6 +385,7 @@ fn parse_credential_injections( Ok(out) } +#[cfg(not(target_arch = "wasm32"))] fn parse_credential_placeholders( value: Option<&Bound<'_, PyAny>>, ) -> PyResult> { @@ -361,6 +418,7 @@ fn parse_credential_placeholders( Ok(out) } +#[cfg(not(target_arch = "wasm32"))] fn require_string(dict: &Bound<'_, PyDict>, key: &str, label: &str) -> PyResult { let value = dict.get_item(key)?.ok_or_else(|| { PyValueError::new_err(format!("network['{label}'] missing required '{key}' key")) @@ -370,6 +428,7 @@ fn require_string(dict: &Bound<'_, PyDict>, key: &str, label: &str) -> PyResult< .map_err(|_| PyValueError::new_err(format!("network['{label}']['{key}'] must be a string"))) } +#[cfg(not(target_arch = "wasm32"))] fn parse_credential_spec( dict: &Bound<'_, PyDict>, label: &str, @@ -436,6 +495,7 @@ fn parse_credential_spec( Ok(spec) } +#[cfg(not(target_arch = "wasm32"))] fn extract_string_pair(item: &Bound<'_, PyAny>) -> PyResult<(String, String)> { if let Ok(tup) = item.cast::() { if tup.len() != 2 { @@ -452,12 +512,14 @@ fn extract_string_pair(item: &Bound<'_, PyAny>) -> PyResult<(String, String)> { Err(PyValueError::new_err("expected a 2-element pair")) } +#[cfg(not(target_arch = "wasm32"))] fn build_allowed_keys(base: &[&str], extra: &[&str]) -> Vec { let mut all: Vec = base.iter().map(|s| s.to_string()).collect(); all.extend(extra.iter().map(|s| s.to_string())); all } +#[cfg(not(target_arch = "wasm32"))] fn reject_unknown_keys(dict: &Bound<'_, PyDict>, allowed: &[String], label: &str) -> PyResult<()> { for (key_obj, _) in dict.iter() { let key: String = key_obj.extract()?; @@ -475,6 +537,14 @@ fn parse_mounts(mounts: Option<&Bound<'_, PyList>>) -> PyResult builder.mount_real_readonly(&mount.host_path), @@ -790,6 +864,8 @@ fn apply_fs_config( (true, Some(vfs_mount)) => builder.mount_real_readwrite_at(&mount.host_path, vfs_mount), }; } + #[cfg(target_arch = "wasm32")] + let _ = real_mounts; Ok(builder) } @@ -1363,6 +1439,11 @@ impl PyFileSystem { Ok(Self::from_static(Arc::new(InMemoryFs::new()), rt)) } + // `FileSystem.real()` needs the realfs backend (host FS), and the capsule + // bridge (`from_capsule`/`to_capsule`) needs the `interop` ABI — both pull + // threads/host-FS deps absent on wasm, so these constructors are native-only. + // See specs/emscripten-wheels.md. + #[cfg(not(target_arch = "wasm32"))] #[staticmethod] #[pyo3(signature = (host_path, writable=false))] fn real(host_path: String, writable: bool) -> PyResult { @@ -1378,6 +1459,7 @@ impl PyFileSystem { Ok(Self::from_static(fs, rt)) } + #[cfg(not(target_arch = "wasm32"))] #[staticmethod] fn from_capsule(capsule: Bound<'_, PyAny>) -> PyResult { let capsule = capsule @@ -1393,6 +1475,7 @@ impl PyFileSystem { Ok(Self::from_static(fs, rt)) } + #[cfg(not(target_arch = "wasm32"))] fn to_capsule<'py>(&self, py: Python<'py>) -> PyResult> { let fs = self.export_fs(py)?; let exported = export_filesystem(fs).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; @@ -1951,7 +2034,7 @@ fn make_py_builtin_context( // tool never race on run_until_complete. struct PyCallbackSessionState { context: Py, - caller_loop_locals: Option, + caller_loop_locals: Option, } #[derive(Clone, Copy)] @@ -2117,7 +2200,11 @@ impl PyCallbackSession { .clone_ref(py) } - fn current_caller_loop_locals(&self) -> Option { + // Native: the caller-loop locals are a non-Copy TaskLocals, so clone out of the + // lock. Wasm: CallerLoopLocals is uninhabited and the field is always None, so + // return None without touching the (Copy) Option. + #[cfg(not(target_arch = "wasm32"))] + fn current_caller_loop_locals(&self) -> Option { self.state .lock() .expect("tool callback session lock") @@ -2125,6 +2212,11 @@ impl PyCallbackSession { .clone() } + #[cfg(target_arch = "wasm32")] + fn current_caller_loop_locals(&self) -> Option { + None + } + fn active_caller_tasks(&self) -> Arc>>> { self.active_caller_tasks.clone() } @@ -2133,16 +2225,26 @@ impl PyCallbackSession { let Some(locals) = self.current_caller_loop_locals() else { return Ok(()); }; - let tasks = self - .active_caller_tasks - .lock() - .expect("tool active caller tasks lock") - .drain(..) - .collect::>(); - for task in tasks { - cancel_python_task(py, &locals, &task)?; + // On wasm `locals` is `Infallible`, so this point is unreachable; the + // caller-loop scheduling helpers it would call are native-only. + #[cfg(target_arch = "wasm32")] + { + let _ = py; + match locals {} + } + #[cfg(not(target_arch = "wasm32"))] + { + let tasks = self + .active_caller_tasks + .lock() + .expect("tool active caller tasks lock") + .drain(..) + .collect::>(); + for task in tasks { + cancel_python_task(py, &locals, &task)?; + } + Ok(()) } - Ok(()) } fn invoke( @@ -2177,6 +2279,10 @@ fn capture_callback_state( needs_async_callbacks: bool, use_caller_loop: bool, ) -> PyResult { + // The caller-loop path captures the running asyncio loop's TaskLocals so async + // callbacks can be scheduled on it. That only happens under async `execute()`, + // which is native-only — on wasm we always fall through to the private-loop path. + #[cfg(not(target_arch = "wasm32"))] if needs_async_callbacks && use_caller_loop { let locals = pyo3_async_runtimes::tokio::get_current_locals(py)?; return Ok(PyCallbackSessionState { @@ -2184,6 +2290,8 @@ fn capture_callback_state( caller_loop_locals: Some(locals), }); } + #[cfg(target_arch = "wasm32")] + let _ = (needs_async_callbacks, use_caller_loop); Ok(PyCallbackSessionState { context: copy_current_context(py)?, @@ -2208,6 +2316,8 @@ fn asyncio_cancelled_error(py: Python<'_>) -> PyResult { )) } +// Caller-loop task scheduling/cancellation — native-only (see CallerLoopLocals). +#[cfg(not(target_arch = "wasm32"))] fn call_soon_threadsafe_with_context( py: Python<'_>, locals: &TaskLocals, @@ -2221,6 +2331,7 @@ fn call_soon_threadsafe_with_context( Ok(()) } +#[cfg(not(target_arch = "wasm32"))] fn cancel_python_task(py: Python<'_>, locals: &TaskLocals, task: &Py) -> PyResult<()> { let cancel = task.bind(py).getattr("cancel")?; call_soon_threadsafe_with_context(py, locals, &cancel) @@ -2355,6 +2466,10 @@ fn wrap_future_with_cancel<'py>( .call1((future, on_cancel.bind(py))) } +// Schedules an async callback on the caller's asyncio loop and awaits its result +// with cancellation propagation — caller-loop-only, hence native-only. On wasm, +// async callbacks run via the private-loop fallback in `call_python_callback_async`. +#[cfg(not(target_arch = "wasm32"))] struct PyCancellableLoopFuture { result_rx: Option>>>, locals: TaskLocals, @@ -2363,6 +2478,7 @@ struct PyCancellableLoopFuture { completed: bool, } +#[cfg(not(target_arch = "wasm32"))] impl PyCancellableLoopFuture { fn new( py: Python<'_>, @@ -2420,6 +2536,7 @@ impl PyCancellableLoopFuture { } } +#[cfg(not(target_arch = "wasm32"))] impl Drop for PyCancellableLoopFuture { fn drop(&mut self) { if self.completed { @@ -2521,6 +2638,9 @@ async fn call_python_callback_async( let awaitable = Python::attach(|py| session.invoke(py, callback, args)) .map_err(|e| format!("{callback_name}: {e}"))?; + // Caller-loop scheduling is native-only; on wasm we always use the private + // event-loop fallback (current-thread, no cross-loop bridging). + #[cfg(not(target_arch = "wasm32"))] let result = if session.current_caller_loop_locals().is_some() { let future = Python::attach(|py| PyCancellableLoopFuture::new(py, session, awaitable)) .map_err(|e| format!("{callback_name}: {e}"))?; @@ -2532,6 +2652,9 @@ async fn call_python_callback_async( Python::attach(|py| session.run_awaitable_on_private_loop(py, &awaitable)) .map_err(|e| format!("{callback_name}: {e}"))? }; + #[cfg(target_arch = "wasm32")] + let result = Python::attach(|py| session.run_awaitable_on_private_loop(py, &awaitable)) + .map_err(|e| format!("{callback_name}: {e}"))?; Ok(result) } @@ -2840,6 +2963,9 @@ async fn exec_bash_with_optional_output( // Decision: reject same-instance live Bash access from external_handler. // Releasing the interpreter mutex during Python callbacks would widen the // execution model; a targeted guard keeps the failure explicit and local. +// Drives an async Python coroutine handler via pyo3-async-runtimes — native-only. +// On wasm, external_handler is rejected at construction. +#[cfg(not(target_arch = "wasm32"))] fn make_external_handler( py_handler: Py, external_handler_reentry_depth: Arc, @@ -2898,7 +3024,10 @@ fn apply_python_config( external_handler_reentry_depth: Arc, ) -> bashkit::BashBuilder { // By construction, handler.is_some() implies python=true (validated in new()). + // On wasm, external_handler is rejected at construction, so handler is always + // None and the external-handler arm is native-only. match (python, handler) { + #[cfg(not(target_arch = "wasm32"))] (true, Some(h)) => { builder = builder.python_with_external_handler( PythonLimits::default(), @@ -2909,7 +3038,10 @@ fn apply_python_config( // in-process Python execution; propagate that to the builtin's env gate. builder = builder.env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1"); } + #[cfg(target_arch = "wasm32")] + (true, Some(_)) => unreachable!("external_handler rejected at construction on wasm"), (true, None) => { + let _ = (&fn_names, &external_handler_reentry_depth); builder = builder.python(); builder = builder.env("BASHKIT_ALLOW_INPROCESS_PYTHON", "1"); } @@ -2925,23 +3057,41 @@ fn apply_python_config( /// builtin and inject the runtime gate env var. The deny-list defaults /// (resource/FS-shaped PRAGMAs) come from `SqliteLimits::default()`. fn apply_sqlite_config( - mut builder: bashkit::BashBuilder, + builder: bashkit::BashBuilder, sqlite: bool, timeout_seconds: Option, max_memory: Option, ) -> PyResult { - if sqlite { - let mut limits = bashkit::SqliteLimits::default(); - if let Some(ts) = timeout_seconds { - limits = limits.max_duration(parse_timeout_seconds(ts)?); + // The embedded SQLite (Turso) backend needs the multi-threaded tokio runtime to + // bridge its sync IO trait back to the async VFS, so the core `sqlite` feature is + // off on wasm. Reject `sqlite=True` loudly there. See specs/emscripten-wheels.md. + #[cfg(target_arch = "wasm32")] + { + let _ = (timeout_seconds, max_memory); + if sqlite { + return Err(PyRuntimeError::new_err( + "the sqlite builtin is not available in the WebAssembly (Pyodide) build", + )); } - if let Some(mm) = max_memory { - limits = limits.max_db_bytes(usize::try_from(mm).unwrap_or(usize::MAX)); + Ok(builder) + } + + #[cfg(not(target_arch = "wasm32"))] + { + let mut builder = builder; + if sqlite { + let mut limits = bashkit::SqliteLimits::default(); + if let Some(ts) = timeout_seconds { + limits = limits.max_duration(parse_timeout_seconds(ts)?); + } + if let Some(mm) = max_memory { + limits = limits.max_db_bytes(usize::try_from(mm).unwrap_or(usize::MAX)); + } + builder = builder.sqlite_with_limits(limits); + builder = builder.env("BASHKIT_ALLOW_INPROCESS_SQLITE", "1"); } - builder = builder.sqlite_with_limits(limits); - builder = builder.env("BASHKIT_ALLOW_INPROCESS_SQLITE", "1"); + Ok(builder) } - Ok(builder) } /// Core bash interpreter with virtual filesystem. @@ -3044,11 +3194,17 @@ impl PyBash { self.external_handler_reentry_depth.clone(), ); builder = apply_sqlite_config(builder, self.sqlite, self.timeout_seconds, self.max_memory)?; - if let Some(ref net) = self.network { - builder = net.apply(builder); - } - if let Some(ref paths) = self.allowed_mount_paths { - builder = builder.allowed_mount_paths(paths.iter().map(|p| PathBuf::from(p.as_str()))); + // network (http_client) and allowed_mount_paths (realfs) are native-only; + // both kwargs are rejected at construction on wasm. See specs/emscripten-wheels.md. + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(ref net) = self.network { + builder = net.apply(builder); + } + if let Some(ref paths) = self.allowed_mount_paths { + builder = + builder.allowed_mount_paths(paths.iter().map(|p| PathBuf::from(p.as_str()))); + } } let files = clone_file_mounts(py, &self.files); let mut builder = apply_fs_config(builder, &files, &self.real_mounts)?; @@ -3130,6 +3286,14 @@ impl PyBash { let network = parse_network_config(network)?; let fn_names = external_functions.clone().unwrap_or_default(); + // external_handler runs an async Python coroutine driven through + // pyo3-async-runtimes, which the wasm build omits. Reject it there. + #[cfg(target_arch = "wasm32")] + if external_handler.is_some() { + return Err(PyRuntimeError::new_err( + "external_handler is not available in the WebAssembly (Pyodide) build", + )); + } if !fn_names.is_empty() && external_handler.is_none() { return Err(PyValueError::new_err( "external_functions requires external_handler — the list has no effect without a handler", @@ -3164,11 +3328,17 @@ impl PyBash { external_handler_reentry_depth.clone(), ); builder = apply_sqlite_config(builder, sqlite, timeout_seconds, max_memory)?; - if let Some(ref net) = network { - builder = net.apply(builder); - } - if let Some(ref paths) = allowed_mount_paths { - builder = builder.allowed_mount_paths(paths.iter().map(|p| PathBuf::from(p.as_str()))); + // network (http_client) and allowed_mount_paths (realfs) are native-only; + // both kwargs are rejected at construction on wasm. See specs/emscripten-wheels.md. + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(ref net) = network { + builder = net.apply(builder); + } + if let Some(ref paths) = allowed_mount_paths { + builder = + builder.allowed_mount_paths(paths.iter().map(|p| PathBuf::from(p.as_str()))); + } } builder = apply_fs_config(builder, &files, &real_mounts)?; builder = builder.readonly_filesystem(readonly_filesystem); @@ -3236,6 +3406,11 @@ impl PyBash { } /// Execute commands asynchronously. + /// + /// Native-only: the async API bridges to a Python asyncio loop via + /// pyo3-async-runtimes, which the WebAssembly (Pyodide) build omits. On wasm, + /// use `execute_sync()`. See specs/emscripten-wheels.md. + #[cfg(not(target_arch = "wasm32"))] #[pyo3(signature = (commands, on_output=None))] fn execute<'py>( &self, @@ -3333,6 +3508,9 @@ impl PyBash { } /// Execute commands asynchronously. Raises `BashError` on non-zero exit. + /// + /// Native-only (see `execute`). Use `execute_sync_or_throw()` on wasm. + #[cfg(not(target_arch = "wasm32"))] #[pyo3(signature = (commands, on_output=None))] fn execute_or_throw<'py>( &self, @@ -3746,11 +3924,17 @@ impl BashTool { builder = builder.max_memory(usize::try_from(max_memory).unwrap_or(usize::MAX)); } - if let Some(ref net) = self.network { - builder = net.apply(builder); - } - if let Some(ref paths) = self.allowed_mount_paths { - builder = builder.allowed_mount_paths(paths.iter().map(|p| PathBuf::from(p.as_str()))); + // network (http_client) and allowed_mount_paths (realfs) are native-only; + // both kwargs are rejected at construction on wasm. See specs/emscripten-wheels.md. + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(ref net) = self.network { + builder = net.apply(builder); + } + if let Some(ref paths) = self.allowed_mount_paths { + builder = + builder.allowed_mount_paths(paths.iter().map(|p| PathBuf::from(p.as_str()))); + } } let files = clone_file_mounts(py, &self.files); let mut builder = apply_fs_config(builder, &files, &self.real_mounts)?; @@ -3861,11 +4045,17 @@ impl BashTool { let real_mounts = parse_mounts(mounts)?; let custom_builtins = parse_custom_builtins(py, custom_builtins)?; let network = parse_network_config(network)?; - if let Some(ref net) = network { - builder = net.apply(builder); - } - if let Some(ref paths) = allowed_mount_paths { - builder = builder.allowed_mount_paths(paths.iter().map(|p| PathBuf::from(p.as_str()))); + // network (http_client) and allowed_mount_paths (realfs) are native-only; + // both kwargs are rejected at construction on wasm. See specs/emscripten-wheels.md. + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(ref net) = network { + builder = net.apply(builder); + } + if let Some(ref paths) = allowed_mount_paths { + builder = + builder.allowed_mount_paths(paths.iter().map(|p| PathBuf::from(p.as_str()))); + } } builder = apply_fs_config(builder, &files, &real_mounts)?; builder = builder.readonly_filesystem(readonly_filesystem); @@ -3924,6 +4114,8 @@ impl BashTool { } } + /// Native-only (see `Bash.execute`). Use `execute_sync()` on wasm. + #[cfg(not(target_arch = "wasm32"))] #[pyo3(signature = (commands, on_output=None))] fn execute<'py>( &self, @@ -4003,6 +4195,9 @@ impl BashTool { } /// Execute commands asynchronously. Raises `BashError` on non-zero exit. + /// + /// Native-only (see `Bash.execute`). Use `execute_sync_or_throw()` on wasm. + #[cfg(not(target_arch = "wasm32"))] #[pyo3(signature = (commands, on_output=None))] fn execute_or_throw<'py>( &self, @@ -4520,6 +4715,9 @@ impl ScriptedTool { } /// Execute a bash script asynchronously. + /// + /// Native-only (see `Bash.execute`). Use `execute_sync()` on wasm. + #[cfg(not(target_arch = "wasm32"))] fn execute<'py>(&self, py: Python<'py>, commands: String) -> PyResult> { let session = PyCallbackSession::capture( py, diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index 3d9dfd8a1..f840a3797 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -724,7 +724,9 @@ impl Bash { // (REPL, agent commands, short shell snippets) the threadpool hop // dominated startup latency. The threshold matches the input byte // size; above it we keep the original behavior so very large scripts - // can be pre-empted. + // can be pre-empted. Only consulted on native targets (the wasm path + // below always parses inline). + #[cfg(not(target_family = "wasm"))] const SPAWN_BLOCKING_THRESHOLD: usize = 16 * 1024; // On WASM, tokio::task::spawn_blocking and tokio::time::timeout don't @@ -818,7 +820,9 @@ impl Bash { self.interpreter.load_history().await; let exec_start = std::time::Instant::now(); - // THREAT[TM-DOS-057]: Wrap execution with timeout to prevent sleep/blocking bypass + // THREAT[TM-DOS-057]: Wrap execution with timeout to prevent sleep/blocking bypass. + // Only the native path arms the tokio timeout; wasm has no reliable timer driver. + #[cfg(not(target_family = "wasm"))] let execution_timeout = self.interpreter.limits().timeout; #[cfg(not(target_family = "wasm"))] let result = diff --git a/specs/emscripten-wheels.md b/specs/emscripten-wheels.md new file mode 100644 index 000000000..8436809d0 --- /dev/null +++ b/specs/emscripten-wheels.md @@ -0,0 +1,185 @@ +# Emscripten / Pyodide Wheels + +## Status + +Implemented (reduced feature set). CI build + smoke test green; PyPI publish wired. + +## Abstract + +Bashkit ships an additional Python wheel targeting `wasm32-unknown-emscripten` +(the Pyodide ABI), so `bashkit` can run **in the browser, JupyterLite, and other +WASM hosts** with no native toolchain. This is a *reduced-feature* variant of the +native package: the in-VFS shell plus the embedded `jq` and Monty `python` +interpreters, driven through the blocking `execute_sync()` API. + +Approach mirrors Pydantic's recipe for building Emscripten wheels for a +Rust + maturin + PyO3 package +(). + +## Why a separate, reduced wheel + +Pyodide runs single-threaded with no OS sockets and no host filesystem. Several +deps the native wheel relies on contain hard `compile_error!`s or missing modules +on wasm: + +| Capability | Native crate/feature | Why it can't build on wasm | +|---|---|---| +| Outbound HTTP (`curl`/`wget`/`http`) | `http_client` → `reqwest` → `mio` | `mio`: "This wasm target is unsupported by mio. Disable the net feature." | +| SQLite (`sqlite`/`sqlite3`) | `sqlite` → `turso_core` + `tokio/rt-multi-thread` | tokio: "Only features sync,macros,io-util,rt,time are supported on wasm." | +| Host directory mounts | `realfs` → `tokio::fs` | `tokio::fs` absent on wasm | +| Capsule FS interop | `interop` → `tokio/rt-multi-thread` | multi-thread runtime unsupported | +| Async `execute()` bridge | `pyo3-async-runtimes` (`tokio-runtime`) | hard-pulls `rt-multi-thread` + tokio `net` (mio) | + +The core `bashkit` crate was already wasm-aware (it gates `rt-multi-thread`/`fs` +behind `cfg(not(target_arch = "wasm32"))`). The work is confined to +`crates/bashkit-python`. + +## Feature matrix: native vs wasm + +| Surface | Native wheel | Pyodide wheel | +|---|---|---| +| `Bash` / `BashTool` / `ScriptedTool` | ✅ | ✅ | +| `execute_sync()` / `execute_sync_or_throw()` | ✅ | ✅ | +| `async execute()` / `execute_or_throw()` | ✅ | ❌ (method absent) | +| `python=True` (Monty) | ✅ | ✅ | +| `jq` builtin | ✅ | ✅ | +| Custom builtins — sync callbacks | ✅ | ✅ | +| Custom builtins — async callbacks | ✅ (caller-loop or private-loop) | ✅ (private-loop fallback only) | +| `network=` (allowlist / credentials) | ✅ | ❌ (raises at construction) | +| `sqlite=True` | ✅ | ❌ (raises at construction) | +| `mounts=` (host dirs) | ✅ | ❌ (raises at construction) | +| `external_handler=` | ✅ | ❌ (raises at construction) | +| `FileSystem.real()` / capsule `to/from_capsule` | ✅ | ❌ (method absent) | + +Unavailable *configuration* kwargs **fail loudly** with a `RuntimeError` rather +than silently no-op, so callers learn immediately the WASM build can't do it. + +## Implementation + +All gating lives in `crates/bashkit-python`: + +### `Cargo.toml` + +Dependencies are split per target: + +```toml +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +bashkit = { path = "../bashkit", features = ["scripted_tool","python","realfs","jq","interop","http_client","sqlite"] } +tokio = { workspace = true, features = ["rt-multi-thread"] } +pyo3-async-runtimes = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +bashkit = { path = "../bashkit", features = ["scripted_tool","python","jq"] } +tokio = { workspace = true } # wasm-safe base features only +``` + +### `src/lib.rs` + +- `#[cfg(not(target_arch = "wasm32"))]` on: the async `execute*()` `#[pymethods]`, + `pyo3-async-runtimes` imports, `make_external_handler`, the caller-loop callback + machinery (`PyCancellableLoopFuture`, `call_soon_threadsafe_with_context`, + `cancel_python_task`), network/credential parsing + `apply`, `FileSystem.real()` + and the capsule bridge. +- `type CallerLoopLocals` aliases `TaskLocals` (native) / `std::convert::Infallible` + (wasm). `caller_loop_locals` is always `None` on wasm, so the caller-loop branches + in `capture_callback_state` and `call_python_callback_async` are statically dead. +- Construction-time guards raise `RuntimeError` for `network=`, `sqlite=True`, + `mounts=`, and `external_handler=` on wasm. +- A wasm-scoped `#![cfg_attr(target_arch = "wasm32", allow(dead_code, unused_imports))]` + silences lints from helpers referenced only by native-gated paths. + +Decision comments are inline at each gate; this spec is the index. + +## Building locally + +### Toolchain matrix (verified) + +| Component | Version | Why | +|---|---|---| +| Python (host for build) | **3.13** | Selects pyodide-build's modern config | +| pyodide-build | 0.34.x (latest) | → Pyodide 0.29.x ABI | +| Emscripten | **4.0.9** | Managed by pyodide-build; binaryen knows modern LLVM wasm features | +| Rust | **nightly** (≥1.95-equivalent) | `-Z link-native-libraries=no`; satisfies monty's MSRV + edition 2024 | + +The Emscripten/ABI versions are dictated by the installed `pyodide-build` for the +host Python. **Python 3.11/3.12 pin pyodide-build ≤0.25.1 → Emscripten 3.1.x**, +whose binaryen is too old for modern LLVM (see "version triangle" below) — use +Python 3.13. + +```bash +# 1. nightly Rust (Pyodide passes -Z link-native-libraries=no) +rustup toolchain install nightly --target wasm32-unknown-emscripten + +# 2. pyodide-build (under Python 3.13) — manages its own matching emsdk +python3.13 -m pip install pyodide-build +pyodide xbuildenv install # downloads Emscripten 4.0.9 + ABI + +# 3. build (no RUSTFLAGS hacks, no separate emsdk needed) +cd crates/bashkit-python +RUSTUP_TOOLCHAIN=nightly pyodide build + +# 4. smoke test — run from a scratch dir so the crate's own bashkit/ source +# package doesn't shadow the installed extension module +pyodide venv .venv-pyodide +.venv-pyodide/bin/pip install dist/*.whl +( cd "$(mktemp -d)" && /abs/path/.venv-pyodide/bin/python -c \ + "from bashkit import Bash; print(Bash(python=True).execute_sync('echo hi | jq -R .').stdout)" ) +``` + +For a fast Rust-only type check without the full wheel build: + +```bash +PYO3_CROSS_PYTHON_VERSION=3.13 \ + cargo check -p bashkit-python --target wasm32-unknown-emscripten +``` + +### The version triangle (the hard part) + +The build sits at the intersection of three independently-versioned toolchains +that must agree on the wasm feature set: + +1. **Rust/LLVM** emits a `target_features` section. Modern LLVM (19+, required by + `edition 2024` and monty's `rustc 1.95` MSRV) marks features like + `bulk-memory-opt` and `call-indirect-overlong`. +2. **Emscripten/binaryen** runs `wasm-opt --detect-features`, reading that section + and passing `--enable-` for each. Binaryen must recognize every name + or the link fails: `Unknown option '--enable-bulk-memory-opt'`. Binaryen ≥ + the one in **Emscripten 4.0.9** knows them; the one in Emscripten 3.1.x does not. +3. **Pyodide runtime** must support the **exception-handling ABI** the wheel uses. + Modern Rust emits the new wasm-EH `__cpp_exception` *tag*; older Pyodide + (0.25/Emscripten 3.1.46) only supports legacy EH, giving a load-time + `LinkError: tag import requires a WebAssembly.Tag`. + +Older Emscripten (3.1.x) fails (2) and (3) against modern Rust, and the +edition-2024 + monty MSRV floor forbids dropping to an old-enough nightly. The +resolution is to move *up*: Python 3.13 → pyodide-build's Emscripten 4.0.9 config, +which matches modern nightly Rust on both feature naming and the wasm-EH ABI. No +`-O1` / wasm-opt-skip or target-feature disabling is needed. + +## CI + +`.github/workflows/python.yml` adds a `wasm` job: Python 3.13 + nightly Rust + +`pyodide build`, then imports the wheel in a `pyodide venv` (from a scratch dir) to +smoke-test `execute_sync`. Wired into the `python-check` gate. + +`.github/workflows/publish-python.yml` adds a `build-emscripten` job feeding the +`inspect` → `publish` pipeline so the Pyodide wheel ships to PyPI alongside the +native wheels. + +## Gotchas (from the Pydantic article, confirmed here) + +- **Nightly Rust required**: Pyodide injects `-Z link-native-libraries=no`. +- **No threads / no asyncio loop bridging**: async `execute()` is native-only; + async custom-builtin callbacks use the private-loop fallback on wasm. +- **No sockets / no host FS**: network, sqlite, realfs, interop all gated off. +- **Version triangle**: Rust/LLVM ↔ Emscripten/binaryen ↔ Pyodide-runtime EH ABI + must agree (see above). Pin via the host Python version (3.13) + a recent + nightly; bump deliberately. +- **Source shadowing in the smoke test**: run the import test from a scratch + directory, or the crate's `bashkit/` source package shadows the installed + extension (`ModuleNotFoundError: No module named 'bashkit._bashkit'`). + +## See also + +- `specs/python-package.md` — native wheel matrix, PyPI publishing, public API. +- `specs/architecture.md` — core interpreter, wasm-aware tokio gating. diff --git a/specs/python-package.md b/specs/python-package.md index bfe9e32ec..00bab0ad7 100644 --- a/specs/python-package.md +++ b/specs/python-package.md @@ -65,6 +65,11 @@ The version chain: `Cargo.toml` (workspace) → `Cargo.toml` (bashkit-python, in Total: ~42 wheels (7 platforms × 6 Python versions). +In addition, a **reduced-feature Pyodide/Emscripten wheel** +(`wasm32-unknown-emscripten`) ships for browser / JupyterLite use. It is built and +published separately (different toolchain, single Python version, no async/network/ +sqlite/realfs). See `specs/emscripten-wheels.md`. + ## PyPI Publishing ### Workflow diff --git a/specs/release-process.md b/specs/release-process.md index 9c266521b..97c815f28 100644 --- a/specs/release-process.md +++ b/specs/release-process.md @@ -199,7 +199,9 @@ Crates must be published in dependency order: 1. `bashkit` (core library, no internal deps) 2. `bashkit-cli` (depends on bashkit) -Python wheels are published independently (no crates.io dependency). +Python wheels are published independently (no crates.io dependency). This +includes the native platform matrix plus a reduced-feature Pyodide/Emscripten +wheel (`wasm32-unknown-emscripten`); see `specs/emscripten-wheels.md`. npm packages are published independently (no crates.io dependency).