Cours de Python / Créer un rapport CSV avec validation et lignes rejetées

Pour produire un rapport CSV fiable, contrôlez les colonnes, les champs obligatoires, les montants et les identifiants en double avant d’additionner les valeurs. Le programme de cet exercice conserve les lignes valides, explique les rejets dans un fichier séparé et termine avec un code d’échec si une partie des données a été exclue.

Définir les données acceptées et la politique de rejet

Le fichier d’entrée possède exactement les colonnes id,category,amount, dans cet ordre. id identifie une dépense et category son groupe. Après suppression des espaces extérieurs, ces deux champs doivent être non vides. Le montant doit contenir uniquement les chiffres ASCII de 0 à 9 : zéro est accepté, mais pas un signe, un séparateur de milliers ou une décimale.

Le premier enregistrement valide et accepté réserve un identifiant. Une ligne invalide ne le réserve pas ; une ligne valide ultérieure peut donc utiliser cet identifiant. À l’inverse, un identifiant déjà accepté est refusé même si le montant suivant est différent. Ce choix évite de compter deux fois une dépense ; il ne constitue pas une règle universelle de correction des données.

Utilisez uniquement des fichiers d’exercice dans un nouveau dossier. Le programme lit expenses-input.csv sans le modifier et crée report.csv et rejected.csv dans le dossier courant. Si l’un de ces deux noms existe déjà, il s’arrête. Conservez vos résultats et préparez un autre dossier pour chaque nouvel essai, plutôt que de retirer la protection contre l’écrasement.

DictReader associe les colonnes à leurs noms. L’encodage utf-8-sig accepte aussi un éventuel indicateur BOM au début du fichier ; newline="" laisse le module gérer les fins de ligne CSV. Ces conventions sont détaillées dans la documentation officielle du module csv et le cours sur la lecture et l’écriture des fichiers CSV.

Préparer le fichier d’entrée complet

Enregistrez ce contenu en UTF-8 dans expenses-input.csv. La répétition de e2 et le montant négatif sont volontaires : ils servent à vérifier les rejets.

id,category,amount
e1,food,3500
e2,food,8000
e3,transport,2500
e2,food,8000
e4,books,-1

Les trois premières dépenses sont valides : food doit totaliser 11 500 et transport 2 500. Le total accepté sera donc 14 000. Ne supprimez pas les deux lignes incorrectes avant le premier essai, sinon vous ne vérifierez pas la politique d’erreur annoncée.

Exécuter le programme et examiner les deux résultats

À côté du fichier d’entrée, enregistrez le programme complet csv_report.py. Il utilise uniquement la bibliothèque standard de Python.

import csv
import re
from collections import defaultdict
from pathlib import Path

source = Path("expenses-input.csv")
report = Path("report.csv")
rejected = Path("rejected.csv")
if report.exists() or rejected.exists():
    raise FileExistsError("Use a new output directory")
totals = defaultdict(int)
seen = set()
errors = []
accepted = 0
with source.open(encoding="utf-8-sig", newline="") as f:
    reader = csv.DictReader(f)
    if reader.fieldnames != ["id", "category", "amount"]:
        raise ValueError("unexpected header")
    for number, row in enumerate(reader, start=2):
        key = (row.get("id") or "").strip()
        category = (row.get("category") or "").strip()
        amount_text = (row.get("amount") or "").strip()
        reason = ""
        if None in row or not key or not category:
            reason = "invalid fields"
        elif key in seen:
            reason = "duplicate id"
        elif not re.fullmatch(r"[0-9]+", amount_text):
            reason = "invalid amount"
        if reason:
            errors.append({"record": number, "reason": reason})
            continue
        seen.add(key)
        totals[category] += int(amount_text)
        accepted += 1
with report.open("x", encoding="utf-8", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["category", "total"])
    writer.writerows(sorted(totals.items()))
with rejected.open("x", encoding="utf-8", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=["record", "reason"])
    writer.writeheader()
    writer.writerows(errors)
print("accepted:", accepted, "rejected:", len(errors))
print("total:", sum(totals.values()))
raise SystemExit(1 if errors else 0)

Dans un terminal ouvert dans ce dossier, avec l’environnement virtuel sélectionné, exécutez :

python csv_report.py

La sortie standard contient accepted: 3 rejected: 2, puis total: 14000. Immédiatement après, consultez $LASTEXITCODE dans PowerShell ou echo $? dans un shell macOS/Ubuntu : le code attendu est 1, puisque deux enregistrements ont été rejetés. Ce code ne signifie pas que les deux fichiers sont absents ; ici, ils contiennent bien le rapport partiel et les raisons d’exclusion.

Le fichier report.csv contient les catégories triées et leurs sommes :

category,total
food,11500
transport,2500

Le fichier rejected.csv contient les motifs suivants :

record,reason
5,duplicate id
6,invalid amount

record compte les enregistrements CSV logiques en considérant l’en-tête comme le numéro 1. Le doublon porte donc le numéro 5 et le montant négatif le numéro 6. Un champ entre guillemets peut contenir un saut de ligne : ce compteur ne serait alors pas le numéro de ligne physique dans un éditeur. Les erreurs de champs sont contrôlées avant les doublons, puis le montant ; un enregistrement reçoit un seul motif selon cet ordre.

Vérifier les corrections et reconnaître les résultats partiels

  1. Comparez chaque catégorie du rapport avec les seules dépenses acceptées dans l’entrée, puis vérifiez que la somme des catégories vaut 14 000. Vérifiez aussi les deux motifs de rejet ; le total seul ne démontre pas que tout le fichier a été traité avec succès.
  2. Dans un deuxième dossier, copiez le programme et une nouvelle entrée : remplacez le second identifiant e2 par e5 et le montant -1 par 1000. Cet essai doit accepter cinq dépenses, ne rejeter aucune ligne, totaliser 23 000 et terminer avec le code 0. Les catégories valent alors books=1000, food=19500 et transport=2500.
  3. Dans un troisième dossier, permutez seulement les noms de colonnes. Le contrôle strict de l’en-tête doit lever ValueError: unexpected header avant la création des résultats. Une colonne supplémentaire ou manquante ne doit pas être silencieusement interprétée comme le schéma attendu.
  4. Relancez le premier dossier sans changer ses résultats : FileExistsError doit empêcher leur remplacement. Revenez à une nouvelle copie du fichier initial pour reproduire le premier résultat.

Les ouvertures en mode "x" demandent une création exclusive : un fichier déjà présent ne doit pas être écrasé. La référence de open décrit ce mode. Le contrôle initial des chemins est utile pour un message clair, mais ne transforme pas les deux écritures en une seule opération atomique.

Si la création ou l’écriture du second fichier échoue après celle du rapport, le premier résultat peut rester sur disque. Un code non nul peut donc signaler un rejet prévu ou une erreur inattendue ; examinez aussi le message et les fichiers. Dans un traitement réel, préparez les sorties dans un emplacement temporaire, validez-les ensemble et publiez-les selon une procédure de finalisation explicite. N’envoyez pas automatiquement un rapport incomplet à l’étape suivante.

Ce petit outil ne couvre pas tous les problèmes d’importation : il n’impose pas de limite métier aux montants et ne normalise pas les noms de catégories au-delà des espaces extérieurs. Des erreurs de décodage ou d’accès peuvent interrompre le programme. Si les chaînes externes sont ensuite ouvertes dans un tableur, prévoyez aussi une politique adaptée à son interprétation des formules ; la simple écriture CSV ne fournit pas cette protection.