From be3e2a9ee01905ffb43bce6f1e4cc5f650ca7852 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Thu, 16 Jul 2026 22:03:33 +0000 Subject: [PATCH 1/5] fix: apply loop factories for asyncio marks via parametrize When pytest.mark.asyncio is attached only to individual parameter sets, pytest_generate_tests still needs to resolve that marker so pytest_asyncio_loop_factories can parametrize the event loop. Fixes #1463 Signed-off-by: Alex Chen --- changelog.d/1463.fixed.rst | 1 + pytest_asyncio/plugin.py | 47 ++++++++++++++++++++++ tests/test_loop_factory_parametrization.py | 43 ++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 changelog.d/1463.fixed.rst 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..53a8fb78 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -685,6 +685,51 @@ 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 +778,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..28efb3cc 100644 --- a/tests/test_loop_factory_parametrization.py +++ b/tests/test_loop_factory_parametrization.py @@ -917,3 +917,46 @@ 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) From f42d7750b1c2eb1b4fccbcee583febe2ee482289 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Fri, 17 Jul 2026 02:37:52 +0000 Subject: [PATCH 2/5] style: apply pre-commit ruff/shed on parametrize loop-factory fix Signed-off-by: Alex Chen --- pytest_asyncio/plugin.py | 3 ++- tests/test_loop_factory_parametrization.py | 20 +++++++------------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 53a8fb78..3c6615b6 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -704,7 +704,8 @@ def _as_mark(mark: object) -> Mark | None: def _asyncio_marker_from_parametrize(definition: Function) -> Mark | None: - """Return an asyncio mark applied via ``@pytest.mark.parametrize`` params. + """ + 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 diff --git a/tests/test_loop_factory_parametrization.py b/tests/test_loop_factory_parametrization.py index 28efb3cc..4e5fe40e 100644 --- a/tests/test_loop_factory_parametrization.py +++ b/tests/test_loop_factory_parametrization.py @@ -922,11 +922,11 @@ async def test_fixture_matches_running_loop(fixture_loop_type): 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.""" + """ + 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( - """\ + pytester.makeconftest(dedent("""\ import asyncio class CustomEventLoop(asyncio.SelectorEventLoop): @@ -934,12 +934,8 @@ class CustomEventLoop(asyncio.SelectorEventLoop): def pytest_asyncio_loop_factories(config, item): return {"custom": CustomEventLoop} - """ - ) - ) - pytester.makepyfile( - dedent( - """\ + """)) + pytester.makepyfile(dedent("""\ import asyncio import pytest @@ -955,8 +951,6 @@ def pytest_asyncio_loop_factories(config, item): ) 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) From d539a6342ad55f4860be3d6c19d2f7fecc23742d Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Tue, 21 Jul 2026 10:38:19 +0000 Subject: [PATCH 3/5] test: cover multi-backend asyncio param with loop factories Address review on #1509: assert loop type for asyncio and an analogous non-asyncio backend mark in the same parametrize, matching the #1463 shape without adding pytest-trio to CI deps. Signed-off-by: Alex Chen --- tests/test_loop_factory_parametrization.py | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/test_loop_factory_parametrization.py b/tests/test_loop_factory_parametrization.py index 4e5fe40e..ee1b58ce 100644 --- a/tests/test_loop_factory_parametrization.py +++ b/tests/test_loop_factory_parametrization.py @@ -954,3 +954,95 @@ async def test_async(backend: str) -> None: """)) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=1, skipped=1) + + +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) From b2219dda742bc3b40e80b8e90e38cd1cafb25ec4 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Tue, 21 Jul 2026 10:49:27 +0000 Subject: [PATCH 4/5] test: multi-backend loop-factory residual in dedicated module Move the #1509 review residual into its own test module so ruff format does not thrash the large existing pytester file. Covers asyncio + analogous non-asyncio backend marks with loop-type asserts (#1463 shape). Signed-off-by: Alex Chen --- tests/test_loop_factory_parametrization.py | 92 ----------------- ..._loop_factory_parametrize_multi_backend.py | 99 +++++++++++++++++++ 2 files changed, 99 insertions(+), 92 deletions(-) create mode 100644 tests/test_loop_factory_parametrize_multi_backend.py diff --git a/tests/test_loop_factory_parametrization.py b/tests/test_loop_factory_parametrization.py index ee1b58ce..4e5fe40e 100644 --- a/tests/test_loop_factory_parametrization.py +++ b/tests/test_loop_factory_parametrization.py @@ -954,95 +954,3 @@ async def test_async(backend: str) -> None: """)) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=1, skipped=1) - - -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) 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..9980715f --- /dev/null +++ b/tests/test_loop_factory_parametrize_multi_backend.py @@ -0,0 +1,99 @@ +"""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) From 1e46cc2db84daa789bfec604ac89df73915b6cf1 Mon Sep 17 00:00:00 2001 From: Alex Chen Date: Tue, 21 Jul 2026 11:07:59 +0000 Subject: [PATCH 5/5] style: reformat multi-backend loop-factory residual for shed Match pre-commit shed/black parentheses collapse on pytester.dedent calls. Signed-off-by: Alex Chen --- ...est_loop_factory_parametrize_multi_backend.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/tests/test_loop_factory_parametrize_multi_backend.py b/tests/test_loop_factory_parametrize_multi_backend.py index 9980715f..32b79b83 100644 --- a/tests/test_loop_factory_parametrize_multi_backend.py +++ b/tests/test_loop_factory_parametrize_multi_backend.py @@ -24,9 +24,7 @@ def test_asyncio_and_analogous_backend_parametrize_uses_loop_factories( "markers =\n" " other_async: analogous non-asyncio backend for multi-backend params\n" ) - pytester.makeconftest( - dedent( - """\ + pytester.makeconftest(dedent("""\ import asyncio import inspect @@ -66,12 +64,8 @@ def pytest_pyfunc_call(pyfuncitem): pass loop.close() return True - """ - ) - ) - pytester.makepyfile( - dedent( - """\ + """)) + pytester.makepyfile(dedent("""\ import asyncio import pytest @@ -92,8 +86,6 @@ async def test_async(backend: str) -> None: else: assert backend == "other" assert loop_name == "OtherBackendLoop" - """ - ) - ) + """)) result = pytester.runpytest("--asyncio-mode=strict", "-v") result.assert_outcomes(passed=2)