Deep Engineering
Средний·Опубликовано·3.11 · 3.12 · 3.13 · 3.14·30 МИН

Исключения и finally: одно слово, после которого функция перестаёт падать

Стоимость try, который не сработал, — около двух наносекунд, пять процентов, и байт-код объясняет почему. Дорого стоит другое: return в finally, который молча уничтожает исключение и превращает функцию в такую, из которой не выходит ни одна ошибка.

Полное техническое изложение

TL;DR

У инструкции try четыре части, и у каждой своя роль: try — попытка, except — обработка ошибки, else — ветка «ошибки не было», finally — выход в любом случае. Про finally важно одно: он выполняется и после удачи, и после ошибки, и это ровно то, ради чего его пишут.

Отсюда главное следствие: finally, который сам передаёт управление наружу, уничтожает летящее исключение. return в finally — справочник формулирует это одним предложением: the saved exception is discarded (сохранённое исключение отбрасывается), — и то же делают break и continue. Отсюда тихая потеря данных: те же строки, что в уроке про контекстные менеджеры, дают 100 вместо 175 — другой механизм, тот же неверный ответ, и ни единого исключения в логе. Рядом стоит вторая ловушка: голый except: ловит BaseException, то есть Ctrl+C и sys.exit().

Дальше — то, что отличает знающего от читавшего. Стоимость try/except, который не сработал, около двух наносекунд: 40,5 нс против 38,7 у того же кода без try (3.13.7). Это пять процентов, и разница различима — размах прибора 0,4 нс, — но величина такая, что ради неё try не убирают. Сработавший — 201,1 нс, впятеро дороже. Причина структурная, а не случайная: SETUP_FINALLY — псевдоинструкция, в байт-код она не попадает, и на счастливом пути try добавляет один NOP. С 3.14 компилятор предупреждает о return в finally (PEP 765) — но только предупреждает: поведение осталось прежним.

Порог входа
Перед уроком достаточно понимать
  • когда в функции случается ошибка, оставшиеся строки не выполняются;
  • ошибка «летит» наружу — из вызванной функции в вызывающую и дальше, пока её кто-нибудь не перехватит;
  • у ошибки есть тип: ValueError, KeyError, — и ловят их по типу.
Заранее знать не нужно
  • BaseException, ExceptionGroup, except*, __context__, __cause__;
  • PEP 765, SETUP_FINALLY, таблица исключений в байт-коде и наносекунды из замеров.

База: четыре части, и у каждой своя роль

Инструкция try состоит из четырёх частей. Первые две знают все, вторые две путают чаще всего — а путаница в ролях и приводит к ошибкам, ради которых написан весь урок.

  • tryпопытка: код, который может не получиться;
  • exceptобработка: что делать, если не получилось, и с какой именно ошибкой;
  • elseветка «ошибки не было»: код, который имеет смысл только тогда, когда попытка удалась;
  • finallyвыход в любом случае: код, который выполняется и после удачи, и после ошибки.

Одним примером все четыре видно сразу:

PYTHON
try:
    value = int(raw)              # попытка
except ValueError:
    value = 0                     # сюда попадём, только если попытка не удалась
else:
    audit(value)                  # а сюда — только если удалась
finally:
    counter += 1                  # а это выполнится в любом из двух случаев

Роль finally стоит проговорить отдельно, потому что она шире, чем кажется. Он выполняется не только после удачи и после пойманной ошибки, но и тогда, когда управление уходит из блока наружу: при return из середины try и при ошибке, которую никто не поймал. Именно поэтому в finally кладут освобождение ресурсов — закрыть файл, отпустить блокировку: это единственное место, про которое известно, что до него дойдут.

И здесь же главный вопрос урока. finally выполняется, пока исключение летит наружу, — а что будет с самим исключением, если finally при этом сам решит выйти наружу: сделает return, break или continue?

Обычно ничего необычного: finally отработал, исключение полетело дальше. Но в этом одном случае — исключение исчезает. Не откладывается, не заменяется: его больше нет, и функция возвращает значение так, будто ошибки не было.

Этого уже достаточно, чтобы ответить на базовый вопрос собеседования: try — попытка, except — обработка, else — ветка удачи, finally — выход в любом случае, и выход этот умеет проглотить ошибку. Всё дальнейшее — про то, как это выглядит в коде, который никто не заподозрит; про то, чем голый except: отличается от except Exception:; про цепочки исключений и про группы, в которых ошибок сразу несколько.

Механизм 1: одно слово, после которого функция перестаёт падать

контракт языкаГарантия языка: return в finally отбрасывает сохранённое исключение. Так записано в справочнике и так работает на всех версиях.

Справочник формулирует это одним предложением: If the finally clause executes a return, break or continue statement, the saved exception is discarded (Если предложение finally выполняет return, break или continue, сохранённое исключение отбрасывается).

Слово «discarded» здесь надо читать буквально. Не «перекрывается», не «заменяется» — исчезает:

PYTHON
def never_fails():
    try:
        raise RuntimeError("что угодно")
    finally:
        return None

Эта функция не может упасть. Никогда и ни от чего: любое исключение внутри try будет отброшено на выходе. Проверено запуском: вызов возвращает None, исключения нет.

То же делает break, и заметить его труднее, потому что слова return рядом нет:

PYTHON
for _ in range(1):
    try:
        raise KeyError("и этого")
    finally:
        break

finally, передающий управление наружу, глотает исключение так же полно, как except BaseException: pass. Разница только в том, что второе видно при чтении кода, а первое — нет.

Четвёртый случай на рисунке — тот же return в finally, но без исключения: отбрасывается уже обычный результат.

Механизм 2: ошибка, которая не падает

Те же строки, что в уроке про контекстные менеджеры. Механизм другой — неверный ответ тот же.

PYTHON
ROWS = [
    {"id": 1, "amount": "100"},
    {"id": 2, "amount": "нет данных"},   # битая
    {"id": 3, "amount": "50"},
    {"id": 4, "amount": "25"},
]

Правильный вариант: finally только закрывает ресурс, а что делать с битой строкой, решает отдельный except.

PYTHON
def correct(rows):
    total = 0
    for row in rows:
        handle = open("/dev/null", "w")
        try:
            total += int(row["amount"])
        except ValueError:
            continue
        finally:
            handle.close()
    return total

Неправильный выглядит аккуратнее — «закрыли ресурс и честно вернули то, что успели посчитать»:

PYTHON
def broken(rows):
    total = 0
    for row in rows:
        handle = open("/dev/null", "w")
        try:
            total += int(row["amount"])
        finally:
            handle.close()
            return total          # ← вот здесь

Проверено запуском (одинаково на 3.13.7 и 3.14.7):

вариантрезультат
finally закрывает, битую строку пропускает except175
finally c return100
верная сумма175

Потеряно 75 из 175, ни одного исключения в логе. Разница между двумя функциями — одна строка, и та выглядит как забота о ресурсе. Но return в finally выходит из функции на первой же битой строке, отбросив исключение по дороге, — а except ValueError: continue в правильном варианте просто идёт к следующей записи.

Механизм 3: else — зачем он, если можно просто дописать в try

Разница видна не в успешном случае, а в том, что происходит с исключением из дописанного кода.

Справочник: The optional else clause is executed if the control flow leaves the try suite, no exception was raised… Exceptions in the else clause are not handled by the preceding except clauses (Необязательное предложение else выполняется, если управление вышло из блока try и исключение не возникло… Исключения в предложении else не обрабатываются предшествующими предложениями except).

То есть else — это способ сузить try до строки, которая действительно может упасть:

PYTHON
try:
    value = registry[key]          # может бросить KeyError
except KeyError:
    value = default
else:
    audit(value)                   # KeyError отсюда НЕ будет перехвачен

Если audit внутри себя бросит KeyError, обработчик его не поймает — и правильно: этот except написан для одной конкретной строки, а не для всего, что случайно окажется рядом. Проверено запуском: исключение, брошенное в else, уходит наружу мимо стоящего выше except.

Если к тому же блоку дописать finally, порядок получается такой (проверено запуском):

без исключения:  try -> else -> finally
с исключением:   try -> except -> finally

Механизм 4: голый except: ловит больше, чем вы думаете

KeyboardInterrupt и SystemExit — не Exception. Проверяется одной строкой: issubclass(SystemExit, Exception) даёт False, а MRO KeyboardInterrupt — это KeyboardInterrupt → BaseException → object.

Документация объясняет, почему так сделано: The exception inherits from BaseException so as to not be accidentally caught by code that catches Exception and thus prevent the interpreter from exiting (Исключение наследуется от BaseException, чтобы его случайно не поймал код, ловящий Exception, и тем самым не помешал интерпретатору завершиться).

То есть иерархия построена специально, чтобы except Exception: пропускал Ctrl+C. А голый except: эту защиту снимает: он ловит BaseException, и программа перестаёт закрываться по Ctrl+C.

Механизм 5: цепочки — __context__, __cause__ и from None

Исключение, возникшее внутри except, само получает ссылку на предыдущее — это PEP 3134, и делается это интерпретатором без участия автора кода.

запись__context____cause____suppress_context__в трассировке
просто raise внутри exceptестьнетFalseDuring handling of the above exception… (При обработке вышеупомянутого исключения…)
raise … from eестьестьTrueThe above exception was the direct cause… (Вышеупомянутое исключение было прямой причиной…)
raise … from NoneестьнетTrueсвязующей фразы нет

Третья строка — самая полезная. from None не удаляет предыдущее исключение: __context__ остаётся на месте, меняется только __suppress_context__ — флаг, по которому трассировка предыдущее исключение не печатает.

Практически это значит, что from None — про читаемость сообщения, а не про сокрытие данных. Предыдущее исключение по-прежнему достижимо из объекта, и в обработчике верхнего уровня его можно записать в лог.

Ещё одна деталь, и уже не из PEP 3134, а из справочника языка: имя из except … as e удаляется в конце блока. Справочник даёт точную расшифровку — это как если бы тело было обёрнуто в try: … finally: del N. Обращение к e после блока даёт NameError; чтобы сохранить объект, его надо присвоить другому имени.

Механизм 6: группы — чем except* отличается от except

PEP 654 (3.11) добавил тип ExceptionGroup и синтаксис except* для одной задачи: бросить и обработать несколько независимых ошибок сразу. Типичный источник — параллельные задачи, где каждая упала по-своему. Именно это и отдаёт asyncio.TaskGroup, и цепочка тут прямая:

  1. TaskGroup отменяет соседей и собирает их ошибки;
  2. отдаёт он их одним объектом — ExceptionGroup, внутри которого несколько независимых исключений;
  3. except* разбирает группу по типам, и каждая ветка выполняется не более одного раза.

Разбор TaskGroup — в уроке про async/await; здесь разбирается вторая половина цепочки. Всё ниже проверено запуском.

Группа — это отдельный объект, а не её содержимое. ExceptionGroup не «раскрывается» в свои ошибки: обычный except ValueError: не поймает группу, даже если внутри ровно один ValueError.

PYTHON
try:
    raise ExceptionGroup("одна", [ValueError("одинокая")])
except ValueError:
    print("сюда не попадём")
except ExceptionGroup as e:
    print("поймали группу:", type(e).__name__)   # → поймали группу: ExceptionGroup

Обычный except Exception группу поймает, но целиком — одним объектом, а не по одной ошибке.

except* разбирает группу по типам и может сработать несколько раз за один raise. Каждая ветка получает подгруппу — снова ExceptionGroup — со всеми листьями своего типа. PEP: a single exception group can cause several except* clauses to execute, but each such clause executes at most once (одна группа исключений может привести к выполнению нескольких предложений except*, но каждое из них выполняется не более одного раза).

PYTHON
try:
    raise ExceptionGroup("две беды", [ValueError("значение"), KeyError("ключ")])
except* ValueError as eg:
    print("ValueError:", [str(x) for x in eg.exceptions])   # → ValueError: ['значение']
except* KeyError as eg:
    print("KeyError:", [str(x) for x in eg.exceptions])      # → KeyError: ["'ключ'"]
# за один raise сработали ОБЕ ветки

Важное: переменная ветки (eg) — не одно исключение, а группа; сами ошибки лежат в eg.exceptions.

Необработанный остаток летит дальше — снова группой. Если ветки разобрали не всё, оставшиеся листья продолжают распространяться как ExceptionGroup, а не как отдельные исключения:

PYTHON
try:
    raise ExceptionGroup("три", [ValueError(), KeyError(), TypeError()])
except* ValueError:
    pass
# наружу летит ExceptionGroup([KeyError(), TypeError()])

Смешивать except и except* в одном try нельзя — это синтаксическая ошибка. Ветки одного try либо все обычные, либо все со звёздочкой.

Глубже: сколько стоит try

наблюдение замераbench/exceptions/cost.py, CPython 3.13.7. Разница между «с try» и «без try» меньше разброса измерения — это наблюдение замера, а не обещание языка.

Теперь то, о чём спорят без чисел: во что обходится сама конструкция.

форма3.13.73.14.7
без try38,7 нс34,5 нс
try/except, не сработал40,5 нс36,9 нс
try/except, сработал201,1 нс189,5 нс

Колонки читаются по отдельности. Сборки 3.13.7 и 3.14.7 различаются не только компилятором, и разрыв между колонками — не разница между версиями языка.

Разницу между первыми двумя строками надо сперва сопоставить с разбросом самого прибора — и прогон это делает сам, повторяя весь замер пять раз:

  РАЗМАХ ПО 5 ПОВТОРАМ ВСЕГО ЗАМЕРА
  без try                        38.7 ..   39.1 нс  размах   0.4
  try/except, не сработал        40.5 ..   40.9 нс  размах   0.4
  try/except, сработал          201.1 ..  203.4 нс  размах   2.3

  разница между первыми двумя строками     1.8 нс
  наибольший размах этих же двух строк      0.4 нс
  различима ли разница прибором          да

Различима: 1,8 нс против размаха 0,4. Значит, утверждение здесь не «неотличимо», а более узкое и более полезное: try, который не сработал, стоит около двух наносекунд — пять процентов, — и знак этой разницы устойчив, во всех пяти повторах вариант с try чуть медленнее.

Вопрос из-за этого меняется. Он уже не «есть ли разница» — есть, — а «стоит ли она того», и это вопрос к величине. Пять процентов на пустом вызове не окупают ни одной перестройки кода: чтобы они дали заметный выигрыш, try должен стоять в цикле, который сам по себе и есть узкое место.

Почему так, видно в байт-коде. SETUP_FINALLY описана в документации dis среди псевдоинструкций, и про них сказано прямо: They do not appear in Python bytecode. They are used by the compiler but are replaced by real opcodes or removed before bytecode is generated (Их нет в байт-коде Python. Компилятор ими пользуется, но до создания байт-кода заменяет их настоящими инструкциями или убирает). Проверено дизассемблером на всех четырёх версиях — SETUP_FINALLY нет ни в одной.

Вот что реально исполняется:

без try:  RESUME     LOAD_NAME  PUSH_NULL  CALL  POP_TOP
в try:    RESUME NOP LOAD_NAME  PUSH_NULL  CALL  POP_TOP

Разница — один NOP. Куда делась обработка? В отдельную таблицу: дизассемблер печатает её блоком ExceptionTable под кодом. Пока исключения нет, таблицу никто не читает.

И здесь важно назвать границу наблюдения, а не только число. Замерялся один пустой вызов в цикле, на двух сборках CPython, на одной машине. Отсюда следует ровно одно: в этом замере на успешном пути try стоит около двух наносекунд — а не «try бесплатен» вообще и не «try можно ставить куда угодно без последствий». Вложенные обработчики, finally с работой внутри, try внутри генератора замером не покрыты.

Поэтому практический вывод пишется от границы, а не от числа: аргумент «уберём try, он тормозит» ничем не подкреплён — если он звучит, просят замер, а не убирают конструкцию. Дорого стоит сработавшее исключение — впятеро. Вот его в горячем цикле как штатный способ ветвления использовать и не стоит.

Глубже: что изменилось в 3.14

деталь реализации · CPython 3.14Предупреждение PEP 765 — свойство конкретной версии: на 3.13 того же кода никто не заметит.

Компилятор начал предупреждать о return и break, выходящих из finally. Проверено компиляцией:

3.11.15   предупреждений: 0
3.12.3    предупреждений: 0
3.13.7    предупреждений: 0
3.14.7    SyntaxWarning: 'return' in a 'finally' block

Для break — своё сообщение, 'break' in a 'finally' block.

Поведение при этом не изменилось. PEP 765 (Ирит Катриэль, Алисса Кофлан) говорит об этом прямо: we leave it open whether, and when, this will become a SyntaxError (мы оставляем открытым вопрос, станет ли это когда-нибудь SyntaxError и когда именно). Код, написанный так, работает ровно как раньше — просто теперь об этом сообщают. Причина осторожности названа в самом PEP: прогоны с -We превращают предупреждения в ошибки, и немедленный запрет сломал бы их.

Глубже: история версий

ВерсияИзменениеЧто это значит для кода
3.0PEP 3134 вводит __context__ и __cause__. Цепочка появляется сама, без участия автора кода; raise … from задаёт её явно.
3.11PEP 654: ExceptionGroup и except*. Проверено дизассемблером: на всех четырёх версиях, 3.11–3.14, SETUP_FINALLY в байт-коде уже нет, и стоимость не сработавшего try в замере не выходит за разброс.
3.14PEP 765: компилятор предупреждает о return, break и continue, выходящих из finally. Поведение прежнее — исключение по-прежнему отбрасывается молча.

Как отвечать на собеседовании

Короткий ответ: try — попытка, except — обработка ошибки, else — ветка «ошибки не было», finally — выход в любом случае. И главное про finally: если он сам передаёт управление наружу — return, break, continue, — летящее исключение исчезает. Справочник формулирует это одним предложением: «сохранённое исключение отбрасывается».

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

Первое — показать, как это выглядит в настоящем коде: return в finally выглядит как забота о ресурсе, а на деле выходит из функции на первой же ошибке; те же строки, что в уроке про контекстные менеджеры, дают 100 вместо 175 без единого исключения в логе. С 3.14 компилятор об этом предупреждает (PEP 765), но только предупреждает: поведение осталось прежним.

Второе — голый except: ловит BaseException, то есть Ctrl+C и sys.exit(); иерархия сделана именно так, чтобы except Exception: их пропускал.

Третье — если разговор ушёл в скорость, назвать замер вместе с его границей: на успешном пути дополнительная стоимость try в этом замере — около двух наносекунд (40,5 нс против 38,7 у того же кода без try — пять процентов, и это больше размаха прибора, 0,4 нс), а сработавшее исключение стоит 201,1 нс — впятеро дороже. Причина структурная: SETUP_FINALLY — псевдоинструкция, в байт-код она не попадает, и на счастливом пути try добавляет один NOP. Вывод отсюда узкий: убирать try ради скорости незачем, а использовать исключение как штатное ветвление в горячем цикле — есть за что.

Дальше спросят

Спросят дальше

return внутри finally — что произойдёт с летящим исключением?

Короткий ответ

Оно исчезнет, и функция вернёт значение как при обычном выходе. Одно слово — и функция перестаёт падать вовсе, включая те исключения, которых автор не имел в виду.

Спросят дальше

Когда except*, а когда обычный except?

Короткий ответ

except* нужен там, где исключений может прилететь несколько сразу и потерять любое из них нельзя, — то есть там же, откуда взялись группы. Обычный except разбирает одно исключение и ветку выбирает одну.

Частые заблуждения

Утверждение

try/except замедляет код, лучше проверять условием

На самом деле

В этом замере на успешном пути дополнительная стоимость около двух наносекунд: 38,7 нс без try против 40,5 нс с ним (3.13.7) — пять процентов, устойчивых по знаку во всех пяти повторах замера, при размахе прибора 0,4 нс. Причина структурная: SETUP_FINALLY — псевдоинструкция и в байт-код не попадает, на счастливом пути добавляется один NOP. Это не значит «try бесплатен вообще»: значит, что аргумент «уберём try, он тормозит» надо подкреплять замером, а не интуицией. Дорого стоит СРАБОТАВШЕЕ исключение: 201,1 нс, впятеро.

Утверждение

return в finally просто перекрывает возвращаемое значение

На самом деле

Он ещё и уничтожает исключение. Справочник: the saved exception is discarded (сохранённое исключение отбрасывается). Функция с return в finally не может упасть вообще — проверено запуском: raise RuntimeError внутри try наружу не выходит, вызов возвращает None. То же делают break и continue.

Утверждение

в 3.14 return в finally запретили

На самом деле

Предупредили, а не запретили. PEP 765: we leave it open whether, and when, this will become a SyntaxError (мы оставляем открытым вопрос, станет ли это когда-нибудь SyntaxError и когда именно). Проверено компиляцией: на 3.14 появляется SyntaxWarning: 'return' in a 'finally' block, на 3.11–3.13 не появляется ничего, а работает код во всех четырёх одинаково.

Утверждение

else в try — синтаксический сахар, можно дописать в конец try

На самом деле

Нельзя без изменения смысла. Справочник: Exceptions in the else clause are not handled by the preceding except clauses (исключения в предложении else не обрабатываются предшествующими предложениями except). Перенеся строку из else в try, вы отдаёте её исключения тому же обработчику — и KeyError из вложенного вызова станет неотличим от KeyError, ради которого except писали.

Утверждение

голый except: — то же, что except Exception:

На самом деле

Он ловит BaseException, то есть KeyboardInterrupt и SystemExit. Иерархия сделана так намеренно — документация: inherits from BaseException so as to not be accidentally caught by code that catches Exception (наследуется от BaseException, чтобы его случайно не поймал код, ловящий Exception). Голый except: снимает эту защиту, и программа перестаёт закрываться по Ctrl+C.

Утверждение

raise ... from None стирает предыдущее исключение

На самом деле

Не стирает. Проверено: после from None у объекта __context__ по-прежнему ZeroDivisionError, меняется только __suppress_context__ — то есть трассировка перестаёт его печатать. Предыдущее исключение остаётся достижимым и его можно записать в лог на верхнем уровне.

Утверждение

переменную из except ... as e можно использовать после блока

На самом деле

Нельзя: она удаляется. Справочник даёт точную расшифровку — тело except оборачивается в try: … finally: del N. Обращение после блока даёт NameError: name 'e' is not defined. Чтобы сохранить объект, присвойте его другому имени внутри блока.

Утверждение

ExceptionGroup из одного исключения ведёт себя как это исключение

На самом деле

Нет. ExceptionGroup("одна", [ValueError()]) обычным except ValueError: не ловится — проверено запуском, срабатывает except ExceptionGroup. Группа остаётся группой независимо от размера, и разбирать её нужно через except*.

Практика

Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.

Практика · что напечатает

Две функции отличаются одной строкой в finally. Обе бросают одно и то же исключение. Что напечатает этот код?
def swallow():
  try:
      raise ValueError("boom")
  finally:
      return "ok"


def keep():
  try:
      raise ValueError("boom")
  finally:
      pass


print(swallow())
try:
  keep()
except ValueError as e:
  print("ValueError:", e)

Практика · оцените

Один и тот же try/except: в первом случае исключения нет, во втором оно брошено и поймано. Во сколько раз дороже второй случай?
раза

Проверка знаний

Вопрос 1 из 4

Что вернёт функция, у которой в try стоит raise RuntimeError, а в finally — return None?

Источники и что читать дальше

6 ИСТОЧНИКОВ

  1. Справочник языка — инструкция tryОфициальная документация. Два нормативных предложения, на которых держится половина урока. Про finally: «If the finally clause executes a return, break or continue statement, the saved exception is discarded» (Если предложение finally выполняет return, break или continue, сохранённое исключение отбрасывается). Про else: «The optional else clause is executed if the control flow leaves the try suite, no exception was raised… Exceptions in the else clause are not handled by the preceding except clauses» (Необязательное предложение else выполняется, если управление вышло из блока try и исключение не возникло… Исключения в предложении else не обрабатываются предшествующими предложениями except). Там же — что имя из `except … as N` удаляется в конце блока, с явной расшифровкой через try/finally: del N.https://docs.python.org/3.14/reference/compound_stmts.html#the-try-statement
  2. PEP 765 — Disallow return/break/continue that exit a finally blockPEP. Ирит Катриэль и Алисса Кофлан, статус Final, Python 3.14. Компилятор с 3.14 предупреждает — но именно предупреждает: «we leave it open whether, and when, this will become a SyntaxError» (мы оставляем открытым вопрос, станет ли это когда-нибудь SyntaxError и когда именно). То есть код продолжает работать по-старому, просто теперь об этом сообщают.https://peps.python.org/pep-0765/
  3. PEP 654 — Exception Groups and except*PEP. Ирит Катриэль, Юрий Селиванов, Гвидо ван Россум; Final, Python 3.11. Отсюда два из трёх свойств раздела про группы: «a single exception group can cause several except* clauses to execute, but each such clause executes at most once» (одна группа исключений может привести к выполнению нескольких предложений except*, но каждое из них выполняется не более одного раза) и «the remaining part of the group is propagated on» (оставшаяся часть группы передаётся дальше наверх). Третье — что группа из одного исключения не ловится except-ом по типу листа (except ValueError) — проверено запуском (bench/exceptions/groups.py); нормативной формулировки в PEP для него нет.https://peps.python.org/pep-0654/
  4. PEP 3134 — Exception Chaining and Embedded TracebacksPEP. Ка-Пинг Йи, Final, Python 3.0. Документ, которым введены `__context__` и `__cause__`: первый ставится интерпретатором сам, второй — записью `raise EXCEPTION from CAUSE`, равносильной `exc.__cause__ = CAUSE`.https://peps.python.org/pep-3134/
  5. dis — SETUP_FINALLY среди псевдоинструкцийОфициальная документация. Инструкция описана в разделе «Pseudo-instructions», про который сказано прямо: «They do not appear in Python bytecode. They are used by the compiler but are replaced by real opcodes or removed before bytecode is generated» (Их нет в байт-коде Python. Компилятор ими пользуется, но до создания байт-кода заменяет их настоящими инструкциями или убирает). Это и объясняет замер: на счастливом пути `try` не выполняется ничего, кроме NOP.https://docs.python.org/3.14/library/dis.html
  6. Встроенные исключения — Exception против BaseExceptionОфициальная документация. «All built-in, non-system-exiting exceptions are derived from this class [Exception]» (Все встроенные исключения, не завершающие работу системы, происходят от этого класса [Exception]). И про KeyboardInterrupt дословно: «The exception inherits from BaseException so as to not be accidentally caught by code that catches Exception and thus prevent the interpreter from exiting» (Исключение наследуется от BaseException, чтобы его случайно не поймал код, ловящий Exception, и тем самым не помешал интерпретатору завершиться).https://docs.python.org/3.14/library/exceptions.html