URL:
https://linuxfr.org/news/python-partie-10-formateur-de-code-analyse-statique
Title: Python — partie 10 ― formateur de code, analyse statique
Authors: Collectif
Philippe F, Oliver, bayo, Ysabeau, Yves Bourguignon, Atem18, Gil Cot, vaxvms, tisaac, gusterhack, yPhil, patrick_g et François GUÉRIN
Date: 2019-09-05T18:37:54+02:00
License: CC By-SA
Tags: python
Score: 4
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](
https://linuxfr.org/users/oliver_h) et [Philippe](
https://linuxfr.org/users/bluebird), 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. 🐍 🐍 🐍
[](
https://github.com/olibre/GreatPractices/tree/master/python)
Pour rappel, les autres dépêches déjà publiées :
* Python — [Popularité](
https://linuxfr.org/news/python-pour-la-rentree-2019-partie-1)
* Python — [Python 2](
https://linuxfr.org/news/python-pour-la-rentree-2019-partie-2)
* Python — [Installation de Python et de paquets](
https://linuxfr.org/news/python-pour-la-rentree-2019-partie-3-installation-de-python-et-de-paquets)
* Python — [Py Pyenv](
https://linuxfr.org/news/python-pour-noel-2019-partie-4-py-pyenv)
* Python — [Nix (et Guix)](
https://linuxfr.org/news/python-partie-5-nix-et-guix)
* Python — [Pip et Pipx](
https://linuxfr.org/news/python-partie-6-pip-et-pipx)
* Python — [environnements virtuels](
https://linuxfr.org/news/python-pour-noel-202x-partie-7-environnements-virtuels)
* Python — [Pipenv](
https://linuxfr.org/news/python-partie-8-pipenv)
* Python — [Entretiens](
https://linuxfr.org/news/python-partie-11-entretiens)
----
----
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` :
```shell
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 :
```python
$ 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`](
https://github.com/psf/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 :
```shell
$ black .
reformatted bateau.py
All done! ✨ 🍰 ✨
1 file reformatted.
```
```python
$ 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`](
https://pypi.org/project/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](
https://github.com/google/yapf) 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`](
https://github.com/llvm-mirror/clang/tree/master/tools/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` :
```python
$ 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`](
https://github.com/hhatto/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 = lamda 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 :
```python
$ 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`](
https://github.com/PyCQA/isort.git) 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 :
```python
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 :
```python
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](
https://github.com/bluebird75/sxtool/commit/ffca043d63bf649efc3a59528991f67398861116) ;
* [reformatage avec black](
https://github.com/bluebird75/sxtool/commit ;/8523a4354e75df924ab3a12c255000f95db4fd41), 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](
https://github.com/bluebird75/sxtool/commit/89fe05e28d1df64bc1be0a6f8bf7cfd7afcff95e), longueur de ligne à 120 ;
* [reformatage avec yapf](
https://github.com/bluebird75/sxtool/commit/ae4f8e07e82de84df22e516cff0bcdf3c1c41c9c), longueur de ligne à 110 ;
* [reformatage avec autopep8](
https://github.com/bluebird75/sxtool/commit/9f29f578eeddc2f44e75e24065059ce6a3a28d83) ;
* [reformatage avec isort](
https://github.com/bluebird75/sxtool/commits/reformat-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`](
https://github.com/myint/docformatter) permet de reformater la partie [docstring](
https://en.wikipedia.org/wiki/Docstring#Python) du code source.
Nous l’utilisons avec ces paramètres :
```shell
docformatter --wrap-summaries 444 --pre-summary-newline --in-place --recursive .
```
`pyment`
--------
La documentation du code source Python se fait à l’aide des [**docstring**](
http://sametmax.com/les-docstrings/) standardisée par la [PEP 257](
https://www.python.org/dev/peps/pep-0257/) (2001). Plusieurs types de **docstring** sont utilisés, les plus connus étant :
* le format [reStructuredText](
https://fr.wikipedia.org/wiki/ReStructuredText) de la [PEP 287](
https://www.python.org/dev/peps/pep-0287/) (2002) ;
* le [format recommandé par Google](
https://google.github.io/styleguide/pyguide.html#comments) ([exemple](
https://sphinxcontrib-napoleon.readthedocs.io/en/latest/example_google.html)) ;
* le format utilisé par le projet NumPy, connu sous le nom de [numpydoc](
https://numpydoc.readthedocs.io/en/latest/format.html) ([exemple](
https://numpydoc.readthedocs.io/en/latest/example.html#example)).
L’outil `pyment` permet de créer, corriger et de modifier ces représentations docstring.
L’auteur de [`pyment`](
https://pypi.org/project/pyment/), [Adel Daouzli](
http://daouzli.com/blog/pages/About.html) (dadadel) nous avait [présenté son outil dans son journal](
https://linuxfr.org/users/dadadel/journaux/pyment-patcher-les-docstrings-python) (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 surun 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](
https://docs.python.org/fr/3/library/typing.html)) 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](
https://en.wikipedia.org/wiki/Pdoc), successeur du bon vieux [Epydoc](
https://en.wikipedia.org/wiki/Epydoc) ;
* [Sphinx](
https://fr.wikipedia.org/wiki/Sphinx_(généA9rateur_de_documentation)), LE générateur de documentation Python le plus connu.
Attention, pour Pdoc, nous avons deux projets qui ont divergé : [`pdoc`](
https://github.com/mitmproxy/pdoc) (l’original) et [`pdoc3`](
https://github.com/pdoc3/pdoc) (le *fork*, plus actif).
Analyse statique de code
========================
pylint
-------
[Pylint](
https://www.pylint.org/) 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](
https://github.com/bluebird75/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](
https://gist.github.com/bluebird75/dcfb7b66d9f7ba70bb6c982f0214fea8). 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](
https://gist.github.com/bluebird75/34b1000e4171761606a74c4d966b143d) 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](
https://pylint.readthedocs.io/en/latest/technical_reference/features.html). 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`](
https://github.com/PyCQA/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](
https://github.com/bluebird75/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](
https://github.com/PyCQA/pyflakes/blob/master/NEWS.rst) 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 : <error>, 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](
https://github.com/bluebird75/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](
https://github.com/DmytroLitvinov/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](
https://github.com/PyCQA/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](
https://github.com/bluebird75/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](
https://gist.github.com/bluebird75/a85948e84d963e6ab52c0a6c2b1ceb0d).
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](
https://github.com/python/typeshed/tree/master/stubs) 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:
```python
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 :
```python
def print_is_equal(a, b):
if is_equal(a, b):
print('Egalité pour', a)
else:
print('Différence:', a, b)
```
```python
>>> 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 :
```python
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](
http://www.mypy-lang.org/) 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](
https://fr.wikipedia.org/wiki/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é](
https://mypy.readthedocs.io/) pour aider à se mettre au typage. L’outil dispose d’un [large jeu d’option](
https://mypy.readthedocs.io/en/stable/command_line.html), 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](
https://mypy.readthedocs.io/en/stable/mypy_daemon.html) 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.
```python
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 :
```python
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](
https://pyre-check.org/) 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](
https://pyre-check.org/docs/pysa-basics) 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`](
https://github.com/Instagram/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`:
```python
>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.
```python
>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`**](
https://docs.python.org/dev/library/unittest.html). D’autres alternatives intéressantes : [**`nose`**](
https://nose.readthedocs.io/en/latest/) et [**`pytest`**](
https://docs.pytest.org/).
`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.
```py
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 :
```py
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 :
```py
> 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 :
```py
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 :
```py
======================================================================
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.
```py
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`](
https://github.com/darrenburns/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 :
```py
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.
```py
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`).
```py
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](
https://docs.python.org/3/library/unittest.mock.html).
`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).
```py
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](
https://github.com/pytest-dev/pytest-mock/) qui permet de mettre en place le mécanisme de mock pour la durée du test en question.
`hypothesis`
------------
[Hypothesis](
https://hypothesis.works/) est une implémentation de [QuickCheck](
https://en.wikipedia.org/wiki/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](
https://fr.wikipedia.org/wiki/%C3%89quation_du_second_degr%C3%A9).
```py
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.
```shell
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 example, 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 ;-)