MicroPython packaged in WASM for wasmtime
Project description
micropython-wasm
MicroPython packaged as a WASI WebAssembly module and executed from Python using Wasmtime.
This project is an experimental Python package for running small snippets of MicroPython in a fresh WebAssembly sandbox. It is designed around:
- A custom MicroPython WASI artifact, not the Emscripten browser/Node build.
- The official
wasmtimePython package. - A fresh Wasmtime instance for one-shot execution, plus an optional persistent session API backed by a background thread.
- No host filesystem access unless an explicit read-only directory is preopened.
- No network capability.
- Configurable WebAssembly memory, fuel, and wall-clock controls.
Installation
Install from PyPI:
pip install micropython-wasm
For local development, use uv:
git clone https://github.com/simonw/micropython-wasm
cd micropython-wasm
uv sync --dev
Quick Start
from micropython_wasm import run
result = run("print(1 + 1)")
print(result.stdout)
Output:
2
run() returns a RunResult:
from micropython_wasm import run
result = run("print('hello')")
print(result.stdout) # "hello\n"
print(result.stderr) # ""
print(result.fuel_remaining) # integer Wasmtime fuel count
Each call creates a new engine, store, WASI config, module instance, and MicroPython process. Globals and imports do not persist between calls.
For stateful usage with real resident MicroPython state, use
MicroPythonSession:
from micropython_wasm import MicroPythonSession
with MicroPythonSession() as session:
print(session.run("x = 10\nprint(x)").stdout)
print(session.run("x += 5\nprint(x)").stdout)
print(session.run("print(x * 2)").stdout)
Output:
10
15
30
You can also use the same object without a context manager, which is convenient in an interactive Python REPL:
from micropython_wasm import MicroPythonSession
session = MicroPythonSession()
session.run("x = 10")
session.run("print(x)")
session.close()
API
run(code, ...)
Run MicroPython source code using the bundled artifact:
from micropython_wasm import run
result = run(
"print(sum(range(10)))",
memory_bytes=16 * 1024 * 1024,
fuel=5_000_000,
wall_timeout_seconds=1.0,
)
Arguments:
code: MicroPython source code passed asmicropython -c <code>.wasm_path: optional path to a custom WASI MicroPython artifact. If omitted,micropython_wasm/artifacts/micropython-wasi.wasmis used.memory_bytes: maximum WebAssembly linear memory for the store.fuel: Wasmtime fuel budget. Guest execution traps when it runs out.wall_timeout_seconds: wall-clock timeout. PassNoneto disable epoch interruption.readonly_dir: optional host directory to expose inside the guest as/input, with read-only WASI directory and file permissions.host_functions: optional mapping of host function names to Python callables. This enables the low-levelhost.call(name, payload_json)bridge.
run_micropython_wasi(code, wasm_path, ...)
Run code against an explicit .wasm artifact:
from micropython_wasm import run_micropython_wasi
result = run_micropython_wasi(
"print(2 ** 8)",
"micropython_wasm/artifacts/micropython-wasi.wasm",
)
This is useful when testing a locally rebuilt MicroPython artifact before copying it into the package.
MicroPythonSession(...)
Create a persistent MicroPython VM running in a background Python thread:
from micropython_wasm import MicroPythonSession
session = MicroPythonSession()
session.run("x = 10")
session.run("x += 5")
result = session.run("print(x)")
print(result.stdout)
session.close()
MicroPythonSession starts lazily on the first run(). A bootstrap loop
runs inside MicroPython and repeatedly calls back to the Python host for the
next code snippet. Each snippet is executed with exec(..., globals()) in the
same MicroPython VM, so variables, imports, functions, classes, and live objects
really stay resident between calls.
Because the VM stays alive, side effects from a previous snippet are not repeated by later snippets:
from micropython_wasm import MicroPythonSession
calls = []
def record(value):
calls.append(value)
return len(calls)
session = MicroPythonSession(host_functions={"record": record})
session.run("count = record('once')")
session.run("print(count)")
session.close()
print(calls) # ["once"]
MicroPythonSession accepts the same resource, filesystem, and host
function arguments as run():
session = MicroPythonSession(
memory_bytes=16 * 1024 * 1024,
fuel=5_000_000,
readonly_dir="fixtures",
host_functions={"add": lambda a, b: a + b},
)
Methods and properties:
session.run(code): run code in the resident VM and return aRunResult.session.register_function(func, name=None): expose a Python function to MicroPython code, optionally under a custom name.session.close(): send a close message to the guest loop and reject further runs.session.closed:Trueafterclose().session.host_functions: copy of registered host functions.- Context manager support:
with MicroPythonSession() as session: ....
Fuel is refreshed for each session.run() request. If a snippet exhausts fuel
or otherwise traps the guest, the background VM stops and the session should be
discarded.
MicroPythonReplaySession(...)
Create a transcript-backed session object with the same basic run() and
close() shape as MicroPythonSession, but without keeping a MicroPython VM
running in a background thread.
MicroPythonReplaySession does not run a background thread and does not keep a
live MicroPython VM between calls. Each run() call executes a fresh Wasmtime
instance and returns when that one command-style execution finishes. To preserve
variables, functions, classes, and imports from the caller's point of view, it
reconstructs state by replaying previous successful snippets before each new
snippet.
from micropython_wasm import MicroPythonReplaySession
session = MicroPythonReplaySession()
session.run("""
import math
def hypotenuse(a, b):
return math.sqrt(a * a + b * b)
""")
result = session.run("print(hypotenuse(3, 4))")
print(result.stdout)
session.close()
MicroPythonReplaySession accepts the same resource and filesystem arguments as
run():
session = MicroPythonReplaySession(
memory_bytes=16 * 1024 * 1024,
fuel=5_000_000,
wall_timeout_seconds=1.0,
readonly_dir="fixtures",
)
Methods and properties:
session.run(code): run code and return aRunResult.session.close(): clear the transcript and reject further runs.session.closed:Trueafterclose().session.snippets: tuple of successful snippets currently retained.- Context manager support:
with MicroPythonReplaySession() as session: ....
Only successful snippets are retained. If a snippet exits nonzero or traps, it is not added to the session transcript:
from micropython_wasm import MicroPythonReplaySession, MicroPythonWasmError
session = MicroPythonReplaySession()
session.run("x = 1")
try:
session.run("x = 2\nraise ValueError('boom')")
except MicroPythonWasmError:
pass
print(session.run("print(x)").stdout) # "1\n"
For MicroPythonReplaySession, each session.run() call creates a fresh guest
instance, replays previous successful snippets, emits an internal marker, then
runs the new snippet and returns only the output after that marker. This
preserves ordinary Python state from the caller's point of view, including
variables, functions, classes, and imports, but previous snippets are
re-executed internally on every call.
That replay behavior matters if previous snippets perform side effects such as
writing files, making time-dependent calculations, consuming randomness, or
mutating external host state. Use MicroPythonSession when you want true
resident in-VM persistence.
Host Functions
MicroPythonSession and MicroPythonReplaySession can expose regular Python
functions to MicroPython code. Register a function, then call it by name inside
the guest:
from micropython_wasm import MicroPythonSession
def add(a, b):
return a + b
session = MicroPythonSession()
session.register_function(add)
result = session.run("print(add(2, 3))")
print(result.stdout)
Output:
5
If the Python callable already has the name you want to expose, pass it directly:
def shout(value):
return value.upper() + "!"
session = MicroPythonSession()
session.register_function(shout)
print(session.run("print(shout('hello'))").stdout)
To expose a function under a different MicroPython name, pass name=:
def add(a, b):
return a + b
session = MicroPythonSession()
session.register_function(add, name="plus")
print(session.run("print(plus(2, 3))").stdout)
You can also provide functions when constructing the session:
def format_name(first, last, uppercase=False):
result = f"{first} {last}"
if uppercase:
result = result.upper()
return result
session = MicroPythonSession(
host_functions={"format_name": format_name},
)
print(session.run("print(format_name('Ada', last='Lovelace', uppercase=True))").stdout)
Arguments and return values cross the WebAssembly boundary as JSON. Supported
values are therefore JSON-compatible values: None, booleans, numbers, strings,
lists, and dictionaries with string keys.
Python-side exceptions are returned to the MicroPython wrapper and raised as
RuntimeError, so guest code can catch them:
def fail():
raise ValueError("bad host value")
session = MicroPythonSession(host_functions={"fail": fail})
result = session.run("""
try:
fail()
except RuntimeError as ex:
print(str(ex))
""")
print(result.stdout)
Output:
ValueError: bad host value
Under the hood the bundled MicroPython artifact includes a tiny built-in module
named host. That module imports micropython_wasm.host_call from Wasmtime and
exposes a low-level host.call(name, payload_json) function. The session API
builds friendly MicroPython wrappers on top of that low-level bridge.
One-shot run() and run_micropython_wasi() also accept a host_functions
mapping, but they do not automatically define friendly wrappers. They expose the
low-level host module:
from micropython_wasm import run
def add(a, b):
return a + b
result = run(
"""
import host
print(host.call("add", '{"args": [2, 3], "kwargs": {}}'))
""",
host_functions={"add": add},
)
print(result.stdout)
default_wasm_path()
Return the package's expected artifact path:
from micropython_wasm import default_wasm_path
print(default_wasm_path())
Exceptions
The package raises:
MicroPythonWasmArtifactNotFoundif the configured artifact does not exist.MicroPythonSessionClosedifsession.run()is called aftersession.close().MicroPythonWasmErrorfor guest traps, nonzero guest exits, invalid artifacts, missing Wasmtime support, or invalid preopened directories.ValueErrorfor invalid host-side resource limits.
For example:
from micropython_wasm import MicroPythonWasmError, run
try:
run('raise ValueError("boom")')
except MicroPythonWasmError as ex:
print(ex)
Filesystem Access
By default, the guest gets no preopened host directories:
from micropython_wasm import run
run("print('no files by default')")
To expose input files, place them in a directory and pass readonly_dir:
from pathlib import Path
from micropython_wasm import run
fixtures = Path("fixtures")
fixtures.mkdir(exist_ok=True)
(fixtures / "example.txt").write_text("hello from the host\n")
result = run(
"print(open('/input/example.txt').read())",
readonly_dir=fixtures,
)
print(result.stdout)
The directory is mounted at /input in the WASI guest. The package asks
Wasmtime for read-only directory and file permissions. Attempts to write inside
/input should fail.
Do not preopen your project root, home directory, /, or a shared temporary
directory when running untrusted code.
Resource Controls
The host configures these Wasmtime controls for each execution:
Store.set_limits(memory_size=...)limits WebAssembly linear memory.Store.set_fuel(...)limits CPU-like instruction progress.- Epoch interruption is enabled when
wall_timeout_secondsis notNone. Config.max_wasm_stackis set to512 * 1024.
Example:
from micropython_wasm import MicroPythonWasmError, run
try:
run(
"while True:\n pass",
fuel=50_000,
wall_timeout_seconds=None,
)
except MicroPythonWasmError as ex:
print("stopped:", ex)
memory_bytes limits guest linear memory, not total host process RSS. Wasmtime
runtime memory, compiled code, Python process memory, and host callbacks are
outside that limit. For high-risk multi-tenant workloads, run each execution in
a separate worker process with OS-level CPU, memory, and wall-clock limits too.
Network Access
This package does not expose network imports or host socket functions. The current MicroPython WASI artifact also has socket and SSL support disabled in the WASI variant configuration.
If network access is ever added, prefer a narrow host-mediated API such as
http_get(url) with explicit allowlists, timeouts, redirect limits, and maximum
response sizes. Do not expose raw sockets to untrusted code.
Supported Python Behavior
MicroPython is not CPython. It implements a substantial subset of Python, but there are differences in syntax support, standard-library coverage, object behavior, and platform details.
The test suite verifies useful behavior including:
- Arithmetic and big integers.
- Strings and bytes.
- Lists, tuples, dictionaries, and sets.
- List, dict, set, and generator comprehensions.
- Functions, default arguments, keyword-only arguments, lambdas, closures, and recursion.
- Classes, inheritance,
property,isinstance. try/except/finallyand context managers.math,json,re,binascii,sys, andos.listdir('/input').- Fresh execution state between calls.
- Transcript-backed session state across
MicroPythonReplaySession.run()calls. - True resident VM state across
MicroPythonSession.run()calls. - Host function callbacks through session
register_function()methods. - Read-only file preopens.
- Fuel exhaustion.
Known observations from this artifact:
sys.platformreportslinux.sys.argvis['-c']inside the guest.hashlib.sha256is not available in the bundled artifact.zlibis not available in the bundled artifact.
Listing Available Modules
To see the MicroPython import path and the modules available to the bundled artifact, run:
from micropython_wasm import run
result = run(
"""
import sys
print("sys.path:")
for path in sys.path:
print(" ", path)
print()
print("modules:")
help("modules")
"""
)
print(result.stdout)
help("modules") is the most useful MicroPython-native listing because it
includes built-in and frozen modules as well as modules available on the
filesystem. A plain os.listdir() scan of sys.path will miss frozen modules
in this artifact.
Rebuilding the WASI Artifact
The build helper is:
scripts/build_micropython_wasi.py
It:
- Clones MicroPython into
/tmp/micropython-wasm-build/micropython. - Checks out the requested ref, including GitHub PR refs such as
pull/13676/head. - Builds
mpy-cross. - Runs
make submodulesforports/unix. - Builds
ports/unix VARIANT=wasi. - Includes the bundled
hostuser C module by default. - Finds the best wasm artifact.
- Copies it to
micropython_wasm/artifacts/micropython-wasi.wasm.
macOS ARM64 Setup
Install Binaryen:
brew install binaryen
Download wasi-sdk 25.0 to /tmp:
curl -L -o /tmp/wasi-sdk-25.0-arm64-macos.tar.gz \
https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/wasi-sdk-25.0-arm64-macos.tar.gz
tar -xzf /tmp/wasi-sdk-25.0-arm64-macos.tar.gz -C /tmp
Build the artifact:
uv run python scripts/build_micropython_wasi.py \
--ref pull/13676/head \
--wasi-sdk /tmp/wasi-sdk-25.0-arm64-macos
Use --clean to discard the existing /tmp checkout:
uv run python scripts/build_micropython_wasi.py \
--clean \
--ref pull/13676/head \
--wasi-sdk /tmp/wasi-sdk-25.0-arm64-macos
Use --skip-build to recopy an already-built artifact from the checkout:
uv run python scripts/build_micropython_wasi.py \
--skip-build \
--ref pull/13676/head \
--wasi-sdk /tmp/wasi-sdk-25.0-arm64-macos
Useful Build Options
--repo-url: alternate MicroPython repository.--ref: git ref to build. PR refs likepull/<number>/headare supported.--work-dir: alternate build checkout directory.--output: alternate destination for the copied artifact.--variant: alternate Unix variant, defaults towasi.--wasi-sdk: path to awasi-sdkdirectory.--user-c-modules: path to a MicroPythonUSER_C_MODULESdirectory.--jobs: parallel build jobs.--extra-make-arg: additional argument forwarded to the Unix make command.--clean: remove the checkout before cloning.--skip-build: find and copy an existing artifact without rebuilding.
On macOS, the script passes
CFLAGS_EXTRA=-Wno-error=gnu-folding-constant when building mpy-cross, because
the experimental branch currently trips this Apple Clang warning while using
-Werror.
Artifact Selection
The script prefers artifacts in this order:
build-wasi/micropython.spilled.exnrefbuild-wasi/micropython.exnrefbuild-wasi/micropython.wasmbuild-wasi/micropython
The current local build produced:
/tmp/micropython-wasm-build/micropython/ports/unix/build-wasi/micropython
/tmp/micropython-wasm-build/micropython/ports/unix/build-wasi/micropython.exnref
micropython_wasm/artifacts/micropython-wasi.wasm
The copied package artifact is the micropython.exnref output.
Testing
Run the full test suite:
uv run pytest
The suite includes package tests and runtime integration tests against the bundled wasm artifact. If the artifact is missing, runtime integration tests are skipped, but package/build-script tests still run.
Current local result:
53 passed
To test a custom artifact manually:
uv run python - <<'PY'
from micropython_wasm import run_micropython_wasi
result = run_micropython_wasi(
"print(1 + 1)",
"/path/to/micropython-wasi.wasm",
)
print(result.stdout)
PY
Security Notes
This package is a Wasmtime embedding for a MicroPython WASI command module. It is a useful sandboxing layer, but it is not a complete security boundary by itself for high-risk production use.
Reasonable defaults in this package:
- Fresh instance for each run.
- No inherited host environment.
- No preopened host directories by default.
- Optional read-only preopened directory only.
- No network host functions.
- Fuel and memory limits.
- Optional wall-clock timeout.
Additional protections to consider:
- Run executions in separate worker processes.
- Apply OS-level memory and CPU limits.
- Put workers in containers or another isolation boundary.
- Bound stdout/stderr capture size if running untrusted code at scale.
- Avoid host callbacks that expose ambient authority.
- Keep Wasmtime and the MicroPython artifact pinned and regularly tested.
Implementation Status and Caveats
The repository currently includes a working bundled artifact at:
micropython_wasm/artifacts/micropython-wasi.wasm
That artifact was built from MicroPython PR #13676, using the PR ref
pull/13676/head. MicroPython's WASI Unix variant is still experimental
upstream, so this package should also be treated as experimental.
The test suite verifies the bundled artifact against arithmetic, strings,
bytes, collections, comprehensions, functions, closures, recursion, classes,
exceptions, context managers, a small standard-library subset, fresh instance
isolation, read-only file access, and fuel interruption. It also verifies both
stateful session APIs: the transcript-backed MicroPythonReplaySession and the
persistent background-thread MicroPythonSession.
One important build caveat: the PR's full post-link Binaryen pipeline currently
fails here at wasm-opt --spill-pointers with Binaryen 130. The artifact in this
repository uses the successful wasm-opt --translate-to-exnref postprocess
instead. Simple and moderately broad Python execution works under Wasmtime, but
this should be stress-tested before relying on it for hostile or long-running
code.
License
Apache-2.0
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file micropython_wasm-0.1a0.tar.gz.
File metadata
- Download URL: micropython_wasm-0.1a0.tar.gz
- Upload date:
- Size: 195.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
507a5d1564738284293efed0cdf8f42477bbd2d1b79e2c4ed8013ac92cdc36c3
|
|
| MD5 |
c40b4f2a445eeab8aad1d7072d0b656d
|
|
| BLAKE2b-256 |
c3f00430c5785aa549105cb68ad1a8acbd6e5969a35591574366ec2ad7609a37
|
Provenance
The following attestation bundles were made for micropython_wasm-0.1a0.tar.gz:
Publisher:
publish.yml on simonw/micropython-wasm
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
micropython_wasm-0.1a0.tar.gz -
Subject digest:
507a5d1564738284293efed0cdf8f42477bbd2d1b79e2c4ed8013ac92cdc36c3 - Sigstore transparency entry: 1700874533
- Sigstore integration time:
-
Permalink:
simonw/micropython-wasm@06b58d131ab14bce16a382447e05db84fdc9c6f8 -
Branch / Tag:
refs/tags/0.1a0 - Owner: https://github.com/simonw
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@06b58d131ab14bce16a382447e05db84fdc9c6f8 -
Trigger Event:
release
-
Statement type:
File details
Details for the file micropython_wasm-0.1a0-py3-none-any.whl.
File metadata
- Download URL: micropython_wasm-0.1a0-py3-none-any.whl
- Upload date:
- Size: 183.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0a61f0eb2964a7550a01e4743cae2498138d4672b5496bef1ee84b2750e6bd0b
|
|
| MD5 |
78cd57bae2433a24eff3cdec08914521
|
|
| BLAKE2b-256 |
975fcee33111a103c6350b6c8a13cb4e3368d82073b7846809e63251a6bf2f34
|
Provenance
The following attestation bundles were made for micropython_wasm-0.1a0-py3-none-any.whl:
Publisher:
publish.yml on simonw/micropython-wasm
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
micropython_wasm-0.1a0-py3-none-any.whl -
Subject digest:
0a61f0eb2964a7550a01e4743cae2498138d4672b5496bef1ee84b2750e6bd0b - Sigstore transparency entry: 1700874556
- Sigstore integration time:
-
Permalink:
simonw/micropython-wasm@06b58d131ab14bce16a382447e05db84fdc9c6f8 -
Branch / Tag:
refs/tags/0.1a0 - Owner: https://github.com/simonw
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@06b58d131ab14bce16a382447e05db84fdc9c6f8 -
Trigger Event:
release
-
Statement type: