Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ jobs:
- name: Install tox
run: uv tool install tox --with tox-uv
- name: Run tests against pytest main
run: tox run -e pytest-dev
run: tox run -e pytest-dev,pytest-dev-experimental

lint-github-actions:
name: Lint GitHub Actions
Expand Down
1 change: 1 addition & 0 deletions changelog.d/+test-dependencies.downstream.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added test dependencies on ``anyio >= 4`` and ``pytest-timeout >= 2.3``.
1 change: 1 addition & 0 deletions changelog.d/1083.added.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added the experimental :ref:`asyncio_experimental_task_per_fixture <configuration/asyncio_experimental_task_per_fixture>` option (Python 3.11 and later), which runs each async fixture in a single task from setup to teardown, so task groups, timeouts and cancel scopes can stay open across ``yield``.
1 change: 1 addition & 0 deletions changelog.d/1191.added.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added the experimental :ref:`asyncio_experimental_task_per_fixture <configuration/asyncio_experimental_task_per_fixture>` option (Python 3.11 and later), which runs each async fixture in a single task from setup to teardown, so task groups, timeouts and cancel scopes can stay open across ``yield``.
36 changes: 36 additions & 0 deletions docs/concepts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,39 @@ pytest-asyncio runs async tests sequentially, just like how pytest runs synchron
:code: python

This sequential execution is intentional and important for maintaining test isolation. Running tests concurrently could introduce race conditions and side effects where one test could interfere with another, making test results unreliable and difficult to debug.

.. _concepts/tasks:

Fixture tasks and cancellation
==============================

This section explains the experimental :ref:`asyncio_experimental_task_per_fixture <configuration/asyncio_experimental_task_per_fixture>` option.

By default, pytest-asyncio runs the setup of an async generator fixture, the code before ``yield``, in one asyncio task, and its teardown, the code after ``yield``, in another.
While tests use the fixture, no task runs it, so context managers around ``yield`` do not work as they do in ordinary asyncio code:

* If a background task in an ``asyncio.TaskGroup`` fails, the test is not stopped, and the fixture's teardown can hang while it waits for the group's other tasks.
* An ``asyncio.timeout()`` that expires does not stop the test, and no error is reported.

With the option enabled, each async fixture runs in an asyncio task of its own, from setup to teardown.
While tests use the fixture, its task waits at ``yield``, so context managers around ``yield`` are entered and exited in the same task, as in ordinary asyncio code.

For example, this fixture sets a one-second deadline, counted from its setup, for each async test that uses it:

.. include:: concepts_fixture_deadline_example.py
:code: python

A task group or timeout stops the code inside it by *cancelling* the task that runs it: ``asyncio.CancelledError`` is raised where that task is waiting.
While tests use a fixture, its task is waiting at ``yield``, so the error would be raised there.
But the test and other fixtures are still using the fixture's value, and cleaning the fixture up immediately would close resources they depend on.
So pytest-asyncio handles the cancellation in two steps:

* It cancels the async test or fixture setup that is running, just as the task group or timeout would cancel code inside it.
* It raises the cancellation at the fixture's ``yield`` later, when pytest tears the fixture down, after the fixtures that depend on it.
Context managers and ``finally`` blocks run, but other statements after ``yield`` are skipped unless the fixture handles the cancellation.
The task group's or timeout's error is reported as an error at the fixture's teardown.

In the example, a test that takes longer than a second is cancelled, and pytest reports the timeout's ``TimeoutError`` at the fixture's teardown.
By default, the timeout has no effect: a slow test passes, and a test that waits for something that never happens hangs.

The :ref:`option's reference <configuration/asyncio_experimental_task_per_fixture>` gives the exact rules and limitations.
16 changes: 16 additions & 0 deletions docs/concepts_fixture_deadline_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import asyncio

import pytest

import pytest_asyncio


@pytest_asyncio.fixture
async def deadline():
async with asyncio.timeout(1):
yield


@pytest.mark.asyncio
async def test_finishes_before_the_deadline(deadline):
await asyncio.sleep(0.01)
6 changes: 6 additions & 0 deletions docs/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import sys

collect_ignore = []
if sys.version_info < (3, 11):
# asyncio.timeout requires Python 3.11.
collect_ignore.append("concepts_fixture_deadline_example.py")
45 changes: 45 additions & 0 deletions docs/reference/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,48 @@ The value can also be set via the ``--asyncio-mode`` command-line option:


If the asyncio mode is set in both the pytest configuration file and the command-line option, the command-line option takes precedence. If no asyncio mode is specified, the mode defaults to `strict`.

.. _configuration/asyncio_experimental_task_per_fixture:

asyncio_experimental_task_per_fixture
=====================================

Runs each async fixture in an asyncio task of its own, from setup to teardown, so that task groups and timeouts can span an async generator fixture's ``yield``.
See :ref:`concepts/tasks` for an explanation.

The option is experimental: its behavior may change in any release.
It requires Python 3.11 or later; enabling it on Python 3.10 is a usage error.
Defaults to ``false``.

.. code-block:: ini

# pytest.ini
[pytest]
asyncio_experimental_task_per_fixture = true

.. _configuration/asyncio_experimental_task_per_fixture/cancellation:

Cancellation and cleanup
------------------------

A fixture can be cancelled while in use, for example when a background task in its task group fails or its timeout expires.
Then:

* The async test or fixture setup running on the fixture's event loop, if any, is cancelled.
* The fixture's own cleanup waits until pytest tears it down, after its dependents, in pytest's usual order.
The cancellation is then raised at its ``yield``.
* Until then, new async tests and fixture setups on the fixture's event loop fail with an error that names the fixture.
This affects the remaining async tests in the fixture's scope that run on that event loop.
A function-scoped fixture affects no other test, because it is torn down right after its test.

When an exception interrupts the event loop, for example Ctrl-C or an exception raised by a signal handler, pytest-asyncio cancels the running async test or fixture and waits for its cleanup while the fixtures it uses are still available.
Without this option, only Ctrl-C is handled this way.

.. _configuration/asyncio_experimental_task_per_fixture/limitations:

Limitations
-----------

Cancelling a task that pytest-asyncio uses, for example by cancelling every task in ``asyncio.all_tasks()``, can fail a test or fixture, cut its cleanup short, or make code that then waits for that task hang.

If a fixture's AnyIO cancel scope is cancelled while the fixture is in use, the fixture uses extra CPU until pytest tears it down, for example while its dependents clean up.
2 changes: 2 additions & 0 deletions docs/reference/decorators/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ Examples:
:code: python

*auto* mode automatically converts coroutines and async generator functions declared with the standard ``@pytest.fixture`` decorator to pytest-asyncio fixtures.

To keep a task group, timeout or cancel scope open across an async generator fixture's ``yield``, enable the experimental :ref:`asyncio_experimental_task_per_fixture <configuration/asyncio_experimental_task_per_fixture>` option.
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ optional-dependencies.docs = [
"sphinx-tabs>=3.5",
]
optional-dependencies.testing = [
"anyio>=4",
"coverage>=6.2",
"hypothesis>=5.7.1",
"pytest-timeout>=2.3",
]
urls."Bug Tracker" = "https://github.com/pytest-dev/pytest-asyncio/issues"
urls.Changelog = "https://pytest-asyncio.readthedocs.io/en/stable/reference/changelog.html"
Expand All @@ -70,8 +72,10 @@ dev = [
{ include-group = "typing" },
]
test = [
"anyio>=4",
"coverage>=6.2",
"hypothesis>=5.7.1",
"pytest-timeout>=2.3",
]
docs = [
"sphinx>=5.3",
Expand Down
Loading
Loading