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 :
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 auNone
de Python.boolean
, un booléen donc,true
oufalse
.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)
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.
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.
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')
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.
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)
...
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 :
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.
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.
- 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 |
|
|
|
|
|
|
|
|
1 |
|
|
|
|
|
|
|
|
2 |
|
|
|
|
|
|
|
|
3 |
|
|
|
|
|
|
|
|
4 |
|
|
|
|
|
|
|
|
5 |
|
|
|
|
|
|
|
|
6 |
|
|
|
|
|
|
|
|
7 |
|
|
|
|
|
|
|
|
8 |
|
|
|
|
|
|
|
|
9 |
|
|
|
|
|
|
|
|
A |
|
|
|
|
|
|
|
|
B |
|
|
|
|
|
|
|
|
C |
|
|
|
|
|
|
|
|
D |
|
|
|
|
|
|
|
|
E |
|
|
|
|
|
|
|
|
F |
|
|
|
|
|
|
|
|
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.
- Page de la bibliothèque
toml
en Python : https://github.com/uiri/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.
- Page de la bibliothèque
PyYAML
en Python : https://pyyaml.org/wiki/PyYAML
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.
- Page du projet
msgpack
: https://msgpack.org/
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.
- Page du projet
protobuf
: https://developers.google.com/protocol-buffers