Skip to main content
Mirage TypeScript implements python3 as a shell builtin, backed by Pyodide (CPython compiled to WebAssembly). Behavior matches Python Mirage’s reference, with a few WASM-runtime divergences noted below. The same code path runs in Node and in the browser.

What works

Also: export FOO=bar is visible via os.environ, sys.argv[1:] reflects shell args, sys.exit(n) is honored, uncaught exceptions return exit 1 with traceback on stderr, missing script returns exit 1 with python3: <path>: No such file.

Setup

Pyodide is an optional peer dependency of @struktoai/mirage-core. Workspaces that never call python3 never load it.
npm install and yarn add work too. If pyodide isn’t installed, python3 returns exit=127 with a helpful stderr message, and the workspace keeps running.

Limitations

Pyodide runs CPython in WebAssembly on the same JS thread. That creates these divergences from Python Mirage’s subprocess model:

1. Shared module cache (sys.modules)

A single Pyodide interpreter serves all python3 calls in one workspace, so imports persist across calls.
This is a perf win (import numpy is paid once) with no correctness impact, since Python imports are idempotent. User-level globals (foo = 1 at top level) do not leak; each call gets a fresh globals().

2. No true CPU parallelism within a workspace

Pyodide is single-interpreter-per-JS-thread, so concurrent python3 calls in one workspace serialize via a JS queue.
For parallelism, use separate workspaces. Envs and sys.modules are fully isolated across workspaces.

3. No real OS file descriptors

sys.stdin, sys.stdout, sys.stderr are Python-level wrappers over in-memory buffers. Byte-level IO works:
Anything through sys.stdin.read(), input(), print(), .buffer.read/write() works. select, poll, fcntl, and os.read(fd, ...) on fd 0/1/2 don’t apply in WASM.

Reading and writing Mirage mounts from Python

Python code under python3 can open() paths inside any Mirage-mounted prefix. Reads and writes route through the workspace’s mount layer (RAM, S3, OPFS, Slack, anything you’ve registered).
PIL and other native-extension libs that go through Python’s open() work too:

How it works

  • Its own filesystem: Mirage registers an Emscripten filesystem at each mount prefix, below the interpreter’s syscall boundary. Every spelling of an operation arrives as the same callback, so nothing inside Python is patched.
  • Collected before the run: each prefix is walked into the filesystem’s node table before the script starts, so reads are sync and cost no round trip.
  • Replayed after it: writes are recorded in guest order and applied to the mounts once the script returns. A handle that only extended a file replays as an append.
  • Every spelling: open(), os.open, pathlib, shutil, numpy.save, PIL.Image.save, pandas.to_csv, and C extensions calling fopen (sqlite3, h5py) all reach the mount.

Runtime requirements

None. No V8 flag, no JSPI, no stack switching: a filesystem callback never suspends, because the reads it serves were collected before the run and the writes it takes are replayed after.

What doesn’t work

  • Live external edits: a change made to the resource from outside mid-run is not seen until the next run re-collects the prefix.
  • Concurrent writers: last write wins; no conflict detection.
  • Symlinks: links are namespace state in Mirage, so os.symlink is refused with EPERM rather than accepted and lost at the end of the run.

What you cannot do

pip install at runtime

Pre-bundle what you need. Pyodide’s micropip isn’t wired into the python3 builtin yet.

Native CPython fallback

Mirage TS always uses Pyodide, never child_process.spawn('python3', ...), so behavior is identical in Node and in the browser.

Shell parser quirk (not python3-specific)

The tree-sitter-bash grammar strips newlines inside "...". For multi-line -c, use single quotes or a heredoc:

Quick reference