Modèles et Validation de données en Python avec Pydantic
- 2023-07-28
- Publié par : Christophe DELEUZE
- Catégorie : Pydantic
La modélisation et la validation de données sont deux aspects généralement indispensables de tout projet. Le choix des bons outils et techniques peut faire une grande différence dans la qualité et la fiabilité de vos modèles ainsi que de vos API.
La modélisation des données (data modeling) est le processus qui consiste à décrire de manière simple et efficace la structure, les associations, les relations et les contraintes entre plusieurs données.
La validation de données est l’action de vérifier et valider la structure, les associations, les relations et les contraintes d’une donnée.
La combinaison modélisation et validation se retrouve nativement dans les ORM tel que SQLAlchemy. Dans ce type de Framework, une donnée est représentée, modélisée, sous forme d’un attribut d’une classe python auquel est associée sa description, ses contraintes, ses relations, etc. À partir du modèle de données, l’ORM construit dynamiquement des requêtes SQL et la validation / récupération des données est alors effectuée du côté de la base de données SQL.
Toutefois, la problématique de validation de données ne se restreint pas qu’au domaine des bases de données. Elle concerne tout ou partie d’un code exposé à des personnes tierces. Si demain la planète entière a accès à ma super fonction qui fait une division de deux entiers, attention à la casse quand un utilisateur va s’en servir en utilisant des chaines de caractères à la place des entiers (et c’est possible) ! Je vais donc avoir la nécessité de valider le type de la donnée avant de l’utiliser pour retourner le résultat ! Le résultat de la validation sera généralement une erreur proprement retournée avec un message explicite, ou une conversion / normalisation du type de la donnée pour pouvoir gérer tous les cas d’usage. Et si mon paramètre d’entrée est un dictionnaire, comment valider tout son contenu le plus rapidement dessus et être sûr de l’intégrité du contenu du dictionnaire ?
Au quotidien, la validation de données tel qu’illustré dans mon exemple précédent est une thématique récurrente dans tous les projets. À implémenter, c’est souvent ingrat et ce n’est pas passionnant. Pour aider le développeur, python met à disposition des annotations (commentaires) de types dont l’utilité est de nous aider à mieux suivre et comprendre les types de données. Ne serait-ce pas le top du top si ces annotations avaient aussi la capacité sur demande de valider les données qu’elles annotent ? Nous n’aurions donc plus à écrire nous-mêmes le gros de la validation et en plus cela nous forcerait à écrire du code propre et élégant en nous forçant à utiliser les annotations de types. Cet outil existe et c’est Pydantic, une bibliothèque Python qui fournit un moyen simple et efficace de définir et de valider des modèles de données.
Qu'est ce que pydantic ?
Pydantic est une bibliothèque qui a été créée pour définir et valider des modèles de données en Python. Elle est construite sur le même principe que les dataclasses natives de Python et offre un certain nombre de fonctionnalités et d’avantages supplémentaires, tels que :
- Annotations de type : Pydantic utilise les annotations de type pour définir les champs et les types de votre modèle de données. Cela nous aide non seulement à documenter et à comprendre votre modèle, mais permet également à Pydantic de valider automatiquement les données et de générer des erreurs utiles en cas de problème ;
- Contraintes et valeurs par défaut : Pydantic permet de définir des contraintes et des valeurs par défaut pour chaque champ de notre modèle. Cela peut nous aider à nous assurer que les données sont toujours valides et complètes, et peut nous faire économiser beaucoup de temps et d’efforts dans la validation et la gestion des erreurs ;
- Sérialisation et dé-sérialisation : Pydantic fournit une prise en charge intégrée pour la sérialisation et la dé-sérialisation de nos modèles de données vers et depuis différents formats, tels que JSON, YAML et XML. Cela peut faciliter le travail avec nos données dans différents contextes et environnements.
pip install pydantic
Cas d'usage simple - validation d'arguments
Après avoir installé pydantic, nous pouvons rentrer dans le code et reprendre l’exemple abordé en introduction de cet article pour valider les arguments d’une fonction. Comparons une même fonction sans et avec validation.
D’abord sans :
def division(a: int, b: int) -> float:
print(f"le type de 'a' est '{type(a)}' et le type de b est '{type(a)}'.")
return a / b
>>> division(1, 2)
le type de 'a' est <class 'int'> et le type de 'b' est <class 'int'>.
0.5
>>> division(1, "2")
le type de 'a' est <class 'int'> et le type de 'b' est <class 'str'>.
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[16], line 1
----> 1 division(1, "2")
Cell In[12], line 4, in division(a, b)
2 if not b:
3 return float('inf')
----> 4 return a / b
TypeError: unsupported operand type(s) for /: 'int' and 'str'
Comme attendu, une erreur de type a été soulevée au niveau de la division.
Ne pas l’avoir anticipé n’est pas souhaitable. Évitons cette erreur en validant préalablement les données à l’aide du décorateur @validate_call de pydantic V2.x ou @validate_arguments de la version V1.x :
from pydantic import validate_call
@validate_call
def division(a: int, b: int) -> float:
print(f"le type de 'a' est '{type(a)}' et le type de b est '{type(a)}'.")
return a / b
Le décorateur @validate_call va utiliser les annotations de types de chaque paramètre pour effectuer des validations. Pydantic validera automatiquement les données pour s’assurer qu’elles ont toujours le type correspondant.
>>> division(1, 2)
le type de 'a' est <class 'int'> et le type de 'b' est <class 'int'>.
0.5
>>> division(1, "2")
le type de 'a' est <class 'int'> et le type de 'b' est <class 'int'>.
0.5
>>> division(1, "a")
ValidationError: 1 validation error for Division
b
value is not a valid integer (type=type_error.integer)
Dans cet exemple, la validation de données a parfaitement fonctionné et a permis à la fois d’anticiper les erreurs de type et de faire de la conversion automatique de type quand celle-ci était possible et cohérente.
Avertissement : Toutefois, garder toujours à l’esprit que faire de la validation de données à toujours un coût en termes de performance. L’utilisation de ce décorateur dépendra donc du contexte, comme pour protéger des fonctions exposées d’une API ou pour résoudre des problèmes complexes de validation de données.
Cas d'usage simple - modèles de données
Maintenant que nous avons compris ce qu’est la validation de données, que devons-nous faire pour valider, par exemple, des données complexes hiérarchisées ?
La seule chose dont nous allons avoir besoin sera de simplement représenter la donnée au travers de ce que l’on appelle : un modèle de données. Rentrons plus en détail dans le cœur nucléaire de pydantic et créons un modèle de données :
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
age: int
email: str
Ce modèle simple définit une classe User (utilisateur) avec quatre champs : id, name, age, email. Les types de champs sont spécifiés à l’aide d’annotations de type et seront utilisés par pydantic pour valider automatiquement le type des données correspondantes. Nous pouvons ensuite créer des instances de ce modèle en transmettant les données en tant qu’arguments avec mots clés :
user = User(id=1, name='Développeur', age=37, email='contact@pythoniste.fr')
Pydantic validera les données et génèrera une erreur s’il y a des problèmes, tels que des champs manquants ou invalides. Nous pouvons également définir des contraintes et des valeurs par défaut pour nos champs en utilisant les contraintes et les arguments par défaut :
from pydantic import BaseModel
from datetime import datetime
class User(BaseModel):
id: int
name: str
age: int = None
email: str
password: str = None
created_at: datetime = None
Dans ce cas, les champs age, password et created_at sont facultatifs et ont comme valeur par défaut None.
Pour nous en assurer, nous pouvons sérialiser et désérialiser nos modèles de données en utilisant la méthode .dict() :
>>> user.dict()
{'id': 1,
'name': 'Développeur',
'age': 37,
'email': 'contact@pythoniste.fr',
'password': None,
'created_at': None}
En bonus, la méthode .dict() inclut aussi la possibilité d’exclure les champs ayant pour valeur None à l’aide de l’argument exclude_none.
>>> user.dict(exclude_none=True)
{'id': 1,
'name': 'Développeur',
'age': 37,
'email': 'contact@pythoniste.fr'}
À noter que l’argument exclude_unset existe aussi pour exclure les champs non initialisés dans le modèle.
Enfin, nous pouvons également sérialiser et désérialiser vers et depuis d’autres formats, tels que JSON, YAML et XML, en utilisant respectivement les méthodes .json(), .yaml() et .xml().
Parser et valider des données JSON
JSON est un format standard d’échange de données sur le web. La plupart des développeurs travaillant avec des données ont forcément été amenées à travailler avec. JSON, à l’image du dictionnaire (dict) en Python, permet de proposer d’échanger des données de manière structurée. Ce standard est devenu incontournable quand on travaille avec des API web par exemple et pour lesquelles nous attendons une structure de données cohérente que l’on puisse utiliser en toute sécurité.
Imaginons que nous souhaitons récupérer des données à partir d’une API qui fonctionne comme un répertoire d’utilisateurs (Users). Ensuite, pour un point de terminaison spécifique (endpoint) qui va nous permettre de récupérer les informations d’un utilisateur précis, nous attendons le contrat de données suivant :
{
"name": string,
"age": integer,
"address": {
"city": string,
"zip_code": string,
"number": integer
}
}
La structure définit une entrée user avec la définition imbriquée d’une adresse : address. Résoudre la problématique de validation de ce JSON consistera donc à simplement définir deux modèles Pydantic :
from pydantic import BaseModel
class Address(BaseModel):
"""User API Address definition."""
city: str
zip_code: str
street_number: int
street_name: str
class User(BaseModel):
"""User API Request definition."""
name: str
age: int
address: Address
Dans ce scénario, les définitions ne nécessitaient qu’un seul niveau d’imbrication, mais Pydantic permet des combinaisons simples de n’importe quel nombre de modèles.
Naturellement, l’étape suivante consiste à analyser / valider les données fournies à l’aide de ces schémas, en transformant un JSON brut en un objet Python spécifique avec lequel nous pourrons jouer.
json = {
"name": "Pythoniste",
"age": 37,
"address": {
"city": "Nîmes",
"zip_code": "30000",
"street_number": 1,
"street_name": "Bd des Arènes"
}
}
user = User.parse_obj(json)
user.name # Pythoniste
user.address.street_number # 1
Vous remarquerez que tous les champs ont été transformés en attributs qui eux-mêmes, grâce aux modèles, sont connus de l’interpréteur Python et des IDE, ce qui est super pratique pour développer ! Jusqu’à présent, nous avons suivi un chemin heureux. Cependant, allons plus loin : Que se passe-t-il si l’API qui nous fournit les JSON commence à envoyer des données différentes ? Lorsque tout est automatisé, les équipes doivent s’assurer qu’elles peuvent détecter rapidement les problèmes et y réagir en toute sécurité.
Voyons ce qui se passe si le contrat est rompu sans mise à jour :
mauvais_json = {
"name": "Pythoniste",
"age": "42 est la bonne réponse",
"address": {
"city": "Nîmes",
"zip_code": "30000",
"street_number": 1,
"street_name": "Bd des Arènes"
}
}
try:
user = User.parse_obj(mauvais_json)
except ValidationError as err:
print("Il y a de la casse dans le JSON !")
Pydantic à la rescousse !
Comme le champ age est défini comme un int, mais que nous avons reçu un str, l’extrait de code lèvera une ValidationError (erreur de validation).
Cette même exception de validation sera aussi levée pour d’autres types d’incohérences, comme un champ manquant (s’il n’est pas défini comme Optional – facultatif).
Casser le contrat
Et si c’était exactement le contraire qui se produisait ? Que se passerait-il si l’API commençait à ajouter des champs qui ne faisaient pas partie du contrat ? Si ce scénario n’est pas surveillé correctement, il se peut que nous passions à côté de bugs cachés.
json = {
"name": "Pythoniste",
"age": 37,
"doudou": "chat",
"address": {
"city": "Nîmes",
"zip_code": "30000",
"street_number": 1,
"street_name": "Bd des Arènes",
}
}
user = User.parse_obj(json)
user.name # Pythoniste
user.address.street_number # 1
Cet extrait fonctionnera parfaitement et l’objet de données ne contiendra que les champs définis, bien que cela masque certaines incohérences. Mais ce n’est pas parce qu’il n’y a pas d’exception dans un morceau de code spécifique que l’ensemble du processus fonctionne comme prévu. Bien qu’il s’agisse du comportement par défaut dans Pydantic, nous pouvons modifier les configurations pour interdire l’envoi de tout champ supplémentaire à la définition de classe :
from pydantic import BaseModel, Extra, Field
class Address(BaseModel):
"""User API Address definition."""
# Note : Il est même possible d'ajouter une description sur les champs !
city: str = Field(..., description="Ville d'habitation de l'utilisateur.")
zip_code: str
street_number: int
street_name: str
class Config:
extra = Extra.forbid
class User(BaseModel):
"""User API Request definition."""
name: str
age: int
address: Address
class Config:
extra = Extra.forbid
Ce petit changement dans la configuration de la classe génèrera désormais une ValidationError (erreur de validation) lors de l’analyse des données avec des champs supplémentaires :
ValidationError: 1 validation errors for User
doudou
extra fields not permitted (type=value_error.extra)
Interopérabilité
Imaginons un contexte ou nous avons des plates-formes et des équipes distinctes qui doivent partager des définitions de données globales entre des systèmes distincts. Ce type de contrainte, courante dans le monde du développement informatique, peut vite être un casse-tête à gérer, car il faut s’assurer que les architectures puissent partager et évoluer en toute sécurité à partir de schémas centraux et uniques qui crééront une source unique de vérité.
Une façon de parvenir à une solution, indépendamment du langage, consiste à définir des structures de données en tant que schémas JSON. Heureusement, cela ne retire pas Pydantic de l’équation, car il existe un projet fantastique qui aide les développeurs à traduire les schémas JSON directement en modèles Pydantic : datamodel-codegen.
Reprenons l’exemple précédemment utilisé et construisons un schéma JSON avec :
{
"$id": "https://pythoniste.fr/schema/api/data/userRequest.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "userRequest",
"description": "User API request definition.",
"type": "object",
"definitions": {
"address": {
"description": "Defines the city, code and street number and name.",
"type": "object",
"properties": {
"city": {
"description": "User's city",
"type": "string"
},
"zip_code": {
"description": "City postal code",
"type": "string"
},
"street_number": {
"description": "House street number",
"type": "integer"
},
"street_name": {
"description": "House street name",
"type": "string"
}
},
"additionalProperties": false
}
},
"properties": {
"name": {
"description": "User's name.",
"type": "string"
},
"age": {
"description": "User's age in years.",
"type": "integer"
},
"address": {
"description": "Where does the user live.",
"$ref": "#/definitions/address"
}
},
"required": ["name"],
"additionalProperties": false
}
Ici, nous avons défini notre schéma User en fournissant non seulement ses propriétés : name, age et adress, mais également la possibilité d’écrire les définitions imbriquées en une seule fois.
Deux caractéristiques précieuses des schémas JSON sont :
– La possibilité de sélectionner les champs obligatoires et facultatifs en transmettant un tableau de noms de champs obligatoires. Pour les besoins de la démonstration, nous avons juste choisi le name comme étant incontournable.
– À l’aide de additionalProperties, les développeurs peuvent facilement contrôler que ces définitions ne deviennent pas des vidages clé-valeur. Définir un contrat n’est utile que si nous pouvons nous assurer qu’il tient.
Convertissons maintenant le schéma en classes Pydantic en utilisant la CLI datamodel-codegen :
pip install datamodel-code-generator
datamodel-codegen --input user.json --input-file-type jsonschema --output user.py
Qui stocke les définitions des modèles dans cat.py, non seulement en ajoutant les configurations, mais avec une saisie correcte dans les options et les descriptions de champs.
# generated by datamodel-codegen:
# filename: user.json
# timestamp: 2023-08-18T06:18:19+00:00
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel, Extra, Field
class Address(BaseModel):
class Config:
extra = Extra.forbid
city: Optional[str] = Field(None, description="User's city")
zip_code: Optional[str] = Field(None, description='City postal code')
street_number: Optional[int] = Field(None, description='House street number')
street_name: Optional[str] = Field(None, description='House street name')
class UserRequest(BaseModel):
class Config:
extra = Extra.forbid
name: str = Field(..., description="User's name.")
age: Optional[int] = Field(None, description="User's age in years.")
address: Optional[Address] = Field(None, description='Where does the user live.')
Garder dans un coin de votre esprit qu’en plus de transformer un schéma JSON en modèle Pydantic, datamodel-codegen ouvre la porte à la possibilité d’utiliser comme référence de définitions de modèles des schémas JSON. Le principal avantage de faire cela est que ces schémas pourront être utilisés partout dans différents modules du code construit avec plusieurs langages.
Pipeline de données avec singledispatch
En programmation, il est courant d’avoir une seule méthode de traitement où un pipeline doit exécuter une logique différente pour des modèles de données différents qui se présenteront à l’entrée de celle-ci. En général, les développeurs font généralement le choix d’employer en premier lieu des logiques à base de if-else qui tendent à augmenter drastiquement la complexité du code métier et donc sa maintenabilité.
Et s’il y avait un meilleur moyen ? Et s’il était intégré à Python ? Le décorateur singledispatch et Pydantic répondent parfaitement à notre besoin pour faire ce travail. Ce décorateur est un outil puissant mit à disposition dans le module functools et qui nous aidera à maintenir une base de code plus évolutive en enregistrant des comportements spécifiques pour chaque type de données.
Le singledispatch est un décorateur qui s’utilise en surchargeant une fonction principale (process) et des fonctions secondaires. C’est-à-dire que l’appel de la fonction principale saura quelle logique interne à exécuter en fonction des arguments qu’elle reçoit. Cependant, notez que singledispatch ne considère que le type du premier argument. Heureusement, nous pouvons regrouper toutes les données dont nous avons besoin dans cet argument unique en utilisant un modèle Pydantic.
from functools import singledispatch
@singledispatch
def process(model):
""" Fonction principale process qui sera appelée. """
raise NotImplementedError(f"Je ne sais pas à quel modèle fait référence le modèle suivant : {type(model)}")
@process.register
def _process_address(model: Address) -> str:
""" Fonction privée de traitement des adresses. """
return f"C'est une adresse dont la ville est : {model.city}"
@process.register
def _process_user(model: User) -> str:
""" Fonction privée de traitement des utilisateurs. """
return f"C'est un utilisateur dont le nom est : {model.name}"
address = Address(
city="Pythonland",
zip_code="30000",
street_number=42,
street_name="boulevard des devs"
)
user = User(
name="Fou du Python",
age=38,
address=address
)
process(address) # C'est un utilisateur dont le nom est : Pythonland
process(user) # C'est un utilisateur dont le nom est : Fou du Python
process("autre chose") # NotImplementedError: Je ne sais pas à quel modèle fait référence le modèle suivant : <class 'str'>
Avec cette approche, nous pouvons rapidement créer et tester des fonctions individuelles qui répondent à des besoins de données spécifiques, intégrer de nouveaux modèles dans la boucle sans aucun problème et avoir une approche claire et directe pour gérer plusieurs sources de données.
Le mot de la fin
Pydantic est l’un de ces outils qui a créé un avant et un après dans l’écosystème Python. Non seulement il facilite la vie des développeurs en améliorant considérablement la qualité et la lisibilité du code, mais il a également été à la base de frameworks modernes tels que FastAPI.
Dans cet article, nous avons vu les meilleures pratiques concernant :
– Être capable de suivre tout changement dans un contrat de données avec des champs ajoutés ou manquants.
– Utilisation de schémas JSON pour intégrer des définitions de données entre des systèmes distribués tout en utilisant Pydantic pour les bases de code Python.
– Application du décorateur singledispatch pour concevoir des pipelines de traitement plus faciles à lire, à tester et à mettre à l’échelle.