Faire un code compatible python 2 et 3

Je suis un développeur python et j’écris principalement du code compatible python 2.7 et parfois 2.6. Seulement, python 3 est sorti depuis longtemps, avec des fonctionnalités bien sûr, mais surtout chaque nouvelle release montre une volonté de rendre la migration plus simple pour les développeurs, et il est maintenant tout à fait possible d’écrire du code qui soit compatible python 2 et 3. Même si votre cible en production reste python 2, python 3 est bien l’avenir de python donc il est temps d’apprendre à faire du code compatible sur les deux releases majeures de votre langage préféré.

Au boulot, j’écris des drivers d’automate d’analyses médicales, ces machines sont souvent à la pointe de la technologie sauf sur la partie informatique où on trouve encore des ports série avec des protocoles de communication complètement farfelus. Le code fait 25k lignes d’après sloccount, il y a un coverage par les tests qui est supérieur à 80%. Mon objectif était de faire passer les tests sur python 3.

Avant de se lancer, il faut s’armer des meilleurs outils:

  • pyenv, permet d’installer facilement plusieurs versions d’interpréteurs python.
  • tox, qui va créer les virtualenv et lancer les tests.
  • six, un module de compatibilité python 2 et 3 (deux fois trois = six)

Premièrement on installe les interpréteurs avec pyenv, par exemple:

pyenv install -v 2.7.6
pyenv install -v 3.3.5
pyenv install -v 3.4.1

Ensuite on configure tox dans le fichier tox.ini:

[tox]
envlist = py27,py33,py34
[testenv]
deps=-rtest-requirements.txt
commands=nosetests

Et on lance les tests, le but du jeu va être de lancer les tests et de les corriger un par un.

Unicode

Une fonctionnalité majeure dans python 3 est la nouvelle gestion de l’unicode. Il y a maintenant une différence stricte entre le type bytes, qui est plus ou moins l’équivalent du type str en python 2, et le type str qui lui est l’équivalent du type unicode en python 2. En python 3 on ne peut plus combiner ces types sans explicitement décoder les bytes et encoder les chaines unicode.

python2.7 -c 'print(u"foo" + "bar")'
# et l'équivalent en python3:
python3.3 -c 'print("foo" + b"bar")'
Traceback (most recent call last):
	File "<string>", line 1, in <module>
TypeError: Can't convert 'bytes' object to str implicitly

Mon code était truffé de mélanges entre ces deux types, tout simplement parce qu’en python2 il y a une conversion implicite (Ce qui peut produire les déroutantes exceptions UnicodeEncodeError et UnicodeDecodeError si on ne fait pas attention).

La première chose à faire est de préfixer tous les bytes par un b, ce qui n’a aucun effet en python 2 mais qui est nécessaire en python 3.

# <stx>foo<etx>
b"\x02foo\x03"

Ensuite pour les chaines qui devraient être de l’unicode il y a deux solutions:

  • Utiliser unicode_litterals du module future qui va transformer en python2 toutes les chaines non préfixées par u en unicode.
  • Utiliser le préfixe u qui revient dans python 3.3 avec la pep414
  • Utiliser six.u() du module six.

Il y a du pour et du contre dans tous les cas, personnellement j’ai opté pour unicode_litterals parce que ça fait un code moins lourd et plus proche de python 3. Tout dépend du code à migrer et de votre capacité à le modifier, il y a un bon article qui détaille les arguments.

Les bytes

Les bytes en python 3 ne se comportent pas exactement comme les bytes en python 2.

# python 2
>>> b"\x06"[0]
"\x06"
>>> b"\x06"[0] == "\x06"
True
>>> b"\x06"[:1] == "\x06"
True

# python 3
>>> b"\x06"[0]
6
>>> b"\x06"[0] == b"\x06"
False
>>> b"\x06"[:1] == b"\x06"
True

Pour faire du code compatible, j’ai utilisé des slices ou la fonction six.byte2int().

De la même manière j’avais des implémentations de checksum qui nécessitaient d’itérer sur la valeur numérique des bytes que j’ai modifié en utilisant six.iterbytes():

# python 2
checksum = (sum(ord(c) for c in data) & 0xFF) % 0x100

# python 3
checksum = (sum(c for c in data) & 0xFF) % 0x100

# python 2 et 3
checksum = (sum(c for c in six.iterbytes(data)) & 0xFF) % 0x100

Les bytes de python 3 ne supportent pas les formats, ni avec % ni avec la fonction format(). Avec la pep461 elles vont revenir dans python 3.5, mais en attendant on peut faire des concaténations et passer par des chaines unicode pour le formatage.

STX = b"\x02"
ETX = b"\x03"
# code python2
data = b"%s%s%02X%s" % (STX, payload, checksum, ETX)

# code python 2 et 3
data = STX + payload + ("%02X" % checksum).encode("ascii") + ETX

Metaclass

Rien de bien compliqué ici:

# python 2
class Foo(object):
	__metaclass__ = MetaFoo
	pass

# python 3
class Foo(metaclass=MetaFoo):
	pass

# python 2 et 3
@six.add_metaclass(MetaFoo)
class Foo(object):
	pass

Le reste

Les méthodes items(), keys() et values() des dictionnaires en python 3 sont les mêmes que iteritems(), iterkeys() et itervalues() en python 2.

# python 2
d.iteritems()

# python 3
d.items()

# python 2 et 3
six.iteritems(d)

Et de la même manière:

# python 2
d.items()
# python 2 et 3
list(d.items())

Pour le reste, je vous conseille la lecture de la documentation du module six. Moi je m’en suis beaucoup servi pour six.string_types à la place de basestring et les imports (Queue, StringIO, …)

Conclusion

Maintenant le code est plus beau, il fonctionne sur python 3, de nombreux potentiels bugs ont été corrigés grâce à python 3 qui est plus strict sur les types, et tout ça ne m’a pris que quelques heures.