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-projectproject_config.tomldeep-merge into/runtime_config.msgpack. Both inputs are gitignored and share a[section]-keyed TOML layout. - Deploy integration: :class:
~chumicro_workspace.deploy_source.WithRuntimeConfigand theproject_*_sourcehelpers compose withchumicro-deploy'sFileSources so a singleDeployer.deploy_diff(...)call ships app code, the merged config, and an optional boot shim in one shot. devices.ymlround-trip: three-zone writer (USER_OWNED / HARDWARE_ONCE / PROBED_ALWAYS), owned bychumicro-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.mainpowers thechumicro-workspaceentry-point and the workspacerun.pyshim.
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_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 |
None
|
Raises:
| Type | Description |
|---|---|
WorkspaceNotFoundError
|
When no |
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:
- Parse the file. Syntax errors fail it.
- Detect whether it's hardware-only via the
__chumicro_runtimes__marker. - 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 |
required |
display_root
|
Path | None
|
Path that relative-display paths are computed
against. Defaults to |
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 |
required |
project_config
|
Path | None
|
Per-project / per-library config file.
|
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_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 |
None
|
Raises:
| Type | Description |
|---|---|
WorkspaceNotFoundError
|
When no |
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 |
required |
project_config
|
Path | None
|
Per-project / per-library config file.
|
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 |
required |
project_config
|
Path | None
|
Path to |
required |
output_path
|
Path
|
Where to write the msgpack file on the host.
Typically |
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 firstsetupfrom the shipped template. The whole file is the device config: nodefaults: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 |
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 |
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: |
required |
output_path
|
Path
|
Where to write the msgpack file on the host.
The on-device deployment lands it at
|
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:
WithRuntimeConfigdecorates any innerFileSource(DirectorySource,FileMapSource,ImportGraphSource, custom) and injects the msgpack at/runtime_config.msgpack. - :func:
project_directory_sourcebuilds aDirectorySourcefromprojects/<name>/(skippingproject_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 |
required |
secrets_toml
|
Path
|
Path to |
required |
project_config
|
Path | None
|
Path to |
required |
output_path
|
Path | None
|
Where to write the msgpack on the host. Defaults
to |
None
|
device_path
|
str
|
On-device path for the msgpack. Defaults to
:data: |
RUNTIME_CONFIG_DEVICE_PATH
|
library_roots
|
tuple[Path, ...] | list[Path] | None
|
Library checkout paths (each a
|
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. |
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 |
required |
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
|
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 isNone. - project_config falls back to :func:
find_project_configunder 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 |
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
|
None
|
workspace
|
WorkspaceLayout | None
|
Resolved :class: |
None
|
secrets_toml
|
Path | None
|
Explicit |
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 |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
Neither secrets_toml nor workspace given,
so there is no |
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
|
|
required |
secrets_toml
|
Path
|
Path to |
required |
entrypoint
|
str
|
On-device entrypoint path. Defaults to
|
'/code.py'
|
resource_prefix
|
str
|
On-device prefix prepended to each app file.
Forwarded to :class: |
'/'
|
extra_excluded
|
Iterable[str]
|
Additional filename / directory names to skip
beyond the defaults (config files, |
()
|
target_runtime
|
str | None
|
Forwarded to :class: |
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_filesreturns the synthesised shim file map for the runtime-matching entrypoint. - :func:
project_boot_sourcewraps the shim plus flat project files in :class:WithRuntimeConfigso the msgpack rides the deploy. - :func:
project_boot_with_import_graph_sourceadds 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'
|
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: |
required |
entrypoint_filename
|
str
|
|
'code.py'
|
secrets_toml
|
Path | None
|
Override |
None
|
extra_excluded
|
Iterable[str]
|
Additional filename / directory names to skip on the project walk. |
()
|
target_runtime
|
str | None
|
When set, |
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: |
required |
workspace
|
WorkspaceLayout
|
Resolved :class: |
required |
entrypoint_filename
|
str
|
Device-side shim entrypoint.
|
'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'
|
workspace_yaml
|
Path | None
|
Override |
None
|
secrets_toml
|
Path | None
|
Override |
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: |
None
|
extra_search_paths
|
list[Path] | None
|
Additional directories prepended to the
workspace-derived search-path tail (after |
None
|
Raises:
| Type | Description |
|---|---|
FileNotFoundError
|
When project_dir contains no recognized config file or project_entrypoint doesn't exist under it. |
WorkspaceConfigError
|
When |
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 |
required |
Raises:
| Type | Description |
|---|---|
WorkspaceConfigError
|
When |
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: |
required |
library_sources_override
|
dict[str, Path] | None
|
|
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
|
|
required |
workspace
|
WorkspaceLayout
|
Resolved :class: |
required |
workspace_yaml
|
Path | None
|
Override the workspace.yml path.
Defaults to |
None
|
secrets_toml
|
Path | None
|
Override the secrets.toml path.
Defaults to |
None
|
entrypoint_filename
|
str
|
Host-side filename of the project's
entrypoint; must exist under project_dir. Defaults to
|
'code.py'
|
device_entrypoint
|
str
|
On-device path the runtime executes
after staging. Defaults to |
'/code.py'
|
resource_prefix
|
str
|
On-device prefix for non-entrypoint module
files. Defaults to |
'/lib'
|
extra_modules
|
list[str] | None
|
Dotted module names to force-include even
when AST can't see them. Forwarded to
:class: |
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: |
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
|
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 |
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,
|
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-deviceregisters cleanly without flashing first. - :attr:
BoardState.UF2_BOOTLOADER: a UF2 drive (a directory withINFO_UF2.TXTat 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 isinstall-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 isinstall-firmware --method esptool, with--erasefor 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 isdiscoveror 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: |
uf2_drive |
Path | None
|
When :attr: |
probe_implementation_name |
str | None
|
When :attr: |
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: |
runtime |
str | None
|
The runtime name reported by the probe, i.e.
|
last_exception |
BaseException | None
|
The last exception encountered during the
search, when no candidate succeeded. |
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 |
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:
- 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. - 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). - 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: |
required |
uf2_search_paths
|
list[Path] | None
|
Override the platform-default UF2 mount
roots. Forwarded to :func: |
None
|
probe_function
|
Callable[[Device], DeviceInfo] | None
|
Inject a probe replacement. Defaults to
:func: |
None
|
drive_scanner
|
Callable[[list[Path] | None], Path | None] | None
|
Inject a UF2 scanner replacement. Defaults
to :func: |
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
|
probe_function
|
Callable[[Device], DeviceInfo] | None
|
Inject a :func: |
None
|
device_factory
|
Callable[[str, str], Device] | None
|
Inject a constructor for
:class: |
None
|
Returns:
| Type | Description |
|---|---|
RuntimeInferenceResult
|
class: |
RuntimeInferenceResult
|
populated on success; on failure both are |
RuntimeInferenceResult
|
|
RuntimeInferenceResult
|
|
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 |
floor |
tuple[int, ...] | None
|
The floor for this runtime (matched lookup), or
|
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
|
required |
check_firmware_supported(implementation)
¶
Classify a probed implementation against the floor.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
implementation
|
DeviceImplementation
|
The probe's :class: |
required |
Returns:
| Name | Type | Description |
|---|---|---|
A |
FirmwareSupportResult
|
class: |
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: |
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 ( |
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.tomlat 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 thequality: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 onworkspace.yml: the per-machine override (the file is gitignored). Any key set here wins over the same key inquality.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 = falseturnspython3 run.py lintinto a no-op with a hint (still discoverable; just doesn't run anything).lint.toolsselects which tools to run. Default runs bothruffandchumicro-checks; drop one to disable that tool without disabling the whole phase. Empty list short-circuits to the same hint asenabled = false.lint.selectis forwarded to ruff as--select <comma list>before any user-supplied passthrough args (so user--overrides win).coverage_thresholdis 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, |
tools |
list[str]
|
Which lint tools to run. Defaults to running both
|
select |
list[str] | None
|
Optional list of ruff rule codes ( |
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 |
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 (timing → chumicro-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. |
required |
package_kind
|
str
|
|
'library'
|
branding
|
ScaffoldBranding
|
Upstream identity stamped into the README banner,
docs footers, |
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 minimalworkspace.yml,secrets.toml, anddevices.ymltrio at tmp_path. - :func:
seed_project: addprojects/<name>/with a config TOML andcode.py/main.pyentrypoints. - :class:
FakePort: pyserialListPortInfoshim withdeviceanddescriptionfields, forlist_ports.comportspatches. - :class:
FakeSubprocessRunner: callable that records everysubprocess.runinvocation and returns canned :class:subprocess.CompletedProcessresults. - :func:
fake_probe_info: object matching the shape :func:chumicro_deploy.probe_devicereturns, 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 returnsreturncodes[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 |
required |
runtime
|
str
|
|
'micropython'
|
device_id
|
str | None
|
Override the registered device id. Defaults to
|
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.