sweepito 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.
sweepito/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Sweepito: safely clean caches and build artifacts."""
2
+
3
+ __version__ = "0.1.0"
sweepito/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from sweepito.cli import main
2
+
3
+ raise SystemExit(main())
sweepito/api.py ADDED
@@ -0,0 +1,72 @@
1
+ """UI-free core API. The command line is a thin wrapper around these functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from sweepito import git
9
+ from sweepito.config import (
10
+ FileConfig,
11
+ Options,
12
+ Settings,
13
+ find_config,
14
+ load_config,
15
+ merge,
16
+ )
17
+ from sweepito.errors import ConfigError
18
+ from sweepito.executor import apply_plan
19
+ from sweepito.models import CleanResult, Plan
20
+ from sweepito.planner import build_plan
21
+ from sweepito.registry import Registry
22
+
23
+
24
+ @dataclass
25
+ class Context:
26
+ root: Path
27
+ settings: Settings
28
+ registry: Registry
29
+ config: FileConfig
30
+ in_git: bool
31
+
32
+
33
+ def resolve_root(path: str | None = None) -> Path:
34
+ """The project root: ``path``, else the git root of the cwd, else the cwd."""
35
+ if path:
36
+ root = Path(path).resolve()
37
+ if not root.is_dir():
38
+ raise ConfigError(f"Not a directory: {path}")
39
+ return root
40
+ cwd = Path.cwd().resolve()
41
+ top = git.toplevel(cwd)
42
+ return top.resolve() if top else cwd
43
+
44
+
45
+ def prepare(
46
+ root: Path, options: Options | None = None, config_file: str | None = None
47
+ ) -> Context:
48
+ options = options or Options()
49
+ top = git.toplevel(root)
50
+ if config_file:
51
+ path = Path(config_file)
52
+ if not path.is_file():
53
+ raise ConfigError(f"Config file not found: {config_file}")
54
+ else:
55
+ path = find_config(root, stop=top or root)
56
+ config = load_config(path) if path else FileConfig()
57
+ registry = Registry.build(config.profile_defs, config.rules)
58
+ return Context(
59
+ root=root,
60
+ settings=merge(options, config),
61
+ registry=registry,
62
+ config=config,
63
+ in_git=top is not None,
64
+ )
65
+
66
+
67
+ def scan(ctx: Context) -> Plan:
68
+ return build_plan(ctx.root, ctx.settings, ctx.registry)
69
+
70
+
71
+ def clean(ctx: Context, plan: Plan) -> CleanResult:
72
+ return apply_plan(plan, in_git=ctx.in_git)
sweepito/cli.py ADDED
@@ -0,0 +1,551 @@
1
+ """Command line interface: a thin wrapper over :mod:`sweepito.api`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+ from sweepito import __version__, api, doctor, git, hooks
10
+ from sweepito.config import Options
11
+ from sweepito.errors import (
12
+ EXIT_FAILED,
13
+ EXIT_INTERRUPTED,
14
+ EXIT_OK,
15
+ EXIT_UNEXPECTED,
16
+ EXIT_USAGE,
17
+ ConfigError,
18
+ SafetyRefusal,
19
+ SweepitoError,
20
+ )
21
+ from sweepito.models import Plan, Risk, format_size
22
+ from sweepito.planner import build_plan
23
+ from sweepito.registry import Registry
24
+ from sweepito.ui import (
25
+ error,
26
+ info,
27
+ make_console,
28
+ print_checks,
29
+ print_config,
30
+ print_hook_status,
31
+ print_plan,
32
+ print_profile_detail,
33
+ print_profiles,
34
+ print_report,
35
+ print_result,
36
+ print_rule_detail,
37
+ print_rules,
38
+ print_summary,
39
+ success,
40
+ warning,
41
+ )
42
+
43
+ RISK_CHOICES = ["custom", "safe", "configurable", "high-impact"]
44
+
45
+
46
+ def _global_parent() -> argparse.ArgumentParser:
47
+ p = argparse.ArgumentParser(add_help=False)
48
+ p.add_argument(
49
+ "--config", metavar="FILE", help="Use this config file (skips discovery)."
50
+ )
51
+ p.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
52
+ p.add_argument("-q", "--quiet", action="store_true", help="Print only when needed.")
53
+ p.add_argument(
54
+ "-v", "--verbose", action="store_true", help="Explain skipped paths."
55
+ )
56
+ p.add_argument("--no-color", action="store_true", help="Disable colored output.")
57
+ p.add_argument("--hook", action="store_true", help=argparse.SUPPRESS)
58
+ return p
59
+
60
+
61
+ def _selection_parent() -> argparse.ArgumentParser:
62
+ p = argparse.ArgumentParser(add_help=False)
63
+ p.add_argument("path", nargs="?", help="Project root (default: git root or cwd).")
64
+ p.add_argument(
65
+ "-p",
66
+ "--profile",
67
+ action="append",
68
+ default=[],
69
+ dest="profiles",
70
+ metavar="NAME",
71
+ help="Only use this profile (repeatable).",
72
+ )
73
+ p.add_argument(
74
+ "--enable",
75
+ action="append",
76
+ default=[],
77
+ metavar="RULE",
78
+ help="Enable a rule by ID (repeatable).",
79
+ )
80
+ p.add_argument(
81
+ "--disable",
82
+ action="append",
83
+ default=[],
84
+ metavar="RULE",
85
+ help="Disable a rule by ID (repeatable).",
86
+ )
87
+ p.add_argument(
88
+ "-x",
89
+ "--exclude",
90
+ action="append",
91
+ default=[],
92
+ metavar="PATTERN",
93
+ help="Never touch matching paths (repeatable).",
94
+ )
95
+ p.add_argument(
96
+ "--risk",
97
+ choices=RISK_CHOICES,
98
+ help="Highest risk level to include (custom: only your own rules).",
99
+ )
100
+ p.add_argument(
101
+ "--no-git-check",
102
+ action="store_true",
103
+ help="Allow running outside a Git repository (high impact).",
104
+ )
105
+ return p
106
+
107
+
108
+ def build_parser() -> argparse.ArgumentParser:
109
+ glob, sel = _global_parent(), _selection_parent()
110
+ parser = argparse.ArgumentParser(
111
+ prog="sweepito",
112
+ description="Safely clean caches and build artifacts across technologies.",
113
+ )
114
+ parser.add_argument(
115
+ "--version", action="version", version=f"sweepito {__version__}"
116
+ )
117
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
118
+
119
+ scan = sub.add_parser("scan", parents=[glob, sel], help="List cleanup candidates.")
120
+ scan.set_defaults(func=cmd_scan)
121
+
122
+ clean = sub.add_parser(
123
+ "clean", parents=[glob, sel], help="Delete cleanup candidates."
124
+ )
125
+ clean.add_argument("-n", "--dry-run", action="store_true", help="Same as scan.")
126
+ clean.add_argument(
127
+ "-y", "--yes", action="store_true", help="Do not ask for confirmation."
128
+ )
129
+ clean.add_argument(
130
+ "--non-interactive",
131
+ action="store_true",
132
+ help="Never prompt; proceed with the configured rules.",
133
+ )
134
+ clean.set_defaults(func=cmd_clean)
135
+
136
+ check = sub.add_parser(
137
+ "check", parents=[glob, sel], help="Exit with code 1 if candidates exist."
138
+ )
139
+ check.set_defaults(func=cmd_check)
140
+
141
+ report = sub.add_parser(
142
+ "report",
143
+ parents=[glob, sel],
144
+ help="Summarise reclaimable space by profile, rule and risk.",
145
+ )
146
+ report.set_defaults(func=cmd_report)
147
+
148
+ rules = sub.add_parser("rules", parents=[glob], help="List profiles and rules.")
149
+ rules.add_argument("path", nargs="?", help="Project root (for custom rules).")
150
+ rules.add_argument("-p", "--profile", action="append", default=[], dest="profiles")
151
+ rules.add_argument(
152
+ "--risk", choices=RISK_CHOICES, help="Only rules at exactly this risk level."
153
+ )
154
+ rules.add_argument("--explain", metavar="RULE", help="Describe one rule.")
155
+ rules.set_defaults(func=cmd_rules)
156
+
157
+ profiles = sub.add_parser("profiles", parents=[glob], help="List profiles.")
158
+ profiles.add_argument("path", nargs="?", help="Project root (for custom profiles).")
159
+ profiles.add_argument("--explain", metavar="PROFILE", help="Describe one profile.")
160
+ profiles.set_defaults(func=cmd_profiles)
161
+
162
+ cfg = sub.add_parser("config", help="Show or validate configuration.")
163
+ cfg_sub = cfg.add_subparsers(dest="action", metavar="ACTION", required=True)
164
+ for name, text in (
165
+ ("show", "Show effective settings."),
166
+ ("validate", "Validate the configuration."),
167
+ ):
168
+ leaf = cfg_sub.add_parser(name, parents=[glob], help=text)
169
+ leaf.add_argument("path", nargs="?", help="Project root.")
170
+ leaf.set_defaults(func=cmd_config)
171
+
172
+ init = sub.add_parser("init", parents=[glob], help="Write a starter sweepito.toml.")
173
+ init.add_argument("path", nargs="?", help="Project root.")
174
+ init.add_argument(
175
+ "--force", action="store_true", help="Overwrite an existing file."
176
+ )
177
+ init.set_defaults(func=cmd_init)
178
+
179
+ hook = sub.add_parser("hook", help="Manage the native Git hook.")
180
+ hook_sub = hook.add_subparsers(dest="action", metavar="ACTION", required=True)
181
+ for name, text in (
182
+ ("install", "Install the hook."),
183
+ ("uninstall", "Remove the hook."),
184
+ ("status", "Show installed hooks."),
185
+ ):
186
+ leaf = hook_sub.add_parser(name, parents=[glob], help=text)
187
+ leaf.add_argument("path", nargs="?", help="Project root.")
188
+ if name != "status":
189
+ leaf.add_argument(
190
+ "--stage", choices=sorted(hooks.STAGES), default="post-commit"
191
+ )
192
+ leaf.set_defaults(func=cmd_hook)
193
+
194
+ doc = sub.add_parser("doctor", parents=[glob], help="Diagnose the environment.")
195
+ doc.add_argument("path", nargs="?", help="Project root.")
196
+ doc.set_defaults(func=cmd_doctor)
197
+ return parser
198
+
199
+
200
+ def _options(args: argparse.Namespace) -> Options:
201
+ return Options(
202
+ profiles=tuple(getattr(args, "profiles", ()) or ()),
203
+ enable=tuple(getattr(args, "enable", ()) or ()),
204
+ disable=tuple(getattr(args, "disable", ()) or ()),
205
+ exclude=tuple(getattr(args, "exclude", ()) or ()),
206
+ risk=(Risk.parse(args.risk) if getattr(args, "risk", None) else None),
207
+ no_git_check=getattr(args, "no_git_check", False),
208
+ hook=args.hook,
209
+ )
210
+
211
+
212
+ def _context(args: argparse.Namespace) -> api.Context:
213
+ root = api.resolve_root(args.path)
214
+ return api.prepare(root, _options(args), args.config)
215
+
216
+
217
+ def _emit(payload: dict) -> None:
218
+ print(json.dumps(payload, indent=2))
219
+
220
+
221
+ def _plan(args: argparse.Namespace) -> tuple[api.Context, Plan]:
222
+ ctx = _context(args)
223
+ return ctx, api.scan(ctx)
224
+
225
+
226
+ def _quiet(args: argparse.Namespace, ctx: api.Context) -> bool:
227
+ return args.quiet or ctx.settings.quiet
228
+
229
+
230
+ def _scan_and_report(args: argparse.Namespace) -> Plan:
231
+ ctx, plan = _plan(args)
232
+ if args.json:
233
+ _emit({**plan.to_dict(), "dry_run": True})
234
+ return plan
235
+ console = make_console(args.no_color)
236
+ if _quiet(args, ctx):
237
+ if plan.candidates:
238
+ info(
239
+ console,
240
+ f"sweepito: {len(plan.candidates)} cleanup candidate(s), "
241
+ f"{format_size(plan.total_bytes)} reclaimable (run 'sweepito scan').",
242
+ )
243
+ return plan
244
+ print_plan(console, plan, args.verbose)
245
+ print_summary(console, plan)
246
+ return plan
247
+
248
+
249
+ def cmd_scan(args: argparse.Namespace) -> int:
250
+ _scan_and_report(args)
251
+ return EXIT_OK
252
+
253
+
254
+ def cmd_check(args: argparse.Namespace) -> int:
255
+ plan = _scan_and_report(args)
256
+ return EXIT_FAILED if plan.candidates else EXIT_OK
257
+
258
+
259
+ def _confirm(count: int, size: int) -> bool:
260
+ if not sys.stdin.isatty():
261
+ return False
262
+ try:
263
+ answer = input(f"Delete {count} item(s), {format_size(size)}? [y/N] ")
264
+ except EOFError:
265
+ raise SafetyRefusal(
266
+ "Confirmation required but no input is available. "
267
+ "Pass --yes or --non-interactive."
268
+ ) from None
269
+ return answer.strip().lower() in ("y", "yes")
270
+
271
+
272
+ def cmd_clean(args: argparse.Namespace) -> int:
273
+ ctx, plan = _plan(args)
274
+ quiet = _quiet(args, ctx)
275
+ console = make_console(args.no_color)
276
+ if args.dry_run:
277
+ if args.json:
278
+ _emit({**plan.to_dict(), "dry_run": True})
279
+ else:
280
+ print_plan(console, plan, args.verbose)
281
+ print_summary(console, plan)
282
+ return EXIT_OK
283
+ if not plan.candidates:
284
+ if args.json:
285
+ _emit({**plan.to_dict(), "dry_run": False, **_empty_result()})
286
+ elif not quiet:
287
+ print_summary(console, plan, deleting=True)
288
+ return EXIT_OK
289
+ if not (args.yes or args.non_interactive):
290
+ if not sys.stdin.isatty():
291
+ raise SafetyRefusal(
292
+ "Confirmation required but no terminal is attached. "
293
+ "Pass --yes or --non-interactive."
294
+ )
295
+ print_plan(console, plan, args.verbose)
296
+ if not _confirm(len(plan.candidates), plan.total_bytes):
297
+ info(console, "Aborted. Nothing deleted.")
298
+ return EXIT_OK
299
+ result = api.clean(ctx, plan)
300
+ if args.json:
301
+ _emit({**plan.to_dict(), "dry_run": False, **result.to_dict()})
302
+ elif not quiet or result.failed:
303
+ print_result(make_console(args.no_color, stderr=quiet), result)
304
+ return EXIT_FAILED if result.failed else EXIT_OK
305
+
306
+
307
+ def _empty_result() -> dict:
308
+ return {"removed": [], "failed": [], "freed_bytes": 0}
309
+
310
+
311
+ def cmd_report(args: argparse.Namespace) -> int:
312
+ ctx = _context(args)
313
+ # Informational only: plan everything, delete nothing.
314
+ from dataclasses import replace
315
+
316
+ settings = replace(ctx.settings, risk=Risk.HIGH_IMPACT)
317
+ plan = build_plan(ctx.root, settings, ctx.registry)
318
+ groups: dict[str, dict[str, dict]] = {"profile": {}, "rule": {}, "risk": {}}
319
+ for cand in plan.candidates:
320
+ for kind, key in (
321
+ ("profile", cand.profile),
322
+ ("rule", cand.rule),
323
+ ("risk", cand.risk.label),
324
+ ):
325
+ slot = groups[kind].setdefault(key, {"count": 0, "bytes": 0})
326
+ if kind == "rule":
327
+ slot["risk"] = cand.risk.label
328
+ slot["count"] += 1
329
+ slot["bytes"] += cand.size
330
+ if args.json:
331
+ _emit({"root": str(plan.root), "total_bytes": plan.total_bytes, **groups})
332
+ return EXIT_OK
333
+ print_report(make_console(args.no_color), str(plan.root), groups, plan.total_bytes)
334
+ return EXIT_OK
335
+
336
+
337
+ def cmd_rules(args: argparse.Namespace) -> int:
338
+ root = api.resolve_root(args.path)
339
+ ctx = api.prepare(root, Options(hook=args.hook), args.config)
340
+ registry: Registry = ctx.registry
341
+ if args.explain:
342
+ rule = registry.rules.get(args.explain)
343
+ if rule is None:
344
+ raise ConfigError(f"Unknown rule ID {args.explain!r}.")
345
+ details = {
346
+ "id": rule.id,
347
+ "profile": rule.profile,
348
+ "risk": rule.risk.label,
349
+ "paths": list(rule.paths),
350
+ "requires": list(rule.requires or registry.profiles[rule.profile].markers),
351
+ "description": rule.description,
352
+ "allow_untracked": rule.allow_untracked,
353
+ }
354
+ if args.json:
355
+ _emit(details)
356
+ else:
357
+ print_rule_detail(make_console(args.no_color), details)
358
+ return EXIT_OK
359
+ rows = []
360
+ for profile in registry.profiles.values():
361
+ if args.profiles and profile.id not in args.profiles:
362
+ continue
363
+ for rule in profile.rules:
364
+ if args.risk and rule.risk.label != args.risk:
365
+ continue
366
+ rows.append(rule)
367
+ if args.json:
368
+ _emit(
369
+ {
370
+ "rules": [
371
+ {
372
+ "id": r.id,
373
+ "profile": r.profile,
374
+ "risk": r.risk.label,
375
+ "default": r.risk in (Risk.SAFE, Risk.CUSTOM),
376
+ "description": r.description,
377
+ }
378
+ for r in rows
379
+ ]
380
+ }
381
+ )
382
+ return EXIT_OK
383
+ print_rules(make_console(args.no_color), rows)
384
+ return EXIT_OK
385
+
386
+
387
+ def cmd_profiles(args: argparse.Namespace) -> int:
388
+ root = api.resolve_root(args.path)
389
+ registry = api.prepare(root, Options(hook=args.hook), args.config).registry
390
+ if args.explain:
391
+ profile = registry.profiles.get(args.explain)
392
+ if profile is None:
393
+ raise ConfigError(f"Unknown profile {args.explain!r}.")
394
+ if args.json:
395
+ _emit(
396
+ {
397
+ "id": profile.id,
398
+ "description": profile.description,
399
+ "markers": list(profile.markers),
400
+ "always_active": profile.always,
401
+ "rules": [
402
+ {
403
+ "id": r.id,
404
+ "risk": r.risk.label,
405
+ "paths": list(r.paths),
406
+ "requires": list(r.requires or profile.markers),
407
+ "description": r.description,
408
+ }
409
+ for r in profile.rules
410
+ ],
411
+ }
412
+ )
413
+ else:
414
+ print_profile_detail(make_console(args.no_color), profile)
415
+ return EXIT_OK
416
+ profiles = list(registry.profiles.values())
417
+ if args.json:
418
+ _emit(
419
+ {
420
+ "profiles": [
421
+ {
422
+ "id": p.id,
423
+ "description": p.description,
424
+ "markers": list(p.markers),
425
+ "always_active": p.always,
426
+ "rules": len(p.rules),
427
+ **{k.replace("-", "_"): v for k, v in p.risk_counts().items()},
428
+ }
429
+ for p in profiles
430
+ ]
431
+ }
432
+ )
433
+ else:
434
+ print_profiles(make_console(args.no_color), profiles)
435
+ return EXIT_OK
436
+
437
+
438
+ def cmd_config(args: argparse.Namespace) -> int:
439
+ ctx = _context(args)
440
+ if args.action == "validate":
441
+ if args.json:
442
+ _emit({"valid": True})
443
+ else:
444
+ success(make_console(args.no_color), "Configuration OK.")
445
+ return EXIT_OK
446
+ payload = {
447
+ "config_file": str(ctx.config.path) if ctx.config.path else None,
448
+ "settings": ctx.settings.to_dict(),
449
+ "custom_rules": [r.id for r in ctx.config.rules],
450
+ }
451
+ if args.json:
452
+ _emit(payload)
453
+ else:
454
+ print_config(make_console(args.no_color), payload)
455
+ return EXIT_OK
456
+
457
+
458
+ _TEMPLATE = """\
459
+ # Sweepito configuration. See 'sweepito rules' for rule IDs.
460
+
461
+ [defaults]
462
+ profiles = ["auto"] # or an explicit list, e.g. ["python", "node"]
463
+ risk = "safe" # custom | safe | configurable | high-impact
464
+
465
+ [clean]
466
+ enable = [] # e.g. ["rust.target", "node.next"]
467
+ disable = []
468
+ exclude = [] # e.g. ["dist/", "build/keep-me/"]
469
+
470
+ [hook]
471
+ quiet = true
472
+
473
+ # Custom path rule: only git-ignored, untracked paths are ever removed.
474
+ # [[rule]]
475
+ # id = "custom.test-output"
476
+ # paths = ["test-results/", "tmp/e2e/**"]
477
+ """
478
+
479
+
480
+ def cmd_init(args: argparse.Namespace) -> int:
481
+ root = api.resolve_root(args.path)
482
+ target = root / "sweepito.toml"
483
+ if target.exists() and not args.force:
484
+ raise SafetyRefusal(f"{target} already exists. Use --force to overwrite.")
485
+ ctx = api.prepare(root, Options(), args.config)
486
+ detected = api.scan(ctx).profiles if ctx.in_git else []
487
+ header = (
488
+ f"# Detected profiles: {', '.join(detected) or 'none'}\n" if detected else ""
489
+ )
490
+ target.write_text(header + _TEMPLATE, encoding="utf-8", newline="\n")
491
+ success(make_console(args.no_color), f"Wrote {target}")
492
+ return EXIT_OK
493
+
494
+
495
+ def cmd_hook(args: argparse.Namespace) -> int:
496
+ root = api.resolve_root(args.path)
497
+ if git.toplevel(root) is None:
498
+ raise SafetyRefusal("Hooks need a Git repository.")
499
+ console = make_console(args.no_color)
500
+ if args.action == "install":
501
+ path = hooks.install(root, args.stage)
502
+ success(console, f"Installed {args.stage} hook: {path}")
503
+ elif args.action == "uninstall":
504
+ if hooks.uninstall(root, args.stage):
505
+ success(console, f"Removed {args.stage} hook.")
506
+ else:
507
+ info(console, f"No {args.stage} hook installed.")
508
+ else:
509
+ state = hooks.status(root)
510
+ if args.json:
511
+ _emit(state)
512
+ else:
513
+ print_hook_status(console, state)
514
+ return EXIT_OK
515
+
516
+
517
+ def cmd_doctor(args: argparse.Namespace) -> int:
518
+ checks = doctor.run_checks(args.path, args.config)
519
+ healthy = doctor.passed(checks)
520
+ if args.json:
521
+ _emit({"healthy": healthy, "checks": [c.to_dict() for c in checks]})
522
+ else:
523
+ print_checks(make_console(args.no_color), checks)
524
+ return EXIT_OK if healthy else EXIT_FAILED
525
+
526
+
527
+ def _stderr(args: argparse.Namespace):
528
+ return make_console(getattr(args, "no_color", False), stderr=True)
529
+
530
+
531
+ def main(argv: list[str] | None = None) -> int:
532
+ parser = build_parser()
533
+ args = parser.parse_args(argv)
534
+ if not getattr(args, "func", None):
535
+ parser.print_help()
536
+ return EXIT_USAGE
537
+ try:
538
+ return args.func(args)
539
+ except SweepitoError as exc:
540
+ error(_stderr(args), str(exc))
541
+ return exc.exit_code
542
+ except KeyboardInterrupt:
543
+ warning(_stderr(args), "sweepito: interrupted")
544
+ return EXIT_INTERRUPTED
545
+ except Exception as exc: # noqa: BLE001 - last-resort handler
546
+ error(_stderr(args), f"unexpected error: {exc!r}")
547
+ return EXIT_UNEXPECTED
548
+
549
+
550
+ if __name__ == "__main__":
551
+ raise SystemExit(main())