Instructions and suites — the execution pipeline¶
otto run and otto test both dispatch ordinary Python through the same
shape: a registry entry and a synthesized Typer subcommand. An instruction
(@instruction()) is a procedure — one
async function with full lab access, one body, one outcome. A suite
(OttoSuite) is a verdict — many independent
async test methods, run by the runner underneath: stock pytest with an otto
plugin layered on, not a bespoke test framework.
Registration synthesizes the CLI¶
Both paths transform a plain signature into CLI flags with the same
options-to-parameters machinery: a parameter annotated with an options
dataclass has its fields — including inherited ones, which is how
repo-wide RepoOptions bases work (Options classes) —
expanded into individual flags, and the populated instance is reconstructed
at call time. One options hierarchy serves both instructions and suites.
For an instruction, @instruction() stores an entry in the
INSTRUCTIONS registry (Registries and the pluggable CLI) and builds a Typer sub-app around
the function. Besides options expansion, a parameter annotated OttoContext
is stripped from the CLI signature and injected from the active context at
call time — the DI-friendly way for an instruction to reach hosts without
global lookups (The command lifecycle).
For a suite, a class extends OttoSuite with a
Test-prefixed name (matching pytest’s own python_classes = Test*
collection rule), which triggers __init_subclass__ to call
register_suite_class(). Registration does three
things at import time — for repo test files, during bootstrap phase 2
(The command lifecycle):
Reads the suite’s
Optionsclass — any dataclass works; an@optionspydantic dataclass adds validation — and synthesizes a Typer subcommand whose flags mirror its fields, via the options-to-parameters machinery above.Registers the suite in the
SUITESregistry under its class name.Makes re-registration idempotent per source file: pytest re-importing the same file is expected and harmless, while a second suite of the same name from a different file is a loud collision.
Because both live in a registry, tab completion of instruction and suite
names, and --list-instructions / --list-suites, come for free — like
every other registry (Registries and the pluggable CLI). See it captured live in
otto run and otto test.
Handing off to pytest¶
A suite’s synthesized subcommand builds the options instance and calls
run_suite (otto.suite.run.run_suite()), which invokes pytest.main()
scoped to the suite’s source file, with otto’s plugin installed. Conftest
loading is cut at the owning repo’s root (--confcutdir), so the user
repo’s full conftest hierarchy applies while otto’s own never leaks in.
pytest keeps what it is good at — collection, fixtures, parametrize,
markers, reporting — and the plugin (OttoPlugin)
layers on otto’s concerns:
Artifacts — each test gets its own directory under the invocation’s output dir (Logging), exposed to the suite as
testDir.Stability modes —
--iterations/--durationre-run tests via the runtest protocol and aggregate per-test pass rates, reportingUnstablerather than failing on the first flake.Retry —
@pytest.mark.retry(n)re-runs a failing test in place.Monitoring and coverage — test start/end events are stamped onto the monitor timeline, and coverage runs fetch embedded counters after the session (Monitoring — the observation pipeline, Coverage — the collection pipeline).
Selection runs¶
--tests NAME[,NAME…] and -m/--markers also work without naming a
suite: the selection path resolves test names to exact pytest nodeids
(unknown names fail with a did-you-mean), then runs one pytest session per
matching repo — a repo with no match is skipped rather than reported as
“collected 0 items”. --tests/-m live on the parent otto test command,
while a suite name dispatches a distinct synthesized subcommand, so
combining the two is a loud usage error rather than a silent intersection.
This is the deliberate second door into the same pipeline: plain pytest
functions (no OttoSuite at all) are first-class here, which is what the
otto init scaffold demonstrates
(getting started).
--tests tab-completion is fed by two layers. The always-available floor
is a static ast scan of def test_* / Test* methods — instant, never
runs your test code. On top of it sits a pytest-collected set that also
includes dynamically generated tests (pytest_generate_tests, conftest
fixtures) that a source scan can’t see. That set is warmed by any real
collection: an otto test --list-tests run fills it for free, and otherwise
the first --tests TAB spawns a single bounded collection in the background
(a one-time slower TAB — capped, and it falls back to the floor if it can’t
finish in time) and caches the result, so every later TAB is fast and
complete. A test-file edit invalidates the cache via the same fingerprint
the rest of the cache uses, so the collected set never goes stale silently.
The completer itself still never runs user code — the collection happens
in a disposable subprocess, never in the process answering the keystroke.
The behavior this feeds — base-name matching, TestClass::test_name
disambiguation — is documented in otto test.
Non-fatal assertions¶
self.expect(...) records a failed expectation — with the captured source
line and locals — and keeps the test running; the accumulated failures
raise one combined AssertionError at the end
(ExpectCollector). This exists because hardware
tests are expensive to reach: when a board takes minutes to provision, “check
everything, then fail with the full list” beats fail-fast.
Suites vs instructions¶
Both ride the standard invoke preamble unmodified (The command lifecycle);
what differs is the body. An instruction’s body is just the user’s coroutine
on the invocation’s event loop, and its returned Result
(if any) becomes the process exit code (Results and exit codes);
artifacts belong in get_context().output_dir (otto run).
A suite’s body hands off to pytest, as above.
Both are registered callables with option classes; the split is intent.
Instructions (instruction()) are procedures — deploy,
flash, collect — with one body and an exit code from their returned
Result. Suites are verdicts: many independent test
methods, pytest semantics, stability statistics, per-test artifacts. Shared
repo-wide options classes keep the two consistent
(Options classes).
Where the code lives¶
otto.cli.run— the@instructiondecorator, theINSTRUCTIONSregistry, and context injectionotto.suite—OttoSuite, suite registration,run_suite,OttoPlugin, andExpectCollectorotto.result— theResultfamily that becomes an instruction’s exit code