Skip to content

Core objects

reemote.core holds the value types that carry a deploy through both phases. OperationRequest is what a deploy script yields; OpContext is what the driver hands back to an operation; FactCache is the boundary that gives generator-body fact reads their sequential semantics.

sub_deploy and compose are the composition helpers: pure request-stream transforms that wrap sub-deploys with default meta-parameters (a name prefix, privilege elevation, an eligibility guard) and concatenate them into one deploy-shaped callable, optionally collapsing requests that would execute identically (dedupe by effective identity — the plan name is excluded and the first occurrence wins).

Privilege is the tri-state behind the doas/sudo/su meta-parameters — ON, OFF and UNSET — which lets a sub_deploy policy force elevation across a role while still honouring an explicit sudo=False opt-out (see Composing deploys). op and sub_deploy also accept a plain bool (True → ON, False → OFF) and default to UNSET; the driver collapses the tri-state to a concrete bool before a command is sent, where only ON elevates.

The when meta-parameter is an eligibility guard: a WhenPredicate (or tuple of predicates) over static inventory data, captured on the request and evaluated by the driver at the execution boundary. A guarded request stays in every host's sequence — one shared schedule — and is skipped on hosts that fail the guard, without being materialized at all; the plan and run log render it as [skipped: when guard <name>]. Unlike the escalation policy's inner-wins fill, guards compose by conjunction: a sub_deploy guard and an inner guard must both pass. Predicates must be stable module-level functions reading only static data, so both phases reach the same decision (see Determinism).

OperationError is raised by an operation whose prerequisites are not met on a host — server.timezone, for instance, requires timedatectl and is supported on systemd systems only. It aborts the run fail-fast while materializing (in a deploy run that is the prepare phase, before anything is sent), with a message naming the host and the missing prerequisite.

UnhashableArgumentError is raised by the request model itself, while an OperationRequest is being built. A request's args/kwargs values are hashed by compose(..., dedupe=True), so a mutable container there — packages=["nginx"], or a list nested inside a tuple — is rejected on the spot (a TypeError subclass) naming the offending field, rather than failing later from inside a set insertion in a composition helper.

DeployRequestError enforces the deploy body's other rule: yield the requests, do not merely build them. It is raised (also a TypeError subclass) when the body returns no iterable at all, when it hands over something that is not an OperationRequest, or when it builds a request with op() and drops it instead of yielding it — the last of these is caught by a per-invocation tally of requests built versus requests yielded, maintained through a ContextVar that op() charges on each construction. compose's dedupe collapses are charged as dropped on purpose, so a legal duplicate is never read as a lost request. Both phases run the checks — see Drivers and Deploy scripts.

reemote.core

Core data structures for the generator-based two-phase execution model.

See the design document sections 4.1 (OperationRequest), 4.3 (OpContext), 4.4 (FactCache) and 4.6 (HostRuntime). None of these objects are aware of the phase they are used in; phase awareness lives entirely in the drivers.

WhenPredicate module-attribute

WhenPredicate = Callable[[Host], bool]

OperationRequest dataclass

OperationRequest(
    func: Callable,
    args: tuple[Any, ...] = (),
    kwargs: dict = dict(),
    name: str = "",
    sudo: Privilege = UNSET,
    su: Privilege = UNSET,
    doas: Privilege = UNSET,
    when: WhenPredicate
    | tuple[WhenPredicate, ...]
    | None = None,
)

A plain, frozen, serializable descriptor of "an operation to perform".

frozen=True gives value equality, which is what the lockstep scheduler compares to pair "the Nth operation for host A" with "the Nth operation for host B" -- and to check an apply-phase request against the prepare-phase plan. Hashing is a second, narrower need: the dict kwargs field is unhashable, so __hash__ is defined explicitly over a hashable projection of the fields, and it is compose(dedupe=True) (which puts _effective_key tuples in a set) that calls it. Because that key includes every argument value, an unhashable one -- packages=["nginx"] -- is rejected here, in __post_init__, with UnhashableArgumentError.

sudo, su and doas are meta-parameters: like name they describe how the driver should run the operation rather than being forwarded to func. Each is a :class:Privilege tri-state, not a plain bool, so a request can distinguish "elevate" (ON), "deliberately run unprivileged" (OFF) and "no opinion" (UNSET, the default). Only ON elevates: the driver collapses the tri-state with Privilege.elevates before sending, wrapping the command with doas, sudo and/or su on the real transport (the test framework's simulated seam ignores them, since privilege has no in-memory effect). The tri-state is what lets a sub_deploy policy force elevation while honouring an explicit opt-out -- see sub_deploy. Note that these are only requests to elevate: how to elevate -- the run-as users and the passwords -- comes from the host's inventory privilege section, never from the operation, so a deploy script cannot choose its own privilege parameters. When several are ON the escalations combine in a fixed order, doas then sudo then su (doas is the outermost escalation and su the innermost, so doas runs sudo, which runs su inside it).

when is the eligibility meta-parameter: a guard predicate (or tuple of predicates) over static host data, stored normalized as a tuple (empty = always eligible). The driver evaluates the predicates at the execution boundary -- a request whose guard fails on a host is skipped there, but stays in the host's request sequence, so every host shares the same schedule and lockstep pairing is untouched. Unlike sudo/su (configuration values a wrapper fills in), guards compose by conjunction: a sub_deploy guard and an inner guard must both pass. Predicates must read only static inventory data (host.name, host.group, host.tags, ...) -- never mutable facts -- so both phases reach the same skip decision, and they must be stable function objects (module- or composition-level definitions, like func itself): an inline lambda in a deploy body is a fresh object on each phase invocation, which the determinism guard correctly reports as divergence.

doas class-attribute instance-attribute

doas: Privilege = Privilege.UNSET

func instance-attribute

func: Callable

su class-attribute instance-attribute

su: Privilege = Privilege.UNSET

sudo class-attribute instance-attribute

sudo: Privilege = Privilege.UNSET

when class-attribute instance-attribute

when: WhenPredicate | tuple[WhenPredicate, ...] | None = (
    None
)

when_failure

when_failure(host: Host) -> WhenPredicate | None

The first guard predicate host fails, or None when eligible.

Short-circuits in tuple order (outer sub_deploy guards first), so the returned predicate names the outermost reason a host is skipped -- which is what the plan and run log report. An empty when tuple means no guard: every host is eligible.

OperationError

Raised when an operation's prerequisites are not met on a host.

An operation may require a capability the host does not have -- e.g. server.timezone requires timedatectl and is supported on systemd systems only. Rather than silently skipping the host or yielding a command that cannot run, the operation raises this while materializing, which in a deploy run happens in the prepare phase -- before anything is sent -- so the run aborts fail-fast like CommandError, with a message naming the host and the missing prerequisite.

UnhashableArgumentError

Raised when an OperationRequest carries an unhashable argument value.

Pairing hosts in lockstep and the determinism guard both compare requests by value; hashing comes in separately, where compose(dedupe=True) builds a set of _effective_key tuples -- and every argument value is part of that key. An unhashable value (a list, a dict, a set, or one of those nested inside a tuple) therefore breaks deduplication with a bare TypeError raised from inside a set insertion, far from the deploy line that caused it. So the request validates its own arguments at construction time, while the offending call site is still on the traceback.

Subclasses TypeError -- what it replaces -- so existing handlers keep working.

DeployRequestError

Raised when a deploy body breaks the "yield requests" contract.

A deploy is a plain sync generator whose body hands the driver exactly one OperationRequest per yield. Breaking that shape loses work in silence, or fails with an error raised so deep in the driver that it names nothing useful, so the driver checks the contract at the two points where a deploy body is invoked (drivers._prepare_host and the lockstep pull in drivers.run_apply) -- which in a normal run means the prepare phase, before anything is sent:

  • the body returned a non-iterable (typically None) because it calls op() without yield, so there is no request stream at all;
  • a yielded item is not an OperationRequest -- e.g. an operation called from the deploy body, which cannot even be invoked without the OpContext only the driver holds;
  • the body built a request with op() and dropped it instead of yielding it. This is the shape nothing else can catch: the sequence stays valid, is identically shorter in both phases (so the determinism guard holds) and the operation simply never runs. It is caught by the per-invocation built-versus-yielded tally in _RequestCounter.

Subclasses TypeError -- the bare error these misuses used to raise from inside the driver -- so a caller handling TypeError keeps handling it, and the message names the host, the phase and the missing yield.

Privilege

Tri-state privilege-escalation request for the sudo/su/doas meta-parameters.

A plain bool conflates two different intentions: "run this operation unprivileged" and "no opinion, let a wrapper decide". Because they are the same False, a sub_deploy(sudo=True) policy cannot tell a request that deliberately opted out from one that was simply never flagged, and elevates both. Privilege separates the three states so forced elevation can honour an explicit opt-out:

  • ON -- elevate this operation's commands.
  • OFF -- deliberately run unprivileged; a forced-elevation wrapper (sub_deploy(sudo=Privilege.ON)) respects it. This is the opt-out a plain bool cannot express.
  • UNSET -- no opinion. A wrapper may fill it in; with no wrapper left it resolves to "do not elevate" at the execution boundary, so an unflagged top-level request stays unprivileged exactly as sudo=False did before.

The execution layer (drivers.command_shell, Host.run_shell_privileged, LogEntry) deals in concrete bools, so the driver collapses a request's tri-state with elevates() before sending: only ON elevates.

coerce classmethod

coerce(value: Privilege | bool | None) -> Privilege

Normalize the accepted spellings to a Privilege member.

Accepts a Privilege (returned unchanged), None (-> UNSET) and a plain bool for ergonomic call sites -- True -> ON, False -> OFF -- so existing sudo=True/sudo=False keep working while the omitted default stays UNSET.

elevates

elevates() -> bool

The concrete elevation decision: True only for ON.

OFF and UNSET both collapse to False -- by the time a request reaches the driver every wrapper has run, so a still-UNSET flag means nothing claimed it and it must not elevate.

OpContext dataclass

OpContext(
    host: Host,
    facts: FactCache,
    changed: bool = False,
    reason: str = "",
)

Mutable context threaded through one operation invocation.

This is the mechanism by which an operation communicates changed and reason back to the driver (design doc section 6.3, approach C).

changed class-attribute instance-attribute

changed: bool = False

facts instance-attribute

facts: FactCache

host instance-attribute

host: Host

reason class-attribute instance-attribute

reason: str = ''

FactCache

FactCache(host: Host, phase: str)

Per-host, per-phase fact cache.

Also recreated at each lockstep step in the apply phase, which is what gives generator-body fact reads sequential "act, then read" semantics. get is a coroutine because a fact's process gathers its value asynchronously (over asyncssh in the real implementation).

host instance-attribute

host = host

phase instance-attribute

phase = phase

get async

get(fact_cls, *args, **kwargs)

HostRuntime dataclass

HostRuntime(
    host: Host,
    generator: Generator,
    fact_cache: FactCache,
    current_request: OperationRequest | None = None,
    current_ctx: OpContext | None = None,
    done: bool = False,
    deploy_counter: _RequestCounter = _RequestCounter(),
)

Per-host state maintained by the apply-phase driver.

deploy_counter is the built-versus-yielded tally for this host's apply-phase generator, installed by run_apply around each request pull. _prepare_host gives every runtime a fresh one, so a deploy body that loses a request is caught in the prepare phase first and again here if a caller drives apply on a runtime it built itself.

current_ctx class-attribute instance-attribute

current_ctx: OpContext | None = None

current_request class-attribute instance-attribute

current_request: OperationRequest | None = None

deploy_counter class-attribute instance-attribute

deploy_counter: _RequestCounter = field(
    default_factory=_RequestCounter
)

done class-attribute instance-attribute

done: bool = False

fact_cache instance-attribute

fact_cache: FactCache

generator instance-attribute

generator: Generator

host instance-attribute

host: Host

op

op(
    func: Callable,
    *,
    name: str = "",
    sudo: Privilege | bool = UNSET,
    su: Privilege | bool = UNSET,
    doas: Privilege | bool = UNSET,
    when: WhenPredicate
    | tuple[WhenPredicate, ...]
    | None = None,
    **kwargs,
) -> OperationRequest

Convenience helper for building OperationRequests in deploy scripts.

name, sudo, su, doas and when are meta-parameters consumed by the driver, so they are captured here and never forwarded to func; everything else in kwargs is passed through to the operation as keyword arguments.

sudo/su/doas accept a :class:Privilege or a plain bool (True -> ON, False -> OFF) and default to UNSET -- "no opinion", so a sub_deploy policy can still fill them in. OperationRequest normalizes the value, so the stored flag is always a Privilege.

when is an eligibility guard: a predicate over static host data (lambda-free at this call site -- pass a stable, module-level function so both phases yield equal requests), a tuple of predicates (all must pass), or None (the default, always eligible). A guarded request stays in every host's sequence; the driver skips it on hosts that fail the guard. OperationRequest normalizes the value to a tuple.

Collection arguments must be immutable: packages=("nginx",), never packages=["nginx"]. OperationRequest checks each args/kwargs value is hashable and raises UnhashableArgumentError on the spot, so a list -- or a list nested inside a tuple -- is named at the deploy line instead of failing later inside compose(dedupe=True).

Every call is charged to the deploy body's built-versus-yielded counter when one is installed, which is what lets the driver catch a request that was built here and then never yielded (yield op(...), not op(...)) with DeployRequestError. Outside a tracked deploy invocation the count is a no-op, so building requests anywhere else is unaffected.

sub_deploy

sub_deploy(
    sub: Callable[[Host], Iterable[OperationRequest]],
    *,
    name_prefix: str = "",
    sudo: Privilege | bool = UNSET,
    su: Privilege | bool = UNSET,
    doas: Privilege | bool = UNSET,
    when: WhenPredicate
    | tuple[WhenPredicate, ...]
    | None = None,
) -> Callable[[Host], Generator[OperationRequest]]

Wrap a sub-deploy, applying default meta-parameters to its requests.

The returned callable has the deploy-script shape -- (host) -> generator of OperationRequest -- so a wrapped sub-deploy nests with yield from, composes with compose and is accepted anywhere a deploy function is (e.g. run).

sudo/su/doas are an escalation policy for the wrapped requests, given as a :class:Privilege (or a bool: True -> ON). The inner request wins whenever it has an opinion, and the policy only fills the UNSET ones (see _inherit):

  • sub_deploy(sudo=Privilege.ON) (equivalently sudo=True) forces elevation on every request that did not deliberately opt out -- an inner sudo=False/Privilege.OFF stays unprivileged, while an unflagged (UNSET) one is elevated. This forced-elevation-with-opt-out is exactly what a plain bool could not express.
  • sub_deploy(sudo=Privilege.OFF) forces the UNSET requests unprivileged, leaving an inner ON untouched.
  • the default Privilege.UNSET is no policy at all: every request passes through unchanged.

when is a role-level eligibility guard -- the analogue of Ansible's block-level when. Unlike the escalation policy it composes by conjunction, not inner-wins: the wrapper's predicates are prepended to each request's own when tuple, so both must pass and a wrapper can only ever restrict eligibility, never widen it (a guard the inside could override would be no guard at all). The default None adds no predicate. Concatenating tuples -- rather than building a composed closure per request -- keeps the transform value-stable across phases, so the determinism guard holds.

name_prefix prepends "<prefix>: " to each plan name (an unnamed request falls back to its operation function's name, matching the formatters).

The wrapper is a pure stream transform over the frozen dataclass (dataclasses.replace produces the modified copies): it keeps no state between calls and reads nothing but each request, so the prepare and apply phases flatten it identically and the determinism guard holds.

compose

compose(
    *subs: Callable[[Host], Iterable[OperationRequest]],
    dedupe: bool = False,
) -> Callable[[Host], Generator[OperationRequest]]

Concatenate sub-deploys into one deploy-shaped callable, in order.

Each sub is any callable taking a Host and producing OperationRequest objects (a deploy generator function, or one wrapped by sub_deploy); their requests are yielded in argument order, so composition doubles as an explicit cross-role ordering statement (e.g. repositories before the packages they provide).

With dedupe=True, requests collapse by effective identity -- func, args, kwargs, sudo, su, doas and when, but not name (see _effective_key) -- to their first occurrence, which keeps its name. Independently written roles may therefore each declare the same prerequisite under a different label or a different sub_deploy name prefix and still collapse to one plan entry; sudo/su/doas still separate requests, since they change what executes, and so does when, since it changes whether it executes. OperationRequest equality remains maximal (name included), because the determinism guard pairs full values.

The seen set is created inside the returned deploy, per invocation: every deploy(host) call (one per host per phase) dedupes from scratch, keeping the transform pure so both phases produce the identical collapsed sequence and the determinism guard holds.

A request dropped as a duplicate is charged to the deploy body's built-versus-yielded counter as dropped, because it was built on purpose and collapsed on purpose -- without it the driver's DeployRequestError check would read a dedupe as a lost request.