ЗАМЕР
bench/ctxmgr-async/async_protocol.py
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/interview/python/context-managers
- Как запустить
Времени здесь не измеряется вообще: все четыре скрипта смотрят на порядок вызовов, тип вышедшего наружу объекта и текст сообщений. Такие наблюдения честны между любыми сборками — в отличие от наносекунд, которые в этом репозитории между версиями не сравниваются. ## Главный результат: `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+ с отличиями по таблице:
Запись прогона
Замеры: контекстные менеджеры в эпоху 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
withblock raises aBaseExceptionGroup, 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'sderivemethod... versionchanged:: 3.12—suppressnow supports suppressing exceptions raised as part of aBaseExceptionGroup.Перевод: «Если код внутри блока
withвозбуждаетBaseExceptionGroup, подавляемые исключения удаляются из группы. Те исключения группы, которые не подавлены, возбуждаются заново в новой группе, созданной методомderiveисходной группы.» / «Изменено в версии 3.12:suppressтеперь поддерживает подавление исключений, возбуждённых в составеBaseExceptionGroup.»Misc/NEWS.d/3.12.0b1.rst, записьgh-issue: 103791,date: 2023-04-24:contextlib.suppressnow supports suppressing exceptions raised as part of anExceptionGroup. 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 (seeBaseExceptionGroup.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
withstatements 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. Theclosemethod is not implemented;aclosemust 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()andasync 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), поэтому номера строк в выводе — из них,
а не из тега.
Скрипт
158 строк"""Асинхронный протокол менеджера: __aenter__/__aexit__, async with, AsyncExitStack.
Один сюжет: `async with` — это тот же перевод из справочника, что и `with`,
только с `await` перед каждым вызовом. Проверяем три вещи, которые обычно
путают: что синхронный менеджер в `async with` не работает и наоборот;
что подавление устроено так же (истина из `__aexit__`); и что между
`__aenter__` и `__aexit__` цикл событий действительно успевает выполнить
другую задачу.
Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import asyncio
import sys
from contextlib import asynccontextmanager, AsyncExitStack
print("PY", sys.version.split()[0])
print()
log = []
class AsyncCM:
"""Минимальный асинхронный менеджер: два метода на ТИПЕ, оба awaitable."""
def __init__(self, name, swallow=False):
self.name = name
self.swallow = swallow
async def __aenter__(self):
await asyncio.sleep(0) # реальная точка приостановки
log.append(f"aenter {self.name}")
return f"ресурс-{self.name}"
async def __aexit__(self, exc_type, exc, tb):
await asyncio.sleep(0)
log.append(f"aexit {self.name} (видит {exc_type.__name__ if exc_type else None})")
return self.swallow
class SyncOnly:
def __enter__(self):
return self
def __exit__(self, *a):
return False
class AsyncOnly:
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def main():
# --- 1. Что связывает as ---------------------------------------------
log.clear()
async with AsyncCM("a") as value:
log.append(f"тело, as = {value!r}")
print("1) минимальный асинхронный менеджер:")
for line in log:
print(" ", line)
print(" as получает возвращённое значение __aenter__, как и в with")
print()
# --- 2. Подавление устроено так же ------------------------------------
log.clear()
async with AsyncCM("b", swallow=True):
raise ValueError("из тела")
print("2) __aexit__ вернул True:")
for line in log:
print(" ", line)
print(" выполнение продолжилось — истина подавляет, как и в __exit__")
print()
# --- 3. Перекрёстные ошибки -------------------------------------------
try:
async with SyncOnly():
pass
except TypeError as e:
print("3a) async with над синхронным менеджером ->", type(e).__name__ + ":", e)
try:
with AsyncOnly():
pass
except TypeError as e:
print("3b) with над асинхронным менеджером ->", type(e).__name__ + ":", e)
print()
# --- 4. Между aenter и aexit цикл действительно переключается ---------
order = []
async def other():
for i in range(3):
order.append(f"фон-{i}")
await asyncio.sleep(0)
async def user():
async with AsyncCM("c"):
order.append("тело начало")
await asyncio.sleep(0)
order.append("тело кончило")
log.clear()
await asyncio.gather(user(), other())
print("4) чередование с фоновой задачей:", order)
print(" между входом и выходом цикл событий отдавал управление другой задаче")
print()
# --- 5. @asynccontextmanager -------------------------------------------
@asynccontextmanager
async def managed(name):
log.append(f"до yield {name}")
try:
yield f"ресурс-{name}"
finally:
log.append(f"после yield {name}")
log.clear()
async with managed("d") as v:
log.append(f"тело: {v}")
print("5) @asynccontextmanager:", log)
print()
# --- 6. AsyncExitStack: смешивание синхронных и асинхронных -----------
log.clear()
class SyncCM:
def __init__(self, name):
self.name = name
def __enter__(self):
log.append(f"enter {self.name}")
return self.name
def __exit__(self, *a):
log.append(f"exit {self.name}")
return False
async with AsyncExitStack() as stack:
await stack.enter_async_context(AsyncCM("A1"))
stack.enter_context(SyncCM("S1"))
await stack.enter_async_context(AsyncCM("A2"))
print("6) AsyncExitStack держит и синхронные, и асинхронные:")
for line in log:
print(" ", line)
print(" выход в обратном порядке регистрации, независимо от вида менеджера")
print()
# --- 7. close у AsyncExitStack нет ------------------------------------
s = AsyncExitStack()
print("7) у AsyncExitStack есть aclose:", hasattr(s, "aclose"),
"| унаследованный close:", hasattr(s, "close"))
await s.aclose()
asyncio.run(main())