Chapitre 1019 minDébutant
Un script d'administration propre : argparse, logging et planification
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- lire les options de la ligne de commande avec argparse ;
- journaliser avec logging au bon niveau ;
- écrire un script avec mode simulation et code de retour ;
- protéger les secrets et planifier un script sous Linux et Windows.
3 exercices corrigés
Un script d'administration « propre » se lance sans modifier son code, explique lui-même comment l'utiliser, garde une trace de ce qu'il a fait et dit par son code de retour s'il a réussi. En Python, trois outils de la bibliothèque standard y suffisent : argparse pour les options de la ligne de commande, logging pour le journal, sys.exit() pour le code de retour. Ce chapitre construit ainsi un script de sauvegarde complet, avec rotation des archives et mode simulation, puis montre comment protéger ses secrets et le planifier sous Linux et sous Windows.
La structure d'un script
Tous les scripts de ce chapitre suivent le même plan :
- le shebang et une docstring qui dit ce que fait le script ;
- les imports, de la bibliothèque standard d'abord ;
- des fonctions qui font chacune une chose : lire les arguments, configurer le journal, travailler ;
- une fonction
main()qui enchaîne le tout et renvoie un code de retour ; - la ligne
sys.exit(main())sousif __name__ == "__main__":.
Ce découpage permet de tester chaque fonction séparément (chapitre 15) et d'importer le script dans un autre sans le lancer.
Des options en ligne de commande avec argparse
Un script qui demande ses paramètres avec input() ne peut pas être planifié, et un script dont les chemins sont écrits dans le code doit être modifié pour chaque serveur. argparse lit les arguments de la ligne de commande, les convertit, les valide et génère l'aide :
def analyser_arguments(arguments=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path, help="dossier à sauvegarder")
parser.add_argument("destination", type=Path, help="dossier qui reçoit les archives")
parser.add_argument("-g", "--garder", type=int, default=7, metavar="N",
help="nombre d'archives à conserver (7 par défaut)")
parser.add_argument("-n", "--simulation", action="store_true",
help="affiche ce qui serait fait, sans rien modifier")
parser.add_argument("-v", "--verbeux", action="store_true",
help="journalise aussi les détails")
return parser.parse_args(arguments)
L'aide est gratuite :
python sauvegarde.py --help
usage: sauvegarde.py [-h] [-g N] [-n] [-v] source destination
Sauvegarde un dossier dans une archive zip horodatée et garde les N plus
récentes.
positional arguments:
source dossier à sauvegarder
destination dossier qui reçoit les archives
options:
-h, --help show this help message and exit
-g, --garder N nombre d'archives à conserver (7 par défaut)
-n, --simulation affiche ce qui serait fait, sans rien modifier
-v, --verbeux journalise aussi les détails
| Déclaration | Effet |
|---|---|
add_argument("source") | Argument positionnel, obligatoire, dans l'ordre |
add_argument("-g", "--garder") | Option, facultative, avec une forme courte et une forme longue ; on lit args.garder |
type=int, type=Path | Conversion automatique, avec un message clair si elle échoue |
default=7 | Valeur si l'option est absente |
action="store_true" | Option sans valeur (un interrupteur) : True si présente |
choices=["zip", "tar"] | Liste des valeurs acceptées |
required=True | Rend une option obligatoire |
Les erreurs de saisie sont traitées pour toi, avec le code de retour 2 :
usage: sauvegarde.py [-h] [-g N] [-n] [-v] source destination
sauvegarde.py: error: the following arguments are required: destination
code 2
usage: sauvegarde.py [-h] [-g N] [-n] [-v] source destination
sauvegarde.py: error: argument -g/--garder: invalid int value: 'trois'
code 2
Journaliser avec logging
print() affiche, logging journalise : chaque message porte une date, un niveau de gravité, et part vers une ou plusieurs destinations (l'écran, un fichier, le journal du système). On règle le niveau minimal à afficher sans toucher aux messages.
journal = logging.getLogger("sauvegarde")
def configurer_journal(verbeux):
logging.basicConfig(
level=logging.DEBUG if verbeux else logging.INFO,
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[logging.StreamHandler(), logging.FileHandler("sauvegarde.log", encoding="utf-8")],
)
| Niveau | Pour quoi | Exemple |
|---|---|---|
DEBUG | Le détail, utile pour déboguer | « 5 archive(s) en comptant la nouvelle » |
INFO | Le déroulement normal | « Archive créée » |
WARNING | Un problème qui n'empêche pas de continuer | « Disque de destination rempli à 85 % » |
ERROR | Une opération a échoué | « Dossier source introuvable » |
CRITICAL | Le script ne peut pas continuer du tout | « Destination en lecture seule » |
Les messages s'écrivent journal.info("Archive créée : %s (%d octets)", nom, taille) : logging assemble le texte lui-même, et seulement si le message est affiché. Dans un bloc except, journal.exception("…") ajoute le traceback complet au journal, au niveau ERROR.
Le script complet : une sauvegarde avec rotation
Le script archive un dossier dans un zip horodaté et ne garde que les N archives les plus récentes. Les noms suivent le format %Y-%m-%d_%H%M%S : l'ordre alphabétique est l'ordre chronologique, et sorted() suffit à trouver les plus anciennes.
#!/usr/bin/env python3
"""Sauvegarde un dossier dans une archive zip horodatée et garde les N plus récentes."""
import argparse
import logging
import shutil
import sys
from datetime import datetime
from pathlib import Path
journal = logging.getLogger("sauvegarde")
def analyser_arguments(arguments=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("source", type=Path, help="dossier à sauvegarder")
parser.add_argument("destination", type=Path, help="dossier qui reçoit les archives")
parser.add_argument("-g", "--garder", type=int, default=7, metavar="N",
help="nombre d'archives à conserver (7 par défaut)")
parser.add_argument("-n", "--simulation", action="store_true",
help="affiche ce qui serait fait, sans rien modifier")
parser.add_argument("-v", "--verbeux", action="store_true",
help="journalise aussi les détails")
return parser.parse_args(arguments)
def configurer_journal(verbeux):
logging.basicConfig(
level=logging.DEBUG if verbeux else logging.INFO,
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
handlers=[logging.StreamHandler(), logging.FileHandler("sauvegarde.log", encoding="utf-8")],
)
def sauvegarder(source, destination, simulation):
"""Crée l'archive de source dans destination et renvoie son chemin."""
nom = f"{source.name}-{datetime.now():%Y-%m-%d_%H%M%S}"
archive = destination / f"{nom}.zip"
if simulation:
journal.info("SIMULATION : créerait %s", archive)
return archive
destination.mkdir(parents=True, exist_ok=True)
shutil.make_archive(str(destination / nom), "zip", root_dir=source)
taille = archive.stat().st_size
journal.info("Archive créée : %s (%d octets)", archive.name, taille)
return archive
def faire_tourner(source, destination, garder, simulation, nouvelle):
"""Supprime les archives de source au-delà des `garder` plus récentes."""
# En simulation, la nouvelle archive n'existe pas sur le disque : on la compte quand même.
archives = sorted(set(destination.glob(f"{source.name}-*.zip")) | {nouvelle})
journal.debug("%d archive(s) en comptant la nouvelle", len(archives))
for ancienne in archives[:-garder]:
if simulation:
journal.info("SIMULATION : supprimerait %s", ancienne.name)
else:
ancienne.unlink()
journal.info("Ancienne archive supprimée : %s", ancienne.name)
def main(arguments=None):
args = analyser_arguments(arguments)
configurer_journal(args.verbeux)
if not args.source.is_dir():
journal.error("Dossier source introuvable : %s", args.source)
return 2
if args.garder < 1:
journal.error("--garder doit valoir au moins 1 (reçu : %d)", args.garder)
return 2
try:
nouvelle = sauvegarder(args.source, args.destination, args.simulation)
faire_tourner(args.source, args.destination, args.garder, args.simulation, nouvelle)
except OSError:
journal.exception("Échec de la sauvegarde")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Avant de lancer pour de vrai un script qui supprime des fichiers, on le lance en simulation, avec les détails. Le dossier sauvegardes contient déjà les archives des quatre nuits précédentes :
python sauvegarde.py partages sauvegardes --garder 3 --simulation -v
2026-11-08 13:03:19 INFO SIMULATION : créerait sauvegardes/partages-2026-11-08_130319.zip
2026-11-08 13:03:19 DEBUG 5 archive(s) en comptant la nouvelle
2026-11-08 13:03:19 INFO SIMULATION : supprimerait partages-2026-11-04_023000.zip
2026-11-08 13:03:19 INFO SIMULATION : supprimerait partages-2026-11-05_023000.zip
code 0
Le résultat est celui attendu : avec la nouvelle archive, il y en aurait 5, il faut en supprimer 2. On lance pour de vrai :
python sauvegarde.py partages sauvegardes --garder 3
2026-11-08 13:03:19 INFO Archive créée : partages-2026-11-08_130319.zip (556 octets)
2026-11-08 13:03:19 INFO Ancienne archive supprimée : partages-2026-11-04_023000.zip
2026-11-08 13:03:19 INFO Ancienne archive supprimée : partages-2026-11-05_023000.zip
code 0
partages-2026-11-06_023000.zip
partages-2026-11-07_023000.zip
partages-2026-11-08_130319.zip
Et l'erreur classique, une faute de frappe dans la source, donne un message clair et un code de retour non nul, que la supervision ou le planificateur détecteront :
2026-11-08 13:03:19 ERROR Dossier source introuvable : partage
code 2
Quatre choix du script méritent d'être retenus. La simulation (--simulation) parcourt exactement le même chemin que l'exécution réelle, mais s'arrête avant de modifier quoi que ce soit. Les paramètres sont validés avant toute action (--garder 0 effacerait toutes les archives). Seules les erreurs prévisibles du système de fichiers (OSError, qui couvre PermissionError et le disque plein) sont interceptées, et journalisées avec leur traceback. Le code de retour distingue l'erreur d'utilisation (2) de l'échec de la sauvegarde (1).
Les secrets : ni dans le code ni dans le dépôt
Un script qui appelle une API, se connecte à une base ou envoie un courriel a besoin d'un secret : jeton, mot de passe, clé. Ce secret n'a rien à faire dans le code : le code se copie, se partage, se versionne sur GitHub, et un secret publié une fois doit être considéré comme compromis.
- Variable d'environnement.
os.environ["GLPI_APP_TOKEN"]: la valeur est fournie par le service ou la tâche qui lance le script. Le[](et nonget()) arrête le script tout de suite si elle manque. - Saisie masquée, pour un script lancé à la main :
getpass.getpass("Mot de passe : ")lit sans afficher les caractères. - Fichier de configuration à part, lisible seulement par le compte de service (
chmod 600sous Linux), jamais versionné : ajoute-le au.gitignore(fichiers .gitignore et secrets). - Coffre-fort de secrets dans une infrastructure plus grande : le gestionnaire de secrets du cloud, ou celui de l'outil d'automatisation.
Et ne journalise jamais un secret : un journal.debug("Connexion avec %s", jeton) oublié finit dans un fichier lisible par tous. Le cours Sécurité applicative détaille la gestion des secrets (configuration et secrets).
Planifier le script
Sur un serveur Linux, la tâche planifiée appelle l'interpréteur de l'environnement virtuel avec le chemin complet du script ; aucune activation n'est nécessaire. Une ligne de crontab qui lance la sauvegarde chaque nuit à 2 h 30 :
30 2 * * * /opt/sauvegarde/.venv/bin/python /opt/sauvegarde/sauvegarde.py /srv/partages /mnt/nas/sauvegardes --garder 14
Les tâches cron s'exécutent avec un environnement minimal, dans le dossier personnel du compte : des chemins absolus partout, y compris pour le fichier journal. Sur Debian 13, un timer systemd est l'alternative moderne : il journalise la sortie dans journalctl et peut rattraper une exécution manquée pendant un arrêt du serveur (option Persistent=true) (crontab et timers systemd).
Sous Windows, dans le Planificateur de tâches, l'action « Démarrer un programme » lance le python.exe de l'environnement virtuel (C:\Scripts\sauvegarde\.venv\Scripts\python.exe), avec en arguments le chemin du script et ses options, et « Commencer dans » vaut le dossier du script. Le cours Scripts PowerShell et Bash détaille les deux planificateurs (planifier un script).
Exercices
Exercice 1 sur 3 Les arguments d'un script de contrôle Réussi À revoir
Écris la fonction analyser_arguments() d'un script controle_reseau.py qui accepte : un réseau à tester (obligatoire) ; une option -d / --delai, l'attente maximale par hôte en secondes, nombre à virgule, 1 par défaut ; une option --csv qui reçoit un chemin de fichier, facultative ; un interrupteur --actifs qui n'affiche que les hôtes qui répondent. Affiche l'aide, puis ce que donne python controle_reseau.py 192.168.21.0/24 --delai 0.5 --actifs.
Voir la réponseMasquer la réponse de l'exercice 1
"""Teste quels hôtes d'un réseau répondent au ping."""
import argparse
from pathlib import Path
def analyser_arguments(arguments=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("reseau", help="réseau à tester, par exemple 192.168.21.0/24")
parser.add_argument("-d", "--delai", type=float, default=1.0, metavar="S",
help="attente maximale par hôte, en secondes (1 par défaut)")
parser.add_argument("--csv", type=Path, metavar="FICHIER",
help="enregistre aussi le résultat dans ce fichier CSV")
parser.add_argument("--actifs", action="store_true",
help="n'affiche que les hôtes qui répondent")
return parser.parse_args(arguments)
if __name__ == "__main__":
print(analyser_arguments())
usage: controle_reseau.py [-h] [-d S] [--csv FICHIER] [--actifs] reseau
Teste quels hôtes d'un réseau répondent au ping.
positional arguments:
reseau réseau à tester, par exemple 192.168.21.0/24
options:
-h, --help show this help message and exit
-d, --delai S attente maximale par hôte, en secondes (1 par défaut)
--csv FICHIER enregistre aussi le résultat dans ce fichier CSV
--actifs n'affiche que les hôtes qui répondent
Namespace(reseau='192.168.21.0/24', delai=0.5, csv=None, actifs=True)
parse_args() renvoie un objet Namespace dont chaque attribut porte le nom long de l'option, sans les tirets. csv vaut None quand l'option est absente : le script testera if args.csv:. Le chapitre 12 écrit le reste de ce script.
Tu avais trouvé ?
Exercice 2 sur 3 Le bon niveau Réussi À revoir
Choisis le niveau de journal de chaque message d'un script de contrôle des sauvegardes.
- « Début du contrôle de 12 serveurs. »
- « srv-nas : dernière sauvegarde il y a 30 heures (attendu : moins de 26). »
- « Fichier de configuration illisible : arrêt. »
- « srv-ad : 3 fichiers comparés, empreintes identiques. »
- « srv-web : connexion refusée, serveur ignoré. »
Voir la réponseMasquer la réponse de l'exercice 2
INFO: le déroulement normal.WARNING: la sauvegarde existe mais est en retard ; le contrôle continue et quelqu'un doit regarder.CRITICAL: le script ne peut rien faire sans configuration, il s'arrête avec un code de retour non nul.DEBUG: un détail utile pour comprendre un problème, inutile chaque nuit.ERROR: une opération a échoué (ce serveur n'a pas été contrôlé), même si le script continue avec les autres.
Tu avais trouvé ?
Exercice 3 sur 3 Revue de code Réussi À revoir
Un collègue propose ce script pour la tâche planifiée de chaque nuit. Relève quatre problèmes et la correction de chacun.
import requests
JETON = "glpi_7f3a9c2e"
serveurs = input("Serveurs à contrôler ? ").split(",")
for s in serveurs:
try:
r = requests.get(f"https://{s}/sante", headers={"Authorization": JETON})
print(s, r.status_code)
except:
pass
Voir la réponseMasquer la réponse de l'exercice 3
- Le jeton est écrit dans le code : il partira dans le dépôt Git et chez tous ceux qui copient le script. Lis-le dans une variable d'environnement :
JETON = os.environ["API_JETON"], et change le jeton exposé. input()dans une tâche planifiée : personne ne répondra, le script s'arrêtera surEOFError. Passe la liste en argument avecargparse(nargs="+") ou dans un fichier de configuration.except: passavale tout : un serveur injoignable disparaît du rapport sans un mot. Intercepterequests.RequestException, journalise l'erreur avecjournal.error()et compte les échecs.print()et aucun code de retour : rien n'est gardé, et la tâche réussit toujours. Utiliseloggingvers un fichier, et termine parsys.exit(1)si un contrôle a échoué.
Bonus : requests.get sans timeout= peut attendre indéfiniment un serveur bloqué (chapitre 13).
Tu avais trouvé ?