Tpye hints for mixins that reference attributes of a third-party base class
I'm trying to add type hints to a mixin class that is to be used alongside an external, third-party class. That is, the mixin relies on members of the third-party class.
For example, a mixin for a Django form:
# 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
Understandably, mypy will complain about the add_suffix method:
"SuffixFormMixin" has no attribute "prefix"
IDE (PyCharm) will also throw a warning:
Unresolved attribute reference 'prefix' for class 'SuffixFormMixin'
Question:
Is there any "easy" solution that lets the mixin understand that self contains attributes/methods of forms.Form?
Here is a github issue that addresses this, but sadly didn't go anywhere: https://github.com/python/typing/issues/246
Maybe some kind of typing object or other mypy-fu that acts as a promise to the mixin class that the future "partner class" has the members that the mixin is using?
Attempted solutions
The suggestions I have found so far all have drawbacks:
Type-hinting self to the partner class
(works for mypy: ✅, doesn't work for IDE: ❌)
I have seen suggestions to type hint self to the class that the mixin will later be used with. Here forms.Form:
def add_suffix(self: forms.Form, field_name: str) -> str:
return f"{self.prefix}__{field_name}__{self.suffix}"
mypy no longer complains (although it really should?), but the IDE still does. This time about the suffix attribute:
Unresolved attribute reference 'suffix' for class 'Form'
Type-hinting self to future concrete class
(mypy: ❌, IDE: ✅)
This goes a bit further than the previous suggestion:
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
With this, the IDE can resolve all the attributes correctly, but mypy throws an error:
The erased type of self "mixin_typing.SuffixForm" is not a supertype of its class "mixin_typing.SuffixFormMixin"
Type-hinting self to a Union
(mypy: ❌, IDE: ✅)
def add_suffix(self: Union["SuffixFormMixin", forms.Form], field_name: str) -> str:
return f"{self.prefix}__{field_name}__{self.suffix}"
Again, the IDE understands what's up and can resolve attributes correctly, but mypy complains:
Item "SuffixFormMixin" of "SuffixFormMixin | Any" has no attribute "prefix"
Type-hinting self to a TypeVar
(mypy: ✅, IDE: ❌)
This suggests declaring the future base class as a TypeVar and using that as a hint on self.
FormType = TypeVar("FormType", bound=forms.Form)
...
def add_suffix(self: FormType, field_name: str) -> str:
return f"{self.prefix}__{field_name}__{self.suffix}"
This doesn't generate any warnings, but it seems that this more because, by using TypeVar, I'm hiding types rather than it being a valid solution? For instance, you can do this without mypy or the IDE complaining:
FormType = TypeVar("FormType", bound=forms.Form)
class SuffixFormMixin:
suffix: str
def this_will_explode(self: FormType) -> Any:
return self.suffix + 1 # str + int!
I guess the type for self.suffix is assumed to be Any, because it can't be resolved anymore? The IDE doesn't show any information on self.suffix either when hovering.
This isn't really workable either considering that mypy absolutely must catch a problem like that in the this_will_explode method.
Inherting a protocol
(mypy: ✅, IDE: ✅)
Declaring a Protocol and having the mixin inherit it, as described here, looks to be the most complete solution. The protocol describes bits of the partner class that the mixin is using.
# 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 is happy, IDE knows the types, but...:
- it requires adding classes
- the protocol must reflect the mixins partner class
forms.Formwhich I do not have control over - the protocol "blocks" the IDE from navigating to the actual implementation on the parent class (i.e. ctrl + click on
self.prefixjumps toHasPrefix.prefixnotforms.Form) - it doesn't actually run
An error occurs when creating the SuffixForm class:
>>> 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
So you need a workaround that switches the base class of the mixin depending on whether we are currently type checking:
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}"
I don't like this. It's even more code that only serves to provide type checking and now you need to explain why the mixin switches base class. Maybe this would work better in a stub file?
Conditionally inheriting from partner class
(mypy: ✅, IDE: ✅)
As suggested by a comment:
You can also use your latest approach without a protocol: use forms.Form or object as a base class conditionally. It still "needs to explain why the class base is dynamic", but in if TYPE_CHECKING case that should be pretty obvious.
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 doesn't like this:
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):
However, adding the TypeAlias type hint to _Base makes it work (explained here):
if TYPE_CHECKING:
_Base: TypeAlias = forms.Form
else:
_Base = object
There's probably quite a lot I do wrong here (a lot of the deeper type hinting stuff flies completely over my head), or maybe I'm missing something obvious (this shouldn't be so difficult), so I'd welcome any suggestion.