Ouvrir la navigation secondaire
Précédent Gestion de la couleur

Unicode et Python

Unicode, quel sujet classique pour un développeur Python ! Parfois on pense avoir tout compris et puis boum, une UnicodeDecodeError pointe le bout de son nez.

Comme je ne parviens jamais à tout retenir par cœur (nous ne sommes pas égaux devant la mémoire !), voici mes notes sur ce que j'ai compris là dessus au cours de ces dernières années.

Ce billet est consacré à Python mais je vous encourage aussi à étudier comment votre langage favori se débrouille avec Unicode. Cette présentation par Tom Christiansen (contributeur Perl) est un bon point de départ même si elle date de 2011 : Unicode: The Good, the Bad, & the (mostly) Ugly.

Introduction à Unicode

En informatique, tout est représenté par des bits, regroupés en multiplets (le plus souvent des octets). Un octet contient 8 bits dont chacun peut prendre 2 états (0 ou 1), soit 2^8 = 256 possibilités au maximum. Toutes les entrées et sorties d'un programme sont en octets.

On a besoin de conventions pour donner du sens aux octets. On parle de système d'encodage (ou codage) de caractères pour décrire des algorithmes permettant de transformer des caractères en octets. Pour schématiser il s'agit de transformer en octets un caractère représenté par un numéro unique dans une table de correspondance.

Je zappe ici l'histoire du codage des caractères et passe en mode avance rapide jusqu'à Unicode.

Aujourd'hui le standard Unicode est (souvent) utilisé pour pouvoir représenter tous les caractères possibles utilisés par l'humanité.

Le standard Unicode est formé d'une multitude de composants (Unicode Regular Expressions, Unicode Collation Algorithm etc.).

Sa table de correspondance est divisée en 17 plans. Chaque plan permet d'attribuer 65.536 points de codes. À chaque point de code est attribué un caractère et un nom unique (écrit en ASCII entièrement en majuscules). Le premier plan nommé plan de base multilingue contient à lui seul la majeure partie des caractères utilisés dans toutes les langues du monde mais aussi des symboles.

Des nouveaux caractères sont ajoutés à chaque nouvelle version du standard. Au moment où j'écris ce billet la version 9.0.0 totalise 128.172 points de codes (il reste 846.293 points de codes possibles).

Un point de code est défini par un préfixe U+ suivi d'une valeur hexadécimale dans l'intervalle 0000 à 10FFFF.

Une chaîne Unicode est constituée d'une suite de points de code.

Les points de codes dans l'intervalle U+0000 à U+007F sont identiques à ceux de l'US-ASCII et ceux dans l'intervalle U+0000 à U+00FF (les 256 premiers) sont identiques à ceux de l'ISO 8859-1 (Latin-1). Grâce à cette compatibilité, les systèmes d'encodage ASCII et ISO 8859-1 sont tous les deux capables de transformer un sous-ensemble restreint de points de codes Unicode en octets.

Pour représenter tous les points de codes existants en données binaires, le standard Unicode définit l'Unicode transformation format (UTF) qui regroupe plusieurs systèmes d'encodage.

Créé en 1992, UTF-8 est le plus populaire d'entre eux et a été adopté comme un standard pour internet.

Représentation interne d'Unicode

En Python, la représentation interne de l'Unicode n'est jamais exposée à l'utilisateur. À la limite on aurait même pas besoin de s'en soucier mais ça peut aider à comprendre certaines choses.

The Guts of Unicode in Python par Benjamin Peterson est un bon résumé des changements effectués au fil du temps (PEP 100, PEP 261, PEP 383, PEP 393).

De Python 2.2 à Python 3.2 vous avez la possibilité de compiler Python avec l'option --enable-unicode=ucs2 (UTF-16, narrow Python) ou --enable-unicode=ucs4 (UTF-32, wide Python).

Les deux systèmes ont la particularité d'encoder les caractères avec une taille fixe (2 octets en UTF-16 ou 4 octets en UTF-32).

En UTF-16 (narrow Python), la particularité est que les points de codes au delà du plan de base multilingue sont encodés sur une paire de 2 octets (soit 4 octets), la fameuse paire suppléante ou surrogate pair.

Ce qui peut donner des choses surprenantes :

>>> char = u"\U0001F4A9"
>>> len(char)
2
>>> import unicodedata
>>> unicodedata.category(char[0])
'Cs'  # Surrogate.
>>> import sys
>>> sys.maxunicode
65535

En Python 2, la version distribuée par défaut est le plus souvent la version UTF-16. Au passage JavaScript souffre des mêmes limitations.

Alors qu'avec un Python en UTF-32 (wide Python) :

>>> char = u"\U0001F4A9"
>>> len(char)
1
>>> import unicodedata
>>> unicodedata.category(char[0])
'So'  # Symbol.
>>> import sys
>>> sys.maxunicode
1114111

Mais avec l'UTF-32, chaque caractère est toujours encodé sur 4 octets. Ça fait beaucoup de gâchis :

$ echo $LC_ALL
fr_FR.UTF-8

$ echo -n "💩" > utf8.txt
$ stat -f "%z bytes" utf8.txt
4 bytes
$ hexdump utf8.txt
0000000 f0 9f 92 a9
0000004

$ iconv -f utf-8 -t utf-16 utf8.txt > utf16.txt
$ stat -f "%z bytes" utf16.txt
6 bytes
$ hexdump utf16.txt
# UTF-16 big-endian BOM = fe ff
0000000 fe ff d8 3d dc a9
0000006

$ iconv -f utf-16 -t utf-32 utf16.txt > utf32.txt
$ stat -f "%z bytes" utf32.txt
8 bytes
$ hexdump utf32.txt
# UTF-32 BOM = 00 00 fe ff
0000000 00 00 fe ff 00 01 f4 a9
0000008

À partir de Python 3.3 une seule représentation flexible est adoptée par CPython pour tenter de trouver une meilleure solution. C'en est terminé des versions narrow et wide. Le nombre de bits utilisés en interne varie en fonction du type des chaînes pour la meilleure utilisation possible de la mémoire. Python supporte maintenant par défaut la totalité des points de code Unicode et un caractère a toujours une longueur qui vaut 1.

La route est pavée pour un support encore meilleur du standard Unicode.

Au passage, signalons que d'autres langages ont fait le choix d'UTF-8 pour la représentation interne.

Encodage des fichiers sources

Un flux d'octets n'est pas capable de signaler lui même son système d'encodage. Il existe bien des manières de le détecter mais on ne peut pas en être certain sans un certain volume de données.

Afin d'indiquer à l'interpréteur Python quel système d'encodage est utilisé par un fichier, la syntaxe du commentaire magique a été implémentée (PEP 263). Avant, c'était la teuf :

Before PEP 263, Python was unaware of source encodings, and would literally copy the bytes from the source code file into the string object - whether they were latin-1, UTF-8, or some other encoding. The only requirement was that the encoding needs to be an ASCII superset, so that Python properly detects the end of the string.

À partir de Python 2.3 l'interpréteur traite un fichier sans indication d'encodage comme de l'ASCII.

Ça n'est plus le cas en Python 3 qui considère un fichier sans indication d'encodage comme de l'UTF-8 (PEP 3120).

Il est possible d'utiliser n'importe quel système d'encodage pour un fichier source s'il permet à l'interpréteur Python d'analyser le commentaire magique. Pas d'UTF-16 par exemple.

La PEP 8 donne les bonnes pratiques en termes d'encodage des fichiers sources.

Encodage et flux standards

Le terminal écrit et lit des bits dans les flux standards (stdin, stdin et stderr). Ces bits sont encodés en fonction de la configuration du terminal.

Sur un système Unix-like, l'interpréteur Python se base sur la valeur de locale.getpreferredencoding() (lui même basé sur locale.getdefaultlocale()) pour tenter de déterminer le système d'encodage utilisé par le terminal. Il va lire une variable d'environnement liée à l'internationalisation qui peut être LC_ALL, LC_CTYPE, LANG ou LANGUAGE.

Vous pouvez faire un test facilement, (ici sous macOS en Python 3.6) :

$ echo $LC_ALL
fr_FR.UTF-8

$ export LC_ALL="fr_FR.ISO8859-1"

python3.6
>>> import locale
>>> locale.getdefaultlocale()
('fr_FR', 'ISO8859-1')
>>> import sys
>>> sys.stdout.encoding
'ISO8859-1'
>>> sys.stdin.encoding
'ISO8859-1'
>>> print('\N{PILE OF POO}')
UnicodeEncodeError: 'latin-1' codec can't encode character '\U0001f4a9' in position 0: ordinal not in range(256)

Il est possible de toujours surcharger les locales d'internationalisation et d'informer l'interpréteur Python du système d'encodage utilisé par les flux standards via la variable d'environnement PYTHONIOENCODING.

Convertir l'Unicode en octets (et vice versa)

Python 2 propose deux types de chaînes qui dérivent de basestring :

  • str : des chaînes d'octets contenant des données 8 bits (le nom str est ambigu)
  • unicode : des chaînes Unicode non encodées (c'est à dire des points de code)

Pour passer d'un type à l'autre on utilise :

Encodage et décodage en Python

Si le paramètre encoding (le système d'encodage) n'est pas passé, alors Python utilise la valeur par défaut sys.getdefaultencoding() qui est l'ASCII en Python 2 (dans la plupart des cas). Vous pouvez encore croiser sys.setdefaultencoding() en Python 2 mais la fonction a été retirée de Python 3 car c'était le mal.

Encoder une chaîne Unicode en octets peut générer des erreurs (UnicodeEncodeError) si le système d'encodage (encoding) utilisé n'est pas capable de représenter un point de code en bits.

L'opération inverse, décoder des octets en chaîne Unicode, peut aussi générer des erreurs (UnicodeDecodeError). Par exemple si on tente de décoder en UTF-8 une séquences d'octets interdite.

Le paramètre errors sert à définir une politique de gestion de ces erreurs. Il vaut strict par défaut et lève des exceptions en cas d'erreur.

Python 3 sépare strictement les concepts de texte et de données :

  • str stocke des points de code Unicode
  • bytes stocke uniquement des octets

En Python 3 une séquence Unicode et une séquence d'octets ne sont pas égales même si elles contiennent les mêmes bits ASCII, alors qu'en Python 2 si ! Une des conséquences est qu'une clé de dictionnaire Unicode ne peut être trouvée avec une séquence de bits en Python 3.

Le préfixe u devant les littéraux Unicode a été réintégré dans Python 3.3 pour simplifier la maintenance de code compatible 2.x et 3.x (PEP 414).

Conversion implicite (coercion)

Python 2 tente automatiquement de transformer des octets en chaîne Unicode lors d'une opération mixant les deux types. On parle de coercion ou conversion implicite.

Cet aspect est considéré comme une erreur de design de Python 2 souvent source d'exceptions imprévues et de comportements inexplicables dans les programmes.

Le système d'encodage utilisé pendant la coercion est celui par défaut (sys.getdefaultencoding()), soit l'ASCII généralement en Python 2.

Par exemple, un simple print de chaîne Unicode va causer une conversion implicite car la sortie est toujours en octets, donc l'Unicode va devoir être converti en octets avant de pouvoir être imprimé.

Pour empêcher votre cerveau d'exploser, dixit David Beazley, les chaînes d'octets et les chaînes Unicode ne doivent jamais être mélangées.

La règle générale est d'utiliser ce que Ned Batchelder appelle le sandwich Unicode : conserver tout le texte au format Unicode à l'intérieur du programme, et le convertir aussi proche que possible des extrémités.

Ça implique de bien comprendre le type de chacune de ses chaînes. Or c'est plus facile à dire qu'à faire car certaines API de Python 2 ne fonctionnent pas avec des chaînes Unicode. Vous pouvez vous faire surprendre par exemple avec un simple strftime car cette fonction effectue en interne un appel à la fonction strftime de C qui retourne des octets encodés en fonction de la locale d'internationalisation :

>>> import datetime

# Set french locale to affect the interpretation of format specifiers in strftime().
>>> import locale
>>> locale.setlocale(locale.LC_ALL, "fr_fr")

>>> now_str = datetime.datetime(2017, 2, 18).strftime('%d %B %Y')
>>> print(u"Nous sommes le %s" % now_str)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3 in position 4: ordinal not in range(128)

>>> now_unicode = now_str.decode(locale.getpreferredencoding())
>>> print(u"Nous sommes le %s" % now_unicode)
Nous sommes le 18 février 2017

Python 3 fixe ce problème dans la plupart des APIs. De plus il interdit complètement de mélanger des objets str et bytes et sonne le glas de la conversion Unicode implicite.

Ouverture de fichiers

En Python 2 la fonction open, qu'elle soit en mode r (par défaut) ou rb retourne toujours des objets str (donc des octets). Cela fonctionne pour lire du texte aussi longtemps que le système d'encodage utilisé est compatible avec ASCII. Quand on connaît le système d'encodage utilisé par un fichier texte on peut se servir de codecs.open pour obtenir des objets unicode :

$ python2.7

>>> with open('a.txt') as f:
...     for line in f:
...         print(type(line))
<type 'str'>

>>> with open('a.txt', mode='rb') as f:
...     for line in f:
...         print(type(line))
<type 'str'>

>>> import codecs
>>> with codecs.open('a.txt', encoding='utf-8') as f:
...     for line in f:
...         print(type(line))
<type 'unicode'>

En Python 2 l'ouverture en mode b (binaire) empêche la conversion des fins de lignes. Ceux qui font du Python sous Windows connaissent bien ce mode.

Python 3 modifie la donne et sépare strictement les octets de l'Unicode :

  • à l'ouverture d'un fichier en mode texte (r), ses octets sont encodés pour obtenir des objets str
  • à l'ouverture d'un fichier en mode binaire (rb), ses octets sont lus sans traitement pour obtenir des objets bytes
$ python3.6

>>> with open('a.txt') as f:
...     for line in f:
...         print(type(line))
... <class 'str'>

>>> with open('a.txt', mode='rb') as f:
>>>     for line in f:
...         print(type(line))
... <class 'bytes'>

L'utilisation de codecs.open est officieusement désapprouvée. À la place il est possible de passer en paramètre le système d'encodage (encoding) à la fonction open().

Si le paramètre encoding n'est pas précisé, CPython utilise le système d'encodage retourné par locale.getpreferredencoding(). Et cela peut conduire à des surprises. Par exemple, sa valeur peut être cp1252 sous Windows. Or une des caractéristiques de cp1252 est de na pas générer d'erreurs en décodant des données encodées en UTF-8. Le résultat est ce que les japonais ont nommé Mojibake.

Par conséquent il vaut mieux toujours être explicite avec le paramètre encoding.

Nick Coghlan a partagé des stratégies dans Processing Text Files in Python 3.

Un nouveau paramètre newline est aussi disponible pour open permettant de gérer avec finesse la conversion des fins de lignes.

Python 3 FTW ?

Avec sa séparation stricte du binaire et du texte, Python 3 rend beaucoup plus facile l'écriture du code d'une application "normale".

Mais il a tendance à rendre la tâche plus difficile pour du code qui fonctionne "aux extrémités du système", c'est à dire par exemple le code de librairies qui s'interfacent avec le protocole WSGI ou des protocoles réseaux comme HTTP etc. Particulièrement lorsqu'il faut supporter à la fois Python 2 et Python 3.

Armin Ronacher s'est largement étendu sur le sujet et parfois à juste titre, voir The Updated Guide to Unicode on Python et Everything you did not want to know about Unicode in Python 3.

Vous pouvez lire une réponse de Nick Coghlan dans Python 3 and ASCII Compatible Binary Protocols.

Dans un autre de ses articles intitulé The transition to multilingual programming with Python, Nick Coghlan décrit une série de challenges encore à venir pour Python 3.

Parmis eux il se trouve que certains systèmes POSIX n'utilisent pas UTF-8 par défaut mais reposent sur la locale C qui force tout à être en ASCII. En guise de solution aux problèmes que cela pose, il a été décidé qu'un mode UTF-8 va être ajouté à Python. Vous pouvez lire la PEP 540 et la PEP 538 pour ce qui semble être la meilleure solution possible actuellement.