From 3b77ab8abb25b1046644c5d06942d12100ec05db Mon Sep 17 00:00:00 2001 From: Michael Seifert Date: Thu, 7 Dec 2023 09:22:18 +0100 Subject: [PATCH] [fix] Fixes a bug that caused event loops to be closed prematurely when using async generator fixtures with class scope or wider in a function-scoped test. The fixture setup of the "event_loop" fixture closes any existing loop. The existing loop could also belong to a pytest-asyncio scoped event loop fixture. This caused async generator fixtures using the scoped loop to raise a RuntimeError on teardown, because the scoped loop was closed before the fixture finalizer could not be run. In fact, everything after the async generation fixture's "yield" statement had no functioning event loop. The issue was addressed by adding a special attribute to the scoped event loops provided by pytest-asyncio. If this attribute is present, the setup code of the "event_loop" fixture will not close the loop. This allows keeping backwards compatibility for code that doesn't use scoped loops. It is assumed that the magic attribute can be removed after the deprecation period of event_loop_ fixture overrides. Signed-off-by: Michael Seifert --- docs/source/reference/changelog.rst | 5 +++++ pytest_asyncio/plugin.py | 5 ++++- tests/markers/test_module_scope.py | 30 ++++++++++++++++++++++++++++ tests/markers/test_session_scope.py | 31 +++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/source/reference/changelog.rst b/docs/source/reference/changelog.rst index c02a528a..7e063d24 100644 --- a/docs/source/reference/changelog.rst +++ b/docs/source/reference/changelog.rst @@ -2,6 +2,11 @@ Changelog ========= +0.23.3 (UNRELEASED) +=================== +- Fixes a bug that caused event loops to be closed prematurely when using async generator fixtures with class scope or wider in a function-scoped test `#708 `_ + + 0.23.2 (2023-12-04) =================== - Fixes a bug that caused an internal pytest error when collecting .txt files `#703 `_ diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 934fb91c..0a92730c 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -600,6 +600,7 @@ def scoped_event_loop( new_loop_policy = event_loop_policy with _temporary_event_loop_policy(new_loop_policy): loop = asyncio.new_event_loop() + loop.__pytest_asyncio = True # type: ignore[attr-defined] asyncio.set_event_loop(loop) yield loop loop.close() @@ -749,7 +750,8 @@ def pytest_fixture_setup( with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) old_loop = policy.get_event_loop() - if old_loop is not loop: + is_pytest_asyncio_loop = getattr(old_loop, "__pytest_asyncio", False) + if old_loop is not loop and not is_pytest_asyncio_loop: old_loop.close() except RuntimeError: # Either the current event loop has been set to None @@ -965,6 +967,7 @@ def _session_event_loop( new_loop_policy = event_loop_policy with _temporary_event_loop_policy(new_loop_policy): loop = asyncio.new_event_loop() + loop.__pytest_asyncio = True # type: ignore[attr-defined] asyncio.set_event_loop(loop) yield loop loop.close() diff --git a/tests/markers/test_module_scope.py b/tests/markers/test_module_scope.py index 94547e40..cf6b2f60 100644 --- a/tests/markers/test_module_scope.py +++ b/tests/markers/test_module_scope.py @@ -282,6 +282,36 @@ async def test_runs_in_different_loop_as_fixture(async_fixture): result.assert_outcomes(passed=1) +def test_allows_combining_module_scoped_asyncgen_fixture_with_function_scoped_test( + pytester: Pytester, +): + pytester.makepyfile( + dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(scope="module") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + yield + + @pytest.mark.asyncio(scope="function") + async def test_runs_in_different_loop_as_fixture(async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + def test_asyncio_mark_handles_missing_event_loop_triggered_by_fixture( pytester: Pytester, ): diff --git a/tests/markers/test_session_scope.py b/tests/markers/test_session_scope.py index ac70d01d..bd0baee5 100644 --- a/tests/markers/test_session_scope.py +++ b/tests/markers/test_session_scope.py @@ -350,6 +350,37 @@ async def test_runs_in_different_loop_as_fixture(async_fixture): result.assert_outcomes(passed=1) +def test_allows_combining_session_scoped_asyncgen_fixture_with_function_scoped_test( + pytester: Pytester, +): + pytester.makepyfile( + __init__="", + test_mixed_scopes=dedent( + """\ + import asyncio + + import pytest + import pytest_asyncio + + loop: asyncio.AbstractEventLoop + + @pytest_asyncio.fixture(scope="session") + async def async_fixture(): + global loop + loop = asyncio.get_running_loop() + yield + + @pytest.mark.asyncio + async def test_runs_in_different_loop_as_fixture(async_fixture): + global loop + assert asyncio.get_running_loop() is not loop + """ + ), + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + def test_asyncio_mark_handles_missing_event_loop_triggered_by_fixture( pytester: Pytester, ):