DEV Community

Cover image for No Magic: Running Wasm Modules in Python
Radmir
Radmir

Posted on • Originally published at grindmachine.xyz

No Magic: Running Wasm Modules in Python

WebAssembly is a pretty young technology and a promising one, it has many pros (platform independent, lighter than containers) and usage scenarios (browser apps, plugin system, edge computing).

WASM is designed to be language-agnostic. However, working with it from Python as a host may be not so obvious.


Basically, WASM application consists of two parts: host and guest.

  • Host is the environment that runs wasm and controls its execution (e.g. a browser or a runtime like wasmtime).

  • Guest is the pluggable wasm module, that is built from Rust, C, Wat, Python, or whatever you want. It runs in sandboxed host environment

Image of interaction

To simplify guest <-> host interaction, there is a tool called Wasm Interface Type (WIT) - we can just describe interfaces in .wit files and then generate bindings for both (guest/host) sides and implement logic.

As of now (february 2026), full automatic host-side binding generation for python remains incomplete.
However, wasmtime.component provides low-level approach like convert_to_c / convert_from_c for marshalling types.

In this article we look how to run wasm modules from python and implement interaction manually (even without wasmtime.component), using just wasm linear memory. I think it's pretty interesting to look how wasm interacts internally.


Ok, let's move next and talk about pluggable modules.

Guest modules can export / import functions. But function arguments can be passed only as simple numeric types - i32 i64 f32 f64.

If a function needs more complex data (like strings, structs, vectors), it must be transferred through linear memory.

wasm memory

Host <-> guest interaction works through wasm linear memory.

Both host and guest have full access to wasm instance memory. For example:

  1. Host wants to send data to the instance, it writes the data into the instance’s memory at some offset.
  2. Then the host calls a wasm function, passing an i32 pointer to that data.
  3. The wasm code can then read memory from that pointer.

component model

A little historical note.
The name WebAssembly comes from its origins: it was designed to bringing low-level "assembly-like" performance to the web — specifically for running code in browsers at near-native speed. It started as a Mozilla project around 2015, became a W3C standard in 2019, and initially targeted browser apps only.

But WebAssembly quickly outgrew the browser sandbox. To run wasm modules outside browsers, a standard way to access system resources safely was needed. This led to the WebAssembly System Interface (WASI) — a portable, capability-based API layer for wasm modules.

Then, in January 2024, the Bytecode Alliance released WASI 0.2 (also known as Preview 2 / P2), which officially incorporated the WebAssembly Component Model.

Instead of dealing with raw linear memory pointers and low-level functions, components communicate using structured, language-agnostic types defined in WIT. This enables true interoperability between components written in different languages.

wit

The wasm interface type language is used to define interfaces for interaction between host and guest.

package xyz:machine;

interface machine-interface {
    type machine-id = u64;

    record point {
        x: u32,
        y: u32,
    }

    record run-result {
        machine-id: machine-id,
        message: string,
    }

    get-u32: func() -> u32;
    get-str: func() -> string;
    get-vec: func() -> list<u8>;
    run: func(machine: machine-id, start: point, destination: point) -> run-result;
    count-symbols: func(s: string) -> u32;
}

world machine {
    export machine-interface;
}
Enter fullscreen mode Exit fullscreen mode

With this approach developers can avoid implementing interfaces manually and just generate host / guest parts code with such tools as wit-bindgen, wasmtime-bindgen etc

the issue with python

But... Python does not fully support these codegen tools and has only low-level support of wasm interface types.

This means on python side implementation must be manually implemented, even if wit is used in module side to simplify module implementation.

Let's say wasm module is written in rust with wit-bindgen

rust side (guest)

wit_bindgen::generate!({
    path: "wit",
    world: "machine",
});

use crate::exports::xyz::machine::machine_interface::{Guest, MachineId, Point, RunResult};

struct Machine;

impl Guest for Machine {
    fn get_u32() -> u32 {
        123
    }

    fn get_str() -> String {
        String::from("simple string to return")
    }

    fn get_vec() -> Vec<u8> {
        return vec![5, 4, 3, 2, 1, 10];
    }

    fn run(machine: MachineId, start: Point, destination: Point) -> RunResult {
        RunResult {
            machine_id: 1,
            message: format!("machine {machine} run from {start:?} to {destination:?}"),
        }
    }

    fn count_symbols(s: String) -> u32 {
        s.chars().count() as u32
    }
}

export!(Machine);
Enter fullscreen mode Exit fullscreen mode

And to compile module:

cargo build --target wasm32-unknown-unknown
Enter fullscreen mode Exit fullscreen mode

note: with wasm32-unknown-unknown target code will be compiled as module. To compile component, use wasm32-wasip2 (Preview 2) or wasm32-wasip3 (Preview 3)

python side (host)

simple argument and return value

import struct
from wasmtime import loader, Config, Engine, Store, Module, Linker

engine = Engine()
store = Store(engine)
linker = Linker(engine)

wasm_intro = Module.from_file(engine, "wasm_intro.wasm")
instance = linker.instantiate(store, wasm_intro)
exports = instance.exports(store)
memory = exports['memory']

print([e for e in exports])
# ['memory', 'xyz:machine/machine-interface#get-u32', 'xyz:machine/machine-interface#get-str', 'cabi_post_xyz:machine/machine-interface#get-str', 'xyz:machine/machine-interface#get-vec', 'cabi_post_xyz:machine/machine-interface#get-vec', 'xyz:machine/machine-interface#run', 'cabi_post_xyz:machine/machine-interface#run', 'xyz:machine/machine-interface#count-symbols', 'cabi_realloc']

# call function
get_u32 = exports['xyz:machine/machine-interface#get-u32']
print(get_u32(store)) # 123
Enter fullscreen mode Exit fullscreen mode

This was a simple interface, now let's write host code for functions with more complex interfaces.

complex return value (string / vector)

As you probably noticed before, every xyz:machine/* function has cabi_post_xyz:machine/* pair.
This cabi_post* functions must be called to free the memory after a string, vector or another value is handled and no longer needed.

# string
get_str = exports['xyz:machine/machine-interface#get-str']
cabi_post_get_str = exports['cabi_post_xyz:machine/machine-interface#get-str']

def parse_string(memory: wasmtime.Memory, store: wasmtime.Store, ptr: int) -> str:
    STR_SIZE = 8
    string_struct = memory.read(store, ptr, ptr + STR_SIZE)
    str_ptr, str_len = struct.unpack('<II', string_struct)
    str_bytes = memory.read(store, str_ptr, str_ptr + str_len)
    return str_bytes.decode()

str_ptr = get_str(store)
print(parse_string(memory, store, str_ptr)) # "simple string to return"
cabi_post_get_str(store, str_ptr)


# vector of u8
get_vec = exports['xyz:machine/machine-interface#get-vec']
cabi_post_get_vec = exports['cabi_post_xyz:machine/machine-interface#get-vec']

def parse_u8_vec(memory: wasmtime.Memory, store: wasmtime.Store, ptr: int) -> list[int]:
    U8_SIZE = 1
    bytes = memory.read(store, ptr, ptr + 8)
    data_ptr, length = struct.unpack('<II', bytes)
    print(data_ptr, length)

    next_elem_ptr = data_ptr
    vec_bytes = memory.read(store, data_ptr, data_ptr + length)

    return list(vec_bytes)

vec_ptr = get_vec(store)
print(parse_u8_vec(memory, store, vec_ptr)) # [5, 4, 3, 2, 1, 10]
cabi_post_get_vec(store, vec_ptr)
Enter fullscreen mode Exit fullscreen mode

Note: If the vector contains not u8 values but, for example, structs, remember to account for alignment between elements. According to the CABI, the alignment of a struct is equal to the alignment of its largest field.

complex argument

In order to send complex data, e.g string to wasm module it is required to write data to memory. But where it should be written?

wit_bindgen provides cabi_realloc function that can be used to allocate memory, and then it returns offset in linear memory. Definition is something like cabi_realloc(old_ptr:i32, old_size:i32, align:i32, new_size:i32) -> i32

count_symbols = exports['xyz:machine/machine-interface#count-symbols']
cabi_realloc = exports['cabi_realloc']

encoded_str = "string with symbols (~25)".encode()
ptr = cabi_realloc(store, 0, 0, 4, len(encoded_str))
memory.write(store, encoded_str, ptr)  # write to instance memory
count = count_symbols(store, ptr, len(encoded_str))  # note ptr is offset of the string in instance memory
print(count)  # 25
Enter fullscreen mode Exit fullscreen mode

Notice that there is no need to clean up string data written into instance memory from the host side. When count_symbols is called we give ownership of this data to wasm module, so it must manage this memory itself and free it when data is no longer needed.

conclusion

That's all.

Despite the fact that wasm is promising for interaction between different subsystems, so far, tool support for languages other than rust may be quite limited. But as discussed before, some things are easy to do manually.

Wasm modules seem interesting as a more lightweight replacement of containers, and there are few interesting projects such as Spin, WasmCloud and others.

additional

There are also a bunch of useful CLI tools:

  • wit-bindgen - generates guest bindings from .wit definitions.
  • componentize-py is used to package Python code into a component (on the guest-side)
  • wasm-objdump - inspect wasm binaries. Useful for debugging or understanding module.
  • wasm2wat - converts a .wasm binary into human-readable WAT (WebAssembly Text format).

Top comments (0)