2025, Dec 02 09:02

Как типизировать асимметричный атрибут в Python: Pyright, Any и TYPE_CHECKING

Как типизировать асимметричный атрибут при динамическом __getattr__/__setattr__: трюк EventList | Any и строгий вариант через TYPE_CHECKING/.pyi для Pyright.

Инструменты статической типизации вроде Pyright в VS Code отлично работают — до тех пор, пока динамические паттерны не сталкиваются с жестким выводом типов. Типичный случай — асимметричный атрибут: присваивать ему можно любой Iterable, а при чтении он должен вести себя как конкретный подкласс списка с дополнительными методами. С явным @property это выразить легко; но при динамическом слое на __getattr__/__setattr__ сложно донести намерение до проверяющего типов, не возвращая в код лишнюю шаблонность.

Асимметричный атрибут в двух словах

Ниже — желаемое поведение с обычным свойством: сеттер принимает любой Iterable и оборачивает его в специализированный подкласс списка; геттер всегда возвращает этот подкласс.

from typing import Iterable

class EventList(list):
    """list with events for change, insert, remove, ..."""
    
    def mark(self) -> str:
        ...

class Panel:
    @property
    def items(self) -> EventList:
        return self._items

    @items.setter
    def items(self, data: Iterable):
        self._items = EventList(data)

pane = Panel()

При такой схеме Pyright видит сразу две вещи: во‑первых, атрибуту можно присвоить любой Iterable; во‑вторых, при чтении возвращается EventList, так что методы, специфичные для EventList, вызывать безопасно. Проблема возникает, когда свойство явно не объявлено, потому что доступ к атрибуту обеспечивает динамический слой.

Почему динамический вариант сбивает типизатор

Когда __getattr__ и __setattr__ реализуют поведение во время выполнения, атрибут отсутствует в теле класса. Описание его типа всё равно нужно. Если аннотировать его как EventList, Pyright отклонит присваивание обычного списка. Если указать Iterable или list, Pyright запретит вызовы методов, которых нет вне EventList. А если записать list | EventList, проверяющий сочтет, что атрибут может быть и тем и другим, поэтому методы только из EventList остаются небезопасными.

Прагматичное решение: трюк с объединением Any

Чтобы совместить «принимает любое присваивание» с «читает как EventList», объявите атрибут как EventList | Any. Это оставляет присваивание максимально свободным и позволяет вызывать API, специфичный для EventList, без помех от объединений.

from typing import Any

class EventList(list):
    """list with events for change, insert, remove, ..."""
    
    def mark(self) -> str:
        ...

class Panel:
    items: EventList | Any

Panel().items = [2, 3]  # ОК: при присваивании принимается обычный список

text: str = Panel().items.mark()  # ОК: атрибут используется как EventList

# Учтите компромисс ниже:
Panel().items = "no error"  # тоже пройдет

Такой подход сознательно меняет строгость присваивания на удобство чтения. При доступе атрибут ведет себя как специализированный тип, но присваивания фактически не проверяются.

Без шаблонного кода, но точно: TYPE_CHECKING или .pyi

Если важнее корректность, а не максимальная разрешительность, и при этом не хочется добавлять код на рантайме, объявите свойство только для типизатора. На выполнение это не влияет, но сохраняет точный асимметричный контракт. Вариант с заглушкой .pyi работает так же в проектах, где типы вынесены из реализации.

from typing import Iterable, TYPE_CHECKING

class EventList(list):
    """list with events for change, insert, remove, ..."""

class Panel:
    if TYPE_CHECKING:
        @property
        def items(self) -> EventList: ...

        @items.setter
        def items(self, seq: Iterable): ...

Так вы даёте Pyright понять, что атрибут существует и ведёт себя асимметрично, не трогая вашу динамическую механику атрибутов.

Почему это важно

Генераторы GUI и похожие фреймворки полагаются на динамические привязки, чтобы убирать рутину. По мере того как проекты подключают Pyright и другие инструменты для устранения ошибок типов, этим динамическим паттернам нужна чистая форма объявления намерений. Трюк с объединением Any дает однострочное объявление: код остается лаконичным, а атрибут — пригодным как специализированный тип. Подход с TYPE_CHECKING или .pyi обеспечивает полностью точную типизацию без оверхеда на рантайме ценой небольшой секции объявлений.

Выводы

Если приоритет — минимум шаблонного кода и приемлемая гибкость, объявляйте атрибут как EventList | Any и помните, что пройдет любое присваивание. Если нужны более строгие гарантии, добавьте свойство только для TYPE_CHECKING (или заглушку .pyi), чтобы точно выразить асимметрию геттера и сеттера. Оба варианта сохраняют динамическую «проводку» атрибутов и возвращают полезную типовую подсказку в VS Code с Pyright.