Deep Engineering

MEASUREMENT

bench/ctxmgr-async/exitstack.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/context-managers
How to run it
Времени здесь не измеряется вообще: все четыре скрипта смотрят на порядок
вызовов, тип вышедшего наружу объекта и текст сообщений. Такие наблюдения
честны между любыми сборками — в отличие от наносекунд, которые в этом
репозитории между версиями не сравниваются.

## Главный результат: `suppress` и `ExceptionGroup`

**Утверждение подтвердилось.** `contextlib.suppress` научился подавлять
исключение *внутри* `BaseExceptionGroup` в **3.12** — и ни строчкой раньше.
Граница видна и в исходнике, и в поведении.

| | 3.11.15 | 3.12.3 | 3.13.7 | 3.14.7 |
| --- | --- | --- | --- | --- |
| ветка `BaseExceptionGroup` в `suppress.__exit__` | нет | **есть** | есть | есть |
| `suppress(ValueError)` над `ExceptionGroup([ValueError])` | группа выходит наружу | **подавлено** | подавлено | подавлено |
| над `ExceptionGroup([ValueError, TypeError])` | наружу оба | **наружу только TypeError** | только TypeError | только TypeError |
| наружу вышел тот же объект, что бросали | **True** | False | False | False |
| `ValueError` во вложенной группе | остаётся | **вырезан** | вырезан | вырезан |
| `BaseExceptionGroup.split` существует | да | да | да | да |

Последняя строка важна отдельно: `split` был на месте с 3.11. Изменился не он,
а `suppress`, который начал его звать.

Дословный вывод, 3.11 (полностью) — и он же на 3.12+ с отличиями по таблице:

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

Замеры: контекстные менеджеры в эпоху ExceptionGroup и async

Четыре сюжета, которых нет в bench/context-managers/: подавление внутри группы исключений, ExitStack/AsyncExitStack, асинхронный протокол __aenter__/__aexit__ и разбор того, что именно 3.12 сделала с contextlib.

скрипт что показывает
exceptiongroup_suppress.py __exit__ -> True глушит группу целиком; suppress(ValueError) вырезает ветку из группы — но только с 3.12
exitstack.py обратный порядок выхода, частичный отказ в __enter__, подмена исключения внутренним __exit__, pop_all
async_protocol.py минимальный __aenter__/__aexit__, подавление в __aexit__, перекрёстные TypeError, AsyncExitStack
what_changed_312.py из чего собирается строка 3.12 в истории версий: suppress + переписанный @contextmanager

Запуск:

for v in 3.11 3.12 3.13 3.14; do
  echo "== $v"; python$v bench/ctxmgr-async/exceptiongroup_suppress.py
done

Времени здесь не измеряется вообще: все четыре скрипта смотрят на порядок вызовов, тип вышедшего наружу объекта и текст сообщений. Такие наблюдения честны между любыми сборками — в отличие от наносекунд, которые в этом репозитории между версиями не сравниваются.

Главный результат: suppress и ExceptionGroup

Утверждение подтвердилось. contextlib.suppress научился подавлять исключение внутри BaseExceptionGroup в 3.12 — и ни строчкой раньше. Граница видна и в исходнике, и в поведении.

3.11.15 3.12.3 3.13.7 3.14.7
ветка BaseExceptionGroup в suppress.__exit__ нет есть есть есть
suppress(ValueError) над ExceptionGroup([ValueError]) группа выходит наружу подавлено подавлено подавлено
над ExceptionGroup([ValueError, TypeError]) наружу оба наружу только TypeError только TypeError только TypeError
наружу вышел тот же объект, что бросали True False False False
ValueError во вложенной группе остаётся вырезан вырезан вырезан
BaseExceptionGroup.split существует да да да да

Последняя строка важна отдельно: split был на месте с 3.11. Изменился не он, а suppress, который начал его звать.

Дословный вывод, 3.11 (полностью) — и он же на 3.12+ с отличиями по таблице:

$ python3.11 exceptiongroup_suppress.py
PY 3.11.15

1) __exit__ -> True над ExceptionGroup
   __exit__ увидел тип: ExceptionGroup
   __exit__ увидел вложенные: ['ValueError', 'TypeError']
   выполнение продолжилось: группа проглочена целиком, обе ветки

2) with suppress(ValueError): raise ExceptionGroup('eg', [ValueError])
   РЕЗУЛЬТАТ: наружу вышло ExceptionGroup 'eg (1 sub-exception)' вложенные: ['ValueError']

3) with suppress(ValueError): raise ExceptionGroup('mix', [ValueError, TypeError])
   РЕЗУЛЬТАТ: наружу вышло ExceptionGroup 'mix (2 sub-exceptions)'
   вложенные: ['ValueError', 'TypeError']
   это тот же объект, что бросали: True

4) ValueError лежит во ВЛОЖЕННОЙ группе
   РЕЗУЛЬТАТ: наружу вышло ExceptionGroup('outer')[ExceptionGroup('inner')[ValueError, KeyError]]

5) контроль: обычный ValueError без группы
   подавлено

6) BaseExceptionGroup.split(ValueError) доступен и на 3.11:
   match: ['ValueError']
   rest : ['TypeError']
   rest is g: False | сообщение сохранено: True

7) есть ли ветка BaseExceptionGroup в Lib/contextlib.py: False
   файл: /usr/lib/python3.11/contextlib.py
   строка класса suppress: 429

8) except* ValueError над той же группой
   поймано except*: ExceptionGroup ['ValueError']
   осталось непойманным: ExceptionGroup ['TypeError']

Те же пункты на 3.14.7:

$ python3.14 exceptiongroup_suppress.py
PY 3.14.7

1) __exit__ -> True над ExceptionGroup
   __exit__ увидел тип: ExceptionGroup
   __exit__ увидел вложенные: ['ValueError', 'TypeError']
   выполнение продолжилось: группа проглочена целиком, обе ветки

2) with suppress(ValueError): raise ExceptionGroup('eg', [ValueError])
   РЕЗУЛЬТАТ: подавлено, выполнение продолжилось

3) with suppress(ValueError): raise ExceptionGroup('mix', [ValueError, TypeError])
   РЕЗУЛЬТАТ: наружу вышло ExceptionGroup 'mix (1 sub-exception)'
   вложенные: ['TypeError']
   это тот же объект, что бросали: False

4) ValueError лежит во ВЛОЖЕННОЙ группе
   РЕЗУЛЬТАТ: наружу вышло ExceptionGroup('outer')[ExceptionGroup('inner')[KeyError]]

5) контроль: обычный ValueError без группы
   подавлено

6) BaseExceptionGroup.split(ValueError) доступен и на 3.11:
   match: ['ValueError']
   rest : ['TypeError']
   rest is g: False | сообщение сохранено: True

7) есть ли ветка BaseExceptionGroup в Lib/contextlib.py: True
   файл: .../cpython-3.14.7-linux-x86_64-gnu/lib/python3.14/contextlib.py
   строка класса suppress: 433

8) except* ValueError над той же группой
   поймано except*: ExceptionGroup ['ValueError']
   осталось непойманным: ExceptionGroup ['TypeError']

(3.12.3 и 3.13.7 дают ровно те же строки; различаются только PY, путь к contextlib.py и номер строки класса — 429 на 3.12, 433 на 3.13 и 3.14.)

Что здесь стоит прочитать вместе. Пункт 1 и пункт 3 — это две разные операции, которые в разговорах называют одним словом «подавить». Возврат истины из __exit__ убирает группу, включая ветки, о которых менеджер не знает. suppress с 3.12 убирает ветку, а остаток пересобирает в новую группу — поэтому rest is g даёт False, а сообщение группы сохраняется.

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

Раздела «contextlib» в «Что нового в 3.12» нет вовсе — проверено поиском по Doc/whatsnew/3.12.rst (тег v3.14.5): ноль вхождений слова contextlib. Изменение задокументировано в двух других местах:

  • Doc/library/contextlib.rst, строки 279–325 (тег v3.14.5), у suppress:

    If the code within the with block raises a BaseExceptionGroup, suppressed exceptions are removed from the group. Any exceptions of the group which are not suppressed are re-raised in a new group which is created using the original group's derive method.

    .. versionchanged:: 3.12suppress now supports suppressing exceptions raised as part of a BaseExceptionGroup.

    Перевод: «Если код внутри блока with возбуждает BaseExceptionGroup, подавляемые исключения удаляются из группы. Те исключения группы, которые не подавлены, возбуждаются заново в новой группе, созданной методом derive исходной группы.» / «Изменено в версии 3.12: suppress теперь поддерживает подавление исключений, возбуждённых в составе BaseExceptionGroup

  • Misc/NEWS.d/3.12.0b1.rst, запись gh-issue: 103791, date: 2023-04-24:

    contextlib.suppress now supports suppressing exceptions raised as part of an ExceptionGroup. If other exceptions exist on the group, they are re-raised in a group that does not contain the suppressed exceptions.

    Перевод: «contextlib.suppress теперь поддерживает подавление исключений, возбуждённых в составе ExceptionGroup. Если в группе есть другие исключения, они возбуждаются заново в группе, не содержащей подавленных.»

Реализация — Lib/contextlib.py, класс suppress, метод __exit__ (строка 429 на 3.11 и 3.12, строка 433 на 3.13.7 и 3.14.7). Разница между 3.11 и 3.12 в одном методе целиком:

# 3.11
return exctype is not None and issubclass(exctype, self._exceptions)

# 3.12+
if exctype is None:
    return
if issubclass(exctype, self._exceptions):
    return True
if issubclass(exctype, BaseExceptionGroup):
    match, rest = excinst.split(self._exceptions)
    if rest is None:
        return True
    raise rest
return False

Правило except*Doc/reference/compound_stmts.rst, строки 333–395 (тег v3.14.5):

When an exception group is raised in the try block, each except* clause splits (see BaseExceptionGroup.split) it into the subgroups of matching and non-matching exceptions.

Перевод: «Когда в блоке try возбуждается группа исключений, каждое предложение except* разделяет её (см. BaseExceptionGroup.split) на подгруппы подходящих и неподходящих исключений.» — то есть suppress с 3.12 делает ровно то же самое, что except*, только без синтаксиса.

ExitStack / AsyncExitStack

Вывод exitstack.py одинаков на всех четырёх версиях (различается только строка PY):

$ python3.13 exitstack.py
PY 3.13.7

1) порядок при обычном выходе:
   enter a
   enter b
   enter c
   exit  c (видит None)
   exit  b (видит None)
   exit  a (видит None)

2) третий менеджер упал в __enter__: c не открылся
   enter a
   enter b
   enter c -> ОШИБКА
   exit  b (видит RuntimeError)
   exit  a (видит RuntimeError)
   d не открывался и не закрывался; a и b закрыты в обратном порядке

3) наружу вышло ValueError: из тела
   enter a
   enter b
   exit  b (видит ValueError)
   exit  a (видит ValueError)

4) inner вернул True — блок завершился без исключения:
   enter outer
   enter inner
   exit  inner (видит ValueError)
   exit  outer (видит None)
   outer увидел None, потому что inner уже подавил

5) наружу вышло: KeyError 'подмена'
   __context__: ValueError из тела
   enter outer
   enter inner
   exit  inner (видит ValueError)
   exit  outer (видит KeyError)
   outer увидел уже KeyError, а не исходный ValueError

6) после выхода из with, но до handle.close(): []
   после handle.close(): ['callback выполнен']
   pop_all переносит зарегистрированную уборку в другой объект

7) enter_context(object()) -> TypeError: 'builtins.object' object does not support the context manager protocol

Пункты 4 и 5 — это и есть та фраза документации, ради которой стоит писать про ExitStack отдельно (Doc/library/contextlib.rst, строки 541–546, тег v3.14.5):

Since registered callbacks are invoked in the reverse order of registration, this ends up behaving as if multiple nested with statements had been used with the registered set of callbacks. This even extends to exception handling - if an inner callback suppresses or replaces an exception, then outer callbacks will be passed arguments based on that updated state.

Перевод: «Поскольку зарегистрированные обработчики вызываются в порядке, обратном регистрации, это ведёт себя так, будто использовалось несколько вложенных инструкций with с зарегистрированным набором обработчиков. Это распространяется даже на обработку исключений: если внутренний обработчик подавляет или заменяет исключение, внешним обработчикам будут переданы аргументы, отражающие это обновлённое состояние.»

Про AsyncExitStack — там же, строки 623–631:

An asynchronous context manager, similar to ExitStack, that supports combining both synchronous and asynchronous context managers, as well as having coroutines for cleanup logic. The close method is not implemented; aclose must be used instead.

Перевод: «Асинхронный контекстный менеджер, похожий на ExitStack, который поддерживает объединение синхронных и асинхронных контекстных менеджеров, а также корутины в качестве логики уборки. Метод close не реализован; вместо него нужно использовать aclose.» Это проверено пунктом 7 в async_protocol.py.

Асинхронный протокол

Вывод async_protocol.py одинаков на 3.11, 3.12 и 3.13; на 3.14 меняются два сообщения об ошибке.

$ python3.13 async_protocol.py
PY 3.13.7

1) минимальный асинхронный менеджер:
   aenter a
   тело, as = 'ресурс-a'
   aexit  a (видит None)
   as получает возвращённое значение __aenter__, как и в with

2) __aexit__ вернул True:
   aenter b
   aexit  b (видит ValueError)
   выполнение продолжилось — истина подавляет, как и в __exit__

3a) async with над синхронным менеджером -> TypeError: 'SyncOnly' object does not support the asynchronous context manager protocol
3b) with над асинхронным менеджером     -> TypeError: 'AsyncOnly' object does not support the context manager protocol

4) чередование с фоновой задачей: ['фон-0', 'тело начало', 'фон-1', 'тело кончило', 'фон-2']
   между входом и выходом цикл событий отдавал управление другой задаче

5) @asynccontextmanager: ['до yield d', 'тело: ресурс-d', 'после yield d']

6) AsyncExitStack держит и синхронные, и асинхронные:
   aenter A1
   enter  S1
   aenter A2
   aexit  A2 (видит None)
   exit   S1
   aexit  A1 (видит None)
   выход в обратном порядке регистрации, независимо от вида менеджера

7) у AsyncExitStack есть aclose: True | унаследованный close: False

На 3.14.7 пункт 3 звучит иначе — и это продолжение той же перестановки LOAD_SPECIAL, о которой уже написано в bench/context-managers/README.md:

3a) async with над синхронным менеджером -> TypeError: 'SyncOnly' object does not support the asynchronous context manager protocol (missed __aexit__ method) but it supports the context manager protocol. Did you mean to use 'with'?
3b) with над асинхронным менеджером     -> TypeError: 'AsyncOnly' object does not support the context manager protocol (missed __exit__ method) but it supports the asynchronous context manager protocol. Did you mean to use 'async with'?
сообщение TypeError 3.11 – 3.13 3.14.7
async with над синхронным менеджером без подсказки + «but it supports the context manager protocol. Did you mean to use 'with'?»
with над асинхронным менеджером без подсказки + «but it supports the asynchronous context manager protocol. Did you mean to use 'async with'?»

Первоисточник протокола — Doc/reference/datamodel.rst, строки 3947–3979 (тег v3.14.5):

object.__aenter__(self) — Semantically similar to __enter__(), the only difference being that it must return an awaitable. object.__aexit__(self, exc_type, exc_value, traceback) — Semantically similar to __exit__(), the only difference being that it must return an awaitable.

Перевод: «object.__aenter__(self) — семантически похож на __enter__(), с единственным отличием: должен вернуть ожидаемый объект. object.__aexit__(self, exc_type, exc_value, traceback) — семантически похож на __exit__(), с единственным отличием: должен вернуть ожидаемый объект

И перевод инструкции — Doc/reference/compound_stmts.rst, строки 1592–1629:

manager = (EXPRESSION)
aenter = manager.__aenter__
aexit = manager.__aexit__
value = await aenter()
hit_except = False

try:
    TARGET = value
    SUITE
except:
    hit_except = True
    if not await aexit(*sys.exc_info()):
        raise
finally:
    if not hit_except:
        await aexit(None, None, None)

Строка if not await aexit(...): raise — тот же механизм подавления, что и в синхронном варианте из PEP 343, с точностью до await. Пункт 2 замера это и показывает.

Что 3.12 сделала с contextlib

what_changed_312.py собирает строку 3.12 для истории версий из двух фактов, потому что раздела contextlib в whatsnew 3.12 не существует.

3.11.15 3.12.3 3.13.7 3.14.7
ветка BaseExceptionGroup в suppress.__exit__ нет есть есть есть
@contextmanager зовёт gen.throw(typ, value, tb) да (3 аргумента) нет нет нет
@contextmanager зовёт gen.throw(value) нет да (1 аргумент) да да
прямой gen.throw(typ, value, tb) даёт DeprecationWarning нет да да да
сколько аргументов contextlib передал шпиону 3 1 1 1
у AbstractContextManager есть __slots__ нет нет да да
$ python3.11 what_changed_312.py
PY 3.11.15

1) suppress.__exit__ знает про BaseExceptionGroup: False
   (подробности поведения — в exceptiongroup_suppress.py)

2) _GeneratorContextManager.__exit__ вызывает gen.throw:
   3-аргументную форму throw(typ, value, tb): True
   1-аргументную форму throw(value):         False
   файл: /usr/lib/python3.11/contextlib.py

3) прямой вызов gen.throw(typ, value, tb): принят
   предупреждений: []

4) прямой вызов gen.throw(value): предупреждений 0

5) сколько аргументов contextlib передал в gen.throw: [3]
   3 на 3.11, 1 начиная с 3.12 — это и есть переписанный @contextmanager

6) у contextlib.AbstractContextManager есть __slots__: False
   (это уже 3.13, не 3.12 — приведено, чтобы 3.12 не приписали чужое)
$ python3.12 what_changed_312.py
PY 3.12.3

1) suppress.__exit__ знает про BaseExceptionGroup: True
   (подробности поведения — в exceptiongroup_suppress.py)

2) _GeneratorContextManager.__exit__ вызывает gen.throw:
   3-аргументную форму throw(typ, value, tb): False
   1-аргументную форму throw(value):         True
   файл: /usr/lib/python3.12/contextlib.py

3) прямой вызов gen.throw(typ, value, tb): принят
   предупреждений: [('DeprecationWarning', 'the (type, exc, tb) signature of throw() is deprecated, use the single-arg signature instead.')]

4) прямой вызов gen.throw(value): предупреждений 0

5) сколько аргументов contextlib передал в gen.throw: [1]
   3 на 3.11, 1 начиная с 3.12 — это и есть переписанный @contextmanager

6) у contextlib.AbstractContextManager есть __slots__: False
   (это уже 3.13, не 3.12 — приведено, чтобы 3.12 не приписали чужое)

3.13.7 и 3.14.7 повторяют вывод 3.12 с одной поправкой: пункт 6 даёт True.

Второй факт задокументирован в Doc/whatsnew/3.12.rst, раздел Deprecated, строки 1328–1330 (тег v3.14.5):

The 3-arg signatures (type, value, traceback) of coroutine throw(), generator throw() and async generator throw() are deprecated and may be removed in a future version of Python. Use the single-arg versions of these functions instead. (Contributed by Ofey Chan in gh:89874.)

Перевод: «3-аргументные сигнатуры (тип, значение, трассировка) методов throw() у корутины, генератора и асинхронного генератора объявлены устаревшими и могут быть удалены в будущей версии Python. Вместо них следует использовать одноаргументные версии этих функций.»

Пункт 5 — практическое следствие: @contextmanager перестал сам вызывать устаревшую форму. Обёртка вокруг генератора, перехватывающая throw, увидит разное число аргументов на 3.11 и на 3.12+.

Оговорка про версию

3.14 здесь — это 3.14.7. Все факты, взятые с неё, поведенческие: текст сообщения об ошибке, наличие ветки в исходнике, число аргументов в вызове. От кандидата к финалу такие вещи меняться не должны — но «не должны» это не «проверено», поэтому там, где строки попадают в урок, версия называется полностью.

Тег исходников CPython, по которому цитируются Doc/ и Misc/, — v3.14.5. Файлы Lib/contextlib.py читаются прямо у установленных интерпретаторов (3.11.15, 3.12.3, 3.13.7, 3.14.7), поэтому номера строк в выводе — из них, а не из тега.

Script

126 lines
"""ExitStack: динамическое число менеджеров, порядок выхода и судьба исключений.

Один сюжет: чем ExitStack отличается от `with a, b, c` — тем, что список
менеджеров неизвестен на этапе написания кода, и тем, что выход происходит
в обратном порядке регистрации, а не в порядке вложенности `with`.

Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import sys
from contextlib import ExitStack

print("PY", sys.version.split()[0])
print()

log = []


class CM:
    def __init__(self, name, fail_enter=False, swallow=False, raise_on_exit=None):
        self.name = name
        self.fail_enter = fail_enter
        self.swallow = swallow
        self.raise_on_exit = raise_on_exit

    def __enter__(self):
        if self.fail_enter:
            log.append(f"enter {self.name} -> ОШИБКА")
            raise RuntimeError(f"{self.name} не открылся")
        log.append(f"enter {self.name}")
        return self.name

    def __exit__(self, exc_type, exc, tb):
        log.append(f"exit  {self.name} (видит {exc_type.__name__ if exc_type else None})")
        if self.raise_on_exit is not None:
            raise self.raise_on_exit
        return self.swallow


# --- 1. Порядок выхода — обратный порядку входа ----------------------------
log.clear()
with ExitStack() as stack:
    for n in ("a", "b", "c"):
        stack.enter_context(CM(n))
print("1) порядок при обычном выходе:")
for line in log:
    print("  ", line)
print()

# --- 2. Частичный отказ: уже открытые закрываются, неоткрытые — нет --------
log.clear()
try:
    with ExitStack() as stack:
        stack.enter_context(CM("a"))
        stack.enter_context(CM("b"))
        stack.enter_context(CM("c", fail_enter=True))
        stack.enter_context(CM("d"))
except RuntimeError as e:
    print("2) третий менеджер упал в __enter__:", e)
for line in log:
    print("  ", line)
print("   d не открывался и не закрывался; a и b закрыты в обратном порядке")
print()

# --- 3. Исключение из тела проходит через ВСЕ __exit__ снизу вверх ---------
log.clear()
try:
    with ExitStack() as stack:
        stack.enter_context(CM("a"))
        stack.enter_context(CM("b"))
        raise ValueError("из тела")
except ValueError as e:
    print("3) наружу вышло ValueError:", e)
for line in log:
    print("  ", line)
print()

# --- 4. Внутренний менеджер подавил — внешние узнают об этом ---------------
# Документация: «if an inner callback suppresses or replaces an exception,
# then outer callbacks will be passed arguments based on that updated state».
log.clear()
with ExitStack() as stack:
    stack.enter_context(CM("outer"))          # закрывается вторым
    stack.enter_context(CM("inner", swallow=True))  # закрывается первым
    raise ValueError("из тела")
print("4) inner вернул True — блок завершился без исключения:")
for line in log:
    print("  ", line)
print("   outer увидел None, потому что inner уже подавил")
print()

# --- 5. Замена исключения внутри __exit__ ----------------------------------
log.clear()
try:
    with ExitStack() as stack:
        stack.enter_context(CM("outer"))
        stack.enter_context(CM("inner", raise_on_exit=KeyError("подмена")))
        raise ValueError("из тела")
except BaseException as e:  # noqa: BLE001
    print("5) наружу вышло:", type(e).__name__, e)
    print("   __context__:", type(e.__context__).__name__, e.__context__)
for line in log:
    print("  ", line)
print("   outer увидел уже KeyError, а не исходный ValueError")
print()

# --- 6. callback и pop_all -------------------------------------------------
log.clear()
stack = ExitStack()
with stack:
    stack.callback(log.append, "callback выполнен")
    handle = stack.pop_all()   # передали ответственность наружу
print("6) после выхода из with, но до handle.close():", log)
handle.close()
print("   после handle.close():", log)
print("   pop_all переносит зарегистрированную уборку в другой объект")
print()

# --- 7. Что enter_context делает с не-менеджером ----------------------------
try:
    with ExitStack() as s:
        s.enter_context(object())
except TypeError as e:
    print("7) enter_context(object()) ->", type(e).__name__ + ":", e)
except AttributeError as e:
    print("7) enter_context(object()) ->", type(e).__name__ + ":", e)