Skip to main content

MicroPython packaged in WASM for wasmtime

Project description

micropython-wasm

PyPI Tests Changelog License

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 wasmtime Python 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=20_000_000,
    wall_timeout_seconds=1.0,
    host_result_bytes=256 * 1024,
)

Arguments:

  • code: MicroPython source code passed as micropython -c <code>.
  • wasm_path: optional path to a custom WASI MicroPython artifact. If omitted, micropython_wasm/artifacts/micropython-wasi.wasm is 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. Pass None to 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-level host.call(name, payload_json) bridge.
  • host_result_bytes: maximum serialized host callback response size. The default is 256 * 1024.

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.

MicroPythonSession accepts the same resource, filesystem, and host function arguments as run():

session = MicroPythonSession(
    memory_bytes=16 * 1024 * 1024,
    fuel=20_000_000,
    readonly_dir="fixtures",
    host_functions={"add": lambda a, b: a + b},
    host_result_bytes=256 * 1024,
)

Methods and properties:

  • session.run(code): run code in the resident VM and return a RunResult.
  • 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: True after close().
  • 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=20_000_000,
    wall_timeout_seconds=1.0,
    readonly_dir="fixtures",
    host_result_bytes=256 * 1024,
)

Methods and properties:

  • session.run(code): run code and return a RunResult.
  • session.close(): clear the transcript and reject further runs.
  • session.closed: True after close().
  • 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.

For example, this calls the Python record() function again during the second session.run(), because the first snippet is replayed before print(count) is executed:

from micropython_wasm import MicroPythonReplaySession

calls = []

def record(value):
    calls.append(value)
    return len(calls)

session = MicroPythonReplaySession(host_functions={"record": record})
session.run("count = record('once')")
session.run("print(count)")
session.close()

print(calls)  # ["once", "once"]

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:

  • MicroPythonWasmArtifactNotFound if the configured artifact does not exist.
  • MicroPythonSessionClosed if session.run() is called after session.close().
  • MicroPythonWasmError for guest traps, nonzero guest exits, invalid artifacts, missing Wasmtime support, or invalid preopened directories.
  • ValueError for 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_seconds is not None.
  • Config.max_wasm_stack is set to 512 * 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/finally and context managers.
  • math, json, re, binascii, sys, and os.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.platform reports linux.
  • sys.argv is ['-c'] inside the guest.
  • hashlib.sha256 is not available in the bundled artifact.
  • zlib is 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:

  1. Clones MicroPython into /tmp/micropython-wasm-build/micropython.
  2. Checks out the requested ref, including GitHub PR refs such as pull/13676/head.
  3. Builds mpy-cross.
  4. Runs make submodules for ports/unix.
  5. Builds ports/unix VARIANT=wasi.
  6. Includes the bundled host user C module by default.
  7. Finds the best wasm artifact.
  8. 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 like pull/<number>/head are supported.
  • --work-dir: alternate build checkout directory.
  • --output: alternate destination for the copied artifact.
  • --variant: alternate Unix variant, defaults to wasi.
  • --wasi-sdk: path to a wasi-sdk directory.
  • --user-c-modules: path to a MicroPython USER_C_MODULES directory.
  • --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:

  1. build-wasi/micropython.spilled.exnref
  2. build-wasi/micropython.exnref
  3. build-wasi/micropython.wasm
  4. build-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.

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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

micropython_wasm-0.1a1.tar.gz (198.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

micropython_wasm-0.1a1-py3-none-any.whl (182.7 kB view details)

Uploaded Python 3

File details

Details for the file micropython_wasm-0.1a1.tar.gz.

File metadata

  • Download URL: micropython_wasm-0.1a1.tar.gz
  • Upload date:
  • Size: 198.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for micropython_wasm-0.1a1.tar.gz
Algorithm Hash digest
SHA256 6160254c0f3537ac8eb5112d5de18a577db52fa60b37cc639defeb32bda25be5
MD5 294e35da9fd14167b14555721d1a0cce
BLAKE2b-256 c1a3969908343b6ea3bad3bd89882bcd28e2aa0871c569983ceb4e4defd9d2a8

See more details on using hashes here.

Provenance

The following attestation bundles were made for micropython_wasm-0.1a1.tar.gz:

Publisher: publish.yml on simonw/micropython-wasm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file micropython_wasm-0.1a1-py3-none-any.whl.

File metadata

File hashes

Hashes for micropython_wasm-0.1a1-py3-none-any.whl
Algorithm Hash digest
SHA256 a2e19a6e0f2f2c7b042201bc760be9e34ae3030379fae049745373161f141897
MD5 bdcc4bc96ca6b13a22b5e1831089558d
BLAKE2b-256 2875eb2e458f1406256d1674325b4139ac1e4d69e142c6818d970382a817d369

See more details on using hashes here.

Provenance

The following attestation bundles were made for micropython_wasm-0.1a1-py3-none-any.whl:

Publisher: publish.yml on simonw/micropython-wasm

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page