6.7. Descriptor¶
6.7.1. Rationale¶
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
6.7.2. 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):
...
6.7.3. 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
6.7.4. 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
6.7.5. Use Cases¶
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 = 10
print(t.kelvin)
# 10
t.kelvin = -1
# Traceback (most recent call last):
# ValueError: Cannot set negative Kelvin
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(f'K: {t.kelvin}') # 273.15
print(f'C: {t.celsius}') # 0.0
print(f'F: {t.fahrenheit}') # 32.0
print()
t.fahrenheit = 100
print(f'K: {t.kelvin}') # 310.93
print(f'C: {t.celsius}') # 37.78
print(f'F: {t.fahrenheit}') # 100.0
print()
t.celsius = 100
print(f'K: {t.kelvin}') # 373.15
print(f'C: {t.celsius}') # 100.0
print(f'F: {t.fahrenheit}') # 212.0
Value Range Descriptor.
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

Figure 6.23. Comparing datetime works only when all has the same timezone (UTC). More information in Datetime Timezone¶
Descriptor Timezone Converter:
from dataclasses import dataclass
from datetime import datetime
from pytz import timezone
class Timezone:
def __init__(self, name):
self.timezone = timezone(name)
def __get__(self, parent, *args):
return parent.utc.astimezone(self.timezone)
def __set__(self, parent, new_datetime):
local_time = self.timezone.localize(new_datetime)
parent.utc = local_time.astimezone(timezone('UTC'))
@dataclass
class Time:
utc = datetime.now(tz=timezone('UTC'))
warsaw = Timezone('Europe/Warsaw')
moscow = Timezone('Europe/Moscow')
est = Timezone('America/New_York')
pdt = Timezone('America/Los_Angeles')
t = Time()
print('Launch of a first man to space:')
t.moscow = datetime(1961, 4, 12, 9, 6, 59)
print(t.utc) # 1961-04-12 06:06:59+00:00
print(t.warsaw) # 1961-04-12 07:06:59+01:00
print(t.moscow) # 1961-04-12 09:06:59+03:00
print(t.est) # 1961-04-12 01:06:59-05:00
print(t.pdt) # 1961-04-11 22:06:59-08:00
print('First man set foot on a Moon:')
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.moscow) # 1969-07-21 05:56:15+03:00
print(t.est) # 1969-07-20 22:56:15-04:00
print(t.pdt) # 1969-07-20 19:56:15-07:00
6.7.6. Function Descriptor¶
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__', '__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__', '__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__']
6.7.7. Assignments¶
"""
* Assignment: Protocol Descriptor Simple
* Filename: protocol_descriptor_simple.py
* 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. Compare result with "Tests" section (see below)
Polish:
1. Zdefiniuj klasę deskryptor `Kelvin`
2. Temperatura musi być zawsze być dodatnia
3. Użyj deskryptorów do sprawdzania wartości granicznych przy każdej modyfikacji
4. Wszystkie testy muszą przejść
5. Porównaj wyniki z sekcją "Tests" (patrz poniżej)
Tests:
>>> 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
* Filename: protocol_descriptor_valuerange.py
* 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. Compare result with "Tests" section (see below)
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. Porównaj wyniki z sekcją "Tests" (patrz poniżej)
Tests:
>>> 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
"""
# Given
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
* Filename: protocol_descriptor_inheritance.py
* Complexity: medium
* Lines of code: 25 lines
* Time: 21 min
English:
1. Use data from "Given" section (see below)
2. Define class `GeographicCoordinate`
3. Use descriptors to check value boundaries
4. All tests must pass
5. Compare result with "Tests" section (see below)
Polish:
1. Użyj danych z sekcji "Given" (patrz poniżej)
2. Zdefiniuj klasę `GeographicCoordinate`
3. Użyj deskryptory do sprawdzania wartości brzegowych
4. Wszystkie testy muszą przejść
5. Porównaj wyniki z sekcją "Tests" (patrz poniżej)
Tests:
>>> 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
"""
# Given
class GeographicCoordinate:
def __str__(self):
return f'Latitude: {self.latitude}, Longitude: {self.longitude}, 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
"""