Skip to content

Adding a New Library

This guide walks you through the full lifecycle of adding a new library to ChuMicro, from idea to published package. If you're working with an AI agent, point it at the new-library skill.


Before you start

Check open issues and discussions, and skim plans/next-up.md and plans/decisions/ to see if your idea overlaps with planned work or settled design choices. If you're unsure whether the library fits the project, open a discussion first. It's much faster to align on scope before building.

Is your package host-only? This guide is for device libraries: code that runs on CircuitPython, MicroPython, and CPython. If you're adding a tool that runs only on the developer's laptop (a CLI that drives devices, a REPL client, a firmware helper), it belongs in workbench/ instead. See workbench.md for the layout and conventions. The scaffolder supports workbench packages: from inside the mono-repo run python scripts/run.py new-library --workbench <name>; for a workspace project outside the mono-repo use chumicro-workspace new --workbench <name>.

1. Scaffold

python scripts/run.py new-library my-sensor

The mono-repo wrapper composes the chumicro_workspace.scaffold.scaffold_library primitive (owned by the workbench chumicro-workspace package) with the mono-repo-only follow-ups (editable install + IDE config sync). External users developing their own chumicro-style libraries get the same scaffolder via python run.py new --library <name> from inside their workspace.

This creates libraries/my-sensor/ with:

libraries/my-sensor/
├── VERSION                          # starts at 0.1.0
├── pyproject.toml                   # package metadata
├── README.md                        # package README (fill in TODOs)
├── mkdocs.yml                       # docs config
├── src/chumicro_my_sensor/
│   ├── __init__.py                  # public exports (imports MySensor)
│   ├── core.py                      # starter class with patterns
│   └── testing.py                   # test fakes (keep or delete)
├── tests/
│   ├── conftest.py                  # collection anchor (see note below)
│   └── test_my_sensor.py             # starter tests (100% coverage)
├── docs/
│   ├── index.md                     # docs landing page
│   ├── guide.md                     # user guide (fill in)
│   ├── api.md                       # API reference (mkdocstrings renders it)
│   └── testing.md                   # testing helpers docs
├── examples/
│   └── basic_usage.py              # working example using MySensor
└── functional_tests/
    └── .gitkeep                     # on-device tests live here

In addition to creating the library directory, the scaffolder runs an editable install (pip install -e libraries/my-sensor) and re-runs sync-ide, which updates the local .idea/chumicro.iml plus the tracked .idea/runConfigurations/, pyrightconfig.json, .vscode/tasks.json, and .vscode/settings.json so your IDE picks up the new package immediately. The .iml is tracked in git (un-ignored via !.idea/chumicro.iml in .gitignore) and regenerated by sync-ide; it and the other config files are the ones you may see in git status after running new-library.

The scaffold is immediately runnable: tests pass at 100% coverage, lint is clean, and the example executes. Start by replacing the starter MySensor class in core.py with your real implementation.

The tests/conftest.py looks empty but is load-bearing: the workspace runs one pytest rootdir over every library with --import-mode=importlib, and the file anchors each tests/ directory as its own collection root so same-named test modules in sibling libraries don't collide. Don't delete it.

2. Implement

Put your code in src/chumicro_my_sensor/. Follow the Style Guide for naming, annotations, docstrings, and formatting. Key rules for library code:

  • No async/await. Use the tick-based runner pattern. If your library has active components, implement check(now_ms) -> bool so they work with Runner.
  • No third-party dependencies that aren't available on all three runtimes.
  • No typing imports. Use PEP 604/585 syntax: int | None, list[int].
  • Memory patterns are optional on day one: const(), memoryview, pre-allocated buffers. Focus on correctness first. The Style Guide has the full list when you're ready.

Constructor injection

Accept dependencies (time sources, I/O objects, network sockets) as constructor parameters instead of importing hardware modules at the top level. This makes your code testable without real hardware:

# ✅ Good: testable, injectable
class MySensor:
    """Reads from a sensor on a schedule."""

    def __init__(self, i2c: object, interval_ms: int = 1000) -> None:
        self._i2c = i2c
        self._interval_ms = interval_ms
# ❌ Bad: hard-wired to hardware, can't test without a board
import board
import busio

class MySensor:
    def __init__(self) -> None:
        self._i2c = busio.I2C(board.SCL, board.SDA)

See Decision 0010 for the reasoning.

Public API

Export your public API from __init__.py:

"""ChuMicro my-sensor: one-line description."""

from chumicro_my_sensor.core import MyClass, helper_function

__all__ = ["MyClass", "helper_function"]

3. Write tests

Tests go in libraries/my-sensor/tests/. Every library must independently meet the coverage threshold configured in pyproject.toml.

pytest libraries/my-sensor/tests/                          # everyday iteration
pytest libraries/my-sensor/tests/test_core.py -x -v        # one file, stop on first failure
python scripts/run.py test --libraries my-sensor           # gated run with coverage enforcement

What a test looks like

Since you accepted dependencies as constructor parameters, testing is straightforward. Pass in a fake:

"""Tests for MySensor reading behavior."""

from chumicro_my_sensor import MySensor


class FakeI2C:
    """Fake I2C bus that returns predetermined data."""

    def __init__(self, data: list) -> None:
        self._data = data
        self.read_count = 0

    def readfrom_into(self, address: int, buffer: bytearray) -> None:
        """Fill buffer with the next predetermined response."""
        buffer[:] = self._data[self.read_count]
        self.read_count += 1


def test_sensor_reads_from_bus() -> None:
    """Sensor returns data from the I2C bus."""
    fake_i2c = FakeI2C(data=[b"\x01\x02"])
    sensor = MySensor(fake_i2c, interval_ms=100)

    result = sensor.read()
    assert result == b"\x01\x02"

Create lightweight fakes for your own interfaces. Use fakes from upstream ChuMicro libraries when available (from chumicro_timing.testing import FakeTicks). unittest.mock is fine when a purpose-built fake doesn't exist or doesn't make sense, but with dependency injection, you'll usually find that a simple fake gives better test control than patching.

Testing submodule

If downstream libraries or users would benefit from test fakes, keep src/chumicro_my_sensor/testing.py and implement real fakes. If there's nothing worth faking, delete it and its references:

  1. Delete src/chumicro_my_sensor/testing.py
  2. Delete docs/testing.md
  3. Remove - Testing Helpers: testing.md from mkdocs.yml
  4. Remove the Testing Helpers link from docs/index.md
  5. Remove the Testing Helpers link from README.md

Functional tests on real boards

Every new library scaffold also includes functional_tests/ for behavior that needs a real board. You do not need to fill this directory immediately, but if the library has timing-, transport-, GPIO-, or storage-sensitive behavior, plan to add real-board tests here once the host/unit tests are stable.

Run them with:

python scripts/run.py setup
python scripts/run.py test-libraries-functional --library my-sensor

See Device Testing for devices.yml, deploy modes, and IDE play-button behavior.

4. Write docs

User guide (docs/guide.md)

Replace the placeholder with a real guide covering:

  1. Overview: what the library does and when to use it
  2. Installation: circup, mip, and pip commands
  3. Quick start: minimal working example
  4. API walkthrough: main classes and functions with examples
  5. Cross-runtime notes: any behavior differences across runtimes
python scripts/run.py docs --libraries my-sensor

API reference (docs/api.md)

mkdocstrings renders API docs from your docstrings. The scaffold starts with a single directive. Add section headings and per-module directives as you add modules. See libraries/timing/docs/api.md for an example.

Every public function, method, and class needs a docstring. Types go on the signature as annotations; docstrings carry descriptions only:

def ticks_diff(end: int, start: int) -> int:
    """Signed difference between two tick values.

    Args:
        end: Later tick value.
        start: Earlier tick value.

    Returns:
        Signed difference in milliseconds.
    """

See the Style Guide for the full format.

5. Write examples

Put examples in libraries/my-sensor/examples/. Rules:

  • Top-level code: no if __name__ == "__main__": guard
  • Descriptive filenames: sensor_basic_reading.py, not example1.py
  • Module docstring with an Example output:: block
  • Self-contained: copy-paste and run
  • Hardware examples: prefix with circuitpython_ or micropython_
python scripts/run.py verify-examples --libraries my-sensor

6. Fill in metadata

Update libraries/my-sensor/pyproject.toml:

  • description: one-line package description
  • dependencies: if your library depends on other ChuMicro libraries (e.g., "chumicro-timing>=0.1")

Update libraries/my-sensor/README.md and replace all TODO placeholders.

The README template now includes a short development/testing section. Keep it accurate for the library you are adding so contributors can see the host-test and device-test entry points without hunting through the repository docs.

7. Preflight and PR

python scripts/run.py preflight

Must print Preflight passed. Then push and open a PR on GitHub targeting main. See Creating a Pull Request for the full walkthrough.

8. After merge

When your PR merges, the VERSION bump triggers an automatic experimental release: your package publishes to PyPI as chumicro-my-sensor-experimental, lands in the experimental bundle repo, and gets experimental docs and a git tag, so users can install it right away. When you're confident it's production-ready, open a Stable Promotion Request and a maintainer promotes it. Releases and Promotion covers the full flow, including the exact install commands and how promotion works.

Checklist

  • python scripts/run.py new-library <name>: scaffold created
  • Implementation in src/chumicro_<name>/
  • Public exports in __init__.py with __all__
  • Tests in tests/: coverage gate passing
  • Testing submodule: kept and implemented, or deleted with all references
  • Examples in examples/: verify-examples passes
  • docs/guide.md: real content, no placeholders
  • README.md: description and API summary filled in
  • pyproject.toml: description and dependencies set
  • Preflight passes
  • PR opened and CI green