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.
Files changed (120) hide show
  1. {processkit_py-1.2.0 → processkit_py-1.2.1}/CHANGELOG.md +32 -1
  2. {processkit_py-1.2.0 → processkit_py-1.2.1}/Cargo.lock +3 -3
  3. {processkit_py-1.2.0 → processkit_py-1.2.1}/Cargo.toml +4 -4
  4. {processkit_py-1.2.0 → processkit_py-1.2.1}/PKG-INFO +1 -1
  5. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/commands.md +94 -0
  6. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/streaming.md +15 -3
  7. {processkit_py-1.2.0 → processkit_py-1.2.1}/pyproject.toml +1 -1
  8. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/command.rs +17 -3
  9. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/errors.rs +4 -0
  10. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/_processkit.pyi +15 -0
  11. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/running.rs +29 -1
  12. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_command.py +67 -0
  13. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_pipelines.py +4 -1
  14. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_streaming.py +34 -0
  15. {processkit_py-1.2.0 → processkit_py-1.2.1}/uv.lock +1 -1
  16. {processkit_py-1.2.0 → processkit_py-1.2.1}/.editorconfig +0 -0
  17. {processkit_py-1.2.0 → processkit_py-1.2.1}/.gitattributes +0 -0
  18. {processkit_py-1.2.0 → processkit_py-1.2.1}/.gitignore +0 -0
  19. {processkit_py-1.2.0 → processkit_py-1.2.1}/.pre-commit-config.yaml +0 -0
  20. {processkit_py-1.2.0 → processkit_py-1.2.1}/.python-version +0 -0
  21. {processkit_py-1.2.0 → processkit_py-1.2.1}/.yamllint.yml +0 -0
  22. {processkit_py-1.2.0 → processkit_py-1.2.1}/CONTRIBUTING.md +0 -0
  23. {processkit_py-1.2.0 → processkit_py-1.2.1}/LICENSE +0 -0
  24. {processkit_py-1.2.0 → processkit_py-1.2.1}/README.md +0 -0
  25. {processkit_py-1.2.0 → processkit_py-1.2.1}/RELEASING.md +0 -0
  26. {processkit_py-1.2.0 → processkit_py-1.2.1}/ROADMAP.md +0 -0
  27. {processkit_py-1.2.0 → processkit_py-1.2.1}/SECURITY.md +0 -0
  28. {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/README.md +0 -0
  29. {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/__init__.py +0 -0
  30. {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/_shared.py +0 -0
  31. {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/test_output_all.py +0 -0
  32. {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/test_process_group.py +0 -0
  33. {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/test_spawn_capture.py +0 -0
  34. {processkit_py-1.2.0 → processkit_py-1.2.1}/benchmarks/test_streaming_throughput.py +0 -0
  35. {processkit_py-1.2.0 → processkit_py-1.2.1}/cliff.toml +0 -0
  36. {processkit_py-1.2.0 → processkit_py-1.2.1}/conftest.py +0 -0
  37. {processkit_py-1.2.0 → processkit_py-1.2.1}/deny.toml +0 -0
  38. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/README.md +0 -0
  39. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/api-reference.md +0 -0
  40. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/cli.md +0 -0
  41. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/cookbook.md +0 -0
  42. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/event-loops.md +0 -0
  43. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/internals.md +0 -0
  44. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/migrating.md +0 -0
  45. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/pipelines.md +0 -0
  46. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/platforms.md +0 -0
  47. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/process-groups.md +0 -0
  48. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/sandboxing.md +0 -0
  49. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/supervision.md +0 -0
  50. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/testing.md +0 -0
  51. {processkit_py-1.2.0 → processkit_py-1.2.1}/docs/timeouts-and-cancellation.md +0 -0
  52. {processkit_py-1.2.0 → processkit_py-1.2.1}/examples/01_no_orphan_guarantee.py +0 -0
  53. {processkit_py-1.2.0 → processkit_py-1.2.1}/examples/02_wait_for_server.py +0 -0
  54. {processkit_py-1.2.0 → processkit_py-1.2.1}/examples/03_supervise_until_healthy.py +0 -0
  55. {processkit_py-1.2.0 → processkit_py-1.2.1}/examples/04_sandbox_resource_limits.py +0 -0
  56. {processkit_py-1.2.0 → processkit_py-1.2.1}/examples/README.md +0 -0
  57. {processkit_py-1.2.0 → processkit_py-1.2.1}/mkdocs.yml +0 -0
  58. {processkit_py-1.2.0 → processkit_py-1.2.1}/rust-toolchain.toml +0 -0
  59. {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/check-env.ps1 +0 -0
  60. {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/check-env.sh +0 -0
  61. {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/ci-privileged-check.sh +0 -0
  62. {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/ci-privileged-guard.py +0 -0
  63. {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/gen_api_reference.py +0 -0
  64. {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/release/__init__.py +0 -0
  65. {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/release/cargo_lock.py +0 -0
  66. {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/release/changelog.py +0 -0
  67. {processkit_py-1.2.0 → processkit_py-1.2.1}/scripts/smoke.py +0 -0
  68. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/batch.rs +0 -0
  69. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/cancellation.rs +0 -0
  70. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/cli.rs +0 -0
  71. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/convert.rs +0 -0
  72. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/group.rs +0 -0
  73. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/lib.rs +0 -0
  74. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/logging.rs +0 -0
  75. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/__init__.py +0 -0
  76. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/__main__.py +0 -0
  77. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/_aio.py +0 -0
  78. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/_protocols.py +0 -0
  79. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/_types.py +0 -0
  80. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/py.typed +0 -0
  81. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/pytest_plugin.py +0 -0
  82. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/processkit/testing.py +0 -0
  83. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/result.rs +0 -0
  84. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/runner.rs +0 -0
  85. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/runtime.rs +0 -0
  86. {processkit_py-1.2.0 → processkit_py-1.2.1}/src/supervisor.rs +0 -0
  87. {processkit_py-1.2.0 → processkit_py-1.2.1}/stubtest-allowlist.txt +0 -0
  88. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/__init__.py +0 -0
  89. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/_docs_snippets.py +0 -0
  90. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/_liveness.py +0 -0
  91. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/_programs.py +0 -0
  92. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/_typing_pins.py +0 -0
  93. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/conftest.py +0 -0
  94. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/__init__.py +0 -0
  95. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/conftest.py +0 -0
  96. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/test_argv_env_roundtrip.py +0 -0
  97. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/test_numeric_validation.py +0 -0
  98. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/test_output_limit.py +0 -0
  99. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/test_signals.py +0 -0
  100. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/property/test_wait_for_line.py +0 -0
  101. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_api_reference.py +0 -0
  102. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_api_surface.py +0 -0
  103. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_async.py +0 -0
  104. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_batch.py +0 -0
  105. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_ci_privileged_guard.py +0 -0
  106. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_cli_client.py +0 -0
  107. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_cli_main.py +0 -0
  108. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_docs_snippets.py +0 -0
  109. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_event_loops.py +0 -0
  110. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_examples.py +0 -0
  111. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_exceptions.py +0 -0
  112. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_hardening.py +0 -0
  113. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_logging.py +0 -0
  114. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_process_group.py +0 -0
  115. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_pytest_plugin.py +0 -0
  116. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_readiness.py +0 -0
  117. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_release_scripts.py +0 -0
  118. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_runner_seam.py +0 -0
  119. {processkit_py-1.2.0 → processkit_py-1.2.1}/tests/test_supervisor.py +0 -0
  120. {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.0...HEAD
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.1.1"
168
+ version = "2.2.1"
169
169
  source = "registry+https://github.com/rust-lang/crates.io-index"
170
- checksum = "ccf43ce2a1756fe36382bc56b0bf1697d9ee1c32c0ed5a2afb5abf5163a64756"
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.0"
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.0"
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.1` (>=2.1.0, <3.0.0) — the
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.1.0 is the floor; the requirement stays within the 2.x major on purpose.
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.1", default-features = false, features = [
56
+ processkit = { version = "2.2", default-features = false, features = [
57
57
  "process-control",
58
58
  "limits",
59
59
  "record",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: processkit-py
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -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). `take_stdin()` **raises**
295
- `ProcessError` if the `Command` didn't `keep_stdin_open()` or the writer was
296
- already taken so a missing setup fails right here, not later on a `None`.
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.0"
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`. The one
625
- /// caveat is `"high"` on Unix: lowering `nice` below its inherited value
626
- /// needs `CAP_SYS_NICE` (Linux) or an equivalent privilege elsewhere;
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
- spawner = spawn_grandchild_command(pid_file).timeout(0.3)
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:
@@ -964,7 +964,7 @@ wheels = [
964
964
 
965
965
  [[package]]
966
966
  name = "processkit-py"
967
- version = "1.2.0"
967
+ version = "1.2.1"
968
968
  source = { editable = "." }
969
969
 
970
970
  [package.dev-dependencies]
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