diff --git a/.gitignore b/.gitignore index c852266ecc..94854b4ad8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ uvicorn.egg-info/ venv/ htmlcov/ site/ +/.idea/ diff --git a/requirements.txt b/requirements.txt index 5527a9149a..3a94919f13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,6 +23,7 @@ coverage==6.3.2 coverage-conditional-plugin==0.5.0 httpx==1.0.0b0 pytest-asyncio==0.15.1 +watchgod==0.8.2 # Documentation diff --git a/tests/supervisors/test_reload.py b/tests/supervisors/test_reload.py index 557f279804..668b841655 100644 --- a/tests/supervisors/test_reload.py +++ b/tests/supervisors/test_reload.py @@ -9,6 +9,7 @@ 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): @@ -36,16 +37,16 @@ def _reload_tester(self, touch_soon, reloader: BaseReload, *files: Path) -> bool reloader.restart() reloader.restart() - if isinstance(reloader, StatReload): + if isinstance(reloader, WatchFilesReload): + touch_soon(*files) + else: assert not next(reloader) sleep(0.1) for file in files: file.touch() - else: - touch_soon(*files) return next(reloader) - @pytest.mark.parametrize("reloader_class", [StatReload, WatchFilesReload]) + @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload, WatchFilesReload]) def test_reloader_should_initialize(self) -> None: """ A basic sanity check. @@ -58,7 +59,7 @@ def test_reloader_should_initialize(self) -> None: reloader = self._setup_reloader(config) reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchFilesReload]) + @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" @@ -70,7 +71,7 @@ def test_reload_when_python_file_is_changed(self, touch_soon) -> None: reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchFilesReload]) + @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload, WatchFilesReload]) def test_should_reload_when_python_file_in_subdir_is_changed( self, touch_soon ) -> None: @@ -84,7 +85,7 @@ def test_should_reload_when_python_file_in_subdir_is_changed( reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [WatchFilesReload]) + @pytest.mark.parametrize("reloader_class", [WatchFilesReload, WatchGodReload]) def test_should_not_reload_when_python_file_in_excluded_subdir_is_changed( self, touch_soon ) -> None: @@ -121,7 +122,7 @@ def test_reload_when_pattern_matched_file_is_changed( reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [WatchFilesReload]) + @pytest.mark.parametrize("reloader_class", [WatchFilesReload, WatchGodReload]) def test_should_not_reload_when_exclude_pattern_match_file_is_changed( self, touch_soon ) -> None: @@ -144,7 +145,7 @@ def test_should_not_reload_when_exclude_pattern_match_file_is_changed( reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchFilesReload]) + @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" @@ -156,7 +157,7 @@ def test_should_not_reload_when_dot_file_is_changed(self, touch_soon) -> None: reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchFilesReload]) + @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" @@ -176,7 +177,7 @@ def test_should_reload_when_directories_have_same_prefix(self, touch_soon) -> No reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchFilesReload]) + @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload, WatchFilesReload]) def test_should_not_reload_when_only_subdirectory_is_watched( self, touch_soon ) -> None: @@ -198,7 +199,7 @@ def test_should_not_reload_when_only_subdirectory_is_watched( reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [WatchFilesReload]) + @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" diff --git a/uvicorn/supervisors/__init__.py b/uvicorn/supervisors/__init__.py index 0ddc01b999..44093b0249 100644 --- a/uvicorn/supervisors/__init__.py +++ b/uvicorn/supervisors/__init__.py @@ -11,6 +11,11 @@ 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/watchfilesreload.py b/uvicorn/supervisors/watchfilesreload.py index 4ca1db254d..e069758595 100644 --- a/uvicorn/supervisors/watchfilesreload.py +++ b/uvicorn/supervisors/watchfilesreload.py @@ -66,7 +66,7 @@ def __init__( sockets: List[socket], ) -> None: super().__init__(config, target, sockets) - self.reloader_name = "WatchFilesReload" + self.reloader_name = "WatchFiles" self.reload_dirs = [] for directory in config.reload_dirs: if Path.cwd() not in directory.parents: diff --git a/uvicorn/supervisors/watchgodreload.py b/uvicorn/supervisors/watchgodreload.py new file mode 100644 index 0000000000..540d8a4214 --- /dev/null +++ b/uvicorn/supervisors/watchgodreload.py @@ -0,0 +1,150 @@ +import logging +from pathlib import Path +from socket import socket +from typing import TYPE_CHECKING, Callable, Dict, List, Optional + +from watchgod import DefaultWatcher + +from uvicorn.config import Config +from uvicorn.supervisors.basereload import BaseReload + +logger = logging.getLogger("uvicorn.error") + +if TYPE_CHECKING: # pragma: no cover + import os + + DirEntry = os.DirEntry[str] + + +class CustomWatcher(DefaultWatcher): + def __init__(self, root_path: Path, 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.excludes.extend(config.reload_excludes) + self.excludes = list(set(self.excludes)) + + self.watched_dirs: Dict[str, bool] = {} + self.watched_files: Dict[str, bool] = {} + self.dirs_includes = set(config.reload_dirs) + self.dirs_excludes = set(config.reload_dirs_excludes) + self.resolved_root = root_path + super().__init__(str(root_path)) + + def should_watch_file(self, entry: "DirEntry") -> bool: + cached_result = self.watched_files.get(entry.path) + if cached_result is not None: + return cached_result + + entry_path = Path(entry) + + # cwd is not verified through should_watch_dir, so we need to verify here + if entry_path.parent == Path.cwd() and not Path.cwd() in self.dirs_includes: + self.watched_files[entry.path] = False + return False + for include_pattern in self.includes: + if entry_path.match(include_pattern): + for exclude_pattern in self.excludes: + if entry_path.match(exclude_pattern): + self.watched_files[entry.path] = False + return False + self.watched_files[entry.path] = True + return True + self.watched_files[entry.path] = False + return False + + def should_watch_dir(self, entry: "DirEntry") -> bool: + cached_result = self.watched_dirs.get(entry.path) + if cached_result is not None: + return cached_result + + entry_path = Path(entry) + + if entry_path in self.dirs_excludes: + self.watched_dirs[entry.path] = False + return False + + for exclude_pattern in self.excludes: + if entry_path.match(exclude_pattern): + is_watched = False + if entry_path in self.dirs_includes: + is_watched = True + + for directory in self.dirs_includes: + if directory in entry_path.parents: + is_watched = True + + if is_watched: + logger.debug( + "WatchGodReload detected a new excluded dir '%s' in '%s'; " + "Adding to exclude list.", + entry_path.relative_to(self.resolved_root), + str(self.resolved_root), + ) + self.watched_dirs[entry.path] = False + self.dirs_excludes.add(entry_path) + return False + + if entry_path in self.dirs_includes: + self.watched_dirs[entry.path] = True + return True + + for directory in self.dirs_includes: + if directory in entry_path.parents: + self.watched_dirs[entry.path] = True + return True + + for include_pattern in self.includes: + if entry_path.match(include_pattern): + logger.info( + "WatchGodReload detected a new reload dir '%s' in '%s'; " + "Adding to watch list.", + str(entry_path.relative_to(self.resolved_root)), + str(self.resolved_root), + ) + self.dirs_includes.add(entry_path) + self.watched_dirs[entry.path] = True + return True + + self.watched_dirs[entry.path] = False + return False + + +class WatchGodReload(BaseReload): + def __init__( + self, + config: Config, + target: Callable[[Optional[List[socket]]], None], + sockets: List[socket], + ) -> None: + super().__init__(config, target, sockets) + self.reloader_name = "WatchGod" + self.watchers = [] + reload_dirs = [] + for directory in config.reload_dirs: + if Path.cwd() not in directory.parents: + reload_dirs.append(directory) + if Path.cwd() not in reload_dirs: + reload_dirs.append(Path.cwd()) + for w in reload_dirs: + self.watchers.append(CustomWatcher(w.resolve(), self.config)) + + def should_restart(self) -> Optional[List[Path]]: + for watcher in self.watchers: + change = watcher.check() + if change != set(): + return list({Path(c[1]) for c in change}) + + return None