All notable changes to processkit are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- Reject HTTP responses whose status token is not exactly three ASCII digits
in
wait_for_http, even when a customexpected_statuswould accept a loosely parsed short or long integer. - Update the bundled ProcessKit-rs core to 3.3.3 so restricted or legacy Linux cgroup teardown reports a refused thaw instead of returning success while leaving the process group frozen.
1.5.0 - 2026-08-08
- Add cumulative whole-tree I/O counters (
ProcessGroupStats.io_read_bytesandio_write_bytes) and the optional kernel high-water markProcessGroupStats.peak_process_count. Availability and units remain mechanism-dependent: Windows Job Objects report transfer bytes, Linux cgroup v2 may report block-layer bytes and task counts, and unsupported measurements are exposed asNonerather than a synthetic zero. - Add
ProcessGroup.adopt_external(pid)to bring an already-running external process under group signalling and teardown when only its pid is available. Adoption captures process identity during the call, never reaps the process or exposes its exit status, and documents the Windows, Linux cgroup, POSIX-fallback, and BSD support boundaries. - Add
Command.arg0()/configured_arg0,Command.merge_stderr_in_pipe(),Command.stdout_raw_tee()/stderr_raw_tee(), andRunningProcess.stdout_bytes_seen/stderr_bytes_seen— the last remaining small binding gaps against theprocesskitcore: a Unix-onlyargv[0]override (raisingUnsupportedoff-Unix), a per-stage2>&1 |-equivalent pipeline marker, an undecoded byte-exact stdout/stderr tee alongside the existing decodedstdout_tee()/stderr_tee(), and raw pipe byte counters alongside the existingstdout_line_count/stderr_line_count. - Add partial-tail readiness probes on
RunningProcess—wait_for_output()/await_for_output()for stdout andwait_for_stderr_output()/await_for_stderr_output()for stderr — which match an un-terminated prompt (Password:,(y/N), a REPL>>>) that the line-oriented probes can never see, so a PTY or CLI dialog can wait for a prompt and answer it overtake_stdin(). Non-consuming and repeatable, they raiseWaitTimeouton their own deadline without ever killing the child. - Add
Command.sanitize_vt(),stdout_sanitize_vt(), andstderr_sanitize_vt()for clean captured and streaming terminal text, plusprocesskit run --sanitize-vtfor ANSI-free CLI relay output. - Add a
processkitconsole-script entry point (processkit run -- pytest -x,processkit doctor), alongside the still-supportedpython -m processkitform, sharing the identical exit-code contract. - Add
ProcessGroup.update_limits(*, max_memory=None, max_processes=None, cpu_quota=None)for full-replacement, synchronous, dynamic adjustment of a live group's resource limits without recreating the group. - Add
Command.rlimit(resource, soft, hard)for POSIX per-processsetrlimit(2)limits (RlimitResourceName:"cpu","core","data","file_size","no_file","stack"), raisingUnsupportedoff-POSIX. - Add
RunningProcess.stdout_json_lines()for built-in NDJSON line streaming — an async iterator that decodes each stdout line as a standalone JSON value, the streaming counterpart toCommand.run_json()/arun_json(). - Add nightly benchmark coverage for PTY relay, lifecycle events, completion-order batches, and live Supervisor restart sessions, with platform-specific PTY handling documented.
- Add
wait_for_named_pipe()readiness probing for Windows services, including busy-server detection and symmetricUnsupportedbehavior elsewhere. - Add Linux/Windows child CPU affinity through
Command.cpu_affinity(...)and therun/superviseCLI--cpu-affinityflag. - Add reuse-safe
process_info()/process_is_alive()helpers for bare pids returned by detached launches, supervision, and process groups. - Add spawn-free
host_containment()capability reports, the currentProcessGroup.soft_stop_scopegraceful-stop reach, and the same host details to human-readable and JSONdoctorreports. - Add deterministic cassette secret scrubbing through
RecordReplayRunner.record(..., scrub=)/replay(..., scrub=)and the overridableprocesskit_cassette_scrubberpytest fixture, backed by ProcessKit-rs 3.1.0's symmetric scrub hook. - Add
--ptywith optional--pty-cols/--pty-rowstopython -m processkit run, exposing a merged pseudo-terminal output stream for tools that require a TTY. - Add runnable examples for managed PTY sessions, lifecycle events, completion-order batches, intentionally detached helpers, shell-free pipelines, and hermetic runner/cassette testing seams.
- Expand
python -m processkit runwith fail-loud captured-output limits, direct stdout/stderr file redirects, abrupt-parent-death cleanup, and CPU/I/O priority controls. - Expand
python -m processkit supervisewith per-incarnation timeouts and resource caps, headless Windows launches, and proactive TCP or HTTP health checks.Supervisornow accepts the matchingmax_memory=,max_processes=, andcpu_quota=constructor options. - Add
RunningProcess.stderr_lines()for stderr-only line streaming and direct use with readiness helpers such aswait_for_line. - Add
Command.run_json()/arun_json()with the same typed JSON decoding andInvalidJsondiagnostics asCliClient. - Add a CLI Runner link immediately after the Rust version in the Pages navigation.
- Add opt-in pseudo-terminal launches with
Command.pty(...)and live terminal resizing throughRunningProcess.resize_pty(...). PTY output is a single merged stdout stream, and interactive stdin uses the existing writer API. - Add live
Supervisor.start()/astart()sessions with status snapshots, graceful stop, completion waits, and sync/async context management. - Add
ProcessGroup.stop()/astop()with aShutdownReportdescribing the graceful signal, remaining members, elapsed time, and hard-kill escalation. - Add
RunningProcess.lifecycle_events()for one ordered stream containing the start pid, stdout/stderr lines, and finalOutcome, while preserving the output-onlyoutput_events()contract. - Add Linux disk-I/O scheduling controls through
Command.io_priority(...); launching a configured command on another platform raisesUnsupported. - Add the deliberately uncontained
Command.spawn_detached()escape hatch and pid-onlyDetachedChildfor helpers that must outlive their launcher. - Add repeatable
--env-file PATHsupport to therunandsuperviseCLI commands for docker-styleKEY=VALUEfiles with deterministic overrides. - Add a generated Release notes page to the documentation site, kept in sync with this changelog by local and CI drift checks.
- Widen
InvalidJson.stdoutfromstrtostr | None: it isNoneonly when the exception comes from the newRunningProcess.stdout_json_lines()streaming iterator, which cannot buffer the full payload, whilerun_json()/arun_json()still always populate it. Typed consumers should narrow the type before use; the full diagnostic remains available throughstr(exc)regardless of the source. - Bump the bundled ProcessKit-rs core to 3.3.0, preserving the existing Python
API and feature set while bringing upstream fixes for merged-stderr pipe
teardown, failed PTY-launch cleanup, pipeline pipefail attribution,
process-identity-safe metrics, and cassette version validation. The upstream
ProcessGroupStatsstatistics additions are exposed by the binding as documented under Added. - Bump the bundled ProcessKit-rs core to 3.2.0, preserving the existing Python API and cancellation defaults while bringing upstream compatibility fixes for ConPTY, PTY EOF, readiness, pipelines, environment resolution, and supervision.
- Document and pin idle monitoring per iterator:
stdout_lines()watches stdout activity, while merged-event and stderr-only streams count both pipes. - Exercise Windows ARM64 in regular and nightly test matrices, and add a sharded nightly cargo-mutants signal for the Rust binding layer.
- Reject CR/LF, control, and whitespace characters in a
wait_for_httphost before HTTP serialization, preventing header injection. - Reject CLI resource and restart limit values above their binding widths with
an argparse usage error before constructing a
ProcessGrouporSupervisor. - CLI duration, CPU, backoff, and health-check numeric options now reject non-finite values before constructing a command or supervisor.
- Keep Nightly hardening actionable: its mutation sandbox now includes the
changelog required by release-note drift tests, first-run benchmark history
can initialize its branch without runner-global git identity, the detached
helper example waits for its process to release Windows resources, and the
PTY alias/status tests no longer race short-lived incarnations. Resource-capped
supervision documentation now also reflects the observable contract:
status.pidis unavailable, whilestatus.started_atidentifies the current capture-only incarnation. - Python writer objects used by decoded and raw output tees now retry partial
integer
write()counts to completion without truncating mirrored output;Noneand other non-integer return values remain supported and mean the full buffer was accepted, while invalid integer counts are reported viasys.unraisablehook. - Close the completion hub's socket at the OS level when Python-level cleanup raises, preventing pending anyio-on-asyncio reader tasks and socket-resource warnings after an awaited operation is cancelled during loop shutdown.
- Deliver the output
python -m processkitrelays itself line by line when its own stdout or stderr is a pipe rather than a terminal: therunmodes that re-emit the child's output (--idle-timeout,--output-limit,--pty) and the live tee ofsupervise. A piped reader —| grep, a log collector, a CI step — previously got those lines in ~8 KiB blocks or in one dump when the run ended, unlike the inherited-stream default; both paths now match the live outputdocs/cli.mddescribes. - Block every direct
Commandspawn path under pytest'sno_real_spawnmarker, including JSON, async JSON, and deliberately detached launches. - Preserve completion-hub rearm errors while still attempting every pending awaiter cancellation when secondary cleanup fails.
- Let
python -m processkit superviserun when the parent interpreter has no stdout or stderr stream by omitting the unavailable live-output tee. - Reject an empty key passed through the CLI's
--envflag with the same usage error used for--env-fileentries. - Treat every accepted spelling of piped stdout/stderr consistently when
combining
Command.stdout()orstderr()with PTY mode. - Prevent a concurrent lifecycle-event finisher from making a still-reportable
RunningProcessappear consumed or letting context-manager teardown become a silent no-op. - Stop
Supervisorimmediately on the first failingScriptedRunner.whenpredicate, including under an unbounded restart policy. - Accept bracketed IPv6 literals in
wait_for_httpand keep scoped IPv6 hosts from being percent-encoded twice between socket andHostheader forms. - Prevent the command-line wrapper's own intermediate output (
doctor, idle streaming, diagnostics, and--profile) from producing a traceback when a pipe closes or another output write fails. A vanished receiver stays silent; other write failures use exit code 119. - Keep async batch result conversion and
CliClient.arun_json()parsing on the Python event-loop thread, and make worker-side error conversion safe during interpreter finalization. - Let
OutcomeandFinishedpickle payloads be restored without entering the Tokio runtime, including from supervisor callbacks and post-fork children.
1.4.2 - 2026-07-26
- New
python -m processkitexit code 119, shared byrun,supervise, anddoctor: the command finished, but the wrapper could not deliver its own buffered output (a final flush that failed with e.g.ENOSPC/EIO, or on a stream closed underneath the process). It is reported instead of the code the run would otherwise have returned — including the child's own — because that code would claim a complete, faithfully relayed run. A receiver that simply went away (BrokenPipeError, e.g.... | head) is deliberately not this case and stays silent, as before. See "Exit codes" and "How the wrapper terminates" indocs/cli.md.
-
Migrate the Rust core to processkit 3.0.0 (a breaking major release;
Cargo.tomlnow requires3, resolved to 3.0.1). The Python API is unchanged:OutputEvent,OutputEventsandRunningProcess.output_events()keep their names, signatures and meaning — the core's rename of those types (OutputEvent→ProcessEvent,OutputEvents→ProcessEvents,output_events()→events()) stays an internal detail of the binding, and itsError→Error/ErrorReasonsplit changes nothing about the exception classes or their structured fields. The enabled feature set is unchanged; 3.0's optional new surface (the PTY launch mode, Linux I/O priority, PTY window-size control, the capture-redaction hook, the flat error classifier) is not adopted here.Two user-visible consequences, both confined to
output_events():- The merged event stream became the child's whole lifecycle in the core,
so it now also reports process start and exit. Those non-line events are
filtered out rather than yielded as an
OutputEventwith an emptytext— which would be indistinguishable from a real blank line the child printed.async for ev in proc.output_events()therefore yields exactly what it always did: output lines. What the lifecycle events carry is already available: process start isRunningProcess.pid, and the exit is what the finisher you call afterwards returns. - The core now delivers that stream's terminal event only when the run is
reaped, which means a consumer that drains the stream and then finishes
would deadlock. The documented Python order — iterate fully, then
await proc.afinish()(oraoutcome()) — is unaffected: the binding drives the run's completion itself once the child is observed to exit, so the iterator ends on its own and the finisher afterwards reports that same run. One deliberate narrowing comes with it — see the BREAKING entry below.
- The merged event stream became the child's whole lifecycle in the core,
so it now also reports process start and exit. Those non-line events are
filtered out rather than yielded as an
-
BREAKING —
output()/output_bytes()/profile()(and theira-twins) now raise aProcessErrornamingoutput_events()once that stream has taken the run over, instead of returning the empty captures they used to: it consumed stdout, delivered stderr as events, and (since the 3.0 migration above) completed the run, so there is nothing left for them to capture or to sample. Usefinish()/afinish()(outcome + stderr) oroutcome()/aoutcome()instead — those report such a run either way. Code that calledoutput()afteroutput_events()and used the result got an emptystdout/stderrwith a real outcome; it now has to read that outcome from a finisher.The stream takes the run over as soon as it observes the child exit. Iterating to the end always reaches that point, but an early
breakcan too — out of a command that finished while you were reading it. Break out while the child is still running and nothing has been taken over: the old behaviour stands there (empty captures with a real outcome;profile()samples the rest of the run). Which side of that line a givenbreakfalls on is a matter of the child's timing, not of how the loop is written, so after streaming events reach for a finisher rather than a capture verb. See "Interleaved stdout and stderr" indocs/streaming.md. -
output_limit(max_bytes=...)underon_overflow="error"— and with it thetotal_bytesanOutputTooLargereports — now counts the raw bytes read from the child's output pipe rather than the bytes of the decoded text, following the same change in the Rust core. Line terminators (\n, or both bytes of a CRLF) and bytes that are not valid UTF-8 are charged against the ceiling too, so a cap sized against decoded text raises marginally sooner: by one byte per line for ordinary UTF-8 output, and by more for CRLF or binary-ish output. The drop modes are unaffected —drop_oldest(the default) anddrop_neweststill bound the retained output by decoded line content, as doesSupervisor'scapture_max_bytes=, whosecapture_on_overflowdefaults todrop_oldest. Raw stdout captured byoutput_bytes()is never decoded, so its cap is unchanged in every mode. No API change; re-check anyon_overflow="error"threshold you sized against decoded text. See "Whatmax_bytesactually counts" indocs/commands.md. -
Ctrl+Cthat interruptspython -m processkitoutsiderun/supervise's own guarded blocks — during startup, argument parsing, ordoctor— now reports the documented128 + SIGINT(130) with the same one-lineprocesskit: interruptedmessage those paths print, instead of ending through the interpreter's own unhandled-KeyboardInterruptpath. This makes the Ctrl+C contract uniform across the entry point and platforms; fordoctorit also keeps an interrupted run distinguishable from its valid1verdict ("containment enforced, limits not").
- Fix an intermittent SIGSEGV at interpreter exit after a program's final
processkit
await. The async bridge no longer completes Python Futures from a detached tokio thread throughcall_soon_threadsafe; tokio stores the outcome and wakes one sharedloop.sock_recvdispatcher per event loop, then the event-loop thread converts and resolves it. Repeated stream steps reuse that socket, and the dispatcher closes it after the loop becomes idle rather than leaving a pending receive behind at loop teardown. A short-lived script can now end immediately after anya-prefixed verb without racingPy_FinalizeEx. This also restores ordinary interpreter finalization forpython -m processkit— includingatexithooks and finalizers — instead of the temporaryos._exitworkaround.
1.4.1 - 2026-07-24
- Add
CliClient.run_json(call)/arun_json(call): run a wrapped tool likerun(requiring a zero exit) and return its stdout parsed as JSON — therun(...)+json.loads(...)+ error-mapping boilerplate the many CLIs that emit machine JSON (gh,kubectl,docker,az,jj) otherwise force on every caller. Stdout that does not parse raises a newInvalidJsonexception (aProcessErrorcarrying the client'sprogramand a bounded stdout fragment, with the parser message instr(exc)) instead of a bare, unattributedjson.JSONDecodeError; a non-zero exit still raisesNonZeroExitasrundoes. Both verbs go through the samedefault_env_fn/when-capture pipeline and injectablerunner=seam as the otherCliClientverbs, so they are hermetically testable with aScriptedRunnerand no real process. - Add
Command.idle_timeout(seconds), an inactivity timeout that tears the child down if it produces no stdout/stderr line for that long — for the "hung tool" case a wall-clocktimeout()handles poorly, where a legitimately long job keeps printing progress. It fires as a new, distinctIdleTimeoutexception (aProcessErrorsibling ofTimeout, carryingidle_timeout_seconds), deliberately not the wall-clocktimed_out/Timeoutsignal, so the two timeout classes stay tellable apart and the existing capturedtimed_outcontract is untouched. Enforced on the streaming/interactive surface (start()/astart()+stdout_lines()/output_events()), where the binding drives the per-line output channel; a redirected/inherited stdout is diagnosed by the existing "stdout is not piped" error rather than silently un-watched. Scope note: the one-shot capture verbs (output/run/exit_code/probeand theira-twins),Pipeline, andSupervisordo not enforce it — processkit 2.3.x has no native idle-timeout to observe per-line activity mid-run through those paths, so honoring it there awaits upstream crate support; the setting is carried on the command regardless. - Add
python -m processkit run --idle-timeout SECONDS: kills the child and exits 123 (distinct from--timeout's 124) if it produces no output line for that long. Because idle monitoring rides the per-line channel, this flag pipes and re-emits the child's stdout/stderr (decoded, one line at a time) instead of inheriting them raw, and is incompatible with--profile. The flag is also present onsupervisefor parity but is a usage error there until upstreamSupervisoridle-timeout support lands (its incarnations run through one-shot verbs the idle watchdog cannot observe).
wait_for_httpnow forms a correct HTTP/1.1 request line for edge-casehost/pathvalues: an IPv6 literalhost(e.g."::1") is bracketed in theHostheader per RFC 9112/3986 (Host: [::1]:8080, not the previously ambiguousHost: ::1:8080), and apathcontaining whitespace, a control character (including CR/LF — previously a header-injection-shaped hazard), or a character outside latin-1 now raisesValueErrorup front, before any connection is attempted, instead of silently corrupting the request line or raising a rawUnicodeEncodeError.
1.4.0 - 2026-07-23
- Add an actionlint CI gate for semantic GitHub Actions and shell-script checks
- Add the
superviseCLI subcommand with restart-policy and backoff flags. - Add
wait_for_http(host, port, path="/", *, timeout, interval=0.05, expected_status=None), a readiness helper that polls an HTTP endpoint (a hand-rolled GET over asyncio streams, no new dependency) and succeeds only on an accepted status code (any 2xx by default; a set/range or a predicate overrides) — a stronger signal thanwait_for_portfor a server that accepts connections while still warming up - Add
aoutput_as_completed/aoutput_as_completed_bytes, the streaming counterpart to theaoutput_allfamily: an async iterator that yields each(index, result)pair as its command finishes rather than waiting for the whole batch, with the same hard concurrency cap and no-orphan teardown on cancellation or early exit - Add
python -m processkit run --profile [FILE], emitting a one-line JSON resource profile (duration, CPU time, peak memory, average CPU cores, sample count, exit code/signal, timed-out flag) after the child exits — to stderr ifFILEis omitted, or written toFILEotherwise - Add
python -m processkit run --create-no-window, applyingCommand.create_no_window()to the child so the wrapper does not create a console window on Windows — a no-op outside Windows (same as the underlying binding method) - Add
Command.stdout_file()/stderr_file(), spawn-time direct-redirect sinks that send a stream straight to a file with no parent-side pump or capture in between (append=False, the default, truncates the file on each spawn;append=Trueappends — e.g. a shared log acrossSupervisorincarnations orretry()attempts). A file-redirected stdout makesoutput()/run()/output_bytes()(and their async twins) raise the usual "not piped"ProcessError, butexit_code()/probe()still work since they never touch the stdout pipe; a file-redirected stderr leavesoutput()working, withresult.stderrcoming back empty - Add
ProcessGroup.members_info()/MemberInfo, an enriched process-tree snapshot alongsidemembers(): each pid comes with best-effortppid/exe_name/start_timemetadata (Nonewherever the platform can't report it).exe_nameis a short image name, not a path, andstart_timeis an opaque, platform-specific identity token, not wall-clock — its sole use is pairing withpidacross two snapshots to tell a recycled pid apart from the original - Add
Command.windows_graceful_ctrl_break(), an opt-in Windows-only graceful shutdown: at a graceful timeout (timeout_grace) or a group shutdown it sends the direct console child aCTRL_BREAKbefore the hardTerminateJobObjectfallback, giving a child that handles it a chance to exit cleanly first. Console-only (inert undercreate_no_window/ detached) and a harmless no-op outside Windows - Add opt-in
Supervisorliveness health checks via three new keyword-only constructor parameters:health_check(a synchronous() -> boolcallable),health_check_interval(required alongside it), andhealth_check_failures. Afterhealth_check_failuresconsecutive probe failures the supervisor force-restarts the run; each force-restart increments the newSupervisionOutcome.liveness_killscounter, and the final such stop underrestart="never"reportsSupervisionOutcome.stopped == "unhealthy" - Add
Command.kill_on_parent_death_scope(), a read-only capability query reporting the scope of parent-death cleanup the current platform actually achieves when the owner dies abruptly, as a string:"whole_tree"on Windows (the Job Object reaps the whole tree on owner death),"direct_child_only"on Linux (PR_SET_PDEATHSIGreaches only the direct child; grandchildren survive), or"unsupported"on macOS/BSD (nopdeathsigequivalent). A static query fixed at build time — read it off the class or any instance, with no priorkill_on_parent_death()call — so a caller can state the real reach of the best-effort hardening instead of overpromising a whole-tree guarantee the OS cannot keep
- Refresh the GitHub Pages landing page from the README: its cover, status badges, no-orphan introduction, runnable example, and capability summary now appear before the guide index.
- Bump the processkit dependency to 2.3.1 (lockfile pinned via
cargo update -p processkit --precise 2.3.1; the Cargo.toml requirement stays at the broad2.3range). 2.3.1 also added new upstream public surface (Command stdout/stderr file-redirect sinks,windows_graceful_ctrl_break,ProcessGroup::members_info/MemberInfo,Supervisorliveness health checks) that this binding has since adopted — see theAddedentries above. - Bump the processkit dependency to 2.3.2 (lockfile pinned via
cargo update -p processkit --precise 2.3.2; the Cargo.toml requirement stays at the broad2.3range). 2.3.2 adds new upstream public surface (Command::kill_on_parent_death_scopeand theParentDeathCleanupenum it returns) that this binding adopts — see theAddedentry above.
- Correct the pipeline documentation to describe per-stage kill-on-drop sub-groups, chain-wide teardown fan-out, and composite timeout attribution, matching the processkit 2.3.x core.
- Fix rendered mdBook links that pointed at the nonexistent
README.html, correct the uvloop section anchor, and align contributor/release instructions with the current mdBook-to-GitHub-Pages workflow. - Make the documented
justrecipes run on Windows by selecting PowerShell 7 instead of relying on an unavailableshexecutable.
1.3.0 - 2026-07-19
- Add
sample_stats(group, every), a pure-Python async generator for liveProcessGroupmonitoring: a fused, periodic series ofProcessGroupStatssnapshots built on top ofProcessGroup.stats()
1.2.4 - 2026-07-12
- Add Open Graph and Twitter Card metadata to the docs site
- Add real Rust crate and .NET documentation links
- Add table border, header fill, and row striping to match the reference site
- Publish the documentation site to GitHub Pages on push to main
- Pin the GitHub Pages deploy actions to a commit SHA
- Reserve navigation placeholders for the Rust crate and .NET variant
- Restyle the docs site navigation and tables to match the ProcessKit look
- Move the Rust crate and .NET nav entries right after Home, then link them directly to their docs sites
- Match the reference site's pinned nav-group title styling, typography, CSS, and table borders/header fill/row striping/dark-theme colors (navy, not coal) more closely
- Move the implementation switcher above Home, then the Rust/Python/.NET version switcher above Overview, in the docs nav
- Rebuild the docs site with mdBook to match the ProcessKit family
- Render API-reference signatures as text and fix the generator's griffe types
- Give a clear diagnostic for
parse_signalwith out-of-range ints and floats, and align the property test with the corrected diagnostic - Convert
Supervisorto the frozen +Mutex<Option<...>>pattern - Unify the named-preset parsers (and their property tests) on case-insensitive matching
- Bump the processkit dependency to 2.2.4
- Fix wide-table scrolling specificity and code word-breaking
- Fix doc comment list-bullet misparse and reformat long test line
- Drop the external crates.io link from the Rust crate placeholder
- Remove stray trailing blank line from mkdocs.yml
1.2.3 - 2026-07-11
- Bump the processkit dependency to 2.2.3
- Fix broken repo-relative README links for PyPI rendering
1.2.2 - 2026-07-10
ProcessResultandSupervisionOutcomeare no longer picklable — pickling either now raisesTypeError(they were advertised as picklable in 1.2.0). Their equality is the underlyingprocesskitcrate's own comparison, which also spans a command's configuredtimeoutand acceptedsuccess_codes— two fields the crate exposes through no accessor. A pickle could not read them back to reconstruct them, so a result from a command that set.timeout(...)or.success_codes(...)unpickled unequal to its original (identical visible fields andhash(), but!=), silently breaking the round-trip invariant a picklable value type promises. Rather than hand back a subtly-wrong value, both refuse loudly, matchingBytesResult/RunProfile.OutcomeandFinishedremain picklable and round-trip exactly (anOutcomeis fully determined by its Python-visiblecode/signal/timed_out; aFinishedadds only itsstderr). To move a captured result across a process boundary — e.g. back from aconcurrent.futures.ProcessPoolExecutorworker — pickleresult.outcome(anOutcome), or persistresult.stdout/.stderr/.codeyourself.
CancellationToken's docstring (Rust doc comment and the.pyistub) no longer claims that achild_token()shares the same cancellation state as its parent and siblings. The actual, already-tested behavior is parent-to-child only: a parent cancels its children, but cancelling a child never affects the parent or its other children.processkit.__version__now matches its own docstring: the first access computes it viaimportlib.metadata.version()and caches the result (including the source-tree"unknown"outcome) for every later access, instead of re-scanning package metadata on every read. The first access is single-flight even under concurrent readers on a free-threaded build.CliClientdefault_env_fnresolvers are now fail-closed: a resolver that raises or returns a non-straborts the triggeringcommand()/verb with that exception, before the runner is reached, so no process is spawned. Previously the failure was only reported via the unraisable hook and the resolved value fell back to an empty string — running the command with a silently-missing credential. Applies uniformly tocommand(), the sync verbs, and the async verbs; a resolver whose key is already set by an explicit per-commandenv()or a staticdefault_envstill never runs (and so cannot abort the call).
1.2.1 - 2026-07-09
- Add
Command.prefer_local, exposing crate 2.2's bare-name resolution override - Add a runnable
Command.prefer_localusage example todocs/commands.md - Add
ProcessStdin.send_controlfor interactive control-byte delivery
- Broaden the
Command.prioritydocstring privilege caveat to coverabove_normaland a niced-parentnormal - Bump the processkit dependency requirement and lockfile to 2.2.0
- Apply rustfmt to the
send_controlsignature - Bump the processkit dependency to 2.2.1
- Fix Windows-incompatible relative path-form assertion in the prefer_local example
1.2.0 - 2026-07-08
Command.stdout_tee/Command.stderr_teenow accept a Python writer object (anything with a callablewrite()—io.StringIO,sys.stderr, a text-mode file, a logger wrapper) in addition to a file path, mirroring the child's output straight into your own console/buffer/logger while still capturing it. Each decoded line (plus a"\n") is passed towrite()as astrvia an async-write bridge: every write is dispatched to the runtime's blocking pool (re-acquiring the GIL there) and awaited on the capture pump, so a slow — even sleeping —write()applies backpressure without blocking the event loop or deadlocking the runtime. The object is discriminated from a path by exposingwrite(neitherstrnorpathlib.Pathdoes) and is never closed for you;append=Trueis meaningful only for a file path and raisesValueErrorif combined with a writer. Awrite()exception disables the tee for the rest of the run (atracingwarning underenable_logging(), the same isolation as the file tee) and is additionally reported viasys.unraisablehook— the run and its captured result are unaffected. The previous "a file path only, an arbitrary Python writer is deliberately not supported" restriction is lifted. Seedocs/streaming.md#tee-output-to-a-file.Command.on_stdout_line(callback)/Command.on_stderr_line(callback): aCallable[[str], None]invoked with every decoded line as it is produced — the way to give the synchronous surface (.output()/.run()) live progress observation during an otherwise-blocking call, without losing the full capture. Also fires on the async verbs and on a streamed run (start()/astart()+stdout_lines()/output_events()); at most one handler per stream (a repeat call replaces the previous one); a raising callback is reported viasys.unraisablehookrather than propagated or breaking the run. Inert understdout("inherit")/stdout("null")(resp.stderr(...)) and, foron_stdout_lineonly, underoutput_bytes()(which captures stdout raw, bypassing the line pump — stderr still goes through it, soon_stderr_linestill fires there). Seedocs/streaming.md#live-per-line-callbacks.- A
benchmarks/suite (pytest-benchmark, newbenchdependency-group) measuring spawn+capture overhead againstsubprocess/asyncio.subprocess,ProcessGroupstart/exit, line-streaming throughput, andoutput_allconcurrency scaling — dev tooling only, no public API change. Runs nightly via thebenchjob innightly-hardening.yml, never in the PR gate; seebenchmarks/README.md. wait_for_path(path, *, timeout, interval=0.05)— a new async readiness helper alongsidewait_until/wait_for_port/wait_for_line, polling until a filesystem path appears (a unix socket, a pid file, or any other marker a daemon creates once ready). Same timeout/interval discipline as its siblings (NaN/negativetimeoutand non-positiveintervalraiseValueError;timeout=0still checks the path at least once) and raisesWaitTimeout(also aTimeoutError) on expiry, now carrying apathfield (WaitTimeout.__init__gained apath: StrPath | None = Noneparameter).python -m processkit run -- <cmd> [args...]: a CLI wrapper that runs a command inside a kill-on-exitProcessGroupwith inherited stdio, for shell scripts and CI steps with no Python to write. Supports--timeout,--timeout-grace,--max-memory,--max-processes, and--cpu-quota; the child's own exit code is passed through unchanged, and a timeout / missing program / rejected resource limit is reported as a one-line stderr message with a documented, GNU-timeout-style exit code instead of a traceback. Seedocs/cli.md.Finishedgainstimed_outandsignalproperties that delegate to the nestedoutcome, so it now mirrorsOutcomefully — matchingcodeandexited_zero, which were already exposed directly — instead of requiringfinished.outcome.timed_out/finished.outcome.signal.Command.stdin_file(path)— feed the child's stdin from a file, streamed in chunks by the crate rather than read whole into a Pythonbytesobject, for large inputs (apsqldump, atararchive, a multi-gigabyte log). Like most other builder methods (stdout_tee/stderr_teeare the deliberate exception), it does not touch the filesystem at build time — the path is opened lazily at spawn, so a missing/unreadable file surfaces as the genericProcessErrorfrom the run/output verb, notFileNotFoundError. Reusable across retries/re-runs, likestdin_bytes/stdin_text; the usual "last stdin method wins" rule applies alongsidestdin_bytes()/stdin_text()/keep_stdin_open().ProcessResultandBytesResultgaindiagnostic: str | None(stderr if it carries text, otherwise stdout, otherwiseNone— the same preference order asNonZeroExit/Timeout/Signalled.diagnosticon the exceptions) andoutcome: Outcome(the same valueRunProfile.outcomeand the checking-verb exceptions expose). A result held as data (output()/output_bytes()withoutensure_success()) no longer requires re-deriving these by hand. (Anoutput_contains_anyconvenience was considered alongside these and rejected: the underlyingprocesskitcrate has no such method, so it wouldn't be parity with the crate or the exceptions likediagnostic/outcomeare — and it's a one-liner callers can already write themselves viacombined, e.g.any(s in result.combined for s in needles).)- Value semantics for the result types:
ProcessResult,BytesResult,Outcome,Finished,RunProfile, andSupervisionOutcomenow define__eq__(comparing every field the underlyingprocesskitcrate's ownPartialEqcompares — notobject's previous identity comparison) and a consistent__hash__(none of their fields are stored floats, so hashing is sound), so two results can now be compared with==and used in asetor as adictkey without a manual field-by-field comparison.ProcessResult/Outcome/Finished/SupervisionOutcomeare also picklable — e.g. to return aProcessResultfrom aconcurrent.futures.ProcessPoolExecutorworker. The underlying crate has no public constructor for any of these types, so unpickling reconstructs one viaprocesskit.testing.ScriptedRunner(an in-memory, no-subprocess replay) — faithful for every field the Python binding exposes, but a command that customizedsuccess_codes()/timeout()is not guaranteed to compare==its original after a round trip (those two fields have no Python accessor to reconstruct exactly).BytesResult(raw stdout may not be valid UTF-8, and the only reconstruction channel available is text-only) andRunProfile(reports live OS resource-sampling telemetry with no synthesis path outside an actual monitored run) explicitly do not support pickling and raise a clearTypeErrorrather than failing silently or fabricating the missing data.
CliClient(default_env_fn=...)now validates that every value in the mapping is callable at construction time, raisingTypeError(naming the offending key) immediately instead of silently accepting a non-callable value and only discovering the mistake later — once per built command, as an unraisable-hook warning plus an always-empty resolved env var. Valid callables behave exactly as before.
Args(from processkit import Args) no longer rejects the single most common real call site — a variable annotatedlist[str](orlist[pathlib.Path]/list[os.PathLike[str]]) passed straight through to an argv-like parameter, e.g.args: list[str] = [...]; cmd.args(args).listis invariant, so the originallist[StrPath] | tuple[StrPath, ...]spelling only ever accepted alist[StrPath]-annotated variable or a literal, not alist[str]/list[Path]/list[os.PathLike[str]]-annotated one, even though the values are runtime-identical — a static-typing-only false positive with no runtime effect.Argsis now a union of the concrete homogeneous list shapes (list[str],list[Path],list[os.PathLike[str]]) instead of the single invariantlist[StrPath]; a mixedstr/os.PathLike[str]argv is still accepted, now spelled as atuplerather than alistliteral (e.g.cmd.args((path, "literal"))). A barestrstill does not type-check asArgs(unchanged; see theArgsdocstring).
1.1.1 - 2026-07-06
Command.line_terminator(mode)/Command.stdout_line_terminator(mode)/Command.stderr_line_terminator(mode)— choose where the line pump splits a stream into lines:"newline"(default, splits on\nonly, unchanged behavior) or"carriage_return"(also splits on a bare\r, delivering each frame of acurl/pip/apt-style redrawn-in-place progress bar live instead of piling it all up into one line at EOF).line_terminatorsets both streams at once; thestdout_/stderr_variants target one stream, leaving the other's framing untouched. Bindsprocesskit2.1.0'sCommand::line_terminator/stdout_line_terminator/stderr_line_terminator(LineTerminator), exposed as the newLineTerminatorNamestring-preset alias.testing.Reply.with_stderr(text)— attach stderr to a scripted reply, including a successful (Reply.ok(...)) one, without resorting toReply.fail(0, ...)as a workaround.processkit.testing.DryRunRunner— a render-only test double that never spawns a process: every verb renders the command to its display-quoted line (via the crate's ownCommand.command_line()quoting) and returns a synthetic success, the seam behind a tool's own--dry-run/--echomode. Inspect the rendered lines withcommands()/only_command(), or stream them live as each call happens withon_invocation(callback). Works at every runner injection point (output_alland friends,Supervisor,CliClient,runner=), like the other doubles. (Bindsprocesskit2.1.0'stesting::DryRunRunner.)Supervisor(..., give_up_when=classifier)— classify a permanent failure so supervision gives up instead of restarting a crash forever, reporting the newSupervisionOutcome.stopped == "gave_up". Bound as a Python callable (likestop_when, not aretry_if-style string preset — the crate's classifier is a per-attempt closure, and a useful verdict is result-specific, not a fixed vocabulary). The callback receives one argument mirroring the crate'sGiveUpAttemptsum type, dispatched withisinstance: aProcessResultfor a crashed run that produced a result (classify by e.g.attempt.code), or aProcessErrorsubclass for a launch that never produced one (classify by e.g.isinstance(attempt, ProcessNotFound)for a missing binary). Consulted only for a crash the policy would otherwise restart, ahead ofmax_restartsand the failure-storm guard. A crash verdict stops withstopped == "gave_up"; a launch-failure verdict has no result to report and surfaces the classified error directly fromrun()/arun(). Off by default — a permanent failure restarts as before. The classifier runs on the runtime thread under the GIL; a raising or non-bool callback reads as "not permanent" (keep restarting) and is surfaced via the unraisable hook, never silently swallowed.Command.umask(mask)— set the child's POSIX file-mode creation mask; on a non-POSIX platform the run raisesUnsupported, matching the existinguid/gid/groups/setsidverbs.Command.priority(level)— set the child's CPU-scheduling priority, one of the named presets"idle","below_normal","normal","above_normal","high"(newPrioritytype alias). Unixnice/setpriority, Windows priority class — unlike the privilege/POSIX-only verbs above, supported on both platform families, so it never raisesUnsupported. Raising to"high"on Unix withoutCAP_SYS_NICE/root raisesPermissionDeniedinstead of silently applying a lower priority.Command.timeout_opt(seconds)— liketimeout(), but takesfloat | None, convenient when a timeout arrives from config asOptional[float]: a value behaves exactly liketimeout(seconds),Noneclears a priortimeout()exactly likeno_timeout().Command.retry_never()— explicitly opt one command out of retrying, even when it runs through aCliClientconfigured with adefault_retry_if.NonZeroExit/Timeout/Signallednow carry astdout_bytes: bytes | Nonefield — the exact raw stdout bytes when the error came from a checking verb overoutput_bytes()(e.g.BytesResult.ensure_success()),Noneon the text path (run()/output()) wherestdoutis already the complete decoded text. When present, these are the exact pre-decode bytesstdoutis a lossy UTF-8 view of (they differ only for non-UTF-8 output). Binds processkit 2.1.0'sError::stdout_bytes().
Command.output_limit(max_bytes=...)'s byte ceiling now also bounds the raw stdout ofoutput_bytes()/aoutput_bytes(), matching processkit 2.1.0 — previously a byte cap bounded only the line-pumped stderr and raw stdout was always unbounded. Underon_overflow="error"an over-capoutput_bytes()run now raisesOutputTooLarge(withmax_lines=None— raw bytes have no line count) where it once returned all bytes; under a drop mode its retained bytes are bounded to a head/tail withBytesResult.truncatedset. Amax_linescap still never bounds raw stdout. This applies to every inheritedoutput_bytesconsumer that runs aCommandbuilt with such a policy (CliClient,Pipeline,RunningProcess,ProcessGroup, and therunner=doubles). TheSupervisorcapture policy is unaffected — it captures line-based output only and has nooutput_bytesverb.
1.1.0 - 2026-07-06
RunningProcess's consuming verbs now come in a sync/async pair, like everywhere else in this library, instead of being coroutine-only. Migration:await proc.wait()→await proc.aoutcome()(renamed —awaitis a reserved word, so the async twin of the new syncoutcome()couldn't be calledawait());await proc.finish()→await proc.afinish();await proc.output()→await proc.aoutput();await proc.output_bytes()→await proc.aoutput_bytes();await proc.profile(...)→await proc.aprofile(...);await proc.shutdown(...)→await proc.ashutdown(...). Each bare name is now a new synchronous method (proc.outcome(),proc.finish(),proc.output(),proc.output_bytes(),proc.profile(...),proc.shutdown(...)), making a handle from the synchronousCommand.start()/Runner.start()genuinely usable end-to-end with no event loop at all — not just for the monitor-and-kill()pattern. No aliasing was possible (the old bare names now mean something different — synchronous — so keeping them pointing at the old async behavior would be actively misleading, not merely redundant).RunningProcess.shutdown()/ashutdown()also now matchProcessGroup.shutdown()/ashutdown()'s naming exactly, closing a trap where the same verb name meant "call it" on one class but "await it" on the other.ProcessRunnerno longer includesstart/astart— it is now the capture/check verb surface only (output/run/exit_code/probeand theira-prefixed twins). A newStreamingRunner(ProcessRunner)protocol addsstart/astartback for code that also needs a liveRunningProcesshandle. Migration: annotate an injection point that only calls the capture/check verbs asProcessRunner(now narrower, easier for a custom double to satisfy); annotate one that also callsstart/astartasStreamingRunner. Every built-in runner (Runner,ScriptedRunner,RecordingRunner,RecordReplayRunner) satisfiesStreamingRunner(and thereforeProcessRunnertoo), so existing injected-runner call sites are unaffected — only code that annotated againstProcessRunnerexpectingstart/astartto be part of it needs to switch toStreamingRunner. The internal_runner.pymodule (never part of the public import path) is renamed_protocols.pyto reflect holding two protocols now, not one.wait_for()is renamedwait_until()— the old name collided withasyncio.wait_for, which bounds one awaitable, not a polled predicate (different semantics entirely). Migration:await wait_for(...)→await wait_until(...), same arguments. No alias was kept — await_foralias sitting next toasyncio.wait_forin the same import line would perpetuate exactly the confusion this rename fixes. All three readiness helpers (wait_until,wait_for_port,wait_for_line) now raiseWaitTimeout(ProcessError,TimeoutError) instead of a bareTimeoutErroron their own deadline — still catchable asexcept TimeoutError, but now carryingtimeout_seconds(and, forwait_for_port,host/port) as structured fields instead of only a message string.
- A pytest plugin, autoloaded via a
pytest11entry point in every pytest session where processkit is installed (nothing to add toconftest.py; the plugin module is pure Python and import-safe). It exposes theprocesskit.testingdoubles as ready-made fixtures —scripted_runner(a freshScriptedRunner),recording_runner(aRecordingRunnerspy replyingReply.ok(""), the neutral default), andrecord_replay_runner(aRecordReplayRunnerbound to a per-test cassette) — so injecting a test double is a single fixture parameter. The cassette fixture is replay-by-default with a vcr-style switch to record (--processkit-recordCLI flag, then thePROCESSKIT_RECORDenv var, then theprocesskit_recordini option, in that precedence); its file lives under the test'stmp_pathunless theprocesskit_cassette_dirini option points at a kept directory, and its name is derived deterministically from the test's node id. A@pytest.mark.no_real_spawnmarker (registered so it passes--strict-markers) makes any real spawn throughCommand/Pipeline/Runner/ProcessGroupinside the marked test fail loudly, while injected doubles keep working. Documented indocs/testing.mdand the cookbook. ArgsandReadableBuffertype aliases (from processkit import Args, ReadableBuffer).Args(list[StrPath] | tuple[StrPath, ...]) replacesSequence[str]/Sequence[StrPath]on every argv-like parameter (Command'sargs,ScriptedRunner.on()/on_sequence()'sprefix,CliClient.command()/its verbs) — deliberately notSequence[StrPath], sincestris itself structurally aSequence[str](each character is astr), so that spelling let a bare string slip through everywhere an argv list was expected (cmd.args("--flag")type-checked, then exploded into one argument per character at runtime). This is a static-typing-only tightening — runtime behavior (and any caller not using mypy) is unaffected; a mypy-strict caller passing something other than alist/tuple(an arbitrary customSequence) at one of these call sites may need to wrap it inlist(...).ReadableBuffer(bytes | bytearray | memoryview) replaces the too-narrowbytesonCommand.stdin_bytes()/ProcessStdin.write()— both already acceptedbytearray/memoryviewat runtime (PyO3's buffer-protocol extraction), so this only catches up the stub to reality, no runtime change.CliClient'scommand()and every verb (run/output/output_bytes/exit_code/probe,a-prefixed twins) now accept astror anyos.PathLike[str]for each argv element, unified withCommand's ownarg/argstyping — previouslyCliClientwasstr-only, so apathlib.Pathargument needed a manualstr()there but not onCommand.- Documented explicitly:
Timeout,ProcessNotFound, andPermissionDeniedare transitivelyOSErrorsubclasses too (since their builtin second base —TimeoutError/FileNotFoundError/PermissionError— has itself been anOSErrorsubclass since Python 3.3), soexcept OSErrorcatches all three alongsideexcept ProcessError. No behavior change — this was already true; it just wasn't written down anywhere. - Fixed:
PermissionDenied.programis now typedstr | None(wasstr) and reliably readsNone— not a missing-attributeAttributeError— on the broader OS-refusal path with no program to name (is_permission_denied()also classifies a program-lessIofailure, e.g. a group signal the OS refused, alongside the ordinary spawn-time denial that does name one). Mirrors the class-level default already used forTimeout.timeout_seconds. CancellationToken— a portable cancel switch:Command.cancel_on(token)(replaces any prior token — last write wins),Pipeline.cancel_on(token)(gap-fill — a stage with its own explicit token keeps it), andCliClient'sdefault_cancel_on=(also gap-fill) tear the run/chain down whentokenfires, surfacing the newCancelledexception.token.cancel()is idempotent;token.child_token()derives a token cancelled automatically with its parent but cancellable independently, for scoping a broader shutdown token down to one operation.Cancelledexception — a run deliberately cancelled via aCancellationToken. Previously such a cancellation surfaced only as a plainProcessError(no dedicated subclass existed sincecancel_onhad no binding yet); now a distinct, terminal exception — never retried byCommand.retry()or restarted bySupervisor, matching the crate's own contract (a cancelled token stays cancelled forever, so a replay could only fail the same way).ScriptedRunner.when(predicate, reply)— reply withreplywhenpredicate(command)accepts it, for a match that isn't a plain argv prefix (on()) — e.g. inspectingcwd/arguments/flags viaCommand's own inspection accessors.predicateis infallible from the crate's perspective, likeSupervisor.stop_when: a raising or non-boolpredicate reads as "does not match", surfaced via the unraisable hook.Reply.with_line_delay(seconds)— sleepsecondsbefore each scripted stdout line on astart()/astart()run, so a hermetic streaming test can observe genuinely incremental delivery instead of every line arriving at once.RecordingRunner.new(inner)— wrap any ofRunner,ScriptedRunner,RecordReplayRunner, or anotherRecordingRunner, recording every call made through it. The general form behind the existingreplying(reply)(a recorder whose inner runner is always a freshScriptedRunnerreplying with one cannedReply) —new()lets a test combine recording with a double it already built (e.g. aRecordReplayRunnercassette) or with the realRunner.ProcessGroupis now itself a runner:group.output(cmd)/.run(cmd)/.exit_code(cmd)/.probe(cmd)/.output_bytes(cmd)(+a-prefixed twins) runcmdas a shared member of the group (not a standalone private tree) — the same verb surfaceRunner/ScriptedRunner/… expose, for code written against that seam that should route every spawn through one shared group. (Not registered as arunner=injection target — aProcessGroupcarries real OS resources and is injected directly by callers who already hold one, not through that kwarg seam.)output_all()/aoutput_all()/output_all_bytes()/aoutput_all_bytes()now rejectconcurrency=0withValueErrorinstead of silently clamping it to1(a confusing "asked for none, got some anyway").Command.no_timeout()— run without a timeout, and (unlike simply leaving it unset) opt out of a client-wideCliClientdefault_timeoutgap-fill. Clears a prior.timeout(); the last of the two wins.Command.stdout_tee(path, *, append=False)/stderr_tee(path, *, append=False)— tee every decoded line of the stream to a file as it is produced (the line plus a\n, CRLF normalized) while the run also keeps capturing the full output: the one-line way to "stream a log to a file and still get the capturedProcessResult", without a manual loop overstdout_lines(). The sink is a file path (str/os.PathLike[str]); teeing to an arbitrary Python object as a live async writer is deliberately not supported yet (a separate, deferred feature — dispatching each line to a thread, re-acquiring the GIL, honoring backpressure across the FFI boundary is its own scope). The file is opened at build time — the crate takes a concrete sink, not a lazy factory — so an unopenable path (missing parent directory, a directory, a permission denial) raises the matchingOSErrorsubclass right at the builder call, not at run; it is created/truncated by default, or appended to withappend=True. Inherited crate semantics: a slow sink applies backpressure (it does not block the runtime); a tee write error disables the tee for the rest of the run without breaking the run or its captured result (warned underenable_logging()); and the tee is inert unless the line pump runs — a no-op understdout("inherit")/stdout("null")and underoutput_bytes()(raw capture), working with the line verbs (output()/aoutput()/run(),start()+stdout_lines()/output_events()). A reused command's shared sink handle appends across sequential re-runs (retries,Supervisorincarnations) and interleaves across concurrent pipeline stages.Command.command_line()— render the command as a single shell-quoted line for display (logs, error messages, a dry-run echo); includes argv, unlike the redactedrepr(). Never used to actually execute anything. PlusCommand.program/Command.argumentsread-only properties (namedarguments, notargs— that name is already the builder method that appends args).Command.unchecked_in_pipe()— exempt a command, as aPipelinestage, from pipefail attribution (its unclean exit, including aSIGPIPE, is skipped when the chain decides what to report); a no-op outside aPipeline.ProcessResult.ensure_success()/BytesResult.ensure_success()— raise the same exception a checking verb would if the result's exit isn't insuccess_codes, for turning an already-capturedoutput()/output_bytes()result into an error after the fact. Returnsselfunchanged on success, so it composes:cmd.output().ensure_success().stdout..diagnostic: str | NoneonNonZeroExit,Timeout, andSignalled— the best human-facing message (captured stderr if it carries text, otherwise captured stdout;Noneif both streams are blank), so a genericexcept ProcessErrorhandler can log/report something useful without knowing which of the three stream-bearing exceptions it caught.Command.timeout_signal()/ProcessGroup.signal()now also accept a raw platform signal number (anint), not just a portable name — the crate'sSignal::Otherescape hatch (Unix only; a raw number isUnsupportedon Windows like every non-Killsignal, same as the named variants).CliClient.command(args)— aCommandforprogram <args>with the client's defaults (timeout/env/retry/cancel) pre-applied; chain more builders for a customized one-off call, then pass the result torun()/output()/ … (which now accept either a plain arg list or such aCommand— theIntoCommandpath). An explicit setting on the returnedCommandalways wins over the client's default; only the gaps get filled.CliClient'sdefault_env_fn={key: resolver, ...}— a per-key zero-arg resolver called fresh each time a command is built (not each retry attempt) to fill an environment variable, for a credential that should be read freshly rather than baked in once at client-construction time (a staticdefault_envvalue). An explicit per-callenv/default_envat the same key still wins — this only fills the gap.Supervisor'scapture_max_bytes=/capture_max_lines=/capture_on_overflow=— bound (or widen) the output captured from each supervised incarnation; the default is already a sensible bounded tail (Command.output_limit's own kwargs, applied here as constructor kwargs instead of a builder method, per the config-struct convention). Setting any of the three requires at least one of the two cap sizes, mirroringoutput_limit's own validation.Command.retry(retry_if, *, max_retries=, initial_backoff=, multiplier=, max_backoff=, jitter=)andCliClient'sdefault_retry_if=(+default_max_retries=/default_initial_backoff=/default_multiplier=/default_max_backoff=/default_jitter=) — retry a run with exponential backoff, a cap, and jitter, whileretry_ifaccepts the resulting error. Honored only by the success-checking verbs (run/exit_code/probe, andCliClient's equivalents); ignored bySupervisor(its ownRestartPolicygoverns keep-alive restarts — a different concern),output_all, andPipeline. Bound as kwargs over the crate'sRetryPolicy, not a mirrored pyclass (the established config-struct convention — seeAGENTS.md).retry_ifis a named preset over the crate's own error-classification accessors, not an arbitrary Python callable crossing the FFI boundary:"transient"(a bare-retry-clears spawn/IO condition — interrupted, would-block, a busy resource) or"transient_or_timeout"(also retries a.timeout()expiry).CliClient's tuning knobs requiredefault_retry_if=to be set (raisesValueErrorotherwise) — the same explicit opt-inCommand.retry()'s requiredretry_ifalready enforces.wait_for_line(lines, predicate, *, timeout)is generalized over the iterator's item type (previously hardcoded toAsyncIterator[str]) — it now works over any async iterator (e.g.RunningProcess.output_events()'sOutputEventitems), not just stdout lines, given a callable predicate.predicatealso accepts a plainstras a substring-match shorthand (wait_for_line(lines, "listening on", timeout=10)) when the iterator yieldsstr. Purely additive: an existing callable-predicate,str-iterator call site is unaffected.Invocation.env_is(name, value)/has_env(name)— the platform-correct (case-insensitive on Windows, last write wins) effective-override check. The existingenvdict is plain Python dict semantics, not platform env-key rules: a same-case duplicate key collapses to its last value, but a differently-cased Windows duplicate ("Path"/"PATH") survives as two separate entries — useenv_is()/has_env()for the correct answer either way.runner=keyword onoutput_all/aoutput_all/output_all_bytes/aoutput_all_bytes,Supervisor(...), andCliClient(...)— drives the batch/supervision/client through an injected runner (Runner,ScriptedRunner,RecordingRunner, orRecordReplayRunner) instead of the real one, so a test double stands in with no real process spawned. Defaults to the realRunnerwhen omitted (no behavior change).CliClientwas previously locked to the real runner; it is now just as testable as rawCommandcode.ScriptedRunner.on_sequence(prefix, replies)— reply with each ofrepliesin turn on successive matching calls (fail a few times, then succeed), then repeat the last reply once exhausted. The declarative form for retry/ supervision test scenarios.- Prebuilt wheels for Intel macOS (x86_64), cross-compiled from the arm64 (Apple Silicon) runner. Previously Intel Mac users installed from the sdist (needing a Rust toolchain); both macOS architectures are now covered.
- Prebuilt wheels for Windows on ARM (arm64), built natively on GitHub's
free-for-public-repos
windows-11-armrunner. Both families ship — the abi3 GIL wheel (CPython 3.10+) and the free-threaded cp314t wheel — so ARM64 Windows users (a growing laptop segment) get a binarypip installinstead of a from-source build needing a Rust toolchain. No cibuildwheel override was needed: it already provides a native ARM64 CPython 3.10 (for the abi3 wheel) and a native ARM64 cp314t, so the existingbuild/skipselectors cover win_arm64 unchanged. - An API reference section on the documentation site — a complete,
per-symbol index of the public surface (every class, function, protocol, type
alias, and exception, plus the
processkit.testingsubmodule), reachable from the site navigation. It is rendered bymkdocstringsstraight from the type stub (_processkit.pyi) and docstrings via griffe's static analysis (no compiled extension needed, so it builds in the extension-free Docs CI), and a drift guard (scripts/gen_api_reference.py --checkplustests/test_api_reference.py) fails if the page ever omits — or invents — a public symbol, so the reference cannot silently diverge from the real API.
[project.urls] Homepageinpyproject.tomlnow points at the project overview site (https://zelanton.github.io/processkit/) instead of the GitHub repository, which is still linked separately asRepository.
- Fixed the macOS x86_64 release wheel build:
delocate-wheelwas rejecting the cross-compiled Intel wheel because the compiled extension's embedded minimum macOS target (10.12, the current Rust default forx86_64-apple-darwin) didn't match the wheel'smacosx_10_9tag. The x86_64 cibuildwheel build now setsMACOSX_DEPLOYMENT_TARGET=10.12explicitly so the tag matches the binary. wait_for()'s deadline handling no longer swallows the caller's own cancellation (turning it into a misleadingTimeoutError) if that cancellation lands while the timed-out predicate is being cancelled and drained; it also no longer cancels a pre-existingasyncio.Future/Taskpassed in as the predicate's own awaitable (only a task it created itself), no longer discards a condition that turns out true in the same tick as the deadline, and no longer swallows aSystemExit/KeyboardInterruptraised by the predicate.wait_for_line()no longer masks a builtin-TimeoutError-family exception raised by the predicate or the stream itself behind the generic timeout message; it now shareswait_for()'s bounding, sotimeout=0reliably evaluates once instead of sometimes short-circuiting first.wait_for(),wait_for_line(), andwait_for_port()now reject a NaNtimeoutwithValueErrorinstead of polling forever;wait_for()andwait_for_port()reject a NaNintervalthe same way (wait_for_line()has nointervalparameter).wait_for_port()now chains the last connection attempt's exception (e.g. a DNS failure) as the raisedTimeoutError's__cause__instead of discarding it.- A consuming verb called without the context it needs — an async verb
(
RunningProcess.wait/finish/output/output_bytes/profile/shutdown/__aexit__,Supervisor.arun,ProcessGroup.ashutdown/__aexit__) called with no runningasyncioevent loop, or a sync verb (Supervisor.run,ProcessGroup.shutdown/__exit__) called from inside an already-running async context — now raises a clear error and leaves the handle intact and reusable. Previously the same misuse destroyed the live process (or spent the handle) as a side effect of the error path. Timeout.timeout_secondsis nowNone(not a misleading0.0) when the deadline wasn't known to the checking verb (a scripted/cassette-replayed timeout with notimeout()configured).ProcessStdin.write()/write_line()/flush()/close()now raise the matching stdlibOSErrorsubclass (e.g.BrokenPipeErrorfor a closed child), not a bareOSError.ProcessGroup.signal()'s docstring no longer claims Windows "emulates" the POSIX signals — a Job Object only deliverskillthere; every other name raisesUnsupported, as it always has.- Error mapping now uses the
processkit1.2.0 crate'sErroraccessors instead of hand-matching each variant, closing two gaps: a cancelled run's exception now carries.program(previously missing); and a spawn/IO failure refused for a permission reason is now consistentlyPermissionDenied(previously only a spawn-time refusal was — e.g. an OS-refusedProcessGroup.signal()used to surface as a plainProcessError). docs/testing.md/docs/cookbook.mdno longer claim an unmatchedScriptedRunnercall with no fallback raisesProcessNotFound(it raises a plainProcessError— that was always the actual behavior, the docs were wrong) or thatCliClientis un-injectable (seerunner=above).
1.0.0 - 2026-07-04
- Synchronous
Commandbuilder over theprocesskitRust crate (pinned at=1.2.0):output()(captures a non-zero exit, timeout, and signal-kill as data),output_bytes()(raw-bytes stdout →BytesResult),run()(returns trimmed stdout, raises on failure),exit_code(), andprobe(), configured witharg/args/cwd/env/envs/env_remove/env_clear/timeout/output_limit. The program and working directory accept anyos.PathLike, not onlystr. - Full environment control on
Command:envs(mapping)(set many at once),env_remove(key), andenv_clear()(start from an empty environment) — for reproducible or locked-down (sandboxed) children. - Output caps on
Command:output_limit(max_bytes=…, max_lines=…, on_overflow="drop_oldest"|"drop_newest"|"error")bounds how much captured output is retained (capmax_bytesto bound the parent's memory against an untrusted child; amax_lines-only cap does not); on"error"overflow the run raisesOutputTooLarge. - More
Commandknobs:success_codes([…])(treat the given exit codes as success, replacing the default{0}— forgrep/diff-style tools),inherit_env([…])(allowlist inheritance),timeout_grace()/timeout_signal()(graceful timeout),stdout("inherit"|"null")/stderr(…)redirection,encoding(…)/stdout_encoding/stderr_encoding(decode non-UTF-8 output),kill_on_parent_death(),create_no_window()(Windows), and POSIXuid/gid/groups/setsid. - Concurrent batch execution:
output_all/aoutput_all(and…_bytesvariants) run many commands with boundedconcurrency, returning eachProcessResult— or aProcessErrorfor a spawn/I/O failure — in input order. CliClient(program, *, default_timeout=…, default_env=…, default_env_remove=…)— a typed wrapper for a tool you call repeatedly, withrun/output/output_bytes/exit_code/probe(+ async) taking just the per-call args.enable_logging()— opt-in observability: forwards the core's per-run events to Python'slogging(aprocesskitlogger; DEBUG for a run, WARNING for an edge case). Idempotent; off by default;argv/envare never logged (secrets). Uselogging.basicConfig(level=…)and filter theprocesskitlogger as usual.RunningProcesslive introspection (elapsed_seconds,cpu_time_seconds,peak_memory_bytes,stdout_line_count/stderr_line_count,owns_group), plusoutput_bytes()andprofile(every_seconds)→RunProfile. ARunProfilecarries the run's fulloutcome(code/signal/timed_out— a superset ofwait()) alongside the CPU/memory samples (cpu_time_seconds,peak_memory_bytes,avg_cpu_cores,samples).- Synchronous
Command.start()— a blocking twin ofastart()returning a liveRunningProcessfor streaming a child from synchronous code (its consuming methodswait/finish/output/ … remain coroutines, awaited from an event loop). RecordReplayRunnertest double —record(path)real runs thensave(), andreplay(path)offline; plusoutput_bytesonRunner/ScriptedRunner. It records and replays the streamingstart()verb too (record is capture-whole; interactive mid-stream stdin can't be cassette-recorded — script those withScriptedRunner);output_bytesthrough a cassette raisesUnsupported(a text fixture can't reproduce exact bytes).RecordingRunnerspy test double —RecordingRunner.replying(reply)answers every command with one cannedReplyand records each call, so a test can assert on what its code ran:calls()returns everyInvocation(in order) andonly_call()the single one. EachInvocationexposesprogram,args,cwd,env,has_stdin, andhas_flag(flag); itsrepris redacted (program- arg count + env names, never values). Completes the test-double set.
ProcessResultwithstdout,stderr,code,is_success,timed_out,signal,program,duration_seconds,truncated, andcombined; plus aBytesResult(raw-bytesstdout, textstderr) fromoutput_bytes()/aoutput_bytes().ProcessGroupcontext manager — a kill-on-drop container for a process tree;start()a command into it, inspectmechanism/members(), and the whole tree (grandchildren included) is reaped onwith-exit orshutdown().RunningProcesshandle exposing the childpid.- Exception hierarchy rooted at
ProcessError:NonZeroExit,Timeout,Signalled,ProcessNotFound,PermissionDenied,Unsupported,OutputTooLarge.Timeoutis also a builtinTimeoutError,ProcessNotFoundis also aFileNotFoundError, andPermissionDeniedis also aPermissionError(matchingasyncio/subprocess), so the stdlibexceptclauses catch them. The data-carrying ones expose structured fields — e.g.NonZeroExit.code/.stdout/.stderr/.program,Timeout.timeout_seconds,Signalled.signal,OutputTooLarge.max_bytes/.total_bytes,Unsupported.operation— so a failure can be inspected programmatically, not just read as a message. (ResourceLimitcarries no extra field; its reason isstr(exc).) - Blocking synchronous calls are interruptible:
Ctrl+C(SIGINT) raisesKeyboardInterruptpromptly and tears down the run's process tree, instead of hanging until the child exits. - Asyncio-native surface (tokio ↔ asyncio bridge). Cancelling an awaited run —
directly, or via
asyncio.wait_for/asyncio.timeout— tears down the whole process tree and raisesasyncio.CancelledError.Command:aoutput(),aoutput_bytes(),arun(),aexit_code(),aprobe(), andastart()(returns aRunningProcessfor streaming/interactive I/O).RunningProcess:async for line in proc.stdout_lines(),output_events()(stdout+stderr asOutputEvents), interactivetake_stdin()→ProcessStdin(write/write_line/flush/close), andawaitablewait()→Outcome,finish()→Finished,output()→ProcessResult, pluskill()/shutdown(grace_seconds). It is also a context manager (with/async with): exiting the block tears the process down deterministically — a hard kill of the whole private tree for a standalonestart()/astart()handle — without relying on Python's GC.ProcessGroup:async with,astart(),ashutdown().
Commandstdin configuration:stdin_bytes()/stdin_text()(feed input upfront) andkeep_stdin_open()(write interactively after start).- New result types:
Outcome,Finished,OutputEvent. - Higher-level features:
- Resource limits on
ProcessGroup: keyword-onlymax_memory,max_processes,cpu_quota,shutdown_grace,escalate_to_kill(enforced via the Windows Job Object or a Linux cgroup-v2 root). - Signals & observability on
ProcessGroup:signal("term"|…),suspend(),resume(),kill_all(), andstats()→ProcessGroupStats. - Pipelines:
Command | Command(or.pipe()) →Pipeline, with the sync/async run verbs (incl.output_bytes()/aoutput_bytes()for a binary tail) andtimeout(). - Supervision:
Supervisor(cmd, restart=…, max_restarts=…, backoff_initial=…, backoff_factor=…, max_backoff=…, jitter=…, stop_when=…, storm_pause=…, failure_threshold=…, failure_decay=…)withrun()/arun()→SupervisionOutcome. Settingstorm_pauseenables the failure-storm guard (crash-loop circuit-breaker), reported viaSupervisionOutcome.storm_pauses. - Readiness probes:
await wait_for_port(host, port, *, timeout),await wait_for_line(lines, predicate, *, timeout), andawait wait_for(predicate, *, timeout)(poll any sync-or-async condition). - New types/exception:
Pipeline,ProcessGroupStats,Supervisor,SupervisionOutcome,ResourceLimit.
- Resource limits on
- Testing seam: a
Runner(real) and aScriptedRunner(test double) with a uniform sync + async (a-prefixed)output/run/exit_code/probe/startinterface, plusReply(ok/fail/timeout/signalled/lines/pending). Inject aRunnerin production and aScriptedRunnerin tests — no real processes spawned; the results returned are genuineProcessResult/RunningProcessobjects. The injected runner is typed by theProcessRunnertyping.Protocol, whichRunner/ScriptedRunner/RecordReplayRunner/RecordingRunnerall satisfy structurally. The test doubles (ScriptedRunner,RecordReplayRunner,RecordingRunner) plusReplyandInvocationlive in theprocesskit.testingsubmodule;RunnerandProcessRunnerare top-level (production). - A full documentation guide set: a task-oriented cookbook plus deep guides for running commands, process groups, streaming & interactive I/O, pipelines, timeouts & cancellation, supervision, and testing, tied together by a progressively-disclosed README with a cover illustration.
- Type stubs (
_processkit.pyi) for the compiled extension. - A platform support & caveats matrix documenting per-OS teardown, resource-limit, signal, and stats behaviour.
- Stability commitment: as of 1.0 the public API follows SemVer — breaking changes land only in a new major version.
- Free-threaded CPython (PEP 703): the extension declares
gil_used = false, so importing it on a free-threaded build (CPython 3.14t) does not re-enable the GIL. Shipped as a version-specific free-threaded wheel alongside the abi3 (GIL) wheel, and the full test suite runs on the free-threaded interpreter in CI. Also adds CPython 3.14 to the supported set (the abi3 wheel already runs there). - musllinux (Alpine/musl) wheels for x86_64 and aarch64, alongside the
existing manylinux (glibc) wheels — so
pip installgets a binary wheel on Alpine-based images instead of building from the sdist. Both the abi3 GIL wheel and the free-threaded cp314t wheel ship per libc. CI builds and smoke-tests the x86_64 musllinux wheels on every push (aarch64 builds natively at release). - Packaging metadata for the PyPI page: Trove classifiers (CPython 3.10–3.14, the supported operating systems, topics) and project URLs (Documentation, Issues).
- Runnable
examples/— self-contained, cross-platform programs, one per target niche (whole-tree no-orphan teardown, a readiness-gated server, supervision-until-healthy, a resource-limited sandbox). Each is exercised in CI. - Docs: a "Coming from subprocess" guide that maps
subprocess/asyncio.subprocesspatterns onto their processkit equivalents (verbs, flags, pipelines, the exception mapping) and shows the whole-tree containment the stdlib can't express.
- Pipeline timeout results now retain best-effort partial stdout and stderr captured by the last stage before the deadline.
- Renamed
Command.ok_codes()→success_codes()(clearer that it is the whole success set, not an addition), and an empty sequence now raisesValueErrorinstead of being silently ignored. - Renamed
RunProfile.exit_code→code, matching the exit-code field on every other result type (ProcessResult,Outcome, …). Command.encoding()/stdout_encoding/stderr_encodingnow also accept common Python codec aliases (latin_1,utf_8,euc_jp, …) in addition to WHATWG labels, normalized to the WHATWG form; an unmappable label raisesValueErrornaming the WHATWG equivalent. (WHATWGiso-8859-1/ Pythonlatin_1decode as windows-1252.)Command.arg()/args()and theCommand(...)constructor's args accept anyos.PathLike[str](e.g.pathlib.Path), not onlystr, so aPathargument needs nostr(). (bytespaths are not accepted;StrPathwas narrowed tostr | os.PathLike[str]to match.)- Closed-set string parameters and return values are typed as
Literalin the stubs (signal names,restart,mechanism,SupervisionOutcome.stopped,OutputEvent.stream) for editor autocomplete andmypytypo-catching. - Exported the
StrPath(str | os.PathLike[str]) andSignalName(the signal-nameLiteral) type aliases from the package, so your own wrappers can annotate against the same types the API accepts. - Renamed
ProcessGroup(memory_max=…)→max_memory, so every ceiling on the surface follows themax_*convention (max_processes,output_limit(max_bytes=…, max_lines=…),Supervisor(max_restarts=…, max_backoff=…)). The crate builder remainsmemory_max(). - Renamed
RunProfile.avg_cpu→avg_cpu_cores(self-documenting: the value is CPU-cores, e.g.1.7≈ 1.7 cores busy). - Renamed
RunningProcess.start_kill()→kill(), matchingsubprocess.Popen.kill()(fire-and-forget; does not wait for exit). - Renamed
ProcessGroup.terminate_all()→kill_all()and theProcessGroup(shutdown_timeout=…)ceiling →shutdown_grace, so the group's teardown surface reads as what it does — a hard kill of the whole tree, after an optional grace period — and lines up withRunningProcess.kill()andCommand.timeout_grace(). The crate keepsterminate_all()/shutdown_timeout(). - Renamed the
OutputTooLargeoverflow fieldsline_limit/byte_limit→max_lines/max_bytes, so the caps reported on overflow match theoutput_limit(max_bytes=…, max_lines=…)kwargs that set them. - Moved the runner test doubles —
ScriptedRunner,RecordReplayRunner,RecordingRunner, theReplybuilder, and theInvocationrecord — into a newprocesskit.testingsubmodule (mirroring the crate'sprocesskit::testingsplit), so the top-levelprocesskitnamespace is the production surface and the test scaffolding is one explicit import away (from processkit.testing import ScriptedRunner).Runnerand theProcessRunnerprotocol stay top-level. ProcessResult.combinedis now a property (wascombined()), matching the other read accessors (stdout,code, …).- Renamed
Outcome.is_success/Finished.is_success→exited_zero. These test literal "exit code 0" and — unlikeProcessResult.is_success— carry nosuccess_codescontext, so the new name no longer implies the command's own success verdict. UseProcessResult.is_success, or testcodeagainst your set. RunningProcess.take_stdin()now raisesProcessError(instead of returningNone) when stdin was not kept open or was already taken — so a missingkeep_stdin_open()fails at the call, not later with anAttributeError. Its return type is nowProcessStdin(no longer... | None).- The readiness helpers
wait_for()/wait_for_port()/wait_for_line()now taketimeoutas a keyword-only argument, for uniformity.
Cancelledexception. It was never raised from the Python surface (the binding exposes no cancellation token; cancelling an awaited run surfaces asasyncio.CancelledError), so it was pure catch-list clutter. Re-addable (additive) if a token-style cancellation API is ever exposed.CliClient.run_unit()/arun_unit(). The success-only-> Noneverb existed nowhere else on the surface; userun()/arun()and ignore the returned stdout for the same "run, raise on failure" behavior.ResourceLimit.message. It duplicatedstr(exc)— idiomatic Python 3 exceptions carry no separate.messageattribute. Read the reason viastr(exc).
- A synchronous verb called from inside a
Supervisorstop_whenpredicate no longer re-enters the tokio runtime and panics (the panic was previously swallowed, so the predicate silently never fired); it now raises a clearProcessError. Documented that the predicate must read the result handed to it rather than run new verbs. Supervisor(backoff_factor=…)is now applied (and validated) independently ofbackoff_initial— previously the factor was silently dropped unlessbackoff_initialwas also passed.- A
RecordReplayRunner.replay()cassette miss now carries the.programfield, matching every other program-bearingProcessError. wait_for_port()no longer leaks the probe socket if the awaiting task is cancelled just after the connection is accepted.wait_for()now bounds its predicate bytimeout— an async predicate that hangs no longer ignores the deadline — while propagating the predicate's own exception unchanged and cancelling the in-flight predicate (rather than orphaning it) when the awaiting task is cancelled.
repr(Command(...))no longer renders argv (or env values): it now uses the crate's redacted form — program, argument count, and env names only. A repr is emitted everywhere (logging%r, f-strings, tracebacks, test diffs), so this prevents a secret passed as an argument from leaking through any of them. (The Python surface exposes no way to recover the full command line; argv remains visible to the OS viaps//procwhile the child runs.)- Documentation hardening: the sandbox/privilege-drop guidance now sets all of
gid/groups/uid(droppinguidalone leaves the child holding the parent's supplementary groups — a sandbox-escape footgun); documents that record/replay cassettes are written owner-only (0600, no symlink follow) on Unix; and warns that exceptionstdout/stderrstill carry raw values — pass secrets viaenv(...), not flags.
- This is the 1.0 release: the public API is frozen.
- Distributed as abi3 wheels for CPython 3.10+ (standard/GIL builds), plus a version-specific free-threaded wheel for CPython 3.14t (PEP 703).
- The
RecordReplayRunnertest double enables the crate'srecordfeature, which pullsserde/serde_jsoninto the compiled wheel. enable_logging()enables the crate'stracingfeature; the bridge pullstracing/tracing-subscriber(registry only) into the compiled wheel.