Language Limitations#

Read this before writing your first project.

PyMCU compiles a statically-typed, allocation-free subset of Python to bare-metal machine code. There is no runtime, no heap, no garbage collector, and no interpreter. Many standard Python features are therefore incompatible with this model.

Standard Library Philosophy

Because of the architectural differences between a PC and a bare-metal microcontroller, PyMCU does not attempt to replicate the CPython standard library 1:1.

Instead, PyMCU adopts the philosophy and API design of MicroPython and CircuitPython (specifically the machine and board modules) as its official user-facing standard library. This ensures that code written for PyMCU looks familiar to developers coming from the broader Python-on-hardware ecosystem, even though it executes entirely differently.

This page lists every known unsupported feature, explains why it cannot be compiled, and suggests the idiomatic PyMCU alternative where one exists.


Dynamic memory and containers#

Feature

Why it fails

Alternative

list.append(x) on a fixed-size array

Fixed arrays have no append

list[uint8] heap-bounded list, or uint8[N] fixed-size array

Growing dict (unbounded)

Hash table requires heap

pymcu.collections.FixedDict(capacity) (mutable, fixed footprint), a closed dict literal (below), or match / case key dispatch

Mutable set (.add())

Hash set requires heap

Closed set literal (below), or a uint8 bitmask

Supported: list[T] (x: list[uint8] = list()) compiles to a bounded bump-allocator with GC; supports append(), len(), x[i], for v in x:, and a parameter or return annotation on any function (real subroutine or @inline) – a plain function’s parameter is expanded at each call site, the same mechanism a class-instance parameter already uses, because list[T] has no fixed-width ABI to give it a standalone one. It is AVR-only – on ARM, PIC and RISC-V it is refused at build time, naming AVR; use a fixed uint8[N] array there. bytearray(N) and bytearray(b"...") compile to SRAM uint8[N] arrays. import array / array.array(typecode) is this same list[T], one call spelling later: the typecode decides T (B/b uint8/int8, H/h uint16/int16, I/L/i/l uint32/int32; f/d/q are refused by name, there is no float/double/64-bit element width here). A parameter or return annotated bare array.array (no typecode) takes its element width from whatever list the caller actually passes. Closed dict/set literals (d = {0: 10, "mid": 2} / OK = {1, 3, 5}) bind compile-time lookup tables with no storage: d[const] folds to its value, d[runtime_key] lowers to a compare chain that raises KeyError (catchable with try/except) on no match, x in d / x in {...} test membership, and len(d) folds. A class-body dict (gain_values = {ALS_GAIN_2: 2, ALS_GAIN_1_4: 0.25} read as self.gain_values[gain]) is the same table; mixed int/float values make the lookup a float. They are read-only. pymcu.collections.FixedDict(capacity) is the mutable counterpart: a fixed-capacity integer dict (open addressing over per-instance fixed arrays — no heap, no GC) with Python semantics where they fit a fixed footprint: d[k] / d[k] = v, KeyError on a missing key, ValueError when inserting into a full dict, k in d, len(d), get(k, default), pop(k), clear(). The capacity is a compile-time constant. Fixed-size arrays arr: uint8[N] support both constant- and variable-index access.

bytearray(n) with a runtime n allocates from a static arena instead of being refused, where the compiler can prove the statement runs at most once: a module-level statement not inside a loop, or an @inline __init__ reached only through inlining from one (the RFC below explains why that reduces to the same check). Anywhere else – inside a loop, inside a function called from a loop or from two or more sites, inside an ISR – it is refused, naming the reason and the two ways out: a compile-time size, or moving the allocation to a module-level statement. There is no free(): the arena is reserved once at start-up and never shrinks, which is why the once rule exists at all – Python would free the buffer when it goes out of scope, and there is nothing here that can. The allocator itself is Python (lib/src/pymcu/arena.py), not a compiler intrinsic; pymcu build reports its reservation (Arena: reserved <N> B ...) and reserves zero bytes, linking none of it in, for a program that never uses it. x[i], x[i] = v and len(x) work, both as a local (buf = bytearray(n)) and as an @inline __init__’s field (self.buf = bytearray(n)), through as many further @inline method calls as needed. Not yet: slicing / memoryview on a runtime-sized buffer, passing one to a function’s bytearray parameter, and allocating inside a non-inlined, non-__init__ function (however many times it is actually called). AVR only, like list[T]. See docs/rfcs/0004-arena-allocator.md.

Rule of thumb: if the size is not known at compile time, it cannot be compiled – except a bytearray(n) that the compiler can prove allocates at most once, above.


String operations#

Feature

Why it fails

Alternative

f"..." inline in arbitrary expressions

No general runtime string objects

Assign it to a name first (s = f"..." builds a fixed buffer), or stream it: print(f"...")

str.split() as a value

There is no list to hand back

for chunk in s.split(sep) / for i, chunk in enumerate(s.split(sep)) unroll the pieces at compile time – receiver and separator must be compile-time strings, maxsplit a compile-time int (adafruit_framebuf text())

str.format()

The format must be a compile-time string

Positional holes {}, {0} and specs {:02x} lower to the same writes an f-string makes, in assignment and streamed positions; *seq splices a compile-time sequence (literal, bound name, member, or a tuple-returning call like a descriptor __get__). Named fields and **kwargs are refused – use an f-string

str.join() over a sequence whose length is not compile-time

The result is spelled out while compiling

sep.join([...]) folds compile-time strings (expression position too); sep.join(f"{x:02x}" for x in buf) over a compile-time sequence streams in print/write_str/println and materializes into a fixed buffer elsewhere; s = ''.join([chr(b) for b in buf]) builds a runtime string from a fixed buffer

len(string_variable)

Runtime string object required

A name bound to ONE compile-time text folds – len(byteorder) in adafruit_pixelbuf; a name that can hold several texts is still refused (below). Otherwise use fixed-size buffers

str + str concatenation

Heap allocation

Separate uart.write_str() calls

str[i] on a runtime string

No runtime string object

Use const[str] parameters

Supported: String literals in flash, raw strings r"\n", uart.println("literal"), for ch in "ABC": and for i, ch in enumerate("ABC") (eight characters or fewer unroll at compile time; a longer string runs a counter loop over its flash copy and ch is a runtime uint8 ord() accepts), const[str] runtime subscript (reads byte from flash), and runtime f-strings streamed directly to a sink — see below.

A name bound to one compile-time text — a literal, a module constant, or a parameter that receives one, through super().__init__ and nested @inline calls included — keeps that text: len(s) folds, s[i] folds to the character code, needle in s folds substring membership, s == "lit" folds, and the pure methods s.strip() / lstrip() / rstrip() (with an optional chars argument), s.index() / s.find() (a miss raises a catchable ValueError / answers -1), s.startswith() / s.endswith(), s.count(), s.replace(), s.upper(), s.lower() and s.split() (only as a for / enumerate iterable) all evaluate where the program is compiled, and s[a:b] slices and str(x) of a constant fold to new compile-time text (adafruit_pixelbuf parse_byteorder, adafruit_framebuf text()). A Union[str, ...] parameter keeps the incoming text the same way a str parameter does.

A str that different paths bind differently#

A string is a compile-time value, so a name normally is its text. When two paths bind the same name to different texts, what the name holds at run time is the id of the text, and a read picks the matching one:

s: str = "idle"
if seed > 10:
    s = "running"
print(s)              # writes "running" or "idle", decided at run time
if s == "running":    # compares the ids, also at run time
    ...

The two texts stay in flash and the name costs one 16-bit slot; nothing is copied into RAM. This covers the four ways a name gets more than one text: a run-time branch, a loop body that rebinds it, a module-level str a function rebinds through global, and a conditional expression (s = "running" if seed > 10 else "idle"), which binds both arms in one statement. Written straight into a write instead – print("running" if seed > 10 else "idle") – the condition is lowered once and each arm writes its own literal, so no slot is needed; a condition the compiler can decide still folds to the chosen arm.

Only print(), uart.write_str() / println() and == / != against a literal can read such a name. Anything else (len(s), s[i], s + t, passing it to a const[str] parameter) is a compile error naming the texts it can hold, because there is no single text to hand over.

f-strings (streamed)#

f"..." with runtime interpolations is supported streamed to a sink — the compiler lowers each piece to a direct write (no heap, no format buffer) — and as a value: s = f"t={t} C" builds the string into a compiler-managed fixed bytearray whose size is statically bounded per part (pymcu.strfmt lowering, auto-injected by the build). On the value form, len(s) is the formatted length, s[i] is the one-character string at that position (print(s[i]) writes the character, as CPython does), print(s) / uart.write_str(s) stream it, and re-assigning s in a loop reuses the buffer (assign the longest f-string first — the buffer is sized at the first assignment). An int interpolation with a format spec ({v:X}, {v:04d}, {v:b}, {v:o}) folds to its text wherever the whole f-string is a compile-time constant. Not yet supported in the value form: s == "lit" comparison, and f-strings inline in other expression positions (assign to a name first). Streamed examples:

print(f"adc={raw} v={mv:04d}")
uart.write_str(f"t={temp:5d}")
uart.println(f"err 0x{code:02X}")
lcd.print_str(f"{hours:02d}:{mins:02d}")

Format specs supported in interpolations: {x:02x}, {x:X}, {x:08b}, {x:o}, {x:5d}, {x:04d} (width, zero-pad, and x/X/b/o/d bases), and float specs {v:f}, {v:.Nf}, {v:W.Nf} including zero-pad ({v:08.2f}) — precision up to 15 digits. Compile-time constant interpolations (f"text={const}") are folded into the flash string as before.

A streamed interpolation accepts a float and prints it the way CPython does for the common cases — two decimals, rounded, with a trailing zero trimmed but never past the first decimal (3.25, -2.25, 0.05, 123.75 and 1234.5 all print exactly). print(x) on a float uses the same formatter. Under a .{N}f spec the value formats with exactly N decimals — the digits come from the float32’s exact decimal expansion, so a .5 boundary is a real tie and rounds half-to-even, matching CPython (f"{2.5:.0f}" is "2", f"{2.675:.2f}" is "2.67"). Both sink kinds share the formatter, so the value form (s = f"{v:.2f}") writes the same text into the fixed buffer.


Exception handling#

try / except / raise / finally are supported on AVR and ARM (RP2040/RP2350) targets via a zero-cost T-flag error-propagation model — not setjmp / longjmp. A function that raises marks the error (AVR: the SREG T flag via SET/CLT/BRTS; ARM: an internal flag + code global pair) and returns normally; every call site inside a try tests the flag and branches to the matching except. There is no jmp_buf and no stack unwinding, so the happy path costs a single skipped branch per guarded call.

Because propagation rides on the function return, raise from a helper and catch it where you call that helper:

def read_sensor(raw: uint16) -> uint8:
    if raw > 1000:
        raise ValueError        # sets the T flag, returns to the caller
    return uint8(raw)

try:
    v: uint8 = read_sensor(adc.read())   # caught here if read_sensor raised
    handle(v)
except ValueError:
    handle_error()
finally:
    cleanup()

ValueError, TypeError, IndexError, KeyError, NotImplementedError and ZeroDivisionError are builtins — no import required, exactly like CPython. ZeroDivisionError is raised automatically on a runtime // or % by zero.

The full statement is supported: try / except / else / finally, plus a bare raise to re-raise the active exception. finally runs on every exit path — normal completion, a caught exception, propagation to an outer scope, and return / break / continue out of the try (including a break or return inside finally that discards the in-flight exception):

try:
    v: uint8 = read_sensor(adc.read())
except ValueError:
    handle_error()
    raise                # bare re-raise — propagates to the caller
else:
    handle(v)            # runs only if no exception
finally:
    cleanup()            # always runs

How it works / limits:

Property

Notes

Zero SRAM, zero happy-path cost

No jmp_buf; each guarded call is followed by one BRTS, skipped when no error was raised

Propagates across calls

A raise inside a called function is caught at the call site in the caller’s try — cross-function propagation is the model; there is no same-function restriction

Propagates to any depth

An unmatched exception re-propagates to the enclosing try, then the caller, and so on — there is no single-nesting-level limit

Caught at call sites

An exception is detected after a function call inside the try. Raise from a helper and catch it where you call it (rather than raise-ing directly in the try body)

AVR + ARM (RP2040/RP2350)

PIC and other backends: use return codes or sentinel values instead

Exception types are integer codes

Builtins (ValueError etc.); handlers match by integer code. A string-literal message is one flash word (#369). A non-literal message (f-string, concatenation, call) is a deferred print: runtime pieces are stored at the raise and print(e) / str(e) / e.args[0] replay them (#435)

raise X(...) from Y

Accepted; compiled as raise X(...). There is no traceback. e.__cause__ / e.__context__ are refused (#434)

except mod.Exc: / except (A, B):

A handler names the type the way the library spells it: a module-qualified module.Class (alias included) or a tuple of alternatives sharing one body — compile-time name resolution onto the class’s code, no tuple allocated

Unmatched at top level

An exception with no handler hits __pymcu_unhandled_exn — E:<TypeName> to UART0 then a halt, never a silent continue. When the raise carried a message the report reads E:<TypeName>: <message>; a bare raise keeps E:<TypeName>. Whether it reached main from a callee or was raised in main’s own body (or in an @inline expansion there) makes no difference

Return codes are still often clearer for firmware

try / except is now zero-cost on the happy path (no jmp_buf, one skipped branch per guarded call), so the old “21 bytes of SRAM per try” objection no longer applies. Even so, an explicit status return is frequently the clearest bare-metal style, reads the same on every backend (not just AVR), and makes the error path obvious at each call:

# Idiomatic: zero SRAM overhead, works across any call depth
STATUS_OK:    uint8 = 0
STATUS_RANGE: uint8 = 1

def read_sensor() -> uint8:
    if adc.read() > 1000:
        return STATUS_RANGE
    return STATUS_OK

match read_sensor():
    case STATUS_OK:    ...
    case STATUS_RANGE: ...

| A bounded exception object | except E as e: binds a name (#369). One exception is live at a time, so the object is two static facts and no allocation: the type code the dispatcher already compares, and the flash address of a string-literal message. print(e), str(e) and e.args[0] read the message; isinstance(e, X) compares the code. Every other use of e is refused by a sentence naming those four. A message that is not a literal stays refused, and a field set in a user exception’s __init__ is not available yet. except* (exception groups) is still refused by name |

CompileError — compile-time intrinsic:

raise CompileError("msg") is intercepted by the compiler and aborts compilation with a CompileError: diagnostic. It never generates any runtime code or error-propagation instruction. Used in all HAL modules to reject unsupported configurations at compile time:

from pymcu.exceptions import CompileError

match __CHIP__.arch:
    case "avr":
        ...
    case _:
        raise CompileError("SPI not supported on this architecture")

CompileError cannot be caught by try / except — compilation aborts before any binary is produced.

Unhandled exception output (AVR with UART0):

When a raise has no active except handler, PyMCU prints "E:<TypeName>\r\n" to UART0 then halts with cli; rjmp .-2. Useful for debugging from a serial monitor:

E:ValueError

The transmitter does not have to be on already. UART0 is set up when the program calls print()/input() or constructs a UART(...), and a program that does neither used to halt with nothing on the wire (PyMCU#340). When nothing in the program owns the UART, this path turns the transmitter on itself, at the [tool.pymcu] stdout_baud rate (115200 by default), 8N1. About 30 bytes of flash, and only in a program that can raise and never prints; a program that does print keeps the image it had, byte for byte, because the initialisation is not emitted at all. A program that owns the UART at its own rate is never reprogrammed under a live stream: the path checks TXEN0 first and writes only when it is already set.

Only exception types actually raised in the program have their name strings emitted in flash — no overhead for unused exception codes. Chips without UART0 (attiny85 etc.) skip output and go directly to the halt loop.

Supported: assert condition, msg as a compile-time check — a statically false assertion is a CompileError; a true or runtime assertion is stripped.


Functions and closures#

Feature

Why it fails

Alternative

Closures capturing mutable vars

Closure cell requires heap

Pass captured values as explicit parameters

*args / **kwargs over a run-time call

The forms themselves are supported as COMPILE-TIME sequences and mappings (#368): the callee is specialised per call site, where the extra arguments are written out, so f(**kwargs) and super().__init__(self, **kwargs) splice the known keys, and kwargs["k"], .get, in, len and .items() fold or unroll. What is refused is a ** built from a run-time mapping, and a key no callee accepts, which is named

Write the keys at the call, or declare the parameter

functools.partial

Runtime partial object

Wrapper @inline function

A function value whose target varies at run time

The address must be known at compile time

Pick with match / case, or index a Callable[N] table of known functions

Recursion of any depth, direct or mutual

No per-call frame: PyMCU uses a static stack layout

Iterative equivalent

Supported: @inline functions expand at call sites — zero call overhead, zero stack.

A free function that takes a class instance (def blink_twice(led: Pin)) is supported and expands at the call site, the way an explicit @inline of the same shape does: the instance fields live in the caller’s frame, so there is no subroutine to call. The parameter must be annotated with the class name; an unannotated one is not an instance parameter. Non-@inline functions use a conventional call/ret ABI, but they may not recurse: the compiler assigns every function a fixed stack slot, so a recursive call cycle is refused at build time (RecursionError: recursive call cycle: f -> g -> f), whatever the depth would have been. Direct and mutual recursion are both detected. lambda x: expr (no closure capture) is inlined at the call site. nonlocal is supported inside nested @inline functions.

A function that promises a value must produce one wherever its result is read. There is no None here: the result of an @inline is a temporary the expansion writes its return into, and the result of a subroutine is a register. A body that reaches its end without returning leaves both untouched, so a caller that reads the result reads whatever the register or stack slot happened to hold. That is refused at the call, naming the callee:

@inline
def bucket(freq: uint16) -> uint8:
    if freq > 488:
        return 3          # and nothing for the other path

p: uint8 = bucket(f)      # refused: a path through bucket returns nothing
bucket(f)                 # fine: nothing reads the result

A match needs a case _: arm and an if needs an else: for the compiler to see that every path leaves; raise counts as leaving, and so does a while True: with no break. Calling the function as a statement is always allowed, which is how an accessor whose one-argument path returns nothing is used.

Function references are supported. A function assigned to a Callable-annotated name captures its address and calling through it emits an indirect call (ICALL on AVR); funcref(fn) is the explicit spelling, and Callable[N] builds a dispatch table you can index at run time. A bare cb = my_handler works too. The one limit is that each target must be a named function known at compile time – rebinding the name inside a run-time branch is refused, naming the branch.


Classes and inheritance#

Feature

Why it fails

Alternative

Multiple inheritance / MRO

C3 linearization is a runtime concept

Single-level inheritance only

Runtime polymorphism (vtable dispatch)

Requires vtable + heap class objects

Compile-time match / case dispatch

isinstance() / type()

No type tags at runtime

isinstance(x, T) on a ZCA instance folds (#424); isinstance(x, (tuple, list)) folds from the receiver’s known shape (#423) – through an inline-parameter alias, a keyword argument, or a module-level string’s own text (adafruit_neopixel pixel_order); isinstance(x, slice) folds too – nothing here is a runtime slice, so it is always False (adafruit_pixelbuf __setitem__); a None-bound name answers False to every builtin. type() is still refused

__repr__, __str__

No runtime string formatting

uart.println() with explicit fields

__new__

An instance is laid out in static storage with no allocation call, so there is nothing to intercept and no object to hand back

Do the work in __init__, or a module-level factory function that returns the instance. Refused where the method is written

__init_subclass__

A class is a compile-time layout with no class object and no creation event

Do the work in each subclass’s __init__. Refused where the method is written

__del__

Storage is static, nothing collects an instance, and del is refused for the same reason, so there is no moment a destructor could run at

deinit() / close() called by name, or a with block’s __exit__. The method compiles and is reported as a warning for every class the program constructs

dataclass

Metaclass + runtime heap

Manual @inline class

namedtuple defaults / rename / module

Extra factory kwargs

Name = namedtuple("Name", ("a", "b")) – two positional arguments. The assignment is a ZCA class

namedtuple index p[0]

Not a tuple subclass

Field access p.x; __match_args__ is set so a class pattern binds in field order

Supported: ZCA @inline classes (zero SRAM), @property / @name.setter, single-level class inheritance with super(), with obj: context managers (__enter__/__exit__), operator dunder methods (__add__, __sub__, __mul__, __len__, __contains__, __getitem__, __setitem__, all comparison / bitwise dunders). A comparison dunder is dispatched wherever the comparison is written – as a condition, as a value, at module level, and on a class-typed field receiver. A class-typed field dispatches correctly through a value-returning method too (self.pin.read() on a nested ZCA field), which is what the compat layers are built on — machine.Pin wrapping the HAL Pin is exactly this shape.

A class declared inside another class is constructible, and its constants are readable through both names: Outer.Inner.A, and mod.Outer.Inner.A through the module that declares it. That is how CircuitPython spells the UART parity, busio.UART.Parity.ODD.

A call argument that holds a compile-time constant is passed as that constant, so a callee that dispatches on it takes the same path whether the caller wrote the value at the call or put it in a local first. The value has to be one the compiler can still see: a name a branch or a loop can change is not one, and neither is anything read from a register.

Comparing an instance of a class that defines no comparison dunder answers what CPython answers. ==, !=, is and is not fall back to IDENTITY, which is a compile-time fact here because every instance owns its own static slot: two separately constructed objects are never equal, b = a is the same object, and nothing that is not an instance is ever equal to one (obj == 0 is False). An ordering (<, <=, >, >=) has no fallback – CPython raises TypeError – and is refused, naming the method the class would need. max() and min() never consult a class at all, so an instance argument, or a sequence of instances, is refused too; sorted() and in over a list of instances were already refused.

The other operand has to be one the compiler can be certain about: another instance, or a literal. A plain name is not enough – it may be a second spelling of the same object – and neither is a field, a call result or a subscript, so those keep the path they had.

An unannotated field takes its width from the widest value the constructor assigns — a conversion call says its own type, a literal the narrowest type that holds it, an arithmetic expression its widest operand, and self.x = self._m() the return type of _m – declared, or inferred from the method’s own return expressions the way an unannotated function’s is (a return None, a member read or anything else the join cannot type leaves the field at the uint8 default). An explicit self.x: T = ... still wins.

A field’s layout is derived from every self.x = ... in the class body, not from __init__ alone — a property setter (@x.setter) or a helper method __init__ reaches through self.<m>() calls at any depth also introduces a field, exactly as __init__ itself does (PyMCU#441), and the same is true of a super().__init__() nested in an if or try: the base constructor’s fields still merge into the layout. A method reachable only from outside construction does not: a name novel to such a method is refused as a typo rather than silently becoming a field of its own, since nothing here can tell the two apart the way __init__ and a setter can be told apart from an arbitrary helper.

A field’s type is fixed at the first site that writes it (scalar widening across sites stays allowed); a later write of a categorically different kind (numeric vs. str vs. anything else) is a compile error reported at that write’s own file, line and column — in __init__ itself exactly as in any other method. Writes whose value’s kind the scan cannot see carry no kind and never trigger it: a const parameter, a Union[...]/Optional[...] annotation, a member read like cs.name. This is a PyMCU design choice, not an attempt to track what CPython, MicroPython or CircuitPython actually do — measured (PyMCU#441): all three let a field change type freely across writes, with no error or warning at all.

Divergence (PyMCU#441): a field read somewhere and never written anywhere reachable in the class is refused at compile time, worded like the AttributeError every one of CPython, MicroPython and CircuitPython would raise for the same program at run time — that refusal is sound, since no run of the program could ever supply a value. What PyMCU cannot decide statically is when, in one instance’s actual execution, a write reaches a field relative to a read of it: the three interpreters resolve attribute existence dynamically, per instance, by execution order, so a write that happens to run before a given read makes that read succeed even when it is not the field’s “defining” site in the class body PyMCU scans. A read that (in execution order) precedes every write reachable from it gets PyMCU’s zero-initialized default instead of the AttributeError the interpreters would raise at that point. The oracle corpus (tests/oracle/probes/ in pymcu-avr) has a probe of this shape marked # expect: divergence, citing this paragraph.


Type system limitations#

Feature

Why it fails

Alternative

complex numbers

Requires float

Not available

Decimal

Requires heap

Not available

None assigned to a scalar (int / uintN)

None is a real null literal, not the integer -1

Use a sentinel value (e.g. 0xFF), or keep None for reference / optional-typed values where is None / == None checks work

Union of more than four members, or of non-scalar members

The member tag encodes at most four states and every member needs scalar storage

Separate functions per type

TypeVar / Generic

Runtime generics

Separate @inline functions per type

None is a compile-time value. It travels: passing it as an argument, assigning it through a property setter, binding it to a name, or storing it in a field binds that name as None, so p is None folds and a match p: is decided at compile time. None matches case None and the wildcard and nothing else, and only the arm it selects is lowered, so a refusal written in an arm the program never takes never fires. This is what makes the CircuitPython spelling pin.pull = None mean “no pull”.

Optional[X] is read as X, and so are X | None and Union[X, None], in every annotation position. The refusal they used to get was about storage, and storage is not the question: None-ness is the compile-time property above, so the annotation needs X’s width and nothing else. A field keeps the knowledge PER INSTANCE, so two objects of one class, one constructed with the optional argument and one without, get different code from the same method:

class Dev:
    @inline
    def __init__(self, pin: Optional[uint8] = None):
        self._pin = pin

    @inline
    def go(self) -> uint8:
        if self._pin is not None:
            return self._pin
        return 99

d = Dev()       # d.go() is the constant 99, and d._pin has no storage
e = Dev(7)      # e.go() is the constant 7, with no test at run time

Neither branch that cannot run is lowered, so this costs nothing: the 321-fixture corpus is byte-identical across the change.

Where the knowledge used to run out was a return – and that is what RFC 0009 phase 1 added. A def declared -> Optional[X] (or -> X | None, -> Union[X, None]) whose None-ness is decided at run time compiles as a tagged union: the payload travels in the ordinary return registers and a one-byte member tag in the next register of the return run (R25 after an 8-bit payload, R22 after a 16-bit one, R20 after a 32-bit/float one; None is always the last tag state). A local that binds the result keeps the tag byte in its own storage, and the readers ask it directly: r is None / r is not None / if r: emit the tag test, r or default picks the payload or the default on it, and if r is None: return narrows r to X on the fall-through the same way if r is not None: narrows inside its arm. Reading an unnarrowed runtime-tagged name dispatches on its tag wherever CPython faults on None (r + 1, buf[r], r < x, -r, len(r), or passing r to a non-Optional parameter): each live member runs the operation at its own width, and a leaf that lands on the None member raises TypeError at run time with CPython’s wording, reported through the RFC 0005 deferred-print channel like any unhandled raise. The sites that CAN represent both read the tag instead: print(r) and f"{r}" emit the payload through the member’s own writer when a real member is active and the text None when it is not, exactly as CPython prints the value. When every reached path is provable – the annotation is wider than the body, the guard folds, the return None sits on a dead arm – no tag exists and the code is byte-identical to a plain -> X return; the byte is spent only where the None-ness is a run-time fact.

A return None on a reached path of a function declared -> X (no None member) stays refused in one sentence at the line it is written on, in an @inline expansion and in a plain def compiled as a shared subroutine alike – the outlined path used to leave ret holding whatever the register happened to hold. A return None on a path the caller cannot reach is not refused, because the guard that excludes it folds first. Union PARAMETERS on real subroutines carry the same tag as a return: the caller stages the member byte in the argument run immediately after the payload, and the callee’s p is None / isinstance(p, T) reads narrow on it. Union[A, B, ...] returns, parameters and union-typed fields are implemented in full: the tag carries the member index, the payload is the widest member’s storage, isinstance/match dispatch on it, and union members are still limited to scalar storage types (a list/tuple/array or class member is refused at the annotation, as is a union of more than four members). A local a program merges None into under a runtime condition (if c: x = None beside a scalar assignment) carries the tag byte too; writes that can only meet sequentially stay provable and emit exactly the code they had before.

A Union of two REAL types on a PARAMETER of an @inline-expanded function or method (a constructor included – every ZCA instance is built at its own call site) reads the same way Optional[X] does, one step further: not “the width both members share” – there is none – but “the type of the argument at THIS call site, which must be one of the members”, exactly how an @inline overload already dispatches on an argument’s type. A field assigned from such a parameter takes the site’s type the same way any unannotated field does. A List[X]/Tuple[X, ...] member matches a fixed array/list literal argument (there is no run-time List/Tuple object here); a Callable[...] member matches a plain function reference. A call whose argument matches none of the members is refused, naming them. A Union on a REAL SUBROUTINE’s parameter takes the tagged-parameter ABI instead: the member byte travels as an extra argument staged immediately after the payload, the callee’s reads dispatch on it, and a parameter every caller provably fills with the same member keeps the untagged code it had before. What is still refused is a union position with no storage or ABI for a tag at all – a buffer member (list/tuple/array or class instance), or a tag that would have to cross a function reference’s fixed signature.

An @inline overload is chosen by the argument’s type, and a BUFFER is one of those types. A bytearray, a sized array, a memoryview or a slice of one reaches a call as its base address, so it selects an overload whose parameter is a buffer, wherever the buffer is held: a local, a module global, an instance field, a field of a field (self.i2c_device.buffer), a buffer written in the class body and read as D.BUF or self.BUF, or the result of a method declared -> bytearray. When NO overload of the name takes a buffer, the call is refused at that line, naming the argument position and listing the overloads on offer – there is nothing to select, and what used to happen was that whichever overload the registry enumerated first read the buffer’s address as a number (PyMCU#503). Widening one number into another is untouched: a uint8 argument still selects a uint16 parameter.

An argument is narrowed to its parameter’s declared width, and only a LITERAL that does not fit is refused. take(300) against def take(n: uint8) is a located error naming the 44 that would arrive and offering uint8(300) if that is what was meant. A COMPUTED value of the same 300 is not refused: take(len(big)) for a big = bytearray(300) compiles and the parameter receives the same 44, silently. The refusal fires on a literal the parser built, and a computed value has no line and column to point at, so it cannot see one. This is a real hole and it has been paid for: the AVR I2C entry point declared its byte count uint8, the MicroPython layer handed it len(buf), and a 128x32 display’s 512-byte framebuffer arrived as zero, so the panel stayed blank while every command byte before it was correct (PyMCU#511). Until the check reaches computed values, a width a value must fit in is the author’s to get right: declare the parameter for the values it will receive, not for the ones the tests happen to pass.

An annotation may also be written through an alias: ColorUnion = Union[int, uint8] binds the name at compile time (inside a discarded if TYPE_CHECKING: / compat-layer guard too), and a parameter annotated x: ColorUnion reads it exactly as if the union were spelled out (adafruit_pixelbuf). The alias is a compile-time name for the annotation, not a type object.

Note on float: Soft-float (IEEE 754 single-precision) is supported on AVR via a pure-assembly helper library. Expect ~200-400 cycles per operation. Subnormals are treated as zero; NaN and Inf propagate correctly. uint32(x * 100.0 + 0.5) and the other float→int casts truncate toward zero on the real value, not on its raw bit pattern. ARM and PIC18 have float too (RP2040 through the bootrom fast-float library, RP2350 through the M33 FPU). PIC16 and RISC-V have no floating point at all – even a bare x: float = 1.5 fails there, today with an unlocated backend message rather than a proper diagnostic.

Note on const[T]: a const[T] parameter accepts compile-time float constants as well as integers and strings, so Timer(freq=2.5) binds. What it does not accept is a value that varies at runtime: passing one is a located CompileError naming the parameter, rather than a silent fold of whatever the variable happened to hold. Pin(n) where n is a runtime variable is the case you are most likely to hit — a pin identity has to be known at compile time for the GPIO access to stay zero-cost.

A local holding a value the compiler can see IS a compile-time value. x: uint8 = 5 then x + 1 is the constant 6, not an addition, and the same goes for a chain of them: a HAL helper written through wide locals, which is how it has to be written for the arithmetic not to truncate, folds exactly as the expression form does. What follows from that is worth knowing before it surprises you:

  • assert x == 3 on such a local is decided at compile time, and a false one is a CompileError rather than a stripped statement.

  • a const parameter accepts it, and a match on it picks its arm at compile time.

  • a diagnostic about “a run-time value” will not be about it.

The value is forgotten at every write to the name, at every name a loop body can assign, and where the arms of an if chain disagree, so a name a loop mutates is a run-time value again. Reading a register (GPIOR0.value) is the way to say “the compiler cannot know this”.


Pointer arithmetic#

ptr[T] in PyMCU is a compile-time constant address alias, not a runtime pointer. It is equivalent to a C volatile register macro:

// C: compile-time constant pointer — what ptr[T] models
volatile uint8_t* const PINB = (volatile uint8_t*)0x36;

This means the following operations are not supported:

Operation

Example

Why it fails

Pointer advance

p = p + 1

ptr has no runtime address value

Runtime bit index through a ptr variable

p[i] where i is a runtime variable

rejected with a clear error (constant-index bits and chip registers are fine)

Pointer difference

p - q

Not in IR

Bare assignment

PORTB = 0xFF

rebinds the name, never writes — the compiler rejects it; use PORTB.value = 0xFF

ptr() of an array

ptr(buf) where buf is a bytearray/uint8[N]

an array lives at a label the assembler assigns, not at an address the compiler resolves; refused by name (it used to compile to a read of buf[0])

The following, previously listed here as unsupported, do work:

  • Grouped peripherals (RFC 0012) - a ptr[T] declaration in a CLASS body is a register, the same declaration one scope deeper. Timer1.TCCR1A.value, Timer1.TIFR1[Timer1.TOV1] and the 16-bit names compile to exactly what the loose module-level names compile to, and the class has no runtime existence. The address may be another register’s name (ptr(TCCR1A)), constant arithmetic on one (ptr(TCCR1A + 1)), or a literal; an address the compiler cannot resolve while compiling is a located error naming the attribute. Naming follows PEP 8: the group is a class, so CapWords (Timer1), while the loose names are module-level constants and keep ALL_CAPS, as do the registers inside the group. A group is a namespace and not a type, so Timer1() is refused with a located message. The grouped classes in pymcu.chips.<chip> are the surface the project keeps stable; the loose module-level register names are an implementation detail of the HAL and may change in any release. Which surface carries which promise, including where pymcu.hal.* and __CHIP__ sit, is written once in the library authoring guide; see Writing a PyMCU Library.

  • ptr as a function parameter and return type — def f(reg: ptr[uint8]) and compile-time selectors returning -> ptr[uint8] are used throughout the HAL; a bare register name in those positions contributes its address. The element width travels with the address through all of them, so .value on the receiving name is an access of that width and not of some default.

  • ptr as an instance field, in every spelling. self.reg = ptr(base + 1), self.reg = TCCR1B, a field bound from a ptr[T] parameter, and the annotated self.reg: ptr[uint8] = TCCR1B, which is the spelling that states the width outright: self.cnt: ptr[uint16] = ptr(0x84) makes every .value on the field a 16-bit access.

  • Runtime-offset dereference — ptr(BASE + off).value with a runtime off compiles to indirect loads/stores (register-base + runtime offset remains unsupported).

Idiomatic alternative — fixed arrays with variable index:

buf: uint8[16] = [0] * 16
i: uint8 = 0
while i < 16:
    buf[i] = compute(i)   # compiles to: LDD / STD with Y+offset
    i = i + 1

uint8[N] arrays with a runtime index already compile to efficient ld/st with Y+offset addressing on AVR — no pointer arithmetic needed.

For performance-critical pointer walks in asm: use the Z register (r30:r31) with ld r24, Z+ / st Z+, r24 for auto-increment through a buffer.

asm("""
ldi  r30, lo8(my_buf)
ldi  r31, hi8(my_buf)
ldi  r18, 16          ; length
_loop:
    ld   r24, Z+      ; load byte and advance pointer
    ...
    dec  r18
    brne _loop
""")

Iterators and comprehensions#

Feature

Why it fails

Alternative

List comprehension over a runtime iterable

Length not known at compile time

for loop with fixed-size array

if-filtered comprehension with a runtime condition

The result length would vary at runtime

Keep the filter compile-time constant, or for loop + explicit index

A tuple literal passed as an argument or stored in a field

A tuple is a compile-time construct here

Separate variables, or a fixed-size array

Dict comprehension

Heap allocation

Not available

Set comprehension

Heap allocation

Not available

Generator expressions as lazy values

There is no runtime iterator object

all(x ... for x in it) / any / sum / min / max unroll the generator at compile time over an iterable whose length is known – a tuple/list literal, a const sequence, range of constants, a compile-time string or a fixed-size array (adafruit_pixelbuf all(0 <= c <= 255 for c in val)). all/any short-circuit like CPython; sum takes its start argument; a for ... if clause filters. Anywhere else the refusal names those five

map() / filter() with runtime iterables

Lazy iterator requires heap

Explicit for loop

Supported: for i in range(N) (runtime or constant N), for x in array, for x in [...], for x in (...), for i, x in enumerate(iterable), for x, y in zip(list1, list2), for x in reversed([...]), for x in reversed(range(...)), x in range(...), list comprehensions with compile-time constant bounds (range(start, stop, step) honours the step), nested list comprehensions, if-filtered list comprehensions (constant condition), for pin in [DigitalInOut(p) for p in (...)] and for bit, pin in enumerate([DigitalInOut(p) for p in (...)]) (CT unroll of ZCA instance arrays), including a comprehension whose element is a factory call that returns the instance ([pcf.get_pin(i) for i in range(8)], adafruit_pcf8574), and for pin in (reset_dio, enable_dio, ...) over already-constructed instances, and for x in t where t is a bound tuple-return result.

A range() bound is folded before the loop is lowered, whatever shape it is written in: a literal, a name, or an expression over either. A count of at most eight unrolls while the body is cheap, an empty range emits nothing, and anything the program really decides at run time stays a loop. An expensive body – a nested loop, a call – lowers even a short constant loop to a counter loop, since unrolling it costs more flash than looping costs. The exception is a loop variable that must stay a compile-time constant: one passed to a const parameter (Pin(n, Pin.OUT)), used to index a name with no runtime storage, and the like unrolls whatever the body costs, because a counter would leave a run-time value where the callee needs a number.

A for over a short constant list unrolls by the same cost rule, and the loop variable is a compile-time constant in each iteration, so a const parameter receiving it resolves as it would from a literal. The elements may be numbers or STRINGS, which is what a row of board pins is: for pin in (board.D2, board.D3, board.D4) works, and so does the pair form for pin, name in [(board.D2, "D2"), (board.D3, "D3")]. A tuple or list of already-constructed instances unrolls the same way: for pin in (reset_dio, enable_dio, ...).

A comprehension of class INSTANCES is not supported outside the forms above: PyMCU lays an instance out at compile time and it has no array slot to live in. Write the list as a literal of constructions ([A(x), A(y)]), or build each one by name.

A driver that takes a list. A class is handed several pins, several devices or a table of numbers the way every embedded library does it, and keeps the list in a field:

class LedBar:
    def __init__(self, pins):
        self._pins = pins

    def all_on(self):
        for p in self._pins:
            p.value(1)

    def one(self, i: uint8):
        self._pins[i].value(1)

bar = LedBar([Pin("PD5", Pin.OUT), Pin("PD6", Pin.OUT), Pin("PD7", Pin.OUT)])

The list is a compile-time sequence: the elements are built once at the call site, and the parameter and the field are other names for them, never copies. So self._pins[0], for p in self._pins, len(self._pins) and a method call through a run-time index all work, and so does the same list written into a name first (pins = [...], LedBar(pins)), a list given to a method rather than to the constructor, and a list of instances that each hold a pin. There is no length limit on the sequence itself.

Two things it is not. A run-time subscript that takes the ELEMENT (p = self._pins[i]) is refused: the instances are flattened at compile time and have no slot to select, so walk them with for or index with a constant. And a run-time subscript that CALLS a method is lowered as one comparison and one expansion per element, so past eight it is refused as more code than it is worth.

A list of NUMBERS in a field works the same way for a constant subscript, for and len(). A bytearray or a fixed array handed to a driver keeps its storage, so self._data[i] = v writes the caller’s buffer.

A 2-D grid the way CircuitPython writes it. g = [[0] * W for _ in range(H)], g = [[v] * W for _ in range(H)] with a constant v, g = [bytearray(W) for _ in range(H)], and self.g = <same> in __init__ all lower to ONE flat fixed array of W * H elements – g[y][x] is g[y * W + x] with the same index arithmetic the hand-flattened spelling emits, so the grid costs nothing over a bytearray(W * H). Both dimensions fold exactly like a fixed array’s size: literals, const names, module constants, or a constructor argument that is a literal at every call site. len(g) is H, len(g[y]) is W, for row in g runs a row-index loop when the body only touches row[x], and for x in g[y] walks one row’s elements. A row is a VIEW into the flat array, not a value: r = g[y] is allowed only while every later use of r in the same block is r[x], len(r) or for x in r. Anything that needs the row to be a value – passing it, returning it, storing it in a field, comparing it, in on it, slicing it, appending to it, rebinding g[y], slicing the grid (g[a:b]) – is refused, naming the construct. [[0] * W] * H is refused outright: in CPython that spelling makes H aliases of ONE row object, so g[0][1] = v would write every row – write the comprehension instead.

A method is not a field. Pin.value is an overloaded method (value() reads, value(x) writes), so p.value = 1 is an assignment to a name the class does not have as a field. It is refused where it is written, and told to call p.value(1) instead. The CircuitPython digitalio.DigitalInOut.value IS a property and takes the assignment.

A driver library, unmodified. The shapes a MicroPython or CircuitPython driver is written in now compile as their authors wrote them:

class SevenSeg:
    def __init__(self, pins, common_anode=False, dp_pin=None):
        self.segments = [Pin(p, Pin.OUT) for p in pins]     # a comprehension of instances
        self.dp = Pin(dp_pin, Pin.OUT) if dp_pin else None  # the dead branch is not lowered
        self.digits = {0: [1,1,1,1,1,1,0], ...}             # a dict of rows, in a field

    def show(self, num):
        pattern = self.digits[num]                          # a run-time key picks a row
        for pin, seg_on in zip(self.segments, pattern):     # zip over a field and a row
            pin.value(seg_on ^ self.common_anode)

A comprehension of instances is the literal of constructions written once instead of N times, so it needs a compile-time iterable: a constant list, a name or parameter bound to one, or range(N). A comprehension whose length is decided at run time is still refused.

A dict of rows must be a rectangle: same-length rows of constants, keyed by distinct constants. Ragged rows have no table and are refused, saying so.

Character keys. A ONE-CHARACTER string literal is its character code, so {"0": [...], "A": [...], "-": [...]} is a rectangle like any other and a byte read at run time indexes it. A MULTI-character key is an interned id instead, which no run-time value equals, so a table keyed by one can only be looked up with a constant. That is the rule for every dict, not only a table of rows.

A lookup table written as a plain list. DIGITS = [0x3F, 0x06, ...] read as DIGITS[digit] with a run-time digit is the shape of every 7-segment table, font and gamma curve. The values are constants and nothing writes them, so the table is placed in flash, and only when a run-time subscript actually needs it: a table that is only ever indexed with a constant emits nothing at all, as before. The same holds for such a list reached through a parameter or held in a self field. What is refused is a table the program STORES into: flash cannot be written, so that one is told to declare its storage (T: uint8[10] = [...]).

A sequence bound to a name. DUTIES = [256, 383, ...] and DUTIES = (256, 383, ...) are the same thing to iterate over, at any length: up to eight constant elements the for unrolls against the literal while the body is cheap, and past that – or at any length with an expensive body – the name gets a fixed array a counter loop walks, the elements read from flash. Without an annotation the element width comes from the widest element, so a 16-bit table stays 16-bit. Being the same storage, a write through the name (DUTIES[0] = 1) is not refused on the tuple the way CPython refuses it.

The range counter. The loop variable of a range() loop is as wide as its bounds need, with no annotation: constants exactly (range(300) is a 16-bit loop, range(200, -1, -1) a signed one), variables by their declared type (n: uint16 gives a 16-bit counter), and an int8 start with a uint8 stop gives int16. A step other than 1 can stop one step past stop, and the counter is sized for that too. A range that fits a byte stays the 8-bit loop it always was. A type declared on the loop variable before the loop is used as written, and is a CompileError when constant bounds do not fit it. A signed runtime step picks its direction at run time; an unsigned one counts up.

An unannotated accumulator at module level is typed the way a local is, from the promoted width of what feeds it (n = 0 then n = n + 1 over range(300) is a 16-bit counter); a written annotation keeps its width and wraps at it. A comparison is decided by the values on both sides: count >= 404 with count: uint8 is False, and x < n with n: uint16 compares all sixteen bits.

After the loop the variable holds the last value visited, as in Python, whether the loop unrolled or ran as a counter. The one difference: a range that runs zero times leaves the variable at start (Python leaves it unbound). enumerate(range(...)) with runtime bounds keeps a runtime index alongside the counter. reversed(range(...)) with runtime bounds needs a step of 1 or -1; with constant bounds any step works.

Slices#

Form

Status

b = arr[1:3] / arr[::2] (slice read)

Compile-time constant bounds only — the result is a fixed-size array sized at compile time. Bounds fold through function-local constants too, and a slice of SRAM-backed storage marshals to a bytearray parameter by base address

arr[a:b] = src (slice assignment)

Supported, equal length, from a list / bytes literal / array / slice, including overlapping copies of the same array (snapshot semantics)

obj[a:b] = src through __setitem__

Supported — lowers to one __setitem__ call per byte

for x in buf[lo:hi] (slice iteration)

Supported with runtime bounds; rewritten to a range loop over the backing array

for x in buf[lo:hi:step] with a runtime step

Rejected with a diagnostic — the step has to be a compile-time constant

A slice read with runtime bounds (b = buf[0:n]) has no lowering: the result would need a runtime-sized array. Iterate it instead, or index the backing array directly.

The __setitem__ form is what makes the canonical CircuitPython persistence pattern compile:

import microcontroller

microcontroller.nvm[0:4] = b"\xcc\x10\xca\xfe"   # one byte-write per element

Async and concurrency#

Feature

Why it fails

Alternative

Awaiting another coroutine/future, await as an expression

Sub-future fields need ZCA construction outside __init__ (not supported yet)

Call the coroutine and poll it, or restructure with asyncio.gather

threading / multiprocessing

OS required

@interrupt ISRs

Supported: async def / await (compiled to a zero-cost state machine; requires import asyncio; await asyncio.sleep/sleep_ms anywhere — if/elif/else, while <cond>, for i in range(...), break/continue, return expr via ._value; executors asyncio.run / asyncio.gather), @interrupt decorator for hardware ISRs, Pin.irq(trigger, handler) for external pin interrupts, atomic flag patterns via GPIOR0.

A routine sits at ONE interrupt vector. Registering the same handler at a second vector is refused where it is written: the table has one entry per routine, and the earlier vector would be left on the bad-interrupt handler. Two pins handled by the same code need a second function that calls the shared body — def on_int1(): step() — registered at the second vector.

Timer0 and millis / ticks_ms

millis_init() (auto-injected when ticks_ms() / monotonic() or, on ATmega, an async def is detected) runs Timer0 at prescaler 64 and counts its overflows. A PWM on PD5/PD6 (Arduino D5/D6) at the default frequency shares the timer without harm; any other frequency there would reprogram the prescaler under the clock (measured: 5000 Hz on D6 made monotonic() run 8.44 times too fast) and is refused at compile time, naming D3/D11 and D9/D10 as the pins to use. CTC or other direct uses of Timer0 in such a program are still yours to avoid.

On AVR the clock await asyncio.sleep_ms(...) waits against is that same Timer0 counter, so its resolution is 4 µs at 16 MHz (1 µs on RP2040/RP2350, which have a hardware microsecond timer). On architectures with no time base — PIC, RISC-V — asyncio.ticks() is 0 and an await never completes.

A Timer0 overflow is 1024 µs, not 1000 µs. millis() — and everything layered on it: ticks_ms(), time.monotonic(), supervisor.ticks_ms() — carries the Arduino-style fractional correction (1 ms per overflow plus 3/125 accumulated in eighths), so it counts real milliseconds rather than running 2.4% slow. micros() reads the raw overflow count plus TCNT0 and is monotonic across an overflow.

delay_ms() and delay_us() are unaffected — they use a software busy-loop with no hardware timer dependency.


Imports and modules#

Feature

Why it fails

Alternative

Third-party PyPI packages

Only pymcu stdlib is compiled

Implement in pymcu stdlib or use @extern

importlib / dynamic imports

Runtime module loading

Not available

Circular imports

Not supported

Restructure module dependencies

A function defined twice in one module

PyMCU compiles the first, Python binds the last

Rename one, or make every definition @inline with different parameter types

Supported: import foo, from foo import Bar, from foo import Bar as B, from foo import *, from package import submodule, relative imports (from .util import half, from . import util), multi-module projects, pymcu stdlib, pymcu-circuitpython and pymcu-micropython compat packages.

from <package> import <submodule> binds the submodule under its own name, as CPython does when the package’s __init__ has no such attribute: from adafruit_motor import servo, then servo.Servo(pwm). Rebinding that name to an instance (servo = servo.Servo(pwm)) is the Adafruit guide spelling: later reads and calls see the instance, not the module (#467). An alias is kept (from adafruit_motor import servo as s).

An import alias belongs to the file that writes it. Two modules that alias different things to the same name each keep their own, the way Python scopes them.

import <package> reaches what the package’s __init__.py binds, including the names it only RE-EXPORTS: a package whose __init__ writes from pkg.mod import f answers pkg.f() with the function pkg.mod defines, since that is the only copy of it there is (#468). The same holds for a submodule the package binds, by either spelling of its own line (from . import mod or from pkg import mod), so from pkg import mod at the use site reaches pkg.mod and not the package.

A package may name ITSELF: from pkg import mod written inside pkg/__init__.py, which is how a package re-exports its submodules without the relative form. It is a no-op in Python, since pkg is already in sys.modules while its own body runs, and it is a no-op here.

from __future__ import X (#452) is a no-op: __future__’s members are CPython compiler pragmas that change how CPython parses the source, and PyMCU already reads annotations straight from the source unconditionally, so there is nothing to enable. The import is dropped rather than resolved – unconditionally, unlike the guarded optional-import idiom (try: ... except ImportError: pass, if TYPE_CHECKING:), which folds away one branch of a compile-time choice.

from foo import * binds the public top-level names of foo: its functions, classes and module-level variables, minus the ones whose name starts with _, which are private and which a star never binds in CPython either. A module that declares __all__ gets exactly that list instead. A name foo re-exports (one it imported itself) resolves through the star as well.

import types does NOT resolve, although pymcu/types.py exists. That file is PyMCU’s own type-system module (ptr, the width names), not the Python types module, and it is read by the compiler rather than loaded like a module. Answering the bare name with it parsed it as ordinary source and reported a SyntaxError inside the stdlib, against a program whose only line was the import (#482); the refusal now names what types is. A library asking for TracebackType writes that import under except ImportError, which the refusal satisfies.

import os / from os import uname resolve to pymcu/os.py, the same stdlib-alias fallback import time already uses. uname(), os.name and os.sep are compile-time facts of __CHIP__. stat(name) and listdir(dir) answer from the embedded-file table (RFC 0008): stat needs the name at compile time and returns the ten-field tuple with the file’s size at index 6; listdir unrolls the embedded names under the directory prefix, sorted. getenv is not defined on that module, and import uos points at import os.

When the project declares a compat layer (stdlib = ["circuitpython"] or ["micropython"]), conditions on sys.implementation.name, sys.implementation.version[i], sys.platform and os.uname() / uname() fields fold at compile time with the answer a real board of that layer reports – sys.implementation.name is "circuitpython" / "micropython", sys.platform is "RP2040" / "rp2", uname().sysname is "rp2040" / "rp2350a" / "rp2", and parts with no upstream port answer with the plain chip name (RFC 0007, PyMCU#266). if not sys.implementation.name == "circuitpython": therefore drops its branch instead of keeping it. The folded answers come from the compiler’s per-board table, not from parsing a sys.py / os.py; without a compat layer these conditions stay run-time and import sys resolves the way it always did.

A module-level object in an imported module is constructed at startup, before the entry file’s own module-level statements, in the order the modules are imported. This applies to the project’s own modules, the ones under sources. An installed distribution (the pymcu stdlib and the compat layers) is written knowing that only the entry file’s module level runs, and several guard their top level on the target chip, so theirs is deliberately left alone.

A program whose module level ends parks the CPU with interrupts disabled and its last outputs held, like avr-libc’s _exit. A body held by a while True: never ends, so it never parks.


Built-ins summary#

Built-in

Status

Notes

print(str) / print(int)

✅ Supported

Routes to UART

print(float)

✅ Supported

Two rounded decimals, trailing zero trimmed (3.25, 1234.5)

print(bytearray) / print(arr[a:b])

✅ Supported

CPython repr — bytearray(b'\xcc\x10'); length must be compile-time

range(n)

✅ Supported

For-loop bounds, runtime or constant; the counter is sized from the bounds. Also x in range(...), reversed(range(...)), enumerate(range(...)). Not a value: r = range(4) is a CompileError

len(arr) / len(b"...")

✅ Supported

Compile-time constant fold

abs(x)

✅ Supported

Intrinsic

min(a, b) / max(a, b)

✅ Supported

Intrinsic. Also over a fixed-size array, and with key=f: the key is called once per operand and the winner is the original value, not its key

sum(iterable)

✅ Supported

Compile-time fold or unrolled additions

enumerate(iterable)

✅ Supported

Compile-time index counter over constant sequences, range(), and fixed-size arrays – including a buffer reached through inline parameter bindings or a bytes([expr]) argument whose elements are run-time. Over a compile-time string it iterates the characters: eight or fewer unroll, a longer string runs a counter loop over the flash copy

zip(a, b)

✅ Supported

Compile-time unroll over constant lists

reversed(iterable)

✅ Supported

Compile-time reverse unroll

any(iterable) / all(iterable)

✅ Supported

Compile-time fold; a generator expression argument unrolls the same way (all(0 <= c <= 255 for c in val), adafruit_pixelbuf) and short-circuits like CPython – the iterable’s length must be compile-time known

sum(genexp) / min(genexp) / max(genexp)

✅ Supported

Same compile-time unroll; sum honours its start argument and a for ... if clause filters. Nowhere else does a generator expression exist – there is no iterator object to pass around

divmod(a, b)

✅ Supported

Compile-time or runtime

pow(x, n) / x ** n / math.pow(x, n)

✅ Supported

Compile-time integer fold; runtime integer unroll; runtime float via __pymcu_powf (#463)

math.sqrt/exp/log/radians(x)

✅ Supported

Software float, run-time argument. sqrt is Newton-Raphson after a scale reduction; log and exp are the two halves of __pymcu_powf’s series; radians is a scaling multiply that folds for a constant angle. Each body lowers LAZILY, so import math with no call costs 0 bytes and a program carries only what it calls. Only these four: each is here because a measured library stops without it (max31865, thermistor, sgp30, mpu6050/lsm6ds). math.pi / math.e are not defined: a module-level float constant in an imported module is storage nothing initialises

Forward-reference annotation "Name"

✅ Supported

A type named as a string literal (PEP 484), the spelling every Adafruit driver uses for its own __enter__ return. The quotes come off and the name inside is resolved and checked like any other, in AnnotationText so both front ends read it the same way

hex(n) / bin(n)

✅ Supported

Compile-time only

str(n)

✅ Supported

Compile-time only

ord('A') / chr(n)

✅ Supported

ord() compile-time only. chr() of a constant keeps its character through a name and a return; a run-time code point prints as its character out of a function whose every return is a chr()

int.from_bytes(b, e)

✅ Supported

Compile-time fold or runtime

memoryview(buf)

✅ Supported

Compile-time alias of a fixed-size buffer (bytearray or fixed array): memoryview(buf)[k] indexes it, memoryview(buf)[a:] as a value is a writable window (offset + shorter len), and the same slice inside struct.unpack/unpack_from adds its start to the read offset. A plain buf[a:b] is still a copy. The name is a CPython builtin type this compiler stores, so -> memoryview is the same view the call already wraps. No run-time buffer protocol

sorted()

❌ Not supported

No dynamic allocation

map() / filter()

❌ Not supported

Use explicit for loops

input()

✅ Supported

line: bytearray = input("prompt") — reads until newline from UART; prompt is optional compile-time string; max length is optional integer (default 64); UART preamble auto-injected

getattr(mod, "name", default)

✅ Supported

Compile-time only, on a module: getattr(board, "SCK", board.D13) folds to the member or the default. Attribute names must be literals; getattr on anything else is refused

open() / file I/O

✅ Supported (romfs, RFC 0008)

No filesystem exists on the chip: open(name, "rb") resolves at compile time to a handle over a blob the driver embedded in flash (files = [...] under [tool.pymcu], or auto-embedded when a literal open() names a file in the sources). name/mode must be compile-time strings; read-only modes only. Protocol: read(n) with compile-time n (a flash view — subscripts, len(), and struct.unpack(fmt, f.read(n)) fuse onto it), readinto(buf), readline(max), seek/tell, close, with. A name that is not compile-time, a write mode, or a file nothing embedded are compile errors that say so

exec() / eval()

❌ Not supported

Interpreter required


Platform notes (ATmega328P / Arduino Uno)#

  • Stack depth: ~80 nested non-inline calls before overflow (2 KB SRAM, ~16 bytes/frame). Use @inline for leaf helpers.

  • Soft float: float variables and arithmetic are supported via a pure-assembly soft-float library. No FPU required. ~200-400 cycles per operation.

  • No heap: every variable must have a size known at compile time, except a runtime-sized bytearray(n) the compiler can prove allocates at most once (see “Dynamic memory and containers” above) – and even that is a static arena with no free(), not a heap: nothing it hands out is ever reclaimed.

  • String literals are in flash: read-only; sent to UART via flash string pool. Cannot be compared, indexed, or modified at runtime.

  • C/C++ interop: supported via @extern and [tool.pymcu.ffi] in pyproject.toml. C sources use avr-gcc; C++ sources (.cpp/.cc/.cxx) use avr-g++ with -fno-exceptions -fno-rtti, enabling use of Arduino libraries.

  • Capacity is checked at build time, not at flash time: an image larger than the chip’s flash fails the build with the exact overage (firmware is 32864 bytes but atmega328p has 32768 bytes of flash (96 bytes over)), and static data that does not fit in SRAM fails in the backend with the same shape (static data needs 2700 bytes but atmega328p has 2048 bytes of SRAM). The SRAM check reserves 64 bytes for the hardware call stack, which grows down into the same space.

  • Profile-guided optimisation is experimental and off by default: pymcu build --profile / PYMCU_PROFILE and pymcu profile --pgo refuse unless pgo = true under [tool.pymcu.experimental] or PYMCU_EXPERIMENTAL_PGO=1 is set; the only optimizer effect is a veto that keeps @inline regions hot under the workload from being outlined (RFC 0010).

Platform notes (RP2040 / Raspberry Pi Pico) — alpha#

The RP2040 backend lowers PyMCU’s IR to LLVM IR (target thumbv6m-none-eabi) rather than emitting assembly directly, so LLVM does register allocation, instruction selection and optimization. pymcu build emits a flat flash image (firmware.bin, with the stage-2 boot loader at offset 0). It is alpha and intentionally limited:

  • MVP peripherals only: GPIO (pymcu.hal.gpio.Pin, via single-cycle IO) and UART0 (pymcu.hal.uart.UART, PL011) are supported. SPI, I2C, PWM, ADC, PIO, USB, timers, EEPROM/flash and the watchdog are not wired up on this backend yet.

  • Single core: only core 0 runs. Dual-core launch and the SIO FIFO are not exposed.

  • No GC / exceptions / soft-float yet: list[T], try/except/raise, and float arithmetic compile on AVR but are not supported on the RP2040 backend — the codegen rejects the corresponding IR with a clear “not supported yet” error. Virtual-method dispatch, runtime-indexed arrays and operand-form inline asm() are likewise deferred.

  • Delays: delay_ms / delay_us poll the hardware TIMER (the free-running 1 MHz microsecond counter), so timing is accurate on real silicon regardless of CPU clock and pipeline, not a calibrated busy-loop. In the emulator the wall-clock measured by RunMilliseconds reads the wait slightly short, because that harness budgets execution by retired instruction count while the timer advances by elapsed cycles — the firmware delay itself is exact.

  • UART clock assumption: the baud divisors assume clk_peri = 125 MHz (clk_sys at the pico-sdk default). A configurable clocks HAL is future work.

  • Toolchain: the backend ships in the pymcu-arm package (pip install pymcu-arm), which registers the rp2040 target. It requires LLVM (opt, llc, llvm-mc, ld.lld, llvm-objcopy) on the host, provided by the pymcu-arm-toolchain wheel (analogous to pymcu-avr-toolchain). If the wheel is not available for your platform the toolchain falls back to a system LLVM (e.g. brew install llvm lld).

  • No C/C++ interop (@extern) yet on this backend.


What stops each Adafruit CircuitPython library#

Measured on 2026-09-17 against an Arduino Uno (atmega328p), with each library’s file byte-identical to its repository and a main() written after the library’s own example that constructs the object and calls its methods. The harness is 37 libraries (the original twenty plus I2C sensors and expanders that sit next to them on Adafruit’s list).

Twenty of the thirty-seven build unmodified: adafruit_hcsr04 (3 430 bytes), adafruit_motor’s servo (2 332 bytes), adafruit_pcf8574 (1 442 bytes), adafruit_bus_device (800 bytes; its own example now builds too – the bytearray([...]) inline argument and the generator expression in join are supported spellings), adafruit_mcp3xxx (3 094 bytes), adafruit_74hc595 (402 bytes), adafruit_ahtx0 (7 108 bytes), adafruit_mcp9808 (4 646 bytes), adafruit_lis3dh (2 522 bytes), adafruit_tsl2591 (5 814 bytes), adafruit_mlx90614 (3 846 bytes), adafruit_bmp280 (25 006 bytes), adafruit_tcs34725 (28 414 bytes), adafruit_ina219 (7 294 bytes), adafruit_aw9523 (2 294 bytes), adafruit_veml7700 (10 614 bytes), adafruit_dps310 (13 162 bytes), adafruit_pca9685 (1 852 bytes), adafruit_seesaw (3 706 bytes, its I2C stream verified against a CPython oracle on the emulated Uno) and neopixel – the real CircuitPython neopixel.py plus its adafruit_pixelbuf base, pixels[i] = (r, g, b) included, verified on the wire on the emulated Uno.

Library

Stops at

What the compiler says

adafruit_ahtx0

builds unmodified, 7 108 bytes

adafruit_ads1x15

generator expression (next(key for key, value in ...))

Expected ')'

adafruit_aw9523

builds unmodified, 2 294 bytes

(moved off name adafruit_aw9523_AW9523: type(inst) in the descriptor rewrite is the source class name)

adafruit_bme280

_bus_implementation.read_register

call to undefined function

adafruit_bmp280

builds unmodified, 25 006 bytes

(moved off list(struct.unpack(...)): an unpack result is a compile-time sequence, and a function returning its local buffer binds the caller’s name to that slot)

adafruit_bus_device

builds unmodified, 800 bytes (simpletest too, 1 444 bytes)

the library and its example compile; the example’s print("".join(f"{x:02x}" for x in result)) is the expression-position join

adafruit_character_lcd

Pin.high() runtime bit index

__init__ is no longer a shared subroutine and a reduced Lcd(mcp.get_pin()) fixture keeps the expander class; the unmodified I2C backpack still reaches HAL self._port[self._bit] = 1

adafruit_debouncer

Debouncer(pin)

'io_or_predicate' is declared Union[ROValueIO, Callable[[], bool]], and this argument’s type matches none of those members

adafruit_dht

builds unmodified, 12 252 bytes

(moved off def temperature(...) -> Union[int, float, None]: a multi-member return union carries the runtime tag; self._temperature/self._humidity store a payload plus a sibling tag byte)

adafruit_dps310

builds unmodified, 13 162 bytes

(moved off coeffs = [None] * 18: a repeated list of None is a fixed SRAM array)

adafruit_ds18x20

import onewireio

module not found

adafruit_ds3231

from time import struct_time

pymcu.time defines the nine-field stub; the CircuitPython overlay’s advertised names are still only monotonic / monotonic_ns / sleep

adafruit_74hc595

builds unmodified, 402 bytes

(moved off bytearray(self._number_of_shift_registers) and DigitalInOut(pin, self): a compile-time field is a buffer size, and a class defined in the module shadows the entry file’s from digitalio import DigitalInOut)

adafruit_hcsr04

builds unmodified, 3 430 bytes

adafruit_ht16k33 (matrix)

builds unmodified (matrix simpletest 4 234 bytes)

(moved off the buffer-size and Optional[bool] pixel demandants: bound module-level instances outline to real subroutines, and pixel’s Optional[bool] color/return ride the tag)

adafruit_ht16k33 (segments)

builds unmodified, 5 710 bytes

(moved off def print(self, value: Union[str, float], ...): a union parameter resolves the member at the call site)

adafruit_ina219

builds unmodified, 7 294 bytes

(moved off self.raw_bus_voltage: type(self) in the descriptor rewrite is INA219, not the mangled adafruit_ina219_INA219)

adafruit_irremote

pulses holds a tagged union with no member slot for its payload (RFC 0009 decision 4)

not the generator: a generator method compiles when its receiver is a bound name, and probe 294 holds that against CPython. The reason written here before outlived the rule that justified it

adafruit_lis3dh

builds unmodified, 2 522 bytes

adafruit_mcp230xx

Pin.high() runtime bit index

same as adafruit_character_lcd

adafruit_mcp3xxx

builds unmodified, 3 094 bytes

(moved off with ... as: the bound name now takes __enter__’s returned instance class)

adafruit_mcp9808

builds unmodified, 4 646 bytes

adafruit_mlx90614

builds unmodified, 3 846 bytes

neopixel

builds unmodified, with unmodified adafruit_pixelbuf

all(0 <= c <= 255 for c in val) unrolls over the tuple; pixels[i] = (r, g, b) sends GRB on the wire – verified byte order on the emulated Uno

adafruit_pca9685

builds unmodified, 1 852 bytes

(moved off [None] * len(self) and storing a PWMChannel in that cache)

adafruit_pcf8523

from time import struct_time

same as adafruit_ds3231

adafruit_pcf8574

builds unmodified, 1 442 bytes

(moved off -> Pull.UP; the pull property compiles)

adafruit_seesaw

builds unmodified, 3 706 bytes; its I2C stream matches the CPython oracle byte for byte on the emulated Uno

(moved off: a raise message mixing an f-string and a literal (f"...{self.chip_id:x}..." "..."), try/except ImportError inside a module reached by a function-scope import, struct.pack as a value and as a slice-assign source, the I/i/L/l codes, cmd[off:] = open-ended slice assign, and full_buffer += buf buffer concatenation)

adafruit_sht31d

(moved off word[i*2], crc[i*2], ... = struct.unpack(...))

an IndexExpr unpack binds the RHS to a name then stores t[k]; a struct.unpack buffer slice may start at a run-time offset; next construct after that is measured after this landing

adafruit_sht4x

(moved off temp_data = self._buffer[0:2])

a field bytearray slices like a named buf[a:b]; next is for byte in buffer in @staticmethod _crc8 (for-in loop iterable must be a compile-time string constant...)

adafruit_si7021

(moved off obj: "adafruit_si7021.SI7021")

a quoted dotted class is the same type as unquoted mod.Cls; next construct after that is measured after this landing

adafruit_ssd1306

the 128x32 simpletest builds unmodified and matches the CPython I2C oracle byte for byte on the emulated Uno, 4 348 bytes

(moved off unused GS2HMSBFormat.rect, an unfolded format if after super().__init__(_FRAMEBUF_FORMAT), len(framebuf.buf) on memoryview(self.buffer)[1:] through I2C -> _SSD1306 -> FrameBuffer, and board.I2C() returning the bus object)

adafruit_tcs34725

builds unmodified, 28 414 bytes

(moved off run-time pow to __pymcu_powf; tuple-valued property reads bind a compile-time sequence)

adafruit_tmp117

with obj.i2c_device as i2c in adafruit_register.i2c_struct

call to undefined function '__with_manager_0___enter__'; CV.add_values and imported Mode.ATTR no longer stop it

adafruit_tsl2591

builds unmodified, 5 814 bytes

adafruit_veml7700

builds unmodified, 10 614 bytes

(moved off self.gain_values[gain]: a class-body dict is a compile-time lookup table, including mixed int/float values)

adafruit_motor (servo)

builds unmodified, 2 332 bytes

(moved off self._min_duty; the whole four-module package compiles)

Which of these are limits and which are gaps#

Limits of the no-heap, fixed-width model. A piece in a raise message whose type has no static width is refused by name. array is dynamic storage. Each says so in one sentence at the line it is written on.

Two entries left this list rather than staying on it. **kwargs was read as needing a run-time dictionary, which is true of CPython and false here: the callee is specialised per call site and the extra keyword arguments are literals there, so it is a compile-time mapping (#368). except X as e was read as needing an exception object, and it does – a bounded one, which one live exception at a time makes static rather than allocated (#369). Both were restrictions of the lowering, not of the model, and all three libraries that stopped on **kwargs now stop somewhere else entirely.

adafruit_ssd1306 now reaches adafruit_framebuf (the module is present in the harness); fill = a, b, c is a tuple and buf[i:i+3] = bytes(fill) is an element-wise copy of compile-time length, so it moves off that assignment. class _SSD1306(framebuf.FrameBuffer) after import adafruit_framebuf as framebuf unwraps the alias, so super().__init__(buffer, width, height, fmt) expands the imported constructor. A field first assigned inside that base __init__ if (self.format = MVLSBFormat()) is still a constructor field: the super expansion prefix is inlineN___init___, which is inside __init__. The harness simpletest is the 128x32 I2C module (SSD1306_I2C(128, 32, i2c) plus two corner pixels); 128x64 is the same class with a different height. A None argument through super().__init__(reset=reset) is still None, so if self.reset_pin: folds and the guarded DigitalInOut use is not lowered (that path was Pin.low() on a port that was never a register). A for-in tuple element folds like any other constant: a const name, a field ternary, a comparison ternary, or field arithmetic, so init_display’s command table unrolls. A parameter named like an import alias (def set_pixel(framebuf, ...) after import adafruit_framebuf as framebuf) is the parameter, so framebuf.stride is the instance field. A class-body function with no self that reads a parameter field expands at the call site (the outlined body cannot see the argument’s class). framebuf.buf = [fill for i in range(len(framebuf.buf))] fills the field array already bound (GS2HMSBFormat.fill). if buf_format == MVLSB with a bound constant keeps that format class; a no-self method that forwards the buffer to a sibling (rect -> set_pixel(framebuf, ...)) expands at the call site, so unused GS2HMSBFormat.rect is not compiled. A local that holds that constant (_FRAMEBUF_FORMAT = framebuf.MVLSB) forwarded through super().__init__ is still a constant, so the I2C subclass’s self.format.fill is MVLSBFormat.fill. FrameBuffer(buf, w, h, MVLSB); fb.fill(0) and a subclass super().__init__(buf, w, h, _FRAMEBUF_FORMAT); fb.fill(0) build. memoryview(self.buffer)[1:] as a constructor argument is a writable window of that field (offset 1, shorter len). The I2C subclass forwards that window through _SSD1306(buffer) into FrameBuffer(buffer), so MVLSBFormat.fill’s for i in range(len(framebuf.buf)) / framebuf.buf[i] = fill writes buffer[1:] and leaves the I2C command byte. A plain buf[a:b] is still a copy. A plain function that returns a multi-field ZCA instance (def I2C(): return _board_i2c(SCL, SDA) in the board layer) expands at the call site, so the constructor’s field stores land in the caller’s binding and self._mode == "c" answers true. The 128x32 I2C simpletest (SSD1306_I2C(128, 32, i2c) plus two corner pixels) builds unmodified (4 348 bytes) and emits the same 50 I2C transactions as the CPython oracle, byte for byte, on the emulated Uno. onewireio is still missing for adafruit_ds18x20. neopixel builds unmodified – the generator expression in adafruit_pixelbuf (all(0 <= component <= 255 for component in val)) unrolls at compile time now that all/any/sum/min/max accept one over a known-length iterable, and pixels[i] = (r, g, b) drives the wire in GRB order. adafruit_framebuf.text("...", x, y, color) renders: the open("font5x8.bin", "rb") inside BitmapFont is a compile-time romfs handle (RFC 0008), so the font blob embedded by the driver feeds seek/read straight out of flash and the glyphs land in the framebuffer byte-identical to CPython.

The remaining refusals are scattered, one construct each. enumerate() over a buffer that reaches busio.I2C.writeto through inline bindings now compiles – the aliased class-attribute array resolves to its module-init storage, and a bytes([expr]) argument whose elements are run-time materializes a hidden buffer. A filled bytearray return is expanded at the call site (#464), and struct.unpack results bind as typed sequences, so adafruit_bmp280 builds unmodified. A runtime float pow(x, 2.5) lowers to __pymcu_powf (#463) and a tuple-returning property binds a compile-time sequence, so adafruit_tcs34725 builds unmodified. A Protocol member of a constructor Union is structural (#465), so adafruit_debouncer moved off the annotation; this simpletest’s Debouncer(pin) still refuses the DigitalInOut as matching none of the members. A field whose first store is a string literal or bytearray(...) is that kind, so adafruit_character_lcd (_message) and adafruit_74hc595 (_gpio) move off the numeric-vs-str / numeric-vs-buffer contradiction. pin.direction = Direction.OUTPUT through a for-unrolled instance is the @property setter, so adafruit_character_lcd moves off that assignment. bytearray(self._number_of_shift_registers) after the field holds a compile-time integer is a fixed buffer, and DigitalInOut(pin, self) inside that module is the file’s own two-argument class even when the entry file imported digitalio.DigitalInOut, so adafruit_74hc595 builds unmodified. __init__ is expanded at each construction, so an MCP get_pin() passed into Character_LCD.__init__ keeps the expander class instead of the digitalio.DigitalInOut annotation. A rebound import alias (servo = servo.Servo(pwm)) sees the instance on later reads (#467). A try-guarded from typing import Tuple that shares its try with a failing sibling import used to lose the resolved names entirely; with the fold fixed, adafruit_ina219 and adafruit_veml7700 move inside adafruit_register to RWBits.__get__/__set__, where obj is the owning instance even though it is annotated I2CDeviceDriver (#419); value <<= self.lowest_bit keeps value as a local so reg |= value is the shifted bits. A class-body _fit(n) keeps the grown _BUFFER rather than letting the replay of bytearray(1) shrink it, so _BUFFER[i] in RWBits is in range. value: Any on a descriptor __set__ is the written value, not a missing width. type(inst) in the descriptor rewrite is the source class name, so self.raw_bus_voltage inside a method of an imported class is not “name ‘adafruit_ina219_INA219’ is not defined”; adafruit_ina219 and adafruit_aw9523 build unmodified. A class-body dict is the same lookup table as a module-level one, so self.gain_values[gain] is a fold or a compare chain (mixed int/float values are a float); adafruit_veml7700 builds unmodified. A constant tuple assigned to a field is the same array as a list (self.scale = (524288, ...)), so adafruit_dps310 moves off that onto coeffs = [None] * 18. A str parameter that received a compile-time string keeps the text at any length, so struct.calcsize(struct_format) inside an inlined descriptor constructor folds (StructArray(0x06, "<HH", 16)). [None] * n is a fixed SRAM array, so coeffs = [None] * 18 and self._channels = [None] * len(self) index; None is a 0 slot. adafruit_pca9685 and adafruit_dps310 build unmodified once that cache and [None] * n are SRAM arrays of instances or integers. x = a, b, c is a tuple and buf[i:i+n] = bytes(fill) copies n bytes at a run-time start, so adafruit_ssd1306 moves off fill = (color >> 16) & 255, ... and framebuf.buf[i:i+3] = bytes(fill) in adafruit_framebuf. A quoted dotted class ("adafruit_si7021.SI7021") is the same type as unquoted mod.Cls, so adafruit_si7021 moves off that annotation. An IndexExpr unpack (word[i*2], crc[i*2], ... = struct.unpack(...)) binds the RHS to a name then stores each t[k], and a buffer slice may start at a run-time offset, so adafruit_sht31d moves off that assignment. The inner Adafruit TYPE_CHECKING guard (except NotImplementedError around from pwmio import PWMOut) keeps the resolved name and does not load the stub package (#480, #481). raise ... from ... (#434) and from __future__ import annotations (#452) no longer stop adafruit_irremote; namedtuple is a compile-time ZCA class factory, so it moves off from collections import namedtuple onto yield in a method. isinstance(address, (tuple, list)) folds from the argument’s shape (#423), so adafruit_ht16k33 matrix moves off that onto bytearray((self._buffer_size) * len(self.i2c_device)). os.uname() is a compile-time view of __CHIP__ (#466), so adafruit_dht moves off from os import uname onto Union[int, float, None] on temperature. time.struct_time is a nine-field stub, so the RTC drivers move off that import. @classmethod setattr in an imported subclass uses the same class key as Mode.ATTR (the mangled adafruit_sht4x_Mode is not prefixed again), so adafruit_sht4x moves off self._mode = Mode.NOHEAT_HIGHPRECISION. A tuple-returning @property indexes like f()[k], so it also moves off return self.measurements[0]. A field bytearray slices like a named buf[a:b], so it also moves off temp_data = self._buffer[0:2] onto for byte in buffer in _crc8.

Five more I2C sensors build unmodified on the expanded list: adafruit_ahtx0, adafruit_mcp9808, adafruit_lis3dh, adafruit_tsl2591, adafruit_mlx90614. adafruit_ina219 (7 294 bytes) and the adafruit_aw9523 expander (2 294 bytes) join them once type(self) in a descriptor rewrite is the source class name. adafruit_veml7700 (10 614 bytes) joins once a class-body dict through self is a lookup table. adafruit_dps310 (13 162 bytes) and adafruit_pca9685 (1 852 bytes) join once [None] * n is a fixed SRAM array that can hold a later instance.

A union of two REAL types is implemented end to end. Optional[X], X | None and Union[X, None] are read as X in parameter and local positions when the None-ness stays provable, and a -> Optional[X] / -> Union[A, B, ...] return carries the runtime tag described in “None is a compile-time value” above. The same tag backs multi-member returns (Union[int, float, None] included), union parameters on real subroutines, union-typed fields on an instance, and a local merged None under a runtime condition – the readers dispatch on the member index and the payload keeps the widest member’s storage. That moved nine of the original twenty off the annotation they used to stop on, and cost nothing (the 321-fixture corpus is byte-identical).

What moved on 2026-09-14. WriteableBuffer and ReadableBuffer are read as the byte buffer they name (#356), so adafruit_bus_device and the three libraries behind it reach the union instead of an unknown type. Tuple[...], and a ... or a Literal[...] inside a tuple annotation, are read (#357): adafruit_tcs34725 moves off the annotation, adafruit_ds18x20 reaches its missing module, and adafruit_ht16k33 segments moves from the annotation on line 181 to the call in a raise message on line 210. A two-index subscript binds its pair at compile time (#352), so adafruit_ht16k33 matrix compiles m[x, y] = 1 as written. And Optional[X] is read as X, which moved nine. Nothing now stops on an annotation naming a type the compiler has, or on a construct whose message points at a bracket.

A two-index subscript (matrix[x, y]) is the same no-runtime-tuple limit reached through a subscript: the pair becomes one tuple before __getitem__ sees it. Whether the compiler should bind that pair at compile time, so the x, y = key upstream writes unpacks the way a, b = f() already does, is open.

Every one of the thirty-seven now names its construct at the line it is written on. None is reported as a missing bracket, and none names anything internal to the compiler. That is the property to check when one of these messages changes.