Cuprum design
The design document: principles, conceptual model, async execution, pipelines, error model, and the Rust extension.
Catalogue and builders construct; policy authorizes; the runtime executes; observers report. This is how Cuprum keeps each of those boundaries legible.
01 — Division of labour
Catalogue and builders construct. Policy authorizes. The runtime executes. Observers report. No component does another’s job.
Stated invariants
Each line names where it is enforced. If the code and this list ever disagree, the code is right and this page is out of date.
shell=False. cuprum/_wait4_process.py, cuprum/_pipeline_spawn.pysh.make resolves an explicit catalogue, else the scope’s, else the default, and raises UnknownProgramError when it does not list the program. cuprum/sh/factory.py, cuprum/catalogue.pyscoped() intersects its allowlist with its parent’s. Widening is a separate, explicit call to allow(). cuprum/context/bind_executable decides only what a permitted program executes; resolution happens after the allowlist check, and a refused program never calls its resolver. cuprum/context/, cuprum/executable_binding.pyterminate() on a handle escalate from SIGTERM to SIGKILL and wait for the exit, even under repeated cancellation. Only a run that opts into OWN_GROUP signals a process group, and only the group its spawn created. cuprum/_process_lifecycle.py, cuprum/_group_teardown.py02 — Execution lifecycle
What happens, in order, between calling a builder and holding a CommandResult — and what happens when the run is cut short.
03 — Pipelines and streams
How stages are joined, where bytes are copied, how backpressure works, and which stage is blamed when one fails.
Stages are joined by operating-system pipes. Between two stages a
pump reads from one and writes to the next, awaiting
drain() on each write, so a slow consumer slows its
producer instead of filling memory.
That inter-stage pump is the one place the optional
Rust extension is used: when
it is installed and selected, the copy runs outside the GIL, using
splice() on Linux. Capturing the final output always
uses the Python path.
When the first non-final failure still has stages to stop, observe
hooks receive one pipeline_fail_fast event before
termination begins, carrying the stage index, pipeline width, exit
code, and duration. It omits argv and tags, so command-line secrets
do not reach the warning it produces.
A final-stage failure, or one whose siblings have already exited,
emits no such event: there was nothing left to terminate. It is
still reported as failure_index.
04 — Policy and observation
Where the allowlist lives, how it narrows, and the channels that report on a run without changing what it does.
CuprumContext is an immutable value held in a
ContextVar. Each thread gets its own; each async task
inherits its creator’s and may change it independently. Entering
scoped() replaces the value, and leaving the block
restores the previous one, even on an exception.
At the root, with no scope established, every catalogued program
may run. That permissive default eases adoption; narrowing it is
how code states its policy. An explicitly empty allowlist inside a
restricted scope permits nothing. A scope made from a catalogue
also makes that catalogue the one sh.make consults,
while a scope that only narrows the allowlist leaves it alone.
Two more policies ride on the same context. EnvMode
decides whether a child inherits, overlays, or replaces the
parent's environment, and an executable binding decides which
file a permitted program runs, without changing the name the
allowlist, metadata, and telemetry carry.
Before hooks run in registration order, parent first. After hooks
run in reverse, child first, so cleanup nests like context
managers. logging_hook() pairs the two to log start
and exit.
sh.observe() delivers structured ExecEvent
values — plan, start, stdout, stderr, exit, one settled event with
the bounded terminal outcome, and the ancillary timeout, teardown,
and group-teardown events — each correlated by an
exec_id rather than a pid the system may recycle.
Narrower facts travel on channels of their own: what a control
operation did to a child, an echo that fell back, a line stream's
lifecycle, and the Rust pump's routing decisions.
The adapters in cuprum.adapters turn those events into
structured logs, Prometheus-style metrics, and OpenTelemetry-style
spans. They define protocols rather than importing a telemetry
library, so Cuprum keeps its zero-dependency install.
An opt-in sink reframes a run’s echoed output without changing
capture or the result. GitHubActionsSink wraps a run in
a collapsible log group and turns a failure into one annotation; it
activates only when GITHUB_ACTIONS is
true, unless forced.
05 — Sources
The design document: principles, conceptual model, async execution, pipelines, error model, and the Rust extension.
The public API, with every option and its failure behaviour.
Why the scope, hooks, and registrations live where they do.
How single-command execution is divided between modules.
Why presentation is an opt-in sink rather than a change to echo.
Why inherit, overlay, and replace are a closed enum on the context, and why UNSET is a value.
Why what a stop request did to a child is reported on its own channel, and why refusal is an outcome.
Why builders accept exactly ArgValue and refuse execution-option names.
Why group ownership is an opt-in policy on the execution context, and what it cannot promise.
Why redaction is a policy each exporting adapter is given, masks only configured text, and fails closed.