Partie II - Structures de donnees · Chapitre 11

Les fichiers

3 sections de cours · fiche d'exercices corrigee

1Ouvrir, lire, ecrire un fichier texte

Jusqu'ici les donnees disparaissaient a la fin du programme. Les fichiers assurent la persistance : ils permettent de conserver des resultats, de traiter des jeux de donnees trop volumineux pour etre saisis a la main, et d'echanger de l'information entre programmes.

Definition

Un fichier est une suite d'octets stockee sur un support persistant, designee par un chemin. On distingue :

  • les fichiers texte : la suite d'octets est interpretee comme des caracteres selon un encodage (UTF-8, Latin-1...) et decoupee en lignes ;
  • les fichiers binaires : les octets sont manipules tels quels (images, sons, donnees compressees).

Au programme, on ne traite que les fichiers texte.

Ouvrir et fermer

f = open("donnees.txt", "r", encoding="utf-8")
contenu = f.read()
f.close()                       # obligatoire, et facile a oublier
La forme a utiliser : with
with open("donnees.txt", "r", encoding="utf-8") as f:
    contenu = f.read()
# ici le fichier est FERME, meme si une exception a ete levee

Le bloc with garantit la fermeture. C'est la forme attendue systematiquement : un fichier non ferme peut perdre des donnees non ecrites (le tampon n'est pas vide) et epuise les descripteurs du systeme.

Les modes d'ouverture

ModeNomSi le fichier existeS'il n'existe pas
"r"lectureouvert au debut FileNotFoundError
"w"ecriturevide integralement cree
"a"ajoutouvert a la fincree
"x"creation exclusiveFileExistsError cree
"r+"lecture-ecritureouvert au debut FileNotFoundError
Le mode "w" detruit

Ouvrir un fichier existant en mode "w" efface immediatement tout son contenu, avant meme la premiere ecriture. Pour ajouter a la suite, il faut le mode "a". Cette erreur est irrattrapable.

Toujours preciser l'encodage

Sans encoding="utf-8", Python utilise l'encodage par defaut du systeme, qui varie (UTF-8 sous Linux et macOS, souvent CP1252 sous Windows). Le meme programme lit alors des caracteres accentues differents selon la machine, ou leve UnicodeDecodeError.

Lire

with open("f.txt", encoding="utf-8") as f:
    tout = f.read()             # UNE chaine contenant tout le fichier

with open("f.txt", encoding="utf-8") as f:
    lignes = f.readlines()      # liste des lignes, AVEC le "\n" final

with open("f.txt", encoding="utf-8") as f:
    premiere = f.readline()     # une seule ligne

with open("f.txt", encoding="utf-8") as f:
    for ligne in f:             # LA BONNE FACON : ligne par ligne, paresseuse
        traite(ligne.rstrip("\n"))
Pourquoi parcourir ligne par ligne

read() et readlines() chargent tout le fichier en memoire : \(\Theta(N)\) octets. Le parcours for ligne in f ne garde qu'une ligne a la fois : \(\Theta(L)\) ou \(L\) est la longueur de la plus longue ligne. Sur un fichier de plusieurs gigaoctets, seule la seconde methode fonctionne.

Le \n parasite
for ligne in f:
    print(len(ligne))           # compte le "\n" final !
    n = int(ligne)              # fonctionne ici, int ignore les blancs
    mots = ligne.split()        # split() sans argument ignore aussi le "\n"
    ligne = ligne.rstrip("\n")  # la facon explicite et sure

Utiliser rstrip("\n") plutot que strip() si les espaces de debut de ligne sont significatifs (fichiers indentes).

Ecrire

with open("sortie.txt", "w", encoding="utf-8") as f:
    f.write("premiere ligne\n")        # le \n n'est PAS ajoute automatiquement
    f.write(f"resultat : {42}\n")
    f.writelines(["a\n", "b\n"])       # n'ajoute aucun \n non plus
    print("via print", file=f)         # print ajoute le \n : souvent plus pratique
def sauvegarde(nom, lignes):
    """Ecrit une liste de chaines, une par ligne."""
    with open(nom, "w", encoding="utf-8") as f:
        for l in lignes:
            f.write(str(l) + "\n")

def ajoute_journal(nom, message):
    """Ajoute une ligne a la fin d'un journal, sans ecraser."""
    with open(nom, "a", encoding="utf-8") as f:
        f.write(message + "\n")

2Fichiers CSV, traitement de donnees et erreurs

Le format CSV

Definition

Un fichier CSV (comma-separated values) represente un tableau : une ligne par enregistrement, des champs separes par un delimiteur (virgule, point-virgule ou tabulation). La premiere ligne contient souvent les noms des colonnes.

nom,classe,note
Amine,MPSI,15
Sara,PCSI,18
Youssef,MPSI,12
def lit_csv(nom, sep=","):
    """Lit un CSV a en-tete et renvoie une liste de dictionnaires."""
    with open(nom, encoding="utf-8") as f:
        entetes = f.readline().rstrip("\n").split(sep)
        lignes = []
        for ligne in f:
            ligne = ligne.rstrip("\n")
            if not ligne:                    # on ignore les lignes vides
                continue
            valeurs = ligne.split(sep)
            lignes.append(dict(zip(entetes, valeurs)))
    return lignes

def ecrit_csv(nom, lignes, colonnes, sep=","):
    """Ecrit une liste de dictionnaires au format CSV."""
    with open(nom, "w", encoding="utf-8") as f:
        f.write(sep.join(colonnes) + "\n")
        for l in lignes:
            f.write(sep.join(str(l.get(c, "")) for c in colonnes) + "\n")
Les limites du split naif

Un champ peut contenir le separateur s'il est entre guillemets : "Dupont, Jean",MPSI,15. Le split le decouperait a tort. Le module standard csv gere correctement ces cas, ainsi que les guillemets echappes et les sauts de ligne dans un champ.

import csv

with open("notes.csv", encoding="utf-8", newline="") as f:
    for ligne in csv.DictReader(f):
        print(ligne["nom"], ligne["note"])

with open("sortie.csv", "w", encoding="utf-8", newline="") as f:
    w = csv.DictWriter(f, fieldnames=["nom", "classe", "note"])
    w.writeheader()
    w.writerows(donnees)

Le newline="" est necessaire pour eviter les lignes vides parasites sous Windows.

Chaine de traitement type

def analyse_notes(fichier):
    """Lit un CSV de notes et renvoie des statistiques par classe."""
    # 1. LECTURE : convertir les types des la lecture
    donnees = []
    with open(fichier, encoding="utf-8") as f:
        entetes = f.readline().rstrip("\n").split(",")
        for numero, ligne in enumerate(f, start=2):
            champs = ligne.rstrip("\n").split(",")
            if len(champs) != len(entetes):
                print(f"ligne {numero} ignoree : {len(champs)} champs au lieu de {len(entetes)}")
                continue
            enreg = dict(zip(entetes, champs))
            try:
                enreg["note"] = float(enreg["note"])
            except ValueError:
                print(f"ligne {numero} ignoree : note illisible {enreg['note']!r}")
                continue
            donnees.append(enreg)

    # 2. TRAITEMENT : regroupement puis agregation
    par_classe = {}
    for e in donnees:
        par_classe.setdefault(e["classe"], []).append(e["note"])

    # 3. RESULTAT
    return {c: {"effectif": len(v),
                "moyenne": sum(v) / len(v),
                "min": min(v), "max": max(v)}
            for c, v in par_classe.items()}
Methode — traiter un fichier de donnees
  1. Lire ligne par ligne, en convertissant les types immediatement : tout ce qui vient d'un fichier est une chaine.
  2. Valider : nombre de champs, valeurs convertibles. Signaler et ignorer les lignes fautives plutot que planter — un jeu de donnees reel contient toujours des anomalies.
  3. Traiter en memoire avec les structures adaptees (dictionnaires pour les regroupements).
  4. Ecrire le resultat, ou l'afficher.

Gestion des erreurs

def lit_ou_defaut(nom, defaut=""):
    """Contenu du fichier, ou une valeur par defaut en cas de probleme."""
    try:
        with open(nom, encoding="utf-8") as f:
            return f.read()
    except FileNotFoundError:
        print(f"fichier introuvable : {nom}")
    except PermissionError:
        print(f"acces refuse : {nom}")
    except UnicodeDecodeError:
        print(f"encodage incorrect : {nom} n'est pas de l'UTF-8")
    return defaut
ExceptionCause
FileNotFoundErrorchemin inexistant, faute de frappe
PermissionErrordroits insuffisants, fichier ouvert ailleurs
IsADirectoryErrorle chemin designe un dossier
UnicodeDecodeErrormauvais encodage suppose
ValueErrorconversion impossible (int("abc"))

Chemins

import os

os.path.exists("donnees.txt")          # le fichier existe-t-il ?
os.path.getsize("donnees.txt")         # taille en octets
os.listdir(".")                        # contenu du dossier courant
os.path.join("dossier", "f.txt")       # separateur adapte au systeme

Un chemin relatif part du dossier courant ; un chemin absolu part de la racine. Toujours preferer os.path.join a une concatenation manuelle, car le separateur differe selon les systemes.

3Simulateur des modes et etude de cas

Simulateur : effet des modes d'ouverture

Un fichier fictif notes.txt contient trois lignes. Observer l'effet de chaque mode d'ouverture avant d'ecrire.

Choisir un mode d'ouverture.

Etude de cas complete — un carnet de notes

import os

FICHIER = "carnet.csv"
COLONNES = ["nom", "matiere", "note"]

def charge(fichier=FICHIER):
    """Charge le carnet, ou renvoie une liste vide s'il n'existe pas encore."""
    if not os.path.exists(fichier):
        return []
    donnees = []
    with open(fichier, encoding="utf-8") as f:
        entetes = f.readline().rstrip("\n").split(",")
        for ligne in f:
            ligne = ligne.rstrip("\n")
            if not ligne:
                continue
            e = dict(zip(entetes, ligne.split(",")))
            try:
                e["note"] = float(e["note"])
            except (KeyError, ValueError):
                continue
            donnees.append(e)
    return donnees

def enregistre(donnees, fichier=FICHIER):
    """Ecrit tout le carnet (ecrasement volontaire)."""
    with open(fichier, "w", encoding="utf-8") as f:
        f.write(",".join(COLONNES) + "\n")
        for e in donnees:
            f.write(",".join(str(e[c]) for c in COLONNES) + "\n")

def ajoute(nom, matiere, note, fichier=FICHIER):
    """Ajoute une note SANS relire tout le fichier : mode append."""
    nouveau = not os.path.exists(fichier)
    with open(fichier, "a", encoding="utf-8") as f:
        if nouveau:
            f.write(",".join(COLONNES) + "\n")
        f.write(f"{nom},{matiere},{note}\n")

def moyennes(donnees):
    """Moyenne par eleve."""
    par_eleve = {}
    for e in donnees:
        par_eleve.setdefault(e["nom"], []).append(e["note"])
    return {n: sum(v) / len(v) for n, v in par_eleve.items()}

# utilisation
for n, m, x in [("Amine", "info", 15), ("Amine", "maths", 13), ("Sara", "info", 18)]:
    ajoute(n, m, x)
d = charge()
print(moyennes(d))          # {'Amine': 14.0, 'Sara': 18.0}
os.remove(FICHIER)          # nettoyage

Noter le choix des modes : "a" pour ajouter une ligne (peu couteux), "w" pour reecrire l'ensemble apres une modification. Reecrire tout le fichier a chaque ajout serait en \(\Theta(n)\) par ajout, soit \(\Theta(n^2)\) au total.

L'essentiel du chapitre

  • Toujours utiliser with open(...) as f: et preciser encoding="utf-8".
  • Le mode "w" vide le fichier des l'ouverture ; "a" ajoute a la fin.
  • Parcourir avec for ligne in f: : memoire constante, quel que soit le volume.
  • Tout ce qui vient d'un fichier est une chaine : convertir explicitement.
  • Retirer le "\n" avec rstrip("\n").
  • Anticiper FileNotFoundError et les lignes malformees.
  • Pour du CSV reel, utiliser le module csv plutot qu'un split.
Fiche d'exercices — chapitre 11Enonces gradues, du calcul d'application au niveau concours, avec corriges detailles.
Ouvrir la fiche →