2025, Dec 16 00:02

Как @cached из aiocache ломает LSP и типизацию в Python

Почему @cached из aiocache ломает LSP и типы: ошибка переопределения в Pyright/Pylance, причины на рантайме и простое решение через type: ignore для метода.

Декорирование переопределённого асинхронного метода с помощью @cached из aiocache на рантайме может выглядеть вполне корректно, но тут же спотыкается о типизацию. Симптом — переопределение метода помечается как несовместимое, хотя возвращаемые типы совпадают. Если после добавления @cached Pylance или Pyright начинают жаловаться, что метод в подклассе больше не соответствует сигнатуре базового класса, вы по адресу.

Минимальный пример, воспроизводящий проблему

Следующая абстракция в стиле репозитория показывает проблему. Базовый класс определяет асинхронный метод с параметризованным возвращаемым типом. Подкласс переопределяет его с тем же типом и добавляет кэширование через @cached.

from typing import Generic, TypeVar, List
from aiocache import cached
X = TypeVar("X")
# Base repository
class RepoBase(Generic[X]):
    async def fetch_all(self) -> List[X]:
        ...
# Concrete repository
class CatalogRepo(RepoBase[CategoryModel]):
    @cached(ttl=600, key_builder=clazzaware_builder)
    async def fetch_all(self) -> List[CategoryModel]:
        return await super().fetch_all()

После добавления декоратора проверка типов выдаёт ошибку переопределения. Типичное сообщение выглядит так:

"get_all" overrides method of same name in class "IBaseRepository" with incompatible type "_Wrapped[..., Unknown, ..., CoroutineType[Any, Any, Unknown]]"

Даже если вынести кэширование в отдельную сервисную функцию и декорировать уже её, с точки зрения проверяющего инструмента форма функции меняется — и претензия остаётся. Бэкендом может быть Redis, но поведение вызвано именно декоратором.

Что на самом деле происходит

Здесь нарушается принцип подстановки Лисков. Метод подкласса, обёрнутый в @cached, больше не взаимозаменяем с родительским так, как ожидает проверка типов. Быстрая проверка на рантайме показывает несоответствие: сравниваем атрибуты методов, которые есть у исходной функции, но отсутствуют у обёрнутого вызываемого объекта.

from typing import Generic, TypeVar
from aiocache import cached
S = TypeVar("S")
class Root(Generic[S]):
    async def fetch_all(self) -> list[S]:
        ...
class Leaf(Root[int]):
    @cached
    async def fetch_all(self) -> list[int]:
        return await super().fetch_all()
print(Root.fetch_all.__code__)   # ОК
print(Leaf.fetch_all.__code__)   # ОК
# AttributeError: 'cached' object has no attribute '__code__'. Did you mean: '__call__'?

В подклассе атрибут-обёртка — это дескриптор, у которого нет тех же атрибутов функции, что у базового метода. С точки зрения проверяющего это ломает взаимозаменяемость и приводит к ошибке переопределения. Переопределение дескриптора полезно на рантайме, но для статического анализа и интроспекции это проблемно.

Практический вариант исправления, если @cached нужен

Если вы используете Pyright или Pylance и хотите оставить декоратор на переопределённом методе, самый простой и безопасный для типизации шаг — подавить диагностику на месте. Добавьте type: ignore к определению с декоратором и продолжайте работу.

from typing import Generic, TypeVar, List
from aiocache import cached
Y = TypeVar("Y")
class StorageBase(Generic[Y]):
    async def pull_all(self) -> List[Y]:
        ...
class TopicRepo(StorageBase[TopicEntity]):
    @cached(ttl=600, key_builder=clazzaware_builder)  # type: ignore
    async def pull_all(self) -> List[TopicEntity]:
        return await super().pull_all()

Так вы признаёте ограничение, но сохраняете поведение на рантайме. Если вам по‑прежнему нужна интроспекция метода во время выполнения, не предполагайте, что после обёртки @cached у него будут атрибуты вроде __code__.

Замечание о типовой информации в библиотеке

Есть и сопутствующий фактор. Пакет aiocache не публикует маркер py.typed, хотя в репозитории GitHub он есть. На практике это означает, что проверяющие инструменты считают библиотеку нетипизированной и могут показывать дополнительные ошибки типизации помимо описанной проблемы с переопределением.

Почему это важно для Python с проверкой типов

В кодовой базе, где строго контролируется совместимость сигнатур методов в иерархиях наследования, декораторы, заменяющие функции на не-функциональные дескрипторы, меняют форму таким образом, что статические инструменты справедливо это отмечают. Понимание, что это осознанный компромисс, помогает не тратить время на ложные следы в вашей настройке типизации. Истоки поведения — в нарушении взаимозаменяемости, а не в конкретных доменных классах или выбранном хранилище кэша.

Выводы

Если @cached на переопределённом асинхронном методе вызывает ошибку несовместимого переопределения, перед вами нарушение LSP, связанное с тем, что обёртка подменяет функцию. Практичное решение для пользователей Pyright или Pylance — добавить type: ignore к этому определению. Учтите, что aiocache сейчас не публикует маркер py.typed, поэтому возможны и другие трения с типами. Если нужна интроспекция методов, не полагайтесь на атрибуты вроде __code__ у декорированных переопределений. С этими ограничениями можно сохранить кэширование и не позволять проверке типов блокировать сборку.