Intéressé par des cours d'informatique en ligne ?
Visitez mon nouveau site https://www.yesik.it !

CSV.png

Le format CSV permet d'enregistrer des données tabulaires dans un fichier texte. Les enregistrements y sont stockés ligne par ligne. Un caractère particulier – généralement la virgule – sert à séparer les différents champs.

Le format CSV est un format de fichier courant pour l'échange de données tabulaires (sous forme de tableaux) entre applications. CSV, pour Comma-separated values, ou en français « valeurs séparées par des virgules », est un format texte dans lequel les données sont enregistrées ligne par ligne, et où les valeurs sont séparées par une virgule. Si le format CSV semble simple et universel en apparence, il en existe cependant de nombreuses variantes, par exemple pour utiliser un autre séparateur que la virgule. La RFC4180Common Format and MIME Type for Comma-Separated Values (CSV) Files est une tentative pour normaliser ce format. Mais dans la pratique, un lecteur CSV doit être assez souple pour pouvoir s'adapter à différentes situation.

Ainsi, face à un format commun mais recélant son lot de subtilités, Python propose en standard un module pour produire et lire des fichiers CSV. C'est ce module que je vais vous présenter ici.

Mon fichier CSV de test

Dans la suite de ce document, les exemples reposeront sur le fichier de test CSV suivant:

sh$ cat table.csv
"ID","Name","Credit","Density"
"1032","John",229,1
"1044","Paul",140,1.5
"1050","Georges",490,1
"1013","Ringo",74,1
"1001","Barry",391,1.5
"1032","Robin",286,0.75
"1036","Maurice",187,1

Ce fichier a été produit par OpenOffice Calc à partir d'une feuille de calcul. Il correspond à une table de 4 colonnes et 8 lignes – la première d'entre-elles contenant le label des colonnes. Remarquez que le filtre d'exportation d'OpenOffice a ajouté des guillemets autour des données texte (dont l'identifiant dans la colonne 1). Observez également que ma version d'OpenOffice Calc est configurée pour utiliser le point comme séparateur décimal – ce qui évite toute confusion avec la virgule qui sert de séparateur de champ.

Principes généraux du module Python CSV

Le module Python csv repose sur deux classes principales:

csv.reader
Pour lire et décoder un flux CSV
csv.writer
Pour encoder et écrire un flux CSV

Ici, je parle de flux et non pas de fichiers, car n'importe quel objet supportant le protocole iterator peut servir de source pour la lecture. De la même manière, n'importe quel objet possédant une méthode write peut servir de destination pour l'écriture. Ce qui permet d'envisager sans difficulté la lecture/écriture de flux CSV sur d'autres supports qu'un fichier à proprement parler: connexion réseau, collection en mémoire, etc.

Lecture d'un flux CSV

Lecture à partir d'un fichier

L'exemple le plus basique d'utilisation consiste à lire un flux CSV à partir d'un fichier:

#!/usr/bin/python
# vim: set fileencoding=utf-8 :
 
#
# Fichier: cat-csv.py
#
 
import csv
 
#
# Ouverture du fichier source.
#
# D'après la documentation, le mode ''b'' est
# *obligatoire* sur les plate-formes où il est
# significatif. Dans la pratique, il est conseillé
# de toujours le mettre.
#
fname = "table.csv"
file = open(fname, "rb")
 
try:
    #
    # Création du ''lecteur'' CSV.
    #
    reader = csv.reader(file)
    #
    # Le ''lecteur'' est itérable, et peut être utilisé
    # dans une boucle ''for'' pour extraire les
    # lignes une par une.
    #
    for row in reader:
	print row
finally:
    #
    # Fermeture du fichier source
    #
    file.close()

Remarque:

Comme indiqué en commentaires dans le code ci-dessus, quand la source est un fichier, celui-ci doit être ouvert avec le mode b (binaire) sur les plate-formes où c'est significatif. Comme sur les autres plate-formes, l'ajout de ce drapeau n'a aucune conséquence particulière, il n'y a aucune contre-indication à systématiquement l'employer.

En supposant le programme précédent enregistré dans le fichier cat-csv.py, l'exécution donne ceci:

sh$ python cat-csv.py
['ID', 'Name', 'Credit', 'Density']
['1032', 'John', '229', '1']
['1044', 'Paul', '140', '1.5']
['1050', 'Georges', '490', '1']
['1013', 'Ringo', '74', '1']
['1001', 'Barry', '391', '1.5']
['1032', 'Robin', '286', '0.75']
['1036', 'Maurice', '187', '1']

Extraction de colonnes

Comme le laisse supposer l'affichage précédent, chaque ligne extraite dans la boucle for est une liste. Il est donc facile d'accéder aux éléments individuels de chaque enregistrement – par exemple pour extraire la première et la troisième colonne:

#!/usr/bin/python
# vim: set fileencoding=utf-8 :
 
#
# Fichier: col-1-and-3.py
#
 
import csv
 
fname = "table.csv"
file = open(fname, "rb")
 
try:
    reader = csv.reader(file)
    for row in reader:
        #
        # N'affiche que certaines colonnes
        #
        print row[0],row[2]
finally:
    file.close()
sh$ python col-1-and-3.py
ID Credit
1032 229
1044 140
1050 490
1013 74
1001 391
1032 286
1036 187

Lecture dans un dictionnaire

En plus du lecteur utilisé jusqu'à présent, le module csv offre la classe csv.DictReader qui permet de récupérer les enregistrements sous la forme d'un dictionnaire plutôt que d'un tableau. Celle-ci se base sur la convention qui veut que le nom des colonnes soit donné dans le premier enregistrement (la première ligne):

#!/usr/bin/python
# vim: set fileencoding=utf-8 :
 
#
# Fichier: dict-reader.py
#
 
import csv
 
fname = "table.csv"
file = open(fname, "rb")
 
try:
    #
    # Utiliser ''DictReader'' au lieu de ''reader''...
    #
    reader = csv.DictReader(file)
 
    #
    # ... permet de lire les lignes de données dans un *dictionnaire*
    #
    for row in reader:
	print row
finally:
    file.close()
sh$ python dict-reader.py
{'Credit': '229', 'Density': '1', 'ID': '1032', 'Name': 'John'}
{'Credit': '140', 'Density': '1.5', 'ID': '1044', 'Name': 'Paul'}
{'Credit': '490', 'Density': '1', 'ID': '1050', 'Name': 'Georges'}
{'Credit': '74', 'Density': '1', 'ID': '1013', 'Name': 'Ringo'}
{'Credit': '391', 'Density': '1.5', 'ID': '1001', 'Name': 'Barry'}
{'Credit': '286', 'Density': '0.75', 'ID': '1032', 'Name': 'Robin'}
{'Credit': '187', 'Density': '1', 'ID': '1036', 'Name': 'Maurice'}


Avec un DictReader, il est possible d'obtenir le nom des champs à l'aide de la variable d'instance fieldnames. Par contre, celle-ci ne contient le nom des colonnes qu'après la lecture du premier enregistrement. Que ce soit explicitement à l'aide d'un appel à next(), ou implicitement dans une boucle for:

#!/usr/bin/python
# vim: set fileencoding=utf-8 :
 
#
# Fichier: field-names.py
#
 
import csv
 
fname = "table.csv"
file = open(fname, "rb")
 
try:
    reader = csv.DictReader(file)
 
    # Avant la lecture d'un enregistrement le nom des champs n'est
    # pas disponible.
    print "Titres (avant next()):", reader.fieldnames 
 
    # La lecture du premier enregistrement...
    row = reader.next()
 
    # ...initialise la liste des titre de colonnes:
    print "Titres (après next()):", reader.fieldnames
    print "Data row 1:", row
finally:
    file.close()
sh$ python field-names.py
Titres (avant next()): None
Titres (après next()): ['ID', 'Name', 'Credit', 'Density']
Data row 1: {'Credit': '229', 'Density': '1', 'ID': '1032', 'Name': 'John'}

Dialectes

CSV dialecte de base.png

Le format CSV utilise traditionnellement la virgule pour séparer les différents champs d'un enregistrement.


CSV conflit séparateur décimal.png

Un conflit survient quand la virgule est aussi utilisée comme séparateur décimal.


CSV séparateur de champs alternatif.png

L'utilisation d'un séparateur de champ alternatif comme le point-virgule ou le deux points résout le conflit possible avec les données numériques. Mais un conflit reste possible avec le contenu des champs de texte.


CSV délimiteur de champ.png

L'utilisation de guillemets pour délimiter les données permet de résoudre le conflit lié à la présence du séparateur de champ dans celles-ci.


Le problème majeur du format CSV est son manque de standardisation ce qui a donné naissance à différents dialectes. Ainsi, normalement la virgule est utilisée pour séparer les champs. Mais d'autres caractères sont aussi couramment utilisés. En particulier le point-virgule dans les logiciels francisés: en effet, ce caractère permet d'éviter la confusion avec la virgule du séparateur décimal. Mais il existe également d'autres variations, et à y regarder de plus près, bien d'autres formats de fichiers peuvent s'apparenter à du CSV. Par exemple, sur un système Unix, les fichiers /etc/passwd ou /etc/groups peuvent être assimilés à des fichiers CSV dont le séparateur serait le deux-points.

sh$ head -5 /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/bin/sh
bin:x:2:2:bin:/bin:/bin/sh
sys:x:3:3:sys:/dev:/bin/sh
sync:x:4:65534:sync:/bin:/bin/sync

Le module csv est suffisamment générique pour pouvoir s'adapter à toutes ces situations. Ainsi, il est possible de configurer les diverses options nécessaires à la lecture d'un fichier CSV lors de l'instanciation du reader. Néanmoins, une solution préférable est de créer un objet de la classe csv.Dialect pour encapsuler ces informations. Ceci permet une plus grande réutilisabilité et facilite la maintenance du code.

Par exemple, si l'on voulait créer un dialecte pour relire les fichiers utilisant le même format que /etc/passwd, on pourrait écrire:

class PasswdDialect(csv.Dialect):
    # Séparateur de champ
    delimiter = ":"
    # Séparateur de ''chaîne''
    quotechar = None
    # Gestion du séparateur dans les ''chaînes''
    escapechar = None
    doublequote = None
    # Fin de ligne
    lineterminator = "\r\n"
    # Ajout automatique du séparateur de chaîne (pour ''writer'')
    quoting = csv.QUOTE_NONE
    # Ne pas ignorer les espaces entre le délimiteur de chaîne
    # et le texte
    skipinitialspace = False

Il est impératif de définir tous les attributs du dialecte. Même ceux qui ne servent pas. Le manuel de référence de Python explique en détail la signification et les valeurs possibles de chacun d'entre eux. En voici un bref résumé (ici, la valeur par défaut est celle employée si aucun dialecte n'est spécifié):

Les attributs d'un Dialect
AttributValeur par défautSignification
delimiter,Séparateur de champ
quotechar"Délimiteur pour les chaînes
quotingQUOTE_MINIMALContrôle l'adjonction de guillemets autour des données
doublequoteTrueDoubler les quotechar qui se trouvent dans les données
escapecharNoneCaractère d'échappement pour protéger les caractères spéciaux dans les données
lineterminator\r\nSéquence de caractères utilisée à la fin de chaque enregistrement
skipinitialspaceFalseIgnorer les espaces entre le délimiteur de chaîne et les données

L'utilisation du dialecte se fait en passant une instance de celui-ci lors de la création du reader:

reader = csv.reader(file, PasswdDialect())

Le reste du code est ensuite identique à une utilisation normale. On peut vérifier le fonctionnement de ce dialecte sur le fichier /etc/passwd:

sh$ python passwd-dialect.py
['root', 'x', '0', '0', 'root', '/root', '/bin/bash']
['daemon', 'x', '1', '1', 'daemon', '/usr/sbin', '/bin/sh']
['bin', 'x', '2', '2', 'bin', '/bin', '/bin/sh']
['sys', 'x', '3', '3', 'sys', '/dev', '/bin/sh']
...

Note:

En standard, Python est livré avec le dialecte csv.excel pour relire les documents CSV générés par Excel ou d'autres tableurs compatibles (comme OpenOffice Calc).

Si ce dialecte utilise la virgule comme séparateur (convention anglo-saxonne), il est possible de se baser dessus pour relire des flux CSV utilisant plutôt un point-virgule – comme c'est souvent la cas des versions francisées des tableurs:

class ExcelFr(csv.excel):
    # Séparateur de champ
    delimiter = ";"

Enfin, quand vous disposez d'un Dialect, il est aussi possible de l'enregistrer auprès du module csv à l'aide de la méthode csv.register_dialect. Une fois enregistré, le dialecte peut être référencé par son nom dans le code – ce qui favorise encore un peu le découplage du code.

#!/usr/bin/python
# vim: set fileencoding=utf-8 :
 
#
# Fichier: excel-fr.py
#
 
import csv
 
#
# Mon dialect basé sur ''excel''
#
class ExcelFr(csv.excel):
    # Séparateur de champ
    delimiter = ";"
 
#
# Enregistre ce dialecte auprès du module csv
#
csv.register_dialect('excel-fr', ExcelFr())
 
 
fname = "table-fr.csv"
file = open(fname, "rb")
 
try:
    #
    # Le dialect est désigné par le ''nom'' sous lequel
    # il a été enregistré.
    #
    reader = csv.reader(file, 'excel-fr')
    for row in reader:
	print row
finally:
    file.close()

Type des données numériques

Jusqu'à présent, toutes les données que nous avons relues l'étaient sous forme de chaînes de caractères. Or, souvent, le format CSV est utilisé pour échanger des données numériques. Le module csv offre la possibilité de faire la conversion automatiquement des données numériques. Celles-ci seront identifiées comme telles par le module csv si elles respectent la syntaxe de Python pour les nombres et si elles ne sont pas encadrées par des délimiteurs de chaîne dans le document CSV source.

Cette possibilité est activée en utilisant un dialecte doté de l'option quoting = QUOTE_NONNUMERIC:

class MyNumDialect(csv.excel):
    quoting = csv.QUOTE_NONNUMERIC
 
# ...
reader = csv.reader(file, MyNumDialect())

Ce qui produit bien un résultat différent de ce que nous obtenions au début de cet article:

['ID', 'Name', 'Credit', 'Density']
['1032', 'John', 229.0, 1.0]
['1044', 'Paul', 140.0, 1.5]
['1050', 'Georges', 490.0, 1.0]
...

Ainsi, observez que les données de la colonne ID sont toujours retournées sous forme de chaîne car dans le document CSV original celles-ci sont délimitées par des guillemets. Par contre, ce qui n'est pas le cas des données des deux dernières colonnes, qui elles sont bien retournées sous forme de données numériques.

Piège:

Pour être reconnues comme des nombres, les données doivent respecter la syntaxe de Python pour les nombres. Ce qui n'est pas le cas des données issues d'un tableur utilisant la virgule pour séparateur décimal! L'option présentée ci-dessus ne fonctionnera donc pas avec des données CSV issues d'un logiciel francisé...

Écrire dans un flux CSV

Générer un flux CSV

Maintenant que nous avons passé beaucoup de temps sur la lecture d'un flux CSV, abordant au passage la notion de dialecte, l'écriture ne devrait pas être compliquée à comprendre. Ainsi, puisque le module csv definit un reader pour lire un flux CSV, comme on peut l'espérer il définit aussi un writer pour écrire dans un flux:

#!/usr/bin/python
# vim: set fileencoding=utf-8 :
 
#
# Fichier: write-csv.py
#
 
import csv
 
#
# Ouverture du fichier source.
#
# D'après la documentation, le mode ''b'' est
# *obligatoire* sur les plate-formes où il est
# significatif. Dans la pratique, il est conseillé
# de toujours le mettre.
# L'ouverture est en écriture, d'où le ''w''.
#
fname = "out.csv"
file = open(fname, "wb")
 
try:
    #
    # Création de l'''écrivain'' CSV.
    #
    writer = csv.writer(file)
 
    #
    # Écriture de la ligne d'en-tête avec le titre
    # des colonnes.
    writer.writerow( ('Prix', 'Désignation') )
 
    #
    # Écriture des quelques données.
    writer.writerow( (9.80, 'Tarte aux pommes') )
    writer.writerow( ('13.40', 'Galette des rois') )
    writer.writerow( (2.45, 'Beignet') )
finally:
    #
    # Fermeture du fichier source
    #
    file.close()

Ici, la méthode clé est csv.writer.writerow. Comme son nom l'indique, celle-ci permet d'écrire une ligne dans le flux CSV de sortie. Notez la forme de l'argument dans mon exemple:

writer.writerow( ('Prix', 'Désignation') )

En fait, writerow n'accepte qu'un seul argument. Ici, il s'agit d'un tupple. Mais n'importe quelle séquence fait l'affaire. Notamment:

# ...
    writer = csv.writer(file)
 
    writer.writerow( range(0,3) )       # intervalle
    writer.writerow( xrange(10,13) )    # intervalle paresseux
    writer.writerow( "abc" )            # chaîne
    writer.writerow( [20, 21, 22] )     # liste
    writer.writerow( (30, 31, 32) )     # tupple

A ces exemples on peut aussi ajouter les compréhensions de listes:

# ...
    writer.writerow( [x for x in range(40,43)] )

Celles-ci doivent d'ailleurs être utilisées si vous souhaitez émettre le contenu d'un générateur:

def my_generator(n):
    while n > 0:
        yield n
        n /= 2
 
# ...
    writer.writerow( [x for x in my_generator(50)] )

Mis bout à bout, ces différents appels à writerow produisent le résultat escompté:

sh$ python write-range.py && cat out.csv
0,1,2
10,11,12
a,b,c
20,21,22
30,31,32
40,41,42
50,25,12,6,3,1

Nombre de champs variable

Comme on peut le constater sur la dernière ligne du précédent exemple, le module csv n'impose aucunement que toutes les lignes générées possèdent le même nombre de champs.

Dialecte pour l'écriture

Les exemples de la section précédente ne posaient pas de problèmes majeurs à csv puisque les données ne contenaient aucun des caractères spéciaux reconnus dans un fichier CSV. A savoir, en standard, la virgule, les guillemets et le retour à la ligne. Voyons maintenant comment il se comporte avec des données piégées:

# ...
    # Données ''normales''
    writer.writerow( [20.80] )
    writer.writerow( ['xyz' ] )
 
    # Données ''pièges''
    writer.writerow( ['abc "def" ghi'] )
    writer.writerow( ['jkl,mno,pqr'] )
    writer.writerow( ['010,50'] )
    writer.writerow( ['line\nfeed'] )
    writer.writerow( ['carriage\rreturn'] )
    writer.writerow( ['new\r\nline'] )
sh$ python write-special.py && cat out.csv
20.8
xyz
"abc ""def"" ghi"
"jkl,mno,pqr"
"010,50"
"line
feed"
return"ge
"new
line"

Comme on peut l'observer, par défaut, le module csv ajoute des guillemets uniquement si nécessaire autour des textes. C'est à dire pour les champs contenant un des caractères spéciaux ,, " \r ou \n. Constatez aussi que les guillemets embarqués dans des données sont doublés.

Double double quotes

Il est amusant de remarquer qu'en doublant les guillemets contenus dans du texte, CSV suit une convention commune à de nombreuses variantes de SQL. A titre d'anecdote, le format CSV et le langage SQL datent tous deux de la fin des années 60. Ceci explique sans doute cela...

Toujours est-il que cela s'oppose à une convention plus moderne issue du langage C, qui inaugura autour de 1973 l'utilisation du backslash comme caractère d'échappement.

Bien sûr, pour boucler la boucle, il existe des dialectes de CSV qui utilisent aussi le backslash pour cet usage!

L'utilisation d'un dialecte spécifique permet de contrôler la manière dont le flux CSV sera généré. Je vous renvoie à la section dialectes un peu plus haut pour un résumé des options possibles.

A titre d'illustration, si vous souhaitez utiliser le pipe (|) comme séparateur de champ, l'apostrophe pour délimiter les données non-numériques et le backslash (\) pour protéger les caractères spéciaux, le dialecte suivant ferait l'affaire:

class MyDialect(csv.Dialect):
    delimiter = "|"
    quotechar = "'"
    escapechar = "\\"
    doublequote = None
    lineterminator = "\r\n"
    quoting = csv.QUOTE_NONNUMERIC
    skipinitialspace = False
# ...
    writer = csv.writer(file,MyDialect())
 
    writer.writerow( [20.80, "xyz", "X|Y|Z","A'B'C"] )

Ce qui à l'execution produira le résultat suivant:

20.8|'xyz'|'X|Y|Z'|'A\'B\'C'

D'où viennent ces lignes vides?

Selon votre configuration ou votre plate-forme, il se peut qu'en visualisant le contenu du fichier CSV généré vous constatiez la présence d'une ligne vide après chaque ligne de donnée.

L'explication réside dans le séparateur d'enregistrement. En effet, d'après la documentation officielle de Python le séparateur d'enregistrement par défaut est CR+LF ("\r\n"). C'est à dire que Python adopte, conformément à la RFC4180Common Format and MIME Type for Comma-Separated Values (CSV) Files, la convention utilisée par quelques anciens OS non-Unix – et Windows – pour les fins de lignes.

Or sous Unix et les systèmes Unix-like (Linux, MacOS X, FreeBSD, ...) seul LF sert à délimiter la fin de ligne. Pour complexifier encore les choses, d'autres OS utilisent à l'inverse uniquement CR, ou le couple complémentaire LF+CR pour signaler la fin de ligne [1]. Face à cette confusion, certains éditeurs de texte sont tentés d'interpréter indépendamment CR puis LF comme deux séparateurs consécutifs de fin de ligne. D'où une mystérieuse ligne vide après chaque enregistrement.

Après cette explication théorique, la bonne nouvelle: le reader CSV de Python interprète intelligemment les fin de lignes et accepte sans distinction CR, LF ou CR+LF (mais pas LF+CR) comme délimiteur d'enregistrement.

Enfin, si malgré tout, l'utilisation du couple CR+LF comme séparateur d'enregistrement est une gène, vous pouvez le modifier au niveau du dialecte:

class MyDialect(csv.Dialect):
    # ...
    lineterminator = "\n" ### LF uniquement (convention Unix)
 
#...
    writer = csv.writer(file,MyDialect())

Support Unicode et données binaires

La documentation du module cvs insiste sur le fait que celui-ci ne supporte pas les données Unicode. En réalité, pour être plus précis, le problème est surtout le non-support des encodages UTF-16 et UTF-32. Or les chaînes unicode Python utilisent un de ces deux encodages. À cela, il faut ajouter des problèmes avec les données contenant le caractère NUL. Pour toutes ces raisons, il est préférable de limiter l'usage de ce module à des données ASCII ou unicode encodées en UTF-8. Ce qui exclu aussi l'import/export direct de données binaires à l'aide de ce module. Si vous devez absolument manipuler de telles données, il sera sans doute judicieux de passer par un encodage capable de représenter les données binaires en ASCII (comme Base64, par exemple).

Un dernier mot

Si cet article est déjà long, il passe quand même sous silence un certain nombres d'options et de fonctionnalités du module Python csv. Comme toujours, la lecture du manuel de référence apportera plus de lumière sur les points laissés dans l'ombre. Néanmoins, vous devriez pouvoir disposer du nécessaire pour importer/exporter vos données au format CSV dans le cas général. Et même au delà, puisque comme nous l'avons vu, ce module est assez souple pour s'adapter à d'autre format de fichiers texte délimité.

Références