Skip to content
Open
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
4 changes: 4 additions & 0 deletions changelog.d/1591.fixed.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Fixed cancelled async fixture setup and teardown being propagated as
``asyncio.CancelledError``, which could stop other fixtures from being torn down or break later
tests that use the fixture. Also ensured ContextVars changed by async generator fixtures are
restored when their teardown raises.
36 changes: 31 additions & 5 deletions pytest_asyncio/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,22 @@ def _sync_fixture_wrapper(
return _sync_fixture_wrapper


def _cancelled_fixture_error(
phase: Literal["setup", "teardown"],
request: FixtureRequest,
) -> PytestAsyncioError:
"""
Return the error to raise instead of a fixture's CancelledError.

CancelledError is a BaseException, so pytest does not handle it like other
fixture errors: it does not cache it as a setup error, and it does not run
a node's remaining finalizers after one is raised during teardown.
"""
return PytestAsyncioError(
f"The {phase} of async fixture {request.fixturename!r} was cancelled."
)


AsyncGenFixtureParams = ParamSpec("AsyncGenFixtureParams")
AsyncGenFixtureYieldType = TypeVar("AsyncGenFixtureYieldType")

Expand All @@ -405,7 +421,10 @@ async def setup():
return res

context = contextvars.copy_context()
result = runner.run(setup(), context=context)
try:
result = runner.run(setup(), context=context)
except asyncio.CancelledError as e:
raise _cancelled_fixture_error("setup", request) from e

reset_contextvars = _apply_contextvar_changes(context)

Expand All @@ -422,9 +441,13 @@ async def async_finalizer() -> None:
msg += "Yield only once."
raise ValueError(msg)

runner.run(async_finalizer(), context=context)
if reset_contextvars is not None:
reset_contextvars()
try:
runner.run(async_finalizer(), context=context)
except asyncio.CancelledError as e:
raise _cancelled_fixture_error("teardown", request) from e
finally:
if reset_contextvars is not None:
reset_contextvars()

request.addfinalizer(finalizer)
return result
Expand Down Expand Up @@ -453,7 +476,10 @@ async def setup():
return res

context = contextvars.copy_context()
result = runner.run(setup(), context=context)
try:
result = runner.run(setup(), context=context)
except asyncio.CancelledError as e:
raise _cancelled_fixture_error("setup", request) from e

# Copy the context vars modified by the setup task into the current
# context, and (if needed) add a finalizer to reset them.
Expand Down
21 changes: 21 additions & 0 deletions tests/async_fixtures/test_async_fixtures_contextvars.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,27 @@ async def test(var_fixture_2):
result.assert_outcomes(passed=1)


def test_var_from_async_generator_reset_when_teardown_fails(pytester: Pytester):
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
pytester.makepyfile(_prelude + dedent("""
@pytest_asyncio.fixture
async def var_fixture():
with context_var_manager("value"):
yield
raise RuntimeError("teardown failed")

@pytest.mark.asyncio
async def test(var_fixture):
assert _context_var.get() == "value"

def test_after_failed_teardown():
with pytest.raises(LookupError):
_context_var.get()
"""))
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=2, errors=1)


def test_var_set_to_existing_value_ok(pytester: Pytester):
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
pytester.makepyfile(_prelude + dedent("""
Expand Down
92 changes: 92 additions & 0 deletions tests/async_fixtures/test_cancelled_fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Async fixtures whose setup or teardown is cancelled."""

from __future__ import annotations

from textwrap import dedent

import pytest
from pytest import Pytester


def test_cancelled_teardown_does_not_prevent_teardown_of_other_fixtures(
pytester: Pytester,
):
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function")
pytester.makepyfile(dedent("""\
import asyncio

import pytest
import pytest_asyncio

torn_down = []

@pytest.fixture
def sync_fixture():
yield
torn_down.append("sync_fixture")

@pytest_asyncio.fixture
async def cancelled_teardown():
task = asyncio.create_task(asyncio.sleep(3600))
yield
task.cancel()
await task

@pytest.mark.asyncio
async def test_requests_fixtures(sync_fixture, cancelled_teardown):
pass

def test_other_fixture_was_torn_down():
assert torn_down == ["sync_fixture"]
"""))
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(passed=2, errors=1)
result.stdout.fnmatch_lines(
[
"*ERROR at teardown of test_requests_fixtures*",
"E*CancelledError",
"The above exception was the direct cause of the following exception:",
"*PytestAsyncioError: The teardown of async fixture "
"'cancelled_teardown' was cancelled.",
]
)


@pytest.mark.parametrize("fixture_end", ("yield", "return"))
def test_cancelled_setup_of_module_scoped_fixture_is_setup_error_of_each_test(
pytester: Pytester, fixture_end: str
):
pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = module")
pytester.makepyfile(dedent(f"""\
import asyncio

import pytest
import pytest_asyncio

@pytest_asyncio.fixture(scope="module")
async def cancelled_setup():
task = asyncio.create_task(asyncio.sleep(3600))
task.cancel()
await task
{fixture_end}

@pytest.mark.asyncio(loop_scope="module")
async def test_first(cancelled_setup):
pass

@pytest.mark.asyncio(loop_scope="module")
async def test_second(cancelled_setup):
pass
"""))
result = pytester.runpytest("--asyncio-mode=strict")
result.assert_outcomes(errors=2)
result.stdout.fnmatch_lines(
[
"*ERROR at setup of test_first*",
"*PytestAsyncioError: The setup of async fixture "
"'cancelled_setup' was cancelled.",
"*ERROR at setup of test_second*",
"*PytestAsyncioError: The setup of async fixture "
"'cancelled_setup' was cancelled.",
]
)
Loading