Solutions — série 05-2 (chemins avec pathlib)

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. Pour cette série, vérifie surtout ton réflexe : à chaque FileNotFoundError, as-tu affiché cwd()/resolve() en premier ?


Exercice facile a — construire et inspecter un chemin

Raisonnement — Pur exercice de vocabulaire pathlib, sans disque : on assemble avec /, on lit les attributs. Le seul piège annoncé est le point du suffixe.

Solution

# ex-05-2-a.py
from pathlib import Path

p = Path("archives") / "2026" / "rapport-juillet.txt"
print(p)
print(p.name)
print(p.stem)
print(p.suffix)
print(p.parent)

Sortie (sous Windows) :

archives\2026\rapport-juillet.txt
rapport-juillet.txt
rapport-juillet
.txt
archives\2026

Pourquoi ça marche — L'opérateur / assemble les morceaux et pathlib choisit le séparateur de la plateforme (d'où les \ affichés alors qu'on a tapé des /). Rien n'existe sur le disque : un Path est une adresse, pas un fichier. suffix vaut bien .txtavec le point.

Erreur classique sur cet exercice — Prédire txt sans le point, ou archives/2026/... avec des slashs à l'affichage. Aussi : s'étonner que ça « marche » alors que le dossier archives n'existe pas — construire un chemin et toucher au disque sont deux choses distinctes, c'est un point central de la leçon.

Variante plus difficile — Affiche p.parts et p.suffixes pour Path("data/backup.tar.gz"). Que remarques-tu sur suffix vs suffixes pour les extensions doubles ?


Exercice facile b — ou-suis-je.py, lancé de deux endroits

Raisonnement — L'exercice matérialise LA phrase de la leçon : le chemin relatif part du dossier courant du terminal. Même script, deux lancements, deux résultats — la preuve par l'expérience.

Solution

# ou-suis-je.py
from pathlib import Path

print("Dossier courant :", Path.cwd())
print("courses.txt serait cherché ici :", Path("courses.txt").resolve())

Lancé depuis mes-reponses/ :

Dossier courant : C:\Users\jtron\Claude\learnpython\lessons\level-05-fichiers-exceptions-modules\exercices\mes-reponses
courses.txt serait cherché ici : C:\...\exercices\mes-reponses\courses.txt

Lancé depuis la racine du repo :

Dossier courant : C:\Users\jtron\Claude\learnpython
courses.txt serait cherché ici : C:\Users\jtron\Claude\learnpython\courses.txt
# Ce qui change : TOUT. Le script est le même, mais le dossier courant est
# celui du TERMINAL au moment du lancement — pas celui du fichier .py.
# "courses.txt" désigne donc deux fichiers différents selon le lancement.

Pourquoi ça marchecwd() interroge le processus (« d'où ai-je été lancé ? ») et resolve() transforme le chemin relatif en absolu à partir de ce même dossier courant. Les deux affichages bougent ensemble : c'est bien le lancement, pas le script, qui décide.

Erreur classique sur cet exercice — Conclure « resolve() se trompe, le fichier n'est pas là » : resolve() ne vérifie PAS l'existence, il montre où Python CHERCHERAIT. C'est justement ce qui en fait un outil de diagnostic — il montre l'intention, que le fichier existe ou non.

Variante plus difficile — Ajoute au script l'affichage de Path(__file__).parent. Relance des deux endroits : lequel des trois affichages est STABLE ? Tu viens de démontrer pourquoi l'ancrage au script (exercice moyen b) fonctionne.


Exercice facile c — dossier sorties/ relançable

Raisonnement — Trois opérations élémentaires (créer un dossier, écrire, relire) plus une contrainte : relançable. Le seul point qui casse au second lancement est mkdir()exist_ok=True.

Solution

# ex-05-2-c.py
from pathlib import Path

out = Path("sorties")
out.mkdir(exist_ok=True)                 # idempotent: no error on re-run

target = out / "bonjour.txt"
target.write_text("Bonjour pathlib !\n", encoding="utf-8")
print(target.read_text(encoding="utf-8").strip())

Sortie (identique au 1er et au 10e lancement) :

Bonjour pathlib !

Pourquoi ça marcheexist_ok=True transforme « le dossier existe déjà » d'erreur en non-événement. write_text en revanche écrase naturellement le contenu précédent — ici c'est acceptable (même contenu), mais note-le : write_text se comporte comme le mode "w".

Erreur classique sur cet exercicemkdir() nu → FileExistsError au deuxième lancement. Et l'oubli d'encoding="utf-8" dans write_text/read_text — le déguisement pathlib du piège de 05-1 ; le ! passe, mais mets un é dans le texte et relis-le depuis un autre outil pour voir le problème.

Variante plus difficile — Remplace l'écriture par un AJOUT d'une ligne horodatée à chaque lancement. write_text ne sait pas faire « append » : il te faut with open(target, "a", encoding="utf-8") — les deux mondes coopèrent, un Path se donne directement à open().


Exercice moyen a — inventaire.py

Raisonnementglob("*.py") fournit les chemins, sorted() impose l'ordre (celui de glob n'est pas garanti), .name extrait le nom seul, et len() donne le total — en gardant la liste dans une variable pour ne pas « globber » deux fois.

Solution

# inventaire.py
from pathlib import Path

py_files = sorted(Path.cwd().glob("*.py"))

for path in py_files:
    print(path.name)
print(f"Total : {len(py_files)} fichier(s) .py")

Sortie (exemple, depuis mes-reponses/) :

ex-05-2-a.py
ex-05-2-c.py
inventaire.py
ou-suis-je.py
Total : 4 fichier(s) .py

Pourquoi ça marcheglob renvoie des objets Path (pas des chaînes) ; sorted sait les comparer ; .name ne garde que le dernier segment. Le script se liste lui-même : normal, il est un .py du dossier.

Erreur classique sur cet exerciceprint(path) au lieu de print(path.name) : chemins complets, contrainte non respectée. Ou compter avec un compteur manuel dans la boucle PUIS re-glober pour l'affichage — deux passages, résultats potentiellement différents si le dossier change entre-temps ; une seule collecte, une variable, deux usages.

Variante plus difficile — Affiche aussi la taille de chaque fichier en octets (path.stat().st_size), et trie par taille décroissante (sorted(..., key=..., reverse=True) — la fonction key mérite dix minutes d'exploration, elle resservira).


Exercice moyen b — la panne du dossier de lancement

Raisonnement — Reproduire d'abord (comprendre la panne AVANT le remède) : lancé depuis la racine, open("config.txt") cherche dans la racine → FileNotFoundError. Le remède : ancrer le chemin sur l'emplacement du script, qui lui ne dépend pas du lancement.

Solution

# ex-05-2-e.py — version corrigée
# Panne reproduite : lancé depuis la racine du repo,
#   FileNotFoundError: [Errno 2] No such file or directory: 'config.txt'
# Explication : "config.txt" est résolu depuis le DOSSIER COURANT du
# terminal (la racine), pas depuis le dossier du script (mes-reponses/).
from pathlib import Path

BASE_DIR = Path(__file__).parent          # folder of THIS script, always
config_file = BASE_DIR / "config.txt"

with open(config_file, encoding="utf-8") as f:
    print(f.read())

Testé depuis mes-reponses/ ET depuis la racine : même affichage.

Pourquoi ça marche__file__ contient le chemin du script en cours d'exécution ; son parent est donc le dossier du script, quel que soit le dossier courant. Le chemin assemblé est absolu de fait : plus rien ne dépend du lancement.

Erreur classique sur cet exercice — « Corriger » en écrivant le chemin absolu en dur (C:/Users/jtron/Claude/learnpython/lessons/...) : ça marche sur TA machine, aujourd'hui — le script casse au premier déplacement de dossier ou sur un autre PC. L'ancrage __file__ est portable, le chemin en dur non.

Variante plus difficile — Mets config.txt dans un sous-dossier config/ et adapte. Puis question de design : cite un cas où l'on VEUT au contraire le dossier courant (indice : pense au script de rangement — le dossier à ranger est donné par l'utilisateur, pas fixé par le script).


Exercice moyen c — familles.py

Raisonnement — Le trio de la leçon (iterdir + is_file + suffix.lower()) alimente le motif de comptage du niveau 03 (dict.get(clé, 0) + 1). Le cas sans extension est annoncé : suffix vaut alors "", qui est une clé de dict comme une autre — c'est l'affichage qui la traduit.

Solution

# familles.py
from pathlib import Path

counts = {}
for path in Path.cwd().iterdir():
    if path.is_file():                       # skip folders
        ext = path.suffix.lower()            # '.TXT' == '.txt', '' if none
        counts[ext] = counts.get(ext, 0) + 1

for ext in sorted(counts):
    label = ext if ext != "" else "(sans extension)"
    print(f"{label} : {counts[ext]}")

Sortie (exemple) :

(sans extension) : 1
.py : 6
.txt : 4

Pourquoi ça marcheiterdir() livre tout le contenu, is_file() écarte les dossiers (qui n'ont pas d'« extension » au sens utile), suffix.lower() fusionne .TXT et .txt, et get(ext, 0) initialise chaque famille à son premier membre. sorted(counts) parcourt les clés en ordre stable ("" passe en tête : plus petit que toute chaîne non vide).

Erreur classique sur cet exercice — Oublier is_file() : les sous-dossiers entrent au comptage avec la clé "", gonflant « sans extension » — résultat faux SANS erreur, le pire genre. Ou le réflexe counts[ext] += 1 sans initialisation → KeyError au premier fichier de chaque famille (⏪ niveau 03, le remède get était le piège).

Variante plus difficile — Version récursive avec glob("**/*") (tout, sous-dossiers compris) : quelles précautions le is_file() prend-il encore plus d'importance ? Puis affiche les familles triées par NOMBRE décroissant (encore key= — décidément).


Exercice difficile a — unique_destination(path)

Raisonnement — Une fonction pure pathlib : si la cible est libre, la rendre telle quelle ; sinon générer les candidats stem_1, stem_2, … et rendre le premier libre. while + exists() : on ne connaît pas d'avance le nombre d'essais. Toute la difficulté est de reconstruire un nom : morceaux parent / stem / suffix, réassemblés en f-string.

Solution

# ex-05-2-g.py
from pathlib import Path

def unique_destination(path):
    """Return path if free, else the first free path_1, path_2, ..."""
    if not path.exists():
        return path
    n = 1
    while True:
        candidate = path.parent / f"{path.stem}_{n}{path.suffix}"
        if not candidate.exists():
            return candidate
        n += 1

if __name__ == "__main__":          # manual checks (⏩ expliqué en 05-4)
    target = Path("rapport.txt")
    print(unique_destination(target))    # rapport.txt   (libre)
    target.touch()                       # cree le fichier "genant"
    print(unique_destination(target))    # rapport_1.txt
    Path("rapport_1.txt").touch()
    print(unique_destination(target))    # rapport_2.txt

Sortie (vérifiée) :

rapport.txt
rapport_1.txt
rapport_2.txt

Pourquoi ça marchestem + suffix reconstituent exactement name ("rapport" + ".txt"), donc f"{path.stem}_{n}{path.suffix}" insère le _n au bon endroit, extension préservée. Le return dans le while True joue le rôle du break : on sort dès le premier candidat libre. La fonction ne CRÉE rien — elle propose un nom ; c'est l'appelant (le futur script de rangement) qui écrira.

Erreur classique sur cet exercice — Construire le candidat en chaîne : f"{path}_{n}"rapport.txt_1 (le _1 APRÈS l'extension) — c'est précisément pour ça qu'il fallait stem/suffix. Autre : tester path.exists() une fois puis générer _1 sans vérifier — si rapport_1.txt existe déjà (script relancé !), on écrase. La boucle doit tester CHAQUE candidat.

Variante plus difficile — Que se passe-t-il avec un fichier SANS extension (Path("notes")) ? Et avec backup.tar.gz (double extension — revoir la variante de l'exercice a) ? Décide du comportement voulu, ajuste, et écris tes cas de test dans le bloc __main__.


Mini-projet — « rangement de fichiers, version simulation »

Raisonnement — La fiche 10-script-rangement-fichiers.md détaille tout ; voici le squelette attendu à l'issue de la version simulation (étapes 1, 3, 4), pour comparaison. Points de design : le mapping est un dict CONSTANT en haut du fichier ; la classification est une fonction pure ; on collecte list(iterdir()) avant d'agir ; et rien — RIEN — n'est déplacé.

Solution (squelette de référence)

# organize.py — version SIMULATION seule (etapes 1, 3, 4 de la fiche)
from pathlib import Path

CATEGORIES = {
    ".jpg": "images", ".jpeg": "images", ".png": "images", ".gif": "images",
    ".pdf": "documents", ".docx": "documents", ".txt": "documents", ".md": "documents",
    ".zip": "archives", ".rar": "archives", ".7z": "archives",
}

def get_category(path):
    """Return the folder name for this file, 'autres' if unknown."""
    return CATEGORIES.get(path.suffix.lower(), "autres")

def main():
    folder = Path("test_folder")            # sys.argv : dans la fiche, etape 2
    files = []
    for p in folder.iterdir():              # collect FIRST, act afterwards
        if p.is_file():
            files.append(p)
    for path in files:
        category = get_category(path)
        print(f"[SIMULATION] {path.name:<20} -> {category}/")
    print(f"{len(files)} fichier(s) a deplacer. "
          "Version --apply : apres la lecon 05-3.")

if __name__ == "__main__":
    main()

Avec un test_folder/ de faux fichiers (Path("test_folder/photo.jpg").touch()…) :

[SIMULATION] photo.jpg            -> images/
[SIMULATION] rapport.pdf          -> documents/
[SIMULATION] notes.TXT            -> documents/
[SIMULATION] setup.exe            -> autres/
4 fichier(s) a deplacer. Version --apply : apres la lecon 05-3.

Pourquoi ça marcheCATEGORIES.get(..., "autres") règle en un appel le « tout le reste » du cahier des charges ; suffix.lower() attrape notes.TXT. La simulation est un vrai livrable : elle montre exactement ce que ferait --apply, sans risque — c'est la compétence centrale de la fiche.

Erreur classique sur cet exercice — Pointer directement un vrai dossier (Downloads) « juste pour voir » : même en simulation c'est une mauvaise habitude — le jour où tu actives --apply, le pli est pris. Dossier de test d'abord, toujours. Et les deux pièges du mapping : "jpg" sans point, .JPG sans lower() — tout finit dans autres/ sans erreur.

Variante plus difficile — C'est la suite de la fiche elle-même : sys.argv pour le dossier cible (étape 2), puis après 05-3 le --apply réel avec mkdir(exist_ok=True), rename() vers unique_destination(...) — ta fonction de l'exercice difficile, réutilisée telle quelle.