Aller au contenu
English

Les enregistrements structurés

  • Leçon 9
  • 45 min
  • Module 3 : Fonctions et structure du programme
Leçon 9 sur 18 · Python pour l'ingénierie de l'IA en pratiqueLes enregistrements structurés

Ce que vous saurez faire

Transformez les enregistrements bruts de billets de soutien provenant d’un fichier en objets Ticket vérifiés. Produisez des rapports uniquement avec ces objets. Le programme repère et nomme chaque enregistrement problématique avant qu’un rapport l’utilise. Ainsi, une faute de frappe dans les données ne peut pas produire un nombre erroné dans le rapport.

Carnet JupyterFaites cette leçon dans un carnet JupyterColab · Kaggle · GitHub · en anglais

Le carnet (notebook) Jupyter contient chaque étape, tâche et vérification de cette leçon, avec les consignes. Ses premières cellules créent le dossier ticket-cleaner tel qu’il est au début de cette leçon. Il fonctionne donc sans votre projet local. Lisez les explications ici et exécutez le code dans le carnet. Aucune installation n’est nécessaire.

Le carnet est en anglais. Colab et Kaggle ouvrent votre propre copie. Il faut un compte Google ou Kaggle gratuit. Vous êtes bloqué ? Consultez le carnet de solution.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 45 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur (seulement pour le parcours local; le carnet et les encadrés de cette page n’en ont pas besoin)

  • Le paquet ticket_cleaner/ avec __init__.py et report.py, ainsi que count_tickets.py, qui importe depuis ce paquet. Vous les avez créés dans Modules et importations : exercice guidé. S’ils sont absents ou différents, refaites cette section. Vous pouvez aussi copier les fichiers depuis la cellule de préparation du carnet de cette leçon. Elle contient tous les fichiers tels qu’ils sont au début de cette leçon.
  • Un terminal dans ~/projects/ticket-cleaner. Vous avez créé le dossier dans Les fichiers et le terminal : exercice guidé.
  • L’environnement .venv du projet, actif dans votre terminal (l’invite commence par (.venv)). Vous l’avez créé dans Isoler les dépendances : exercice guidé. S’il est absent ou ne fonctionne plus, supprimez-le et recréez-le à partir du fichier de verrouillage. Suivez Recréer l’environnement à partir de zéro.

Testé avec Python 3.14.6 sur macOS et Pyodide 314.0 (navigateur). Le carnet a été testé dans Jupyter.

Les enregistrements bruts ne sont pas encore des billets

Section intitulée « Les enregistrements bruts ne sont pas encore des billets »

Jusqu’ici, les billets étaient parfaits : les bonnes clés, un statut en minuscules et une priorité numérique. Les données réelles ne sont pas comme ça. Dans le module 4, vous lirez un fichier. Chaque enregistrement arrivera sous forme de dictionnaire de chaînes saisies par des personnes :

{"id": "t-1003", "status": "Open ", "category": "login", "priority": "1"}
{"id": "T-1004", "status": "opne", "category": "billing", "priority": "2"}
{"id": "T-1005", "status": "open", "category": "billing", "priority": "high"}

Le premier enregistrement est valide après nettoyage : lettres minuscules, sans espaces superflus. Le deuxième contient une faute de frappe dans le statut. Le troisième a une priorité qui n’est pas un nombre. Si les fonctions de rapport reçoivent ces enregistrements, filter_by_status ignore "Open " (ouvert) et "opne" (faute de frappe). Et average_priority s’arrête avec une erreur à "high" (élevée).

Le programme comporte donc trois étapes, chacune dans son propre module :

Étape Module Rôle
Valider parsing.py Trouver les problèmes dans un enregistrement brut.
Analyser parsing.py Transformer un enregistrement brut valide en Ticket nettoyé.
Produire un rapport report.py Calculer uniquement avec des objets Ticket.

Les fonctions de rapport peuvent alors se fier à leurs données d’entrée. Toutes les vérifications sont au même endroit.

Une annotation de type indique le type attendu par un paramètre ou le type retourné par une fonction. Elle s’écrit après un : pour un paramètre et après -> pour la valeur de retour :

def average_priority(tickets: list[Ticket]) -> float | None:

Lisez ceci ainsi : « average_priority reçoit une liste d’objets Ticket et retourne un float ou None ». Voici les annotations courantes :

Annotation Signification
str, int, float, bool Une valeur de ce type.
list[str] Une liste de chaînes.
dict[str, int] Un dictionnaire avec des clés de type chaîne et des valeurs entières.
float | None Un float ou None. Le | signifie « ou ».
-> None La fonction ne retourne rien d’utile (elle affiche du texte ou enregistre un fichier).

Python ne vérifie pas les annotations de type pendant l’exécution du programme. average_priority("abc") s’exécute quand même et échoue plus tard. Les annotations servent aux personnes et aux outils. VS Code les lit et suggère les bons noms pendant que vous tapez. Il souligne aussi les appels avec un type incorrect avant toute exécution.

Un dictionnaire peut contenir n’importe quelles clés. Si vous tapez ticket["stauts"], vous découvrez l’erreur seulement quand cette ligne s’exécute. Pour un enregistrement avec un ensemble fixe de champs, créez plutôt votre propre type. Une classe définit un nouveau type. Une dataclass (classe de données) est la façon la plus courte d’écrire une classe qui contient surtout des données :

ticket_cleaner/records.py
from dataclasses import dataclass
STATUSES = {"open", "pending", "closed"}
PRIORITIES = {1, 2, 3}
REQUIRED_FIELDS = ("id", "status", "category", "priority")
@dataclass(frozen=True)
class Ticket:
"""One valid support ticket."""
id: str
status: str
category: str
priority: int
def is_urgent(self) -> bool:
"""Return True for priority 1, the most urgent level."""
return self.priority == 1
Partie Signification
@dataclass Demande à Python d’écrire le code habituel pour vous : créer un billet à partir de quatre valeurs, l’afficher lisiblement et comparer deux billets.
(frozen=True) Un billet ne peut pas changer après sa création.
id: str et les lignes suivantes Les champs, chacun avec une annotation de type. Chaque billet possède ces quatre valeurs.
def is_urgent(self) Une méthode : une fonction qui appartient au type. self est le billet sur lequel vous l’appelez.

Créez un billet en appelant la classe comme une fonction, puis lisez ses champs avec un point :

ticket = Ticket(id="T-1003", status="open", category="login", priority=1)
print(ticket) # Ticket(id='T-1003', status='open', category='login', priority=1)
print(ticket.status) # open
print(ticket.is_urgent()) # True

Exécutez du Python dans votre navigateur

Créer et utiliser un Ticket

La dernière ligne contient une faute de frappe. Exécutez le code et lisez la dernière ligne de l’erreur. Corrigez ensuite le nom et ajoutez une ligne qui tente de changer le statut : ticket.status = "closed". Que se passe-t-il?

Pourquoi frozen=True? Dans Les collections, deux noms pour une même liste causaient des changements à des endroits inattendus. Un billet figé ne peut pas changer. Vous pouvez donc le partager sans risque entre des listes et des fonctions. Pour obtenir un billet avec un autre statut, créez-en un nouveau. La fonction replace de dataclasses copie un billet en modifiant un champ : replace(ticket, status="closed").

La fonction de validation retourne une liste de problèmes. Une liste vide signifie que l’enregistrement est valide. C’est mieux que True ou False, car le rapport peut indiquer pourquoi un enregistrement a été rejeté. Elle trouve aussi tous les problèmes d’un enregistrement en une fois, pas seulement le premier.

ticket_cleaner/parsing.py
from ticket_cleaner.records import PRIORITIES, REQUIRED_FIELDS, STATUSES, Ticket
def clean_text(value: object) -> str:
"""Return the value as text with no outer spaces, in small letters."""
if value is None:
return ""
return str(value).strip().lower()
def find_problems(raw: dict) -> list[str]:
"""Return a list of problems with one raw record. An empty list means valid."""
problems = []
for field in REQUIRED_FIELDS:
if clean_text(raw.get(field)) == "":
problems.append(f"missing {field}")
status = clean_text(raw.get("status"))
if status and status not in STATUSES:
problems.append(f"unknown status {status!r}")
priority = clean_text(raw.get("priority"))
if priority and not (priority.isdigit() and int(priority) in PRIORITIES):
problems.append(f"priority must be 1, 2 or 3, not {priority!r}")
return problems
def to_ticket(raw: dict) -> Ticket:
"""Make a Ticket from a raw record that has no problems."""
return Ticket(
id=clean_text(raw["id"]).upper(),
status=clean_text(raw["status"]),
category=clean_text(raw["category"]),
priority=int(clean_text(raw["priority"])),
)

Quelques détails :

  • clean_text accepte toute valeur (object), car un fichier JSON peut contenir des nombres, et str(2) donne "2". raw.get(field) donne None si une clé manque, et clean_text(None) donne "". Une clé manquante et une valeur vide représentent donc le même problème.
  • {status!r} encadre la valeur de guillemets dans le message : unknown status 'opne' (statut inconnu « opne »). Une valeur contenant un espace, comme 'open ', devient alors visible.
  • priority.isdigit() donne True seulement si chaque caractère est un chiffre. Le and s’arrête avant int(priority) quand le résultat est False. Ainsi, int("high") ne s’exécute jamais.
  • to_ticket ne vérifie rien. Son contrat indique que l’enregistrement n’a aucun problème. Le code appelant doit d’abord appeler find_problems.

Les fonctions de rapport reçoivent des annotations de type et accèdent aux champs avec un point plutôt qu’avec ["..."] :

ticket_cleaner/report.py
from ticket_cleaner.records import Ticket
def filter_by_status(tickets: list[Ticket], status: str = "open") -> list[Ticket]:
"""Return the tickets that have the given status."""
return [ticket for ticket in tickets if ticket.status == status]
def count_by_category(tickets: list[Ticket]) -> dict[str, int]:
"""Return the number of tickets in each category, sorted by category."""
counts = {}
for ticket in tickets:
counts[ticket.category] = counts.get(ticket.category, 0) + 1
return dict(sorted(counts.items()))
def average_priority(tickets: list[Ticket]) -> float | None:
"""Return the average priority, or None when there are no tickets."""
if not tickets:
return None
return round(sum(ticket.priority for ticket in tickets) / len(tickets), 1)

sum(ticket.priority for ticket in tickets) additionne les priorités en une ligne. Cette expression fonctionne comme une compréhension de liste, sans les crochets.

Ce diagramme montre comment main() utilise les cinq fonctions, ainsi que les types d’entrée et de sortie de chacune :

main dans count_tickets.py appelle cinq fonctions dans l'ordre. Dans parsing.py : find_problems reçoit un dict et retourne une liste de chaînes, et to_ticket reçoit un dict et retourne un Ticket. Dans report.py : filter_by_status reçoit une liste de billets et un statut, puis retourne une liste de billets, count_by_category retourne un dict de chaînes vers des entiers, et average_priority retourne un float ou None.
Le graphe d'appels : main() appelle chaque fonction dans l'ordre. Les types montrent ce dont chaque fonction a besoin et ce qu'elle retourne.
Description textuelle du diagramme

main() appelle d’abord find_problems pour chaque ligne brute (un dict), qui retourne une list[str]. Pour chaque ligne sans problème, elle appelle to_ticket, qui retourne un Ticket. Elle appelle ensuite trois fonctions de report.py avec des listes de billets. filter_by_status retourne la list[Ticket] sélectionnée. count_by_category retourne un dict[str, int]. average_priority retourne un float ou None. Seule main() affiche du texte.

  1. Créez ticket_cleaner/records.py avec le code de la section sur les dataclasses.

  2. Créez ticket_cleaner/parsing.py avec le code de l’exemple détaillé.

  3. Remplacez le contenu de ticket_cleaner/report.py par la version avec annotations de type.

  4. Remplacez count_tickets.py par cette version. Les données arrivent maintenant sous forme de chaînes brutes, comme depuis un fichier :

    count_tickets.py
    from ticket_cleaner.parsing import find_problems, to_ticket
    from ticket_cleaner.records import Ticket
    from ticket_cleaner.report import average_priority, count_by_category, filter_by_status
    SAMPLE_ROWS = [
    {"id": "T-1001", "status": "open", "category": "billing", "priority": "2"},
    {"id": "T-1002", "status": "closed", "category": "login", "priority": "3"},
    {"id": "t-1003", "status": "Open ", "category": "login", "priority": "1"},
    {"id": "T-1004", "status": "opne", "category": "billing", "priority": "2"},
    {"id": "T-1005", "status": "open", "category": "billing", "priority": "high"},
    {"id": "T-1006", "status": "closed", "category": "shipping", "priority": "1"},
    ]
    def main() -> None:
    tickets: list[Ticket] = []
    for raw in SAMPLE_ROWS:
    problems = find_problems(raw)
    if problems:
    print(f"Skipped {raw['id']}: {'; '.join(problems)}")
    else:
    tickets.append(to_ticket(raw))
    status = "open"
    selected = filter_by_status(tickets, status)
    print(f"Tickets with status '{status}': {len(selected)}")
    if not selected:
    print("No tickets to count.")
    return
    for category, count in count_by_category(selected).items():
    print(f" {category}: {count}")
    print(f"Average priority: {average_priority(selected)}")
    if __name__ == "__main__":
    main()

    "; ".join(problems) réunit les chaînes d’une liste en une seule chaîne, avec "; " entre elles.

  5. Exécutez le programme :

    Fenêtre de terminal
    python count_tickets.py
    Sortie
    Skipped T-1004: unknown status 'opne'
    Skipped T-1005: priority must be 1, 2 or 3, not 'high'
    Tickets with status 'open': 2
    billing: 1
    login: 1
    Average priority: 1.5

    t-1003, avec "Open ", est compté comme un billet ouvert, car to_ticket l’a nettoyé. Les deux enregistrements problématiques sont nommés, pas perdus.

Le service de soutien indique que chaque identifiant de billet doit commencer par T-. Ajoutez cette règle à find_problems dans l’encadré ci-dessous. Pour un identifiant qui ne commence pas par T- après nettoyage, ajoutez le problème "id must start with 'T-'" (l’identifiant doit commencer par « T- »). Un identifiant manquant doit donner seulement "missing id" (identifiant manquant), pas les deux problèmes.

Exécutez du Python dans votre navigateur

À vous de jouer : une règle pour l'identifiant

{**good, "id": "X-9"} crée une copie de good avec un autre identifiant. L’encadré vérifie les trois cas. Cette règle sert à vous exercer. Ne l’ajoutez pas à votre projet, car les prochains modules utilisent les règles précédentes.

Afficher une réponse
ticket_id = clean_text(raw.get("id"))
if ticket_id and not ticket_id.startswith("t-"):
problems.append("id must start with 'T-'")

clean_text met l’identifiant en minuscules, alors comparez-le à "t-". La vérification if ticket_id and ... ignore un identifiant vide, qui a déjà le problème missing id (identifiant manquant).

Erreur courante

Une faute de frappe dans un nom de champ

Symptôme : après avoir remplacé les dictionnaires par des objets Ticket, une fonction de rapport s’arrête avec une erreur comme celle-ci :

TypeError: 'Ticket' object is not subscriptable

ou, avec une faute de frappe dans un nom de champ :

AttributeError: 'Ticket' object has no attribute 'stauts'. Did you mean: 'status'?

Cause : la première erreur signifie que le code utilise encore l’accès par dictionnaire, ticket["status"], sur un Ticket. Une dataclass possède des champs, pas des clés. « Subscriptable » signifie « accessible avec [ ] ». La deuxième erreur concerne un nom de champ inexistant. Python suggère le nom le plus proche.

Solution :

  1. Lisez le numéro de ligne dans l’erreur et remplacez ticket["status"] par ticket.status.
  2. Pour un nom mal orthographié, utilisez le nom que Python suggère.
  3. Pour trouver ces erreurs avant d’exécuter le code, cherchez les noms soulignés dans VS Code. L’éditeur connaît les champs de Ticket grâce à ses annotations de type.

Vérifiez votre compréhension

Pourquoi find_problems retourne-t-elle une liste de chaînes plutôt que True ou False?

Afficher la réponse

Une liste indique pourquoi un enregistrement n’est pas valide et peut contenir plusieurs problèmes. Le programme peut afficher les raisons dans le rapport pour qu’une personne corrige les données. Une liste vide signifie toujours « valide », car elle est considérée comme fausse : if problems:.

Vérifiez votre compréhension

Vérification du module : vérifiez to_ticket et find_problems séparément. Donnez une assertion pour chacune et expliquez le contrat de to_ticket en mots simples.

Afficher la réponse

Par exemple : assert find_problems({"id": "T-1", "status": "opne", "category": "x", "priority": "2"}) == ["unknown status 'opne'"], et assert to_ticket({"id": " t-7 ", "status": "Open", "category": "Login", "priority": "1"}) == Ticket(id="T-7", status="open", category="login", priority=1). Contrat de to_ticket : elle reçoit un enregistrement brut qui n’a aucun problème et retourne un Ticket nettoyé. Le texte est nettoyé et la priorité est un entier. Elle ne vérifie pas l’enregistrement. Le code appelant doit d’abord appeler find_problems.

Récapitulatif

  • Les annotations de type montrent le contrat d’une fonction sur sa ligne def : def f(tickets: list[Ticket]) -> float | None:. Python ne les vérifie pas à l’exécution, mais votre éditeur le fait.
  • Une dataclass permet de définir rapidement un type pour un enregistrement. Lisez ses champs avec un point. frozen=True empêche les modifications.
  • Validez d’abord et retournez une liste de problèmes pour que le rapport puisse expliquer pourquoi un enregistrement a été rejeté.
  • Analysez seulement les enregistrements valides pour obtenir des objets nettoyés. Les fonctions de rapport travaillent alors uniquement avec des données nettoyées.
  • Gardez chaque étape dans son propre module : records.py, parsing.py et report.py.

Vous pouvez maintenant organiser le code en fonctions et en modules avec des contrats clairs. Dans le prochain module, les enregistrements bruts proviennent de vrais fichiers CSV et JSON, ainsi que d’un service Web.

Leçon suivante : Lire et écrire des fichiers