Захламился %TEMP%, набралось гигов пять. Ну, думаю, накидаю утилитку — кнопка «почистить» да галка «делай это само раз в неделю». Дел на вечер.

И правда на вечер. Задача — проще некуда: пройтись по папке, поудалять старое. Весь код — пара сотен строк на Python. Но пока писал, попалось несколько штук, про которые захотелось рассказать. Не потому что сложные, а потому что неочевидные и местами прикольные. Вот про них и поговорим, саму рутину с os.remove я пропущу.

«Удалить всё» не катит — и это, по сути, единственное, что тут надо понять

Temp только выглядит помойкой. На деле там прямо сейчас лежат файлы, которыми кто-то пользуется: установщик распаковался и работает, браузер держит кэш сессии, апдейтер докачивает пакет. Снести всё подчистую — значит с ненулевым шансом прервать это на середине.

Поэтому одно правило, из которого дальше всё вытекает: непонятно — не удаляем. Любое сомнение трактуем в сторону «оставить». Всё, дальше просто аккуратно раскладываем это правило по мелочам.

На практике оно превращается в три вещи. Свежее не трогаем — всё моложе суток пропускаем, потому что оно скорее всего кому-то нужно. Занятое не трогаем — если файл держит процесс, Windows сам не даст его удалить, и слава богу, я и не лезу. Ну и список исключений — маски типа nvidia*, которые не тронутся никогда.

Собственно, всё. Дальше — те самые занятные детали.

Что считать «свежим» — и забавная штука про время на винде

Логично взять mtime и сравнить с «сейчас минус сутки». Я решил заодно глянуть на время создания файла — на винде оно в Python лежит в st_ctime (на Unix ctime про метаданные, а на винде исторически именно про рождение файла).

А потом заметил прикол: в Python 3.12 это поле потихоньку депрекейтят — завезли отдельный st_birthtime, а st_ctime обещают со временем переосмыслить. Где-то даже всплывало, что у объектов из os.scandir на винде ctime может приехать нулём.

Но мне на это, оказалось, вообще пофиг. Смотри: файл у меня «старый», только если стары обе метки. Значит, одной свежей достаточно, чтобы файл выжил. Что бы ни случилось с трактовкой ctime — время создания, метаданных, ноль, да хоть фаза луны — решение всегда едет в сторону «оставить». То самое правило из прошлого раздела всё разруливает за меня.

# выживает, если хоть одна метка свежая
if st.st_mtime >= cutoff or st.st_ctime >= cutoff:
    stats["fresh"] += 1
    continue

Мне тут нравится не код, а подход: иногда неоднозначность в API проще не решать, а построить логику так, чтобы она перестала что-либо значить.

По симлинкам не проваливаюсь

Обход рекурсивный, и теоретически в Temp может оказаться симлинк или junction, указывающий куда-нибудь в «Документы». Наивный обход туда провалится и начнёт удалять уже не из Temp. Не хочу.

Поэтому, во-первых, перед стартом проверяю саму папку: раскрываю путь до реального, требую, чтобы в нём был компонент Temp/Tmp, и отбиваю корень диска и домашнюю папку — на случай если %TEMP% вдруг окажется пустым и обход стартанёт не оттуда.

def check_root(path):
    real = os.path.realpath(path)              # раскрыли ссылки
    _, tail = os.path.splitdrive(real)
    parts = [p for p in tail.replace("/", "\\").split("\\") if p]
    if not parts:
        return "корень диска — нет"
    if not any(p.lower() in ("temp", "tmp") for p in parts):
        return "в пути нет Temp — нет"
    if real.rstrip("\\/").lower() == os.path.realpath(
            os.path.expanduser("~")).rstrip("\\/").lower():
        return "домашняя папка — нет"
    return None

А во-вторых, при обходе любую ссылку (junction на винде видно по атрибуту FILE_ATTRIBUTE_REPARSE_POINT) удаляю как ссылку, но внутрь по ней не захожу. И, чисто для спокойствия, перед спуском в папку ещё раз перепроверяю через lstat, что это всё ещё папка, а не подменённый за миллисекунду симлинк (та самая гонка TOCTOU). Реально это кто-то проэксплуатирует вряд ли, но проверка в три строки — почему нет.

if is_dir:
    st2 = os.lstat(entry.path)          # свежая проверка перед спуском
    if not stat.S_ISDIR(st2.st_mode) or \
       st2.st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT:
        continue
    clean_dir(entry.path)

Пара приколов в конфиге

Исключения и расписание храню в JSON рядом с программой. И тут два момента, которые меня слегка удивили.

Первый: захотел проверить, можно ли писать в папку рядом с exe. Пишу os.access(path, os.W_OK) — говорит «можно». Всегда говорит «можно». Оказывается, на винде os.access на запись толком не смотрит в ACL и для существующей папки почти всегда возвращает True. То есть как проверка прав он бесполезен. Ну ладно, проверяю честно — просто пробую создать файлик:

def _probe_writable(d):
    probe = d / f".probe_{os.getpid()}.tmp"
    try:
        probe.write_text("1"); probe.unlink()
        return True
    except OSError:
        return False

Второй момент мне нравится больше. Что если конфиг побился или его на секунду залочил антивирус? Легко представить программу, которая подумает «не смог прочитать исключения — возьму дефолтные» и снесёт ровно то, что человек пометил «не трогать». Поэтому я различаю три случая: прочитал, файла нет (беру дефолт), файл есть но не читается. В последнем — просто не чищу вообще:

patterns = get_excludes()
if patterns is None:            # файл есть, но не читается
    return {"error": "конфиг недоступен — очистку отменяю"}

Лучше пропустить разок, чем стереть не то. Всё то же правило, просто сбоку.

А вот это — самое интересное: чистим по расписанию без процесса в памяти

Ради этого, по сути, и затевалось «включил и забыл».

Первое, что приходит в голову, — свернуться в трей и крутить таймер. Но держать процесс в памяти ради задачи раз в неделю — это как посадить охранника у мусорки, чтобы он раз в семь дней выносил пакет. Не хочу.

Поэтому расписание я отдал системе, а прогу сделал одноразовой. Два механизма, оба зовут её с флагом --clean-if-due. Задание в Планировщике, раз в день:

schtasks /Create /F /TN TempCleanerAuto /SC DAILY /ST 12:00 ^
         /TR "TempCleaner.exe --clean-if-due"

И строчка в HKCU\...\Run — проверка при входе в систему, на случай если в полдень комп был выключен.

А весь смысл флага в том, что прога сама смотрит, пора ли, и почти всегда просто мгновенно закрывается, ничего после себя не оставляя:

if "--clean-if-due" in sys.argv:
    cfg = read_cfg()
    days = cfg.get("schedule_days", 0)
    last = cfg.get("last_run", 0)
    if days and time.time() - last >= days * 86400 - 3600:
        run_clean()
    sys.exit(0)     # рано — вышли, в памяти пусто

И тут вылез единственный по-настоящему неожиданный момент — не про код, про людей. Дал знакомому попробовать, он через день пишет: «слушай, а оно работает вообще? я его в процессах не вижу». И он прав — не видит, потому что видеть нечего, работает-то Планировщик.

Пришлось делать невидимое видимым: теперь каждая тихая проверка отмечается, и внизу окна висит строчка типа «Последняя автопроверка: сегодня, 08:44 — чистить было ещё рано». Свежая дата — и человек спокоен. Фича, которая для работы не делает ничего, а для доверия — всё.

Собрать и отдать

Скидывать знакомым python temp_cleaner.pyw — не вариант, нужен установщик.

exe собираю PyInstaller’ом (режим onedir, без консоли), инсталлятор — на NSIS. Ставлю в профиль пользователя, без прав админа — утилита лезет только в твой Temp, дёргать ради этого UAC как-то неприлично. Деинсталлятор при этом убирает за собой и задание Планировщика, и строчку из автозагрузки.

Кстати, PyInstaller спокойно собирает Windows-exe под Wine на Linux — отдельная винда даже не понадобилась. Подписи, правда, бесплатно не будет, так что на первом запуске люди словят SmartScreen с «неизвестный издатель»; это я честно написал в README.

Итого

Интересное в этой мелкой задачке оказалось не в удалении файлов, а вокруг него: где остановиться, как не выйти за папку, как вести себя, когда данные побились, и как сделать фоновую задачу, не оставляя лишний процесс. Ничего сложного, но подумать над каждым пунктом было приятно.

Если что и стоит забрать из поста — то мысль, что инструмент, который стирает, при любом сомнении должен оставлять. Копейки в реализации, а расплачивается за отсутствие всегда пользователь.

Код открыт (MIT), сборка — в релизах: GitHub. Найдёшь сценарий, где оно удаляет что-то мимо правил — кидай issue.