diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst index 0b302d53..e219b723 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -2,6 +2,11 @@ Changelog ========= +0.25.1 (UNRELEASED) +=================== +- Fixes an issue that caused a broken event loop when a function-scoped test was executed in between two tests with wider loop scope `#950 `_ + + 0.25.0 (2024-12-13) =================== - Deprecated: Added warning when asyncio test requests async ``@pytest.fixture`` in strict mode. This will become an error in a future version of flake8-asyncio. `#979 `_ diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 12ead10f..00d52b2c 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -10,7 +10,7 @@ import inspect import socket import warnings -from asyncio import AbstractEventLoopPolicy +from asyncio import AbstractEventLoop, AbstractEventLoopPolicy from collections.abc import ( AsyncIterator, Awaitable, @@ -709,8 +709,7 @@ def scoped_event_loop( ) -> Iterator[asyncio.AbstractEventLoop]: 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] + loop = _make_pytest_asyncio_loop(asyncio.new_event_loop()) asyncio.set_event_loop(loop) yield loop loop.close() @@ -762,6 +761,19 @@ def _temporary_event_loop_policy(policy: AbstractEventLoopPolicy) -> Iterator[No try: yield finally: + # Try detecting user-created event loops that were left unclosed + # at the end of a test. + try: + current_loop: AbstractEventLoop | None = _get_event_loop_no_warn() + except RuntimeError: + current_loop = None + if current_loop is not None and not current_loop.is_closed(): + warnings.warn( + _UNCLOSED_EVENT_LOOP_WARNING % current_loop, + DeprecationWarning, + ) + current_loop.close() + asyncio.set_event_loop_policy(old_loop_policy) # When a test uses both a scoped event loop and the event_loop fixture, # the "_provide_clean_event_loop" finalizer of the event_loop fixture @@ -849,7 +861,7 @@ def pytest_fixture_setup( # Weird behavior was observed when checking for an attribute of FixtureDef.func # Instead, we now check for a special attribute of the returned event loop fixture_filename = inspect.getsourcefile(fixturedef.func) - if not getattr(loop, "__original_fixture_loop", False): + if not _is_pytest_asyncio_loop(loop): _, fixture_line_number = inspect.getsourcelines(fixturedef.func) warnings.warn( _REDEFINED_EVENT_LOOP_FIXTURE_WARNING @@ -859,8 +871,7 @@ def pytest_fixture_setup( policy = asyncio.get_event_loop_policy() try: old_loop = _get_event_loop_no_warn(policy) - is_pytest_asyncio_loop = getattr(old_loop, "__pytest_asyncio", False) - if old_loop is not loop and not is_pytest_asyncio_loop: + if old_loop is not loop and not _is_pytest_asyncio_loop(old_loop): old_loop.close() except RuntimeError: # Either the current event loop has been set to None @@ -873,6 +884,15 @@ def pytest_fixture_setup( yield +def _make_pytest_asyncio_loop(loop: AbstractEventLoop) -> AbstractEventLoop: + loop.__pytest_asyncio = True # type: ignore[attr-defined] + return loop + + +def _is_pytest_asyncio_loop(loop: AbstractEventLoop) -> bool: + return getattr(loop, "__pytest_asyncio", False) + + def _add_finalizers(fixturedef: FixtureDef, *finalizers: Callable[[], object]) -> None: """ Registers the specified fixture finalizers in the fixture. @@ -906,7 +926,7 @@ def _close_event_loop() -> None: loop = policy.get_event_loop() except RuntimeError: loop = None - if loop is not None: + if loop is not None and not _is_pytest_asyncio_loop(loop): if not loop.is_closed(): warnings.warn( _UNCLOSED_EVENT_LOOP_WARNING % loop, @@ -923,7 +943,7 @@ def _restore_policy(): loop = _get_event_loop_no_warn(previous_policy) except RuntimeError: loop = None - if loop: + if loop and not _is_pytest_asyncio_loop(loop): loop.close() asyncio.set_event_loop_policy(previous_policy) @@ -938,8 +958,13 @@ def _provide_clean_event_loop() -> None: # Note that we cannot set the loop to None, because get_event_loop only creates # a new loop, when set_event_loop has not been called. policy = asyncio.get_event_loop_policy() - new_loop = policy.new_event_loop() - policy.set_event_loop(new_loop) + try: + old_loop = _get_event_loop_no_warn(policy) + except RuntimeError: + old_loop = None + if old_loop is not None and not _is_pytest_asyncio_loop(old_loop): + new_loop = policy.new_event_loop() + policy.set_event_loop(new_loop) def _get_event_loop_no_warn( @@ -1122,16 +1147,16 @@ def _retrieve_scope_root(item: Collector | Item, scope: str) -> Collector: def event_loop(request: FixtureRequest) -> Iterator[asyncio.AbstractEventLoop]: """Create an instance of the default event loop for each test case.""" new_loop_policy = request.getfixturevalue(event_loop_policy.__name__) - asyncio.set_event_loop_policy(new_loop_policy) - loop = asyncio.get_event_loop_policy().new_event_loop() - # Add a magic value to the event loop, so pytest-asyncio can determine if the - # event_loop fixture was overridden. Other implementations of event_loop don't - # set this value. - # The magic value must be set as part of the function definition, because pytest - # seems to have multiple instances of the same FixtureDef or fixture function - loop.__original_fixture_loop = True # type: ignore[attr-defined] - yield loop - loop.close() + with _temporary_event_loop_policy(new_loop_policy): + loop = asyncio.get_event_loop_policy().new_event_loop() + # Add a magic value to the event loop, so pytest-asyncio can determine if the + # event_loop fixture was overridden. Other implementations of event_loop don't + # set this value. + # The magic value must be set as part of the function definition, because pytest + # seems to have multiple instances of the same FixtureDef or fixture function + loop = _make_pytest_asyncio_loop(loop) + yield loop + loop.close() @pytest.fixture(scope="session") @@ -1140,8 +1165,7 @@ def _session_event_loop( ) -> Iterator[asyncio.AbstractEventLoop]: 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] + loop = _make_pytest_asyncio_loop(asyncio.new_event_loop()) asyncio.set_event_loop(loop) yield loop loop.close() diff --git a/tests/markers/test_mixed_scope.py b/tests/markers/test_mixed_scope.py new file mode 100644 index 00000000..40eaaa35 --- /dev/null +++ b/tests/markers/test_mixed_scope.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from textwrap import dedent + +from pytest import Pytester + + +def test_function_scoped_loop_restores_previous_loop_scope(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + + module_loop: asyncio.AbstractEventLoop + + @pytest.mark.asyncio(loop_scope="module") + async def test_remember_loop(): + global module_loop + module_loop = asyncio.get_running_loop() + + @pytest.mark.asyncio(loop_scope="function") + async def test_with_function_scoped_loop(): + pass + + @pytest.mark.asyncio(loop_scope="module") + async def test_runs_in_same_loop(): + global module_loop + assert asyncio.get_running_loop() is module_loop + """ + ) + ) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=3) diff --git a/tests/test_event_loop_fixture_finalizer.py b/tests/test_event_loop_fixture_finalizer.py index 17cc85b9..1e378643 100644 --- a/tests/test_event_loop_fixture_finalizer.py +++ b/tests/test_event_loop_fixture_finalizer.py @@ -14,7 +14,8 @@ def test_event_loop_fixture_finalizer_returns_fresh_loop_after_test(pytester: Py import pytest - loop = asyncio.get_event_loop_policy().get_event_loop() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) @pytest.mark.asyncio async def test_1():