backend / python
Écrire une CLI robuste : argparse avancé et Typer
Explication
Ce que vous allez apprendre
- Construire un parser
argparseavec arguments obligatoires, optionnels et sous-commandes - Générer automatiquement une aide (
--help) cohérente à partir de la définition des arguments - Valider les entrées dès le parsing plutôt que dans la logique métier
- Retourner un code de sortie explicite pour que les scripts d'automatisation détectent les erreurs
- Situer Typer comme alternative moderne basée sur les annotations de type
Dans quel contexte ?
Un développeur a écrit un script sync.py qui lit sys.argv[1] et sys.argv[2] directement pour récupérer un dossier source et une destination. Le jour où un collègue l'exécute sans argument, le script plante avec un IndexError incompréhensible au lieu d'expliquer ce qui est attendu. Migrer vers argparse avec des arguments nommés (--source, --dest) génère automatiquement un message d'aide clair et un rejet propre si un argument obligatoire manque.
D'un script bricolé à un outil en ligne de commande professionnel
Un script qui lit sys.argv[1] directement fonctionne, mais casse dès qu'un utilisateur se trompe d'argument, oublie une option obligatoire, ou demande simplement l'aide. Une CLI (Command Line Interface) robuste doit valider ses entrées, afficher une aide claire, et distinguer proprement les erreurs (mauvaise utilisation vs échec interne).
argparse : le standard, verbeux mais complet
argparse, inclus dans la bibliothèque standard, construit un "parser" qui décrit chaque argument attendu (nom, type, valeur par défaut, aide). Il génère automatiquement le message d'aide (--help), valide les types, et rejette les combinaisons incohérentes. Les sous-commandes (add_subparsers) permettent de construire des outils au style git <commande>, où chaque sous-commande a ses propres options.
Pourquoi valider dès le parsing plutôt que dans la logique métier
Un type personnalisé passé à type= transforme et valide la valeur au moment même où elle est lue en ligne de commande, avec un message d'erreur généré automatiquement et cohérent avec le reste de l'aide. C'est préférable à valider plus tard dans le code métier, où l'erreur serait moins claire pour l'utilisateur.
Le code de sortie, souvent oublié
Une CLI qui se termine sans sys.exit() explicite renvoie 0 (succès) même en cas d'erreur — ce qui casse silencieusement les scripts d'automatisation qui vérifient ce code. Une bonne pratique consiste à distinguer les codes de sortie selon la nature de l'échec.
Piège fréquent
Un script qui affiche print("Erreur : fichier introuvable") puis se termine normalement renvoie le code de sortie 0, comme s'il avait réussi. Un pipeline CI qui enchaîne monoutil sync && deployer.sh continuera donc à déployer malgré l'échec de la synchronisation. Toujours terminer avec sys.exit(1) (ou un code non nul) en cas d'erreur.
Typer : le confort moderne au-dessus de Click
Typer dérive automatiquement le parsing, la validation et l'aide à partir des annotations de type de vos fonctions Python normales, réduisant drastiquement le code répétitif d'argparse — au prix d'une dépendance externe.
| Critère | argparse | Typer |
|---|---|---|
| Dépendance externe | Non (stdlib) | Oui |
| Basé sur | Un objet parser construit manuellement | Les annotations de type d'une fonction |
| Verbosité | Élevée | Faible |
| Aide générée | Oui | Oui, plus riche (couleurs, panneaux) |
Commandes & code
Écrire une CLI robuste
Des scripts jetables aux outils en ligne de commande professionnels.
# --- argparse : le module stdlib, verbeux mais complet et sans dependance ---
import argparse
import sys
def construire_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="monoutil",
description="Synchronise des fichiers vers un serveur distant",
epilog="Exemple : monoutil sync --source ./data --dest s3://bucket -v",
)
parser.add_argument(
"--version", action="version", version="%(prog)s 2.1.0"
)
# Sous-commandes (pattern git-like : monoutil sync, monoutil clean, ...)
sous_commandes = parser.add_subparsers(dest="commande", required=True)
parser_sync = sous_commandes.add_parser("sync", help="Synchronise des fichiers")
parser_sync.add_argument(
"--source", type=str, required=True, help="Dossier source"
)
parser_sync.add_argument(
"--dest", type=str, required=True, help="Destination (chemin ou URI)"
)
parser_sync.add_argument(
"--retries", type=int, default=3, help="Nombre de tentatives (defaut : 3)"
)
parser_sync.add_argument(
"-v", "--verbose", action="count", default=0,
help="Augmente la verbosite (-v, -vv, -vvv)"
)
# Arguments mutuellement exclusifs : impossible de combiner --quiet et --verbose
groupe_bruit = parser_sync.add_mutually_exclusive_group()
groupe_bruit.add_argument("--quiet", action="store_true")
groupe_bruit.add_argument("--dry-run", action="store_true", help="Simule sans agir")
# Type personnalise : valider/transformer une valeur des le parsing
def taille_positive(valeur: str) -> int:
n = int(valeur)
if n <= 0:
raise argparse.ArgumentTypeError(f"{valeur} doit etre un entier positif")
return n
parser_sync.add_argument("--chunk-size", type=taille_positive, default=1024)
# choices : restreint les valeurs acceptees, message d'erreur automatique
parser_sync.add_argument(
"--strategie", choices=["miroir", "additif", "differentiel"], default="miroir"
)
parser_clean = sous_commandes.add_parser("clean", help="Nettoie les fichiers temporaires")
parser_clean.add_argument("--force", action="store_true")
return parser
def main(argv: list[str] | None = None) -> int:
parser = construire_parser()
args = parser.parse_args(argv)
if args.commande == "sync":
niveau = "DEBUG" if args.verbose >= 2 else "INFO" if args.verbose == 1 else "WARNING"
print(f"Sync {args.source} -> {args.dest} (niveau log : {niveau})")
if args.dry_run:
print("Mode simulation : aucune modification reelle")
return 0
# ... logique reelle ...
return 0
elif args.commande == "clean":
print(f"Nettoyage (force={args.force})")
return 0
parser.print_help()
return 1
if __name__ == "__main__":
sys.exit(main())
# Usage :
# python cli.py sync --source ./data --dest ./backup -vv --dry-run
# python cli.py sync --chunk-size -5 -> erreur claire via argparse.ArgumentTypeError
# --- Fallback sur des variables d'environnement pour les valeurs par defaut ---
import os
parser_env = argparse.ArgumentParser()
parser_env.add_argument(
"--api-key",
default=os.environ.get("API_KEY"),
required="API_KEY" not in os.environ, # requis seulement si pas dans l'environnement
)
# --- Codes de sortie conventionnels : 0 = succes, non-zero = categorie d'erreur ---
CODE_SUCCES = 0
CODE_ERREUR_UTILISATEUR = 1
CODE_ERREUR_RESEAU = 2
CODE_ERREUR_INTERNE = 3
def main_avec_codes(argv=None) -> int:
try:
# ... logique ...
return CODE_SUCCES
except ValueError:
print("Argument invalide", file=sys.stderr)
return CODE_ERREUR_UTILISATEUR
except ConnectionError:
print("Erreur reseau", file=sys.stderr)
return CODE_ERREUR_RESEAU
except Exception:
print("Erreur interne inattendue", file=sys.stderr)
return CODE_ERREUR_INTERNE
# --- Typer : CLI moderne basee sur les type hints, au-dessus de Click ---
# pip install typer
'''
import typer
from pathlib import Path
from typing_extensions import Annotated
app = typer.Typer(help="Synchronise des fichiers vers un serveur distant")
@app.command()
def sync(
source: Annotated[Path, typer.Option(help="Dossier source", exists=True)],
dest: Annotated[str, typer.Option(help="Destination")],
retries: Annotated[int, typer.Option(help="Nombre de tentatives")] = 3,
verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
):
"""Synchronise SOURCE vers DEST."""
if verbose:
typer.echo(f"Sync verbeux : {source} -> {dest}")
with typer.progressbar(range(100)) as barre:
for _ in barre:
pass # traitement reel ici
typer.secho("Termine !", fg=typer.colors.GREEN)
@app.command()
def clean(force: bool = False):
"""Nettoie les fichiers temporaires."""
if not force and not typer.confirm("Confirmer le nettoyage ?"):
raise typer.Abort()
typer.echo("Nettoyage effectue")
if __name__ == "__main__":
app()
'''
# Typer genere automatiquement : --help, la completion shell (bash/zsh/fish),
# la validation de type depuis les annotations, et les messages d'erreur formattes.Résumé
add_subparsersstructure une CLI en sous-commandes (patterngit <commande>), chacune avec ses propres arguments.- Un
type=personnalise suradd_argumentvalide/transforme la valeur des le parsing, avec un message d'erreur clair. - Toujours retourner un code de sortie explicite (
sys.exit) distinguant erreur utilisateur, réseau et interne. - Typer (au-dessus de Click) derive parser, validation et aide depuis les type hints, avec beaucoup moins de boilerplate qu'argparse.
Exercices pratiques
Mission : réparer un pipeline CI qui déploie malgré un échec
Objectif : Corriger une CLI qui ne renvoie jamais de code d'erreur non nul, puis migrer un script fragile basé sur sys.argv vers un vrai parser argparse.
Contexte
Un pipeline CI enchaîne monoutil sync && deployer.sh. Un jour, la synchronisation échoue avec un message print("Erreur : fichier introuvable") affiché dans les logs, mais le déploiement se lance quand même juste après, écrasant une version fonctionnelle en production. En parallèle, un autre script sync.py lit encore sys.argv[1] et sys.argv[2] directement, et plante avec un IndexError incompréhensible dès qu'un argument manque.
Tu dois d'abord corriger le code de sortie de la CLI, puis migrer le script fragile vers argparse avec des arguments nommés et validés.