User Guide¶
chumicro-deploy writes Python code onto CircuitPython and MicroPython boards from your laptop, then helps you recover when something goes wrong.
This guide covers Device, Deployer.deploy_diff(), the three file sources, probing, firmware URL resolution, and end-to-end flashing with flash_firmware. It ends with RecoveringDeployer, the wrapper that names the transport failure you hit (unplugged cable, ejected drive, stuck REPL) and walks you through a retry.
Install¶
Host-only. No bundle registration or device-side install needed. After install, a chumicro-deploy console script is on your PATH. See the CLI section at the end for the quick-invocation shortcuts.
Command-line interface¶
Four subcommands cover the one-off jobs you'd otherwise write a script for: probe, deploy, flash-firmware, and resolve-firmware-url.
# Probe a connected board.
chumicro-deploy probe --transport micropython --address /dev/cu.usbmodem213101
# Look up a firmware URL.
chumicro-deploy resolve-firmware-url \
--board-id raspberry_pi_pico_w --runtime circuitpython --version 10.1.4
# Flash a Pi Pico W (UF2 path, programmatic bootloader entry).
# --method is inferred from the .uf2 extension.
chumicro-deploy flash-firmware \
--transport circuitpython --address /dev/cu.usbmodem11401 \
--url https://downloads.circuitpython.org/bin/raspberry_pi_pico_w/en_US/adafruit-circuitpython-raspberry_pi_pico_w-en_US-10.1.4.uf2
# Flash a Lolin S2 Mini running MicroPython (esptool path, offset 0x1000).
# --method is inferred from the .bin extension. erase-flash runs by
# default; pass --no-erase to preserve user data on an in-place upgrade.
chumicro-deploy flash-firmware \
--transport micropython --address /dev/cu.usbmodem211101 \
--url https://micropython.org/resources/firmware/LOLIN_S2_MINI-20260406-v1.28.0.bin \
--offset 0x1000
# Deploy a directory of Python files and run the entrypoint.
chumicro-deploy deploy \
--transport circuitpython --address /dev/cu.usbmodem11401 \
--deploy-mode flash \
--directory ./my_app --entrypoint /code.py
All subcommands accept --help for their full option list. Both chumicro-deploy deploy and chumicro-deploy flash-firmware support --non-interactive. Without that flag, deploy wraps every run in RecoveringDeployer with prompt=input (see Recover from deploy failures below) so transport failures are classified and coached instead of producing a raw traceback. Pass --non-interactive from CI / scripted flows that don't have stdin to answer retry prompts. That builds the same wrapper with prompt=None, which reports once and re-raises.
main() catches the documented exception types (transport errors, FlashFirmwareError, UnresolvedFirmwareError, DeviceConfigError, FileNotFoundError, ValueError) and prints error: <message> on stderr with exit code 1. Anything else propagates as a Python traceback; those are bugs, not user-facing failures.
The three board-facing subcommands (probe, deploy, flash-firmware) also accept --devices-file devices.yml --device <id> instead of --transport + --address, so a workspace with one source-of-truth devices.yml doesn't repeat the same connection details everywhere. resolve-firmware-url needs no board, so it takes neither:
chumicro-deploy probe --devices-file devices.yml --device back-porch
chumicro-deploy deploy --devices-file devices.yml --device back-porch \
--directory ./my_app --entrypoint /code.py
When defaults: in the file pins a single runtime, omitting --device lets the loader pick that default. The schema lives at chumicro_deploy.config.default.load_devices_yml.
Programmatic devices.yml¶
The same loader is exposed as a Python function so scripts and template repos don't have to shell out:
from chumicro_deploy.config.default import load_devices_yml
# Specific entry by id.
device = load_devices_yml("devices.yml", device_id="back-porch")
# Workspace's CircuitPython default, useful when devices.yml has both
# defaults.circuitpython and defaults.micropython set.
device = load_devices_yml("devices.yml", runtime="circuitpython")
# Single-runtime workspaces need neither flag; the loader picks the
# only configured default.
device = load_devices_yml("devices.yml")
device_id and runtime are mutually exclusive: pass one or neither, never both. The same shape ships as a CLI loader (--devices-format default), and third parties register their own config formats via the chumicro_deploy.config_loaders entry-point group.
Configure a target: Device¶
A Device bundles the identity and connection details of one board. It is a frozen dataclass: you construct it explicitly and hand it to the deployer.
from chumicro_deploy import Device
device = Device(
transport="micropython", # or "circuitpython"
address="/dev/cu.usbmodem14101", # serial port path
baudrate=115200, # CircuitPython only (MP uses mpremote defaults)
deploy_mode="ram", # "ram" or "flash"
entrypoint_name=None, # runtime default: code.py on CP, main.py on MP
resource_prefix="/lib", # on-device dir for non-entrypoint files
)
Validation runs in __post_init__:
transportmust be"circuitpython"or"micropython".deploy_modemust be"ram"or"flash".
Deploy modes¶
| Runtime | "ram" |
"flash" |
|---|---|---|
| MicroPython | mpremote mount: stages host dir, mounts at /remote on device, runs from the mount |
mpremote copy: copies to device flash, then execs |
| CircuitPython | inline raw-REPL exec: every .py in files is injected into sys.modules via the class-as-module pattern, then the entrypoint runs as __main__ |
write to CIRCUITPY drive, soft-reboot, capture output |
Deployer.deploy_diff() supports both modes on both runtimes. CP RAM mode does not require a mounted CIRCUITPY drive, since it deploys purely over the serial raw REPL. That makes it the fastest option for a dev loop where the board is reachable over USB but you do not want to wait for the flash round-trip. The tradeoff is that RAM mode cannot ship non-.py assets (TOML config, JSON data, images), because it has no device filesystem to write to. Use flash mode if the payload needs those.
Pick a FileSource¶
FileSource is a Protocol: anything that implements .files() -> dict[str, bytes] and .entrypoint() -> str works. Three built-ins cover the common cases.
FileMapSource: in-memory dict¶
from chumicro_deploy import FileMapSource
source = FileMapSource(
{
"/main.py": "from greeter import greet\nprint(greet('world'))\n",
"/lib/greeter.py": "def greet(name): return f'hi {name}'\n",
},
entrypoint="/main.py",
)
Values can be str (encoded as UTF-8) or raw bytes. The entrypoint must be a key in the dict.
DirectorySource: ship a directory tree¶
from pathlib import Path
from chumicro_deploy import DirectorySource
source = DirectorySource(
Path("my_app"),
entrypoint="/main.py",
resource_prefix="/", # default
excluded_names=None, # defaults: __pycache__, .DS_Store, .git, .pytest_cache, .mypy_cache
)
The entrypoint is the on-device path. /main.py means your my_app/main.py will land at /main.py on the board.
ImportGraphSource: walk Python imports¶
from pathlib import Path
from chumicro_deploy import ImportGraphSource
source = ImportGraphSource(
entrypoint=Path("my_app/main.py"),
search_paths=[Path("my_app"), Path("packages")],
extra_modules=["dynamically_imported_thing"],
device_entrypoint="/code.py", # default
resource_prefix="/lib", # default
)
AST walks the entrypoint, resolves each static import / from ... import against search_paths, and ships only the modules that resolve.
An import that resolves to nothing is not skipped quietly. Names on the device-built-in allowlist (gc, time, board, and the rest of chumicro_deploy.import_allowlist.DEVICE_BUILTIN_MODULES) are dropped, because the board supplies them. Any other unresolved import raises UnresolvedImportError from the constructor, naming the file that imports it, so a typo or a missing search path fails on your laptop instead of as an ImportError on the board. An import guarded by try / except ImportError counts as optional and never refuses the deploy.
Dynamic imports (importlib.import_module, __import__) are invisible to AST walking. Pass those names explicitly via extra_modules.
Bring your own¶
Any object satisfying the FileSource protocol works. isinstance(your_source, FileSource) returns True as long as .files() and .entrypoint() are defined. It's a @runtime_checkable Protocol, so no inheritance is required.
Deploy: Deployer.deploy_diff()¶
from chumicro_deploy import Deployer
deployer = Deployer(device)
def on_progress(fraction: float, message: str) -> None:
print(f"[{fraction:3.0%}] {message}")
def on_file_staged(device_path: str) -> None:
print(f" staged {device_path}")
def on_file_deleted(device_path: str) -> None:
print(f" removed stale {device_path}")
def on_execute_line(line: str) -> None:
print(f" > {line}")
result = deployer.deploy_diff(
source,
on_progress=on_progress,
on_file_staged=on_file_staged,
on_file_deleted=on_file_deleted,
on_execute_line=on_execute_line,
)
if result.success:
print("deploy ok")
print(result.execute_output)
else:
print(f"deploy failed, traceback:\n{result.traceback}")
deploy_diff() is the one stage primitive: it lists the on-device files in scope, deletes the stale set (anything not in the new payload), then stages and runs the entrypoint. It is clean-slate by default (clean=True): the board is reconciled to exactly the payload plus the closed device keep set (boot.py, boot_out.txt, _chu_kv.msgpack), and a board-resident settings.toml is evicted. Pass clean=False for the additive opt-out (reconcile only the entrypoint/state files + /lib, leave other board files), or wipe=True for a full filesystem erase (keep set included) before staging.
The lifecycle is create_transport() -> connect() -> list_files_in_scope() -> delete_files(stale) -> transport.deploy_files() -> disconnect() (a RAM-mode deploy lists nothing and collapses to a plain stage; a wipe=True deploy replaces the list/delete step with wipe_filesystem()). The transport is released even when deploy_files() raises.
DeployResult:
success: boolisTruewhen no traceback was detected in the execute output.staged_files: list[str]holds the on-device paths that were written, sorted.execute_output: stris the combined stdout captured from the board.traceback: str | Noneis the last traceback block extracted fromexecute_output, orNone.
Callbacks are all optional. on_progress emits coarse milestones (0.0 connecting, 0.1 listing in-scope, 0.2 cleaning stale (only when there is a stale set), 0.3 staging, 0.9 executing, 1.0 done). on_file_staged, on_file_deleted, and on_execute_line are forwarded to the transport / diff step; the real transports emit them after the fact rather than live-streaming.
Recover from deploy failures: RecoveringDeployer¶
Deployer.deploy_diff() raises transport errors directly, since it's the deterministic programmatic surface that automation pipelines depend on. For interactive use, RecoveringDeployer wraps a Deployer with classification and user-facing coaching on failure, and optionally an Enter-to-retry loop. Two modes selected by the prompt argument:
prompt=None(the default, for CI and scripted flows): runs once, prints the classified failure + ordered fix steps on a transport error, then re-raises.prompt=input(or any(str) -> strcallable, for interactive flows): runs up tomax_attemptstimes, asks the user between attempts. Any reply starting withq,a, oreaborts.
The chumicro-deploy deploy CLI builds the wrapper with prompt=input by default; pass --non-interactive to switch to prompt=None. chumicro-workspace deploy / deploy-example / demo do the same. When you call Deployer.deploy_diff() from your own Python code you opt in by constructing the wrapper explicitly:
from chumicro_deploy import Deployer, RecoveringDeployer
runner = RecoveringDeployer(
Deployer(device),
prompt=input, # omit (default None) for one-shot non-interactive coaching
max_attempts=3, # retry ceiling; ignored when prompt is None
)
result = runner.deploy_diff(source)
# clean=False is the additive opt-out; wipe=True is a full erase.
result = runner.deploy_diff(source, wipe=True)
When the underlying Deployer.deploy_diff() raises a CircuitpythonTransportError or MicropythonTransportError, RecoveringDeployer:
- Classifies the error into a
DeployFailureKind: one ofPORT_UNAVAILABLE,RAW_REPL_UNRESPONSIVE,COMMAND_TIMED_OUT,NO_PYTHON_RUNTIME,CIRCUITPY_DRIVE_MISSING,MACOS_FSKIT_WEDGED,FAT_VOLUME_CORRUPT,FLASH_COPY_FAILED,BOOTSTRAP_EXEC_FAILED,INSUFFICIENT_MEMORY,TRACEBACK_RETURNED,CONFIGURATION_ERROR,UNRESOLVED_IMPORT, orUNKNOWN. - Prints a headline, the underlying error, and the canned
RecoveryPlanfor that kind (the physical actions that typically fix it: close the app holding the port, tap RESET, replug USB, switch to flash mode). - With
prompt=input: asks the user to fix the condition and press Enter to retry, up tomax_attemptstimes. Typingq/quit/abort/exitat the prompt stops retrying and re-raises the last error. Withprompt=None: re-raises immediately after printing. - For non-retryable kinds (
COMMAND_TIMED_OUT,NO_PYTHON_RUNTIME,FAT_VOLUME_CORRUPT,INSUFFICIENT_MEMORY,CONFIGURATION_ERROR,UNRESOLVED_IMPORT,TRACEBACK_RETURNED) it prints the coaching once and re-raises without prompting, regardless of mode. A source-level bug can't be fixed by replugging, a too-small board can't grow more RAM by retrying, and a wedged USB link, a missing Python runtime, or a corrupt CIRCUITPY filesystem each need the fix steps applied first (replug,install-firmware,reset-board) before a deploy can get anywhere.
When Deployer.deploy_diff() returns a DeployResult with success=False and a traceback, RecoveringDeployer prints the traceback and a source-fix recovery plan, then returns the unchanged result.
Plug in your own prompt and output¶
Both are injectable for testing and for embedding in a non-stdin environment:
from collections.abc import Callable
def my_prompt(text: str) -> str:
# e.g. feed from a scripted queue in a test, or a TUI dialog.
...
def my_output(line: str) -> None:
# e.g. push to a logging framework or a progress widget.
...
runner = RecoveringDeployer(
Deployer(device),
prompt=my_prompt,
output=my_output,
)
Classify errors directly¶
classify_deploy_failure(exception) is exported so you can build your own orchestrator without using RecoveringDeployer:
from chumicro_deploy import DeployFailureKind, classify_deploy_failure
try:
Deployer(device).deploy_diff(source)
except Exception as error:
kind = classify_deploy_failure(error)
if kind is DeployFailureKind.PORT_UNAVAILABLE:
...
macOS FSKit / DiskArbitration wedge¶
Recent macOS releases replaced the in-kernel msdosfs driver with a user-space FSKit extension. When that extension errors out mid-probe (most often on a small CIRCUITPY FAT12 volume), it can leave diskarbitrationd stuck in an uninterruptible kernel wait, and newly inserted CIRCUITPY drives never appear under /Volumes.
RecoveringDeployer auto-detects this condition. On a CIRCUITPY_DRIVE_MISSING failure it calls detect_fskit_wedge() (from chumicro_deploy.macos_fskit); if the daemon is wedged, it promotes the kind to MACOS_FSKIT_WEDGED and prints a coaching block with the exact recovery command:
sudo killall -9 com.apple.fskit.msdos fskit_helper fskitd fskit_agent diskarbitrationd DiskArbitrationAgent
Each killed daemon respawns under launchd in a clean state. The per-user DiskArbitrationAgent is killed directly rather than bounced via launchctl kickstart, which is SIP-blocked on modern macOS; XPC clients re-trigger its on-demand load despite KeepAlive=false. After the paste, CIRCUITPY drives mount and chumicro-deploy can proceed. Hit Enter at the retry prompt to continue.
Heads-up: on recent macOS the drives may be fully functional (mounted at /Volumes, readable, writable, deployable) but not appear in Finder's Locations sidebar. That's an Apple FSKit-Finder regression unrelated to the deploy. Reach them via Shift+Cmd+C (Computer view) or drag one into the Favorites sidebar section. A reboot clears it.
Detection is non-darwin-safe (returns False immediately on Linux / Windows) and fails open on any subprocess error, so it never blocks a legitimate CIRCUITPY_DRIVE_MISSING retry.
Try it against real boards¶
workbench/deploy/examples/demo_recovery_hand_holding.py walks every configured devices.yml board through each failure scenario and prints the coaching output live. Scenarios today: happy-path baseline, traceback-on-board, physical unplug (PORT_UNAVAILABLE), drive-ejected (CIRCUITPY_DRIVE_MISSING, with MACOS_FSKIT_WEDGED promotion when the wedge is live), oversized-payload (FLASH_COPY_FAILED), and silent bootloader-reset verification. Run it when you want to see what the CLI actually says to the user on a real cable-out / drive-ejected / board-rebooted failure.
For shorter end-to-end examples that exercise each built-in FileSource against a plugged-in board, see also:
programmatic_deploy.py:DirectorySourcewalking a local dir.file_map_deploy.py:FileMapSourcefor an in-memory multi-file payload.import_graph_deploy.py:ImportGraphSourceAST-walking from an entrypoint, shipping only reachable modules.
Probe a board: probe_device¶
from chumicro_deploy import probe_device
info = probe_device(device)
if info.implementation:
print(f"{info.implementation.name} {info.implementation.version}")
print(f"machine: {info.implementation.machine}")
else:
print("probe did not return a marker; firmware may not support sys.implementation")
DeviceInfo carries implementation (name / version / machine) and a uid field filled from the same probe (microcontroller.cpu.uid on CircuitPython, machine.unique_id() on MicroPython). Its third field, board_id, is an empty string: probe_device does not set it, so anything that needs a board ID reads it from the devices.yml entry's hardware.board_id instead.
Resolve firmware URLs: resolve_firmware_url¶
from chumicro_deploy import resolve_firmware_url
cp_url = resolve_firmware_url(
board_id="raspberry_pi_pico_w",
runtime="circuitpython",
version="10.1.4",
)
# https://downloads.circuitpython.org/bin/raspberry_pi_pico_w/en_US/adafruit-circuitpython-raspberry_pi_pico_w-en_US-10.1.4.uf2
resolve_firmware_url is a pure formatter: it builds a CircuitPython URL from the board ID and version you pass, and makes no network call. MicroPython URLs embed a per-build date that cannot be inferred from the version alone, so calling with runtime="micropython" raises UnresolvedFirmwareError.
For MicroPython, and for "give me the latest build" on either runtime, use chumicro_deploy.firmware_url.derive_firmware_url(device_entry). It takes a devices.yml device dict and reads the listing pages: hardware.firmware_source wins if set, CircuitPython entries resolve hardware.board_id against the Adafruit S3 bucket, and MicroPython entries map hardware.machine to a board name and scrape that board's micropython.org download page. chumicro-workspace install-firmware uses this path when you omit --url.
Flash firmware: flash_firmware¶
flash_firmware downloads a firmware image and writes it to a connected board. Destructive: overwrites whatever firmware is currently installed. Two reflash backends:
uf2covers RP2040 / RP2350 (Pi Pico family) and any board shipping TinyUF2. Uses the UF2 bootloader drive; requires a.uf2URL. Programmatic bootloader entry works on CircuitPython and on MicroPython ports that implementmachine.bootloader().esptoolcovers the ESP32 family (ESP32, S2, S3, C3, C6) regardless of runtime. Shells out toesptoolover serial; requires a.binURL.
from chumicro_deploy import Device, flash_firmware, resolve_firmware_url
device = Device(
transport="circuitpython",
address="/dev/cu.usbmodem11401",
)
# UF2 path: Pi Pico W to a specific CircuitPython build.
# reflash_method=None (the default) infers from the .uf2 extension.
url = resolve_firmware_url(
board_id="raspberry_pi_pico_w",
runtime="circuitpython",
version="10.1.4",
)
flash_firmware(url, device)
# esptool path: Lolin S2 Mini with MicroPython, offset 0x1000.
# erase_flash=True is the default; pass False to preserve user data.
flash_firmware(
"https://micropython.org/resources/firmware/LOLIN_S2_MINI-20260406-v1.28.0.bin",
Device(transport="micropython", address="/dev/cu.usbmodem211101"),
flash_offset="0x1000",
)
Method selection notes:
reflash_method=None(default) infers the method from the URL extension:.uf2→"uf2",.bin→"esptool". Pass explicitly for URLs that don't carry an extension (e.g. signed CDN URLs with a query-string-only filename).- CircuitPython
.binimages use offset"0x0"(the default). MicroPython ESP32 / S2 / S3.binimages need"0x1000". Using the wrong offset bricks the bootloader region and requires a manual BOOT + RESET hold to recover, andchumicro-deploycannot auto-detect which ecosystem a.bincame from. - Pass
interactive=Falsein automated flows without stdin. When programmatic bootloader entry fails, the default is to prompt the user to hold BOOTSEL / GPIO0;interactive=FalseraisesFlashFirmwareErrorinstead. erase_flash=True(the default for the esptool path) wipes every user partition (CIRCUITPY drive, stored WiFi credentials, NVS) so a fresh reflash doesn't inherit leftover sectors from a previous build. PassFalseto preserve user data on an in-place upgrade.on_progresstakes an optional(fraction, message)callback for UI integration; the CLI wires it to a stderr progress line.
Tail the board with chumicro-repl¶
Deployer.deploy_diff() returns once the entrypoint executes; if the entrypoint then enters a long-running loop (a heartbeat, a sensor publisher, a server) the deploy is "done" but the interesting output is just starting. chumicro-repl is the sister workbench tool for this. It streams the friendly REPL with traceback highlighting and exposes a tail() follow-mode that fails fast on a crash:
from chumicro_deploy import Deployer
from chumicro_repl import tail, ExitCode
result = Deployer(device).deploy_diff(source)
if not result.success:
raise SystemExit(f"deploy failed:\n{result.traceback}")
# Watch for ten seconds, return non-zero if the board crashes.
follow = tail(device, seconds=10.0, fail_on_traceback=True)
if follow is ExitCode.TRACEBACK_DETECTED:
raise SystemExit("board crashed during follow-up tail")
chumicro-repl reuses the same Device object, the same devices.yml schema, and the same pyserial transport, so a deploy → tail pipeline never repeats connection details.
Its CLI takes a port, not a device id: chumicro-repl --address /dev/cu.usbmodem14101. To open a board by the name it carries in devices.yml, go through the workspace CLI instead:
Either way the session defaults to --mode auto, which picks the host-side line editor (persistent history, cursor editing, Ctrl-R reverse search) when stdin is a TTY, and byte-for-byte passthrough when stdin is piped. Pass --mode passthrough explicitly for the mpremote-style behavior that raw-REPL framing and paste mode need.
For headless tests, ReplSession(device) exposes exec(code) / call(function_name, *args, **kwargs) / read_until(pattern, timeout) over raw REPL.
Host platform requirements¶
chumicro-deploy runs on macOS and Linux today. Two host prerequisites are surfaced as explicit exceptions when missing, so failures land before the serial port opens:
WindowsNotSupportedErroris raised fromDeployer.__init__andprobe_devicewhensys.platform == "win32". Windows is not supported; runchumicro-deployfrom WSL2 against a USB-passed-through device instead.RsyncMissingErroris raised before a flash-mode CircuitPython deploy whenrsyncis not on$PATH. CIRCUITPY drive synchronization needsrsyncfor atomic, deterministic file updates; the error message includes a package-manager-specific install hint (brew install rsync/apt install rsync/dnf install rsync) so the fix is one line away.
Both errors live in chumicro_deploy.host_platform and are re-exported at the package top level.
Runtime notes¶
MicroPython sys.path¶
MicroPython does not include /lib on sys.path by default (CircuitPython does). MicropythonTransport.deploy_files auto-inserts /lib (copy mode) or /remote/lib (mount mode) into sys.path before executing the entrypoint. This keeps from my_module import ... working consistently across both runtimes without per-runtime boilerplate in your code.
CircuitPython flash-mode soft-reboot¶
CircuitPython caches the FAT32 filesystem view in-memory. Writing to the CIRCUITPY drive while autoreload is disabled would leave the board reading stale content. CircuitpythonTransport.deploy_files disables autoreload during writes (to prevent mid-deploy resets), then manually soft-reboots the board via Ctrl-B + Ctrl-D. The board picks up the new files on the fresh boot; the code.py output: / Code done running. markers CP emits are used to extract your entrypoint's output from the boot banner.
If your entrypoint is an infinite loop (no return), deploy_files times out at the transport's timeout (default 10 s) and returns whatever was captured up to that point.