wasi-python 0.1.0a3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,743 @@
1
+ Metadata-Version: 2.5
2
+ Name: wasi-python
3
+ Version: 0.1.0a3
4
+ Summary: A statically linked CPython for WebAssembly hosts (wasm32-wasip1) with numpy and pandas compiled in.
5
+ Project-URL: Homepage, https://wasipython.dev
6
+ Project-URL: Repository, https://github.com/claygendron/wasi_python
7
+ Author: Clay Gendron
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Keywords: cpython,numpy,pandas,sandbox,wasi,wasm,wasmtime
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Software Development :: Interpreters
15
+ Requires-Python: >=3.11
16
+ Provides-Extra: run
17
+ Requires-Dist: wasmtime<50,>=49; extra == 'run'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # wasi_python
21
+
22
+ **A statically linked CPython for WebAssembly hosts, with numpy and pandas
23
+ compiled in. No browser, no JavaScript. A few pinned releases, not every
24
+ version of everything.**
25
+
26
+ One `python.wasm` for `wasm32-wasip1` that runs on a WASI host with
27
+ WebAssembly exception handling (see *Hosts*), from your own Rust, Go or
28
+ Python program. The packages are linked in as builtin modules, so there is
29
+ no `dlopen`, no shared library and no package install step inside the
30
+ guest. The sandbox is WASI's capability model: the guest sees only the
31
+ directories and host functions you hand it.
32
+
33
+ - Site: <https://wasipython.dev>
34
+ - Status: **alpha.** `0.1.0a1` (CPython 3.14.8, wasi-sdk 24) is a GitHub
35
+ pre-release; the next alpha moves to CPython 3.15.0 and wasi-sdk 33.
36
+ - Licence: Apache-2.0.
37
+
38
+ ## What you get
39
+
40
+ | artifact | what it is |
41
+ |---|---|
42
+ | `python.wasm` | a WASI reactor: CPython plus numpy, pandas, zlib, bz2, lzma, sqlite3, the `host` door; `_initialize`, then one `run` per instance |
43
+ | `python-snapshot.wasm` | the same binary after a Wizer snapshot with numpy and pandas pre-imported; one `run` per instance, about 20 ms |
44
+ | `python-stdlib.zip` | the stdlib plus numpy, pandas, python-dateutil, tzdata, six as a deflated zip |
45
+ | `MANIFEST`, `SHA256SUMS` | every pinned source, every supported build host's toolchain, and every recipe hash |
46
+
47
+ Published as GitHub release assets (xz, 8 to 20 MB) and as the `wasi-python`
48
+ wheel on PyPI, which carries the same files and a `wasi_python.artifacts()`
49
+ function that returns their paths.
50
+
51
+ ## Measured (arm64 macOS, wasmtime 49)
52
+
53
+ Times are from the first scripted build on the 3.14 line (spec 003's landing
54
+ notes, 2026-10-16); sizes and instruction counts are the 3.15 line's (spec 005's
55
+ landing notes, 2026-10-17). Times are benchmark facts on one machine, not
56
+ promises.
57
+
58
+ | fact | value |
59
+ |---|---|
60
+ | upstream patches | upstream patches carried: 1, each with an upstream pull request drafted for the owner to send (`recipes/patches/`); every other adaptation is a recipe file |
61
+ | build from pinned sources, downloads present | 5 min 41 s (macOS arm64, 10 cores) |
62
+ | `python.wasm` / `python-snapshot.wasm` / `python-stdlib.zip` | 31.8 MB / 65.5 MB / 11.1 MB (3.15 line) |
63
+ | `import numpy` | `origin == 'built-in'`, no `.so` opened |
64
+ | one call, snapshot, `-c pass` | 20 ms, of which about 14 ms is finalizing the interpreter; 81.9M guest instructions on 3.15 |
65
+ | one call, snapshot, skipping finalization (`os._exit(0)`; flush first) | 6 ms |
66
+ | one call, plain reactor, `-c pass` / `import pandas` | 33 ms / 720 ms |
67
+ | numpy kernels vs native (the spike's measurement) | 1.6 to 2.3 times slower (no SIMD, no BLAS, no threads) |
68
+ | pandas groupby, 1e6 rows, vs native (the spike's) | 1.3 to 1.4 times slower |
69
+ | numpy, pandas, bz2, lzma and sqlite3 results vs native, same pins | byte-identical on five golden jobs |
70
+
71
+ The platform's facts, documented rather than patched (with one carried patch,
72
+ below), are what `python.wasm -V` prints after its version:
73
+
74
+ > (wasm32-wasip1: numpy's default integer is 32-bit; no threads; no ctypes, mmap, sockets, ssl or zstd; compute about 1.3 to 2.3 times slower than native)
75
+
76
+ So `np.arange(1_000_000).sum()` wraps (use `dtype=np.int64`). A C++
77
+ `throw` inside a package (numpy's FFT, for one) is caught as in native
78
+ code and reaches Python as an exception. A script's directory is not put on `sys.path`: the interpreter
79
+ runs isolated. `np.save` and `np.load` of paths and open files work, through a
80
+ carried patch (`recipes/patches/numpy-2.5.2/`) until numpy releases the fix;
81
+ `ndarray.tofile` and `np.fromfile` on real files still fail, since WASI cannot
82
+ duplicate a descriptor (use `tobytes()` and `np.frombuffer`, or `io.BytesIO`), and
83
+ memory maps are unavailable. The snapshot runs only beside the exact stdlib zip it was built
84
+ against (its `MANIFEST` names the zip's hash), because zipimport's file
85
+ offsets are part of the snapshot.
86
+
87
+ `run` treats its arguments as CPython's command line does for one program:
88
+ `-c`, `-m`, a script, or `-` and no argument for stdin; `-u`; `-V`. Its return
89
+ value is the exit status, masked to 0 to 255 as a POSIX process's is. Output is
90
+ buffered as CPython's is without `-u`, so a program that ends in `os._exit` or
91
+ is stopped by fuel loses what it printed since its last flush; run it with
92
+ `-u`, or flush first. The snapshot adds three facts of its own:
93
+
94
+ - its string hash secret is fixed (`sys.flags.hash_randomization == 0`), so
95
+ every snapshot instance hashes strings alike;
96
+ - the modules it pre-imports were imported with an empty environment: `run`
97
+ re-reads what is known to matter (the `random` and numpy seeds, `logging`'s
98
+ start time, `zoneinfo`'s `PYTHONTZPATH`), and anything else a module read
99
+ from the environment at import keeps the build's value;
100
+ - it is the same bytes on every build, from any supported build host: while
101
+ it initializes, the clocks, entropy and file metadata (a directory's size
102
+ and link count included) answer constants, and no program ever sees them.
103
+
104
+ ### sqlite3 in the guest
105
+
106
+ `sqlite3`, `bz2` and `lzma` are built in. `bz2`, `lzma` and `tarfile`'s
107
+ `bz2` and `xz` modes write the same bytes as native Python. `sqlite3` has
108
+ native's feature set (FTS3/4/5, R-tree, Geopoly, math functions, dbstat,
109
+ percentile) and gives native's results, except two kinds. SQLite's math
110
+ functions follow the guest's C library, so their last digits can differ:
111
+ over 199 inputs each, the trigonometric and hyperbolic functions, `atan2`,
112
+ `log10` and one-argument `log` did, and `exp`, `ln` and `sqrt` did not.
113
+ And what changed between SQLite releases (native's is 3.53.1) shows, such
114
+ as JSON's float text. WASI has no file locks, so SQLite uses its own WASI default,
115
+ and these facts follow from it:
116
+
117
+ - one connection holds the database, through a `<db>.lock` directory beside
118
+ it, while it writes or has a statement open (an unfinished `SELECT`
119
+ included); a second connection, even in the same program, then gets
120
+ "database is locked" after its busy timeout (5 s by default);
121
+ - `journal_mode=wal` silently stays `delete`, unless the connection first
122
+ sets `locking_mode=exclusive`;
123
+ - a guest stopped inside a transaction (killed, out of fuel, past its
124
+ deadline, or by a hard exit such as `os._exit`) leaves `<db>.lock` and a hot journal; in a
125
+ reused root the embedder removes the lock directory, and SQLite then rolls
126
+ the journal back; a call with `root=True` never sees a stale lock, since
127
+ its root is fresh (with no root, the default, a database file can live only
128
+ in a writable grant);
129
+ - SQLite's own randomness (`random()`, `randomblob()`, its temporary file
130
+ names) is seeded from the time in whole seconds, because the guest has no
131
+ `/dev/urandom` (the process id is always 1 on WASI): two calls in the same
132
+ second repeat it, and a `/dev/urandom` planted in a reused root sets it;
133
+ use `secrets`, `uuid.uuid4()` or `os.urandom` for IDs, which use real
134
+ entropy;
135
+ - a database in a read-only directory opens only through a URI that takes
136
+ no lock (`uri=True`), such as `file:<path>?immutable=1` or
137
+ `file:<path>?nolock=1`, because the lock is a directory made beside it;
138
+ `mode=ro` alone is not enough;
139
+ - no `load_extension`; `sqlite3.threadsafety` is 0 (native: 3);
140
+ - `sysconfig` reports `_bz2` and `_lzma` as missing, `_sqlite3` as
141
+ disabled and `PY_SQLITE_HAVE_SERIALIZE` as 0, because they are linked in
142
+ through `Setup.local`, which CPython's `configure` never sees; all three
143
+ import, and `Connection.serialize` works;
144
+ - `shelve` and `dbm` now create `dbm.sqlite3` files, as native does, so a
145
+ shelf holds the lock directory too.
146
+
147
+ Temporary data goes to files under `TMPDIR` (`/tmp` by default in `run`).
148
+
149
+ ## Hosts
150
+
151
+ The binaries use WebAssembly's exception-handling proposal in its standard
152
+ form (`exnref`, part of WebAssembly 3.0), so C++ exceptions work; a host
153
+ without it cannot load them. They also need multi-value and reference
154
+ types (WebAssembly 2.0). Only wasmtime 49 is run by this project; the rest
155
+ of the list is read from each runtime's release notes and source
156
+ (`context/research/2026-10-16-cpp-exceptions-and-wasi-hosts.md`):
157
+
158
+ - **runs by default:** wasmtime 47 or later (CLI, Rust, wasmtime-py; 37 to
159
+ 46 with `-W exceptions=y`); WasmEdge 0.16 or later (AOT 0.18 or later);
160
+ - **runs with a switch:** wasmer 6 or later on LLVM, V8 or Cranelift 7 or
161
+ later (not Singlepass); wazero 1.12 or later with its experimental
162
+ opt-in; Node 24.15 or later, or 25;
163
+ - **does not run:** WAMR, wasmi, wasmer-python, wasmer-go, wasmtime's
164
+ Winch, and wasmtime built without its `gc` feature.
165
+
166
+ For a host without exception handling, use `0.1.0a1` (CPython 3.14.8,
167
+ wasi-sdk 24): the `v0.1.0a1` release's assets, or `wasi-python==0.1.0a1`
168
+ once that version is on PyPI. On it a C++ `throw` ends the guest, and
169
+ matplotlib will never be built for it. C++ cannot be done on WASI without
170
+ the proposal under this project's rules (the memo above), so a 3.15 set
171
+ without it is not built unless an embedder needs one.
172
+
173
+ ## How it is built
174
+
175
+ One command, from pinned inputs; the source trees are their tarballs plus the
176
+ recorded patches (`recipes/patches/`: one today, numpy's `.npy` fix):
177
+
178
+ ```
179
+ scripts/00_download.sh # fetch every pin and verify it against downloads/SHA256SUMS
180
+ scripts/build.sh # eleven steps, one line each; logs in build/logs/
181
+ ```
182
+
183
+ The steps (`scripts/steps/`): unpack the toolchain and a pinned host Python
184
+ (CPython 3.15.0 from python-build-standalone) and install the build tools from
185
+ hashed wheels, offline; build CPython with its own `Platforms/WASI` driver;
186
+ build zlib, numpy and pandas outside their source trees, cross-compiling with
187
+ meson, and the C libraries behind `bz2`, `lzma` and `sqlite3` (bzip2, liblzma
188
+ from xz with xz's own `configure`, and SQLite's one-file amalgamation); link everything into one interpreter as builtin modules and relink it
189
+ as a WASI reactor; build the bytecode-only, deterministic stdlib zip; snapshot
190
+ the reactor with the pinned wasmtime's own `wasmtime wizer`, numpy and pandas
191
+ pre-imported; assemble
192
+ `build/artifacts/` with `MANIFEST`, `SHA256SUMS` and the licence notices of
193
+ everything inside. The compiler wrapper `scripts/wasi-cc` turns every
194
+ extension module's shared link into a static archive, so no `meson.build`
195
+ changes. The line's versions (CPython, wasi-sdk, the host Python) are named
196
+ once, in `scripts/line.conf`.
197
+
198
+ **The same bytes from any build host.** The build runs on arm64 macOS and
199
+ arm64 Linux, and both give the same artifact set, byte for byte: the same
200
+ `SHA256SUMS` over the three artifacts, `MANIFEST` and the licences
201
+ (`context/specifications/012-the-same-bytes-from-any-host.md`). x86_64 Linux
202
+ is a pinned build host too, but its rebuilds are not yet byte-identical: its
203
+ wasi-sdk compiler lays out one numpy function's stack differently, so its
204
+ `python.wasm` differs by 21 bytes and its snapshot with it; its zip, `MANIFEST`
205
+ and licences are the same
206
+ (`context/research/2026-10-17-x86-64-rebuilds-differ-in-one-function.md`). Each host's
207
+ own inputs (wasi-sdk, wasmtime, the host Python, the Cython and ninja wheels)
208
+ are named once per host in `scripts/line.conf` and fetched by that host's
209
+ branch of `scripts/00_download.sh`; every host's pins are in
210
+ `downloads/SHA256SUMS`. So `MANIFEST`'s `pin` lines name every supported build
211
+ host's inputs, not only the ones one build used (its line
212
+ `x-pins every-supported-build-host` says so), and `MANIFEST` is the same file
213
+ from every host. Four things make the bytes host-neutral: the host's wasi-sdk
214
+ keeps only its compiler, and every host links wasi-sdk's host-neutral sysroot
215
+ and builtins; the build sorts in the C locale; the snapshot sees a directory's
216
+ size and link count as constants; and the stdlib zip's `_sysconfigdata` and
217
+ numpy's `__config__.py` say nothing about the build machine. Inside the guest,
218
+ `sysconfig.get_config_var('BUILD_GNU_TYPE')` and `numpy.show_config()`'s build
219
+ machine therefore say `build`, not the host that built it. No compiler runs in
220
+ a WASI guest, so nothing reads them.
221
+
222
+ The recipe files under `recipes/`: two meson cross files, three rename headers
223
+ for symbols that only collide in a static link, the reactor entry, the door
224
+ module, a host-side numpy stand-in for pandas' build, and one-function stand-ins for `ctypes` and `mmap` that pandas
225
+ imports unconditionally.
226
+
227
+ One host input is not pinned: the native C compiler and `make` that CPython's
228
+ driver uses to build its own build-Python. Nothing it compiles is linked into
229
+ the guest, and Apple clang and gcc 14.2 give the same artifact bytes
230
+ (`context/research/2026-10-16-reproducible-builds-on-linux.md`). The build
231
+ also runs host tools that move or check bytes and are not pinned either:
232
+ `curl`, `shasum` (perl's), `tar`, `xz`, `sed`, `uv` (it installs the pinned
233
+ wheels, offline, by hash) and `git` (it lists the recipes for `MANIFEST`). The
234
+ release workflow prints each runner's versions of them. xz's own `configure`,
235
+ which configures liblzma, also runs the host's shell tools (`sh`, `sed`,
236
+ `grep`, `awk`); the same-bytes gate is what shows they leave the artifacts
237
+ alone.
238
+
239
+ **Building on a Mac with Finder open.** Finder writes `.DS_Store` files into
240
+ folders you browse, at any moment. Every shell step removes its old outputs
241
+ through `remove` (`scripts/steps/remove.sh`), and step 90 through its own
242
+ `_remove`; both retry. The zip, the licences
243
+ and the artifact set never pack such a file (spec 018). One case is left: a
244
+ `.DS_Store` written into the finished `build/artifacts/` makes the judge's
245
+ `SHA256SUMS` check fail, naming the file, because a wheel built from that
246
+ folder would carry it. Delete it with `find build/artifacts -name .DS_Store -delete`.
247
+
248
+ ## Releasing
249
+
250
+ Releases are alpha builds for now, numbered `0.1.0aN`. The version lives in one place,
251
+ `src/wasi_python/__init__.py`, and `release/version.py` changes it:
252
+
253
+ ```
254
+ uv run python release/version.py bump # 0.1.0a1 -> 0.1.0a2, and a CHANGELOG.md section
255
+ uv run python release/version.py release --push # date the section, tag v0.1.0a2, push: the release runs
256
+ ```
257
+
258
+ Pushing the tag starts `.github/workflows/release.yml`. It builds from the pins on arm64 macOS,
259
+ checks that the source trees are their tarballs plus the recorded patches, runs the whole judge, and checks the same bytes on Linux, Windows and
260
+ macOS. Beside the build, a `rebuild` job rebuilds the set from the pins on an arm64 Linux runner,
261
+ and `same-bytes` requires its `SHA256SUMS` to equal the set's, byte for byte; nothing is signed
262
+ or published until it passes. x86_64 Linux is not in this gate yet (above). Then it publishes
263
+ a GitHub pre-release with the wheel, the artifact set as `.tar.xz`, and
264
+ their `SHA256SUMS`, and uploads the wheel to PyPI as `wasi-python` through its trusted publisher (from 0.1.0a3). A manual run of
265
+ the workflow is a dry run that never publishes (`context/specifications/004-alpha-releases.md`).
266
+
267
+ Before publishing, an `attest` job signs build provenance for every asset and verifies it
268
+ strictly (`context/specifications/009-release-security.md`). If it fails, nothing is published:
269
+ re-run the failed jobs of the same workflow run (its artifacts are kept 90 days); never push the
270
+ tag again. Dry runs never sign, since a signature is permanent in Sigstore's public log.
271
+
272
+ ## Verifying a download
273
+
274
+ From the first release after `0.1.0a1` (which has no attestation), every release asset carries
275
+ build provenance signed by this repository's release workflow at the release's tag:
276
+
277
+ ```
278
+ gh attestation verify <asset> --repo ClayGendron/wasi_python \
279
+ --signer-workflow ClayGendron/wasi_python/.github/workflows/release.yml@refs/tags/v<version> \
280
+ --source-ref refs/tags/v<version> --deny-self-hosted-runners
281
+ ```
282
+
283
+ `--signer-workflow` matches a prefix of the signer's identity, so it carries the `@` and the
284
+ tag, which pins the workflow file exactly. `gh` must be logged in for this. Without `gh`, check
285
+ the asset against the release's `SHA256SUMS`.
286
+
287
+ ## Running it
288
+
289
+ From Python, one call (`pip install "wasi-python[run]"`, which brings
290
+ wasmtime-py):
291
+
292
+ ```python
293
+ from wasi_python import run
294
+
295
+ done = run(["-c", "import pandas as pd; print(pd.Series([1, 2, 3]).sum())"])
296
+ done.exit_code, done.stdout # (0, b'6\n')
297
+ ```
298
+
299
+ By default the guest has nothing writable: no `/`, no `/tmp` and no
300
+ `HOME`, only the read-only stdlib and whatever the embedder grants (since
301
+ 0.1.0a3; the owner's rule, constitution §4.15). It can import, compute,
302
+ print, read the stdlib and call the door; `open('x', 'w')`, `tempfile`,
303
+ `np.save` or `df.to_csv('x.csv')` fail with `OSError`. `root=True` gives the
304
+ call a fresh private directory as its `/`, writable, removed afterwards
305
+ (what a call got by default before 0.1.0a3); `root=` a path gives it that host
306
+ directory instead, writable, at `/`. `grants=` hands it host folders, each
307
+ read-only unless it says otherwise (see *Grants*). The stdlib zip is never in a root: every call sees it read-only
308
+ at `/usr/local/lib`. `run` also takes `env`, `stdin`, `host` (a function from
309
+ request bytes to response bytes, bound to the door), `snapshot` (the
310
+ default; `False` runs the plain reactor), `fuel` (a cap on executed
311
+ instructions), `deadline_s`, `memory_bytes`, `output_bytes`, `cache_dir`,
312
+ `artifacts` and `cancel` (a `threading.Event`: once it is set, the guest
313
+ stops, `trapped` is `"cancelled"`; see *From async code* for how soon). It
314
+ returns `exit_code`, `stdout`, `stderr`, `trapped` (`"out of fuel"`,
315
+ `"deadline"`, `"cancelled"`, a trap's kind such as `"stack overflow"`,
316
+ `"unreachable"` or `"memory out of bounds"`, or, for an error with no trap
317
+ code, its first line), `fuel_used` and
318
+ `truncated` (the streams whose output hit the cap). `fuel_used` counts the
319
+ guest's own instructions from the end of instantiation, so it is the same
320
+ whether the module was compiled or loaded from the `.cwasm` cache (measured
321
+ on macOS; Linux maps the memory image itself and never charged for it, and
322
+ Windows, which always copies it, is expected to match)
323
+ (`context/research/2026-10-17-fuel-depends-on-how-the-module-was-made.md`).
324
+
325
+ The compiled module is cached as a `.cwasm` in the user cache directory, or
326
+ in `$WASI_PYTHON_CACHE`, only where no other user can write (a `.cwasm` is
327
+ native code); `cache_dir=False` keeps it in memory only. On Windows the
328
+ runner cannot check who may write a folder, so `cache_dir` and
329
+ `$WASI_PYTHON_CACHE` must name a folder only you can write; the default, in
330
+ your own profile, is one. Measured on Apple
331
+ arm64 (spec 001's landing notes): the first call compiles for about 3 s, a
332
+ first call with the `.cwasm` on disk takes about 0.1 s, and later calls in
333
+ one process about 20 to 25 ms. The runner finds the artifact set through
334
+ `wasi_python.artifacts()`: `$WASI_PYTHON_ARTIFACTS`, else a source
335
+ checkout's `build/artifacts/`, else the wheel's own copy.
336
+
337
+ ### Grants
338
+
339
+ ```python
340
+ from pathlib import Path
341
+
342
+ from wasi_python import Grant, run
343
+
344
+ data = Path.home() / "project" / "data"
345
+ done = run(
346
+ ["-c", "import os; open('/out/n.txt', 'w').write(str(len(os.listdir('/data'))))"],
347
+ grants=[Grant(data, "/data"), Grant("/tmp/job-17", "/out", writable=True)],
348
+ )
349
+ ```
350
+
351
+ `Grant(host, guest, *, writable=False)` gives the guest the host folder `host`
352
+ at the guest path `guest`, read-only unless `writable=True`. A read-only grant
353
+ refuses every write-type call with `EPERM` (`PermissionError` in Python). Grants
354
+ may be nested (`/data` read-only with `/data/out` writable), in any order: the
355
+ guest's C library picks the longest matching guest path. `HOME` is made fresh
356
+ in the folder the guest sees at `/tmp`: a writable grant there, else the root's
357
+ own `tmp`; with neither, there is no `HOME`. With no root (the default) there
358
+ is no `/`, and a relative path fails.
359
+
360
+ State that must outlive a call (an agent's notes between turns, say) lives in
361
+ a writable grant the embedder keeps: each call reads it at the start and
362
+ writes it at the end.
363
+
364
+ ```python
365
+ state = Path.home() / "agent-17" # the embedder's folder for this agent
366
+ state.mkdir(exist_ok=True)
367
+ run(["-c", "import json, os; p = '/state/n.json'; n = json.load(open(p)) if os.path.exists(p) else 0;"
368
+ " json.dump(n + 1, open(p, 'w')); print(n + 1)"],
369
+ grants=[Grant(state, "/state", writable=True)])
370
+ ```
371
+
372
+ Every grant, and `root`, is checked before the guest is set up, and refused
373
+ with `TypeError` or `ValueError` (the runner may first make its own module
374
+ cache, so that it is judged as it will be):
375
+
376
+ - a guest path must be absolute, with no empty, `.` or `..` part and no
377
+ trailing `/`; not `/` (that is `root`), not `/usr/local/lib` or under it
378
+ (the runtime's), and no two grants may share one;
379
+ - a host path is walked one folder at a time and never through a link in a
380
+ folder you can write, which a guest could have made (on Windows, never
381
+ through any link, junction or other reparse point, which includes
382
+ OneDrive's cloud folders and some container layers); a folder you may
383
+ search but not list is walked through, as wasmtime opens it; a grant's
384
+ folder must exist, and a missing `root` is made. Run as the superuser,
385
+ every folder counts as writable, so every link on the way is refused
386
+ (macOS's `/tmp` and `/var` included): name the real path;
387
+ - a read-only grant inside a writable one, or `root`, is refused: the guest
388
+ could change it through the other;
389
+ - a writable grant or `root` at, inside or above the runtime's own folders
390
+ is refused, since later calls and processes trust them: the stdlib folder
391
+ (or the temporary folder that holds it), the artifact set, the module
392
+ cache, the code of `wasi_python` and wasmtime-py themselves, and the host
393
+ Python's own `stdlib`, `site-packages` (the user's too) and `bin` (or
394
+ `Scripts`) folders. So
395
+ `root=Path.home()`, a project folder holding the `.venv` this process runs
396
+ from or `build/artifacts`, and the temporary folder itself are refused;
397
+ - any grant, read-only too, at or above the temporary folder is refused: it
398
+ holds every call's private folders and `stdin`;
399
+ - folders are compared by identity (device and inode), never by spelling.
400
+
401
+ After wasmtime opens each folder itself, the runner checks, before the guest
402
+ runs, that it opened the folder it checked; a folder swapped in between
403
+ raises `RuntimeError`. A folder removed between the check and wasmtime's own
404
+ open raises wasmtime's `WasmtimeError`. The runner's own preparation of a
405
+ `root` (its `usr/local/lib` and `tmp`) goes through the folder it checked,
406
+ never its path, so a folder swapped above it changes nothing elsewhere.
407
+
408
+ What grants do not do:
409
+
410
+ - `..` never crosses from one grant into another, and a grant does not appear
411
+ in a listing of its parent;
412
+ - a read-only grant can still change if the same host files are writable
413
+ elsewhere, by another call or another program;
414
+ - a SQLite database in a read-only grant opens only through a URI that takes
415
+ no lock (`file:/data/db.sqlite?immutable=1`);
416
+ - a folder reachable by two routes (a bind mount, a share mounted twice) is
417
+ judged by the route you give. Each route is checked against the runtime's
418
+ files and the other grants by identity, which a bind mount shares and a
419
+ second mount of a network share may not.
420
+
421
+ ### From any language
422
+
423
+ `wasi-python run` runs one program and prints its result as one line of JSON, so
424
+ any language can call the runner as a subprocess:
425
+
426
+ ```
427
+ $ wasi-python run -- -c "print(1)"
428
+ {"exit_code": 0, "fuel_used": null, "stderr": "", "stdout": "1\n", "trapped": null, "truncated": []}
429
+ ```
430
+
431
+ ```typescript
432
+ import { spawn } from "node:child_process";
433
+
434
+ const child = spawn("wasi-python", ["run", "--deadline-s", "10", "--", "-c", code]);
435
+ let out = "";
436
+ child.stdout.on("data", (chunk) => (out += chunk));
437
+ child.on("error", (err) => console.error("wasi-python could not start:", err.message));
438
+ child.on("close", (status) => {
439
+ if (status === 0) console.log(JSON.parse(out)); // how the program ended
440
+ else if (status === 1) console.error(JSON.parse(out).error); // run() refused or failed
441
+ else console.error(`wasi-python exited ${status}`); // 2: usage error; 130: interrupted
442
+ });
443
+ ```
444
+
445
+ Each option is one of `run`'s arguments, and nothing else: `--env KEY=VALUE`
446
+ (or `--env KEY` to pass this command's own value, which keeps a secret off the
447
+ command line), `--stdin PATH` (or `-` for this command's input), `--deadline-s`,
448
+ `--memory-bytes`, `--output-bytes` (each a number, or `none` for no limit),
449
+ `--fuel` and `--no-snapshot`. Without an option the call has `run`'s defaults.
450
+ Everything after `--` is the program's arguments.
451
+
452
+ Folders, each one of `run`'s grants (see *Grants*): `--grant GUEST=HOST` gives the
453
+ program a host folder read-only, `--grant-writable GUEST=HOST` writable; the split
454
+ is at the first `=`, so `C:\data` and host paths with `=` pass whole. `--root
455
+ HOST` is the guest's `/`, writable, and made if missing; `--private-root` gives
456
+ it a fresh private `/`, removed after the call (`root=True`); without either the
457
+ program has no `/` at all, only its grants (`--no-root` says so explicitly).
458
+ `--root` given twice, or two of the three together, is a usage error. A guest path given here cannot hold `=`. `run`'s
459
+ errors number the grants from 0, across both options, in the order typed. The
460
+ program itself is read from the host with `--stdin`, since the guest does not
461
+ see the host's folders, only its root and its grants:
462
+
463
+ ```
464
+ $ wasi-python run --grant /data=./inputs --grant-writable /out=./results --stdin prog.py -- -
465
+ ```
466
+
467
+ The line's keys: `exit_code`, `trapped`, `fuel_used` and `truncated`, as
468
+ `run` returns them, and `stdout` and `stderr` as text, or `stdout_base64` and
469
+ `stderr_base64` when the bytes are not UTF-8 (output cut at `--output-bytes`
470
+ in the middle of a character is not). The command's own exit code says whether
471
+ there is a result, not how the program ended: 0, a result; 1, `run` refused or
472
+ failed, with `{"error": …, "kind": …}`; 2, a usage error, on standard error;
473
+ 130, Ctrl-C. A program's exit code 3 is `"exit_code": 3` with the command's 0.
474
+
475
+ Two cautions. The line can be up to 6 times the program's output (JSON
476
+ escaping), so collect standard output in full: Node's `exec` stops at 1 MiB by
477
+ default. A long program belongs in a file, `--stdin prog.py -- -`, not in `-c`,
478
+ which meets the system's limit on argument length.
479
+
480
+ ### From async code
481
+
482
+ `run_async` takes `run`'s arguments except `cancel` (cancel its task
483
+ instead), plus `executor`, and runs the call on
484
+ a worker thread, so the event loop never waits for a guest. wasmtime
485
+ releases the GIL while wasm runs, so guests on different workers run in
486
+ parallel. An ASGI endpoint (FastAPI shown):
487
+
488
+ ```python
489
+ import asyncio
490
+ from wasi_python import run_async
491
+
492
+ @app.post("/run")
493
+ async def run_code(code: str):
494
+ async with asyncio.timeout(30): # also bounds the time spent waiting for a worker
495
+ done = await run_async(["-c", code])
496
+ return {"exit_code": done.exit_code, "stdout": done.stdout.decode()}
497
+ ```
498
+
499
+ - **Cancelling the task stops its guest.** The runner checks the cancel
500
+ every 50 ms while the guest computes, and at once when it waits or calls
501
+ the door; the check and the clean-up need the GIL, so other busy Python
502
+ threads delay them (measured: up to 200 ms to stop and 460 ms to return
503
+ with one other thread spinning). The worker is freed and the private
504
+ root removed before `CancelledError` (or the timeout's `TimeoutError`)
505
+ reaches the caller. A call still waiting for a worker never starts. A guest inside
506
+ a door host is stopped only when the host returns: the runner cannot
507
+ interrupt host code.
508
+ - **What cancels the task** is your code: a timeout, a task group,
509
+ shutdown. Starlette does not cancel a plain endpoint's task when the
510
+ client disconnects, so without a timeout that guest runs to its
511
+ `deadline_s`.
512
+ - **How many run at once.** With no `executor`, calls share one pool per
513
+ process of `DEFAULTS.async_workers` threads: the CPU count (with CPU
514
+ affinity, from Python 3.13), at most 8. Further calls wait for a worker.
515
+ At the defaults each running guest may hold 1 GiB of memory, up to
516
+ 32 MiB of output and a copy of its `stdin`, so 8 workers bound one
517
+ process at about 8.3 GiB, plus the disk a guest writes and the file
518
+ descriptors it opens. Four server worker processes have four pools: four
519
+ times that. A container's CPU quota is not seen; set the size with
520
+ `wasi_python.runner.DEFAULTS = dataclasses.replace(DEFAULTS,
521
+ async_workers=n)`. The bound covers only `run_async` on its own pool, not
522
+ `run` called from your threads, an `executor` you pass, or a
523
+ framework's thread pool for synchronous endpoints.
524
+ - **A synchronous endpoint** that calls `run` also keeps the loop free (the
525
+ framework runs it on a thread), but it cannot be cancelled.
526
+ - **`executor`** must run callables on threads of this process; a process
527
+ or interpreter pool is refused.
528
+ - **Forking.** A process forked after it ran a guest refuses to run guests
529
+ (`RuntimeError`): wasmtime does not support use across a fork. Warm the
530
+ runner up after the fork (in gunicorn's `post_fork`, say), never before.
531
+ - **Exit.** At interpreter exit, `run_async`'s running guests are stopped
532
+ together and its queued calls dropped, so exit waits about as long as
533
+ one cancel, longer for a worker inside a door host or still compiling.
534
+ On a Python without `threading._register_atexit` (CPython has had it
535
+ since 3.9) the stop runs later, and exit can wait for queued calls.
536
+ - **A door host that must await** runs the coroutine on its own loop with
537
+ `asyncio.run_coroutine_threadsafe(coro, loop).result(timeout=...)`;
538
+ without a timeout a closing loop blocks it forever.
539
+ - `run_async` is asyncio only. Under trio, call `run(cancel=event)` from a
540
+ worker thread.
541
+
542
+ Any other embedder does the same three things:
543
+
544
+ 1. **Preopen the artifact set's stdlib zip, read-only, in a folder of its
545
+ own at `/usr/local/lib`** (the guest finds `/usr/local/lib/python315.zip`),
546
+ plus whatever folders the embedder grants, possibly none, a folder at `/`
547
+ included. The snapshot runs only beside the zip it was built against.
548
+ 2. **Bind the one custom import, `host::call(ptr, len, out_ptr_ptr,
549
+ out_len_ptr) -> i32`.** Answer −1 when there is no host. Otherwise read
550
+ the request from guest memory, get space from the exported `host_alloc`,
551
+ write the response, store its pointer and length at the two
552
+ out-addresses, and answer 0; answer −2 if the host fails. Frames are
553
+ opaque bytes: their meaning is the embedder's.
554
+ 3. **Call `_initialize`** (the plain reactor only) **and then `run`**,
555
+ once per instance, with argv (`argv[0]` is `/usr/local/bin/python.wasm`)
556
+ and the environment given through WASI. `run` returns the exit status.
557
+
558
+ The plain `wasmtime run` CLI cannot bind a custom import and refuses the
559
+ binary. With `-W unknown-imports-default` it runs it, but the door then
560
+ answers 0 with no response, which guest code reads as an empty answer.
561
+
562
+ ## Safety
563
+
564
+ Made for running code nobody reviewed, such as a model's. The policy:
565
+ code planted in a root never runs at start-up or on import in a later call,
566
+ on either binary; data planted in a root a caller reuses is read by later
567
+ calls, and only a fresh root closes that; a call that names no limit still
568
+ has one.
569
+
570
+ **Enforced, and judged** (`tests/checks/test_v1_2_*.py`):
571
+
572
+ - the guest sees only its root: no host path, no `..` out, and the guest
573
+ cannot follow a symbolic link out (refused on open). It can still
574
+ *create* a relative link that points out, which matters to host code that
575
+ reads its root afterwards (see "Not protected");
576
+ - no network, no processes, no shared libraries; two imports only, WASI
577
+ preview1 and the one door;
578
+ - only the stdlib zip is on the import path, and `site` does not run, so
579
+ modules, `.pth` files or a `pyvenv.cfg` planted in a root never run
580
+ (`sys.flags.no_site` is 1; `exit`, `quit`, `help` and `license` exist
581
+ as in native Python);
582
+ - every door frame is bounded by guest memory, and nothing a frame or a
583
+ host function does makes `run` raise;
584
+ - the deadline holds while the guest waits: the runner answers
585
+ `poll_oneoff` itself (`time.sleep`, `select`), in slices that stop at the
586
+ deadline, as wasmtime answers it except as spec 010 states (a directory or
587
+ an unopened descriptor is reported ready; a bad pointer answers `EFAULT`);
588
+ more than 4,096 subscriptions answer `ENOMEM` (`select.poll()` on more than
589
+ 2,047 descriptors with its default mask);
590
+ - **defaults** (`wasi_python.runner.DEFAULTS`): 1 GiB of memory, a 60 s
591
+ deadline, 16 MiB of output per stream (the rest dropped and named in
592
+ `truncated`); pass `None` for no limit. A limit of the wrong type or out
593
+ of range (a negative cap, `True`, a deadline of 0) raises `TypeError` or
594
+ `ValueError` before any work, so no value can quietly remove a limit;
595
+ integer limits take any integer type (numpy's included) from 0 to
596
+ 2**63 - 1, and a deadline is a positive real number of seconds, counted in
597
+ ticks of 10 ms (any positive deadline is at least one tick). The
598
+ guest's environment starts from `TMPDIR=/tmp`, `PYTHONTZPATH=`
599
+ (`zoneinfo` on the zip's tzdata) and
600
+ a `HOME` made fresh and empty for each call in the folder the guest sees at
601
+ `/tmp` and removed after it (no `HOME` where there is none); `env=` is laid
602
+ over them;
603
+ - **grants** are read-only unless they say otherwise, checked before any
604
+ work, and checked again after wasmtime opens them (see *Grants*); the stdlib
605
+ zip is a read-only grant on every call;
606
+ - **inputs**: `argv` is any iterable of `str`, `env` a mapping of `str` to
607
+ `str`, `stdin` a bytes-like object. One the guest would see differently
608
+ (a NUL, text that is not UTF-8, an `=` or nothing as an env key) raises
609
+ `ValueError` before any work, and a wrong type `TypeError`; no message
610
+ quotes a value;
611
+ - each call's store is closed when it ends, trapped or not, so its memory
612
+ and open files are released at once; a process copies the 11 MB stdlib
613
+ zip once, into a folder of its own that its exit removes;
614
+ - calls made through `run` share nothing with each other but the stdlib
615
+ zip's folder, which each sees read-only: the host
616
+ functions are defined once per engine and serve only the call whose store
617
+ called them, and each call's output goes to its own buffer, never through
618
+ wasmtime-py's shared callback tables; whatever a host function catches (a
619
+ trap in the guest's allocator, a host's `SystemExit` or `CancelledError`,
620
+ Ctrl-C) stops that call and is reported or raised by that call's `run`, on
621
+ its own thread. While `run` holds the main thread under Python's default
622
+ SIGINT handler, Ctrl-C ends the call at the guest's next wait or door
623
+ call, or during a wait, or else at its deadline; an embedder's own
624
+ handler is left alone.
625
+
626
+ To report a way around any of this, see [SECURITY.md](SECURITY.md).
627
+
628
+ **Not protected:** a killed process (mid-call, its private root and stdin
629
+ folder; at any time after its first call, its 11 MB stdlib zip folder; each
630
+ with its lock file, stay in the temporary folder until another
631
+ process using wasi_python on the same kernel, with the same temporary
632
+ folder, starts its first call; folders from releases before 0.1.0a3 have no
633
+ lock file and are never removed: delete `wasi-python-*` entries there by hand
634
+ when no wasi_python process runs; two machines that share one temporary
635
+ folder and report the same identity, such as cloned Windows images, are not
636
+ told apart; a temporary folder on a filesystem without file locks, where such
637
+ folders are never removed); disk use in any
638
+ root, the private one included (a guest
639
+ can write as fast as the disk takes it until its deadline, tens of GiB in
640
+ the default 60 s; point `tempfile.tempdir`, or `root`, at a size-limited
641
+ volume if that matters); links a guest leaves in a granted root that point
642
+ outside it (host code that reads or writes the root after a call must not
643
+ follow links: open with `O_NOFOLLOW`, or check `os.lstat` first);
644
+ the host's file
645
+ descriptors (a guest can hold thousands of open files, which the embedder's
646
+ process shares; wasmtime-py offers no lower limit; while one guest holds them
647
+ all, the embedder's own code and other calls' `run` can fail with `OSError`,
648
+ though that guest's own call ends cleanly); data planted in a
649
+ reused root (dateutil's zone files, `mimetypes`' tables, any user file, the
650
+ plain reactor's `sys.prefix` after a planted `pyvenv.cfg`); pdb's `./.pdbrc`
651
+ in the current directory, `/`, which pdb runs as code when a program starts
652
+ pdb, as native pdb does (a fresh root closes it); a script or module the caller names to run
653
+ from a reused root; concurrent calls on one root, or a guest that can write a folder above
654
+ another call's root (it can hard-link or change the files that call uses,
655
+ and make that call's `run` raise `ValueError`, `RuntimeError`,
656
+ `WasmtimeError` or `OSError`; nothing outside the grants changes); a guest that waits
657
+ (`time.sleep`, `select`) in a call with `deadline_s=None`, which waits as
658
+ long as it asks (no instruction runs, so fuel does not stop it); Ctrl-C
659
+ for a guest that never waits or calls the door in a call with
660
+ `deadline_s=None` and no `cancel` (it runs on; with `cancel=`, the runner's
661
+ check ends it); Ctrl-C under an embedder's SIGINT handler
662
+ that raises, asyncio's on a second Ctrl-C included, while a guest runs on
663
+ the main thread: wasmtime-py, or under `cancel=` the runner's epoch
664
+ callback, can then abort the process, so call `run` on a worker thread
665
+ (`run_async`, `asyncio.to_thread`, a thread pool) there; other code in the
666
+ same process using wasmtime-py directly, whose exceptions can reach a
667
+ trapping `run` through wasmtime-py's shared exception slot; an `env=` that
668
+ overrides `HOME` or `PYTHONTZPATH` on a reused root (pdb then reads a
669
+ `.pdbrc`, and `zoneinfo` a zone file, that a guest may have planted there);
670
+ `stdin`, which is written to a host temporary file of its size; SQLite's own
671
+ randomness (`random()`, `randomblob()`, its temporary names), which is seeded
672
+ from the clock's whole second because the guest has no `/dev/urandom`, so two
673
+ calls in the same second repeat it and a `/dev/urandom` planted in a reused
674
+ root sets it (Python's `random`, `secrets`, `os.urandom` and `tempfile` names
675
+ use real entropy); a stale SQLite lock and hot journal left in a reused root
676
+ by a guest stopped inside a transaction (see *sqlite3 in the guest*); an `argv`
677
+ or `env` larger than the guest's memory, which exits 1 with empty stderr;
678
+ timing
679
+ (the clock is real and fine-grained); real entropy; the one-byte exit status.
680
+
681
+ ## Scope
682
+
683
+ **In the first release:** CPython, numpy, pandas, zlib, python-dateutil,
684
+ tzdata, six, the host door, the snapshot. **Since then:** `sqlite3`, `bz2` and
685
+ `lzma` (SQLite 3.54.1, bzip2 1.0.8, liblzma from xz 5.8.4).
686
+
687
+ **Out, with the one fact that decides each:** matplotlib (its data folder
688
+ needs a decision on the guest's filesystem; then FreeType, HarfBuzz, qhull,
689
+ kiwisolver, contourpy, Pillow); scipy (Fortran: f2c as Pyodide does; months);
690
+ threads; BLAS and SIMD; `_ctypes`, `mmap`, sockets, `_ssl`; `_zstd`
691
+ (`compression.zstd`: libzstd needs its own research and licence approval);
692
+ SQLite's `load_extension` (WASI has no `dlopen`); pip inside the guest.
693
+
694
+ ## Next
695
+
696
+ In order. Each item is a specification or a research memo under
697
+ `context/`; `uv run pytest` names what blocks the most checks (its `next:`
698
+ line). The runner, the door, the reactor, the build, the release workflow
699
+ and the 3.15 line have landed (specs 001, 003, 004 and 005). The direction
700
+ past these is in
701
+ `context/research/2026-10-16-a-principal-engineers-direction.md`.
702
+
703
+ 1. x86_64 Linux rebuilds byte-identical
704
+ (`context/research/2026-10-17-x86-64-rebuilds-differ-in-one-function.md`):
705
+ its wasi-sdk compiler lays out one numpy function's stack differently;
706
+ the cause is found, the fix is not.
707
+ 2. numpy's and pandas' own test subsets under the runtime.
708
+
709
+ Decided already, without further study: both binaries ship, the snapshot
710
+ first; the stdlib zip is bytecode-only and deflated; the int32 default
711
+ integer is documented, not patched.
712
+
713
+ ## How this repo is organized
714
+
715
+ - `recipes/`, `scripts/`, `fixtures/`, `downloads/SHA256SUMS`: the build.
716
+ - `src/wasi_python/`: the wheel. `tests/`: its tests and the progress
717
+ harness; `uv run pytest` prints the v1 scoreboard and guards it with a
718
+ ratchet (`context/progress/README.md`).
719
+ - `context/`: the project's memory. `prior_art/` holds shallow clones we
720
+ study and never copy (git-ignored, indexed in its README); `research/`
721
+ holds dated memos, executed or marked placeholder; `specifications/`
722
+ holds numbered work packages; `reviews/` holds the review of each built
723
+ change before it lands. See `context/README.md`.
724
+ - `AGENTS.md`: the rules an agent follows here.
725
+
726
+ ## Evidence
727
+
728
+ `context/research/2026-10-16-the-spike-that-proved-the-build.md` is the
729
+ executed spike this project grew from: every command, every number.
730
+ Its outputs, logs and generated link lines are in
731
+ `context/research/2026-10-16-the-spike-that-proved-the-build/`, and
732
+ `context/research/2026-10-16-the-spike-evidence-in-this-repository.md`
733
+ maps the spike's paths to this repository's. The spike ran inside
734
+ another repository, the first consumer of this runtime; nothing here
735
+ needs it.
736
+
737
+ ## Why WASI and not "wasm"
738
+
739
+ WebAssembly is the machine: an instruction set and a file format. WASI is
740
+ the operating system interface the binary talks to: files, clock, args,
741
+ stdout. This binary needs a WASI host and nothing else. "Python in wasm"
742
+ usually means Pyodide, which needs a browser or Node through Emscripten's
743
+ JavaScript glue. This is the other thing.