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.
@@ -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