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

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

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

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

8.3.4. Inheritance

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

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

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

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

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

8.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}$')
>>> 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})

8.3.11. Use Case - 0x07

  • Timezone Converter Descriptor

advanced/encapsulation/img/protocol-descriptor-timezone.png

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

8.3.12. Assignments

Code 8.40. 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 8.41. 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 8.42. 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
"""