Writing a PyMCU Library#
The index is live
pymcu install <name> resolves names against the published index at
libraries.pymcu.org/index.json, with a GitHub mirror as fallback. It is
regenerated weekly, and each entry records which compiler measured it.
There is no browsable catalogue page yet — only the JSON. pymcu search and
pymcu libraries read it from the command line.
A PyMCU library is source code the compiler reads at build time, not a module
imported at runtime. There is no interpreter on the device: your .py files are parsed
by pymcuc and compiled into the user’s firmware alongside their own code.
That single fact shapes everything below. A library is a wheel that ships .py files and
a manifest; it never ships a binary, and it is always a project dependency, never a
global install — the compiler looks inside the project’s .venv.
1. Package layout#
pymcu-lib-dht/ # distribution name on PyPI
pyproject.toml
README.md
api-surface.lock
docs/
tests/
examples/basic/ # a compilable project; see below
src/pymcu_lib_dht/
__init__.py # an ordinary Python package
pymcu.toml # the manifest
mcu/ # everything the compiler reads
dht.py # public API, arch-neutral
adafruit_dht.py # a second public name, CircuitPython's
_dht/ # private implementation package
__init__.py
core.py
avr.py
compat/
micropython/dht.py # optional adapter
Two rules, and everything else follows from them.
The package is an ordinary Python package. It has an __init__.py, so
import pymcu_lib_dht works and importlib.resources can find its data. Without one
it would be a PEP 420 namespace package by accident, which is not something to be by
accident.
Only mcu/ reaches the compiler. The include path gets that directory and nothing
else, so what a user can import is exactly what you put there. Everything else —
examples, tests, docs, tooling — lives at the distribution root, outside the package,
and so travels in the sdist and not the wheel. That is the normal split for a Python
project, and pymcu lint --library enforces it: a .py anywhere else in the package
is an error. It has to be, because when the include path was the package itself, a
library’s examples/ answered import examples from any firmware in the world, and two
libraries shipping one shadowed each other with no diagnostic at all.
Modules directly inside mcu/ become top-level imports for the user:
from dht import DHT11
Anything deeper is yours. A private package is how you avoid stamping a prefix on every
file: the include path is flat and shared with every other installed library, so a bare
core.py would be a global name — but _dht/core.py is not, and reads better than
_dht_core.py at every call site.
The distribution name (pymcu-lib-dht) and the import name (dht) are deliberately
different: the first has to be unique on PyPI, the second only has to be unique among the
libraries a project actually installs.
pyproject.toml#
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "pymcu-lib-dht"
version = "0.2.0"
description = "DHT11, DHT22 and DHT21 sensors for PyMCU"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = ["pymcu-stdlib>=0.1.0a5"]
[project.entry-points."pymcu.libraries"]
dht = "pymcu_lib_dht"
[tool.hatch.build.targets.wheel]
packages = ["src/pymcu_lib_dht"]
pymcu-stdlib is a real dependency: the sources import pymcu.types and pymcu.hal.*,
so an environment without it cannot compile them. The entry point is how pymcu build
finds the package without anyone listing it in [tool.pymcu].
2. The manifest#
pymcu.toml sits at the root of the importable package and travels inside the wheel.
[library]
name = "dht"
summary = "DHT11, DHT22 and DHT21 temperature and humidity sensors"
license = "MIT"
repository = "https://github.com/example/pymcu-lib-dht"
categories = ["sensor"]
sources = "mcu"
[library.provides]
modules = ["dht", "adafruit_dht", "_dht"]
[library.supports]
arch = ["avr", "arm"]
chips = []
layer = "native"
adapters = ["micropython", "circuitpython"]
symbols = []
[library.requires]
stdlib = ">=0.1.0a5"
compiler = ">=0.1.0a5"
language-level = 1
[library.examples]
basic = "examples/basic"
Key |
Meaning |
|---|---|
|
Directory inside the package holding what the compiler reads. Defaults to |
|
Top-level names the library claims, private ones included. Two installed libraries claiming the same name is a resolution error, so a private package has to be declared to be protected. |
|
Architectures, as reported by |
|
Narrows to specific chips. Empty means every chip of the listed architectures. |
|
|
|
Layers with a wrapper under |
|
Optional. For layer libraries, the symbols actually used (e.g. |
|
Version ranges and the language level. Mirror |
The manifest carries no version number. The version comes from the distribution
metadata (importlib.metadata.version). A number that is not duplicated cannot drift out
of sync — this rule exists because a package once shipped twice under the same version
with different contents.
supports is a promise, not a measurement. CI verifies it by compiling: everything
listed must build, and an architecture that is not listed must fail to build.
3. Writing the code#
Target the native HAL, not a compatibility layer#
The MicroPython, CircuitPython and native layers are not interoperable. time.sleep
takes a uint16 in the MicroPython layer and a float in the CircuitPython one;
board.D0 is the integer 0 in one and the string "PD0" in the other. The only thing
all three share is pymcu.hal.* and pymcu.types, which both compat packages depend on
and wrap.
So a portable library is written against the HAL and takes pin identifiers, not layer objects:
from pymcu.chips import __CHIP__
from pymcu.exceptions import CompileError
from pymcu.types import uint16, inline
class DHT11:
@inline
def __init__(self, pin: str):
self.name = pin
@inline
def read(self) -> uint16:
match __CHIP__.arch:
case "avr":
from _dht.avr import read
return read(self.name)
case "arm":
from _dht.arm import read
return read(self.name)
case _:
raise CompileError("DHT11 is not supported on this architecture")
__CHIP__.name and __CHIP__.arch are resolved at compile time and the losing branches
are eliminated, so this costs nothing at runtime — the same two-level dispatch the HAL
itself uses.
Which surface carries a promise#
The section above sends you to the native HAL, so it owes you the other half of the answer: what you may rely on, and what can move under you without warning.
Write your library against pymcu.hal.*, pymcu.types and __CHIP__. That is the
surface this guide is about, it is the only vocabulary the three layers share, and for the
facts a portable library actually needs there is no alternative. __CHIP__.arch is the
clearest case: MicroPython and CircuitPython have no API that says avr or riscv,
because neither of them compiles for those parts, and sys.platform names a port rather
than an architecture family. ram_size and flash_size are the same, since gc.mem_free()
is a runtime number and not the part’s static total. A library that has to branch by
architecture has nowhere else to ask.
Reach a peripheral’s registers through its grouped class, Timer1.TCCR1A.value rather
than the loose TCCR1A. The group is a name the project commits to and keeps pointing at
the right silicon; the loose module-level register names, the bit-position constants and
the rest of a chip file are how the HAL reaches the hardware and can be renamed, regrouped
or rewidened in any release. See docs/rfcs/0012-grouped-peripherals.md for why
grouping costs nothing and what the group is derived from.
So, in order of how much you may lean on it:
Surface |
What you may rely on |
|---|---|
|
The vocabulary this guide tells you to use. It is where a portable library belongs, and |
Grouped peripherals ( |
The register surface for programs. Named after the datasheet, and derived from the loose names rather than copied from them. |
Loose register names, bit constants, per-chip internals |
Nothing. They are the HAL’s own plumbing and change with it. |
One thing this does not say, because the project is not there yet. PyMCU is beta on
AVR and alpha on ARM, PIC and RISC-V (see the supported-hardware section of the
documentation index), so nothing here is frozen: signatures will still change while the
standard library is aligned with the MicroPython and CircuitPython APIs. What the first
two rows promise is that such a change arrives with a changelog entry and a migration
note, and the third row promises the opposite, that it can change in any release with
neither. The alpha note on the ARM, PIC and RISC-V backends
is a separate statement about which chips are ready, not about this vocabulary:
__CHIP__.arch means the same thing on every backend, including the ones that are alpha.
End the dispatch with CompileError, never a sentinel#
The case _: branch must raise. A driver that returns 0xFFFF on an unsupported
architecture compiles, and the user finds out on the bench instead of at build time.
With CompileError, “it compiles” means “the author implemented this architecture”, which
is also what makes the measured compatibility matrix trustworthy.
Keep it zero-cost#
Flash is the scarce resource: an ATmega328P has 32 KB. Follow the same rules as the HAL —
@inline methods, no instance state beyond what the wrapped object needs, primitives in
helper signatures. A library written in ordinary Python style compiles, but may not fit.
See Type System for the zero-cost abstraction model.
ASCII only#
The lexer rejects any non-ASCII character outside comments and strings. Worse, non-ASCII inside a string passes the lexer and is then encoded as ASCII, corrupting the byte silently. Keep every source file in the package plain ASCII — degree signs and accented characters included.
4. Optional compatibility adapters#
An adapter re-exports the core with the idioms of one layer. It lives under
mcu/compat/<layer>/ and only enters the include path when the project declares that
layer, so several adapters can use the same module name:
# mcu/compat/micropython/dht.py
from pymcu.types import uint16, inline
from _dht.core import Frame
class DHT11:
@inline
def __init__(self, pin):
self._frame = Frame(pin) # a machine.Pin, not a bare name
@inline
def measure(self):
...
An adapter is only worth writing where the API actually differs. If a layer would import the same name with the same shape, do not add a directory for it — a copy that shadows nothing is just a second place to fix the same bug.
Import the core through its private package (_dht.core), never through the public
name — inside the adapter, dht is the adapter. That is the whole reason the core has
a name of its own rather than living in a dht/ package: an adapter that shadows the
public name would otherwise shadow the implementation along with it.
This mirrors what the compat packages already do with pymcu.drivers.neopixel: one core,
thin wrappers per layer.
5. The example project#
examples/basic/ is a normal PyMCU project — pyproject.toml with [tool.pymcu] plus a
src/main.py. It sits at the distribution root, beside tests/ and docs/, so it
travels in the sdist and not in the wheel.
It has two jobs: it is the copy-pasteable snippet the docs and the catalogue show, and it is what the index compiles per chip to produce the compatibility matrix and the flash/RAM figures. The index reads it from the sdist, which is exactly what an sdist is for — an immutable, versioned artefact carrying everything needed to build and check the project.
It is not what pymcu install --verify compiles. That builds a small program importing
the modules in provides.modules, which needs nothing but the wheel: it answers whether
those modules resolve and compile for the installing project’s chip and layer, and rolls
the install back if they do not.
Keep it minimal. Anything the example pulls in shows up in the numbers.
6. Testing it locally#
Install the library into a test project’s environment in editable mode and build:
cd examples/basic
uv pip install -e ../..
pymcu build --verbose
--verbose prints the include paths. The package directory (and its compat/<layer>/
when the project declares a layer) must appear as [debug] Extra include: lines. If it
does not, the entry point is missing or the environment being used is not the project’s.
Then check the negative case: switch [tool.pymcu] to a chip of an architecture you do
not support and confirm the build fails with your CompileError, not with wrong output.
7. Publishing#
Lint:
pymcu lint --librarychecks ASCII, the manifest, the dispatch rule and the public API surface hash againstapi-surface.lock.Bump the version whenever the public surface changes. The surface hash exists to make forgetting impossible: CI fails if the hash moved and the version did not.
Publish to PyPI, ideally with trusted publishing from a GitHub Release, the same way the PyMCU packages are published.
Submit to the index: open a PR against
pymcu-librariesadding one line with your distribution name. CI installs it, runs the checks above, and compiles your example for one chip per architecture — including the ones you did not declare. That last part is the whole point: it is what separates “does not support ARM” from “nobody updatedsupports.arch”. Declaring an architecture that does not build fails the check; building for one you never declared is reported so you can claim it.
You do not need a new PR for later releases. A scheduled run re-installs the newest
version of everything listed and measures it again, so an entry says what builds today
rather than what built the day it was submitted — and a library that stops building
against a new compiler is marked broken without anyone filing an issue.
Where the index lives#
Two copies of the same file: https://libraries.pymcu.org/index.json (an R2 bucket behind
a Worker) and a mirror at raw.githubusercontent.com/PyMCU/pymcu-libraries/main/index.json.
The driver tries the first and falls back to the second, because the pymcu.org zone
answers 403 to requests from data centres and pymcu install runs inside other people’s
CI. PYMCU_LIBRARY_INDEX overrides both, which is also how you test against a local file.
Upstream libraries: no wrapper needed#
Everything above is for a library written for PyMCU. A library someone already
publishes on PyPI for CircuitPython or MicroPython, that happens to be plain Python with
no interpreter-only surface, does not need any of it: no pymcu.toml, no
pymcu.libraries entry point, no fork. The index can list it directly as an upstream
entry. It names the distribution, what it provides, and a measurement program, and the
compiler is told where to find the module(s) it already installed.
The bytes stay the author’s. The index only vouches for the distribution and measures whether it compiles; it never carries a copy of the library’s code, only of the one file used to measure it.
Adding one is a maintainer action on the pymcu-libraries
repository, not something the library’s own author does. libraries.txt grows a second
line form:
upstream <distribution> provides=<module>[,<module>...] layer=<native|micropython|circuitpython> example=<path> [name=<name>]
provides: the top-level module(s) the distribution installs (comma-separated for more than one). Read from the distribution’s own files at build time, never imported.layer: which stdlib flavor a project must declare for the library to apply (layer=circuitpythonfor anadafruit_*module). Defaults tonative.example: a path, relative to thepymcu-librariesrepository root, to a copy of the library’s own example committed underupstream-examples/<distribution>/. This is the measurement program, the same bar a manifest library’sexamples/basic/clears, kept because the index has nowhere else to read one from: an upstream distribution’s sdist is not guaranteed to carry its examples the way a PyMCU library’s is required to.name: optional; defaults to the firstprovidesmodule, and is whatpymcu install <name>andpymcu searchmatch against, alongside the distribution name itself.
At build time, an installed distribution the index lists this way contributes its
declared module(s) to the compiler’s include path, staged into dist/_upstream/, never
pointed at site-packages directly, so nothing else installed alongside it becomes
importable from a user’s firmware. This happens after every manifest library, so an
upstream one can never shadow board, digitalio, pulseio, or a curated library.
pymcu index build/verify measure an upstream entry exactly like a manifest one: one
chip per architecture, the declared layer enabled, and record the verified installed
version, the license and summary read from the distribution’s own metadata, and the same
per-chip measured block every entry carries. There is no supports.arch to compare the
measurement against, since there is no manifest to hold that promise; the measurement is
the entry’s only claim about compatibility.
An upstream entry is re-measured on the same schedule as everything else, and is removed from the index when it stops building. That, too, is a maintainer action, since there is no author to notify.
Checklist#
Public API takes pin identifiers, not layer objects
Architecture dispatch via
match __CHIP__.arch, ending inCompileErrorEvery method
@inline; no unnecessary instance stateAll sources ASCII, comments included
pymcu.tomlpresent, with no version number in itpymcu-stdlib(and any other requirement) declared in[project.dependencies]pymcu.librariesentry point registeredexamples/basic/compiles for every declared architectureapi-surface.lockregenerated and the version bumped