🐍 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