Skip to content

Commit 0cd4cfa

Browse files
committed
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.
1 parent 7c8b393 commit 0cd4cfa

File tree

2 files changed

+27
-97
lines changed

2 files changed

+27
-97
lines changed

changelog.d/+d874f4f1.changed.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

pytest_asyncio/plugin.py

+26-97
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333

3434
import pluggy
3535
import pytest
36+
from _pytest.scope import Scope
3637
from pytest import (
3738
Class,
3839
Collector,
@@ -657,10 +658,6 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass(
657658
Session: "session",
658659
}
659660

660-
# A stack used to push package-scoped loops during collection of a package
661-
# and pop those loops during collection of a Module
662-
__package_loop_stack: list[Callable[..., Any]] = []
663-
664661

665662
@pytest.hookimpl
666663
def pytest_collectstart(collector: pytest.Collector) -> None:
@@ -672,76 +669,9 @@ def pytest_collectstart(collector: pytest.Collector) -> None:
672669
)
673670
except StopIteration:
674671
return
675-
# Session is not a PyCollector type, so it doesn't have a corresponding
676-
# "obj" attribute to attach a dynamic fixture function to.
677-
# However, there's only one session per pytest run, so there's no need to
678-
# create the fixture dynamically. We can simply define a session-scoped
679-
# event loop fixture once in the plugin code.
680-
if collector_scope == "session":
681-
event_loop_fixture_id = _session_event_loop.__name__
682-
collector.stash[_event_loop_fixture_id] = event_loop_fixture_id
683-
return
684-
# There seem to be issues when a fixture is shadowed by another fixture
685-
# and both differ in their params.
686-
# https://github.com/pytest-dev/pytest/issues/2043
687-
# https://github.com/pytest-dev/pytest/issues/11350
688-
# As such, we assign a unique name for each event_loop fixture.
689-
# The fixture name is stored in the collector's Stash, so it can
690-
# be injected when setting up the test
691-
event_loop_fixture_id = f"{collector.nodeid}::<event_loop>"
672+
event_loop_fixture_id = f"_{collector_scope}_event_loop"
692673
collector.stash[_event_loop_fixture_id] = event_loop_fixture_id
693674

694-
@pytest.fixture(
695-
scope=collector_scope,
696-
name=event_loop_fixture_id,
697-
)
698-
def scoped_event_loop(
699-
*args, # Function needs to accept "cls" when collected by pytest.Class
700-
event_loop_policy,
701-
) -> Iterator[asyncio.AbstractEventLoop]:
702-
new_loop_policy = event_loop_policy
703-
with (
704-
_temporary_event_loop_policy(new_loop_policy),
705-
_provide_event_loop() as loop,
706-
):
707-
asyncio.set_event_loop(loop)
708-
yield loop
709-
710-
# @pytest.fixture does not register the fixture anywhere, so pytest doesn't
711-
# know it exists. We work around this by attaching the fixture function to the
712-
# collected Python object, where it will be picked up by pytest.Class.collect()
713-
# or pytest.Module.collect(), respectively
714-
if type(collector) is Package:
715-
# Packages do not have a corresponding Python object. Therefore, the fixture
716-
# for the package-scoped event loop is added to a stack. When a module inside
717-
# the package is collected, the module will attach the fixture to its
718-
# Python object.
719-
__package_loop_stack.append(scoped_event_loop)
720-
elif isinstance(collector, Module):
721-
# Accessing Module.obj triggers a module import executing module-level
722-
# statements. A module-level pytest.skip statement raises the "Skipped"
723-
# OutcomeException or a Collector.CollectError, if the "allow_module_level"
724-
# kwargs is missing. These cases are handled correctly when they happen inside
725-
# Collector.collect(), but this hook runs before the actual collect call.
726-
# Therefore, we monkey patch Module.collect to add the scoped fixture to the
727-
# module before it runs the actual collection.
728-
def _patched_collect():
729-
# If the collected module is a DoctestTextfile, collector.obj is None
730-
module = collector.obj
731-
if module is not None:
732-
module.__pytest_asyncio_scoped_event_loop = scoped_event_loop
733-
try:
734-
package_loop = __package_loop_stack.pop()
735-
module.__pytest_asyncio_package_scoped_event_loop = package_loop
736-
except IndexError:
737-
pass
738-
return collector.__original_collect()
739-
740-
collector.__original_collect = collector.collect # type: ignore[attr-defined]
741-
collector.collect = _patched_collect # type: ignore[method-assign]
742-
elif isinstance(collector, Class):
743-
collector.obj.__pytest_asyncio_scoped_event_loop = scoped_event_loop
744-
745675

746676
@contextlib.contextmanager
747677
def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[None]:
@@ -971,21 +901,30 @@ def _retrieve_scope_root(item: Collector | Item, scope: str) -> Collector:
971901
raise pytest.UsageError(error_message)
972902

973903

974-
@pytest.fixture(
975-
scope="function",
976-
name="_function_event_loop",
977-
)
978-
def _function_event_loop(
979-
*args, # Function needs to accept "cls" when collected by pytest.Class
980-
event_loop_policy,
981-
) -> Iterator[asyncio.AbstractEventLoop]:
982-
new_loop_policy = event_loop_policy
983-
with (
984-
_temporary_event_loop_policy(new_loop_policy),
985-
_provide_event_loop() as loop,
986-
):
987-
asyncio.set_event_loop(loop)
988-
yield loop
904+
def _create_scoped_event_loop_fixture(scope: _ScopeName) -> Callable:
905+
@pytest.fixture(
906+
scope=scope,
907+
name=f"_{scope}_event_loop",
908+
)
909+
def _scoped_event_loop(
910+
*args, # Function needs to accept "cls" when collected by pytest.Class
911+
event_loop_policy,
912+
) -> Iterator[asyncio.AbstractEventLoop]:
913+
new_loop_policy = event_loop_policy
914+
with (
915+
_temporary_event_loop_policy(new_loop_policy),
916+
_provide_event_loop() as loop,
917+
):
918+
asyncio.set_event_loop(loop)
919+
yield loop
920+
921+
return _scoped_event_loop
922+
923+
924+
for scope in Scope:
925+
globals()[f"_{scope.value}_event_loop"] = _create_scoped_event_loop_fixture(
926+
scope.value
927+
)
989928

990929

991930
@contextlib.contextmanager
@@ -1004,16 +943,6 @@ def _provide_event_loop() -> Iterator[asyncio.AbstractEventLoop]:
1004943
loop.close()
1005944

1006945

1007-
@pytest.fixture(scope="session")
1008-
def _session_event_loop(
1009-
request: FixtureRequest, event_loop_policy: AbstractEventLoopPolicy
1010-
) -> Iterator[asyncio.AbstractEventLoop]:
1011-
new_loop_policy = event_loop_policy
1012-
with _temporary_event_loop_policy(new_loop_policy), _provide_event_loop() as loop:
1013-
asyncio.set_event_loop(loop)
1014-
yield loop
1015-
1016-
1017946
@pytest.fixture(scope="session", autouse=True)
1018947
def event_loop_policy() -> AbstractEventLoopPolicy:
1019948
"""Return an instance of the policy used to create asyncio event loops."""

0 commit comments

Comments
 (0)