MEASUREMENT
bench/typing/version_matrix.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:3373 —
follow_wrapped=True). Рантайм честен, проверяющий слеп — и обычно ждут
ровно наоборот.
Оговорка про версию
3.14 здесь — это 3.14.7, финальный релиз. До перемера в таблице стоял
кандидат в релизы 3.14.0rc2, и здесь была оговорка, что финальной сборки на
машине замеров нет; теперь она есть, и таблица переснята на ней.
С 3.14 в таблице взяты поведенческие факты — есть ли синтаксис, есть ли модуль, падает ли определение, — и от кандидата к финалу они меняться не должны. Но «не должны» — не «проверено», поэтому таблица переснята целиком, а не переподписана; там, где эти строки попадают в статью, версия называется полностью.
Script
95 lines"""Что из типизации декораторов доступно на каждой версии языка.
Запускается одним и тем же файлом на 3.11 / 3.12 / 3.13 / 3.14 — разница в
выводе и есть таблица версий для статьи. 3.11 добавлена не для полноты: только
на ней видно, что `ParamSpec` не всегда был типом из встроенного `_typing`. Ничего не измеряет по времени: типизация
на скорость не влияет, и таблицы наносекунд в этой статье быть не должно.
"""
import functools
import inspect
import sys
import typing
def line(key: str, value: object) -> None:
print(f"{key:<24} {value}")
print(f"python {sys.version.split()[0]}")
# --- PEP 695: синтаксис списка параметров типа прямо в def.
ns: dict[str, object] = {}
try:
exec(compile("def deco[**P, R](f): return f\n", "<pep695>", "exec"), ns)
params = ns["deco"].__type_params__ # type: ignore[attr-defined]
line("pep695 syntax", "ok " + str(tuple(f"{type(t).__name__}:{t.__name__}" for t in params)))
except SyntaxError as exc:
line("pep695 syntax", f"SyntaxError: {exc.msg}")
# --- ParamSpec: с 3.12 это тип из C (Objects/typevarobject.c), а не класс typing.py.
try:
import _typing # noqa: E402
except ImportError: # до 3.11 модуля нет вовсе
_typing = None # type: ignore[assignment]
line("ParamSpec is _typing", hasattr(_typing, "ParamSpec") and typing.ParamSpec is _typing.ParamSpec)
# Откуда взялся `_typing`: модуль встроен в интерпретатор, файла у него нет.
# Это и означает «ParamSpec живёт в C, а не в typing.py».
line("_typing builtin", "_typing" in sys.builtin_module_names)
line("_typing has __file__", hasattr(_typing, "__file__"))
line("_typing.ParamSpec exists", hasattr(_typing, "ParamSpec"))
P = typing.ParamSpec("P")
line("P.args / P.kwargs", f"{P.args!r} / {P.kwargs!r}")
# --- PEP 696: значения по умолчанию у параметров типа.
try:
T = typing.TypeVar("T", default=int)
line("pep696 runtime", f"ok has_default={T.has_default()} default={T.__default__}")
except TypeError as exc:
line("pep696 runtime", f"TypeError: {exc}")
try:
ns2: dict[str, object] = {}
exec(compile("def f[T = int](x): return x\n", "<pep696>", "exec"), ns2)
line("pep696 syntax", f"ok default={ns2['f'].__type_params__[0].__default__}") # type: ignore[attr-defined]
except SyntaxError as exc:
line("pep696 syntax", f"SyntaxError: {exc.msg}")
# --- functools.wraps: рантайм видит исходную сигнатуру, проверяющий — нет.
def add_logging(f):
@functools.wraps(f)
def inner(*args, **kwargs):
return f(*args, **kwargs)
return inner
@add_logging
def takes_int_str(x: int, y: str) -> int:
return x + 7
line("signature()", str(inspect.signature(takes_int_str)))
line("signature(follow=False)", str(inspect.signature(takes_int_str, follow_wrapped=False)))
line("real co_varnames", takes_int_str.__code__.co_varnames)
# --- PEP 649: аннотации вычисляются лениво только с 3.14.
try:
import annotationlib
line("annotationlib", [f.name for f in annotationlib.Format])
exec(compile("def g(x: NotYet) -> NotYet: ...\n", "<pep649>", "exec"), ns3 := {})
line("forward name in def", "ok")
line(
" FORWARDREF",
annotationlib.get_annotations(ns3["g"], format=annotationlib.Format.FORWARDREF),
)
except ImportError:
line("annotationlib", "absent")
try:
exec(compile("def g(x: NotYet) -> NotYet: ...\n", "<pep649>", "exec"), {})
line("forward name in def", "ok")
except NameError as exc:
line("forward name in def", f"NameError: {exc}")