cachekat 0.4.8__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.
- cachekat/__init__.py +3 -0
- cachekat/actions.py +127 -0
- cachekat/cli.py +89 -0
- cachekat/fsutil.py +25 -0
- cachekat/i18n.py +265 -0
- cachekat/keys.py +22 -0
- cachekat/models.py +39 -0
- cachekat/probes/__init__.py +5 -0
- cachekat/probes/docker_df.py +226 -0
- cachekat/probes/npm_cache.py +72 -0
- cachekat/probes/pip_cache.py +40 -0
- cachekat/probes/playwright_cache.py +104 -0
- cachekat/registry.py +71 -0
- cachekat/tui.py +322 -0
- cachekat-0.4.8.dist-info/METADATA +109 -0
- cachekat-0.4.8.dist-info/RECORD +20 -0
- cachekat-0.4.8.dist-info/WHEEL +5 -0
- cachekat-0.4.8.dist-info/entry_points.txt +2 -0
- cachekat-0.4.8.dist-info/licenses/LICENSE +21 -0
- cachekat-0.4.8.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Docker disk usage probe — `docker system df --format json`, risk-graded.
|
|
2
|
+
|
|
3
|
+
Red line: Local Volumes are data, REPORT_ONLY forever. Sizes come from
|
|
4
|
+
docker's own numbers (reclaimable for cleanable rows).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from cachekat import keys
|
|
15
|
+
from cachekat.i18n import t
|
|
16
|
+
from cachekat.models import Finding, Risk
|
|
17
|
+
from cachekat.registry import register
|
|
18
|
+
|
|
19
|
+
RunFn = Callable[[list[str]], subprocess.CompletedProcess[str]]
|
|
20
|
+
|
|
21
|
+
_DF_TIMEOUT = 30 # seconds; system df on a big machine can take a while
|
|
22
|
+
_DF_CMD = ["docker", "system", "df", "--format", "{{json .}}"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class _Row:
|
|
27
|
+
type: str
|
|
28
|
+
size_bytes: int
|
|
29
|
+
reclaimable_bytes: int
|
|
30
|
+
count: int
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
|
|
34
|
+
# encoding is EXPLICIT: docker speaks UTF-8 on every platform. With the
|
|
35
|
+
# default locale codec (cp936 on zh-Windows) a non-ASCII byte sequence
|
|
36
|
+
# crashes the hidden Windows reader thread inside communicate() — the
|
|
37
|
+
# exception is swallowed by the thread and we get stdout=None, rc=0
|
|
38
|
+
# (2026-10-09 real-machine incident, see tests/test_docker_probe.py).
|
|
39
|
+
return subprocess.run(
|
|
40
|
+
cmd, capture_output=True, text=True,
|
|
41
|
+
encoding="utf-8", errors="replace", timeout=_DF_TIMEOUT,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parse_size(text: str) -> int:
|
|
46
|
+
"""'516.2MB (55%)' -> 516_200_000 (docker uses decimal SI units)."""
|
|
47
|
+
s = text.split("(")[0].strip().replace(" ", "")
|
|
48
|
+
if not s or s == "0B":
|
|
49
|
+
return 0
|
|
50
|
+
# longest-first is load-bearing: "516.2MB" also ends with "B"
|
|
51
|
+
units = {"TB": 10**12, "GB": 10**9, "MB": 10**6, "KB": 10**3, "B": 1}
|
|
52
|
+
for suffix, mult in units.items():
|
|
53
|
+
if s.endswith(suffix):
|
|
54
|
+
num = s[: -len(suffix)]
|
|
55
|
+
try:
|
|
56
|
+
value = float(num) if "." in num else int(num)
|
|
57
|
+
except ValueError:
|
|
58
|
+
return 0
|
|
59
|
+
return int(value * mult)
|
|
60
|
+
return 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_rows(stdout: str) -> list[_Row]:
|
|
64
|
+
rows: list[_Row] = []
|
|
65
|
+
for line in stdout.splitlines():
|
|
66
|
+
if not line.strip():
|
|
67
|
+
continue
|
|
68
|
+
d = json.loads(line) # malformed line = probe error, surfaces upstream
|
|
69
|
+
rows.append(
|
|
70
|
+
_Row(
|
|
71
|
+
type=str(d.get("Type", "")),
|
|
72
|
+
size_bytes=parse_size(str(d.get("Size", ""))),
|
|
73
|
+
reclaimable_bytes=parse_size(str(d.get("Reclaimable", ""))),
|
|
74
|
+
count=int(d.get("TotalCount", 0) or 0),
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
return rows
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _findings_from_rows(rows: list[_Row]) -> list[Finding]:
|
|
81
|
+
"""Aggregate rows: volumes/images/build-cache. Containers are NOT here —
|
|
82
|
+
they are per-container findings from `docker ps` (user selects each one;
|
|
83
|
+
deleting a container is removing the thing itself, not a cache)."""
|
|
84
|
+
findings: list[Finding] = []
|
|
85
|
+
for r in rows:
|
|
86
|
+
if r.type == "Local Volumes":
|
|
87
|
+
findings.append(
|
|
88
|
+
Finding(
|
|
89
|
+
key=keys.DOCKER_VOLUMES,
|
|
90
|
+
label=t("dock_volumes_label"),
|
|
91
|
+
size_bytes=r.size_bytes,
|
|
92
|
+
detail=t("dock_volumes_detail", n=r.count),
|
|
93
|
+
risk=Risk.REPORT_ONLY,
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
elif r.type == "Images":
|
|
97
|
+
findings.append(
|
|
98
|
+
Finding(
|
|
99
|
+
key=keys.DOCKER_IMAGES,
|
|
100
|
+
label=t("dock_images_label"),
|
|
101
|
+
size_bytes=r.reclaimable_bytes,
|
|
102
|
+
detail=t("dock_images_detail"),
|
|
103
|
+
risk=Risk.REPORT_ONLY,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
elif r.type == "Build Cache":
|
|
107
|
+
findings.append(
|
|
108
|
+
Finding(
|
|
109
|
+
key=keys.DOCKER_BUILD_CACHE,
|
|
110
|
+
label=t("dock_build_label"),
|
|
111
|
+
size_bytes=r.reclaimable_bytes,
|
|
112
|
+
detail=t("dock_build_detail"),
|
|
113
|
+
risk=Risk.CLEANABLE,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
return findings
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# per-container: stopped states only — running/paused/restarting never listed
|
|
120
|
+
_STOPPED_STATES = {"exited", "created", "dead"}
|
|
121
|
+
_PS_CMD = ["docker", "ps", "-a", "-s", "--format", "{{json .}}"]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _short_image(image: str) -> str:
|
|
125
|
+
"""'docker.n8n.io/n8nio/n8n:latest' -> 'n8n:latest' — registry hosts
|
|
126
|
+
are noise in a list row (2026-10-09 user report: rows overran the
|
|
127
|
+
panel). The TUI ellipsizes too, but labels shouldn't START at hopeless."""
|
|
128
|
+
return image.rsplit("/", 1)[-1]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _container_findings(run: RunFn) -> list[Finding]:
|
|
132
|
+
"""One finding per stopped container, each CLEANABLE but individually
|
|
133
|
+
chosen. The warning is load-bearing: docker rm removes the CONTAINER
|
|
134
|
+
ITSELF, not a cache (2026-10-08 incident, explicit design decision)."""
|
|
135
|
+
try:
|
|
136
|
+
cp = run(_PS_CMD)
|
|
137
|
+
except subprocess.TimeoutExpired:
|
|
138
|
+
return [
|
|
139
|
+
Finding(
|
|
140
|
+
key=keys.DOCKER_PS_ERROR,
|
|
141
|
+
label=t("dock_ps_timeout_label"),
|
|
142
|
+
size_bytes=0,
|
|
143
|
+
detail=t("dock_ps_timeout"),
|
|
144
|
+
risk=Risk.REPORT_ONLY,
|
|
145
|
+
)
|
|
146
|
+
]
|
|
147
|
+
if cp.returncode != 0:
|
|
148
|
+
return [
|
|
149
|
+
Finding(
|
|
150
|
+
key=keys.DOCKER_PS_ERROR,
|
|
151
|
+
label=t("dock_ps_failed_label"),
|
|
152
|
+
size_bytes=0,
|
|
153
|
+
detail=t("dock_ps_failed", rc=cp.returncode),
|
|
154
|
+
risk=Risk.REPORT_ONLY,
|
|
155
|
+
)
|
|
156
|
+
]
|
|
157
|
+
findings: list[Finding] = []
|
|
158
|
+
for line in _stdout_or_raise(cp, "docker ps").splitlines():
|
|
159
|
+
if not line.strip():
|
|
160
|
+
continue
|
|
161
|
+
d = json.loads(line)
|
|
162
|
+
if str(d.get("State", "")) not in _STOPPED_STATES:
|
|
163
|
+
continue # running/paused containers are never offered
|
|
164
|
+
names = str(d.get("Names", "")).split()
|
|
165
|
+
if not names:
|
|
166
|
+
continue
|
|
167
|
+
name = names[0]
|
|
168
|
+
findings.append(
|
|
169
|
+
Finding(
|
|
170
|
+
key=f"{keys.DOCKER_CONTAINER_PREFIX}{name}",
|
|
171
|
+
label=t(
|
|
172
|
+
"dock_container_label",
|
|
173
|
+
name=name,
|
|
174
|
+
image=_short_image(str(d.get("Image", "?"))),
|
|
175
|
+
),
|
|
176
|
+
size_bytes=parse_size(str(d.get("Size", ""))),
|
|
177
|
+
detail=t("dock_container_detail", status=d.get("Status", "?")),
|
|
178
|
+
risk=Risk.CLEANABLE,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
return findings
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _stdout_or_raise(cp: subprocess.CompletedProcess[str], cmd_name: str) -> str:
|
|
185
|
+
"""rc=0 with stdout=None is a LIE: communicate() swallows reader-thread
|
|
186
|
+
crashes (the 2026-10-09 zh-Windows GBK decode incident). Refuse to guess."""
|
|
187
|
+
if cp.stdout is None:
|
|
188
|
+
msg = (
|
|
189
|
+
f"{cmd_name} exited rc={cp.returncode!r} but stdout is None — "
|
|
190
|
+
"a swallowed pipe-reader crash; see _run's encoding note"
|
|
191
|
+
)
|
|
192
|
+
raise RuntimeError(msg)
|
|
193
|
+
return cp.stdout
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@register("docker")
|
|
197
|
+
def probe(runner: RunFn | None = None) -> list[Finding]:
|
|
198
|
+
"""runner param exists for tests (canned CompletedProcess injection)."""
|
|
199
|
+
run = runner or _run
|
|
200
|
+
try:
|
|
201
|
+
cp = run(_DF_CMD)
|
|
202
|
+
except FileNotFoundError:
|
|
203
|
+
return [] # docker not installed on this machine — nothing to report
|
|
204
|
+
except subprocess.TimeoutExpired:
|
|
205
|
+
return [
|
|
206
|
+
Finding(
|
|
207
|
+
key=keys.DOCKER_DAEMON,
|
|
208
|
+
label=t("dock_daemon_timeout_label"),
|
|
209
|
+
size_bytes=0,
|
|
210
|
+
detail=t("dock_daemon_timeout"),
|
|
211
|
+
risk=Risk.REPORT_ONLY,
|
|
212
|
+
)
|
|
213
|
+
]
|
|
214
|
+
if cp.returncode != 0:
|
|
215
|
+
return [
|
|
216
|
+
Finding(
|
|
217
|
+
key=keys.DOCKER_DAEMON,
|
|
218
|
+
label=t("dock_daemon_label"),
|
|
219
|
+
size_bytes=0,
|
|
220
|
+
detail=t("dock_daemon_unreachable", rc=cp.returncode),
|
|
221
|
+
risk=Risk.REPORT_ONLY,
|
|
222
|
+
)
|
|
223
|
+
]
|
|
224
|
+
findings = _findings_from_rows(_parse_rows(_stdout_or_raise(cp, "docker system df")))
|
|
225
|
+
findings.extend(_container_findings(run))
|
|
226
|
+
return findings
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""npm cache probe. Prefers asking npm itself; falls back to default paths."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from cachekat import keys
|
|
13
|
+
from cachekat.fsutil import dir_size_bytes
|
|
14
|
+
from cachekat.models import Finding, Risk
|
|
15
|
+
from cachekat.registry import register
|
|
16
|
+
|
|
17
|
+
RunFn = Callable[[str], str] # cmd -> stdout (raises OSError when missing)
|
|
18
|
+
|
|
19
|
+
_NPM_TIMEOUT = 10 # seconds; `npm config get cache` should be near-instant
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def npm_default_cache_dir() -> Path:
|
|
23
|
+
"""npm's documented defaults when we cannot ask npm itself."""
|
|
24
|
+
if sys.platform == "win32":
|
|
25
|
+
local = os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local"
|
|
26
|
+
return Path(local) / "npm-cache"
|
|
27
|
+
return Path.home() / ".npm"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _subprocess_stdout(cmd: str) -> str:
|
|
31
|
+
exe = shutil.which(cmd) # resolves npm.cmd on Windows
|
|
32
|
+
if exe is None:
|
|
33
|
+
msg = f"{cmd} not on PATH"
|
|
34
|
+
raise FileNotFoundError(msg)
|
|
35
|
+
cp = subprocess.run(
|
|
36
|
+
[exe, "config", "get", "cache"],
|
|
37
|
+
capture_output=True, text=True,
|
|
38
|
+
encoding="utf-8", errors="replace", # npm paths may carry non-ASCII
|
|
39
|
+
timeout=_NPM_TIMEOUT,
|
|
40
|
+
)
|
|
41
|
+
if cp.returncode != 0:
|
|
42
|
+
msg = f"{cmd} exited {cp.returncode}"
|
|
43
|
+
raise RuntimeError(msg)
|
|
44
|
+
return cp.stdout
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_cache_dir(runner: RunFn | None = None) -> Path:
|
|
48
|
+
"""npm config get cache (authoritative) -> default paths (fallback)."""
|
|
49
|
+
run = runner or _subprocess_stdout
|
|
50
|
+
try:
|
|
51
|
+
out = run("npm").strip()
|
|
52
|
+
except (OSError, RuntimeError, subprocess.SubprocessError):
|
|
53
|
+
return npm_default_cache_dir()
|
|
54
|
+
if out:
|
|
55
|
+
return Path(out)
|
|
56
|
+
return npm_default_cache_dir()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@register("npm")
|
|
60
|
+
def probe(cache_dir: Path | None = None) -> list[Finding]:
|
|
61
|
+
"""cache_dir param exists for tests (fake tree injection)."""
|
|
62
|
+
d = cache_dir if cache_dir is not None else resolve_cache_dir()
|
|
63
|
+
return [
|
|
64
|
+
Finding(
|
|
65
|
+
key=keys.NPM_CACHE,
|
|
66
|
+
label="npm cache (_cacache/_npx/_logs)",
|
|
67
|
+
size_bytes=dir_size_bytes(d),
|
|
68
|
+
detail=str(d),
|
|
69
|
+
risk=Risk.CLEANABLE,
|
|
70
|
+
path=d,
|
|
71
|
+
)
|
|
72
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""pip download cache probe (M0 reference probe)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from cachekat import keys
|
|
10
|
+
from cachekat.fsutil import dir_size_bytes
|
|
11
|
+
from cachekat.models import Finding, Risk
|
|
12
|
+
from cachekat.registry import register
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def pip_cache_dir() -> Path:
|
|
16
|
+
"""Platform cache dir per pip docs (https://pip.pypa.io/en/stable/topics/caching/)."""
|
|
17
|
+
home = Path.home()
|
|
18
|
+
if sys.platform == "win32":
|
|
19
|
+
local = os.environ.get("LOCALAPPDATA") or home / "AppData" / "Local"
|
|
20
|
+
return Path(local) / "pip" / "cache"
|
|
21
|
+
if sys.platform == "darwin":
|
|
22
|
+
return home / "Library" / "Caches" / "pip"
|
|
23
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
24
|
+
return Path(xdg) / "pip" if xdg else home / ".cache" / "pip"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@register("pip")
|
|
28
|
+
def probe(cache_dir: Path | None = None) -> list[Finding]:
|
|
29
|
+
"""cache_dir param exists for tests (fake tree injection)."""
|
|
30
|
+
d = cache_dir if cache_dir is not None else pip_cache_dir()
|
|
31
|
+
return [
|
|
32
|
+
Finding(
|
|
33
|
+
key=keys.PIP_CACHE,
|
|
34
|
+
label="pip download cache",
|
|
35
|
+
size_bytes=dir_size_bytes(d),
|
|
36
|
+
detail=str(d),
|
|
37
|
+
risk=Risk.CLEANABLE,
|
|
38
|
+
path=d,
|
|
39
|
+
)
|
|
40
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Playwright browser cache probe — orphan-version aware.
|
|
2
|
+
|
|
3
|
+
Liveness comes from the installed playwright package's browsers.json
|
|
4
|
+
(driver/package/browsers.json). When playwright is not importable in this
|
|
5
|
+
interpreter we CANNOT tell orphans from in-use browsers — the finding is
|
|
6
|
+
REPORT_ONLY with that stated, instead of a guess.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib.util
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from cachekat import keys
|
|
18
|
+
from cachekat.fsutil import dir_size_bytes
|
|
19
|
+
from cachekat.i18n import t
|
|
20
|
+
from cachekat.models import Finding, Risk
|
|
21
|
+
from cachekat.registry import register
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def browsers_dir() -> Path:
|
|
25
|
+
"""Playwright's download roots per platform (PLAYWRIGHT_BROWSERS_PATH wins)."""
|
|
26
|
+
env = os.environ.get("PLAYWRIGHT_BROWSERS_PATH")
|
|
27
|
+
if env:
|
|
28
|
+
return Path(env)
|
|
29
|
+
home = Path.home()
|
|
30
|
+
if sys.platform == "win32":
|
|
31
|
+
return home / "AppData" / "Local" / "ms-playwright"
|
|
32
|
+
if sys.platform == "darwin":
|
|
33
|
+
return home / "Library" / "Caches" / "ms-playwright"
|
|
34
|
+
xdg = os.environ.get("XDG_CACHE_HOME")
|
|
35
|
+
return Path(xdg) / "ms-playwright" if xdg else home / ".cache" / "ms-playwright"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def needed_revisions(browsers_json: Path | None = None) -> set[str] | None:
|
|
39
|
+
"""Revisions the installed playwright wants, e.g. {"chromium-1148"}.
|
|
40
|
+
|
|
41
|
+
None = playwright not importable (or browsers.json unreadable) — caller
|
|
42
|
+
must treat liveness as unknown.
|
|
43
|
+
"""
|
|
44
|
+
bj = browsers_json
|
|
45
|
+
if bj is None:
|
|
46
|
+
spec = importlib.util.find_spec("playwright")
|
|
47
|
+
if spec is None or not spec.submodule_search_locations:
|
|
48
|
+
return None
|
|
49
|
+
pkg = Path(next(iter(spec.submodule_search_locations)))
|
|
50
|
+
bj = pkg / "driver" / "package" / "browsers.json"
|
|
51
|
+
if not bj.exists():
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(bj.read_text(encoding="utf-8"))
|
|
55
|
+
except (OSError, json.JSONDecodeError):
|
|
56
|
+
return None
|
|
57
|
+
revs = {
|
|
58
|
+
f"{b.get('name')}-{b.get('revision')}"
|
|
59
|
+
for b in data.get("browsers", [])
|
|
60
|
+
if b.get("name") and b.get("revision")
|
|
61
|
+
}
|
|
62
|
+
return revs or None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@register("playwright")
|
|
66
|
+
def probe(browsers_root: Path | None = None, needed: set[str] | None = ...) -> list[Finding]:
|
|
67
|
+
"""Both params exist for tests (fake tree + fake revision set).
|
|
68
|
+
|
|
69
|
+
`needed=...` (default) means: derive from the installed playwright.
|
|
70
|
+
Pass an explicit set (possibly None) to pin behaviour in tests.
|
|
71
|
+
"""
|
|
72
|
+
d = browsers_root if browsers_root is not None else browsers_dir()
|
|
73
|
+
if not d.exists():
|
|
74
|
+
return []
|
|
75
|
+
revs = needed_revisions() if needed is ... else needed
|
|
76
|
+
# hidden entries (".links" = playwright's driver registry) are infra, not
|
|
77
|
+
# browsers — never counted, never cleaned
|
|
78
|
+
entries = sorted(p for p in d.iterdir() if p.is_dir() and not p.name.startswith("."))
|
|
79
|
+
if revs is None:
|
|
80
|
+
total = sum(dir_size_bytes(e) for e in entries)
|
|
81
|
+
return [
|
|
82
|
+
Finding(
|
|
83
|
+
key=keys.PLAYWRIGHT_BROWSERS,
|
|
84
|
+
label=t("pw_unknown_label"),
|
|
85
|
+
size_bytes=total,
|
|
86
|
+
detail=t("pw_unknown_detail", path=d),
|
|
87
|
+
risk=Risk.REPORT_ONLY,
|
|
88
|
+
path=d,
|
|
89
|
+
)
|
|
90
|
+
]
|
|
91
|
+
findings: list[Finding] = []
|
|
92
|
+
for e in entries:
|
|
93
|
+
in_use = e.name in revs
|
|
94
|
+
findings.append(
|
|
95
|
+
Finding(
|
|
96
|
+
key=f"{keys.PLAYWRIGHT_PREFIX}{e.name}",
|
|
97
|
+
label=t("pw_orphan_label" if not in_use else "pw_inuse_label", name=e.name),
|
|
98
|
+
size_bytes=dir_size_bytes(e),
|
|
99
|
+
detail=str(e) + ("" if in_use else f" — {t('pw_orphan_detail')}"),
|
|
100
|
+
risk=Risk.REPORT_ONLY if in_use else Risk.CLEANABLE,
|
|
101
|
+
path=e,
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
return findings
|
cachekat/registry.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Probe registry — the ONE place probes register. Interface convergence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import traceback
|
|
7
|
+
from collections.abc import Callable, Iterable
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
from cachekat.i18n import t
|
|
11
|
+
from cachekat.models import Finding, Risk
|
|
12
|
+
|
|
13
|
+
ProbeFn = Callable[..., list[Finding]]
|
|
14
|
+
T = TypeVar("T", bound=ProbeFn)
|
|
15
|
+
|
|
16
|
+
_REGISTRY: dict[str, ProbeFn] = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(probe_id: str) -> Callable[[T], T]:
|
|
20
|
+
"""Decorator: register a probe under a stable id. Duplicate ids are a bug."""
|
|
21
|
+
|
|
22
|
+
def deco(fn: T) -> T:
|
|
23
|
+
if probe_id in _REGISTRY:
|
|
24
|
+
msg = f"probe id {probe_id!r} already registered"
|
|
25
|
+
raise ValueError(msg)
|
|
26
|
+
_REGISTRY[probe_id] = fn
|
|
27
|
+
return fn
|
|
28
|
+
|
|
29
|
+
return deco
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def all_probes() -> Iterable[tuple[str, ProbeFn]]:
|
|
33
|
+
return sorted(_REGISTRY.items())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _crash_detail(exc: Exception) -> str:
|
|
37
|
+
"""One-line crash report: exception type, raise site, message.
|
|
38
|
+
|
|
39
|
+
2026-10-09 real-world case: a probe crashed on a Windows machine and the
|
|
40
|
+
TUI's narrow table column truncated the detail to "AttributeErro" —
|
|
41
|
+
invisible WHERE. The raise site (file:line in func) makes the next
|
|
42
|
+
screenshot self-sufficient."""
|
|
43
|
+
tb = exc.__traceback__
|
|
44
|
+
last = traceback.extract_tb(tb)[-1] if tb else None
|
|
45
|
+
where = (
|
|
46
|
+
f" @{os.path.basename(last.filename)}:{last.lineno} in {last.name}"
|
|
47
|
+
if last
|
|
48
|
+
else ""
|
|
49
|
+
)
|
|
50
|
+
return f"{type(exc).__name__}{where}: {exc}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_scan() -> list[Finding]:
|
|
54
|
+
"""Run every probe. A crashing probe becomes a visible error finding —
|
|
55
|
+
failures are surfaced, never swallowed (anti-fake-work clause)."""
|
|
56
|
+
findings: list[Finding] = []
|
|
57
|
+
for probe_id, fn in all_probes():
|
|
58
|
+
try:
|
|
59
|
+
findings.extend(fn())
|
|
60
|
+
except Exception as exc: # noqa: BLE001 — deliberate: report, don't die
|
|
61
|
+
findings.append(
|
|
62
|
+
Finding(
|
|
63
|
+
key=f"{probe_id}/error",
|
|
64
|
+
label=t("probe_failed_label", probe=probe_id),
|
|
65
|
+
size_bytes=0,
|
|
66
|
+
detail=_crash_detail(exc),
|
|
67
|
+
risk=Risk.REPORT_ONLY,
|
|
68
|
+
is_error=True,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return findings
|