Python — partie 9 ― formateur de code, analyse statique

37
9
juin
2021
Python

Cette dépêche est la suite d’une série sur Python initiée en septembre 2019. Après un sommeil cryogénique d’un an et demi, on repart en forme avec d’autres contenus Python à vous proposer : actualité, bonnes pratiques, astuces, témoignages… Elle a été rédigée principalement à deux voix, Oliver et Philippe, qui vous font part de leur expérience sur les fonctions.

Cette dixième partie présente les formateurs de code bien pratiques et les analyseurs de code. 🐍 🐍 🐍

Le logo de Python est entouré de petites icônes symbolisant la variété des domaines où s’applique Python, et, à droite, un joyeux barbu se tient derrière un écran d’ordinateur qui affiche « partie = 10, "Formateurs" \n print(partie) »

Pour rappel, les autres dépêches déjà publiées :

Sommaire

Formateurs de code source

Les formateurs de code source ne dépendent pas des modules utilisés par les projets. Donc nous pouvons les installer avec pip :

python3 -m pip install --progress-bar emoji --user --upgrade black
python3 -m pip install --progress-bar emoji --user --upgrade yapf
python3 -m pip install --progress-bar emoji --user --upgrade autopep8
python3 -m pip install --progress-bar emoji --user --upgrade docformatter

Par la suite, ils seront illustrés avec l’exemple suivant :

$ cat > bateau.py
capitaine = { 'age':   42,       # univers ?
              'nom':  'Grant',
              'pays': 'Royaume-Uni',
            }
navire    = { 'nom':       'Britannia',
              'longueur':   127,# metres
              'tonnage':    5860,
              'lancement': "16 mars 1953"
            }

mission  =  { "commandant" : capitaine , 'bateau' : navire , }
f  =  lambda x:   True if x%9 == 0   else False

black

Le projet black est très récent, son premier commit date de mars 2018. Et pourtant ce formateur de code Python bénéficie d’un succès énorme avec plus de 20 000 étoiles sur GitHub (et une centaine de contributeurs).

Son succès est lié à la quasi-absence de configuration et fonctionne dans le même esprit que gofmt, c’est-à-dire que les développeurs n’ont plus à discuter des règles de codage. C’est toujours black qui a raison et on ne perd plus de temps à négocier les règles, à les rediscuter en revue de code… On se concentre sur son travail : coder sans se prendre la tête à bien indenter. De toutes façons, black va changer l’indentation avec ses propres règles de codage non-négociables : uncompromising Python code formater.

Les deux seuls paramètres sur lesquels on peut encore chipoter :

  • --line-length 88
  • --skip-string-normalization
    (si présent ne remplace pas 'texte' par "texte")

Exemple :

$ black .
reformatted bateau.py
All done! ✨ 🍰 ✨
1 file reformatted.
$ cat bateau.py
capitaine = {"age": 42, "nom": "Grant", "pays": "Royaume-Uni"}  # univers ?
navire = {
    "nom": "Britannia",
    "longueur": 127,  # metres
    "tonnage": 5860,
    "lancement": "16 mars 1953",
}

mission = {"commandant": capitaine, "bateau": navire}
f = lambda x: True if x % 9 == 0 else False

Une discussion a été ouverte sur le fait de passer le code de la lib standard de Python par black, mais, pour l’instant, il y a pas mal d’éléments qui font que ça n’aura pas lieu. Un des arguments principaux est de ne pas surcharger le nombre d’outils nécessaires pour une contribution à Python.

blue

Le projet blue est un dérivé de black avec quelques ajustements sur les points qui sont les plus controversés.

Les différences avec black:

  • utilisation des simples guillemets pour les chaînes de caractère (en dehors de docstring) ;
  • longueur de ligne à 79 caractères ;
  • configuration via plusieurs mécanismes possibles, pyproject.toml, setup.cfg, tox.ini, et .blue.

Il s’utilise à l’identique de black et est disponible sous pypi.org

yapf

Le projet Yet Another Python Formatter est plus vieux que black (premier commit en mars 2015), a moins d’étoiles (9 700) et le même nombre de contributeurs.

L’innovation de yapf réside dans la réutilisation du puissant clang-format. Les règles de sa configuration sont prises en compte pour calculer le score de tel ou tel reformatage et de boucler ainsi afin d’obtenir le meilleur score.

L’idée est superbe, mais en pratique, on passe trop de temps à essayer de peaufiner la configuration sans trop comprendre quel paramètre influe sur telle indentation. Et comme c’est configurable, une personne va passer du temps pour tenter d’améliorer les choses. Et pire, dans de rares circonstances, yapf peut reformater un code source différemment deux fois de suite ! (avec la même configuration)

En fait, le seul paramètre que nous devrions tester c’est --style avec les valeurs actuelles : pep8 (défaut), google, chromium et facebook.

Le résultat à partir du même fichier d’origine quel que soit le paramètre --style :

$ yapf bateau.py
capitaine = {
    'age': 42,  # univers ?
    'nom': 'Grant',
    'pays': 'Royaume-Uni',
}
navire = {
    'nom': 'Britannia',
    'longueur': 127,  # metres
    'tonnage': 5860,
    'lancement': "16 mars 1953"
}

mission = {
    "commandant": capitaine,
    'bateau': navire,
}

autopep8

Le projet autopep8 est encore plus vieux (premier commit en décembre 2010), a encore moins d’étoiles (3 000) et moins de contributeurs (une trentaine).

Ce formateur de code est beaucoup moins agressif que les deux premiers, car il ne reformate pas ce qui est compatible avec les règles PEP8. Cependant quelques corrections sont intéressantes comme le remplacement de f = lambda x: par def f(x):.

Le formateur autopep8 semble avoir --max-line-length comme seule règle de formatage. En fait, sa configuration est différente des deux autres : l’option --ignore permet de désactiver des règles. Les options --aggressive et --experimental sont intéressantes.

Exemple :

$ autopep8 --aggressive --aggressive --aggressive bateau.py
capitaine = {'age': 42,       # univers ?
             'nom': 'Grant',
             'pays': 'Royaume-Uni',
             }
navire = {'nom': 'Britannia',
          'longueur': 127,  # metres
          'tonnage': 5860,
          'lancement': "16 mars 1953"
          }

mission = {"commandant": capitaine, 'bateau': navire, }


def f(x):
    return True if x % 9 == 0 else False

isort

Le projet isort a une ambition plus modeste que les précédents formateurs de code. Il se focalise sur les imports et vous propose de les reformater pour vous simplifier la vie. La première version officielle date de 2013 et le projet est toujours assez actif et dispose de 3900 étoiles sous GitHub.

Le README du projet donne un bon exemple de son action.

Avant :

from my_lib import Object

import os

from my_lib import Object3

from my_lib import Object2

import sys

from third_party import lib15, lib1, lib2, lib3, lib4, lib5, lib6, lib7, lib8, lib9, lib10, lib11, lib12, lib13, lib14

import sys

from __future__ import absolute_import

from third_party import lib3

print("Hey")
print("yo")

Après isort :

from __future__ import absolute_import

import os
import sys

from third_party import (lib1, lib2, lib3, lib4, lib5, lib6, lib7, lib8,
                         lib9, lib10, lib11, lib12, lib13, lib14, lib15)

from my_lib import Object, Object2, Object3

print("Hey")
print("yo")

Donc concrètement :

  • regroupement des imports par groupe, les __future__ en premier, la lib standard en deuxième, les autres lib par la suite ;
  • regroupement des imports divers d’une bibliothèque sur un seul import ;
  • tri par ordre alphabétique des imports au sein d’un même groupe ;
  • formatage sous forme de bloc équilibré pour lib1 à lib15 par exemple ;
  • on ne touche pas au reste du code.

L’ambition du projet est modeste mais sympathique, et ça fonctionne bien. À voir s’il est essentiel pour vous d’avoir de beaux imports dans vos projets. Perso, je ne l’utiliserais pas sur mes petits projets, mais sur des projets un peu gros, gérés sur un temps long avec une équipe qui bouge, ça peut être une bonne idée.

Bilan

Retour d’Oliver

Personnellement, je regrette que black mette sur une seule ligne un petit dictionnaire que je trouve plus lisible sur plusieurs lignes. Je suis déçu des quatre styles fournis par yapf, et je n’ai pas trouvé une superbe configuration magique. Finalement, sur notre projet c’est le bon vieux vénérable autopep8 qui est utilisé, car il ne change que très peu le code source que nous écrivons.

Sur notre projet on utilise black dans une ancienne version non taguée. L’intégration continue vérifie que le formatage est « standard ». Et contraint tous les contributeurs à utiliser la même version. Et par la même, limite la mise à jour de black. Malgré ce petit désagrément, j’ai configuré l’outil pour formater à chaque sauvegarde. Laisser un outil faire le formatage complet est très confortable. Avec une grosse base de code, j’ai vraiment autre chose à faire qu’aligner des commentaires et des valeurs de dictionnaires.

Retour de Philippe

J’ai testé pour vous les différents formatters sur un petit projet libre. Il s’agit d’un projet développé initialement par un stagiaire, que j’ai retravaillé avant que ma boite ne le mette en open source. Du coup, le style est un peu hétérogène, ça fait un bon candidat.

Voici les liens vers les diff sous GitHub :

Mon point de vue général : sur un projet où je travaille seul, je vais pas utiliser de formateur de code. Je sais assez bien ce que j’aime et je suis relativement cohérent sur mon style. Sur un projet en équipe, c’est à réfléchir, mais je trouve le style de black/blue plutôt désagréable. Ma tentation de l’utiliser viendra donc de l’écart entre mon style et celui de mes coparticipants : s’il est grand, autant unifier avec un outil extérieur. Si on est proche, on garde comme ça.

À noter que j’ai un style pas forcément commun. J’aime bien que l’affichage ait une densité raisonnable. Par exemple, si je peux faire en sorte de voir la totalité d’une fonction sur un seul écran en tirant parti de lignes un peu plus longues, il y a un vrai bénéfice pour moi puisque je peux capturer en un coup d’œil l’ensemble du traitement. C’est pour ça que j’ai réglé la longueur maximum de ligne à 110 (ou 120 quand je me suis gouré) car c’est ce que j’affiche sans problème sur un portable 14 pouces. J’utilise aussi le formatage pour faire ressortir des similitudes dans le code, ce qui va s’opposer parfois à un formatage agressif tenant compte avec rigueur du niveau d’imbrication des structures.

Mon bilan sur ce projet-là :

  • isort, c’est gentil, j’aime bien, mais je vois pas trop l’intérêt. La valeur ajoutée est vraiment très faible, surtout que j’aime bien grouper tous les imports de la stdlib de Python sur une seule ligne (notion de densité de code évoquée plus haut), ce qu’il se refuse à faire (comme la majorité des gens) ;
  • autopep8, c’est assez peu envahissant. Ça me convient bien pour rectifier une base de code comme dans le projet que j’ai pris, sans pour autant tout péter mon style. J’aime bien ;
  • yapf, black, blue : honnêtement, je suis entre deux. Il y a clairement des gains en lisibilité par endroits, et d’autres où le code devient inutilement étalé sur plusieurs lignes et où la perte de densité me semble dommageable à la compréhension globale. Donc je suis réservé sur l’amélioration, mais pas hostile au concept en général.

Finalement, tout ça est vraiment très subjectif. Je comprends tout à fait pourquoi des gros projets ont adopté black, au moins, on évite ce type de discussion et le style reste tout à fait raisonnable.

Formateurs de docstring

docformatter

Le projet docformatter permet de reformater la partie docstring du code source.

Nous l’utilisons avec ces paramètres :

docformatter --wrap-summaries 444 --pre-summary-newline --in-place --recursive .

pyment

La documentation du code source Python se fait à l’aide des docstring standardisée par la PEP 257 (2001). Plusieurs types de docstring sont utilisés, les plus connus étant :

L’outil pyment permet de créer, corriger et de modifier ces représentations docstring.

L’auteur de pyment, Adel Daouzli (dadadel) nous avait présenté son outil dans son journal (2014). Mais Adel ne semble plus maintenir le code source ces derniers temps.

Comme plusieurs bugs sont corrigés dans des Pull Requests fights, j’ai donc pris en compte ces corrections et autres améliorations apportées et j’ai tout *mergé sur la branche olibre publiée sur un triple fork :

Attention, l’annotation des types (type hints Python 3.5) n’est pas prise en charge par pyment.

Génération de la documentation

C’est pratique quand la documentation de son code source et automatiquement générée. Deux outils intéressants :

  • Pdoc, successeur du bon vieux Epydoc ;
  • Sphinx, LE générateur de documentation Python le plus connu.

Attention, pour Pdoc, nous avons deux projets qui ont divergé : pdoc (l’original) et pdoc3 (le fork, plus actif).

Analyse statique de code

pylint

Pylint est je cite : « un outil qui recherche des erreurs dans le code Python, qui essaye d’imposer un standard de codage et qui cherche du code malodorant (code smells). Il peut aussi trouver certains types d’erreurs, faire des recommandations sur la façon dont un bloc peut être réorganisé et détaille la complexité du code ».

Pylint est un projet ancien (plus de 15 ans), qui analyse le code Python dans plusieurs optiques différentes :

  • conformité à un style de codage, le fameux pep8 plus quelques petits détails supplémentaires ;
  • analyse de la complexité du code (nombre de chemins d’exécution dans une fonction, etc.) ;
  • erreurs de codage ;
  • améliorations possibles (suppression de parenthèses, simplifications…).

Chaque problème reporté peut-être désactivable, en ligne de commande ou via une variété de fichiers de configuration (.pylintrc, pyproject.toml ou setup.cfg). On peut aussi dans le code activer ou désactiver spécifiquement des configurations, au niveau du fichier, d’une fonction, d’un bloc de code ou, tout simplement, d’une ligne.

Retour de Philippe

J’ai fait une tentative sur le même projet, sxtool. J’ai un peu galéré pour le lancer et je n’ai pas trouvé la ligne magique où il comprend tous les imports de mon projet. La première exécution m’a retourné 1 500 lignes d’erreurs. La très grande majorité sont des erreurs de style (lignes trop longues, nommage des variables pas en snake_case, docstring manquantes, absence de fin de ligne en fin de fichier…). En désactivant les erreurs de style les plus courantes, je tombe sur quelques erreurs plus intéressantes du type :

  • trop de return dans une fonction ;
  • trop de chemins d’exécution dans une fonction ;
  • variables ou import inutilisés ;
  • redéfinition de nom pré-intégrés, format, file ;
  • clause d’attrapage d’exception trop large ;
  • else inutile après un return.

Les problèmes de code signalés sont légitimes. Ils correspondent à du code peu lisible et des erreurs liées au manque de familiarité avec Python de l’auteur initial. Le code correspondant a été développé par un stagiaire qui débutait en Python.

Mais, une fois ce constat fait, il est totalement irréaliste d’imaginer passer du temps à rectifier le code en question. Ce serait très coûteux en temps, et le bénéfice reste modeste. Exiger du code avec un style parfaitement conforme est l’apanage de quelques rares projets ou entreprises de logiciel très exigeantes. Le reste du monde vit très bien avec du code aux styles complètement hétérogènes (et je suis le premier à le regretter). Essayez de le mettre en place dans une équipe et vous verrez ! C’est ce qui fait que les linters Python, bien qu’existant depuis longtemps, ne sont pas plus populaires que cela. Honnêtement, se faire rappeler à l’ordre par un outil parce qu’il manque une espace après une virgule, c’est très pénible.

L’approche récente du reformatage prise par black et consorts résout ce problème de façon plus pérenne.

Concernant l’analyse de la complexité du code, j’aime beaucoup le concept, mais j’imagine mal le mettre en place. Sur mes projets solo, je suis déjà attentif à la complexité et la lisibilité, donc il ne m’apportera rien. Sur des projets en équipe, les gens qui vont être favorables à la mise en place d’un tel outil sont justement ceux qui sont conscients du problème de la complexité du code, et qui ont déjà tendance à ne pas privilégier ce style. Les non-favorables tombent vite dans des guerres de chapelle (« mais si, sept if imbriqués, c’est très bien ! ») et on ne s’en sort pas. Les cas qui me paraissent réalistes pour la mise en place seraient ceux où des managers sont conscients des bénéfices d’un code peu complexe et imposent l’outil. Ou alors une équipe qui aborde une base de code héritée importante et qui souhaite cibler les modules où le risque de bug est plus élevé qu’ailleurs.

Restent, enfin, les erreurs que peut détecter pylint. On en trouve la liste dans la documentation de référence. Les classes d’erreur ont l’air intéressantes bien que certaines soient un peu louches à mon goût : je vois pas bien comment du code pourrait tourner avec certaines des erreurs qui sont signalées. J’imagine qu’elles sont pourtant toutes basées sur des cas réels.

Voici quelques erreurs prises au hasard :

  • nonlocal-and-global (E0115): Emitted when a name is both nonlocal and global ;
  • not-in-loop (E0103): Used when break or continue keywords are used outside a loop ;
  • return-in-init (E0101): Used when the special class method init has an explicit return value ;
  • inherit-non-class (E0239): Used when a class inherits from something which is not a class ;

pylint peut être lancé avec -E pour ne signaler que les erreurs de ce type. Serait-ce parce que c’est sa plus grande valeur ajoutée ?

Mon bilan

Le concept est intéressant mais le côté pédant de l’outil est désagréable et les autres bénéfices restent trop réduits. Je pense que mettre en place des revues de code sera plus efficace et plus constructif que de passer un projet à pylint.

pyflakes

PyFlakes est avec pylint un des anciens linter/checker de code Python : les premières versions datent de 2009. Le principe est simple : un programme simple qui vérifie les fichiers source Python à la recherche d’erreurs. En complément, le README ajoute il ne va jamais se plaindre à propos du style, il va essayer très très intensément de ne jamais émettre de faux positifs .

Retour de Philippe

Tout ça est très prometteur. Par contre, la documentation ajoute que pyflakes est plus limité dans les types d’erreurs qu’il peut trouver, car il inspecte l’arbre de syntaxe plutôt qu’importer le code.

Voyons voir ce que ça donne, je prends le même cobaye, sxtool. Pas de problème à l’installation, pas de problème à l’exécution. Il me signale une cinquantaine d’erreurs qui ne correspondent en fait qu’à deux cas :

  • un nom est importé mais pas utilisé ;
  • une variable ou un argument est inutilisé.

Intéressant, mais pas fantastique. Mon projet n’a pas d’erreur manifeste, c’est cool.

Je jette un coup d’œil à la documentation pour en savoir plus sur le potentiel de pyflakes pour découvrir qu’il n’y a pas de documentation. Impossible de savoir quelles classes d’erreurs sont détectées. La lecture du ChangeLog laisse entrevoir quelques idées mais sans plus.

Rien non plus sur la configuration, on ne peut pas ignorer certains fichiers ou annoter une ligne pour ignorer une erreur. Il me semble que c’est parce que pyflakes ne s’utilise plus tel quel. Le projet a fait cause commune avec un autre linter, pep8, pour former flake8, un lanceur de linter/checker Python. flake8 est couvert dans la suite de la dépêche et c’est lui qui permet de configurer finement la vérification d’un fichier et la désactivation de certaines erreurs. Par contre, la documentation de flake8 n’en dit pas plus sur les types de vérifications effectuées par pyflakes.

En conclusion, je n’ai pas pu mettre en évidence l’intérêt de pyflakes, mais je sais qu’il a plutôt une bonne réputation dans la communauté Python. Mon projet cobaye est aussi assez simple, il n’utilise que très peu de fonctionnalités de Python. Sur des projets plus élaborés fonctionnellement, j’imagine qu’il peut trouver des erreurs intéressantes.

Si vous utilisez pyflakes et que vous connaissez sa valeur, n’hésitez pas à nous le partager dans les commentaires.

flake8

Flake8 est un lanceur de linter. Il est né du rapprochement des projets pyflakes et pep8 (qui est devenu pycodestyle au passage). La version de base fait du 3-en-1 :

  • PyFlakes, la recherche d’erreurs générales ;
  • pycodestyle, les vérifications de style façon pep8 ;
  • le script McCabe de Ned Batchelder, la vérification de la complexité du code.

Flake8 exécute tous les outils en lançant la commande unique flake8. Il affiche les avertissements par fichiers dans une sortie commune.

Il ajoute également quelques fonctionnalités :

  • les fichiers qui contiennent cette ligne sont ignorés :
# flake8 : noqa
  • les lignes qui contiennent un commentaire # noqa à la fin n’émettront pas d’avertissement ;
  • vous pouvez ignorer des erreurs spécifiques sur une ligne avec # noqa : , par exemple, # noqa : E234. Plusieurs codes peuvent être donnés, séparés par une virgule, le jeton noqa n’est pas sensible à la casse, les deux points avant la liste des codes sont nécessaires, sinon la partie après noqa est ignorée ;
  • des hooks Git et Mercurial ;
  • extensible via les points d’entrée flake8.extension et flake8.formatting.

Configuration

Exemple de fichier de configuration :

[flake8]
max-line-length = 88
select = C,E,F,W,B,B9
ignore = E203, E501, W503
exclude = __init__.py

Ce contenu peut être glissé dans un fichier .flake8, ou dans tox.ini ou encore un setup.cfg, ce qui permet de s’intégrer dans un fichier de config partagé avec d’autres outils de l’écosystème de packaging python.

La force de flake8, c’est qu’on peut facilement rajouter des plugins pour compléter son travail. Il existe des plugins pour tout un tas de vérifications supplémentaires, pour lancer d’autres outils ou pour adapter le format de sortie à des services spécifiques.

Retour de Philippe

flake8 a une bonne réputation dans l’écosystème Python. Je l’ai essayé toujours sur mon projet cobaye sxtool. Je n’ai récupéré que des erreurs de style, et une ou deux variables non utilisées. En forçant le test de complexité à maximum 5, j’ai récupéré une erreur due à la complexité trop élevée d’une fonction.

Je suis plutôt déçu. Les erreurs de style ne m’intéressent pas, je les traiterai avec black. Mais, pas moyen de les ignorer toutes d’un coup. Pas d’erreurs de codage reporté, c’est bien pour mon projet, mais je n’ai toujours aucune idée du type d’erreur qui peut être détecté. Pour la complexité, pylint avait trouvé plus de fonctions nécessitant un retravail, je suis également déçu.

L’écosystème de plugin est réputé riche, mais là encore, la documentation n’en mentionne presque aucun. Vous pouvez piocher dans la longue liste de awesome-flake8-extensions pour trouver votre bonheur. On trouve pas mal de plugins pour lancer d’autres outils dans flake8, genre pylint ou mypy ou encore bandit. On trouve aussi pas mal de plugins pour ajuster le format de sortie à un besoin spécifique, et encore des plugins pour faire quelques vérifications très ciblées.

bandit

Bandit est un outil conçu pour trouver de failles de sécurité connues dans du code Python. Comme Pylint, il analyse les fichiers Python en construisant leur arbre syntaxique (AST) et exécute un ensemble de vérification sur ce dernier. Le projet existe depuis 2015 et a reçu plus de 3000 étoiles GitHub.

Bandit est extensible par plugin, à la fois pour rajouter des vérifications ou pour modifier le format de sortie. On le configure par un fichier en YAML ou par des directives dans les fichiers ou lignes de code concernées.

Retour de Philippe

Avant de lancer le projet, je note déjà que la documentation est bien faite et couvre les aspects qui m’intéressent facilement. Alors, toujours sur mon projet sxtool, que donne bandit ?

 > bandit -r sxtool
[main]  INFO    profile include tests: None
[main]  INFO    profile exclude tests: None
[main]  INFO    cli include tests: None
[main]  INFO    cli exclude tests: None
[main]  INFO    running on Python 3.8.8
Run started:2021-05-15 16:49:52.455989

[...]
--------------------------------------------------
>> Issue: [B318:blacklist] Using xml.dom.minidom.parse to parse untrusted XML data is known to be vulnerable to XML attacks. Replace xml.dom.minidom.parse with its defusedxml equivalent function or make sure defusedxml.defuse_stdlib() is called
   Severity: Medium   Confidence: High
   Location: .\src\utils.py:20
   More Info: https://bandit.readthedocs.io/en/latest/blacklists/blacklist_calls.html#b313-b320-xml-bad-minidom
19              try:
20                  self.tree = dom.parse(fileName)
21              except sax.SAXParseException :

--------------------------------------------------

Code scanned:
        Total lines of code: 3073
        Total lines skipped (#nosec): 0

Run metrics:
        Total issues (by severity):
                Undefined: 0.0
                Low: 5.0
                Medium: 1.0
                High: 1.0
        Total issues (by confidence):
                Undefined: 0.0
                Low: 0.0
                Medium: 0.0
                High: 7.0
Files skipped (0):

Donc sept problèmes majeurs! Quand même! Si vous voulez voir les détails, c’est ici.

Les catégories de problème trouvés :

  • utilisation de assert, alors qu’en compilant avec -O, les asserts disparaissent ;
  • lancement d’une commande dans un shell de façon non sécurisée ;
  • utilisation d’une bibliothèque non sécurisées pour parser du XML, notamment vulnérables à de l’injection de code.

Tout ça est présenté dans un magnifique rapport, avec des belles couleurs selon le niveau de vulnérabilité estimé. Et on peut aussi en faire une version html, ou CSV. Pour chaque erreur, un lien vers la documentation indique la raison pour laquelle c’est un risque de sécurité, et pour la plupart la marche à suivre pour le corriger !

Conclusion : j’adore ! Le niveau de finition est très agréable !

Annotation de type

Depuis 2006 et la version 3.0 de Python, il est possible de rajouter des annotations au code Python. Et depuis 2009, Dropbox livre un outil de vérification d’annotations de type pour Python : mypy.

Au fil des versions de Python, les annotations de type se sont généralisées (d’abord des arguments de fonctions à maintenant toutes les variables et attributs de classes) et simplifiées dans leur usage. Mypy a aussi continué à évoluer, permettant des définitions de plus en plus fines des types pour décrire un contenu. En parallèle, le nombre de projet avec du typage disponible n’a cessé d’évoluer, que ce soit directement dans le projet typeshed qui regroupe les informations de types (typing stubs) de la lib standard ou alors livré en même temps avec le paquet concerné (comme Flask mentionné récemment dans une dépêche), ou encore via un paquet séparé qui ne fournit que les stubs (cas de PyQt5-stubs et django-stubs qui fournissent les stubs de respectivement PyQt5 et django).

Pour faire bref, ajouter des annotations de type à votre code va apporter les avantages suivants :

  • les annotations créent une forme de documentation très compact des arguments et résultats des fonctions ;
  • les annotations permettent de garantir que votre code est utilisé de la bonne façon ; corollaire, ça permet de découvrir des bugs difficiles à trouver autrement, quand une fonction/méthode est utilisée de façon incorrecte, ou quand l’ensemble des types possibles d’une variable a mal été pris en compt ;
  • les IDE peuvent utiliser les annotations de types pour proposer une complétion plus intelligente.

Bien sûr, tout cela a un coût :

  • annoter une base de code est très rapide et simple au début, mais peut devenir assez fastidieux et chronophage si on vise le 100 % annoté. Heureusement, les outils fonctionnent très bien avec du code partiellement annoté ;
  • pour les cas complexes, il faut se pencher pas mal sur la documentation. C’est assez chronophage ;
  • certaines constructions dynamiques de Python, ou tout simplement la logique de votre code peut être impossible à capturer avec du typage statique ;
  • certaines annotations sont longues à écrire, et alourdissent la lisibilité du code : des définitions de fonctions vont passer de une ligne à plusieurs à cause de cela ;
  • la vérification de la cohérence globale, c’est un outil de plus à lancer, qui plus est un outil qui n’est pas forcément rapide, donc ça ralentit le processus de développement global.

Depuis quelques années, Dropbox et Facebook/Instagram se sont mis au typage statique de tout leur code, et les retours des développeurs sont très positifs. Il y a eu plusieurs sessions au PyCon US sur ce sujet.

Pour comprendre l’intérêt du typage statique en Python, prenons un exemple simple:

def is_equal(a, b):
    if a == b: 
        return True

Bien qu’imparfaite, cette fonction va plutôt bien marcher sur tout ce qui implémente correctement l’égalité : les booléens, les entiers, les chaînes de caractères, etc. C’est pas mal, et ça veut aussi dire qu’on peut facilement passer à côté d’un bug. Voyons en pratique :

def print_is_equal(a, b):
    if is_equal(a, b):
        print('Egalité pour', a)
    else:
        print('Différence:', a, b)
>>> print_is_equal(1, 1)
Egalité pour 1

>>> print_is_equal(1, 2)
Différence: 1 2

>>> print_is_equal('abc', "abc")    # deux représentations de la même chaîne de caractère sont identiques
Egalité pour abc

>>> print_is_equal(0.3, 0.3)
Egalité pour 0.3

>>> print_is_equal(0.3, 0.2 + 0.1)
Différence: 0.3 0.30000000000000004

Oups ! Et oui, comme 0.1 se représente mal en base 2, il génère des erreurs dans les calculs. Donc, il faut éviter d’utiliser notre belle fonction avec des flottants. C’est ce que peut nous aider à faire les vérificateurs d’annotations de type.

Si on rajoute un brin de documentation et des annotations de type, voilà ce que ça donne :

def is_equal(a: int, b: int) -> bool:
    '''Compare two integers and return True if they are equal'''
    if a == b: 
        return True


def print_is_equal(a: int, b: int) -> None:
    '''Display whether two numeric values are equal'''
    if is_equal(a, b):
        print('Egalité pour', a)
    else:
        print('Différence:', a, b)


print_is_equal(1, 1)
print_is_equal(1, 2)
print_is_equal(0.3, 0.2 + 0.1)

Et lorsqu’on passe ce programme à travers mypy :

>mypy src\is_equal.py
src\is_equal.py:1: error: Missing return statement
src\is_equal.py:16: error: Argument 1 to "print_is_equal" has incompatible type "float"; expected "int"
src\is_equal.py:16: error: Argument 2 to "print_is_equal" has incompatible type "float"; expected "int"
Found 3 errors in 1 file (checked 1 source file)

Il détecte bien, d’une part que la fonction est utilisée de façon incorrecte avec des flottants, d’autre part que nous avons oublié un return : la fonction renvoie None dans le cas d’une inégalité.

À noter que la documentation des deux fonctions est correcte sans être assez précise: two numeric value peut aussi bien faire référence à deux flottants qu’à deux entiers. De même, pour is_equal(), ce que retourne la fonction en cas d’inégalité n’est pas documenté et va fonctionner dans tous les tests qui ne vérifient pas exclusivement l’égalité àFalse. Le développeur avait sûrement en tête de retourner False, mais difficile d’en être sûr. C’est l’intérêt des annotations de type : elles obligent à plus de rigueur et elles capturent l’intention du développeur mieux que de la documentation.

Si vous voulez vous mettre à l’annotation de type dans Python, on trouve pas mal de ressources sur Internet, dont une conférence en français réalisée par un certain Philippe F.

Penchons-nous maintenant sur les outils de l’annotation de type.

mypy

Mypy est la référence en termes de vérification de typage. C’est l’émergence de mypy qui a permis aux annotations de s’imposer dans l’écosystème Python. L’outil est maintenu par l’équipe Python de Dropbox (dans laquelle Guido Van Rossum a fait un séjour assez long). Le projet est très dynamique, avec des nouvelles versions fréquentes, qui permettent de pousser le typage de plus en plus finement. Mypy fournit une documentation de bonne qualité pour aider à se mettre au typage. L’outil dispose d’un large jeu d’option, qui permettent d’ajuster assez finement le niveau de typage qu’on souhaite, de très léger à très exigeant. C’est un peu comme les options de compilation de gcc, il y en a pour tous les goûts. Tout ça peut se régler aussi par un beau fichier de config au format ini.

Mypy gère les annotations Python 3 (directement dans le code) ou Python 2 (sous forme de commentaire). Comme pour les vérificateurs de code, il est possible directement depuis le code d’ignorer une erreur en ajoutant un commentaire # type: ignore. On peut même préciser le type d’erreur à ignorer plus précisément.

Ça se lance en ligne de commande, mais comme l’outil est lent sur des grosses bases de code, on peut lancer un serveur dmypy qui va garder en cache les résultats intermédiaires et vérifier le code beaucoup plus vite.

Ah oui, et c’est écrit en Python, c’est pour ça que c’est lent ! (attention un troll velu s’est caché dans la phrase précédente, à toi de le débusquer sans le nourrir !).

Retour de Philippe

Sans surprise, je suis un grand fan de l’annotation de type et j’utilise mypy intensément. Il s’installe très simplement avec pip. À l’usage, sur mes projets, au fur à mesure que j’y rajoute les annotations de type, j’ai constaté que :

  • c’est chronophage, notamment au début, où on se perd dans la documentation, et à la fin quand on essaye d’être 100 % typés, on croise des cas vraiment complexes à annoter ;
  • c’est bien documenté et on trouve facilement de l’aide, sur le site de mypy ou sur stackoverflow ;
  • il faut parfois modifier le code pour aider mypy avec quelques asserts, c’est sans conséquence et dans un certain nombre de cas, ça oblige à se poser les bonnes questions : est-ce que ma variable trucmuche peut encore être à None ou pas dans cette partie de code ?
  • je n’ai pas l’impression d’avoir trouvé des gros bugs avec ça, par contre, je sais que ma base de code est beaucoup plus fiable. Au boulot, j’ai récemment fait du « refactoring » sur des « callback » un peu poilus et j’étais content que mypy me pointe tous les endroits où je devais intervenir ;
  • l’aspect documentation compacte est incroyablement agréable. Mes collègues qui arrivent sur mes projets avec annotation de type me disent aussi qu’ils ont beaucoup plus de facilité pour comprendre le code. J’ai retravaillé un petit projet à moi de 15 ans d’âge récemment. J’étais à moitié perdu dans mon code de l’époque. J’ai décidé de le typer pour m’y retrouver mieux et ça a fait une vraie différence.

Sur ce petit projet de 15 ans d’âge, je vous montre le code avant. C’est un tout petit bout de code qui doit déplacer une pièce d’un jeu.

    def move_tile(self, pid, d):
         if not self.move_enabled: 
             return

        self.map.move( pid, d )
        self.board.move( pid, d )

        [...]

En revoyant ce code, je ne me rappelais plus ce qu’était pid et d. Avec les annotations, ça donne :

    def move_tile(self, pid: str, d: Tuple[int, int]) -> None:
         if not self.move_enabled: 
             return

        assert self.map
        self.map.move( pid, d )
        self.board.move( pid, d )

        [...]

Avec ces informations, j’ai recollé les morceaux: pid est un identifiant de pièce (piece-id) et d est le delta de déplacement, sous forme de deux entiers.

pyre

Pyre est l’implémentation de Facebook pour la vérification de typage en Python. Écrite en OCaml, c’est un dérivé d’un moteur d’inférence de type qu’ils avaient déjà construit pour PHP. Au niveau de la vérification du typage statique, ils se sont calés sur mypy. Les commentaires pour supprimer une erreur façon mypy (# type: ignore) sont d’ailleurs supporté aussi par pyre.

La documentation est correcte et le contrôle du niveau de vérification est un peu moins fin que mypy. En gros, on a vérification stricte ou pas stricte.

Pyre a la réputation d’être rapide pour valider beaucoup de lignes de code : ça fait quand même tourner plus d’un million de lignes de code chez Facebook. On peut notre quelques différences mineures d’interprétation entre mypy et pyre. En conséquence, il vaut mieux éviter d’utiliser les deux outils conjointement sur une base de code. Il y a un choix à faire.

À noter que Pyre fournit aussi Pysa qui fait des vérifications de sécurité par analyse de code. Il vérifie qu’une chaîne de caractère sous contrôle de l’utilisateur (genre, un paramètre d’url) ne peut pas atteindre un composant critique (genre une écriture en base de donnée) sans passer par un désinfectant (« sanitizer »). Ce travail est fait en s’appuyant aussi sur les annotations, cette fois un peu moins orienté typage statique.

Retour de Philippe

Cette dépêche est l’occasion de faire un petit test de pyre. D’après la documentation, ça marche bien sous Linux et MacOs mais c’est expérimental sous Windows, et ça ne fonctionne que grâce a WSL (Windows Subsystem for Linux). Sur ma machine Windows, j’ai donc étrenné mon WSL Ubuntu avec pyre : ça se passe sans accroc, exactement comme décrit dans la documentation.

Pour mes projets PyQt, il n’interprète pas les stubs PyQt comme mypy. Du coup, du code avec 0 erreur sous mypy génère quelques erreurs sous pyre. Autre petit souci, la gestion de la valeur None ne se fait pas comme sous mypy, donc mon code qui plaît à mypy lui semble poser problème.

En dehors de ça, la vérification est rapide et les diagnostics sont clairs. Sur l’exemple que j’ai donné sur l’annotation, ça donne :

(pyre-env) $ pyre --source-directory . check
ƛ Using virtual environment site-packages in search path...
ƛ Setting up a `.pyre_configuration` with `pyre init` may reduce overhead.
ƛ Found 2 type errors!
linuxfr.py:5:8 Incompatible return type [7]: Expected `bool` but got implicit return value of `None`.
linuxfr.py:16:15 Incompatible parameter type [6]: Expected `int` for 1st positional only parameter to call `print_is_equal` but got `float`.

Il a trouvé en gros les mêmes erreurs que mypy, sauf qu’il s’est arrêté au premier argument incorrect de la fonction alors que mypy a eu la générosité de signaler que les deux arguments étaient passés en float plutôt que en int.

En conclusion, je pense que c’est un bon projet même si je vais rester avec mypy. La bataille entre mypy et pyre, c’est un peu la bataille entre git et mercurial : mypy et git ont gagné depuis longtemps à ce qu’il me semble, mais pyre et mercurial restent de très bons outils.

monkeytype

Plutôt que d’annoter une base de code à la main, pourquoi ne pas la faire tourner en production et regarder quels types sont effectivement utilisés ? C’est ce que fait monkeytype, il vous aide à collecter les types lors de l’exécution du code, puis à les appliquer sous forme de typage statique sur une base de code. L’outil est maintenu par Instagram et a plus de 3 000 étoiles sur GitHub.

Évidemment, la collecte durant l’exécution du code ralentit énormément le programme (genre 10 fois plus lent). À partir des résultats de la collecte, Monkeytype génère un fichier stub de tous les types qu’il a rencontrés. Ceux-ci ne représentent certainement pas tous les contextes possibles d’exécution du code, il est donc essentiel de faire une relecture pour compléter et vérifier la cohérence du résultat. Cela dit, Monkeytype peut faire gagner pas mal de temps pour démarrer l’annotation d’une base de code.

Retour de Philippe

Je vais tester monkeytype sur le cas très simple du is_equal.py que j’ai présenté en version non annotée. Première, étape, il faut isoler le code à annoter dans un module. Je déplace donc les trois lignes contenant les appels à print_is_equal() dans un fichier run_is_equal.py . Après, l’utilisation est simple : je remplace Python par monkeytype et hop, ça tourne.

> monkeytype run run_is_equal.py
Egalité pour 1
Différence: 1 2
Différence: 0.3 0.30000000000000004

Je constate apparition d’un fichier monkeytype.sql3 dans mon répertoire, c’est bon signe.

Il y a plusieurs façons de générer des annotations. La première, c’est de générer un fichier stub à part. Je lui demande de faire ça pour le module is_equal:

>monkeytype stub is_equal
from typing import (
    Optional,
    Union,
)

def is_equal(a: Union[int, float], b: Union[int, float]) -> Optional[bool]: ...

def print_is_equal(a: Union[float, int], b: Union[float, int]) -> None: ...

Voilà, je peux stocker ce résultat dans un fichier is_equal.pyi et mon code est vérifiable. On note que l’exécution du code a mis en évidence que les fonctions étaient appelées avec entiers ou des flottants, et que is_equal() retourne un booléen ou None. Comme ce n’est pas l’intention de départ, c’est bien de relire les stubs avant de les ajouter aveuglément au code.

On peut aussi demander à monkeytype de modifier directement le code pour ajouter les annotations. C’est mon mode préféré d’utilisation puisqu’on voit bien le diff avec git.

>monkeytype apply  is_equal
from typing import Optional, Union

def is_equal(a: Union[float, int], b: Union[float, int]) -> Optional[bool]:
    '''Compare two integers and return True if they are equal, False if not'''
    if a == b:
        return True

def print_is_equal(a: Union[int, float], b: Union[int, float]) -> None:
    '''Display whether two numeric values are equal'''
    if is_equal(a, b):
        print('Egalité pour', a)
    else:
        print('Différence:', a, b)

Il a modifié le fichier sur place et m’a aussi montré le code sur la sortie standard.

À noter que si vous avez déjà des annotations, monkeytype ne va pas y toucher, mais vous pouvez lui demander de montrer la différence entre vos annotations et ce qu’il a vu passer.

En conclusion, monkeytype est très pratique pour annoter du code qui a déjà des tests et une utilisation en production. On gagne un temps vraiment important et l’outil est mature.

Tests unitaires

Historiquement, nous avons unittest. D’autres alternatives intéressantes : nose et pytest.

unittest

unittest est la bibliothèque de test unitaire incluse par défaut dans Python. Le fonctionnement est calqué sur les bibliothèques de test du genre junit (première version), basée sur des classes et des fonctions spécifiques d’assertion.

def incremente(x):
    return x + 1

class TestIncremente(unittest.TestCase):
    def test_incremente(self):
        self.assertEqual(incremente(3), 4)

C’est une très bonne bibliothèque de test, qu’on peut utiliser sur de très gros projets sans souci. Au-delà de ses fonctionnalités classiques, elle a des fonctionnalités méconnues mais bien sympathiques. Par exemple, il est possible de tester le comportement d’une fonction en faisant varier les paramètres. L’approche choisie est « pythonesque » puisqu’elle s’appuie sur les gestionnaires de contexte. Si on complète l’exemple précédent, ça donne :

def incremente(x):
    return x + 1

class TestIncremente(unittest.TestCase):
    def test_incremente(self):
        self.assertEqual(incremente(3), 4)

    def test_many_increments(self):
        for value_in, value_out in [
            (1, 2),
            (-100, -99),
            (-1, -2),  # oups, celui-la va échouer
            (-1, 0),
            (0, 1),
            (100, 99),  #  celui-la va échouer aussi
        ]:
            with self.subTest(value_in=value_in, value_out=value_out):
                self.assertEqual(incremente(value_in), value_out)

Et à l’exécution :

> python -m unittest test_incremente.py
======================================================================
FAIL: test_many_increments (test_incremente.TestIncremente) (value_in=-1, value_out=-2)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_incremente.py", line 22, in test_many_increments
    self.assertEqual(incremente(value_in), value_out)
AssertionError: 0 != -2
======================================================================
FAIL: test_many_increments (test_incremente.TestIncremente) (value_in=100, value_out=99)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_incremente.py", line 22, in test_many_increments
    self.assertEqual(incremente(value_in), value_out)
AssertionError: 101 != 99
----------------------------------------------------------------------
Ran 2 tests in 0.003s

FAILED (failures=2)

On voit clairement quelles valeurs ont posé problème dans le rapport de test.

Autre aspect appréciable, unittest fait de gros effort pour vous aider à comprendre ce qui diffère lorsque vous comparez deux chaînes de caractères ou deux listes. Par exemple, en complétant le code précédent avec deux tests supplémentaires :

    def test_list_diff(self):
        self.assertEqual([1, 2, 3, 4], [1, 3, 4]) # il manque la valeur 2

    def test_str_diff(self):
        self.assertEqual('abcdef', 'abdef') # il manque le caractère 'c'

Le rapport d’exécution :

======================================================================
FAIL: test_list_diff (test_incremente.TestIncremente)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_incremente.py", line 26, in test_list_diff
    self.assertEqual([1,2,3,4], [1,3,4])
AssertionError: Lists differ: [1, 2, 3, 4] != [1, 3, 4]

First differing element 1:
2
3

First list contains 1 additional elements.
First extra element 3:
4

- [1, 2, 3, 4]
?     ---

+ [1, 3, 4]

======================================================================
FAIL: test_str_diff (test_incremente.TestIncremente)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "test_incremente.py", line 29, in test_str_diff
    self.assertEqual('abcdef', 'abdef')
AssertionError: 'abcdef' != 'abdef'
- abcdef
?   -
+ abdef

pytest

pytest est un projet plus récent. Il permet de décrire les tests d’une manière plus « pythonesque », sans avoir à implémenter de classe ni à utiliser des méthodes spécifiques d’assertion.

def incremente(x):
    return x + 1

def test_incremente():
    assert incremente(3) == 4

pytest est un outil de test très élaboré. Parmi ses fonctionnalités notables, on peut citer :

  • la découverte automatique des fichiers de test ;
  • une compatibilité avec unittest et nose, permettant une migration en douceur ;
  • la possibilité d’affecter un ou plusieurs labels à un test, ce qui permet ensuite de lancer des groupes de test spécifiques assez facilement ;
  • un système d’initialisation des tests très élaboré, qui permet de partager aisément une ou plusieurs ressources entre plusieurs tests (pratique par exemple pour des opérations de créations de ressources coûteuses, qu’on souhaite partager entre plusieurs tests) ;
  • les tests paramétrés, c’est-à-dire la possibilité de lancer le même test avec un jeu de valeur ;
  • une ligne de commande élaborée, qui permet par exemple de lancer uniquement les derniers tests ayant échoué (très pratique en phase de debug) ;
  • un jeu d’extension via des plugins très pratiques. On en trouve pour tout, par exemple, pytest-cov permet de faire de la couverture de code

Vous l’aurez compris, pytest, c’est le niveau au-dessus du test. Si vous rencontrez des limitations avec unittest, c’est le bon candidat à essayer. La documentation est de bonne qualité. Par contre, la mise en œuvre des tests utilise une approche différente de la famille de unittest, qui peut déstabiliser dans un premier temps.

nose

nose ajoute à unittest un lanceur et des extensions.

L’outil n’est plus maintenu, mais un projet nose2, fonctionnant avec les versions de Python maintenues fournit quasiment les mêmes services.

https://nose2.readthedocs.io/en/latest/differences.html

ward

ward reprend beaucoup des concepts de pytest notamment sur l’initialisation des tests (les fixtures) et leur découverte. L’auteur a, d’ailleurs, un plugin pour pytest qui propose une partie du rendu de ward. Ce qui distingue ward, c’est la façon d’écrire un test avec une description plutôt qu’un nom, et un affichage très soigné visuellement : il tire parti des couleurs d’avant-plan et d’arrière-plan, ainsi que de quelques symboles unicode pour faire un rendu riche en détails, en particulier sur l’analyse des différences :

La sortie de ward

Notre exemple donne avec ward :

from ward import test

def incremente(x):
    return x + 1

@test("incremente une valeur _positive_")
def _():
    assert incremente(33) == 34

unittest.mock

Pour isoler la partie de code que l’on souhaite tester, il est souvent nécessaire de construire un environnement spécifique. Un véritable environnement fonctionnel, ou des simulateurs de composants, ou encore des « mocks » : des simulateurs au comportement extrêmement simplifié.

La bibliothèque standard unittest.mock permet de créer de tels objets, et de s’en servir pour patcher ou instrumenter son environnement.

Les objets Mock, PropertyMock, NonCallableMock permettent, par combinaison, de construire des objets en leur associant un comportement simple. MagicMock et NonCallableMagicMock vont fonctionner de la même manière, mais leur construction sera construite automatiquement par le code testé.

Il est possible de remplacer en partie un objet, de le faire passer pour l’instance d’une classe particulière (isinstance), de générer une séquence de résultats ou encore de lever une exception.

Après exécution il est possible de vérifier que les appels se sont déroulés comme attendu.

def process(value, database=None):
    result = value ** 2
    if database is not None:
        saved = database.save(value, result)
        if not saved:
            raise Exception("Database problem")
    return result

from unittest import mock

def test_database_is_called():
    database = mock.NonCallableMock()
    database.save = mock.MagicMock(return_value=True)
    result = process(2, database)
    # La base de donnée a bien été appelé.
    # Une seule et unique demande de sauvegarde a été faite
    # avec pour argument la donnée d’entrée et le résultat
    database.save.assert_called_once_with(2, result)

En sus l’utilitaire create_autospec permet de générer un mock répondant aux spécifications d’un objet particulier. Python 3.7 a également ajouté la fonction seal pour verrouiller les mocks dans un état donné.

Enfin les mocks peuvent être placés temporairement en remplacement de classes et de fonctions par le biais de patchs. Par défaut la fonction construit un MagicMock. Elle peut s’utiliser comme un décorateur ou comme une fonction contextuelle (avec l’opérateur with).

import os

def clean_up_config():
    os.remove("foo/bar")
    os.remove("foo/baz")

from unittest import mock

@mock.patch("os.remove")
def test_clean_up(os_remove_mock):
    clean_up_config()
    # Vérifie que la requête de supression des
    # deux fichiers à bien été faite
    calls = [mock.call("foo/bar"), mock.call("foo/baz")]
    os.remove.assert_has_calls(calls, any_order=True)

Il existe encore d’autres utilitaires, qui permettent par exemple de patcher temporairement des dictionnaires, ou de simuler la fonction open. Voir la documentation complète.

pytest-mock

pytest-mock fournit la même API par le biais d’une fixture pytest. Par ce biais, le contexte temporaire est géré automatiquement, et les retours d’erreurs sont également améliorés (en y nettoyant les traces d’exception induites par l’environnement de test).

import os
from unittest import mock

def clean_up_config():
    os.remove("foo/bar")
    os.remove("foo/baz")

def test_clean_up(mocker):
    mocker.patch("os.remove")
    clean_up_config()
    # Vérifie que la requête de supression des
    # deux fichiers à bien été faite
    calls = [mock.call("foo/bar"), mock.call("foo/baz")]
    os.remove.assert_has_calls(calls, any_order=True)

Dans cet exemple de code, l’argument mocker est un appel à une extension de pytest nommée pytest-mock qui permet de mettre en place le mécanisme de mock pour la durée du test en question.

hypothesis

Hypothesis est une implémentation de QuickCheck pour Python (et quelques autres langages).

Cette bibliothèque permet de décrire dans les tests pytest les propriétés qu’une fonction doit avoir. Sa combinatoire en entrée et ses invariants. À l’exécution du test elle génère automatiquement des entrées visant à trouver des effets de bord. Les jeux de test générés sont mis en cache et réutilisés.

C’est donc un complément à des tests standards.

La bibliothèque fournit un ensemble de stratégies pour différents type de données, allant des nombres, aux chaînes de caractères, en passant par les tableaux numpy, ainsi que des outils pour les combiner. Elle permet également de décrire des séquences de changements, que l’on construit à l’aide d’un automate à état, afin de vérifier des systèmes plus complexes.

Voici un exemple permettant de se faire une idée, avec la résolution d’une équation quadratique.

def solve_poly2(a, b, c):
    """Résout l'équation ax^2 + bx + c == 0"""
    delta = b**2 - 4.0 * a * c
    results = []
    if delta > 0:
        results.append((-b + delta**0.5) / (2.0 * a))
        results.append((-b - delta**0.5) / (2.0 * a))
    elif delta == 0:
        results.append(-b / (2.0 * a))
    return results

import pytest
import numpy
from hypothesis import given
from hypothesis.strategies import floats

# Décrit que la fonction prend trois flottants
@given(floats(), floats(), floats())
def test_solve_poly2(a, b, c):
    results = solve_poly2(a, b, c)
    # Test que les résultats sont conformes
    poly = numpy.poly1d([a, b, c])
    for r in results:
        assert pytest.approx(poly(r), 0.0)

Et les résultats ne se font pas attendre.

Falsifying example: test_solve_poly2(a=0.0, b=1.0, c=0.0)
Traceback (most recent call last):
  File "/home/ordinateur/Workspace/pytest/test_number.py", line 35, in test_solve_poly2
    results = solve_poly2(a, b, c)
  File "/home/ordinateur/Workspace/pytest/test_number.py", line 9, in solve_poly2
    results.append((-b + delta**0.5) / (2.0 * a))
ZeroDivisionError: float division by zero

Falsifying example: test_solve_poly2(a=0.0, b=0.0, c=0.0)
Traceback (most recent call last):
  File "/home/ordinateur/Workspace/pytest/test_number.py", line 35, in test_solve_poly2
    results = solve_poly2(a, b, c)
  File "/home/ordinateur/Workspace/pytest/test_number.py", line 12, in solve_poly2
    results.append(-b / (2.0 * a))
ZeroDivisionError: float division by zero

On trouve deux divisions par zéro que nos tests de base auraient pu oublier.

coverage

Pour vérifier que les jeux de tests ont une utilité, une bonne méthode est de vérifier leur couverture. La bibliothèque coverage permet cela.

Son utilisation se fait en deux étapes. D’une part l’exécution. Par exemple :

coverage run -m pytest test_number.py

Puis la génération de résultat. Classiquement la couverture en pourcentage de chaque fichier python.

coverage report

Ou plus utile pour améliorer ses tests, la couverture ligne par ligne.

coverage annotate
more test_number.py,cover

> def solve_poly2(a, b, c):
>    """Solve ax^3 + bx^2 + c == 0"""
>    delta = b**2 - 4.0 * a * c
>    results = []
>    if delta > 0:
>        results.append((-b + delta**0.5) / (2.0 * a))
>        results.append((-b - delta**0.5) / (2.0 * a))
>    elif delta == 0:
>        results.append(-b / (2.0 * a))
>    return results

> def pas_couvert():
!    return O + O

pytest_cov

Cette extension à pytest ajoute les options de couverture à la commande pytest.

Par exemple, pour générer un rapport du pourcentage de couverture.

pytest test_number.py --cov=.

Ou pour générer les fichiers de couverture.

pytest test_number.py --cov=. --cov-report annotate

Voir aussi

https://wiki.python.org/moin/PythonTestingToolsTaxonomy

Et tes astuces ?

Merci de partager tes recommandations, tes mésaventures, tes bonnes pratiques… :-D

J’ai découvert/appris Python en le pratiquant au bureau à l’arrache, et sans collègue à la fois expert et pédagogue. Du coup, j’ai accumulé plein de mauvaises pratiques que je tente désormais de corriger. Cette dépêche est pour partager mes astuces et faire éviter les mêmes pièges :-)

Je ne suis pas encore un expert Python, alors merci de me corriger gentiment dans les commentaires ;-)

Aller plus loin

  • # juste une coquille

    Posté par  (site web personnel) . Évalué à 2.

    Il y a une coquille dans f = lamda x::
    lamda -> lambda

    Le tour des outils est très complet. :)

  • # Type Checking

    Posté par  (site web personnel) . Évalué à 4.

    Perso, ni mypy, ni pyre, ni pytype (google) n'ont su correctement analyser les annotations de type que j'avais (des TypedDict recursifs et des Union à droite à gauche).

    Pire même, pytype ne supporte pas les TypedDict de Python 3.9 et nécessite donc un import de mypy_extensions (ce que je voudrais éviter dans du code en production).

    Seul pyright (microsoft, développé en typescript :/ pour l'extension pylance de VSCode) par contre marche du tonnerre :)

    https://link-society.com - https://kubirds.com

  • # Astuce

    Posté par  (site web personnel) . Évalué à 5.

    Personnellement, je regrette que black mette sur une seule ligne un petit dictionnaire que je trouve plus lisible sur plusieurs lignes.

    Je trouve plus regrettable que le style par défaut ne soit pas de mettre tous les éléments sur une ligne indépendante, ça rend les diffs plus lisibles, et fait des lignes plus courtes. Cependant, on peut forcer black à utiliser ce style, il suffit de mettre une virgule après le dernier élément. Il me semble que c'est le cas pour yapf également.

    • [^] # Re: Astuce

      Posté par  (site web personnel) . Évalué à 2.

      En prenant un peu plus le temps de lire l'article, je pense que c'est ça le fameux magic-trailing-comma.

  • # Merci Philippe !

    Posté par  (Mastodon) . Évalué à 7.

    Merci à tous les contributeurs et un merci spécial à Philippe. Cette dépêche comme les précédentes est publiée sous Collectif mais il faut se rendre compte que sans tout le travail de Philippe qui a été plus ou moins important selon les cas (et carrément géant sur celle-ci), la plupart des dépêches de cette saga récemment publiées serait encore en train de se languir dans l’espace de rédaction.

    Il ne reste plus qu'une dépêche de cette saga dans l’espace de rédaction. N’hésitez pas à y contribuer.
    Tu n’y connais rien à Python ? Tu peux aussi relire pour corriger l’orthographe et les typos. Tu peux aussi remplacer les ' par des ’ (je crois qu’Ysabeau t’en sera reconnaissante ;-)) C’est d’ailleurs valable pour les autres dépêches aussi.

    Surtout, ne pas tout prendre au sérieux !

    • [^] # Re: Merci Philippe !

      Posté par  (site web personnel, Mastodon) . Évalué à 2. Dernière modification le 10 juin 2021 à 19:30.

      Oui Philippe a fait un boulot formidable sur cette dépêche (et les autres de la série) et je l'en remercie.
      Et faire attention à la typographie, ça donne moins de travail en modération, j'apprécie ceux et celles qui y font attention.

      « Tak ne veut pas quʼon pense à lui, il veut quʼon pense », Terry Pratchett, Déraillé.

  • # Attention à pytest-cov

    Posté par  . Évalué à 2.

    Pour avoir utilisé les deux (coverage -m pytest, tout simplement, et en utilisant pytest-cov), j'ai finalement eu moins de soucis avec coverage -m pytest, surtout avec des tests parallèles.

    J'ai un exemple ici :

    https://github.com/JulienPalard/oeis/blob/main/tox.ini

    Où un tox -m all lance des tests sur 3 versions de Python en parallèle et agrège les résultat de coverage à la fin. Faire la même chose avec pytest-cov est un enfer ☹.

    L'agrégation est particulièrement utile dès qu'on a quelque chose comme :

    if sys.version_info > (3, 8):
        ...
    else:
        ...

    sans agréger à la fin, soit le if soit le else sera compté comme non couvert, alors qu'en agrégeant le if et le else peuvent être couverts (et atteindre le fameux 100%, ha, ha).

  • # pre-commit

    Posté par  . Évalué à 3. Dernière modification le 11 juin 2021 à 11:40.

    Merci pour cette dépêche très intéressante.

    Tout cela peut s'interfacer avec pre-commit qui permet de de lancer des actions avant chaque commit git.

    Nous combinons alors black et isort dans des hooks de pre-commit afin de s'assurer que les fichiers soient formatés de manière "standard" avant intégration au dépôt.

    Dans d'autres projets nous intégrons aussi flake8 (via flakehell qui porte bien son nom), mais c'est trop sévère à mon goût et les corrections ne se font pas automatiquement. Ça ajoute une forte friction lors des commits (à en devenir angoissant parfois).

    Config .pre-commit-config.yaml

    repos:
    - repo: https://github.com/pycqa/isort
      rev: 5.6.3
      hooks:
        - id: isort
          name: isort (python)
    - repo: https://github.com/ambv/black
      rev: 20.8b1
      hooks:
      - id: black
        args: ["--line-length", "120"]
        language_version: python3
        exclude: ".*/migrations/.*.py"
    

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.