Skip to main content

chumicro-runner

Tick-based scheduling without async. Every state change is one print() away.

Register your services once, call runner.tick() in your main loop, and each one gets a turn each tick. Every networked library in ChuMicro (wifi, sockets, mqtt, requests, http_server, websockets) registers here, so your LED keeps blinking through TLS handshakes (the encrypted-connection setup), slow HTTP responses, and stalled peers because every service gets a fair share of every tick.

Works on CircuitPython, MicroPython, and CPython. Built on chumicro-timing.


Part of the ChuMicro family: small, focused Python libraries for microcontrollers and laptops. Browse all libraries.

Install

# CircuitPython (after `circup bundle-add ChuMicro/ChuMicro-Bundle`)
circup install chumicro_runner

# MicroPython
mpremote mip install github:ChuMicro/ChuMicro-Bundle/chumicro_runner

# CPython
pip install chumicro-runner

For bundle setup, pre-compiled .mpy bundles, the experimental channel, and details on PyPI naming, see the chumicro INSTALL guide.

Quick example

from chumicro_runner import Runner

runner = Runner()
runner.add_periodic(lambda now_ms: print("blink!"), period_ms=500)

while True:
    now_ms = runner.tick()
    runner.wait(now_ms)

tick() fires every due handler. wait() then idles the CPU until the next deadline, or until a registered socket is ready for networked services. Together they give every service a fair share of every tick without burning the loop.

For a bounded run, runner.run_until(predicate, timeout_ms=...) is the one-call form of that while loop. It ticks and idles until predicate() is truthy (returns True) or the timeout elapses (returns False):

handle = runner.add_generator(echo_run(host, port, radio=wifi.adapter.radio))
runner.run_until(lambda: handle.done)

That's all you need for simple tasks. For services with conditional logic (only do something when a condition is met), implement check() and handle():

from chumicro_runner import Runner

class TemperatureSensor:
    """Alert when temperature exceeds a threshold.

    Args:
        threshold: Temperature in °C that triggers an alert.
    """

    def __init__(self, threshold: float = 30.0) -> None:
        self._threshold = threshold
        self._last_reading = 0.0

    def read_temperature(self) -> float:
        """Read from hardware: fast I2C or ADC operation."""
        # On a real board: return self._i2c_device.temperature
        return self._last_reading

    def check(self, now_ms: int) -> bool:
        """Return True when the reading exceeds the threshold.

        Args:
            now_ms: Current tick timestamp (unused here).

        Returns:
            True if the last reading exceeds the threshold.
        """
        self._last_reading = self.read_temperature()
        return self._last_reading > self._threshold

    def handle(self, now_ms: int) -> None:
        """Print an alert with the current reading.

        Args:
            now_ms: Current tick timestamp.
        """
        print(f"ALERT: {self._last_reading}°C exceeds {self._threshold}°C")

runner = Runner()
sensor = TemperatureSensor(threshold=30.0)
runner.add(sensor, period_ms=5000)  # check every 5 seconds


while True:
    now_ms = runner.tick()
    runner.wait(now_ms)

Both loops isolate a handler that raises: the fault is counted in handler_errors and the other services keep ticking, but neither says a word when it happens. A real app should pass on_handler_error(handle, exception) to Runner(...) (see the API table below) so a fire-and-forget service that dies surfaces a line instead of stalling in silence.

What's included

Core

Symbol Description
Runner(ticks=None, poller=None, on_handler_error=None) Tick-based service loop with shared timestamps. A handler that raises is isolated and counted in handler_errors so one faulting service can't stop the others; pass on_handler_error(handle, exception) to log, remove the task, or re-raise to fail fast. poller is an injectable select.poll-shaped object consulted by wait(); the default is built lazily on the first wait that has a socket to register
Runner.add(task, handler=None, period_ms=None, start_after_ms=None, run_count=None) Register a task; returns a TaskHandle
Runner.add_periodic(handler, period_ms, start_after_ms=None, run_count=None) Register a periodic handler; returns a TaskHandle
Runner.add_generator(gen) Register a generator function (for sequential I/O written top-to-bottom); returns a GeneratorHandle. See Generator-driven sequential I/O in the guide
Runner.tick() Capture time, check services, batch-fire handlers; returns now_ms
Runner.wait(now_ms) Idle until the next deadline or a registered socket is ready. Companion to tick(); see Idling between ticks in the guide
TaskHandle Opaque handle for runtime mutation of a registered service
TaskHandle.set_period(period_ms) Add, change, or remove the period (None to remove)
TaskHandle.remove() Remove this service from the runner
TaskHandle.period_ms Current period in milliseconds, or None. Mutate via set_period(), not direct assignment (direct writes skip the timer reset)
TaskHandle.run_count Remaining run count, or None if unlimited. Decremented by the runner after each fire
TaskHandle.active Whether the service is still registered. Set to False by remove()
GeneratorHandle.done True once the generator has returned or been cancelled
GeneratorHandle.cancel() Stop the generator early; fires any finally blocks inside the body

Generator helpers (opt-in sub-module)

chumicro_runner.generators carries the scheduler-side sleep; the completion-wait vocabulary lives in chumicro_timing.waits and the socket-driven helpers in chumicro_sockets.generators (with the raw read/write wait markers in chumicro_sockets.waits). Import explicitly so plain-runner consumers stay free of the load:

Symbol Description
sleep_until(until_ms) Suspend until the absolute tick until_ms; pair with chumicro_timing.ticks_add(ticks_ms(), delay_ms)
Signal (chumicro_timing.waits) One-slot completion token a callback-style service set(value)s; reusable via clear()
wait_for(signal, deadline_ms=...) (chumicro_timing.waits) Suspend until signal is set; return its value, or raise OSError(ETIMEDOUT) past the optional deadline
ReadWait(sock, deadline_ms=None) / WriteWait(sock, deadline_ms=None) (chumicro_sockets.waits) Yieldable poll-interest markers (the canonical wait-protocol home); park until sock is readable / writable, with an optional absolute deadline
connect(connector) (chumicro_sockets.generators) Drive any SocketConnector-shaped object to ready across runner ticks; return the connected socket via PEP 380 (sock = yield from connect(connector))
send_all(sock, data) (chumicro_sockets.generators) Send every byte of data with an EAGAIN-yielding inner loop
recv_until(sock, separator, max_bytes=...) (chumicro_sockets.generators) Read until separator appears, capped at max_bytes (heap-DoS guard)
recv_exact(sock, byte_count, max_bytes=...) (chumicro_sockets.generators) Read exactly byte_count bytes, capped at max_bytes

A Signal bridges callback-land into a generator body: hand signal.set to a service as its callback, then value = yield from wait_for(signal):

link_up = Signal()
wifi.on_state_change(lambda old, new: link_up.set(new))
state = yield from wait_for(link_up)

Testing

Symbol Description
CallRecorder() Callable that records handler invocations for test assertions
CallRecorder.calls Direct access to the list of recorded now_ms values
FakePoller() Host-test stand-in for select.poll().ipoll. Pass as Runner(poller=FakePoller()) so tests can drive wait() without real file descriptors; records register / modify / unregister / ipoll calls for assertion, and set_ready(obj, eventmask) queues a ready pair for the next ipoll return

Where this fits

Depends on chumicro-timing for the tick source. Every networked library in ChuMicro registers here (wifi, sockets, requests, http_server, mqtt, websockets), so the runner lives at the center of any multi-service app.

Platform support

All classes use only basic Python features. Works identically on CPython, MicroPython, and CircuitPython. Designed to be lightweight: uses minimal memory per task, suitable for boards with limited RAM.

Examples

Example What it shows
sensor_threshold.py Object-based check/handle with a temperature sensor
periodic_blink.py Periodic handler with no service class
basic_handler.py Handler-only task (fires every tick)
multi_service.py Multiple services at different rates
runtime_control.py TaskHandle: change period, limit runs, remove at runtime
generator_basic.py Generator-driven service using sleep_until (no hardware)
circuitpython_blink.py LED blink on CircuitPython hardware
circuitpython_button_led.py Button-gated LED on CircuitPython
micropython_blink.py LED blink on MicroPython hardware
micropython_button_led.py Button-gated LED on MicroPython

The full user guide covers registration patterns, generator-driven sequential I/O, idling between ticks, runtime mutation, and testing your components in depth.

Contributing

Issues, bug reports, and pull requests are welcome, and so is "I ran it on this board and here's what happened", some of the most useful feedback a hardware project can get. Development happens in the ChuMicro repository, whose contributing guide covers setup and the test workflow.

Docs

📖 Stable docs · Experimental docs

Find this library

License

MIT

Metadata

Release files for chumicro-runner 0.19.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for chumicro-runner 0.19.2
File Size Uploaded
chumicro_runner-0.19.2.tar.gz 55.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for chumicro-runner 0.19.2
File Interpreter ABI Platform
chumicro_runner-0.19.2-py3-none-any.whl Python 3 none any Details

Total release size: 72.0 kB

Release files / chumicro_runner-0.19.2.tar.gz

Download URL chumicro_runner-0.19.2.tar.gz
Size 55.3 kB
Tags Source
SHA-256 checksum
How to use checksums
c5fc1c1f758e2f39ee80a291bea6884d74914c7b3e88200945b6d56e4cdc4eb2
BLAKE2b-256 checksum
How to use checksums
f9dfbd01fa4b56ced2a3dfebd2a1db0a170221d4537ffa0388fd7ec9efd7adb2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.13

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 7, 2026.

Transparency log

Release files / chumicro_runner-0.19.2-py3-none-any.whl

Download URL chumicro_runner-0.19.2-py3-none-any.whl
Size 16.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6429ce428ec476c7734d1d3761b0e60b9a85b4a0b266a1a22ad517d22198f08f
BLAKE2b-256 checksum
How to use checksums
a37d5ec179abc5e22ec75772b29bc081327a5c5b3430750d9145f4d107f9f14e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.13

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 7, 2026.

Transparency log

Release history Release notifications | RSS feed

0.22.2

2 release files

0.22.1

2 release files

0.22.0

2 release files

This release

0.19.2 This release

2 release files

0.19.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page