Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Fix wrong event loop reference with mixed scoped tests #1029

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/reference/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/pytest-dev/pytest-asyncio/issues/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 <https://github.com/pytest-dev/pytest-asyncio/pull/979>`_
Expand Down
68 changes: 46 additions & 22 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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)

Expand All @@ -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(
Expand Down Expand Up @@ -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")
Expand All @@ -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()
Expand Down
36 changes: 36 additions & 0 deletions tests/markers/test_mixed_scope.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 2 additions & 1 deletion tests/test_event_loop_fixture_finalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
Loading