Python Type Hints - How to Type a Descriptor

Now invoking The Scroll of Descriptors.

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:

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.

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.

Fin

May this scroll guide you in your Level 17+ Quest “Accurate Type Hints Everywhere”,

—Adam


Newly updated: my book Boost Your Django DX now covers Django 5.0 and Python 3.12.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: ,