Skip to content

Commit 5d96c84

Browse files
committed
Add Command.prefer_local usage example to docs/commands.md
Demonstrates preferring a local project directory over PATH for bare-name resolution, including stacking multiple directories in priority order and the bare-name-only boundary, consistent with the prefer_local docs from T-072.
2 parents 62b5af8 + 031af85 commit 5d96c84

5 files changed

Lines changed: 93 additions & 5 deletions

File tree

‎docs/streaming.md‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,9 +291,21 @@ assert finished.exited_zero
291291
```
292292

293293
`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`.
297309

298310
**Avoid the full-duplex deadlock.** A child's stdout pipe has a finite OS
299311
buffer; once it fills, the child blocks *writing* stdout until something reads

‎src/processkit/_processkit.pyi‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,13 @@ class ProcessStdin:
456456

457457
async def write(self, data: ReadableBuffer) -> None: ...
458458
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+
459466
async def flush(self) -> None: ...
460467
async def close(self) -> None: ...
461468

‎src/running.rs‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use processkit::OutputEvents as PkOutputEvents;
88
use processkit::ProcessStdin as PkProcessStdin;
99
use processkit::RunningProcess as PkRunningProcess;
1010
use processkit::StdoutLines as PkStdoutLines;
11-
use pyo3::exceptions::{PyOSError, PyStopAsyncIteration};
11+
use pyo3::exceptions::{PyOSError, PyStopAsyncIteration, PyValueError};
1212
use pyo3::prelude::*;
1313
use tokio::sync::Mutex;
1414

@@ -53,6 +53,38 @@ impl PyProcessStdin {
5353
})
5454
}
5555

56+
/// Send a single control byte to the child's stdin.
57+
fn send_control<'py>(
58+
&self,
59+
py: Python<'py>,
60+
control: String,
61+
) -> PyResult<Bound<'py, PyAny>> {
62+
let mut chars = control.chars();
63+
let c = chars.next().ok_or_else(|| {
64+
PyValueError::new_err("send_control() requires exactly one control character")
65+
})?;
66+
if chars.next().is_some() {
67+
return Err(PyValueError::new_err(
68+
"send_control() requires exactly one control character",
69+
));
70+
}
71+
72+
let stdin = self.inner.clone();
73+
pyo3_async_runtimes::tokio::future_into_py(py, async move {
74+
let mut guard = stdin.lock().await;
75+
let writer = guard
76+
.as_mut()
77+
.ok_or_else(|| PyOSError::new_err("stdin is closed"))?;
78+
writer.send_control(c).await.map_err(|err| {
79+
if err.kind() == std::io::ErrorKind::InvalidInput {
80+
PyValueError::new_err(err.to_string())
81+
} else {
82+
PyErr::from(err)
83+
}
84+
})
85+
})
86+
}
87+
5688
/// Flush buffered writes to the child.
5789
fn flush<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
5890
let stdin = self.inner.clone();

‎tests/test_pipelines.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,10 @@ def test_pipeline_stage_timeout_kills_its_whole_subtree(pid_file: pathlib.Path)
136136
# stage's whole subtree, including a grandchild it forks off -- previously
137137
# the stage's own kill reached only its direct child, so a forking stage's
138138
# 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)
140143
downstream = Command(PY, ["-c", "import sys; sys.stdin.read()"])
141144
pipe = spawner | downstream
142145

‎tests/test_streaming.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@
4343
"import sys; [(sys.stdout.write(line.upper()), sys.stdout.flush()) for line in sys.stdin]"
4444
)
4545

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+
4654
# stdout + stderr on both streams.
4755
_BOTH_STREAMS = (
4856
"import sys; "
@@ -223,6 +231,32 @@ async def scenario() -> list[str]:
223231
assert asyncio.run(scenario()) == ["FROM-BYTEARRAY", "FROM-MEMORYVIEW"]
224232

225233

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+
226260
def test_take_stdin_is_once() -> None:
227261
# The first take hands over the handle; a second take raises (consumed).
228262
async def scenario() -> None:

0 commit comments

Comments
 (0)