Les Crash-Devs d'un Passionné

Annotated avec pydantic

/Catégorie/python

Temps de lecture : 2 minutes

pydantic
est une excellente librairie qui permet de construire simplement un objet avec des attributs tout en se reposant sur le typage

clipboard
Copier le code
1
2
3
class User(BaseModel):
   id: int 
   name: str

Sa grande force est également la possibilité de faire de la validation de ses données. Ainsi en affectant à

User
un
id
qui n'est pas un entier, par exemple un
UUID
,
pydantic
me lèvera une exception

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class User(BaseModel):
    id: UUID
    name: str

User(id="7682927b-4e56-4769-a2dc-058bc4296f50", name="David")

User(id=12, name="David") # Lève l'erreur ci-dessous

# pydantic_core._pydantic_core.ValidationError: 1 validation error for User
#   id
#     UUID input should be a string, bytes or UUID object [type=uuid_type, input_value=12, input_type=int]

Pour des besoins spécifique on peut mettre en place une vérification statique comme

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from uuid import UUID

from pydantic import BaseModel, Field


class User(BaseModel):
    id: UUID
    name: str
    email: str = Field(pattern=r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$")


User(id="7682927b-4e56-4769-a2dc-058bc4296f50", name="David", email="david@domain.fr")
User(id="7682927b-4e56-4769-a2dc-058bc4296f50", name="David", email="david")


# pydantic_core._pydantic_core.ValidationError: 1 validation error for User
#   email
#      String should match pattern '^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' [type=string_pattern_mismatch, input_value='david', input_type=str]

On pourrait également vouloir filtrer sur les personnes majeures. Dans ce cas on introduit une logique, on va s'orienter vers un validateur

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from datetime import date
from uuid import UUID

from pydantic import BaseModel, field_validator


class User(BaseModel):
    id: UUID
    name: str
    birthday: date

    @field_validator("birthday")
    @classmethod
    def check_major(cls, value: date) -> date:
        today = date.today()
        age = (
            today.year
            - value.year
            - ((today.month, today.day) < (value.month, value.day))
        )

        if age < 18:
            raise ValueError("L'utilisateur doit être majeur")
        return value


User(
    id="7682927b-4e56-4769-a2dc-058bc4296f50",
    name="David",
    birthday=date(day=1, month=1, year=2000),
)
User(
    id="7682927b-4e56-4769-a2dc-058bc4296f50",
    name="David",
    birthday=date(day=1, month=1, year=2020),
)

# pydantic_core._pydantic_core.ValidationError: 1 validation error for User
#   birthday
#       Value error, L'utilisateur doit être majeur [type=value_error, input_value=datetime.date(2020, 1, 1), input_type=date]

Solution avec Annotated

Et après ce rappel, j'aimerais vous le présenter sous une autre approche. En effet, sur le dernier cas de

birthday
on introduit une logique spécifique sur cet attribut et pas dans l'objet User.

Il peut être intéressant de déporter cela pour avoir une séparation de la logique (aka SOLID)

C'est à ce moment que l'on va pouvoir utiliser

Annotated

clipboard
Copier le code
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from typing import Annotated
from pydantic import AfterValidator

# adult.py
from datetime import date
from typing import Annotated
from uuid import UUID

from pydantic import AfterValidator, BaseModel


def validate_age(value: date):
    if (date.today().year - value.year) < 18:
        raise ValueError("L'utilisateur doit être majeur")
    return value


LegalAgeDate = Annotated[date, AfterValidator(validate_age)]


# user.py
class User(BaseModel):
    id: UUID
    birthday: LegalAgeDate


User(
    id="7682927b-4e56-4769-a2dc-058bc4296f50",
    name="David",
    birthday=date(day=1, month=1, year=2000),
)
User(
    id="7682927b-4e56-4769-a2dc-058bc4296f50",
    name="David",
    birthday=date(day=1, month=1, year=2020),
)
pydantic_core._pydantic_core.ValidationError: 1 validation error for User
birthday
  Value error, L'utilisateur doit être majeur [type=value_error, input_value=datetime.date(2020, 1, 1), input_type=date]

Je trouve cette solution très élégante et pythonique. On se rapproche ainsi des concepts du DDD où ce

LegalAgeDate
est ce que l'on appelle un ValueObject