diff --git a/changelog.d/1164.added.rst b/changelog.d/1164.added.rst index ccf2988a..c68d22dd 100644 --- a/changelog.d/1164.added.rst +++ b/changelog.d/1164.added.rst @@ -1,3 +1,5 @@ Added the ``pytest_asyncio_loop_factories`` hook to parametrize asyncio tests with custom event loop factories. -The hook now returns a mapping of factory names to loop factories, and ``pytest.mark.asyncio(loop_factories=[...])`` can be used to select a subset of configured factories per test. +The hook returns a mapping of factory names to loop factories, and ``pytest.mark.asyncio(loop_factories=[...])`` selects a subset of configured factories per test. When a single factory is configured, test names are unchanged on pytest 8.4+. + +Synchronous ``@pytest_asyncio.fixture`` functions now see the correct event loop when custom loop factories are configured, even when test code disrupts the current event loop (e.g., via ``asyncio.run()`` or ``asyncio.set_event_loop(None)``). diff --git a/docs/how-to-guides/run_test_with_specific_loop_factories.rst b/docs/how-to-guides/run_test_with_specific_loop_factories.rst index 338d28ab..5be0333a 100644 --- a/docs/how-to-guides/run_test_with_specific_loop_factories.rst +++ b/docs/how-to-guides/run_test_with_specific_loop_factories.rst @@ -12,3 +12,5 @@ To run a test with only a subset of configured factories, use the ``loop_factori @pytest.mark.asyncio(loop_factories=["custom"]) async def test_only_with_custom_event_loop(): pass + +If a requested factory name is not available from the hook, the test variant for that factory is skipped. diff --git a/docs/reference/markers/index.rst b/docs/reference/markers/index.rst index 761196a8..3e8f3bb1 100644 --- a/docs/reference/markers/index.rst +++ b/docs/reference/markers/index.rst @@ -36,7 +36,7 @@ Subpackages do not share the loop with their parent package. Tests marked with *session* scope share the same event loop, even if the tests exist in different packages. -The ``pytest.mark.asyncio`` marker also accepts a ``loop_factories`` keyword argument to select a subset of configured event loop factories for a test. If ``loop_factories`` contains unknown names, pytest-asyncio raises a ``pytest.UsageError`` during collection. +The ``pytest.mark.asyncio`` marker also accepts a ``loop_factories`` keyword argument to select a subset of configured event loop factories for a test. If ``loop_factories`` contains names not available from the hook, those test variants are skipped. .. |auto mode| replace:: *auto mode* .. _auto mode: ../../concepts.html#auto-mode diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index a69350bd..9dd3c582 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -17,6 +17,7 @@ AsyncIterator, Awaitable, Callable, + Collection, Generator, Iterable, Iterator, @@ -328,8 +329,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 +543,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: @@ -692,42 +741,76 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: ) return + factory_params: Collection[object] + factory_ids: Collection[str] if marker_selected_factory_names is None: - effective_factories = hook_factories + factory_params = hook_factories.values() + factory_ids = hook_factories.keys() else: - missing_factory_names = tuple( - name for name in marker_selected_factory_names if name not in hook_factories - ) - if missing_factory_names: - msg = ( - f"Unknown factory name(s) {missing_factory_names}." - f" Available names: {', '.join(hook_factories)}." + # Iterate in marker order to preserve explicit user selection + # order. + factory_ids = marker_selected_factory_names + factory_params = [ + ( + hook_factories[name] + if name in hook_factories + else pytest.param( + None, + marks=pytest.mark.skip( + reason=( + f"Loop factory {name!r} is not available." + f" Available factories:" + f" {', '.join(hook_factories)}." + ), + ), + ) ) - raise pytest.UsageError(msg) - # Build the mapping in marker order to preserve explicit user - # selection order in parametrization. - effective_factories = { - name: hook_factories[name] for name in marker_selected_factory_names - } + 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( _asyncio_loop_factory.__name__, - effective_factories.values(), - ids=effective_factories.keys(), + factory_params, + ids=(pytest.HIDDEN_PARAM,) if hide_id else factory_ids, 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 +929,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 +1023,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..224c9141 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 8.4+", +) +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("""\ @@ -371,7 +400,7 @@ async def test_runs_only_with_uvloop(): result.assert_outcomes(passed=1) -def test_asyncio_marker_loop_factories_unknown_name_errors(pytester: Pytester) -> None: +def test_unavailable_factory_skips_with_reason(pytester: Pytester) -> None: pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") pytester.makeconftest(dedent("""\ import asyncio @@ -385,16 +414,77 @@ def pytest_asyncio_loop_factories(config, item): pytest_plugins = "pytest_asyncio" @pytest.mark.asyncio(loop_factories=["missing"]) - async def test_errors(): + async def test_skipped(): assert True """)) - result = pytester.runpytest("--asyncio-mode=strict") - result.assert_outcomes(errors=1) - result.stdout.fnmatch_lines( - [ - "*Unknown factory name(s)*Available names:*", - ] - ) + result = pytester.runpytest("--asyncio-mode=strict", "-rs") + result.assert_outcomes(skipped=1) + result.stdout.fnmatch_lines(["*SKIPPED*Loop factory 'missing' is not available*"]) + + +def test_partial_intersection_runs_available_and_skips_missing( + 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 { + "available": CustomEventLoop, + "other": asyncio.new_event_loop, + } + """)) + pytester.makepyfile(dedent("""\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio(loop_factories=["available", "missing"]) + async def test_runs_with_available(): + assert type(asyncio.get_running_loop()).__name__ == "CustomEventLoop" + """)) + result = pytester.runpytest("--asyncio-mode=strict", "-rs") + result.assert_outcomes(passed=1, skipped=1) + result.stdout.fnmatch_lines(["*SKIPPED*Loop factory 'missing' is not available*"]) + + +def test_platform_conditional_factories(pytester: Pytester) -> None: + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent("""\ + import asyncio + import sys + + def pytest_asyncio_loop_factories(config, item): + factories = {"default": asyncio.new_event_loop} + if sys.platform == "a_platform_that_does_not_exist": + factories["exotic"] = asyncio.new_event_loop + return factories + """)) + pytester.makepyfile(dedent("""\ + import pytest + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio(loop_factories=["exotic"]) + async def test_exotic_only(): + assert True + + @pytest.mark.asyncio(loop_factories=["default"]) + async def test_default_only(): + assert True + + @pytest.mark.asyncio(loop_factories=["default", "exotic"]) + async def test_both(): + assert True + """)) + result = pytester.runpytest("--asyncio-mode=strict", "-rs") + result.assert_outcomes(passed=2, skipped=2) + result.stdout.fnmatch_lines(["*SKIPPED*Loop factory 'exotic' is not available*"]) def test_asyncio_marker_loop_factories_without_hook_errors( @@ -533,6 +623,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 +697,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)