Deep Engineering

MEASUREMENT

bench/typing/03_concatenate_and_protocol.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/python/typing/decorator-typing
How to run it
mypy 2.3.1 (compiled: yes)   под CPython 3.13.7
pyright 1.1.413              под CPython 3.13.7

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/, где доказательство — не время и не байты, а вывод проверяющего типов. Поэтому файлы лежат целиком, а не кусками: чтобы читатель статьи получил ровно те же строки ошибок, что и мы.

Чем проверено

mypy 2.3.1 (compiled: yes)   под CPython 3.13.7
pyright 1.1.413              под CPython 3.13.7

Установка и запуск:

python3.13 -m pip install mypy
npm install -g pyright

python3.13 -m mypy --no-error-summary --no-color-output bench/typing/01_naive.py
pyright --pythonpath "$(which python3.13)" bench/typing/01_naive.py

Проверяющих здесь два, и это не для красоты. Первичен документ typing.python.org/en/latest/spec/; вывод любого инструмента — наблюдение, а не источник. Один инструмент оставлял бы открытым вопрос, не смотрим ли мы на его особенность вместо особенности языка. Два независимых снимают этот вопрос там, где сходятся, — а сошлись они на всех семи файлах: те же строки, те же количества, те же выводы.

Расхождение было ровно одно, и оно оказалось нашей дырой, а не разногласием инструментов. В 04 и 05 реализация под @overload сначала стояла без аннотаций (def trace(f=None, *, level=1):). mypy на такую реализацию не смотрит вовсе; pyright выдавал по две ошибки Overloaded implementation is not consistent with signature of overload. Аннотация реализации убрала обе, ничего не изменив в выводах примера. Это стоит помнить и за пределами этих файлов: неаннотированная реализация перегрузки для mypy невидима.

Что должно получиться

Файл Что показывает Ошибок
01_naive.py Callable[..., R] — это не «любые аргументы», а «не проверять» 0
02_paramspec.py тот же код через ParamSpec 3
03_concatenate_and_protocol.py Concatenate добавляет аргумент; Protocol сохраняет атрибут 4
04_factory_broken.py @deco(...) молча теряет сигнатуру 1 + 2 reveal_type
05_factory_fixed.py починка через Protocol с обобщённым __call__ 1 + 2 reveal_type
06_methods.py Concatenate[S, P] сохраняет получателя, Self выживает 1 + 2 reveal_type
07_args_kwargs_pair.py P.args и P.kwargs существуют только парой 3

Дословно:

$ mypy 01_naive.py
  -> exit=0

$ mypy 02_paramspec.py
02_paramspec.py:22: error: Argument 1 to "takes_int_str" has incompatible type "str"; expected "int"  [arg-type]
02_paramspec.py:22: error: Argument 2 to "takes_int_str" has incompatible type "int"; expected "str"  [arg-type]
02_paramspec.py:23: error: Missing positional arguments "x", "y" in call to "takes_int_str"  [call-arg]

$ mypy 03_concatenate_and_protocol.py
03_concatenate_and_protocol.py:23: error: Argument 1 to "query" has incompatible type "int"; expected "str"  [arg-type]
03_concatenate_and_protocol.py:23: error: Argument 2 to "query" has incompatible type "str"; expected "int"  [arg-type]
03_concatenate_and_protocol.py:24: error: Too many arguments for "query"  [call-arg]
03_concatenate_and_protocol.py:46: error: Argument 1 to "__call__" of "HasCache" has incompatible type "str"; expected "int"  [arg-type]

$ mypy 04_factory_broken.py
04_factory_broken.py:30: note: Revealed type is "def (x: int) -> str"
04_factory_broken.py:31: note: Revealed type is "def (*Any, **Any) -> object"
04_factory_broken.py:32: error: Argument 1 to "a" has incompatible type "str"; expected "int"  [arg-type]

$ mypy 05_factory_fixed.py
05_factory_fixed.py:34: note: Revealed type is "def (x: int) -> str"
05_factory_fixed.py:35: note: Revealed type is "def (x: int) -> str"
05_factory_fixed.py:36: error: Argument 1 to "b" has incompatible type "str"; expected "int"  [arg-type]

$ mypy 06_methods.py
06_methods.py:24: note: Revealed type is "def (x: int) -> str"
06_methods.py:25: note: Revealed type is "06_methods.A"
06_methods.py:26: error: Argument 1 to "m" of "A" has incompatible type "str"; expected "int"  [arg-type]

$ mypy 07_args_kwargs_pair.py
07_args_kwargs_pair.py:26: error: ParamSpec must have "*args" typed as "P.args" and "**kwargs" typed as "P.kwargs"  [valid-type]
07_args_kwargs_pair.py:27: error: Too few arguments  [call-arg]
07_args_kwargs_pair.py:29: error: Incompatible return value type (got "def inner(*args: Any) -> R", expected "Callable[P, R]")  [return-value]

Формулировка второго проверяющего на том же файле стоит того, чтобы её привести: он отказывается теми же тремя строками, но называет правило прямее.

$ pyright 07_args_kwargs_pair.py
26: "args" and "kwargs" attributes of ParamSpec must both appear within a function signature
27: Arguments for ParamSpec "P@half" are missing
29: Type "(*args: P@half.args) -> R@half" is not assignable to return type "(**P@half) -> R@half"

Две строки, ради которых всё написано:

  • в 01_naive.py ноль ошибок на вызовах takes_int_str("B", 2) и takes_int_str();
  • в 04_factory_broken.py строка 33 — b("bad")не подчёркнута вовсе, хотя a("bad") строкой выше подчёркнута. Разница только в том, что b задекорирован вызванной формой @trace(level=2).

Таблица версий

version_matrix.py запускается одним и тем же файлом на четырёх интерпретаторах. 3.11 добавлена не для полноты: только на ней видно, что в C ParamSpec не был всегда — он туда переехал.

python3.11 bench/typing/version_matrix.py
python3.12 bench/typing/version_matrix.py
python3.13 bench/typing/version_matrix.py
python3.14 bench/typing/version_matrix.py
3.11.15 3.12.3 3.13.7 3.14.7
def f[**P, R] (PEP 695) SyntaxError ok ok ok
typing.ParamSpec is _typing.ParamSpec False True True True
_typing встроен в интерпретатор False True True True
у _typing есть __file__ True False False False
у _typing есть ParamSpec False True True True
TypeVar(..., default=int) (PEP 696) TypeError TypeError ok ok
def f[T = int] (PEP 696) SyntaxError SyntaxError ok ok
import annotationlib (PEP 649) absent absent absent ok
имя из будущего в аннотации def NameError NameError NameError ok

Три средние строки читаются вместе. На 3.11 _typing — обычный модуль с файлом, и ParamSpec в нём отсутствует: он живёт в typing.py. С 3.12 _typing встроен в интерпретатор, файла у него нет, и typing.ParamSpec — это буквально он же. Вот что означает «ParamSpec переехал в C».

Три строки — про рантайм, и на всех версиях они одинаковы:

signature()              (x: int, y: str) -> int
signature(follow=False)  (*args, **kwargs) -> int
real co_varnames         ('args', 'kwargs')

Настоящая функция принимает *args, **kwargs; inspect.signature показывает исходную сигнатуру, потому что идёт по __wrapped__, который поставил functools.wraps (Lib/functools.py:62 на v3.13.7, Lib/inspect.py:3373follow_wrapped=True). Рантайм честен, проверяющий слеп — и обычно ждут ровно наоборот.

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

3.14 здесь — это 3.14.7, финальный релиз. До перемера в таблице стоял кандидат в релизы 3.14.0rc2, и здесь была оговорка, что финальной сборки на машине замеров нет; теперь она есть, и таблица переснята на ней.

С 3.14 в таблице взяты поведенческие факты — есть ли синтаксис, есть ли модуль, падает ли определение, — и от кандидата к финалу они меняться не должны. Но «не должны» — не «проверено», поэтому таблица переснята целиком, а не переподписана; там, где эти строки попадают в статью, версия называется полностью.

Script

47 lines
"""Concatenate и Protocol: декоратор меняет сигнатуру, а типы сохраняются.

Ожидаемый вывод mypy — в bench/typing/README.md.
"""

from typing import Callable, Concatenate, Protocol


# --- A. Декоратор ДОБАВЛЯЕТ первый позиционный аргумент и прячет его снаружи.
def needs_conn[**P, R](f: Callable[Concatenate[str, P], R]) -> Callable[P, R]:
    def inner(*args: P.args, **kwargs: P.kwargs) -> R:
        return f("conn", *args, **kwargs)

    return inner


@needs_conn
def query(conn: str, sql: str, limit: int) -> int:
    return limit


query("select", 10)  # ok: conn внедрён декоратором
query(10, "select")  # ошибка: порядок типов
query("select", 10, 1)  # ошибка: лишний аргумент


# --- B. Декоратор вешает атрибут. Callable[P, R] его теряет, Protocol — нет.
class HasCache[**P, R](Protocol):
    __wrapped__: Callable[P, R]

    def cache_clear(self) -> None: ...
    def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ...


def memo[**P, R](f: Callable[P, R]) -> HasCache[P, R]:
    raise NotImplementedError


@memo
def h(a: int) -> str:
    return str(a)


h(1)  # ok
h.cache_clear()  # ok — атрибут виден проверяющему
h("x")  # ошибка: arg-type в __call__ протокола