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.
- plugins/agy/_sequential_hooks/__init__.py +1 -0
- plugins/agy/_sequential_hooks/_adapter.py +566 -0
- plugins/agy/_sequential_hooks/_doctor.py +413 -0
- plugins/claude/_sequential_hooks/__init__.py +1 -0
- plugins/claude/_sequential_hooks/_adapter.py +560 -0
- plugins/claude/_sequential_hooks/_doctor.py +455 -0
- plugins/codex/_sequential_hooks/__init__.py +1 -0
- plugins/codex/_sequential_hooks/_adapter.py +466 -0
- plugins/codex/_sequential_hooks/_doctor.py +563 -0
- sequential_hooks/__init__.py +3 -0
- sequential_hooks/__main__.py +5 -0
- sequential_hooks/_arguments.py +222 -0
- sequential_hooks/_cleanup.py +88 -0
- sequential_hooks/_cli.py +440 -0
- sequential_hooks/_containment/__init__.py +410 -0
- sequential_hooks/_containment/_posix.py +236 -0
- sequential_hooks/_containment/_uncontained.py +239 -0
- sequential_hooks/_containment/_windows.py +853 -0
- sequential_hooks/_containment/_windows_api.py +570 -0
- sequential_hooks/_containment/_windows_launcher.py +189 -0
- sequential_hooks/_diagnostics.py +265 -0
- sequential_hooks/_doctor/__init__.py +342 -0
- sequential_hooks/_doctor/_command.py +469 -0
- sequential_hooks/_doctor/_common.py +448 -0
- sequential_hooks/_doctor/_types.py +94 -0
- sequential_hooks/_downstream.py +181 -0
- sequential_hooks/_executable.py +187 -0
- sequential_hooks/_executor.py +825 -0
- sequential_hooks/_registry.py +103 -0
- sequential_hooks/_runner.py +48 -0
- sequential_hooks/_types.py +125 -0
- sequential_hooks/hosts/__init__.py +130 -0
- sequential_hooks/hosts/_contract.py +434 -0
- sequential_hooks/hosts/_inspection.py +78 -0
- sequential_hooks/hosts/_json.py +150 -0
- sequential_hooks/hosts/_records.py +273 -0
- sequential_hooks/hosts/_skeleton.py +1326 -0
- sequential_hooks/py.typed +0 -0
- sequential_hooks-0.1.0.dist-info/METADATA +90 -0
- sequential_hooks-0.1.0.dist-info/RECORD +43 -0
- sequential_hooks-0.1.0.dist-info/WHEEL +4 -0
- sequential_hooks-0.1.0.dist-info/entry_points.txt +7 -0
- sequential_hooks-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Absorb closure of a downstream reader of standard output."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import errno
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Generator, Iterable
|
|
8
|
+
from typing import TextIO, cast
|
|
9
|
+
|
|
10
|
+
_WINDOWS = sys.platform == 'win32'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _null_text_stream() -> TextIO:
|
|
14
|
+
"""Open a text stream on the null device."""
|
|
15
|
+
sink = os.open(os.devnull, os.O_WRONLY)
|
|
16
|
+
try:
|
|
17
|
+
return os.fdopen(sink, 'w', encoding='utf-8')
|
|
18
|
+
except BaseException:
|
|
19
|
+
os.close(sink)
|
|
20
|
+
raise
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_downstream_closure(error: OSError, *, windows: bool | None = None) -> bool:
|
|
24
|
+
"""Report whether a write or flush failed because the reader closed the stream.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
error: Error raised by a write or flush of an output stream.
|
|
28
|
+
windows: Whether Windows error reporting applies, or `None` for the
|
|
29
|
+
running platform. CPython on Windows can report a write to a pipe
|
|
30
|
+
whose reader closed as `EINVAL`.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
Whether the error means the downstream reader is gone.
|
|
34
|
+
"""
|
|
35
|
+
on_windows = _WINDOWS if windows is None else windows
|
|
36
|
+
return isinstance(error, BrokenPipeError) or (on_windows and error.errno == errno.EINVAL)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ClosableTextOutput:
|
|
40
|
+
"""Text output that discards writes after its downstream reader closes.
|
|
41
|
+
|
|
42
|
+
Attributes other than writing and flushing come from the wrapped stream, so
|
|
43
|
+
encoding, terminal detection, and reconfiguration behave as on that stream.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, stream: TextIO, *, windows: bool | None = None) -> None:
|
|
47
|
+
"""Wrap a text stream.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
stream: Stream written through until its reader closes.
|
|
51
|
+
windows: Whether Windows closed-pipe reporting applies, or `None`
|
|
52
|
+
for the running platform.
|
|
53
|
+
"""
|
|
54
|
+
self._closed_downstream = False
|
|
55
|
+
self._stream = stream
|
|
56
|
+
self._windows = windows
|
|
57
|
+
|
|
58
|
+
def __getattr__(self, name: str) -> object:
|
|
59
|
+
"""Return an attribute of the wrapped stream."""
|
|
60
|
+
return getattr(self._stream, name)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def closed_downstream(self) -> bool:
|
|
64
|
+
"""Whether the downstream reader has closed the stream."""
|
|
65
|
+
return self._closed_downstream
|
|
66
|
+
|
|
67
|
+
def flush(self) -> None:
|
|
68
|
+
"""Flush the wrapped stream unless its reader has closed.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
OSError: If flushing fails for a reason other than closure.
|
|
72
|
+
"""
|
|
73
|
+
if self._closed_downstream:
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
self._stream.flush()
|
|
77
|
+
except OSError as error:
|
|
78
|
+
if not is_downstream_closure(error, windows=self._windows):
|
|
79
|
+
raise
|
|
80
|
+
self._closed_downstream = True
|
|
81
|
+
|
|
82
|
+
def write(self, text: str) -> int:
|
|
83
|
+
"""Write text, or discard it after the reader has closed.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
text: Text to write.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The number of characters accepted.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
OSError: If writing fails for a reason other than closure.
|
|
93
|
+
"""
|
|
94
|
+
if self._closed_downstream:
|
|
95
|
+
return len(text)
|
|
96
|
+
try:
|
|
97
|
+
return self._stream.write(text)
|
|
98
|
+
except OSError as error:
|
|
99
|
+
if not is_downstream_closure(error, windows=self._windows):
|
|
100
|
+
raise
|
|
101
|
+
self._closed_downstream = True
|
|
102
|
+
return len(text)
|
|
103
|
+
|
|
104
|
+
def writelines(self, lines: Iterable[str]) -> None:
|
|
105
|
+
"""Write lines until the reader closes, then stop consuming them.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
lines: Text lines in output order.
|
|
109
|
+
|
|
110
|
+
Raises:
|
|
111
|
+
OSError: If writing fails for a reason other than closure.
|
|
112
|
+
"""
|
|
113
|
+
if self._closed_downstream:
|
|
114
|
+
return
|
|
115
|
+
for line in lines:
|
|
116
|
+
self.write(line)
|
|
117
|
+
if self._closed_downstream:
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def silence_descriptor(stream: TextIO) -> bool:
|
|
122
|
+
"""Point a stream's descriptor at the null device when it has one.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
stream: Stream whose descriptor the interpreter flushes at shutdown.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Whether the stream had a descriptor to redirect.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
OSError: If redirecting an existing descriptor fails.
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
target = stream.fileno()
|
|
135
|
+
except (OSError, ValueError):
|
|
136
|
+
return False
|
|
137
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
138
|
+
try:
|
|
139
|
+
os.dup2(devnull, target)
|
|
140
|
+
finally:
|
|
141
|
+
os.close(devnull)
|
|
142
|
+
return True
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@contextlib.contextmanager
|
|
146
|
+
def closable_stdout() -> Generator[ClosableTextOutput]:
|
|
147
|
+
"""Route standard output through a closure-absorbing wrapper.
|
|
148
|
+
|
|
149
|
+
On every exit, including `SystemExit` from argument parsing, pending output
|
|
150
|
+
is flushed through the wrapper and the original stream is restored. If the
|
|
151
|
+
reader closed, the original descriptor is pointed at the null device, or a
|
|
152
|
+
stream without a descriptor is replaced by a null-device stream, so the
|
|
153
|
+
interpreter's final flush cannot fail again.
|
|
154
|
+
|
|
155
|
+
Yields:
|
|
156
|
+
The installed wrapper.
|
|
157
|
+
"""
|
|
158
|
+
original = sys.stdout
|
|
159
|
+
output = ClosableTextOutput(original)
|
|
160
|
+
sys.stdout = cast('TextIO', output)
|
|
161
|
+
try:
|
|
162
|
+
yield output
|
|
163
|
+
finally:
|
|
164
|
+
try:
|
|
165
|
+
output.flush()
|
|
166
|
+
finally:
|
|
167
|
+
sys.stdout = original
|
|
168
|
+
if output.closed_downstream and not silence_descriptor(original):
|
|
169
|
+
# The interpreter still flushes a stream without a descriptor
|
|
170
|
+
# at shutdown, so replace it with one that cannot fail.
|
|
171
|
+
sys.stdout = _null_text_stream()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def stdout_closed() -> bool:
|
|
175
|
+
"""Report whether standard output's downstream reader has closed.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Whether the installed wrapper has observed closure.
|
|
179
|
+
"""
|
|
180
|
+
output = sys.stdout
|
|
181
|
+
return isinstance(output, ClosableTextOutput) and output.closed_downstream
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Resolve supported child executable paths without launching them."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
_DEFAULT_WINDOWS_PATHEXT = ('.COM', '.EXE', '.BAT', '.CMD')
|
|
11
|
+
_RUNNABLE_WINDOWS_SUFFIXES = frozenset({'.com', '.exe'})
|
|
12
|
+
_UNSUPPORTED_WINDOWS_SUFFIXES = frozenset({'.bat', '.cmd', '.ps1'})
|
|
13
|
+
_WINDOWS_PATH_SEPARATOR = ';'
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ExecutableStatus(StrEnum):
|
|
17
|
+
"""Classify a resolved child executable.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
RUNNABLE: The executable can be started directly.
|
|
21
|
+
NOT_FOUND: No matching executable could be resolved.
|
|
22
|
+
UNSUPPORTED_SHIM: Resolution found a shell-dependent Windows shim.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
RUNNABLE = 'runnable'
|
|
26
|
+
NOT_FOUND = 'not_found'
|
|
27
|
+
UNSUPPORTED_SHIM = 'unsupported_shim'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class ExecutableResolution:
|
|
32
|
+
"""Describe the static resolution of one executable token.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
requested: Literal executable token supplied by the caller.
|
|
36
|
+
resolved: Matching filesystem path, if one was found.
|
|
37
|
+
status: Classification of the resolution result.
|
|
38
|
+
correction: Safe corrective guidance for an unsupported result.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
requested: Literal executable token supplied by the caller.
|
|
42
|
+
resolved: Matching filesystem path, if one was found.
|
|
43
|
+
status: Classification of the resolution result.
|
|
44
|
+
correction: Safe corrective guidance for an unsupported result.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
requested: str
|
|
48
|
+
resolved: Path | None
|
|
49
|
+
status: ExecutableStatus
|
|
50
|
+
correction: str | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _case_insensitive_file(candidate: Path) -> Path | None:
|
|
54
|
+
"""Return the actual-cased file matching one Windows candidate."""
|
|
55
|
+
current = Path(candidate.anchor) if candidate.is_absolute() else Path()
|
|
56
|
+
parts = candidate.parts[1:] if candidate.is_absolute() else candidate.parts
|
|
57
|
+
for part in parts:
|
|
58
|
+
if part == '..':
|
|
59
|
+
current /= part
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
matches = sorted(
|
|
63
|
+
(child for child in current.iterdir() if child.name.casefold() == part.casefold()),
|
|
64
|
+
key=lambda child: child.name,
|
|
65
|
+
)
|
|
66
|
+
except OSError:
|
|
67
|
+
return None
|
|
68
|
+
if not matches:
|
|
69
|
+
return None
|
|
70
|
+
current = next((child for child in matches if child.name == part), matches[0])
|
|
71
|
+
return current if current.is_file() else None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _not_found(requested: str) -> ExecutableResolution:
|
|
75
|
+
"""Build a missing-executable result."""
|
|
76
|
+
return ExecutableResolution(requested, None, ExecutableStatus.NOT_FOUND)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _resolve_posix(executable: str, path_value: str | None) -> ExecutableResolution:
|
|
80
|
+
"""Resolve one executable with POSIX path and permission rules."""
|
|
81
|
+
if '/' in executable:
|
|
82
|
+
candidate = Path(executable)
|
|
83
|
+
if candidate.is_file() and os.access(candidate, os.X_OK):
|
|
84
|
+
return ExecutableResolution(executable, candidate, ExecutableStatus.RUNNABLE)
|
|
85
|
+
return _not_found(executable)
|
|
86
|
+
if path_value is None:
|
|
87
|
+
return _not_found(executable)
|
|
88
|
+
resolved = shutil.which(executable, path=path_value)
|
|
89
|
+
if resolved is None:
|
|
90
|
+
return _not_found(executable)
|
|
91
|
+
return ExecutableResolution(executable, Path(resolved), ExecutableStatus.RUNNABLE)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _windows_candidate_names(executable: str, pathext: tuple[str, ...]) -> tuple[str, ...]:
|
|
95
|
+
"""Apply Windows executable suffix search rules to one token."""
|
|
96
|
+
suffix = Path(executable).suffix.casefold()
|
|
97
|
+
if (
|
|
98
|
+
suffix in _RUNNABLE_WINDOWS_SUFFIXES
|
|
99
|
+
or suffix in _UNSUPPORTED_WINDOWS_SUFFIXES
|
|
100
|
+
or any(suffix == extension.casefold() for extension in pathext)
|
|
101
|
+
):
|
|
102
|
+
return (executable,)
|
|
103
|
+
return tuple(f'{executable}{extension}' for extension in pathext)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _windows_resolution(
|
|
107
|
+
requested: str,
|
|
108
|
+
candidate: Path,
|
|
109
|
+
) -> ExecutableResolution | None:
|
|
110
|
+
"""Classify one existing native or simulated Windows candidate."""
|
|
111
|
+
if sys.platform == 'win32':
|
|
112
|
+
resolved = candidate if candidate.is_file() else None
|
|
113
|
+
else:
|
|
114
|
+
resolved = _case_insensitive_file(candidate)
|
|
115
|
+
if resolved is None:
|
|
116
|
+
return None
|
|
117
|
+
if resolved.suffix.casefold() in _UNSUPPORTED_WINDOWS_SUFFIXES:
|
|
118
|
+
correction = (
|
|
119
|
+
f'Use a real executable entry point for {requested}; '
|
|
120
|
+
f'resolved unsupported Windows shim {resolved}.'
|
|
121
|
+
)
|
|
122
|
+
return ExecutableResolution(
|
|
123
|
+
requested,
|
|
124
|
+
resolved,
|
|
125
|
+
ExecutableStatus.UNSUPPORTED_SHIM,
|
|
126
|
+
correction,
|
|
127
|
+
)
|
|
128
|
+
if resolved.suffix.casefold() not in _RUNNABLE_WINDOWS_SUFFIXES:
|
|
129
|
+
return None
|
|
130
|
+
return ExecutableResolution(requested, resolved, ExecutableStatus.RUNNABLE)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _resolve_windows(
|
|
134
|
+
executable: str,
|
|
135
|
+
path_value: str | None,
|
|
136
|
+
pathext_value: str | None,
|
|
137
|
+
) -> ExecutableResolution:
|
|
138
|
+
"""Resolve one executable with Windows search rules."""
|
|
139
|
+
pathext = (
|
|
140
|
+
tuple(extension for extension in pathext_value.split(_WINDOWS_PATH_SEPARATOR) if extension)
|
|
141
|
+
if pathext_value is not None
|
|
142
|
+
else ()
|
|
143
|
+
)
|
|
144
|
+
if not pathext:
|
|
145
|
+
pathext = _DEFAULT_WINDOWS_PATHEXT
|
|
146
|
+
names = _windows_candidate_names(executable.replace('\\', os.sep), pathext)
|
|
147
|
+
if '/' in executable or '\\' in executable:
|
|
148
|
+
directories = ('',)
|
|
149
|
+
elif path_value:
|
|
150
|
+
directories = tuple(path_value.split(_WINDOWS_PATH_SEPARATOR))
|
|
151
|
+
else:
|
|
152
|
+
directories = ()
|
|
153
|
+
|
|
154
|
+
for directory in directories:
|
|
155
|
+
normalized_directory = directory.replace('\\', os.sep)
|
|
156
|
+
if len(directory) > 1 and directory.startswith('"') and directory.endswith('"'):
|
|
157
|
+
normalized_directory = normalized_directory[1:-1]
|
|
158
|
+
for name in names:
|
|
159
|
+
candidate = Path(normalized_directory) / name
|
|
160
|
+
if resolution := _windows_resolution(executable, candidate):
|
|
161
|
+
return resolution
|
|
162
|
+
return _not_found(executable)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def resolve_executable(
|
|
166
|
+
executable: str,
|
|
167
|
+
*,
|
|
168
|
+
path_value: str | None,
|
|
169
|
+
windows: bool,
|
|
170
|
+
pathext_value: str | None,
|
|
171
|
+
) -> ExecutableResolution:
|
|
172
|
+
"""Resolve one literal executable without starting it.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
executable: Literal executable token to resolve.
|
|
176
|
+
path_value: Injected executable search path, if set.
|
|
177
|
+
windows: Whether to apply Windows lookup rules.
|
|
178
|
+
pathext_value: Injected Windows executable suffix list, if set.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Static executable resolution and any required correction.
|
|
182
|
+
"""
|
|
183
|
+
if not executable:
|
|
184
|
+
return _not_found(executable)
|
|
185
|
+
if windows:
|
|
186
|
+
return _resolve_windows(executable, path_value, pathext_value)
|
|
187
|
+
return _resolve_posix(executable, path_value)
|