L’encoding en Python, une bonne fois pour toute

Cette page est un extrait du site sametmax.com consulté le 25/02/20.

Dans ce qui suit, il ne faudra tenir compte que de ce qui concerne python3 (et non python 2.X).



Vous avez tous un jour eu l’erreur suivante :

UnicodeDecodeError: 'machine' codec can't decode character 'trucmuche' in position x: ordinal not in range(z)

Et là, pour vous en sortir, vous en avez chié des ronds de pâté.

Le problème vient du fait que la plupart du temps, ignorer l’encoding marche : nous travaillons dans des environnements homogènes et toujours avec des données dans le même format, ou un format plus ou moins compatible.

Mais le texte, c’est compliqué, terriblement compliqué, et le jour où ça se gâte, si vous ne savez pas ce que vous faites, vous ne vous en sortirez pas.

C’est d’autant plus vrai en Python car :

  • Par défaut, Python plante sur les erreurs d’encoding là où d’autres langages (comme le PHP) se débrouillent pour vous sortir un truc (qui ne veut rien dire, qui peut corrompre toute votre base de données, mais qui ne plante pas).
  • Python est utilisé dans des environnements hétérogènes. Quand vous codez en JS sur le navigateur, vous n’avez presque jamais à vous soucier de l’encoding : le browser gère quasiment tout pour vous. En Python dès que vous allez lire un fichier et l’afficher dans un terminal, cela fait potentiellement 3 encoding différents.
  • Python 2.7 a des réglages par défaut très stricts, et pas forcément adaptés à notre informatique moderne (fichier de code en ASCII par exemple).

A la fin de cet article, vous saurez vous sortir de toutes les situations merdiques liées aux encodages.

Règle numéro 1 : Le texte brut n’existe pas.

Quand vous avez du texte quelque part (un terminal, un fichier, une base de données…), il est forcément représenté sous forme de 0 et de 1.

La corrélation entre cette suite de 0 et de 1 et la lettre est faite dans un énorme tableau qui contient toutes les lettres d’un côté, et toutes les combinaisons de 0 et de 1 de l’autre. Il n’y a pas de magie. C’est un énorme tableau stocké quelque part dans votre ordinateur. Si vous n’avez pas ce tableau, vous ne pouvez pas lire du texte. Même le texte le plus simple.

Malheureusement, au début de l’informatique, presque chaque pays a créé son propre tableau, et ces tableaux sont incompatibles entre eux : pour la même combinaison de 0 et de 1, ils donnent un caractère différent voire rien du tout.

La mauvaise nouvelle, c’est qu’ils sont encore utilisés aujourd’hui.

Ces tableaux, c’est ce qu’on appelle les encodings, et il y en a beaucoup. Voici la liste de ceux que Python gère :

>>> import encodings
>>> print ''.join('- ' + e + '\n' for e in sorted(set(encodings.aliases.aliases.values())))
- ascii
- base64_codec
- big5
- big5hkscs
- bz2_codec
- cp037
- cp1026
- cp1140
- cp1250
- cp1251
- cp1252
- cp1253
- cp1254
- cp1255
- cp1256
- cp1257
- cp1258
- cp424
- cp437
- cp500
- cp775
- cp850
- cp852
- cp855
- cp857
- cp858
- cp860
- cp861
- cp862
- cp863
- cp864
- cp865
- cp866
- cp869
- cp932
- cp949
- cp950
- euc_jis_2004
- euc_jisx0213
- euc_jp
- euc_kr
- gb18030
- gb2312
- gbk
- hex_codec
- hp_roman8
- hz
- iso2022_jp
- iso2022_jp_1
- iso2022_jp_2
- iso2022_jp_2004
- iso2022_jp_3
- iso2022_jp_ext
- iso2022_kr
- iso8859_10
- iso8859_11
- iso8859_13
- iso8859_14
- iso8859_15
- iso8859_16
- iso8859_2
- iso8859_3
- iso8859_4
- iso8859_5
- iso8859_6
- iso8859_7
- iso8859_8
- iso8859_9
- johab
- koi8_r
- latin_1
- mac_cyrillic
- mac_greek
- mac_iceland
- mac_latin2
- mac_roman
- mac_turkish
- mbcs
- ptcp154
- quopri_codec
- rot_13
- shift_jis
- shift_jis_2004
- shift_jisx0213
- tactis
- tis_620
- utf_16
- utf_16_be
- utf_16_le
- utf_32
- utf_32_be
- utf_32_le
- utf_7
- utf_8
- uu_codec
- zlib_codec

Et certains ont plusieurs noms (des alias), donc on pourrait en compter plus:

>>> len(encodings.aliases.aliases.keys())
307

Quand vous affichez du texte sur un terminal avec un simple print, votre ordinateur va implicitement chercher le tableau qu’il pense être le plus adapté, et fait la traduction. Même pour le texte le plus simple. Même pour un espace tout seul.

Mais surtout, ça veut dire que votre propre code EST dans un encoding. Et vous DEVEZ savoir lequel.

Règle numéro 2 : utf8 est le langage universel, utilisez-le

Il existe un encoding qui essaye des regrouper toutes les langues du monde, et il s’appelle unicode. Unicode est un tableau gigantesque qui contient des combinaisons de 1 et de 0 d’un côté, et les caractères de toutes la langues possibles de l’autre : chinois, arabe, français, espagnol, russe…

Bon, il ne contient pas encore absolument tout, mais il couvre suffisamment de terrain pour éliminer 99.999999999% des problèmes de communications de texte entre machines dans le monde.

Le défaut d’Unicode est qu’il est plus lent et prend plus de place que d’autres représentations du même texte. Aujourd’hui le téléphone le plus pourri a 10 fois la puissance nécessaire, et ce n’est plus un souci : il peut être utilisé presque partout (sauf peut-être dans l’embarqué drastique) sans même réfléchir à la question. Tous les langages les plus importants, tous les services les plus importants, tous les logiciels les plus importants gèrent unicode.

Il y a plusieurs implémentations concrètes d’unicode, la plus célèbre est “UTF 8”.

Moralité, par défaut, utilisez utf-8.

Une fois, à l’entretien d’embauche, un mec m’avait reproché d’utiliser UTF8 parce que “ça posait des problèmes d’encoding”. Comprenez bien qu’utf-8 ne pose aucun problème d’encoding. Ce sont tous les autres codecs du monde qui posent des problèmes d’encoding. UTF-8 est certainement le seul à justement, ne poser aucun problème.

UTF 8 est le seul encoding vers lequel, aujourd’hui, on puisse convertir vers et depuis (pratiquement) n’importe quel autre codec du monde. C’est un espéranto. C’est une pierre de rosette. C’est au texte ce que l’or est à l’économie.

Si UTF8 vous pose “un problème d’encoding”, c’est que vous ne savez pas dans quel encoding votre texte est actuellement ou comment le convertir. C’est tout.

Il n’y a presque aucune raison de ne pas utiliser UTF8 aujourd’hui (à part sur des vieux systèmes ou des systèmes où les ressources sont tellement limitées que vous n’utiliseriez pas Python de toute façon).

Utilisez utf8. Partout. Tout le temps.

Si vous communiquez avec un système qui ne comprend pas UTF8, convertissez.

Mais gardez votre partie en UTF8.

Règle numéro 3 : il faut maîtriser l’encoding de son code

Le fichier dans lequel vous écrivez votre code est dans un encoding et ce n’est pas lié à votre OS. C’est votre éditeur qui s’en occupe. Apprenez à régler votre éditeur pour qu’il utilise l’encoding que vous voulez.

Et l’encoding que vous voulez est UTF8.

Si vous ne savez pas dans quel encoding est votre code, vous ne pouvez pas manipuler du texte et garantir l’absence de bug.

Vous ne POUVEZ PAS.

Donc réflexe : vous configurez votre éditeur de texte pour sauvegarder tous vos nouveaux fichiers par défaut en UTF8. Maintenant. Tout de suite.

Regardez dans la doc de l’éditeur, dans l’aide ou tapez sur Google, mais faites le.

Puis il faut déclarer cet encoding à la première ligne de chaque fichier de code avec l’expression suivante :

# coding: encoding

Par exemple :

# coding: utf8

C’est une spécificité de Python : si l’encoding du fichier est différent de l’encoding par défaut du langage, il faut le déclarer sinon le programme plantera à la première conversion. En Python 2.7, l’encoding par défaut est ASCII, donc il faut presque toujours le déclarer. En Python 3, l’encoding par défaut est UTF8 et on peut donc l’omettre si on l’utilise. Ce que vous allez faire après la lecture de cet article.

Ensuite, il existe deux types de chaînes de caractères en Python :

  • La chaîne de caractères encodée: type ‘str’ en Python 2.7, ‘byte’ en Python 3.
  • La chaîne de caractères décodée: type ‘unicode’ en Python 2.7, et ‘str’ en python 3 (sic).

Illustration :

$ python2.7
Python 2.7.3 (default, Aug  1 2012, 05:14:39)
[GCC 4.6.3] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> type('chaine') # bits => encodée
<type 'str'>
>>> type(u'chaine') # unicode => décodée
<type 'unicode'>
$ python3
Python 3.2.3 (default, Oct 19 2012, 20:10:41)
[GCC 4.6.3] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> type("chaine") # unicode => decodée
<class 'str'>
>>> type(b"chaine") # bits => encodée
 
<class 'bytes'>

Votre but, c’est de n’avoir dans votre code que des chaînes de type ‘unicode’.

En Python 3, c’est automatique. Toutes les chaînes sont de type ‘unicode’ (appelé ‘str’ dans cette version, je sais, je sais, c’est confusionant à mort) par défaut.

En Python 2.7 en revanche, il faut préfixer la chaîne par un u.

Donc, dans votre code, TOUTES vos chaînes doivent être déclarées ainsi :

u"votre chaîne"

Oui, c’est chiant. Mais c’est indispensable. Encore une fois, il n’y a pas d’alternative (dites le avec la voix de Thatcher si ça vous excite).

Si vous voulez, vous pouvez activer le comportement de Python 3 dans Python 2.7 en mettant ceci au début de CHACUN de vos modules :

from __future__ import unicode_literals

Ceci n’affecte que le fichier en cours, jamais les autres modules.

On peut le mettre au démarrage d’iPython également.

Je résume :

  • Réglez votre éditeur sur UTF8.
  • Mettez # coding: utf8 au début de vos modules.
  • Préfixez toutes vos chaînes de u ou faites from __future__ import unicode_literals en début de chaque module.

Si vous ne faites pas cela, votre code marchera. La plupart de temps. Et un jour, dans une situation particulière, il ne marchera plus. Plus du tout.

Oh, et ce n’est pas grave si vous avez d’anciens modules dans d’autres encodings. Tant que vous utilisez des objets ‘unicode’ partout, ils marcheront sans problème ensemble.

Règle numéro 4 : décodez toutes les entrées de votre programme

La partie difficile de ce conseil, c’est de savoir ce qu’est une entrée.

Je vais vous donner une définition simple : tout ce qui ne fait pas partie du code de votre programme et qui est traité dans votre programme est une entrée.

Le texte des fichiers, le nom de ces fichiers, le retour des appels système, le retour d’une ligne de commande parsée, la saisie utilisateur sur un terminal, le retour d’une requête SQL, le téléchargement d’une donnée sur le Web, etc.

Ce sont toutes des entrées.

Comme tous les textes du monde, les entrées sont dans un encoding. Et vous DEVEZ savoir lequel.

Comprenez bien, si vous ne connaissez pas l’encoding de vos entrées, ça marchera la plupart du temps, et un jour, ça va planter.

Il n’y a pas d’alternative (bis).

Or, il n’y a pas de moyen de détecter un encoding de façon fiable.

Donc, soit le fournisseur de la donnée vous donne cette information (settings dans la base de données, doc de votre logiciel, configuration de votre OS, spec du client, coup de fils au fournisseur…), soit vous êtes baisés.

On ne peut pas lire un simple fichier si on ne connait pas son encoding. Point.

Si cela a marché jusqu’ici pour vous, c’est que vous avez eu de la chance : la plupart de vos fichiers étaient dans l’encoding de votre éditeur et de votre système. Tant qu’on travaille sur sa machine, tout va bien.

Si vous lisez une page HTML, l’encoding est souvent déclaré dans la balise META ou dans un header.

Si vous écrivez dans un terminal, l’encoding du terminal est accessible avec sys.(stdin|stdout).encoding.

Si vous manipulez des noms de fichier, on peut récupérer l’encoding du file system en cours avec sys.getfilesystemencoding().

Mais parfois il n’y a pas d’autres moyens d’obtenir cette information que de demander à la personne qui a produit la donnée. Parfois même, l’encoding déclaré est faux.

Dans tous les cas, vous avez besoin de cette information.

Et une fois que vous l’avez, il faut décoder le texte reçu.

La manière la plus simple de faire cela est :

votre_chaine = votre_chaine.decode('nom_du_codec')

Le texte sera de type ‘str’, et decode() retourne (si vous lui fournissez le bon codec ;-)), une version ‘unicode’.

Exemple, obtenir une chaîne ‘unicode’ depuis une chaîne ‘str’ encodée en utf8 :

>>> une_chaine = 'Chaîne' # mon fichier est encodé en UTF8, donc la chaine est en UTF8
>>> type(une_chaine)
<type 'str'>
>>> une_chaine = une_chaine.decode('utf8')
>>> type(une_chaine)
<type 'unicode'>

Donc dès que vous lisez un fichier, récupérez une réponse d’une base de données ou parsez des arguments d’un terminal, appelez decode() sur la chaîne reçue.

Règle numéro 5 : encodez toutes les sorties de votre programme

La partie difficile de ce conseil, c’est de savoir ce qu’est une sortie.

Encore une fois, une définition simple : toute donnée que vous traitez et qui va être lue par autre chose que votre code est une sortie.

Un print dans un terminal est une sortie, un write() dans un fichier est une sortie, un UPDATE en SQL est une sortie, un envoi dans une socket est une sortie, etc.

Le reste du monde ne peut pas lire les objets ‘unicode’ de Python. Si vous écrivez ces objets dans un fichier, un terminal ou dans une base de données, Python va les convertir automatiquement en objet ‘str’, et l’encoding utilisé dépendra du contexte.

Malheureusement, il y a une limite à la capacité de Python à décider du bon encoding.

Donc, tout comme il vous faut connaitre l’encoding d’un texte en entrée, il vous faut connaitre l’encoding attendu par le système avec lequel vous communiquez en sortie : sachez quel est l’encoding du terminal, de votre base de données ou système de fichiers sur lequel vous écrivez.

Si vous ne pouvez pas savoir (page Web, API, etc), utilisez UTF8.

Pour ce faire, il suffit d’appelez encode() sur tout objet de type ‘unicode’ :

une_chaine = une_chaine.encode('nom_du_codec')

Par exemple, pour convertir un objet ‘unicode’ en ‘str’ utf8:

>>> une_chaine = u'Chaîne'
>>> type(une_chaine)
<type 'unicode'>
>>> une_chaine = une_chaine.encode('utf8')
>>> type(une_chaine)
<type 'str'>

Résumé des règles

  1. Le texte brut n’existe pas.
  2. Utilisez UTF8. Maintenant. Partout.
  3. Dans votre code, spécifiez l’encoding du fichier et déclarez vos chaînes comme ‘unicode’.
  4. À l’entrée, connaissez l’encoding de vos données, et décodez avec decode().
  5. A la sortie, encodez dans l’encoding attendu par le système qui va recevoir la données, ou si vous ne pouvez pas savoir, en UTF8, avec encode().

Je sais que ça vous démange de voir un cas concret, alors voici un pseudo programme (téléchargeable ici) :

# coding: utf-8 
 
 
# toutes les chaines sont en unicode (même les docstrings)
from __future__ import unicode_literals
 
"""
    Un script tout pourri qui télécharge plein de page et les sauvegarde
    dans une base de données sqlites.
 
    On écrit dans un fichier de log les opérations effectuées.
"""
 
import re
import urllib2
import sqlite3
 
pages = (
    ('Snippets de Sebsauvage', 'http://www.sebsauvage.net/python/snyppets/'),
    ('Top 50 de bashfr', 'http://danstonchat.com/top50.html'),
)
 
# création de la base de données
conn = sqlite3.connect(r"backup.db")
c = conn.cursor()
 
try:
    c.execute('''
        CREATE TABLE pages (
            id INTEGER PRIMARY KEY,
            nom TEXT,
            html TEXT
        )'''
    )
except sqlite3.OperationalError:
    pass
 
log = open('backup.log', 'wa')
 
for nom, page in pages:
 
    # ceci est une manière très fragile de télécharger et
    # parser du HTML. Utilisez plutôt scrapy et beautifulsoup
    # si vous faites un vrai crawler
    response = urllib2.urlopen(page)
    html = response.read(100000)
 
    # je récupère l'encoding à l'arrache
    encoding = re.findall(r'<meta.*?charset=["\']*(.+?)["\'>]', html, flags=re.I)[0]
 
    # html devient de l'unicode
    html = html.decode(encoding)
 
    # ici je peux faire des traitements divers et varié avec ma chaîne
    # et en fin de programme...
 
    # la lib sqlite convertie par défaut tout objet unicode en UTF8
    # car c'est l'encoding de sqlite par défaut donc passer des chaînes
    # unicode marche, et toutes les chaînes de mon programme sont en unicode
    # grace à mon premier import
    c.execute("""INSERT INTO pages (nom, html) VALUES (?, ?)""", (nom, html))
 
    # j'écris dans mon fichier en UTF8 car c'est ce que je veux pouvoir lire
    # plus tard
    msg = "Page '{}' sauvée\n".format(nom)
    log.write(msg.encode('utf8'))
 
    # notez que si je ne fais pas encode(), soit:
    # - j'ai un objet 'unicode' et ça plante
    # - j'ai un objet 'str' et ça va marcher mais mon fichier contiendra
    #   l'encoding de la chaîne initiale (qui ici serait aussi UTF8, mais
    #   ce n'est pas toujours le cas)
 
conn.commit()
c.close()
 
log.close()

Quelques astuces

Certaines bibliothèques acceptent indifféremment des objets ‘unicode’ et ‘str’ :

>>> from logging import basicConfig, getLogger
>>> basicConfig()
>>> log = getLogger()
>>> log.warn("Détécé")
WARNING:root:Détécé
>>> log.warn(u"Détécé")
WARNING:root:Détécé

Et ce n’est pas forcément une bonne chose car si il y a derrière écriture dans un fichier de log, cela peut poser problème.

D’autres ont besoin qu’on leur précise:

>>> import re
>>> import re
>>> re.search('é', 'télé')
<_sre.SRE_Match object at 0x7fa4d3f77238>
>>> re.search(u'é', u'télé', re.UNICODE)
<_sre.SRE_Match object at 0x7fa4d3f772a0>

Le module re par exemple aura des résultats biaisés sur une chaîne ‘unicode’ si on ne précise pas le flag re.UNICODE.

D’autres n’acceptent pas d’objet ‘str’:

>>> import io
>>> >>> io.StringIO(u'é')
<_io.StringIO object at 0x14a96d0>
>>> io.StringIO(u'é'.encode('utf8'))
Traceback (most recent call last):
  File "<ipython-input-5-16988a0d4ac4>", line 1, in <module>
    io.StringIO('é'.encode('utf8'))
TypeError: initial_value must be unicode or None, not str

D’autres encore n’acceptent pas d’objet ‘unicode’:

>>> import base64
>>> base64.encodestring('é'.encode('utf8'))
'w6k=\n'
>>> base64.encodestring(u'é')
Traceback (most recent call last):
  File "<ipython-input-3-1714982ca68e>", line 1, in <module>
    base64.encodestring('é')
  File "/usr/lib/python2.7/base64.py", line 315, in encodestring
    pieces.append(binascii.b2a_base64(chunk))
UnicodeEncodeError: 'ascii' codec can't encode character u'\xe9' in position 0: ordinal not in range(128)

Cela peut être pour des raison de performances (certaines opérations sont plus rapides sur un objet ‘str’), ou pour des raisons historiques, d’ignorance ou de paresse.

Vous ne pouvez pas le deviner à l’avance. Souvent c’est marqué dans la doc, sinon il faut tester dans le shell.

Une bibliothèque bien faite demandera de l’unicode et vous retournera de l’unicode, vous libérant l’esprit. Par exemple, requests et l’ORM Django le font, et communiquent avec le reste du monde (en l’occurence le Web et les bases de données) dans le meilleur encoding possible automatiquement de manière transparente. Quand c’est possible bien entendu, parfois il faudra forcer l’encoding car le fournisseur de votre donnée déclare le mauvais. Vous n’y pouvez rien, c’est pareil pour tous les langages du monde.

Enfin il existe des raccourcis pour certaines opérations, utilisez-les autant que possible. Par exemple, pour lire un fichier, au lieu de faire un simple open(), vous pouvez faire :

from codecs import open
 
# open() de codec à exactement la même API, y compris avec "with"
f = open('fichier', encoding='encoding')

Les chaînes récupérées seront automatiquement sous forme d’objet ‘unicode’ au lieu d’objet ‘str’ qu’il vous aurait fallu convertir à la main.

Les outils de la dernière chance

Je vous ai menti, si vous ne connaissez pas l’encoding de vos entrées ou de vos sorties, il vous reste encore quelques options.

Sachez cependant que ces options sont des hacks, des trucs à tenter quand tout ce qui a été décrit plus haut a foiré.

Si vous faites bien votre boulot, ça ne doit pas arriver souvent. Une à deux fois max dans votre année, sauf environnement de travail très très merdique.

D’abord, parlons de l’entrée.

Si vous recevez un objet et qu’il vous est impossible de trouver l’encoding, vous pouvez forcer un décodage imparfait avec decode() en spécifiant le paramètre error.

Il peut prendre les valeurs suivantes :

  • 'strict' : lever une exception en cas d’erreur. C’est le comportement par défaut.
  • 'ignore' : tout caractère qui provoque une erreur est ignoré.
  • 'replace' : tout caractère qui provoque une erreur est remplacé par un point d’interrogation.
>>> print 'Père Noël'.decode('ascii')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 1: ordinal not in range(128)
>>> print 're Noël'.decode('ascii', errors='ignore')
Pre Nol
>>> print 're Noël'.decode('ascii', errors='replace')
P��re No��l

Mozilla vient également à la rescousse avec sa lib chardet qu’il faut donc installer :

pip install chardet

Et qui TENTE (du verbe ‘tenter’, “qui essaye”, et qui donc peut échouer et se tromper) de détecter l’encoding utilisé.

>>> chardet.detect(u'Le Père Noël est une ordure'.encode('utf8'))
{'confidence': 0.8063275188616134, 'encoding': 'ISO-8859-2'}
>>> chardet.detect(u"Le Père Noël est une ordure j'ai dis enculé".encode('utf8'))
{'confidence': 0.87625, 'encoding': 'utf-8'}

Cela marche pas trop mal, mais n’attendez pas de miracles. Plus il y a de texte, plus c’est précis, et plus le paramètre confidence est proche de 1.

Parlons maintenant de la sortie, c’est à dire le cas où le système qui va recevoir vos données est une grosse quiche qui plante dès qu’on lui donne autre chose que de l’ASCII.

Je ne veux balancer personne, mais mon regard se tourne vers l’administration américaine. Subtilement. De manière insistante.

D’abord, encode() accepte les mêmes valeurs pour errors que decode(). Mais en prime, il accepte 'xmlcharrefreplace', très pratique pour les fichiers XML :

>>> u"Et là-bas, tu vois, c'est la coulée du grand bronze".encode('ascii', errors='xmlcharrefreplace')
"Et l&#224;-bas, tu vois, c'est la coul&#233;e du grand bronze"

Enfin, on peut essayer d’obtenir un texte potable en remplaçant les caractères spéciaux par leur équivalent ASCII le plus proche.

Avec l’alphabet latin, c’est très facile :

>>> unicodedata.normalize('NFKD', u"éçûö").encode('ascii', 'ignore')
'ecuo'

Pour des trucs plus avancés comme le cyrilique ou le mandarin, il faut installer unidecode :

pip install unidecode
>>> from unidecode import unidecode
>>> print unidecode(u"En russe, Moscou s'écrit Москва")
En russe, Moscou s'ecrit Moskva