pax_global_header00006660000000000000000000000064140053046020014504gustar00rootroot0000000000000052 comment=2a3462c82b81fc2339cb79ece6bd2eabea2f9937 watchgod-0.7/000077500000000000000000000000001400530460200131525ustar00rootroot00000000000000watchgod-0.7/.github/000077500000000000000000000000001400530460200145125ustar00rootroot00000000000000watchgod-0.7/.github/FUNDING.yml000066400000000000000000000000251400530460200163240ustar00rootroot00000000000000github: samuelcolvin watchgod-0.7/.github/workflows/000077500000000000000000000000001400530460200165475ustar00rootroot00000000000000watchgod-0.7/.github/workflows/ci.yml000066400000000000000000000035151400530460200176710ustar00rootroot00000000000000name: ci on: push: branches: - master tags: - '**' pull_request: {} jobs: test: name: test py${{ matrix.python-version }} on ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu, macos, windows] python-version: ['3.6', '3.7', '3.8', '3.9'] runs-on: ${{ matrix.os }}-latest env: PYTHON: ${{ matrix.python-version }} OS: ${{ matrix.os }} steps: - uses: actions/checkout@v2 - name: set up python uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} - run: pip install -r tests/requirements.txt - run: pip install . - run: pip freeze - run: make test - run: coverage xml - uses: codecov/codecov-action@v1.0.13 with: file: ./coverage.xml env_vars: PYTHON,OS lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v1 with: python-version: '3.8' - run: pip install -r tests/requirements-linting.txt - run: make lint - run: make mypy deploy: needs: - test - lint if: "success() && startsWith(github.ref, 'refs/tags/')" runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: set up python uses: actions/setup-python@v1 with: python-version: '3.8' - name: install run: | pip install -U pip setuptools twine wheel pip install . - name: check tag run: PACKAGE=watchgod python <(curl -Ls https://git.io/JvQsH) - run: python setup.py sdist bdist_wheel - run: twine check dist/* - name: upload to pypi run: twine upload dist/* env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.pypi_token }} watchgod-0.7/.gitignore000066400000000000000000000002331400530460200151400ustar00rootroot00000000000000/.idea/ /env/ /env35/ *.py[cod] *.egg-info/ build/ dist/ .cache/ .mypy_cache/ test.py .coverage htmlcov/ docs/_build/ /.pytest_cache/ /sandbox/ /foobar.py watchgod-0.7/LICENSE000066400000000000000000000021121400530460200141530ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2017, 2018, 2019, 2020 Samuel Colvin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. watchgod-0.7/MANIFEST.in000066400000000000000000000000421400530460200147040ustar00rootroot00000000000000include LICENSE include README.md watchgod-0.7/Makefile000066400000000000000000000017561400530460200146230ustar00rootroot00000000000000.DEFAULT_GOAL := all isort = isort watchgod tests black = black -S -l 120 --target-version py38 watchgod tests .PHONY: install install: pip install -U pip wheel pip install -r tests/requirements.txt pip install -U . .PHONY: install-all install-all: install pip install -r tests/requirements-linting.txt .PHONY: isort format: $(isort) $(black) .PHONY: lint lint: python setup.py check -ms flake8 watchgod/ tests/ $(isort) --check-only --df $(black) --check --diff .PHONY: mypy mypy: mypy watchgod .PHONY: test test: pytest --cov=watchgod --log-format="%(levelname)s %(message)s" .PHONY: testcov testcov: test @echo "building coverage html" @coverage html .PHONY: all all: lint mypy testcov .PHONY: clean clean: rm -rf `find . -name __pycache__` rm -f `find . -type f -name '*.py[co]' ` rm -f `find . -type f -name '*~' ` rm -f `find . -type f -name '.*~' ` rm -rf .cache rm -rf htmlcov rm -rf *.egg-info rm -f .coverage rm -f .coverage.* rm -rf build python setup.py clean watchgod-0.7/README.md000066400000000000000000000135771400530460200144460ustar00rootroot00000000000000# watchgod [![CI](https://github.com/samuelcolvin/watchgod/workflows/ci/badge.svg?event=push)](https://github.com/samuelcolvin/watchgod/actions?query=event%3Apush+branch%3Amaster+workflow%3Aci) [![Coverage](https://codecov.io/gh/samuelcolvin/watchgod/branch/master/graph/badge.svg)](https://codecov.io/gh/samuelcolvin/watchgod) [![pypi](https://img.shields.io/pypi/v/watchgod.svg)](https://pypi.python.org/pypi/watchgod) [![license](https://img.shields.io/github/license/samuelcolvin/watchgod.svg)](https://github.com/samuelcolvin/watchgod/blob/master/LICENSE) Simple, modern file watching and code reload in python. *(watchgod is inspired by [watchdog](https://pythonhosted.org/watchdog/), hence the name, but tries to fix some of the frustrations I found with watchdog, namely: separate approaches for each OS, an inelegant approach to concurrency using threading, challenges around debouncing changes and bugs which weren't being fixed)* ## Usage To watch for changes in a directory: ```python from watchgod import watch for changes in watch('./path/to/dir'): print(changes) ``` To run a function and restart it when code changes: ```python from watchgod import run_process def foobar(a, b, c): ... run_process('./path/to/dir', foobar, args=(1, 2, 3)) ``` `run_process` uses `PythonWatcher` so only changes to python files will prompt a reload, see **custom watchers** below. If you need notifications about change events as well as to restart a process you can use the `callback` argument to pass a function which will be called on every file change with one argument: the set of file changes. ## Asynchronous Methods *watchgod* comes with an asynchronous equivalents of `watch`: `awatch` which uses a `ThreadPoolExecutor` to iterate over files. ```python import asyncio from watchgod import awatch async def main(): async for changes in awatch('/path/to/dir'): print(changes) loop = asyncio.get_event_loop() loop.run_until_complete(main()) ``` There's also an asynchronous equivalents of `run_process`: `arun_process` which in turn uses `awatch`: ```python import asyncio from watchgod import arun_process def foobar(a, b, c): ... async def main(): await arun_process('./path/to/dir', foobar, args=(1, 2, 3)) loop = asyncio.get_event_loop() loop.run_until_complete(main()) ``` `arun_process` uses `PythonWatcher` so only changes to python files will prompt a reload, see **custom watchers** below. The signature of `arun_process` is almost identical to `run_process` except that the optional `callback` argument must be a coroutine, not a function. ## Custom Watchers *watchgod* comes with the following watcher classes which can be used via the `watcher_cls` keyword argument to any of the methods above. For example: ```python for changes in watch(directoryin, watcher_cls=RegExpWatcher, watcher_kwargs=dict(re_files=r'^.*(\.mp3)$')): print (changes) ``` For more details, checkout [`watcher.py`](https://github.com/samuelcolvin/watchgod/blob/master/watchgod/watcher.py), it's pretty simple. * **AllWatcher** The base watcher, all files are checked for changes. * **DefaultWatcher** The watcher used by default by `watch` and `awatch`, commonly ignored files like `*.swp`, `*.pyc` and `*~` are ignored along with directories like `.git`. * **PythonWatcher** Specific to python files, only `*.py`, `*.pyx` and `*.pyd` files are watched. * **DefaultDirWatcher** Is the base for `DefaultWatcher` and `DefaultDirWatcher`. It takes care of ignoring some regular directories. If these classes aren't sufficient you can define your own watcher, in particular you will want to override `should_watch_dir` and `should_watch_file`. Unless you're doing something very odd, you'll want to inherit from `DefaultDirWatcher`. Note that events related to *directories* are not reported (e.g. creation of a directory), but new files in new directories will be reported. ## CLI *watchgod* also comes with a CLI for running and reloading python code. Lets say you have `foobar.py`: ```python from aiohttp import web async def handle(request): return web.Response(text='testing') app = web.Application() app.router.add_get('/', handle) def main(): web.run_app(app, port=8000) ``` You could run this and reload it when any file in the current directory changes with:: watchgod foobar.main In case you need to ignore certain files or directories, you can use the argument `--ignore-paths`. Run `watchgod --help` for more options. *watchgod* is also available as a python executable module via `python -m watchgod ...`. ## Why no inotify / kqueue / fsevent / winapi support *watchgod* (for now) uses file polling rather than the OS's built in file change notifications. This is not an oversight, it's a decision with the following rationale: 1. Polling is "fast enough", particularly since PEP 471 introduced fast `scandir`. For reasonably large projects like the TutorCruncher code base with 850 files and 300k lines of code, *watchgod* can scan the entire tree in ~24ms. With a scan interval of 400ms that is roughly 5% of one CPU - perfectly acceptable load during development. 2. The clue is in the title, there are at least 4 different file notification systems to integrate with, most of them not trivial. That is all before we get to changes between different OS versions. 3. Polling works well when you want to group or "debounce" changes. Let's say you're running a dev server and you change branch in git, 100 files change. Do you want to reload the dev server 100 times or once? Right. Polling periodically will likely group these changes into one event. If you're receiving a stream of events you need to delay execution of the reload when you receive the first event to see if it's part of a group of file changes. This is not trivial. All that said, I might still use rust's "notify" crate to do the heavy lifting of file watching, see[#25](https://github.com/samuelcolvin/watchgod/issues/25). watchgod-0.7/setup.cfg000066400000000000000000000011021400530460200147650ustar00rootroot00000000000000[tool:pytest] testpaths = tests filterwarnings = error [flake8] max-line-length = 120 max-complexity = 12 inline-quotes = ' multiline-quotes = """ ignore = E203, W503 [coverage:run] branch = True [coverage:report] precision = 2 exclude_lines = pragma: no cover raise NotImplementedError if TYPE_CHECKING: @overload omit = # __main__.py is trivial and hard to test properly */__main__.py [isort] line_length=120 known_first_party=watchgod multi_line_output=3 include_trailing_comma=True force_grid_wrap=0 combine_as_imports=True [mypy] strict = True watchgod-0.7/setup.py000066400000000000000000000035071400530460200146710ustar00rootroot00000000000000from importlib.machinery import SourceFileLoader from pathlib import Path from setuptools import setup THIS_DIR = Path(__file__).resolve().parent long_description = THIS_DIR.joinpath('README.md').read_text() # avoid loading the package before requirements are installed: version = SourceFileLoader('version', 'watchgod/version.py').load_module() setup( name='watchgod', version=str(version.VERSION), description='Simple, modern file watching and code reload in python.', long_description=long_description, long_description_content_type='text/markdown', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Console', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Intended Audience :: Developers', 'Intended Audience :: Information Technology', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: MIT License', 'Operating System :: POSIX :: Linux', 'Operating System :: Microsoft :: Windows', 'Operating System :: MacOS', 'Environment :: MacOS X', 'Topic :: Software Development :: Libraries :: Python Modules', 'Topic :: System :: Filesystems', ], author='Samuel Colvin', author_email='s@muelcolvin.com', url='https://github.com/samuelcolvin/watchgod', entry_points=""" [console_scripts] watchgod=watchgod.cli:cli """, license='MIT', packages=['watchgod'], package_data={'watchgod': ['py.typed']}, python_requires='>=3.5', zip_safe=True, ) watchgod-0.7/tests/000077500000000000000000000000001400530460200143145ustar00rootroot00000000000000watchgod-0.7/tests/__init__.py000066400000000000000000000000001400530460200164130ustar00rootroot00000000000000watchgod-0.7/tests/check_tag.py000077500000000000000000000007101400530460200165770ustar00rootroot00000000000000#!/usr/bin/env python3 import os import sys from watchgod.version import VERSION git_tag = os.getenv('TRAVIS_TAG') if git_tag: if git_tag.lower().lstrip('v') != str(VERSION).lower(): print('✖ "TRAVIS_TAG" environment variable does not match package version: "%s" vs. "%s"' % (git_tag, VERSION)) sys.exit(1) else: print('✓ "TRAVIS_TAG" environment variable matches package version: "%s" vs. "%s"' % (git_tag, VERSION)) watchgod-0.7/tests/requirements-linting.txt000066400000000000000000000001551400530460200212430ustar00rootroot00000000000000black==20.8b1 flake8==3.8.4 flake8-quotes==3.2.0 isort==5.7.0 mypy==0.800 pycodestyle==2.6.0 pyflakes==2.2.0 watchgod-0.7/tests/requirements.txt000066400000000000000000000002211400530460200175730ustar00rootroot00000000000000coverage==5.4 pygments==2.7.4 pytest==6.2.2 pytest-cov==2.11.1 pytest-asyncio==0.14.0 pytest-mock==3.5.1 pytest-sugar==0.9.4 pytest-toolbox==0.4 watchgod-0.7/tests/test_cli.py000066400000000000000000000126251400530460200165020ustar00rootroot00000000000000import sys from pathlib import Path import pytest from watchgod.cli import callback, cli, run_function, set_tty, sys_argv pytestmark = pytest.mark.skipif(sys.platform == 'win32', reason='many tests fail on windows') def foobar(): # used by tests below Path('sentinel').write_text('ok') def with_parser(): # used by tests below Path('sentinel').write_text(' '.join(map(str, sys.argv[1:]))) def test_simple(mocker, tmpdir): mocker.patch('watchgod.cli.set_start_method') mocker.patch('watchgod.cli.sys.stdin.fileno') mocker.patch('os.ttyname', return_value='/path/to/tty') mock_run_process = mocker.patch('watchgod.cli.run_process') cli('tests.test_cli.foobar', str(tmpdir)) mock_run_process.assert_called_once_with( Path(str(tmpdir)), run_function, args=('tests.test_cli.foobar', '/path/to/tty'), callback=callback, watcher_kwargs={'ignored_paths': set()}, ) def test_invalid_import1(mocker, tmpdir, capsys): sys_exit = mocker.patch('watchgod.cli.sys.exit') cli('foobar') sys_exit.assert_called_once_with(1) out, err = capsys.readouterr() assert out == '' assert err == 'ImportError: "foobar" doesn\'t look like a module path\n' def test_invalid_import2(mocker, tmpdir, capsys): sys_exit = mocker.patch('watchgod.cli.sys.exit') cli('pprint.foobar') sys_exit.assert_called_once_with(1) out, err = capsys.readouterr() assert out == '' assert err == 'ImportError: Module "pprint" does not define a "foobar" attribute\n' def test_invalid_path(mocker, capsys): sys_exit = mocker.patch('watchgod.cli.sys.exit') cli('tests.test_cli.foobar', '/does/not/exist') sys_exit.assert_called_once_with(1) out, err = capsys.readouterr() assert out == '' assert err == 'path "/does/not/exist" does not exist\n' def test_tty_os_error(mocker, tmpworkdir): mocker.patch('watchgod.cli.set_start_method') mocker.patch('watchgod.cli.sys.stdin.fileno', side_effect=OSError) mock_run_process = mocker.patch('watchgod.cli.run_process') cli('tests.test_cli.foobar') mock_run_process.assert_called_once_with( Path(str(tmpworkdir)), run_function, args=('tests.test_cli.foobar', '/dev/tty'), callback=callback, watcher_kwargs={'ignored_paths': set()}, ) def test_tty_attribute_error(mocker, tmpdir): mocker.patch('watchgod.cli.set_start_method') mocker.patch('watchgod.cli.sys.stdin.fileno', side_effect=AttributeError) mock_run_process = mocker.patch('watchgod.cli.run_process') cli('tests.test_cli.foobar', str(tmpdir)) mock_run_process.assert_called_once_with( Path(str(tmpdir)), run_function, args=('tests.test_cli.foobar', None), callback=callback, watcher_kwargs={'ignored_paths': set()}, ) def test_run_function(tmpworkdir): assert not tmpworkdir.join('sentinel').exists() run_function('tests.test_cli.foobar', None) assert tmpworkdir.join('sentinel').exists() def test_run_function_tty(tmpworkdir): # could this cause problems by changing sys.stdin? assert not tmpworkdir.join('sentinel').exists() run_function('tests.test_cli.foobar', '/dev/tty') assert tmpworkdir.join('sentinel').exists() def test_callback(mocker): # boring we have to test this directly, but we do mock_logger = mocker.patch('watchgod.cli.logger.info') callback({1, 2, 3}) mock_logger.assert_called_once_with('%d files changed, reloading', 3) def test_set_tty_error(): with set_tty('/foo/bar'): pass @pytest.mark.parametrize( 'initial, expected', [ ([], []), (['--foo', 'bar'], []), (['--foo', 'bar', '-a'], []), (['--foo', 'bar', '--args'], []), (['--foo', 'bar', '-a', '--foo', 'bar'], ['--foo', 'bar']), (['--foo', 'bar', '-f', 'b', '--args', '-f', '-b', '-z', 'x'], ['-f', '-b', '-z', 'x']), ], ) def test_sys_argv(initial, expected, mocker): mocker.patch('sys.argv', ['script.py', *initial]) # mocker will restore initial sys.argv after test argv = sys_argv('path.to.func') assert argv[0] == str(Path('path/to.py').absolute()) assert argv[1:] == expected @pytest.mark.parametrize( 'initial, expected', [ ([], []), (['--foo', 'bar'], []), (['--foo', 'bar', '-a'], []), (['--foo', 'bar', '--args'], []), (['--foo', 'bar', '-a', '--foo', 'bar'], ['--foo', 'bar']), (['--foo', 'bar', '-f', 'b', '--args', '-f', '-b', '-z', 'x'], ['-f', '-b', '-z', 'x']), ], ) def test_func_with_parser(tmpworkdir, mocker, initial, expected): # setup mocker.patch('sys.argv', ['foo.py', *initial]) mocker.patch('watchgod.cli.set_start_method') mocker.patch('watchgod.cli.sys.stdin.fileno', side_effect=AttributeError) mock_run_process = mocker.patch('watchgod.cli.run_process') # test assert not tmpworkdir.join('sentinel').exists() cli('tests.test_cli.with_parser', str(tmpworkdir)) # run til mock_run_process run_function('tests.test_cli.with_parser', None) # run target function once file = tmpworkdir.join('sentinel') mock_run_process.assert_called_once_with( Path(str(tmpworkdir)), run_function, args=('tests.test_cli.with_parser', None), callback=callback, watcher_kwargs={'ignored_paths': set()}, ) assert file.exists() assert file.read_text(encoding='utf-8') == ' '.join(expected) watchgod-0.7/tests/test_run_process.py000066400000000000000000000062561400530460200203000ustar00rootroot00000000000000import sys from asyncio import Future import pytest from watchgod import arun_process, run_process from watchgod.main import _start_process pytestmark = pytest.mark.asyncio skip_on_windows = pytest.mark.skipif(sys.platform == 'win32', reason='fails on windows') class FakeWatcher: def __init__(self, path): self._async = 'async' in path self._check = 0 self.files = [1, 2, 3] def check(self): self._check += 1 if self._check == 1: return {'x'} elif self._check == 2: return set() elif self._async: raise StopAsyncIteration else: raise KeyboardInterrupt class FakeProcess: def __init__(self, is_alive=True, exitcode=1, pid=123): self._is_alive = is_alive self.exitcode = exitcode self.pid = pid def is_alive(self): return self._is_alive def join(self, wait): pass def test_alive_terminates(mocker): mock_start_process = mocker.patch('watchgod.main._start_process') mock_start_process.return_value = FakeProcess() mock_kill = mocker.patch('watchgod.main.os.kill') assert run_process('/x/y/z', object(), watcher_cls=FakeWatcher, debounce=5, min_sleep=1) == 1 assert mock_start_process.call_count == 2 assert mock_kill.call_count == 2 # kill in loop + final kill def test_dead_callback(mocker): mock_start_process = mocker.patch('watchgod.main._start_process') mock_start_process.return_value = FakeProcess(is_alive=False) mock_kill = mocker.patch('watchgod.main.os.kill') c = mocker.MagicMock() assert run_process('/x/y/z', object(), watcher_cls=FakeWatcher, callback=c, debounce=5, min_sleep=1) == 1 assert mock_start_process.call_count == 2 assert mock_kill.call_count == 0 assert c.call_count == 1 c.assert_called_with({'x'}) @skip_on_windows def test_alive_doesnt_terminate(mocker): mock_start_process = mocker.patch('watchgod.main._start_process') mock_start_process.return_value = FakeProcess(exitcode=None) mock_kill = mocker.patch('watchgod.main.os.kill') assert run_process('/x/y/z', object(), watcher_cls=FakeWatcher, debounce=5, min_sleep=1) == 1 assert mock_start_process.call_count == 2 assert mock_kill.call_count == 4 # 2 kills in loop (graceful and termination) + 2 final kills def test_start_process(mocker): mock_process = mocker.patch('watchgod.main.Process') v = object() _start_process(v, (1, 2, 3), {}) assert mock_process.call_count == 1 mock_process.assert_called_with(target=v, args=(1, 2, 3), kwargs={}) async def test_async_alive_terminates(mocker): mock_start_process = mocker.patch('watchgod.main._start_process') mock_start_process.return_value = FakeProcess() mock_kill = mocker.patch('watchgod.main.os.kill') f = Future() f.set_result(1) c = mocker.MagicMock(return_value=f) reloads = await arun_process('/x/y/async', object(), watcher_cls=FakeWatcher, callback=c, debounce=5, min_sleep=1) assert reloads == 1 assert mock_start_process.call_count == 2 assert mock_kill.call_count == 2 # kill in loop + final kill assert c.call_count == 1 c.assert_called_with({'x'}) watchgod-0.7/tests/test_watch.py000066400000000000000000000261241400530460200170400ustar00rootroot00000000000000import asyncio import re import sys import threading from time import sleep import pytest from pytest_toolbox import mktree from watchgod import AllWatcher, Change, DefaultWatcher, PythonWatcher, RegExpWatcher, awatch, watch pytestmark = pytest.mark.asyncio skip_on_windows = pytest.mark.skipif(sys.platform == 'win32', reason='fails on windows') skip_unless_linux = pytest.mark.skipif(sys.platform != 'linux', reason='test only on linux') tree = { 'foo': { 'bar.txt': 'bar', 'spam.py': 'whatever', 'spam.pyc': 'splosh', 'recursive_dir': { 'a.js': 'boom', }, '.git': { 'x': 'y', }, } } def test_add(tmpdir): watcher = AllWatcher(str(tmpdir)) changes = watcher.check() assert changes == set() sleep(0.01) tmpdir.join('foo.txt').write('foobar') changes = watcher.check() assert changes == {(Change.added, str(tmpdir.join('foo.txt')))} def test_add_watched_file(tmpdir): file = tmpdir.join('bar.txt') watcher = AllWatcher(str(file)) assert watcher.check() == set() sleep(0.01) file.write('foobar') assert watcher.check() == {(Change.added, str(file))} def test_modify(tmpdir): mktree(tmpdir, tree) watcher = AllWatcher(str(tmpdir)) assert watcher.check() == set() sleep(0.01) tmpdir.join('foo/bar.txt').write('foobar') assert watcher.check() == {(Change.modified, str(tmpdir.join('foo/bar.txt')))} @skip_on_windows def test_ignore_root(tmpdir): mktree(tmpdir, tree) watcher = AllWatcher(str(tmpdir), ignored_paths={tmpdir.join('foo')}) assert watcher.check() == set() sleep(0.01) tmpdir.join('foo/bar.txt').write('foobar') assert watcher.check() == set() @skip_on_windows def test_ignore_file_path(tmpdir): mktree(tmpdir, tree) watcher = AllWatcher(str(tmpdir), ignored_paths={tmpdir.join('foo', 'bar.txt')}) assert watcher.check() == set() sleep(0.01) tmpdir.join('foo', 'bar.txt').write('foobar') tmpdir.join('foo', 'new_not_ignored.txt').write('foobar') tmpdir.join('foo', 'spam.py').write('foobar') assert watcher.check() == { (Change.added, tmpdir.join('foo', 'new_not_ignored.txt')), (Change.modified, tmpdir.join('foo', 'spam.py')), } @skip_on_windows def test_ignore_subdir(tmpdir): mktree(tmpdir, tree) watcher = AllWatcher(str(tmpdir), ignored_paths={tmpdir.join('dir', 'ignored')}) assert watcher.check() == set() sleep(0.01) tmpdir.mkdir('dir') tmpdir.mkdir('dir', 'ignored') tmpdir.mkdir('dir', 'not_ignored') tmpdir.join('dir', 'ignored', 'file.txt').write('content') tmpdir.join('dir', 'not_ignored', 'file.txt').write('content') assert watcher.check() == {(Change.added, tmpdir.join('dir', 'not_ignored', 'file.txt'))} def test_modify_watched_file(tmpdir): file = tmpdir.join('bar.txt') file.write('foobar') watcher = AllWatcher(str(file)) assert watcher.check() == set() sleep(0.01) file.write('foobar') assert watcher.check() == {(Change.modified, str(file))} # same content but time updated sleep(0.01) file.write('baz') assert watcher.check() == {(Change.modified, str(file))} def test_delete(tmpdir): mktree(tmpdir, tree) watcher = AllWatcher(str(tmpdir)) sleep(0.01) tmpdir.join('foo/bar.txt').remove() assert watcher.check() == {(Change.deleted, str(tmpdir.join('foo/bar.txt')))} def test_delete_watched_file(tmpdir): file = tmpdir.join('bar.txt') file.write('foobar') watcher = AllWatcher(str(file)) assert watcher.check() == set() sleep(0.01) file.remove() assert watcher.check() == {(Change.deleted, str(file))} def test_ignore_file(tmpdir): mktree(tmpdir, tree) watcher = DefaultWatcher(str(tmpdir)) sleep(0.01) tmpdir.join('foo/spam.pyc').write('foobar') assert watcher.check() == set() def test_ignore_dir(tmpdir): mktree(tmpdir, tree) watcher = DefaultWatcher(str(tmpdir)) sleep(0.01) tmpdir.join('foo/.git/abc').write('xxx') assert watcher.check() == set() def test_python(tmpdir): mktree(tmpdir, tree) watcher = PythonWatcher(str(tmpdir)) sleep(0.01) tmpdir.join('foo/spam.py').write('xxx') tmpdir.join('foo/bar.txt').write('xxx') assert watcher.check() == {(Change.modified, str(tmpdir.join('foo/spam.py')))} def test_regexp(tmpdir): mktree(tmpdir, tree) re_files = r'^.*(\.txt|\.js)$' re_dirs = r'^(?:(?!recursive_dir).)*$' watcher = RegExpWatcher(str(tmpdir), re_files, re_dirs) changes = watcher.check() assert changes == set() sleep(0.01) tmpdir.join('foo/spam.py').write('xxx') tmpdir.join('foo/bar.txt').write('change') tmpdir.join('foo/borec.txt').write('ahoy') tmpdir.join('foo/borec-js.js').write('peace') tmpdir.join('foo/recursive_dir/b.js').write('borec') assert watcher.check() == { (Change.modified, str(tmpdir.join('foo/bar.txt'))), (Change.added, str(tmpdir.join('foo/borec.txt'))), (Change.added, str(tmpdir.join('foo/borec-js.js'))), } def test_regexp_no_re_dirs(tmpdir): mktree(tmpdir, tree) re_files = r'^.*(\.txt|\.js)$' watcher_no_re_dirs = RegExpWatcher(str(tmpdir), re_files) changes = watcher_no_re_dirs.check() assert changes == set() sleep(0.01) tmpdir.join('foo/spam.py').write('xxx') tmpdir.join('foo/bar.txt').write('change') tmpdir.join('foo/recursive_dir/foo.js').write('change') assert watcher_no_re_dirs.check() == { (Change.modified, str(tmpdir.join('foo/bar.txt'))), (Change.added, str(tmpdir.join('foo/recursive_dir/foo.js'))), } def test_regexp_no_re_files(tmpdir): mktree(tmpdir, tree) re_dirs = r'^(?:(?!recursive_dir).)*$' watcher_no_re_files = RegExpWatcher(str(tmpdir), re_dirs=re_dirs) changes = watcher_no_re_files.check() assert changes == set() sleep(0.01) tmpdir.join('foo/spam.py').write('xxx') tmpdir.join('foo/bar.txt').write('change') tmpdir.join('foo/recursive_dir/foo.js').write('change') assert watcher_no_re_files.check() == { (Change.modified, str(tmpdir.join('foo/spam.py'))), (Change.modified, str(tmpdir.join('foo/bar.txt'))), } def test_regexp_no_args(tmpdir): mktree(tmpdir, tree) watcher_no_args = RegExpWatcher(str(tmpdir)) changes = watcher_no_args.check() assert changes == set() sleep(0.01) tmpdir.join('foo/spam.py').write('xxx') tmpdir.join('foo/bar.txt').write('change') tmpdir.join('foo/recursive_dir/foo.js').write('change') assert watcher_no_args.check() == { (Change.modified, str(tmpdir.join('foo/spam.py'))), (Change.modified, str(tmpdir.join('foo/bar.txt'))), (Change.added, str(tmpdir.join('foo/recursive_dir/foo.js'))), } @skip_on_windows def test_does_not_exist(caplog, tmp_path): p = str(tmp_path / 'missing') AllWatcher(p) assert f"error walking file system: FileNotFoundError [Errno 2] No such file or directory: '{p}'" in caplog.text def test_watch(mocker): class FakeWatcher: def __init__(self, path): self._results = iter( [ {'r1'}, set(), {'r2'}, set(), ] ) def check(self): return next(self._results) iter_ = watch('xxx', watcher_cls=FakeWatcher, debounce=5, normal_sleep=2, min_sleep=1) assert next(iter_) == {'r1'} assert next(iter_) == {'r2'} def test_watch_watcher_kwargs(mocker): class FakeWatcher: def __init__(self, path, arg1=None, arg2=None): self._results = iter( [ {arg1}, set(), {arg2}, set(), ] ) def check(self): return next(self._results) kwargs = dict(arg1='foo', arg2='bar') iter_ = watch('xxx', watcher_cls=FakeWatcher, watcher_kwargs=kwargs, debounce=5, normal_sleep=2, min_sleep=1) assert next(iter_) == {kwargs['arg1']} assert next(iter_) == {kwargs['arg2']} def test_watch_stop(): class FakeWatcher: def __init__(self, path): self._results = iter( [ {'r1'}, set(), {'r2'}, ] ) def check(self): return next(self._results) stop_event = threading.Event() stop_event.set() ans = [] for c in watch('xxx', watcher_cls=FakeWatcher, debounce=5, min_sleep=1, stop_event=stop_event): ans.append(c) assert ans == [] def test_watch_keyboard_error(): class FakeWatcher: def __init__(self, path): pass def check(self): raise KeyboardInterrupt() iter = watch('xxx', watcher_cls=FakeWatcher, debounce=5, min_sleep=1) assert list(iter) == [] def test_watch_log(mocker, caplog): mock_log_enabled = mocker.patch('watchgod.main.logger.isEnabledFor') mock_log_enabled.return_value = True class FakeWatcher: def __init__(self, path): self.files = [1, 2, 3] def check(self): return {'r1'} iter = watch('xxx', watcher_cls=FakeWatcher, debounce=5, min_sleep=10) assert next(iter) == {'r1'} assert 'xxx time=Xms debounced=Xms files=3 changes=1 (1)' in re.sub(r'\dms', 'Xms', caplog.text) async def test_awatch(mocker): class FakeWatcher: def __init__(self, path): self._results = iter( [ set(), set(), {'r1'}, set(), {'r2'}, set(), ] ) def check(self): return next(self._results) ans = [] async for v in awatch('xxx', watcher_cls=FakeWatcher, debounce=5, normal_sleep=2, min_sleep=1): ans.append(v) if len(ans) == 2: break assert ans == [{'r1'}, {'r2'}] async def test_awatch_stop(): class FakeWatcher: def __init__(self, path): self._results = iter( [ {'r1'}, set(), {'r2'}, ] ) def check(self): return next(self._results) stop_event = asyncio.Event() stop_event.set() ans = [] async for v in awatch('xxx', watcher_cls=FakeWatcher, debounce=5, min_sleep=1, stop_event=stop_event): ans.append(v) assert ans == [] @skip_unless_linux async def test_awatch_log(mocker, caplog): mock_log_enabled = mocker.patch('watchgod.main.logger.isEnabledFor') mock_log_enabled.return_value = True class FakeWatcher: def __init__(self, path): self.files = [1, 2, 3] def check(self): return {'r1'} async for v in awatch('xxx', watcher_cls=FakeWatcher, debounce=5, min_sleep=1): assert v == {'r1'} break print(caplog.text) assert caplog.text.count('DEBUG') > 3 assert 'xxx time=Xms debounced=Xms files=3 changes=1 (1)' in re.sub(r'\dms', 'Xms', caplog.text) watchgod-0.7/watchgod/000077500000000000000000000000001400530460200147525ustar00rootroot00000000000000watchgod-0.7/watchgod/__init__.py000066400000000000000000000004521400530460200170640ustar00rootroot00000000000000# flake8: noqa from .main import * from .version import * from .watcher import * __all__ = ( 'watch', 'awatch', 'run_process', 'arun_process', 'Change', 'AllWatcher', 'DefaultDirWatcher', 'DefaultWatcher', 'PythonWatcher', 'RegExpWatcher', 'VERSION', ) watchgod-0.7/watchgod/__main__.py000066400000000000000000000000731400530460200170440ustar00rootroot00000000000000from .cli import cli if __name__ == '__main__': cli() watchgod-0.7/watchgod/cli.py000066400000000000000000000111261400530460200160740ustar00rootroot00000000000000import argparse import contextlib import logging import os import sys from importlib import import_module from multiprocessing import set_start_method from pathlib import Path from typing import Any, Generator, List, Optional, Sized from .main import run_process logger = logging.getLogger('watchgod.cli') def import_string(dotted_path: str) -> Any: """ Stolen approximately from django. Import a dotted module path and return the attribute/class designated by the last name in the path. Raise ImportError if the import fails. """ try: module_path, class_name = dotted_path.strip(' ').rsplit('.', 1) except ValueError as e: raise ImportError('"{}" doesn\'t look like a module path'.format(dotted_path)) from e module = import_module(module_path) try: return getattr(module, class_name) except AttributeError as e: raise ImportError('Module "{}" does not define a "{}" attribute'.format(module_path, class_name)) from e @contextlib.contextmanager def set_tty(tty_path: Optional[str]) -> Generator[None, None, None]: if tty_path: try: with open(tty_path) as tty: # pragma: no cover sys.stdin = tty yield except OSError: # eg. "No such device or address: '/dev/tty'", see https://github.com/samuelcolvin/watchgod/issues/40 yield else: # currently on windows tty_path is None and there's nothing we can do here yield def run_function(function: str, tty_path: Optional[str]) -> None: with set_tty(tty_path): func = import_string(function) func() def callback(changes: Sized) -> None: logger.info('%d files changed, reloading', len(changes)) def sys_argv(function: str) -> List[str]: """ Remove watchgod-related arguments from sys.argv and prepend with func's script path. """ bases_ = function.split('.')[:-1] # remove function and leave only file path base = os.path.join(*bases_) + '.py' base = os.path.abspath(base) for i, arg in enumerate(sys.argv): if arg in {'-a', '--args'}: return [base] + sys.argv[i + 1 :] return [base] # strip all args if no additional args were provided def cli(*args_: str) -> None: args = args_ or sys.argv[1:] parser = argparse.ArgumentParser( prog='watchgod', description='Watch a directory and execute a python function on changes.' ) parser.add_argument('function', help='Path to python function to execute.') parser.add_argument('path', nargs='?', default='.', help='Filesystem path to watch, defaults to current directory.') parser.add_argument('--verbosity', nargs='?', type=int, default=1, help='0, 1 (default) or 2') parser.add_argument( '--ignore-paths', nargs='*', type=str, default=[], help='Specify paths to files or directories to ignore their updates', ) parser.add_argument( '--args', '-a', nargs=argparse.REMAINDER, help='Arguments for argparser inside executed function. Ex.: module.func path --args --inner arg -v', ) arg_namespace = parser.parse_args(args) log_level = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG}[arg_namespace.verbosity] hdlr = logging.StreamHandler() hdlr.setLevel(log_level) hdlr.setFormatter(logging.Formatter(fmt='[%(asctime)s] %(message)s', datefmt='%H:%M:%S')) wg_logger = logging.getLogger('watchgod') wg_logger.addHandler(hdlr) wg_logger.setLevel(log_level) sys.path.append(os.getcwd()) try: import_string(arg_namespace.function) except ImportError as e: print('ImportError: {}'.format(e), file=sys.stderr) sys.exit(1) return path = Path(arg_namespace.path) if not path.exists(): print('path "{}" does not exist'.format(path), file=sys.stderr) sys.exit(1) return path = path.resolve() try: tty_path: Optional[str] = os.ttyname(sys.stdin.fileno()) except OSError: # fileno() always fails with pytest tty_path = '/dev/tty' except AttributeError: # on windows. No idea of a better solution tty_path = None logger.info('watching "%s" and reloading "%s" on changes...', path, arg_namespace.function) set_start_method('spawn') sys.argv = sys_argv(arg_namespace.function) ignored_paths = {str(Path(p).resolve()) for p in arg_namespace.ignore_paths} run_process( path, run_function, args=(arg_namespace.function, tty_path), callback=callback, watcher_kwargs={'ignored_paths': ignored_paths}, ) watchgod-0.7/watchgod/main.py000066400000000000000000000165711400530460200162620ustar00rootroot00000000000000import asyncio import functools import logging import os import signal from concurrent.futures import ThreadPoolExecutor from functools import partial from multiprocessing import Process from pathlib import Path from time import time from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Generator, Optional, Set, Tuple, Type, Union, cast from .watcher import DefaultWatcher, PythonWatcher __all__ = 'watch', 'awatch', 'run_process', 'arun_process' logger = logging.getLogger('watchgod.main') if TYPE_CHECKING: from .watcher import AllWatcher, FileChange FileChanges = Set[FileChange] AnyCallable = Callable[..., Any] def unix_ms() -> int: return int(round(time() * 1000)) def watch(path: Union[Path, str], **kwargs: Any) -> Generator['FileChanges', None, None]: """ Watch a directory and yield a set of changes whenever files change in that directory or its subdirectories. """ loop = asyncio.new_event_loop() try: _awatch = awatch(path, loop=loop, **kwargs) while True: try: yield loop.run_until_complete(_awatch.__anext__()) except StopAsyncIteration: break except KeyboardInterrupt: logger.debug('KeyboardInterrupt, exiting') finally: loop.close() class awatch: """ asynchronous equivalent of watch using a threaded executor. 3.5 doesn't support yield in coroutines so we need all this fluff. Yawwwwn. """ __slots__ = ( '_loop', '_path', '_watcher_cls', '_watcher_kwargs', '_debounce', '_min_sleep', '_stop_event', '_normal_sleep', '_w', 'lock', '_executor', ) def __init__( self, path: Union[Path, str], *, watcher_cls: Type['AllWatcher'] = DefaultWatcher, watcher_kwargs: Optional[Dict[str, Any]] = None, debounce: int = 1600, normal_sleep: int = 400, min_sleep: int = 50, stop_event: Optional[asyncio.Event] = None, loop: Optional[asyncio.AbstractEventLoop] = None, ) -> None: self._loop = loop or asyncio.get_event_loop() self._executor = ThreadPoolExecutor(max_workers=4) self._path = path self._watcher_cls = watcher_cls self._watcher_kwargs = watcher_kwargs or dict() self._debounce = debounce self._normal_sleep = normal_sleep self._min_sleep = min_sleep self._stop_event = stop_event self._w: Optional['AllWatcher'] = None asyncio.set_event_loop(self._loop) self.lock = asyncio.Lock() def __aiter__(self) -> 'awatch': return self async def __anext__(self) -> 'FileChanges': if self._w: watcher = self._w else: watcher = self._w = await self.run_in_executor( functools.partial(self._watcher_cls, self._path, **self._watcher_kwargs) ) check_time = 0 changes: 'FileChanges' = set() last_change = 0 while True: if self._stop_event and self._stop_event.is_set(): raise StopAsyncIteration() async with self.lock: if not changes: last_change = unix_ms() if check_time: if changes: sleep_time = self._min_sleep else: sleep_time = max(self._normal_sleep - check_time, self._min_sleep) await asyncio.sleep(sleep_time / 1000) s = unix_ms() new_changes = await self.run_in_executor(watcher.check) changes.update(new_changes) now = unix_ms() check_time = now - s debounced = now - last_change if logger.isEnabledFor(logging.DEBUG) and changes: logger.debug( '%s time=%0.0fms debounced=%0.0fms files=%d changes=%d (%d)', self._path, check_time, debounced, len(watcher.files), len(changes), len(new_changes), ) if changes and (not new_changes or debounced > self._debounce): logger.debug('%s changes released debounced=%0.0fms', self._path, debounced) return changes async def run_in_executor(self, func: 'AnyCallable', *args: Any) -> Any: return await self._loop.run_in_executor(self._executor, func, *args) def __del__(self) -> None: self._executor.shutdown() def _start_process(target: 'AnyCallable', args: Tuple[Any, ...], kwargs: Optional[Dict[str, Any]]) -> Process: process = Process(target=target, args=args, kwargs=kwargs or {}) process.start() return process def _stop_process(process: Process) -> None: if process.is_alive(): logger.debug('stopping process...') pid = cast(int, process.pid) os.kill(pid, signal.SIGINT) process.join(5) if process.exitcode is None: logger.warning('process has not terminated, sending SIGKILL') os.kill(pid, signal.SIGKILL) process.join(1) else: logger.debug('process stopped') else: logger.warning('process already dead, exit code: %d', process.exitcode) def run_process( path: Union[Path, str], target: 'AnyCallable', *, args: Tuple[Any, ...] = (), kwargs: Optional[Dict[str, Any]] = None, callback: Optional[Callable[[Set['FileChange']], None]] = None, watcher_cls: Type['AllWatcher'] = PythonWatcher, watcher_kwargs: Optional[Dict[str, Any]] = None, debounce: int = 400, min_sleep: int = 100, ) -> int: """ Run a function in a subprocess using multiprocessing.Process, restart it whenever files change in path. """ process = _start_process(target=target, args=args, kwargs=kwargs) reloads = 0 try: for changes in watch( path, watcher_cls=watcher_cls, debounce=debounce, min_sleep=min_sleep, watcher_kwargs=watcher_kwargs ): callback and callback(changes) _stop_process(process) process = _start_process(target=target, args=args, kwargs=kwargs) reloads += 1 finally: _stop_process(process) return reloads async def arun_process( path: Union[Path, str], target: 'AnyCallable', *, args: Tuple[Any, ...] = (), kwargs: Optional[Dict[str, Any]] = None, callback: Optional[Callable[['FileChanges'], Awaitable[None]]] = None, watcher_cls: Type['AllWatcher'] = PythonWatcher, debounce: int = 400, min_sleep: int = 100, ) -> int: """ Run a function in a subprocess using multiprocessing.Process, restart it whenever files change in path. """ watcher = awatch(path, watcher_cls=watcher_cls, debounce=debounce, min_sleep=min_sleep) start_process = partial(_start_process, target=target, args=args, kwargs=kwargs) process = await watcher.run_in_executor(start_process) reloads = 0 async for changes in watcher: callback and await callback(changes) await watcher.run_in_executor(_stop_process, process) process = await watcher.run_in_executor(start_process) reloads += 1 await watcher.run_in_executor(_stop_process, process) return reloads watchgod-0.7/watchgod/py.typed000066400000000000000000000001031400530460200164430ustar00rootroot00000000000000# Marker file for PEP 561. The watchgod package uses inline types. watchgod-0.7/watchgod/version.py000066400000000000000000000000501400530460200170040ustar00rootroot00000000000000__all__ = ('VERSION',) VERSION = '0.7' watchgod-0.7/watchgod/watcher.py000066400000000000000000000105471400530460200167700ustar00rootroot00000000000000import logging import os import re from enum import IntEnum from pathlib import Path from typing import TYPE_CHECKING, Dict, Optional, Pattern, Set, Tuple, Union __all__ = 'Change', 'AllWatcher', 'DefaultDirWatcher', 'DefaultWatcher', 'PythonWatcher', 'RegExpWatcher' logger = logging.getLogger('watchgod.watcher') class Change(IntEnum): added = 1 modified = 2 deleted = 3 if TYPE_CHECKING: FileChange = Tuple[Change, str] DirEntry = os.DirEntry[str] StatResult = os.stat_result class AllWatcher: def __init__(self, root_path: Union[Path, str], ignored_paths: Optional[Set[str]] = None) -> None: self.files: Dict[str, float] = {} self.root_path = str(root_path) self.ignored_paths = ignored_paths self.check() def should_watch_dir(self, entry: 'DirEntry') -> bool: return True def should_watch_file(self, entry: 'DirEntry') -> bool: return True def _walk(self, path: str, changes: Set['FileChange'], new_files: Dict[str, float]) -> None: if os.path.isfile(path): self._watch_file(path, changes, new_files, os.stat(path)) else: self._walk_dir(path, changes, new_files) def _watch_file( self, path: str, changes: Set['FileChange'], new_files: Dict[str, float], stat: 'StatResult' ) -> None: mtime = stat.st_mtime new_files[path] = mtime old_mtime = self.files.get(path) if not old_mtime: changes.add((Change.added, path)) elif old_mtime != mtime: changes.add((Change.modified, path)) def _walk_dir(self, dir_path: str, changes: Set['FileChange'], new_files: Dict[str, float]) -> None: for entry in os.scandir(dir_path): if self.ignored_paths is not None and os.path.join(dir_path, entry) in self.ignored_paths: continue if entry.is_dir(): if self.should_watch_dir(entry): self._walk_dir(entry.path, changes, new_files) elif self.should_watch_file(entry): self._watch_file(entry.path, changes, new_files, entry.stat()) def check(self) -> Set['FileChange']: changes: Set['FileChange'] = set() new_files: Dict[str, float] = {} try: self._walk(self.root_path, changes, new_files) except OSError as e: # happens when a directory has been deleted between checks logger.warning('error walking file system: %s %s', e.__class__.__name__, e) # look for deleted deleted = self.files.keys() - new_files.keys() if deleted: changes |= {(Change.deleted, entry) for entry in deleted} self.files = new_files return changes class DefaultDirWatcher(AllWatcher): ignored_dirs = {'.git', '__pycache__', 'site-packages', '.idea', 'node_modules'} def should_watch_dir(self, entry: 'DirEntry') -> bool: return entry.name not in self.ignored_dirs class DefaultWatcher(DefaultDirWatcher): ignored_file_regexes = r'\.py[cod]$', r'\.___jb_...___$', r'\.sw.$', '~$', r'^\.\#', r'^flycheck_' def __init__(self, root_path: str) -> None: self._ignored_file_regexes = tuple(re.compile(r) for r in self.ignored_file_regexes) super().__init__(root_path) def should_watch_file(self, entry: 'DirEntry') -> bool: return not any(r.search(entry.name) for r in self._ignored_file_regexes) class PythonWatcher(DefaultDirWatcher): def should_watch_file(self, entry: 'DirEntry') -> bool: return entry.name.endswith(('.py', '.pyx', '.pyd')) class RegExpWatcher(AllWatcher): def __init__(self, root_path: str, re_files: Optional[str] = None, re_dirs: Optional[str] = None): self.re_files: Optional[Pattern[str]] = re.compile(re_files) if re_files is not None else re_files self.re_dirs: Optional[Pattern[str]] = re.compile(re_dirs) if re_dirs is not None else re_dirs super().__init__(root_path) def should_watch_file(self, entry: 'DirEntry') -> bool: if self.re_files is not None: return bool(self.re_files.match(entry.path)) else: return super().should_watch_file(entry) def should_watch_dir(self, entry: 'DirEntry') -> bool: if self.re_dirs is not None: return bool(self.re_dirs.match(entry.path)) else: return super().should_watch_dir(entry)