From 208a37a4de8f2fd6697dc3eaf076bac7442f5628 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 14 Oct 2023 11:48:19 +0100 Subject: [PATCH 01/19] use asyncio.run(..., loop_factory) to avoid asyncio.set_event_loop_policy --- tests/test_auto_detection.py | 11 ++--- uvicorn/_compat.py | 86 ++++++++++++++++++++++++++++++++++++ uvicorn/config.py | 17 +++---- uvicorn/loops/asyncio.py | 13 ++++-- uvicorn/loops/auto.py | 18 +++++--- uvicorn/loops/uvloop.py | 9 +++- uvicorn/main.py | 4 +- uvicorn/server.py | 4 +- uvicorn/workers.py | 6 +-- 9 files changed, 138 insertions(+), 30 deletions(-) create mode 100644 uvicorn/_compat.py diff --git a/tests/test_auto_detection.py b/tests/test_auto_detection.py index 1f79b3786..c40ab998c 100644 --- a/tests/test_auto_detection.py +++ b/tests/test_auto_detection.py @@ -1,10 +1,11 @@ import asyncio +import contextlib import importlib import pytest from uvicorn.config import Config -from uvicorn.loops.auto import auto_loop_setup +from uvicorn.loops.auto import auto_loop_factory from uvicorn.main import ServerState from uvicorn.protocols.http.auto import AutoHTTPProtocol from uvicorn.protocols.websockets.auto import AutoWebSocketsProtocol @@ -33,10 +34,10 @@ async def app(scope, receive, send): def test_loop_auto(): - auto_loop_setup() - policy = asyncio.get_event_loop_policy() - assert isinstance(policy, asyncio.events.BaseDefaultEventLoopPolicy) - assert type(policy).__module__.startswith(expected_loop) + loop_factory = auto_loop_factory() + with contextlib.closing(loop_factory()) as loop: + assert isinstance(loop, asyncio.AbstractEventLoop) + assert type(loop).__module__.startswith(expected_loop) @pytest.mark.anyio diff --git a/uvicorn/_compat.py b/uvicorn/_compat.py new file mode 100644 index 000000000..23ffc61f1 --- /dev/null +++ b/uvicorn/_compat.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import asyncio +import sys +from collections.abc import Callable, Coroutine +from typing import Any, TypeVar + +_T = TypeVar("_T") + +if sys.version_info >= (3, 12): + asyncio_run = asyncio.run +elif sys.version_info >= (3, 11): + + def asyncio_run( + main: Coroutine[Any, Any, _T], + *, + debug: bool = False, + loop_factory: Callable[[], asyncio.AbstractEventLoop] | None = None, + ) -> _T: + # asyncio.run from Python 3.12 + # https://docs.python.org/3/license.html#psf-license + with asyncio.Runner(debug=debug, loop_factory=loop_factory) as runner: + return runner.run(main) + +else: + # modified version of asyncio.run from Python 3.10 to add loop_factory kwarg + # https://docs.python.org/3/license.html#psf-license + def asyncio_run( + main: Coroutine[Any, Any, _T], + *, + debug: bool = False, + loop_factory: Callable[[], asyncio.AbstractEventLoop] | None = None, + ) -> _T: + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + raise RuntimeError( + "asyncio.run() cannot be called from a running event loop" + ) + + if not asyncio.iscoroutine(main): + raise ValueError(f"a coroutine was expected, got {main!r}") + + if loop_factory is None: + loop = asyncio.new_event_loop() + else: + loop = loop_factory() + try: + if loop_factory is None: + asyncio.set_event_loop(loop) + if debug is not None: + loop.set_debug(debug) + return loop.run_until_complete(main) + finally: + try: + _cancel_all_tasks(loop) + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.run_until_complete(loop.shutdown_default_executor()) + finally: + if loop_factory is None: + asyncio.set_event_loop(None) + loop.close() + + def _cancel_all_tasks(loop: asyncio.AbstractEventLoop) -> None: + to_cancel = asyncio.all_tasks(loop) + if not to_cancel: + return + + for task in to_cancel: + task.cancel() + + loop.run_until_complete(asyncio.gather(*to_cancel, return_exceptions=True)) + + for task in to_cancel: + if task.cancelled(): + continue + if task.exception() is not None: + loop.call_exception_handler( + { + "message": "unhandled exception during asyncio.run() shutdown", + "exception": task.exception(), + "task": task, + } + ) diff --git a/uvicorn/config.py b/uvicorn/config.py index 9aff8c968..d672ff8db 100644 --- a/uvicorn/config.py +++ b/uvicorn/config.py @@ -53,11 +53,11 @@ "on": "uvicorn.lifespan.on:LifespanOn", "off": "uvicorn.lifespan.off:LifespanOff", } -LOOP_SETUPS: dict[LoopSetupType, str | None] = { +LOOP_FACTORIES: dict[LoopSetupType, str | None] = { "none": None, - "auto": "uvicorn.loops.auto:auto_loop_setup", - "asyncio": "uvicorn.loops.asyncio:asyncio_setup", - "uvloop": "uvicorn.loops.uvloop:uvloop_setup", + "auto": "uvicorn.loops.auto:auto_loop_factory", + "asyncio": "uvicorn.loops.asyncio:asyncio_loop_factory", + "uvloop": "uvicorn.loops.uvloop:uvloop_loop_factory", } INTERFACES: list[InterfaceType] = ["auto", "asgi3", "asgi2", "wsgi"] @@ -471,10 +471,11 @@ def load(self) -> None: self.loaded = True - def setup_event_loop(self) -> None: - loop_setup: Callable | None = import_from_string(LOOP_SETUPS[self.loop]) - if loop_setup is not None: - loop_setup(use_subprocess=self.use_subprocess) + def get_loop_factory(self) -> Callable[[], asyncio.AbstractEventLoop] | None: + loop_factory: Callable | None = import_from_string(LOOP_FACTORIES[self.loop]) + if loop_factory is None: + return None + return loop_factory(use_subprocess=self.use_subprocess) def bind_socket(self) -> socket.socket: logger_args: list[str | int] diff --git a/uvicorn/loops/asyncio.py b/uvicorn/loops/asyncio.py index 1bead4a06..a598bc075 100644 --- a/uvicorn/loops/asyncio.py +++ b/uvicorn/loops/asyncio.py @@ -1,10 +1,17 @@ +from __future__ import annotations + import asyncio import logging import sys +from collections.abc import Callable +from typing import TypeVar + +_T = TypeVar("_T") logger = logging.getLogger("uvicorn.error") -def asyncio_setup(use_subprocess: bool = False) -> None: - if sys.platform == "win32" and use_subprocess: - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) # pragma: full coverage +def asyncio_loop_factory(use_subprocess: bool = False) -> Callable[[], asyncio.AbstractEventLoop]: + if sys.platform == "win32" and not use_subprocess: + return asyncio.ProactorEventLoop + return asyncio.SelectorEventLoop diff --git a/uvicorn/loops/auto.py b/uvicorn/loops/auto.py index 2285457bf..333491b8b 100644 --- a/uvicorn/loops/auto.py +++ b/uvicorn/loops/auto.py @@ -1,11 +1,19 @@ -def auto_loop_setup(use_subprocess: bool = False) -> None: +from __future__ import annotations + +import asyncio +from collections.abc import Callable + + +def auto_loop_factory( + use_subprocess: bool = False, +) -> Callable[[], asyncio.AbstractEventLoop]: try: import uvloop # noqa except ImportError: # pragma: no cover - from uvicorn.loops.asyncio import asyncio_setup as loop_setup + from uvicorn.loops.asyncio import asyncio_loop_factory as loop_factory - loop_setup(use_subprocess=use_subprocess) + return loop_factory(use_subprocess=use_subprocess) else: # pragma: no cover - from uvicorn.loops.uvloop import uvloop_setup + from uvicorn.loops.uvloop import uvloop_loop_factory - uvloop_setup(use_subprocess=use_subprocess) + return uvloop_loop_factory(use_subprocess=use_subprocess) diff --git a/uvicorn/loops/uvloop.py b/uvicorn/loops/uvloop.py index 0e2fd1eb0..1d05a373c 100644 --- a/uvicorn/loops/uvloop.py +++ b/uvicorn/loops/uvloop.py @@ -1,7 +1,12 @@ +from __future__ import annotations + import asyncio +from collections.abc import Callable import uvloop -def uvloop_setup(use_subprocess: bool = False) -> None: - asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) +def uvloop_loop_factory( + use_subprocess: bool = False, +) -> Callable[[], asyncio.AbstractEventLoop]: + return uvloop.new_event_loop diff --git a/uvicorn/main.py b/uvicorn/main.py index 4352efbca..bda612dfa 100644 --- a/uvicorn/main.py +++ b/uvicorn/main.py @@ -19,7 +19,7 @@ LIFESPAN, LOG_LEVELS, LOGGING_CONFIG, - LOOP_SETUPS, + LOOP_FACTORIES, SSL_PROTOCOL_VERSION, WS_PROTOCOLS, Config, @@ -36,7 +36,7 @@ HTTP_CHOICES = click.Choice(list(HTTP_PROTOCOLS.keys())) WS_CHOICES = click.Choice(list(WS_PROTOCOLS.keys())) LIFESPAN_CHOICES = click.Choice(list(LIFESPAN.keys())) -LOOP_CHOICES = click.Choice([key for key in LOOP_SETUPS.keys() if key != "none"]) +LOOP_CHOICES = click.Choice([key for key in LOOP_FACTORIES.keys() if key != "none"]) INTERFACE_CHOICES = click.Choice(INTERFACES) STARTUP_FAILURE = 3 diff --git a/uvicorn/server.py b/uvicorn/server.py index fa7638b7d..5cc00bd60 100644 --- a/uvicorn/server.py +++ b/uvicorn/server.py @@ -16,6 +16,7 @@ import click +from uvicorn._compat import asyncio_run from uvicorn.config import Config if TYPE_CHECKING: @@ -61,8 +62,7 @@ def __init__(self, config: Config) -> None: self._captured_signals: list[int] = [] def run(self, sockets: list[socket.socket] | None = None) -> None: - self.config.setup_event_loop() - return asyncio.run(self.serve(sockets=sockets)) + return asyncio_run(self.serve(sockets=sockets), loop_factory=self.config.get_loop_factory()) async def serve(self, sockets: list[socket.socket] | None = None) -> None: with self.capture_signals(): diff --git a/uvicorn/workers.py b/uvicorn/workers.py index 061805b6c..e815f49f2 100644 --- a/uvicorn/workers.py +++ b/uvicorn/workers.py @@ -10,6 +10,7 @@ from gunicorn.arbiter import Arbiter from gunicorn.workers.base import Worker +from uvicorn._compat import asyncio_run from uvicorn.config import Config from uvicorn.main import Server @@ -71,8 +72,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.config = Config(**config_kwargs) def init_process(self) -> None: - self.config.setup_event_loop() - super().init_process() + super(UvicornWorker, self).init_process() def init_signals(self) -> None: # Reset signals so Gunicorn doesn't swallow subprocess return codes @@ -104,7 +104,7 @@ async def _serve(self) -> None: sys.exit(Arbiter.WORKER_BOOT_ERROR) def run(self) -> None: - return asyncio.run(self._serve()) + return asyncio_run(self._serve(), loop_factory=self.config.get_loop_factory()) async def callback_notify(self) -> None: self.notify() From 8021788fc30db813434426c610fc450613920c4b Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 14 Oct 2023 12:09:12 +0100 Subject: [PATCH 02/19] only shutdown the default executor on 3.9 --- uvicorn/_compat.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/uvicorn/_compat.py b/uvicorn/_compat.py index 23ffc61f1..560aa89d1 100644 --- a/uvicorn/_compat.py +++ b/uvicorn/_compat.py @@ -57,7 +57,8 @@ def asyncio_run( try: _cancel_all_tasks(loop) loop.run_until_complete(loop.shutdown_asyncgens()) - loop.run_until_complete(loop.shutdown_default_executor()) + if sys.version_info >= (3, 9): + loop.run_until_complete(loop.shutdown_default_executor()) finally: if loop_factory is None: asyncio.set_event_loop(None) From db8f9a1013e6e8090eccf8a781bb4fe317941478 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 20 Oct 2023 12:58:47 +0100 Subject: [PATCH 03/19] rename LoopSetupType to LoopFactoryType --- uvicorn/config.py | 6 +++--- uvicorn/main.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/uvicorn/config.py b/uvicorn/config.py index d672ff8db..6e0d49a8a 100644 --- a/uvicorn/config.py +++ b/uvicorn/config.py @@ -26,7 +26,7 @@ HTTPProtocolType = Literal["auto", "h11", "httptools"] WSProtocolType = Literal["auto", "none", "websockets", "wsproto"] LifespanType = Literal["auto", "on", "off"] -LoopSetupType = Literal["none", "auto", "asyncio", "uvloop"] +LoopFactoryType = Literal["none", "auto", "asyncio", "uvloop"] InterfaceType = Literal["auto", "asgi3", "asgi2", "wsgi"] LOG_LEVELS: dict[str, int] = { @@ -53,7 +53,7 @@ "on": "uvicorn.lifespan.on:LifespanOn", "off": "uvicorn.lifespan.off:LifespanOff", } -LOOP_FACTORIES: dict[LoopSetupType, str | None] = { +LOOP_FACTORIES: dict[LoopFactoryType, str | None] = { "none": None, "auto": "uvicorn.loops.auto:auto_loop_factory", "asyncio": "uvicorn.loops.asyncio:asyncio_loop_factory", @@ -180,7 +180,7 @@ def __init__( port: int = 8000, uds: str | None = None, fd: int | None = None, - loop: LoopSetupType = "auto", + loop: LoopFactoryType = "auto", http: type[asyncio.Protocol] | HTTPProtocolType = "auto", ws: type[asyncio.Protocol] | WSProtocolType = "auto", ws_max_size: int = 16 * 1024 * 1024, diff --git a/uvicorn/main.py b/uvicorn/main.py index bda612dfa..22b403501 100644 --- a/uvicorn/main.py +++ b/uvicorn/main.py @@ -26,7 +26,7 @@ HTTPProtocolType, InterfaceType, LifespanType, - LoopSetupType, + LoopFactoryType, WSProtocolType, ) from uvicorn.server import Server, ServerState # noqa: F401 # Used to be defined here. @@ -364,7 +364,7 @@ def main( port: int, uds: str, fd: int, - loop: LoopSetupType, + loop: LoopFactoryType, http: HTTPProtocolType, ws: WSProtocolType, ws_max_size: int, @@ -465,7 +465,7 @@ def run( port: int = 8000, uds: str | None = None, fd: int | None = None, - loop: LoopSetupType = "auto", + loop: LoopFactoryType = "auto", http: type[asyncio.Protocol] | HTTPProtocolType = "auto", ws: type[asyncio.Protocol] | WSProtocolType = "auto", ws_max_size: int = 16777216, From 62472229ea21c951da153ebb72bc32b894d36376 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 20 Oct 2023 13:19:50 +0100 Subject: [PATCH 04/19] remove redundant UvicornWorker.init_process --- uvicorn/workers.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/uvicorn/workers.py b/uvicorn/workers.py index e815f49f2..25fa8533c 100644 --- a/uvicorn/workers.py +++ b/uvicorn/workers.py @@ -71,9 +71,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.config = Config(**config_kwargs) - def init_process(self) -> None: - super(UvicornWorker, self).init_process() - def init_signals(self) -> None: # Reset signals so Gunicorn doesn't swallow subprocess return codes # other signals are set up by Server.install_signal_handlers() From a8df5b9050d871c069ed8748c72b5eeeabc75e42 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 14 Apr 2024 22:34:43 +0200 Subject: [PATCH 05/19] fix linter --- uvicorn/loops/asyncio.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/uvicorn/loops/asyncio.py b/uvicorn/loops/asyncio.py index a598bc075..ad6121ee0 100644 --- a/uvicorn/loops/asyncio.py +++ b/uvicorn/loops/asyncio.py @@ -1,14 +1,8 @@ from __future__ import annotations import asyncio -import logging import sys from collections.abc import Callable -from typing import TypeVar - -_T = TypeVar("_T") - -logger = logging.getLogger("uvicorn.error") def asyncio_loop_factory(use_subprocess: bool = False) -> Callable[[], asyncio.AbstractEventLoop]: From 849169f438c9e502d6d95b5d11062aa6bd318ddb Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 14 Apr 2024 22:34:43 +0200 Subject: [PATCH 06/19] fix linter --- uvicorn/_compat.py | 4 +--- uvicorn/loops/auto.py | 4 +--- uvicorn/loops/uvloop.py | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/uvicorn/_compat.py b/uvicorn/_compat.py index 560aa89d1..e2650507a 100644 --- a/uvicorn/_compat.py +++ b/uvicorn/_compat.py @@ -36,9 +36,7 @@ def asyncio_run( except RuntimeError: pass else: - raise RuntimeError( - "asyncio.run() cannot be called from a running event loop" - ) + raise RuntimeError("asyncio.run() cannot be called from a running event loop") if not asyncio.iscoroutine(main): raise ValueError(f"a coroutine was expected, got {main!r}") diff --git a/uvicorn/loops/auto.py b/uvicorn/loops/auto.py index 333491b8b..190839905 100644 --- a/uvicorn/loops/auto.py +++ b/uvicorn/loops/auto.py @@ -4,9 +4,7 @@ from collections.abc import Callable -def auto_loop_factory( - use_subprocess: bool = False, -) -> Callable[[], asyncio.AbstractEventLoop]: +def auto_loop_factory(use_subprocess: bool = False) -> Callable[[], asyncio.AbstractEventLoop]: try: import uvloop # noqa except ImportError: # pragma: no cover diff --git a/uvicorn/loops/uvloop.py b/uvicorn/loops/uvloop.py index 1d05a373c..c6692c58f 100644 --- a/uvicorn/loops/uvloop.py +++ b/uvicorn/loops/uvloop.py @@ -6,7 +6,5 @@ import uvloop -def uvloop_loop_factory( - use_subprocess: bool = False, -) -> Callable[[], asyncio.AbstractEventLoop]: +def uvloop_loop_factory(use_subprocess: bool = False) -> Callable[[], asyncio.AbstractEventLoop]: return uvloop.new_event_loop From 2dafe54bda2cc8978e529f4aa40c9d15e1944b55 Mon Sep 17 00:00:00 2001 From: Nir Geller Date: Sun, 18 Aug 2024 19:58:22 +0300 Subject: [PATCH 07/19] Fix coverage --- pyproject.toml | 2 +- tests/test_auto_detection.py | 2 +- tests/test_config.py | 23 ++++++++++++++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2242d424a..5400a68ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,7 +97,7 @@ filterwarnings = [ [tool.coverage.run] source_pkgs = ["uvicorn", "tests"] plugins = ["coverage_conditional_plugin"] -omit = ["uvicorn/workers.py", "uvicorn/__main__.py"] +omit = ["uvicorn/workers.py", "uvicorn/__main__.py", "uvicorn/_compat.py"] [tool.coverage.report] precision = 2 diff --git a/tests/test_auto_detection.py b/tests/test_auto_detection.py index c40ab998c..ef86bf265 100644 --- a/tests/test_auto_detection.py +++ b/tests/test_auto_detection.py @@ -34,7 +34,7 @@ async def app(scope, receive, send): def test_loop_auto(): - loop_factory = auto_loop_factory() + loop_factory = auto_loop_factory(use_subprocess=True) with contextlib.closing(loop_factory()) as loop: assert isinstance(loop, asyncio.AbstractEventLoop) assert type(loop).__module__.startswith(expected_loop) diff --git a/tests/test_config.py b/tests/test_config.py index e16cc5d56..48c2b1c10 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import configparser import io import json @@ -8,6 +9,7 @@ import socket import sys import typing +from contextlib import closing from pathlib import Path from typing import Any, Literal from unittest.mock import MagicMock @@ -25,7 +27,7 @@ Scope, StartResponse, ) -from uvicorn.config import Config +from uvicorn.config import Config, LoopFactoryType from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware from uvicorn.middleware.wsgi import WSGIMiddleware from uvicorn.protocols.http.h11_impl import H11Protocol @@ -545,3 +547,22 @@ def test_warn_when_using_reload_and_workers(caplog: pytest.LogCaptureFixture) -> Config(app=asgi_app, reload=True, workers=2) assert len(caplog.records) == 1 assert '"workers" flag is ignored when reloading is enabled.' in caplog.records[0].message + + +@pytest.mark.parametrize( + ("loop_type", "expected_loop_factory"), + [ + ("none", None), + ("asyncio", asyncio.ProactorEventLoop if sys.platform == "win32" else asyncio.SelectorEventLoop), # type: ignore + ], +) +def test_get_loop_factory(loop_type: LoopFactoryType, expected_loop_factory: Any): + config = Config(app=asgi_app, loop=loop_type) + loop_factory = config.get_loop_factory() + if loop_factory is None: + assert expected_loop_factory is loop_factory + else: + loop = loop_factory() + with closing(loop): + assert loop is not None + assert isinstance(loop, expected_loop_factory) From 100d1ad1188b2612f9dfffb04688d48912887838 Mon Sep 17 00:00:00 2001 From: Nir Geller Date: Sun, 18 Aug 2024 20:27:58 +0300 Subject: [PATCH 08/19] Allow passing a custom loop --- docs/deployment.md | 6 +++++- docs/index.md | 6 +++++- tests/custom_loop_utils.py | 12 ++++++++++++ tests/test_config.py | 32 +++++++++++++++++++++++++++++--- tests/utils.py | 9 +++++++++ uvicorn/config.py | 13 ++++++++++--- uvicorn/main.py | 12 ++++++------ 7 files changed, 76 insertions(+), 14 deletions(-) create mode 100644 tests/custom_loop_utils.py diff --git a/docs/deployment.md b/docs/deployment.md index 7a2c7972c..241b533cc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -57,7 +57,11 @@ Options: --workers INTEGER Number of worker processes. Defaults to the $WEB_CONCURRENCY environment variable if available, or 1. Not valid with --reload. - --loop [auto|asyncio|uvloop] Event loop implementation. [default: auto] + --loop TEXT Event loop implementation. Can be one of + [auto|asyncio|uvloop] or an import string to + a function of type: (use_subprocess: bool) + -> Callable[[], asyncio.AbstractEventLoop]. + [default: auto] --http [auto|h11|httptools] HTTP protocol implementation. [default: auto] --ws [auto|none|websockets|wsproto] diff --git a/docs/index.md b/docs/index.md index 5d805316b..e741e75b0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -127,7 +127,11 @@ Options: --workers INTEGER Number of worker processes. Defaults to the $WEB_CONCURRENCY environment variable if available, or 1. Not valid with --reload. - --loop [auto|asyncio|uvloop] Event loop implementation. [default: auto] + --loop TEXT Event loop implementation. Can be one of + [auto|asyncio|uvloop] or an import string to + a function of type: (use_subprocess: bool) + -> Callable[[], asyncio.AbstractEventLoop]. + [default: auto] --http [auto|h11|httptools] HTTP protocol implementation. [default: auto] --ws [auto|none|websockets|wsproto] diff --git a/tests/custom_loop_utils.py b/tests/custom_loop_utils.py new file mode 100644 index 000000000..3a2db4a78 --- /dev/null +++ b/tests/custom_loop_utils.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import asyncio +from asyncio import AbstractEventLoop + + +class CustomLoop(asyncio.SelectorEventLoop): + pass + + +def custom_loop_factory(use_subprocess: bool) -> type[AbstractEventLoop]: + return CustomLoop diff --git a/tests/test_config.py b/tests/test_config.py index 48c2b1c10..fcbce4880 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio import configparser import io import json @@ -18,7 +17,8 @@ import yaml from pytest_mock import MockerFixture -from tests.utils import as_cwd +from tests.custom_loop_utils import CustomLoop +from tests.utils import as_cwd, get_asyncio_default_loop_per_os from uvicorn._types import ( ASGIApplication, ASGIReceiveCallable, @@ -553,7 +553,7 @@ def test_warn_when_using_reload_and_workers(caplog: pytest.LogCaptureFixture) -> ("loop_type", "expected_loop_factory"), [ ("none", None), - ("asyncio", asyncio.ProactorEventLoop if sys.platform == "win32" else asyncio.SelectorEventLoop), # type: ignore + ("asyncio", get_asyncio_default_loop_per_os()), ], ) def test_get_loop_factory(loop_type: LoopFactoryType, expected_loop_factory: Any): @@ -566,3 +566,29 @@ def test_get_loop_factory(loop_type: LoopFactoryType, expected_loop_factory: Any with closing(loop): assert loop is not None assert isinstance(loop, expected_loop_factory) + + +def test_custom_loop__importable_custom_loop_setup_function() -> None: + config = Config(app=asgi_app, loop="tests.custom_loop_utils:custom_loop_factory") + config.load() + loop_factory = config.get_loop_factory() + assert loop_factory, "Loop factory should be set" + event_loop = loop_factory() + with closing(event_loop): + assert event_loop is not None + assert isinstance(event_loop, CustomLoop) + + +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") +def test_custom_loop__not_importable_custom_loop_setup_function(caplog: pytest.LogCaptureFixture) -> None: + config = Config(app=asgi_app, loop="tests.test_config:non_existing_setup_function") + config.load() + with pytest.raises(SystemExit): + config.get_loop_factory() + error_messages = [ + record.message for record in caplog.records if record.name == "uvicorn.error" and record.levelname == "ERROR" + ] + assert ( + 'Error loading custom loop setup function. Attribute "non_existing_setup_function" not found in module "tests.test_config".' # noqa: E501 + == error_messages.pop(0) + ) diff --git a/tests/utils.py b/tests/utils.py index 56362f20f..8145a2bd2 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -3,6 +3,7 @@ import asyncio import os import signal +import sys from collections.abc import AsyncIterator from contextlib import asynccontextmanager, contextmanager from pathlib import Path @@ -44,3 +45,11 @@ def as_cwd(path: Path): yield finally: os.chdir(prev_cwd) + + +def get_asyncio_default_loop_per_os() -> type[asyncio.AbstractEventLoop]: + """Get the default asyncio loop per OS.""" + if sys.platform == "win32": + return asyncio.ProactorEventLoop # type: ignore # pragma: nocover + else: + return asyncio.SelectorEventLoop # pragma: nocover diff --git a/uvicorn/config.py b/uvicorn/config.py index 6e0d49a8a..6d2a9489e 100644 --- a/uvicorn/config.py +++ b/uvicorn/config.py @@ -53,7 +53,7 @@ "on": "uvicorn.lifespan.on:LifespanOn", "off": "uvicorn.lifespan.off:LifespanOff", } -LOOP_FACTORIES: dict[LoopFactoryType, str | None] = { +LOOP_FACTORIES: dict[str, str | None] = { "none": None, "auto": "uvicorn.loops.auto:auto_loop_factory", "asyncio": "uvicorn.loops.asyncio:asyncio_loop_factory", @@ -180,7 +180,7 @@ def __init__( port: int = 8000, uds: str | None = None, fd: int | None = None, - loop: LoopFactoryType = "auto", + loop: str = "auto", http: type[asyncio.Protocol] | HTTPProtocolType = "auto", ws: type[asyncio.Protocol] | WSProtocolType = "auto", ws_max_size: int = 16 * 1024 * 1024, @@ -472,7 +472,14 @@ def load(self) -> None: self.loaded = True def get_loop_factory(self) -> Callable[[], asyncio.AbstractEventLoop] | None: - loop_factory: Callable | None = import_from_string(LOOP_FACTORIES[self.loop]) + if self.loop in LOOP_FACTORIES: + loop_factory: Callable | None = import_from_string(LOOP_FACTORIES[self.loop]) + else: + try: + loop_factory = import_from_string(self.loop) + except ImportFromStringError as exc: + logger.error("Error loading custom loop setup function. %s" % exc) + sys.exit(1) if loop_factory is None: return None return loop_factory(use_subprocess=self.use_subprocess) diff --git a/uvicorn/main.py b/uvicorn/main.py index 22b403501..22f9e3a90 100644 --- a/uvicorn/main.py +++ b/uvicorn/main.py @@ -26,7 +26,6 @@ HTTPProtocolType, InterfaceType, LifespanType, - LoopFactoryType, WSProtocolType, ) from uvicorn.server import Server, ServerState # noqa: F401 # Used to be defined here. @@ -36,7 +35,7 @@ HTTP_CHOICES = click.Choice(list(HTTP_PROTOCOLS.keys())) WS_CHOICES = click.Choice(list(WS_PROTOCOLS.keys())) LIFESPAN_CHOICES = click.Choice(list(LIFESPAN.keys())) -LOOP_CHOICES = click.Choice([key for key in LOOP_FACTORIES.keys() if key != "none"]) +LOOP_CHOICES = [key for key in LOOP_FACTORIES.keys() if key != "none"] INTERFACE_CHOICES = click.Choice(INTERFACES) STARTUP_FAILURE = 3 @@ -117,9 +116,10 @@ def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> No ) @click.option( "--loop", - type=LOOP_CHOICES, + type=str, default="auto", - help="Event loop implementation.", + help=f"Event loop implementation. Can be one of [{'|'.join(LOOP_CHOICES)}] " + f"or an import string to a function of type: (use_subprocess: bool) -> Callable[[], asyncio.AbstractEventLoop].", show_default=True, ) @click.option( @@ -364,7 +364,7 @@ def main( port: int, uds: str, fd: int, - loop: LoopFactoryType, + loop: str, http: HTTPProtocolType, ws: WSProtocolType, ws_max_size: int, @@ -465,7 +465,7 @@ def run( port: int = 8000, uds: str | None = None, fd: int | None = None, - loop: LoopFactoryType = "auto", + loop: str = "auto", http: type[asyncio.Protocol] | HTTPProtocolType = "auto", ws: type[asyncio.Protocol] | WSProtocolType = "auto", ws_max_size: int = 16777216, From fcb1cbe06dc4cb0e144682e73c49f3982e6f818c Mon Sep 17 00:00:00 2001 From: Nir Geller Date: Mon, 19 Aug 2024 00:38:32 +0300 Subject: [PATCH 09/19] Add tests for `_compat.py` --- tests/test_compat.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 tests/test_compat.py diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 000000000..15af6a4eb --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import asyncio +from asyncio import AbstractEventLoop + +import pytest + +from tests.custom_loop_utils import CustomLoop, custom_loop_factory +from tests.utils import get_asyncio_default_loop_per_os +from uvicorn._compat import asyncio_run + + +async def assert_event_loop(expected_loop_class: type[AbstractEventLoop]): + assert isinstance(asyncio.get_event_loop(), expected_loop_class) + + +def test_asyncio_run__default_loop_factory() -> None: + asyncio_run(assert_event_loop(get_asyncio_default_loop_per_os()), loop_factory=None) + + +def test_asyncio_run__custom_loop_factory() -> None: + asyncio_run(assert_event_loop(CustomLoop), loop_factory=custom_loop_factory(use_subprocess=False)) + + +def test_asyncio_run__passing_a_non_awaitable_callback_should_throw_error() -> None: + with pytest.raises(ValueError): + asyncio_run( + lambda: None, # type: ignore + loop_factory=custom_loop_factory(use_subprocess=False), + ) From 30b80932e97cccbd393c84e3a9c63b47a11ff595 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 30 Dec 2024 08:00:07 +0000 Subject: [PATCH 10/19] fix linting --- uvicorn/_compat.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/uvicorn/_compat.py b/uvicorn/_compat.py index e2650507a..f380aca50 100644 --- a/uvicorn/_compat.py +++ b/uvicorn/_compat.py @@ -55,8 +55,7 @@ def asyncio_run( try: _cancel_all_tasks(loop) loop.run_until_complete(loop.shutdown_asyncgens()) - if sys.version_info >= (3, 9): - loop.run_until_complete(loop.shutdown_default_executor()) + loop.run_until_complete(loop.shutdown_default_executor()) finally: if loop_factory is None: asyncio.set_event_loop(None) From d9db4e4ad0c6654056afead1f16d6d39cb16cde2 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 30 Dec 2024 08:02:38 +0000 Subject: [PATCH 11/19] loop factory should not take use_subprocess --- tests/custom_loop_utils.py | 4 ---- tests/test_compat.py | 6 +++--- tests/test_config.py | 2 +- uvicorn/config.py | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/custom_loop_utils.py b/tests/custom_loop_utils.py index 3a2db4a78..705208c4f 100644 --- a/tests/custom_loop_utils.py +++ b/tests/custom_loop_utils.py @@ -6,7 +6,3 @@ class CustomLoop(asyncio.SelectorEventLoop): pass - - -def custom_loop_factory(use_subprocess: bool) -> type[AbstractEventLoop]: - return CustomLoop diff --git a/tests/test_compat.py b/tests/test_compat.py index 15af6a4eb..0a962030c 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -5,7 +5,7 @@ import pytest -from tests.custom_loop_utils import CustomLoop, custom_loop_factory +from tests.custom_loop_utils import CustomLoop from tests.utils import get_asyncio_default_loop_per_os from uvicorn._compat import asyncio_run @@ -19,12 +19,12 @@ def test_asyncio_run__default_loop_factory() -> None: def test_asyncio_run__custom_loop_factory() -> None: - asyncio_run(assert_event_loop(CustomLoop), loop_factory=custom_loop_factory(use_subprocess=False)) + asyncio_run(assert_event_loop(CustomLoop), loop_factory=CustomLoop) def test_asyncio_run__passing_a_non_awaitable_callback_should_throw_error() -> None: with pytest.raises(ValueError): asyncio_run( lambda: None, # type: ignore - loop_factory=custom_loop_factory(use_subprocess=False), + loop_factory=CustomLoop, ) diff --git a/tests/test_config.py b/tests/test_config.py index fcbce4880..5ad61fc56 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -569,7 +569,7 @@ def test_get_loop_factory(loop_type: LoopFactoryType, expected_loop_factory: Any def test_custom_loop__importable_custom_loop_setup_function() -> None: - config = Config(app=asgi_app, loop="tests.custom_loop_utils:custom_loop_factory") + config = Config(app=asgi_app, loop="tests.custom_loop_utils:CustomLoop") config.load() loop_factory = config.get_loop_factory() assert loop_factory, "Loop factory should be set" diff --git a/uvicorn/config.py b/uvicorn/config.py index 8922cf597..90f48b9eb 100644 --- a/uvicorn/config.py +++ b/uvicorn/config.py @@ -477,7 +477,7 @@ def get_loop_factory(self) -> Callable[[], asyncio.AbstractEventLoop] | None: loop_factory: Callable | None = import_from_string(LOOP_FACTORIES[self.loop]) else: try: - loop_factory = import_from_string(self.loop) + return import_from_string(self.loop) except ImportFromStringError as exc: logger.error("Error loading custom loop setup function. %s" % exc) sys.exit(1) From 16c120b5c8a7f99981ecb793469e5abe3c7a61de Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 30 Dec 2024 08:03:15 +0000 Subject: [PATCH 12/19] remove redundant import --- tests/custom_loop_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/custom_loop_utils.py b/tests/custom_loop_utils.py index 705208c4f..ab767f660 100644 --- a/tests/custom_loop_utils.py +++ b/tests/custom_loop_utils.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -from asyncio import AbstractEventLoop class CustomLoop(asyncio.SelectorEventLoop): From dc2c956c3233e5f0b8e3218265882efd2aaa751a Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 30 Dec 2024 08:08:03 +0000 Subject: [PATCH 13/19] update docs --- docs/deployment.md | 3 ++- docs/index.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 4d5819011..1f6d520d1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -133,7 +133,8 @@ Options: --version Display the uvicorn version and exit. --app-dir TEXT Look for APP in the specified directory, by adding this to the PYTHONPATH. Defaults to - the current working directory. + the current working directory. [default: + ""] --h11-max-incomplete-event-size INTEGER For h11, the maximum number of bytes to buffer of an incomplete event. diff --git a/docs/index.md b/docs/index.md index 20da6442b..7af4e8c02 100644 --- a/docs/index.md +++ b/docs/index.md @@ -203,7 +203,8 @@ Options: --version Display the uvicorn version and exit. --app-dir TEXT Look for APP in the specified directory, by adding this to the PYTHONPATH. Defaults to - the current working directory. + the current working directory. [default: + ""] --h11-max-incomplete-event-size INTEGER For h11, the maximum number of bytes to buffer of an incomplete event. From 66df77e0a89db5c22640cb633ec9716d4bee4763 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 30 Dec 2024 08:15:15 +0000 Subject: [PATCH 14/19] test on 3.14 --- .github/workflows/test-suite.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 64a0b0a66..0fce51de7 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -15,13 +15,14 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] os: [windows-latest, ubuntu-latest, macos-latest] steps: - uses: "actions/checkout@v4" - uses: "actions/setup-python@v5" with: python-version: "${{ matrix.python-version }}" + allow-prereleases: true - name: "Install dependencies" run: "scripts/install" shell: bash From fc8119478fb8711a2918bc413183853febdc360f Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 30 Dec 2024 08:24:22 +0000 Subject: [PATCH 15/19] configure PYO3_USE_ABI3_FORWARD_COMPATIBILITY --- scripts/install | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install b/scripts/install index edd300997..d9a6998de 100755 --- a/scripts/install +++ b/scripts/install @@ -16,4 +16,4 @@ else fi ${PIP} install -U pip -${PIP} install -r "$REQUIREMENTS" +PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1 ${PIP} install -r "$REQUIREMENTS" From 7a84fd6e6de6104d23bec53875ab071314677a5f Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 30 Dec 2024 08:35:03 +0000 Subject: [PATCH 16/19] fixes for 3.14 --- tests/test_auto_detection.py | 5 +++++ tests/test_compat.py | 3 ++- uvicorn/_compat.py | 7 +++++++ uvicorn/config.py | 5 +++-- uvicorn/loops/auto.py | 14 ++++++++++---- 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/tests/test_auto_detection.py b/tests/test_auto_detection.py index ef86bf265..68f456899 100644 --- a/tests/test_auto_detection.py +++ b/tests/test_auto_detection.py @@ -1,6 +1,7 @@ import asyncio import contextlib import importlib +import sys import pytest @@ -15,6 +16,10 @@ expected_loop = "uvloop" # pragma: py-win32 except ImportError: # pragma: py-not-win32 expected_loop = "asyncio" +except AttributeError: + if sys.version_info < (3, 14): + raise + expected_loop = "asyncio" try: importlib.import_module("httptools") diff --git a/tests/test_compat.py b/tests/test_compat.py index 0a962030c..012062311 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import sys from asyncio import AbstractEventLoop import pytest @@ -23,7 +24,7 @@ def test_asyncio_run__custom_loop_factory() -> None: def test_asyncio_run__passing_a_non_awaitable_callback_should_throw_error() -> None: - with pytest.raises(ValueError): + with pytest.raises(TypeError if sys.version_info >= (3, 14) else ValueError): asyncio_run( lambda: None, # type: ignore loop_factory=CustomLoop, diff --git a/uvicorn/_compat.py b/uvicorn/_compat.py index f380aca50..118a19ac0 100644 --- a/uvicorn/_compat.py +++ b/uvicorn/_compat.py @@ -5,6 +5,13 @@ from collections.abc import Callable, Coroutine from typing import Any, TypeVar +__all__ = ["asyncio_run", "iscoroutinefunction"] + +if sys.version_info >= (3, 14): + from inspect import iscoroutinefunction +else: + from asyncio import iscoroutinefunction + _T = TypeVar("_T") if sys.version_info >= (3, 12): diff --git a/uvicorn/config.py b/uvicorn/config.py index 90f48b9eb..cb55a3828 100644 --- a/uvicorn/config.py +++ b/uvicorn/config.py @@ -16,6 +16,7 @@ import click +from uvicorn._compat import iscoroutinefunction from uvicorn._types import ASGIApplication from uvicorn.importer import ImportFromStringError, import_from_string from uvicorn.logging import TRACE_LOG_LEVEL @@ -453,10 +454,10 @@ def load(self) -> None: if inspect.isclass(self.loaded_app): use_asgi_3 = hasattr(self.loaded_app, "__await__") elif inspect.isfunction(self.loaded_app): - use_asgi_3 = asyncio.iscoroutinefunction(self.loaded_app) + use_asgi_3 = iscoroutinefunction(self.loaded_app) else: call = getattr(self.loaded_app, "__call__", None) - use_asgi_3 = asyncio.iscoroutinefunction(call) + use_asgi_3 = iscoroutinefunction(call) self.interface = "asgi3" if use_asgi_3 else "asgi2" if self.interface == "wsgi": diff --git a/uvicorn/loops/auto.py b/uvicorn/loops/auto.py index 190839905..d15d737f9 100644 --- a/uvicorn/loops/auto.py +++ b/uvicorn/loops/auto.py @@ -1,17 +1,23 @@ from __future__ import annotations import asyncio +import sys from collections.abc import Callable -def auto_loop_factory(use_subprocess: bool = False) -> Callable[[], asyncio.AbstractEventLoop]: +def auto_loop_factory(use_subprocess: bool = False) -> Callable[[], asyncio.AbstractEventLoop]: # pragma: no cover try: import uvloop # noqa except ImportError: # pragma: no cover - from uvicorn.loops.asyncio import asyncio_loop_factory as loop_factory - - return loop_factory(use_subprocess=use_subprocess) + pass + except AttributeError: # pragma: no cover + if sys.version_info < (3, 14): + raise else: # pragma: no cover from uvicorn.loops.uvloop import uvloop_loop_factory return uvloop_loop_factory(use_subprocess=use_subprocess) + + from uvicorn.loops.asyncio import asyncio_loop_factory as loop_factory + + return loop_factory(use_subprocess=use_subprocess) From f7ae6dde20fdcb2cc782c2d24976ac7ad4f5e9eb Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 30 Dec 2024 09:07:50 +0000 Subject: [PATCH 17/19] fix coverage pragmas --- pyproject.toml | 3 +++ tests/test_auto_detection.py | 7 ++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6c2ccb87f..e972b2a51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -120,6 +120,7 @@ exclude_lines = [ "tests/supervisors/test_multiprocess.py", ] "sys_platform != 'win32'" = ["uvicorn/loops/asyncio.py"] +"sys_version_info >= (3, 14)" = ["uvicorn/loops/uvloop.py"] [tool.coverage.coverage_conditional_plugin.rules] py-win32 = "sys_platform == 'win32'" @@ -132,3 +133,5 @@ py-gte-310 = "sys_version_info >= (3, 10)" py-lt-310 = "sys_version_info < (3, 10)" py-gte-311 = "sys_version_info >= (3, 11)" py-lt-311 = "sys_version_info < (3, 11)" +py-gte-314 = "sys_version_info >= (3, 14)" +py-lt-314 = "sys_version_info < (3, 14)" diff --git a/tests/test_auto_detection.py b/tests/test_auto_detection.py index 68f456899..f1a30a073 100644 --- a/tests/test_auto_detection.py +++ b/tests/test_auto_detection.py @@ -13,13 +13,14 @@ try: importlib.import_module("uvloop") - expected_loop = "uvloop" # pragma: py-win32 except ImportError: # pragma: py-not-win32 expected_loop = "asyncio" -except AttributeError: - if sys.version_info < (3, 14): +except AttributeError: # pragma: py-lt-314 + if sys.version_info < (3, 14): # pragma: no cover raise expected_loop = "asyncio" +else: # pragma: py-win32 # pragma: py-gte-314 + expected_loop = "uvloop" try: importlib.import_module("httptools") From 4886358cb61583e469de245102490264e1a23334 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 30 Dec 2024 09:14:19 +0000 Subject: [PATCH 18/19] add 3.14 trove classifier --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index e972b2a51..6e90b0df8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy", "Topic :: Internet :: WWW/HTTP", From 1a29ee9aea8c30c6d5da5e2ac6ceae2f41e7fda0 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Mon, 30 Dec 2024 09:55:28 +0000 Subject: [PATCH 19/19] coverage --- tests/test_auto_detection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_auto_detection.py b/tests/test_auto_detection.py index f1a30a073..8fff81f29 100644 --- a/tests/test_auto_detection.py +++ b/tests/test_auto_detection.py @@ -15,7 +15,7 @@ importlib.import_module("uvloop") except ImportError: # pragma: py-not-win32 expected_loop = "asyncio" -except AttributeError: # pragma: py-lt-314 +except AttributeError: # pragma: py-lt-314 # pragma: py-win32 if sys.version_info < (3, 14): # pragma: no cover raise expected_loop = "asyncio"