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
cachekat/__init__.py
ADDED
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
|