Learning Platform
Глоссарий Troubleshooting
Урок 07.05 · 22 мин
Продвинутый
contextlib@contextmanager_GeneratorContextManagersuppressredirect_stdoutclosingExitStackClimax synthesisPre-exam consolidation
Требуемые знания:

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

В этом уроке:

  1. Recipe 1: @contextmanager для temporary attribute override (one-liner alternative класса из урока 04).
  2. Internals: _GeneratorContextManager source из Lib/contextlib.py — как yield становится boundary.
  3. Recipes 2-4: suppress, redirect_stdout, closing, ExitStack — production-ready stdlib helpers.
  4. Pitfall 10: yield без try/finally — teardown skipped under exception path.
  5. Climax synthesis: closure + generator + protocol = @contextmanager.
  6. 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!

Что произошло:

  1. @contextmanager — это decorator, оборачивающий generator function в _GeneratorContextManager (instance с __enter__ / __exit__).
  2. temp_attr — generator function (имеет yield → CO_GENERATOR flag). Compiler знает: тело не runs при вызове, возвращается PyGenObject.
  3. with temp_attr(...) as cfg:_GeneratorContextManager.__enter__ вызывает next(gen), исполняет тело до yield obj, captures yielded value (obj) → cfg.
  4. body runs (print(cfg.debug)) — generator suspended на yield.
  5. __exit__ вызывает next(gen) (или gen.throw(exc) если exception) — generator resume’ится, finally-block runs → setattr(obj, attr, original).

yieldboundary между 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

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

  1. @contextmanager возвращает helper (closure, замыкающая func). При вызове temp_attr(obj, attr, val) создаётся _GeneratorContextManager instance.
  2. __init__ вызывает func(*args) — это вызов generator function (см. M05 урок 02 — возвращает PyGenObject, тело не runs).
  3. __enter__ вызывает next(self.gen) — это первый resume frame’а (gen_send_ex в Objects/genobject.c — см. M05 урок 02). Тело runs до yield, yielded value возвращается caller’у.
  4. __exit__ на normal path вызывает next(self.gen) — это второй resume; тело runs после yield (включая finally block), завершается 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_DEREFhelper замыкает func через cell — это closure-based wrapper
Generator function (M05 урок 02)PyGenObject + CO_GENERATOR + YIELD_VALUEfunc — 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: внутри withprint пишет в buf (потому что sys.stdout = buf); после withsys.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 managersif some_flag: stack.enter_context(extra_resource).
  • Dynamic cleanupstack.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.

WARNING

Rule: Каждый @contextmanager generator должен иметь try/finally вокруг yield. Без этого — silent resource leak под exception path. Это обязательная hygiene, не optional.


Diagram: @contextmanager lifecycle

caller
_GeneratorContextManager
generator (PyGenObject)

Sequence:

  1. my_cm() — generator function call → PyGenObject (frame frozen, тело не runs).
  2. @contextmanager wraps PyGenObject в _GeneratorContextManager.
  3. __enter__next(gen) → frame resume’ится, runs setup, suspends на yield val. val returned to caller (binds as v).
  4. body runs.
  5. __exit__next(gen) (normal) или gen.throw(exc) (exception) → frame resume, runs teardown (in finally), завершается StopIteration.

Это точная sequence из M05 урок 02 (PyGenObject suspension) + M05 урок 04 (gen.throw injection) + M06 урок 04 (__enter__ / __exit__ protocol). Три уроков встречаются в одной 30-строчной функции.


Pre-exam consolidation: 5 концепций Module 06

#КонцепцияM06 lessonCPython sourceCross-module link
1@decf = dec(f) + closure-based wrapperУрок 01Objects/cellobject.c, Python/ceval.c LOAD_DEREFM03 урок 04 (PyCellObject revisit)
2@functools.wraps копирует __name__/__doc__/__module__/__annotations__/__qualname__ + __wrapped__Урок 02Lib/functools.py WRAPPER_ASSIGNMENTS, update_wrapper, wrapsM03 урок 04 (lru_cache — параметризованный pattern)
3Class-based decorator__init__ + __call__; update_wrapper(self, fn) для Pitfall 4Урок 03Objects/typeobject.c slot_tp_callM04 урок 02 (__call__tp_call slot mapping)
4__enter__/__exit__ + tri-arg + ExceptionTable bytecode (Python 3.12+)Урок 04Python/compile.c ExceptionTable; PEP 343, PEP 617M02 урок 06 (resource invariant — immutable handle)
5@contextlib.contextmanager — closure + generator + protocol synthesisУрок 05Lib/contextlib.py _GeneratorContextManagerM03 урок 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

Перед экзаменом проверьте, что можете ответить без запинки:

  1. Запишите без подсказки @retry(max_attempts=3) — three nested defs, с @functools.wraps внутри.
  2. Объясните, почему wrapper.__name__ == 'wrapper' без @wraps. Какие 5 атрибутов копирует WRAPPER_ASSIGNMENTS?
  3. Запишите class-based decorator CountCalls с __init__ + __call__. Как лечить Pitfall 4 (lost __name__)?
  4. Объясните tri-arg signature __exit__(self, exc_type, exc_val, exc_tb). Что происходит, если __exit__ returns True?
  5. Запишите @contextmanager temp_attr(obj, attr, val) с try/finally вокруг yield. Почему try/finally обязателен?
  6. Назовите 4 contextlib рецепта: suppress, redirect_stdout, closing, ExitStack. Когда использовать каждый?
  7. 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 прямо используют @contextmanager pattern (M06 урок 05 — exact prerequisite).
  • Phase 68 — File I/O & serializationwith 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 кода.


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

  1. @contextlib.contextmanager оборачивает generator function в _GeneratorContextManager. yield — boundary __enter__/__exit__. Cite Lib/contextlib.py.
  2. Pitfall 10: yield обязан быть внутри try/finally — иначе teardown пропускается под exception path. Hygiene rule, не optional.
  3. contextlib.suppress(*exceptions) — idiomatic replacement для try/except/pass. Self-documenting.
  4. contextlib.redirect_stdout(target) / redirect_stderr(target) — capture output для тестов / programmatic processing.
  5. contextlib.closing(thing) — wrap объект с .close()-методом, но без __enter__/__exit__.
  6. contextlib.ExitStack — variable-count resources; stack.enter_context(cm); LIFO cleanup. Альтернатива deeply nested with’ов.
  7. 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.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. **CLIMAX synthesis Q**: что общего у closure (M03 урок 04) + generator function (M05 урок 02) + context-manager protocol (M06 урок 04)?

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

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

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

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