pathlib à la place de os.path: gérer les chemins modernes

1. Aperçu général

De nombreux anciens codes Python gèrent les chemins avec os.path.join, os.path.splitext, os.listdir… Ces fonctions prennent et retournent des chaînes, rendant le code long, difficile à lire et sujet à erreurs. Le module pathlib (disponible depuis Python 3.4) représente les chemins par un objet Path disposant de toutes les méthodes pour concaténer, scinder, lire, écrire et parcourir les fichiers.

Après cet article, vous pourrez convertir la majeure partie de votre code os.path en pathlib et connaître les pièges courants. Les exemples utilisent Python 3.12.

2. Créer des objets Path

Path est le point de départ pour toute opération :

from pathlib import Path

reports = Path("data/reports")
home = Path.home()
cwd = Path.cwd()

print(reports)
print(type(reports))
print(home)
data/reports
<class 'pathlib.PosixPath'>
/home/minh

Explications :

  • Path("data/reports") crée un objet chemin. Aucune opération sur le disque n’est effectuée. Le répertoire n’a pas besoin d’exister.
  • Sur Linux/macOS, Path retourne un PosixPath. Sur Windows, il retourne un WindowsPath. Vous n’avez pas besoin de vous soucier de la classe spécifique.
  • Path.home() et Path.cwd() remplacent os.path.expanduser("~") et os.getcwd().

Si vous avez seulement besoin de manipuler des chaînes de chemin sans toucher aux fichiers réels, utilisez PurePath, PurePosixPath ou PureWindowsPath. C’est pratique pour traiter les chemins Windows lors de l’exécution sur Linux.

3. Concaténer les chemins avec l’opérateur /

C’est le changement le plus visible. Au lieu d’imbriquer os.path.join, vous utilisez l’opérateur / :

import os
from pathlib import Path

# Ancienne méthode
old = os.path.join("data", "orders", "2026", "order_1001.json")

# Nouvelle méthode
base = Path("data")
new = base / "orders" / "2026" / "order_1001.json"

print(old)
print(new)
print(new == Path(old))
data/orders/2026/order_1001.json
data/orders/2026/order_1001.json
True

L’opérateur / accepte à la fois Path et str à droite. Le résultat est toujours un Path, vous pouvez donc continuer à concaténer. Le séparateur change automatiquement selon le système d’exploitation : / sur Linux, \ sur Windows.

Si le nombre de composants change à l’exécution, utilisez joinpath :

parts = ["orders", "2026", "order_1001.json"]
path = Path("data").joinpath(*parts)
print(path)  # data/orders/2026/order_1001.json

4. Scinder les composants d’un chemin

os.path nécessite plusieurs fonctions pour cela : basename, dirname, splitext. Avec pathlib, tout est un attribut :

from pathlib import Path

invoice = Path("/srv/shop/invoices/2026/invoice_042.final.pdf")

print(invoice.name)      # nom complet du fichier
print(invoice.stem)      # nom sans la dernière extension
print(invoice.suffix)    # dernière extension
print(invoice.suffixes)  # toutes les extensions
print(invoice.parent)    # répertoire contenant
print(invoice.parents[1])
print(invoice.parts)
invoice_042.final.pdf
invoice_042.final
.pdf
['.final', '.pdf']
/srv/shop/invoices/2026
/srv/shop/invoices
('/', 'srv', 'shop', 'invoices', '2026', 'invoice_042.final.pdf')

Explications :

  • stem et suffix ne scindent qu’au dernier point. Pour les fichiers .tar.gz, utilisez suffixes.
  • parent équivaut à os.path.dirname. parents est une séquence de répertoires parents, remontant graduellement vers la racine.
  • parts divise le chemin en tuple, utile pour comparer niveau par niveau.

Pour créer un nouveau chemin avec un nom ou une extension différents, utilisez les méthodes with_* :

invoice = Path("invoices/invoice_042.pdf")

print(invoice.with_suffix(".xml"))    # invoices/invoice_042.xml
print(invoice.with_stem("invoice_043"))  # invoices/invoice_043.pdf
print(invoice.with_name("summary.csv"))  # invoices/summary.csv

Ces méthodes ne changent pas le nom du fichier sur le disque. Elles retournent simplement un nouveau Path, car Path est immuable.

5. Lire et écrire des fichiers

Pour les petits fichiers, vous n’avez pas besoin de open() :

from pathlib import Path

config = Path("app_config.txt")
config.write_text("currency=VND\ntimeout=30\n", encoding="utf-8")

content = config.read_text(encoding="utf-8")
print(content.splitlines())

logo = Path("logo.bin")
logo.write_bytes(b"\x89PNG")
print(logo.read_bytes())
['currency=VND', 'timeout=30']
b'\x89PNG'
  • write_text ouvre le fichier, écrit puis ferme. L’ancien fichier est écrasé.
  • read_text lit tout le contenu en chaîne.
  • write_bytes et read_bytes fonctionnent de la même manière avec des données binaires.

Pour les grands fichiers ou quand vous devez lire ligne par ligne, utilisez toujours un gestionnaire de contexte. Path a une méthode open intégrée :

with Path("orders.csv").open("w", encoding="utf-8") as f:
    f.write("id,amount\n1001,250000\n1002,99000\n")

with Path("orders.csv").open(encoding="utf-8") as f:
    total = sum(int(line.split(",")[1]) for line in f if not line.startswith("id"))

print(total)  # 349000

Attention : Toujours passer encoding="utf-8". Si vous l’omettez, Python utilise l’encodage par défaut du système. Sur Windows, le défaut est souvent cp1252, donc les données en vietnamien seront corrompues.

6. Vérifier et parcourir les répertoires

6.1. Vérifier l’état

from pathlib import Path

p = Path("orders.csv")
print(p.exists(), p.is_file(), p.is_dir())
print(p.stat().st_size)
print(p.resolve())
True True False
36
/home/minh/project/orders.csv

resolve() retourne le chemin absolu et résout les symlinks. Il remplace os.path.abspath et os.path.realpath.

6.2. Lister et trouver des fichiers

from pathlib import Path

root = Path("shop")
(root / "orders" / "2026").mkdir(parents=True, exist_ok=True)
(root / "orders" / "2026" / "o1.json").write_text("{}", encoding="utf-8")
(root / "orders" / "o0.json").write_text("{}", encoding="utf-8")
(root / "readme.md").write_text("# Shop", encoding="utf-8")

print(sorted(p.name for p in root.iterdir()))
print(sorted(str(p) for p in root.glob("*.md")))
print(sorted(str(p) for p in root.rglob("*.json")))
['orders', 'readme.md']
['shop/readme.md']
['shop/orders/2026/o1.json', 'shop/orders/o0.json']
  • iterdir() remplace os.listdir(). Il retourne les chemins Path complets, pas seulement les noms, vous n’avez donc pas besoin de les réassembler.
  • glob("*.md") cherche uniquement dans le répertoire actuel.
  • rglob("*.json") cherche de manière récursive, équivalent à glob("**/*.json").

Depuis Python 3.12, Path.walk() remplace os.walk() :

for dirpath, dirnames, filenames in Path("shop").walk():
    print(dirpath, filenames)

7. Créer, renommer et supprimer

import shutil
from pathlib import Path

backup = Path("backup/daily")
backup.mkdir(parents=True, exist_ok=True)

src = Path("orders.csv")
dst = src.rename(backup / "orders_2026-10-17.csv")
print(dst)

dst.unlink()
Path("not_there.txt").unlink(missing_ok=True)

backup.rmdir()
shutil.rmtree("backup")
backup/daily/orders_2026-10-17.csv
  • mkdir(parents=True, exist_ok=True) équivaut à os.makedirs(..., exist_ok=True).
  • rename retourne le nouveau Path. Sur Windows, rename génère une erreur si le fichier de destination existe. Pour écraser sur toutes les plateformes, utilisez replace.
  • unlink supprime un fichier. Avec missing_ok=True, il n’y a pas d’erreur si le fichier n’existe pas.
  • rmdir ne supprime que les répertoires vides. Pour supprimer une arborescence entière, vous avez toujours besoin de shutil.rmtree. Cette fonction accepte les Path normalement.

8. Tableau de comparaison rapide

os / os.path pathlib
os.path.join(a, b) Path(a) / b
os.path.basename(p) p.name
os.path.dirname(p) p.parent
os.path.splitext(p) p.stem, p.suffix
os.path.exists(p) p.exists()
os.path.isfile(p) / isdir p.is_file() / p.is_dir()
os.path.abspath(p) p.resolve() ou p.absolute()
os.path.expanduser("~") Path.home() ou p.expanduser()
os.getcwd() Path.cwd()
os.listdir(p) p.iterdir()
glob.glob("**/*.py", recursive=True) p.rglob("*.py")
os.makedirs(p, exist_ok=True) p.mkdir(parents=True, exist_ok=True)
os.remove(p) p.unlink()
os.rename(a, b) a.rename(b)
os.path.getsize(p) p.stat().st_size
os.path.relpath(p, start) p.relative_to(start)

9. Écrire des tests avec pytest

La fixture tmp_path de pytest retourne déjà un Path, donc tester du code utilisant pathlib est très concis. Supposons que vous ayez une fonction qui archive les fichiers PDF de factures dans un répertoire :

# archive.py
from pathlib import Path


def archive_invoices(source: Path, target: Path) -> list[Path]:
    target.mkdir(parents=True, exist_ok=True)
    moved = []
    for pdf in sorted(source.rglob("*.pdf")):
        moved.append(pdf.rename(target / pdf.name))
    return moved

Le test correspondant :

# test_archive.py
from pathlib import Path

from archive import archive_invoices


def test_archive_moves_only_pdf_files(tmp_path: Path):
    source = tmp_path / "inbox"
    (source / "2026").mkdir(parents=True)
    (source / "a.pdf").write_bytes(b"%PDF")
    (source / "2026" / "b.pdf").write_bytes(b"%PDF")
    (source / "note.txt").write_text("skip me", encoding="utf-8")
    target = tmp_path / "archive"

    moved = archive_invoices(source, target)

    assert [p.name for p in moved] == ["b.pdf", "a.pdf"]
    assert sorted(p.name for p in target.iterdir()) == ["a.pdf", "b.pdf"]
    assert (source / "note.txt").exists()
    assert not (source / "a.pdf").exists()
pytest -q test_archive.py
.                                                                [100%]
1 passed in 0.02s

L’ordre dans moved est b.pdf avant a.pdf. Raison : sorted compare les objets Path, et inbox/2026/b.pdf précède inbox/a.pdf car le caractère 2 est inférieur à a dans l’ordre de tri. Ce détail est facile à oublier quand vous passez de la comparaison de chaînes de noms de fichiers à la comparaison de Path.

10. Erreurs courantes

Additionner Path avec une chaîne. Path("data") + "/x.txt" génère TypeError. Utilisez / à la place de +. Si vous avez vraiment besoin de concaténer au nom du fichier, utilisez p.with_name(p.name + "_old").

Le côté droit est un chemin absolu. Le résultat peut vous surprendre :

from pathlib import Path

print(Path("/srv/uploads") / "/etc/passwd")  # /etc/passwd

Un composant absolu à droite remplace toute la partie gauche. Si le nom de fichier vient de l’utilisateur, vous devez vérifier avant utilisation :

base = Path("/srv/uploads").resolve()
candidate = (base / user_input).resolve()
if not candidate.is_relative_to(base):
    raise ValueError("Chemin invalide")

with_suffix manque le point. p.with_suffix("pdf") génère ValueError. Vous devez écrire ".pdf".

Oublier que glob retourne un générateur. Vous ne pouvez le parcourir qu’une fois. Si vous devez le réutiliser ou appeler len(), enveloppez-le dans list(...). L’ordre des résultats n’est pas garanti non plus, utilisez donc sorted quand l’ordre importe.

Fichiers cachés et stem. Avec Path(".env"), suffix est une chaîne vide et stem est ".env". N’assumez pas que tous les fichiers ont une extension.

Les vieilles bibliothèques n’acceptent que str. La plupart des API standard acceptent os.PathLike depuis Python 3.6. Si vous rencontrez une vieille bibliothèque, utilisez str(p) ou os.fspath(p).

Attention : pathlib ne remplace pas complètement os et shutil. Pour copier des fichiers, vous utilisez toujours shutil.copy2 (Python 3.14 ajoute seulement Path.copy). Les variables d’environnement sont toujours lues via os.environ. Les permissions complexes nécessitent toujours os.chmod ou os.access.

Quand ne pas changer ? Pour des boucles traitant des millions de chaînes de chemin sans toucher au disque, créer des objets Path est plus lent que manipuler des chaînes pures. C’est un cas rare, mesurez avant d’optimiser. Pour le code ancien qui fonctionne bien, vous n’avez pas besoin de tout refaire. Convertissez graduellement en touchant chaque module.

11. Conclusion

pathlib rassemble les opérations éparpillées dans os, os.path et glob en un seul objet. Le code est plus court, lit naturellement et a moins d’erreurs de concaténation. Vous avez vu comment concaténer les chemins avec /, scinder les composants avec name, stem, suffix, parent, lire et écrire avec read_text et write_text, parcourir les répertoires avec iterdir, glob, rglob, walk, et tester avec tmp_path de pytest.

Vous devez retenir trois points. Toujours passer encoding. Soyez prudent quand vous concaténez avec des chemins absolus provenant d’entrée externe. Continuez à utiliser shutil pour les opérations comme copier et supprimer des arborescences. Pour le code nouveau, utilisez Path par défaut et ne revenez à os.path que s’il y a une raison spécifique.

pythonpathlibos.pathfile-iopython3