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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
bloatr/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from bloatr.cli import main
2
+
3
+ main()
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bloatr = bloatr.cli:main