Aller au contenu
English

L'interface en ligne de commande

  • Leçon 17
  • 45 min
  • Module 6 : Du carnet Jupyter au projet facile à maintenir
Leçon 17 sur 18 · Python pour l'ingénierie de l'IA en pratiqueL'interface en ligne de commande

Ce que vous saurez faire

Exécutez ticket-cleaner en une commande, avec le fichier d’entrée et les options sur la ligne de commande : python -m ticket_cleaner data/tickets.csv --status pending. Pour l’utiliser, personne n’a besoin de lire le code ou de définir des variables. --help (aide) explique chaque option.

Carnet JupyterFaites cette leçon dans un carnet JupyterColab · Kaggle · GitHub · en anglais

Le carnet (notebook) Jupyter contient chaque étape, tâche et vérification de cette leçon, avec les instructions. Ses premières cellules créent le dossier ticket-cleaner tel qu’il est au début de cette leçon, avec les tests. Elles installent aussi httpx et pytest et téléchargent les fichiers de données. Le carnet fonctionne donc sans votre projet local. Lisez les explications ici et exécutez le code dans le carnet.

Le carnet est en anglais. Colab et Kaggle ouvrent votre propre copie. Il faut un compte Google ou Kaggle gratuit. Vous êtes bloqué ? Consultez le carnet de solution.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 45 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur (seulement pour le parcours local; le carnet n’en a pas besoin)

Testé avec Python 3.14.6 sur macOS. Le carnet a été testé dans Jupyter.

Dans Les fichiers et le terminal, les commandes prenaient des arguments (cd projects) et des options (python3 --version). Votre programme peut aussi en prendre. Python place les mots de la commande dans une liste, sys.argv :

show_args.py
import sys
print(sys.argv)
Terminal
python show_args.py data/tickets.csv --status pending
Sortie
['show_args.py', 'data/tickets.csv', '--status', 'pending']

Le premier élément est le script. Les autres sont les mots que vous avez tapés, sous forme de chaînes de caractères. Vous pourriez lire cette liste vous-même. Mais vous devriez aussi gérer les valeurs manquantes, les fautes de frappe dans les options et le texte d’aide. Le module argparse fait tout cela.

Quels paramètres deviennent des arguments, et lesquels restent dans les variables d’environnement? Demandez-vous : changent-ils à chaque exécution, ou dépendent-ils de l’endroit où le programme s’exécute?

Paramètre Change C’est donc
Le fichier d’entrée À chaque exécution Un argument : data/tickets.csv
Le statut à compter À chaque exécution Une option : --status pending
Le fichier du rapport Parfois Une option avec une valeur par défaut : --output
L’URL du service, le jeton, le niveau de journalisation Selon l’ordinateur ou le serveur Des variables d’environnement, comme avant

Amira écrit un analyseur qui décrit la commande :

import argparse
from pathlib import Path
parser = argparse.ArgumentParser(
prog="ticket_cleaner",
description="Check support-ticket records and write a summary report.",
)
parser.add_argument(
"input", help="a .csv or .json file of tickets, or 'remote' to download them"
)
parser.add_argument(
"--status",
default="open",
choices=sorted(STATUSES),
help="which tickets to count (default: open)",
)
parser.add_argument(
"--output",
type=Path,
default=Path("reports/summary.json"),
help="where to save the report (default: reports/summary.json)",
)
args = parser.parse_args()
Partie Signification
"input" Un argument positionnel : un nom sans --. Il est obligatoire, et sa position l’identifie.
"--status" Une option : elle commence par -- et elle est facultative.
default="open" La valeur utilisée quand la personne ne fournit pas l’option.
choices=... Les seules valeurs permises. Toute autre valeur produit une erreur, avec un message qui énumère les choix.
type=Path Convertit le texte en Path. Sans type, chaque valeur est une chaîne de caractères.
help="..." Le texte que --help affiche. Indiquez ce que représente la valeur et sa valeur par défaut.
parser.parse_args() Lit sys.argv. Renvoie un objet avec un attribut par argument : args.input, args.status, args.output.

argparse ajoute -h et --help pour vous. Voici l’aide qu’il produit à partir de l’analyseur :

Output of python -m ticket_cleaner --help
usage: ticket_cleaner [-h] [--status {closed,open,pending}] [--output OUTPUT]
input
Check support-ticket records and write a summary report.
positional arguments:
input a .csv or .json file of tickets, or 'remote' to
download them
options:
-h, --help show this help message and exit
--status {closed,open,pending}
which tickets to count (default: open)
--output OUTPUT where to save the report (default:
reports/summary.json)

Le programme a maintenant trois codes de sortie. Chacun a un sens différent pour la personne ou le programme qui l’exécute :

Code de sortie Signification Exemple
0 Réussite. Le rapport est écrit. Les enregistrements rejetés sont un résultat normal, pas un échec. python -m ticket_cleaner data/tickets.csv
1 Impossible de lire l’entrée. Un fichier manque, ou le service est indisponible.
2 La commande est incorrecte. argparse affiche l’utilisation et l’erreur, puis termine le programme pour vous. --status Pending, ou aucune entrée
Output of python -m ticket_cleaner data/tickets.csv --status Pending
usage: ticket_cleaner [-h] [--status {closed,open,pending}] [--output OUTPUT]
input
ticket_cleaner: error: argument --status: invalid choice: 'Pending' (choose from 'closed', 'open', 'pending')

Un paquet peut s’exécuter comme un programme s’il contient un fichier nommé __main__.py. Ensuite, python -m ticket_cleaner exécute ce fichier depuis le dossier du projet. Cela remplace count_tickets.py : le programme fait maintenant partie du paquet. Le paquet est la seule chose à installer, à tester et à exécuter.

Le code du programme va dans un nouveau module, cli.py (CLI signifie interface en ligne de commande). __main__.py ne fait que le démarrer :

ticket_cleaner/__main__.py
from ticket_cleaner.cli import main
raise SystemExit(main())
ticket_cleaner/cli.py
import argparse
import logging
from pathlib import Path
import httpx
from ticket_cleaner.config import Settings, load_settings
from ticket_cleaner.files import read_rows, write_json
from ticket_cleaner.parsing import split_records
from ticket_cleaner.records import STATUSES
from ticket_cleaner.remote import fetch_rows
from ticket_cleaner.report import build_summary
logger = logging.getLogger("ticket_cleaner")
def make_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="ticket_cleaner",
description="Check support-ticket records and write a summary report.",
)
parser.add_argument(
"input", help="a .csv or .json file of tickets, or 'remote' to download them"
)
parser.add_argument(
"--status",
default="open",
choices=sorted(STATUSES),
help="which tickets to count (default: open)",
)
parser.add_argument(
"--output",
type=Path,
default=Path("reports/summary.json"),
help="where to save the report (default: reports/summary.json)",
)
return parser
def load_rows(source: str, settings: Settings) -> list[dict]:
"""Return raw records from a file, or from the web service for "remote"."""
if source == "remote":
return fetch_rows(settings.tickets_url, token=settings.api_token)
return read_rows(Path(source))
def main(argv: list[str] | None = None) -> int:
args = make_parser().parse_args(argv)
settings = load_settings()
logging.basicConfig(
level=settings.log_level, format="%(levelname)s %(name)s: %(message)s"
)
logger.info("reading tickets from %s", args.input)
try:
rows = load_rows(args.input, settings)
except (OSError, ValueError, httpx.HTTPError) as error:
logger.error("cannot read the tickets from %s: %s", args.input, error)
return 1
tickets, rejected = split_records(rows)
for record in rejected:
logger.warning("row %d rejected: %s", record.row, "; ".join(record.problems))
summary = build_summary(tickets, rejected, args.status)
write_json(summary, args.output)
logger.info("wrote %s", args.output)
print(f"{len(rows)} rows: {len(tickets)} valid, {len(rejected)} rejected.")
print(f"Report: {args.output}")
return 0

Deux détails :

  • main(argv: list[str] | None = None). parse_args(None) lit sys.argv, ce qui est le cas normal. Un test peut plutôt passer sa propre liste : main(["data/tickets.csv", "--status", "pending"]). Un test peut ainsi exécuter tout le programme sans terminal.
  • config.py perd source. L’entrée est maintenant un argument. Supprimez donc le champ source de Settings et sa ligne dans load_settings. L’URL, le jeton et le niveau de journalisation restent.
  1. Créez ticket_cleaner/cli.py et ticket_cleaner/__main__.py avec le code ci-dessus.

  2. Dans ticket_cleaner/config.py, supprimez le champ source et la ligne source=....

  3. Supprimez count_tickets.py. Son rôle est maintenant dans cli.py.

  4. Affichez l’aide :

    Fenêtre de terminal
    python -m ticket_cleaner --help

    Vous voyez le texte d’aide de l’exemple détaillé. Dans certains terminaux, il est en couleur.

  5. Exécutez le programme avec les valeurs par défaut :

    Fenêtre de terminal
    python -m ticket_cleaner data/tickets.csv
    Sortie (dernières lignes)
    WARNING ticket_cleaner: row 14 rejected: missing category
    15 rows: 9 valid, 6 rejected.
    Report: reports/summary.json
  6. Exécutez-le pour les billets de soutien en attente, avec un fichier de sortie différent :

    Fenêtre de terminal
    python -m ticket_cleaner data/tickets.json --status pending --output reports/pending.json

    reports/pending.json contient "selected": 2.

  7. Vérifiez les trois codes de sortie. Exécutez chaque commande, puis echo $? (ou $LASTEXITCODE) : python -m ticket_cleaner data/tickets.csv donne 0, python -m ticket_cleaner data/nothing.csv donne 1, et python -m ticket_cleaner data/tickets.csv --status Pending donne 2.

Comme main accepte une liste, un test peut exécuter tout le programme. Créez tests/test_cli.py :

tests/test_cli.py
import json
from ticket_cleaner.cli import main
def test_report_for_pending_tickets(tmp_path):
output = tmp_path / "pending.json"
exit_code = main(
["data/tickets.csv", "--status", "pending", "--output", str(output)]
)
assert exit_code == 0
summary = json.loads(output.read_text(encoding="utf-8"))
assert summary["selected"] == 2
assert len(summary["rejected"]) == 6
def test_missing_file_gives_exit_code_1(tmp_path):
assert main([str(tmp_path / "missing.csv")]) == 1

Le rapport va dans tmp_path, donc le test ne modifie jamais reports/. Exécutez python -m pytest. Tous les tests réussissent.

La personne responsable du soutien veut utiliser ticket-cleaner dans une tâche automatique qui doit s’arrêter si les données ont des problèmes. Ajoutez une option --strict. Avec cette option, le programme écrit toujours le rapport. Mais il termine avec le code de sortie 3 si au moins un enregistrement a été rejeté. Sans cette option, le comportement ne change pas.

Utilisez action="store_true" : l’option ne prend aucune valeur, et args.strict vaut True quand la personne la fournit. Mettez à jour l’aide et ajoutez un test pour chaque cas.

Afficher une réponse

Dans make_parser :

parser.add_argument(
"--strict",
action="store_true",
help="end with exit code 3 if any record is rejected",
)

À la fin de main, au lieu de return 0 :

if args.strict and rejected:
return 3
return 0

Tests :

def test_strict_with_rejected_records_gives_exit_code_3(tmp_path):
output = tmp_path / "summary.json"
assert main(["data/tickets.csv", "--strict", "--output", str(output)]) == 3
assert output.exists()
def test_strict_with_clean_data_gives_exit_code_0(tmp_path):
clean = tmp_path / "clean.csv"
clean.write_text("id,status,category,priority\nT-1,open,login,1\n", encoding="utf-8")
assert main([str(clean), "--strict", "--output", str(tmp_path / "s.json")]) == 0

Pourquoi 3 et non 1? Le code de sortie 1 signifie déjà « impossible de lire l’entrée ». Un code différent permet à la tâche automatique de distinguer les deux cas.

Erreur courante

Aucun module nommé ticket_cleaner.__main__

Symptôme : python -m ticket_cleaner data/tickets.csv s’arrête avec l’un de ces messages :

No module named ticket_cleaner.__main__; 'ticket_cleaner' is a package and cannot be directly executed
No module named ticket_cleaner

Cause : le premier message indique que le paquet n’a pas de fichier __main__.py, ou que ce fichier porte un autre nom. Par exemple, il pourrait s’appeler main.py ou __main__.py.txt. Le deuxième indique que Python ne trouve pas le paquet. Le terminal n’est pas dans le dossier du projet, ou l’environnement n’est pas actif.

Solution :

  1. Exécutez ls ticket_cleaner. La liste doit contenir __main__.py, avec deux _ avant et après main.
  2. Exécutez pwd. Le terminal doit être dans ticket-cleaner, le dossier qui contient ticket_cleaner.
  3. Vérifiez que l’invite commence par (.venv).

Vérifiez votre compréhension

Pourquoi le fichier d'entrée est-il un argument, mais le jeton du service une variable d'environnement?

Afficher la réponse

L’entrée change à chaque exécution, donc la personne la fournit dans la commande. Le jeton dépend de l’endroit où le programme s’exécute, et c’est un secret. Une commande peut être enregistrée dans l’historique du terminal, dans un script ou dans un journal, puis partagée. Une variable d’environnement ne fait pas partie de la commande.

Vérifiez votre compréhension

Une tâche automatique exécute ticket-cleaner chaque nuit. Quel code de sortie indique que le rapport est prêt, et lequel indique que le service était indisponible?

Afficher la réponse

0 signifie que le rapport est écrit, même si certains enregistrements ont été rejetés (ils sont dans le rapport). 1 signifie que l’entrée n’a pas pu être lue, par exemple parce que le service était indisponible. Il n’y a donc pas de nouveau rapport. 2 signifie que la commande elle-même est incorrecte. Il faut donc corriger la configuration de la tâche.

Récapitulatif

  • argparse décrit une commande : arguments positionnels, options avec valeurs par défaut et choix, et texte d’aide. Il gère --help et les commandes incorrectes pour vous.
  • Les valeurs qui changent à chaque exécution sont des arguments. Les valeurs qui dépendent de l’environnement, ainsi que les secrets, sont des variables d’environnement.
  • Les codes de sortie indiquent ce qui s’est passé : 0 réussite, 1 entrée impossible à lire, 2 commande incorrecte.
  • __main__.py permet d’exécuter un paquet avec python -m package.
  • main(argv=None) permet à un test d’exécuter tout le programme avec ses propres arguments.

Ensuite, vous ajoutez le formatage et l’analyse statique. Vous documentez comment préparer et exécuter le projet, puis prouvez que le README fonctionne sur une nouvelle copie.

Leçon suivante : Qualité et passation