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__ у декорированных переопределений. С этими ограничениями можно сохранить кэширование и не позволять проверке типов блокировать сборку.