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/__init__.py +18 -0
- sift/answers.py +175 -0
- sift/background.py +444 -0
- sift/capture.py +240 -0
- sift/cli.py +820 -0
- sift/digest.py +101 -0
- sift/distill.py +670 -0
- sift/fallback.py +98 -0
- sift/hook.py +275 -0
- sift/lines.py +37 -0
- sift/many.py +51 -0
- sift/memory.py +94 -0
- sift/model.py +433 -0
- sift/outline.py +117 -0
- sift/peek.py +161 -0
- sift/privacy.py +145 -0
- sift/records.py +95 -0
- sift/server.py +552 -0
- sift/store.py +499 -0
- sift/tools.py +76 -0
- sift/view.py +317 -0
- sift/watch.py +166 -0
- sift_cli-1.0.0.dist-info/METADATA +326 -0
- sift_cli-1.0.0.dist-info/RECORD +27 -0
- sift_cli-1.0.0.dist-info/WHEEL +4 -0
- sift_cli-1.0.0.dist-info/entry_points.txt +3 -0
- sift_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
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()
|