diff --git a/pyproject.toml b/pyproject.toml index 6dd4916db..6f809030e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ license = "BSD-3-Clause" requires-python = ">=3.8" authors = [ { name = "Tom Christie", email = "tom@tomchristie.com" }, - { name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" } + { name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" }, ] classifiers = [ "Development Status :: 4 - Beta", @@ -60,11 +60,7 @@ Source = "https://github.com/encode/uvicorn" path = "uvicorn/__init__.py" [tool.hatch.build.targets.sdist] -include = [ - "/uvicorn", - "/tests", - "/requirements.txt", -] +include = ["/uvicorn", "/tests", "/requirements.txt"] [tool.ruff] line-length = 120 @@ -94,10 +90,9 @@ addopts = "-rxXs --strict-config --strict-markers" xfail_strict = true filterwarnings = [ "error", - 'ignore: \"watchgod\" is deprecated\, you should switch to watchfiles \(`pip install watchfiles`\)\.:DeprecationWarning', "ignore:Uvicorn's native WSGI implementation is deprecated.*:DeprecationWarning", "ignore: 'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning", - "ignore: remove second argument of ws_handler:DeprecationWarning:websockets" + "ignore: remove second argument of ws_handler:DeprecationWarning:websockets", ] [tool.coverage.run] diff --git a/requirements.txt b/requirements.txt index bf8b09827..12c93899a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,6 @@ coverage==7.6.1; python_version < '3.9' coverage==7.6.4; python_version >= '3.9' coverage-conditional-plugin==0.9.0 httpx==0.27.2 -watchgod==0.8.2 # Documentation mkdocs==1.6.1 diff --git a/tests/conftest.py b/tests/conftest.py index b1214061a..1b0c0e84e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,8 +9,6 @@ from hashlib import md5 from pathlib import Path from tempfile import TemporaryDirectory -from threading import Thread -from time import sleep from typing import Any from uuid import uuid4 @@ -214,27 +212,6 @@ def make_tmp_dir(base_dir): 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() - - def _unused_port(socket_type: int) -> int: """Find an unused localhost port from 1024-65535 and return it.""" with contextlib.closing(socket.socket(type=socket_type)) as sock: diff --git a/tests/supervisors/test_reload.py b/tests/supervisors/test_reload.py index 30eea2321..c4ad76acb 100644 --- a/tests/supervisors/test_reload.py +++ b/tests/supervisors/test_reload.py @@ -1,14 +1,16 @@ from __future__ import annotations -import logging import platform import signal import socket import sys from pathlib import Path +from threading import Thread from time import sleep +from typing import Callable, Generator import pytest +from pytest_mock import MockerFixture from tests.utils import as_cwd from uvicorn.config import Config @@ -20,11 +22,6 @@ except ImportError: # pragma: no cover WatchFilesReload = None # type: ignore[misc,assignment] -try: - from uvicorn.supervisors.watchgodreload import WatchGodReload -except ImportError: # pragma: no cover - WatchGodReload = None # type: ignore[misc,assignment] - # TODO: Investigate why this is flaky on MacOS M1. skip_if_m1 = pytest.mark.skipif( @@ -33,17 +30,34 @@ ) -def run(sockets): +def run(sockets: list[socket.socket] | None) -> None: pass # pragma: no cover +def sleep_touch(*paths: Path): + sleep(0.1) + for p in paths: + p.touch() + + +@pytest.fixture +def touch_soon() -> Generator[Callable[[Path], None]]: + threads: list[Thread] = [] + + def start(*paths: Path) -> None: + thread = Thread(target=sleep_touch, args=paths) + thread.start() + threads.append(thread) + + yield start + + for t in threads: + t.join() + + class TestBaseReload: @pytest.fixture(autouse=True) - def setup( - self, - reload_directory_structure: Path, - reloader_class: type[BaseReload] | None, - ): + def setup(self, reload_directory_structure: Path, reloader_class: type[BaseReload] | None): if reloader_class is None: # pragma: no cover pytest.skip("Needed dependency not installed") self.reload_path = reload_directory_structure @@ -52,17 +66,15 @@ def setup( def _setup_reloader(self, config: Config) -> BaseReload: 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=[]) + reloader = self.reloader_class(config, target=run, sockets=[]) assert config.should_reload reloader.startup() return reloader - def _reload_tester(self, touch_soon, reloader: BaseReload, *files: Path) -> list[Path] | None: + def _reload_tester( + self, touch_soon: Callable[[Path], None], reloader: BaseReload, *files: Path + ) -> list[Path] | None: reloader.restart() if WatchFilesReload is not None and isinstance(reloader, WatchFilesReload): touch_soon(*files) @@ -73,7 +85,7 @@ def _reload_tester(self, touch_soon, reloader: BaseReload, *files: Path) -> list file.touch() return next(reloader) - @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload, WatchFilesReload]) + @pytest.mark.parametrize("reloader_class", [StatReload, WatchFilesReload]) def test_reloader_should_initialize(self) -> None: """ A basic sanity check. @@ -86,8 +98,8 @@ def test_reloader_should_initialize(self) -> None: reloader = self._setup_reloader(config) reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload, WatchFilesReload]) - def test_reload_when_python_file_is_changed(self, touch_soon) -> None: + @pytest.mark.parametrize("reloader_class", [StatReload, WatchFilesReload]) + def test_reload_when_python_file_is_changed(self, touch_soon: Callable[[Path], None]): file = self.reload_path / "main.py" with as_cwd(self.reload_path): @@ -99,8 +111,8 @@ def test_reload_when_python_file_is_changed(self, touch_soon) -> None: reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload, WatchFilesReload]) - def test_should_reload_when_python_file_in_subdir_is_changed(self, touch_soon) -> None: + @pytest.mark.parametrize("reloader_class", [StatReload, WatchFilesReload]) + def test_should_reload_when_python_file_in_subdir_is_changed(self, touch_soon: Callable[[Path], None]): file = self.reload_path / "app" / "sub" / "sub.py" with as_cwd(self.reload_path): @@ -111,8 +123,8 @@ def test_should_reload_when_python_file_in_subdir_is_changed(self, touch_soon) - reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [WatchFilesReload, WatchGodReload]) - def test_should_not_reload_when_python_file_in_excluded_subdir_is_changed(self, touch_soon) -> None: + @pytest.mark.parametrize("reloader_class", [WatchFilesReload]) + def test_should_not_reload_when_python_file_in_excluded_subdir_is_changed(self, touch_soon: Callable[[Path], None]): sub_dir = self.reload_path / "app" / "sub" sub_file = sub_dir / "sub.py" @@ -129,7 +141,7 @@ def test_should_not_reload_when_python_file_in_excluded_subdir_is_changed(self, reloader.shutdown() @pytest.mark.parametrize("reloader_class, result", [(StatReload, False), (WatchFilesReload, True)]) - def test_reload_when_pattern_matched_file_is_changed(self, result: bool, touch_soon) -> None: + def test_reload_when_pattern_matched_file_is_changed(self, result: bool, touch_soon: Callable[[Path], None]): file = self.reload_path / "app" / "js" / "main.js" with as_cwd(self.reload_path): @@ -140,14 +152,10 @@ def test_reload_when_pattern_matched_file_is_changed(self, result: bool, touch_s reloader.shutdown() - @pytest.mark.parametrize( - "reloader_class", - [ - pytest.param(WatchFilesReload, marks=skip_if_m1), - WatchGodReload, - ], - ) - def test_should_not_reload_when_exclude_pattern_match_file_is_changed(self, touch_soon) -> None: + @pytest.mark.parametrize("reloader_class", [pytest.param(WatchFilesReload, marks=skip_if_m1)]) + def test_should_not_reload_when_exclude_pattern_match_file_is_changed( + self, touch_soon: Callable[[Path], None] + ): # pragma: py-darwin 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" @@ -167,8 +175,8 @@ def test_should_not_reload_when_exclude_pattern_match_file_is_changed(self, touc reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload, WatchFilesReload]) - def test_should_not_reload_when_dot_file_is_changed(self, touch_soon) -> None: + @pytest.mark.parametrize("reloader_class", [StatReload, WatchFilesReload]) + def test_should_not_reload_when_dot_file_is_changed(self, touch_soon: Callable[[Path], None]): file = self.reload_path / ".dotted" with as_cwd(self.reload_path): @@ -179,8 +187,8 @@ def test_should_not_reload_when_dot_file_is_changed(self, touch_soon) -> None: reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchGodReload, WatchFilesReload]) - def test_should_reload_when_directories_have_same_prefix(self, touch_soon) -> None: + @pytest.mark.parametrize("reloader_class", [StatReload, WatchFilesReload]) + def test_should_reload_when_directories_have_same_prefix(self, touch_soon: Callable[[Path], None]): app_dir = self.reload_path / "app" app_file = app_dir / "src" / "main.py" app_first_dir = self.reload_path / "app_first" @@ -201,13 +209,9 @@ def test_should_reload_when_directories_have_same_prefix(self, touch_soon) -> No @pytest.mark.parametrize( "reloader_class", - [ - StatReload, - WatchGodReload, - pytest.param(WatchFilesReload, marks=skip_if_m1), - ], + [StatReload, pytest.param(WatchFilesReload, marks=skip_if_m1)], ) - def test_should_not_reload_when_only_subdirectory_is_watched(self, touch_soon) -> None: + def test_should_not_reload_when_only_subdirectory_is_watched(self, touch_soon: Callable[[Path], None]): app_dir = self.reload_path / "app" app_dir_file = self.reload_path / "app" / "src" / "main.py" root_file = self.reload_path / "main.py" @@ -224,14 +228,8 @@ def test_should_not_reload_when_only_subdirectory_is_watched(self, touch_soon) - reloader.shutdown() - @pytest.mark.parametrize( - "reloader_class", - [ - pytest.param(WatchFilesReload, marks=skip_if_m1), - WatchGodReload, - ], - ) - def test_override_defaults(self, touch_soon) -> None: + @pytest.mark.parametrize("reloader_class", [pytest.param(WatchFilesReload, marks=skip_if_m1)]) + def test_override_defaults(self, touch_soon: Callable[[Path], None]) -> None: # pragma: py-darwin dotted_file = self.reload_path / ".dotted" dotted_dir_file = self.reload_path / ".dotted_dir" / "file.txt" python_file = self.reload_path / "main.py" @@ -252,14 +250,8 @@ def test_override_defaults(self, touch_soon) -> None: reloader.shutdown() - @pytest.mark.parametrize( - "reloader_class", - [ - pytest.param(WatchFilesReload, marks=skip_if_m1), - WatchGodReload, - ], - ) - def test_explicit_paths(self, touch_soon) -> None: + @pytest.mark.parametrize("reloader_class", [pytest.param(WatchFilesReload, marks=skip_if_m1)]) + def test_explicit_paths(self, touch_soon: Callable[[Path], None]) -> None: # pragma: py-darwin dotted_file = self.reload_path / ".dotted" non_dotted_file = self.reload_path / "ext" / "ext.jpg" python_file = self.reload_path / "main.py" @@ -307,33 +299,9 @@ def test_watchfiles_no_changes(self) -> None: reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [WatchGodReload]) - def test_should_detect_new_reload_dirs(self, touch_soon, 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_includes=["app*"]) - reloader = self._setup_reloader(config) - assert self._reload_tester(touch_soon, reloader, app_file) - - app_first_dir.mkdir() - assert self._reload_tester(touch_soon, reloader, app_first_file) - assert caplog.records[-2].levelno == logging.INFO - assert ( - 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.skipif(WatchFilesReload is None, reason="watchfiles not available") -def test_should_watch_one_dir_cwd(mocker, reload_directory_structure): +def test_should_watch_one_dir_cwd(mocker: MockerFixture, reload_directory_structure: Path): mock_watch = mocker.patch("uvicorn.supervisors.watchfilesreload.watch") app_dir = reload_directory_structure / "app" app_first_dir = reload_directory_structure / "app_first" @@ -350,7 +318,7 @@ def test_should_watch_one_dir_cwd(mocker, reload_directory_structure): @pytest.mark.skipif(WatchFilesReload is None, reason="watchfiles not available") -def test_should_watch_separate_dirs_outside_cwd(mocker, reload_directory_structure): +def test_should_watch_separate_dirs_outside_cwd(mocker: MockerFixture, reload_directory_structure: Path): mock_watch = mocker.patch("uvicorn.supervisors.watchfilesreload.watch") app_dir = reload_directory_structure / "app" app_first_dir = reload_directory_structure / "app_first" @@ -368,7 +336,7 @@ def test_should_watch_separate_dirs_outside_cwd(mocker, reload_directory_structu } -def test_display_path_relative(tmp_path): +def test_display_path_relative(tmp_path: Path): with as_cwd(tmp_path): p = tmp_path / "app" / "foobar.py" # accept windows paths as wells as posix @@ -380,8 +348,8 @@ def test_display_path_non_relative(): assert _display_path(p) in ("'/foo/bar.py'", "'\\foo\\bar.py'") -def test_base_reloader_run(tmp_path): - calls = [] +def test_base_reloader_run(tmp_path: Path): + calls: list[str] = [] step = 0 class CustomReload(BaseReload): @@ -411,7 +379,7 @@ def should_restart(self): assert calls == ["startup", "restart", "shutdown"] -def test_base_reloader_should_exit(tmp_path): +def test_base_reloader_should_exit(tmp_path: Path): config = Config(app="tests.test_config:asgi_app", reload=True) reloader = BaseReload(config, target=run, sockets=[]) assert not reloader.should_exit.is_set() diff --git a/uvicorn/config.py b/uvicorn/config.py index 65dfe651e..b08a8426b 100644 --- a/uvicorn/config.py +++ b/uvicorn/config.py @@ -137,7 +137,7 @@ def resolve_reload_patterns(patterns_list: list[str], directories_list: list[str # Special case for the .* pattern, otherwise this would only match # hidden directories which is probably undesired if pattern == ".*": - continue + continue # pragma: py-darwin patterns.append(pattern) if is_dir(Path(pattern)): directories.append(Path(pattern)) diff --git a/uvicorn/supervisors/__init__.py b/uvicorn/supervisors/__init__.py index c90f24e4a..cfceb6b94 100644 --- a/uvicorn/supervisors/__init__.py +++ b/uvicorn/supervisors/__init__.py @@ -9,15 +9,8 @@ ChangeReload: type[BaseReload] else: try: - from uvicorn.supervisors.watchfilesreload import ( - WatchFilesReload as ChangeReload, - ) + from uvicorn.supervisors.watchfilesreload import WatchFilesReload as ChangeReload except ImportError: # pragma: no cover - try: - from uvicorn.supervisors.watchgodreload import ( - WatchGodReload as ChangeReload, - ) - except ImportError: - from uvicorn.supervisors.statreload import StatReload as ChangeReload + from uvicorn.supervisors.statreload import StatReload as ChangeReload __all__ = ["Multiprocess", "ChangeReload"] diff --git a/uvicorn/supervisors/watchgodreload.py b/uvicorn/supervisors/watchgodreload.py deleted file mode 100644 index 6f248faa7..000000000 --- a/uvicorn/supervisors/watchgodreload.py +++ /dev/null @@ -1,152 +0,0 @@ -from __future__ import annotations - -import logging -import warnings -from pathlib import Path -from socket import socket -from typing import TYPE_CHECKING, Callable - -from watchgod import DefaultWatcher - -from uvicorn.config import Config -from uvicorn.supervisors.basereload import BaseReload - -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): - 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 Path.cwd() not in self.dirs_includes: - self.watched_files[entry.path] = False - return False - for include_pattern in self.includes: - if str(entry_path).endswith(include_pattern): - self.watched_files[entry.path] = True - return True - 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[[list[socket] | None], None], - sockets: list[socket], - ) -> None: - warnings.warn( - '"watchgod" is deprecated, you should switch ' "to watchfiles (`pip install watchfiles`).", - DeprecationWarning, - ) - 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) -> list[Path] | None: - self.pause() - - for watcher in self.watchers: - change = watcher.check() - if change != set(): - return list({Path(c[1]) for c in change}) - - return None