PyWasm — Comprehensive Techincal Documentation
Version documented:
2.2.3
Specification target: WebAssembly Core Specification 2.0 (Draft 2025-04-25) · WASI Preview 1
Python requirement: ≥ 3.12
License: MIT
Table of Contents
- Executive Summary
- Project Overview
- Directory Structure
- Architecture
- Dependency Analysis
- File-by-File Documentation
- Execution Flow
- API Documentation
- Configuration Documentation
- Build & Deployment
- Security Analysis
- Performance Analysis
- Developer Guide
- Improvement Recommendations
1. Executive Summary
pywasm is a complete WebAssembly interpreter written entirely in standard Python (no third-party libraries). It parses .wasm binary files, instantiates modules according to the WebAssembly abstract machine semantics, and executes bytecode instruction by instruction inside a Python-level evaluation loop.
What problem it solves
WebAssembly runtimes (Wasmtime, V8, Wasmer) are fast but require native code and are difficult to embed into pure-Python environments or to instrument without binary patching. pywasm fills the niche of a 100 % portable, introspectable, embeddable WebAssembly interpreter: it runs wherever CPython or PyPy runs, and can be extended with host functions written in ordinary Python.
Target users
| Audience | Use case |
|---|---|
| Researchers / security auditors | Trace WebAssembly execution, hook every instruction |
| Educators | Study the WebAssembly abstract machine via readable Python |
| Tool builders | Embed Wasm execution in Python scripts without native deps |
| CI/CD pipelines | Validate Wasm modules on any platform |
Key features
- Full WebAssembly 2.0 instruction set (integer, float, SIMD, reference types, bulk-memory, table operations)
- WASI Preview 1 host implementation (file I/O, sockets, clocks, randomness, environment, signals, polling)
- Host-function injection API — call Python callables from inside Wasm
- CLI entry-point (
python -m pywasm) - Optional debug-level tracing of every decoded section and every executed instruction
- PyPy-friendly design (≈10× speedup over CPython on PyPy)
- Comprehensive spec-test suite and WASI test suite
High-level architecture
┌─────────────────────────────────────────────────────────────┐
│ Host / User │
│ pywasm.Runtime ◄──► pywasm.wasi.Preview1 │
└─────────────────┬───────────────────────────────────────────┘
│ instance_from_file / invocate
┌─────────────────▼───────────────────────────────────────────┐
│ pywasm.core.Machine │
│ ┌───────────┐ ┌────────────────────────────────────────┐ │
│ │ Store │ │ Stack │ │
│ │ func[] │ │ value[] (ValInst) │ │
│ │ tabl[] │ │ label[] (Label / control flow) │ │
│ │ mems[] │ │ frame[] (Frame / activation records) │ │
│ │ glob[] │ └────────────────────────────────────────┘ │
│ │ elem[] │ │
│ │ data[] │ ← allocate() / instance() / invocate() │
│ └───────────┘ │
└─────────────────┬───────────────────────────────────────────┘
│ byte stream
┌─────────────────▼───────────────────────────────────────────┐
│ pywasm.core.ModuleDesc (binary parser) │
│ Section loop: type / import / func / table / mem / │
│ global / export / start / elem / code / data │
└─────────────────────────────────────────────────────────────┘
helpers: pywasm.leb128 · pywasm.arith · pywasm.opcode
2. Project Overview
Project goals
- Correctness first. Pass the official WebAssembly spec test suite (core + SIMD) and the WASI preview-1 test suite.
- Readability. Every class and method mirrors the prose of the WebAssembly specification. Someone reading the spec should be able to map terminology directly to code.
- Embeddability. The public API is intentionally small:
Runtime,instance_from_file,invocate,allocate_func_host. - Zero dependencies. Only Python's standard library (
io,struct,ctypes,math,typing,platform,os,socket, …).
Core concepts
| Concept | Description |
|---|---|
| ValType | One of i32, i64, f32, f64, v128, ref.func, ref.extern |
| ValInst | A 16-byte tagged union holding any WebAssembly value |
| ModuleDesc | Static, decoded description of a .wasm binary (no runtime state) |
| ModuleInst | Runtime instance: index tables pointing into the shared Store |
| Store | Shared heap of function, table, memory, global, element, data instances |
| Machine | Owns one Store and one Stack; drives the evaluation loop |
| Label | Control-flow record for block / loop / if; tracks current instruction pointer |
| Frame | Activation record for a function call; holds locals and module context |
| Runtime | Convenience wrapper around Machine; manages import registration |
Design philosophy
- Specification-faithful naming. Classes, fields, and methods use the vocabulary of the WebAssembly spec wherever possible (e.g.
ModuleInst,FuncInst,Store,allocate_func_wasm). - Everything is an index.
ModuleInststores integer indices intoStorelists, not direct references. This allows the store to grow while modules hold stable handles. - 16-byte value representation.
ValInst.datais always a 16-bytebytearray, supportingv128(SIMD) uniformly alongside scalars. Scalars occupy the low bytes; the high bytes are zero-padded. - Iterative evaluation loop. Instead of recursive function calls, execution uses an iterative loop over
stack.label.FrameandLabelentries are pushed/popped by call and control-flow instructions. This keeps Python's call stack shallow and makes the design JIT-friendly.
Technology stack
| Layer | Technology |
|---|---|
| Language | Python ≥ 3.12 (uses match/case, typing.Self) |
| Binary encoding | struct, custom LEB128 reader |
| Arithmetic precision | ctypes.c_float, struct.pack/unpack for IEEE 754 |
| WASI I/O | os, fcntl, socket, select, stat, io (Linux/macOS only) |
| Wasm source examples | Rust → wasm32-unknown-unknown / wasm32-wasip1 |
| Test infrastructure | Official WebAssembly spec tests, official WASI test suite, wast2json from WABT |
| Packaging | hatchling (pyproject.toml) |
| CI | GitHub Actions (macOS, Ubuntu, Windows, Python 3.14) |
3. Directory Structure
pywasm/ ← repository root
├── .github/
│ └── workflows/
│ └── develop.yaml ← CI: test on 3 OS × Python 3.14
├── .gitignore
├── LICENSE ← MIT
├── MANIFEST.in ← includes LICENSE in sdist
├── README.md
├── pyproject.toml ← hatchling build; version 2.2.3
│
├── pywasm/ ← THE LIBRARY (Python package)
│ ├── __init__.py ← re-exports, version, platform guard
│ ├── __main__.py ← CLI entry point
│ ├── arith.py ← fixed-width integer & IEEE 754 arithmetic
│ ├── core.py ← 4193-line interpreter heart
│ ├── leb128.py ← LEB128 encode/decode helpers
│ ├── log.py ← lightweight debug logger
│ ├── opcode.py ← opcode constants + name lookup table
│ └── wasi.py ← WASI Preview 1 implementation (Linux/macOS)
│
├── example/ ← runnable Python driver scripts
│ ├── blake2b.py ← Blake2b via Wasm
│ ├── blake2b_direct.py
│ ├── blake2b_iter.py
│ ├── blake2b_simd.py
│ ├── fibonacci.py
│ ├── fibonacci_env.py ← host-function injection demo
│ ├── pi.py
│ ├── wasi_httpbin.py
│ ├── wasi_ll.py
│ ├── wasi_stdout.py ← stdout capture demo
│ ├── wasi_zen.py
│ └── <name>/ ← Rust crate for each example
│ ├── Cargo.toml
│ ├── src/lib.rs (or main.rs)
│ └── bin/<name>.wasm ← pre-compiled binary (committed)
│
├── res/
│ └── pywasm.jpg ← project logo
│
├── script/
│ ├── build_example.py ← compiles all Rust examples → .wasm
│ ├── build_spec.py ← clones/updates spec test suite, runs wast2json
│ ├── build_wabt.py ← downloads platform-appropriate WABT release
│ └── build_wasi.py ← downloads wasi-testsuite
│
└── test/
├── example.py ← runs all example/ scripts
├── main.py ← CLI smoke test (pi, wasi_ll)
├── spec.py ← WebAssembly spec conformance test runner
└── wasi.py ← WASI test runner
Directory roles
| Directory | Role |
|---|---|
pywasm/ |
The distributable Python package. Everything a downstream user installs. |
example/ |
Demonstrates all public API surfaces; Rust source is the ground truth; .wasm binaries are committed so the repo is usable without a Rust toolchain. |
script/ |
Developer-only automation: downloads tooling, compiles examples, prepares test fixtures. Not shipped in the sdist. |
test/ |
Test suite. Requires external fixtures built by script/. |
res/ |
Static assets (logo). |
.github/ |
CI configuration. |
4. Architecture
4.1 System architecture
pywasm is a single-process, single-threaded interpreter. There are no network calls, background threads, or foreign-function interfaces at runtime. The only external processes are spawned at development time (by scripts) — cargo build, wast2json, git.
4.2 Component architecture
┌──────────────────────────────────────────────────┐
│ Public API Layer │
│ Runtime (core.py) │
│ ├── allocate_func_host() │
│ ├── allocate_table() │
│ ├── allocate_memory() │
│ ├── allocate_global() │
│ ├── instance() / instance_from_file() │
│ ├── invocate() │
│ ├── exported_memory() │
│ └── exported_global() │
└──────────────────┬───────────────────────────────┘
│
┌──────────────────▼───────────────────────────────┐
│ Machine (core.py) │
│ ├── Store ─► FuncInst | FuncHost │
│ │ TableInst │
│ │ MemInst (bytearray, pages) │
│ │ GlobalInst │
│ │ ElemInst │
│ │ DataInst │
│ ├── Stack ─► value: list[ValInst] │
│ │ label: list[Label] │
│ │ frame: list[Frame] │
│ │ │
│ ├── allocate() — fills Store, returns ModInst │
│ ├── instance() — validates imports, runs init │
│ ├── invocate() — public function call entry │
│ └── evaluate() — the bytecode dispatch loop │
└──────────────────┬───────────────────────────────┘
│
┌──────────────────▼───────────────────────────────┐
│ Binary Decoder (core.py) │
│ ModuleDesc.from_reader() │
│ ├── Section 0x01 — type (FuncType) │
│ ├── Section 0x02 — import (Import) │
│ ├── Section 0x03 — func (FuncDesc type ref) │
│ ├── Section 0x04 — table (TableType) │
│ ├── Section 0x05 — memory (MemType) │
│ ├── Section 0x06 — global (GlobalDesc+Expr) │
│ ├── Section 0x07 — export (ExportDesc) │
│ ├── Section 0x08 — start (function index) │
│ ├── Section 0x09 — elem (ElemDesc) │
│ ├── Section 0x0a — code (LocalsDesc+Expr) │
│ ├── Section 0x0b — data (DataDesc) │
│ └── Section 0x0c — datacount │
└──────────────────────────────────────────────────┘
↑ uses
┌─────────────────────────────────────────────────┐
│ Support Modules │
│ leb128.py — unsigned/signed LEB128 I/O │
│ arith.py — fixed-width arithmetic helpers │
│ opcode.py — opcode constants + name table │
│ log.py — timestamped debug output │
└─────────────────────────────────────────────────┘
4.3 Runtime architecture (abstract machine)
The WebAssembly specification defines execution in terms of a Store and a Stack. pywasm implements both faithfully.
Store
All runtime objects live in one global Store per Machine:
Store.func : list[FuncInst | FuncHost] # function instances
Store.tabl : list[TableInst] # table instances
Store.mems : list[MemInst] # linear memory instances
Store.glob : list[GlobalInst] # global variable instances
Store.elem : list[ElemInst] # element segment instances
Store.data : list[DataInst] # data segment instances
Indices into these lists serve as addresses. A ModuleInst holds list[int] for each category, mapping module-local indices to store addresses.
Stack
Stack.value : list[ValInst] # operand stack
Stack.label : list[Label] # control-flow stack
Stack.frame : list[Frame] # call-frame stack
All three are Python lists used as stacks (append / pop). The spec mixes all three kinds on a single conceptual stack; pywasm separates them into three parallel lists indexed by frame/label depth.
Label
class Label:
arity : int # number of return values
frame : int # frame depth at label entry
value : int # value-stack depth at label entry
carry : int # 0x00 = loop-back, 0x01 = pop label, 0x03 = pop frame too
instr : list[Inst] # instruction sequence to execute
index : int # current instruction pointer within instr
carry is a bit field. Bit 0 controls branch semantics (loop vs block). Bit 1 indicates the label is associated with a Frame (i.e. a function activation), so popping the label also pops the frame.
Frame
class Frame:
module : ModuleInst # module context for resolving indices
locals : LocalsInst # local variables (including arguments)
arity : int # number of return values expected
label : int # label-stack depth at frame entry
value : int # value-stack depth at frame entry
4.4 Opcode dispatch
The Machine.evaluate() method is a tight iterative loop:
for _ in range(1 << 32): # effectively infinite; exception exits
label = self.stack.label[-1]
frame = self.stack.frame[-1]
if label.index == len(label.instr):
self.stack.label.pop()
if label.carry & 2: # frame-carrying label
self.stack.frame.pop()
continue
instr = label.instr[label.index]
label.index += 1
match instr.opcode:
case opcode.i32_add: ...
case opcode.call: ...
# ... 400+ cases
Each iteration:
- Fetches the current label (top of label stack)
- If the instruction list is exhausted, pops the label (and optionally the frame)
- Otherwise, fetches the next instruction and dispatches via
match
This design is allocation-free per iteration for most arithmetic instructions (just list indexing and ValInst construction).
4.5 Control-flow mechanics
| Instruction | Effect |
|---|---|
block |
Push a new Label(carry=1) with the block's instruction list |
loop |
Push a new Label(carry=0) — br resets index to 0 (loops back) |
if |
Pop condition; push Label with either then or else instruction list |
br n |
evaluate_br(n): pop n labels from label stack, collect arity values, restore value stack |
br_if |
Conditional br |
br_table |
Indexed br |
return |
Pop all labels up to and including the frame label |
call |
Push a new Frame and a new Label(carry=3) containing the callee's instruction list |
4.6 Instantiation lifecycle
sequenceDiagram
participant User
participant Runtime
participant Machine
participant Store
User->>Runtime: instance_from_file(path)
Runtime->>Runtime: open(path) → ModuleDesc.from_reader()
Runtime->>Machine: instance(module, extern_list)
Machine->>Machine: validate imports
Machine->>Machine: push auxiliary Frame
Machine->>Machine: evaluate global init exprs
Machine->>Machine: evaluate elem init exprs
Machine->>Store: allocate(module, extern, globin, elemin)
Store-->>Machine: ModuleInst
Machine->>Machine: push newmod Frame
Machine->>Machine: run active elem segments (table_init)
Machine->>Machine: run active data segments (memory_init)
Machine->>Machine: call start function (if any)
Machine->>Machine: pop newmod Frame
Machine-->>Runtime: ModuleInst
Runtime-->>User: ModuleInst
4.7 WASI integration architecture
┌──────────────────────┐
│ Preview1 (wasi.py) │ bind(runtime) → registers 45 host funcs
│ │ under "wasi_snapshot_preview1"
│ fd: list[File] │
│ args, dirs, envs │
│ return_on_exit │
└──────────┬───────────┘
│ host functions called from Wasm via call_indirect/call
▼
┌──────────────────────┐
│ Machine (evaluate) │ FuncHost.hostcode(machine, args) → rets
│ │
│ reads/writes linear │
│ memory for iovec, │
│ stat structs, etc. │
└──────────────────────┘
│
▼
OS: open/read/write/socket/...
WASI functions receive a Machine reference and a list[int] of already-unwrapped argument values. They access linear memory through machine.store.mems[...].get_u32(ptr) etc. Results are written back into memory and an error code is returned as a list[int].
5. Dependency Analysis
pywasm has zero runtime dependencies outside the Python standard library.
| Module | Used by | Purpose |
|---|---|---|
ctypes |
arith.py, core.py |
c_float for IEEE 754 single-precision rounding; struct for byte layout |
struct |
arith.py, core.py |
Pack/unpack little-endian integers and floats |
math |
arith.py, core.py |
isnan, isinf, copysign, ceil, floor, trunc, sqrt for float instructions |
io |
leb128.py, core.py |
io.BytesIO as a seekable byte reader for section-level parsing |
typing |
core.py, leb128.py |
typing.Self, typing.Any, typing.Callable for type hints |
platform |
__init__.py, wasi.py |
Detect OS to conditionally import wasi and handle Darwin vs Linux errno differences |
os |
wasi.py |
All POSIX file operations (open, read, write, stat, lseek, pread, pwrite, openat-style dir_fd=) |
fcntl |
wasi.py |
fcntl.F_SETFL for non-blocking / append flag changes |
socket |
wasi.py |
TCP/UDP socket operations for sock_accept, sock_recv, sock_send, sock_shutdown |
select |
wasi.py |
select.select() for poll_oneoff subscription polling |
stat |
wasi.py |
stat.S_ISREG, stat.S_ISDIR for WASI file-type mapping |
random |
wasi.py |
random.randbytes() for random_get |
time |
wasi.py |
time.time_ns(), time.monotonic_ns(), time.process_time_ns() for WASI clocks |
dataclasses |
wasi.py |
@dataclasses.dataclass for the Preview1.File descriptor |
sys |
wasi.py, __main__.py |
sys.stdin/stdout/stderr file descriptors; sys.exit() |
argparse |
__main__.py |
CLI argument parsing |
Build-time (developer only)
| Tool | Purpose |
|---|---|
hatchling |
PEP 517 build backend (declared in pyproject.toml) |
wabt (wast2json) |
Converts .wast spec tests to .json + .wasm |
| Rust + cargo | Compiles example Wasm binaries (wasm32-unknown-unknown, wasm32-wasip1) |
| git | Clones the official WebAssembly spec and WASI test suite repos |
6. File-by-File Documentation
6.1 pywasm/__init__.py
File purpose
Package entry point. Re-exports the public surface of core, conditionally imports wasi (POSIX only), and declares the library version.
Key components
version = '2.2.3'
The version string is duplicated in pyproject.toml. There is no automated synchronization between the two.
Platform guard
if platform.system().lower() in ['darwin', 'linux']:
from . import wasi
wasi.py uses fcntl, select, os.pread, and POSIX-specific dir_fd= arguments which are not available on Windows. The guard prevents an ImportError on Windows, where WASI programs cannot currently be run through pywasm.
Relationships
- Imports from:
arith,core,leb128,log,opcode, conditionallywasi from .core import *makes all public symbols fromcore.pyavailable at thepywasmnamespace level
6.2 pywasm/log.py
File purpose
Minimal timestamped logger used throughout the interpreter for debug tracing.
Key components
lvl = 0 # global debug level; set to 1 to enable tracing
def debugln(*args):
if lvl > 0:
println(*args)
def println(*args):
pre = datetime.datetime.now().strftime('%Y/%m/%d %H:%M:%S')
print(pre, 'pywasm:', *args)
Internal logic
debugln is gated by lvl. Setting pywasm.log.lvl = 1 enables verbose output that includes:
- Every section header as the binary is decoded
- Every instruction executed during
evaluate() - Global and element initializer evaluations
Notes
- There is no log level above 1 (any non-zero value enables debug output).
- There is no file-based logging or structured logging. All output goes to
stdout. - Enabling tracing incurs significant overhead because string formatting happens for every instruction.
6.3 pywasm/leb128.py
File purpose
Implementation of LEB128 (Little-Endian Base-128) variable-length integer encoding, as specified by the WebAssembly binary format.
Key components
class _U — Unsigned LEB128
| Method | Signature | Description |
|---|---|---|
encode |
(i: int) → bytearray |
Encode a non-negative integer |
decode |
(b: bytearray) → int |
Decode a pre-read bytearray |
decode_reader |
(r: io.IOBase) → tuple[int, int] |
Read bytes from a stream until the MSB is 0; return (value, bytes_read) |
class _I — Signed LEB128
Same interface as _U, but handles two's-complement sign extension in decode:
if e & 0x40 != 0: # if the sign bit of the last byte is set
r |= - (1 << (i * 7) + 7) # sign-extend
Module-level singletons
u = _U() # unsigned LEB128 codec
i = _I() # signed LEB128 codec
These are used throughout core.py as pywasm.leb128.u.decode_reader(r).
Internal logic
The encoding loop accumulates 7 bits per byte with the high bit (0x80) set as a continuation flag. The decoder reverses this, collecting bytes until one lacks the continuation flag.
Notes
- The
decode_readerreturns a(value, num_bytes_read)tuple, but in almost all call sites only[0](the value) is used. The byte count is available for callers that need to advance a manual position counter. - There is no maximum byte length validation. Malformed inputs with an infinite continuation-bit stream would loop forever.
6.4 pywasm/arith.py
File purpose
Fixed-width arithmetic helper classes that correctly implement WebAssembly integer and floating-point semantics, which differ from Python's arbitrary-precision defaults.
Key components
class I(blen) — Signed integer of blen bytes
| Attribute | Value (for I(4)) |
|---|---|
bits_length |
32 |
byte_length |
4 |
mask |
0xFFFFFFFF |
mask_sign |
0x80000000 |
max |
2147483647 |
min |
-2147483648 |
Key methods:
fit(n)— Wrapsninto the signed range using two's-complement masking:n = n & self.mask # truncate to bit width n = n - ((n & self.mask_sign) << 1) # sign-extenddiv(a, b)— Implements truncation toward zero (WebAssembly requirement):
Python's nativereturn a // b if a * b > 0 else (a + (-a % b)) // b//truncates toward negative infinity; this correction is critical for correctness.rem(a, b)— Matching signed remainder (always has sign of dividend).shl/shr— Shift amount is masked tobits_length - 1(WebAssembly requirement).
class U(blen) — Unsigned integer of blen bytes
Same structure but fit is just n & mask. Adds rotation and bit-counting operations:
rotl(a, b)/rotr(a, b)— Circular bit rotationclz(a)— Count leading zeros (bit loop, O(bits_length))ctz(a)— Count trailing zerospopcnt(a)— Population count (count set bits)
class F32 and class F64 — IEEE 754 float wrappers
F32.fit(n)usesctypes.c_float(n).valueto round to 32-bit precision — essential for correctness because Python'sfloatis always 64-bit.F64.div(a, b)handles division by zero per WebAssembly: returns ±∞ or NaN with correct sign.min/maxpropagate NaN on either operand (WebAssembly NaN-propagation rules differ from Python'smin/max).
Module-level singletons
i8, u8, i16, u16, i32, i64, u32, u64 = I(1), U(1), I(2), U(2), I(4), I(8), U(4), U(8)
f32, f64 = F32(), F64()
These are imported by core.py as pywasm.arith.i32.add(a, b) etc.
Notes
clz,ctz,popcntare O(N) bit-by-bit loops. For large N=64 (i64), this is 64 iterations — acceptable for an interpreter but not optimal. Nativeint.bit_count()(Python 3.10+) could replacepopcnt.I.divspecial-case:assert a != self.min or b != -1guards against integer overflow onINT_MIN / -1.
6.5 pywasm/opcode.py
File purpose
Flat module of opcode constants and a name dictionary for debug display. Contains every WebAssembly opcode from the 2.0 specification including SIMD (0xfd prefix), saturating truncation (0xfc prefix), and reference types.
Key components
# Basic opcodes (single byte)
unreachable = 0x00
nop = 0x01
block = 0x02
...
# Extended opcodes (two-byte with 0xfc prefix, encoded as combined int)
i32_trunc_sat_f32_s = 0xfc00
memory_init = 0xfc08
...
# SIMD opcodes (0xfd prefix)
v128_load = 0xfd00
i8x16_shuffle = 0xfd0d
...
i16x8_abs = 0xfd8001 # multi-byte SIMD: 0xfd, LEB128(0x0180) = 0x80 0x03 (note encoding)
Opcode encoding note
The Inst.from_reader method reads multi-byte opcodes by:
- Reading the first byte
b - If
b >= 0xfc, reading the remainder as unsigned LEB128 and combining
The combined value (e.g. 0xfd8001) is used as the dictionary key in opcode.name and in the match statement.
The name dict maps every opcode integer to its human-readable string form (e.g. name[0x6a] = 'i32.add'), used solely for debug output.
Notes
- The module contains 437 opcode definitions and 437 corresponding
nameentries. - It is purely declarative — no logic, no imports. This makes it very cheap to import.
6.6 pywasm/core.py
File purpose
The central 4193-line module. Contains:
- All type-descriptor and runtime-instance classes (mirroring the WebAssembly spec)
- The binary module decoder (
ModuleDesc.from_reader) - The abstract machine (
Machine) - The public convenience API (
Runtime)
6.6.1 Value system
class ValType
Seven WebAssembly value types encoded as single bytes:
| Constant | Value | Meaning |
|---|---|---|
0x7f |
i32 |
32-bit integer |
0x7e |
i64 |
64-bit integer |
0x7d |
f32 |
32-bit float |
0x7c |
f64 |
64-bit float |
0x7b |
v128 |
128-bit SIMD vector |
0x70 |
ref.func |
Function reference |
0x6f |
ref.extern |
External reference |
Factory class methods: ValType.i32(), ValType.f32(), etc.
class ValInst
A 16-byte tagged value. The data field is always exactly 16 bytes.
Layout for i32 value 42:
bytes 0-3: 42 in little-endian (0x2a 0x00 0x00 0x00)
bytes 4-15: zeros
Layout for ref (func index 7, non-null):
bytes 0-3: 7 in signed 32-bit little-endian
byte 4: 0x01 (non-null flag)
bytes 5-15: zeros
Layout for v128:
bytes 0-15: raw 16 bytes of the vector
Construction methods (class methods): from_i32, from_i64, from_u32, from_u64, from_f32, from_f64, from_v128, from_v128_i8 … from_v128_f64, from_ref, from_all.
Extraction methods (instance methods): into_i32, into_i64, into_u32, into_u64, into_f32, into_f64, into_v128, into_v128_i8 … into_v128_f64, into_ref, into_all.
The from_all(type, n) and into_all() dispatch on type.data to call the appropriate typed constructor/extractor.
6.6.2 Type descriptors (static / decoded from binary)
| Class | Purpose |
|---|---|
Bype |
Block type — empty (0x40), single value type, or function type index |
Inst |
A single decoded instruction: opcode + args list |
Expr |
A list of Inst terminated by end |
LocalsDesc |
n × type pair for local variable declaration |
LocalsInst |
Runtime mutable list of ValInst locals |
FuncType |
args: list[ValType], rets: list[ValType] |
FuncDesc |
Type index + locals + body Expr |
Limits |
n (min), m (max, 0 = unlimited) |
MemType |
Wraps Limits |
TableType |
ValType + Limits |
GlobalType |
ValType + mutability flag (0=const, 1=mut) |
GlobalDesc |
GlobalType + initializer Expr |
Import |
Module name, item name, kind, descriptor |
Extern |
Runtime external value: kind + store address |
ExportDesc |
Name, kind (func/table/mem/global), descriptor index |
ElemDesc |
Element segment (8 kinds per spec, parsed accordingly) |
DataDesc |
Data segment (3 kinds per spec) |
Custom |
Custom section (ignored by semantics) |
ModuleDesc |
All decoded sections aggregated |
6.6.3 Inst.from_reader — instruction decoder
This is a 350-line match statement that reads one instruction at a time from a BytesIO reader. For structured control instructions (block, loop, if), it recursively reads until end (or else for if), building a tree of nested instruction lists.
For memory instructions it reads alignment+offset immediate pairs and asserts the alignment is within spec bounds.
For SIMD instructions it handles lane indices, immediate bytes (shuffle), and vector constants.
f32/f64 constant encoding note:
case opcode.f32_const:
# Python misinterprets 0x7fa00000 as 0x7fe00000 when cast to float.
o.args.append(struct.unpack('<i', r.read(4))[0]) # store as raw int
Float constants are stored as their raw bit-pattern (signed int32/int64) to avoid Python's NaN-canonicalization. They are reinterpreted at execution time via ValInst.from_f32_u32.
6.6.4 ModuleDesc.from_reader — binary module decoder
Reads the Wasm binary format:
- Checks magic
\x00asmand version\x01\x00\x00\x00 - Loops up to 32 sections, reading each as a
BytesIOsub-reader - Dispatches on section ID (0–12)
- The code section (0x0a) is special: it is a count of function bodies, each sized with its own LEB128 length. The code body for
func[i]patcheslocalsandexprinto the already-createdFuncDescfrom the function section (0x03).
6.6.5 Runtime instances
| Class | Description |
|---|---|
ModuleInst |
Per-module index tables (type, func, tabl, mems, glob, elem, data, exps) |
FuncInst |
Wasm-defined function: type, module context, code |
FuncHost |
Python-hosted function: type, callable |
MemInst |
Linear memory: bytearray data + page count; grow(n) adds 64KiB pages |
TableInst |
Table: list[ValInst] of references; grow(n, v) appends n copies of value v |
GlobalInst |
Global: data: ValInst + mutability flag |
ElemInst |
Element segment: type + list[ValInst] references |
DataInst |
Data segment: bytearray |
MemInst memory limits:
The memory_grow instruction is additionally capped to 1024 pages (64 MiB):
cnda = size + incr <= 1024 # hard cap
cndb = mems.type.limits.m == 0 or size + incr <= mems.type.limits.m
This is a deliberate embedder policy, not a spec requirement.
6.6.6 Machine.allocate — resource allocation
Maps ModuleDesc → ModuleInst by allocating all resources into the Store:
- External values (from imports) are appended to module index tables
store.allocate_func_wasm(inst, func)→ createsFuncInst, returns store addressstore.allocate_table,allocate_memory,allocate_global,allocate_elem,allocate_datasimilarly- Export descriptors are resolved to
ExportInstentries
6.6.7 Machine.instance — module instantiation
The full spec-defined instantiation procedure:
- Validate that import list length and kinds match declared imports
- Type-check each import against the declared descriptor
- Push an auxiliary
Frameto allow global/element init expressions to run (they can reference imports) - Evaluate each global initializer expression
- Evaluate each element initializer expression
- Call
allocate()to build the realModuleInst - Re-push frame with the real module
- Run active element segments (
table_init+elem_drop) - Run declarative element segments (
elem_drop) - Run active data segments (
memory_init+data_drop) - Call start function if declared
- Pop frame and return
ModuleInst
6.6.8 Machine.invocate — function invocation
Public entry point for calling an exported function:
- Validate argument types
- Push a sentinel
Frame(to detect over-unwinding) - Build locals from args + zero-initialized local variables
- Push the real
Frameand an initialLabel(carry=3)with the function body - Call
evaluate() - Pop return values from value stack
- Validate return types
- Pop sentinel frame; assert stacks are empty
6.6.9 Machine.evaluate — the dispatch loop
The heart of the interpreter. Approximately 2300 lines of a single match instr.opcode: statement inside a for loop. Notable implementation patterns:
Memory instructions:
def evaluate_mem_load(self, offset: int, size: int) -> bytearray:
mems = self.store.mems[self.stack.frame[-1].module.mems[0]]
addr = self.stack.value.pop().into_u32() + offset
assert addr + size <= len(mems.data) # trap on OOB
return mems.data[addr:addr+size]
All loads share this helper. Stores have evaluate_mem_save.
Branch handling (evaluate_br):
def evaluate_br(self, l: int) -> None:
label = self.stack.label[-1 - l] # target label (depth l)
rets = [self.stack.value.pop() for _ in range(label.arity)][::-1]
self.stack.frame = self.stack.frame[:label.frame] # unwind frames
self.stack.label = self.stack.label[:len(self.stack.label)-l]
match label.carry & 1:
case 0x00: label.index = 0 # loop: reset to start
case 0x01: self.stack.label.pop() # block: exit label
self.stack.value = self.stack.value[:label.value]
self.stack.value.extend(rets)
SIMD instructions — all follow the same pattern: pop v128 operand(s) as typed lists, apply element-wise operation, push result:
case opcode.i8x16_add:
b = self.stack.value.pop().into_v128_i8()
a = self.stack.value.pop().into_v128_i8()
c = [pywasm.arith.i8.add(a[i], b[i]) for i in range(16)]
self.stack.value.append(ValInst.from_v128_i8(c))
Float abs/neg are implemented directly via bit manipulation to correctly handle NaN sign bits:
case opcode.f32_abs:
a = self.stack.value.pop()
b = ValInst(ValType.f32(), a.data.copy())
b.data[3] = b.data[3] & 0x7f # clear sign bit
self.stack.value.append(b)
6.6.10 class Runtime
The public API facade. Owns one Machine and an imports dict:
class Runtime:
machine: Machine
imports: dict[str, dict[str, Extern]] # module_name → {item_name → Extern}
| Method | Description |
|---|---|
allocate_func_host(type, func) |
Register a Python callable as a host function |
allocate_table(type) |
Pre-allocate a table for import |
allocate_memory(type) |
Pre-allocate a memory for import |
allocate_global(type, data) |
Pre-allocate a global for import |
exported_memory(module, name) |
Look up an exported MemInst |
exported_global(module, name) |
Look up an exported GlobalInst |
instance(module) |
Instantiate from a ModuleDesc; resolves imports from self.imports |
instance_from_file(path) |
Open file → ModuleDesc.from_reader → instance() |
invocate(module, func, args) |
Call an exported function by name; auto-converts Python values to ValInst |
Host function calling convention:
# Signature required for all host functions:
def my_func(machine: pywasm.core.Machine, args: list[int|float|...]) -> list[int|float|...]:
...
The Machine reference gives host functions full access to memory, globals, and the call stack.
6.7 pywasm/__main__.py
File purpose
CLI entry point, invoked as python -m pywasm.
Key components
Arguments:
file wasm file path (required)
--version, -v print version and exit
--func exported function name to call (default: _start)
--func-args repeated; argument values as strings
--wasi 'preview1' to enable WASI
--wasi-envs 'KEY=VAL' pairs
--wasi-dirs 'wasm_path:host_path' directory mappings
--wasi-args argv for the WASI program
Internal logic
Two code paths:
main_wasi()—--wasi preview1is present:- Creates a
Runtimeand aPreview1object - Calls
wasi.bind(runtime)to register host functions - Calls
runtime.instance_from_file(file) - Calls
wasi.main(runtime, inst)to call_start sys.exit(code)with the process exit code
- Creates a
main_wasm()— plain Wasm:- Creates
Runtime, instantiates the module - Resolves the named export
- Parses
--func-argsstrings into typed Python values (int, float, or hex bytearray for v128) - Calls
runtime.invocate(...)and prints the result list
- Creates
Notes
- The script runs
main_wasm()unconditionally after the WASI check — if--wasiis set,wasi.main()eventually callssys.exit()(or raises an exception that propagates), somain_wasm()is never reached in practice when--wasiis active. This is technically a control-flow quirk — it relies on side-effects frommain_wasi()to stop execution.
6.8 pywasm/wasi.py
File purpose
Complete implementation of the WASI Preview 1 ABI as a Python class. Provides 45 syscall-like host functions that Wasm programs compiled against wasm32-wasip1 will call.
Important: Platform restriction
wasi.py uses fcntl (POSIX) and dir_fd= arguments to os functions. This module is only imported on Linux and macOS. On Windows, WASI support is not available.
Key components
class Preview1.File (dataclass)
Represents an open file descriptor with both host-side and WASI-side metadata:
| Field | Description |
|---|---|
host_fd |
OS file descriptor integer |
host_flag |
OS flags (O_RDONLY, O_APPEND, etc.) |
host_name |
Absolute path on the host filesystem |
host_status |
FILE_STATUS_OPENED or FILE_STATUS_CLOSED |
pipe |
Optional io.BytesIO for redirecting reads/writes (e.g. stdout capture) |
wasm_fd |
WASI file descriptor number (index into self.fd) |
wasm_flag |
WASI fdflags |
wasm_name |
Path as seen by the Wasm program |
wasm_rights_base |
Bitmask of allowed operations |
wasm_rights_root |
Bitmask of inheritable rights |
wasm_status |
WASI fd status |
wasm_type |
WASI file type (regular, directory, socket, etc.) |
Preview1.__init__
Pre-populates self.fd with:
- Index 0: stdin (
FD_STDIN) - Index 1: stdout (
FD_STDOUT) - Index 2: stderr (
FD_STDERR) - Indices 3+: pre-opened directories from the
dirsmapping
The pipe field on stdout/stderr allows capturing output into a BytesIO object (see wasi_stdout.py example).
Preview1.bind(runtime)
Registers all 45 WASI functions as host functions in runtime.imports['wasi_snapshot_preview1']. Each function gets an explicit FuncType descriptor with the exact WASI-specified parameter and return types.
WASI function categories
| Category | Functions |
|---|---|
| Args/Env | args_get, args_sizes_get, environ_get, environ_sizes_get |
| Clock | clock_res_get, clock_time_get |
| File descriptor | fd_advise, fd_allocate, fd_close, fd_datasync, fd_fdstat_get, fd_fdstat_set_flags, fd_fdstat_set_rights, fd_filestat_get, fd_filestat_set_size, fd_filestat_set_times, fd_pread, fd_prestat_get, fd_prestat_dir_name, fd_pwrite, fd_read, fd_readdir, fd_renumber, fd_seek, fd_sync, fd_tell, fd_write |
| Path | path_create_directory, path_filestat_get, path_filestat_set_times, path_link, path_open, path_readlink, path_remove_directory, path_rename, path_symlink, path_unlink_file |
| Poll | poll_oneoff |
| Process | proc_exit, proc_raise |
| Random | random_get |
| Scheduler | sched_yield |
| Socket | sock_accept, sock_recv, sock_send, sock_shutdown |
I/O implementation pattern
WASI read/write functions use the iovec (scatter/gather I/O) model:
Memory layout of an iovec array:
+0: ptr (u32) → address of data buffer
+4: len (u32) → length of data buffer
(repeat for each vector element)
fd_write and fd_read iterate over these, calling os.write(host_fd, data) or os.read(host_fd, n). If file.pipe is set, I/O goes to/from the BytesIO object instead of the OS.
proc_exit behavior
def proc_exit(self, _, args):
if self.return_on_exit:
raise Exception(SystemExit, args[0]) # caught by main()
sys.exit(args[0])
return_on_exit = 1 by default — this allows the Python program to continue after the Wasm program exits (e.g. to inspect captured stdout). Setting it to 0 causes the Python process to terminate.
Path sandboxing
Every path_* function calls help_escp(wasm_name, user_path) to detect path traversal attempts (e.g. ../../etc/passwd). If the normalized path escapes the pre-opened directory, ERRNO_PERM is returned.
Helper methods
| Helper | Purpose |
|---|---|
help_badf(fd) |
Returns True if fd is invalid or closed |
help_idir(fd) |
Returns True if fd is a directory (disallowed for file ops) |
help_sock(fd) |
Returns True if fd is NOT a socket (disallowed for socket ops) |
help_perm(fd, rights) |
Returns True if wasm_rights_base & rights == 0 |
help_escp(root, name) |
Returns True if name escapes root |
help_time(clockid) |
Maps WASI clock IDs to nanosecond timestamps |
help_wasm_type(stat) |
Maps os.stat_result to WASI file type constants |
help_host_open_flag(oflags) |
Maps WASI open flags to OS O_* flags |
6.9 test/spec.py
File purpose
Runs the official WebAssembly specification test suite. Tests are in JSON format (generated from .wast by wast2json), specifying module loads, function invocations, expected return values, and expected traps.
Key components
valj(j)— Converts a JSON value descriptor to aValInst. Handles all types includingnan:canonical,nan:arithmetic, and all SIMD lane types.vale(a, b)— Value equality check with NaN propagation and floating-point tolerance (rel_tol=1e-6viamath.isclose). v128 equality also checks float lanes.host(runtime)— Registers thespectestpseudo-module with standard globals, table, memory, and print functions required by spec tests.
Test command types
| Type | Action |
|---|---|
module |
Load a .wasm file |
register |
Make a module's exports available as imports under an alias |
assert_return |
Call a function and compare result |
assert_trap |
Expect an exception (any Exception) |
assert_exhaustion |
Expect a stack depth limit exception |
assert_invalid |
Skipped (pywasm has no static validator) |
assert_malformed |
Skipped |
assert_uninstantiable |
Expect instantiation failure |
assert_unlinkable |
Expect import resolution failure |
action |
Invoke with no result check |
6.10 test/main.py
File purpose
Smoke test that exercises the CLI (python -m pywasm) for a pure-Wasm invocation and a WASI invocation.
call('python -m pywasm --func pi --func-args 7 example/pi/bin/pi.wasm')
call('python -m pywasm --wasi preview1 --wasi-dirs pywasm:pywasm example/wasi_ll/bin/wasi_ll.wasm')
6.11 test/example.py and test/wasi.py
test/example.py runs all Python example scripts. test/wasi.py runs the downloaded WASI test suite binaries through pywasm.
6.12 script/build_wabt.py
Downloads the platform-appropriate WABT release tarball from GitHub (v1.0.41) and extracts it to res/wabt/. Supports macOS, Linux, and Windows. Required because wast2json is needed to compile spec tests.
6.13 script/build_spec.py
Clones the official WebAssembly spec repository (if not already present), checks out a fixed commit (fffc6e12f), and runs wast2json on every .wast file in test/core and test/core/simd, producing JSON + .wasm files for test/spec.py.
6.14 script/build_wasi.py
Downloads the WASI test suite (similar pattern to build_spec.py).
6.15 script/build_example.py
Compiles every Rust crate in example/*/ that doesn't yet have a compiled bin/<name>.wasm. Uses cargo build --release and copies the result to bin/. Also runs wasm2wat to produce a human-readable .wat alongside the binary.
6.16 Example files (example/)
Each example demonstrates a distinct pywasm use case:
| File | What it demonstrates |
|---|---|
fibonacci.py |
Basic API: Runtime, instance_from_file, invocate |
fibonacci_env.py |
Host function injection: allocate_func_host, runtime.imports['env'] |
blake2b.py |
Non-trivial Wasm (crypto hash); output via memory pointer |
blake2b_direct.py |
Return value vs. output pointer patterns |
blake2b_iter.py |
Benchmarking; repeated invocation |
blake2b_simd.py |
SIMD-accelerated variant |
pi.py |
Float-heavy Wasm (f64) |
wasi_stdout.py |
Capturing WASI stdout via pipe = io.BytesIO() |
wasi_zen.py |
Running a WASI binary that prints to stdout |
wasi_ll.py |
WASI with pre-opened directories |
wasi_httpbin.py |
WASI with socket access (HTTP GET) |
The Rust source for each example is in example/<name>/src/. Examples without a main.rs are cdylib (library) crates targeting wasm32-unknown-unknown; WASI examples are bin crates targeting wasm32-wasip1.
6.17 pyproject.toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "pywasm"
version = "2.2.3"
authors = [{ name="Mohanson", email="mohanson@outlook.com" }]
description = "WebAssembly Interpreter by pure Python"
readme = "README.md"
license = { file = "LICENSE" }
Note that python_requires is not set. Any Python 3 version would nominally be accepted by the package metadata, but the code uses match/case (Python 3.10+) and typing.Self (Python 3.11+), so Python ≥ 3.12 is the actual minimum.
6.18 .github/workflows/develop.yaml
Triggered on every push and on pull-request open. Runs on all three platforms (macOS, Ubuntu, Windows) with Python 3.14:
- Download WABT
- Install pywasm in editable mode
- Build spec tests
- Build WASI tests
- Run
test/example.py,test/main.py,test/spec.py,test/wasi.py
7. Execution Flow
7.1 Library usage startup sequence
import pywasm
│
├── arith.py module executed → creates i8, u8, …, f32, f64 singletons
├── leb128.py module executed → creates u, i singletons
├── log.py module executed → lvl = 0
├── opcode.py module executed → 437 constants + name dict
├── core.py module executed → class definitions only
└── wasi.py imported (Linux/macOS only)
runtime = pywasm.core.Runtime()
│
└── Machine()
└── Store()
└── Stack()
runtime.imports = {}
runtime.instance_from_file('module.wasm')
│
├── open('module.wasm', 'rb')
├── ModuleDesc.from_reader(f)
│ ├── Check magic + version
│ └── Loop over sections (type, import, func, table, mem, global, export, start, elem, code, data)
│ Each section → BytesIO sub-reader → section-specific parsing
│
└── Runtime.instance(module)
└── Machine.instance(module, extern_list)
├── Validate imports (count, kind, type)
├── Push aux Frame (for init exprs)
├── Evaluate global init exprs → globin list
├── Evaluate elem init exprs → elemin list
├── Pop aux Frame
├── Machine.allocate(module, extern, globin, elemin)
│ ├── store.allocate_func_wasm × N
│ ├── store.allocate_table × N
│ ├── store.allocate_memory × N
│ ├── store.allocate_global × N
│ ├── store.allocate_elem × N
│ ├── store.allocate_data × N
│ └── Build ExportInst entries
├── Push newmod Frame
├── Run active elem segments
├── Run declarative elem segments
├── Run active data segments
├── Run start function
└── Pop newmod Frame
→ return ModuleInst
7.2 Function call sequence
runtime.invocate(module, 'fibonacci', [10])
│
├── Look up 'fibonacci' in module.exps → func_addr
├── Get func_inst = store.func[func_addr]
├── Build func_args = [ValInst.from_i32(10)]
└── Machine.invocate(func_addr, func_args)
├── Push sentinel Frame
├── Build LocalsInst([ValInst.from_i32(10)] + [zeros for locals])
├── Push real Frame(module=func.module, locals=..., arity=1)
├── Push Label(carry=3, instr=func.code.expr.data)
└── Machine.evaluate()
Loop until label stack empty:
fetch instr from label.instr[label.index++]
match instr.opcode:
... dispatch ...
case call: push new Frame + Label → recursive wasm call
case return: unwind frames
case i32_add: pop 2, push 1
...
← label stack empty
Pop return values from value stack
Pop sentinel Frame
→ return [55] (fibonacci(10))
7.3 Sequence diagram: WASI fd_write call path
sequenceDiagram
participant WasmProg as Wasm Program
participant Eval as evaluate()
participant FuncHost as FuncHost(fd_write)
participant Preview1 as Preview1.fd_write()
participant OS as OS (write syscall)
WasmProg->>Eval: call fd_write (via import table)
Eval->>Eval: pop args from value stack
Eval->>FuncHost: hostcode(machine, [fd, iovs_ptr, iovs_len, nwritten_ptr])
FuncHost->>Preview1: fd_write(machine, args)
Preview1->>Preview1: validate fd, permissions
Preview1->>Preview1: read iovec from machine.store.mems[0].data
Preview1->>OS: os.write(host_fd, data) or pipe.write(data)
OS-->>Preview1: bytes_written
Preview1->>Preview1: put_u32(nwritten_ptr, bytes_written)
Preview1-->>FuncHost: [ERRNO_SUCCESS]
FuncHost->>Eval: push ValInst.from_i32(0)
Eval-->>WasmProg: i32 result on stack
8. API Documentation
8.1 Runtime (primary public interface)
Runtime()
Create a new, isolated WebAssembly runtime.
runtime = pywasm.core.Runtime()
runtime.imports
runtime.imports: dict[str, dict[str, Extern]]
Import registry. Populate before instantiation:
runtime.imports['env'] = {
'my_func': runtime.allocate_func_host(type, callable),
}
runtime.allocate_func_host(type, func) → Extern
Register a Python callable as a WebAssembly host function.
type: FuncType— parameter and return typesfunc: Callable[[Machine, list], list]— implementation
def my_print(machine, args):
print(f"Wasm says: {args[0]}")
return []
extern = runtime.allocate_func_host(
pywasm.core.FuncType([pywasm.core.ValType.i32()], []),
my_print
)
runtime.allocate_memory(type) → Extern
Pre-allocate a linear memory for export to a Wasm module.
extern = runtime.allocate_memory(
pywasm.core.MemType(pywasm.core.Limits(1, 4)) # min=1 page, max=4 pages
)
runtime.allocate_global(type, data) → Extern
Pre-allocate a mutable or immutable global.
extern = runtime.allocate_global(
pywasm.core.GlobalType(pywasm.core.ValType.i32(), 0x01), # mutable i32
pywasm.core.ValInst.from_i32(42)
)
runtime.instance_from_file(path) → ModuleInst
Load a .wasm binary and instantiate it. Resolves all imports from runtime.imports.
Raises: AssertionError if magic/version mismatch, if imports are unsatisfied, or if type validation fails.
runtime.invocate(module, func, args) → list
Call an exported function by name.
module: ModuleInst— frominstance_from_filefunc: str— exported function nameargs: list[int|float|bytearray]— argument values (auto-typed via function signature)
Returns: list[int|float|bytearray] of return values.
result = runtime.invocate(m, 'fibonacci', [10])
# result == [55]
runtime.exported_memory(module, name) → MemInst
Get direct access to an exported linear memory.
mem = runtime.exported_memory(m, 'memory')
data = mem.get(ptr, length) # read bytes
mem.put(ptr, bytearray(...)) # write bytes
runtime.exported_global(module, name) → GlobalInst
Get access to an exported global's current value.
g = runtime.exported_global(m, 'my_global')
print(g.data.into_i32())
g.data = pywasm.core.ValInst.from_i32(99) # modify mutable global
8.2 Core types used in the API
| Type | Construction | Description |
|---|---|---|
ValType.i32() |
Class method | 32-bit integer type |
ValType.i64() |
Class method | 64-bit integer type |
ValType.f32() |
Class method | 32-bit float type |
ValType.f64() |
Class method | 64-bit float type |
ValType.v128() |
Class method | 128-bit SIMD type |
FuncType(args, rets) |
Constructor | Function signature |
Limits(n, m) |
Constructor | Min/max bounds (m=0 means unlimited) |
MemType(limits) |
Constructor | Memory type |
TableType(valtype, limits) |
Constructor | Table type |
GlobalType(valtype, mut) |
Constructor | Global type (mut: 0=const, 1=mut) |
ValInst.from_i32(n) |
Class method | Create i32 value |
ValInst.from_f64(n) |
Class method | Create f64 value |
ValInst.zero(type) |
Class method | Create zero value of given type |
9. Configuration Documentation
9.1 Debug logging
import pywasm
pywasm.log.lvl = 1 # enable verbose debug output
Setting lvl to any non-zero integer enables per-section and per-instruction tracing to stdout. There is no way to redirect this output to a file without monkey-patching pywasm.log.println.
9.2 Memory limit
Machine.evaluate hard-caps memory_grow to 1024 pages (64 MiB). This is not configurable at runtime without modifying core.py:
# core.py:2008
cnda = size + incr <= 1024 # hard cap — change this to increase limit
9.3 Call stack depth
Machine.evaluate_call enforces a 1024-frame limit:
assert len(self.stack.frame) < 1024
This prevents infinite recursion from consuming unbounded memory. Not configurable at runtime.
9.4 WASI return_on_exit
wasi = pywasm.wasi.Preview1(args, dirs, envs)
wasi.return_on_exit = 0 # set to 0 to call sys.exit() instead of raising
Default is 1 (raise an exception). Set to 0 if the Wasm program must cleanly terminate the Python process.
9.5 WASI stdout/stdin capture
import io
wasi.fd[1].pipe = io.BytesIO(bytearray()) # stdout → buffer
wasi.fd[0].pipe = io.BytesIO(b'input data') # stdin ← buffer
9.6 Environment variables and directory mappings (WASI)
wasi = pywasm.wasi.Preview1(
args=['program.wasm', '--flag'],
dirs={'/sandbox': '/host/path/to/dir'}, # wasm_path: host_path
envs={'HOME': '/sandbox', 'LANG': 'C'}
)
10. Build & Deployment
10.1 Installing from PyPI
pip install pywasm
10.2 Installing from source (editable)
git clone https://github.com/libraries/pywasm
cd pywasm
pip install --editable .
10.3 Building a distribution package
pip install build
python -m build # creates dist/pywasm-2.2.3.tar.gz and dist/pywasm-2.2.3-py3-none-any.whl
The build uses hatchling. The wheel is universal (py3-none-any) because the package is pure Python.
10.4 Compiling Wasm examples (developer only)
Requires Rust with wasm32-unknown-unknown and wasm32-wasip1 targets:
rustup target add wasm32-unknown-unknown wasm32-wasip1
python script/build_wabt.py # download wabt tools
python script/build_example.py # compile all Rust examples
10.5 Running tests
python script/build_wabt.py # one-time
python script/build_spec.py # clone + compile spec tests
python script/build_wasi.py # clone WASI test suite
python test/example.py # run all examples
python test/main.py # CLI smoke tests
python test/spec.py # spec conformance (slow)
python test/wasi.py # WASI conformance
10.6 CI/CD pipeline
GitHub Actions (.github/workflows/develop.yaml):
- Triggers on every push and PR open
- Matrix: macOS, Ubuntu, Windows × Python 3.14
- Steps: checkout → setup-python → build_wabt → install pywasm → build tests → run all tests
- No deployment step (PyPI publishing is manual)
10.7 Versioning strategy
Version is set in two places:
pyproject.toml:version = "2.2.3"pywasm/__init__.py:version = '2.2.3'
There is no automated version bumping. A developer must update both files manually before releasing.
11. Security Analysis
11.1 Trust boundaries
Untrusted: .wasm file content
Trusted: Python host code, WASI pre-opened directory paths
11.2 Sandboxing properties
Memory access: All MemInst.data accesses use Python slice indexing with explicit bounds assertions. Out-of-bounds accesses raise AssertionError (trapped), not segfaults. The interpreter cannot access host memory outside the bytearray.
File system: WASI path_* functions call help_escp to prevent directory traversal. Pre-opened directories define the sandbox boundary. A Wasm program cannot access paths outside pre-opened directories.
Network: Socket operations require an existing socket to be passed in via the fd table. The Wasm program cannot initiate new connections independently; it can only operate on sockets the host has pre-opened.
Process isolation: The Wasm program cannot spawn processes, load shared libraries, or access the Python interpreter's objects directly. The only interaction is through the host-function API.
11.3 Potential vulnerabilities
| Risk | Severity | Notes |
|---|---|---|
assert for bounds checking |
Medium | assert statements are disabled with python -O. Running pywasm optimized disables all safety checks. This would cause incorrect behavior, not necessarily a sandbox escape, but is undesirable. |
| LEB128 infinite loop | Low | A malformed .wasm with a missing stop byte in LEB128 data would loop forever. No maximum byte count guard. |
| Memory limit hard-coded | Low | 64 MiB cap may be bypassed if running modules that request exactly 1024 pages then rely on unguarded growth. |
WASI help_escp reliability |
Medium | The path-escape check uses os.path.normpath and string prefix matching. On case-insensitive filesystems (macOS default, Windows) with mixed-case paths, escapes might be possible. |
random.randbytes |
Low | Uses Python's random module (Mersenne Twister), not a cryptographically secure RNG. WASI's random_get is documented to provide "high-quality randomness" — this implementation does not meet that guarantee. |
| No resource limits | Medium | A runaway Wasm program can exhaust CPU time and Python heap. No timeout or memory-use limits exist beyond the page cap. |
11.4 Authentication / authorization
pywasm has no authentication mechanism. It is a library; access control is the responsibility of the embedding application.
12. Performance Analysis
12.1 Baseline characteristics
pywasm is an interpreter with no JIT compiler. Every WebAssembly instruction is a Python function call (dictionary lookup + match case). Rough order-of-magnitude:
- Native Wasmtime: ~1 ns/instruction
- pywasm on CPython: ~1 µs/instruction (1000× slower)
- pywasm on PyPy:
100 ns/instruction (10× faster than CPython)
12.2 Primary bottlenecks
| Bottleneck | Explanation |
|---|---|
match instr.opcode dispatch |
Python's structural pattern matching is not as fast as a switch table. Each instruction requires traversing the match cases. |
ValInst allocation |
Most instructions create a new ValInst (16-byte bytearray + type). This generates significant GC pressure. |
| SIMD list comprehensions | Each SIMD instruction converts the 16-byte bytearray to a Python list, operates element-wise, and re-packs. This is 16–32 Python object operations per SIMD instruction. |
arith.clz/ctz/popcnt |
Pure Python bit loops for O(64) iterations. |
| Debug mode overhead | pywasm.log.lvl = 1 calls f-string formatting and print() for every instruction. |
12.3 Scalability
pywasm is single-threaded. There is no support for threading or asyncio. Multiple modules can share a single Store (as demonstrated by the spec test runner which loads many modules into one Runtime), but execution is sequential.
12.4 Optimization opportunities
| Opportunity | Expected gain |
|---|---|
Use int.bit_count() for popcnt |
Replaces O(N) loop with native method |
Cache label.instr[label.index] in a local |
Reduces attribute lookups per iteration |
| Pre-compute instruction list as a flat array of tuples | Reduce Inst object overhead |
| Compile frequently-called functions to Python bytecode (JIT) | The "JIT-friendly" design comment suggests this is intended |
Pool ValInst objects (flyweight for common values) |
Reduce allocation/GC pressure |
Use memoryview for memory slices |
Avoid bytearray copy in evaluate_mem_load |
13. Developer Guide
13.1 Prerequisites
| Tool | Version | Purpose |
|---|---|---|
| Python | ≥ 3.12 | Runtime and development |
| Rust + cargo | stable | Compile example .wasm binaries |
| wasm32 targets | — | rustup target add wasm32-unknown-unknown wasm32-wasip1 |
| WABT (auto-downloaded) | 1.0.41 | wast2json for spec tests |
| Git | any | Clone spec and WASI test repos |
13.2 First-time setup
# 1. Clone
git clone <repo>
cd pywasm
# 2. Install (editable)
pip install --editable .
# 3. Download dev tooling
python script/build_wabt.py
# 4. Optional: compile Rust examples (requires Rust)
python script/build_example.py
# 5. Run basic tests (no spec tests needed)
python test/example.py
python test/main.py
13.3 Running a quick smoke test
import pywasm
runtime = pywasm.core.Runtime()
m = runtime.instance_from_file('example/fibonacci/bin/fibonacci.wasm')
r = runtime.invocate(m, 'fibonacci', [10])
assert r == [55], r
print("OK")
13.4 Enabling debug tracing
import pywasm
pywasm.log.lvl = 1
runtime = pywasm.core.Runtime()
m = runtime.instance_from_file('example/fibonacci/bin/fibonacci.wasm')
r = runtime.invocate(m, 'fibonacci', [3])
# Output:
# 2026/06/07 12:00:00 pywasm: section type [i32] -> [i32]
# 2026/06/07 12:00:00 pywasm: invocate 0 [i32 3]
# 2026/06/07 12:00:00 pywasm: local.get 0
# 2026/06/07 12:00:00 pywasm: i32.const 1
# ...
13.5 Injecting a host function
import pywasm
def my_log(machine: pywasm.core.Machine, args: list) -> list:
print(f"[WASM LOG] value={args[0]}")
return []
runtime = pywasm.core.Runtime()
runtime.imports['env'] = {
'log': runtime.allocate_func_host(
pywasm.core.FuncType([pywasm.core.ValType.i32()], []),
my_log
)
}
m = runtime.instance_from_file('my_module.wasm')
runtime.invocate(m, 'run', [])
The corresponding Rust code would declare the import:
extern "C" { fn log(value: i32); }
13.6 Reading / writing linear memory
mem = runtime.exported_memory(m, 'memory')
# Read a null-terminated C string at address 1000
ptr = 1000
end = mem.data.index(0, ptr)
s = mem.data[ptr:end].decode('utf-8')
# Write 4 bytes at address 2000
mem.put(2000, bytearray([0x01, 0x02, 0x03, 0x04]))
13.7 Adding a new opcode
Add the constant to
opcode.py:my_new_op = 0xXX name[my_new_op] = 'my_new.op'Add decode logic in
Inst.from_readerincore.py(if it has immediates):case pywasm.opcode.my_new_op: o.args.append(pywasm.leb128.u.decode_reader(r)[0]) # if it has an argAdd execution logic in
Machine.evaluateincore.py:case pywasm.opcode.my_new_op: a = self.stack.value.pop().into_i32() b = ValInst.from_i32(my_operation(a)) self.stack.value.append(b)
13.8 Adding a new WASI syscall
- Add constant(s) to
Preview1class (errno, flags, etc.) - Write the implementation method:
def my_syscall(self, m: pywasm.core.Machine, args: list[int]) -> list[int]: if self.help_badf(args[0]): return [self.ERRNO_BADF] # ... implementation ... return [self.ERRNO_SUCCESS] - Register in
Preview1.bind():['my_syscall', [i32, i32], [i32], self.my_syscall],
13.9 Running spec tests
python script/build_spec.py # one-time
python test/spec.py
Individual test files can be isolated by modifying all_test in test/spec.py.
13.10 Debugging a failed spec test
- Set
pywasm.log.lvl = 1intest/spec.py(line 8) - Filter
all_testto a single file:all_test = ['res/spec/test/core/i32.json'] - Filter the JSON commands to a specific test case index
14. Improvement Recommendations
14.1 Architectural improvements
| Recommendation | Rationale |
|---|---|
| Separate decoder from core | ModuleDesc and all *Desc classes live in core.py alongside the machine. Splitting into a decoder.py would improve navigability. |
| Introduce a proper exception hierarchy | All errors currently use AssertionError (for traps) or bare Exception. A WasmTrap, WasmLinkError, WasiError hierarchy would allow callers to distinguish error types. |
| Make memory limit configurable | The 1024-page hard cap and 1024-frame depth limit should be parameters of Runtime, not magic numbers in core.py. |
| Thread-safe multi-module execution | A threading.Lock around the store's allocation lists would allow concurrent instantiation in multi-threaded embedder scenarios. |
14.2 Code quality improvements
| Recommendation | Rationale |
|---|---|
Add python_requires = ">=3.12" to pyproject.toml |
Prevents confusing failures on older Python. |
| Synchronize version from one source | Use importlib.metadata.version('pywasm') in __init__.py or a version file, to avoid version drift between pyproject.toml and __init__.py. |
Replace assert with explicit checks |
assert is disabled with python -O. Safety-critical bounds checks should be if ... raise .... |
Add type hints to wasi.py methods |
The args: list[int] hint is accurate for most syscalls, but should be `list[int |
| Separate SIMD opcode handling | The 1200+ SIMD cases in evaluate() could be extracted into a evaluate_simd(instr) helper to reduce the size of the main loop. |
14.3 Maintainability improvements
| Recommendation | Rationale |
|---|---|
| Add docstrings | The code relies entirely on inline comments. Docstrings on Runtime, Machine, Store, Preview1 would improve IDE integration. |
| Introduce a changelog | There is no CHANGELOG.md. Contributors cannot see what changed between versions. |
| Pin WABT version in CI | build_wabt.py downloads a hardcoded v1.0.41. This should match the version expected by the spec checkout (fffc6e12f). |
14.4 Performance improvements
| Recommendation | Priority | Expected gain |
|---|---|---|
Replace arith.popcnt with int.bit_count() |
Low | 64× speedup for popcount |
Pool common ValInst objects (zero, small integers) |
Medium | Reduce GC pressure by ~30% |
Represent instructions as (opcode, *args) tuples |
Medium | Smaller objects, fewer attribute lookups |
Add optional Cython extension for evaluate() |
High | 5–50× speedup for CPU-bound code |
Implement a basic JIT via compile() / exec() |
Very High | Could match PyPy performance on CPython |
14.5 Security improvements
| Recommendation | Rationale |
|---|---|
Replace assert with raise for memory OOB |
python -O disables assert; OOB access would corrupt host memory. |
Use os.urandom for random_get |
Replace random.randbytes with os.urandom to meet the "high-quality randomness" WASI spec requirement. |
| Add path traversal tests for WASI | help_escp logic should be tested against edge cases: symlinks, .. components, Windows paths, Unicode normalization. |
| Add LEB128 max-length guard | Cap LEB128 reads at ceil(64/7) = 10 bytes to prevent infinite loops on malformed input. |
Documentation generated by code analysis of pywasm v2.2.3. All line references are based on the repository state at the time of analysis.