7.7. Descriptor¶
7.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
class Temperature:
kelvin = property()
_value: float
@kelvin.setter
def myattribute(self, value):
if value < 0:
raise ValueError
else:
self._value = value
class Temperature:
kelvin: float
def __setattr__(self, attrname, value):
if attrname == 'kelvin' and value < 0:
raise ValueError
else:
super().__setattr__(attrname, value)
class Kelvin:
def __set__(self, parent, value):
if value < 0:
raise ValueError
else:
parent._value = value
class Temperature:
kelvin = Kelvin()
_value: float
7.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):
...
7.7.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
7.7.4. 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
class ValueRange:
name: str
min: float
max: float
value: float
def __init__(self, name, min, max):
self.name = name
self.min = min
self.max = max
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('Mark Watney', age=44, height=170)
# Traceback (most recent call last):
# ValueError: Age is not between 28 and 42
Astronaut('Mark Watney', age=38, height=210)
# Traceback (most recent call last):
# ValueError: Height is not between 150 and 200

Figure 7.10. 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
7.7.5. Function Descriptor¶
class Astronaut:
def say_hello(self):
pass
Astronaut.say_hello
# <function __main__.Astronaut.say_hello(self)>
astro = Astronaut()
astro.say_hello
# <bound method Astronaut.say_hello of <__main__.Astronaut object at 0x10a270070>>
Astronaut.say_hello.__get__(astro, Astronaut)
# <bound method Astronaut.say_hello of <__main__.Astronaut object at 0x10a270070>>
type(Astronaut.say_hello)
# <class 'function'>
type(astro.say_hello)
# <class 'method'>
dir(Astronaut.say_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__']
dir(astro.say_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__']
7.7.6. Assignments¶
"""
* Assignment: Protocol Descriptor Simple
* Filename: protocol_descriptor_simple.py
* Complexity: easy
* Lines of code: 9 lines
* Time: 13 min
English:
1. Define class `Temperature`
2. Class stores values in Kelvins using descriptor
3. Temperature must always be positive
4. Use descriptors to check boundaries at each value modification
5. All tests must pass
6. Compare result with "Tests" section (see below)
Polish:
1. Zdefiniuj klasę `Temperature`
2. Klasa przetrzymuje wartości jako Kelwiny używając deskryptora
3. Temperatura musi być zawsze być dodatnia
4. Użyj deskryptorów do sprawdzania wartości granicznych przy każdej modyfikacji
5. Wszystkie testy muszą przejść
6. 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
"""