Quand on a besoin de copier un répertoire, déplacer des fichiers ou créer une archive en Python, la tentation est de passer par subprocess.run(["cp", "-r", ...]) ou os.system("mv ..."). C’est fragile, non portable et inutile : shutil (shell utilities) est dans la bibliothèque standard Python depuis la version 2.3 et gère tout cela proprement. La bibliothèque Python shutil est l’outil de référence pour toutes les opérations de haut niveau sur le système de fichiers.
Pourquoi shutil plutôt qu’os ou subprocess
os expose les appels système de bas niveau : renommer, lier, créer des répertoires. Il ne copie pas le contenu des fichiers. os.rename() échoue si la source et la destination sont sur des systèmes de fichiers différents (partitions distinctes, volumes Docker montés, etc.). subprocess avec cp ou mv ne fonctionne pas sous Windows.
shutil opère à un niveau au-dessus : il gère la lecture et l’écriture du contenu, les permissions, les métadonnées, et les cas de bord comme les déplacements inter-systèmes de fichiers. Le code reste portable sans aucun changement entre Linux, macOS et Windows.
copy() : copier un fichier
import shutil
shutil.copy("rapport.pdf", "backup/rapport.pdf")
copy() copie le contenu du fichier et les permissions Unix. Il ne copie pas les métadonnées (dates de création/modification). Pour copier également les métadonnées, utiliser copy2() :
shutil.copy2("rapport.pdf", "backup/rapport.pdf")
La destination peut être un répertoire existant : le fichier y sera créé avec le même nom.
shutil.copy2("rapport.pdf", "backup/") # → backup/rapport.pdf
move() : déplacer un fichier ou un répertoire
shutil.move("anciens_logs/", "archive/logs_2025/")
move() utilise os.rename() si la source et la destination sont sur le même système de fichiers (opération atomique et instantanée). Si ce n’est pas le cas, il copie puis supprime la source. C’est ce comportement qui le rend fiable là où os.rename() lève une OSError.
# Déplacer un fichier d'un volume à l'autre : aucun problème
shutil.move("/tmp/export.csv", "/mnt/nas/exports/export.csv")
Attention au comportement quand la destination est un répertoire existant : move() place la source dans ce répertoire, il ne le remplace pas.
# Si archive/logs_2025/ existe déjà :
shutil.move("anciens_logs/", "archive/logs_2025/")
# Résultat : archive/logs_2025/anciens_logs/ ← placé à l'intérieur
# Si archive/logs_2025/ n'existe pas :
shutil.move("anciens_logs/", "archive/logs_2025/")
# Résultat : archive/logs_2025/ ← renommé
copytree() : copier une arborescence complète
shutil.copytree("src/", "dist/")
copytree() recrée récursivement toute la structure de répertoires. Depuis Python 3.8, le répertoire de destination peut déjà exister en passant dirs_exist_ok=True :
shutil.copytree("config/", "deploy/config/", dirs_exist_ok=True)
On peut filtrer les fichiers à copier avec le paramètre ignore :
shutil.copytree(
"projet/",
"deploy/projet/",
ignore=shutil.ignore_patterns("*.pyc", "__pycache__", ".git"),
)
shutil.ignore_patterns() retourne une fonction de filtrage que copytree() appellera à chaque niveau de l’arborescence.
rmtree() : supprimer un répertoire et son contenu
shutil.rmtree("dist/")
os.rmdir() ne supprime que les répertoires vides. shutil.rmtree() supprime récursivement tout le contenu. C’est l’équivalent de rm -rf, donc à utiliser avec le même niveau de précaution.
Pour continuer en cas d’erreur de permission plutôt que de lever une exception :
import os
import stat
def forcer_suppression(func, path, exc_info):
os.chmod(path, stat.S_IWRITE)
func(path)
shutil.rmtree("dist/", onerror=forcer_suppression)
Depuis Python 3.12, onerror est déprécié au profit de onexc, dont le callback reçoit l’exception directement plutôt que exc_info :
def forcer_suppression_312(func, path, exc):
os.chmod(path, stat.S_IWRITE)
func(path)
shutil.rmtree("dist/", onexc=forcer_suppression_312)
Ce pattern est utile sous Windows, où les fichiers en lecture seule bloquent la suppression.
make_archive() : créer une archive
shutil.make_archive("backup_2025", "zip", root_dir="exports/")
Arguments : nom de l’archive (sans extension), format, répertoire source. Les formats supportés nativement : zip, tar, gztar (.tar.gz), bztar (.tar.bz2), xztar (.tar.xz).
# Crée backup_2025.tar.gz contenant tout ce qui est dans exports/
shutil.make_archive("backup_2025", "gztar", root_dir="exports/")
Pour connaître les formats disponibles sur le système :
print(shutil.get_archive_formats())
# [('bztar', ...), ('gztar', ...), ('tar', ...), ('xztar', ...), ('zip', ...)]
Exemple concret : script de déploiement
Un script qui construit un artefact de déploiement à partir d’un projet Python :
import shutil
from pathlib import Path
BUILD_DIR = Path("dist")
SOURCE_DIR = Path("src")
CONFIG_DIR = Path("config/production")
def build():
if BUILD_DIR.exists():
shutil.rmtree(BUILD_DIR)
shutil.copytree(
SOURCE_DIR,
BUILD_DIR / "app",
ignore=shutil.ignore_patterns("*.pyc", "__pycache__", "tests"),
)
shutil.copytree(CONFIG_DIR, BUILD_DIR / "config", dirs_exist_ok=True)
archive = shutil.make_archive(
base_name=str(BUILD_DIR / "release"),
format="gztar",
root_dir=BUILD_DIR,
)
print(f"Archive créée : {archive}")
if __name__ == "__main__":
build()
Ce script est lisible, portable, et ne dépend d’aucune dépendance externe.
Récapitulatif
| Fonction | Usage | Équivalent shell |
|---|---|---|
copy(src, dst) | Copie un fichier (contenu + permissions) | cp |
copy2(src, dst) | Copie un fichier (contenu + métadonnées) | cp -p |
move(src, dst) | Déplace fichier ou répertoire | mv |
copytree(src, dst) | Copie une arborescence complète | cp -r |
rmtree(path) | Supprime un répertoire et son contenu | rm -rf |
make_archive(name, fmt, root) | Crée une archive | zip -r / tar czf |
shutil couvre la grande majorité des besoins en manipulation de fichiers dans un script Python. Avant d’appeler subprocess pour une commande shell, vérifier si shutil n’a pas déjà la fonction qu’il faut.
