Skip to content

Commit adfd7fd

Browse files
committed
Fix fixtures seeing the wrong event loop
1 parent 7499c3d commit adfd7fd

File tree

4 files changed

+230
-2
lines changed

4 files changed

+230
-2
lines changed

changelog.d/1373.fixed.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Fixed ``pytest_asyncio_loop_factories`` not installing the custom event loop as the current loop, and async fixture teardown/cache invalidation not being tied to the runner lifecycle.
1+
Fixed ``pytest_asyncio_loop_factories`` not installing the custom event loop as the current loop, and async fixture teardown/cache invalidation not being tied to the runner lifecycle, and sync ``@pytest_asyncio.fixture`` seeing the wrong event loop when multiple loop scopes are active.

pytest_asyncio/plugin.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,8 +328,50 @@ def _fixture_synchronizer(
328328
return _wrap_asyncgen_fixture(fixture_function, runner, request) # type: ignore[arg-type]
329329
elif inspect.iscoroutinefunction(fixturedef.func):
330330
return _wrap_async_fixture(fixture_function, runner, request) # type: ignore[arg-type]
331+
elif inspect.isgeneratorfunction(fixturedef.func):
332+
return _wrap_syncgen_fixture(fixture_function, runner) # type: ignore[arg-type]
331333
else:
332-
return fixturedef.func
334+
return _wrap_sync_fixture(fixture_function, runner) # type: ignore[arg-type]
335+
336+
337+
SyncGenFixtureParams = ParamSpec("SyncGenFixtureParams")
338+
SyncGenFixtureYieldType = TypeVar("SyncGenFixtureYieldType")
339+
340+
341+
def _wrap_syncgen_fixture(
342+
fixture_function: Callable[
343+
SyncGenFixtureParams, Generator[SyncGenFixtureYieldType]
344+
],
345+
runner: Runner,
346+
) -> Callable[SyncGenFixtureParams, Generator[SyncGenFixtureYieldType]]:
347+
@functools.wraps(fixture_function)
348+
def _syncgen_fixture_wrapper(
349+
*args: SyncGenFixtureParams.args,
350+
**kwargs: SyncGenFixtureParams.kwargs,
351+
) -> Generator[SyncGenFixtureYieldType]:
352+
with _temporary_event_loop(runner.get_loop()):
353+
yield from fixture_function(*args, **kwargs)
354+
355+
return _syncgen_fixture_wrapper
356+
357+
358+
SyncFixtureParams = ParamSpec("SyncFixtureParams")
359+
SyncFixtureReturnType = TypeVar("SyncFixtureReturnType")
360+
361+
362+
def _wrap_sync_fixture(
363+
fixture_function: Callable[SyncFixtureParams, SyncFixtureReturnType],
364+
runner: Runner,
365+
) -> Callable[SyncFixtureParams, SyncFixtureReturnType]:
366+
@functools.wraps(fixture_function)
367+
def _sync_fixture_wrapper(
368+
*args: SyncFixtureParams.args,
369+
**kwargs: SyncFixtureParams.kwargs,
370+
) -> SyncFixtureReturnType:
371+
with _temporary_event_loop(runner.get_loop()):
372+
return fixture_function(*args, **kwargs)
373+
374+
return _sync_fixture_wrapper
333375

334376

335377
AsyncGenFixtureParams = ParamSpec("AsyncGenFixtureParams")
@@ -729,6 +771,22 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
729771
)
730772

731773

774+
@contextlib.contextmanager
775+
def _temporary_event_loop(loop: AbstractEventLoop) -> Iterator[None]:
776+
try:
777+
old_loop = _get_event_loop_no_warn()
778+
except RuntimeError:
779+
old_loop = None
780+
if old_loop is loop:
781+
yield
782+
return
783+
_set_event_loop(loop)
784+
try:
785+
yield
786+
finally:
787+
_set_event_loop(old_loop)
788+
789+
732790
@contextlib.contextmanager
733791
def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[None]:
734792
old_loop_policy = _get_event_loop_policy()

tests/test_loop_factory_parametrization.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,119 @@ async def test_debug_mode_visible():
605605
result.assert_outcomes(passed=1)
606606

607607

608+
@pytest.mark.parametrize(
609+
("fixture_scope", "wider_scope"),
610+
[
611+
("function", "module"),
612+
("function", "package"),
613+
("function", "session"),
614+
("module", "session"),
615+
("package", "session"),
616+
],
617+
)
618+
def test_sync_fixture_sees_its_own_loop_when_wider_scoped_loop_active(
619+
pytester: Pytester,
620+
fixture_scope: str,
621+
wider_scope: str,
622+
) -> None:
623+
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
624+
pytester.makeconftest(dedent(f"""\
625+
import asyncio
626+
import pytest_asyncio
627+
628+
class CustomEventLoop(asyncio.SelectorEventLoop):
629+
pass
630+
631+
def pytest_asyncio_loop_factories(config, item):
632+
return {{"custom": CustomEventLoop}}
633+
634+
@pytest_asyncio.fixture(
635+
autouse=True,
636+
scope="{wider_scope}",
637+
loop_scope="{wider_scope}",
638+
)
639+
async def wider_scoped_fixture():
640+
yield
641+
642+
@pytest_asyncio.fixture(
643+
autouse=True,
644+
scope="{fixture_scope}",
645+
loop_scope="{fixture_scope}",
646+
)
647+
def sync_fixture_captures_loop():
648+
return id(asyncio.get_event_loop())
649+
"""))
650+
pytester.makepyfile(dedent(f"""\
651+
import asyncio
652+
import pytest
653+
654+
pytest_plugins = "pytest_asyncio"
655+
656+
@pytest.mark.asyncio(loop_scope="{fixture_scope}")
657+
async def test_sync_fixture_and_test_see_same_loop(sync_fixture_captures_loop):
658+
assert sync_fixture_captures_loop == id(asyncio.get_running_loop())
659+
"""))
660+
result = pytester.runpytest("--asyncio-mode=strict")
661+
result.assert_outcomes(passed=1)
662+
663+
664+
@pytest.mark.parametrize(
665+
("fixture_scope", "wider_scope"),
666+
[
667+
("function", "module"),
668+
("function", "session"),
669+
("module", "session"),
670+
],
671+
)
672+
def test_sync_generator_fixture_teardown_sees_own_loop(
673+
pytester: Pytester,
674+
fixture_scope: str,
675+
wider_scope: str,
676+
) -> None:
677+
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
678+
pytester.makeconftest(dedent(f"""\
679+
import asyncio
680+
import pytest_asyncio
681+
682+
class CustomEventLoop(asyncio.SelectorEventLoop):
683+
pass
684+
685+
def pytest_asyncio_loop_factories(config, item):
686+
return {{"custom": CustomEventLoop}}
687+
688+
@pytest_asyncio.fixture(
689+
autouse=True,
690+
scope="{wider_scope}",
691+
loop_scope="{wider_scope}",
692+
)
693+
async def wider_scoped_fixture():
694+
yield
695+
696+
@pytest_asyncio.fixture(
697+
autouse=True,
698+
scope="{fixture_scope}",
699+
loop_scope="{fixture_scope}",
700+
)
701+
def sync_generator_fixture():
702+
loop_at_setup = id(asyncio.get_event_loop())
703+
yield loop_at_setup
704+
loop_at_teardown = id(asyncio.get_event_loop())
705+
assert loop_at_setup == loop_at_teardown
706+
"""))
707+
pytester.makepyfile(dedent(f"""\
708+
import asyncio
709+
import pytest
710+
711+
pytest_plugins = "pytest_asyncio"
712+
713+
@pytest.mark.asyncio(loop_scope="{fixture_scope}")
714+
async def test_generator_fixture_sees_correct_loop(sync_generator_fixture):
715+
assert sync_generator_fixture == id(asyncio.get_running_loop())
716+
"""))
717+
result = pytester.runpytest("--asyncio-mode=strict")
718+
result.assert_outcomes(passed=1)
719+
720+
608721
@pytest.mark.parametrize("loop_scope", ("module", "package", "session"))
609722
def test_async_generator_fixture_teardown_runs_under_custom_factory(
610723
pytester: Pytester,

tests/test_set_event_loop.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,3 +329,60 @@ async def test_after_second(second_webserver):
329329
"""))
330330
result = pytester.runpytest("--asyncio-mode=strict")
331331
result.assert_outcomes(passed=5)
332+
333+
334+
@pytest.mark.parametrize("test_loop_scope", ("module", "package", "session"))
335+
@pytest.mark.parametrize(
336+
"loop_breaking_action",
337+
[
338+
"asyncio.set_event_loop(None)",
339+
"asyncio.run(asyncio.sleep(0))",
340+
pytest.param(
341+
"with asyncio.Runner(): pass",
342+
marks=pytest.mark.skipif(
343+
sys.version_info < (3, 11),
344+
reason="asyncio.Runner requires Python 3.11+",
345+
),
346+
),
347+
],
348+
)
349+
def test_sync_fixture_sees_correct_loop_after_loop_broken_with_factory(
350+
pytester: Pytester,
351+
test_loop_scope: str,
352+
loop_breaking_action: str,
353+
):
354+
pytester.makeini(dedent(f"""\
355+
[pytest]
356+
asyncio_default_test_loop_scope = {test_loop_scope}
357+
asyncio_default_fixture_loop_scope = function
358+
"""))
359+
pytester.makepyfile(dedent(f"""\
360+
import asyncio
361+
import pytest
362+
import pytest_asyncio
363+
364+
pytest_plugins = "pytest_asyncio"
365+
366+
class CustomEventLoop(asyncio.SelectorEventLoop):
367+
pass
368+
369+
def pytest_asyncio_loop_factories(config, item):
370+
return {{"custom": CustomEventLoop}}
371+
372+
@pytest.mark.asyncio
373+
async def test_before():
374+
pass
375+
376+
def test_break_event_loop():
377+
{loop_breaking_action}
378+
379+
@pytest_asyncio.fixture(loop_scope="{test_loop_scope}")
380+
def sync_fixture_loop_id():
381+
return id(asyncio.get_event_loop())
382+
383+
@pytest.mark.asyncio
384+
async def test_sync_fixture_sees_correct_loop(sync_fixture_loop_id):
385+
assert sync_fixture_loop_id == id(asyncio.get_running_loop())
386+
"""))
387+
result = pytester.runpytest_subprocess()
388+
result.assert_outcomes(passed=3)

0 commit comments

Comments
 (0)