processkit-py 1.0.0__tar.gz → 1.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {processkit_py-1.0.0 → processkit_py-1.1.0}/.gitignore +2 -9
- processkit_py-1.1.0/CHANGELOG.md +629 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/CONTRIBUTING.md +7 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/Cargo.lock +12 -12
- {processkit_py-1.0.0 → processkit_py-1.1.0}/Cargo.toml +15 -5
- {processkit_py-1.0.0 → processkit_py-1.1.0}/PKG-INFO +21 -17
- {processkit_py-1.0.0 → processkit_py-1.1.0}/README.md +19 -15
- {processkit_py-1.0.0 → processkit_py-1.1.0}/RELEASING.md +2 -1
- {processkit_py-1.0.0 → processkit_py-1.1.0}/ROADMAP.md +47 -8
- processkit_py-1.1.0/conftest.py +11 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/docs/README.md +11 -7
- processkit_py-1.1.0/docs/api-reference.md +163 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/docs/commands.md +78 -11
- {processkit_py-1.0.0 → processkit_py-1.1.0}/docs/cookbook.md +84 -11
- processkit_py-1.1.0/docs/event-loops.md +148 -0
- processkit_py-1.1.0/docs/internals.md +251 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/docs/migrating.md +1 -1
- {processkit_py-1.0.0 → processkit_py-1.1.0}/docs/pipelines.md +19 -4
- {processkit_py-1.0.0 → processkit_py-1.1.0}/docs/platforms.md +14 -12
- {processkit_py-1.0.0 → processkit_py-1.1.0}/docs/process-groups.md +13 -3
- {processkit_py-1.0.0 → processkit_py-1.1.0}/docs/streaming.md +123 -38
- {processkit_py-1.0.0 → processkit_py-1.1.0}/docs/supervision.md +6 -2
- {processkit_py-1.0.0 → processkit_py-1.1.0}/docs/testing.md +158 -9
- {processkit_py-1.0.0 → processkit_py-1.1.0}/docs/timeouts-and-cancellation.md +53 -16
- {processkit_py-1.0.0 → processkit_py-1.1.0}/mkdocs.yml +33 -0
- processkit_py-1.1.0/pyproject.toml +243 -0
- processkit_py-1.1.0/scripts/ci-privileged-check.sh +83 -0
- processkit_py-1.1.0/scripts/ci-privileged-guard.py +79 -0
- processkit_py-1.1.0/scripts/gen_api_reference.py +294 -0
- processkit_py-1.1.0/scripts/release/__init__.py +7 -0
- processkit_py-1.1.0/scripts/release/cargo_lock.py +55 -0
- processkit_py-1.1.0/scripts/release/changelog.py +211 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/src/batch.rs +63 -27
- processkit_py-1.1.0/src/cancellation.rs +67 -0
- processkit_py-1.1.0/src/cli.rs +324 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/src/command.rs +211 -21
- processkit_py-1.1.0/src/convert.rs +248 -0
- processkit_py-1.1.0/src/errors.rs +281 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/src/group.rs +112 -13
- processkit_py-1.1.0/src/lib.rs +53 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/src/logging.rs +6 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/src/processkit/__init__.py +37 -16
- processkit_py-1.1.0/src/processkit/_aio.py +309 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/src/processkit/_processkit.pyi +268 -106
- processkit_py-1.1.0/src/processkit/_protocols.py +68 -0
- processkit_py-1.1.0/src/processkit/_types.py +33 -0
- processkit_py-1.1.0/src/processkit/pytest_plugin.py +296 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/src/result.rs +49 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/src/runner.rs +192 -17
- {processkit_py-1.0.0 → processkit_py-1.1.0}/src/running.rs +130 -46
- {processkit_py-1.0.0 → processkit_py-1.1.0}/src/runtime.rs +34 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/src/supervisor.rs +81 -6
- {processkit_py-1.0.0 → processkit_py-1.1.0}/stubtest-allowlist.txt +6 -5
- processkit_py-1.1.0/tests/_docs_snippets.py +161 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/tests/_liveness.py +19 -16
- processkit_py-1.1.0/tests/_programs.py +51 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/tests/_typing_pins.py +17 -6
- processkit_py-1.1.0/tests/conftest.py +54 -0
- processkit_py-1.1.0/tests/property/__init__.py +3 -0
- processkit_py-1.1.0/tests/property/conftest.py +45 -0
- processkit_py-1.1.0/tests/property/test_argv_env_roundtrip.py +100 -0
- processkit_py-1.1.0/tests/property/test_numeric_validation.py +247 -0
- processkit_py-1.1.0/tests/property/test_output_limit.py +150 -0
- processkit_py-1.1.0/tests/property/test_signals.py +80 -0
- processkit_py-1.1.0/tests/property/test_wait_for_line.py +76 -0
- processkit_py-1.1.0/tests/test_api_reference.py +77 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/tests/test_api_surface.py +186 -11
- {processkit_py-1.0.0 → processkit_py-1.1.0}/tests/test_async.py +9 -24
- processkit_py-1.1.0/tests/test_batch.py +164 -0
- processkit_py-1.1.0/tests/test_cli_client.py +287 -0
- processkit_py-1.1.0/tests/test_command.py +881 -0
- processkit_py-1.1.0/tests/test_docs_snippets.py +242 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/tests/test_exceptions.py +63 -8
- {processkit_py-1.0.0 → processkit_py-1.1.0}/tests/test_hardening.py +36 -8
- {processkit_py-1.0.0 → processkit_py-1.1.0}/tests/test_logging.py +6 -2
- {processkit_py-1.0.0 → processkit_py-1.1.0}/tests/test_pipelines.py +16 -1
- processkit_py-1.1.0/tests/test_process_group.py +422 -0
- processkit_py-1.1.0/tests/test_pytest_plugin.py +301 -0
- processkit_py-1.1.0/tests/test_readiness.py +579 -0
- processkit_py-1.1.0/tests/test_release_scripts.py +197 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/tests/test_runner_seam.py +192 -5
- processkit_py-1.1.0/tests/test_streaming.py +563 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/tests/test_supervisor.py +105 -6
- processkit_py-1.1.0/uv.lock +1345 -0
- processkit_py-1.0.0/CHANGELOG.md +0 -291
- processkit_py-1.0.0/pyproject.toml +0 -138
- processkit_py-1.0.0/src/cli.rs +0 -121
- processkit_py-1.0.0/src/convert.rs +0 -139
- processkit_py-1.0.0/src/errors.rs +0 -202
- processkit_py-1.0.0/src/lib.rs +0 -104
- processkit_py-1.0.0/src/processkit/_aio.py +0 -175
- processkit_py-1.0.0/src/processkit/_runner.py +0 -46
- processkit_py-1.0.0/src/processkit/_types.py +0 -17
- processkit_py-1.0.0/tests/_programs.py +0 -28
- processkit_py-1.0.0/tests/test_batch.py +0 -72
- processkit_py-1.0.0/tests/test_cli_client.py +0 -69
- processkit_py-1.0.0/tests/test_command.py +0 -427
- processkit_py-1.0.0/tests/test_import.py +0 -10
- processkit_py-1.0.0/tests/test_process_group.py +0 -221
- processkit_py-1.0.0/tests/test_readiness.py +0 -316
- processkit_py-1.0.0/tests/test_streaming.py +0 -353
- processkit_py-1.0.0/uv.lock +0 -584
- {processkit_py-1.0.0 → processkit_py-1.1.0}/.editorconfig +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/.gitattributes +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/.pre-commit-config.yaml +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/.python-version +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/.yamllint.yml +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/LICENSE +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/SECURITY.md +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/cliff.toml +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/examples/01_no_orphan_guarantee.py +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/examples/02_wait_for_server.py +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/examples/03_supervise_until_healthy.py +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/examples/04_sandbox_resource_limits.py +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/examples/README.md +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/rust-toolchain.toml +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/scripts/check-env.ps1 +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/scripts/check-env.sh +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/scripts/smoke.py +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/src/processkit/py.typed +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/src/processkit/testing.py +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/tests/__init__.py +0 -0
- {processkit_py-1.0.0 → processkit_py-1.1.0}/tests/test_examples.py +0 -0
|
@@ -19,8 +19,10 @@ venv/
|
|
|
19
19
|
.ruff_cache/
|
|
20
20
|
.coverage
|
|
21
21
|
.coverage.*
|
|
22
|
+
coverage.xml
|
|
22
23
|
htmlcov/
|
|
23
24
|
.tox/
|
|
25
|
+
.hypothesis/
|
|
24
26
|
|
|
25
27
|
# Release pipeline scratch file — written by .github/workflows/release.yml
|
|
26
28
|
release-notes.md
|
|
@@ -68,15 +70,6 @@ Desktop.ini
|
|
|
68
70
|
$RECYCLE.BIN/
|
|
69
71
|
*.lnk
|
|
70
72
|
|
|
71
|
-
## -------
|
|
72
|
-
## Agent-instruction files — kept on disk for tooling but never pushed to remote.
|
|
73
|
-
## CLAUDE.md, CLAUDE.local.md, AGENTS.md, and .claude/ are local-only here.
|
|
74
|
-
## -------
|
|
75
|
-
/CLAUDE.md
|
|
76
|
-
/CLAUDE.local.md
|
|
77
|
-
/AGENTS.md
|
|
78
|
-
.claude/
|
|
79
|
-
|
|
80
73
|
## -------
|
|
81
74
|
## Rust build artifacts
|
|
82
75
|
## -------
|
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to **processkit** are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
-
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
-
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
-
|
|
18
|
+
|
|
19
|
+
## [1.1.0] - 2026-07-06
|
|
20
|
+
|
|
21
|
+
### Breaking
|
|
22
|
+
- `RunningProcess`'s consuming verbs now come in a sync/async pair, like
|
|
23
|
+
everywhere else in this library, instead of being coroutine-only. Migration:
|
|
24
|
+
`await proc.wait()` → `await proc.aoutcome()` (renamed — `await` is a
|
|
25
|
+
reserved word, so the async twin of the new sync `outcome()` couldn't be
|
|
26
|
+
called `await()`); `await proc.finish()` → `await proc.afinish()`;
|
|
27
|
+
`await proc.output()` → `await proc.aoutput()`; `await proc.output_bytes()`
|
|
28
|
+
→ `await proc.aoutput_bytes()`; `await proc.profile(...)` →
|
|
29
|
+
`await proc.aprofile(...)`; `await proc.shutdown(...)` →
|
|
30
|
+
`await proc.ashutdown(...)`. Each bare name is now a new **synchronous**
|
|
31
|
+
method (`proc.outcome()`, `proc.finish()`, `proc.output()`,
|
|
32
|
+
`proc.output_bytes()`, `proc.profile(...)`, `proc.shutdown(...)`), making a
|
|
33
|
+
handle from the synchronous `Command.start()` / `Runner.start()` genuinely
|
|
34
|
+
usable end-to-end with no event loop at all — not just for the
|
|
35
|
+
monitor-and-`kill()` pattern. No aliasing was possible (the old bare names
|
|
36
|
+
now mean something different — synchronous — so keeping them pointing at the
|
|
37
|
+
old async behavior would be actively misleading, not merely redundant).
|
|
38
|
+
`RunningProcess.shutdown()`/`ashutdown()` also now match
|
|
39
|
+
`ProcessGroup.shutdown()`/`ashutdown()`'s naming exactly, closing a trap
|
|
40
|
+
where the same verb name meant "call it" on one class but "await it" on the
|
|
41
|
+
other.
|
|
42
|
+
- `ProcessRunner` no longer includes `start`/`astart` — it is now the
|
|
43
|
+
capture/check verb surface only (`output`/`run`/`exit_code`/`probe` and
|
|
44
|
+
their `a`-prefixed twins). A new `StreamingRunner(ProcessRunner)` protocol
|
|
45
|
+
adds `start`/`astart` back for code that also needs a live `RunningProcess`
|
|
46
|
+
handle. Migration: annotate an injection point that only calls the
|
|
47
|
+
capture/check verbs as `ProcessRunner` (now narrower, easier for a custom
|
|
48
|
+
double to satisfy); annotate one that also calls `start`/`astart` as
|
|
49
|
+
`StreamingRunner`. Every built-in runner (`Runner`, `ScriptedRunner`,
|
|
50
|
+
`RecordingRunner`, `RecordReplayRunner`) satisfies `StreamingRunner` (and
|
|
51
|
+
therefore `ProcessRunner` too), so existing injected-runner call sites are
|
|
52
|
+
unaffected — only code that annotated *against* `ProcessRunner` expecting
|
|
53
|
+
`start`/`astart` to be part of it needs to switch to `StreamingRunner`. The
|
|
54
|
+
internal `_runner.py` module (never part of the public import path) is
|
|
55
|
+
renamed `_protocols.py` to reflect holding two protocols now, not one.
|
|
56
|
+
- `wait_for()` is renamed `wait_until()` — the old name collided with
|
|
57
|
+
`asyncio.wait_for`, which bounds one *awaitable*, not a *polled predicate*
|
|
58
|
+
(different semantics entirely). Migration: `await wait_for(...)` →
|
|
59
|
+
`await wait_until(...)`, same arguments. No alias was kept — a `wait_for`
|
|
60
|
+
alias sitting next to `asyncio.wait_for` in the same import line would
|
|
61
|
+
perpetuate exactly the confusion this rename fixes. All three readiness
|
|
62
|
+
helpers (`wait_until`, `wait_for_port`, `wait_for_line`) now raise
|
|
63
|
+
`WaitTimeout` (`ProcessError`, `TimeoutError`) instead of a bare
|
|
64
|
+
`TimeoutError` on their own deadline — still catchable as `except
|
|
65
|
+
TimeoutError`, but now carrying `timeout_seconds` (and, for
|
|
66
|
+
`wait_for_port`, `host`/`port`) as structured fields instead of only a
|
|
67
|
+
message string.
|
|
68
|
+
|
|
69
|
+
### Added
|
|
70
|
+
- A **pytest plugin**, autoloaded via a `pytest11` entry point in every pytest
|
|
71
|
+
session where processkit is installed (nothing to add to `conftest.py`; the
|
|
72
|
+
plugin module is pure Python and import-safe). It exposes the
|
|
73
|
+
`processkit.testing` doubles as ready-made fixtures — `scripted_runner` (a fresh
|
|
74
|
+
`ScriptedRunner`), `recording_runner` (a `RecordingRunner` spy replying
|
|
75
|
+
`Reply.ok("")`, the neutral default), and `record_replay_runner` (a
|
|
76
|
+
`RecordReplayRunner` bound to a per-test cassette) — so injecting a test double
|
|
77
|
+
is a single fixture parameter. The cassette fixture is replay-by-default with a
|
|
78
|
+
vcr-style switch to record (`--processkit-record` CLI flag, then the
|
|
79
|
+
`PROCESSKIT_RECORD` env var, then the `processkit_record` ini option, in that
|
|
80
|
+
precedence); its file lives under the test's `tmp_path` unless the
|
|
81
|
+
`processkit_cassette_dir` ini option points at a kept directory, and its name is
|
|
82
|
+
derived deterministically from the test's node id. A `@pytest.mark.no_real_spawn`
|
|
83
|
+
marker (registered so it passes `--strict-markers`) makes any real spawn through
|
|
84
|
+
`Command`/`Pipeline`/`Runner`/`ProcessGroup` inside the marked test fail loudly,
|
|
85
|
+
while injected doubles keep working. Documented in `docs/testing.md` and the
|
|
86
|
+
cookbook.
|
|
87
|
+
- `Args` and `ReadableBuffer` type aliases (`from processkit import Args,
|
|
88
|
+
ReadableBuffer`). `Args` (`list[StrPath] | tuple[StrPath, ...]`) replaces
|
|
89
|
+
`Sequence[str]`/`Sequence[StrPath]` on every argv-like parameter
|
|
90
|
+
(`Command`'s `args`, `ScriptedRunner.on()`/`on_sequence()`'s `prefix`,
|
|
91
|
+
`CliClient.command()`/its verbs) — deliberately **not** `Sequence[StrPath]`,
|
|
92
|
+
since `str` is itself structurally a `Sequence[str]` (each character is a
|
|
93
|
+
`str`), so that spelling let a bare string slip through everywhere an argv
|
|
94
|
+
list was expected (`cmd.args("--flag")` type-checked, then exploded into
|
|
95
|
+
one argument *per character* at runtime). This is a static-typing-only
|
|
96
|
+
tightening — runtime behavior (and any caller not using mypy) is
|
|
97
|
+
unaffected; a mypy-strict caller passing something other than a `list`/
|
|
98
|
+
`tuple` (an arbitrary custom `Sequence`) at one of these call sites may
|
|
99
|
+
need to wrap it in `list(...)`. `ReadableBuffer` (`bytes | bytearray |
|
|
100
|
+
memoryview`) replaces the too-narrow `bytes` on `Command.stdin_bytes()` /
|
|
101
|
+
`ProcessStdin.write()` — both already accepted `bytearray`/`memoryview` at
|
|
102
|
+
runtime (PyO3's buffer-protocol extraction), so this only catches up the
|
|
103
|
+
stub to reality, no runtime change.
|
|
104
|
+
- `CliClient`'s `command()` and every verb (`run`/`output`/`output_bytes`/
|
|
105
|
+
`exit_code`/`probe`, `a`-prefixed twins) now accept a `str` or any
|
|
106
|
+
`os.PathLike[str]` for each argv element, unified with `Command`'s own
|
|
107
|
+
`arg`/`args` typing — previously `CliClient` was `str`-only, so a
|
|
108
|
+
`pathlib.Path` argument needed a manual `str()` there but not on `Command`.
|
|
109
|
+
- Documented explicitly: `Timeout`, `ProcessNotFound`, and `PermissionDenied`
|
|
110
|
+
are transitively `OSError` subclasses too (since their builtin second base
|
|
111
|
+
— `TimeoutError`/`FileNotFoundError`/`PermissionError` — has itself been an
|
|
112
|
+
`OSError` subclass since Python 3.3), so `except OSError` catches all
|
|
113
|
+
three alongside `except ProcessError`. No behavior change — this was
|
|
114
|
+
already true; it just wasn't written down anywhere.
|
|
115
|
+
- Fixed: `PermissionDenied.program` is now typed `str | None` (was `str`) and
|
|
116
|
+
reliably reads `None` — not a missing-attribute `AttributeError` — on the
|
|
117
|
+
broader OS-refusal path with no program to name (`is_permission_denied()`
|
|
118
|
+
also classifies a program-less `Io` failure, e.g. a group signal the OS
|
|
119
|
+
refused, alongside the ordinary spawn-time denial that does name one).
|
|
120
|
+
Mirrors the class-level default already used for `Timeout.timeout_seconds`.
|
|
121
|
+
- `CancellationToken` — a portable cancel switch: `Command.cancel_on(token)`
|
|
122
|
+
(replaces any prior token — last write wins), `Pipeline.cancel_on(token)`
|
|
123
|
+
(gap-fill — a stage with its own explicit token keeps it), and `CliClient`'s
|
|
124
|
+
`default_cancel_on=` (also gap-fill) tear the run/chain down when `token`
|
|
125
|
+
fires, surfacing the new `Cancelled` exception. `token.cancel()` is
|
|
126
|
+
idempotent; `token.child_token()` derives a token cancelled automatically
|
|
127
|
+
with its parent but cancellable independently, for scoping a broader
|
|
128
|
+
shutdown token down to one operation.
|
|
129
|
+
- `Cancelled` exception — a run deliberately cancelled via a
|
|
130
|
+
`CancellationToken`. Previously such a cancellation surfaced only as a
|
|
131
|
+
plain `ProcessError` (no dedicated subclass existed since `cancel_on` had
|
|
132
|
+
no binding yet); now a distinct, terminal exception — never retried by
|
|
133
|
+
`Command.retry()` or restarted by `Supervisor`, matching the crate's own
|
|
134
|
+
contract (a cancelled token stays cancelled forever, so a replay could only
|
|
135
|
+
fail the same way).
|
|
136
|
+
- `ScriptedRunner.when(predicate, reply)` — reply with `reply` when
|
|
137
|
+
`predicate(command)` accepts it, for a match that isn't a plain argv
|
|
138
|
+
prefix (`on()`) — e.g. inspecting `cwd`/`arguments`/flags via `Command`'s
|
|
139
|
+
own inspection accessors. `predicate` is infallible from the crate's
|
|
140
|
+
perspective, like `Supervisor.stop_when`: a raising or non-`bool` predicate
|
|
141
|
+
reads as "does not match", surfaced via the unraisable hook.
|
|
142
|
+
- `Reply.with_line_delay(seconds)` — sleep `seconds` before each scripted
|
|
143
|
+
stdout line on a `start()`/`astart()` run, so a hermetic streaming test can
|
|
144
|
+
observe genuinely incremental delivery instead of every line arriving at
|
|
145
|
+
once.
|
|
146
|
+
- `RecordingRunner.new(inner)` — wrap any of `Runner`, `ScriptedRunner`,
|
|
147
|
+
`RecordReplayRunner`, or another `RecordingRunner`, recording every call
|
|
148
|
+
made through it. The general form behind the existing `replying(reply)`
|
|
149
|
+
(a recorder whose inner runner is always a fresh `ScriptedRunner` replying
|
|
150
|
+
with one canned `Reply`) — `new()` lets a test combine recording with a
|
|
151
|
+
double it already built (e.g. a `RecordReplayRunner` cassette) or with the
|
|
152
|
+
real `Runner`.
|
|
153
|
+
- `ProcessGroup` is now itself a runner: `group.output(cmd)` / `.run(cmd)` /
|
|
154
|
+
`.exit_code(cmd)` / `.probe(cmd)` / `.output_bytes(cmd)` (+ `a`-prefixed
|
|
155
|
+
twins) run `cmd` as a *shared* member of the group (not a standalone
|
|
156
|
+
private tree) — the same verb surface `Runner`/`ScriptedRunner`/… expose,
|
|
157
|
+
for code written against that seam that should route every spawn through
|
|
158
|
+
one shared group. (Not registered as a `runner=` injection target — a
|
|
159
|
+
`ProcessGroup` carries real OS resources and is injected directly by
|
|
160
|
+
callers who already hold one, not through that kwarg seam.)
|
|
161
|
+
- `output_all()` / `aoutput_all()` / `output_all_bytes()` / `aoutput_all_bytes()`
|
|
162
|
+
now reject `concurrency=0` with `ValueError` instead of silently clamping it
|
|
163
|
+
to `1` (a confusing "asked for none, got some anyway").
|
|
164
|
+
- `Command.no_timeout()` — run without a timeout, and (unlike simply leaving
|
|
165
|
+
it unset) opt out of a client-wide `CliClient` `default_timeout` gap-fill.
|
|
166
|
+
Clears a prior `.timeout()`; the last of the two wins.
|
|
167
|
+
- `Command.stdout_tee(path, *, append=False)` / `stderr_tee(path, *,
|
|
168
|
+
append=False)` — tee every decoded line of the stream to a file *as it is
|
|
169
|
+
produced* (the line plus a `\n`, CRLF normalized) while the run **also** keeps
|
|
170
|
+
capturing the full output: the one-line way to "stream a log to a file and
|
|
171
|
+
still get the captured `ProcessResult`", without a manual loop over
|
|
172
|
+
`stdout_lines()`. The sink is a **file path** (`str` / `os.PathLike[str]`);
|
|
173
|
+
teeing to an arbitrary Python object as a live async writer is deliberately
|
|
174
|
+
**not** supported yet (a separate, deferred feature — dispatching each line to
|
|
175
|
+
a thread, re-acquiring the GIL, honoring backpressure across the FFI boundary
|
|
176
|
+
is its own scope). The file is opened **at build time** — the crate takes a
|
|
177
|
+
concrete sink, not a lazy factory — so an unopenable path (missing parent
|
|
178
|
+
directory, a directory, a permission denial) raises the matching `OSError`
|
|
179
|
+
subclass right at the builder call, not at run; it is created/truncated by
|
|
180
|
+
default, or appended to with `append=True`. Inherited crate semantics: a slow
|
|
181
|
+
sink applies backpressure (it does not block the runtime); a tee write error
|
|
182
|
+
disables the tee for the rest of the run without breaking the run or its
|
|
183
|
+
captured result (warned under `enable_logging()`); and the tee is inert unless
|
|
184
|
+
the line pump runs — a no-op under `stdout("inherit")` / `stdout("null")` and
|
|
185
|
+
under `output_bytes()` (raw capture), working with the line verbs (`output()`
|
|
186
|
+
/ `aoutput()` / `run()`, `start()` + `stdout_lines()` / `output_events()`). A
|
|
187
|
+
reused command's shared sink handle **appends** across sequential re-runs
|
|
188
|
+
(retries, `Supervisor` incarnations) and **interleaves** across concurrent
|
|
189
|
+
pipeline stages.
|
|
190
|
+
- `Command.command_line()` — render the command as a single shell-quoted line
|
|
191
|
+
for display (logs, error messages, a dry-run echo); includes argv, unlike
|
|
192
|
+
the redacted `repr()`. Never used to actually execute anything. Plus
|
|
193
|
+
`Command.program` / `Command.arguments` read-only properties (named
|
|
194
|
+
`arguments`, not `args` — that name is already the builder method that
|
|
195
|
+
appends args).
|
|
196
|
+
- `Command.unchecked_in_pipe()` — exempt a command, as a `Pipeline` stage,
|
|
197
|
+
from pipefail attribution (its unclean exit, including a `SIGPIPE`, is
|
|
198
|
+
skipped when the chain decides what to report); a no-op outside a
|
|
199
|
+
`Pipeline`.
|
|
200
|
+
- `ProcessResult.ensure_success()` / `BytesResult.ensure_success()` — raise
|
|
201
|
+
the same exception a checking verb would if the result's exit isn't in
|
|
202
|
+
`success_codes`, for turning an already-captured `output()`/`output_bytes()`
|
|
203
|
+
result into an error after the fact. Returns `self` unchanged on success, so
|
|
204
|
+
it composes: `cmd.output().ensure_success().stdout`.
|
|
205
|
+
- `.diagnostic: str | None` on `NonZeroExit`, `Timeout`, and `Signalled` — the
|
|
206
|
+
best human-facing message (captured stderr if it carries text, otherwise
|
|
207
|
+
captured stdout; `None` if both streams are blank), so a generic `except
|
|
208
|
+
ProcessError` handler can log/report something useful without knowing which
|
|
209
|
+
of the three stream-bearing exceptions it caught.
|
|
210
|
+
- `Command.timeout_signal()` / `ProcessGroup.signal()` now also accept a raw
|
|
211
|
+
platform signal number (an `int`), not just a portable name — the crate's
|
|
212
|
+
`Signal::Other` escape hatch (Unix only; a raw number is `Unsupported` on
|
|
213
|
+
Windows like every non-`Kill` signal, same as the named variants).
|
|
214
|
+
- `CliClient.command(args)` — a `Command` for `program <args>` with the
|
|
215
|
+
client's defaults (timeout/env/retry/cancel) pre-applied; chain more
|
|
216
|
+
builders for a customized one-off call, then pass the result to `run()` /
|
|
217
|
+
`output()` / … (which now accept either a plain arg list or such a
|
|
218
|
+
`Command` — the `IntoCommand` path). An explicit setting on the returned
|
|
219
|
+
`Command` always wins over the client's default; only the gaps get filled.
|
|
220
|
+
- `CliClient`'s `default_env_fn={key: resolver, ...}` — a per-key zero-arg
|
|
221
|
+
resolver called fresh each time a command is *built* (not each retry
|
|
222
|
+
attempt) to fill an environment variable, for a credential that should be
|
|
223
|
+
read freshly rather than baked in once at client-construction time (a
|
|
224
|
+
static `default_env` value). An explicit per-call `env`/`default_env` at
|
|
225
|
+
the same key still wins — this only fills the gap.
|
|
226
|
+
- `Supervisor`'s `capture_max_bytes=`/`capture_max_lines=`/
|
|
227
|
+
`capture_on_overflow=` — bound (or widen) the output captured from each
|
|
228
|
+
supervised incarnation; the default is already a sensible bounded tail
|
|
229
|
+
(`Command.output_limit`'s own kwargs, applied here as constructor kwargs
|
|
230
|
+
instead of a builder method, per the config-struct convention). Setting any
|
|
231
|
+
of the three requires at least one of the two cap sizes, mirroring
|
|
232
|
+
`output_limit`'s own validation.
|
|
233
|
+
- `Command.retry(retry_if, *, max_retries=, initial_backoff=, multiplier=,
|
|
234
|
+
max_backoff=, jitter=)` and `CliClient`'s `default_retry_if=` (+
|
|
235
|
+
`default_max_retries=`/`default_initial_backoff=`/`default_multiplier=`/
|
|
236
|
+
`default_max_backoff=`/`default_jitter=`) — retry a run with exponential
|
|
237
|
+
backoff, a cap, and jitter, while `retry_if` accepts the resulting error.
|
|
238
|
+
Honored only by the success-checking verbs (`run`/`exit_code`/`probe`, and
|
|
239
|
+
`CliClient`'s equivalents); ignored by `Supervisor` (its own `RestartPolicy`
|
|
240
|
+
governs keep-alive restarts — a different concern), `output_all`, and
|
|
241
|
+
`Pipeline`. Bound as kwargs over the crate's `RetryPolicy`, not a mirrored
|
|
242
|
+
pyclass (the established config-struct convention — see `AGENTS.md`).
|
|
243
|
+
`retry_if` is a named preset over the crate's own error-classification
|
|
244
|
+
accessors, not an arbitrary Python callable crossing the FFI boundary:
|
|
245
|
+
`"transient"` (a bare-retry-clears spawn/IO condition — interrupted,
|
|
246
|
+
would-block, a busy resource) or `"transient_or_timeout"` (also retries a
|
|
247
|
+
`.timeout()` expiry). `CliClient`'s tuning knobs require
|
|
248
|
+
`default_retry_if=` to be set (raises `ValueError` otherwise) — the same
|
|
249
|
+
explicit opt-in `Command.retry()`'s required `retry_if` already enforces.
|
|
250
|
+
- `wait_for_line(lines, predicate, *, timeout)` is generalized over the
|
|
251
|
+
iterator's item type (previously hardcoded to `AsyncIterator[str]`) — it now
|
|
252
|
+
works over any async iterator (e.g. `RunningProcess.output_events()`'s
|
|
253
|
+
`OutputEvent` items), not just stdout lines, given a callable predicate.
|
|
254
|
+
`predicate` also accepts a plain `str` as a substring-match shorthand
|
|
255
|
+
(`wait_for_line(lines, "listening on", timeout=10)`) when the iterator
|
|
256
|
+
yields `str`. Purely additive: an existing callable-predicate,
|
|
257
|
+
`str`-iterator call site is unaffected.
|
|
258
|
+
- `Invocation.env_is(name, value)` / `has_env(name)` — the platform-correct
|
|
259
|
+
(case-insensitive on Windows, last write wins) effective-override check. The
|
|
260
|
+
existing `env` dict is plain Python dict semantics, not platform env-key
|
|
261
|
+
rules: a same-case duplicate key collapses to its last value, but a
|
|
262
|
+
differently-cased Windows duplicate (`"Path"`/`"PATH"`) survives as two
|
|
263
|
+
separate entries — use `env_is()`/`has_env()` for the correct answer either
|
|
264
|
+
way.
|
|
265
|
+
- `runner=` keyword on `output_all` / `aoutput_all` / `output_all_bytes` /
|
|
266
|
+
`aoutput_all_bytes`, `Supervisor(...)`, and `CliClient(...)` — drives the
|
|
267
|
+
batch/supervision/client through an injected runner (`Runner`,
|
|
268
|
+
`ScriptedRunner`, `RecordingRunner`, or `RecordReplayRunner`) instead of the
|
|
269
|
+
real one, so a test double stands in with no real process spawned. Defaults
|
|
270
|
+
to the real `Runner` when omitted (no behavior change). `CliClient` was
|
|
271
|
+
previously locked to the real runner; it is now just as testable as raw
|
|
272
|
+
`Command` code.
|
|
273
|
+
- `ScriptedRunner.on_sequence(prefix, replies)` — reply with each of `replies`
|
|
274
|
+
in turn on successive matching calls (fail a few times, then succeed), then
|
|
275
|
+
repeat the last reply once exhausted. The declarative form for retry/
|
|
276
|
+
supervision test scenarios.
|
|
277
|
+
- Prebuilt wheels for **Intel macOS** (x86_64), cross-compiled from the arm64
|
|
278
|
+
(Apple Silicon) runner. Previously Intel Mac users installed from the sdist
|
|
279
|
+
(needing a Rust toolchain); both macOS architectures are now covered.
|
|
280
|
+
- Prebuilt wheels for **Windows on ARM (arm64)**, built natively on GitHub's
|
|
281
|
+
free-for-public-repos `windows-11-arm` runner. Both families ship — the abi3
|
|
282
|
+
GIL wheel (CPython 3.10+) and the free-threaded cp314t wheel — so ARM64
|
|
283
|
+
Windows users (a growing laptop segment) get a binary `pip install` instead
|
|
284
|
+
of a from-source build needing a Rust toolchain. No cibuildwheel override was
|
|
285
|
+
needed: it already provides a native ARM64 CPython 3.10 (for the abi3 wheel)
|
|
286
|
+
and a native ARM64 cp314t, so the existing `build`/`skip` selectors cover
|
|
287
|
+
win_arm64 unchanged.
|
|
288
|
+
- An **API reference** section on the documentation site — a complete,
|
|
289
|
+
per-symbol index of the public surface (every class, function, protocol, type
|
|
290
|
+
alias, and exception, plus the `processkit.testing` submodule), reachable from
|
|
291
|
+
the site navigation. It is rendered by `mkdocstrings` straight from the type
|
|
292
|
+
stub (`_processkit.pyi`) and docstrings via griffe's *static* analysis (no
|
|
293
|
+
compiled extension needed, so it builds in the extension-free Docs CI), and a
|
|
294
|
+
drift guard (`scripts/gen_api_reference.py --check` plus
|
|
295
|
+
`tests/test_api_reference.py`) fails if the page ever omits — or invents — a
|
|
296
|
+
public symbol, so the reference cannot silently diverge from the real API.
|
|
297
|
+
|
|
298
|
+
### Changed
|
|
299
|
+
- `[project.urls] Homepage` in `pyproject.toml` now points at the project
|
|
300
|
+
overview site (https://zelanton.github.io/processkit/) instead of the
|
|
301
|
+
GitHub repository, which is still linked separately as `Repository`.
|
|
302
|
+
|
|
303
|
+
### Fixed
|
|
304
|
+
- Fixed the macOS x86_64 release wheel build: `delocate-wheel` was rejecting
|
|
305
|
+
the cross-compiled Intel wheel because the compiled extension's embedded
|
|
306
|
+
minimum macOS target (10.12, the current Rust default for
|
|
307
|
+
`x86_64-apple-darwin`) didn't match the wheel's `macosx_10_9` tag. The
|
|
308
|
+
x86_64 cibuildwheel build now sets `MACOSX_DEPLOYMENT_TARGET=10.12`
|
|
309
|
+
explicitly so the tag matches the binary.
|
|
310
|
+
- `wait_for()`'s deadline handling no longer swallows the *caller's* own
|
|
311
|
+
cancellation (turning it into a misleading `TimeoutError`) if that cancellation
|
|
312
|
+
lands while the timed-out predicate is being cancelled and drained; it also no
|
|
313
|
+
longer cancels a pre-existing `asyncio.Future`/`Task` passed in as the
|
|
314
|
+
predicate's own awaitable (only a task it created itself), no longer discards a
|
|
315
|
+
condition that turns out true in the same tick as the deadline, and no longer
|
|
316
|
+
swallows a `SystemExit`/`KeyboardInterrupt` raised by the predicate.
|
|
317
|
+
- `wait_for_line()` no longer masks a builtin-`TimeoutError`-family exception
|
|
318
|
+
raised by the predicate or the stream itself behind the generic timeout
|
|
319
|
+
message; it now shares `wait_for()`'s bounding, so `timeout=0` reliably
|
|
320
|
+
evaluates once instead of sometimes short-circuiting first.
|
|
321
|
+
- `wait_for()`, `wait_for_line()`, and `wait_for_port()` now reject a NaN
|
|
322
|
+
`timeout` with `ValueError` instead of polling forever; `wait_for()` and
|
|
323
|
+
`wait_for_port()` reject a NaN `interval` the same way (`wait_for_line()` has
|
|
324
|
+
no `interval` parameter).
|
|
325
|
+
- `wait_for_port()` now chains the last connection attempt's exception (e.g. a
|
|
326
|
+
DNS failure) as the raised `TimeoutError`'s `__cause__` instead of discarding
|
|
327
|
+
it.
|
|
328
|
+
- A consuming verb called without the context it needs — an async verb
|
|
329
|
+
(`RunningProcess.wait`/`finish`/`output`/`output_bytes`/`profile`/`shutdown`/
|
|
330
|
+
`__aexit__`, `Supervisor.arun`, `ProcessGroup.ashutdown`/`__aexit__`) called
|
|
331
|
+
with no running `asyncio` event loop, or a sync verb (`Supervisor.run`,
|
|
332
|
+
`ProcessGroup.shutdown`/`__exit__`) called from inside an already-running
|
|
333
|
+
async context — now raises a clear error and leaves the handle intact and
|
|
334
|
+
reusable. Previously the same misuse destroyed the live process (or spent the
|
|
335
|
+
handle) as a side effect of the error path.
|
|
336
|
+
- `Timeout.timeout_seconds` is now `None` (not a misleading `0.0`) when the
|
|
337
|
+
deadline wasn't known to the checking verb (a scripted/cassette-replayed
|
|
338
|
+
timeout with no `timeout()` configured).
|
|
339
|
+
- `ProcessStdin.write()` / `write_line()` / `flush()` / `close()` now raise the
|
|
340
|
+
matching stdlib `OSError` subclass (e.g. `BrokenPipeError` for a closed
|
|
341
|
+
child), not a bare `OSError`.
|
|
342
|
+
- `ProcessGroup.signal()`'s docstring no longer claims Windows "emulates" the
|
|
343
|
+
POSIX signals — a Job Object only delivers `kill` there; every other name
|
|
344
|
+
raises `Unsupported`, as it always has.
|
|
345
|
+
- Error mapping now uses the `processkit` 1.2.0 crate's `Error` accessors
|
|
346
|
+
instead of hand-matching each variant, closing two gaps: a cancelled run's
|
|
347
|
+
exception now carries `.program` (previously missing); and a spawn/IO
|
|
348
|
+
failure refused for a permission reason is now consistently `PermissionDenied`
|
|
349
|
+
(previously only a spawn-time refusal was — e.g. an OS-refused
|
|
350
|
+
`ProcessGroup.signal()` used to surface as a plain `ProcessError`).
|
|
351
|
+
- `docs/testing.md`/`docs/cookbook.md` no longer claim an unmatched
|
|
352
|
+
`ScriptedRunner` call with no fallback raises `ProcessNotFound` (it raises a
|
|
353
|
+
plain `ProcessError` — that was always the actual behavior, the docs were
|
|
354
|
+
wrong) or that `CliClient` is un-injectable (see `runner=` above).
|
|
355
|
+
|
|
356
|
+
## [1.0.0] - 2026-07-04
|
|
357
|
+
|
|
358
|
+
### Added
|
|
359
|
+
- Synchronous `Command` builder over the `processkit` Rust crate (pinned at
|
|
360
|
+
`=1.2.0`): `output()` (captures a non-zero exit, timeout, and signal-kill as
|
|
361
|
+
data), `output_bytes()` (raw-bytes stdout → `BytesResult`), `run()` (returns
|
|
362
|
+
trimmed stdout, raises on failure), `exit_code()`, and `probe()`, configured
|
|
363
|
+
with `arg`/`args`/`cwd`/`env`/`envs`/`env_remove`/`env_clear`/`timeout`/
|
|
364
|
+
`output_limit`. The program and working directory accept any `os.PathLike`, not
|
|
365
|
+
only `str`.
|
|
366
|
+
- Full environment control on `Command`: `envs(mapping)` (set many at once),
|
|
367
|
+
`env_remove(key)`, and `env_clear()` (start from an empty environment) — for
|
|
368
|
+
reproducible or locked-down (sandboxed) children.
|
|
369
|
+
- Output caps on `Command`: `output_limit(max_bytes=…, max_lines=…,
|
|
370
|
+
on_overflow="drop_oldest"|"drop_newest"|"error")` bounds how much captured
|
|
371
|
+
output is retained (cap `max_bytes` to bound the parent's memory against an
|
|
372
|
+
untrusted child; a `max_lines`-only cap does not); on `"error"` overflow the
|
|
373
|
+
run raises `OutputTooLarge`.
|
|
374
|
+
- More `Command` knobs: `success_codes([…])` (treat the given exit codes as
|
|
375
|
+
success, replacing the default `{0}` — for `grep`/`diff`-style tools),
|
|
376
|
+
`inherit_env([…])`
|
|
377
|
+
(allowlist inheritance), `timeout_grace()` / `timeout_signal()` (graceful
|
|
378
|
+
timeout), `stdout("inherit"|"null")` / `stderr(…)` redirection, `encoding(…)` /
|
|
379
|
+
`stdout_encoding` / `stderr_encoding` (decode non-UTF-8 output),
|
|
380
|
+
`kill_on_parent_death()`, `create_no_window()` (Windows), and POSIX
|
|
381
|
+
`uid` / `gid` / `groups` / `setsid`.
|
|
382
|
+
- Concurrent batch execution: `output_all` / `aoutput_all` (and `…_bytes`
|
|
383
|
+
variants) run many commands with bounded `concurrency`, returning each
|
|
384
|
+
`ProcessResult` — or a `ProcessError` for a spawn/I/O failure — in input order.
|
|
385
|
+
- `CliClient(program, *, default_timeout=…, default_env=…, default_env_remove=…)`
|
|
386
|
+
— a typed wrapper for a tool you call repeatedly, with `run` / `output` /
|
|
387
|
+
`output_bytes` / `exit_code` / `probe` (+ async) taking just the per-call args.
|
|
388
|
+
- `enable_logging()` — opt-in observability: forwards the core's per-run events to
|
|
389
|
+
Python's `logging` (a `processkit` logger; DEBUG for a run, WARNING for an edge
|
|
390
|
+
case). Idempotent; off by default; `argv`/`env` are never logged (secrets). Use
|
|
391
|
+
`logging.basicConfig(level=…)` and filter the `processkit` logger as usual.
|
|
392
|
+
- `RunningProcess` live introspection (`elapsed_seconds`, `cpu_time_seconds`,
|
|
393
|
+
`peak_memory_bytes`, `stdout_line_count` / `stderr_line_count`, `owns_group`),
|
|
394
|
+
plus `output_bytes()` and `profile(every_seconds)` → `RunProfile`. A `RunProfile`
|
|
395
|
+
carries the run's full `outcome` (`code` / `signal` / `timed_out` — a superset of
|
|
396
|
+
`wait()`) alongside the CPU/memory samples (`cpu_time_seconds`,
|
|
397
|
+
`peak_memory_bytes`, `avg_cpu_cores`, `samples`).
|
|
398
|
+
- Synchronous `Command.start()` — a blocking twin of `astart()` returning a live
|
|
399
|
+
`RunningProcess` for streaming a child from synchronous code (its consuming
|
|
400
|
+
methods `wait` / `finish` / `output` / … remain coroutines, awaited from an
|
|
401
|
+
event loop).
|
|
402
|
+
- `RecordReplayRunner` test double — `record(path)` real runs then `save()`, and
|
|
403
|
+
`replay(path)` offline; plus `output_bytes` on `Runner` / `ScriptedRunner`. It
|
|
404
|
+
records and replays the streaming `start()` verb too (record is capture-whole;
|
|
405
|
+
interactive mid-stream stdin can't be cassette-recorded — script those with
|
|
406
|
+
`ScriptedRunner`); `output_bytes` through a cassette raises `Unsupported` (a
|
|
407
|
+
text fixture can't reproduce exact bytes).
|
|
408
|
+
- `RecordingRunner` spy test double — `RecordingRunner.replying(reply)` answers
|
|
409
|
+
every command with one canned `Reply` and records each call, so a test can
|
|
410
|
+
assert on *what* its code ran: `calls()` returns every `Invocation` (in order)
|
|
411
|
+
and `only_call()` the single one. Each `Invocation` exposes `program`, `args`,
|
|
412
|
+
`cwd`, `env`, `has_stdin`, and `has_flag(flag)`; its `repr` is redacted (program
|
|
413
|
+
+ arg count + env names, never values). Completes the test-double set.
|
|
414
|
+
- `ProcessResult` with `stdout`, `stderr`, `code`, `is_success`, `timed_out`,
|
|
415
|
+
`signal`, `program`, `duration_seconds`, `truncated`, and `combined`; plus a
|
|
416
|
+
`BytesResult` (raw-bytes `stdout`, text `stderr`) from `output_bytes()` /
|
|
417
|
+
`aoutput_bytes()`.
|
|
418
|
+
- `ProcessGroup` context manager — a kill-on-drop container for a process tree;
|
|
419
|
+
`start()` a command into it, inspect `mechanism` / `members()`, and the whole
|
|
420
|
+
tree (grandchildren included) is reaped on `with`-exit or `shutdown()`.
|
|
421
|
+
- `RunningProcess` handle exposing the child `pid`.
|
|
422
|
+
- Exception hierarchy rooted at `ProcessError`: `NonZeroExit`, `Timeout`,
|
|
423
|
+
`Signalled`, `ProcessNotFound`, `PermissionDenied`, `Unsupported`,
|
|
424
|
+
`OutputTooLarge`. `Timeout` is also a builtin `TimeoutError`, `ProcessNotFound`
|
|
425
|
+
is also a `FileNotFoundError`, and `PermissionDenied` is also a
|
|
426
|
+
`PermissionError` (matching `asyncio` / `subprocess`), so the stdlib `except`
|
|
427
|
+
clauses catch them. The data-carrying ones expose structured fields — e.g.
|
|
428
|
+
`NonZeroExit.code` / `.stdout` / `.stderr` / `.program`,
|
|
429
|
+
`Timeout.timeout_seconds`, `Signalled.signal`, `OutputTooLarge.max_bytes` /
|
|
430
|
+
`.total_bytes`, `Unsupported.operation` — so a failure can be inspected
|
|
431
|
+
programmatically, not just read as a message. (`ResourceLimit` carries no extra
|
|
432
|
+
field; its reason is `str(exc)`.)
|
|
433
|
+
- Blocking synchronous calls are interruptible: `Ctrl+C` (SIGINT) raises
|
|
434
|
+
`KeyboardInterrupt` promptly and tears down the run's process tree, instead of
|
|
435
|
+
hanging until the child exits.
|
|
436
|
+
- Asyncio-native surface (tokio ↔ asyncio bridge). Cancelling an awaited run —
|
|
437
|
+
directly, or via `asyncio.wait_for` / `asyncio.timeout` — tears down the whole
|
|
438
|
+
process tree and raises `asyncio.CancelledError`.
|
|
439
|
+
- `Command`: `aoutput()`, `aoutput_bytes()`, `arun()`, `aexit_code()`,
|
|
440
|
+
`aprobe()`, and `astart()` (returns a `RunningProcess` for
|
|
441
|
+
streaming/interactive I/O).
|
|
442
|
+
- `RunningProcess`: `async for line in proc.stdout_lines()`, `output_events()`
|
|
443
|
+
(stdout+stderr as `OutputEvent`s), interactive `take_stdin()` →
|
|
444
|
+
`ProcessStdin` (`write`/`write_line`/`flush`/`close`), and `await`able
|
|
445
|
+
`wait()` → `Outcome`, `finish()` → `Finished`, `output()` → `ProcessResult`,
|
|
446
|
+
plus `kill()` / `shutdown(grace_seconds)`. It is also a context manager
|
|
447
|
+
(`with` / `async with`): exiting the block tears the process down
|
|
448
|
+
deterministically — a hard kill of the whole private tree for a standalone
|
|
449
|
+
`start()`/`astart()` handle — without relying on Python's GC.
|
|
450
|
+
- `ProcessGroup`: `async with`, `astart()`, `ashutdown()`.
|
|
451
|
+
- `Command` stdin configuration: `stdin_bytes()` / `stdin_text()` (feed input
|
|
452
|
+
upfront) and `keep_stdin_open()` (write interactively after start).
|
|
453
|
+
- New result types: `Outcome`, `Finished`, `OutputEvent`.
|
|
454
|
+
- Higher-level features:
|
|
455
|
+
- **Resource limits** on `ProcessGroup`: keyword-only `max_memory`,
|
|
456
|
+
`max_processes`, `cpu_quota`, `shutdown_grace`, `escalate_to_kill`
|
|
457
|
+
(enforced via the Windows Job Object or a Linux cgroup-v2 *root*).
|
|
458
|
+
- **Signals & observability** on `ProcessGroup`: `signal("term"|…)`,
|
|
459
|
+
`suspend()`, `resume()`, `kill_all()`, and `stats()` →
|
|
460
|
+
`ProcessGroupStats`.
|
|
461
|
+
- **Pipelines**: `Command | Command` (or `.pipe()`) → `Pipeline`, with the
|
|
462
|
+
sync/async run verbs (incl. `output_bytes()` / `aoutput_bytes()` for a binary
|
|
463
|
+
tail) and `timeout()`.
|
|
464
|
+
- **Supervision**: `Supervisor(cmd, restart=…, max_restarts=…, backoff_initial=…,
|
|
465
|
+
backoff_factor=…, max_backoff=…, jitter=…, stop_when=…, storm_pause=…,
|
|
466
|
+
failure_threshold=…, failure_decay=…)` with `run()` / `arun()` →
|
|
467
|
+
`SupervisionOutcome`. Setting `storm_pause` enables the failure-storm guard
|
|
468
|
+
(crash-loop circuit-breaker), reported via `SupervisionOutcome.storm_pauses`.
|
|
469
|
+
- **Readiness probes**: `await wait_for_port(host, port, *, timeout)`,
|
|
470
|
+
`await wait_for_line(lines, predicate, *, timeout)`, and
|
|
471
|
+
`await wait_for(predicate, *, timeout)` (poll any sync-or-async condition).
|
|
472
|
+
- New types/exception: `Pipeline`, `ProcessGroupStats`, `Supervisor`,
|
|
473
|
+
`SupervisionOutcome`, `ResourceLimit`.
|
|
474
|
+
- Testing seam: a `Runner` (real) and a `ScriptedRunner` (test double) with a
|
|
475
|
+
uniform sync + async (`a`-prefixed) `output`/`run`/`exit_code`/`probe`/`start`
|
|
476
|
+
interface, plus `Reply`
|
|
477
|
+
(`ok`/`fail`/`timeout`/`signalled`/`lines`/`pending`). Inject a `Runner` in
|
|
478
|
+
production and a `ScriptedRunner` in tests — no real processes spawned; the
|
|
479
|
+
results returned are genuine `ProcessResult` / `RunningProcess` objects. The
|
|
480
|
+
injected runner is typed by the `ProcessRunner` `typing.Protocol`, which
|
|
481
|
+
`Runner` / `ScriptedRunner` / `RecordReplayRunner` / `RecordingRunner` all
|
|
482
|
+
satisfy structurally. The test doubles (`ScriptedRunner`, `RecordReplayRunner`,
|
|
483
|
+
`RecordingRunner`) plus `Reply` and `Invocation` live in the **`processkit.testing`**
|
|
484
|
+
submodule; `Runner` and `ProcessRunner` are top-level (production).
|
|
485
|
+
- A full [documentation guide set](docs/README.md): a task-oriented
|
|
486
|
+
[cookbook](docs/cookbook.md) plus deep guides for
|
|
487
|
+
[running commands](docs/commands.md), [process groups](docs/process-groups.md),
|
|
488
|
+
[streaming & interactive I/O](docs/streaming.md), [pipelines](docs/pipelines.md),
|
|
489
|
+
[timeouts & cancellation](docs/timeouts-and-cancellation.md),
|
|
490
|
+
[supervision](docs/supervision.md), and [testing](docs/testing.md), tied
|
|
491
|
+
together by a progressively-disclosed README with a cover illustration.
|
|
492
|
+
- Type stubs (`_processkit.pyi`) for the compiled extension.
|
|
493
|
+
- A [platform support & caveats](docs/platforms.md) matrix documenting per-OS
|
|
494
|
+
teardown, resource-limit, signal, and stats behaviour.
|
|
495
|
+
- **Stability commitment:** as of 1.0 the public API follows SemVer — breaking
|
|
496
|
+
changes land only in a new major version.
|
|
497
|
+
- **Free-threaded CPython (PEP 703):** the extension declares `gil_used = false`,
|
|
498
|
+
so importing it on a free-threaded build (CPython 3.14t) does **not** re-enable
|
|
499
|
+
the GIL. Shipped as a version-specific free-threaded wheel alongside the
|
|
500
|
+
abi3 (GIL) wheel, and the full test suite runs on the free-threaded interpreter
|
|
501
|
+
in CI. Also adds CPython **3.14** to the supported set (the abi3 wheel already
|
|
502
|
+
runs there).
|
|
503
|
+
- **musllinux (Alpine/musl) wheels** for x86_64 and aarch64, alongside the
|
|
504
|
+
existing manylinux (glibc) wheels — so `pip install` gets a binary wheel on
|
|
505
|
+
Alpine-based images instead of building from the sdist. Both the abi3 GIL wheel
|
|
506
|
+
and the free-threaded cp314t wheel ship per libc. CI builds and smoke-tests the
|
|
507
|
+
x86_64 musllinux wheels on every push (aarch64 builds natively at release).
|
|
508
|
+
- Packaging metadata for the PyPI page: Trove classifiers (CPython 3.10–3.14, the
|
|
509
|
+
supported operating systems, topics) and project URLs (Documentation, Issues).
|
|
510
|
+
- Runnable [`examples/`](examples/) — self-contained, cross-platform programs, one
|
|
511
|
+
per target niche (whole-tree no-orphan teardown, a readiness-gated server,
|
|
512
|
+
supervision-until-healthy, a resource-limited sandbox). Each is exercised in CI.
|
|
513
|
+
- Docs: a **"Coming from subprocess"** guide that maps `subprocess` /
|
|
514
|
+
`asyncio.subprocess` patterns onto their processkit equivalents (verbs, flags,
|
|
515
|
+
pipelines, the exception mapping) and shows the whole-tree containment the stdlib
|
|
516
|
+
can't express.
|
|
517
|
+
|
|
518
|
+
### Changed
|
|
519
|
+
- Renamed `Command.ok_codes()` → **`success_codes()`** (clearer that it is the
|
|
520
|
+
whole success set, not an addition), and an empty sequence now raises
|
|
521
|
+
`ValueError` instead of being silently ignored.
|
|
522
|
+
- Renamed `RunProfile.exit_code` → **`code`**, matching the exit-code field on
|
|
523
|
+
every other result type (`ProcessResult`, `Outcome`, …).
|
|
524
|
+
- `Command.encoding()` / `stdout_encoding` / `stderr_encoding` now also accept
|
|
525
|
+
common **Python codec aliases** (`latin_1`, `utf_8`, `euc_jp`, …) in addition to
|
|
526
|
+
WHATWG labels, normalized to the WHATWG form; an unmappable label raises
|
|
527
|
+
`ValueError` naming the WHATWG equivalent. (WHATWG `iso-8859-1` / Python
|
|
528
|
+
`latin_1` decode as windows-1252.)
|
|
529
|
+
- `Command.arg()` / `args()` and the `Command(...)` constructor's args accept any
|
|
530
|
+
`os.PathLike[str]` (e.g. `pathlib.Path`), not only `str`, so a `Path` argument
|
|
531
|
+
needs no `str()`. (`bytes` paths are not accepted; `StrPath` was narrowed to
|
|
532
|
+
`str | os.PathLike[str]` to match.)
|
|
533
|
+
- Closed-set string parameters and return values are typed as `Literal` in the
|
|
534
|
+
stubs (signal names, `restart`, `mechanism`, `SupervisionOutcome.stopped`,
|
|
535
|
+
`OutputEvent.stream`) for editor autocomplete and `mypy` typo-catching.
|
|
536
|
+
- Exported the `StrPath` (`str | os.PathLike[str]`) and `SignalName` (the signal-name
|
|
537
|
+
`Literal`) type aliases from the package, so your own wrappers can annotate against
|
|
538
|
+
the same types the API accepts.
|
|
539
|
+
- Renamed `ProcessGroup(memory_max=…)` → **`max_memory`**, so every ceiling on the
|
|
540
|
+
surface follows the `max_*` convention (`max_processes`, `output_limit(max_bytes=…,
|
|
541
|
+
max_lines=…)`, `Supervisor(max_restarts=…, max_backoff=…)`). The crate builder
|
|
542
|
+
remains `memory_max()`.
|
|
543
|
+
- Renamed `RunProfile.avg_cpu` → **`avg_cpu_cores`** (self-documenting: the value is
|
|
544
|
+
CPU-cores, e.g. `1.7` ≈ 1.7 cores busy).
|
|
545
|
+
- Renamed `RunningProcess.start_kill()` → **`kill()`**, matching
|
|
546
|
+
`subprocess.Popen.kill()` (fire-and-forget; does not wait for exit).
|
|
547
|
+
- Renamed `ProcessGroup.terminate_all()` → **`kill_all()`** and the
|
|
548
|
+
`ProcessGroup(shutdown_timeout=…)` ceiling → **`shutdown_grace`**, so the group's
|
|
549
|
+
teardown surface reads as what it does — a hard kill of the whole tree, after an
|
|
550
|
+
optional grace period — and lines up with `RunningProcess.kill()` and
|
|
551
|
+
`Command.timeout_grace()`. The crate keeps `terminate_all()` / `shutdown_timeout()`.
|
|
552
|
+
- Renamed the `OutputTooLarge` overflow fields `line_limit` / `byte_limit` →
|
|
553
|
+
**`max_lines`** / **`max_bytes`**, so the caps reported on overflow match the
|
|
554
|
+
`output_limit(max_bytes=…, max_lines=…)` kwargs that set them.
|
|
555
|
+
- Moved the runner test doubles — `ScriptedRunner`, `RecordReplayRunner`,
|
|
556
|
+
`RecordingRunner`, the `Reply` builder, and the `Invocation` record — into a new
|
|
557
|
+
**`processkit.testing`** submodule (mirroring the crate's `processkit::testing`
|
|
558
|
+
split), so the top-level `processkit` namespace is the production surface and the
|
|
559
|
+
test scaffolding is one explicit import away (`from processkit.testing import
|
|
560
|
+
ScriptedRunner`). `Runner` and the `ProcessRunner` protocol stay top-level.
|
|
561
|
+
- `ProcessResult.combined` is now a **property** (was `combined()`), matching the
|
|
562
|
+
other read accessors (`stdout`, `code`, …).
|
|
563
|
+
- Renamed `Outcome.is_success` / `Finished.is_success` → **`exited_zero`**. These
|
|
564
|
+
test literal "exit code 0" and — unlike `ProcessResult.is_success` — carry no
|
|
565
|
+
`success_codes` context, so the new name no longer implies the command's own
|
|
566
|
+
success verdict. Use `ProcessResult.is_success`, or test `code` against your set.
|
|
567
|
+
- `RunningProcess.take_stdin()` now **raises** `ProcessError` (instead of returning
|
|
568
|
+
`None`) when stdin was not kept open or was already taken — so a missing
|
|
569
|
+
`keep_stdin_open()` fails at the call, not later with an `AttributeError`. Its
|
|
570
|
+
return type is now `ProcessStdin` (no longer `... | None`).
|
|
571
|
+
- The readiness helpers `wait_for()` / `wait_for_port()` / `wait_for_line()` now
|
|
572
|
+
take `timeout` as a **keyword-only** argument, for uniformity.
|
|
573
|
+
|
|
574
|
+
### Removed
|
|
575
|
+
- `Cancelled` exception. It was never raised from the Python surface (the binding
|
|
576
|
+
exposes no cancellation token; cancelling an awaited run surfaces as
|
|
577
|
+
`asyncio.CancelledError`), so it was pure catch-list clutter. Re-addable
|
|
578
|
+
(additive) if a token-style cancellation API is ever exposed.
|
|
579
|
+
- `CliClient.run_unit()` / `arun_unit()`. The success-only `-> None` verb existed
|
|
580
|
+
nowhere else on the surface; use `run()` / `arun()` and ignore the returned
|
|
581
|
+
stdout for the same "run, raise on failure" behavior.
|
|
582
|
+
- `ResourceLimit.message`. It duplicated `str(exc)` — idiomatic Python 3 exceptions
|
|
583
|
+
carry no separate `.message` attribute. Read the reason via `str(exc)`.
|
|
584
|
+
|
|
585
|
+
### Fixed
|
|
586
|
+
- A synchronous verb called from inside a `Supervisor` `stop_when` predicate no
|
|
587
|
+
longer re-enters the tokio runtime and panics (the panic was previously
|
|
588
|
+
swallowed, so the predicate silently never fired); it now raises a clear
|
|
589
|
+
`ProcessError`. Documented that the predicate must read the result handed to it
|
|
590
|
+
rather than run new verbs.
|
|
591
|
+
- `Supervisor(backoff_factor=…)` is now applied (and validated) independently of
|
|
592
|
+
`backoff_initial` — previously the factor was silently dropped unless
|
|
593
|
+
`backoff_initial` was also passed.
|
|
594
|
+
- A `RecordReplayRunner.replay()` cassette miss now carries the `.program` field,
|
|
595
|
+
matching every other program-bearing `ProcessError`.
|
|
596
|
+
- `wait_for_port()` no longer leaks the probe socket if the awaiting task is
|
|
597
|
+
cancelled just after the connection is accepted.
|
|
598
|
+
- `wait_for()` now bounds its predicate by `timeout` — an async predicate that
|
|
599
|
+
hangs no longer ignores the deadline — while propagating the predicate's own
|
|
600
|
+
exception unchanged and cancelling the in-flight predicate (rather than orphaning
|
|
601
|
+
it) when the awaiting task is cancelled.
|
|
602
|
+
|
|
603
|
+
### Security
|
|
604
|
+
- `repr(Command(...))` no longer renders argv (or env *values*): it now uses the
|
|
605
|
+
crate's redacted form — program, argument *count*, and env *names* only. A repr
|
|
606
|
+
is emitted everywhere (logging `%r`, f-strings, tracebacks, test diffs), so this
|
|
607
|
+
prevents a secret passed as an argument from leaking through any of them. (The
|
|
608
|
+
Python surface exposes no way to recover the full command line; argv remains
|
|
609
|
+
visible to the OS via `ps` / `/proc` while the child runs.)
|
|
610
|
+
- Documentation hardening: the sandbox/privilege-drop guidance now sets all of
|
|
611
|
+
`gid` / `groups` / `uid` (dropping `uid` alone leaves the child holding the
|
|
612
|
+
parent's supplementary groups — a sandbox-escape footgun); documents that
|
|
613
|
+
record/replay cassettes are written owner-only (`0600`, no symlink follow) on
|
|
614
|
+
Unix; and warns that exception `stdout`/`stderr` still carry raw values — pass
|
|
615
|
+
secrets via `env(...)`, not flags.
|
|
616
|
+
|
|
617
|
+
### Notes
|
|
618
|
+
|
|
619
|
+
- This is the **1.0** release: the public API is frozen.
|
|
620
|
+
- Distributed as abi3 wheels for CPython 3.10+ (standard/GIL builds), **plus a
|
|
621
|
+
version-specific free-threaded wheel** for CPython 3.14t (PEP 703).
|
|
622
|
+
- The `RecordReplayRunner` test double enables the crate's `record` feature,
|
|
623
|
+
which pulls `serde` / `serde_json` into the compiled wheel.
|
|
624
|
+
- `enable_logging()` enables the crate's `tracing` feature; the bridge pulls
|
|
625
|
+
`tracing` / `tracing-subscriber` (registry only) into the compiled wheel.
|
|
626
|
+
|
|
627
|
+
[Unreleased]: https://github.com/ZelAnton/processkit-py/compare/v1.1.0...HEAD
|
|
628
|
+
[1.1.0]: https://github.com/ZelAnton/processkit-py/compare/v1.0.0...v1.1.0
|
|
629
|
+
[1.0.0]: https://github.com/ZelAnton/processkit-py/releases/tag/v1.0.0
|