Design principles¶
Recurring rules the codebase holds itself to. They are not aspirations — each one is enforced somewhere concrete, and a change that violates one should expect to be challenged in review.
Pydantic at the boundary, plain objects inside¶
External data (lab JSON, settings TOML, environment) is validated exactly
once, by a spec model, at the moment it enters; what circulates inside is
ordinary objects that are never re-validated. Corollary: otto.models stays
a leaf — it must not import the app graph it describes. (Data at the boundary)
Customize behavior in code, shape in data¶
Lab data declares what machines are (addresses, menus, profiles, preferences); code declares what to do with them (products, providers, backends). Products deliberately cannot be declared in lab data — the two evolve on different clocks and are owned by different people. (The host subsystem)
One registry idiom, symmetric registration¶
Every extension seam is a Registry: loud duplicates
with origin attribution, did-you-mean lookups, uniform --list-* support.
Built-ins register through the same public functions third parties use, so
the seams cannot rot unnoticed. (Registries and the pluggable CLI)
Dependencies flow through the context¶
OttoContext carries the per-invocation runtime, and
components that want their dependencies visible take a ctx. The
context-variable behind get_context() is plumbing for
the zero-argument convenience accessors — not license for hidden globals.
(The command lifecycle)
Deterministic lifecycles, no __del__¶
Resources are closed by scopes (HostScope, async with, explicit
close()), never by garbage collection. __del__ was removed deliberately;
teardown must be orderly and observable — containers close before their
parent’s connection, sessions drain before sockets. (The command lifecycle,
Design: Docker container hosts)
The CLI edge is lazy and unbrickable¶
import otto is lazy (PEP 562); otto --help imports zero subcommand
modules; shell completion’s fast path runs zero user code; a malformed
settings.toml or broken init module degrades to a framed warning, never a
traceback before argv parsing. A deterministic import-budget guard in the
test suite keeps startup cost from regressing. (Registries and the pluggable CLI,
The command lifecycle)
Fail loud, fail framed¶
No silent fallbacks: unknown registry names error with suggestions rather than guessing; coverage stops on counter/build mismatch with the fix in the message rather than emitting a plausible-but-wrong report; an unreachable host is an error naming the host, not a skipped test. Graceful degradation is reserved for listing paths (help, completion); dispatch fails loud.
Logging: most-restrictive wins, and only for command I/O¶
Sensitivity composes by max: if either the host or the call says QUIET
or NEVER, the stricter mode holds. And LogMode
gates command echo/output only — never warnings or errors, so silencing
chatter can’t hide failures. (Logging)
One event loop, stateless strategies¶
Concurrency is asyncio only — no thread pools mixed into the loop; fan-out
uses oneshot/gather with per-host error isolation. Strategy objects
(command frames, binary loaders, filesystems) are small stateless values a
session or host holds, keeping them unit-testable without live hardware.
(The host subsystem)
Speak existing conventions¶
Where an established tool has trained users, otto follows: exit codes are
ssh-like (255 = never ran, otherwise the shell’s retcode); test semantics
are pytest’s, not a reinvention; suites and instructions share option
classes; JSON/TOML field names are snake_case, matching the Python they
become. (Results and exit codes, otto test — the test pipeline)
Documentation is part of the change¶
Public behavior ships with docs and doctests: pure functions carry >>>
examples that run in CI, markdown examples run through the Sphinx doctest
builder, and the nitpicky, warnings-as-errors docs build means a renamed
class breaks the build rather than the reader. (See
Contributing.)