Deep Engineering

ЗАМЕР

bench/typing/07_args_kwargs_pair.py

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

Цитируется в статье
/ru/python/typing/decorator-typing
Как запустить
mypy 2.3.1 (compiled: yes)   под CPython 3.13.7
pyright 1.1.413              под CPython 3.13.7

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

Типизация декораторов: воспроизводимые примеры

Здесь единственный случай в 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 в таблице взяты поведенческие факты — есть ли синтаксис, есть ли модуль, падает ли определение, — и от кандидата к финалу они меняться не должны. Но «не должны» — не «проверено», поэтому таблица переснята целиком, а не переподписана; там, где эти строки попадают в статью, версия называется полностью.

Скрипт

30 строк
"""P.args и P.kwargs существуют только парой.

PEP 612 объясняет почему: `ParamSpec` захватывает и позиционные параметры, и
именованные, а одного объекта на оба в рантайме нет. Два корректных вызова
одной и той же функции могут разложить один и тот же набор параметров
по-разному — что-то в `*args`, что-то в `**kwargs`. Поэтому запись верна только
тогда, когда обе половины стоят рядом.

Здесь это правило проверено, а не пересказано: оба проверяющих отказываются
принимать половину пары. Ожидаемый вывод — в bench/typing/README.md.
"""

from typing import Callable


# --- Правильно: обе половины на месте.
def whole[**P, R](f: Callable[P, R]) -> Callable[P, R]:
    def inner(*args: P.args, **kwargs: P.kwargs) -> R:
        return f(*args, **kwargs)

    return inner


# --- Неправильно: только позиционная половина.
def half[**P, R](f: Callable[P, R]) -> Callable[P, R]:
    def inner(*args: P.args) -> R:  # ошибка: половина пары
        return f(*args)

    return inner