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.
Files changed (25) hide show
  1. {devtidy-0.1.0/src/devtidy.egg-info → devtidy-0.2.0}/PKG-INFO +7 -1
  2. {devtidy-0.1.0 → devtidy-0.2.0}/README.md +4 -0
  3. {devtidy-0.1.0 → devtidy-0.2.0}/pyproject.toml +5 -1
  4. {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/__init__.py +1 -2
  5. {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/cli.py +111 -37
  6. devtidy-0.2.0/src/devtidy/ui.py +163 -0
  7. {devtidy-0.1.0 → devtidy-0.2.0/src/devtidy.egg-info}/PKG-INFO +7 -1
  8. {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy.egg-info/SOURCES.txt +3 -0
  9. devtidy-0.2.0/src/devtidy.egg-info/requires.txt +2 -0
  10. devtidy-0.2.0/tests/test_cli.py +50 -0
  11. {devtidy-0.1.0 → devtidy-0.2.0}/LICENSE +0 -0
  12. {devtidy-0.1.0 → devtidy-0.2.0}/setup.cfg +0 -0
  13. {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/__main__.py +0 -0
  14. {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/models.py +0 -0
  15. {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/rules.py +0 -0
  16. {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/safety.py +0 -0
  17. {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/scanner.py +0 -0
  18. {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/storage.py +0 -0
  19. {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy/units.py +0 -0
  20. {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy.egg-info/dependency_links.txt +0 -0
  21. {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy.egg-info/entry_points.txt +0 -0
  22. {devtidy-0.1.0 → devtidy-0.2.0}/src/devtidy.egg-info/top_level.txt +0 -0
  23. {devtidy-0.1.0 → devtidy-0.2.0}/tests/test_scanner.py +0 -0
  24. {devtidy-0.1.0 → devtidy-0.2.0}/tests/test_storage.py +0 -0
  25. {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.1.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.1.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",
@@ -1,4 +1,3 @@
1
1
  """DevTidy public package interface."""
2
2
 
3
- __version__ = "0.1.0"
4
-
3
+ __version__ = "0.2.0"
@@ -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("scan", help="find cleanup candidates")
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("clean", help="archive or delete candidates")
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("restore", help="restore an archive session")
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("history", help="show local cleanup history")
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("rules", help="explain built-in matching rules")
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
- _print_candidates(candidates)
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
- candidates = _candidates(args)
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
- print("No cleanup candidates found.")
167
+ if args.json:
168
+ print("[]")
169
+ else:
170
+ render_candidates(console, [])
121
171
  return 0
122
172
  if not args.yes:
123
- _print_candidates(candidates)
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
- print(f"\nRefusing to {action} without --yes.", file=sys.stderr)
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
- print(
133
- f"{result['action'].capitalize()} complete: "
134
- f"{len(candidates)} item(s), {human_size(int(result['total_size']))}."
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
- print(f"Restored {len(result['items'])} item(s) from {result['session_id']}.")
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
- print("No cleanup history found.")
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} {record['action']:<7} "
161
- f"{human_size(int(record['total_size'])):>10} {record['session_id']}"
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(f"{rule['name']} ({rule['category']}): {names}")
182
- print(f" {rule['description']}")
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
- print(f"devtidy: error: {error}", file=sys.stderr)
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.1.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,2 @@
1
+ rich>=13.9
2
+ rich-argparse>=1.7
@@ -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