Контекстные менеджеры в Python: with, __enter__ и __exit__

Python Автор: Среда и версия: CPython 3.14.5

with: синтаксис для кода, который обязан выполниться при выходе из блока, даже если блок упал с исключением. Открыл файл, обязан закрыть. Взял блокировку, обязан отпустить. Контекстный менеджер это гарантирует, а try/finally вручную работает только пока не забудешь его написать. Код проверен на Python 3.14.5.

Зачем with: файл без него остаётся открытым

Без with закрытие файла превращается в отдельную строку, а исключение между открытием и ею эту строку пропускает.

f = open("data.txt", "w")
try:
    f.write("привет")
    raise ValueError("сеть оборвалась")
except ValueError as exc:
    print(f"поймали: {exc}")

print("файл закрыт?", f.closed)
поймали: сеть оборвалась
файл закрыт? False

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

Лечится try/finally: close() переносится в блок, который выполнится в любом случае, и при успехе, и при исключении.

f = open("data.txt", "w")
try:
    f.write("привет")
    raise ValueError("сеть оборвалась")
except ValueError as exc:
    print(f"поймали: {exc}")
finally:
    f.close()

print("try/finally: файл закрыт?", f.closed)
поймали: сеть оборвалась
try/finally: файл закрыт? True

with делает ровно то же самое, короче:

try:
    with open("data.txt", "w") as f2:
        f2.write("привет")
        raise ValueError("сеть оборвалась")
except ValueError as exc:
    print(f"поймали: {exc}")

print("with: файл закрыт?", f2.closed)
поймали: сеть оборвалась
with: файл закрыт? True

Три строки вместо шести, и cleanup нельзя забыть: он не в теле функции, а в объекте, который знает, как себя закрыть. За этим стоит протокол из двух методов. __enter__ вызывается на входе в блок, __exit__ на выходе, независимо от того, как блок завершился.

class Traced:
    def __enter__(self):
        print("1. __enter__")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("3. __exit__", exc_type, exc_val)
        return False


with Traced() as t:
    print("2. тело блока")
1. __enter__
2. тело блока
3. __exit__ None None

Порядок фиксированный: __enter__ → тело → __exit__. Значение, которое вернул __enter__, попадает в переменную после as, и это необязательно self: годится что угодно, включая ничего.

Свой класс-менеджер: таймер блока кода

Класс с __enter__/__exit__ пишется под что угодно, что нужно засечь до и после. Таймер — типовой пример: старт в __enter__, разница в __exit__.

import time


class Timer:
    def __enter__(self):
        self.start = time.perf_counter()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.elapsed = time.perf_counter() - self.start
        print(f"блок занял {self.elapsed:.3f} c")
        return False


with Timer() as t:
    total = sum(range(20_000_000))

print("сумма:", total)
блок занял 0.161 c
сумма: 199999990000000

self.elapsed остаётся доступным и после блока: __exit__ кладёт его в объект, а не теряет вместе со стеком. Замер остаётся доступен и после with, не только в выводе.

exit и исключения: три параметра и ловушка return True

Если тело блока упало, __exit__ получает подробности исключения тремя аргументами: тип, само исключение, объект трассировки. Это не опциональные параметры для галочки, Python вызывает __exit__ именно так при любом исходе. При успехе все три просто равны None.

class LogsAndPasses:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("exc_type:", exc_type)
        print("exc_val:", exc_val)
        print("exc_tb:", type(exc_tb).__name__ if exc_tb else None)
        return False


try:
    with LogsAndPasses():
        1 / 0
except ZeroDivisionError as exc:
    print("наружу вылетело:", exc)
exc_type: <class 'ZeroDivisionError'>
exc_val: division by zero
exc_tb: traceback
наружу вылетело: division by zero

__exit__ вернул False, и исключение полетело дальше как обычно. Возвращаемое значение здесь решает, подавлять исключение или нет: True глушит его насмерть, with завершается тихо, будто ошибки не было.

class SwallowsEverything:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        return True


with SwallowsEverything():
    raise KeyError("важная ошибка")
print("код после блока выполнился, будто ничего не было")
код после блока выполнился, будто ничего не было

KeyError исчез бесследно, вывода про саму ошибку нет вообще. Вот и ловушка: return True в конце __exit__: частая опечатка, когда пишут «верну True, если всё ок» и забывают, что в контексте __exit__ это значит не «всё ок», а «глотай любую ошибку из блока». Ловится не сразу: код продолжает работать, просто результат внутри блока молча не случился. Если менеджеру нужно подавлять конкретное исключение, проверяют exc_type явно, через return exc_type is ValueError, а не голое True.

contextlib.contextmanager: тот же таймер через yield

Писать класс с двумя методами ради одного таймера избыточно. @contextlib.contextmanager строит менеджер из функции-генератора: код до yield работает как __enter__, код после yield — как __exit__. Механика та же, что у генераторов вообще, про заморозку кадра и yield подробно разобрано в статье про генераторы.

import contextlib
import time


@contextlib.contextmanager
def timer():
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"блок занял {time.perf_counter() - start:.3f} c")


try:
    with timer():
        sum(range(10_000_000))
        raise ValueError("что-то упало внутри")
except ValueError as exc:
    print("поймали снаружи:", exc)
блок занял 0.080 c
поймали снаружи: что-то упало внутри

try/finally вокруг yield здесь не стилистика, а обязательное условие. Исключение из тела with contextlib поднимает прямо в точке yield. Если вокруг неё нет finally, код после yield при падении блока просто не выполнится.

@contextlib.contextmanager
def timer_broken():
    start = time.perf_counter()
    yield
    print(f"блок занял {time.perf_counter() - start:.3f} c")  # без finally


try:
    with timer_broken():
        sum(range(10_000_000))
        raise ValueError("что-то упало внутри")
except ValueError as exc:
    print("поймали снаружи:", exc, "— а времени в логе нет")
поймали снаружи: что-то упало внутри — а времени в логе нет

Строка с замером времени не напечаталась. При успешном завершении блока разницы не видно, оба варианта отработают одинаково. Расхождение проявляется только на исключении, и именно там cleanup чаще всего и нужен.

Готовые менеджеры: closing, suppress, ExitStack

Три вещи из contextlib, которые закрывают конкретные задачи без своего класса.

closing превращает в менеджер объект, у которого есть close(), но нет __enter__/__exit__: так устроены некоторые сетевые клиенты и старые API.

import contextlib
import os
import tempfile


class Connection:
    def __init__(self):
        self.open = True
        print("соединение открыто")

    def close(self):
        self.open = False
        print("соединение закрыто")


conn = Connection()
with contextlib.closing(conn):
    print("работаем, open =", conn.open)
print("после блока, open =", conn.open)
соединение открыто
работаем, open = True
соединение закрыто
после блока, open = False

suppress заменяет except Exception: pass, но с адресом. Пустой except глотает вообще всё, включая KeyboardInterrupt и опечатку в имени переменной внутри блока. suppress(FileNotFoundError) называет конкретное исключение и пропускает остальные наружу, и в этом вся разница, а не в форме записи.

with contextlib.suppress(FileNotFoundError):
    os.remove("no-such-file.txt")
print("файла не было, но программа не упала")

try:
    with contextlib.suppress(FileNotFoundError):
        raise PermissionError("нет прав")
except PermissionError as exc:
    print("suppress пропустил чужую ошибку наружу:", exc)
файла не было, но программа не упала
suppress пропустил чужую ошибку наружу: нет прав

ExitStack нужен, когда число ресурсов заранее неизвестно: не два файла, а список из N. Внутри цикла with не завести, а ExitStack.enter_context регистрирует любое число менеджеров и закроет их все на выходе, в обратном порядке.

with contextlib.ExitStack() as stack:
    files = [
        stack.enter_context(tempfile.NamedTemporaryFile(mode="w", delete=True))
        for _ in range(4)
    ]
    for i, f in enumerate(files):
        f.write(f"файл {i}")
        f.flush()
    print("открыто файлов:", len(files))
    print("все существуют:", all(os.path.exists(f.name) for f in files))

print("после блока все закрыты:", all(f.closed for f in files))
открыто файлов: 4
все существуют: True
после блока все закрыты: True

Упади исключение на третьем файле из четырёх, ExitStack всё равно закроет уже открытые. Ручной цикл с try/finally на каждый файл писать не нужно.

Где менеджеры уже вокруг тебя

open() — контекстный менеджер, с которого обычно и знакомятся с with. Второй пример: threading.Lock. with lock берёт блокировку на входе в блок и гарантированно отпускает на выходе, даже если тело блока упадёт.

import threading

lock = threading.Lock()
balance = 0


def deposit():
    global balance
    for _ in range(100_000):
        with lock:
            balance += 1


threads = [threading.Thread(target=deposit) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print("итоговый баланс:", balance, "ожидалось:", 4 * 100_000)
итоговый баланс: 400000 ожидалось: 400000

Без with lock пришлось бы писать lock.acquire() и lock.release() вручную. При исключении внутри критической секции release() не вызвался бы, поток остался бы держать блокировку навсегда, а остальные три зависли бы на acquire(). Подробнее про потоки и гонки за общими данными читай в статье про threading.

async with — тот же протокол, но асинхронный

У асинхронного кода свой вариант протокола: __aenter__ и __aexit__, обе корутины, обе ждутся через await внутри async with. Нужен он там, где вход и выход в ресурс сами требуют ожидания: открыть соединение с базой асинхронным драйвером, закрыть сессию HTTP-клиента. Пример здесь избыточен, механика async/await и то, почему синхронный блокирующий вызов внутри корутины останавливает весь цикл, а await цикл, наоборот, освобождает, разобраны в статье про asyncio.

Частые вопросы

Обязательно ли писать exit, если исключения не ожидаются

Да. __exit__ — единственное место, куда гарантированно попадает управление на выходе из блока, при ошибке и без неё. Без него менеджер выполнит __enter__ и не закроет ресурс при обычном успешном выполнении блока тоже.

Чем with отличается от декоратора

with оборачивает произвольный блок кода, декоратор — вызов функции целиком. Когда нужны оба варианта на одном объекте, contextlib.ContextDecorator даёт класс, который работает и так, и так, подробности в статье про декораторы.

Что вернуть из exit, чтобы просто залогировать ошибку и пробросить её дальше

False или None — оба варианта не подавляют исключение. return False в конце __exit__ эквивалентен отсутствию return: без явного return функция и так вернёт None, а None в булевом контексте это ложь.

Источники