Solutions — série 09-4 (CSV : lire, écrire, analyser)
Rappel de la règle d'or : on n'ouvre ce fichier qu'après avoir terminé (ou sérieusement séché sur) la série. Compare le raisonnement, pas seulement le code : une solution différente qui marche est souvent valable.
Les solutions supposent le
expenses.csvde la section 5 de la leçon dans le dossier courant.
Exercice facile a — lire et afficher avec DictReader
Raisonnement — L'exercice de prise en main : ouverture rituelle (newline="", encoding), une boucle sur DictReader, et une preuve expérimentale de LA règle de la leçon (le type des valeurs).
Solution
# ex-09-4-a.py
import csv
with open("expenses.csv", newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
for row in rows:
print(f"{row['category']} : {row['amount']} €")
print(f"{len(rows)} lignes")
print(type(rows[0]["amount"])) # <class 'str'>
Sortie :
courses : 54.30 €
essence : 70.00 €
courses : 23.10 €
restaurant : 41.00 €
essence : 65.50 €
5 lignes
<class 'str'>
Pourquoi ça marche — DictReader lit la première ligne comme en-têtes et sert chaque ligne suivante en dict {en-tête: valeur}. Le list(...) matérialise le flux : on peut ensuite le mesurer (len) et le réutiliser. Et le type() final confirme : '54.30' est une chaîne, quoi qu'il ressemble à un nombre.
Erreur classique sur cet exercice — len(reader) sans passer par une liste : TypeError: object of type 'DictReader' has no len() — un reader est un flux, pas une collection. Et dans la f-string, attention aux guillemets : f"{row['category']}" — simples à l'intérieur, doubles à l'extérieur (ou l'inverse), sinon SyntaxError.
Variante plus difficile — Affiche aussi le numéro de ligne du FICHIER (en-tête = ligne 1, première donnée = ligne 2) avec enumerate(rows, start=2). Ce décalage de 2 est un grand classique des rapports d'erreur CSV — autant l'apprivoiser sur un cas sain.
Exercice facile b — filtrer au-dessus de 50 €
Raisonnement — Un filtre + un total, donc DEUX conversions possibles : on convertit une fois pour toutes (dans le filtre) et on travaille ensuite sur des floats. Le piège annoncé : comparer des chaînes à un nombre.
Solution
# ex-09-4-b.py
import csv
with open("expenses.csv", newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
big_expenses = [row for row in rows if float(row["amount"]) > 50]
for row in big_expenses:
print(f"{row['date']} {row['category']:<12} {row['amount']} €")
total = sum(float(row["amount"]) for row in big_expenses)
print(f"Total > 50 € : {total:.2f} €")
Sortie :
2026-06-01 courses 54.30 €
2026-06-03 essence 70.00 €
2026-06-11 essence 65.50 €
Total > 50 € : 189.80 €
Pourquoi ça marche — float(row["amount"]) > 50 compare des nombres — seule comparaison qui a un sens. La compréhension de liste filtre en une ligne (⏩ niveau 03), et le sum(...) sur une expression génératrice reconvertit pour additionner. Le :.2f garantit 189.80, pas 189.8.
Erreur classique sur cet exercice — row["amount"] > 50 → TypeError: '>' not supported between instances of 'str' and 'int' : celle-là au moins se voit. La version sournoise : row["amount"] > "50" — comparaison de CHAÎNES, caractère par caractère, qui donne souvent le bon résultat sur ces données-ci ("54.30" > "50" est vrai)… et un résultat faux dès qu'un montant a moins de chiffres ("9.90" > "50" est vrai aussi ! "9" > "5" alphabétiquement). Le bug qui passe les tests et ment en production.
Variante plus difficile — Rends le seuil paramétrable via sys.argv (défaut 50 si absent), avec un message d'usage propre si l'argument n'est pas convertible en nombre. Trois leçons assemblées en dix lignes.
Exercice facile c — top3.csv avec DictWriter
Raisonnement — Trier (sur le montant CONVERTI, décroissant), tronquer à 3, écrire avec le rituel complet : mode "w", newline="", fieldnames, writeheader(). Puis vérification À L'ŒIL — c'est une exigence de l'énoncé, pas une option.
Solution
# ex-09-4-c.py
import csv
with open("expenses.csv", newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
top3 = sorted(rows, key=lambda row: float(row["amount"]), reverse=True)[:3]
with open("top3.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["date", "category", "amount"])
writer.writeheader()
writer.writerows(top3)
print("top3.csv écrit — ouvre-le et vérifie.")
Contenu de top3.csv :
date,category,amount
2026-06-03,essence,70.00
2026-06-11,essence,65.50
2026-06-01,courses,54.30
Pourquoi ça marche — La key du tri convertit à la volée : les dicts eux-mêmes gardent leurs chaînes d'origine ("70.00" reste tel quel dans le fichier de sortie — aucune raison de le réécrire en 70.0). La tranche [:3] après sorted(..., reverse=True) prend les trois plus gros.
Erreur classique sur cet exercice — Trier sans convertir : sorted(rows, key=lambda r: r["amount"], reverse=True) classe ALPHABÉTIQUEMENT — sur ces données le résultat semble bon, mais ajoute une dépense de 9.99 et elle passe devant 70.00 (« 9 » > « 7 »). Encore un bug silencieux de chaînes. L'autre classique : oublier newline="" et livrer un fichier à lignes vides intercalées — c'est exactement ce que la vérification à l'œil attrape.
Variante plus difficile — Écris un top 3 PAR CATÉGORIE (un dict catégorie → liste des dépenses d'abord, puis tri de chaque liste). La sortie : un seul CSV avec une colonne rank (1-3) en plus.
Exercice moyen a — le chargeur robuste
Raisonnement — Un CSV sale se traite ligne par ligne avec une politique EXPLICITE par anomalie : montant vide → ignorer + signaler ; virgule décimale → réparer ; casse incohérente → normaliser. Le numéro de ligne réel (fichier, pas liste) s'obtient avec enumerate(..., start=2) : la ligne 1 est l'en-tête.
Solution
# ex-09-4-ma.py
import csv
def load_expenses(path):
"""Load a messy expenses CSV; return (clean_rows, ignored_count)."""
kept = []
ignored = 0
with open(path, newline="", encoding="utf-8") as f:
for line_number, row in enumerate(csv.DictReader(f), start=2):
raw = (row["amount"] or "").strip().replace(",", ".")
if not raw:
print(f"Ligne {line_number} ignorée : montant vide")
ignored += 1
continue
try:
row["amount"] = float(raw)
except ValueError:
print(f"Ligne {line_number} ignorée : montant invalide {row['amount']!r}")
ignored += 1
continue
row["category"] = row["category"].strip().lower()
kept.append(row)
return kept, ignored
rows, ignored = load_expenses("expenses_dirty.csv")
total = sum(row["amount"] for row in rows)
print(f"{len(rows)} lignes gardées, {ignored} ignorées, total {total:.2f} €")
Sortie (sur un fichier avec 8 lignes dont les 3 anomalies imposées) :
Ligne 4 ignorée : montant vide
7 lignes gardées, 1 ignorée(s), total 349.50 €
(La ligne à virgule et la ligne COURSES ne sont PAS ignorées : elles sont réparées — c'est toute la différence entre anomalie réparable et donnée inutilisable.)
Pourquoi ça marche — (row["amount"] or "") gère à la fois "" (cellule vide) et None (colonne carrément absente en fin de ligne — comportement de DictReader). Le .replace(",", ".") AVANT le float répare le format français ; le try/except ValueError attrape ce qui reste d'irréparable. Chaque anomalie a une trace imprimée : un chargeur qui jette des données en silence est un chargeur menteur.
Erreur classique sur cet exercice — Compter les lignes avec un compteur manuel initialisé à 1 (ou 0) : le rapport pointe alors la MAUVAISE ligne du fichier, et on cherche l'erreur au mauvais endroit — pénible sur 8 lignes, infernal sur 8 000. enumerate(..., start=2) encode le décalage d'en-tête une fois pour toutes.
Variante plus difficile — Ajoute la validation de la DATE (datetime.strptime(row["date"], "%Y-%m-%d") dans un try — ⏩ niveau 05) et une politique différente : ligne à date invalide gardée mais marquée row["date_ok"] = False. Trois politiques différentes dans un même chargeur : ignorer, réparer, marquer.
Exercice moyen b — totaux par mois
Raisonnement — La clé d'agrégation se FABRIQUE : "2026-06-01"[:7] → "2026-06". Ensuite, même motif accumulateur qu'en catégories, mais avec DEUX dicts (total et compte) pour pouvoir sortir la moyenne. Écriture triée : les mois d'un rapport se lisent dans l'ordre.
Solution
# ex-09-4-mb.py
import csv
totals = {}
counts = {}
with open("expenses.csv", newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
month = row["date"][:7] # '2026-06-01' → '2026-06'
amount = float(row["amount"])
totals[month] = totals.get(month, 0) + amount
counts[month] = counts.get(month, 0) + 1
with open("by_month.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["month", "total", "count", "average"])
writer.writeheader()
for month in sorted(totals):
writer.writerow({
"month": month,
"total": round(totals[month], 2),
"count": counts[month],
"average": round(totals[month] / counts[month], 2),
})
print("by_month.csv écrit.")
Contenu type de by_month.csv (avec des dépenses de juillet ajoutées) :
month,total,count,average
2026-06,253.90,5,50.78
2026-07,118.40,3,39.47
Pourquoi ça marche — La tranche [:7] exploite le format ISO des dates (AAAA-MM-JJ) : les 7 premiers caractères identifient le mois, ET se trient correctement en alphabétique (c'est LA raison d'aimer le format ISO). La moyenne se calcule À LA FIN, à partir des deux accumulateurs — accumulée en route, elle serait fausse ou pénible.
Erreur classique sur cet exercice — Accumuler la moyenne au fil de l'eau (averages[month] = (averages[month] + amount) / 2 ou autres bricolages) : mathématiquement faux (la moyenne de moyennes n'est pas la moyenne). On accumule somme et compte, on divise à la sortie. Autre écueil : row["date"].split("-")[1] qui donne "06" SANS l'année — juin 2026 et juin 2027 fusionnent en silence.
Variante plus difficile — Ajoute une colonne top_category : la catégorie la plus dépensière DU MOIS. Il faut un accumulateur imbriqué (dict mois → dict catégorie → total) — bonne gymnastique de structures avant le niveau suivant.
Exercice moyen c — le convertisseur franco-anglais
Raisonnement — Deux dialectes CSV : lecture avec delimiter=";" et encodage tolérant au BOM (utf-8-sig), écriture standard (virgule, utf-8). Les montants sont réparés au passage. Les noms de colonnes, inconnus à l'avance, se récupèrent sur le reader.
Solution
# ex-09-4-mc.py
import csv
with open("expenses_fr.csv", newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f, delimiter=";")
columns = reader.fieldnames # ex. ['date', 'categorie', 'montant']
rows = list(reader)
for row in rows:
row["montant"] = row["montant"].replace(",", ".")
with open("expenses_std.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=columns)
writer.writeheader()
writer.writerows(rows)
print(f"{len(rows)} lignes converties → expenses_std.csv")
Entrée (expenses_fr.csv) puis sortie (expenses_std.csv) :
date;categorie;montant date,categorie,montant
2026-06-01;courses;54,30 → 2026-06-01,courses,54.30
2026-06-03;essence;70,00 2026-06-03,essence,70.00
Pourquoi ça marche — delimiter=";" change UN paramètre du dialecte, tout le reste du module suit. utf-8-sig lit un fichier UTF-8 avec ou sans BOM (le marqueur invisible d'Excel) : s'il existe, il est avalé et la première clé est bien date, pas 'date'. À l'écriture, on revient au standard : virgule, UTF-8 sans BOM.
Erreur classique sur cet exercice — Oublier delimiter=";" à la lecture : DictReader voit UNE colonne nommée date;categorie;montant et chaque ligne tient dans une seule valeur. Le symptôme dans un traceback : KeyError: 'montant' alors que « la colonne existe » — affiche reader.fieldnames et l'énorme clé unique saute aux yeux.
Variante plus difficile — Fais deviner le séparateur automatiquement : lis la première ligne brute et compte les ; contre les , (ou découvre csv.Sniffer dans la doc — il existe pour ça). Ton convertisseur devient un normaliseur universel.
Exercice difficile a — csv_report, le diagnostic universel
Raisonnement — Colonnes inconnues → tout part de reader.fieldnames. Pour chaque colonne : compter les vides, puis décider si elle est numérique — définition choisie : TOUTES ses valeurs non vides passent float(). Le test de conversion est un try/except encapsulé dans une petite fonction : pas d'heuristique, pas de regex — on essaie, c'est tout.
Solution
# ex-09-4-da.py
import csv
import sys
def is_number(value):
"""True if float() accepts the value."""
try:
float(value)
return True
except ValueError:
return False
def csv_report(path):
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
columns = reader.fieldnames
rows = list(reader)
print(f"{path} : {len(rows)} lignes, {len(columns)} colonnes")
for col in columns:
values = [row[col] for row in rows]
filled = [v for v in values if v] # écarte '' et None
empty = len(values) - len(filled)
if filled and all(is_number(v) for v in filled):
numbers = [float(v) for v in filled]
stats = (f"numérique — min {min(numbers)}, max {max(numbers)}, "
f"moy {sum(numbers) / len(numbers):.2f}")
else:
stats = "non numérique"
print(f" {col:<15} {empty} vide(s), {stats}")
csv_report(sys.argv[1] if len(sys.argv) > 1 else "expenses.csv")
Sortie sur expenses.csv :
expenses.csv : 5 lignes, 3 colonnes
date 0 vide(s), non numérique
category 0 vide(s), non numérique
amount 0 vide(s), numérique — min 23.1, max 70.0, moy 50.78
Pourquoi ça marche — La fonction ne présume RIEN du fichier : les colonnes viennent de fieldnames, la « numéricité » se constate valeur par valeur (all(...) court-circuite au premier échec). Les vides sont exclus du verdict numérique — une colonne de montants avec des trous reste une colonne de nombres, c'est le choix le plus utile pour un diagnostic.
Erreur classique sur cet exercice — Décider du type sur la PREMIÈRE valeur seulement : une colonne reference contenant 123, 456, puis A-789 serait déclarée numérique et le float() planterait plus loin — diagnostic qui ment. L'autre écueil : v.isdigit() comme test — il rejette 54.30 (le point n'est pas un chiffre) et les négatifs ; seul le try float dit la vérité de float.
Variante plus difficile — Ajoute par colonne : le nombre de valeurs DISTINCTES (len(set(filled))) et, pour les colonnes non numériques, les 3 valeurs les plus fréquentes (un dict accumulateur, encore). Tu obtiens un describe() maison — c'est exactement le service que pandas rend en une ligne, et tu sais maintenant ce qu'il fait dedans.
Exercice difficile b — le rapprochement budget/dépenses
Raisonnement — Une jointure à la main : deux fichiers → deux dicts indexés par la même clé (category), puis un parcours de l'UNION des clés (les catégories des deux côtés, sans doublon : set | set). Chaque combinaison présence/absence a son statut : les deux → OK/DÉPASSÉ ; dépense sans budget → SANS BUDGET ; budget sans dépense → dépensé 0.
Solution
# ex-09-4-db.py
import csv
def load_column_map(path, key_col, value_col):
"""Read a CSV into {key: float(value)}, accumulating duplicates."""
result = {}
with open(path, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
key = row[key_col]
result[key] = result.get(key, 0) + float(row[value_col])
return result
spent = load_column_map("expenses.csv", "category", "amount")
budgets = load_column_map("budget.csv", "category", "monthly_budget")
all_categories = sorted(set(spent) | set(budgets)) # union des deux mondes
with open("budget_check.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(
f, fieldnames=["category", "budget", "spent", "gap", "status"])
writer.writeheader()
for category in all_categories:
budget = budgets.get(category)
amount = round(spent.get(category, 0.0), 2)
if budget is None:
row = {"budget": "", "gap": "", "status": "SANS BUDGET"}
else:
gap = round(budget - amount, 2)
row = {"budget": budget, "gap": gap,
"status": "OK" if amount <= budget else "DÉPASSÉ"}
row.update({"category": category, "spent": amount})
writer.writerow(row)
print("budget_check.csv écrit.")
Contenu type de budget_check.csv :
category,budget,spent,gap,status
courses,100.0,77.4,22.6,OK
essence,120.0,135.5,-15.5,DÉPASSÉ
loisirs,50.0,0.0,50.0,OK
restaurant,,41.0,,SANS BUDGET
Pourquoi ça marche — Les deux dicts transforment « chercher la catégorie dans l'autre fichier » (une boucle dans une boucle, lent et illisible) en simple accès par clé. L'union set(spent) | set(budgets) garantit qu'aucune catégorie d'aucun des deux fichiers n'est oubliée — c'est la définition d'une jointure « externe complète », que tu viens d'implémenter sans le savoir. La même fonction load_column_map charge les deux fichiers : factorisation naturelle.
Erreur classique sur cet exercice — Itérer seulement sur les catégories du budget : les dépenses hors budget (le cas restaurant) disparaissent du rapport — précisément celles qu'un contrôle budgétaire veut voir ! L'autre sens (itérer sur les dépenses seules) perd les budgets non consommés. Seule l'union montre tout.
Variante plus difficile — Ajoute une colonne used_percent (part du budget consommée, vide si sans budget) et un TOTAL général en dernière ligne. Puis gère un budget mensuel face à des dépenses multi-mois : le budget doit être multiplié par le nombre de mois observés dans les données — à toi de le calculer depuis les dates.
Mini-projet — « rapport de dépenses »
Raisonnement — Aucune technique nouvelle : le projet évalue l'ASSEMBLAGE. Trois fonctions aux responsabilités nettes (charger — celle de l'exercice moyen a, presque telle quelle ; agréger — les accumulateurs ; restituer — texte, CSV, écran), un main() qui les enchaîne.
Solution — squelette de référence (le détail est le tien) :
# expense_report.py
import csv
from pathlib import Path
def load_expenses(path): # → (rows, ignored) [exercice moyen a]
...
def totals_by(rows, key_func): # → dict [key_func: lambda r: r["category"] ou r["date"][:7]]
...
def write_text_report(rows, ignored, totals_cat, path):
top = sorted(totals_cat.items(), key=lambda kv: kv[1], reverse=True)[:3]
biggest = max(rows, key=lambda r: r["amount"])
with open(path, "w", encoding="utf-8") as f:
f.write(f"Total : {sum(r['amount'] for r in rows):.2f} €\n")
f.write("Top catégories : "
+ ", ".join(f"{c} ({t:.2f} €)" for c, t in top) + "\n")
f.write(f"Plus grosse dépense : {biggest['category']} "
f"{biggest['amount']:.2f} € le {biggest['date']}\n")
f.write(f"Lignes ignorées : {ignored}\n")
def write_csv_summary(totals, path, key_name):
... # DictWriter, trié [exemple 5 de la leçon]
def main():
rows, ignored = load_expenses("expenses.csv")
by_category = totals_by(rows, lambda r: r["category"])
by_month = totals_by(rows, lambda r: r["date"][:7])
write_text_report(rows, ignored, by_category, "report.txt")
write_csv_summary(by_category, "by_category.csv", "category")
write_csv_summary(by_month, "by_month.csv", "month")
print(f"{len(rows)} dépenses analysées ({ignored} lignes ignorées)")
print(f"Total : {sum(r['amount'] for r in rows):.2f} €")
print("Fichiers : report.txt, by_category.csv, by_month.csv")
if __name__ == "__main__":
main()
Pourquoi ça marche — totals_by avec une fonction en paramètre (⏩ fonctions comme valeurs : niveau 04) sert les deux agrégations sans duplication. Les lignes sales sont neutralisées à l'entrée par le chargeur : tout le reste du programme travaille sur des données propres et typées — c'est l'architecture « défense aux frontières » de la leçon 09-3, appliquée aux fichiers.
Erreur classique sur ce projet — Vérifier que « ça tourne » sans vérifier que c'est JUSTE : le total de report.txt doit se recouper à la calculatrice avec quelques lignes du CSV source, et la somme des totaux de by_category.csv doit égaler le total général. Un rapport qui tourne mais ment est pire qu'un crash.
Variante plus difficile — Le script accepte le chemin du CSV en argument et, si on lui donne un DOSSIER, traite tous les .csv qu'il contient et produit un rapport consolidé. C'est exactement le pont vers la leçon 09-5 — pathlib t'y attend.