Aller au contenu
Sommaire

Cours Scripting shell et Python — scripts sûrs, stdlib d'abord, essai à blanc

Cours · environ 19 min de lecture

Validé

Validé. Ce site n’exécute aucune commande. 7 points non vérifiés · Sources

Objectifs d'apprentissage

À la fin de ce cours, l'apprenant peut :

  1. écrire un script shell qui s'arrête à la première erreur, protège ses variables par des guillemets et renvoie un code de sortie exact ;
  2. relire un script avec ShellCheck et comprendre un avertissement ;
  3. choisir Python plutôt que le shell dès que le script manipule des fichiers, des options ou des données structurées ;
  4. utiliser pathlib, argparse et json de la bibliothèque standard ;
  5. lancer un programme externe depuis Python avec subprocess, sans passer par un shell ;
  6. créer et utiliser un environnement virtuel, et dire pourquoi il n'est pas versionné ;
  7. écrire un test unittest et lancer le lint et le formatage avec Ruff ;
  8. refuser d'exécuter ou d'interpréter des données non fiables ;
  9. écrire un script idempotent avec une option --a-blanc qui décrit sans modifier.

Prérequis

  • Savoir ouvrir Terminal, lire une commande avant de la valider et manipuler des redirections (cours « Terminal macOS »).
  • Python 3 et, pour les contrôles, ShellCheck et Ruff : non vérifiés sur le poste de Stéphane. Ce cours n'enseigne pas leur installation et n'installe rien.
  • Un dossier de test jetable. Aucun exercice ne demande de droits administrateur.

Parcours et chapitres

1. Shell, script et shebang sur macOS

D'après Apple, zsh est le shell par défaut des comptes créés à partir de macOS 10.15 ; bash l'était jusqu'à macOS 10.14, et zsh est présenté comme très compatible avec le shell Bourne (sh) et « presque » compatible avec bash, avec des différences. Un script indique son interpréteur sur sa première ligne (shebang, voir le cours « Terminal macOS ») :

Première ligneSens
#!/bin/shshell Bourne ; écrire alors du sh portable
#!/usr/bin/env bashbash trouvé dans le PATH (usage courant, non vérifié dans les sources Apple)
#!/usr/bin/env python3Python 3 trouvé dans le PATH (idem)

À retenir : le shell interactif de Stéphane (probablement zsh) n'est pas forcément celui qui exécute le script : c'est le shebang qui décide. Un script se teste avec l'interpréteur qu'il déclare. La version de bash livrée avec macOS n'a pas été relevée dans les sources consultées : non vérifié.

Règle de choix : shell pour enchaîner quelques commandes ; Python dès qu'il faut des options, des boucles non triviales, du JSON, des fichiers ou des tests.

2. Un script shell sûr

Squelette recommandé, à adapter :

Script shell
#!/usr/bin/env bash
set -euo pipefail

dossier="${1:?usage : $0 <dossier>}"

for fichier in "$dossier"/*.txt; do
  [ -e "$fichier" ] || continue
  printf '%s\n' "$fichier"
done
  • Contexte : Terminal, script enregistré sous lister-txt.sh dans un dossier de test ; bash ; version non vérifiée.
  • Droits : utilisateur normal ; lecture du dossier passé en argument.
  • Effet : liste, une par ligne, les fichiers .txt du dossier. Aucune modification de fichier.
  • Vérification : sans argument, le script s'arrête avec un message d'usage et un code non nul (echo $?) ; avec un dossier de test contenant deux fichiers, il en affiche deux.
  • Retour arrière : sans objet (lecture seule) ; supprimer le script de test.

Chaque ligne a un rôle :

  • set -e : arrêt à la première commande qui échoue. Il a des exceptions (commandes dans un test if, une liste &&/||, etc.) : lire la page « The Set Builtin » du manuel Bash avant de s'y fier (page non relue ici, voir « Points non vérifiés »).
  • set -u : une variable non définie est une erreur au lieu d'une chaîne vide silencieuse.
  • set -o pipefail : d'après le manuel Bash, le statut d'un tube devient celui de la dernière commande (la plus à droite) qui a échoué, ou zéro si toutes ont réussi ; sans cette option, seule la dernière commande compte. Option de bash, pas de sh strict : non vérifié pour le sh de macOS.
  • "${1:?message}" : forme usuelle (non vérifiée dans les sources consultées) qui arrête avec le message si l'argument manque.

À retenir : set -euo pipefail n'est pas une garantie, c'est un filet. Il ne remplace ni la vérification des entrées ni la lecture du code de retour.

Guillemets

Le wiki ShellCheck (SC2086) explique qu'une variable sans guillemets subit un découpage en mots (selon IFS) puis une expansion de motifs (globbing) : echo $1 n'est donc pas echo "$1". Quoter protège des espaces, des sauts de ligne et des caractères spéciaux. Règle simple : toujours écrire "$variable", "$@" pour tous les arguments, et "${tableau[@]}" pour construire une commande à plusieurs options (le wiki donne l'exemple make "${options[@]}" file).

Terminal
fichier="mon rapport.txt"printf '%s\n' "$fichier"
  • Contexte : bash ou zsh dans un dossier de test.
  • Droits : utilisateur normal.
  • Effet : affiche mon rapport.txt comme un seul argument ; sans guillemets, printf '%s\n' $fichier en verrait deux. printf : comportement usuel, non vérifié dans les sources consultées.
  • Vérification : une seule ligne s'affiche avec les guillemets, deux sans.
  • Retour arrière : sans objet.

Codes de sortie

Convention usuelle de bash : 0 signifie succès, toute autre valeur un échec ; $? donne le code de la dernière commande. Les valeurs précises à connaître (126, 127, 128 + numéro de signal) sont non vérifiées ici, la page « Exit Status » du manuel GNU n'ayant pas pu être lue (erreur 429). Un script bien élevé :

  • renvoie 0 seulement si tout s'est bien passé, exit 1 (ou une autre valeur non nulle) sinon ;
  • écrit ses messages d'erreur sur stderr (echo "erreur" >&2) et son résultat sur stdout, pour rester utilisable en tube ;
  • ne masque pas un échec par || true sans raison écrite en commentaire.

Nettoyer avec trap

Le builtin trap exécute une commande à la sortie du script. Exemple d'usage courant (non vérifié dans la page du manuel, non lue) pour nettoyer un fichier temporaire créé avec mktemp (outil BSD ; options non vérifiées) :

Terminal
tmp="$(mktemp)"trap 'rm -f "$tmp"' EXIT
  • Contexte : dans un script bash de test.
  • Droits : utilisateur normal ; création dans le dossier temporaire du système.
  • Effet : crée un fichier temporaire vide, puis programme sa suppression à la fin du script, même en cas d'erreur.
  • Vérification : le fichier n'existe plus après la fin du script.
  • Retour arrière : le trap s'en charge ; sinon supprimer le fichier en relisant son nom.

3. ShellCheck

ShellCheck est, d'après son dépôt, un outil d'analyse statique GPLv3 qui signale erreurs de syntaxe, problèmes sémantiques et pièges des scripts bash et sh ; il cible sh, bash, dash et ksh. Il lit le shebang (ou une directive) pour connaître le dialecte ; zsh n'est pas listé parmi les shells pris en charge dans la page consultée : ne pas lui soumettre un script zsh. Il s'utilise ainsi :

Terminal
shellcheck lister-txt.shecho $?
  • Contexte : Terminal, ShellCheck installé (non vérifié sur le poste de Stéphane).
  • Droits : utilisateur normal ; lecture seule.
  • Effet : affiche des avertissements numérotés (SC2086…) ; le code de sortie est non nul s'il en trouve (« usage canonique des codes de sortie », d'après le dépôt).
  • Vérification : un script sans avertissement n'affiche rien et renvoie 0.
  • Retour arrière : sans objet.

Chaque numéro SCxxxx a une page dans le wiki (https://www.shellcheck.net/wiki/SCxxxx) avec le code fautif, le code corrigé et la justification. Désactiver un avertissement se fait au cas par cas, avec un commentaire qui dit pourquoi.

Astuce : exécuter ShellCheck avant le premier lancement d'un script, pas après un incident.

4. Python d'abord, bibliothèque standard d'abord

Règle de l'Orchestrator : Python stdlib d'abord ; toute dépendance ou service payant demande l'accord de Stéphane. Trois modules couvrent l'essentiel d'un script.

pathlib : les chemins comme des objets

D'après la documentation Python, pathlib.Path représente un chemin ; l'opérateur / assemble ; read_text() et write_text() lisent et écrivent ; mkdir(parents=True, exist_ok=True) crée une arborescence sans erreur si elle existe déjà ; unlink(missing_ok=True) supprime en ignorant l'absence ; glob, iterdir, resolve et exists parcourent et interrogent.

Python
from pathlib import Path

dossier = Path("sortie-demo")
dossier.mkdir(parents=True, exist_ok=True)
(dossier / "salut.txt").write_text("bonjour\n", encoding="utf-8")
print(sorted(p.name for p in dossier.glob("*.txt")))
  • Contexte : Python 3 dans un dossier de test (version de Stéphane : non vérifiée) ; encoding= : paramètre usuel, non relu dans la page consultée.
  • Droits : utilisateur normal ; écriture dans le dossier courant.
  • Effet : crée sortie-demo/ s'il manque, écrase salut.txt, puis affiche ['salut.txt'].
  • Vérification : la sortie est ['salut.txt'].
  • Retour arrière : supprimer le dossier de test en relisant son nom.

Attention : write_text remplace le contenu d'un fichier existant sans avertissement. Pour ne rien écraser par accident, tester exists() avant ou utiliser l'essai à blanc (chapitre 9).

argparse : des options claires

argparse.ArgumentParser définit les arguments, génère l'aide (-h) et vérifie les entrées ; add_argument accepte type, choices, default, help ; action="store_true" crée un indicateur booléen. D'après la documentation, une erreur d'arguments affiche un message sur stderr et sort avec le code 2.

json : échanger des données structurées

json.load et json.dump lisent et écrivent un fichier ; loads et dumps travaillent sur des chaînes. indent met en forme, sort_keys ordonne les clés, ensure_ascii=False garde les accents. Une erreur de syntaxe lève json.JSONDecodeError (sous-classe de ValueError).

Attention : la documentation avertit qu'un JSON venant d'une source non fiable peut consommer beaucoup de processeur et de mémoire : limiter la taille lue.

5. subprocess : lancer un programme sans shell

D'après la documentation de subprocess, la bibliothèque n'appelle pas implicitement un shell : tous les caractères, métacaractères compris, peuvent être passés en toute sécurité à un processus enfant, à condition de donner les arguments sous forme de liste. Avec shell=True, c'est à l'application de protéger chaque espace et métacaractère pour éviter l'injection (la page cite shlex.quote comme aide).

Python
import subprocess

resultat = subprocess.run(
    ["git", "status", "--short"],
    capture_output=True,
    text=True,
    check=True,
    timeout=30,
)
print(resultat.stdout)
  • Contexte : Python 3 dans un dépôt Git de test.
  • Droits : utilisateur normal ; lecture seule du dépôt.
  • Effet : lance git status --short sans shell, capture la sortie en texte, lève CalledProcessError si le code de sortie n'est pas nul (check=True) et TimeoutExpired au bout de 30 secondes. Selon la documentation, un processus dépassant le délai n'est pas tué automatiquement dans tous les cas : prévoir le nettoyage.
  • Vérification : la sortie courte de git status s'affiche ; dans un dossier qui n'est pas un dépôt, l'exception est levée.
  • Retour arrière : sans objet (lecture seule).

À retenir : liste d'arguments, check=True, timeout=, jamais shell=True avec une donnée qui ne vient pas de vous. Ne construire une commande qu'avec des morceaux validés (liste blanche) ; ne jamais y coller une chaîne d'origine externe.

6. Environnements virtuels

python -m venv <dossier> crée un environnement isolé : fichier pyvenv.cfg, sous-dossier bin avec l'interpréteur et lib/pythonX.Y/site-packages pour les paquets (d'après la documentation de venv). Sur POSIX, on l'active avec source <dossier>/bin/activate (bash/zsh) et on en sort avec deactivate.

Terminal
python3 -m venv .venvsource .venv/bin/activatepython --versiondeactivate
  • Contexte : Terminal, dossier de projet de test, Python 3 installé (version non vérifiée).
  • Droits : utilisateur normal ; écriture dans le dossier courant.
  • Effet : crée .venv/ (dossier de plusieurs dizaines de Mo possible : taille non vérifiée), modifie le PATH du terminal courant jusqu'à deactivate. Aucun paquet n'est installé par ces commandes.
  • Vérification : python --version répond avec l'interpréteur de l'environnement ; which python pointe dans .venv (usage courant, non relu).
  • Retour arrière : deactivate, puis supprimer le dossier .venv en relisant son nom.

À retenir : la documentation précise qu'un environnement virtuel est jetable (on le supprime et on le recrée), non déplaçable et à ne pas versionner. Depuis Python 3.13, venv crée par défaut un .gitignore dans l'environnement ; ajouter tout de même .venv/ au .gitignore du dépôt si la version de Stéphane est plus ancienne.

Dans l'Orchestrator, la règle « stdlib d'abord » rend l'environnement virtuel utile surtout pour les outils de développement (Ruff) ; installer un paquet reste une décision à valider. Les commandes d'installation (pip) ne sont pas couvertes ici : page pip non consultée.

7. Tester avec unittest

unittest est dans la bibliothèque standard. Un test est une méthode test_… d'une sous-classe de unittest.TestCase ; les assertions courantes sont assertEqual, assertTrue, assertIn et assertRaises ; setUp et tearDown préparent et nettoient ; subTest distingue des cas dans une boucle. La découverte automatique trouve les fichiers test*.py :

Python
import unittest

from archiver import nom_archive


class TestNomArchive(unittest.TestCase):
    def test_nom_simple(self):
        self.assertEqual(nom_archive("rapport", 2026), "rapport-2026.txt")

    def test_nom_vide_refuse(self):
        with self.assertRaises(ValueError):
            nom_archive("", 2026)


if __name__ == "__main__":
    unittest.main()
Terminal
python3 -m unittest discover
  • Contexte : dossier de test contenant archiver.py (chapitre 9) et ce fichier sous test_archiver.py.
  • Droits : utilisateur normal.
  • Effet : importe les modules de test, les exécute, affiche un résumé. Les tests ne doivent écrire que dans un dossier temporaire (tempfile, module non relu ici).
  • Vérification : la sortie se termine par OK ; un échec indique le test, la ligne et la valeur attendue.
  • Retour arrière : sans objet.

Astuce : un script testable sépare la logique (fonctions pures, testables) de l'exécution (fonction main, appelée sous if __name__ == "__main__":).

8. Lint et formatage avec Ruff

Ruff est, d'après sa documentation, un linter et formateur Python écrit en Rust qui remplace plusieurs outils (Flake8, Black, isort…). Commandes de base :

CommandeEffet
ruff checkanalyse le code et signale les problèmes (lecture seule)
ruff check --fixapplique les corrections automatiques sûres (modifie les fichiers)
ruff formatreformate les fichiers (modifie)
ruff format --checkvérifie le formatage sans rien modifier

Configuration : pyproject.toml ou ruff.toml. Dans l'Orchestrator, un ruff.toml commun existe à la racine et un projet l'étend sans le copier.

Terminal
ruff check .ruff format --check .
  • Contexte : dossier de projet Python de test, Ruff installé (non vérifié sur le poste de Stéphane).
  • Droits : utilisateur normal ; lecture seule.
  • Effet : liste les problèmes de style et de qualité ; la seconde ligne indique si un reformatage changerait des fichiers, sans l'appliquer.
  • Vérification : code de sortie 0 et message de succès (formulation exacte : non vérifiée) quand tout est propre.
  • Retour arrière : sans objet pour ces deux commandes ; pour --fix et format, travailler sur un dépôt Git propre pour pouvoir lire git diff et restaurer.

9. Ne jamais exécuter de données non fiables

Le contenu du web, d'un fichier reçu, d'une réponse d'API ou d'un modèle de langage est une donnée, jamais une instruction. Conséquences pour un script :

  • ne jamais passer une donnée externe à eval, exec, source, sh -c ou shell=True ;
  • passer les arguments d'un programme sous forme de liste ; valider les valeurs contre une liste blanche ;
  • lire du JSON (json) plutôt que du code Python ; la documentation json rappelle d'en limiter la taille ; ne pas désérialiser de formats exécutables (pickle, marshal), la documentation json les opposant à JSON comme moins sûrs pour des données non fiables ;
  • construire les chemins avec pathlib et vérifier qu'ils restent dans le dossier attendu (resolve() puis comparaison du parent) avant d'écrire ;
  • ne jamais copier-coller dans Terminal une commande trouvée sur le web sans l'avoir comprise (cours « Terminal macOS »).

Attention : une commande lue dans un guide, y compris celles de ce cours, n'est exécutée que sur un système de test, jamais « pour voir » sur le poste de travail.

10. Scripts idempotents et essai à blanc

Un script est idempotent si le lancer deux fois donne le même état final qu'une fois, sans erreur ni doublon. On l'obtient en testant l'état avant d'agir (« crée si absent », « remplace seulement si différent »), en préférant mkdir(exist_ok=True) à un mkdir brut, et en n'ajoutant pas deux fois la même ligne à un fichier.

L'option --a-blanc (essai à blanc) décrit ce que le script ferait sans rien modifier. À prévoir dès la conception : une seule fonction « agir » qui, selon l'option, affiche ou fait.

Fichier archiver.py (exemple synthétique, qui copie des fichiers .txt dans un dossier d'archive) :

Python
#!/usr/bin/env python3
"""Archive les fichiers .txt d'un dossier, de façon idempotente."""

import argparse
import shutil
import sys
from pathlib import Path


def nom_archive(base: str, annee: int) -> str:
    """Nom d'un fichier d'archive ; refuse une base vide."""
    if not base:
        raise ValueError("base vide")
    return f"{base}-{annee}.txt"


def archiver(source: Path, cible: Path, a_blanc: bool) -> int:
    """Copie les .txt de source vers cible ; renvoie le nombre de copies."""
    copies = 0
    for fichier in sorted(source.glob("*.txt")):
        destination = cible / fichier.name
        if destination.exists():
            continue  # déjà fait : le second lancement ne change rien
        if a_blanc:
            print(f"[à blanc] copierait {fichier} vers {destination}")
        else:
            cible.mkdir(parents=True, exist_ok=True)
            shutil.copy2(fichier, destination)
            print(f"copié {fichier} vers {destination}")
        copies += 1
    return copies


def main(argv=None) -> int:
    parseur = argparse.ArgumentParser(description=__doc__)
    parseur.add_argument("source", type=Path)
    parseur.add_argument("cible", type=Path)
    parseur.add_argument("--a-blanc", action="store_true", help="décrit sans modifier")
    args = parseur.parse_args(argv)
    if not args.source.is_dir():
        print(f"erreur : {args.source} n'est pas un dossier", file=sys.stderr)
        return 1
    n = archiver(args.source, args.cible, args.a_blanc)
    print(f"{n} fichier(s) concerné(s)")
    return 0


if __name__ == "__main__":
    sys.exit(main())
Terminal
python3 archiver.py donnees-test archive-test --a-blancpython3 archiver.py donnees-test archive-testpython3 archiver.py donnees-test archive-test
  • Contexte : dossier de test contenant archiver.py et un sous-dossier donnees-test avec deux fichiers .txt synthétiques ; Python 3 (version non vérifiée). shutil.copy2 n'a pas été relu dans la documentation : non vérifié.
  • Droits : utilisateur normal ; lecture de donnees-test, écriture de archive-test (hors premier appel).
  • Effet : le premier appel liste seulement les copies prévues et ne crée rien ; le deuxième copie les fichiers ; le troisième ne copie plus rien (« 0 fichier(s) concerné(s) ») : c'est l'idempotence. Un fichier déjà présent dans archive-test n'est jamais écrasé.
  • Vérification : après le premier appel, archive-test n'existe pas ; après le deuxième, il contient deux fichiers ; le code de sortie (echo $?) vaut 0, et 1 si la source n'est pas un dossier.
  • Retour arrière : supprimer archive-test (dossier de test) en relisant son nom.

À retenir : l'essai à blanc est un filet de lecture, pas une preuve : il faut aussi un test (chapitre 7) sur un dossier temporaire et la relecture de la sortie. Le même principe vaut pour un script shell : une variable A_BLANC qui préfixe les commandes à effet par echo.

11. Le cycle d'un script sûr

Schéma : cinq étapes en colonne : écrire avec set -euo pipefail et des guillemets ; contrôler avec shellcheck, ruff et unittest ; lancer à blanc avec --a-blanc ; lancer pour de bon sur des données de test dans un dossier jetable ; vérifier le code de sortie et le résultat.1. Écrireset -euo pipefail, variables entre guillemets2. Contrôlershellcheck, ruff check, tests unittest3. Lancer à blanc--a-blanc : décrit, ne modifie rien4. Lancer pour de bondonnées de test, dossier jetable5. Vérifiercode de sortie 0, résultat relu
Le cycle d'un script sûr, de l'écriture à la vérification. Schéma pédagogique : ordre recommandé par ce cours, pas une procédure officielle.

Un script de l'Orchestrator expose une seule commande de bout en bout, nommée dans le CLAUDE.md de son projet ; il ne contient aucun secret en clair (Trousseau macOS, bin/secret), n'écrit jamais un mot de passe dans l'historique, et ne contourne jamais un captcha ni une protection anti-robot.

Exercices et vérifications de fin de cours

Tous dans un dossier de test jetable.

  1. Shell sûr. Écrire lister-txt.sh (chapitre 2), le lancer sans argument, avec un dossier vide, avec un dossier de deux fichiers ; relever chaque code de sortie.
  2. Guillemets. Créer un fichier dont le nom contient une espace ; montrer ce qui casse sans guillemets, puis corriger.
  3. ShellCheck. Soumettre un script contenant echo $1 à ShellCheck ; lire la page SC2086 du wiki et corriger.
  4. pathlib. Réécrire en Python la boucle du script shell avec Path.glob.
  5. argparse. Ajouter --verbeux à un script, lire -h, provoquer une erreur d'argument et relever le code 2.
  6. subprocess. Appeler git status --short avec une liste d'arguments ; provoquer une erreur et capter CalledProcessError.
  7. Environnement virtuel. Créer, activer et supprimer .venv ; l'ajouter au .gitignore.
  8. Tests. Écrire deux tests pour nom_archive, dont un qui attend ValueError, et les lancer avec discover.
  9. Ruff. Lancer ruff check . et ruff format --check . sur archiver.py.
  10. Idempotence. Lancer archiver.py à blanc, puis deux fois pour de bon ; expliquer chaque sortie.
  11. Données non fiables. Citer trois constructions interdites avec une donnée externe et leur remplaçant.

Critères de réussite

  • Expliquer -e, -u et pipefail, et reconnaître qu'ils ne suffisent pas.
  • Écrire "$variable" sans oubli et dire pourquoi.
  • Lire un code de sortie et en produire un cohérent.
  • Lancer un programme depuis Python sans shell=True.
  • Dire pourquoi un environnement virtuel n'est pas versionné.
  • Exécuter unittest, ruff check et ruff format --check.
  • Prouver l'idempotence d'un script par deux lancements successifs.
  • Citer le périmètre des sources (documentation en ligne, Python 3.15.0 affichée le 17 octobre 2026) sans la confondre avec la version installée.

Points non vérifiés ou à confirmer

  • Versions de macOS, de zsh, de bash, de Python, de ShellCheck et de Ruff sur le Mac de Stéphane ; version de bash livrée avec macOS.
  • Numéro de version courant de Ruff et de ShellCheck : non affiché dans les pages lues.
  • Pages du manuel GNU Bash « The Set Builtin », « Quoting », « Exit Status » et « Bourne Shell Builtins » : réponse HTTP 429 à la consultation ; la définition de pipefail a été confirmée par un résultat de recherche citant la page, le reste (exceptions de -e, codes 126, 127 et 128+n, syntaxe de trap) est non vérifié et à relire sur la page officielle.
  • set -euo pipefail, trap … EXIT, ${1:?…}, mktemp, printf, shutil.copy2, tempfile, which, pip : usages courants non relus dans une source officielle consultée.
  • Support de zsh par ShellCheck (non listé dans la page lue) ; comportement de set -e en zsh.
  • Indication Apple sur le shebang et les scripts : voir le cours « Terminal macOS » ; le « Shell Scripting Primer » d'Apple n'a pas été retrouvé dans la recherche effectuée (non vérifié).
  • Pages de documentation lues par un outil de synthèse automatique : citations en traduction libre, à relire sur les pages.

Sources officielles

Toutes consultées le 17 octobre 2026. Python : documentation affichant 3.15.0 comme version stable. Ruff et ShellCheck : numéro de version non affiché (état de la page à la date de consultation).