devtidy 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {devtidy-0.1.0/src/devtidy.egg-info → devtidy-0.2.0}/PKG-INFO +7 -1
- {devtidy-0.1.0 → devtidy-0.2.0}/README.md +4 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/pyproject.toml +5 -1
- {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/__init__.py +1 -2
- {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/cli.py +111 -37
- devtidy-0.2.0/src/devtidy/ui.py +163 -0
- {devtidy-0.1.0 → devtidy-0.2.0/src/devtidy.egg-info}/PKG-INFO +7 -1
- {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy.egg-info/SOURCES.txt +3 -0
- devtidy-0.2.0/src/devtidy.egg-info/requires.txt +2 -0
- devtidy-0.2.0/tests/test_cli.py +50 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/LICENSE +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/setup.cfg +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/__main__.py +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/models.py +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/rules.py +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/safety.py +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/scanner.py +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/storage.py +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/units.py +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy.egg-info/dependency_links.txt +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy.egg-info/entry_points.txt +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy.egg-info/top_level.txt +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/tests/test_scanner.py +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/tests/test_storage.py +0 -0
- {devtidy-0.1.0 → devtidy-0.2.0}/tests/test_units.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devtidy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Safely find and reclaim stale developer build artifacts and environments.
|
|
5
5
|
Author: Harish
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,8 @@ Classifier: Topic :: Utilities
|
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
License-File: LICENSE
|
|
25
|
+
Requires-Dist: rich>=13.9
|
|
26
|
+
Requires-Dist: rich-argparse>=1.7
|
|
25
27
|
Dynamic: license-file
|
|
26
28
|
|
|
27
29
|
# DevTidy
|
|
@@ -43,6 +45,7 @@ easy, but removing data should require an explicit decision.
|
|
|
43
45
|
- Can archive files with a manifest and restore them later.
|
|
44
46
|
- Produces machine-readable JSON for scripts and CI.
|
|
45
47
|
- Stores cleanup history locally. No telemetry or network calls.
|
|
48
|
+
- Presents scans with a colorful terminal dashboard and animated progress.
|
|
46
49
|
|
|
47
50
|
## Install
|
|
48
51
|
|
|
@@ -76,6 +79,9 @@ devtidy restore --latest
|
|
|
76
79
|
|
|
77
80
|
# JSON output for automation
|
|
78
81
|
devtidy scan . --json
|
|
82
|
+
|
|
83
|
+
# Disable styling for basic terminals
|
|
84
|
+
devtidy scan . --no-color
|
|
79
85
|
```
|
|
80
86
|
|
|
81
87
|
## Commands
|
|
@@ -17,6 +17,7 @@ easy, but removing data should require an explicit decision.
|
|
|
17
17
|
- Can archive files with a manifest and restore them later.
|
|
18
18
|
- Produces machine-readable JSON for scripts and CI.
|
|
19
19
|
- Stores cleanup history locally. No telemetry or network calls.
|
|
20
|
+
- Presents scans with a colorful terminal dashboard and animated progress.
|
|
20
21
|
|
|
21
22
|
## Install
|
|
22
23
|
|
|
@@ -50,6 +51,9 @@ devtidy restore --latest
|
|
|
50
51
|
|
|
51
52
|
# JSON output for automation
|
|
52
53
|
devtidy scan . --json
|
|
54
|
+
|
|
55
|
+
# Disable styling for basic terminals
|
|
56
|
+
devtidy scan . --no-color
|
|
53
57
|
```
|
|
54
58
|
|
|
55
59
|
## Commands
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devtidy"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Safely find and reclaim stale developer build artifacts and environments."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -13,6 +13,10 @@ authors = [
|
|
|
13
13
|
{ name = "Harish" }
|
|
14
14
|
]
|
|
15
15
|
keywords = ["cleanup", "developer-tools", "disk-space", "node-modules", "virtualenv"]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"rich>=13.9",
|
|
18
|
+
"rich-argparse>=1.7",
|
|
19
|
+
]
|
|
16
20
|
classifiers = [
|
|
17
21
|
"Development Status :: 3 - Alpha",
|
|
18
22
|
"Environment :: Console",
|
|
@@ -6,12 +6,22 @@ import sys
|
|
|
6
6
|
from datetime import datetime
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
from rich_argparse import RichHelpFormatter
|
|
10
|
+
|
|
9
11
|
from devtidy import __version__
|
|
10
12
|
from devtidy.models import Candidate
|
|
11
13
|
from devtidy.rules import RULES
|
|
12
14
|
from devtidy.safety import UnsafePathError
|
|
13
15
|
from devtidy.scanner import scan
|
|
14
16
|
from devtidy.storage import archive, delete, read_history, restore
|
|
17
|
+
from devtidy.ui import (
|
|
18
|
+
make_console,
|
|
19
|
+
render_action_result,
|
|
20
|
+
render_candidates,
|
|
21
|
+
render_error,
|
|
22
|
+
render_header,
|
|
23
|
+
scan_status,
|
|
24
|
+
)
|
|
15
25
|
from devtidy.units import human_size, parse_duration, parse_size
|
|
16
26
|
|
|
17
27
|
|
|
@@ -42,20 +52,35 @@ def _add_scan_arguments(parser: argparse.ArgumentParser) -> None:
|
|
|
42
52
|
parser.add_argument("--exclude", action="append", default=[], metavar="GLOB")
|
|
43
53
|
parser.add_argument("--max-depth", type=int)
|
|
44
54
|
parser.add_argument("--json", action="store_true")
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--no-color",
|
|
57
|
+
action="store_true",
|
|
58
|
+
help="disable colors and terminal styling",
|
|
59
|
+
)
|
|
45
60
|
|
|
46
61
|
|
|
47
62
|
def build_parser() -> argparse.ArgumentParser:
|
|
48
63
|
parser = argparse.ArgumentParser(
|
|
49
64
|
prog="devtidy",
|
|
50
65
|
description="Safely reclaim stale developer artifacts.",
|
|
66
|
+
epilog="Scan first. Archive when unsure. Delete only when you mean it.",
|
|
67
|
+
formatter_class=RichHelpFormatter,
|
|
51
68
|
)
|
|
52
69
|
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
53
70
|
subparsers = parser.add_subparsers(dest="command")
|
|
54
71
|
|
|
55
|
-
scan_parser = subparsers.add_parser(
|
|
72
|
+
scan_parser = subparsers.add_parser(
|
|
73
|
+
"scan",
|
|
74
|
+
help="find cleanup candidates",
|
|
75
|
+
formatter_class=RichHelpFormatter,
|
|
76
|
+
)
|
|
56
77
|
_add_scan_arguments(scan_parser)
|
|
57
78
|
|
|
58
|
-
clean_parser = subparsers.add_parser(
|
|
79
|
+
clean_parser = subparsers.add_parser(
|
|
80
|
+
"clean",
|
|
81
|
+
help="archive or delete candidates",
|
|
82
|
+
formatter_class=RichHelpFormatter,
|
|
83
|
+
)
|
|
59
84
|
_add_scan_arguments(clean_parser)
|
|
60
85
|
action = clean_parser.add_mutually_exclusive_group(required=True)
|
|
61
86
|
action.add_argument("--archive", action="store_true")
|
|
@@ -66,18 +91,33 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
66
91
|
help="confirm the selected cleanup action",
|
|
67
92
|
)
|
|
68
93
|
|
|
69
|
-
restore_parser = subparsers.add_parser(
|
|
94
|
+
restore_parser = subparsers.add_parser(
|
|
95
|
+
"restore",
|
|
96
|
+
help="restore an archive session",
|
|
97
|
+
formatter_class=RichHelpFormatter,
|
|
98
|
+
)
|
|
70
99
|
restore_parser.add_argument("session_id", nargs="?")
|
|
71
100
|
restore_parser.add_argument("--latest", action="store_true")
|
|
72
101
|
restore_parser.add_argument("--overwrite", action="store_true")
|
|
73
102
|
restore_parser.add_argument("--json", action="store_true")
|
|
103
|
+
restore_parser.add_argument("--no-color", action="store_true")
|
|
74
104
|
|
|
75
|
-
history_parser = subparsers.add_parser(
|
|
105
|
+
history_parser = subparsers.add_parser(
|
|
106
|
+
"history",
|
|
107
|
+
help="show local cleanup history",
|
|
108
|
+
formatter_class=RichHelpFormatter,
|
|
109
|
+
)
|
|
76
110
|
history_parser.add_argument("--limit", type=int, default=10)
|
|
77
111
|
history_parser.add_argument("--json", action="store_true")
|
|
112
|
+
history_parser.add_argument("--no-color", action="store_true")
|
|
78
113
|
|
|
79
|
-
rules_parser = subparsers.add_parser(
|
|
114
|
+
rules_parser = subparsers.add_parser(
|
|
115
|
+
"rules",
|
|
116
|
+
help="explain built-in matching rules",
|
|
117
|
+
formatter_class=RichHelpFormatter,
|
|
118
|
+
)
|
|
80
119
|
rules_parser.add_argument("--json", action="store_true")
|
|
120
|
+
rules_parser.add_argument("--no-color", action="store_true")
|
|
81
121
|
return parser
|
|
82
122
|
|
|
83
123
|
|
|
@@ -92,48 +132,64 @@ def _candidates(args: argparse.Namespace) -> list[Candidate]:
|
|
|
92
132
|
)
|
|
93
133
|
|
|
94
134
|
|
|
95
|
-
def _print_candidates(candidates: list[Candidate]) -> None:
|
|
96
|
-
if not candidates:
|
|
97
|
-
print("No cleanup candidates found.")
|
|
98
|
-
return
|
|
99
|
-
for candidate in candidates:
|
|
100
|
-
activity = datetime.fromtimestamp(candidate.last_activity).strftime("%Y-%m-%d")
|
|
101
|
-
print(
|
|
102
|
-
f"{human_size(candidate.size):>10} "
|
|
103
|
-
f"{candidate.category:<7} {activity} {candidate.path}"
|
|
104
|
-
)
|
|
105
|
-
print(f"\nPotential savings: {human_size(sum(item.size for item in candidates))}")
|
|
106
|
-
|
|
107
|
-
|
|
108
135
|
def _scan_command(args: argparse.Namespace) -> int:
|
|
109
|
-
candidates = _candidates(args)
|
|
110
136
|
if args.json:
|
|
137
|
+
candidates = _candidates(args)
|
|
111
138
|
print(json.dumps([item.to_dict() for item in candidates], indent=2))
|
|
112
139
|
else:
|
|
113
|
-
|
|
140
|
+
console = make_console(no_color=args.no_color)
|
|
141
|
+
roots = args.paths or [Path.cwd()]
|
|
142
|
+
render_header(
|
|
143
|
+
console,
|
|
144
|
+
"Workspace scan",
|
|
145
|
+
"Finding generated artifacts that can be rebuilt when needed.",
|
|
146
|
+
)
|
|
147
|
+
with scan_status(console, roots, enabled=console.is_terminal):
|
|
148
|
+
candidates = _candidates(args)
|
|
149
|
+
render_candidates(console, candidates)
|
|
114
150
|
return 0
|
|
115
151
|
|
|
116
152
|
|
|
117
153
|
def _clean_command(args: argparse.Namespace) -> int:
|
|
118
|
-
|
|
154
|
+
if args.json:
|
|
155
|
+
candidates = _candidates(args)
|
|
156
|
+
else:
|
|
157
|
+
console = make_console(no_color=args.no_color)
|
|
158
|
+
render_header(
|
|
159
|
+
console,
|
|
160
|
+
"Cleanup preview",
|
|
161
|
+
"Nothing changes until the requested safety checks pass.",
|
|
162
|
+
)
|
|
163
|
+
roots = args.paths or [Path.cwd()]
|
|
164
|
+
with scan_status(console, roots, enabled=console.is_terminal):
|
|
165
|
+
candidates = _candidates(args)
|
|
119
166
|
if not candidates:
|
|
120
|
-
|
|
167
|
+
if args.json:
|
|
168
|
+
print("[]")
|
|
169
|
+
else:
|
|
170
|
+
render_candidates(console, [])
|
|
121
171
|
return 0
|
|
122
172
|
if not args.yes:
|
|
123
|
-
|
|
173
|
+
if args.json:
|
|
174
|
+
print(json.dumps([item.to_dict() for item in candidates], indent=2))
|
|
175
|
+
else:
|
|
176
|
+
render_candidates(console, candidates)
|
|
124
177
|
action = "archive" if args.archive else "permanently delete"
|
|
125
|
-
|
|
178
|
+
error_console = make_console(no_color=args.no_color, stderr=True)
|
|
179
|
+
render_error(error_console, f"Refusing to {action} without --yes.")
|
|
126
180
|
return 2
|
|
127
181
|
|
|
128
182
|
result = archive(candidates) if args.archive else delete(candidates)
|
|
129
183
|
if args.json:
|
|
130
184
|
print(json.dumps(result, indent=2))
|
|
131
185
|
else:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
186
|
+
render_action_result(
|
|
187
|
+
console,
|
|
188
|
+
action=str(result["action"]),
|
|
189
|
+
count=len(candidates),
|
|
190
|
+
total_size=int(result["total_size"]),
|
|
191
|
+
session_id=str(result["session_id"]),
|
|
135
192
|
)
|
|
136
|
-
print(f"Session: {result['session_id']}")
|
|
137
193
|
return 0
|
|
138
194
|
|
|
139
195
|
|
|
@@ -142,7 +198,15 @@ def _restore_command(args: argparse.Namespace) -> int:
|
|
|
142
198
|
if args.json:
|
|
143
199
|
print(json.dumps(result, indent=2))
|
|
144
200
|
else:
|
|
145
|
-
|
|
201
|
+
console = make_console(no_color=args.no_color)
|
|
202
|
+
render_header(console, "Archive restored", "Your generated artifacts are back in place.")
|
|
203
|
+
render_action_result(
|
|
204
|
+
console,
|
|
205
|
+
action="restore",
|
|
206
|
+
count=len(result["items"]),
|
|
207
|
+
total_size=int(result["total_size"]),
|
|
208
|
+
session_id=str(result["session_id"]),
|
|
209
|
+
)
|
|
146
210
|
return 0
|
|
147
211
|
|
|
148
212
|
|
|
@@ -152,13 +216,19 @@ def _history_command(args: argparse.Namespace) -> int:
|
|
|
152
216
|
print(json.dumps(records, indent=2))
|
|
153
217
|
return 0
|
|
154
218
|
if not records:
|
|
155
|
-
|
|
219
|
+
console = make_console(no_color=args.no_color)
|
|
220
|
+
render_header(console, "Cleanup history", "Local-only records of DevTidy actions.")
|
|
221
|
+
console.print("[dim]No cleanup history found.[/]")
|
|
156
222
|
return 0
|
|
223
|
+
console = make_console(no_color=args.no_color)
|
|
224
|
+
render_header(console, "Cleanup history", "Local-only records of DevTidy actions.")
|
|
157
225
|
for record in records:
|
|
158
226
|
created = datetime.fromtimestamp(record["created_at"]).strftime("%Y-%m-%d %H:%M")
|
|
159
|
-
print(
|
|
160
|
-
f"{created}
|
|
161
|
-
f"{
|
|
227
|
+
console.print(
|
|
228
|
+
f"[dim]{created}[/] "
|
|
229
|
+
f"[bold]{record['action']:<7}[/] "
|
|
230
|
+
f"[green]{human_size(int(record['total_size'])):>10}[/] "
|
|
231
|
+
f"[dim]{record['session_id']}[/]"
|
|
162
232
|
)
|
|
163
233
|
return 0
|
|
164
234
|
|
|
@@ -176,10 +246,14 @@ def _rules_command(args: argparse.Namespace) -> int:
|
|
|
176
246
|
if args.json:
|
|
177
247
|
print(json.dumps(rules, indent=2))
|
|
178
248
|
else:
|
|
249
|
+
console = make_console(no_color=args.no_color)
|
|
250
|
+
render_header(console, "Cleanup rules", "Every match is explainable and project-aware.")
|
|
179
251
|
for rule in rules:
|
|
180
252
|
names = ", ".join(rule["directories"])
|
|
181
|
-
print(
|
|
182
|
-
|
|
253
|
+
console.print(
|
|
254
|
+
f"[bold cyan]{rule['name']}[/] [dim]({rule['category']})[/] {names}"
|
|
255
|
+
)
|
|
256
|
+
console.print(f" [dim]{rule['description']}[/]")
|
|
183
257
|
return 0
|
|
184
258
|
|
|
185
259
|
|
|
@@ -207,6 +281,6 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
207
281
|
parser.print_help()
|
|
208
282
|
return 0
|
|
209
283
|
except (UnsafePathError, ValueError, FileExistsError, PermissionError) as error:
|
|
210
|
-
|
|
284
|
+
no_color = bool(getattr(args, "no_color", False))
|
|
285
|
+
render_error(make_console(no_color=no_color, stderr=True), str(error))
|
|
211
286
|
return 1
|
|
212
|
-
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import nullcontext
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import ContextManager, Iterable
|
|
7
|
+
|
|
8
|
+
from rich import box
|
|
9
|
+
from rich.console import Console, Group
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from devtidy import __version__
|
|
15
|
+
from devtidy.models import Candidate
|
|
16
|
+
from devtidy.units import human_size
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
ACCENT = "rgb(190,145,255)"
|
|
20
|
+
CYAN = "rgb(100,210,255)"
|
|
21
|
+
GREEN = "rgb(120,220,160)"
|
|
22
|
+
MUTED = "grey62"
|
|
23
|
+
WARNING = "rgb(255,190,100)"
|
|
24
|
+
CATEGORY_STYLES = {
|
|
25
|
+
"node": "rgb(120,220,160)",
|
|
26
|
+
"python": "rgb(100,180,255)",
|
|
27
|
+
"cache": "rgb(190,145,255)",
|
|
28
|
+
"build": "rgb(255,190,100)",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def make_console(*, no_color: bool = False, stderr: bool = False) -> Console:
|
|
33
|
+
return Console(
|
|
34
|
+
stderr=stderr,
|
|
35
|
+
no_color=no_color,
|
|
36
|
+
highlight=False,
|
|
37
|
+
soft_wrap=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_header(console: Console, title: str, subtitle: str) -> None:
|
|
42
|
+
brand = Text()
|
|
43
|
+
brand.append("dev", style=f"bold {ACCENT}")
|
|
44
|
+
brand.append("tidy", style=f"bold {CYAN}")
|
|
45
|
+
brand.append(f" v{__version__}", style=MUTED)
|
|
46
|
+
|
|
47
|
+
heading = Text(title, style="bold white")
|
|
48
|
+
detail = Text(subtitle, style=MUTED)
|
|
49
|
+
console.print(
|
|
50
|
+
Panel(
|
|
51
|
+
Group(brand, Text(""), heading, detail),
|
|
52
|
+
border_style=ACCENT,
|
|
53
|
+
box=box.ROUNDED,
|
|
54
|
+
padding=(0, 1),
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def scan_status(console: Console, roots: Iterable[Path], enabled: bool) -> ContextManager:
|
|
60
|
+
if not enabled:
|
|
61
|
+
return nullcontext()
|
|
62
|
+
root_count = len(list(roots))
|
|
63
|
+
label = "workspace" if root_count == 1 else f"{root_count} workspaces"
|
|
64
|
+
return console.status(
|
|
65
|
+
f"[bold {CYAN}]Scanning {label}[/] [dim]for reclaimable artifacts...[/]",
|
|
66
|
+
spinner="dots12",
|
|
67
|
+
spinner_style=ACCENT,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render_candidates(console: Console, candidates: list[Candidate]) -> None:
|
|
72
|
+
if not candidates:
|
|
73
|
+
console.print(
|
|
74
|
+
Panel(
|
|
75
|
+
"[bold green]All tidy.[/] No cleanup candidates matched your filters.",
|
|
76
|
+
border_style=GREEN,
|
|
77
|
+
box=box.ROUNDED,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
table = Table(
|
|
83
|
+
box=box.SIMPLE_HEAVY,
|
|
84
|
+
border_style="grey35",
|
|
85
|
+
header_style="bold grey85",
|
|
86
|
+
padding=(0, 1),
|
|
87
|
+
expand=True,
|
|
88
|
+
)
|
|
89
|
+
table.add_column("SIZE", justify="right", no_wrap=True, style="bold white")
|
|
90
|
+
table.add_column("TYPE", no_wrap=True)
|
|
91
|
+
table.add_column("LAST ACTIVE", no_wrap=True, style=MUTED)
|
|
92
|
+
table.add_column("PATH", ratio=1, overflow="fold")
|
|
93
|
+
|
|
94
|
+
for candidate in candidates:
|
|
95
|
+
activity = datetime.fromtimestamp(candidate.last_activity).strftime("%Y-%m-%d")
|
|
96
|
+
category_style = CATEGORY_STYLES.get(candidate.category, "white")
|
|
97
|
+
category = Text(f" {candidate.category.upper()} ", style=f"bold black on {category_style}")
|
|
98
|
+
path = Text(str(candidate.path), style="grey85")
|
|
99
|
+
path.stylize(f"bold {CYAN}", max(0, len(path) - len(candidate.path.name)))
|
|
100
|
+
table.add_row(human_size(candidate.size), category, activity, path)
|
|
101
|
+
|
|
102
|
+
total = sum(item.size for item in candidates)
|
|
103
|
+
by_category: dict[str, int] = {}
|
|
104
|
+
for candidate in candidates:
|
|
105
|
+
by_category[candidate.category] = by_category.get(candidate.category, 0) + candidate.size
|
|
106
|
+
|
|
107
|
+
summary = Text()
|
|
108
|
+
summary.append("Found ", style=MUTED)
|
|
109
|
+
summary.append(str(len(candidates)), style="bold white")
|
|
110
|
+
summary.append(" candidates ", style=MUTED)
|
|
111
|
+
summary.append("Potential savings ", style=MUTED)
|
|
112
|
+
summary.append(human_size(total), style=f"bold {GREEN}")
|
|
113
|
+
if by_category:
|
|
114
|
+
summary.append("\n")
|
|
115
|
+
summary.append(
|
|
116
|
+
" ".join(
|
|
117
|
+
f"{category}: {human_size(size)}"
|
|
118
|
+
for category, size in sorted(by_category.items(), key=lambda item: -item[1])
|
|
119
|
+
),
|
|
120
|
+
style=MUTED,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
console.print(table)
|
|
124
|
+
console.print(
|
|
125
|
+
Panel(summary, border_style=GREEN, box=box.ROUNDED, padding=(0, 1))
|
|
126
|
+
)
|
|
127
|
+
console.print(
|
|
128
|
+
f"[{MUTED}]Next:[/] [bold]devtidy clean <path> --archive --yes[/] "
|
|
129
|
+
f"[{MUTED}]to reclaim space safely.[/]"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def render_action_result(
|
|
134
|
+
console: Console,
|
|
135
|
+
*,
|
|
136
|
+
action: str,
|
|
137
|
+
count: int,
|
|
138
|
+
total_size: int,
|
|
139
|
+
session_id: str,
|
|
140
|
+
) -> None:
|
|
141
|
+
verbs = {
|
|
142
|
+
"archive": "Archived",
|
|
143
|
+
"delete": "Deleted",
|
|
144
|
+
"restore": "Restored",
|
|
145
|
+
}
|
|
146
|
+
verb = verbs.get(action, action.capitalize())
|
|
147
|
+
message = Text()
|
|
148
|
+
message.append(f"{verb} {count} item(s)", style="bold white")
|
|
149
|
+
message.append(" and reclaimed ", style=MUTED)
|
|
150
|
+
message.append(human_size(total_size), style=f"bold {GREEN}")
|
|
151
|
+
message.append(f"\nSession {session_id}", style=MUTED)
|
|
152
|
+
console.print(Panel(message, title="[bold green]Complete[/]", border_style=GREEN, box=box.ROUNDED))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def render_error(console: Console, message: str) -> None:
|
|
156
|
+
console.print(
|
|
157
|
+
Panel(
|
|
158
|
+
Text(message, style="bold white"),
|
|
159
|
+
title=f"[bold {WARNING}]DevTidy stopped[/]",
|
|
160
|
+
border_style=WARNING,
|
|
161
|
+
box=box.ROUNDED,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devtidy
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Safely find and reclaim stale developer build artifacts and environments.
|
|
5
5
|
Author: Harish
|
|
6
6
|
License-Expression: MIT
|
|
@@ -22,6 +22,8 @@ Classifier: Topic :: Utilities
|
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
License-File: LICENSE
|
|
25
|
+
Requires-Dist: rich>=13.9
|
|
26
|
+
Requires-Dist: rich-argparse>=1.7
|
|
25
27
|
Dynamic: license-file
|
|
26
28
|
|
|
27
29
|
# DevTidy
|
|
@@ -43,6 +45,7 @@ easy, but removing data should require an explicit decision.
|
|
|
43
45
|
- Can archive files with a manifest and restore them later.
|
|
44
46
|
- Produces machine-readable JSON for scripts and CI.
|
|
45
47
|
- Stores cleanup history locally. No telemetry or network calls.
|
|
48
|
+
- Presents scans with a colorful terminal dashboard and animated progress.
|
|
46
49
|
|
|
47
50
|
## Install
|
|
48
51
|
|
|
@@ -76,6 +79,9 @@ devtidy restore --latest
|
|
|
76
79
|
|
|
77
80
|
# JSON output for automation
|
|
78
81
|
devtidy scan . --json
|
|
82
|
+
|
|
83
|
+
# Disable styling for basic terminals
|
|
84
|
+
devtidy scan . --no-color
|
|
79
85
|
```
|
|
80
86
|
|
|
81
87
|
## Commands
|
|
@@ -9,12 +9,15 @@ src/devtidy/rules.py
|
|
|
9
9
|
src/devtidy/safety.py
|
|
10
10
|
src/devtidy/scanner.py
|
|
11
11
|
src/devtidy/storage.py
|
|
12
|
+
src/devtidy/ui.py
|
|
12
13
|
src/devtidy/units.py
|
|
13
14
|
src/devtidy.egg-info/PKG-INFO
|
|
14
15
|
src/devtidy.egg-info/SOURCES.txt
|
|
15
16
|
src/devtidy.egg-info/dependency_links.txt
|
|
16
17
|
src/devtidy.egg-info/entry_points.txt
|
|
18
|
+
src/devtidy.egg-info/requires.txt
|
|
17
19
|
src/devtidy.egg-info/top_level.txt
|
|
20
|
+
tests/test_cli.py
|
|
18
21
|
tests/test_scanner.py
|
|
19
22
|
tests/test_storage.py
|
|
20
23
|
tests/test_units.py
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import tempfile
|
|
3
|
+
import unittest
|
|
4
|
+
from contextlib import redirect_stdout
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from devtidy.cli import main
|
|
9
|
+
from devtidy.models import Candidate
|
|
10
|
+
from devtidy.ui import make_console, render_candidates
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CliTests(unittest.TestCase):
|
|
14
|
+
def test_json_output_has_no_terminal_markup(self):
|
|
15
|
+
with tempfile.TemporaryDirectory() as temporary:
|
|
16
|
+
root = Path(temporary)
|
|
17
|
+
cache = root / "__pycache__"
|
|
18
|
+
cache.mkdir()
|
|
19
|
+
(cache / "module.pyc").write_bytes(b"cache")
|
|
20
|
+
output = StringIO()
|
|
21
|
+
|
|
22
|
+
with redirect_stdout(output):
|
|
23
|
+
exit_code = main(["scan", str(root), "--json"])
|
|
24
|
+
|
|
25
|
+
payload = json.loads(output.getvalue())
|
|
26
|
+
self.assertEqual(exit_code, 0)
|
|
27
|
+
self.assertEqual(payload[0]["category"], "cache")
|
|
28
|
+
|
|
29
|
+
def test_no_color_candidate_output(self):
|
|
30
|
+
output = StringIO()
|
|
31
|
+
console = make_console(no_color=True)
|
|
32
|
+
console.file = output
|
|
33
|
+
candidate = Candidate(
|
|
34
|
+
path=Path("project/node_modules"),
|
|
35
|
+
root=Path("project"),
|
|
36
|
+
rule="node_modules",
|
|
37
|
+
category="node",
|
|
38
|
+
size=1024,
|
|
39
|
+
last_activity=0,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
render_candidates(console, [candidate])
|
|
43
|
+
|
|
44
|
+
rendered = output.getvalue()
|
|
45
|
+
self.assertIn("Potential savings", rendered)
|
|
46
|
+
self.assertNotIn("\x1b[", rendered)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|