Skip to content

Commit

Permalink
fix: Shutdown generators before closing event loops.
Browse files Browse the repository at this point in the history
Add a comment
  • Loading branch information
provinzkraut authored and seifertm committed Jan 8, 2025
1 parent e8ffb10 commit c3ad634
Show file tree
Hide file tree
Showing 3 changed files with 56 additions and 15 deletions.
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.2 (UNRELEASED)
===================

- Call ``loop.shutdown_asyncgens()`` before closing the event loop to ensure async generators are closed in the same manner as ``asyncio.run`` does `#1034 <https://github.com/pytest-dev/pytest-asyncio/pull/1034>`_

0.25.1 (2025-01-02)
===================
- 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>`_
Expand Down
39 changes: 24 additions & 15 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -708,11 +708,12 @@ def scoped_event_loop(
event_loop_policy,
) -> Iterator[asyncio.AbstractEventLoop]:
new_loop_policy = event_loop_policy
with _temporary_event_loop_policy(new_loop_policy):
loop = _make_pytest_asyncio_loop(asyncio.new_event_loop())
with (
_temporary_event_loop_policy(new_loop_policy),
_provide_event_loop() as loop,
):
asyncio.set_event_loop(loop)
yield loop
loop.close()

# @pytest.fixture does not register the fixture anywhere, so pytest doesn't
# know it exists. We work around this by attaching the fixture function to the
Expand Down Expand Up @@ -1147,28 +1148,36 @@ 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__)
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)
with _temporary_event_loop_policy(new_loop_policy), _provide_event_loop() as loop:
yield loop
loop.close()


@contextlib.contextmanager
def _provide_event_loop() -> Iterator[asyncio.AbstractEventLoop]:
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)
try:
yield loop
finally:
try:
loop.run_until_complete(loop.shutdown_asyncgens())
finally:
loop.close()


@pytest.fixture(scope="session")
def _session_event_loop(
request: FixtureRequest, event_loop_policy: AbstractEventLoopPolicy
) -> Iterator[asyncio.AbstractEventLoop]:
new_loop_policy = event_loop_policy
with _temporary_event_loop_policy(new_loop_policy):
loop = _make_pytest_asyncio_loop(asyncio.new_event_loop())
with _temporary_event_loop_policy(new_loop_policy), _provide_event_loop() as loop:
asyncio.set_event_loop(loop)
yield loop
loop.close()


@pytest.fixture(scope="session", autouse=True)
Expand Down
27 changes: 27 additions & 0 deletions tests/test_event_loop_fixture.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,30 @@ async def test_custom_policy_is_not_overwritten():
)
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
result.assert_outcomes(passed=2)


def test_event_loop_fixture_handles_unclosed_async_gen(
pytester: Pytester,
):
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
pytester.makepyfile(
dedent(
"""\
import asyncio
import pytest
pytest_plugins = 'pytest_asyncio'
@pytest.mark.asyncio
async def test_something():
async def generator_fn():
yield
yield
gen = generator_fn()
await gen.__anext__()
"""
)
)
result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W", "default")
result.assert_outcomes(passed=1, warnings=0)

0 comments on commit c3ad634

Please sign in to comment.