From da113d72d212858fe07d43575bc516d1095ad00e Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Fri, 2 May 2025 14:00:55 +0200 Subject: [PATCH] perf: Create one event loop fixture per scope rather than one fixture per scope instance. This reduces the number of fixtures and therefore improves test collection time, especially for large test suites. --- changelog.d/+d874f4f1.changed.rst | 1 + pytest_asyncio/plugin.py | 123 +++++++----------------------- 2 files changed, 27 insertions(+), 97 deletions(-) create mode 100644 changelog.d/+d874f4f1.changed.rst diff --git a/changelog.d/+d874f4f1.changed.rst b/changelog.d/+d874f4f1.changed.rst new file mode 100644 index 00000000..94a97c08 --- /dev/null +++ b/changelog.d/+d874f4f1.changed.rst @@ -0,0 +1 @@ +Scoped event loops (e.g. module-scoped loops) are created once rather than per scope (e.g. per module). This reduces the number of fixtures and speeds up collection time, especially for large test suites. diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index a3354f43..309f4497 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -33,6 +33,7 @@ import pluggy import pytest +from _pytest.scope import Scope from pytest import ( Class, Collector, @@ -657,10 +658,6 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( Session: "session", } -# A stack used to push package-scoped loops during collection of a package -# and pop those loops during collection of a Module -__package_loop_stack: list[Callable[..., Any]] = [] - @pytest.hookimpl def pytest_collectstart(collector: pytest.Collector) -> None: @@ -672,76 +669,9 @@ def pytest_collectstart(collector: pytest.Collector) -> None: ) except StopIteration: return - # Session is not a PyCollector type, so it doesn't have a corresponding - # "obj" attribute to attach a dynamic fixture function to. - # However, there's only one session per pytest run, so there's no need to - # create the fixture dynamically. We can simply define a session-scoped - # event loop fixture once in the plugin code. - if collector_scope == "session": - event_loop_fixture_id = _session_event_loop.__name__ - collector.stash[_event_loop_fixture_id] = event_loop_fixture_id - return - # There seem to be issues when a fixture is shadowed by another fixture - # and both differ in their params. - # https://github.com/pytest-dev/pytest/issues/2043 - # https://github.com/pytest-dev/pytest/issues/11350 - # As such, we assign a unique name for each event_loop fixture. - # The fixture name is stored in the collector's Stash, so it can - # be injected when setting up the test - event_loop_fixture_id = f"{collector.nodeid}::" + event_loop_fixture_id = f"_{collector_scope}_event_loop" collector.stash[_event_loop_fixture_id] = event_loop_fixture_id - @pytest.fixture( - scope=collector_scope, - name=event_loop_fixture_id, - ) - def scoped_event_loop( - *args, # Function needs to accept "cls" when collected by pytest.Class - event_loop_policy, - ) -> Iterator[asyncio.AbstractEventLoop]: - new_loop_policy = event_loop_policy - with ( - _temporary_event_loop_policy(new_loop_policy), - _provide_event_loop() as loop, - ): - asyncio.set_event_loop(loop) - yield loop - - # @pytest.fixture does not register the fixture anywhere, so pytest doesn't - # know it exists. We work around this by attaching the fixture function to the - # collected Python object, where it will be picked up by pytest.Class.collect() - # or pytest.Module.collect(), respectively - if type(collector) is Package: - # Packages do not have a corresponding Python object. Therefore, the fixture - # for the package-scoped event loop is added to a stack. When a module inside - # the package is collected, the module will attach the fixture to its - # Python object. - __package_loop_stack.append(scoped_event_loop) - elif isinstance(collector, Module): - # Accessing Module.obj triggers a module import executing module-level - # statements. A module-level pytest.skip statement raises the "Skipped" - # OutcomeException or a Collector.CollectError, if the "allow_module_level" - # kwargs is missing. These cases are handled correctly when they happen inside - # Collector.collect(), but this hook runs before the actual collect call. - # Therefore, we monkey patch Module.collect to add the scoped fixture to the - # module before it runs the actual collection. - def _patched_collect(): - # If the collected module is a DoctestTextfile, collector.obj is None - module = collector.obj - if module is not None: - module.__pytest_asyncio_scoped_event_loop = scoped_event_loop - try: - package_loop = __package_loop_stack.pop() - module.__pytest_asyncio_package_scoped_event_loop = package_loop - except IndexError: - pass - return collector.__original_collect() - - collector.__original_collect = collector.collect # type: ignore[attr-defined] - collector.collect = _patched_collect # type: ignore[method-assign] - elif isinstance(collector, Class): - collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop - @contextlib.contextmanager def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[None]: @@ -971,21 +901,30 @@ def _retrieve_scope_root(item: Collector | Item, scope: str) -> Collector: raise pytest.UsageError(error_message) -@pytest.fixture( - scope="function", - name="_function_event_loop", -) -def _function_event_loop( - *args, # Function needs to accept "cls" when collected by pytest.Class - event_loop_policy, -) -> Iterator[asyncio.AbstractEventLoop]: - new_loop_policy = event_loop_policy - with ( - _temporary_event_loop_policy(new_loop_policy), - _provide_event_loop() as loop, - ): - asyncio.set_event_loop(loop) - yield loop +def _create_scoped_event_loop_fixture(scope: _ScopeName) -> Callable: + @pytest.fixture( + scope=scope, + name=f"_{scope}_event_loop", + ) + def _scoped_event_loop( + *args, # Function needs to accept "cls" when collected by pytest.Class + event_loop_policy, + ) -> Iterator[asyncio.AbstractEventLoop]: + new_loop_policy = event_loop_policy + with ( + _temporary_event_loop_policy(new_loop_policy), + _provide_event_loop() as loop, + ): + asyncio.set_event_loop(loop) + yield loop + + return _scoped_event_loop + + +for scope in Scope: + globals()[f"_{scope.value}_event_loop"] = _create_scoped_event_loop_fixture( + scope.value + ) @contextlib.contextmanager @@ -1004,16 +943,6 @@ def _provide_event_loop() -> Iterator[asyncio.AbstractEventLoop]: loop.close() -@pytest.fixture(scope="session") -def _session_event_loop( - request: FixtureRequest, event_loop_policy: AbstractEventLoopPolicy -) -> Iterator[asyncio.AbstractEventLoop]: - new_loop_policy = event_loop_policy - with _temporary_event_loop_policy(new_loop_policy), _provide_event_loop() as loop: - asyncio.set_event_loop(loop) - yield loop - - @pytest.fixture(scope="session", autouse=True) def event_loop_policy() -> AbstractEventLoopPolicy: """Return an instance of the policy used to create asyncio event loops."""