Registries and the pluggable CLI¶
Every place otto can be extended — new transfer protocols, host classes,
reservation backends, CLI commands — stores its entries in the same engine:
otto.registry.Registry. One storage idiom buys uniform behavior
everywhere:
Loud duplicates. Registering a taken name raises, and the error names the module that owns the existing entry (every registration records its origin via the call stack). Accidental double-registration cannot silently shadow a backend.
Did-you-mean lookups. An unknown name fails with the closest registered match, the full list of known names, and the exact
register_*call that would add a custom one.Attribution and introspection.
names(),items(), andorigin(name)make--list-*flags and debugging cheap.
Domain modules keep their own public register_* / build_* wrapper
functions; the class is the shared engine behind them.
The registry inventory¶
Registry |
Kind |
Register via |
Built-ins |
|---|---|---|---|
|
top-level CLI command |
the nine first-party commands — see Overview |
|
|
|
— |
|
|
|
|
— |
|
host class |
|
|
|
|
|
|
|
term (connection) backend |
|
|
|
transfer backend |
|
|
|
command frame |
|
|
|
binary loader |
|
|
|
embedded filesystem type |
|
FAT-on-RAM, LittleFS, none |
|
power controller |
|
— |
|
lab repository (host source) |
|
|
|
reservation backend |
|
|
|
monitor parser set |
|
default |
|
SNMP metric descriptor |
|
standard OIDs |
(Product providers are the one seam that is a list, not a named registry — every registered provider runs for every host; see The host subsystem.)
Registration symmetry¶
Built-in backends register through the same public functions third-party
code uses — sftp goes through register_transfer_backend exactly like a
custom protocol would. There is no privileged private path, which keeps the
public seams honest: if a registration API is awkward for otto’s own
built-ins, it is awkward for everyone, and it gets fixed rather than bypassed.
Downstream repos register from their init modules (the init list in
.otto/settings.toml), which bootstrap imports in phase 2
(The command lifecycle).
The CLI command registry¶
The top-level CLI is itself registry-backed. A
CommandSpec describes one command:
name— what the user types (run,flash, …).loader— atyper.Typerapp, a plain or async function, or a lazy string"pkg.mod:attr"that is imported only on dispatch.help— the one-liner forotto --help, rendered without importing the command’s module.lab_free— the command never needs a lab (e.g.schema), so the preamble skips lab loading entirely.output_dir/gate— whether invocations create a per-command output directory and run the reservation gate.
First-party commands in otto/cli/builtin_commands.py and third-party
commands both go through register_cli_command() (or
the cli_command() decorator) — the symmetry rule
again. See Extending the otto CLI for the how-to.
Lazy dispatch¶
The root group resolves commands in two tiers:
Enumeration (
otto --help, completion listings) uses lightweight stubs built from each spec’s stored help line. No subcommand module is imported —otto --helpimports zero subcommand modules.Dispatch resolves the real command — importing the loader’s module, flattening single-command Typer apps, and wrapping leaf callbacks with the invoke preamble — only for the one token actually being executed or completed.
The completion fast path¶
Shell completion must be low-latency and must never traceback into the shell.
Completion invocations first try a cache
(otto/configmodule/completion_cache.py) of command, suite, instruction, and
host names snapshotted on previous runs. On a cache hit, completion runs
zero user code — no bootstrap, no init modules. Cached third-party command
names still appear in listings even though their registrations never ran;
only actually dispatching one triggers the real import. Any discovery failure
in completion mode is swallowed and falls back to the slow path.
The payoff is registry-shaped completion everywhere — captured live from a scaffolded demo repo at docs build time:
More showcases live on the lifecycle pages: suite names and --tests
(otto test — the test pipeline), instruction names (otto run — instructions),
per-class host verbs plus registry-backed option values
(otto host — verbs from methods), and --lab (The command lifecycle).
The consistent rule behind all of them: the process answering the keystroke
never runs user code. Registry names come from the cache the slow path
already wrote; host ids and lab names are read from hosts.json data;
--tests names come from a static ast scan of the test sources. The one
case that genuinely needs a live pytest collection — dynamically generated
tests — is handled without breaking that rule: the collection runs in a
disposable, timeout-bounded subprocess (warmed for free by any real otto test --list-tests, or by a one-time slow first TAB), and its result is cached
under a reserved key so later completions are a plain read. The static scan
stays as the always-available floor, so --tests completion is never empty.