Deep Engineering

MEASUREMENT

bench/context-managers/traps.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/python/context-managers
How to run it
for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v traps.py; done

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для урока «Контекстные менеджеры»

скрипт что показывает
protocol.py два метода на ТИПЕ, что связывает as, почему методы на экземпляре не считаются
suppress.py истина из __exit__ глушит исключение; return True «ради успеха» глотает всё подряд
silent.py ошибка, которая не падает: suppress вокруг цикла даёт 100 вместо 175
generator_cm.py @contextmanager без try/finally, исключение бросается в точку yield, один объект — один вход, LIFO у ExitStack, suppress выходит из блока целиком
traps.py упавший __enter__ не зовёт __exit__; несколько менеджеров — это вложение; сообщение TypeError по версиям
bytecode.py BEFORE_WITH против LOAD_SPECIAL и порядок __exit____enter__ в 3.14
cost.py цена трёх форм: без with, класс, @contextmanager

Запускать на всех версиях, которые есть:

for v in 3.11 3.12 3.13 3.14; do echo "== $v"; python$v traps.py; done

Практика урока

practice.py — источник ответов двух практических задач урока, а runs/practice.txt — дословная запись его прогона. Ответ задачи не сочиняется: сборка сверяет заявленное с этой записью (scripts/validate-practice.mjs) и не проходит, если они разошлись.

Прогон снят 30.08.2026 на CPython 3.13.7 (Clang 20.1.4). Абсолютные числа — этой машины; переносится кратность, и задача «во сколько раз» стоит именно на ней.

Кратность устойчива только потому, что формы меряются ВПЕРЕМЕЖКУ: в каждом круге меряются все, минимум для каждой берётся по кругам. Пока замеры шли подряд, просадка машины в окне одной формы целиком доставалась ей, и отношение гуляло в полтора раза от запуска к запуску (измерено на декораторах: 5,9 / 7,1 / 7,5 / 9,2). После перехода на чередование расхождение между прогонами не выходит за несколько процентов. Перезаписывать запись прогона имеет смысл только вместе с проверкой задачи: если после перезапуска ответ изменился, менять нужно задачу, а не файл.

Что здесь сравнивать можно, а что нельзя

Общее правило замеров: время между версиями не сравнивается вообще. Сравнивается только то, что измерено внутри одного запуска одного интерпретатора. Дело не только в компиляторе: 3.11 и 3.12 собраны GCC 13.3.0, 3.13.7 и 3.14.7 — Clang 20.1.4, но и эти две сборки различаются между собой, причём ровно тем флагом (--with-tail-call-interp), которому «Что нового в 3.14» приписывает «a geometric mean of 3-5% faster».

Поэтому cost.py сравнивает три формы внутри одного запуска, а числа 3.13.7 и 3.14.7 стоят рядом как два независимых результата. Сравнения по времени с 3.11 и 3.12 в уроке нет вовсе.

bytecode.py, protocol.py, suppress.py, generator_cm.py и traps.py от тулчейна не зависят: они смотрят на имена инструкций, порядок вызовов и текст сообщений. Эти наблюдения честны между любыми версиями.

Разброс между запусками

cost.py — лучшее из семи прогонов по 200 000 итераций. Повторные запуски дают разброс около ±4 % у формы с классом и около ±3 % у @contextmanager. Числа в уроке округлены до целых наносекунд, и выводы построены на кратностях (во сколько раз одна форма дороже другой), а не на последней цифре: кратности разброс переживают, а цифра — нет.

Script

90 lines
import sys
from contextlib import contextmanager
print("PY", sys.version.split()[0])

# 1. Если __enter__ бросил — __exit__ НЕ ВЫЗЫВАЕТСЯ.
#    Ресурс, захваченный в первой половине __enter__, никто не отпустит.
calls = []
class FailsOnEnter:
    def __enter__(self):
        calls.append("__enter__ начался")
        raise RuntimeError("не смогли открыть")
    def __exit__(self, *a):
        calls.append("__exit__")
        return None
try:
    with FailsOnEnter():
        pass
except RuntimeError:
    pass
print("1) что вызвалось:", calls)
print("   __exit__ был вызван:", "__exit__" in calls)

# 2. Несколько менеджеров в одной строке — это ВЛОЖЕНИЕ, а не параллель.
#    Если второй __enter__ упал, первый __exit__ отработает.
order = []
@contextmanager
def named(n, boom=False):
    order.append(f"вход {n}")
    try:
        if boom:
            raise RuntimeError(f"{n} не открылся")
        yield n
    finally:
        order.append(f"выход {n}")

try:
    with named("A"), named("B", boom=True):
        order.append("тело")
except RuntimeError:
    pass
print("2) порядок:", " | ".join(order))
# ОСТОРОЖНО С ЧТЕНИЕМ ЭТОЙ СТРОКИ. «выход B» здесь — это finally ВНУТРИ
# генератора, а не вызов __exit__: исключение до yield разматывает кадр
# генератора и проходит через finally. У класса такого нет — там при
# упавшем __enter__ не вызывается ничего, как в пункте 1. То есть
# @contextmanager с try/finally прощает больше, чем класс, и это одно из
# немногих мест, где генераторная форма ведёт себя ЛУЧШЕ.
order2 = []
class ClassCM:
    def __init__(self, n, boom=False): self.n, self.boom = n, boom
    def __enter__(self):
        order2.append(f"вход {self.n}")
        if self.boom:
            raise RuntimeError(f"{self.n} не открылся")
        return self
    def __exit__(self, *a):
        order2.append(f"выход {self.n}")
        return None
try:
    with ClassCM("A"), ClassCM("B", boom=True):
        order2.append("тело")
except RuntimeError:
    pass
print("   то же на классах:", " | ".join(order2))

# 3. Сообщение об ошибке называет метод — но какой именно, зависит от версии.
class OnlyEnter:
    def __enter__(self): return self
class OnlyExit:
    def __exit__(self, *a): return None
class Neither: pass
for cls in (OnlyEnter, OnlyExit, Neither):
    try:
        with cls(): pass
    except TypeError as e:
        print(f"3) {cls.__name__:<10} ->", e)

# 4. __exit__ получает ровно три аргумента, и все три — None при штатном выходе.
seen = []
class Args:
    def __enter__(self): return self
    def __exit__(self, *a):
        seen.append(tuple(x is None for x in a))
        return None
with Args(): pass
try:
    with Args(): raise ValueError
except ValueError: pass
print("4) (exc_type, exc, tb) is None:", seen)