ruk·si

🐍 Python
Attribute Descriptors

Updated at 2018-06-10 23:41

Attribute descriptors allow reusing the same access logic in multiple attributes. For example, Django ORM field types are descriptors. Descriptor implements __get__, __set__ and __delete__.

from typing import Any

class Quantity:
    _counter: int = 0

    def __init__(self) -> None:
        # we construct a name for the wrapped attribute
        # so we don't overwrite other attributes of
        # this class in the instance
        prefix = self.__class__.__name__
        counter = self.__class__._counter
        self.storage_name = f'_{prefix}#{counter}'
        self.__class__._counter += 1

    def __get__(self, instance, owner: type) -> Any:
        assert owner == Weapon  # the managed class is also passed
        if instance is None:
            return self  # attribute is called through the class, not an instance
        else:
            return getattr(instance, self.storage_name)

    def __set__(self, instance, value: Any) -> None:
        if value > 0:
            setattr(instance, self.storage_name, value)
        else:
            raise ValueError('value must be > 0')

class Weapon:
    name: str
    price: int = Quantity()
    endurance: int = Quantity()

    def __init__(self, name: str, price: int, endurance: int):
        self.name = name
        self.price = price
        self.endurance = endurance

# Weapon('Nothing', 0) # => ValueError
knife = Weapon('Knife', 1, 50)
assert knife.price == 1 and knife.endurance == 50

sword = Weapon('Sword', 5, 100)
assert sword.price == 5 and sword.endurance == 100

assert isinstance(Weapon.price, Quantity)

Attribute descriptors are similar to property factories, but can be extended.

from typing import Any

class AutoStorage:
    storage_name: str
    _counter: int = 0

    def __init__(self) -> None:
        prefix = self.__class__.__name__
        counter = self.__class__._counter
        self.storage_name = f'_{prefix}#{counter}'
        self.__class__._counter += 1

    def __get__(self, instance, owner: type) -> Any:
        if instance is None:
            return self
        else:
            return getattr(instance, self.storage_name)

    def __set__(self, instance, value: Any):
        setattr(instance, self.storage_name, value)

import abc
from typing import Any, TypeVar

class Validation(abc.ABC, AutoStorage):

    def __set__(self, instance, value: Any) -> None:
        value = self.validate(instance, value)
        super().__set__(instance, value)

    T = TypeVar('T')

    @abc.abstractmethod
    def validate(self, instance, value: T) -> T:
        pass

from numbers import Real

class Quantity(Validation):

    def validate(self, instance, value: Real) -> Real:
        if value <= 0:
            raise ValueError('value must be > 0')
        return value

class NonBlank(Validation):

    def validate(self, instance, value: str) -> str:
        value = value.strip()
        if len(value) == 0:
            raise ValueError('value cannot be empty or blank')
        return value

import math

class Person:
    name: str = NonBlank()
    weight: int = Quantity()
    height: float = Quantity()

    def __init__(self, name: str, weight: int, height: float):
        self.name = name
        self.weight = weight
        self.height = height

    def bmi(self) -> float:
        return self.weight / math.pow(self.height, 2)

# Person('John', 60, 0)  # => ValueError
# Person('John', 60, 1.5)  # => ValueError
# Person('', 60, 1.5)  # => ValueError
assert round(Person('John', 120, 1.90).bmi(), 1) == 33.2
assert round(Person('Peter', 80, 1.70).bmi(), 1) == 27.7
assert round(Person('Elise', 60, 1.60).bmi(), 1) == 23.4

Descriptors can be overwritten by modifying the managed class. This is used in monkey patching and testing. But note that you can't overwrite __special_methods__.

from typing import Any

class Overriding:

    def __get__(self, instance, owner: type) -> None:
        print(f'{self.__class__.__name__}.__get__')

    def __set__(self, instance, value: Any) -> None:
        print(f'{self.__class__.__name__}.__set__')

class Managed:
    over: type = Overriding()

m = Managed()
m.over      # => Overriding.__get__
m.over = 1  # => Overriding.__set__

Managed.over = 5

assert m.over == 5
m.over = 10
assert m.over == 10

All methods are descriptors under the hood. Because all user-defined functions have a __get__ method. As they don't have __set__, they can overwritten by assignment.

Descriptors are also the reason methods get self as parameter. When a function is accessed through an instance, the call is bound to the managed instance.

import collections

class Text(collections.UserString):

    def reverse(self) -> 'Text':
        return self[::-1]

assert Text('123').reverse() == '321'
assert type(Text('123').reverse()) == Text

assert Text.reverse(Text('123')) == '321'
assert type(Text.reverse(Text('123'))) == Text

Sources

  • Fluent Python, Luciano Ramalho