Deep Engineering

MEASUREMENT

bench/introspection/async_decorator.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/decorators
How to run it
Ни в одном из трёх нет измерения времени: смотрим на значения, типы и признаки.
Адреса объектов в выводе заменены на `0x...`, чтобы прогон был сравним между
запусками.

## `inspect.unwrap` и `__signature__`

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

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

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

Три сюжета, которых нет в 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.

Script

151 lines
"""Синхронная обёртка вокруг async def: что именно ломается и чем чинится.

Один сюжет: `functools.wraps` копирует имя и `__doc__`, но НЕ делает обёртку
корутинной функцией. `inspect.iscoroutinefunction` смотрит на флаг кода самой
обёртки — и отвечает False. Всё, что построено на этой проверке (веб-фреймворки,
пулы задач, тестовые раннеры), после такого декоратора уходит по синхронной
ветке.

Три починки сравниваются по одному и тому же признаку: async-обёртка,
`inspect.markcoroutinefunction` (3.12+) и ручная правка флага.

Запускать: python3.11 / 3.12 / 3.13 / 3.14 — строка про markcoroutinefunction
различается.
"""
import asyncio
import functools
import inspect
import re
import sys
import warnings

print("PY", sys.version.split()[0])
print()


def asyncio_iscoro(fn):
    """asyncio.iscoroutinefunction + текст предупреждения, если оно есть."""
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        value = asyncio.iscoroutinefunction(fn)
    note = f" [{caught[0].category.__name__}: {caught[0].message}]" if caught else ""
    return f"{value}{note}"


async def job(n: int) -> int:
    """Настоящая корутинная функция."""
    await asyncio.sleep(0)
    return n * 2


# --- 1. Наивный синхронный декоратор ---------------------------------------
def sync_deco(fn):
    @functools.wraps(fn)
    def wrapper(*a, **kw):
        return fn(*a, **kw)      # возвращает КОРУТИНУ, не результат
    return wrapper


broken = sync_deco(job)

print("1) что скопировал wraps и что не скопировал:")
print("   __name__         :", broken.__name__)
print("   __doc__          :", repr(broken.__doc__))
print("   __wrapped__ is job:", broken.__wrapped__ is job)
print("   iscoroutinefunction(job)   :", inspect.iscoroutinefunction(job))
print("   iscoroutinefunction(broken):", inspect.iscoroutinefunction(broken))
print()

# --- 2. Где именно живёт признак -------------------------------------------
CO_COROUTINE = 0x0080
print("2) признак — флаг кода, а не атрибут:")
print("   job.__code__.co_flags & CO_COROUTINE   :",
      bool(job.__code__.co_flags & CO_COROUTINE))
print("   broken.__code__.co_flags & CO_COROUTINE:",
      bool(broken.__code__.co_flags & CO_COROUTINE))
print("   asyncio.iscoroutinefunction(broken):", asyncio_iscoro(broken))
print()

# --- 3. Что ломается на практике -------------------------------------------
# Типовая диспетчеризация: «если корутинная — await, иначе вызвать».
async def dispatch(fn, *a):
    if inspect.iscoroutinefunction(fn):
        return await fn(*a)
    return fn(*a)


async def show3():
    r_ok = await dispatch(job, 21)
    r_bad = await dispatch(broken, 21)
    print("3) диспетчер, который смотрит на iscoroutinefunction:")
    print("   dispatch(job, 21)   ->", repr(r_ok))
    print("   dispatch(broken, 21)->", type(r_bad).__name__,
          re.sub(r"0x[0-9a-f]+", "0x...", repr(r_bad)))
    if asyncio.iscoroutine(r_bad):
        r_bad.close()   # иначе «coroutine was never awaited»
    print("   вместо числа наружу уехал объект корутины: он не выполнен,")
    print("   и при сборке мусора даст RuntimeWarning 'was never awaited'")


asyncio.run(show3())
print()

# --- 4. Починка A: асинхронная обёртка --------------------------------------
def async_deco(fn):
    @functools.wraps(fn)
    async def wrapper(*a, **kw):
        return await fn(*a, **kw)
    return wrapper


fixed_a = async_deco(job)
print("4) починка A — обёртка сама async def:")
print("   iscoroutinefunction(fixed_a):", inspect.iscoroutinefunction(fixed_a))
print("   результат:", asyncio.run(fixed_a(21)))
print()

# --- 5. Починка B: inspect.markcoroutinefunction (3.12+) --------------------
print("5) починка B — inspect.markcoroutinefunction:")
if hasattr(inspect, "markcoroutinefunction"):
    marked = inspect.markcoroutinefunction(sync_deco(job))
    print("   доступна: True")
    print("   iscoroutinefunction(marked):", inspect.iscoroutinefunction(marked))
    print("   а флаг кода по-прежнему НЕ корутинный:",
          bool(marked.__code__.co_flags & CO_COROUTINE))
    print("   маркер — обычный атрибут:", "_is_coroutine_marker" in vars(marked))
    print("   asyncio.iscoroutinefunction(marked):", asyncio_iscoro(marked))
else:
    print("   доступна: False — на этой версии markcoroutinefunction нет")
    print("   единственный переносимый способ — сделать обёртку async def")
print()

# --- 6. Универсальный декоратор, который не ломает признак ------------------
def smart_deco(fn):
    if inspect.iscoroutinefunction(fn):
        @functools.wraps(fn)
        async def wrapper(*a, **kw):
            return await fn(*a, **kw)
    else:
        @functools.wraps(fn)
        def wrapper(*a, **kw):
            return fn(*a, **kw)
    return wrapper


def plain(n):
    return n + 1


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

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