4.3. Encapsulation 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 cls and classname

Descriptors are a powerful feature in Python that allow you to customize the behavior of attribute access on a class. They are used to define how an attribute is accessed, set, or deleted on an instance of a class.

In Python, every attribute access on an object is handled by a descriptor. A descriptor is an object that defines one or more of the following methods:

  • __get__(self, instance, owner) - used to get the value of the attribute.

  • __set__(self, instance, value) - used to set the value of the attribute.

  • __delete__(self, instance) - used to delete the attribute.

Descriptors can be used to implement a variety of features, such as lazy evaluation of attributes, type checking, and data validation. They are commonly used in frameworks like Django and Flask to provide database access and validation of user input.

4.3.1. Protocol

  • __get__(self, cls, *args) -> self

  • __set__(self, cls, value) -> None

  • __delete__(self, cls) -> 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, cls, *args):
...         return ...
...
...     def __set__(self, cls, value):
...         ...
...
...     def __delete__(self, cls):
...         ...
...
...     def __set_name__(self, cls, attrname):
...         ...

4.3.2. Example

>>> class MyField:
...     def __get__(self, cls, *args):
...         print('Getter')
...
...     def __set__(self, cls, value):
...         print('Setter')
...
...     def __delete__(self, cls):
...         print('Deleter')
>>>
>>>
>>> class MyClass:
...     value = MyField()
>>>
>>>
>>> my = MyClass()
>>>
>>> my.value = 'something'
Setter
>>>
>>> my.value
Getter
>>>
>>> del my.value
Deleter

4.3.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, cls, value):
...         if value < 0:
...             raise ValueError
...         else:
...             cls._value = value
>>>
>>>
>>> class Temperature:
...     kelvin = Kelvin()
...     _value: float

4.3.4. Inheritance

4.3.5. 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__',
 '__getstate__', '__globals__', '__gt__', '__hash__', '__init__',
 '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__',
 '__name__', '__ne__', '__new__', '__qualname__', '__reduce__',
 '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__',
 '__subclasshook__']
>>> class User:
...     def hello(self):
...         pass
>>>
>>> type(User.hello)
<class 'function'>
>>> hasattr(User.hello, '__get__')
True
>>> hasattr(User.hello, '__set__')
False
>>> hasattr(User.hello, '__delete__')
False
>>> hasattr(User.hello, '__set_name__')
False
>>> dir(User.hello)  
['__annotations__', '__builtins__', '__call__', '__class__', '__closure__',
 '__code__', '__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__',
 '__eq__', '__format__', '__ge__', '__get__', '__getattribute__',
 '__getstate__', '__globals__', '__gt__', '__hash__', '__init__',
 '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__',
 '__name__', '__ne__', '__new__', '__qualname__', '__reduce__',
 '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__',
 '__subclasshook__']
>>> class User:
...     def hello(self):
...         pass
>>>
>>> mark = User()
>>>
>>> type(mark.hello)
<class 'method'>
>>> hasattr(mark.hello, '__get__')
True
>>> hasattr(mark.hello, '__set__')
False
>>> hasattr(mark.hello, '__delete__')
False
>>> hasattr(mark.hello, '__set_name__')
False
>>> dir(mark.hello)  
['__call__', '__class__', '__delattr__', '__dir__', '__doc__', '__eq__',
 '__format__', '__func__', '__ge__', '__getattribute__', '__getstate__',
 '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__',
 '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__self__',
 '__setattr__', '__sizeof__', '__str__', '__subclasshook__']

4.3.6. Use Case - 0x01

  • Kelvin Temperature Validator

>>> class KelvinValidator:
...     def __set__(self, cls, value):
...         if value < 0.0:
...             raise ValueError('Cannot set negative Kelvin')
...         cls._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

4.3.7. Use Case - 0x02

  • Temperature Conversion

>>> class Kelvin:
...     def __get__(self, cls, *args):
...         return round(cls._value, 2)
...
...     def __set__(self, cls, value):
...         cls._value = value
>>>
>>>
>>> class Celsius:
...     def __get__(self, cls, *args):
...         value = cls._value - 273.15
...         return round(value, 2)
...
...     def __set__(self, cls, value):
...         cls._value = value + 273.15
>>>
>>>
>>> class Fahrenheit:
...     def __get__(self, cls, *args):
...         value = (cls._value - 273.15) * 9 / 5 + 32
...         return round(value, 2)
...
...     def __set__(self, cls, fahrenheit):
...         cls._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

4.3.8. Use Case - 0x03

  • Value Range Descriptor

>>> class Between:
...     def __init__(self, min, max):
...         self.min = min
...         self.max = max
...
...     def __set_name__(self, cls, attrname):
...         self.attrname = f'_{attrname}'
...
...     def __set__(self, cls, value):
...         if not self.min <= value <= self.max:
...             field = self.attrname.removeprefix('_')
...             raise ValueError(f'Value of field "{field}" is not between {self.min} and {self.max}')
...         setattr(cls, self.attrname, value)
...
...     def __get__(self, cls, clstype):
...         return getattr(cls, self.attrname)
...
...     def __delete__(self, cls):
...         setattr(cls, self.attrname, None)
>>>
>>>
>>> class User:
...     firstname: str
...     lastname: str
...     age = Between(30, 50)
...     height = Between(150, 210)
...     weight = Between(50, 90)
>>> mark = User()
>>>
>>> mark.firstname = 'Mark'
>>> mark.lastname = 'Watney'
>>> mark.age = 42
>>> mark.height = 178.0
>>> mark.weight = 75.5
>>> mark.age = 18
Traceback (most recent call last):
ValueError: Value of field "age" is not between 30 and 50
>>>
>>> mark.weight = 100
Traceback (most recent call last):
ValueError: Value of field "weight" is not between 50 and 90
>>>
>>> mark.height = 220
Traceback (most recent call last):
ValueError: Value of field "height" is not between 150 and 210

4.3.9. Use Case - 0x04

>>> import re
>>>
>>>
>>> class Validator:
...     def __set_name__(self, cls, attribute_name):
...         self.attrname_short = f'_{attribute_name}'
...         self.attrname_full = f'{cls.__name__}.{attribute_name}'
...
...     def __get__(self, cls, cls_type):
...         return getattr(cls, self.attrname_short)
...
...     def __delete__(self, cls):
...         setattr(cls, self.attrname_short, None)
>>>
>>>
>>> class Between(Validator):
...     def __init__(self, min, max):
...         self.min = min
...         self.max = max
...
...     def __set__(self, cls, value):
...         if self.min <= value < self.max:
...             setattr(cls, self.attrname_short, value)
...         else:
...             raise ValueError(f'{self.attrname_full} value: {value} '
...                              f'is not between {self.min} and {self.max}')
>>>
>>>
>>> class MaxLength(Validator):
...     def __init__(self, max_length):
...         self.max_length = max_length
...
...     def __set__(self, cls, value):
...         if len(value) <= self.max_length:
...             setattr(cls, self.attrname_short, value)
...         else:
...             raise ValueError(f'{self.attrname_full} value: {value} '
...                              f'is longer than {self.max_length}')
>>>
>>> class MatchesRegex(Validator):
...     def __init__(self, pattern):
...         self.pattern = pattern
...         self.regex = re.compile(pattern)
...
...     def __set__(self, cls, value):
...         if self.regex.match(value):
...             setattr(cls, self.attrname_short, value)
...         else:
...             raise ValueError(f'{self.attrname_full} value: {value} '
...                              f'does not match pattern: {self.pattern}')
>>>
>>>
>>> class User:
...     firstname: str = MaxLength(20)
...     lastname: str = MaxLength(30)
...     age: int = Between(30, 50)
...     height: float = Between(150, 210)
...     weight: float = Between(50, 90)
...     email: str = MatchesRegex('^[a-z]+@nasa.gov$')
>>> mark = User()
>>>
>>> mark.firstname = 'Mark'
>>> mark.lastname = 'Watney'
>>> mark.age = 42
>>> mark.height = 178.0
>>> mark.weight = 75.5
>>> mark.email = 'mwatney@nasa.gov'
>>> mark.firstname = 'MarkMarkMarkMarkMarkMark'
Traceback (most recent call last):
ValueError: User.firstname value: MarkMarkMarkMarkMarkMark is longer than 20
>>> mark.lastname = 'WatneyWatneyWatneyWatneyWatneyWatney'
Traceback (most recent call last):
ValueError: User.lastname value: WatneyWatneyWatneyWatneyWatneyWatney is longer than 30
>>> mark.age = 60
Traceback (most recent call last):
ValueError: User.age value: 60 is not between 30 and 50
>>> mark.height = 220
Traceback (most recent call last):
ValueError: User.height value: 220 is not between 150 and 210
>>> mark.weight = 100
Traceback (most recent call last):
ValueError: User.weight value: 100 is not between 50 and 90
>>> mark.email = 'invalid-email@nasa.gov'
Traceback (most recent call last):
ValueError: User.email value: invalid-email@nasa.gov does not match pattern: ^[a-z]+@nasa.gov$

4.3.10. Use Case - 0x05

>>> from abc import ABC, abstractmethod
>>> from dataclasses import InitVar, dataclass, field
>>> from typing import Any
>>> import re
>>>
>>>
>>> @dataclass
... class Validator(ABC):
...     fieldname: str = field(init=False, repr=False)
...
...     @property
...     @abstractmethod
...     def error(self) -> str:
...         raise NotImplementedError
...
...     @abstractmethod
...     def is_valid(self, value: Any) -> bool:
...         raise NotImplementedError
...
...     def __get__(self, instance, owner):
...         return getattr(instance, self.fieldname)
...
...     def __delete__(self, instance):
...         setattr(instance, self.fieldname, None)
...
...     def __set_name__(self, owner, name):
...         self.fieldname = f'_{name}'
...
...     def __set__(self, instance, value):
...         if not self.is_valid(value):
...             raise ValueError(self.error.format(**vars(self), value=value))
...         setattr(instance, self.fieldname, value)
...
...
>>> @dataclass
... class String(Validator):
...     max_length: int
...     error: str = 'Field `{fieldname}` value `{value}` is longer than {max_length}'
...
...     def is_valid(self, value) -> bool:
...         return len(value) <= self.max_length
...
...
>>> @dataclass
... class Integer(Validator):
...     min: int
...     max: int
...     error: str = 'Field `{fieldname}` value `{value}` is not in between {min} and {max}'
...
...     def is_valid(self, value) -> bool:
...         return value in range(self.min, self.max)
...
...
>>> @dataclass
... class Select(Validator):
...     options: list[str]
...     error: str = 'Field `{fieldname}` value `value` not in {options}'
...
...     def is_valid(self, value: Any) -> bool:
...         return value in self.options
...
>>> @dataclass
... class Email(Validator):
...     domain: str
...     error: str = 'Field `{fieldname}` value `{value}` does not ends with `{domain}`'
...
...     def is_valid(self, value) -> bool:
...         return value.endswith(self.domain)
...
...
>>> @dataclass
... class Regex(Validator):
...     pattern: InitVar[str]
...     regex: re.Pattern | None = field(default=None, init=False)
...     error: str = 'Field `{fieldname}` value `{value}` does not match regex `{regex.pattern}`'
...
...     def __post_init__(self, pattern: str):
...         self.regex = re.compile(pattern)
...
...     def is_valid(self, value: Any) -> bool:
...         return self.regex.match(value)
>>> class User:
...     firstname = String(max_length=10)
...     lastname = String(max_length=15)
...     age = Integer(min=0, max=130)
...     group = Select(options=['user', 'staff', 'admin'])
...     email = Email(domain='@nasa.gov')
...     phone = Regex(pattern='^\+1 \(\d{3}\) \d{3}-\d{4}$')

4.3.11. Use Case - 0x06

#%% Import

from abc import abstractmethod, ABC from dataclasses import dataclass, InitVar, field from typing import Literal from unittest import TestCase import re import string

#%% Implementation

@dataclass class Validator(ABC):

name: str = None

@property @abstractmethod def error(self) -> str: ...

@abstractmethod def is_valid(self, value) -> bool:

raise NotImplementedError

def __set_name__(self, owner, name):

self.fieldname = name

def __get__(self, instance, owner):

return instance.__dict__[self.fieldname]

def __delete__(self, instance):

instance.__dict__[self.fieldname] = None

def __set__(self, instance, value):
if not self.is_valid(value):

locals = self.__dict__ | {'value': value} raise ValueError(self.error.format(**locals)

instance.__dict__[self.fieldname] = value

@dataclass class String(Validator):

max_length: int = 255 error: str = 'Field {fieldname} value {value} is longer than {max_length}'

def is_valid(self, value) -> bool:

return len(value) <= self.max_length

@dataclass class Integer(Validator):

min_value: int = -256 max_value: int = 255 error: str = 'Field {fieldname} value {value} is not in between {min_value} and {max_value}'

def is_valid(self, value) -> bool:

return value in range(self.min_value, self.max_value)

@dataclass class Float(Validator):

min_value: int = float('-inf') max_value: int = float('inf') error: str = 'Field {fieldname} value {value} is not in between {min_value} and {max_value}'

def is_valid(self, value) -> bool:

return self.min_value <= value <= self.max_value

@dataclass class Select(Validator):

choices: list = field(default_factory=list) error: str = 'Field {fieldname} value value not in {choices}'

def is_valid(self, value) -> bool:

return value in self.choices

@dataclass class Email(Validator):

domain: str = '' error: str = 'Field {fieldname} value {value} is invalid'

def is_valid(self, value) -> bool:

invalid_characters = set(string.punctuation) invalid_characters.remove('@') invalid_characters.remove('.') return value.endswith(self.domain)

and value.count('@') == 1 and not any(char in invalid_characters for char in value)

@dataclass class Phone(Validator):

pattern: InitVar[str] = None regex: re.Pattern = field(default=None, init=False) error: str = 'Field {fieldname} value {value} does not match regex {regex.pattern}'

def __post_init__(self, pattern: str):

self.regex = re.compile(pattern)

def is_valid(self, value) -> bool:

result = self.regex.match(value) return result is not None

class User:

firstname: str = String(max_length=10) lastname: str = String(max_length=15) age: int = Integer(min_value=0, max_value=100) height: float = Float(min_value=100, max_value=220) weight: float = Float(min_value=40, max_value=150) group: Literal['user', 'staff', 'admin'] = Select(choices=['user', 'staff', 'admin']) email: str = Email(domain='nasa.gov') phone: str = Phone(pattern=r'^+d{0,3} (d{3}) d{3}-d{4}$')

#%% Usage

>>> mark = User()
>>> mark.firstname = 'Mark'
>>> mark.lastname = 'Watney'
>>> mark.email = 'mwatney@nasa.gov'
>>> mark.age = 41
>>> mark.group = 'user'
>>> mark.phone = '+1 (234) 567-8910'
>>>
>>> vars(mark)  
{'_firstname': 'Mark',
 '_lastname': 'Watney',
 '_email': 'mwatney@nasa.gov',
 '_age': 41,
 '_group': 'user',
 '_phone': '+1 (234) 567-8910'}
>>>
>>> mark.firstname
'Mark'
>>> mark.lastname
'Watney'
>>> mark.email
'mwatney@nasa.gov'
>>> mark.age
41
>>> mark.group
'user'
>>> mark.phone
'+1 (234) 567-8910'
>>> mark.firstname = 'MarkMarkMarkMark'
Traceback (most recent call last):
ValueError: Field `_firstname` value `MarkMarkMarkMark` is longer than 10
>>> mark.lastname = 'WatneyWatneyWatneyWatneyWatney'
Traceback (most recent call last):
ValueError: Field `_lastname` value `WatneyWatneyWatneyWatneyWatney` is longer than 15
>>> mark.age = 135
Traceback (most recent call last):
ValueError: Field `_age` value `135` is not in between 0 and 130
>>> mark.group = 'editors'
Traceback (most recent call last):
ValueError: Field `_group` value `value` not in ['user', 'staff', 'admin']
>>> mark.email = 'mwatney@gmail.com'
Traceback (most recent call last):
ValueError: Field `_email` value `mwatney@gmail.com` does not ends with `@nasa.gov`
>>> mark.phone = '+48 123 456 789'
Traceback (most recent call last):
ValueError: Field `_phone` value `+48 123 456 789` does not match regex `^\+1 \(\d{3}\) \d{3}-\d{4}$`
>>> vars(User)  
mappingproxy({'__module__': '__main__',
              'firstname': String(max_length=10, error='Field `{fieldname}` value `{value}` is longer than {max_length}'),
              'lastname': String(max_length=15, error='Field `{fieldname}` value `{value}` is longer than {max_length}'),
              'age': Integer(min=0, max=130, error='Field `{fieldname}` value `{value}` is not in between {min} and {max}'),
              'group': Select(options=['user', 'staff', 'admin'], error='Field `{fieldname}` value `value` not in {options}'),
              'email': Email(domain='@nasa.gov', error='Field `{fieldname}` value `{value}` does not ends with `{domain}`'),
              'phone': Regex(regex=re.compile('^\\+1 \\(\\d{3}\\) \\d{3}-\\d{4}$'), error='Field `{fieldname}` value `{value}` does not match regex `{regex.pattern}`'),
              '__dict__': <attribute '__dict__' of 'User' objects>,
              '__weakref__': <attribute '__weakref__' of 'User' objects>,
              '__doc__': None})

#%% Tests

class FirstnameTest(TestCase):
def setUp(self):

self.user = User()

def test_valid(self):

self.user.firstname = 'Mark' self.assertEqual(self.user.firstname, 'Mark')

def test_invalid(self):
with self.assertRaises(ValueError):

self.user.firstname = 'MarkMarkMarkMarkMark'

class LastnameTest(TestCase):
def setUp(self):

self.user = User()

def test_valid(self):

self.user.lastname = 'Watney' self.assertEqual(self.user.lastname, 'Watney')

def test_invalid(self):
with self.assertRaises(ValueError):

self.user.lastname = 'WatneyWatneyWatneyWatneyWatney'

class AgeTest(TestCase):
def setUp(self):

self.user = User()

def test_valid(self):

self.user.age = 40 self.assertEqual(self.user.age, 40)

def test_invalid(self):
with self.assertRaises(ValueError):

self.user.age = -1

with self.assertRaises(ValueError):

self.user.age = 120

class WeightTest(TestCase):
def setUp(self):

self.user = User()

def test_valid(self):

self.user.weight = 75.0 self.assertEqual(self.user.weight, 75.0)

def test_invalid(self):
with self.assertRaises(ValueError):

self.user.weight = 30.0

with self.assertRaises(ValueError):

self.user.weight = 160.0

class HeightTest(TestCase):
def setUp(self):

self.user = User()

def test_valid(self):

self.user.height = 175.0 self.assertEqual(self.user.height, 175.0)

def test_invalid(self):
with self.assertRaises(ValueError):

self.user.height = 50.0

with self.assertRaises(ValueError):

self.user.height = 230.0

class GroupTest(TestCase):
def setUp(self):

self.user = User()

def test_valid(self):

self.user.group = 'user' self.assertEqual(self.user.group, 'user')

def test_invalid(self):
with self.assertRaises(ValueError):

self.user.group = 'not-existing'

class EmailTest(TestCase):
def setUp(self):

self.user = User()

def test_valid(self):

self.user.email = 'mwatney@nasa.gov' self.assertEqual(self.user.email, 'mwatney@nasa.gov')

def test_invalid_domain(self):
with self.assertRaises(ValueError):

self.user.email = 'mwatney@nasa.pl'

with self.assertRaises(ValueError):

self.user.email = 'mwatney@nasa.gov.pl'

def test_invalid_format(self):
with self.assertRaises(ValueError):

self.user.email = 'mwatney@@nasa.gov'

def test_invalid_characters(self):
with self.assertRaises(ValueError):

self.user.email = 'm#watney@nasa.gov'

class PhoneTest(TestCase):
def setUp(self):

self.user = User()

def test_valid(self):

self.user.phone = '+1 (234) 567-8910' self.assertEqual(self.user.phone, '+1 (234) 567-8910')

def test_invalid(self):
with self.assertRaises(ValueError):

self.user.phone = '+1 234 567 8910'

with self.assertRaises(ValueError):

self.user.phone = '+1-234-567-8910'

with self.assertRaises(ValueError):

self.user.phone = '+1a234-567-8910'

4.3.12. Use Case - 0x07

  • Timezone Converter Descriptor

../../_images/encapsulation-descriptor-timezone.png

Figure 4.13. Comparing datetime works only when all has the same timezone (UTC). More information in Stdlib Datetime Timezone

>>> 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, cls, *args):
...         utc = cls.utc.replace(tzinfo=ZoneInfo('UTC'))
...         return utc.astimezone(self.timezone)
...
...     def __set__(self, cls, new_datetime):
...         local_time = new_datetime.replace(tzinfo=self.timezone)
...         cls.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

4.3.13. Assignments

Code 4.54. Solution
"""
* Assignment: Accessor Descriptor Simple
* Complexity: easy
* Lines of code: 9 lines
* Time: 5 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
"""


Code 4.55. Solution
"""
* Assignment: Accessor Descriptor ValueRange
* Complexity: medium
* 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 `User` with attributes:
        a. `age = ValueRange('Age', min=28, max=42)`
        b. `height = ValueRange('Height', min=150, max=200)`
    3. Setting `User` 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ę `User` z atrybutami:
        a. `age = ValueRange('Age', min=28, max=42)`
        b. `height = ValueRange('Height', min=150, max=200)`
    3. Ustawianie atrybutu `User` powinno wywołać sprawdzanie zakresu z `ValueRange`
    6. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0

    >>> mark = User('Mark Watney', 36, 170)

    >>> melissa = User('Melissa Lewis', 44, 170)
    Traceback (most recent call last):
    ValueError: Age is not between 28 and 42

    >>> alex = User('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

    def __init__(self, name, min, max):
        pass


class User:
    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'User({name=}, {age=}, {height=})'


Code 4.56. Solution
"""
* Assignment: Accessor Descriptor Inheritance
* Complexity: hard
* 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
"""