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

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
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.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
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
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
Loading