devklean 1.0.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.
- devklean/__init__.py +6 -0
- devklean/__main__.py +4 -0
- devklean/_version.py +8 -0
- devklean/cli/__init__.py +3 -0
- devklean/cli/commands/__init__.py +4 -0
- devklean/cli/commands/clean.py +106 -0
- devklean/cli/commands/common.py +38 -0
- devklean/cli/commands/doctor.py +43 -0
- devklean/cli/commands/history.py +14 -0
- devklean/cli/commands/restore.py +20 -0
- devklean/cli/commands/scan.py +16 -0
- devklean/cli/confirmation.py +51 -0
- devklean/cli/dispatcher.py +45 -0
- devklean/cli/main.py +61 -0
- devklean/cli/parser.py +164 -0
- devklean/config/__init__.py +15 -0
- devklean/config/defaults.py +14 -0
- devklean/config/manager.py +226 -0
- devklean/config/models.py +48 -0
- devklean/config/paths.py +24 -0
- devklean/config/targets.py +23 -0
- devklean/deletion/__init__.py +17 -0
- devklean/deletion/history.py +66 -0
- devklean/deletion/integrity.py +39 -0
- devklean/deletion/metadata.py +198 -0
- devklean/deletion/paths.py +24 -0
- devklean/deletion/safety.py +176 -0
- devklean/deletion/trash.py +89 -0
- devklean/formatting.py +31 -0
- devklean/logging_setup.py +74 -0
- devklean/models.py +34 -0
- devklean/output/__init__.py +5 -0
- devklean/output/base.py +38 -0
- devklean/output/console.py +94 -0
- devklean/output/history_payload.py +34 -0
- devklean/output/json.py +75 -0
- devklean/output/scan_payload.py +51 -0
- devklean/output/sorting.py +12 -0
- devklean/output/text.py +183 -0
- devklean/output/theme.py +46 -0
- devklean/scanner/__init__.py +17 -0
- devklean/scanner/filters.py +32 -0
- devklean/scanner/scanner.py +170 -0
- devklean/tui.py +126 -0
- devklean-1.0.0.dist-info/METADATA +257 -0
- devklean-1.0.0.dist-info/RECORD +49 -0
- devklean-1.0.0.dist-info/WHEEL +4 -0
- devklean-1.0.0.dist-info/entry_points.txt +2 -0
- devklean-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Sequence
|
|
4
|
+
|
|
5
|
+
from devklean.formatting import format_size
|
|
6
|
+
from devklean.models import CleanableItem
|
|
7
|
+
from devklean.output.sorting import items_by_size_desc
|
|
8
|
+
|
|
9
|
+
SCAN_OUTPUT_VERSION = "1.0"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_scan_payload(
|
|
13
|
+
root: str,
|
|
14
|
+
items: list[CleanableItem],
|
|
15
|
+
permission_errors: Sequence[str] = (),
|
|
16
|
+
) -> dict:
|
|
17
|
+
"""Build the canonical scan result structure for renderers and tooling."""
|
|
18
|
+
sorted_items = items_by_size_desc(items)
|
|
19
|
+
total_size = sum(item.size for item in items)
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
"version": SCAN_OUTPUT_VERSION,
|
|
23
|
+
"root": root,
|
|
24
|
+
"items": [serialize_cleanable_item(item) for item in sorted_items],
|
|
25
|
+
"permission_errors": list(permission_errors),
|
|
26
|
+
"summary": {
|
|
27
|
+
"count": len(sorted_items),
|
|
28
|
+
"total_size": total_size,
|
|
29
|
+
"formatted_total_size": format_size(total_size),
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def serialize_cleanable_item(item: CleanableItem) -> dict:
|
|
35
|
+
return {
|
|
36
|
+
"path": item.path,
|
|
37
|
+
"display_name": item.display_label,
|
|
38
|
+
"size": item.size,
|
|
39
|
+
"formatted_size": format_size(item.size),
|
|
40
|
+
"type": item.name,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def build_error_payload(code: str, message: str) -> dict:
|
|
45
|
+
return {
|
|
46
|
+
"version": SCAN_OUTPUT_VERSION,
|
|
47
|
+
"error": {
|
|
48
|
+
"code": code,
|
|
49
|
+
"message": message,
|
|
50
|
+
},
|
|
51
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from devklean.models import CleanableItem
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def items_by_size_desc(items: list[CleanableItem]) -> list[CleanableItem]:
|
|
7
|
+
"""Return items sorted largest-first by size.
|
|
8
|
+
|
|
9
|
+
Safe: sort is stable; equal sizes retain relative order. Used by all
|
|
10
|
+
output paths so sort logic lives in one place without affecting scan order.
|
|
11
|
+
"""
|
|
12
|
+
return sorted(items, key=lambda item: item.size, reverse=True)
|
devklean/output/text.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Sequence
|
|
4
|
+
|
|
5
|
+
from devklean.deletion.history import HistoryOperation
|
|
6
|
+
from devklean.deletion.integrity import IntegrityReport
|
|
7
|
+
from devklean.formatting import format_size, format_timestamp
|
|
8
|
+
from devklean.models import CleanableItem, DeleteResult
|
|
9
|
+
from devklean.output.console import SYM_ERROR, SYM_SUCCESS, Console
|
|
10
|
+
from devklean.output.sorting import items_by_size_desc
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TextRenderer:
|
|
14
|
+
"""Color-gated terminal output with a standardized symbol scheme."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, console: Console | None = None) -> None:
|
|
17
|
+
self._console = console if console is not None else Console()
|
|
18
|
+
|
|
19
|
+
def _println(self, text: str = "") -> None:
|
|
20
|
+
self._console.plain(text)
|
|
21
|
+
|
|
22
|
+
def scan_start(self, root: str) -> None:
|
|
23
|
+
c = self._console
|
|
24
|
+
banner = f"{c.paint('devklean', 'bold')} {c.paint(f'scanning {root}...', 'detail')}"
|
|
25
|
+
self._println(f"\n{banner}\n")
|
|
26
|
+
|
|
27
|
+
def scan_summary(self, items: list[CleanableItem]) -> int:
|
|
28
|
+
c = self._console
|
|
29
|
+
total_size = sum(item.size for item in items)
|
|
30
|
+
|
|
31
|
+
self._println(f"{'TYPE':<18} {'SIZE':>8} {'PATH'}")
|
|
32
|
+
self._println(c.paint("─" * 70, "detail"))
|
|
33
|
+
|
|
34
|
+
for item in items_by_size_desc(items):
|
|
35
|
+
role = "error" if item.size > 50 * 1024 * 1024 else "warning"
|
|
36
|
+
self._println(
|
|
37
|
+
f"{c.paint(f'{item.display_label:<18}', 'detail')} "
|
|
38
|
+
f"{c.paint(f'{format_size(item.size):>8}', role)} "
|
|
39
|
+
f"{c.paint(item.path, 'detail')}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
self._println(c.paint("─" * 70, "detail"))
|
|
43
|
+
total = c.paint(c.paint(f"{format_size(total_size):>8}", "bold"), "error")
|
|
44
|
+
self._println(f"{'TOTAL':<18} {total}\n")
|
|
45
|
+
|
|
46
|
+
return total_size
|
|
47
|
+
|
|
48
|
+
def nothing_to_clean(self) -> None:
|
|
49
|
+
self._console.success("Nothing to clean.")
|
|
50
|
+
self._println()
|
|
51
|
+
|
|
52
|
+
def dry_run_nothing_deleted(self) -> None:
|
|
53
|
+
self._console.warning("[dry-run] would delete nothing.")
|
|
54
|
+
self._println()
|
|
55
|
+
|
|
56
|
+
def dry_run_selected(self, count: int) -> None:
|
|
57
|
+
word = "directory" if count == 1 else "directories"
|
|
58
|
+
self._console.warning(f"[dry-run] would delete {count} {word}; nothing deleted.")
|
|
59
|
+
self._println()
|
|
60
|
+
|
|
61
|
+
def aborted(self) -> None:
|
|
62
|
+
self._println()
|
|
63
|
+
self._console.detail("Aborted. Nothing deleted.")
|
|
64
|
+
self._println()
|
|
65
|
+
|
|
66
|
+
def no_items_selected(self) -> None:
|
|
67
|
+
self._println()
|
|
68
|
+
self._console.detail("No items selected. Nothing deleted.")
|
|
69
|
+
self._println()
|
|
70
|
+
|
|
71
|
+
def invalid_directory(self, path: str) -> None:
|
|
72
|
+
self._console.error(f"'{path}' is not a directory.")
|
|
73
|
+
|
|
74
|
+
def confirm_prompt(self, count: int, total_size: int = 0) -> str:
|
|
75
|
+
word = "directory" if count == 1 else "directories"
|
|
76
|
+
return (
|
|
77
|
+
f"{self._console.paint('Delete', 'bold')} {count} {word} "
|
|
78
|
+
f"(~{format_size(total_size)})? "
|
|
79
|
+
f"{self._console.paint('(y/N)', 'detail')} "
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def deletion_result(self, result: DeleteResult) -> None:
|
|
83
|
+
c = self._console
|
|
84
|
+
self._println()
|
|
85
|
+
for path in result.deleted:
|
|
86
|
+
self._println(f" {c.paint(SYM_SUCCESS, 'success')} {c.paint(path, 'detail')}")
|
|
87
|
+
for failure in result.failed:
|
|
88
|
+
self._println(f" {c.paint(SYM_ERROR, 'error')} {failure.path} — {failure.error}")
|
|
89
|
+
|
|
90
|
+
deleted = result.deleted_count
|
|
91
|
+
word = "directory" if deleted == 1 else "directories"
|
|
92
|
+
self._println()
|
|
93
|
+
self._console.success(
|
|
94
|
+
c.paint(f"Cleaned {deleted} {word}, freed ~{format_size(result.total_size)}.", "bold")
|
|
95
|
+
)
|
|
96
|
+
if result.failed_count:
|
|
97
|
+
self._console.error(f"{result.failed_count} failed.")
|
|
98
|
+
self._println()
|
|
99
|
+
|
|
100
|
+
def permission_warnings(self, paths: Sequence[str]) -> None:
|
|
101
|
+
if not paths:
|
|
102
|
+
return
|
|
103
|
+
count = len(paths)
|
|
104
|
+
plural = "s" if count != 1 else ""
|
|
105
|
+
self._console.warning(f"Skipped {count} path{plural} (permission denied):")
|
|
106
|
+
for path in paths:
|
|
107
|
+
self._console.detail(f" {path}")
|
|
108
|
+
|
|
109
|
+
def history(
|
|
110
|
+
self,
|
|
111
|
+
operations: Sequence[HistoryOperation],
|
|
112
|
+
invalid_count: int,
|
|
113
|
+
) -> None:
|
|
114
|
+
c = self._console
|
|
115
|
+
if not operations:
|
|
116
|
+
c.detail("No cleanup history.")
|
|
117
|
+
self._print_invalid_note(invalid_count)
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
self._println(f"\n{c.paint('Cleanup history', 'bold')}\n")
|
|
121
|
+
self._println(f"{'WHEN':<18} {'SIZE':>9} {'STRATEGY':<10} {'ITEMS':>5}")
|
|
122
|
+
self._println(c.paint("─" * 48, "detail"))
|
|
123
|
+
|
|
124
|
+
for op in operations:
|
|
125
|
+
self._println(
|
|
126
|
+
f"{c.paint(f'{format_timestamp(op.timestamp):<18}', 'detail')} "
|
|
127
|
+
f"{c.paint(f'{format_size(op.reclaimed_size):>9}', 'success')} "
|
|
128
|
+
f"{op.strategy:<10} "
|
|
129
|
+
f"{op.item_count:>5}"
|
|
130
|
+
)
|
|
131
|
+
self._println()
|
|
132
|
+
self._print_invalid_note(invalid_count)
|
|
133
|
+
|
|
134
|
+
def _print_invalid_note(self, invalid_count: int) -> None:
|
|
135
|
+
if invalid_count:
|
|
136
|
+
plural = "s" if invalid_count != 1 else ""
|
|
137
|
+
self._console.warning(
|
|
138
|
+
f"Skipped {invalid_count} corrupt metadata record{plural} "
|
|
139
|
+
f"— run `devklean doctor` to inspect."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# --- doctor ---
|
|
143
|
+
|
|
144
|
+
def doctor_healthy(self) -> None:
|
|
145
|
+
self._console.success("Metadata store is healthy.")
|
|
146
|
+
|
|
147
|
+
def doctor_corruption_report(self, report: IntegrityReport) -> None:
|
|
148
|
+
c = self._console
|
|
149
|
+
self._println()
|
|
150
|
+
c.warning(
|
|
151
|
+
c.paint(f"CORRUPT ({len(report.corrupt)})", "error")
|
|
152
|
+
+ " — unparseable metadata, will be removed on confirmation"
|
|
153
|
+
)
|
|
154
|
+
for entry in report.corrupt:
|
|
155
|
+
c.detail(f" {SYM_ERROR} {entry.path.name} — {entry.reason}")
|
|
156
|
+
|
|
157
|
+
def doctor_confirm_prompt(self, count: int) -> str:
|
|
158
|
+
plural = "s" if count != 1 else ""
|
|
159
|
+
return f"\nRemove {count} corrupt metadata record{plural}? (y/N) "
|
|
160
|
+
|
|
161
|
+
def doctor_kept(self) -> None:
|
|
162
|
+
self._console.detail("Kept all records. Nothing removed.")
|
|
163
|
+
|
|
164
|
+
def doctor_removed(self, removed: int) -> None:
|
|
165
|
+
plural = "s" if removed != 1 else ""
|
|
166
|
+
self._println()
|
|
167
|
+
self._console.success(f"Removed {removed} corrupt metadata record{plural}.")
|
|
168
|
+
|
|
169
|
+
def doctor_remove_error(self, name: str, error: str) -> None:
|
|
170
|
+
self._console.error(f"could not remove {name}: {error}")
|
|
171
|
+
|
|
172
|
+
# --- restore ---
|
|
173
|
+
|
|
174
|
+
def restore_help(self) -> None:
|
|
175
|
+
c = self._console
|
|
176
|
+
c.info("devklean moves deleted items to your system trash, not to a devklean-owned store.")
|
|
177
|
+
self._println()
|
|
178
|
+
c.detail("To recover something you deleted:")
|
|
179
|
+
c.detail(" • Windows — open the Recycle Bin and restore the item.")
|
|
180
|
+
c.detail(" • macOS — open Trash in Finder and 'Put Back'.")
|
|
181
|
+
c.detail(" • Linux — open Trash in your file manager and restore.")
|
|
182
|
+
self._println()
|
|
183
|
+
c.detail("Run `devklean history` to see what was removed and when.")
|
devklean/output/theme.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Palette:
|
|
8
|
+
"""ANSI codes for semantic roles. Empty strings mean 'no color'."""
|
|
9
|
+
|
|
10
|
+
green: str
|
|
11
|
+
red: str
|
|
12
|
+
yellow: str
|
|
13
|
+
cyan: str
|
|
14
|
+
dim: str
|
|
15
|
+
bold: str
|
|
16
|
+
reset: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_COLOR_PALETTE = Palette(
|
|
20
|
+
green="\033[32m",
|
|
21
|
+
red="\033[31m",
|
|
22
|
+
yellow="\033[33m",
|
|
23
|
+
cyan="\033[36m",
|
|
24
|
+
dim="\033[2m",
|
|
25
|
+
bold="\033[1m",
|
|
26
|
+
reset="\033[0m",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_MONO_PALETTE = Palette(green="", red="", yellow="", cyan="", dim="", bold="", reset="")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Theme:
|
|
34
|
+
name: str
|
|
35
|
+
palette: Palette
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_THEMES = {
|
|
39
|
+
"default": Theme(name="default", palette=_COLOR_PALETTE),
|
|
40
|
+
"mono": Theme(name="mono", palette=_MONO_PALETTE),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_theme(name: str) -> Theme:
|
|
45
|
+
"""Return the named theme, falling back to 'default' for unknown names."""
|
|
46
|
+
return _THEMES.get(name, _THEMES["default"])
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from devklean.config.defaults import DEFAULT_TARGETS
|
|
2
|
+
from devklean.scanner.filters import (
|
|
3
|
+
dir_is_under_ignored_path,
|
|
4
|
+
normalize_paths,
|
|
5
|
+
path_is_excluded,
|
|
6
|
+
)
|
|
7
|
+
from devklean.scanner.scanner import ScanResult, get_dir_size, scan_tree
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"DEFAULT_TARGETS",
|
|
11
|
+
"ScanResult",
|
|
12
|
+
"dir_is_under_ignored_path",
|
|
13
|
+
"get_dir_size",
|
|
14
|
+
"normalize_paths",
|
|
15
|
+
"path_is_excluded",
|
|
16
|
+
"scan_tree",
|
|
17
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def normalize_paths(paths: frozenset[str] | set[str] | list[str] | tuple[str, ...]) -> set[str]:
|
|
7
|
+
if not paths:
|
|
8
|
+
return set()
|
|
9
|
+
return {os.path.abspath(os.path.expanduser(path)) for path in paths}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def dir_is_under_ignored_path(dirpath: str, ignored_paths: set[str]) -> bool:
|
|
13
|
+
"""Return True when ``dirpath`` is inside an ignored path.
|
|
14
|
+
|
|
15
|
+
Safe: callers pass absolute ``dirpath`` values from ``os.walk(abspath(root))``
|
|
16
|
+
and absolute entries in ``ignored_paths`` from ``normalize_paths``.
|
|
17
|
+
"""
|
|
18
|
+
for ignored_path in ignored_paths:
|
|
19
|
+
if dirpath == ignored_path or dirpath.startswith(ignored_path + os.sep):
|
|
20
|
+
return True
|
|
21
|
+
return False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def path_is_excluded(full_path: str, ignored_paths: set[str]) -> bool:
|
|
25
|
+
"""Return True when ``full_path`` should be excluded from scan results.
|
|
26
|
+
|
|
27
|
+
Safe: same absolute-path precondition as ``dir_is_under_ignored_path``.
|
|
28
|
+
"""
|
|
29
|
+
for ignored_path in ignored_paths:
|
|
30
|
+
if full_path == ignored_path or full_path.startswith(ignored_path + os.sep):
|
|
31
|
+
return True
|
|
32
|
+
return False
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Scanner performance notes.
|
|
2
|
+
|
|
3
|
+
Before (per directory visited during ``os.walk``):
|
|
4
|
+
- Rebuilt ``dirnames`` via list comprehension (new list allocation).
|
|
5
|
+
- Copied ``dirnames`` with ``list()`` for safe removal during iteration.
|
|
6
|
+
- Called ``dirnames.remove`` multiple times (O(n) each).
|
|
7
|
+
- Called ``os.path.abspath`` on every ``dirpath`` and matched path.
|
|
8
|
+
- Ran ignore-path checks even when the ignore list was empty.
|
|
9
|
+
|
|
10
|
+
After:
|
|
11
|
+
- Single-pass ``dirnames`` filtering via slice assignment (one list built per node).
|
|
12
|
+
- ``root`` normalized once so walk paths stay absolute; filters skip ``abspath``.
|
|
13
|
+
- Ignore-path checks gated on ``has_ignored_paths`` (empty config = no work).
|
|
14
|
+
- Directory-name ignore checks gated on ``has_ignored_directories``.
|
|
15
|
+
- ``active_targets[dirname]`` used directly after membership test (no redundant ``.get``).
|
|
16
|
+
|
|
17
|
+
Complexity (unchanged asymptotics, improved constants):
|
|
18
|
+
- Walk: still O(N) over filesystem nodes under ``root``.
|
|
19
|
+
- Per node: O(d) directory entries; target/ignore lookups O(1) average.
|
|
20
|
+
- Found-path pruning: O(1) per child via ``found_paths`` set (unchanged).
|
|
21
|
+
|
|
22
|
+
Sizing and concurrency (qualitative before/after):
|
|
23
|
+
- Sizing: ``get_dir_size`` moved from ``os.walk`` + ``os.path.getsize`` (which
|
|
24
|
+
re-``stat``s every file) to ``os.scandir`` + ``entry.stat`` (cached stat, one
|
|
25
|
+
syscall per entry). Symlinked dirs/files are skipped, so no double-counting.
|
|
26
|
+
- Concurrency: target sizing was sequential (each target's subtree walked one
|
|
27
|
+
after another). It now runs on a bounded ``ThreadPoolExecutor`` — sizing is
|
|
28
|
+
I/O-bound, so wide trees with many targets see real wall-clock wins. Discovery
|
|
29
|
+
stays a single deterministic walk; result order is preserved (map keeps input
|
|
30
|
+
order), so output is identical to the sequential version.
|
|
31
|
+
- Memory: only per-directory totals are retained (never a list of every file),
|
|
32
|
+
unchanged — large scans stay bounded by the number of matched targets, not
|
|
33
|
+
the number of files.
|
|
34
|
+
|
|
35
|
+
All changes preserve behavior: same targets matched, same paths ignored, same
|
|
36
|
+
sizes reported, same traversal boundaries (no recursion into matched targets).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import os
|
|
42
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
|
|
45
|
+
from devklean.config.models import ScanSettings
|
|
46
|
+
from devklean.models import CleanableItem
|
|
47
|
+
from devklean.scanner.filters import (
|
|
48
|
+
dir_is_under_ignored_path,
|
|
49
|
+
normalize_paths,
|
|
50
|
+
path_is_excluded,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ScanResult:
|
|
56
|
+
"""Discovered cleanable items plus paths skipped due to permission errors."""
|
|
57
|
+
|
|
58
|
+
items: list[CleanableItem]
|
|
59
|
+
permission_errors: list[str] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_dir_size(path: str) -> int:
|
|
63
|
+
"""Return the combined byte size of all regular files under ``path``.
|
|
64
|
+
|
|
65
|
+
Uses ``os.scandir`` with ``entry.stat(follow_symlinks=False)``, which:
|
|
66
|
+
- avoids the redundant ``stat`` that ``os.path.getsize`` performs after
|
|
67
|
+
``os.walk`` (scandir caches the entry's stat where the OS provides it),
|
|
68
|
+
- never traverses symlinked directories and never counts symlinked files,
|
|
69
|
+
so symlinks/hardlink aliases are not double-counted,
|
|
70
|
+
- counts regular files only.
|
|
71
|
+
"""
|
|
72
|
+
total = 0
|
|
73
|
+
stack = [path]
|
|
74
|
+
while stack:
|
|
75
|
+
current = stack.pop()
|
|
76
|
+
try:
|
|
77
|
+
with os.scandir(current) as entries:
|
|
78
|
+
for entry in entries:
|
|
79
|
+
try:
|
|
80
|
+
if entry.is_dir(follow_symlinks=False):
|
|
81
|
+
stack.append(entry.path)
|
|
82
|
+
elif entry.is_file(follow_symlinks=False):
|
|
83
|
+
total += entry.stat(follow_symlinks=False).st_size
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|
|
88
|
+
return total
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def scan_tree(root: str, settings: ScanSettings | None = None) -> ScanResult:
|
|
92
|
+
"""Discover cleanable directories, reporting permission-denied paths.
|
|
93
|
+
|
|
94
|
+
Permission errors during traversal are collected (via ``os.walk``'s
|
|
95
|
+
``onerror`` hook) rather than silently swallowed, so the caller can tell the
|
|
96
|
+
user which paths were skipped. Scanning continues past them.
|
|
97
|
+
"""
|
|
98
|
+
active = settings or ScanSettings.defaults()
|
|
99
|
+
|
|
100
|
+
# Safe: absolute root => os.walk yields absolute dirpaths; ignored paths
|
|
101
|
+
# are normalized the same way in ConfigManager / normalize_paths.
|
|
102
|
+
root = os.path.abspath(root)
|
|
103
|
+
ignored_paths = normalize_paths(active.ignored_paths)
|
|
104
|
+
has_ignored_paths = bool(ignored_paths)
|
|
105
|
+
ignored_directories = active.ignored_directories
|
|
106
|
+
has_ignored_directories = bool(ignored_directories)
|
|
107
|
+
active_targets = active.targets
|
|
108
|
+
|
|
109
|
+
# Discovery is a single deterministic pass; sizing (the real cost) is
|
|
110
|
+
# deferred so it can run concurrently afterwards.
|
|
111
|
+
matched: list[tuple[str, str]] = [] # (child_path, dirname)
|
|
112
|
+
found_paths: set[str] = set()
|
|
113
|
+
permission_errors: list[str] = []
|
|
114
|
+
|
|
115
|
+
def _on_walk_error(error: OSError) -> None:
|
|
116
|
+
if isinstance(error, PermissionError):
|
|
117
|
+
permission_errors.append(error.filename or str(error))
|
|
118
|
+
|
|
119
|
+
for dirpath, dirnames, _ in os.walk(
|
|
120
|
+
root, topdown=True, followlinks=False, onerror=_on_walk_error
|
|
121
|
+
):
|
|
122
|
+
if has_ignored_paths and dir_is_under_ignored_path(dirpath, ignored_paths):
|
|
123
|
+
dirnames.clear()
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
kept_dirnames: list[str] = []
|
|
127
|
+
for dirname in dirnames:
|
|
128
|
+
if has_ignored_directories and dirname in ignored_directories:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
child_path = os.path.join(dirpath, dirname)
|
|
132
|
+
|
|
133
|
+
if child_path in found_paths:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
if dirname in active_targets:
|
|
137
|
+
if has_ignored_paths and path_is_excluded(child_path, ignored_paths):
|
|
138
|
+
continue
|
|
139
|
+
matched.append((child_path, dirname))
|
|
140
|
+
found_paths.add(child_path)
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
kept_dirnames.append(dirname)
|
|
144
|
+
|
|
145
|
+
dirnames[:] = kept_dirnames
|
|
146
|
+
|
|
147
|
+
sizes = _compute_sizes_concurrently([path for path, _ in matched])
|
|
148
|
+
found = [
|
|
149
|
+
CleanableItem(
|
|
150
|
+
path=child_path,
|
|
151
|
+
name=dirname,
|
|
152
|
+
size=size,
|
|
153
|
+
display_label=active_targets[dirname],
|
|
154
|
+
)
|
|
155
|
+
for (child_path, dirname), size in zip(matched, sizes)
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
return ScanResult(items=found, permission_errors=permission_errors)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _compute_sizes_concurrently(paths: list[str]) -> list[int]:
|
|
162
|
+
"""Compute directory sizes in parallel (I/O-bound). Order is preserved."""
|
|
163
|
+
if not paths:
|
|
164
|
+
return []
|
|
165
|
+
if len(paths) == 1:
|
|
166
|
+
return [get_dir_size(paths[0])]
|
|
167
|
+
|
|
168
|
+
workers = min(32, (os.cpu_count() or 1) * 5, len(paths))
|
|
169
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
170
|
+
return list(executor.map(get_dir_size, paths))
|
devklean/tui.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from devklean.cli.confirmation import (
|
|
4
|
+
DEFAULT_LARGE_THRESHOLD,
|
|
5
|
+
confirm_large_deletion,
|
|
6
|
+
exceeds_threshold,
|
|
7
|
+
)
|
|
8
|
+
from devklean.deletion import SafetyValidator, delete_items
|
|
9
|
+
from devklean.formatting import format_size, truncate
|
|
10
|
+
from devklean.models import CleanableItem
|
|
11
|
+
from devklean.output.base import Renderer
|
|
12
|
+
from devklean.output.sorting import items_by_size_desc
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def interactive_ui(stdscr, items: list[CleanableItem], dry_run: bool) -> list[int] | None:
|
|
16
|
+
import curses # Unix-only; imported lazily so this module loads on Windows.
|
|
17
|
+
|
|
18
|
+
curses.curs_set(0)
|
|
19
|
+
stdscr.keypad(True)
|
|
20
|
+
|
|
21
|
+
selected = [False] * len(items)
|
|
22
|
+
idx = 0
|
|
23
|
+
top = 0
|
|
24
|
+
|
|
25
|
+
while True:
|
|
26
|
+
stdscr.erase()
|
|
27
|
+
height, width = stdscr.getmaxyx()
|
|
28
|
+
|
|
29
|
+
header = "devklean interactive - SPACE select | A all | D none | ENTER confirm | Q/ESC quit"
|
|
30
|
+
stdscr.addnstr(0, 0, truncate(header, width), width)
|
|
31
|
+
|
|
32
|
+
list_top = 1
|
|
33
|
+
list_height = max(0, height - 3)
|
|
34
|
+
footer_y = height - 1
|
|
35
|
+
|
|
36
|
+
if len(items) == 0:
|
|
37
|
+
stdscr.addnstr(
|
|
38
|
+
list_top,
|
|
39
|
+
0,
|
|
40
|
+
"No matching directories found — press Q to exit.",
|
|
41
|
+
width,
|
|
42
|
+
)
|
|
43
|
+
else:
|
|
44
|
+
if idx < top:
|
|
45
|
+
top = idx
|
|
46
|
+
if idx >= top + list_height:
|
|
47
|
+
top = idx - list_height + 1
|
|
48
|
+
|
|
49
|
+
for row in range(list_height):
|
|
50
|
+
i = top + row
|
|
51
|
+
if i >= len(items):
|
|
52
|
+
break
|
|
53
|
+
item = items[i]
|
|
54
|
+
mark = "x" if selected[i] else " "
|
|
55
|
+
line = f"[{mark}] {item.display_label:<18} {format_size(item.size):>8} {item.path}"
|
|
56
|
+
line = truncate(line, width)
|
|
57
|
+
if i == idx:
|
|
58
|
+
stdscr.addnstr(list_top + row, 0, line, width, curses.A_REVERSE)
|
|
59
|
+
else:
|
|
60
|
+
stdscr.addnstr(list_top + row, 0, line, width)
|
|
61
|
+
|
|
62
|
+
selected_count = sum(1 for s in selected if s)
|
|
63
|
+
selected_size = sum(items[i].size for i, s in enumerate(selected) if s)
|
|
64
|
+
footer = f"Selected: {selected_count}/{len(items)} Total: {format_size(selected_size)}"
|
|
65
|
+
if dry_run:
|
|
66
|
+
footer += " [dry-run: would delete, nothing removed]"
|
|
67
|
+
stdscr.addnstr(footer_y, 0, truncate(footer, width), width)
|
|
68
|
+
|
|
69
|
+
stdscr.refresh()
|
|
70
|
+
|
|
71
|
+
key = stdscr.getch()
|
|
72
|
+
if key in (ord("q"), ord("Q"), 27):
|
|
73
|
+
return None
|
|
74
|
+
if key in (curses.KEY_UP,):
|
|
75
|
+
if idx > 0:
|
|
76
|
+
idx -= 1
|
|
77
|
+
elif key in (curses.KEY_DOWN,):
|
|
78
|
+
if idx < len(items) - 1:
|
|
79
|
+
idx += 1
|
|
80
|
+
elif key == ord(" "):
|
|
81
|
+
if len(items) > 0:
|
|
82
|
+
selected[idx] = not selected[idx]
|
|
83
|
+
elif key in (ord("a"), ord("A")):
|
|
84
|
+
selected = [True] * len(items)
|
|
85
|
+
elif key in (ord("d"), ord("D")):
|
|
86
|
+
selected = [False] * len(items)
|
|
87
|
+
elif key in (curses.KEY_ENTER, 10, 13):
|
|
88
|
+
return [i for i, s in enumerate(selected) if s]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run_interactive(
|
|
92
|
+
renderer: Renderer,
|
|
93
|
+
found: list[CleanableItem],
|
|
94
|
+
dry_run: bool,
|
|
95
|
+
validator: SafetyValidator | None = None,
|
|
96
|
+
*,
|
|
97
|
+
confirm_threshold: int = DEFAULT_LARGE_THRESHOLD,
|
|
98
|
+
) -> None:
|
|
99
|
+
import curses # Unix-only; imported lazily so this module loads on Windows.
|
|
100
|
+
|
|
101
|
+
items = items_by_size_desc(found)
|
|
102
|
+
|
|
103
|
+
selected_indices = curses.wrapper(interactive_ui, items, dry_run)
|
|
104
|
+
if selected_indices is None:
|
|
105
|
+
renderer.aborted()
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
selected = [items[i] for i in selected_indices]
|
|
109
|
+
if not selected:
|
|
110
|
+
renderer.no_items_selected()
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
total_size = sum(item.size for item in selected)
|
|
114
|
+
|
|
115
|
+
if dry_run:
|
|
116
|
+
renderer.dry_run_selected(len(selected))
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
# Large selections require an explicit typed confirmation even here.
|
|
120
|
+
if exceeds_threshold(total_size, confirm_threshold):
|
|
121
|
+
if not confirm_large_deletion(len(selected), total_size, confirm_threshold):
|
|
122
|
+
renderer.aborted()
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
result = delete_items(selected, total_size, validator=validator)
|
|
126
|
+
renderer.deletion_result(result)
|