Skip to content

Commit a999328

Browse files
committed
[feat] Fixtures and tests sharing the same asyncio_event_loop mark are executed in the same event loop.
Signed-off-by: Michael Seifert <[email protected]>
1 parent 53ff025 commit a999328

File tree

4 files changed

+125
-21
lines changed

4 files changed

+125
-21
lines changed

docs/source/reference/markers.rst

+24
Original file line numberDiff line numberDiff line change
@@ -152,5 +152,29 @@ The ``policy`` keyword argument may also take an iterable of event loop policies
152152
153153
If no explicit policy is provided, the mark will use the loop policy returned by ``asyncio.get_event_loop_policy()``.
154154

155+
Fixtures and tests sharing the same `asyncio_event_loop` mark are executed in the same event loop:
156+
157+
.. code-block:: python
158+
159+
import asyncio
160+
161+
import pytest
162+
163+
import pytest_asyncio
164+
165+
166+
@pytest.mark.asyncio_event_loop
167+
class TestClassScopedLoop:
168+
loop: asyncio.AbstractEventLoop
169+
170+
@pytest_asyncio.fixture
171+
async def my_fixture(self):
172+
TestClassScopedLoop.loop = asyncio.get_running_loop()
173+
174+
@pytest.mark.asyncio
175+
async def test_runs_is_same_loop_as_fixture(self, my_fixture):
176+
assert asyncio.get_running_loop() is TestClassScopedLoop.loop
177+
178+
155179
.. |pytestmark| replace:: ``pytestmark``
156180
.. _pytestmark: http://doc.pytest.org/en/latest/example/markers.html#marking-whole-classes-or-modules

pytest_asyncio/plugin.py

+41-21
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import pytest
2828
from _pytest.mark.structures import get_unpacked_marks
2929
from pytest import (
30+
Collector,
3031
Config,
3132
FixtureRequest,
3233
Function,
@@ -202,11 +203,17 @@ def pytest_report_header(config: Config) -> List[str]:
202203

203204

204205
def _preprocess_async_fixtures(
205-
config: Config,
206+
collector: Collector,
206207
processed_fixturedefs: Set[FixtureDef],
207208
) -> None:
209+
config = collector.config
208210
asyncio_mode = _get_asyncio_mode(config)
209211
fixturemanager = config.pluginmanager.get_plugin("funcmanage")
212+
event_loop_fixture_id = "event_loop"
213+
for node, mark in collector.iter_markers_with_node("asyncio_event_loop"):
214+
event_loop_fixture_id = node.stash.get(_event_loop_fixture_id, None)
215+
if event_loop_fixture_id:
216+
break
210217
for fixtures in fixturemanager._arg2fixturedefs.values():
211218
for fixturedef in fixtures:
212219
func = fixturedef.func
@@ -219,46 +226,51 @@ def _preprocess_async_fixtures(
219226
# This applies to pytest_trio fixtures, for example
220227
continue
221228
_make_asyncio_fixture_function(func)
222-
_inject_fixture_argnames(fixturedef)
223-
_synchronize_async_fixture(fixturedef)
229+
_inject_fixture_argnames(fixturedef, event_loop_fixture_id)
230+
_synchronize_async_fixture(fixturedef, event_loop_fixture_id)
224231
assert _is_asyncio_fixture_function(fixturedef.func)
225232
processed_fixturedefs.add(fixturedef)
226233

227234

228-
def _inject_fixture_argnames(fixturedef: FixtureDef) -> None:
235+
def _inject_fixture_argnames(
236+
fixturedef: FixtureDef, event_loop_fixture_id: str
237+
) -> None:
229238
"""
230239
Ensures that `request` and `event_loop` are arguments of the specified fixture.
231240
"""
232241
to_add = []
233-
for name in ("request", "event_loop"):
242+
for name in ("request", event_loop_fixture_id):
234243
if name not in fixturedef.argnames:
235244
to_add.append(name)
236245
if to_add:
237246
fixturedef.argnames += tuple(to_add)
238247

239248

240-
def _synchronize_async_fixture(fixturedef: FixtureDef) -> None:
249+
def _synchronize_async_fixture(
250+
fixturedef: FixtureDef, event_loop_fixture_id: str
251+
) -> None:
241252
"""
242253
Wraps the fixture function of an async fixture in a synchronous function.
243254
"""
244255
if inspect.isasyncgenfunction(fixturedef.func):
245-
_wrap_asyncgen_fixture(fixturedef)
256+
_wrap_asyncgen_fixture(fixturedef, event_loop_fixture_id)
246257
elif inspect.iscoroutinefunction(fixturedef.func):
247-
_wrap_async_fixture(fixturedef)
258+
_wrap_async_fixture(fixturedef, event_loop_fixture_id)
248259

249260

250261
def _add_kwargs(
251262
func: Callable[..., Any],
252263
kwargs: Dict[str, Any],
264+
event_loop_fixture_id: str,
253265
event_loop: asyncio.AbstractEventLoop,
254266
request: SubRequest,
255267
) -> Dict[str, Any]:
256268
sig = inspect.signature(func)
257269
ret = kwargs.copy()
258270
if "request" in sig.parameters:
259271
ret["request"] = request
260-
if "event_loop" in sig.parameters:
261-
ret["event_loop"] = event_loop
272+
if event_loop_fixture_id in sig.parameters:
273+
ret[event_loop_fixture_id] = event_loop
262274
return ret
263275

264276

@@ -281,17 +293,18 @@ def _perhaps_rebind_fixture_func(
281293
return func
282294

283295

284-
def _wrap_asyncgen_fixture(fixturedef: FixtureDef) -> None:
296+
def _wrap_asyncgen_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None:
285297
fixture = fixturedef.func
286298

287299
@functools.wraps(fixture)
288-
def _asyncgen_fixture_wrapper(
289-
event_loop: asyncio.AbstractEventLoop, request: SubRequest, **kwargs: Any
290-
):
300+
def _asyncgen_fixture_wrapper(request: SubRequest, **kwargs: Any):
291301
func = _perhaps_rebind_fixture_func(
292302
fixture, request.instance, fixturedef.unittest
293303
)
294-
gen_obj = func(**_add_kwargs(func, kwargs, event_loop, request))
304+
event_loop = kwargs.pop(event_loop_fixture_id)
305+
gen_obj = func(
306+
**_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request)
307+
)
295308

296309
async def setup():
297310
res = await gen_obj.__anext__()
@@ -319,19 +332,20 @@ async def async_finalizer() -> None:
319332
fixturedef.func = _asyncgen_fixture_wrapper
320333

321334

322-
def _wrap_async_fixture(fixturedef: FixtureDef) -> None:
335+
def _wrap_async_fixture(fixturedef: FixtureDef, event_loop_fixture_id: str) -> None:
323336
fixture = fixturedef.func
324337

325338
@functools.wraps(fixture)
326-
def _async_fixture_wrapper(
327-
event_loop: asyncio.AbstractEventLoop, request: SubRequest, **kwargs: Any
328-
):
339+
def _async_fixture_wrapper(request: SubRequest, **kwargs: Any):
329340
func = _perhaps_rebind_fixture_func(
330341
fixture, request.instance, fixturedef.unittest
331342
)
343+
event_loop = kwargs.pop(event_loop_fixture_id)
332344

333345
async def setup():
334-
res = await func(**_add_kwargs(func, kwargs, event_loop, request))
346+
res = await func(
347+
**_add_kwargs(func, kwargs, event_loop_fixture_id, event_loop, request)
348+
)
335349
return res
336350

337351
return event_loop.run_until_complete(setup())
@@ -351,7 +365,7 @@ def pytest_pycollect_makeitem(
351365
"""A pytest hook to collect asyncio coroutines."""
352366
if not collector.funcnamefilter(name):
353367
return None
354-
_preprocess_async_fixtures(collector.config, _HOLDER)
368+
_preprocess_async_fixtures(collector, _HOLDER)
355369
return None
356370

357371

@@ -456,6 +470,12 @@ def pytest_generate_tests(metafunc: Metafunc) -> None:
456470
_event_loop_fixture_id, None
457471
)
458472
if event_loop_fixture_id:
473+
# This specific fixture name may already be in metafunc.argnames, if this
474+
# test indirectly depends on the fixture. For example, this is the case
475+
# when the test depends on an async fixture, both of which share the same
476+
# asyncio_event_loop mark.
477+
if event_loop_fixture_id in metafunc.fixturenames:
478+
continue
459479
fixturemanager = metafunc.config.pluginmanager.get_plugin("funcmanage")
460480
if "event_loop" in metafunc.fixturenames:
461481
raise MultipleEventLoopsRequestedError(

tests/markers/test_class_marker.py

+29
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,32 @@ async def test_parametrized_loop(self):
188188
)
189189
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
190190
result.assert_outcomes(passed=2)
191+
192+
193+
def test_asyncio_event_loop_mark_provides_class_scoped_loop_to_fixtures(
194+
pytester: pytest.Pytester,
195+
):
196+
pytester.makepyfile(
197+
dedent(
198+
"""\
199+
import asyncio
200+
201+
import pytest
202+
import pytest_asyncio
203+
204+
@pytest.mark.asyncio_event_loop
205+
class TestClassScopedLoop:
206+
loop: asyncio.AbstractEventLoop
207+
208+
@pytest_asyncio.fixture
209+
async def my_fixture(self):
210+
TestClassScopedLoop.loop = asyncio.get_running_loop()
211+
212+
@pytest.mark.asyncio
213+
async def test_runs_is_same_loop_as_fixture(self, my_fixture):
214+
assert asyncio.get_running_loop() is TestClassScopedLoop.loop
215+
"""
216+
)
217+
)
218+
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
219+
result.assert_outcomes(passed=1)

tests/markers/test_module_marker.py

+31
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,34 @@ async def test_parametrized_loop():
212212
)
213213
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
214214
result.assert_outcomes(passed=2)
215+
216+
217+
def test_asyncio_event_loop_mark_provides_module_scoped_loop_to_fixtures(
218+
pytester: Pytester,
219+
):
220+
pytester.makepyfile(
221+
dedent(
222+
"""\
223+
import asyncio
224+
225+
import pytest
226+
import pytest_asyncio
227+
228+
pytestmark = pytest.mark.asyncio_event_loop
229+
230+
loop: asyncio.AbstractEventLoop
231+
232+
@pytest_asyncio.fixture
233+
async def my_fixture():
234+
global loop
235+
loop = asyncio.get_running_loop()
236+
237+
@pytest.mark.asyncio
238+
async def test_runs_is_same_loop_as_fixture(my_fixture):
239+
global loop
240+
assert asyncio.get_running_loop() is loop
241+
"""
242+
)
243+
)
244+
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
245+
result.assert_outcomes(passed=1)

0 commit comments

Comments
 (0)