sequential-hooks 0.1.0__py3-none-any.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.
Files changed (43) hide show
  1. plugins/agy/_sequential_hooks/__init__.py +1 -0
  2. plugins/agy/_sequential_hooks/_adapter.py +566 -0
  3. plugins/agy/_sequential_hooks/_doctor.py +413 -0
  4. plugins/claude/_sequential_hooks/__init__.py +1 -0
  5. plugins/claude/_sequential_hooks/_adapter.py +560 -0
  6. plugins/claude/_sequential_hooks/_doctor.py +455 -0
  7. plugins/codex/_sequential_hooks/__init__.py +1 -0
  8. plugins/codex/_sequential_hooks/_adapter.py +466 -0
  9. plugins/codex/_sequential_hooks/_doctor.py +563 -0
  10. sequential_hooks/__init__.py +3 -0
  11. sequential_hooks/__main__.py +5 -0
  12. sequential_hooks/_arguments.py +222 -0
  13. sequential_hooks/_cleanup.py +88 -0
  14. sequential_hooks/_cli.py +440 -0
  15. sequential_hooks/_containment/__init__.py +410 -0
  16. sequential_hooks/_containment/_posix.py +236 -0
  17. sequential_hooks/_containment/_uncontained.py +239 -0
  18. sequential_hooks/_containment/_windows.py +853 -0
  19. sequential_hooks/_containment/_windows_api.py +570 -0
  20. sequential_hooks/_containment/_windows_launcher.py +189 -0
  21. sequential_hooks/_diagnostics.py +265 -0
  22. sequential_hooks/_doctor/__init__.py +342 -0
  23. sequential_hooks/_doctor/_command.py +469 -0
  24. sequential_hooks/_doctor/_common.py +448 -0
  25. sequential_hooks/_doctor/_types.py +94 -0
  26. sequential_hooks/_downstream.py +181 -0
  27. sequential_hooks/_executable.py +187 -0
  28. sequential_hooks/_executor.py +825 -0
  29. sequential_hooks/_registry.py +103 -0
  30. sequential_hooks/_runner.py +48 -0
  31. sequential_hooks/_types.py +125 -0
  32. sequential_hooks/hosts/__init__.py +130 -0
  33. sequential_hooks/hosts/_contract.py +434 -0
  34. sequential_hooks/hosts/_inspection.py +78 -0
  35. sequential_hooks/hosts/_json.py +150 -0
  36. sequential_hooks/hosts/_records.py +273 -0
  37. sequential_hooks/hosts/_skeleton.py +1326 -0
  38. sequential_hooks/py.typed +0 -0
  39. sequential_hooks-0.1.0.dist-info/METADATA +90 -0
  40. sequential_hooks-0.1.0.dist-info/RECORD +43 -0
  41. sequential_hooks-0.1.0.dist-info/WHEEL +4 -0
  42. sequential_hooks-0.1.0.dist-info/entry_points.txt +7 -0
  43. sequential_hooks-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,239 @@
1
+ """Run explicitly allowed children without process-tree containment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import subprocess
7
+ import sys
8
+ import time
9
+ from typing import TYPE_CHECKING, BinaryIO, cast
10
+
11
+ from sequential_hooks._containment import ContainmentError, start_process
12
+ from sequential_hooks._types import ContainmentState
13
+
14
+ if TYPE_CHECKING:
15
+ from sequential_hooks._containment import ManagedStep, StderrReader
16
+ from sequential_hooks._containment._windows_api import WindowsApi
17
+ from sequential_hooks._types import Step
18
+
19
+ _ABORT_GRACE_SECONDS = 1.0
20
+ _CREATE_NEW_PROCESS_GROUP = cast(
21
+ 'int',
22
+ getattr(subprocess, 'CREATE_NEW_PROCESS_GROUP', 0x00000200),
23
+ )
24
+ _FORCE_KILL_WAIT_SECONDS = 1.0
25
+
26
+
27
+ class UncontainedBackend:
28
+ """Create only explicitly authorized uncontained processes."""
29
+
30
+ def start(self, step: Step, *, allow_uncontained: bool) -> ManagedStep:
31
+ """Create one explicitly authorized uncontained process.
32
+
33
+ Args:
34
+ step: Literal argv to start.
35
+ allow_uncontained: Whether the user explicitly allowed fallback.
36
+
37
+ Returns:
38
+ Fresh uncontained immediate process.
39
+
40
+ Raises:
41
+ ContainmentError: If fallback was not explicitly allowed.
42
+ """
43
+ if not allow_uncontained:
44
+ raise ContainmentError('uncontained execution requires an explicit override')
45
+ return _UncontainedManagedStep(step)
46
+
47
+ def recover_release_failure(
48
+ self,
49
+ step: Step,
50
+ failed: ManagedStep,
51
+ *,
52
+ allow_uncontained: bool,
53
+ ) -> ManagedStep:
54
+ """Return a fresh explicitly uncontained process.
55
+
56
+ Args:
57
+ step: Step whose prepared process could not be released.
58
+ failed: Failed managed process already disposed by the executor.
59
+ allow_uncontained: Whether the user explicitly allowed fallback.
60
+
61
+ Returns:
62
+ Fresh uncontained immediate process.
63
+
64
+ Raises:
65
+ ContainmentError: If fallback was not explicitly allowed.
66
+ """
67
+ del failed
68
+ return self.start(step, allow_uncontained=allow_uncontained)
69
+
70
+
71
+ class _UncontainedManagedStep:
72
+ """Own only one explicitly uncontained immediate process."""
73
+
74
+ def __init__(
75
+ self,
76
+ step: Step,
77
+ *,
78
+ platform_name: str | None = None,
79
+ windows_api: WindowsApi | None = None,
80
+ ) -> None:
81
+ """Start one explicitly uncontained child and validate its pipes."""
82
+ selected_platform = sys.platform if platform_name is None else platform_name
83
+ self._graceful_signal_forwarded = False
84
+ self._windows_api = None
85
+ if selected_platform == 'win32':
86
+ if windows_api is None:
87
+ from sequential_hooks._containment._windows_api import ( # noqa: PLC0415
88
+ NativeWindowsApi,
89
+ )
90
+
91
+ windows_api = NativeWindowsApi()
92
+ self._windows_api = windows_api
93
+ creationflags = _CREATE_NEW_PROCESS_GROUP if self._windows_api is not None else 0
94
+ self._process = start_process(
95
+ step,
96
+ contained=False,
97
+ creationflags=creationflags,
98
+ )
99
+ if (
100
+ self._process.stdin is None
101
+ or self._process.stdout is None
102
+ or self._process.stderr is None
103
+ ):
104
+ self.terminate_tree(_ABORT_GRACE_SECONDS)
105
+ raise ContainmentError('uncontained child pipes were not created')
106
+
107
+ def _terminate_windows(self, api: WindowsApi, grace_seconds: float) -> None:
108
+ """Reap the owned native process within the grace and force deadlines."""
109
+ grace_deadline = time.monotonic() + max(0.0, grace_seconds)
110
+ exited = self._process.poll() is not None
111
+ termination_requested = False
112
+ try:
113
+ if not exited and not self._graceful_signal_forwarded:
114
+ with contextlib.suppress(ProcessLookupError):
115
+ self._process.terminate()
116
+ termination_requested = True
117
+ if api.wait_process(self._process, max(0.0, grace_deadline - time.monotonic())):
118
+ self._process.wait(timeout=0.0)
119
+ return
120
+ force_deadline = time.monotonic() + _FORCE_KILL_WAIT_SECONDS
121
+ if not exited and not termination_requested:
122
+ self._process.kill()
123
+ if not api.wait_process(self._process, max(0.0, force_deadline - time.monotonic())):
124
+ raise ContainmentError(
125
+ 'uncontained immediate process could not be reaped after kill'
126
+ )
127
+ self._process.wait(timeout=max(0.0, force_deadline - time.monotonic()))
128
+ except (OSError, subprocess.TimeoutExpired) as error:
129
+ raise ContainmentError(
130
+ 'uncontained immediate process could not be reaped after kill'
131
+ ) from error
132
+
133
+ @property
134
+ def containment(self) -> ContainmentState:
135
+ """Return the reduced containment state.
136
+
137
+ Returns:
138
+ Explicitly uncontained state.
139
+ """
140
+ return ContainmentState.UNCONTAINED
141
+
142
+ def forward_signal(self, signum: int) -> None:
143
+ """Forward one graceful signal to the immediate process.
144
+
145
+ Args:
146
+ signum: Actual signal received by the wrapper.
147
+ """
148
+ if self._graceful_signal_forwarded or self._process.poll() is not None:
149
+ return
150
+ if self._windows_api is not None:
151
+ del signum
152
+ try:
153
+ delivered = self._windows_api.generate_ctrl_break(self._process.pid)
154
+ except OSError:
155
+ return
156
+ if not delivered:
157
+ return
158
+ else:
159
+ try:
160
+ self._process.send_signal(signum)
161
+ except ProcessLookupError:
162
+ return
163
+ self._graceful_signal_forwarded = True
164
+
165
+ @property
166
+ def process(self) -> subprocess.Popen[bytes]:
167
+ """Return the immediate process completion handle.
168
+
169
+ Returns:
170
+ Immediate uncontained process.
171
+ """
172
+ return self._process
173
+
174
+ def release(self) -> None:
175
+ """Do nothing because the uncontained child starts during construction."""
176
+
177
+ @property
178
+ def stderr(self) -> StderrReader:
179
+ """Return the parent stderr pipe.
180
+
181
+ Returns:
182
+ Binary stderr reader.
183
+ """
184
+ return cast('StderrReader', self._process.stderr)
185
+
186
+ @property
187
+ def stdin(self) -> BinaryIO:
188
+ """Return the parent stdin pipe.
189
+
190
+ Returns:
191
+ Binary stdin writer.
192
+ """
193
+ return cast('BinaryIO', self._process.stdin)
194
+
195
+ @property
196
+ def stdout(self) -> BinaryIO:
197
+ """Return the parent stdout pipe.
198
+
199
+ Returns:
200
+ Binary stdout reader.
201
+ """
202
+ return cast('BinaryIO', self._process.stdout)
203
+
204
+ def terminate_tree(self, grace_seconds: float) -> None:
205
+ """Terminate only the immediate process after a bounded grace.
206
+
207
+ Args:
208
+ grace_seconds: Maximum graceful wait before force termination.
209
+
210
+ Raises:
211
+ ContainmentError: If the immediate process cannot be reaped after
212
+ forced termination.
213
+ """
214
+ if self._windows_api is not None:
215
+ self._terminate_windows(self._windows_api, grace_seconds)
216
+ return
217
+ if self._process.poll() is not None:
218
+ return
219
+ if not self._graceful_signal_forwarded:
220
+ with contextlib.suppress(ProcessLookupError):
221
+ self._process.terminate()
222
+ try:
223
+ self._process.wait(timeout=max(0.0, grace_seconds))
224
+ except subprocess.TimeoutExpired:
225
+ self._process.kill()
226
+ try:
227
+ self._process.wait(timeout=_FORCE_KILL_WAIT_SECONDS)
228
+ except subprocess.TimeoutExpired as error:
229
+ raise ContainmentError(
230
+ 'uncontained immediate process could not be reaped after kill'
231
+ ) from error
232
+
233
+ def abort_before_release(self) -> None:
234
+ """Dispose of the immediate process before release."""
235
+ self.terminate_tree(_ABORT_GRACE_SECONDS)
236
+
237
+ def finish(self) -> None:
238
+ """Terminate only the remaining immediate process."""
239
+ self.terminate_tree(_ABORT_GRACE_SECONDS)