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 +111 -0
- processkit/_aio.py +175 -0
- processkit/_processkit.pyd +0 -0
- processkit/_processkit.pyi +658 -0
- processkit/_runner.py +46 -0
- processkit/_types.py +17 -0
- processkit/py.typed +0 -0
- processkit/testing.py +30 -0
- processkit_py-1.0.0.dist-info/METADATA +490 -0
- processkit_py-1.0.0.dist-info/RECORD +13 -0
- processkit_py-1.0.0.dist-info/WHEEL +4 -0
- processkit_py-1.0.0.dist-info/licenses/LICENSE +21 -0
- processkit_py-1.0.0.dist-info/sboms/processkit.cyclonedx.json +2112 -0
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
|