diff --git a/changelog.d/1373.fixed.rst b/changelog.d/1373.fixed.rst new file mode 100644 index 00000000..793b3b06 --- /dev/null +++ b/changelog.d/1373.fixed.rst @@ -0,0 +1 @@ +Fixed ``pytest_asyncio_loop_factories`` not installing the custom event loop as the current loop, async fixture teardown/cache invalidation not being tied to the runner lifecycle, sync ``@pytest_asyncio.fixture`` seeing the wrong event loop when multiple loop scopes are active, and an event loop leak on Python 3.10-3.13. diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index a69350bd..54f5e307 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -328,8 +328,50 @@ def _fixture_synchronizer( return _wrap_asyncgen_fixture(fixture_function, runner, request) # type: ignore[arg-type] elif inspect.iscoroutinefunction(fixturedef.func): return _wrap_async_fixture(fixture_function, runner, request) # type: ignore[arg-type] + elif inspect.isgeneratorfunction(fixturedef.func): + return _wrap_syncgen_fixture(fixture_function, runner) # type: ignore[arg-type] else: - return fixturedef.func + return _wrap_sync_fixture(fixture_function, runner) # type: ignore[arg-type] + + +SyncGenFixtureParams = ParamSpec("SyncGenFixtureParams") +SyncGenFixtureYieldType = TypeVar("SyncGenFixtureYieldType") + + +def _wrap_syncgen_fixture( + fixture_function: Callable[ + SyncGenFixtureParams, Generator[SyncGenFixtureYieldType] + ], + runner: Runner, +) -> Callable[SyncGenFixtureParams, Generator[SyncGenFixtureYieldType]]: + @functools.wraps(fixture_function) + def _syncgen_fixture_wrapper( + *args: SyncGenFixtureParams.args, + **kwargs: SyncGenFixtureParams.kwargs, + ) -> Generator[SyncGenFixtureYieldType]: + with _temporary_event_loop(runner.get_loop()): + yield from fixture_function(*args, **kwargs) + + return _syncgen_fixture_wrapper + + +SyncFixtureParams = ParamSpec("SyncFixtureParams") +SyncFixtureReturnType = TypeVar("SyncFixtureReturnType") + + +def _wrap_sync_fixture( + fixture_function: Callable[SyncFixtureParams, SyncFixtureReturnType], + runner: Runner, +) -> Callable[SyncFixtureParams, SyncFixtureReturnType]: + @functools.wraps(fixture_function) + def _sync_fixture_wrapper( + *args: SyncFixtureParams.args, + **kwargs: SyncFixtureParams.kwargs, + ) -> SyncFixtureReturnType: + with _temporary_event_loop(runner.get_loop()): + return fixture_function(*args, **kwargs) + + return _sync_fixture_wrapper AsyncGenFixtureParams = ParamSpec("AsyncGenFixtureParams") @@ -500,6 +542,12 @@ def setup(self) -> None: runner_fixture_id = f"_{self._loop_scope}_scoped_runner" if runner_fixture_id not in self.fixturenames: self.fixturenames.append(runner_fixture_id) + # When loop factories are configured, resolve the loop factory + # fixture early so that a factory variant change cascades cache + # invalidation before any async fixture checks its cache. + hook_caller = self.config.hook.pytest_asyncio_loop_factories + if hook_caller.get_hookimpls(): + _ = self._request.getfixturevalue(_asyncio_loop_factory.__name__) return super().setup() def runtest(self) -> None: @@ -712,22 +760,47 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: metafunc.fixturenames.append(_asyncio_loop_factory.__name__) default_loop_scope = _get_default_test_loop_scope(metafunc.config) loop_scope = marker_loop_scope or default_loop_scope + # pytest.HIDDEN_PARAM was added in pytest 8.4 + hide_id = len(effective_factories) == 1 and hasattr(pytest, "HIDDEN_PARAM") metafunc.parametrize( _asyncio_loop_factory.__name__, effective_factories.values(), - ids=effective_factories.keys(), + ids=(pytest.HIDDEN_PARAM,) if hide_id else effective_factories.keys(), indirect=True, scope=loop_scope, ) @contextlib.contextmanager -def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[None]: - old_loop_policy = _get_event_loop_policy() +def _temporary_event_loop(loop: AbstractEventLoop) -> Iterator[None]: try: old_loop = _get_event_loop_no_warn() except RuntimeError: old_loop = None + if old_loop is loop: + yield + return + _set_event_loop(loop) + try: + yield + finally: + _set_event_loop(old_loop) + + +@contextlib.contextmanager +def _temporary_event_loop_policy( + policy: AbstractEventLoopPolicy, + *, + has_custom_factory: bool, +) -> Iterator[None]: + old_loop_policy = _get_event_loop_policy() + if has_custom_factory: + old_loop = None + else: + try: + old_loop = _get_event_loop_no_warn() + except RuntimeError: + old_loop = None _set_event_loop_policy(policy) try: yield @@ -846,6 +919,11 @@ def pytest_fixture_setup(fixturedef: FixtureDef, request) -> object | None: ) runner_fixture_id = f"_{loop_scope}_scoped_runner" runner = request.getfixturevalue(runner_fixture_id) + # Prevent the runner closing before the fixture's async teardown. + runner_fixturedef = request._get_active_fixturedef(runner_fixture_id) + runner_fixturedef.addfinalizer( + functools.partial(fixturedef.finish, request=request) + ) synchronizer = _fixture_synchronizer(fixturedef, runner, request) _make_asyncio_fixture_function(synchronizer, loop_scope) with MonkeyPatch.context() as c: @@ -935,11 +1013,16 @@ def _scoped_runner( ) -> Iterator[Runner]: new_loop_policy = event_loop_policy debug_mode = _get_asyncio_debug(request.config) - with _temporary_event_loop_policy(new_loop_policy): + with _temporary_event_loop_policy( + new_loop_policy, + has_custom_factory=_asyncio_loop_factory is not None, + ): runner = Runner( debug=debug_mode, loop_factory=_asyncio_loop_factory, ).__enter__() + if _asyncio_loop_factory is not None: + _set_event_loop(runner.get_loop()) try: yield runner except Exception as e: diff --git a/tests/test_loop_factory_parametrization.py b/tests/test_loop_factory_parametrization.py index f6bac235..8221d135 100644 --- a/tests/test_loop_factory_parametrization.py +++ b/tests/test_loop_factory_parametrization.py @@ -6,6 +6,35 @@ from pytest import Pytester +@pytest.mark.skipif( + not hasattr(pytest, "HIDDEN_PARAM"), + reason="pytest.HIDDEN_PARAM requires pytest 9.0+", +) +def test_single_factory_does_not_add_suffix_to_test_name( + pytester: Pytester, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent("""\ + import asyncio + + def pytest_asyncio_loop_factories(config, item): + return {"asyncio": asyncio.new_event_loop} + """)) + pytester.makepyfile(dedent("""\ + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio + async def test_example(): + assert True + """)) + result = pytester.runpytest("--asyncio-mode=strict", "--collect-only", "-q") + result.stdout.fnmatch_lines( + ["test_single_factory_does_not_add_suffix_to_test_name.py::test_example"] + ) + + def test_named_hook_factories_apply_to_async_tests(pytester: Pytester) -> None: pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makeconftest(dedent("""\ @@ -533,6 +562,42 @@ async def test_uses_custom_loop(): result.assert_outcomes(passed=1) +def test_no_event_loop_leak_with_custom_factory(pytester: Pytester) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent("""\ + import asyncio + import pytest_asyncio + + class CustomEventLoop(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {"custom": CustomEventLoop} + + @pytest_asyncio.fixture(autouse=True, scope="session", loop_scope="session") + async def session_fixture(): + yield + + @pytest_asyncio.fixture(autouse=True) + def sync_fixture(): + asyncio.get_event_loop() + """)) + pytester.makepyfile(dedent("""\ + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio + async def test_passes(): + assert True + """)) + result = pytester.runpytest_subprocess( + "--asyncio-mode=auto", "-W", "error::ResourceWarning" + ) + result.assert_outcomes(passed=1) + result.stderr.no_fnmatch_line("*unclosed event loop*") + + def test_function_loop_scope_allows_per_test_factories_with_session_default( pytester: Pytester, ) -> None: @@ -571,3 +636,223 @@ async def test_b(): """)) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_sync_fixture_sees_same_loop_as_async_test_under_custom_factory( + pytester: Pytester, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent("""\ + import asyncio + + class CustomEventLoop(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {"custom": CustomEventLoop} + """)) + pytester.makepyfile(dedent("""\ + import asyncio + import pytest + import pytest_asyncio + + pytest_plugins = "pytest_asyncio" + + @pytest_asyncio.fixture(autouse=True) + def enable_debug_on_event_loop(): + asyncio.get_event_loop().set_debug(True) + + @pytest.mark.asyncio + async def test_debug_mode_visible(): + assert asyncio.get_running_loop().get_debug() + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize( + ("fixture_scope", "wider_scope"), + [ + ("function", "module"), + ("function", "package"), + ("function", "session"), + ("module", "session"), + ("package", "session"), + ], +) +def test_sync_fixture_sees_its_own_loop_when_wider_scoped_loop_active( + pytester: Pytester, + fixture_scope: str, + wider_scope: str, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent(f"""\ + import asyncio + import pytest_asyncio + + class CustomEventLoop(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {{"custom": CustomEventLoop}} + + @pytest_asyncio.fixture( + autouse=True, + scope="{wider_scope}", + loop_scope="{wider_scope}", + ) + async def wider_scoped_fixture(): + yield + + @pytest_asyncio.fixture( + autouse=True, + scope="{fixture_scope}", + loop_scope="{fixture_scope}", + ) + def sync_fixture_captures_loop(): + return id(asyncio.get_event_loop()) + """)) + pytester.makepyfile(dedent(f"""\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio(loop_scope="{fixture_scope}") + async def test_sync_fixture_and_test_see_same_loop(sync_fixture_captures_loop): + assert sync_fixture_captures_loop == id(asyncio.get_running_loop()) + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize( + ("fixture_scope", "wider_scope"), + [ + ("function", "module"), + ("function", "session"), + ("module", "session"), + ], +) +def test_sync_generator_fixture_teardown_sees_own_loop( + pytester: Pytester, + fixture_scope: str, + wider_scope: str, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent(f"""\ + import asyncio + import pytest_asyncio + + class CustomEventLoop(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {{"custom": CustomEventLoop}} + + @pytest_asyncio.fixture( + autouse=True, + scope="{wider_scope}", + loop_scope="{wider_scope}", + ) + async def wider_scoped_fixture(): + yield + + @pytest_asyncio.fixture( + autouse=True, + scope="{fixture_scope}", + loop_scope="{fixture_scope}", + ) + def sync_generator_fixture(): + loop_at_setup = id(asyncio.get_event_loop()) + yield loop_at_setup + loop_at_teardown = id(asyncio.get_event_loop()) + assert loop_at_setup == loop_at_teardown + """)) + pytester.makepyfile(dedent(f"""\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio(loop_scope="{fixture_scope}") + async def test_generator_fixture_sees_correct_loop(sync_generator_fixture): + assert sync_generator_fixture == id(asyncio.get_running_loop()) + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize("loop_scope", ("module", "package", "session")) +def test_async_generator_fixture_teardown_runs_under_custom_factory( + pytester: Pytester, + loop_scope: str, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent(f"""\ + import asyncio + import pytest_asyncio + + class CustomEventLoop(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {{"custom": CustomEventLoop}} + + @pytest_asyncio.fixture( + autouse=True, scope="{loop_scope}", loop_scope="{loop_scope}" + ) + async def fixture_with_teardown(): + yield + print("TEARDOWN_EXECUTED") + """)) + pytester.makepyfile(dedent(f"""\ + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio(loop_scope="{loop_scope}") + async def test_passes(): + assert True + """)) + result = pytester.runpytest("--asyncio-mode=strict", "-s") + result.assert_outcomes(passed=1) + result.stdout.fnmatch_lines(["*TEARDOWN_EXECUTED*"]) + + +@pytest.mark.parametrize("loop_scope", ("module", "package", "session")) +def test_async_fixture_recreated_per_loop_factory_variant( + pytester: Pytester, + loop_scope: str, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent(f"""\ + import asyncio + import pytest_asyncio + + class CustomLoopA(asyncio.SelectorEventLoop): + pass + + class CustomLoopB(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {{"loop_a": CustomLoopA, "loop_b": CustomLoopB}} + + @pytest_asyncio.fixture(scope="{loop_scope}", loop_scope="{loop_scope}") + async def fixture_loop_type(): + return type(asyncio.get_running_loop()).__name__ + """)) + pytester.makepyfile(dedent(f"""\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio(loop_scope="{loop_scope}") + async def test_fixture_matches_running_loop(fixture_loop_type): + running_loop_type = type(asyncio.get_running_loop()).__name__ + assert fixture_loop_type == running_loop_type + """)) + result = pytester.runpytest("--asyncio-mode=strict", "-v") + result.assert_outcomes(passed=2) diff --git a/tests/test_set_event_loop.py b/tests/test_set_event_loop.py index 7f0d5dea..3854c04b 100644 --- a/tests/test_set_event_loop.py +++ b/tests/test_set_event_loop.py @@ -329,3 +329,60 @@ async def test_after_second(second_webserver): """)) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=5) + + +@pytest.mark.parametrize("test_loop_scope", ("module", "package", "session")) +@pytest.mark.parametrize( + "loop_breaking_action", + [ + "asyncio.set_event_loop(None)", + "asyncio.run(asyncio.sleep(0))", + pytest.param( + "with asyncio.Runner(): pass", + marks=pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio.Runner requires Python 3.11+", + ), + ), + ], +) +def test_sync_fixture_sees_correct_loop_after_loop_broken_with_factory( + pytester: Pytester, + test_loop_scope: str, + loop_breaking_action: str, +): + pytester.makeini(dedent(f"""\ + [pytest] + asyncio_default_test_loop_scope = {test_loop_scope} + asyncio_default_fixture_loop_scope = function + """)) + pytester.makepyfile(dedent(f"""\ + import asyncio + import pytest + import pytest_asyncio + + pytest_plugins = "pytest_asyncio" + + class CustomEventLoop(asyncio.SelectorEventLoop): + pass + + def pytest_asyncio_loop_factories(config, item): + return {{"custom": CustomEventLoop}} + + @pytest.mark.asyncio + async def test_before(): + pass + + def test_break_event_loop(): + {loop_breaking_action} + + @pytest_asyncio.fixture(loop_scope="{test_loop_scope}") + def sync_fixture_loop_id(): + return id(asyncio.get_event_loop()) + + @pytest.mark.asyncio + async def test_sync_fixture_sees_correct_loop(sync_fixture_loop_id): + assert sync_fixture_loop_id == id(asyncio.get_running_loop()) + """)) + result = pytester.runpytest_subprocess() + result.assert_outcomes(passed=3)