diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b3b59a9c..8cce9bd4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 diff --git a/changelog.d/+test-dependencies.downstream.rst b/changelog.d/+test-dependencies.downstream.rst new file mode 100644 index 00000000..f9534da2 --- /dev/null +++ b/changelog.d/+test-dependencies.downstream.rst @@ -0,0 +1 @@ +Added test dependencies on ``anyio >= 4`` and ``pytest-timeout >= 2.3``. diff --git a/changelog.d/1083.added.rst b/changelog.d/1083.added.rst new file mode 100644 index 00000000..8f9bb20b --- /dev/null +++ b/changelog.d/1083.added.rst @@ -0,0 +1 @@ +Added the experimental :ref:`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``. diff --git a/changelog.d/1191.added.rst b/changelog.d/1191.added.rst new file mode 100644 index 00000000..8f9bb20b --- /dev/null +++ b/changelog.d/1191.added.rst @@ -0,0 +1 @@ +Added the experimental :ref:`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``. diff --git a/docs/concepts.rst b/docs/concepts.rst index 3ac57069..736b0630 100644 --- a/docs/concepts.rst +++ b/docs/concepts.rst @@ -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 ` 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 ` gives the exact rules and limitations. diff --git a/docs/concepts_fixture_deadline_example.py b/docs/concepts_fixture_deadline_example.py new file mode 100644 index 00000000..02213034 --- /dev/null +++ b/docs/concepts_fixture_deadline_example.py @@ -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) diff --git a/docs/conftest.py b/docs/conftest.py new file mode 100644 index 00000000..6a82c0e5 --- /dev/null +++ b/docs/conftest.py @@ -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") diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst index 5341f02a..0bc37a2f 100644 --- a/docs/reference/configuration.rst +++ b/docs/reference/configuration.rst @@ -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. diff --git a/docs/reference/decorators/index.rst b/docs/reference/decorators/index.rst index 0fcb7087..d0a25208 100644 --- a/docs/reference/decorators/index.rst +++ b/docs/reference/decorators/index.rst @@ -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 ` option. diff --git a/pyproject.toml b/pyproject.toml index f8dd729d..1b40da64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" @@ -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", diff --git a/pytest_asyncio/_runner.py b/pytest_asyncio/_runner.py new file mode 100644 index 00000000..b299c2e2 --- /dev/null +++ b/pytest_asyncio/_runner.py @@ -0,0 +1,334 @@ +""" +Run each async fixture in one asyncio task, from setup to teardown. + +pytest-asyncio uses this module when +asyncio_experimental_task_per_fixture is enabled. + +pytest calls a fixture's setup, the test and the fixture's teardown as +separate synchronous calls. A fixture's task stays alive between those +calls, so the context managers around its yield are entered and exited +in the same task. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import contextvars +import dataclasses +import functools +from collections.abc import AsyncGenerator, Callable, Coroutine, Generator +from typing import Any, Generic, NoReturn, TypeVar + +_T = TypeVar("_T") + + +@dataclasses.dataclass(frozen=True) +class FixtureSetup(Generic[_T]): + value: _T + context: contextvars.Context + + +class FixtureTask(Generic[_T]): + """ + Run setup and teardown in one task, waiting for pytest between them. + + Constructing it starts the task. Between setup and teardown the + fixture is held: suspended at its yield while pytest uses its value. + A fixture cancelled while held (e.g., by its task group or timeout) + stays held until pytest has torn down its dependents. + """ + + def __init__( + self, + gen: AsyncGenerator[_T], + create_task: Callable[[Coroutine[object, object, None]], asyncio.Task[None]], + *, + name: str, + loop: asyncio.AbstractEventLoop, + on_cancelled_while_held: Callable[[], None], + ) -> None: + __tracebackhide__ = True + self._gen = gen + self.name = name + self._on_cancelled_while_held = on_cancelled_while_held + self.cancelled_while_held = False + self.yielded: asyncio.Future[FixtureSetup[_T]] = loop.create_future() + self._teardown_requested = asyncio.Event() + self.task = create_task(self._run()) + + def setup_result(self) -> FixtureSetup[_T]: + __tracebackhide__ = True + if not self.yielded.done(): + self.task.result() + return self.yielded.result() + + def cancel_setup(self, msg: str | None = None) -> None: + # A fixture that has yielded is torn down, not cancelled. + if not self.yielded.done(): + self.task.cancel(msg) + + def request_teardown(self) -> None: + self._teardown_requested.set() + + async def _run(self) -> None: + try: + value = await anext(self._gen) + except StopAsyncIteration: + if self._teardown_requested.is_set(): + # The setup was interrupted: pytest expects no value. + return + raise + self.yielded.set_result(FixtureSetup(value, contextvars.copy_context())) + cancellation = await self._wait_for_teardown_request() + try: + if cancellation is None: + await anext(self._gen) + else: + await self._gen.athrow(cancellation) + except StopAsyncIteration: + return + try: + msg = f"Async generator fixture {self.name!r} didn't stop. Yield only once." + raise ValueError(msg) + finally: + # Exit the generator's context managers in this task. + await self._gen.aclose() + + async def _wait_for_teardown_request(self) -> asyncio.CancelledError | None: + cancellation: asyncio.CancelledError | None = None + while not self._teardown_requested.is_set(): + try: + await self._teardown_requested.wait() + except asyncio.CancelledError as exc: + # Raise the first cancellation at the yield later, as + # asyncio.TaskGroup does, because the fixture's + # dependents still use the fixture. Do not call + # uncancel(): contexts that exit at the yield read the + # cancellation count. + if cancellation is None: + # Hide this wait from the traceback at the yield. + cancellation = exc.with_traceback(None) + if not self._teardown_requested.is_set(): + self.cancelled_while_held = True + self._on_cancelled_while_held() + return cancellation + + +class TaskRunner: + """ + Run async fixtures and tests in tasks on an asyncio.Runner's loop. + + If an exception such as Ctrl-C escapes the loop during a call, the + call cancels its task. It then waits for the task's cleanup while + the task's fixtures are still set up. A second interruption ends + that wait. + + When a held fixture is cancelled, the runner cancels the running + test or fixture setup. It starts no new one until the fixture is + torn down. + """ + + def __init__(self, asyncio_runner: asyncio.Runner) -> None: + self._asyncio_runner = asyncio_runner + self._loop = asyncio_runner.get_loop() + # The loop keeps only weak references to its tasks. A task + # outlives the call that created it if its cleanup is + # interrupted. + self._unfinished_tasks: set[asyncio.Task[Any]] = set() + self._live_fixtures: set[FixtureTask[Any]] = set() + self._cancel_running_test_or_setup: Callable[[str | None], object] | None = None + self._last_run_was_interrupted = False + + def run( + self, + coro: Coroutine[object, object, _T], + *, + context: contextvars.Context, + ) -> _T: + __tracebackhide__ = True + if (refusal_reason := self._refusal_reason) is not None: + coro.close() + raise RuntimeError(refusal_reason) + # Each Hypothesis example starts from the test's context. + task = self._create_task(coro, context=context.copy()) + with self._cancellable_by_held_fixtures(task.cancel): + try: + self._run_loop_until_any_done(task) + except BaseException as interruption: + task.cancel() + self._join_and_raise(task, interruption) + return task.result() + + def start_fixture( + self, + gen: AsyncGenerator[_T], + *, + context: contextvars.Context, + name: str, + ) -> tuple[FixtureTask[_T], FixtureSetup[_T]]: + __tracebackhide__ = True + if (refusal_reason := self._refusal_reason) is not None: + raise RuntimeError(refusal_reason) + fixture = FixtureTask( + gen, + functools.partial(self._create_task, context=context), + name=name, + loop=self._loop, + on_cancelled_while_held=self._on_held_fixture_cancelled, + ) + self._live_fixtures.add(fixture) + fixture.task.add_done_callback(lambda _: self._live_fixtures.discard(fixture)) + with self._cancellable_by_held_fixtures(fixture.cancel_setup): + try: + self._run_loop_until_any_done(fixture.task, fixture.yielded) + except BaseException as interruption: + # Pytest never receives this fixture. If it still + # yields, tear it down now, while its dependencies + # exist. + fixture.request_teardown() + fixture.cancel_setup() + self._join_and_raise(fixture.task, interruption) + return fixture, fixture.setup_result() + + def finish_fixture(self, fixture: FixtureTask[_T]) -> None: + __tracebackhide__ = True + fixture.request_teardown() + try: + self._run_loop_until_any_done(fixture.task) + except BaseException as interruption: + fixture.task.cancel() + self._join_and_raise(fixture.task, interruption) + fixture.task.result() + + def prepare_loop_close(self) -> None: + try: + self._discard_stale_stop() + finally: + # A fixture that pytest never finalized would stay held. + for fixture in self._live_fixtures: + fixture.request_teardown() + + @property + def _refusal_reason(self) -> str | None: + """Why new work is refused: a held fixture was cancelled.""" + for fixture in self._live_fixtures: + if fixture.cancelled_while_held: + return ( + f"Async fixture {fixture.name!r} was cancelled while waiting for " + "teardown. Until it is torn down, this event loop does not accept " + "new async tests or fixture setups." + ) + return None + + def _create_task( + self, + coro: Coroutine[object, object, _T], + *, + context: contextvars.Context, + ) -> asyncio.Task[_T]: + __tracebackhide__ = True + try: + task = self._loop.create_task(coro, context=context) + except BaseException: + coro.close() # A task factory that fails may not close it. + raise + self._unfinished_tasks.add(task) + task.add_done_callback(self._unfinished_tasks.discard) + return task + + @contextlib.contextmanager + def _cancellable_by_held_fixtures( + self, + cancel: Callable[[str | None], object], + ) -> Generator[None]: + self._cancel_running_test_or_setup = cancel + try: + yield + finally: + self._cancel_running_test_or_setup = None + + def _on_held_fixture_cancelled(self) -> None: + if self._cancel_running_test_or_setup is not None: + self._cancel_running_test_or_setup(self._refusal_reason) + + def _join_and_raise( + self, + task: asyncio.Task[object], + interruption: BaseException, + ) -> NoReturn: + """ + Join the cancelled task and raise its error or the interruption. + + A second interruption is raised unchanged. + """ + __tracebackhide__ = True + try: + self._run_loop_until_any_done(task) + except asyncio.CancelledError: + # Keep the interruption. If a cancelled wait replaced + # Ctrl-C, pytest would report a test failure and run the + # next test. + raise interruption from None + except BaseException as second_interruption: + if _error_raised_by(task) is not second_interruption: + raise + failure = _error_raised_by(task) + if failure is None or failure is interruption: + raise interruption + failure.add_note( + f"Raised during cleanup after pytest-asyncio received: {interruption!r}" + ) + raise failure + + def _run_loop_until_any_done( + self, + *futures: asyncio.Future[Any], + ) -> None: + """ + Ctrl-C cancels this wait and is raised here. + + It does not cancel the test or fixture. + """ + __tracebackhide__ = True + self._discard_stale_stop() + try: + self._asyncio_runner.run( + asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED) + ) + except BaseException as interruption: + self._last_run_was_interrupted = True + if isinstance(interruption, asyncio.CancelledError): + # Runner.run raises Ctrl-C as KeyboardInterrupt. + interruption.add_note( + "pytest-asyncio's internal wait was cancelled, e.g. by code " + "that cancels every task. Cancel only tasks that your code " + "created." + ) + raise + + def _discard_stale_stop(self) -> None: + """ + Work around CPython gh-158406. + + An interrupted run can leave a loop stop queued, which would end + the next run early. Remove this once the oldest supported Python + has a fix. + """ + if self._last_run_was_interrupted: + self._loop.stop() + self._loop.run_forever() + self._last_run_was_interrupted = False + + +def _error_raised_by(task: asyncio.Task[object]) -> BaseException | None: + """ + Return the exception that ended the task, unless it was cancelled. + + asyncio stores a task's KeyboardInterrupt or SystemExit in the task + and also raises it out of the loop. The exception that interrupted a + wait can therefore be this one. + """ + if not task.done() or task.cancelled(): + return None + return task.exception() diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 38b75e41..b1764a94 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -56,6 +56,8 @@ if sys.version_info >= (3, 11): from asyncio import Runner + + from ._runner import FixtureSetup, TaskRunner else: from backports.asyncio.runner import Runner @@ -134,6 +136,13 @@ def pytest_addoption(parser: Parser, pluginmanager: PytestPluginManager) -> None type="bool", default="false", ) + parser.addini( + "asyncio_experimental_task_per_fixture", + help="run each async fixture in a task of its own, from setup to teardown " + "(experimental, Python 3.11+)", + type="bool", + default=False, + ) parser.addini( "asyncio_default_fixture_loop_scope", type="string", @@ -293,7 +302,16 @@ def _validate_scope(scope: str | None, option_name: str) -> None: ) +_USE_TASK_RUNNER = pytest.StashKey[bool]() + + def pytest_configure(config: Config) -> None: + use_task_runner = config.getini("asyncio_experimental_task_per_fixture") + if use_task_runner and sys.version_info < (3, 11): + raise pytest.UsageError( + "asyncio_experimental_task_per_fixture requires Python 3.11 or newer" + ) + config.stash[_USE_TASK_RUNNER] = use_task_runner default_fixture_loop_scope = config.getini("asyncio_default_fixture_loop_scope") _validate_scope(default_fixture_loop_scope, "asyncio_default_fixture_loop_scope") if not default_fixture_loop_scope: @@ -322,6 +340,8 @@ def pytest_report_header(config: Config) -> list[str]: f"asyncio_default_fixture_loop_scope={default_fixture_loop_scope}", f"asyncio_default_test_loop_scope={default_test_loop_scope}", ] + if config.stash[_USE_TASK_RUNNER]: + header.append("asyncio_experimental_task_per_fixture=True") return [ "asyncio: " + ", ".join(header), ] @@ -547,7 +567,9 @@ def _can_substitute(item: Function) -> bool: raise NotImplementedError() def setup(self) -> None: - runner_fixture_id = f"_{self._loop_scope}_scoped_runner" + if self.config.stash[_USE_TASK_RUNNER]: + _raise_if_event_loop_running() + runner_fixture_id = self._runner_fixture_id if runner_fixture_id not in self.fixturenames: self.fixturenames.append(runner_fixture_id) # When loop factories are configured, resolve the loop factory @@ -559,8 +581,7 @@ def setup(self) -> None: return super().setup() def runtest(self) -> None: - runner_fixture_id = f"_{self._loop_scope}_scoped_runner" - runner = self._request.getfixturevalue(runner_fixture_id) + runner = self._request.getfixturevalue(self._runner_fixture_id) context = contextvars.copy_context() synchronized_obj = _synchronize_coroutine( getattr(*self._synchronization_target_attr), runner, context @@ -569,6 +590,12 @@ def runtest(self) -> None: c.setattr(*self._synchronization_target_attr, synchronized_obj) super().runtest() + @property + def _runner_fixture_id(self) -> str: + if self.config.stash[_USE_TASK_RUNNER]: + return f"_{self._loop_scope}_scoped_task_runner" + return f"_{self._loop_scope}_scoped_runner" + @functools.cached_property def _loop_scope(self) -> _ScopeName: """ @@ -892,7 +919,7 @@ def pytest_pyfunc_call(pyfuncitem: Function) -> object | None: def _synchronize_coroutine( func: Callable[..., CoroutineType], - runner: asyncio.Runner, + runner: asyncio.Runner | TaskRunner, context: contextvars.Context, ): """ @@ -931,14 +958,21 @@ def pytest_fixture_setup(fixturedef: FixtureDef, request) -> object | None: or default_loop_scope or fixturedef.scope ) - runner_fixture_id = f"_{loop_scope}_scoped_runner" - runner = request.getfixturevalue(runner_fixture_id) - # Prevent the runner closing before the fixture's async teardown. - runner_fixturedef = request._get_active_fixturedef(runner_fixture_id) - runner_fixturedef.addfinalizer( - functools.partial(fixturedef.finish, request=request) - ) - synchronizer = _fixture_synchronizer(fixturedef, runner, request) + if request.config.stash[_USE_TASK_RUNNER] and _is_coroutine_or_asyncgen( + fixturedef.func + ): + synchronizer = _task_runner_fixture_synchronizer( + fixturedef, request, loop_scope + ) + else: + runner_fixture_id = f"_{loop_scope}_scoped_runner" + runner = request.getfixturevalue(runner_fixture_id) + # Prevent the runner closing before the fixture's async teardown. + runner_fixturedef = request._get_active_fixturedef(runner_fixture_id) + runner_fixturedef.addfinalizer( + functools.partial(fixturedef.finish, request=request) + ) + synchronizer = _fixture_synchronizer(fixturedef, runner, request) _make_asyncio_fixture_function(synchronizer, loop_scope) with MonkeyPatch.context() as c: c.setattr(fixturedef, "func", synchronizer) @@ -1138,3 +1172,187 @@ def factory(): return port return factory + + +def _request_task_runner( + fixturedef: FixtureDef[object], + request: FixtureRequest, + loop_scope: _ScopeName, +) -> TaskRunner: + """Make the loop's task runner a dependency of the fixture in setup.""" + _raise_if_event_loop_running() + name = f"_{loop_scope}_scoped_task_runner" + task_runner: TaskRunner = request.getfixturevalue(name) + # Pytest finishes a fixture when a fixture that it declares as a + # parameter finishes, but not when one that it requests with + # getfixturevalue() does. + request._get_active_fixturedef(name).addfinalizer( + # Pytest passes a SubRequest, which it does not export; + # FixtureRequest is its public base type. + functools.partial(fixturedef.finish, request=request) # type: ignore[arg-type] + ) + return task_runner + + +def _raise_if_event_loop_running() -> None: + # Fail before creating a coroutine: asyncio would refuse to run it, + # and it would stay unawaited. + try: + asyncio.get_running_loop() + except RuntimeError: + pass + else: + raise RuntimeError( + "pytest-asyncio cannot start an async fixture or test while an event " + "loop is running" + ) + + +def _task_runner_fixture_synchronizer( + fixturedef: FixtureDef[object], + request: FixtureRequest, + loop_scope: _ScopeName, +) -> Callable[..., object]: + fixture_function = resolve_fixture_function(fixturedef, request) + if inspect.isasyncgenfunction(fixture_function): + return _wrap_task_runner_asyncgen_fixture( + fixture_function, fixturedef, request, loop_scope + ) + assert inspect.iscoroutinefunction(fixture_function) + return _wrap_task_runner_async_fixture( + fixture_function, fixturedef, request, loop_scope + ) + + +def _cancelled_fixture_error( + phase: Literal["setup", "teardown"], + name: str, +) -> PytestAsyncioError: + """ + Return the error to raise instead of a fixture's CancelledError. + + Pytest does not cache a raw CancelledError as a fixture's setup + error, and does not run a node's remaining finalizers after one. + """ + return PytestAsyncioError(f"The {phase} of async fixture {name!r} was cancelled.") + + +def _wrap_task_runner_asyncgen_fixture( + fixture_function: Callable[ + AsyncGenFixtureParams, AsyncGeneratorType[AsyncGenFixtureYieldType, None] + ], + fixturedef: FixtureDef[object], + request: FixtureRequest, + loop_scope: _ScopeName, +) -> Callable[AsyncGenFixtureParams, AsyncGenFixtureYieldType]: + """Run the setup and teardown of an async generator in one task.""" + name = fixturedef.argname + + @functools.wraps(fixture_function) + def asyncgen_fixture( + *args: AsyncGenFixtureParams.args, **kwargs: AsyncGenFixtureParams.kwargs + ) -> AsyncGenFixtureYieldType: + __tracebackhide__ = True + task_runner = _request_task_runner(fixturedef, request, loop_scope) + try: + fixture_task, setup = task_runner.start_fixture( + fixture_function(*args, **kwargs), + context=contextvars.copy_context(), + name=name, + ) + except asyncio.CancelledError as exc: + raise _cancelled_fixture_error("setup", name) from exc + reset_contextvars = _apply_contextvar_changes(setup.context) + + def finalizer() -> None: + __tracebackhide__ = True + try: + task_runner.finish_fixture(fixture_task) + except asyncio.CancelledError as exc: + raise _cancelled_fixture_error("teardown", name) from exc + finally: + if reset_contextvars is not None: + reset_contextvars() + + request.addfinalizer(finalizer) + return setup.value + + return asyncgen_fixture + + +def _wrap_task_runner_async_fixture( + fixture_function: Callable[ + AsyncFixtureParams, CoroutineType[object, object, AsyncFixtureReturnType] + ], + fixturedef: FixtureDef[object], + request: FixtureRequest, + loop_scope: _ScopeName, +) -> Callable[AsyncFixtureParams, AsyncFixtureReturnType]: + name = fixturedef.argname + + @functools.wraps(fixture_function) + def async_fixture( + *args: AsyncFixtureParams.args, **kwargs: AsyncFixtureParams.kwargs + ) -> AsyncFixtureReturnType: + __tracebackhide__ = True + task_runner = _request_task_runner(fixturedef, request, loop_scope) + + async def setup_coroutine() -> FixtureSetup[AsyncFixtureReturnType]: + __tracebackhide__ = True + result = await fixture_function(*args, **kwargs) + return FixtureSetup(result, contextvars.copy_context()) + + try: + setup = task_runner.run( + setup_coroutine(), context=contextvars.copy_context() + ) + except asyncio.CancelledError as exc: + raise _cancelled_fixture_error("setup", name) from exc + reset_contextvars = _apply_contextvar_changes(setup.context) + if reset_contextvars is not None: + request.addfinalizer(reset_contextvars) + return setup.value + + return async_fixture + + +def _task_runner(asyncio_runner: Runner) -> Iterator[TaskRunner]: + __tracebackhide__ = True + task_runner = TaskRunner(asyncio_runner) + yield task_runner + task_runner.prepare_loop_close() + + +@pytest.fixture(scope="function") +def _function_scoped_task_runner( + _function_scoped_runner: Runner, +) -> Iterator[TaskRunner]: + yield from _task_runner(_function_scoped_runner) + + +@pytest.fixture(scope="class") +def _class_scoped_task_runner( + _class_scoped_runner: Runner, +) -> Iterator[TaskRunner]: + yield from _task_runner(_class_scoped_runner) + + +@pytest.fixture(scope="module") +def _module_scoped_task_runner( + _module_scoped_runner: Runner, +) -> Iterator[TaskRunner]: + yield from _task_runner(_module_scoped_runner) + + +@pytest.fixture(scope="package") +def _package_scoped_task_runner( + _package_scoped_runner: Runner, +) -> Iterator[TaskRunner]: + yield from _task_runner(_package_scoped_runner) + + +@pytest.fixture(scope="session") +def _session_scoped_task_runner( + _session_scoped_runner: Runner, +) -> Iterator[TaskRunner]: + yield from _task_runner(_session_scoped_runner) diff --git a/tests/async_fixtures/test_async_fixtures_contextvars.py b/tests/async_fixtures/test_async_fixtures_contextvars.py index 0b9046eb..73667900 100644 --- a/tests/async_fixtures/test_async_fixtures_contextvars.py +++ b/tests/async_fixtures/test_async_fixtures_contextvars.py @@ -5,6 +5,7 @@ from __future__ import annotations +import sys from textwrap import dedent from typing import Literal @@ -223,3 +224,248 @@ async def test_async_second(): """)) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=2) + + +def test_a_sync_fixture_assignment_is_seen_by_async_tests_on_new_and_reused_loops( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent(""" + from contextvars import ContextVar + + import pytest + + _context_var = ContextVar("context_var") + + @pytest.fixture + def var_fixture(): + token = _context_var.set("value") + yield + _context_var.reset(token) + + @pytest.mark.asyncio(loop_scope="module") + async def test_fixture_before_loop(var_fixture): + assert _context_var.get() == "value" + + @pytest.mark.asyncio(loop_scope="module") + async def test_fixture_after_loop(var_fixture): + assert _context_var.get() == "value" + + @pytest.mark.asyncio(loop_scope="module") + async def test_var_reset(): + with pytest.raises(LookupError): + _context_var.get() + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=3) + + +def test_a_sync_test_assignment_is_seen_by_later_async_tests_on_a_reused_loop( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent(""" + from contextvars import ContextVar + + import pytest + + _context_var = ContextVar("context_var") + + @pytest.mark.asyncio(loop_scope="module") + async def test_async_before(): + pass + + def test_sync(): + _context_var.set("value") + + @pytest.mark.asyncio(loop_scope="module") + async def test_async_after(): + assert _context_var.get() == "value" + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=3) + + +def test_a_test_assignment_does_not_change_its_fixtures_teardown_context( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent(""" + from contextvars import ContextVar + + import pytest + import pytest_asyncio + + _context_var = ContextVar("context_var") + + @pytest_asyncio.fixture + async def var_fixture(): + _context_var.set("value") + yield + assert _context_var.get() == "value" + + @pytest.mark.asyncio + async def test(var_fixture): + _context_var.set("other") + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +def test_an_async_test_assignment_is_not_seen_by_a_fixture_set_up_later( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent(""" + from contextvars import ContextVar + + import pytest + import pytest_asyncio + + _context_var = ContextVar("context_var") + + @pytest.mark.asyncio(loop_scope="module") + async def test_set_var(): + _context_var.set("test value") + + @pytest_asyncio.fixture(loop_scope="module") + async def var_fixture(): + with pytest.raises(LookupError): + _context_var.get() + yield + + @pytest.mark.asyncio(loop_scope="module") + async def test_uses_var(var_fixture): + pass + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +def test_an_async_coroutine_fixture_assignment_is_undone_after_its_test( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent(""" + from contextvars import ContextVar + + import pytest + import pytest_asyncio + + _context_var = ContextVar("context_var") + + @pytest_asyncio.fixture + async def var_fixture(): + _context_var.set("value") + + def test_uses_var(var_fixture): + assert _context_var.get() == "value" + + def test_after(): + with pytest.raises(LookupError): + _context_var.get() + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2) + + +@pytest.mark.skipif( + sys.version_info < (3, 12), reason="Task.get_context() requires Python 3.12" +) +def test_the_current_task_context_is_the_context_of_the_fixture_or_test( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent(""" + import asyncio + from contextvars import ContextVar + + import pytest + import pytest_asyncio + + _context_var = ContextVar("context_var") + + @pytest_asyncio.fixture + async def var_fixture(): + _context_var.set("fixture value") + context = asyncio.current_task().get_context() + assert context[_context_var] == "fixture value" + yield + context = asyncio.current_task().get_context() + assert context[_context_var] == "fixture value" + + @pytest.mark.asyncio + async def test(var_fixture): + _context_var.set("test value") + context = asyncio.current_task().get_context() + assert context[_context_var] == "test value" + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=1) + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="loop.create_task() passes task factories a context from Python 3.11", +) +@pytest.mark.parametrize( + ("task_per_fixture", "seen_by_sync_dependent"), + [ + pytest.param("false", "missing", id="default runner"), + pytest.param("true", "set by the task factory", id="experimental runner"), + ], +) +def test_a_task_factory_assignment_in_the_task_context_reaches_sync_dependents( + pytester: Pytester, task_per_fixture: str, seen_by_sync_dependent: str +): + """Only the experimental runner passes this context to sync code.""" + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makeconftest(dedent(""" + import asyncio + import contextvars + + import pytest + + trace = contextvars.ContextVar("trace") + + def traced_task_factory(loop, coro, *, context=None, **kwargs): + if context is None: + context = contextvars.copy_context() + context = context.copy() + context.run(trace.set, "set by the task factory") + return asyncio.Task(coro, loop=loop, context=context, **kwargs) + + def traced_loop_factory(): + loop = asyncio.new_event_loop() + loop.set_task_factory(traced_task_factory) + return loop + + def pytest_asyncio_loop_factories(config, item): + return {"traced": traced_loop_factory} + + @pytest.fixture + def trace_var(): + return trace + """)) + pytester.makepyfile(dedent(f""" + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def traced_fixture(trace_var): + assert trace_var.get() == "set by the task factory" + + @pytest.fixture + def sync_dependent(traced_fixture, trace_var): + assert trace_var.get("missing") == "{seen_by_sync_dependent}" + + @pytest.mark.asyncio + async def test_uses_sync_dependent(sync_dependent): + pass + """)) + result = pytester.runpytest( + "--asyncio-mode=strict", + "-o", + f"asyncio_experimental_task_per_fixture={task_per_fixture}", + ) + result.assert_outcomes(passed=1) diff --git a/tests/async_fixtures/test_async_generator_fixtures.py b/tests/async_fixtures/test_async_generator_fixtures.py new file mode 100644 index 00000000..5cc4bc2b --- /dev/null +++ b/tests/async_fixtures/test_async_generator_fixtures.py @@ -0,0 +1,133 @@ +"""Setup, yield and teardown errors of async generator fixtures.""" + +from __future__ import annotations + +import sys +from textwrap import dedent + +import pytest +from pytest import Pytester + + +def test_a_failing_test_leaves_its_fixture_to_run_its_cleanup_after_the_yield( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + cleanup = [] + + @pytest_asyncio.fixture + async def resource(): + yield + await asyncio.sleep(0) + cleanup.append("ran after the yield") + + @pytest.mark.asyncio + async def test_fails(resource): + raise AssertionError("reported to pytest") + + def test_cleanup_ran(): + assert cleanup == ["ran after the yield"] + """)) + + result = pytester.runpytest("--asyncio-mode=strict") + + result.assert_outcomes(failed=1, passed=1) + result.stdout.fnmatch_lines(["*AssertionError: reported to pytest*"]) + + +def test_a_fixture_that_never_yields_is_a_setup_error(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def generator(): + return + yield + + @pytest.mark.asyncio + async def test_uses_generator(generator): + pass + """)) + + result = pytester.runpytest("--asyncio-mode=strict") + + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines(["*ERROR at setup of test_uses_generator*"]) + + +def test_a_fixture_that_yields_twice_is_a_teardown_error(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def generator(): + yield + yield + + @pytest.mark.asyncio + async def test_uses_generator(generator): + pass + """)) + + result = pytester.runpytest("--asyncio-mode=strict") + + result.assert_outcomes(passed=1, errors=1) + result.stdout.fnmatch_lines( + [ + "*ERROR at teardown of test_uses_generator*", + "*ValueError: Async generator fixture*didn't stop*", + ] + ) + + +@pytest.mark.skipif( + sys.version_info < (3, 11), reason="asyncio.TaskGroup requires Python 3.11" +) +def test_a_task_group_child_failing_during_fixture_setup_is_a_setup_error( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = module") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def service(): + async def fail(): + raise RuntimeError("background service failed") + + async with asyncio.TaskGroup() as group: + group.create_task(fail()) + await asyncio.Event().wait() + yield + + @pytest.mark.asyncio(loop_scope="module") + async def test_uses_service(service): + pass + + @pytest.mark.asyncio(loop_scope="module") + async def test_next(): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(errors=1, passed=1) + result.stdout.fnmatch_lines( + [ + "*ERROR at setup of test_uses_service*", + "*RuntimeError: background service failed*", + ] + ) diff --git a/tests/conftest.py b/tests/conftest.py index eecab735..90756369 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,19 @@ from __future__ import annotations +import pytest + pytest_plugins = "pytester" + + +@pytest.fixture +def pytester( + pytester: pytest.Pytester, + pytestconfig: pytest.Config, + monkeypatch: pytest.MonkeyPatch, +) -> pytest.Pytester: + """Run pytester sessions with the runner that this session uses.""" + if pytestconfig.getini("asyncio_experimental_task_per_fixture"): + monkeypatch.setenv( + "PYTEST_ADDOPTS", "-o asyncio_experimental_task_per_fixture=true" + ) + return pytester diff --git a/tests/experimental_task_per_fixture/__init__.py b/tests/experimental_task_per_fixture/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/experimental_task_per_fixture/test_cancelled_fixtures.py b/tests/experimental_task_per_fixture/test_cancelled_fixtures.py new file mode 100644 index 00000000..e7f02f47 --- /dev/null +++ b/tests/experimental_task_per_fixture/test_cancelled_fixtures.py @@ -0,0 +1,318 @@ +""" +Async fixtures cancelled in setup, while held, or in teardown. + +With asyncio_experimental_task_per_fixture, a fixture cancelled while +pytest holds it makes its event loop refuse new async tests and fixture +setups until pytest has torn it down. +""" + +from __future__ import annotations + +import sys +from textwrap import dedent + +import pytest +from pytest import Pytester + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio_experimental_task_per_fixture requires Python 3.11", +) + +_COROUTINE_CANCELLED_IN_SETUP = """\ +import asyncio + +import pytest_asyncio + +@pytest_asyncio.fixture(loop_scope="module") +async def resource(): + asyncio.current_task().cancel("setup cancelled") + await asyncio.sleep(0) +""" + +_GENERATOR_CANCELLED_IN_SETUP = """\ +import asyncio + +import pytest_asyncio + +@pytest_asyncio.fixture(loop_scope="module") +async def resource(): + asyncio.current_task().cancel("setup cancelled") + await asyncio.sleep(0) + yield +""" + + +@pytest.mark.parametrize( + "conftest", + [ + pytest.param(_COROUTINE_CANCELLED_IN_SETUP, id="coroutine"), + pytest.param(_GENERATOR_CANCELLED_IN_SETUP, id="generator"), + ], +) +def test_a_fixture_cancelled_during_setup_is_a_setup_error_of_its_test_only( + pytester: Pytester, conftest: str +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makeconftest(conftest) + pytester.makepyfile(dedent("""\ + import pytest + + @pytest.mark.asyncio(loop_scope="module") + async def test_uses_resource(resource): + pass + + @pytest.mark.asyncio(loop_scope="module") + async def test_next(): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(errors=1, passed=1) + result.stdout.fnmatch_lines( + [ + "*ERROR at setup of test_uses_resource*", + "*CancelledError: setup cancelled", + "*PytestAsyncioError: *'resource' was cancelled*", + ] + ) + + +def test_a_module_fixture_cancelled_during_setup_is_a_setup_error_of_each_test( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture(scope="module", loop_scope="module") + async def resource(): + asyncio.current_task().cancel() + await asyncio.sleep(0) + yield + + @pytest.mark.asyncio(loop_scope="module") + async def test_first(resource): + pass + + @pytest.mark.asyncio(loop_scope="module") + async def test_second(resource): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(errors=2) + result.stdout.fnmatch_lines( + [ + "*ERROR at setup of test_first*", + "*PytestAsyncioError: *'resource' was cancelled*", + "*ERROR at setup of test_second*", + "*PytestAsyncioError: *'resource' was cancelled*", + ] + ) + + +def test_new_async_work_on_the_loop_of_a_cancelled_held_fixture_is_refused( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture(scope="module", loop_scope="module") + async def service(): + yield asyncio.current_task() + + @pytest_asyncio.fixture(loop_scope="module") + async def new_fixture(): + pytest.fail("the loop should have refused this setup") + + @pytest.mark.asyncio(loop_scope="module") + async def test_cancels_the_service(service): + service.cancel() + await asyncio.Event().wait() + + @pytest.mark.asyncio(loop_scope="module") + async def test_refused(): + pytest.fail("the loop should have refused this test") + + @pytest.mark.asyncio(loop_scope="module") + async def test_refused_setup(new_fixture): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(failed=2, errors=2) + result.stdout.fnmatch_lines( + [ + "*ERROR at setup of test_refused_setup*", + "*RuntimeError: *'service' was cancelled while waiting for teardown*", + "*_ test_refused _*", + "*RuntimeError: *'service' was cancelled while waiting for teardown*", + ] + ) + + +def test_tests_on_other_loops_run_while_a_cancelled_fixture_is_held( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture(scope="module", loop_scope="module") + async def service(): + yield asyncio.current_task() + + @pytest.mark.asyncio(loop_scope="module") + async def test_cancels_the_service(service): + service.cancel() + await asyncio.Event().wait() + + @pytest.mark.asyncio(loop_scope="function") + async def test_on_its_own_loop(): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-v", timeout=30) + + result.assert_outcomes(failed=1, passed=1, errors=1) + result.stdout.fnmatch_lines(["*::test_on_its_own_loop PASSED*"]) + + +def test_a_loop_accepts_new_tests_once_its_cancelled_fixture_is_torn_down( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = session\n" + "asyncio_default_test_loop_scope = session" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def service(): + yield asyncio.current_task() + + @pytest.mark.asyncio + async def test_cancels_the_service(service): + service.cancel() + await asyncio.Event().wait() + + @pytest.mark.asyncio + async def test_after_teardown(): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-v", timeout=30) + + result.assert_outcomes(failed=1, passed=1, errors=1) + result.stdout.fnmatch_lines(["*::test_after_teardown PASSED*"]) + + +def test_a_cancelled_dependent_teardown_does_not_prevent_its_parent_teardown( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def parent(): + yield + raise RuntimeError("parent cleanup failed") + + @pytest_asyncio.fixture + async def dependent(parent): + yield + asyncio.current_task().cancel() + await asyncio.sleep(0) + + @pytest.mark.asyncio + async def test_uses_dependent(dependent): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(passed=1, errors=1) + result.stdout.fnmatch_lines(["*PytestAsyncioError: *'dependent' was cancelled*"]) + result.stdout.fnmatch_lines(["*RuntimeError: parent cleanup failed*"]) + + +def test_a_fixture_cancelled_twice_while_held_receives_the_first_cancellation( + pytester: Pytester, +): + """asyncio.TaskGroup also keeps the first cancellation.""" + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def parent(): + yield asyncio.current_task() + + @pytest_asyncio.fixture + async def dependent(parent): + yield + parent.cancel("first") + await asyncio.sleep(0) + parent.cancel("second") + await asyncio.sleep(0) + + @pytest.mark.asyncio + async def test_uses_dependent(dependent): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(passed=1, errors=1) + result.stdout.fnmatch_lines(["*CancelledError: first"]) diff --git a/tests/experimental_task_per_fixture/test_context_variables.py b/tests/experimental_task_per_fixture/test_context_variables.py new file mode 100644 index 00000000..75183bd0 --- /dev/null +++ b/tests/experimental_task_per_fixture/test_context_variables.py @@ -0,0 +1,49 @@ +"""Context variables with asyncio_experimental_task_per_fixture.""" + +from __future__ import annotations + +import sys +from textwrap import dedent + +import pytest +from pytest import Pytester + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio_experimental_task_per_fixture requires Python 3.11", +) + + +def test_an_async_fixture_context_is_restored_in_sync_code_when_its_teardown_fails( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent(""" + from contextvars import ContextVar + + import pytest + import pytest_asyncio + + _context_var = ContextVar("context_var") + + @pytest_asyncio.fixture + async def var_fixture(): + _context_var.set("value") + yield + raise ValueError("teardown error") + + @pytest.mark.asyncio + async def test_uses_var(var_fixture): + pass + + def test_after(): + with pytest.raises(LookupError): + _context_var.get() + """)) + result = pytester.runpytest("--asyncio-mode=strict") + result.assert_outcomes(passed=2, errors=1) + result.stdout.fnmatch_lines(["*ValueError: teardown error*"]) diff --git a/tests/experimental_task_per_fixture/test_fixture_requests.py b/tests/experimental_task_per_fixture/test_fixture_requests.py new file mode 100644 index 00000000..d98b3515 --- /dev/null +++ b/tests/experimental_task_per_fixture/test_fixture_requests.py @@ -0,0 +1,134 @@ +""" +Async fixtures and tests requested while an event loop runs, with +asyncio_experimental_task_per_fixture. +""" + +from __future__ import annotations + +import sys +from textwrap import dedent + +import pytest +from pytest import Pytester + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio_experimental_task_per_fixture requires Python 3.11", +) + + +def test_an_async_fixture_requested_by_a_running_test_is_refused_without_harm( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture(scope="session", loop_scope="session") + async def returned(): + return 42 + + @pytest_asyncio.fixture(scope="session", loop_scope="session") + async def yielded(): + yield 42 + + @pytest.mark.asyncio + async def test_requests_new_async_setups(request): + with pytest.raises(RuntimeError, match="event loop is running"): + request.getfixturevalue("returned") + with pytest.raises(RuntimeError, match="event loop is running"): + request.getfixturevalue("yielded") + + @pytest.mark.asyncio(loop_scope="session") + async def test_session_loop_is_usable(): + pass + """)) + + result = pytester.runpytest_subprocess( + "--asyncio-mode=strict", "-W", "error", timeout=30 + ) + + result.assert_outcomes(passed=2) + # -W error turns an unawaited coroutine into a failed exit, after + # the summary. + assert result.ret == pytest.ExitCode.OK + + +def test_repeated_async_fixture_requests_report_the_original_setup_error( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture(scope="session", loop_scope="session") + async def value(): + return 42 + + @pytest.mark.asyncio + async def test_repeated_request(request): + with pytest.raises(RuntimeError, match="event loop is running") as first: + request.getfixturevalue("value") + with pytest.raises(RuntimeError, match="event loop is running") as second: + request.getfixturevalue("value") + assert second.value is first.value + """)) + + result = pytester.runpytest_subprocess( + "--asyncio-mode=strict", "-W", "error", timeout=30 + ) + + result.assert_outcomes(passed=1) + + +def test_async_tests_in_a_running_loop_report_setup_errors_without_coroutine_warnings( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(test_nested=dedent("""\ + import pytest + + @pytest.mark.asyncio + async def test_async(): + pass + """)) + launcher = pytester.makepyfile(run_pytest=dedent("""\ + import asyncio + + import pytest + + async def run(): + return pytest.main( + ["test_nested.py", "--asyncio-mode=strict", "-W", "error"] + ) + + raise SystemExit(asyncio.run(run())) + """)) + + result = pytester.run(sys.executable, str(launcher), timeout=30) + + assert result.ret == pytest.ExitCode.TESTS_FAILED + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines( + [ + "*RuntimeError: pytest-asyncio cannot start an async fixture or test " + "while an event loop is running*" + ] + ) + output = result.stdout.str() + result.stderr.str() + assert "never awaited" not in output + assert "never retrieved" not in output diff --git a/tests/experimental_task_per_fixture/test_interference.py b/tests/experimental_task_per_fixture/test_interference.py new file mode 100644 index 00000000..2f87a935 --- /dev/null +++ b/tests/experimental_task_per_fixture/test_interference.py @@ -0,0 +1,127 @@ +""" +Code that stops the event loop or cancels tasks that it did not create. + +With asyncio_experimental_task_per_fixture, pytest-asyncio runs the loop +until its own tasks finish. Code that interferes with them fails the +test or fixture that runs it. +""" + +from __future__ import annotations + +import sys +from textwrap import dedent + +import pytest +from pytest import Pytester + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio_experimental_task_per_fixture requires Python 3.11", +) + + +def test_an_async_fixture_cancelling_every_other_task_fails_its_teardown_with_a_hint( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest_asyncio + + @pytest_asyncio.fixture(autouse=True) + async def cancel_leftover_tasks(): + yield + for task in asyncio.all_tasks(): + if task is not asyncio.current_task(): + task.cancel() + + async def test_leaves_a_task(): + asyncio.get_running_loop().create_task(asyncio.sleep(100)) + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=auto", timeout=30) + + result.assert_outcomes(passed=1, errors=1) + result.stdout.fnmatch_lines( + [ + "*ERROR at teardown of test_leaves_a_task*", + "*Cancel only tasks that your code created*", + ] + ) + + +def test_a_test_cancelling_every_other_task_fails_with_a_hint_and_later_tests_run( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + + @pytest.mark.asyncio + async def test_cancels_every_other_task(): + others = [ + task + for task in asyncio.all_tasks() + if task is not asyncio.current_task() + ] + for task in others: + task.cancel() + await asyncio.gather(*others, return_exceptions=True) + + @pytest.mark.asyncio + async def test_next(): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(failed=1, passed=1) + result.stdout.fnmatch_lines( + [ + "*_ test_cancels_every_other_task _*", + "*Cancel only tasks that your code created*", + ] + ) + + +def test_a_test_that_stops_the_loop_is_cancelled_before_its_fixtures_are_torn_down( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def report(): + with open("report.txt", "w") as stream: + yield stream + + @pytest.mark.asyncio + async def test_stops_the_loop(report): + asyncio.get_running_loop().stop() + await asyncio.sleep(0) + report.write("ran after the loop stopped") + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(failed=1) + assert (pytester.path / "report.txt").read_text() == "" diff --git a/tests/experimental_task_per_fixture/test_interruption.py b/tests/experimental_task_per_fixture/test_interruption.py new file mode 100644 index 00000000..cfa3e491 --- /dev/null +++ b/tests/experimental_task_per_fixture/test_interruption.py @@ -0,0 +1,440 @@ +""" +Ctrl-C and pytest-timeout with asyncio_experimental_task_per_fixture. + +The interrupted test or fixture is cancelled, and its cleanup runs while +its fixtures are still set up. A second Ctrl-C ends the wait for that +cleanup, which then runs when the loop closes. A loop callback raising +KeyboardInterrupt stands in for Ctrl-C. +""" + +from __future__ import annotations + +import sys +from textwrap import dedent + +import pytest +from pytest import Pytester + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio_experimental_task_per_fixture requires Python 3.11", +) + + +@pytest.mark.skipif(sys.platform == "win32", reason="Uses pytest-timeout's signals") +def test_pytest_timeout_lets_the_test_cleanup_use_its_fixtures(pytester: Pytester): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def report(): + with open("report.txt", "w") as stream: + yield stream + + @pytest.mark.timeout(0.5, func_only=True) + @pytest.mark.asyncio + async def test_hang(report): + try: + await asyncio.Event().wait() + finally: + await asyncio.sleep(0) + report.write("saved during cleanup") + """)) + + result = pytester.runpytest_subprocess( + "--asyncio-mode=strict", + "-p", + "timeout", + "-o", + "timeout_method=signal", + timeout=30, + ) + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines(["*_ test_hang _*", "*Failed: Timeout*0.5s*"]) + assert (pytester.path / "report.txt").read_text() == "saved during cleanup" + + +def test_a_test_calling_sys_exit_fails_and_later_tests_use_its_loop( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import sys + + import pytest + + @pytest.mark.asyncio(loop_scope="module") + async def test_exits(): + sys.exit(3) + + @pytest.mark.asyncio(loop_scope="module") + async def test_next(): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(failed=1, passed=1) + result.stdout.fnmatch_lines(["*_ test_exits _*", "*SystemExit: 3"]) + assert "pytest-asyncio received" not in result.stdout.str() + + +def test_a_cleanup_error_after_ctrl_c_fails_the_test_and_the_session_continues( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + + def ctrl_c(): + raise KeyboardInterrupt + + @pytest.mark.asyncio(loop_scope="module") + async def test_interrupted(): + asyncio.get_running_loop().call_soon(ctrl_c) + try: + await asyncio.Event().wait() + finally: + raise ValueError("cleanup failed") + + @pytest.mark.asyncio(loop_scope="module") + async def test_next(): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + assert result.ret == pytest.ExitCode.TESTS_FAILED + result.assert_outcomes(failed=1, passed=1) + result.stdout.fnmatch_lines( + ["*_ test_interrupted _*", "*ValueError: cleanup failed", "*KeyboardInterrupt*"] + ) + + +def test_sys_exit_during_cleanup_after_ctrl_c_fails_the_test_and_names_the_ctrl_c( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + import sys + + import pytest + + def ctrl_c(): + raise KeyboardInterrupt + + @pytest.mark.asyncio + async def test_exits_during_cleanup(): + asyncio.get_running_loop().call_soon(ctrl_c) + try: + await asyncio.Event().wait() + finally: + sys.exit(3) + + def test_next(): + pass + """)) + + result = pytester.runpytest_subprocess( + "--asyncio-mode=strict", "-W", "error", timeout=30 + ) + + result.assert_outcomes(failed=1, passed=1) + result.stdout.fnmatch_lines( + [ + "*SystemExit: 3", + "*Raised during cleanup after pytest-asyncio received: KeyboardInterrupt()", + ] + ) + assert "never retrieved" not in result.stdout.str() + result.stderr.str() + + +@pytest.mark.skipif(sys.platform == "win32", reason="Uses pytest-timeout's signals") +def test_a_cleanup_error_after_pytest_timeout_is_reported_with_the_timeout( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + + @pytest.mark.timeout(0.1, func_only=True) + @pytest.mark.asyncio + async def test_hang(): + try: + await asyncio.Event().wait() + finally: + raise ConnectionError("cleanup failed") + """)) + + result = pytester.runpytest_subprocess( + "--asyncio-mode=strict", + "-p", + "timeout", + "-o", + "timeout_method=signal", + timeout=30, + ) + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + ["*_ test_hang _*", "*ConnectionError: cleanup failed", "*Timeout*"] + ) + + +def test_a_cleanup_error_in_a_fixture_setup_after_ctrl_c_is_a_setup_error( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + def ctrl_c(): + raise KeyboardInterrupt + + @pytest_asyncio.fixture + async def resource(): + asyncio.get_running_loop().call_soon(ctrl_c) + try: + await asyncio.Event().wait() + yield + finally: + raise ValueError("setup cleanup failed") + + @pytest.mark.asyncio + async def test_uses_resource(resource): + pytest.fail("the fixture setup should have failed") + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + assert result.ret == pytest.ExitCode.TESTS_FAILED + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines( + [ + "*ERROR at setup of test_uses_resource*", + "*ValueError: setup cleanup failed", + "*KeyboardInterrupt*", + ] + ) + + +def test_a_fixture_failing_during_the_test_cleanup_after_ctrl_c_cancels_that_cleanup( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + from pathlib import Path + + import pytest + import pytest_asyncio + + def ctrl_c(): + raise KeyboardInterrupt + + @pytest_asyncio.fixture + async def service(): + failure = asyncio.Event() + + async def fail(): + await failure.wait() + raise RuntimeError("service failed") + + async with asyncio.TaskGroup() as group: + group.create_task(fail()) + yield failure + + @pytest.mark.asyncio + async def test_interrupted(service): + asyncio.get_running_loop().call_soon(ctrl_c) + try: + await asyncio.Event().wait() + finally: + service.set() + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + Path("cleanup.txt").write_text("cancelled") + raise + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.stdout.fnmatch_lines(["*! KeyboardInterrupt !*"]) + assert (pytester.path / "cleanup.txt").read_text() == "cancelled" + + +def test_a_fixture_that_yields_as_ctrl_c_arrives_is_torn_down_before_its_parent( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + def ctrl_c(): + raise KeyboardInterrupt + + @pytest.fixture + def output(): + with open("report.txt", "w") as stream: + yield stream + + @pytest_asyncio.fixture + async def report(output): + asyncio.get_running_loop().call_soon(ctrl_c) + yield output + await asyncio.sleep(0) + output.write("saved during teardown") + + @pytest.mark.asyncio + async def test_uses_report(report): + pytest.fail("the fixture setup should have been interrupted") + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + assert result.ret == pytest.ExitCode.INTERRUPTED + result.assert_outcomes() + assert (pytester.path / "report.txt").read_text() == "saved during teardown" + + +def test_cleanup_abandoned_by_a_second_interruption_finishes_when_the_loop_closes( + pytester: Pytester, +): + """A stopped loop interrupts the test and its cleanup only.""" + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + import gc + from pathlib import Path + + import pytest + + @pytest.mark.asyncio(loop_scope="module") + async def test_interrupted_twice(): + loop = asyncio.get_running_loop() + loop.call_soon(loop.stop) + try: + await asyncio.Event().wait() + finally: + loop.call_soon(loop.stop) + try: + await asyncio.Event().wait() + finally: + await asyncio.sleep(0) + Path("cleanup.txt").write_text("finished") + + def test_collects_garbage(): + gc.collect() + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(failed=1, passed=1) + assert (pytester.path / "cleanup.txt").read_text() == "finished" + + +def test_a_second_ctrl_c_after_cleanup_still_closes_async_generators( + pytester: Pytester, +): + """ + The second Ctrl-C is raised once every task is done, after asyncio + has queued the callback that stops that run of the loop. + """ + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + from pathlib import Path + + import pytest + + def ctrl_c(): + raise KeyboardInterrupt + + def ctrl_c_once_every_task_is_done(): + loop = asyncio.get_running_loop() + if asyncio.all_tasks(loop): + loop.call_soon(ctrl_c_once_every_task_is_done) + else: + raise KeyboardInterrupt + + async def numbers(): + try: + yield 1 + finally: + Path("generator.txt").write_text("closed") + + unfinished_generators = [] + + @pytest.mark.asyncio + async def test_interrupted(): + unfinished_generators.append(numbers()) + await anext(unfinished_generators[0]) + loop = asyncio.get_running_loop() + loop.call_soon(ctrl_c) + try: + await asyncio.Event().wait() + finally: + loop.call_soon(ctrl_c_once_every_task_is_done) + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + assert result.ret == pytest.ExitCode.INTERRUPTED + assert (pytester.path / "generator.txt").read_text() == "closed" diff --git a/tests/experimental_task_per_fixture/test_scopes_spanning_yield.py b/tests/experimental_task_per_fixture/test_scopes_spanning_yield.py new file mode 100644 index 00000000..e5eb8533 --- /dev/null +++ b/tests/experimental_task_per_fixture/test_scopes_spanning_yield.py @@ -0,0 +1,559 @@ +""" +Task groups, cancel scopes and timeouts that span a fixture's yield. + +With asyncio_experimental_task_per_fixture, a fixture exits such a scope +in the task that entered it (issues #1083 and #1191). If the scope +cancels the fixture while pytest holds it, the running test is +cancelled, and the scope's error is reported at the fixture's teardown. +""" + +from __future__ import annotations + +import sys +from textwrap import dedent + +import pytest +from pytest import Pytester + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio_experimental_task_per_fixture requires Python 3.11", +) + +_EAGER_TASK_LOOP = """\ +import asyncio + +def eager_task_loop(): + loop = asyncio.new_event_loop() + loop.set_task_factory(asyncio.eager_task_factory) + return loop + +def pytest_asyncio_loop_factories(config, item): + return {"eager_task_loop": eager_task_loop} +""" + + +@pytest.mark.parametrize( + "conftest", + [ + pytest.param("", id="default loop"), + pytest.param( + _EAGER_TASK_LOOP, + id="eager tasks", + marks=pytest.mark.skipif( + sys.version_info < (3, 12), + reason="asyncio.eager_task_factory requires Python 3.12", + ), + ), + ], +) +def test_a_task_group_child_failing_during_the_test_cancels_the_test( + pytester: Pytester, conftest: str +): + """Issue #1083: the child's error is reported at teardown.""" + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function\n" + "asyncio_debug = true" + ) + pytester.makeconftest(conftest) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def service(): + failure = asyncio.Event() + + async def fail(): + await failure.wait() + raise RuntimeError("background service failed") + + async with asyncio.TaskGroup() as group: + group.create_task(fail()) + yield failure + + @pytest.mark.asyncio + async def test_uses_service(service): + service.set() + await asyncio.Event().wait() + """)) + + result = pytester.runpytest_subprocess( + "--asyncio-mode=strict", "-W", "error", timeout=30 + ) + + result.assert_outcomes(failed=1, errors=1) + result.stdout.fnmatch_lines( + [ + "*ERROR at teardown of test_uses_service*", + "*RuntimeError: background service failed*", + "*_ test_uses_service _*", + "*CancelledError: Async fixture 'service' was cancelled*", + ] + ) + output = result.stdout.str() + result.stderr.str() + for noise in ("Task was destroyed", "never retrieved", "never awaited"): + assert noise not in output + + +def test_a_task_group_child_failing_during_a_dependent_setup_cancels_that_setup( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def service(): + failure = asyncio.Event() + + async def fail(): + await failure.wait() + raise RuntimeError("background service failed") + + async with asyncio.TaskGroup() as group: + group.create_task(fail()) + yield failure + + @pytest_asyncio.fixture + async def dependent(service): + service.set() + await asyncio.Event().wait() + yield + + @pytest.mark.asyncio + async def test_uses_dependent(dependent): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(errors=2) + result.stdout.fnmatch_lines( + [ + "*ERROR at setup of test_uses_dependent*", + "*PytestAsyncioError: *'dependent' was cancelled*", + "*ERROR at teardown of test_uses_dependent*", + "*RuntimeError: background service failed*", + ] + ) + + +def test_an_anyio_task_group_spanning_the_yield_is_exited_in_its_own_task( + pytester: Pytester, +): + """Issue #1191: teardown exits the group's cancel scope.""" + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import anyio + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def service(): + async with anyio.create_task_group() as group: + group.start_soon(asyncio.Event().wait) + yield + group.cancel_scope.cancel() + + @pytest.mark.asyncio + async def test_uses_service(service): + await asyncio.sleep(0) + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(passed=1) + + +_ASYNCIO_TASK_GROUP_PARENT = """\ +import asyncio + +import pytest_asyncio + +@pytest_asyncio.fixture +async def parent(): + failure = asyncio.Event() + + async def fail(): + await failure.wait() + raise RuntimeError("background task failed") + + try: + async with asyncio.TaskGroup() as group: + group.create_task(fail()) + yield failure + print("parent continued after the yield") + finally: + print("parent torn down") +""" + +_ANYIO_TASK_GROUP_PARENT = """\ +import anyio +import pytest_asyncio + +@pytest_asyncio.fixture +async def parent(): + failure = anyio.Event() + + async def fail(): + await failure.wait() + raise RuntimeError("background task failed") + + try: + async with anyio.create_task_group() as group: + group.start_soon(fail) + yield failure + print("parent continued after the yield") + finally: + print("parent torn down") +""" + + +@pytest.mark.parametrize( + "conftest", + [ + pytest.param(_ASYNCIO_TASK_GROUP_PARENT, id="asyncio task group"), + pytest.param(_ANYIO_TASK_GROUP_PARENT, id="anyio task group"), + ], +) +def test_a_cancelled_fixture_stays_set_up_until_its_dependents_are_torn_down( + pytester: Pytester, conftest: str +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makeconftest(conftest) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def dependent(parent): + yield + await asyncio.sleep(0) + print("dependent torn down") + + @pytest.mark.asyncio + async def test_uses_dependent(parent, dependent): + parent.set() + try: + await asyncio.Event().wait() + finally: + await asyncio.sleep(0) + print("test cleaned up") + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-s", timeout=30) + + result.assert_outcomes(failed=1, errors=1) + result.stdout.fnmatch_lines( + ["*test cleaned up", "*dependent torn down", "*parent torn down"] + ) + result.stdout.no_fnmatch_line("*parent continued after the yield") + result.stdout.fnmatch_lines( + [ + "*ERROR at teardown of test_uses_dependent*", + "*RuntimeError: background task failed*", + ] + ) + + +_ASYNCIO_TIMEOUT = """\ +import asyncio + +import pytest_asyncio + +@pytest_asyncio.fixture +async def expire(): + async with asyncio.timeout(None) as timeout: + yield lambda: timeout.reschedule(asyncio.get_running_loop().time()) +""" + +_ANYIO_TIMEOUT = """\ +import anyio +import pytest_asyncio + +@pytest_asyncio.fixture +async def expire(): + with anyio.fail_after(None) as scope: + + def expire_now(): + scope.deadline = anyio.current_time() + + yield expire_now +""" + + +@pytest.mark.parametrize( + "conftest", + [ + pytest.param(_ASYNCIO_TIMEOUT, id="asyncio.timeout"), + pytest.param(_ANYIO_TIMEOUT, id="anyio.fail_after"), + ], +) +def test_a_timeout_spanning_the_yield_expiring_during_the_test_cancels_it( + pytester: Pytester, conftest: str +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makeconftest(conftest) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + + @pytest.mark.asyncio + async def test_times_out(expire): + expire() + await asyncio.Event().wait() + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(failed=1, errors=1) + result.stdout.fnmatch_lines( + [ + "*ERROR at teardown of test_times_out*", + "*TimeoutError*", + "*_ test_times_out _*", + "*CancelledError*", + ] + ) + # The fixture's traceback ends at its yield, not inside pytest-asyncio. + result.stdout.fnmatch_lines(["conftest.py:*: CancelledError"]) + + +def test_a_fixture_timeout_is_reported_when_the_test_catches_its_cancellation( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def deadline(): + async with asyncio.timeout(None) as timeout: + yield timeout + + @pytest.mark.asyncio + async def test_catches_cancellation(deadline): + deadline.reschedule(asyncio.get_running_loop().time()) + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(passed=1, errors=1) + result.stdout.fnmatch_lines( + ["*ERROR at teardown of test_catches_cancellation*", "*TimeoutError*"] + ) + + +def test_a_timeout_inside_a_failed_task_group_leaves_the_cancellation_to_the_group( + pytester: Pytester, +): + """ + A timeout and its enclosing task group both cancel the held fixture. + + The timeout sees both requests, as it would if both reached a task + before the task resumed. It therefore raises CancelledError for the + group, not TimeoutError. + """ + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + from pathlib import Path + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def service(): + failure = asyncio.Event() + + async def fail(): + await failure.wait() + raise RuntimeError("service failed") + + async with asyncio.TaskGroup() as group: + group.create_task(fail()) + try: + async with asyncio.timeout(None) as deadline: + yield deadline, failure + except TimeoutError: + await asyncio.sleep(0) + Path("continued.txt").write_text("group cancellation lost") + + @pytest.mark.asyncio + async def test_service(service): + deadline, failure = service + deadline.reschedule(asyncio.get_running_loop().time()) + failure.set() + await asyncio.Event().wait() + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(failed=1, errors=1) + result.stdout.fnmatch_lines( + ["*ERROR at teardown of test_service*", "*RuntimeError: service failed*"] + ) + assert not (pytester.path / "continued.txt").exists() + + +def test_the_test_cleanup_finishes_while_anyio_keeps_cancelling_its_fixture( + pytester: Pytester, +): + """AnyIO cancels the held fixture repeatedly, the test only once.""" + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + from pathlib import Path + + import anyio + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def scope(): + with anyio.CancelScope() as scope: + yield scope + + @pytest.mark.asyncio + async def test_cancels_scope(scope): + scope.cancel() + try: + await asyncio.Event().wait() + finally: + for _ in range(5): + await asyncio.sleep(0) + Path("cleanup.txt").write_text("finished") + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(failed=1) + assert (pytester.path / "cleanup.txt").read_text() == "finished" + + +def test_a_fixture_that_has_yielded_is_not_cancelled_by_its_parent_failing( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def service(): + failure = asyncio.Event() + + async def fail(): + await failure.wait() + raise RuntimeError("service failed") + + with open("report.txt", "w") as report: + async with asyncio.TaskGroup() as group: + group.create_task(fail()) + yield failure, report + + @pytest_asyncio.fixture + async def dependent(service): + failure, report = service + failure.set() + yield + await asyncio.sleep(0) + report.write("dependent teardown finished") + + @pytest.mark.asyncio + async def test_uses_dependent(dependent): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(failed=1, errors=1) + result.stdout.fnmatch_lines(["*RuntimeError: service failed*"]) + assert (pytester.path / "report.txt").read_text() == "dependent teardown finished" + + +def test_a_fixture_that_yields_twice_exits_its_cancel_scope_in_its_own_task( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import anyio + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def generator(): + with anyio.CancelScope(): + yield + yield + + @pytest.mark.asyncio + async def test_uses_generator(generator): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(passed=1, errors=1) + result.stdout.fnmatch_lines( + ["*ValueError: Async generator fixture 'generator' didn't stop*"] + ) + result.stdout.no_fnmatch_line("*cancel scope in a different task*") diff --git a/tests/experimental_task_per_fixture/test_task_factories.py b/tests/experimental_task_per_fixture/test_task_factories.py new file mode 100644 index 00000000..796107ed --- /dev/null +++ b/tests/experimental_task_per_fixture/test_task_factories.py @@ -0,0 +1,54 @@ +"""Custom task factories with asyncio_experimental_task_per_fixture.""" + +from __future__ import annotations + +import sys +from textwrap import dedent + +import pytest +from pytest import Pytester + +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio_experimental_task_per_fixture requires Python 3.11", +) + + +def test_a_task_factory_that_fails_without_closing_the_coroutine_leaves_no_warning( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest_asyncio + + def reject_task(loop, coro, **kwargs): + raise RuntimeError("task creation failed") + + @pytest_asyncio.fixture + def loop(): + loop = asyncio.get_event_loop() + loop.set_task_factory(reject_task) + yield loop + loop.set_task_factory(None) + + @pytest_asyncio.fixture + async def value(loop): + return 42 + + def test_uses_value(value): + pass + """)) + + result = pytester.runpytest_subprocess( + "--asyncio-mode=strict", "-W", "error", timeout=30 + ) + + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines(["*RuntimeError: task creation failed*"]) + assert "never awaited" not in result.stdout.str() + result.stderr.str() diff --git a/tests/hypothesis/test_base.py b/tests/hypothesis/test_base.py index ea37b6a9..d89c7fc0 100644 --- a/tests/hypothesis/test_base.py +++ b/tests/hypothesis/test_base.py @@ -5,8 +5,10 @@ from __future__ import annotations +import sys from textwrap import dedent +import pytest from pytest import Pytester @@ -94,3 +96,54 @@ def test_hypothesis(request, n: int): """)) result = pytester.runpytest("--asyncio-mode=auto") result.assert_outcomes(passed=1) + + +@pytest.mark.parametrize( + ("task_per_fixture", "values_seen"), + [ + pytest.param("false", {"initial", "changed"}, id="default runner"), + pytest.param( + "true", + {"initial"}, + id="experimental runner", + marks=pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio_experimental_task_per_fixture requires Python 3.11", + ), + ), + ], +) +def test_examples_share_context_assignments_only_with_the_default_runner( + pytester: Pytester, task_per_fixture: str, values_seen: set[str] +): + """The experimental runner gives each example the test's context.""" + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent(f""" + from contextvars import ContextVar + + import pytest + from hypothesis import example, given, settings, strategies as st + + request_id = ContextVar("request_id", default="initial") + seen = set() + + @pytest.mark.asyncio + @settings(database=None, deadline=None) + @example(value=False) + @example(value=True) + @given(value=st.booleans()) + async def test_example(value): + seen.add(request_id.get()) + request_id.set("changed") + + def test_values_seen(): + assert seen == {values_seen!r} + """)) + + result = pytester.runpytest( + "--asyncio-mode=strict", + "-o", + f"asyncio_experimental_task_per_fixture={task_per_fixture}", + ) + + result.assert_outcomes(passed=2) diff --git a/tests/test_experimental_task_per_fixture_option.py b/tests/test_experimental_task_per_fixture_option.py new file mode 100644 index 00000000..75449116 --- /dev/null +++ b/tests/test_experimental_task_per_fixture_option.py @@ -0,0 +1,95 @@ +"""The asyncio_experimental_task_per_fixture configuration option.""" + +from __future__ import annotations + +import sys +from textwrap import dedent + +import pytest +from pytest import Config, MonkeyPatch, Pytester + + +@pytest.mark.parametrize( + ("setting", "same_task"), + [ + pytest.param("", "False", id="unset"), + pytest.param( + "asyncio_experimental_task_per_fixture = false", "False", id="false" + ), + pytest.param( + "asyncio_experimental_task_per_fixture = true", + "True", + id="true", + marks=pytest.mark.skipif( + sys.version_info < (3, 11), + reason="asyncio_experimental_task_per_fixture requires Python 3.11", + ), + ), + ], +) +def test_only_the_enabled_option_tears_down_a_fixture_in_its_setup_task( + pytester: Pytester, monkeypatch: MonkeyPatch, setting: str, same_task: str +): + # The runner selected for this session would override the setting + # under test. + monkeypatch.delenv("PYTEST_ADDOPTS", raising=False) + pytester.makeini( + f"[pytest]\nasyncio_default_fixture_loop_scope = function\n{setting}" + ) + pytester.makepyfile(dedent("""\ + import asyncio + from pathlib import Path + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def resource(): + setup_task = asyncio.current_task() + yield + same_task = asyncio.current_task() is setup_task + Path("same-task.txt").write_text(str(same_task)) + + @pytest.mark.asyncio + async def test_uses_resource(resource): + pass + """)) + + result = pytester.runpytest("--asyncio-mode=strict") + + result.assert_outcomes(passed=1) + assert (pytester.path / "same-task.txt").read_text() == same_task + + +@pytest.mark.skipif( + sys.version_info >= (3, 11), reason="Python 3.11 supports the option" +) +def test_enabling_the_option_on_python_3_10_is_a_usage_error(pytester: Pytester): + pytester.makeini( + "[pytest]\n" + "asyncio_experimental_task_per_fixture = true\n" + "asyncio_default_fixture_loop_scope = function" + ) + + result = pytester.runpytest() + + assert result.ret == pytest.ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines( + ["*asyncio_experimental_task_per_fixture requires Python 3.11*"] + ) + + +def test_pytester_sessions_use_the_option_of_this_session( + pytester: Pytester, pytestconfig: Config +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile("def test_passes(): pass") + + result = pytester.runpytest() + + reports_the_option = ( + "asyncio_experimental_task_per_fixture=True" in result.stdout.str() + ) + assert reports_the_option is pytestconfig.getini( + "asyncio_experimental_task_per_fixture" + ) diff --git a/tests/test_interruption.py b/tests/test_interruption.py new file mode 100644 index 00000000..0651a387 --- /dev/null +++ b/tests/test_interruption.py @@ -0,0 +1,293 @@ +""" +Ctrl-C and pytest-timeout while async tests and fixtures run. + +Tests that send SIGINT run on POSIX only. Where the signal itself does +not matter, a loop callback raising KeyboardInterrupt stands in for +Ctrl-C. +""" + +from __future__ import annotations + +import sys +from textwrap import dedent + +import pytest +from pytest import Pytester + + +@pytest.mark.skipif(sys.platform == "win32", reason="Sends SIGINT with os.kill") +def test_ctrl_c_lets_the_test_cleanup_use_its_fixtures(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + import os + import signal + + import pytest + import pytest_asyncio + + # asyncio.Runner handles SIGINT only if the default handler is + # installed. The process that runs pytest may ignore SIGINT. + signal.signal(signal.SIGINT, signal.default_int_handler) + + @pytest_asyncio.fixture + async def report(): + with open("report.txt", "w") as stream: + yield stream + + @pytest.mark.asyncio + async def test_interrupted(report): + asyncio.get_running_loop().call_soon(os.kill, os.getpid(), signal.SIGINT) + try: + await asyncio.Event().wait() + finally: + await asyncio.sleep(0) + report.write("saved during cleanup") + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + assert result.ret == pytest.ExitCode.INTERRUPTED + assert (pytester.path / "report.txt").read_text() == "saved during cleanup" + + +@pytest.mark.skipif(sys.platform == "win32", reason="Sends SIGINT with os.kill") +def test_ctrl_c_cancels_the_test_once(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + import os + import signal + from pathlib import Path + + import pytest + + # asyncio.Runner handles SIGINT only if the default handler is + # installed. The process that runs pytest may ignore SIGINT. + signal.signal(signal.SIGINT, signal.default_int_handler) + + @pytest.mark.asyncio + async def test_interrupted(): + asyncio.get_running_loop().call_soon(os.kill, os.getpid(), signal.SIGINT) + try: + await asyncio.Event().wait() + finally: + cancelling = asyncio.current_task().cancelling() + Path("cancelling.txt").write_text(str(cancelling)) + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + assert result.ret == pytest.ExitCode.INTERRUPTED + assert (pytester.path / "cancelling.txt").read_text() == "1" + + +def test_ctrl_c_stops_the_session_even_if_the_test_suppresses_the_cancellation( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + from pathlib import Path + + import pytest + + def ctrl_c(): + raise KeyboardInterrupt + + @pytest.mark.asyncio + async def test_interrupted(): + asyncio.get_running_loop().call_soon(ctrl_c) + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + pass + Path("finished.txt").write_text("ran to its end") + + @pytest.mark.asyncio + async def test_next(): + pytest.fail("the session should have stopped") + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + assert result.ret == pytest.ExitCode.INTERRUPTED + result.assert_outcomes() + assert (pytester.path / "finished.txt").read_text() == "ran to its end" + + +@pytest.mark.skipif(sys.platform == "win32", reason="Sends SIGINT with os.kill") +def test_ctrl_c_during_an_async_fixture_teardown_cancels_it_where_it_waits( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + import os + import signal + from pathlib import Path + + import pytest + import pytest_asyncio + + # asyncio.Runner handles SIGINT only if the default handler is + # installed. The process that runs pytest may ignore SIGINT. + signal.signal(signal.SIGINT, signal.default_int_handler) + + @pytest_asyncio.fixture + async def resource(): + yield + asyncio.get_running_loop().call_soon(os.kill, os.getpid(), signal.SIGINT) + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + Path("teardown.txt").write_text("cancelled while waiting") + raise + + @pytest.mark.asyncio + async def test_uses_resource(resource): + pass + """)) + + result = pytester.runpytest_subprocess( + "--asyncio-mode=strict", "-W", "error", timeout=30 + ) + + assert result.ret == pytest.ExitCode.INTERRUPTED + result.assert_outcomes(passed=1) + assert (pytester.path / "teardown.txt").read_text() == "cancelled while waiting" + assert "Task was destroyed" not in result.stdout.str() + result.stderr.str() + + +def test_ctrl_c_stops_the_session_when_the_test_cleanup_cancels_every_other_task( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + + def ctrl_c(): + raise KeyboardInterrupt + + @pytest.mark.asyncio + async def test_interrupted(): + asyncio.get_running_loop().call_soon(ctrl_c) + try: + await asyncio.Event().wait() + finally: + await asyncio.sleep(0) + current = asyncio.current_task() + others = [task for task in asyncio.all_tasks() if task is not current] + for task in others: + task.cancel() + await asyncio.gather(*others, return_exceptions=True) + + @pytest.mark.asyncio + async def test_next(): + pytest.fail("the session should have stopped") + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + assert result.ret == pytest.ExitCode.INTERRUPTED + result.assert_outcomes() + + +def test_ctrl_c_just_after_a_test_finishes_stops_the_session(pytester: Pytester): + """ + A Ctrl-C that arrives after the test's last task finishes, but + before its loop stops, is not lost. + + The callback raises once no task is left. That is after asyncio has + queued the callback that stops this run of the loop, provided + pytest-asyncio keeps no task of its own alive after the test. + """ + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + + def ctrl_c_once_every_task_is_done(): + loop = asyncio.get_running_loop() + if asyncio.all_tasks(loop): + loop.call_soon(ctrl_c_once_every_task_is_done) + else: + raise KeyboardInterrupt + + @pytest.mark.asyncio + async def test_finishes(): + asyncio.get_running_loop().call_soon(ctrl_c_once_every_task_is_done) + + @pytest.mark.asyncio + async def test_next(): + pytest.fail("the session should have stopped") + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + assert result.ret == pytest.ExitCode.INTERRUPTED + result.stdout.fnmatch_lines(["*! KeyboardInterrupt !*"]) + result.assert_outcomes() + + +def test_ctrl_c_is_reported_when_the_interrupted_fixture_setup_returns_without_yielding( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + def ctrl_c(): + raise KeyboardInterrupt + + @pytest_asyncio.fixture + async def resource(): + asyncio.get_running_loop().call_soon(ctrl_c) + try: + await asyncio.Event().wait() + except asyncio.CancelledError: + return + yield + + @pytest.mark.asyncio + async def test_uses_resource(resource): + pytest.fail("the fixture setup should have been interrupted") + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + assert result.ret == pytest.ExitCode.INTERRUPTED + result.assert_outcomes() + assert "StopAsyncIteration" not in result.stdout.str() + + +@pytest.mark.skipif(sys.platform == "win32", reason="Uses pytest-timeout's signals") +def test_pytest_timeout_fails_a_test_hung_in_a_busy_loop(pytester: Pytester): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import pytest + + @pytest.mark.timeout(0.5, func_only=True) + @pytest.mark.asyncio + async def test_hang(): + while True: + pass + """)) + + result = pytester.runpytest_subprocess( + "--asyncio-mode=strict", + "-p", + "timeout", + "-o", + "timeout_method=signal", + timeout=30, + ) + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines(["*_ test_hang _*", "*Failed: Timeout*0.5s*"]) diff --git a/tests/test_self_cancellation.py b/tests/test_self_cancellation.py new file mode 100644 index 00000000..1f4acc53 --- /dev/null +++ b/tests/test_self_cancellation.py @@ -0,0 +1,64 @@ +"""Async tests that cancel their own task.""" + +from __future__ import annotations + +from textwrap import dedent + +import pytest +from pytest import Pytester + + +def test_a_test_that_cancels_itself_fails_and_its_fixture_is_torn_down_normally( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + @pytest_asyncio.fixture + async def resource(): + yield + await asyncio.sleep(0) + print("teardown finished") + + @pytest.mark.asyncio + async def test_cancels_itself(resource): + asyncio.current_task().cancel() + await asyncio.sleep(0) + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + [ + "*_ test_cancels_itself _*", + "*CancelledError*", + "*Captured stdout teardown*", + "teardown finished", + ] + ) + + +def test_a_keyboard_interrupt_raised_after_the_test_cancels_itself_stops_the_session( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + + @pytest.mark.asyncio + async def test_cancels_itself(): + asyncio.current_task().cancel() + raise KeyboardInterrupt + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + assert result.ret == pytest.ExitCode.INTERRUPTED + result.assert_outcomes() diff --git a/tests/test_task_cleanup.py b/tests/test_task_cleanup.py index fda60b81..ae1c70d9 100644 --- a/tests/test_task_cleanup.py +++ b/tests/test_task_cleanup.py @@ -24,3 +24,82 @@ async def coroutine(): """)) result = pytester.runpytest("--asyncio-mode=strict") result.assert_outcomes(passed=1) + + +def test_a_task_left_running_by_a_fixture_is_cancelled_when_its_loop_closes( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + from io import StringIO + + import pytest + import pytest_asyncio + + @pytest.fixture(scope="session") + def output(): + stream = StringIO() + yield stream + assert stream.closed + + async def run_forever(output): + try: + await asyncio.Event().wait() + finally: + await asyncio.sleep(0) + output.close() + + @pytest_asyncio.fixture(scope="module", loop_scope="module") + async def background(output): + return asyncio.create_task(run_forever(output)) + + @pytest.mark.asyncio(loop_scope="module") + async def test_starts_the_task(background): + await asyncio.sleep(0) + + @pytest.mark.asyncio(loop_scope="module") + async def test_task_still_runs(background): + assert not background.done() + """)) + + result = pytester.runpytest_subprocess( + "--asyncio-mode=strict", "-W", "error", timeout=30 + ) + + result.assert_outcomes(passed=2) + assert "Task was destroyed" not in result.stdout.str() + result.stderr.str() + + +def test_a_sync_fixture_cancelling_every_task_after_a_test_leaves_later_tests_working( + pytester: Pytester, +): + pytester.makeini( + "[pytest]\n" + "asyncio_default_fixture_loop_scope = module\n" + "asyncio_default_test_loop_scope = module" + ) + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + + @pytest.fixture(autouse=True) + def cancel_leftover_tasks(): + yield + loop = asyncio.get_event_loop() + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + + async def test_leaves_a_task(): + asyncio.get_running_loop().create_task(asyncio.sleep(100)) + + async def test_after(): + pass + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=auto", timeout=30) + + result.assert_outcomes(passed=2) diff --git a/tests/test_task_factories.py b/tests/test_task_factories.py new file mode 100644 index 00000000..9d4947f3 --- /dev/null +++ b/tests/test_task_factories.py @@ -0,0 +1,111 @@ +"""Task factories that wrap or refuse fixture and test tasks.""" + +from __future__ import annotations + +from textwrap import dedent + +from pytest import Pytester + + +def test_a_task_factory_wrapper_failing_after_the_test_raised_reports_both_errors( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest + import pytest_asyncio + + def failing_wrapper_factory(loop, coro, **kwargs): + # Wrap only the next task, the test's. + loop.set_task_factory(None) + + async def run(): + try: + return await coro + finally: + raise RuntimeError("wrapper failed") + + return asyncio.Task(run(), loop=loop, **kwargs) + + @pytest_asyncio.fixture + async def failing_wrapper(): + asyncio.get_running_loop().set_task_factory(failing_wrapper_factory) + yield + + @pytest.mark.asyncio + async def test_exits(failing_wrapper): + raise SystemExit("original exit") + """)) + + result = pytester.runpytest_subprocess("--asyncio-mode=strict", timeout=30) + + result.assert_outcomes(failed=1) + result.stdout.fnmatch_lines( + [ + "E *SystemExit: original exit", + "During handling of the above exception, another exception occurred:", + "E *RuntimeError: wrapper failed", + ] + ) + + +def test_a_refused_fixture_task_is_a_setup_error_of_every_test_using_the_fixture( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = module") + pytester.makepyfile(dedent("""\ + import asyncio + + import pytest_asyncio + + def reject_task(loop, coro, **kwargs): + coro.close() + raise RuntimeError("task creation failed") + + @pytest_asyncio.fixture(scope="module", loop_scope="module") + def loop(): + loop = asyncio.get_event_loop() + loop.set_task_factory(reject_task) + yield loop + loop.set_task_factory(None) + + @pytest_asyncio.fixture(scope="module", loop_scope="module") + async def returned(loop): + return 42 + + @pytest_asyncio.fixture(scope="module", loop_scope="module") + async def yielded(loop): + yield 42 + + def test_first_returned(returned): + pass + + def test_second_returned(returned): + pass + + def test_first_yielded(yielded): + pass + + def test_second_yielded(yielded): + pass + """)) + + result = pytester.runpytest_subprocess( + "--asyncio-mode=strict", "-W", "error", timeout=30 + ) + + result.assert_outcomes(errors=4) + result.stdout.fnmatch_lines( + [ + "*ERROR at setup of test_first_returned*", + "E*RuntimeError: task creation failed", + "*ERROR at setup of test_second_returned*", + "E*RuntimeError: task creation failed", + "*ERROR at setup of test_first_yielded*", + "E*RuntimeError: task creation failed", + "*ERROR at setup of test_second_yielded*", + "E*RuntimeError: task creation failed", + ] + ) diff --git a/tox.ini b/tox.ini index 21ba3ead..41786a7b 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,13 @@ minversion = 4.28.0 requires = tox-uv>=1.25 -envlist = build, py310, py311, py312, py313, py314, py310-lower-bounds, docs, pyright +envlist = + build + py310 + py{311,312,313,314,314t}{,-experimental} + py310-lower-bounds + docs + pyright isolated_build = true passenv = CI @@ -11,6 +17,9 @@ passenv = runner = uv-venv-lock-runner package = wheel dependency_groups = test +# tests/conftest.py propagates this setting to pytester sessions. +set_env = + experimental: PYTEST_ADDOPTS = -o asyncio_experimental_task_per_fixture=true commands = make test allowlist_externals = make @@ -29,8 +38,12 @@ commands = description = Run tests with the lowest supported direct dependency versions runner = uv-venv-runner uv_resolution = lowest-direct +# Install test requirements as an extra, so that uv resolves them together with +# the runtime requirements. A dependency group would bring in the newest pytest. +dependency_groups = +extras = testing -[testenv:pytest-dev] +[testenv:pytest-dev{,-experimental}] description = Run tests against pytest main runner = uv-venv-runner deps = @@ -85,8 +98,9 @@ commands = pyright pytest_asyncio/ tests/ [gh-actions] python = 3.10: py310, py310-lower-bounds, build - 3.11: py311 - 3.12: py312 - 3.13: py313, pyright - 3.14-dev: py314 + 3.11: py311, py311-experimental + 3.12: py312, py312-experimental + 3.13: py313, py313-experimental, pyright + 3.14: py314, py314-experimental + 3.14t: py314t, py314t-experimental pypy3: pypy3 diff --git a/uv.lock b/uv.lock index b0865577..fd465529 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -1134,8 +1148,10 @@ docs = [ { name = "sphinx-tabs" }, ] testing = [ + { name = "anyio" }, { name = "coverage" }, { name = "hypothesis" }, + { name = "pytest-timeout" }, ] [package.dev-dependencies] @@ -1145,6 +1161,7 @@ build = [ { name = "twine" }, ] dev = [ + { name = "anyio" }, { name = "build" }, { name = "check-wheel-contents" }, { name = "coverage" }, @@ -1152,6 +1169,7 @@ dev = [ { name = "pre-commit" }, { name = "pyright", extra = ["nodejs"] }, { name = "pytest" }, + { name = "pytest-timeout" }, { name = "sphinx" }, { name = "sphinx-rtd-theme" }, { name = "sphinx-tabs" }, @@ -1163,8 +1181,10 @@ docs = [ { name = "sphinx-tabs" }, ] test = [ + { name = "anyio" }, { name = "coverage" }, { name = "hypothesis" }, + { name = "pytest-timeout" }, ] typing = [ { name = "pyright", extra = ["nodejs"] }, @@ -1173,10 +1193,12 @@ typing = [ [package.metadata] requires-dist = [ + { name = "anyio", marker = "extra == 'testing'", specifier = ">=4" }, { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'", specifier = ">=1.1,<2" }, { name = "coverage", marker = "extra == 'testing'", specifier = ">=6.2" }, { name = "hypothesis", marker = "extra == 'testing'", specifier = ">=5.7.1" }, { name = "pytest", specifier = ">=8.4,<10" }, + { name = "pytest-timeout", marker = "extra == 'testing'", specifier = ">=2.3" }, { name = "sphinx", marker = "extra == 'docs'", specifier = ">=5.3" }, { name = "sphinx-rtd-theme", marker = "extra == 'docs'", specifier = ">=1" }, { name = "sphinx-tabs", marker = "extra == 'docs'", specifier = ">=3.5" }, @@ -1191,6 +1213,7 @@ build = [ { name = "twine" }, ] dev = [ + { name = "anyio", specifier = ">=4" }, { name = "build" }, { name = "check-wheel-contents" }, { name = "coverage", specifier = ">=6.2" }, @@ -1198,6 +1221,7 @@ dev = [ { name = "pre-commit" }, { name = "pyright", extras = ["nodejs"] }, { name = "pytest" }, + { name = "pytest-timeout", specifier = ">=2.3" }, { name = "sphinx", specifier = ">=5.3" }, { name = "sphinx-rtd-theme", specifier = ">=1" }, { name = "sphinx-tabs", specifier = ">=3.5" }, @@ -1209,14 +1233,28 @@ docs = [ { name = "sphinx-tabs", specifier = ">=3.5" }, ] test = [ + { name = "anyio", specifier = ">=4" }, { name = "coverage", specifier = ">=6.2" }, { name = "hypothesis", specifier = ">=5.7.1" }, + { name = "pytest-timeout", specifier = ">=2.3" }, ] typing = [ { name = "pyright", extras = ["nodejs"] }, { name = "pytest" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "python-discovery" version = "1.4.4"