On this page
Single authorized surface for effects in rlsbl: subprocess launches, filesystem mutations and network requests route through it so --dry-run previews them.
#rlsbl.effects
#rlsbl.effects
The single authorized surface for effectful calls in rlsbl production code.
Every subprocess launch, filesystem mutation, and network call made by rlsbl/ goes through this module. Nothing else in the package may call subprocess.run, open(path, "w"), os.replace, shutil.rmtree, urllib.request.urlopen, or their siblings directly -- tests/test_effects_chokepoint.py enforces that with an AST scan and a tiny explicit exemption list (this module and :mod:rlsbl._effects_direct, which holds the primitives).
Why a chokepoint: rlsbl rides strictcli's ctx.effects regime, where every mutation is declared, previewable under --dry-run, and recorded. With every effect funnelled through this one module, that regime is adapted in one file instead of ~380 call sites.
The mode rule (declared, never inferred) ----------------------------------------
A command handler binds the dispatch context here (@effects.handler), and from then on:
- Preview mode (
--dry-run;ctx.dry_runis true) -- every
operation below is minted on ctx.effects, with three named exceptions listed at the end of this docstring. Mutations are recorded, never executed, and return strictcli's Unsettled carrier; a caller that forwards the carrier into a later effect keeps the preview going, and a caller that reads a field off it truncates the preview with the framework's own error. Subprocess runs whose argv matches the app's proc_observe_allowlist are observes: they really execute and return real values, which is what lets the release engine's read-then-branch code walk a preview end to end.
- Live mode -- the operations execute through
:mod:rlsbl._effects_direct, with their full rlsbl semantics: per-call timeouts, byte-mode captures, atomic_write_text's temp-file + rename (the only way to rewrite a 0o444 released changelog), and the exist_ok / missing_ok distinctions call sites branch on. The contract's closed method set expresses none of those, so routing a live run through it would silently drop a hang guard or a permission-preserving rename.
The split is by mode, decided before anything runs, and identical on every invocation -- it is not a fallback: nothing here ever tries the handle, fails, and retries elsewhere. What preview mode buys (recording, read-only enforcement, the would-do log) it buys in full; what live mode keeps (timeouts, atomicity, byte fidelity) it keeps in full.
Unbound calls -- the library path -- execute directly too. rlsbl's checks, its programmatic API and its own test suite call these functions outside any command dispatch; there is no handle to mint on there. tests/test_effects_binding.py asserts that every registered command handler carries @effects.handler, so a bound path is never missed by accident.
The three operations that execute in every mode -----------------------------------------------
Each is declared by name, has a reason that is about the operation rather than about convenience, and touches nothing a preview reports on:
- :func:
lock_makedirs/ :func:lock_open/ :func:lock_remove/
:func:lock_rmdir -- the advisory lock is process infrastructure. A preview needs mutual exclusion as much as a live run, fcntl.flock needs a real descriptor, and the lock file is created and deleted inside the same process's lifetime.
- :func:
observe_scratch_files-- operands of an allowlisted observe. The
observe really runs under --dry-run, so recorded stand-ins would leave it reading absent paths and reporting a failure that is about the preview rather than about the project.
- :func:
tcp_connect-- a connect-and-close probe is a network read, and
reads execute in every mode here (see :func:urlopen).
Everything else, including :func:mkdtemp and :func:temp_file, records.
#handler
def handler(fn)Bind the dispatch context to this module for the length of a handler.
Applied innermost on every rlsbl command handler, under the @app.command(...) / @strictcli.flag(...) stack. functools.wraps keeps inspect.signature reporting the wrapped handler's real parameters, so strictcli's guard v2 still validates the declared flags and args against the signature it would have seen without the wrapper -- no forwarding= waiver is needed.
#unsettled
def unsettled(value)True when value is a carrier standing in for a recorded mutation.
The one thing a caller may do with a carrier besides forwarding it into a later effect: recognize it, and decline to read a result that does not exist. rlsbl.utils.run and its gh siblings use it to return the carrier itself instead of reaching for .stdout -- so a preview walks past a mutation whose output nobody needed, and truncates (honestly) at the first caller that does need it.
#previewing
def previewing()True when the current dispatch is previewing rather than executing.
#_handle
def _handle()The strictcli effects handle to mint on, or None to execute directly.
#_p
def _p(path)Render a path operand as text for the handle.
#run
def run(argv, *, cwd=None, env=None, timeout=None, check=False, capture_output=False, text=False, shell=False, resource=None, skip_if_current=None, grant=None)Run a command and return the :class:subprocess.CompletedProcess.
In preview mode the call is minted on ctx.effects.run: an allowlisted observe really executes and returns a CompletedProcess as always, and anything else is recorded and returns the Unsettled carrier standing in for the run that did not happen.
Args:
argv: argument list, or a shell string when shell is true.cwd: working directory for the child process.env: complete environment mapping for the child (None inherits).timeout: seconds beforeTimeoutExpiredis raised.check: raiseCalledProcessErroron a non-zero exit.capture_output: capture stdout/stderr instead of inheriting them.text: decode captured streams as text.shell: run argv through the system shell.resource: opaque token naming what this run produces (preview only).skip_if_current: token the preview annotates the line with, spelling
out that the handler skips this step when the resource is current.
grant: name of a grant declared on the running command, whose reason
is rendered beside the step in the preview.
#spawn
def spawn(argv, *, cwd=None, env=None)Start a child process without waiting for it (PROC_SPAWN).
In preview mode the spawn is recorded and no child is ever forked -- which is the whole reason the regime needs no cross-process mode token.
#gh
def gh(args, *, repo=None, cwd=None, env=None, timeout=None, check=False, capture_output=False, text=False)Invoke the gh CLI.
When repo is given, GH_REPO is injected into a per-call environment copy so gh targets that repository. os.environ is never mutated (critical for thread-safety in watch.py's ThreadPoolExecutor).
#gh_argv
def gh_argv(args)The argv a :func:gh call would execute (for previews and messages).
#urlopen
def urlopen(url, *, timeout=None)Open an HTTP(S) request and return the response object.
url is a URL string or a urllib.request.Request. The return value is a context manager, exactly as urllib.request.urlopen returns.
A GET or HEAD is a network read and executes in every mode -- reads are never effects, and a preview that could not probe a registry would have nothing to preview. Any other method is a network mutation and is minted on ctx.effects.http, so a preview records it instead of performing it.
#tcp_connect
def tcp_connect(host, port, *, timeout=None)Open a TCP connection to host:port and return the socket.
Connect-and-close is a network read: it leaves nothing behind on the far side, so -- exactly like a GET -- it executes in every mode. A deploy health check that could not reach the host under a preview would report a failure that says nothing about the deploy being previewed.
It still lives here rather than in the caller so the network surface stays enumerable in one place: tests/test_effects_chokepoint.py bans socket, http.client and requests everywhere else.
#_RecordedWriter
A file-like sink that mints one write effect when it is closed.
open_write hands streaming writers (json.dump, loops of f.write) a real file object in live mode. The contract has no streaming write, so in preview mode the content accumulates here and the single resulting write carries the byte count the file would have had.
#close
def close(self)#open_write
def open_write(path, mode='w', *, encoding=None, newline=None, resource=None, skip_if_current=None)Open path for writing and return the file object.
A thin open wrapper for streaming writers (json.dump, loops of f.write). Use it as a context manager, exactly like open. Whole-content writers should prefer :func:write_text / :func:atomic_write_text.
#open_exclusive
def open_exclusive(path, *, file_mode=420, encoding='utf-8')Create path open for writing, raising FileExistsError if it exists.
The exclusive create is what closes the TOCTOU between an exists() check and the write that follows it, so call sites branch on FileExistsError rather than re-checking.
#write_text
def write_text(path, content, *, encoding='utf-8', newline=None)Write content to path, truncating any existing file.
#append_text
def append_text(path, content, *, encoding='utf-8')Append content to path, creating it when absent.
#write_bytes
def write_bytes(path, data)Write data to path, truncating any existing file.
#atomic_write_text
def atomic_write_text(path, content, *, encoding='utf-8', preserve_mode=False, file_mode=None, resource=None, skip_if_current=None)Write content to path atomically (temp file + :func:os.replace).
A crash mid-write can never leave a truncated file: the content lands in a sibling temp file that is renamed over the target in one directory operation. Because the rename is a directory operation it also succeeds when path itself is read-only (0o444 changelog files), with no unlock step -- which is why this one never routes through the handle in live mode: the contract's write is a plain write and would fail on those files.
Permission bits of the result, in precedence order:
- file_mode, when given, is applied verbatim.
- preserve_mode keeps an existing target's ORIGINAL bits -- a
deliberately locked file (a 0o444 released changelog, say) must not silently become writable.
- otherwise the umask-derived default, matching plain
open(path, "w").
#_preview_temp_path
def _preview_temp_path(prefix, suffix, dir)A stable, obviously-synthetic path for a temp entry nobody creates.
Preview mode has to hand the caller a path string rather than the Unsettled carrier: call sites join names onto it and pass it as a subprocess cwd, and a carrier would truncate the preview at the first os.path.join instead of at the effect that actually matters. The counter keeps two staging directories in one preview distinguishable in the would-do log.
#mkdtemp
def mkdtemp(*, prefix=None, suffix=None, dir=None)Create a temporary directory and return its path.
Live mode creates it. Preview mode creates NOTHING: the directory is recorded as a mkdir and the synthetic path comes back, so the writes and runs the caller aims at it are recorded against it too and the matching rmtree is recorded rather than performed. Calling tempfile.mkdtemp directly could not do that -- it creates its directory in every mode, which is how claim-name --dry-run used to leave a real staging directory behind on every preview.
#temp_file
def temp_file(content='', *, prefix=None, suffix=None, dir=None, encoding='utf-8')Create a temporary file holding content and return its path.
The caller owns the result and deletes it when done (delete=False semantics). Preview mode creates nothing and records the write.
#observe_scratch_files
def observe_scratch_files(items, *, dir=None)Materialize scratch files that exist ONLY as operands of an observe.
items is a sequence of (content, suffix) pairs; the paths are yielded in the same order and deleted when the block exits.
Real in every mode, like the advisory lock and for the same reason: the consumer is an allowlisted observe (git merge-file -p), which really executes under --dry-run and would read nothing but absent paths from a recorded stand-in -- reporting a merge conflict that does not exist. A preview may not fabricate its own inputs. These files are scratch the block owns end to end: created here, deleted here, never named in the would-do log because nothing about them survives the call.
#makedirs
def makedirs(path, *, exist_ok=False)Create path and any missing parents.
The default mirrors os.makedirs exactly (an existing path raises) so translating a call site never changes its behavior.
#mkdir
def mkdir(path)Create a single directory path (parents must already exist).
#rename
def rename(src, dst)Rename src to dst, failing if dst exists (POSIX: overwrites).
#replace
def replace(src, dst)Atomically move src onto dst, overwriting dst if it exists.
#remove
def remove(path, *, missing_ok=False)Delete the file at path.
#rmdir
def rmdir(path)Remove the empty directory at path.
#removedirs
def removedirs(path)Remove path and then each now-empty parent directory.
#rmtree
def rmtree(path, *, ignore_errors=False)Recursively delete the directory tree at path.
#chmod
def chmod(path, mode)Set the permission bits of path.
#copy_file
def copy_file(src, dst)Copy src to dst, preserving metadata (shutil.copy2).
#copytree
def copytree(src, dst, *, dirs_exist_ok=False, ignore=None, symlinks=False)Recursively copy the directory tree src to dst.
#lock_makedirs
def lock_makedirs(path)Create the advisory lock's containing directory (real in every mode).
#lock_open
def lock_open(path)Open the advisory lock file, returning a REAL file object in every mode.
The caller flocks the returned object's descriptor, so a recorded stand-in would be useless: see the section comment above.
#lock_remove
def lock_remove(path)Delete the advisory lock file (real in every mode).
FileNotFoundError propagates, which is the caller's "already gone" signal.
#lock_rmdir
def lock_rmdir(path)Remove the advisory lock's directory when empty (real in every mode).
OSError propagates when the directory is not empty, which is the caller's "somebody else's files live here" signal.