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, 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

Two documented cases: int is 32 bits and wraps instead of growing, and a NaN inside a collection does not equal itself. 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. There is no top-level run step, so module level holds definitions only; 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 two 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. The second is a NaN inside a collection, which does not equal itself (see the list below). Both are explained under Numbers.

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. A WebAssembly module has no top-level run step, 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__":.

  • async def, await, async for, async with

    refused

    There is no event loop in the output.

  • 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, del name, 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 list index, list slice, or dict key do work.

  • 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. A user decorator on a method or a class, a wrapper that uses *args other than to pass it on, @cached_property, @singledispatchmethod, and a decorator name the program never defines are refused, and so are calls to functools.partial, reduce, and cmp_to_key. Write the wrapper's work inline, or use @property.

  • Comparing collections whose element types differ or are unknown

    refusedworkaround

    Tuples and lists 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 are equality between dicts and 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 widened for you (xs.append(3) on a List[float]). The other direction is refused, 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 where the values are ints, floats, or strings, which have 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 fractional or negative exponent

    traps

    Powers are computed by repeated multiplication. 2.0 ** 0.5 traps, because it needs exp/log this runtime does not carry, and a negative integer exponent traps because Python's answer is a float an int cannot hold. 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

    The allocator has no free: every instance lives until the module is torn down, and __del__ is never invoked. A long-running program that allocates in a loop will grow its memory, and a generator abandoned mid-iteration does not run its finally blocks until close() is called.

  • 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.

  • Third-party packages, and most of the standard library

    refused

    No PyPI. Standard-library support is small so far: the module constants (math.pi, os.sep, ...), sys.stdin, sys.stdout, sys.stderr, sys.argv, and sys.exit(), re.sub and re.escape on constant arguments, and the functools decorators. Every other function in sys, os, os.path, math, random, json, re, datetime, logging, collections, itertools, and functools is refused at compile time, and each is tracked on the modules board.

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.