Tom Daluzeau
← Parcours Python · fichiers
Python · fichiers · leçon 3 / 4Ch. 2 · CSV13 minTerminée

CSV : reader, DictReader et lignes invalides

Lire un CSV avec csv.reader ou csv.DictReader, régler newline, l'encodage et le séparateur, numéroter les lignes avec enumerate, sauter une ligne invalide avec continue et valider un champ avec re.search.

Mis à jour le

À recouper avec le cours. Cette leçon a été écrite à partir du plan du chapitre, avant relecture avec les supports. En cas de doute, le cours fait foi.

Pourquoi on voit ça

CSV veut dire « valeurs séparées par des virgules ». C’est le format des exports de tableur : une ligne par enregistrement, un séparateur entre les champs. Les inventaires de serveurs arrivent très souvent sous cette forme.

Un CSV est tapé ou exporté par des humains, donc il contient des erreurs : un nom mal écrit, un champ vide. Ton outil doit lire le fichier, repérer les lignes fausses, les signaler avec leur numéro et continuer avec les autres.

Dans toute la leçon, servers.csv contient :

name,env,port
web-1,prod,80
db-1,dev,5432

La première ligne est l’en-tête : elle donne le nom des colonnes.

csv.reader : chaque ligne est une liste

import csv

with open("servers.csv", newline="", encoding="utf-8") as f:
    for row in csv.reader(f):
        print(row)
['name', 'env', 'port']
['web-1', 'prod', '80']
['db-1', 'dev', '5432']

csv.reader découpe chaque ligne en liste de chaînes. Deux points à voir :

  • L’en-tête est une ligne comme les autres. C’est à toi de la mettre de côté.
  • Tout est du texte. '80' est un str : row[2] == 80 vaut False. Pour calculer, il faut int(row[2]).

On lit un champ par sa position : row[0] pour le nom.

csv.DictReader : chaque ligne est un dictionnaire

with open("servers.csv", newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        print(row)
{'name': 'web-1', 'env': 'prod', 'port': '80'}
{'name': 'db-1', 'env': 'dev', 'port': '5432'}

DictReader utilise l’en-tête pour nommer les clés. La première ligne n’apparaît donc plus dans les données : on obtient deux dictionnaires, pas trois lignes.

csv.reader csv.DictReader
Une ligne est… une list un dict
On lit le nom avec row[0] row["name"]
L’en-tête est la première ligne renvoyée sert de clés, n’est pas renvoyé
Les valeurs des str des str
ligne 1 (en-tête) ligne 2 name,env,port web-1,prod,80 csv.reader 'web-1' 'prod' '80' 0 1 2 row[0] → 'web-1' l'en-tête est aussi une ligne renvoyée csv.DictReader 'name' 'web-1' 'env' 'prod' 'port' '80' row["name"] → 'web-1' l'en-tête sert de clés, il n'est pas renvoyé
La même ligne coupée aux virgules : reader la lit par position, DictReader par nom de colonne. Dans les deux cas, '80' reste du texte.

Trois réglages : newline, utf-8-sig, delimiter

newline=“”

Avec le module csv, on ouvre toujours le fichier avec newline="". Ça laisse csv gérer lui-même les fins de ligne. Sans ça, on risque des lignes vides en trop selon le système.

encoding=“utf-8-sig”

Certains tableurs ajoutent au tout début du fichier un caractère invisible, le BOM. Lu en utf-8, il se colle au premier nom de colonne :

{'\ufeffname': 'web-1', 'env': 'prod', 'port': '80'}

Résultat : row["name"] lève KeyError: 'name' alors que la colonne a l’air d’exister. Avec encoding="utf-8-sig", Python enlève ce caractère s’il est là, et lit normalement s’il n’y est pas.

delimiter

Un tableur réglé en français sépare souvent les champs par des points-virgules. Par défaut, csv cherche des virgules et ne coupe rien :

['web-1;prod;80']

Une liste d’un seul élément. Il faut annoncer le séparateur :

with open("export.csv", newline="", encoding="utf-8-sig") as f:
    rows = list(csv.reader(f, delimiter=";"))
print(rows[1])
['web-1', 'prod', '80']

Parcourir les lignes de données

Sauter l’en-tête : rows[1:]

Avec csv.reader, on range tout dans une liste, puis on parcourt à partir de la position 1.

with open("servers.csv", newline="", encoding="utf-8") as f:
    rows = list(csv.reader(f))

for row in rows[1:]:
    print(row[0])
web-1
db-1

rows[0] est l’en-tête ; rows[1:] veut dire « tout sauf le premier ».

Numéroter : enumerate

Pour dire à quelle ligne se trouve une erreur, il faut un compteur. enumerate donne à chaque tour le numéro et l’élément.

for i, row in enumerate(rows[1:], start=2):
    print(i, row[0])
2 web-1
3 db-1

Sans start, enumerate compte à partir de 0. Ici on part de 2 : l’en-tête est la ligne 1 du fichier, le premier serveur est donc à la ligne 2.

name,env,port web-1,prod,80 db-1,dev,5432 ligne du fichier 1 2 3 position dans rows rows[0] rows[1] rows[2] i, avec start=2 sautée i = 2 i = 3
rows[1:] saute l'en-tête, start=2 recale le compteur sur les vrais numéros de ligne.

Associer en-tête et valeurs : zip

zip avance dans deux listes en même temps et donne des paires.

header = rows[0]
row = rows[1]
for k, v in zip(header, row):
    print(k, "=", v)
print(dict(zip(header, row)))
name = web-1
env = prod
port = 80
{'name': 'web-1', 'env': 'prod', 'port': '80'}

dict(zip(header, row)) fabrique à la main ce que DictReader fait tout seul.

Erreur fatale ou ligne invalide ?

Ce sont deux situations différentes, avec deux réactions différentes.

Erreur fatale Ligne invalide
Exemple fichier absent ou illisible un nom mal formé, un champ vide
Ce qui est touché tout le traitement une ligne
Réaction message, puis sys.exit(1) message avec le numéro de ligne, puis continue

continue abandonne le tour de boucle en cours et passe à la ligne suivante. Les autres serveurs sont traités normalement.

sys.exit(1) tout s'arrête OSError open("parc.csv") erreur fatale fichier illisible for i, row in enumerate(rows[1:], start=2) ligne 2 web-1,prod,80 valide : compté ligne 3 cache_1,prod,6379 message, continue ligne 4 db-1,dev,5432 valide : compté 2 serveurs valides
Un fichier illisible arrête tout. Une ligne fausse est signalée avec son numéro, puis la boucle continue.

Valider un champ : re.search

Le module re vérifie qu’un texte suit un motif. re.search(motif, texte) renvoie un objet si le motif est trouvé, None sinon. On l’utilise donc directement dans un if.

Le motif r"^[a-z]+-\d+$" se lit : début (^), une ou plusieurs minuscules ([a-z]+), un tiret, un ou plusieurs chiffres (\d+), fin ($). web-1 correspond, cache_1 non.

Tout ensemble

parc.csv contient une ligne fausse, cache_1,prod,6379, en troisième ligne du fichier.

import csv
import re
import sys

try:
    with open("parc.csv", newline="", encoding="utf-8-sig") as f:
        rows = list(csv.reader(f))
except OSError as e:
    print("Erreur fatale :", e)
    sys.exit(1)

valid = 0
for i, row in enumerate(rows[1:], start=2):
    if not re.search(r"^[a-z]+-\d+$", row[0]):
        print(f"ligne {i} : nom invalide {row[0]}")
        continue
    valid = valid + 1

print(valid, "serveurs valides")
ligne 3 : nom invalide cache_1
2 serveurs valides

Le fichier s’est ouvert : pas d’erreur fatale. Une ligne est fausse : elle est signalée, sautée, et le script va jusqu’au bout.

À retenir

  • csv.reader : chaque ligne est une liste, en-tête compris. csv.DictReader : chaque ligne est un dict, l’en-tête sert de clés.
  • Toutes les valeurs d’un CSV sont des str. Convertis avec int() avant de calculer.
  • open(..., newline="", encoding="utf-8-sig"), et delimiter=";" pour un export de tableur français.
  • rows[1:] saute l’en-tête ; enumerate(rows[1:], start=2) donne les vrais numéros de ligne.
  • Fichier illisible : erreur fatale, sys.exit(1). Ligne fausse : message, continue, et on poursuit.
  • re.search(motif, texte) renvoie None si le texte ne correspond pas.

Entraînement

12 questions, dont 7 à compléter. Tu réponds, on passe à la suivante. La note arrive à la fin.

Cartes · 8

Réviser les cartes ici

Espace : retourner