logo le blog invivoo blanc

La migration de Python 2.X à Python 3.X

28 janvier 2019 | Python | 0 comments

Depuis 2008, deux versions de Python coexistaient avec, pour chacune d’entre elles, son lot de défenseurs… Guido Van Rossum avait souhaité, avec la version 3.X, corriger certaines syntaxes qui limitaient l’évolution du langage. Python 3 a été conçu comme un vrai langage fonctionnel. Malheureusement, pendant un temps, la version Python 2 étant plus performante, cela a ralenti l’adhésion de la communauté à cette nouvelle mouture.

Aujourd’hui, si on regarde les différents benchmarks, la version 3.X est globalement plus performante que la version 2.X. et la communauté a basculé petit à petit mais point assez vite. Aussi Guido a sifflé la fin de la récréation ! Le support de Python 2 prendra fin le 1er janvier 2020… Deux choix s’offrent aux entreprises et particuliers qui sont encore sous Python 2.X : une migration forcée ou rester avec une version obsolète qui n’aura plus de mises à jour, plus aucune nouvelle fonctionnalité et des coûts d’exploitation qui augmenteront au fil des années.

Quelques informations

Il était une fois…

En février 1991, la première version publique de Python (0.9) est mise à disposition de la communauté. En 2000, la première version Python 2.0 est disponible.

Puis en 2008, Python 3.0 et sa rupture de syntaxe est mise à la disposition du public en même temps qu’une nouvelle mise à jour de la branche 2.X (Python 2.6.1). Et depuis, les deux versions continuent de coexister.

Le changement de version majeure n’est pas très fréquent si on le compare à d’autres logiciels ou langages. La première raison est le manque de développeurs comme l’a si bien énoncé M. Victor Stinner lors de la dernière PyConFR qui s’est déroulée à Lille du 4 au 7 octobre 2018 : leur force de développement consiste en deux développeurs à temps plein pour gérer le coeur… C’est bien peu au regard d’autres suites logicielles. Après dix années de coexistence, Guido Van Rossum a décidé d’arrêter le support de la branche 2. Mais des fournisseurs de librairies comme Django (l’un des plus populaires parmi les frameworks web) avaient déjà annoncé ne plus être compatible avec les versions 2.X à partir de Django 2.0 (avril 2017) suivi par d’autres logiciels très prisés par la communauté (comme numpy).

Ce désengagement a incité de nombreuses sociétés à migrer leur code sans tarder. Par exemple, Instagram a annoncé en 2017 avoir migré la majorité de son code et Dropbox a annoncé en septembre dernier avoir achevé sa migration, commencée en 2015. En octobre 2017, il restait de l’ordre de 25 % des codes écrits en Python 2 si on en croit les différentes statistiques. Il reste donc encore beaucoup de travail de migration.

Print

On a parlé de l’évolution de la situation entre Python 2 et Python 3 mais nous n’avons pas abordé quelles étaient les différences entre les deux versions. La première de ces différences est liée à « print » :

  • Dans Python 2, c’est une commande :
$ python2
Python 2.7.14 (default, Oct 31 2017, 21:12:13)
[GCC 6.4.0] on cygwin
Type "help", "copyright", "credits" or "license" for more information.
>>> x = 5
>>> print "value=", x
value= 5
>>>
  • Dans Python 3, c’est une fonction :
$ python3
Python 3.6.4 (default, Jan  7 2018, 15:53:53)
[GCC 6.4.0] on cygwin
Type "help", "copyright", "credits" or "license" for more information.
>>> x = 5
>>> print( "value=", x )
value= 5
>>>

Les parenthèses deviennent obligatoires ce qui, pour certains, est une contrainte. Mais étant une fonction, on peut désormais l’utiliser dans les fonctions en le passant en paramètre. De plus, sa syntaxe étendue permet facilement de rediriger le flux vers un fichier plutôt que vers la console.

La division sur les entiers

Ce deuxième changement est de ceux qui pourraient nous créer le plus de problèmes. Plutôt que de faire un long discours, un petit exemple va vous montrer le risque :

$ python2
Python 2.7.14 (default, Oct 31 2017, 21:12:13)
[GCC 6.4.0] on cygwin
Type "help", "copyright", "credits" or "license" for more information.
>>> print( 2/5 )
0
>>>
$ python3
Python 3.6.4 (default, Jan  7 2018, 15:53:53)
[GCC 6.4.0] on cygwin
Type "help", "copyright", "credits" or "license" for more information.
>>> print( 2/5 )
0.4
>>>

En Python 2, la division de deux entiers nous retourne la division euclidienne de ces deux entiers. En Python 3, nous obtenons le résultat de la division flottante.

Les chaînes de caractères

En Python 2, toutes les chaînes sont par défaut en ASCII. Pour avoir des chaînes unicodes, il fallait faire un appel explicite : u”ceci est une chaine unicode”. En Python 3, toutes les chaînes sont unicodes.

L’appel à raise

La syntaxe a un peu évolué :

$ python2
Python 2.7.14 (default, Oct 31 2017, 21:12:13)
[GCC 6.4.0] on cygwin
Type "help", "copyright", "credits" or "license" for more information.
>>> raise SyntaxError, "une erreur"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
SyntaxError: une erreur
>>>
$ python3
Python 3.6.4 (default, Jan  7 2018, 15:53:53)
[GCC 6.4.0] on cygwin
Type "help", "copyright", "credits" or "license" for more information.
>>> raise SyntaxError( "une erreur" )
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
SyntaxError: une erreur
>>>

Ainsi, on se rapproche des syntaxes présentes dans des langages tels que C++ ou C#.

Les générateurs

Dans un souci d’améliorer les performances ainsi que la consommation mémoire, une des grandes modifications apportées est le changement de certains types de retour. En Python 2, beaucoup de fonctions retournent des listes. En Python 3, ces fonctions retournent des itérateurs.

$ python2
Python 2.7.14 (default, Oct 31 2017, 21:12:13)
[GCC 6.4.0] on cygwin
Type "help", "copyright", "credits" or "license" for more information.
>>> print range(5)
[0, 1, 2, 3, 4]
>>> {"a":5, "b":3}.keys()
['a', 'b']
>>>
$ python3
Python 3.6.4 (default, Jan  7 2018, 15:53:53)
[GCC 6.4.0] on cygwin
Type "help", "copyright", "credits" or "license" for more information.
>>> print( range(5) )
range(0, 5)
>>> {"a":5, "b":3}.keys()
dict_keys(['a', 'b'])
>>>

Les imports

Dans une moindre mesure, des changements sur la façon dont les imports sont effectués ont été apportés en Python 3.

Stratégie de tests

Si l’on sait lorsque l’on commence une migration, comment sait-on que l’on a fini, qu’il n’y a plus aucun problème caché ? Pour répondre à cette question je vais laisser la parole à mon collègue Christophe Godard (Manager de l’expertise « Méthodologies & Pratiques Agiles » au sein d’INVIVOO).

La réussite d’un projet de migration technique (montée de version du langage utilisé comme décrit dans l’article ci-contre, changement de version d’un framework …) passe nécessairement par la mise en place de tests. Pour des raisons de coût et de délai pour les exécuter, il est évident qu’une majeure partie de ces tests doivent être automatisés, d’autant plus qu’ils devront certainement être joués à plusieurs reprises durant la vie du projet. Toutefois, quels tests faut-il automatiser et quels tests n’est-il pas préconisé d’exécuter de manière automatique ? D’autre part, il existe une multitude de familles de tests :

  • unitaires qui ont pour portée une fonction ou un composant
  • d’intégration qui ont pour portée les interactions entre plusieurs composants
  • systèmes
  • fonctionnels
  • de performance
  • de sécurité

Et n’oublions pas le taux de couverture du code. Ces questions sont adressées par un seul artefact : la stratégie de test (un document d’assez haut niveau qui définit le cadre d’écriture et d’exécution des tests sur une ou plusieurs applications afin d’en assurer la qualité. Elle doit également définir le suivi et le pilotage des tests : reporting attendu, KPI…). Concrètement, une stratégie de test peut être un simple visuel montrant les activités de test sur une fonctionnalité tout au long de son cycle de réalisation. Elle peut aussi être un document plus complexe en fonction du contexte projet.  

Pour revenir à la question du choix des tests dans une stratégie de test, il y a un pattern à respecter : la pyramide des tests de Mike Cohn. Cette dernière consiste à préconiser d’avoir un grand nombre de tests unitaires (la base de la pyramide) car peu coûteux à développer et maintenir si l’application évolue, un peu moins de tests d’intégration et nettement moins de tests de bout en bout (le sommet de la pyramide) qui sont, eux, plus coûteux à développer et maintenir.

Mettre en place les tests

Il faut commencer par mettre en place des tests unitaires afin d’avoir des KPI fiables sur les briques élémentaires du code (fonctions, classes ou petits modules). Pensez à bien couvrir les fonctions utilisant des calculs sur les entiers (notamment la division).

Ensuite, il faut mettre en place les tests fonctionnels en commençant par les fonctionnalités les plus importantes : celles qui sont les plus utilisées. En effet, il est logique de dépenser son temps sur les fonctionnalités les plus souvent utilisées…

Il est essentiel d’avoir un retour sur les temps d’exécutions afin d’avoir des éléments de comparaison qui vous permettent de détecter d’éventuelles contre-performances. Ceci étant, il faut que vous ayez des tests de charge afin de certifier que face à une augmentation anormale de la volumétrie il n’y ait pas de comportements bloquants ou dégradés. C’est un problème que j’ai croisé :

Après une migration d’un progiciel nous avons déployé celui-ci, en test, chez certains de nos clients. S’en est suivi plusieurs mois de tests : le logiciel a été déployé chez tous nos clients et notamment celui qui s’en servait de la manière la plus intensive. Très rapidement ils nous ont remonté des erreurs dans la génération des rapports. Ils en créaient plus de 10 000. Après plusieurs semaines pour comprendre l’environnement de production du client et estimer sa volumétrie, nous avons pu reproduire le problème dans un environnement de développement. Malheureusement, il y avait un bug dans l’API fichier de Python qui ne les fermait pas correctement. Le bug était, certes, corrigé par un patch mais celui-ci n’avait pas été intégré dans notre logiciel.  

Les tests de charge sont donc nécessaires. Ne faites pas l’impasse dessus pour des raisons de budget ou de manque de temps car perdre la confiance de vos clients vous coûtera bien plus cher !

Les outils

De nombreux outils vous seront nécessaires pour :

  • gérer le workflow exécuter les tâches comme buildbot, Jenkins, etc.
  • créer et exécuter les tests unitaires tels que unittest ou pytest.
  • créer et mettre en œuvre les tests fonctionnels
  • automatiser les tests GUI (comme UFT, anciennement QTP)
  • mesurer le taux de couverture des tests (le nombre de lignes de codes exécutées par les tests).

Le choix des outils dépend avant tout de vos contextes applicatifs et/ou humains. Si votre projet n’est pas outillé ou mal outillé, faîtes vous aider par un spécialiste de l’intégration continue. Vous perdrez un peu de temps au début mais vous en gagnerez beaucoup lorsque vous passerez en vitesse de croisière.

Le Périmètre

Le périmètre est une notion importante. Il décrit l’ensemble des codes, des modules internes, des librairies externes et des outils que vous utiliser lorsque vous travailler avec votre logiciel. Mais celui-ci inclut aussi le hardware des ordinateurs, les systèmes d’exploitation que vous supportez, la version de la base de données que vous utilisez, etc. Pour reprendre un exemple vécu :

Lors de la migration du progiciel sur lequel je travaillais nous avions pour cible Windows XP, Windows 7 ainsi que plusieurs versions de Windows Server (de 2002 à 2008 avec différents niveaux de service pack) et pour la base de données nous avions prévu Oracle 9 et Oracle 10. Nous en étions à la phase de tests chez nos clients lorsque nous avons découvert qu’ils avaient migrés. En effet, certains étaient passés à Windows Server 2012 et à Oracle 11.2G. Ceci entrainant des mises à jour de notre côté.

Malheureusement un changement en entraine souvent un autre ! Et faire une mise à jour majeure d’un logiciel peut entrainer, chez un client, l’envie d’effectuer un changement d’architecture hardware pour décommissionner des serveurs en fin de vie ou des bases de données qui n’ont plus de support : une opportunité en déclenchant une autre.

Le périmètre “externe”

Par périmètre externe, je souhaite parler des éléments externes à votre projet sur lequel vous n’avez pas forcément le pouvoir de décision : le hardware, l’OS, la base de données ou certains outils externes (comme CrystalReport) qu’un client vous imposerait. Ne perdez pas de vue que le choix d’un environnement de développement fait aussi partie du périmètre externe. Du fait de sa nature, il ajoute des librairies à votre OS. Utiliser Visual Studio 2017 nécessitera de déployer chez le client un Runtime spécifique (Etes-vous sûr que celui-ci voudra déployer ce runtime ? Certains de vos choix techniques pourraient être invalidé par certaines politiques de sécurité ou d’harmonisation des technologies utilisées). Et par environnement de développement il faut aussi inclure les outils d’intégration continu.

On a la fausse impression que lorsque l’on change de version on reste compatible pourtant il y a un tas de petites modifications dont nous n’avons pas conscience qui auront un impact sur notre projet en bien ou en mal. Réussir une migration consiste à prévoir les risques et à les gérer.

Avant de démarrer la migration, il est bon de construire une carte de ce périmètre externe avec les versions supportées aujourd’hui et celles que l’on souhaite supporter après la migration. Faire cette carte peut nécessiter de discuter avec les architectes des clients afin de savoir quels choix ceux-ci ont faits et lesquels d’entre eux auront un impact sur votre migration.

Le périmètre “interne”

Pour déterminer le périmètre interne il est intéressant de partir du code et des tests. Rechercher les ‘imports’ dans le code Python est un bon point de départ mais ce n’est pas forcément suffisant. Python étant un langage dynamique, on peut récupérer un script en base de données puis l’exécuter et ce script aura des dépendances vers d’autres modules.

En exécutant les tests, on va pouvoir déterminer nos dépendances grâce à une fonctionnalité ajouté à Python 2.3 : modulefinder.ModuleFinder. Nous allons reprendre l’exemple utilisé dans la documentation officielle de Python.

La seule limite de cette stratégie sera votre taux de couverture de code. A partir de là vous aller pouvoir créer une carte de vos dépendances en termes de modules Python. Et si vous avez une bonne granularité de vos tests vous pourrez mettre en évidence les dépendances entre les modules ou quelles sont les dépendances d’une fonctionnalité (pour s’exécuter de quels modules aura-t-elle besoin).

Comprendre et enrichir sa carte

La première étape consiste à analyser la carte des dépendances. Pour chaque module, il va falloir savoir si :

  • Il a été codé en interne ou non ?
    • en Python ?
    • dans un autre langage ?
  • C’est un module standard ?
    • a-t-il été déclaré obsolète et remplacé par une nouvelle API ?
    • il a de nouvelles fonctionnalités qui pourraient invalider une partie de notre code ?
    • y a-t-il des contraintes de compatibilité (OS, par exemple) ?
  • C’est une librairie supportée en Python 3 ?
    • elle a changé de nom ?
    • ses API ont évolué invalidant de facto une partie de votre code ?
    • il a des contraintes liées à d’autres modules en termes de compatibilité ou à des éléments tels que l’OS ?
    • il a un changement de licences ou passer de nouveaux accords ?
  • La librairie n’est pas portée (pendant longtemps cela a été le cas de wxPython par exemple) :
    • Avez-vous le code source ? Êtes-vous prêt à maintenir et faire évoluer ce code ?
    • Avez-vous la possibilité de passer à une autre bibliothèque qui fournirait les mêmes services ?

Répondre à toutes ces questions vous permettra de dessiner le contour de votre cible technique et vous obligera à effectuer un travail de recherche pour trouver des équivalents.

Découper

La clé du succès : diviser pour mieux régner !!! Vouloir adresser tous les problèmes en même temps en migrant tout en une passe est illusoire et risqué. En effet, il est préférable de se donner des jalons en commençant par migrer des briques indépendantes puis de migrer de nouvelles parties dépendantes des 1ères briques et ainsi de suite.

Il faudra donc : découper ces blocs que vous aller migrer, définir les dépendances entre les blocs qui vous permettrait, si vous avez les ressources disponibles de les migrer séparément mais en même temps. La planification de votre migration (diagramme de GANTT) commence à prendre forme. Certains risques sont déterminés et des actions peuvent être entreprises pour les réduire.

Méthodologie

Une fois les blocs choisis, il va falloir commencer à les migrer et, là aussi, il est nécessaire de découper la tâche en plusieurs étapes. Et, dans leur grande sagesse, les développeurs de Python ont mis en place des outils qui vont faciliter le travail !

_future_ ?

Nous allons commencer par intégrer, dans le code Python 2 certaines des nouvelles fonctionnalités de Python 3 grâce aux packages __future__. Ces ajouts seront faits l’un après l’autre, et à chaque fois lancez tous les tests relatifs à la brique logicielle.

  • from_future_import division : vous importerez ainsi la nouvelle version de la division entière. Et ainsi, vous pourrez corriger le code.
  • from_future_import print_function ‘print’ devient une fonction, rajouter les parenthèses.
  • from _future_ import absolute_import : utilisation des mêmes règles de gestion des imports qu’en Python 3.
  • from _future_ import unicode_literals : ajout des modifications liées à la gestion de l’Unicode.

Le grand saut

Une fois que les modifications ont été apportées, on est prêt à faire le grand saut. Grâce à 2to3.py nous allons pouvoir convertir nos fichiers sources pour être compatible avec la syntaxe de Python 3.

Mais tout d’abord, n’oubliez pas de migrer les fichiers de tests. Car si vous n’avez pas de tests unitaires qui tournent en Python 3 comment pourriez-vous valider le travail accompli ? Les premiers tests sont susceptibles de remonter des erreurs de compilation :

  • modules non-trouvés : les fameux modules qui ont changé de noms ou qui ne sont pas portés
  • méthodes ou des objets inconnus : les fameux changements d’API
  • options invalides dans des appels de fonctions : des changements d’API

Si vous avez correctement fait votre travail au cours de l’analyse du périmètre, vous connaissez déjà la liste des actions pour passer ce palier. Pour les modules non-portés, il y a un outil qui peut vous débloquer : 2to6. Il vous permettra d’importer en Python 3 des modules écrits pour Python 2. Mais cette solution doit rester temporaire pour vous permettre d’aller plus loin dans la migration.

Puis arrivent les premières exécutions avec leurs lots de problèmes. L’un des problèmes que vous rencontrerez est lié au fait que là où vous aviez des listes retournées par certaines API, vous avez désormais des itérateurs : convertir l’itérateur en liste via la fonction ‘list(it)’ est certes rapide mais pas forcément efficace dans votre contexte.

Et à ce niveau il n’y a pas de solution miracle : cela dépend uniquement de votre code et de ses dépendances. Il faut prendre les problèmes les uns après les autres, les analyser et leur trouver des réponses.

Refactoring

Vous venez de finir la migration, votre code tourne sous Python 3. Cependant, pour parachever votre migration il va falloir remplacer certaines façons de faire (liées à Python 2) par celles qui sont conseillés en Python 3. Vous tirerez ainsi parti des nouvelles fonctionnalités, des meilleures performances ainsi que d’un code plus compact. Vous trouverez ci-dessous une liste non-exhaustive du refactoring qui vous permettra de rendre votre code plus lisible ou plus performant. Là aussi, ne faites pas toutes les modifications en une fois. Faites-les, une par une et brique par brique (les mêmes que celles que vous aviez isolées grâce à votre carte de dépendances).

listdir VS scandir

En Python 2, vous aviez deux manières de parcourir une liste de fichiers/répertoires via le module ‘os’ : os.walk et os.listdir.

from os      import listdir
from os.path import splitext, isdir
PATH = "C:\\Tools\\Anaconda3"

def nb_file_listdir( path, ext ):
    nb = 0
    for name in listdir( path ):
        fname = F"{path}\\{name}"
        if isdir( fname ):
            nb += nb_file_listdir( fname, ext )
        else:
            r, e = splitext( name )
            if e.lower() == ext:
                nb += 1
    return nb
print( "nb files=", nb_file_listdir( PATH, ".py" ) )

En Python 3, une nouvelle API est apparu : os.scandir. Celle-ci est bien plus performante que os.listdir.

from os      import scandir
from os.path import splitext

PATH = "C:\\Tools\\Anaconda3"

def nb_file_listdir( path, ext ):
    nb = 0
    for entry in scandir( path ):
        if entry.is_dir():
            nb += nb_file_listdir( entry.path, ext )
        else:
            r, e = splitext( entry.name )
            if e.lower() == ext:
                nb += 1
    return nb

print( "nb files=", nb_file_listdir( PATH, ".py" ) )

Cette nouvelle version est cinq fois plus rapide sur l’exemple choisi.

Fstring VS string.format

En Python 2.X, pour formater une chaîne de caractères nous pouvons utiliser la fonction suivante :

nom = "Philippe"
age  = 46
text = "{nom} a {age} ans".format( nom=nom, age=age )
print( text )

L’introduction des Fstring en Python 3.6 permet de simplifier le code (de le rendre plus lisible) tout en étant plus efficace :

nom = "Philippe"
age  = 46
text = F"{nom} a {age} ans"
print( text ) 

Générateurs et itérateurs

Les API qui retournaient des listes en Python 2 retournent majoritairement des itérateurs en Python 3. Cela amènera probablement à repenser certains algorithmes. Mais il est également possible de remplacer du code plus conséquent. Par exemple :

def frange( a, b, n ):
    h = ( b - a ) / n
    for i in range( n+1 ):
        yield a + i * h

for x in frange( 0., 1., 10 ):
    print( x )

que l’on peut remplacer par :

a = 0.
b = 1.
n = 10
h = ( b - a ) / n
for x in ( a + i * h for i in range( n+1 ) ):
    print( x )

With

Une exécution contextuelle dans un bloc ‘with’ permet de simplifier le code tout en évitant les erreurs liées à un oubli (notamment avec les verrous dans la programmation multi-threadée).

Conclusion

Les migrations sont comme des enfants, il n’y en a pas 2 pareilles : chacune aura ses spécificités ou ses difficultés. Mais ne baissez pas les bras devant la difficulté, allez-y calmement, méthodiquement par étapes. Et à chaque étape : TESTEZ ! N’hésitez pas à demander conseil à des personnes expérimentés ou qui ont du recul par rapport à votre tâche afin de vous donner un regard neuf, des idées différentes de celles que vous avez déjà eues ou des recettes éprouvées.