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,189 @@
|
|
|
1
|
+
"""Launch one gated Windows child and report its status."""
|
|
2
|
+
|
|
3
|
+
_release = __import__('os').read(
|
|
4
|
+
(
|
|
5
|
+
_control_fd := __import__('msvcrt').open_osfhandle(
|
|
6
|
+
int(__import__('sys').argv[1]),
|
|
7
|
+
__import__('os').O_RDONLY | __import__('os').O_BINARY,
|
|
8
|
+
)
|
|
9
|
+
),
|
|
10
|
+
1,
|
|
11
|
+
)
|
|
12
|
+
if _release != b'R':
|
|
13
|
+
__import__('os').close(_control_fd)
|
|
14
|
+
raise SystemExit(0)
|
|
15
|
+
|
|
16
|
+
_status_fd = __import__('msvcrt').open_osfhandle(
|
|
17
|
+
int(__import__('sys').argv[2]),
|
|
18
|
+
__import__('os').O_WRONLY | __import__('os').O_BINARY,
|
|
19
|
+
)
|
|
20
|
+
_os = __import__('os')
|
|
21
|
+
try:
|
|
22
|
+
_contextlib = __import__('contextlib')
|
|
23
|
+
_ctypes = __import__('ctypes')
|
|
24
|
+
_json = __import__('json')
|
|
25
|
+
_signal = __import__('signal')
|
|
26
|
+
_subprocess = __import__('subprocess')
|
|
27
|
+
_typing = __import__('typing')
|
|
28
|
+
except ImportError:
|
|
29
|
+
_import_failure_message = b'launcher invariant failure'
|
|
30
|
+
_import_failure_frame = (
|
|
31
|
+
b'\x03' + len(_import_failure_message).to_bytes(4, 'little') + _import_failure_message
|
|
32
|
+
)
|
|
33
|
+
_import_failure_offset = 0
|
|
34
|
+
while _import_failure_offset < len(_import_failure_frame):
|
|
35
|
+
try:
|
|
36
|
+
_import_failure_written = _os.write(
|
|
37
|
+
_status_fd,
|
|
38
|
+
_import_failure_frame[_import_failure_offset:],
|
|
39
|
+
)
|
|
40
|
+
except OSError:
|
|
41
|
+
raise SystemExit(0) from None
|
|
42
|
+
if _import_failure_written <= 0:
|
|
43
|
+
raise SystemExit(0) from None
|
|
44
|
+
_import_failure_offset += _import_failure_written
|
|
45
|
+
for _descriptor in (_control_fd, _status_fd):
|
|
46
|
+
try:
|
|
47
|
+
_os.close(_descriptor)
|
|
48
|
+
except OSError:
|
|
49
|
+
continue
|
|
50
|
+
raise SystemExit(0) from None
|
|
51
|
+
_ARGV_LIMIT_BYTES = 1024 * 1024
|
|
52
|
+
_STATUS_LIMIT_BYTES = 4096
|
|
53
|
+
_control_state = [_control_fd]
|
|
54
|
+
_status_state = [_status_fd]
|
|
55
|
+
_status_written = [False]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _InvariantReporter:
|
|
59
|
+
"""Report and suppress post-release invariant exceptions as status 3."""
|
|
60
|
+
|
|
61
|
+
def __enter__(self) -> None:
|
|
62
|
+
"""Begin guarded launcher execution."""
|
|
63
|
+
|
|
64
|
+
def __exit__(
|
|
65
|
+
self,
|
|
66
|
+
_error_type: type[BaseException] | None,
|
|
67
|
+
error: BaseException | None,
|
|
68
|
+
_traceback: object,
|
|
69
|
+
) -> bool:
|
|
70
|
+
"""Report and suppress one launcher invariant exception."""
|
|
71
|
+
if error is None:
|
|
72
|
+
return False
|
|
73
|
+
with _contextlib.suppress(BaseException):
|
|
74
|
+
_write_status(3, 'launcher invariant failure')
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _close_control() -> None:
|
|
79
|
+
"""Close the control descriptor before creating the real child."""
|
|
80
|
+
if _control_state[0] >= 0:
|
|
81
|
+
_os.close(_control_state[0])
|
|
82
|
+
_control_state[0] = -1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _close_status() -> None:
|
|
86
|
+
"""Close the status descriptor exactly once."""
|
|
87
|
+
if _status_state[0] >= 0:
|
|
88
|
+
_os.close(_status_state[0])
|
|
89
|
+
_status_state[0] = -1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _ignore_break(_signum: int, _frame: object) -> None:
|
|
93
|
+
"""Keep the launcher alive while its real child handles Ctrl+Break."""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _read_exact(count: int) -> bytes:
|
|
97
|
+
"""Read exactly one bounded control-frame component."""
|
|
98
|
+
data = bytearray()
|
|
99
|
+
while len(data) < count:
|
|
100
|
+
chunk = _os.read(_control_state[0], count - len(data))
|
|
101
|
+
if not chunk:
|
|
102
|
+
raise ValueError('truncated launcher control frame')
|
|
103
|
+
data.extend(chunk)
|
|
104
|
+
return bytes(data)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _write_all(data: bytes) -> None:
|
|
108
|
+
"""Write one exact status frame without reserving an exit value."""
|
|
109
|
+
offset = 0
|
|
110
|
+
while offset < len(data):
|
|
111
|
+
written = _os.write(_status_state[0], data[offset:])
|
|
112
|
+
if written <= 0:
|
|
113
|
+
raise OSError('launcher status write made no progress')
|
|
114
|
+
offset += written
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _write_status(status: int, message: str = '') -> None:
|
|
118
|
+
"""Write one bounded status frame with sanitized diagnostic text."""
|
|
119
|
+
if _status_written[0]:
|
|
120
|
+
return
|
|
121
|
+
encoded = message.encode('utf-8')[: _STATUS_LIMIT_BYTES - 5]
|
|
122
|
+
while True:
|
|
123
|
+
try:
|
|
124
|
+
encoded.decode('utf-8')
|
|
125
|
+
except UnicodeDecodeError:
|
|
126
|
+
encoded = encoded[:-1]
|
|
127
|
+
continue
|
|
128
|
+
break
|
|
129
|
+
_write_all(bytes((status,)) + len(encoded).to_bytes(4, 'little') + encoded)
|
|
130
|
+
_status_written[0] = True
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _main() -> None:
|
|
134
|
+
"""Validate the control frame, run one child, and relay exact status."""
|
|
135
|
+
argv_length = int.from_bytes(_read_exact(8), 'little')
|
|
136
|
+
if argv_length > _ARGV_LIMIT_BYTES:
|
|
137
|
+
raise ValueError('launcher argv frame exceeds its bound')
|
|
138
|
+
argv_value = _json.loads(_read_exact(argv_length).decode('utf-8'))
|
|
139
|
+
if not isinstance(argv_value, list):
|
|
140
|
+
raise TypeError('launcher argv must be a non-empty string list')
|
|
141
|
+
argv_items = _typing.cast(list[object], argv_value)
|
|
142
|
+
if not argv_items or any(not isinstance(value, str) for value in argv_items):
|
|
143
|
+
raise ValueError('launcher argv must be a non-empty string list')
|
|
144
|
+
argv = _typing.cast(list[str], argv_items)
|
|
145
|
+
if _os.read(_control_state[0], 1):
|
|
146
|
+
raise ValueError('launcher control frame contains trailing bytes')
|
|
147
|
+
_close_control()
|
|
148
|
+
break_signal = getattr(_signal, 'SIGBREAK', None)
|
|
149
|
+
if break_signal is not None:
|
|
150
|
+
_signal.signal(break_signal, _ignore_break)
|
|
151
|
+
kernel32 = _typing.cast(
|
|
152
|
+
_typing.Any,
|
|
153
|
+
vars(_ctypes)['WinDLL']('kernel32', use_last_error=True),
|
|
154
|
+
)
|
|
155
|
+
exit_process = kernel32.ExitProcess
|
|
156
|
+
exit_process.argtypes = [_ctypes.c_uint32]
|
|
157
|
+
exit_process.restype = None
|
|
158
|
+
try:
|
|
159
|
+
child = _subprocess.Popen(
|
|
160
|
+
argv,
|
|
161
|
+
stdin=0,
|
|
162
|
+
stdout=1,
|
|
163
|
+
stderr=2,
|
|
164
|
+
shell=False,
|
|
165
|
+
close_fds=True,
|
|
166
|
+
)
|
|
167
|
+
except FileNotFoundError:
|
|
168
|
+
_write_status(1, 'real step was not found')
|
|
169
|
+
return
|
|
170
|
+
except OSError:
|
|
171
|
+
_write_status(2, 'real step could not be spawned')
|
|
172
|
+
return
|
|
173
|
+
except ValueError:
|
|
174
|
+
_write_status(2, 'real step could not be spawned')
|
|
175
|
+
return
|
|
176
|
+
returncode = child.wait()
|
|
177
|
+
try:
|
|
178
|
+
_write_status(0)
|
|
179
|
+
_close_status()
|
|
180
|
+
finally:
|
|
181
|
+
exit_process(_ctypes.c_uint32(returncode).value)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
with _InvariantReporter():
|
|
185
|
+
_main()
|
|
186
|
+
with _contextlib.suppress(OSError):
|
|
187
|
+
_close_control()
|
|
188
|
+
with _contextlib.suppress(OSError):
|
|
189
|
+
_close_status()
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# pyright: reportPrivateUsage=false
|
|
2
|
+
|
|
3
|
+
"""Write bounded, synchronized child diagnostics."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import threading
|
|
8
|
+
from collections import deque
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from sequential_hooks._downstream import is_downstream_closure, silence_descriptor
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from typing import TextIO
|
|
15
|
+
|
|
16
|
+
from sequential_hooks.hosts import Step
|
|
17
|
+
|
|
18
|
+
_STDERR_CHAIN_LIMIT_BYTES = 4 * 1024 * 1024
|
|
19
|
+
_STDERR_STEP_LIMIT_BYTES = 1024 * 1024
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _DiagnosticState:
|
|
23
|
+
"""Own diagnostic output synchronization and the shared chain budget."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, stream: TextIO) -> None:
|
|
26
|
+
"""Initialize shared diagnostic budgets and synchronization."""
|
|
27
|
+
self._chain_bytes = 0
|
|
28
|
+
self._chain_notice_written = False
|
|
29
|
+
self._broken = False
|
|
30
|
+
self._output_draining = False
|
|
31
|
+
self._output_lines: deque[str] = deque()
|
|
32
|
+
self._state_lock = threading.Lock()
|
|
33
|
+
self._stream = stream
|
|
34
|
+
|
|
35
|
+
def _drain_output(self, owns_drain: bool) -> None:
|
|
36
|
+
"""Drain queued lines when this caller owns diagnostic output."""
|
|
37
|
+
if not owns_drain:
|
|
38
|
+
return
|
|
39
|
+
completed = False
|
|
40
|
+
try:
|
|
41
|
+
while True:
|
|
42
|
+
with self._state_lock:
|
|
43
|
+
if not self._output_lines:
|
|
44
|
+
self._output_draining = False
|
|
45
|
+
completed = True
|
|
46
|
+
return
|
|
47
|
+
batch = self._output_lines.popleft()
|
|
48
|
+
if not self._broken:
|
|
49
|
+
try:
|
|
50
|
+
self._stream.write(batch)
|
|
51
|
+
self._stream.flush()
|
|
52
|
+
except OSError as error:
|
|
53
|
+
if not is_downstream_closure(error):
|
|
54
|
+
raise
|
|
55
|
+
self._broken = True
|
|
56
|
+
silence_descriptor(self._stream)
|
|
57
|
+
finally:
|
|
58
|
+
if not completed:
|
|
59
|
+
with self._state_lock:
|
|
60
|
+
self._output_draining = False
|
|
61
|
+
|
|
62
|
+
def _enqueue_lines_locked(self, lines: list[str]) -> bool:
|
|
63
|
+
"""Queue complete lines and claim an idle drainer."""
|
|
64
|
+
if not lines:
|
|
65
|
+
return False
|
|
66
|
+
self._output_lines.append(''.join(lines))
|
|
67
|
+
if self._output_draining:
|
|
68
|
+
return False
|
|
69
|
+
self._output_draining = True
|
|
70
|
+
return True
|
|
71
|
+
|
|
72
|
+
def _enqueue_step_locked(self, step: Step, messages: list[str]) -> bool:
|
|
73
|
+
"""Render and queue ordered step lines under the state lock."""
|
|
74
|
+
prefix = f'sequential-hooks: step {step.index} ({step.name}): '
|
|
75
|
+
lines = [prefix + message + '\n' for message in messages]
|
|
76
|
+
return self._enqueue_lines_locked(lines)
|
|
77
|
+
|
|
78
|
+
def _write_global(self, message: str) -> None:
|
|
79
|
+
"""Queue one global line and drain it when output is idle."""
|
|
80
|
+
with self._state_lock:
|
|
81
|
+
owns_drain = self._enqueue_lines_locked(
|
|
82
|
+
[f'sequential-hooks: {message}\n'],
|
|
83
|
+
)
|
|
84
|
+
self._drain_output(owns_drain)
|
|
85
|
+
|
|
86
|
+
def _write_step(self, step: Step, messages: list[str]) -> None:
|
|
87
|
+
"""Queue ordered step lines and drain them when output is idle."""
|
|
88
|
+
with self._state_lock:
|
|
89
|
+
owns_drain = self._enqueue_step_locked(step, messages)
|
|
90
|
+
self._drain_output(owns_drain)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class StderrRelay:
|
|
94
|
+
"""Forward one child's bounded stderr and retain its native reason.
|
|
95
|
+
|
|
96
|
+
Feed raw chunks in arrival order, then call `finish()` after ordinary EOF
|
|
97
|
+
to flush a partial line. Call `close()` during cleanup to stop forwarding
|
|
98
|
+
without diagnostic I/O and retrieve the bounded captured text. Output and
|
|
99
|
+
flush failures other than downstream closure propagate from `feed()` and
|
|
100
|
+
`finish()`; downstream closure latches the shared output closed.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, state: _DiagnosticState, step: Step) -> None:
|
|
104
|
+
"""Bind one child to the shared diagnostic state.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
state: Shared diagnostic stream, synchronization, and chain budget.
|
|
108
|
+
step: Child whose stderr is forwarded and labeled.
|
|
109
|
+
"""
|
|
110
|
+
self._captured = bytearray()
|
|
111
|
+
self._closed = False
|
|
112
|
+
self._line_buffer = bytearray()
|
|
113
|
+
self._state = state
|
|
114
|
+
self._step = step
|
|
115
|
+
self._step_bytes = 0
|
|
116
|
+
self._step_notice_written = False
|
|
117
|
+
|
|
118
|
+
def _decode_line(self, line: bytes | bytearray) -> str:
|
|
119
|
+
"""Decode one safe user-facing line."""
|
|
120
|
+
raw_line = bytes(line)
|
|
121
|
+
try:
|
|
122
|
+
return raw_line.decode()
|
|
123
|
+
except UnicodeDecodeError:
|
|
124
|
+
return f'[invalid UTF-8 replaced] {raw_line.decode(errors="replace")}'
|
|
125
|
+
|
|
126
|
+
def _complete_line_messages_locked(self, accepted: bytes) -> list[str]:
|
|
127
|
+
"""Capture every complete buffered line while holding the state lock."""
|
|
128
|
+
if b'\n' not in accepted:
|
|
129
|
+
return []
|
|
130
|
+
lines = self._line_buffer.split(b'\n')
|
|
131
|
+
self._line_buffer = bytearray(lines.pop())
|
|
132
|
+
return [self._decode_line(line) for line in lines]
|
|
133
|
+
|
|
134
|
+
def close(self) -> str:
|
|
135
|
+
"""Close without diagnostic I/O and return bounded child stderr text.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Replacement-decoded child text without diagnostic prefixes.
|
|
139
|
+
"""
|
|
140
|
+
with self._state._state_lock:
|
|
141
|
+
self._closed = True
|
|
142
|
+
return self._captured.decode(errors='replace')
|
|
143
|
+
|
|
144
|
+
def feed(self, data: bytes) -> None:
|
|
145
|
+
"""Forward and retain bytes that fit the step and chain budgets.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
data: Next raw child stderr chunk.
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
OSError: If writing or flushing diagnostic output fails for a
|
|
152
|
+
reason other than downstream closure. Downstream closure is
|
|
153
|
+
absorbed and latches output closed.
|
|
154
|
+
"""
|
|
155
|
+
with self._state._state_lock:
|
|
156
|
+
if self._closed:
|
|
157
|
+
return
|
|
158
|
+
messages: list[str] = []
|
|
159
|
+
step_remaining = max(0, _STDERR_STEP_LIMIT_BYTES - self._step_bytes)
|
|
160
|
+
chain_remaining = max(
|
|
161
|
+
0,
|
|
162
|
+
_STDERR_CHAIN_LIMIT_BYTES - self._state._chain_bytes,
|
|
163
|
+
)
|
|
164
|
+
accepted_count = min(len(data), step_remaining, chain_remaining)
|
|
165
|
+
accepted = data[:accepted_count]
|
|
166
|
+
self._captured.extend(accepted)
|
|
167
|
+
self._line_buffer.extend(accepted)
|
|
168
|
+
self._step_bytes += accepted_count
|
|
169
|
+
self._state._chain_bytes += accepted_count
|
|
170
|
+
messages.extend(self._complete_line_messages_locked(accepted))
|
|
171
|
+
|
|
172
|
+
if (
|
|
173
|
+
len(data) > step_remaining
|
|
174
|
+
and step_remaining <= chain_remaining
|
|
175
|
+
and not self._step_notice_written
|
|
176
|
+
):
|
|
177
|
+
messages.append(
|
|
178
|
+
f'stderr truncated after {_STDERR_STEP_LIMIT_BYTES} bytes for this step',
|
|
179
|
+
)
|
|
180
|
+
self._step_notice_written = True
|
|
181
|
+
if (
|
|
182
|
+
len(data) > chain_remaining
|
|
183
|
+
and chain_remaining <= step_remaining
|
|
184
|
+
and not self._state._chain_notice_written
|
|
185
|
+
):
|
|
186
|
+
messages.append(
|
|
187
|
+
f'stderr truncated after {_STDERR_CHAIN_LIMIT_BYTES} bytes for this chain',
|
|
188
|
+
)
|
|
189
|
+
self._state._chain_notice_written = True
|
|
190
|
+
owns_drain = self._state._enqueue_step_locked(self._step, messages)
|
|
191
|
+
self._state._drain_output(owns_drain)
|
|
192
|
+
|
|
193
|
+
def finish(self) -> None:
|
|
194
|
+
"""Flush one partial line after ordinary reader EOF.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
OSError: If writing or flushing diagnostic output fails for a
|
|
198
|
+
reason other than downstream closure. Downstream closure is
|
|
199
|
+
absorbed and latches output closed.
|
|
200
|
+
"""
|
|
201
|
+
with self._state._state_lock:
|
|
202
|
+
if self._closed:
|
|
203
|
+
return
|
|
204
|
+
messages: list[str] = []
|
|
205
|
+
if self._line_buffer:
|
|
206
|
+
messages.append(self._decode_line(self._line_buffer))
|
|
207
|
+
self._line_buffer.clear()
|
|
208
|
+
owns_drain = self._state._enqueue_step_locked(self._step, messages)
|
|
209
|
+
self._state._drain_output(owns_drain)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class Diagnostics:
|
|
213
|
+
"""Write bounded, labeled child diagnostics to one text stream.
|
|
214
|
+
|
|
215
|
+
One instance synchronizes writes and shares the aggregate byte budget
|
|
216
|
+
across every relay it creates. Output and flush failures other than
|
|
217
|
+
downstream closure propagate from write operations; downstream closure
|
|
218
|
+
latches output closed.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def __init__(self, stream: TextIO) -> None:
|
|
222
|
+
"""Bind diagnostics to one text output stream.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
stream: Text stream that receives user-facing diagnostic lines.
|
|
226
|
+
"""
|
|
227
|
+
self._state = _DiagnosticState(stream)
|
|
228
|
+
|
|
229
|
+
def relay(self, step: Step) -> StderrRelay:
|
|
230
|
+
"""Create a bounded stderr relay for one child.
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
step: Child whose stderr will be forwarded.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
Relay sharing this diagnostic stream and chain budget.
|
|
237
|
+
"""
|
|
238
|
+
return StderrRelay(self._state, step)
|
|
239
|
+
|
|
240
|
+
def write_global(self, message: str) -> None:
|
|
241
|
+
"""Write a diagnostic that does not belong to one step.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
message: Safe user-facing diagnostic text.
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
OSError: If writing or flushing diagnostic output fails for a
|
|
248
|
+
reason other than downstream closure. Downstream closure is
|
|
249
|
+
absorbed and latches output closed.
|
|
250
|
+
"""
|
|
251
|
+
self._state._write_global(message)
|
|
252
|
+
|
|
253
|
+
def write_step(self, step: Step, message: str) -> None:
|
|
254
|
+
"""Write one labeled step diagnostic.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
step: Child associated with the message.
|
|
258
|
+
message: Safe user-facing diagnostic text.
|
|
259
|
+
|
|
260
|
+
Raises:
|
|
261
|
+
OSError: If writing or flushing diagnostic output fails for a
|
|
262
|
+
reason other than downstream closure. Downstream closure is
|
|
263
|
+
absorbed and latches output closed.
|
|
264
|
+
"""
|
|
265
|
+
self._state._write_step(step, [message])
|