diff --git a/changelog.d/1463.fixed.rst b/changelog.d/1463.fixed.rst new file mode 100644 index 00000000..b53b597c --- /dev/null +++ b/changelog.d/1463.fixed.rst @@ -0,0 +1 @@ +Apply ``pytest_asyncio_loop_factories`` when ``pytest.mark.asyncio`` is provided via parametrization diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 38b75e41..3c6615b6 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -685,6 +685,52 @@ def _resolve_asyncio_marker(item: Function) -> Mark | None: return None +def _mark_name(mark: object) -> str | None: + """Return the pytest mark name for Mark or MarkDecorator instances.""" + name = getattr(mark, "name", None) + if isinstance(name, str): + return name + nested = getattr(mark, "mark", None) + nested_name = getattr(nested, "name", None) + return nested_name if isinstance(nested_name, str) else None + + +def _as_mark(mark: object) -> Mark | None: + """Normalize MarkDecorator/Mark objects to a Mark instance.""" + if isinstance(mark, Mark): + return mark + nested = getattr(mark, "mark", None) + return nested if isinstance(nested, Mark) else None + + +def _asyncio_marker_from_parametrize(definition: Function) -> Mark | None: + """ + Return an asyncio mark applied via ``@pytest.mark.parametrize`` params. + + In strict mode, ``pytest.mark.asyncio`` may only be present on individual + parameter sets (for example multi-backend tests). Collection still converts + those async parameter sets into Coroutine items, but ``pytest_generate_tests`` + previously only looked at function-level markers and therefore skipped + ``pytest_asyncio_loop_factories`` parametrization for such tests. + """ + from _pytest.mark.structures import ParameterSet + + for mark in definition.iter_markers("parametrize"): + if not mark.args: + continue + # ``@pytest.mark.parametrize(argnames, argvalues, ...)`` + argvalues = mark.args[1] if len(mark.args) >= 2 else () + for argvalue in argvalues: + parameter_set = ParameterSet.extract_from(argvalue, force_tuple=True) + for param_mark in parameter_set.marks: + if _mark_name(param_mark) != "asyncio": + continue + resolved = _as_mark(param_mark) + if resolved is not None: + return resolved + return None + + # The function name needs to start with "pytest_" # see https://github.com/pytest-dev/pytest/issues/11307 @pytest.hookimpl(specname="pytest_pycollect_makeitem", hookwrapper=True) @@ -733,6 +779,8 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: return asyncio_marker = _resolve_asyncio_marker(metafunc.definition) + if asyncio_marker is None: + asyncio_marker = _asyncio_marker_from_parametrize(metafunc.definition) if asyncio_marker is None: return marker_loop_scope, marker_selected_factory_names = _parse_asyncio_marker( diff --git a/tests/test_loop_factory_parametrization.py b/tests/test_loop_factory_parametrization.py index 224c9141..4e5fe40e 100644 --- a/tests/test_loop_factory_parametrization.py +++ b/tests/test_loop_factory_parametrization.py @@ -917,3 +917,40 @@ async def test_fixture_matches_running_loop(fixture_loop_type): """)) result = pytester.runpytest("--asyncio-mode=strict", "-v") result.assert_outcomes(passed=2) + + +def test_asyncio_mark_via_parametrize_uses_hook_loop_factories( + pytester: Pytester, +) -> None: + """ + Regression for #1463: asyncio mark only on a parameter set still uses + factories.""" + 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 + + pytest_plugins = "pytest_asyncio" + + + @pytest.mark.parametrize( + "backend", + [ + pytest.param("asyncio", marks=pytest.mark.asyncio), + pytest.param("other", marks=pytest.mark.skip(reason="not asyncio")), + ], + ) + async def test_async(backend: str) -> None: + assert type(asyncio.get_running_loop()).__name__ == "CustomEventLoop" + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1, skipped=1) diff --git a/tests/test_loop_factory_parametrize_multi_backend.py b/tests/test_loop_factory_parametrize_multi_backend.py new file mode 100644 index 00000000..32b79b83 --- /dev/null +++ b/tests/test_loop_factory_parametrize_multi_backend.py @@ -0,0 +1,91 @@ +"""Multi-backend parametrize + loop factories (#1463 / #1509 residual).""" + +from __future__ import annotations + +from textwrap import dedent + +from pytest import Pytester + + +def test_asyncio_and_analogous_backend_parametrize_uses_loop_factories( + pytester: Pytester, +) -> None: + """ + Regression for #1463 issue shape: asyncio mark on one parameter set and + an analogous non-asyncio backend mark on another, with loop-type asserts + in the test body. + + CI does not depend on pytest-trio (removed from test deps historically), so + the second backend is a tiny inline plugin analogous to a trio-style mark. + """ + pytester.makeini( + "[pytest]\n" + "asyncio_default_fixture_loop_scope = function\n" + "markers =\n" + " other_async: analogous non-asyncio backend for multi-backend params\n" + ) + pytester.makeconftest(dedent("""\ + import asyncio + import inspect + + import pytest + + + class CustomEventLoop(asyncio.SelectorEventLoop): + pass + + + class OtherBackendLoop(asyncio.SelectorEventLoop): + pass + + + def pytest_asyncio_loop_factories(config, item): + return {"custom": CustomEventLoop} + + + @pytest.hookimpl(tryfirst=True) + def pytest_pyfunc_call(pyfuncitem): + if pyfuncitem.get_closest_marker("other_async") is None: + return None + testfunction = pyfuncitem.obj + if not inspect.iscoroutinefunction(testfunction): + return None + funcargs = { + arg: pyfuncitem.funcargs[arg] + for arg in pyfuncitem._fixtureinfo.argnames + } + loop = OtherBackendLoop() + try: + loop.run_until_complete(testfunction(**funcargs)) + finally: + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + except Exception: + pass + loop.close() + return True + """)) + pytester.makepyfile(dedent("""\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + + @pytest.mark.parametrize( + "backend", + [ + pytest.param("asyncio", marks=pytest.mark.asyncio), + pytest.param("other", marks=pytest.mark.other_async), + ], + ) + async def test_async(backend: str) -> None: + loop_name = type(asyncio.get_running_loop()).__name__ + if backend == "asyncio": + assert loop_name == "CustomEventLoop" + else: + assert backend == "other" + assert loop_name == "OtherBackendLoop" + """)) + result = pytester.runpytest("--asyncio-mode=strict", "-v") + result.assert_outcomes(passed=2)