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.com 1
En guise d’introduction, penchons-nous un peu sur le mot-clé assert
.
def faire_le_café(au_régime=False, sucre=True):
if au_régime:
assert not sucre
Que se passe-t-il lorsque ce code tourne avec au_régime
à True
et sucre
à True
?
>>> faire_le_café(au_régime=True, sucre=True)
Traceback (most recent call last):
File "foo.py", line 7, in <module>
faire_le_café()
File "foo.py", line 5, in faire_le_café
assert not sucre
AssertionError
On constate que assert
a évalué la condition et comme celle-ci était “falsy”, il a levé une exception nommée AssertionError
On peut modifier le message de l’assertion en rajoutant une chaîne de caractères après la virgule :
def faire_le_café(au_régime=False, sucre=True):
if au_régime:
assert not sucre, "tu es au régime: pas de sucre dans le café!"
Et on peut aussi vérifier que assert
ne fait rien si la condition est “truthy” :
>>> x = 42
>>> assert x
# rien
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 :
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”Voir cet article de Sam & Max pour plus de détails.
Voici un exemple minimal :
# dans calc.py
def add_one(x):
return x + 2
# dans test_calc.py
import calc
result = calc.add_one(3)
assert result == 4, "result != 4"
On retrouve l’idée d’utiliser assert
pour indiquer une erreur interne au code. En l’occurrence, si on lance le script test_calc.py
, on va obtenir :
$ python3 test_calc.py
Traceback (most recent call last):
File "test_calc.py", line 4, in <module>
assert result == 4, "result != 4"
AssertionError: result != 4
Notez que le message d’erreur ne nous indique pas la valeur effective de result
, juste sa valeur attendue.
Quoi qu’il en soit, le code dans test_calc.py
nous a permis de trouver un bug dans la fonction add_one
de calc.py
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.
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.
On a vu plus haut comment écrire du code de test “à la main” avec assert
. Étoffons un peu l’exemple :
# dans calc.py
def add_one(x):
return x + 2
def add_two(x):
return x + 2
# dans test_calc.py
result = calc.add_one(3)
assert result == 4
result = calc.add_two(5)
assert result == 7
On constate que tester le code ainsi est fastidieux :
calc_one
est cassé, on ne saura rien sur l’état de calc_two
C’est là que pytest
entre en jeu.
On commence par créer un virtualenv pour calc
et par installer pytest
dedans 3
$ mkdir -p venvs && cd venvs
$ python3 -m venv calc
$ source calc/bin/activate
(calc) $ pip install pytest
Ensuite, on transforme chaque assertion en une fonction commençant par test_
:
import calc
def test_add_one():
result = calc.add_one(3)
assert result == 4, "result != 4"
def test_add_two():
result = calc.add_two(5)
assert result == 7
… et on corrige les bugs :
def add_one(x):
return x + 1
def add_two(x):
return x + 2
Enfin, on lance pytest
en précisant le chemin de fichier de test :
$ pytest test_calc.py
============================= test session starts ==============================
test_calc.py .. [100%]
========================== 2 passed in 0.01 seconds ===========================
Chaque point après test_calc.py
représente un test qui passe. Voyons ce qui arrive si
on ré-introduit un bug :
def add_one(x):
return x + 3
def add_two(x):
return x + 2
$ pytest test_calc.py
============================= test session starts ==============================
test_calc.py F. [100%]
=================================== FAILURES ===================================
_________________________________ test_add_one _________________________________
def test_add_one():
result = calc.add_one(3)
> assert result == 4
E assert 6 == 4
test_calc.py:5: AssertionError
À noter :
add_two
a quand même été lancéF
majusculeOn peut aussi dire à pytest de ne lancer que les tests qui ont échoués à la session précédente :
$ pytest test_calc.py --last-failed
run-last-failure: rerun previous 1 failure
test_calc.py
=================================== FAILURES ===================================
_________________________________ test_add_one _________________________________
Cool, non ?
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 :
def test_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 :
def add_two(x):
return x + 3
def test_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 :
def fonction_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.
Le deuxième effet bénéfique est lié au premier.
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 :
Et voici une procédure pour appliquer ces règles: suivre le cycle de dévelopement suivant :
Si tout cela peut vous semble abstrait, je vous propose une démonstration.
Pour cela, on va utiliser les règles du bowling.
Comme on code en anglais6, on va utiliser les termes anglophones. Voici les règles :
On calcule le score frame par frame :
La dernière frame est spéciale : si on fait un strike, on a droit à deux rolls supplémentaires, et si on fait une spare, on a droit à un roll en plus.
La règle 0 de tout bon programmeur est : “réfléchir avant de coder”. Prenons le temps de réfléchir un peu, donc.
On peut se dire que pour calculer le score, une bonne façon sera d’avoir une classe Game
avec deux méthodes:
roll()
, qui sera appelée à chaque lancer avec le nombre de quilles renversées en paramètrescore()
, qui renverra le score finalAu niveau du découpage en classes, on peut partir du diagramme suivant:
On a:
Game
qui contient des frames
Frame
Roll
Roll
contenant un attribut pins
correspondant au nombre
de quilles renversées.TenthFrame
, qui hérite de la classe Frame
et implémente
les règles spécifiques au dernier lancer.Retours aux règles:
Comme pour l’instant on a aucun code, la seule chose qu’on puisse faire c’est écrire un test qui échoue.
On crée un virtualenv pour notre code:
$ python3 -m venvs/bowling
$ source venvs/bowling/bin/activate
$ pip install pytest
On créé un fichier test_bowling.py
qui contient juste une ligne:
import bowling
On lance les tests:
$ pytest test_bowling.py
test_bowling.py:1: in <module>
import bowling
E ModuleNotFoundError: No module named 'bowling'
On a une erreur, donc on arrête d’écrire du code de test (règle 2), et on passe à l’état suivant.
Pour faire passer le test, il suffit de créer un fichier bowling.py
vide.
$ pytest test_bowling.py
collected 0 items
========================= no tests ran in 0.34 seconds ========================
Bon, clairement ici il n’y a rien à refactorer (règle 4), donc on repart au début du cycle.
Ici on cherche à faire échouer le test le plus simplement possible.
Commençons simplement par vérifier qu’on peut instancier la class Game :
import bowling
def test_can_create_game():
game = bowling.Game()
$ pytest test_bowling.py
> game = bowling.Game()
E AttributeError: module 'bowling' has no attribute 'Game'
Le test échoue, faisons-le passer :
class Game:
pass
Toujours rien à refactorer …
Écrivons un test pour roll()
:
def test_can_roll():
game = bowling.Game()
game.roll(0)
$ pytest test_bowling.py
> game.roll(0)
E AttributeError: 'Game' object has no attribute 'roll'
Faisons passer les tests en rajoutant une méthode :
class Game:
def roll(self, pins):
pass
Toujours pas de refactoring en vue. En même temps, on n’a que 6 lignes de test et 3 lignes de code de production …
On continue à tester les méthodes de la classe Game, de la façon la plus simple possible :
def test_can_score():
game = bowling.Game()
game.roll(0)
score = game.score()
$ pytest test_bowling.py
> game.roll(0)
E AttributeError: 'Game' object has no attribute 'roll'
On fait passer le test, toujours de la façon la plus simple possible :
class Game:
def roll(self, pins):
pass
def score(self):
pass
Le code production a l’air impossible à refactorer, mais jetons un œil aux tests :
import bowling
def test_can_create_game():
game = bowling.Game()
def test_can_roll():
game = bowling.Game()
game.roll(0)
def test_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.
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 :
def test_score_is_zero_after_gutter():
game = bowling.Game()
game.roll(0)
score = game.score()
assert score == 0
gutter
signifie “gouttière” en anglais et désigne un lancer qui finit dans la rigole (et donc ne renverse aucune quille)
$ pytest test_bowling.py
> assert score == 0
E assert None == 0
Faisons le passer :
class Game:
def roll(self, pins):
pass
def score(self):
return 0
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.
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 :
def test_all_ones():
game = bowling.Game()
for roll in range(20):
game.roll(1)
score = game.score()
assert score == 20
> assert score == 20
E assert 0 == 20
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.
class Game:
def __init__(self):
self.knocked_pins = 0
def roll(self, pins):
self.knocked_pins += pins
def score(self):
return self.knocked_pins
Les deux tests passent, mission accomplie.
Encore une fois, concentrons-nous sur les tests.
def test_score_is_zero_after_gutter():
game = bowling.Game()
game.roll(0)
score = game.score()
assert score == 0
def test_all_ones():
game = bowling.Game()
for roll in range(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)
:
def test_gutter_game():
game = bowling.Game()
for roll in range(20):
game.roll(0)
score = game.score()
assert score == 0
def test_all_ones():
game = bowling.Game()
for roll in range(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
:
def roll_many(game, count, value):
for roll in range(count):
game.roll(value)
def test_gutter_game():
game = bowling.Game()
roll_many(game, 20, 0)
score = game.score()
assert score == 0
def test_all_ones():
game = bowling.Game()
roll_many(game, 20, 1)
score = game.score()
assert score == 20
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 :
def test_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
score = game.score()
> assert score == 16
E assert 13 == 16
Et là, on se retrouve coincé. Il semble impossible d’implémenter la gestion des spares sans revoir le code de production en profondeur :
def roll(self, pins):
# TODO: get the knocked pin in the next
# roll if we are in a spare ???
self.knocked_pins += pins
C’est un état dans lequel on peut parfois se retrouver. La solution ? Faire un pas en arrière pour prendre du recul.
On peut commencer par désactiver le test qui nous ennuie :
import pytest
@pytest.mark.skip
def test_one_spare():
...
Ensuite, on peut regarder le code de production dans le blanc des yeux :
def roll(self, pins):
self.knocked_pins += pins
def score(self):
return self.knocked_pins
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.
On remplace donc l’attribut knocked_pins()
par une liste de rolls et un index:
class Game:
def __init__(self):
self.rolls = [0] * 21
self.roll_index = 0
def roll(self, pins):
self.rolls[self.roll_index] = pins
self.roll_index += 1
def score(self):
result = 0
for 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)
Relançons les tests :
test_bowling.py ..s [100%]
===================== 2 passed, 1 skipped in 0.01 seconds ======================
(notez le ’s' pour ‘skipped’)
L’algorithme est toujours éronné, mais on sent qu’on une meilleure chance de réussir à gérer les spares.
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
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 :
def score(self):
result = 0
i = 0
for frame in range(10):
if self.rolls[i] + self.rolls[i + 1] == 10: # spare
result += 10
result += self.rolls[i + 2]
i += 2
else:
result += self.rolls[i]
result += self.rolls[i + 1]
i += 2
return 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.
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 frame
def is_spare(self, i):
return self.rolls[i] + self.rolls[i + 1] == 10
def score(self):
result = 0
i = 0
for frame in range(10):
if self.is_spare(i):
result += 10
result += self.rolls[i + 2]
i += 2
else:
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) :
def test_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
.
import bowling
import pytest
def roll_many(game, count, value):
for roll in range(count):
game.roll(value)
def roll_spare(game):
game.roll(5)
game.roll(5)
def test_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()
:
import bowling
import pytest
class GameTest(bowling.Game):
def roll_many(self, count, value):
for roll in range(count):
self.roll(value)
def roll_spare(self):
self.roll(5)
self.roll(5)
def test_gutter_game():
game = GameTest()
game.roll_many(20, 0)
score = game.score()
assert score == 0
def test_all_ones():
game = bowling.GameTest()
game.roll_many(20, 1)
score = game.score()
assert score == 20
def test_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.
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 :
class GameTest:
...
def roll_spare(self):
...
def roll_strike(self):
self.roll(10)
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
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 :
On peut maintenant corriger notre faux positif, relancer les tests, vérifier qu’ils échouent pour la bonne raison et passer à l’étape suivante.
______________________________ 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
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.
class Game:
...
def is_spare(self, i):
return self.rolls[i] + self.rolls[i + 1] == 10
def is_strike(self, i):
return self.rolls[i] == 10
def score(self):
result = 0
i = 0
for frame in range(10):
if self.is_strike(i):
result += 10
result += self.rolls[i + 1]
result += self.rolls[i + 2]
i += 1
elif self.is_spare(i):
result += 10
result += self.rolls[i + 2]
i += 2
else:
result += self.rolls[i]
result += self.rolls[i + 1]
i += 2
return 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 !
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 :
class Game:
...
def is_strike(self, i):
return self.rolls[i] == 10
def is_spare(self, i):
return self.rolls[i] + self.rolls[i + 1] == 10
def next_two_rolls_for_strike(self, i):
return self.rolls[i + 1] + self.rolls[i + 2]
def next_roll_for_spare(self, i):
return self.rolls[i + 2]
def rolls_in_frame(self, i):
return self.rolls[i] + self.rolls[i + 1]
def score(self):
result = 0
i = 0
for frame in range(10):
if self.is_strike(i):
result += 10
result += self.next_two_rolls_for_strike(i)
i += 1
elif self.is_spare(i):
result += 10
result += self.next_roll_for_spare(i)
i += 2
else:
result += self.rolls_in_frame(i)
i += 2
return result
On approche du but, il ne reste plus qu’à gérer la dernière frame.
É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 :
def test_perfect_game():
game = GameTest()
for i in range(0, 12):
game.roll_strike()
assert game.score() == 300
On lance les tests, et…
collected 5 items
test_bowling.py ..... [100%]
============================= 5 passed in 0.02 seconds ==============================
Ils passent ?
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 = 0
for frame in range(10):
if self.is_strike(i):
result += 10
result += self.next_two_rolls_for_strike(i)
i += 1
elif self.is_spare(i):
result += 10
result += self.next_roll_for_spare(i)
i += 2
else:
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.
Plusieurs remarques :
1/ La méthode roll()
peut être appelée un nombre trop grand de fois, comme le prouve le test suivant :
def test_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. ↩︎
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
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.
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.
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é.
2to3
et faites un commit avec le patch générésix
pour rendre
le code polyglotte.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__ import print
print("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.
Ç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é :
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.
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__.py
import bar
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:
import foo.bar
(C’est ma solution
préférée)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 …
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)
class Foo:
pass
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.
import six
my_iterator = six.moves.range(0, 3)
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 in list(my_dict.keys()):
if something(key):
del my_dict[key]
Ou
for key in list(six.iterkeys(my_dict)):
if something(key):
del my_dict[key]
si vous préférez.
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.
En Python2
, tout est ordonné:
>>> print sorted(["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
class MaClassPerso():
...
def __lt__(self, other):
return self.quelque_chose < other.quelque_chose
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 :
from setuptools import 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.
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:
--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 !
Note : cet article reprend en grande partie le cours donné à l’École du Logiciel Libre le 4 mai 2019.
Quelques rappels pour commencer.
Soit le code suivant :
import foo
foo.bar()
Ce code fonctionne s’il y a un ficher foo.py
quelque part qui contient la fonction bar
1
Ce fichier peut être présent soit dans le répertoire courant, soit dans la bibliothèque standard Python.
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
.
En Python, il existe une variable path
prédéfinie dans le module sys
qui fonctionne de manière similaire.
Si j’essaye de l’afficher sur mon Arch Linux, voici ce que j’obtiens :
>>> import sys
>>> sys.path
[
"",
"/usr/lib/python3.7",
"/usr/lib/python3.7/lib-dynload",
"/home/dmerej/.local/lib/python3.7/",
"/usr/lib/python3.7/site-packages",
]
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.
Prenons un exemple :
# dans foo.py
import tabulate
scores = [
["John", 345],
["Mary-Jane", 2],
["Bob", 543],
]
table = tabulate.tabulate(scores)
print(table)
$ 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
?
Eh bien, plusieurs solutions s’offrent à vous.
Si vous utilisez une distribution Linux, peut-être pourrez-vous utiliser votre gestionnaire de paquets :
$ sudo apt install python3-tabulate
Comme vous lancez votre gestionnaire de paquets avec sudo
, celui-ci sera capable d’écrire dans les chemins système de sys.path
.
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.
Voici une marche à suivre possible :
src/tabulate
src/tabulate
et lancer python3 setup.py install --user
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 :
# tabulate/setup.py
from setuptools import setup
setup(
name='tabulate',
version='0.8.1',
description='Pretty-print tabular data',
py_modules=["tabulate"],
scripts=["bin/tabulate"],
...
)
Par défaut, setup.py
essaiera d’écrire dans un des chemins système de
sys.path
3, 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.
Prenons une autre bibliothèque : cli-ui
.
Elle permet d’afficher du texte en couleur dans un terminal
import cli_ui
cli_ui.info("Ceci est en", cli_ui.red, "rouge")
Elle permet également d’afficher des tableaux en couleur :
headers=["name", "score"]
data = [
[(bold, "John"), (green, 10.0)],
[(bold, "Jane"), (green, 5.0)],
]
cli_ui.info_table(data, headers=headers)
Pour ce faire, elle repose sur la bibliothèque tabulate
vue précédemment. On dit que cli-ui
dépend de tabulate
.
La déclaration de la dépendance de cli-ui
vers tabulate
s’effectue également dans le fichier setup.py
:
setup(
name="cli-ui",
version="0.9.1",
install_requires=[
"tabulate",
...
],
...
)
On comprend dès lors qu’il doit nécessairement exister un annuaire permettant de relier les noms de dépendances à leur code source.
Cet annuaire, c’est le site pypi.org. Vous y trouverez les pages correspondant à tabulate et cli-ui.
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
:
$ python3 -m pip install cli-ui --user
Collecting cli-ui
...
Requirement already satisfied: unidecode in /usr/lib/python3.7/site-packages (from cli-ui) (1.0.23)
Requirement already satisfied: colorama in /usr/lib/python3.7/site-packages (from cli-ui) (0.4.1)
Requirement already satisfied: tabulate in /mnt/data/dmerej/src/python-tabulate (from cli-ui) (0.8.4)
Installing collected packages: cli-ui
Successfully installed cli-ui-0.9.1
On constate ici quelques limitations de pip
:
--user
(de la même façon que lorsqu’on lance setup.py
à la main)En revanche, pip
contient de nombreuses fonctionnalités intéressantes:
freeze
.Voici un extrait de la commande python3 -m pip freeze
au moment de la rédaction de cet article sur ma machine:
$ python3 -m pip freeze
apipkg==1.5
cli-ui==0.9.1
gaupol==1.5
tabulate==0.8.4
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.
Les numéros de version remplissent plusieurs rôles, mais l’un des principaux est de spécifier des changements incompatibles.
Par exemple, pour cli-ui
, la façon d’appeler la fonction ask_choice
a changé entre les versions 0.7 et 0.8, comme le montre le changelog:
the list of choices used by ask_choice is now a named keyword argument:
# Old (<= 0.7)
ask_choice("select a fruit", ["apple", "banana"])
# New (>= 0.8)
ask_choice("select a fruit", choices=["apple", "banana"])
Ceci s’appelle un changement d’API.
Plusieurs possibilités:
setup(
install_requires=[
"cli-ui < 0.8",
...
]
)
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.
Mais il y a un autre problème encore pire.
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 solution est d’utiliser un environnement virtuel (virtualenv en abrégé). C’est un répertoire isolé du reste du système.
Pour créer un virtualenv il faut utiliser la commande:
$ python -m venv /chemin/vers/virtualenv
où /chemin/vers/virtualenv
est le dossier cible. Les dossiers parents seront créés si nécessaire par le module venv
.
En pratique, on préfère utiliser un chemin qui n’existe pas encore, typiquement :
$ cd /chemin/vers/project
$ python -m .venv
Ici on a utilisé le répertoire relatif .venv
.
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 :
python3-venv
,pip
pour installer virtualenv
, avec python3 -m pip install virtualenv --user
puis en lançant python3 -m virtualenv foo-venv
.Ce répertoire contient de nombreux fichiers et dossiers, et notamment un binaire dans foo-venv/bin/python3
.
Voyons comment il se comporte en le comparant au binaire /usr/bin/python3
habituel :
$ /usr/bin/python3 -c 'import sys; print(sys.path)'
['',
...
'/usr/lib/python3.7',
'/usr/lib/python3.7.zip',
'/usr/lib/python3.7/lib-dynload',
'/home/dmerej/.local/lib/python3.7/site-packages',
'/usr/lib/python3.7/site-packages'
]
$ /home/dmerej/foo-venv/bin/python -c 'import sys; print(sys.path)'
['',
'/usr/lib/python3.7',
'/usr/lib/python3.7.zip',
'/usr/lib/python3.7/lib-dynload',
'/home/dmerej/foo-venv/lib/python3.7/site-packages,
]
À noter:
~/.local/lib
a disparuAinsi, 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.
D’après ce qui précède, le virtualenv ne devrait contenir aucun module en dehors de la bibliothèque standard et de pip
lui-même.
On peut s’en assurer en lançant python3 -m pip freeze
depuis le virtualenv et en vérifiant que rien ne s’affiche.
$ python3 -m pip freeze
# de nombreuses bibliothèques en dehors du virtualenv
apipkg==1.5
cli-ui==0.9.1
gaupol==1.5
tabulate==0.8.4
$ /home/dmerej/foo-venv/bin/python3 -m pip freeze
# rien :)
On peut alors utiliser le module pip
du virtualenv pour installer des bibliothèques dans celui-ci :
$ /home/dmerej/foo-venv/bin/python3 -m pip install cli-ui
Collecting cli-ui
Using cached https://pythonhosted.org/..cli_ui-0.9.1-py3-none-any.whl
Collecting colorama (from cli-ui)
Using cached https://pythonhosted.org/..colorama-0.4.1-py2.py3-none-any.whl
Collecting unidecode (from cli-ui)
Using cached https://pythonhosted.org/..Unidecode-1.0.23-py2.py3-none-any.whl
Collecting tabulate (from cli-ui)
Installing collected packages: colorama, unidecode, tabulate, cli-ui
Successfully installed cli-ui-0.9.1 colorama-0.4.1 tabulate-0.8.3
unidecode-1.0.23
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 POSIXsource foo-venv/bin/activate.fish
- si vous utilisez Fishfoo-venv\bin\activate.bat
- sous WindowsUne 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 :
pip
depuis un virtualenvCertes, 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. ↩︎
Bonjour à tous et bienvenue !
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 ?
Sans plus attendre, vous pouvez lire mon premier article sur Python et les bibliothèques tierces.