2025, Dec 03 12:02
Почему декораторы с Concatenate ломают override в Pyright
Разбираем, почему декораторы с Concatenate делают параметры только позиционными и вызывают ошибку Pyright при override. Покажем решение и верную сигнатуру.
Декораторы, которые преобразуют параметры метода, могут конфликтовать с правилами переопределения в статических проверках типов. Частый симптом — Pyright помечает переопределение как несовместимое, когда декорированный метод принимает два параметра вместо одного. Разница упирается в то, как типовые конструкции кодируют виды параметров и как именно проверяется совместимость переопределений.
Проблема
Следующий пример декорирует переопределяющий метод и вызывает ошибку Pyright, когда в сигнатуре присутствуют и self, и второй параметр. Если убрать явный второй параметр из типизированной сигнатуры декоратора, ошибка исчезает — что поначалу сбивает с толку.
from functools import wraps
from typing import Callable, Concatenate, ParamSpec, TypeVar
U = TypeVar("U")
Q = ParamSpec("Q")
S = TypeVar("S")
# Здесь обёртка добавляет в начало: (self, channel)
def bind_channel(
fn: Callable[Concatenate[U, str, Q], S]
) -> Callable[Concatenate[U, str, Q], S]:
@wraps(fn)
def inner(obj: U, channel: str, *a: Q.args, **kw: Q.kwargs) -> S:
print(f"[decorator] obj={obj}, channel={channel}")
return fn(obj, channel, *a, **kw)
return inner
class BaseClient:
def configure_stream(self, channel: str):
pass
class DerivedClient(BaseClient):
@bind_channel
# Здесь ошибка Pyright
def configure_stream(self, channel: str):
pass
Метод "configure_stream" переопределяет класс "BaseClient" несовместимым образом
Несоответствие 2-го параметра: базовый параметр "channel" — именованный, в переопределении он только позиционный
Что на самом деле происходит
Использование Callable[Concatenate[U, str, Q], S] означает, что параметры U и str добавляются в начало вызываемого объекта и становятся только позиционными в соответствии с семантикой PEP 612. Следовательно, декоратор возвращает функцию, у которой первые два параметра — только позиционные. В базовом классе параметр channel объявлен без маркера «только позиционный», то есть его можно передавать позиционно или по имени. Переопределение же сужает его до только позиционного, и Pyright рассматривает это как несовместимое переопределение.
Когда вы меняете декоратор так, чтобы он добавлял только self, а остальные параметры оставались в Q, их вид не принудительно становится только позиционным. Поэтому версия, которая фактически лишь жёстко задаёт self, не вызывает той же ошибки переопределения.
Исправление
Один из способов сделать переопределение совместимым — объявить второй параметр базового метода как только позиционный, чтобы база и переопределение совпадали по видам параметров. Так сигнатуры выравниваются, и предупреждение Pyright о несовместимости пропадает. Дополнительно, наличие маркера только позиционных в сигнатуре обёртки сохраняет заявленный смысл и для других проверяющих.
from functools import wraps
from typing import Callable, Concatenate, ParamSpec, TypeVar
U = TypeVar("U")
Q = ParamSpec("Q")
S = TypeVar("S")
# Оставляем (self, channel) добавленными в начало и только позиционными
def bind_channel(
fn: Callable[Concatenate[U, str, Q], S]
) -> Callable[Concatenate[U, str, Q], S]:
@wraps(fn)
def inner(obj: U, channel: str, /, *a: Q.args, **kw: Q.kwargs) -> S:
print(f"[decorator] obj={obj}, channel={channel}")
return fn(obj, channel, *a, **kw)
return inner
class BaseClient:
def configure_stream(self, channel: str, /): # только позиционный
pass
class DerivedClient(BaseClient):
@bind_channel # ОК
def configure_stream(self, channel: str):
pass
Любопытный побочный эффект: допустимость вызова вроде DerivedClient().configure_stream(channel="x") во время выполнения зависит от маркера только позиционных, тогда как проверяющий типы сочтёт такой именованный аргумент ошибкой, если параметр объявлен как только позиционный.
Почему это важно
Используя декораторы вместе с методами в иерархиях наследования, вы изменяете не только поведение — вы также меняете сигнатуру, которую видят анализаторы типов. Добавление параметров через Concatenate делает их только позиционными. Если в базовом классе тот же параметр допускает передачу по имени, переопределение становится строже базового контракта, и статический анализ справедливо укажет на это. Понимание этой взаимосвязи предотвращает хрупкость API и избавляет от шумных, сбивающих с толку диагностик в крупных кодовых базах.
Выводы
Если декоратор использует Concatenate, чтобы добавить параметры перед сигнатурой метода, эти параметры становятся только позиционными. Убедитесь, что метод базового класса отражает это намерение: объявите соответствующие параметры как только позиционные с помощью слэша. Когда декоратор и метод согласованы, Pyright принимает переопределение, а маркер только позиционных в сигнатуре обёртки делает намерение понятным и для других проверяющих.