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,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)