cli.invoke

Shared command-wrapper machinery: OttoContext injection + options expansion.

The plumbing behind both @instruction (otto run subcommands) and @cli_command (top-level commands): a parameter annotated OttoContext is stripped from the CLI signature and supplied at call time from the active context, and an options dataclass parameter is expanded into individual CLI flags. Factored out of cli/run.py so both decorators share one implementation.

otto.cli.invoke.prepare_command_target(func: Callable[[...], Any], options_cls: type | None = None) Callable[[...], Any]

Apply otto’s CLI wrappers to func: OttoContext injection + options expansion.

The shared machinery behind @instruction and @cli_command: a parameter annotated OttoContext is stripped from the CLI signature and injected at call time; an options_cls dataclass parameter is expanded into individual CLI flags.

Idempotent by contract, not coincidence: a callable this function already wrapped is returned unchanged (sentinel attribute). The dispatch path prepares twice — @cli_command at decoration, then resolve_spec_command’s function-loader branch, which serves every function loader and can’t know one was pre-prepared. Without the sentinel that was safe only because _inject_ctx happens to strip the ctx annotation that triggers it.

exception otto.cli.invoke.LabContextError(message: str, exit_code: int, *, rich: bool = True)

Bases: Exception

A lab-context failure carrying its user-facing message + exit code.

ensure_lab_context() raises this instead of printing directly, so a soft probe (try_ensure_lab(), used by the class-scoped otto host menu) can swallow it silently. The loud callers — command_preamble() and the root --show-lab / --list-hosts branch — catch it and print (the rich flag chooses rich.print vs a plain stderr typer.echo) before re-raising typer.Exit with the stored exit_code.

otto.cli.invoke.report_lab_context_error(err: LabContextError) None

Print err’s message the loud way, then raise typer.Exit with its code.

Shared by the loud lab-context callers so the exact user-facing text and exit codes live in one place. Rich messages go through rich.print; non-rich messages (the plain Missing option '--lab' usage error) go to stderr via typer.echo to match click’s own usage-error stream.

class otto.cli.invoke.RootOptions(labs: list[str] | None, xdir: Path, log_days: int, log_level: str, rich_log_file: bool, show_time: bool, dry_run: bool, as_user: str | None, skip_reservation_check: bool)

Bases: object

The root-callback options the preamble needs, stashed on ctx.meta.

The root callback shrinks to recording these; the lab-loading / session-setup work reads them back lazily from ctx.meta at the moment a real (non-help) command invocation begins.

labs : list[str] | None
xdir : Path
log_days : int
log_level : str
rich_log_file : bool
show_time : bool
dry_run : bool
as_user : str | None
skip_reservation_check : bool
otto.cli.invoke.ensure_cli_session(ctx: Context) None

Print the banner and initialise CLI logging once per invocation (idempotent).

Split out of ensure_lab_context() so a soft lab probe (class-scoped otto host menus via try_ensure_lab()) never prints the banner or touches logging. Guarded by ctx.meta['_otto_session_ready'].

otto.cli.invoke.ensure_lab_context(ctx: Context) OttoContext

Load the lab, build reservation state, and install the runtime context (idempotent).

Enforces --lab, builds the lab repository, loads the lab, synthesizes docker placeholder hosts, resolves reservation state (stashed on ctx.meta['otto_reservation']), and installs an OttoContext via set_context. Guarded by ctx.meta['_otto_lab_ready'] so repeated calls are cheap. No banner, no logging init, no output dir — those belong to ensure_cli_session() / command_preamble().

otto.cli.invoke.try_ensure_lab(ctx: Context) OttoContext | None

Soft variant of ensure_lab_context(): return None instead of raising.

Used by HostGroup class-scoping — a soft probe where any failure (no --lab, unknown lab, broken backend) simply means “no class scoping available”, falling back to the full unscoped verb menu.

otto.cli.invoke.fail_loud_on_bootstrap_errors() None

Exit(1) when bootstrap contained any repo error — shared loud gate.

The per-error warning: lines were already printed by entry() at startup; print ONLY the framed summary here (don’t re-print each error in red) — the summary points back at those warnings. Used by the leaf preamble AND the root --show-lab/--list-hosts branch, so anything that inspects the registered world fails the same way.

otto.cli.invoke.command_preamble(ctx: Context) None

Run once when a real (non-help) command invocation starts.

Order: bootstrap errors fail loud → lab-free commands are done → CLI session (banner/logging) → lab context → per-command output dir → reservation gate. --help paths never reach this function: click’s help option exits during leaf parse, before Command.invoke.

otto.cli.invoke.wrap_leaf_callbacks(cmd: Any, spec: CommandSpec) Any

Wrap every leaf command under cmd so its invoke runs the preamble first.

Wrapping Command.invoke (not the callback) means the preamble runs only on real execution: a --help on the leaf exits during parse and never reaches invoke. Groups recurse into their static subcommands AND wrap their get_command so lazily-synthesized subcommands (e.g. the dynamic otto host <verb> commands) are wrapped on resolution too. Already-wrapped commands are skipped (resolution results are cached).

otto.cli.invoke.make_registry_group(child_registry: Registry[Any]) type[TyperGroup]

Build a TyperGroup class whose children come from child_registry.

Children (suite/instruction sub-apps) convert lazily on first access. This follows the same idiom as HostGroup (cli/expose.py): the group only resolves children on demand — it does NOT itself wrap them with the leaf-invoke preamble. main.py’s root dispatch wraps the WHOLE resolved group (and therefore every child it lazily resolves, via wrap_leaf_callbacks’s get_command recursion) with the preamble for the top-level spec ("run" / "test"). This keeps run_app / suite_app usable standalone (e.g. in unit tests that invoke them directly via CliRunner without going through the full otto root app) while otto run smoke / otto test TestX still get the preamble when dispatched for real.