processkit-py 1.0.0__cp310-abi3-win_amd64.whl

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/__init__.py ADDED
@@ -0,0 +1,111 @@
1
+ """processkit — thin Python bindings to the `processkit` Rust crate.
2
+
3
+ Process containment with a kernel-backed no-orphan guarantee: spawn child
4
+ process trees and tear them down whole, with honest results (a non-zero exit is
5
+ data, a timeout is captured, a cancellation is an error).
6
+
7
+ Both a synchronous surface and an asyncio-native one are provided:
8
+
9
+ - Sync: `Command(...).output()` / `.run()`, `with ProcessGroup() as g:`, and
10
+ `Command(...).start()` for a scoped background child you watch and tear down.
11
+ - Async: `await Command(...).aoutput()` / `.arun()` / `.astart()`,
12
+ `async with ProcessGroup() as g:`, and streaming over a `RunningProcess`
13
+ (`async for line in proc.stdout_lines(): ...`, interactive `take_stdin()`).
14
+
15
+ A `RunningProcess`'s *consuming* verbs (`wait` / `finish` / `output` /
16
+ `output_bytes` / `profile` / `shutdown`) are coroutines with no `a` prefix —
17
+ they exist for streaming/interactive use and have no synchronous twin to
18
+ disambiguate from — so they are awaited whether the handle came from `start()`
19
+ or `astart()`. Cancelling an awaited run tears down the whole process tree.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from importlib.metadata import PackageNotFoundError, version
25
+
26
+ from ._aio import wait_for, wait_for_line, wait_for_port
27
+ from ._processkit import (
28
+ BytesResult,
29
+ CliClient,
30
+ Command,
31
+ Finished,
32
+ NonZeroExit,
33
+ Outcome,
34
+ OutputEvent,
35
+ OutputEvents,
36
+ OutputTooLarge,
37
+ PermissionDenied,
38
+ Pipeline,
39
+ ProcessError,
40
+ ProcessGroup,
41
+ ProcessGroupStats,
42
+ ProcessNotFound,
43
+ ProcessResult,
44
+ ProcessStdin,
45
+ ResourceLimit,
46
+ Runner,
47
+ RunningProcess,
48
+ RunProfile,
49
+ Signalled,
50
+ StdoutLines,
51
+ SupervisionOutcome,
52
+ Supervisor,
53
+ Timeout,
54
+ Unsupported,
55
+ aoutput_all,
56
+ aoutput_all_bytes,
57
+ enable_logging,
58
+ output_all,
59
+ output_all_bytes,
60
+ )
61
+ from ._runner import ProcessRunner
62
+ from ._types import SignalName, StrPath
63
+
64
+ try:
65
+ # Distribution name is `processkit-py` (the bare `processkit` is taken on
66
+ # PyPI); the import name stays `processkit`. The metadata lookup keys off the
67
+ # distribution name.
68
+ __version__ = version("processkit-py")
69
+ except PackageNotFoundError: # not installed (e.g. running from a source tree)
70
+ __version__ = "unknown"
71
+
72
+ __all__ = [
73
+ "BytesResult",
74
+ "CliClient",
75
+ "Command",
76
+ "Finished",
77
+ "NonZeroExit",
78
+ "Outcome",
79
+ "OutputEvent",
80
+ "OutputEvents",
81
+ "OutputTooLarge",
82
+ "PermissionDenied",
83
+ "Pipeline",
84
+ "ProcessError",
85
+ "ProcessGroup",
86
+ "ProcessGroupStats",
87
+ "ProcessNotFound",
88
+ "ProcessResult",
89
+ "ProcessRunner",
90
+ "ProcessStdin",
91
+ "ResourceLimit",
92
+ "RunProfile",
93
+ "Runner",
94
+ "RunningProcess",
95
+ "SignalName",
96
+ "Signalled",
97
+ "StdoutLines",
98
+ "StrPath",
99
+ "SupervisionOutcome",
100
+ "Supervisor",
101
+ "Timeout",
102
+ "Unsupported",
103
+ "aoutput_all",
104
+ "aoutput_all_bytes",
105
+ "enable_logging",
106
+ "output_all",
107
+ "output_all_bytes",
108
+ "wait_for",
109
+ "wait_for_line",
110
+ "wait_for_port",
111
+ ]
processkit/_aio.py ADDED
@@ -0,0 +1,175 @@
1
+ """Pure-Python asyncio readiness helpers.
2
+
3
+ These compose on top of the compiled async surface (a `StdoutLines` iterator, a
4
+ plain TCP connect) rather than bridging the Rust crate's borrowing probe methods
5
+ — simpler, fully composable, and they work against any server, not only one this
6
+ package started. (The `processkit` crate's 1.1.0 made its probes `Send`-bridgeable,
7
+ but these Python helpers are kept deliberately: a free `wait_for_line(iterator)` /
8
+ `wait_for_port(host, port)` is more composable than methods bound to one started
9
+ `RunningProcess`.)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import contextlib
16
+ from collections.abc import AsyncIterator, Awaitable, Callable
17
+
18
+ from ._processkit import ProcessError
19
+
20
+ __all__ = ["wait_for", "wait_for_line", "wait_for_port"]
21
+
22
+
23
+ async def wait_for(
24
+ predicate: Callable[[], bool | Awaitable[bool]],
25
+ *,
26
+ timeout: float,
27
+ interval: float = 0.05,
28
+ ) -> None:
29
+ """Poll ``predicate`` until it returns true, or ``timeout`` seconds elapse.
30
+
31
+ ``predicate`` may be synchronous or return an awaitable. Polls every
32
+ ``interval`` seconds; raises `TimeoutError` if the deadline passes first. A
33
+ synchronous ``predicate`` runs on the event loop, so keep it non-blocking —
34
+ use an async ``predicate`` for anything that does I/O.
35
+ """
36
+ if interval <= 0:
37
+ raise ValueError("interval must be a positive number of seconds")
38
+ loop = asyncio.get_running_loop()
39
+ deadline = loop.time() + timeout
40
+ while True:
41
+ outcome = predicate()
42
+ if isinstance(outcome, Awaitable):
43
+ # Bound the predicate by the deadline so a hung async predicate (a server
44
+ # that accepts but never answers) can't outlive ``timeout``. Drive it as an
45
+ # explicit task under ``asyncio.wait`` rather than ``asyncio.wait_for``:
46
+ # ``wait_for`` cancels the task *before it runs* at ``timeout<=0`` (which
47
+ # would break "evaluate at least once"), and its own ``TimeoutError`` is
48
+ # indistinguishable from one the predicate raises for its own I/O. With
49
+ # ``asyncio.wait`` we tell the two apart — if our deadline fires the task
50
+ # isn't ``done``; otherwise ``task.result()`` re-raises the predicate's own
51
+ # exception untouched.
52
+ task = asyncio.ensure_future(outcome)
53
+ remaining = deadline - loop.time()
54
+ try:
55
+ done, _pending = await asyncio.wait({task}, timeout=max(remaining, 0.0))
56
+ except asyncio.CancelledError:
57
+ # The caller cancelled us. asyncio.wait (unlike asyncio.wait_for) does
58
+ # NOT cancel its member task, so cancel the predicate ourselves or it
59
+ # leaks. Drain suppressing *any* exception — the task may have already
60
+ # finished with its own error the instant we were cancelled, and that
61
+ # must not replace the cancellation — then re-raise the CancelledError.
62
+ task.cancel()
63
+ with contextlib.suppress(BaseException):
64
+ await task
65
+ raise
66
+ if task not in done:
67
+ # Our deadline fired first: cancel the predicate and drain it (again
68
+ # swallowing whatever it raises on the way down) before timing out.
69
+ task.cancel()
70
+ with contextlib.suppress(BaseException):
71
+ await task
72
+ raise TimeoutError(f"condition not met within {timeout}s")
73
+ ready = task.result()
74
+ else:
75
+ ready = outcome
76
+ if ready:
77
+ return
78
+ remaining = deadline - loop.time()
79
+ if remaining <= 0:
80
+ raise TimeoutError(f"condition not met within {timeout}s")
81
+ await asyncio.sleep(min(interval, remaining))
82
+
83
+
84
+ _Connection = tuple[asyncio.StreamReader, asyncio.StreamWriter]
85
+
86
+
87
+ def _close_pending_connection(task: asyncio.Task[_Connection]) -> None:
88
+ """Close a probe transport that ``open_connection`` produced but that we never
89
+ took ownership of — e.g. a timeout or cancellation that raced a successful
90
+ connect (the classic ``asyncio.wait_for`` leak, where the established
91
+ connection is dropped on the floor). If the task hasn't finished, cancel it so
92
+ it can't produce an orphan transport later.
93
+ """
94
+ if not task.done():
95
+ task.cancel()
96
+ return
97
+ if task.cancelled() or task.exception() is not None:
98
+ return
99
+ _reader, writer = task.result()
100
+ writer.close()
101
+
102
+
103
+ async def wait_for_port(
104
+ host: str,
105
+ port: int,
106
+ *,
107
+ timeout: float,
108
+ interval: float = 0.05,
109
+ ) -> None:
110
+ """Wait until a TCP connection to ``(host, port)`` succeeds.
111
+
112
+ Polls every ``interval`` seconds until the port accepts a connection or
113
+ ``timeout`` seconds elapse, in which case `TimeoutError` is raised.
114
+ """
115
+ if interval <= 0:
116
+ raise ValueError("interval must be a positive number of seconds")
117
+ loop = asyncio.get_running_loop()
118
+ deadline = loop.time() + timeout
119
+ while True:
120
+ remaining = deadline - loop.time()
121
+ if remaining <= 0:
122
+ raise TimeoutError(f"port {host}:{port} not ready within {timeout}s")
123
+ # Own the connect as a task: if a timeout or a cancellation races a
124
+ # successful connect, `asyncio.wait_for` can drop the established transport
125
+ # on the floor (a known leak). Owning the task lets us close it instead.
126
+ conn = asyncio.ensure_future(asyncio.open_connection(host, port))
127
+ try:
128
+ _reader, writer = await asyncio.wait_for(conn, timeout=remaining)
129
+ except (OSError, asyncio.TimeoutError):
130
+ _close_pending_connection(conn)
131
+ remaining = deadline - loop.time()
132
+ if remaining <= 0:
133
+ raise TimeoutError(f"port {host}:{port} not ready within {timeout}s") from None
134
+ # Don't overshoot the deadline by a full interval on the last retry.
135
+ await asyncio.sleep(min(interval, remaining))
136
+ continue
137
+ except asyncio.CancelledError:
138
+ _close_pending_connection(conn)
139
+ raise
140
+ # Connected — close the probe socket (best-effort) and succeed.
141
+ writer.close()
142
+ with contextlib.suppress(OSError):
143
+ await writer.wait_closed()
144
+ return
145
+
146
+
147
+ async def wait_for_line(
148
+ lines: AsyncIterator[str],
149
+ predicate: Callable[[str], bool],
150
+ *,
151
+ timeout: float,
152
+ ) -> str:
153
+ """Consume from an stdout line iterator until ``predicate(line)`` is true.
154
+
155
+ Returns the matching line. Raises `TimeoutError` if no line matches within
156
+ ``timeout`` seconds, or `ProcessError` if the stream ends first. Lines read
157
+ before the match are consumed; iteration may continue afterwards.
158
+ """
159
+
160
+ async def scan() -> str:
161
+ async for line in lines:
162
+ if predicate(line):
163
+ return line
164
+ raise ProcessError("the output stream ended before a matching line")
165
+
166
+ # Own the scan as a task so a line that matches at the exact deadline — which
167
+ # would complete the task just as `wait_for` cancels it — is recovered rather
168
+ # than dropped (the line is already consumed from the iterator).
169
+ task = asyncio.ensure_future(scan())
170
+ try:
171
+ return await asyncio.wait_for(task, timeout)
172
+ except asyncio.TimeoutError:
173
+ if task.done() and not task.cancelled() and task.exception() is None:
174
+ return task.result()
175
+ raise TimeoutError(f"no matching line within {timeout}s") from None
Binary file