Tpye подсказки для миксинов, ссылающихся на атрибуты стороннего базового класса

Я пытаюсь добавить подсказки типов в класс-миксин, который должен использоваться вместе с внешним, сторонним классом. То есть миксин опирается на члены стороннего класса.

Например, миксин для формы Django:

# mixin_typing.py
from django import forms


class SuffixFormMixin: 
    suffix: str

    def add_suffix(self, field_name: str) -> str:
        # prefix is an attribute of forms.Form
        return f"{self.prefix}__{field_name}__{self.suffix}"


class SuffixForm(SuffixFormMixin, forms.Form):
    pass

Понятно, что mypy будет жаловаться на метод add_suffix:

"SuffixFormMixin" has no attribute "prefix"

IDE (PyCharm) также выдаст предупреждение:

Unresolved attribute reference 'prefix' for class 'SuffixFormMixin'

Вопрос:

Есть ли какое-нибудь "простое" решение, позволяющее миксину понять, что self содержит атрибуты/методы forms.Form? Вот вопрос на github, в котором это рассматривается, но, к сожалению, он никуда не делся: https://github.com/python/typing/issues/246

Может быть, какой-то объект типизации или другое mypy-фу, которое действует как обещание классу-миксину, что будущий "класс-партнер" имеет члены, которые использует миксин?

Попытки решения

Все предложения, которые я нашел до сих пор, имеют недостатки:

Типовая подсказка себя классу-партнеру

(работает для mypy: ✅, не работает для IDE: ❌)

Я встречал предложения вписать hint self в класс, с которым впоследствии будет использоваться миксин. Здесь forms.Form:

    def add_suffix(self: forms.Form, field_name: str) -> str:
        return f"{self.prefix}__{field_name}__{self.suffix}"

mypy больше не жалуется (хотя должен бы?), но IDE все еще жалуется. На этот раз по поводу атрибута suffix:

Unresolved attribute reference 'suffix' for class 'Form' 

Типизация себя к будущему конкретному классу

(mypy: ❌, IDE: ✅)

Это идет немного дальше, чем предыдущее предложение:

class SuffixFormMixin: 
    suffix: str

    def add_suffix(self: "SuffixForm", field_name: str) -> str:
        return f"{self.prefix}__{field_name}__{self.suffix}"

class SuffixForm(SuffixFormMixin, forms.Form):
    pass

При этом IDE может правильно разрешить все атрибуты, но mypy выбрасывает ошибку:

The erased type of self "mixin_typing.SuffixForm" is not a supertype of its class "mixin_typing.SuffixFormMixin"

Приведение себя к типу Union

(mypy: ❌, IDE: ✅)

    def add_suffix(self: Union["SuffixFormMixin", forms.Form], field_name: str) -> str:
        return f"{self.prefix}__{field_name}__{self.suffix}"

Опять же, IDE понимает, в чем дело, и может правильно разрешить атрибуты, но mypy жалуется:

Item "SuffixFormMixin" of "SuffixFormMixin | Any" has no attribute "prefix"

Переход от типа self к TypeVar

(mypy: ✅, IDE: ❌)

Это предлагает объявить будущий базовый класс как TypeVar и использовать его как подсказку для self.

FormType = TypeVar("FormType", bound=forms.Form)
...

    def add_suffix(self: FormType, field_name: str) -> str:
        return f"{self.prefix}__{field_name}__{self.suffix}"

Это не генерирует никаких предупреждений, но кажется, что это скорее потому, что, используя TypeVar, я скрываю типы, а не потому, что это правильное решение? Например, можно сделать так, чтобы ни mypy, ни IDE не жаловались:

FormType = TypeVar("FormType", bound=forms.Form)

class SuffixFormMixin:
    suffix: str

    def this_will_explode(self: FormType) -> Any:
        return self.suffix + 1  # str + int!

Полагаю, что тип для self.suffix принимается за Any, поскольку он больше не может быть определен? IDE также не показывает никакой информации о self.suffix при наведении.

Это тоже не совсем подходит, учитывая, что mypy абсолютно точно должен поймать подобную проблему в методе this_will_explode.

Ввод протокола

(mypy: ✅, IDE: ✅)

Объявление протокола и наследование его миксином, как описано здесь, выглядит наиболее полным решением. Протокол описывает биты класса-партнера, который использует миксин.

# mixin_typing.py
from typing import Protocol

from django import forms


class HasPrefix(Protocol):
    prefix: str


class SuffixFormMixin(HasPrefix):
    suffix: str

    def add_suffix(self, field_name: str) -> str:
        return f"{self.prefix}__{field_name}__{self.suffix}"


class SuffixForm(SuffixFormMixin, forms.Form):
    pass

mypy счастлив, IDE знает типы, но...:

  • это требует добавления классов
  • протокол должен отражать миксины партнерского класса forms.Form , над которыми у меня нет контроля
  • протокол "блокирует" переход IDE к фактической реализации на родительском классе (т.е. ctrl + клик на self.prefix переводит на HasPrefix.prefix, а не на forms.Form)
  • фактически не запускается

При создании класса SuffixForm возникает ошибка:

>>> import mixin_typing
Traceback (most recent call last):
  File "", line 36, in <module>
    class SuffixForm(SuffixFormMixin, forms.Form):
TypeError: metaclass conflict: the metaclass of a derived class must be a (non-strict) subclass of the metaclasses of all its bases

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

class HasPrefix(Protocol):
    prefix: str


if TYPE_CHECKING:
    _Base = HasPrefix
else:
    _Base = object


class SuffixFormMixin(_Base):
    suffix: str

    def add_suffix(self, field_name: str) -> str:
        return f"{self.prefix}__{field_name}__{self.suffix}"

Мне это не нравится. Это еще больше кода, который служит только для проверки типов, и теперь вам нужно объяснить, почему миксин переключает базовый класс. Может быть, это будет лучше работать в файле-заглушке?

Обязательное наследование от класса-партнера

(mypy: ✅, IDE: ✅)

Как было предложено в одном из комментариев:

Вы также можете использовать свой последний подход без протокола: условно использовать forms.Form или object в качестве базового класса. При этом все равно "нужно объяснить, почему база класса динамическая", но в случае TYPE_CHECKING это должно быть довольно очевидно.

if TYPE_CHECKING:
    _Base = forms.Form
else:
    _Base = object


class SuffixFormMixin(_Base):
    suffix: str

    def add_suffix(self, field_name: str) -> str:
        return f"{self.prefix}__{field_name}__{self.suffix}"

mypy это не нравится:

mixin_typing.py:18: error: Variable "mixin_typing._Base" is not valid as a type  [valid-type]
    class SuffixFormMixin(_Base):
                          ^
mixin_typing.py:18: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases
mixin_typing.py:18: error: Invalid base class "_Base"  [misc]
    class SuffixFormMixin(_Base):

Однако добавление подсказки типа TypeAlias к _Base заставляет его работать (объясняется здесь):

if TYPE_CHECKING:
    _Base: TypeAlias = forms.Form
else:
    _Base = object

Возможно, я многое делаю неправильно (многие вещи, связанные с более глубокими подсказками, пролетают мимо моей головы), или, возможно, я упускаю что-то очевидное (это не должно быть так сложно), поэтому я буду рад любым предложениям.

Вернуться на верх