Deep Engineering

ЗАМЕР

bench/introspection/code_object_limits.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/python/decorators
Как запустить
Ни в одном из трёх нет измерения времени: смотрим на значения, типы и признаки.
Адреса объектов в выводе заменены на `0x...`, чтобы прогон был сравним между
запусками.

## `inspect.unwrap` и `__signature__`

Вывод **одинаков на всех четырёх версиях** (различается только строка `PY`):

Запись прогона

Замеры: интроспекция обёрток и замыканий

Три сюжета, которых нет в bench/decorators/ и bench/closures/: снятие обёртки и подмена видимой сигнатуры, поломка признака «корутинная функция» синхронной обёрткой, и разница между ячейкой замыкания и снимком значения.

скрипт что показывает статьи
unwrap_and_signature.py inspect.unwrap проходит цепочку __wrapped__ целиком; __signature__ перебивает и цепочку, и follow_wrapped=False декораторы
async_decorator.py синхронная обёртка над async def даёт iscoroutinefunction() == False; три способа починки декораторы
closure_vars.py inspect.getclosurevars; ячейка читается поздно, __defaults__ и partial — рано замыкания

Запуск:

for v in 3.11 3.12 3.13 3.14; do
  echo "== $v"; python$v bench/introspection/async_decorator.py
done

Ни в одном из трёх нет измерения времени: смотрим на значения, типы и признаки. Адреса объектов в выводе заменены на 0x..., чтобы прогон был сравним между запусками.

inspect.unwrap и __signature__

Вывод одинаков на всех четырёх версиях (различается только строка PY):

$ python3.13 unwrap_and_signature.py
PY 3.13.7

1) цепочка __wrapped__ сверху вниз: ['c', 'b', 'a', 'ОРИГИНАЛ']
   три обёртки над одной функцией — и все зовутся 'target': ['target', 'target', 'target']

2) inspect.unwrap(three) is target: True
   а three is target: False
   один __wrapped__ снимает только слой: False | его layer: b

3) unwrap(..., stop=layer=='a') остановился на слое: a

4) цикл в __wrapped__ -> ValueError: wrapper loop when unwrapping <function loop_a at 0x...>

5) видимые сигнатуры одной и той же обёртки:
   signature(three)                       (x: int, y: str = 's') -> bool
   signature(three, follow_wrapped=False) (*a, **kw) -> bool
   реальные параметры обёртки             ('a', 'kw')

6) обёртка с __signature__:
   signature(lying)                       (count: int, *, label: str = '?') -> None
   signature(lying, follow_wrapped=False) (count: int, *, label: str = '?') -> None
   __wrapped__ по-прежнему указывает на target: True
   но unwrap всё равно доходит до target: True

7) __signature__ стоит на ВНУТРЕННЕЙ обёртке:
   signature(outer_over_inner) (count: int, *, label: str = '?') -> None
   (не (x: int, y: str = 's') -> bool — раскрутка встала на слое с __signature__)

8) что увидит генератор документации:
   str(inspect.signature(lying)) -> "(count: int, *, label: str = '?') -> None"
   вызов lying(1) при этом уйдёт в target и сработает: True
   bind(count=1, label='x') по ПОДДЕЛЬНОЙ сигнатуре прошёл
   а реальный вызов lying(count=1, label='x') -> TypeError: target() got an unexpected keyword argument 'count'

Три строки, ради которых написан файл.

Пункт 6: follow_wrapped=False — не способ добраться до правды. Он отключает проход по __wrapped__, но __signature__ стоит на самой обёртке, и поэтому побеждает в обоих случаях. Единственный, кто по-прежнему честен, — __code__ (пункт 5, ('a', 'kw')).

Пункт 7: __signature__ на любом слое цепочки останавливает раскрутку внутри signature, но не внутри unwrap. Это два разных обхода одной цепочки.

Пункт 8: расхождение между заявленной и настоящей сигнатурой не ловится ничем. Signature.bind проверяет по подделке и пропускает вызов, который упадёт.

Первоисточники, Doc/library/inspect.rst (тег v3.14.5):

  • строки 1289–1303, unwrap:

    Get the object wrapped by func. It follows the chain of __wrapped__ attributes returning the last object in the chain. […] For example, signature uses this to stop unwrapping if any object in the chain has a __signature__ attribute defined. ValueError is raised if a cycle is encountered.

    Перевод: «Получить объект, обёрнутый func. Функция идёт по цепочке атрибутов __wrapped__ и возвращает последний объект цепочки. […] Например, signature использует это, чтобы остановить раскрутку, если у какого-нибудь объекта в цепочке определён атрибут __signature__. ValueError возбуждается при обнаружении цикла.»

  • строки 803–808, impl-detail внутри описания signature:

    If the passed object has a __signature__ attribute, we may use it to create the signature. The exact semantics are an implementation detail and are subject to unannounced changes. Consult the source code for current semantics.

    Перевод: «Если у переданного объекта есть атрибут __signature__, мы можем использовать его для построения сигнатуры. Точная семантика — деталь реализации и может измениться без объявления. За текущей семантикой обращайтесь к исходному коду.»

    Формулировка проверена на всех четырёх версиях документации (теги v3.11.15, v3.12.3, v3.13.7, v3.14.5) — она дословно одна и та же. То есть «деталь реализации» здесь не означает «менялось»: не менялось.

Асинхронный декоратор

3.11.15 3.12.3 3.13.7 3.14.7
iscoroutinefunction(sync_wrapper) False False False False
inspect.markcoroutinefunction существует нет есть есть есть
asyncio.iscoroutinefunction даёт DeprecationWarning нет нет нет да
$ python3.11 async_decorator.py
PY 3.11.15

1) что скопировал wraps и что не скопировал:
   __name__         : job
   __doc__          : 'Настоящая корутинная функция.'
   __wrapped__ is job: True
   iscoroutinefunction(job)   : True
   iscoroutinefunction(broken): False

2) признак — флаг кода, а не атрибут:
   job.__code__.co_flags & CO_COROUTINE   : True
   broken.__code__.co_flags & CO_COROUTINE: False
   asyncio.iscoroutinefunction(broken): False

3) диспетчер, который смотрит на iscoroutinefunction:
   dispatch(job, 21)   -> 42
   dispatch(broken, 21)-> coroutine <coroutine object job at 0x...>
   вместо числа наружу уехал объект корутины: он не выполнен,
   и при сборке мусора даст RuntimeWarning 'was never awaited'

4) починка A — обёртка сама async def:
   iscoroutinefunction(fixed_a): True
   результат: 42

5) починка B — inspect.markcoroutinefunction:
   доступна: False — на этой версии markcoroutinefunction нет
   единственный переносимый способ — сделать обёртку async def

6) декоратор, выбирающий форму обёртки по цели:
   iscoroutinefunction(smart_deco(job))  : True
   iscoroutinefunction(smart_deco(plain)): False
   smart_deco(plain)(1) = 2 | asyncio.run(smart_deco(job)(21)) = 42

7) iscoroutinefunction по __wrapped__ НЕ идёт:
   iscoroutinefunction(broken)                : False
   iscoroutinefunction(inspect.unwrap(broken)): True
   то есть проверять надо оригинал, а чинить — обёртку

На 3.12.3 и 3.13.7 пункт 5 выглядит так:

5) починка B — inspect.markcoroutinefunction:
   доступна: True
   iscoroutinefunction(marked): True
   а флаг кода по-прежнему НЕ корутинный: False
   маркер — обычный атрибут: True
   asyncio.iscoroutinefunction(marked): True

На 3.14.7 к обеим строкам с asyncio.iscoroutinefunction добавляется:

   [DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead]

Что тут важно. Пункт 2: признак — это флаг объекта кода CO_COROUTINE (0x0080), а не атрибут функции; functools.wraps копирует атрибуты и до флага не дотягивается по определению. Пункт 5: markcoroutinefunction чинит ответ iscoroutinefunction, не трогая флаг, — он ставит обычный атрибут _is_coroutine_marker. Пункт 7: iscoroutinefunction не идёт по __wrapped__, поэтому подсказка «оберни в wraps и всё наладится» не работает.

Первоисточник — Doc/library/inspect.rst, строки 438–472 (тег v3.14.5):

Return True if the object is a coroutine function (a function defined with an async def syntax), a functools.partial wrapping a coroutine function, or a sync function marked with markcoroutinefunction. […] .. versionchanged:: 3.12 — Sync functions marked with markcoroutinefunction now return True.

Перевод: «Возвращает True, если объект — корутинная функция (функция, определённая синтаксисом async def), functools.partial вокруг корутинной функции или синхронная функция, помеченная markcoroutinefunction. […] Изменено в версии 3.12: синхронные функции, помеченные markcoroutinefunction, теперь дают True

И там же, строки 460–473:

markcoroutinefunction(func) — Decorator to mark a callable as a coroutine function if it would not otherwise be detected by iscoroutinefunction. […] When possible, using an async def function is preferred. .. versionadded:: 3.12

Перевод: «Декоратор, помечающий вызываемый объект как корутинную функцию, если иначе iscoroutinefunction её не распознаёт. […] Когда возможно, предпочтительна функция async def. Добавлено в версии 3.12.»

Ячейка против снимка

Вывод closure_vars.py одинаков на всех четырёх версиях:

$ python3.13 closure_vars.py
PY 3.13.7

1) getclosurevars(by_closure):
   nonlocals: {'n': 2}
   globals  : {'GLOBAL_RATE': 10}
   builtins : {'len': <built-in function len>}
   unbound  : set()

2) getclosurevars(by_default) — n НЕ в nonlocals, он параметр:
   nonlocals: {}
   globals  : {'GLOBAL_RATE': 10}
   а значение снимка лежит здесь: (2,)

3) getclosurevars(partial) -> TypeError: functools.partial(<function make_all_three.<locals>.body at 0x...>, 2) is not a Python function
   значение снимка у partial: (2,) | func: body
   getclosurevars(partial.func): {}

4) unbound у функции с несуществующим именем: {'NOT_DEFINED_ANYWHERE'}

5) до переприсваивания: первое | первое | первое
   после rebind('второе'): второе | первое | первое
   замыкание видит новое значение, снимки — старое

6) хранилища:
   closure: __closure__ = (<cell at 0x...: str object at 0x...>,) -> cell_contents = ['второе']
   default: __defaults__ = ('первое',) | __closure__ = None
   partial: .args = ('первое',) | .keywords = {}

7) запись в cell_contents снаружи: третье
   запись в __defaults__ снаружи: четвёртое
   partial неизменяем — нужен новый объект: пятое

8) три способа в цикле:
   замыкание по i     : [2, 2, 2]
   аргумент по умолч. : [0, 1, 2]
   functools.partial  : [0, 1, 2]

Пункты 1–3 — про интроспекцию: getclosurevars показывает только ячейки, глобальные и встроенные имена. Снимок в аргументе по умолчанию для него не существует (пункт 2: nonlocals пуст), а functools.partial он вообще отказывается принимать — TypeError, потому что это не питоновская функция.

Пункты 5–8 — про поведение: три способа «запомнить значение» дают одинаковый результат в цикле (пункт 8) и разный при переприсваивании (пункт 5). Ровно та же граница видна и в хранилищах (пункт 6): __closure__ — кортеж ячеек, __defaults__ — кортеж значений, partial.args — кортеж значений.

Первоисточник — Doc/library/inspect.rst, строки 1273–1287 (тег v3.14.5):

Get the mapping of external name references in a Python function or method func to their current values. A named tuple ClosureVars(nonlocals, globals, builtins, unbound) is returned. nonlocals maps referenced names to lexical closure variables, globals to the function's module globals and builtins to the builtins visible from the function body. unbound is the set of names referenced in the function that could not be resolved at all given the current module globals and builtins. TypeError is raised if func is not a Python function or method.

Перевод: «Получить отображение внешних имён, на которые ссылается питоновская функция или метод func, в их текущие значения. Возвращается именованный кортеж ClosureVars(nonlocals, globals, builtins, unbound). nonlocals сопоставляет упомянутые имена переменным лексического замыкания, globals — глобальным именам модуля функции, builtins — встроенным именам, видимым из тела функции. unbound — множество имён, упомянутых в функции, которые вообще не удалось разрешить при текущих глобальных и встроенных именах модуля. TypeError возбуждается, если func не является питоновской функцией или методом.»

Слово «current values» в первом предложении — не оборот речи: пункт 7 меняет cell_contents снаружи, и следующий вызов getclosurevars показал бы уже новое значение. Снимок так не умеет.

Оговорка про версию

3.14 здесь — это 3.14.7. Единственное, что взято именно с неё, — текст DeprecationWarning у asyncio.iscoroutinefunction. Все остальные строки на 3.14 совпадают с 3.13.7.

Тег исходников CPython для цитат из Doc/v3.14.5; формулировка про __signature__ дополнительно сверена с тегами v3.11.15, v3.12.3 и v3.13.7.

Скрипт

128 строк
"""Границы `__code__`: где он говорит правду, а где его просто нет.

ЗАЧЕМ ЭТОТ ФАЙЛ. Урок про декораторы заканчивал модель фразой «`__wrapped__` и
`__signature__` — для людей и документации, `__code__` — для правды». Фраза
эффектная, и в своём случае верная: у обёртки, объявленной как `(*args,
**kwargs)`, именно `__code__` показывает, что обёртка принимает на самом деле,
пока `signature` показывает цель.

Но как общее правило она не работает: `__code__` есть только у функции,
написанной на Python. У встроенной функции, у объекта с `__call__`, у
`functools.partial` его нет вовсе — а вызов у них есть. И даже там, где он
есть, он описывает ПАРАМЕТРЫ ТЕЛА, а не то, как вызов дойдёт до этого тела:
связанный метод прячет первый параметр, а обёртка, перекладывающая аргументы,
описывает себя, а не то, что получит цель.

ЗАПУСК: python3.13 bench/introspection/code_object_limits.py
Вывод по версиям: runs/code_object_limits-3.11.txt и соседние.
"""

import functools
import inspect
import sys


def show(title: str) -> None:
    print()
    print(title)
    print("-" * len(title))


def row(label: str, value: object) -> None:
    print(f"  {label:<44} {value}")


def code_of(obj: object) -> str:
    """Печатает параметры из `__code__` или ТИП ошибки, если его нет."""
    try:
        code = obj.__code__  # type: ignore[attr-defined]
    except AttributeError as exc:
        return f"{type(exc).__name__}"
    # co_argcount не считает `*args` и `**kwargs`, и на обёртке урока срез по
    # нему даёт пустой кортеж — то есть ровно ту картину, ради которой фраза
    # «__code__ — для правды» и была написана, но без самой правды. Имена
    # звёздочек лежат в co_varnames сразу за обычными параметрами, а их наличие
    # отмечено флагами.
    count = code.co_argcount + code.co_kwonlyargcount
    names = list(code.co_varnames[:count])
    if code.co_flags & inspect.CO_VARARGS:
        names.append("*" + code.co_varnames[count])
        count += 1
    if code.co_flags & inspect.CO_VARKEYWORDS:
        names.append("**" + code.co_varnames[count])
    return f"({', '.join(names)})"


def signature_of(obj: object) -> str:
    try:
        return str(inspect.signature(obj))
    except (TypeError, ValueError) as exc:
        return f"{type(exc).__name__}"


def target(a: int, b: str = "x", *, flag: bool = False) -> str:
    """Обычная функция на Python: у неё есть и то и другое."""
    return f"{a}{b}{flag}"


class Callable:
    def __call__(self, a: int, b: int) -> int:
        return a + b


class WithMethod:
    def method(self, a: int, b: int) -> int:
        return a + b


def transforming_wrapper(fn):
    """Обёртка, которая МЕНЯЕТ аргументы: её собственные параметры о вызове цели не говорят."""

    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs, flag=True)

    return wrapper


def main() -> None:
    print(f"Python {sys.version.split()[0]} ({sys.implementation.name})")

    show("1. Обычная функция: оба ответа есть и совпадают по смыслу")
    row("__code__", code_of(target))
    row("signature", signature_of(target))

    show("2. Вызываемые объекты без __code__ вовсе")
    obj = Callable()
    row("len (встроенная)", code_of(len))
    row("len: signature", signature_of(len))
    row("объект с __call__", code_of(obj))
    row("объект с __call__: signature", signature_of(obj))
    row("но у самого __call__ он есть", code_of(type(obj).__call__))
    row("partial(target, 1)", code_of(functools.partial(target, 1)))
    row("partial: signature", signature_of(functools.partial(target, 1)))
    row("dict.get (метод C-типа)", code_of(dict.get))
    row("dict.get: signature", signature_of(dict.get))

    show("3. Связанный метод: __code__ показывает self, вызов — нет")
    bound = WithMethod().method
    row("__code__ связанного метода", code_of(bound))
    row("signature связанного метода", signature_of(bound))
    row("__code__ функции из класса", code_of(WithMethod.method))

    show("4. Случай урока: обёртка (*args, **kwargs)")
    wrapped = transforming_wrapper(target)
    row("__code__ обёртки", code_of(wrapped))
    row("signature обёртки (через __wrapped__)", signature_of(wrapped))
    row("__code__ цели", code_of(target))
    row("что обёртка добавляет к вызову", "flag=True — этого не видно ни там, ни там")

    show("5. Вывод, который можно писать в урок")
    row("для функции на Python", "__code__ — параметры её собственного тела")
    row("для всего остального", "__code__ может отсутствовать вовсе")
    row("ни один из двух", "не описывает, что обёртка делает с аргументами")


if __name__ == "__main__":
    main()