Skip to content

Commit 78e93b8

Browse files
committed
feat: Use asyncio.Runner to manage scoped event loops.
Previously, pytest-asyncio used a combination of low-level asyncio functions to manage event loops. This patch replaces the low-level functions with the use of asyncio.Runner. The runner takes care of cancelling remaining tasks at the end of its scope, avoiding errors about pending tasks. This also fixes an issue that caused RuntimeErrors in abanoned tasks that called functions which depend on a running loop. Moreover, the use of asyncio.Runner allows backporting the propagation of contextvars from fixtures to tests to Python 3.9 and Python 3.10.
1 parent 55a5a10 commit 78e93b8

File tree

8 files changed

+101
-73
lines changed

8 files changed

+101
-73
lines changed

changelog.d/+127.added.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Propagation of ContextVars from async fixtures to other fixtures and tests on Python 3.10 and older

changelog.d/+6aa3d3e0.added.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Warning when the current event loop is closed by a test

changelog.d/+878.fixed.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Error about missing loop when calling functions requiring a loop in the `finally` clause of a task

changelog.d/200.added.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Cancellation of tasks when the `loop_scope` ends

pytest_asyncio/plugin.py

Lines changed: 59 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import inspect
1111
import socket
1212
import sys
13+
import traceback
1314
import warnings
1415
from asyncio import AbstractEventLoop, AbstractEventLoopPolicy
1516
from collections.abc import (
@@ -54,6 +55,10 @@
5455
else:
5556
from typing_extensions import Concatenate, ParamSpec
5657

58+
if sys.version_info >= (3, 11):
59+
from asyncio import Runner
60+
else:
61+
from backports.asyncio.runner import Runner
5762

5863
_ScopeName = Literal["session", "package", "module", "class", "function"]
5964
_T = TypeVar("_T")
@@ -230,14 +235,12 @@ def pytest_report_header(config: Config) -> list[str]:
230235
]
231236

232237

233-
def _fixture_synchronizer(
234-
fixturedef: FixtureDef, event_loop: AbstractEventLoop
235-
) -> Callable:
238+
def _fixture_synchronizer(fixturedef: FixtureDef, runner: Runner) -> Callable:
236239
"""Returns a synchronous function evaluating the specified fixture."""
237240
if inspect.isasyncgenfunction(fixturedef.func):
238-
return _wrap_asyncgen_fixture(fixturedef.func, event_loop)
241+
return _wrap_asyncgen_fixture(fixturedef.func, runner)
239242
elif inspect.iscoroutinefunction(fixturedef.func):
240-
return _wrap_async_fixture(fixturedef.func, event_loop)
243+
return _wrap_async_fixture(fixturedef.func, runner)
241244
else:
242245
return fixturedef.func
243246

@@ -278,7 +281,7 @@ def _wrap_asyncgen_fixture(
278281
fixture_function: Callable[
279282
AsyncGenFixtureParams, AsyncGeneratorType[AsyncGenFixtureYieldType, Any]
280283
],
281-
event_loop: AbstractEventLoop,
284+
runner: Runner,
282285
) -> Callable[
283286
Concatenate[FixtureRequest, AsyncGenFixtureParams], AsyncGenFixtureYieldType
284287
]:
@@ -296,8 +299,7 @@ async def setup():
296299
return res
297300

298301
context = contextvars.copy_context()
299-
setup_task = _create_task_in_context(event_loop, setup(), context)
300-
result = event_loop.run_until_complete(setup_task)
302+
result = runner.run(setup(), context=context)
301303

302304
reset_contextvars = _apply_contextvar_changes(context)
303305

@@ -314,8 +316,7 @@ async def async_finalizer() -> None:
314316
msg += "Yield only once."
315317
raise ValueError(msg)
316318

317-
task = _create_task_in_context(event_loop, async_finalizer(), context)
318-
event_loop.run_until_complete(task)
319+
runner.run(async_finalizer(), context=context)
319320
if reset_contextvars is not None:
320321
reset_contextvars()
321322

@@ -333,7 +334,7 @@ def _wrap_async_fixture(
333334
fixture_function: Callable[
334335
AsyncFixtureParams, CoroutineType[Any, Any, AsyncFixtureReturnType]
335336
],
336-
event_loop: AbstractEventLoop,
337+
runner: Runner,
337338
) -> Callable[Concatenate[FixtureRequest, AsyncFixtureParams], AsyncFixtureReturnType]:
338339

339340
@functools.wraps(fixture_function) # type: ignore[arg-type]
@@ -349,8 +350,7 @@ async def setup():
349350
return res
350351

351352
context = contextvars.copy_context()
352-
setup_task = _create_task_in_context(event_loop, setup(), context)
353-
result = event_loop.run_until_complete(setup_task)
353+
result = runner.run(setup(), context=context)
354354

355355
# Copy the context vars modified by the setup task into the current
356356
# context, and (if needed) add a finalizer to reset them.
@@ -610,22 +610,22 @@ def pytest_generate_tests(metafunc: Metafunc) -> None:
610610
return
611611
default_loop_scope = _get_default_test_loop_scope(metafunc.config)
612612
loop_scope = _get_marked_loop_scope(marker, default_loop_scope)
613-
event_loop_fixture_id = f"_{loop_scope}_event_loop"
613+
runner_fixture_id = f"_{loop_scope}_scoped_runner"
614614
# This specific fixture name may already be in metafunc.argnames, if this
615615
# test indirectly depends on the fixture. For example, this is the case
616616
# when the test depends on an async fixture, both of which share the same
617617
# event loop fixture mark.
618-
if event_loop_fixture_id in metafunc.fixturenames:
618+
if runner_fixture_id in metafunc.fixturenames:
619619
return
620620
fixturemanager = metafunc.config.pluginmanager.get_plugin("funcmanage")
621621
assert fixturemanager is not None
622622
# Add the scoped event loop fixture to Metafunc's list of fixture names and
623623
# fixturedefs and leave the actual parametrization to pytest
624624
# The fixture needs to be appended to avoid messing up the fixture evaluation
625625
# order
626-
metafunc.fixturenames.append(event_loop_fixture_id)
627-
metafunc._arg2fixturedefs[event_loop_fixture_id] = fixturemanager._arg2fixturedefs[
628-
event_loop_fixture_id
626+
metafunc.fixturenames.append(runner_fixture_id)
627+
metafunc._arg2fixturedefs[runner_fixture_id] = fixturemanager._arg2fixturedefs[
628+
runner_fixture_id
629629
]
630630

631631

@@ -747,10 +747,10 @@ def pytest_runtest_setup(item: pytest.Item) -> None:
747747
return
748748
default_loop_scope = _get_default_test_loop_scope(item.config)
749749
loop_scope = _get_marked_loop_scope(marker, default_loop_scope)
750-
event_loop_fixture_id = f"_{loop_scope}_event_loop"
750+
runner_fixture_id = f"_{loop_scope}_scoped_runner"
751751
fixturenames = item.fixturenames # type: ignore[attr-defined]
752-
if event_loop_fixture_id not in fixturenames:
753-
fixturenames.append(event_loop_fixture_id)
752+
if runner_fixture_id not in fixturenames:
753+
fixturenames.append(runner_fixture_id)
754754
obj = getattr(item, "obj", None)
755755
if not getattr(obj, "hypothesis", False) and getattr(
756756
obj, "is_hypothesis_test", False
@@ -777,9 +777,9 @@ def pytest_fixture_setup(fixturedef: FixtureDef, request) -> object | None:
777777
or default_loop_scope
778778
or fixturedef.scope
779779
)
780-
event_loop_fixture_id = f"_{loop_scope}_event_loop"
781-
event_loop = request.getfixturevalue(event_loop_fixture_id)
782-
synchronizer = _fixture_synchronizer(fixturedef, event_loop)
780+
runner_fixture_id = f"_{loop_scope}_scoped_runner"
781+
runner = request.getfixturevalue(runner_fixture_id)
782+
synchronizer = _fixture_synchronizer(fixturedef, runner)
783783
_make_asyncio_fixture_function(synchronizer, loop_scope)
784784
with MonkeyPatch.context() as c:
785785
if "request" not in fixturedef.argnames:
@@ -825,28 +825,51 @@ def _get_default_test_loop_scope(config: Config) -> _ScopeName:
825825
return config.getini("asyncio_default_test_loop_scope")
826826

827827

828-
def _create_scoped_event_loop_fixture(scope: _ScopeName) -> Callable:
828+
_RUNNER_TEARDOWN_WARNING = """\
829+
An exception occurred during teardown of an asyncio.Runner. \
830+
The reason is likely that you closed the underlying event loop in a test, \
831+
which prevents the cleanup of asynchronous generators by the runner.
832+
This warning will become an error in future versions of pytest-asyncio. \
833+
Please ensure that your tests don't close the event loop. \
834+
Here is the traceback of the exception triggered during teardown:
835+
%s
836+
"""
837+
838+
839+
def _create_scoped_runner_fixture(scope: _ScopeName) -> Callable:
829840
@pytest.fixture(
830841
scope=scope,
831-
name=f"_{scope}_event_loop",
842+
name=f"_{scope}_scoped_runner",
832843
)
833-
def _scoped_event_loop(
844+
def _scoped_runner(
834845
*args, # Function needs to accept "cls" when collected by pytest.Class
835846
event_loop_policy,
836-
) -> Iterator[asyncio.AbstractEventLoop]:
847+
) -> Iterator[Runner]:
837848
new_loop_policy = event_loop_policy
838-
with (
839-
_temporary_event_loop_policy(new_loop_policy),
840-
_provide_event_loop() as loop,
841-
):
842-
_set_event_loop(loop)
843-
yield loop
849+
with _temporary_event_loop_policy(new_loop_policy):
850+
runner = Runner().__enter__()
851+
try:
852+
yield runner
853+
except Exception as e:
854+
runner.__exit__(type(e), e, e.__traceback__)
855+
else:
856+
with warnings.catch_warnings():
857+
warnings.filterwarnings(
858+
"ignore", ".*BaseEventLoop.shutdown_asyncgens.*", RuntimeWarning
859+
)
860+
try:
861+
runner.__exit__(None, None, None)
862+
except RuntimeError:
863+
warnings.warn(
864+
_RUNNER_TEARDOWN_WARNING % traceback.format_exc(),
865+
RuntimeWarning,
866+
)
844867

845-
return _scoped_event_loop
868+
return _scoped_runner
846869

847870

848871
for scope in Scope:
849-
globals()[f"_{scope.value}_event_loop"] = _create_scoped_event_loop_fixture(
872+
globals()[f"_{scope.value}_scoped_runner"] = _create_scoped_runner_fixture(
850873
scope.value
851874
)
852875

tests/async_fixtures/test_async_fixtures_contextvars.py

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,8 @@
55

66
from __future__ import annotations
77

8-
import sys
98
from textwrap import dedent
109

11-
import pytest
1210
from pytest import Pytester
1311

1412
_prelude = dedent(
@@ -56,11 +54,6 @@ async def test(check_var_fixture):
5654
result.assert_outcomes(passed=1)
5755

5856

59-
@pytest.mark.xfail(
60-
sys.version_info < (3, 11),
61-
reason="requires asyncio Task context support",
62-
strict=True,
63-
)
6457
def test_var_from_async_generator_propagates_to_sync(pytester: Pytester):
6558
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
6659
pytester.makepyfile(
@@ -86,11 +79,6 @@ async def test(check_var_fixture):
8679
result.assert_outcomes(passed=1)
8780

8881

89-
@pytest.mark.xfail(
90-
sys.version_info < (3, 11),
91-
reason="requires asyncio Task context support",
92-
strict=True,
93-
)
9482
def test_var_from_async_fixture_propagates_to_sync(pytester: Pytester):
9583
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
9684
pytester.makepyfile(
@@ -115,11 +103,6 @@ def test(check_var_fixture):
115103
result.assert_outcomes(passed=1)
116104

117105

118-
@pytest.mark.xfail(
119-
sys.version_info < (3, 11),
120-
reason="requires asyncio Task context support",
121-
strict=True,
122-
)
123106
def test_var_from_generator_reset_before_previous_fixture_cleanup(pytester: Pytester):
124107
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
125108
pytester.makepyfile(
@@ -149,11 +132,6 @@ async def test(var_fixture):
149132
result.assert_outcomes(passed=1)
150133

151134

152-
@pytest.mark.xfail(
153-
sys.version_info < (3, 11),
154-
reason="requires asyncio Task context support",
155-
strict=True,
156-
)
157135
def test_var_from_fixture_reset_before_previous_fixture_cleanup(pytester: Pytester):
158136
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
159137
pytester.makepyfile(
@@ -183,11 +161,6 @@ async def test(var_fixture):
183161
result.assert_outcomes(passed=1)
184162

185163

186-
@pytest.mark.xfail(
187-
sys.version_info < (3, 11),
188-
reason="requires asyncio Task context support",
189-
strict=True,
190-
)
191164
def test_var_previous_value_restored_after_fixture(pytester: Pytester):
192165
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
193166
pytester.makepyfile(
@@ -216,11 +189,6 @@ async def test(var_fixture_2):
216189
result.assert_outcomes(passed=1)
217190

218191

219-
@pytest.mark.xfail(
220-
sys.version_info < (3, 11),
221-
reason="requires asyncio Task context support",
222-
strict=True,
223-
)
224192
def test_var_set_to_existing_value_ok(pytester: Pytester):
225193
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
226194
pytester.makepyfile(

tests/test_event_loop_fixture.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ async def generator_fn():
8282
result.assert_outcomes(passed=1, warnings=0)
8383

8484

85-
def test_event_loop_already_closed(
85+
def test_closing_event_loop_in_sync_fixture_teardown_raises_warning(
8686
pytester: Pytester,
8787
):
8888
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
@@ -99,19 +99,22 @@ async def _event_loop():
9999
return asyncio.get_running_loop()
100100
101101
@pytest.fixture
102-
def cleanup_after(_event_loop):
102+
def close_event_loop(_event_loop):
103103
yield
104104
# fixture has its own cleanup code
105105
_event_loop.close()
106106
107107
@pytest.mark.asyncio
108-
async def test_something(cleanup_after):
108+
async def test_something(close_event_loop):
109109
await asyncio.sleep(0.01)
110110
"""
111111
)
112112
)
113-
result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W", "default")
114-
result.assert_outcomes(passed=1, warnings=0)
113+
result = pytester.runpytest_subprocess("--asyncio-mode=strict")
114+
result.assert_outcomes(passed=1, warnings=1)
115+
result.stdout.fnmatch_lines(
116+
["*An exception occurred during teardown of an asyncio.Runner*"]
117+
)
115118

116119

117120
def test_event_loop_fixture_asyncgen_error(

tests/test_task_cleanup.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from __future__ import annotations
2+
3+
from textwrap import dedent
4+
5+
from pytest import Pytester
6+
7+
8+
def test_task_is_cancelled_when_abandoned_by_test(pytester: Pytester):
9+
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
10+
pytester.makepyfile(
11+
dedent(
12+
"""\
13+
import asyncio
14+
import pytest
15+
16+
@pytest.mark.asyncio
17+
async def test_create_task():
18+
async def coroutine():
19+
try:
20+
while True:
21+
await asyncio.sleep(0)
22+
finally:
23+
raise RuntimeError("The task should be cancelled at this point.")
24+
25+
asyncio.create_task(coroutine())
26+
"""
27+
)
28+
)
29+
result = pytester.runpytest("--asyncio-mode=strict")
30+
result.assert_outcomes(passed=1)

0 commit comments

Comments
 (0)