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/store.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""Where a capture lives after the command has finished.
|
|
2
|
+
|
|
3
|
+
The store is the reason `sift` can promise that nothing is thrown away. Whatever
|
|
4
|
+
the command wrote goes to disk first, exactly as it arrived, and every view
|
|
5
|
+
built later is a *selection over this file* rather than a replacement for it.
|
|
6
|
+
Deciding what matters is a judgement and judgements are wrong sometimes; the
|
|
7
|
+
cost of being wrong has to stay at one line of a view, never at a lost byte.
|
|
8
|
+
|
|
9
|
+
Layout, one directory per capture:
|
|
10
|
+
|
|
11
|
+
$SIFT_HOME/captures/<handle>/raw the bytes, untouched
|
|
12
|
+
$SIFT_HOME/captures/<handle>/meta.json what was run, how it ended
|
|
13
|
+
$SIFT_HOME/captures/<handle>/running.json a command still going
|
|
14
|
+
$SIFT_HOME/captures/<handle>/read.json how much of it a reader has seen
|
|
15
|
+
|
|
16
|
+
`raw` is opened in binary and never rewritten. `meta.json` is written once the
|
|
17
|
+
command has finished, which also makes it the marker for a complete capture: a
|
|
18
|
+
directory with `raw` but no `meta.json` is a run that was interrupted.
|
|
19
|
+
|
|
20
|
+
A third file, `view.json`, is written when a view is built: what the capture
|
|
21
|
+
cost and what the reader was handed instead. It is what `sift stats` adds up,
|
|
22
|
+
and it sits beside the capture rather than in a log of its own so that removing
|
|
23
|
+
a capture removes the claim made about it, with nothing left to keep in step.
|
|
24
|
+
|
|
25
|
+
`running.json` and `read.json` belong to commands that have not finished. The
|
|
26
|
+
first says a process was started and left going; the second says how far into
|
|
27
|
+
its output a reader has already been taken. Both are files rather than something
|
|
28
|
+
held in memory because every `sift` invocation is its own process: a cursor kept
|
|
29
|
+
in memory would start over at zero each time, and the reader would be handed the
|
|
30
|
+
same thousand lines again -- which is the cost this tool exists to avoid.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import contextlib
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import shutil
|
|
39
|
+
import time
|
|
40
|
+
from dataclasses import asdict, dataclass
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
_HANDLE_LENGTH = 8
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def home() -> Path:
|
|
47
|
+
"""The root under which captures are kept.
|
|
48
|
+
|
|
49
|
+
Read from the environment on every call rather than cached at import, so a
|
|
50
|
+
test can point it somewhere temporary without reloading the module.
|
|
51
|
+
"""
|
|
52
|
+
if os.environ.get("SIFT_HOME"):
|
|
53
|
+
return Path(os.environ["SIFT_HOME"]).expanduser()
|
|
54
|
+
base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
|
|
55
|
+
return Path(base).expanduser() / "sift"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def captures_dir() -> Path:
|
|
59
|
+
return home() / "captures"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Meta:
|
|
64
|
+
"""What a capture knows about itself once the command has stopped."""
|
|
65
|
+
|
|
66
|
+
handle: str
|
|
67
|
+
command: list[str]
|
|
68
|
+
shell: bool
|
|
69
|
+
exit_code: int | None
|
|
70
|
+
timed_out: bool
|
|
71
|
+
started_at: float
|
|
72
|
+
duration_s: float
|
|
73
|
+
byte_count: int
|
|
74
|
+
cwd: str
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def failed(self) -> bool:
|
|
78
|
+
"""True when the command did not end cleanly.
|
|
79
|
+
|
|
80
|
+
A timeout counts as failure even though it has no exit code, because to
|
|
81
|
+
anyone reading the output it is the same event: the thing did not work.
|
|
82
|
+
"""
|
|
83
|
+
return self.timed_out or self.exit_code not in (0, None)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def new_handle(command: list[str], started_at: float) -> str:
|
|
87
|
+
"""A short, unique name for one run.
|
|
88
|
+
|
|
89
|
+
Content-hashing the command would collide the moment the same command is run
|
|
90
|
+
twice, which is the common case, so the clock and the process take part. The
|
|
91
|
+
handle is typed by hand into `sift peek`, so it is kept short.
|
|
92
|
+
"""
|
|
93
|
+
import hashlib
|
|
94
|
+
|
|
95
|
+
seed = f"{started_at!r}|{os.getpid()}|{' '.join(command)}"
|
|
96
|
+
return hashlib.sha256(seed.encode("utf-8", "replace")).hexdigest()[:_HANDLE_LENGTH]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class Running:
|
|
101
|
+
"""A command that was started and left to run.
|
|
102
|
+
|
|
103
|
+
`pid` is the process that was started to look after the command, not the
|
|
104
|
+
command itself. Killing that process's group ends both, and asking whether
|
|
105
|
+
it is alive is asking whether anything is still watching -- which is the
|
|
106
|
+
question a reader actually has.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
handle: str
|
|
110
|
+
command: list[str]
|
|
111
|
+
shell: bool
|
|
112
|
+
cwd: str
|
|
113
|
+
started_at: float
|
|
114
|
+
pid: int
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class Cursor:
|
|
119
|
+
"""How far into a capture a reader has already been taken.
|
|
120
|
+
|
|
121
|
+
Bytes and lines both, because they answer different questions and neither
|
|
122
|
+
can be worked out from the other without reading the file again: bytes say
|
|
123
|
+
where to start reading, lines say what number the next line has.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
bytes: int = 0
|
|
127
|
+
lines: int = 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def raw_path(handle: str) -> Path:
|
|
131
|
+
return captures_dir() / handle / "raw"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def meta_path(handle: str) -> Path:
|
|
135
|
+
return captures_dir() / handle / "meta.json"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def running_path(handle: str) -> Path:
|
|
139
|
+
return captures_dir() / handle / "running.json"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cursor_path(handle: str) -> Path:
|
|
143
|
+
return captures_dir() / handle / "read.json"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def begin(handle: str) -> Path:
|
|
147
|
+
"""Make room for a capture and hand back the file to write bytes into."""
|
|
148
|
+
d = captures_dir() / handle
|
|
149
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
return d / "raw"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def finish(meta: Meta) -> None:
|
|
154
|
+
"""Record how the run ended. Writing this file is what marks it complete."""
|
|
155
|
+
meta_path(meta.handle).write_text(
|
|
156
|
+
json.dumps(asdict(meta), ensure_ascii=False, indent=2), encoding="utf-8"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def mark_running(started: Running) -> None:
|
|
161
|
+
"""Record that a command was started and nobody is waiting for it."""
|
|
162
|
+
running_path(started.handle).write_text(
|
|
163
|
+
json.dumps(asdict(started), ensure_ascii=False, indent=2), encoding="utf-8"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def load_running(handle: str) -> Running | None:
|
|
168
|
+
"""What was started under this handle, or None if nothing was left going.
|
|
169
|
+
|
|
170
|
+
A capture that has finished is not running whatever the file says: the
|
|
171
|
+
marker is removed at the end, but a process killed hard enough never gets to
|
|
172
|
+
remove it, and `meta.json` is the older and more trustworthy of the two.
|
|
173
|
+
"""
|
|
174
|
+
if meta_path(handle).is_file():
|
|
175
|
+
return None
|
|
176
|
+
p = running_path(handle)
|
|
177
|
+
if not p.is_file():
|
|
178
|
+
return None
|
|
179
|
+
try:
|
|
180
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
181
|
+
except (OSError, json.JSONDecodeError):
|
|
182
|
+
return None
|
|
183
|
+
fields = set(Running.__dataclass_fields__)
|
|
184
|
+
try:
|
|
185
|
+
return Running(**{k: v for k, v in data.items() if k in fields})
|
|
186
|
+
except TypeError:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def clear_running(handle: str) -> None:
|
|
191
|
+
"""Forget the marker, whether or not it was there."""
|
|
192
|
+
with contextlib.suppress(OSError):
|
|
193
|
+
running_path(handle).unlink()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def started() -> list[Running]:
|
|
197
|
+
"""Every command left going, newest first."""
|
|
198
|
+
d = captures_dir()
|
|
199
|
+
if not d.is_dir():
|
|
200
|
+
return []
|
|
201
|
+
found = [load_running(p.name) for p in d.iterdir() if p.is_dir()]
|
|
202
|
+
alive = [r for r in found if r is not None]
|
|
203
|
+
alive.sort(key=lambda r: r.started_at, reverse=True)
|
|
204
|
+
return alive
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def load_cursor(handle: str) -> Cursor:
|
|
208
|
+
"""How much of this capture a reader has already been handed.
|
|
209
|
+
|
|
210
|
+
Nothing read is the honest answer for a capture nobody has looked at and for
|
|
211
|
+
one whose cursor cannot be read, so both come back the same: a reader shown
|
|
212
|
+
a line twice has lost nothing but patience, and one shown nothing has lost
|
|
213
|
+
the output.
|
|
214
|
+
"""
|
|
215
|
+
p = cursor_path(handle)
|
|
216
|
+
if not p.is_file():
|
|
217
|
+
return Cursor()
|
|
218
|
+
try:
|
|
219
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
220
|
+
return Cursor(bytes=int(data["bytes"]), lines=int(data["lines"]))
|
|
221
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError):
|
|
222
|
+
return Cursor()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def save_cursor(handle: str, cursor: Cursor) -> None:
|
|
226
|
+
"""Move the cursor, and never let moving it cost the caller their output."""
|
|
227
|
+
with contextlib.suppress(OSError):
|
|
228
|
+
cursor_path(handle).write_text(
|
|
229
|
+
json.dumps(asdict(cursor), ensure_ascii=False, indent=2), encoding="utf-8"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def load(handle: str) -> Meta | None:
|
|
234
|
+
"""The metadata for a finished capture, or None if there is no such capture.
|
|
235
|
+
|
|
236
|
+
An interrupted run -- bytes on disk, no `meta.json` -- reads as absent here,
|
|
237
|
+
while `read_raw` will still hand back what it managed to write. That split is
|
|
238
|
+
deliberate: the bytes are always worth keeping, the claims about them are not
|
|
239
|
+
worth making up.
|
|
240
|
+
"""
|
|
241
|
+
p = meta_path(handle)
|
|
242
|
+
if not p.is_file():
|
|
243
|
+
return None
|
|
244
|
+
try:
|
|
245
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
246
|
+
except (OSError, json.JSONDecodeError):
|
|
247
|
+
return None
|
|
248
|
+
fields = {f for f in Meta.__dataclass_fields__}
|
|
249
|
+
return Meta(**{k: v for k, v in data.items() if k in fields})
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def read_raw(handle: str) -> bytes:
|
|
253
|
+
"""Every byte the command wrote, exactly as it wrote them.
|
|
254
|
+
|
|
255
|
+
A handle that was never captured raises rather than reading as empty. The
|
|
256
|
+
two look identical on screen -- nothing -- and they mean opposite things:
|
|
257
|
+
"the command said nothing" against "you are asking about something that is
|
|
258
|
+
not here". Silently answering the first when asked the second sends someone
|
|
259
|
+
looking for a bug in their command.
|
|
260
|
+
"""
|
|
261
|
+
p = raw_path(handle)
|
|
262
|
+
if not p.is_file():
|
|
263
|
+
raise FileNotFoundError(f"no capture named {handle!r}")
|
|
264
|
+
return p.read_bytes()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def recent(limit: int = 20) -> list[Meta]:
|
|
268
|
+
"""Finished captures, newest first."""
|
|
269
|
+
d = captures_dir()
|
|
270
|
+
if not d.is_dir():
|
|
271
|
+
return []
|
|
272
|
+
metas = [m for m in (load(p.name) for p in d.iterdir() if p.is_dir()) if m is not None]
|
|
273
|
+
metas.sort(key=lambda m: m.started_at, reverse=True)
|
|
274
|
+
return metas[:limit]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True)
|
|
278
|
+
class Saving:
|
|
279
|
+
"""What one view cost, next to what it stood for."""
|
|
280
|
+
|
|
281
|
+
handle: str
|
|
282
|
+
raw_bytes: int
|
|
283
|
+
shown_bytes: int
|
|
284
|
+
kept: int
|
|
285
|
+
total: int
|
|
286
|
+
model: str | None
|
|
287
|
+
asks: int
|
|
288
|
+
# Defaulted, so that a report written before this field existed still loads
|
|
289
|
+
# as what it was: a run nobody had counted the silent questions of.
|
|
290
|
+
unanswered: int = 0
|
|
291
|
+
# What the asking cost, in the endpoint's own tokens. Defaulted for the same
|
|
292
|
+
# reason and read the same way: zero means nobody counted, not that it was
|
|
293
|
+
# free. A report that told those two apart by guessing would be inventing
|
|
294
|
+
# the only number here that is not this tool's own arithmetic.
|
|
295
|
+
tokens: int = 0
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def part(self) -> float:
|
|
299
|
+
"""The share of the capture the reader was actually handed, as a percent."""
|
|
300
|
+
return self.shown_bytes * 100 / self.raw_bytes if self.raw_bytes else 0.0
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def view_path(handle: str) -> Path:
|
|
304
|
+
return captures_dir() / handle / "view.json"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def record(saving: Saving) -> None:
|
|
308
|
+
"""Write down what a view cost, and never let the writing cost anything.
|
|
309
|
+
|
|
310
|
+
A full disk, a read-only cache, a directory swept up between the run and the
|
|
311
|
+
view -- none of those are reasons for a caller to lose the output they asked
|
|
312
|
+
for. Of everything this tool does, the bookkeeping is the part that may fail
|
|
313
|
+
silently, because it is the only part nobody asked for.
|
|
314
|
+
"""
|
|
315
|
+
with contextlib.suppress(OSError):
|
|
316
|
+
view_path(saving.handle).write_text(
|
|
317
|
+
json.dumps(asdict(saving), ensure_ascii=False, indent=2), encoding="utf-8"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def load_saving(handle: str) -> Saving | None:
|
|
322
|
+
"""What a view of this capture cost, or None if no view was ever built."""
|
|
323
|
+
p = view_path(handle)
|
|
324
|
+
if not p.is_file():
|
|
325
|
+
return None
|
|
326
|
+
try:
|
|
327
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
328
|
+
except (OSError, json.JSONDecodeError):
|
|
329
|
+
return None
|
|
330
|
+
fields = {f for f in Saving.__dataclass_fields__}
|
|
331
|
+
return Saving(**{k: v for k, v in data.items() if k in fields})
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def savings(limit: int = 20) -> list[tuple[Meta, Saving]]:
|
|
335
|
+
"""Of the last `limit` runs, those that produced a view, newest first.
|
|
336
|
+
|
|
337
|
+
Runs without one are left out rather than counted as saving nothing: a
|
|
338
|
+
capture whose view was never built has not been measured, and a report that
|
|
339
|
+
quietly averaged it in would understate the tool by exactly the number of
|
|
340
|
+
times somebody ran `sift list`.
|
|
341
|
+
"""
|
|
342
|
+
pairs = []
|
|
343
|
+
for meta in recent(limit):
|
|
344
|
+
found = load_saving(meta.handle)
|
|
345
|
+
if found is not None:
|
|
346
|
+
pairs.append((meta, found))
|
|
347
|
+
return pairs
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def now() -> float:
|
|
351
|
+
return time.time()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# What `sift gc` calls old when nobody says otherwise, in days.
|
|
355
|
+
#
|
|
356
|
+
# There is no automatic sweep and there is not going to be one. "Nothing is
|
|
357
|
+
# thrown away" is the second of the three rules, and a tool that quietly deleted
|
|
358
|
+
# a capture on its own initiative would be breaking it whatever the age. What a
|
|
359
|
+
# person types is a different act: `sift gc` is them throwing something away,
|
|
360
|
+
# and this number only decides what the command means when they do not say.
|
|
361
|
+
KEEP_DAYS = 30
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def keep_days() -> float:
|
|
365
|
+
"""How old is old, with `SIFT_KEEP_DAYS` overriding."""
|
|
366
|
+
written = os.environ.get("SIFT_KEEP_DAYS", "").strip()
|
|
367
|
+
try:
|
|
368
|
+
asked = float(written)
|
|
369
|
+
except ValueError:
|
|
370
|
+
return float(KEEP_DAYS)
|
|
371
|
+
return asked if asked > 0 else float(KEEP_DAYS)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def gone_path() -> Path:
|
|
375
|
+
"""One file for every capture `gc` has removed, not one directory each.
|
|
376
|
+
|
|
377
|
+
A stone per capture was the first shape and it was wrong twice over. It
|
|
378
|
+
leaves a directory behind for every run ever swept -- 518 of them, four
|
|
379
|
+
kilobytes of block each, to hold fifty-two bytes -- so a sweep that was
|
|
380
|
+
supposed to reclaim space keeps a permanent tax on having had it. And it
|
|
381
|
+
means a capture directory is never actually gone, which is precisely what
|
|
382
|
+
the README promises about one somebody removes by hand.
|
|
383
|
+
|
|
384
|
+
So the directory goes entirely, and what it was is written here.
|
|
385
|
+
"""
|
|
386
|
+
return home() / "gone.json"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@dataclass(frozen=True)
|
|
390
|
+
class Gone:
|
|
391
|
+
"""A capture that was removed, and the little that outlives it."""
|
|
392
|
+
|
|
393
|
+
handle: str
|
|
394
|
+
removed_at: float
|
|
395
|
+
byte_count: int
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _stones() -> dict[str, dict]:
|
|
399
|
+
try:
|
|
400
|
+
data = json.loads(gone_path().read_text(encoding="utf-8"))
|
|
401
|
+
except (OSError, json.JSONDecodeError):
|
|
402
|
+
return {}
|
|
403
|
+
return data if isinstance(data, dict) else {}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def gone(handle: str) -> Gone | None:
|
|
407
|
+
"""Whether this handle names something that was here and was removed.
|
|
408
|
+
|
|
409
|
+
What is kept is a handle, a date and a size, and nothing else. Not the
|
|
410
|
+
command, not a line of output, not the working directory: a handle is a hash
|
|
411
|
+
and gives nothing back, while the command would be the most identifying part
|
|
412
|
+
of what was just deleted.
|
|
413
|
+
|
|
414
|
+
It is kept at all for the second rule. A gap marker says `sift peek 9f2c41ab`
|
|
415
|
+
and may be read a week later; without this, that lands on "no such capture",
|
|
416
|
+
which is the answer for a handle somebody invented. Those two are not the
|
|
417
|
+
same and a reader acts on them differently.
|
|
418
|
+
"""
|
|
419
|
+
found = _stones().get(handle)
|
|
420
|
+
if found is None:
|
|
421
|
+
return None
|
|
422
|
+
return Gone(
|
|
423
|
+
handle=handle,
|
|
424
|
+
removed_at=float(found.get("removed_at", 0.0)),
|
|
425
|
+
byte_count=int(found.get("byte_count", 0)),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@dataclass(frozen=True)
|
|
430
|
+
class Swept:
|
|
431
|
+
"""One capture that `sift gc` removed, described while it still could be."""
|
|
432
|
+
|
|
433
|
+
handle: str
|
|
434
|
+
command: list[str]
|
|
435
|
+
byte_count: int
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def sweep(older_than: float, now_at: float | None = None) -> list[Swept]:
|
|
439
|
+
"""Remove every finished capture that ended more than `older_than` ago.
|
|
440
|
+
|
|
441
|
+
Three things are never swept, and each refusal is a rule rather than a
|
|
442
|
+
caution. A run still marked running is left alone: nobody knows how it came
|
|
443
|
+
out, and its supervisor is still writing to the file this would delete. A
|
|
444
|
+
capture whose age cannot be established is left alone -- guessing an age
|
|
445
|
+
would mean deleting on a guess.
|
|
446
|
+
|
|
447
|
+
What comes back is what went, described from the metadata while it was still
|
|
448
|
+
there to read. The caller prints it; nothing else records it.
|
|
449
|
+
"""
|
|
450
|
+
where = captures_dir()
|
|
451
|
+
if not where.is_dir():
|
|
452
|
+
return []
|
|
453
|
+
cut = (now() if now_at is None else now_at) - older_than
|
|
454
|
+
stones = _stones()
|
|
455
|
+
swept: list[Swept] = []
|
|
456
|
+
for entry in sorted(where.iterdir()):
|
|
457
|
+
if not entry.is_dir():
|
|
458
|
+
continue
|
|
459
|
+
handle = entry.name
|
|
460
|
+
if running_path(handle).is_file():
|
|
461
|
+
continue
|
|
462
|
+
meta = load(handle)
|
|
463
|
+
ended = _ended_at(handle, meta)
|
|
464
|
+
if ended is None or ended > cut:
|
|
465
|
+
continue
|
|
466
|
+
|
|
467
|
+
raw = raw_path(handle)
|
|
468
|
+
size = raw.stat().st_size if raw.is_file() else 0
|
|
469
|
+
try:
|
|
470
|
+
shutil.rmtree(entry)
|
|
471
|
+
except OSError:
|
|
472
|
+
continue # a capture that would not go is not a capture that went
|
|
473
|
+
stones[handle] = {"removed_at": now(), "byte_count": size}
|
|
474
|
+
swept.append(
|
|
475
|
+
Swept(handle, list(meta.command) if meta is not None else [], size)
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
if swept:
|
|
479
|
+
with contextlib.suppress(OSError):
|
|
480
|
+
gone_path().write_text(
|
|
481
|
+
json.dumps(stones, ensure_ascii=False, indent=2), encoding="utf-8"
|
|
482
|
+
)
|
|
483
|
+
return swept
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _ended_at(handle: str, meta: Meta | None) -> float | None:
|
|
487
|
+
"""When this capture stopped being written to, or None if that is unknown.
|
|
488
|
+
|
|
489
|
+
A finished run says so itself. A run that was interrupted has bytes and no
|
|
490
|
+
claims about them, and the file's own modification time is the last honest
|
|
491
|
+
thing left -- but only if the file is there. Nothing else is guessed at.
|
|
492
|
+
"""
|
|
493
|
+
if meta is not None:
|
|
494
|
+
return meta.started_at + meta.duration_s
|
|
495
|
+
raw = raw_path(handle)
|
|
496
|
+
try:
|
|
497
|
+
return raw.stat().st_mtime
|
|
498
|
+
except OSError:
|
|
499
|
+
return None
|
sift/tools.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Tools that answer a question without opening the file.
|
|
2
|
+
|
|
3
|
+
Three commands worth knowing about, and the reason they are here rather than
|
|
4
|
+
left to the caller: each of them replaces *reading*, which is the expensive
|
|
5
|
+
thing this project exists to avoid.
|
|
6
|
+
|
|
7
|
+
ast-grep where does this shape occur -- instead of opening the
|
|
8
|
+
candidates one by one to find out
|
|
9
|
+
difftastic what actually changed -- instead of a line diff that calls a
|
|
10
|
+
reindent a change and buries the one line that moved
|
|
11
|
+
scc how big is this tree -- instead of guessing, or listing it
|
|
12
|
+
|
|
13
|
+
Their output is large by nature, which is why they belong to `sift` at all: a
|
|
14
|
+
4,000-line structural search costs a screenful here and stays whole on disk.
|
|
15
|
+
|
|
16
|
+
**No binaries ship with this package.** The list below is a list of programs a
|
|
17
|
+
machine may or may not have, not a dependency. Nothing is downloaded, nothing is
|
|
18
|
+
installed behind anyone's back; if a tool is missing, this says so and says what
|
|
19
|
+
it is called, and the decision stays with the person whose machine it is.
|
|
20
|
+
|
|
21
|
+
The list is short and it is meant to stay short. It is not a table of languages
|
|
22
|
+
wearing a different hat -- those three entries are the same three whatever
|
|
23
|
+
language the project is written in, and a fourth would have to earn its place by
|
|
24
|
+
replacing reading too.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import shutil
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Dense:
|
|
35
|
+
"""One program, what it replaces, and what it is called on a package manager."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
binary: str
|
|
39
|
+
replaces: str
|
|
40
|
+
known_as: str
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def here(self) -> bool:
|
|
44
|
+
return shutil.which(self.binary) is not None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
KNOWN: tuple[Dense, ...] = (
|
|
48
|
+
Dense(
|
|
49
|
+
name="sg",
|
|
50
|
+
binary="ast-grep",
|
|
51
|
+
replaces="opening candidate files to find where a shape occurs",
|
|
52
|
+
known_as="ast-grep",
|
|
53
|
+
),
|
|
54
|
+
Dense(
|
|
55
|
+
name="diff",
|
|
56
|
+
binary="difft",
|
|
57
|
+
replaces="a line diff that cannot tell a reindent from a change",
|
|
58
|
+
known_as="difftastic",
|
|
59
|
+
),
|
|
60
|
+
Dense(
|
|
61
|
+
name="loc",
|
|
62
|
+
binary="scc",
|
|
63
|
+
replaces="guessing how large a tree is, or listing it to find out",
|
|
64
|
+
known_as="scc",
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
BY_NAME = {one.name: one for one in KNOWN}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def command_for(name: str, args: list[str]) -> list[str] | None:
|
|
72
|
+
"""The command line for one of these, or nothing if this is not one of them."""
|
|
73
|
+
known = BY_NAME.get(name)
|
|
74
|
+
if known is None:
|
|
75
|
+
return None
|
|
76
|
+
return [known.binary, *args]
|