Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 :

  1. le shebang et une docstring qui dit ce que fait le script ;
  2. les imports, de la bibliothèque standard d'abord ;
  3. des fonctions qui font chacune une chose : lire les arguments, configurer le journal, travailler ;
  4. une fonction main() qui enchaîne le tout et renvoie un code de retour ;
  5. la ligne sys.exit(main()) sous if __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 :

python
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 :

bash
python sauvegarde.py --help
text
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éclarationEffet
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=PathConversion automatique, avec un message clair si elle échoue
default=7Valeur 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=TrueRend une option obligatoire

Les erreurs de saisie sont traitées pour toi, avec le code de retour 2 :

text
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.

python
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")],
    )
NiveauPour quoiExemple
DEBUGLe détail, utile pour déboguer« 5 archive(s) en comptant la nouvelle »
INFOLe déroulement normal« Archive créée »
WARNINGUn problème qui n'empêche pas de continuer« Disque de destination rempli à 85 % »
ERRORUne opération a échoué« Dossier source introuvable »
CRITICALLe 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.

python
#!/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 :

bash
python sauvegarde.py partages sauvegardes --garder 3 --simulation -v
text
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 :

bash
python sauvegarde.py partages sauvegardes --garder 3
text
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 :

text
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 non get()) 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 600 sous 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 :

text
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
python
"""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())
text
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
text
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.

  1. « Début du contrôle de 12 serveurs. »
  2. « srv-nas : dernière sauvegarde il y a 30 heures (attendu : moins de 26). »
  3. « Fichier de configuration illisible : arrêt. »
  4. « srv-ad : 3 fichiers comparés, empreintes identiques. »
  5. « srv-web : connexion refusée, serveur ignoré. »
Voir la réponseMasquer la réponse de l'exercice 2
  1. INFO : le déroulement normal.
  2. WARNING : la sauvegarde existe mais est en retard ; le contrôle continue et quelqu'un doit regarder.
  3. CRITICAL : le script ne peut rien faire sans configuration, il s'arrête avec un code de retour non nul.
  4. DEBUG : un détail utile pour comprendre un problème, inutile chaque nuit.
  5. 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.

python
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
  1. 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é.
  2. input() dans une tâche planifiée : personne ne répondra, le script s'arrêtera sur EOFError. Passe la liste en argument avec argparse (nargs="+") ou dans un fichier de configuration.
  3. except: pass avale tout : un serveur injoignable disparaît du rapport sans un mot. Intercepte requests.RequestException, journalise l'erreur avec journal.error() et compte les échecs.
  4. print() et aucun code de retour : rien n'est gardé, et la tâche réussit toujours. Utilise logging vers un fichier, et termine par sys.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é ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Expressions régulières et analyse de logs avec reRevoir le sommaire du cours