From bfa754e21e2cc1d5b0d7cabf24933a6c3afc315e Mon Sep 17 00:00:00 2001 From: Till Backhaus Date: Wed, 4 Dec 2024 14:33:45 +0100 Subject: [PATCH 01/10] Fixes import of uvicorn server. (encode/uvicorn#2527) (#2528) --- uvicorn/workers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uvicorn/workers.py b/uvicorn/workers.py index 061805b6c..06fdf295e 100644 --- a/uvicorn/workers.py +++ b/uvicorn/workers.py @@ -11,7 +11,7 @@ from gunicorn.workers.base import Worker from uvicorn.config import Config -from uvicorn.main import Server +from uvicorn.server import Server warnings.warn( "The `uvicorn.workers` module is deprecated. Please use `uvicorn-worker` package instead.\n" From 6725ebb1ee7d454f4c7259374b48941faa57b7be Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 14 Dec 2024 10:40:57 +0100 Subject: [PATCH 02/10] docs: add more mkdocs-material features (#2534) --- mkdocs.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index 2869183d9..dbc8e40ea 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,6 @@ site_name: Uvicorn site_description: The lightning-fast ASGI server. +site_url: https://www.uvicorn.org theme: name: material @@ -19,11 +20,14 @@ theme: icon: "material/lightbulb-outline" name: "Switch to light mode" features: - - content.code.copy + - content.code.copy # https://squidfunk.github.io/mkdocs-material/upgrade/?h=content+copy#contentcodecopy + - navigation.top # https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation/#back-to-top-button + - navigation.footer # https://squidfunk.github.io/mkdocs-material/upgrade/?h=content+copy#navigationfooter + - toc.follow # https://squidfunk.github.io/mkdocs-material/setup/setting-up-navigation/#anchor-following repo_name: encode/uvicorn repo_url: https://github.com/encode/uvicorn -edit_uri: "" +edit_uri: edit/master/docs/ nav: - Introduction: index.md From a3cc36016e34ed29345d3afda441adab58cbf2d5 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 14 Dec 2024 10:58:09 +0100 Subject: [PATCH 03/10] docs: add note about server behavior on exceptions (#2535) --- docs/server-behavior.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/server-behavior.md b/docs/server-behavior.md index 15fdd8607..1bb447fc4 100644 --- a/docs/server-behavior.md +++ b/docs/server-behavior.md @@ -83,6 +83,9 @@ Server errors will be logged at the `error` log level. All logging defaults to b If an exception is raised by an ASGI application, and a response has not yet been sent on the connection, then a `500 Server Error` HTTP response will be sent. +Uvicorn sends the headers and the status code as soon as it receives from the ASGI application. This means that if the application sends a [Response Start](https://asgi.readthedocs.io/en/latest/specs/www.html#response-start-send-event) +message with a status code of `200 OK`, and then an exception is raised, the response will still be sent with a status code of `200 OK`. + ### Invalid responses Uvicorn will ensure that ASGI applications send the correct sequence of messages, and will raise errors otherwise. This includes checking for no response sent, partial response sent, or invalid message sequences being sent. From 3aa1d010d68afcc2a1311669f6b05cede1aa7cfc Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 14 Dec 2024 11:40:17 +0100 Subject: [PATCH 04/10] Remove WatchGod (#2536) * Remove WatchGod * Readd missing pragma * Add py-darwin coverage --- pyproject.toml | 11 +- requirements.txt | 1 - tests/conftest.py | 23 ---- tests/supervisors/test_reload.py | 148 ++++++++++--------------- uvicorn/config.py | 2 +- uvicorn/supervisors/__init__.py | 11 +- uvicorn/supervisors/watchgodreload.py | 152 -------------------------- 7 files changed, 64 insertions(+), 284 deletions(-) delete mode 100644 uvicorn/supervisors/watchgodreload.py 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 From 038f8ef3fe8f174ef7b38379165536d2c84b7eb9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Dec 2024 12:08:17 +0100 Subject: [PATCH 05/10] Bump the python-packages group across 1 directory with 9 updates (#2538) * Bump the python-packages group across 1 directory with 9 updates Bumps the python-packages group with 9 updates in the / directory: | Package | From | To | | --- | --- | --- | | [websockets](https://github.com/python-websockets/websockets) | `13.1` | `14.1` | | [twine](https://github.com/pypa/twine) | `5.1.1` | `6.0.1` | | [ruff](https://github.com/astral-sh/ruff) | `0.7.1` | `0.8.3` | | [pytest](https://github.com/pytest-dev/pytest) | `8.3.3` | `8.3.4` | | [trustme](https://github.com/python-trio/trustme) | `1.1.0` | `1.2.0` | | [cryptography](https://github.com/pyca/cryptography) | `43.0.3` | `44.0.0` | | [coverage](https://github.com/nedbat/coveragepy) | `7.6.4` | `7.6.9` | | [httpx](https://github.com/encode/httpx) | `0.27.2` | `0.28.1` | | [mkdocs-material](https://github.com/squidfunk/mkdocs-material) | `9.5.43` | `9.5.48` | Updates `websockets` from 13.1 to 14.1 - [Release notes](https://github.com/python-websockets/websockets/releases) - [Commits](https://github.com/python-websockets/websockets/compare/13.1...14.1) Updates `twine` from 5.1.1 to 6.0.1 - [Release notes](https://github.com/pypa/twine/releases) - [Changelog](https://github.com/pypa/twine/blob/main/docs/changelog.rst) - [Commits](https://github.com/pypa/twine/compare/v5.1.1...6.0.1) Updates `ruff` from 0.7.1 to 0.8.3 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.7.1...0.8.3) Updates `pytest` from 8.3.3 to 8.3.4 - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.3.3...8.3.4) Updates `trustme` from 1.1.0 to 1.2.0 - [Release notes](https://github.com/python-trio/trustme/releases) - [Commits](https://github.com/python-trio/trustme/compare/v1.1.0...v1.2.0) Updates `cryptography` from 43.0.3 to 44.0.0 - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/43.0.3...44.0.0) Updates `coverage` from 7.6.4 to 7.6.9 - [Release notes](https://github.com/nedbat/coveragepy/releases) - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst) - [Commits](https://github.com/nedbat/coveragepy/compare/7.6.4...7.6.9) Updates `httpx` from 0.27.2 to 0.28.1 - [Release notes](https://github.com/encode/httpx/releases) - [Changelog](https://github.com/encode/httpx/blob/master/CHANGELOG.md) - [Commits](https://github.com/encode/httpx/compare/0.27.2...0.28.1) Updates `mkdocs-material` from 9.5.43 to 9.5.48 - [Release notes](https://github.com/squidfunk/mkdocs-material/releases) - [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG) - [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.43...9.5.48) --- updated-dependencies: - dependency-name: websockets dependency-type: direct:production update-type: version-update:semver-major dependency-group: python-packages - dependency-name: twine dependency-type: direct:production update-type: version-update:semver-major dependency-group: python-packages - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-packages - dependency-name: pytest dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-packages - dependency-name: trustme dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-packages - dependency-name: cryptography dependency-type: direct:production update-type: version-update:semver-major dependency-group: python-packages - dependency-name: coverage dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-packages - dependency-name: httpx dependency-type: direct:production update-type: version-update:semver-minor dependency-group: python-packages - dependency-name: mkdocs-material dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-packages ... Signed-off-by: dependabot[bot] * Add websockets old version to the requiremnts * trustme * fix issue with httpx * continue with websockets old version --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Marcelo Trylesinski --- requirements.txt | 17 +++++++++-------- tests/middleware/test_wsgi.py | 2 +- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/requirements.txt b/requirements.txt index 12c93899a..b3a464c0b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,22 +11,23 @@ websockets==13.1 # Packaging build==1.2.2.post1 -twine==5.1.1 +twine==6.0.1 # Testing -ruff==0.7.1 -pytest==8.3.3 +ruff==0.8.3 +pytest==8.3.4 pytest-mock==3.14.0 mypy==1.13.0 types-click==7.1.8 types-pyyaml==6.0.12.20240917 -trustme==1.1.0 -cryptography==43.0.3 +trustme==1.1.0; python_version < '3.9' +trustme==1.2.0; python_version >= '3.9' +cryptography==44.0.0 coverage==7.6.1; python_version < '3.9' -coverage==7.6.4; python_version >= '3.9' +coverage==7.6.9; python_version >= '3.9' coverage-conditional-plugin==0.9.0 -httpx==0.27.2 +httpx==0.28.1 # Documentation mkdocs==1.6.1 -mkdocs-material==9.5.43 +mkdocs-material==9.5.48 diff --git a/tests/middleware/test_wsgi.py b/tests/middleware/test_wsgi.py index a79a21342..69a40db8c 100644 --- a/tests/middleware/test_wsgi.py +++ b/tests/middleware/test_wsgi.py @@ -72,7 +72,7 @@ async def test_wsgi_post(wsgi_middleware: Callable) -> None: async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: response = await client.post("/", json={"example": 123}) assert response.status_code == 200 - assert response.text == '{"example": 123}' + assert response.text == '{"example":123}' @pytest.mark.anyio From a50051308509600388ed170fdedacfb673757de4 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 14 Dec 2024 12:12:52 +0100 Subject: [PATCH 06/10] Version 0.33.0 (#2539) --- CHANGELOG.md | 6 ++++++ uvicorn/__init__.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffa11d53f..b6a62dc5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## 0.33.0 (2024-12-14) + +### Removed + +* Remove `WatchGod` support for `--reload` [#2536](https://github.com/encode/uvicorn/pull/2536) + ## 0.32.1 (2024-11-20) ### Fixed diff --git a/uvicorn/__init__.py b/uvicorn/__init__.py index 869de7984..0a8f431f4 100644 --- a/uvicorn/__init__.py +++ b/uvicorn/__init__.py @@ -1,5 +1,5 @@ from uvicorn.config import Config from uvicorn.main import Server, main, run -__version__ = "0.32.1" +__version__ = "0.33.0" __all__ = ["main", "run", "Config", "Server"] From 3575cbaa4ef3303d12e1b00e570bb044cec10d80 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 14 Dec 2024 23:32:08 +0100 Subject: [PATCH 07/10] Add `content-length` to 500 response in wsproto (#2542) --- uvicorn/protocols/websockets/wsproto_impl.py | 1 + 1 file changed, 1 insertion(+) diff --git a/uvicorn/protocols/websockets/wsproto_impl.py b/uvicorn/protocols/websockets/wsproto_impl.py index 072dec942..828afe512 100644 --- a/uvicorn/protocols/websockets/wsproto_impl.py +++ b/uvicorn/protocols/websockets/wsproto_impl.py @@ -224,6 +224,7 @@ def send_500_response(self) -> None: headers: list[tuple[bytes, bytes]] = [ (b"content-type", b"text/plain; charset=utf-8"), (b"connection", b"close"), + (b"content-length", b"21"), ] output = self.conn.send(wsproto.events.RejectConnection(status_code=500, headers=headers, has_body=True)) output += self.conn.send(wsproto.events.RejectData(data=b"Internal Server Error")) From 4156ccb4c9779786630cd7c52f9919c3647ac036 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 15 Dec 2024 12:40:42 +0100 Subject: [PATCH 08/10] Drop Python 3.8 (#2543) * Drop Python 3.8 * Run lint --- .github/workflows/test-suite.yml | 2 +- pyproject.toml | 7 ++---- requirements.txt | 6 ++--- tests/middleware/test_wsgi.py | 3 ++- tests/supervisors/test_reload.py | 3 ++- tests/test_cli.py | 2 +- tests/test_server.py | 6 +++-- uvicorn/_types.py | 22 +++++-------------- uvicorn/config.py | 3 ++- uvicorn/middleware/wsgi.py | 2 +- .../protocols/websockets/websockets_impl.py | 3 ++- uvicorn/server.py | 8 +++---- uvicorn/supervisors/basereload.py | 3 ++- uvicorn/supervisors/statreload.py | 3 ++- 14 files changed, 31 insertions(+), 42 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 6f7e2db8f..7646f10c9 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] os: [windows-latest, ubuntu-latest, macos-latest] steps: - uses: "actions/checkout@v4" diff --git a/pyproject.toml b/pyproject.toml index 6f809030e..0a8996666 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "The lightning-fast ASGI server." readme = "README.md" license = "BSD-3-Clause" -requires-python = ">=3.8" +requires-python = ">=3.9" authors = [ { name = "Tom Christie", email = "tom@tomchristie.com" }, { name = "Marcelo Trylesinski", email = "marcelotryle@gmail.com" }, @@ -20,7 +20,6 @@ classifiers = [ "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -38,7 +37,7 @@ dependencies = [ [project.optional-dependencies] standard = [ - "colorama>=0.4;sys_platform == 'win32'", + "colorama>=0.4; sys_platform == 'win32'", "httptools>=0.6.3", "python-dotenv>=0.13", "PyYAML>=5.1", @@ -127,8 +126,6 @@ py-win32 = "sys_platform == 'win32'" py-not-win32 = "sys_platform != 'win32'" py-linux = "sys_platform == 'linux'" py-darwin = "sys_platform == 'darwin'" -py-gte-38 = "sys_version_info >= (3, 8)" -py-lt-38 = "sys_version_info < (3, 8)" py-gte-39 = "sys_version_info >= (3, 9)" py-lt-39 = "sys_version_info < (3, 9)" py-gte-310 = "sys_version_info >= (3, 10)" diff --git a/requirements.txt b/requirements.txt index b3a464c0b..e26e6b3c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,11 +20,9 @@ pytest-mock==3.14.0 mypy==1.13.0 types-click==7.1.8 types-pyyaml==6.0.12.20240917 -trustme==1.1.0; python_version < '3.9' -trustme==1.2.0; python_version >= '3.9' +trustme==1.2.0 cryptography==44.0.0 -coverage==7.6.1; python_version < '3.9' -coverage==7.6.9; python_version >= '3.9' +coverage==7.6.9 coverage-conditional-plugin==0.9.0 httpx==0.28.1 diff --git a/tests/middleware/test_wsgi.py b/tests/middleware/test_wsgi.py index 69a40db8c..6003f27f9 100644 --- a/tests/middleware/test_wsgi.py +++ b/tests/middleware/test_wsgi.py @@ -2,7 +2,8 @@ import io import sys -from typing import AsyncGenerator, Callable +from collections.abc import AsyncGenerator +from typing import Callable import a2wsgi import httpx diff --git a/tests/supervisors/test_reload.py b/tests/supervisors/test_reload.py index c4ad76acb..44eb9970b 100644 --- a/tests/supervisors/test_reload.py +++ b/tests/supervisors/test_reload.py @@ -4,10 +4,11 @@ import signal import socket import sys +from collections.abc import Generator from pathlib import Path from threading import Thread from time import sleep -from typing import Callable, Generator +from typing import Callable import pytest from pytest_mock import MockerFixture diff --git a/tests/test_cli.py b/tests/test_cli.py index 8c54e6d19..303ae6feb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -3,9 +3,9 @@ import os import platform import sys +from collections.abc import Iterator from pathlib import Path from textwrap import dedent -from typing import Iterator from unittest import mock import pytest diff --git a/tests/test_server.py b/tests/test_server.py index c650be290..d14206eb5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -5,7 +5,9 @@ import logging import signal import sys -from typing import Callable, ContextManager, Generator +from collections.abc import Generator +from contextlib import AbstractContextManager +from typing import Callable import httpx import pytest @@ -62,7 +64,7 @@ async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable @pytest.mark.parametrize("exception_signal", signals) @pytest.mark.parametrize("capture_signal", signal_captures) async def test_server_interrupt( - exception_signal: signal.Signals, capture_signal: Callable[[signal.Signals], ContextManager[None]] + exception_signal: signal.Signals, capture_signal: Callable[[signal.Signals], AbstractContextManager[None]] ): # pragma: py-win32 """Test interrupting a Server that is run explicitly inside asyncio""" diff --git a/uvicorn/_types.py b/uvicorn/_types.py index 8c8065ae3..c927cc11d 100644 --- a/uvicorn/_types.py +++ b/uvicorn/_types.py @@ -32,20 +32,8 @@ import sys import types -from typing import ( - Any, - Awaitable, - Callable, - Iterable, - Literal, - MutableMapping, - Optional, - Protocol, - Tuple, - Type, - TypedDict, - Union, -) +from collections.abc import Awaitable, Iterable, MutableMapping +from typing import Any, Callable, Literal, Optional, Protocol, TypedDict, Union if sys.version_info >= (3, 11): # pragma: py-lt-311 from typing import NotRequired @@ -54,8 +42,8 @@ # WSGI Environ = MutableMapping[str, Any] -ExcInfo = Tuple[Type[BaseException], BaseException, Optional[types.TracebackType]] -StartResponse = Callable[[str, Iterable[Tuple[str, str]], Optional[ExcInfo]], None] +ExcInfo = tuple[type[BaseException], BaseException, Optional[types.TracebackType]] +StartResponse = Callable[[str, Iterable[tuple[str, str]], Optional[ExcInfo]], None] WSGIApp = Callable[[Environ, StartResponse], Union[Iterable[bytes], BaseException]] @@ -281,7 +269,7 @@ def __init__(self, scope: Scope) -> None: ... # pragma: no cover async def __call__(self, receive: ASGIReceiveCallable, send: ASGISendCallable) -> None: ... # pragma: no cover -ASGI2Application = Type[ASGI2Protocol] +ASGI2Application = type[ASGI2Protocol] ASGI3Application = Callable[ [ Scope, diff --git a/uvicorn/config.py b/uvicorn/config.py index b08a8426b..664d1918f 100644 --- a/uvicorn/config.py +++ b/uvicorn/config.py @@ -9,9 +9,10 @@ import socket import ssl import sys +from collections.abc import Awaitable from configparser import RawConfigParser from pathlib import Path -from typing import IO, Any, Awaitable, Callable, Literal +from typing import IO, Any, Callable, Literal import click diff --git a/uvicorn/middleware/wsgi.py b/uvicorn/middleware/wsgi.py index 078de1af0..2193e8194 100644 --- a/uvicorn/middleware/wsgi.py +++ b/uvicorn/middleware/wsgi.py @@ -6,7 +6,7 @@ import sys import warnings from collections import deque -from typing import Iterable +from collections.abc import Iterable from uvicorn._types import ( ASGIReceiveCallable, diff --git a/uvicorn/protocols/websockets/websockets_impl.py b/uvicorn/protocols/websockets/websockets_impl.py index af66c29b3..cd6c54f35 100644 --- a/uvicorn/protocols/websockets/websockets_impl.py +++ b/uvicorn/protocols/websockets/websockets_impl.py @@ -3,7 +3,8 @@ import asyncio import http import logging -from typing import Any, Literal, Optional, Sequence, cast +from collections.abc import Sequence +from typing import Any, Literal, Optional, cast from urllib.parse import unquote import websockets diff --git a/uvicorn/server.py b/uvicorn/server.py index f14026f16..cca2e850c 100644 --- a/uvicorn/server.py +++ b/uvicorn/server.py @@ -10,9 +10,10 @@ import sys import threading import time +from collections.abc import Generator, Sequence from email.utils import formatdate from types import FrameType -from typing import TYPE_CHECKING, Generator, Sequence, Union +from typing import TYPE_CHECKING, Union import click @@ -284,10 +285,7 @@ async def shutdown(self, sockets: list[socket.socket] | None = None) -> None: len(self.server_state.tasks), ) for t in self.server_state.tasks: - if sys.version_info < (3, 9): # pragma: py-gte-39 - t.cancel() - else: # pragma: py-lt-39 - t.cancel(msg="Task cancelled, timeout graceful shutdown exceeded") + t.cancel(msg="Task cancelled, timeout graceful shutdown exceeded") # Send the lifespan shutdown event, and wait for application shutdown. if not self.force_exit: diff --git a/uvicorn/supervisors/basereload.py b/uvicorn/supervisors/basereload.py index f07ca3912..4df50af33 100644 --- a/uvicorn/supervisors/basereload.py +++ b/uvicorn/supervisors/basereload.py @@ -5,10 +5,11 @@ import signal import sys import threading +from collections.abc import Iterator from pathlib import Path from socket import socket from types import FrameType -from typing import Callable, Iterator +from typing import Callable import click diff --git a/uvicorn/supervisors/statreload.py b/uvicorn/supervisors/statreload.py index 70d0a6d5c..bdcdaa0bf 100644 --- a/uvicorn/supervisors/statreload.py +++ b/uvicorn/supervisors/statreload.py @@ -1,9 +1,10 @@ from __future__ import annotations import logging +from collections.abc import Iterator from pathlib import Path from socket import socket -from typing import Callable, Iterator +from typing import Callable from uvicorn.config import Config from uvicorn.supervisors.basereload import BaseReload From 75d4402f32e09b37a76b5fc7325482906c402275 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 15 Dec 2024 12:56:20 +0100 Subject: [PATCH 09/10] Add alls-green job (#2544) --- .github/workflows/test-suite.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 7646f10c9..64a0b0a66 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -11,7 +11,7 @@ jobs: tests: name: "Python ${{ matrix.python-version }} ${{ matrix.os }}" runs-on: "${{ matrix.os }}" - timeout-minutes: 30 + timeout-minutes: 10 strategy: fail-fast: false matrix: @@ -38,3 +38,14 @@ jobs: - name: "Enforce coverage" run: "scripts/coverage" shell: bash + + # https://github.com/marketplace/actions/alls-green#why + check: + if: always() + needs: [tests] + runs-on: ubuntu-latest + steps: + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} From 7983c1ae9c2276b94cd85217f7aa58bb248847c4 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 15 Dec 2024 14:31:56 +0100 Subject: [PATCH 10/10] Version 0.34.0 (#2546) --- CHANGELOG.md | 16 +++++++++++++--- uvicorn/__init__.py | 2 +- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a62dc5c..e37e8e19a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,27 @@ # Change Log +## 0.34.0 (2024-12-15) + +### Added + +* Add `content-length` to 500 response in `wsproto` implementation (#2542) + +### Removed + +* Drop support for Python 3.8 (#2543) + ## 0.33.0 (2024-12-14) ### Removed -* Remove `WatchGod` support for `--reload` [#2536](https://github.com/encode/uvicorn/pull/2536) +* Remove `WatchGod` support for `--reload` (#2536) ## 0.32.1 (2024-11-20) ### Fixed -* Drop ASGI spec version to 2.3 on HTTP scope [#2513](https://github.com/encode/uvicorn/pull/2513) -* Enable httptools lenient data on `httptools >= 0.6.3` [#2488](https://github.com/encode/uvicorn/pull/2488) +* Drop ASGI spec version to 2.3 on HTTP scope (#2513) +* Enable httptools lenient data on `httptools >= 0.6.3` (#2488) ## 0.32.0 (2024-10-15) diff --git a/uvicorn/__init__.py b/uvicorn/__init__.py index 0a8f431f4..6c88b8901 100644 --- a/uvicorn/__init__.py +++ b/uvicorn/__init__.py @@ -1,5 +1,5 @@ from uvicorn.config import Config from uvicorn.main import Server, main, run -__version__ = "0.33.0" +__version__ = "0.34.0" __all__ = ["main", "run", "Config", "Server"]