4.9. Dataclass Metadata¶
metadata
- For storing extra information about fielddict | None
None
is treated as an emptydict
Metadata is not used at all by Data Classes
Metadata is provided as a third-party extension mechanism
Use Case: SQLAlchemy https://python.astrotech.io/database/sqlalchemy/model-dataclass.html
def field(*,
default: Any,
default_factory: Callable,
init: bool = True,
repr: bool = True,
hash: bool|None = None,
compare: bool = True,
metadata: dict = None) -> None
4.9.1. Syntax¶
>>> from dataclasses import dataclass, field
>>>
>>>
>>> @dataclass
... class Astronaut:
... firstname: str
... lastname: str
... age: int = field(metadata={'min': 27, 'max': 50})
... agency: str = field(metadata={'choices': ['NASA', 'ESA']})
... height: float = field(metadata={'unit': 'cm'})
... weight: float = field(metadata={'unit': 'kg'})
4.9.2. 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
4.9.3. Use Case - 0x01¶
Validation
>>> from dataclasses import dataclass, field, KW_ONLY
>>> from datetime import date, time, datetime, timezone, timedelta
>>>
>>>
>>> @dataclass
... class Mission:
... year: int
... name: str
>>>
>>>
>>> @dataclass(frozen=True)
... class Astronaut:
... firstname: str
... lastname: str
... _: KW_ONLY
... born: date
... job: str = 'astronaut'
... agency: str = field(default='NASA', metadata={'choices': ['NASA', 'ESA']})
... age: int | None = None
... height: int | float | None = field(default=None, metadata={'unit': 'cm', 'min': 156, 'max': 210})
... weight: int | float | None = 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: list[str] | None = field(default=None, metadata={'choices': ['Apollo18', 'Ares3', 'STS-136']})
... missions: list[Mission] = field(default_factory=list)
... experience: timedelta = timedelta(hours=0)
... account_last_login: datetime | None = 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')
>>>
>>>
>>> mark = 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(mark)
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))
4.9.4. Use Case - 0x02¶
Setattr
>>> from dataclasses import dataclass, field
>>>
>>>
>>> @dataclass
... class Astronaut:
... firstname: str
... lastname: str
... age: float = field(default=None, metadata={'unit': 'years', '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
4.9.5. Use Case - 0x03¶
>>> from dataclasses import dataclass, field
>>>
>>>
>>> @dataclass
... class Astronaut:
... firstname: str
... lastname: str
... age: int = field(default=None, metadata={'type': 'range', 'unit': 'years', 'min': 30, 'max': 50})
... height: float | None = field(default=None, metadata={'type': 'range', 'unit': 'cm', 'min': 156, 'max': 210})
... agency: str | None = field(default='NASA', metadata={'type': 'choices', 'options': ['NASA', 'ESA']})
...
... def _validate_range(self, attrname, value):
... min = self.__dataclass_fields__[attrname].metadata['min']
... max = self.__dataclass_fields__[attrname].metadata['max']
... if value and not min <= value <= max:
... raise ValueError(f'Attribute {attrname} is not between {min} and {max}')
...
... def _validate_choices(self, attrname, value):
... options = self.__dataclass_fields__[attrname].metadata['options']
... if options and value not in options:
... raise ValueError(f'Attribute {attrname} is not an option, choices are: {options}')
...
... def __setattr__(self, attrname, value):
... try:
... attrtype = self.__dataclass_fields__[attrname].metadata['type']
... except KeyError:
... return super().__setattr__(attrname, value)
... match attrtype:
... case 'range': self._validate_range(attrname, value)
... case 'choices': self._validate_choices(attrname, value)
... case _: raise NotImplementedError
>>>
>>>
>>> mark = Astronaut('Mark', 'Watney')
>>>
>>> mark
Astronaut(firstname='Mark', lastname='Watney', age=None, height=None, agency='NASA')
>>>
>>> mark.agency = 'ESA'
>>> mark.agency = 'Roscosmos'
Traceback (most recent call last):
ValueError: Attribute agency is not an option, choices are: ['NASA', 'ESA']
>>>
>>> mark.age = 40
>>> mark.age = 10
Traceback (most recent call last):
ValueError: Attribute age is not between 30 and 50
4.9.6. Use Case - 0x04¶
>>>
... from __future__ import annotations
... from dataclasses import dataclass, field
... from sqlalchemy import Column, ForeignKey, Integer, String
... from sqlalchemy.orm import registry, relationship
...
... mapper_registry = registry()
...
...
... @mapper_registry.mapped
... @dataclass
... class User:
... __tablename__ = "user"
... __sa_dataclass_metadata_key__ = "db"
...
... id: int = field(init=False, metadata={"db": Column(Integer, primary_key=True)})
... name: str = field(default=None, metadata={"db": Column(String(50))})
... fullname: str = field(default=None, metadata={"db": Column(String(50))})
... nickname: str = field(default=None, metadata={"db": Column(String(12))})
... addresses: list[Address] = field(default_factory=list, metadata={"db": relationship("Address")})
...
...
... @mapper_registry.mapped
... @dataclass
... class Address:
... __tablename__ = "address"
... __sa_dataclass_metadata_key__ = "db"
...
... id: int = field(init=False, metadata={"db": Column(Integer, primary_key=True)})
... user_id: int = field(init=False, metadata={"db": Column(ForeignKey("user.id"))})
... email_address: str = field(default=None, metadata={"db": Column(String(50))})
4.9.7. Assignments¶
"""
* 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:
a. Do not use Python 3.10 syntax for Optionals, ie: `str | None`
b. Use old style `Optional[str]` instead
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
a. Nie używaj składni Optionali z Python 3.10, np.: `str | None`
b. Użyj starego sposobu, tj. `Optional[str]`
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'
TODO: Add support for Python 3.10 Optional and Union syntax
"""
from dataclasses import dataclass, field
from typing import Optional
DATA = [
{"firstname": "Pan", "lastname": "Twardowski", "addresses": [
{"street": "Kamienica Pod św. Janem Kapistranem", "city": "Kraków",
"post_code": 31008, "region": "Małopolskie", "country": "Poland"}]},
{"firstname": "Mark", "lastname": "Watney", "addresses": [
{"street": "2101 E NASA Pkwy", "city": "Houston", "post_code": 77058,
"region": "Texas", "country": "USA"},
{"street": None, "city": "Kennedy Space Center", "post_code": 32899,
"region": "Florida", "country": "USA"}]},
{"firstname": "Melissa", "lastname": "Lewis", "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": "Rick", "lastname": "Martinez"},
{"firstname": "Alex", "lastname": "Vogel", "addresses": [
{"street": "Linder Hoehe", "city": "Köln", "post_code": 51147,
"region": "North Rhine-Westphalia", "country": "Germany"}]}
]
# Model `DATA` using `dataclasses`, do not use: `str | None` syntax
# type: Type
class Address:
...
# Model `DATA` using `dataclasses`, do not use: `str | None` syntax
# type: Type
class Astronaut:
...