Port d’un gros projet vers Python3 - Retour d’expérience #
Introduction : le projet en question #
Il s’agit d’une collection d’outils en ligne de commande que j’ai développé dans mon ancienne boîte, les points importants étant
- la taille du projet: un peu moins de 30,000 lignes de code, et
- l’ancienneté du code: près de 6 ans, qui initialement a tourné avec Python 2.6 (eh oui)
Le challenge #
On veut garder la rétro-compat vers Python2.7, histoire que la transition se fasse en douceur.
On veut aussi pouvoir continuer les développements en Python2 sans attendre la fin du port.
À faire avant de commencer à porter #
Visez la bonne version de Python #
Déjà, si vous supportez à la fois Python2 et Python3, vous pouvez (et devez) ignorer les versions de Python comprises entre 3.0 et 3.2 inclus.
Les utilisateurs de distros “archaïques” (genre Ubuntu 12.04) avec un vieux Python3 pourront tout à fait continuer à utiliser la version Python2.
Ayez une bonne couverture de tests #
Ne vous lancez pas dans le port sans une bonne couverture de tests.
Les changements entre Python2 et Python3 sont parfois très subtils, donc sans une bonne couverture de tests vous risquez d’introduire pas mal de régressions.
Dans mon cas, j’avais une couverture de 80%, et la plupart des problèmes ont été trouvés par les (nombreux) tests automatiques (un peu plus de 900)
Le port proprement dit #
Marche à suivre #
Voici les étapes que j’ai suivies. Il faut savoir qu’à la base je comptais passer directement en Python3, sans être compatible Python2, mais en cours de route je me suis aperçu que ça ne coûtait pas très cher de rendre le code “polyglotte” (c’est comme ça qu’on dit) une fois le gros du travail pour Python3 effectué.
- Lancez
2to3
et faites un commit avec le patch généré - Lancez les tests en Python3 jusqu’à ce qu’ils passent tous
- Relancez tous les tests en Python2, en utilisant
six
pour rendre le code polyglotte. - Assurez vous que tous les tests continuent à passer en Python3, commitez et poussez.
Note 1 : je ne connaissais pas python-futureà
l’époque. Il contient un outil appelé futurize
qui transforme directement
du code Python2 en code polyglotte. Si vous avez des retours à faire sur cet
outil, partagez !
Note 2 : Vous n’êtes bien sûr pas obligés d’utiliser six
si vous n’avez pas
envie. Vous pouvez vous en sortir avec des if sys.version_info()[1] < 3
, et autres
from __future__ import
(voir plus bas). Mais certaines fonctionnalités de six
sont compliquées à ré-implémenter à la main.
Note 3 : il existe aussi pies
comme alternative à six
. Voir
ici
pour une liste des différences avec six
. Personnellement, je trouve
pies
un peu trop “magique” et je préfère rester explicite. De plus,
six
semble être devenu le “standard” pour le code Python polyglotte.
Voyons maintenant quelques exemples de modifications à effectuer.
print #
C’est le changement qui a fait le plus de bruit. Il est très facile
de faire du code polyglotte quand on utilise print
. Il suffit de faire le bon import au début du fichier.
from__future__importprintprint("bar:", bar)
Notes:
L’import de
__future__
doit être fait en premierIl faut le faire sur tous les fichiers qui utilisent
print
Il est nécessaire pour avoir le même comportement en Python2 et Pyton3. En effet, sans la ligne d’import,
print("bar:", "bar")
en Python2 est lu comme “afficher le tuple("foo", bar)
”, ce qui n’est probablement pas le comportement attendu.
bytes, str, unicode #
Ça c’est le gros morceau.
Il n’y a pas de solution miracle, partout où vous avez des chaînes de
caractères, il va falloir savoir si vous voulez une chaîne de caractères
“encodée” (str
en Python2, bytes
en Python3) ou “décodée” (unicode
en
Python2, str
en Python3)
Deux articles de Sam qui abordent très bien la question:
Allez les (re)-lire si c’est pas déjà fait.
En résumé :
- Utilisez UTF-8
- Décodez toutes les entrées
- Encodez toutes les sorties
J’ai vu conseiller de faire from __future__ import unicode_literals
:
# avec from __future__ import unicode_literals
a = "foo">>>type(a)
<type'unicode'># sans
a = "foo">>>type(a)
<type'str'>
Personnellement je m’en suis sorti sans. À vous de voir.
Les imports relatifs et absolus #
Personnellement, j’ai tendance à n’utiliser que des imports absolus.
Faisons l’hypothèse que vous avez installé un module externe bar
,
dans votre système (ou dans votre virtualenv) et que vous avez déjà un fichier
bar.py
dans vos sources.
Les imports absolus ne changent pas l’ordre de résolution quand
vous n’êtes pas dans un paquet. Si vous avez un fichier foo.py
et un
fichier bar.py
côte à côte, Python trouvera bar
dans le répertoire courant.
En revanche, si vous avec une arborescence comme suit :
src
foo
__init__.py
bar.py
Avec
# in foo/__init__.pyimportbar
En Python2, c’est foo/bar.py
qui sera utilisé, et non lib/site-packages/bar.py
. En Python3 ce sera l’inverse,
le fichier bar.py
, relatifà foo/__init__
aura la priorité.
Pour vous éviter ce genre de problèmes, utilisez donc :
from__future__import absolute_import
Ou bien rendez votre code plus explicite en utilisant un point :
from.import bar
Vous pouvez aussi:
- Changer le nom de votre module pour éviter les conflits.
- Utiliser systématiquement
import foo.bar
(C’est ma solution préférée)
La division #
Même principe que pour print
. Vous pouvez faire
from__future__import division
et /
fera toujours une division flottante, même utilisé avec des entiers.
Pour retrouver la division entière, utilisez //
.
Example:
>>>5/2>>>2.5>>>3
Note: celui-ci est assez vicieux à détecter …
Les changements de noms #
De manière générale, le module six.moves
contient tout ce qu’il faut
pour résoudre les problèmes de changement de noms.
Allez voir la table des cas traités par six
ici
six
est notamment indispensable pour supporter les métaclasses, dont
la syntaxe a complètement changé entre Python2 et Python3. (Ne vous amusez
pas à recoder ça vous-mêmes, c’est velu)
Avec six
, vous pouvez écrire
@six.add_metaclass(Meta)
classFoo:
pass
range et xrange #
En Python2, range()
est “gourmand” et retourne la liste entière dès qu’on
l’appelle, alors qu’en Python3, range()
est “feignant” et retourne un
itérateur produisant les éléments sur demande. En Python2, si vous
voulez un itérateur, il faut utiliser xrange()
.
Partant de là, vous avez deux solutions:
Utiliser
range
tout le temps, même quand vous utilisiezxrange
en Python2. Bien sûr il y aura un coût en performance, mais à vous de voir s’il est négligeable ou non.Ou bien utiliser
six.moves.range()
qui vous retourne un itérateur dans tous les cas.
importsix
my_iterator = six.moves.range(0, 3)
Les “vues” des dictionnaires #
On va prendre un exemple:
my_dict = { "a" : 1 }
keys = my_dict.keys()
Quand vous lancez 2to3
, ce code est remplacé par:
my_dict = { "a" : 1 }
keys = list(my_dict.keys())
C’est très laid :/
En fait en Python3, keys()
retourne une “vue”, ce qui est différent de
la liste que vous avez en Python2, mais qui est aussi différent de l’itérateur
que vous obtenez avec iterkeys()
en Python2. En vrai ce sont des
view objects.
La plupart du temps, cependant, vous voulez juste itérer sur les clés
et donc je recommande d’utiliser 2to3
avec --nofix=dict
.
Bien sûr, keys()
est plus lent en Python2, mais comme pour
range
vous pouvez ignorer ce détail la plupart du temps.
Faites attention cependant, le code plantera si vous faites :
my_dict = { "a" : 1 }
keys = my_dict.keys()
keys.sort()
La raison est que les vues n’ont pas de méthode sort
. À la place, utilisez :
my_dict = { "a" : 1 }
keys = sorted(my_dict.keys())
Enfin, il existe un cas pathologique : celui où le dictionnaire change pendant que vous itérez sur les clés, par exemple:
for key in my_dict.keys():
if something(key):
del my_dict[key]
Là, pas le choix, il faut faire :
for key inlist(my_dict.keys()):
if something(key):
del my_dict[key]
Ou
for key inlist(six.iterkeys(my_dict)):
if something(key):
del my_dict[key]
si vous préférez.
Les exceptions #
En Python2, vous pouvez toujours écrire:
raise MyException, message
try:
# ....except MyException, e:
# ...# Do something with e.message
C’est une très vielle syntaxe.
Le code peut être réécrit comme suit, et sera polyglotte :
raise MyException(message)
try:
# ....except MyException as e:
# ....# Do something with e.args
Notez l’utilisation de e.args
(une liste), et non
e.message
. L’attribut message
(une string) n’existe que dans
Python2. Vous pouvez utiliser .args[0]
pour récupérer le message d’une
façon polyglotte.
Comparer des pommes et des bananes #
En Python2
, tout est ordonné:
>>>printsorted(["1", 0, None])
[None, 0, "1"]
L’interpréteur n’a aucun souci avec le fait que vous tentiez d’ordonner une string et un nombre.
En Python3, ça crashe:
TypeError: '<' not supported between instances of 'int' and 'str'
Pensez y si vous avez des tris sur des classes à vous. La technique recommandée
c’est d’utiliser @functools.total_ordering
et de définir __lt__
:
@functools.total_ordering
classMaClassPerso():
...
def __lt__(self, other):
return self.quelque_chose < other.quelque_chose
Le ficher setup.py #
Assurez vous de spécifier les versions de Python supportées dans votre
setup.py
Par exemple, si vous supportez à la fois Python2 et Python3, utilisez :
fromsetuptoolsimport setup, find_packages
setup(name="foo",
# ...
classifiers = [
# ..."Programming Language :: Python :: 2",
"Programming Language :: Python :: 3",
],
# ...
)
Et ajoutez un setup.cfg
comme suit :
[bdist_wheel]
universal = 1
pour générer une wheel
compatible Python2 et Python3.
Deux trois mots sur l’intégration continue #
Comme mentionné plus haut, le développement du projet a dû continuer sans attendre que le support de Python3 soit mergé.
Le port a donc dû se faire dans une autre branche (que j’ai appelé six
)
Du coup, comment faire pour que la branche ‘six’ reste à jour ?
La solution passe par l’intégration continue. Dans mon cas j’utilise jenkins
À chaque commit sur la branche de développement, voici ce qu’il se passe:
- La branche ‘six’ est rebasée
- Les tests sont lancés avec Python2 puis Python3
- La branche est poussée (avec
--force
).
Si l’une des étapes ne fonctionne pas (par exemple, le rebase ne passe pas à cause de conflits, ou bien l’une des suites de test échoue), l’équipe est prévenue par mail.
Ainsi la branche six
continue d’être “vivante” et il est trivial et
sans risque de la fusionner dans la branche de développement au moment
opportun.
Conclusion #
J’aimerais remercier Eric S. Raymond qui m’a donné l’idée de ce billet suite à un article sur son blog et m’a autorisé à contribuer à son HOWTO, suite à ma réponse
N’hésitez pas en commentaire à partager votre propre expérience (surtout si vous avez procédé différemment) ou vos questions, j’essaierai d’y répondre.
Il vous reste jusqu’à la fin de l’année avant l’arrêt du support de Python2 en 2020, et ce coup-là il n’y aura probablement pas de report. Au boulot !