diff --git a/changelog.d/796.fixed.rst b/changelog.d/796.fixed.rst new file mode 100644 index 00000000..16b4d17c --- /dev/null +++ b/changelog.d/796.fixed.rst @@ -0,0 +1 @@ +Prevent parametrized ``event_loop_policy`` fixtures from parametrizing synchronous tests. diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 2fe8db12..2d808219 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -723,20 +723,59 @@ def pytest_pycollect_makeitem_convert_async_functions_to_subclass( hook_result.force_result(updated_node_collection) +@pytest.hookimpl(tryfirst=True) +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + for item in items: + if _item_needs_event_loop_policy(item): + _append_fixture_name(item, "event_loop_policy") + + +def _item_needs_event_loop_policy(item: pytest.Item) -> bool: + if not isinstance(item, Function): + return False + if isinstance(item, PytestAsyncioFunction) and _resolve_asyncio_marker(item): + return True + return _item_uses_asyncio_fixtures(item) + + +def _item_uses_asyncio_fixtures(item: pytest.Item) -> bool: + fixtureinfo = getattr(item, "_fixtureinfo", None) + if fixtureinfo is None: + return False + return _fixture_defs_use_asyncio( + item.config, + fixtureinfo.name2fixturedefs.values(), + ) + + +def _append_fixture_name(item: pytest.Item, fixture_name: str) -> None: + if fixture_name not in item.fixturenames: + item.fixturenames.append(fixture_name) + + @pytest.hookimpl(tryfirst=True) def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: specialized_item_class = PytestAsyncioFunction.item_subclass_for( metafunc.definition ) if specialized_item_class is None: + if _uses_asyncio_fixtures(metafunc): + _add_fixture_to_metafunc(metafunc, "event_loop_policy") return asyncio_marker = _resolve_asyncio_marker(metafunc.definition) if asyncio_marker is None: + if _uses_asyncio_fixtures(metafunc): + _add_fixture_to_metafunc(metafunc, "event_loop_policy") return marker_loop_scope, marker_selected_factory_names = _parse_asyncio_marker( asyncio_marker ) + default_loop_scope = _get_default_test_loop_scope(metafunc.config) + loop_scope = marker_loop_scope or default_loop_scope + runner_fixture_id = f"_{loop_scope}_scoped_runner" + _add_fixture_to_metafunc(metafunc, runner_fixture_id) + _add_fixture_to_metafunc(metafunc, "event_loop_policy") hook_factories = _collect_hook_loop_factories(metafunc.config, metafunc.definition) if hook_factories is None: @@ -774,8 +813,6 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: for name in marker_selected_factory_names ] 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(factory_ids) == 1 and hasattr(pytest, "HIDDEN_PARAM") metafunc.parametrize( @@ -787,6 +824,44 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: ) +def _add_fixture_to_metafunc(metafunc: pytest.Metafunc, fixture_name: str) -> None: + fixturemanager = metafunc.definition.session._fixturemanager + fixturenames_closure, arg2fixturedefs = fixturemanager.getfixtureclosure( + metafunc.definition, + (fixture_name,), + ignore_args=set(), + ) + metafunc._arg2fixturedefs.update(arg2fixturedefs) + for name in fixturenames_closure: + if name not in metafunc.fixturenames: + metafunc.fixturenames.append(name) + + +def _uses_asyncio_fixtures(metafunc: pytest.Metafunc) -> bool: + return _fixture_defs_use_asyncio( + metafunc.config, + metafunc._arg2fixturedefs.values(), + ) + + +def _fixture_defs_use_asyncio( + config: Config, + fixturedefs_by_name: Iterable[Sequence[FixtureDef[Any]]], +) -> bool: + asyncio_mode = _get_asyncio_mode(config) + for fixturedefs in fixturedefs_by_name: + if not fixturedefs: + continue + fixturedef = fixturedefs[-1] + fixture_func = fixturedef.func + if _is_asyncio_fixture_function(fixture_func): + return True + if asyncio_mode == Mode.AUTO and _is_coroutine_or_asyncgen(fixture_func): + return True + + return False + + @contextlib.contextmanager def _temporary_event_loop(loop: AbstractEventLoop) -> Iterator[None]: try: @@ -1073,7 +1148,7 @@ def _asyncio_loop_factory(request: FixtureRequest) -> LoopFactory | None: return getattr(request, "param", None) -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope="session") def event_loop_policy() -> AbstractEventLoopPolicy: """Return an instance of the policy used to create asyncio event loops.""" return _get_event_loop_policy() diff --git a/tests/markers/test_function_scope.py b/tests/markers/test_function_scope.py index 180d5c71..b1d892db 100644 --- a/tests/markers/test_function_scope.py +++ b/tests/markers/test_function_scope.py @@ -154,6 +154,168 @@ async def test_parametrized_loop(): result.assert_outcomes(passed=2) +def test_parametrized_loop_policy_does_not_parametrize_sync_tests( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + + @pytest.fixture( + scope="session", + params=[ + asyncio.get_event_loop_policy(), + asyncio.get_event_loop_policy(), + ], + ) + def event_loop_policy(request): + return request.param + + @pytest.mark.asyncio + async def test_async(): + pass + + def test_sync(): + pass + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=3) + + +def test_parametrized_loop_policy_parametrizes_sync_tests_with_async_fixtures( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest.fixture( + scope="session", + params=[ + asyncio.get_event_loop_policy(), + asyncio.get_event_loop_policy(), + ], + ) + def event_loop_policy(request): + return request.param + + @pytest_asyncio.fixture + async def async_fixture(): + pass + + def test_sync_with_async_fixture(async_fixture): + pass + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_parametrized_loop_policy_parametrizes_auto_mode_async_fixtures( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + + @pytest.fixture( + scope="session", + params=[ + asyncio.get_event_loop_policy(), + asyncio.get_event_loop_policy(), + ], + ) + def event_loop_policy(request): + return request.param + + @pytest.fixture + async def async_fixture(): + pass + + def test_sync_with_auto_async_fixture(async_fixture): + pass + """)) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=2) + + +def test_parametrized_loop_policy_can_depend_on_parametrized_fixture( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + + @pytest.fixture(params=["policy_a", "policy_b"]) + def policy(request): + return request.param + + @pytest.fixture + def event_loop_policy(policy): + return asyncio.get_event_loop_policy() + + @pytest.mark.asyncio + async def test_marked_async(): + pass + + async def test_auto_async(): + pass + """)) + result = pytester.runpytest("--asyncio-mode=auto") + result.assert_outcomes(passed=4) + + +def test_injected_loop_policy_is_visible_during_collection_modifyitems( + pytester: Pytester, +): + pytester.makeini(dedent("""\ + [pytest] + asyncio_default_fixture_loop_scope = function + markers = + uses_loop_policy: item uses the event_loop_policy fixture + """)) + pytester.makeconftest(dedent("""\ + import pytest + + def pytest_collection_modifyitems(items): + for item in items: + if "event_loop_policy" in item.fixturenames: + item.add_marker(pytest.mark.uses_loop_policy) + """)) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + + @pytest.fixture( + scope="session", + params=[ + asyncio.get_event_loop_policy(), + asyncio.get_event_loop_policy(), + ], + ) + def event_loop_policy(request): + return request.param + + @pytest.mark.asyncio + async def test_async(request): + assert request.node.get_closest_marker("uses_loop_policy") is not None + + def test_sync(request): + assert request.node.get_closest_marker("uses_loop_policy") is None + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=3) + + def test_event_loop_policy_fixture_override_emits_deprecation_warning( pytester: Pytester, ): diff --git a/tests/test_set_event_loop.py b/tests/test_set_event_loop.py index 52dfc7af..959efcd3 100644 --- a/tests/test_set_event_loop.py +++ b/tests/test_set_event_loop.py @@ -112,8 +112,6 @@ def test_asyncio_run_after_async_fixture_does_not_leak_loop( import pytest import pytest_asyncio - pytest_plugins = "pytest_asyncio" - @pytest_asyncio.fixture async def async_fixture(): yield