Run Python
View SourceCPython compiled to WebAssembly runs Python scripts inside the sandbox with the standard library, no filesystem beyond what you grant, no network, and a time and memory cap per run. You need it when the logic arrives as Python and must not be able to reach the host.
This is not Pyodide. Pyodide is CPython built with Emscripten for browsers
and Node: its runtime is JavaScript, so it needs a JavaScript host and cannot
load into Wasmtime. The build below is CPython's own wasm32-wasi target,
which imports only wasi_snapshot_preview1.
Get the interpreter
curl -fsSLo python-wasi.zip \
https://github.com/brettcannon/cpython-wasi-build/releases/download/v3.14.7/python-3.14.7-wasi_sdk-24.zip
mkdir -p /opt/python-wasi && unzip -q python-wasi.zip -d /opt/python-wasi
14 MB zipped, sha256 2e064d3fb8172471d39d741348efa722349c40b96301f69968dff714999c584b.
It unpacks to python.wasm (30 MB) and lib/python3.14/, the standard
library the interpreter reads at start.
Compile once, run many
{ok, Bin} = file:read_file("/opt/python-wasi/python.wasm"),
{ok, Python} = wasmtime:compile(Bin).Compiling the 30 MB module takes about 350 ms, once. A run instantiates it
fresh (1 ms) and mounts the unpacked directory read-only at / so the
interpreter finds lib/:
{ok, Inst} = wasmtime:instantiate(Python, #{
wasi => #{args => [~"python", ~"-c", ~"import json; print(json.dumps({'a': [1, 2, 3]}))"],
dirs => [{~"/", "/opt/python-wasi", read}],
stdout => capture, stderr => capture},
memory_limit => 512 * 1024 * 1024}),
{ok, []} = wasmtime:call(Inst, ~"_start", [], #{timeout => 30000}),
{ok, {~"{\"a\": [1, 2, 3]}\n", <<>>, _}} = wasmtime:read_output(Inst).Interpreter start-up dominates a small script: about 100 ms for the run above, most of it importing the standard library from the mounted directory.
Run a file
Grant a second directory for the script and give it arguments:
{ok, Inst} = wasmtime:instantiate(Python, #{
wasi => #{args => [~"python", ~"/app/main.py", ~"--fast"],
dirs => [{~"/", "/opt/python-wasi", read},
{~"/app", "/srv/scripts", read},
{~"/out", "/srv/output", write}],
env => [{~"GREETING", ~"hi"}],
stdout => capture, stderr => capture}}),
{ok, []} = wasmtime:call(Inst, ~"_start", [], #{timeout => 30000}),
{ok, {Out, Err, _}} = wasmtime:read_output(Inst).# /srv/scripts/main.py
import os, sys
print("args:", sys.argv[1:])
print("GREETING =", os.environ.get("GREETING"))
with open("/out/result.txt", "w") as f:
f.write("done\n")The script sees /, /app and /out and nothing else; only /out accepts
writes.
Handle failures
An uncaught exception exits with status 1 and its traceback on stderr;
sys.exit(3) gives status => 3:
{error, #{class := exit, status := 1}} = wasmtime:call(Inst, ~"_start", []),
{ok, {_, Traceback, _}} = wasmtime:read_output(Inst).An endless loop is stopped by the timeout:
{error, #{kind := timeout}} = wasmtime:call(Inst, ~"_start", [], #{timeout => 30000}).Talk to a running script
A script can stay up and serve requests over stdin and stdout made into
streams (see streams). A streamed stdout looks like a
terminal to the interpreter, so print flushes at each newline.
# /srv/scripts/worker.py
import sys, json
for line in sys.stdin:
req = json.loads(line)
sys.stdout.write(json.dumps({"sku": req["sku"], "price": 42}) + "\n"){ok, Inst} = wasmtime:instantiate(Py, #{
wasi => #{args => [~"python", ~"/app/worker.py"],
dirs => [{~"/", "/opt/python-wasi", read}, {~"/app", "/srv/scripts", read}],
stdin => stream, stdout => stream, stderr => capture},
stream => self()}),
{ok, Req} = wasmtime:call_async(Inst, ~"_start", []),
Ref = wasmtime:ref(Inst),
ok = wasmtime:send(Inst, [json:encode(#{sku => ~"A1"}), $\n]),
receive {wasmtime_stream, Ref, stdout, Line} -> json:decode(string:chomp(Line)) end,
ok = wasmtime:close(Inst),
{ok, []} = wasmtime:await(Inst, Req, 30000).Each line is one message. close/1 ends the input, the for loop
finishes and await returns.
A small wrapper
examples/py/py.erl packages the above:
1> c("examples/py/py.erl").
2> {ok, Py} = py:load("/opt/python-wasi").
3> py:eval(Py, ~"print(sum(range(10)))").
{ok, ~"45\n"}
4> py:eval(Py, ~"import os; print(os.environ['WHO'])", #{env => [{~"WHO", ~"erlang"}]}).
{ok, ~"erlang\n"}
5> py:eval(Py, ~"while True: pass", #{timeout => 200}).
{error, timeout}
6> py:eval(Py, ~"1/0").
{error, {exit, 1, ~"Traceback (most recent call last):\n File \"<string>\", line 1, in <module>\n 1/0\n ~^~\nZeroDivisionError: division by zero\n"}}
7> py:run_file(Py, "/srv/scripts", "hello.py").
{ok, ~"args: []\n"}Notes
- Input goes in through
args,env,stdin => {binary, Bytes}or a granted directory; output comes back withstdout/stderr => captureandread_output/1, through files, or a directory granted withwrite. CPython has no imports of its own beyond WASI, soimportshost functions do not apply. - No sockets, no subprocesses, no threads:
socket,subprocessandthreadingimport but fail at use.time.sleepworks (WASIpoll_oneoff). - Pure-Python packages work when placed under a granted directory on
PYTHONPATH(env => [{~"PYTHONPATH", ~"/app/site-packages"}]). Packages with C extensions need to be built forwasm32-wasi; that is where Pyodide's wheel ecosystem has no counterpart here. - One interpreter module serves any number of concurrent runs; each
instantiateis its own store, memory and thread. Budget about 30 MB of linear memory per run for the interpreter itself.