From adcd338405c440a38d2c04da0761e9a355518ab4 Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Mon, 13 Jul 2026 05:15:16 -0700 Subject: [PATCH] Fix loop factory teardown ordering Record each managed fixture's scoped runner as a pytest fixture dependency. This lets pytest invalidate async fixtures in dependency order when the loop factory parameter changes, preserving shared parents until their children have finished. Add a regression test covering an async test followed by a synchronous test that consumes a child session-scoped async fixture. Fixes #1501 --- changelog.d/1501.fixed.rst | 1 + pytest_asyncio/plugin.py | 39 ++++++++++++++++++---- tests/test_loop_factory_parametrization.py | 35 +++++++++++++++++++ 3 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 changelog.d/1501.fixed.rst diff --git a/changelog.d/1501.fixed.rst b/changelog.d/1501.fixed.rst new file mode 100644 index 00000000..8197bd97 --- /dev/null +++ b/changelog.d/1501.fixed.rst @@ -0,0 +1 @@ +The scoped runner is now registered as a dependency of managed fixtures. This ensures loop factory parameter changes tear down dependent fixtures in dependency order. (`#1501 `_) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 38b75e41..afecc133 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -328,18 +328,29 @@ def pytest_report_header(config: Config) -> list[str]: def _fixture_synchronizer( - fixturedef: FixtureDef, runner: Runner, request: FixtureRequest + fixturedef: FixtureDef, + runner: Runner, + request: FixtureRequest, + runner_fixture_id: str, ) -> Callable: """Returns a synchronous function evaluating the specified fixture.""" fixture_function = resolve_fixture_function(fixturedef, request) if inspect.isasyncgenfunction(fixturedef.func): - return _wrap_asyncgen_fixture(fixture_function, runner, request) # type: ignore[arg-type] + return _wrap_asyncgen_fixture( # type: ignore[arg-type] + fixture_function, runner, request, runner_fixture_id + ) elif inspect.iscoroutinefunction(fixturedef.func): - return _wrap_async_fixture(fixture_function, runner, request) # type: ignore[arg-type] + return _wrap_async_fixture( # type: ignore[arg-type] + fixture_function, runner, request, runner_fixture_id + ) elif inspect.isgeneratorfunction(fixturedef.func): - return _wrap_syncgen_fixture(fixture_function, runner) # type: ignore[arg-type] + return _wrap_syncgen_fixture( # type: ignore[arg-type] + fixture_function, runner, runner_fixture_id + ) else: - return _wrap_sync_fixture(fixture_function, runner) # type: ignore[arg-type] + return _wrap_sync_fixture( # type: ignore[arg-type] + fixture_function, runner, runner_fixture_id + ) SyncGenFixtureParams = ParamSpec("SyncGenFixtureParams") @@ -351,12 +362,14 @@ def _wrap_syncgen_fixture( SyncGenFixtureParams, Generator[SyncGenFixtureYieldType] ], runner: Runner, + runner_fixture_id: str, ) -> Callable[SyncGenFixtureParams, Generator[SyncGenFixtureYieldType]]: @functools.wraps(fixture_function) def _syncgen_fixture_wrapper( *args: SyncGenFixtureParams.args, **kwargs: SyncGenFixtureParams.kwargs, ) -> Generator[SyncGenFixtureYieldType]: + kwargs.pop(runner_fixture_id, None) with _temporary_event_loop(runner.get_loop()): yield from fixture_function(*args, **kwargs) @@ -370,12 +383,14 @@ def _syncgen_fixture_wrapper( def _wrap_sync_fixture( fixture_function: Callable[SyncFixtureParams, SyncFixtureReturnType], runner: Runner, + runner_fixture_id: str, ) -> Callable[SyncFixtureParams, SyncFixtureReturnType]: @functools.wraps(fixture_function) def _sync_fixture_wrapper( *args: SyncFixtureParams.args, **kwargs: SyncFixtureParams.kwargs, ) -> SyncFixtureReturnType: + kwargs.pop(runner_fixture_id, None) with _temporary_event_loop(runner.get_loop()): return fixture_function(*args, **kwargs) @@ -392,12 +407,14 @@ def _wrap_asyncgen_fixture( ], runner: Runner, request: FixtureRequest, + runner_fixture_id: str, ) -> Callable[AsyncGenFixtureParams, AsyncGenFixtureYieldType]: @functools.wraps(fixture_function) def _asyncgen_fixture_wrapper( *args: AsyncGenFixtureParams.args, **kwargs: AsyncGenFixtureParams.kwargs, ): + kwargs.pop(runner_fixture_id, None) gen_obj = fixture_function(*args, **kwargs) async def setup(): @@ -442,12 +459,15 @@ def _wrap_async_fixture( ], runner: Runner, request: FixtureRequest, + runner_fixture_id: str, ) -> Callable[AsyncFixtureParams, AsyncFixtureReturnType]: @functools.wraps(fixture_function) def _async_fixture_wrapper( *args: AsyncFixtureParams.args, **kwargs: AsyncFixtureParams.kwargs, ): + kwargs.pop(runner_fixture_id, None) + async def setup(): res = await fixture_function(*args, **kwargs) return res @@ -932,13 +952,20 @@ def pytest_fixture_setup(fixturedef: FixtureDef, request) -> object | None: or fixturedef.scope ) runner_fixture_id = f"_{loop_scope}_scoped_runner" + if runner_fixture_id not in fixturedef.argnames: + # pytest derives fixture dependencies from argnames. Add the runner here so + # its parametrization invalidates dependent async fixtures in dependency + # order, rather than finalizing a parent fixture before its child. + object.__setattr__( + fixturedef, "argnames", (*fixturedef.argnames, runner_fixture_id) + ) 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) + synchronizer = _fixture_synchronizer(fixturedef, runner, request, runner_fixture_id) _make_asyncio_fixture_function(synchronizer, loop_scope) with MonkeyPatch.context() as c: c.setattr(fixturedef, "func", synchronizer) diff --git a/tests/test_loop_factory_parametrization.py b/tests/test_loop_factory_parametrization.py index 224c9141..96a4cc5d 100644 --- a/tests/test_loop_factory_parametrization.py +++ b/tests/test_loop_factory_parametrization.py @@ -190,6 +190,41 @@ async def test_async(request): result.assert_outcomes(passed=3) +def test_sync_test_with_shared_async_fixture_is_not_torn_down( + pytester: Pytester, +) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = session") + pytester.makeconftest(dedent("""\ + import asyncio + + def pytest_asyncio_loop_factories(config, item): + return {"default": asyncio.new_event_loop} + """)) + pytester.makepyfile(dedent("""\ + import pytest + import pytest_asyncio + + pytest_plugins = "pytest_asyncio" + + @pytest_asyncio.fixture(scope="session") + async def parent(): + yield "parent" + + @pytest_asyncio.fixture(scope="session") + async def child(parent): + yield "child" + + @pytest.mark.asyncio(loop_scope="session") + async def test_async(parent): + assert parent == "parent" + + def test_sync(child): + assert child == "child" + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + @pytest.mark.parametrize( "hook_body", (