Dátové triedy

ako na dátové triedy pomocou triedy BaseModel

Dátové triedy sú špeciálne triedy, ktoré slúžia na prenos dát. Obsahujú obyčajne iba členské premenné a minimum logiky. Často sa používajú na manažment konfigurácie alebo na správu serializovateľných údajov, ktoré sa napr. prenášajú prostredníctvom REST API alebo protokolom MQTT. Štandardná knižnica jazyka Python obsahuje priamu podporu v balíku dataclasses, ale najznámejšou knižnicou je rozhodne Pydantic.

Natívna podpora dátových tried v jazyku MicroPython však nie je a rovnako vlastnosti tohto jazyka obmedzujú ich plnohodnotné využívanie. Pre naše potreby budeme používať jednoduchú implementáciu v podobe triedy BaseModel z modulu models/base.py, ktorá je inšpirovaná práve knižnicou Pydantic. Jej kód je súčasťou kostry projektu chytrého zariadenia.

Vlastnosti triedy BaseModel

  • typová kontrola pri priradení hodnoty – typ členskej premennej určuje jej predvolená hodnota v triede, prípadne parameter type funkcie Field(); pri priradení hodnoty iného typu vznikne výnimka ValueError,
  • podrobnejší opis členskej premennej pomocou funkcie Field() – predvolená hodnota, továreň na predvolenú hodnotu (default_factory), typ a voliteľnosť,
  • povinné a voliteľné členské premenné – premenná bez predvolenej hodnoty je povinná, voliteľná premenná môže mať aj hodnotu None,
  • slot-like správanie – nie je možné pridávať nové atribúty, ktoré nie sú definované v triede (vznikne výnimka AttributeError),
  • vnorené dátové triedy – slovník priradený do členskej premennej typu BaseModel sa automaticky prevedie na objekt,
  • dekorátor validator na tvorbu vlastných funkcií na validáciu členských premenných – validácia sa spustí automaticky pri každom priradení novej hodnoty,
  • iterovateľnosť – iterácia vracia dvojice (názov, hodnota),
  • export dát do slovníka pomocou metódy .model_dump() – exportujú sa aj vnorené dátové triedy a zoznamy dátových tried,
  • reprezentácia objektu pomocou __repr__() v tvare ClassName(field=value, ...).

Vytvorenie triedy

Dátovú triedu vytvoríme ako potomka triedy BaseModel. Jej členské premenné uvedieme ako triedne premenné. Typ každej premennej určí jej predvolená hodnota, prípadne parameter type funkcie Field(). Typové anotácie (napr. name: str) slúžia len na lepšiu čitateľnosť kódu, pretože jazyk MicroPython ich pri behu ignoruje.

from models.base import BaseModel, Field


class Address(BaseModel):
    city: str = Field(type=str)
    zip: str = Field(type=str)


class Person(BaseModel):
    name: str = Field(type=str)
    age: int = 0
    email: str = Field(default=None, type=str, optional=True)
    address: Address = Field(default=None, type=Address, optional=True)

Členské premenné triedy Person ukazujú tri možnosti:

  • name – povinná premenná; nemá predvolenú hodnotu, preto ju treba zadať pri vytváraní objektu, inak vznikne výnimka TypeError,
  • age – premenná s predvolenou hodnotou; typ int určí hodnota 0,
  • email a address – voliteľné premenné; predvolená hodnota None typ určiť nevie, preto ho uvedieme parametrom type a parametrom optional povolíme, aby premenná mohla mať aj hodnotu None.

Ak do premennej address priradíme slovník, automaticky sa prevedie na objekt triedy Address.

Funkcia Field()

Funkcia Field() umožňuje podrobnejšie opísať členskú premennú. Má tieto parametre:

  • default – predvolená hodnota premennej,
  • default_factory – funkcia alebo trieda, ktorá predvolenú hodnotu vytvorí pri vytváraní každého objektu zvlášť,
  • type – typ premennej; použije sa vtedy, keď ho nie je možné určiť z predvolenej hodnoty (napr. pri None),
  • optional – ak je True, premenná môže mať okrem hodnoty daného typu aj hodnotu None.

Ak nie je uvedený ani parameter default, ani default_factory, premenná je povinná.

Parameter default_factory je potrebný pri hodnotách, ktoré sa dajú meniť – napr. pri zoznamoch alebo vnorených dátových triedach. Ak by sme použili default, všetky objekty by zdieľali ten istý zoznam či objekt a zmena v jednom by sa prejavila vo všetkých:

class WiFi(BaseModel):
    ssid: str = Field(default=None, type=str, optional=True)


class Settings(BaseModel):
    wifi: WiFi = Field(default_factory=WiFi, type=WiFi)
    tags: list = Field(default_factory=list)

Každý objekt triedy Settings tak dostane vlastný objekt WiFi aj vlastný prázdny zoznam tags.

Vytvorenie inštancie

Inštanciu je možné vytvoriť priamo pomocou konštruktora:

p = Person(
    name='Alice',
    age=30,
    address=Address(city='Paris', zip='12345')
)

Rovnako ju je možné vytvoriť rozbalením slovníka. Vnorený slovník sa pritom automaticky prevedie na objekt triedy Address:

>>> d = {
  'address': {
    'zip': '54321',
    'city': 'Kosice'
  },
  'age': 23,
  'name': 'Juraj'
}

>>> p2 = Person(**d)
>>> print(p2)
Person(age=23,email=None,address=Address(zip='54321',city='Kosice'),name='Juraj')

Ak pri vytváraní objektu neuvedieme povinnú premennú, vznikne výnimka TypeError:

>>> Person(age=23)
Traceback (most recent call last):
  ...
TypeError: Missing required field "name" for Person.

Čítanie hodnôt

>>> print(f'{p.name} is {p.age} years old and lives in {p.address.city}.')
Alice is 30 years old and lives in Paris.

Zápis hodnôt

Pri zápise hodnôt je dôležité, aby typ zapisovanej hodnoty zodpovedal typu členskej premennej. V opačnom prípade dôjde k výnimke ValueError:

# typ clenskej premennej p.age je int
>>> type(p.age)
<class 'int'>

# ak priradime hodnotu typu int, je vsetko v poriadku
>>> p.age = 32

# ak vsak priradime hodnotu ineho typu, dojde k vynimke
>>> p.age = 'twenty'
Traceback (most recent call last):
  ...
ValueError: Value "twenty" for attribute "age" is not of type "<class 'int'>".

Do voliteľnej členskej premennej je okrem hodnoty jej typu možné priradiť aj None:

>>> p.email = 'alice@example.com'
>>> p.email = None
>>> p.email = 23
Traceback (most recent call last):
  ...
ValueError: Value "23" for attribute "email" is not of type "<class 'str'>".

Do objektu nie je možné pridať členskú premennú, ktorá nie je definovaná v triede:

>>> p.phone = '0905 123 456'
Traceback (most recent call last):
  ...
AttributeError: Attribute "phone" is not in class Person.

Rovnako to platí aj pri vytváraní objektu – ak konštruktor alebo vnorený slovník obsahuje kľúč, ktorý nezodpovedá žiadnej členskej premennej, vznikne výnimka AttributeError:

>>> Person(name='Alice', phone='0905 123 456')
Traceback (most recent call last):
  ...
AttributeError: Attribute "phone" is not in class Person.

Validácia hodnôt

V triede, ktorá je potomkom triedy BaseModel, je možné vytvárať aj vlastné validátory členských premenných. Stačí vytvoriť metódu s dekorátorom @validator(), ktorý dostane názov validovanej premennej. Metóda dostane novú hodnotu a ak ju odmietne, vyvolá výnimku:

from models.base import BaseModel, Field, validator


class Person(BaseModel):
    name: str = Field(type=str)
    age: int = 0

    @validator('age')
    def check_age(self, value):
        if value < 0:
            raise ValueError('Age is negative.')

Validátor sa spustí pri každom priradení hodnoty, aj pri vytváraní objektu:

>>> p = Person(name='Alice', age=-1)
Traceback (most recent call last):
  ...
ValueError: Age is negative.

Validátor sa spustí ešte pred uložením hodnoty. Ak hodnotu odmietne, hodnota členskej premennej sa nezmení:

>>> p = Person(name='Alice', age=30)
>>> p.age = -5
Traceback (most recent call last):
  ...
ValueError: Age is negative.
>>> p.age
30

Opakovane použiteľné validátory

Rovnakú kontrolu niekedy potrebuje viacero členských premenných, prípadne aj viacero tried. Napríklad meno osoby ani názov mesta v adrese nesmú byť prázdne. Ak by sme takúto kontrolu skopírovali do každého validátora zvlášť, prípadnú opravu by sme museli robiť na viacerých miestach a kópie by sa časom mohli od seba líšiť.

Kontrolu preto presunieme do obyčajnej funkcie v samostatnom module validators.py balíka models. O takejto funkcii platí:

  • dostane len hodnotu, ktorú má skontrolovať – o dátovej triede nič nevie,
  • ak hodnota nie je platná, vyvolá výnimku ValueError so zrozumiteľnou správou,
  • nič nevracia.
# models/validators.py

def not_empty(value: str) -> None:
    """Checks that the string is not empty or made of spaces only.

    Raises:
        ValueError: If the string is empty.
    """
    if value.strip() == '':
        raise ValueError('Value must not be empty.')

Funkciu potom zavoláme z validátora každej členskej premennej, ktorá túto kontrolu potrebuje, a to v ľubovoľnej dátovej triede:

from models.base import BaseModel, Field, validator
from models.validators import not_empty


class Address(BaseModel):
    city: str = Field(type=str)
    zip: str = Field(type=str)

    @validator('city')
    def check_city(self, value):
        not_empty(value)


class Person(BaseModel):
    name: str = Field(type=str)
    age: int = 0

    @validator('name')
    def check_name(self, value):
        not_empty(value)

Obe triedy teraz prázdnu hodnotu odmietnu rovnakým spôsobom a hodnota členskej premennej sa nezmení:

>>> p = Person(name='Alice')
>>> p.name = '  '
Traceback (most recent call last):
  ...
ValueError: Value must not be empty.
>>> p.name
'Alice'
>>> Address(city='', zip='040 01')
Traceback (most recent call last):
  ...
ValueError: Value must not be empty.

Validátor môže zavolať aj viacero takýchto funkcií a doplniť ich vlastnou podmienkou, ktorá platí len pre danú triedu.

Serializácia a deserializácia

Objekt dátovej triedy je možné previesť na slovník pomocou metódy .model_dump(). Prevedú sa aj vnorené dátové triedy, takže výsledok je možné priamo uložiť napr. do súboru vo formáte JSON (serializácia):

import json

with open('person.json', 'w') as file:
    json.dump(p.model_dump(), file)

Opačným postupom je vytvorenie objektu zo slovníka, napr. načítaného zo súboru vo formáte JSON (deserializácia). Slovník rozbalíme do konštruktora triedy a vnorené slovníky sa automaticky prevedú na objekty vnorených dátových tried:

import json

with open('person.json') as file:
    p = Person(**json.load(file))

Pri deserializácii sa uplatnia všetky pravidlá triedy – typová kontrola, povinné premenné aj validátory. Ak slovník obsahuje kľúč, ktorý v triede nie je, vznikne výnimka AttributeError.