Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Speed up test suite with pytest-xdist #2537

Merged
merged 17 commits into from
Mar 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.cache
.coverage
.coverage.*
.mypy_cache/
__pycache__/
uvicorn.egg-info/
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"]
Expand Down Expand Up @@ -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)"
Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@ twine==6.1.0
ruff==0.9.9
pytest==8.3.4
pytest-mock==3.14.0
pytest-xdist[psutil]==3.6.0
mypy==1.15.0
types-click==7.1.8
types-pyyaml==6.0.12.20241230
trustme==1.2.1
cryptography==44.0.1
coverage==7.6.12
coverage-conditional-plugin==0.9.0
coverage-enable-subprocess==1.0
httpx==0.28.1

# Documentation
Expand Down
1 change: 1 addition & 0 deletions scripts/coverage
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@ export SOURCE_FILES="uvicorn tests"

set -x

${PREFIX}coverage combine
${PREFIX}coverage report
2 changes: 2 additions & 0 deletions scripts/test
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 22 additions & 18 deletions tests/supervisors/test_reload.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

import platform
import signal
import socket
import sys
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand All @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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"
Expand Down
6 changes: 4 additions & 2 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion uvicorn/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion uvicorn/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down