Formater les données

On a vu comment ouvrir des fichiers et y écrire du texte, mais toutes les données que nous manipulons ne sont pas du texte. Bien sûr il est possible de les convertir, c’est d’ailleurs ce que fait la fonction print sur ce qu’elle reçoit, mais comment conserver une structure des données ?

Par exemple pour notre sauvegarde il va nous falloir enregistrer l’état du jeu, tout ce qui différencie la partie en cours d’une autre : les noms des monstres et leurs points de vie. Il s’agit donc de données de types différents (chaînes de caractères, nombres) qu’il va nous falloir représenter, en utilisant pour cela un format de données adéquat. Le format est une notion un peu abstraite qui explique de quelle manière les données doivent être traitées, comment elles peuvent être reconstruites à partir de leur représentation.

Tous les fichiers que l’on utilise représentent leurs données selon un certain format, et tous les formats ne permettent pas de stocker la même chose, ils ont chacun leurs particularités. On ne représente pas une image de la même manière qu’une musique par exemple.

On appelle sérialisation l’opération qui permet de transformer en texte des données structurées, de façon à pouvoir reconstruire ces données ensuite. À l’inverse, cette reconstruction s’appelle une désérialisation. On parle aussi de parsing pour qualifier l’analyse syntaxique du texte et l’extraction des données.

Format JSON

Un premier format de données assez courant en informatique est le JSON (pour JavaScript Object Notation) qui comme son nom l’indique provient du Javascript. Il s’est ainsi répandu dans le monde du web pour devenir un format de prédilection pour les échanges entre applications.

C’est un format textuel, c’est-à-dire qu’il est lisible à l’œil sous forme de texte (contrairement à un format binaire), bien que parfois difficile à écrire à la main.

Voici à quoi ressemble un document JSON :

{
  "id": "001",
  "name": "Pythachu",
  "type": "foudre",
  "attaques": ["tonnerre", "charge"],
  "base_pv": 50
}
pythachu.json

On le voit, c’est un format qui ressemble beaucoup au Python. Il est cependant plus restreint.

Un document JSON ne peut comporter des valeurs que de 7 types :

  • null, équivalent au None de Python.
  • boolean, un booléen donc, true ou false.
  • int, un nombre entier (42).
  • float, un nombre flottant (1.5, 1e10).
  • str, une chaîne de caractère, toujours entre double guillemets ("hello world").
  • array, un tableau de valeurs, équivalent à une liste Python ([8, "foo"]).
  • object, l’équivalent plus restreint d’un dictionnaire Python : seules les chaînes de caractères peuvent être utilisées en clés, les types des valeurs sont libres ({"key": [3, 5]}).
Module json

Ce format est exploitable en Python avec le module json de la bibliothèque standard. Le module fournit principalement 4 fonctions : load, loads, dump et dumps. Retenez ces noms de fonctions, ils sont courants en Python et communs à beaucoup de modules de sérialisation.

Lecture

load est une fonction qui prend en argument un fichier (un objet-fichier ouvert en lecture au préalable) et traite son contenu afin de le renvoyer sous la forme d’un objet Python. Par exemple, avec le document pythachu.json présenté plus haut, nous aurions ceci.

>>> import json
>>> with open('pythachu.json') as f:
...     json.load(f)
... 
{'id': '001', 'name': 'Pythachu', 'type': 'foudre', 'attaques': ['tonnerre', 'charge'], 'base_pv': 50}

La fonction nous a renvoyé la représentation en Python de notre objet.

loads est similaire à load mais reçoit en argument une chaîne de caractères plutôt qu’un fichier (loads pour load string). Elle traite donc le contenu directement depuis la chaîne.

>>> json.loads('{"name": "Pythard", "base_pv": null}')
{'name': 'Pythard', 'base_pv': None}

Ces fonctions lèveront une exception si l’entrée n’est pas dans un format correct.

>>> json.loads('{42: "foo"}')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  [...]
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)
Écriture

dump et dumps sont les fonctions de sérialisation, elles permettent de passer d’un objet Python à sa représentation JSON.

dumps reçoit en argument un objet Python et renvoie sa sérialisation sous forme d’une chaîne de caractères.

>>> json.dumps([1, 2, 3, 'foo'])
'[1, 2, 3, "foo"]'

dump reçoit un objet Python et un fichier (ouvert en écriture), la sérialisation de l’objet sera écrite dans le fichier donné.

with open('output.json', 'w') as f:
    json.dump({'key': 'value'}, f)
{"key": "value"}
output.json

Ces deux fonctions prennent aussi un argument nommé indent qui permet de préciser l’indentation du document de sortie. Avec json.dump({'key': 'value'}, f, indent=2), nous aurions obtenu le résultat suivant.

{
  "key": "value"
}
output.json

L’objet passé en argument se doit d’être composé de types convertibles en JSON, une exception sera levée dans le cas contraire.

>>> json.dumps(1+5j)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  [...]
TypeError: Object of type complex is not JSON serializable
Avantages et inconvénients

Les avantages de ce format sont qu’il est très répandu et assez lisible, il est donc adapté pour une communication basique entre programmes (notamment des API web) ou pour sauvegarder des données simples (dans les types supportés par le format).

C’est en revanche un format avec une syntaxe assez stricte, qui ne conviendrait pas à une écriture humaine, évitez-le donc pour un fichier de configuration. Il est assez verbeux et ne se prête pas forcément à des échanges « intenses » entre programmes. De plus il ne permet pas de représenter tous les objets Python, ce qui peut-être limitant dans certains cas.

Format XML

Le XML (pour eXtensible Markup Language) est un format assez ancien toujours couramment utilisé (SVG, XHTML, docx, ODT).

C’est un langage dit de balisage, formé de différentes balises imbriquées. Il se présente comme suit.

<monster id="001">
  <name>Pythachu</name>
  <type>foudre</type>
  <attaques>
    <attaque>tonnerre</attaque>
    <attaque>charge</attaque>
  </attaques>
  <base_pv>50</base_pv>
</monster>
pythachu.xml

On le voit donc, une balise XML s’ouvre par un <balise> et se ferme avec un </balise>, on peut placer à l’intérieur d’autres balises (qui forment donc la hiérarchie du document) ou du texte.

Il est aussi possible de spécifier des attributs aux balises lors de leur ouverture, comme des métadonnées, avec la syntaxe <balise attribut="valeur">.

Un document XML ne comprend que le texte et pas d’autres types de valeurs, il vous faudra donc opérer les conversions manuellement lors du traitement du document.

Module xml

L’analyse d’un document XML n’est pas aussi simple que celle d’un JSON. Il n’y a pas un unique module pour le faire, et pas de fonction load / dump, juste des fonctions pour opérer sur le document et aller extraire des informations à un endroit précis.

Il existe plusieurs modules Python dédiés à l’analyse des documents XML, tous regroupés dans le module xml. Nous ne nous intéresserons ici qu’au module xml.etree.

Pour commencer, on va importer le module xml.etree.ElementTree qu’il est courant de simplement appeler ET.

import xml.etree.ElementTree as ET
Lire un fichier XML

Ensuite, on va ouvrir un document XML à l’aide de la fonction parse de ce module. La fonction accepte un chemin de fichier en argument, ou directement un objet-fichier.

>>> tree = ET.parse('pythachu.xml')
>>> tree
<xml.etree.ElementTree.ElementTree object at 0x7f6ff11b5f70>

Il est coutume d’appeler tree (arbre) un document XML, par rapport à sa structure arborescente.

Une fois ce document chargé, on peut en récupérer l’élément principal (le nœud racine) à l’aide de la méthode getroot.

>>> root = tree.getroot()
>>> root
<Element 'monster' at 0x7f6ff0faaef0>

root est un objet de type Element. Il possède entre autres un attribut tag qui référence le nom de la balise, et un attribut attrib qui contient le dictionnaire d’attributs de la balise.

>>> root.tag
'monster'
>>> root.attrib
{'id': '001'}

Notez qu’il existe aussi la fonction fromstring pour charger un élément à partir d’une chaîne de caractères.

>>> ET.fromstring('<foo>bar</foo>')
<Element 'foo' at 0x7f6ff10e1900>

Cette fonction lève une erreur ParseError si la chaîne ne représente pas un document XML valide (il en est de même avec la fonction parse).

>>> ET.fromstring('<foo>bar</foo')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.9/xml/etree/ElementTree.py", line 1348, in XML
    return parser.close()
xml.etree.ElementTree.ParseError: unclosed token: line 1, column 8

Les éléments XML sont des objets Python itérables. Itérer dessus revient à parcourir les balises filles.

>>> for elem in root:
...     print(elem)
... 
<Element 'name' at 0x7f6ff0faaf40>
<Element 'type' at 0x7f6ff0faaf90>
<Element 'attaques' at 0x7f6ff0fad040>
<Element 'base_pv' at 0x7f6ff0fad130>

Les éléments possèdent aussi une méthode find pour directement trouver une balise fille en fonction de son nom.

>>> root.find('name')
<Element 'name' at 0x7f6ff0faaf40>
>>> root.find('attaques')
<Element 'attaques' at 0x7f6ff0fad040>

Quand il existe plusieurs éléments du même nom, la méthode findall permet de tous les trouver, elle renvoie une liste d’éléments.

>>> root.find('attaques').findall('attaque')
[<Element 'attaque' at 0x7f6ff0fad090>, <Element 'attaque' at 0x7f6ff0fad0e0>]

Et l’on peut accéder au contenu textuel des éléments à l’aide de leur attribut text.

>>> root.find('name').text
'Pythachu'
>>> root.find('base_pv').text
'50'
>>> for attack in root.find('attaques').findall('attaque'):
...     print(attack.text)
... 
tonnerre
charge
Construire un fichier XML

Il est aussi possible de construire un document XML de toute pièce à l’aide d'etree.

On peut pour cela commencer par créer un élément racine en instanciant un objet Element, en fournissant le nom de la balise comme argument.

>>> root = ET.Element('foo')
>>> root
<Element 'foo' at 0x7f2496c4c8b0>

On peut ensuite facilement ajouter des éléments à un élément parent avec la fonction SubElement.

>>> ET.SubElement(root, 'bar')
<Element 'bar' at 0x7f2496c4c8b0>
>>> ET.SubElement(root, 'baz')
<Element 'baz' at 0x7f2496c44f40>

Et l’on peut parfaitement ajouter des sous-éléments à un sous-élément, etc.

>>> sub = ET.SubElement(root, 'list')
>>> ET.SubElement(sub, 'item')
<Element 'item' at 0x7f2496c619a0>
>>> ET.SubElement(sub, 'item')
<Element 'item' at 0x7f2496b2af90>

On peut aussi manipuler directement le dictionnaire d’attributs des éléments pour en ajouter ou en modifier.

>>> root.attrib['name'] = 'Doc'
>>> root.attrib
{'name': 'Doc'}

De même que l’on peut redéfinir l’attribut text pour ajouter du texte à une balise.

root.find('bar').text = 'bonjour'

Enfin, le module ET possède une fonction dump pour transformer en chaîne de caractères l’élément que l’on vient de créer.

>>> ET.dump(root)
<foo name="Doc"><bar>bonjour</bar><baz /><list><item /><item /></list></foo>

Notez que les balises telles que <baz /> sont des balises auto-fermantes.
<baz/> est équivalent à <baz></baz>, c’est simplement une balise qui ne contient ni enfants ni texte.

Il est aussi possible de créer un document (ElementTree) et d’appeler sa méthode write pour écrire le document dans un fichier.

>>> ET.ElementTree(root).write('doc.xml')
<foo name="Doc"><bar>bonjour</bar><baz /><list><item /><item /></list></foo>
doc.xml

Il y a beaucoup à dire sur le format XML et tout ne pourra pas être décrit ici. Sachez que c’est un format assez complet, qui comporte des mécanismes de validation (schémas XML), d’espaces de noms (namespaces), un sous-langage de requêtage (XPath) et tout un écosystème avec des outils de transformation comme XSLT.
Tous ces termes peuvent vous amener à des ressources complémentaires sur le format XML.

Il est aussi à noter que plusieurs types de parseurs existent pour analyser des documents XML. L’approche de construction d’un document tel que nous l’avons fait ici (DOM n’est pas la seule.
Il existe par exemple l'approche SAX qui consiste à ne pas construire le document mais à le parcourir et à appeler des fonctions définies par l’utilisateur pour chaque ouverture/fermeture de balise, ce qui permet de ne pas occuper de place en mémoire. Voyez par exemple cet article qui utilise la fonction iterparse d'etree pour analyser un document (l’article nécessite de comprendre les générateurs).

Enfin, sachez qu’il existe en Python une bibliothèque externe, lxml, qui simplifie l’usage des documents XML.

Avantages et inconvénients

Son ancienneté et les technologies autour (XMLSchema, XSLT, XPath) sont les forces de ce format plutôt décrié pour sa verbosité et sa relative illisibilité.
Un autre avantage se situe au niveau des diverses technologies de parsing, notamment le SAX plutôt adapté aux gros documents et à la réception de données au fil de l’eau.

Mais le gros point noir d’un point de vue Python est clairement relatif à ces technologies, il est difficile de savoir par où commencer et de manipuler un document XML, là où JSON est très simple d’utilisation.

Format INI

Le format INI (Initialization) est un format dédié à l’écriture de fichiers de configuration simples.

Il permet de décrire différents paramètres de configuration (sous forme de couples clé-valeur) et de les regrouper en sections.

[game]
save=game.dat

[window]
title=Mon super jeu
width=800
height=600
config.ini

Ainsi une section est définie par un [nom_de_la_section] et réunit en son sein toutes les définitions suivantes (de la forme cle=valeur).

Toutes les valeurs sont considérées comme des chaînes de caractères et peuvent donc nécessiter une conversion manuelle au cas par cas (on voudra par exemple convertir les valeurs width et height vers des nombres).

Module configparser

Python propose une implémentation du format INI via son module configparser.

Lecture

Afin de lire un document INI, il faut au préalable instancier un objet ConfigParser.

>>> from configparser import ConfigParser
>>> config = ConfigParser()

Cet objet possède une méthode read qui prend un chemin de fichier en argument et complète la configuration à partir du contenu de ce fichier.

>>> config.read('config.ini')
['config.ini']

On peut ensuite accéder aux différentes sections de la configuration à l’aide de la méthode sections et utiliser l’objet config comme un dictionnaire.

>>> config.sections()
['game', 'window']
>>> config['game']
<Section: game>
>>> config['window']
<Section: window>

Les sections elles aussi sont des objets semblables à des dictionnaires que l’on peut donc manipuler pour accéder aux différentes valeurs.

>>> config['game']['save']
'game.dat'
>>> config['window']['height']
'600'
>>> int(config['window']['height'])
600
>>> dict(config['window'])
{'title': 'Mon super jeu', 'width': '800', 'height': '600'}

En cas de fichier invalide, la méthode read lèvera une exception configparser.ParsongError.

>>> config.read('invalid.ini')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.10/configparser.py", line 698, in read
    self._read(fp, filename)
  File "/usr/lib/python3.10/configparser.py", line 1117, in _read
    raise e
configparser.ParsingError: Source contains parsing errors: 'invalid.ini'
	[line  6]: 'width\n'
Écriture

En écriture, un objet ConfigParser se comporte là aussi comme un dictionnaire.

>>> config = ConfigParser()
>>> config['game'] = {'save': 'new.dat'}
>>> config['window'] = {}
>>> config['window']['width'] = '200'

Attention, toutes les valeurs renseignées dans la configuration doivent être des chaînes de caractères, sans quoi vous obtiendrez une erreur TypeError.

Et l’objet possède une méthode write pour écriture le contenu de la configuration dans un fichier précédemment ouvert en écriture.

>>> with open('new.ini', 'w') as configfile:
...     config.write(configfile)
... 
[game]
save = new.dat

[window]
width = 200

new.ini
Avantages et inconvénients

Le format INI est un format plat (il n’y a pas de structures arborescentes), ce qui est à la fois un avantage et un inconvénient : cela permet de garder des fichiers de configuration simples puisque les constructions complexes n’y sont pas permises.

Ce format a aussi l’avantage d’être clair pour comprendre en un coup d’œil la configuration d’un programme, il est aussi assez répandu.

Son principal inconvénient est de n’autoriser que les chaînes de caractères et donc de forcer les conversions manuelles pour chacune des valeurs.

Format CSV

Le format CSV (Comma-Separated Values) est un format textuel utilisé pour représenter des données tabulaires, comme un tableur. Chaque ligne du fichier correspondra à une ligne du tableau, et les lignes sont divisées en colonnes selon un séparateur (généralement , ou ;).

Voici un exemple de document CSV :

nom,type,degats
charge,normal,20
tonnerre,foudre,50
jet-de-flotte,aquatique,40
brûlure,flamme,40
attaques.csv

Une première ligne (l’en-tête) identifie les noms des colonnes, elle est facultative, mais il faudra en tenir compte lors de l’analyse du fichier.

Comme en XML, toutes les données du document sont considérées comme du texte, les nombres devront donc être convertis manuellement.

Module csv

Le module csv de la bibliothèque standard offre ce qu’il faut pour traiter un document CSV.

Lecture

Le module fournit une fonction reader qui permet de lire un document CSV depuis un fichier.
Elle reçoit donc le fichier en argument1 et renvoie un itérable contenant les lignes du CSV, ces lignes prenant la forme de listes de valeurs.

>>> import csv
>>> with open('attaques.csv') as f:
...     reader = csv.reader(f)
...     for row in reader:
...         print(row)
...
['nom', 'type', 'degats']
['charge', 'normal', '20']
['tonnerre', 'foudre', '50']
['jet-de-flotte', 'aquatique', '40']
['brûlure', 'flamme', '40']

Comme on le voit notre en-tête est considérée comme une ligne à part entière. Il serait néanmoins possible de l’isoler en utilisant par exemple la fonction next de Python (je reviendrai plus tard sur cette fonction).

>>> with open('attaques.csv') as f:
...     reader = csv.reader(f)
...     header = next(reader)
...     print('en-tête:', header)
...     for row in reader:
...         print(row)
...
en-tête: ['nom', 'type', 'degats']
['charge', 'normal', '20']
['tonnerre', 'foudre', '50']
['jet-de-flotte', 'aquatique', '40']
['brûlure', 'flamme', '40']

Mais encore mieux, le module offre aussi l’utilitaire DictReader. Celui-ci s’utilise de la même manière que reader, mais il consomme directement l’en-tête et produit les lignes sous forme de dictionnaires plutôt que de listes (utilisant les valeurs de l’en-tête comme clés).

>>> with open('attaques.csv') as f:
...     reader = csv.DictReader(f)
...     for row in reader:
...         print(row)
... 
{'nom': 'charge', 'type': 'normal', 'degats': '20'}
{'nom': 'tonnerre', 'type': 'foudre', 'degats': '50'}
{'nom': 'jet-de-flotte', 'type': 'aquatique', 'degats': '40'}
{'nom': 'brûlure', 'type': 'flamme', 'degats': '40'}
Écriture

On trouve de manière similaire une fonction writer recevant un fichier (ouvert en écriture) pour y écrire des données tabulaires au format CSV. Cette fonction renvoie un objet possédant une méthode writerow qui sera appelée pour l’écriture de chaque ligne.

>>> with open('monstres.csv', 'w') as f:
...     writer = csv.writer(f)
...     writer.writerow(['nom', 'type', 'pv']) # en-tête
...     writer.writerow(['pythachu', 'foudre', '100'])
...     writer.writerow(['ponytha', 'flamme', '150'])
... 
13
21
20

Chaque appel renvoie le nombre d’octets écrits dans le fichier.

Le code précédent produit donc le fichier suivant.

nom,type,pv
pythachu,foudre,100
ponytha,flamme,150
monstres.csv

On notera que l’objet possède aussi une méthode writerows pour écrire plusieurs lignes en une fois (en prenant en argument une liste de lignes).

De même, le module propose aussi DictWriter pour écrire des lignes depuis un dictionnaire.
Le DictWriter doit être appelé avec en arguments le fichier de sortie mais aussi la ligne d’en-tête, qui servira à extraire les bonnes valeurs des dictionnaires. La ligne d’en-tête en elle-même sera écrite en appelant la méthode writeheader de l’objet.

Ainsi, notre code précédent est équivalent à :

with open('monstres.csv', 'w') as f:
    writer = csv.DictWriter(f, ['nom', 'type', 'pv'])
    writer.writeheader()
    writer.writerow({'nom': 'pythachu', 'type': 'foudre', 'pv': '100'})
    writer.writerow({'nom': 'ponytha', 'type': 'flamme', 'pv': '150'})
Dialectes

Une particularité du CSV est de supporter plusieurs dialectes, car différents outils apportent au format leurs propres spécifications. , n’est pas toujours le séparateur de colonnes par exemple. Le dialecte définit aussi quels caractères d’échappement utiliser dans différents contextes.

Ainsi, toutes les fonctions que nous avons vu acceptent un argument nommé dialect qui permet de choisir le dialecte à utiliser (il s’agit d''excel' par défaut), ou directement des arguments correspondant aux options à définir (delimiter, quotechar, escapechar, etc.).

Avantages et inconvénients

Le format CSV a l’intérêt d’être interopérable, malgré ses multiples dialectes qui peuvent rendre son utilisation confuse. Il est néanmoins assez lisible et facile d’utilisation.

C’est par contre un format assez limité qui ne permet que de représenter des données tabulaires simples (peu adapté pour formater des données arborescentes) et qui ne permet pas de typer ses valeurs.


  1. En réalité tout itérable sur des lignes (chaînes de caractères) est accepté en entrée, un fichier correspond à cette définition.

Chaînes de bytes

Pour la suite nous allons quitter les formats textuels et nous intéresser aux formats dits « binaires », qui ne sont donc pas lisibles comme du texte. Et pour cela, nous avons besoin de découvrir un autre type de Python, le type bytes.

Ce type représente une chaîne d’octets, les octets étant l’unité de stockage des informations sur un ordinateur, soit des nombres de 8 bits (de 0 à 255 inclus). Un objet bytes peut donc être vu comme un tableau de nombres, chaque nombre étant la valeur d’un octet.

On peut d’ailleurs définir un objet bytes à partir d’un tel tableau.

>>> bytes([1, 2, 3])
b'\x01\x02\x03'

La représentation de notre objet peut sembler perturbante, mais il s’agit bien de notre tableau.

>>> data = bytes([1, 2, 3])
>>> data[0]
1

Comme les chaînes de caractères, les chaînes d’octets sont immutables.

>>> data[0] = 10
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'bytes' object does not support item assignment

Les deux types sont d’ailleurs assez semblables, ils étaient même confondus en Python 2, les deux identifiant des chaînes.
Les caractères ne sont qu’une abstraction pour interpréter des octets comme du texte, et une chaîne de caractères est ainsi une chaîne d’octets munie d’une règle définissant comment interpréter les octets en caractères. Cette règle est appelée un encodage, mais j’y reviendrai ensuite.

Cette similitude entre les deux s’appuie entre autres sur la table ASCII qui établit une correspondance entre certains caractères (notamment les caractères alphanumériques latins « de base » — sans accents — et les chiffres, ainsi que des caractères de contrôle) et des octets, elle sert encore aujourd’hui de base à de nombreux encodages.

00

10

20

30

40

50

60

70

0

NUL

DLE

' '

'0'

'@'

'P'

''`

'p'

1

SOH

DC1

'!'

'1'

'A'

'Q'

'a'

'q'

2

STX

DC2

'"'

'2'

'B'

'R'

'b'

'r'

3

ETX

DC3

'#'

'3'

'C'

'S'

'c'

's'

4

EOT

DC4

'$'

'4'

'D'

'T'

'd'

't'

5

ENQ

NAK

'%'

'5'

'E'

'U'

'e'

'u'

6

ACK

SYN

'&'

'6'

'F'

'V'

'f'

'v'

7

BEL

ETB

"'"

'7'

'G'

'W'

'g'

'w'

8

BS

CAN

'('

'8'

'H'

'X'

'h'

'x'

9

HT

EM

')'

'9'

'I'

'Y'

'i'

'y'

A

LF

SUB

'*'

':'

'J'

'Z'

'j'

'z'

B

VT

ESC

'+'

';'

'K'

'['

'k'

'{'

C

FF

FS

','

'<'

'L'

'\'

'l'

'|'

D

CR

GS

'-'

'='

'M'

']'

'm'

'}'

E

SO

RS

'.'

'>'

'N'

'^'

'n'

'~'

F

SI

US

'/'

'?'

'O'

'_'

'o'

DEL

Table ASCII

C’est pourquoi, lors de l’affichage, Python essaie généralement de représenter un objet bytes comme du texte, en s’appuyant sur la table ASCII.

>>> bytes([65, 66, 67])
b'ABC'

65, 66 et 67 sont les valeurs ASCII des caractères A, B et C (ou 0x41, 0x42 et 0x43 en hexadécimal).

On le voit ainsi, une chaîne d’octets peut simplement se définir comme une chaîne de caractères préfixée d’un b.

>>> b'foobar'
b'foobar'

Cela ne change rien au fait que la chaîne ainsi créée est toujours considérée comme un tableau de nombres.

>>> b'foobar'[0]
102

Bien sûr, seulement les caractères de la table ASCII sont utilisables pour construire une chaîne d’octet, impossible d’y utiliser des caractères spéciaux qui n’ont aucune correspondance.

>>> b'été'
  File "<stdin>", line 1
SyntaxError: bytes can only contain ASCII literal characters.

Et comme on l’a vu plus haut, on peut utiliser la notation \xNN pour insérer des octets particuliers, NN étant la valeur de l’octet en hexadécimal.

>>> data = b'\x01\x2A\x61'
>>> data[1]
42
>>> hex(data[1])
'0x2a'
>>> hex(data[2])
'0x61'

Les octets pouvant être interprétés comme des caractères sont affichés comme tel par Python pour faciliter la lisibilité.

>>> data
b'\x01*a'

Qui dit similitude avec les chaînes de caractères dit aussi opérations similaires. Ainsi il est possible de concaténer des chaînes d’octets et d’y appliquer pratiquement les mêmes méthodes.

>>> b'abc' + b'def'
b'abcdef'
>>> b'foo'.replace(b'o', b'e')
b'fee'
>>> b'a;b;c'.split(b';')
[b'a', b'b', b'c']

Mais les deux types ne sont pas compatibles entre eux.

>>> b'abc' + 'def'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: can't concat str to bytes
Encodages

Il est en revanche possible de convertir l’un vers l’autre. Les chaînes de caractères possèdent une méthode encode renvoyant une chaîne d’octets.

>>> 'foobar'.encode()
b'foobar'

À l’inverse, les chaînes d’octets ont une méthode decode pour les convertir en chaînes de caractères.

>>> b'foobar'.decode()
'foobar'

Je n’utilise ici que des caractères de la table ASCII, mais cela fonctionne aussi avec des caractères « spéciaux ».

>>> 'été'.encode()
b'\xc3\xa9t\xc3\xa9'
>>> b'\xc3\xa9t\xc3\xa9'.decode()
'été'

Comment cela fonctionne ? Avec la notion d’encodage dont je parlais plus haut. Un encodage c’est une table qui fait la correspondance entre des caractères et des octets, associant un ou plusieurs octets à un caractère. La table ASCII est un encodage (mais avec un ensemble limité de caractères).

En Python, on utilise plus couramment des encodages unicode — qui peuvent représenter tous les caractères existant — et plus particulièrement UTF-8. C’est cet encodage UTF-8 qui a été utilisé par défaut lors des opérations précédentes. En effet, les méthodes encode et decode peuvent prendre un argument optionnel pour spécifier l’encodage vers lequel encode / depuis lequel décoder.

>>> 'été'.encode('utf-8')
b'\xc3\xa9t\xc3\xa9'

On notera que la taille varie entre chaînes de caractères et chaînes d’octets, l’appel à len nous renverra 3 dans le premier cas et 5 dans le second. C’est bien parce que l’on compte soit les caractères soit les octets.

>>> len('été')
3
>>> len('été'.encode('utf-8'))
5

D’autres encodages existent et ils ont chacun leurs particularités. Par exemple l’UTF-32 est un encodage unicode qui représente chaque caractère sur 4 octets.

>>> 'été'.encode('utf-32')
b'\xff\xfe\x00\x00\xe9\x00\x00\x00t\x00\x00\x00\xe9\x00\x00\x00'
>>> 'abc'.encode('utf-32')
b'\xff\xfe\x00\x00a\x00\x00\x00b\x00\x00\x00c\x00\x00\x00'

Ou encore l’encodage latin-1 (ou iso-8859–1) un encodage encore parfois utilisé sur certains systèmes en Europe (Windows notamment).

>>> 'été'.encode('latin-1')
b'\xe9t\xe9'

Mais latin-1 n’est pas un encodage unicode et ne pourra donc pas représenter tous les caractères.

>>> '♫'.encode('utf-8')
b'\xe2\x99\xab'
>>> '♫'.encode('latin-1')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'latin-1' codec can't encode character '\u266b' in position 0: ordinal not in range(256)

Une chaîne ayant été encodée avec un certain encodage doit toujours être décodé avec ce même encodage, cela donnerait sinon lieu à des erreurs ou des incohérences.

>>> 'été'.encode('utf-8').decode('latin-1')
'été'
>>> 'été'.encode('latin-1').decode('utf-8')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 0: invalid continuation byte

On notera aussi que l’ascii est reconnu comme un encodage à part entière par les méthodes encode et decode. Bien sûr, seuls les caractères de la table ASCII sont autorisés dans les chaînes.

>>> 'abcdef'.encode('ascii')
b'abcdef'
>>> b'abcdef'.decode('ascii')
'abcdef'

Les encodages interviennent quand vous traitez des données extérieures au programme, et notamment des fichiers. Ainsi, la fonction open dispose d’un paramètre encoding pour préciser l’encodage du fichier à ouvrir.

with open('output.txt', 'w', encoding='latin-1') as f:
    f.write('été')

Gardez donc en tête qu’un fichier texte (ou même n’importe quel texte) est toujours lié à un encodage, et que celui-ci n’est pas toujours UTF-8. Souvent l’encodage sera renseigné comme métadonnée avec le fichier, comme c’est le cas en HTTP avec l’en-tête Content-Type qui précise l’encodage des données.

Mode binaire

Mais tous les fichiers ne représentent pas du texte, même sous des encodages particuliers, les images par exemple. Ainsi, on voudrait parfois pouvoir traiter un fichier comme des données brutes, comme des octets.

Cela est possible à l’aide du mode binaire, il s’agit d’un caractère b ajouté au mode d’ouverture du fichier. Ce mode aura pour effet que toutes les opérations sur le fichier traiteront des chaînes d’octets et non des chaînes de caractères.

>>> with open('output.txt', 'rb') as f:
...     f.read()
... 
b'\xe9t\xe9'

Il en est de même en écriture, où les méthodes attendront des chaînes d’octets.

>>> with open('output.txt', 'wb') as f:
...     f.write(b'\x01\x02\x03')
... 
3

Ce mode nous sera utile pour maintenant aborder un autre format de sérialisation des données, un format binaire.

Sérialisation binaire

Python possède un format de sérialisation qui lui est propre, disponible via le module pickle, capable de gérer à peu près tous les types Python. C’est donc un format très pratique pour enregistrer l’état d’un programme.

Étant un format binaire, je ne vais pas décrire à quoi il ressemble, ça serait juste un tas d’octets illisibles. Sachez juste que le format gère de nombreux objets Python, pour peu que leur type soit connu par le programme qui chargera les données, en inspectant ce qui est contenu dans ces objets.

Module pickle

Le module pickle utilise l’interface dont je vous avais parlé plus tôt pour json, et propose donc les fonctions load, loads, dump et dumps.

Écriture

Puisqu’il nous est impossible de partir d’un fichier existant, nous débuterons cette fois par l’écriture. Elle se fait donc à l’aider des fonctions dump et dumps.

dump prend en argument un objet Python et un fichier (ouvert en écriture) vers lequel le sérialiser.
Il est possible d’enchaîner les appels à dump pour écrire plusieurs objets dans le fichier.

with open('game.dat', 'wb') as f:
    monsters = {
        '001': {
            'name': 'Pythachu',
            'attaques': ['charge', 'tonnerre'],
        },
        '002': {
            'name': 'Pythard',
            'attaques': ['charge', 'jet-de-flotte'],
        },
    }
    pickle.dump(monsters, f)
    attacks = [
        {'name': 'charge', 'type': 'normal', 'damage': 20},
        {'name': 'tonnerre', 'type': 'foudre', 'damage': 50},
        {'name': 'jet-de-flotte', 'type': 'aquatique', 'damage': 50},
    ]
    pickle.dump(attacks, f)

La méthode dumps prend simplement un objet et renvoie une chaîne d’octets qui sera compatible avec loads.

>>> pickle.dumps([1, 2, 3])
b'\x80\x04\x95\x0b\x00\x00\x00\x00\x00\x00\x00]\x94(K\x01K\x02K\x03e.'
>>> pickle.dumps(2+1j)
b'\x80\x04\x95.\x00\x00\x00\x00\x00\x00\x00\x8c\x08builtins\x94\x8c\x07complex\x94\x93\x94G@\x00\x00\x00\x00\x00\x00\x00G?\xf0\x00\x00\x00\x00\x00\x00\x86\x94R\x94.'
Lecture

La méthode loads prend donc en argument une chaîne d’octets, reconstruit l’objet Python représenté et le renvoie.

>>> pickle.loads(b'\x80\x04\x95\x0b\x00\x00\x00\x00\x00\x00\x00]\x94(K\x01K\x02K\x03e.')
[1, 2, 3]
>>> pickle.loads(b'\x80\x04\x95.\x00\x00\x00\x00\x00\x00\x00\x8c\x08builtins\x94\x8c\x07complex\x94\x93\x94G@\x00\x00\x00\x00\x00\x00\x00G?\xf0\x00\x00\x00\x00\x00\x00\x86\x94R\x94.')
(2+1j)

load prend elle un fichier et renvoie aussi l’objet qui y est contenu. Comme pour dump, il est possible d’appeler load plusieurs fois de suite sur un même fichier (pour y lire les différents objets écrits).

>>> with open('game.dat', 'rb') as f:
...     print('monstres :', pickle.load(f))
...     print('attaques :', pickle.load(f))
... 
monstres : {'001': {'name': 'Pythachu', 'attaques': ['charge', 'tonnerre']}, '002': {'name': 'Pythard', 'attaques': ['charge', 'jet-de-flotte']}}
attaques : [{'name': 'charge', 'type': 'normal', 'damage': 20}, {'name': 'tonnerre', 'type': 'foudre', 'damage': 50}, {'name': 'jet-de-flotte', 'type': 'aquatique', 'damage': 50}]
Avantages et inconvénients

Vous l’aurez compris, pickle est un format très pratique en Python, puisqu’il permet de tout représenter ou presque. Il n’est en revanche pas interopérable puisque applicable seulement à Python.

Attention aussi, ce format permet l’exécution de code arbitraire ce qui présente donc une grosse faille de sécurité sur des données non sûres, il est donc à bannir pour tout ce qui reçoit des données distantes sans couche supplémentaire de sécurité.

Dans notre cas d’une sauvegarde de l’état d’un programme, c’est un assez bon choix.

Autres formats

Nous avons fait un tour des modules disponibles dans la bibliothèque standard de Python, mais ce ne sont pas les seuls formats existant. Pour les autres, il faudra en revanche s’appuyer sur des modules tiers, nous verrons par la suite comment en installer.

Voici donc quelques autres formats que vous pourriez croiser et qui se prêtent à diverses utilisations.

toml

Le format toml est un format simple adapté à des fichiers de configuration, dérivé du format INI.
Il permet ainsi de représenter des couples clé/valeur regroupés en sections mais ajoute la gestion des types des valeurs.

Le type d’une valeur sera alors dépendant de la syntaxe utilisée pour la définir, de la même manière que le fait le format JSON. Ainsi dans l’exemple suivant width et height seront des valeurs de type int alors que save sera une chaîne de caractères.

[window]
width = 800
height = 600

[game]
save = "game.dat"
config.toml
yaml

YAML est un format de données riches, semblable à JSON mais plus axé sur la lisibilité. Il permet de décrire de manière claire des données complexes.

id: 001
name: Pythachu
type: foudre
attaques:
  - tonnerre
  - charge
base_pv: 50
pythachu.yaml
msgpack

msgpack est lui aussi un format de données assez semblable à JSON, à l’exception près que c’est un format binaire. Il permet donc de manière compacte de représenter nombres, chaînes de caractères, listes et dictionnaires.

C’est un format interopérable qui possède des bibliothèques pour à peu près tous les langages.

Protobuf

Protobuf est un format plus complexe destiné à établir des protocoles de communication entre programmes. Les programmes doivent donc utiliser un protocole commun qui définit les types des données transmises dans un message.

Cela permet d’omettre les informations de typage dans la sérialisation, et d’avoir une assurance de la validité des données transmises.