Portage de code Python 2 vers Python 3

auteur

Brett Cannon

Résumé

Python 3 étant le futur de Python tandis que Python 2 est encore activement utilisé, il est préférable de faire en sorte que votre projet soit disponible pour les deux versions majeures de Python. Ce guide est destiné à vous aider à comprendre comment gérer simultanément Python 2 & 3.

Si vous cherchez à porter un module d'extension plutôt que du pur Python, veuillez consulter Portage des modules d'extension vers Python 3.

Si vous souhaitez lire l'avis d'un développeur principal de Python sur ce qui a motivé la création de Python 3, vous pouvez lire le Python 3 Q & A de Nick Coghlan ou bien Why Python 3 exists de Brett Cannon.

Vous pouvez lire les archives de la liste diffusion python-porting dans vos recherches sur les questions liées au portage.

La version courte

Afin de rendre votre projet compatible Python 2/3 avec le même code source, les étapes de base sont :

  1. Ne se préoccuper que du support de Python 2.7

  2. S'assurer d'une bonne couverture des tests (coverage.py peut aider ; python -m pip install coverage)

  3. Apprendre les différences entre Python 2 et 3

  4. Utiliser Futurize (ou Modernize) pour mettre à jour votre code (par exemple python -m pip install future)

  5. Utilisez Pylint pour vous assurer que vous ne régressez pas sur votre prise en charge de Python 3 (python -m pip install pylint)

  6. Utiliser caniusepython3 pour déterminer quelles sont, parmi les dépendances que vous utilisez, celles qui bloquent votre utilisation de Python 3 (python -m pip install caniusepython3)

  7. Une fois que vos dépendances ne sont plus un obstacle, utiliser l'intégration continue pour s'assurer que votre code demeure compatible Python 2 & 3 (tox peut aider à tester la comptabilité de sources avec plusieurs versions de Python; python -m pip install tox)

  8. Envisager l'utilisation d'un vérifieur de type statique afin de vous assurer que votre façon d'utiliser les types est compatible avec Python 2 et 3 (par exemple en utilisant mypy pour vérifier votre typage sous Python 2 et 3 ; python -m pip install mypy).

Note

Note : L'utilisation de python -m pip install garantit que le pip invoqué est bien celui installé avec la version de Python que vous utilisez, que ce soit un pip du système ou un pip installé dans un environnement virtuel.

Détails

Un point clé du support simultané de Python 2 et 3 est qu'il vous est possible de commencer dès aujourd'hui ! Même si vos dépendances ne sont pas encore compatibles Python 3, vous pouvez moderniser votre code dès maintenant pour gérer Python 3. La plupart des modifications nécessaires à la compatibilité Python 3 donnent un code plus propre utilisant une syntaxe plus récente, même dans du code Python 2.

Un autre point important est que la modernisation de votre code Python 2 pour le rendre compatible Python 3 est pratiquement automatique. Bien qu'il soit possible d'avoir à effectuer des changements d'API compte-tenu de la clarification de la gestion des données textuelles et binaires dans Python 3, le travail de bas niveau est en grande partie fait pour vous et vous pouvez ainsi bénéficiez de ces modifications automatiques immédiatement.

Gardez ces points clés en tête pendant que vous lisez les détails ci-dessous concernant le portage de votre code vers une compatibilité simultanée Python 2 et 3.

Abandon de la compatibilité Python 2.6 et antérieures

Bien qu'il soit possible de rendre Python 2.5 compatible avec Python 3, il est beaucoup plus simple de n'avoir qu'à travailler avec Python 2.7. Si abandonner Python 2.5 n'est pas une option, alors le projet six peut vous aider à gérer simultanément Python 2.5 et 3 (python -m pip install six). Néanmoins, soyez conscient que la quasi-totalité des projets listés dans ce guide pratique ne seront pas applicables à votre situation.

Si vous pouvez ignorer Python 2.5 et antérieur, les changements nécessaires à appliquer à votre code devraient encore ressembler à vos yeux à du code Python idiomatique. Dans le pire cas, vous devrez utiliser une fonction plutôt qu'une méthode dans certains cas, ou bien vous devrez importer une fonction plutôt qu'utiliser une fonction native, mais le reste du temps le code transformé devrait vous rester familier.

Mais nous vous conseillons de viser seulement un support de Python 2.7. Python 2.6 n'est plus supporté gratuitement et par conséquent ne reçoit plus aucun correctif. Cela signifie que vous devrez trouver des solutions de contournement aux problèmes que vous rencontrez avec Python 2.6. Il existe en outre des outils mentionnés dans ce guide pratique qui ne supportent pas Python 2.6 (par exemple Pylint), ce qui sera de plus en plus courant au fil du temps. Il est simplement plus facile pour vous de n'assurer une compatibilité qu'avec les versions de Python que vous avez l'obligation de gérer.

Assurez vous de spécifier la bonne version supportée dans le fichier setup.py

Votre fichier setup.py devrait contenir le bon trove classifier spécifiant les versions de Python avec lesquelles vous êtes compatible. Comme votre projet ne supporte pas encore Python 3, vous devriez au moins spécifier Programming Language :: Python :: 2 :: Only. Dans l'idéal vous devriez indiquer chaque version majeure/mineure de Python que vous gérez, par exemple Programming Language :: Python :: 2.7.

Obtenir une bonne couverture de code

Une fois que votre code est compatible avec la plus ancienne version de Python 2 que vous souhaitez, vous devez vous assurer que votre suite de test a une couverture suffisante. Une bonne règle empirique consiste à avoir suffisamment confiance en la suite de test pour qu'une erreur apparaissant après la réécriture du code par les outils automatiques résulte de bogues de ces derniers et non de votre code. Si vous souhaitez une valeur cible, essayez de dépasser les 80 % de couverture (et ne vous sentez pas coupable si vous trouvez difficile de faire mieux que 90 % de couverture). Si vous ne disposez pas encore d'un outil pour mesurer la couverture de code, coverage.py est recommandé.

Apprendre les différences entre Python 2 et 3

Une fois que votre code est bien testé, vous êtes prêt à démarrer votre portage vers Python 3 ! Mais afin de comprendre comment votre code va changer et à quoi s'intéresser spécifiquement pendant que vous codez, vous aurez sûrement envie de découvrir quels sont les changements introduits par Python 3 par rapport à Python 2. Pour atteindre cet objectif, les deux meilleurs moyens sont de lire le document Nouveautés de Python de chaque version de Python 3 et le livre Porting to Python 3 (gratuit en ligne, en anglais). Il y a également une « antisèche » (cheat sheet, ressource en anglais) très pratique du projet Python-Future.

Mettre à jour votre code

Une fois que vous pensez en savoir suffisamment sur les différences entre Python 3 et Python 2, il est temps de mettre à jour votre code ! Vous avez le choix entre deux outils pour porter votre code automatiquement : Futurize et Modernize. Le choix de l'outil dépend de la dose de Python 3 que vous souhaitez introduire dans votre code. Futurize s'efforce d'introduire les idiomes et pratiques de Python 3 dans Python 2, par exemple en réintroduisant le type bytes de Python 3 de telle sorte que la sémantique soit identique entre les deux versions majeures de Python. En revanche, Modernize est plus conservateur et vise un sous-ensemble d'instructions Python 2/3, en s'appuyant directement sur six pour la compatibilité. Python 3 étant le futur de Python, il pourrait être préférable d'utiliser Futurize afin de commencer à s'ajuster aux nouvelles pratiques introduites par Python 3 avec lesquelles vous n'êtes pas encore habitué.

Indépendamment de l'outil sur lequel se porte votre choix, celui-ci mettra à jour votre code afin qu'il puisse être exécuté par Python 3 tout en maintenant sa compatibilité avec la version de Python 2 dont vous êtes parti. En fonction du niveau de prudence que vous visez, vous pouvez exécuter l'outil sur votre suite de test d'abord puis inspecter visuellement la différence afin de vous assurer que la transformation est exacte. Après avoir transformé votre suite de test et vérifié que tous les tests s'exécutent comme attendu, vous pouvez transformer le code de votre application avec l'assurance que chaque test qui échoue correspond à un échec de traduction.

Malheureusement les outils ne peuvent pas automatiser tous les changements requis pour permettre à votre code de s'exécuter sous Python 3 et il y a donc quelques points sur lesquels vous devrez travailler manuellement afin d'atteindre la compatibilité totale Python 3 (les étapes nécessaires peuvent varier en fonction de l'outil utilisé). Lisez la documentation de l'outil que vous avez choisi afin d'identifier ce qu'il corrige par défaut et ce qui peut être appliqué de façon optionnelle afin de savoir ce qui sera (ou non) corrigé pour vous ou ce que vous devrez modifier vous-même (par exemple, le remplacement io.open() plutôt que la fonction native open() est inactif par défaut dans Modernize). Heureusement, il n'y a que quelques points à surveiller qui peuvent réellement être considérés comme des problèmes difficiles à déboguer si vous n'y prêtez pas attention.

Division

Dans Python 3, 5 / 2 == 2.5 et non 2; toutes les divisions entre des valeurs int renvoient un float. Ce changement était en réalité planifié depuis Python 2.2, publié en 2002. Depuis cette date, les utilisateurs ont été encouragés à ajouter from __future__ import division à tous les fichiers utilisant les opérateurs / et // ou à exécuter l'interpréteur avec l'option -Q. Si vous n'avez pas suivi cette recommandation, vous devrez manuellement modifier votre code et effectuer deux changements :

  1. Ajouter from __future__ import division à vos fichiers

  2. Remplacer tous les opérateurs de division par // pour la division entière, le cas échéant, ou utiliser / et vous attendre à un résultat flottant

La raison pour laquelle / n'est pas simplement remplacé par // automatiquement est que si un objet définit une méthode __truediv__ mais pas de méthode __floordiv__, alors votre code pourrait produire une erreur (par exemple, une classe définie par l'utilisateur qui utilise / pour définir une opération quelconque mais pour laquelle // n'a pas du tout la même signification, voire n'est pas utilisé du tout).

Texte et données binaires

Dans Python 2, il était possible d'utiliser le type str pour du texte et pour des données binaires. Malheureusement cet amalgame entre deux concepts différents peut conduire à du code fragile pouvant parfois fonctionner pour les deux types de données et parfois non. Cela a également conduit à des API confuses si les auteurs ne déclaraient pas explicitement que quelque chose qui acceptait str était compatible avec du texte ou des données binaires et pas un seul des deux types. Cela a compliqué la situation pour les personnes devant gérer plusieurs langages avec des API qui ne se préoccupaient pas de la gestion de unicode lorsqu'elles affirmaient être compatibles avec des données au format texte.

Afin de rendre la distinction entre texte et données binaires claire et prononcée, Python 3 a suivi la voie pavée par la plupart des langages créés à l'ère d'Internet et a séparé les types texte et données binaires de telle sorte qu'il ne soit plus possible de les confondre (Python est antérieur à la démocratisation de l'accès à Internet). Cette séparation ne pose pas de problème pour du code ne gérant soit que du texte, soit que des données binaires. Cependant un code source devant gérer les deux doit désormais se préoccuper du type des données manipulées, ce qui explique que ce processus ne peut pas être entièrement automatisé.

Pour commencer, vous devrez choisir quelles API travaillent sur du texte et lesquelles travaillent avec des données binaires (il est fortement recommandé de ne pas concevoir d'API qui gèrent les deux types compte-tenu de la difficulté supplémentaire que cela induit). Dans Python 2, cela signifie s'assurer que les API recevant du texte en entrée peuvent gérer unicode et celles qui reçoivent des données binaires fonctionnent avec le type bytes de Python 3 (qui est un sous-ensemble de str dans Python 2 et opère comme un alias du type bytes de Python 2). En général, le principal problème consiste à inventorier quelles méthodes existent et opèrent sur quel type dans Python & 3 simultanément (pour le texte, il s'agit de unicode dans Python 2 et str dans Python 3, pour le binaire il s'agit de str/bytes dans Python 2 et bytes dans Python 3). Le tableau ci-dessous liste les méthodes spécifiques à chaque type de données dans Python 2 et 3 (par exemple, la méthode decode() peut être utilisée sur des données binaires équivalentes en Python 2 et 3, mais ne peut pas être utilisée de la même façon sur le type texte en Python 2 et 3 car le type str de Python 3 ne possède pas de telle méthode). Notez que depuis Python 3.5, la méthode __mod__ a été ajoutée au type bytes.

Format texte

Format binaire

decode

encode

format

isdecimal

isnumeric

Vous pouvez rendre le problème plus simple à gérer en réalisant les opérations d'encodage et de décodage entre données binaires et texte aux extrémités de votre code. Cela signifie que lorsque vous recevez du texte dans un format binaire, vous devez immédiatement le décoder. À l'inverse si votre code doit transmettre du texte sous forme binaire, encodez-le le plus tard possible. Cela vous permet de ne manipuler que du texte à l'intérieur de votre code et permet de ne pas se préoccuper du type des données sur lesquelles vous travaillez.

Le point suivant est de s'assurer que vous savez quelles chaînes de caractères littérales de votre code correspondent à du texte ou à du binaire. Vous devez préfixer par b tous les littéraux qui représentent des données binaires et par u les littéraux qui représentent du texte (il existe une importation du module __future__ permettant de forcer l'encodage de toutes les chaînes de caractères littérales non spécifiées en Unicode, mais cette pratique s'est avérée moins efficace que l'ajout explicite des préfixe b et u).

Une conséquence de cette dichotomie est que vous devez être prudents lors de l'ouverture d'un fichier. À moins que vous travailliez sous Windows, il y a des chances pour que vous ne vous soyez jamais préoccupé de spécifier le mode b lorsque vous ouvrez des fichiers binaires (par exemple rb pour lire un fichier binaire). Sous Python 3, les fichiers binaire et texte sont distincts et mutuellement incompatibles ; se référer au module io pour plus de détails. Ainsi vous devez décider lorsque vous ouvrez un fichier si vous y accéderez en mode binaire (ce qui permet de lire et écrire des données binaires) ou en mode texte (ce qui permet de lire et écrire du texte). Vous devez également utiliser io.open() pour ouvrir des fichiers plutôt que la fonction native open() étant donné que le module io est cohérent de Python 2 à 3, ce qui n'est pas vrai pour la fonction open() (en Python 3, il s'agit en réalité de io.open()). Ne cherchez pas à appliquer l'ancienne pratique consistant à utiliser codecs.open() qui n'est nécessaire que pour préserver une compatibilité avec Python 2.5.

Les constructeurs des types str et bytes possèdent une sémantique différente pour les mêmes arguments sous Python 2 et 3. Passer un entier à bytes sous Python 2 produit une représentation de cet entier en chaîne de caractères : bytes(3) == '3'. Mais sous Python 3, fournir un argument entier à bytes produit un objet bytes de la longueur de l'entier spécifié, rempli par des octets nuls : bytes(3) == b'\x00\x00\x00'. La même prudence est nécessaire lorsque vous passez un objet bytes à str. En Python 2, vous récupérez simplement l'objet bytes initial : str(b'3') == b'3'. Mais en Python 3, vous récupérez la représentation en chaîne de caractères de l'objet bytes : str(b'3') == "b'3'".

Enfin, l'indiçage des données binaires exige une manipulation prudente (bien que le découpage, ou slicing en anglais, ne nécessite pas d'attention particulière). En Python 2, b'123'[1] == b'2' tandis qu'en Python 3 b'123'[1] == 50. Puisque les données binaires ne sont simplement qu'une collection de nombres en binaire, Python 3 renvoie la valeur entière de l'octet indicé. Mais en Python 2, étant donné que bytes == str, l'indiçage renvoie une tranche de longueur 1 de bytes. Le projet six dispose d'une fonction appelée six.indexbytes() qui renvoie un entier comme en Python 3 : six.indexbytes(b'123', 1).

Pour résumer :

  1. Décidez lesquelles de vos API travaillent sur du texte et lesquelles travaillent sur des données binaires

  2. Assurez vous que votre code travaillant sur du texte fonctionne aussi avec le type unicode et que le code travaillant sur du binaire fonctionne avec le type bytes en Python 2 (voir le tableau ci-dessus pour la liste des méthodes utilisables par chaque type)

  3. Préfixez tous vos littéraux binaires par b et toutes vos chaînes de caractères littérales par u

  4. Décodez les données binaires en texte dès que possible, encodez votre texte au format binaire le plus tard possible

  5. Ouvrez les fichiers avec la fonction io.open() et assurez-vous de spécifier le mode b le cas échéant

  6. Utilisez avec prudence l'indiçage sur des données binaires

Utilisez la détection de fonctionnalités plutôt que la détection de version

Vous rencontrerez inévitablement du code devant décider quoi faire en fonction de la version de Python qui s'exécute. La meilleure façon de gérer ce cas est de détecter si les fonctionnalités dont vous avez besoin sont gérées par la version de Python sous laquelle le code s'exécute. Si pour certaines raisons cela ne fonctionne pas, alors vous devez tester si votre version est Python 2 et non Python 3. Afin de clarifier cette pratique, voici un exemple.

Supposons que vous avez besoin d'accéder à une fonctionnalité de importlib qui est disponible dans la bibliothèque standard de Python depuis la version 3.3, dans celle de Python 2 via le module importlib2 sur PyPI. Vous pourriez être tenté d'écrire un code qui accède, par exemple, au module importlib.abc avec l'approche suivante :

import sys

if sys.version_info[0] == 3:
    from importlib import abc
else:
    from importlib2 import abc

Le problème est le suivant : que se passe-t-il lorsque Python 4 est publié ? Il serait préférable de traiter le cas Python 2 comme l'exception plutôt que Python 3 et de supposer que les versions futures de Python 2 seront plus compatibles avec Python 3 qu'avec Python 2 :

import sys

if sys.version_info[0] > 2:
    from importlib import abc
else:
    from importlib2 import abc

Néanmoins la meilleure solution est de ne pas chercher à déterminer la version de Python mais plutôt à détecter les fonctionnalités disponibles. Cela évite les problèmes potentiels liés aux erreurs de détection de version et facilite la compatibilité future :

try:
    from importlib import abc
except ImportError:
    from importlib2 import abc

Prévenir les régressions de compatibilité

Une fois votre code traduit pour être compatible avec Python 3, vous devez vous assurer que votre code n'a pas régressé ou qu'il ne fonctionne pas sous Python 3. Ceci est particulièrement important si une de vos dépendances vous empêche de réellement exécuter le code sous Python 3 pour le moment.

Afin de vous aider à maintenir la compatibilité, nous préconisons que tous les nouveaux modules que vous créez aient au moins le bloc de code suivant en en-tête :

from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

Vous pouvez également lancer Python 2 avec le paramètre -3 afin d'être alerté en cas de divers problèmes de compatibilité que votre code déclenche durant son exécution. Si vous transformez les avertissements en erreur avec -Werror, vous pouvez être certain que ne passez pas accidentellement à côté d'un avertissement.

Vous pouvez également utiliser le projet Pylint et son option --py3k afin de modifier votre code pour recevoir des avertissements lorsque celui-ci dévie de la compatibilité Python 3. Cela vous évite par ailleurs d'appliquer Modernize ou Futurize sur votre code régulièrement pour détecter des régressions liées à la compatibilité. Cependant cela nécessite de votre part le support de Python 2.7 et Python 3.4 ou ultérieur étant donné qu'il s'agit de la version minimale gérée par Pylint.

Vérifier quelles dépendances empêchent la migration

Après avoir rendu votre code compatible avec Python 3, vous devez commencer à vous intéresser au portage de vos dépendances. Le projet caniusepython3 a été créé afin de vous aider à déterminer quels projets sont bloquants dans votre support de Python 3, directement ou indirectement. Il existe un outil en ligne de commande ainsi qu'une interface web : https://caniusepython3.com.

Le projet fournit également du code intégrable dans votre suite de test qui déclenchera un échec de test lorsque plus aucune de vos dépendances n'est bloquante pour l'utilisation de Python 3. Cela vous permet de ne pas avoir à vérifier manuellement vos dépendances et d'être notifié rapidement quand vous pouvez exécuter votre application avec Python 3.

Mettre à jour votre fichier setup.py pour spécifier la compatibilité avec Python 3

Une fois que votre code fonctionne sous Python 3, vous devez mettre à jour vos classeurs dans votre setup.py pour inclure Programming Language :: Python :: 3 et non seulement le support de Python 2. Cela signifiera à quiconque utilise votre code que vous gérez Python 2 et 3. Dans l'idéal vous devrez aussi ajouter une mention pour chaque version majeure/mineure de Python que vous supportez désormais.

Utiliser l'intégration continue pour maintenir la compatibilité

Une fois que vous êtes en mesure d'exécuter votre code sous Python 3, vous devrez vous assurer que celui-ci fonctionne toujours pour Python 2 & 3. tox est vraisemblablement le meilleur outil pour exécuter vos tests avec plusieurs interpréteurs Python. Vous pouvez alors intégrer tox à votre système d'intégration continue afin de ne jamais accidentellement casser votre gestion de Python 2 ou 3.

Vous pouvez également utiliser l'option -bb de l'interpréteur Python 3 afin de déclencher une exception lorsque vous comparez des bytes à des chaînes de caractères ou à un entier (cette deuxième possibilité est disponible à partir de Python 3.5). Par défaut, des comparaisons entre types différents renvoient simplement False mais si vous avez fait une erreur dans votre séparation de la gestion texte/données binaires ou votre indiçage des bytes, vous ne trouverez pas facilement le bogue. Ce drapeau lève une exception lorsque ce genre de comparaison apparaît, facilitant ainsi son identification et sa localisation.

Et c'est à peu près tout ! Une fois ceci fait, votre code source est compatible avec Python 2 et 3 simultanément. Votre suite de test est également en place de telle sorte que vous ne cassiez pas la compatibilité Python 2 ou 3 indépendamment de la version que vous utilisez pendant le développement.

Envisager l'utilisation d'un vérificateur de type statique optionnel

Une autre façon de faciliter le portage de votre code est d'utiliser un vérificateur de type statique comme mypy ou pytype. Ces outils peuvent être utilisés pour analyser votre code comme s'il était exécuté sous Python 2, puis une seconde fois comme s'il était exécuté sous Python 3. L'utilisation double d'un vérificateur de type statique de cette façon permet de détecter si, par exemple, vous faites une utilisation inappropriée des types de données binaires dans une version de Python par rapport à l'autre. Si vous ajoutez les indices optionnels de typage à votre code, vous pouvez alors explicitement déclarer que vos API attendent des données binaires ou du texte, ce qui facilite alors la vérification du comportement de votre code dans les deux versions de Python.