Reputation: 12940
I am using some classes that derived from a parent class (Widget
); among the children, some have certain attributes (posx
and posy
) but some don't.
import enum
from dataclasses import dataclass
from typing import List
class Color(enum.IntEnum):
GLOWING_IN_THE_DARK = enum.auto()
BROWN_WITH_RAINBOW_DOTS = enum.auto()
@dataclass
class Widget:
"""Generic class for widget"""
@dataclass
class Rectangle(Widget):
"""A Color Rectangle"""
posx: int
posy: int
width: int = 500
height: int = 200
color: Color = Color.BROWN_WITH_RAINBOW_DOTS
@dataclass
class Group(Widget):
children: List[Widget]
@dataclass
class Button(Widget):
"""A clickable button"""
posx: int
posy: int
width: int = 200
height: int = 100
label: str = "some label"
Even after doing some filtering with only widgets with these attributes, mypy
is not able to recognize that they should have.
Is there a way to indicate to mypy
that we have an object with a given attribute?
For example, the following function and call:
def some_function_that_does_something(widgets: List[Widget]):
"""A useful docstring that says what the function does"""
widgets_with_pos = [w for w in widgets if hasattr(w, "posx") and hasattr(w, "posy")]
if not widgets_with_pos:
raise AttributeError(f"No widget with position found among list {widgets}")
first_widget = widgets_with_pos[0]
pos_x = first_widget.posx
pos_y = first_widget.posy
print(f"Widget {first_widget} with position: {(pos_x, pos_y)}")
some_widgets = [Group([Rectangle(0, 0)]), Button(10, 10, label="A button")]
some_function_that_does_something(some_widgets)
would return a result as expected: Widget Button(posx=10, posy=10, width=200, height=100, label='A button') with position: (10, 10)
But mypy
would complain:
__check_pos_and_mypy.py:53: error: "Widget" has no attribute "posx"
pos_x = first_widget.posx
^
__check_pos_and_mypy.py:54: error: "Widget" has no attribute "posy"
pos_y = first_widget.posy
^
Found 2 errors in 1 file (checked 1 source file)
How to do?
Maybe, one way could be to change the design of the classes:
Widget
with the position (e.g. WidgetWithPos
)Rectangle
and Button
would derive from this classwidget_with_pos: List[WidgetWithPos] = ...
... however, I cannot change the original design of the classes and mypy
might still complain with something like:
List comprehension has incompatible type List[Widget]; expected List[WidgetWithPos]
Of course, we could put a bunch of # type:ignore
but that will clutter the code and I am sure there is a smarter way ;)
Thanks!
Upvotes: 4
Views: 3700
Reputation: 5020
Here's a small variation on Alex Waygood's answer, to remove the cast
. The trick is to put the @runtime_checkable decorator on the Protocol class. It simply makes isinstance()
do the hasattr()
checks.
import sys
from dataclasses import dataclass
from typing import List
# Protocol has been added in Python 3.8+
# so this makes the code backwards-compatible
# without adding any dependencies
# (typing_extensions is a MyPy dependency already)
if sys.version_info >= (3, 8):
from typing import Protocol, runtime_checkable
else:
from typing_extensions import Protocol, runtime_checkable
@dataclass
class Widget:
"""Generic class for widget"""
@runtime_checkable
class WithPos(Protocol):
"""Minimum interface of all widgets that have a position"""
posx: int
posy: int
def some_function_that_does_something(widgets: List[Widget]):
"""A useful docstring that says what the function does"""
widgets_with_pos = [w for w in widgets if isinstance(w, WithPos)]
if not widgets_with_pos:
raise AttributeError(f"No widget with position found among list {widgets}")
first_widget = widgets_with_pos[0]
pos_x = first_widget.posx
pos_y = first_widget.posy
print(f"Widget {first_widget} with position: {(pos_x, pos_y)}")
The following code (using the other sub-classes defined in the original question) passes MyPy:
w1 = Group([])
w2 = Rectangle(2, 3)
some_function_that_does_something([w1, w2])
Further reading
For reference, here are some of the links Alex included in his answer:
typing.Protocol
typing.Protocol
and explaining the concept of structural subtyping.Protocol
s and structural subtyping.Upvotes: 2
Reputation: 7559
I would use typing.Protocol
and typing.cast
to solve this. typing.Protocol
allows us to define "structural types" — types that are defined by attributes or properties rather than the classes they inherit from — and typing.cast
is a function that has no effect at runtime, but allows us to assert to the type-checker that an object has a certain type.
Note that Protocol
has been added in Python 3.8 so for 3.7 (3.6 does not support dataclasses
, although it also has a backport), we need to use typing_extensions
(which is a dependency of mypy
by the way).
import sys
from dataclasses import dataclass
from typing import cast, List
# Protocol has been added in Python 3.8+
if sys.version_info >= (3, 8):
from typing import Protocol
else:
from typing_extensions import Protocol
@dataclass
class Widget:
"""Generic class for widget"""
class WidgetWithPosProto(Protocol):
"""Minimum interface of all widgets that have a position"""
posx: int
posy: int
def some_function_that_does_something(widgets: List[Widget]):
"""A useful docstring that says what the function does"""
widgets_with_pos = [
cast(WidgetWithPosProto, w)
for w in widgets
if hasattr(w, "posx") and hasattr(w, "posy")
]
if not widgets_with_pos:
raise AttributeError(f"No widget with position found among list {widgets}")
first_widget = widgets_with_pos[0]
pos_x = first_widget.posx
pos_y = first_widget.posy
print(f"Widget {first_widget} with position: {(pos_x, pos_y)}")
This passes MyPy.
Further reading:
typing.Protocol
typing.Protocol
and explaining the concept of structural subtyping.Protocol
s and structural subtyping.typing.cast
.typing.cast
.Upvotes: 2