Leçon 09-5 — Automatiser de vraies tâches

1. Objectif

À la fin de cette leçon tu sauras : parcourir des dossiers avec pathlib (glob, iterdir), renommer et déplacer des fichiers en masse avec Path.rename et shutiltoujours en testant d'abord sur un dossier bac-à-sable et en mode simulation (« dry run »), générer un rapport automatique qui assemble API et CSV en un fichier de sortie, et structurer un script d'automatisation complet qui annonce ce qu'il fait et gère ses erreurs.

2. Pourquoi c'est important

C'est la leçon où tout le niveau converge : venv + requests + JSON + CSV

ranger un dossier Téléchargements, renommer 200 photos, produire chaque semaine le même rapport. L'automatisation est LA raison pour laquelle la moitié des gens apprennent Python. Mais elle a un prix : un script qui modifie de vrais fichiers peut faire de vrais dégâts — pas un traceback, des fichiers écrasés ou perdus. Cette leçon enseigne donc autant une discipline de sécurité (bac-à-sable, dry run, garde-fous) qu'une technique. Cette discipline est exactement celle des outils professionnels : git, terraform, rsync ont tous un mode simulation.

3. Explication simple

Automatiser une corvée de fichiers, c'est toujours la même boucle : lister → décider → agir.

from pathlib import Path

folder = Path("sandbox")
for file in folder.glob("*.jpg"):        # LISTER : tous les .jpg du dossier
    new_name = "photo-" + file.name       # DÉCIDER : le nouveau nom
    print(f"{file.name} -> {new_name}")   # AGIR... ou plutôt : SIMULER d'abord

glob("*.jpg") signifie « tous les fichiers dont le nom se termine par .jpg » — le * remplace n'importe quoi. Et la vraie action (renommer) n'apparaît qu'à la toute fin, protégée par la règle des trois étapes :

  1. Bac-à-sable : on copie quelques fichiers dans un dossier de test — jamais d'essai sur les vrais fichiers.
  2. Dry run : le script AFFICHE ce qu'il ferait, sans rien toucher. C'est le mode par défaut.
  3. Exécution réelle : seulement quand la simulation affiche exactement ce qu'on veut, on passe un drapeau explicite (--apply) pour agir.

Un script qui saute ces étapes n'est pas « rapide », il est dangereux.

4. Explication approfondie

a) La boîte à outils pathlib du parcours de dossiers (⏩ les bases de Path : niveau 05) :

folder.iterdir()          # tout le contenu du dossier (fichiers ET sous-dossiers)
folder.glob("*.pdf")      # les fichiers correspondant au motif
folder.glob("**/*.pdf")   # pareil, mais RÉCURSIF (sous-dossiers inclus)
file.name                 # "rapport final.pdf"
file.stem                 # "rapport final"        (sans extension)
file.suffix               # ".pdf"
file.is_file()            # True/False — filtre les sous-dossiers
file.stat().st_mtime      # date de dernière modification (timestamp)

Deux subtilités : glob renvoie les résultats dans un ordre non garantisorted(folder.glob(...)) si l'ordre compte (et il compte, pour des dry runs comparables). Et sous Windows, glob("*.jpg") attrape aussi photo.JPG (système insensible à la casse), mais sur Linux/macOS non — ne compte pas dessus : filtre avec file.suffix.lower() == ".jpg" pour un script portable.

b) Renommer, déplacer : rename et shutil.

file.rename(new_path)              # renomme OU déplace (même disque)
shutil.move(str(src), str(dst))    # déplace, même entre disques
shutil.copy2(src, dst)             # copie avec métadonnées (dates…)
shutil.rmtree(folder)              # ⚠️ supprime un dossier ENTIER, sans corbeille

Piège majeur : sous Windows, rename vers un chemin déjà existant lève FileExistsError... et shutil.move vers un fichier existant l'ÉCRASE sans un mot. D'où le garde-fou systématique avant toute action : if destination.exists(): ... (renoncer, ou suffixer -1, -2…). Et shutil.rmtree ne passe PAS par la corbeille : dans un script d'apprentissage, interdis-toi toute suppression — déplace vers un dossier a-trier/ ou corbeille/ à la place ; supprimer, c'est toi qui le feras à la main après vérification.

c) Le dry run n'est pas une option, c'est une architecture. Le mode simulation se code en UN booléen qui contrôle chaque action :

def move_file(src, dst, apply=False):
    print(f"{'[RÉEL]' if apply else '[SIMULATION]'} {src.name} -> {dst}")
    if apply:
        shutil.move(str(src), str(dst))

Points cardinaux : le défaut est apply=False (l'oubli est sans danger — principe de sécurité par défaut) ; la simulation affiche exactement ce que le réel fera (même code de décision, seule l'action finale est conditionnée) ; et le passage au réel exige un geste explicite de l'utilisateur (--apply sur la ligne de commande, via sys.argv, ⏩ niveau 05).

d) Idempotence : relancer sans casser. Un bon script d'automatisation peut être lancé DEUX fois sans aggraver les choses. Relance ton rangeur sur un dossier déjà rangé : il doit dire « rien à faire », pas re-déplacer images/ dans images/images/ ni planter sur un doublon. Concrètement : ignorer les fichiers déjà à leur place, tester exists() avant de créer (mkdir(exist_ok=True)), et gérer les collisions de noms. Teste toujours le scénario « deuxième lancement » — c'est lui qui révèle les scripts fragiles.

e) Le rapport automatique : assembler les leçons. Deuxième famille d'automatisation : produire régulièrement un fichier de synthèse. Le motif est toujours : collecter (API via requests, fichiers CSV via csv, dossiers via pathlib) → calculer (agrégats du 09-4) → écrire (texte, CSV, JSON — horodaté). Une nouveauté d'état d'esprit : un script d'automatisation a vocation à tourner sans humain devant — chaque source peut échouer, et le rapport doit le DIRE (météo : indisponible) plutôt que planter à 3 h du matin ou, pire, écrire des données fausses en silence.

Difficulté honnête : techniquement, cette leçon n'introduit presque rien — elle assemble. La difficulté est ailleurs : la rigueur. C'est la première fois que ton code a des effets irréversibles hors de son propre dossier. Le réflexe bac-à-sable + dry run doit devenir physique, comme regarder avant de traverser. Prends le temps de le rendre automatique ici, sur des fichiers de test — pas le jour où tu lances un script sur tes photos de famille.

5. Exemples commentés

Exemple 1 — fabriquer le bac-à-sable (à réutiliser partout)

# make_sandbox.py — create a disposable test folder with fake files
from pathlib import Path

sandbox = Path("sandbox")
sandbox.mkdir(exist_ok=True)               # exist_ok : pas d'erreur si déjà là

fake_files = ["photo1.jpg", "photo2.JPG", "rapport.pdf", "notes.txt",
              "archive.zip", "IMG_2026.png", "vieux rapport.pdf"]
for name in fake_files:
    (sandbox / name).write_text("fake content", encoding="utf-8")

print(f"{len(fake_files)} fichiers créés dans {sandbox.resolve()}")

Sortie :

7 fichiers créés dans C:\Users\jtron\Claude\learnpython\sandbox

Casser ce dossier n'a AUCUNE conséquence : on peut le supprimer et le régénérer en une commande. C'est ça, un bac-à-sable.

Exemple 2 — lister et inspecter avec glob

from pathlib import Path

sandbox = Path("sandbox")
for file in sorted(sandbox.glob("*")):         # sorted : ordre stable
    if file.is_file():                         # ignorer d'éventuels dossiers
        print(f"{file.name:<22} stem={file.stem:<15} suffix={file.suffix.lower()}")

Sortie :

IMG_2026.png           stem=IMG_2026        suffix=.png
archive.zip            stem=archive         suffix=.zip
notes.txt              stem=notes           suffix=.txt
photo1.jpg             stem=photo1          suffix=.jpg
photo2.JPG             stem=photo2          suffix=.jpg
rapport.pdf            stem=rapport         suffix=.pdf
vieux rapport.pdf      stem=vieux rapport   suffix=.pdf

Note suffix.lower() : photo2.JPG est bien normalisé en .jpg — la comparaison d'extensions se fait TOUJOURS en minuscules.

Exemple 3 — renommage en masse, dry run d'abord

# rename_photos.py — add a date prefix to every image, dry run by default
import sys
from pathlib import Path

APPLY = "--apply" in sys.argv                  # ⏩ sys.argv : niveau 05
sandbox = Path("sandbox")
prefix = "2026-07-"

count = 0
for file in sorted(sandbox.iterdir()):
    if not file.is_file() or file.suffix.lower() not in (".jpg", ".png"):
        continue                               # pas une image : on saute
    if file.name.startswith(prefix):
        continue                               # déjà renommé : idempotence !
    target = file.with_name(prefix + file.name)
    if target.exists():                        # garde-fou anti-écrasement
        print(f"[CONFLIT] {target.name} existe déjà, non touché")
        continue
    tag = "[RÉEL]" if APPLY else "[SIMULATION]"
    print(f"{tag} {file.name} -> {target.name}")
    if APPLY:
        file.rename(target)
    count += 1

mode = "renommés" if APPLY else "à renommer (relance avec --apply)"
print(f"{count} fichiers {mode}")

Sortie du dry run (python rename_photos.py) :

[SIMULATION] IMG_2026.png -> 2026-07-IMG_2026.png
[SIMULATION] photo1.jpg -> 2026-07-photo1.jpg
[SIMULATION] photo2.JPG -> 2026-07-photo2.JPG
3 fichiers à renommer (relance avec --apply)

Tout y est : défaut inoffensif, même logique dans les deux modes, garde-fou exists(), idempotence (le startswith fait qu'une relance dit « 0 fichiers »).

Exemple 4 — trier un dossier par type, avec shutil.move

# sort_folder.py — move files into subfolders by category, dry run by default
import shutil
import sys
from pathlib import Path

CATEGORIES = {
    ".jpg": "images", ".png": "images", ".gif": "images",
    ".pdf": "documents", ".txt": "documents", ".docx": "documents",
    ".zip": "archives",
}   # extension → sous-dossier ; le reste ira dans "autres"

def sort_folder(folder, apply=False):
    moved = 0
    for file in sorted(folder.iterdir()):
        if not file.is_file():
            continue
        category = CATEGORIES.get(file.suffix.lower(), "autres")   # .get + défaut !
        dest_dir = folder / category
        dest = dest_dir / file.name
        if dest.exists():
            print(f"[CONFLIT] {dest} existe déjà, non touché")
            continue
        tag = "[RÉEL]" if apply else "[SIMULATION]"
        print(f"{tag} {file.name:<25} -> {category}/")
        if apply:
            dest_dir.mkdir(exist_ok=True)      # créer le sous-dossier au besoin
            shutil.move(str(file), str(dest))
        moved += 1
    print(f"{moved} fichiers traités" + ("" if apply else " (simulation)"))

sort_folder(Path("sandbox"), apply="--apply" in sys.argv)

Sortie du dry run :

[SIMULATION] 2026-07-IMG_2026.png    -> images/
[SIMULATION] 2026-07-photo1.jpg      -> images/
[SIMULATION] 2026-07-photo2.JPG      -> images/
[SIMULATION] archive.zip             -> archives/
[SIMULATION] notes.txt               -> documents/
[SIMULATION] rapport.pdf             -> documents/
[SIMULATION] vieux rapport.pdf       -> documents/
7 fichiers traités (simulation)

Le dict CATEGORIES avec .get(..., "autres") : la même défense que sur le JSON (09-3), appliquée aux extensions inconnues.

Exemple 5 — le rapport automatique : API + CSV → fichier texte

# daily_report.py — assemble weather (API) and expenses (CSV) into a report
import csv
from datetime import date
from pathlib import Path

import requests

def fetch_temperature():
    """Return current temperature in Nice, or None on any network failure."""
    try:
        response = requests.get(
            "https://api.open-meteo.com/v1/forecast",
            params={"latitude": 43.7, "longitude": 7.27,
                    "current": "temperature_2m"},
            timeout=10,
        )
        response.raise_for_status()
        return response.json()["current"]["temperature_2m"]
    except requests.exceptions.RequestException:
        return None                    # le rapport le dira, sans planter

def expense_total(path):
    """Return (total, line count), or (None, 0) if the file is unusable."""
    try:
        with open(path, newline="", encoding="utf-8") as f:
            amounts = [float(row["amount"]) for row in csv.DictReader(f)]
        return sum(amounts), len(amounts)
    except (FileNotFoundError, ValueError, KeyError):
        return None, 0

temperature = fetch_temperature()
total, count = expense_total("expenses.csv")

report_path = Path(f"report-{date.today()}.txt")
with open(report_path, "w", encoding="utf-8") as f:
    f.write(f"Rapport du {date.today()}\n")
    f.write("=" * 30 + "\n")
    f.write(f"Météo Nice : {temperature} °C\n" if temperature is not None
            else "Météo Nice : indisponible\n")
    f.write(f"Dépenses : {total:.2f} € ({count} lignes)\n" if total is not None
            else "Dépenses : fichier illisible ou absent\n")

print(f"Rapport écrit : {report_path}")

Contenu de report-2026-07-05.txt (cas nominal) :

Rapport du 2026-07-05
==============================
Météo Nice : 26.7 °C
Dépenses : 254.90 € (7 lignes)

Chaque source a son propre filet ; une panne de l'une n'empêche ni l'autre ni l'écriture du rapport — et le rapport dit honnêtement ce qui a manqué. C'est le squelette de toute automatisation périodique.

6. Erreurs fréquentes

1) Tester directement sur les vrais fichiers

sort_folder(Path.home() / "Downloads", apply=True)   # premier lancement !

Aucun traceback — le script « marche »… et une règle fausse a envoyé 40 PDF dans autres/, écrasé deux doublons, et tu ne sais plus ce qui a bougé. Correction : bac-à-sable (copie de 10 fichiers) → dry run → lecture ligne à ligne de la simulation → SEULEMENT ALORS le vrai dossier, d'abord en dry run aussi.

2) L'action réelle par défaut

def sort_folder(folder, apply=True):    # défaut dangereux !

Celui qui appelle sort_folder(folder) « juste pour voir » modifie tout. Le défaut doit être l'option INOFFENSIVE : apply=False. Même logique que les médicaments à bouchon sécurisé : l'action dangereuse doit demander un geste volontaire.

3) Modifier ce qu'on est en train de parcourir

for file in folder.iterdir():
    file.rename(folder / "done" / file.name)   # on déplace PENDANT le parcours

Comportement indéfini : fichiers sautés, parfois erreurs — et pas pareil d'une machine à l'autre. Correction : figer la liste d'abord — for file in list(folder.iterdir()): (ou sorted(...), qui matérialise aussi). Même piège que modifier une liste pendant qu'on la parcourt (⏩ niveau 03).

4) Écraser sans le savoir avec shutil.move

shutil.move("a/rapport.pdf", "b/rapport.pdf")   # b/rapport.pdf existait…
# aucun message — l'ancien b/rapport.pdf a DISPARU

Path.rename lève FileExistsError sous Windows dans ce cas, mais shutil.move écrase en silence. Correction : le garde-fou if dest.exists(): avant CHAQUE déplacement, avec une politique explicite (sauter + signaler, ou suffixer -1, -2).

5) Construire des chemins en collant des chaînes

dest = str(folder) + "\\" + category + "\\" + file.name   # fragile et illisible

Ça casse à la première surprise (slash final déjà présent, portabilité). Correction : l'opérateur / de pathlibdest = folder / category / file.name — conçu exactement pour ça (⏩ niveau 05).

6) Le glob récursif qui ramasse trop

for file in folder.glob("**/*"):     # récursif : descend dans images/, documents/…
    ...                              # → re-trie ce qui est déjà trié, boucle sans fin

Un rangeur récursif retraite ses propres dossiers de destination. Correction : pour ranger UN niveau, iterdir() ou glob("*") suffisent ; si le récursif est voulu, exclure explicitement les dossiers de destination (if category_dir in file.parents: continue).

7. Lire les messages d'erreur

FileExistsError — la destination existe déjà

Traceback (most recent call last):
  File "C:\Users\jtron\Claude\learnpython\rename_photos.py", line 18, in <module>
    file.rename(target)
FileExistsError: [WinError 183] Impossible de créer un fichier déjà existant:
'sandbox\\photo1.jpg' -> 'sandbox\\2026-07-photo1.jpg'

Lecture : la dernière ligne donne les DEUX chemins — source et cible. La cible existe déjà (souvent : le script a déjà tourné — ton idempotence a une faille, ou deux fichiers différents produisent le même nom cible). Ce traceback est en réalité une bonne nouvelle : Windows a refusé d'écraser. Traite le cas dans le code (garde-fou exists()), pas en supprimant le fichier gênant à la main.

PermissionError — le fichier est verrouillé ou protégé

PermissionError: [WinError 32] Le processus ne peut pas accéder au fichier car
ce fichier est utilisé par un autre processus: 'sandbox\\rapport.pdf'

Très fréquent sous Windows : le fichier est OUVERT ailleurs (le PDF affiché dans le navigateur, le CSV ouvert dans Excel — Excel verrouille !). Ferme le programme et relance. Dans un script robuste : try/except PermissionError autour de l'action, signaler le fichier et CONTINUER avec les suivants — un fichier verrouillé ne doit pas faire échouer les 199 autres.

FileNotFoundError sur un chemin que tu « viens de construire »

FileNotFoundError: [WinError 3] Le chemin d'accès spécifié est introuvable:
'sandbox\\photo1.jpg' -> 'sandbox\\images\\photo1.jpg'

Le déplacement vise un dossier (images/) qui n'existe pas encore : rename/move ne créent JAMAIS les dossiers intermédiaires. Correction : dest_dir.mkdir(exist_ok=True) (ou parents=True pour créer toute la chaîne) avant le déplacement — et seulement en mode réel, sinon ton dry run laisse des dossiers vides partout.

8. Exercices — faciles

Règles : fichiers dans exercices/mes-reponses/, nommés ex-09-5-a.py, etc. TOUT se passe dans un bac-à-sable regénérable (exemple 1) — aucun script de cette série ne touche un vrai dossier.

a) Écris ton make_sandbox.py : il crée sandbox/ avec au moins 10 fichiers de types variés (dont un .JPG majuscule et un nom avec espace), et affiche le compte. Relance-le : il doit fonctionner aussi la deuxième fois (quel argument de mkdir le permet ?).

b) L'inventaire : liste le bac-à-sable trié par extension — une ligne par fichier (nom, extension normalisée, taille en octets via file.stat().st_size), puis un résumé par extension avec le dict accumulateur du 09-4 ({'.jpg': 3, '.pdf': 2, ...}).

c) Dry run pur : un script qui affiche [SIMULATION] <nom> -> majuscules/<NOM> pour chaque .txt du bac-à-sable — SANS rien renommer, et sans même de variable apply (cette version ne sait QUE simuler). Vérifie après coup : sandbox/ doit être strictement intact.

9. Exercices — moyens

a) Complète l'exercice (c) : ajoute le mode réel derrière --apply (via sys.argv), le garde-fou exists(), et le compteur final. Scénario de test imposé, dans l'ordre : dry run → vérifier à l'œil → --apply → relancer --apply une DEUXIÈME fois. La deuxième exécution réelle doit afficher 0 fichiers — sinon ton script n'est pas idempotent, corrige.

b) Le rangeur par date : trie les fichiers du bac-à-sable dans des sous-dossiers 2026-07/, 2026-06/… selon leur date de modification (file.stat().st_mtimedatetime.fromtimestamp(...)f"{dt.year}-{dt.month:02d}", ⏩ niveau 05). Dry run par défaut, shutil.move en réel, création des dossiers au bon moment (pas en simulation !).

c) Le rapport de dossier : un script qui génère folder_report.csv — une ligne par fichier du bac-à-sable (nom, extension, taille, date de modification ISO) + affiche le total. Assemble pathlib (lister) et DictWriter (09-4). Bonus : une deuxième passe qui lit ce CSV et affiche les 3 plus gros fichiers.

10. Exercices — difficiles

a) Le dédoublonneur prudent : dans un bac-à-sable où tu auras créé des doublons EXACTS (même contenu, noms différents — fais-le dans make_sandbox.py), écris un script qui les détecte par contenu, et propose en dry run de déplacer les copies (pas les originaux !) vers sandbox/doublons/. Jamais de suppression. Définis et affiche clairement ta règle : lequel est « l'original » ?

b) L'automatisation hebdomadaire complète : weekly_report.py assemble TOUT le niveau — il (1) interroge Open-Meteo pour les prévisions à 7 jours, (2) lit ton expenses.csv du 09-4, (3) inventorie un dossier au choix, et écrit weekly-report-<date>.txt en trois sections. Contraintes : chaque source a son filet d'erreur indépendant (le rapport se génère TOUJOURS, sections « indisponible » comprises) ; les appels réseau ont leur timeout ; zéro modification de fichiers hors du rapport lui-même. Lance-le réseau coupé pour valider.

11. Mini-projet lié — « rangeur de Téléchargements »

Fiche complète : projects/mini-projects/10-script-rangement-fichiers.md.

C'est le grand œuvre de la leçon (2–3 h) : le script qui range un vrai dossier en désordre par catégories, dossier cible en argument, mode simulation par défaut, --apply explicite, garde-fous anti-écrasement, compte rendu final. La fiche détaille le cahier des charges et les étapes. Rappel solennel : c'est le premier projet du parcours qui modifie de vrais fichiers — la règle des trois étapes (bac-à-sable → dry run → réel) s'applique à CHAQUE essai, sans exception, même « juste pour tester un petit truc ».

12. Correction / méthode de correction

Les solutions détaillées sont dans solutions/serie-09-5-solutions.md.

Méthode : (1) finis TOUS les exercices avant d'ouvrir les solutions ; (2) compare d'abord les garde-fous (défaut inoffensif ? exists() avant chaque action ? idempotence testée ?) — c'est le cœur de la leçon, avant l'élégance du code ; (3) rejoue les scénarios de panne des solutions (fichier verrouillé, dossier manquant) sur ton propre code ; (4) note dans ton journal ta checklist personnelle de mise en production d'un script de fichiers — tu la relies avant CHAQUE --apply des mois à venir.

13. À retenir

14. Questions de révision

  1. Quelles sont les trois étapes obligatoires avant qu'un script de fichiers touche à de vraies données, et pourquoi cet ordre ?
  2. Pourquoi le mode simulation doit-il être le DÉFAUT, et pas une option ?
  3. Quelle différence de comportement entre Path.rename et shutil.move quand la destination existe, et quel garde-fou neutralise les deux cas ?
  4. Que signifie « idempotent » pour un script de rangement, et comment le teste-t-on concrètement ?
  5. Pourquoi faut-il figer (sorted/list) le résultat d'iterdir avant de déplacer des fichiers ?
  6. Un PermissionError sous Windows en plein renommage : cause la plus probable, et que doit faire le script pour les 199 autres fichiers ?
  7. Pourquoi mkdir ne doit-il s'exécuter qu'en mode réel dans un script à dry run ?
  8. Dans un rapport automatique, pourquoi chaque source de données a-t-elle son propre try/except plutôt qu'un seul global ?

15. Checklist de compréhension

Si une case reste vide : refais les exercices de la section correspondante demain, AVANT de passer au niveau suivant.

16. Commit conseillé

git add lessons/level-09-automatisation-apis-donnees/exercices/mes-reponses/
git commit -m "exercises: complete lesson 09-5 real-world automation"

C'est aussi la fin du niveau : coche les critères de sortie du README du niveau, puis fais le commit de fin de niveau qui y est indiqué. Entrée de journal + git push.