contextlib и module summary — climax synthesis
Это финальный урок Phase 66 — и educational climax. Мы покажем @contextlib.contextmanager decorator — простую stdlib utility, которая синтезирует три primitives, изученных в Phase 65/66:
- Closure (M03 урок 04 — PyCellObject + LOAD_DEREF — base для decorator),
- Generator function (M05 урок 02 — PyGenObject + CO_GENERATOR + YIELD_VALUE — frame suspension),
- Context-manager protocol (M06 урок 04 —
__enter__/__exit__+ tri-arg).
Все три встречаются в одном паттерне: @contextmanager берёт generator function и оборачивает её в context manager, используя closure для wrapper’а. yield внутри generator работает как boundary между __enter__ (всё ДО yield) и __exit__ (всё ПОСЛЕ yield, в try/finally).
В этом уроке:
- Recipe 1:
@contextmanagerдля temporary attribute override (one-liner alternative класса из урока 04). - Internals:
_GeneratorContextManagersource изLib/contextlib.py— как yield становится boundary. - Recipes 2-4:
suppress,redirect_stdout,closing,ExitStack— production-ready stdlib helpers. - Pitfall 10:
yieldбезtry/finally— teardown skipped under exception path. - Climax synthesis: closure + generator + protocol =
@contextmanager. - Pre-exam consolidation — 5 концепций модуля + 7-item self-assessment checklist.
Recipe 1: @contextmanager для temporary attribute
В уроке 04 мы написали TempAttr — class с __enter__/__exit__ для temporary mutation. Через @contextmanager это one-liner:
from contextlib import contextmanager
@contextmanager
def temp_attr(obj, attr, val):
original = getattr(obj, attr)
setattr(obj, attr, val)
try:
yield obj # ← boundary: enter завершён, body исполняется
finally:
setattr(obj, attr, original) # ← teardown: всегда runs
class Config:
debug = False
print(Config.debug) # False
with temp_attr(Config, 'debug', True) as cfg:
print(cfg.debug) # True
print(Config.debug) # False — restored!
Что произошло:
@contextmanager— это decorator, оборачивающий generator function в_GeneratorContextManager(instance с__enter__/__exit__).temp_attr— generator function (имеетyield→ CO_GENERATOR flag). Compiler знает: тело не runs при вызове, возвращается PyGenObject.with temp_attr(...) as cfg:—_GeneratorContextManager.__enter__вызываетnext(gen), исполняет тело доyield obj, captures yielded value (obj) →cfg.- body runs (
print(cfg.debug)) — generator suspended на yield. __exit__вызываетnext(gen)(илиgen.throw(exc)если exception) — generator resume’ится,finally-block runs →setattr(obj, attr, original).
yield — boundary между setup и teardown. Это тот же pattern, что class-based TempAttr, но в три строки generator body вместо класса с двумя методами.
Internals: _GeneratorContextManager source
Откроем Lib/contextlib.py (упрощено):
# Lib/contextlib.py (упрощено для ясности)
class _GeneratorContextManager:
def __init__(self, func, args, kwds):
self.gen = func(*args, **kwds) # ← вызываем generator function → PyGenObject
def __enter__(self):
try:
return next(self.gen) # ← runs тело до первого yield, returns yielded value
except StopIteration:
raise RuntimeError("generator didn't yield") from None
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is None:
try:
next(self.gen) # ← runs тело после yield (finally block)
except StopIteration:
return False # generator завершился — ОК, exception не suppress
else:
raise RuntimeError("generator didn't stop")
else:
# Exception в body — пробрасываем в generator
try:
self.gen.throw(exc_type, exc_val, exc_tb)
except StopIteration as exc:
return exc is not exc_val # generator suppressed exception
except BaseException as e:
if e is exc_val:
return False # re-raised same exception
raise
def contextmanager(func):
@wraps(func)
def helper(*args, **kwds):
return _GeneratorContextManager(func, args, kwds)
return helper
Что происходит:
@contextmanagerвозвращаетhelper(closure, замыкающаяfunc). При вызовеtemp_attr(obj, attr, val)создаётся_GeneratorContextManagerinstance.__init__вызываетfunc(*args)— это вызов generator function (см. M05 урок 02 — возвращает PyGenObject, тело не runs).__enter__вызываетnext(self.gen)— это первый resume frame’а (gen_send_ex в Objects/genobject.c — см. M05 урок 02). Тело runs доyield, yielded value возвращается caller’у.__exit__на normal path вызываетnext(self.gen)— это второй resume; тело runs после yield (включаяfinallyblock), завершается StopIteration. На exception path —self.gen.throw(exc)— вбрасывает exception в yield point (см. M05 урок 04).
Cite: Lib/contextlib.py — _GeneratorContextManager, contextmanager.
Climax synthesis: closure + generator + protocol
Это главное observation Phase 66:
| Primitive | Где введён | Роль в @contextmanager |
|---|---|---|
| Closure (M03 урок 04) | PyCellObject + LOAD_DEREF | helper замыкает func через cell — это closure-based wrapper |
| Generator function (M05 урок 02) | PyGenObject + CO_GENERATOR + YIELD_VALUE | func — generator function; yield suspendит frame между setup и teardown |
| Context-manager protocol (M06 урок 04) | __enter__ / __exit__ + tri-arg | _GeneratorContextManager реализует протокол через wrap’инг generator |
Один elegant pattern. Три primitives. @contextmanager — не новый mechanism, а синтез: existing primitives composed.
Это и есть педагогическая ценность Phase 65/66 — мы изучаем primitives по-отдельности, чтобы потом видеть, как они комбинируются в @contextlib.contextmanager. Это и есть “знание Python внутри” — не запомнить factsheet’у, а понимать composition.
Recipe 2: suppress(ValueError) — silent ignoring
contextlib.suppress — context manager, подавляющий specific exception types. Replacement для try/except/pass boilerplate:
from contextlib import suppress
# Anti-pattern (verbose):
try:
int('not a number')
except ValueError:
pass
# Idiomatic (one-liner):
with suppress(ValueError):
int('not a number')
# Multiple types:
with suppress(KeyError, IndexError):
some_dict['missing_key']
suppress — это просто class с __exit__ returning True для matching exception type. Source (Lib/contextlib.py):
class suppress(AbstractContextManager):
def __init__(self, *exceptions):
self._exceptions = exceptions
def __enter__(self):
pass
def __exit__(self, exctype, excinst, exctb):
return exctype is not None and issubclass(exctype, self._exceptions)
Возвращает True только для matched exception types — это safe version Pitfall 3 (__exit__ → True for specific cases). Self-documenting, идиоматичный.
Recipe 3: redirect_stdout — capture print output
contextlib.redirect_stdout(target) временно redirect’ит sys.stdout на target (любой file-like). Useful для тестов и captureа output библиотечного кода:
from contextlib import redirect_stdout
import io
buf = io.StringIO()
with redirect_stdout(buf):
print('this goes to buf, not screen')
print('another line')
print('captured:', repr(buf.getvalue()))
# captured: 'this goes to buf, not screen\nanother line\n'
Pattern: внутри with — print пишет в buf (потому что sys.stdout = buf); после with — sys.stdout restored. Resource invariant: redirect → use → restore.
Аналогично redirect_stderr(target) для stderr.
Recipe 4: closing — wrap non-CM resources
Если у объекта есть .close() метод, но нет __enter__/__exit__, оберните через contextlib.closing:
from contextlib import closing
import io
# io.StringIO уже context manager, но для demo:
with closing(io.StringIO('data')) as buf:
print(buf.read())
# buf.close() гарантированно вызывается на exit
Source:
class closing(AbstractContextManager):
def __init__(self, thing):
self.thing = thing
def __enter__(self):
return self.thing
def __exit__(self, *exc_info):
self.thing.close()
Pitfall 7 reminder: в Pyodide open() не работает; io.StringIO и io.BytesIO — proper drop-in replacement, оба already context managers (with closing not strictly needed).
Recipe 5: ExitStack — variable-count resources
contextlib.ExitStack — для случая, когда число resources неизвестно в compile-time (e.g., разное число файлов на основе config):
from contextlib import ExitStack
import io
filenames = ['file1.txt', 'file2.txt', 'file3.txt']
with ExitStack() as stack:
# Аллокация N resources в loop — все будут closed на exit
files = [stack.enter_context(io.StringIO(f'content of {name}'))
for name in filenames]
# Use all files:
for f in files:
print(f.read())
# ExitStack теперь exits all enter'ed contexts в reverse order (LIFO)
stack.enter_context(cm) — manually вызывает cm.__enter__() и регистрирует cleanup на ExitStack-уровне. На exit — все registered context managers exited в LIFO order (last-allocated first-released).
Когда использовать ExitStack:
- Variable-count resources — N не известно до runtime.
- Conditional context managers —
if some_flag: stack.enter_context(extra_resource). - Dynamic cleanup —
stack.callback(some_func)registers cleanup function, не attached к специфическому context.
Без ExitStack альтернатива — deeply nested with-statements, что не масштабируется.
Pitfall 10: yield без try/finally — teardown skipped
Самая частая ошибка с @contextmanager. Если body внутри with raises exception, generator получает gen.throw() в yield point. Без try/finally это означает teardown пропускается:
from contextlib import contextmanager
# BUG: yield не в try/finally
@contextmanager
def buggy_resource():
print('setup')
yield 'resource'
print('teardown — BUT THIS WON\'T RUN ON EXCEPTION!')
try:
with buggy_resource() as r:
raise ValueError('oh no')
except ValueError:
pass
# Output:
# setup
# (никакого 'teardown' — потому что exception в body!)
Generator получает throw’нутый exception в yield point, exception пропагирует наверх по generator frame, тело после yield не runs.
Правильный pattern:
@contextmanager
def correct_resource():
print('setup')
try:
yield 'resource'
finally:
print('teardown — ALWAYS runs')
try:
with correct_resource() as r:
raise ValueError('oh no')
except ValueError:
pass
# Output:
# setup
# teardown — ALWAYS runs
try/finally оборачивает yield. Generator на exception path всё равно исполняет finally (M05 урок 04 — gen.throw() triggers finally blocks). На normal path — finally исполняется при next(gen) после yield.
Rule: Каждый @contextmanager generator должен иметь try/finally вокруг yield. Без этого — silent resource leak под exception path. Это обязательная hygiene, не optional.
Diagram: @contextmanager lifecycle
Sequence:
my_cm()— generator function call → PyGenObject (frame frozen, тело не runs).@contextmanagerwraps PyGenObject в_GeneratorContextManager.__enter__→next(gen)→ frame resume’ится, runs setup, suspends наyield val.valreturned to caller (bindsas v).- body runs.
__exit__→next(gen)(normal) илиgen.throw(exc)(exception) → frame resume, runs teardown (infinally), завершается StopIteration.
Это точная sequence из M05 урок 02 (PyGenObject suspension) + M05 урок 04 (gen.throw injection) + M06 урок 04 (__enter__ / __exit__ protocol). Три уроков встречаются в одной 30-строчной функции.
Pre-exam consolidation: 5 концепций Module 06
| # | Концепция | M06 lesson | CPython source | Cross-module link |
|---|---|---|---|---|
| 1 | @dec ≡ f = dec(f) + closure-based wrapper | Урок 01 | Objects/cellobject.c, Python/ceval.c LOAD_DEREF | M03 урок 04 (PyCellObject revisit) |
| 2 | @functools.wraps копирует __name__/__doc__/__module__/__annotations__/__qualname__ + __wrapped__ | Урок 02 | Lib/functools.py WRAPPER_ASSIGNMENTS, update_wrapper, wraps | M03 урок 04 (lru_cache — параметризованный pattern) |
| 3 | Class-based decorator — __init__ + __call__; update_wrapper(self, fn) для Pitfall 4 | Урок 03 | Objects/typeobject.c slot_tp_call | M04 урок 02 (__call__ → tp_call slot mapping) |
| 4 | __enter__/__exit__ + tri-arg + ExceptionTable bytecode (Python 3.12+) | Урок 04 | Python/compile.c ExceptionTable; PEP 343, PEP 617 | M02 урок 06 (resource invariant — immutable handle) |
| 5 | @contextlib.contextmanager — closure + generator + protocol synthesis | Урок 05 | Lib/contextlib.py _GeneratorContextManager | M03 урок 04 (closure) + M05 урок 02 (PyGenObject) + M06 урок 04 (protocol) |
Cross-module synthesis recall
M03 урок 04 → M06 урок 01: closure as decorator base
Closure (PyCellObject + LOAD_DEREF) — primitive M03. Decorator — закономерное прикладное использование: wrapper-функция замыкает оригинальную через cell. Empirically wrapper.__closure__[0].cell_contents IS оригинал.
M05 урок 02 → M06 урок 05: generator function как basis для @contextmanager
Generator function (PyGenObject + CO_GENERATOR + YIELD_VALUE) — primitive M05. @contextmanager оборачивает generator в context manager. yield — boundary __enter__ / __exit__. Frame suspension — exact mechanism, что в M05 уроках 02-04.
M02 урок 06 → M06 урок 04: resource invariant ↔ immutability
Immutable handle (tuple, frozenset) — stable identity. Context manager handle между __enter__ и __exit__ — frozen resource. Та же idea: invariant guarantees safe usage.
M04 урок 02 → M06 урок 03: call → tp_call → class-based decorator
Class-based decorator __call__ — это slot wrapper для tp_call (M04 урок 02 mapping). Это same mechanism, что обычные function calls.
M03 урок 04 → M06 урок 02: lru_cache как параметризованный decorator
@lru_cache(maxsize=128) — параметризованный декоратор (three nested defs, как @retry). M06 урок 02 explicitly показывает этот pattern; M03 урок 04 показывал его applied use.
7-item self-assessment checklist
Перед экзаменом проверьте, что можете ответить без запинки:
- ☐ Запишите без подсказки
@retry(max_attempts=3)— three nested defs, с@functools.wrapsвнутри. - ☐ Объясните, почему
wrapper.__name__ == 'wrapper'без@wraps. Какие 5 атрибутов копируетWRAPPER_ASSIGNMENTS? - ☐ Запишите class-based decorator
CountCallsс__init__+__call__. Как лечить Pitfall 4 (lost__name__)? - ☐ Объясните tri-arg signature
__exit__(self, exc_type, exc_val, exc_tb). Что происходит, если__exit__returns True? - ☐ Запишите
@contextmanagertemp_attr(obj, attr, val)сtry/finallyвокруг yield. Почемуtry/finallyобязателен? - ☐ Назовите 4 contextlib рецепта:
suppress,redirect_stdout,closing,ExitStack. Когда использовать каждый? - ☐ Synthesize: explain, как
@contextmanagerобъединяет closure (M03) + generator (M05) + context-manager protocol (M06 урок 04). Что такое “yield = boundary”?
Если уверенно отвечаете на все 7 — экзамен пройдёте без проблем. Сомневаетесь в каком-то пункте — re-read соответствующий урок.
What’s next: Phase 67 testing & beyond
M06 завершает Core Python (Phase 65 + 66 — модули 0-6). Дальше:
- Phase 67 — Testing & pytest — yield-style fixtures прямо используют
@contextmanagerpattern (M06 урок 05 — exact prerequisite). - Phase 68 — File I/O & serialization —
with open()повсеместно; understanding__exit__exception semantics critical для resource safety. - Phase 70+ — async/await (deferred к v2 milestone) —
async def+awaitпользуется тем же frame-suspension primitive (M05 урок 02 baseline).__aenter__/__aexit__(PEP 492) — async аналог context manager protocol.
Это и есть carried benefit Phase 66: каждый последующий module/phase будет узнаваемо использовать decorators + context managers — это bread and butter Pythonic кода.
Ключевые выводы
@contextlib.contextmanagerоборачивает generator function в_GeneratorContextManager.yield— boundary__enter__/__exit__. CiteLib/contextlib.py.- Pitfall 10:
yieldобязан быть внутриtry/finally— иначе teardown пропускается под exception path. Hygiene rule, не optional. contextlib.suppress(*exceptions)— idiomatic replacement дляtry/except/pass. Self-documenting.contextlib.redirect_stdout(target)/redirect_stderr(target)— capture output для тестов / programmatic processing.contextlib.closing(thing)— wrap объект с.close()-методом, но без__enter__/__exit__.contextlib.ExitStack— variable-count resources;stack.enter_context(cm); LIFO cleanup. Альтернатива deeply nestedwith’ов.- Climax synthesis:
@contextmanager= closure (M03 урок 04) + generator function (M05 урок 02) + context-manager protocol (M06 урок 04). Three primitives — один elegant pattern. Это и есть main takeaway Phase 66.
Поздравляем — вы прошли весь Core Python. Готовьтесь к экзамену через self-assessment checklist выше; перед сдачей — quick re-read пяти уроков M06.