Типизация декораторов: почему проверяющий замолкает и чем это лечится
Декоратор, объявленный через Callable[..., R], пропускает оба заведомо неверных вызова — и это не поломка проверяющего типов, а ровно то, что многоточие означает. При этом в рантайме сигнатура никуда не девается: inspect показывает исходную. Рантайм честен, а проверяющий слеп — и обычно ждут наоборот.
Полное техническое изложение
TL;DR
- Декоратор, объявленный через
Callable[..., R], пропускает оба заведомо неверных вызова из трёх написанных. Проверяющие типов —mypyиpyright— не сломались: многоточие означает не «любые аргументы», а «не проверять». - Тот же код с
ParamSpecдаёт три ошибки на тех же двух строках. Меняется одна строка — объявление декоратора. - В рантайме сигнатура при этом цела:
inspect.signatureпоказывает исходную, потому чтоfunctools.wrapsоставил ссылку на обёрнутую функцию. - Фабрика декораторов — место, где ошибаются почти все:
@traceсигнатуру сохраняет,@trace(level=2)теряет её молча.
Два неверных вызова, ноль ошибок
Вот декоратор, написанный так, как его пишут чаще всего:
def add_logging(f: Callable[..., R]) -> Callable[..., R]:
def inner(*args: object, **kwargs: object) -> R:
return f(*args, **kwargs)
return inner
@add_logging
def takes_int_str(x: int, y: str) -> int:
return x + 7Под ним три вызова, из которых два заведомо неверны: takes_int_str(1, "A"),
takes_int_str("B", 2) — типы переставлены, и takes_int_str() — аргументов
нет вовсе.
Проверяющий типов — mypy — не находит ни одного. Ноль ошибок, код возврата
ноль.
Дело в многоточии
«... значит любые аргументы» — объяснение, которое звучит безобидно.
PEP 612 формулирует иначе: многоточие вместо типов параметров означает, что
проверки аргументов не выполняется вовсе.
Разница между «подойдёт любой» и «не проверяем» — это и есть разница между безобидной формулировкой и тремя пропущенными вызовами. Проверяющий сделал ровно то, что ему сказали. Сказали неудачно.
ParamSpec: та же функция, три ошибки
def add_logging[**P, R](f: Callable[P, R]) -> Callable[P, R]:
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return f(*args, **kwargs)
return innerТело декоратора не изменилось, вызовы не изменились. Изменилось объявление — и проверяющий находит все три ошибки: две по типам и одну по числу аргументов.
P.args и P.kwargs пишутся только парой. Причина не в удобстве записи: один
и тот же набор параметров разные вызовы раскладывают по-разному — f(1, y=2)
кладёт двойку в kwargs, а f(1, 2) — в args. Половина пары описывала бы не
все случаи, и оба проверяющих такую запись отвергают.
Рантайм честен, проверяющий слеп
Самое неожиданное здесь — что в рантайме сигнатура никуда не делась:
inspect.signature(takes_int_str) -> (x: int, y: str) -> int
inspect.signature(takes_int_str, follow_wrapped=False) -> (*args, **kwargs) -> int
Вторая строка — правда: вызовут функцию, принимающую *args, **kwargs. Первая
— то, что показывает inspect по умолчанию, потому что functools.wraps
оставил на обёртке ссылку на обёрнутое, а signature по этой ссылке прыгает.
Отсюда и уверенность, что «wraps всё чинит». Он чинит help() и
интроспекцию. Проверяющему он не говорит ничего.
Где ошибаются почти все
Декоратор, умеющий обе формы — @trace и @trace(level=2), — пишут парой
перегрузок, и выглядит она безупречно. Измерено:
| как задекорировано | что видит проверяющий |
|---|---|
@trace | def (x: int) -> str |
@trace(level=2) | def (*Any, **Any) -> object |
Вторая строка — потеря, и потеря молчаливая: неверный вызов такой функции просто не подчёркивается.
Причина в том, что фабрика обязана вернуть один тип, а ParamSpec должен
связываться заново на каждом применении декоратора. Лечится тем, что фабрика
возвращает не Callable, а протокол с обобщённым __call__:
class _Deco(Protocol):
def __call__[**P, R](self, f: Callable[P, R], /) -> Callable[P, R]: ...После этого обе формы дают одну и ту же сигнатуру.
Чем измерено
Все примеры лежат целыми файлами в bench/typing/: от
bench/typing/01_naive.py до bench/typing/07_args_kwargs_pair.py, плюс
bench/typing/version_matrix.py для матрицы версий. Проверяющих два —
mypy 2.3.1 и pyright 1.1.413 под CPython 3.13.7, — и на всех файлах они
сошлись.
Вывод любого из них — наблюдение, а не источник: первична спецификация системы типов, и там, где выше сказано «правило», за ним стоит она или PEP.
TL;DR
- Декоратор, объявленный через
Callable[..., R], пропускает оба заведомо неверных вызова из трёх написанных. Проверяющих типов здесь два —mypy 2.3.1иpyright 1.1.413, — и молчат оба; это не их поломка: PEP 612 говорит про многоточие прямо — «мы не выполняем никакой проверки аргументов». ParamSpec(PEP 612) на том же коде даёт три ошибки на тех же двух строках: две по типам и одну по числу аргументов. Меняется только объявление декоратора; ни его тело, ни вызовы не трогаются.P.argsиP.kwargsсуществуют только парой, и оба проверяющих отказываются принимать половину. Причина в PEP: два корректных вызова могут разложить один набор параметров по-разному.- Рантайм при этом честен, а проверяющий слеп — и обычно ждут ровно
наоборот.
inspect.signatureпоказывает исходную сигнатуру, потому чтоfunctools.wrapsоставил__wrapped__, иinspectпо нему прыгает. Concatenateумеет добавлять только позиционные параметры. Keyword-only так не добавить, и это ограничение самого PEP, а не проверяющих.- Фабрика декораторов — место, где ошибаются почти все:
@traceсигнатуру сохраняет,@trace(level=2)теряет её молча. Лечится протоколом с обобщённым__call__.
Ноль ошибок на двух неверных вызовах
Начнём с замера, а не с объяснения. Вот обычный декоратор, написанный так, как его пишут чаще всего:
def add_logging(f: Callable[..., R]) -> Callable[..., R]:
def inner(*args: object, **kwargs: object) -> R:
return f(*args, **kwargs)
return inner
@add_logging
def takes_int_str(x: int, y: str) -> int:
return x + 7И три вызова под ним, из которых два заведомо неверны:
takes_int_str(1, "A") # верный вызов
takes_int_str("B", 2) # типы переставлены
takes_int_str() # аргументов нет вовсеПроверяющий типов — mypy 2.3.1 — не находит ни одного. Файл целиком —
bench/typing/01_naive.py, вывод дословно:
$ mypy 01_naive.py
-> exit=0
Ни ошибки, ни предупреждения, код возврата ноль. Второй проверяющий,
pyright 1.1.413, на том же файле тоже молчит.
Что означает многоточие
Обычное объяснение — «... значит любые аргументы» — звучит безобидно и
поэтому не настораживает. Документация typing формулирует именно так:
If a literal ellipsis ... is given as the argument list, it indicates that a
callable with any arbitrary parameter list would be acceptable.
Если в качестве списка аргументов указано литеральное многоточие `...`, это означает, что подойдёт вызываемый объект с любым произвольным списком параметров.
Про то, что происходит с проверкой ВЫЗОВОВ такого объекта, здесь не сказано ничего. А сказано это в PEP 612, и совсем в других словах:
This was not caught by the type checker because the decorated takes_int_str
was given the type Callable[..., Awaitable[int]] (an ellipsis in place of
parameter types is specified to mean that we do no validation on arguments).
Это не было отловлено проверяющим типов, потому что декорированной takes_int_str был присвоен тип Callable[..., Awaitable[int]] (многоточие вместо типов параметров, по спецификации, означает, что мы не выполняем никакой проверки аргументов).
Разница между «подойдёт любой» и «не проверяем» — это и есть разница между
безобидной формулировкой и тремя пропущенными вызовами. Спецификация системы
типов говорит то же самое строже: This is a gradual form indicating that the type is consistent with any input signature
(Это градуальная форма, указывающая, что данный тип совместим с любой входной сигнатурой).
Градуальная форма — это место, где проверка сознательно отключается.
Заметьте, чего в этом объяснении нет: слова «баг». Проверяющий сделал ровно то, что ему сказали. Сказали неудачно.
ParamSpec: то же самое, три ошибки
PEP 612 существует ровно затем, чтобы это чинить. Постановка задачи в нём — про декораторы прямым текстом:
Neither of these support forwarding the parameter types of one callable over to
another callable, making it difficult to annotate function decorators.
Ни один из них не поддерживает проброс типов параметров одного вызываемого объекта в другой, что затрудняет аннотирование декораторов функций.
Тот же декоратор, переписанный через ParamSpec в синтаксисе PEP 695:
def add_logging[**P, R](f: Callable[P, R]) -> Callable[P, R]:
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return f(*args, **kwargs)
return innerТело декоратора не изменилось. Вызовы не изменились. Файл вызовов тот же самый.
Изменилась одна строка — объявление. Вывод (bench/typing/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]
Три ошибки на тех же двух строках — две по типам и одна по числу аргументов.
pyright находит те же три, другими словами.
Вся статья, в сущности, сводится к этой паре прогонов: ... — не «любые
аргументы», а «не проверять».
Почему P.args и P.kwargs только парой
Первое, обо что спотыкаются, — попытка написать половину:
def half[**P, R](f: Callable[P, R]) -> Callable[P, R]:
def inner(*args: P.args) -> R: # ошибка: половина пары
return f(*args)
return innerОба проверяющих отказываются, каждый своими словами
(bench/typing/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]
И следом ещё две, на строках 27 и 29: вызов остался без параметров, а результат перестал подходить под объявленный тип. Второй проверяющий отказывается на тех же трёх строках и называет правило прямее:
07_args_kwargs_pair.py:26: "args" and "kwargs" attributes of ParamSpec must both appear within a function signature
Причина названа в PEP, и она не про удобство записи:
A ParamSpec captures both positional and keyword accessible parameters, but
there unfortunately is no object in the runtime that captures both of these
together.
ParamSpec захватывает как позиционно, так и по ключу доступные параметры, но, к сожалению, в среде выполнения нет объекта, который захватывал бы и то и другое вместе.
Дальше — то, ради чего это ограничение и введено. Один и тот же набор
параметров разные вызовы раскладывают по-разному: f(1, y=2) кладёт единицу в
args, двойку в kwargs, а f(1, 2) — обе в args.
Therefore, we need to make sure that these special types are only brought into
the world together, and are used together, so that our usage is valid for all
possible partitions.
Поэтому мы должны обеспечить, чтобы эти специальные типы появлялись на свет только вместе и использовались вместе — так, чтобы наше использование было корректным для всех возможных разбиений.
То есть пара — не синтаксическая формальность, а единственный способ описать «те же самые параметры», не зная заранее, как их разложит вызывающий.
Рантайм честен, проверяющий слеп
Здесь начинается то, чего почти нигде не пишут, и оно переворачивает привычную картину.
Пока проверяющий молчал, в рантайме сигнатура никуда не делась. Измерено на 3.12.3, 3.13.7 и 3.14.7 — на всех трёх одинаково:
inspect.signature(takes_int_str) -> (x: int, y: str) -> int
inspect.signature(takes_int_str, follow_wrapped=False) -> (*args, **kwargs) -> int
takes_int_str.__code__.co_varnames -> ('args', 'kwargs')
Читать это надо снизу вверх. Последняя строка — правда: под декоратором стоит
функция, принимающая *args, **kwargs, и вызывать будут именно её. Средняя
строка — та же правда, сказанная inspect, когда его просят не прыгать. А
верхняя — то, что inspect показывает по умолчанию.
Прыжок возможен из-за functools.wraps — сам механизм разобран в статье про
декораторы, здесь он берётся готовым. wraps ставит на обёртку ссылку на обёрнутое
(wrapper.__wrapped__ = wrapped, Lib/functools.py:62 на 3.13.7), а
inspect.signature объявлен так, что по этой ссылке идёт:
def signature(obj, *, follow_wrapped=True, ...) (Lib/inspect.py:3373).
Отсюда и берётся уверенность, что «functools.wraps всё чинит». Он чинит
интроспекцию, help() и всё, что читает __wrapped__. Проверяющему он не
говорит ничего: wraps — это код, который выполняется при декорировании, а
проверяющий смотрит на объявленные типы и до выполнения не доходит.
Получается редкая для Python картина: инструмент времени выполнения знает правду, а инструмент, задача которого — предупредить заранее, не знает. Обычно бывает наоборот, и на этом ожидании ошибка и держится.
Когда декоратор добавляет аргумент
ParamSpec описывает «те же самые параметры». Но декоратор часто меняет
сигнатуру: что-то внедряет сам, а наружу это не выставляет. Для таких случаев
PEP 612 вводит Concatenate:
The semantics of Concatenate[X, Y, P] are that it represents the parameters
represented by P with two positional-only parameters prepended.
Семантика Concatenate[X, Y, P] такова, что он представляет параметры, представленные P, с двумя добавленными спереди позиционными-только параметрами.
Декоратор, который подставляет соединение с базой, а снаружи его прячет
(bench/typing/03_concatenate_and_protocol.py):
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 остаётся два параметра, а не три, и это проверяется:
| вызов | вердикт |
|---|---|
query("select", 10) | принят: conn внедрён декоратором |
query(10, "select") | две ошибки arg-type — порядок типов |
query("select", 10, 1) | call-arg: Too many arguments for "query" |
Тип входа и тип выхода у декоратора теперь разные, и в этом весь смысл:
Callable[Concatenate[str, P], R] на входе, Callable[P, R] на выходе.
Чего Concatenate не умеет
Симметричного способа добавить именованный параметр нет. Это не пробел проверяющих и не то, что «пока не сделали в mypy», — ограничение записано в самом PEP отдельным подразделом, вместе с причиной:
However, the key distinction is that while prepending positional-only
parameters to a valid callable type always yields another valid callable type,
the same cannot be said for adding keyword-only parameters.
Однако ключевое различие в том, что добавление positional-only параметров в начало корректного вызываемого типа всегда даёт другой корректный вызываемый тип, тогда как про добавление keyword-only параметров того же сказать нельзя.
PEP заканчивает подраздел обещанием вернуться к вопросу if there is sufficient demand
(если будет достаточный спрос). Пока спроса,
видимо, не набралось: в 3.14 ничего не изменилось.
Практический вывод простой. Декоратор, который добавляет именованный параметр,
типизируется только протоколом с явно выписанным __call__ — то есть перечнем
параметров руками, без ParamSpec.
Декоратор метода: Self переживает
Расхожее «после декоратора цепочки вызовов ломаются» проверяется тем же
способом. Concatenate[S, P] сохраняет и получателя, и Self
(bench/typing/06_methods.py):
def logged[S, **P, R](m: Callable[Concatenate[S, P], R]) -> Callable[Concatenate[S, P], R]:
def inner(self: S, *args: P.args, **kwargs: P.kwargs) -> R:
return m(self, *args, **kwargs)
return inner
class A:
@logged
def m(self, x: int) -> str:
return str(x)
@logged
def chain(self) -> Self:
return selfВывод:
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]
Первая строка: у a.m получатель уже связан, снаружи виден один параметр.
Вторая: a.chain() даёт A, то есть Self доехал через декоратор. Третья:
неверный вызов метода ловится так же, как неверный вызов функции.
Когда декоратор вешает атрибут
Callable[P, R] описывает вызываемое — и только. Если декоратор добавляет
объекту атрибут (cache_clear, счётчик, что угодно), в этом типе для атрибута
места нет. Решение — протокол, у которого есть и __call__, и всё остальное
(вторая половина bench/typing/03_concatenate_and_protocol.py):
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]: ...Тогда h.cache_clear() проходит, а h("x") при h(a: int) даёт ошибку — и
она приходит с адресом: Argument 1 to "__call__" of "HasCache". Обратите
внимание, что P.args и P.kwargs работают внутри протокола ровно так же, как
в обычной функции: пара остаётся парой.
Фабрика: тут ошибаются почти все
Декоратор, умеющий обе формы — @trace и @trace(level=2), — пишут парой
перегрузок, и выглядит она безупречно:
@overload
def trace[**P, R](f: Callable[P, R], /) -> Callable[P, R]: ...
@overload
def trace(*, level: int = ...) -> Callable[[Callable[..., object]], Callable[..., object]]: ...Вывод на двух функциях, задекорированных разными формами
(bench/typing/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]
Первая строка — @trace, сигнатура цела. Вторая — @trace(level=2), сигнатуры
больше нет. Третья — ошибка на a("bad"). А строкой ниже стоит b("bad"),
ровно такой же неверный вызов, и он не подчёркнут вовсе.
Причина видна во второй перегрузке. Фабрика обязана вернуть один тип, а
ParamSpec должен связываться заново на каждом применении декоратора. Один
Callable[[Callable[..., object]], Callable[..., object]] этого не умеет — и
многоточие внутри него делает ровно то, с чего начиналась статья.
Лечится тем, что фабрика возвращает не Callable, а протокол с обобщённым
__call__ (bench/typing/05_factory_fixed.py):
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: ...Параметры типа переехали с функции на сам __call__, и связываются они теперь в
момент применения. Вывод:
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]
Обе формы дают одну и ту же сигнатуру, и b("bad") наконец ловится.
Попутно — наблюдение, которое стоило нам самим одной правки. Реализация под
@overload сначала стояла без аннотаций (def trace(f=None, *, level=1):).
mypy на такую реализацию не смотрит вовсе; pyright выдавал по две ошибки
Overloaded implementation is not consistent with signature of overload.
Аннотация реализации убрала обе, ничего не изменив в выводах примера.
Неаннотированная реализация перегрузки для mypy невидима — это стоит помнить
и за пределами декораторов.
История версий
| Версия | Изменение | Что это значит для кода |
|---|---|---|
| 3.10 | PEP 612: появляются ParamSpec и Concatenate. До этого декоратор, сохраняющий сигнатуру, выразить было нечем — только callback-протокол с руками выписанными параметрами. | |
| 3.12 | PEP 695: синтаксис def deco[**P, R] вместо отдельного объявления P = ParamSpec("P"). Измерено: с 3.12 typing.ParamSpec — это буквально _typing.ParamSpec, тип из встроенного в интерпретатор модуля (typing.ParamSpec is _typing.ParamSpec даёт True, у _typing нет __file__). На 3.11 у _typing атрибута ParamSpec нет вовсе. | |
| 3.13 | PEP 696: у параметров типа появляются значения по умолчанию, в том числе у ParamSpec. Измерено: TypeVar(..., default=int) на 3.12 даёт TypeError, а def f[T = int] — SyntaxError; на 3.13 работает и то и другое. | |
| 3.14 | PEP 649: аннотации вычисляются лениво. Практический подарок для декораторов — протокол, описывающий обёртку, можно объявить ниже декоратора, который его возвращает, без кавычек и без from __future__ import annotations. Измерено: на 3.13 такое определение падает NameError прямо на строке def, на 3.14 проходит, а annotationlib отдаёт ForwardRef. |
Чем измерено
Числа этой статьи получены этими файлами. Каждый открывается прямо отсюда — вместе с записью прогона: чем проверяли, какой командой и что вышло дословно.
bench/typing/01_naive.pybench/typing/02_paramspec.pybench/typing/03_concatenate_and_protocol.pybench/typing/04_factory_broken.pybench/typing/05_factory_fixed.pybench/typing/06_methods.pybench/typing/07_args_kwargs_pair.pybench/typing/version_matrix.py
Проверяющих два: mypy 2.3.1 и pyright 1.1.413, оба под CPython 3.13.7. На
всех семи файлах они сошлись — те же строки, те же количества, те же выводы.
Это важно назвать: вывод любого инструмента здесь — наблюдение, а не источник.
Первична спецификация системы типов, и там, где статья опирается на правило, а
не на наблюдение, стоит цитата из неё или из PEP.
Матрица версий снята одним и тем же файлом на четырёх интерпретаторах: 3.11.15,
3.12.3, 3.13.7 и 3.14.7 — все четыре релизные сборки, не кандидаты. 3.11 здесь
не для полноты: только на ней видно, что ParamSpec не всегда жил в C.
Времени в этой статье нет намеренно. Типизация на скорость не влияет, и любая
таблица наносекунд здесь была бы таблицей про PEP 649, а не про ParamSpec.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- Декоратор, объявленный через
Callable[..., R], пропускает оба заведомо неверных вызова из трёх написанных. Проверяющих типов здесь два —mypy 2.3.1иpyright 1.1.413, — и молчат оба; это не их поломка: PEP 612 говорит про многоточие прямо — «мы не выполняем никакой проверки аргументов». ParamSpec(PEP 612) на том же коде даёт три ошибки на тех же двух строках: две по типам и одну по числу аргументов. Меняется только объявление декоратора; ни его тело, ни вызовы не трогаются.P.argsиP.kwargsсуществуют только парой, и оба проверяющих отказываются принимать половину. Причина в PEP: два корректных вызова могут разложить один набор параметров по-разному.- Рантайм при этом честен, а проверяющий слеп — и обычно ждут ровно наоборот.
inspect.signatureпоказывает исходную сигнатуру, потому чтоfunctools.wrapsоставил__wrapped__, иinspectпо нему прыгает. Concatenateумеет добавлять только позиционные параметры. Keyword-only так не добавить, и это ограничение самого PEP, а не проверяющих.- Фабрика декораторов — место, где ошибаются почти все:
@traceсигнатуру сохраняет,@trace(level=2)теряет её молча. Лечится протоколом с обобщённым__call__.
На самом деле
- Он чинит интроспекцию, и только её.
wrapsставитwrapper.__wrapped__ = wrapped(Lib/functools.py:62), аinspect.signatureпо этой ссылке прыгает, потому что объявлен сfollow_wrapped=True(Lib/inspect.py:3373). Проверяющему это не говорит ничего:wrapsвыполняется при декорировании, а проверяющий до выполнения не доходит. Измерено наbench/typing/01_naive.py: оба заведомо неверных вызова проходят молча.wrapsв этом файле нет — и в этом соль: он ничего бы не изменил. - Означает «не проверять». Документация
typingформулирует мягко («подойдёт вызываемый объект с любым произвольным списком параметров»), а PEP 612 прямо: an ellipsis in place of parameter types is specified to mean that we do no validation on arguments. Измерено наbench/typing/01_naive.py: ноль ошибок, выход с кодом 0. - Не теряется, если декоратор объявлен через
Concatenate[S, P]. Измерено наbench/typing/06_methods.py: у классаAс декорированными методамиreveal_type(a.m)даётdef (x: int) -> str— получатель связан, — аreveal_type(a.chain())даётA. Оба проверяющих согласны. - Это самое дорогое место темы. Измерено: при обычной паре перегрузок
@traceдаётdef (x: int) -> str, а@trace(level=2)—def (*Any, **Any) -> object. Потеря молчаливая:a("bad")подчёркнуто,b("bad")строкой ниже — нет. Причина в том, что фабрика обязана вернуть один тип, аParamSpecдолжен связываться заново на каждом применении. Лечится протоколом с обобщённым__call__. - Не умеет, и это ограничение самого PEP 612, а не проверяющих. The semantics of Concatenate[X, Y, P] are that it represents the parameters represented by P with two positional-only parameters prepended. Для keyword-only в PEP есть отдельный подраздел с объяснением, почему так нельзя, и обещанием вернуться к вопросу «если будет достаточный спрос». В 3.14 не вернулись.
По версиям
- 3.10
- PEP 612: появляются
ParamSpecиConcatenate. До этого декоратор, сохраняющий сигнатуру, выразить было нечем — только callback-протокол с руками выписанными параметрами.< - 3.12
- PEP 695: синтаксис
def deco[**P, R]вместо отдельного объявленияP = ParamSpec("P"). Измерено: с 3.12typing.ParamSpec— это буквально_typing.ParamSpec, тип из встроенного в интерпретатор модуля (typing.ParamSpec is _typing.ParamSpecдаётTrue, у_typingнет__file__). На 3.11 у_typingатрибутаParamSpecнет вовсе.< - 3.13
- PEP 696: у параметров типа появляются значения по умолчанию, в том числе у
ParamSpec. Измерено:TypeVar(..., default=int)на 3.12 даётTypeError, аdef f[T = int]—SyntaxError; на 3.13 работает и то и другое.< - 3.14
- PEP 649: аннотации вычисляются лениво. Практический подарок для декораторов — протокол, описывающий обёртку, можно объявить ниже декоратора, который его возвращает, без кавычек и без
from __future__ import annotations. Измерено: на 3.13 такое определение падаетNameErrorпрямо на строкеdef, на 3.14 проходит, аannotationlibотдаётForwardRef.<
Что разобрано
- Ноль ошибок на двух неверных вызовах
- Что означает многоточие
- `ParamSpec`: то же самое, три ошибки
- Почему `P.args` и `P.kwargs` только парой
- Рантайм честен, проверяющий слеп
- Когда декоратор добавляет аргумент
- Чего `Concatenate` не умеет
- Декоратор метода: `Self` переживает
- Когда декоратор вешает атрибут
- Фабрика: тут ошибаются почти все
- История версий
- Чем измерено
Частые заблуждения
functools.wraps чинит типы
Он чинит интроспекцию, и только её. wraps ставит wrapper.__wrapped__ = wrapped (Lib/functools.py:62), а inspect.signature по этой ссылке прыгает, потому что объявлен с follow_wrapped=True (Lib/inspect.py:3373). Проверяющему это не говорит ничего: wraps выполняется при декорировании, а проверяющий до выполнения не доходит. Измерено на bench/typing/01_naive.py: оба заведомо неверных вызова проходят молча. wraps в этом файле нет — и в этом соль: он ничего бы не изменил.
Callable[..., R] означает «любые аргументы»
Означает «не проверять». Документация typing формулирует мягко («подойдёт вызываемый объект с любым произвольным списком параметров»), а PEP 612 прямо: an ellipsis in place of parameter types is specified to mean that we do no validation on arguments
(многоточие вместо типов параметров, по спецификации, означает, что мы не выполняем никакой проверки аргументов). Измерено на bench/typing/01_naive.py: ноль ошибок, выход с кодом 0.
После декоратора цепочки вызовов ломаются: Self теряется
Не теряется, если декоратор объявлен через Concatenate[S, P]. Измерено на bench/typing/06_methods.py: у класса A с декорированными методами reveal_type(a.m) даёт def (x: int) -> str — получатель связан, — а reveal_type(a.chain()) даёт A. Оба проверяющих согласны.
Если @deco типизирован правильно, то и @deco(level=2) тоже
Это самое дорогое место темы. Измерено: при обычной паре перегрузок @trace даёт def (x: int) -> str, а @trace(level=2) — def (*Any, **Any) -> object. Потеря молчаливая: a("bad") подчёркнуто, b("bad") строкой ниже — нет. Причина в том, что фабрика обязана вернуть один тип, а ParamSpec должен связываться заново на каждом применении. Лечится протоколом с обобщённым __call__.
ParamSpec с Concatenate умеет добавить декоратором именованный параметр
Не умеет, и это ограничение самого PEP 612, а не проверяющих. The semantics of Concatenate[X, Y, P] are that it represents the parameters represented by P with two positional-only parameters prepended
(Семантика Concatenate[X, Y, P] такова, что он представляет параметры, представленные P, с двумя добавленными спереди позиционными-только параметрами). Для keyword-only в PEP есть отдельный подраздел с объяснением, почему так нельзя, и обещанием вернуться к вопросу «если будет достаточный спрос». В 3.14 не вернулись.
Проверка знаний
Декоратор объявлен как Callable[..., R] -> Callable[..., R], под ним функция takes_int_str(x: int, y: str). Сколько ошибок найдёт проверяющий на вызовах takes_int_str("B", 2) и takes_int_str()?
Источники и что читать дальше
9 ИСТОЧНИКОВ
- PEP 612 — Parameter Specification VariablesPEP. Марк Мендоза, Final, Python 3.10. Документ, которым `ParamSpec` и `Concatenate` вошли в язык, и единственное место, где прямо сказано, что означает многоточие: «an ellipsis in place of parameter types is specified to mean that we do no validation on arguments» (многоточие вместо типов параметров, по спецификации, означает, что мы не выполняем никакой проверки аргументов). Оттуда же постановка задачи: «Neither of these support forwarding the parameter types of one callable over to another callable, making it difficult to annotate function decorators» (Ни один из них не поддерживает проброс типов параметров одного вызываемого объекта в другой, что затрудняет аннотирование декораторов функций). Правило пары: «A ParamSpec captures both positional and keyword accessible parameters, but there unfortunately is no object in the runtime that captures both of these together» (ParamSpec захватывает как позиционно, так и по ключу доступные параметры, но, к сожалению, в среде выполнения нет объекта, который захватывал бы и то и другое вместе) и вывод: «we need to make sure that these special types are only brought into the world together, and are used together, so that our usage is valid for all possible partitions» (мы должны обеспечить, чтобы эти специальные типы появлялись на свет только вместе и использовались вместе — так, чтобы наше использование было корректным для всех возможных разбиений). Семантика соединения: «The semantics of Concatenate[X, Y, P] are that it represents the parameters represented by P with two positional-only parameters prepended» (Семантика Concatenate[X, Y, P] такова, что он представляет параметры, представленные P, с двумя добавленными спереди позиционными-только параметрами).https://peps.python.org/pep-0612/
- Спецификация системы типов — CallablesОфициальная документация. Первичнее поведения любого проверяющего. О многоточии: «The Callable special form supports the use of `...` in place of the list of parameter types. This is a gradual form indicating that the type is consistent with any input signature» (Специальная форма Callable поддерживает использование `...` вместо списка типов параметров. Это градуальная форма, указывающая, что данный тип совместим с любой входной сигнатурой). Здесь же, что многоточие сочетается с `Concatenate`: «A `...` can also be used with Concatenate» (`...` можно также использовать вместе с Concatenate).https://typing.python.org/en/latest/spec/callables.html
- Спецификация системы типов — GenericsОфициальная документация. Раздел про `ParamSpec` открывается пометкой о происхождении — «(Originally specified by PEP 612.)» ((Изначально специфицировано в PEP 612.)) — и дословно воспроизводит формулировки PEP: и правило совместного использования `P.args` / `P.kwargs`, и «Placing keyword-only parameters between the *args and **kwargs is forbidden» (Размещение keyword-only параметров между *args и **kwargs запрещено). То есть оба ключевых утверждения этой статьи имеют нормативную опору, а не только поведение инструмента.https://typing.python.org/en/latest/spec/generics.html
- PEP 695 — Type Parameter SyntaxPEP. Final, Python 3.12. Синтаксис, которым в статье записаны все декораторы: «The syntax adds support for a comma-delimited list of type parameters in square brackets after the name of the class, function, or type alias» (Синтаксис добавляет поддержку списка параметров типа, разделённого запятыми, в квадратных скобках после имени класса, функции или псевдонима типа). Двойная звёздочка для `ParamSpec` — часть грамматики: `type_param: | a=NAME b=[type_param_bound] | '*' a=NAME | '**' a=NAME`.https://peps.python.org/pep-0695/
- PEP 696 — Type Defaults for Type ParametersPEP. Final, Python 3.13. «This PEP introduces the concept of type defaults for type parameters, including TypeVar, ParamSpec, and TypeVarTuple, which act as defaults for type parameters for which no type is specified» (Этот PEP вводит понятие значений по умолчанию для параметров типа, включая TypeVar, ParamSpec и TypeVarTuple, которые выступают значениями по умолчанию для тех параметров типа, для которых тип не указан). В статье используется как граница версии: на 3.12 синтаксис `def f[T = int]` даёт SyntaxError, а `TypeVar(..., default=int)` — TypeError; на 3.13 работает и то и другое.https://peps.python.org/pep-0696/
- PEP 649 — Deferred Evaluation Of Annotations Using DescriptorsPEP. Final, Python 3.14. Механизм: «It adds a new internal mechanism for lazily computing annotations on demand, via a new object method called `__annotate__`» (Он добавляет новый внутренний механизм для ленивого вычисления аннотаций по требованию — через новый метод объекта, называемый `__annotate__`) и следствие: «This mechanism delays the evaluation of annotations expressions until the annotations are examined, which solves many circular reference problems» (Этот механизм откладывает вычисление выражений аннотаций до момента, когда аннотации будут запрошены, что решает многие проблемы циклических ссылок). Именно отсюда возможность объявить протокол ниже декоратора, который его возвращает.https://peps.python.org/pep-0649/
- Lib/functools.py — update_wrapperИсходный код CPython. Строка, из-за которой рантайм остаётся честным: `wrapper.__wrapped__ = wrapped` (`Lib/functools.py:62` на 3.13.7), и комментарий рядом объясняет, почему она последняя: «Issue #17482: set __wrapped__ last so we don't inadvertently copy it from the wrapped function when updating __dict__» (Issue #17482: ставим __wrapped__ последним, чтобы ненароком не скопировать его из обёрнутой функции при обновлении __dict__). Список копируемых полей — `WRAPPER_ASSIGNMENTS` на строке 33.https://github.com/python/cpython/blob/v3.13.7/Lib/functools.py
- Lib/inspect.py — signature и unwrapИсходный код CPython. Умолчание, из-за которого `inspect.signature` показывает не ту функцию, которую вызовут: `def signature(obj, *, follow_wrapped=True, ...)` (`Lib/inspect.py:3373` на 3.13.7). Прыжок по цепочке делает `unwrap` (строка 764). Проверено на 3.12.3, 3.13.7 и 3.14.7: с умолчанием видна исходная сигнатура, с `follow_wrapped=False` — `(*args, **kwargs)`.https://github.com/python/cpython/blob/v3.13.7/Lib/inspect.py
- typing — Support for type hintsОфициальная документация. Формулировка документации про многоточие мягче, чем в PEP: «If a literal ellipsis `...` is given as the argument list, it indicates that a callable with any arbitrary parameter list would be acceptable» (Если в качестве списка аргументов указано литеральное многоточие `...`, это означает, что подойдёт вызываемый объект с любым произвольным списком параметров). Отдельного предложения о том, что аргументы при этом не проверяются, на этой странице нет — оно есть в PEP 612, и разница между «подойдёт любой» и «не проверяем» как раз и есть предмет первого раздела статьи.https://docs.python.org/3/library/typing.html