bloatr 0.1.0__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.
- bloatr/__init__.py +1 -0
- bloatr/__main__.py +3 -0
- bloatr/app.py +509 -0
- bloatr/cleaner.py +216 -0
- bloatr/cli.py +122 -0
- bloatr/locations.py +70 -0
- bloatr/models.py +72 -0
- bloatr/scanner.py +145 -0
- bloatr/sizes.py +80 -0
- bloatr-0.1.0.dist-info/METADATA +131 -0
- bloatr-0.1.0.dist-info/RECORD +13 -0
- bloatr-0.1.0.dist-info/WHEEL +4 -0
- bloatr-0.1.0.dist-info/entry_points.txt +2 -0
bloatr/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
bloatr/__main__.py
ADDED
bloatr/app.py
ADDED
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
"""Textual TUI for bloatr."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import ClassVar
|
|
8
|
+
|
|
9
|
+
from textual import work
|
|
10
|
+
from textual.app import App, ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Vertical
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widgets import DataTable, Footer, Header, Label, ProgressBar, Static
|
|
15
|
+
|
|
16
|
+
from bloatr.cleaner import delete_with_progress
|
|
17
|
+
from bloatr.models import Item
|
|
18
|
+
from bloatr.scanner import list_children, scan_locations
|
|
19
|
+
from bloatr.sizes import format_size
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ========================================================================= #
|
|
23
|
+
# Confirmation modal
|
|
24
|
+
# ========================================================================= #
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfirmScreen(ModalScreen[bool]):
|
|
28
|
+
"""Modal 'are you sure?' prompt shown before any deletion runs."""
|
|
29
|
+
|
|
30
|
+
DEFAULT_CSS = """
|
|
31
|
+
ConfirmScreen {
|
|
32
|
+
align: center middle;
|
|
33
|
+
}
|
|
34
|
+
#confirm-dialog {
|
|
35
|
+
padding: 2 4;
|
|
36
|
+
background: $surface;
|
|
37
|
+
border: tall $warning;
|
|
38
|
+
width: 70;
|
|
39
|
+
height: auto;
|
|
40
|
+
}
|
|
41
|
+
#confirm-question {
|
|
42
|
+
text-align: center;
|
|
43
|
+
margin-bottom: 1;
|
|
44
|
+
text-style: bold;
|
|
45
|
+
}
|
|
46
|
+
#confirm-hint {
|
|
47
|
+
text-align: center;
|
|
48
|
+
color: $text-muted;
|
|
49
|
+
}
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
BINDINGS: ClassVar = [
|
|
53
|
+
Binding("y", "confirm", "Yes"),
|
|
54
|
+
Binding("n,escape", "cancel", "No"),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
def __init__(self, total_size: int, count: int, dry_run: bool) -> None:
|
|
58
|
+
super().__init__()
|
|
59
|
+
self._total_size = total_size
|
|
60
|
+
self._count = count
|
|
61
|
+
self._dry_run = dry_run
|
|
62
|
+
|
|
63
|
+
def compose(self) -> ComposeResult:
|
|
64
|
+
prefix = "DRY RUN — would delete" if self._dry_run else "Delete"
|
|
65
|
+
with Vertical(id="confirm-dialog"):
|
|
66
|
+
yield Static(
|
|
67
|
+
f"{prefix} {self._count} item(s) ({format_size(self._total_size)})?",
|
|
68
|
+
id="confirm-question",
|
|
69
|
+
)
|
|
70
|
+
yield Static(
|
|
71
|
+
"Press Y to confirm, N or ESC to cancel",
|
|
72
|
+
id="confirm-hint",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def action_confirm(self) -> None:
|
|
76
|
+
self.dismiss(True)
|
|
77
|
+
|
|
78
|
+
def action_cancel(self) -> None:
|
|
79
|
+
self.dismiss(False)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ========================================================================= #
|
|
83
|
+
# Progress modal — live deletion progress
|
|
84
|
+
# ========================================================================= #
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ProgressResult:
|
|
88
|
+
"""Result object dismissed from :class:`ProgressScreen`."""
|
|
89
|
+
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
freed: int,
|
|
93
|
+
errors: list[tuple[Item, str]],
|
|
94
|
+
done_paths: set[Path],
|
|
95
|
+
) -> None:
|
|
96
|
+
self.freed = freed
|
|
97
|
+
self.errors = errors
|
|
98
|
+
self.done_paths = done_paths
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ProgressScreen(ModalScreen[ProgressResult]):
|
|
102
|
+
"""Modal that streams live deletion progress and runs the worker."""
|
|
103
|
+
|
|
104
|
+
DEFAULT_CSS = """
|
|
105
|
+
ProgressScreen {
|
|
106
|
+
align: center middle;
|
|
107
|
+
}
|
|
108
|
+
#progress-dialog {
|
|
109
|
+
padding: 2 4;
|
|
110
|
+
background: $surface;
|
|
111
|
+
border: tall $primary;
|
|
112
|
+
width: 90;
|
|
113
|
+
height: auto;
|
|
114
|
+
}
|
|
115
|
+
#progress-title {
|
|
116
|
+
text-align: center;
|
|
117
|
+
text-style: bold;
|
|
118
|
+
margin-bottom: 1;
|
|
119
|
+
}
|
|
120
|
+
#progress-current {
|
|
121
|
+
color: $text-muted;
|
|
122
|
+
margin-bottom: 1;
|
|
123
|
+
width: 100%;
|
|
124
|
+
}
|
|
125
|
+
#progress-bar {
|
|
126
|
+
margin-bottom: 1;
|
|
127
|
+
}
|
|
128
|
+
#progress-stats {
|
|
129
|
+
text-align: center;
|
|
130
|
+
color: $text;
|
|
131
|
+
}
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
BINDINGS: ClassVar = [] # no keys — user waits for worker
|
|
135
|
+
|
|
136
|
+
def __init__(self, items: list[Item], dry_run: bool) -> None:
|
|
137
|
+
super().__init__()
|
|
138
|
+
self._items = items
|
|
139
|
+
self._dry_run = dry_run
|
|
140
|
+
self._total_bytes = max(sum(max(i.size, 0) for i in items), 1)
|
|
141
|
+
self._freed = 0
|
|
142
|
+
self._errors: list[tuple[Item, str]] = []
|
|
143
|
+
self._done_paths: set[Path] = set()
|
|
144
|
+
|
|
145
|
+
def compose(self) -> ComposeResult:
|
|
146
|
+
title = "DRY RUN — simulating deletion" if self._dry_run else "Deleting"
|
|
147
|
+
with Vertical(id="progress-dialog"):
|
|
148
|
+
yield Static(title, id="progress-title")
|
|
149
|
+
yield Static(
|
|
150
|
+
f"[1/{len(self._items)}] preparing…",
|
|
151
|
+
id="progress-current",
|
|
152
|
+
)
|
|
153
|
+
yield ProgressBar(
|
|
154
|
+
total=self._total_bytes,
|
|
155
|
+
show_eta=False,
|
|
156
|
+
show_percentage=True,
|
|
157
|
+
id="progress-bar",
|
|
158
|
+
)
|
|
159
|
+
yield Static(
|
|
160
|
+
f"0 B / {format_size(self._total_bytes)}",
|
|
161
|
+
id="progress-stats",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def on_mount(self) -> None:
|
|
165
|
+
self._run_worker()
|
|
166
|
+
|
|
167
|
+
# ------------------------------------------------------------------ #
|
|
168
|
+
# Worker — runs in a background thread
|
|
169
|
+
# ------------------------------------------------------------------ #
|
|
170
|
+
|
|
171
|
+
@work(thread=True, exclusive=True)
|
|
172
|
+
def _run_worker(self) -> None:
|
|
173
|
+
# Throttle UI updates: batch byte deltas and push at ~12 fps.
|
|
174
|
+
state = {"pending_bytes": 0, "last_push": 0.0}
|
|
175
|
+
UPDATE_INTERVAL = 0.08 # seconds
|
|
176
|
+
|
|
177
|
+
def flush_progress() -> None:
|
|
178
|
+
if state["pending_bytes"] > 0:
|
|
179
|
+
delta = state["pending_bytes"]
|
|
180
|
+
state["pending_bytes"] = 0
|
|
181
|
+
state["last_push"] = time.monotonic()
|
|
182
|
+
self.app.call_from_thread(self._add_progress, delta)
|
|
183
|
+
|
|
184
|
+
for idx, item in enumerate(self._items):
|
|
185
|
+
# Flush previous item's leftover bytes first.
|
|
186
|
+
flush_progress()
|
|
187
|
+
|
|
188
|
+
# Update "current item" line in the UI.
|
|
189
|
+
self.app.call_from_thread(self._set_current, idx, item)
|
|
190
|
+
|
|
191
|
+
def on_progress(delta: int, _path: str) -> None:
|
|
192
|
+
state["pending_bytes"] += delta
|
|
193
|
+
now = time.monotonic()
|
|
194
|
+
if now - state["last_push"] >= UPDATE_INTERVAL:
|
|
195
|
+
pending = state["pending_bytes"]
|
|
196
|
+
state["pending_bytes"] = 0
|
|
197
|
+
state["last_push"] = now
|
|
198
|
+
self.app.call_from_thread(self._add_progress, pending)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
_, error = delete_with_progress(
|
|
202
|
+
item,
|
|
203
|
+
on_progress=on_progress,
|
|
204
|
+
dry_run=self._dry_run,
|
|
205
|
+
)
|
|
206
|
+
if error:
|
|
207
|
+
self._errors.append((item, error))
|
|
208
|
+
else:
|
|
209
|
+
self._done_paths.add(item.path)
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
self._errors.append((item, str(exc)))
|
|
212
|
+
|
|
213
|
+
# Final flush.
|
|
214
|
+
flush_progress()
|
|
215
|
+
self.app.call_from_thread(self._finish)
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------ #
|
|
218
|
+
# UI update methods — always called from the main thread
|
|
219
|
+
# ------------------------------------------------------------------ #
|
|
220
|
+
|
|
221
|
+
def _set_current(self, idx: int, item: Item) -> None:
|
|
222
|
+
current = self.query_one("#progress-current", Static)
|
|
223
|
+
current.update(f"[{idx + 1}/{len(self._items)}] {item.display}")
|
|
224
|
+
|
|
225
|
+
def _add_progress(self, delta: int) -> None:
|
|
226
|
+
self._freed += delta
|
|
227
|
+
capped = min(self._freed, self._total_bytes)
|
|
228
|
+
bar = self.query_one("#progress-bar", ProgressBar)
|
|
229
|
+
bar.update(progress=capped)
|
|
230
|
+
stats = self.query_one("#progress-stats", Static)
|
|
231
|
+
stats.update(
|
|
232
|
+
f"{format_size(self._freed)} / {format_size(self._total_bytes)}"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def _finish(self) -> None:
|
|
236
|
+
self.dismiss(
|
|
237
|
+
ProgressResult(
|
|
238
|
+
freed=self._freed,
|
|
239
|
+
errors=self._errors,
|
|
240
|
+
done_paths=self._done_paths,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# ========================================================================= #
|
|
246
|
+
# Main application
|
|
247
|
+
# ========================================================================= #
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class BloatrApp(App):
|
|
251
|
+
"""Main bloatr Textual application."""
|
|
252
|
+
|
|
253
|
+
TITLE = "bloatr"
|
|
254
|
+
SUB_TITLE = "Kill your Mac's bloat"
|
|
255
|
+
|
|
256
|
+
CSS = """
|
|
257
|
+
DataTable {
|
|
258
|
+
height: 1fr;
|
|
259
|
+
}
|
|
260
|
+
#status {
|
|
261
|
+
height: 1;
|
|
262
|
+
background: $panel;
|
|
263
|
+
padding: 0 1;
|
|
264
|
+
color: $text-muted;
|
|
265
|
+
dock: bottom;
|
|
266
|
+
}
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
BINDINGS: ClassVar = [
|
|
270
|
+
Binding("j,down", "cursor_down", "Down", show=False),
|
|
271
|
+
Binding("k,up", "cursor_up", "Up", show=False),
|
|
272
|
+
Binding("space", "toggle_select", "Select"),
|
|
273
|
+
Binding("enter", "drill_in", "Explore"),
|
|
274
|
+
Binding("escape,backspace", "go_back", "Back"),
|
|
275
|
+
Binding("d", "delete_selected", "Delete"),
|
|
276
|
+
Binding("r", "rescan", "Rescan"),
|
|
277
|
+
Binding("q", "quit", "Quit"),
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
def __init__(
|
|
281
|
+
self,
|
|
282
|
+
items: list[Item],
|
|
283
|
+
dry_run: bool = False,
|
|
284
|
+
scan_root: Path | None = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
super().__init__()
|
|
287
|
+
self._items = items
|
|
288
|
+
self._dry_run = dry_run
|
|
289
|
+
self._scan_root = scan_root
|
|
290
|
+
self._stack: list[tuple[str, list[Item]]] = [] # (breadcrumb, items)
|
|
291
|
+
self._selected: set[Path] = set()
|
|
292
|
+
self._current_path: str = str(scan_root) if scan_root else "~"
|
|
293
|
+
|
|
294
|
+
# ------------------------------------------------------------------ #
|
|
295
|
+
# Composition & mount
|
|
296
|
+
# ------------------------------------------------------------------ #
|
|
297
|
+
|
|
298
|
+
def compose(self) -> ComposeResult:
|
|
299
|
+
yield Header(show_clock=False)
|
|
300
|
+
yield DataTable(id="table", cursor_type="row", zebra_stripes=True)
|
|
301
|
+
yield Label("", id="status")
|
|
302
|
+
yield Footer()
|
|
303
|
+
|
|
304
|
+
def on_mount(self) -> None:
|
|
305
|
+
table = self.query_one(DataTable)
|
|
306
|
+
table.add_column(" ", width=3, key="checkbox")
|
|
307
|
+
table.add_column("SIZE", width=12, key="size")
|
|
308
|
+
table.add_column("LOCATION", key="location")
|
|
309
|
+
self._load_items(self._items)
|
|
310
|
+
|
|
311
|
+
# ------------------------------------------------------------------ #
|
|
312
|
+
# Rendering
|
|
313
|
+
# ------------------------------------------------------------------ #
|
|
314
|
+
|
|
315
|
+
def _load_items(self, items: list[Item]) -> None:
|
|
316
|
+
"""Refresh the DataTable with *items* as the current view."""
|
|
317
|
+
self._items = items
|
|
318
|
+
table = self.query_one(DataTable)
|
|
319
|
+
table.clear()
|
|
320
|
+
|
|
321
|
+
if not items:
|
|
322
|
+
table.add_row("", "", "(no items)", key="__empty__")
|
|
323
|
+
self._set_status("No items in this view. Press ESC to go back or Q to quit.")
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
for item in items:
|
|
327
|
+
checkbox = "✓" if item.path in self._selected else "○"
|
|
328
|
+
if item.error:
|
|
329
|
+
size_str = "-- missing --"
|
|
330
|
+
else:
|
|
331
|
+
size_str = format_size(item.size)
|
|
332
|
+
explore = " ▶" if item.is_dir and not item.error else " "
|
|
333
|
+
table.add_row(checkbox, size_str, f"{item.display}{explore}", key=str(item.path))
|
|
334
|
+
|
|
335
|
+
self._update_status()
|
|
336
|
+
|
|
337
|
+
def _update_status(self) -> None:
|
|
338
|
+
count = len(self._selected)
|
|
339
|
+
total = sum(i.size for i in self._items if i.path in self._selected)
|
|
340
|
+
mode = " [DRY RUN]" if self._dry_run else ""
|
|
341
|
+
if count:
|
|
342
|
+
self._set_status(
|
|
343
|
+
f"{count} selected — {format_size(total)} to free | press D to delete{mode}"
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
self._set_status(
|
|
347
|
+
f"SPACE select • ENTER explore • ESC back • D delete • R rescan • Q quit{mode}"
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def _set_status(self, msg: str) -> None:
|
|
351
|
+
self.query_one("#status", Label).update(msg)
|
|
352
|
+
|
|
353
|
+
def _current_item(self) -> Item | None:
|
|
354
|
+
table = self.query_one(DataTable)
|
|
355
|
+
try:
|
|
356
|
+
coord = table.cursor_coordinate
|
|
357
|
+
row_key = table.coordinate_to_cell_key(coord).row_key
|
|
358
|
+
path_str = row_key.value
|
|
359
|
+
if path_str is None or path_str == "__empty__":
|
|
360
|
+
return None
|
|
361
|
+
return next((i for i in self._items if str(i.path) == path_str), None)
|
|
362
|
+
except Exception:
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
# ------------------------------------------------------------------ #
|
|
366
|
+
# Actions
|
|
367
|
+
# ------------------------------------------------------------------ #
|
|
368
|
+
|
|
369
|
+
def action_cursor_down(self) -> None:
|
|
370
|
+
self.query_one(DataTable).action_cursor_down()
|
|
371
|
+
|
|
372
|
+
def action_cursor_up(self) -> None:
|
|
373
|
+
self.query_one(DataTable).action_cursor_up()
|
|
374
|
+
|
|
375
|
+
def action_toggle_select(self) -> None:
|
|
376
|
+
item = self._current_item()
|
|
377
|
+
if item is None or item.error:
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
if item.path in self._selected:
|
|
381
|
+
self._selected.discard(item.path)
|
|
382
|
+
else:
|
|
383
|
+
self._selected.add(item.path)
|
|
384
|
+
|
|
385
|
+
# Update ONLY the checkbox cell — cursor position is preserved.
|
|
386
|
+
checkbox = "✓" if item.path in self._selected else "○"
|
|
387
|
+
table = self.query_one(DataTable)
|
|
388
|
+
table.update_cell(str(item.path), "checkbox", checkbox)
|
|
389
|
+
self._update_status()
|
|
390
|
+
|
|
391
|
+
# DataTable intercepts Enter internally and emits RowSelected before
|
|
392
|
+
# App-level key bindings fire — so we handle drill-in via the event,
|
|
393
|
+
# not the key binding. action_drill_in is kept as a documented fallback.
|
|
394
|
+
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
395
|
+
self._drill_into_key(event.row_key.value)
|
|
396
|
+
|
|
397
|
+
def action_drill_in(self) -> None:
|
|
398
|
+
item = self._current_item()
|
|
399
|
+
if item:
|
|
400
|
+
self._drill_into_key(str(item.path))
|
|
401
|
+
|
|
402
|
+
def _drill_into_key(self, path_str: str | None) -> None:
|
|
403
|
+
if path_str is None or path_str == "__empty__":
|
|
404
|
+
return
|
|
405
|
+
item = next((i for i in self._items if str(i.path) == path_str), None)
|
|
406
|
+
if item is None or not item.is_dir or item.error:
|
|
407
|
+
return
|
|
408
|
+
self._stack.append((self._current_path, self._items))
|
|
409
|
+
self._current_path = item.display
|
|
410
|
+
self._set_status(f"Scanning {item.display}…")
|
|
411
|
+
self._fetch_children(item)
|
|
412
|
+
|
|
413
|
+
@work(thread=True)
|
|
414
|
+
def _fetch_children(self, item: Item) -> None:
|
|
415
|
+
children = list_children(item)
|
|
416
|
+
self.call_from_thread(self._load_items, children)
|
|
417
|
+
|
|
418
|
+
def action_go_back(self) -> None:
|
|
419
|
+
if not self._stack:
|
|
420
|
+
return
|
|
421
|
+
prev_path, prev_items = self._stack.pop()
|
|
422
|
+
self._current_path = prev_path
|
|
423
|
+
self._load_items(prev_items)
|
|
424
|
+
|
|
425
|
+
def action_rescan(self) -> None:
|
|
426
|
+
"""Rescan from scratch — either the custom root or built-in locations."""
|
|
427
|
+
self._stack.clear()
|
|
428
|
+
self._current_path = str(self._scan_root) if self._scan_root else "~"
|
|
429
|
+
self._selected.clear()
|
|
430
|
+
self._set_status("Rescanning…")
|
|
431
|
+
self._do_rescan()
|
|
432
|
+
|
|
433
|
+
@work(thread=True)
|
|
434
|
+
def _do_rescan(self) -> None:
|
|
435
|
+
if self._scan_root:
|
|
436
|
+
root_item = Item.from_path(self._scan_root)
|
|
437
|
+
items = list_children(root_item)
|
|
438
|
+
else:
|
|
439
|
+
from bloatr.locations import get_locations
|
|
440
|
+
items = scan_locations(get_locations())
|
|
441
|
+
self.call_from_thread(self._load_items, items)
|
|
442
|
+
|
|
443
|
+
def action_delete_selected(self) -> None:
|
|
444
|
+
selected_items = [i for i in self._items if i.path in self._selected]
|
|
445
|
+
if not selected_items:
|
|
446
|
+
self._set_status("Nothing selected. Press SPACE on items first.")
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
total = sum(i.size for i in selected_items)
|
|
450
|
+
|
|
451
|
+
def handle_confirm(confirmed: bool | None) -> None:
|
|
452
|
+
if not confirmed:
|
|
453
|
+
self._set_status("Cancelled.")
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
def handle_progress(result: ProgressResult | None) -> None:
|
|
457
|
+
if result is None:
|
|
458
|
+
return
|
|
459
|
+
# Drop deleted items from current view
|
|
460
|
+
self._selected -= result.done_paths
|
|
461
|
+
self._items = [i for i in self._items if i.path not in result.done_paths]
|
|
462
|
+
self._load_items(self._items)
|
|
463
|
+
|
|
464
|
+
# Summarise
|
|
465
|
+
if self._dry_run:
|
|
466
|
+
self._set_status(
|
|
467
|
+
f"DRY RUN complete — would have freed {format_size(result.freed)}"
|
|
468
|
+
)
|
|
469
|
+
elif result.errors:
|
|
470
|
+
first = result.errors[0]
|
|
471
|
+
errmsg = f"{first[0].display}: {first[1]}"
|
|
472
|
+
self._set_status(
|
|
473
|
+
f"Freed {format_size(result.freed)} — {len(result.errors)} error(s). First: {errmsg}"
|
|
474
|
+
)
|
|
475
|
+
else:
|
|
476
|
+
self._set_status(
|
|
477
|
+
f"✓ Success! Freed {format_size(result.freed)} across {len(result.done_paths)} item(s)."
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
self.push_screen(
|
|
481
|
+
ProgressScreen(selected_items, self._dry_run),
|
|
482
|
+
handle_progress,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
self.push_screen(
|
|
486
|
+
ConfirmScreen(total, len(selected_items), self._dry_run),
|
|
487
|
+
handle_confirm,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# ========================================================================= #
|
|
492
|
+
# Entry point
|
|
493
|
+
# ========================================================================= #
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def run_tui(
|
|
497
|
+
items: list[Item],
|
|
498
|
+
dry_run: bool = False,
|
|
499
|
+
scan_root: Path | None = None,
|
|
500
|
+
) -> None:
|
|
501
|
+
"""Launch the bloatr TUI.
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
items: Pre-scanned list of Items to display on first paint.
|
|
505
|
+
dry_run: When True, deletions are simulated only.
|
|
506
|
+
scan_root: When set, rescans this directory instead of built-in locations.
|
|
507
|
+
"""
|
|
508
|
+
app = BloatrApp(items=items, dry_run=dry_run, scan_root=scan_root)
|
|
509
|
+
app.run()
|
bloatr/cleaner.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Safe deletion utilities for bloatr."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Callable
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from bloatr.models import Item
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class UnsafePathError(Exception):
|
|
15
|
+
"""Raised when a path fails the safety check and must not be deleted."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
MIN_DEPTH: int = 1 # Minimum number of path components below home directory.
|
|
19
|
+
|
|
20
|
+
# Top-level home subdirectories that must never be deleted outright.
|
|
21
|
+
_BLOCKLIST: frozenset[str] = frozenset({
|
|
22
|
+
"Library", "Documents", "Desktop", "Downloads",
|
|
23
|
+
"Pictures", "Music", "Movies", "Applications",
|
|
24
|
+
"Public", "Sites",
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
ProgressCallback = Callable[[int, str], None]
|
|
29
|
+
"""A callback that receives (bytes_delta, current_path) as deletion progresses."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_safe_path(path: Path) -> bool:
|
|
33
|
+
"""Return True only if *path* is safe to delete.
|
|
34
|
+
|
|
35
|
+
Safety criteria (all must hold):
|
|
36
|
+
|
|
37
|
+
1. The resolved path must be located inside the user's home directory.
|
|
38
|
+
2. The resolved path must be at least :data:`MIN_DEPTH` components below
|
|
39
|
+
the home directory.
|
|
40
|
+
3. The path must not be a bare top-level directory listed in ``_BLOCKLIST``
|
|
41
|
+
(``~/Library``, ``~/Documents``, etc.).
|
|
42
|
+
|
|
43
|
+
Symlinks are resolved before the check so that a symlink pointing outside
|
|
44
|
+
the home directory is correctly rejected.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
path: The path to check. Need not exist on disk.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
``True`` if the path passes the safety checks, ``False`` otherwise.
|
|
51
|
+
"""
|
|
52
|
+
home = Path.home().resolve()
|
|
53
|
+
resolved = path.resolve()
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
relative = resolved.relative_to(home)
|
|
57
|
+
except ValueError:
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
if len(relative.parts) < MIN_DEPTH:
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
if len(relative.parts) == 1 and relative.parts[0] in _BLOCKLIST:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def delete(item: "Item", dry_run: bool = False) -> None:
|
|
70
|
+
"""Delete the filesystem entry represented by *item* (no progress).
|
|
71
|
+
|
|
72
|
+
Simpler wrapper around :func:`delete_with_progress` used by the CLI's
|
|
73
|
+
non-interactive paths and tests.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
UnsafePathError: If ``item.path`` is not safe to delete.
|
|
77
|
+
OSError: If the underlying filesystem operation fails.
|
|
78
|
+
"""
|
|
79
|
+
if not is_safe_path(item.path):
|
|
80
|
+
raise UnsafePathError(f"Refusing to delete unsafe path: {item.path!r}")
|
|
81
|
+
|
|
82
|
+
if dry_run:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
if item.is_dir:
|
|
86
|
+
shutil.rmtree(item.path)
|
|
87
|
+
else:
|
|
88
|
+
item.path.unlink()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def delete_with_progress(
|
|
92
|
+
item: "Item",
|
|
93
|
+
on_progress: ProgressCallback | None = None,
|
|
94
|
+
dry_run: bool = False,
|
|
95
|
+
) -> tuple[int, str | None]:
|
|
96
|
+
"""Delete *item* file-by-file so progress can be streamed to a callback.
|
|
97
|
+
|
|
98
|
+
Walks the directory bottom-up using :func:`os.walk`, deleting individual
|
|
99
|
+
files and empty subdirectories. After each file is removed, the caller's
|
|
100
|
+
``on_progress`` callback is invoked with ``(bytes_freed_delta, path)`` so
|
|
101
|
+
the UI can update a progress bar in real time.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
item: The :class:`~bloatr.models.Item` to delete.
|
|
105
|
+
on_progress: Optional callback invoked with ``(bytes_delta, current_path)``
|
|
106
|
+
for every file removed (or once, with the total, in dry-run mode).
|
|
107
|
+
dry_run: When True, report progress but make no filesystem changes.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
A tuple ``(bytes_freed, first_error_or_None)``. Errors on individual
|
|
111
|
+
files are recorded and processing continues; only the first error
|
|
112
|
+
message is returned.
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
UnsafePathError: If ``item.path`` fails the safety check.
|
|
116
|
+
"""
|
|
117
|
+
if not is_safe_path(item.path):
|
|
118
|
+
raise UnsafePathError(f"Refusing to delete unsafe path: {item.path!r}")
|
|
119
|
+
|
|
120
|
+
if not item.path.exists() and not item.path.is_symlink():
|
|
121
|
+
return 0, "not found"
|
|
122
|
+
|
|
123
|
+
# Dry-run: just report the pre-computed size and bail.
|
|
124
|
+
if dry_run:
|
|
125
|
+
if on_progress:
|
|
126
|
+
on_progress(item.size, str(item.path))
|
|
127
|
+
return item.size, None
|
|
128
|
+
|
|
129
|
+
# Non-directory path (file or symlink).
|
|
130
|
+
if not item.path.is_dir() or item.path.is_symlink():
|
|
131
|
+
try:
|
|
132
|
+
size = item.path.lstat().st_size
|
|
133
|
+
item.path.unlink()
|
|
134
|
+
if on_progress:
|
|
135
|
+
on_progress(size, str(item.path))
|
|
136
|
+
return size, None
|
|
137
|
+
except OSError as exc:
|
|
138
|
+
return 0, str(exc)
|
|
139
|
+
|
|
140
|
+
# Directory: walk bottom-up, delete per-file.
|
|
141
|
+
freed = 0
|
|
142
|
+
first_error: str | None = None
|
|
143
|
+
walk_errors: list[str] = []
|
|
144
|
+
|
|
145
|
+
def on_walk_error(exc: OSError) -> None:
|
|
146
|
+
walk_errors.append(str(exc))
|
|
147
|
+
|
|
148
|
+
for root, dirs, files in os.walk(
|
|
149
|
+
item.path,
|
|
150
|
+
topdown=False,
|
|
151
|
+
followlinks=False,
|
|
152
|
+
onerror=on_walk_error,
|
|
153
|
+
):
|
|
154
|
+
for name in files:
|
|
155
|
+
fpath = os.path.join(root, name)
|
|
156
|
+
try:
|
|
157
|
+
st = os.lstat(fpath)
|
|
158
|
+
os.unlink(fpath)
|
|
159
|
+
freed += st.st_size
|
|
160
|
+
if on_progress:
|
|
161
|
+
on_progress(st.st_size, fpath)
|
|
162
|
+
except OSError as exc:
|
|
163
|
+
if first_error is None:
|
|
164
|
+
first_error = str(exc)
|
|
165
|
+
|
|
166
|
+
for name in dirs:
|
|
167
|
+
dpath = os.path.join(root, name)
|
|
168
|
+
try:
|
|
169
|
+
# rmdir only succeeds on empty dirs; that's the invariant after
|
|
170
|
+
# the files loop above.
|
|
171
|
+
os.rmdir(dpath)
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
if first_error is None:
|
|
174
|
+
first_error = str(exc)
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
os.rmdir(item.path)
|
|
178
|
+
except OSError as exc:
|
|
179
|
+
if first_error is None:
|
|
180
|
+
first_error = str(exc)
|
|
181
|
+
|
|
182
|
+
if first_error is None and walk_errors:
|
|
183
|
+
first_error = walk_errors[0]
|
|
184
|
+
|
|
185
|
+
return freed, first_error
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def delete_many(
|
|
189
|
+
items: list["Item"],
|
|
190
|
+
dry_run: bool = False,
|
|
191
|
+
) -> list[tuple["Item", Exception | None]]:
|
|
192
|
+
"""Delete multiple items, capturing any per-item errors.
|
|
193
|
+
|
|
194
|
+
Each item is deleted independently via :func:`delete`. If an exception
|
|
195
|
+
is raised for a particular item it is caught and recorded; processing
|
|
196
|
+
continues with the remaining items.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
items: List of :class:`~bloatr.models.Item` objects to delete.
|
|
200
|
+
dry_run: Passed through to :func:`delete` for every item.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
A list of ``(item, error)`` tuples in the same order as *items*.
|
|
204
|
+
``error`` is ``None`` when deletion succeeded, or the exception
|
|
205
|
+
instance when it failed.
|
|
206
|
+
"""
|
|
207
|
+
results: list[tuple[Item, Exception | None]] = []
|
|
208
|
+
|
|
209
|
+
for item in items:
|
|
210
|
+
try:
|
|
211
|
+
delete(item, dry_run=dry_run)
|
|
212
|
+
results.append((item, None))
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
results.append((item, exc))
|
|
215
|
+
|
|
216
|
+
return results
|
bloatr/cli.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""CLI entry point for bloatr."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from bloatr import __version__
|
|
12
|
+
from bloatr.app import run_tui
|
|
13
|
+
from bloatr.locations import get_locations
|
|
14
|
+
from bloatr.models import Item
|
|
15
|
+
from bloatr.scanner import list_children, scan_locations
|
|
16
|
+
from bloatr.sizes import format_size, parse_size
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main() -> None:
|
|
20
|
+
"""Parse CLI flags, run the scanner, then launch TUI or JSON output."""
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="bloatr",
|
|
23
|
+
description="Kill your Mac's bloat. Select. Delete. Done.",
|
|
24
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
25
|
+
epilog="""
|
|
26
|
+
Examples:
|
|
27
|
+
bloatr # launch interactive TUI (built-in locations)
|
|
28
|
+
bloatr ~/ # scan home directory children
|
|
29
|
+
bloatr ~/Documents # scan a specific folder's children
|
|
30
|
+
bloatr --dry-run # TUI, but don't actually delete anything
|
|
31
|
+
bloatr --min-size 1G # only show items larger than 1 GB
|
|
32
|
+
bloatr --json # print JSON to stdout (no TUI)
|
|
33
|
+
bloatr --json | jq '.[].size_human'
|
|
34
|
+
""",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--dry-run",
|
|
38
|
+
action="store_true",
|
|
39
|
+
help="Show what would be deleted without actually deleting",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--min-size",
|
|
43
|
+
default=None,
|
|
44
|
+
metavar="SIZE",
|
|
45
|
+
help="Only show items larger than SIZE (e.g. 500M, 1G, 2T)",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--json",
|
|
49
|
+
action="store_true",
|
|
50
|
+
dest="json_output",
|
|
51
|
+
help="Output scan results as JSON and exit (non-interactive)",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--version",
|
|
55
|
+
action="version",
|
|
56
|
+
version=f"bloatr {__version__}",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"path",
|
|
60
|
+
nargs="?",
|
|
61
|
+
default=None,
|
|
62
|
+
metavar="PATH",
|
|
63
|
+
help="Scan this directory's children instead of the built-in developer locations (e.g. ~/ or ~/Documents)",
|
|
64
|
+
)
|
|
65
|
+
args = parser.parse_args()
|
|
66
|
+
|
|
67
|
+
# Validate --min-size
|
|
68
|
+
min_size = 0
|
|
69
|
+
if args.min_size:
|
|
70
|
+
try:
|
|
71
|
+
min_size = parse_size(args.min_size)
|
|
72
|
+
except ValueError as exc:
|
|
73
|
+
print(f"bloatr: invalid --min-size value: {exc}", file=sys.stderr)
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
|
|
76
|
+
# Scan — either a user-supplied path or the built-in developer locations.
|
|
77
|
+
scan_root: Path | None = None
|
|
78
|
+
|
|
79
|
+
if args.path:
|
|
80
|
+
scan_root = Path(args.path).expanduser().resolve()
|
|
81
|
+
if not scan_root.exists():
|
|
82
|
+
print(f"bloatr: path does not exist: {scan_root}", file=sys.stderr)
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
if not scan_root.is_dir():
|
|
85
|
+
print(f"bloatr: not a directory: {scan_root}", file=sys.stderr)
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
if not args.json_output:
|
|
88
|
+
print(f"bloatr: scanning {scan_root}…", file=sys.stderr)
|
|
89
|
+
root_item = Item.from_path(scan_root)
|
|
90
|
+
items = list_children(root_item)
|
|
91
|
+
else:
|
|
92
|
+
locations = get_locations()
|
|
93
|
+
if not args.json_output:
|
|
94
|
+
print("bloatr: scanning…", file=sys.stderr)
|
|
95
|
+
items = scan_locations(locations)
|
|
96
|
+
|
|
97
|
+
# Filter by min-size
|
|
98
|
+
if min_size > 0:
|
|
99
|
+
items = [i for i in items if i.size >= min_size]
|
|
100
|
+
|
|
101
|
+
# JSON mode — print and exit
|
|
102
|
+
if args.json_output:
|
|
103
|
+
data = [
|
|
104
|
+
{
|
|
105
|
+
"path": str(item.path),
|
|
106
|
+
"display": item.display,
|
|
107
|
+
"size": item.size,
|
|
108
|
+
"size_human": format_size(item.size),
|
|
109
|
+
"is_dir": item.is_dir,
|
|
110
|
+
"error": item.error,
|
|
111
|
+
}
|
|
112
|
+
for item in items
|
|
113
|
+
]
|
|
114
|
+
print(json.dumps(data, indent=2))
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
# Interactive TUI
|
|
118
|
+
run_tui(items, dry_run=args.dry_run, scan_root=scan_root)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__":
|
|
122
|
+
main()
|
bloatr/locations.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Known macOS developer bloat locations for bloatr."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
KNOWN_PATHS: tuple[str, ...] = (
|
|
9
|
+
"~/Library/Developer/Xcode/DerivedData",
|
|
10
|
+
"~/Library/Developer/Xcode/Archives",
|
|
11
|
+
"~/Library/Developer/CoreSimulator/Runtimes",
|
|
12
|
+
"~/Library/Caches",
|
|
13
|
+
"~/Library/Developer/Xcode/iOS DeviceSupport",
|
|
14
|
+
"~/.gradle/caches",
|
|
15
|
+
"~/.npm",
|
|
16
|
+
"~/.yarn/cache",
|
|
17
|
+
"~/.pnpm-store",
|
|
18
|
+
"~/.cocoapods/repos",
|
|
19
|
+
"~/.cargo/registry",
|
|
20
|
+
"~/.cargo/git",
|
|
21
|
+
"~/.pub-cache",
|
|
22
|
+
"~/go/pkg/mod",
|
|
23
|
+
"~/.expo",
|
|
24
|
+
"~/Library/Android/sdk",
|
|
25
|
+
"~/Library/Application Support/JetBrains",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _brew_cache() -> Path | None:
|
|
30
|
+
"""Return the Homebrew cache directory, or None if brew is unavailable.
|
|
31
|
+
|
|
32
|
+
Runs ``brew --cache`` with a 5-second timeout. Any exception (including
|
|
33
|
+
FileNotFoundError when brew is not installed, TimeoutExpired, or a
|
|
34
|
+
non-zero exit code) results in None being returned.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Path to the brew cache directory, or None.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["brew", "--cache"],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
timeout=5,
|
|
45
|
+
)
|
|
46
|
+
if result.returncode == 0:
|
|
47
|
+
cache_path = result.stdout.strip()
|
|
48
|
+
if cache_path:
|
|
49
|
+
return Path(cache_path)
|
|
50
|
+
return None
|
|
51
|
+
except Exception:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_locations() -> list[Path]:
|
|
56
|
+
"""Return the full list of developer bloat paths to scan.
|
|
57
|
+
|
|
58
|
+
Expands all entries in ``KNOWN_PATHS`` and appends the Homebrew cache
|
|
59
|
+
directory if ``brew`` is available.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
List of absolute ``Path`` objects (paths may or may not exist on disk).
|
|
63
|
+
"""
|
|
64
|
+
locations: list[Path] = [Path(p).expanduser() for p in KNOWN_PATHS]
|
|
65
|
+
|
|
66
|
+
brew = _brew_cache()
|
|
67
|
+
if brew is not None:
|
|
68
|
+
locations.append(brew)
|
|
69
|
+
|
|
70
|
+
return locations
|
bloatr/models.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Data models for bloatr."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from bloatr.sizes import format_size
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Item:
|
|
13
|
+
"""Represents a single filesystem entry that bloatr can inspect or delete.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
path: Absolute path to the entry.
|
|
17
|
+
display: Path in ``~/...`` form relative to the user's home directory.
|
|
18
|
+
size: Size in bytes; 0 when the entry is missing or the size could not
|
|
19
|
+
be determined.
|
|
20
|
+
is_dir: True when the entry is a directory.
|
|
21
|
+
error: Human-readable error message if something went wrong during
|
|
22
|
+
scanning; None otherwise.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
path: Path
|
|
26
|
+
display: str
|
|
27
|
+
size: int
|
|
28
|
+
is_dir: bool
|
|
29
|
+
error: str | None = None
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_path(cls, path: Path) -> "Item":
|
|
33
|
+
"""Construct an Item from an absolute path.
|
|
34
|
+
|
|
35
|
+
The ``display`` field is computed by replacing the user's home
|
|
36
|
+
directory prefix with ``~``. ``size`` is initialised to 0; callers
|
|
37
|
+
are responsible for populating real size data via a scanner.
|
|
38
|
+
``is_dir`` reflects whether *path* is a directory at construction
|
|
39
|
+
time.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
path: Absolute path to the filesystem entry.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
A new :class:`Item` with ``size=0`` and ``error=None``.
|
|
46
|
+
"""
|
|
47
|
+
home = Path.home()
|
|
48
|
+
try:
|
|
49
|
+
relative = path.relative_to(home)
|
|
50
|
+
display = f"~/{relative}"
|
|
51
|
+
except ValueError:
|
|
52
|
+
display = str(path)
|
|
53
|
+
|
|
54
|
+
is_dir = path.is_dir()
|
|
55
|
+
|
|
56
|
+
return cls(
|
|
57
|
+
path=path,
|
|
58
|
+
display=display,
|
|
59
|
+
size=0,
|
|
60
|
+
is_dir=is_dir,
|
|
61
|
+
error=None,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def size_display(self) -> str:
|
|
65
|
+
"""Return the size formatted as a human-readable string.
|
|
66
|
+
|
|
67
|
+
Delegates to :func:`bloatr.sizes.format_size`.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Human-readable size string, e.g. ``"1.5 GB"``.
|
|
71
|
+
"""
|
|
72
|
+
return format_size(self.size)
|
bloatr/scanner.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Concurrent directory scanner for bloatr."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from bloatr.models import Item
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def compute_size(path: Path) -> tuple[int, str | None]:
|
|
13
|
+
"""Compute the total size of a directory tree iteratively.
|
|
14
|
+
|
|
15
|
+
Uses an explicit stack to avoid hitting Python's recursion limit on deep
|
|
16
|
+
directory trees. Symlinks are skipped entirely. Per-entry permission
|
|
17
|
+
errors are swallowed; the first error message is returned alongside the
|
|
18
|
+
partial size.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
path: Root directory to measure.
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
A ``(total_bytes, first_error_or_None)`` tuple.
|
|
25
|
+
"""
|
|
26
|
+
if not path.exists():
|
|
27
|
+
return 0, "not found"
|
|
28
|
+
|
|
29
|
+
if path.is_file():
|
|
30
|
+
try:
|
|
31
|
+
return path.stat().st_size, None
|
|
32
|
+
except OSError as exc:
|
|
33
|
+
return 0, str(exc)
|
|
34
|
+
|
|
35
|
+
total = 0
|
|
36
|
+
first_error: str | None = None
|
|
37
|
+
stack: list[str] = [str(path)]
|
|
38
|
+
|
|
39
|
+
while stack:
|
|
40
|
+
current = stack.pop()
|
|
41
|
+
try:
|
|
42
|
+
with os.scandir(current) as it:
|
|
43
|
+
for entry in it:
|
|
44
|
+
if entry.is_symlink():
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
if entry.is_dir(follow_symlinks=False):
|
|
48
|
+
stack.append(entry.path)
|
|
49
|
+
else:
|
|
50
|
+
total += entry.stat(follow_symlinks=False).st_size
|
|
51
|
+
except (PermissionError, OSError) as exc:
|
|
52
|
+
if first_error is None:
|
|
53
|
+
first_error = str(exc)
|
|
54
|
+
except (PermissionError, OSError) as exc:
|
|
55
|
+
if first_error is None:
|
|
56
|
+
first_error = str(exc)
|
|
57
|
+
|
|
58
|
+
return total, first_error
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _size_one(path: Path) -> Item:
|
|
62
|
+
"""Size a single location and return a complete Item."""
|
|
63
|
+
base = Item.from_path(path)
|
|
64
|
+
|
|
65
|
+
if not path.exists():
|
|
66
|
+
return Item(
|
|
67
|
+
path=path,
|
|
68
|
+
display=base.display,
|
|
69
|
+
size=0,
|
|
70
|
+
is_dir=False,
|
|
71
|
+
error="not found",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
size, error = compute_size(path)
|
|
75
|
+
return Item(
|
|
76
|
+
path=path,
|
|
77
|
+
display=base.display,
|
|
78
|
+
size=size,
|
|
79
|
+
is_dir=path.is_dir(),
|
|
80
|
+
error=error,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def scan_locations(paths: list[Path], max_workers: int = 8) -> list[Item]:
|
|
85
|
+
"""Scan a list of locations concurrently and return Items sorted by size.
|
|
86
|
+
|
|
87
|
+
Each path is measured in a separate thread. Missing paths produce an Item
|
|
88
|
+
with ``error="not found"`` and ``size=0``.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
paths: Absolute paths to scan.
|
|
92
|
+
max_workers: Thread pool size (default 8).
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of :class:`~bloatr.models.Item` objects sorted descending by size.
|
|
96
|
+
"""
|
|
97
|
+
results: list[Item] = []
|
|
98
|
+
|
|
99
|
+
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
100
|
+
future_to_path = {pool.submit(_size_one, p): p for p in paths}
|
|
101
|
+
for future in as_completed(future_to_path):
|
|
102
|
+
try:
|
|
103
|
+
results.append(future.result())
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
p = future_to_path[future]
|
|
106
|
+
base = Item.from_path(p)
|
|
107
|
+
results.append(
|
|
108
|
+
Item(
|
|
109
|
+
path=p,
|
|
110
|
+
display=base.display,
|
|
111
|
+
size=0,
|
|
112
|
+
is_dir=False,
|
|
113
|
+
error=str(exc),
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
results.sort(key=lambda i: i.size, reverse=True)
|
|
118
|
+
return results
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def list_children(item: Item) -> list[Item]:
|
|
122
|
+
"""Return immediate children of a directory item, sorted by size desc.
|
|
123
|
+
|
|
124
|
+
Each child is sized individually. Non-directory items and missing paths
|
|
125
|
+
return an empty list.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
item: The parent directory item to drill into.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Sorted list of child :class:`~bloatr.models.Item` objects.
|
|
132
|
+
"""
|
|
133
|
+
if not item.is_dir or not item.path.exists():
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
children: list[Path] = []
|
|
137
|
+
try:
|
|
138
|
+
with os.scandir(item.path) as it:
|
|
139
|
+
for entry in it:
|
|
140
|
+
if not entry.name.startswith(".") or True: # show hidden too
|
|
141
|
+
children.append(Path(entry.path))
|
|
142
|
+
except (PermissionError, OSError):
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
return scan_locations(children)
|
bloatr/sizes.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Size parsing and formatting utilities for bloatr."""
|
|
2
|
+
|
|
3
|
+
_SUFFIXES: dict[str, int] = {
|
|
4
|
+
"K": 1024,
|
|
5
|
+
"M": 1024**2,
|
|
6
|
+
"G": 1024**3,
|
|
7
|
+
"T": 1024**4,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
_FORMAT_UNITS: list[tuple[str, float]] = [
|
|
11
|
+
("TB", 1024.0**4),
|
|
12
|
+
("GB", 1024.0**3),
|
|
13
|
+
("MB", 1024.0**2),
|
|
14
|
+
("KB", 1024.0),
|
|
15
|
+
("B", 1.0),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_size(s: str) -> int:
|
|
20
|
+
"""Parse a human-readable size string into bytes.
|
|
21
|
+
|
|
22
|
+
Supports suffixes K, M, G, T (case-insensitive) and bare integers.
|
|
23
|
+
Examples: "500M", "1.5G", "100", "2T", "10K".
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
s: Size string to parse.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Size in bytes as an integer.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ValueError: If the string is empty, has an unknown suffix, or is
|
|
33
|
+
otherwise malformed.
|
|
34
|
+
"""
|
|
35
|
+
if not s:
|
|
36
|
+
raise ValueError("Empty size string")
|
|
37
|
+
|
|
38
|
+
upper = s.strip().upper()
|
|
39
|
+
|
|
40
|
+
if upper[-1] in _SUFFIXES:
|
|
41
|
+
multiplier = _SUFFIXES[upper[-1]]
|
|
42
|
+
numeric_part = upper[:-1]
|
|
43
|
+
else:
|
|
44
|
+
multiplier = 1
|
|
45
|
+
numeric_part = upper
|
|
46
|
+
|
|
47
|
+
if not numeric_part:
|
|
48
|
+
raise ValueError(f"No numeric component in size string: {s!r}")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
value = float(numeric_part)
|
|
52
|
+
except ValueError:
|
|
53
|
+
raise ValueError(f"Invalid size string: {s!r}")
|
|
54
|
+
|
|
55
|
+
return int(value * multiplier)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_size(n: int) -> str:
|
|
59
|
+
"""Format a byte count as a human-readable string.
|
|
60
|
+
|
|
61
|
+
Returns values like "0 B", "1.5 KB", "24.3 GB". Values below 1 KB are
|
|
62
|
+
shown without a decimal place; everything else uses one decimal place.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
n: Number of bytes (non-negative integer).
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Human-readable size string.
|
|
69
|
+
"""
|
|
70
|
+
if n == 0:
|
|
71
|
+
return "0 B"
|
|
72
|
+
|
|
73
|
+
for unit, threshold in _FORMAT_UNITS:
|
|
74
|
+
if n >= threshold:
|
|
75
|
+
value = n / threshold
|
|
76
|
+
if unit == "B":
|
|
77
|
+
return f"{n} B"
|
|
78
|
+
return f"{value:.1f} {unit}"
|
|
79
|
+
|
|
80
|
+
return f"{n} B"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bloatr
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Kill your Mac's bloat. Select. Delete. Done.
|
|
5
|
+
Project-URL: Homepage, https://github.com/suhasdinesh/bloatr
|
|
6
|
+
Project-URL: Repository, https://github.com/suhasdinesh/bloatr
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/suhasdinesh/bloatr/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: cleanup,developer,disk,macos,storage
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: textual>=0.60
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# bloatr
|
|
15
|
+
|
|
16
|
+
> Kill your Mac's bloat. Select. Delete. Done.
|
|
17
|
+
|
|
18
|
+
A dead-simple macOS CLI that finds your biggest developer cache directories, lets you browse them, and deletes what you don't need — with a clean terminal UI.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
SIZE LOCATION
|
|
22
|
+
24.3 GB ▶ ~/Library/Developer/Xcode/DerivedData
|
|
23
|
+
11.2 GB ~/Library/Caches
|
|
24
|
+
4.1 GB ~/Library/Android/sdk
|
|
25
|
+
3.1 GB ~/.expo
|
|
26
|
+
1.9 GB ~/.gradle/caches
|
|
27
|
+
515 MB ~/.npm
|
|
28
|
+
|
|
29
|
+
space:select enter:explore esc:back d:delete q:quit
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pipx install bloatr
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or with pip:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install bloatr
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Launch interactive TUI (scans known developer bloat locations)
|
|
52
|
+
bloatr
|
|
53
|
+
|
|
54
|
+
# Scan any directory — like `du` but interactive
|
|
55
|
+
bloatr ~/
|
|
56
|
+
bloatr ~/Documents
|
|
57
|
+
bloatr ~/Library/Caches
|
|
58
|
+
|
|
59
|
+
# Preview what would be deleted (safe mode)
|
|
60
|
+
bloatr --dry-run
|
|
61
|
+
|
|
62
|
+
# Only show items > 1 GB
|
|
63
|
+
bloatr --min-size 1G
|
|
64
|
+
|
|
65
|
+
# JSON output (pipe-friendly)
|
|
66
|
+
bloatr --json
|
|
67
|
+
bloatr --json ~/Library | jq '.[].size_human'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## What it scans
|
|
73
|
+
|
|
74
|
+
| Location | What it is |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `~/Library/Developer/Xcode/DerivedData` | Xcode build artifacts |
|
|
77
|
+
| `~/Library/Developer/Xcode/Archives` | Xcode app archives |
|
|
78
|
+
| `~/Library/Developer/CoreSimulator/Runtimes` | iOS/watchOS simulator runtimes |
|
|
79
|
+
| `~/Library/Developer/Xcode/iOS DeviceSupport` | Per-device debug symbols |
|
|
80
|
+
| `~/Library/Caches` | App caches |
|
|
81
|
+
| `~/.gradle/caches` | Gradle build cache |
|
|
82
|
+
| `~/.npm` | npm package cache |
|
|
83
|
+
| `~/.yarn/cache` | Yarn package cache |
|
|
84
|
+
| `~/.pnpm-store` | pnpm content-addressable store |
|
|
85
|
+
| `~/.cocoapods/repos` | CocoaPods spec repos |
|
|
86
|
+
| `~/.cargo/registry` | Rust crate registry |
|
|
87
|
+
| `~/.cargo/git` | Rust git dependencies |
|
|
88
|
+
| `~/.pub-cache` | Flutter/Dart package cache |
|
|
89
|
+
| `~/go/pkg/mod` | Go module cache |
|
|
90
|
+
| `~/.expo` | Expo/React Native cache |
|
|
91
|
+
| `~/Library/Android/sdk` | Android SDK |
|
|
92
|
+
| `~/Library/Application Support/JetBrains` | JetBrains IDE caches |
|
|
93
|
+
| Homebrew cache | `brew --cache` output |
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Keyboard shortcuts
|
|
98
|
+
|
|
99
|
+
| Key | Action |
|
|
100
|
+
|---|---|
|
|
101
|
+
| `j` / `↓` | Move down |
|
|
102
|
+
| `k` / `↑` | Move up |
|
|
103
|
+
| `space` | Toggle select |
|
|
104
|
+
| `enter` | Drill into folder |
|
|
105
|
+
| `esc` / `backspace` | Go back |
|
|
106
|
+
| `d` | Delete selected items |
|
|
107
|
+
| `r` | Rescan from scratch |
|
|
108
|
+
| `q` | Quit |
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Safety
|
|
113
|
+
|
|
114
|
+
bloatr will **never** delete:
|
|
115
|
+
- Anything outside your home directory (`~`)
|
|
116
|
+
- Top-level protected directories (`~/Library`, `~/Documents`, `~/Desktop`, etc.)
|
|
117
|
+
- The home directory itself
|
|
118
|
+
|
|
119
|
+
Every deletion requires an explicit confirmation prompt. Use `--dry-run` to preview without touching anything.
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Credits
|
|
124
|
+
|
|
125
|
+
Idea and direction by a human who lost 100 GB to Mac bloat one too many times. Code written entirely by [Claude Code](https://claude.ai/code).
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
bloatr/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
bloatr/__main__.py,sha256=T2828FUbE1pmpVsrPJJhUhRD-Eq1DAlndk3_JNtp5Yo,36
|
|
3
|
+
bloatr/app.py,sha256=X1A3KiZk2vGfJdlnho0rx3OG9tKthLLzWLNm816wPvc,17245
|
|
4
|
+
bloatr/cleaner.py,sha256=zmkJH4DqaEdAij-MEByhkm5v4l4LfGI26W23q6I3I0M,6681
|
|
5
|
+
bloatr/cli.py,sha256=PPe-BsIpWm3YAxpsz6GCRmHTuJgAtoWQdghniHfa1oA,3809
|
|
6
|
+
bloatr/locations.py,sha256=rd4mHNy_nJqMCmUhpYYr3rcScUAvWQt-p0k820QkRos,1923
|
|
7
|
+
bloatr/models.py,sha256=aPeBbKteH7CFNqSi3XNI0d2oqEBvsFiiRBFwOzqMQ-Q,2035
|
|
8
|
+
bloatr/scanner.py,sha256=v7zju64xgxGd_CuoqOAl2hzMuVEvC6x9t_WAwNdc_io,4313
|
|
9
|
+
bloatr/sizes.py,sha256=ODG-un7OngJohe0-jo7ma6J1bo5Yw0cdMbOAXDRi31Q,1882
|
|
10
|
+
bloatr-0.1.0.dist-info/METADATA,sha256=lw81FNqc6SJWgpuklVxYd2vYYNRjJBR5I-L-fBBpJZ8,3210
|
|
11
|
+
bloatr-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
12
|
+
bloatr-0.1.0.dist-info/entry_points.txt,sha256=xKF1DYLsd354F_ta45lGfbjDf9cxqkVaKC0GLHCX-oQ,43
|
|
13
|
+
bloatr-0.1.0.dist-info/RECORD,,
|