Aller au contenu

Chapitre 07 — Les fichiers : texte, CSV et JSON

Durée estimée : 5 à 6 heures (cours, exercices et mini-projet)

Prérequis : Chapitre 06 — Chaînes et compréhensions (méthodes de str, split/join, compréhensions)

Objectifs — à la fin de ce chapitre, tu sauras…

  • manipuler des chemins de fichiers proprement avec pathlib ;
  • ouvrir un fichier avec with open(...) dans le bon mode (r, w, a) et le bon encodage (UTF-8) ;
  • lire un fichier texte en entier ou ligne par ligne, et écrire dedans ;
  • lire et écrire des fichiers CSV avec csv.reader, csv.DictReader et csv.DictWriter ;
  • sauvegarder et recharger des données Python au format JSON ;
  • rendre un programme persistant : ses données survivent à sa fermeture.

Cours

1. Pourquoi des fichiers ?

Jusqu'ici, toutes les données de tes programmes disparaissaient à la fin de l'exécution. Ta liste de courses du chapitre 04, aussi belle soit-elle, repartait de zéro à chaque lancement. C'est parce que les variables vivent dans la mémoire vive (RAM) de l'ordinateur, qui est vidée quand le programme se termine.

Analogie : la mémoire vive, c'est ton bureau : rapide d'accès, mais on le débarrasse le soir. Un fichier, c'est un classeur rangé dans une armoire : un peu plus long à consulter, mais il est encore là le lendemain.

Les fichiers servent à :

  • sauvegarder des données (scores, contacts, paramètres) pour les retrouver plus tard ;
  • lire des données produites ailleurs (un export de tableur, un fichier de configuration…) ;
  • échanger avec d'autres programmes : le format CSV s'ouvre dans Excel ou LibreOffice, le format JSON est la langue commune du web.

Dans ce chapitre, on travaille avec trois sortes de fichiers texte :

Format Extension Usage Exemple de contenu
texte brut .txt notes, journaux, listes simples Acheter du pain
CSV .csv tableaux (lignes et colonnes) nom;age;ville
JSON .json données structurées (listes, dictionnaires) {"nom": "Alice", "age": 30}

2. Les chemins de fichiers avec pathlib

Avant d'ouvrir un fichier, il faut savoir où il se trouve. C'est le rôle du chemin (path).

Chemins absolus et relatifs

  • Un chemin absolu part de la racine du disque : /home/alice/projets/notes.txt (Linux, macOS) ou C:\Users\alice\projets\notes.txt (Windows).
  • Un chemin relatif part du répertoire courant, c'est-à-dire le dossier depuis lequel tu as lancé ton programme : notes.txt, donnees/contacts.json.

Attention : le répertoire courant est le dossier ouvert dans ton terminal au moment où tu tapes python mon_script.py, pas forcément le dossier où se trouve le script ! Dans VS Code, c'est généralement le dossier ouvert dans l'explorateur (cf. chapitre 00). On verra plus bas comment construire un chemin fiable.

Le module pathlib et l'objet Path

Les chemins s'écrivent différemment selon le système (/ sous Linux et macOS, \ sous Windows). Le module pathlib de la bibliothèque standard règle ce problème : on manipule des objets Path qui s'adaptent tout seuls au système.

On l'importe avec une ligne from ... import ... en haut du fichier (les imports seront détaillés au chapitre 09) :

from pathlib import Path

dossier = Path("donnees")
fichier = dossier / "contacts.json"     # l'opérateur / assemble les morceaux de chemin
print(fichier)                          # → donnees/contacts.json
print(fichier.name)                     # → contacts.json
print(fichier.stem)                     # → contacts
print(fichier.suffix)                   # → .json
print(fichier.parent)                   # → donnees

(Sous Windows, le premier print affichera donnees\contacts.json : c'est justement l'intérêt de pathlib.)

L'opérateur / est la grande astuce de pathlib : il colle les morceaux de chemin avec le bon séparateur. Plus besoin de concaténer des chaînes à la main.

Attribut ou méthode Résultat pour Path("donnees/contacts.json")
.name contacts.json (nom complet)
.stem contacts (nom sans extension)
.suffix .json (extension)
.parent donnees (dossier parent)
.exists() True si le fichier ou dossier existe
.is_file() / .is_dir() True si c'est un fichier / un dossier
.with_suffix(".csv") donnees/contacts.csv (change l'extension)

Quelques chemins utiles

from pathlib import Path

print(Path.cwd())     # le répertoire courant (current working directory)
print(Path.home())    # ton dossier personnel, ex. /home/alice ou C:\Users\alice

Pour obtenir le dossier où se trouve ton script (et non le répertoire courant), on utilise la variable spéciale __file__ :

from pathlib import Path

DOSSIER_SCRIPT = Path(__file__).parent
FICHIER_DONNEES = DOSSIER_SCRIPT / "contacts.json"

Ainsi, ton programme trouvera toujours contacts.json à côté du script, quel que soit le dossier d'où tu le lances. C'est la méthode recommandée pour les vrais projets. (Ce code ne fonctionne que dans un fichier .py, pas dans la console interactive.)

Créer des dossiers, tester l'existence, lister

from pathlib import Path

dossier = Path("essais") / "sous_dossier"
dossier.mkdir(parents=True, exist_ok=True)   # crée aussi les dossiers parents, sans erreur s'il existe déjà
print(dossier.exists())                      # → True
print(dossier.is_dir())                      # → True
print(Path("inexistant.txt").exists())       # → False
  • parents=True : crée tous les dossiers intermédiaires manquants (ici essais).
  • exist_ok=True : ne provoque pas d'erreur si le dossier existe déjà.

Pour lister le contenu d'un dossier, iterdir() renvoie tous les éléments et glob("*.txt") ceux qui correspondent à un motif (* signifie « n'importe quoi ») :

from pathlib import Path

dossier = Path("essais")
for nom in ["a.txt", "b.txt", "c.csv"]:
    (dossier / nom).write_text("contenu\n", encoding="utf-8")   # crée des fichiers (expliqué plus bas)

print(sorted(p.name for p in dossier.iterdir()))
print(sorted(p.name for p in dossier.glob("*.txt")))
['a.txt', 'b.txt', 'c.csv', 'sous_dossier']
['a.txt', 'b.txt']

L'ordre renvoyé par iterdir et glob n'est pas garanti : on utilise sorted pour un résultat stable. glob("**/*.txt") cherche aussi dans les sous-dossiers.


3. Ouvrir un fichier : open, with et les modes

La fonction open

Pour lire ou écrire dans un fichier, on l'ouvre avec open(chemin, mode, encoding=...). On obtient un objet « fichier » sur lequel on peut lire ou écrire. Puis il faut le fermer, pour que tout soit bien enregistré et que le fichier soit libéré.

Le bloc with : la bonne façon de faire

On ouvre toujours un fichier avec with :

from pathlib import Path

chemin = Path("bonjour.txt")
with open(chemin, "w", encoding="utf-8") as fichier:
    fichier.write("Bonjour le monde !\n")
    fichier.write("Deuxième ligne, avec des accents : é è ç ù.\n")

print("Fichier écrit, et déjà fermé.")
Fichier écrit, et déjà fermé.

Lis-le ainsi : « avec le fichier ouvert sous le nom fichier, fais… ». Dès qu'on sort du bloc indenté, Python ferme automatiquement le fichier, même si une erreur survient au milieu. C'est comme une porte qui se referme toute seule derrière toi.

Ouvre maintenant bonjour.txt dans VS Code : tu y verras tes deux lignes.

Les modes d'ouverture

Le deuxième argument de open indique ce qu'on veut faire :

Mode Nom Si le fichier existe S'il n'existe pas
"r" lecture (read), par défaut on le lit erreur FileNotFoundError
"w" écriture (write) son contenu est effacé ! il est créé
"a" ajout (append) on écrit à la fin il est créé
"x" création exclusive erreur FileExistsError il est créé

Le mode "w" est dangereux : il vide le fichier dès l'ouverture, avant même d'écrire quoi que ce soit. Pour ajouter des lignes sans rien perdre, utilise "a".

L'encodage : toujours encoding="utf-8"

Un fichier ne contient que des octets (des nombres). L'encodage est la règle qui traduit ces octets en caractères. Le standard universel aujourd'hui est UTF-8, qui gère tous les caractères : accents, emojis, alphabets du monde entier.

Si tu ne précises rien, Python utilise l'encodage par défaut de ton système, qui n'est pas forcément UTF-8 (sous Windows, c'est souvent cp1252). Résultat : un fichier écrit sur un ordinateur peut afficher é au lieu de é sur un autre. Voici ce qui se passe quand on relit un fichier UTF-8 avec le mauvais encodage :

from pathlib import Path

with open(Path("bonjour.txt"), "r", encoding="latin-1") as fichier:
    print(fichier.read())
Bonjour le monde !
Deuxième ligne, avec des accents : é è ç ù.

Règle d'or de ce chapitre : écris toujours encoding="utf-8" quand tu ouvres un fichier texte. Pour lire comme pour écrire.


4. Lire un fichier texte

Préparons un fichier de travail :

from pathlib import Path

with open(Path("fruits.txt"), "w", encoding="utf-8") as fichier:
    fichier.write("pomme\n")
    fichier.write("banane\n")
    fichier.write("cerise\n")

Il existe plusieurs façons de lire, selon le besoin.

Tout lire d'un coup : read()

from pathlib import Path

with open(Path("fruits.txt"), "r", encoding="utf-8") as fichier:
    contenu = fichier.read()

print(repr(contenu))
print(contenu)
'pomme\nbanane\ncerise\n'
pomme
banane
cerise

read() renvoie une seule chaîne contenant tout le fichier, retours à la ligne \n compris. repr() permet de voir ces caractères invisibles (d'où la ligne vide à la fin du second affichage : c'est le dernier \n, plus celui ajouté par print).

Comme on lit, on pourrait omettre "r" (c'est le mode par défaut), mais l'écrire rend le code plus explicite.

Ligne par ligne : boucler sur le fichier

Un objet fichier se parcourt avec for, ligne par ligne. C'est la méthode à privilégier, surtout pour les gros fichiers, car Python ne charge qu'une ligne à la fois en mémoire.

from pathlib import Path

with open(Path("fruits.txt"), "r", encoding="utf-8") as fichier:
    for numero, ligne in enumerate(fichier, start=1):
        print(f"{numero}: {ligne}")
1: pomme

2: banane

3: cerise

Pourquoi ces lignes vides ? Chaque ligne se termine par \n, et print en ajoute un autre. On retire donc presque toujours le retour à la ligne avec strip() (ou rstrip("\n") pour ne retirer que lui) :

from pathlib import Path

with open(Path("fruits.txt"), "r", encoding="utf-8") as fichier:
    for numero, ligne in enumerate(fichier, start=1):
        ligne = ligne.strip()
        print(f"{numero}: {ligne}")
1: pomme
2: banane
3: cerise

Obtenir une liste de lignes

Pour récupérer une liste de lignes propres, la façon la plus simple combine read() et splitlines() (vue au chapitre 06), qui découpe sur les retours à la ligne et les supprime :

from pathlib import Path

with open(Path("fruits.txt"), "r", encoding="utf-8") as fichier:
    fruits = fichier.read().splitlines()

print(fruits)   # → ['pomme', 'banane', 'cerise']

Il existe aussi fichier.readlines(), qui renvoie une liste de lignes avec leur \n final, et fichier.readline() (sans s), qui lit une seule ligne à la fois.

Les raccourcis de pathlib : read_text et write_text

Pour les cas simples (lire ou écrire tout un fichier d'un coup), un objet Path propose deux méthodes qui ouvrent et ferment le fichier pour toi :

from pathlib import Path

chemin = Path("message.txt")
chemin.write_text("Salut !\nÀ bientôt.\n", encoding="utf-8")   # mode "w" : écrase !

texte = chemin.read_text(encoding="utf-8")
print(texte.splitlines())   # → ['Salut !', 'À bientôt.']

C'est très pratique, mais write_text remplace tout le contenu, comme le mode "w". Pour ajouter à la fin ou traiter un gros fichier ligne par ligne, reviens à with open(...).

Que se passe-t-il si le fichier n'existe pas ?

from pathlib import Path

with open(Path("inexistant.txt"), "r", encoding="utf-8") as fichier:
    print(fichier.read())
Traceback (most recent call last):
  File "<stdin>", line 3, in <module>
FileNotFoundError: [Errno 2] No such file or directory: 'inexistant.txt'

Pour l'instant, la solution est de vérifier avant avec exists() :

from pathlib import Path

chemin = Path("inexistant.txt")
if chemin.exists():
    print(chemin.read_text(encoding="utf-8"))
else:
    print(f"Le fichier {chemin} n'existe pas encore.")   # → Le fichier inexistant.txt n'existe pas encore.

Au chapitre 08, tu apprendras à intercepter ce genre d'erreur avec try/except, ce qui est souvent plus robuste.


5. Écrire dans un fichier

write() : écrire une chaîne

fichier.write(chaine) écrit la chaîne telle quelle : il n'ajoute pas de retour à la ligne, contrairement à print. Il faut mettre les \n soi-même.

from pathlib import Path

with open(Path("sans_retour.txt"), "w", encoding="utf-8") as fichier:
    fichier.write("un")
    fichier.write("deux")
    fichier.write("trois")

print(Path("sans_retour.txt").read_text(encoding="utf-8"))   # → undeuxtrois

write() n'accepte que des chaînes : pour écrire un nombre, convertis-le avec str() ou une f-string.

from pathlib import Path

notes = [12, 15.5, 9]
with open(Path("notes.txt"), "w", encoding="utf-8") as fichier:
    for note in notes:
        fichier.write(f"{note}\n")

print(Path("notes.txt").read_text(encoding="utf-8").splitlines())   # → ['12', '15.5', '9']

Pour écrire une liste de lignes d'un coup, utilise join :

from pathlib import Path

villes = ["Paris", "Lyon", "Marseille"]
with open(Path("villes.txt"), "w", encoding="utf-8") as fichier:
    fichier.write("\n".join(villes) + "\n")

print(Path("villes.txt").read_text(encoding="utf-8").splitlines())   # → ['Paris', 'Lyon', 'Marseille']

Tu peux aussi utiliser print en lui indiquant un fichier : il ajoute alors automatiquement les espaces et le retour à la ligne, comme à l'écran.

from pathlib import Path

with open(Path("rapport.txt"), "w", encoding="utf-8") as fichier:
    print("Rapport du jour", file=fichier)
    print("Total :", 42, "ventes", file=fichier)

print(Path("rapport.txt").read_text(encoding="utf-8"), end="")
Rapport du jour
Total : 42 ventes

Le mode "a" : ajouter à la fin

Le mode ajout est idéal pour un journal (log) : chaque exécution ajoute des lignes sans effacer les précédentes.

from pathlib import Path

journal = Path("journal.txt")

with open(journal, "w", encoding="utf-8") as fichier:      # on repart d'un fichier vide
    fichier.write("Démarrage\n")

with open(journal, "a", encoding="utf-8") as fichier:      # ajout
    fichier.write("Connexion d'Alice\n")

with open(journal, "a", encoding="utf-8") as fichier:      # nouvel ajout
    fichier.write("Connexion de Bob\n")

print(journal.read_text(encoding="utf-8"), end="")
Démarrage
Connexion d'Alice
Connexion de Bob

Si on avait utilisé "w" à chaque fois, il ne resterait que la dernière ligne.

Exemple complet : traiter un fichier et en écrire un autre

Un schéma très courant : lire un fichier, transformer chaque ligne, écrire le résultat dans un autre fichier. Ici, on nettoie une liste d'inscrits (espaces superflus, lignes vides, majuscules incohérentes, doublons).

from pathlib import Path

brut = Path("inscrits_brut.txt")
brut.write_text("  alice MARTIN\n\nBob durand  \nalice martin\n  \nchloé petit\n", encoding="utf-8")

propres = []
with open(brut, "r", encoding="utf-8") as entree:
    for ligne in entree:
        nom = " ".join(ligne.split()).title()     # espaces normalisés + majuscules
        if nom != "" and nom not in propres:      # ignore lignes vides et doublons
            propres.append(nom)

with open(Path("inscrits.txt"), "w", encoding="utf-8") as sortie:
    for nom in sorted(propres):
        sortie.write(nom + "\n")

print(Path("inscrits.txt").read_text(encoding="utf-8"), end="")
Alice Martin
Bob Durand
Chloé Petit

6. Les fichiers CSV

Le format CSV

CSV signifie Comma-Separated Values, « valeurs séparées par des virgules ». C'est le format le plus simple pour stocker un tableau : une ligne du fichier par ligne du tableau, et un séparateur entre les colonnes. La première ligne contient généralement les en-têtes (noms des colonnes).

nom,age,ville
Alice,30,Lyon
Bob,25,Paris

En France, les tableurs utilisent souvent le point-virgule ; comme séparateur, car la virgule sert de séparateur décimal (12,5). Tous les tableurs (Excel, LibreOffice Calc, Google Sheets) savent ouvrir et exporter du CSV.

Pourquoi ne pas simplement utiliser split(",") ? Parce qu'une valeur peut elle-même contenir une virgule ou un retour à la ligne. Le format CSV prévoit alors de l'entourer de guillemets : "Dupont, Jean",42. Un split naïf couperait au mauvais endroit. Le module csv de la bibliothèque standard gère tous ces cas pour toi.

Écrire un CSV avec csv.writer

import csv
from pathlib import Path

eleves = [
    ["nom", "note", "classe"],
    ["Alice", 15.5, "3A"],
    ["Dupont, Jean", 12, "3B"],
    ["Chloé", 18, "3A"],
]

with open(Path("eleves.csv"), "w", encoding="utf-8", newline="") as fichier:
    ecrivain = csv.writer(fichier)
    ecrivain.writerows(eleves)          # écrit toutes les lignes d'un coup

print(Path("eleves.csv").read_text(encoding="utf-8"), end="")
nom,note,classe
Alice,15.5,3A
"Dupont, Jean",12,3B
Chloé,18,3A

Points importants :

  • newline="" est obligatoire pour les fichiers CSV : le module csv gère lui-même les fins de ligne. Sans cela, sous Windows, tu obtiendrais une ligne vide entre chaque ligne.
  • writerow(liste) écrit une ligne, writerows(liste_de_listes) en écrit plusieurs.
  • Les nombres sont convertis automatiquement en texte.
  • La valeur contenant une virgule a été entourée de guillemets automatiquement.

Lire un CSV avec csv.reader

import csv
from pathlib import Path

with open(Path("eleves.csv"), "r", encoding="utf-8", newline="") as fichier:
    lecteur = csv.reader(fichier)
    for ligne in lecteur:
        print(ligne)
['nom', 'note', 'classe']
['Alice', '15.5', '3A']
['Dupont, Jean', '12', '3B']
['Chloé', '18', '3A']

Chaque ligne devient une liste de chaînes. Remarque deux choses :

  1. Les guillemets autour de Dupont, Jean ont été correctement interprétés.
  2. Tout est du texte : '15.5' et '12' sont des chaînes ! Pour calculer, il faut convertir avec float() ou int().

Pour sauter la ligne d'en-têtes, on peut utiliser next(lecteur), qui lit la ligne suivante :

import csv
from pathlib import Path

with open(Path("eleves.csv"), "r", encoding="utf-8", newline="") as fichier:
    lecteur = csv.reader(fichier)
    en_tetes = next(lecteur)            # consomme la première ligne
    notes = [float(ligne[1]) for ligne in lecteur]

print(en_tetes)                          # → ['nom', 'note', 'classe']
print(notes)                             # → [15.5, 12.0, 18.0]
print(f"Moyenne : {sum(notes) / len(notes):.2f}")   # → Moyenne : 15.17

Changer de séparateur : delimiter

Pour un CSV « à la française » avec des points-virgules, on précise delimiter=";", à la lecture comme à l'écriture :

import csv
from pathlib import Path

Path("depenses.csv").write_text(
    "date;libelle;montant\n2024-03-01;Courses;54,20\n2024-03-03;Cinéma;11,50\n",
    encoding="utf-8",
)

total = 0
with open(Path("depenses.csv"), "r", encoding="utf-8", newline="") as fichier:
    lecteur = csv.reader(fichier, delimiter=";")
    next(lecteur)
    for date, libelle, montant in lecteur:
        valeur = float(montant.replace(",", "."))   # 54,20 → 54.20
        total += valeur
        print(f"{date} {libelle:<10} {valeur:>7.2f}")

print(f"Total : {total:.2f} €")
2024-03-01 Courses      54.20
2024-03-03 Cinéma       11.50
Total : 65.70 €

Note le déballage for date, libelle, montant in lecteur et la conversion de la virgule décimale en point, indispensable avant float().

Lire avec csv.DictReader : accéder aux colonnes par leur nom

Accéder aux colonnes par indice (ligne[1]) n'est pas très lisible, et casse dès qu'on ajoute une colonne. csv.DictReader utilise la ligne d'en-têtes pour transformer chaque ligne en dictionnaire :

import csv
from pathlib import Path

with open(Path("eleves.csv"), "r", encoding="utf-8", newline="") as fichier:
    lecteur = csv.DictReader(fichier)
    for eleve in lecteur:
        print(eleve)
{'nom': 'Alice', 'note': '15.5', 'classe': '3A'}
{'nom': 'Dupont, Jean', 'note': '12', 'classe': '3B'}
{'nom': 'Chloé', 'note': '18', 'classe': '3A'}

C'est beaucoup plus parlant : eleve["note"] au lieu de eleve[1]. La ligne d'en-têtes est consommée automatiquement. Et on retrouve la liste de dictionnaires du chapitre 04 :

import csv
from pathlib import Path

with open(Path("eleves.csv"), "r", encoding="utf-8", newline="") as fichier:
    eleves = list(csv.DictReader(fichier))

classe_3a = [e["nom"] for e in eleves if e["classe"] == "3A"]
meilleur = max(eleves, key=lambda e: float(e["note"]))
print(classe_3a)                 # → ['Alice', 'Chloé']
print(meilleur["nom"])           # → Chloé

Écrire avec csv.DictWriter

Symétriquement, csv.DictWriter écrit une liste de dictionnaires. On lui indique la liste des colonnes avec fieldnames, et on écrit la ligne d'en-têtes avec writeheader() :

import csv
from pathlib import Path

produits = [
    {"nom": "Stylo", "prix": 1.5, "stock": 120},
    {"nom": "Cahier", "prix": 3.2, "stock": 45},
    {"nom": "Gomme", "prix": 0.8, "stock": 0},
]

colonnes = ["nom", "prix", "stock"]
with open(Path("produits.csv"), "w", encoding="utf-8", newline="") as fichier:
    ecrivain = csv.DictWriter(fichier, fieldnames=colonnes, delimiter=";")
    ecrivain.writeheader()
    ecrivain.writerows(produits)

print(Path("produits.csv").read_text(encoding="utf-8"), end="")
nom;prix;stock
Stylo;1.5;120
Cahier;3.2;45
Gomme;0.8;0
  • L'ordre des colonnes dans le fichier est celui de fieldnames.
  • Si un dictionnaire contient une clé absente de fieldnames, DictWriter provoque une erreur ValueError (on peut ajouter extrasaction="ignore" pour l'ignorer).
  • Si une clé manque dans un dictionnaire, la case reste vide.

Ajouter des lignes à un CSV existant

On combine le mode "a" et DictWriter, sans réécrire les en-têtes :

import csv
from pathlib import Path

nouveau = {"nom": "Règle", "prix": 2.1, "stock": 30}
with open(Path("produits.csv"), "a", encoding="utf-8", newline="") as fichier:
    ecrivain = csv.DictWriter(fichier, fieldnames=["nom", "prix", "stock"], delimiter=";")
    ecrivain.writerow(nouveau)

print(Path("produits.csv").read_text(encoding="utf-8"), end="")
nom;prix;stock
Stylo;1.5;120
Cahier;3.2;45
Gomme;0.8;0
Règle;2.1;30

Et Excel ? Excel a parfois du mal à détecter l'UTF-8 et affiche alors des caractères bizarres. Si un fichier doit être ouvert par Excel, utilise encoding="utf-8-sig" à l'écriture : cela ajoute une petite signature invisible au début du fichier que Excel reconnaît. Python relit sans problème ce fichier avec encoding="utf-8-sig".


7. Les fichiers JSON

Le format JSON

JSON (JavaScript Object Notation) est un format texte pour représenter des données structurées : des listes et des dictionnaires imbriqués. C'est la langue commune des applications web et de très nombreux fichiers de configuration. Bonne nouvelle : il ressemble énormément à la syntaxe Python !

{
    "nom": "Alice",
    "age": 30,
    "abonne": true,
    "telephone": null,
    "loisirs": ["lecture", "escalade"],
    "adresse": {"ville": "Lyon", "code_postal": "69001"}
}

Correspondance entre JSON et Python :

JSON Python
objet {...} dict
tableau [...] list
chaîne "..." (guillemets doubles obligatoires) str
nombre 42, 3.14 int, float
true / false True / False
null None

Contrairement au CSV, qui ne stocke que des tableaux de textes, le JSON conserve la structure et les types (nombres, booléens, listes imbriquées…). C'est le format idéal pour sauvegarder les données d'un programme.

Sauvegarder avec json.dump

import json
from pathlib import Path

contact = {
    "nom": "Chloé",
    "age": 28,
    "abonne": True,
    "telephone": None,
    "loisirs": ["piano", "randonnée"],
}

with open(Path("contact.json"), "w", encoding="utf-8") as fichier:
    json.dump(contact, fichier, ensure_ascii=False, indent=4)

print(Path("contact.json").read_text(encoding="utf-8"))
{
    "nom": "Chloé",
    "age": 28,
    "abonne": true,
    "telephone": null,
    "loisirs": [
        "piano",
        "randonnée"
    ]
}

json.dump(donnees, fichier, ...) convertit les données Python en texte JSON et les écrit dans le fichier. Deux options à utiliser systématiquement :

  • indent=4 : met en forme le JSON sur plusieurs lignes avec une indentation, pour qu'il soit lisible par un humain. Sans cela, tout est écrit sur une seule ligne.
  • ensure_ascii=False : écrit les caractères accentués tels quels. Sans cette option, Python remplace chaque caractère non-ASCII par un code, comme \u00e9 pour é : le fichier reste valide, mais devient illisible pour un humain.

Compare :

import json

print(json.dumps({"ville": "Besançon"}))
print(json.dumps({"ville": "Besançon"}, ensure_ascii=False))
{"ville": "Besan\u00e7on"}
{"ville": "Besançon"}

(json.dumps, avec un s pour string, renvoie le JSON sous forme de chaîne au lieu de l'écrire dans un fichier.)

Charger avec json.load

import json
from pathlib import Path

with open(Path("contact.json"), "r", encoding="utf-8") as fichier:
    contact = json.load(fichier)

print(type(contact))         # → <class 'dict'>
print(contact["nom"])        # → Chloé
print(contact["loisirs"])    # → ['piano', 'randonnée']
print(contact["abonne"])     # → True
print(contact["telephone"])  # → None

json.load(fichier) lit le fichier et reconstruit les objets Python : on retrouve un vrai dictionnaire, avec de vrais booléens, de vraies listes, de vrais nombres. Aucune conversion manuelle n'est nécessaire, contrairement au CSV.

Il existe aussi json.loads(chaine) (avec un s), qui analyse une chaîne JSON :

import json

donnees = json.loads('{"scores": [12, 18, 15], "joueur": "Léo"}')
print(donnees["joueur"], max(donnees["scores"]))   # → Léo 18

Pour retenir : dump = de Python vers le JSON (on « vide » ses données dans le fichier), load = du JSON vers Python (on « charge »). Avec un s final, on travaille sur une chaîne au lieu d'un fichier.

Sauvegarder toute une structure

Le JSON gère les structures imbriquées : une liste de dictionnaires, un dictionnaire de listes… Voici le schéma typique d'un programme qui conserve ses données entre deux exécutions :

import json
from pathlib import Path

FICHIER_SCORES = Path("scores.json")


def charger_scores(chemin):
    """Renvoie le dictionnaire des scores, ou un dictionnaire vide si le fichier n'existe pas."""
    if not chemin.exists():
        return {}
    with open(chemin, "r", encoding="utf-8") as fichier:
        return json.load(fichier)


def sauvegarder_scores(scores, chemin):
    """Écrit le dictionnaire des scores dans le fichier JSON."""
    with open(chemin, "w", encoding="utf-8") as fichier:
        json.dump(scores, fichier, ensure_ascii=False, indent=2)


scores = charger_scores(FICHIER_SCORES)
print(f"Scores chargés : {scores}")

scores["Inès"] = scores.get("Inès", 0) + 150
scores["Noé"] = scores.get("Noé", 0) + 90
sauvegarder_scores(scores, FICHIER_SCORES)

print(f"Scores sauvegardés : {charger_scores(FICHIER_SCORES)}")

Première exécution :

Scores chargés : {}
Scores sauvegardés : {'Inès': 150, 'Noé': 90}

Si tu relances le programme, les scores sont retrouvés et augmentés :

Scores chargés : {'Inès': 150, 'Noé': 90}
Scores sauvegardés : {'Inès': 300, 'Noé': 180}

Ton programme a désormais une mémoire ! Retiens bien ce couple de fonctions charger / sauvegarder : tu le réutiliseras dans presque tous tes projets.

Ce que JSON ne sait pas stocker

JSON ne connaît que les types du tableau ci-dessus. Quelques conséquences :

  • Les tuples deviennent des listes : (1, 2) est relu comme [1, 2].
  • Les ensembles (set) ne sont pas acceptés : convertis-les en liste avant (list(mon_ensemble)).
  • Les clés de dictionnaire deviennent toujours des chaînes (voir les pièges).
  • Les dates et les objets que tu créeras plus tard (chapitres 09 et 10) doivent être convertis à la main (en chaîne ou en dictionnaire).
import json

donnees = {"point": (3, 4), "tags": sorted({"b", "a"})}
texte = json.dumps(donnees)
print(texte)               # → {"point": [3, 4], "tags": ["a", "b"]}
print(json.loads(texte))   # → {'point': [3, 4], 'tags': ['a', 'b']}

Essayer d'enregistrer un ensemble directement provoque une erreur :

import json

json.dumps({"tags": {"python", "json"}})
Traceback (most recent call last):
  File "<stdin>", line 3, in <module>
TypeError: Object of type set is not JSON serializable

8. CSV ou JSON : lequel choisir ?

Critère CSV JSON
Structure un tableau plat (lignes × colonnes) arbitraire : listes et dictionnaires imbriqués
Types tout est texte, il faut convertir nombres, booléens, null conservés
Lisible par un tableur oui (Excel, LibreOffice) non
Usage typique exports, données tabulaires, échanges avec des non-programmeurs sauvegarde de l'état d'un programme, configuration, web (API)

En pratique : si tes données ressemblent à un tableau et doivent être ouvertes dans un tableur, choisis le CSV. Si elles sont structurées (un contact avec plusieurs téléphones, des paramètres imbriqués), ou s'il s'agit simplement de sauvegarder les variables de ton programme, choisis le JSON.

On peut d'ailleurs passer facilement de l'un à l'autre : csv.DictReader produit une liste de dictionnaires, que json.dump sait écrire, et inversement (c'est l'exercice 7).


Pièges fréquents

Piège 1 — Oublier encoding="utf-8"

from pathlib import Path

Path("accents.txt").write_text("Crème brûlée\n", encoding="utf-8")
with open(Path("accents.txt"), "r", encoding="ascii") as fichier:   # mauvais encodage
    print(fichier.read())
Traceback (most recent call last):
  File "<stdin>", line 5, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 2: ordinal not in range(128)

Selon le système et l'encodage utilisé, on obtient soit une UnicodeDecodeError comme ici, soit des caractères bizarres (Crème brûlée). Si tu n'indiques pas d'encodage, Python utilise celui de ton système, et ton programme peut fonctionner chez toi mais pas chez quelqu'un d'autre. Correction : toujours encoding="utf-8", en lecture et en écriture.

Piège 2 — Écraser un fichier avec le mode "w"

from pathlib import Path

with open(Path("journal.txt"), "w", encoding="utf-8") as fichier:
    fichier.write("Nouvelle entrée\n")

print(Path("journal.txt").read_text(encoding="utf-8"), end="")   # → Nouvelle entrée

Toutes les entrées précédentes du journal ont disparu, sans aucun avertissement. Correction : utiliser le mode "a" pour ajouter. En cas de doute, vérifie avec exists() avant d'écrire, ou utilise "x" qui refuse d'écraser un fichier existant.

Piège 3 — Oublier les \n avec write

from pathlib import Path

with open(Path("courses.txt"), "w", encoding="utf-8") as fichier:
    for article in ["pain", "lait", "oeufs"]:
        fichier.write(article)

print(Path("courses.txt").read_text(encoding="utf-8"))   # → painlaitoeufs

Correction : fichier.write(article + "\n"), ou print(article, file=fichier).

Piège 4 — Oublier de retirer les \n à la lecture

from pathlib import Path

Path("mdp.txt").write_text("secret123\n", encoding="utf-8")
with open(Path("mdp.txt"), "r", encoding="utf-8") as fichier:
    mot_de_passe = fichier.readline()

print(mot_de_passe == "secret123")   # → False
print(repr(mot_de_passe))            # → 'secret123\n'

Le \n fait partie de la ligne lue ! Correction : fichier.readline().strip(). Quand une comparaison échoue mystérieusement, affiche la valeur avec repr() pour voir les caractères invisibles.

Piège 5 — Calculer avec des valeurs CSV sans les convertir

import csv
from pathlib import Path

Path("ventes.csv").write_text("produit,quantite\nA,3\nB,5\n", encoding="utf-8")
with open(Path("ventes.csv"), "r", encoding="utf-8", newline="") as fichier:
    total = 0
    for ligne in csv.DictReader(fichier):
        total += ligne["quantite"]
Traceback (most recent call last):
  File "<stdin>", line 7, in <module>
TypeError: unsupported operand type(s) for +=: 'int' and 'str'

Le module csv renvoie toujours des chaînes. Correction : total += int(ligne["quantite"]).

Piège 6 — Oublier newline="" avec le module csv

Sous Linux et macOS, on ne voit pas la différence. Mais sous Windows, sans newline="", chaque ligne écrite par csv.writer est suivie d'une ligne vide quand on ouvre le fichier dans un tableur. Correction : ouvre toujours un fichier CSV avec open(chemin, mode, encoding="utf-8", newline=""), en lecture comme en écriture.

Piège 7 — Lire deux fois le même fichier ouvert

from pathlib import Path

with open(Path("fruits.txt"), "r", encoding="utf-8") as fichier:
    premiere_lecture = fichier.read()
    deuxieme_lecture = fichier.read()

print(len(premiere_lecture))   # → 20
print(repr(deuxieme_lecture))  # → ''

Un fichier ouvert a un curseur, comme une tête de lecture : après le premier read(), il est à la fin et il n'y a plus rien à lire. Même chose si tu boucles deux fois sur un fichier ou un csv.reader. Correction : stocke le contenu dans une variable (une liste, par exemple) et réutilise-la, ou rouvre le fichier.

Piège 8 — Les clés JSON deviennent des chaînes

import json

niveaux = {1: "facile", 2: "moyen"}
relu = json.loads(json.dumps(niveaux))
print(relu)           # → {'1': 'facile', '2': 'moyen'}
print(relu.get(1))    # → None

En JSON, les clés d'un objet sont toujours des chaînes. Après un aller-retour, 1 est devenu "1", et relu[1] provoquerait une KeyError. Correction : utiliser des chaînes comme clés dès le départ, ou reconvertir après chargement : {int(cle): valeur for cle, valeur in relu.items()}.


Exercices

Pour chaque exercice, le corrigé commence par créer les fichiers de données nécessaires : tu peux donc les tester dans un dossier vide.

Exercice 1 — Écrire puis relire ⭐

  1. Écris dans table7.txt la table de multiplication de 7, de 1 à 10, une opération par ligne (7 x 1 = 7).
  2. Relis le fichier et affiche seulement les lignes dont le résultat est pair, précédées de leur numéro de ligne.

Sortie attendue :

Ligne 2 : 7 x 2 = 14
Ligne 4 : 7 x 4 = 28
Ligne 6 : 7 x 6 = 42
Ligne 8 : 7 x 8 = 56
Ligne 10 : 7 x 10 = 70

Exercice 2 — Compteur de fichier ⭐

Écris une fonction statistiques_fichier(chemin) qui renvoie un dictionnaire avec le nombre de lignes, de mots et de caractères d'un fichier texte (comme la commande wc de Linux), en comptant les caractères sans les retours à la ligne. Teste-la sur un fichier poeme.txt contenant :

Demain, dès l'aube, à l'heure où blanchit la campagne,
Je partirai. Vois-tu, je sais que tu m'attends.
J'irai par la forêt, j'irai par la montagne.

Sortie attendue :

{'lignes': 3, 'mots': 25, 'caracteres': 145}

Exercice 3 — Nettoyer un fichier de configuration ⭐⭐

Un fichier config.txt contient des réglages cle = valeur, des lignes vides et des commentaires commençant par # :

# Configuration du jeu
largeur = 800
hauteur=600

# Son
volume =  75
pseudo = Joueur 1

Écris une fonction lire_config(chemin) qui renvoie un dictionnaire des réglages, en ignorant les commentaires et les lignes vides, et en nettoyant les espaces autour des clés et des valeurs. Indice : ligne.split("=", 1) coupe uniquement au premier =.

print(lire_config(Path("config.txt")))
{'largeur': '800', 'hauteur': '600', 'volume': '75', 'pseudo': 'Joueur 1'}

Exercice 4 — Bulletin à partir d'un CSV ⭐⭐

Le fichier notes.csv (séparateur ;) contient une ligne par élève :

nom;maths;francais;anglais
Alice;15;12;17
Bob;9;11;8
Chloé;18;16;14
David;10;9;12

Avec csv.DictReader et csv.DictWriter, crée un fichier bulletin.csv (séparateur ;) avec les colonnes nom, moyenne (arrondie à 2 décimales) et resultat (admis si moyenne ≥ 10, sinon ajourné), trié par moyenne décroissante. Affiche ensuite son contenu.

Sortie attendue :

nom;moyenne;resultat
Chloé;16.0;admis
Alice;14.67;admis
David;10.33;admis
Bob;9.33;ajourné

Exercice 5 — Préférences en JSON ⭐⭐

Écris un programme qui gère un fichier preferences.json : - une constante PREFERENCES_PAR_DEFAUT = {"theme": "clair", "langue": "fr", "taille_police": 12} ; - une fonction charger_preferences(chemin) qui renvoie les préférences du fichier s'il existe, complétées par les valeurs par défaut pour les clés manquantes ; si le fichier n'existe pas, elle renvoie une copie des valeurs par défaut ; - une fonction sauvegarder_preferences(preferences, chemin) (JSON lisible, accents conservés).

Teste : supprime le fichier s'il existe (chemin.unlink(missing_ok=True)), charge, passe le thème à "sombre", sauvegarde ; écris ensuite à la main un fichier ne contenant que {"langue": "en"} et recharge.

Au départ : {'theme': 'clair', 'langue': 'fr', 'taille_police': 12}
Après sauvegarde : {'theme': 'sombre', 'langue': 'fr', 'taille_police': 12}
Fichier partiel : {'theme': 'clair', 'langue': 'en', 'taille_police': 12}

Exercice 6 — Inventaire d'un dossier ⭐⭐

Crée un dossier inventaire contenant les fichiers rapport.txt, photo.jpg, budget.csv, notes.txt et vacances.jpg (contenu au choix, par exemple leur propre nom). Écris une fonction inventaire_par_extension(dossier) qui renvoie un dictionnaire {extension: [noms de fichiers triés]}, puis affiche-le par extension triée.

Sortie attendue :

.csv : budget.csv
.jpg : photo.jpg, vacances.jpg
.txt : notes.txt, rapport.txt

Exercice 7 — Convertisseur CSV ↔ JSON ⭐⭐⭐

Écris deux fonctions : - csv_vers_json(chemin_csv, chemin_json) : lit un CSV (séparateur ,) avec DictReader et écrit la liste de dictionnaires en JSON. Les valeurs qui ne contiennent que des chiffres doivent être converties en int ; - json_vers_csv(chemin_json, chemin_csv) : fait l'inverse avec DictWriter, en prenant comme colonnes les clés du premier élément.

Teste avec un fichier villes.csv :

ville,departement,habitants
Lyon,Rhône,522250
Lille,Nord,236710
Nantes,Loire-Atlantique,320732

Puis reconvertis le JSON en villes_copie.csv et vérifie que le contenu est identique à l'original.

Sortie attendue :

[
  {
    "ville": "Lyon",
    "departement": "Rhône",
    "habitants": 522250
  },
  {
    "ville": "Lille",
    "departement": "Nord",
    "habitants": 236710
  },
  {
    "ville": "Nantes",
    "departement": "Loire-Atlantique",
    "habitants": 320732
  }
]
Total habitants : 1079692
Copie identique : True

Exercice 8 — Rapport de ventes ⭐⭐⭐

Le fichier ventes.csv liste des ventes :

date,produit,categorie,quantite,prix_unitaire
2024-05-02,Clavier,Informatique,2,49.90
2024-05-02,Stylo,Papeterie,10,1.20
2024-05-03,Souris,Informatique,3,19.90
2024-05-03,Cahier,Papeterie,5,3.50
2024-05-04,Clavier,Informatique,1,49.90
2024-05-04,Casque,Audio,1,89.00

Écris un programme qui : 1. calcule le chiffre d'affaires (quantité × prix) par catégorie et le nombre d'articles vendus par catégorie ; 2. écrit rapport_ventes.csv avec les colonnes categorie, articles, chiffre_affaires (2 décimales), trié par chiffre d'affaires décroissant ; 3. écrit rapport_ventes.json contenant {"total": ..., "meilleur_produit": ..., "categories": {...}}, où meilleur_produit est le produit au plus gros chiffre d'affaires cumulé ; 4. affiche le contenu des deux fichiers.

Sortie attendue :

categorie,articles,chiffre_affaires
Informatique,6,209.40
Audio,1,89.00
Papeterie,15,29.50
{
  "total": 327.9,
  "meilleur_produit": "Clavier",
  "categories": {
    "Informatique": 209.4,
    "Papeterie": 29.5,
    "Audio": 89.0
  }
}

Mini-projet — Le carnet de contacts sauvegardé en JSON

Cahier des charges

Écris un carnet de contacts en ligne de commande, dont les données survivent à la fermeture du programme grâce à un fichier contacts.json.

Chaque contact est un dictionnaire :

{"nom": "Martin", "prenom": "Alice", "telephone": "06 12 34 56 78", "email": "alice@exemple.fr"}

Le carnet entier est une liste de ces dictionnaires. Le programme affiche un menu en boucle :

=== CARNET DE CONTACTS (2 contacts) ===
1. Lister les contacts
2. Ajouter un contact
3. Rechercher
4. Supprimer un contact
5. Exporter en CSV
6. Quitter
  • Au démarrage, les contacts sont chargés depuis contacts.json (liste vide si le fichier n'existe pas).
  • Lister : affiche les contacts triés par nom puis prénom, numérotés, dans un tableau aligné.
  • Ajouter : demande nom, prénom, téléphone et email ; nom et prénom sont obligatoires (on redemande s'ils sont vides). Le nom est mis en majuscules, le prénom avec une majuscule initiale. Sauvegarde immédiate.
  • Rechercher : demande un texte et affiche les contacts dont le nom, le prénom ou l'email le contient (sans tenir compte de la casse).
  • Supprimer : affiche la liste numérotée, demande un numéro, demande confirmation (o/n), supprime et sauvegarde.
  • Exporter : écrit tous les contacts dans contacts.csv (séparateur ;, encodage utf-8-sig pour Excel) avec DictWriter.
  • Quitter : message d'au revoir.

Étapes suggérées

  1. Écris charger_contacts(chemin) et sauvegarder_contacts(contacts, chemin) (sur le modèle des scores de la section 7) et teste-les seules.
  2. Écris afficher_contacts(contacts) avec un tableau aligné par f-strings et sorted(..., key=lambda c: (c["nom"], c["prenom"])).
  3. Écris demander_non_vide(message) puis ajouter_contact(contacts).
  4. Écris rechercher(contacts, texte) qui renvoie la liste des résultats (sans print).
  5. Écris supprimer_contact(contacts) en vérifiant que le numéro saisi est valide (isdigit() et dans les bornes).
  6. Écris exporter_csv(contacts, chemin).
  7. Assemble le tout dans main() avec le menu.

Pistes d'amélioration

  • Modifier un contact existant.
  • Refuser les doublons (même nom et même prénom).
  • Vérifier le format de l'email (présence d'un @ et d'un . après) et du téléphone (10 chiffres).
  • Permettre plusieurs numéros par contact (une liste dans le dictionnaire : impossible en CSV simple, facile en JSON !).
  • Importer des contacts depuis un CSV.
  • Faire une sauvegarde de sécurité contacts.bak.json avant chaque écriture.
  • Gérer un fichier JSON corrompu avec try/except (chapitre 08), puis transformer les contacts en objets (chapitre 10).

Corrigés

Corrigé de l'exercice 1
from pathlib import Path

chemin = Path("table7.txt")

# 1. Écriture
with open(chemin, "w", encoding="utf-8") as fichier:
    for i in range(1, 11):
        fichier.write(f"7 x {i} = {7 * i}\n")

# 2. Relecture et filtrage
with open(chemin, "r", encoding="utf-8") as fichier:
    for numero, ligne in enumerate(fichier, start=1):
        ligne = ligne.strip()
        resultat = int(ligne.split("=")[1])
        if resultat % 2 == 0:
            print(f"Ligne {numero} : {ligne}")
Sortie :
Ligne 2 : 7 x 2 = 14
Ligne 4 : 7 x 4 = 28
Ligne 6 : 7 x 6 = 42
Ligne 8 : 7 x 8 = 56
Ligne 10 : 7 x 10 = 70
À l'écriture, la f-string se termine par `\n` pour que chaque opération soit sur sa propre ligne. À la lecture, on boucle directement sur le fichier avec `enumerate` pour avoir le numéro de ligne, on retire le `\n` avec `strip()`, puis on récupère le résultat en coupant sur `=` : `"7 x 2 = 14".split("=")` donne `['7 x 2 ', ' 14']`, et `int()` accepte les espaces autour du nombre.
Corrigé de l'exercice 2
from pathlib import Path


def statistiques_fichier(chemin):
    """Renvoie le nombre de lignes, de mots et de caractères (hors retours à la ligne)."""
    nb_lignes = 0
    nb_mots = 0
    nb_caracteres = 0
    with open(chemin, "r", encoding="utf-8") as fichier:
        for ligne in fichier:
            ligne = ligne.rstrip("\n")
            nb_lignes += 1
            nb_mots += len(ligne.split())
            nb_caracteres += len(ligne)
    return {"lignes": nb_lignes, "mots": nb_mots, "caracteres": nb_caracteres}


poeme = Path("poeme.txt")
poeme.write_text(
    "Demain, dès l'aube, à l'heure où blanchit la campagne,\n"
    "Je partirai. Vois-tu, je sais que tu m'attends.\n"
    "J'irai par la forêt, j'irai par la montagne.\n",
    encoding="utf-8",
)

print(statistiques_fichier(poeme))
Sortie :
{'lignes': 3, 'mots': 25, 'caracteres': 145}
On lit ligne par ligne (efficace même pour un très gros fichier) et on met à jour trois compteurs. `rstrip("\n")` ne retire que le retour à la ligne, pas les éventuels espaces en fin de ligne, qui sont de vrais caractères. `split()` sans argument découpe sur les espaces : « l'aube, » compte pour un seul mot. Les accents comptent pour un caractère chacun, grâce au décodage UTF-8.
Corrigé de l'exercice 3
from pathlib import Path


def lire_config(chemin):
    """Lit un fichier 'cle = valeur' et renvoie un dictionnaire des réglages."""
    reglages = {}
    with open(chemin, "r", encoding="utf-8") as fichier:
        for ligne in fichier:
            ligne = ligne.strip()
            if ligne == "" or ligne.startswith("#"):
                continue                         # ligne vide ou commentaire
            cle, valeur = ligne.split("=", 1)
            reglages[cle.strip()] = valeur.strip()
    return reglages


Path("config.txt").write_text(
    "# Configuration du jeu\n"
    "largeur = 800\n"
    "hauteur=600\n"
    "\n"
    "# Son\n"
    "volume =  75\n"
    "pseudo = Joueur 1\n",
    encoding="utf-8",
)

print(lire_config(Path("config.txt")))
Sortie :
{'largeur': '800', 'hauteur': '600', 'volume': '75', 'pseudo': 'Joueur 1'}
`continue` (chapitre 03) passe directement à la ligne suivante pour les lignes à ignorer. `split("=", 1)` coupe au **premier** `=` seulement : une valeur pourrait contenir un `=` (par exemple une formule). Les valeurs restent des chaînes ; c'est au programme qui les utilise de les convertir (`int(reglages["largeur"])`). Une ligne sans `=` provoquerait une erreur au déballage : on apprendra à la gérer proprement au chapitre 08.
Corrigé de l'exercice 4
import csv
from pathlib import Path

Path("notes.csv").write_text(
    "nom;maths;francais;anglais\n"
    "Alice;15;12;17\n"
    "Bob;9;11;8\n"
    "Chloé;18;16;14\n"
    "David;10;9;12\n",
    encoding="utf-8",
)

# Lecture et calcul
bulletin = []
with open(Path("notes.csv"), "r", encoding="utf-8", newline="") as fichier:
    for ligne in csv.DictReader(fichier, delimiter=";"):
        notes = [int(ligne[matiere]) for matiere in ["maths", "francais", "anglais"]]
        moyenne = round(sum(notes) / len(notes), 2)
        bulletin.append({
            "nom": ligne["nom"],
            "moyenne": moyenne,
            "resultat": "admis" if moyenne >= 10 else "ajourné",
        })

bulletin.sort(key=lambda eleve: eleve["moyenne"], reverse=True)

# Écriture
with open(Path("bulletin.csv"), "w", encoding="utf-8", newline="") as fichier:
    ecrivain = csv.DictWriter(fichier, fieldnames=["nom", "moyenne", "resultat"], delimiter=";")
    ecrivain.writeheader()
    ecrivain.writerows(bulletin)

print(Path("bulletin.csv").read_text(encoding="utf-8"), end="")
Sortie :
nom;moyenne;resultat
Chloé;16.0;admis
Alice;14.67;admis
David;10.33;admis
Bob;9.33;ajourné
`DictReader` permet d'écrire `ligne["maths"]` plutôt que `ligne[1]`. Une compréhension convertit les trois notes en entiers. On construit une liste de dictionnaires (les lignes du futur fichier), qu'on trie avec une `lambda` avant de la donner à `DictWriter`. Le même `delimiter=";"` est utilisé en lecture et en écriture.
Corrigé de l'exercice 5
import json
from pathlib import Path

PREFERENCES_PAR_DEFAUT = {"theme": "clair", "langue": "fr", "taille_police": 12}


def charger_preferences(chemin):
    """Renvoie les préférences du fichier, complétées par les valeurs par défaut."""
    preferences = PREFERENCES_PAR_DEFAUT.copy()
    if chemin.exists():
        with open(chemin, "r", encoding="utf-8") as fichier:
            preferences.update(json.load(fichier))
    return preferences


def sauvegarder_preferences(preferences, chemin):
    """Enregistre les préférences au format JSON lisible."""
    with open(chemin, "w", encoding="utf-8") as fichier:
        json.dump(preferences, fichier, ensure_ascii=False, indent=4)


chemin = Path("preferences.json")
chemin.unlink(missing_ok=True)            # on part d'une situation propre

preferences = charger_preferences(chemin)
print(f"Au départ : {preferences}")

preferences["theme"] = "sombre"
sauvegarder_preferences(preferences, chemin)
print(f"Après sauvegarde : {charger_preferences(chemin)}")

chemin.write_text('{"langue": "en"}', encoding="utf-8")
print(f"Fichier partiel : {charger_preferences(chemin)}")
Sortie :
Au départ : {'theme': 'clair', 'langue': 'fr', 'taille_police': 12}
Après sauvegarde : {'theme': 'sombre', 'langue': 'fr', 'taille_police': 12}
Fichier partiel : {'theme': 'clair', 'langue': 'en', 'taille_police': 12}
L'astuce est de partir d'une **copie** des valeurs par défaut, puis de la mettre à jour avec `update()`, une méthode des dictionnaires qui ajoute ou remplace les paires d'un autre dictionnaire. Sans `.copy()`, on modifierait la constante elle-même (piège de la référence, chapitre 04). `unlink()` supprime un fichier ; `missing_ok=True` évite l'erreur s'il n'existe pas.
Corrigé de l'exercice 6
from pathlib import Path


def inventaire_par_extension(dossier):
    """Renvoie {extension: [noms de fichiers triés]} pour les fichiers du dossier."""
    inventaire = {}
    for chemin in dossier.iterdir():
        if chemin.is_file():
            extension = chemin.suffix
            if extension not in inventaire:
                inventaire[extension] = []
            inventaire[extension].append(chemin.name)
    return {ext: sorted(noms) for ext, noms in inventaire.items()}


dossier = Path("inventaire")
dossier.mkdir(exist_ok=True)
for nom in ["rapport.txt", "photo.jpg", "budget.csv", "notes.txt", "vacances.jpg"]:
    (dossier / nom).write_text(nom, encoding="utf-8")

inventaire = inventaire_par_extension(dossier)
for extension in sorted(inventaire):
    print(f"{extension} : {', '.join(inventaire[extension])}")
Sortie :
.csv : budget.csv
.jpg : photo.jpg, vacances.jpg
.txt : notes.txt, rapport.txt
`iterdir()` parcourt le contenu du dossier ; `is_file()` écarte les éventuels sous-dossiers. `suffix` donne l'extension et `name` le nom complet. Comme l'ordre de `iterdir()` n'est pas garanti, on trie les noms (compréhension de dictionnaire) et les extensions à l'affichage. C'est la base d'un script qui range automatiquement un dossier de téléchargements (cf. [chapitre 14](14-specialisations.md)).
Corrigé de l'exercice 7
import csv
import json
from pathlib import Path


def csv_vers_json(chemin_csv, chemin_json):
    """Convertit un CSV en JSON (liste de dictionnaires), les nombres entiers étant convertis."""
    with open(chemin_csv, "r", encoding="utf-8", newline="") as fichier:
        lignes = list(csv.DictReader(fichier))

    donnees = []
    for ligne in lignes:
        donnees.append({cle: int(valeur) if valeur.isdigit() else valeur
                        for cle, valeur in ligne.items()})

    with open(chemin_json, "w", encoding="utf-8") as fichier:
        json.dump(donnees, fichier, ensure_ascii=False, indent=2)


def json_vers_csv(chemin_json, chemin_csv):
    """Convertit un JSON (liste de dictionnaires) en CSV."""
    with open(chemin_json, "r", encoding="utf-8") as fichier:
        donnees = json.load(fichier)

    with open(chemin_csv, "w", encoding="utf-8", newline="") as fichier:
        ecrivain = csv.DictWriter(fichier, fieldnames=list(donnees[0].keys()), lineterminator="\n")
        ecrivain.writeheader()
        ecrivain.writerows(donnees)


Path("villes.csv").write_text(
    "ville,departement,habitants\n"
    "Lyon,Rhône,522250\n"
    "Lille,Nord,236710\n"
    "Nantes,Loire-Atlantique,320732\n",
    encoding="utf-8",
)

csv_vers_json(Path("villes.csv"), Path("villes.json"))
print(Path("villes.json").read_text(encoding="utf-8"))

with open(Path("villes.json"), "r", encoding="utf-8") as fichier:
    villes = json.load(fichier)
print(f"Total habitants : {sum(v['habitants'] for v in villes)}")

json_vers_csv(Path("villes.json"), Path("villes_copie.csv"))
original = Path("villes.csv").read_text(encoding="utf-8")
copie = Path("villes_copie.csv").read_text(encoding="utf-8")
print(f"Copie identique : {original == copie}")
Sortie :
[
  {
    "ville": "Lyon",
    "departement": "Rhône",
    "habitants": 522250
  },
  {
    "ville": "Lille",
    "departement": "Nord",
    "habitants": 236710
  },
  {
    "ville": "Nantes",
    "departement": "Loire-Atlantique",
    "habitants": 320732
  }
]
Total habitants : 1079692
Copie identique : True
La compréhension de dictionnaire avec `int(valeur) if valeur.isdigit() else valeur` convertit seulement les valeurs numériques : dans le JSON, `522250` apparaît sans guillemets, c'est un vrai nombre, et la somme fonctionne directement après rechargement. Pour l'inverse, les colonnes sont les clés du premier dictionnaire. Un détail : par défaut, `csv.writer` termine les lignes par `\r\n` (la convention officielle du format CSV). Pour obtenir un fichier **strictement** identique à l'original, qui utilise `\n`, on a précisé `lineterminator="\n"`. Sans cette option, le contenu serait le même pour un tableur, mais la comparaison de chaînes renverrait `False`.
Corrigé de l'exercice 8
import csv
import json
from pathlib import Path

Path("ventes.csv").write_text(
    "date,produit,categorie,quantite,prix_unitaire\n"
    "2024-05-02,Clavier,Informatique,2,49.90\n"
    "2024-05-02,Stylo,Papeterie,10,1.20\n"
    "2024-05-03,Souris,Informatique,3,19.90\n"
    "2024-05-03,Cahier,Papeterie,5,3.50\n"
    "2024-05-04,Clavier,Informatique,1,49.90\n"
    "2024-05-04,Casque,Audio,1,89.00\n",
    encoding="utf-8",
)

# 1. Agrégation
ca_categories = {}
articles_categories = {}
ca_produits = {}
with open(Path("ventes.csv"), "r", encoding="utf-8", newline="") as fichier:
    for vente in csv.DictReader(fichier):
        quantite = int(vente["quantite"])
        montant = quantite * float(vente["prix_unitaire"])
        categorie = vente["categorie"]
        produit = vente["produit"]
        ca_categories[categorie] = ca_categories.get(categorie, 0) + montant
        articles_categories[categorie] = articles_categories.get(categorie, 0) + quantite
        ca_produits[produit] = ca_produits.get(produit, 0) + montant

# 2. Rapport CSV, trié par chiffre d'affaires décroissant
categories_triees = sorted(ca_categories, key=lambda c: ca_categories[c], reverse=True)
with open(Path("rapport_ventes.csv"), "w", encoding="utf-8", newline="") as fichier:
    ecrivain = csv.DictWriter(fichier, fieldnames=["categorie", "articles", "chiffre_affaires"],
                              lineterminator="\n")
    ecrivain.writeheader()
    for categorie in categories_triees:
        ecrivain.writerow({
            "categorie": categorie,
            "articles": articles_categories[categorie],
            "chiffre_affaires": f"{ca_categories[categorie]:.2f}",
        })

# 3. Rapport JSON
rapport = {
    "total": round(sum(ca_categories.values()), 2),
    "meilleur_produit": max(ca_produits, key=lambda p: ca_produits[p]),
    "categories": {c: round(montant, 2) for c, montant in ca_categories.items()},
}
with open(Path("rapport_ventes.json"), "w", encoding="utf-8") as fichier:
    json.dump(rapport, fichier, ensure_ascii=False, indent=2)

# 4. Affichage
print(Path("rapport_ventes.csv").read_text(encoding="utf-8"), end="")
print(Path("rapport_ventes.json").read_text(encoding="utf-8"))
Sortie :
categorie,articles,chiffre_affaires
Informatique,6,209.40
Audio,1,89.00
Papeterie,15,29.50
{
  "total": 327.9,
  "meilleur_produit": "Clavier",
  "categories": {
    "Informatique": 209.4,
    "Papeterie": 29.5,
    "Audio": 89.0
  }
}
On réutilise le schéma « compter avec un dictionnaire et `get` » (chapitre 04), cette fois pour additionner des montants. Trois dictionnaires d'agrégation suffisent. Dans le CSV, le chiffre d'affaires est formaté avec `:.2f` pour toujours afficher 2 décimales (`89.00`) ; dans le JSON, on garde de vrais nombres arrondis avec `round` (le JSON n'affiche pas les zéros inutiles). `round` est important : sans lui, les calculs en virgule flottante pourraient donner des valeurs comme `209.39999999999998`.
Corrigé du mini-projet
import csv
import json
from pathlib import Path

FICHIER_CONTACTS = Path("contacts.json")
FICHIER_EXPORT = Path("contacts.csv")
CHAMPS = ["nom", "prenom", "telephone", "email"]


# ---------- Persistance ----------

def charger_contacts(chemin):
    """Renvoie la liste des contacts du fichier JSON (liste vide s'il n'existe pas)."""
    if not chemin.exists():
        return []
    with open(chemin, "r", encoding="utf-8") as fichier:
        return json.load(fichier)


def sauvegarder_contacts(contacts, chemin):
    """Enregistre la liste des contacts au format JSON lisible."""
    with open(chemin, "w", encoding="utf-8") as fichier:
        json.dump(contacts, fichier, ensure_ascii=False, indent=2)


def exporter_csv(contacts, chemin):
    """Exporte les contacts dans un CSV lisible par Excel."""
    with open(chemin, "w", encoding="utf-8-sig", newline="") as fichier:
        ecrivain = csv.DictWriter(fichier, fieldnames=CHAMPS, delimiter=";")
        ecrivain.writeheader()
        ecrivain.writerows(trier(contacts))


# ---------- Traitements (sans input ni print) ----------

def trier(contacts):
    """Renvoie une copie de la liste triée par nom puis prénom."""
    return sorted(contacts, key=lambda c: (c["nom"], c["prenom"]))


def rechercher(contacts, texte):
    """Renvoie les contacts dont le nom, le prénom ou l'email contient texte."""
    texte = texte.lower()
    return [c for c in trier(contacts)
            if texte in c["nom"].lower() or texte in c["prenom"].lower() or texte in c["email"].lower()]


# ---------- Interface ----------

def afficher_contacts(contacts):
    """Affiche les contacts dans un tableau numéroté."""
    if not contacts:
        print("Aucun contact.")
        return
    print(f"{'N°':<4}{'Nom':<12}{'Prénom':<10}{'Téléphone':<16}Email")
    for numero, contact in enumerate(contacts, start=1):
        print(f"{numero:<4}{contact['nom']:<12}{contact['prenom']:<10}"
              f"{contact['telephone']:<16}{contact['email']}")


def demander_non_vide(message):
    """Redemande tant que la saisie est vide ; renvoie la saisie nettoyée."""
    saisie = input(message).strip()
    while saisie == "":
        print("Ce champ est obligatoire.")
        saisie = input(message).strip()
    return saisie


def ajouter_contact(contacts):
    """Demande les informations d'un contact, l'ajoute et sauvegarde."""
    contact = {
        "nom": demander_non_vide("Nom : ").upper(),
        "prenom": demander_non_vide("Prénom : ").capitalize(),
        "telephone": input("Téléphone : ").strip(),
        "email": input("Email : ").strip().lower(),
    }
    contacts.append(contact)
    sauvegarder_contacts(contacts, FICHIER_CONTACTS)
    print(f"{contact['prenom']} {contact['nom']} ajouté(e) et sauvegardé(e).")


def supprimer_contact(contacts):
    """Demande un numéro de contact, confirme, supprime et sauvegarde."""
    tries = trier(contacts)
    afficher_contacts(tries)
    if not tries:
        return
    saisie = input("Numéro à supprimer : ").strip()
    if not saisie.isdigit() or not 1 <= int(saisie) <= len(tries):
        print("Numéro invalide.")
        return
    contact = tries[int(saisie) - 1]
    confirmation = input(f"Supprimer {contact['prenom']} {contact['nom']} ? (o/n) ").strip().lower()
    if confirmation == "o":
        contacts.remove(contact)
        sauvegarder_contacts(contacts, FICHIER_CONTACTS)
        print("Contact supprimé.")
    else:
        print("Suppression annulée.")


def main():
    """Boucle principale du carnet de contacts."""
    contacts = charger_contacts(FICHIER_CONTACTS)
    while True:
        print()
        print(f"=== CARNET DE CONTACTS ({len(contacts)} contacts) ===")
        print("1. Lister les contacts")
        print("2. Ajouter un contact")
        print("3. Rechercher")
        print("4. Supprimer un contact")
        print("5. Exporter en CSV")
        print("6. Quitter")
        choix = input("Ton choix : ").strip()

        if choix == "1":
            afficher_contacts(trier(contacts))
        elif choix == "2":
            ajouter_contact(contacts)
        elif choix == "3":
            texte = input("Rechercher : ").strip()
            resultats = rechercher(contacts, texte)
            print(f"{len(resultats)} résultat(s) :")
            afficher_contacts(resultats)
        elif choix == "4":
            supprimer_contact(contacts)
        elif choix == "5":
            exporter_csv(contacts, FICHIER_EXPORT)
            print(f"{len(contacts)} contact(s) exporté(s) dans {FICHIER_EXPORT}.")
        elif choix == "6":
            print("Au revoir !")
            break
        else:
            print("Choix invalide.")


main()
Exemple de première session, en partant sans fichier `contacts.json` (saisies : `2`, `martin`, `alice`, `06 12 34 56 78`, `Alice@Exemple.fr`, `2`, `durand`, une ligne vide, `bob`, `07 98 76 54 32`, `bob@mail.fr`, `1`, `6`) :
=== CARNET DE CONTACTS (0 contacts) ===
1. Lister les contacts
2. Ajouter un contact
3. Rechercher
4. Supprimer un contact
5. Exporter en CSV
6. Quitter
Ton choix : 2
Nom : martin
Prénom : alice
Téléphone : 06 12 34 56 78
Email : Alice@Exemple.fr
Alice MARTIN ajouté(e) et sauvegardé(e).

=== CARNET DE CONTACTS (1 contacts) ===
1. Lister les contacts
2. Ajouter un contact
3. Rechercher
4. Supprimer un contact
5. Exporter en CSV
6. Quitter
Ton choix : 2
Nom : durand
Prénom : 
Ce champ est obligatoire.
Prénom : bob
Téléphone : 07 98 76 54 32
Email : bob@mail.fr
Bob DURAND ajouté(e) et sauvegardé(e).

=== CARNET DE CONTACTS (2 contacts) ===
1. Lister les contacts
2. Ajouter un contact
3. Rechercher
4. Supprimer un contact
5. Exporter en CSV
6. Quitter
Ton choix : 1
N°  Nom         Prénom    Téléphone       Email
1   DURAND      Bob       07 98 76 54 32  bob@mail.fr
2   MARTIN      Alice     06 12 34 56 78  alice@exemple.fr

=== CARNET DE CONTACTS (2 contacts) ===
1. Lister les contacts
2. Ajouter un contact
3. Rechercher
4. Supprimer un contact
5. Exporter en CSV
6. Quitter
Ton choix : 6
Au revoir !
Le fichier `contacts.json` contient alors :
[
  {
    "nom": "MARTIN",
    "prenom": "Alice",
    "telephone": "06 12 34 56 78",
    "email": "alice@exemple.fr"
  },
  {
    "nom": "DURAND",
    "prenom": "Bob",
    "telephone": "07 98 76 54 32",
    "email": "bob@mail.fr"
  }
]
Deuxième session, en relançant le programme (saisies : `3`, `ALI`, `4`, `1`, `o`, `5`, `6`) : les deux contacts ont bien été retrouvés.
=== CARNET DE CONTACTS (2 contacts) ===
1. Lister les contacts
2. Ajouter un contact
3. Rechercher
4. Supprimer un contact
5. Exporter en CSV
6. Quitter
Ton choix : 3
Rechercher : ALI
1 résultat(s) :
N°  Nom         Prénom    Téléphone       Email
1   MARTIN      Alice     06 12 34 56 78  alice@exemple.fr

=== CARNET DE CONTACTS (2 contacts) ===
1. Lister les contacts
2. Ajouter un contact
3. Rechercher
4. Supprimer un contact
5. Exporter en CSV
6. Quitter
Ton choix : 4
N°  Nom         Prénom    Téléphone       Email
1   DURAND      Bob       07 98 76 54 32  bob@mail.fr
2   MARTIN      Alice     06 12 34 56 78  alice@exemple.fr
Numéro à supprimer : 1
Supprimer Bob DURAND ? (o/n) o
Contact supprimé.

=== CARNET DE CONTACTS (1 contacts) ===
1. Lister les contacts
2. Ajouter un contact
3. Rechercher
4. Supprimer un contact
5. Exporter en CSV
6. Quitter
Ton choix : 5
1 contact(s) exporté(s) dans contacts.csv.

=== CARNET DE CONTACTS (1 contacts) ===
1. Lister les contacts
2. Ajouter un contact
3. Rechercher
4. Supprimer un contact
5. Exporter en CSV
6. Quitter
Ton choix : 6
Au revoir !
**Points clés de la solution :** - Le code est organisé en trois couches : **persistance** (fichiers), **traitements** (calculs purs, sans `input` ni `print`), **interface** (dialogue avec l'utilisateur). Cette séparation facilitera les tests ([chapitre 13](13-tests-pytest-bonnes-pratiques.md)) et un éventuel passage à une interface web ([chapitre 14](14-specialisations.md)). - Le fichier est **sauvegardé après chaque modification** : même si le programme est fermé brutalement, rien n'est perdu. - `charger_contacts` gère l'absence de fichier au premier lancement grâce à `exists()`. - `ensure_ascii=False` et `indent=2` rendent `contacts.json` lisible et modifiable à la main. - La suppression se fait sur la liste **triée** affichée (pour que le numéro corresponde à ce que voit l'utilisateur), puis `contacts.remove(contact)` retire ce même dictionnaire de la liste d'origine : les deux listes contiennent des références vers les mêmes dictionnaires (chapitre 04). - `not 1 <= int(saisie) <= len(tries)` vérifie les bornes ; grâce à l'évaluation paresseuse de `or`, `int(saisie)` n'est appelé que si `isdigit()` est vrai. - L'export utilise `utf-8-sig` et `;` pour qu'Excel affiche correctement les accents et les colonnes.

Récapitulatif

  • Les fichiers rendent les données persistantes : elles survivent à la fin du programme.
  • pathlib.Path manipule les chemins de façon portable : / pour assembler, .name, .suffix, .parent, .exists(), .mkdir(parents=True, exist_ok=True), .iterdir(), .glob("*.txt"). Path(__file__).parent donne le dossier du script.
  • On ouvre toujours un fichier avec with open(chemin, mode, encoding="utf-8") as fichier: ; il est fermé automatiquement à la sortie du bloc.
  • Modes : "r" lecture (erreur si absent), "w" écriture (écrase), "a" ajout à la fin, "x" création exclusive.
  • Lecture : read() (tout), for ligne in fichier (ligne par ligne, penser à strip()), read().splitlines() (liste propre). Raccourcis : Path.read_text() / Path.write_text().
  • Écriture : write() n'ajoute pas de \n ; print(..., file=fichier) si.
  • CSV : import csv, ouvrir avec newline="" ; csv.reader / csv.writer (listes), csv.DictReader / csv.DictWriter (dictionnaires, avec fieldnames et writeheader()) ; delimiter=";" pour le format français. Toutes les valeurs lues sont des chaînes.
  • JSON : import json ; json.dump(donnees, fichier, ensure_ascii=False, indent=4) pour écrire, json.load(fichier) pour lire ; dumps/loads pour les chaînes. Types conservés, mais clés toujours converties en chaînes, et pas d'ensembles.
  • Schéma à retenir : une fonction charger(chemin) (avec valeur par défaut si le fichier n'existe pas) et une fonction sauvegarder(donnees, chemin).

Checklist de validation

  • Je sais construire un chemin avec Path et l'opérateur /, et tester s'il existe.
  • J'ouvre mes fichiers avec with et encoding="utf-8", sans exception.
  • Je connais la différence entre les modes "r", "w" et "a", et le danger de "w".
  • Je sais lire un fichier ligne par ligne en retirant les retours à la ligne.
  • Je sais lire un CSV avec DictReader et convertir les valeurs en nombres.
  • Je sais écrire un CSV avec DictWriter (avec newline="" et writeheader()).
  • Je sais sauvegarder et recharger une liste de dictionnaires en JSON lisible et avec accents.
  • Mon carnet de contacts retrouve ses données quand je le relance.

← Chapitre précédent | Sommaire | Chapitre suivant →