processkit-py 1.2.0__tar.gz → 1.2.1__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.2.0 → processkit_py-1.2.1}/CHANGELOG.md +32 -1
- {processkit_py-1.2.0 → processkit_py-1.2.1}/Cargo.lock +3 -3
- {processkit_py-1.2.0 → processkit_py-1.2.1}/Cargo.toml +4 -4
- {processkit_py-1.2.0 → processkit_py-1.2.1}/PKG-INFO +1 -1
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/commands.md +94 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/streaming.md +15 -3
- {processkit_py-1.2.0 → processkit_py-1.2.1}/pyproject.toml +1 -1
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/command.rs +17 -3
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/errors.rs +4 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/_processkit.pyi +15 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/running.rs +29 -1
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_command.py +67 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_pipelines.py +4 -1
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_streaming.py +34 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/uv.lock +1 -1
- {processkit_py-1.2.0 → processkit_py-1.2.1}/.editorconfig +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/.gitattributes +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/.gitignore +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/.pre-commit-config.yaml +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/.python-version +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/.yamllint.yml +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/CONTRIBUTING.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/LICENSE +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/README.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/RELEASING.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/ROADMAP.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/SECURITY.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/README.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/__init__.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/_shared.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/test_output_all.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/test_process_group.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/test_spawn_capture.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/test_streaming_throughput.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/cliff.toml +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/conftest.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/deny.toml +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/README.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/api-reference.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/cli.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/cookbook.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/event-loops.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/internals.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/migrating.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/pipelines.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/platforms.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/process-groups.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/sandboxing.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/supervision.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/testing.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/timeouts-and-cancellation.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/examples/01_no_orphan_guarantee.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/examples/02_wait_for_server.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/examples/03_supervise_until_healthy.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/examples/04_sandbox_resource_limits.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/examples/README.md +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/mkdocs.yml +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/rust-toolchain.toml +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/check-env.ps1 +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/check-env.sh +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/ci-privileged-check.sh +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/ci-privileged-guard.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/gen_api_reference.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/release/__init__.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/release/cargo_lock.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/release/changelog.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/smoke.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/batch.rs +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/cancellation.rs +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/cli.rs +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/convert.rs +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/group.rs +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/lib.rs +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/logging.rs +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/__init__.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/__main__.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/_aio.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/_protocols.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/_types.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/py.typed +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/pytest_plugin.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/testing.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/result.rs +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/runner.rs +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/runtime.rs +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/src/supervisor.rs +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/stubtest-allowlist.txt +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/__init__.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/_docs_snippets.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/_liveness.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/_programs.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/_typing_pins.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/conftest.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/__init__.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/conftest.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/test_argv_env_roundtrip.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/test_numeric_validation.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/test_output_limit.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/test_signals.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/test_wait_for_line.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_api_reference.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_api_surface.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_async.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_batch.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_ci_privileged_guard.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_cli_client.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_cli_main.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_docs_snippets.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_event_loops.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_examples.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_exceptions.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_hardening.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_logging.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_process_group.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_pytest_plugin.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_readiness.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_release_scripts.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_runner_seam.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_supervisor.py +0 -0
- {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_threading.py +0 -0
|
@@ -16,6 +16,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
16
|
### Fixed
|
|
17
17
|
-
|
|
18
18
|
|
|
19
|
+
## [1.2.1] - 2026-07-09
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Add Command.prefer_local, exposing crate 2.2's bare-name resolution override
|
|
24
|
+
- Add Command.prefer_local, exposing crate 2.2's bare-name resolution override
|
|
25
|
+
- Add a runnable Command.prefer_local example to docs
|
|
26
|
+
- Add ProcessStdin.send_control for interactive control-byte delivery
|
|
27
|
+
- Add ProcessStdin.send_control for interactive control-byte delivery
|
|
28
|
+
- Add Command.prefer_local usage example to docs/commands.md
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Broaden Command.priority docstring privilege caveat to cover above_normal and niced-parent normal
|
|
34
|
+
- Bump processkit dependency requirement and lockfile to 2.2.0
|
|
35
|
+
- Bump processkit dependency requirement and lockfile to 2.2.0
|
|
36
|
+
- Broaden Command.priority docstring privilege caveat to cover above_normal and niced-parent normal
|
|
37
|
+
- Initialize integration workspace for batch B-20260709T132808Z
|
|
38
|
+
- Initialize integration workspace for batch B-20260709T144637Z
|
|
39
|
+
- Apply rustfmt to send_control signature
|
|
40
|
+
- Bump processkit dependency to 2.2.1
|
|
41
|
+
- Initialize integration workspace for batch B-20260709T162529Z
|
|
42
|
+
- Bump processkit dependency to 2.2.1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- Fix Windows-incompatible relative path-form assertion in the prefer_local example
|
|
48
|
+
|
|
19
49
|
## [1.2.0] - 2026-07-08
|
|
20
50
|
|
|
21
51
|
### Added
|
|
@@ -835,7 +865,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
835
865
|
- `enable_logging()` enables the crate's `tracing` feature; the bridge pulls
|
|
836
866
|
`tracing` / `tracing-subscriber` (registry only) into the compiled wheel.
|
|
837
867
|
|
|
838
|
-
[Unreleased]: https://github.com/ZelAnton/processkit-py/compare/v1.2.
|
|
868
|
+
[Unreleased]: https://github.com/ZelAnton/processkit-py/compare/v1.2.1...HEAD
|
|
869
|
+
[1.2.1]: https://github.com/ZelAnton/processkit-py/compare/v1.2.0...v1.2.1
|
|
839
870
|
[1.2.0]: https://github.com/ZelAnton/processkit-py/compare/v1.1.1...v1.2.0
|
|
840
871
|
[1.1.1]: https://github.com/ZelAnton/processkit-py/compare/v1.1.0...v1.1.1
|
|
841
872
|
[1.1.0]: https://github.com/ZelAnton/processkit-py/compare/v1.0.0...v1.1.0
|
|
@@ -165,9 +165,9 @@ dependencies = [
|
|
|
165
165
|
|
|
166
166
|
[[package]]
|
|
167
167
|
name = "processkit"
|
|
168
|
-
version = "2.
|
|
168
|
+
version = "2.2.1"
|
|
169
169
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
170
|
-
checksum = "
|
|
170
|
+
checksum = "d5291d099b0b4780f8fdf0e8d9c9f1308f61dd5562bd22115e8d054576eb23bb"
|
|
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.2.
|
|
187
|
+
version = "1.2.1"
|
|
188
188
|
dependencies = [
|
|
189
189
|
"processkit",
|
|
190
190
|
"pyo3",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "processkit-py"
|
|
3
|
-
version = "1.2.
|
|
3
|
+
version = "1.2.1"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
description = "PyO3 binding crate for the processkit Python package (compiled into wheels)"
|
|
6
6
|
license = "MIT"
|
|
@@ -34,10 +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. Compatible-version requirement `2.
|
|
37
|
+
# The Rust core. Compatible-version requirement `2.2` (>=2.2.0, <3.0.0) — the
|
|
38
38
|
# binding tracks API/behaviour churn deliberately, not transitively. 1.3.0 (a
|
|
39
39
|
# semver-broken minor) and 2.0.0 (a mistaken publish) were yanked upstream, so
|
|
40
|
-
# 2.
|
|
40
|
+
# 2.2.0 is the floor; the requirement stays within the 2.x major on purpose.
|
|
41
41
|
#
|
|
42
42
|
# Features are listed EXPLICITLY with `default-features = false`, so a change to
|
|
43
43
|
# the crate's default set can never silently alter our public surface:
|
|
@@ -53,7 +53,7 @@ pyo3-async-runtimes = { version = "0.29", features = ["tokio-runtime"] }
|
|
|
53
53
|
# tests; it has no Python value (the Python test doubles come from the crate's
|
|
54
54
|
# always-on `doubles` surface — ScriptedRunner / RecordingRunner — plus the
|
|
55
55
|
# `record`-gated cassette RecordReplayRunner enabled above).
|
|
56
|
-
processkit = { version = "2.
|
|
56
|
+
processkit = { version = "2.2", default-features = false, features = [
|
|
57
57
|
"process-control",
|
|
58
58
|
"limits",
|
|
59
59
|
"record",
|
|
@@ -10,6 +10,7 @@ so an early return, an exception, or a cancelled task can never leak a child.
|
|
|
10
10
|
- [The two surfaces: sync and async](#the-two-surfaces-sync-and-async)
|
|
11
11
|
- [Picking a verb](#picking-a-verb)
|
|
12
12
|
- [Program, arguments, working directory](#program-arguments-working-directory)
|
|
13
|
+
- [Local program search](#local-program-search)
|
|
13
14
|
- [Environment and sandboxing](#environment-and-sandboxing)
|
|
14
15
|
- [Standard input](#standard-input)
|
|
15
16
|
- [Redirecting stdout and stderr](#redirecting-stdout-and-stderr)
|
|
@@ -109,6 +110,99 @@ cmd.command_line() # "login --password hunter2" — includes the secret!
|
|
|
109
110
|
repr(cmd) # redacted: shows arg COUNT, never values
|
|
110
111
|
```
|
|
111
112
|
|
|
113
|
+
## Local program search
|
|
114
|
+
|
|
115
|
+
Use `prefer_local(dir)` when a bare-name program should resolve from a project or
|
|
116
|
+
toolchain directory before falling back to the system `PATH`: for example
|
|
117
|
+
`node_modules/.bin`, `target/debug`, or a vendored tool directory. The directory
|
|
118
|
+
argument accepts `str` and `os.PathLike[str]`, like `cwd`.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
out = (
|
|
122
|
+
Command("ruff")
|
|
123
|
+
.prefer_local(Path(".venv/bin"))
|
|
124
|
+
.prefer_local(Path("tools/bin"))
|
|
125
|
+
.arg("--version")
|
|
126
|
+
.run()
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Repeated calls accumulate in priority order, so the first preferred directory is
|
|
131
|
+
searched first, then the next, then the normal `PATH`. The search reuses the same
|
|
132
|
+
platform behavior as `PATH` resolution, including `PATHEXT` on Windows.
|
|
133
|
+
|
|
134
|
+
`prefer_local` affects only bare-name programs such as `"ruff"` or `"cargo"`.
|
|
135
|
+
Path-form programs such as `"./ruff"`, `"tools/ruff"`, or an absolute path are
|
|
136
|
+
used as written. It also does not rewrite the child's own `PATH`; it only changes
|
|
137
|
+
how processkit finds the executable to spawn. If the program is not found, the
|
|
138
|
+
preferred directories are included in the failure diagnostics along with the
|
|
139
|
+
normal search locations.
|
|
140
|
+
|
|
141
|
+
Here is a self-contained example that creates two local tool directories and
|
|
142
|
+
prefers both before the system `PATH`:
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
import os
|
|
146
|
+
import stat
|
|
147
|
+
import tempfile
|
|
148
|
+
from pathlib import Path
|
|
149
|
+
|
|
150
|
+
from processkit import Command
|
|
151
|
+
|
|
152
|
+
name = "demo-tool"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def write_tool(directory: Path, text: str) -> Path:
|
|
156
|
+
directory.mkdir(parents=True)
|
|
157
|
+
if os.name == "nt":
|
|
158
|
+
tool = directory / f"{name}.cmd"
|
|
159
|
+
tool.write_text(f"@echo off\necho {text}\n", encoding="utf-8")
|
|
160
|
+
else:
|
|
161
|
+
tool = directory / name
|
|
162
|
+
tool.write_text(f"#!/bin/sh\necho {text}\n", encoding="utf-8")
|
|
163
|
+
tool.chmod(tool.stat().st_mode | stat.S_IXUSR)
|
|
164
|
+
return tool
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
168
|
+
project = Path(tmp)
|
|
169
|
+
first = write_tool(project / "node_modules" / ".bin", "node tool")
|
|
170
|
+
second = write_tool(project / "target" / "debug", "debug tool")
|
|
171
|
+
|
|
172
|
+
# Search order for the bare name "demo-tool":
|
|
173
|
+
# 1. ./node_modules/.bin
|
|
174
|
+
# 2. ./target/debug
|
|
175
|
+
# 3. the parent process PATH
|
|
176
|
+
out = (
|
|
177
|
+
Command(name)
|
|
178
|
+
.cwd(project)
|
|
179
|
+
.prefer_local(first.parent)
|
|
180
|
+
.prefer_local(second.parent)
|
|
181
|
+
.run()
|
|
182
|
+
)
|
|
183
|
+
assert out == "node tool"
|
|
184
|
+
|
|
185
|
+
# The child still receives the inherited PATH unless you change it with
|
|
186
|
+
# env(...). prefer_local only affects processkit's spawn-time lookup.
|
|
187
|
+
assert str(first.parent) not in os.environ.get("PATH", "")
|
|
188
|
+
|
|
189
|
+
# Path-form programs bypass prefer_local and are used exactly as written.
|
|
190
|
+
assert Command(second).prefer_local(first.parent).run() == "debug tool"
|
|
191
|
+
old_cwd = Path.cwd()
|
|
192
|
+
os.chdir(project)
|
|
193
|
+
try:
|
|
194
|
+
assert (
|
|
195
|
+
Command(f"target{os.sep}debug{os.sep}{second.name}")
|
|
196
|
+
.prefer_local(first.parent)
|
|
197
|
+
.run()
|
|
198
|
+
== "debug tool"
|
|
199
|
+
)
|
|
200
|
+
finally:
|
|
201
|
+
os.chdir(old_cwd)
|
|
202
|
+
|
|
203
|
+
print(out)
|
|
204
|
+
```
|
|
205
|
+
|
|
112
206
|
## Environment and sandboxing
|
|
113
207
|
|
|
114
208
|
The environment builders compose, applied in a fixed order at spawn:
|
|
@@ -291,9 +291,21 @@ assert finished.exited_zero
|
|
|
291
291
|
```
|
|
292
292
|
|
|
293
293
|
`ProcessStdin` is fully awaitable: `await write(bytes)`, `write_line(str)`
|
|
294
|
-
(newline + flush), `flush()`, and `close()` (EOF).
|
|
295
|
-
`
|
|
296
|
-
|
|
294
|
+
(newline + flush), `send_control(str)`, `flush()`, and `close()` (EOF).
|
|
295
|
+
`send_control()` accepts exactly one recognized control character and writes
|
|
296
|
+
the mapped control byte to the child's stdin pipe: for example,
|
|
297
|
+
`await stdin.send_control("c")` writes Ctrl-C (`\x03`) and
|
|
298
|
+
`await stdin.send_control("d")` writes Ctrl-D (`\x04`). Invalid input raises
|
|
299
|
+
`ValueError`.
|
|
300
|
+
|
|
301
|
+
This is a byte in a normal pipe, not a terminal signal. It only affects
|
|
302
|
+
children that read and interpret that byte from stdin; real terminal semantics
|
|
303
|
+
such as SIGINT/SIGTSTP delivery require a pseudoterminal, which `processkit`
|
|
304
|
+
does not provide yet.
|
|
305
|
+
|
|
306
|
+
`take_stdin()` **raises** `ProcessError` if the `Command` didn't
|
|
307
|
+
`keep_stdin_open()` or the writer was already taken — so a missing setup fails
|
|
308
|
+
right here, not later on a `None`.
|
|
297
309
|
|
|
298
310
|
**Avoid the full-duplex deadlock.** A child's stdout pipe has a finite OS
|
|
299
311
|
buffer; once it fills, the child blocks *writing* stdout until something reads
|
|
@@ -7,7 +7,7 @@ build-backend = "maturin"
|
|
|
7
7
|
# module-name below): `pip install processkit-py` -> `import processkit`.
|
|
8
8
|
[project]
|
|
9
9
|
name = "processkit-py"
|
|
10
|
-
version = "1.2.
|
|
10
|
+
version = "1.2.1"
|
|
11
11
|
description = "Python bindings to the processkit Rust crate — asyncio-native, no-orphan process containment"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
# abi3 wheels (cp310+): Python 3.9 reached EOL October 2025; dev tools (mypy>=2.1) require 3.10+.
|
|
@@ -95,6 +95,18 @@ impl PyCommand {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/// Search this directory before `PATH` when resolving a bare-name program.
|
|
99
|
+
///
|
|
100
|
+
/// Repeated calls accumulate in priority order. This only affects programs
|
|
101
|
+
/// such as `"tool"`; path-form programs such as `"./tool"` or
|
|
102
|
+
/// `"/opt/tool"` are left unchanged. The child's own `PATH` environment is
|
|
103
|
+
/// not rewritten.
|
|
104
|
+
fn prefer_local(&self, dir: PathBuf) -> Self {
|
|
105
|
+
Self {
|
|
106
|
+
inner: self.inner.clone().prefer_local(dir),
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
98
110
|
fn env(&self, key: &str, value: &str) -> Self {
|
|
99
111
|
Self {
|
|
100
112
|
inner: self.inner.clone().env(key, value),
|
|
@@ -621,9 +633,11 @@ impl PyCommand {
|
|
|
621
633
|
/// priority class, alongside `create_no_window`.
|
|
622
634
|
///
|
|
623
635
|
/// Unlike the privilege/POSIX-only knobs above, `priority` is supported on
|
|
624
|
-
/// **both** platform families and never raises `Unsupported`.
|
|
625
|
-
///
|
|
626
|
-
///
|
|
636
|
+
/// **both** platform families and never raises `Unsupported`. On Unix,
|
|
637
|
+
/// `"high"`, `"above_normal"`, and `"normal"` under a positive-niced parent
|
|
638
|
+
/// raise priority relative to the inherited value. Lowering `nice` below
|
|
639
|
+
/// its inherited value needs `CAP_SYS_NICE` (Linux), root, or an equivalent
|
|
640
|
+
/// privilege elsewhere;
|
|
627
641
|
/// without it the OS refuses the change and the run raises
|
|
628
642
|
/// `PermissionDenied` (never a silent downgrade to a lower priority) —
|
|
629
643
|
/// Windows needs no special privilege for its `HIGH_PRIORITY_CLASS`.
|
|
@@ -109,6 +109,7 @@ fn init_dual_exceptions(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
|
|
109
109
|
py.get_type::<PyFileNotFoundError>(),
|
|
110
110
|
"The program could not be found / spawned. Also a `FileNotFoundError`.",
|
|
111
111
|
)?;
|
|
112
|
+
not_found.setattr("searched", py.None())?;
|
|
112
113
|
let permission_denied = make_dual_exception(
|
|
113
114
|
py,
|
|
114
115
|
"PermissionDenied",
|
|
@@ -288,6 +289,9 @@ pub(crate) fn map_err_ref(error: &processkit::Error) -> PyErr {
|
|
|
288
289
|
E::Exit { .. } => {
|
|
289
290
|
let _ = value.setattr("diagnostic", error.diagnostic());
|
|
290
291
|
}
|
|
292
|
+
E::NotFound { searched, .. } => {
|
|
293
|
+
let _ = value.setattr("searched", searched.as_deref());
|
|
294
|
+
}
|
|
291
295
|
E::OutputTooLarge {
|
|
292
296
|
max_lines,
|
|
293
297
|
max_bytes,
|
|
@@ -154,6 +154,11 @@ class Command:
|
|
|
154
154
|
def arg(self, arg: StrPath) -> Command: ...
|
|
155
155
|
def args(self, args: Args) -> Command: ...
|
|
156
156
|
def cwd(self, path: StrPath) -> Command: ...
|
|
157
|
+
def prefer_local(self, dir: StrPath) -> Command:
|
|
158
|
+
"""Search this directory before ``PATH`` when resolving a bare-name
|
|
159
|
+
program. Repeated calls accumulate in priority order, path-form
|
|
160
|
+
programs are unchanged, and the child's own ``PATH`` is not rewritten."""
|
|
161
|
+
|
|
157
162
|
def env(self, key: str, value: str) -> Command: ...
|
|
158
163
|
def envs(self, vars: Mapping[str, str]) -> Command: ...
|
|
159
164
|
def env_remove(self, key: str) -> Command: ...
|
|
@@ -451,6 +456,13 @@ class ProcessStdin:
|
|
|
451
456
|
|
|
452
457
|
async def write(self, data: ReadableBuffer) -> None: ...
|
|
453
458
|
async def write_line(self, line: str) -> None: ...
|
|
459
|
+
async def send_control(self, control: str) -> None:
|
|
460
|
+
"""Write one mapped control byte, e.g. ``"c"`` -> Ctrl-C (``\\x03``).
|
|
461
|
+
|
|
462
|
+
This writes a byte to the child's stdin pipe, not a terminal signal;
|
|
463
|
+
real SIGINT/SIGTSTP delivery requires a pseudoterminal.
|
|
464
|
+
"""
|
|
465
|
+
|
|
454
466
|
async def flush(self) -> None: ...
|
|
455
467
|
async def close(self) -> None: ...
|
|
456
468
|
|
|
@@ -976,6 +988,9 @@ class ProcessNotFound(ProcessError, FileNotFoundError):
|
|
|
976
988
|
"""
|
|
977
989
|
|
|
978
990
|
program: str
|
|
991
|
+
# The searched directories joined by the platform path separator, or `None`
|
|
992
|
+
# when no PATH-like search was used.
|
|
993
|
+
searched: str | None
|
|
979
994
|
|
|
980
995
|
class PermissionDenied(ProcessError, PermissionError):
|
|
981
996
|
"""The program could not be spawned because of insufficient permissions
|
|
@@ -8,7 +8,7 @@ use processkit::OutputEvents as PkOutputEvents;
|
|
|
8
8
|
use processkit::ProcessStdin as PkProcessStdin;
|
|
9
9
|
use processkit::RunningProcess as PkRunningProcess;
|
|
10
10
|
use processkit::StdoutLines as PkStdoutLines;
|
|
11
|
-
use pyo3::exceptions::{PyOSError, PyStopAsyncIteration};
|
|
11
|
+
use pyo3::exceptions::{PyOSError, PyStopAsyncIteration, PyValueError};
|
|
12
12
|
use pyo3::prelude::*;
|
|
13
13
|
use tokio::sync::Mutex;
|
|
14
14
|
|
|
@@ -53,6 +53,34 @@ impl PyProcessStdin {
|
|
|
53
53
|
})
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/// Send a single control byte to the child's stdin.
|
|
57
|
+
fn send_control<'py>(&self, py: Python<'py>, control: String) -> PyResult<Bound<'py, PyAny>> {
|
|
58
|
+
let mut chars = control.chars();
|
|
59
|
+
let c = chars.next().ok_or_else(|| {
|
|
60
|
+
PyValueError::new_err("send_control() requires exactly one control character")
|
|
61
|
+
})?;
|
|
62
|
+
if chars.next().is_some() {
|
|
63
|
+
return Err(PyValueError::new_err(
|
|
64
|
+
"send_control() requires exactly one control character",
|
|
65
|
+
));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let stdin = self.inner.clone();
|
|
69
|
+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
|
|
70
|
+
let mut guard = stdin.lock().await;
|
|
71
|
+
let writer = guard
|
|
72
|
+
.as_mut()
|
|
73
|
+
.ok_or_else(|| PyOSError::new_err("stdin is closed"))?;
|
|
74
|
+
writer.send_control(c).await.map_err(|err| {
|
|
75
|
+
if err.kind() == std::io::ErrorKind::InvalidInput {
|
|
76
|
+
PyValueError::new_err(err.to_string())
|
|
77
|
+
} else {
|
|
78
|
+
PyErr::from(err)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
/// Flush buffered writes to the child.
|
|
57
85
|
fn flush<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
|
|
58
86
|
let stdin = self.inner.clone();
|
|
@@ -46,6 +46,18 @@ from processkit.testing import RecordingRunner, Reply, ScriptedRunner
|
|
|
46
46
|
from .conftest import NO_SUCH_PROGRAM, PY
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
def _make_executable_command(directory: pathlib.Path, name: str, output: str) -> pathlib.Path:
|
|
50
|
+
if os.name == "nt":
|
|
51
|
+
path = directory / f"{name}.cmd"
|
|
52
|
+
path.write_text(f"@echo off\r\necho {output}\r\n", encoding="utf-8")
|
|
53
|
+
return path
|
|
54
|
+
|
|
55
|
+
path = directory / name
|
|
56
|
+
path.write_text(f"#!/bin/sh\nprintf '%s\\n' {output!r}\n", encoding="utf-8")
|
|
57
|
+
path.chmod(0o755)
|
|
58
|
+
return path
|
|
59
|
+
|
|
60
|
+
|
|
49
61
|
def test_output_captures_stdout_and_code() -> None:
|
|
50
62
|
result = Command(PY, ["-c", "print('hello')"]).output()
|
|
51
63
|
assert result.stdout.strip() == "hello"
|
|
@@ -96,6 +108,61 @@ def test_missing_program_raises_process_not_found() -> None:
|
|
|
96
108
|
Command(NO_SUCH_PROGRAM).output()
|
|
97
109
|
|
|
98
110
|
|
|
111
|
+
def test_prefer_local_finds_program_before_path(
|
|
112
|
+
tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
|
|
113
|
+
) -> None:
|
|
114
|
+
local_dir = tmp_path / "local-bin"
|
|
115
|
+
path_dir = tmp_path / "path-bin"
|
|
116
|
+
local_dir.mkdir()
|
|
117
|
+
path_dir.mkdir()
|
|
118
|
+
_make_executable_command(local_dir, "pk-tool", "local")
|
|
119
|
+
_make_executable_command(path_dir, "pk-tool", "path")
|
|
120
|
+
monkeypatch.setenv("PATH", str(path_dir))
|
|
121
|
+
|
|
122
|
+
assert Command("pk-tool").prefer_local(local_dir).run() == "local"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_prefer_local_accumulates_in_priority_order(tmp_path: pathlib.Path) -> None:
|
|
126
|
+
first_dir = tmp_path / "first"
|
|
127
|
+
second_dir = tmp_path / "second"
|
|
128
|
+
first_dir.mkdir()
|
|
129
|
+
second_dir.mkdir()
|
|
130
|
+
_make_executable_command(first_dir, "pk-tool", "first")
|
|
131
|
+
_make_executable_command(second_dir, "pk-tool", "second")
|
|
132
|
+
|
|
133
|
+
output = Command("pk-tool").prefer_local(first_dir).prefer_local(second_dir).run()
|
|
134
|
+
|
|
135
|
+
assert output == "first"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_prefer_local_does_not_affect_path_form_program(tmp_path: pathlib.Path) -> None:
|
|
139
|
+
local_dir = tmp_path / "local-bin"
|
|
140
|
+
explicit_dir = tmp_path / "explicit-bin"
|
|
141
|
+
local_dir.mkdir()
|
|
142
|
+
explicit_dir.mkdir()
|
|
143
|
+
_make_executable_command(local_dir, "pk-tool", "local")
|
|
144
|
+
explicit_program = _make_executable_command(explicit_dir, "pk-tool", "explicit")
|
|
145
|
+
|
|
146
|
+
assert Command(explicit_program).prefer_local(local_dir).run() == "explicit"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_prefer_local_failure_diagnostics_include_preferred_dirs(
|
|
150
|
+
tmp_path: pathlib.Path,
|
|
151
|
+
) -> None:
|
|
152
|
+
first_dir = tmp_path / "first"
|
|
153
|
+
second_dir = tmp_path / "second"
|
|
154
|
+
first_dir.mkdir()
|
|
155
|
+
second_dir.mkdir()
|
|
156
|
+
|
|
157
|
+
with pytest.raises(ProcessNotFound) as excinfo:
|
|
158
|
+
Command(NO_SUCH_PROGRAM).prefer_local(first_dir).prefer_local(second_dir).output()
|
|
159
|
+
|
|
160
|
+
searched = excinfo.value.searched
|
|
161
|
+
assert searched is not None
|
|
162
|
+
assert str(first_dir) in searched
|
|
163
|
+
assert str(second_dir) in searched
|
|
164
|
+
|
|
165
|
+
|
|
99
166
|
def test_timeout_is_captured_by_output() -> None:
|
|
100
167
|
result = Command(PY, ["-c", "import time; time.sleep(5)"]).timeout(0.3).output()
|
|
101
168
|
assert result.timed_out
|
|
@@ -136,7 +136,10 @@ def test_pipeline_stage_timeout_kills_its_whole_subtree(pid_file: pathlib.Path)
|
|
|
136
136
|
# stage's whole subtree, including a grandchild it forks off -- previously
|
|
137
137
|
# the stage's own kill reached only its direct child, so a forking stage's
|
|
138
138
|
# grandchild survived, kept the pipe open, and stalled the downstream stage.
|
|
139
|
-
|
|
139
|
+
# Keep this comfortably above Windows process-start latency under xdist:
|
|
140
|
+
# the timeout must fire after the grandchild PID is observable, otherwise
|
|
141
|
+
# the probe races the very timeout whose teardown behavior it is testing.
|
|
142
|
+
spawner = spawn_grandchild_command(pid_file).timeout(2.0)
|
|
140
143
|
downstream = Command(PY, ["-c", "import sys; sys.stdin.read()"])
|
|
141
144
|
pipe = spawner | downstream
|
|
142
145
|
|
|
@@ -43,6 +43,14 @@ _ECHO_UPPER = (
|
|
|
43
43
|
"import sys; [(sys.stdout.write(line.upper()), sys.stdout.flush()) for line in sys.stdin]"
|
|
44
44
|
)
|
|
45
45
|
|
|
46
|
+
# Echoes the first raw stdin byte to stdout.
|
|
47
|
+
_ECHO_ONE_STDIN_BYTE = (
|
|
48
|
+
"import sys; "
|
|
49
|
+
"data = sys.stdin.buffer.read(1); "
|
|
50
|
+
"sys.stdout.buffer.write(data); "
|
|
51
|
+
"sys.stdout.buffer.flush()"
|
|
52
|
+
)
|
|
53
|
+
|
|
46
54
|
# stdout + stderr on both streams.
|
|
47
55
|
_BOTH_STREAMS = (
|
|
48
56
|
"import sys; "
|
|
@@ -223,6 +231,32 @@ def test_interactive_stdin_write_accepts_bytearray_and_memoryview() -> None:
|
|
|
223
231
|
assert asyncio.run(scenario()) == ["FROM-BYTEARRAY", "FROM-MEMORYVIEW"]
|
|
224
232
|
|
|
225
233
|
|
|
234
|
+
def test_interactive_stdin_send_control_writes_control_byte() -> None:
|
|
235
|
+
async def scenario() -> bytes:
|
|
236
|
+
proc = await Command(PY, ["-c", _ECHO_ONE_STDIN_BYTE]).keep_stdin_open().astart()
|
|
237
|
+
stdin = proc.take_stdin()
|
|
238
|
+
await stdin.send_control("d")
|
|
239
|
+
await stdin.close()
|
|
240
|
+
result = await proc.aoutput_bytes()
|
|
241
|
+
return result.stdout
|
|
242
|
+
|
|
243
|
+
assert asyncio.run(scenario()) == b"\x04"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_interactive_stdin_send_control_rejects_invalid_argument() -> None:
|
|
247
|
+
async def scenario() -> None:
|
|
248
|
+
proc = await Command(PY, ["-c", _ECHO_ONE_STDIN_BYTE]).keep_stdin_open().astart()
|
|
249
|
+
stdin = proc.take_stdin()
|
|
250
|
+
with pytest.raises(ValueError):
|
|
251
|
+
await stdin.send_control("0")
|
|
252
|
+
with pytest.raises(ValueError):
|
|
253
|
+
await stdin.send_control("cc")
|
|
254
|
+
await stdin.close()
|
|
255
|
+
await proc.aoutcome()
|
|
256
|
+
|
|
257
|
+
asyncio.run(scenario())
|
|
258
|
+
|
|
259
|
+
|
|
226
260
|
def test_take_stdin_is_once() -> None:
|
|
227
261
|
# The first take hands over the handle; a second take raises (consumed).
|
|
228
262
|
async def scenario() -> None:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|