Planet Python Fr

Previous [12] Next

2019-07-05

AFPy - Actualités

La PyCon Fr 2019 c'est pour bientôt !

TL;DR: Ce sera à Bordeaux du 31 octobre au 3 novembre 2019.

by AFPy - Actualités at 2019-07-05 16:04

2019-06-27

AFPy - Emplois

Job Opportunities @Odoo

We look for the best Js & Py developers!

by AFPy - Emplois at 2019-06-27 14:13

2019-06-17

AFPy - Emplois

Développeur Python/Django H/F - CDI - Paris 8e

Offre d'emploi pour développeur/se Python/Django. Poste basé à Paris 8e.

by AFPy - Emplois at 2019-06-17 15:34

2019-06-08

Zeste de savoir - Tutoriels

Faire un bot Discord simple avec les webhooks

Un petit bot sans bibliothèque et sans se prendre la tête !

by Eskimon at 2019-06-08 12:29

2019-06-01

Le blog de Dim'

Écriture de tests en Python: pytest et TDD

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

Assertions #

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

À quoi servent les assertions #

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”

Voir cet article de Sam & Max pour plus de détails.

Qu’est-ce qu’un test ? #

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

Code de test et code de production #

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.

Les deux valeurs 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.

pytest #

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 :

  • Les valeurs effectives ne sont pas affichées par défaut
  • Le programme de test va s’arrêter à la première erreur, donc si calc_one est cassé, on ne saura rien sur l’état de calc_two
  • On ne peut pas facilement isoler les tests à lancer

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 :

  • Le test pour add_two a quand même été lancé
  • La valeur effective est affiché sous la ligne d’assert
  • La backtrace a été affiché
  • On a une vue du code qui a produit le bug
  • Le test qui a échoué est affiché avec un F majuscule

On 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 ?

Limites des tests #

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.

Empêcher les régressions #

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.

Refactorer sans peur #

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.

Une discipline #

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”
  • Retour à l’étape “red”.

TDD en pratique #

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 :

  • Un jeu de bowling comporte 10 carreaux (ou frames).
  • Chaque frame comporte deux lancers (ou roll) et 10 quilles (ou pins)
  • Si on renverse toutes les quilles en un lancer, on marque un abat (ou strike)
  • Si on renverse toutes les quilles dans un même carreau, on marque une réserve (ou spare)

On calcule le score frame par frame :

  • Si on fait un strike, on marque 10 points, plus les points obtenus à la frame suivante (donc 2 rolls)
  • Si on fait une spare, on marque 10 points, plus les points obtenus au lancer suivant (donc juste le roll suivant)
  • Sinon on marque le total de quilles renversées dans la 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.

Un peu d’architecture #

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ètre
  • score(), qui renverra le score final

Au niveau du découpage en classes, on peut partir du diagramme suivant:

class diagram

On a:

  • Une classe Game qui contient des frames
  • Chaque frame est une instance de la class Frame
  • Chaque frame contient une ou deux instances de la class Roll
  • Une classe Roll contenant un attribut pins correspondant au nombre de quilles renversées.
  • Une classe TenthFrame, qui hérite de la classe Frame et implémente les règles spécifiques au dernier lancer.

C’est parti #

Retours aux 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

Comme pour l’instant on a aucun code, la seule chose qu’on puisse faire c’est écrire un test qui échoue.

⁂ RED⁂

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.

⁂ GREEN⁂

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.

⁂ RED⁂

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 :

⁂GREEN⁂
class Game:
    pass

Toujours rien à refactorer …

⁂RED⁂

É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'
⁂GREEN⁂

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 …

⁂RED⁂

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'
⁂GREEN⁂

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
⁂ REFACTOR⁂

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.

⁂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 :

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
⁂GREEN⁂

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.

⁂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 :

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
⁂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.

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.

⁂REFACTOR⁂

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
⁂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 :

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
⁂GREEN⁂

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.

⁂REFACTOR⁂

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)

  • 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.

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.

⁂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 :

    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.

⁂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 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.

⁂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 :

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 :

  1. Il faut toujours avoir une vague idée des tests qui vont échouer et de quelle manière
  2. 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.

______________________________ 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
⁂GREEN⁂

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 !

⁂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 :

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.

⁂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 :

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.

Conclusions #

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.

Pour aller plus loin #

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

Conclusion #

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.

À +


  1. C’est payant, c’est en anglais, les exemples sont en Java, mais c’est vraiment très bien. ↩︎

  2. Par exemple, quand on lance python avec l’option -O ↩︎

  3. Voir cet article pour comprendre pourquoi on procède ansi. ↩︎

  4. Il existe de nombreux outils pour palier aux limitations des tests, mais on en parlera une prochaine fois. ↩︎

  5. Les trois premières règles sont de Uncle Bob, la dernière est de moi. ↩︎

  6. 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. ↩︎

  7. Si cette façon de commenter du code vous intrigue, vous pouvez lire cet excellent article (en anglais) pour plus de détails. ↩︎

by Le blog de Dim' at 2019-06-01 18:21

2019-05-29

AFPy - Emplois

Développeur python / django

Nous recherchons un développeur ou une développeuse Python de profil bac+5 ou d’expérience professionnelle équivalente, maîtrisant les environnements GNU/Linux

by AFPy - Emplois at 2019-05-29 13:34

2019-05-15

Le blog de Dim'

Porter un gros project vers Python3

Port d’un gros projet vers Python3 - Retour d’expérience #

Introduction : le projet en question #

Il s’agit d’une collection d’outils en ligne de commande que j’ai développé dans mon ancienne boîte, les points importants étant

  • la taille du projet: un peu moins de 30,000 lignes de code, et
  • l’ancienneté du code: près de 6 ans, qui initialement a tourné avec Python 2.6 (eh oui)

Le challenge #

On veut garder la rétro-compat vers Python2.7, histoire que la transition se fasse en douceur.

On veut aussi pouvoir continuer les développements en Python2 sans attendre la fin du port.

À faire avant de commencer à porter #

Visez la bonne version de Python #

Déjà, si vous supportez à la fois Python2 et Python3, vous pouvez (et devez) ignorer les versions de Python comprises entre 3.0 et 3.2 inclus.

Les utilisateurs de distros “archaïques” (genre Ubuntu 12.04) avec un vieux Python3 pourront tout à fait continuer à utiliser la version Python2.

Ayez une bonne couverture de tests #

Ne vous lancez pas dans le port sans une bonne couverture de tests.

Les changements entre Python2 et Python3 sont parfois très subtils, donc sans une bonne couverture de tests vous risquez d’introduire pas mal de régressions.

Dans mon cas, j’avais une couverture de 80%, et la plupart des problèmes ont été trouvés par les (nombreux) tests automatiques (un peu plus de 900)

Le port proprement dit #

Marche à suivre #

Voici les étapes que j’ai suivies. Il faut savoir qu’à la base je comptais passer directement en Python3, sans être compatible Python2, mais en cours de route je me suis aperçu que ça ne coûtait pas très cher de rendre le code “polyglotte” (c’est comme ça qu’on dit) une fois le gros du travail pour Python3 effectué.

  1. Lancez 2to3 et faites un commit avec le patch généré
  2. Lancez les tests en Python3 jusqu’à ce qu’ils passent tous
  3. Relancez tous les tests en Python2, en utilisant six pour rendre le code polyglotte.
  4. Assurez vous que tous les tests continuent à passer en Python3, commitez et poussez.

Note 1 : je ne connaissais pas python-future à l’époque. Il contient un outil appelé futurize qui transforme directement du code Python2 en code polyglotte. Si vous avez des retours à faire sur cet outil, partagez !

Note 2 : Vous n’êtes bien sûr pas obligés d’utiliser six si vous n’avez pas envie. Vous pouvez vous en sortir avec des if sys.version_info()[1] < 3, et autres from __future__ import (voir plus bas). Mais certaines fonctionnalités de six sont compliquées à ré-implémenter à la main.

Note 3 : il existe aussi pies comme alternative à six. Voir ici pour une liste des différences avec six. Personnellement, je trouve pies un peu trop “magique” et je préfère rester explicite. De plus, six semble être devenu le “standard” pour le code Python polyglotte.

Voyons maintenant quelques exemples de modifications à effectuer.

print #

C’est le changement qui a fait le plus de bruit. Il est très facile de faire du code polyglotte quand on utilise print. Il suffit de faire le bon import au début du fichier.

from __future__ 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.

bytes, str, unicode #

Ça c’est le gros morceau.

Il n’y a pas de solution miracle, partout où vous avez des chaînes de caractères, il va falloir savoir si vous voulez une chaîne de caractères “encodée” (str en Python2, bytes en Python3) ou “décodée” (unicode en Python2, str en Python3)

Deux articles de Sam qui abordent très bien la question:

Allez les (re)-lire si c’est pas déjà fait.

En résumé :

  • Utilisez UTF-8
  • Décodez toutes les entrées
  • Encodez toutes les sorties

J’ai vu conseiller de faire from __future__ import unicode_literals:

# avec from __future__ import unicode_literals
a = "foo"
>>> type(a)
<type 'unicode'>

# sans
a = "foo"
>>> type(a)
<type 'str'>

Personnellement je m’en suis sorti sans. À vous de voir.

Les imports relatifs et absolus #

Personnellement, j’ai tendance à n’utiliser que des imports absolus.

Faisons l’hypothèse que vous avez installé un module externe bar, dans votre système (ou dans votre virtualenv) et que vous avez déjà un fichier bar.py dans vos sources.

Les imports absolus ne changent pas l’ordre de résolution quand vous n’êtes pas dans un paquet. Si vous avez un fichier foo.py et un fichier bar.py côte à côte, Python trouvera bar dans le répertoire courant.

En revanche, si vous avec une arborescence comme suit :

src
  foo
      __init__.py
      bar.py

Avec

# in foo/__init__.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:

  • Changer le nom de votre module pour éviter les conflits.
  • Utiliser systématiquement import foo.bar (C’est ma solution préférée)

La division #

Même principe que pour print. Vous pouvez faire

from __future__ import division

et / fera toujours une division flottante, même utilisé avec des entiers.

Pour retrouver la division entière, utilisez //.

Example:

>>> 5/2
>>> 2.5

>>> 3

Note: celui-ci est assez vicieux à détecter …

Les changements de noms #

De manière générale, le module six.moves contient tout ce qu’il faut pour résoudre les problèmes de changement de noms.

Allez voir la table des cas traités par six ici

six est notamment indispensable pour supporter les métaclasses, dont la syntaxe a complètement changé entre Python2 et Python3. (Ne vous amusez pas à recoder ça vous-mêmes, c’est velu)

Avec six, vous pouvez écrire

@six.add_metaclass(Meta)
class Foo:
    pass

range et xrange #

En Python2, range() est “gourmand” et retourne la liste entière dès qu’on l’appelle, alors qu’en Python3, range() est “feignant” et retourne un itérateur produisant les éléments sur demande. En Python2, si vous voulez un itérateur, il faut utiliser xrange().

Partant de là, vous avez deux solutions:

  • Utiliser range tout le temps, même quand vous 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)

Les “vues” des dictionnaires #

On va prendre un exemple:

my_dict = { "a" : 1 }
keys = my_dict.keys()

Quand vous lancez 2to3, ce code est remplacé par:

my_dict = { "a" : 1 }
keys = list(my_dict.keys())

C’est très laid :/

En fait en Python3, keys() retourne une “vue”, ce qui est différent de la liste que vous avez en Python2, mais qui est aussi différent de l’itérateur que vous obtenez avec iterkeys() en Python2. En vrai ce sont des view objects.

La plupart du temps, cependant, vous voulez juste itérer sur les clés et donc je recommande d’utiliser 2to3 avec --nofix=dict.

Bien sûr, keys() est plus lent en Python2, mais comme pour range vous pouvez ignorer ce détail la plupart du temps.

Faites attention cependant, le code plantera si vous faites :

my_dict = { "a" : 1 }
keys = my_dict.keys()
keys.sort()

La raison est que les vues n’ont pas de méthode sort. À la place, utilisez :

my_dict = { "a" : 1 }
keys = sorted(my_dict.keys())

Enfin, il existe un cas pathologique : celui où le dictionnaire change pendant que vous itérez sur les clés, par exemple:

for key in my_dict.keys():
    if something(key):
        del my_dict[key]

Là, pas le choix, il faut faire :

for key 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.

Les exceptions #

En Python2, vous pouvez toujours écrire:

raise MyException, message

try:
    # ....
except MyException, e:
    # ...
    # Do something with e.message

C’est une très vielle syntaxe.

Le code peut être réécrit comme suit, et sera polyglotte :

raise MyException(message)

try:
    # ....
except MyException as e:
    # ....
    # Do something with e.args

Notez l’utilisation de e.args (une liste), et non e.message. L’attribut message (une string) n’existe que dans Python2. Vous pouvez utiliser .args[0] pour récupérer le message d’une façon polyglotte.

Comparer des pommes et des bananes #

En Python2, tout est ordonné:

>>> 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

Le ficher setup.py #

Assurez vous de spécifier les versions de Python supportées dans votre setup.py

Par exemple, si vous supportez à la fois Python2 et Python3, utilisez :


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.

Deux trois mots sur l’intégration continue #

Comme mentionné plus haut, le développement du projet a dû continuer sans attendre que le support de Python3 soit mergé.

Le port a donc dû se faire dans une autre branche (que j’ai appelé six)

Du coup, comment faire pour que la branche ‘six’ reste à jour ?

La solution passe par l’intégration continue. Dans mon cas j’utilise jenkins

À chaque commit sur la branche de développement, voici ce qu’il se passe:

  • La branche ‘six’ est rebasée
  • Les tests sont lancés avec Python2 puis Python3
  • La branche est poussée (avec --force).

Si l’une des étapes ne fonctionne pas (par exemple, le rebase ne passe pas à cause de conflits, ou bien l’une des suites de test échoue), l’équipe est prévenue par mail.

Ainsi la branche six continue d’être “vivante” et il est trivial et sans risque de la fusionner dans la branche de développement au moment opportun.

Conclusion #

J’aimerais remercier Eric S. Raymond qui m’a donné l’idée de ce billet suite à un article sur son blog et m’a autorisé à contribuer à son HOWTO, suite à ma réponse

N’hésitez pas en commentaire à partager votre propre expérience (surtout si vous avez procédé différemment) ou vos questions, j’essaierai d’y répondre.

Il vous reste jusqu’à la fin de l’année avant l’arrêt du support de Python2 en 2020, et ce coup-là il n’y aura probablement pas de report. Au boulot !

by Le blog de Dim' at 2019-05-15 12:38

2019-05-05

Le blog de Dim'

Utiliser des bibliothèques tierces avec Python

Note : cet article reprend en grande partie le cours donné à l’École du Logiciel Libre le 4 mai 2019.

Quelques rappels pour commencer.

Importer un module #

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.

La variable PATH #

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.

sys.path #

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.

Bibliothèques tierces #

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.

Le gestionnaire de paquets #

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.

À la main #

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 :

  1. Récupérer les sources de la version qui vous intéresse dans la section téléchargement de bitbucket.
  2. Extraire l’archive, par exemple dans src/tabulate
  3. Se rendre dans src/tabulate et lancer python3 setup.py install --user

Anatomie du fichier setup.py #

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"],
  ...
)

Résultat de l’invocation de setup.py #

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.

Dépendances #

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.

Déclaration des dépendances #

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",
     ...
  ],
  ...
)

pypi.org #

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 #

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:

  • Il faut penser à utiliser --user (de la même façon que lorsqu’on lance setup.py à la main)
  • Si le paquet est déjà installé dans le système, pip ne saura pas le mettre à jour - il faudra passer par le gestionnaire de paquet de la distribution

En revanche, pip contient de nombreuses fonctionnalités intéressantes:

  • Il est capable de désinstaller des bibliothèques (à condition toutefois qu’elles ne soient pas dans un répertoire système)
  • Il est aussi capable d’afficher la liste complète des bibliothèques Python accessibles par l’utilisateur courant avec 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.

Numéros 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.

Réagir aux changements d’API #

Plusieurs possibilités:

  • On peut bien sûr adapter le code pour utiliser la nouvelle API, mais cela n’est pas toujours possible ni souhaitable.
  • Une autre solution est de spécifier des contraintes sur le numéro de version dans la déclaration des dépendances. Par exemple :
setup(
  install_requires=[
    "cli-ui < 0.8",
    ...
  ]
)

Aparté : pourquoi éviter sudo pip #

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.

Conflit de dépendances #

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 ?

Environnements virtuels #

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

/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.

Aparté : python3 -m venv sur Debian #

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.

Comportement de python dans le virtualenv #

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:

  • 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.

Comportement de pip dans le virtualenv #

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.

Activer un virtualenv #

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.

Conclusion #

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 pip depuis 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.


  1. C’est une condition suffisante, mais pas nécessaire - on y reviendra. ↩︎

  2. 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. ↩︎

  3. 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. ↩︎

  4. Presque. Parfois il faut installer un paquet supplémentaire, notamment sur les distributions basées sur Debian ↩︎

  5. 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. ↩︎

by Le blog de Dim' at 2019-05-05 13:10

Le blog de Dim'

Bonjour, monde

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.

by Le blog de Dim' at 2019-05-05 13:08

2019-04-30

AFPy - Emplois

Développeur Python

Pickmeup is currently seeking for, one of its clients, a Python back end developer The mission is based in Paris (10), for freelance only.

by AFPy - Emplois at 2019-04-30 14:31

2019-04-29

AFPy - Emplois

Back End Developer/Data Warehouse/Python

We are looking for a Back End Developer to work on a number of user-facing and internal full-stack applications. We’re a logistically complex, technology-enabled business, and the majority of our work goes towards supporting operations and services which are the core of the host and guest experience. Le poste à pourvoir est dans le Marais à Paris, contrat temps plein CDI ou Freelance

by AFPy - Emplois at 2019-04-29 14:36

2019-04-17

AFPy - Emplois

Ingénieur en informatique scientifique.

Au coeur d’un laboratoire de recherche qui produit et expérimente du logiciel, les missions de l’ingénieur consistent à rendre plus robustes et plus performants des codes scientifiques.

by AFPy - Emplois at 2019-04-17 13:23

2019-04-12

AFPy - Emplois

Financial Software Engineer - Real Time Portfolio Management

Au sein de Murex, les équipes de Consultants de l’entité « Product Evolution Services » ont la responsabilité du product management et le rôle d’experts produit auprès de nos clients. Dans un contexte d’évolution permanente des marchés financiers, ils interviennent sur des problématiques des domaines Trading, Financial Engineering, Enterprise Risk Management, Operations, Finance et Repositories. Le poste concerne l’équipe RTPM – Real Time Portfolio Management. Cette équipe est responsable des outils génériques de portfolio management utilisés par tout types de desk, allant de Cash et linéaire aux exotiques. Dans cet environnement, votre rôle sera de : Participer à l’exécution de la vision produit et aux maintenances évolutives du produit Participer à l’écriture des spécifications des améliorations à apporter au produit. Gérer des demandes clients en traitant les incidents et en faisant le suivi des corrections en collaborant aves les équipes de développeurs dans un environnement Agile. Donner des formations ou des présentations du module en interne et aux clients, en France ou à l’étranger. Participer au développement des documentations ou des best practices. Se tenir constamment informés des nouvelles évolutions métiers ou technologiques liées aux marchés financiers. Les + de l'offre : Intégrer une communauté leader sur son marché. Bénéficier d’une formation riche, interne et externe, sur le produit, le métier et la relation client Evoluer dans un environnement Agile et international.

by AFPy - Emplois at 2019-04-12 14:19

2019-03-30

AFPy - Emplois

Formateur python / initiation à la programmation

Initiation d'une cible lycéenne à la programmation, sur une semaine, en juillet à paris. Pourra être suivie d'autres missions de ce type.

by AFPy - Emplois at 2019-03-30 18:22

2019-03-29

AFPy - Emplois

Développeur Python Full Stack (Paris, France) - CDI

En tant que Développeur chez Gymglish, vous participerez à toutes les étapes de notre processus : la spécification, le développement et le déploiement de notre logiciel. Nous développons avec Python, MySQL et Linux. Nous utilisons des librairies comme Django, Flask, jQuery, Bootstrap, et utilisons également RedHat, CentOS, JIRA, Google Apps, Grunt, Less, Reviewboard, Jenkins, Sentry, etc. Nous documentons notre code, avons des tests unitaires systématiques et des commits par équipe. Nous aimons les logiciels libres et pour la plupart, les logiciels libres nous aiment aussi.

by AFPy - Emplois at 2019-03-29 10:36

2019-03-19

AFPy - Actualités

Deux nouveaux ateliers de traduction à Paris

La traduction ça avance, on a fait 3% en deux mois, en ce moment on fait un atelier par mois, on en est à 39%... :)

by AFPy - Actualités at 2019-03-19 14:11

2019-03-11

AFPy - Emplois

(lead) développeuse ou développeur expérimenté(e)

Algoo recrute une développeuse ou un développeur expérimenté(e) pour travailler sur l'ensemble de ses produits et projets clients. Technologies web, compétences full-stack, intégration continue, logiciels libres...

by AFPy - Emplois at 2019-03-11 21:36

2019-03-08

AFPy - Emplois

UI engineer

Concevoir et développer des interfaces de visualisation de données dans le domaine de la météorologie.

by AFPy - Emplois at 2019-03-08 14:43

Previous [12] Next