Learning Platform
Глоссарий Troubleshooting
Урок 07.04 · 25 мин
Продвинутый
with statement__enter____exit__Context manager protocolTri-arg signaturePEP 343PEP 617ExceptionTableTimer recipeResource invariant

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.

В этом уроке:

  1. Desugaring: with cm as v: body — что compiler делает.
  2. __enter__ — setup phase, возвращает значение для as v.
  3. __exit__(exc_type, exc_val, exc_tb) — teardown, tri-arg обязателен.
  4. Pitfall 3: __exit__ → True suppresses exception — не возвращайте True без причины.
  5. Multi-context (PEP 617, Python 3.10+) — with a as x, b as y:.
  6. Bytecode lowering Python 3.12+: ExceptionTable вместо SETUP_FINALLY — empirically dis.dis verified.
  7. Recipe: class-based Timer context manager — measures body execution time.
  8. Code challenge: py-m06-04-code-1 — Timer protocol-correctness check.
  9. 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)

Что происходит:

  1. cm.__enter__() — вызывается до body. Возвращает значение, которое связывается с v (через as v).
  2. body(v) — исполняется обычный код блока.
  3. 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_typetype or NoneКласс exception (если был) — ValueError, KeyError, etc.
exc_valException or NoneСам exception object
exc_tbtraceback or NoneTraceback 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
WARNING

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
TIP

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 возможным.

WARNING

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/finallywith) компилировался через 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.

TIP

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

Что мы получили:

  1. __enter__ возвращает self — caller получает доступ к Timer instance через as t.
  2. __exit__ вычисляет self.elapsed_ms — доступно после with-block (instance не уничтожается).
  3. return None — exceptions пробрасываются (правильное поведение).
  4. 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

with cm as v:
cm.__enter__()
body(v)
cm.__exit__(exc_type, exc_val, exc_tb)

Lifecycle:

  1. cm.__enter__() — setup; результат → v.
  2. body(v) — execute.
  3. На любом exit path (normal или exception) — __exit__(exc_type, exc_val, exc_tb) вызывается. Tri-arg = (None, None, None) если normal, or actual exception info.
  4. Если __exit__ returns truthy → exception suppress’ится. Если returns falsy/None → exception пробрасывается дальше (если он был).

@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.


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

#TrapFix
1__exit__(self): без tri-args — TypeError при exceptionВсегда def __exit__(self, exc_type, exc_val, exc_tb):
2__exit__ → True accidentally — silent exception suppressionExplicit return None или нет return
3with open() в Pyodide — FileNotFoundErrorio.StringIO / tempfile.TemporaryDirectory
4Забыли __enter__ возвращать self или value — as v будет NoneExplicit return self (или нужное value)
5Resource не cleanups при exception (no try/finally в обычном коде)Используйте with — гарантия __exit__

Cross-course context

Cross-course → ClickHouse: 14/07 atomic-ddlwith cm: body гарантирует __exit__ cleanup на любом exit path; та же гарантия в SQL: Atomic DDL в ClickHouse гарантирует, что DDL-операция (CREATE, DROP, RENAME) либо целиком зафиксируется, либо целиком откатится — без partial state. Параллель: Python with оборачивает критическую секцию двумя гарантированными точками (acquire/release); atomic DDL оборачивает schema-change pair (begin/commit-or-rollback). Обе абстракции скрывают exception-safety boilerplate (try/finally, BEGIN/ROLLBACK).


Ключевые выводы

  1. with cm as v: body desugars в cm.__enter__()vbodycm.__exit__(exc_type, exc_val, exc_tb). Cleanup гарантирован на любом exit path.
  2. __exit__(self, exc_type, exc_val, exc_tb)tri-arg обязателен. На normal exit — все три None; на exception — actual values.
  3. Pitfall 3: __exit__ → True подавляет exception (silently!). Default — return None. Только conditional suppress для конкретных exception types.
  4. Multi-context PEP 617 (Python 3.10+): with a as x, b as y: — syntactically convenient, semantics эквивалентны nested.
  5. Bytecode Python 3.12+: try/finally + with lowered через ExceptionTable, не SETUP_FINALLY. Zero-cost exceptions на normal path. Cite PEP 657.
  6. Recipe Timer: class-based pattern — __enter__ сохраняет start time, __exit__ вычисляет elapsed; as t даёт access к instance attrs после with.
  7. 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.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. Сколько аргументов получает `__exit__` (помимо `self`)?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5