"""Консольный режим SysDevCleaner — для скриптов и быстрой проверки.""" from __future__ import annotations import argparse import sys from .core import cleaner as cleanmod from .core.advisor import report_text from .core.model import Category, Risk from .core.scanner import (ScanOptions, default_project_roots, full_scan, normalize_drives) from .core.util import Cancel, human, is_admin CAT_KEYS = { "windows": Category.WINDOWS, "browsers": Category.BROWSERS, "devcache": Category.DEV_CACHE, "projects": Category.DEV_PROJECT, "apps": Category.APPS, "logs": Category.LOGS, "pkg": Category.PACKAGE_MANAGERS, } RISK_KEYS = {"safe": Risk.SAFE, "low": Risk.LOW, "medium": Risk.MEDIUM, "high": Risk.HIGH} def out(text: str = "", **kw) -> None: """print, устойчивый к кодировке консоли. В русской Windows консоль обычно cp1251: кириллица в неё ложится, а эмодзи из названий категорий — нет, и обычный print падает с UnicodeEncodeError. Заменяем то, что консоль не осилит, на безопасный символ. """ enc = getattr(sys.stdout, "encoding", None) or "utf-8" try: text.encode(enc) except (UnicodeEncodeError, LookupError): text = text.encode(enc, "replace").decode(enc, "replace") print(text, **kw) def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="sysdevcleaner", description="Анализ и очистка кешей, мусора и остатков разработки.") p.add_argument("--gui", action="store_true", help="запустить графический интерфейс") p.add_argument("--categories", default="all", help="через запятую: " + ",".join(CAT_KEYS) + " или all") p.add_argument("--root", action="append", default=[], help="папка для поиска проектов (можно указать несколько раз)") p.add_argument("--drive", action="append", default=[], metavar="БУКВА", help="сканировать только указанный диск, например --drive D " "(можно указать несколько раз; по умолчанию — все)") p.add_argument("--big-files", type=int, metavar="МБ", default=0, help="искать файлы крупнее указанного размера") p.add_argument("--duplicates", type=int, metavar="МБ", default=0, help="искать дубликаты файлов крупнее указанного размера") p.add_argument("--no-programs", action="store_true", help="не собирать список программ") p.add_argument("--report", metavar="ФАЙЛ", help="сохранить подробный отчёт") p.add_argument("--top", type=int, default=30, help="сколько находок показать (по умолчанию 30)") p.add_argument("--clean", metavar="РИСК", choices=list(RISK_KEYS), help="удалить находки с риском не выше указанного") p.add_argument("--permanent", action="store_true", help="удалять мимо Корзины (по умолчанию — в Корзину)") p.add_argument("--dry-run", action="store_true", help="показать план, ничего не удаляя") p.add_argument("--yes", action="store_true", help="не спрашивать подтверждение") return p def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) if args.gui: from .__main__ import launch_gui return launch_gui() if args.categories.strip().lower() == "all": cats = set(CAT_KEYS.values()) else: cats = set() for key in args.categories.split(","): key = key.strip().lower() if key not in CAT_KEYS: print(f"Неизвестная категория: {key}", file=sys.stderr) return 2 cats.add(CAT_KEYS[key]) opts = ScanOptions( categories=cats, project_roots=args.root or default_project_roots(), scan_big_files=args.big_files > 0, big_file_min=max(1, args.big_files) * 1024 * 1024, scan_duplicates=args.duplicates > 0, duplicate_min=max(1, args.duplicates) * 1024 * 1024, scan_programs=not args.no_programs, drives=normalize_drives(args.drive), ) cancel = Cancel() last = [""] def progress(msg: str, frac: float) -> None: line = f" [{frac * 100:5.1f}%] {msg}"[:110] if line != last[0]: last[0] = line out(line.ljust(112), end="\r", flush=True) scope = ("диски: " + ", ".join(sorted(opts.drives))) if opts.drives else "все диски" out(f"SysDevCleaner · сканирование ({scope})" f"{'' if is_admin() else ' · без прав администратора'}…") try: res = full_scan(opts, cancel, progress) except KeyboardInterrupt: cancel.cancel() out("\nПрервано.") return 130 out(" " * 112, end="\r") out(f"\nНайдено {human(res.total())} в {len(res.findings)} местах " f"за {res.duration:.0f} с\n") for cat, items in sorted(res.by_category().items(), key=lambda kv: -sum(f.size for f in kv[1])): out(f" {cat.icon} {cat.label:<28} {human(sum(f.size for f in items)):>10} " f"({len(items)})") out(f"\nКрупнейшие находки (топ-{args.top}):") for f in res.findings[:args.top]: out(f" {human(f.size):>10} [{f.risk.label:<14}] {f.title}") out(f" {f.path}") if res.recommendations: out("\nРекомендации:") for i, r in enumerate(res.recommendations, 1): gain = f" — {human(r.gain)}" if r.gain else "" out(f" {i}. {r.title}{gain}") if args.report: try: with open(args.report, "w", encoding="utf-8") as fh: fh.write(report_text(res)) out(f"\nОтчёт сохранён: {args.report}") except OSError as e: print(f"\nНе удалось сохранить отчёт: {e}", file=sys.stderr) if args.clean: limit = RISK_KEYS[args.clean] targets = [f for f in res.findings if f.risk <= limit and f.category not in (Category.BIG_FILES, Category.DUPLICATES)] if not targets: out("\nНечего удалять с таким уровнем риска.") return 0 total = sum(f.size for f in targets) out(f"\nК удалению: {len(targets)} объектов, {human(total)} " f"(риск не выше «{limit.label}»)") if args.permanent: out("Режим: БЕЗВОЗВРАТНОЕ удаление мимо Корзины.") if args.dry_run: out("Пробный запуск — ничего не будет удалено.") if not args.yes and not args.dry_run: ans = input("Продолжить? [y/N] ").strip().lower() if ans not in ("y", "yes", "д", "да"): out("Отменено.") return 0 cres = cleanmod.clean( targets, cleanmod.CleanOptions(to_recycle_bin=not args.permanent, dry_run=args.dry_run), cancel, lambda m, f: progress(m, f), ) out(" " * 112, end="\r") out("\n" + cres.summary()) fails = [i for i in cres.items if not i.ok] if fails: out(f"\nНе удалось очистить {len(fails)}:") for i in fails[:15]: out(f" • {i.finding.title}: {i.message or 'файлы заняты'}") if cres.journal_path: out(f"\nЖурнал: {cres.journal_path}") return 0 if __name__ == "__main__": raise SystemExit(main())