Первый выпуск 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:
396
sysdevcleaner/core/util.py
Normal file
396
sysdevcleaner/core/util.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""Низкоуровневые утилиты: размеры, пути, защита, корзина."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import string
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from typing import Callable, Iterable
|
||||
|
||||
IS_WIN = sys.platform == "win32"
|
||||
|
||||
# ---------------------------------------------------------------- размеры ----
|
||||
|
||||
_UNITS = ("Б", "КБ", "МБ", "ГБ", "ТБ", "ПБ")
|
||||
|
||||
|
||||
def human(size: float) -> str:
|
||||
"""1536 -> '1.5 КБ'."""
|
||||
size = float(size or 0)
|
||||
neg = size < 0
|
||||
size = abs(size)
|
||||
i = 0
|
||||
while size >= 1024 and i < len(_UNITS) - 1:
|
||||
size /= 1024.0
|
||||
i += 1
|
||||
txt = f"{size:.0f} {_UNITS[i]}" if (i == 0 or size >= 100) else f"{size:.1f} {_UNITS[i]}"
|
||||
return ("-" if neg else "") + txt
|
||||
|
||||
|
||||
class Cancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Cancel:
|
||||
"""Кооперативная отмена длительных обходов."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._e = threading.Event()
|
||||
|
||||
def cancel(self) -> None:
|
||||
self._e.set()
|
||||
|
||||
def reset(self) -> None:
|
||||
self._e.clear()
|
||||
|
||||
@property
|
||||
def cancelled(self) -> bool:
|
||||
return self._e.is_set()
|
||||
|
||||
def check(self) -> None:
|
||||
if self._e.is_set():
|
||||
raise Cancelled()
|
||||
|
||||
|
||||
def dir_stats(
|
||||
path: str,
|
||||
cancel: "Cancel | None" = None,
|
||||
max_entries: int = 400_000,
|
||||
follow_links: bool = False,
|
||||
) -> tuple[int, int, int, float]:
|
||||
"""Размер каталога: (байты, файлов, папок, max_mtime). Устойчиво к ошибкам."""
|
||||
total = files = dirs = 0
|
||||
newest = 0.0
|
||||
seen = 0
|
||||
stack = [path]
|
||||
while stack:
|
||||
if cancel is not None and cancel.cancelled:
|
||||
break
|
||||
cur = stack.pop()
|
||||
try:
|
||||
it = os.scandir(cur)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
with it:
|
||||
while True:
|
||||
try:
|
||||
entry = next(it)
|
||||
except StopIteration:
|
||||
break
|
||||
except OSError:
|
||||
break
|
||||
seen += 1
|
||||
if seen > max_entries:
|
||||
stack.clear()
|
||||
break
|
||||
try:
|
||||
if entry.is_dir(follow_symlinks=follow_links):
|
||||
dirs += 1
|
||||
stack.append(entry.path)
|
||||
elif entry.is_file(follow_symlinks=follow_links):
|
||||
st = entry.stat(follow_symlinks=False)
|
||||
files += 1
|
||||
total += st.st_size
|
||||
if st.st_mtime > newest:
|
||||
newest = st.st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if not newest:
|
||||
try:
|
||||
newest = os.stat(path).st_mtime
|
||||
except OSError:
|
||||
newest = 0.0
|
||||
return total, files, dirs, newest
|
||||
|
||||
|
||||
def path_size(path: str, cancel: "Cancel | None" = None) -> tuple[int, int, int, float]:
|
||||
"""Размер файла или каталога в том же формате, что и dir_stats."""
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
return 0, 0, 0, 0.0
|
||||
if stat.S_ISDIR(st.st_mode):
|
||||
return dir_stats(path, cancel)
|
||||
return st.st_size, 1, 0, st.st_mtime
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ пути ----
|
||||
|
||||
|
||||
def env(name: str) -> str:
|
||||
return os.environ.get(name, "") or ""
|
||||
|
||||
|
||||
def expand(path: str) -> str:
|
||||
return os.path.expandvars(os.path.expanduser(path))
|
||||
|
||||
|
||||
def known_paths() -> dict[str, str]:
|
||||
home = os.path.expanduser("~")
|
||||
local = env("LOCALAPPDATA") or os.path.join(home, "AppData", "Local")
|
||||
roaming = env("APPDATA") or os.path.join(home, "AppData", "Roaming")
|
||||
return {
|
||||
"HOME": home,
|
||||
"LOCAL": local,
|
||||
"LOCALLOW": os.path.join(os.path.dirname(local), "LocalLow") if local else "",
|
||||
"ROAMING": roaming,
|
||||
"TEMP": env("TEMP") or os.path.join(local, "Temp"),
|
||||
"WINDIR": env("WINDIR") or "C:\\Windows",
|
||||
"SYSTEMDRIVE": (env("SYSTEMDRIVE") or "C:") + "\\",
|
||||
"PROGRAMDATA": env("PROGRAMDATA") or "C:\\ProgramData",
|
||||
"PROGRAMFILES": env("PROGRAMFILES") or "C:\\Program Files",
|
||||
"PROGRAMFILES86": env("ProgramFiles(x86)") or "C:\\Program Files (x86)",
|
||||
"PUBLIC": env("PUBLIC") or "C:\\Users\\Public",
|
||||
}
|
||||
|
||||
|
||||
def drives() -> list[str]:
|
||||
"""Список смонтированных дисков ('C:\\', 'D:\\')."""
|
||||
if not IS_WIN:
|
||||
return ["/"]
|
||||
out = []
|
||||
try:
|
||||
mask = ctypes.windll.kernel32.GetLogicalDrives()
|
||||
except Exception:
|
||||
mask = 0
|
||||
for i, letter in enumerate(string.ascii_uppercase):
|
||||
if mask >> i & 1:
|
||||
root = f"{letter}:\\"
|
||||
try:
|
||||
dtype = ctypes.windll.kernel32.GetDriveTypeW(ctypes.c_wchar_p(root))
|
||||
except Exception:
|
||||
dtype = 3
|
||||
if dtype in (2, 3, 4): # removable / fixed / network
|
||||
out.append(root)
|
||||
return out or ["C:\\"]
|
||||
|
||||
|
||||
def disk_usage(root: str) -> dict:
|
||||
try:
|
||||
u = shutil.disk_usage(root)
|
||||
return {
|
||||
"root": root,
|
||||
"total": u.total,
|
||||
"used": u.used,
|
||||
"free": u.free,
|
||||
"percent": (u.used / u.total * 100) if u.total else 0.0,
|
||||
}
|
||||
except OSError:
|
||||
return {"root": root, "total": 0, "used": 0, "free": 0, "percent": 0.0}
|
||||
|
||||
|
||||
# --------------------------------------------------------------- защита -----
|
||||
|
||||
|
||||
def _norm(p: str) -> str:
|
||||
return os.path.normcase(os.path.abspath(p)).rstrip("\\/")
|
||||
|
||||
|
||||
def _protected_roots() -> set[str]:
|
||||
k = known_paths()
|
||||
raw = [
|
||||
k["WINDIR"],
|
||||
os.path.join(k["WINDIR"], "System32"),
|
||||
os.path.join(k["WINDIR"], "SysWOW64"),
|
||||
k["PROGRAMFILES"],
|
||||
k["PROGRAMFILES86"],
|
||||
k["SYSTEMDRIVE"],
|
||||
k["HOME"],
|
||||
k["LOCAL"],
|
||||
k["ROAMING"],
|
||||
k["PROGRAMDATA"],
|
||||
os.path.join(k["HOME"], "Documents"),
|
||||
os.path.join(k["HOME"], "Desktop"),
|
||||
os.path.join(k["HOME"], "Downloads"),
|
||||
os.path.join(k["HOME"], "Pictures"),
|
||||
os.path.join(k["HOME"], "Videos"),
|
||||
os.path.join(k["HOME"], "Music"),
|
||||
os.path.join(k["HOME"], "OneDrive"),
|
||||
]
|
||||
out = {_norm(p) for p in raw if p}
|
||||
out |= {_norm(d) for d in drives()}
|
||||
return out
|
||||
|
||||
|
||||
PROTECTED = _protected_roots()
|
||||
|
||||
|
||||
def is_protected(path: str) -> bool:
|
||||
"""Нельзя удалять сам путь (но можно то, что глубже внутри)."""
|
||||
n = _norm(path)
|
||||
if not n or len(n) <= 3:
|
||||
return True
|
||||
return n in PROTECTED
|
||||
|
||||
|
||||
def is_safe_target(path: str) -> tuple[bool, str]:
|
||||
"""Финальная проверка перед удалением."""
|
||||
if not path:
|
||||
return False, "пустой путь"
|
||||
n = _norm(path)
|
||||
if len(n) <= 3:
|
||||
return False, "корень диска"
|
||||
if is_protected(path):
|
||||
return False, "системный/пользовательский корневой каталог"
|
||||
depth = n.replace("/", "\\").count("\\")
|
||||
if depth < 2:
|
||||
return False, "слишком высокий уровень в дереве"
|
||||
return True, ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------- удаление ---
|
||||
|
||||
|
||||
def _on_rm_error(func: Callable, path: str, exc_info) -> None:
|
||||
"""Снимаем read-only и пробуем ещё раз."""
|
||||
try:
|
||||
os.chmod(path, stat.S_IWRITE)
|
||||
func(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def delete_path(path: str, to_recycle_bin: bool = True) -> tuple[bool, str]:
|
||||
"""Удалить файл/каталог. Возвращает (успех, сообщение об ошибке)."""
|
||||
if not os.path.exists(path):
|
||||
return True, ""
|
||||
if to_recycle_bin and IS_WIN:
|
||||
ok, err = _recycle(path)
|
||||
if ok:
|
||||
return True, ""
|
||||
# корзина не справилась (сетевой диск, слишком большой объект) — не насилуем
|
||||
return False, err
|
||||
try:
|
||||
if os.path.isdir(path) and not os.path.islink(path):
|
||||
shutil.rmtree(path, onerror=_on_rm_error)
|
||||
else:
|
||||
try:
|
||||
os.chmod(path, stat.S_IWRITE)
|
||||
except OSError:
|
||||
pass
|
||||
os.remove(path)
|
||||
if os.path.exists(path):
|
||||
return False, "путь остался на месте"
|
||||
return True, ""
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def _recycle(path: str) -> tuple[bool, str]:
|
||||
"""SHFileOperationW с флагом FOF_ALLOWUNDO."""
|
||||
try:
|
||||
from ctypes import wintypes
|
||||
|
||||
class SHFILEOPSTRUCTW(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("hwnd", wintypes.HWND),
|
||||
("wFunc", wintypes.UINT),
|
||||
("pFrom", wintypes.LPCWSTR),
|
||||
("pTo", wintypes.LPCWSTR),
|
||||
("fFlags", ctypes.c_uint16),
|
||||
("fAnyOperationsAborted", wintypes.BOOL),
|
||||
("hNameMappings", ctypes.c_void_p),
|
||||
("lpszProgressTitle", wintypes.LPCWSTR),
|
||||
]
|
||||
|
||||
FO_DELETE = 3
|
||||
FOF_ALLOWUNDO = 0x0040
|
||||
FOF_NOCONFIRMATION = 0x0010
|
||||
FOF_NOERRORUI = 0x0400
|
||||
FOF_SILENT = 0x0004
|
||||
FOF_NOCONFIRMMKDIR = 0x0200
|
||||
|
||||
op = SHFILEOPSTRUCTW()
|
||||
op.wFunc = FO_DELETE
|
||||
op.pFrom = os.path.abspath(path) + "\0\0"
|
||||
op.fFlags = (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI
|
||||
| FOF_SILENT | FOF_NOCONFIRMMKDIR)
|
||||
res = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op))
|
||||
if res == 0 and not op.fAnyOperationsAborted:
|
||||
return True, ""
|
||||
return False, f"SHFileOperation код {res}"
|
||||
except Exception as e: # noqa: BLE001
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def clear_contents(path: str, to_recycle_bin: bool = True) -> tuple[int, int, list[str]]:
|
||||
"""Очистить содержимое каталога, сам каталог оставить.
|
||||
|
||||
Возвращает (удалено, пропущено, ошибки).
|
||||
"""
|
||||
done = skipped = 0
|
||||
errs: list[str] = []
|
||||
try:
|
||||
entries = list(os.scandir(path))
|
||||
except OSError as e:
|
||||
return 0, 0, [f"{path}: {e}"]
|
||||
for entry in entries:
|
||||
ok, err = delete_path(entry.path, to_recycle_bin)
|
||||
if ok:
|
||||
done += 1
|
||||
else:
|
||||
skipped += 1
|
||||
if err and len(errs) < 20:
|
||||
errs.append(f"{entry.name}: {err}")
|
||||
return done, skipped, errs
|
||||
|
||||
|
||||
# ------------------------------------------------------------- процессы -----
|
||||
|
||||
|
||||
def running_processes() -> set[str]:
|
||||
"""Имена запущенных процессов в нижнем регистре."""
|
||||
names: set[str] = set()
|
||||
if not IS_WIN:
|
||||
return names
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["tasklist", "/FO", "CSV", "/NH"],
|
||||
capture_output=True, text=True, timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
errors="ignore",
|
||||
).stdout
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('"'):
|
||||
names.add(line.split('","')[0].strip('"').lower())
|
||||
except Exception:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
def is_admin() -> bool:
|
||||
if not IS_WIN:
|
||||
return os.geteuid() == 0 if hasattr(os, "geteuid") else False
|
||||
try:
|
||||
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def open_in_explorer(path: str) -> None:
|
||||
try:
|
||||
if IS_WIN:
|
||||
if os.path.isdir(path):
|
||||
os.startfile(path) # noqa: S606
|
||||
else:
|
||||
subprocess.Popen(["explorer", "/select,", os.path.abspath(path)])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", os.path.dirname(path)])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def iter_globs(patterns: Iterable[str]) -> list[str]:
|
||||
import glob as _g
|
||||
out: list[str] = []
|
||||
for p in patterns:
|
||||
try:
|
||||
out.extend(_g.glob(expand(p), recursive=True))
|
||||
except Exception:
|
||||
continue
|
||||
return out
|
||||
Reference in New Issue
Block a user