sift-cli 1.0.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.
sift/capture.py ADDED
@@ -0,0 +1,240 @@
1
+ """Running the command, and keeping every byte of what it said.
2
+
3
+ `sift` runs the command itself rather than being handed its output afterwards.
4
+ That is not a convenience: it is what makes the rest possible. Output that has
5
+ already reached the conversation has already been paid for, and no amount of
6
+ distilling afterwards refunds it. Standing where the bytes appear is the only
7
+ place a tool can act before the cost is incurred.
8
+
9
+ Two choices here are worth stating, because both could reasonably have gone the
10
+ other way.
11
+
12
+ **stderr is merged into stdout.** Kept apart, the two streams have to be stitched
13
+ back together afterwards and the order is guesswork; merged at the pipe, the
14
+ operating system interleaves them exactly as a terminal would. What a person
15
+ would have seen is the thing worth judging, so that is what gets stored.
16
+
17
+ **Bytes go straight to disk.** A command that prints for an hour must not grow
18
+ the process that is watching it. The file is the buffer, and reading back for
19
+ judgement is bounded separately -- so a runaway `yes` costs disk, which is cheap
20
+ and reclaimable, rather than memory, which is neither.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import contextlib
26
+ import os
27
+ import selectors
28
+ import signal
29
+ import subprocess
30
+ import sys
31
+ import threading
32
+ from collections.abc import Sequence
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+
36
+ from sift import store
37
+
38
+ _READ_CHUNK = 64 * 1024
39
+ _TICK = 0.1 # how often a waiting pump looks up to see whether it has been told to stop
40
+ _DRAIN_GRACE = 2.0 # how long a stopped pump may keep collecting before `run` moves on
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Capture:
45
+ """One finished run: how it ended, and where its bytes are."""
46
+
47
+ meta: store.Meta
48
+
49
+ @property
50
+ def handle(self) -> str:
51
+ return self.meta.handle
52
+
53
+ @property
54
+ def raw(self) -> bytes:
55
+ return store.read_raw(self.handle)
56
+
57
+ def text(self) -> str:
58
+ """The capture as text.
59
+
60
+ Decoded with replacement rather than strictness, because a build log is
61
+ allowed to contain a stray byte and a tool that raises on it would be
62
+ useless exactly when it is needed. Size is measured after this point:
63
+ one invalid byte becomes a three-byte replacement character, so counting
64
+ before decoding would understate what a model is about to be shown.
65
+ """
66
+ return self.raw.decode("utf-8", errors="replace")
67
+
68
+
69
+ def run(
70
+ command: Sequence[str],
71
+ *,
72
+ cwd: str | Path | None = None,
73
+ env: dict[str, str] | None = None,
74
+ timeout: float | None = None,
75
+ shell: bool = False,
76
+ stdin: bytes | None = None,
77
+ ) -> Capture:
78
+ """Run a command, store everything it writes, and report how it ended.
79
+
80
+ `shell=True` exists because pipes and globs are half of what people actually
81
+ run, and refusing them would only push users back to the unwatched terminal.
82
+ It is opt-in and never the default: a list of arguments cannot be
83
+ accidentally reinterpreted by a shell, and that is the safer thing to reach
84
+ for first.
85
+ """
86
+ argv = list(command)
87
+ started = store.now()
88
+ handle = store.new_handle(argv, started)
89
+ target = store.begin(handle)
90
+
91
+ popen_command: Sequence[str] | str = " ".join(argv) if shell else argv
92
+ timed_out = False
93
+ exit_code: int | None = None
94
+
95
+ sink = open(target, "wb") # noqa: SIM115 -- the pump closes this; see below
96
+ try:
97
+ # Running whatever was asked for is the whole point of this tool, so the
98
+ # usual warning about handing a command to the system does not apply here.
99
+ proc = subprocess.Popen(
100
+ popen_command,
101
+ stdout=subprocess.PIPE,
102
+ stderr=subprocess.STDOUT,
103
+ stdin=subprocess.PIPE if stdin is not None else subprocess.DEVNULL,
104
+ cwd=str(cwd) if cwd else None,
105
+ env=env,
106
+ shell=shell,
107
+ **_new_session(),
108
+ )
109
+ except BaseException:
110
+ sink.close()
111
+ raise
112
+
113
+ if stdin is not None and proc.stdin is not None:
114
+ with contextlib.suppress(OSError):
115
+ proc.stdin.write(stdin)
116
+ proc.stdin.close()
117
+
118
+ # The pump runs on its own thread and the clock is watched here. Reading and
119
+ # timing on one thread cannot both work: a read waits until the pipe has
120
+ # something to give, so a command that prints nothing and never exits -- the
121
+ # exact thing a timeout is for -- would never reach the check.
122
+ #
123
+ # From this point the pump owns the pipe and the file, and closes both when
124
+ # it is finished. Closing either from here would mean closing it out from
125
+ # under a thread that is reading, which is how a bounded wait quietly becomes
126
+ # an unbounded one.
127
+ stop = threading.Event()
128
+ pump = threading.Thread(target=_pump, args=(proc.stdout, sink, stop), daemon=True)
129
+ pump.start()
130
+ try:
131
+ exit_code = proc.wait(timeout=timeout)
132
+ except subprocess.TimeoutExpired:
133
+ timed_out = True
134
+ _stop(proc)
135
+ with contextlib.suppress(subprocess.TimeoutExpired):
136
+ proc.wait(timeout=5)
137
+ exit_code = None
138
+ finally:
139
+ stop.set()
140
+ pump.join(timeout=_DRAIN_GRACE)
141
+
142
+ meta = store.Meta(
143
+ handle=handle,
144
+ command=argv,
145
+ shell=shell,
146
+ exit_code=exit_code,
147
+ timed_out=timed_out,
148
+ started_at=started,
149
+ duration_s=round(store.now() - started, 3),
150
+ byte_count=target.stat().st_size if target.is_file() else 0,
151
+ cwd=str(Path(cwd).resolve()) if cwd else os.getcwd(),
152
+ )
153
+ store.finish(meta)
154
+ return Capture(meta)
155
+
156
+
157
+ def _pump(source, sink, stop: threading.Event) -> None:
158
+ """Move bytes from the pipe into the file until the pipe closes, or until
159
+ `stop` is set and there is nothing left to read.
160
+
161
+ A pipe stays open while *any* process holds its write end, and the command is
162
+ not always the last one holding it: a build leaves a daemon behind, a test
163
+ runner spawns a worker that steps into its own session. Waiting on a read
164
+ until such a stranger decides to exit is precisely the hang a timeout exists
165
+ to prevent, so the wait here is always a bounded one.
166
+
167
+ Nothing is dropped by stopping. A command that has exited already handed its
168
+ bytes to the pipe, so they are readable and get read; the stop only ends the
169
+ waiting for bytes that no longer have an author worth waiting for.
170
+
171
+ Errors are swallowed on purpose. This thread exists so the command's output
172
+ is not lost; if the pipe breaks under it, the run is still a real result and
173
+ the bytes written so far are still worth keeping.
174
+ """
175
+ try:
176
+ if sys.platform == "win32":
177
+ _pump_until_closed(source, sink)
178
+ else:
179
+ _pump_until_stopped(source, sink, stop)
180
+ except (OSError, ValueError):
181
+ pass
182
+ finally:
183
+ for closable in (source, sink):
184
+ with contextlib.suppress(OSError, ValueError):
185
+ closable.close()
186
+
187
+
188
+ def _pump_until_stopped(source, sink, stop: threading.Event) -> None:
189
+ """Read only when the pipe says it has something, so waiting stays bounded."""
190
+ fd = source.fileno()
191
+ with selectors.DefaultSelector() as sel:
192
+ sel.register(fd, selectors.EVENT_READ)
193
+ while True:
194
+ if sel.select(timeout=_TICK):
195
+ chunk = os.read(fd, _READ_CHUNK)
196
+ if not chunk:
197
+ return # every writer has let go: this is the end of the output
198
+ sink.write(chunk)
199
+ elif stop.is_set():
200
+ return
201
+
202
+
203
+ def _pump_until_closed(source, sink) -> None:
204
+ """The Windows path: wait on the pipe itself.
205
+
206
+ Selectors there speak only of sockets, so the read waits until the pipe
207
+ closes. Combined with having no way to kill a process tree, this is the same
208
+ honest limit `_new_session` describes: on Windows a command that leaves
209
+ children behind is watched less closely than on a system with process groups.
210
+ """
211
+ while True:
212
+ chunk = source.read(_READ_CHUNK)
213
+ if not chunk:
214
+ return
215
+ sink.write(chunk)
216
+
217
+
218
+ def _new_session() -> dict[str, object]:
219
+ """Put the child in its own group so a timeout can kill what it started.
220
+
221
+ A command that spawns children -- a test runner, a build -- leaves them
222
+ running when only the parent is killed, and those orphans keep writing to a
223
+ pipe nobody is reading. Windows has no process groups in this sense, so
224
+ there the child alone is stopped and that is the honest limit.
225
+ """
226
+ if sys.platform == "win32":
227
+ return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
228
+ return {"start_new_session": True}
229
+
230
+
231
+ def _stop(proc: subprocess.Popen) -> None:
232
+ """End a run that overstayed, the whole tree of it where the platform allows."""
233
+ try:
234
+ if sys.platform == "win32":
235
+ proc.kill()
236
+ else:
237
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
238
+ except (ProcessLookupError, PermissionError, OSError):
239
+ with contextlib.suppress(OSError):
240
+ proc.kill()