Skip to content

Commit b9bed8b

Browse files
committed
[feat] Class-scoped and module-scoped event loops no longer override the function-scoped event_loop fixture.
They rather provide a fixture with a different name, based on the nodeid of the pytest.Collector that has the "asyncio_event_loop" mark. When a test requests the event_loop fixture and a dynamically generated event loop with class or module scope, pytest-asyncio will raise an error. Signed-off-by: Michael Seifert <[email protected]>
1 parent e344200 commit b9bed8b

File tree

3 files changed

+91
-2
lines changed

3 files changed

+91
-2
lines changed

pytest_asyncio/plugin.py

+47-2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
Parser,
3535
PytestPluginManager,
3636
Session,
37+
StashKey,
3738
)
3839

3940
_R = TypeVar("_R")
@@ -55,6 +56,14 @@
5556
SubRequest = Any
5657

5758

59+
class PytestAsyncioError(Exception):
60+
"""Base class for exceptions raised by pytest-asyncio"""
61+
62+
63+
class MultipleEventLoopsRequestedError(PytestAsyncioError):
64+
"""Raised when a test requests multiple asyncio event loops."""
65+
66+
5867
class Mode(str, enum.Enum):
5968
AUTO = "auto"
6069
STRICT = "strict"
@@ -345,6 +354,9 @@ def pytest_pycollect_makeitem(
345354
return None
346355

347356

357+
_event_loop_fixture_id = StashKey[str]
358+
359+
348360
@pytest.hookimpl
349361
def pytest_collectstart(collector: pytest.Collector):
350362
if not isinstance(collector, (pytest.Class, pytest.Module)):
@@ -356,9 +368,19 @@ def pytest_collectstart(collector: pytest.Collector):
356368
if not mark.name == "asyncio_event_loop":
357369
continue
358370

371+
# There seem to be issues when a fixture is shadowed by another fixture
372+
# and both differ in their params.
373+
# https://github.com/pytest-dev/pytest/issues/2043
374+
# https://github.com/pytest-dev/pytest/issues/11350
375+
# As such, we assign a unique name for each event_loop fixture.
376+
# The fixture name is stored in the collector's Stash, so it can
377+
# be injected when setting up the test
378+
event_loop_fixture_id = f"{collector.nodeid}::<event_loop>"
379+
collector.stash[_event_loop_fixture_id] = event_loop_fixture_id
380+
359381
@pytest.fixture(
360382
scope="class" if isinstance(collector, pytest.Class) else "module",
361-
name="event_loop",
383+
name=event_loop_fixture_id,
362384
)
363385
def scoped_event_loop(
364386
*args, # Function needs to accept "cls" when collected by pytest.Class
@@ -569,15 +591,38 @@ def inner(*args, **kwargs):
569591
return inner
570592

571593

594+
_MULTIPLE_LOOPS_REQUESTED_ERROR = dedent(
595+
"""\
596+
Multiple asyncio event loops with different scopes have been requested
597+
by %s. The test explicitly requests the event_loop fixture, while another
598+
event loop is provided by %s.
599+
Remove "event_loop" from the requested fixture in your test to run the test
600+
in a larger-scoped event loop or remove the "asyncio_event_loop" mark to run
601+
the test in a function-scoped event loop.
602+
"""
603+
)
604+
605+
572606
def pytest_runtest_setup(item: pytest.Item) -> None:
573607
marker = item.get_closest_marker("asyncio")
574608
if marker is None:
575609
return
610+
event_loop_fixture_id = "event_loop"
611+
for node, mark in item.iter_markers_with_node("asyncio_event_loop"):
612+
scoped_event_loop_provider_node = node
613+
event_loop_fixture_id = node.stash.get(_event_loop_fixture_id, None)
614+
if event_loop_fixture_id:
615+
break
576616
fixturenames = item.fixturenames # type: ignore[attr-defined]
577617
# inject an event loop fixture for all async tests
578618
if "event_loop" in fixturenames:
619+
if event_loop_fixture_id != "event_loop":
620+
raise MultipleEventLoopsRequestedError(
621+
_MULTIPLE_LOOPS_REQUESTED_ERROR
622+
% (item.nodeid, scoped_event_loop_provider_node.nodeid),
623+
)
579624
fixturenames.remove("event_loop")
580-
fixturenames.insert(0, "event_loop")
625+
fixturenames.insert(0, event_loop_fixture_id)
581626
obj = getattr(item, "obj", None)
582627
if not getattr(obj, "hypothesis", False) and getattr(
583628
obj, "is_hypothesis_test", False

tests/markers/test_class_marker.py

+22
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,25 @@ async def test_this_runs_in_same_loop(self):
104104
)
105105
result = pytester.runpytest("--asyncio-mode=strict")
106106
result.assert_outcomes(passed=2)
107+
108+
109+
def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop(
110+
pytester: pytest.Pytester,
111+
):
112+
pytester.makepyfile(
113+
dedent(
114+
"""\
115+
import asyncio
116+
import pytest
117+
118+
@pytest.mark.asyncio_event_loop
119+
class TestClassScopedLoop:
120+
@pytest.mark.asyncio
121+
async def test_remember_loop(self, event_loop):
122+
pass
123+
"""
124+
)
125+
)
126+
result = pytester.runpytest("--asyncio-mode=strict")
127+
result.assert_outcomes(errors=1)
128+
result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *")

tests/markers/test_module_marker.py

+22
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,25 @@ async def test_this_runs_in_same_loop(self):
113113
)
114114
result = pytester.runpytest("--asyncio-mode=auto")
115115
result.assert_outcomes(passed=3)
116+
117+
118+
def test_raise_when_event_loop_fixture_is_requested_in_addition_to_scoped_loop(
119+
pytester: Pytester,
120+
):
121+
pytester.makepyfile(
122+
dedent(
123+
"""\
124+
import asyncio
125+
import pytest
126+
127+
pytestmark = pytest.mark.asyncio_event_loop
128+
129+
@pytest.mark.asyncio
130+
async def test_remember_loop(event_loop):
131+
pass
132+
"""
133+
)
134+
)
135+
result = pytester.runpytest("--asyncio-mode=strict")
136+
result.assert_outcomes(errors=1)
137+
result.stdout.fnmatch_lines("*MultipleEventLoopsRequestedError: *")

0 commit comments

Comments
 (0)