bashkit

Embedded CPython (WebAssembly)#

Bashkit can run real CPython 3.14 as python/python3. The interpreter is compiled to WebAssembly (wasm32-wasip1), pre-initialized into a snapshot and shipped inside the binary, so there is nothing to install and no network access at build or run time. Each python3 call runs in a fresh, isolated instance that can only reach the Bashkit virtual filesystem and its own stdio.

See also:

Quick start#

Enable the cpython cargo feature and register the builtins:

[dependencies]
bashkit = { version = "0.18.2", features = ["cpython"] }
use bashkit::Bash;

let mut bash = Bash::builder().cpython().build();

let r = bash
    .exec("python3 -c 'import json, sys; print(json.dumps({\"v\": sys.version_info[:2]}))'")
    .await?;
assert_eq!(r.stdout, "{\"v\": [3, 14]}\n");

No runtime opt-in variable is needed (unlike Monty’s BASHKIT_ALLOW_INPROCESS_PYTHON): calling .cpython() is the opt-in, because the guest never runs native code in your process.

The interpreter loads on the first call of a process: the first python3 takes about 19 ms on the reference machine, later calls about 4.4 ms. To move that one-time cost out of the first request, call bashkit::CPython::warm_up() at startup.

Why CPython instead of Monty#

Monty is a Python subset written in Rust and designed for code-mode execution: evaluate a snippet, call back into host functions, return a value. Scripts written by people and agents expect a Python command line and the standard library behind it. CPython provides that:

Monty (python feature)CPython (cpython feature)
LanguageSubset (no classes, limited stdlib)Full Python 3.14
Stdlibmath, pathlib, os.getenv, sys, typing, …Pure-Python stdlib for scripts (see Limitations) plus json, re, csv, sqlite3, zlib, hashlib, decimal, datetime, asyncio, …
CLI-c, file, --c, -m, file, directory with __main__.py, -, stdin, -x, -W, -V, -h
ErrorsMonty-specific textCPython tracebacks, exit codes, sys.exit semantics
IsolationIn-process Rust interpreterWebAssembly sandbox (memory-safe boundary), fresh instance per call
Start per call~15 µs~4.4 ms (first call in a process ~19 ms)
CPU-bound speedNative~4-30x slower than Monty (interpreted wasm); ~4x slower with cpython-native
Host callbacksYes (external functions)Not yet
HTTPNourllib.request, http.client through Bashkit’s egress allowlist

Pick CPython when scripts need real Python behavior; pick Monty when you need host function callbacks or microsecond start-up for tiny snippets. Both can be compiled in: the builder method called last owns python/python3.

What works#

  • python3 -c CODE, python3 FILE, python3 -m MODULE, python3 DIR (runs DIR/__main__.py), python3 - and programs piped on stdin.
  • sys.argv, sys.exit, uncaught exceptions (exit 1, traceback on stderr), os._exit, atexit, input(), binary stdio via sys.stdout.buffer.
  • Exported shell variables in os.environ; PWD becomes the working directory, so relative paths resolve like in a real shell.
  • open(), os, pathlib, shutil, glob, tempfile, sqlite3 database files, gzip/zipfile/tarfile on the virtual filesystem. Files the script leaves open are flushed when it exits.
  • Local modules next to the script or in the working directory import as usual.
  • asyncio (single-threaded event loop, timers, queues, gather).

Limits#

use bashkit::{Bash, CPythonLimits};
use std::time::Duration;

let bash = Bash::builder()
    .cpython_with_limits(
        CPythonLimits::default()
            .max_duration(Duration::from_secs(5)) // wall clock per call (default 30 s)
            .max_memory(128 * 1024 * 1024)        // guest memory (default 256 MB, max 1 GB)
            .max_recursion(500)                   // sys.getrecursionlimit() (default 1000)
            .max_output(1024 * 1024),             // stdout + stderr bytes (default 16 MB)
    )
    .build();
  • Time: a call past its deadline stops with exit code 124 and python3: execution timed out .... A tighter Bashkit ExecutionLimits timeout or cancellation wins.
  • Memory: allocation past the cap raises MemoryError inside Python.
  • Output: bytes past the cap are dropped and stderr ends with python3: output truncated at N bytes.
  • Files: Bashkit filesystem limits (file size, total bytes, file count) apply; violations raise OSError.
  • Concurrency: up to 512 CPython calls run at once per process; further calls wait for a free slot within their own deadline.

HTTP#

With the http_client feature and a network allowlist (BashBuilder::network), urllib.request and http.client work for http:// and https:// URLs. Each request is handed to the host and goes through the same pipeline as curl: allowlist, private-IP (SSRF) checks, credential injection, request signing, your HttpTransport and the response size cap. TLS runs on the host; the guest has no sockets.

let mut bash = Bash::builder()
    .cpython()
    .network(NetworkAllowlist::new().allow("https://api.example.com"))
    .build();
bash.exec(r#"python3 -c '
import json, urllib.request
with urllib.request.urlopen("https://api.example.com/v1/items") as r:
    print(json.load(r))
'"#).await?;
  • A denied URL raises URLError (or ConnectionError from http.client) with the same “access denied” text curl prints; a timeout raises TimeoutError. Without a network allowlist every request fails with “network access not configured”.
  • Redirects are followed by Python, so every hop is checked again.
  • Methods: GET, POST, PUT, DELETE, HEAD, PATCH. Headers the host owns (Host, Content-Length, Transfer-Encoding, Connection, proxy headers) are set by the host; values with CR/LF are rejected.
  • Responses are buffered (up to the client’s max_response_bytes), so streaming past that cap fails. ssl contexts, verify=False, client certificates and proxy settings are ignored: the host verifies TLS.
  • CPythonLimits::max_http_requests caps requests per call (default 100); a request’s timeout never outlasts the call’s deadline.

requests and httpx#

import requests, import httpx and import httpx2 work out of the box. They are Bashkit’s own compact implementations of the common API, written on top of the same host bridge (not the upstream packages, which take seconds to import in the sandbox). They are preloaded in the snapshot, so importing them costs nothing.

import requests
r = requests.get("https://api.example.com/v1/items", params={"page": 2}, timeout=5)
r.raise_for_status()
print(r.json())

import httpx
with httpx.Client(base_url="https://api.example.com", headers={"X-Key": "..."}) as c:
    print(c.post("/v1/items", json={"name": "a"}).status_code)
  • requests: get/post/put/patch/delete/head/request, Session (headers, params, auth, cookies, hooks), params, data, json, files (multipart), headers, cookies, basic auth, timeout, allow_redirects; Response with status_code, ok, reason, headers, content, text, json(), url, history, links, iter_content/iter_lines, raise_for_status(); the upstream exception classes (ConnectionError, ReadTimeout, HTTPError, …).
  • httpx (and httpx2, the same module): the verb functions and stream(), Client and AsyncClient (base_url, headers, params, cookies, auth, timeout, follow_redirects, event_hooks, transport=httpx.MockTransport(...)), Response, URL, Headers, QueryParams, Cookies, Timeout, BasicAuth, codes and the upstream exception classes. As in httpx, redirects are not followed unless follow_redirects=True.
  • Not supported: retries (HTTPAdapter(max_retries=...) is accepted and ignored), proxies, client certificates, HTTP/2, OPTIONS (not an allowed method), digest auth, streaming uploads. Bodies are buffered, so stream=True and iter_* walk a body that is already complete. AsyncClient requests run one at a time.

Limitations#

  • No subprocesses: subprocess, os.system, os.fork, os.popen raise OSError/AttributeError. Python cannot call back into the shell yet.
  • HTTP only, through Bashkit’s egress: see HTTP. Raw socket connections, ssl and non-HTTP protocols are unavailable.
  • No threads: threading.Thread.start() raises RuntimeError; multiprocessing and concurrent.futures.ProcessPoolExecutor are absent. asyncio works.
  • No native extensions or pip: only the bundled stdlib (plus Bashkit’s own requests/httpx, see HTTP). ctypes, numpy and other third-party packages are unavailable; ssl, _hashlib (OpenSSL), tkinter, curses, readline, dbm.gnu are not built. hashlib still provides md5, sha1, sha2, sha3 and blake2.
  • No interactive mode: python3 with no program reads one from stdin; there is no REPL, pydoc (help()) is not shipped, and breakpoint() prints a notice and continues (no debugger).
  • Stdlib is bytecode only: tracebacks through stdlib code show no source line, and inspect.getsource() fails on stdlib objects. Your own code keeps full tracebacks. Non-HTTP network clients and servers (smtplib, ftplib, http.server, xmlrpc, …) are not shipped since the guest has no sockets.
  • Trimmed for scripts: the stdlib targets agents running file-processing and glue scripts. Test, profiling and packaging tools (unittest, doctest, cProfile, profile, trace, compileall, zipapp, …), dbm/shelve, plistlib, wave, netrc, cmd, tty/pty, and bz2/lzma/compression.zstd (no C codec in the guest) are not shipped. gzip, zipfile (deflate) and tarfile (plain or gzip) work.
  • Symlinks are not followed, like everywhere in the Bashkit VFS.
  • errno numbers are WASI’s (ENOENT is 44, not 2). Exception types (FileNotFoundError, …) and messages are correct; code comparing e.errno == errno.ENOENT works because the errno module matches.
  • Fixed hash seed: hash() of str/bytes is the same in every call (the seed is baked into the snapshot). random is re-seeded per call (on first use).
  • Deep C-level recursion (for example repr of a list nested 100 000 levels) ends the call with python3: fatal error: stack overflow in the interpreter instead of RecursionError.
  • CPU-bound code is slow: the guest runs on Wasmtime’s portable Pulley interpreter, roughly 4-30x slower than Monty and far slower than native CPython. Start-up, not throughput, is what this runtime is tuned for.
  • No host callbacks (Monty’s external functions) or ToolDef integration yet.
  • Interpreter-start options (-E, -I, -s, -S, -B, -u, -O, -q, -X ...) are accepted and ignored.

Native code (opt-in)#

By default the interpreter ships as portable Pulley bytecode, which needs no executable memory. Enable cpython-native instead of cpython to compile it to machine code for your target at build time:

bashkit = { version = "0.18.2", features = ["cpython-native"] }

Calls get 4-10x faster (print(1) ~1 ms instead of ~4 ms; CPU-bound code ~10x). In exchange the host must allow executable memory, and the first load in a process takes ~47 ms, so call bashkit::CPython::warm_up() at startup. Nothing is compiled at run time with either option.

Binary size#

The cpython feature adds about 45 MB to a binary: the precompiled interpreter snapshot (~41 MB, mostly the pre-initialized 40 MB heap image so it can be mapped copy-on-write) and the zipped stdlib bytecode (~3.2 MB) are embedded, plus the Wasmtime runtime. Pages are mapped on demand, so resident memory per process is far smaller.