Первый выпуск SysDevCleaner
Анализ Windows и очистка кешей, мусора и остатков разработки. Ядро: - каталог правил: 228 правил путей (496 шаблонов) + 43 правила артефактов проектов — Windows, браузеры, кеши сборки, IDE, приложения, пакетные менеджеры; - рекурсивный поиск node_modules, target, build, __pycache__, .venv и т.п. с проверкой маркеров проекта, чтобы не ловить ложные срабатывания; - поиск крупных файлов и дубликатов (хеш по началу/середине/концу и полная сверка), инвентаризация установленных программ через реестр; - ограничение области сканирования по дискам; - рекомендации и текстовый отчёт. Безопасность: - четыре уровня риска, автоматически отмечается только «Безопасно»; - список защищённых путей проверяется и при выборе, и перед удалением; - для каталогов вроде %TEMP% удаляется содержимое, а не сам каталог; - удаление в Корзину по умолчанию, пробный запуск, журнал очисток. Интерфейс на Dear PyGui: - JetBrains Mono Nerd Font: кириллица и иконки в одном шрифте; - тёмная и светлая темы, адаптивная раскладка; - заголовок окна ставится через SetWindowTextW — Dear PyGui отдаёт его в ANSI-функцию как UTF-8 и портит кириллицу; - запуск всегда с правами администратора: манифест UAC в exe и самоперезапуск через ShellExecuteW при работе из исходников. Сборка в один exe через PyInstaller со вшитым шрифтом и значком. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
188
sysdevcleaner/core/devscan.py
Normal file
188
sysdevcleaner/core/devscan.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""Рекурсивный поиск артефактов сборки и зависимостей внутри проектов."""
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from .model import Category, Finding
|
||||
from .rules import NEVER_DESCEND, PROJECT_RULES, SKIP_DIRS, ProjectRule
|
||||
from .util import Cancel, dir_stats, human
|
||||
|
||||
# имя каталога -> список правил с таким именем
|
||||
_BY_NAME: dict[str, list[ProjectRule]] = {}
|
||||
for _r in PROJECT_RULES:
|
||||
_BY_NAME.setdefault(_r.name.lower(), []).append(_r)
|
||||
|
||||
# файлы-маркеры, по которым каталог считается корнем проекта
|
||||
PROJECT_MARKERS = (
|
||||
"package.json", "pyproject.toml", "setup.py", "requirements.txt", "Cargo.toml",
|
||||
"go.mod", "pom.xml", "build.gradle", "build.gradle.kts", "composer.json", "Gemfile",
|
||||
"pubspec.yaml", "CMakeLists.txt", "Makefile", ".git", "*.sln", "*.csproj",
|
||||
"ProjectSettings", "*.uproject",
|
||||
)
|
||||
|
||||
|
||||
def _matches_marker(entries: set[str], markers: Iterable[str]) -> bool:
|
||||
"""Есть ли рядом файл, подтверждающий тип проекта."""
|
||||
for m in markers:
|
||||
if "*" in m or "?" in m:
|
||||
if any(fnmatch.fnmatch(e, m.lower()) for e in entries):
|
||||
return True
|
||||
elif m.lower() in entries:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _has_inner(path: str, inner: Iterable[str]) -> bool:
|
||||
"""Есть ли внутри каталога подтверждающий файл (pyvenv.cfg и т.п.)."""
|
||||
try:
|
||||
names = {e.name.lower() for e in os.scandir(path)}
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return any(m.lower() in names for m in inner)
|
||||
|
||||
|
||||
def scan_projects(
|
||||
roots: Iterable[str],
|
||||
cancel: Cancel | None = None,
|
||||
progress: Callable[[str], None] | None = None,
|
||||
max_depth: int = 9,
|
||||
min_size: int = 1024 * 1024,
|
||||
include_hidden: bool = False,
|
||||
) -> list[Finding]:
|
||||
"""Обойти ``roots`` и найти node_modules, target, build, __pycache__ и т.п.
|
||||
|
||||
Найденный артефакт не раскрывается вглубь — его размер считается целиком,
|
||||
а вложенные артефакты не дублируются в отчёте.
|
||||
"""
|
||||
findings: list[Finding] = []
|
||||
seen: set[str] = set()
|
||||
scanned = 0
|
||||
|
||||
for root in roots:
|
||||
root = os.path.abspath(root)
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
stack: list[tuple[str, int]] = [(root, 0)]
|
||||
while stack:
|
||||
if cancel is not None and cancel.cancelled:
|
||||
return findings
|
||||
cur, depth = stack.pop()
|
||||
if depth > max_depth:
|
||||
continue
|
||||
try:
|
||||
entries = list(os.scandir(cur))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
|
||||
scanned += 1
|
||||
if progress is not None and scanned % 200 == 0:
|
||||
progress(cur)
|
||||
|
||||
names = {e.name.lower() for e in entries}
|
||||
subdirs: list[os.DirEntry] = []
|
||||
for e in entries:
|
||||
try:
|
||||
if not e.is_dir(follow_symlinks=False):
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
subdirs.append(e)
|
||||
|
||||
for e in subdirs:
|
||||
low = e.name.lower()
|
||||
if low in SKIP_DIRS:
|
||||
continue
|
||||
if not include_hidden and low.startswith(".") and low not in _BY_NAME:
|
||||
# скрытые каталоги без собственного правила пропускаем
|
||||
continue
|
||||
|
||||
rules = _BY_NAME.get(low)
|
||||
matched: ProjectRule | None = None
|
||||
if rules:
|
||||
for rule in rules:
|
||||
if rule.marker and not _matches_marker(names, rule.marker):
|
||||
continue
|
||||
if rule.inner and not _has_inner(e.path, rule.inner):
|
||||
continue
|
||||
matched = rule
|
||||
break
|
||||
|
||||
if matched is not None:
|
||||
key = os.path.normcase(e.path)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
size, nfiles, ndirs, mtime = dir_stats(e.path, cancel)
|
||||
if size < min_size:
|
||||
continue
|
||||
project = os.path.basename(cur) or cur
|
||||
findings.append(Finding(
|
||||
path=e.path,
|
||||
title=f"{matched.title} — {project}",
|
||||
category=Category.DEV_PROJECT,
|
||||
risk=matched.risk,
|
||||
size=size, files=nfiles, dirs=ndirs, mtime=mtime,
|
||||
detail=(f"{matched.detail}\nПроект: {cur}\n"
|
||||
f"Экосистема: {matched.ecosystem or '—'} · "
|
||||
f"{nfiles:,} файлов · {human(size)}".replace(",", " ")),
|
||||
group=matched.title,
|
||||
extra={"project": cur, "ecosystem": matched.ecosystem,
|
||||
"artifact": matched.name},
|
||||
))
|
||||
if matched.stop:
|
||||
continue # внутрь не идём
|
||||
|
||||
if low in NEVER_DESCEND:
|
||||
continue # чужие зависимости — своих проектов внутри нет
|
||||
stack.append((e.path, depth + 1))
|
||||
|
||||
findings.sort(key=lambda f: -f.size)
|
||||
return findings
|
||||
|
||||
|
||||
def find_project_roots(
|
||||
roots: Iterable[str],
|
||||
cancel: Cancel | None = None,
|
||||
max_depth: int = 6,
|
||||
) -> list[str]:
|
||||
"""Быстро найти корни проектов — чтобы показать пользователю, где он программирует."""
|
||||
found: list[str] = []
|
||||
for root in roots:
|
||||
root = os.path.abspath(root)
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
stack = [(root, 0)]
|
||||
while stack:
|
||||
if cancel is not None and cancel.cancelled:
|
||||
return found
|
||||
cur, depth = stack.pop()
|
||||
if depth > max_depth:
|
||||
continue
|
||||
try:
|
||||
entries = list(os.scandir(cur))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
names = {e.name.lower() for e in entries}
|
||||
if _matches_marker(names, PROJECT_MARKERS):
|
||||
found.append(cur)
|
||||
continue # вложенные подпроекты не ищем
|
||||
for e in entries:
|
||||
try:
|
||||
if e.is_dir(follow_symlinks=False) and e.name.lower() not in SKIP_DIRS \
|
||||
and not e.name.startswith("."):
|
||||
stack.append((e.path, depth + 1))
|
||||
except OSError:
|
||||
continue
|
||||
return found
|
||||
|
||||
|
||||
def summarize_by_ecosystem(findings: list[Finding]) -> dict[str, tuple[int, int]]:
|
||||
"""{'JavaScript': (размер, количество)}"""
|
||||
out: dict[str, tuple[int, int]] = {}
|
||||
for f in findings:
|
||||
eco = f.extra.get("ecosystem") or "Прочее"
|
||||
size, cnt = out.get(eco, (0, 0))
|
||||
out[eco] = (size + f.size, cnt + 1)
|
||||
return dict(sorted(out.items(), key=lambda kv: -kv[1][0]))
|
||||
Reference in New Issue
Block a user