Контекстные менеджеры и исключения

with — протокол из двух методов, и второй из них умеет то, чего не умеет ни один деструктор: подавить исключение. Отсюда и suppress, и способ незаметно съесть ошибку, и цепочки, по которым восстанавливается настоящая причина сбоя.

L1 · 9 минL2 · 10 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
class Sup:
    def __enter__(self): return self
    def __exit__(self, *a): return True

with Sup():
    raise ValueError("и что?")
print("дошли сюда")
дошли сюда
Исключение брошено и никуда не полетело.
Фрагмент B
class Bad:
    def __enter__(self): return self
    def __exit__(self, *a): raise RuntimeError("из exit")

try:
    with Bad():
        raise ValueError("исходное")
except Exception as e:
    print(type(e).__name__, e)
    print("context:", type(e.__context__).__name__)
RuntimeError из exit
context: ValueError
Наружу вышло не то исключение, которое бросили. Но исходное не пропало.
Фрагмент C
def g():
    try:
        raise ValueError("потеряно")
    finally:
        return "съедено"

print(g())
съедено
Исключение было. Его нет, и никто не узнает.
Фрагмент D
def f():
    try:
        1 / 0
    except ZeroDivisionError:
        raise ValueError("обёрнуто")

try:
    f()
except ValueError as e:
    print(type(e.__context__).__name__, e.__cause__)
ZeroDivisionError None
Про исходную ошибку никто не писал from, а она всё равно сохранилась.

2Механизм

Протокол здесь предельно скромный — два метода, — но второй из них наделён полномочием, которого нет больше нигде в языке.

__enter__ вызывается на входе; то, что он вернул, попадает в as.

__exit__(exc_type, exc, tb) вызывается на выходе всегда — и при обычном завершении, и при исключении, и при return, и при break. Если тело завершилось нормально, все три аргумента None.

Истинное значение из __exit__ подавляет исключение. Возврат None — пропустить дальше.

Следствие 1 · подавление — штатная возможность, а не побочный эффект

Фрагмент A. __exit__ вернул True, исключение остановлено, выполнение продолжается со следующей строки после блока. Это не хак: язык специально даёт менеджеру право решать судьбу исключения, потому что иначе нельзя было бы написать contextlib.suppress.

И тут же ловушка. Метод, случайно возвращающий что-то истинное — например, заканчивающийся на return self.close(), где close отдаёт число, — начинает глотать все исключения в блоке. Ошибок нет, тесты зелёные, данные тихо теряются. Отсюда правило: __exit__ либо явно возвращает None, либо возвращает булево осознанно.

Следствие 2 · исключение внутри __exit__ замещает исходное

Фрагмент B. Если __exit__ сам упал, наружу пойдёт его исключение — исходное вытесняется. Это логично: обработчик сломался, доверия к нему нет.

Но исходное не исчезает. Python записывает его в __context__ нового исключения и печатает в трейсбеке строкой «During handling of the above exception, another exception occurred». Механизм называется неявным связыванием и работает всегда, когда исключение возбуждается во время обработки другого.

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

Следствие 3 · finally с return уничтожает исключение

Фрагмент C — самый опасный в уроке. finally выполняется при выходе из блока в любом случае, включая раскрутку стека при исключении. И return внутри него завершает функцию нормально, отменяя раскрутку: исключение не подавляется обработчиком, а просто перестаёт существовать.

То же делают break и continue внутри finally. Никакого предупреждения нет; ошибка исчезает бесследно, вместе с трейсбеком. В отличие от следствия 1, здесь даже __context__ не остаётся.

Вывод без исключений: в finally не место передаче управления. Только уборка.

Следствие 4 · две цепочки, явная и неявная

Фрагмент D. У исключения есть два поля для связи с предыдущим. __context__ заполняется автоматически, когда новое исключение возникает при обработке старого — именно это и произошло, хотя from никто не писал. __cause__ заполняется явно через raise ... from e и означает «вот настоящая причина».

Различие видно в трейсбеке: неявная связь печатается как «another exception occurred», явная — как «The above exception was the direct cause». Первое читается как «а ещё сломалась уборка», второе — как «вот почему».

Отсюда правило для своих обёрток над чужими ошибками: всегда raise MyError(...) from e. Стоит два слова, а разница в том, восстановит ли следующий человек причину сбоя за минуту или за час.

3Границы модели

Где сказанное перестаёт держать

__exit__ вызывается, но не обязан успеть. Аварийное завершение процесса, os._exit, убийство сигналом обходят его. Гарантия распространяется на нормальную раскрутку стека, а не на любой конец программы.

Менеджер, сделанный из генератора, — одноразовый. @contextlib.contextmanager оборачивает генератор, а тот исчерпывается после первого прохода. Повторный вход в тот же объект даст RuntimeError; для многоразового нужен класс либо вызов фабрики заново.

Группы исключений — отдельный синтаксис. С 3.11 ExceptionGroup переносит несколько ошибок сразу, и ловятся они через except*, а не обычным except. Обычный except ValueError группу с ValueError внутри не поймает 🔒 — это то, обо что спотыкаются при переходе на TaskGroup.

4Корень

Корень R4: поведение задаётся протоколом. with не знает ни про файлы, ни про блокировки — он знает два имени и вызывает их. Отсюда и то, что менеджером может быть что угодно, включая объект, у которого нет никакого ресурса.

Появился протокол в 2005 году (PEP 343) с очень конкретной мотивацией: код вида try/finally для захвата и освобождения повторялся везде, писался с ошибками и не читался. Решение сознательно сделали протоколом, а не оператором с фиксированной семантикой — и именно поэтому with сегодня используется для вещей, о которых в 2005 не думали: замер времени, подмена в тестах, транзакции, ограничение точности вычислений, перенаправление вывода.

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

5Аналогия

with — это RAII с явной областью видимости. В C++ ресурс освобождается, когда объект покидает область; в Python — когда исполнение покидает блок. Обе конструкции существуют, чтобы освобождение нельзя было забыть, и обе срабатывают при раскрутке стека.

Расходятся в двух местах, и оба показательны. Первое: RAII привязан к типу, а with — к месту использования; поэтому в C++ нельзя забыть освободить, но и нельзя решить не освобождать, а в Python можно и то и другое. Второе: деструктор не имеет доступа к летящему исключению и не может его отменить, а __exit__ получает его целиком, с типом и трейсбеком, и решает его судьбу.

Второе различие и есть причина, по которой contextlib.suppress существует в Python и не имеет аналога в C++.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему голый except: — плохо, если он ловит всё?

Именно поэтому: он ловит и то, что ловить нельзя.

Голый except: эквивалентен except BaseException:, а от BaseException наследуются три вещи, которые обязаны проходить насквозь: KeyboardInterrupt, SystemExit и GeneratorExit.

while True:
    try:
        do_work()
    except:              # съедает Ctrl-C
        continue

Этот цикл невозможно остановить с клавиатуры: каждое нажатие ловится и игнорируется. То же с sys.exit() внутри блока — процесс не завершится. А проглоченный GeneratorExit ломает закрытие генераторов и даёт RuntimeError: generator ignored GeneratorExit.

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

for item in queue:
    try:
        handle(item)
    except Exception:                  # не BaseException
        log.exception("упало на %s", item)   # с трассировкой, а не str(e)

И отдельно: log.exception вместо log.error(str(e)). Строка исключения без трассировки — самая частая причина, по которой инцидент невозможно разобрать: видно что, не видно где.

Зачем raise ... from, если контекст и так сохраняется?

Затем, что автоматический контекст говорит «случилось во время», а from говорит «случилось из-за». Это разные утверждения.

Python хранит две разные ссылки. __context__ проставляется сам, когда одно исключение возникло внутри обработки другого. __cause__ проставляется только явным from и означает причинно-следственную связь.

try:
    try: 1 / 0
    except ZeroDivisionError as e:
        raise ValueError("плохие данные")
except ValueError as e:
    print(e.__context__, "|", e.__cause__)
# → division by zero | None

В трассировке разница видна словами: без from печатается «During handling of the above exception, another exception occurred», с from — «The above exception was the direct cause».

И третья форма, про которую забывают: raise ... from None — она подавляет цепочку целиком. Нужна там, где внутреннее исключение — деталь реализации, которую незачем показывать пользователю библиотеки:

def get_config(d, key):
    try:
        return d[key]
    except KeyError:
        raise ConfigError(f"нет ключа {key}") from None   # KeyError не всплывёт

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

Почему return в finally проглатывает исключение — это не баг?

Не баг, а прямое следствие того, что finally обязан выполниться при любом выходе из блока.

Если в finally есть return, break или continue, они задают новый способ выхода из функции — и он замещает тот, который был в процессе. Летящее исключение просто перестаёт лететь.

def f():
    try:
        raise ValueError("важная ошибка")
    finally:
        return "всё хорошо"

print(f())      # → всё хорошо      исключение исчезло бесследно

Ни трассировки, ни записи в лог, ни следа в __context__. С точки зрения вызывающего функция отработала успешно.

Как это выглядит в реальном коде. Не так явно: обычно это finally, в котором делают уборку и на всякий случай возвращают статус, или цикл, где в finally стоит continue ради «пропустим битую запись». Второй вариант особенно коварен — он глотает и KeyboardInterrupt.

В 3.14 на такую конструкцию добавили предупреждение компилятора (SyntaxWarning), но поведение оставили — R9: менять семантику нельзя, на неё мог опереться существующий код. Так что диагностика есть, а защиты нет.

Правило: в finally только уборка. Всё, что меняет поток управления, — в else или после блока.

with или try/finally — есть разница, кроме краткости?

Есть, и существенная: __exit__ видит исключение и может его подавить, а finally — нет.

finally выполняется «вслепую»: он не знает, вышли из блока нормально или с ошибкой, и какой именно. __exit__ получает три аргумента — тип, значение, трассировку — и его возвращаемое значение решает судьбу исключения.

class Suppress:
    def __enter__(self): return self
    def __exit__(self, exc_type, exc, tb):
        print("поймал:", exc_type)
        return exc_type is ValueError     # True → исключение подавлено

with Suppress():
    raise ValueError("тихо")
print("дошли сюда")
# → поймал: <class 'ValueError'>
# → дошли сюда

На этом стоит contextlib.suppress, на этом же — транзакции, которые откатываются при ошибке и коммитятся при успехе, различая случаи.

Второе отличие — переиспользование. try/finally надо повторять в каждом месте; менеджер пишется один раз и получает имя. Плюс contextlib.contextmanager позволяет описать его генератором, где yield и есть граница блока.

Где try/finally всё-таки уместен: одноразовая уборка, ради которой заводить класс избыточно, и случаи, где вход и выход не парные — например ресурс захвачен выше по стеку.

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

Итог. with — протокол из двух методов: __enter__ отдаёт значение в as, __exit__ вызывается всегда и получает летящее исключение целиком. Возврат истинного значения из __exit__ подавляет исключение — это штатное полномочие, на котором стоит suppress, и одновременно способ незаметно проглотить ошибку, если метод случайно вернул что-то истинное. Исключение, возникшее внутри __exit__, замещает исходное, но не уничтожает его: оно сохраняется в __context__, и в двойном трейсбеке читать надо верхнюю часть. А вот return внутри finally уничтожает исключение бесследно, вместе с трейсбеком — поэтому в finally место только уборке. И две цепочки различаются смыслом: __context__ заполняется автоматически и означает «сломалось при обработке», __cause__ ставится явным raise ... from и означает «вот причина» — два слова, экономящие час расследования.
Дальше — по желанию
контрфактуалC++ RAII, Go defer, JavaДеструктор не может отменить исключение — __exit__ может

C++ RAII — та же задача, решённая раньше и жёстче. Деструктор вызывается при выходе из области видимости, включая раскрутку стека, и это надёжнее with: не нужно ничего писать в месте использования, освобождение привязано к типу. Но деструктор не может подавить исключение — а бросив своё, во время раскрутки он и вовсе завершает программу через std::terminate. Сравни с фрагментом B, где Python спокойно заменяет одно исключение другим и сохраняет исходное в __context__.

Отсюда и то, что в C++ нет finally: он не нужен, потому что уборка живёт в деструкторах. Python пошёл наоборот — дал явную конструкцию, а RAII-подобное поведение получил из счётчика ссылок (урок 09), но только как деталь реализации.

Go с его defer ближе к finally: отложенный вызов при выходе из функции, и через recover он тоже умеет останавливать панику. Java сделала try-with-resources — узкий частный случай with, работающий только с AutoCloseable и без права подавления.

Четыре языка, одна задача, и только два из них разрешают уборщику отменить аварию.

🐛 баг__exit__, возвращающий не тоМетод заканчивается вызовом, отдающим истинное значение — и блок перестаёт пропускать ошибки наружу

Менеджер транзакции. Написан аккуратно, ревью прошёл, ошибки перестали доходить до логов.

class Transaction:
    def __enter__(self):
        self.conn = pool.acquire()
        return self.conn

    def __exit__(self, exc_type, exc, tb):
        if exc_type is None:
            self.conn.commit()
        else:
            self.conn.rollback()
        return pool.release(self.conn)     # ← вот здесь

Автор написал return, чтобы «вернуть соединение в пул» — по смыслу фразы, а не по смыслу протокола. А pool.release возвращает, скажем, число свободных соединений. Число ненулевое, значит истинное, значит каждое исключение внутри блока подавляется.

with Transaction() as conn:
    process(conn)          # падает
# сюда попадаем как ни в чём не бывало, откат сделан,
# наверх ничего не ушло, в логах пусто

Худшее в этом баге — что откат работает. Данные консистентны, а вызывающий код считает операцию успешной и идёт дальше. Расхождение всплывёт много позже и в другом месте.

def __exit__(self, exc_type, exc, tb):
    ...
    pool.release(self.conn)
    return None                # или просто ничего не возвращать

Правило из следствия 1: из __exit__ ничего не возвращают, если не подавляют осознанно. Проверить существующие менеджеры в кодовой базе можно грепом по def __exit__ — любой return с непустым выражением заслуживает взгляда.

⚡ приёмcontextlib вместо своих классовГенератор, suppress и ExitStack закрывают почти все случаи без единого класса

Класс с двумя дандерами пишут по привычке. В большинстве случаев хватает декоратора над генератором.

from contextlib import contextmanager

@contextmanager
def timed(label):
    t = time.perf_counter()
    try:
        yield
    finally:
        log.info("%s: %.3f c", label, time.perf_counter() - t)

Читается как обычная функция: до yield — вход, после — выход, try/finally гарантирует уборку при исключении. Восемь строк вместо класса, и вся логика на виду по порядку исполнения, а не разнесена по двум методам.

suppress — то самое следствие 1 в готовом виде, и оно честнее пустого except:

from contextlib import suppress

with suppress(FileNotFoundError):
    os.remove(path)

# вместо
try:
    os.remove(path)
except FileNotFoundError:
    pass

ExitStack решает то, что классом решается неудобно: заранее неизвестное число ресурсов. Закрываются они в обратном порядке, как и положено.

with ExitStack() as stack:
    files = [stack.enter_context(open(p)) for p in paths]
    process(files)          # все закроются, сколько бы их ни было

Когда всё-таки класс: менеджер должен быть многоразовым или переиспользуемым (генератор одноразов, см. «Границы модели»), нужно состояние между входами, или объект и так существует и логично дать ему протокол.

💻 терминалПроверь в REPLЧетыре фрагмента плюс порядок ExitStack, вложенные with и except*

Прогони четыре фрагмента, сверяясь с предсказанием. Затем:

from contextlib import ExitStack
with ExitStack() as st:
    st.callback(lambda: print("1"))
    st.callback(lambda: print("2"))
# В каком порядке и почему именно так?

with A() as a, B() as b:
    ...
# Если B.__enter__ упадёт — вызовется ли A.__exit__?
# А если упадёт A.__enter__ — вызовется ли B.__enter__?

try:
    raise ExceptionGroup("g", [ValueError("a")])
except ValueError:
    print("поймали")
# Поймает? А через except* ValueError?

@contextmanager
def cm():
    yield 1
c = cm()
with c: pass
with c: pass
# Что произойдёт на втором входе?

Третий вопрос — практически важный при переходе на TaskGroup: обычный except группу не ловит, и код, работавший с gather, перестаёт обрабатывать ошибки.

исходникиcontextlib и decimalКак менеджер делают из генератора и где with используется не для ресурсов

Lib/contextlib.py, класс _GeneratorContextManager. Стоит прочитать целиком — там видно, как генератор превращается в менеджер: __enter__ делает next(gen) и возвращает выданное значение, а __exit__ при исключении вызывает gen.throw(exc), то есть бросает исключение внутрь генератора в точку yield. Поэтому try/finally вокруг yield и работает так, как ожидается.

Там же видно, откуда берётся подавление: если после throw генератор завершился без исключения, значит он его поглотил, и __exit__ возвращает True. Следствие 1 в тридцати строках кода.

Lib/decimal.py, localcontext. Пример with без всякого ресурса: менеджер подменяет глобальные настройки точности на время блока и возвращает прежние на выходе. Тот же приём — во всех менеджерах, временно меняющих состояние: unittest.mock.patch, numpy.errstate, torch.no_grad. Понимание, что with — это просто пара вызовов, сразу объясняет их все.

L2Что делает компилятор с with и как устроена раскруткаSETUP_WITH, порядок вызовов и zero-cost исключения в 3.11

Из L1 ты знаешь протокол. Здесь — во что он превращается и сколько стоит.

Компилятор разворачивает with в последовательность: получить __exit__ типа (именно типа — это протокол, урок 11), вызвать __enter__, выполнить тело, вызвать __exit__ с тремя аргументами. Оба метода берутся до входа в блок, поэтому подмена __exit__ на экземпляре внутри тела ни на что не влияет.

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

Про цену исключений. До 3.11 вход в try стоил инструкции: на стеке заводился блок обработчика. С 3.11 (та же работа, что дала инлайн-кеши) переход на таблицу исключений: диапазоны байткода и соответствующие обработчики лежат в отдельной структуре объекта кода, и вход в try не стоит ничего — платит только тот путь, где исключение действительно возникло.

def f():
    try: pass
    except: pass
f.__code__.co_exceptiontable      # b'' если обработчик не нужен

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

L3Где это в исходниках CPythoncontextlib.py, bytecodes.c, PEP 343 и PEP 654

Тег v3.11.15.

  • Lib/contextlib.py — весь модуль на Python, читается за двадцать минут и лучше любого объяснения показывает, что with это просто два вызова.
  • Python/ceval.c, инструкции BEFORE_WITH и WITH_EXCEPT_START — что именно генерирует компилятор.
  • Objects/exceptions.c — поля __context__, __cause__, __suppress_context__ и логика их заполнения при возбуждении.
  • PEP 343 — введение with; в мотивации собраны все повторяющиеся try/finally, ради которых он делался. PEP 654 — группы исключений и except*.
связиКуда это ведётL11, L09, L14 · контраст с RAII, defer и try-with-resources
корень R4 · Протоколы вместо интерфейсов
← основа L11 · Протоколы — __enter__ и __exit__ берутся из типа, а не из экземпляра
← основа L09 · Подсчёт ссылок — почему with обязателен, хотя объект и так разрушится
↔ контраст RAII в C++: надёжнее, но деструктор не может отменить исключение
↔ контраст defer в Go и try-with-resources в Java — два более узких решения той же задачи
→ дальше L14 · async/await — асинхронный вариант протокола и почему TaskGroup требует except*
чего здесь нет Как генератор умеет принимать исключение внутрь через throw — это R5, урок 13
источникиЧто почитатьcontextlib.py 🟥 · PEP 343 🟧 · PEP 654 🟦