# **quattro**: task control for asyncio

[![PyPI](https://img.shields.io/pypi/v/quattro.svg)](https://pypi.python.org/pypi/quattro)
[![Build](https://github.com/Tinche/quattro/workflows/CI/badge.svg)](https://github.com/Tinche/quattro/actions?workflow=CI)
[![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/Tinche/87277dd3077fb1eefebc5d4f71b4c4b7/raw/covbadge.json)](https://github.com/Tinche/quattro/actions/workflows/main.yml)
[![Supported Python versions](https://img.shields.io/python/required-version-toml?tomlFilePath=https%3A%2F%2Fraw.githubusercontent.com%2FTinche%2Fquattro%2Fmain%2Fpyproject.toml)](https://github.com/Tinche/quattro)
[![Black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)

______________________________________________________________________

**quattro** is an Apache 2 licensed library, written in Python, for advanced task control in asyncio applications.
_quattro_ is influenced by structured concurrency concepts from the [Trio framework](https://trio.readthedocs.io/en/stable/).

_quattro_ supports Python versions 3.9 - 3.13, including PyPy.

## Installation

To install _quattro_, simply:

```bash
$ pip install quattro
```

## `quattro.gather`

_quattro_ comes with an independent, simple implementation of [`asyncio.gather`](https://docs.python.org/3/library/asyncio-task.html#asyncio.gather) based on Task Groups.
The _quattro_ version is safer, and uses a task group under the hood to not leak tasks in cases of errors in child tasks.

```python
from quattro import gather

async def my_handler():
    res_1, res_2 = await gather(long_query_1(), long_query_2())
```

The `return_exceptions` argument can be used to make `gather()` catch and return exceptions as responses instead of letting them bubble out.

```python
from quattro import gather

async def my_handler():
    res_1, res_2 = await gather(
        long_query_1(),
        long_query_2(),
        return_exceptions=True,
    )

    # res_1 and res_2 may be instances of exceptions.
```

The differences to `asyncio.gather()` are:
- If a child task fails other unfinished tasks will be cancelled, just like in a TaskGroup.
- `quattro.gather()` only accepts coroutines and not futures and generators, just like a TaskGroup.
- When `return_exceptions` is false (the default), an exception in a child task will cause an ExceptionGroup to bubble out of the top-level `gather()` call, just like in a TaskGroup.
- Results are returned as a tuple, not a list.

## Cancel Scopes

_quattro_ contains an independent, asyncio implementation of [Trio CancelScopes](https://trio.readthedocs.io/en/stable/reference-core.html#cancellation-and-timeouts).
Due to fundamental differences between asyncio and Trio the actual runtime behavior isn't exactly the same, but close.

```python
from quattro import move_on_after

async def my_handler():
    with move_on_after(1.0) as cancel_scope:
        await long_query()

    # 1 second later, the function continues running
```

_quattro_ contains the following helpers:

- `move_on_after`
- `move_on_at`
- `fail_after`
- `fail_at`

All helpers produce instances of `quattro.CancelScope`, which is largely similar to the Trio variant.

`CancelScopes` have the following attributes:

- `cancel()` - a method through which the scope can be cancelled manually.
  `cancel()` can be called before the scope is entered; entering the scope will cancel it at the first opportunity
- `deadline` - read/write, an optional deadline for the scope, at which the scope will be cancelled
- `cancelled_caught` - a readonly bool property, whether the scope finished via cancellation

_quattro_ also supports retrieving the current effective deadline in a task using `quattro.current_effective_deadline`.
The current effective deadline is a float value, with `float('inf')` standing in for no deadline.

Python versions 3.11 and higher contain [similar helpers](https://docs.python.org/3/library/asyncio-task.html#timeouts), `asyncio.timeout` and `asyncio.timeout_at`.
The _quattro_ `fail_after` and `fail_at` helpers are effectively equivalent to the asyncio timeouts, and pass the test suite for them.

The differences are:

- The _quattro_ versions are normal context managers (used with just `with`), asyncio versions are async context managers (using `async with`).
  Neither version needs to be async since nothing is awaited; _quattro_ chooses to be non-async to signal there are no suspension points being hit, match Trio and be a little more readable.
- _quattro_ additionally contains the `move_on_at` and `move_on_after` helpers.
- The _quattro_ versions support getting the current effective deadline.
- The _quattro_ versions can be cancelled manually using `scope.cancel()`, and precancelled before they are entered
- The _quattro_ versions are available on all supported Python versions, not just 3.11+.


### asyncio and Trio differences

`fail_after` and `fail_at` raise `asyncio.Timeout` instead of `trio.Cancelled` exceptions when they fail.

asyncio has edge-triggered cancellation semantics, while Trio has level-triggered cancellation semantics.
The following example will behave differently in _quattro_ and Trio:

```python
with trio.move_on_after(TIMEOUT):
    conn = make_connection()
    try:
        await conn.send_hello_msg()
    finally:
        await conn.send_goodbye_msg()
```

In Trio, if the `TIMEOUT` expires while awaiting `send_hello_msg()`, `send_goodbye_msg()` will also be cancelled.
In _quattro_, `send_goodbye_msg()` will run (and potentially block) anyway.
This is a limitation of the underlying framework.

In _quattro_, cancellation scopes cannot be shielded.


## Task Groups

_quattro_ contains a [TaskGroup](https://docs.python.org/3/library/asyncio-task.html#task-groups) subclass with support for background tasks.
TaskGroups are inspired by [Trio nurseries](https://trio.readthedocs.io/en/stable/reference-core.html#nurseries-and-spawning).

```python
from quattro import TaskGroup

async def my_handler():
    # We want to spawn some tasks, and ensure they are all handled before we return.
    async def task_1():
        ...

    async def task_2():
        ...

    async def background_task():
        ...

    async with TaskGroup() as tg:
        t1 = tg.create_task(task_1)
        t2 = tg.create_task(task_2)
        tg.create_background_task(background_task)

    # The end of the `async with` block awaits the tasks, ensuring they are handled.
```

TaskGroups are essential building blocks for achieving the concept of [structured concurrency](https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/).
In simple terms, structured concurrency means your code does not leak tasks - when a coroutine
finishes, all tasks spawned by that coroutine and all its children are also finished.
(In fancy terms, the execution flow becomes a directed acyclic graph.)

Structured concurrency can be achieved by using TaskGroups instead of `asyncio.create_task` to start background tasks.
TaskGroups essentially do two things:

- when exiting from a TaskGroup `async with` block, the TaskGroup awaits all of its children, ensuring they are finished when it exits
- when a TaskGroup child task raises an exception, all other children and the task inside the context manager are cancelled

### Background Tasks

_quattro_ TaskGroups can be used to start _background tasks_.
Background tasks are different than normal tasks in that they do not block an exit from the TaskGroup if they aren't finished.
Instead, any running background tasks are cancelled at the time of exit.
Background tasks are useful for auxiliary tasks that support a main task, for example pumping events between queues.
An unhandled error in a background task will still abort the entire TaskGroup.


## Changelog

### 25.1.1 (2025-02-24)

- Change a `sys.version_info` guard to work better with Pyright.
  ([#17](https://github.com/Tinche/quattro/pull/17))

### 25.1.0 (2025-02-21)

- TaskGroups now support background tasks via `TaskGroup.create_background_task`.
  ([#10](https://github.com/Tinche/quattro/pull/10))
- Add support for Python 3.13.
  ([#9](https://github.com/Tinche/quattro/pull/9))
- Depend on the [_taskgroup_ package](https://pypi.org/project/taskgroup/) on Python 3.9 and 3.10, instead of our own implementation.
  ([#11](https://github.com/Tinche/quattro/pull/11))
- Switch to [uv](https://github.com/astral-sh/uv).
  ([#14](https://github.com/Tinche/quattro/pull/14))
- Introduce [Zizmor](https://github.com/woodruffw/zizmor) for CI hardening.
  ([#12](https://github.com/Tinche/quattro/pull/12))
- _quattro_ now has 100% branch coverage.
  ([#16](https://github.com/Tinche/quattro/pull/16))

### 24.1.0 (2024-05-01)

- Add Trove classifiers.
- Add `name` keyword-only parameter to `TaskGroup.create_task`.
  ([#8](https://github.com/Tinche/quattro/pull/8))

### 23.1.0 (2023-11-29)

- Introduce `quattro.gather`.
  ([#5](https://github.com/Tinche/quattro/pull/5))
- Add support for Python 3.12.
- Switch to [PDM](https://pdm.fming.dev/latest/).

### 22.2.0 (2022-12-27)

- More robust nested cancellation on 3.11.
- Better typing support for `fail_after` and `fail_at`.
- Improve effective deadline handling for pre-cancelled scopes.
- TaskGroups now support custom ContextVar contexts when creating tasks, just like the standard library implementation.

### 22.1.0 (2022-12-19)

- Restore TaskGroup copyright notice.
- TaskGroups now raise ExceptionGroups (using the PyPI backport when necessary) on child errors.
- Add support for Python 3.11, drop 3.8.
- TaskGroups no longer have a `name` and the `repr` is slightly different, to harmonize with the Python 3.11 standard library implementation.
- TaskGroups no longer swallow child exceptions when aborting, to harmonize with the Python 3.11 standard library implementation.
- Switch to CalVer.

### 0.3.0 (2022-01-08)

- Add `py.typed` to enable typing information.
- Flesh out type annotations for TaskGroups.

### 0.2.0 (2021-12-27)

- Add `quattro.current_effective_deadline`.

### 0.1.0 (2021-12-08)

- Initial release, containing task groups and cancellation scopes.

## Credits

The initial TaskGroup implementation has been taken from the [EdgeDB](https://github.com/edgedb/edgedb) project.
The CancelScope implementation was heavily influenced by [Trio](https://trio.readthedocs.io/en/stable/), and inspired by the [async_timeout](https://github.com/aio-libs/async-timeout) package.
