with-statement: __enter__ и __exit__
with file as f: ... — самая знакомая Python idiom. Но что на самом деле происходит при with? Это не магия — это два dunder-метода: __enter__() и __exit__(exc_type, exc_val, exc_tb). Любой объект, реализующий эти два метода, может быть использован в with-statement. Это context-manager protocol — формализован в PEP 343 (Python 2.5, 2005), стандартизован в datamodel.html#context-managers.
В этом уроке:
- Desugaring:
with cm as v: body— что compiler делает. __enter__— setup phase, возвращает значение дляas v.__exit__(exc_type, exc_val, exc_tb)— teardown, tri-arg обязателен.- Pitfall 3:
__exit__ → Truesuppresses exception — не возвращайте True без причины. - Multi-context (PEP 617, Python 3.10+) —
with a as x, b as y:. - Bytecode lowering Python 3.12+:
ExceptionTableвместоSETUP_FINALLY— empiricallydis.disverified. - Recipe: class-based
Timercontext manager — measures body execution time. - Code challenge:
py-m06-04-code-1— Timer protocol-correctness check. - Forward-link Phase 67 pytest fixtures: yield-style fixtures используют context-manager protocol.
Это pragmatic-DEEP урок: 50% — рецепты production resource management, 50% — bytecode internals.
Desugaring: with cm as v: body
Compiler буквально переписывает with в эквивалентное try/finally-выражение:
# Исходный код:
with cm as v:
body(v)
# Эквивалент (упрощённо, без обработки exception type / suppress):
_cm = cm
v = _cm.__enter__()
try:
body(v)
finally:
# Если body raised exc — передаём (exc_type, exc_val, exc_tb)
# Иначе — (None, None, None)
_cm.__exit__(exc_type, exc_val, exc_tb)
Что происходит:
cm.__enter__()— вызывается до body. Возвращает значение, которое связывается сv(черезas v).- body(v) — исполняется обычный код блока.
cm.__exit__(...)— вызывается всегда, даже если body raised exception. Это и есть resource cleanup invariant.
Ключевое: __exit__ гарантированно вызывается, как finally block. Это и есть основное value with — гарантия cleanup’а независимо от exit path (нормальный или exceptional).
__enter__() — setup phase
__enter__ принимает self, возвращает значение для as v:
class MyContext:
def __enter__(self):
print('entering')
return 'hello from enter' # ← это будет связано с as v
def __exit__(self, exc_type, exc_val, exc_tb):
print('exiting')
return None
with MyContext() as v:
print(f'inside, v={v!r}')
# Output:
# entering
# inside, v='hello from enter'
# exiting
Часто __enter__ возвращает self (так делает большинство класс-based context managers — например, open()):
class FileMock:
def __init__(self, name):
self.name = name
self.opened = False
def __enter__(self):
self.opened = True
return self # возвращаем self — caller может вызывать self.read() и т.п.
def __exit__(self, exc_type, exc_val, exc_tb):
self.opened = False
return None
with FileMock('data.txt') as f:
print(f.opened) # True
print(f.name) # 'data.txt'
print(f.opened) # False — __exit__ вызвался
__enter__ может ничего не возвращать (тогда as v свяжет с None); это валидно, но as v-clause часто опускают:
with my_lock_cm(): # без as — ничего не bind'им
do_something_critical()
__exit__(exc_type, exc_val, exc_tb) — tri-arg signature
__exit__ обязан принимать три аргумента (помимо self):
| Параметр | Type | Что содержит |
|---|---|---|
exc_type | type or None | Класс exception (если был) — ValueError, KeyError, etc. |
exc_val | Exception or None | Сам exception object |
exc_tb | traceback or None | Traceback object (для traceback.format_tb etc.) |
Если body завершился нормально (без exception), все три = None. Если raised exception — три значения соответствуют sys.exc_info():
class TraceableContext:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
print('normal exit')
else:
print(f'exception: {exc_type.__name__}: {exc_val}')
return None
# Normal exit:
with TraceableContext():
pass
# normal exit
# Exception path:
try:
with TraceableContext():
raise ValueError('boom')
except ValueError:
pass
# exception: ValueError: boom
Tri-arg обязателен. Если ваш __exit__ объявлен def __exit__(self): (без trip args) — Python вызовет его с тремя аргументами, и вы получите TypeError. Pylint и mypy предупредят, но runtime тоже сломается. Это протокольное требование.
Pitfall 3: __exit__ → True suppresses exception
__exit__ имеет специальное return value: если возвращает truthy (True, 1, любой non-empty), то exception подавляется — with-statement завершается нормально, exception не пробрасывается.
class SuppressAllErrors:
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is not None:
print(f'silently suppressing {exc_type.__name__}')
return True # ← suppresses ANY exception!
with SuppressAllErrors():
raise ValueError('this should propagate but won\'t')
print('after with — we got here despite ValueError')
# silently suppressing ValueError
# after with — we got here despite ValueError
Это очень опасно. Молчаливо подавлять все exceptions — анти-паттерн: вы теряете stack trace, debugging становится impossible, программа continues в incorrect state.
Правильно: __exit__ должен возвращать None (или False) — exception тогда пробрасывается. Только если есть конкретная reason подавлять specific exception type — тогда возвращайте True для этого type:
class SuppressKeyError:
def __exit__(self, exc_type, exc_val, exc_tb):
# Подавляем только KeyError, остальные пробрасываем:
return exc_type is KeyError
Production-pattern: для “suppress this specific exception” используйте contextlib.suppress(KeyError) (см. урок 05) — это idiomatic и явно. Свой __exit__ с return True для one-off случаев — confusing, а через suppress — self-documenting.
Pitfall recognition: если ваш __exit__ accidentally returns truthy (e.g., return self.was_active = True), exceptions silently disappear. Tests пропадают, bugs остаются. Всегда explicit return None (или нет return statement — Python implicit None).
Multi-context (PEP 617, Python 3.10+)
С Python 3.10 — несколько context managers в одном with-statement через запятую:
# Python 3.10+:
with open('input.txt') as fin, open('output.txt', 'w') as fout:
fout.write(fin.read().upper())
# Многострочно (PEP 617 parenthesized form):
with (
open('input.txt') as fin,
open('output.txt', 'w') as fout,
open('log.txt', 'a') as flog,
):
flog.write('processing started\n')
fout.write(fin.read().upper())
До Python 3.10 — это работало только через nested with:
# Python <= 3.9:
with open('input.txt') as fin:
with open('output.txt', 'w') as fout:
fout.write(fin.read().upper())
Multi-context — синтаксический сахар; semantics эквивалентны nested with. Cleanup идёт в обратном порядке allocation (LIFO): fout.__exit__ вызывается до fin.__exit__.
Cite: PEP 617 — new PEG parser сделал parenthesized multi-context возможным.
Pyodide constraint: в этом курсе bare open() не работает — Pyodide virtual FS не имеет файлов. Используйте io.StringIO / io.BytesIO / tempfile.TemporaryDirectory для in-memory resources.
import io
with (
io.StringIO('input data') as src,
io.StringIO() as dest,
):
dest.write(src.read().upper())
print(dest.getvalue()) # 'INPUT DATA'
Bytecode lowering: ExceptionTable (Python 3.12+)
В Python ≤ 3.11 try/finally (и with) компилировался через opcodes SETUP_FINALLY / END_FINALLY. С Python 3.11+ (PEP 657 / Python 3.11 changelog) compiler перешёл на ExceptionTable — отдельную structure, описывающую exception handlers как table на code object’е, не как opcodes в потоке. Это reduces bytecode size и speeds up no-exception path (zero-cost exceptions).
Empirically — открываем dis.dis:
import dis
def with_resource():
try:
return 'ok'
finally:
pass
dis.dis(with_resource)
# Вывод (Python 3.12.7 локально):
# with_resource:
# 0 RESUME 0
# 2 NOP
# 4 RETURN_CONST 0 ('ok')
# >> 6 PUSH_EXC_INFO
# 8 NOP
# 10 RERAISE 0
# 12 COPY 3
# 14 POP_EXCEPT
# 16 RERAISE 1
# ExceptionTable:
# 2 to 4 -> 6 [0] ← table entry: try-block byte 2..4 maps to handler at byte 6
# 6 to 16 -> 12 [1] lasti
НЕТ opcode SETUP_FINALLY. Вместо него — ExceptionTable секция в bytecode, описывающая handlers как (start, end, target, depth, lasti) tuples. На no-exception path только RESUME / NOP / RETURN_CONST исполняются — никаких setup/teardown opcodes (zero-cost).
[VERIFIED: Python 3.12.7 локально 2026-04-28] — dis.dis(try/finally) показывает RESUME / NOP / RETURN_CONST / PUSH_EXC_INFO / RERAISE — нет SETUP_FINALLY. Тот же pattern для with-statement: compiler emits __enter__ call + body + __exit__ call, exception handling — через ExceptionTable.
Cite: Python 3.11 changelog — Exception Tables; PEP 657.
Why this matters: zero-cost exceptions означает, что наличие try/finally (или with) не slow’ит код, если exception не raises. До 3.11 каждое try/finally добавляло overhead на normal path. С 3.11+ — overhead только при actual exception. Это перформансная победа для idiomatic Python (где with повсеместно).
Recipe: class-based Timer context manager
Production-ready pattern: измерение времени выполнения body. Class-based — instance state (self.start, self.elapsed_ms) доступно после with-block для inspection.
import time
class Timer:
def __enter__(self):
self.start = time.perf_counter()
return self # caller получает Timer instance
def __exit__(self, exc_type, exc_val, exc_tb):
self.elapsed_ms = (time.perf_counter() - self.start) * 1000
return None # exceptions пробрасываются (НЕ suppress)
with Timer() as t:
sum(range(10000))
print(f'{t.elapsed_ms:.3f} ms') # доступно после with — instance attrs persist
Что мы получили:
__enter__возвращаетself— caller получает доступ к Timer instance черезas t.__exit__вычисляетself.elapsed_ms— доступно после with-block (instance не уничтожается).return None— exceptions пробрасываются (правильное поведение).- Composable:
with Timer() as t: with OtherCM():— works, multi-context PEP 617 also works.
Empirical demo:
with Timer() as t:
sum(range(100_000))
print(f'sum(range(100_000)) took {t.elapsed_ms:.3f} ms')
# sum(range(100_000)) took 1.234 ms (примерно)
Recipe: temporary attribute override
Pattern для temporary mutation объекта с restore’ом — useful для тестов:
class TempAttr:
"""Temporarily set obj.attr = new_val; restore on exit."""
def __init__(self, obj, attr, new_val):
self.obj = obj
self.attr = attr
self.new_val = new_val
def __enter__(self):
self.original = getattr(self.obj, self.attr)
setattr(self.obj, self.attr, self.new_val)
return self.obj
def __exit__(self, exc_type, exc_val, exc_tb):
setattr(self.obj, self.attr, self.original)
return None
class Config:
debug = False
print(Config.debug) # False
with TempAttr(Config, 'debug', True) as cfg:
print(cfg.debug) # True (внутри with)
print(Config.debug) # False (after with — restored!)
Это invariant: enter (allocate/modify) → use → exit (release/restore). Resource lifecycle.
В уроке 05 покажем, как тот же pattern пишется one-liner’ом через @contextlib.contextmanager (без класса) — synthesis closure + generator + protocol.
Diagram: with-statement lifecycle
Lifecycle:
cm.__enter__()— setup; результат →v.- body(v) — execute.
- На любом exit path (normal или exception) —
__exit__(exc_type, exc_val, exc_tb)вызывается. Tri-arg =(None, None, None)если normal, or actual exception info. - Если
__exit__returns truthy → exception suppress’ится. Если returns falsy/None → exception пробрасывается дальше (если он был).
Forward-link Phase 67: pytest fixtures
@pytest.fixture (Phase 67 — testing) поддерживает yield-style fixtures:
import pytest
@pytest.fixture
def db_connection():
conn = create_connection() # setup
yield conn # ← test использует conn здесь
conn.close() # teardown
Под капотом — это контекст-менеджер, генерируемый pytest-ом. yield работает как boundary __enter__ / __exit__ — exact pattern, который мы покажем в уроке 05 через @contextlib.contextmanager. Понимание M06 уроков 04-05 — прямой prerequisite для понимания pytest fixtures в Phase 67.
Cross-link M02 урок 06: resource invariant
with-statement реализует resource invariant pattern: enter (allocate immutable handle) → use → exit (release). Это та же idea, что в M02 урок 06 — immutable handle обеспечивает stable identity на время использования.
# M02 урок 06 — immutable handle:
# tuple — immutable; идентичность стабильна, можно использовать как dict key.
# M06 урок 04 — resource invariant:
# context manager — immutable resource handle между enter и exit.
# Между __enter__ и __exit__ — handle "frozen" (нельзя alloc/dealloc).
Связка: immutability — stability — safe usage. Это general principle Python design.
Pitfall checklist
| # | Trap | Fix |
|---|---|---|
| 1 | __exit__(self): без tri-args — TypeError при exception | Всегда def __exit__(self, exc_type, exc_val, exc_tb): |
| 2 | __exit__ → True accidentally — silent exception suppression | Explicit return None или нет return |
| 3 | with open() в Pyodide — FileNotFoundError | io.StringIO / tempfile.TemporaryDirectory |
| 4 | Забыли __enter__ возвращать self или value — as v будет None | Explicit return self (или нужное value) |
| 5 | Resource не cleanups при exception (no try/finally в обычном коде) | Используйте with — гарантия __exit__ |
Cross-course context
Cross-course → ClickHouse: 14/07 atomic-ddl —
with cm: bodyгарантирует__exit__cleanup на любом exit path; та же гарантия в SQL:Atomic DDLв ClickHouse гарантирует, что DDL-операция (CREATE,DROP,RENAME) либо целиком зафиксируется, либо целиком откатится — без partial state. Параллель: Pythonwithоборачивает критическую секцию двумя гарантированными точками (acquire/release); atomic DDL оборачивает schema-change pair (begin/commit-or-rollback). Обе абстракции скрывают exception-safety boilerplate (try/finally,BEGIN/ROLLBACK).
Ключевые выводы
with cm as v: bodydesugars вcm.__enter__()→v→body→cm.__exit__(exc_type, exc_val, exc_tb). Cleanup гарантирован на любом exit path.__exit__(self, exc_type, exc_val, exc_tb)— tri-arg обязателен. На normal exit — все три None; на exception — actual values.- Pitfall 3:
__exit__ → Trueподавляет exception (silently!). Default —return None. Только conditional suppress для конкретных exception types. - Multi-context PEP 617 (Python 3.10+):
with a as x, b as y:— syntactically convenient, semantics эквивалентны nested. - Bytecode Python 3.12+:
try/finally+withlowered черезExceptionTable, неSETUP_FINALLY. Zero-cost exceptions на normal path. Cite PEP 657. - Recipe Timer: class-based pattern —
__enter__сохраняет start time,__exit__вычисляет elapsed;as tдаёт access к instance attrs после with. - Forward-link Phase 67 pytest fixtures — yield-style fixtures = generator + context-manager protocol (синтез в уроке 05).
В уроке 05 — @contextlib.contextmanager decorator: closure (M03) + generator (M05) + context-manager protocol (M06 урок 04) объединяются в один elegant pattern. Educational climax Phase 66.