Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Контекстные менеджеры: with в Python

with и contextmanager: управление ресурсами в Python без утечек

Как работает with: протокол __enter__/__exit__, декоратор contextmanager, вложенность и ExitStack, подавление исключений и свои контекстные менеджеры для тестов и ресурсов.

Прямой ответ

with — гарантия cleanup'а: ресурс освобождается при выходе из блока при любом исходе (нормальный выход, return, исключение) — замена try/finally, которую невозможно забыть. Механика: объект с __enter__ (возвращает значение для as) и __exit__ (cleanup; возвращает True — исключение подавлено) — протокол, реализованный файлами, замками, соединениями; писать свой проще всего декоратором @contextmanager: генератор с одним yield — код до yield = enter, после = exit (finally внутри обязателен — cleanup при исключении в блоке). Практическое применение шире «закрыть файл»: транзакции БД (commit/rollback), временное переключение состояния (лог-уровень, директория, переменные окружения в тестах), таймеры и замер контекста, пулы соединений (кеш-пулы). Три детали зрелого использования: вложенность читается по отступам и может сворачиваться (with open(a) as fa, open(b) as fb: — одна строка, закрытие в обратном порядке); ExitStack — динамический набор контекстов (N файлов/соединений из списка — цикл внутри with вместо рекурсии); подавление исключений в __exit__/suppress — осознанное решение с комментарием (молчаливое проглатывание — дисциплина исключений применима целиком). И anti-pattern: @contextmanager без try/finally вокруг yield — cleanup пропустится на исключении (половина самописных контекст-менеджеров с этим багом).

Протокол: что происходит в with

Разбор по шагам: with EXPR as VAR: — вызов EXPR.__enter__(), результат присваивается VAR; тело блока; при выходе — __exit__(exc_type, exc_val, tb) с аргументами исключения (None при нормальном выходе) — менеджёр знает, что произошло, и может реагировать по-разному (транзакция: нет исключения — commit, есть — rollback). Возврат __exit__: True — исключение подавлено (with-блок «проглотил» — используется в contextlib.suppress(FileNotFoundError) — точечная замена try/except/pass с явной областью действия; подавление шире задуманного — типовой баг: подавлять конкретный класс, не Exception); False/None — исключение пробрасывается дальше после cleanup. Порядок при вложенных with — стек (LIFO: последний открытый закрывается первым — как и должно быть для зависимых ресурсов). Асинхронная версия — async with/__aenter__ (await-able enter/exit — ресурсы с асинхронным открытием: HTTP-сессии, БД-пулы — async-контекст); sync-менеджер в async-коде работает обычный (синхронный cleanup блокирует цикл — для долгого закрытия — async-вариант).

@contextmanager: свой менеджер за пять строк

Шаблон: @contextmanager def timer(label): start = time.perf_counter(); try: yield; finally: log(f"{label}: {time.perf_counter()-start:.3f}s") — yield отдает управление в блок, finally гарантирует замер при любом исходе; yield value — значение для as. Классы применений: состояние с восстановлением (@contextmanager def log_level(logger, lvl): old = logger.level; logger.setLevel(lvl); try: yield; finally: logger.setLevel(old) — временный DEBUG вокруг отлаживаемого участка; в тестах — временные env-переменные/monkeypatch-подобное); транзакционные границы (with db.transaction(): — commit/rollback без ручных веток); захват вывода/артефактов (тесты: with capture_stdout() as out: — фикстуры часто удобнее, но локальный захват в середине теста — contextmanager); ресурсы с проверкой (временная директория с очисткой, файл с fsync при выходе). Правила качества: cleanup в finally (не после yield «на удачу»); менеджер не глотает исключения блока без нужды (прозрачность — вызывающий видит свои ошибки); enter-фаза быстрая (тяжёлое открытие — до yield, блок начинается с готовым ресурсом); документировать, что происходит при исключении (особенно если подавляется).

ExitStack и динамические наборы

Задача «открыть N ресурсов из списка» без рекурсии и массива try: with contextlib.ExitStack() as stack: files = [stack.enter_context(open(p)) for p in paths] — каждый enter_context регистрирует cleanup в стеке (закрытие в обратном порядке, частично открытые закрываются при падении на пятом — классическая проблема «открыл три, четвёртый упал, первые три текут» решена); stack.callback(close_fn, arg) — регистрация произвольного cleanup (не обязательно контекст-менеджер); передача стека наружу (return stack.pop_all() — продление жизни ресурсов за пределы with — паттерн для фабричных функций, возвращающих открытые ресурсы с владением). Применения: динамические пулы (соединения к шардам — партиции данных по источникам), условные ресурсы (файл кеша открывается только при флаге — enter по условию), тестовые окружения с переменным числом компонентов (docker-контейнеры в интеграционных тестах — testcontainers-паттерн внутри). Смежные инструменты contextlib: suppress (точечное подавление), redirect_stdout/stderr (захват потоков), closing (обёртка «просто close()» для объектов без протокола), nullcontext (заглушка «менеджер или ничего» — условный контекст в параметрах).

Практический сценарий: генератор отчётов с ресурсами

Модуль выгрузок: каждый отчёт — временная директория, соединение с читающей репликой, файл результата в S3. Дизайн через контексты: with tempfile.TemporaryDirectory() as tmp, db.replica() as conn, s3_uploader(bucket) as up: — три ресурса, закрытие гарантировано на любом пути (включая исключение на середине выгрузки — временная директория не остаётся, соединение возвращается в пул, недокачанный multipart отменяется — S3-механика); свой менеджер s3_uploader — @contextmanager: enter — инициализация multipart, yield — объект записи, finally — complete при успехе/abort при исключении (клиент не обязан помнить про abort — API прощает ошибки). Для набора отчётов — ExitStack (каждый отчёт открывает свой набор — стек на итерацию). Находки тестов: менеджер без finally пропускал abort (тест с принудительным исключением в блоке показал висящий multipart — починено); подавление ConnectionError в cleanup-фазе (закрытие уже упавшего соединения — suppress с комментарием) — осознанно. Итог: утечки соединений в нагрузочном тесте — ноль (до контекстной рефакторизации — 2–3 за прогон из-за ранних return), код выгрузок читается как декларация ресурсов, а не как оборона.

FAQ

with против try/finally — есть разница? Семантически with = try/finally + протокол; разница практическая: cleanup написан один раз в менеджере (переиспользование, тестирование отдельно) и не забывается на новых вызовах; «просто try/finally» уместен для разового локального cleanup без переиспользования.

Можно ли переиспользовать контекстный менеджер дважды? Обычно нет: @contextmanager-генератор одноразовый (повторный enter на том же объекте — RuntimeError «generator didn't yield»); класс-менеджер может разрешать повторный enter (сброс состояния в exit) — но стандартная практика — новый менеджер на каждый with; фабрика-функция вместо объекта — паттерн переиспользования.

Что будет, если исключение и в блоке, и в exit? Исключение из __exit__ заменяет оригинальное (с цепочкой «During handling...» — chaining); поэтому cleanup-код обороняется (suppress вокруг закрытия уже упавшего ресурса) — cleanup, ломающий диагностику основной ошибки, — антипаттерн.

Нужен ли with для коротких операций (запрос, строка)? По наличию ресурса, не по длительности: HTTP-ответ httpx с потоковым телом — with обязателен (соединение); короткий GET без стриминга — клиент закроет сам; правило — «объект держит что-то внешнее (fd, сокет, блокировку) — with», остальное — по стилю.

Источники: Data model (with statement), contextlib, PEP 343 — ссылки в шапке.