11.4. Dataclass Field

11.4.1. Rationale

def field(*,
          default: Any,
          default_factory: Any,
          init: Any = True,
          repr: Any = True,
          hash: Any = None,
          compare: Any = True,
          metadata: Any = None) -> None
  • default - Default value for the field

  • default_factory - Field factory

  • init - Use this field in __init__()

  • repr - Use this field in __repr__()

  • hash - Use this field in __hash__()

  • compare - Use this field in comparison functions (le, lt, gt, ge, eq, ne)

  • metadata - For storing extra information about field

11.4.2. Default

>>> from dataclasses import dataclass, field
>>>
>>>
>>> @dataclass
... class Astronaut:
...     firstname: str
...     lastname: str
...     mission: str = 'Ares3'
>>> from dataclasses import dataclass, field
>>>
>>>
>>> @dataclass
... class Astronaut:
...     firstname: str
...     lastname: str
...     mission: str = field(default='Ares3')

11.4.3. Default Factory

The following code will not work:

>>> from dataclasses import dataclass, field
>>>
>>>
>>> @dataclass
... class Astronaut:
...     firstname: str
...     lastname: str
...     missions: list[str] = ['Ares3', 'Apollo18']
Traceback (most recent call last):
ValueError: mutable default <class 'list'> for field missions is not allowed: use default_factory

If you want to create a list with default values, you have to create a field with default_factory=lambda: ['Ares3', 'Apollo18']. Lambda expression will be evaluated on field initialization.

>>> from dataclasses import dataclass, field
>>>
>>>
>>> @dataclass
... class Astronaut:
...     firstname: str
...     lastname: str
...     missions: list[str] = field(default_factory=lambda: ['Ares3', 'Apollo18'])
>>>
>>>
>>> Astronaut('Mark', 'Watney')
Astronaut(firstname='Mark', lastname='Watney', missions=['Ares3', 'Apollo18'])

11.4.4. Init

>>> from dataclasses import dataclass, field
>>> from typing import Final
>>>
>>>
>>> @dataclass
... class Astronaut:
...     firstname: str
...     lastname: str
...     age: int
...     AGE_MIN: Final[int] = field(default=27, init=False)
...     AGE_MAX: Final[int] = field(default=50, init=False)
>>>
>>>
>>> Astronaut('Mark', 'Watney', age=44)
Astronaut(firstname='Mark', lastname='Watney', age=44, AGE_MIN=27, AGE_MAX=50)

11.4.5. Repr

>>> from dataclasses import dataclass, field
>>> from typing import Final
>>>
>>>
>>> @dataclass
... class Astronaut:
...     firstname: str
...     lastname: str
...     age: int
...     AGE_MIN: Final[int] = field(default=27, init=False, repr=False)
...     AGE_MAX: Final[int] = field(default=50, init=False, repr=False)
>>>
>>>
>>> Astronaut('Mark', 'Watney', age=44)
Astronaut(firstname='Mark', lastname='Watney', age=44)

11.4.6. Metadata

  • Optional[dict]

  • None is treated as an empty dict

  • Metadata is not used at all by Data Classes

  • Metadata is provided as a third-party extension mechanism

Using Metadata for hints:

>>> from dataclasses import dataclass, field
>>>
>>>
>>> @dataclass
... class Astronaut:
...     firstname: str
...     lastname: str
...     height: float = field(default=None, metadata={'unit': 'cm'})
...     weight: float = field(default=None, metadata={'unit': 'kg'})

Using Metadata for validation:

>>> from dataclasses import dataclass, field
>>>
>>>
>>> @dataclass
... class Astronaut:
...     firstname: str
...     lastname: str
...     age: int = field(default=None, metadata={'min': 27, 'max': 50})
...
...     def __post_init__(self):
...         AGE_MIN = self.__dataclass_fields__['age'].metadata['min']
...         AGE_MAX = self.__dataclass_fields__['age'].metadata['max']
...
...         if self.age not in range(AGE_MIN, AGE_MAX):
...             raise ValueError('Invalid age')
>>>
>>>
>>> astro = Astronaut('Mark', 'Watney', age=99)
Traceback (most recent call last):
ValueError: Invalid age

11.4.7. Use Case - Validation

>>> from dataclasses import dataclass, field
>>> from typing import Optional, Union
>>> from datetime import date, time, datetime, timezone, timedelta
>>>
>>>
>>> @dataclass
... class Mission:
...     year: int
...     name: str
>>>
>>>
>>> @dataclass(frozen=True)
... class Astronaut:
...     firstname: str
...     lastname: str
...     born: date
...     job: str = 'astronaut'
...     agency: str = field(default='NASA', metadata={'choices': ['NASA', 'ESA']})
...     age: Optional[int] = None
...     height: Optional[Union[float,int]] = field(default=None, metadata={'unit': 'cm', 'min': 156, 'max': 210})
...     weight: Optional[Union[float,int]] = field(default=None, metadata={'unit': 'kg', 'min': 50, 'max': 90})
...     groups: list[str] = field(default_factory=lambda: ['astronauts', 'managers'])
...     friends: dict[str,str] = field(default_factory=dict)
...     assignments: Optional[list[str]] = field(default=None, metadata={'choices': ['Apollo18', 'Ares3', 'STS-136']})
...     missions: list[Mission] = field(default_factory=list)
...     experience: timedelta = timedelta(hours=0)
...     account_last_login: Optional[datetime] = None
...     account_created: datetime = datetime.now(tz=timezone.utc)
...     AGE_MIN: int = field(default=30, init=False, repr=False)
...     AGE_MAX: int = field(default=50, init=False, repr=False)
...
...     def __post_init__(self):
...         HEIGHT_MIN = self.__dataclass_fields__['height'].metadata['min']
...         HEIGHT_MAX = self.__dataclass_fields__['height'].metadata['max']
...         WEIGHT_MIN = self.__dataclass_fields__['weight'].metadata['min']
...         WEIGHT_MAX = self.__dataclass_fields__['weight'].metadata['max']
...         if not HEIGHT_MIN <= self.height < HEIGHT_MAX:
...             raise ValueError(f'Height {self.height} is not in between {HEIGHT_MIN} and {HEIGHT_MAX}')
...         if not WEIGHT_MIN <= self.weight < WEIGHT_MAX:
...             raise ValueError(f'Height {self.weight} is not in between {WEIGHT_MIN} and {WEIGHT_MAX}')
...         if self.age not in range(self.AGE_MIN, self.AGE_MAX):
...             raise ValueError('Age is not valid for an astronaut')
>>>
>>>
>>> astro = Astronaut(firstname='Mark',
...                   lastname='Watney',
...                   born=date(1961, 4, 12),
...                   age=44,
...                   height=175.5,
...                   weight=75.5,
...                   assignments=['STS-136'],
...                   missions=[Mission(2035, 'Ares 3'), Mission(1973, 'Apollo 18')])
>>>
>>> print(astro)  
Astronaut(firstname='Mark', lastname='Watney', born=datetime.date(1961, 4, 12),
          job='astronaut', agency='NASA', age=44, height=175.5, weight=75.5,
          groups=['astronauts', 'managers'], friends={}, assignments=['STS-136'],
          missions=[Mission(year=2035, name='Ares 3'), Mission(year=1973, name='Apollo 18')],
          experience=datetime.timedelta(0), account_last_login=None,
          account_created=datetime.datetime(1969, 7, 21, 2, 56, 15, 123456, tzinfo=datetime.timezone.utc))

11.4.8. Use Case - Setattr

>>> from dataclasses import dataclass, field
>>>
>>>
>>> @dataclass
... class Astronaut:
...     firstname: str
...     lastname: str
...     age: float = field(default=None, metadata={'unit': 'cm', 'min': 30, 'max': 50})
...     height: float = field(default=None, metadata={'unit': 'cm', 'min': 156, 'max': 210})
...     weight: float = field(default=None, metadata={'unit': 'kg', 'min': 50, 'max': 90})
...
...     def __setattr__(self, attrname, attrvalue):
...         if attrvalue is None:
...             return super().__setattr__(attrname, attrvalue)
...         try:
...             min = Astronaut.__dataclass_fields__[attrname].metadata['min']
...             max = Astronaut.__dataclass_fields__[attrname].metadata['max']
...         except KeyError:
...             # field does not have min and max metadata
...             pass
...         else:
...             assert min <= attrvalue < max, f'{attrname} value {attrvalue} is not between {min} and {max}'
...         finally:
...             super().__setattr__(attrname, attrvalue)
>>>
>>>
>>>
>>> Astronaut('Mark', 'Watney')
Astronaut(firstname='Mark', lastname='Watney', age=None, height=None, weight=None)
>>>
>>> Astronaut('Mark', 'Watney', age=44)
Astronaut(firstname='Mark', lastname='Watney', age=44, height=None, weight=None)
>>>
>>> Astronaut('Mark', 'Watney', age=44, height=175, weight=75)
Astronaut(firstname='Mark', lastname='Watney', age=44, height=175, weight=75)
>>>
>>> Astronaut('Mark', 'Watney', age=99)
Traceback (most recent call last):
AssertionError: age value 99 is not between 30 and 50
>>>
>>> Astronaut('Mark', 'Watney', age=44, weight=200)
Traceback (most recent call last):
AssertionError: weight value 200 is not between 50 and 90
>>>
>>> Astronaut('Mark', 'Watney', age=44, height=120)
Traceback (most recent call last):
AssertionError: height value 120 is not between 156 and 210

11.4.9. Assignments

Code 11.10. Solution
"""
* Assignment: Dataclass Field Addressbook
* Complexity: easy
* Lines of code: 12 lines
* Time: 8 min

English:
    1. Model `DATA` using `dataclasses`
    2. Create class definition, fields and their types
    3. Do not write code converting `DATA` to your classes
    4. Run doctests - all must succeed

Polish:
    1. Zamodeluj `DATA` wykorzystując `dataclass`
    2. Stwórz definicję klas, pól i ich typów
    3. Nie pisz kodu konwertującego `DATA` do Twoich klas
    4. Uruchom doctesty - wszystkie muszą się powieść

Tests:
    >>> import sys; sys.tracebacklimit = 0
    >>> from inspect import isclass
    >>> from dataclasses import is_dataclass

    >>> assert isclass(Astronaut)
    >>> assert isclass(Address)
    >>> assert is_dataclass(Astronaut)
    >>> assert is_dataclass(Address)

    >>> astronaut = Astronaut.__dataclass_fields__
    >>> address = Address.__dataclass_fields__

    >>> assert 'firstname' in astronaut, \
    'Class Astronaut is missing field: firstname'

    >>> assert 'lastname' in astronaut, \
    'Class Astronaut is missing field: lastname'

    >>> assert 'addresses' in astronaut, \
    'Class Astronaut is missing field: addresses'

    >>> assert 'street' in address, \
    'Class Address is missing field: street'

    >>> assert 'city' in address, \
    'Class Address is missing field: city'

    >>> assert 'post_code' in address, \
    'Class Address is missing field: post_code'

    >>> assert 'region' in address, \
    'Class Address is missing field: region'

    >>> assert 'country' in address, \
    'Class Address is missing field: country'

    >>> assert astronaut['firstname'].type is str, \
    'Astronaut.firstname has invalid type annotation, expected: str'

    >>> assert astronaut['lastname'].type is str, \
    'Astronaut.lastname has invalid type annotation, expected: str'

    >>> assert astronaut['addresses'].type.__name__ == 'list', \
    'Astronaut.addresses has invalid type annotation, expected: list[Address]'

    >>> assert address['street'].type is Optional[str], \
    'Address.street has invalid type annotation, expected: Optional[str]'

    >>> assert address['city'].type is str, \
    'Address.city has invalid type annotation, expected: str'

    >>> assert address['post_code'].type is Optional[int], \
    'Address.post_code has invalid type annotation, expected: Optional[int]'

    >>> assert address['region'].type is str, \
    'Address.region has invalid type annotation, expected: str'

    >>> assert address['country'].type is str, \
    'Address.country has invalid type annotation, expected: str'
"""
from dataclasses import dataclass, field
from typing import Optional


DATA = [
    {"firstname": "Jan", "lastname": "Twardowski", "addresses": [
        {"street": "Kamienica Pod św. Janem Kapistranem", "city": "Kraków",
         "post_code": 31008, "region": "Małopolskie", "country": "Poland"}]},

    {"firstname": "José", "lastname": "Jiménez", "addresses": [
        {"street": "2101 E NASA Pkwy", "city": "Houston", "post_code": 77058,
         "region": "Texas", "country": "USA"},
        {"street": "", "city": "Kennedy Space Center", "post_code": 32899,
         "region": "Florida", "country": "USA"}]},

    {"firstname": "Mark", "lastname": "Watney", "addresses": [
        {"street": "4800 Oak Grove Dr", "city": "Pasadena", "post_code": 91109,
         "region": "California", "country": "USA"},
        {"street": "2825 E Ave P", "city": "Palmdale", "post_code": 93550,
         "region": "California", "country": "USA"}]},

    {"firstname": "Иван", "lastname": "Иванович", "addresses": [
        {"street": "", "city": "Космодро́м Байкону́р", "post_code": None,
         "region": "Кызылординская область", "country": "Қазақстан"},
        {"street": "", "city": "Звёздный городо́к", "post_code": 141160,
         "region": "Московская область", "country": "Россия"}]},

    {"firstname": "Melissa", "lastname": "Lewis"},

    {"firstname": "Alex", "lastname": "Vogel", "addresses": [
        {"street": "Linder Hoehe", "city": "Köln", "post_code": 51147,
         "region": "North Rhine-Westphalia", "country": "Germany"}]}
]


class Address:
    ...


class Astronaut:
    ...