Анализ 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>
397 lines
12 KiB
Python
397 lines
12 KiB
Python
"""Низкоуровневые утилиты: размеры, пути, защита, корзина."""
|
||
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
|