From 8f49404c30a5ab302c29cf54e19dd1a7908b5a56 Mon Sep 17 00:00:00 2001 From: Tom Kuson Date: Sat, 9 Aug 2025 13:53:19 +0100 Subject: [PATCH 1/2] test: Added tests which assert that the event loop is reinstated if unset by a test. --- changelog.d/1177.fixed.rst | 1 + pytest_asyncio/plugin.py | 9 +- tests/test_set_event_loop.py | 371 +++++++++++++++++++++++++++++++++++ 3 files changed, 376 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1177.fixed.rst create mode 100644 tests/test_set_event_loop.py diff --git a/changelog.d/1177.fixed.rst b/changelog.d/1177.fixed.rst new file mode 100644 index 00000000..b51d078c --- /dev/null +++ b/changelog.d/1177.fixed.rst @@ -0,0 +1 @@ +``RuntimeError: There is no current event loop in thread 'MainThread'`` when any test unsets the event loop (such as when using ``asyncio.run`` and ``asyncio.Runner``). diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 88646fb5..29252b3e 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -311,7 +311,7 @@ def _asyncgen_fixture_wrapper( gen_obj = fixture_function(*args, **kwargs) async def setup(): - res = await gen_obj.__anext__() # type: ignore[union-attr] + res = await gen_obj.__anext__() return res context = contextvars.copy_context() @@ -324,7 +324,7 @@ def finalizer() -> None: async def async_finalizer() -> None: try: - await gen_obj.__anext__() # type: ignore[union-attr] + await gen_obj.__anext__() except StopAsyncIteration: pass else: @@ -353,8 +353,7 @@ def _wrap_async_fixture( runner: Runner, request: FixtureRequest, ) -> Callable[AsyncFixtureParams, AsyncFixtureReturnType]: - - @functools.wraps(fixture_function) # type: ignore[arg-type] + @functools.wraps(fixture_function) def _async_fixture_wrapper( *args: AsyncFixtureParams.args, **kwargs: AsyncFixtureParams.kwargs, @@ -782,7 +781,7 @@ def _get_marked_loop_scope( return scope -def _get_default_test_loop_scope(config: Config) -> _ScopeName: +def _get_default_test_loop_scope(config: Config) -> Any: return config.getini("asyncio_default_test_loop_scope") diff --git a/tests/test_set_event_loop.py b/tests/test_set_event_loop.py new file mode 100644 index 00000000..20037b48 --- /dev/null +++ b/tests/test_set_event_loop.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import sys +from textwrap import dedent + +import pytest +from pytest import Pytester + + +@pytest.mark.parametrize( + "test_loop_scope", + ("function", "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_set_event_loop_none( + 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 + + pytest_plugins = "pytest_asyncio" + + @pytest.mark.asyncio + async def test_before(): + pass + + def test_set_event_loop_none(): + {loop_breaking_action} + + @pytest.mark.asyncio + async def test_after(): + pass + """ + ) + ) + result = pytester.runpytest_subprocess() + result.assert_outcomes(passed=3) + + +@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_set_event_loop_none_class(pytester: Pytester, loop_breaking_action: str): + pytester.makeini( + dedent( + """\ + [pytest] + asyncio_default_test_loop_scope = class + asyncio_default_fixture_loop_scope = function + """ + ) + ) + pytester.makepyfile( + dedent( + f"""\ + import asyncio + import pytest + + pytest_plugins = "pytest_asyncio" + + + class TestClass: + @pytest.mark.asyncio + async def test_before(self): + pass + + def test_set_event_loop_none(self): + {loop_breaking_action} + + @pytest.mark.asyncio + async def test_after(self): + pass + """ + ) + ) + result = pytester.runpytest_subprocess() + result.assert_outcomes(passed=3) + + +@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_original_shared_loop_is_reinstated_not_fresh_loop( + 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 + + pytest_plugins = "pytest_asyncio" + + original_shared_loop: asyncio.AbstractEventLoop = None + + @pytest.mark.asyncio + async def test_store_original_shared_loop(): + global original_shared_loop + original_shared_loop = asyncio.get_running_loop() + original_shared_loop._custom_marker = "original_loop_marker" + + def test_unset_event_loop(): + {loop_breaking_action} + + @pytest.mark.asyncio + async def test_verify_original_loop_reinstated(): + global original_shared_loop + current_loop = asyncio.get_running_loop() + assert current_loop is original_shared_loop + assert hasattr(current_loop, '_custom_marker') + assert current_loop._custom_marker == "original_loop_marker" + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=3) + + +@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_shared_loop_with_fixture_preservation( + 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 = {test_loop_scope} + """ + ) + ) + pytester.makepyfile( + dedent( + f"""\ + import asyncio + import pytest + import pytest_asyncio + + pytest_plugins = "pytest_asyncio" + + fixture_loop: asyncio.AbstractEventLoop = None + long_running_task = None + + @pytest_asyncio.fixture + async def webserver(): + global fixture_loop, long_running_task + fixture_loop = asyncio.get_running_loop() + + async def background_task(): + while True: + await asyncio.sleep(1) + + long_running_task = asyncio.create_task(background_task()) + yield + long_running_task.cancel() + + + @pytest.mark.asyncio + async def test_before(webserver): + global fixture_loop, long_running_task + assert asyncio.get_running_loop() is fixture_loop + assert not long_running_task.done() + + + def test_set_event_loop_none(): + {loop_breaking_action} + + + @pytest.mark.asyncio + async def test_after(webserver): + global fixture_loop, long_running_task + current_loop = asyncio.get_running_loop() + assert current_loop is fixture_loop + assert not long_running_task.done() + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=3) + + +@pytest.mark.parametrize( + "first_scope,second_scope", + [ + ("module", "session"), + ("session", "module"), + ("package", "session"), + ("session", "package"), + ("package", "module"), + ("module", "package"), + ], +) +@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_shared_loop_with_multiple_fixtures_preservation( + pytester: Pytester, + first_scope: str, + second_scope: str, + loop_breaking_action: str, +): + pytester.makeini( + dedent( + """\ + [pytest] + asyncio_default_test_loop_scope = session + asyncio_default_fixture_loop_scope = session + """ + ) + ) + pytester.makepyfile( + dedent( + f"""\ + import asyncio + import pytest + import pytest_asyncio + + pytest_plugins = "pytest_asyncio" + + first_fixture_loop: asyncio.AbstractEventLoop = None + second_fixture_loop: asyncio.AbstractEventLoop = None + first_long_running_task = None + second_long_running_task = None + + @pytest_asyncio.fixture(scope="{first_scope}", loop_scope="{first_scope}") + async def first_webserver(): + global first_fixture_loop, first_long_running_task + first_fixture_loop = asyncio.get_running_loop() + + async def background_task(): + while True: + await asyncio.sleep(0.1) + + first_long_running_task = asyncio.create_task(background_task()) + yield + first_long_running_task.cancel() + + @pytest_asyncio.fixture(scope="{second_scope}", loop_scope="{second_scope}") + async def second_webserver(): + global second_fixture_loop, second_long_running_task + second_fixture_loop = asyncio.get_running_loop() + + async def background_task(): + while True: + await asyncio.sleep(0.1) + + second_long_running_task = asyncio.create_task(background_task()) + yield + second_long_running_task.cancel() + + @pytest.mark.asyncio(loop_scope="{first_scope}") + async def test_before_first(first_webserver): + global first_fixture_loop, first_long_running_task + assert asyncio.get_running_loop() is first_fixture_loop + assert not first_long_running_task.done() + + @pytest.mark.asyncio(loop_scope="{second_scope}") + async def test_before_second(second_webserver): + global second_fixture_loop, second_long_running_task + assert asyncio.get_running_loop() is second_fixture_loop + assert not second_long_running_task.done() + + def test_set_event_loop_none(): + {loop_breaking_action} + + @pytest.mark.asyncio(loop_scope="{first_scope}") + async def test_after_first(first_webserver): + global first_fixture_loop, first_long_running_task + current_loop = asyncio.get_running_loop() + assert current_loop is first_fixture_loop + assert not first_long_running_task.done() + + @pytest.mark.asyncio(loop_scope="{second_scope}") + async def test_after_second(second_webserver): + global second_fixture_loop, second_long_running_task + current_loop = asyncio.get_running_loop() + assert current_loop is second_fixture_loop + assert not second_long_running_task.done() + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict") + result.assert_outcomes(passed=5) From f5739a6700ebbb79150ffd961e1a0858baf5478c Mon Sep 17 00:00:00 2001 From: Tom Kuson Date: Mon, 8 Sep 2025 12:55:45 +0200 Subject: [PATCH 2/2] docs: Add changelog entry for Pyright compatibility. --- changelog.d/1172.added.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/1172.added.rst diff --git a/changelog.d/1172.added.rst b/changelog.d/1172.added.rst new file mode 100644 index 00000000..3ce26f1a --- /dev/null +++ b/changelog.d/1172.added.rst @@ -0,0 +1 @@ +Compatibility with the `Pyright` type checker