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

Оператор with в Python: контекстные менеджеры, __enter__ и __exit__ Полезное

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

Примеры проверены на Python 3.14.7 (официальный образ python:3.14-slim), 24.09.2026. Ниже разберем протокол по шагам, что возвращает __exit__, как писать свои менеджеры через contextlib, async with и запись нескольких менеджеров в скобках.

Три термина, которые легко спутать

Термин Что это Пример
Оператор with Синтаксическая конструкция языка with open(...) as f:
Контекстный менеджер Объект с методами __enter__ и __exit__ файловый объект, threading.Lock
Цель as То, что вернул __enter__, а не обязательно сам менеджер для файла — сам файл, для Lock — True

Последняя строка — частый источник путаницы. У open() метод __enter__ возвращает тот же файловый объект, поэтому кажется, что as просто дает имя менеджеру. Но у threading.Lock в as попадет True, а у своего класса — что угодно.

Минимальный пример: with и файл

with open("notes.txt", "w", encoding="utf-8") as f:
    f.write("привет\n")
print(f.closed)   # True: файл закрыт сразу после выхода из блока

Программа напечатает True. Переменная f после блока по-прежнему существует (with не создает своей области видимости), но файл уже закрыт. Подробно про режимы открытия, кодировки и чтение файлов — в статье «Python: основы работы с файлами»; здесь нас интересует сам механизм with.

Что with делает по шагам

Упрощенно запись with EXPR as VAR: BLOCK разворачивается в такую схему (полная версия — в PEP 343):

import sys

manager = EXPR
enter = type(manager).__enter__
exit_ = type(manager).__exit__
VAR = enter(manager)
hit_except = False
try:
    BLOCK
except BaseException:
    hit_except = True
    if not exit_(manager, *sys.exc_info()):
        raise                      # __exit__ вернул ложь - исключение летит дальше
finally:
    if not hit_except:             # обычный выход, return, break, continue
        exit_(manager, None, None, None)

Этот фрагмент — схема, а не готовый к запуску код (EXPR, VAR, BLOCK — подстановки). Нормальный выход обрабатывается в finally, а не в else: так __exit__ вызывается и при return, break или continue внутри блока. Из схемы видно три вещи:

  1. Методы ищутся у типа объекта, а не у экземпляра. Если присвоить obj.__enter__ = ... конкретному объекту, with этот атрибут не увидит.
  2. __exit__ получает три аргумента: тип исключения, само исключение и трассировку. При нормальном выходе все три равны None.
  3. Если __enter__ упал, __exit__ не вызывается: ресурс считается не захваченным.

Проверим на своем классе:

class Tracer:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        print(f"вход в {self.name}")
        return self.name.upper()          # это значение попадет в as

    def __exit__(self, exc_type, exc, tb):
        print(f"выход из {self.name}, исключение: {exc_type}")
        return False                      # не подавлять исключение


with Tracer("блок") as value:
    print("внутри, value =", value)

try:
    with Tracer("сбой"):
        raise ValueError("что-то пошло не так")
except ValueError as e:
    print("поймано снаружи:", e)

Вывод:

вход в блок
внутри, value = БЛОК
выход из блок, исключение: None
вход в сбой
выход из сбой, исключение: <class 'ValueError'>
поймано снаружи: что-то пошло не так

В value лежит строка БЛОК — результат __enter__, а не объект Tracer. При исключении __exit__ сначала отработал, и только потом ошибка дошла до внешнего except.

Что возвращает exit и как подавляются исключения

Правило одно: если __exit__ вернул истинное значение, исключение из блока подавляется, и программа продолжает работу после with. Ложное значение (False, None или отсутствие return) — исключение пробрасывается дальше. При нормальном выходе возвращаемое значение ни на что не влияет.

Типичная ошибка — вернуть True «на всякий случай»:

class Swallow:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        print("закрываю ресурс")
        return True                       # ошибка: глушит ВСЕ исключения


with Swallow():
    result = 1 / 0
print("программа продолжила работу, result не создан:", "result" in globals())
закрываю ресурс
программа продолжила работу, result не создан: False

Деление на ноль бесследно исчезло, а переменная result так и не появилась — дальше код упадет в неожиданном месте. Исправление: подавлять только то исключение, которое вы действительно ожидаете.

class IgnoreMissing:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        print("закрываю ресурс")
        return exc_type is not None and issubclass(exc_type, FileNotFoundError)


with IgnoreMissing():
    open("no-such-file.txt")
print("FileNotFoundError подавлен")

with IgnoreMissing():
    1 / 0
закрываю ресурс
FileNotFoundError подавлен
закрываю ресурс
Traceback (most recent call last):
  ...
ZeroDivisionError: division by zero

Отсутствующий файл подавлен, а деление на ноль, как и положено, дошло до пользователя.

contextlib: менеджер без класса

Писать класс ради пары действий «до» и «после» не обязательно. Модуль contextlib из стандартной библиотеки дает готовые инструменты.

@contextmanager и обязательный try/finally

Декоратор contextmanager превращает генератор в менеджер: код до yield — это __enter__, значение yield попадает в as, код после — это __exit__. Исключение из блока with возникает в точке yield, поэтому без try/finally очистка не выполнится:

from contextlib import contextmanager


@contextmanager
def broken():
    print("захватили ресурс")
    yield
    print("освободили ресурс")            # не выполнится при исключении


@contextmanager
def fixed():
    print("захватили ресурс")
    try:
        yield
    finally:
        print("освободили ресурс")


for cm in (broken, fixed):
    try:
        with cm():
            raise KeyError("x")
    except KeyError:
        print(f"{cm.__name__}: исключение дошло наружу\n")
захватили ресурс
broken: исключение дошло наружу

захватили ресурс
освободили ресурс
fixed: исключение дошло наружу

В варианте broken строки «освободили ресурс» нет: ресурс утек. Если внутри генератора перехватить исключение через except и не выбросить его снова, оно будет подавлено — аналог return True в __exit__.

suppress и ExitStack

suppress(*exceptions) подавляет перечисленные исключения и заменяет конструкцию try/except: pass. ExitStack нужен, когда число ресурсов заранее неизвестно: каждый добавленный менеджер закроется при выходе, в обратном порядке.

import os
import tempfile
from contextlib import ExitStack, suppress

tmp = tempfile.mkdtemp()
names = [os.path.join(tmp, f"part{i}.txt") for i in range(3)]

with ExitStack() as stack:
    files = [stack.enter_context(open(n, "w", encoding="utf-8")) for n in names]
    for i, f in enumerate(files):
        f.write(f"строка {i}\n")
    stack.callback(print, "все файлы будут закрыты")
    print("открыто файлов:", sum(not f.closed for f in files))
print("закрыто файлов:", sum(f.closed for f in files))

with suppress(FileNotFoundError):
    os.remove(os.path.join(tmp, "absent.txt"))
print("удаление несуществующего файла не уронило программу")

for n in names:
    os.remove(n)
os.rmdir(tmp)
открыто файлов: 3
все файлы будут закрыты
закрыто файлов: 3
удаление несуществующего файла не уронило программу

Если третий open() упадет, ExitStack все равно закроет два уже открытых файла. Кроме этих двух, в contextlib есть closing (вызывает close() у объекта без протокола with), nullcontext (пустой менеджер для условного кода) и chdir (временная смена каталога, с Python 3.11).

Несколько менеджеров в одном with

Менеджеры можно перечислить через запятую. С Python 3.10 официально разрешены круглые скобки, чтобы разбить длинную строку:

class Step:
    def __init__(self, name):
        self.name = name

    def __enter__(self):
        print("enter", self.name)
        return self

    def __exit__(self, *exc):
        print("exit ", self.name)


with (
    Step("A") as a,
    Step("B") as b,
):
    print("тело блока")
enter A
enter B
тело блока
exit  B
exit  A

Вход идет слева направо, выход — в обратном порядке, как у вложенных with. Если B не смог войти, A все равно будет закрыт. На версиях до 3.10 вместо скобок используйте обратный слеш или ExitStack.

async with: асинхронные менеджеры

В асинхронном коде захват и освобождение ресурса сами бывают корутинами (подключение к базе, HTTP-сессия). Для этого есть async with и методы __aenter__/__aexit__. Писать их можно только внутри async def. Готовый декоратор — asynccontextmanager:

import asyncio
from contextlib import asynccontextmanager


@asynccontextmanager
async def connection(name):
    await asyncio.sleep(0.01)             # имитация асинхронного подключения
    print("подключились к", name)
    try:
        yield f"conn:{name}"
    finally:
        await asyncio.sleep(0.01)         # асинхронное закрытие
        print("отключились от", name)


async def main():
    async with connection("db") as conn:
        print("работаем через", conn)


asyncio.run(main())
подключились к db
работаем через conn:db
отключились от db

Правило про try/finally вокруг yield здесь то же самое. Обычный with для асинхронного менеджера не подойдет, и наоборот.

Менеджер делает не всегда то, что вы ждете

Что именно происходит в __exit__, решает автор класса, и это не всегда «закрыть». Показательный пример — sqlite3.Connection:

import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE t (x INTEGER)")
with conn:                                # транзакция: commit или rollback
    conn.execute("INSERT INTO t VALUES (1)")
print(conn.execute("SELECT count(*) FROM t").fetchone())  # соединение еще открыто
conn.close()

Программа выведет (1,): with conn зафиксировал транзакцию (при исключении откатил бы), но соединение не закрыл. Закрывать его нужно явно или через contextlib.closing(conn). Перед использованием чужого менеджера смотрите документацию: что он делает на входе и на выходе.

Если не получилось: типовые симптомы

Симптом Причина Что делать
TypeError: '...' object does not support the context manager protocol У объекта нет __enter__/__exit__, например генератор без @contextmanager Добавить декоратор или методы протокола
Исключение из блока молча пропало __exit__ вернул True или генератор перехватил ошибку Возвращать истину только для ожидаемого типа
Ресурс не освобождается при ошибке Нет try/finally вокруг yield Обернуть yield в try/finally
В as оказалось не то __enter__ вернул другой объект Сверить с документацией менеджера

Текст ошибки из первой строки на Python 3.14 выглядит так (генератор вместо менеджера):

TypeError: 'generator' object does not support the context manager protocol (missed __exit__ method)

Выводы

  • with вызывает __enter__ перед блоком и __exit__ после него при любом выходе, включая исключение.
  • В as попадает результат __enter__, а не сам менеджер: у файла это файл, у Lock — True.
  • Истинное значение из __exit__ подавляет исключение; подавлять стоит только ожидаемые типы.
  • @contextmanager требует try/finally вокруг yield, иначе очистка пропускается при ошибке; ExitStack и suppress закрывают типовые задачи без своих классов.
  • Скобки для нескольких менеджеров официально доступны с Python 3.10; для корутин используется async with.

Где применяется / связь с практикой

Освойте тему на практике

Контекстные менеджеры встречаются в любом зрелом Python-коде: транзакции и сессии ORM, блокировки в многопоточном коде, временные настройки (decimal.localcontext), фикстуры тестов, асинхронные клиенты. Умение написать свой менеджер и правильно обработать в нем исключения отличает аккуратный production-код от учебного. Протокол with, генераторы, асинхронность и другие механизмы языка системно разбираются на курсе «Python-разработчик. Продвинутый уровень». Отдельные темы можно посмотреть на бесплатных открытых уроках Otus.

FAQ

Можно ли использовать один и тот же менеджер в нескольких with подряд?
Зависит от менеджера. Lock можно захватывать повторно в разных блоках, закрытый файл — нет, а объект из @contextmanager одноразовый: повторный вход в тот же объект завершится ошибкой (на Python 3.14 это AttributeError). Для нового блока вызывайте функцию заново.

Чем with лучше, чем try/finally?
По гарантиям они эквивалентны, with — это обертка над той же схемой. Выигрыш в том, что логика захвата и освобождения живет в одном месте (менеджере) и не дублируется в каждом вызывающем коде.

Появилось ли что-то новое в with в последних версиях Python?
Сам протокол со времен PEP 343 не менялся. Из заметного: скобки для нескольких менеджеров (3.10), contextlib.chdir (3.11) и более понятный текст ошибки при отсутствии протокола (3.11+).

OTUS Журнал
Бесплатные открытые уроки (поп-ап)