Skip to content

API reference · 04 of 8

Scopes, policy, and hooks

Narrowing what a block of code may run, composing its environment, binding programs to executables, and running code around each command.

Read from Cuprum 0.2.0-beta2 at a4089878

function

scoped

cuprum/context/scoped.py, the source of scoped
def scoped(
    config: ScopeConfig | None = None,
    *,
    catalogue: ProgramCatalogue | None = None,
) -> _ScopedContext

Create a scoped context manager for narrowed execution.

Parameters

config
Scope configuration describing allowlist and hook updates. Mutually exclusive with catalogue.
catalogue
Catalogue whose allowlist establishes the scope and whose program entries sh.make resolves inside it. Mutually exclusive with config.

Returns

_ScopedContext
A context manager that narrows the current context.

Raises

TypeError
If neither, or both, config and catalogue are supplied.

Examples

>>> with scoped(ScopeConfig(allowlist=frozenset([ECHO]))) as ctx:
...     assert ctx.is_allowed(ECHO)
>>> catalogue = ProgramCatalogue.from_programs(ECHO)
>>> with scoped(catalogue=catalogue) as ctx:
...     assert ctx.is_allowed(ECHO)
class

ScopeConfig

cuprum/context/_scope.py, the source of ScopeConfig
class ScopeConfig(
    allowlist: frozenset[Program] | None = None,
    before_hooks: tuple[BeforeHook, ...] = (),
    after_hooks: tuple[AfterHook, ...] = (),
    observe_hooks: tuple[ExecHook, ...] = (),
    timeout: float | None = None,
    env_overlay: EnvOverlay | None = None,
    env_mode: EnvMode = EnvMode.OVERLAY,
    catalogue: ProgramCatalogue | None = None,
    executable_bindings: ExecutableBindingOverlay | None = None,
)

Bases: _ExecutableBindingSource

Configuration object for scoped execution context updates.

Attributes

allowlist
Optional allowlist for the scope. When None, inherit the current allowlist.
before_hooks
Hooks invoked before command execution (FIFO order).
after_hooks
Hooks invoked after command execution (LIFO order).
observe_hooks
Hooks invoked for structured execution events.
timeout
Optional default timeout in seconds for calls within the scope.
env_overlay
Optional immutable environment overlay layered over the live os.environ at subprocess spawn time. When None, no overlay is applied within the scope.
env_mode
Policy used to render the composed environment for child processes.
catalogue
Catalogue this scope activates for command construction. When None, the active catalogue is inherited. A named catalogue replaces the inherited one outright; see _resolve_narrowed_catalogue.
executable_bindings
Optional immutable mapping from a logical program to the executable it should run within the scope. When None, the bindings already in effect are inherited unchanged. Entries here win per program over the inherited layer, and never widen the allowlist.
10 members

field ScopeConfig.allowlist

allowlist: frozenset[Program] | None

field ScopeConfig.before_hooks

before_hooks: tuple[BeforeHook, ...]

field ScopeConfig.after_hooks

after_hooks: tuple[AfterHook, ...]

field ScopeConfig.observe_hooks

observe_hooks: tuple[ExecHook, ...]

field ScopeConfig.timeout

timeout: float | None

field ScopeConfig.env_overlay

env_overlay: EnvOverlay | None

field ScopeConfig.env_mode

env_mode: EnvMode

field ScopeConfig.catalogue

catalogue: ProgramCatalogue | None

field ScopeConfig.executable_bindings

executable_bindings: ExecutableBindingOverlay | None

method ScopeConfig.executable_binding

def executable_binding(program: Program) -> ExecutableBinding | None

Return the binding for *program*, or None when it is unbound.

Parameters
program Program
The logical program whose binding is wanted.
Returns
ExecutableBinding | None
The effective binding, or None when the program runs under the name it was catalogued with.
Examples
>>> from cuprum.context import CuprumContext
>>> CuprumContext().executable_binding("echo") is None
True
class

CuprumContext

cuprum/context/core.py, the source of CuprumContext
class CuprumContext(
    allowlist: frozenset[Program] = dc.field(default_factory=frozenset),
    before_hooks: tuple[BeforeHook, ...] = (),
    after_hooks: tuple[AfterHook, ...] = (),
    observe_hooks: tuple[ExecHook, ...] = (),
    timeout: float | None = None,
    env_overlay: EnvOverlay | None = None,
    _allowlist_is_restricted: bool = False,
    env_mode: EnvMode = EnvMode.OVERLAY,
    catalogue: ProgramCatalogue | None = dc.field(default=None, compare=False),
    executable_bindings: ExecutableBindingOverlay | None = None,
)

Bases: _HookPolicy, _ExecutableBindingPolicy

Immutable execution context holding allowlist and hooks.

Attributes

allowlist
Frozenset of programs permitted in this context.
before_hooks
Tuple of hooks invoked before command execution (FIFO order).
after_hooks
Tuple of hooks invoked after command execution (LIFO order).
observe_hooks
Tuple of hooks invoked for structured execution events (FIFO order).
timeout
Optional default timeout in seconds applied when a call does not supply an explicit timeout.
env_overlay
Optional immutable environment overlay layered over the live os.environ when command environments are resolved. When None, no overlay is active on this context.
_allowlist_is_restricted
Internal marker distinguishing the permissive empty default allowlist from an empty allowlist produced by narrowing a restricted scope. It keeps its position ahead of catalogue so the positional argument order that predates the catalogue field is unchanged.
env_mode
Policy used to render the composed environment for child processes.
catalogue
Catalogue the innermost active scope activated, or None outside any catalogue scope. sh.make consults it to resolve builders that do not name a catalogue explicitly. Excluded from equality because it carries no permission semantics: two contexts that allow the same programs are equivalent for enforcement regardless of which catalogue supplied them.
executable_bindings
Optional immutable mapping from a logical program to the executable it should run. When None, every permitted program runs under the name it was catalogued with. A binding supplies the executable for an already-permitted program and never widens the allowlist.
25 members

field CuprumContext.allowlist

allowlist: frozenset[Program]

field CuprumContext.before_hooks

before_hooks: tuple[BeforeHook, ...]

field CuprumContext.after_hooks

after_hooks: tuple[AfterHook, ...]

field CuprumContext.observe_hooks

observe_hooks: tuple[ExecHook, ...]

field CuprumContext.timeout

timeout: float | None

field CuprumContext.env_overlay

env_overlay: EnvOverlay | None

field CuprumContext.env_mode

env_mode: EnvMode

field CuprumContext.catalogue

catalogue: ProgramCatalogue | None

field CuprumContext.executable_bindings

executable_bindings: ExecutableBindingOverlay | None

method CuprumContext.is_allowed

def is_allowed(program: Program) -> bool

Return True when the program is in the allowlist.

This method only checks membership and returns False for empty allowlists. Use check_allowed() for enforcement; it applies the two-mode empty-allowlist policy, permitting empty unrestricted contexts and denying empty restricted contexts.

Parameters
program Program
The program to check against the current allowlist.
Returns
bool
True when the program is a member of the allowlist.

method CuprumContext.check_allowed

def check_allowed(program: Program) -> None

Raise ForbiddenProgramError if program is not allowed.

When the allowlist is empty and unrestricted, all programs are permitted (permissive default). When the allowlist is empty and restricted, all programs are denied.

A warning log with operation and restricted_state fields is emitted before raising ForbiddenProgramError.

Raises
ForbiddenProgramError
If the program is not permitted by the context allowlist.

method CuprumContext.narrow

def narrow(config: ScopeConfig) -> CuprumContext

Create a derived context with narrowed allowlist and extended hooks.

Parameters
config
Scope configuration describing allowlist and hook updates.
Returns
CuprumContext
A new context with narrowed permissions and extended hooks.
Notes

When the parent has an empty *unrestricted* allowlist, the provided allowlist is used directly to establish a base scope. When the parent is restricted and empty, narrowing keeps it empty. When the parent has programs, the new allowlist is intersected to enforce narrowing (can only remove, not add programs).

method CuprumContext.with_allowlist

def with_allowlist(allowlist: frozenset[Program]) -> CuprumContext

Return a context with the given allowlist replacing the current one.

Unlike narrow(), this sets the allowlist directly without intersection. Use with care; prefer narrow() for enforcing safety invariants.

Parameters
allowlist frozenset[Program]
The allowlist to install in place of the current one.
Returns
CuprumContext
A new context with the supplied allowlist.

method CuprumContext.with_program

def with_program(program: Program) -> CuprumContext

Return a context with the program added to the allowlist.

Parameters
program Program
The program to add to the allowlist.
Returns
CuprumContext
A new context whose allowlist includes the program.

method CuprumContext.without_program

def without_program(program: Program) -> CuprumContext

Return a context with the program removed from the allowlist.

Parameters
program Program
The program to remove from the allowlist.
Returns
CuprumContext
A new context whose allowlist excludes the program.

method CuprumContext.with_env_overlay

def with_env_overlay(
    overlay: EnvOverlay | None,
    mode: EnvMode = EnvMode.OVERLAY,
) -> CuprumContext

Return a context whose env overlay is layered with overlay.

Values in overlay win over earlier overlay entries. A replacement policy discards earlier layers, while every other policy reads the live os.environ at subprocess spawn time. In non-replacement modes, passing None leaves the effective overlay unchanged; in replacement mode, it creates an empty replacement boundary.

Parameters
overlay collections.abc.Mapping[str, str | UnsetType] | None
Environment variables to layer over the current overlay. None leaves the current overlay unchanged.
mode EnvMode
Policy contributed by this child layer.
Returns
CuprumContext
A new context with the merged environment overlay.

method CuprumContext.with_before_hook

def with_before_hook(hook: BeforeHook) -> typ.Self

Return a context with an additional before hook.

Parameters
hook BeforeHook
The before-execution hook to append.
Returns
CuprumContext
A new context with the before hook appended.

method CuprumContext.without_before_hook

def without_before_hook(hook: BeforeHook) -> typ.Self

Return a context with the specified before hook removed.

Parameters
hook BeforeHook
The before-execution hook to remove.
Returns
CuprumContext
A new context without the given before hook.

method CuprumContext.with_after_hook

def with_after_hook(hook: AfterHook) -> typ.Self

Return a context with an additional after hook (prepended for LIFO).

Parameters
hook AfterHook
The after-execution hook to prepend.
Returns
CuprumContext
A new context with the after hook prepended.

method CuprumContext.without_after_hook

def without_after_hook(hook: AfterHook) -> typ.Self

Return a context with the specified after hook removed.

Parameters
hook AfterHook
The after-execution hook to remove.
Returns
CuprumContext
A new context without the given after hook.

method CuprumContext.with_observe_hook

def with_observe_hook(hook: ExecHook) -> typ.Self

Return a context with an additional observe hook.

Parameters
hook ExecHook
The structured-event observe hook to append.
Returns
CuprumContext
A new context with the observe hook appended.

method CuprumContext.without_observe_hook

def without_observe_hook(hook: ExecHook) -> typ.Self

Return a context with the specified observe hook removed.

Parameters
hook ExecHook
The structured-event observe hook to remove.
Returns
CuprumContext
A new context without the given observe hook.

method CuprumContext.with_executable_binding

def with_executable_binding(
    program: Program,
    binding: ExecutableBinding,
) -> typ.Self

Return a context whose binding for *program* is *binding*.

Rebinding a program replaces the executable for the derived context and its descendants; the receiver is unchanged.

Parameters
program Program
The logical program to bind.
binding ExecutableBinding
The executable the program should run.
Returns
CuprumContext
A new context carrying the binding.
Examples
>>> from cuprum.context import CuprumContext
>>> from cuprum.executable_binding import executable_binding
>>> ctx = CuprumContext().with_executable_binding(
...     "echo", executable_binding("echo", "/bin/echo")
... )
>>> ctx.resolve_executable("echo", cwd=None)
'/bin/echo'

method CuprumContext.resolve_executable

def resolve_executable(
    program: Program,
    *,
    cwd: str | None,
) -> str | None

Return the executable to run for *program*, or None.

Resolution is deliberately independent of allowlist enforcement: the allowlist decides whether a program may run, and this method decides only what a permitted program runs. Keeping the two apart means an unpermitted program cannot become runnable by acquiring a binding, and a refusal still names the logical program rather than a path.

Parameters
program Program
The logical program to resolve.
cwd str | None
The working directory the execution will run in. A relative bound path is anchored at it; None leaves the platform to resolve the path's own spelling.
Returns
str | None
The executable string, or None when the program is unbound and should run under its catalogued name.
Raises
ExecutableResolutionError
A bound program's resolver failed. The error names *program* and chains the resolver's own exception as __cause__.
Examples
>>> from cuprum.context import CuprumContext
>>> CuprumContext().resolve_executable("echo", cwd=None) is None
True

method CuprumContext.executable_binding

def executable_binding(program: Program) -> ExecutableBinding | None

Return the binding for *program*, or None when it is unbound.

Parameters
program Program
The logical program whose binding is wanted.
Returns
ExecutableBinding | None
The effective binding, or None when the program runs under the name it was catalogued with.
Examples
>>> from cuprum.context import CuprumContext
>>> CuprumContext().executable_binding("echo") is None
True
function

allow

cuprum/context/registration.py, the source of allow
def allow(*programs: Program) -> AllowRegistration

Extend the current context's allowlist with additional programs.

Parameters

programs
Programs to add to the allowlist.

Returns

AllowRegistration
A handle that can be detached or used as a context manager.

Examples

>>> with allow(LS):
...     assert current_context().is_allowed(LS)
class

AllowRegistration

cuprum/context/registration.py, the source of AllowRegistration
class AllowRegistration(*programs: Program) -> None

Bases: _TokenRegistration

Registration handle for dynamic allowlist extension.

Supports detach() and context-manager usage for scoped allowing. The token-restoration discipline is documented on _TokenRegistration.

1 member

method AllowRegistration.detach

def detach() -> None

Release the registration, exactly once.

function

env

cuprum/context/_env_registration.py, the source of env
def env(
    *overlays: cabc.Mapping[str, EnvOverlayValue],
    **kwvars: EnvOverlayValue | EnvMode,
) -> EnvRegistration

Overlay environment variables on top of the live os.environ.

Mirrors dict in how arguments are combined: positional mappings are merged left-to-right and any keyword arguments win over them. For effective modes other than REPLACE, values are not snapshot against os.environ — the live process environment is read at subprocess spawn time so that variables set after Cuprum is imported (for example by monkeypatch.setenv) remain visible. A REPLACE mode renders from an empty environment instead, so the live process environment stays out of the child.

Parameters

overlays
Zero or more Mapping[str, str | UnsetType] instances supplying overlay entries. Useful when the variable name is not a valid Python identifier.
mode
When an EnvMode, the policy contributed by this scope. OVERLAY remains the default; another value creates the environment variable named mode for compatibility.
kwvars
Keyword pairs naming environment variables or UNSET markers. Identifier-safe variable names are typically expressed this way.

Returns

EnvRegistration
A handle that can be detached or used as a context manager.

Notes

env is bound to the contextvars.Context in which it is created. Detach it in that same logical context (thread or task) to avoid ValueError from contextvars.ContextVar.reset.

Examples

>>> import os
>>> os.environ["GIT_AUTHOR_NAME"] = "Cuprum"
>>> with env(PATH="/usr/bin"):
...     # Subprocesses spawned here see PATH=/usr/bin overlaid on the
...     # *live* os.environ, including GIT_AUTHOR_NAME.
...     pass
class

EnvRegistration

cuprum/context/_env_registration.py, the source of EnvRegistration
class EnvRegistration(
    overlay: EnvOverlay,
    mode: EnvMode = EnvMode.OVERLAY,
) -> None

Bases: _TokenRegistration

Registration handle for a scoped environment overlay.

The overlay is layered on top of any overlay already present in the current context; nested registrations therefore behave as a stack. The token-restoration discipline is documented on _TokenRegistration.

The overlay itself is overlay-only. For effective modes other than REPLACE, the live os.environ is read at subprocess spawn time, when render_env renders the composed policy, so any updates to the process environment after the registration is created — for example via pytest's monkeypatch.setenv — remain visible to subprocesses spawned inside the scope. This is the behaviour the issue requires. A REPLACE mode is the exception: it renders from an empty environment, so the live process environment stays out of the child entirely.

2 members

property EnvRegistration.overlay

overlay: EnvOverlay | None

The immutable overlay this registration applied.

method EnvRegistration.detach

def detach() -> None

Release the registration, exactly once.

function

merge_env_overlays

cuprum/context/env_overlay.py, the source of merge_env_overlays
def merge_env_overlays(
    parent: EnvOverlay | None,
    child: EnvOverlay | None,
) -> EnvOverlay | None

Layer child over parent; None means *inherit unchanged*.

Both layers are kept overlay-only — they never include a snapshot of os.environ. The live process environment is read at spawn time so that callers can monkey-patch or otherwise mutate os.environ after Cuprum has been imported and still have those updates visible to subprocesses spawned inside the scope.

Exposed as public API so that other cuprum modules — and downstream code that builds custom observation tags — can merge overlay layers without reaching for a private symbol.

Parameters

parent collections.abc.Mapping[str, str | UnsetType] | None
The base overlay layer. None means *inherit unchanged* — the layer contributes nothing.
child collections.abc.Mapping[str, str | UnsetType] | None
The overlay layered on top of parent; its values win on key collisions. None means *inherit unchanged*.

Returns

collections.abc.Mapping[str, str | UnsetType] | None
An immutable snapshot of the merged overlay, or None when both layers are None (meaning *inherit the environment unchanged*).
function

resolve_env

cuprum/context/env_overlay.py, the source of resolve_env
def resolve_env(*layers: EnvOverlay | None) -> dict[str, str] | None

Merge os.environ (read live) with the supplied overlay layers.

Layers are applied left-to-right; later values win. None *and empty* layers are skipped — an empty overlay contributes nothing, so treating it as a no-op avoids an unnecessary copy of os.environ and lets the subprocess inherit the parent environment directly. When every layer is skipped the function returns None so callers may pass it through to subprocess APIs to mean *inherit the parent environment unchanged*.

The call to os.environ.copy is deferred until at least one overlay is non-empty, so the result reflects whatever the process environment looks like at the moment of resolution — the exact behaviour the issue requires.

Parameters

*layers collections.abc.Mapping[str, str | UnsetType] | None
Overlay layers applied left-to-right over a live copy of os.environ; later values win. None and empty layers are skipped.

Returns

dict[str, str] | None
The merged environment mapping, or None when every layer is None or empty (meaning *inherit the parent environment unchanged*).
module

cuprum.context

cuprum/context/__init__.py, the source of cuprum.context
from cuprum import context

Execution context with scoped allowlists and hooks.

CuprumContext provides a ContextVar-backed execution context that scopes allowlists and hooks for command execution. Contexts support narrowing (restricting the allowlist) and hook registration with deterministic ordering.

Example: >>> from cuprum.context import ScopeConfig, scoped, before, current_context >>> from cuprum.catalogue import ECHO >>> def log_hook(cmd): ... print(f"Running: {cmd}") >>> with scoped(ScopeConfig(allowlist=frozenset([ECHO]))): ... with before(log_hook): ... ctx = current_context() ... ctx.is_allowed(ECHO) True

Scoped environment overlays (issue #100): four public symbols layer environment variables on top of the live os.environ for subprocesses spawned inside the scope. env(*overlays, **kwvars) is a factory that returns an EnvRegistration applying an immutable overlay to the active context, resolved live at subprocess spawn time against os.environ. EnvRegistration is the handle returned by env(); it works as a context manager and exposes detach for explicit teardown. merge_env_overlays(parent, child) is the overlay-only merge that returns an immutable MappingProxyType without reading os.environ — it is the helper used to record the effective overlay in observation events. resolve_env(*layers) is an overlay-only helper that merges os.environ with one or more overlay layers and returns a plain dict or None when no layers contribute; it is not on the subprocess spawn path. Spawning composes through _resolve_env_policy and renders through render_env, which is what consults the effective EnvMode. Precedence at spawn time, from lowest to highest, is: live os.environ < scoped env() overlays (innermost wins) < per-call ExecutionContext.env. This deliberately diverges from plumbum's local.env, which snapshots os.environ at module import time and therefore misses variables added to the process environment after import. The CuprumContext and ScopeConfig dataclasses each carry an env_overlay field (Mapping[str, str] | None) that is coerced to an immutable proxy on construction.

>>> from cuprum.context import env, current_context >>> with env(MY_VAR="hello"): ... current_context().env_overlay["MY_VAR"] 'hello'

Scoped executable bindings (issue #440): bind_executable(program, path_or_resolver, *, allow_relative=False) binds a logical catalogued program to the executable it should run inside the scope, and returns an ExecutableBindingRegistration handle that detaches or acts as a context manager. The CuprumContext and ScopeConfig dataclasses each carry an executable_bindings field coerced to an immutable proxy on construction; merge_executable_bindings(parent, child) records the effective layer without resolving it. Bindings are a second, narrower authority than the allowlist: the allowlist decides which logical programs may run at all, and a binding decides only which executable a permitted program runs. Resolution therefore happens strictly after allowlist enforcement, and CuprumContext.resolve_executable never consults is_allowed or check_allowed.

>>> from cuprum.context import bind_executable, current_context >>> from cuprum.catalogue import ECHO >>> with bind_executable(ECHO, "/opt/tools/echo"): ... current_context().resolve_executable(ECHO, cwd=None) '/opt/tools/echo'

4 exports

constant cuprum.context.UNSET

UNSET = UnsetType()

exception cuprum.context.ContextError

ContextError

Base class for execution-context domain errors.

class cuprum.context.EnvMode

EnvMode

Choose how a child environment relates to its parent environment.

OVERLAY is the default and layers supplied values over the live parent environment. INHERIT preserves an inherited policy without adding a replacement boundary. REPLACE starts the child with an empty environment before applying its supplied values.

class cuprum.context.UnsetType

UnsetType

Singleton marker requesting removal of an environment variable.

function

bind_executable

cuprum/context/registration.py, the source of bind_executable
def bind_executable(
    program: Program,
    path_or_resolver: str | os.PathLike[str] | ExecutableResolver,
    *,
    allow_relative: bool = False,
) -> ExecutableBindingRegistration

Bind a logical program to an executable for the enclosing scope.

The binding applies only inside the scope; leaving it restores the bindings that were in effect before. A nested registration for the same program overrides the outer one for the inner scope alone, and bindings for distinct programs compose.

Parameters

program
The logical program to bind.
path_or_resolver
The executable path, or a zero-argument callable returning one. A resolver is evaluated once per execution, at spawn time.
allow_relative
When True, a relative path is permitted. It is anchored at the execution's working directory when the execution supplies one; an execution that names no directory leaves the platform to resolve it — against PATH for a bare name, and otherwise relative to the process's own working directory. Ignored for a resolver.

Returns

ExecutableBindingRegistration
A handle that can be detached or used as a context manager.

Raises

InvalidExecutableBindingError
A supplied path failed validation. The error names the logical program, quotes the path, and carries the classified reason.

Examples

>>> with bind_executable(ECHO, "/opt/tools/echo"):
...     # Commands for ECHO run /opt/tools/echo inside this scope.
...     pass
class

ExecutableBindingRegistration

cuprum/context/registration.py, the source of ExecutableBindingRegistration
class ExecutableBindingRegistration(
    program: Program,
    binding: ExecutableBinding,
) -> None

Bases: _TokenRegistration

Registration handle for a scoped executable binding.

The binding is layered onto any bindings already present in the current context, so nested registrations behave as a stack: the innermost binding for a program is the one an execution resolves. The token-restoration discipline is documented on _TokenRegistration.

A binding supplies the executable a permitted program runs. It is not a permission: the allowlist continues to decide which logical programs may run at all, and resolution happens only after that decision has been made.

3 members

property ExecutableBindingRegistration.program

program: Program

The logical program this registration bound.

property ExecutableBindingRegistration.binding

binding: ExecutableBinding

The binding this registration applied.

method ExecutableBindingRegistration.detach

def detach() -> None

Release the registration, exactly once.

function

merge_executable_bindings

cuprum/context/executable_overlay.py, the source of merge_executable_bindings
def merge_executable_bindings(
    parent: ExecutableBindingOverlay | None,
    child: ExecutableBindingOverlay | None,
) -> ExecutableBindingOverlay | None

Layer child over parent; None means *inherit unchanged*.

The child layer wins key by key: an inner scope can rebind a program the outer scope already bound, and can add bindings the outer scope never mentioned. Composition never copies a binding — entries move between layers by identity, so a resolver stays the same callable wherever it is read from.

Parameters

parent collections.abc.Mapping[Program, ExecutableBinding] | None
The base layer. None means *inherit unchanged* — the layer contributes nothing.
child collections.abc.Mapping[Program, ExecutableBinding] | None
The layer applied over parent; its bindings win on key collisions. None means *inherit unchanged*.

Returns

collections.abc.Mapping[Program, ExecutableBinding] | None
An immutable snapshot of the composed layer, or None when both layers are None, meaning *inherit the effective bindings unchanged*.

Examples

>>> merged = merge_executable_bindings({"echo": "outer"}, {"echo": "inner"})
>>> merged["echo"]
'inner'
module

cuprum.executable_binding

cuprum/executable_binding.py, the source of cuprum.executable_binding
from cuprum import executable_binding

Typed bindings from a logical program identity to an executable path.

A Program names an executable for catalogue and policy purposes, and the same value is handed to the operating system as argv[0]. Those two jobs usually coincide, but not always: a tool may live at a version-pinned absolute path, inside a virtual environment, or be replaced by a controlled stub during tests. This module carries the second job as its own value so the first can stay a bounded logical label.

The path vocabulary this module builds on — ExecutablePath, PathBindingRejection, executable_path, and advisory_path_rejection — lives in executable_paths and is re-exported here, so callers reach the whole feature through one import.

Both modules are pure: neither reads or writes CuprumContext, and neither imports the context package at runtime. That keeps the dependency direction acyclic, so the context package and the execution layer can both depend on them.

Resolution and its limits -------------------------

resolve_binding turns a binding plus a working directory into the string to execute. It is pure and total for every binding the constructors accept.

Checking a path and then executing it is not atomic. Between the advisory check and the child's exec, another process with write access to the directory can replace, rename, or re-link the file, and the child will run whatever is there at that moment. Cuprum cannot close that window from inside the process: it would need an O_PATH descriptor plus fexecve, which is not available portably or from asyncio. Operators who need the guarantee that the checked path is the executed binary must supply it from the filesystem, by owning the directory, restricting write permission to a trusted account, and deploying the tree read-only.

11 exports

class cuprum.executable_binding.ExecutableBinding

class ExecutableBinding(
    path: ExecutablePath | None = None,
    resolver: ExecutableResolver | None = None,
)

The executable a logical program should run.

Exactly one of path and resolver must be set. A path binding is a fixed, already-validated string; a resolver binding is evaluated once per execution, so it can consult state that is only available at spawn time.

Construct through executable_binding, which applies validation and reports the logical program in its errors; constructing directly is supported for callers that already hold a validated ExecutablePath or a trusted resolver.

Parameters
path ExecutablePath | None, optional
The fixed executable path.
resolver ExecutableResolver | None, optional
The callable producing the executable at spawn time.
Raises
ValueError
Neither or both of path and resolver were supplied.

new type cuprum.executable_binding.ExecutablePath

ExecutablePath = typ.NewType('ExecutablePath', str)

A validated string naming an executable to run.

The string is not necessarily absolute; whether an absolute path is required is decided by executable_path's allow_relative flag, not by the type.

exception cuprum.executable_binding.ExecutableResolutionError

class ExecutableResolutionError(
    program: Program,
    cause: BaseException,
) -> None

Raised when a bound program's resolver fails to produce an executable.

A resolver is caller-supplied and typically consults state that may be absent — a virtual environment that was never created, a toolchain that is not installed. Each of those is a recognizable failure of the *binding*, so it is reported as one rather than escaping as whichever exception the resolver happened to raise: an escaping FileNotFoundError names a path the caller never configured, and gives no hint that a binding was involved. The original exception is chained as __cause__.

Parameters
program Program
The logical program whose resolver failed.
cause BaseException
The exception the resolver raised.
Attributes
program Program
The logical program whose resolver failed.
cause BaseException
The exception the resolver raised.
Examples
>>> from cuprum.context import CuprumContext
>>> def missing() -> str:
...     raise FileNotFoundError("no venv")
>>> binding = executable_binding("echo", missing)
>>> ctx = CuprumContext().with_executable_binding("echo", binding)
>>> try:
...     ctx.resolve_executable("echo", cwd=None)
... except ExecutableResolutionError as err:
...     print(err)
Program 'echo' cannot resolve its executable: FileNotFoundError

type alias cuprum.executable_binding.ExecutableResolver

ExecutableResolver = cabc.Callable[[], str]

A zero-argument callable producing the executable to run.

Evaluated once per execution, at spawn time. It exists for callers whose executable is only knowable late: a virtual environment concretized during the run, a toolchain selected from configuration, or a test double chosen by the test's own fixtures. Its result is used verbatim, so a resolver that wants its result anchored must return an absolute path itself.

exception cuprum.executable_binding.InvalidExecutableBindingError

class InvalidExecutableBindingError(
    program: Program | None,
    path: str,
    reason: PathBindingRejection,
) -> None

Raised when an executable path or a binding for it cannot be built.

The error lives beside the rejection vocabulary rather than beside the binding type, because both executable_path here and executable_binding in executable_binding raise it, and the binding module already depends on this one.

Parameters
program Program | None
The logical identity the binding was being built for, or None when the path was validated on its own.
path str
The path the caller supplied, quoted verbatim.
reason PathBindingRejection
The classified reason for the rejection.
Attributes
program Program | None
The logical identity the binding was being built for, or None.
path str
The rejected path, quoted verbatim.
reason PathBindingRejection
The classified reason for the rejection.

class cuprum.executable_binding.PathBindingRejection

PathBindingRejection

Reason a raw string fails executable-path validation or its advisory check.

Each member's value is the exact error message raised for that category, so callers can reason about the rejection category rather than only pass/fail. Members are declared in the order the checks are applied; the classifiers return the first match.

function cuprum.executable_binding.advisory_path_rejection

def advisory_path_rejection(
    raw_value: str,
) -> PathBindingRejection | None

Report whether *raw_value* currently looks like a runnable executable.

This is an advisory probe, not a guarantee. It answers "at the instant it was called, did this path exist as an executable file?" and says nothing about what will be executed later; see the module docstring of executable_binding for the limits of that claim.

A bare name with no directory component is left unchecked, because the platform resolves it against PATH and Cuprum does not replicate that search. Only absolute paths and relative paths containing a separator are probed. The check is skipped entirely on Windows, where the execute bit is not part of the file's identity.

Parameters
raw_value str
The path to probe.
Returns
PathBindingRejection | None
NOT_FOUND when the path is absent, NOT_EXECUTABLE when it exists as something other than an executable file, or None when nothing was found to report.
Examples
>>> advisory_path_rejection("tool") is None
True

function cuprum.executable_binding.classify_executable_path

def classify_executable_path(
    raw_value: str,
    *,
    allow_relative: bool,
) -> PathBindingRejection | None

Classify why a raw executable-path string is rejected, or None.

Mirrors classify_path_string: the checks run in declaration order, and the first match wins, so a NUL-bearing traversal is reported as a NUL rather than a traversal.

Parameters
raw_value str
Path string to classify.
allow_relative bool
When True, relative paths are permitted.
Returns
PathBindingRejection | None
The rejection category, or None when the value is acceptable.
Examples
>>> classify_executable_path("/opt/tools/tool", allow_relative=False) is None
True
>>> classify_executable_path("bin/tool", allow_relative=False).name
'NOT_ABSOLUTE'
>>> classify_executable_path("bin/tool", allow_relative=True) is None
True
>>> classify_executable_path(r"C:\tools\..\evil.exe", allow_relative=False)
<PathBindingRejection.PARENT_SEGMENT: "ExecutablePath cannot contain '..' segments">

function cuprum.executable_binding.executable_binding

def executable_binding(
    program: Program,
    path_or_resolver: str | os.PathLike[str] | ExecutableResolver,
    *,
    allow_relative: bool = False,
) -> ExecutableBinding

Bind a logical *program* to an executable path or resolver.

A callable is stored as a resolver and used verbatim; a string or any other path-like value is validated through executable_path and stored as a normalized path.

Parameters
program Program
The logical identity the binding is for. It is used only for error reporting; the binding does not itself restrict which program may use it.
path_or_resolver str | os.PathLike[str] | ExecutableResolver
The executable path, or a zero-argument callable returning one. A path is validated and stored as a str; a callable is stored unchanged and evaluated at spawn time.
allow_relative bool, optional
When True, a relative path is permitted. Ignored for a resolver, whose result Cuprum does not inspect. Defaults to False.
Returns
ExecutableBinding
The binding.
Raises
InvalidExecutableBindingError
A supplied path failed validation. The error names the program, quotes the path, and carries the classified reason.
Examples
>>> binding = executable_binding("tool", "/opt/tools/tool")
>>> binding.path
'/opt/tools/tool'
>>> binding.is_lazy
False

function cuprum.executable_binding.executable_path

def executable_path(
    value: str | os.PathLike[str],
    *,
    allow_relative: bool = False,
) -> ExecutablePath

Validate and normalize a string into an ExecutablePath.

Accepts any os.PathLike — a pathlib.Path or a bare pathlib.PurePosixPath alike — for symmetry with safe_path, but unlike safe_path it does not require an absolute path unless allow_relative is left at its default. Validation is purely syntactic; the filesystem is not consulted, so a path may be validated and bound before the file it names exists.

Parameters
value str | os.PathLike[str]
The path to validate.
allow_relative bool, optional
When True, relative paths are permitted. Defaults to False, which requires an absolute path.
Returns
ExecutablePath
The normalized path.
Raises
InvalidExecutableBindingError
The path is empty, contains a NUL byte or a .. segment, or is relative while allow_relative is False. The error carries the classified reason and a program of None, because no logical identity was supplied.
Examples
>>> executable_path("/opt//tools/./tool")
'/opt/tools/tool'
>>> executable_path("bin/tool", allow_relative=True)
'bin/tool'

function cuprum.executable_binding.resolve_binding

def resolve_binding(
    binding: ExecutableBinding,
    *,
    cwd: str | None,
) -> str

Evaluate *binding* into the executable string to run.

A resolver is called once and its result is used verbatim. A path binding is returned as-is when it is already absolute; a relative path binding is anchored at cwd when one is supplied, and returned unchanged otherwise, leaving the platform to resolve it — against PATH for a bare name, or relative to the process's own working directory otherwise.

Parameters
binding ExecutableBinding
The binding to evaluate.
cwd str | None
The working directory the execution will run in, or None.
Returns
str
The executable string.
Examples
>>> resolve_binding(
...     executable_binding("tool", "bin/tool", allow_relative=True),
...     cwd="/srv/project",
... )
'/srv/project/bin/tool'
function

before

cuprum/context/registration.py, the source of before
def before(hook: BeforeHook) -> HookRegistration

Register a before-execution hook in the current context.

Parameters

hook
Callable invoked with the SafeCmd before execution.

Returns

HookRegistration
A handle that can be detached or used as a context manager.

Examples

>>> def log_cmd(cmd):
...     print(f"Running: {cmd.program}")
>>> with before(log_cmd):
...     # Commands run here will trigger log_cmd
...     pass
function

after

cuprum/context/registration.py, the source of after
def after(hook: AfterHook) -> HookRegistration

Register an after-execution hook in the current context.

Parameters

hook
Callable invoked with the SafeCmd and CommandResult after execution.

Returns

HookRegistration
A handle that can be detached or used as a context manager.

Examples

>>> def log_result(cmd, result):
...     print(f"Finished: {cmd.program} -> {result.exit_code}")
>>> with after(log_result):
...     # Commands run here will trigger log_result
...     pass
class

HookRegistration

cuprum/context/registration.py, the source of HookRegistration
class HookRegistration(
    hook: BeforeHook | AfterHook | ExecHook,
    hook_type: typ.Literal['before', 'after', 'observe'],
) -> None

Bases: _TokenRegistration

Registration handle for hooks with detach and context-manager support.

The token-restoration discipline is documented on _TokenRegistration.

Prefer the before, after, and observe factories: each pairs one hook shape with its slot, so a mismatched hook is rejected at the call site. Constructing the handle directly pairs the hook and the tag by hand, and only the tag itself is validated — at runtime.

1 member

method HookRegistration.detach

def detach() -> None

Release the registration, exactly once.