diff --git a/README.md b/README.md index e0954c5a7..9617acab5 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ In this context, "Cython-based" means the following: Moreover, "optional extras" means that: - the websocket protocol will be handled by `websockets` (should you want to use `wsproto` you'd need to install it manually) if possible. -- the `--reload` flag in development mode will use `watchgod`. +- the `--reload` flag in development mode will use `watchfiles`. - windows users will have `colorama` installed for the colored logs. - `python-dotenv` will be installed should you want to use the `--env-file` option. - `PyYAML` will be installed to allow you to provide a `.yaml` file to `--log-config`, if desired. diff --git a/docs/deployment.md b/docs/deployment.md index 94a0d130b..64f8b0bce 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -43,12 +43,12 @@ Options: for files. Includes '*.py' by default; these defaults can be overridden with `--reload- exclude`. This option has no effect unless - watchgod is installed. + watchfiles is installed. --reload-exclude TEXT Set glob patterns to exclude while watching for files. Includes '.*, .py[cod], .sw.*, ~*' by default; these defaults can be overridden with `--reload-include`. This - option has no effect unless watchgod is + option has no effect unless watchfiles is installed. --reload-delay FLOAT Delay between previous and next check if application needs to be. Defaults to 0.25s. diff --git a/docs/index.md b/docs/index.md index 472c92ca1..cae1a2c0a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,7 +54,7 @@ In this context, "Cython-based" means the following: Moreover, "optional extras" means that: - the websocket protocol will be handled by `websockets` (should you want to use `wsproto` you'd need to install it manually) if possible. -- the `--reload` flag in development mode will use `watchgod`. +- the `--reload` flag in development mode will use `watchfiles`. - windows users will have `colorama` installed for the colored logs. - `python-dotenv` will be installed should you want to use the `--env-file` option. - `PyYAML` will be installed to allow you to provide a `.yaml` file to `--log-config`, if desired. @@ -110,12 +110,12 @@ Options: for files. Includes '*.py' by default; these defaults can be overridden with `--reload- exclude`. This option has no effect unless - watchgod is installed. + watchfiles is installed. --reload-exclude TEXT Set glob patterns to exclude while watching for files. Includes '.*, .py[cod], .sw.*, ~*' by default; these defaults can be overridden with `--reload-include`. This - option has no effect unless watchgod is + option has no effect unless watchfiles is installed. --reload-delay FLOAT Delay between previous and next check if application needs to be. Defaults to 0.25s. diff --git a/docs/settings.md b/docs/settings.md index 25be52353..263e7d947 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -32,13 +32,13 @@ For example, in case you want to run the app on port `5000`, just set the enviro * `--reload` - Enable auto-reload. Uvicorn supports two versions of auto-reloading behavior enabled by this option. There are important differences between them. * `--reload-dir ` - Specify which directories to watch for python file changes. May be used multiple times. If unused, then by default the whole current directory will be watched. If you are running programmatically use `reload_dirs=[]` and pass a list of strings. -### Reloading without watchgod +### Reloading without watchfiles -If Uvicorn _cannot_ load [watchgod](https://pypi.org/project/watchgod/) at runtime, it will periodically look for changes in modification times to all `*.py` files (and only `*.py` files) inside of its monitored directories. See the `--reload-dir` option. Specifying other file extensions is not supported unless watchgod is installed. See the `--reload-include` and `--reload-exclude` options for details. +If Uvicorn _cannot_ load [watchfiles](https://pypi.org/project/watchfiles/) at runtime, it will periodically look for changes in modification times to all `*.py` files (and only `*.py` files) inside of its monitored directories. See the `--reload-dir` option. Specifying other file extensions is not supported unless watchfiles is installed. See the `--reload-include` and `--reload-exclude` options for details. -### Reloading with watchgod +### Reloading with watchfiles -For more nuanced control over which file modifications trigger reloads, install `uvicorn[standard]`, which includes watchgod as a dependency. Alternatively, install [watchgod](https://pypi.org/project/watchgod/) where Unvicorn can see it. This will enable the following options (which are otherwise ignored). +For more nuanced control over which file modifications trigger reloads, install `uvicorn[standard]`, which includes watchfiles as a dependency. Alternatively, install [watchfiles](https://pypi.org/project/watchfiles/) where Uvicorn can see it. This will enable the following options (which are otherwise ignored). * `--reload-include ` - Specify a glob pattern to match files or directories which will be watched. May be used multiple times. By default the following patterns are included: `*.py`. These defaults can be overwritten by including them in `--reload-exclude`. * `--reload-exclude ` - Specify a glob pattern to match files or directories which will excluded from watching. May be used multiple times. By default the following patterns are excluded: `.*, .py[cod], .sw.*, ~*`. These defaults can be overwritten by including them in `--reload-include`. diff --git a/requirements.txt b/requirements.txt index e61b5a683..88e460c72 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ cryptography==3.4.8 coverage==6.4 coverage-conditional-plugin==0.5.0 httpx==1.0.0b0 +watchgod==0.8.2 # Documentation mkdocs==1.3.0 diff --git a/setup.cfg b/setup.cfg index 38c6c9fa4..2d1fef39b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,6 +22,7 @@ files = uvicorn/supervisors/__init__.py, uvicorn/middleware/debug.py, uvicorn/middleware/wsgi.py, + uvicorn/supervisors/watchfilesreload.py, uvicorn/supervisors/watchgodreload.py, uvicorn/_logging.py, uvicorn/middleware/asgi2.py, @@ -62,7 +63,7 @@ check_untyped_defs = True profile = black combine_as_imports = True known_first_party = uvicorn,tests -known_third_party = click,does_not_exist,gunicorn,h11,httptools,pytest,requests,setuptools,urllib3,uvloop,watchgod,websockets,wsproto,yaml +known_third_party = click,does_not_exist,gunicorn,h11,httptools,pytest,requests,setuptools,urllib3,uvloop,watchgod,watchfiles,websockets,wsproto,yaml [tool:pytest] addopts = -rxXs @@ -72,6 +73,7 @@ xfail_strict=True filterwarnings= # Turn warnings that aren't filtered into exceptions error + ignore: \"watchgod\" is depreciated\, you should switch to watchfiles \(`pip install watchfiles`\)\.:DeprecationWarning [coverage:run] omit = venv/* @@ -88,6 +90,7 @@ exclude_lines = pragma: no cover pragma: nocover if TYPE_CHECKING: + raise NotImplementedError [coverage:coverage_conditional_plugin] rules = diff --git a/setup.py b/setup.py index 2ee71f2ad..402cd9827 100755 --- a/setup.py +++ b/setup.py @@ -56,7 +56,7 @@ def get_packages(package): "httptools>=0.4.0", "uvloop>=0.14.0,!=0.15.0,!=0.15.1; " + env_marker_cpython, "colorama>=0.4;" + env_marker_win, - "watchgod>=0.6", + "watchfiles>=0.13", "python-dotenv>=0.13", "PyYAML>=5.1", ] diff --git a/tests/conftest.py b/tests/conftest.py index af74b4aeb..f5313cea3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,8 @@ from hashlib import md5 from pathlib import Path from tempfile import TemporaryDirectory +from threading import Thread +from time import sleep from uuid import uuid4 import pytest @@ -181,3 +183,24 @@ def make_tmp_dir(base_dir): sock_path = str(tmpd / "".join((identifier, socket_filename))) yield sock_path return + + +def sleep_touch(*paths: Path): + sleep(0.1) + for p in paths: + p.touch() + + +@pytest.fixture +def touch_soon(): + threads = [] + + def start(*paths: Path): + thread = Thread(target=sleep_touch, args=paths) + thread.start() + threads.append(thread) + + yield start + + for t in threads: + t.join() diff --git a/tests/supervisors/test_reload.py b/tests/supervisors/test_reload.py index 81e770b41..04bfb41cf 100644 --- a/tests/supervisors/test_reload.py +++ b/tests/supervisors/test_reload.py @@ -1,45 +1,60 @@ +import logging import signal -from logging import DEBUG, INFO, WARNING from pathlib import Path from time import sleep +from typing import Type import pytest from tests.utils import as_cwd from uvicorn.config import Config -from uvicorn.supervisors.basereload import BaseReload +from uvicorn.supervisors.basereload import BaseReload, _display_path from uvicorn.supervisors.statreload import StatReload +from uvicorn.supervisors.watchfilesreload import WatchFilesReload from uvicorn.supervisors.watchgodreload import WatchGodReload +def run(sockets): + pass # pragma: no cover + + class TestBaseReload: @pytest.fixture(autouse=True) def setup( self, reload_directory_structure: Path, - reloader_class: BaseReload, + reloader_class: Type[BaseReload], ): self.reload_path = reload_directory_structure self.reloader_class = reloader_class - def run(self, sockets): - pass # pragma: no cover - def _setup_reloader(self, config: Config) -> BaseReload: - reloader = self.reloader_class(config, target=self.run, sockets=[]) + config.reload_delay = 0 # save time + + if self.reloader_class is WatchGodReload: + with pytest.deprecated_call(): + reloader = self.reloader_class(config, target=run, sockets=[]) + else: + reloader = self.reloader_class(config, target=run, sockets=[]) + assert config.should_reload - reloader.signal_handler(sig=signal.SIGINT, frame=None) reloader.startup() return reloader - def _reload_tester(self, reloader: BaseReload, file: Path) -> bool: + def _reload_tester(self, touch_soon, reloader: BaseReload, *files: Path) -> bool: reloader.restart() - assert not reloader.should_restart() - sleep(0.1) - file.touch() - return reloader.should_restart() + if isinstance(reloader, WatchFilesReload): + touch_soon(*files) + else: + assert not next(reloader) + sleep(0.1) + for file in files: + file.touch() + return next(reloader) - @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload]) + @pytest.mark.parametrize( + "reloader_class", [StatReload, WatchGodReload, WatchFilesReload] + ) def test_reloader_should_initialize(self) -> None: """ A basic sanity check. @@ -52,33 +67,40 @@ def test_reloader_should_initialize(self) -> None: reloader = self._setup_reloader(config) reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload]) - def test_reload_when_python_file_is_changed(self) -> None: + @pytest.mark.parametrize( + "reloader_class", [StatReload, WatchGodReload, WatchFilesReload] + ) + def test_reload_when_python_file_is_changed(self, touch_soon) -> None: file = self.reload_path / "main.py" with as_cwd(self.reload_path): config = Config(app="tests.test_config:asgi_app", reload=True) reloader = self._setup_reloader(config) - assert self._reload_tester(reloader, file) + changes = self._reload_tester(touch_soon, reloader, file) + assert changes == [file] reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload]) - def test_should_reload_when_python_file_in_subdir_is_changed(self) -> None: + @pytest.mark.parametrize( + "reloader_class", [StatReload, WatchGodReload, WatchFilesReload] + ) + def test_should_reload_when_python_file_in_subdir_is_changed( + self, touch_soon + ) -> None: file = self.reload_path / "app" / "sub" / "sub.py" with as_cwd(self.reload_path): config = Config(app="tests.test_config:asgi_app", reload=True) reloader = self._setup_reloader(config) - assert self._reload_tester(reloader, file) + assert self._reload_tester(touch_soon, reloader, file) reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [WatchGodReload]) + @pytest.mark.parametrize("reloader_class", [WatchFilesReload, WatchGodReload]) def test_should_not_reload_when_python_file_in_excluded_subdir_is_changed( - self, + self, touch_soon ) -> None: sub_dir = self.reload_path / "app" / "sub" sub_file = sub_dir / "sub.py" @@ -91,14 +113,16 @@ def test_should_not_reload_when_python_file_in_excluded_subdir_is_changed( ) reloader = self._setup_reloader(config) - assert not self._reload_tester(reloader, sub_file) + assert not self._reload_tester(touch_soon, reloader, sub_file) reloader.shutdown() @pytest.mark.parametrize( - "reloader_class, result", [(StatReload, False), (WatchGodReload, True)] + "reloader_class, result", [(StatReload, False), (WatchFilesReload, True)] ) - def test_reload_when_pattern_matched_file_is_changed(self, result: bool) -> None: + def test_reload_when_pattern_matched_file_is_changed( + self, result: bool, touch_soon + ) -> None: file = self.reload_path / "app" / "js" / "main.js" with as_cwd(self.reload_path): @@ -107,12 +131,14 @@ def test_reload_when_pattern_matched_file_is_changed(self, result: bool) -> None ) reloader = self._setup_reloader(config) - assert self._reload_tester(reloader, file) == result + assert bool(self._reload_tester(touch_soon, reloader, file)) == result reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [WatchGodReload]) - def test_should_not_reload_when_exclude_pattern_match_file_is_changed(self) -> None: + @pytest.mark.parametrize("reloader_class", [WatchFilesReload, WatchGodReload]) + def test_should_not_reload_when_exclude_pattern_match_file_is_changed( + self, touch_soon + ) -> None: python_file = self.reload_path / "app" / "src" / "main.py" css_file = self.reload_path / "app" / "css" / "main.css" js_file = self.reload_path / "app" / "js" / "main.js" @@ -126,26 +152,30 @@ def test_should_not_reload_when_exclude_pattern_match_file_is_changed(self) -> N ) reloader = self._setup_reloader(config) - assert self._reload_tester(reloader, python_file) - assert self._reload_tester(reloader, css_file) - assert not self._reload_tester(reloader, js_file) + assert self._reload_tester(touch_soon, reloader, python_file) + assert self._reload_tester(touch_soon, reloader, css_file) + assert not self._reload_tester(touch_soon, reloader, js_file) reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload]) - def test_should_not_reload_when_dot_file_is_changed(self) -> None: + @pytest.mark.parametrize( + "reloader_class", [StatReload, WatchGodReload, WatchFilesReload] + ) + def test_should_not_reload_when_dot_file_is_changed(self, touch_soon) -> None: file = self.reload_path / ".dotted" with as_cwd(self.reload_path): config = Config(app="tests.test_config:asgi_app", reload=True) reloader = self._setup_reloader(config) - assert not self._reload_tester(reloader, file) + assert not self._reload_tester(touch_soon, reloader, file) reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload]) - def test_should_reload_when_directories_have_same_prefix(self) -> None: + @pytest.mark.parametrize( + "reloader_class", [StatReload, WatchGodReload, WatchFilesReload] + ) + def test_should_reload_when_directories_have_same_prefix(self, touch_soon) -> None: app_dir = self.reload_path / "app" app_file = app_dir / "src" / "main.py" app_first_dir = self.reload_path / "app_first" @@ -159,52 +189,37 @@ def test_should_reload_when_directories_have_same_prefix(self) -> None: ) reloader = self._setup_reloader(config) - assert self._reload_tester(reloader, app_file) - assert self._reload_tester(reloader, app_first_file) + assert self._reload_tester(touch_soon, reloader, app_file) + assert self._reload_tester(touch_soon, reloader, app_first_file) reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload]) - def test_should_not_reload_when_only_subdirectory_is_watched(self) -> None: + @pytest.mark.parametrize( + "reloader_class", [StatReload, WatchGodReload, WatchFilesReload] + ) + def test_should_not_reload_when_only_subdirectory_is_watched( + self, touch_soon + ) -> None: app_dir = self.reload_path / "app" app_dir_file = self.reload_path / "app" / "src" / "main.py" root_file = self.reload_path / "main.py" - with as_cwd(self.reload_path): - config = Config( - app="tests.test_config:asgi_app", - reload=True, - reload_dirs=[str(app_dir)], - ) - reloader = self._setup_reloader(config) + config = Config( + app="tests.test_config:asgi_app", + reload=True, + reload_dirs=[str(app_dir)], + ) + reloader = self._setup_reloader(config) - assert self._reload_tester(reloader, app_dir_file) - assert not self._reload_tester(reloader, root_file) + assert self._reload_tester(touch_soon, reloader, app_dir_file) + assert not self._reload_tester( + touch_soon, reloader, root_file, app_dir / "~ignored" + ) - reloader.shutdown() + reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload]) - def test_should_parse_dir_from_includes(self) -> None: - app_dir = self.reload_path / "app" - app_file = app_dir / "src" / "main.py" - app_first_dir = self.reload_path / "app_first" - app_first_file = app_first_dir / "src" / "main.py" - - with as_cwd(self.reload_path): - config = Config( - app="tests.test_config:asgi_app", - reload=True, - reload_includes=[str(app_dir)], - ) - reloader = self._setup_reloader(config) - - assert self._reload_tester(reloader, app_file) - assert not self._reload_tester(reloader, app_first_file) - - reloader.shutdown() - - @pytest.mark.parametrize("reloader_class", [WatchGodReload]) - def test_override_defaults(self) -> None: + @pytest.mark.parametrize("reloader_class", [WatchFilesReload, WatchGodReload]) + def test_override_defaults(self, touch_soon) -> None: dotted_file = self.reload_path / ".dotted" dotted_dir_file = self.reload_path / ".dotted_dir" / "file.txt" python_file = self.reload_path / "main.py" @@ -219,111 +234,42 @@ def test_override_defaults(self) -> None: ) reloader = self._setup_reloader(config) - assert self._reload_tester(reloader, dotted_file) - assert self._reload_tester(reloader, dotted_dir_file) - assert not self._reload_tester(reloader, python_file) + assert self._reload_tester(touch_soon, reloader, dotted_file) + assert self._reload_tester(touch_soon, reloader, dotted_dir_file) + assert not self._reload_tester(touch_soon, reloader, python_file) reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [WatchGodReload]) - def test_should_start_one_watcher_for_dirs_inside_cwd( - self, caplog: pytest.LogCaptureFixture - ) -> None: - app_file = self.reload_path / "app" / "src" / "main.py" - app_first_file = self.reload_path / "app_first" / "src" / "main.py" + @pytest.mark.parametrize("reloader_class", [WatchFilesReload]) + def test_watchfiles_no_changes(self) -> None: + sub_dir = self.reload_path / "app" / "sub" with as_cwd(self.reload_path): - config = Config( - app="tests.test_config:asgi_app", reload=True, reload_includes=["app*"] - ) - reloader = self._setup_reloader(config) - assert len(reloader.watchers) == 1 - - assert self.reload_path == reloader.watchers[0].resolved_root - - assert self._reload_tester(reloader, app_file) - assert ( - caplog.records[-1].message - == f"WatchGodReload detected file change in '{[str(app_file)]}'." - " Reloading..." - ) - assert caplog.records[-1].levelno == WARNING - assert self._reload_tester(reloader, app_first_file) - assert "WatchGodReload detected file change" in caplog.records[-1].message - assert ( - caplog.records[-1].message == "WatchGodReload detected file change in " - f"'{[str(app_first_file)]}'. Reloading..." - ) - assert caplog.records[-1].levelno == WARNING - - reloader.shutdown() - - @pytest.mark.parametrize("reloader_class", [WatchGodReload]) - def test_should_start_separate_watchers_for_dirs_outside_cwd( - self, caplog: pytest.LogCaptureFixture - ) -> None: - app_dir = self.reload_path / "app" - app_file = self.reload_path / "app" / "src" / "main.py" - app_first_dir = self.reload_path / "app_first" - app_first_file = app_first_dir / "src" / "main.py" - - with as_cwd(app_dir): config = Config( app="tests.test_config:asgi_app", reload=True, - reload_dirs=[str(app_dir), str(app_first_dir)], + reload_excludes=[str(sub_dir)], ) reloader = self._setup_reloader(config) - assert len(reloader.watchers) == 2 - - assert frozenset([app_dir, app_first_dir]) == frozenset( - [x.resolved_root for x in reloader.watchers] - ) - - assert self._reload_tester(reloader, app_file) - assert caplog.records[-1].levelno == WARNING - assert ( - caplog.records[-1].message == "WatchGodReload detected file change in " - f"'{[str(app_file)]}'. Reloading..." - ) - assert self._reload_tester(reloader, app_first_file) - assert caplog.records[-1].levelno == WARNING - assert ( - caplog.records[-1].message == "WatchGodReload detected file change in " - f"'{[str(app_first_file)]}'. Reloading..." - ) - - reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload]) - def test_should_print_full_path_for_non_relative( - self, caplog: pytest.LogCaptureFixture - ) -> None: - app_dir = self.reload_path / "app" - app_first_dir = self.reload_path / "app_first" - app_first_file = app_first_dir / "src" / "main.py" + from watchfiles import watch - with as_cwd(app_dir): - config = Config( - app="tests.test_config:asgi_app", - reload=True, - reload_dirs=[str(app_first_dir)], + # just so we can make rust_timeout 100ms + reloader.watcher = watch( + sub_dir, + watch_filter=None, + stop_event=reloader.should_exit, + yield_on_timeout=True, + rust_timeout=100, ) - reloader = self._setup_reloader(config) - assert self._reload_tester(reloader, app_first_file) - - assert ( - caplog.records[-1].message - == f"StatReload detected file change in '{str(app_first_file)}'." - " Reloading..." - ) + assert reloader.should_restart() is None reloader.shutdown() @pytest.mark.parametrize("reloader_class", [WatchGodReload]) def test_should_detect_new_reload_dirs( - self, caplog: pytest.LogCaptureFixture, tmp_path: Path + self, touch_soon, caplog: pytest.LogCaptureFixture, tmp_path: Path ) -> None: app_dir = tmp_path / "app" app_file = app_dir / "file.py" @@ -337,44 +283,104 @@ def test_should_detect_new_reload_dirs( app="tests.test_config:asgi_app", reload=True, reload_includes=["app*"] ) reloader = self._setup_reloader(config) - assert self._reload_tester(reloader, app_file) + assert self._reload_tester(touch_soon, reloader, app_file) app_first_dir.mkdir() - assert self._reload_tester(reloader, app_first_file) - assert caplog.records[-2].levelno == INFO + assert self._reload_tester(touch_soon, reloader, app_first_file) + assert caplog.records[-2].levelno == logging.INFO assert ( - caplog.records[-2].message == "WatchGodReload detected a new reload " + caplog.records[-1].message == "WatchGodReload detected a new reload " f"dir '{app_first_dir.name}' in '{tmp_path}'; Adding to watch list." ) reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [WatchGodReload]) - def test_should_detect_new_exclude_dirs( - self, caplog: pytest.LogCaptureFixture, tmp_path: Path - ) -> None: - app_dir = tmp_path / "app" - app_file = app_dir / "file.py" - app_dir.mkdir() - app_file.touch() - app_first_dir = tmp_path / "app_first" - app_first_file = app_first_dir / "file.py" - with as_cwd(tmp_path): - config = Config( - app="tests.test_config:asgi_app", reload=True, reload_excludes=["app*"] - ) - reloader = self._setup_reloader(config) - caplog.set_level(DEBUG, logger="uvicorn.error") +def test_should_watch_one_dir_cwd(mocker, reload_directory_structure): + mock_watch = mocker.patch("uvicorn.supervisors.watchfilesreload.watch") + app_dir = reload_directory_structure / "app" + app_first_dir = reload_directory_structure / "app_first" + + with as_cwd(reload_directory_structure): + config = Config( + app="tests.test_config:asgi_app", + reload=True, + reload_dirs=[str(app_dir), str(app_first_dir)], + ) + WatchFilesReload(config, target=run, sockets=[]) + mock_watch.assert_called_once() + assert mock_watch.call_args[0] == (Path.cwd(),) + + +def test_should_watch_separate_dirs_outside_cwd(mocker, reload_directory_structure): + mock_watch = mocker.patch("uvicorn.supervisors.watchfilesreload.watch") + app_dir = reload_directory_structure / "app" + app_first_dir = reload_directory_structure / "app_first" + config = Config( + app="tests.test_config:asgi_app", + reload=True, + reload_dirs=[str(app_dir), str(app_first_dir)], + ) + WatchFilesReload(config, target=run, sockets=[]) + mock_watch.assert_called_once() + assert set(mock_watch.call_args[0]) == { + app_dir, + app_first_dir, + Path.cwd(), + } - assert not self._reload_tester(reloader, app_file) - app_first_dir.mkdir() - assert not self._reload_tester(reloader, app_first_file) - assert caplog.records[-1].levelno == DEBUG - assert ( - caplog.records[-1].message == "WatchGodReload detected a new excluded " - f"dir '{app_first_dir.name}' in '{tmp_path}'; Adding to exclude list." - ) +def test_display_path_relative(tmp_path): + with as_cwd(tmp_path): + p = tmp_path / "app" / "foobar.py" + # accept windows paths as wells as posix + assert _display_path(p) in ("'app/foobar.py'", "'app\\foobar.py'") - reloader.shutdown() + +def test_display_path_non_relative(): + p = Path("/foo/bar.py") + assert _display_path(p) in ("'/foo/bar.py'", "'\\foo\\bar.py'") + + +def test_base_reloader_run(tmp_path): + calls = [] + step = 0 + + class CustomReload(BaseReload): + def startup(self): + calls.append("startup") + + def restart(self): + calls.append("restart") + + def shutdown(self): + calls.append("shutdown") + + def should_restart(self): + nonlocal step + step += 1 + if step == 1: + return None + elif step == 2: + return [tmp_path / "foobar.py"] + else: + raise StopIteration() + + config = Config(app="tests.test_config:asgi_app", reload=True) + reloader = CustomReload(config, target=run, sockets=[]) + reloader.run() + + assert calls == ["startup", "restart", "shutdown"] + + +def test_base_reloader_should_exit(tmp_path): + config = Config(app="tests.test_config:asgi_app", reload=True) + reloader = BaseReload(config, target=run, sockets=[]) + assert not reloader.should_exit.is_set() + reloader.pause() + + reloader.signal_handler(signal.SIGINT, None) + + assert reloader.should_exit.is_set() + with pytest.raises(StopIteration): + reloader.pause() diff --git a/uvicorn/main.py b/uvicorn/main.py index bfeeeb584..a8dc27f27 100644 --- a/uvicorn/main.py +++ b/uvicorn/main.py @@ -93,7 +93,7 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No multiple=True, help="Set glob patterns to include while watching for files. Includes '*.py' " "by default; these defaults can be overridden with `--reload-exclude`. " - "This option has no effect unless watchgod is installed.", + "This option has no effect unless watchfiles is installed.", ) @click.option( "--reload-exclude", @@ -101,7 +101,7 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No multiple=True, help="Set glob patterns to exclude while watching for files. Includes " "'.*, .py[cod], .sw.*, ~*' by default; these defaults can be overridden " - "with `--reload-include`. This option has no effect unless watchgod is " + "with `--reload-include`. This option has no effect unless watchfiles is " "installed.", ) @click.option( diff --git a/uvicorn/supervisors/__init__.py b/uvicorn/supervisors/__init__.py index ff7e5f66a..deaf12ede 100644 --- a/uvicorn/supervisors/__init__.py +++ b/uvicorn/supervisors/__init__.py @@ -7,8 +7,15 @@ ChangeReload: Type[BaseReload] else: try: - from uvicorn.supervisors.watchgodreload import WatchGodReload as ChangeReload + from uvicorn.supervisors.watchfilesreload import ( + WatchFilesReload as ChangeReload, + ) except ImportError: # pragma: no cover - from uvicorn.supervisors.statreload import StatReload as ChangeReload + try: + from uvicorn.supervisors.watchgodreload import ( + WatchGodReload as ChangeReload, + ) + except ImportError: + from uvicorn.supervisors.statreload import StatReload as ChangeReload __all__ = ["Multiprocess", "ChangeReload"] diff --git a/uvicorn/supervisors/basereload.py b/uvicorn/supervisors/basereload.py index 667633ee3..b2d80d3ef 100644 --- a/uvicorn/supervisors/basereload.py +++ b/uvicorn/supervisors/basereload.py @@ -2,9 +2,10 @@ import os import signal import threading +from pathlib import Path from socket import socket from types import FrameType -from typing import Callable, List, Optional +from typing import Callable, Iterator, List, Optional import click @@ -41,12 +42,27 @@ def signal_handler(self, sig: int, frame: Optional[FrameType]) -> None: def run(self) -> None: self.startup() - while not self.should_exit.wait(self.config.reload_delay): - if self.should_restart(): + for changes in self: + if changes: + logger.warning( + "%s detected changes in %s. Reloading...", + self.reloader_name, + ", ".join(map(_display_path, changes)), + ) self.restart() self.shutdown() + def pause(self) -> None: + if self.should_exit.wait(self.config.reload_delay): + raise StopIteration() + + def __iter__(self) -> Iterator[Optional[List[Path]]]: + return self + + def __next__(self) -> Optional[List[Path]]: + return self.should_restart() + def startup(self) -> None: message = f"Started reloader process [{self.pid}] using {self.reloader_name}" color_message = "Started reloader process [{}] using {}".format( @@ -86,5 +102,12 @@ def shutdown(self) -> None: ) logger.info(message, extra={"color_message": color_message}) - def should_restart(self) -> bool: + def should_restart(self) -> Optional[List[Path]]: raise NotImplementedError("Reload strategies should override should_restart()") + + +def _display_path(path: Path) -> str: + try: + return f"'{path.relative_to(Path.cwd())}'" + except ValueError: + return f"'{path}'" diff --git a/uvicorn/supervisors/statreload.py b/uvicorn/supervisors/statreload.py index 8392e9f1f..7cbcce310 100644 --- a/uvicorn/supervisors/statreload.py +++ b/uvicorn/supervisors/statreload.py @@ -17,16 +17,18 @@ def __init__( sockets: List[socket], ) -> None: super().__init__(config, target, sockets) - self.reloader_name = "statreload" + self.reloader_name = "StatReload" self.mtimes: Dict[Path, float] = {} if config.reload_excludes or config.reload_includes: logger.warning( - "--reload-include and --reload-exclude have no effect unless watchgod " - "is installed." + "--reload-include and --reload-exclude have no effect unless " + "watchfiles is installed." ) - def should_restart(self) -> bool: + def should_restart(self) -> Optional[List[Path]]: + self.pause() + for file in self.iter_py_files(): try: mtime = file.stat().st_mtime @@ -38,15 +40,8 @@ def should_restart(self) -> bool: self.mtimes[file] = mtime continue elif mtime > old_time: - display_path = str(file) - try: - display_path = str(file.relative_to(Path.cwd())) - except ValueError: - pass - message = "StatReload detected file change in '%s'. Reloading..." - logger.warning(message, display_path) - return True - return False + return [file] + return None def restart(self) -> None: self.mtimes = {} diff --git a/uvicorn/supervisors/watchfilesreload.py b/uvicorn/supervisors/watchfilesreload.py new file mode 100644 index 000000000..9ba10ce9e --- /dev/null +++ b/uvicorn/supervisors/watchfilesreload.py @@ -0,0 +1,89 @@ +from pathlib import Path +from socket import socket +from typing import Callable, List, Optional + +from watchfiles import watch + +from uvicorn.config import Config +from uvicorn.supervisors.basereload import BaseReload + + +class FileFilter: + def __init__(self, config: Config): + default_includes = ["*.py"] + self.includes = [ + default + for default in default_includes + if default not in config.reload_excludes + ] + self.includes.extend(config.reload_includes) + self.includes = list(set(self.includes)) + + default_excludes = [".*", ".py[cod]", ".sw.*", "~*"] + self.excludes = [ + default + for default in default_excludes + if default not in config.reload_includes + ] + self.exclude_dirs = [] + for e in config.reload_excludes: + p = Path(e) + try: + is_dir = p.is_dir() + except OSError: # pragma: no cover + # gets raised on Windows for values like "*.py" + is_dir = False + + if is_dir: + self.exclude_dirs.append(p) + else: + self.excludes.append(e) + self.excludes = list(set(self.excludes)) + + def __call__(self, path: Path) -> bool: + for include_pattern in self.includes: + if path.match(include_pattern): + for exclude_dir in self.exclude_dirs: + if exclude_dir in path.parents: + return False + + for exclude_pattern in self.excludes: + if path.match(exclude_pattern): + return False + + return True + return False + + +class WatchFilesReload(BaseReload): + def __init__( + self, + config: Config, + target: Callable[[Optional[List[socket]]], None], + sockets: List[socket], + ) -> None: + super().__init__(config, target, sockets) + self.reloader_name = "WatchFiles" + self.reload_dirs = [] + for directory in config.reload_dirs: + if Path.cwd() not in directory.parents: + self.reload_dirs.append(directory) + if Path.cwd() not in self.reload_dirs: + self.reload_dirs.append(Path.cwd()) + + self.watch_filter = FileFilter(config) + self.watcher = watch( + *self.reload_dirs, + watch_filter=None, + stop_event=self.should_exit, + # using yield_on_timeout here mostly to make sure tests don't + # hang forever, won't affect the class's behavior + yield_on_timeout=True, + ) + + def should_restart(self) -> Optional[List[Path]]: + changes = next(self.watcher) + if changes: + unique_paths = {Path(c[1]) for c in changes} + return [p for p in unique_paths if self.watch_filter(p)] + return None diff --git a/uvicorn/supervisors/watchgodreload.py b/uvicorn/supervisors/watchgodreload.py index 223799b58..1ec3dc596 100644 --- a/uvicorn/supervisors/watchgodreload.py +++ b/uvicorn/supervisors/watchgodreload.py @@ -1,4 +1,5 @@ import logging +import warnings from pathlib import Path from socket import socket from typing import TYPE_CHECKING, Callable, Dict, List, Optional @@ -8,13 +9,13 @@ from uvicorn.config import Config from uvicorn.supervisors.basereload import BaseReload -logger = logging.getLogger("uvicorn.error") - if TYPE_CHECKING: import os DirEntry = os.DirEntry[str] +logger = logging.getLogger("uvicorn.error") + class CustomWatcher(DefaultWatcher): def __init__(self, root_path: Path, config: Config): @@ -129,8 +130,13 @@ def __init__( target: Callable[[Optional[List[socket]]], None], sockets: List[socket], ) -> None: + warnings.warn( + '"watchgod" is depreciated, you should switch ' + "to watchfiles (`pip install watchfiles`).", + DeprecationWarning, + ) super().__init__(config, target, sockets) - self.reloader_name = "watchgod" + self.reloader_name = "WatchGod" self.watchers = [] reload_dirs = [] for directory in config.reload_dirs: @@ -141,12 +147,12 @@ def __init__( for w in reload_dirs: self.watchers.append(CustomWatcher(w.resolve(), self.config)) - def should_restart(self) -> bool: + def should_restart(self) -> Optional[List[Path]]: + self.pause() + for watcher in self.watchers: change = watcher.check() if change != set(): - message = "WatchGodReload detected file change in '%s'. Reloading..." - logger.warning(message, [c[1] for c in change]) - return True + return list({Path(c[1]) for c in change}) - return False + return None