Deep Engineering

MEASUREMENT

bench/cancellation/05_taskgroup_cancellation.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/python/async-await
How to run it
CPython 3.11.15
CPython 3.12.3
CPython 3.13.7      <- основной прогон, весь дословный вывод ниже с него
CPython 3.14.7

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Отмена в asyncio: воспроизводимые примеры

Здесь доказательство — не время и не байты, а поведение при отмене: что выполнится, что не выполнится, какое исключение куда прилетит и во что превратится. Поэтому файлы лежат целиком: читатель должен получить ровно те же строки, что и мы.

Числа в выводе — только те, что печатает скрипт. Всё, что меняется от запуска к запуску (адреса объектов, loop.time(), номера строк, доли секунды в логе debug-режима), скрипты нормализуют сами и помечают как <...>.

Чем проверено

CPython 3.11.15
CPython 3.12.3
CPython 3.13.7      <- основной прогон, весь дословный вывод ниже с него
CPython 3.14.7

Исходники, на которые ссылаются скрипты, — установленный 3.13.7:

python3.13 -c "import sysconfig;print(sysconfig.get_paths()['stdlib'])"
# /root/.local/share/uv/python/cpython-3.13.7-linux-x86_64-gnu/lib/python3.13

Что где

Файл Что показывает
01_cancellederror_is_baseexception.py CancelledError — не Exception; except Exception её пропускает, except BaseException без raise её убивает
02_finally_budget.py finally при отмене выполняется, но бюджета у него нет; повторный cancel(); проглоченная отмена; счётчик cancelling()
03_timeout_vs_wait_for.py asyncio.timeout против wait_for: внутри CancelledError, снаружи TimeoutError; чем таймаут отличает свою отмену от внешней
04_shield.py что shield защищает (работу) и чего не защищает (ожидающего)
05_taskgroup_cancellation.py отмена соседей в TaskGroup, ExceptionGroup, except*, контраст с gather
06_debug_slow_callback.py debug-режим asyncio и loop.slow_callback_duration: что именно печатается на медленный коллбэк
version_matrix.py один файл на четырёх интерпретаторах: где границы двигались

Запуск:

python3.13 bench/cancellation/01_cancellederror_is_baseexception.py
python3.13 bench/cancellation/02_finally_budget.py
python3.13 bench/cancellation/03_timeout_vs_wait_for.py
python3.13 bench/cancellation/04_shield.py
python3.13 bench/cancellation/05_taskgroup_cancellation.py
python3.13 bench/cancellation/06_debug_slow_callback.py

python3.11 bench/cancellation/version_matrix.py
python3.12 bench/cancellation/version_matrix.py
python3.13 bench/cancellation/version_matrix.py
python3.14 bench/cancellation/version_matrix.py

Все семь файлов запускаются на всех четырёх интерпретаторах и на всех четырёх завершаются с кодом 0.

Дословный вывод: решающие места

Полный вывод каждого файла получается запуском — он длинный, и переписывать его сюда целиком значило бы завести второй экземпляр того, что и так печатается. Ниже только те строки, ради которых файлы написаны.

01 — где CancelledError в иерархии и что из этого следует.

  __mro__                                        ('CancelledError', 'BaseException', 'object')
  issubclass(CancelledError, Exception)          False
  asyncio.CancelledError is concurrent.futures.CancelledError False
  concurrent.futures.CancelledError.__mro__      ('CancelledError', 'Error', 'Exception', 'BaseException', 'object')

  except Exception                               пропустил
  except BaseException                           ПОЙМАЛ

И то, ради чего это меряется, — на живой задаче:

  б) try/except BaseException/finally, БЕЗ raise
      task.cancelled()                           False
      итог                                       await task вернул: 'вернул значение, как будто ничего не было'

  д) contextlib.suppress(CancelledError)
      task.cancelled()                           False
      итог                                       await task вернул: None

Ловушка не в том, что отмену трудно поймать, а в том, что её легко поймать НЕ ТАМ: except Exception пропускает, except BaseException без raise — убивает.

02 — у finally нет бюджета.

  уборка просила                                 0.30 с
  от cancel() до конца задачи прошло             0.30 с

== 3. Повторный cancel() обрывает уборку; счётчик cancelling() растёт
  после 1-го cancel(): task.cancelling()         1
  после 2-го cancel(): task.cancelling()         2
    | finally: уборку оборвали на await

== 6. cancelling()/uncancel() — счётчик, а не флаг
  cancel()                                       1
  uncancel() вернул                              0
  итог                                           бросил CancelledError

Сколько уборка попросила — столько цикл и ждал. Предел появляется только тогда, когда отменяющая сторона отменяет ещё раз.

03 — одно событие, две ветки except.

    | изнутри блока прилетел CancelledError
    | снаружи блока: TimeoutError
    | цепочка: TimeoutError <-cause- CancelledError

== 3. Проглотили отмену внутри блока — таймаут молча не сработал
    | тело досчиталось до конца, хотя таймаут истёк
    | TimeoutError НЕ прилетел
    | блок с лимитом 0.05 с занял 0.15 с

04 — что shield защищает.

== 1. Отменяем ОЖИДАЮЩЕГО: внутренняя задача не замечает
  outer.cancelled()                              True
  inner.cancelled()                              False
  inner.result()                                 'внутренняя-результат'

== 4. shield под asyncio.timeout
    | снаружи: TimeoutError через 0.05 с
    | защищённая всё же доработала за 0.25 с

Таймаут отменил ожидание, но не работу. Не зная этого, получают «таймаут сработал, а запрос всё равно ушёл».

05 — отмена соседей и что выходит наружу.

== 2. Две задачи упали — обе в одном ExceptionGroup
    ExceptionGroup: 'unhandled errors in a TaskGroup' (2 шт.)
      ValueError: V
      KeyError: 'K'

== 3. CancelledError от ребёнка группу НЕ роняет
  итог                                           группа вышла штатно

== 4. Внешняя отмена родителя: наружу CancelledError, не ExceptionGroup
  await parent ->                                CancelledError

06 — debug-режим и порог.

  новый цикл: get_debug()                        False
  новый цикл: slow_callback_duration             0.1

  б) debug включён, порог по умолчанию 0.1 с
    | WARNING:asyncio:Executing <Handle blocking_callback() ...> took <N> seconds

  в) debug включён, порог поднят до 1.0 с — блокировка не заметна
    (предупреждения нет)

== 3. Тот же блок, но внутри задачи — жалуются на Task, а не на Handle
    | WARNING:asyncio:Executing <Task finished name='Task-<n>' coro=<blocking_step() ...>> took <N> seconds

Режим и порог — две независимые ручки: без debug порог не смотрится вовсе, а с порогом 1,0 с блокировка на 0,25 с не видна. Формат строки один, меняется только объект: Handle для call_soon, TimerHandle для call_later, Task для шага задачи — последний и означает «корутина заблокировала цикл».

Таблица версий

Строки, где выводы четырёх интерпретаторов разошлись:

3.11.15 3.12.3 3.13.7 3.14.7
hasattr(asyncio, 'eager_task_factory') False True True True
hasattr(asyncio.Queue, 'shutdown') False False True True
import asyncio.graph ModuleNotFoundError ModuleNotFoundError ModuleNotFoundError ok
get_event_loop() без работающего цикла вернул цикл, без предупреждений вернул цикл + DeprecationWarning вернул цикл + DeprecationWarning RuntimeError
wait_for: корутина выполняется в той же задаче False True True True
uncancel() снимает ещё не доставленную отмену нет нет да да
create_task у не-входившей TaskGroup: RuntimeWarning 'never awaited' есть есть нет нет

Строки, одинаковые на всех четырёх (граница здесь — 3.11, и она не двигалась):

hasattr(asyncio, 'timeout')                          True
hasattr(asyncio, 'timeout_at')                       True
hasattr(asyncio, 'TaskGroup')                        True
hasattr(asyncio, 'shield')                           True
hasattr(asyncio.Task, 'cancelling')                  True
hasattr(asyncio.Task, 'uncancel')                    True
builtins.ExceptionGroup                              True
builtins.BaseExceptionGroup                          True
синтаксис except*                                    ok
CancelledError.__mro__                               ('CancelledError', 'BaseException', 'object')
issubclass(CancelledError, Exception)                False
asyncio.TimeoutError is builtins.TimeoutError        True
TimeoutError.__mro__                                 ('TimeoutError', 'OSError', 'Exception', 'BaseException', 'object')
Task реализован на C                                 True
wait_for отдаёт значение, если отмену съели          'проглотил и вернул'
ошибка ребёнка под внешним asyncio.timeout           ExceptionGroup['ValueError']
две вложенные TaskGroup падают разом                 ExceptionGroup -> ['ValueError(внешняя)', 'ValueError(внутренняя)']

Три различия из семи — не про наличие имён, а про поведение, и каждое ловится только запуском.

wait_for и «та же задача». На 3.11 wait_for заворачивал переданное в отдельную задачу и ждал её через коллбэк; на 3.12 и дальше он реализован как async with timeouts.timeout(timeout): return await fut (Lib/asyncio/tasks.py:506 на 3.13.7), то есть тело выполняется прямо в вызывающей задаче. Наблюдается это через asyncio.current_task().get_name(): Task-4 против Task-1.

uncancel() и ещё не доставленная отмена. До 3.13 uncancel() уменьшал только счётчик; с 3.13 он ещё и снимает флаг _must_cancel (Lib/asyncio/tasks.py:256-259). Скрипт ловит это так: отменить задачу до её первого шага (тогда _fut_waiter ещё None и отмена только «назначена»), сразу вызвать uncancel() — на 3.11/3.12 задача всё равно отменяется, на 3.13/3.14 доживает до конца и возвращает результат.

create_task у неактивной группы. С 3.13 переданная корутина закрывается, и RuntimeWarning: coroutine ... was never awaited больше не появляется. На 3.11/3.12 предупреждение есть — скрипт ловит его через warnings.catch_warnings(record=True), а не по тексту в stderr.

Про «зависание вложенных TaskGroup». Проба «две вложенные TaskGroup падают разом» обёрнута сторожевым asyncio.timeout(2) — если бы группа повисла, в таблице стояло бы ПОВИСЛО. На всех четырёх версиях она не виснет. Это не опровергает исправление 3.13: сценарий из gh-116720 воспроизводится не любым вложением, и доказать здесь можно только то, что эта проба ведёт себя одинаково. Такой пробы для той ошибки у нас нет.

Первоисточники

Дословные цитаты с переводами стоят во frontmatter статей, которые на эти файлы ссылаются, — там же, где они нужны читателю. Здесь только адреса: документация asyncio (asyncio-task, asyncio-exceptions, asyncio-dev, asyncio-eventloop), PEP 654 и «Что нового в 3.13». Текст снят с .rst-исходников CPython на теге v3.13.7 — это тот же текст, что рендерится на docs.python.org/3.13, и он привязан к точной версии.

Ключевые места в исходниках 3.13.7

Пути от корня установки: .../lib/python3.13/asyncio/.

Файл:строка Что там
exceptions.py:10 class CancelledError(BaseException): — одна строка, из которой растёт весь сюжет
exceptions.py:14 TimeoutError = TimeoutError # make local alias for the standard exception
tasks.py:115-116 self._num_cancels_requested = 0 и self._must_cancel = False — два поля состояния отмены
tasks.py:198 def cancel(self, msg=None)
tasks.py:223 self._num_cancels_requested += 1
tasks.py:224-228 закомментированный if self._num_cancels_requested > 1: return False с пометкой «These two lines are controversial» и ссылкой на обсуждение
tasks.py:236-237 self._must_cancel = True — путь, когда задача ещё не ждёт ни на чём
tasks.py:240 def cancelling(self)
tasks.py:248 def uncancel(self)
tasks.py:256-259 уменьшение счётчика и, с 3.13, сброс _must_cancel при нуле
tasks.py:459 async def wait_for(fut, timeout)
tasks.py:506-507 async with timeouts.timeout(timeout): return await fut — вся реализация wait_for с 3.12
tasks.py:918 def shield(arg)
tasks.py:930-935 «…the task running in something() is not cancelled… If something() is cancelled by other means this will still cancel shield()»
tasks.py:946-949 «Save a reference to tasks passed to this function…»
timeouts.py:88-94 __aenter__: запоминает self._cancelling = self._task.cancelling()
timeouts.py:112 if self._task.uncancel() <= self._cancelling and exc_type is not None: — вот чем своя отмена отличается от чужой
timeouts.py:115-116 if issubclass(exc_type, exceptions.CancelledError): raise TimeoutError from exc_val
timeouts.py:127-132 _on_timeout: self._task.cancel() и переход в состояние EXPIRING
taskgroups.py:35 self._tasks = set() — отсюда невоспроизводимый порядок отмены соседей
taskgroups.py:144-149 if self._parent_cancel_requested: if self._parent_task.uncancel() == 0: propagate_cancellation_error = None
taskgroups.py:169-176 uncancel + повторный cancel родителя, затем raise BaseExceptionGroup('unhandled errors in a TaskGroup', self._errors)
taskgroups.py:186-194 три проверки в create_task, каждая с coro.close() перед RuntimeError
taskgroups.py:221-226 _abort: for t in self._tasks: if not t.done(): t.cancel()
taskgroups.py:235-236 if task.cancelled(): return — отменённый ребёнок группу не роняет
taskgroups.py:257-278 отмена родительской задачи при первой ошибке ребёнка
runners.py:198-206 _cancel_all_tasks: cancel() всем и run_until_complete(gather(...)) без ограничения по времени
base_events.py:432 self.set_debug(coroutines._is_debug_mode())
base_events.py:437 self.slow_callback_duration = 0.1
base_events.py:2038-2047 ветка if self._debug: в _run_once и logger.warning('Executing %s took %.3f seconds', ...)
log.py:7 logger = logging.getLogger(__package__) — логгер называется asyncio, к нему и цепляется обработчик в 06

Оговорки

3.14 здесь — 3.14.7. Финальной сборки на машине нет. В таблицу с 3.14 попали только поведенческие факты (есть ли модуль, что вернёт вызов, какое исключение), и от rc до финала они меняться не должны — но «не должны» это не «проверено».

Числа в выводе — это asyncio.sleep, а не замер. 0,05 / 0,15 / 0,20 / 0,25 / 0,30 с воспроизводятся на любой машине с точностью до второго знака, потому что это заказанная длительность сна, а не измеренная стоимость работы. Единственное место, где стоит настоящее измерение, — секунды в логе debug-режима, и они нормализованы до <N>.

Порядок отмены соседей в TaskGroup не воспроизводим (set в taskgroups.py:35). Сам факт отмены — воспроизводим, порядок — нет.

Про исправление вложенных TaskGroup в 3.13 доказана только рамка. Проба в version_matrix.py показывает, что на всех четырёх версиях этот конкретный сценарий не виснет; воспроизведения самой ошибки gh-116720 у нас нет, и в статье это должно называться цитатой из whatsnew, а не «проверено запуском».

Вывод 06 нормализован. Секунды, адреса, номера строк и имена задач заменены на <...> самим скриптом — иначе дословное сравнение между запусками невозможно. Всё, что не в угловых скобках, — ровно то, что печатает asyncio.

Script

306 lines
"""Отмена в TaskGroup: что происходит с соседями и что видно в ExceptionGroup.

Показывает запуском:
  * одна упала — остальные отменены, их finally выполнился, тело async with
    тоже прервано;
  * две упали — обе приехали в одном ExceptionGroup;
  * CancelledError от ребёнка группу НЕ роняет (в отличие от любого Exception);
  * внешняя отмена родителя выходит как CancelledError, а не ExceptionGroup;
  * ошибка в теле async with попадает в ту же группу;
  * create_task после аварии — RuntimeError;
  * то же на gather для контраста: соседей никто не отменяет.

    python3.13 bench/cancellation/05_taskgroup_cancellation.py
"""

import asyncio
import sys


def row(name, value):
    print(f"  {name:<46} {value}")


def head(title):
    print()
    print(f"== {title}")


async def spin(n=3):
    for _ in range(n):
        await asyncio.sleep(0)


def describe(exc, indent="    "):
    """Дерево ExceptionGroup в виде строк."""
    lines = []

    def walk(e, depth):
        pad = indent + "  " * depth
        if isinstance(e, BaseExceptionGroup):
            lines.append(f"{pad}{type(e).__name__}: {e.message!r} "
                         f"({len(e.exceptions)} шт.)")
            for sub in e.exceptions:
                walk(sub, depth + 1)
        else:
            lines.append(f"{pad}{type(e).__name__}: {e}")

    walk(exc, 0)
    return lines


def states(tasks):
    out = []
    for t in tasks:
        if not t.done():
            state = "ещё работает"
        elif t.cancelled():
            state = "cancelled"
        elif t.exception() is not None:
            state = f"exception {type(t.exception()).__name__}"
        else:
            state = f"result {t.result()!r}"
        out.append(f"{t.get_name():<12} {state}")
    return out


# ---------------------------------------------------------------------------

async def good(log, name, seconds):
    try:
        await asyncio.sleep(seconds)
    except asyncio.CancelledError:
        log.append(f"{name}: получил отмену")
        raise
    finally:
        log.append(f"{name}: finally")
    log.append(f"{name}: доработал")
    return name


async def bad(log, name, seconds, exc):
    await asyncio.sleep(seconds)
    log.append(f"{name}: бросает {type(exc).__name__}")
    raise exc


async def self_cancelling(log, name, seconds):
    await asyncio.sleep(seconds)
    log.append(f"{name}: сам бросает CancelledError")
    raise asyncio.CancelledError()


# ---------------------------------------------------------------------------

async def main():
    print(f"Python {sys.version.split()[0]}")

    head("1. Одна задача упала — соседей отменяют")
    log = []
    handles = []
    try:
        async with asyncio.TaskGroup() as tg:
            handles.append(tg.create_task(good(log, "мирная-1", 5.0),
                                          name="мирная-1"))
            handles.append(tg.create_task(good(log, "мирная-2", 5.0),
                                          name="мирная-2"))
            handles.append(tg.create_task(bad(log, "падучая", 0.05,
                                              ValueError("бух")),
                                          name="падучая"))
            log.append("тело async with: жду 5 с")
            await asyncio.sleep(5.0)
            log.append("тело async with: доспало (не должно)")
    except* ValueError as eg:
        log.append(f"except* ValueError: {len(eg.exceptions)} шт.")
        caught = eg
    for line in log:
        print(f"    | {line}")
    for line in states(handles):
        print(f"    | {line}")
    print("    ExceptionGroup:")
    for line in describe(caught):
        print(line)

    head("2. Две задачи упали — обе в одном ExceptionGroup")
    log = []
    handles = []
    caught = None
    try:
        async with asyncio.TaskGroup() as tg:
            handles.append(tg.create_task(bad(log, "первая", 0.05,
                                              ValueError("V")), name="первая"))
            handles.append(tg.create_task(bad(log, "вторая", 0.05,
                                              KeyError("K")), name="вторая"))
            handles.append(tg.create_task(good(log, "мирная", 5.0),
                                          name="мирная"))
            await asyncio.sleep(5.0)
    except BaseExceptionGroup as eg:
        caught = eg
    for line in log:
        print(f"    | {line}")
    for line in states(handles):
        print(f"    | {line}")
    print("    ExceptionGroup:")
    for line in describe(caught):
        print(line)
    row("тип", type(caught).__name__)
    row("isinstance(eg, Exception)", isinstance(caught, Exception))
    print("    разбор через except*:")
    try:
        raise caught
    except* ValueError as sub:
        print(f"      except* ValueError -> {type(sub).__name__} "
              f"{[type(e).__name__ for e in sub.exceptions]}")
    except* KeyError as sub:
        print(f"      except* KeyError   -> {type(sub).__name__} "
              f"{[type(e).__name__ for e in sub.exceptions]}")

    head("3. CancelledError от ребёнка группу НЕ роняет")
    log = []
    handles = []
    result = "группа вышла штатно"
    try:
        async with asyncio.TaskGroup() as tg:
            handles.append(tg.create_task(self_cancelling(log, "самоотменяющаяся",
                                                          0.05),
                                          name="самоотмена"))
            handles.append(tg.create_task(good(log, "мирная", 0.10),
                                          name="мирная"))
    except BaseExceptionGroup as eg:
        result = f"ExceptionGroup {[type(e).__name__ for e in eg.exceptions]}"
    except asyncio.CancelledError:
        result = "CancelledError"
    for line in log:
        print(f"    | {line}")
    for line in states(handles):
        print(f"    | {line}")
    row("итог", result)
    print("  taskgroups.py:235 — `if task.cancelled(): return`, отменённый")
    print("  ребёнок в _errors не попадает и соседей не роняет.")

    head("4. Внешняя отмена родителя: наружу CancelledError, не ExceptionGroup")
    log = []
    handles = []

    async def parent():
        try:
            async with asyncio.TaskGroup() as tg:
                handles.append(tg.create_task(good(log, "дитя-1", 5.0),
                                              name="дитя-1"))
                handles.append(tg.create_task(good(log, "дитя-2", 5.0),
                                              name="дитя-2"))
                await asyncio.sleep(5.0)
        except BaseExceptionGroup as eg:
            log.append(f"родитель: ExceptionGroup {len(eg.exceptions)}")
            raise
        except asyncio.CancelledError:
            log.append("родитель: CancelledError")
            raise

    ptask = asyncio.ensure_future(parent())
    await spin(4)
    ptask.cancel()
    try:
        await ptask
        verdict = "вернулся нормально"
    except BaseExceptionGroup as eg:
        verdict = f"ExceptionGroup {[type(e).__name__ for e in eg.exceptions]}"
    except asyncio.CancelledError:
        verdict = "CancelledError"
    for line in log:
        print(f"    | {line}")
    for line in states(handles):
        print(f"    | {line}")
    row("await parent ->", verdict)
    row("parent.cancelled()", ptask.cancelled())

    head("5. Ошибка в теле async with едет в ту же группу")
    log = []
    handles = []
    caught = None
    try:
        async with asyncio.TaskGroup() as tg:
            handles.append(tg.create_task(bad(log, "задача", 0.05,
                                              ValueError("из задачи")),
                                          name="задача"))
            await asyncio.sleep(5.0)
    except BaseExceptionGroup as eg:
        caught = eg
    print("    ExceptionGroup:")
    for line in describe(caught):
        print(line)
    log = []
    handles = []
    caught = None
    try:
        async with asyncio.TaskGroup() as tg:
            handles.append(tg.create_task(good(log, "мирная", 5.0),
                                          name="мирная"))
            await asyncio.sleep(0.05)
            raise RuntimeError("из тела async with")
    except BaseExceptionGroup as eg:
        caught = eg
    print("    ExceptionGroup:")
    for line in describe(caught):
        print(line)
    for line in states(handles):
        print(f"    | {line}")

    head("6. create_task у аварийной/законченной группы")
    async def noop():
        return None
    outcome = []
    try:
        async with asyncio.TaskGroup() as tg:
            saved = tg
            tg.create_task(bad([], "падучая", 0.05, ValueError("бух")))
            await asyncio.sleep(5.0)
    except* ValueError:
        pass
    import gc
    import warnings
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        coro = noop()
        try:
            saved.create_task(coro)
            outcome.append("RuntimeError НЕ бросили")
        except RuntimeError as exc:
            outcome.append(f"RuntimeError: {exc}")
        del coro
        gc.collect()
    warned = [str(w.message) for w in caught if "never awaited" in str(w.message)]
    outcome.append(f"RuntimeWarning 'never awaited': {warned or 'нет'}")
    for line in outcome:
        print(f"    | {line}")
    print("  taskgroups.py:186-194 — три отдельные проверки, и каждая перед")
    print("  RuntimeError делает coro.close(), чтобы не осталось «never awaited».")
    print("  coro.close() тут появился в 3.13 — на 3.11/3.12 предупреждение есть.")

    head("7. Для контраста: gather соседей не отменяет")
    log = []
    tasks = [
        asyncio.create_task(good(log, "мирная-1", 0.20), name="мирная-1"),
        asyncio.create_task(good(log, "мирная-2", 0.20), name="мирная-2"),
        asyncio.create_task(bad(log, "падучая", 0.05, ValueError("бух")),
                            name="падучая"),
    ]
    try:
        await asyncio.gather(*tasks)
        verdict = "gather вернулся"
    except ValueError as exc:
        verdict = f"gather бросил {type(exc).__name__}: {exc}"
    row("сразу после gather", verdict)
    for line in states(tasks):
        print(f"    | {line}")
    await asyncio.sleep(0.25)
    print("    ...через 0.25 с:")
    for line in states(tasks):
        print(f"    | {line}")
    for line in log:
        print(f"    | {line}")


if __name__ == "__main__":
    asyncio.run(main())