ЗАМЕР
bench/typing/05_factory_fixed.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:3373 —
follow_wrapped=True). Рантайм честен, проверяющий слеп — и обычно ждут
ровно наоборот.
Оговорка про версию
3.14 здесь — это 3.14.7, финальный релиз. До перемера в таблице стоял
кандидат в релизы 3.14.0rc2, и здесь была оговорка, что финальной сборки на
машине замеров нет; теперь она есть, и таблица переснята на ней.
С 3.14 в таблице взяты поведенческие факты — есть ли синтаксис, есть ли модуль, падает ли определение, — и от кандидата к финалу они меняться не должны. Но «не должны» — не «проверено», поэтому таблица переснята целиком, а не переподписана; там, где эти строки попадают в статью, версия называется полностью.
Скрипт
37 строк"""Починка: фабрика возвращает Protocol с обобщённым __call__.
Тогда ParamSpec связывается заново на каждом применении, и обе формы —
@trace и @trace(level=2) — одинаково сохраняют сигнатуру.
"""
from typing import Callable, Protocol, overload
class _Deco(Protocol):
def __call__[**P, R](self, f: Callable[P, R], /) -> Callable[P, R]: ...
@overload
def trace[**P, R](f: Callable[P, R], /) -> Callable[P, R]: ...
@overload
def trace(*, level: int = ...) -> _Deco: ...
def trace(
f: Callable[..., object] | None = None, *, level: int = 1
) -> Callable[..., object]:
raise NotImplementedError
@trace
def a(x: int) -> str:
return str(x)
@trace(level=2)
def b(x: int) -> str:
return str(x)
reveal_type(a) # noqa: F821
reveal_type(b) # noqa: F821
b("bad") # теперь ловится