Vendredi dernier j’ai signé une rupture conventionnelle qui mettra fin
au contrat qui me lie à Tanker le 24 février 2021.
Ainsi se conclura une aventure de près de 5 années riches en
enseignements et en rebondissements. Merci à tous mes collègues, et bon
courage pour la suite!
Dans l’idéal, j’aimerais trouver une activité de professeur à temps
plein (toujours dans le domaine de l’informatique) et dans la région
parisienne, mais je suis ouvert à toute forme de contrat.
J’ai acquis de nombreuses compétences en tant que développeur
professionnel - tant chez Tanker que dans ma dans ma boîte précédente,
Softbank Robotics - compétences que je souhaite aujourd’hui transmettre.
Parmi les sujets sur lesquels je me sens prêt à donner des cours dès
maintenant:
Les bonnes pratiques de développement (tests automatiques, intégration
continue, DevOps, revue de code, …)
Les méthodes agiles (SCRUM et Lean en particulier)
etc …
Il est bien sûr possible que je n’arrive pas à trouver un poste à temps
plein tout de suite, et donc j’ai prévu de chercher également un poste
dans une coopérative ou similaire.
Je ne me vois pas du tout travailler dans une société de service ou dans
une grosse boîte, et après plus de 10 ans passés dans le monde des start-
up, j’ai besoin de changement. Je pense que je m’épanouirai davantage
dans ce genre de structure, et en tout cas, cela m’intéresse de
découvrir une nouvelle forme d’organisation.
J’écris cet article en partie pour clarifier mes objectifs pour l’avenir,
mais surtout pour que vous, chers lecteurs, puissiez le partager et
m’aider à passer cette nouvelle étape de ma carrière.
Comme d’habitude, ma page de contact est là si vous
avez des pistes à me suggérer, des questions à me poser, ou pour toute autre
remarque.
Note : cet article reprend en grande partie le cours donné à l’École
du Logiciel Libre le 18 mai 2019.
Il s’inspire également des travaux de Robert C. Martin (alias Uncle Bob) sur la question,
notamment sa série de vidéos sur
cleancoders.com1
Comme on l’a vu, utiliser assert ressemble fortement à lever une exception. Dans les deux cas, on veut signaler
à celui qui appelle notre code que quelque chose ne va pas. Mais assert est différent par deux aspects :
Il peut arrive que la ligne contenant assert soit tout simplement ignorée 2.
assert et souvent utilisé pour signaler qu’il y a une erreur dans le code qui a appelé la fonction, et
non à cause d’une erreur “extérieure”
On dit que calc.py est le code de production, et test_calc.py le code de test. Comme son nom l’indique, le code de production sert de base à un produit - un programme, un site web, etc.
On sépare souvent le code de production et le code de test dans des fichiers différents, tout simplement parce que le code de test ne sert pas directement aux utilisateurs du produit. Le code de test ne sert en général qu’aux auteurs du code.
Une petite digression s’impose ici. Selon Robert C. Martin, le code possède une valeur primaire et une valeur secondaire.
La valeur primaire est le comportement du code - ce que j’ai appelé le produit ci-dessus
La valeur secondaire est le fait que le code (et donc le produit) peut être modifié.
Selon lui, la valeur secondaire (en dépit de son nom) est la plus importante : dans software, il y a “soft”, par opposition à hardware. Si vous avez un produit qui fonctionne bien mais que le code est impossible à changer, vous
risquez de vous faire de ne pas réussir à rajouter de nouvelles fonctionnalités,
de ne pas pouvoir corriger les bugs suffisamment rapidement, et de vous faire dépasser par la concurrence.
Ainsi, si le code de test n’a a priori pas d’effet sur la valeur primaire du code (après tout, l’utilisateur
du produit n’est en général même pas conscient de son existence), il a un effet très important sur la valeur secondaire, comme on le verra par la suite.
Avant de poursuivre, penchons-nous sur deux limitations importantes des tests.
Premièrement, les tests peuvent échouer même si le code de production est correct :
deftest_add_one():
result = add_one(2)
assert result == 4
Ici on a un faux négatif. L’exemple peut vous faire sourire, mais c’est un problème plus
fréquent que ce que l’on croit.
Ensuite, les tests peuvent passer en dépit de bugs dans le code. Par exemple, si
on oublie une assertion :
defadd_two(x):
return x + 3deftest_add_two():
result = calc.add_two(3)
# fin du test
Ici, on a juste vérifié qu’appeler add_two(3) ne provoque pas d’erreur. On dit
qu’on a un faux positif, ou un bug silencieux.
Autre exemple :
deffonction_complexe():
if condition_a:
...
if condition_b:
...
Ici, même s’il n’y a que deux lignes commençant par if, pour être
exhaustif, il faut tester 4 possibilités, correspondant aux 4 valeurs
combinées des deux conditions. On comprend bien que plus le code devient
complexe, plus le nombre de cas à tester devient gigantesque.
Dans le même ordre d’idée, les tests ne pourront jamais vérifier le
comportement entier du code. On peut tester add_one() avec des exemples,
mais on voit difficilement commeent tester add_one() avec tous les entiers
possibles. 4
Cela dit, maintenant qu’on sait comment écrire et lancer des tests,
revenons sur les bénéfices des tests sur la valeur secondaire du code.
On a vu comment les tests peuvent mettre en évidence des bugs présents dans le code.
Ainsi, à tout moment, on peut lancer la suite de tests pour vérifier (une partie) du
comportement du code, notamment après toute modification du code de production.
On a donc une chance de trouver des bugs bien avant que les utilisateurs du produit
l’aient entre les mains.
Imaginez un code avec un comportement assez complexe. Vous avez une nouvelle fonctionnalité à
rajouter, mais le code dans son état actuel ne s’y prête pas.
Une des solutions est de commencer par effectuer un refactoring, c’est-à dire de commencer
par adapter le code mais sans changer son comportement (donc sans introduire de bugs). Une fois ce refactoring effectué,
le code sera prêt à être modifié et il deviendra facile d’ajouter la fonctionnalité.
Ainsi, disposer d’une batterie de tests qui vérifient le comportement du programme automatiquement et de manière exhaustive
est très utile. Si, à la fin du refactoring vous pouvez lancer les tests et constater qu’ils passent tous, vous serez plus confiant sur le fait que votre refactoring n’a pas introduit de nouveaux bugs.
Cela peut paraître surprenant, surtout à la lumière des exemples basiques que je vous ai montrés, mais écrire des tests est un art difficile à maîtriser. Cela demande un état d’esprit différent
de celui qu’on a quand on écrit du code de production. En fait, écrire des bons tests est une compétence
qui s’apprend.
Ce que je vous propose ici c’est une discipline : un ensemble de règles et une façon de faire qui vous
aidera à développer cette compétence. Plus vous pratiquerez cette discipline, meilleur sera votre code
de test, et, par extension, votre code de production.
Commençons par les règles :
Règle 1 : Il est interdit d’écrire du code de production, sauf si c’est pour faire passer un test qui
a échoué.
Règle 2 : Il est interdit d’écrire plus de code que celui qui est nécessaire pour provoquer une erreur
dans les tests (n’importe quelle erreur)
Règle 3 : Il est interdit d’écrire plus de code que celui qui est nécessaire pour faire passer
un test qui a échoué
Règle 4 : Une fois que tous les tests passent, il est interdit de modifier le code sans s’arrêter
pour considérer la possibilité d’un refactoring. 5
Et voici une procédure pour appliquer ces règles: suivre le cycle de dévelopement suivant :
Écrire un test qui échoue - étape “red”
Faire passer le test - étape “green”
Refactorer à la fois le code de production et le code de test - étape “refactor”
Le code production a l’air impossible à refactorer, mais jetons un œil aux tests :
importbowlingdeftest_can_create_game():
game = bowling.Game()
deftest_can_roll():
game = bowling.Game()
game.roll(0)
deftest_can_score():
game = bowling.Game()
game.roll(0)
game.score()
Hum. Le premier et le deuxième test sont inclus exactement dans le dernier test. Ils ne servent donc à rien, et
peuvent être supprimés.
⁂RED⁂
En y réfléchissant, can_score() ne vérifie même pas la valeur de retour de score(). Écrivons un test légèrement différent :
Notez qu’on a fait passer le test en écrivant du code que l’on sait être incorrect. Mais la règle 3 nous interdit d’aller plus loin.
Vous pouvez voir cela comme une contrainte arbitraire (et c’en est est une), mais j’aimerais vous faire remarquer qu’on en a fait spécifié
l’API de la classe Game. Le test, bien qu’il ne fasse que quelques lignes,
nous indique l’existence des métode roll() et score(), les paramètres
qu’elles attendent et, à un certain point, la façon dont elles intéragissent
C’est une autre facette des tests: ils vous permettent de transformer une
spécification en code éxecutable. Ou, dit autrement, ils vous permettent
d’écrire des exemples d’utilisation de votre API pendant que vous
l’implémentez. Et, en vous forçant à ne pas écrire trop de code de production,
vous avez la possibilité de vous concentrer uniquement sur l’API de votre code,
sans vous soucier de l’implémentation.
Bon, on a enlevé plein de tests, du coup il n’y a encore plus grand-chose à refactorer,
passons au prochain.
⁂RED⁂
Rappelez-vous, on vient de dire que le code de score() est incorrect. La question devient donc : quel test pouvons-nous
écrire pour nous forcer à écrire un code un peu plus correct ?
Une possible idée est d’écrire un test pour un jeu où tous les lancers renversent exactement une quille :
deftest_all_ones():
game = bowling.Game()
for roll inrange(20):
game.roll(1)
score = game.score()
assert score == 20
> assert score == 20
E assert 0 == 20
⁂GREEN⁂
Ici la boucle dans le test nous force à changer l’état de la
class Game à chaque appel à roll(), ce que nous pouvons faire
en rajoutant un attribut qui compte le nombre de quilles
renversées.
deftest_score_is_zero_after_gutter():
game = bowling.Game()
game.roll(0)
score = game.score()
assert score == 0deftest_all_ones():
game = bowling.Game()
for roll inrange(20):
game.roll(1)
score = game.score()
assert score == 20
Les deux tests sont subtilement différents. Dans un cas, on appelle roll() une fois, suivi immédiatement d’un appel à score().
Dans l’autre, on appelle roll() 20 fois, et on appelle score() à la fin.
Ceci nous montre une ambiguïté dans les spécifications. Veut-on pouvoir obtenir le score en temps réel, ou voulons-nous
simplement appeler score à la fin de la partie ?
On retrouve ce lien intéressant entre tests et API : aurions-nous découvert cette ambiguïté sans avoir écrit aucun test ?
Ici, on va décider que score() n’est appelé qu’à la fin de la partie, et donc réécrire les tests ainsi , en appelant 20 fois
roll(0):
deftest_gutter_game():
game = bowling.Game()
for roll inrange(20):
game.roll(0)
score = game.score()
assert score == 0deftest_all_ones():
game = bowling.Game()
for roll inrange(20):
game.roll(1)
score = game.score()
assert score == 20
Les tests continuent à passer. On peut maintenant réduire la duplication en introduisant une fonction roll_many :
defroll_many(game, count, value):
for roll inrange(count):
game.roll(value)
deftest_gutter_game():
game = bowling.Game()
roll_many(game, 20, 0)
score = game.score()
assert score == 0deftest_all_ones():
game = bowling.Game()
roll_many(game, 20, 1)
score = game.score()
assert score == 20
⁂RED⁂
L’algorithme utilisé (rajouter les quilles renversées au score à chaque lancer) semble fonctionner tant qu’il n’y a ni spare ni strike.
Du coup, rajoutons un test sur les spares :
deftest_one_spare():
game = bowling.Game()
game.roll(5)
game.roll(5) # spare, next roll should be counted twice game.roll(3)
roll_many(game, 17, 0)
score = game.score()
assert score == 16
Ce code a un problème : en fait, c’est la méthode roll() qui calcule le score, et non la fonction score() !
On comprend que roll() doit simplement enregistrer l’ensemble des résultats des lancers, et qu’ensuite seulement,
score() pourra parcourir les frames et calculer le score.
⁂REFACTOR⁂
On remplace donc l’attribut knocked_pins() par une liste de rolls et un index:
classGame:
def __init__(self):
self.rolls = [0] * 21 self.roll_index = 0defroll(self, pins):
self.rolls[self.roll_index] = pins
self.roll_index += 1defscore(self):
result = 0for roll in self.rolls:
result += roll
return result
Petit aparté sur le nombre 21. Ici ce qu’on veut c’est le nombre maximum de frames.
On peut s’assurer que 21 est bien le nombre maximum en énumérant les cas possibles
de la dernière frame, et en supposant qu’il n’y a eu ni spare ni strike au cours
du début de partie (donc 20 lancers, 2 pour chacune des 10 premières frame)
spare: on va avoir droit à un un lancer en plus: 20 + 1 = 21
strike: par définition, on n’a fait qu’un lancer à la dernière frame, donc au plus 19 lancers, et 19 plus 2 font bien 21.
sinon: pas de lancer supplémentaire, on reste à 20 lancers.
L’algorithme est toujours éronné, mais on sent qu’on une meilleure chance de réussir à gérer les spares.
⁂RED⁂
On ré-active le test en enlevant la ligne @pytest.mark.skip et on retombe évidemment sur la même erreur :
> assert score == 16
E assert 13 == 16
⁂GREEN⁂
Pour faire passer le test, on peut simplement itérer sur les frames une par une, en utilisant
une variable i qui vaut l’index du premier lancer de la prochaine frame :
defscore(self):
result = 0 i = 0for frame inrange(10):
if self.rolls[i] + self.rolls[i + 1] == 10: # spare result += 10 result += self.rolls[i + 2]
i += 2else:
result += self.rolls[i]
result += self.rolls[i + 1]
i += 2return result
Mon Dieu que c’est moche ! Mais cela me permet d’aborder un autre aspect du TDD. Ici, on est dans la phase “green”. On fait tout ce qu’on peut
pour faire passer le tests et rien d’autre. C’est un état d’esprit particulier, on était concentré sur l’algorithme en lui-même.
⁂REFACTOR⁂
Par contraste, ici on sait que l’algorithme est correct. Notre unique objectif est de rendre le code plus lisible. Un des avantages de TDD est qu’on passe d’un objectif précis à l’autre, au lieu d’essayer de tout faire en même temps.
Bref, une façon de refactorer est d’introduire une nouvelle méthode :
# note: i represents the index of the# first roll of the current framedefis_spare(self, i):
return self.rolls[i] + self.rolls[i + 1] == 10defscore(self):
result = 0 i = 0for frame inrange(10):
if self.is_spare(i):
result += 10 result += self.rolls[i + 2]
i += 2else:
result += self.rolls[i]
result += self.rolls[i + 1]
i += 2
En passant, on s’est débarrassé du commentaire “# spare” à la fin du if, vu qu’il n’était plus utile. En revanche, on a gardé un commentaire au-dessus
de la méthode is_spare(). En effet, il n’est pas évident de comprendre la valeur représentée par l’index i juste en lisant le code. 7
On voit aussi qu’on a gardé un peu de duplication. Ce n’est pas forcément très grave, surtout que l’algorithme est loin d’être terminé. Il faut encore gérer les strikes et la dernière frame.
Mais avant cela, revenons sur les tests (règle 4) :
deftest_one_spare():
game = bowling.Game()
game.roll(5)
game.roll(5) # spare, next roll should be counted twice game.roll(3)
roll_many(game, 17, 0)
score = game.score()
assert score == 16
On a le même genre de commentaire qui nous suggère qu’il manque une abstraction quelque part : une fonction roll_spare.
importbowlingimportpytestdefroll_many(game, count, value):
for roll inrange(count):
game.roll(value)
defroll_spare(game):
game.roll(5)
game.roll(5)
deftest_one_spare():
game = bowling.Game()
roll_spare(game)
game.roll(3)
roll_many(game, 17, 0)
score = game.score()
assert score == 16
Les tests continuent à passer, tout va bien.
Mais le code de test peut encore être amélioré. On voit qu’on a deux fonctions qui prennent chacune le même paramètre en premier argument.
Souvent, c’est le signe qu’une classe se cache quelque part.
On peut créer une classe GameTest qui hérite de Game et contient les méthodes roll_many() et roll_spare() :
importbowlingimportpytestclassGameTest(bowling.Game):
defroll_many(self, count, value):
for roll inrange(count):
self.roll(value)
defroll_spare(self):
self.roll(5)
self.roll(5)
deftest_gutter_game():
game = GameTest()
game.roll_many(20, 0)
score = game.score()
assert score == 0deftest_all_ones():
game = bowling.GameTest()
game.roll_many(20, 1)
score = game.score()
assert score == 20deftest_one_spare():
game = GameTest()
game.roll_spare()
game.roll(3)
game.roll_many(17, 0)
score = game.score()
assert score == 16
Ouf! Suffisamment de refactoring pour l’instant, retour au rouge.
⁂RED⁂
Avec notre nouvelle classe définie au sein de test_bowling.py (on dit souvent “test helper”), on peut facilement rajouter le test sur les strikes :
A priori, tous les tests devraient passer sauf le dernier, et on devrait avoir une erreur de genre x != 24, avec x légèrement en-dessous de 24 :
________________________________ test_all_ones _________________________________
def test_all_ones():
> game = bowling.GameTest()
E AttributeError: module 'bowling' has no attribute 'GameTest'
_______________________________ test_one_strike ________________________________
def test_one_strike():
game = GameTest()
game.roll_strike()
game.roll(3)
game.roll(4)
game.roll_many(16, 0)
score = game.score()
> assert score == 24
E assert 17 == 24
test_bowling.py:48: AssertionError
Oups, deux erreurs ! Il se trouve qu’on a oublié de lancer les tests à la fin du dernier refactoring. En fait, il y a une ligne qui a été changée de façon incorrecte : game = bowling.GameTest() au lieu de game = GameTest(). L’aviez-vous remarqué ?
Cela illustre deux points :
Il faut toujours avoir une vague idée des tests qui vont échouer et de quelle manière
Il est important de garder le cycle de TDD court. En effet, ici on sait que seuls les tests ont changé depuis la dernière session de test, donc on sait que le problème vient des tests et non du code de production.
On peut maintenant corriger notre faux positif, relancer les tests, vérifier qu’ils échouent pour la bonne raison et passer à l’étape suivante.
Là encore, on a tous les éléments pour implémenter la gestion de strikes correctement, grâce aux refactorings précédents et au fait qu’on a implémenté l’algorithme de façon incrémentale, un petit bout à la fois.
classGame:
...
defis_spare(self, i):
return self.rolls[i] + self.rolls[i + 1] == 10defis_strike(self, i):
return self.rolls[i] == 10defscore(self):
result = 0 i = 0for frame inrange(10):
if self.is_strike(i):
result += 10 result += self.rolls[i + 1]
result += self.rolls[i + 2]
i += 1elif self.is_spare(i):
result += 10 result += self.rolls[i + 2]
i += 2else:
result += self.rolls[i]
result += self.rolls[i + 1]
i += 2return result
J’espère que vous ressentez ce sentiment que le code “s’écrit tout seul”. Par contraste, rappelez-vous la difficulté pour implémenter les spares et imaginez à quel point cela aurait été difficile de gérer les spares et les strikes en un seul morceau !
⁂REFACTOR⁂
On a maintenant une boucle avec trois branches. Il est plus facile de finir le refactoring commencé précédement, et d’isoler les lignes qui se ressemblent des lignes qui diffèrent :
classGame:
...
defis_strike(self, i):
return self.rolls[i] == 10defis_spare(self, i):
return self.rolls[i] + self.rolls[i + 1] == 10defnext_two_rolls_for_strike(self, i):
return self.rolls[i + 1] + self.rolls[i + 2]
defnext_roll_for_spare(self, i):
return self.rolls[i + 2]
defrolls_in_frame(self, i):
return self.rolls[i] + self.rolls[i + 1]
defscore(self):
result = 0 i = 0for frame inrange(10):
if self.is_strike(i):
result += 10 result += self.next_two_rolls_for_strike(i)
i += 1elif self.is_spare(i):
result += 10 result += self.next_roll_for_spare(i)
i += 2else:
result += self.rolls_in_frame(i)
i += 2return result
On approche du but, il ne reste plus qu’à gérer la dernière frame.
⁂RED⁂
Écrivons maintenant le test du jeu parfait, où le joueur fait un strike à chaque essai. Il y a donc 10 frames de strike, puis deux strikes (pour les deux derniers lancers de la dernière frame) soit 12 strikes en tout.
Et comme tout joueur de bowling le sait, le score maximum au bowling est 300 :
deftest_perfect_game():
game = GameTest()
for i inrange(0, 12):
game.roll_strike()
assert game.score() == 300
Ici, je vais vous laisser 5 minutes de réflexion pour vous convaincre qu’en realité, la dernière
frame n’a absolument rien de spécial, et que c’est la raison pour laquelle notre algorithme
fonctionne.
D’abord, je trouve qu’on peut être fier du code auquel on a abouti :
result = 0 i = 0for frame inrange(10):
if self.is_strike(i):
result += 10 result += self.next_two_rolls_for_strike(i)
i += 1elif self.is_spare(i):
result += 10 result += self.next_roll_for_spare(i)
i += 2else:
result += self.rolls_in_frame(i)
i += 2
Le code se “lit” quasiment comme les règles du bowling. Il a l’air correct, et il est correct.
Ensuite, même si notre refléxion initiale nous a guidé (notamment avec la classe Game et ses deux méthodes),
notez qu’on a pas eu besoin des classes Frame ou Roll, ni de la classe fille TenthFrame. En ce sens, on peut dire que TDD est également
une façon de concevoir le code, et pas juste une façon de faire évoluer le code de production et le code de test en parallèle.
Enfin, on avait un moyen de savoir quand le code était fini. Quand on pratique TDD, on sait qu’on peut s’arrêter dès que tous les tests
passent. Et, d’après l’ensemble des règles, on sait qu’on a écrit uniquement le code nécessaire.
1/ La méthode roll() peut être appelée un nombre trop grand de fois, comme le prouve le test suivant :
deftest_two_many_rolls():
game = GameTest()
game.roll_many(21, 1)
assert game.score() == 20
Savoir si c’est un bug ou non dépend des spécifications.
2/ Il y a probablement une classe ou une méthode cachée dans la classe Game. En effet, on a plusieurs méthodes qui prennent toutes un index en premier paramètre, et le paramètre en question nécessite un commentaire pour être compris.
Résoudre ces deux problèmes sera laissé en exercice au lecteur :P
Voilà pour cette présentation sur le TDD. Je vous recommande d’essayer cette méthode par vous-mêmes. En ce qui me concerne elle a changé ma façon d’écrire du code en profondeur, et après plus de 5 ans de pratique, j’ai du mal à envisager de coder autrement.
À +
C’est payant, c’est en anglais, les exemples sont en Java, mais c’est vraiment très bien. ↩︎
Par exemple, quand on lance python avec l’option -O↩︎
Voir cet article pour comprendre pourquoi on procède ansi. ↩︎
Il existe de nombreux outils pour palier aux limitations des tests, mais on en parlera une prochaine fois. ↩︎
Les trois premières règles sont de Uncle Bob, la dernière est de moi. ↩︎↩︎
Vous avez tout à fait le droit d’écrire du code en français. Mais au moindre doute sur la possibilité qu’un non-francophone doive lire votre code un jour, vous devez passer à l’anglais. ↩︎
Si cette façon de commenter du code vous intrigue, vous pouvez lire cet excellent article (en anglais) pour plus de détails. ↩︎
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)
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.
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 premier
Il 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.
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:
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)
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)
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 utilisiez
xrange 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.
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 :
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.
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.
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 !
Vous connaissez peut-être le rôle de la variable d’environnement PATH. Celle-ci contient une liste de chemins,
et est utilisée par votre shell pour trouver le chemin complet des commandes que vous lancez.
Par exemple:
PATH="/bin:/usr/bin:/usr/sbin"$ ifconfig
# lance le binaire /usr/sbin/ifconfig$ ls
# lance le binaire /bin/ls
Le chemin est “résolu” par le shell en parcourant la liste de tout les segments de PATH, et en regardant si le chemin complet
existe. La résolution s’arrête dès le premier chemin trouvé.
Par exemple, si vous avez PATH="/home/user/bin:/usr/bin" et un fichier ls dans /home/user/bin/ls, c’est ce fichier-là
(et non /bin/ls) qui sera utilisé quand vous taperez ls.
Notez que le résultat dépend de ma distribution, et de la présence ou non du répertoire ~/.local/lib/python3.7/ sur ma machine - cela prouve que sys.path est construit dynamiquement par l’interpréteur Python.
Notez également que sys.path commence par une chaîne vide. En pratique, cela signifie que le répertoire courant a la priorité sur tout le reste.
Ainsi, si vous avez un fichier random.py dans votre répertoire courant, et que vous lancez un script foo.py dans ce même répertoire, vous vous retrouvez à utiliser le code dans random.py, et non celui de la bibliothèque standard ! Pour information, la liste de tous les modules de la bibliothèque standard est présente dans la documentation.
Un autre aspect notable de sys.path est qu’il ne contient que deux répertoires dans lesquels l’utilisateur courant peut potentiellement écrire : le chemin courant et le chemin dans ~/.local/lib. Tous les autres (/usr/lib/python3.7/, etc.) sont des chemins “système” et ne peuvent être modifiés que par un compte administrateur (avec root ou sudo, donc).
La situation est semblable sur macOS et Windows 2.
$ python3 foo.py
--------- ---
John 345
Mary-Jane 2
Bob 543
--------- ---
Ici, le module tabulate n’est ni dans la bibliothèque standard, ni écrit par l’auteur du script foo.py. On dit que c’est une bibliothèque tierce.
On peut trouver le code source de tabulate facilement. La question qui se pose alors est: comment faire en sorte que sys.path contienne le module tabulate?
Une autre méthode consiste à partir des sources - par exemple, si le paquet de votre distribution n’est pas assez récent, ou si vous avez besoin de modifier le code de la bibliothèque en question.
La plupart des bibliothèques Python contiennent un setup.py à
la racine de leurs sources. Il sert à plein de choses, la commande install
n’étant qu’une parmi d’autres.
Le fichier setup.py contient en général simplement un import de setuptools, et un appel à la fonction setup(), avec de nombreux arguments :
Par défaut, setup.py essaiera d’écrire dans un des chemins système de
sys.path3, d’où l’utilisation de l’option --user.
Voici à quoi ressemble la sortie de la commande :
$ cd src/tabulate
$ python3 setup.py install --user
running install
...
Copying tabulate-0.8.4-py3.7.egg to /home/dmerej/.local/lib/python3.7/site-packages
...
Installing tabulate script to /home/dmerej/.local/bin
Notez que module a été copié dans ~/.local/lib/python3.7/site-packages/ et le script dans ~/.local/bin. Cela signifie que tous les scripts Python lancés par l’utilisateur courant auront accès au module tabulate.
Notez également qu’un script a été installé dans ~/.local/bin - Une bibliothèque Python peut contenir aussi bien des modules que des scripts.
Un point important est que vous n’avez en général pas besoin de lancer le script directement. Vous pouvez utiliser python3 -m tabulate. Procéder de cette façon est intéressant puisque vous n’avez pas à vous soucier de rajouter le chemin d’installation des scripts dans la variable d’environnement PATH.
pip est un outil qui vient par défaut avec Python34. Vous pouvez également l’installer grâce au script get-pip.py, en lançant python3 get-pip.py --user.
Il est conseillé de toujours lancer pip avec python3 -m pip. De cette façon, vous êtes certains d’utiliser le module pip correspondant à votre binaire python3, et vous ne dépendez pas de ce qu’il y a dans votre PATH.
pip est capable d’interroger le site pypi.org pour retrouver les dépendances, et également de lancer les différents scripts setup.py.
Comme de nombreux outils, il s’utilise à l’aide de commandes. Voici comment installer cli-ui à l’aide de la commande ‘install’ de pip:
On y retrouve les bibliothèques cli-ui et tabulate, bien sûr, mais aussi la bibliothèque gaupol, qui correspond au programme d’édition de sous-titres que j’ai installé à l’aide du gestionnaire de paquets de ma distribution. Précisons que les modules de la bibliothèque standard et ceux utilisés directement par pip sont omis de la liste.
On constate également que chaque bibliothèque possède un numéro de version.
Souvenez-vous que les fichiers systèmes sont contrôlés par votre gestionnaire de paquets.
Les mainteneurs de votre distribution font en sorte qu’ils fonctionnent bien les uns
avec les autres. Par exemple, le paquet python3-cli-ui ne sera mis à jour que lorsque tous les paquets qui en dépendent seront prêts à utiliser la nouvelle API.
En revanche, si vous lancez sudo pip (où pip avec un compte root), vous allez écrire dans ces mêmes répertoire et vous risquez de “casser” certains programmes de votre système.
Supposons deux projets A et B dans votre répertoire personnel. Ils dépendent tous les deux de cli-ui, mais l’un des deux utilise cli-ui 0.7 et l’autre cli-ui 0.9. Que faire ?
La commande python3 -m venv fonctionne en général partout, dès l’installation de Python3 (out of the box, en Anglais), sauf sur Debian et ses dérivées 5.
Si vous utilisez Debian, la commande pourrait ne pas fonctionner. En fonction des messages d’erreur que vous obtenez, il est possible de résoudre le problème en :
installant le paquet python3-venv,
ou en utilisant d’abord pip pour installer virtualenv, avec python3 -m pip install virtualenv --user puis en lançant python3 -m virtualenv foo-venv.
Le répertoire “global” dans ~/.local/lib a disparu
Seuls quelques répertoires systèmes sont présents (ils correspondent plus ou moins à l’emplacement des modules de la bibliothèque standard)
Un répertoire au sein du virtualenv a été rajouté
Ainsi, l’isolation du virtualenv est reflété dans la différence de la valeur de sys.path.
Il faut aussi préciser que le virtualenv n’est pas complètement isolé du reste du système. En particulier, il dépend encore du binaire Python utilisé pour le créer.
Par exemple, si vous utilisez /usr/local/bin/python3.7 -m venv foo-37, le virtualenv dans foo-37 utilisera Python 3.7 et fonctionnera tant que le binaire /usr/local/bin/python3.7 existe.
Cela signifie également qu’il est possible qu’en mettant à jour le paquet python3 sur votre distribution, vous rendiez inutilisables les virtualenvs créés avec l’ancienne version du paquet.
Cette fois, aucune bibliothèque n’est marquée comme déjà installée, et on récupère donc cli-ui et toutes ses dépendances.
On a enfin notre solution pour résoudre notre conflit de dépendances :
on peut simplement créer un virtualenv par projet. Ceci nous permettra
d’avoir effectivement deux versions différentes de cli-ui, isolées les
unes des autres.
Devoir préciser le chemin du virtualenv en entier pour chaque commande peut devenir fastidieux ; heureusement, il est possible d’activer un virtualenv, en lançant une des commandes suivantes :
source foo-venv/bin/activate - si vous utilisez un shell POSIX
source foo-venv/bin/activate.fish - si vous utilisez Fish
foo-venv\bin\activate.bat - sous Windows
Une fois le virtualenv activé, taper python, python3 ou pip utilisera les binaires correspondants dans le virtualenv automatiquement,
et ce, tant que la session du shell sera ouverte.
Le script d’activation ne fait en réalité pas grand-chose à part modifier la variable PATH et rajouter le nom du virtualenv au début de l’invite de commandes :
# Avant
user@host:~/src $ source foo-env/bin/activate
# Après
(foo-env) user@host:~/src $
Pour sortir du virtualenv, entrez la commande deactivate.
Le système de gestions des dépendances de Python peut paraître compliqué et bizarre, surtout venant d’autres langages.
Mon conseil est de toujours suivre ces deux règles :
Un virtualenv par projet et par version de Python
Toujours utiliser pipdepuis un virtualenv
Certes, cela peut paraître fastidieux, mais c’est une méthode qui vous évitera probablement de vous arracher les cheveux (croyez-en mon expérience).
Dans un futur article, nous approfondirons la question, en évoquant d’autres sujets comme PYTHONPATH, le fichier requirements.txt ou des outils comme poetry ou pipenv. À suivre.
C’est une condition suffisante, mais pas nécessaire - on y reviendra. ↩︎
Presque. Il peut arriver que l’utilisateur courant ait les droits d’écriture dans tous les segments de sys.path, en fonction de l’installation de Python. Cela dit, c’est plutôt l’exception que la règle. ↩︎
Cela peut vous paraître étrange à première vue. Il y a de nombreuses raisons historiques à ce comportement, et il n’est pas sûr qu’il puisse être changé un jour. ↩︎
Presque. Parfois il faut installer un paquet supplémentaire, notamment sur les distributions basées sur Debian ↩︎
Je n’ai pas réussi à trouver une explication satisfaisante à ce choix des mainteneurs Debian. Si vous avez des informations à ce sujet, je suis preneur. Mise à jour: Il se trouve que cette décision s’inscrit au sein de la “debian policy”, c’est à dire une liste de règles que doivent respecter tous les programmes maintenus par Debian.↩︎
Cet article est le premier d’un tout nouveau blog (sur https://dmerej.info/blog/fr), contenant exclusivement des articles en français.
Notez l’existence de mon blog anglophone, qui est disponible sur
https://dmerej.info/blog. Pour l’instant, les deux blogs sont complémentaires:
aucun article n’est la traduction directe d’un autre, mais
cela pourra changer un jour, qui sait ?