ADR 011. Everyday stdlib: dataclasses, pytest, and the eight modules a CPython script reaches for daily¶
- Status: Accepted. Implemented in v0.7 (8 modules shipped with a pytest_lite test surface reachable via
make test-stdlibon both arms). - Decider: project maintainers
- Supersedes: nothing
- Branches from: ADR 003 (frozen-tiny stdlib + lazy-fetch model; this ADR fills in which modules ship in the frozen tier); ADR 010 §A3a (stdlib expansion slices; this ADR is the v0.7+ continuation of that lane); ADR 012 (profiles + feature matrix; once landed, each stdlib module here declares its
# nanopy:profile-requiresheader per ADR 012 D5, and the "which profile bundles which module" question becomes mechanical) - Consequences: commits the project to shipping a Python-stdlib subset shaped by typical CPython script use rather than by porting upstream micropython-lib verbatim; commits to writing the modules in pure Python (no C/Zig), each as its own file under
nanopython-lib/; commits to a "bootstrap-then-test" methodology where pytest-lite is the second module written and is used to test the rest; commits to ~15-25 KB total frozen-tiny growth over the v0.6 baseline.
1. Context¶
1.1 What we have today¶
nanopython-lib/ ships (frozen-tiny under wasm; filesystem under native, per ADR 003 D4):
collections.py: CPython-shapeddeque(iterable)plus the standardnamedtuple/OrderedDict/defaultdict/Counterfrom micropython-lib.itertools.py: ADR 010 §A3a slice 2.chain,cycle,count,repeat,starmap,tee, etc.string.py: ADR 010 §A3a slice 2.ascii_letters,digits,whitespace,Formatter.re: built-in C carveout (vendorextmod/modre.c+lib/re1.5/). Both arms.json,__future__,_thread,types,heapq,bisect: frozen-tiny via micropython-lib.
The following are missing for a daily CPython feel:
- No dataclasses. Every script that uses
@dataclass class Point: x: int; y: intfalls back to a hand-written__init__+__repr__+__eq__. - No test runner. Programs ship tests as
if __name__ == "__main__":blocks with assertions. There is nopytest tests/equivalent and no way to discovertest_*functions across a tree. - No
logging.print(level, msg)everywhere; no level filtering, no format strings, no namespacing. - No
argparse. CLI scripts hand-rollsys.argvparsing. - No
enum.class Color: RED = 1; GREEN = 2is the workaround. functoolsis missing key everyday helpers:partial,wraps,lru_cache.- No
contextlib.@contextmanagerover a generator is unavailable;withblocks need full class implementations.
The v0.6.1 DELATTR_SETATTR flip unlocks the descriptor + __setattr__ machinery several of these modules need (dataclasses synthesizes __init__ which assigns to instance attributes; enum uses a metaclass-ish pattern; pytest decorators want __setattr__ to attach metadata to functions). The runtime work is done; the library work isn't.
1.2 What "everyday" means¶
This ADR is anchored on the workflow of a Python developer who reaches for the language daily: for scripts, CLIs, small services, data munging, test suites. The shape of their stdlib usage is well-studied (see CPython's import telemetry, PyPI's import graph). The eight modules below cover most of what such a developer types into a fresh main.py.
In contrast, ADR 003 framed the stdlib question around "the modules micropython-lib already implements." That's a perfectly valid framing for embedded compatibility. But for the nanopython demos site, for kids working through tutorials, for a "write a Python script that solves my problem" use case, the modules a CPython developer reaches for daily are the right surface to optimize.
1.3 Why this isn't subsumed by micropython-lib¶
Most of the modules below exist in micropython-lib in some form. The reasons to write nanopython-shaped versions anyway:
- Size. Upstream
argparse(in micropython-lib) is ~1700 lines and pulls a chain of dependencies; a nanopython-shaped version covering 80% of script-CLI use can be ~120 lines. The frozen-tiny tier is opinionated; everything in it pays a cold-load cost. Subset before vendor. - Test discoverability. There is no upstream
pytest-shaped runner under micropython-lib. The closest isunittest, which is heavier and lacks the function-discovery + bare-assertworkflow we want. @dataclass. CPython'sdataclasses.pyis ~1500 lines and depends oninspect,copy,types. A nanopython-shaped version covering field synthesis (with defaults, no slots, no frozen, no kw_only) is ~150 lines.- Bootstrap test path. The runner that tests the stdlib modules needs to itself be one of the modules. Writing pytest-lite second (after
enum) and testing the rest with it is cleaner than depending onunittest.
1.4 What this ADR does not commit to¶
This ADR addresses the stdlib library tier. The language stays out of scope. Out of scope for v0.7:
- Type hints at runtime beyond what already works (annotation reading via
__annotations__). typingmodule:NamedTuple,Protocol,Any,Genericruntime semantics.pathlib: until the WASI filesystem story is more developed.subprocess: no OS process model in wasm.datetime: needs calendar + locale APIs whose surface is larger than this ADR can scope.asyncio: Path B deferral from ADR 010 §A4 still stands.pickle,shelve,xml,http,socket,ssl: large surface, low signal for the use cases above.
2. Decision space¶
2.1 What should ship in the frozen-tiny tier vs lazy-fetch¶
ADR 003 D4 introduces both. Frozen-tiny is bundled into the wasm shipping artifact (pays the cold-load tax); lazy-fetch is fetched on first import (no cold-load tax but a runtime tax).
The criterion: a module belongs in frozen-tiny if (a) it's small, (b) it's reached for often enough that the lazy-fetch latency would surprise users, and © other frozen modules depend on it.
For the candidates here:
- dataclasses: small (~150 lines after subsetting), reached for often (decorator on every script), and used by other modules below (logging's Record could be a dataclass). Frozen-tiny.
- pytest-lite: ~250 lines, but only reached for during testing; never at runtime by a shipping script. Lazy-fetch. Lives at
nanopython-lib/pytest.py;import pytesttriggers fetch the first time. - logging: small (~80 lines), reached for in every script that wants to emit anything beyond
print. Frozen-tiny. - argparse-lite: ~120 lines, reached for in every CLI script. CLI scripts care about cold-load less because they're one-shot. Frozen-tiny but slow to second-fetch: argparse is fundamental enough to warrant being in the always-available tier.
- enum: tiny (~50 lines), reached for everywhere as a base concept. Frozen-tiny.
- functools: subset (partial, wraps, lru_cache; ~80 lines), reached for in everyday composition. Frozen-tiny.
- contextlib: tiny (~40 lines), reached for in
withblocks. Frozen-tiny. - abc: tiny (~30 lines), reached for in any class hierarchy with abstract methods. Frozen-tiny.
2.2 Implementation tier: pure Python vs C/Zig¶
All modules here are pure Python. No Zig, no vendor C extensions. The reasoning:
- Each is small. The frozen-tiny size budget for the eight is ~15-25 KB unoptimized. A
mpy-crosspass shrinks each by ~40% (ADR 003 references this). Pure Python ships well. - Pure Python is easier to audit and update than a Zig port. None of these modules sit on the hot path of an interpreter loop; performance isn't a concern.
- The C extensions micropython-lib provides for some of these (
micropython-uctypesetc.) bring transitive dependencies we don't want in the frozen tier.
2.3 Bootstrap-then-test methodology¶
The natural order for writing these modules has a dependency graph:
enum ──┐
├──→ pytest-lite ──→ test everything else
contextlib ─┘
↑
functools ───────┘
dataclasses ─────┘
logging (independent)
argparse (independent)
abc (independent)
We write enum + contextlib + functools first (no runtime features beyond what v0.6.1 already shipped). Then pytest-lite using those. Then dataclasses and the rest, each with a pytest-lite test suite that ships in tests/stdlib/.
This avoids the "test the test runner with the test runner" bootstrap problem: pytest-lite has a tiny self-test that runs first (tests/stdlib/test_pytest_lite.py) and asserts on its own discovery before the rest of the suite trusts it.
3. Decision¶
D1. Ship eight modules in two phases¶
Phase 1 (the headline pair the user asked for):
- dataclasses (frozen-tiny)
- pytest aliased to pytest_lite (lazy-fetch)
Phase 2 (the everyday set):
- logging (frozen-tiny)
- argparse (frozen-tiny)
- enum (frozen-tiny)
- functools (frozen-tiny; subset: partial, wraps, lru_cache, reduce)
- contextlib (frozen-tiny; subset: contextmanager, closing, suppress)
- abc (frozen-tiny; ABCMeta, abstractmethod)
Phase 1 lands first because the user asked. Phase 2 lands as a unit once Phase 1 has been used in at least one demo (target: a CLI script in web/demos/cli-example/ that uses dataclasses + argparse + logging + pytest).
D2. Pure Python, one file per module¶
Every module is a single .py file under nanopython-lib/. No subpackages. No C extensions. The modules can import each other; the import graph stays acyclic.
This matches the pattern nanopython-lib/collections.py and nanopython-lib/itertools.py already follow.
D3. Tests live at tests/stdlib/¶
Each module gets a tests/stdlib/test_<name>.py file. The runner is the module under test only for pytest itself; for the rest, pytest-lite runs the test file.
make test-stdlib runs the whole directory.
D4. Subset over completeness¶
Every module is explicitly a subset of its CPython counterpart. The subsetting rules:
- Cover the 80% case: what a "fresh
main.py" needs. - No rarely-used edge features (
@dataclass(frozen=True),pytest.mark.parametrize, argparse subparsers, lru_cache withtyped=True). - No features that depend on missing nano runtime support (metaclass-based ABC if metaclasses aren't fully supported;
dataclass(slots=True)since__slots__is silently ignored on micropython). - Each module's docstring explicitly lists what's NOT supported so callers know what to expect.
D5. import pytest works whether installed or not¶
For the lazy-fetch case: the frozen-tiny tier ships a tiny stub pytest.py that re-exports from pytest_lite once the latter is fetched. The stub is ~10 lines and means import pytest works the moment the package fetches.
This is asymmetric with what CPython users expect (pytest is a third-party package), but matches what they want: the import name is reliable.
4. Sequencing¶
Phase 1. dataclasses + pytest-lite (the headlines)¶
Order within phase:
1. enum (smallest, prerequisite for nothing here but useful immediately)
2. pytest_lite (tested initially via tiny self-test, then becomes the runner for everything else)
3. dataclasses (the user's headline, depends on v0.6.1 __setattr__)
4. pytest stub (10-line re-export, makes import pytest work)
Estimated total: ~3-4 days.
Phase 2. the everyday rest¶
Once Phase 1 demonstrates the methodology and a tiny CLI demo (web/demos/cli-example/) uses dataclasses + pytest + at least one Phase 2 module:
contextlib(small, unblocks pytest-lite's yield-style fixtures)functools(frequently reached for; depends oncontextlibforlru_cache's docstring example only)logging(independent)argparse(independent)abc(independent)
Estimated total: ~3-4 days.
Phase 3. sizing pass¶
After both phases land, run make wasm-small and tools/metrics.py record to measure the cold-load impact. Acceptable: ≤25 KB total growth in the frozen-tiny tier. If we land at ≤15 KB, the budget is comfortable and we can revisit datetime / pathlib-lite. If we're closer to 25 KB, defer those.
5. Consequences¶
Positive:
- A typical CPython script (
dataclassesfor data,argparsefor CLI,loggingfor output,pytestfor tests) runs unchanged on nanopython for the first time. - Demos in
web/demos/can use these idioms without falling back to hand-written__init__/print/ etc. - The tutorials we'd write to teach Python on nanopython can mirror the CPython curriculum more closely. "Here's how you write a class" doesn't have to dodge
@dataclass. - The library tier in
nanopython-lib/grows in a coherent direction: each module added is one a daily-CPython user already knows.
Negative:
- Frozen-tiny grows by ~15-25 KB. The shipping wasm artifact grows proportionally; this is partly offset by the
wasm-optpass but is real cold-load tax. - Maintenance surface: 8 new pure-Python modules to keep in sync with CPython behavior changes. Each ships subset semantics; deltas need to be documented or callers will hit "but CPython does X" issues.
- The pytest aliasing is mildly confusing:
import pytestworks withoutpip install pytest, which CPython users might find surprising. The first surprise is good; the second time someone tries to installpytestand discover the version landscape diverges is worse. We document this indocs/COMPATIBILITY.md.
Open risks:
__init_subclass__support on nano needs verification. ADR drafted assuming it works (vendor MP supports it). If it doesn't,enum.pyandabc.pyneed redesigns to use class decorators instead.- The "lazy-fetch via micropython-lib path" for
pytest_litedepends on ADR 003 D6 (IndexedDB cache) being wired for non-micropython-lib origin packages. Verify before committing the lazy-fetch tier.
6. Out of scope (deferred to later v0.x)¶
datetime: needs Python-level calendar + epoch APIs we don't have. Defer to v0.8 or whenever atimemodule surfaces.pathlib: depends on a richer WASI filesystem story.typingruntime:NamedTuple,Protocol,Any. Defer.subprocess,socket,ssl,http: wasm + browser environment doesn't have these.pickle,xml,csv: defer until a concrete use case asks.asyncio: Path B deferral from ADR 010 §A4 still stands.
7. See also¶
- ADR 003: frozen-tiny + lazy-fetch delivery model; this ADR is the "which modules" instantiation of D4.
- ADR 010 §A3a: earlier stdlib slices (re, itertools, string). This ADR is the v0.7+ continuation.
notes/06-v0.6-followups.md: v0.6.1 closed DELATTR_SETATTR whichdataclassesdepends on.notes/08-game-developer-roadmap.md: separate lane (browser game library) that complements but doesn't overlap this ADR.