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/tui.py ADDED
@@ -0,0 +1,322 @@
1
+ """Textual TUI: scan -> select -> confirm -> clean (dry-run switchable).
2
+
3
+ Logic spine (deliberately boring and hard to misuse):
4
+ - left panel: CLEANABLE findings, selectable (space); right panel: REPORT_ONLY,
5
+ visible but untouchable β€” the layout itself teaches the safety model.
6
+ - `c` opens a confirm modal listing exactly what will run; nothing cleans
7
+ without an explicit yes.
8
+ - `d` toggles dry-run: actions describe what they WOULD do, disk untouched.
9
+ - clean runs in a worker thread; results land in the log panel, honestly
10
+ (failures included β€” never swallowed).
11
+
12
+ All UI copy goes through cachekat.i18n.t β€” no hardcoded user-facing text.
13
+ Language is set once at construction (CLI --lang, default auto by locale).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+
20
+ from rich.markup import escape
21
+ from rich.text import Text
22
+ from textual import work
23
+ from textual.app import App, ComposeResult
24
+ from textual.binding import Binding, BindingsMap
25
+ from textual.containers import Horizontal, Vertical
26
+ from textual.screen import ModalScreen
27
+ from textual.widgets import (
28
+ Button,
29
+ DataTable,
30
+ Footer,
31
+ RichLog,
32
+ SelectionList,
33
+ Static,
34
+ )
35
+
36
+ from cachekat.actions import ActionResult, clean, consequence
37
+ from cachekat.cli import human_size
38
+ from cachekat.i18n import resolve_lang, set_lang, t
39
+ from cachekat.models import Finding, Risk
40
+ from cachekat.registry import run_scan
41
+
42
+ FindingsFn = Callable[[], list[Finding]]
43
+
44
+
45
+ def _fit_row(label: str, size: str, width: int) -> Text:
46
+ """One selection-list row: `~ label size`, measured in display cells.
47
+
48
+ Long labels ellipsize; the size column stays visible β€” a row without its
49
+ price tag is a lie (2026-10-09 user report: container rows overran the
50
+ left panel edge). Full label still shows in the confirm modal."""
51
+ marker = Text("~ ", style="green")
52
+ size_txt = Text(size, style="dim")
53
+ # fudge: toggle button + gutter + trailing padding (~6 cells)
54
+ budget = width - size_txt.cell_len - marker.cell_len - 6
55
+ label_txt = Text(label)
56
+ if budget > 4 and label_txt.cell_len > budget:
57
+ label_txt.truncate(budget, overflow="ellipsis")
58
+ return Text.assemble(marker, label_txt, Text(" "), size_txt)
59
+
60
+
61
+ class ConfirmModal(ModalScreen[bool]):
62
+ """The one gate between selection and deletion."""
63
+
64
+ BINDINGS = [
65
+ Binding("y,enter", "confirm", "confirm", show=False),
66
+ Binding("escape,n", "cancel", "cancel", show=False),
67
+ ]
68
+
69
+ def __init__(self, message: str) -> None:
70
+ super().__init__()
71
+ self._message = message
72
+
73
+ def compose(self) -> ComposeResult:
74
+ with Vertical(classes="dialog"):
75
+ yield Static(self._message, classes="msg")
76
+ with Horizontal(classes="buttons"):
77
+ yield Button(t("yes_button"), id="yes", variant="error")
78
+ yield Button(t("no_button"), id="no", variant="default")
79
+
80
+ def on_button_pressed(self, event: Button.Pressed) -> None:
81
+ self.dismiss(event.button.id == "yes")
82
+
83
+ def action_confirm(self) -> None:
84
+ self.dismiss(True)
85
+
86
+ def action_cancel(self) -> None:
87
+ self.dismiss(False)
88
+
89
+
90
+ class CacheKatApp(App[None]):
91
+ TITLE = "CacheKat"
92
+
93
+ CSS = """
94
+ Screen { layout: vertical; }
95
+
96
+ #banner {
97
+ dock: top; height: 1; padding: 0 2;
98
+ background: $accent 20%; text-style: bold;
99
+ }
100
+ #summary {
101
+ dock: top; height: 3; padding: 0 2;
102
+ border: round $accent; content-align: center middle;
103
+ background: $surface;
104
+ }
105
+ #body { height: 1fr; }
106
+ #left, #right { width: 1fr; height: 1fr; padding: 0 1; }
107
+ #left { border: round $success; }
108
+ #right { border: round $error 60%; }
109
+ #left-label, #right-label, #log-label { text-style: bold; padding: 0 1; }
110
+ #cleanable { border: none; background: transparent; height: 1fr; }
111
+ #report { border: none; height: 1fr; }
112
+ #log { height: 10; border: round $panel; padding: 0 1; }
113
+ ConfirmModal { align: center middle; }
114
+ .dialog {
115
+ width: 78; height: auto; max-width: 95%;
116
+ border: thick $accent; background: $surface; padding: 1 2;
117
+ }
118
+ .dialog .msg { padding: 0 1; margin-bottom: 1; }
119
+ .dialog .buttons { height: auto; align-horizontal: center; }
120
+ .dialog Button { margin: 0 2; }
121
+ """
122
+
123
+ BINDINGS = [
124
+ Binding("a", "all", "all"),
125
+ Binding("n", "none", "none"),
126
+ Binding("d", "dry", "dry-run"),
127
+ Binding("c", "clean", "clean"),
128
+ Binding("r", "rescan", "rescan"),
129
+ Binding("q", "quit", "quit"),
130
+ ]
131
+
132
+ def __init__(
133
+ self, findings_provider: FindingsFn | None = None, lang: str = "auto"
134
+ ) -> None:
135
+ set_lang(resolve_lang(lang)) # language FIRST (it feeds compose())
136
+ super().__init__()
137
+ # Localize the footer bindings. Class BINDINGS are merged and baked
138
+ # at class-creation time (__init_subclass__), so swapping the class
139
+ # attribute does nothing (2026-10-08 real-world miss). The instance
140
+ # _bindings map is OUR copy β€” REBUILD it from the current one,
141
+ # preserving every built-in (ctrl+p palette, ctrl+q quit, ...) and
142
+ # only replacing our six descriptions.
143
+ override = {
144
+ "all": t("key_all"),
145
+ "none": t("key_none"),
146
+ "dry": t("key_dry"),
147
+ "clean": t("key_clean"),
148
+ "rescan": t("key_rescan"),
149
+ "quit": t("key_quit"),
150
+ }
151
+ rebuilt = BindingsMap()
152
+ for key, b in self._bindings:
153
+ desc = override.get(b.action, b.description)
154
+ rebuilt.bind(
155
+ key, b.action, desc, show=b.show, key_display=b.key_display,
156
+ priority=b.priority,
157
+ )
158
+ self._bindings = rebuilt
159
+ self._provider = findings_provider or run_scan
160
+ self._findings: list[Finding] = []
161
+ self._pending: list[Finding] = []
162
+ self._dry = False
163
+ self._busy = False
164
+ self._results: list[ActionResult] = []
165
+
166
+ def compose(self) -> ComposeResult:
167
+ yield Static(t("banner_title"), id="banner")
168
+ yield Static(t("scanning"), id="summary")
169
+ with Horizontal(id="body"):
170
+ with Vertical(id="left"):
171
+ yield Static(t("left_label"), id="left-label")
172
+ yield SelectionList(id="cleanable")
173
+ with Vertical(id="right"):
174
+ yield Static(t("right_label"), id="right-label")
175
+ yield DataTable(id="report")
176
+ yield Static(t("log_label"), id="log-label")
177
+ yield RichLog(id="log", markup=True)
178
+ # the real Footer (restored 2026-10-08, second attempt): clickable
179
+ # bindings + built-in command palette button (^p) β€” the Static keybar
180
+ # placeholder could not do either
181
+ yield Footer()
182
+
183
+ def on_mount(self) -> None:
184
+ self.query_one("#report", DataTable).add_columns(
185
+ t("col_item"), t("col_size"), t("col_note")
186
+ )
187
+ self._kick_scan()
188
+
189
+ # --- scanning -----------------------------------------------------------
190
+
191
+ def _kick_scan(self) -> None:
192
+ self._busy = True
193
+ self._render_summary(t("scanning"))
194
+ self._scan_worker()
195
+
196
+ @work(thread=True, exclusive=True)
197
+ def _scan_worker(self) -> None:
198
+ findings = self._provider()
199
+ self.call_from_thread(self._render, findings)
200
+
201
+ def _render(self, findings: list[Finding]) -> None:
202
+ self._findings = findings
203
+ self._busy = False
204
+ sel = self.query_one("#cleanable", SelectionList)
205
+ sel.clear_options()
206
+ panel_w = sel.size.width or 60 # 0 before first layout β€” sane fallback
207
+ for f in findings:
208
+ if f.risk is Risk.CLEANABLE:
209
+ sel.add_option(
210
+ (_fit_row(f.label, human_size(f.size_bytes), panel_w), f.key)
211
+ )
212
+ table = self.query_one("#report", DataTable)
213
+ table.clear()
214
+ for f in findings:
215
+ if f.risk is Risk.REPORT_ONLY:
216
+ table.add_row(f.label, human_size(f.size_bytes), f.detail, key=f.key)
217
+ # probe crash reports ALSO land in the log panel: the table's note
218
+ # column truncates ("AttributeErro"…), the log shows the whole line
219
+ # with the raise site (2026-10-09 Windows case)
220
+ log = self.query_one("#log", RichLog)
221
+ for f in findings:
222
+ if f.is_error:
223
+ log.write(f"[red]✘[/red] {escape(f.label)}\n {escape(f.detail)}")
224
+ self._render_summary()
225
+
226
+ def _render_summary(self, extra: str = "") -> None:
227
+ total = sum(f.size_bytes for f in self._findings if f.risk is Risk.CLEANABLE)
228
+ selected = self._selected_keys()
229
+ chosen = sum(
230
+ f.size_bytes for f in self._findings if f.risk is Risk.CLEANABLE and f.key in selected
231
+ )
232
+ dry = t("dry_on") if self._dry else t("dry_off")
233
+ text = t(
234
+ "summary_line",
235
+ size=f"[b]{human_size(total)}[/b]",
236
+ n=len(selected),
237
+ sel=human_size(chosen),
238
+ dry=dry,
239
+ )
240
+ if extra:
241
+ text += f"\n{extra}"
242
+ self.query_one("#summary", Static).update(text)
243
+
244
+ def _selected_keys(self) -> list[str]:
245
+ # no blanket except here (audit 2026-10-08): a failing query means a
246
+ # real bug (widget missing) and must crash loudly, not silently
247
+ # behave as "nothing selected"
248
+ sel = self.query_one("#cleanable", SelectionList).selected
249
+ return [str(v) for v in sel]
250
+
251
+ # --- bindings -----------------------------------------------------------
252
+
253
+ def action_all(self) -> None:
254
+ self.query_one("#cleanable", SelectionList).select_all()
255
+ self._render_summary()
256
+
257
+ def action_none(self) -> None:
258
+ self.query_one("#cleanable", SelectionList).deselect_all()
259
+ self._render_summary()
260
+
261
+ def action_dry(self) -> None:
262
+ self._dry = not self._dry
263
+ self._render_summary()
264
+
265
+ def action_rescan(self) -> None:
266
+ self._kick_scan()
267
+
268
+ def action_clean(self) -> None:
269
+ if self._busy:
270
+ self.notify(t("notify_busy"), severity="warning")
271
+ return
272
+ chosen = [
273
+ f
274
+ for f in self._findings
275
+ if f.risk is Risk.CLEANABLE and f.key in self._selected_keys()
276
+ ]
277
+ if not chosen:
278
+ self.notify(t("notify_none_selected"), severity="information")
279
+ return
280
+ size = human_size(sum(f.size_bytes for f in chosen))
281
+ verb = t("confirm_dry") if self._dry else t("confirm_real")
282
+ # 2026-10-08 lesson: item names alone don't warn β€” every line carries
283
+ # a plain-words consequence from actions.consequence()
284
+ lines = "\n".join(
285
+ f"~ {f.label}({human_size(f.size_bytes)}οΌ‰\n ↳ {consequence(f)}"
286
+ for f in chosen
287
+ )
288
+ self.push_screen(
289
+ ConfirmModal(t("confirm_title", verb=verb, n=len(chosen), size=size) + "\n" + lines),
290
+ callback=self._on_confirm,
291
+ )
292
+ self._pending = chosen # stash for the callback
293
+
294
+ def _on_confirm(self, ok: bool) -> None:
295
+ chosen = self._pending
296
+ if not ok:
297
+ self.notify(t("notify_cancelled"))
298
+ return
299
+ self._busy = True
300
+ self._clean_worker(chosen)
301
+
302
+ @work(thread=True, exclusive=True)
303
+ def _clean_worker(self, chosen: list[Finding]) -> None:
304
+ results = [clean(f, dry_run=self._dry) for f in chosen]
305
+ self.call_from_thread(self._show_results, results)
306
+
307
+ def _show_results(self, results: list[ActionResult]) -> None:
308
+ self._results = results
309
+ self._busy = False
310
+ log = self.query_one("#log", RichLog)
311
+ for r in results:
312
+ mark = "βœ”" if r.ok else "✘"
313
+ style = "" if r.ok else "[red]"
314
+ log.write(f"{style}{mark} {escape(r.key)} β€” {escape(r.detail)}")
315
+ ok_n = sum(1 for r in results if r.ok)
316
+ mode = t("mode_dry") if self._dry else t("mode_real")
317
+ self.notify(t("notify_done", ok=ok_n, n=len(results), mode=mode))
318
+
319
+
320
+ def run_app(lang: str = "auto") -> int:
321
+ CacheKatApp(lang=lang).run()
322
+ return 0
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: cachekat
3
+ Version: 0.4.8
4
+ Summary: A TUI to see which dev caches eat your disk - and reclaim space safely, risk-graded.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/Aeluris/CacheKat
7
+ Project-URL: Issues, https://github.com/Aeluris/CacheKat/issues
8
+ Project-URL: Changelog, https://github.com/Aeluris/CacheKat/blob/main/CHANGELOG.md
9
+ Keywords: cache,disk-space,tui,docker,textual,cleanup
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: System :: Filesystems
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: textual>=1.0
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8; extra == "dev"
28
+ Requires-Dist: ruff>=0.6; extra == "dev"
29
+ Dynamic: license-file
30
+
31
+ # CacheKat 🐈
32
+
33
+ [English](README.md) | [δΈ­ζ–‡](README.zh-CN.md)
34
+
35
+ > A TUI to see which dev caches eat your disk β€” and reclaim space safely,
36
+ > risk-graded. **Never touches your data.**
37
+
38
+ **Main view** β€” cleanable items on the left (select with space, run with `c`);
39
+ report-only facts on the right (docker volumes, unknown liveness) are visible
40
+ but untouchable β€” the layout itself teaches the safety model:
41
+
42
+ ![main view](docs/screenshots/en-main.png)
43
+
44
+ **Confirm gate** β€” nothing cleans without an explicit yes; each item carries
45
+ its size and a plain-words consequence, and dry-run (`d`) describes what
46
+ would happen without touching the disk:
47
+
48
+ ![confirm modal](docs/screenshots/en-confirm.png)
49
+
50
+ [![ci](https://github.com/Aeluris/CacheKat/actions/workflows/ci.yml/badge.svg)](https://github.com/Aeluris/CacheKat/actions/workflows/ci.yml)
51
+ [![python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org)
52
+ [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
53
+
54
+ ## Why
55
+
56
+ Every developer machine accumulates caches that nobody watches: pip wheels,
57
+ npm tarballs, playwright browsers (old versions pile up per release), docker
58
+ build cache, dangling images, stopped containers... A fresh scan answers the
59
+ only question that matters: **who is eating the disk, and what is actually
60
+ safe to reclaim?**
61
+
62
+ ## Safety model (the feature)
63
+
64
+ | Category | Behavior |
65
+ |---|---|
66
+ | True caches (pip, npm, playwright orphans, docker build cache) | Selectable β€” each row states its plain-words consequence before anything runs |
67
+ | **Docker volumes (your data)** | **Report-only. Never cleaned. No flag unlocks this.** |
68
+ | Docker stopped containers / unused images | Containers: cleanable **one by one**, each warning that `docker rm` deletes the container **itself** (recreate via `docker run`/`compose up`; volumes untouched). Images: report-only until per-image selection ships. |
69
+ | Anything the probe cannot judge | Honest report-only ("liveness unknown"), never a guess |
70
+
71
+ - Every scan is read-only; cleaning is an explicit, confirmed selection.
72
+ - Dry-run mode (`d`): rehearse exactly what would happen β€” disk untouched.
73
+ - Probes that fail surface as visible error rows, never swallowed.
74
+
75
+ ## Install
76
+
77
+ ```
78
+ pipx install cachekat
79
+ ```
80
+
81
+ Or from source: `pipx install git+https://github.com/Aeluris/CacheKat.git`
82
+
83
+ ## Use
84
+
85
+ ```
86
+ cachekat tui # interactive: scan -> select -> confirm -> clean
87
+ cachekat scan # read-only report: who eats the disk
88
+ cachekat scan --json # machine-readable
89
+ # every command accepts --lang auto|en|zh (auto follows the system language)
90
+ ```
91
+
92
+ Docker rows need the docker daemon running β€” if the TUI shows
93
+ `docker daemon unreachable`, start Docker Desktop and press `r` to rescan.
94
+
95
+ ## Roadmap
96
+
97
+ - [x] M0 β€” skeleton, registry, `scan` CLI, pip probe, CI (win+linux)
98
+ - [x] M1 β€” docker / npm / playwright probes (orphan detection, volumes red-lined)
99
+ - [x] M2 β€” textual TUI: select, confirm, clean, dry-run
100
+ - [x] Per-container selection with loud not-a-cache warnings
101
+ - [x] i18n: full en/zh, `--lang auto|en|zh`
102
+ - [ ] per-image selection, more cache families (cargo, gradle, ...)
103
+
104
+ See [CHANGELOG.md](CHANGELOG.md) for details, [CONTRIBUTING.md](CONTRIBUTING.md)
105
+ to help, [AGENTS.md](AGENTS.md) for the engineering rulebook.
106
+
107
+ ## License
108
+
109
+ [MIT](LICENSE) β€” Β© 2026 Aeluris
@@ -0,0 +1,20 @@
1
+ cachekat/__init__.py,sha256=Pwr6EdMnerwT7K0woEmfwTXKTGvtNIU64KmpI_wreRw,100
2
+ cachekat/actions.py,sha256=AbfFOZ2yKucAO493NaGhaVD_JI6xu8gwGTHF7YjETi4,4864
3
+ cachekat/cli.py,sha256=AVCEuWwcSN8iZR5czM9V1YavN-xamD5lnvD4iIplVMo,2953
4
+ cachekat/fsutil.py,sha256=KzcDaxrN9oJw4bstiooGRL3TwuVE9xsE1SOFYXd3KqE,800
5
+ cachekat/i18n.py,sha256=l4n-T-FNSCF3noehdijevXmg7zQ7eCteF7CXJ13j3Ic,10768
6
+ cachekat/keys.py,sha256=AVUFCSelqBBRxjVSybynQWTa37xI2oOP8tJafH4R9Oo,767
7
+ cachekat/models.py,sha256=Gaox3ltWDhVMv5BLhejLGOF14bwBsYHkEaC6kwylzAc,1305
8
+ cachekat/registry.py,sha256=-2FIrGclNQzSafTF-E3Rmz3UEh8i4eptbpRhhzE9Mnk,2234
9
+ cachekat/tui.py,sha256=83QxW725_2MQ2sNjEhMD2NXTCK85MxQMsHbnF5aZcS0,12145
10
+ cachekat/probes/__init__.py,sha256=_DQiJ3_xdO_hvMAioWduzygOAAfJnATm0sqovOO-I20,228
11
+ cachekat/probes/docker_df.py,sha256=DePG4thANtTc9FECoJ07IZhCedYo14BlsgIedzXeOms,7997
12
+ cachekat/probes/npm_cache.py,sha256=mOM5mTirLruqi4DaYFS5H03xEMheJZUI97naOO4Eggk,2229
13
+ cachekat/probes/pip_cache.py,sha256=2CFwsSBHmLS-huegcvAn7E2O5V7zHwrJOREQC2wAuKQ,1226
14
+ cachekat/probes/playwright_cache.py,sha256=sUKD05LIc90s-3C6Lt1ZzJEVN1BjBXvtseDohFwwSgw,3761
15
+ cachekat-0.4.8.dist-info/licenses/LICENSE,sha256=mY5mV9J0Ysc8Qc6PFi8KuvpKu5XgGp6T9OvzMM-bLF4,1064
16
+ cachekat-0.4.8.dist-info/METADATA,sha256=1IHZGzms-MeFTE9aKxavsfATw3ywOQf373lRjF3wGhA,4543
17
+ cachekat-0.4.8.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
18
+ cachekat-0.4.8.dist-info/entry_points.txt,sha256=E8TaivLjN9g-Kug1hCr8G_zi0EdJw7iEQ9ZnyDC4_bY,47
19
+ cachekat-0.4.8.dist-info/top_level.txt,sha256=qkuCASxowPhCLiR_4sG0A7MZB_i6c_ZLxPLlYYsfaCU,9
20
+ cachekat-0.4.8.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cachekat = cachekat.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aeluris
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ cachekat