reservations

The reservations package gates every live-lab subcommand on whether the effective user actually holds the resources the selected lab needs. It is pluggable: the check itself is fixed, but the “who has what reserved?” query is answered by a ReservationBackend implementation — shipped ones, or your own class selected by registered name in .otto/settings.toml.

For narrative setup, configuration, and writing a custom backend, see the user guide.

Package summary

Lab reservation / scheduler-check subsystem.

See otto.reservations.protocol for the backend contract and docs/guide/reservations.md for the end-user and implementer docs.

The backend contract

Third-party backends implement the ReservationBackend Protocol. The contract is deliberately small — three read-only methods, no write methods of any kind. Otto never mutates scheduler state.

class otto.reservations.SupportsUsernameCompletion(*args, **kwargs)

Bases: Protocol

Optional capability: enumerate usernames for --as-user completion.

A backend that can list its users implements list_usernames; otto detects it structurally (isinstance(backend, SupportsUsernameCompletion)) and feeds the values into --as-user tab-completion (cached, see otto.configmodule.completion_cache.collect_reservation_usernames). Backends that cannot enumerate users simply omit it.

list_usernames() list[str]

Return all usernames the backend knows about, for completion.

Protocol contract for pluggable lab-reservation backends.

A reservation backend answers two questions for otto:

  • “What resources does user X currently have reserved?”

  • “Who, if anyone, currently holds resource Y?”

Otto is strictly a consumer — it never creates, modifies, or releases reservations. The scheduler (Jira, a booking tool, a shared JSON file, anything) remains authoritative.

Implementers

Third-party backends implement the ReservationBackend protocol, register under a bare name via register_reservation_backend("my-team-jira", MyBackend) from an init module, and are selected in the repo’s .otto/settings.toml:

[reservations]
backend = "my-team-jira"
url = "https://scheduler.example.com"

[reservations.my-team-jira]
api_key_env = "SCHEDULER_API_KEY"

The url key and any [reservations.<name>] sub-table are passed to the backend’s __init__ as keyword arguments. url is optional on both sides: implementers may accept and use it, or hardcode their own endpoint — whichever fits the deployment.

All failure modes that prevent answering a query (network down, database unreachable, credentials rejected, file corrupt) must be raised as otto.reservations.check.ReservationBackendError so the CLI can translate them into a fail-closed startup error with a clear hint about the --skip-reservation-check escape hatch.

class otto.reservations.protocol.ReservationBackend(*args, **kwargs)

Bases: Protocol

Read-only view over a reservation scheduler.

get_reserved_resources(username: str) set[str]

Return the set of resource identifiers currently reserved by username.

Parameters

usernamestr

The reservation-system identity to query. Case sensitivity and any other normalization rules are the backend’s responsibility; otto passes the username through unchanged.

Returns

set[str]

Resource identifiers the user currently holds. Empty set if the user has no active reservations. Resource strings must match byte-for-byte the values in UnixHost.resources and Lab.resources — any necessary normalization is the backend’s job.

Raises

otto.reservations.check.ReservationBackendError

On any failure that prevents a definitive answer (network error, file I/O error, DB error, credential rejection, malformed data).

who_reserved(resource: str) list[str]

Return the usernames currently holding resource.

Used for error messages when a reservation check fails (e.g. "shared-lab is held by alice, bob") so the caller knows who to talk to.

Parameters

resourcestr

Resource identifier to look up.

Returns

list[str]

The usernames holding the resource, in a deterministic order with duplicates removed. An empty list means no one currently holds it (there is no None sentinel — a resource can have any number of concurrent holders).

Raises

otto.reservations.check.ReservationBackendError

On any failure that prevents a definitive answer.

backend_name() str

Return a short human-readable identifier for this backend.

Used in diagnostic output and error messages (e.g. "json", "my-team-jira"). Should be stable across runs.

class otto.reservations.protocol.SupportsUsernameCompletion(*args, **kwargs)

Bases: Protocol

Optional capability: enumerate usernames for --as-user completion.

A backend that can list its users implements list_usernames; otto detects it structurally (isinstance(backend, SupportsUsernameCompletion)) and feeds the values into --as-user tab-completion (cached, see otto.configmodule.completion_cache.collect_reservation_usernames). Backends that cannot enumerate users simply omit it.

list_usernames() list[str]

Return all usernames the backend knows about, for completion.

Exceptions

Two exceptions classify the two failure modes a caller cares about: the user doesn’t hold something versus we couldn’t ask. They are surfaced differently in the CLI — see Skip-flag hint policy below.

exception otto.reservations.check.ReservationBackendError

Bases: Exception

Raised by backends when a query cannot be answered.

Network outages, DB errors, malformed data files, and authentication failures all surface as this exception so the CLI can translate them into a single fail-closed startup error.

exception otto.reservations.check.MissingReservationError

Bases: Exception

Raised when the effective user does not hold every required resource.

The message lists the missing resources and their current holders. It does not mention --skip-reservation-check — that suggestion belongs only in the backend-failure path, never on a legitimate contention failure (or the option gets abused).

The check

Lab reservation check logic and exceptions.

The check_reservations() function is the heart of the subsystem: given a lab, a username, and a backend, it raises MissingReservationError if the user does not hold every resource the lab needs. The error message lists missing resources and their current holders (via who_reserved()) but deliberately does NOT advertise --skip-reservation-check — that flag is surfaced only when the backend itself is unreachable, where proceeding requires it.

gate() is the subcommand-facing entry point that wires the check into the CLI: it reads the per-invocation reservation state from Typer’s ctx.meta["otto_reservation"], honors the top-level skip flag, emits the bold-red skip warning when used, and otherwise runs the check.

class otto.reservations.check.ReservationState(backend: "'ReservationBackend | None'" = None, identity: "'ResolvedIdentity | None'" = None, skip_check: 'bool' = False, backend_factory: "'Callable[[], ReservationBackend] | None'" = None)

Bases: object

backend : ReservationBackend | None = None
identity : ResolvedIdentity | None = None
skip_check : bool = False
backend_factory : Callable[[], ReservationBackend] | None = None
exception otto.reservations.check.ReservationBackendError

Bases: Exception

Raised by backends when a query cannot be answered.

Network outages, DB errors, malformed data files, and authentication failures all surface as this exception so the CLI can translate them into a single fail-closed startup error.

exception otto.reservations.check.MissingReservationError

Bases: Exception

Raised when the effective user does not hold every required resource.

The message lists the missing resources and their current holders. It does not mention --skip-reservation-check — that suggestion belongs only in the backend-failure path, never on a legitimate contention failure (or the option gets abused).

otto.reservations.check.required_resources(lab: Lab) set[str]

Return every resource identifier the lab needs.

The union of the lab’s own resources set and each host’s resources set. Any of these resources that are not held by the effective user will cause check_reservations() to raise.

otto.reservations.check.check_reservations(lab: Lab, username: str, backend: ReservationBackend) None

Raise MissingReservationError if username does not cover lab.

Parameters

labLab

The lab about to be used.

usernamestr

The reservation-system identity to check against.

backendReservationBackend

The configured reservation backend.

Raises

MissingReservationError

If any required resource is not held by username.

ReservationBackendError

If the backend cannot answer the query (network, file, DB failure).

otto.reservations.check.gate(ctx: Context) None

Run the reservation check for this invocation, reading state from ctx.meta.

When -R (skip_check) is set, a loud warning is emitted regardless of whether a backend was configured. No-ops when no reservation state is present (e.g. unit tests invoking a subcommand app directly) or, after the skip-warning path, when no backend is configured. The active lab is fetched lazily so the no-op paths never require an OttoContext.

Skip-flag hint policy

Only ReservationBackendError surfaces a suggestion to pass --skip-reservation-check / -R — because with a broken backend the user has no other way to proceed. MissingReservationError deliberately does not mention the flag, since offering it on every contention failure trains users to reach for the bypass instead of fixing the underlying reservation.

Identity resolution

Effective-user resolution for the reservation check.

Precedence: --as-user USERNAME > getpass.getuser().

There is deliberately no persistent config or environment-variable source. A CLI flag is always visible on the command line that uses it; a sticky env var in shell rc would be invisible and could cause a user to operate under someone else’s identity for weeks without realizing. When --as-user IS used, the top-level Typer callback prints a bold-magenta banner so the override is impossible to miss.

class otto.reservations.identity.ResolvedIdentity(username: str, source: '--as-user' | '$USER')

Bases: object

Effective reservation identity for this otto invocation.

Attributes

usernamestr

The username passed to the backend.

sourceLiteral[”–as-user”, “$USER”]

Where the username came from, used for diagnostic output.

username : str
source : Literal['--as-user', '$USER']
otto.reservations.identity.resolve_username(as_user: str | None) ResolvedIdentity

Resolve the effective reservation identity.

Parameters

as_userstr | None

The value of the top-level --as-user Typer option. None or an empty string means the option was not supplied.

Returns

ResolvedIdentity

(username, source)source="--as-user" when the flag was used, otherwise source="$USER".

Bundled backends

JSON backend

Reference implementation and test double — also a perfectly usable production backend for small teams that don’t have a scheduler yet. See the user guide for the file format.

JSON-file reservation backend — reference implementation and test double.

Intended for two audiences:

  1. Small teams with no scheduler who just want to hand-edit a JSON file checked into the repo (or kept on a shared volume) that lists who holds which resources.

  2. Tests — unit and integration tests construct fixture JSON files and point a JsonReservationBackend at them.

File format (version: 1):

{
  "version": 1,
  "reservations": [
    {"user": "alice", "resources": ["rack3-psu"], "expires": "2026-05-01T00:00:00Z"},
    {"user": "bob",   "resources": ["rack4-psu"]}
  ]
}
  • reservations is a list so one user may appear multiple times (useful when merging reservations from multiple sources).

  • expires is optional; past-dated entries are silently ignored.

class otto.reservations.json_backend.JsonReservationBackend(url: str | None = None, *, path: Path)

Bases: object

Read reservations from a JSON file on disk.

Parameters

urlstr | None

Accepted and ignored. Kept in the signature so the factory (otto.reservations.build_backend()) can pass url=url uniformly to any backend.

pathPath

Location of the reservation file on disk. Required.

backend_name() str
get_reserved_resources(username: str) set[str]
who_reserved(resource: str) list[str]

Null backend

Default when no [reservations] section is configured, or when backend = "none" is set. check_reservations() recognizes this type and becomes a no-op.

Null reservation backend used when no scheduler is configured.

Selected by setting backend = "none" in the repo’s [reservations] TOML section. otto.reservations.check.check_reservations() recognizes this type and becomes a no-op, so teams that haven’t set up a scheduler yet aren’t blocked.

class otto.reservations.null_backend.NullReservationBackend

Bases: object

Always returns “no reservations known” — the check is a no-op.

get_reserved_resources(username: str) set[str]
who_reserved(resource: str) list[str]
backend_name() str

Backend factory

otto.reservations.build_backend(settings: dict[str, Any], repo_dir: Path) ReservationBackend

Construct a reservation backend from a parsed [reservations] section.

Parameters

settingsdict[str, Any]

The [reservations] sub-dict parsed from .otto/settings.toml. Expected keys:

  • backend"json", "none", or a name registered via register_reservation_backend() from an init module. Defaults to "none" when absent.

  • url — optional string, forwarded as url=... to the backend constructor when present.

  • <backend-name> — optional nested table with backend-specific keyword arguments (e.g. [reservations.json] path = "...").

repo_dirPath

The SUT repo root. Used only to expand the JSON backend’s path setting when it is relative.

Returns

ReservationBackend

A ready-to-query backend instance.

Raises

ValueError

If backend names an unknown backend.

ReservationBackendError

If a third-party backend’s construction fails for backend reasons (network, bad credentials, etc.).

otto.reservations.register_reservation_backend(name: str, cls: type) None

Make a custom reservation backend selectable as backend = "<name>".

Call from an init module listed in .otto/settings.toml. The class must satisfy the ReservationBackend protocol.

otto.reservations.build_reservation_state(repos: list[Any], *, as_user: str | None, skip_reservation_check: bool, cwd_fallback: Path) ReservationState

Resolve the per-invocation reservation state from the active repos.

The first repo with a [reservations] section wins. With skip_reservation_check (the -R break-glass flag) the backend is not constructed at all — a scheduler that fails or hangs in its constructor can never block lab access. A backend_factory thunk is always attached so otto reservation subcommands can build it on demand.

Raises

ReservationBackendError

If construction fails and skip_reservation_check is False.

Name → class registry for reservation backends.

Mirrors otto’s other extension registries (register_term_backend / register_transfer_backend / register_host_class): a custom backend registers a bare name from an init module, and [reservations] backend = "<name>" selects it. Built-ins none and json are pre-registered at import so they resolve through the same path.

otto.reservations.registry.register_reservation_backend(name: str, cls: type) None

Make a custom reservation backend selectable as backend = "<name>".

Call from an init module listed in .otto/settings.toml. The class must satisfy the ReservationBackend protocol.

otto.reservations.registry.get_reservation_backend_class(name: str) type

Return the backend class registered under name.

Raises

ValueError

If name is not registered; the message lists the registered names.

Extension points for implementers

A custom backend needs three pieces:

  1. A class that satisfies ReservationBackend. Protocol satisfaction is structural — no explicit inheritance is required (and none is recommended).

  2. An init module that registers the class under a bare name:

    from otto.reservations import register_reservation_backend
    register_reservation_backend("my-team-jira", MyBackend)
    

    The init module must be importable (add its containing directory to libs = [...] in .otto/settings.toml, or install it into the same environment) and listed under [init] in .otto/settings.toml.

  3. A ``[reservations]`` entry selecting the registered name:

    [reservations]
    backend = "my-team-jira"
    

    Optional per-backend kwargs go in a [reservations.my-team-jira] sub-table and are passed to the constructor alongside the optional url setting.

The factory calls the class as Class(url=url, **kwargs_from_settings) when url is set in settings, otherwise Class(**kwargs_from_settings). Accept or omit url as fits your deployment.

See the user guide for a worked example with request handling, credential loading, and package layout.