Если вам когда-либо требовалось перезапускать веб-сервер при изменении кода, пересобирать статический сайт при правках в markdown-файлах или запускать тесты при обновлении исходников, вы сталкивались с задачей мониторинга файловой системы. Традиционно в Python для этого использовали библиотеки вроде watchdog
или самописные решения на основе периодического сканирования (поллинга). Оба подхода имеют свои недостатки: от сложностей с зависимостями и производительностью до высокого потребления CPU.
watchfiles — это современная библиотека, которая решает эту проблему кардинально иначе. Она предлагает удобный способ отслеживания изменений файлов, который под капотом использует мощь Rust и нативные механизмы операционной системы. Это не просто очередная обертка, а фундаментально иной подход, который делает мониторинг файлов быстрым, надежным и экономичным.
В этом руководстве мы погрузимся в архитектуру watchfiles
, разберем, почему она так эффективна, и рассмотрим ключевые сценарии ее использования — от простого отслеживания до автоматического перезапуска процессов.
На первый взгляд, watchfiles
делает то же самое, что и другие библиотеки: сообщает вам, когда файлы или каталоги были добавлены, изменены или удалены. Дьявол, как всегда, в деталях, а точнее — в реализации.
Ключевое отличие watchfiles
— ее производительность и эффективность. Вместо того чтобы постоянно опрашивать файловую систему в цикле на Python, тратя ресурсы CPU, watchfiles
делегирует эту задачу скомпилированному коду на Rust, который, в свою очередь, использует лучшие из доступных в ОС механизмов уведомлений:
Это означает, что watchfiles
практически не потребляет ресурсы, пока в файловой системе ничего не происходит. Пробуждение происходит только тогда, когда ядро операционной системы само сообщает о событии. Такой подход на порядки эффективнее любого решения на чистом Python, основанного на поллинге.
[!INFO] Библиотека
watchfiles
является идейным и технологическим преемникомwatchgod
. Основные изменения — переход с поллинга на нативные уведомления через Rust и значительное упрощение API.
Чтобы по-настоящему оценить watchfiles
, нужно заглянуть в ее "двигатель". Архитектура библиотеки — это элегантный пример того, как можно объединить сильные стороны Python и 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
.
Когда вы сохраняете файл в IDE или выполняете git pull
, в файловой системе может произойти целая серия событий за доли секунды. Если реагировать на каждое из них, можно вызвать шквал ненужных перезапусков.
watchfiles
решает эту проблему с помощью механизма debouncing, реализованного также на стороне Rust. Библиотека не отдает изменения немедленно, а накапливает их в течение короткого периода (по умолчанию 1.6 секунды, настраивается через параметр debounce
). Все события, произошедшие за этот промежуток времени, группируются в один "пакет" и отдаются за один раз. Это позволяет, например, перезапустить сервер только один раз после сохранения десяти файлов, а не десять раз подряд.
Взаимодействие между двумя языками происходит через класс RustNotify
, который является Python-оберткой над Rust-кодом.
watch
, run_process
): Rust-код запускается в отдельном потоке. Основной поток Python блокируется в ожидании данных из этого потока, периодически освобождая GIL, чтобы не мешать другим Python-потокам.awatch
, arun_process
): Используется anyio.to_thread.run_sync
, чтобы запустить блокирующий вызов к Rust-коду в отдельном потоке, не блокируя при этом основной event loop.[!NOTE] Ключевые архитектурные преимущества
watchfiles
:
- Нативная производительность: Использование Rust и системных API для максимальной эффективности.
- Низкое потребление ресурсов: Отсутствие активной работы, пока нет файловых событий.
- Надежность: Автоматический фолбэк на поллинг в сложных окружениях.
- Продуманное API: Встроенный механизм debouncing и фильтрации для практического удобства.
watchfiles
предоставляет четыре основные функции, покрывающие как синхронные, так и асинхронные сценарии:
watch()
— синхронный генератор, отслеживающий изменения.awatch()
— асинхронный генератор.run_process()
— синхронная функция для запуска и перезапуска процесса.arun_process()
— асинхронная версия run_process
.Рассмотрим их по порядку.
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.
Не все файловые события представляют интерес. Временные файлы редакторов, кеш __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()):
# ...
run_process
и arun_process
Это самая востребованная функция watchfiles
. Она позволяет не просто отслеживать изменения, а автоматически перезапускать команду или Python-функцию. Это основа для любого "live reload" сервера.
run_process
запускает вашу функцию в отдельном дочернем процессе (через multiprocessing.get_context('spawn')
). При обнаружении изменений он корректно завершает старый процесс через SIGINT
(и SIGKILL
, если тот не ответил) и запускает новый.
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
функции.
Одно из самых мощных преимуществ watchfiles
— это встроенный инструмент командной строки (CLI). Он позволяет использовать всю мощь библиотеки, вообще не написав ни строчки Python-кода. Запускать его можно как watchfiles ...
или python -m watchfiles ...
.
CLI-инструмент по сути является оберткой над run_process
и так же умеет перезапускать как команды, так и функции.
Предположим, вы хотите автоматически перезапускать тесты pytest
каждый раз, когда меняются файлы в проекте. Это делается одной командой:
# Отслеживать текущий каталог и все подкаталоги
# и перезапускать 'pytest --lf' при изменениях
watchfiles "pytest --lf"
Если нужно сузить область отслеживания, можно указать конкретные каталоги и фильтр. Например, следить только за python-файлами в src
и tests
:
watchfiles --filter python "pytest --lf" src tests
Точно так же можно запустить и перезапускать 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 (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
. Это сократит время запуска и уменьшит потребление ресурсов.
watchfiles
спроектирована с учетом работы в контейнеризированных средах. Она перехватывает сигнал SIGTERM
(который, например, отправляет docker stop
) и преобразует его в KeyboardInterrupt
. Это позволяет приложению корректно завершить работу, остановить дочерний процесс и выполнить все необходимые очистки, вместо того чтобы быть "убитым" системой. Это маленький, но очень важный аспект, который говорит о продуманности библиотеки.
Чтобы лучше понять место watchfiles
, сравним ее с двумя другими подходами: библиотекой 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
остается жизнеспособным вариантом, если требуется более сложная, событийная логика обработки, и вы готовы писать больше кода для ее реализации.
Иногда возникает соблазн написать свой собственный скрипт для отслеживания изменений, особенно если задача кажется простой.
# Пример наивного поллинг-скрипта (НЕ ДЕЛАЙТЕ ТАК!)
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)
Этот подход имеет катастрофические недостатки:
os.walk
будет постоянно нагружать процессор и дисковую подсистему, даже если ничего не меняется.watchfiles
делает то же самое, но на порядки быстрее и с нулевой нагрузкой в режиме ожидания.Никогда не пишите свой собственный поллинг-скрипт, если watchfiles
может решить вашу задачу. Это классический пример "изобретения колеса", которое к тому же получится квадратным и тяжелым.
Ваша поддержка — это энергия для новых статей и проектов. Спасибо, что читаете!
Если перед вами стоит задача отслеживания изменений в файловой системе на Python, watchfiles
— это почти всегда хороший выбор.
Используйте watchfiles
, когда вам нужно:
watchfiles
подходит идеально. Такие фреймворки, как FastAPI (Uvicorn) и Litestar, используют watchfiles
под капотом для своего режима разработки.watchfiles
может стать ядром вашей системы автоматизации.watch
и awatch
позволяет строить на их основе любую кастомную логику.Благодаря своей производительности, надежности и простому API, watchfiles
устраняет целый класс проблем, связанных с мониторингом файлов. А в экосистеме Python, где скорость разработки часто ценится выше абсолютной производительности, watchfiles
представляет собой редкое и ценное исключение. Это инструмент, который не требует компромиссов: он одновременно и прост в использовании, и эффективен.