Deep Engineering
Средний·Опубликовано·3.11 · 3.12 · 3.13 · 3.14·40 МИН

Контекстные менеджеры: два метода, одна возвращаемая истина и одна тихая потеря данных

Протокол — ровно два метода на типе. Но судьбу исключения после успешного входа определяет истинность одного значения: того, что вернул __exit__. Вернул истину — исключение исчезло вместе с остатком блока, и в логе не осталось ничего.

Полное техническое изложение

TL;DR

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

Отсюда главное следствие: метод выхода решает не только «убрать за собой». as связывает то, что вернул __enter__, а не сам объект: метод без return отдаёт None. А судьбу исключения после успешного входа определяет истинность значения, которое вернул __exit__: ложь — исключение летит дальше, истина — исчезает вместе с остатком блока. Отсюда тихая потеря данных: suppress вокруг цикла вместо suppress вокруг строки даёт 100 вместо 175, без единого исключения в логе. А если __enter__ бросил исключение, __exit__ не вызывается вовсе — гарантия справочника начинается только после успешного входа.

Дальше — то, что отличает знающего от читавшего. Оба метода ищутся на типе, а не на экземпляре: p.__enter__ = ... даёт TypeError. Подавить группу исключений целиком и вырезать из неё одну ветку — разные операции, и вторую suppress умеет только с 3.12. Цена входа в блок: with на классе — 96 нс против 13 нс у пустого вызова, @contextmanager — 431 нс (3.13.7); на 3.14 форма с классом подешевела до 65 нс, генераторная — до 417.

Порог входа
Перед уроком достаточно понимать
  • функция может закончиться не только return, но и ошибкой, и тогда строки после неё не выполнятся;
  • у программы есть вещи, которые надо не просто взять, а обязательно вернуть: открытый файл, соединение, блокировку;
  • запись with open(…) as f: вы уже видели, даже если не задумывались, что за ней стоит.
Заранее знать не нужно
  • __enter__, __exit__, contextlib, @contextmanager, ExitStack;
  • группы исключений и except*, async with, байт-код и LOAD_SPECIAL.

База: зачем понадобился with

У программы есть вещи, которые надо не просто получить, а потом обязательно вернуть: открытый файл — закрыть, соединение — отдать, блокировку — отпустить. Вся сложность в слове «обязательно». Строка, которая освобождает ресурс, стоит в конце работы, а до конца можно и не дойти: по дороге случится ошибка, и управление уйдёт наружу мимо этой строки. Ресурс останется занятым — файл незакрытым, блокировка невозвращённой.

Поэтому освобождение пишут не «после работы», а в блоке, который выполняется в любом случае:

PYTHON
handle = open("data.txt")
try:
    process(handle)
finally:
    handle.close()      # выполнится и при удаче, и при ошибке

Это верно, но многословно, и повторять это приходится в каждом месте, где ресурс берут. with — та же конструкция, свёрнутая в одну строку:

PYTHON
with open("data.txt") as handle:
    process(handle)

Здесь open сам знает, что нужно сделать на выходе, и делает это — при обычном завершении блока, при return из середины, при исключении. Для читателя with означает ровно одно: выход из этого блока будет обработан, чем бы блок ни кончился.

Отсюда и протокол. Чтобы объект годился для with, он должен уметь две вещи: что-то сделать на входе в блок и что-то сделать на выходе из него. В Python эти две вещи называются __enter__ и __exit__; as получает то, что вернул первый, а второй вызывается на выходе.

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

Механизм 1: два метода, и оба — на типе

контракт языкаГарантия языка: with ищет __enter__ и __exit__ на ТИПЕ, а не на экземпляре. Справочник формулирует это прямо.

Справочник описывает исполнение with семью шагами. Первые пять — про вход: вычислить выражение, загрузить __enter__, загрузить __exit__, вызвать __enter__, присвоить результат цели после as.

Слово «загрузить» здесь не украшение. Оба метода ищутся неявным поиском специального метода — то есть на типе, а не на экземпляре. Присвоить их объекту нельзя:

PYTHON
class Plain:
    pass
 
p = Plain()
p.__enter__ = lambda: None
p.__exit__ = lambda *a: None
 
with p:          # TypeError: 'Plain' object does not
    pass         # support the context manager protocol

На 3.14 к этому сообщению добавится (missed __exit__ method): там __exit__ загружается первым, поэтому именно его имя и попадает в текст ошибки.

Второе, что видно из шага 5: as получает возвращаемое значение __enter__, а не сам менеджер. Метод, забывший return, отдаёт None:

PYTHON
class ReturnsNothing:
    def __enter__(self): pass
    def __exit__(self, *a): pass
 
with ReturnsNothing() as v:
    print(v)     # None

Совпадение объекта и as-переменной — не правило языка, а решение автора менеджера. open() возвращает из __enter__ сам файл, поэтому там они совпадают; contextlib.suppress возвращает себя; @contextmanager возвращает то, что стоит после yield.

Механизм 2: судьбу исключения решает истинность одного значения

PEP 343 записывает перевод with в явный try. Стоит прочитать его целиком — дальше в уроке нет ни одного утверждения, которого не было бы видно прямо здесь:

PYTHON
mgr = (EXPR)
exit = type(mgr).__exit__
value = type(mgr).__enter__(mgr)
exc = True
try:
    try:
        VAR = value
        BLOCK
    except:
        exc = False
        if not exit(mgr, *sys.exc_info()):
            raise
finally:
    if exc:
        exit(mgr, None, None, None)

Строка if not exit(...): raise — это и есть весь механизм подавления. Справочник формулирует то же нормативно: If the return value was true, the exception is suppressed, and execution continues with the statement following the with statement (Если возвращённое значение истинно, исключение подавляется, и выполнение продолжается с инструкции, следующей за with).

Значит, __exit__ может закончиться тремя разными способами. Первые два различает истинность возвращённого значения — именно она, а не конкретный объект: годится любая истина, будь то True, единица или непустая строка. Третий способ — метод сам бросил исключение.

У этого правила есть граница, и она названа в самой записи PEP: exit вызывается после того, как __enter__ вернул управление. До успешного входа решать нечего — __exit__ не позовут вовсе (об этом ниже отдельный раздел).

что вернул __exit__что происходит с исключением
None (или любая ложь)летит наружу, как будто with не было
истинаисчезает; выполнение идёт со следующей после with строки
сам бросил исключениенаружу летит новое, старое становится его контекстом

Метод __exit__, который заканчивается return True «чтобы не падало», глушит не ту ошибку, ради которой его писали, а все.

Механизм 3: ошибка, которая не падает

Как и с генераторами, интересна не та ошибка, что роняет программу. Вот разбор строк с одной битой записью:

PYTHON
ROWS = [
    {"id": 1, "amount": "100"},
    {"id": 2, "amount": "no data"},   # битая
    {"id": 3, "amount": "50"},
    {"id": 4, "amount": "25"},
]

Правильный вариант — suppress вокруг одной строки:

PYTHON
total = 0
for row in ROWS:
    with suppress(ValueError):
        total += int(row["amount"])

Неправильный отличается положением двух строк — suppress оказывается вокруг цикла:

PYTHON
total = 0
with suppress(ValueError):
    for row in ROWS:
        total += int(row["amount"])

Замер (одинаково на 3.13.7 и 3.14.7):

вариантрезультат
suppress на строку175
suppress на цикл100
верная сумма175

Битая запись при этом действительно пропущена — отдельный счётчик в замере получает ровно одну. Потеряно 75 из 175. Ни одного исключения, ни одной строки в логе: suppress не «пропускает ошибку», он выходит из блока целиком — ровно как записано в PEP 343, где подавление означает переход к строке после with.

Та же ошибка в виде собственного менеджера страшнее, потому что в месте вызова нет слова suppress:

PYTHON
class Quiet:
    def __enter__(self): return self
    def __exit__(self, *a): return True     # «чтобы не падало»

С ним та же сумма — 100.

Механизм 4: подавить группу и вырезать ветку — разные операции

Слово «подавить» в предыдущем разделе означало одно: __exit__ вернул истину, и блок кончился. С группой исключений операций становится две, и они дают разные результаты (bench/ctxmgr-async/exceptiongroup_suppress.py).

Первая — старая. __exit__ -> True над ExceptionGroup глотает группу целиком, со всеми ветками (здесь и ниже вывод скрипта приведён сокращённо — только относящиеся к делу строки):

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

Вторая появилась в 3.12 и работает тоньше: suppress вырезает свою ветку и пересобирает остаток. На 3.11 то же самое не подавляло вообще ничего:

3.11.15
2) with suppress(ValueError): raise ExceptionGroup('eg', [ValueError])
   наружу вышло ExceptionGroup 'eg (1 sub-exception)' вложенные: ['ValueError']
3) ... ExceptionGroup('mix', [ValueError, TypeError])
   наружу вышло 'mix (2 sub-exceptions)' вложенные: ['ValueError', 'TypeError']
   это тот же объект, что бросали: True

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

Строка про то, что наружу вышел уже не тот объект, который бросали, — это и есть весь механизм. Документация описывает его дословно:

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.

перевод

Если код внутри блока with возбуждает BaseExceptionGroup, подавляемые исключения удаляются из группы. Те исключения группы, которые не подавлены, возбуждаются заново в новой группе, созданной методом derive исходной группы.

contextlib — suppress

Работает это и вглубь: ValueError, лежавший во вложенной группе, на 3.12 из неё вырезан, а на 3.11 остался на месте.

Тут стоит снять частое заблуждение о причине. BaseExceptionGroup.split, которым это сделано, существует с 3.11 — замер вызывает его напрямую и на 3.11, и результат тот же. Изменился не split, а suppress, который начал его звать: до 3.12 его __exit__ умещался в одну строку — return exctype is not None and issubclass(exctype, self._exceptions), — и группа под это условие просто не подходила.

Рядом с suppress полезно помнить except*. Он делит группу тем же способом — split возвращает the pair (match, rest) where match is subgroup(condition) and rest is the remaining non-matching part (пару (match, rest), где match — это subgroup(condition), а rest — оставшаяся несовпавшая часть), — и справочник описывает его так:

The exception type for matching is interpreted as in the case of except, but in the case of exception groups we can have partial matches when the type matches some of the exceptions in the group. This means that multiple except* clauses can execute, each handling part of the exception group.

перевод

Тип исключения для сопоставления понимается так же, как в случае except, но в случае групп исключений возможны частичные совпадения, когда тип совпадает с частью исключений группы. Это значит, что выполниться может несколько предложений except*, каждое из которых обработает свою часть группы исключений.

Справочник языка — предложение except*

Практический вывод для кода, который пользуется TaskGroup или любой другой структурной конкурентностью: suppress(ValueError) там означает не «глушить всё», а «вынуть из группы ValueError и отдать наружу остальное». Начиная с 3.12.

Механизм 5: если __enter__ бросил исключение, уборки не будет

Гарантия справочника сформулирована с условием, и условие легко потерять при чтении: if the __enter__() method returns without an error, then __exit__() will always be called (если метод __enter__() вернул управление без ошибки, то __exit__() будет вызван обязательно).

То есть до успешного входа гарантии нет никакой:

PYTHON
class FailsOnEnter:
    def __enter__(self):
        self.acquire()          # первая половина удалась
        raise RuntimeError      # вторая — нет
    def __exit__(self, *a):
        self.release()          # НЕ БУДЕТ ВЫЗВАН

Проверено запуском: __enter__ пишет в журнал до raise, и после падения там остаётся только эта запись — __exit__ не добавил ничего. Практический вывод — __enter__ должен либо захватить всё, либо не захватить ничего: собственный try/except внутри него, а не надежда на __exit__.

Справочник отдельно говорит, что with A(), B(): равносильно вложенным with. Сложив это с правилом выше, получаем: если B.__enter__ упал, A.__exit__ отработает, а B.__exit__ — нет. Проверено запуском, на классах:

вход A | вход B | выход A

У @contextmanager с try/finally та же строка выглядит иначе — там уборка для B всё-таки выполнится. Почему — в следующем разделе.

Механизм 6: @contextmanager — где живёт исключение

Генераторная форма короче, и в ней не видно ни __exit__, ни трёх его аргументов. Строка после yield читается как обычная уборка — а её пропустит любое исключение:

PYTHON
@contextmanager
def naive():
    resource = acquire()
    yield resource
    release(resource)        # при исключении сюда не придут

Документация contextlib объясняет, почему: If an unhandled exception occurs in the block, it is reraised inside the generator at the point where the yield occurred (Если в блоке возникает необработанное исключение, оно возбуждается заново внутри генератора в той точке, где произошёл yield). Исключение не «происходит снаружи» — оно бросается в точку yield, и дальше ведёт себя как обычное исключение внутри функции: разматывает кадр, минуя строки после yield.

Проверено запуском:

менеджербез исключенияс исключением
без try/finallyоткрыли, закрылиоткрыли
с try/finallyоткрыли, закрылиоткрыли, закрыли

Из того же свойства следует и способ подавить исключение в генераторной форме: не return True, а обычный except, из которого не перебрасывают:

PYTHON
@contextmanager
def catches():
    try:
        yield
    except ValueError:
        pass            # исключение подавлено

Документация предупреждает об этом прямо: If an exception is trapped merely in order to log it… the generator must reraise that exception (Если исключение перехвачено лишь ради того, чтобы его записать в журнал… генератор обязан возбудить это исключение заново).

Есть и место, где генераторная форма ведёт себя лучше класса. Если код до yield обёрнут в try/finally, а генератор упал ещё до yield, finally всё равно отработает: исключение разматывает кадр генератора и проходит через него. У класса в той же ситуации не вызывается ничего — это и есть та разница, из-за которой строка вход A | вход B | выход A выше помечена «на классах».

Механизм 7: ExitStack — вложенность, которой нет в коде

Когда менеджеров заранее неизвестное число — открыть все файлы из списка, все соединения из конфигурации, — вложенных with написать нельзя. Для этого есть ExitStack, и вести себя он обязан ровно так, как вели бы себя вложенные with:

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 с зарегистрированным набором обработчиков. Это распространяется даже на обработку исключений: если внутренний обработчик подавляет или заменяет исключение, внешним обработчикам будут переданы аргументы, отражающие это обновлённое состояние.

contextlib — ExitStack

Второе предложение — это не оговорка, а то, ради чего абзац стоит читать. Оно проверяется прямо (bench/ctxmgr-async/exitstack.py, вывод одинаков на 3.11–3.14):

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

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

То есть внешний __exit__ видит не то исключение, которое случилось, а то, которое осталось после внутреннего. Логирование «что упало» на внешнем уровне поэтому врёт, если внутри кто-то подменяет.

Остальное поведение — то же, что у вложенных with, и потому предсказуемо: падение третьего __enter__ закрывает первые два в обратном порядке и не открывает четвёртый; enter_context(object()) даёт TypeError сразу, а не позже. Отдельно стоит pop_all: он переносит зарегистрированную уборку в другой объект, и тогда выход из with ничего не закрывает — закрывает тот, кому уборку передали. Это штатный способ вернуть наружу уже открытый ресурс, не закрыв его на выходе из функции, которая его открыла.

Механизм 8: async with — тот же протокол, другие имена

Асинхронный вариант не добавляет ни одного нового правила. Модель данных описывает оба метода одной формулировкой:

Semantically similar to __enter__(), the only difference being that it must return an awaitable.

перевод

Семантически похож на __enter__(), с единственным отличием: должен вернуть ожидаемый объект.

Модель данных — __aenter__

Отсюда всё остальное: as получает возвращённое значение, истина из __aexit__ подавляет исключение, порядок тот же. Проверено запуском (bench/ctxmgr-async/async_protocol.py), и вывод одинаков на 3.11–3.13.

Практически важны две вещи, которых у синхронного with быть не может.

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

4) чередование с фоновой задачей: ['фон-0', 'тело начало', 'фон-1', 'тело кончило', 'фон-2']

Блокировка, взятая на входе в такой менеджер, держится через await — и всё, что о блокировках сказано в уроке про async, начинается здесь.

Вторая: протоколы не взаимозаменяемы. Синхронный менеджер под async with не работает и наоборот:

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

На 3.14 эти же две ошибки стали подсказывать выход — прямое следствие того, что в этой версии оба метода ищутся по отдельности:

3a) ... (missed __aexit__ method) but it supports the context manager protocol. Did you mean to use 'with'?
3b) ... (missed __exit__ method) but it supports the asynchronous context manager protocol. Did you mean to use 'async with'?

Для смешанного случая есть AsyncExitStack: он держит и синхронные, и асинхронные менеджеры в одном стеке и закрывает их в обратном порядке регистрации, не разделяя по виду. У него нет close — только aclose, и это записано в документации, а не выведено из поведения.

Глубже: один объект — один вход

Документация называет результат @contextmanager однократным и приводит пример, который заканчивается так:

RuntimeError: generator didn't yield

На всех четырёх версиях получается другое. Пример из документации, запущенный дословно на 3.11.15, 3.12.3, 3.13.7 и 3.14.7, даёт:

AttributeError: '_GeneratorContextManager' object has no attribute 'args'

Причина видна в исходниках. Lib/contextlib.py на теге v3.13.7, строки 118–125:

PYTHON
def __enter__(self):
    # do not keep args and kwds alive unnecessarily
    # they are only needed for recreation, which is not possible anymore
    del self.args, self.kwds, self.func
    try:
        return next(self.gen)
    except StopIteration:
        raise RuntimeError("generator didn't yield") from None

Удаление атрибутов стоит до next(self.gen), поэтому второй вход падает раньше, чем дело доходит до генератора. Задокументированный RuntimeError живёт в ветке except StopIteration — до неё второй вход просто не доходит.

Практического вывода это не меняет: повторно входить надо не в объект, а вызывать функцию заново — with careful(): каждый раз. Но знать стоит: если в чужом коде встретился AttributeError: ... has no attribute 'args', это не загадка, а повторный вход в уже использованный менеджер.

Глубже: что изменилось в 3.14

деталь реализации · CPython 3.14Текст сообщения об ошибке — не контракт: он менялся между версиями и может измениться снова.

Время двух версий нельзя ставить рядом: сборки 3.13.7 и 3.14.7 различаются не только версией языка — у 3.14 включён --with-tail-call-interp — тот самый флаг, которому «Что нового в 3.14» приписывает a geometric mean of 3-5% faster (в среднем геометрическом на 3–5 % быстрее).

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

форма3.13.73.14.7
пустой вызов, без with12,5 нс12,7 нс
класс с __enter__/__exit__95,5 нс65,2 нс
@contextmanager430,7 нс417,2 нс

Эталон не сдвинулся вовсе — 12,5 против 12,7 нс, то есть разница в пределах разброса, и общего ускорения сборки в этой паре нет. Генераторная форма тоже входит в with, поэтому вторым эталоном быть не может: её 3,1 % — смесь вклада сборки и вклада изменения байт-кода. А форма с классом подешевела на 31,7 %. Приписать эти тридцать два процента сборке нечему: эталон, измеренный тем же процессом в тех же кругах, остался на месте. Причина видна в байт-коде: на 3.13 вход в with — это одна инструкция BEFORE_WITH, на 3.14 её нет вовсе, а вместо неё стоят обычные загрузки и вызов:

3.13:  CALL 0 / BEFORE_WITH / STORE_NAME x

3.14:  CALL 0 / COPY 1 / LOAD_SPECIAL __exit__ / SWAP 2 / SWAP 3
       / LOAD_SPECIAL __enter__ / CALL 0 / STORE_NAME x

Инструкций стало больше, а времени — меньше. Мотив изменения записан в CPython gh-120507, и он не про скорость: they are bulky and won't optimize well in tier 2. Instead, we should lower them to attribute lookups and calls which can then be optimized (они громоздки и плохо оптимизируются на втором уровне. Вместо этого их следует свести к поиску атрибутов и вызовам, которые уже поддаются оптимизации). Ускорение здесь — следствие, а не цель, и никакого числа ни в задаче, ни в документации не обещано.

У перестановки есть видимый побочный эффект. На 3.14 __exit__ загружается первым, и об этом сообщают ошибки:

объект3.11 – 3.133.14
есть __enter__, нет __exit__(missed __exit__ method)(missed __exit__ method)
есть __exit__, нет __enter__без уточнения(missed __enter__ method)
нет обоихбез уточнения(missed __exit__ method)

Последняя строка — не опечатка: когда нет обоих методов, 3.14 называет __exit__, потому что до __enter__ дело не доходит.

Глубже: история версий

ВерсияИзменениеЧто это значит для кода
2.5PEP 343 вводит with и пару __enter__/__exit__. Перевод в try/finally, записанный в PEP, с тех пор не менялся по смыслу — менялось только его представление в байт-коде.
3.11Появляется инструкция BEFORE_WITH (документация dis: Added in version 3.11 (Добавлено в версии 3.11)). Вход в with — одна инструкция.
3.12suppress учится разбирать группы исключений: Changed in version 3.12: suppress now supports suppressing exceptions raised as part of a BaseExceptionGroup (Изменено в версии 3.12: suppress теперь поддерживает подавление исключений, возбуждённых в составе BaseExceptionGroup). И тем же выпуском переписан @contextmanager: трёхаргументная форма throw(typ, value, tb) объявлена устаревшей, поэтому contextlib перешёл на throw(value). Замер видит это снаружи: число аргументов, с которым contextlib зовёт gen.throw, — 3 на 3.11 и 1 начиная с 3.12.
3.14BEFORE_WITH исчезает со страницы dis, появляется LOAD_SPECIAL (Added in version 3.14 (Добавлено в версии 3.14)). Замер: форма с классом 95,5 → 65,2 нс. Побочный эффект — TypeError при отсутствии обоих методов теперь называет __exit__.

Чем измерено

Наблюдения этой статьи открываются отсюда — вместе с записью прогона:

Последние четыре времени не меряют вовсе: они печатают поведение на четырёх интерпретаторах — 3.11.15, 3.12.3, 3.13.7 и 3.14.7 — и различия между ними.

Как отвечать на собеседовании

Короткий ответ: with — это гарантия того, что выход из блока будет обработан, чем бы блок ни кончился. Протокол — два метода на типе: __enter__ вызывается на входе, __exit__ на выходе, и as связывает то, что вернул __enter__, а не сам объект.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

Первое — назвать границу гарантии: __exit__ вызывается, только если __enter__ вернул управление без ошибки. Если __enter__ бросил, уборки не будет вовсе, и всё, что он успел захватить, придётся освобождать в нём же.

Второе — сказать, что после успешного входа __exit__ решает не только «убрать за собой»: судьбу летящего исключения определяет истинность того, что он вернул. Поэтому return True в __exit__ означает не «мы всё убрали успешно», а «исключения больше нет», — и глушит любое исключение, а не только ожидаемое. Отсюда и тихая потеря данных: suppress вокруг цикла вместо suppress вокруг строки даёт 100 вместо 175, и в журнале при этом ни одного исключения.

Дальше спросят

Спросят дальше

__exit__ вернул True. Что именно подавлено?

Короткий ответ

Ровно то исключение, которое до него дошло, — и подавление возвращает управление в строку после with, а не внутрь блока. Весь with — это try, записанный в PEP 343 явно, и всё поведение видно прямо в этой записи: другого источника у урока нет.

Спросят дальше

А если __enter__ бросил исключение — кто закроет то, что он успел открыть?

Короткий ответ

Никто. Гарантия справочника сформулирована с условием, которое легко потерять при чтении: __exit__ будет вызван обязательно, если __enter__ вернул управление без ошибки. До успешного входа гарантии нет никакой, и всё, что захвачено внутри __enter__ до броска, придётся освобождать в нём же.

Частые заблуждения

Утверждение

with просто гарантирует, что файл закроется

На самом деле

Гарантия условная, и условие в справочнике записано прямо: __exit__ вызывается, только если __enter__ вернулся без ошибки. Менеджер, упавший в середине __enter__, не получит уборки вовсе — проверено запуском, в журнал попадает только «__enter__ начался». Захватывать ресурсы надо так, чтобы __enter__ либо сделал всё, либо не сделал ничего.

Утверждение

as-переменная — это объект, который стоит после with

На самом деле

Это возвращаемое значение __enter__. Совпадение — решение автора менеджера, а не правило языка: open() возвращает файл, @contextmanager — то, что стоит после yield, а метод без return отдаёт None. Проверяется одной строкой: with obj as v: print(obj is v) печатает False у менеджера, который возвращает что-то своё.

Утверждение

return True в __exit__ значит «мы всё убрали успешно»

На самом деле

Это значит «исключение проглочено». В PEP 343 перевод записан дословно: if not exit(mgr, *sys.exc_info()): raise — истина отменяет raise. Замер: тот же разбор строк даёт 100 вместо 175, без единой записи в логе. Метод, которому нечего сказать, не должен возвращать ничего.

Утверждение

suppress — это короткая запись для try/except вокруг строки

На самом деле

Он выходит из блока целиком. В замере блок with suppress(ZeroDivisionError): вокруг двух строк выполнил только первую: подавление означает переход к строке после with, а не к следующей строке внутри него. Отсюда и потеря 75 из 175 в примере с циклом.

Утверждение

@contextmanager с yield посередине эквивалентен try/finally

На самом деле

Только пока нет исключений. Документация contextlib: исключение is reraised inside the generator at the point where the yield occurred (возбуждается заново внутри генератора в той точке, где произошёл yield) — то есть строки после yield пропускаются так же, как пропускался бы остаток любой функции. Замер: без try/finally при исключении в журнале остаётся только «открыли».

Утверждение

объект от @contextmanager можно использовать повторно

На самом деле

Нельзя, и ошибка при этом не та, что в документации. Документация показывает RuntimeError: generator didn't yield, а на 3.11, 3.12, 3.13 и 3.14 получается AttributeError: '_GeneratorContextManager' object has no attribute 'args' — потому что __enter__ начинается с del self.args, self.kwds, self.func (Lib/contextlib.py, v3.13.7, строки 118–125). Вызывать надо функцию заново, а не входить в тот же объект.

Утверждение

with A(), B() открывает оба менеджера независимо

На самом деле

Это вложение: справочник называет такую запись semantically equivalent to (семантически эквивалентно) вложенным with. Замер с упавшим B.__enter__ на классах даёт «вход A | вход B | выход A» — у B уборки нет, у A есть. То же и с закрытием: сначала B, потом A.

Утверждение

with почти ничего не стоит

На самом деле

На 3.13.7 пустой вызов — 12,5 нс, тот же код в with на классе — 95,5 нс, через @contextmanager — 430,7 нс. В обработчике с походом в базу это шум; в цикле на миллион итераций генераторная форма добавляет больше четырёх десятых секунды. На 3.14 форма с классом подешевела до 65,2 нс, генераторная — до 417,2.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Четыре записи, одна битая. suppress стоит вокруг цикла. Что напечатает этот код?
from contextlib import suppress

ROWS = [
  {"id": 1, "amount": "100"},
  {"id": 2, "amount": "no data"},
  {"id": 3, "amount": "50"},
  {"id": 4, "amount": "25"},
]

total = 0
added = []
with suppress(ValueError):
  for row in ROWS:
      total += int(row["amount"])
      added.append(row["id"])

print(total)
print(added)

Практика · оцените

Один и тот же пустой вызов — сам по себе и внутри with с менеджером на классе. Во сколько раз дороже вариант с with?
раза

Проверка знаний

Вопрос 1 из 4

__enter__ захватил блокировку, а потом бросил исключение. Что вызовет with?

Источники и что читать дальше

9 ИСТОЧНИКОВ

  1. PEP 343 — The "with" StatementPEP. Гвидо ван Россум и Алисса Кофлан, 13 мая 2005, статус Final, Python 2.5. Отсюда дословный перевод with в try/finally, на который опирается весь урок: `exit = type(mgr).__exit__` берётся ДО вызова `__enter__`, а истинный результат `exit(...)` означает, что исключение «swallowed».https://peps.python.org/pep-0343/
  2. Справочник языка — инструкция withОфициальная документация. Семь пронумерованных шагов исполнения. Оттуда же два правила, которые в уроке проверены запуском: «The with statement guarantees that if the __enter__() method returns without an error, then __exit__() will always be called» (Инструкция with гарантирует: если метод __enter__() вернул управление без ошибки, то __exit__() будет вызван обязательно) и «If the return value was true, the exception is suppressed» (Если возвращённое значение истинно, исключение подавляется). Там же — что несколько менеджеров в одной строке равносильны вложенным with.https://docs.python.org/3.14/reference/compound_stmts.html#the-with-statement
  3. contextlib — утилиты для withОфициальная документация. «If an unhandled exception occurs in the block, it is reraised inside the generator at the point where the yield occurred» (Если в блоке возникает необработанное исключение, оно возбуждается заново внутри генератора в той точке, где произошёл yield) — фраза, объясняющая, зачем в генераторном менеджере нужен try/finally. Там же про однократность: «Context managers created using contextmanager() are also single use context managers» (Контекстные менеджеры, созданные через contextmanager(), тоже одноразовые) — на этой фразе держится раздел про повторный вход.https://docs.python.org/3.14/library/contextlib.html
  4. dis — BEFORE_WITH (документация 3.13)Официальная документация. «This opcode performs several operations before a with block starts… Added in version 3.11» (Эта инструкция выполняет несколько действий перед началом блока with… Добавлено в версии 3.11). На странице 3.14 этой инструкции уже нет; что её нет и в самом байт-коде, показывает дизассемблер в bench/context-managers/bytecode.py.https://docs.python.org/3.13/library/dis.html
  5. dis — LOAD_SPECIAL (документация 3.14)Официальная документация. «Performs special method lookup on STACK[-1]… Added in version 3.14» (Выполняет поиск специального метода у STACK[-1]… Добавлено в версии 3.14). Инструкция, на которую разложился BEFORE_WITH. Никакого утверждения о скорости в документации нет — измеренное ускорение в уроке приводится как наблюдение, а не как обещание.https://docs.python.org/3.14/library/dis.html
  6. CPython gh-120507 — Lower BEFORE_WITH and BEFORE_ASYNC_WITH to attribute lookups and callsИсточник. Марк Шеннон, 14 июня 2024. Заявленный мотив — не скорость, а пригодность к оптимизации: «they are bulky and won't optimize well in tier 2. Instead, we should lower them to attribute lookups and calls which can then be optimized» (они громоздки и плохо оптимизируются на втором уровне. Вместо этого их следует свести к поиску атрибутов и вызовам, которые уже поддаются оптимизации). Ни одного числа в задаче нет.https://github.com/python/cpython/issues/120507
  7. Lib/contextlib.py — _GeneratorContextManager.__enter__Исходный код CPython. Строки 118–125 на теге v3.13.7. Первая же строка метода — `del self.args, self.kwds, self.func`, и именно поэтому повторный вход в тот же объект даёт AttributeError, а не задокументированный RuntimeError. Проверено на 3.11, 3.12, 3.13 и 3.14: во всех четырёх метод одинаков.https://github.com/python/cpython/blob/v3.13.7/Lib/contextlib.py
  8. Модель данных — __aenter__ и __aexit__Официальная документация. Формулировка, из которой следует, что асинхронный протокол не добавляет ни одного нового правила: «Semantically similar to `__enter__()`, the only difference being that it must return an *awaitable*» (Семантически похож на `__enter__()`, с единственным отличием: должен вернуть ожидаемый объект), и такая же фраза про `__aexit__`. Перевод инструкции `async with` в `try/finally` стоит в справочнике языка и повторяет синхронный дословно, с добавленным `await` перед каждым вызовом.https://docs.python.org/3.14/reference/datamodel.html#object.__aenter__
  9. Что нового в Python 3.12 — раздел DeprecatedОфициальная документация. Причина, по которой `@contextmanager` в 3.12 переписан: «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» (Трёхаргументные сигнатуры (тип, значение, трассировка) методов `throw()` у корутины, генератора и асинхронного генератора объявлены устаревшими и могут быть удалены в будущей версии Python). Слова `contextlib` в «Что нового в 3.12» нет вовсе — изменение `suppress` записано только в документации `contextlib` пометкой versionchanged. Тег CPython 3.13.7.https://docs.python.org/3.12/whatsnew/3.12.html#deprecated