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

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. 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

7.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

7.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:

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
../_images/protocol-descriptor-timezone.png

Figure 7.1. 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 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.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__']

7.7.7. Assignments

Code 7.13. Solution
"""
* 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 wartości granicznych przy każdej modyfikacji
    4. Wszystkie testy muszą przejść
    5. Uruchom doctesty - wszystkie muszą się powieść

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.14. Solution
"""
* 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:
    >>> 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.15. Solution
"""
* Assignment: Protocol Descriptor Inheritance
* 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. Run doctests - all must succeed

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. Uruchom doctesty - wszystkie muszą się powieść

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
"""