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
../../_images/datetime-compare.png

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

Code 7.31. Solution
"""
* 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
"""


Code 7.32. Solution
"""
* 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)


Code 7.33. Solution
"""
* 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
"""