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.
- wasi_python/__init__.py +91 -0
- wasi_python/__main__.py +5 -0
- wasi_python/artifacts/LICENSES/bzip2.txt +44 -0
- wasi_python/artifacts/LICENSES/cpython.txt +1573 -0
- wasi_python/artifacts/LICENSES/llvm-runtimes.txt +1252 -0
- wasi_python/artifacts/LICENSES/numpy.txt +934 -0
- wasi_python/artifacts/LICENSES/pandas.txt +1097 -0
- wasi_python/artifacts/LICENSES/python-dateutil.txt +56 -0
- wasi_python/artifacts/LICENSES/six.txt +20 -0
- wasi_python/artifacts/LICENSES/sqlite.txt +34 -0
- wasi_python/artifacts/LICENSES/tzdata.txt +221 -0
- wasi_python/artifacts/LICENSES/wasi-libc.txt +688 -0
- wasi_python/artifacts/LICENSES/xz.txt +85 -0
- wasi_python/artifacts/LICENSES/zlib.txt +24 -0
- wasi_python/artifacts/MANIFEST +94 -0
- wasi_python/artifacts/SHA256SUMS +16 -0
- wasi_python/artifacts/python-snapshot.wasm +4 -0
- wasi_python/artifacts/python-stdlib.zip +0 -0
- wasi_python/artifacts/python.wasm +0 -0
- wasi_python/cli.py +225 -0
- wasi_python/runner.py +2527 -0
- wasi_python-0.1.0a3.dist-info/METADATA +743 -0
- wasi_python-0.1.0a3.dist-info/RECORD +26 -0
- wasi_python-0.1.0a3.dist-info/WHEEL +4 -0
- wasi_python-0.1.0a3.dist-info/entry_points.txt +2 -0
- wasi_python-0.1.0a3.dist-info/licenses/LICENSE +190 -0
|
@@ -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.
|