Статьи

watchfiles: руководство по эффективному мониторингу файлов в Python

2025-07-26 12:51 Опенсорс

Если вам когда-либо требовалось перезапускать веб-сервер при изменении кода, пересобирать статический сайт при правках в markdown-файлах или запускать тесты при обновлении исходников, вы сталкивались с задачей мониторинга файловой системы. Традиционно в Python для этого использовали библиотеки вроде watchdog или самописные решения на основе периодического сканирования (поллинга). Оба подхода имеют свои недостатки: от сложностей с зависимостями и производительностью до высокого потребления CPU.

watchfiles — это современная библиотека, которая решает эту проблему кардинально иначе. Она предлагает удобный способ отслеживания изменений файлов, который под капотом использует мощь Rust и нативные механизмы операционной системы. Это не просто очередная обертка, а фундаментально иной подход, который делает мониторинг файлов быстрым, надежным и экономичным.

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

Что такое watchfiles и почему это важно?

На первый взгляд, watchfiles делает то же самое, что и другие библиотеки: сообщает вам, когда файлы или каталоги были добавлены, изменены или удалены. Дьявол, как всегда, в деталях, а точнее — в реализации.

Ключевое отличие watchfiles — ее производительность и эффективность. Вместо того чтобы постоянно опрашивать файловую систему в цикле на Python, тратя ресурсы CPU, watchfiles делегирует эту задачу скомпилированному коду на Rust, который, в свою очередь, использует лучшие из доступных в ОС механизмов уведомлений:

  • inotify на Linux
  • FSEvents на macOS
  • ReadDirectoryChangesW на Windows

Это означает, что watchfiles практически не потребляет ресурсы, пока в файловой системе ничего не происходит. Пробуждение происходит только тогда, когда ядро операционной системы само сообщает о событии. Такой подход на порядки эффективнее любого решения на чистом Python, основанного на поллинге.

[!INFO] Библиотека watchfiles является идейным и технологическим преемником watchgod. Основные изменения — переход с поллинга на нативные уведомления через Rust и значительное упрощение API.

Под капотом: как watchfiles достигает производительности

Чтобы по-настоящему оценить watchfiles, нужно заглянуть в ее "двигатель". Архитектура библиотеки — это элегантный пример того, как можно объединить сильные стороны Python и Rust для создания высокопроизводительного инструмента.

Rust-сердце: библиотека notify

В основе watchfiles лежит популярная Rust-библиотека notify. Она предоставляет унифицированный API для работы с нативными системами уведомлений на разных ОС. Вся "грязная" работа по взаимодействию с низкоуровневыми системными вызовами скрыта внутри notify.

Python-часть watchfiles через биндинги PyO3 общается с Rust-компонентом, который инкапсулирует логику notify. Это позволяет достичь производительности, близкой к нативной, и избежать узких мест, связанных с GIL (Global Interpreter Lock) в Python, поскольку основная работа по ожиданию событий происходит в отдельном потоке, управляемом Rust-кодом.

От нативных событий к поллингу: надежность прежде всего

Несмотря на ставку на нативные уведомления, watchfiles готова к ситуациям, когда они недоступны или работают некорректно (например, при работе с сетевыми файловыми системами, такими как NFS, или в некоторых виртуализированных средах вроде WSL1).

В таких случаях библиотека автоматически переключается в режим поллинга — периодического сканирования файловой системы на предмет изменений. Это гарантирует, что watchfiles будет работать надежно в любом окружении, пусть и с меньшей эффективностью. Принудительно включить поллинг можно с помощью аргумента force_polling=True или переменной окружения WATCHFILES_FORCE_POLLING.

Интеллектуальное пакетирование (Debouncing)

Когда вы сохраняете файл в IDE или выполняете git pull, в файловой системе может произойти целая серия событий за доли секунды. Если реагировать на каждое из них, можно вызвать шквал ненужных перезапусков.

watchfiles решает эту проблему с помощью механизма debouncing, реализованного также на стороне Rust. Библиотека не отдает изменения немедленно, а накапливает их в течение короткого периода (по умолчанию 1.6 секунды, настраивается через параметр debounce). Все события, произошедшие за этот промежуток времени, группируются в один "пакет" и отдаются за один раз. Это позволяет, например, перезапустить сервер только один раз после сохранения десяти файлов, а не десять раз подряд.

Связь Python и Rust

Взаимодействие между двумя языками происходит через класс RustNotify, который является Python-оберткой над Rust-кодом.

  • Синхронные функции (watch, run_process): Rust-код запускается в отдельном потоке. Основной поток Python блокируется в ожидании данных из этого потока, периодически освобождая GIL, чтобы не мешать другим Python-потокам.
  • Асинхронные функции (awatch, arun_process): Используется anyio.to_thread.run_sync, чтобы запустить блокирующий вызов к Rust-коду в отдельном потоке, не блокируя при этом основной event loop.

[!NOTE] Ключевые архитектурные преимущества watchfiles:

  1. Нативная производительность: Использование Rust и системных API для максимальной эффективности.
  2. Низкое потребление ресурсов: Отсутствие активной работы, пока нет файловых событий.
  3. Надежность: Автоматический фолбэк на поллинг в сложных окружениях.
  4. Продуманное API: Встроенный механизм debouncing и фильтрации для практического удобства.

Основные API и сценарии использования

watchfiles предоставляет четыре основные функции, покрывающие как синхронные, так и асинхронные сценарии:

  1. watch() — синхронный генератор, отслеживающий изменения.
  2. awatch() — асинхронный генератор.
  3. run_process() — синхронная функция для запуска и перезапуска процесса.
  4. arun_process() — асинхронная версия run_process.

Рассмотрим их по порядку.

1. Простое отслеживание: watch и awatch

Это самые базовые функции, которые просто сообщают вам об изменениях.

Синхронное отслеживание с watch

Функция watch — это генератор, который блокирует выполнение и yield-ит set изменений каждый раз, когда они происходят.

import time
from pathlib import Path
from watchfiles import watch

# Создадим временный каталог для демонстрации
p = Path('./temp_dir')
p.mkdir(exist_ok=True)

print("Начинаем отслеживание...")
for changes in watch(p, debounce=500, step=50):
    print(changes)
    # Для примера выйдем после первого же пакета изменений
    break

# В другом терминале или потоке выполните:
# touch temp_dir/new_file.txt
# echo "hello" > temp_dir/another_file.txt

Что вернет этот код?

Вывод будет представлять собой множество кортежей, где каждый кортеж — это (тип_изменения, путь_к_файлу).

{(<Change.added: 1>, 'temp_dir/new_file.txt'), (<Change.added: 1>, 'temp_dir/another_file.txt')}

Change — это IntEnum с тремя возможными значениями:

  • Change.added (1)
  • Change.modified (2)
  • Change.deleted (3)

Асинхронное отслеживание с awatch

awatch делает то же самое, но в асинхронном стиле. Это идеальный вариант для интеграции в асинхронные приложения, например, на asyncio или trio.

import asyncio
from pathlib import Path
from watchfiles import awatch

async def main():
    p = Path('./temp_dir_async')
    p.mkdir(exist_ok=True)

    print("Начинаем асинхронное отслеживание...")
    async for changes in awatch(p, debounce=500, step=50):
        print(changes)
        break

if __name__ == '__main__':
    # В другом терминале: touch temp_dir_async/test.log
    asyncio.run(main())

Код поведет себя аналогично синхронной версии, но не будет блокировать event loop.

2. Фильтрация изменений

Не все файловые события представляют интерес. Временные файлы редакторов, кеш __pycache__, каталоги .git — все это "шум", который хотелось бы игнорировать. watchfiles предоставляет мощный механизм фильтрации через аргумент watch_filter.

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

Встроенные фильтры

  • DefaultFilter: Стандартный фильтр. Игнорирует каталоги вроде .git, __pycache__, node_modules и файлы по шаблонам (*.swp, ~* и т.д.).
  • PythonFilter: Наследуется от DefaultFilter, но дополнительно оставляет только изменения в файлах с расширениями .py, .pyx, .pyd.

Создание кастомного фильтра

Вы можете передать любую функцию (или вызываемый объект), которая принимает change: Change и path: str и возвращает bool.

Пример: отслеживать только .md и .css файлы.

from watchfiles import Change, watch

def web_files_filter(change: Change, path: str):
    # change не используется, но должен быть в сигнатуре
    return path.endswith(('.md', '.css'))

# Использование
for changes in watch('./my_project', watch_filter=web_files_filter):
    print("Документация или стили изменились:", changes)
    break

Более идиоматичный способ — наследование от DefaultFilter, чтобы сохранить его полезное поведение по умолчанию.

from watchfiles import DefaultFilter, Change

class WebFilter(DefaultFilter):
    allowed_extensions = '.html', '.css', '.js'

    def __call__(self, change: Change, path: str) -> bool:
        # Сначала применяем логику родительского класса (игнорирование .git и т.д.)
        # А затем добавляем свою проверку на расширение
        return (
            super().__call__(change, path) and
            path.endswith(self.allowed_extensions)
        )

# Использование
# for changes in watch('my/web/project', watch_filter=WebFilter()):
#     ...

3. Автоматический перезапуск процессов: run_process и arun_process

Это самая востребованная функция watchfiles. Она позволяет не просто отслеживать изменения, а автоматически перезапускать команду или Python-функцию. Это основа для любого "live reload" сервера.

Перезапуск Python-функции

run_process запускает вашу функцию в отдельном дочернем процессе (через multiprocessing.get_context('spawn')). При обнаружении изменений он корректно завершает старый процесс через SIGINTSIGKILL, если тот не ответил) и запускает новый.

import os
import json
from watchfiles import run_process

def my_complex_task(a, b):
    # Получаем информацию об изменениях из переменной окружения
    changes_str = os.getenv('WATCHFILES_CHANGES', '[]')
    changes = json.loads(changes_str)
    
    if changes:
        print(f"Перезапуск из-за изменений: {changes}")
    else:
        print("Первый запуск процесса...")
    
    print(f"Выполняю задачу с a={a}, b={b}")
    # ... здесь могла бы быть ваша логика, например, запуск веб-сервера

if __name__ == '__main__':
    print("Главный процесс watchfiles запущен. Нажмите Ctrl+C для выхода.")
    run_process('.', target=my_complex_task, args=(10, 20))

Когда вы запустите этот скрипт и создадите/измените файл в текущем каталоге, вы увидите, как run_process перезапустит my_complex_task. Обратите внимание, как информация об изменениях передается через переменную окружения WATCHFILES_CHANGES — это простой и надежный способ сообщить дочернему процессу причину его перезапуска.

Перезапуск внешней команды

Не менее полезен сценарий запуска внешней команды. Например, для пересборки документации или запуска тестов.

from watchfiles import run_process

if __name__ == '__main__':
    # При любом изменении в 'src' или 'tests' будет запущен pytest
    run_process(
        './src', 
        './tests', 
        target='pytest --lf',  # --lf (last-failed) запускает только упавшие тесты
        watch_filter=PythonFilter()
    )

run_process сама определит, что target — это команда, а не Python-функция, и будет использовать subprocess.Popen для ее запуска.

Асинхронный arun_process работает по тому же принципу, но органично вписывается в asyncio event loop и позволяет использовать асинхронные callback функции.

CLI: Мониторинг из командной строки

Одно из самых мощных преимуществ watchfiles — это встроенный инструмент командной строки (CLI). Он позволяет использовать всю мощь библиотеки, вообще не написав ни строчки Python-кода. Запускать его можно как watchfiles ... или python -m watchfiles ....

CLI-инструмент по сути является оберткой над run_process и так же умеет перезапускать как команды, так и функции.

Примеры использования CLI

Перезапуск команды

Предположим, вы хотите автоматически перезапускать тесты pytest каждый раз, когда меняются файлы в проекте. Это делается одной командой:

# Отслеживать текущий каталог и все подкаталоги
# и перезапускать 'pytest --lf' при изменениях
watchfiles "pytest --lf"

Если нужно сузить область отслеживания, можно указать конкретные каталоги и фильтр. Например, следить только за python-файлами в src и tests:

watchfiles --filter python "pytest --lf" src tests

Перезапуск Python-функции

Точно так же можно запустить и перезапускать Python-функцию. Допустим, у вас есть файл server.py с функцией main, которая запускает веб-сервер:

# server.py
def main():
    print("Запуск веб-сервера...")
    # ... логика запуска ...

Запустить и автоматически перезапускать его можно так:

watchfiles server.main

watchfiles автоматически определит, что server.main — это путь к Python-функции, импортирует ее и будет перезапускать в дочернем процессе при изменениях.

Полный список опций

CLI предоставляет множество опций для тонкой настройки. Получить их полный список можно с помощью флага --help. Вот актуальный вывод этой команды:

usage: watchfiles [-h] [--ignore-paths [IGNORE_PATHS]]
                  [--target-type [{command,function,auto}]]
                  [--filter [FILTER]] [--args [ARGS]] [--verbose]
                  [--non-recursive] [--verbosity [{warning,info,debug}]]
                  [--sigint-timeout [SIGINT_TIMEOUT]]
                  [--grace-period [GRACE_PERIOD]]
                  [--sigkill-timeout [SIGKILL_TIMEOUT]]
                  [--ignore-permission-denied] [--version]
                  target [paths ...]

Watch one or more directories and execute either a shell command or a python function on file changes.

Example of watching the current directory and calling a python function:

    watchfiles foobar.main

Example of watching python files in two local directories and calling a shell command:

    watchfiles --filter python 'pytest --lf' src tests

See https://watchfiles.helpmanual.io/cli/ for more information.

positional arguments:
  target                Command or dotted function path to run
  paths                 Filesystem paths to watch, defaults to current directory

options:
  -h, --help            show this help message and exit
  --ignore-paths [IGNORE_PATHS]
                        Specify directories to ignore, to ignore multiple paths use a comma as separator, e.g. "env" or "env,node_modules"
  --target-type [{command,function,auto}]
                        Whether the target should be intercepted as a shell command or a python function, defaults to "auto" which infers the target type from the target string
  --filter [FILTER]     Which files to watch, defaults to "default" which uses the "DefaultFilter", "python" uses the "PythonFilter", "all" uses no filter, any other value is interpreted as a python function/class path which is imported
  --args [ARGS]         Arguments to set on sys.argv before calling target function, used only if the target is a function
  --verbose             Set log level to "debug", wins over `--verbosity`
  --non-recursive       Do not watch for changes in sub-directories recursively
  --verbosity [{warning,info,debug}]
                        Log level, defaults to "info"
  --sigint-timeout [SIGINT_TIMEOUT]
                        How long to wait for the sigint timeout before sending sigkill.
  --grace-period [GRACE_PERIOD]
                        Number of seconds after the process is started before watching for changes.
  --sigkill-timeout [SIGKILL_TIMEOUT]
                        How long to wait for the sigkill timeout before issuing a timeout exception.
  --ignore-permission-denied
                        Ignore permission denied errors while watching files and directories.
  --version, -V         show program's version number and exit

Практические нюансы и подводные камни

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

Работа в WSL, Docker и на сетевых дисках

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

  • WSL (Windows Subsystem for Linux): Проброс файловых событий с хостовой системы Windows в гостевую Linux может быть медленным, ненадежным или вовсе отсутствовать (особенно в WSL1). watchfiles достаточно умна, чтобы автоматически определять запуск под WSL и по умолчанию переключаться в режим поллинга. Это делает ее "просто работающей" из коробки там, где другие библиотеки, полагающиеся на inotify, могут молчать.

  • Docker: При использовании bind mounts (проброс локального каталога в контейнер), особенно на macOS и Windows, передача файловых событий от хоста в контейнер также может страдать от задержек или не работать вовсе. Если вы замечаете, что watchfiles не реагирует на изменения, запущенная в Docker-контейнере, самое надежное решение — принудительно включить поллинг с помощью force_polling=True или переменной окружения WATCHFILES_FORCE_POLLING=1.

  • Сетевые файловые системы (NFS, Samba): Нативные уведомления, как правило, не работают через сеть. В этом случае поллинг — единственный надежный способ отслеживать изменения.

Отслеживание очень больших каталогов

Хотя watchfiles чрезвычайно быстра, инициализация отслеживания для очень большого дерева каталогов (например, корневого каталога / или домашнего каталога с сотнями тысяч файлов) может занять некоторое время и потребовать значительное количество памяти. Это связано с тем, что notify должен зарегистрировать "наблюдателя" для каждого подкаталога.

[!TIP] Практический совет: Всегда указывайте как можно более конкретные пути для отслеживания. Вместо того чтобы следить за всем проектом (.), лучше указать конкретные папки с исходным кодом, например, watchfiles ... src tests docs. Это сократит время запуска и уменьшит потребление ресурсов.

Обработка сигналов завершения (SIGTERM)

watchfiles спроектирована с учетом работы в контейнеризированных средах. Она перехватывает сигнал SIGTERM (который, например, отправляет docker stop) и преобразует его в KeyboardInterrupt. Это позволяет приложению корректно завершить работу, остановить дочерний процесс и выполнить все необходимые очистки, вместо того чтобы быть "убитым" системой. Это маленький, но очень важный аспект, который говорит о продуманности библиотеки.

Сравнение с альтернативами

Чтобы лучше понять место watchfiles, сравним ее с двумя другими подходами: библиотекой watchdog и самописным скриптом на основе поллинга.

watchfiles vs. watchdog

watchdog — это "старая гвардия" в мире мониторинга файлов на Python. Это солидная, проверенная временем библиотека, но у нее есть ряд отличий от watchfiles.

Критерий watchfiles watchdog
Производительность Очень высокая. Использует Rust и нативные API ОС. Минимальная нагрузка на CPU. Умеренная. Также использует нативные API, но имеет больше Python-кода на "горячем" пути.
Зависимости Требует биндингов к Rust-коду (поставляются в виде wheels для большинства платформ). Pure Python, но может требовать PyYAML и pathtools.
API Простое и декларативное. Функции-генераторы (watch) и готовые решения (run_process). Более сложное, событийное. Требует создания классов-обработчиков событий (event handlers).
Надежность Встроенный автоматический фолбэк на поллинг (включая автоопределение WSL). Требует ручной настройки для выбора между разными наблюдателями (PollingObserver, InotifyObserver).
CLI Мощный и функциональный CLI "из коробки". Нет встроенного CLI для перезапуска процессов.

Вердикт: Для большинства современных задач watchfiles является предпочтительным выбором из-за своей простоты, производительности и продуманных "из коробки" решений для частых проблем (WSL, debouncing). watchdog остается жизнеспособным вариантом, если требуется более сложная, событийная логика обработки, и вы готовы писать больше кода для ее реализации.

watchfiles vs. самописный скрипт

Иногда возникает соблазн написать свой собственный скрипт для отслеживания изменений, особенно если задача кажется простой.

# Пример наивного поллинг-скрипта (НЕ ДЕЛАЙТЕ ТАК!)
import os
import time

def my_diy_watcher(path):
    last_mtimes = {}
    while True:
        for root, _, files in os.walk(path):
            for filename in files:
                filepath = os.path.join(root, filename)
                try:
                    mtime = os.path.getmtime(filepath)
                    if filepath in last_mtimes and mtime > last_mtimes[filepath]:
                        print(f"Файл изменен: {filepath}")
                    last_mtimes[filepath] = mtime
                except FileNotFoundError:
                    # ... обработка удаленных файлов ...
                    pass
        time.sleep(1)

Этот подход имеет катастрофические недостатки:

  1. Высокая нагрузка на CPU: Бесконечный цикл с os.walk будет постоянно нагружать процессор и дисковую подсистему, даже если ничего не меняется.
  2. Сложность реализации: Этот наивный пример не обрабатывает добавление/удаление файлов, не имеет механизма debouncing и неэффективен. Написать надежное решение с нуля — нетривиальная задача.
  3. Неэффективность: watchfiles делает то же самое, но на порядки быстрее и с нулевой нагрузкой в режиме ожидания.

Никогда не пишите свой собственный поллинг-скрипт, если watchfiles может решить вашу задачу. Это классический пример "изобретения колеса", которое к тому же получится квадратным и тяжелым.

Вердикт: когда использовать watchfiles?

Если перед вами стоит задача отслеживания изменений в файловой системе на Python, watchfiles — это почти всегда хороший выбор.

Используйте watchfiles, когда вам нужно:

  1. Создать live-reload (живую перезагрузку) для веб-сервера. Это каноничный пример использования, для которого watchfiles подходит идеально. Такие фреймворки, как FastAPI (Uvicorn) и Litestar, используют watchfiles под капотом для своего режима разработки.
  2. Автоматизировать сборочные процессы. Пересборка документации (MkDocs, Sphinx), компиляция ассетов (CSS, JS), генерация кода — watchfiles может стать ядром вашей системы автоматизации.
  3. Автоматически запускать тесты. Как в примере выше, это невероятно удобный способ получать мгновенную обратную связь при разработке.
  4. Синхронизировать каталоги или выполнять любые другие действия в ответ на файловые события. Гибкость watch и awatch позволяет строить на их основе любую кастомную логику.

Благодаря своей производительности, надежности и простому API, watchfiles устраняет целый класс проблем, связанных с мониторингом файлов. А в экосистеме Python, где скорость разработки часто ценится выше абсолютной производительности, watchfiles представляет собой редкое и ценное исключение. Это инструмент, который не требует компромиссов: он одновременно и прост в использовании, и эффективен.