Script Python pour comparer des répertoires, en vérifiant les fichiers ajoutés/supprimés/modifiés

Le script Python suivant compare deux répertoires et signale les fichiers et répertoires ajoutés, supprimés ou modifiés (affichés en couleur pour les terminaux qui prennent en charge les codes d’échappement ANSI)

compare_directories.py
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2025 Uli Köhler <[email protected]>
# SPDX-License-Identifier: CC0-1.0
#
# Mainteneurs : Équipe de bibliothèque KiCad
"""Comparer deux répertoires et signaler les entrées ajoutées, supprimées ou modifiées."""

from __future__ import annotations

import argparse
import concurrent.futures
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Sequence, Set, Tuple


@dataclass
class DirectoryDifferences:
    added_files: List[str]
    deleted_files: List[str]
    changed_files: List[str]
    added_dirs: List[str]
    deleted_dirs: List[str]
    changed_dirs: List[str]
    unchanged_files: List[str]

    @property
    def has_differences(self) -> bool:
        return any([
            self.added_files,
            self.deleted_files,
            self.changed_files,
            self.added_dirs,
            self.deleted_dirs,
            self.changed_dirs,
        ])


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Comparer deux répertoires et signaler les entrées ajoutées, supprimées ou modifiées."
    )
    parser.add_argument("reference", type=Path, help="Répertoire original à partir duquel comparer")
    parser.add_argument("candidate", type=Path, help="Répertoire à comparer par rapport à la référence")
    parser.add_argument(
        "-v",
        "--verbose",
        action="store_true",
        help="Lister également les fichiers identiques dans les deux répertoires",
    )
    return parser.parse_args()


def ensure_directory(path: Path, role: str) -> None:
    if not path.exists():
        raise FileNotFoundError(f"Le répertoire {role} '{path}' n'existe pas")
    if not path.is_dir():
        raise NotADirectoryError(f"Le chemin {role} '{path}' n'est pas un répertoire")


def relative_entries(root: Path) -> Tuple[Dict[str, Path], Set[str]]:
    files: Dict[str, Path] = {}
    directories: Set[str] = set()
    for dirpath, dirnames, filenames in os.walk(root):
        current = Path(dirpath)
        rel_dir = current.relative_to(root)
        if rel_dir != Path('.'):
            directories.add(rel_dir.as_posix())
        for dirname in dirnames:
            rel = (current / dirname).relative_to(root).as_posix()
            directories.add(rel)
        for filename in filenames:
            abs_path = current / filename
            rel_path = abs_path.relative_to(root).as_posix()
            files[rel_path] = abs_path
    return files, directories


def sha256sum(path: Path) -> str:
    completed = subprocess.run(
        ["sha256sum", str(path)],
        check=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
    )
    return completed.stdout.split()[0]


def compute_checksums(root: Path, rel_paths: Sequence[str]) -> Dict[str, str]:
    results: Dict[str, str] = {}
    max_workers = min(16, os.cpu_count() or 1)

    def worker(rel_path: str) -> Tuple[str, str]:
        return rel_path, sha256sum(root / rel_path)

    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_map = {executor.submit(worker, rel): rel for rel in rel_paths}
        for future in concurrent.futures.as_completed(future_map):
            rel, digest = future.result()
            results[rel] = digest
    return results


def detect_differences(
    ref_files: Dict[str, Path],
    ref_dirs: Set[str],
    cand_files: Dict[str, Path],
    cand_dirs: Set[str],
    ref_root: Path,
    cand_root: Path,
) -> DirectoryDifferences:
    ref_file_set = set(ref_files)
    cand_file_set = set(cand_files)
    ref_dir_set = set(ref_dirs)
    cand_dir_set = set(cand_dirs)

    added_files = sorted(cand_file_set - ref_file_set)
    deleted_files = sorted(ref_file_set - cand_file_set)
    common_files = sorted(ref_file_set & cand_file_set)

    added_dirs = sorted(cand_dir_set - ref_dir_set)
    deleted_dirs = sorted(ref_dir_set - cand_dir_set)

    if common_files:
        ref_checksums = compute_checksums(ref_root, common_files)
        cand_checksums = compute_checksums(cand_root, common_files)
        changed_files = []
        unchanged_files = []
        for rel in common_files:
            if ref_checksums.get(rel) != cand_checksums.get(rel):
                changed_files.append(rel)
            else:
                unchanged_files.append(rel)
    else:
        changed_files = []
        unchanged_files = []

    changed_dirs = sorted({
        rel_parent
        for rel in changed_files + added_files + deleted_files
        for rel_parent in parent_paths(rel)
        if rel_parent
    } & (ref_dir_set & cand_dir_set))

    return DirectoryDifferences(
        added_files=added_files,
        deleted_files=deleted_files,
        changed_files=changed_files,
        added_dirs=added_dirs,
        deleted_dirs=deleted_dirs,
        changed_dirs=changed_dirs,
        unchanged_files=unchanged_files,
    )


def parent_paths(rel_path: str) -> List[str]:
    parts = Path(rel_path).parts
    parents: List[str] = []
    for idx in range(1, len(parts)):
        parents.append(Path(*parts[:idx]).as_posix())
    return parents


class Palette:
    def __init__(self, enabled: bool) -> None:
        self.green = "\033[32m" if enabled else ""
        self.yellow = "\033[33m" if enabled else ""
        self.red = "\033[31m" if enabled else ""
        self.reset = "\033[0m" if enabled else ""

    def wrap(self, text: str, color: str) -> str:
        return f"{color}{text}{self.reset}" if color else text


def supports_color() -> bool:
    return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None


def report(
    palette: Palette,
    summary: DirectoryDifferences,
    show_unchanged: bool,
) -> None:
    if not summary.has_differences:
        print("Aucune différence détectée.")
        if not show_unchanged or not summary.unchanged_files:
            return

    for rel in summary.added_dirs:
        print(palette.wrap(f"RÉP AJOUTÉ : {rel}/", palette.green))
    for rel in summary.added_files:
        print(palette.wrap(f"FICHIER AJOUTÉ : {rel}", palette.green))

    for rel in summary.deleted_dirs:
        print(palette.wrap(f"RÉP SUPPRIMÉ : {rel}/", palette.red))
    for rel in summary.deleted_files:
        print(palette.wrap(f"FICHIER SUPPRIMÉ : {rel}", palette.red))

    for rel in summary.changed_dirs:
        print(palette.wrap(f"RÉP MODIFIÉ : {rel}/", palette.yellow))
    for rel in summary.changed_files:
        print(palette.wrap(f"FICHIER MODIFIÉ : {rel}", palette.yellow))

    if show_unchanged and summary.unchanged_files:
        for rel in summary.unchanged_files:
            print(f"FICHIER INCHANGÉ : {rel}")


def main() -> int:
    args = parse_args()
    ref_root = args.reference.resolve()
    cand_root = args.candidate.resolve()

    try:
        ensure_directory(ref_root, "Référence")
        ensure_directory(cand_root, "Candidat")
    except (FileNotFoundError, NotADirectoryError) as exc:
        print(exc, file=sys.stderr)
        return 2

    ref_files, ref_dirs = relative_entries(ref_root)
    cand_files, cand_dirs = relative_entries(cand_root)

    try:
        summary = detect_differences(ref_files, ref_dirs, cand_files, cand_dirs, ref_root, cand_root)
    except FileNotFoundError:
        print("Exécutable sha256sum introuvable dans PATH", file=sys.stderr)
        return 2
    except subprocess.CalledProcessError as exc:
        target = exc.cmd[-1] if exc.cmd else "inconnu"
        detail = exc.stderr.strip() if isinstance(exc.stderr, str) else ""
        print(f"Échec de sha256sum pour '{target}' : {detail}", file=sys.stderr)
        return 2

    palette = Palette(supports_color())
    report(palette, summary, args.verbose)
    return -1 if summary.has_differences else 0


if __name__ == "__main__":
    sys.exit(main())

Check out similar posts by category: Python