diff --git a/.gitignore b/.gitignore index a92a445ac..ed8bcf67e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .cache .coverage +.coverage.* .mypy_cache/ __pycache__/ uvicorn.egg-info/ diff --git a/pyproject.toml b/pyproject.toml index 0a8996666..9af744a1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,7 +85,7 @@ disallow_untyped_defs = false check_untyped_defs = true [tool.pytest.ini_options] -addopts = "-rxXs --strict-config --strict-markers" +addopts = "-rxXs --strict-config --strict-markers -n 8" xfail_strict = true filterwarnings = [ "error", @@ -95,6 +95,7 @@ filterwarnings = [ ] [tool.coverage.run] +parallel = true source_pkgs = ["uvicorn", "tests"] plugins = ["coverage_conditional_plugin"] omit = ["uvicorn/workers.py", "uvicorn/__main__.py"] @@ -125,6 +126,7 @@ exclude_lines = [ py-win32 = "sys_platform == 'win32'" py-not-win32 = "sys_platform != 'win32'" py-linux = "sys_platform == 'linux'" +py-not-linux = "sys_platform != 'linux'" py-darwin = "sys_platform == 'darwin'" py-gte-39 = "sys_version_info >= (3, 9)" py-lt-39 = "sys_version_info < (3, 9)" diff --git a/requirements.txt b/requirements.txt index e26e6b3c5..6bdb74be0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,7 @@ twine==6.0.1 ruff==0.8.3 pytest==8.3.4 pytest-mock==3.14.0 +pytest-xdist[psutil]==3.6.0 mypy==1.13.0 types-click==7.1.8 types-pyyaml==6.0.12.20240917 @@ -24,6 +25,7 @@ trustme==1.2.0 cryptography==44.0.0 coverage==7.6.9 coverage-conditional-plugin==0.9.0 +coverage-enable-subprocess==1.0 httpx==0.28.1 # Documentation diff --git a/scripts/coverage b/scripts/coverage index c93e45e85..2d6ea60ee 100755 --- a/scripts/coverage +++ b/scripts/coverage @@ -8,4 +8,5 @@ export SOURCE_FILES="uvicorn tests" set -x +${PREFIX}coverage combine ${PREFIX}coverage report diff --git a/scripts/test b/scripts/test index ed2bdd01a..64ccdd0da 100755 --- a/scripts/test +++ b/scripts/test @@ -11,6 +11,8 @@ if [ -z $GITHUB_ACTIONS ]; then scripts/check fi +export COVERAGE_PROCESS_START=$(pwd)/pyproject.toml + ${PREFIX}coverage run --debug config -m pytest "$@" if [ -z $GITHUB_ACTIONS ]; then diff --git a/tests/supervisors/test_reload.py b/tests/supervisors/test_reload.py index 44eb9970b..8f7c50988 100644 --- a/tests/supervisors/test_reload.py +++ b/tests/supervisors/test_reload.py @@ -1,6 +1,5 @@ from __future__ import annotations -import platform import signal import socket import sys @@ -24,11 +23,8 @@ WatchFilesReload = None # type: ignore[misc,assignment] -# TODO: Investigate why this is flaky on MacOS M1. -skip_if_m1 = pytest.mark.skipif( - sys.platform == "darwin" and platform.processor() == "arm", - reason="Flaky on MacOS M1", -) +# TODO: Investigate why this is flaky on MacOS, and Windows. +skip_non_linux = pytest.mark.skipif(sys.platform in ("darwin", "win32"), reason="Flaky on Windows and MacOS") def run(sockets: list[socket.socket] | None) -> None: @@ -141,8 +137,12 @@ 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: Callable[[Path], None]): + @pytest.mark.parametrize( + "reloader_class, result", [(StatReload, False), pytest.param(WatchFilesReload, True, marks=skip_non_linux)] + ) + def test_reload_when_pattern_matched_file_is_changed( + self, result: bool, touch_soon: Callable[[Path], None] + ): # pragma: py-not-linux file = self.reload_path / "app" / "js" / "main.js" with as_cwd(self.reload_path): @@ -153,10 +153,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)]) + @pytest.mark.parametrize("reloader_class", [pytest.param(WatchFilesReload, marks=skip_non_linux)]) def test_should_not_reload_when_exclude_pattern_match_file_is_changed( self, touch_soon: Callable[[Path], None] - ): # pragma: py-darwin + ): # pragma: py-not-linux 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" @@ -188,8 +188,10 @@ def test_should_not_reload_when_dot_file_is_changed(self, touch_soon: Callable[[ reloader.shutdown() - @pytest.mark.parametrize("reloader_class", [StatReload, WatchFilesReload]) - def test_should_reload_when_directories_have_same_prefix(self, touch_soon: Callable[[Path], None]): + @pytest.mark.parametrize("reloader_class", [StatReload, pytest.param(WatchFilesReload, marks=skip_non_linux)]) + def test_should_reload_when_directories_have_same_prefix( + self, touch_soon: Callable[[Path], None] + ): # pragma: py-not-linux app_dir = self.reload_path / "app" app_file = app_dir / "src" / "main.py" app_first_dir = self.reload_path / "app_first" @@ -210,9 +212,11 @@ def test_should_reload_when_directories_have_same_prefix(self, touch_soon: Calla @pytest.mark.parametrize( "reloader_class", - [StatReload, pytest.param(WatchFilesReload, marks=skip_if_m1)], + [StatReload, pytest.param(WatchFilesReload, marks=skip_non_linux)], ) - def test_should_not_reload_when_only_subdirectory_is_watched(self, touch_soon: Callable[[Path], None]): + def test_should_not_reload_when_only_subdirectory_is_watched( + self, touch_soon: Callable[[Path], None] + ): # pragma: py-not-linux app_dir = self.reload_path / "app" app_dir_file = self.reload_path / "app" / "src" / "main.py" root_file = self.reload_path / "main.py" @@ -229,8 +233,8 @@ def test_should_not_reload_when_only_subdirectory_is_watched(self, touch_soon: C reloader.shutdown() - @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 + @pytest.mark.parametrize("reloader_class", [pytest.param(WatchFilesReload, marks=skip_non_linux)]) + def test_override_defaults(self, touch_soon: Callable[[Path], None]) -> None: # pragma: py-not-linux dotted_file = self.reload_path / ".dotted" dotted_dir_file = self.reload_path / ".dotted_dir" / "file.txt" python_file = self.reload_path / "main.py" @@ -251,8 +255,8 @@ def test_override_defaults(self, touch_soon: Callable[[Path], None]) -> None: # reloader.shutdown() - @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 + @pytest.mark.parametrize("reloader_class", [pytest.param(WatchFilesReload, marks=skip_non_linux)]) + def test_explicit_paths(self, touch_soon: Callable[[Path], None]) -> None: # pragma: py-not-linux dotted_file = self.reload_path / ".dotted" non_dotted_file = self.reload_path / "ext" / "ext.jpg" python_file = self.reload_path / "main.py" diff --git a/tests/test_server.py b/tests/test_server.py index d14206eb5..ad82cf139 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -64,7 +64,9 @@ 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], AbstractContextManager[None]] + exception_signal: signal.Signals, + capture_signal: Callable[[signal.Signals], AbstractContextManager[None]], + unused_tcp_port: int, ): # pragma: py-win32 """Test interrupting a Server that is run explicitly inside asyncio""" @@ -73,7 +75,7 @@ async def interrupt_running(srv: Server): await asyncio.sleep(0.01) signal.raise_signal(exception_signal) - server = Server(Config(app=dummy_app, loop="asyncio")) + server = Server(Config(app=dummy_app, loop="asyncio", port=unused_tcp_port)) asyncio.create_task(interrupt_running(server)) with capture_signal(exception_signal) as witness: await server.serve() diff --git a/uvicorn/config.py b/uvicorn/config.py index 664d1918f..aeabb2a1c 100644 --- a/uvicorn/config.py +++ b/uvicorn/config.py @@ -138,7 +138,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 # pragma: py-darwin + continue # pragma: py-not-linux patterns.append(pattern) if is_dir(Path(pattern)): directories.append(Path(pattern)) diff --git a/uvicorn/server.py b/uvicorn/server.py index cca2e850c..f0e2ce2cf 100644 --- a/uvicorn/server.py +++ b/uvicorn/server.py @@ -119,7 +119,7 @@ def create_protocol( def _share_socket( sock: socket.SocketType, - ) -> socket.SocketType: # pragma py-linux pragma: py-darwin + ) -> socket.SocketType: # pragma py-not-win32 # Windows requires the socket be explicitly shared across # multiple workers (processes). from socket import fromshare # type: ignore[attr-defined]