Skip to content

API Reference

Auto-generated from docstrings via mkdocstrings. The package root keeps a deliberately narrow surface; everything else is imported from the submodule that owns it, so the sections below are grouped by source file.

devices.yml reading and writing is not here. That schema belongs to chumicro-deploy, whose API reference documents chumicro_deploy.config.devices_yaml along with derive_firmware_url and the transports.

Package root

chumicro_workspace

Host-side runtime for ChuMicro project workspaces.

Combines:

  • Config merge: secrets.toml (workspace-wide credentials and device defaults) and per-project project_config.toml deep-merge into /runtime_config.msgpack. Both inputs are gitignored and share a [section]-keyed TOML layout.
  • Deploy integration: :class:~chumicro_workspace.deploy_source.WithRuntimeConfig and the project_*_source helpers compose with chumicro-deploy's FileSource s so a single Deployer.deploy_diff(...) call ships app code, the merged config, and an optional boot shim in one shot.
  • devices.yml round-trip: three-zone writer (USER_OWNED / HARDWARE_ONCE / PROBED_ALWAYS), owned by chumicro-deploy.
  • Onboarding: board-state detection, firmware URL derivation (CP S3 listing plus MP curated machine-to-BOARD map).
  • Init / update: clone the workspace template repo and re-flow tool-owned files.
  • CLI dispatch: :func:chumicro_workspace.cli.main powers the chumicro-workspace entry-point and the workspace run.py shim.

Package-root surface (__all__ below) is intentionally narrow. It exposes the :class:WorkspaceLayout type and the few helpers sibling packages reach for through the root::

from chumicro_workspace import (
    WorkspaceLayout,             # workspace path resolution + project tree
    compose_runtime_config,      # functional-test config merge
    read_workspace_yml_template, # workspace.yml template content
    read_devices_yml_template,   # devices.yml template content
    verify_examples,             # AST-based example verifier
)

Everything else lives in submodules (chumicro_workspace.deploy_source, chumicro_workspace.pipeline, chumicro_workspace.config_manifest, chumicro_workspace.workspace for :data:ENTRY_POINT_FILENAMES / :class:ProjectClassification, etc.) and stays reachable via explicit submodule imports.

Workbench-only: runs on CPython, never lands on a microcontroller. Workbench tools and the workspace's run.py shim consume this package. The on-device counterpart is chumicro-config.

ProjectClassification

Bases: StrEnum

How a directory under projects/ is treated by the workspace tools.

WorkspaceLayout dataclass

Resolved paths for a single project workspace.

Construct with :meth:from_dir (walks up from a starting directory) or by passing root directly when the location is already known.

Attributes:

Name Type Description
root Path

Directory containing workspace.yml. Every other path is derived from this.

workspace_yaml property

Path to <root>/workspace.yml.

secrets_toml property

Path to <root>/secrets.toml (gitignored device-bound config).

Materialized on first setup from the shipped template (:func:read_secrets_toml_template). Carries wifi credentials, MQTT broker auth, and any other workspace-wide default that flows onto a board through runtime_config.msgpack. May not exist on a fresh workspace before setup runs.

devices_yaml property

Path to <root>/devices.yml. May not exist on a fresh workspace.

projects_dir property

Path to <root>/projects/.

shared_dir property

Path to <root>/shared/ for flat shared modules.

A file shared/foo.py is imported by projects under its bare module name (from foo import bar), never shared.foo: the deploy search path roots at this directory, so its modules resolve as top-level names without any package scaffolding. No tests, no version, no chumicro library shape.

libraries_dir property

Path to <root>/libraries/ for full chumicro-style library trees.

Each entry is a proper chumicro library package with src/<name>/, tests/, optional docs/ and examples/, pyproject.toml, VERSION. Created by chumicro-workspace new --library. Use this when the library is meant to be publishable.

import_graph.build_search_paths includes libraries/<name>/src/ for every entry so projects can import my_lib without a separate :data:library_sources mapping.

packages_dir property

Path to <root>/packages/ (third-party packages, gitignored).

project_dir(name)

Return the directory for the named project (existence not checked).

name may be a single segment ("bedroom_sensor"), slash-form ("upstairs/bedroom_sensor"), or dotted ("upstairs.bedroom_sensor"). Dotted forms are normalized to slash so the Path join lands in the right directory.

list_projects()

Return slash-form paths for every project under projects/, sorted.

Walks the tree recursively per the classifier in :data:ProjectClassification. Each path returned is a deployable leaf. Namespaces, supporting directories, _template / _generated and hidden dirs are filtered out.

Returns an empty list when projects/ doesn't exist yet.

iter_projects_with_classification()

Return every classified directory under projects/, sorted.

Includes both projects and namespaces. Namespaces are needed so the tree renderer can draw branches above leaves and callers that report on supporting branches. Sorting is by slash-form path, which gives natural depth-first display order.

from_dir(start=None) classmethod

Walk up from start until a workspace.yml is found.

The walk lets users run python3 run.py deploy ... from any directory inside the workspace. The root is resolved as the nearest ancestor with a workspace.yml.

Parameters:

Name Type Description Default
start Path | None

Starting directory. Defaults to Path.cwd().

None

Raises:

Type Description
WorkspaceNotFoundError

When no workspace.yml exists in start or any of its parents.

WorkspaceNotFoundError

Bases: FileNotFoundError

Raised when no workspace.yml is found above the start directory.

verify_examples(package_dirs, *, display_root=None)

Verify examples have valid syntax and resolvable imports.

Each package_dirs entry should point at a package directory that contains an examples/ subdirectory (e.g. libraries/timing). For each examples/*.py:

  1. Parse the file. Syntax errors fail it.
  2. Detect whether it's hardware-only via the __chumicro_runtimes__ marker.
  3. Walk imports: every chumicro_* import must resolve. Non-chumicro_ imports must resolve only on non-hardware files. Hardware files skip them so platform built-ins don't fail on the host.

Parameters:

Name Type Description Default
package_dirs list[Path]

Package directories whose examples/ to verify.

required
display_root Path | None

Path that relative-display paths are computed against. Defaults to Path.cwd(). Purely cosmetic, since the verifier walks package_dirs to find files regardless.

None

Returns:

Type Description
int

Exit code (0 for success, 1 for failures).

compose_runtime_config(*, secrets_toml, project_config)

Read sources, deep-merge, flatten to dotted keys, return the dict.

Same flow as build_runtime_config minus the write_runtime_config call, for callers that need the resolved dict in memory rather than an on-disk msgpack. See :mod:chumicro_workspace.merge for precedence rules.

Parameters:

Name Type Description Default
secrets_toml Path

Path to secrets.toml.

required
project_config Path | None

Per-project / per-library config file. None or a missing path means no overrides: the secrets-toml defaults pass through verbatim.

required

Returns:

Type Description
dict

The merged, flattened dict with dotted keys.

Raises:

Type Description
TOMLDecodeError

A TOML source file is malformed.

read_workspace_yml_template()

Return the workspace.yml template content.

Complete commented-example file with schema for library_sources: / deploy_targets: / quality: / environments: blocks.

Workspace layout

chumicro_workspace.workspace

Workspace path resolution and project-tree classification.

:class:WorkspaceLayout resolves the paths under a workspace root (workspace.yml, secrets.toml, devices.yml, projects/, shared/, libraries/, packages/). :meth:WorkspaceLayout.from_dir walks up from a starting directory until it finds a workspace.yml, so users can invoke commands from anywhere inside the tree.

Workspace layout::

<root>/
    workspace.yml          # gitignored defaults + credentials
    devices.yml            # board entries (chumicro_deploy.config.default)
    projects/<...>/<name>/ # one directory per project, optionally nested
    shared/                # shared library code
    packages/              # third-party packages (gitignored)

Directories under projects/ may nest arbitrarily deep (projects/upstairs/bedroom_sensor/, projects/garage/sensors/door_open/). Each is walked and classified:

  • project: leaf containing an entry-point file (app.py / code.py / main.py). Deployable.
  • namespace: recursively contains at least one project or another namespace. Pure organizational structure, not deployed itself.
  • supporting: neither. Silently ignored, so docs/ and design notes can live anywhere in the tree without flagging.

ProjectClassification

Bases: StrEnum

How a directory under projects/ is treated by the workspace tools.

WorkspaceNotFoundError

Bases: FileNotFoundError

Raised when no workspace.yml is found above the start directory.

WorkspaceLayout dataclass

Resolved paths for a single project workspace.

Construct with :meth:from_dir (walks up from a starting directory) or by passing root directly when the location is already known.

Attributes:

Name Type Description
root Path

Directory containing workspace.yml. Every other path is derived from this.

workspace_yaml property

Path to <root>/workspace.yml.

secrets_toml property

Path to <root>/secrets.toml (gitignored device-bound config).

Materialized on first setup from the shipped template (:func:read_secrets_toml_template). Carries wifi credentials, MQTT broker auth, and any other workspace-wide default that flows onto a board through runtime_config.msgpack. May not exist on a fresh workspace before setup runs.

devices_yaml property

Path to <root>/devices.yml. May not exist on a fresh workspace.

projects_dir property

Path to <root>/projects/.

shared_dir property

Path to <root>/shared/ for flat shared modules.

A file shared/foo.py is imported by projects under its bare module name (from foo import bar), never shared.foo: the deploy search path roots at this directory, so its modules resolve as top-level names without any package scaffolding. No tests, no version, no chumicro library shape.

libraries_dir property

Path to <root>/libraries/ for full chumicro-style library trees.

Each entry is a proper chumicro library package with src/<name>/, tests/, optional docs/ and examples/, pyproject.toml, VERSION. Created by chumicro-workspace new --library. Use this when the library is meant to be publishable.

import_graph.build_search_paths includes libraries/<name>/src/ for every entry so projects can import my_lib without a separate :data:library_sources mapping.

packages_dir property

Path to <root>/packages/ (third-party packages, gitignored).

project_dir(name)

Return the directory for the named project (existence not checked).

name may be a single segment ("bedroom_sensor"), slash-form ("upstairs/bedroom_sensor"), or dotted ("upstairs.bedroom_sensor"). Dotted forms are normalized to slash so the Path join lands in the right directory.

list_projects()

Return slash-form paths for every project under projects/, sorted.

Walks the tree recursively per the classifier in :data:ProjectClassification. Each path returned is a deployable leaf. Namespaces, supporting directories, _template / _generated and hidden dirs are filtered out.

Returns an empty list when projects/ doesn't exist yet.

iter_projects_with_classification()

Return every classified directory under projects/, sorted.

Includes both projects and namespaces. Namespaces are needed so the tree renderer can draw branches above leaves and callers that report on supporting branches. Sorting is by slash-form path, which gives natural depth-first display order.

from_dir(start=None) classmethod

Walk up from start until a workspace.yml is found.

The walk lets users run python3 run.py deploy ... from any directory inside the workspace. The root is resolved as the nearest ancestor with a workspace.yml.

Parameters:

Name Type Description Default
start Path | None

Starting directory. Defaults to Path.cwd().

None

Raises:

Type Description
WorkspaceNotFoundError

When no workspace.yml exists in start or any of its parents.

runner_invocation(workspace_root)

Return the command prefix a hint should name for this workspace.

A workspace driven by the template's run.py shim keeps its venv off PATH, so a hint naming chumicro-workspace ... fails with command-not-found when pasted; a standalone install has no run.py and drives the CLI directly. Picks whichever invocation actually resolves: python3 run.py when the shim exists at workspace_root, chumicro-workspace otherwise.

Config merge pipeline

build_runtime_config is the whole flow; the four steps below it are public so callers can compose them directly.

chumicro_workspace.pipeline

End-to-end pipeline that wires the loader, merger, flatten, and writer modules.

build_runtime_config is the convenience the deployer calls per deploy: read secrets.toml plus per-project config, deep-merge in precedence order, flatten nested tables to dotted keys, write msgpack. compose_runtime_config is the same flow without the msgpack write, for callers that need the resolved dict in memory rather than on disk.

Each underlying step is also a public function so callers can compose them directly. See :func:chumicro_workspace.flatten.flatten_config for the dotted-key shape ([wifi] ssid = "x" becomes "wifi.ssid").

compose_runtime_config(*, secrets_toml, project_config)

Read sources, deep-merge, flatten to dotted keys, return the dict.

Same flow as build_runtime_config minus the write_runtime_config call, for callers that need the resolved dict in memory rather than an on-disk msgpack. See :mod:chumicro_workspace.merge for precedence rules.

Parameters:

Name Type Description Default
secrets_toml Path

Path to secrets.toml.

required
project_config Path | None

Per-project / per-library config file. None or a missing path means no overrides: the secrets-toml defaults pass through verbatim.

required

Returns:

Type Description
dict

The merged, flattened dict with dotted keys.

Raises:

Type Description
TOMLDecodeError

A TOML source file is malformed.

build_runtime_config(*, secrets_toml, project_config, output_path)

Read all sources, deep-merge, flatten, write msgpack.

Parameters:

Name Type Description Default
secrets_toml Path

Path to secrets.toml.

required
project_config Path | None

Path to projects/<name>/project_config.toml, or None when no per-project overrides apply.

required
output_path Path

Where to write the msgpack file on the host. Typically projects/<name>/_generated/runtime_config.msgpack.

required

Returns:

Type Description
dict

The fully-merged and flattened dict that was written.

Raises:

Type Description
TOMLDecodeError

A TOML source file is malformed.

chumicro_workspace.loaders

Host-side file readers for the runtime-config pipeline.

Two input shapes, both TOML:

  • secrets.toml: workspace-wide credentials and device defaults. Gitignored, materialized on first setup from the shipped template. The whole file is the device config: no defaults: wrapper, no other top-level blocks. Keys are nested TOML tables ([wifi] ssid = "x"). Compose-time flattening produces the wire shape the on-device reader consumes.
  • projects/<name>/project_config.toml: per-project knobs that override the workspace defaults at deploy time.

workspace.yml is the workspace machinery file (library_sources, deploy_targets, quality, environments). That is a separate concern, read by other modules (:mod:chumicro_workspace.import_graph, :mod:chumicro_workspace.deploy_targets). It never flows onto a device, and this module doesn't touch it.

Both readers return plain dicts via stdlib tomllib (CPython 3.11+).

WorkspaceConfigError

Bases: ValueError

Raised by workspace config readers on a top-level shape error.

File-level validation only. Schema-level checks happen at the library boundary when each from_config fires on device.

read_secrets_toml(path)

Read a secrets.toml and return its contents as a nested dict.

Returns an empty dict when the file is empty. TOML stdlib guarantees the top level is a table, so no shape check is needed on this side. Malformed bytes raise :class:tomllib.TOMLDecodeError.

Parameters:

Name Type Description Default
path Path

Path to secrets.toml.

required

Raises:

Type Description
FileNotFoundError

When path does not exist.

TOMLDecodeError

File is malformed TOML.

read_project_config(path)

Read a project's project_config.toml.

Parameters:

Name Type Description Default
path Path

Path to project_config.toml.

required

Raises:

Type Description
FileNotFoundError

path does not exist.

TOMLDecodeError

File is malformed TOML.

chumicro_workspace.merge

Deep per-key merge for the runtime-config pipeline.

secrets.toml defaults + per-project overrides combine key-by-key within sections, project winning on conflict. Sections present only in secrets.toml carry through as workspace-wide defaults. Sections present only in the project carry through as project-specific.

Pure functions, fully deterministic: same inputs always yield the same merged output. No file IO; the loaders module handles that.

merge_configs(*sources)

Deep-merge two or more dicts left-to-right.

Later sources override earlier ones key-by-key. Nested dicts merge recursively; non-dict leaf values are replaced wholesale. Lists do not merge: a later source's list replaces the earlier source's list entirely. Merge is key-level, not element-level.

Parameters:

Name Type Description Default
*sources dict

Two or more dicts to merge. The leftmost is the base, with lowest precedence. Each subsequent source overrides the merged-so-far state.

()

Returns:

Type Description
dict

A new dict with the merged contents. Original sources are

dict

not mutated.

Raises:

Type Description
ValueError

Fewer than one source was provided.

chumicro_workspace.flatten

Compose-time flattening for the runtime-config pipeline.

Nested [wifi] / [mqtt.broker] TOML tables on disk are the beginner-readable shape. The device-side runtime works with a flat dotted-key dict ("wifi.ssid", "mqtt.broker.host"). Flattening happens once at compose time so the on-disk format optimizes for human readability and the wire / device format optimizes for memory on a 256 KB-RAM target: single hash lookup per key, no recursion.

:func:flatten_config is pure, has no I/O, and is deterministic. The flat shape is what writes into runtime_config.msgpack and what on-device readers see directly.

flatten_config(nested, *, _prefix='')

Return a flat dotted-key copy of nested.

Walks nested recursively; each non-dict leaf value lands at "<dotted.path>" in the result. Empty dicts are dropped (no key is emitted for an inner table that has no leaf descendants). Lists, tuples, and scalars are taken verbatim. The per-key value isn't recursed into, only nested dicts are.

Parameters:

Name Type Description Default
nested dict

Source dict to flatten.

required
_prefix str

Internal; used during recursion to accumulate the dotted-path key.

''

Returns:

Type Description
dict[str, Any]

A new flat dict. Original nested is not mutated.

Raises:

Type Description
ValueError

A nested key isn't a string. Runtime config keys must be string-typed for the dotted-path representation to be reversible.

chumicro_workspace.writer

Write the merged runtime config as msgpack at the on-device path.

Device format is msgpack. The on-device path is /runtime_config.msgpack. This module writes the host-side artifact at projects/<name>/_generated/runtime_config.msgpack, which the deployer later overlays onto device flash. The path constant for the on-device location lives in chumicro_config.runtime so the write side and the read side stay in sync.

Wire compatibility with the device-side reader is preserved by passing use_single_float=True so floats encode as float32 (0xca + 4 bytes), which CircuitPython's native msgpack module accepts (CP doesn't support float64). The caller is also expected to keep integers in [-2**31, 2**32-1] and string/bin/array/map sizes under 65 536, matching the chumicro-msgpack 32-bit/16-bit subset that the device-side decoder enforces.

write_runtime_config(merged, output_path)

Write merged as msgpack bytes to output_path.

Creates the parent directory if needed.

Parameters:

Name Type Description Default
merged dict[str, Any]

The merged section-namespaced dict, post :func:merge_configs.

required
output_path Path

Where to write the msgpack file on the host. The on-device deployment lands it at /runtime_config.msgpack.

required

Raises:

Type Description
TypeError

merged contains a value msgpack can't encode (cycles, sets, custom classes).

Deploy sources

chumicro_workspace.deploy_source

FileSource composition that injects the merged runtime config.

Each deploy ships the project's app code alongside the merged /runtime_config.msgpack so users don't have to regenerate the config before deploying. The classes here compose with the FileSource producers in chumicro-deploy so a single Deployer.deploy_diff(source) call ships both.

  • :class:WithRuntimeConfig decorates any inner FileSource (DirectorySource, FileMapSource, ImportGraphSource, custom) and injects the msgpack at /runtime_config.msgpack.
  • :func:project_directory_source builds a DirectorySource from projects/<name>/ (skipping project_config.toml, the _generated/ output directory, and the usual cache artifacts) and wraps it with :class:WithRuntimeConfig. Covers the self-contained project-directory case.

Projects that pull in shared libraries elsewhere in the workspace build the inner source explicitly (ImportGraphSource(...) or a custom FileSource) and wrap it with :class:WithRuntimeConfig directly.

WithRuntimeConfig

FileSource decorator that injects the merged runtime config.

Every call to :meth:files regenerates the msgpack, then merges the inner source's files with {device_path: msgpack_bytes}. The entrypoint is forwarded from the inner source unchanged.

Parameters:

Name Type Description Default
inner FileSource

The base FileSource (typically the project's app code).

required
secrets_toml Path

Path to secrets.toml.

required
project_config Path | None

Path to projects/<name>/project_config.toml, or None when no per-project overrides apply (the merged config is then just the secrets.toml contents). When None, output_path must be supplied, because there is no project file to anchor the _generated/ default to.

required
output_path Path | None

Where to write the msgpack on the host. Defaults to project_config.parent / _generated / runtime_config.msgpack.

None
device_path str

On-device path for the msgpack. Defaults to :data:RUNTIME_CONFIG_DEVICE_PATH.

RUNTIME_CONFIG_DEVICE_PATH
library_roots tuple[Path, ...] | list[Path] | None

Library checkout paths (each a libraries/<name>/ with a pyproject.toml) whose [tool.chumicro.config] manifests should be unioned and validated against the resolved config before the msgpack is written. Missing required keys surface as a :class:ConfigManifestError here rather than as a MissingConfigKey at device boot. None or empty skips validation (for callers that don't plumb the import-graph library list through).

None

Raises:

Type Description
ValueError

If device_path is already a key in the inner source's file map. Indicates the caller is producing two different files for the same on-device location, which the transport would resolve unpredictably.

files()

Regenerate the msgpack and merge it into the inner file map.

entrypoint()

Forward the inner source's entrypoint unchanged.

find_project_config(project_dir)

Return the per-project config path for project_dir.

The project-config filename is project_config.toml.

Parameters:

Name Type Description Default
project_dir Path

Path to projects/<name>/.

required

Raises:

Type Description
FileNotFoundError

project_config.toml does not exist in project_dir.

wrap_with_runtime_config(inner, *, project_dir, search_paths=None, workspace=None, secrets_toml=None, project_config=None, output_path=None)

Wrap inner in :class:WithRuntimeConfig after filling in conventional defaults.

Each front-end (directory source, boot-shim source, import-graph source) calls this once it has built its inner FileSource, so the four conventional paths the wrapper needs are resolved here instead of in each builder.

Defaults applied when the corresponding argument is None:

  • secrets_toml falls back to workspace.secrets_toml; workspace is required when secrets_toml is None.
  • project_config falls back to :func:find_project_config under project_dir.
  • output_path falls back to project_dir / _generated / runtime_config.msgpack (the gitignored build-artifact directory).
  • library_roots (for manifest validation) is derived from search_paths when given (an import-graph front-end) and left empty otherwise (a directory or boot-shim front-end without walked libraries; validation is then off).

Parameters:

Name Type Description Default
inner FileSource

The base FileSource to wrap.

required
project_dir Path

The project (or, for an example, the owning library) directory. Only consulted for the project_config / output_path defaults.

required
search_paths Iterable[Path] | None

Import-graph search paths. When given, each libraries/<name>/ root among them is read for its [tool.chumicro.config] manifest and the merged config is validated before the msgpack is written.

None
workspace WorkspaceLayout | None

Resolved :class:WorkspaceLayout. Used as the secrets_toml fallback when secrets_toml is None.

None
secrets_toml Path | None

Explicit secrets.toml path; overrides the workspace fallback.

None
project_config Path | None

Explicit per-project config path; overrides the project_dir lookup.

None
output_path Path | None

Explicit host path for the generated msgpack; overrides the _generated/ default.

None

Raises:

Type Description
ValueError

Neither secrets_toml nor workspace given, so there is no secrets.toml to resolve.

FileNotFoundError

project_config defaulted and no recognized config file exists under project_dir.

project_directory_source(project_dir, *, secrets_toml, entrypoint='/code.py', resource_prefix='/', extra_excluded=(), target_runtime=None)

Build a deploy-ready FileSource for a typical project directory.

Walks project_dir with :class:chumicro_deploy.DirectorySource, skipping the project's host-side config files and _generated/ output directory, then wraps the result with :class:WithRuntimeConfig so the merged msgpack rides the deploy.

Parameters:

Name Type Description Default
project_dir Path

projects/<name>/ directory.

required
secrets_toml Path

Path to secrets.toml.

required
entrypoint str

On-device entrypoint path. Defaults to "/code.py" (CircuitPython convention). Override to "/main.py" for MicroPython projects.

'/code.py'
resource_prefix str

On-device prefix prepended to each app file. Forwarded to :class:DirectorySource.

'/'
extra_excluded Iterable[str]

Additional filename / directory names to skip beyond the defaults (config files, _generated/, __pycache__/, etc.).

()
target_runtime str | None

Forwarded to :class:DirectorySource so .py files marked for a different runtime via __chumicro_runtimes__ are filtered out before staging. None (the default) ships every file unfiltered; the workspace deploy CLI fills this in from the device's runtime.

None

Raises:

Type Description
FileNotFoundError

When project_dir contains no recognized config file.

NotADirectoryError

When project_dir is not a directory.

ValueError

When the directory walk doesn't include entrypoint.

Boot shim

chumicro_workspace.boot_shim

Boot-shim deploy layout: shim entrypoint + flat project files.

The boot shim is a three-line /code.py (CircuitPython) or /main.py (MicroPython) module synthesised by chumicro-workspace. It runs from app import run; run(), so every project that opts in ships an app.py exporting a synchronous run(). app.py and any helper modules land at the device root, alongside the merged /runtime_config.msgpack. One project per board: switch projects by redeploying.

Three public builders:

  • :func:boot_shim_files returns the synthesised shim file map for the runtime-matching entrypoint.
  • :func:project_boot_source wraps the shim plus flat project files in :class:WithRuntimeConfig so the msgpack rides the deploy.
  • :func:project_boot_with_import_graph_source adds the import-graph contribution for projects that pull in workspace libraries.

Opt-in is automatic when a project ships app.py with a top-level run() and no code.py / main.py; the deploy --boot-shim CLI flag forces it.

project_app_exports_run(project_dir)

Return True when app.py defines a top-level synchronous run.

Only a plain def run(...) qualifies. The shim calls run() synchronously (from app import run; run()), so an async def run is not a usable entrypoint and returns False here. :func:project_app_exports_async_run distinguishes the async case so callers can surface it as a clear failure instead of shipping a board that boots and silently does nothing.

project_app_exports_async_run(project_dir)

Return True when app.py's top-level run is async def.

The boot shim calls run() synchronously, so an async def run would evaluate to a coroutine that is created and immediately discarded. The board boots and does nothing, with no traceback. Detecting that case lets the deploy path surface it as a clear failure instead of letting it through.

source_calls_hard_reset_at_top_level(source)

Line of a module-top-level board-reset call in source, or None.

The closure-scan companion to :func:module_calls_hard_reset: an imported module's top-level code runs at boot exactly like the entrypoint's, so a reset there crash-loops the board just the same. But a reset inside a def is the recommended pattern (called on a deliberate condition), so this walk descends into top-level if / try / loop / with blocks (they run at import) and prunes at any function or class body (those run only when called). Unreadable or unparseable source returns None, because the deploy's own compile step reports those failures with better context.

module_calls_hard_reset(path)

Return the line of a microcontroller.reset() / machine.reset() call in path, or None.

Either call reboots the board. In a shipped boot entrypoint, which the board runs on every boot, a reset reboots the board, which re-runs the entrypoint and resets again: a crash loop that bricks the deploy cycle until the board is wiped. Returns the first such call's line so the deploy path can refuse it. Import aliases are resolved first, so import machine as m; m.reset() and from microcontroller import reset as r; r() are caught alongside the plain microcontroller.reset() / machine.reset() forms. A file that can't be read or parsed returns None.

boot_shim_files(*, entrypoint_filename='code.py')

Return the synthesized shim file map (a single entry).

The shim is a three-line module that imports the project's app.run and calls it. Lands at /<entrypoint_filename> on the device: /code.py for CircuitPython, /main.py for MicroPython. Only the runtime-matching file is synthesized. Both are never shipped speculatively.

Parameters:

Name Type Description Default
entrypoint_filename str

"code.py" for CircuitPython, "main.py" for MicroPython.

'code.py'

Returns:

Type Description
dict[str, bytes]

Path → bytes map ready to merge into a deploy file map,

dict[str, bytes]

a single entry kept dict-shaped to match the merge interface.

project_boot_source(project_dir, *, workspace, entrypoint_filename='code.py', secrets_toml=None, extra_excluded=(), target_runtime=None)

Build a deploy-ready FileSource using the boot-shim layout.

Bundles the synthesized entrypoint shim with the project's own files (at the device root) and the merged runtime-config msgpack (via :class:WithRuntimeConfig).

Parameters:

Name Type Description Default
project_dir Path

Filesystem path to the project directory.

required
workspace WorkspaceLayout

Resolved :class:WorkspaceLayout. Supplies the secrets.toml fallback when secrets_toml is None.

required
entrypoint_filename str

"code.py" for CP, "main.py" for MP. Decides the host-side filename for the shim stub written at the device root.

'code.py'
secrets_toml Path | None

Override secrets.toml path.

None
extra_excluded Iterable[str]

Additional filename / directory names to skip on the project walk.

()
target_runtime str | None

When set, .py files in the project directory whose __chumicro_runtimes__ marker excludes this runtime are dropped. None (the default) ships every file unfiltered; the workspace deploy CLI fills this in from the device's runtime.

None

Raises:

Type Description
FileNotFoundError

When project_dir contains no recognized config file.

project_boot_with_import_graph_source(project_dir, *, workspace, entrypoint_filename='code.py', project_entrypoint='app.py', workspace_yaml=None, secrets_toml=None, extra_excluded=(), target_runtime=None, extra_modules=None, extra_search_paths=None)

Boot-shim layout PLUS import-graph-discovered libraries.

Use when a project authored for the boot-shim convention also needs library code (chumicro / shared / packages / library_sources overrides) shipped to the device. Common case: dev mode with a sibling chumicro/ checkout configured via chumicro-dev.toml. Without this composition, chumicro_* libraries the project imports never reach the board.

Auto-detect picks this layout when a project ships app.py with run() and no code.py / main.py.

Parameters:

Name Type Description Default
project_dir Path

Project directory (same shape as for :func:project_boot_source).

required
workspace WorkspaceLayout

Resolved :class:WorkspaceLayout.

required
entrypoint_filename str

Device-side shim entrypoint. "code.py" (CP) or "main.py" (MP). The shim is written at /<entrypoint_filename> and calls app.run().

'code.py'
project_entrypoint str

Host-side filename inside project_dir that the import-graph walker uses as its starting point. Defaults to "app.py", the boot-shim convention's entrypoint module.

'app.py'
workspace_yaml Path | None

Override workspace.yml path.

None
secrets_toml Path | None

Override secrets.toml path.

None
extra_excluded Iterable[str]

Additional filename / directory names to skip on the project walk.

()
target_runtime str | None

Forwarded to both inner sources so wrong-runtime files are dropped on either side.

None
extra_modules list[str] | None

Dotted module names to force-include even when AST can't see them. Forwarded to :class:chumicro_deploy.ImportGraphSource.

None
extra_search_paths list[Path] | None

Additional directories prepended to the workspace-derived search-path tail (after project_dir).

None

Raises:

Type Description
FileNotFoundError

When project_dir contains no recognized config file or project_entrypoint doesn't exist under it.

WorkspaceConfigError

When workspace.yml's library_sources: block is malformed.

Import graph

chumicro_workspace.import_graph

Build a deploy-ready FileSource for a workspace project by AST-walking imports from its entrypoint.

:func:project_import_graph_source is the one-call factory. It composes a workspace-shaped search path (project dir, shared/, each libraries/<name>/src/, packages/, and any library_sources: overrides from workspace.yml), hands it to :class:chumicro_deploy.ImportGraphSource for the AST walk, and wraps the result with :class:WithRuntimeConfig so the merged runtime-config msgpack rides alongside the resolved app code.

:func:read_library_sources parses the library_sources: map on its own for callers that compose the layers manually (custom search paths, alternate entrypoint). Each entry is an explicit override mapping an import name to a directory tried first during resolution.

read_library_sources(workspace_yaml)

Parse library_sources: out of workspace_yaml.

Returns {package_or_root_name: Path}. The key is what a Python import statement would spell (e.g. chumicro_wifi or my_house_libs), and the value points at a directory whose children are importable under that name.

Returns an empty dict when workspace.yml has no library_sources: block. The no-overrides case is normal, not an error.

Parameters:

Name Type Description Default
workspace_yaml Path

Path to workspace.yml.

required

Raises:

Type Description
WorkspaceConfigError

When library_sources: exists but isn't a mapping, or when any value isn't a string path.

build_search_paths(workspace, *, library_sources_override=None, extra_search_paths=None)

Compose the import-resolution search-path list for workspace.

First match wins inside :class:ImportGraphSource, so order encodes precedence: explicit overrides, then workspace-internal sources, then third-party packages. Pure path arithmetic: the caller passes any library_sources: map already parsed, so this function performs no YAML I/O. Paths that don't exist on disk are dropped, and duplicates collapse.

Resolution order::

1. library_sources_override values
2. workspace/shared/                (user-authored shared modules)
3. workspace/libraries/<name>/src/  (scaffolded chumicro-style
                                     library packages)
4. workspace/packages/              (third-party, gitignored)
5. extra_search_paths               (caller-supplied tail; e.g.
                                     mono-repo dogfooding with the
                                     live chumicro src/)

Parameters:

Name Type Description Default
workspace WorkspaceLayout

Resolved :class:WorkspaceLayout.

required
library_sources_override dict[str, Path] | None

{package_name: Path} already read from workspace.yml's library_sources:, or None to skip override processing.

None
extra_search_paths list[Path] | None

Directories appended at the tail.

None

shared_import_hint(unresolved)

Return the bare-name fix when an unresolved import names shared.

unresolved is the (importing_file, module_name) list an :class:chumicro_deploy.UnresolvedImportError collected before refusing a deploy. When any module_name's top segment is shared (shared or shared.foo), the project spelled a shared/ module as if shared were a package; the search path roots at shared/ instead, so the module resolves by its bare stem. Returns :data:SHARED_BARE_NAME_HINT for that case, or None when no unresolved import names shared (nothing to coach).

project_import_graph_source(project_dir, *, workspace, workspace_yaml=None, secrets_toml=None, entrypoint_filename='code.py', device_entrypoint='/code.py', resource_prefix='/lib', extra_modules=None, extra_search_paths=None, target_runtime=None)

Build a deploy-ready FileSource using AST-walked imports.

Uses :class:chumicro_deploy.ImportGraphSource so only transitively-imported modules ship. This keeps the device payload minimal even when the workspace has many shared libs.

The project's entrypoint file is parsed and every reachable module via shared/ / packages/ / library_sources: lands in the deploy. Modules that don't resolve (gc, time, board, etc.) are silently skipped. They're assumed to be runtime built-ins the host can't ship.

Parameters:

Name Type Description Default
project_dir Path

projects/<name>/ directory. Used as the root of the entrypoint lookup and as a search path so project-local modules under the same dir resolve.

required
workspace WorkspaceLayout

Resolved :class:WorkspaceLayout. Supplies the workspace.yml and secrets.toml fallbacks when workspace_yaml and secrets_toml are not given.

required
workspace_yaml Path | None

Override the workspace.yml path. Defaults to workspace.workspace_yaml.

None
secrets_toml Path | None

Override the secrets.toml path. Defaults to workspace.secrets_toml.

None
entrypoint_filename str

Host-side filename of the project's entrypoint; must exist under project_dir. Defaults to "code.py" (CircuitPython convention). Override to "main.py" for MicroPython.

'code.py'
device_entrypoint str

On-device path the runtime executes after staging. Defaults to "/code.py".

'/code.py'
resource_prefix str

On-device prefix for non-entrypoint module files. Defaults to "/lib", the conventional CP / MP search path.

'/lib'
extra_modules list[str] | None

Dotted module names to force-include even when AST can't see them. Forwarded to :class:ImportGraphSource.

None
extra_search_paths list[Path] | None

Additional directories appended to the workspace-derived search path tail.

None
target_runtime str | None

Forwarded to :class:ImportGraphSource so modules marked for a different runtime via __chumicro_runtimes__ are dropped (and their imports not walked). None (the default) ships every module unfiltered.

None

Raises:

Type Description
FileNotFoundError

When project_dir doesn't contain a recognized config file or entrypoint_filename doesn't exist under it.

WorkspaceConfigError

When workspace.yml's library_sources: block is malformed.

Deploy targets

chumicro_workspace.deploy_targets

Per-project to per-device deploy mapping.

Workspaces with multiple boards register where each project should land in workspace.yml::

deploy_targets:
  garage/door: pi-pico-w-circuitpython-board
  garage/window: [lolin-s2-circuitpython-board]
  garage/server: [pi-pico-w-mp, lolin-s2-mp]

A bare deploy <project> without --device / --runtime / --all-devices then picks the project's first registered target, falling back to the devices.yml defaults block when the project isn't mapped. deploy --all-projects walks the mapping and deploys each project to every device it lists, in declaration order.

Host-only. The on-device boot path knows nothing about which host-side device the bytes arrived from.

read_deploy_targets(workspace_yaml)

Parse deploy_targets: out of workspace_yaml.

Returns {project_slash_path: [device_id, ...]}. Dotted keys (garage.door) normalize to slash form. A scalar string value auto-promotes to a single-element list so one-target projects can stay terse::

garage/door: pi-pico-w-circuitpython-board    # bare string
garage/door: [pi-pico-w-circuitpython-board]  # equivalent

Returns an empty dict when the block is missing or empty.

Parameters:

Name Type Description Default
workspace_yaml Path

Path to workspace.yml.

required

Returns:

Type Description
dict[str, list[str]]

Mapping from project path to a list of device ids.

Raises:

Type Description
WorkspaceConfigError

The top-level YAML isn't a mapping, deploy_targets itself isn't a mapping, a key isn't a string, a value is neither a string nor a list, a list contains a non-string entry, or a list is empty.

Board onboarding

chumicro_workspace.onboarding

Board-state detection + onboarding diagnostics.

Boards arrive in one of four states from the workspace's point of view:

  • :attr:BoardState.REPL_REACHABLE: serial port opens, probe returns a runtime and version. This is the "I can run code on it" state. add-device registers cleanly without flashing first.
  • :attr:BoardState.UF2_BOOTLOADER: a UF2 drive (a directory with INFO_UF2.TXT at its root) is mounted. Pi Pico / Pi Pico W / most SAMD51 / nRF52840 boards land here on a fresh power-up with the BOOTSEL button held. The right next step is install-firmware --method uf2.
  • :attr:BoardState.NO_PROBE_RESPONSE: serial opens but the probe doesn't return. Most often an ESP32 family chip in ROM bootloader with no Python firmware installed yet. The right next step is install-firmware --method esptool, with --erase for a fresh chip.
  • :attr:BoardState.SERIAL_UNREACHABLE: the serial port can't be opened. Cable not plugged, wrong port path, permissions issue. The right next step is discover or replug.

Diagnosis uses chumicro_deploy's probe and a UF2 mount scan. Neither writes to the board. The returned next_steps are English strings the caller prints; nothing in this module shells out, flashes firmware, or reboots a device.

BoardState

Bases: StrEnum

States distinguished by :func:detect_board_state.

OnboardingDiagnosis dataclass

Result of :func:detect_board_state: the diagnosis and next steps.

Attributes:

Name Type Description
state BoardState

Which :class:BoardState the board is in.

uf2_drive Path | None

When :attr:state is :attr:~BoardState.UF2_BOOTLOADER, the path of the mounted drive. None otherwise.

probe_implementation_name str | None

When :attr:state is :attr:~BoardState.REPL_REACHABLE, the firmware identity from the probe ("circuitpython" / "micropython"). None otherwise.

probe_error str

When the probe failed, the str form of the exception that ended it. Useful for showing the user why their board isn't responsive (port-busy, timeout, permission-denied). Empty string when the probe wasn't attempted (e.g. UF2 drive found before probing).

next_steps list[str]

Human-readable suggestions the CLI prints to stderr. Free-form English, not machine-parseable.

RuntimeInferenceResult dataclass

Outcome of :func:probe_with_runtime_inference.

Attributes:

Name Type Description
info DeviceInfo | None

:class:chumicro_deploy.DeviceInfo from the first probe whose implementation field came back populated, or None when every candidate transport failed.

runtime str | None

The runtime name reported by the probe, i.e. info.implementation.name. None when no probe returned a marker. This is the truthful runtime name from sys.implementation, not necessarily the same as the candidate transport that succeeded (an MP transport on a CP board still returns implementation.name == "circuitpython").

last_exception BaseException | None

The last exception encountered during the search, when no candidate succeeded. None on success or when no exceptions were raised (every candidate returned cleanly with no marker).

find_uf2_drive(search_paths=None)

Scan platform-default mount paths for a directory with INFO_UF2.TXT.

INFO_UF2.TXT is the marker the UF2 bootloader writes to its mass-storage drive on every mount, regardless of board family or drive label (RPI-RP2, RPI-RP3, SAMD51, CIRCUITPYUF2, etc.). Scanning for it is the only board-agnostic detection method.

Parameters:

Name Type Description Default
search_paths list[Path] | None

Override platform-default mount roots. Tests inject a temp-dir containing a fixture UF2 drive layout.

None

Returns:

Type Description
Path | None

The first matching drive directory, or None when no UF2

Path | None

drive is currently mounted.

detect_board_state(device, *, uf2_search_paths=None, probe_function=None, drive_scanner=None)

Diagnose what state device's board is in right now.

Order of operations:

  1. Try the probe. If it returns an implementation marker, the board is in :attr:~BoardState.REPL_REACHABLE. No UF2 scan is needed. A running firmware can't also be the bootloader for the same board, and a stray UF2 drive from a different board would be a false positive.
  2. On probe failure, scan for a UF2 drive. If found, the board is in :attr:~BoardState.UF2_BOOTLOADER (or one of the user's other boards is, but the user-facing recommendation is the same: flash firmware before retrying).
  3. Otherwise, the probe error string disambiguates. A "could not open port" type failure maps to :attr:~BoardState.SERIAL_UNREACHABLE. Anything else maps to :attr:~BoardState.NO_PROBE_RESPONSE (port opens but the board doesn't speak Python, typically an ESP32 in ROM bootloader).

Parameters:

Name Type Description Default
device Device

Constructed :class:chumicro_deploy.Device. The transport / address / runtime fields drive the probe; the deploy-mode fields are ignored.

required
uf2_search_paths list[Path] | None

Override the platform-default UF2 mount roots. Forwarded to :func:find_uf2_drive; tests inject a tmp_path-rooted layout.

None
probe_function Callable[[Device], DeviceInfo] | None

Inject a probe replacement. Defaults to :func:chumicro_deploy.probe_device.

None
drive_scanner Callable[[list[Path] | None], Path | None] | None

Inject a UF2 scanner replacement. Defaults to :func:find_uf2_drive.

None

probe_with_runtime_inference(address, *, candidates=DEFAULT_RUNTIME_INFERENCE_ORDER, probe_function=None, device_factory=None)

Probe address without knowing the runtime up-front.

Tries each candidate transport in :data:DEFAULT_RUNTIME_INFERENCE_ORDER until one returns an implementation marker. Returns the :class:RuntimeInferenceResult so the caller can record the probed runtime and version once a candidate succeeds.

Both runtimes speak the same probe script (reads sys.implementation), so the returned implementation name is truthful regardless of which transport delivered it. See :data:DEFAULT_RUNTIME_INFERENCE_ORDER for why both candidates are tried.

Parameters:

Name Type Description Default
address str

Serial port path of the board.

required
candidates Sequence[str]

Runtime names to try, in order. Override for tests or to skip a runtime entirely. Defaults to DEFAULT_RUNTIME_INFERENCE_ORDER.

DEFAULT_RUNTIME_INFERENCE_ORDER
probe_function Callable[[Device], DeviceInfo] | None

Inject a :func:chumicro_deploy.probe_device replacement. Tests pass a fake to avoid hardware.

None
device_factory Callable[[str, str], Device] | None

Inject a constructor for :class:chumicro_deploy.Device. Receives (transport, address) and returns a Device. Tests pass a fake.

None

Returns:

Type Description
RuntimeInferenceResult

class:RuntimeInferenceResult with info and runtime

RuntimeInferenceResult

populated on success; on failure both are None and

RuntimeInferenceResult

last_exception carries the most recent exception (or

RuntimeInferenceResult

None when every candidate completed cleanly without a

RuntimeInferenceResult

marker).

Firmware support windows

chumicro_workspace.firmware_support

Firmware-version floor check for the workspace tool.

Classifies a probed :class:chumicro_deploy.DeviceImplementation against per-runtime minimum versions, parses the dotted-version string the probe returns, and produces human-readable warning text for OLD / UNKNOWN / UNPARSEABLE results. Commands that need the check route through :func:check_firmware_supported and :func:explain so the policy lives in one place.

Checks are warn-only. Callers proceed regardless.

MIN_MICROPYTHON_VERSION = (1, 27, 0) module-attribute

Minimum MicroPython version the workspace tool tests against.

MIN_CIRCUITPYTHON_VERSION = (10, 1, 0) module-attribute

Minimum CircuitPython version the workspace tool tests against.

FirmwareSupportStatus

Bases: StrEnum

Result of checking a probed runtime + version against the floor.

SUPPORTED = 'supported' class-attribute instance-attribute

Probed runtime + version meet the floor.

OLD = 'old' class-attribute instance-attribute

Probed runtime matches CP/MP, version below the floor.

UNKNOWN = 'unknown' class-attribute instance-attribute

Probed runtime name isn't circuitpython / micropython.

UNPARSEABLE = 'unparseable' class-attribute instance-attribute

Probed version string doesn't parse as dotted ints.

FirmwareSupportResult dataclass

Classification result returned by :func:check_firmware_supported.

Carries the status plus the parsed running version and floor so callers can format their own messages without re-doing the parse.

Attributes:

Name Type Description
status FirmwareSupportStatus

The classification.

running_version tuple[int, ...] | None

Parsed dotted version of the firmware on the board, or None if the probe version string didn't parse.

floor tuple[int, ...] | None

The floor for this runtime (matched lookup), or None when the runtime name isn't recognized.

runtime_name str

The probed implementation name, lowered.

parse_version_tuple(version)

Parse a dotted version string into an int tuple.

Takes the leading run of int-parseable dotted components and returns them as a tuple:

  • "10.1.4"(10, 1, 4)
  • "1.26.0"(1, 26, 0)
  • "10.2.0."(10, 2, 0). Trailing dot comes from CP's (10, 2, 0, '') 4-tuple shape on RC builds.
  • "10.2.0.rc.0"(10, 2, 0). Non-int suffix stops parsing.
  • "10.1.0-rc1" / "1.27.0-dev"(10, 1, 0) / (1, 27, 0). Release-suffix stripped before splitting.
  • "" / "abc" / no leading ints → None

Returns None for empty input, no leading int component, or any other parse failure. Callers treat None as :attr:FirmwareSupportStatus.UNPARSEABLE.

Parameters:

Name Type Description Default
version str

Dotted version string from DeviceImplementation.version.

required

check_firmware_supported(implementation)

Classify a probed implementation against the floor.

Parameters:

Name Type Description Default
implementation DeviceImplementation

The probe's :class:DeviceImplementation with name (lowercase runtime name) and version (dotted string from sys.implementation.version).

required

Returns:

Name Type Description
A FirmwareSupportResult

class:FirmwareSupportResult capturing the status, the

FirmwareSupportResult

parsed running version, the per-runtime floor, and the

FirmwareSupportResult

probed runtime name.

explain(result)

Return human-readable lines describing the result.

Empty list for :attr:FirmwareSupportStatus.SUPPORTED (silent on the happy path). Non-empty for OLD / UNKNOWN / UNPARSEABLE, each ending with the install-firmware pointer when applicable.

Parameters:

Name Type Description Default
result FirmwareSupportResult

The :func:check_firmware_supported output.

required

Workspace health

chumicro_workspace.health

Workspace health checks for the status and doctor CLI commands.

Each check runs locally (filesystem read, YAML parse, AST scan, platform probe) and returns a :class:HealthFinding capturing what was inspected, the severity, a one-line summary, and an optional remediation hint. No check talks to a device.

:func:collect_health_findings returns the small set status displays. :func:collect_doctor_findings extends it with stricter checks: Python version, project run() defs, macOS FSKit wedge, held serial ports.

HealthLevel

Bases: Enum

Severity of a single health finding.

HealthFinding dataclass

One row in the status / doctor output.

Attributes:

Name Type Description
label str

Section name ("WORKSPACE.YML", "PROJECTS" …). Rendered in column-1; uppercase by convention.

level HealthLevel

Severity (OK / WARN / ERROR).

message str

One-line summary of what was found. Optional detail can ride along in hint.

hint str | None

Optional remediation pointer with what the user should do next. Status prints it on the line below. Doctor renders it as a bullet under the failure.

check_workspace_yaml(workspace)

Verify workspace.yml parses as the expected shape.

Only confirms the file exists and parses to a mapping. Schema validation for the library_sources: / deploy_targets: / quality: blocks happens lazily inside the modules that consume each one.

check_secrets_toml(workspace)

Verify secrets.toml exists and parses cleanly.

secrets.toml is gitignored and materialized on first setup. Its absence on a fresh clone is a setup-not-yet-run state rather than a configuration error.

check_devices_yaml(workspace)

Count entries in devices.yml (no reachability probe).

count_projects(workspace)

Summarize the projects tree (count + first few names).

collect_health_findings(workspace)

Run every status check and return the findings in display order.

check_python_version()

Verify the host Python is recent enough for the workspace deps.

check_project_run_functions(workspace)

Verify each boot-shim-shaped project's app.py defines run().

Projects without an app.py (the code.py / main.py layouts) are skipped. The run() contract only binds the workspace-runtime boot shim. Projects with a syntax error are counted as missing run() so the user sees the failure here rather than at deploy-time.

check_macos_fskit_wedge()

Flag the macOS FSKit wedge that makes CIRCUITPY mounts unreachable.

On non-macOS the wedge cannot happen. The check returns a "not applicable" OK row without probing. On macOS :func:detect_fskit_wedge runs ps -o state= -p $(pgrep diskarbitrationd) to detect a stuck diskarbitrationd. A wedged daemon surfaces as ERROR with the recovery command in the hint. A healthy daemon surfaces as OK.

Doctor-only.

check_serial_ports_held(workspace)

Flag registered devices whose serial port is held by another process.

Walks devices.yml addresses and runs :func:diagnose_port_holders on each. When a port has at least one holder, surfaces as a WARN finding listing the PIDs and commands of the holders, typically a serial-terminal app the user has open against the board. A held port doesn't imply something is broken (the user may be debugging), but if the next action is a deploy, the deploy will fail with Resource busy until the holder is closed.

Doctor-only.

Returns OK on Windows (no portable lsof equivalent without handle.exe) and when no devices are registered.

collect_doctor_findings(workspace)

Run the strict (status + Python version + AST + FSKit + ports) check set.

Failure hints

chumicro_workspace.recovery

App-level deploy-failure recovery hints.

Pattern-matches a captured Python traceback against known workspace-shaped failure modes (NameError, missing chumicro library, missing config key, RAM-mode runtime_config write) and returns one or more :class:AppErrorHint rows for the CLI to print under the traceback.

Raw Python tracebacks name the stdlib error but not the workspace context behind it, so a missing config key surfaces as a bare KeyError without saying which file should have carried it. Each pattern in :data:_HINT_TABLE translates a generic error into a workspace-aware remediation pointer.

AppErrorHint dataclass

One remediation hint for a pattern matched in a traceback.

Attributes:

Name Type Description
pattern_label str

Stable short identifier for the pattern. Useful for tests and structured logging.

hint str

User-facing remediation text. The CLI prints these verbatim under the traceback, indented one level.

detect_hints(traceback_text)

Return remediation hints matching patterns in traceback_text.

Order matches the table, so earlier patterns fire first. Multiple patterns can match the same traceback, and each independent match becomes its own hint.

Empty input or no match returns an empty list.

format_hints(hints)

Render hints as the block printed under a traceback.

Returns an empty string for an empty list, so an unmatched traceback doesn't get a "--- hints ---" header with nothing under it.

Quality knobs

chumicro_workspace.quality

Workspace quality knobs: lint, coverage.

Two sources, merged per key:

  • quality.toml at the workspace root: the committed policy. Travels with the workspace's git history, so a shared repo carries its own gates. TOML mirror of the block below, without the quality: wrapper:

.. code-block:: toml

  coverage_threshold = 85    # top-level keys go BEFORE any [table]

  [lint]
  enabled = true
  tools = ["ruff", "chumicro-checks"]
  select = ["E", "F", "I"]
  • the quality: block on workspace.yml: the per-machine override (the file is gitignored). Any key set here wins over the same key in quality.toml:

.. code-block:: yaml

  quality:
    lint:
      enabled: true
      tools: ["ruff", "chumicro-checks"]
      select: ["E", "F", "I"]
    coverage_threshold: 85

Reads both, validates each file's shape separately (so an error names the file that carries it), merges, and returns a typed :class:QualityConfig. Validation only. Lint and coverage are run elsewhere.

  • lint.enabled = false turns python3 run.py lint into a no-op with a hint (still discoverable; just doesn't run anything).
  • lint.tools selects which tools to run. Default runs both ruff and chumicro-checks; drop one to disable that tool without disabling the whole phase. Empty list short-circuits to the same hint as enabled = false.
  • lint.select is forwarded to ruff as --select <comma list> before any user-supplied passthrough args (so user -- overrides win).
  • coverage_threshold is forwarded to pytest as --cov-fail-under=<n>.

LintConfig dataclass

Lint-related knobs from workspace.yml's quality.lint.

Attributes:

Name Type Description
enabled bool

When False, python3 run.py lint is a no-op. Defaults to True so a missing block doesn't disable linting silently.

tools list[str]

Which lint tools to run. Defaults to running both ruff and chumicro-checks; drop one to disable that tool without disabling the whole phase. An empty list behaves like enabled = false.

select list[str] | None

Optional list of ruff rule codes (["E", "F", "I"]). None means "use whatever's in pyproject.toml's [tool.ruff.lint] block."

QualityConfig dataclass

Combined workspace quality config.

Attributes:

Name Type Description
lint LintConfig

Lint sub-config. Always present; defaults preserved when the YAML block is absent.

coverage_threshold int | None

Optional --cov-fail-under value; None means "don't enforce a gate from workspace.yml" (pyproject.toml's [tool.coverage.report] fail_under still applies).

load_quality_config(workspace_yaml)

Load + validate the workspace quality config.

Two sources merge, per key: the committed quality.toml next to workspace_yaml carries the policy that travels with the repo, and workspace.yml's quality: block carries per-machine overrides that win over it. When neither exists, the defaults apply (lint enabled, no coverage gate from these files; pyproject.toml's fail_under still holds the floor).

Each source validates separately, so a shape violation raises :class:WorkspaceConfigError naming the file that carries the bad value rather than a vague tool exit code later.

Library scaffolding

chumicro_workspace.scaffold

Create chumicro-style library and workbench package trees.

The output layout::

libraries/<name>/
├── VERSION
├── pyproject.toml
├── mkdocs.yml
├── README.md
├── src/<import_name>/
│   ├── __init__.py
│   ├── core.py
│   └── testing.py
├── tests/
│   ├── conftest.py
│   └── test_<name>.py
├── functional_tests/
│   └── .gitkeep
├── docs/
│   ├── index.md, guide.md, api.md, testing.md
└── examples/
    └── basic_usage.py

Templates ship beside this module under _payloads/library_template/ and travel with the wheel. package_kind="workbench" swaps the four docs/ templates for a workbench-flavored set under _payloads/workbench_template/. Every other file renders from the one shared template set, with the kind steering the fragments that differ: install lines, bundle links, source paths, and the README's platform claim.

python3 run.py new --library <name> is the usual entry point. Callers that need finer control call :func:scaffold_library directly with an explicit target directory.

LibraryAlreadyExistsError

Bases: FileExistsError

Raised when the scaffold target directory already exists.

Carries the path so callers can construct a precise message without re-deriving it.

ScaffoldBranding dataclass

Upstream project identity stamped into a scaffolded package.

A scaffold rendered with CHUMICRO_BRANDING points its README banner, docs footers, [project.urls], and mkdocs repo_url at the ChuMicro repositories, bundle, and docs site where those packages actually live. A scaffold rendered for a downstream chumicro-workspace new --library run uses the default, NEUTRAL_BRANDING: the package owns itself, so the banner, family note, bundle install, project URLs, and docs footers are dropped rather than pointing the package owner at an upstream that isn't theirs.

A None field selects the self-owned rendering for the text derived from it. NEUTRAL_BRANDING leaves every field None; a fully populated instance renders the branded form.

distribution_prefix prepends the family name to the package's distribution and import names (timingchumicro-timing / chumicro_timing). The neutral default is empty: a downstream package is named by its owner, not stamped with someone else's brand.

scaffold_library(target_dir, name, *, package_kind='library', branding=NEUTRAL_BRANDING)

Create a library tree at target_dir / name.

Parameters:

Name Type Description Default
target_dir Path

Parent directory. Created if missing.

required
name str

Library short name (e.g. "gpio"). The branding's distribution_prefix prepends to form the distribution name, and hyphens convert to underscores for the import path (neutral my-projectmy_project; branded timingchumicro-timing / chumicro_timing).

required
package_kind str

"library" (default) for cross-runtime device packages. Produces the standard chumicro library shape with no extras. "workbench" for host-only CPython tools uses a workbench-flavored pyproject template with a [project.scripts] block (CLI entry point), and pulls the four docs/ templates from _payloads/workbench_template/ (no Runner pattern, no Memory notes, no Bundle footer link). The rest of the tree (src/tests/examples/README/mkdocs) renders from the shared templates, with the kind steering the README's install block, "Find this library" rows, family note, and platform claim, plus every source URL's tree.

'library'
branding ScaffoldBranding

Upstream identity stamped into the README banner, docs footers, [project.urls], and mkdocs repo_url. Defaults to NEUTRAL_BRANDING so a downstream scaffold owns itself; the mono-repo's own new-library flow passes CHUMICRO_BRANDING to point its packages at the ChuMicro repos, bundle, and docs site.

NEUTRAL_BRANDING

Returns:

Type Description
Path

Path to the created library directory.

Raises:

Type Description
LibraryAlreadyExistsError

When the target dir already exists. Caller decides whether to delete + retry or bail.

ValueError

When package_kind isn't one of the supported values.

Curated libraries

chumicro_workspace.library

Fetch curated chumicro libraries from a snapshot channel and place them under the workspace's libraries/<name>/.

:func:fetch_library pulls one package; :func:fetch_closure pulls a root plus every chumicro library it transitively imports. Both place each library where the deploy walker treats it like any local checkout, and both raise :class:LibraryFetchError with a :class:LibraryFetchFailureKind value on every failure so callers can coach instead of dumping a traceback.

Placement preserves user work. An existing tree is moved to _library-backups/<name>/<old-version>-<timestamp>/ before a re-fetch, and a tree carrying the .chumicro-local sentinel is left untouched entirely.

The snapshot transport (HTTP, tarball, index parsing) lives in :mod:chumicro_workspace.library_channel.

LibraryFetchFailureKind

Bases: Enum

Closed set of fetch-failure kinds. No string-typed failures.

LibraryFetchError

Bases: RuntimeError

A fetch failed. kind is the machine-readable category.

is_locally_held(workspace_root, package)

Whether libraries/<package>/ carries the user-edit sentinel.

read_installed_version(workspace_root, package)

Return the VERSION of a curated library on disk, or None if absent.

remove_library(workspace_root, package)

Delete a curated library's tree. Returns True if it existed.

fetch_library(package, *, channel=DEFAULT_CHANNEL, version=HEAD, workspace_root, http_get=_real_http_get)

Fetch one package from a channel snapshot into the workspace.

version is :data:HEAD (the channel's latest snapshot) or a pinned snapshot tag. An existing curated copy is backed up, not clobbered. Returns the destination directory; raises :class:LibraryFetchError with a classified kind on any failure.

fetch_closure(root, *, channel=DEFAULT_CHANNEL, version=HEAD, workspace_root, http_get=_real_http_get)

Fetch root and every chumicro library reachable from it.

The snapshot tarball already contains every library in the channel, so one index.json GET and one tarball GET cover the whole closure regardless of size. Each library's direct deps are read from its placed pyproject.toml, and the walk is breadth-first from root via :func:~chumicro_workspace.dep_resolver.transitive_closure (cycle-safe, deterministic order).

Returns the closure as import names in BFS order, root first. Raises :class:LibraryFetchError on the first member that fails to extract; libraries already placed stay on disk and the caller decides whether to roll back.

Test fakes

chumicro_workspace.testing

Test fakes and seeders for chumicro-workspace.

Provides host-side helpers for tests that drive cli.main([...]) end-to-end against a temp directory:

  • :func:seed_workspace: write the minimal workspace.yml, secrets.toml, and devices.yml trio at tmp_path.
  • :func:seed_project: add projects/<name>/ with a config TOML and code.py / main.py entrypoints.
  • :class:FakePort: pyserial ListPortInfo shim with device and description fields, for list_ports.comports patches.
  • :class:FakeSubprocessRunner: callable that records every subprocess.run invocation and returns canned :class:subprocess.CompletedProcess results.
  • :func:fake_probe_info: object matching the shape :func:chumicro_deploy.probe_device returns, with firmware-floor- passing defaults so tests that don't care about the floor don't trip the warning path.

FakePort dataclass

Stand-in for serial.tools.list_ports_common.ListPortInfo.

Two attributes are load-bearing: device (the /dev/cu.* path) and description (the user-facing label). Everything else the real ListPortInfo carries is unused.

FakeSubprocessCall dataclass

One recorded invocation of :class:FakeSubprocessRunner.

cwd property

The cwd= kwarg the call was made with, or None.

FakeSubprocessRunner

Callable stand-in for :func:subprocess.run that records every call.

Each invocation appends a :class:FakeSubprocessCall to :attr:calls and returns a :class:subprocess.CompletedProcess shaped by the constructor kwargs:

  • returncode: single returncode for every call (default 0).
  • returncodes: list of returncodes consumed in order. The first call returns returncodes[0], the second returns returncodes[1], and so on. Falls back to returncode once the list runs out. Use to script "ruff fails, then chumicro-checks succeeds" sequences.
  • stdout / stderr: canned strings copied into every CompletedProcess.

Install via monkeypatch.setattr::

runner = FakeSubprocessRunner()
monkeypatch.setattr(cli.subprocess, "run", runner)
...
assert runner.calls[0].args == [sys.executable, "-m", "pytest"]
assert runner.calls[0].cwd == workspace_root

FakeProbeInfo dataclass

Mimics the shape :func:chumicro_deploy.probe_device returns.

seed_workspace(tmp_path, *, runtime='micropython', device_id=None)

Create a minimal workspace at tmp_path and return the root.

Writes workspace.yml (machinery placeholder), secrets.toml (wifi block with placeholder credentials), and devices.yml with a single device targeting /dev/cu.fake.

Parameters:

Name Type Description Default
tmp_path Path

Directory to populate (typically pytest's tmp_path).

required
runtime str

"micropython" or "circuitpython". Drives the default device_id and the defaults: block in devices.yml.

'micropython'
device_id str | None

Override the registered device id. Defaults to "lolin-s2" for micropython, "pico-w-cp" for circuitpython.

None

seed_project(workspace_root, name='back-porch')

Add projects/<name>/ under workspace_root and return its dir.

Carries both code.py (CircuitPython convention) and main.py (MicroPython convention) so the deploy command's runtime-derived entrypoint resolves cleanly regardless of the test fixture's chosen transport.

name may be slash-form ("upstairs/bedroom_sensor"). The intermediate parent directories are created automatically.

fake_probe_info(*, runtime='micropython', machine='Lolin S2', uid='ABCD1234', board_id='lolin_s2', version='1.27.0', with_implementation=True)

Build a probe-info object for tests of probe-driven commands.

Default version is at the supported floor for micropython so tests that don't care about firmware support don't trip the firmware-floor warning path. Tests that exercise the floor pass a lower or runtime-specific version.

Pass with_implementation=False to simulate a board where the probe couldn't read an implementation marker. The returned object has implementation=None and empty board_id / uid, matching what production code returns in that branch.