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/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]