Python type hints: types for a descriptor
The descriptor protocol allow us to completely customize attribute access. Python’s documentation describes the protocol with types involved described with words. Let’s look at how we can write those as type hints.
Descriptor exemplum
Let’s add type hints to this complete descriptor, which validates that allows only positive numbers:
import math
class PositiveFloat:
def __set_name__(self, owner, name) -> None:
self.name = name
def __get__(self, obj, objtype=None):
if obj is None:
return self
return obj.__dict__[self.name]
def __set__(self, obj, value):
if value <= 0.0 or math.isnan(value) or math.isinf(value):
raise ValueError(f"{self.name} must be a positive real number.")
obj.__dict__[self.name] = value
def __delete__(self, obj) -> None:
if self.name not in obj.__dict__:
raise AttributeError(self.name)
del obj.__dict__[self.name]
A quick reminder of all these methods:
__set_name__()
is called when our descriptor is assigned to a class variable. It allows our descriptor instance to known which name it has been assigned to.__get__()
is called on attribute access. When accessed on the class,obj
isNone
, in which case it’s normal to return the descriptor instance. When accessed on an instance,obj
contains that instance.Our descriptor stores its value inside the instance
__dict__
, so its implementation is straightforward.__set__()
is called on setting a new value for the attribute.obj
is the instance.__delete__()
is called on attribute deletion.obj
is again the instance.
Descriptors do not need to implement all of these methods. We’re only using a complete example here to cover all the type hints.
We can check our descriptor in action with python -i example.py
:
>>> class Widget:
... rotations = PositiveFloat()
...
>>>
>>> widget = Widget()
>>> # Test set, get, delete happy paths
>>> widget.rotations = 12.0
>>> widget.rotations
12.0
>>> del widget.rotations
>>> # Setting to an invalid value:
>>> widget.rotations = -1.0
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/.../example.py", line 15, in __set__
raise ValueError(f"{self.name} must be a positive real number.")
ValueError: rotations must be a positive real number.
>>> # Deleting when it’s already been deleted:
>>> del widget.rotations
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/.../example.py", line 20, in __delete__
raise AttributeError(self.name)
AttributeError: rotations
Okay great, now how about them type hints?
Addimus Type Hints
Alright, when we add type hints we get:
from __future__ import annotations
import math
from typing import cast, overload
class PositiveFloat:
def __set_name__(self, owner: type[object], name: str) -> None:
self.name = name
@overload
def __get__(self, obj: None, objtype: None) -> PositiveFloat:
...
@overload
def __get__(self, obj: object, objtype: type[object]) -> float:
...
def __get__(
self, obj: object | None, objtype: type[object] | None = None
) -> PositiveFloat | float:
if obj is None:
return self
return cast(float, obj.__dict__[self.name])
def __set__(self, obj: object, value: float) -> None:
if value <= 0.0 or math.isnan(value) or math.isinf(value):
raise ValueError(f"{self.name} must be a positive real number.")
obj.__dict__[self.name] = value
def __delete__(self, obj: object) -> None:
if self.name not in obj.__dict__:
raise AttributeError(self.name)
del obj.__dict__[self.name]
Let’s look through this.
In
__set_name__()
, theowner
argument is the class that the descriptor is assigned to. We know it’s a class, but nothing more, so we can usetype[object]
to mean “any class derived fromobject
”, that is “any class”.name
is the name of the attribute, and we don’t need to return anythign.__get__()
is the most complicated. It requires use ofoverload()
to accurately cover the two ways it can be called. We spell out the two cases for calling on the class (obj: None
) and the instance (obj: object
). The implementation then unions the types for the two cases.We need to use
cast()
in ourreturn
statement, because Mypy cannot tell that the attribute in__dict__
must be afloat
. It assumes that a dynamic attribute fetch from__dict__
could beAny
. This is a reasonable assumption, but from our descriptor’s implementation we know the value should be a float. (...unless some naughty code changes__dict__
directly).Without
cast()
we would get the error:example.py:24: error: Returning Any from function declared to return "Union[PositiveFloat, float]"
Both
__set__()
and__delete__()
are more straightforward. We declare that the instance may be any type, and that the set value must be afloat
.
Phew.
Limited owner classes
Throughout the type hints we’ve used type[object]
for the type our descriptor is attached to, and object
for the type of the instance. This is because our descriptor doesn’t target attachment to any particular type.
If we want to limit our descriptor’s attachment to particular types, we need only edit every type hint to swap object
for that type. For example, imagine we introduce a class Validatable
. We would edit the types in __set_name__()
to read:
def __set_name__(self, owner: type[Validatable], name: str) -> None:
...
…and similarly for the other methods.
Then if we assigned our descriptor to a non-Validatable
class:
class Widget:
rotations = PositiveFloat()
widget = Widget()
widget.rotations = 12.0
…Mypy would complain about the overload
cases going unmatched:
$ mypy --strict example.py
example.py:46: error: No overload variant of "__get__" of "PositiveFloat" matches argument types "Widget", "Type[Widget]"
example.py:46: note: Possible overload variants:
example.py:46: note: def __get__(self, obj: None, objtype: None) -> PositiveFloat
example.py:46: note: def __get__(self, obj: Validatable, objtype: Type[Validatable]) -> float
example.py:46: error: Argument 1 to "__set__" of "PositiveFloat" has incompatible type "Widget"; expected "Validatable"
Found 2 errors in 1 file (checked 1 source file)
Note that Mypy doesn’t see any problem in the class definition, just when we use an instance.
Newly updated: my book Boost Your Django DX now covers Django 5.0 and Python 3.12.
One summary email a week, no spam, I pinky promise.
Related posts: