"""Движок сканирования: раскрывает правила в конкретные находки.""" from __future__ import annotations import fnmatch import glob import os import time from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from typing import Callable from . import devscan, heavy, programs as progmod from .advisor import build_recommendations from .model import Category, Finding, Risk, ScanResult from .rules import Rule, all_rules from .util import (Cancel, disk_usage, drives, human, is_protected, known_paths, running_processes) Progress = Callable[[str, float], None] # (сообщение, доля 0..1) @dataclass class ScanOptions: categories: set[Category] = field(default_factory=lambda: { Category.WINDOWS, Category.BROWSERS, Category.DEV_CACHE, Category.DEV_PROJECT, Category.APPS, Category.LOGS, Category.PACKAGE_MANAGERS, }) project_roots: list[str] = field(default_factory=list) scan_big_files: bool = False big_file_min: int = 200 * 1024 * 1024 scan_duplicates: bool = False duplicate_min: int = 10 * 1024 * 1024 scan_programs: bool = True measure_program_size: bool = True min_finding_size: int = 64 * 1024 workers: int = 12 project_min_size: int = 1024 * 1024 drives: set[str] = field(default_factory=set) """Ограничение по дискам, например ``{"C:", "D:"}``. Пусто — искать везде. Фильтр применяется и к правилам путей, и к поиску проектов, крупных файлов и дубликатов, поэтому «просканировать только D:» действительно не трогает C:. """ def drive_of(path: str) -> str: """'D:\\Desktop\\x' -> 'D:'. Для UNC-путей вернёт сам сетевой корень.""" return os.path.splitdrive(os.path.abspath(path))[0].upper() def normalize_drives(values) -> set[str]: """Привести 'd', 'D:', 'D:\\' к единому виду 'D:'.""" out: set[str] = set() for v in values or (): v = str(v).strip().upper().rstrip("\\/") if not v: continue if not v.endswith(":"): v += ":" out.add(v) return out def on_selected_drive(path: str, drives: set[str]) -> bool: return not drives or drive_of(path) in drives def roots_for(opts: "ScanOptions") -> list[str]: """Где искать проекты с учётом выбранных дисков.""" roots = opts.project_roots or default_project_roots() if not opts.drives: return roots kept = [r for r in roots if drive_of(r) in opts.drives] # диск выбран, но ни одной папки на нём не настроено — берём диск целиком for d in sorted(opts.drives): if any(drive_of(r) == d for r in kept): continue root = d + "\\" if os.path.isdir(root): kept.append(root) return kept def default_project_roots() -> list[str]: """Разумные места, где обычно лежат проекты.""" k = known_paths() home = k["HOME"] candidates = [ os.path.join(home, "Documents"), os.path.join(home, "Desktop"), os.path.join(home, "source"), os.path.join(home, "src"), os.path.join(home, "Projects"), os.path.join(home, "projects"), os.path.join(home, "dev"), os.path.join(home, "Dev"), os.path.join(home, "repos"), os.path.join(home, "git"), os.path.join(home, "code"), os.path.join(home, "work"), ] roots = [p for p in candidates if os.path.isdir(p)] for d in drives(): if os.path.normcase(d) == os.path.normcase(k["SYSTEMDRIVE"]): continue roots.append(d) # несистемные диски целиком # убираем вложенные дубликаты uniq: list[str] = [] for r in sorted(set(roots), key=len): n = os.path.normcase(os.path.abspath(r)) if any(n.startswith(os.path.normcase(os.path.abspath(u)) + os.sep) for u in uniq): continue uniq.append(r) return uniq # ------------------------------------------------------- раскрытие правил --- def _expand(pattern: str, kp: dict[str, str]) -> list[str]: try: raw = pattern.format(**kp) except (KeyError, IndexError, ValueError): return [] if any(ch in raw for ch in "*?["): try: return glob.glob(raw) except OSError: return [] return [raw] if os.path.exists(raw) else [] def _measure(rule: Rule, path: str, cancel: Cancel, procs: set[str]) -> Finding | None: from .util import dir_stats, path_size if not os.path.exists(path): return None if rule.contents_only and is_protected(path): # чистим только содержимое — сам защищённый каталог не трогаем, это допустимо pass elif is_protected(path): return None if rule.files_only: total = 0 newest = 0.0 count = 0 try: for e in os.scandir(path): try: if not e.is_file(follow_symlinks=False): continue if not any(fnmatch.fnmatch(e.name.lower(), m.lower()) for m in rule.files_only): continue st = e.stat(follow_symlinks=False) except OSError: continue total += st.st_size count += 1 newest = max(newest, st.st_mtime) except OSError: return None size, files, dirs, mtime = total, count, 0, newest elif os.path.isdir(path): size, files, dirs, mtime = dir_stats(path, cancel) else: size, files, dirs, mtime = path_size(path, cancel) if size < max(rule.min_size, 1): return None busy = [p for p in rule.processes if p.lower() in procs] detail = rule.detail if busy: detail += ("\n\n⚠ Сейчас запущено: " + ", ".join(busy) + ". Закройте программу, иначе часть файлов не удалится.") if rule.needs_admin: detail += "\n\nТребуются права администратора." return Finding( path=path, title=rule.title, category=rule.category, risk=rule.risk, size=size, files=files, dirs=dirs, mtime=mtime, detail=detail, group=rule.id, is_dir=os.path.isdir(path), delete_contents_only=rule.contents_only, extra={"rule": rule.id, "needs_admin": rule.needs_admin, "busy": busy, "file_masks": list(rule.files_only)}, ) def scan_rules( opts: ScanOptions, cancel: Cancel, progress: Progress | None = None, ) -> tuple[list[Finding], list[str]]: kp = known_paths() procs = running_processes() rules = [r for r in all_rules() if r.category in opts.categories] jobs: list[tuple[Rule, str]] = [] for rule in rules: for pattern in rule.paths: for path in _expand(pattern, kp): jobs.append((rule, path)) # дедупликация путей (одно правило может дать один и тот же путь дважды) seen: set[tuple[str, str]] = set() uniq_jobs = [] for rule, path in jobs: if not on_selected_drive(path, opts.drives): continue key = (rule.id, os.path.normcase(os.path.abspath(path))) if key in seen: continue seen.add(key) uniq_jobs.append((rule, path)) findings: list[Finding] = [] errors: list[str] = [] total = max(1, len(uniq_jobs)) done = 0 with ThreadPoolExecutor(max_workers=opts.workers) as pool: futures = {pool.submit(_measure, r, p, cancel, procs): (r, p) for r, p in uniq_jobs} for fut in as_completed(futures): done += 1 rule, path = futures[fut] if progress is not None and done % 5 == 0: progress(f"Проверяю: {rule.title}", done / total * 0.55) if cancel.cancelled: break try: f = fut.result() except Exception as e: # noqa: BLE001 errors.append(f"{path}: {e}") continue if f is not None and f.size >= opts.min_finding_size: findings.append(f) # если одно правило дало несколько путей (профили браузера) — оставляем все, # но убираем вложенные пути, чтобы не считать объём дважды findings = _drop_nested(findings) findings.sort(key=lambda f: -f.size) return findings, errors def _drop_nested(findings: list[Finding]) -> list[Finding]: """Убрать находки, лежащие внутри других находок того же правила.""" by_path = sorted(findings, key=lambda f: len(f.path)) kept: list[Finding] = [] roots: list[str] = [] for f in by_path: n = os.path.normcase(os.path.abspath(f.path)) if any(n.startswith(r + os.sep) for r in roots): continue kept.append(f) roots.append(n) return kept # ------------------------------------------------------------ полный скан --- def full_scan( opts: ScanOptions, cancel: Cancel, progress: Progress | None = None, ) -> ScanResult: res = ScanResult(started=time.time()) def say(msg: str, frac: float) -> None: if progress is not None: progress(msg, min(1.0, max(0.0, frac))) say("Собираю сведения о дисках…", 0.01) res.disks = [disk_usage(d) for d in drives() if on_selected_drive(d, opts.drives)] say("Ищу кеши и мусор по каталогу правил…", 0.03) findings, errors = scan_rules(opts, cancel, progress) res.findings.extend(findings) res.errors.extend(errors) if cancel.cancelled: res.finished = time.time() return res if Category.DEV_PROJECT in opts.categories: roots = roots_for(opts) say("Ищу артефакты сборки в проектах…", 0.58) proj = devscan.scan_projects( roots, cancel, progress=lambda p: say(f"Проекты: {_short(p)}", 0.62), min_size=opts.project_min_size, ) res.findings.extend(proj) if opts.scan_big_files and not cancel.cancelled: roots = roots_for(opts) say("Ищу крупные файлы…", 0.72) res.findings.extend(heavy.scan_big_files( roots, min_size=opts.big_file_min, cancel=cancel, progress=lambda p: say(f"Крупные файлы: {_short(p)}", 0.76), )) if opts.scan_duplicates and not cancel.cancelled: roots = roots_for(opts) say("Ищу дубликаты (это самая долгая часть)…", 0.80) res.findings.extend(heavy.scan_duplicates( roots, min_size=opts.duplicate_min, cancel=cancel, progress=lambda p: say(f"Дубликаты: {_short(p)}", 0.85), )) if opts.scan_programs and not cancel.cancelled: say("Читаю список установленных программ…", 0.90) res.programs = progmod.scan_programs( opts.measure_program_size, cancel, progress=lambda n: say(f"Программы: {n}", 0.93), ) say("Формирую рекомендации…", 0.97) res.recommendations = build_recommendations(res) res.scanned_paths = len(res.findings) res.finished = time.time() say(f"Готово: найдено {human(res.total())} в {len(res.findings)} местах", 1.0) return res def _short(path: str, width: int = 60) -> str: return path if len(path) <= width else "…" + path[-(width - 1):]