processkit-py 1.1.0__tar.gz → 1.2.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.
Files changed (127) hide show
  1. {processkit_py-1.1.0 → processkit_py-1.2.0}/CHANGELOG.md +214 -1
  2. {processkit_py-1.1.0 → processkit_py-1.2.0}/CONTRIBUTING.md +5 -0
  3. {processkit_py-1.1.0 → processkit_py-1.2.0}/Cargo.lock +3 -3
  4. {processkit_py-1.1.0 → processkit_py-1.2.0}/Cargo.toml +6 -4
  5. {processkit_py-1.1.0 → processkit_py-1.2.0}/PKG-INFO +13 -2
  6. {processkit_py-1.1.0 → processkit_py-1.2.0}/README.md +12 -1
  7. {processkit_py-1.1.0 → processkit_py-1.2.0}/RELEASING.md +6 -10
  8. {processkit_py-1.1.0 → processkit_py-1.2.0}/SECURITY.md +8 -0
  9. processkit_py-1.2.0/benchmarks/README.md +57 -0
  10. processkit_py-1.2.0/benchmarks/__init__.py +11 -0
  11. processkit_py-1.2.0/benchmarks/_shared.py +20 -0
  12. processkit_py-1.2.0/benchmarks/test_output_all.py +52 -0
  13. processkit_py-1.2.0/benchmarks/test_process_group.py +26 -0
  14. processkit_py-1.2.0/benchmarks/test_spawn_capture.py +61 -0
  15. processkit_py-1.2.0/benchmarks/test_streaming_throughput.py +41 -0
  16. processkit_py-1.2.0/deny.toml +44 -0
  17. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/README.md +5 -2
  18. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/api-reference.md +9 -1
  19. processkit_py-1.2.0/docs/cli.md +114 -0
  20. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/commands.md +36 -9
  21. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/cookbook.md +98 -17
  22. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/event-loops.md +4 -4
  23. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/internals.md +62 -18
  24. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/migrating.md +1 -1
  25. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/process-groups.md +15 -0
  26. processkit_py-1.2.0/docs/sandboxing.md +208 -0
  27. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/streaming.md +109 -31
  28. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/supervision.md +40 -2
  29. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/testing.md +63 -11
  30. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/timeouts-and-cancellation.md +7 -5
  31. processkit_py-1.2.0/examples/04_sandbox_resource_limits.py +98 -0
  32. {processkit_py-1.1.0 → processkit_py-1.2.0}/examples/README.md +4 -2
  33. {processkit_py-1.1.0 → processkit_py-1.2.0}/mkdocs.yml +2 -1
  34. {processkit_py-1.1.0 → processkit_py-1.2.0}/pyproject.toml +52 -3
  35. {processkit_py-1.1.0 → processkit_py-1.2.0}/scripts/ci-privileged-guard.py +7 -3
  36. {processkit_py-1.1.0 → processkit_py-1.2.0}/scripts/gen_api_reference.py +20 -4
  37. {processkit_py-1.1.0 → processkit_py-1.2.0}/scripts/release/cargo_lock.py +4 -1
  38. {processkit_py-1.1.0 → processkit_py-1.2.0}/scripts/release/changelog.py +31 -12
  39. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/batch.rs +10 -7
  40. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/cli.rs +36 -2
  41. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/command.rs +304 -49
  42. processkit_py-1.2.0/src/convert.rs +848 -0
  43. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/errors.rs +43 -8
  44. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/group.rs +58 -26
  45. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/lib.rs +8 -2
  46. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/processkit/__init__.py +13 -2
  47. processkit_py-1.2.0/src/processkit/__main__.py +263 -0
  48. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/processkit/_aio.py +101 -11
  49. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/processkit/_processkit.pyi +288 -52
  50. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/processkit/_protocols.py +5 -5
  51. processkit_py-1.2.0/src/processkit/_types.py +117 -0
  52. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/processkit/pytest_plugin.py +7 -0
  53. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/processkit/testing.py +4 -0
  54. processkit_py-1.2.0/src/result.rs +867 -0
  55. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/runner.rs +277 -55
  56. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/running.rs +87 -54
  57. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/runtime.rs +1 -8
  58. processkit_py-1.2.0/src/supervisor.rs +607 -0
  59. {processkit_py-1.1.0 → processkit_py-1.2.0}/stubtest-allowlist.txt +10 -5
  60. processkit_py-1.2.0/tests/_liveness.py +195 -0
  61. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/_programs.py +16 -6
  62. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/_typing_pins.py +81 -3
  63. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/property/test_argv_env_roundtrip.py +2 -2
  64. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_api_surface.py +1 -0
  65. processkit_py-1.2.0/tests/test_batch.py +362 -0
  66. processkit_py-1.2.0/tests/test_ci_privileged_guard.py +102 -0
  67. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_cli_client.py +16 -2
  68. processkit_py-1.2.0/tests/test_cli_main.py +138 -0
  69. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_command.py +830 -7
  70. processkit_py-1.2.0/tests/test_event_loops.py +235 -0
  71. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_exceptions.py +28 -0
  72. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_hardening.py +8 -0
  73. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_logging.py +20 -1
  74. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_pipelines.py +97 -1
  75. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_pytest_plugin.py +22 -0
  76. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_readiness.py +151 -4
  77. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_release_scripts.py +129 -0
  78. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_runner_seam.py +226 -2
  79. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_streaming.py +291 -1
  80. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_supervisor.py +167 -1
  81. processkit_py-1.2.0/tests/test_threading.py +521 -0
  82. {processkit_py-1.1.0 → processkit_py-1.2.0}/uv.lock +315 -218
  83. processkit_py-1.1.0/examples/04_sandbox_resource_limits.py +0 -82
  84. processkit_py-1.1.0/src/convert.rs +0 -248
  85. processkit_py-1.1.0/src/processkit/_types.py +0 -33
  86. processkit_py-1.1.0/src/result.rs +0 -456
  87. processkit_py-1.1.0/src/supervisor.rs +0 -306
  88. processkit_py-1.1.0/tests/_liveness.py +0 -97
  89. processkit_py-1.1.0/tests/test_batch.py +0 -164
  90. {processkit_py-1.1.0 → processkit_py-1.2.0}/.editorconfig +0 -0
  91. {processkit_py-1.1.0 → processkit_py-1.2.0}/.gitattributes +0 -0
  92. {processkit_py-1.1.0 → processkit_py-1.2.0}/.gitignore +0 -0
  93. {processkit_py-1.1.0 → processkit_py-1.2.0}/.pre-commit-config.yaml +0 -0
  94. {processkit_py-1.1.0 → processkit_py-1.2.0}/.python-version +0 -0
  95. {processkit_py-1.1.0 → processkit_py-1.2.0}/.yamllint.yml +0 -0
  96. {processkit_py-1.1.0 → processkit_py-1.2.0}/LICENSE +0 -0
  97. {processkit_py-1.1.0 → processkit_py-1.2.0}/ROADMAP.md +0 -0
  98. {processkit_py-1.1.0 → processkit_py-1.2.0}/cliff.toml +0 -0
  99. {processkit_py-1.1.0 → processkit_py-1.2.0}/conftest.py +0 -0
  100. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/pipelines.md +0 -0
  101. {processkit_py-1.1.0 → processkit_py-1.2.0}/docs/platforms.md +0 -0
  102. {processkit_py-1.1.0 → processkit_py-1.2.0}/examples/01_no_orphan_guarantee.py +0 -0
  103. {processkit_py-1.1.0 → processkit_py-1.2.0}/examples/02_wait_for_server.py +0 -0
  104. {processkit_py-1.1.0 → processkit_py-1.2.0}/examples/03_supervise_until_healthy.py +0 -0
  105. {processkit_py-1.1.0 → processkit_py-1.2.0}/rust-toolchain.toml +0 -0
  106. {processkit_py-1.1.0 → processkit_py-1.2.0}/scripts/check-env.ps1 +0 -0
  107. {processkit_py-1.1.0 → processkit_py-1.2.0}/scripts/check-env.sh +0 -0
  108. {processkit_py-1.1.0 → processkit_py-1.2.0}/scripts/ci-privileged-check.sh +0 -0
  109. {processkit_py-1.1.0 → processkit_py-1.2.0}/scripts/release/__init__.py +0 -0
  110. {processkit_py-1.1.0 → processkit_py-1.2.0}/scripts/smoke.py +0 -0
  111. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/cancellation.rs +0 -0
  112. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/logging.rs +0 -0
  113. {processkit_py-1.1.0 → processkit_py-1.2.0}/src/processkit/py.typed +0 -0
  114. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/__init__.py +0 -0
  115. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/_docs_snippets.py +0 -0
  116. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/conftest.py +0 -0
  117. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/property/__init__.py +0 -0
  118. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/property/conftest.py +0 -0
  119. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/property/test_numeric_validation.py +0 -0
  120. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/property/test_output_limit.py +0 -0
  121. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/property/test_signals.py +0 -0
  122. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/property/test_wait_for_line.py +0 -0
  123. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_api_reference.py +0 -0
  124. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_async.py +0 -0
  125. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_docs_snippets.py +0 -0
  126. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_examples.py +0 -0
  127. {processkit_py-1.1.0 → processkit_py-1.2.0}/tests/test_process_group.py +0 -0
@@ -16,6 +16,217 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
16
  ### Fixed
17
17
  -
18
18
 
19
+ ## [1.2.0] - 2026-07-08
20
+
21
+ ### Added
22
+ - `Command.stdout_tee` / `Command.stderr_tee` now accept a **Python writer**
23
+ object (anything with a callable `write()` — `io.StringIO`, `sys.stderr`, a
24
+ text-mode file, a logger wrapper) in addition to a file path, mirroring the
25
+ child's output straight into your own console/buffer/logger while still
26
+ capturing it. Each decoded line (plus a `"\n"`) is passed to `write()` as a
27
+ `str` via an async-write bridge: every write is dispatched to the runtime's
28
+ blocking pool (re-acquiring the GIL there) and awaited on the capture pump, so
29
+ a slow — even sleeping — `write()` applies backpressure without blocking the
30
+ event loop or deadlocking the runtime. The object is discriminated from a path
31
+ by exposing `write` (neither `str` nor `pathlib.Path` does) and is never closed
32
+ for you; `append=True` is meaningful only for a file path and raises
33
+ `ValueError` if combined with a writer. A `write()` exception disables the tee
34
+ for the rest of the run (a `tracing` warning under `enable_logging()`, the same
35
+ isolation as the file tee) and is additionally reported via `sys.unraisablehook`
36
+ — the run and its captured result are unaffected. The previous "a file path
37
+ only, an arbitrary Python writer is deliberately not supported" restriction is
38
+ lifted. See `docs/streaming.md#tee-output-to-a-file`.
39
+ - `Command.on_stdout_line(callback)` / `Command.on_stderr_line(callback)`: a
40
+ `Callable[[str], None]` invoked with every decoded line as it is produced —
41
+ the way to give the **synchronous** surface (`.output()`/`.run()`) live
42
+ progress observation during an otherwise-blocking call, without losing the
43
+ full capture. Also fires on the async verbs and on a streamed run
44
+ (`start()`/`astart()` + `stdout_lines()`/`output_events()`); at most one
45
+ handler per stream (a repeat call replaces the previous one); a raising
46
+ callback is reported via `sys.unraisablehook` rather than propagated or
47
+ breaking the run. Inert under `stdout("inherit")`/`stdout("null")` (resp.
48
+ `stderr(...)`) and, for `on_stdout_line` only, under `output_bytes()` (which
49
+ captures stdout raw, bypassing the line pump — stderr still goes through it,
50
+ so `on_stderr_line` still fires there). See
51
+ `docs/streaming.md#live-per-line-callbacks`.
52
+ - A `benchmarks/` suite (`pytest-benchmark`, new `bench` dependency-group)
53
+ measuring spawn+capture overhead against `subprocess`/`asyncio.subprocess`,
54
+ `ProcessGroup` start/exit, line-streaming throughput, and `output_all`
55
+ concurrency scaling — dev tooling only, no public API change. Runs nightly
56
+ via the `bench` job in `nightly-hardening.yml`, never in the PR gate; see
57
+ `benchmarks/README.md`.
58
+ - `wait_for_path(path, *, timeout, interval=0.05)` — a new async readiness
59
+ helper alongside `wait_until` / `wait_for_port` / `wait_for_line`, polling
60
+ until a filesystem path appears (a unix socket, a pid file, or any other
61
+ marker a daemon creates once ready). Same timeout/interval discipline as its
62
+ siblings (NaN/negative `timeout` and non-positive `interval` raise
63
+ `ValueError`; `timeout=0` still checks the path at least once) and raises
64
+ `WaitTimeout` (also a `TimeoutError`) on expiry, now carrying a `path` field
65
+ (`WaitTimeout.__init__` gained a `path: StrPath | None = None` parameter).
66
+ - `python -m processkit run -- <cmd> [args...]`: a CLI wrapper that runs a
67
+ command inside a kill-on-exit `ProcessGroup` with inherited stdio, for
68
+ shell scripts and CI steps with no Python to write. Supports `--timeout`,
69
+ `--timeout-grace`, `--max-memory`, `--max-processes`, and `--cpu-quota`;
70
+ the child's own exit code is passed through unchanged,
71
+ and a timeout / missing program / rejected resource limit is reported as a
72
+ one-line stderr message with a documented, GNU-`timeout`-style exit code
73
+ instead of a traceback. See `docs/cli.md`.
74
+ - `Finished` gains `timed_out` and `signal` properties that delegate to the
75
+ nested `outcome`, so it now mirrors `Outcome` fully — matching `code` and
76
+ `exited_zero`, which were already exposed directly — instead of requiring
77
+ `finished.outcome.timed_out` / `finished.outcome.signal`.
78
+ - `Command.stdin_file(path)` — feed the child's stdin from a file, streamed in
79
+ chunks by the crate rather than read whole into a Python `bytes` object, for
80
+ large inputs (a `psql` dump, a `tar` archive, a multi-gigabyte log). Like
81
+ most other builder methods (`stdout_tee`/`stderr_tee` are the deliberate
82
+ exception), it does not touch the filesystem at build time — the path is
83
+ opened lazily at spawn, so a missing/unreadable file surfaces as the generic
84
+ `ProcessError` from the run/output verb, not `FileNotFoundError`. Reusable
85
+ across retries/re-runs, like `stdin_bytes`/`stdin_text`; the usual "last
86
+ stdin method wins" rule applies alongside `stdin_bytes()`/`stdin_text()`/
87
+ `keep_stdin_open()`.
88
+ - `ProcessResult` and `BytesResult` gain `diagnostic: str | None` (stderr if it
89
+ carries text, otherwise stdout, otherwise `None` — the same preference order
90
+ as `NonZeroExit`/`Timeout`/`Signalled.diagnostic` on the exceptions) and
91
+ `outcome: Outcome` (the same value `RunProfile.outcome` and the checking-verb
92
+ exceptions expose). A result held as data (`output()`/`output_bytes()`
93
+ without `ensure_success()`) no longer requires re-deriving these by hand.
94
+ (An `output_contains_any` convenience was considered alongside these and
95
+ rejected: the underlying `processkit` crate has no such method, so it
96
+ wouldn't be parity with the crate or the exceptions like `diagnostic`/
97
+ `outcome` are — and it's a one-liner callers can already write themselves
98
+ via `combined`, e.g. `any(s in result.combined for s in needles)`.)
99
+ - Value semantics for the result types: `ProcessResult`, `BytesResult`,
100
+ `Outcome`, `Finished`, `RunProfile`, and `SupervisionOutcome` now define
101
+ `__eq__` (comparing every field the underlying `processkit` crate's own
102
+ `PartialEq` compares — not `object`'s previous identity comparison) and a
103
+ consistent `__hash__` (none of their fields are stored floats, so hashing is
104
+ sound), so two results can now be compared with `==` and used in a `set` or
105
+ as a `dict` key without a manual field-by-field comparison.
106
+ `ProcessResult`/`Outcome`/`Finished`/`SupervisionOutcome` are also picklable
107
+ — e.g. to return a `ProcessResult` from a
108
+ `concurrent.futures.ProcessPoolExecutor` worker. The underlying crate has no
109
+ public constructor for any of these types, so unpickling reconstructs one via
110
+ `processkit.testing.ScriptedRunner` (an in-memory, no-subprocess replay) —
111
+ faithful for every field the Python binding exposes, but a command that
112
+ customized `success_codes()`/`timeout()` is not guaranteed to compare `==`
113
+ its original after a round trip (those two fields have no Python accessor to
114
+ reconstruct exactly). `BytesResult` (raw stdout may not be valid UTF-8, and
115
+ the only reconstruction channel available is text-only) and `RunProfile`
116
+ (reports live OS resource-sampling telemetry with no synthesis path outside
117
+ an actual monitored run) explicitly do **not** support pickling and raise a
118
+ clear `TypeError` rather than failing silently or fabricating the missing
119
+ data.
120
+
121
+ ### Changed
122
+ - `CliClient(default_env_fn=...)` now validates that every value in the
123
+ mapping is callable **at construction time**, raising `TypeError` (naming
124
+ the offending key) immediately instead of silently accepting a non-callable
125
+ value and only discovering the mistake later — once per built command, as
126
+ an unraisable-hook warning plus an always-empty resolved env var. Valid
127
+ callables behave exactly as before.
128
+
129
+ ### Fixed
130
+ - `Args` (`from processkit import Args`) no longer rejects the single most
131
+ common real call site — a variable annotated `list[str]` (or
132
+ `list[pathlib.Path]` / `list[os.PathLike[str]]`) passed straight through to
133
+ an argv-like parameter, e.g. `args: list[str] = [...]; cmd.args(args)`.
134
+ `list` is invariant, so the original `list[StrPath] | tuple[StrPath, ...]`
135
+ spelling only ever accepted a `list[StrPath]`-annotated variable or a
136
+ literal, not a `list[str]`/`list[Path]`/`list[os.PathLike[str]]`-annotated
137
+ one, even though the values are runtime-identical — a static-typing-only
138
+ false positive with no runtime effect. `Args` is now a union of the concrete
139
+ homogeneous list shapes (`list[str]`, `list[Path]`, `list[os.PathLike[str]]`)
140
+ instead of the single invariant `list[StrPath]`; a *mixed* `str`/
141
+ `os.PathLike[str]` argv is still accepted, now spelled as a `tuple` rather
142
+ than a `list` literal (e.g. `cmd.args((path, "literal"))`). A bare `str`
143
+ still does not type-check as `Args` (unchanged; see the `Args` docstring).
144
+
145
+ ## [1.1.1] - 2026-07-06
146
+
147
+ ### Added
148
+ - `Command.line_terminator(mode)` / `Command.stdout_line_terminator(mode)` /
149
+ `Command.stderr_line_terminator(mode)` — choose where the line pump splits a
150
+ stream into lines: `"newline"` (default, splits on `\n` only, unchanged
151
+ behavior) or `"carriage_return"` (also splits on a bare `\r`, delivering each
152
+ frame of a `curl`/`pip`/`apt`-style redrawn-in-place progress bar live instead
153
+ of piling it all up into one line at EOF). `line_terminator` sets both
154
+ streams at once; the `stdout_`/`stderr_` variants target one stream, leaving
155
+ the other's framing untouched. Binds `processkit` 2.1.0's
156
+ `Command::line_terminator`/`stdout_line_terminator`/`stderr_line_terminator`
157
+ (`LineTerminator`), exposed as the new `LineTerminatorName` string-preset
158
+ alias.
159
+ - `testing.Reply.with_stderr(text)` — attach stderr to a scripted reply,
160
+ including a successful (`Reply.ok(...)`) one, without resorting to
161
+ `Reply.fail(0, ...)` as a workaround.
162
+ - `processkit.testing.DryRunRunner` — a render-only test double that never
163
+ spawns a process: every verb renders the command to its display-quoted line
164
+ (via the crate's own `Command.command_line()` quoting) and returns a
165
+ synthetic success, the seam behind a tool's own `--dry-run`/`--echo` mode.
166
+ Inspect the rendered lines with `commands()` / `only_command()`, or stream
167
+ them live as each call happens with `on_invocation(callback)`. Works at every
168
+ runner injection point (`output_all` and friends, `Supervisor`, `CliClient`,
169
+ `runner=`), like the other doubles. (Binds `processkit` 2.1.0's
170
+ `testing::DryRunRunner`.)
171
+ - `Supervisor(..., give_up_when=classifier)` — classify a permanent failure so
172
+ supervision gives up instead of restarting a crash forever, reporting the new
173
+ `SupervisionOutcome.stopped == "gave_up"`. Bound as a **Python callable**
174
+ (like `stop_when`, not a `retry_if`-style string preset — the crate's
175
+ classifier is a per-attempt closure, and a useful verdict is result-specific,
176
+ not a fixed vocabulary). The callback receives one argument mirroring the
177
+ crate's `GiveUpAttempt` sum type, dispatched with `isinstance`: a
178
+ `ProcessResult` for a crashed run that produced a result (classify by e.g.
179
+ `attempt.code`), or a `ProcessError` subclass for a launch that never produced
180
+ one (classify by e.g. `isinstance(attempt, ProcessNotFound)` for a missing
181
+ binary). Consulted only for a crash the policy would otherwise restart, ahead
182
+ of `max_restarts` and the failure-storm guard. A crash verdict stops with
183
+ `stopped == "gave_up"`; a launch-failure verdict has no result to report and
184
+ surfaces the classified error directly from `run()`/`arun()`. Off by default —
185
+ a permanent failure restarts as before. The classifier runs on the runtime
186
+ thread under the GIL; a raising or non-bool callback reads as "not permanent"
187
+ (keep restarting) and is surfaced via the unraisable hook, never silently
188
+ swallowed.
189
+ - `Command.umask(mask)` — set the child's POSIX file-mode creation mask; on a
190
+ non-POSIX platform the run raises `Unsupported`, matching the existing
191
+ `uid`/`gid`/`groups`/`setsid` verbs.
192
+ - `Command.priority(level)` — set the child's CPU-scheduling priority, one of
193
+ the named presets `"idle"`, `"below_normal"`, `"normal"`, `"above_normal"`,
194
+ `"high"` (new `Priority` type alias). Unix `nice`/`setpriority`, Windows
195
+ priority class — unlike the privilege/POSIX-only verbs above, supported on
196
+ **both** platform families, so it never raises `Unsupported`. Raising to
197
+ `"high"` on Unix without `CAP_SYS_NICE`/root raises `PermissionDenied`
198
+ instead of silently applying a lower priority.
199
+ - `Command.timeout_opt(seconds)` — like `timeout()`, but takes `float | None`,
200
+ convenient when a timeout arrives from config as `Optional[float]`: a value
201
+ behaves exactly like `timeout(seconds)`, `None` clears a prior `timeout()`
202
+ exactly like `no_timeout()`.
203
+ - `Command.retry_never()` — explicitly opt one command out of retrying, even
204
+ when it runs through a `CliClient` configured with a `default_retry_if`.
205
+ - `NonZeroExit` / `Timeout` / `Signalled` now carry a `stdout_bytes: bytes | None`
206
+ field — the exact raw stdout bytes when the error came from a checking verb over
207
+ `output_bytes()` (e.g. `BytesResult.ensure_success()`), `None` on the text path
208
+ (`run()` / `output()`) where `stdout` is already the complete decoded text.
209
+ When present, these are the exact pre-decode bytes `stdout` is a lossy UTF-8
210
+ view of (they differ only for non-UTF-8 output). Binds processkit 2.1.0's
211
+ `Error::stdout_bytes()`.
212
+
213
+ ### Changed
214
+ - `Command.output_limit(max_bytes=...)`'s byte ceiling now also bounds the raw
215
+ stdout of `output_bytes()` / `aoutput_bytes()`, matching processkit 2.1.0 —
216
+ previously a byte cap bounded only the line-pumped stderr and raw stdout was
217
+ always unbounded. Under `on_overflow="error"` an over-cap `output_bytes()` run
218
+ now raises `OutputTooLarge` (with `max_lines=None` — raw bytes have no line
219
+ count) where it once returned all bytes; under a drop mode its retained bytes
220
+ are bounded to a head/tail with `BytesResult.truncated` set. A `max_lines` cap
221
+ still never bounds raw stdout. This applies to every inherited `output_bytes`
222
+ consumer that runs a `Command` built with such a policy (`CliClient`,
223
+ `Pipeline`, `RunningProcess`, `ProcessGroup`, and the `runner=` doubles). The
224
+ `Supervisor` capture policy is unaffected — it captures line-based output only
225
+ and has no `output_bytes` verb.
226
+
227
+ ### Fixed
228
+ -
229
+
19
230
  ## [1.1.0] - 2026-07-06
20
231
 
21
232
  ### Breaking
@@ -624,6 +835,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
624
835
  - `enable_logging()` enables the crate's `tracing` feature; the bridge pulls
625
836
  `tracing` / `tracing-subscriber` (registry only) into the compiled wheel.
626
837
 
627
- [Unreleased]: https://github.com/ZelAnton/processkit-py/compare/v1.1.0...HEAD
838
+ [Unreleased]: https://github.com/ZelAnton/processkit-py/compare/v1.2.0...HEAD
839
+ [1.2.0]: https://github.com/ZelAnton/processkit-py/compare/v1.1.1...v1.2.0
840
+ [1.1.1]: https://github.com/ZelAnton/processkit-py/compare/v1.1.0...v1.1.1
628
841
  [1.1.0]: https://github.com/ZelAnton/processkit-py/compare/v1.0.0...v1.1.0
629
842
  [1.0.0]: https://github.com/ZelAnton/processkit-py/releases/tag/v1.0.0
@@ -80,6 +80,11 @@ native `uv run pytest`, which is faster for day-to-day work.
80
80
  warning-free `pytest`, plus `cargo fmt` / `clippy` on the Rust side — all
81
81
  configured in [`pyproject.toml`](pyproject.toml); run the
82
82
  [gates above](#build-and-test) locally before opening a pull request.
83
+ - **Docs are built here, not published here.** `docs.yml` runs `mkdocs build
84
+ --strict` as a link/anchor check only; this repo does not deploy to GitHub
85
+ Pages or anywhere else (a separate project owns docs publishing). Do not
86
+ add a Pages/`mike`/`gh-pages` deploy step back — see RELEASING.md's "Docs
87
+ site" note.
83
88
 
84
89
  ## Changelog
85
90
 
@@ -165,9 +165,9 @@ dependencies = [
165
165
 
166
166
  [[package]]
167
167
  name = "processkit"
168
- version = "1.2.0"
168
+ version = "2.1.1"
169
169
  source = "registry+https://github.com/rust-lang/crates.io-index"
170
- checksum = "9d260c8da1b17daaf2f4379bd5c9ec9250d76e0af9c4fcf66e501ab443570ad0"
170
+ checksum = "ccf43ce2a1756fe36382bc56b0bf1697d9ee1c32c0ed5a2afb5abf5163a64756"
171
171
  dependencies = [
172
172
  "async-trait",
173
173
  "encoding_rs",
@@ -184,7 +184,7 @@ dependencies = [
184
184
 
185
185
  [[package]]
186
186
  name = "processkit-py"
187
- version = "1.1.0"
187
+ version = "1.2.0"
188
188
  dependencies = [
189
189
  "processkit",
190
190
  "pyo3",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "processkit-py"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  edition = "2021"
5
5
  description = "PyO3 binding crate for the processkit Python package (compiled into wheels)"
6
6
  license = "MIT"
@@ -34,8 +34,10 @@ pyo3 = { version = "0.29", features = ["abi3-py310"] }
34
34
  # runtime we reuse for BOTH the sync surface (`block_on`) and the async surface
35
35
  # (`future_into_py`), so the binding owns exactly one runtime. Tracks pyo3 0.29.
36
36
  pyo3-async-runtimes = { version = "0.29", features = ["tokio-runtime"] }
37
- # The Rust core. Exact pin the binding tracks API/behaviour churn deliberately,
38
- # not transitively (a 2.0.0 was published then yanked upstream; pin guards us).
37
+ # The Rust core. Compatible-version requirement `2.1` (>=2.1.0, <3.0.0) the
38
+ # binding tracks API/behaviour churn deliberately, not transitively. 1.3.0 (a
39
+ # semver-broken minor) and 2.0.0 (a mistaken publish) were yanked upstream, so
40
+ # 2.1.0 is the floor; the requirement stays within the 2.x major on purpose.
39
41
  #
40
42
  # Features are listed EXPLICITLY with `default-features = false`, so a change to
41
43
  # the crate's default set can never silently alter our public surface:
@@ -51,7 +53,7 @@ pyo3-async-runtimes = { version = "0.29", features = ["tokio-runtime"] }
51
53
  # tests; it has no Python value (the Python test doubles come from the crate's
52
54
  # always-on `doubles` surface — ScriptedRunner / RecordingRunner — plus the
53
55
  # `record`-gated cassette RecordReplayRunner enabled above).
54
- processkit = { version = "=1.2.0", default-features = false, features = [
56
+ processkit = { version = "2.1", default-features = false, features = [
55
57
  "process-control",
56
58
  "limits",
57
59
  "record",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processkit-py
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -102,7 +102,7 @@ The first column is the differentiator: a child's *descendants* are contained an
102
102
  reaped as a unit (Job Object / cgroup v2 / process group), not just the direct
103
103
  child.
104
104
 
105
- > **Status: 1.0 — API frozen.** The public API follows
105
+ > **Stable API.** The public API has been stable since 1.0 and follows
106
106
  > [Semantic Versioning](https://semver.org/): breaking changes land only in a new
107
107
  > major version, so `1.x` upgrades are backward-compatible. See
108
108
  > [CHANGELOG.md](CHANGELOG.md), and [ROADMAP.md](ROADMAP.md) for how it was built.
@@ -186,6 +186,17 @@ async def main():
186
186
  asyncio.run(main())
187
187
  ```
188
188
 
189
+ No Python to write? `python -m processkit run -- <cmd> [args...]` gives a
190
+ shell script or CI step the same kill-on-exit containment and resource
191
+ limits from the command line, no code required:
192
+
193
+ ```bash
194
+ python -m processkit run --timeout 30 --max-memory 536870912 -- pytest -x
195
+ ```
196
+
197
+ See [Command-line usage](docs/cli.md) for the full flag list and exit-code
198
+ contract.
199
+
189
200
  ## Documentation
190
201
 
191
202
  This README is the quick tour. The **[`docs/` guide set](docs/README.md)** goes
@@ -69,7 +69,7 @@ The first column is the differentiator: a child's *descendants* are contained an
69
69
  reaped as a unit (Job Object / cgroup v2 / process group), not just the direct
70
70
  child.
71
71
 
72
- > **Status: 1.0 — API frozen.** The public API follows
72
+ > **Stable API.** The public API has been stable since 1.0 and follows
73
73
  > [Semantic Versioning](https://semver.org/): breaking changes land only in a new
74
74
  > major version, so `1.x` upgrades are backward-compatible. See
75
75
  > [CHANGELOG.md](CHANGELOG.md), and [ROADMAP.md](ROADMAP.md) for how it was built.
@@ -153,6 +153,17 @@ async def main():
153
153
  asyncio.run(main())
154
154
  ```
155
155
 
156
+ No Python to write? `python -m processkit run -- <cmd> [args...]` gives a
157
+ shell script or CI step the same kill-on-exit containment and resource
158
+ limits from the command line, no code required:
159
+
160
+ ```bash
161
+ python -m processkit run --timeout 30 --max-memory 536870912 -- pytest -x
162
+ ```
163
+
164
+ See [Command-line usage](docs/cli.md) for the full flag list and exit-code
165
+ contract.
166
+
156
167
  ## Documentation
157
168
 
158
169
  This README is the quick tour. The **[`docs/` guide set](docs/README.md)** goes
@@ -68,17 +68,13 @@ GitHub Release (wheels + sdist + `SHA256SUMS`).
68
68
  ## Docs site
69
69
 
70
70
  The guides in `docs/` render as a [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/)
71
- site via `.github/workflows/docs.yml`. The build (`mkdocs build --strict`) runs on
72
- every docs change as a link/anchor check; **deployment is opt-in** so there are no
73
- red runs before Pages is set up. To publish the site:
71
+ site. `docs.yml` builds it (`mkdocs build --strict`) on every docs change as a
72
+ link/anchor check only this repo does **not** publish the site (no GitHub
73
+ Pages, no `gh-pages` branch, no `mike`, no `DOCS_DEPLOY`). A separate project
74
+ owns docs publishing. **Do not re-add a Pages/mike deploy job here** — if a
75
+ future dependency bump or template sync tries to reintroduce one, undo it.
74
76
 
75
- 1. Repo **Settings Pages Source: GitHub Actions**.
76
- 2. Repo **Settings → Secrets and variables → Actions → Variables**: add
77
- `DOCS_DEPLOY` = `true`.
78
-
79
- The next push to `main` that touches `docs/` or `mkdocs.yml` then deploys to
80
- `https://zelanton.github.io/processkit-py/`. Preview locally with
81
- `uvx --with mkdocs-material mkdocs serve`.
77
+ Preview the docs locally with `uv run --group docs mkdocs serve`.
82
78
 
83
79
  ## If a release fails
84
80
 
@@ -36,6 +36,14 @@ ready, a patched release is published to PyPI and the advisory is disclosed.
36
36
  [`.github/workflows/ci.yml`](.github/workflows/ci.yml)). It scans the resolved
37
37
  Python dependency tree against the [PyPI Advisory Database](https://github.com/pypa/advisory-database)
38
38
  and fails the build on a known vulnerability.
39
+ - **[cargo-deny](https://github.com/EmbarkStudios/cargo-deny)** is the Rust
40
+ analogue of pip-audit: the `rust-audit` job in
41
+ [`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs
42
+ `cargo deny check advisories bans licenses sources` on every pull request
43
+ and every push to `main`, scanning the compiled Rust dependency tree
44
+ (configured in [`deny.toml`](deny.toml)) against the RustSec Advisory
45
+ Database and failing the build on a known vulnerability, a yanked crate, or
46
+ a disallowed license.
39
47
  - **[Dependabot](.github/dependabot.yml)** opens weekly pull requests to keep
40
48
  GitHub Actions, Python packages (`uv` ecosystem), and Rust crates (`cargo`
41
49
  ecosystem) current, so advisory fixes land promptly.
@@ -0,0 +1,57 @@
1
+ # Benchmarks
2
+
3
+ `pytest-benchmark` timings for the questions [ROADMAP.md](../ROADMAP.md)'s
4
+ Phase 5 asks — "does the bridge add silly overhead?" — with a real number
5
+ attached instead of only the loose pass/fail bound in
6
+ `tests/test_hardening.py::test_no_silly_per_call_overhead`:
7
+
8
+ - **`test_spawn_capture.py`** — spawn + capture a single short-lived command:
9
+ `processkit`'s `Command(...).output()` against the two stdlib "naive"
10
+ equivalents, `subprocess.run(..., capture_output=True)` and
11
+ `asyncio.create_subprocess_exec(...)` + `communicate()`. Same payload on
12
+ all three, so the comparison is per-call overhead, not a differing
13
+ workload.
14
+ - **`test_process_group.py`** — `ProcessGroup` start/exit: creating the
15
+ group's kernel container, entering it, starting one short-lived child,
16
+ tearing the whole tree down.
17
+ - **`test_streaming_throughput.py`** — `RunningProcess.stdout_lines()` (see
18
+ [`docs/streaming.md`](../docs/streaming.md)) draining a known number of
19
+ lines end to end.
20
+ - **`test_output_all.py`** — `output_all()` / `aoutput_all()` at 1/10/50-way
21
+ concurrency (see [`docs/cookbook.md`](../docs/cookbook.md)).
22
+
23
+ ## Running locally
24
+
25
+ This suite is **not** part of the PR gate — it lives in its own
26
+ `bench` dependency-group and is excluded from `testpaths` (`tests/` only), so
27
+ an ordinary `pytest`/`uv run pytest` never collects it. Install the group and
28
+ run it explicitly:
29
+
30
+ ```console
31
+ uv sync --group bench
32
+ uv run pytest benchmarks/ --benchmark-only -p no:xdist -o addopts=""
33
+ ```
34
+
35
+ `-p no:xdist -o addopts=""` disables `-n auto` (the repo's default
36
+ `addopts`) — `pytest-benchmark` needs to run in the main process, in a single
37
+ worker, to produce meaningful timings; under `pytest-xdist` it silently skips
38
+ measuring instead.
39
+
40
+ Useful extras:
41
+
42
+ - `--benchmark-only` skips the normal (non-benchmark) test collection outside
43
+ this directory should it ever leak in; harmless here since `benchmarks/`
44
+ has none, but keeps the invocation copy-pasteable elsewhere.
45
+ - `--benchmark-compare` / `--benchmark-autosave` — compare a run against a
46
+ previously saved one, to check a change before landing it.
47
+ - `--benchmark-json=out.json` — machine-readable results (what the nightly
48
+ CI job uses to render the job-summary table; see below).
49
+
50
+ ## CI
51
+
52
+ The `bench` job in
53
+ [`.github/workflows/nightly-hardening.yml`](../.github/workflows/nightly-hardening.yml)
54
+ runs this suite on the same `schedule`/`workflow_dispatch` triggers as the
55
+ `stress` job — never on `push`/`pull_request` — and publishes the results as
56
+ a table in the job summary, so a regression shows up as a trend across nights
57
+ rather than only when someone happens to run this locally.
@@ -0,0 +1,11 @@
1
+ """Benchmark suite (`pytest-benchmark`) — overhead vs `subprocess`.
2
+
3
+ Not part of the PR gate: `[tool.pytest.ini_options] testpaths` in
4
+ `pyproject.toml` is `["tests"]`, so an ordinary `pytest` run never collects
5
+ this directory. It runs on a schedule (and on manual dispatch) from the
6
+ `bench` job in `.github/workflows/nightly-hardening.yml`, which installs the
7
+ separate `bench` dependency-group and invokes `pytest benchmarks/
8
+ --benchmark-only` explicitly. See `benchmarks/README.md` to run it locally.
9
+ """
10
+
11
+ from __future__ import annotations
@@ -0,0 +1,20 @@
1
+ """Small pieces shared by the benchmark modules — no test collection here."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ #: The interpreter under test, for building child `Command`s/`subprocess`
8
+ #: calls — same idea as `tests/conftest.py`'s `PY`, kept local so this
9
+ #: directory has no import-time dependency on the `tests/` package.
10
+ PY = sys.executable
11
+
12
+ #: A short-lived child that does no work (the pure per-call-overhead
13
+ #: baseline) — the same style as `tests/test_hardening.py`'s
14
+ #: `test_no_silly_per_call_overhead`.
15
+ NOOP_CODE = "pass"
16
+
17
+ #: A payload that also exercises stdout *capture* (not just spawn/exit),
18
+ #: identical across all three "spawn + capture" benchmarks below so the
19
+ #: comparison isolates per-call overhead rather than differing workloads.
20
+ CAPTURE_CODE = "import sys; sys.stdout.write('x' * 4096)"
@@ -0,0 +1,52 @@
1
+ """`output_all` / `aoutput_all` at different concurrency levels — bounded
2
+ parallel batches of short-lived commands (see `docs/cookbook.md`'s
3
+ "many commands, bounded concurrency" recipe).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from typing import TYPE_CHECKING
10
+
11
+ import pytest
12
+
13
+ from processkit import Command, aoutput_all, output_all
14
+
15
+ from ._shared import NOOP_CODE, PY
16
+
17
+ if TYPE_CHECKING:
18
+ from pytest_benchmark.fixture import BenchmarkFixture
19
+
20
+ #: Concurrency == batch size in every case below: N commands, all allowed to
21
+ #: run at once, at each of the sizes the task calls out (1/10/50) — that
22
+ #: isolates how the batch dispatch machinery itself scales, rather than
23
+ #: mixing in a fixed backlog queued behind a narrower `concurrency=`.
24
+ _CONCURRENCIES = (1, 10, 50)
25
+
26
+
27
+ @pytest.mark.parametrize("concurrency", _CONCURRENCIES)
28
+ def test_output_all_concurrency(benchmark: BenchmarkFixture, concurrency: int) -> None:
29
+ commands = [Command(PY, ["-c", NOOP_CODE]) for _ in range(concurrency)]
30
+ benchmark.group = f"output_all(concurrency={concurrency})"
31
+
32
+ def run() -> None:
33
+ results = output_all(commands, concurrency=concurrency)
34
+ assert len(results) == concurrency
35
+
36
+ benchmark(run)
37
+
38
+
39
+ @pytest.mark.parametrize("concurrency", _CONCURRENCIES)
40
+ def test_aoutput_all_concurrency(benchmark: BenchmarkFixture, concurrency: int) -> None:
41
+ commands = [Command(PY, ["-c", NOOP_CODE]) for _ in range(concurrency)]
42
+ benchmark.group = f"aoutput_all(concurrency={concurrency})"
43
+
44
+ async def scenario() -> int:
45
+ results = await aoutput_all(commands, concurrency=concurrency)
46
+ return len(results)
47
+
48
+ def run() -> None:
49
+ count = asyncio.run(scenario())
50
+ assert count == concurrency
51
+
52
+ benchmark(run)
@@ -0,0 +1,26 @@
1
+ """`ProcessGroup` start/exit — the cost of creating the group's kernel
2
+ container (Job Object / cgroup-v2 / process group, see `docs/platforms.md`),
3
+ entering the context manager, starting one short-lived child into it, and
4
+ tearing the whole tree down on exit.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from processkit import Command, ProcessGroup
12
+
13
+ from ._shared import NOOP_CODE, PY
14
+
15
+ if TYPE_CHECKING:
16
+ from pytest_benchmark.fixture import BenchmarkFixture
17
+
18
+
19
+ def test_process_group_start_exit(benchmark: BenchmarkFixture) -> None:
20
+ def run() -> None:
21
+ with ProcessGroup() as group:
22
+ proc = group.start(Command(PY, ["-c", NOOP_CODE]))
23
+ outcome = proc.outcome()
24
+ assert outcome.exited_zero
25
+
26
+ benchmark(run)
@@ -0,0 +1,61 @@
1
+ """Spawn + capture a single short-lived command: `processkit` vs the two
2
+ "naive" ways of doing the same thing from stdlib — `subprocess.run` and
3
+ `asyncio.create_subprocess_exec` + `communicate()`. Same payload on all
4
+ three (`_shared.CAPTURE_CODE`) so the comparison isolates the bridge's
5
+ per-call overhead rather than a differing workload.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import subprocess
12
+ from typing import TYPE_CHECKING
13
+
14
+ from processkit import Command
15
+
16
+ from ._shared import CAPTURE_CODE, PY
17
+
18
+ if TYPE_CHECKING:
19
+ from pytest_benchmark.fixture import BenchmarkFixture
20
+
21
+ _EXPECTED = "x" * 4096
22
+
23
+
24
+ def test_spawn_capture_processkit(benchmark: BenchmarkFixture) -> None:
25
+ def run() -> str:
26
+ result = Command(PY, ["-c", CAPTURE_CODE]).output()
27
+ assert result.is_success
28
+ return result.stdout
29
+
30
+ stdout = benchmark(run)
31
+ assert stdout == _EXPECTED
32
+
33
+
34
+ def test_spawn_capture_subprocess(benchmark: BenchmarkFixture) -> None:
35
+ def run() -> str:
36
+ result = subprocess.run(
37
+ [PY, "-c", CAPTURE_CODE], capture_output=True, text=True, check=True
38
+ )
39
+ return result.stdout
40
+
41
+ stdout = benchmark(run)
42
+ assert stdout == _EXPECTED
43
+
44
+
45
+ def test_spawn_capture_asyncio_subprocess(benchmark: BenchmarkFixture) -> None:
46
+ # `asyncio.run` opens/closes a fresh event loop per call — the same
47
+ # "naive per-call" shape a caller reaching for asyncio.subprocess without
48
+ # already running inside a long-lived loop would write.
49
+ async def scenario() -> str:
50
+ proc = await asyncio.create_subprocess_exec(
51
+ PY, "-c", CAPTURE_CODE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
52
+ )
53
+ stdout, _stderr = await proc.communicate()
54
+ assert proc.returncode == 0
55
+ return stdout.decode()
56
+
57
+ def run() -> str:
58
+ return asyncio.run(scenario())
59
+
60
+ stdout = benchmark(run)
61
+ assert stdout == _EXPECTED