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 принимает переопределение, а маркер только позиционных в сигнатуре обёртки делает намерение понятным и для других проверяющих.