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.
  • Directory links are not walked into: os.symlink and os.readlink are served, and a link the shell made is collected as a link rather than as a copy of its target, so nothing under a directory link is in the tree the run reads.

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