5.6. Protocol Descriptor¶
Add managed attributes to objects
Outsource functionality into specialized classes
Descriptors:
classmethod
,staticmethod
,property
, functions in general__del__(self)
is reserved when object is being deleted by garbage collector (destructor)__set_name()
After class creation, Python default metaclass will call it with parent and classname
5.6.1. Protocol¶
__get__(self, parent, *args) -> self
__set__(self, parent, value) -> None
__delete__(self, parent) -> None
__set_name__(self)
If any of those methods are defined for an object, it is said to be a descriptor.
—Raymond Hettinger
>>> class Descriptor:
... def __get__(self, parent, *args):
... return ...
...
... def __set__(self, parent, value):
... ...
...
... def __delete__(self, parent):
... ...
...
... def __set_name__(self, parent, classname):
... ...
5.6.2. Property vs Reflection vs Descriptor¶
Property:
>>> class Temperature:
... kelvin = property()
... _value: float
...
... @kelvin.setter
... def myattribute(self, value):
... if value < 0:
... raise ValueError
... else:
... self._value = value
Reflection:
>>> class Temperature:
... kelvin: float
...
... def __setattr__(self, attrname, value):
... if attrname == 'kelvin' and value < 0:
... raise ValueError
... else:
... super().__setattr__(attrname, value)
Descriptor:
>>> class Kelvin:
... def __set__(self, parent, value):
... if value < 0:
... raise ValueError
... else:
... parent._value = value
>>>
>>>
>>> class Temperature:
... kelvin = Kelvin()
... _value: float
5.6.3. Example¶
>>> class MyField:
... def __get__(self, parent, *args):
... print('Getter')
...
... def __set__(self, parent, value):
... print('Setter')
...
... def __delete__(self, parent):
... print('Deleter')
>>>
>>>
>>> class MyClass:
... value = MyField()
>>>
>>>
>>> my = MyClass()
>>>
>>> my.value = 'something'
Setter
>>>
>>> my.value
Getter
>>>
>>> del my.value
Deleter
5.6.4. Use Case - 0x01¶
Kelvin Temperature Validator
>>> class KelvinValidator:
... def __set__(self, parent, value):
... if value < 0.0:
... raise ValueError('Cannot set negative Kelvin')
... parent._value = value
>>>
>>>
>>> class Temperature:
... kelvin = KelvinValidator()
...
... def __init__(self):
... self._value = None
>>>
>>>
>>> t = Temperature()
>>> t.kelvin = -1
Traceback (most recent call last):
ValueError: Cannot set negative Kelvin
5.6.5. Use Case - 0x02¶
Temperature Conversion
>>> class Kelvin:
... def __get__(self, parent, *args):
... return round(parent._value, 2)
...
... def __set__(self, parent, value):
... parent._value = value
>>>
>>>
>>> class Celsius:
... def __get__(self, parent, *args):
... value = parent._value - 273.15
... return round(value, 2)
...
... def __set__(self, parent, value):
... parent._value = value + 273.15
>>>
>>>
>>> class Fahrenheit:
... def __get__(self, parent, *args):
... value = (parent._value - 273.15) * 9 / 5 + 32
... return round(value, 2)
...
... def __set__(self, parent, fahrenheit):
... parent._value = (fahrenheit - 32) * 5 / 9 + 273.15
>>>
>>>
>>> class Temperature:
... kelvin = Kelvin()
... celsius = Celsius()
... fahrenheit = Fahrenheit()
...
... def __init__(self):
... self._value = 0.0
>>>
>>>
>>> t = Temperature()
>>>
>>> t.kelvin = 273.15
>>> print(t.kelvin)
273.15
>>> print(t.celsius)
0.0
>>> print(t.fahrenheit)
32.0
>>>
>>> t.fahrenheit = 100
>>> print(t.kelvin)
310.93
>>> print(t.celsius)
37.78
>>> print(t.fahrenheit)
100.0
>>>
>>> t.celsius = 100
>>> print(t.kelvin)
373.15
>>> print(t.celsius)
100.0
>>> print(t.fahrenheit)
212.0
5.6.6. Use Case - 0x03¶
Value Range Descriptor
>>> class Value:
... MIN: int
... MAX: int
... name: str
... value: float
...
... def __init__(self, min, max):
... self.MIN = min
... self.MAX = max
...
... def __set__(self, instance, value):
... if self.MIN <= value < self.MAX:
... self.value = value
... else:
... raise ValueError(f'{self.name} ({value}) is not in range({self.MIN}, {self.MAX})')
...
... def __get__(self, instance, owner):
... return self.value
...
... def __delete__(self, instance):
... raise PermissionError
...
... def __set_name__(self, owner, name):
... self.name = name
>>>
>>>
>>> class KelvinTemperature:
... kelvin = Value(min=0, max=99999)
... celsius = Value(min=-273.15, max=99999)
>>>
>>>
>>> t = KelvinTemperature()
>>>
>>> t.kelvin = 10
>>> t.kelvin = -1
Traceback (most recent call last):
ValueError: kelvin (-1) is not in range(0, 99999)
>>>
>>> t.celsius = -273
>>> t.celsius = -274
Traceback (most recent call last):
ValueError: celsius (-274) is not in range(-273.15, 99999)
>>>
>>> print(t.kelvin)
10
>>> print(t.celsius)
-273
Note __repr__()
method and how to access Descriptor value.
>>> from dataclasses import dataclass
>>>
>>>
>>> @dataclass
... class ValueRange:
... name: str
... min: float
... max: float
... value: float = None
...
... def __set__(self, parent, value):
... if value not in range(self.min, self.max):
... raise ValueError(f'{self.name} is not between {self.min} and {self.max}')
... self.value = value
>>>
>>>
>>> class Astronaut:
... name: str
... age = ValueRange('Age', min=28, max=42)
... height = ValueRange('Height', min=150, max=200)
...
... def __init__(self, name, age, height):
... self.name = name
... self.height = height
... self.age = age
...
... def __repr__(self):
... name = self.name
... age = self.age.value
... height = self.height.value
... return f'Astronaut({name=}, {age=}, {height=})'
>>>
>>>
>>> Astronaut('Mark Watney', age=38, height=170)
Astronaut(name='Mark Watney', age=38, height=170)
>>>
>>> Astronaut('Melissa Lewis', age=44, height=170)
Traceback (most recent call last):
ValueError: Age is not between 28 and 42
>>>
>>> Astronaut('Rick Martinez', age=38, height=210)
Traceback (most recent call last):
ValueError: Height is not between 150 and 200
>>> from dataclasses import dataclass
>>>
>>>
>>> @dataclass
... class ValueRange:
... name: str
... min: int
... max: int
...
... def __set__(self, instance, value):
... print(f'Setter: {self.name} -> {value}')
>>>
>>>
>>> class Point:
... x = ValueRange('x', 0, 10)
... y = ValueRange('y', 0, 10)
... z = ValueRange('z', 0, 10)
...
... def __init__(self, x, y, z):
... self.x = x
... self.y = y
... self.z = z
...
... def __setattr__(self, attrname, value):
... print(f'Setattr: {attrname} -> {value}')
... super().__setattr__(attrname, value)
>>>
>>>
>>> p = Point(1,2,3)
Setattr: x -> 1
Setter: x -> 1
Setattr: y -> 2
Setter: y -> 2
Setattr: z -> 3
Setter: z -> 3
>>>
>>> p.notexisting = 1337
Setattr: notexisting -> 1337
5.6.7. Inheritance¶
class Validator:
attribute_name: str
def __set_name__(self, parent, attribute_name):
self.attribute_name = f'_{attribute_name}'
def __get__(self, parent, parent_type):
return getattr(parent, self.attribute_name)
def __delete__(self, parent):
setattr(parent, self.attribute_name, None)
class RangeValidator(Validator):
min: int | float
max: int | float
def __init__(self, min: float, max: float):
self.min = min
self.max = max
def __set__(self, parent, newvalue):
if self.min <= newvalue < self.max:
setattr(parent, self.attribute_name, newvalue)
else:
attr = f'{parent.__class__.__name__}.{self.attribute_name.removeprefix("_")}'
min = self.min
max = self.max
raise ValueError(f'{attr} value: {newvalue} is out of range {min=}, {max=}')
class Astronaut:
firstname: str
lastname: str
age: int = RangeValidator(min=27, max=50)
height: float = RangeValidator(min=150, max=200)
weight: float = RangeValidator(min=50, max=90)
_firstname: str
_lastname: str
_age: int
_height: float
_weight: float
astro = Astronaut()
5.6.8. Function Descriptor¶
Function are Descriptors too
>>> def hello():
... pass
>>>
>>>
>>> type(hello)
<class 'function'>
>>> hasattr(hello, '__get__')
True
>>> hasattr(hello, '__set__')
False
>>> hasattr(hello, '__delete__')
False
>>> hasattr(hello, '__set_name__')
False
>>> dir(hello)
['__annotations__', '__builtins__', '__call__', '__class__', '__closure__',
'__code__', '__defaults__', '__delattr__', '__dict__', '__dir__',
'__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__',
'__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__',
'__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__',
'__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__',
'__setattr__', '__sizeof__', '__str__', '__subclasshook__']
>>> class Astronaut:
... def hello(self):
... pass
>>>
>>> type(Astronaut.hello)
<class 'function'>
>>> hasattr(Astronaut.hello, '__get__')
True
>>> hasattr(Astronaut.hello, '__set__')
False
>>> hasattr(Astronaut.hello, '__delete__')
False
>>> hasattr(Astronaut.hello, '__set_name__')
False
>>> dir(Astronaut.hello)
['__annotations__', '__builtins__', '__call__', '__class__', '__closure__',
'__code__', '__defaults__', '__delattr__', '__dict__', '__dir__',
'__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__',
'__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__',
'__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__',
'__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__',
'__setattr__', '__sizeof__', '__str__', '__subclasshook__']
>>> class Astronaut:
... def hello(self):
... pass
>>>
>>> astro = Astronaut()
>>>
>>> type(astro.hello)
<class 'method'>
>>> hasattr(astro.hello, '__get__')
True
>>> hasattr(astro.hello, '__set__')
False
>>> hasattr(astro.hello, '__delete__')
False
>>> hasattr(astro.hello, '__set_name__')
False
>>> dir(astro.hello)
['__call__', '__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__',
'__func__', '__ge__', '__get__', '__getattribute__', '__gt__', '__hash__', '__init__',
'__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__',
'__repr__', '__self__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']
5.6.9. Use Case - 0x01¶
Timezone Converter Descriptor

Figure 5.6. Comparing datetime works only when all has the same timezone (UTC). More information in Stdlib Datetime Timezone¶
Descriptor Timezone Converter:
>>> from dataclasses import dataclass
>>> from datetime import datetime
>>> from zoneinfo import ZoneInfo
>>>
>>>
>>> class Timezone:
... def __init__(self, name):
... self.timezone = ZoneInfo(name)
...
... def __get__(self, parent, *args):
... utc = parent.utc.replace(tzinfo=ZoneInfo('UTC'))
... return utc.astimezone(self.timezone)
...
... def __set__(self, parent, new_datetime):
... local_time = new_datetime.replace(tzinfo=self.timezone)
... parent.utc = local_time.astimezone(ZoneInfo('UTC'))
>>>
>>>
>>> @dataclass
... class Time:
... utc = datetime.now(tz=ZoneInfo('UTC'))
... warsaw = Timezone('Europe/Warsaw')
... eastern = Timezone('America/New_York')
... pacific = Timezone('America/Los_Angeles')
>>>
>>>
>>> t = Time()
>>>
>>> # Gagarin's launch to space
>>> t.utc = datetime(1961, 4, 12, 6, 7)
>>>
>>> print(t.utc)
1961-04-12 06:07:00
>>> print(t.warsaw)
1961-04-12 07:07:00+01:00
>>> print(t.eastern)
1961-04-12 01:07:00-05:00
>>> print(t.pacific)
1961-04-11 22:07:00-08:00
>>>
>>>
>>> # Armstrong's first Lunar step
>>> t.warsaw = datetime(1969, 7, 21, 3, 56, 15)
>>>
>>> print(t.utc)
1969-07-21 02:56:15+00:00
>>> print(t.warsaw)
1969-07-21 03:56:15+01:00
>>> print(t.eastern)
1969-07-20 22:56:15-04:00
>>> print(t.pacific)
1969-07-20 19:56:15-07:00
5.6.10. Assignments¶
"""
* Assignment: Protocol Descriptor Simple
* Complexity: easy
* Lines of code: 9 lines
* Time: 13 min
English:
1. Define descriptor class `Kelvin`
2. Temperature must always be positive
3. Use descriptors to check boundaries at each value modification
4. All tests must pass
5. Run doctests - all must succeed
Polish:
1. Zdefiniuj klasę deskryptor `Kelvin`
2. Temperatura musi być zawsze być dodatnia
3. Użyj deskryptorów do sprawdzania zakresów przy każdej modyfikacji
4. Wszystkie testy muszą przejść
5. Uruchom doctesty - wszystkie muszą się powieść
Tests:
>>> import sys; sys.tracebacklimit = 0
>>> class Temperature:
... kelvin = Kelvin()
>>> t = Temperature()
>>> t.kelvin = 1
>>> t.kelvin
1
>>> t.kelvin = -1
Traceback (most recent call last):
ValueError: Negative temperature
"""
"""
* Assignment: Protocol Descriptor ValueRange
* Complexity: easy
* Lines of code: 9 lines
* Time: 13 min
English:
1. Define descriptor class `ValueRange` with attributes:
a. `name: str`
b. `min: float`
c. `max: float`
d. `value: float`
2. Define class `Astronaut` with attributes:
a. `age = ValueRange('Age', min=28, max=42)`
b. `height = ValueRange('Height', min=150, max=200)`
3. Setting `Astronaut` attribute should invoke boundary check of `ValueRange`
4. Run doctests - all must succeed
Polish:
1. Zdefiniuj klasę-deskryptor `ValueRange` z atrybutami:
a. `name: str`
b. `min: float`
c. `max: float`
d. `value: float`
2. Zdefiniuj klasę `Astronaut` z atrybutami:
a. `age = ValueRange('Age', min=28, max=42)`
b. `height = ValueRange('Height', min=150, max=200)`
3. Ustawianie atrybutu `Astronaut` powinno wywołać sprawdzanie zakresu z `ValueRange`
6. Uruchom doctesty - wszystkie muszą się powieść
Tests:
>>> import sys; sys.tracebacklimit = 0
>>> mark = Astronaut('Mark Watney', 36, 170)
>>> melissa = Astronaut('Melissa Lewis', 44, 170)
Traceback (most recent call last):
ValueError: Age is not between 28 and 42
>>> alex = Astronaut('Alex Vogel', 40, 201)
Traceback (most recent call last):
ValueError: Height is not between 150 and 200
"""
class ValueRange:
name: str
min: float
max: float
value: float
def __init__(self, name, min, max):
pass
class Astronaut:
age = ValueRange('Age', min=28, max=42)
height = ValueRange('Height', min=150, max=200)
"""
* Assignment: Protocol Descriptor Inheritance
* Complexity: medium
* Lines of code: 25 lines
* Time: 21 min
English:
1. Define class `GeographicCoordinate`
2. Use descriptors to check value boundaries
3. Run doctests - all must succeed
Polish:
1. Zdefiniuj klasę `GeographicCoordinate`
2. Użyj deskryptory do sprawdzania wartości brzegowych
3. Uruchom doctesty - wszystkie muszą się powieść
Tests:
>>> import sys; sys.tracebacklimit = 0
>>> place1 = GeographicCoordinate(50, 120, 8000)
>>> place1
Latitude: 50, Longitude: 120, Elevation: 8000
>>> place2 = GeographicCoordinate(22, 33, 44)
>>> place2
Latitude: 22, Longitude: 33, Elevation: 44
>>> place1.latitude = 1
>>> place1.longitude = 2
>>> place1
Latitude: 1, Longitude: 2, Elevation: 8000
>>> place2
Latitude: 22, Longitude: 33, Elevation: 44
>>> GeographicCoordinate(90, 0, 0)
Latitude: 90, Longitude: 0, Elevation: 0
>>> GeographicCoordinate(-90, 0, 0)
Latitude: -90, Longitude: 0, Elevation: 0
>>> GeographicCoordinate(0, +180, 0)
Latitude: 0, Longitude: 180, Elevation: 0
>>> GeographicCoordinate(0, -180, 0)
Latitude: 0, Longitude: -180, Elevation: 0
>>> GeographicCoordinate(0, 0, +8848)
Latitude: 0, Longitude: 0, Elevation: 8848
>>> GeographicCoordinate(0, 0, -10994)
Latitude: 0, Longitude: 0, Elevation: -10994
>>> GeographicCoordinate(-91, 0, 0)
Traceback (most recent call last):
ValueError: Out of bounds
>>> GeographicCoordinate(+91, 0, 0)
Traceback (most recent call last):
ValueError: Out of bounds
>>> GeographicCoordinate(0, -181, 0)
Traceback (most recent call last):
ValueError: Out of bounds
>>> GeographicCoordinate(0, +181, 0)
Traceback (most recent call last):
ValueError: Out of bounds
>>> GeographicCoordinate(0, 0, -10995)
Traceback (most recent call last):
ValueError: Out of bounds
>>> GeographicCoordinate(0, 0, +8849)
Traceback (most recent call last):
ValueError: Out of bounds
"""
class GeographicCoordinate:
def __str__(self):
return f'Latitude: {self.latitude}, ' +\
f'Longitude: {self.longitude}, ' +\
f'Elevation: {self.elevation}'
def __repr__(self):
return self.__str__()
"""
latitude - min: -90.0, max: 90.0
longitude - min: -180.0, max: 180.0
elevation - min: -10994.0, max: 8848.0
"""