Skip to content

Commit 7499c3d

Browse files
committed
Fix loop factory lifecycle
1 parent b6f574c commit 7499c3d

File tree

3 files changed

+124
-1
lines changed

3 files changed

+124
-1
lines changed

changelog.d/1373.fixed.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +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.

pytest_asyncio/plugin.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,7 +498,15 @@ def _can_substitute(item: Function) -> bool:
498498

499499
def setup(self) -> None:
500500
runner_fixture_id = f"_{self._loop_scope}_scoped_runner"
501-
if runner_fixture_id not in self.fixturenames:
501+
if runner_fixture_id in self.fixturenames:
502+
return super().setup()
503+
# The runner must be resolved before async fixtures when loop
504+
# factories are configured. Otherwise, the async fixtures see a
505+
# stale loop from the previous factory.
506+
hook_caller = self.config.hook.pytest_asyncio_loop_factories
507+
if hook_caller.get_hookimpls():
508+
self.fixturenames.insert(0, runner_fixture_id)
509+
else:
502510
self.fixturenames.append(runner_fixture_id)
503511
return super().setup()
504512

@@ -846,6 +854,11 @@ def pytest_fixture_setup(fixturedef: FixtureDef, request) -> object | None:
846854
)
847855
runner_fixture_id = f"_{loop_scope}_scoped_runner"
848856
runner = request.getfixturevalue(runner_fixture_id)
857+
# Prevent the runner closing before the fixture's async teardown.
858+
runner_fixturedef = request._get_active_fixturedef(runner_fixture_id)
859+
runner_fixturedef.addfinalizer(
860+
functools.partial(fixturedef.finish, request=request)
861+
)
849862
synchronizer = _fixture_synchronizer(fixturedef, runner, request)
850863
_make_asyncio_fixture_function(synchronizer, loop_scope)
851864
with MonkeyPatch.context() as c:
@@ -940,6 +953,8 @@ def _scoped_runner(
940953
debug=debug_mode,
941954
loop_factory=_asyncio_loop_factory,
942955
).__enter__()
956+
if _asyncio_loop_factory is not None:
957+
_set_event_loop(runner.get_loop())
943958
try:
944959
yield runner
945960
except Exception as e:

tests/test_loop_factory_parametrization.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,3 +571,110 @@ async def test_b():
571571
"""))
572572
result = pytester.runpytest("--asyncio-mode=strict")
573573
result.assert_outcomes(passed=2)
574+
575+
576+
def test_sync_fixture_sees_same_loop_as_async_test_under_custom_factory(
577+
pytester: Pytester,
578+
) -> None:
579+
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
580+
pytester.makeconftest(dedent("""\
581+
import asyncio
582+
583+
class CustomEventLoop(asyncio.SelectorEventLoop):
584+
pass
585+
586+
def pytest_asyncio_loop_factories(config, item):
587+
return {"custom": CustomEventLoop}
588+
"""))
589+
pytester.makepyfile(dedent("""\
590+
import asyncio
591+
import pytest
592+
import pytest_asyncio
593+
594+
pytest_plugins = "pytest_asyncio"
595+
596+
@pytest_asyncio.fixture(autouse=True)
597+
def enable_debug_on_event_loop():
598+
asyncio.get_event_loop().set_debug(True)
599+
600+
@pytest.mark.asyncio
601+
async def test_debug_mode_visible():
602+
assert asyncio.get_running_loop().get_debug()
603+
"""))
604+
result = pytester.runpytest("--asyncio-mode=strict")
605+
result.assert_outcomes(passed=1)
606+
607+
608+
@pytest.mark.parametrize("loop_scope", ("module", "package", "session"))
609+
def test_async_generator_fixture_teardown_runs_under_custom_factory(
610+
pytester: Pytester,
611+
loop_scope: str,
612+
) -> None:
613+
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
614+
pytester.makeconftest(dedent(f"""\
615+
import asyncio
616+
import pytest_asyncio
617+
618+
class CustomEventLoop(asyncio.SelectorEventLoop):
619+
pass
620+
621+
def pytest_asyncio_loop_factories(config, item):
622+
return {{"custom": CustomEventLoop}}
623+
624+
@pytest_asyncio.fixture(
625+
autouse=True, scope="{loop_scope}", loop_scope="{loop_scope}"
626+
)
627+
async def fixture_with_teardown():
628+
yield
629+
print("TEARDOWN_EXECUTED")
630+
"""))
631+
pytester.makepyfile(dedent(f"""\
632+
import pytest
633+
634+
pytest_plugins = "pytest_asyncio"
635+
636+
@pytest.mark.asyncio(loop_scope="{loop_scope}")
637+
async def test_passes():
638+
assert True
639+
"""))
640+
result = pytester.runpytest("--asyncio-mode=strict", "-s")
641+
result.assert_outcomes(passed=1)
642+
result.stdout.fnmatch_lines(["*TEARDOWN_EXECUTED*"])
643+
644+
645+
@pytest.mark.parametrize("loop_scope", ("module", "package", "session"))
646+
def test_async_fixture_recreated_per_loop_factory_variant(
647+
pytester: Pytester,
648+
loop_scope: str,
649+
) -> None:
650+
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
651+
pytester.makeconftest(dedent(f"""\
652+
import asyncio
653+
import pytest_asyncio
654+
655+
class CustomLoopA(asyncio.SelectorEventLoop):
656+
pass
657+
658+
class CustomLoopB(asyncio.SelectorEventLoop):
659+
pass
660+
661+
def pytest_asyncio_loop_factories(config, item):
662+
return {{"loop_a": CustomLoopA, "loop_b": CustomLoopB}}
663+
664+
@pytest_asyncio.fixture(scope="{loop_scope}", loop_scope="{loop_scope}")
665+
async def fixture_loop_type():
666+
return type(asyncio.get_running_loop()).__name__
667+
"""))
668+
pytester.makepyfile(dedent(f"""\
669+
import asyncio
670+
import pytest
671+
672+
pytest_plugins = "pytest_asyncio"
673+
674+
@pytest.mark.asyncio(loop_scope="{loop_scope}")
675+
async def test_fixture_matches_running_loop(fixture_loop_type):
676+
running_loop_type = type(asyncio.get_running_loop()).__name__
677+
assert fixture_loop_type == running_loop_type
678+
"""))
679+
result = pytester.runpytest("--asyncio-mode=strict", "-v")
680+
result.assert_outcomes(passed=2)

0 commit comments

Comments
 (0)