Skip to content

Running

What a program looks like

One complete working program, and a plain list of the shapes that do not compile. Read it in a minute and you will know whether your code is in scope.

On this page

Compiles and runs

Classes holding collections of instances, inheritance with virtual dispatch, methods calling methods, string and dict work, comprehensions, f-strings, exceptions as objects, generators, closures, coroutines, printing and file I/O through WASI, and imports across your own files.

Refused, loudly

Everything not implemented is a compile error naming the construct, its line, the enclosing function, and a workaround. A refusal is the design, not a bug.

Answers differently

A short documented list, led by int, which is 32 bits and wraps instead of growing, and a NaN inside a collection, which does not equal itself: see Where the answer differs. Everywhere else the rule is Python's answer or a loud failure.

A whole program

This is the code of examples/shopping_cart.py from the repository, as it is apart from its module docstring. Nothing in it was chosen to show off a compiler feature, and its results are asserted against what CPython answers for the same source, under three WebAssembly runtimes.

shopping_cart.py
class Item:
    def __init__(self, name: str, price: float, qty: int):
        self.name = name
        self.price = price
        self.qty = qty

    def subtotal(self) -> float:
        return self.price * self.qty


class Cart:
    def __init__(self):
        self.items = []
        self.count = 0

    def add(self, name: str, price: float, qty: int):
        item = Item(name, price, qty)
        self.items.append(item)
        self.count = self.count + 1

    def total(self) -> float:
        out: float = 0.0
        for it in self.items:
            out = out + it.subtotal()
        return out

    def units(self) -> int:
        n: int = 0
        for it in self.items:
            n = n + it.qty
        return n


def discount_rate(total: float) -> float:
    if total >= 100.0:
        return 0.10
    if total >= 50.0:
        return 0.05
    return 0.0


def checkout() -> float:
    cart = Cart()
    cart.add("widget", 9.99, 3)
    cart.add("gadget", 24.50, 2)
    cart.add("doohickey", 5.00, 4)
    subtotal = cart.total()
    rate = discount_rate(subtotal)
    return subtotal - subtotal * rate


def item_count() -> int:
    cart = Cart()
    cart.add("a", 1.0, 2)
    cart.add("b", 2.0, 3)
    return cart.count * 100 + cart.units()


def empty_cart_total() -> float:
    cart = Cart()
    return cart.total()

Compiled, every function above is an export a host can call: checkout() answers 94.0215, item_count() answers 205, and empty_cart_total() answers 0.0, as CPython does. What you need to know to write the same:

Annotations
Parameter and return annotations pick the WebAssembly value types. A collection passed into or out of a function needs the parameterised form, List[str] rather than a bare list: the parameterised form is what gives the compiler an element type. Calls may pass arguments by keyword, and functions and __init__ collect extras with *args: T and **kwargs: T.
Entry points
Every module-level def is exported. Only definitions run at module level; put the code that runs in a function, or under if __name__ == "__main__":, which makes the module a WASI command that wasmtime run runs like python3.
Imports
import mod and from mod import f resolve sibling .py files (and pkg/mod.py packages) from disk and link them into the one output module, each compiled once. No PyPI packages: beyond your own modules, the supported subset is standard-library only.
Side effects
print(), input(), sys.argv, and open() go through WASI, and a module imports only the WASI functions it uses, so the program above imports nothing at all. See Running compiled modules.

What it costs

Median of nine release-build compilations on an Apple M1 Pro, via just benchmark. Wall-clock numbers from one machine, so read them for orders of magnitude, not percentages.

Program Source Compile Module Compile (opt) Module (opt)
shopping_cart2.0 KiB0.5 ms1.4 KiB1.8 ms1013 B
text_report3.4 KiB1.9 ms33.7 KiB14.4 ms31.8 KiB
library_project3.3 KiB2.9 ms14.1 KiB11.1 ms6.6 KiB
nested_collections4.7 KiB2.2 ms60.8 KiB30.5 ms55.5 KiB

A whole program compiles in a few milliseconds into a module of one to a few tens of kilobytes, with no interpreter bundled in: the module is the program, plus the runtime helpers it calls (float formatting, string case mapping, and the like), each built only when a program uses it. Optimization is where the time goes. Binaryen costs 4 to 14 times the rest of the pipeline and takes 6 to 54% off the module, so it is worth running for something you ship and worth skipping in a fast edit loop, which is what CompilerOptions { optimize: false, ..CompilerOptions::default() } is for. The first three rows are end-to-end programs from the repository; nested_collections is the feature example with the largest module, as a second data point at a different shape.

The rule the compiler holds itself to: a construct either produces Python's answer or fails loudly. It never quietly produces a different one.

There are a few documented exceptions. Integer width is the first: int is a 32-bit two's-complement value, so 1000000 * 1000000 wraps to -727379968 where CPython answers 1000000000000. If your arithmetic can exceed 32 bits, your program is not in the supported subset. Another is a NaN inside a collection, which does not equal itself. Both are explained under Numbers, and Limitations has the full list.

Shapes that do not compile

Tagged by what happens when you try. refused is a compile error you will see immediately; traps stops the module at run time instead of answering; differs is a case where the answer is not Python's; workaround means there is a supported way to write the same thing.

  • Module-level statements other than definitions

    refusedworkaround

    A loop, if, try, bare call, augmented assignment, tuple unpacking, or write through a subscript or attribute at module level. Only definitions run when a module starts, so the statement would be compiled away. X = 1, X = helper(), X = ClassName(), def, class, and imports (a try/except ImportError guarded import included) do work, and a definition that is not a plain constant is evaluated once, at instantiation, in source order, so every function shares the same list or instance. Move the rest into a function, or under if __name__ == "__main__":.

  • Asynchronous generators and comprehensions

    refused

    async def, await, async with, and async for over a class with __aiter__ and __anext__ work, on asyncio's event loop compiled with the program (see Coroutines and asyncio). An async def that yields, and so an async for over one, an asynchronous comprehension, and an async def inside a function are refused.

  • Starred parameters on methods, ** unpacking in a call, positional-only parameters

    refused

    Functions and __init__ take *args: T (a tuple of T), **kwargs: T (a dict[str, T]), and keyword-only parameters, f(*xs) passes a list or tuple on as *args, and keyword arguments work in every call. Other methods take neither starred nor keyword-only parameters, a call cannot unpack a dict with **, and / is refused. Every call to one of the module's own functions is checked against its signature, so a surplus or missing argument is a compile-time TypeError.

  • match, except*, type aliases, from x import *

    refused

    Not implemented. Each is reported by name with its location, and so is del of an attribute or a stepped slice. global, nonlocal, nested def, assert, conditional expressions, and del of a name, a list index, a list slice, or a dict key do work.

  • Subclassing a builtin, or a base that is not a class name

    refusedworkaround

    class Stack(list), class Config(dict), and a base computed by a call are refused: an instance would have to be the builtin's value too, and leaving the base out is a wrong answer. Subclass collections.UserList, UserDict, or UserString, which are ordinary classes here, or hold the value in a field.

  • Metaclass shapes beyond __new__, __init__, and __call__

    refused

    Multiple inheritance works, along CPython's C3 method resolution order with cooperative super(), and so do metaclasses: a metaclass's __new__ and __init__ run once for each class it makes, specialized at compile time, and its __call__ replaces instantiation. The class namespace argument, __prepare__, creating a class by calling the metaclass, and class keywords other than metaclass= are refused.

  • An override that changes the signature

    refusedworkaround

    Every implementation of an overridden method is reached through one indirect call, which names a single WebAssembly signature, so def m(self) in the base and def m(self, k: int) in the subclass is refused. Give the override the same parameters and return type.

  • Indexing, measuring, or calling a method on a parameter nothing types

    refusedworkaround

    A lambda's parameters, and a nested function's unannotated ones, take their types from the calls in their scope when those agree, and a sorted() key's from the elements it is given, so sorted(pairs, key=lambda kv: kv[1]) works. A parameter nothing types (a lambda only passed on to an unannotated parameter, or called with an int in one place and a str in another) holds an untyped word, so kv[1] or len(w) on it is refused rather than answering from that word. Use a named def, whose parameters can be annotated.

  • A decorator the compiler cannot apply

    refusedworkaround

    The method kinds, @dataclass, @abstractmethod, @functools.total_ordering, @functools.singledispatch with @f.register, the caching decorators (accepted, no effect), and decorators the program writes itself all work on module functions: a user decorator is applied at compile time and specialized for each function it wraps, factories and stacks included. functools's @cached_property and @singledispatchmethod work on methods. A user decorator on a method or a class, a wrapper that uses *args other than to pass it on, and a decorator name the program never defines are refused. Write the wrapper's work inline, or use @property.

  • Comparing collections whose element types differ or are unknown

    refusedworkaround

    Tuples, lists, and dicts compare by value, and a tuple works as a set member or a dict key. Comparing two with different element types ((1, 2) == (1.0, 2), True in CPython) or with element types the compiler cannot see is refused rather than read at the wrong width, and so is a list, dict, or set used as a set member or dict key (unhashable in CPython). Annotate the collections (List[Tuple[int, int]]) so both sides are known.

  • Mixing types in one collection

    refusedworkaround

    Every slot in a collection is read at one width, so a literal mixing types no one type holds ([1, "a"], [1, True]) is refused; use a class or a tuple for a record. An int written into a float collection is refused too, since CPython keeps the int (xs.append(3) on a List[float]). So is the other direction, because storing 2.5 where the slot is read as an integer would truncate it. Make the elements one type.

  • Reading a field through an unannotated parameter

    refusedworkaround

    def total(cart): return cart.count has no type for cart, so the read is refused with a hint rather than answering 0. Annotate the parameter (cart: Cart). The rich comparison methods are the exception: an unannotated other in __eq__, __lt__, and the rest is typed as the class.

  • A string, float, collection, or instance through an untyped place

    refusedworkaround

    An unannotated parameter or return, and the elements of a bare list, set, or dict parameter or return, hold one word read back as an int. Ints, bools, None, and functions pass through; anything else is refused, since a string would lose its length and a float its width. Annotate the parameter, or write List[str] and Dict[str, int]. A bare annotation on a variable is fine: its elements take the type of the value assigned.

  • x is None on an untyped value, and dict.get(key) without a default

    refusedworkaround

    None is the word 0, which an untyped value can also be, so the test cannot be answered there. An Optional[int], Optional[bool], or Optional[float] is boxed, so is None, ==, and truthiness on one give CPython's answer; arithmetic between two optional numbers is refused. dict.get(key) with no default is refused unless the values are numbers or instances, since a string or collection has no distinct word for the None a missing key answers; pass a default.

  • str.format or % on a format string built at run time

    refused

    The whole format specification mini-language works in f-strings, format(), .format(), and %, digit for digit with CPython. .format() and % need a literal format string, and a * width or precision in %, or a precision on %d, %x, or %o, is refused. A spec CPython rejects is a compile error carrying CPython's message when it is a literal, and traps when it is built at run time.

  • ** with a negative int exponent, or a negative float to a fractional power

    traps

    Float powers are CPython's, so 2.0 ** 0.5 works. A negative integer exponent traps because Python's answer is a float an int cannot hold, and a negative float to a fractional power traps because its answer is a complex number. float() and int() of a string with non-ASCII digits or spaces trap too, where CPython would accept them.

  • Two modules defining the same function name

    refusedworkaround

    Merged modules share one namespace, so utils.format next to report.format is a compile error naming both files. Rename one.

  • Object lifetime

    differs

    Memory is reclaimed, and __del__ runs when the last reference goes: as a function returns, a name is rebound or deleted, or a collection lets go of what it held, in CPython's order. A generator abandoned mid-iteration runs its finally blocks then too. What differs is a temporary, which lives to the end of its statement (so print(Res().name) prints before the finalizer runs), and an object in a reference cycle, which is finalized whenever the collector next runs rather than at CPython's count.

  • A NaN inside a collection

    differs

    With x = float("nan"), (x,) == (x,) and [x] == [x] are False here, where CPython answers True: floats are unboxed values, so there is no object identity for CPython's same-object shortcut to find. x == x is False in both.

  • Tracebacks

    differs

    No traceback is kept. An exception a __main__ block does not catch writes only the last line of CPython's report (ValueError: boom) to standard error and exits with status 1.

  • A generator expression kept in a variable or passed to your own code

    refusedworkaround

    A generator expression goes where something walks it once: an argument of a builtin or standard-library call (sum(x * x for x in xs)), a for statement, or a comprehension. g = (f(y) for y in ys), returning one, and passing one to a function of your own are refused, because it is built as a list, which can be walked twice and measured where CPython's generator cannot. Write a list comprehension for a list, or a generator function for a lazy sequence.

  • A generator expression's side effects, inside a builtin call

    differs

    sum(f(y) for y in ys) calls f for every element before sum() reads the first, where CPython interleaves them. The answer is the same; only the order of what f prints or changes can differ. A for statement over one, and any() and all(), pull it as CPython does.

  • Third-party packages, and what the standard library does not implement

    refused

    No PyPI. sys, os, os.path, math, random, json, re, datetime, logging, collections, itertools, functools, and asyncio run on runtime values, checked against CPython 3.13. What is not implemented is refused at compile time, by name: the modules board tracks each feature, and Limitations has the rules these modules follow.

Where to look next

The repository's examples/ directory holds the programs this page is drawn from, each asserted against CPython in the test suite.