sift-summarizer 0.1.95__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 +16 -0
- sift/__main__.py +7 -0
- sift/cache.py +412 -0
- sift/categories.py +348 -0
- sift/chunk.py +159 -0
- sift/cli.py +754 -0
- sift/config.py +529 -0
- sift/deadline.py +157 -0
- sift/detect.py +1229 -0
- sift/embeddings.py +133 -0
- sift/extract/__init__.py +417 -0
- sift/extract/active.py +2334 -0
- sift/extract/base.py +3515 -0
- sift/extract/email_msg.py +1352 -0
- sift/extract/fallback.py +899 -0
- sift/extract/html_doc.py +229 -0
- sift/extract/image.py +305 -0
- sift/extract/iso9660.py +771 -0
- sift/extract/legacy.py +1088 -0
- sift/extract/members.py +85 -0
- sift/extract/office.py +2898 -0
- sift/extract/pdf.py +4920 -0
- sift/extract/rtf.py +357 -0
- sift/extract/sevenzip.py +973 -0
- sift/extract/sqlite_db.py +289 -0
- sift/extract/structured.py +393 -0
- sift/extract/tabular.py +1370 -0
- sift/extract/text.py +546 -0
- sift/llm/__init__.py +68 -0
- sift/llm/base.py +1109 -0
- sift/llm/ollama.py +240 -0
- sift/llm/openai_compat.py +448 -0
- sift/llm/prompts.py +600 -0
- sift/natural_language.py +108 -0
- sift/report.py +389 -0
- sift/server.py +806 -0
- sift/summarize.py +4904 -0
- sift/webui/app.js +254 -0
- sift/webui/index.html +80 -0
- sift/webui/style.css +137 -0
- sift/zipguard.py +125 -0
- sift_summarizer-0.1.95.dist-info/METADATA +758 -0
- sift_summarizer-0.1.95.dist-info/RECORD +47 -0
- sift_summarizer-0.1.95.dist-info/WHEEL +5 -0
- sift_summarizer-0.1.95.dist-info/entry_points.txt +2 -0
- sift_summarizer-0.1.95.dist-info/licenses/LICENSE +21 -0
- sift_summarizer-0.1.95.dist-info/top_level.txt +1 -0
sift/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""sift — recognize & summarize files with a local LLM (offline-first).
|
|
2
|
+
|
|
3
|
+
sift detects a file's true type (by extension *and* content), extracts its
|
|
4
|
+
text/structure with a purpose-built extractor, and asks a locally-hosted LLM
|
|
5
|
+
(Ollama, LM Studio, or MLX-LM) to classify and summarize it. Everything runs
|
|
6
|
+
on your machine; no data leaves the host.
|
|
7
|
+
|
|
8
|
+
The core has **zero third-party dependencies** — csv, eml, json, html, docx,
|
|
9
|
+
xlsx, pptx and image metadata are all parsed with the standard library. Optional
|
|
10
|
+
packages (pypdf, Pillow, PyYAML, rich, …) are used automatically when present to
|
|
11
|
+
raise fidelity, but nothing breaks when they are absent.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.95"
|
|
16
|
+
__all__ = ["__version__"]
|
sift/__main__.py
ADDED
sift/cache.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""Summary cache, keyed by what actually determines the answer.
|
|
2
|
+
|
|
3
|
+
Summarizing a directory with a local model costs minutes: the operator's 40B
|
|
4
|
+
took 663 seconds for nine small files. Re-running after changing one file
|
|
5
|
+
should cost one file, not nine. That only holds if the key is exact — a cache
|
|
6
|
+
that returns a summary produced by a different model, or from a file that has
|
|
7
|
+
since changed, is worse than no cache, because the answer looks fresh.
|
|
8
|
+
|
|
9
|
+
So the key covers everything that can change the answer: the file's content
|
|
10
|
+
(hashed, not its mtime — a restored backup has a new mtime and identical
|
|
11
|
+
bytes), the provider and model, the generation settings, and a version stamp
|
|
12
|
+
that is bumped whenever prompts or extraction change. Nothing else is stored,
|
|
13
|
+
and an unreadable or corrupt entry is simply a miss.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import tempfile
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Optional
|
|
24
|
+
|
|
25
|
+
#: Bumped by hand when the storage format changes. It is not the whole story:
|
|
26
|
+
#: what actually decides whether an old entry is still right is the code that
|
|
27
|
+
#: produced it, and that is fingerprinted below.
|
|
28
|
+
CACHE_FORMAT = 5
|
|
29
|
+
|
|
30
|
+
#: A shape such as {"cache_version": ..., "summary": ...} can occur in a
|
|
31
|
+
#: project JSON document by accident. Ownership must be explicit before sift
|
|
32
|
+
#: deletes anything below an operator-selected SIFT_CACHE_DIR.
|
|
33
|
+
CACHE_OWNER = "sift-summary-cache"
|
|
34
|
+
|
|
35
|
+
_CHUNK = 1 << 20
|
|
36
|
+
#: Cache entries are bounded by the extracted text and metadata. Refuse to
|
|
37
|
+
#: parse anything larger while deciding whether clear() owns it: a user may
|
|
38
|
+
#: point SIFT_CACHE_DIR at a directory containing arbitrary files.
|
|
39
|
+
_MAX_ENTRY_FILE = 8 * 1024 * 1024
|
|
40
|
+
|
|
41
|
+
#: Modules whose behaviour determines a summary. Anything else — the CLI, the
|
|
42
|
+
#: report renderer, the server — changes how a summary is displayed, not what
|
|
43
|
+
#: it says.
|
|
44
|
+
_ANSWER_MODULES = ("summarize.py", "detect.py", "chunk.py",
|
|
45
|
+
"llm", "extract")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _code_fingerprint() -> str:
|
|
49
|
+
"""A digest of the code that decides what a summary says.
|
|
50
|
+
|
|
51
|
+
Relying on someone remembering to bump a constant is how a cache goes
|
|
52
|
+
stale: the heuristics were rewritten twice in one session and the old
|
|
53
|
+
answers kept being served, which looked exactly like the rewrite not
|
|
54
|
+
working. Deriving the version from the source makes forgetting impossible.
|
|
55
|
+
Unreadable sources (a zipped install) fall back to the manual number.
|
|
56
|
+
"""
|
|
57
|
+
here = Path(__file__).resolve().parent
|
|
58
|
+
digest = hashlib.sha256()
|
|
59
|
+
try:
|
|
60
|
+
paths: list[Path] = []
|
|
61
|
+
for entry in _ANSWER_MODULES:
|
|
62
|
+
target = here / entry
|
|
63
|
+
paths.extend(sorted(target.rglob("*.py")) if target.is_dir()
|
|
64
|
+
else [target])
|
|
65
|
+
for path in paths:
|
|
66
|
+
digest.update(path.read_bytes())
|
|
67
|
+
except OSError: # pragma: no cover - installed without sources
|
|
68
|
+
return "nosrc"
|
|
69
|
+
return digest.hexdigest()[:12]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
#: What the cache may occupy before the oldest entries are evicted. A
|
|
73
|
+
#: summary is a few kilobytes, so this is room for tens of thousands of
|
|
74
|
+
#: them — and a hard stop on an unauthenticated disk-fill.
|
|
75
|
+
MAX_CACHE_BYTES = 256 * 1024 * 1024
|
|
76
|
+
|
|
77
|
+
def _occupied(info) -> int:
|
|
78
|
+
"""What an entry costs the volume, not what it says it contains.
|
|
79
|
+
|
|
80
|
+
A 900-byte summary occupies a 4 KB block. Counting st_size understated a
|
|
81
|
+
60,000-entry cache by 2.6x — 94.7 MB claimed against 245.8 MB allocated.
|
|
82
|
+
st_blocks is in 512-byte units by POSIX definition and absent on
|
|
83
|
+
Windows, where st_size is the best available answer.
|
|
84
|
+
"""
|
|
85
|
+
blocks = getattr(info, "st_blocks", None)
|
|
86
|
+
return blocks * 512 if blocks else info.st_size
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
CACHE_VERSION = f"{CACHE_FORMAT}-{_code_fingerprint()}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cache_dir() -> Path:
|
|
93
|
+
base = os.environ.get("SIFT_CACHE_DIR")
|
|
94
|
+
if base:
|
|
95
|
+
return Path(base)
|
|
96
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
97
|
+
root = Path(xdg) if xdg else Path.home() / ".cache"
|
|
98
|
+
return root / "sift"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def file_digest(path: Path) -> str:
|
|
102
|
+
"""SHA-256 of the file's bytes, read in chunks so size does not matter."""
|
|
103
|
+
digest = hashlib.sha256()
|
|
104
|
+
with path.open("rb") as fh:
|
|
105
|
+
while True:
|
|
106
|
+
block = fh.read(_CHUNK)
|
|
107
|
+
if not block:
|
|
108
|
+
break
|
|
109
|
+
digest.update(block)
|
|
110
|
+
return digest.hexdigest()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cache_key(content_hash: str, config, provider: str, model: str,
|
|
114
|
+
endpoint: str = "", filename: str = "") -> str:
|
|
115
|
+
"""One key per (content, engine, settings) triple.
|
|
116
|
+
|
|
117
|
+
The settings are in the key rather than the value because a summary made
|
|
118
|
+
with a 2000-token ceiling is a different summary from one made with 800,
|
|
119
|
+
and silently serving the wrong one is the failure this whole module exists
|
|
120
|
+
to avoid.
|
|
121
|
+
|
|
122
|
+
The endpoint is in the key too, and that is not belt-and-braces. When the
|
|
123
|
+
model is auto-picked it is still unresolved at lookup time — empty for
|
|
124
|
+
every server — so without the address, two different machines answering on
|
|
125
|
+
the same provider shared one entry. The suite caught it: a summary produced
|
|
126
|
+
by a stub server was served for a run against a dead one.
|
|
127
|
+
|
|
128
|
+
So is the filename, for two reasons: the extension steers detection, and
|
|
129
|
+
the name is in the prompt. Identical bytes named t.txt and t.csv are a
|
|
130
|
+
text file and a table, and the cache served the first answer for both —
|
|
131
|
+
"Plain text with 3 lines" for a CSV.
|
|
132
|
+
"""
|
|
133
|
+
parts = [
|
|
134
|
+
str(CACHE_VERSION), content_hash, provider or "-", model or "-",
|
|
135
|
+
endpoint or "-", filename or "-",
|
|
136
|
+
str(config.context_tokens), str(config.max_output_tokens),
|
|
137
|
+
f"{config.temperature:.3f}", str(config.max_bytes),
|
|
138
|
+
"vision" if config.vision else "novision",
|
|
139
|
+
"llm" if not config.no_llm else "heuristic",
|
|
140
|
+
]
|
|
141
|
+
# What changes the answer only when it is asked for, and only then: a
|
|
142
|
+
# default run's key is the one it had before these settings existed.
|
|
143
|
+
# The active-content scan reads further than max_bytes only where
|
|
144
|
+
# active_max_bytes asks it to (extract/active.py reach).
|
|
145
|
+
from .extract.active import reach
|
|
146
|
+
scanned = reach(config.max_bytes, getattr(config, "active_max_bytes", 0))
|
|
147
|
+
if scanned != config.max_bytes:
|
|
148
|
+
parts.append(f"active={scanned}")
|
|
149
|
+
tags = getattr(config, "tags", 0)
|
|
150
|
+
if tags:
|
|
151
|
+
parts.append(f"tags={tags}")
|
|
152
|
+
categories = getattr(config, "categories", None) or []
|
|
153
|
+
rules = getattr(config, "category_rules", None) or []
|
|
154
|
+
if categories or rules:
|
|
155
|
+
from .categories import category_set_hash
|
|
156
|
+
parts.append(f"categories={category_set_hash(categories, rules)}")
|
|
157
|
+
material = "\x1f".join(parts)
|
|
158
|
+
# surrogateescape, not plain utf-8: a filename holding an invalid byte
|
|
159
|
+
# arrives as a lone surrogate, which utf-8 refuses to encode. That raised
|
|
160
|
+
# a UnicodeEncodeError — a ValueError, not an OSError — straight past
|
|
161
|
+
# summarize_path's promise never to raise for a bad file, and the whole
|
|
162
|
+
# run died on a directory listing.
|
|
163
|
+
return hashlib.sha256(
|
|
164
|
+
material.encode("utf-8", "surrogateescape")).hexdigest()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class SummaryCache:
|
|
168
|
+
"""A directory of JSON files. Absent, unreadable and stale all mean miss."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, enabled: bool = True, directory: Optional[Path] = None):
|
|
171
|
+
self.enabled = enabled
|
|
172
|
+
self.dir = Path(directory) if directory else cache_dir()
|
|
173
|
+
self.hits = 0
|
|
174
|
+
self.evicted = 0
|
|
175
|
+
#: Running total of what the cache occupies, so the ordinary write
|
|
176
|
+
#: does not walk the directory. None until the first write, which
|
|
177
|
+
#: measures what is already there — a fresh SummaryCache per CLI run
|
|
178
|
+
#: otherwise starts at zero and never notices the ceiling.
|
|
179
|
+
self._estimate = None
|
|
180
|
+
self.misses = 0
|
|
181
|
+
|
|
182
|
+
def _path(self, key: str) -> Path:
|
|
183
|
+
# Two levels of fan-out: one directory with 100k entries is slow to
|
|
184
|
+
# list and unpleasant to inspect by hand.
|
|
185
|
+
return self.dir / key[:2] / f"{key}.json"
|
|
186
|
+
|
|
187
|
+
def get(self, key: str) -> Optional[dict]:
|
|
188
|
+
if not self.enabled:
|
|
189
|
+
return None
|
|
190
|
+
try:
|
|
191
|
+
# surrogateescape on both sides. Writing with it and reading
|
|
192
|
+
# without meant an entry naming an undecodable file was written
|
|
193
|
+
# once and never readable again: every run a miss, every run
|
|
194
|
+
# another write. Half a fix is worse than none — it looks like a
|
|
195
|
+
# working cache and is a growing pile of unreadable files.
|
|
196
|
+
with self._path(key).open("r", encoding="utf-8",
|
|
197
|
+
errors="surrogateescape") as fh:
|
|
198
|
+
entry = json.load(fh)
|
|
199
|
+
except (OSError, ValueError):
|
|
200
|
+
self.misses += 1
|
|
201
|
+
return None
|
|
202
|
+
if (not isinstance(entry, dict)
|
|
203
|
+
or entry.get("cache_owner") != CACHE_OWNER
|
|
204
|
+
or entry.get("cache_version") != CACHE_VERSION):
|
|
205
|
+
self.misses += 1
|
|
206
|
+
return None
|
|
207
|
+
self.hits += 1
|
|
208
|
+
# Mark it used, so eviction runs least-recently-*used* rather than
|
|
209
|
+
# oldest-written. Without this an attacker's fresh uploads evicted
|
|
210
|
+
# the entries the defender actually re-reads — the wrong end of the
|
|
211
|
+
# cache. Explicit rather than relying on st_atime, which most
|
|
212
|
+
# filesystems are mounted not to keep.
|
|
213
|
+
try:
|
|
214
|
+
os.utime(self._path(key), None)
|
|
215
|
+
except OSError:
|
|
216
|
+
pass # a read-only cache is still a cache
|
|
217
|
+
return entry.get("summary")
|
|
218
|
+
|
|
219
|
+
def put(self, key: str, summary: dict) -> None:
|
|
220
|
+
if not self.enabled:
|
|
221
|
+
return
|
|
222
|
+
target = self._path(key)
|
|
223
|
+
entry = {"cache_owner": CACHE_OWNER,
|
|
224
|
+
"cache_version": CACHE_VERSION, "stored_at": int(time.time()),
|
|
225
|
+
"summary": summary}
|
|
226
|
+
try:
|
|
227
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
# Write-then-rename: a crash mid-write must not leave a truncated
|
|
229
|
+
# entry that later reads as a valid summary.
|
|
230
|
+
temp = None
|
|
231
|
+
try:
|
|
232
|
+
with tempfile.NamedTemporaryFile(
|
|
233
|
+
# errors="surrogateescape": a summary can carry an
|
|
234
|
+
# undecodable filename, and a cache write must never
|
|
235
|
+
# be the thing that ends a run.
|
|
236
|
+
"w", encoding="utf-8", errors="surrogateescape",
|
|
237
|
+
dir=target.parent,
|
|
238
|
+
prefix=".tmp-", delete=False) as fh:
|
|
239
|
+
temp = Path(fh.name)
|
|
240
|
+
json.dump(entry, fh, ensure_ascii=False)
|
|
241
|
+
temp.replace(target)
|
|
242
|
+
finally:
|
|
243
|
+
# Named before the dump, not after: when the dump raised — the
|
|
244
|
+
# lone-surrogate case — the file was never named and never
|
|
245
|
+
# removed, so every run over the same input left another one
|
|
246
|
+
# behind, and clear() counts *.json and could not see them.
|
|
247
|
+
if temp is not None and temp.exists():
|
|
248
|
+
temp.unlink(missing_ok=True)
|
|
249
|
+
# Cheap in the ordinary case: the running estimate is updated
|
|
250
|
+
# per write and the directory is walked only when it says the
|
|
251
|
+
# ceiling might have been crossed.
|
|
252
|
+
if self._estimate is None:
|
|
253
|
+
# Once per process, before the first write is counted: the
|
|
254
|
+
# estimate started at zero, so it only ever counted *this*
|
|
255
|
+
# run's writes. A CLI run over 600 files writes about 1% of
|
|
256
|
+
# the ceiling, so _make_room was never called and the cache
|
|
257
|
+
# grew without bound across runs — measured at nine times the
|
|
258
|
+
# ceiling after twenty short runs, with zero evictions. The
|
|
259
|
+
# walk is right; doing it per write was what was wrong.
|
|
260
|
+
self._make_room()
|
|
261
|
+
else:
|
|
262
|
+
self._estimate += _occupied(target.stat())
|
|
263
|
+
if self._estimate > MAX_CACHE_BYTES:
|
|
264
|
+
self._make_room()
|
|
265
|
+
except (OSError, ValueError):
|
|
266
|
+
# ValueError covers UnicodeEncodeError: a file whose *content* is
|
|
267
|
+
# a lone surrogate — 13 bytes of {"\ud800": 1} — escaped this and
|
|
268
|
+
# killed the whole run. surrogateescape handles a name that is not
|
|
269
|
+
# UTF-8; nothing handles a surrogate the JSON parser handed back
|
|
270
|
+
# as a genuine character. A cache that cannot write is still a
|
|
271
|
+
# cache.
|
|
272
|
+
pass
|
|
273
|
+
|
|
274
|
+
def _make_room(self) -> None:
|
|
275
|
+
"""Keep the cache under its ceiling, least recently *used* out first.
|
|
276
|
+
|
|
277
|
+
There was no ceiling and no eviction. The key includes the filename,
|
|
278
|
+
so thirty uploads of identical content under thirty names wrote
|
|
279
|
+
thirty entries, and on a host whose local disk is the small volume
|
|
280
|
+
that is an unauthenticated way to fill it.
|
|
281
|
+
|
|
282
|
+
Four things this has to get right, each of which it got wrong first:
|
|
283
|
+
|
|
284
|
+
Cost. Walking the directory on every write is O(entries) forever —
|
|
285
|
+
measured at 613 ms per summary at 60,000 entries, which the ceiling
|
|
286
|
+
alone guarantees the cache will reach. The walk now happens only
|
|
287
|
+
when the running estimate says it might be needed, so the ordinary
|
|
288
|
+
write costs nothing.
|
|
289
|
+
|
|
290
|
+
Size. st_size is the logical length; a 900-byte entry occupies a
|
|
291
|
+
4 KB block. Summed over 60,000 small files that is 94.7 MB counted
|
|
292
|
+
against 245.8 MB allocated — a "256 MB" cache was 660 MB of the
|
|
293
|
+
small volume. st_blocks is what the volume actually loses.
|
|
294
|
+
|
|
295
|
+
Concurrency. One `stat` of a file another process has just unlinked
|
|
296
|
+
raised FileNotFoundError out of the listing comprehension, and the
|
|
297
|
+
except-clause skipped the whole trim: a cache four times over its
|
|
298
|
+
ceiling stayed there. Each entry is now stat'ed on its own.
|
|
299
|
+
|
|
300
|
+
Use, not age. get() never touched the file, so eviction ran on write
|
|
301
|
+
time — an attacker's fresh uploads evicted the defender's most
|
|
302
|
+
reused entries first, which is the wrong end of the cache.
|
|
303
|
+
"""
|
|
304
|
+
entries = []
|
|
305
|
+
total = 0
|
|
306
|
+
for path in self._owned_paths():
|
|
307
|
+
try:
|
|
308
|
+
info = path.stat()
|
|
309
|
+
except OSError:
|
|
310
|
+
continue # vanished under us; not our business
|
|
311
|
+
size = _occupied(info)
|
|
312
|
+
entries.append((info.st_mtime, size, path))
|
|
313
|
+
total += size
|
|
314
|
+
self._estimate = total
|
|
315
|
+
if total <= MAX_CACHE_BYTES:
|
|
316
|
+
return
|
|
317
|
+
# Down to three quarters, not to exactly the ceiling: evicting one
|
|
318
|
+
# entry per write once full would make every subsequent write pay for
|
|
319
|
+
# a full directory scan and a delete.
|
|
320
|
+
goal = MAX_CACHE_BYTES * 3 // 4
|
|
321
|
+
for _, size, path in sorted(entries):
|
|
322
|
+
if total <= goal:
|
|
323
|
+
break
|
|
324
|
+
try:
|
|
325
|
+
path.unlink()
|
|
326
|
+
total -= size
|
|
327
|
+
self.evicted += 1
|
|
328
|
+
except OSError:
|
|
329
|
+
continue
|
|
330
|
+
self._estimate = total
|
|
331
|
+
|
|
332
|
+
def _owned_paths(self):
|
|
333
|
+
"""Yield only files sift can prove it created.
|
|
334
|
+
|
|
335
|
+
Deliberately one directory deep. ``SIFT_CACHE_DIR`` may name a project
|
|
336
|
+
tree, so a recursive walk would turn unrelated JSON into cache data
|
|
337
|
+
before its contents were even considered.
|
|
338
|
+
"""
|
|
339
|
+
if not self.dir.is_dir():
|
|
340
|
+
return
|
|
341
|
+
try:
|
|
342
|
+
buckets = list(self.dir.iterdir())
|
|
343
|
+
except OSError:
|
|
344
|
+
return
|
|
345
|
+
for bucket in buckets:
|
|
346
|
+
if (bucket.is_symlink() or not bucket.is_dir()
|
|
347
|
+
or len(bucket.name) != 2
|
|
348
|
+
or any(c not in "0123456789abcdef" for c in bucket.name)):
|
|
349
|
+
continue
|
|
350
|
+
try:
|
|
351
|
+
candidates = list(bucket.iterdir())
|
|
352
|
+
except OSError:
|
|
353
|
+
continue
|
|
354
|
+
for path in candidates:
|
|
355
|
+
key = path.name[:-5] if path.name.endswith(".json") else ""
|
|
356
|
+
if (path.is_symlink() or len(key) != 64
|
|
357
|
+
or any(c not in "0123456789abcdef" for c in key)
|
|
358
|
+
or bucket.name != key[:2]
|
|
359
|
+
or not self._owns_entry(path)):
|
|
360
|
+
continue
|
|
361
|
+
yield path
|
|
362
|
+
|
|
363
|
+
def clear(self) -> int:
|
|
364
|
+
"""Remove sift entries, never the directory named by the operator.
|
|
365
|
+
|
|
366
|
+
``SIFT_CACHE_DIR`` is an arbitrary path. Recursively deleting that
|
|
367
|
+
path made a typo such as a project directory destructive. An entry is
|
|
368
|
+
ours only when it sits at the exact two-level path produced by
|
|
369
|
+
:meth:`_path` and carries sift's cache envelope. Everything else is
|
|
370
|
+
preserved, including symlinks and nested directories.
|
|
371
|
+
"""
|
|
372
|
+
if not self.dir.is_dir():
|
|
373
|
+
return 0
|
|
374
|
+
count = 0
|
|
375
|
+
touched = set()
|
|
376
|
+
for path in list(self._owned_paths()):
|
|
377
|
+
try:
|
|
378
|
+
path.unlink()
|
|
379
|
+
except OSError:
|
|
380
|
+
continue
|
|
381
|
+
count += 1
|
|
382
|
+
touched.add(path.parent)
|
|
383
|
+
for bucket in touched:
|
|
384
|
+
try:
|
|
385
|
+
bucket.rmdir() # only when nothing else is there
|
|
386
|
+
except OSError:
|
|
387
|
+
pass
|
|
388
|
+
return count
|
|
389
|
+
|
|
390
|
+
@staticmethod
|
|
391
|
+
def _owns_entry(path: Path) -> bool:
|
|
392
|
+
"""Whether *path* has the bounded on-disk envelope written by put()."""
|
|
393
|
+
try:
|
|
394
|
+
if not path.is_file() or path.stat().st_size > _MAX_ENTRY_FILE:
|
|
395
|
+
return False
|
|
396
|
+
with path.open("r", encoding="utf-8",
|
|
397
|
+
errors="surrogateescape") as fh:
|
|
398
|
+
entry = json.load(fh)
|
|
399
|
+
except (OSError, ValueError):
|
|
400
|
+
return False
|
|
401
|
+
return (isinstance(entry, dict)
|
|
402
|
+
and entry.get("cache_owner") == CACHE_OWNER
|
|
403
|
+
and isinstance(entry.get("cache_version"), str)
|
|
404
|
+
and isinstance(entry.get("stored_at"), (int, float))
|
|
405
|
+
and not isinstance(entry.get("stored_at"), bool)
|
|
406
|
+
and isinstance(entry.get("summary"), dict))
|
|
407
|
+
|
|
408
|
+
def stats(self) -> str:
|
|
409
|
+
total = self.hits + self.misses
|
|
410
|
+
if not total:
|
|
411
|
+
return ""
|
|
412
|
+
return f"{self.hits}/{total} from cache"
|