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. 🐍 🐍 🐍
Pour rappel, les autres dépêches déjà publiées :
- Python — Popularité
- Python — Python 2
- Python — Installation de Python et de paquets
- Python — Py Pyenv
- Python — Nix (et Guix)
- Python — Pip et Pipx
- Python — environnements virtuels
- Python — Pipenv
- Python — Entretiens
Sommaire
- Formateurs de code source
- Formateurs de docstring
- Analyse statique de code
- Annotation de type
- Tests unitaires
- Et tes astuces ?
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 :
- reformatage avec black, zéro configuration ;
-
reformatage avec black, longueur de ligne à 120, pas de changements sur les chaînes (conservation des simple quote) et pas de
magic-trailing-comma
(je n’ai aucune idée de ce que ça fait) ; - reformatage avec blue, longueur de ligne à 120 ;
- reformatage avec yapf, longueur de ligne à 110 ;
- reformatage avec autopep8 ;
- reformatage avec isort.
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 :
- le format reStructuredText de la PEP 287 (2002) ;
- le format recommandé par Google (exemple) ;
- le format utilisé par le projet NumPy, connu sous le nom de numpydoc (exemple).
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 :
- https://framagit.org/olibre/pyment
- https://gitlab.com/olibre/pyment
- https://github.com/olibre/pyment
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 :
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 unreturn
.
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
etnose
, 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 :
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
- Python — partie 1 ― Popularité (1 clic)
- Python — partie 2 ― Python 2 (3 clics)
- Python — partie 3 — Installation de Python et de paquets (1 clic)
- Python — partie 4 — Py Pyenv (1 clic)
- Python — partie 6 — Pip et Pipx (8 clics)
- Python — partie 7 — Environnements virtuels (2 clics)
- Python— partie 8 — Pipenv (2 clics)
- Python — partie 10 — Entretiens (22 clics)
# juste une coquille
Posté par srb (site web personnel) . Évalué à 2.
Il y a une coquille dans
f = lamda x:
:lamda -> lambda
Le tour des outils est très complet. :)
[^] # Re: juste une coquille
Posté par Benoît Sibaud (site web personnel) . Évalué à 4.
Corrigé, merci. Ça change de lambada.
[^] # Re: juste une coquille
Posté par Gil Cot ✔ (site web personnel, Mastodon) . Évalué à 1.
Autre coquille :
il manque l'espace « publiée sur un triple »
“It is seldom that liberty of any kind is lost all at once.” ― David Hume
[^] # Re: juste une coquille
Posté par Benoît Sibaud (site web personnel) . Évalué à 3.
Corrigé, merci.
# Type Checking
Posté par David Delassus (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 - https://github.com/link-society/flowg
[^] # Re: Type Checking
Posté par Philippe F (site web personnel) . Évalué à 5.
Intéressant. Je me serai attendu à ce que mypy soit le leader sur le sujet.
Sinon, en passant, j'ai découvert un outil pour convertir les annotations style Python 2 (commentaires) en Python 3: https://github.com/ilevkivskyi/com2ann .
# Astuce
Posté par liberforce (site web personnel) . Évalué à 5.
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 liberforce (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 tisaac (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 Ysabeau 🧶 (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 JulienPalard . Évalué à 2.
Pour avoir utilisé les deux (
coverage -m pytest
, tout simplement, et en utilisant pytest-cov), j'ai finalement eu moins de soucis aveccoverage -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 decoverage
à 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 :
sans agréger à la fin, soit le if soit le else sera compté comme non couvert, alors qu'en agrégeant le
if
et leelse
peuvent être couverts (et atteindre le fameux 100%, ha, ha).[^] # Re: Attention à pytest-cov
Posté par Philippe F (site web personnel) . Évalué à 2.
Merci pour le tuyau !
# pre-commit
Posté par Fabien . É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
Suivre le flux des commentaires
Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.