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 ADDED
@@ -0,0 +1,3 @@
1
+ """CacheKat — see which dev caches eat your disk, reclaim space safely."""
2
+
3
+ __version__ = "0.4.8"
cachekat/actions.py ADDED
@@ -0,0 +1,127 @@
1
+ """Clean actions, one per finding key. The refusal path is the feature.
2
+
3
+ Rules:
4
+ - REPORT_ONLY findings are refused, always — even in dry-run, even by
5
+ internal callers. There is no flag that unlocks this.
6
+ - dry_run=True must not touch disk or run any CLI, period.
7
+ - Unknown cleanable keys fail loudly ("no clean action implemented") —
8
+ pretending to clean is worse than admitting we can't.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from typing import Protocol
18
+
19
+ from cachekat import keys
20
+ from cachekat.i18n import t
21
+ from cachekat.models import Finding, Risk
22
+
23
+ _TIMEOUT = 600 # docker prunes can be slow on big machines
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ActionResult:
28
+ key: str
29
+ ok: bool
30
+ detail: str
31
+
32
+
33
+ class _Runner(Protocol):
34
+ def __call__(self, cmd: list[str]) -> subprocess.CompletedProcess[str]: ...
35
+
36
+
37
+ def _run(cmd: list[str]) -> subprocess.CompletedProcess[str]:
38
+ # explicit UTF-8: same zh-Windows reader-thread hazard as probes (a
39
+ # swallowed decode crash surfaces as stdout=None, see docker_df._run)
40
+ return subprocess.run(
41
+ cmd, capture_output=True, text=True,
42
+ encoding="utf-8", errors="replace", timeout=_TIMEOUT,
43
+ )
44
+
45
+
46
+ def _cmd_action(
47
+ finding: Finding, dry_run: bool, run: _Runner, cmd: list[str], desc: str
48
+ ) -> ActionResult:
49
+ if dry_run:
50
+ return ActionResult(finding.key, True, f"[dry-run] would {desc}")
51
+ cp = run(cmd)
52
+ if cp.returncode == 0:
53
+ tail = (cp.stdout or "").strip().splitlines()
54
+ return ActionResult(finding.key, True, f"{desc}: {tail[-1]}" if tail else desc)
55
+ err = (cp.stderr or cp.stdout or "").strip().splitlines()
56
+ return ActionResult(
57
+ finding.key, False, f"{desc} failed (exit {cp.returncode}): {err[-1] if err else ''}"
58
+ )
59
+
60
+
61
+ def _rmtree_action(finding: Finding, dry_run: bool) -> ActionResult:
62
+ if finding.path is None:
63
+ return ActionResult(finding.key, False, "no path on finding — cannot remove")
64
+ desc = f"delete {finding.path}"
65
+ if dry_run:
66
+ return ActionResult(finding.key, True, f"[dry-run] would {desc}")
67
+ shutil.rmtree(finding.path)
68
+ return ActionResult(finding.key, True, desc)
69
+
70
+
71
+ # docker prunes: -f skips their interactive prompt; our TUI is the prompt.
72
+ # 2026-10-08 incident: image prune -a / container prune are indiscriminate
73
+ # ("unused"/"stopped" != unwanted — a user's persistent test bench was
74
+ # pruned). Both docker rows are REPORT_ONLY now; only build cache (true
75
+ # cache semantics) remains cleanable here.
76
+ _DOCKER_ACTIONS = {
77
+ keys.DOCKER_BUILD_CACHE: (
78
+ ["docker", "builder", "prune", "-f"],
79
+ "docker builder prune -f",
80
+ ),
81
+ }
82
+
83
+ # what each clean actually does, in plain words — the confirm modal shows
84
+ # these BEFORE anything runs (2026-10-08 lesson: item names alone don't warn).
85
+ # Wording lives in cachekat.i18n (en source of truth, zh table).
86
+ _CONSEQUENCE_KEYS = {
87
+ keys.PIP_CACHE: "cons_pip",
88
+ keys.NPM_CACHE: "cons_npm",
89
+ keys.DOCKER_BUILD_CACHE: "cons_build_cache",
90
+ }
91
+
92
+
93
+ def consequence(finding: Finding) -> str:
94
+ """One plain sentence about what cleaning this finding does."""
95
+ if finding.key.startswith(keys.DOCKER_CONTAINER_PREFIX):
96
+ return t("cons_container")
97
+ if finding.key in _CONSEQUENCE_KEYS:
98
+ return t(_CONSEQUENCE_KEYS[finding.key])
99
+ if finding.key.startswith(keys.PLAYWRIGHT_PREFIX):
100
+ return t("cons_playwright")
101
+ return t("cons_none")
102
+
103
+
104
+ def clean(
105
+ finding: Finding, *, dry_run: bool = False, runner: _Runner | None = None
106
+ ) -> ActionResult:
107
+ run = runner if runner is not None else _run
108
+ if finding.risk is not Risk.CLEANABLE:
109
+ return ActionResult(finding.key, False, "refused: report-only is never cleaned")
110
+ if finding.key == keys.PIP_CACHE:
111
+ cmd = [sys.executable, "-m", "pip", "cache", "purge"]
112
+ return _cmd_action(finding, dry_run, run, cmd, "pip cache purge")
113
+ if finding.key == keys.NPM_CACHE:
114
+ return _rmtree_action(finding, dry_run)
115
+ if finding.key.startswith(keys.PLAYWRIGHT_PREFIX):
116
+ return _rmtree_action(finding, dry_run)
117
+ if finding.key.startswith(keys.DOCKER_CONTAINER_PREFIX):
118
+ # name derivation by documented prefix-strip (see cachekat/keys.py):
119
+ # docker names never contain "/", display text is never parsed
120
+ name = finding.key.removeprefix(keys.DOCKER_CONTAINER_PREFIX)
121
+ # no -f: a container that started since the scan refuses naturally,
122
+ # and docker rm never touches volumes
123
+ return _cmd_action(finding, dry_run, run, ["docker", "rm", name], f"docker rm {name}")
124
+ if finding.key in _DOCKER_ACTIONS:
125
+ cmd, desc = _DOCKER_ACTIONS[finding.key]
126
+ return _cmd_action(finding, dry_run, run, cmd, desc)
127
+ return ActionResult(finding.key, False, "no clean action implemented for this finding")
cachekat/cli.py ADDED
@@ -0,0 +1,89 @@
1
+ """CLI entry point: `cachekat scan` (read-only report) and `cachekat tui`.
2
+
3
+ Language: every invocation resolves --lang (default auto: system locale,
4
+ zh* -> zh, else en) and resets the i18n state — no cross-call leakage.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from dataclasses import asdict
13
+
14
+ from cachekat import probes # noqa: F401 (probe registration by import)
15
+ from cachekat.i18n import resolve_lang, set_lang, t
16
+ from cachekat.models import Finding, Risk
17
+ from cachekat.registry import run_scan
18
+
19
+
20
+ def human_size(n: int) -> str:
21
+ steps = ("B", "KiB", "MiB", "GiB", "TiB")
22
+ size = float(n)
23
+ for unit in steps:
24
+ if size < 1024 or unit == steps[-1]:
25
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
26
+ size /= 1024
27
+ return f"{size:.1f} TiB" # pragma: no cover (loop always returns above)
28
+
29
+
30
+ def format_table(findings: list[Finding]) -> str:
31
+ lines = [
32
+ t("scan_title"),
33
+ "=" * 64,
34
+ ]
35
+ for f in findings:
36
+ marker = "~" if f.risk is Risk.CLEANABLE else "!"
37
+ lines.append(
38
+ f"[{marker}] {f.label:<28} {human_size(f.size_bytes):>10}"
39
+ f" {f.detail}"
40
+ )
41
+ lines.append("=" * 64)
42
+ reclaimable = sum(f.size_bytes for f in findings if f.risk is Risk.CLEANABLE)
43
+ lines.append(t("scan_summary", size=human_size(reclaimable)))
44
+ if any(f.risk is Risk.REPORT_ONLY for f in findings):
45
+ lines.append(t("scan_report_note"))
46
+ return "\n".join(lines)
47
+
48
+
49
+ def to_json(findings: list[Finding]) -> str:
50
+ rows = [
51
+ asdict(f)
52
+ | {
53
+ "risk": f.risk.value,
54
+ "path": str(f.path) if f.path is not None else None,
55
+ }
56
+ for f in findings
57
+ ]
58
+ return json.dumps(rows, ensure_ascii=False, indent=2)
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ parser = argparse.ArgumentParser(
63
+ prog="cachekat",
64
+ description="See which dev caches eat your disk. Scans are read-only; "
65
+ "cleaning is always an explicit, confirmed choice.",
66
+ )
67
+ sub = parser.add_subparsers(dest="command", required=True)
68
+ p_scan = sub.add_parser("scan", help="read-only scan and report")
69
+ p_scan.add_argument("--json", action="store_true", help="machine-readable output")
70
+ p_scan.add_argument("--lang", default="auto", choices=["auto", "en", "zh"])
71
+ p_tui = sub.add_parser("tui", help="interactive TUI (select, confirm, clean)")
72
+ p_tui.add_argument("--lang", default="auto", choices=["auto", "en", "zh"])
73
+ args = parser.parse_args(argv)
74
+
75
+ set_lang(resolve_lang(args.lang))
76
+
77
+ if args.command == "tui":
78
+ from cachekat.tui import run_app # lazy: keeps scan light without textual
79
+
80
+ return run_app(lang=args.lang)
81
+
82
+ findings = run_scan()
83
+ if args.command == "scan":
84
+ print(to_json(findings) if args.json else format_table(findings))
85
+ return 0
86
+
87
+
88
+ if __name__ == "__main__":
89
+ sys.exit(main())
cachekat/fsutil.py ADDED
@@ -0,0 +1,25 @@
1
+ """Filesystem helpers shared by probes (single implementation, no copies)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def dir_size_bytes(path: Path) -> int:
10
+ """Recursive on-disk size. Missing path -> 0.
11
+
12
+ Files that vanish or are locked mid-walk are skipped, so the result is a
13
+ LOWER BOUND. Known limitation, not hidden: for cache directories this
14
+ undershoot is tiny and harmless; revisit if a probe ever needs exactness.
15
+ """
16
+ if not path.exists():
17
+ return 0
18
+ total = 0
19
+ for root, _dirs, files in os.walk(path):
20
+ for name in files:
21
+ try:
22
+ total += (Path(root) / name).stat().st_size
23
+ except OSError:
24
+ continue # raced-away or locked file (see docstring)
25
+ return total
cachekat/i18n.py ADDED
@@ -0,0 +1,265 @@
1
+ """Single home for every user-facing string (2026-10-08 design decision).
2
+
3
+ Rules:
4
+ - ALL UI copy goes through `t()` — hardcoded user-facing text is a review
5
+ blocker (see AGENTS.md conventions).
6
+ - en is the source of truth; zh is a full table. A missing zh entry falls
7
+ back to en (never crashes the TUI); a missing KEY fails loudly.
8
+ - Language is a process-wide choice set once at entry (CLI flag -> set_lang);
9
+ runtime hot-switching is deliberately out of scope until real users ask.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import locale
15
+ import os
16
+ import sys
17
+
18
+ Lang = str # "en" | "zh"
19
+
20
+ _LANG: Lang = "en" # safe default until an entry point resolves it
21
+
22
+ _STRINGS: dict[str, dict[str, str]] = {
23
+ # --- TUI chrome -------------------------------------------------------
24
+ "banner_title": {
25
+ "en": "🐈 CacheKat — dev cache checkup",
26
+ "zh": "🐈 CacheKat — 开发缓存体检",
27
+ },
28
+ "col_item": {"en": "item", "zh": "项"},
29
+ "col_size": {"en": "size", "zh": "大小"},
30
+ "col_note": {"en": "note", "zh": "说明"},
31
+ "key_all": {"en": "select all", "zh": "全选"},
32
+ "key_none": {"en": "clear selection", "zh": "清空选择"},
33
+ "key_dry": {"en": "dry-run on/off", "zh": "干跑 开/关"},
34
+ "key_clean": {"en": "clean selected", "zh": "清理选中"},
35
+ "key_rescan": {"en": "rescan", "zh": "重新扫描"},
36
+ "key_quit": {"en": "quit", "zh": "退出"},
37
+ "left_label": {
38
+ "en": "cleanable (select with space, run with c)",
39
+ "zh": "可清理(勾选后 c 执行)",
40
+ },
41
+ "right_label": {
42
+ "en": "report-only (data / uncertain)",
43
+ "zh": "只报不动(数据/判不准)",
44
+ },
45
+ "log_label": {"en": "action log", "zh": "执行日志"},
46
+ "scanning": {"en": "scanning…", "zh": "扫描中…"},
47
+ "summary_line": {
48
+ "en": "reclaimable total {size} · selected {n} items ({sel}) · {dry}",
49
+ "zh": "可回收合计 {size} · 已选 {n} 项({sel})· {dry}",
50
+ },
51
+ "dry_on": {
52
+ "en": "dry-run ON (rehearse — nothing is deleted)",
53
+ "zh": "干跑 开(只演示,不删任何东西)",
54
+ },
55
+ "dry_off": {
56
+ "en": "dry-run OFF (clean executes for real)",
57
+ "zh": "干跑 关(c 执行即真删)",
58
+ },
59
+ "notify_busy": {"en": "scanning, wait a beat", "zh": "扫描中,稍等"},
60
+ "notify_none_selected": {
61
+ "en": "nothing selected — press space on a row",
62
+ "zh": "没勾任何可清项——space 勾选",
63
+ },
64
+ "confirm_dry": {"en": "dry-run", "zh": "干跑演示"},
65
+ "confirm_real": {"en": "DELETE", "zh": "真删"},
66
+ "confirm_title": {
67
+ "en": "{verb} {n} items, ~{size}:",
68
+ "zh": "{verb} {n} 项,约 {size}:",
69
+ },
70
+ "notify_cancelled": {
71
+ "en": "cancelled — nothing touched",
72
+ "zh": "已取消,什么都没动",
73
+ },
74
+ "notify_done": {"en": "{ok}/{n} done ({mode})", "zh": "{ok}/{n} 项完成({mode})"},
75
+ "mode_dry": {"en": "dry-run", "zh": "干跑"},
76
+ "mode_real": {"en": "executed", "zh": "已执行"},
77
+ "yes_button": {"en": "confirm [Y]", "zh": "确认 [Y]"},
78
+ "no_button": {"en": "cancel [Esc]", "zh": "取消 [Esc]"},
79
+ # --- scan CLI ----------------------------------------------------------
80
+ "scan_title": {
81
+ "en": "CacheKat scan (read-only — nothing is deleted)",
82
+ "zh": "CacheKat 扫描(只读——不删任何东西)",
83
+ },
84
+ "scan_report_note": {
85
+ "en": "[!] lines are report-only — CacheKat never cleans those",
86
+ "zh": "[!] 行为只报不动——CacheKat 永不清理它们",
87
+ },
88
+ "scan_summary": {
89
+ "en": "reclaimable (cache, re-download cost only): {size}",
90
+ "zh": "可回收(缓存,重下代价而已):{size}",
91
+ },
92
+ "probe_failed_label": {
93
+ "en": "{probe} probe failed — full reason in the log below",
94
+ "zh": "{probe} 探针崩溃——完整原因见下方执行日志",
95
+ },
96
+ # --- actions.consequence ------------------------------------------------
97
+ "cons_pip": {
98
+ "en": "purge pip download cache — packages re-download on next install",
99
+ "zh": "清空 pip 下载缓存——下次装包重新下载",
100
+ },
101
+ "cons_npm": {
102
+ "en": "delete the npm cache directory — npm re-downloads on demand",
103
+ "zh": "删除 npm 缓存目录——用时重新下载",
104
+ },
105
+ "cons_build_cache": {
106
+ "en": "docker builder prune -f — removes ORPHANED build cache only "
107
+ "(in-use cache untouched); next build re-runs cold steps",
108
+ "zh": "docker builder prune -f——只删无主构建缓存(在用的不动);"
109
+ "下次构建重跑冷步骤",
110
+ },
111
+ "cons_container": {
112
+ "en": "docker rm — deletes the CONTAINER ITSELF, not a cache; "
113
+ "recreate with docker run / compose up if needed. Volumes untouched",
114
+ "zh": "docker rm——删除的是容器本体(不是缓存);需要时用 "
115
+ "docker run/compose 重建。卷不受影响",
116
+ },
117
+ "cons_playwright": {
118
+ "en": "delete this browser directory — `playwright install` "
119
+ "re-downloads if ever needed",
120
+ "zh": "删除该浏览器目录——需要时 `playwright install` 重下",
121
+ },
122
+ "cons_none": {
123
+ "en": "no clean action for this finding",
124
+ "zh": "此条目暂无可执行清理",
125
+ },
126
+ # --- docker probe -------------------------------------------------------
127
+ "dock_volumes_label": {"en": "docker volumes (data!)", "zh": "docker 卷(数据!)"},
128
+ "dock_volumes_detail": {
129
+ "en": "{n} volumes — CacheKat never cleans volumes",
130
+ "zh": "{n} 个卷——CacheKat 永不清理卷",
131
+ },
132
+ "dock_images_label": {
133
+ "en": "docker unused images (report-only)",
134
+ "zh": "docker 未用镜像(只报)",
135
+ },
136
+ "dock_images_detail": {
137
+ "en": "docker image prune -a would remove ALL unused images — "
138
+ "per-image selection pending; use `docker rmi` yourself meanwhile",
139
+ "zh": "docker image prune -a 会删掉全部未用镜像——逐镜像勾选待做;"
140
+ "需要清理请自己 `docker rmi` 挑着删",
141
+ },
142
+ "dock_build_label": {"en": "docker build cache", "zh": "docker 构建缓存"},
143
+ "dock_build_detail": {
144
+ "en": "build cache layers, per docker system df (builder prune keeps in-use cache)",
145
+ "zh": "构建缓存层,docker system df 口径(builder prune 保留在用缓存)",
146
+ },
147
+ "dock_container_label": {
148
+ "en": "container: {name} ({image})",
149
+ "zh": "容器:{name}({image})",
150
+ },
151
+ "dock_container_detail": {
152
+ "en": "{status} — docker rm deletes the CONTAINER ITSELF, not a "
153
+ "cache; recreate via docker run / compose up. Volumes are NOT touched",
154
+ "zh": "{status}——docker rm 删除的是容器本体而非缓存;可 docker "
155
+ "run/compose 重建。卷不受影响",
156
+ },
157
+ "dock_daemon_unreachable": {
158
+ "en": "docker CLI exists but exited {rc} — start Docker Desktop, "
159
+ "then press r in the TUI to rescan",
160
+ "zh": "docker CLI 存在但退出码 {rc}——启动 Docker Desktop 后在 "
161
+ "TUI 里按 r 重扫",
162
+ },
163
+ "dock_daemon_timeout": {
164
+ "en": "docker CLI exists but `docker system df` timed out",
165
+ "zh": "docker CLI 存在但 `docker system df` 超时",
166
+ },
167
+ "dock_ps_timeout": {
168
+ "en": "container listing timed out",
169
+ "zh": "容器列表获取超时",
170
+ },
171
+ "dock_ps_failed": {
172
+ "en": "exited {rc} — container listing unavailable",
173
+ "zh": "退出码 {rc}——容器列表不可用",
174
+ },
175
+ "dock_ps_timeout_label": {"en": "docker ps timed out", "zh": "docker ps 超时"},
176
+ "dock_ps_failed_label": {"en": "docker ps failed", "zh": "docker ps 失败"},
177
+ "dock_daemon_label": {
178
+ "en": "docker daemon unreachable",
179
+ "zh": "docker 守护进程连不上",
180
+ },
181
+ "dock_daemon_timeout_label": {
182
+ "en": "docker daemon timed out",
183
+ "zh": "docker 守护进程超时",
184
+ },
185
+ # --- playwright probe ----------------------------------------------------
186
+ "pw_orphan_label": {
187
+ "en": "playwright {name} (orphan)",
188
+ "zh": "playwright {name}(孤儿)",
189
+ },
190
+ "pw_inuse_label": {"en": "playwright {name}", "zh": "playwright {name}"},
191
+ "pw_orphan_detail": {
192
+ "en": "not referenced by installed playwright",
193
+ "zh": "未被当前 playwright 引用",
194
+ },
195
+ "pw_unknown_label": {
196
+ "en": "playwright browsers (liveness unknown)",
197
+ "zh": "playwright 浏览器(活性未知)",
198
+ },
199
+ "pw_unknown_detail": {
200
+ "en": "{path} — playwright package not importable here, cannot tell "
201
+ "orphans from in-use",
202
+ "zh": "{path}——此环境 import 不到 playwright 包,判不了哪些在用",
203
+ },
204
+ }
205
+
206
+
207
+ def _windows_prefers_zh() -> bool:
208
+ """Ask Windows directly: GetUserDefaultUILanguage LANGID, primary
209
+ language 0x04 = Chinese (2026-10-08 real-world bug: cmd sets no LANG and
210
+ locale.getlocale() returns (None, None) — auto never resolved zh)."""
211
+ if sys.platform != "win32":
212
+ return False
213
+ try:
214
+ import ctypes
215
+
216
+ lang_id = ctypes.windll.kernel32.GetUserDefaultUILanguage()
217
+ return (lang_id & 0x3FF) == 0x04
218
+ except Exception: # noqa: BLE001 — detection must never crash the app
219
+ return False
220
+
221
+
222
+ def _system_prefers_zh() -> bool:
223
+ """Env vars first (POSIX semantics: first set var wins), then the OS
224
+ native call on Windows, locale last. getdefaultlocale is NOT used:
225
+ deprecated, removed in 3.15."""
226
+ for var in ("LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"):
227
+ val = os.environ.get(var, "")
228
+ if val:
229
+ return val.lower().startswith("zh")
230
+ if sys.platform == "win32":
231
+ return _windows_prefers_zh()
232
+ try:
233
+ loc = locale.getlocale()[0] or ""
234
+ except ValueError:
235
+ loc = ""
236
+ return loc.lower().startswith("zh")
237
+
238
+
239
+ def resolve_lang(spec: str) -> Lang:
240
+ """'auto' follows the system preference (zh* -> zh, else en)."""
241
+ if spec in ("en", "zh"):
242
+ return spec
243
+ return "zh" if _system_prefers_zh() else "en"
244
+
245
+
246
+ def set_lang(lang: Lang) -> None:
247
+ global _LANG
248
+ if lang not in ("en", "zh"):
249
+ msg = f"unknown lang {lang!r}"
250
+ raise ValueError(msg)
251
+ _LANG = lang
252
+
253
+
254
+ def current_lang() -> Lang:
255
+ return _LANG
256
+
257
+
258
+ def t(key: str, **fmt: object) -> str:
259
+ """Translate `key` in the active language. Missing zh -> en fallback."""
260
+ entry = _STRINGS.get(key)
261
+ if entry is None:
262
+ msg = f"missing i18n key {key!r}"
263
+ raise KeyError(msg)
264
+ text = entry.get(_LANG) or entry["en"]
265
+ return text.format(**fmt) if fmt else text
cachekat/keys.py ADDED
@@ -0,0 +1,22 @@
1
+ """Finding-key contract — the single source of every key string.
2
+
3
+ Probes emit these keys; actions dispatch on them; the TUI passes them as
4
+ selection values. src code MUST use these constants — no fresh literals.
5
+ Tests intentionally pin the literal strings (contract tests: if a key ever
6
+ changes, tests fail loudly rather than silently following the constant).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ PIP_CACHE = "pip/cache"
12
+ NPM_CACHE = "npm/cache"
13
+
14
+ PLAYWRIGHT_PREFIX = "playwright/"
15
+ PLAYWRIGHT_BROWSERS = "playwright/browsers"
16
+
17
+ DOCKER_VOLUMES = "docker/volumes"
18
+ DOCKER_IMAGES = "docker/images-reclaimable"
19
+ DOCKER_BUILD_CACHE = "docker/build-cache"
20
+ DOCKER_CONTAINER_PREFIX = "docker/container/"
21
+ DOCKER_DAEMON = "docker/daemon"
22
+ DOCKER_PS_ERROR = "docker/ps-error"
cachekat/models.py ADDED
@@ -0,0 +1,39 @@
1
+ """Core data shapes — explicit types at every boundary, no magic strings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum, unique
7
+ from pathlib import Path
8
+
9
+
10
+ @unique
11
+ class Risk(str, Enum):
12
+ """What a finding is allowed to become.
13
+
14
+ CLEANABLE: cache/junk — deleting it only costs a later re-download.
15
+ REPORT_ONLY: valuable or risky (data volumes, vhdx) — show, never clean.
16
+ """
17
+
18
+ CLEANABLE = "cleanable"
19
+ REPORT_ONLY = "report-only"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Finding:
24
+ """One scan result. Immutable; the report layer renders, probes fill.
25
+
26
+ path: the on-disk location this finding measures, when there is one
27
+ (docker rows are CLI-level facts, not paths). Actions consume it —
28
+ parsing paths back out of `detail` (display text) is forbidden.
29
+ """
30
+
31
+ key: str # stable id from cachekat.keys — the wire contract
32
+ label: str # human name, e.g. "pip download cache"
33
+ size_bytes: int # 0 when unknown/absent — never negative
34
+ detail: str # path or explanation shown to the user
35
+ risk: Risk
36
+ path: Path | None = None
37
+ # registry sets this when a probe CRASHED (finding = the crash report):
38
+ # explicit flag at the boundary, no key-string sniffing downstream
39
+ is_error: bool = False
@@ -0,0 +1,5 @@
1
+ """Probes: one module per cache family. Import side effect = registration."""
2
+
3
+ from cachekat.probes import docker_df, npm_cache, pip_cache, playwright_cache
4
+
5
+ __all__ = ["docker_df", "npm_cache", "pip_cache", "playwright_cache"]