Solutions — série 09-5 (automatiser de vraies tâches)

Rappel de la règle d'or : on n'ouvre ce fichier qu'après avoir terminé (ou sérieusement séché sur) la série. Compare le raisonnement, pas seulement le code : une solution différente qui marche est souvent valable.

Pour cette série, compare EN PRIORITÉ les garde-fous : défaut inoffensif, exists() avant chaque action, idempotence. Un code élégant sans garde-fou est une non-solution ici.


Exercice facile a — make_sandbox.py

Raisonnement — Le bac-à-sable doit être REGÉNÉRABLE : le script est relançable sans erreur (c'est exist_ok=True qui le permet), et il écrase tranquillement ses propres fichiers de test — ils sont jetables par définition.

Solution

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

FAKE_FILES = [
    "photo1.jpg", "photo2.JPG", "IMG_2026.png", "capture.gif",
    "rapport.pdf", "vieux rapport.pdf", "notes.txt", "todo.txt",
    "archive.zip", "backup.zip", "script.py",
]

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

for name in FAKE_FILES:
    (sandbox / name).write_text(f"fake content of {name}", encoding="utf-8")

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

Sortie :

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

Pourquoi ça marchemkdir(exist_ok=True) répond à la question de l'énoncé : sans cet argument, la deuxième exécution lève FileExistsError: [WinError 183] ...: 'sandbox'. write_text crée ou écrase chaque fichier — un contenu par nom, ce qui servira à l'exercice des doublons (contenus DIFFÉRENTS ici, exprès). La liste contient bien un .JPG majuscule et un nom avec espace, comme exigé.

Erreur classique sur cet exercice — Mettre les fichiers de test dans le dossier COURANT au lieu d'un sous-dossier dédié : au premier script de tri, tes vrais fichiers d'exercices (ex-09-5-a.py !) se font ranger dans documents/. Le bac-à-sable est un sous-dossier isolé, toujours.

Variante plus difficile — Ajoute un argument --reset qui supprime et recrée le bac-à-sable (shutil.rmtree PUIS mkdir). C'est la seule utilisation légitime de rmtree du niveau : sur un dossier que ton propre script a créé, identifié par son nom exact — et protège-la d'un test if sandbox.name != "sandbox": ... qui refuse tout autre cible.


Exercice facile b — l'inventaire par extension

Raisonnement — Pur « lister + agréger » : parcours trié, normalisation des extensions (.lower()), affichage par fichier, puis le dict accumulateur du 09-4 appliqué aux extensions au lieu des catégories de dépenses.

Solution

# ex-09-5-b.py
from pathlib import Path

sandbox = Path("sandbox")

files = sorted(
    (f for f in sandbox.iterdir() if f.is_file()),
    key=lambda f: f.suffix.lower(),
)

counts = {}
for file in files:
    ext = file.suffix.lower()
    size = file.stat().st_size
    print(f"{file.name:<22} {ext:<6} {size} octets")
    counts[ext] = counts.get(ext, 0) + 1

print("-" * 40)
print(counts)

Sortie (extrait) :

capture.gif            .gif   27 octets
IMG_2026.png           .png   28 octets
photo1.jpg             .jpg   26 octets
photo2.JPG             .jpg   26 octets
...
----------------------------------------
{'.gif': 1, '.png': 1, '.jpg': 2, '.pdf': 2, '.py': 1, '.txt': 2, '.zip': 2}

Pourquoi ça marche — Le tri par suffix.lower() regroupe visuellement les types ET normalise photo2.JPG avec les .jpg (compté avec eux dans le dict — c'est la preuve demandée que la normalisation fonctionne). stat().st_size donne la taille en octets sans ouvrir le fichier.

Erreur classique sur cet exercice — Compter avec file.suffix brut : le résumé affiche .jpg: 1 ET .JPG: 1 — deux catégories pour un même type. Sur cette machine (Windows, insensible à la casse) beaucoup d'outils les confondent, ton dict Python, lui, ne les confond JAMAIS : les clés de dict sont sensibles à la casse, normalise avant d'accumuler.

Variante plus difficile — Accumule aussi la taille totale par extension et affiche le résumé trié par taille décroissante, tailles en Ko (size / 1024:.1f). Deux accumulateurs parallèles — exactement le motif totaux/comptes du 09-4.


Exercice facile c — le dry run pur

Raisonnement — Version d'entraînement du motif : on écrit TOUTE la logique de décision (sélection des .txt, calcul du nouveau nom) et on remplace l'action par un affichage. Aucun rename dans le fichier — il est donc PHYSIQUEMENT incapable de faire des dégâts.

Solution

# ex-09-5-c.py — simulation only: this script cannot modify anything
from pathlib import Path

sandbox = Path("sandbox")

count = 0
for file in sorted(sandbox.glob("*.txt")):
    new_name = file.stem.upper() + file.suffix     # notes.txt → NOTES.txt
    print(f"[SIMULATION] {file.name} -> majuscules/{new_name}")
    count += 1

print(f"{count} fichiers concernés — rien n'a été modifié.")

Sortie :

[SIMULATION] notes.txt -> majuscules/NOTES.txt
[SIMULATION] todo.txt -> majuscules/TODO.txt
2 fichiers concernés — rien n'a été modifié.

Pourquoi ça marche — La décision (stem.upper() + suffix) est exactement celle qu'exécuterait le mode réel : la simulation est FIDÈLE, pas approximative. Vérification d'intégrité demandée : relance l'inventaire de l'exercice b — mêmes fichiers, mêmes tailles.

Erreur classique sur cet exercicefile.name.upper() au lieu de file.stem.upper() + file.suffix : la simulation affiche NOTES.TXT — l'extension aussi passe en majuscules, ce qui casserait la cohérence (et les glob("*.txt") suivants sur les systèmes sensibles à la casse). Une simulation fausse est PIRE que pas de simulation : on valide des yeux un plan qui n'est pas celui qu'on exécutera.

Variante plus difficile — Affiche aussi ce que le mode réel DEVRA créer : la ligne [SIMULATION] mkdir majuscules/ en tête (seulement si au moins un fichier est concerné). Penser aux actions préparatoires dès la simulation, c'est ce qui rendra le --apply de l'exercice suivant sans surprise.


Exercice moyen a — --apply et le test d'idempotence

Raisonnement — On complète le motif : booléen depuis sys.argv, action conditionnée, garde-fou exists(), ET la clause d'idempotence — un fichier déjà en majuscules ne doit pas être retraité, sinon la deuxième exécution réelle re-déplace ou signale des conflits fantômes.

Solution

# ex-09-5-ma.py
import sys
from pathlib import Path

APPLY = "--apply" in sys.argv
sandbox = Path("sandbox")
dest_dir = sandbox / "majuscules"

count = 0
for file in sorted(sandbox.glob("*.txt")):
    new_name = file.stem.upper() + file.suffix
    if file.stem == file.stem.upper():        # déjà en majuscules : idempotence
        continue
    target = dest_dir / new_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} -> majuscules/{new_name}")
    if APPLY:
        dest_dir.mkdir(exist_ok=True)         # créé au besoin, en mode réel seulement
        file.rename(target)
    count += 1

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

Scénario de test imposé, et ses sorties :

$ python ex-09-5-ma.py            # dry run
[SIMULATION] notes.txt -> majuscules/NOTES.txt
[SIMULATION] todo.txt -> majuscules/TODO.txt
2 fichiers à traiter (relance avec --apply)

$ python ex-09-5-ma.py --apply    # exécution réelle
[RÉEL] notes.txt -> majuscules/NOTES.txt
[RÉEL] todo.txt -> majuscules/TODO.txt
2 fichiers traités

$ python ex-09-5-ma.py --apply    # DEUXIÈME exécution réelle
0 fichiers traités                # ← idempotent : rien à refaire

Pourquoi ça marche — La deuxième exécution trouve 0 fichier : les .txt restants (déplacés dans majuscules/) ne matchent plus sandbox.glob("*.txt") — et si on relançait sur des fichiers déjà en majuscules restés sur place, la clause stem == stem.upper() les saute. Le mkdir vit DANS le if APPLY: : le dry run ne laisse aucune trace.

Erreur classique sur cet exercice — Faire du garde-fou exists() le SEUL mécanisme d'idempotence : il empêche l'écrasement, mais la deuxième exécution affiche alors des [CONFLIT] partout au lieu de « rien à faire » — techniquement sans dégât, mais un rapport mensonger (il n'y a pas de conflit, il n'y a juste rien à faire). Idempotence = reconnaître le travail déjà fait, pas seulement refuser de le refaire.

Variante plus difficile — Ajoute --verbose : en mode normal, seul le résumé s'affiche ; en verbose, le détail ligne par ligne. Deux drapeaux indépendants dans sys.argv — quand tu en auras trois, tu auras découvert pourquoi argparse existe (⏩ hors méthode, module standard à explorer en autonomie).


Exercice moyen b — le rangeur par date

Raisonnement — La catégorie n'est plus l'extension mais une date formatée depuis st_mtime. Chaîne de conversion : timestamp → datetime → clé "AAAA-MM". Le reste est le motif standard : figer la liste, décider, simuler/agir avec garde-fous.

Solution

# ex-09-5-mb.py
import shutil
import sys
from datetime import datetime
from pathlib import Path

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

moved = 0
for file in sorted(sandbox.iterdir()):
    if not file.is_file():
        continue                                  # sous-dossiers : pas touche
    modified = datetime.fromtimestamp(file.stat().st_mtime)
    month_key = f"{modified.year}-{modified.month:02d}"    # ex. 2026-07
    dest_dir = sandbox / month_key
    target = dest_dir / file.name
    if target.exists():
        print(f"[CONFLIT] {target} existe déjà, non touché")
        continue
    tag = "[RÉEL]" if APPLY else "[SIMULATION]"
    print(f"{tag} {file.name:<22} -> {month_key}/")
    if APPLY:
        dest_dir.mkdir(exist_ok=True)
        shutil.move(str(file), str(target))
    moved += 1

print(f"{moved} fichiers" + ("" if APPLY else " (simulation)"))

Sortie du dry run (tous les fichiers de test datent d'aujourd'hui) :

[SIMULATION] IMG_2026.png           -> 2026-07/
[SIMULATION] archive.zip            -> 2026-07/
...
11 fichiers (simulation)

Pourquoi ça marchest_mtime est un nombre de secondes depuis 1970 ; datetime.fromtimestamp le rend humain, et le f-string {month:02d} force 07 (deux chiffres) — sans quoi 2026-7 et 2026-11 se trieraient mal. iterdir + is_file() épargne les dossiers de mois déjà créés : c'est ce qui rend le script relançable.

Erreur classique sur cet exercice — Tester le dry run… sur un bac-à-sable créé à l'instant : tous les fichiers vont dans le même mois et on ne voit pas si le script gère plusieurs dossiers. Pour un vrai test, anti-date quelques fichiers : os.utime(path, (t, t)) avec un timestamp du mois dernier (calcule t = time.time() - 40 * 24 * 3600). Tester avec des données qui n'exercent qu'UN chemin du code, c'est ne pas tester.

Variante plus difficile — Combine les deux critères : 2026-07/images/, 2026-07/documents/… (date PUIS type). Une seule ligne change vraiment — laquelle ? (Réponse : la construction de dest_dir, qui gagne un étage ; c'est la récompense d'avoir isolé la décision de l'action.)


Exercice moyen c — folder_report.csv

Raisonnement — L'inverse du rangeur : on ne modifie RIEN, on OBSERVE et on écrit un rapport. Assemblage direct : pathlib fournit les données, DictWriter les écrit — et la deuxième passe relit avec DictReader (le CSV redevient des chaînes !).

Solution

# ex-09-5-mc.py
import csv
from datetime import datetime
from pathlib import Path

sandbox = Path("sandbox")
report_path = Path("folder_report.csv")

rows = []
for file in sorted(sandbox.iterdir()):
    if not file.is_file():
        continue
    stat = file.stat()
    rows.append({
        "name": file.name,
        "extension": file.suffix.lower(),
        "size_bytes": stat.st_size,
        "modified": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"),
    })

with open(report_path, "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["name", "extension", "size_bytes", "modified"])
    writer.writeheader()
    writer.writerows(rows)

print(f"{len(rows)} fichiers inventoriés -> {report_path}")

# seconde passe : relire le rapport et sortir le top 3 des tailles
with open(report_path, newline="", encoding="utf-8") as f:
    inventory = list(csv.DictReader(f))
top3 = sorted(inventory, key=lambda r: int(r["size_bytes"]), reverse=True)[:3]
for row in top3:
    print(f"{row['name']:<22} {row['size_bytes']} octets")

Pourquoi ça marche — Chaque ligne du CSV est construite comme un dict aux types simples (le datetime passé en ISO via isoformat — le réflexe du 09-3). La seconde passe illustre le cycle complet producteur/consommateur de CSV… et repasse par la case int(r["size_bytes"]) : même TON PROPRE fichier te revient en chaînes.

Erreur classique sur cet exercice — Le top 3 avec key=lambda r: r["size_bytes"] sans int() : tri alphabétique — "9" bat "1000". Tu as écrit ces nombres toi-même trois lignes plus haut, ils reviennent quand même en chaînes : c'est le format qui veut ça, pas la provenance.

Variante plus difficile — Ajoute une colonne sha256 (hachage du contenu — hashlib.sha256(file.read_bytes()).hexdigest(), module standard) : ton rapport devient une base pour détecter les doublons de l'exercice difficile, même entre deux exécutions.


Exercice difficile a — le dédoublonneur prudent

Raisonnement — Détecter les doublons PAR CONTENU : un dict contenu → premier fichier vu. Le parcours en ordre trié rend la règle d'originalité STABLE et annonçable : « le premier par ordre alphabétique est l'original ». Ensuite, motif dry run habituel — et jamais de suppression : les copies vont dans doublons/.

Solution

# ex-09-5-da.py
import shutil
import sys
from pathlib import Path

APPLY = "--apply" in sys.argv
sandbox = Path("sandbox")
quarantine = sandbox / "doublons"

print("Règle : à contenu identique, l'original est le premier nom "
      "par ordre alphabétique.")

seen = {}              # content (bytes) → original Path
duplicates = []        # list of (copy, original)
for file in sorted(p for p in sandbox.iterdir() if p.is_file()):
    content = file.read_bytes()
    if content in seen:
        duplicates.append((file, seen[content]))
    else:
        seen[content] = file

for copy, original in duplicates:
    target = quarantine / copy.name
    if target.exists():
        print(f"[CONFLIT] {target} existe déjà, non touché")
        continue
    tag = "[RÉEL]" if APPLY else "[SIMULATION]"
    print(f"{tag} {copy.name} (copie de {original.name}) -> doublons/")
    if APPLY:
        quarantine.mkdir(exist_ok=True)
        shutil.move(str(copy), str(target))

if not duplicates:
    print("Aucun doublon détecté.")
elif not APPLY:
    print(f"{len(duplicates)} doublon(s) — relance avec --apply pour déplacer.")

Sortie (avec, dans make_sandbox.py, deux fichiers créés au même contenu : notes.txt et notes - copie.txt) :

Règle : à contenu identique, l'original est le premier nom par ordre alphabétique.
[SIMULATION] notes.txt (copie de notes - copie.txt) -> doublons/
1 doublon(s) — relance avec --apply pour déplacer.

(Eh oui : notes - copie.txt passe AVANT notes.txt dans l'ordre alphabétique — l'espace est plus petit que le point. La règle est stable et annoncée, mais surprenante : parfait sujet pour ton journal.)

Pourquoi ça marcheread_bytes() compare les CONTENUS exacts, pas les noms ni les tailles ; le dict fait de la détection un simple test d'appartenance. Séparer détection (première boucle) et action (seconde) rend la simulation complète avant le premier geste — et permettrait d'ajouter un récapitulatif entre les deux.

Erreur classique sur cet exercice — Comparer les fichiers par PAIRES imbriquées (for a in files: for b in files:) : quadratique, doublons signalés deux fois (a,b) et (b,a), et l'original mal défini. Le dict règle les trois problèmes d'un coup. L'autre erreur, de conception : SUPPRIMER les copies « puisque c'est des doublons » — l'énoncé l'interdit et la vie aussi : une détection boguée + suppression = données perdues ; une détection boguée + quarantaine = un dossier à re-trier. Toujours l'option réversible.

Variante plus difficile — Pour de gros fichiers, read_bytes() charge tout en mémoire et compare octet à octet : remplace le contenu par son empreinte hashlib.sha256 comme clé du dict, calculée par blocs (while chunk := f.read(65536):). Même logique, échelle industrielle — c'est ainsi que fonctionnent les vrais outils de déduplication.


Exercice difficile b — weekly_report.py, l'assemblage final

Raisonnement — Trois sources indépendantes, trois collecteurs qui renvoient chacun leurs données OU None, et un assembleur qui écrit TOUJOURS le rapport en signalant les sections mortes. C'est l'exemple 5 de la leçon, étendu à trois sources — la structure ne change pas, elle se répète.

Solution

# weekly_report.py
import csv
from datetime import date, datetime
from pathlib import Path

import requests

def fetch_forecast():
    """Return a list of (day, tmin, tmax) for 7 days, or None."""
    try:
        response = requests.get(
            "https://api.open-meteo.com/v1/forecast",
            params={"latitude": 43.7, "longitude": 7.27,
                    "daily": "temperature_2m_min,temperature_2m_max",
                    "forecast_days": 7, "timezone": "auto"},
            timeout=10,
        )
        response.raise_for_status()
        daily = response.json()["daily"]
        return list(zip(daily["time"], daily["temperature_2m_min"],
                        daily["temperature_2m_max"]))
    except (requests.exceptions.RequestException, KeyError):
        return None

def load_expense_totals(path):
    """Return {category: total} or None."""
    try:
        totals = {}
        with open(path, newline="", encoding="utf-8") as f:
            for row in csv.DictReader(f):
                totals[row["category"]] = (
                    totals.get(row["category"], 0) + float(row["amount"]))
        return totals
    except (FileNotFoundError, KeyError, ValueError):
        return None

def scan_folder(folder):
    """Return (file_count, total_bytes) or None."""
    if not folder.is_dir():
        return None
    files = [f for f in folder.iterdir() if f.is_file()]
    return len(files), sum(f.stat().st_size for f in files)

def write_report(forecast, expenses, folder_stats, folder, path):
    with open(path, "w", encoding="utf-8") as f:
        f.write(f"Rapport hebdomadaire — {date.today()}\n")
        f.write("=" * 40 + "\n\n")

        f.write("MÉTÉO (Nice, 7 jours)\n")
        if forecast is None:
            f.write("  indisponible\n")
        else:
            for day, tmin, tmax in forecast:
                f.write(f"  {day} : {tmin} à {tmax} °C\n")

        f.write("\nDÉPENSES PAR CATÉGORIE\n")
        if expenses is None:
            f.write("  indisponible (fichier absent ou illisible)\n")
        else:
            for category, total in sorted(expenses.items()):
                f.write(f"  {category:<15} {total:>8.2f} €\n")

        f.write(f"\nDOSSIER SURVEILLÉ ({folder})\n")
        if folder_stats is None:
            f.write("  indisponible (dossier introuvable)\n")
        else:
            count, size = folder_stats
            f.write(f"  {count} fichiers, {size / 1024:.1f} Ko\n")

def main():
    watched = Path("sandbox")
    report_path = Path(f"weekly-report-{date.today()}.txt")
    write_report(
        fetch_forecast(),
        load_expense_totals("expenses.csv"),
        scan_folder(watched),
        watched,
        report_path,
    )
    print(f"Rapport écrit : {report_path}")

if __name__ == "__main__":
    main()

Test réseau coupé — le rapport s'écrit quand même :

MÉTÉO (Nice, 7 jours)
  indisponible

DÉPENSES PAR CATÉGORIE
  courses            77.40 €
  ...

Pourquoi ça marche — Chaque collecteur encapsule SES risques (réseau pour l'API, fichier/format pour le CSV, existence pour le dossier) et parle le même langage d'échec : None. L'assembleur n'a aucun try : il ne fait que de la mise en forme sur des données déjà sécurisées. Panne d'une source = une section « indisponible », jamais un rapport manquant — le critère exact de l'énoncé, vérifié réseau coupé.

Erreur classique sur cet exercice — Le try/except UNIQUE autour de tout main() : à la première panne (souvent le réseau), les deux autres sections — parfaitement disponibles — disparaissent aussi, et le rapport ne s'écrit pas. La granularité de la gestion d'erreurs doit suivre la granularité des sources : trois sources, trois filets.

Variante plus difficile — Historise : ajoute une ligne par exécution dans report_history.csv (date, météo OK ?, total dépenses, nb fichiers) — le rapport devient une série temporelle qu'on peut analyser… avec les outils du 09-4, la boucle est bouclée. Puis programme l'exécution hebdomadaire réelle avec le Planificateur de tâches Windows (hors Python : c'est le système qui lance python weekly_report.py).


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

La fiche complète (cahier des charges, étapes, extensions) : projects/mini-projects/10-script-rangement-fichiers.md.

Ta grille d'auto-correction, dans l'ordre d'importance : (1) sécurité — simulation par défaut, --apply explicite, garde-fou exists() avant chaque déplacement, aucune suppression nulle part ; (2) idempotence — relancé deux fois avec --apply, le script dit « 0 fichiers » la deuxième ; les dossiers de destination (images/…) ne sont jamais eux-mêmes re-triés ; (3) fidélité — la simulation affiche EXACTEMENT ce que le réel fera (mêmes décisions, même compte) ; (4) robustesse — un fichier verrouillé (ouvre un PDF du dossier pendant le test !) est signalé et n'arrête pas les autres ; (5) lisibilité — le dict extension → catégorie est en haut du fichier, modifiable sans lire le reste. Si les cinq tiennent sur le bac-à-sable, tu peux le lancer — en simulation d'abord — sur ton vrai dossier Downloads. C'est le premier script du parcours qui te rend SERVICE pour de vrai : savoure.