Écrire un script Python durable
Vincent Bernat
Python est un excellent langage pour écrire rapidement un script entre une dizaine et quelques centaines de lignes de code. Une fois terminé, vous pouvez l’oublier et vous concentrer sur votre prochaine mission.
Six mois plus tard, un collègue vous demande pourquoi ce script échoue et vous n’en avez aucune idée : pas de documentation, paramètres codés en dur, aucune trace pendant l’exécution et aucuns tests pour vous éclairer.
Transformer un script Python « vite fait » en une version durable, facile à utiliser, à comprendre et à modifier par vos collègues et votre futur alter ego, ne demande qu’un effort modéré. À titre d’illustration, commençons par le script suivant pour résoudre le test classique « Fizz-Buzz » :
import sys for n in range(int(sys.argv[1]), int(sys.argv[2])): if n % 3 == 0 and n % 5 == 0: print("fizzbuzz") elif n % 3 == 0: print("fizz") elif n % 5 == 0: print("buzz") else: print(n)
Documentation#
Je trouve utile d’écrire de la documentation avant même d’écrire la première ligne de code : cela facilite la conception et m’assure de ne pas reporter cette tâche indéfiniment. La documentation peut être placée en haut du script1 :
#!/usr/bin/env python3 """Simple fizzbuzz generator. This script prints out a sequence of numbers from a provided range with the following restrictions: - if the number is divisble by 3, then print out "fizz," - if the number is divisible by 5, then print out "buzz," - if the number is divisible by 3 and 5, then print out "fizzbuzz." """
La première ligne est un bref résumé du but du script. Les autres paragraphes contiennent des détails supplémentaires sur son action.
Arguments en ligne de commande#
La deuxième étape consiste à transformer les paramètres codés en dur
en valeurs documentées et configurables à l’aide d’arguments en ligne
de commande, via le module argparse
. Dans notre
exemple, nous demandons à l’utilisateur de spécifier une plage et nous
lui permettons de modifier les valeurs modulo pour « fizz » et « buzz ».
import argparse import sys class CustomFormatter(argparse.RawDescriptionHelpFormatter, argparse.ArgumentDefaultsHelpFormatter): pass def parse_args(args=sys.argv[1:]): """Parse arguments.""" parser = argparse.ArgumentParser( description=sys.modules[__name__].__doc__, formatter_class=CustomFormatter) g = parser.add_argument_group("fizzbuzz settings") g.add_argument("--fizz", metavar="N", default=3, type=int, help="Modulo value for fizz") g.add_argument("--buzz", metavar="N", default=5, type=int, help="Modulo value for buzz") parser.add_argument("start", type=int, help="Start value") parser.add_argument("end", type=int, help="End value") return parser.parse_args(args) options = parse_args() for n in range(options.start, options.end + 1): # ...
La valeur ajoutée de cette modification est considérable : les
paramètres sont maintenant correctement documentés et peuvent être
découverts grâce à l’option --help
. De plus, la documentation que
nous avons écrite dans la section précédente est également affichée :
$ ./fizzbuzz.py --help usage: fizzbuzz.py [-h] [--fizz N] [--buzz N] start end Simple fizzbuzz generator. This script prints out a sequence of numbers from a provided range with the following restrictions: - if the number is divisble by 3, then print out "fizz," - if the number is divisible by 5, then print out "buzz," - if the number is divisible by 3 and 5, then print out "fizzbuzz." positional arguments: start Start value end End value optional arguments: -h, --help show this help message and exit fizzbuzz settings: --fizz N Modulo value for fizz (default: 3) --buzz N Modulo value for buzz (default: 5)
Le module argparse
est assez puissant. S’il ne vous est pas
familier, un survol de sa documentation est utile.
J’aime particulièrement la possibilité de définir des
sous-commandes et de grouper des options.
Traces#
La troisième étape est d’afficher des informations durant l’exécution.
Le module logging
convient parfaitement à cet
effet. Tout d’abord, nous définissons le « logger » :
import logging import logging.handlers import os import sys logger = logging.getLogger(os.path.splitext(os.path.basename(sys.argv[0]))[0])
Ensuite, nous rendons sa verbosité configurable : logger.debug()
ne
devrait afficher quelque chose que lorsqu’un utilisateur utilise le
drapeau --debug
. --silent
devrait couper les traces sauf si une
condition exceptionnelle se produit. Pour cela, nous ajoutons le code
suivant dans parse_args()
:
# In parse_args() g = parser.add_mutually_exclusive_group() g.add_argument("--debug", "-d", action="store_true", default=False, help="enable debugging") g.add_argument("--silent", "-s", action="store_true", default=False, help="don't log to console")
Cette fonction permet alors de configurer les traces :
def setup_logging(options): """Configure logging.""" root = logging.getLogger("") root.setLevel(logging.WARNING) logger.setLevel(options.debug and logging.DEBUG or logging.INFO) if not options.silent: ch = logging.StreamHandler() ch.setFormatter(logging.Formatter( "%(levelname)s[%(name)s] %(message)s")) root.addHandler(ch)
Le corps de notre script devient ceci :
if __name__ == "__main__": options = parse_args() setup_logging(options) try: logger.debug("compute fizzbuzz from {} to {}".format(options.start, options.end)) for n in range(options.start, options.end + 1): # ... except Exception as e: logger.exception("%s", e) sys.exit(1) sys.exit(0)
Si le script peut être exécuté non interactivement, par exemple depuis une crontab, il est possible d’envoyer les traces vers syslog2 :
def setup_logging(options): """Configure logging.""" root = logging.getLogger("") root.setLevel(logging.WARNING) logger.setLevel(options.debug and logging.DEBUG or logging.INFO) if not options.silent: if not sys.stderr.isatty(): facility = logging.handlers.SysLogHandler.LOG_DAEMON sh = logging.handlers.SysLogHandler(address='/dev/log', facility=facility) sh.setFormatter(logging.Formatter( "{0}[{1}]: %(message)s".format( logger.name, os.getpid()))) root.addHandler(sh) else: ch = logging.StreamHandler() ch.setFormatter(logging.Formatter( "%(levelname)s[%(name)s] %(message)s")) root.addHandler(ch)
Pour cet exemple, cela représente beaucoup de code pour un seul appel
à logger.debug()
, mais en situation réelle, cela est très utile pour
aider un utilisateur à comprendre le déroulement du script.
$ ./fizzbuzz.py --debug 1 3 DEBUG[fizzbuzz] compute fizzbuzz from 1 to 3 1 2 fizz
Tests#
Les tests unitaires sont très utiles pour s’assurer qu’une application se comporte comme prévu. Il n’est pas courant de les utiliser dans des scripts, mais en écrire quelques-uns améliore grandement la qualité. Transformons le code contenu dans la boucle en une fonction avec quelques exemples interactifs d’utilisation dans sa documentation :
def fizzbuzz(n, fizz, buzz): """Compute fizzbuzz nth item given modulo values for fizz and buzz. >>> fizzbuzz(5, fizz=3, buzz=5) 'buzz' >>> fizzbuzz(3, fizz=3, buzz=5) 'fizz' >>> fizzbuzz(15, fizz=3, buzz=5) 'fizzbuzz' >>> fizzbuzz(4, fizz=3, buzz=5) 4 >>> fizzbuzz(4, fizz=4, buzz=6) 'fizz' """ if n % fizz == 0 and n % buzz == 0: return "fizzbuzz" if n % fizz == 0: return "fizz" if n % buzz == 0: return "buzz" return n
pytest permet de s’assurer que les résultats sont corrects3 :
$ python3 -m pytest -v --log-level=debug --doctest-modules ./fizzbuzz.py ============================ test session starts ============================= platform linux -- Python 3.7.4, pytest-3.10.1, py-1.8.0, pluggy-0.8.0 -- /usr/bin/python3 cachedir: .pytest_cache rootdir: /home/bernat/code/perso/python-script, inifile: plugins: xdist-1.26.1, timeout-1.3.3, forked-1.0.2, cov-2.6.0 collected 1 item fizzbuzz.py::fizzbuzz.fizzbuzz PASSED [100%] ========================== 1 passed in 0.05 seconds ==========================
En cas d’erreur, pytest affiche un message décrivant la localisation et la nature du problème :
$ python3 -m pytest -v --log-level=debug --doctest-modules ./fizzbuzz.py -k fizzbuzz.fizzbuzz ============================ test session starts ============================= platform linux -- Python 3.7.4, pytest-3.10.1, py-1.8.0, pluggy-0.8.0 -- /usr/bin/python3 cachedir: .pytest_cache rootdir: /home/bernat/code/perso/python-script, inifile: plugins: xdist-1.26.1, timeout-1.3.3, forked-1.0.2, cov-2.6.0 collected 1 item fizzbuzz.py::fizzbuzz.fizzbuzz FAILED [100%] ================================== FAILURES ================================== ________________________ [doctest] fizzbuzz.fizzbuzz _________________________ 100 101 >>> fizzbuzz(5, fizz=3, buzz=5) 102 'buzz' 103 >>> fizzbuzz(3, fizz=3, buzz=5) 104 'fizz' 105 >>> fizzbuzz(15, fizz=3, buzz=5) 106 'fizzbuzz' 107 >>> fizzbuzz(4, fizz=3, buzz=5) 108 4 109 >>> fizzbuzz(4, fizz=4, buzz=6) Expected: fizz Got: 4 /home/bernat/code/perso/python-script/fizzbuzz.py:109: DocTestFailure ========================== 1 failed in 0.02 seconds ==========================
Nous pouvons également écrire des tests unitaires sous forme de code. Supposons que nous voulions tester la fonction suivante :
def main(options): """Compute a fizzbuzz set of strings and return them as an array.""" logger.debug("compute fizzbuzz from {} to {}".format(options.start, options.end)) return [str(fizzbuzz(i, options.fizz, options.buzz)) for i in range(options.start, options.end+1)]
À la fin du script4, nous ajoutons quelques tests unitaires utilisant les tests paramétrés de pytest :
# Unit tests import pytest # noqa: E402 import shlex # noqa: E402 @pytest.mark.parametrize("args, expected", [ ("0 0", ["fizzbuzz"]), ("3 5", ["fizz", "4", "buzz"]), ("9 12", ["fizz", "buzz", "11", "fizz"]), ("14 17", ["14", "fizzbuzz", "16", "17"]), ("14 17 --fizz=2", ["fizz", "buzz", "fizz", "17"]), ("17 20 --buzz=10", ["17", "fizz", "19", "buzz"]), ]) def test_main(args, expected): options = parse_args(shlex.split(args)) assert main(options) == expected
La fonction de test s’exécute une fois pour chacun des paramètres
fournis. La partie args
est utilisée comme entrée pour la fonction
parse_args()
afin d’obtenir les options à passer à la fonction
main()
. La partie expected
est comparée au résultat de la fonction
main()
. Quand tout fonctionne comme prévu, pytest affiche :
python3 -m pytest -v --log-level=debug --doctest-modules ./fizzbuzz.py ============================ test session starts ============================= platform linux -- Python 3.7.4, pytest-3.10.1, py-1.8.0, pluggy-0.8.0 -- /usr/bin/python3 cachedir: .pytest_cache rootdir: /home/bernat/code/perso/python-script, inifile: plugins: xdist-1.26.1, timeout-1.3.3, forked-1.0.2, cov-2.6.0 collected 7 items fizzbuzz.py::fizzbuzz.fizzbuzz PASSED [ 14%] fizzbuzz.py::test_main[0 0-expected0] PASSED [ 28%] fizzbuzz.py::test_main[3 5-expected1] PASSED [ 42%] fizzbuzz.py::test_main[9 12-expected2] PASSED [ 57%] fizzbuzz.py::test_main[14 17-expected3] PASSED [ 71%] fizzbuzz.py::test_main[14 17 --fizz=2-expected4] PASSED [ 85%] fizzbuzz.py::test_main[17 20 --buzz=10-expected5] PASSED [100%] ========================== 7 passed in 0.03 seconds ==========================
Quand une erreur survient, pytest fournit une évaluation de la situation :
$ python3 -m pytest -v --log-level=debug --doctest-modules ./fizzbuzz.py […] ================================== FAILURES ================================== __________________________ test_main[0 0-expected0] __________________________ args = '0 0', expected = ['0'] @pytest.mark.parametrize("args, expected", [ ("0 0", ["0"]), ("3 5", ["fizz", "4", "buzz"]), ("9 12", ["fizz", "buzz", "11", "fizz"]), ("14 17", ["14", "fizzbuzz", "16", "17"]), ("14 17 --fizz=2", ["fizz", "buzz", "fizz", "17"]), ("17 20 --buzz=10", ["17", "fizz", "19", "buzz"]), ]) def test_main(args, expected): options = parse_args(shlex.split(args)) > assert main(options) == expected E AssertionError: assert ['fizzbuzz'] == ['0'] E At index 0 diff: 'fizzbuzz' != '0' E Full diff: E - ['fizzbuzz'] E + ['0'] fizzbuzz.py:160: AssertionError ----------------------------- Captured log call ------------------------------ fizzbuzz.py 125 DEBUG compute fizzbuzz from 0 to 0 ===================== 1 failed, 6 passed in 0.05 seconds =====================
L’appel à logger.debug()
est inclus dans la sortie. C’est une autre
bonne raison d’émettre des traces ! Si vous voulez en savoir plus sur
les fonctionnalités de pytest, jetez un coup d’œil sur « Test d’un
applicatif réseau avec pytest et les espaces de noms Linux ».
En résumé, les quatre modifications à apporter pour rendre un script Python plus durable sont :
- ajouter de la documentation en haut du script,
- utiliser le module
argparse
pour documenter les différents paramètres, - utiliser le module
logging
pour enregistrer les détails sur l’exécution, - ajouter quelques tests unitaires.
L’exemple complet est disponible sur GitHub et peut être utilisé comme modèle !
-
La documentation est systématiquement en anglais. Dans notre domaine, il paraît difficile de faire l’impasse sur ce sujet. ↩︎
-
Alternativement, envoyer les traces vers
journald
est plus simple :↩︎from systemd import journal # […] if not sys.stderr.isatty(): sh = journal.JournalHandler(SYSLOG_IDENTIFIER=logger.name) root.addHandler(sh)
-
Ceci nécessite que le nom du script se termine par
.py
. Je n’aime pas ajouter une extension à un nom de script : le langage est un détail technique qui ne doit pas être exposé à l’utilisateur. Cependant, il semble que ce soit le moyen le plus simple de permettre aux programmes tels que pytest de découvrir les tests à exécuter. ↩︎ -
Du fait que le script se termine par un appel à
sys.exit()
, le code contenant les tests n’est pas exécuté en temps normal. Ainsi,pytest
n’est pas nécessaire pour faire tourner le script. ↩︎