Skip to content

Commit 91b428e

Browse files
tjkusonseifertm
authored andcommitted
Fix fixtures seeing the wrong event loop
1 parent c6ce43c commit 91b428e

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
@@ -334,8 +334,50 @@ def _fixture_synchronizer(
334334
return _wrap_asyncgen_fixture(fixture_function, runner, request) # type: ignore[arg-type]
335335
elif inspect.iscoroutinefunction(fixturedef.func):
336336
return _wrap_async_fixture(fixture_function, runner, request) # type: ignore[arg-type]
337+
elif inspect.isgeneratorfunction(fixturedef.func):
338+
return _wrap_syncgen_fixture(fixture_function, runner) # type: ignore[arg-type]
337339
else:
338-
return fixturedef.func
340+
return _wrap_sync_fixture(fixture_function, runner) # type: ignore[arg-type]
341+
342+
343+
SyncGenFixtureParams = ParamSpec("SyncGenFixtureParams")
344+
SyncGenFixtureYieldType = TypeVar("SyncGenFixtureYieldType")
345+
346+
347+
def _wrap_syncgen_fixture(
348+
fixture_function: Callable[
349+
SyncGenFixtureParams, Generator[SyncGenFixtureYieldType]
350+
],
351+
runner: Runner,
352+
) -> Callable[SyncGenFixtureParams, Generator[SyncGenFixtureYieldType]]:
353+
@functools.wraps(fixture_function)
354+
def _syncgen_fixture_wrapper(
355+
*args: SyncGenFixtureParams.args,
356+
**kwargs: SyncGenFixtureParams.kwargs,
357+
) -> Generator[SyncGenFixtureYieldType]:
358+
with _temporary_event_loop(runner.get_loop()):
359+
yield from fixture_function(*args, **kwargs)
360+
361+
return _syncgen_fixture_wrapper
362+
363+
364+
SyncFixtureParams = ParamSpec("SyncFixtureParams")
365+
SyncFixtureReturnType = TypeVar("SyncFixtureReturnType")
366+
367+
368+
def _wrap_sync_fixture(
369+
fixture_function: Callable[SyncFixtureParams, SyncFixtureReturnType],
370+
runner: Runner,
371+
) -> Callable[SyncFixtureParams, SyncFixtureReturnType]:
372+
@functools.wraps(fixture_function)
373+
def _sync_fixture_wrapper(
374+
*args: SyncFixtureParams.args,
375+
**kwargs: SyncFixtureParams.kwargs,
376+
) -> SyncFixtureReturnType:
377+
with _temporary_event_loop(runner.get_loop()):
378+
return fixture_function(*args, **kwargs)
379+
380+
return _sync_fixture_wrapper
339381

340382

341383
AsyncGenFixtureParams = ParamSpec("AsyncGenFixtureParams")
@@ -735,6 +777,22 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
735777
)
736778

737779

780+
@contextlib.contextmanager
781+
def _temporary_event_loop(loop: AbstractEventLoop) -> Iterator[None]:
782+
try:
783+
old_loop = _get_event_loop_no_warn()
784+
except RuntimeError:
785+
old_loop = None
786+
if old_loop is loop:
787+
yield
788+
return
789+
_set_event_loop(loop)
790+
try:
791+
yield
792+
finally:
793+
_set_event_loop(old_loop)
794+
795+
738796
@contextlib.contextmanager
739797
def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[None]:
740798
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)