processkit-py 1.1.1__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 (126) hide show
  1. {processkit_py-1.1.1 → processkit_py-1.2.0}/CHANGELOG.md +128 -1
  2. {processkit_py-1.1.1 → processkit_py-1.2.0}/CONTRIBUTING.md +5 -0
  3. {processkit_py-1.1.1 → processkit_py-1.2.0}/Cargo.lock +3 -3
  4. {processkit_py-1.1.1 → processkit_py-1.2.0}/Cargo.toml +1 -1
  5. {processkit_py-1.1.1 → processkit_py-1.2.0}/PKG-INFO +13 -2
  6. {processkit_py-1.1.1 → processkit_py-1.2.0}/README.md +12 -1
  7. {processkit_py-1.1.1 → processkit_py-1.2.0}/RELEASING.md +6 -10
  8. {processkit_py-1.1.1 → 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.1 → processkit_py-1.2.0}/docs/README.md +5 -2
  18. {processkit_py-1.1.1 → processkit_py-1.2.0}/docs/api-reference.md +3 -1
  19. processkit_py-1.2.0/docs/cli.md +114 -0
  20. {processkit_py-1.1.1 → processkit_py-1.2.0}/docs/commands.md +22 -4
  21. {processkit_py-1.1.1 → processkit_py-1.2.0}/docs/cookbook.md +75 -14
  22. {processkit_py-1.1.1 → processkit_py-1.2.0}/docs/event-loops.md +4 -4
  23. {processkit_py-1.1.1 → processkit_py-1.2.0}/docs/internals.md +62 -18
  24. {processkit_py-1.1.1 → processkit_py-1.2.0}/docs/migrating.md +1 -1
  25. {processkit_py-1.1.1 → 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.1 → processkit_py-1.2.0}/docs/streaming.md +109 -31
  28. {processkit_py-1.1.1 → processkit_py-1.2.0}/docs/supervision.md +40 -2
  29. {processkit_py-1.1.1 → processkit_py-1.2.0}/docs/testing.md +1 -1
  30. {processkit_py-1.1.1 → 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.1 → processkit_py-1.2.0}/examples/README.md +4 -2
  33. {processkit_py-1.1.1 → processkit_py-1.2.0}/mkdocs.yml +2 -1
  34. {processkit_py-1.1.1 → processkit_py-1.2.0}/pyproject.toml +52 -3
  35. {processkit_py-1.1.1 → processkit_py-1.2.0}/scripts/ci-privileged-guard.py +7 -3
  36. {processkit_py-1.1.1 → processkit_py-1.2.0}/scripts/gen_api_reference.py +3 -2
  37. {processkit_py-1.1.1 → processkit_py-1.2.0}/scripts/release/cargo_lock.py +4 -1
  38. {processkit_py-1.1.1 → processkit_py-1.2.0}/scripts/release/changelog.py +31 -12
  39. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/batch.rs +10 -7
  40. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/cli.rs +36 -2
  41. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/command.rs +186 -47
  42. processkit_py-1.2.0/src/convert.rs +848 -0
  43. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/group.rs +56 -24
  44. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/lib.rs +8 -2
  45. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/processkit/__init__.py +2 -1
  46. processkit_py-1.2.0/src/processkit/__main__.py +263 -0
  47. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/processkit/_aio.py +101 -11
  48. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/processkit/_processkit.pyi +206 -82
  49. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/processkit/_protocols.py +5 -5
  50. processkit_py-1.2.0/src/processkit/_types.py +117 -0
  51. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/processkit/pytest_plugin.py +7 -0
  52. processkit_py-1.2.0/src/result.rs +867 -0
  53. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/runner.rs +155 -63
  54. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/running.rs +86 -53
  55. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/runtime.rs +1 -8
  56. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/supervisor.rs +209 -8
  57. processkit_py-1.2.0/tests/_liveness.py +195 -0
  58. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/_programs.py +16 -6
  59. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/_typing_pins.py +81 -3
  60. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/property/test_argv_env_roundtrip.py +2 -2
  61. processkit_py-1.2.0/tests/test_batch.py +362 -0
  62. processkit_py-1.2.0/tests/test_ci_privileged_guard.py +102 -0
  63. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_cli_client.py +16 -2
  64. processkit_py-1.2.0/tests/test_cli_main.py +138 -0
  65. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_command.py +624 -4
  66. processkit_py-1.2.0/tests/test_event_loops.py +235 -0
  67. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_hardening.py +8 -0
  68. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_logging.py +20 -1
  69. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_pipelines.py +37 -8
  70. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_pytest_plugin.py +22 -0
  71. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_readiness.py +151 -4
  72. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_release_scripts.py +129 -0
  73. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_streaming.py +140 -0
  74. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_supervisor.py +30 -0
  75. processkit_py-1.2.0/tests/test_threading.py +521 -0
  76. {processkit_py-1.1.1 → processkit_py-1.2.0}/uv.lock +314 -217
  77. processkit_py-1.1.1/examples/04_sandbox_resource_limits.py +0 -82
  78. processkit_py-1.1.1/src/convert.rs +0 -289
  79. processkit_py-1.1.1/src/processkit/_types.py +0 -52
  80. processkit_py-1.1.1/src/result.rs +0 -458
  81. processkit_py-1.1.1/tests/_liveness.py +0 -97
  82. processkit_py-1.1.1/tests/test_batch.py +0 -164
  83. {processkit_py-1.1.1 → processkit_py-1.2.0}/.editorconfig +0 -0
  84. {processkit_py-1.1.1 → processkit_py-1.2.0}/.gitattributes +0 -0
  85. {processkit_py-1.1.1 → processkit_py-1.2.0}/.gitignore +0 -0
  86. {processkit_py-1.1.1 → processkit_py-1.2.0}/.pre-commit-config.yaml +0 -0
  87. {processkit_py-1.1.1 → processkit_py-1.2.0}/.python-version +0 -0
  88. {processkit_py-1.1.1 → processkit_py-1.2.0}/.yamllint.yml +0 -0
  89. {processkit_py-1.1.1 → processkit_py-1.2.0}/LICENSE +0 -0
  90. {processkit_py-1.1.1 → processkit_py-1.2.0}/ROADMAP.md +0 -0
  91. {processkit_py-1.1.1 → processkit_py-1.2.0}/cliff.toml +0 -0
  92. {processkit_py-1.1.1 → processkit_py-1.2.0}/conftest.py +0 -0
  93. {processkit_py-1.1.1 → processkit_py-1.2.0}/docs/pipelines.md +0 -0
  94. {processkit_py-1.1.1 → processkit_py-1.2.0}/docs/platforms.md +0 -0
  95. {processkit_py-1.1.1 → processkit_py-1.2.0}/examples/01_no_orphan_guarantee.py +0 -0
  96. {processkit_py-1.1.1 → processkit_py-1.2.0}/examples/02_wait_for_server.py +0 -0
  97. {processkit_py-1.1.1 → processkit_py-1.2.0}/examples/03_supervise_until_healthy.py +0 -0
  98. {processkit_py-1.1.1 → processkit_py-1.2.0}/rust-toolchain.toml +0 -0
  99. {processkit_py-1.1.1 → processkit_py-1.2.0}/scripts/check-env.ps1 +0 -0
  100. {processkit_py-1.1.1 → processkit_py-1.2.0}/scripts/check-env.sh +0 -0
  101. {processkit_py-1.1.1 → processkit_py-1.2.0}/scripts/ci-privileged-check.sh +0 -0
  102. {processkit_py-1.1.1 → processkit_py-1.2.0}/scripts/release/__init__.py +0 -0
  103. {processkit_py-1.1.1 → processkit_py-1.2.0}/scripts/smoke.py +0 -0
  104. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/cancellation.rs +0 -0
  105. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/errors.rs +0 -0
  106. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/logging.rs +0 -0
  107. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/processkit/py.typed +0 -0
  108. {processkit_py-1.1.1 → processkit_py-1.2.0}/src/processkit/testing.py +0 -0
  109. {processkit_py-1.1.1 → processkit_py-1.2.0}/stubtest-allowlist.txt +0 -0
  110. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/__init__.py +0 -0
  111. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/_docs_snippets.py +0 -0
  112. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/conftest.py +0 -0
  113. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/property/__init__.py +0 -0
  114. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/property/conftest.py +0 -0
  115. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/property/test_numeric_validation.py +0 -0
  116. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/property/test_output_limit.py +0 -0
  117. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/property/test_signals.py +0 -0
  118. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/property/test_wait_for_line.py +0 -0
  119. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_api_reference.py +0 -0
  120. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_api_surface.py +0 -0
  121. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_async.py +0 -0
  122. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_docs_snippets.py +0 -0
  123. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_examples.py +0 -0
  124. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_exceptions.py +0 -0
  125. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_process_group.py +0 -0
  126. {processkit_py-1.1.1 → processkit_py-1.2.0}/tests/test_runner_seam.py +0 -0
@@ -16,6 +16,132 @@ 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
+
19
145
  ## [1.1.1] - 2026-07-06
20
146
 
21
147
  ### Added
@@ -709,7 +835,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
709
835
  - `enable_logging()` enables the crate's `tracing` feature; the bridge pulls
710
836
  `tracing` / `tracing-subscriber` (registry only) into the compiled wheel.
711
837
 
712
- [Unreleased]: https://github.com/ZelAnton/processkit-py/compare/v1.1.1...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
713
840
  [1.1.1]: https://github.com/ZelAnton/processkit-py/compare/v1.1.0...v1.1.1
714
841
  [1.1.0]: https://github.com/ZelAnton/processkit-py/compare/v1.0.0...v1.1.0
715
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 = "2.1.0"
168
+ version = "2.1.1"
169
169
  source = "registry+https://github.com/rust-lang/crates.io-index"
170
- checksum = "d4f13810e1562b13246da25b9a00eb2a3da2fd1159c771d21c2328652cfa067d"
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.1"
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.1"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processkit-py
3
- Version: 1.1.1
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
@@ -0,0 +1,41 @@
1
+ """Line-streaming throughput: `RunningProcess.stdout_lines()` (see
2
+ `docs/streaming.md`) draining a child that writes a known number of lines as
3
+ fast as it can, end to end (spawn through `afinish()`).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from typing import TYPE_CHECKING
10
+
11
+ from processkit import Command
12
+
13
+ from ._shared import PY
14
+
15
+ if TYPE_CHECKING:
16
+ from pytest_benchmark.fixture import BenchmarkFixture
17
+
18
+ #: How many lines the child emits per run. Large enough that the loop
19
+ #: dominates over the fixed spawn cost, small enough that a bench round stays
20
+ #: fast (this file runs once per nightly job, at whatever --benchmark-min-rounds
21
+ #: pytest-benchmark's calibration lands on).
22
+ _LINE_COUNT = 5_000
23
+
24
+ _PRODUCER = f"for i in range({_LINE_COUNT}): print(i)"
25
+
26
+
27
+ def test_stdout_lines_throughput(benchmark: BenchmarkFixture) -> None:
28
+ async def scenario() -> int:
29
+ proc = await Command(PY, ["-c", _PRODUCER]).astart()
30
+ count = 0
31
+ async for _line in proc.stdout_lines():
32
+ count += 1
33
+ finished = await proc.afinish()
34
+ assert finished.exited_zero
35
+ return count
36
+
37
+ def run() -> int:
38
+ return asyncio.run(scenario())
39
+
40
+ lines = benchmark(run)
41
+ assert lines == _LINE_COUNT
@@ -0,0 +1,44 @@
1
+ # cargo-deny configuration. CI runs
2
+ # `cargo deny check advisories bans licenses sources`
3
+ # (see .github/workflows/ci.yml, `rust-audit` job). Kept in the PR gate (not
4
+ # nightly-hardening.yml) for the same reason
5
+ # pip-audit is: the crate is compiled into every wheel users install, so a
6
+ # newly-disclosed RustSec advisory or license violation should block the next
7
+ # merge, not wait for the nightly schedule. Adapted from the sibling
8
+ # ProcessKit-rs repository's `deny.toml` (same categories), scoped to this
9
+ # binding's own Cargo tree (pyo3, pyo3-async-runtimes, tokio, tracing, ...).
10
+ [advisories]
11
+ # Fail on crates with a RustSec security advisory or that have been yanked.
12
+ version = 2
13
+ yanked = "deny"
14
+
15
+ [licenses]
16
+ # This crate is MIT and is compiled into the distributed wheels; the
17
+ # dependency tree must stay on permissive licenses a downstream MIT/Apache
18
+ # consumer can absorb. A new transitive dependency with anything outside this
19
+ # list (copyleft, source-available, unknown) fails CI loudly instead of
20
+ # slipping into a release.
21
+ version = 2
22
+ allow = [
23
+ "MIT",
24
+ "Apache-2.0",
25
+ # target-lexicon (a pyo3-build-config build-dependency) uses this LLVM
26
+ # linking exception on top of Apache-2.0 -- still permissive, no
27
+ # copyleft/attribution burden beyond plain Apache-2.0.
28
+ "Apache-2.0 WITH LLVM-exception",
29
+ "BSD-3-Clause",
30
+ "Unicode-3.0",
31
+ ]
32
+
33
+ [bans]
34
+ # Duplicate versions of the same crate are a warning, not a hard failure.
35
+ multiple-versions = "warn"
36
+ # Wildcard version requirements hide breakage; flag them.
37
+ wildcards = "deny"
38
+
39
+ [sources]
40
+ # Only allow dependencies from the official crates.io registry -- no git
41
+ # dependencies or unknown/private registries sneaking into the tree that
42
+ # gets compiled into every wheel.
43
+ unknown-registry = "deny"
44
+ unknown-git = "deny"
@@ -46,11 +46,13 @@ handy before you ship: it collects every per-OS caveat in one place.
46
46
  | [Coming from subprocess](migrating.md) | Side-by-side translation of `subprocess` / `asyncio.subprocess` patterns, the exception mapping, and the whole-tree containment the stdlib can't give |
47
47
  | [Running commands](commands.md) | The `Command` builder end to end — args, env/sandboxing, stdin, stdout/stderr redirection, encodings, output caps, timeouts, privileges — and every consuming verb (`output`, `run`, `probe`, …) with its error semantics |
48
48
  | [Process groups](process-groups.md) | Kill-on-drop containment: creating groups, spawning, teardown, whole-tree signals, suspend/resume, member listing, resource limits, stats |
49
+ | [Sandboxing untrusted tools](sandboxing.md) | The agent/LLM-tool recipe: locked-down env → bounded output → group resource limits → timeout → teardown, a checklist, and an honest threat model (what this does and does not protect against) |
49
50
  | [Streaming & interactive I/O](streaming.md) | `astart()` and the live `RunningProcess`: line streaming, interactive stdin, readiness probes (`wait_for_line` / `wait_for_port` / `wait_until`), per-run profiling |
50
51
  | [Pipelines](pipelines.md) | Shell-free command pipelines — chain with `.pipe()` or the pipe operator: wiring, pipefail attribution, chain timeouts, binary tails |
51
52
  | [Timeouts & cancellation](timeouts-and-cancellation.md) | How a deadline is *captured* vs when it raises, interrupting a blocked sync call (Ctrl+C), and asyncio cancellation that reaps the whole tree |
52
53
  | [Supervision](supervision.md) | Keeping a child alive: restart policies, backoff & jitter, stop conditions, outcomes |
53
54
  | [Testing your code](testing.md) | The `ProcessRunner` seam — `ScriptedRunner` (incl. scripted streaming `start()`), record/replay cassettes, the `RecordingRunner` spy, the `CliClient` wrapper, and the autoloaded **pytest plugin** (ready-made fixtures + a no-real-spawn guard) |
55
+ | [Command-line usage](cli.md) | `python -m processkit run -- ...`: containment and resource limits for a shell command with no Python to write, flags, exit codes |
54
56
  | [Async runtimes & event loops](event-loops.md) | Which event loops the asyncio-native surface runs on — asyncio and uvloop (yes), anyio-on-asyncio (yes), native trio / anyio-on-trio / curio (no) — and why |
55
57
  | [Platform support](platforms.md) | The containment mechanisms, every per-feature support matrix in one place, and the caveats worth knowing before you ship |
56
58
 
@@ -62,10 +64,11 @@ record/replay, and opt-in logging are all present in every published wheel.
62
64
  There is nothing to opt into at install time:
63
65
 
64
66
  ```bash
65
- pip install processkit-py # supported path once the first PyPI release lands (import name: processkit)
67
+ pip install processkit-py # import name: processkit
66
68
  ```
67
69
 
68
- Until then, build from source (`uv run maturin develop`) — see the
70
+ On a platform without a prebuilt wheel, build from source (`uv run maturin
71
+ develop`) — see the
69
72
  [README](https://github.com/ZelAnton/processkit-py#building-from-source).
70
73
 
71
74
  Distributed as **abi3 wheels for CPython 3.10+** (one wheel per OS/arch runs on