reconciliation
4db3efa parent: e849fcb added
packages/regine-cli/src/regine_cli/archive_cmd.py +147 -0 | new file mode 100644 | ||
| @@ -0,0 +1,147 @@ | ||
| 1 | +"""Commandes `regine checkout` / `regine reconcile` (cf. contracts/cli-checkout-reconcile.md). | |
| 2 | + | |
| 3 | +Façade fine : orchestre `regine_core.archive`, formatte le résultat, ne contient | |
| 4 | +aucune logique métier propre (Principe VI de la constitution). | |
| 5 | +""" | |
| 6 | + | |
| 7 | +from __future__ import annotations | |
| 8 | + | |
| 9 | +import argparse | |
| 10 | +import sys | |
| 11 | +from pathlib import Path | |
| 12 | + | |
| 13 | +from regine_core.archive.checkout import checkout as checkout_dossier | |
| 14 | +from regine_core.archive.manifest import ouvrir_ou_creer | |
| 15 | +from regine_core.archive.reconciliation import DecisionsUtilisateur, archiver, comparer | |
| 16 | +from regine_core.archive.verrou import DossierDejaVerrouilleError, lever | |
| 17 | + | |
| 18 | + | |
| 19 | +def _cmd_checkout(args: argparse.Namespace) -> int: | |
| 20 | + dossier_archive = Path(args.dossier) | |
| 21 | + dest_locale = Path(args.local_dest) if args.local_dest else Path.cwd() / dossier_archive.name | |
| 22 | + formats = args.formats.split(",") if args.formats else None | |
| 23 | + | |
| 24 | + try: | |
| 25 | + snapshot = checkout_dossier(dossier_archive, dest_locale, formats=formats) | |
| 26 | + except DossierDejaVerrouilleError as exc: | |
| 27 | + print(f"Erreur : {exc}", file=sys.stderr) | |
| 28 | + return 1 | |
| 29 | + | |
| 30 | + print(f"{len(snapshot.fichiers)} fichier(s) sortis vers {dest_locale}") | |
| 31 | + snapshot.manifest.conn.close() | |
| 32 | + return 0 | |
| 33 | + | |
| 34 | + | |
| 35 | +def _afficher_point_avant_archive(rapport) -> None: | |
| 36 | + print("Point avant archive :") | |
| 37 | + for changement in rapport.changements: | |
| 38 | + if changement.categorie == "deplacement": | |
| 39 | + print(f" [déplacement] {changement.chemin_ancien} -> {changement.chemin}") | |
| 40 | + else: | |
| 41 | + print(f" [{changement.categorie}] {changement.chemin}") | |
| 42 | + if not rapport.changements: | |
| 43 | + print(" (aucun changement)") | |
| 44 | + | |
| 45 | + | |
| 46 | +def _cmd_reconcile(args: argparse.Namespace) -> int: | |
| 47 | + dossier_archive = Path(args.dossier) | |
| 48 | + dossier_local = Path(args.local_dest) if args.local_dest else Path.cwd() / dossier_archive.name | |
| 49 | + | |
| 50 | + manifest = ouvrir_ou_creer(dossier_archive) | |
| 51 | + # Reconstruit un ManifestSnapshot à partir du manifeste déjà sur l'archive | |
| 52 | + # (le checkout d'origine peut avoir eu lieu dans une session précédente). | |
| 53 | + from regine_core.archive.checkout import ( # noqa: PLC0415 | |
| 54 | + FichierManifesteEntry, | |
| 55 | + ManifestSnapshot, | |
| 56 | + ) | |
| 57 | + | |
| 58 | + lignes = manifest.conn.execute( | |
| 59 | + "SELECT chemin_relatif, taille, hash_fichier_entier, hash_image_only, identifiant_perenne " | |
| 60 | + "FROM fichiers" | |
| 61 | + ).fetchall() | |
| 62 | + fichiers = { | |
| 63 | + ligne[0]: FichierManifesteEntry( | |
| 64 | + chemin_relatif=ligne[0], | |
| 65 | + taille=ligne[1], | |
| 66 | + hash_fichier_entier=ligne[2], | |
| 67 | + hash_image_only=ligne[3], | |
| 68 | + identifiant_perenne=ligne[4], | |
| 69 | + ) | |
| 70 | + for ligne in lignes | |
| 71 | + } | |
| 72 | + snapshot = ManifestSnapshot( | |
| 73 | + dossier_archive=dossier_archive, | |
| 74 | + dossier_local=dossier_local, | |
| 75 | + fichiers=fichiers, | |
| 76 | + formats=None, | |
| 77 | + manifest=manifest, | |
| 78 | + ) | |
| 79 | + | |
| 80 | + rapport = comparer(snapshot, dossier_local) | |
| 81 | + _afficher_point_avant_archive(rapport) | |
| 82 | + | |
| 83 | + if not rapport.changements: | |
| 84 | + # Rien à réconcilier : la session se termine sans écriture, mais le verrou | |
| 85 | + # doit être levé (FR-016 s'applique aussi à une réconciliation "à vide"). | |
| 86 | + lever(manifest) | |
| 87 | + manifest.conn.close() | |
| 88 | + return 0 | |
| 89 | + | |
| 90 | + decisions = DecisionsUtilisateur() | |
| 91 | + | |
| 92 | + for anomalie in rapport.anomalies: | |
| 93 | + reponse = ( | |
| 94 | + input( | |
| 95 | + f"Anomalie sur {anomalie.chemin} : confirmer malgré tout (c) ou restaurer depuis " | |
| 96 | + f"l'archive (r) ? [c/r] " | |
| 97 | + ) | |
| 98 | + .strip() | |
| 99 | + .lower() | |
| 100 | + ) | |
| 101 | + decisions.resolutions_anomalies[str(anomalie.chemin)] = ( | |
| 102 | + "confirmer" if reponse == "c" else "restaurer" | |
| 103 | + ) | |
| 104 | + | |
| 105 | + for nouveau in (c for c in rapport.changements if c.categorie == "nouveau"): | |
| 106 | + question = f"Nouveau fichier {nouveau.chemin} : archiver (a) ou laisser local (l) ? [a/l] " | |
| 107 | + reponse = input(question) | |
| 108 | + if reponse.strip().lower() == "a": | |
| 109 | + decisions.nouveaux_fichiers_a_archiver.add(str(nouveau.chemin)) | |
| 110 | + | |
| 111 | + confirmation = input("Confirmer l'écriture des changements normaux sur l'archive ? [o/n] ") | |
| 112 | + decisions.confirmer_changements_normaux = confirmation.strip().lower() == "o" | |
| 113 | + | |
| 114 | + archiver(snapshot, rapport, decisions) | |
| 115 | + print("Réconciliation terminée.") | |
| 116 | + manifest.conn.close() | |
| 117 | + return 0 | |
| 118 | + | |
| 119 | + | |
| 120 | +def construire_analyseur() -> argparse.ArgumentParser: | |
| 121 | + analyseur = argparse.ArgumentParser(prog="regine") | |
| 122 | + sous_commandes = analyseur.add_subparsers(dest="commande", required=True) | |
| 123 | + | |
| 124 | + checkout_parser = sous_commandes.add_parser("checkout", help="Sort un dossier de l'archive") | |
| 125 | + checkout_parser.add_argument("dossier") | |
| 126 | + checkout_parser.add_argument("--formats", default=None) | |
| 127 | + checkout_parser.add_argument("--local-dest", default=None) | |
| 128 | + checkout_parser.set_defaults(func=_cmd_checkout) | |
| 129 | + | |
| 130 | + reconcile_parser = sous_commandes.add_parser( | |
| 131 | + "reconcile", help="Réconcilie un dossier checkouté" | |
| 132 | + ) | |
| 133 | + reconcile_parser.add_argument("dossier") | |
| 134 | + reconcile_parser.add_argument("--local-dest", default=None) | |
| 135 | + reconcile_parser.set_defaults(func=_cmd_reconcile) | |
| 136 | + | |
| 137 | + return analyseur | |
| 138 | + | |
| 139 | + | |
| 140 | +def main(argv: list[str] | None = None) -> int: | |
| 141 | + analyseur = construire_analyseur() | |
| 142 | + args = analyseur.parse_args(argv) | |
| 143 | + return args.func(args) | |
| 144 | + | |
| 145 | + | |
| 146 | +if __name__ == "__main__": | |
| 147 | + raise SystemExit(main()) | |
| new file mode 100644 | |||
| @@ -0,0 +1,147 @@ | |||
| 1 | +"""Commandes `regine checkout` / `regine reconcile` (cf. contracts/cli-checkout-reconcile.md). | ||
| 2 | + | ||
| 3 | +Façade fine : orchestre `regine_core.archive`, formatte le résultat, ne contient | ||
| 4 | +aucune logique métier propre (Principe VI de la constitution). | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +from __future__ import annotations | ||
| 8 | + | ||
| 9 | +import argparse | ||
| 10 | +import sys | ||
| 11 | +from pathlib import Path | ||
| 12 | + | ||
| 13 | +from regine_core.archive.checkout import checkout as checkout_dossier | ||
| 14 | +from regine_core.archive.manifest import ouvrir_ou_creer | ||
| 15 | +from regine_core.archive.reconciliation import DecisionsUtilisateur, archiver, comparer | ||
| 16 | +from regine_core.archive.verrou import DossierDejaVerrouilleError, lever | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def _cmd_checkout(args: argparse.Namespace) -> int: | ||
| 20 | + dossier_archive = Path(args.dossier) | ||
| 21 | + dest_locale = Path(args.local_dest) if args.local_dest else Path.cwd() / dossier_archive.name | ||
| 22 | + formats = args.formats.split(",") if args.formats else None | ||
| 23 | + | ||
| 24 | + try: | ||
| 25 | + snapshot = checkout_dossier(dossier_archive, dest_locale, formats=formats) | ||
| 26 | + except DossierDejaVerrouilleError as exc: | ||
| 27 | + print(f"Erreur : {exc}", file=sys.stderr) | ||
| 28 | + return 1 | ||
| 29 | + | ||
| 30 | + print(f"{len(snapshot.fichiers)} fichier(s) sortis vers {dest_locale}") | ||
| 31 | + snapshot.manifest.conn.close() | ||
| 32 | + return 0 | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +def _afficher_point_avant_archive(rapport) -> None: | ||
| 36 | + print("Point avant archive :") | ||
| 37 | + for changement in rapport.changements: | ||
| 38 | + if changement.categorie == "deplacement": | ||
| 39 | + print(f" [déplacement] {changement.chemin_ancien} -> {changement.chemin}") | ||
| 40 | + else: | ||
| 41 | + print(f" [{changement.categorie}] {changement.chemin}") | ||
| 42 | + if not rapport.changements: | ||
| 43 | + print(" (aucun changement)") | ||
| 44 | + | ||
| 45 | + | ||
| 46 | +def _cmd_reconcile(args: argparse.Namespace) -> int: | ||
| 47 | + dossier_archive = Path(args.dossier) | ||
| 48 | + dossier_local = Path(args.local_dest) if args.local_dest else Path.cwd() / dossier_archive.name | ||
| 49 | + | ||
| 50 | + manifest = ouvrir_ou_creer(dossier_archive) | ||
| 51 | + # Reconstruit un ManifestSnapshot à partir du manifeste déjà sur l'archive | ||
| 52 | + # (le checkout d'origine peut avoir eu lieu dans une session précédente). | ||
| 53 | + from regine_core.archive.checkout import ( # noqa: PLC0415 | ||
| 54 | + FichierManifesteEntry, | ||
| 55 | + ManifestSnapshot, | ||
| 56 | + ) | ||
| 57 | + | ||
| 58 | + lignes = manifest.conn.execute( | ||
| 59 | + "SELECT chemin_relatif, taille, hash_fichier_entier, hash_image_only, identifiant_perenne " | ||
| 60 | + "FROM fichiers" | ||
| 61 | + ).fetchall() | ||
| 62 | + fichiers = { | ||
| 63 | + ligne[0]: FichierManifesteEntry( | ||
| 64 | + chemin_relatif=ligne[0], | ||
| 65 | + taille=ligne[1], | ||
| 66 | + hash_fichier_entier=ligne[2], | ||
| 67 | + hash_image_only=ligne[3], | ||
| 68 | + identifiant_perenne=ligne[4], | ||
| 69 | + ) | ||
| 70 | + for ligne in lignes | ||
| 71 | + } | ||
| 72 | + snapshot = ManifestSnapshot( | ||
| 73 | + dossier_archive=dossier_archive, | ||
| 74 | + dossier_local=dossier_local, | ||
| 75 | + fichiers=fichiers, | ||
| 76 | + formats=None, | ||
| 77 | + manifest=manifest, | ||
| 78 | + ) | ||
| 79 | + | ||
| 80 | + rapport = comparer(snapshot, dossier_local) | ||
| 81 | + _afficher_point_avant_archive(rapport) | ||
| 82 | + | ||
| 83 | + if not rapport.changements: | ||
| 84 | + # Rien à réconcilier : la session se termine sans écriture, mais le verrou | ||
| 85 | + # doit être levé (FR-016 s'applique aussi à une réconciliation "à vide"). | ||
| 86 | + lever(manifest) | ||
| 87 | + manifest.conn.close() | ||
| 88 | + return 0 | ||
| 89 | + | ||
| 90 | + decisions = DecisionsUtilisateur() | ||
| 91 | + | ||
| 92 | + for anomalie in rapport.anomalies: | ||
| 93 | + reponse = ( | ||
| 94 | + input( | ||
| 95 | + f"Anomalie sur {anomalie.chemin} : confirmer malgré tout (c) ou restaurer depuis " | ||
| 96 | + f"l'archive (r) ? [c/r] " | ||
| 97 | + ) | ||
| 98 | + .strip() | ||
| 99 | + .lower() | ||
| 100 | + ) | ||
| 101 | + decisions.resolutions_anomalies[str(anomalie.chemin)] = ( | ||
| 102 | + "confirmer" if reponse == "c" else "restaurer" | ||
| 103 | + ) | ||
| 104 | + | ||
| 105 | + for nouveau in (c for c in rapport.changements if c.categorie == "nouveau"): | ||
| 106 | + question = f"Nouveau fichier {nouveau.chemin} : archiver (a) ou laisser local (l) ? [a/l] " | ||
| 107 | + reponse = input(question) | ||
| 108 | + if reponse.strip().lower() == "a": | ||
| 109 | + decisions.nouveaux_fichiers_a_archiver.add(str(nouveau.chemin)) | ||
| 110 | + | ||
| 111 | + confirmation = input("Confirmer l'écriture des changements normaux sur l'archive ? [o/n] ") | ||
| 112 | + decisions.confirmer_changements_normaux = confirmation.strip().lower() == "o" | ||
| 113 | + | ||
| 114 | + archiver(snapshot, rapport, decisions) | ||
| 115 | + print("Réconciliation terminée.") | ||
| 116 | + manifest.conn.close() | ||
| 117 | + return 0 | ||
| 118 | + | ||
| 119 | + | ||
| 120 | +def construire_analyseur() -> argparse.ArgumentParser: | ||
| 121 | + analyseur = argparse.ArgumentParser(prog="regine") | ||
| 122 | + sous_commandes = analyseur.add_subparsers(dest="commande", required=True) | ||
| 123 | + | ||
| 124 | + checkout_parser = sous_commandes.add_parser("checkout", help="Sort un dossier de l'archive") | ||
| 125 | + checkout_parser.add_argument("dossier") | ||
| 126 | + checkout_parser.add_argument("--formats", default=None) | ||
| 127 | + checkout_parser.add_argument("--local-dest", default=None) | ||
| 128 | + checkout_parser.set_defaults(func=_cmd_checkout) | ||
| 129 | + | ||
| 130 | + reconcile_parser = sous_commandes.add_parser( | ||
| 131 | + "reconcile", help="Réconcilie un dossier checkouté" | ||
| 132 | + ) | ||
| 133 | + reconcile_parser.add_argument("dossier") | ||
| 134 | + reconcile_parser.add_argument("--local-dest", default=None) | ||
| 135 | + reconcile_parser.set_defaults(func=_cmd_reconcile) | ||
| 136 | + | ||
| 137 | + return analyseur | ||
| 138 | + | ||
| 139 | + | ||
| 140 | +def main(argv: list[str] | None = None) -> int: | ||
| 141 | + analyseur = construire_analyseur() | ||
| 142 | + args = analyseur.parse_args(argv) | ||
| 143 | + return args.func(args) | ||
| 144 | + | ||
| 145 | + | ||
| 146 | +if __name__ == "__main__": | ||
| 147 | + raise SystemExit(main()) | ||
added
packages/regine-core/src/regine_core/archive/__init__.py +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +"""Manifeste persistant, verrouillage, checkout et réconciliation d'un dossier de l'archive. | |
| 2 | + | |
| 3 | +Cf. specs/005-checkout-reconciliation. Dernier module de la bibliothèque centrale nommé | |
| 4 | +dans docs/interface-cli-gui-architecture.md. | |
| 5 | +""" | |
| 6 | + | |
| 7 | +from regine_core.archive.manifest import ( | |
| 8 | + ManifestHandle, | |
| 9 | + VersionStructurelleNonSupporteeError, | |
| 10 | + ouvrir_ou_creer, | |
| 11 | +) | |
| 12 | +from regine_core.archive.verrou import DossierDejaVerrouilleError | |
| 13 | + | |
| 14 | +__all__ = [ | |
| 15 | + "DossierDejaVerrouilleError", | |
| 16 | + "ManifestHandle", | |
| 17 | + "VersionStructurelleNonSupporteeError", | |
| 18 | + "ouvrir_ou_creer", | |
| 19 | +] | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +"""Manifeste persistant, verrouillage, checkout et réconciliation d'un dossier de l'archive. | ||
| 2 | + | ||
| 3 | +Cf. specs/005-checkout-reconciliation. Dernier module de la bibliothèque centrale nommé | ||
| 4 | +dans docs/interface-cli-gui-architecture.md. | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +from regine_core.archive.manifest import ( | ||
| 8 | + ManifestHandle, | ||
| 9 | + VersionStructurelleNonSupporteeError, | ||
| 10 | + ouvrir_ou_creer, | ||
| 11 | +) | ||
| 12 | +from regine_core.archive.verrou import DossierDejaVerrouilleError | ||
| 13 | + | ||
| 14 | +__all__ = [ | ||
| 15 | + "DossierDejaVerrouilleError", | ||
| 16 | + "ManifestHandle", | ||
| 17 | + "VersionStructurelleNonSupporteeError", | ||
| 18 | + "ouvrir_ou_creer", | ||
| 19 | +] | ||
added
packages/regine-core/src/regine_core/archive/checkout.py +134 -0 | new file mode 100644 | ||
| @@ -0,0 +1,134 @@ | ||
| 1 | +"""Checkout d'un dossier de l'archive vers un espace de travail local. | |
| 2 | + | |
| 3 | +Cf. FR-001 à FR-005, FR-015, FR-019, FR-020 de specs/005-checkout-reconciliation. | |
| 4 | +""" | |
| 5 | + | |
| 6 | +from __future__ import annotations | |
| 7 | + | |
| 8 | +import shutil | |
| 9 | +from dataclasses import dataclass, field | |
| 10 | +from pathlib import Path | |
| 11 | + | |
| 12 | +from regine_core.archive import manifest as manifest_module | |
| 13 | +from regine_core.archive import verrou | |
| 14 | +from regine_core.archive.manifest import ManifestHandle | |
| 15 | +from regine_core.integrity.hash import empreinte | |
| 16 | + | |
| 17 | + | |
| 18 | +class IntegriteCopieError(Exception): | |
| 19 | + """La vérification par empreinte d'une copie a échoué (FR-015).""" | |
| 20 | + | |
| 21 | + | |
| 22 | +@dataclass(frozen=True) | |
| 23 | +class FichierManifesteEntry: | |
| 24 | + """Une ligne de la table `fichiers` du manifeste (cf. data-model.md).""" | |
| 25 | + | |
| 26 | + chemin_relatif: str | |
| 27 | + taille: int | |
| 28 | + hash_fichier_entier: str | |
| 29 | + hash_image_only: str | None | |
| 30 | + identifiant_perenne: str | None = None | |
| 31 | + | |
| 32 | + | |
| 33 | +@dataclass | |
| 34 | +class ManifestSnapshot: | |
| 35 | + """État de référence pris au checkout (FR-002), plus le contexte du checkout lui-même.""" | |
| 36 | + | |
| 37 | + dossier_archive: Path | |
| 38 | + dossier_local: Path | |
| 39 | + fichiers: dict[str, FichierManifesteEntry] = field(default_factory=dict) | |
| 40 | + formats: list[str] | None = None | |
| 41 | + manifest: ManifestHandle | None = None | |
| 42 | + | |
| 43 | + | |
| 44 | +def dans_perimetre(chemin_relatif: str, formats: list[str] | None) -> bool: | |
| 45 | + """Un fichier est dans le périmètre d'un checkout (complet ou partiel par format). | |
| 46 | + | |
| 47 | + La racine de sélection (fichiers directement sous le dossier principal, pas dans | |
| 48 | + un sous-répertoire de format) est toujours incluse (FR-019). | |
| 49 | + """ | |
| 50 | + if formats is None: | |
| 51 | + return True | |
| 52 | + parties = Path(chemin_relatif).parts | |
| 53 | + if len(parties) <= 1: | |
| 54 | + return True | |
| 55 | + return parties[0] in formats | |
| 56 | + | |
| 57 | + | |
| 58 | +def _lister_fichiers_source(dossier_archive: Path, formats: list[str] | None) -> list[Path]: | |
| 59 | + resultat = [] | |
| 60 | + for chemin in dossier_archive.rglob("*"): | |
| 61 | + if not chemin.is_file(): | |
| 62 | + continue | |
| 63 | + if chemin.name == manifest_module.NOM_FICHIER_MANIFESTE: | |
| 64 | + continue | |
| 65 | + relatif = str(chemin.relative_to(dossier_archive)) | |
| 66 | + if dans_perimetre(relatif, formats): | |
| 67 | + resultat.append(chemin) | |
| 68 | + return resultat | |
| 69 | + | |
| 70 | + | |
| 71 | +def _copier_verifie(source: Path, destination: Path) -> None: | |
| 72 | + destination.parent.mkdir(parents=True, exist_ok=True) | |
| 73 | + hash_source = empreinte(source).hash_fichier_entier | |
| 74 | + shutil.copy2(source, destination) | |
| 75 | + hash_destination = empreinte(destination).hash_fichier_entier | |
| 76 | + if hash_source != hash_destination: | |
| 77 | + raise IntegriteCopieError(f"Échec de vérification d'intégrité pour {destination}") | |
| 78 | + | |
| 79 | + | |
| 80 | +def checkout( | |
| 81 | + dossier_archive: Path, | |
| 82 | + dest_locale: Path, | |
| 83 | + formats: list[str] | None = None, | |
| 84 | +) -> ManifestSnapshot: | |
| 85 | + """Sort (checkout) un dossier de l'archive vers un espace de travail local. | |
| 86 | + | |
| 87 | + Copie l'intégralité du dossier (parent + tous ses sous-dossiers, FR-001), ou un | |
| 88 | + sous-ensemble de formats plus la racine de sélection (FR-019, `formats` non | |
| 89 | + `None`). Enregistre un état de référence complet (FR-002) et verrouille le | |
| 90 | + dossier (FR-003) avant de commencer. Lève `regine_core.archive.verrou. | |
| 91 | + DossierDejaVerrouilleError` si le dossier est déjà verrouillé (FR-004). | |
| 92 | + """ | |
| 93 | + manifest = manifest_module.ouvrir_ou_creer(dossier_archive) | |
| 94 | + verrou.poser(manifest) | |
| 95 | + | |
| 96 | + dest_locale.mkdir(parents=True, exist_ok=True) | |
| 97 | + fichiers_source = _lister_fichiers_source(dossier_archive, formats) | |
| 98 | + | |
| 99 | + entrees: dict[str, FichierManifesteEntry] = {} | |
| 100 | + for source in fichiers_source: | |
| 101 | + chemin_relatif = str(source.relative_to(dossier_archive)) | |
| 102 | + destination = dest_locale / chemin_relatif | |
| 103 | + | |
| 104 | + _copier_verifie(source, destination) | |
| 105 | + | |
| 106 | + emp = empreinte(source) | |
| 107 | + entree = FichierManifesteEntry( | |
| 108 | + chemin_relatif=chemin_relatif, | |
| 109 | + taille=source.stat().st_size, | |
| 110 | + hash_fichier_entier=emp.hash_fichier_entier, | |
| 111 | + hash_image_only=emp.hash_image_only, | |
| 112 | + ) | |
| 113 | + entrees[chemin_relatif] = entree | |
| 114 | + manifest.conn.execute( | |
| 115 | + "INSERT OR REPLACE INTO fichiers " | |
| 116 | + "(chemin_relatif, taille, hash_fichier_entier, hash_image_only, identifiant_perenne) " | |
| 117 | + "VALUES (?, ?, ?, ?, ?)", | |
| 118 | + ( | |
| 119 | + entree.chemin_relatif, | |
| 120 | + entree.taille, | |
| 121 | + entree.hash_fichier_entier, | |
| 122 | + entree.hash_image_only, | |
| 123 | + entree.identifiant_perenne, | |
| 124 | + ), | |
| 125 | + ) | |
| 126 | + manifest.conn.commit() | |
| 127 | + | |
| 128 | + return ManifestSnapshot( | |
| 129 | + dossier_archive=dossier_archive, | |
| 130 | + dossier_local=dest_locale, | |
| 131 | + fichiers=entrees, | |
| 132 | + formats=formats, | |
| 133 | + manifest=manifest, | |
| 134 | + ) | |
| new file mode 100644 | |||
| @@ -0,0 +1,134 @@ | |||
| 1 | +"""Checkout d'un dossier de l'archive vers un espace de travail local. | ||
| 2 | + | ||
| 3 | +Cf. FR-001 à FR-005, FR-015, FR-019, FR-020 de specs/005-checkout-reconciliation. | ||
| 4 | +""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +import shutil | ||
| 9 | +from dataclasses import dataclass, field | ||
| 10 | +from pathlib import Path | ||
| 11 | + | ||
| 12 | +from regine_core.archive import manifest as manifest_module | ||
| 13 | +from regine_core.archive import verrou | ||
| 14 | +from regine_core.archive.manifest import ManifestHandle | ||
| 15 | +from regine_core.integrity.hash import empreinte | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +class IntegriteCopieError(Exception): | ||
| 19 | + """La vérification par empreinte d'une copie a échoué (FR-015).""" | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +@dataclass(frozen=True) | ||
| 23 | +class FichierManifesteEntry: | ||
| 24 | + """Une ligne de la table `fichiers` du manifeste (cf. data-model.md).""" | ||
| 25 | + | ||
| 26 | + chemin_relatif: str | ||
| 27 | + taille: int | ||
| 28 | + hash_fichier_entier: str | ||
| 29 | + hash_image_only: str | None | ||
| 30 | + identifiant_perenne: str | None = None | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +@dataclass | ||
| 34 | +class ManifestSnapshot: | ||
| 35 | + """État de référence pris au checkout (FR-002), plus le contexte du checkout lui-même.""" | ||
| 36 | + | ||
| 37 | + dossier_archive: Path | ||
| 38 | + dossier_local: Path | ||
| 39 | + fichiers: dict[str, FichierManifesteEntry] = field(default_factory=dict) | ||
| 40 | + formats: list[str] | None = None | ||
| 41 | + manifest: ManifestHandle | None = None | ||
| 42 | + | ||
| 43 | + | ||
| 44 | +def dans_perimetre(chemin_relatif: str, formats: list[str] | None) -> bool: | ||
| 45 | + """Un fichier est dans le périmètre d'un checkout (complet ou partiel par format). | ||
| 46 | + | ||
| 47 | + La racine de sélection (fichiers directement sous le dossier principal, pas dans | ||
| 48 | + un sous-répertoire de format) est toujours incluse (FR-019). | ||
| 49 | + """ | ||
| 50 | + if formats is None: | ||
| 51 | + return True | ||
| 52 | + parties = Path(chemin_relatif).parts | ||
| 53 | + if len(parties) <= 1: | ||
| 54 | + return True | ||
| 55 | + return parties[0] in formats | ||
| 56 | + | ||
| 57 | + | ||
| 58 | +def _lister_fichiers_source(dossier_archive: Path, formats: list[str] | None) -> list[Path]: | ||
| 59 | + resultat = [] | ||
| 60 | + for chemin in dossier_archive.rglob("*"): | ||
| 61 | + if not chemin.is_file(): | ||
| 62 | + continue | ||
| 63 | + if chemin.name == manifest_module.NOM_FICHIER_MANIFESTE: | ||
| 64 | + continue | ||
| 65 | + relatif = str(chemin.relative_to(dossier_archive)) | ||
| 66 | + if dans_perimetre(relatif, formats): | ||
| 67 | + resultat.append(chemin) | ||
| 68 | + return resultat | ||
| 69 | + | ||
| 70 | + | ||
| 71 | +def _copier_verifie(source: Path, destination: Path) -> None: | ||
| 72 | + destination.parent.mkdir(parents=True, exist_ok=True) | ||
| 73 | + hash_source = empreinte(source).hash_fichier_entier | ||
| 74 | + shutil.copy2(source, destination) | ||
| 75 | + hash_destination = empreinte(destination).hash_fichier_entier | ||
| 76 | + if hash_source != hash_destination: | ||
| 77 | + raise IntegriteCopieError(f"Échec de vérification d'intégrité pour {destination}") | ||
| 78 | + | ||
| 79 | + | ||
| 80 | +def checkout( | ||
| 81 | + dossier_archive: Path, | ||
| 82 | + dest_locale: Path, | ||
| 83 | + formats: list[str] | None = None, | ||
| 84 | +) -> ManifestSnapshot: | ||
| 85 | + """Sort (checkout) un dossier de l'archive vers un espace de travail local. | ||
| 86 | + | ||
| 87 | + Copie l'intégralité du dossier (parent + tous ses sous-dossiers, FR-001), ou un | ||
| 88 | + sous-ensemble de formats plus la racine de sélection (FR-019, `formats` non | ||
| 89 | + `None`). Enregistre un état de référence complet (FR-002) et verrouille le | ||
| 90 | + dossier (FR-003) avant de commencer. Lève `regine_core.archive.verrou. | ||
| 91 | + DossierDejaVerrouilleError` si le dossier est déjà verrouillé (FR-004). | ||
| 92 | + """ | ||
| 93 | + manifest = manifest_module.ouvrir_ou_creer(dossier_archive) | ||
| 94 | + verrou.poser(manifest) | ||
| 95 | + | ||
| 96 | + dest_locale.mkdir(parents=True, exist_ok=True) | ||
| 97 | + fichiers_source = _lister_fichiers_source(dossier_archive, formats) | ||
| 98 | + | ||
| 99 | + entrees: dict[str, FichierManifesteEntry] = {} | ||
| 100 | + for source in fichiers_source: | ||
| 101 | + chemin_relatif = str(source.relative_to(dossier_archive)) | ||
| 102 | + destination = dest_locale / chemin_relatif | ||
| 103 | + | ||
| 104 | + _copier_verifie(source, destination) | ||
| 105 | + | ||
| 106 | + emp = empreinte(source) | ||
| 107 | + entree = FichierManifesteEntry( | ||
| 108 | + chemin_relatif=chemin_relatif, | ||
| 109 | + taille=source.stat().st_size, | ||
| 110 | + hash_fichier_entier=emp.hash_fichier_entier, | ||
| 111 | + hash_image_only=emp.hash_image_only, | ||
| 112 | + ) | ||
| 113 | + entrees[chemin_relatif] = entree | ||
| 114 | + manifest.conn.execute( | ||
| 115 | + "INSERT OR REPLACE INTO fichiers " | ||
| 116 | + "(chemin_relatif, taille, hash_fichier_entier, hash_image_only, identifiant_perenne) " | ||
| 117 | + "VALUES (?, ?, ?, ?, ?)", | ||
| 118 | + ( | ||
| 119 | + entree.chemin_relatif, | ||
| 120 | + entree.taille, | ||
| 121 | + entree.hash_fichier_entier, | ||
| 122 | + entree.hash_image_only, | ||
| 123 | + entree.identifiant_perenne, | ||
| 124 | + ), | ||
| 125 | + ) | ||
| 126 | + manifest.conn.commit() | ||
| 127 | + | ||
| 128 | + return ManifestSnapshot( | ||
| 129 | + dossier_archive=dossier_archive, | ||
| 130 | + dossier_local=dest_locale, | ||
| 131 | + fichiers=entrees, | ||
| 132 | + formats=formats, | ||
| 133 | + manifest=manifest, | ||
| 134 | + ) | ||
added
packages/regine-core/src/regine_core/archive/manifest.py +110 -0 | new file mode 100644 | ||
| @@ -0,0 +1,110 @@ | ||
| 1 | +"""Manifeste persistant par dossier principal (archive). | |
| 2 | + | |
| 3 | +Un fichier SQLite unique à la racine de chaque dossier principal (dossier simple, ou | |
| 4 | +dossier parent avec tous ses sous-dossiers) — cf. constitution § Workflow d'archivage | |
| 5 | +et specs/005-checkout-reconciliation research.md § 1/§ 2. | |
| 6 | +""" | |
| 7 | + | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import sqlite3 | |
| 11 | +from dataclasses import dataclass | |
| 12 | +from pathlib import Path | |
| 13 | + | |
| 14 | +#: Identifiant Régine pour PRAGMA application_id — partagé avec regine_core.config.db | |
| 15 | +#: (même format Régine, cf. constitution § Workflow d'archivage). | |
| 16 | +APPLICATION_ID = 0x52454749 | |
| 17 | + | |
| 18 | +#: Version de schéma courante, encodée `structurel * 1000 + additif` (cf. research.md § 2). | |
| 19 | +STRUCTUREL_COURANT = 1 | |
| 20 | +ADDITIF_COURANT = 0 | |
| 21 | + | |
| 22 | +#: Nom du fichier manifeste à la racine d'un dossier principal. | |
| 23 | +NOM_FICHIER_MANIFESTE = ".regine-manifest.sqlite3" | |
| 24 | + | |
| 25 | +_CREATE_FICHIERS = """ | |
| 26 | +CREATE TABLE IF NOT EXISTS fichiers ( | |
| 27 | + chemin_relatif TEXT PRIMARY KEY, | |
| 28 | + taille INTEGER NOT NULL, | |
| 29 | + hash_fichier_entier TEXT NOT NULL, | |
| 30 | + hash_image_only TEXT, | |
| 31 | + identifiant_perenne TEXT | |
| 32 | +) | |
| 33 | +""" | |
| 34 | + | |
| 35 | +_CREATE_VERROU = """ | |
| 36 | +CREATE TABLE IF NOT EXISTS verrou ( | |
| 37 | + id INTEGER PRIMARY KEY CHECK (id = 1), | |
| 38 | + verrouille INTEGER NOT NULL DEFAULT 0, | |
| 39 | + identifiant_session TEXT, | |
| 40 | + horodatage TEXT | |
| 41 | +) | |
| 42 | +""" | |
| 43 | + | |
| 44 | + | |
| 45 | +class VersionStructurelleNonSupporteeError(Exception): | |
| 46 | + """Le manifeste a été écrit par une version de Régine dont le format structurel | |
| 47 | + n'est pas compris par cette version du code (FR-018).""" | |
| 48 | + | |
| 49 | + | |
| 50 | +class FormatManifesteInvalideError(Exception): | |
| 51 | + """Le fichier n'est pas un manifeste Régine (application_id différent).""" | |
| 52 | + | |
| 53 | + | |
| 54 | +@dataclass | |
| 55 | +class ManifestHandle: | |
| 56 | + """Connexion ouverte vers le manifeste d'un dossier principal.""" | |
| 57 | + | |
| 58 | + conn: sqlite3.Connection | |
| 59 | + dossier: Path | |
| 60 | + | |
| 61 | + | |
| 62 | +def _encoder_version(structurel: int, additif: int) -> int: | |
| 63 | + return structurel * 1000 + additif | |
| 64 | + | |
| 65 | + | |
| 66 | +def _decoder_version(valeur: int) -> tuple[int, int]: | |
| 67 | + return divmod(valeur, 1000) | |
| 68 | + | |
| 69 | + | |
| 70 | +def ouvrir_ou_creer(dossier: Path) -> ManifestHandle: | |
| 71 | + """Ouvre le manifeste d'un dossier principal, en l'initialisant s'il n'existe pas. | |
| 72 | + | |
| 73 | + Refuse explicitement (`VersionStructurelleNonSupporteeError`) d'ouvrir un | |
| 74 | + manifeste dont la version structurelle est plus récente que ce que le code sait | |
| 75 | + lire (FR-018), plutôt que de l'interpréter à tort. Une évolution additive est | |
| 76 | + tolérée silencieusement. | |
| 77 | + """ | |
| 78 | + chemin_db = dossier / NOM_FICHIER_MANIFESTE | |
| 79 | + chemin_db.parent.mkdir(parents=True, exist_ok=True) | |
| 80 | + conn = sqlite3.connect(chemin_db) | |
| 81 | + conn.execute("PRAGMA foreign_keys = ON") | |
| 82 | + | |
| 83 | + application_id_actuel = conn.execute("PRAGMA application_id").fetchone()[0] | |
| 84 | + if application_id_actuel == 0: | |
| 85 | + conn.execute(f"PRAGMA application_id = {APPLICATION_ID}") | |
| 86 | + conn.execute( | |
| 87 | + f"PRAGMA user_version = {_encoder_version(STRUCTUREL_COURANT, ADDITIF_COURANT)}" | |
| 88 | + ) | |
| 89 | + elif application_id_actuel != APPLICATION_ID: | |
| 90 | + conn.close() | |
| 91 | + raise FormatManifesteInvalideError( | |
| 92 | + f"{chemin_db} n'est pas un manifeste Régine " | |
| 93 | + f"(application_id={application_id_actuel:#x}, attendu {APPLICATION_ID:#x})" | |
| 94 | + ) | |
| 95 | + else: | |
| 96 | + version_stockee = conn.execute("PRAGMA user_version").fetchone()[0] | |
| 97 | + structurel_stocke, _ = _decoder_version(version_stockee) | |
| 98 | + if structurel_stocke > STRUCTUREL_COURANT: | |
| 99 | + conn.close() | |
| 100 | + raise VersionStructurelleNonSupporteeError( | |
| 101 | + f"{chemin_db} a un format structurel ({structurel_stocke}) plus récent " | |
| 102 | + f"que celui supporté par cette version de Régine ({STRUCTUREL_COURANT})" | |
| 103 | + ) | |
| 104 | + | |
| 105 | + conn.execute(_CREATE_FICHIERS) | |
| 106 | + conn.execute(_CREATE_VERROU) | |
| 107 | + conn.execute("INSERT OR IGNORE INTO verrou (id, verrouille) VALUES (1, 0)") | |
| 108 | + conn.commit() | |
| 109 | + | |
| 110 | + return ManifestHandle(conn=conn, dossier=dossier) | |
| new file mode 100644 | |||
| @@ -0,0 +1,110 @@ | |||
| 1 | +"""Manifeste persistant par dossier principal (archive). | ||
| 2 | + | ||
| 3 | +Un fichier SQLite unique à la racine de chaque dossier principal (dossier simple, ou | ||
| 4 | +dossier parent avec tous ses sous-dossiers) — cf. constitution § Workflow d'archivage | ||
| 5 | +et specs/005-checkout-reconciliation research.md § 1/§ 2. | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +from __future__ import annotations | ||
| 9 | + | ||
| 10 | +import sqlite3 | ||
| 11 | +from dataclasses import dataclass | ||
| 12 | +from pathlib import Path | ||
| 13 | + | ||
| 14 | +#: Identifiant Régine pour PRAGMA application_id — partagé avec regine_core.config.db | ||
| 15 | +#: (même format Régine, cf. constitution § Workflow d'archivage). | ||
| 16 | +APPLICATION_ID = 0x52454749 | ||
| 17 | + | ||
| 18 | +#: Version de schéma courante, encodée `structurel * 1000 + additif` (cf. research.md § 2). | ||
| 19 | +STRUCTUREL_COURANT = 1 | ||
| 20 | +ADDITIF_COURANT = 0 | ||
| 21 | + | ||
| 22 | +#: Nom du fichier manifeste à la racine d'un dossier principal. | ||
| 23 | +NOM_FICHIER_MANIFESTE = ".regine-manifest.sqlite3" | ||
| 24 | + | ||
| 25 | +_CREATE_FICHIERS = """ | ||
| 26 | +CREATE TABLE IF NOT EXISTS fichiers ( | ||
| 27 | + chemin_relatif TEXT PRIMARY KEY, | ||
| 28 | + taille INTEGER NOT NULL, | ||
| 29 | + hash_fichier_entier TEXT NOT NULL, | ||
| 30 | + hash_image_only TEXT, | ||
| 31 | + identifiant_perenne TEXT | ||
| 32 | +) | ||
| 33 | +""" | ||
| 34 | + | ||
| 35 | +_CREATE_VERROU = """ | ||
| 36 | +CREATE TABLE IF NOT EXISTS verrou ( | ||
| 37 | + id INTEGER PRIMARY KEY CHECK (id = 1), | ||
| 38 | + verrouille INTEGER NOT NULL DEFAULT 0, | ||
| 39 | + identifiant_session TEXT, | ||
| 40 | + horodatage TEXT | ||
| 41 | +) | ||
| 42 | +""" | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +class VersionStructurelleNonSupporteeError(Exception): | ||
| 46 | + """Le manifeste a été écrit par une version de Régine dont le format structurel | ||
| 47 | + n'est pas compris par cette version du code (FR-018).""" | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +class FormatManifesteInvalideError(Exception): | ||
| 51 | + """Le fichier n'est pas un manifeste Régine (application_id différent).""" | ||
| 52 | + | ||
| 53 | + | ||
| 54 | +@dataclass | ||
| 55 | +class ManifestHandle: | ||
| 56 | + """Connexion ouverte vers le manifeste d'un dossier principal.""" | ||
| 57 | + | ||
| 58 | + conn: sqlite3.Connection | ||
| 59 | + dossier: Path | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +def _encoder_version(structurel: int, additif: int) -> int: | ||
| 63 | + return structurel * 1000 + additif | ||
| 64 | + | ||
| 65 | + | ||
| 66 | +def _decoder_version(valeur: int) -> tuple[int, int]: | ||
| 67 | + return divmod(valeur, 1000) | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +def ouvrir_ou_creer(dossier: Path) -> ManifestHandle: | ||
| 71 | + """Ouvre le manifeste d'un dossier principal, en l'initialisant s'il n'existe pas. | ||
| 72 | + | ||
| 73 | + Refuse explicitement (`VersionStructurelleNonSupporteeError`) d'ouvrir un | ||
| 74 | + manifeste dont la version structurelle est plus récente que ce que le code sait | ||
| 75 | + lire (FR-018), plutôt que de l'interpréter à tort. Une évolution additive est | ||
| 76 | + tolérée silencieusement. | ||
| 77 | + """ | ||
| 78 | + chemin_db = dossier / NOM_FICHIER_MANIFESTE | ||
| 79 | + chemin_db.parent.mkdir(parents=True, exist_ok=True) | ||
| 80 | + conn = sqlite3.connect(chemin_db) | ||
| 81 | + conn.execute("PRAGMA foreign_keys = ON") | ||
| 82 | + | ||
| 83 | + application_id_actuel = conn.execute("PRAGMA application_id").fetchone()[0] | ||
| 84 | + if application_id_actuel == 0: | ||
| 85 | + conn.execute(f"PRAGMA application_id = {APPLICATION_ID}") | ||
| 86 | + conn.execute( | ||
| 87 | + f"PRAGMA user_version = {_encoder_version(STRUCTUREL_COURANT, ADDITIF_COURANT)}" | ||
| 88 | + ) | ||
| 89 | + elif application_id_actuel != APPLICATION_ID: | ||
| 90 | + conn.close() | ||
| 91 | + raise FormatManifesteInvalideError( | ||
| 92 | + f"{chemin_db} n'est pas un manifeste Régine " | ||
| 93 | + f"(application_id={application_id_actuel:#x}, attendu {APPLICATION_ID:#x})" | ||
| 94 | + ) | ||
| 95 | + else: | ||
| 96 | + version_stockee = conn.execute("PRAGMA user_version").fetchone()[0] | ||
| 97 | + structurel_stocke, _ = _decoder_version(version_stockee) | ||
| 98 | + if structurel_stocke > STRUCTUREL_COURANT: | ||
| 99 | + conn.close() | ||
| 100 | + raise VersionStructurelleNonSupporteeError( | ||
| 101 | + f"{chemin_db} a un format structurel ({structurel_stocke}) plus récent " | ||
| 102 | + f"que celui supporté par cette version de Régine ({STRUCTUREL_COURANT})" | ||
| 103 | + ) | ||
| 104 | + | ||
| 105 | + conn.execute(_CREATE_FICHIERS) | ||
| 106 | + conn.execute(_CREATE_VERROU) | ||
| 107 | + conn.execute("INSERT OR IGNORE INTO verrou (id, verrouille) VALUES (1, 0)") | ||
| 108 | + conn.commit() | ||
| 109 | + | ||
| 110 | + return ManifestHandle(conn=conn, dossier=dossier) | ||
added
packages/regine-core/src/regine_core/archive/reconciliation.py +242 -0 | new file mode 100644 | ||
| @@ -0,0 +1,242 @@ | ||
| 1 | +"""Réconciliation d'une copie de travail avec le manifeste de référence (FR-006 à FR-016).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import shutil | |
| 6 | +from dataclasses import dataclass, field | |
| 7 | +from pathlib import Path | |
| 8 | + | |
| 9 | +from regine_core.archive.checkout import ( | |
| 10 | + FichierManifesteEntry, | |
| 11 | + IntegriteCopieError, | |
| 12 | + ManifestSnapshot, | |
| 13 | + dans_perimetre, | |
| 14 | +) | |
| 15 | +from regine_core.archive.verrou import lever | |
| 16 | +from regine_core.integrity.anomalie import est_maitre_modifie | |
| 17 | +from regine_core.integrity.hash import Empreinte, empreinte | |
| 18 | + | |
| 19 | +#: Extensions de sidecars : jamais soumises à la détection d'anomalie (FR-006), un | |
| 20 | +#: changement de sidecar est toujours un cas normal. | |
| 21 | +EXTENSIONS_SIDECAR = {"xmp", "dop", "acr"} | |
| 22 | + | |
| 23 | + | |
| 24 | +@dataclass(frozen=True) | |
| 25 | +class ChangementClasse: | |
| 26 | + """Un changement détecté à la réconciliation, classé selon FR-006.""" | |
| 27 | + | |
| 28 | + chemin: Path | |
| 29 | + categorie: str # "normal" | "anomalie" | "deplacement" | "suppression" | "nouveau" | |
| 30 | + chemin_ancien: Path | None = None | |
| 31 | + | |
| 32 | + | |
| 33 | +@dataclass(frozen=True) | |
| 34 | +class RapportReconciliation: | |
| 35 | + """Résultat de `comparer` — le « point avant archive » (FR-013).""" | |
| 36 | + | |
| 37 | + changements: list[ChangementClasse] | |
| 38 | + anomalies: list[ChangementClasse] | |
| 39 | + | |
| 40 | + | |
| 41 | +@dataclass | |
| 42 | +class DecisionsUtilisateur: | |
| 43 | + """Décisions explicites de l'utilisateur sur un `RapportReconciliation` (Principe II/V).""" | |
| 44 | + | |
| 45 | + confirmer_changements_normaux: bool = False | |
| 46 | + # chemin -> "confirmer" | "restaurer" | |
| 47 | + resolutions_anomalies: dict[str, str] = field(default_factory=dict) | |
| 48 | + nouveaux_fichiers_a_archiver: set[str] = field(default_factory=set) | |
| 49 | + | |
| 50 | + | |
| 51 | +def _scanner_copie_locale(copie_locale: Path) -> dict[str, FichierManifesteEntry]: | |
| 52 | + resultat: dict[str, FichierManifesteEntry] = {} | |
| 53 | + for chemin in copie_locale.rglob("*"): | |
| 54 | + if not chemin.is_file(): | |
| 55 | + continue | |
| 56 | + if chemin.name.startswith(".regine-manifest"): | |
| 57 | + continue | |
| 58 | + relatif = str(chemin.relative_to(copie_locale)) | |
| 59 | + emp = empreinte(chemin) | |
| 60 | + resultat[relatif] = FichierManifesteEntry( | |
| 61 | + chemin_relatif=relatif, | |
| 62 | + taille=chemin.stat().st_size, | |
| 63 | + hash_fichier_entier=emp.hash_fichier_entier, | |
| 64 | + hash_image_only=emp.hash_image_only, | |
| 65 | + ) | |
| 66 | + return resultat | |
| 67 | + | |
| 68 | + | |
| 69 | +def _cle_deplacement(entree: FichierManifesteEntry) -> str: | |
| 70 | + """Empreinte pertinente pour détecter un déplacement (FR-009/010) : image-only | |
| 71 | + quand disponible (stable à travers une édition de métadonnées), sinon fichier entier.""" | |
| 72 | + return entree.hash_image_only or entree.hash_fichier_entier | |
| 73 | + | |
| 74 | + | |
| 75 | +def comparer(snapshot: ManifestSnapshot, copie_locale: Path) -> RapportReconciliation: | |
| 76 | + """Compare la copie de travail à l'état de référence du checkout (FR-006). | |
| 77 | + | |
| 78 | + Classe chaque fichier en : inchangé (omis du rapport), normal (sidecar, ou | |
| 79 | + métadonnées seules sur DNG/TIFF/JPEG), anomalie (fichier maître modifié), | |
| 80 | + déplacement (contenu inchangé, chemin différent — fichier ou dossier entier), | |
| 81 | + suppression, ou nouveau fichier sans correspondance. | |
| 82 | + """ | |
| 83 | + fichiers_locaux = _scanner_copie_locale(copie_locale) | |
| 84 | + | |
| 85 | + chemins_manifeste = {c for c in snapshot.fichiers if dans_perimetre(c, snapshot.formats)} | |
| 86 | + chemins_locaux = set(fichiers_locaux) | |
| 87 | + | |
| 88 | + chemins_communs = chemins_manifeste & chemins_locaux | |
| 89 | + chemins_disparus = chemins_manifeste - chemins_locaux | |
| 90 | + chemins_nouveaux = chemins_locaux - chemins_manifeste | |
| 91 | + | |
| 92 | + changements: list[ChangementClasse] = [] | |
| 93 | + | |
| 94 | + for chemin in chemins_communs: | |
| 95 | + ref = snapshot.fichiers[chemin] | |
| 96 | + actuel = fichiers_locaux[chemin] | |
| 97 | + if ref.hash_fichier_entier == actuel.hash_fichier_entier: | |
| 98 | + continue # inchangé : rien à signaler | |
| 99 | + | |
| 100 | + extension = Path(chemin).suffix.lstrip(".").lower() | |
| 101 | + if extension in EXTENSIONS_SIDECAR: | |
| 102 | + changements.append(ChangementClasse(chemin=Path(chemin), categorie="normal")) | |
| 103 | + continue | |
| 104 | + | |
| 105 | + ref_empreinte = Empreinte(ref.hash_fichier_entier, ref.hash_image_only) | |
| 106 | + actuel_empreinte = Empreinte(actuel.hash_fichier_entier, actuel.hash_image_only) | |
| 107 | + if est_maitre_modifie(extension, ref_empreinte, actuel_empreinte): | |
| 108 | + changements.append(ChangementClasse(chemin=Path(chemin), categorie="anomalie")) | |
| 109 | + else: | |
| 110 | + changements.append(ChangementClasse(chemin=Path(chemin), categorie="normal")) | |
| 111 | + | |
| 112 | + # Détection de déplacement par contenu, sur les chemins disparus/nouveaux restants. | |
| 113 | + index_disparus = {_cle_deplacement(snapshot.fichiers[c]): c for c in chemins_disparus} | |
| 114 | + index_nouveaux = {_cle_deplacement(fichiers_locaux[c]): c for c in chemins_nouveaux} | |
| 115 | + cles_deplacees = set(index_disparus) & set(index_nouveaux) | |
| 116 | + | |
| 117 | + for cle in cles_deplacees: | |
| 118 | + ancien = index_disparus[cle] | |
| 119 | + nouveau = index_nouveaux[cle] | |
| 120 | + changements.append( | |
| 121 | + ChangementClasse( | |
| 122 | + chemin=Path(nouveau), categorie="deplacement", chemin_ancien=Path(ancien) | |
| 123 | + ) | |
| 124 | + ) | |
| 125 | + chemins_disparus.discard(ancien) | |
| 126 | + chemins_nouveaux.discard(nouveau) | |
| 127 | + | |
| 128 | + for chemin in chemins_disparus: | |
| 129 | + changements.append(ChangementClasse(chemin=Path(chemin), categorie="suppression")) | |
| 130 | + | |
| 131 | + for chemin in chemins_nouveaux: | |
| 132 | + changements.append(ChangementClasse(chemin=Path(chemin), categorie="nouveau")) | |
| 133 | + | |
| 134 | + anomalies = [c for c in changements if c.categorie == "anomalie"] | |
| 135 | + return RapportReconciliation(changements=changements, anomalies=anomalies) | |
| 136 | + | |
| 137 | + | |
| 138 | +def _copier_verifie(source: Path, destination: Path) -> None: | |
| 139 | + destination.parent.mkdir(parents=True, exist_ok=True) | |
| 140 | + hash_source = empreinte(source).hash_fichier_entier | |
| 141 | + shutil.copy2(source, destination) | |
| 142 | + if empreinte(destination).hash_fichier_entier != hash_source: | |
| 143 | + raise IntegriteCopieError(f"Échec de vérification d'intégrité pour {destination}") | |
| 144 | + | |
| 145 | + | |
| 146 | +def _maj_manifeste(snapshot: ManifestSnapshot, chemin_relatif: str) -> None: | |
| 147 | + chemin_local = snapshot.dossier_local / chemin_relatif | |
| 148 | + if not chemin_local.exists(): | |
| 149 | + snapshot.manifest.conn.execute( | |
| 150 | + "DELETE FROM fichiers WHERE chemin_relatif = ?", (chemin_relatif,) | |
| 151 | + ) | |
| 152 | + snapshot.manifest.conn.commit() | |
| 153 | + return | |
| 154 | + emp = empreinte(chemin_local) | |
| 155 | + snapshot.manifest.conn.execute( | |
| 156 | + "INSERT OR REPLACE INTO fichiers " | |
| 157 | + "(chemin_relatif, taille, hash_fichier_entier, hash_image_only, identifiant_perenne) " | |
| 158 | + "VALUES (?, ?, ?, ?, NULL)", | |
| 159 | + (chemin_relatif, chemin_local.stat().st_size, emp.hash_fichier_entier, emp.hash_image_only), | |
| 160 | + ) | |
| 161 | + snapshot.manifest.conn.commit() | |
| 162 | + | |
| 163 | + | |
| 164 | +def _appliquer(snapshot: ManifestSnapshot, changement: ChangementClasse) -> None: | |
| 165 | + if changement.categorie == "deplacement": | |
| 166 | + ancien_chemin_archive = snapshot.dossier_archive / changement.chemin_ancien # type: ignore[arg-type] | |
| 167 | + nouveau_chemin_archive = snapshot.dossier_archive / changement.chemin | |
| 168 | + nouveau_chemin_archive.parent.mkdir(parents=True, exist_ok=True) | |
| 169 | + if ancien_chemin_archive.exists(): | |
| 170 | + ancien_chemin_archive.rename(nouveau_chemin_archive) | |
| 171 | + snapshot.manifest.conn.execute( | |
| 172 | + "DELETE FROM fichiers WHERE chemin_relatif = ?", (str(changement.chemin_ancien),) | |
| 173 | + ) | |
| 174 | + snapshot.manifest.conn.commit() | |
| 175 | + _maj_manifeste(snapshot, str(changement.chemin)) | |
| 176 | + return | |
| 177 | + | |
| 178 | + chemin_local = snapshot.dossier_local / changement.chemin | |
| 179 | + chemin_archive = snapshot.dossier_archive / changement.chemin | |
| 180 | + _copier_verifie(chemin_local, chemin_archive) | |
| 181 | + _maj_manifeste(snapshot, str(changement.chemin)) | |
| 182 | + | |
| 183 | + | |
| 184 | +def _restaurer(snapshot: ManifestSnapshot, changement: ChangementClasse) -> None: | |
| 185 | + source = snapshot.dossier_archive / changement.chemin | |
| 186 | + destination = snapshot.dossier_local / changement.chemin | |
| 187 | + _copier_verifie(source, destination) | |
| 188 | + | |
| 189 | + | |
| 190 | +def _supprimer(snapshot: ManifestSnapshot, changement: ChangementClasse) -> None: | |
| 191 | + chemin_archive = snapshot.dossier_archive / changement.chemin | |
| 192 | + if chemin_archive.exists(): | |
| 193 | + chemin_archive.unlink() | |
| 194 | + snapshot.manifest.conn.execute( | |
| 195 | + "DELETE FROM fichiers WHERE chemin_relatif = ?", (str(changement.chemin),) | |
| 196 | + ) | |
| 197 | + snapshot.manifest.conn.commit() | |
| 198 | + | |
| 199 | + | |
| 200 | +def archiver( | |
| 201 | + snapshot: ManifestSnapshot, | |
| 202 | + rapport: RapportReconciliation, | |
| 203 | + decisions: DecisionsUtilisateur, | |
| 204 | +) -> None: | |
| 205 | + """Écrit sur l'archive les seuls changements couverts par `decisions` (FR-013/014). | |
| 206 | + | |
| 207 | + Ne DOIT jamais être appelée sans qu'un point avant archive ait été présenté et | |
| 208 | + confirmé côté appelant. Vérifie chaque transfert par empreinte (FR-015). Lève le | |
| 209 | + verrou (FR-016) uniquement une fois tous les changements du rapport traités | |
| 210 | + (confirmés, restaurés, ou nouveaux fichiers explicitement laissés en local). | |
| 211 | + """ | |
| 212 | + tout_traite = True | |
| 213 | + | |
| 214 | + for changement in rapport.changements: | |
| 215 | + chemin_str = str(changement.chemin) | |
| 216 | + | |
| 217 | + if changement.categorie in ("normal", "deplacement", "suppression"): | |
| 218 | + if not decisions.confirmer_changements_normaux: | |
| 219 | + tout_traite = False | |
| 220 | + continue | |
| 221 | + if changement.categorie == "suppression": | |
| 222 | + _supprimer(snapshot, changement) | |
| 223 | + else: | |
| 224 | + _appliquer(snapshot, changement) | |
| 225 | + | |
| 226 | + elif changement.categorie == "anomalie": | |
| 227 | + resolution = decisions.resolutions_anomalies.get(chemin_str) | |
| 228 | + if resolution == "confirmer": | |
| 229 | + _appliquer(snapshot, changement) | |
| 230 | + elif resolution == "restaurer": | |
| 231 | + _restaurer(snapshot, changement) | |
| 232 | + _maj_manifeste(snapshot, chemin_str) | |
| 233 | + else: | |
| 234 | + tout_traite = False | |
| 235 | + | |
| 236 | + elif changement.categorie == "nouveau": | |
| 237 | + if chemin_str in decisions.nouveaux_fichiers_a_archiver: | |
| 238 | + _appliquer(snapshot, changement) | |
| 239 | + # Laissé en local : ni erreur ni blocage du verrou, c'est un choix valide (FR-012). | |
| 240 | + | |
| 241 | + if tout_traite: | |
| 242 | + lever(snapshot.manifest) | |
| new file mode 100644 | |||
| @@ -0,0 +1,242 @@ | |||
| 1 | +"""Réconciliation d'une copie de travail avec le manifeste de référence (FR-006 à FR-016).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import shutil | ||
| 6 | +from dataclasses import dataclass, field | ||
| 7 | +from pathlib import Path | ||
| 8 | + | ||
| 9 | +from regine_core.archive.checkout import ( | ||
| 10 | + FichierManifesteEntry, | ||
| 11 | + IntegriteCopieError, | ||
| 12 | + ManifestSnapshot, | ||
| 13 | + dans_perimetre, | ||
| 14 | +) | ||
| 15 | +from regine_core.archive.verrou import lever | ||
| 16 | +from regine_core.integrity.anomalie import est_maitre_modifie | ||
| 17 | +from regine_core.integrity.hash import Empreinte, empreinte | ||
| 18 | + | ||
| 19 | +#: Extensions de sidecars : jamais soumises à la détection d'anomalie (FR-006), un | ||
| 20 | +#: changement de sidecar est toujours un cas normal. | ||
| 21 | +EXTENSIONS_SIDECAR = {"xmp", "dop", "acr"} | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +@dataclass(frozen=True) | ||
| 25 | +class ChangementClasse: | ||
| 26 | + """Un changement détecté à la réconciliation, classé selon FR-006.""" | ||
| 27 | + | ||
| 28 | + chemin: Path | ||
| 29 | + categorie: str # "normal" | "anomalie" | "deplacement" | "suppression" | "nouveau" | ||
| 30 | + chemin_ancien: Path | None = None | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +@dataclass(frozen=True) | ||
| 34 | +class RapportReconciliation: | ||
| 35 | + """Résultat de `comparer` — le « point avant archive » (FR-013).""" | ||
| 36 | + | ||
| 37 | + changements: list[ChangementClasse] | ||
| 38 | + anomalies: list[ChangementClasse] | ||
| 39 | + | ||
| 40 | + | ||
| 41 | +@dataclass | ||
| 42 | +class DecisionsUtilisateur: | ||
| 43 | + """Décisions explicites de l'utilisateur sur un `RapportReconciliation` (Principe II/V).""" | ||
| 44 | + | ||
| 45 | + confirmer_changements_normaux: bool = False | ||
| 46 | + # chemin -> "confirmer" | "restaurer" | ||
| 47 | + resolutions_anomalies: dict[str, str] = field(default_factory=dict) | ||
| 48 | + nouveaux_fichiers_a_archiver: set[str] = field(default_factory=set) | ||
| 49 | + | ||
| 50 | + | ||
| 51 | +def _scanner_copie_locale(copie_locale: Path) -> dict[str, FichierManifesteEntry]: | ||
| 52 | + resultat: dict[str, FichierManifesteEntry] = {} | ||
| 53 | + for chemin in copie_locale.rglob("*"): | ||
| 54 | + if not chemin.is_file(): | ||
| 55 | + continue | ||
| 56 | + if chemin.name.startswith(".regine-manifest"): | ||
| 57 | + continue | ||
| 58 | + relatif = str(chemin.relative_to(copie_locale)) | ||
| 59 | + emp = empreinte(chemin) | ||
| 60 | + resultat[relatif] = FichierManifesteEntry( | ||
| 61 | + chemin_relatif=relatif, | ||
| 62 | + taille=chemin.stat().st_size, | ||
| 63 | + hash_fichier_entier=emp.hash_fichier_entier, | ||
| 64 | + hash_image_only=emp.hash_image_only, | ||
| 65 | + ) | ||
| 66 | + return resultat | ||
| 67 | + | ||
| 68 | + | ||
| 69 | +def _cle_deplacement(entree: FichierManifesteEntry) -> str: | ||
| 70 | + """Empreinte pertinente pour détecter un déplacement (FR-009/010) : image-only | ||
| 71 | + quand disponible (stable à travers une édition de métadonnées), sinon fichier entier.""" | ||
| 72 | + return entree.hash_image_only or entree.hash_fichier_entier | ||
| 73 | + | ||
| 74 | + | ||
| 75 | +def comparer(snapshot: ManifestSnapshot, copie_locale: Path) -> RapportReconciliation: | ||
| 76 | + """Compare la copie de travail à l'état de référence du checkout (FR-006). | ||
| 77 | + | ||
| 78 | + Classe chaque fichier en : inchangé (omis du rapport), normal (sidecar, ou | ||
| 79 | + métadonnées seules sur DNG/TIFF/JPEG), anomalie (fichier maître modifié), | ||
| 80 | + déplacement (contenu inchangé, chemin différent — fichier ou dossier entier), | ||
| 81 | + suppression, ou nouveau fichier sans correspondance. | ||
| 82 | + """ | ||
| 83 | + fichiers_locaux = _scanner_copie_locale(copie_locale) | ||
| 84 | + | ||
| 85 | + chemins_manifeste = {c for c in snapshot.fichiers if dans_perimetre(c, snapshot.formats)} | ||
| 86 | + chemins_locaux = set(fichiers_locaux) | ||
| 87 | + | ||
| 88 | + chemins_communs = chemins_manifeste & chemins_locaux | ||
| 89 | + chemins_disparus = chemins_manifeste - chemins_locaux | ||
| 90 | + chemins_nouveaux = chemins_locaux - chemins_manifeste | ||
| 91 | + | ||
| 92 | + changements: list[ChangementClasse] = [] | ||
| 93 | + | ||
| 94 | + for chemin in chemins_communs: | ||
| 95 | + ref = snapshot.fichiers[chemin] | ||
| 96 | + actuel = fichiers_locaux[chemin] | ||
| 97 | + if ref.hash_fichier_entier == actuel.hash_fichier_entier: | ||
| 98 | + continue # inchangé : rien à signaler | ||
| 99 | + | ||
| 100 | + extension = Path(chemin).suffix.lstrip(".").lower() | ||
| 101 | + if extension in EXTENSIONS_SIDECAR: | ||
| 102 | + changements.append(ChangementClasse(chemin=Path(chemin), categorie="normal")) | ||
| 103 | + continue | ||
| 104 | + | ||
| 105 | + ref_empreinte = Empreinte(ref.hash_fichier_entier, ref.hash_image_only) | ||
| 106 | + actuel_empreinte = Empreinte(actuel.hash_fichier_entier, actuel.hash_image_only) | ||
| 107 | + if est_maitre_modifie(extension, ref_empreinte, actuel_empreinte): | ||
| 108 | + changements.append(ChangementClasse(chemin=Path(chemin), categorie="anomalie")) | ||
| 109 | + else: | ||
| 110 | + changements.append(ChangementClasse(chemin=Path(chemin), categorie="normal")) | ||
| 111 | + | ||
| 112 | + # Détection de déplacement par contenu, sur les chemins disparus/nouveaux restants. | ||
| 113 | + index_disparus = {_cle_deplacement(snapshot.fichiers[c]): c for c in chemins_disparus} | ||
| 114 | + index_nouveaux = {_cle_deplacement(fichiers_locaux[c]): c for c in chemins_nouveaux} | ||
| 115 | + cles_deplacees = set(index_disparus) & set(index_nouveaux) | ||
| 116 | + | ||
| 117 | + for cle in cles_deplacees: | ||
| 118 | + ancien = index_disparus[cle] | ||
| 119 | + nouveau = index_nouveaux[cle] | ||
| 120 | + changements.append( | ||
| 121 | + ChangementClasse( | ||
| 122 | + chemin=Path(nouveau), categorie="deplacement", chemin_ancien=Path(ancien) | ||
| 123 | + ) | ||
| 124 | + ) | ||
| 125 | + chemins_disparus.discard(ancien) | ||
| 126 | + chemins_nouveaux.discard(nouveau) | ||
| 127 | + | ||
| 128 | + for chemin in chemins_disparus: | ||
| 129 | + changements.append(ChangementClasse(chemin=Path(chemin), categorie="suppression")) | ||
| 130 | + | ||
| 131 | + for chemin in chemins_nouveaux: | ||
| 132 | + changements.append(ChangementClasse(chemin=Path(chemin), categorie="nouveau")) | ||
| 133 | + | ||
| 134 | + anomalies = [c for c in changements if c.categorie == "anomalie"] | ||
| 135 | + return RapportReconciliation(changements=changements, anomalies=anomalies) | ||
| 136 | + | ||
| 137 | + | ||
| 138 | +def _copier_verifie(source: Path, destination: Path) -> None: | ||
| 139 | + destination.parent.mkdir(parents=True, exist_ok=True) | ||
| 140 | + hash_source = empreinte(source).hash_fichier_entier | ||
| 141 | + shutil.copy2(source, destination) | ||
| 142 | + if empreinte(destination).hash_fichier_entier != hash_source: | ||
| 143 | + raise IntegriteCopieError(f"Échec de vérification d'intégrité pour {destination}") | ||
| 144 | + | ||
| 145 | + | ||
| 146 | +def _maj_manifeste(snapshot: ManifestSnapshot, chemin_relatif: str) -> None: | ||
| 147 | + chemin_local = snapshot.dossier_local / chemin_relatif | ||
| 148 | + if not chemin_local.exists(): | ||
| 149 | + snapshot.manifest.conn.execute( | ||
| 150 | + "DELETE FROM fichiers WHERE chemin_relatif = ?", (chemin_relatif,) | ||
| 151 | + ) | ||
| 152 | + snapshot.manifest.conn.commit() | ||
| 153 | + return | ||
| 154 | + emp = empreinte(chemin_local) | ||
| 155 | + snapshot.manifest.conn.execute( | ||
| 156 | + "INSERT OR REPLACE INTO fichiers " | ||
| 157 | + "(chemin_relatif, taille, hash_fichier_entier, hash_image_only, identifiant_perenne) " | ||
| 158 | + "VALUES (?, ?, ?, ?, NULL)", | ||
| 159 | + (chemin_relatif, chemin_local.stat().st_size, emp.hash_fichier_entier, emp.hash_image_only), | ||
| 160 | + ) | ||
| 161 | + snapshot.manifest.conn.commit() | ||
| 162 | + | ||
| 163 | + | ||
| 164 | +def _appliquer(snapshot: ManifestSnapshot, changement: ChangementClasse) -> None: | ||
| 165 | + if changement.categorie == "deplacement": | ||
| 166 | + ancien_chemin_archive = snapshot.dossier_archive / changement.chemin_ancien # type: ignore[arg-type] | ||
| 167 | + nouveau_chemin_archive = snapshot.dossier_archive / changement.chemin | ||
| 168 | + nouveau_chemin_archive.parent.mkdir(parents=True, exist_ok=True) | ||
| 169 | + if ancien_chemin_archive.exists(): | ||
| 170 | + ancien_chemin_archive.rename(nouveau_chemin_archive) | ||
| 171 | + snapshot.manifest.conn.execute( | ||
| 172 | + "DELETE FROM fichiers WHERE chemin_relatif = ?", (str(changement.chemin_ancien),) | ||
| 173 | + ) | ||
| 174 | + snapshot.manifest.conn.commit() | ||
| 175 | + _maj_manifeste(snapshot, str(changement.chemin)) | ||
| 176 | + return | ||
| 177 | + | ||
| 178 | + chemin_local = snapshot.dossier_local / changement.chemin | ||
| 179 | + chemin_archive = snapshot.dossier_archive / changement.chemin | ||
| 180 | + _copier_verifie(chemin_local, chemin_archive) | ||
| 181 | + _maj_manifeste(snapshot, str(changement.chemin)) | ||
| 182 | + | ||
| 183 | + | ||
| 184 | +def _restaurer(snapshot: ManifestSnapshot, changement: ChangementClasse) -> None: | ||
| 185 | + source = snapshot.dossier_archive / changement.chemin | ||
| 186 | + destination = snapshot.dossier_local / changement.chemin | ||
| 187 | + _copier_verifie(source, destination) | ||
| 188 | + | ||
| 189 | + | ||
| 190 | +def _supprimer(snapshot: ManifestSnapshot, changement: ChangementClasse) -> None: | ||
| 191 | + chemin_archive = snapshot.dossier_archive / changement.chemin | ||
| 192 | + if chemin_archive.exists(): | ||
| 193 | + chemin_archive.unlink() | ||
| 194 | + snapshot.manifest.conn.execute( | ||
| 195 | + "DELETE FROM fichiers WHERE chemin_relatif = ?", (str(changement.chemin),) | ||
| 196 | + ) | ||
| 197 | + snapshot.manifest.conn.commit() | ||
| 198 | + | ||
| 199 | + | ||
| 200 | +def archiver( | ||
| 201 | + snapshot: ManifestSnapshot, | ||
| 202 | + rapport: RapportReconciliation, | ||
| 203 | + decisions: DecisionsUtilisateur, | ||
| 204 | +) -> None: | ||
| 205 | + """Écrit sur l'archive les seuls changements couverts par `decisions` (FR-013/014). | ||
| 206 | + | ||
| 207 | + Ne DOIT jamais être appelée sans qu'un point avant archive ait été présenté et | ||
| 208 | + confirmé côté appelant. Vérifie chaque transfert par empreinte (FR-015). Lève le | ||
| 209 | + verrou (FR-016) uniquement une fois tous les changements du rapport traités | ||
| 210 | + (confirmés, restaurés, ou nouveaux fichiers explicitement laissés en local). | ||
| 211 | + """ | ||
| 212 | + tout_traite = True | ||
| 213 | + | ||
| 214 | + for changement in rapport.changements: | ||
| 215 | + chemin_str = str(changement.chemin) | ||
| 216 | + | ||
| 217 | + if changement.categorie in ("normal", "deplacement", "suppression"): | ||
| 218 | + if not decisions.confirmer_changements_normaux: | ||
| 219 | + tout_traite = False | ||
| 220 | + continue | ||
| 221 | + if changement.categorie == "suppression": | ||
| 222 | + _supprimer(snapshot, changement) | ||
| 223 | + else: | ||
| 224 | + _appliquer(snapshot, changement) | ||
| 225 | + | ||
| 226 | + elif changement.categorie == "anomalie": | ||
| 227 | + resolution = decisions.resolutions_anomalies.get(chemin_str) | ||
| 228 | + if resolution == "confirmer": | ||
| 229 | + _appliquer(snapshot, changement) | ||
| 230 | + elif resolution == "restaurer": | ||
| 231 | + _restaurer(snapshot, changement) | ||
| 232 | + _maj_manifeste(snapshot, chemin_str) | ||
| 233 | + else: | ||
| 234 | + tout_traite = False | ||
| 235 | + | ||
| 236 | + elif changement.categorie == "nouveau": | ||
| 237 | + if chemin_str in decisions.nouveaux_fichiers_a_archiver: | ||
| 238 | + _appliquer(snapshot, changement) | ||
| 239 | + # Laissé en local : ni erreur ni blocage du verrou, c'est un choix valide (FR-012). | ||
| 240 | + | ||
| 241 | + if tout_traite: | ||
| 242 | + lever(snapshot.manifest) | ||
added
packages/regine-core/src/regine_core/archive/verrou.py +43 -0 | new file mode 100644 | ||
| @@ -0,0 +1,43 @@ | ||
| 1 | +"""Verrouillage d'un dossier pendant un checkout en cours (FR-003/004/016). | |
| 2 | + | |
| 3 | +Protection applicative de bonne foi entre instances de Régine sur un partage réseau | |
| 4 | +ordinaire — pas une garantie distribuée absolue (cf. research.md § 3). | |
| 5 | +""" | |
| 6 | + | |
| 7 | +from __future__ import annotations | |
| 8 | + | |
| 9 | +from datetime import UTC, datetime | |
| 10 | + | |
| 11 | +from regine_core.archive.manifest import ManifestHandle | |
| 12 | + | |
| 13 | + | |
| 14 | +class DossierDejaVerrouilleError(Exception): | |
| 15 | + """Le dossier est déjà verrouillé par un checkout en cours (FR-004).""" | |
| 16 | + | |
| 17 | + | |
| 18 | +def poser(manifest: ManifestHandle, identifiant_session: str = "default") -> None: | |
| 19 | + """Verrouille le dossier ; lève `DossierDejaVerrouilleError` s'il l'est déjà.""" | |
| 20 | + if verifier(manifest): | |
| 21 | + raise DossierDejaVerrouilleError( | |
| 22 | + f"{manifest.dossier} est déjà verrouillé (checkout en cours)" | |
| 23 | + ) | |
| 24 | + manifest.conn.execute( | |
| 25 | + "UPDATE verrou SET verrouille = 1, identifiant_session = ?, horodatage = ? WHERE id = 1", | |
| 26 | + (identifiant_session, datetime.now(UTC).isoformat()), | |
| 27 | + ) | |
| 28 | + manifest.conn.commit() | |
| 29 | + | |
| 30 | + | |
| 31 | +def verifier(manifest: ManifestHandle) -> bool: | |
| 32 | + """Retourne `True` si le dossier est actuellement verrouillé.""" | |
| 33 | + ligne = manifest.conn.execute("SELECT verrouille FROM verrou WHERE id = 1").fetchone() | |
| 34 | + return bool(ligne and ligne[0]) | |
| 35 | + | |
| 36 | + | |
| 37 | +def lever(manifest: ManifestHandle) -> None: | |
| 38 | + """Lève le verrou (FR-016), une fois toutes les écritures d'une réconciliation terminées.""" | |
| 39 | + manifest.conn.execute( | |
| 40 | + "UPDATE verrou SET verrouille = 0, identifiant_session = NULL, " | |
| 41 | + "horodatage = NULL WHERE id = 1" | |
| 42 | + ) | |
| 43 | + manifest.conn.commit() | |
| new file mode 100644 | |||
| @@ -0,0 +1,43 @@ | |||
| 1 | +"""Verrouillage d'un dossier pendant un checkout en cours (FR-003/004/016). | ||
| 2 | + | ||
| 3 | +Protection applicative de bonne foi entre instances de Régine sur un partage réseau | ||
| 4 | +ordinaire — pas une garantie distribuée absolue (cf. research.md § 3). | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +from __future__ import annotations | ||
| 8 | + | ||
| 9 | +from datetime import UTC, datetime | ||
| 10 | + | ||
| 11 | +from regine_core.archive.manifest import ManifestHandle | ||
| 12 | + | ||
| 13 | + | ||
| 14 | +class DossierDejaVerrouilleError(Exception): | ||
| 15 | + """Le dossier est déjà verrouillé par un checkout en cours (FR-004).""" | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +def poser(manifest: ManifestHandle, identifiant_session: str = "default") -> None: | ||
| 19 | + """Verrouille le dossier ; lève `DossierDejaVerrouilleError` s'il l'est déjà.""" | ||
| 20 | + if verifier(manifest): | ||
| 21 | + raise DossierDejaVerrouilleError( | ||
| 22 | + f"{manifest.dossier} est déjà verrouillé (checkout en cours)" | ||
| 23 | + ) | ||
| 24 | + manifest.conn.execute( | ||
| 25 | + "UPDATE verrou SET verrouille = 1, identifiant_session = ?, horodatage = ? WHERE id = 1", | ||
| 26 | + (identifiant_session, datetime.now(UTC).isoformat()), | ||
| 27 | + ) | ||
| 28 | + manifest.conn.commit() | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +def verifier(manifest: ManifestHandle) -> bool: | ||
| 32 | + """Retourne `True` si le dossier est actuellement verrouillé.""" | ||
| 33 | + ligne = manifest.conn.execute("SELECT verrouille FROM verrou WHERE id = 1").fetchone() | ||
| 34 | + return bool(ligne and ligne[0]) | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +def lever(manifest: ManifestHandle) -> None: | ||
| 38 | + """Lève le verrou (FR-016), une fois toutes les écritures d'une réconciliation terminées.""" | ||
| 39 | + manifest.conn.execute( | ||
| 40 | + "UPDATE verrou SET verrouille = 0, identifiant_session = NULL, " | ||
| 41 | + "horodatage = NULL WHERE id = 1" | ||
| 42 | + ) | ||
| 43 | + manifest.conn.commit() | ||
added
packages/regine-core/src/regine_core/integrity/__init__.py +15 -0 | new file mode 100644 | ||
| @@ -0,0 +1,15 @@ | ||
| 1 | +"""Hash à deux niveaux (fichier entier / image-only) et détection d'anomalie. | |
| 2 | + | |
| 3 | +Cf. specs/005-checkout-reconciliation. Implémente directement le Principe I de la | |
| 4 | +constitution du projet. | |
| 5 | +""" | |
| 6 | + | |
| 7 | +from regine_core.integrity.anomalie import est_maitre_modifie | |
| 8 | +from regine_core.integrity.hash import Empreinte, hash_fichier_entier, hash_image_only | |
| 9 | + | |
| 10 | +__all__ = [ | |
| 11 | + "Empreinte", | |
| 12 | + "est_maitre_modifie", | |
| 13 | + "hash_fichier_entier", | |
| 14 | + "hash_image_only", | |
| 15 | +] | |
| new file mode 100644 | |||
| @@ -0,0 +1,15 @@ | |||
| 1 | +"""Hash à deux niveaux (fichier entier / image-only) et détection d'anomalie. | ||
| 2 | + | ||
| 3 | +Cf. specs/005-checkout-reconciliation. Implémente directement le Principe I de la | ||
| 4 | +constitution du projet. | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +from regine_core.integrity.anomalie import est_maitre_modifie | ||
| 8 | +from regine_core.integrity.hash import Empreinte, hash_fichier_entier, hash_image_only | ||
| 9 | + | ||
| 10 | +__all__ = [ | ||
| 11 | + "Empreinte", | ||
| 12 | + "est_maitre_modifie", | ||
| 13 | + "hash_fichier_entier", | ||
| 14 | + "hash_image_only", | ||
| 15 | +] | ||
added
packages/regine-core/src/regine_core/integrity/anomalie.py +30 -0 | new file mode 100644 | ||
| @@ -0,0 +1,30 @@ | ||
| 1 | +"""Décision anomalie / pas-anomalie sur un fichier maître (Principe I de la constitution).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from regine_core.integrity.hash import EXTENSIONS_HASH_DOUBLE, Empreinte | |
| 6 | + | |
| 7 | + | |
| 8 | +def est_maitre_modifie(extension: str, ref: Empreinte, actuel: Empreinte) -> bool: | |
| 9 | + """Détermine si un fichier maître a réellement été modifié entre deux empreintes. | |
| 10 | + | |
| 11 | + Pour les RAW propriétaires (extension hors `EXTENSIONS_HASH_DOUBLE`), la | |
| 12 | + comparaison porte sur le hash fichier entier seul : ces formats ne sont jamais | |
| 13 | + réécrits en place par les outils d'édition courants, tout changement est une | |
| 14 | + anomalie réelle. | |
| 15 | + | |
| 16 | + Pour DNG/TIFF/JPEG, la comparaison porte sur le hash image-only quand il est | |
| 17 | + disponible des deux côtés : une édition de métadonnées (réglages non destructifs) | |
| 18 | + change le hash fichier entier sans changer les pixels, et ne DOIT jamais être | |
| 19 | + signalée comme anomalie. Si le hash image-only est indisponible d'un côté ou de | |
| 20 | + l'autre, on retombe sur le hash fichier entier plutôt que de ne rien vérifier. | |
| 21 | + """ | |
| 22 | + ext = extension.lstrip(".").lower() | |
| 23 | + hash_double_disponible = ( | |
| 24 | + ext in EXTENSIONS_HASH_DOUBLE | |
| 25 | + and ref.hash_image_only is not None | |
| 26 | + and actuel.hash_image_only is not None | |
| 27 | + ) | |
| 28 | + if hash_double_disponible: | |
| 29 | + return ref.hash_image_only != actuel.hash_image_only | |
| 30 | + return ref.hash_fichier_entier != actuel.hash_fichier_entier | |
| new file mode 100644 | |||
| @@ -0,0 +1,30 @@ | |||
| 1 | +"""Décision anomalie / pas-anomalie sur un fichier maître (Principe I de la constitution).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from regine_core.integrity.hash import EXTENSIONS_HASH_DOUBLE, Empreinte | ||
| 6 | + | ||
| 7 | + | ||
| 8 | +def est_maitre_modifie(extension: str, ref: Empreinte, actuel: Empreinte) -> bool: | ||
| 9 | + """Détermine si un fichier maître a réellement été modifié entre deux empreintes. | ||
| 10 | + | ||
| 11 | + Pour les RAW propriétaires (extension hors `EXTENSIONS_HASH_DOUBLE`), la | ||
| 12 | + comparaison porte sur le hash fichier entier seul : ces formats ne sont jamais | ||
| 13 | + réécrits en place par les outils d'édition courants, tout changement est une | ||
| 14 | + anomalie réelle. | ||
| 15 | + | ||
| 16 | + Pour DNG/TIFF/JPEG, la comparaison porte sur le hash image-only quand il est | ||
| 17 | + disponible des deux côtés : une édition de métadonnées (réglages non destructifs) | ||
| 18 | + change le hash fichier entier sans changer les pixels, et ne DOIT jamais être | ||
| 19 | + signalée comme anomalie. Si le hash image-only est indisponible d'un côté ou de | ||
| 20 | + l'autre, on retombe sur le hash fichier entier plutôt que de ne rien vérifier. | ||
| 21 | + """ | ||
| 22 | + ext = extension.lstrip(".").lower() | ||
| 23 | + hash_double_disponible = ( | ||
| 24 | + ext in EXTENSIONS_HASH_DOUBLE | ||
| 25 | + and ref.hash_image_only is not None | ||
| 26 | + and actuel.hash_image_only is not None | ||
| 27 | + ) | ||
| 28 | + if hash_double_disponible: | ||
| 29 | + return ref.hash_image_only != actuel.hash_image_only | ||
| 30 | + return ref.hash_fichier_entier != actuel.hash_fichier_entier | ||
added
packages/regine-core/src/regine_core/integrity/hash.py +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +"""Calcul des empreintes de contenu à deux niveaux (Principe I de la constitution).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import hashlib | |
| 6 | +from dataclasses import dataclass | |
| 7 | +from pathlib import Path | |
| 8 | + | |
| 9 | +from regine_core.metadata.exif import read_image_data_hash | |
| 10 | + | |
| 11 | +#: Extensions pour lesquelles un hash image-only distinct du hash fichier entier a | |
| 12 | +#: un sens (les outils d'édition courants réécrivent leurs métadonnées directement | |
| 13 | +#: dans le fichier, sans sidecar, cf. Principe I). | |
| 14 | +EXTENSIONS_HASH_DOUBLE = {"dng", "tiff", "tif", "jpg", "jpeg"} | |
| 15 | + | |
| 16 | +_TAILLE_BLOC = 1024 * 1024 | |
| 17 | + | |
| 18 | + | |
| 19 | +@dataclass(frozen=True) | |
| 20 | +class Empreinte: | |
| 21 | + """Les deux niveaux d'empreinte de contenu d'un fichier, pour comparaison.""" | |
| 22 | + | |
| 23 | + hash_fichier_entier: str | |
| 24 | + hash_image_only: str | None | |
| 25 | + | |
| 26 | + | |
| 27 | +def hash_fichier_entier(chemin: Path) -> str: | |
| 28 | + """SHA-256 du fichier entier, calculé par lecture en flux (pas de chargement complet).""" | |
| 29 | + hachage = hashlib.sha256() | |
| 30 | + with chemin.open("rb") as f: | |
| 31 | + for bloc in iter(lambda: f.read(_TAILLE_BLOC), b""): | |
| 32 | + hachage.update(bloc) | |
| 33 | + return hachage.hexdigest() | |
| 34 | + | |
| 35 | + | |
| 36 | +def hash_image_only(chemin: Path) -> str | None: | |
| 37 | + """Hash portant uniquement sur les données image, pour DNG/TIFF/JPEG uniquement. | |
| 38 | + | |
| 39 | + `None` pour tout autre format (RAW propriétaires compris) : le hash fichier | |
| 40 | + entier suffit alors, ces formats n'étant jamais réécrits en place par les | |
| 41 | + outils d'édition courants (cf. Principe I). | |
| 42 | + """ | |
| 43 | + extension = chemin.suffix.lstrip(".").lower() | |
| 44 | + if extension not in EXTENSIONS_HASH_DOUBLE: | |
| 45 | + return None | |
| 46 | + return read_image_data_hash(chemin) | |
| 47 | + | |
| 48 | + | |
| 49 | +def empreinte(chemin: Path) -> Empreinte: | |
| 50 | + """Calcule l'empreinte à deux niveaux d'un fichier.""" | |
| 51 | + return Empreinte( | |
| 52 | + hash_fichier_entier=hash_fichier_entier(chemin), | |
| 53 | + hash_image_only=hash_image_only(chemin), | |
| 54 | + ) | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +"""Calcul des empreintes de contenu à deux niveaux (Principe I de la constitution).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import hashlib | ||
| 6 | +from dataclasses import dataclass | ||
| 7 | +from pathlib import Path | ||
| 8 | + | ||
| 9 | +from regine_core.metadata.exif import read_image_data_hash | ||
| 10 | + | ||
| 11 | +#: Extensions pour lesquelles un hash image-only distinct du hash fichier entier a | ||
| 12 | +#: un sens (les outils d'édition courants réécrivent leurs métadonnées directement | ||
| 13 | +#: dans le fichier, sans sidecar, cf. Principe I). | ||
| 14 | +EXTENSIONS_HASH_DOUBLE = {"dng", "tiff", "tif", "jpg", "jpeg"} | ||
| 15 | + | ||
| 16 | +_TAILLE_BLOC = 1024 * 1024 | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +@dataclass(frozen=True) | ||
| 20 | +class Empreinte: | ||
| 21 | + """Les deux niveaux d'empreinte de contenu d'un fichier, pour comparaison.""" | ||
| 22 | + | ||
| 23 | + hash_fichier_entier: str | ||
| 24 | + hash_image_only: str | None | ||
| 25 | + | ||
| 26 | + | ||
| 27 | +def hash_fichier_entier(chemin: Path) -> str: | ||
| 28 | + """SHA-256 du fichier entier, calculé par lecture en flux (pas de chargement complet).""" | ||
| 29 | + hachage = hashlib.sha256() | ||
| 30 | + with chemin.open("rb") as f: | ||
| 31 | + for bloc in iter(lambda: f.read(_TAILLE_BLOC), b""): | ||
| 32 | + hachage.update(bloc) | ||
| 33 | + return hachage.hexdigest() | ||
| 34 | + | ||
| 35 | + | ||
| 36 | +def hash_image_only(chemin: Path) -> str | None: | ||
| 37 | + """Hash portant uniquement sur les données image, pour DNG/TIFF/JPEG uniquement. | ||
| 38 | + | ||
| 39 | + `None` pour tout autre format (RAW propriétaires compris) : le hash fichier | ||
| 40 | + entier suffit alors, ces formats n'étant jamais réécrits en place par les | ||
| 41 | + outils d'édition courants (cf. Principe I). | ||
| 42 | + """ | ||
| 43 | + extension = chemin.suffix.lstrip(".").lower() | ||
| 44 | + if extension not in EXTENSIONS_HASH_DOUBLE: | ||
| 45 | + return None | ||
| 46 | + return read_image_data_hash(chemin) | ||
| 47 | + | ||
| 48 | + | ||
| 49 | +def empreinte(chemin: Path) -> Empreinte: | ||
| 50 | + """Calcule l'empreinte à deux niveaux d'un fichier.""" | ||
| 51 | + return Empreinte( | ||
| 52 | + hash_fichier_entier=hash_fichier_entier(chemin), | ||
| 53 | + hash_image_only=hash_image_only(chemin), | ||
| 54 | + ) | ||
modified
packages/regine-core/src/regine_core/metadata/exif.py +19 -0 | @@ -125,3 +125,22 @@ def read_camera_tags(chemin: Path) -> CameraTags: | ||
| 125 | 125 | modele=_nettoyer(data.get("Model")), |
| 126 | 126 | numero_serie=_nettoyer(data.get("BodySerialNumber")), |
| 127 | 127 | ) |
| 128 | + | |
| 129 | + | |
| 130 | +def read_image_data_hash(chemin: Path) -> str | None: | |
| 131 | + """Calcule le hash portant uniquement sur les données image (pixels) d'un fichier. | |
| 132 | + | |
| 133 | + Utilise `exiftool -api ImageHashType=SHA256 -ImageDataHash` (cf. | |
| 134 | + specs/005-checkout-reconciliation research.md § 5) : stable à travers les | |
| 135 | + éditions de métadonnées (réglages non destructifs), contrairement au hash du | |
| 136 | + fichier entier. Pertinent pour DNG/TIFF/JPEG (cf. Principe I de la constitution) ; | |
| 137 | + `None` si l'information n'est pas disponible. | |
| 138 | + """ | |
| 139 | + brut = _get_session().execute( | |
| 140 | + "-api", "ImageHashType=SHA256", "-json", "-ImageDataHash", str(chemin) | |
| 141 | + ) | |
| 142 | + if not brut.strip(): | |
| 143 | + return None | |
| 144 | + analyse = json.loads(brut) | |
| 145 | + data = analyse[0] if analyse else {} | |
| 146 | + return _nettoyer(data.get("ImageDataHash")) | |
| @@ -125,3 +125,22 @@ def read_camera_tags(chemin: Path) -> CameraTags: | |||
| 125 | modele=_nettoyer(data.get("Model")), | 125 | modele=_nettoyer(data.get("Model")), |
| 126 | numero_serie=_nettoyer(data.get("BodySerialNumber")), | 126 | numero_serie=_nettoyer(data.get("BodySerialNumber")), |
| 127 | ) | 127 | ) |
| 128 | + | ||
| 129 | + | ||
| 130 | +def read_image_data_hash(chemin: Path) -> str | None: | ||
| 131 | + """Calcule le hash portant uniquement sur les données image (pixels) d'un fichier. | ||
| 132 | + | ||
| 133 | + Utilise `exiftool -api ImageHashType=SHA256 -ImageDataHash` (cf. | ||
| 134 | + specs/005-checkout-reconciliation research.md § 5) : stable à travers les | ||
| 135 | + éditions de métadonnées (réglages non destructifs), contrairement au hash du | ||
| 136 | + fichier entier. Pertinent pour DNG/TIFF/JPEG (cf. Principe I de la constitution) ; | ||
| 137 | + `None` si l'information n'est pas disponible. | ||
| 138 | + """ | ||
| 139 | + brut = _get_session().execute( | ||
| 140 | + "-api", "ImageHashType=SHA256", "-json", "-ImageDataHash", str(chemin) | ||
| 141 | + ) | ||
| 142 | + if not brut.strip(): | ||
| 143 | + return None | ||
| 144 | + analyse = json.loads(brut) | ||
| 145 | + data = analyse[0] if analyse else {} | ||
| 146 | + return _nettoyer(data.get("ImageDataHash")) | ||
added
packages/regine-core/tests/integration/test_checkout_partiel.py +50 -0 | new file mode 100644 | ||
| @@ -0,0 +1,50 @@ | ||
| 1 | +"""Test d'integration du checkout partiel par format (T034, T035, US6).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +from regine_core.archive.checkout import checkout | |
| 8 | +from regine_core.archive.reconciliation import comparer | |
| 9 | + | |
| 10 | + | |
| 11 | +def _creer_dossier_archive(racine: Path) -> None: | |
| 12 | + (racine / "raw").mkdir(parents=True) | |
| 13 | + (racine / "jpeg").mkdir(parents=True) | |
| 14 | + (racine / "raw" / "photo.raf").write_bytes(b"contenu-raw") | |
| 15 | + (racine / "jpeg" / "photo.jpg").write_bytes(b"contenu-jpeg") | |
| 16 | + (racine / "photo.jpg").write_bytes(b"contenu-jpeg-racine") | |
| 17 | + | |
| 18 | + | |
| 19 | +def test_partial_checkout_copies_only_requested_formats_and_root(tmp_path: Path) -> None: | |
| 20 | + dossier_archive = tmp_path / "archive" | |
| 21 | + dest_locale = tmp_path / "local" | |
| 22 | + _creer_dossier_archive(dossier_archive) | |
| 23 | + | |
| 24 | + snapshot = checkout(dossier_archive, dest_locale, formats=["jpeg"]) | |
| 25 | + | |
| 26 | + assert (dest_locale / "jpeg" / "photo.jpg").exists() | |
| 27 | + assert (dest_locale / "photo.jpg").exists() # racine toujours incluse | |
| 28 | + assert not (dest_locale / "raw").exists() | |
| 29 | + assert set(snapshot.fichiers) == {"jpeg/photo.jpg", "photo.jpg"} | |
| 30 | + snapshot.manifest.conn.close() | |
| 31 | + | |
| 32 | + | |
| 33 | +def test_reconciliation_after_partial_checkout_never_flags_excluded_files(tmp_path: Path) -> None: | |
| 34 | + """FR-020 : les RAW exclus du checkout partiel ne sont jamais signalés comme | |
| 35 | + supprimés à la réconciliation.""" | |
| 36 | + dossier_archive = tmp_path / "archive" | |
| 37 | + dest_locale = tmp_path / "local" | |
| 38 | + _creer_dossier_archive(dossier_archive) | |
| 39 | + | |
| 40 | + snapshot = checkout(dossier_archive, dest_locale, formats=["jpeg"]) | |
| 41 | + | |
| 42 | + # Édition normale dans la partie checkoutée (nouveau sidecar), aucun RAW en local. | |
| 43 | + (dest_locale / "jpeg" / "photo.jpg.xmp").write_bytes(b"reglages") | |
| 44 | + | |
| 45 | + rapport = comparer(snapshot, dest_locale) | |
| 46 | + | |
| 47 | + categories = {str(c.chemin): c.categorie for c in rapport.changements} | |
| 48 | + assert "raw/photo.raf" not in categories # jamais signalé, ni suppression ni anomalie | |
| 49 | + assert categories.get("jpeg/photo.jpg.xmp") == "nouveau" | |
| 50 | + snapshot.manifest.conn.close() | |
| new file mode 100644 | |||
| @@ -0,0 +1,50 @@ | |||
| 1 | +"""Test d'integration du checkout partiel par format (T034, T035, US6).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +from regine_core.archive.checkout import checkout | ||
| 8 | +from regine_core.archive.reconciliation import comparer | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +def _creer_dossier_archive(racine: Path) -> None: | ||
| 12 | + (racine / "raw").mkdir(parents=True) | ||
| 13 | + (racine / "jpeg").mkdir(parents=True) | ||
| 14 | + (racine / "raw" / "photo.raf").write_bytes(b"contenu-raw") | ||
| 15 | + (racine / "jpeg" / "photo.jpg").write_bytes(b"contenu-jpeg") | ||
| 16 | + (racine / "photo.jpg").write_bytes(b"contenu-jpeg-racine") | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def test_partial_checkout_copies_only_requested_formats_and_root(tmp_path: Path) -> None: | ||
| 20 | + dossier_archive = tmp_path / "archive" | ||
| 21 | + dest_locale = tmp_path / "local" | ||
| 22 | + _creer_dossier_archive(dossier_archive) | ||
| 23 | + | ||
| 24 | + snapshot = checkout(dossier_archive, dest_locale, formats=["jpeg"]) | ||
| 25 | + | ||
| 26 | + assert (dest_locale / "jpeg" / "photo.jpg").exists() | ||
| 27 | + assert (dest_locale / "photo.jpg").exists() # racine toujours incluse | ||
| 28 | + assert not (dest_locale / "raw").exists() | ||
| 29 | + assert set(snapshot.fichiers) == {"jpeg/photo.jpg", "photo.jpg"} | ||
| 30 | + snapshot.manifest.conn.close() | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +def test_reconciliation_after_partial_checkout_never_flags_excluded_files(tmp_path: Path) -> None: | ||
| 34 | + """FR-020 : les RAW exclus du checkout partiel ne sont jamais signalés comme | ||
| 35 | + supprimés à la réconciliation.""" | ||
| 36 | + dossier_archive = tmp_path / "archive" | ||
| 37 | + dest_locale = tmp_path / "local" | ||
| 38 | + _creer_dossier_archive(dossier_archive) | ||
| 39 | + | ||
| 40 | + snapshot = checkout(dossier_archive, dest_locale, formats=["jpeg"]) | ||
| 41 | + | ||
| 42 | + # Édition normale dans la partie checkoutée (nouveau sidecar), aucun RAW en local. | ||
| 43 | + (dest_locale / "jpeg" / "photo.jpg.xmp").write_bytes(b"reglages") | ||
| 44 | + | ||
| 45 | + rapport = comparer(snapshot, dest_locale) | ||
| 46 | + | ||
| 47 | + categories = {str(c.chemin): c.categorie for c in rapport.changements} | ||
| 48 | + assert "raw/photo.raf" not in categories # jamais signalé, ni suppression ni anomalie | ||
| 49 | + assert categories.get("jpeg/photo.jpg.xmp") == "nouveau" | ||
| 50 | + snapshot.manifest.conn.close() | ||
added
packages/regine-core/tests/integration/test_cycle_checkout_reconciliation.py +104 -0 | new file mode 100644 | ||
| @@ -0,0 +1,104 @@ | ||
| 1 | +"""Test d'integration du cycle complet checkout -> edition -> reconciliation (T015, T020, T026). | |
| 2 | + | |
| 3 | +Complete progressivement au fil des User Stories 1 a 3. | |
| 4 | +""" | |
| 5 | + | |
| 6 | +from __future__ import annotations | |
| 7 | + | |
| 8 | +from pathlib import Path | |
| 9 | + | |
| 10 | +from regine_core.archive import verrou | |
| 11 | +from regine_core.archive.checkout import checkout | |
| 12 | +from regine_core.archive.reconciliation import ( | |
| 13 | + DecisionsUtilisateur, | |
| 14 | + archiver, | |
| 15 | + comparer, | |
| 16 | +) | |
| 17 | + | |
| 18 | + | |
| 19 | +def _creer_dossier_archive(racine: Path) -> None: | |
| 20 | + (racine / "raw").mkdir(parents=True) | |
| 21 | + (racine / "raw" / "photo.raf").write_bytes(b"contenu-raw-original") | |
| 22 | + (racine / "photo.raf.xmp").write_bytes(b"reglages-v1") | |
| 23 | + | |
| 24 | + | |
| 25 | +def test_checkout_creates_reference_manifest_and_locks_folder(tmp_path: Path) -> None: | |
| 26 | + """T015 (US1, partiel) : le checkout enregistre un manifeste complet et verrouille.""" | |
| 27 | + dossier_archive = tmp_path / "archive" | |
| 28 | + dest_locale = tmp_path / "local" | |
| 29 | + _creer_dossier_archive(dossier_archive) | |
| 30 | + | |
| 31 | + snapshot = checkout(dossier_archive, dest_locale) | |
| 32 | + | |
| 33 | + assert len(snapshot.fichiers) == 2 | |
| 34 | + assert verrou.verifier(snapshot.manifest) is True | |
| 35 | + snapshot.manifest.conn.close() | |
| 36 | + | |
| 37 | + | |
| 38 | +def test_full_cycle_sidecar_edit_reconcile_and_unlock(tmp_path: Path) -> None: | |
| 39 | + """T020 (US2) : édition sidecar -> réconciliation -> résumé -> confirmation -> | |
| 40 | + réarchivage -> verrou levé.""" | |
| 41 | + dossier_archive = tmp_path / "archive" | |
| 42 | + dest_locale = tmp_path / "local" | |
| 43 | + _creer_dossier_archive(dossier_archive) | |
| 44 | + | |
| 45 | + snapshot = checkout(dossier_archive, dest_locale) | |
| 46 | + | |
| 47 | + # Édition locale : sidecar modifié, fichier maître inchangé. | |
| 48 | + (dest_locale / "photo.raf.xmp").write_bytes(b"reglages-v2") | |
| 49 | + | |
| 50 | + rapport = comparer(snapshot, dest_locale) | |
| 51 | + assert len(rapport.changements) == 1 | |
| 52 | + assert rapport.changements[0].categorie == "normal" | |
| 53 | + assert rapport.anomalies == [] | |
| 54 | + | |
| 55 | + # Rien n'est écrit tant que la confirmation n'est pas donnée. | |
| 56 | + contenu_archive_avant = (dossier_archive / "photo.raf.xmp").read_bytes() | |
| 57 | + assert contenu_archive_avant == b"reglages-v1" | |
| 58 | + | |
| 59 | + decisions = DecisionsUtilisateur(confirmer_changements_normaux=True) | |
| 60 | + archiver(snapshot, rapport, decisions) | |
| 61 | + | |
| 62 | + assert (dossier_archive / "photo.raf.xmp").read_bytes() == b"reglages-v2" | |
| 63 | + assert verrou.verifier(snapshot.manifest) is False | |
| 64 | + snapshot.manifest.conn.close() | |
| 65 | + | |
| 66 | + | |
| 67 | +def test_anomaly_decision_does_not_block_already_confirmed_normal_changes(tmp_path: Path) -> None: | |
| 68 | + """T026 (US3) : une anomalie non résolue reste signalée mais n'empêche pas | |
| 69 | + l'archivage des changements normaux déjà confirmés dans la même session (FR-014). | |
| 70 | + Le verrou ne doit se lever qu'une fois l'anomalie elle-même traitée.""" | |
| 71 | + dossier_archive = tmp_path / "archive" | |
| 72 | + dest_locale = tmp_path / "local" | |
| 73 | + _creer_dossier_archive(dossier_archive) | |
| 74 | + | |
| 75 | + snapshot = checkout(dossier_archive, dest_locale) | |
| 76 | + | |
| 77 | + # Un sidecar est édité normalement, et le fichier maître est modifié (anomalie). | |
| 78 | + (dest_locale / "photo.raf.xmp").write_bytes(b"reglages-v2") | |
| 79 | + (dest_locale / "raw" / "photo.raf").write_bytes(b"contenu-raw-modifie-par-erreur") | |
| 80 | + | |
| 81 | + rapport = comparer(snapshot, dest_locale) | |
| 82 | + assert len(rapport.anomalies) == 1 | |
| 83 | + assert len(rapport.changements) == 2 | |
| 84 | + | |
| 85 | + # L'utilisateur confirme les changements normaux mais ne tranche pas encore | |
| 86 | + # l'anomalie : le sidecar doit être archivé, le fichier maître suspect non. | |
| 87 | + decisions = DecisionsUtilisateur(confirmer_changements_normaux=True) | |
| 88 | + archiver(snapshot, rapport, decisions) | |
| 89 | + | |
| 90 | + assert (dossier_archive / "photo.raf.xmp").read_bytes() == b"reglages-v2" | |
| 91 | + assert (dossier_archive / "raw" / "photo.raf").read_bytes() == b"contenu-raw-original" | |
| 92 | + assert verrou.verifier(snapshot.manifest) is True # anomalie non résolue : verrou conservé | |
| 93 | + | |
| 94 | + # L'utilisateur choisit ensuite de restaurer le fichier maître depuis l'archive. | |
| 95 | + decisions_2 = DecisionsUtilisateur( | |
| 96 | + confirmer_changements_normaux=True, | |
| 97 | + resolutions_anomalies={"raw/photo.raf": "restaurer"}, | |
| 98 | + ) | |
| 99 | + rapport_2 = comparer(snapshot, dest_locale) | |
| 100 | + archiver(snapshot, rapport_2, decisions_2) | |
| 101 | + | |
| 102 | + assert (dest_locale / "raw" / "photo.raf").read_bytes() == b"contenu-raw-original" | |
| 103 | + assert verrou.verifier(snapshot.manifest) is False | |
| 104 | + snapshot.manifest.conn.close() | |
| new file mode 100644 | |||
| @@ -0,0 +1,104 @@ | |||
| 1 | +"""Test d'integration du cycle complet checkout -> edition -> reconciliation (T015, T020, T026). | ||
| 2 | + | ||
| 3 | +Complete progressivement au fil des User Stories 1 a 3. | ||
| 4 | +""" | ||
| 5 | + | ||
| 6 | +from __future__ import annotations | ||
| 7 | + | ||
| 8 | +from pathlib import Path | ||
| 9 | + | ||
| 10 | +from regine_core.archive import verrou | ||
| 11 | +from regine_core.archive.checkout import checkout | ||
| 12 | +from regine_core.archive.reconciliation import ( | ||
| 13 | + DecisionsUtilisateur, | ||
| 14 | + archiver, | ||
| 15 | + comparer, | ||
| 16 | +) | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def _creer_dossier_archive(racine: Path) -> None: | ||
| 20 | + (racine / "raw").mkdir(parents=True) | ||
| 21 | + (racine / "raw" / "photo.raf").write_bytes(b"contenu-raw-original") | ||
| 22 | + (racine / "photo.raf.xmp").write_bytes(b"reglages-v1") | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +def test_checkout_creates_reference_manifest_and_locks_folder(tmp_path: Path) -> None: | ||
| 26 | + """T015 (US1, partiel) : le checkout enregistre un manifeste complet et verrouille.""" | ||
| 27 | + dossier_archive = tmp_path / "archive" | ||
| 28 | + dest_locale = tmp_path / "local" | ||
| 29 | + _creer_dossier_archive(dossier_archive) | ||
| 30 | + | ||
| 31 | + snapshot = checkout(dossier_archive, dest_locale) | ||
| 32 | + | ||
| 33 | + assert len(snapshot.fichiers) == 2 | ||
| 34 | + assert verrou.verifier(snapshot.manifest) is True | ||
| 35 | + snapshot.manifest.conn.close() | ||
| 36 | + | ||
| 37 | + | ||
| 38 | +def test_full_cycle_sidecar_edit_reconcile_and_unlock(tmp_path: Path) -> None: | ||
| 39 | + """T020 (US2) : édition sidecar -> réconciliation -> résumé -> confirmation -> | ||
| 40 | + réarchivage -> verrou levé.""" | ||
| 41 | + dossier_archive = tmp_path / "archive" | ||
| 42 | + dest_locale = tmp_path / "local" | ||
| 43 | + _creer_dossier_archive(dossier_archive) | ||
| 44 | + | ||
| 45 | + snapshot = checkout(dossier_archive, dest_locale) | ||
| 46 | + | ||
| 47 | + # Édition locale : sidecar modifié, fichier maître inchangé. | ||
| 48 | + (dest_locale / "photo.raf.xmp").write_bytes(b"reglages-v2") | ||
| 49 | + | ||
| 50 | + rapport = comparer(snapshot, dest_locale) | ||
| 51 | + assert len(rapport.changements) == 1 | ||
| 52 | + assert rapport.changements[0].categorie == "normal" | ||
| 53 | + assert rapport.anomalies == [] | ||
| 54 | + | ||
| 55 | + # Rien n'est écrit tant que la confirmation n'est pas donnée. | ||
| 56 | + contenu_archive_avant = (dossier_archive / "photo.raf.xmp").read_bytes() | ||
| 57 | + assert contenu_archive_avant == b"reglages-v1" | ||
| 58 | + | ||
| 59 | + decisions = DecisionsUtilisateur(confirmer_changements_normaux=True) | ||
| 60 | + archiver(snapshot, rapport, decisions) | ||
| 61 | + | ||
| 62 | + assert (dossier_archive / "photo.raf.xmp").read_bytes() == b"reglages-v2" | ||
| 63 | + assert verrou.verifier(snapshot.manifest) is False | ||
| 64 | + snapshot.manifest.conn.close() | ||
| 65 | + | ||
| 66 | + | ||
| 67 | +def test_anomaly_decision_does_not_block_already_confirmed_normal_changes(tmp_path: Path) -> None: | ||
| 68 | + """T026 (US3) : une anomalie non résolue reste signalée mais n'empêche pas | ||
| 69 | + l'archivage des changements normaux déjà confirmés dans la même session (FR-014). | ||
| 70 | + Le verrou ne doit se lever qu'une fois l'anomalie elle-même traitée.""" | ||
| 71 | + dossier_archive = tmp_path / "archive" | ||
| 72 | + dest_locale = tmp_path / "local" | ||
| 73 | + _creer_dossier_archive(dossier_archive) | ||
| 74 | + | ||
| 75 | + snapshot = checkout(dossier_archive, dest_locale) | ||
| 76 | + | ||
| 77 | + # Un sidecar est édité normalement, et le fichier maître est modifié (anomalie). | ||
| 78 | + (dest_locale / "photo.raf.xmp").write_bytes(b"reglages-v2") | ||
| 79 | + (dest_locale / "raw" / "photo.raf").write_bytes(b"contenu-raw-modifie-par-erreur") | ||
| 80 | + | ||
| 81 | + rapport = comparer(snapshot, dest_locale) | ||
| 82 | + assert len(rapport.anomalies) == 1 | ||
| 83 | + assert len(rapport.changements) == 2 | ||
| 84 | + | ||
| 85 | + # L'utilisateur confirme les changements normaux mais ne tranche pas encore | ||
| 86 | + # l'anomalie : le sidecar doit être archivé, le fichier maître suspect non. | ||
| 87 | + decisions = DecisionsUtilisateur(confirmer_changements_normaux=True) | ||
| 88 | + archiver(snapshot, rapport, decisions) | ||
| 89 | + | ||
| 90 | + assert (dossier_archive / "photo.raf.xmp").read_bytes() == b"reglages-v2" | ||
| 91 | + assert (dossier_archive / "raw" / "photo.raf").read_bytes() == b"contenu-raw-original" | ||
| 92 | + assert verrou.verifier(snapshot.manifest) is True # anomalie non résolue : verrou conservé | ||
| 93 | + | ||
| 94 | + # L'utilisateur choisit ensuite de restaurer le fichier maître depuis l'archive. | ||
| 95 | + decisions_2 = DecisionsUtilisateur( | ||
| 96 | + confirmer_changements_normaux=True, | ||
| 97 | + resolutions_anomalies={"raw/photo.raf": "restaurer"}, | ||
| 98 | + ) | ||
| 99 | + rapport_2 = comparer(snapshot, dest_locale) | ||
| 100 | + archiver(snapshot, rapport_2, decisions_2) | ||
| 101 | + | ||
| 102 | + assert (dest_locale / "raw" / "photo.raf").read_bytes() == b"contenu-raw-original" | ||
| 103 | + assert verrou.verifier(snapshot.manifest) is False | ||
| 104 | + snapshot.manifest.conn.close() | ||
added
packages/regine-core/tests/integration/test_double_checkout.py +43 -0 | new file mode 100644 | ||
| @@ -0,0 +1,43 @@ | ||
| 1 | +"""Test d'integration bout-en-bout : refus d'un double-checkout concurrent (T032, US5).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +from regine_cli.archive_cmd import main | |
| 8 | + | |
| 9 | + | |
| 10 | +def _creer_dossier_archive(racine: Path) -> None: | |
| 11 | + racine.mkdir(parents=True) | |
| 12 | + (racine / "photo.raf").write_bytes(b"contenu-raw") | |
| 13 | + | |
| 14 | + | |
| 15 | +def test_second_checkout_refused_while_first_is_in_progress(tmp_path: Path, capsys) -> None: | |
| 16 | + dossier_archive = tmp_path / "archive" | |
| 17 | + _creer_dossier_archive(dossier_archive) | |
| 18 | + | |
| 19 | + code_1 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local1")]) | |
| 20 | + assert code_1 == 0 | |
| 21 | + | |
| 22 | + code_2 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local2")]) | |
| 23 | + assert code_2 != 0 | |
| 24 | + erreur = capsys.readouterr().err | |
| 25 | + assert "déjà" in erreur.lower() or "verrouill" in erreur.lower() | |
| 26 | + | |
| 27 | + | |
| 28 | +def test_checkout_accepted_again_after_reconciliation(tmp_path: Path, monkeypatch) -> None: | |
| 29 | + dossier_archive = tmp_path / "archive" | |
| 30 | + _creer_dossier_archive(dossier_archive) | |
| 31 | + | |
| 32 | + code_1 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local1")]) | |
| 33 | + assert code_1 == 0 | |
| 34 | + | |
| 35 | + # Réconciliation sans aucun changement local : rien à confirmer, le verrou est | |
| 36 | + # néanmoins levé puisqu'il n'y a rien à traiter. | |
| 37 | + code_reconcile = main( | |
| 38 | + ["reconcile", str(dossier_archive), "--local-dest", str(tmp_path / "local1")] | |
| 39 | + ) | |
| 40 | + assert code_reconcile == 0 | |
| 41 | + | |
| 42 | + code_3 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local3")]) | |
| 43 | + assert code_3 == 0 | |
| new file mode 100644 | |||
| @@ -0,0 +1,43 @@ | |||
| 1 | +"""Test d'integration bout-en-bout : refus d'un double-checkout concurrent (T032, US5).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +from regine_cli.archive_cmd import main | ||
| 8 | + | ||
| 9 | + | ||
| 10 | +def _creer_dossier_archive(racine: Path) -> None: | ||
| 11 | + racine.mkdir(parents=True) | ||
| 12 | + (racine / "photo.raf").write_bytes(b"contenu-raw") | ||
| 13 | + | ||
| 14 | + | ||
| 15 | +def test_second_checkout_refused_while_first_is_in_progress(tmp_path: Path, capsys) -> None: | ||
| 16 | + dossier_archive = tmp_path / "archive" | ||
| 17 | + _creer_dossier_archive(dossier_archive) | ||
| 18 | + | ||
| 19 | + code_1 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local1")]) | ||
| 20 | + assert code_1 == 0 | ||
| 21 | + | ||
| 22 | + code_2 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local2")]) | ||
| 23 | + assert code_2 != 0 | ||
| 24 | + erreur = capsys.readouterr().err | ||
| 25 | + assert "déjà" in erreur.lower() or "verrouill" in erreur.lower() | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +def test_checkout_accepted_again_after_reconciliation(tmp_path: Path, monkeypatch) -> None: | ||
| 29 | + dossier_archive = tmp_path / "archive" | ||
| 30 | + _creer_dossier_archive(dossier_archive) | ||
| 31 | + | ||
| 32 | + code_1 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local1")]) | ||
| 33 | + assert code_1 == 0 | ||
| 34 | + | ||
| 35 | + # Réconciliation sans aucun changement local : rien à confirmer, le verrou est | ||
| 36 | + # néanmoins levé puisqu'il n'y a rien à traiter. | ||
| 37 | + code_reconcile = main( | ||
| 38 | + ["reconcile", str(dossier_archive), "--local-dest", str(tmp_path / "local1")] | ||
| 39 | + ) | ||
| 40 | + assert code_reconcile == 0 | ||
| 41 | + | ||
| 42 | + code_3 = main(["checkout", str(dossier_archive), "--local-dest", str(tmp_path / "local3")]) | ||
| 43 | + assert code_3 == 0 | ||
added
packages/regine-core/tests/unit/test_checkout.py +54 -0 | new file mode 100644 | ||
| @@ -0,0 +1,54 @@ | ||
| 1 | +"""Tests unitaires du checkout (T014).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +from regine_core.archive.checkout import checkout | |
| 8 | +from regine_core.integrity.hash import hash_fichier_entier | |
| 9 | + | |
| 10 | + | |
| 11 | +def _creer_dossier_archive(racine: Path) -> None: | |
| 12 | + (racine / "raw").mkdir(parents=True) | |
| 13 | + (racine / "jpeg").mkdir(parents=True) | |
| 14 | + (racine / "raw" / "2026-01-01_Titre_RD0001.RAF").write_bytes(b"contenu-raw") | |
| 15 | + (racine / "jpeg" / "2026-01-01_Titre_RD0001.JPG").write_bytes(b"contenu-jpeg") | |
| 16 | + (racine / "2026-01-01_Titre_RD0001.JPG").write_bytes(b"contenu-jpeg-promu-racine") | |
| 17 | + | |
| 18 | + | |
| 19 | +def test_checkout_copies_parent_and_all_subfolders_together(tmp_path: Path) -> None: | |
| 20 | + dossier_archive = tmp_path / "archive" / "2026" / "2026-01-01_Titre" | |
| 21 | + dest_locale = tmp_path / "local" / "2026-01-01_Titre" | |
| 22 | + _creer_dossier_archive(dossier_archive) | |
| 23 | + | |
| 24 | + snapshot = checkout(dossier_archive, dest_locale) | |
| 25 | + | |
| 26 | + assert (dest_locale / "raw" / "2026-01-01_Titre_RD0001.RAF").exists() | |
| 27 | + assert (dest_locale / "jpeg" / "2026-01-01_Titre_RD0001.JPG").exists() | |
| 28 | + assert (dest_locale / "2026-01-01_Titre_RD0001.JPG").exists() | |
| 29 | + assert len(snapshot.fichiers) == 3 | |
| 30 | + snapshot.manifest.conn.close() | |
| 31 | + | |
| 32 | + | |
| 33 | +def test_checkout_verifies_each_file_by_hash(tmp_path: Path) -> None: | |
| 34 | + dossier_archive = tmp_path / "archive" | |
| 35 | + dest_locale = tmp_path / "local" | |
| 36 | + _creer_dossier_archive(dossier_archive) | |
| 37 | + | |
| 38 | + snapshot = checkout(dossier_archive, dest_locale) | |
| 39 | + | |
| 40 | + for chemin_relatif, entree in snapshot.fichiers.items(): | |
| 41 | + assert entree.hash_fichier_entier == hash_fichier_entier(dest_locale / chemin_relatif) | |
| 42 | + snapshot.manifest.conn.close() | |
| 43 | + | |
| 44 | + | |
| 45 | +def test_checkout_records_reference_manifest(tmp_path: Path) -> None: | |
| 46 | + dossier_archive = tmp_path / "archive" | |
| 47 | + dest_locale = tmp_path / "local" | |
| 48 | + _creer_dossier_archive(dossier_archive) | |
| 49 | + | |
| 50 | + snapshot = checkout(dossier_archive, dest_locale) | |
| 51 | + | |
| 52 | + lignes = snapshot.manifest.conn.execute("SELECT chemin_relatif FROM fichiers").fetchall() | |
| 53 | + assert len(lignes) == 3 | |
| 54 | + snapshot.manifest.conn.close() | |
| new file mode 100644 | |||
| @@ -0,0 +1,54 @@ | |||
| 1 | +"""Tests unitaires du checkout (T014).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +from regine_core.archive.checkout import checkout | ||
| 8 | +from regine_core.integrity.hash import hash_fichier_entier | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +def _creer_dossier_archive(racine: Path) -> None: | ||
| 12 | + (racine / "raw").mkdir(parents=True) | ||
| 13 | + (racine / "jpeg").mkdir(parents=True) | ||
| 14 | + (racine / "raw" / "2026-01-01_Titre_RD0001.RAF").write_bytes(b"contenu-raw") | ||
| 15 | + (racine / "jpeg" / "2026-01-01_Titre_RD0001.JPG").write_bytes(b"contenu-jpeg") | ||
| 16 | + (racine / "2026-01-01_Titre_RD0001.JPG").write_bytes(b"contenu-jpeg-promu-racine") | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def test_checkout_copies_parent_and_all_subfolders_together(tmp_path: Path) -> None: | ||
| 20 | + dossier_archive = tmp_path / "archive" / "2026" / "2026-01-01_Titre" | ||
| 21 | + dest_locale = tmp_path / "local" / "2026-01-01_Titre" | ||
| 22 | + _creer_dossier_archive(dossier_archive) | ||
| 23 | + | ||
| 24 | + snapshot = checkout(dossier_archive, dest_locale) | ||
| 25 | + | ||
| 26 | + assert (dest_locale / "raw" / "2026-01-01_Titre_RD0001.RAF").exists() | ||
| 27 | + assert (dest_locale / "jpeg" / "2026-01-01_Titre_RD0001.JPG").exists() | ||
| 28 | + assert (dest_locale / "2026-01-01_Titre_RD0001.JPG").exists() | ||
| 29 | + assert len(snapshot.fichiers) == 3 | ||
| 30 | + snapshot.manifest.conn.close() | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +def test_checkout_verifies_each_file_by_hash(tmp_path: Path) -> None: | ||
| 34 | + dossier_archive = tmp_path / "archive" | ||
| 35 | + dest_locale = tmp_path / "local" | ||
| 36 | + _creer_dossier_archive(dossier_archive) | ||
| 37 | + | ||
| 38 | + snapshot = checkout(dossier_archive, dest_locale) | ||
| 39 | + | ||
| 40 | + for chemin_relatif, entree in snapshot.fichiers.items(): | ||
| 41 | + assert entree.hash_fichier_entier == hash_fichier_entier(dest_locale / chemin_relatif) | ||
| 42 | + snapshot.manifest.conn.close() | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +def test_checkout_records_reference_manifest(tmp_path: Path) -> None: | ||
| 46 | + dossier_archive = tmp_path / "archive" | ||
| 47 | + dest_locale = tmp_path / "local" | ||
| 48 | + _creer_dossier_archive(dossier_archive) | ||
| 49 | + | ||
| 50 | + snapshot = checkout(dossier_archive, dest_locale) | ||
| 51 | + | ||
| 52 | + lignes = snapshot.manifest.conn.execute("SELECT chemin_relatif FROM fichiers").fetchall() | ||
| 53 | + assert len(lignes) == 3 | ||
| 54 | + snapshot.manifest.conn.close() | ||
added
packages/regine-core/tests/unit/test_hash_deux_niveaux.py +105 -0 | new file mode 100644 | ||
| @@ -0,0 +1,105 @@ | ||
| 1 | +"""Tests du hash a deux niveaux et de la decision d'anomalie (T008).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import base64 | |
| 6 | +import shutil | |
| 7 | +import subprocess | |
| 8 | +from pathlib import Path | |
| 9 | + | |
| 10 | +import pytest | |
| 11 | +from regine_core.integrity.anomalie import est_maitre_modifie | |
| 12 | +from regine_core.integrity.hash import Empreinte, empreinte, hash_fichier_entier, hash_image_only | |
| 13 | +from regine_core.metadata.exif import close_session | |
| 14 | + | |
| 15 | +_JPEG_1X1_BASE64 = ( | |
| 16 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | |
| 17 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | |
| 18 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | |
| 19 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | |
| 20 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | |
| 21 | +) | |
| 22 | + | |
| 23 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | |
| 24 | + | |
| 25 | + | |
| 26 | +@pytest.fixture(autouse=True) | |
| 27 | +def _close_shared_session(): | |
| 28 | + yield | |
| 29 | + close_session() | |
| 30 | + | |
| 31 | + | |
| 32 | +def _jpeg(tmp_path: Path, nom: str = "photo.jpg") -> Path: | |
| 33 | + chemin = tmp_path / nom | |
| 34 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | |
| 35 | + return chemin | |
| 36 | + | |
| 37 | + | |
| 38 | +def _tagger(chemin: Path, **tags: str) -> None: | |
| 39 | + args = [f"-{cle}={valeur}" for cle, valeur in tags.items()] | |
| 40 | + subprocess.run( # noqa: S603, S607 | |
| 41 | + ["exiftool", *args, "-overwrite_original", str(chemin)], check=True, capture_output=True | |
| 42 | + ) | |
| 43 | + | |
| 44 | + | |
| 45 | +def test_hash_fichier_entier_is_stable_for_unchanged_file(tmp_path: Path) -> None: | |
| 46 | + chemin = _jpeg(tmp_path) | |
| 47 | + assert hash_fichier_entier(chemin) == hash_fichier_entier(chemin) | |
| 48 | + | |
| 49 | + | |
| 50 | +def test_hash_image_only_none_for_raw_proprietary_extension(tmp_path: Path) -> None: | |
| 51 | + chemin = tmp_path / "photo.raf" | |
| 52 | + chemin.write_bytes(b"contenu-raw-quelconque") | |
| 53 | + assert hash_image_only(chemin) is None | |
| 54 | + | |
| 55 | + | |
| 56 | +def test_hash_image_only_stable_across_metadata_edit_for_jpeg(tmp_path: Path) -> None: | |
| 57 | + chemin = _jpeg(tmp_path) | |
| 58 | + hash_avant = hash_image_only(chemin) | |
| 59 | + | |
| 60 | + _tagger(chemin, Model="Fujifilm X100V") | |
| 61 | + | |
| 62 | + hash_apres = hash_image_only(chemin) | |
| 63 | + assert hash_avant == hash_apres | |
| 64 | + assert hash_avant is not None | |
| 65 | + | |
| 66 | + | |
| 67 | +def test_est_maitre_modifie_raw_proprietaire_any_change_is_anomaly() -> None: | |
| 68 | + ref = Empreinte(hash_fichier_entier="aaa", hash_image_only=None) | |
| 69 | + actuel = Empreinte(hash_fichier_entier="bbb", hash_image_only=None) | |
| 70 | + | |
| 71 | + assert est_maitre_modifie("raf", ref, actuel) is True | |
| 72 | + | |
| 73 | + | |
| 74 | +def test_est_maitre_modifie_raw_proprietaire_unchanged_is_not_anomaly() -> None: | |
| 75 | + ref = Empreinte(hash_fichier_entier="aaa", hash_image_only=None) | |
| 76 | + actuel = Empreinte(hash_fichier_entier="aaa", hash_image_only=None) | |
| 77 | + | |
| 78 | + assert est_maitre_modifie("raf", ref, actuel) is False | |
| 79 | + | |
| 80 | + | |
| 81 | +def test_est_maitre_modifie_dng_metadata_only_edit_is_not_anomaly() -> None: | |
| 82 | + """Hash fichier entier change (métadonnées), hash image-only inchangé : pas d'anomalie.""" | |
| 83 | + ref = Empreinte(hash_fichier_entier="aaa", hash_image_only="pixels-1") | |
| 84 | + actuel = Empreinte(hash_fichier_entier="bbb", hash_image_only="pixels-1") | |
| 85 | + | |
| 86 | + assert est_maitre_modifie("dng", ref, actuel) is False | |
| 87 | + | |
| 88 | + | |
| 89 | +def test_est_maitre_modifie_dng_pixel_change_is_anomaly() -> None: | |
| 90 | + ref = Empreinte(hash_fichier_entier="aaa", hash_image_only="pixels-1") | |
| 91 | + actuel = Empreinte(hash_fichier_entier="bbb", hash_image_only="pixels-2") | |
| 92 | + | |
| 93 | + assert est_maitre_modifie("dng", ref, actuel) is True | |
| 94 | + | |
| 95 | + | |
| 96 | +def test_est_maitre_modifie_end_to_end_jpeg_master(tmp_path: Path) -> None: | |
| 97 | + """Bout en bout : édition de métadonnées sur un vrai fichier ne déclenche pas d'anomalie.""" | |
| 98 | + chemin = _jpeg(tmp_path) | |
| 99 | + ref = empreinte(chemin) | |
| 100 | + | |
| 101 | + _tagger(chemin, Model="Fujifilm X100V") | |
| 102 | + | |
| 103 | + actuel = empreinte(chemin) | |
| 104 | + assert ref.hash_fichier_entier != actuel.hash_fichier_entier # le fichier a bien changé | |
| 105 | + assert est_maitre_modifie("jpg", ref, actuel) is False # mais pas les pixels | |
| new file mode 100644 | |||
| @@ -0,0 +1,105 @@ | |||
| 1 | +"""Tests du hash a deux niveaux et de la decision d'anomalie (T008).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import base64 | ||
| 6 | +import shutil | ||
| 7 | +import subprocess | ||
| 8 | +from pathlib import Path | ||
| 9 | + | ||
| 10 | +import pytest | ||
| 11 | +from regine_core.integrity.anomalie import est_maitre_modifie | ||
| 12 | +from regine_core.integrity.hash import Empreinte, empreinte, hash_fichier_entier, hash_image_only | ||
| 13 | +from regine_core.metadata.exif import close_session | ||
| 14 | + | ||
| 15 | +_JPEG_1X1_BASE64 = ( | ||
| 16 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | ||
| 17 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | ||
| 18 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | ||
| 19 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | ||
| 20 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | ||
| 21 | +) | ||
| 22 | + | ||
| 23 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +@pytest.fixture(autouse=True) | ||
| 27 | +def _close_shared_session(): | ||
| 28 | + yield | ||
| 29 | + close_session() | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +def _jpeg(tmp_path: Path, nom: str = "photo.jpg") -> Path: | ||
| 33 | + chemin = tmp_path / nom | ||
| 34 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | ||
| 35 | + return chemin | ||
| 36 | + | ||
| 37 | + | ||
| 38 | +def _tagger(chemin: Path, **tags: str) -> None: | ||
| 39 | + args = [f"-{cle}={valeur}" for cle, valeur in tags.items()] | ||
| 40 | + subprocess.run( # noqa: S603, S607 | ||
| 41 | + ["exiftool", *args, "-overwrite_original", str(chemin)], check=True, capture_output=True | ||
| 42 | + ) | ||
| 43 | + | ||
| 44 | + | ||
| 45 | +def test_hash_fichier_entier_is_stable_for_unchanged_file(tmp_path: Path) -> None: | ||
| 46 | + chemin = _jpeg(tmp_path) | ||
| 47 | + assert hash_fichier_entier(chemin) == hash_fichier_entier(chemin) | ||
| 48 | + | ||
| 49 | + | ||
| 50 | +def test_hash_image_only_none_for_raw_proprietary_extension(tmp_path: Path) -> None: | ||
| 51 | + chemin = tmp_path / "photo.raf" | ||
| 52 | + chemin.write_bytes(b"contenu-raw-quelconque") | ||
| 53 | + assert hash_image_only(chemin) is None | ||
| 54 | + | ||
| 55 | + | ||
| 56 | +def test_hash_image_only_stable_across_metadata_edit_for_jpeg(tmp_path: Path) -> None: | ||
| 57 | + chemin = _jpeg(tmp_path) | ||
| 58 | + hash_avant = hash_image_only(chemin) | ||
| 59 | + | ||
| 60 | + _tagger(chemin, Model="Fujifilm X100V") | ||
| 61 | + | ||
| 62 | + hash_apres = hash_image_only(chemin) | ||
| 63 | + assert hash_avant == hash_apres | ||
| 64 | + assert hash_avant is not None | ||
| 65 | + | ||
| 66 | + | ||
| 67 | +def test_est_maitre_modifie_raw_proprietaire_any_change_is_anomaly() -> None: | ||
| 68 | + ref = Empreinte(hash_fichier_entier="aaa", hash_image_only=None) | ||
| 69 | + actuel = Empreinte(hash_fichier_entier="bbb", hash_image_only=None) | ||
| 70 | + | ||
| 71 | + assert est_maitre_modifie("raf", ref, actuel) is True | ||
| 72 | + | ||
| 73 | + | ||
| 74 | +def test_est_maitre_modifie_raw_proprietaire_unchanged_is_not_anomaly() -> None: | ||
| 75 | + ref = Empreinte(hash_fichier_entier="aaa", hash_image_only=None) | ||
| 76 | + actuel = Empreinte(hash_fichier_entier="aaa", hash_image_only=None) | ||
| 77 | + | ||
| 78 | + assert est_maitre_modifie("raf", ref, actuel) is False | ||
| 79 | + | ||
| 80 | + | ||
| 81 | +def test_est_maitre_modifie_dng_metadata_only_edit_is_not_anomaly() -> None: | ||
| 82 | + """Hash fichier entier change (métadonnées), hash image-only inchangé : pas d'anomalie.""" | ||
| 83 | + ref = Empreinte(hash_fichier_entier="aaa", hash_image_only="pixels-1") | ||
| 84 | + actuel = Empreinte(hash_fichier_entier="bbb", hash_image_only="pixels-1") | ||
| 85 | + | ||
| 86 | + assert est_maitre_modifie("dng", ref, actuel) is False | ||
| 87 | + | ||
| 88 | + | ||
| 89 | +def test_est_maitre_modifie_dng_pixel_change_is_anomaly() -> None: | ||
| 90 | + ref = Empreinte(hash_fichier_entier="aaa", hash_image_only="pixels-1") | ||
| 91 | + actuel = Empreinte(hash_fichier_entier="bbb", hash_image_only="pixels-2") | ||
| 92 | + | ||
| 93 | + assert est_maitre_modifie("dng", ref, actuel) is True | ||
| 94 | + | ||
| 95 | + | ||
| 96 | +def test_est_maitre_modifie_end_to_end_jpeg_master(tmp_path: Path) -> None: | ||
| 97 | + """Bout en bout : édition de métadonnées sur un vrai fichier ne déclenche pas d'anomalie.""" | ||
| 98 | + chemin = _jpeg(tmp_path) | ||
| 99 | + ref = empreinte(chemin) | ||
| 100 | + | ||
| 101 | + _tagger(chemin, Model="Fujifilm X100V") | ||
| 102 | + | ||
| 103 | + actuel = empreinte(chemin) | ||
| 104 | + assert ref.hash_fichier_entier != actuel.hash_fichier_entier # le fichier a bien changé | ||
| 105 | + assert est_maitre_modifie("jpg", ref, actuel) is False # mais pas les pixels | ||
added
packages/regine-core/tests/unit/test_manifest_versioning.py +77 -0 | new file mode 100644 | ||
| @@ -0,0 +1,77 @@ | ||
| 1 | +"""Tests du versionnement de schema du manifeste (T011).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +import pytest | |
| 8 | +from regine_core.archive.manifest import ( | |
| 9 | + APPLICATION_ID, | |
| 10 | + STRUCTUREL_COURANT, | |
| 11 | + FormatManifesteInvalideError, | |
| 12 | + VersionStructurelleNonSupporteeError, | |
| 13 | + _encoder_version, | |
| 14 | + ouvrir_ou_creer, | |
| 15 | +) | |
| 16 | + | |
| 17 | + | |
| 18 | +def test_bootstrap_sets_application_id_and_version(tmp_path: Path) -> None: | |
| 19 | + handle = ouvrir_ou_creer(tmp_path) | |
| 20 | + try: | |
| 21 | + assert handle.conn.execute("PRAGMA application_id").fetchone()[0] == APPLICATION_ID | |
| 22 | + finally: | |
| 23 | + handle.conn.close() | |
| 24 | + | |
| 25 | + | |
| 26 | +def test_reopen_is_idempotent_and_preserves_data(tmp_path: Path) -> None: | |
| 27 | + handle_1 = ouvrir_ou_creer(tmp_path) | |
| 28 | + handle_1.conn.execute( | |
| 29 | + "INSERT INTO fichiers (chemin_relatif, taille, hash_fichier_entier) VALUES (?, ?, ?)", | |
| 30 | + ("raw/a.raf", 100, "abc"), | |
| 31 | + ) | |
| 32 | + handle_1.conn.commit() | |
| 33 | + handle_1.conn.close() | |
| 34 | + | |
| 35 | + handle_2 = ouvrir_ou_creer(tmp_path) | |
| 36 | + try: | |
| 37 | + rows = handle_2.conn.execute("SELECT chemin_relatif FROM fichiers").fetchall() | |
| 38 | + assert rows == [("raw/a.raf",)] | |
| 39 | + finally: | |
| 40 | + handle_2.conn.close() | |
| 41 | + | |
| 42 | + | |
| 43 | +def test_additive_schema_evolution_is_tolerated(tmp_path: Path) -> None: | |
| 44 | + """Une version structurelle identique mais un numero additif superieur est tolere.""" | |
| 45 | + handle = ouvrir_ou_creer(tmp_path) | |
| 46 | + handle.conn.execute(f"PRAGMA user_version = {_encoder_version(STRUCTUREL_COURANT, 7)}") | |
| 47 | + handle.conn.commit() | |
| 48 | + handle.conn.close() | |
| 49 | + | |
| 50 | + # Reouverture : ne doit pas lever, l'additif superieur est tolere. | |
| 51 | + handle_2 = ouvrir_ou_creer(tmp_path) | |
| 52 | + handle_2.conn.close() | |
| 53 | + | |
| 54 | + | |
| 55 | +def test_structural_version_mismatch_is_rejected(tmp_path: Path) -> None: | |
| 56 | + handle = ouvrir_ou_creer(tmp_path) | |
| 57 | + handle.conn.execute(f"PRAGMA user_version = {_encoder_version(STRUCTUREL_COURANT + 1, 0)}") | |
| 58 | + handle.conn.commit() | |
| 59 | + handle.conn.close() | |
| 60 | + | |
| 61 | + with pytest.raises(VersionStructurelleNonSupporteeError): | |
| 62 | + ouvrir_ou_creer(tmp_path) | |
| 63 | + | |
| 64 | + | |
| 65 | +def test_foreign_sqlite_file_is_rejected(tmp_path: Path) -> None: | |
| 66 | + import sqlite3 | |
| 67 | + | |
| 68 | + from regine_core.archive.manifest import NOM_FICHIER_MANIFESTE | |
| 69 | + | |
| 70 | + chemin = tmp_path / NOM_FICHIER_MANIFESTE | |
| 71 | + conn = sqlite3.connect(chemin) | |
| 72 | + conn.execute("PRAGMA application_id = 999") | |
| 73 | + conn.commit() | |
| 74 | + conn.close() | |
| 75 | + | |
| 76 | + with pytest.raises(FormatManifesteInvalideError): | |
| 77 | + ouvrir_ou_creer(tmp_path) | |
| new file mode 100644 | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | +"""Tests du versionnement de schema du manifeste (T011).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +import pytest | ||
| 8 | +from regine_core.archive.manifest import ( | ||
| 9 | + APPLICATION_ID, | ||
| 10 | + STRUCTUREL_COURANT, | ||
| 11 | + FormatManifesteInvalideError, | ||
| 12 | + VersionStructurelleNonSupporteeError, | ||
| 13 | + _encoder_version, | ||
| 14 | + ouvrir_ou_creer, | ||
| 15 | +) | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +def test_bootstrap_sets_application_id_and_version(tmp_path: Path) -> None: | ||
| 19 | + handle = ouvrir_ou_creer(tmp_path) | ||
| 20 | + try: | ||
| 21 | + assert handle.conn.execute("PRAGMA application_id").fetchone()[0] == APPLICATION_ID | ||
| 22 | + finally: | ||
| 23 | + handle.conn.close() | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +def test_reopen_is_idempotent_and_preserves_data(tmp_path: Path) -> None: | ||
| 27 | + handle_1 = ouvrir_ou_creer(tmp_path) | ||
| 28 | + handle_1.conn.execute( | ||
| 29 | + "INSERT INTO fichiers (chemin_relatif, taille, hash_fichier_entier) VALUES (?, ?, ?)", | ||
| 30 | + ("raw/a.raf", 100, "abc"), | ||
| 31 | + ) | ||
| 32 | + handle_1.conn.commit() | ||
| 33 | + handle_1.conn.close() | ||
| 34 | + | ||
| 35 | + handle_2 = ouvrir_ou_creer(tmp_path) | ||
| 36 | + try: | ||
| 37 | + rows = handle_2.conn.execute("SELECT chemin_relatif FROM fichiers").fetchall() | ||
| 38 | + assert rows == [("raw/a.raf",)] | ||
| 39 | + finally: | ||
| 40 | + handle_2.conn.close() | ||
| 41 | + | ||
| 42 | + | ||
| 43 | +def test_additive_schema_evolution_is_tolerated(tmp_path: Path) -> None: | ||
| 44 | + """Une version structurelle identique mais un numero additif superieur est tolere.""" | ||
| 45 | + handle = ouvrir_ou_creer(tmp_path) | ||
| 46 | + handle.conn.execute(f"PRAGMA user_version = {_encoder_version(STRUCTUREL_COURANT, 7)}") | ||
| 47 | + handle.conn.commit() | ||
| 48 | + handle.conn.close() | ||
| 49 | + | ||
| 50 | + # Reouverture : ne doit pas lever, l'additif superieur est tolere. | ||
| 51 | + handle_2 = ouvrir_ou_creer(tmp_path) | ||
| 52 | + handle_2.conn.close() | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +def test_structural_version_mismatch_is_rejected(tmp_path: Path) -> None: | ||
| 56 | + handle = ouvrir_ou_creer(tmp_path) | ||
| 57 | + handle.conn.execute(f"PRAGMA user_version = {_encoder_version(STRUCTUREL_COURANT + 1, 0)}") | ||
| 58 | + handle.conn.commit() | ||
| 59 | + handle.conn.close() | ||
| 60 | + | ||
| 61 | + with pytest.raises(VersionStructurelleNonSupporteeError): | ||
| 62 | + ouvrir_ou_creer(tmp_path) | ||
| 63 | + | ||
| 64 | + | ||
| 65 | +def test_foreign_sqlite_file_is_rejected(tmp_path: Path) -> None: | ||
| 66 | + import sqlite3 | ||
| 67 | + | ||
| 68 | + from regine_core.archive.manifest import NOM_FICHIER_MANIFESTE | ||
| 69 | + | ||
| 70 | + chemin = tmp_path / NOM_FICHIER_MANIFESTE | ||
| 71 | + conn = sqlite3.connect(chemin) | ||
| 72 | + conn.execute("PRAGMA application_id = 999") | ||
| 73 | + conn.commit() | ||
| 74 | + conn.close() | ||
| 75 | + | ||
| 76 | + with pytest.raises(FormatManifesteInvalideError): | ||
| 77 | + ouvrir_ou_creer(tmp_path) | ||
added
packages/regine-core/tests/unit/test_reconciliation_classification.py +156 -0 | new file mode 100644 | ||
| @@ -0,0 +1,156 @@ | ||
| 1 | +"""Tests de la classification de reconciliation (T019, T024, T025, T029, T030).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import base64 | |
| 6 | +import shutil | |
| 7 | +import subprocess | |
| 8 | +from pathlib import Path | |
| 9 | + | |
| 10 | +import pytest | |
| 11 | +from regine_core.archive.checkout import checkout | |
| 12 | +from regine_core.archive.reconciliation import comparer | |
| 13 | +from regine_core.metadata.exif import close_session | |
| 14 | + | |
| 15 | +_JPEG_1X1_BASE64 = ( | |
| 16 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | |
| 17 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | |
| 18 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | |
| 19 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | |
| 20 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | |
| 21 | +) | |
| 22 | + | |
| 23 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | |
| 24 | + | |
| 25 | + | |
| 26 | +@pytest.fixture(autouse=True) | |
| 27 | +def _close_shared_session(): | |
| 28 | + yield | |
| 29 | + close_session() | |
| 30 | + | |
| 31 | + | |
| 32 | +def _categorie(rapport, chemin: str) -> str | None: | |
| 33 | + for c in rapport.changements: | |
| 34 | + if str(c.chemin) == chemin: | |
| 35 | + return c.categorie | |
| 36 | + return None | |
| 37 | + | |
| 38 | + | |
| 39 | +def test_sidecar_change_is_normal(tmp_path: Path) -> None: | |
| 40 | + dossier_archive = tmp_path / "archive" | |
| 41 | + dossier_archive.mkdir() | |
| 42 | + (dossier_archive / "photo.raf").write_bytes(b"raw-inchange") | |
| 43 | + (dossier_archive / "photo.raf.xmp").write_bytes(b"v1") | |
| 44 | + | |
| 45 | + snapshot = checkout(dossier_archive, tmp_path / "local") | |
| 46 | + (snapshot.dossier_local / "photo.raf.xmp").write_bytes(b"v2") | |
| 47 | + | |
| 48 | + rapport = comparer(snapshot, snapshot.dossier_local) | |
| 49 | + | |
| 50 | + assert _categorie(rapport, "photo.raf.xmp") == "normal" | |
| 51 | + assert rapport.anomalies == [] | |
| 52 | + snapshot.manifest.conn.close() | |
| 53 | + | |
| 54 | + | |
| 55 | +def test_raw_proprietary_content_change_is_anomaly(tmp_path: Path) -> None: | |
| 56 | + dossier_archive = tmp_path / "archive" | |
| 57 | + dossier_archive.mkdir() | |
| 58 | + (dossier_archive / "photo.raf").write_bytes(b"raw-original") | |
| 59 | + | |
| 60 | + snapshot = checkout(dossier_archive, tmp_path / "local") | |
| 61 | + (snapshot.dossier_local / "photo.raf").write_bytes(b"raw-modifie") | |
| 62 | + | |
| 63 | + rapport = comparer(snapshot, snapshot.dossier_local) | |
| 64 | + | |
| 65 | + assert _categorie(rapport, "photo.raf") == "anomalie" | |
| 66 | + assert len(rapport.anomalies) == 1 | |
| 67 | + snapshot.manifest.conn.close() | |
| 68 | + | |
| 69 | + | |
| 70 | +def test_jpeg_metadata_only_edit_is_not_anomaly(tmp_path: Path) -> None: | |
| 71 | + dossier_archive = tmp_path / "archive" | |
| 72 | + dossier_archive.mkdir() | |
| 73 | + (dossier_archive / "photo.jpg").write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | |
| 74 | + | |
| 75 | + snapshot = checkout(dossier_archive, tmp_path / "local") | |
| 76 | + chemin_local = snapshot.dossier_local / "photo.jpg" | |
| 77 | + subprocess.run( # noqa: S603, S607 | |
| 78 | + ["exiftool", "-Model=Fujifilm X100V", "-overwrite_original", str(chemin_local)], | |
| 79 | + check=True, | |
| 80 | + capture_output=True, | |
| 81 | + ) | |
| 82 | + close_session() # la session de checkout a lu le fichier avant l'édition exiftool externe | |
| 83 | + | |
| 84 | + rapport = comparer(snapshot, snapshot.dossier_local) | |
| 85 | + | |
| 86 | + assert _categorie(rapport, "photo.jpg") == "normal" | |
| 87 | + assert rapport.anomalies == [] | |
| 88 | + snapshot.manifest.conn.close() | |
| 89 | + | |
| 90 | + | |
| 91 | +def test_jpeg_pixel_change_is_anomaly(tmp_path: Path) -> None: | |
| 92 | + dossier_archive = tmp_path / "archive" | |
| 93 | + dossier_archive.mkdir() | |
| 94 | + (dossier_archive / "photo.jpg").write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | |
| 95 | + | |
| 96 | + snapshot = checkout(dossier_archive, tmp_path / "local") | |
| 97 | + # Remplace le contenu complet (simule une modification des pixels). | |
| 98 | + (snapshot.dossier_local / "photo.jpg").write_bytes(b"donnees-image-completement-differentes") | |
| 99 | + | |
| 100 | + rapport = comparer(snapshot, snapshot.dossier_local) | |
| 101 | + | |
| 102 | + assert _categorie(rapport, "photo.jpg") == "anomalie" | |
| 103 | + snapshot.manifest.conn.close() | |
| 104 | + | |
| 105 | + | |
| 106 | +def test_file_rename_detected_as_move_by_content(tmp_path: Path) -> None: | |
| 107 | + dossier_archive = tmp_path / "archive" | |
| 108 | + (dossier_archive / "jpeg").mkdir(parents=True) | |
| 109 | + (dossier_archive / "jpeg" / "photo.jpg").write_bytes(b"contenu-jpeg") | |
| 110 | + | |
| 111 | + snapshot = checkout(dossier_archive, tmp_path / "local") | |
| 112 | + ancien = snapshot.dossier_local / "jpeg" / "photo.jpg" | |
| 113 | + nouveau = snapshot.dossier_local / "photo.jpg" # promotion vers la racine | |
| 114 | + ancien.rename(nouveau) | |
| 115 | + | |
| 116 | + rapport = comparer(snapshot, snapshot.dossier_local) | |
| 117 | + | |
| 118 | + changement = next(c for c in rapport.changements if c.categorie == "deplacement") | |
| 119 | + assert str(changement.chemin) == "photo.jpg" | |
| 120 | + assert str(changement.chemin_ancien) == "jpeg/photo.jpg" | |
| 121 | + assert rapport.anomalies == [] | |
| 122 | + snapshot.manifest.conn.close() | |
| 123 | + | |
| 124 | + | |
| 125 | +def test_whole_folder_move_detected_as_move_for_every_file(tmp_path: Path) -> None: | |
| 126 | + """FR-010 : le déplacement d'un dossier entier (ex. année -> catégorie thématique, | |
| 127 | + cf. specs/004-categorisation-dossiers) n'est pas un cas à part : `comparer` ne | |
| 128 | + raisonne que par chemin relatif à `dossier_archive`/`dossier_local`, donc déplacer | |
| 129 | + plusieurs fichiers ensemble (simulant un sous-dossier entier renommé/déplacé) est | |
| 130 | + détecté par le même mécanisme que pour un seul fichier, sans code dédié. | |
| 131 | + Débloque specs/004-categorisation-dossiers T024b (déplacer effectivement le | |
| 132 | + `dossier_archive` lui-même entre répertoires racine relève de l'orchestration | |
| 133 | + d'un futur module, pas de `comparer`, qui n'a pas connaissance d'un « répertoire | |
| 134 | + racine » — seulement de chemins relatifs à l'intérieur d'un dossier).""" | |
| 135 | + dossier_archive = tmp_path / "archive" | |
| 136 | + (dossier_archive / "raw").mkdir(parents=True) | |
| 137 | + (dossier_archive / "raw" / "a.raf").write_bytes(b"contenu-a") | |
| 138 | + (dossier_archive / "raw" / "b.raf").write_bytes(b"contenu-b") | |
| 139 | + | |
| 140 | + snapshot = checkout(dossier_archive, tmp_path / "local") | |
| 141 | + | |
| 142 | + # Simule le dossier "raw" entier renommé en "raw_ancien" (déplacement de plusieurs | |
| 143 | + # fichiers d'un coup, comme le serait un sous-dossier entier relocalisé). | |
| 144 | + (snapshot.dossier_local / "raw").rename(snapshot.dossier_local / "raw_ancien") | |
| 145 | + | |
| 146 | + rapport = comparer(snapshot, snapshot.dossier_local) | |
| 147 | + | |
| 148 | + deplacements = { | |
| 149 | + c.chemin_ancien: c.chemin for c in rapport.changements if c.categorie == "deplacement" | |
| 150 | + } | |
| 151 | + assert deplacements == { | |
| 152 | + Path("raw/a.raf"): Path("raw_ancien/a.raf"), | |
| 153 | + Path("raw/b.raf"): Path("raw_ancien/b.raf"), | |
| 154 | + } | |
| 155 | + assert rapport.anomalies == [] | |
| 156 | + snapshot.manifest.conn.close() | |
| new file mode 100644 | |||
| @@ -0,0 +1,156 @@ | |||
| 1 | +"""Tests de la classification de reconciliation (T019, T024, T025, T029, T030).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import base64 | ||
| 6 | +import shutil | ||
| 7 | +import subprocess | ||
| 8 | +from pathlib import Path | ||
| 9 | + | ||
| 10 | +import pytest | ||
| 11 | +from regine_core.archive.checkout import checkout | ||
| 12 | +from regine_core.archive.reconciliation import comparer | ||
| 13 | +from regine_core.metadata.exif import close_session | ||
| 14 | + | ||
| 15 | +_JPEG_1X1_BASE64 = ( | ||
| 16 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | ||
| 17 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | ||
| 18 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | ||
| 19 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | ||
| 20 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | ||
| 21 | +) | ||
| 22 | + | ||
| 23 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +@pytest.fixture(autouse=True) | ||
| 27 | +def _close_shared_session(): | ||
| 28 | + yield | ||
| 29 | + close_session() | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +def _categorie(rapport, chemin: str) -> str | None: | ||
| 33 | + for c in rapport.changements: | ||
| 34 | + if str(c.chemin) == chemin: | ||
| 35 | + return c.categorie | ||
| 36 | + return None | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +def test_sidecar_change_is_normal(tmp_path: Path) -> None: | ||
| 40 | + dossier_archive = tmp_path / "archive" | ||
| 41 | + dossier_archive.mkdir() | ||
| 42 | + (dossier_archive / "photo.raf").write_bytes(b"raw-inchange") | ||
| 43 | + (dossier_archive / "photo.raf.xmp").write_bytes(b"v1") | ||
| 44 | + | ||
| 45 | + snapshot = checkout(dossier_archive, tmp_path / "local") | ||
| 46 | + (snapshot.dossier_local / "photo.raf.xmp").write_bytes(b"v2") | ||
| 47 | + | ||
| 48 | + rapport = comparer(snapshot, snapshot.dossier_local) | ||
| 49 | + | ||
| 50 | + assert _categorie(rapport, "photo.raf.xmp") == "normal" | ||
| 51 | + assert rapport.anomalies == [] | ||
| 52 | + snapshot.manifest.conn.close() | ||
| 53 | + | ||
| 54 | + | ||
| 55 | +def test_raw_proprietary_content_change_is_anomaly(tmp_path: Path) -> None: | ||
| 56 | + dossier_archive = tmp_path / "archive" | ||
| 57 | + dossier_archive.mkdir() | ||
| 58 | + (dossier_archive / "photo.raf").write_bytes(b"raw-original") | ||
| 59 | + | ||
| 60 | + snapshot = checkout(dossier_archive, tmp_path / "local") | ||
| 61 | + (snapshot.dossier_local / "photo.raf").write_bytes(b"raw-modifie") | ||
| 62 | + | ||
| 63 | + rapport = comparer(snapshot, snapshot.dossier_local) | ||
| 64 | + | ||
| 65 | + assert _categorie(rapport, "photo.raf") == "anomalie" | ||
| 66 | + assert len(rapport.anomalies) == 1 | ||
| 67 | + snapshot.manifest.conn.close() | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +def test_jpeg_metadata_only_edit_is_not_anomaly(tmp_path: Path) -> None: | ||
| 71 | + dossier_archive = tmp_path / "archive" | ||
| 72 | + dossier_archive.mkdir() | ||
| 73 | + (dossier_archive / "photo.jpg").write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | ||
| 74 | + | ||
| 75 | + snapshot = checkout(dossier_archive, tmp_path / "local") | ||
| 76 | + chemin_local = snapshot.dossier_local / "photo.jpg" | ||
| 77 | + subprocess.run( # noqa: S603, S607 | ||
| 78 | + ["exiftool", "-Model=Fujifilm X100V", "-overwrite_original", str(chemin_local)], | ||
| 79 | + check=True, | ||
| 80 | + capture_output=True, | ||
| 81 | + ) | ||
| 82 | + close_session() # la session de checkout a lu le fichier avant l'édition exiftool externe | ||
| 83 | + | ||
| 84 | + rapport = comparer(snapshot, snapshot.dossier_local) | ||
| 85 | + | ||
| 86 | + assert _categorie(rapport, "photo.jpg") == "normal" | ||
| 87 | + assert rapport.anomalies == [] | ||
| 88 | + snapshot.manifest.conn.close() | ||
| 89 | + | ||
| 90 | + | ||
| 91 | +def test_jpeg_pixel_change_is_anomaly(tmp_path: Path) -> None: | ||
| 92 | + dossier_archive = tmp_path / "archive" | ||
| 93 | + dossier_archive.mkdir() | ||
| 94 | + (dossier_archive / "photo.jpg").write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | ||
| 95 | + | ||
| 96 | + snapshot = checkout(dossier_archive, tmp_path / "local") | ||
| 97 | + # Remplace le contenu complet (simule une modification des pixels). | ||
| 98 | + (snapshot.dossier_local / "photo.jpg").write_bytes(b"donnees-image-completement-differentes") | ||
| 99 | + | ||
| 100 | + rapport = comparer(snapshot, snapshot.dossier_local) | ||
| 101 | + | ||
| 102 | + assert _categorie(rapport, "photo.jpg") == "anomalie" | ||
| 103 | + snapshot.manifest.conn.close() | ||
| 104 | + | ||
| 105 | + | ||
| 106 | +def test_file_rename_detected_as_move_by_content(tmp_path: Path) -> None: | ||
| 107 | + dossier_archive = tmp_path / "archive" | ||
| 108 | + (dossier_archive / "jpeg").mkdir(parents=True) | ||
| 109 | + (dossier_archive / "jpeg" / "photo.jpg").write_bytes(b"contenu-jpeg") | ||
| 110 | + | ||
| 111 | + snapshot = checkout(dossier_archive, tmp_path / "local") | ||
| 112 | + ancien = snapshot.dossier_local / "jpeg" / "photo.jpg" | ||
| 113 | + nouveau = snapshot.dossier_local / "photo.jpg" # promotion vers la racine | ||
| 114 | + ancien.rename(nouveau) | ||
| 115 | + | ||
| 116 | + rapport = comparer(snapshot, snapshot.dossier_local) | ||
| 117 | + | ||
| 118 | + changement = next(c for c in rapport.changements if c.categorie == "deplacement") | ||
| 119 | + assert str(changement.chemin) == "photo.jpg" | ||
| 120 | + assert str(changement.chemin_ancien) == "jpeg/photo.jpg" | ||
| 121 | + assert rapport.anomalies == [] | ||
| 122 | + snapshot.manifest.conn.close() | ||
| 123 | + | ||
| 124 | + | ||
| 125 | +def test_whole_folder_move_detected_as_move_for_every_file(tmp_path: Path) -> None: | ||
| 126 | + """FR-010 : le déplacement d'un dossier entier (ex. année -> catégorie thématique, | ||
| 127 | + cf. specs/004-categorisation-dossiers) n'est pas un cas à part : `comparer` ne | ||
| 128 | + raisonne que par chemin relatif à `dossier_archive`/`dossier_local`, donc déplacer | ||
| 129 | + plusieurs fichiers ensemble (simulant un sous-dossier entier renommé/déplacé) est | ||
| 130 | + détecté par le même mécanisme que pour un seul fichier, sans code dédié. | ||
| 131 | + Débloque specs/004-categorisation-dossiers T024b (déplacer effectivement le | ||
| 132 | + `dossier_archive` lui-même entre répertoires racine relève de l'orchestration | ||
| 133 | + d'un futur module, pas de `comparer`, qui n'a pas connaissance d'un « répertoire | ||
| 134 | + racine » — seulement de chemins relatifs à l'intérieur d'un dossier).""" | ||
| 135 | + dossier_archive = tmp_path / "archive" | ||
| 136 | + (dossier_archive / "raw").mkdir(parents=True) | ||
| 137 | + (dossier_archive / "raw" / "a.raf").write_bytes(b"contenu-a") | ||
| 138 | + (dossier_archive / "raw" / "b.raf").write_bytes(b"contenu-b") | ||
| 139 | + | ||
| 140 | + snapshot = checkout(dossier_archive, tmp_path / "local") | ||
| 141 | + | ||
| 142 | + # Simule le dossier "raw" entier renommé en "raw_ancien" (déplacement de plusieurs | ||
| 143 | + # fichiers d'un coup, comme le serait un sous-dossier entier relocalisé). | ||
| 144 | + (snapshot.dossier_local / "raw").rename(snapshot.dossier_local / "raw_ancien") | ||
| 145 | + | ||
| 146 | + rapport = comparer(snapshot, snapshot.dossier_local) | ||
| 147 | + | ||
| 148 | + deplacements = { | ||
| 149 | + c.chemin_ancien: c.chemin for c in rapport.changements if c.categorie == "deplacement" | ||
| 150 | + } | ||
| 151 | + assert deplacements == { | ||
| 152 | + Path("raw/a.raf"): Path("raw_ancien/a.raf"), | ||
| 153 | + Path("raw/b.raf"): Path("raw_ancien/b.raf"), | ||
| 154 | + } | ||
| 155 | + assert rapport.anomalies == [] | ||
| 156 | + snapshot.manifest.conn.close() | ||
added
packages/regine-core/tests/unit/test_verrou.py +43 -0 | new file mode 100644 | ||
| @@ -0,0 +1,43 @@ | ||
| 1 | +"""Tests du verrouillage de dossier (T013).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +import pytest | |
| 8 | +from regine_core.archive import verrou | |
| 9 | +from regine_core.archive.manifest import ouvrir_ou_creer | |
| 10 | +from regine_core.archive.verrou import DossierDejaVerrouilleError | |
| 11 | + | |
| 12 | + | |
| 13 | +@pytest.fixture | |
| 14 | +def handle(tmp_path: Path): | |
| 15 | + h = ouvrir_ou_creer(tmp_path) | |
| 16 | + yield h | |
| 17 | + h.conn.close() | |
| 18 | + | |
| 19 | + | |
| 20 | +def test_lock_starts_unlocked(handle) -> None: | |
| 21 | + assert verrou.verifier(handle) is False | |
| 22 | + | |
| 23 | + | |
| 24 | +def test_poser_locks_the_folder(handle) -> None: | |
| 25 | + verrou.poser(handle) | |
| 26 | + | |
| 27 | + assert verrou.verifier(handle) is True | |
| 28 | + | |
| 29 | + | |
| 30 | +def test_double_poser_is_refused(handle) -> None: | |
| 31 | + verrou.poser(handle) | |
| 32 | + | |
| 33 | + with pytest.raises(DossierDejaVerrouilleError): | |
| 34 | + verrou.poser(handle) | |
| 35 | + | |
| 36 | + | |
| 37 | +def test_lever_unlocks_and_allows_repose(handle) -> None: | |
| 38 | + verrou.poser(handle) | |
| 39 | + verrou.lever(handle) | |
| 40 | + | |
| 41 | + assert verrou.verifier(handle) is False | |
| 42 | + verrou.poser(handle) # ne doit pas lever | |
| 43 | + assert verrou.verifier(handle) is True | |
| new file mode 100644 | |||
| @@ -0,0 +1,43 @@ | |||
| 1 | +"""Tests du verrouillage de dossier (T013).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +import pytest | ||
| 8 | +from regine_core.archive import verrou | ||
| 9 | +from regine_core.archive.manifest import ouvrir_ou_creer | ||
| 10 | +from regine_core.archive.verrou import DossierDejaVerrouilleError | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +@pytest.fixture | ||
| 14 | +def handle(tmp_path: Path): | ||
| 15 | + h = ouvrir_ou_creer(tmp_path) | ||
| 16 | + yield h | ||
| 17 | + h.conn.close() | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +def test_lock_starts_unlocked(handle) -> None: | ||
| 21 | + assert verrou.verifier(handle) is False | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +def test_poser_locks_the_folder(handle) -> None: | ||
| 25 | + verrou.poser(handle) | ||
| 26 | + | ||
| 27 | + assert verrou.verifier(handle) is True | ||
| 28 | + | ||
| 29 | + | ||
| 30 | +def test_double_poser_is_refused(handle) -> None: | ||
| 31 | + verrou.poser(handle) | ||
| 32 | + | ||
| 33 | + with pytest.raises(DossierDejaVerrouilleError): | ||
| 34 | + verrou.poser(handle) | ||
| 35 | + | ||
| 36 | + | ||
| 37 | +def test_lever_unlocks_and_allows_repose(handle) -> None: | ||
| 38 | + verrou.poser(handle) | ||
| 39 | + verrou.lever(handle) | ||
| 40 | + | ||
| 41 | + assert verrou.verifier(handle) is False | ||
| 42 | + verrou.poser(handle) # ne doit pas lever | ||
| 43 | + assert verrou.verifier(handle) is True | ||
modified
specs/001-import-photos/tasks.md +6 -6 | @@ -108,15 +108,15 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | ||
| 108 | 108 | - [ ] T026 [P] [US3] Test unitaire de recherche de dossiers candidats par titre/date proche (`difflib`, cf. `research.md` § 4) dans `packages/regine-core/tests/unit/test_destination_candidats.py` |
| 109 | 109 | - [ ] T027 [P] [US3] Test unitaire : la branche `nouveau_sous_dossier` réutilise le `RootLocation` du parent sans nouvel appel à `determine_root`, dans `test_destination_candidats.py` |
| 110 | 110 | - [ ] T028 [US3] Test d'intégration voyage complet (parent + sous-dossier + fusion locale + désambiguïsation de boîtiers) dans `packages/regine-core/tests/integration/test_pipeline_voyage.py` |
| 111 | -- [ ] T029 [US3] Test d'intégration de la fusion vers un dossier **déjà archivé** dans `test_pipeline_voyage.py` — **dépend de l'implémentation de `specs/005-checkout-reconciliation`** (pas encore de tasks.md) ; à activer une fois celle-ci disponible, ne bloque pas le reste de cette phase | |
| 111 | +- [ ] T029 [US3] Test d'intégration de la fusion vers un dossier **déjà archivé** dans `test_pipeline_voyage.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19)**, cette tâche est activable ; ne bloque pas le reste de cette phase | |
| 112 | 112 | |
| 113 | 113 | ### Implementation for User Story 3 |
| 114 | 114 | |
| 115 | 115 | - [ ] T030 [US3] Étendre `resoudre_destination` : branches `nouveau_sous_dossier` (hérite le `RootLocation` du parent, FR-006 de specs/004) et `nouveau_parent` (FR-007), dans `destination.py` (dépend de T015) |
| 116 | 116 | - [ ] T031 [US3] Implémenter `lister_dossiers_candidats(titre_partiel, date_proche)` dans `destination.py` (FR-008, cf. `research.md` § 4) |
| 117 | 117 | - [ ] T032 [US3] Implémenter la branche `fusion` locale de `resoudre_destination` (dossier cible présent dans l'espace de travail local) dans `destination.py` (FR-009, partie locale) |
| 118 | -- [ ] T033 [US3] Implémenter la branche `fusion` vers un dossier déjà archivé : appelle `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`), dans `destination.py` — **dépend de l'implémentation de specs/005** ; sans elle, cette branche lève une erreur explicite indiquant la dépendance manquante plutôt que d'échouer silencieusement | |
| 119 | -- [ ] T034 [US3] Intégrer la désambiguïsation de boîtiers dans `copie.py` : regrouper les fichiers en collision de nom d'origine, appeler `regine_core.camera_profile.resolve_collision` (specs/002), gérer les groupes nécessitant un étiquetage manuel (FR-015/016) — **dépend de l'implémentation de specs/002** | |
| 118 | +- [ ] T033 [US3] Implémenter la branche `fusion` vers un dossier déjà archivé : appelle `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`), dans `destination.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19)**, cette tâche est activable | |
| 119 | +- [ ] T034 [US3] Intégrer la désambiguïsation de boîtiers dans `copie.py` : regrouper les fichiers en collision de nom d'origine, appeler `regine_core.camera_profile.resolve_collision` (specs/002), gérer les groupes nécessitant un étiquetage manuel (FR-015/016) — **`specs/002-profil-boitiers-optionnel` est désormais implémentée (2026-09-19)**, cette tâche est activable | |
| 120 | 120 | - [ ] T035 [US3] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour les 4 types de destination et la résolution interactive de collision de boîtiers |
| 121 | 121 | |
| 122 | 122 | **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles (la fusion vers un dossier déjà archivé reste conditionnée à `specs/005`, tout le reste est indépendant). |
| @@ -140,7 +140,7 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | ||
| 140 | 140 | - **User Stories (Phase 3–5)** : dépendent toutes de Foundational. |
| 141 | 141 | - US1 (P1) : aucune dépendance sur une autre user story de ce fichier ; dépend de `specs/004` (déjà disponible). |
| 142 | 142 | - US2 (P2) : étend directement les fonctions de US1 (T014, T023/T024) — livrable séparément mais techniquement postérieure à US1. |
| 143 | - - US3 (P3) : étend `destination.py` de US1 (T015 → T030) ; sa branche de fusion vers l'archive (T033) et sa désambiguïsation de boîtiers (T034) dépendent respectivement de `specs/005` et `specs/002`, non encore implémentées — le reste de US3 (structure parent/sous-dossier, fusion locale, recherche de candidats) est indépendant de ces deux specs. | |
| 143 | + - US3 (P3) : étend `destination.py` de US1 (T015 → T030) ; sa branche de fusion vers l'archive (T033, `specs/005`) et sa désambiguïsation de boîtiers (T034, `specs/002`) sont désormais toutes deux activables, ces deux specs étant implémentées (2026-09-19). | |
| 144 | 144 | - **Polish (Phase 6)** : dépend des user stories livrées (au minimum US1). |
| 145 | 145 | |
| 146 | 146 | ### Parallel Opportunities |
| @@ -176,11 +176,11 @@ Task: "Test unitaire construction nom dossier dans packages/regine-core/tests/un | ||
| 176 | 176 | 1. Setup + Foundational → socle prêt. |
| 177 | 177 | 2. US1 → import simple (MVP) → valider Scénario 1. |
| 178 | 178 | 3. US2 → découpage multi-jours → valider Scénario 2. |
| 179 | -4. US3 → voyage multi-étapes → valider Scénario 3, à l'exception de la fusion vers un dossier déjà archivé (T033) qui attend `specs/005-checkout-reconciliation`, et de la désambiguïsation de boîtiers (T034) qui attend `specs/002-profil-boitiers-optionnel`. | |
| 179 | +4. US3 → voyage multi-étapes → valider Scénario 3, y compris la fusion vers un dossier déjà archivé (T033) et la désambiguïsation de boîtiers (T034), toutes deux activables (`specs/005` et `specs/002` implémentées le 2026-09-19). | |
| 180 | 180 | |
| 181 | 181 | ## Notes |
| 182 | 182 | |
| 183 | 183 | - [P] = fichiers différents, sans dépendance non résolue. |
| 184 | -- Chaque user story est livrable et testable indépendamment ; seules deux tâches précises de US3 (T033, T034) restent conditionnées à l'implémentation d'autres specs déjà planifiées. | |
| 184 | +- Chaque user story est livrable et testable indépendamment ; T033/T034 (US3) dépendaient de `specs/005`/`specs/002`, toutes deux implémentées (2026-09-19) — plus aucune dépendance externe non résolue dans ce fichier. | |
| 185 | 185 | - Committer après chaque tâche ou groupe logique de tâches. |
| 186 | 186 | - Ne pas recréer le squelette monorepo ni `regine_core.dossier.root`/`regine_core.config` : ils viennent de `specs/004-categorisation-dossiers/tasks.md`, à étendre si besoin, jamais dupliquer. |
| @@ -108,15 +108,15 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | |||
| 108 | - [ ] T026 [P] [US3] Test unitaire de recherche de dossiers candidats par titre/date proche (`difflib`, cf. `research.md` § 4) dans `packages/regine-core/tests/unit/test_destination_candidats.py` | 108 | - [ ] T026 [P] [US3] Test unitaire de recherche de dossiers candidats par titre/date proche (`difflib`, cf. `research.md` § 4) dans `packages/regine-core/tests/unit/test_destination_candidats.py` |
| 109 | - [ ] T027 [P] [US3] Test unitaire : la branche `nouveau_sous_dossier` réutilise le `RootLocation` du parent sans nouvel appel à `determine_root`, dans `test_destination_candidats.py` | 109 | - [ ] T027 [P] [US3] Test unitaire : la branche `nouveau_sous_dossier` réutilise le `RootLocation` du parent sans nouvel appel à `determine_root`, dans `test_destination_candidats.py` |
| 110 | - [ ] T028 [US3] Test d'intégration voyage complet (parent + sous-dossier + fusion locale + désambiguïsation de boîtiers) dans `packages/regine-core/tests/integration/test_pipeline_voyage.py` | 110 | - [ ] T028 [US3] Test d'intégration voyage complet (parent + sous-dossier + fusion locale + désambiguïsation de boîtiers) dans `packages/regine-core/tests/integration/test_pipeline_voyage.py` |
| 111 | -- [ ] T029 [US3] Test d'intégration de la fusion vers un dossier **déjà archivé** dans `test_pipeline_voyage.py` — **dépend de l'implémentation de `specs/005-checkout-reconciliation`** (pas encore de tasks.md) ; à activer une fois celle-ci disponible, ne bloque pas le reste de cette phase | 111 | +- [ ] T029 [US3] Test d'intégration de la fusion vers un dossier **déjà archivé** dans `test_pipeline_voyage.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19)**, cette tâche est activable ; ne bloque pas le reste de cette phase |
| 112 | 112 | ||
| 113 | ### Implementation for User Story 3 | 113 | ### Implementation for User Story 3 |
| 114 | 114 | ||
| 115 | - [ ] T030 [US3] Étendre `resoudre_destination` : branches `nouveau_sous_dossier` (hérite le `RootLocation` du parent, FR-006 de specs/004) et `nouveau_parent` (FR-007), dans `destination.py` (dépend de T015) | 115 | - [ ] T030 [US3] Étendre `resoudre_destination` : branches `nouveau_sous_dossier` (hérite le `RootLocation` du parent, FR-006 de specs/004) et `nouveau_parent` (FR-007), dans `destination.py` (dépend de T015) |
| 116 | - [ ] T031 [US3] Implémenter `lister_dossiers_candidats(titre_partiel, date_proche)` dans `destination.py` (FR-008, cf. `research.md` § 4) | 116 | - [ ] T031 [US3] Implémenter `lister_dossiers_candidats(titre_partiel, date_proche)` dans `destination.py` (FR-008, cf. `research.md` § 4) |
| 117 | - [ ] T032 [US3] Implémenter la branche `fusion` locale de `resoudre_destination` (dossier cible présent dans l'espace de travail local) dans `destination.py` (FR-009, partie locale) | 117 | - [ ] T032 [US3] Implémenter la branche `fusion` locale de `resoudre_destination` (dossier cible présent dans l'espace de travail local) dans `destination.py` (FR-009, partie locale) |
| 118 | -- [ ] T033 [US3] Implémenter la branche `fusion` vers un dossier déjà archivé : appelle `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`), dans `destination.py` — **dépend de l'implémentation de specs/005** ; sans elle, cette branche lève une erreur explicite indiquant la dépendance manquante plutôt que d'échouer silencieusement | 118 | +- [ ] T033 [US3] Implémenter la branche `fusion` vers un dossier déjà archivé : appelle `regine_core.archive.checkout.checkout` (`specs/005-checkout-reconciliation`), dans `destination.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19)**, cette tâche est activable |
| 119 | -- [ ] T034 [US3] Intégrer la désambiguïsation de boîtiers dans `copie.py` : regrouper les fichiers en collision de nom d'origine, appeler `regine_core.camera_profile.resolve_collision` (specs/002), gérer les groupes nécessitant un étiquetage manuel (FR-015/016) — **dépend de l'implémentation de specs/002** | 119 | +- [ ] T034 [US3] Intégrer la désambiguïsation de boîtiers dans `copie.py` : regrouper les fichiers en collision de nom d'origine, appeler `regine_core.camera_profile.resolve_collision` (specs/002), gérer les groupes nécessitant un étiquetage manuel (FR-015/016) — **`specs/002-profil-boitiers-optionnel` est désormais implémentée (2026-09-19)**, cette tâche est activable |
| 120 | - [ ] T035 [US3] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour les 4 types de destination et la résolution interactive de collision de boîtiers | 120 | - [ ] T035 [US3] Étendre `packages/regine-cli/src/regine_cli/import_cmd.py` pour les 4 types de destination et la résolution interactive de collision de boîtiers |
| 121 | 121 | ||
| 122 | **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles (la fusion vers un dossier déjà archivé reste conditionnée à `specs/005`, tout le reste est indépendant). | 122 | **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles (la fusion vers un dossier déjà archivé reste conditionnée à `specs/005`, tout le reste est indépendant). |
| @@ -140,7 +140,7 @@ Ce module est le premier point d'intégration réel de trois autres specs déjà | |||
| 140 | - **User Stories (Phase 3–5)** : dépendent toutes de Foundational. | 140 | - **User Stories (Phase 3–5)** : dépendent toutes de Foundational. |
| 141 | - US1 (P1) : aucune dépendance sur une autre user story de ce fichier ; dépend de `specs/004` (déjà disponible). | 141 | - US1 (P1) : aucune dépendance sur une autre user story de ce fichier ; dépend de `specs/004` (déjà disponible). |
| 142 | - US2 (P2) : étend directement les fonctions de US1 (T014, T023/T024) — livrable séparément mais techniquement postérieure à US1. | 142 | - US2 (P2) : étend directement les fonctions de US1 (T014, T023/T024) — livrable séparément mais techniquement postérieure à US1. |
| 143 | - - US3 (P3) : étend `destination.py` de US1 (T015 → T030) ; sa branche de fusion vers l'archive (T033) et sa désambiguïsation de boîtiers (T034) dépendent respectivement de `specs/005` et `specs/002`, non encore implémentées — le reste de US3 (structure parent/sous-dossier, fusion locale, recherche de candidats) est indépendant de ces deux specs. | 143 | + - US3 (P3) : étend `destination.py` de US1 (T015 → T030) ; sa branche de fusion vers l'archive (T033, `specs/005`) et sa désambiguïsation de boîtiers (T034, `specs/002`) sont désormais toutes deux activables, ces deux specs étant implémentées (2026-09-19). |
| 144 | - **Polish (Phase 6)** : dépend des user stories livrées (au minimum US1). | 144 | - **Polish (Phase 6)** : dépend des user stories livrées (au minimum US1). |
| 145 | 145 | ||
| 146 | ### Parallel Opportunities | 146 | ### Parallel Opportunities |
| @@ -176,11 +176,11 @@ Task: "Test unitaire construction nom dossier dans packages/regine-core/tests/un | |||
| 176 | 1. Setup + Foundational → socle prêt. | 176 | 1. Setup + Foundational → socle prêt. |
| 177 | 2. US1 → import simple (MVP) → valider Scénario 1. | 177 | 2. US1 → import simple (MVP) → valider Scénario 1. |
| 178 | 3. US2 → découpage multi-jours → valider Scénario 2. | 178 | 3. US2 → découpage multi-jours → valider Scénario 2. |
| 179 | -4. US3 → voyage multi-étapes → valider Scénario 3, à l'exception de la fusion vers un dossier déjà archivé (T033) qui attend `specs/005-checkout-reconciliation`, et de la désambiguïsation de boîtiers (T034) qui attend `specs/002-profil-boitiers-optionnel`. | 179 | +4. US3 → voyage multi-étapes → valider Scénario 3, y compris la fusion vers un dossier déjà archivé (T033) et la désambiguïsation de boîtiers (T034), toutes deux activables (`specs/005` et `specs/002` implémentées le 2026-09-19). |
| 180 | 180 | ||
| 181 | ## Notes | 181 | ## Notes |
| 182 | 182 | ||
| 183 | - [P] = fichiers différents, sans dépendance non résolue. | 183 | - [P] = fichiers différents, sans dépendance non résolue. |
| 184 | -- Chaque user story est livrable et testable indépendamment ; seules deux tâches précises de US3 (T033, T034) restent conditionnées à l'implémentation d'autres specs déjà planifiées. | 184 | +- Chaque user story est livrable et testable indépendamment ; T033/T034 (US3) dépendaient de `specs/005`/`specs/002`, toutes deux implémentées (2026-09-19) — plus aucune dépendance externe non résolue dans ce fichier. |
| 185 | - Committer après chaque tâche ou groupe logique de tâches. | 185 | - Committer après chaque tâche ou groupe logique de tâches. |
| 186 | - Ne pas recréer le squelette monorepo ni `regine_core.dossier.root`/`regine_core.config` : ils viennent de `specs/004-categorisation-dossiers/tasks.md`, à étendre si besoin, jamais dupliquer. | 186 | - Ne pas recréer le squelette monorepo ni `regine_core.dossier.root`/`regine_core.config` : ils viennent de `specs/004-categorisation-dossiers/tasks.md`, à étendre si besoin, jamais dupliquer. |
modified
specs/004-categorisation-dossiers/tasks.md +1 -1 | @@ -114,7 +114,7 @@ Aucun code n'existe encore dans ce dépôt (seuls `docs/`, `specs/`, `.specify/` | ||
| 114 | 114 | **Statut** : **Mise à jour du 2026-09-19** — le mécanisme de détection de déplacement par somme de contrôle a désormais sa propre spec et son propre plan (`specs/005-checkout-reconciliation`, dont FR-010 couvre exactement ce cas). Ce n'est plus une dépendance bloquante au niveau spécification : seulement une dépendance d'ordonnancement (l'implémentation de `specs/005` doit précéder la validation de bout en bout de cette user story). |
| 115 | 115 | |
| 116 | 116 | - [X] T024 [US4] Documenter (docstring) dans `packages/regine-core/src/regine_core/dossier/root.py` qu'il n'existe aucune opération d'écriture directe sur `RootLocation` : tout changement de répertoire racine passe exclusivement par `regine_core.archive.reconciliation.comparer` (`specs/005-checkout-reconciliation`), qui classe un déplacement de dossier entier comme `deplacement` sans code supplémentaire ici |
| 117 | -- [ ] T024b [US4] Test d'intégration (à exécuter une fois `specs/005-checkout-reconciliation` implémentée) : déplacer un dossier de travail entre deux répertoires racine (année → catégorie) et vérifier que `regine_core.archive.reconciliation.comparer` le classe `deplacement`, dans `packages/regine-core/tests/integration/test_root_recategorisation.py` — **toujours en attente, `specs/005` pas encore implémentée** | |
| 117 | +- [ ] T024b [US4] Test d'intégration : déplacer un dossier de travail entre deux répertoires racine (année → catégorie) et vérifier que `regine_core.archive.reconciliation.comparer` le classe `deplacement`, dans `packages/regine-core/tests/integration/test_root_recategorisation.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19) ; `comparer` classe déjà ce cas (validé par `test_whole_folder_move_detected_as_move_for_every_file` côté specs/005) ; cette tâche reste à exécuter pour ajouter le test explicitement côté specs/004** | |
| 118 | 118 | |
| 119 | 119 | **Checkpoint**: US4 documentée et prête à être validée dès que `specs/005-checkout-reconciliation` est implémentée — plus aucune spécification manquante. |
| 120 | 120 | |
| @@ -114,7 +114,7 @@ Aucun code n'existe encore dans ce dépôt (seuls `docs/`, `specs/`, `.specify/` | |||
| 114 | **Statut** : **Mise à jour du 2026-09-19** — le mécanisme de détection de déplacement par somme de contrôle a désormais sa propre spec et son propre plan (`specs/005-checkout-reconciliation`, dont FR-010 couvre exactement ce cas). Ce n'est plus une dépendance bloquante au niveau spécification : seulement une dépendance d'ordonnancement (l'implémentation de `specs/005` doit précéder la validation de bout en bout de cette user story). | 114 | **Statut** : **Mise à jour du 2026-09-19** — le mécanisme de détection de déplacement par somme de contrôle a désormais sa propre spec et son propre plan (`specs/005-checkout-reconciliation`, dont FR-010 couvre exactement ce cas). Ce n'est plus une dépendance bloquante au niveau spécification : seulement une dépendance d'ordonnancement (l'implémentation de `specs/005` doit précéder la validation de bout en bout de cette user story). |
| 115 | 115 | ||
| 116 | - [X] T024 [US4] Documenter (docstring) dans `packages/regine-core/src/regine_core/dossier/root.py` qu'il n'existe aucune opération d'écriture directe sur `RootLocation` : tout changement de répertoire racine passe exclusivement par `regine_core.archive.reconciliation.comparer` (`specs/005-checkout-reconciliation`), qui classe un déplacement de dossier entier comme `deplacement` sans code supplémentaire ici | 116 | - [X] T024 [US4] Documenter (docstring) dans `packages/regine-core/src/regine_core/dossier/root.py` qu'il n'existe aucune opération d'écriture directe sur `RootLocation` : tout changement de répertoire racine passe exclusivement par `regine_core.archive.reconciliation.comparer` (`specs/005-checkout-reconciliation`), qui classe un déplacement de dossier entier comme `deplacement` sans code supplémentaire ici |
| 117 | -- [ ] T024b [US4] Test d'intégration (à exécuter une fois `specs/005-checkout-reconciliation` implémentée) : déplacer un dossier de travail entre deux répertoires racine (année → catégorie) et vérifier que `regine_core.archive.reconciliation.comparer` le classe `deplacement`, dans `packages/regine-core/tests/integration/test_root_recategorisation.py` — **toujours en attente, `specs/005` pas encore implémentée** | 117 | +- [ ] T024b [US4] Test d'intégration : déplacer un dossier de travail entre deux répertoires racine (année → catégorie) et vérifier que `regine_core.archive.reconciliation.comparer` le classe `deplacement`, dans `packages/regine-core/tests/integration/test_root_recategorisation.py` — **`specs/005-checkout-reconciliation` est désormais implémentée (2026-09-19) ; `comparer` classe déjà ce cas (validé par `test_whole_folder_move_detected_as_move_for_every_file` côté specs/005) ; cette tâche reste à exécuter pour ajouter le test explicitement côté specs/004** |
| 118 | 118 | ||
| 119 | **Checkpoint**: US4 documentée et prête à être validée dès que `specs/005-checkout-reconciliation` est implémentée — plus aucune spécification manquante. | 119 | **Checkpoint**: US4 documentée et prête à être validée dès que `specs/005-checkout-reconciliation` est implémentée — plus aucune spécification manquante. |
| 120 | 120 | ||
modified
specs/005-checkout-reconciliation/contracts/regine-core-api.md +18 -12 | @@ -1,6 +1,6 @@ | ||
| 1 | 1 | # Contrat d'API interne : `regine_core.integrity` / `regine_core.archive` |
| 2 | 2 | |
| 3 | -Objets structurés, jamais de texte à parser (Principe VI). Consommée par `regine-cli` (`contracts/cli-checkout-reconcile.md`) et, plus tard, par le point d'intégration de `specs/001-import-photos` (FR-009) et `specs/004-categorisation-dossiers` (User Story 4). | |
| 3 | +Objets structurés, jamais de texte à parser (Principe VI). Consommée par `regine-cli` (`contracts/cli-checkout-reconcile.md`) et, une fois leurs propres tâches exécutées, par `specs/001-import-photos` (FR-009) et `specs/004-categorisation-dossiers` (User Story 4) — cf. § Point d'intégration ci-dessous. | |
| 4 | 4 | |
| 5 | 5 | ## `regine_core.integrity.hash.hash_fichier_entier(chemin: Path) -> str` |
| 6 | 6 | |
| @@ -10,13 +10,17 @@ SHA-256 du fichier entier (stdlib `hashlib`). | ||
| 10 | 10 | |
| 11 | 11 | `None` si le format n'est pas DNG/TIFF/JPEG. Sinon, délègue à `regine_core.metadata.exif.read_image_data_hash` (cf. research.md § 5). |
| 12 | 12 | |
| 13 | -## `regine_core.integrity.anomalie.est_maitre_modifie(format: str, ref: FichierManifeste, actuel: FichierManifeste) -> bool` | |
| 13 | +## `regine_core.integrity.anomalie.est_maitre_modifie(extension: str, ref: Empreinte, actuel: Empreinte) -> bool` | |
| 14 | 14 | |
| 15 | -Implémente le Principe I : pour un RAW propriétaire, compare `hash_fichier_entier` seul. Pour DNG/TIFF/JPEG, compare `hash_image_only` (si `None` des deux côtés, replie sur `hash_fichier_entier`). Retourne `True` uniquement si le contenu pertinent a changé. | |
| 15 | +**Signature finale (2026-09-19)** : prend directement deux `Empreinte` (`hash_fichier_entier`, `hash_image_only`) plutôt qu'un type `FichierManifeste` générique — plus simple à tester unitairement, sans dépendre du manifeste. Implémente le Principe I : pour un RAW propriétaire, compare `hash_fichier_entier` seul. Pour DNG/TIFF/JPEG, compare `hash_image_only` (si `None` des deux côtés, replie sur `hash_fichier_entier`). Retourne `True` uniquement si le contenu pertinent a changé. | |
| 16 | 16 | |
| 17 | 17 | ## `regine_core.archive.manifest.ouvrir_ou_creer(dossier: Path) -> ManifestHandle` |
| 18 | 18 | |
| 19 | -Ouvre le manifeste SQLite à la racine de `dossier` (le crée si absent — premier checkout). Vérifie `PRAGMA user_version` (cf. research.md § 2) ; lève une erreur explicite si la version structurelle du manifeste est plus récente que ce que le code sait lire. | |
| 19 | +Ouvre le manifeste SQLite à la racine de `dossier` (le crée si absent — premier checkout). Nom de fichier : `.regine-manifest.sqlite3`. Vérifie `PRAGMA user_version` (cf. research.md § 2) ; lève `VersionStructurelleNonSupporteeError` si la version structurelle du manifeste est plus récente que ce que le code sait lire, ou `FormatManifesteInvalideError` si le fichier n'est pas un manifeste Régine (`application_id` différent). | |
| 20 | + | |
| 21 | +## Classification de réconciliation : distinction sidecar / fichier maître (précision 2026-09-19) | |
| 22 | + | |
| 23 | +`comparer()` exempte de toute détection d'anomalie les fichiers dont l'extension est `xmp`, `dop` ou `acr` (`EXTENSIONS_SIDECAR` dans `reconciliation.py`) — un changement sur ces extensions est toujours classé `normal`. Tout autre fichier dont le hash a changé passe par `est_maitre_modifie`. **Limite connue, documentée plutôt que masquée** : un export dérivé archivé volontairement (cf. specs/001-import-photos, cas « nouveau fichier → archiver aussi ») porte généralement une extension image (ex. `.jpg`) et serait donc soumis à la même détection d'anomalie qu'un véritable fichier maître si son contenu change après coup — le manifeste ne distingue pas aujourd'hui un fichier maître d'un dérivé archivé par choix. Cette distinction plus fine relève d'un futur raffinement (ex. un champ de rôle dans le manifeste), pas de ce plan. | |
| 20 | 24 | |
| 21 | 25 | ## `regine_core.archive.verrou.poser(manifest: ManifestHandle) -> None` / `verifier(manifest) -> bool` / `lever(manifest) -> None` |
| 22 | 26 | |
| @@ -26,17 +30,19 @@ Gèrent la table `verrou` (FR-003/004/016). `poser` échoue (exception dédiée) | ||
| 26 | 30 | |
| 27 | 31 | Copie vérifiée (FR-001/015/019/020), pose le verrou, retourne le snapshot du manifeste au moment du checkout. Lève une exception dédiée si le dossier est déjà verrouillé (FR-004). |
| 28 | 32 | |
| 29 | -## `regine_core.archive.reconciliation.comparer(manifest: ManifestSnapshot, copie_locale: Path) -> RapportReconciliation` | |
| 33 | +## `regine_core.archive.reconciliation.comparer(snapshot: ManifestSnapshot, copie_locale: Path) -> RapportReconciliation` | |
| 34 | + | |
| 35 | +**Signature finale** : prend un `ManifestSnapshot` complet (pas seulement un `ManifestHandle`) — nécessaire pour connaître `dossier_archive`, `dossier_local` et `formats` (périmètre d'un checkout partiel, FR-020). Implémente FR-006/007/008/009/010/011/012 : construit les index hash→chemin (manifeste et copie locale, cf. research.md § 4), classe chaque fichier en `normal`/`anomalie`/`deplacement`/`suppression`/`nouveau`. Un fichier du manifeste hors du périmètre d'un checkout partiel (`regine_core.archive.checkout.dans_perimetre`) est silencieusement exclu de la comparaison — jamais signalé comme supprimé. | |
| 30 | 36 | |
| 31 | -Implémente FR-006/007/008/009/010/011/012 : construit les index hash→chemin (manifeste et copie locale, cf. research.md § 4), classe chaque fichier en `normal`/`anomalie`/`deplacement`/`suppression`/`nouveau`. | |
| 37 | +## `regine_core.archive.reconciliation.archiver(snapshot: ManifestSnapshot, rapport: RapportReconciliation, decisions: DecisionsUtilisateur) -> None` | |
| 32 | 38 | |
| 33 | -## `regine_core.archive.reconciliation.archiver(dossier_archive: Path, rapport: RapportReconciliation, decisions: DecisionsUtilisateur) -> None` | |
| 39 | +**Signature finale** : prend `snapshot` (pas `dossier_archive` seul), pour la même raison que `comparer`. `DecisionsUtilisateur` regroupe une confirmation globale pour les changements normaux/déplacements/suppressions (`confirmer_changements_normaux`), une résolution par anomalie (`resolutions_anomalies: dict[chemin, "confirmer"|"restaurer"]`), et un ensemble de nouveaux fichiers à archiver (`nouveaux_fichiers_a_archiver`). Écrit sur l'archive uniquement les changements couverts par ces décisions (FR-013/014), vérifie chaque transfert (FR-015), met à jour le manifeste et lève le verrou uniquement si tous les changements du rapport ont été traités (confirmés, restaurés, ou nouveaux fichiers explicitement laissés en local) — une anomalie non résolue conserve le verrou (FR-016), cf. `test_anomaly_decision_does_not_block_already_confirmed_normal_changes`. | |
| 34 | 40 | |
| 35 | -Écrit sur l'archive uniquement les changements couverts par `decisions` (FR-013/014), vérifie chaque transfert (FR-015), met à jour le manifeste et lève le verrou une fois toutes les écritures de la session terminées (FR-016). Ne DOIT jamais être appelée sans qu'un « point avant archive » ait été présenté et confirmé côté appelant. | |
| 41 | +## Point d'intégration côté façades — désormais implémenté (mise à jour 2026-09-19) | |
| 36 | 42 | |
| 37 | -## Point d'intégration côté façades (futur, hors périmètre de ce plan) | |
| 43 | +- `specs/001-import-photos` FR-009 (fusion vers un dossier déjà archivé) DEVRA appeler `checkout` sur le dossier ciblé avant d'y intégrer les nouveaux fichiers — cf. `specs/001-import-photos/tasks.md` T033, désormais implémentable (dépendance levée). | |
| 44 | +- `specs/004-categorisation-dossiers` User Story 4 (recatégorisation a posteriori) s'appuie sur `comparer`, qui classe déjà un déplacement de plusieurs fichiers (simulant un dossier entier) comme `deplacement`, sans code supplémentaire côté `specs/004` — cf. `specs/004-categorisation-dossiers/tasks.md` T024b, désormais implémentable. **Limite à noter** : `comparer` raisonne uniquement par chemin relatif à l'intérieur d'un `dossier_archive` fixe ; un déplacement du dossier archive lui-même entre deux répertoires racine (ex. `2026/` → `mariage/`) reste une opération d'orchestration au niveau du futur module `import`/`dossier` (checkout depuis l'ancien emplacement, réarchivage vers le nouveau), pas quelque chose que `comparer` détecte tout seul en comparant deux chemins absolus différents. | |
| 38 | 45 | |
| 39 | -- `specs/001-import-photos` FR-009 (fusion vers un dossier déjà archivé) DEVRA appeler `checkout` sur le dossier ciblé avant d'y intégrer les nouveaux fichiers, plutôt que de lever `ChecoutNonDisponibleError` comme actuellement documenté dans son plan. | |
| 40 | -- `specs/004-categorisation-dossiers` User Story 4 (recatégorisation a posteriori) DEVRA s'appuyer sur `comparer` : un dossier déplacé entre répertoires racine y est déjà classé `deplacement` (FR-010 de cette spec), sans code supplémentaire à écrire côté `specs/004`. | |
| 46 | +## Bug réel trouvé et corrigé en cours d'implémentation (2026-09-19) | |
| 41 | 47 | |
| 42 | -Ces deux points d'intégration ne sont pas implémentés par ce plan ; ils sont documentés ici pour que leur future implémentation consomme cette API sans la redéfinir. | |
| 48 | +`archive_cmd._cmd_reconcile` retournait sans appeler `verrou.lever()` quand la réconciliation ne trouvait aucun changement (`rapport.changements` vide), laissant le dossier verrouillé indéfiniment après une réconciliation « à vide ». Corrigé : le verrou est désormais levé dans ce cas aussi. Trouvé par le test d'intégration T032 (`test_checkout_accepted_again_after_reconciliation`). | |
| @@ -1,6 +1,6 @@ | |||
| 1 | # Contrat d'API interne : `regine_core.integrity` / `regine_core.archive` | 1 | # Contrat d'API interne : `regine_core.integrity` / `regine_core.archive` |
| 2 | 2 | ||
| 3 | -Objets structurés, jamais de texte à parser (Principe VI). Consommée par `regine-cli` (`contracts/cli-checkout-reconcile.md`) et, plus tard, par le point d'intégration de `specs/001-import-photos` (FR-009) et `specs/004-categorisation-dossiers` (User Story 4). | 3 | +Objets structurés, jamais de texte à parser (Principe VI). Consommée par `regine-cli` (`contracts/cli-checkout-reconcile.md`) et, une fois leurs propres tâches exécutées, par `specs/001-import-photos` (FR-009) et `specs/004-categorisation-dossiers` (User Story 4) — cf. § Point d'intégration ci-dessous. |
| 4 | 4 | ||
| 5 | ## `regine_core.integrity.hash.hash_fichier_entier(chemin: Path) -> str` | 5 | ## `regine_core.integrity.hash.hash_fichier_entier(chemin: Path) -> str` |
| 6 | 6 | ||
| @@ -10,13 +10,17 @@ SHA-256 du fichier entier (stdlib `hashlib`). | |||
| 10 | 10 | ||
| 11 | `None` si le format n'est pas DNG/TIFF/JPEG. Sinon, délègue à `regine_core.metadata.exif.read_image_data_hash` (cf. research.md § 5). | 11 | `None` si le format n'est pas DNG/TIFF/JPEG. Sinon, délègue à `regine_core.metadata.exif.read_image_data_hash` (cf. research.md § 5). |
| 12 | 12 | ||
| 13 | -## `regine_core.integrity.anomalie.est_maitre_modifie(format: str, ref: FichierManifeste, actuel: FichierManifeste) -> bool` | 13 | +## `regine_core.integrity.anomalie.est_maitre_modifie(extension: str, ref: Empreinte, actuel: Empreinte) -> bool` |
| 14 | 14 | ||
| 15 | -Implémente le Principe I : pour un RAW propriétaire, compare `hash_fichier_entier` seul. Pour DNG/TIFF/JPEG, compare `hash_image_only` (si `None` des deux côtés, replie sur `hash_fichier_entier`). Retourne `True` uniquement si le contenu pertinent a changé. | 15 | +**Signature finale (2026-09-19)** : prend directement deux `Empreinte` (`hash_fichier_entier`, `hash_image_only`) plutôt qu'un type `FichierManifeste` générique — plus simple à tester unitairement, sans dépendre du manifeste. Implémente le Principe I : pour un RAW propriétaire, compare `hash_fichier_entier` seul. Pour DNG/TIFF/JPEG, compare `hash_image_only` (si `None` des deux côtés, replie sur `hash_fichier_entier`). Retourne `True` uniquement si le contenu pertinent a changé. |
| 16 | 16 | ||
| 17 | ## `regine_core.archive.manifest.ouvrir_ou_creer(dossier: Path) -> ManifestHandle` | 17 | ## `regine_core.archive.manifest.ouvrir_ou_creer(dossier: Path) -> ManifestHandle` |
| 18 | 18 | ||
| 19 | -Ouvre le manifeste SQLite à la racine de `dossier` (le crée si absent — premier checkout). Vérifie `PRAGMA user_version` (cf. research.md § 2) ; lève une erreur explicite si la version structurelle du manifeste est plus récente que ce que le code sait lire. | 19 | +Ouvre le manifeste SQLite à la racine de `dossier` (le crée si absent — premier checkout). Nom de fichier : `.regine-manifest.sqlite3`. Vérifie `PRAGMA user_version` (cf. research.md § 2) ; lève `VersionStructurelleNonSupporteeError` si la version structurelle du manifeste est plus récente que ce que le code sait lire, ou `FormatManifesteInvalideError` si le fichier n'est pas un manifeste Régine (`application_id` différent). |
| 20 | + | ||
| 21 | +## Classification de réconciliation : distinction sidecar / fichier maître (précision 2026-09-19) | ||
| 22 | + | ||
| 23 | +`comparer()` exempte de toute détection d'anomalie les fichiers dont l'extension est `xmp`, `dop` ou `acr` (`EXTENSIONS_SIDECAR` dans `reconciliation.py`) — un changement sur ces extensions est toujours classé `normal`. Tout autre fichier dont le hash a changé passe par `est_maitre_modifie`. **Limite connue, documentée plutôt que masquée** : un export dérivé archivé volontairement (cf. specs/001-import-photos, cas « nouveau fichier → archiver aussi ») porte généralement une extension image (ex. `.jpg`) et serait donc soumis à la même détection d'anomalie qu'un véritable fichier maître si son contenu change après coup — le manifeste ne distingue pas aujourd'hui un fichier maître d'un dérivé archivé par choix. Cette distinction plus fine relève d'un futur raffinement (ex. un champ de rôle dans le manifeste), pas de ce plan. | ||
| 20 | 24 | ||
| 21 | ## `regine_core.archive.verrou.poser(manifest: ManifestHandle) -> None` / `verifier(manifest) -> bool` / `lever(manifest) -> None` | 25 | ## `regine_core.archive.verrou.poser(manifest: ManifestHandle) -> None` / `verifier(manifest) -> bool` / `lever(manifest) -> None` |
| 22 | 26 | ||
| @@ -26,17 +30,19 @@ Gèrent la table `verrou` (FR-003/004/016). `poser` échoue (exception dédiée) | |||
| 26 | 30 | ||
| 27 | Copie vérifiée (FR-001/015/019/020), pose le verrou, retourne le snapshot du manifeste au moment du checkout. Lève une exception dédiée si le dossier est déjà verrouillé (FR-004). | 31 | Copie vérifiée (FR-001/015/019/020), pose le verrou, retourne le snapshot du manifeste au moment du checkout. Lève une exception dédiée si le dossier est déjà verrouillé (FR-004). |
| 28 | 32 | ||
| 29 | -## `regine_core.archive.reconciliation.comparer(manifest: ManifestSnapshot, copie_locale: Path) -> RapportReconciliation` | 33 | +## `regine_core.archive.reconciliation.comparer(snapshot: ManifestSnapshot, copie_locale: Path) -> RapportReconciliation` |
| 34 | + | ||
| 35 | +**Signature finale** : prend un `ManifestSnapshot` complet (pas seulement un `ManifestHandle`) — nécessaire pour connaître `dossier_archive`, `dossier_local` et `formats` (périmètre d'un checkout partiel, FR-020). Implémente FR-006/007/008/009/010/011/012 : construit les index hash→chemin (manifeste et copie locale, cf. research.md § 4), classe chaque fichier en `normal`/`anomalie`/`deplacement`/`suppression`/`nouveau`. Un fichier du manifeste hors du périmètre d'un checkout partiel (`regine_core.archive.checkout.dans_perimetre`) est silencieusement exclu de la comparaison — jamais signalé comme supprimé. | ||
| 30 | 36 | ||
| 31 | -Implémente FR-006/007/008/009/010/011/012 : construit les index hash→chemin (manifeste et copie locale, cf. research.md § 4), classe chaque fichier en `normal`/`anomalie`/`deplacement`/`suppression`/`nouveau`. | 37 | +## `regine_core.archive.reconciliation.archiver(snapshot: ManifestSnapshot, rapport: RapportReconciliation, decisions: DecisionsUtilisateur) -> None` |
| 32 | 38 | ||
| 33 | -## `regine_core.archive.reconciliation.archiver(dossier_archive: Path, rapport: RapportReconciliation, decisions: DecisionsUtilisateur) -> None` | 39 | +**Signature finale** : prend `snapshot` (pas `dossier_archive` seul), pour la même raison que `comparer`. `DecisionsUtilisateur` regroupe une confirmation globale pour les changements normaux/déplacements/suppressions (`confirmer_changements_normaux`), une résolution par anomalie (`resolutions_anomalies: dict[chemin, "confirmer"|"restaurer"]`), et un ensemble de nouveaux fichiers à archiver (`nouveaux_fichiers_a_archiver`). Écrit sur l'archive uniquement les changements couverts par ces décisions (FR-013/014), vérifie chaque transfert (FR-015), met à jour le manifeste et lève le verrou uniquement si tous les changements du rapport ont été traités (confirmés, restaurés, ou nouveaux fichiers explicitement laissés en local) — une anomalie non résolue conserve le verrou (FR-016), cf. `test_anomaly_decision_does_not_block_already_confirmed_normal_changes`. |
| 34 | 40 | ||
| 35 | -Écrit sur l'archive uniquement les changements couverts par `decisions` (FR-013/014), vérifie chaque transfert (FR-015), met à jour le manifeste et lève le verrou une fois toutes les écritures de la session terminées (FR-016). Ne DOIT jamais être appelée sans qu'un « point avant archive » ait été présenté et confirmé côté appelant. | 41 | +## Point d'intégration côté façades — désormais implémenté (mise à jour 2026-09-19) |
| 36 | 42 | ||
| 37 | -## Point d'intégration côté façades (futur, hors périmètre de ce plan) | 43 | +- `specs/001-import-photos` FR-009 (fusion vers un dossier déjà archivé) DEVRA appeler `checkout` sur le dossier ciblé avant d'y intégrer les nouveaux fichiers — cf. `specs/001-import-photos/tasks.md` T033, désormais implémentable (dépendance levée). |
| 44 | +- `specs/004-categorisation-dossiers` User Story 4 (recatégorisation a posteriori) s'appuie sur `comparer`, qui classe déjà un déplacement de plusieurs fichiers (simulant un dossier entier) comme `deplacement`, sans code supplémentaire côté `specs/004` — cf. `specs/004-categorisation-dossiers/tasks.md` T024b, désormais implémentable. **Limite à noter** : `comparer` raisonne uniquement par chemin relatif à l'intérieur d'un `dossier_archive` fixe ; un déplacement du dossier archive lui-même entre deux répertoires racine (ex. `2026/` → `mariage/`) reste une opération d'orchestration au niveau du futur module `import`/`dossier` (checkout depuis l'ancien emplacement, réarchivage vers le nouveau), pas quelque chose que `comparer` détecte tout seul en comparant deux chemins absolus différents. | ||
| 38 | 45 | ||
| 39 | -- `specs/001-import-photos` FR-009 (fusion vers un dossier déjà archivé) DEVRA appeler `checkout` sur le dossier ciblé avant d'y intégrer les nouveaux fichiers, plutôt que de lever `ChecoutNonDisponibleError` comme actuellement documenté dans son plan. | 46 | +## Bug réel trouvé et corrigé en cours d'implémentation (2026-09-19) |
| 40 | -- `specs/004-categorisation-dossiers` User Story 4 (recatégorisation a posteriori) DEVRA s'appuyer sur `comparer` : un dossier déplacé entre répertoires racine y est déjà classé `deplacement` (FR-010 de cette spec), sans code supplémentaire à écrire côté `specs/004`. | ||
| 41 | 47 | ||
| 42 | -Ces deux points d'intégration ne sont pas implémentés par ce plan ; ils sont documentés ici pour que leur future implémentation consomme cette API sans la redéfinir. | 48 | +`archive_cmd._cmd_reconcile` retournait sans appeler `verrou.lever()` quand la réconciliation ne trouvait aucun changement (`rapport.changements` vide), laissant le dossier verrouillé indéfiniment après une réconciliation « à vide ». Corrigé : le verrou est désormais levé dans ce cas aussi. Trouvé par le test d'intégration T032 (`test_checkout_accepted_again_after_reconciliation`). |
modified
specs/005-checkout-reconciliation/tasks.md +36 -36 | @@ -36,15 +36,15 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | ||
| 36 | 36 | |
| 37 | 37 | **⚠️ CRITICAL**: Aucune user story ne peut être implémentée avant la fin de cette phase. |
| 38 | 38 | |
| 39 | -- [ ] T005 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` avec `read_image_data_hash(chemin) -> str | None` (`exiftool -api ImageHashType=SHA256 -ImageDataHash`, cf. `research.md` § 5) | |
| 40 | -- [ ] T006 [P] Implémenter `hash_fichier_entier(chemin) -> str` (SHA-256 stdlib) dans `packages/regine-core/src/regine_core/integrity/hash.py` | |
| 41 | -- [ ] T007 Implémenter `hash_image_only(chemin) -> str | None` dans `integrity/hash.py` (délègue à `read_image_data_hash` pour DNG/TIFF/JPEG uniquement, dépend de T005) | |
| 42 | -- [ ] T008 [P] Test unitaire du hash à deux niveaux (RAW propriétaire → un seul hash ; DNG/TIFF/JPEG → deux hash) dans `packages/regine-core/tests/unit/test_hash_deux_niveaux.py` | |
| 43 | -- [ ] T009 Implémenter `est_maitre_modifie(format, ref, actuel) -> bool` dans `packages/regine-core/src/regine_core/integrity/anomalie.py` (FR-007/008, Principe I — bascule fichier entier / image-only selon le format, dépend de T006/T007) | |
| 44 | -- [ ] T010 Implémenter `manifest.ouvrir_ou_creer(dossier) -> ManifestHandle` dans `packages/regine-core/src/regine_core/archive/manifest.py` : table `fichiers`, table `verrou`, `PRAGMA user_version` (`structurel*1000+additif`, cf. `research.md` § 2), `PRAGMA application_id`, refus explicite si version structurelle non supportée | |
| 45 | -- [ ] T011 [P] Test unitaire du versionnement de schéma (tolérance additive, refus structurel, réouverture idempotente sans perte) dans `packages/regine-core/tests/unit/test_manifest_versioning.py` | |
| 46 | -- [ ] T012 Implémenter `verrou.poser/verifier/lever(manifest)` dans `packages/regine-core/src/regine_core/archive/verrou.py` (FR-003/004/016, dépend de T010) | |
| 47 | -- [ ] T013 [P] Test unitaire du verrou (pose, refus de double pose, levée après réconciliation) dans `packages/regine-core/tests/unit/test_verrou.py` | |
| 39 | +- [X] T005 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` avec `read_image_data_hash(chemin) -> str | None` (`exiftool -api ImageHashType=SHA256 -ImageDataHash`, cf. `research.md` § 5) | |
| 40 | +- [X] T006 [P] Implémenter `hash_fichier_entier(chemin) -> str` (SHA-256 stdlib) dans `packages/regine-core/src/regine_core/integrity/hash.py` | |
| 41 | +- [X] T007 Implémenter `hash_image_only(chemin) -> str | None` dans `integrity/hash.py` (délègue à `read_image_data_hash` pour DNG/TIFF/JPEG uniquement, dépend de T005) | |
| 42 | +- [X] T008 [P] Test unitaire du hash à deux niveaux (RAW propriétaire → un seul hash ; DNG/TIFF/JPEG → deux hash) dans `packages/regine-core/tests/unit/test_hash_deux_niveaux.py` — validé avec de vraies éditions de métadonnées exiftool | |
| 43 | +- [X] T009 Implémenter `est_maitre_modifie(format, ref, actuel) -> bool` dans `packages/regine-core/src/regine_core/integrity/anomalie.py` (FR-007/008, Principe I — bascule fichier entier / image-only selon le format, dépend de T006/T007) | |
| 44 | +- [X] T010 Implémenter `manifest.ouvrir_ou_creer(dossier) -> ManifestHandle` dans `packages/regine-core/src/regine_core/archive/manifest.py` : table `fichiers`, table `verrou`, `PRAGMA user_version` (`structurel*1000+additif`, cf. `research.md` § 2), `PRAGMA application_id`, refus explicite si version structurelle non supportée | |
| 45 | +- [X] T011 [P] Test unitaire du versionnement de schéma (tolérance additive, refus structurel, réouverture idempotente sans perte) dans `packages/regine-core/tests/unit/test_manifest_versioning.py` | |
| 46 | +- [X] T012 Implémenter `verrou.poser/verifier/lever(manifest)` dans `packages/regine-core/src/regine_core/archive/verrou.py` (FR-003/004/016, dépend de T010) | |
| 47 | +- [X] T013 [P] Test unitaire du verrou (pose, refus de double pose, levée après réconciliation) dans `packages/regine-core/tests/unit/test_verrou.py` | |
| 48 | 48 | |
| 49 | 49 | **Checkpoint**: Fondations prêtes — les phases User Story peuvent commencer. |
| 50 | 50 | |
| @@ -58,14 +58,14 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | ||
| 58 | 58 | |
| 59 | 59 | ### Tests for User Story 1 |
| 60 | 60 | |
| 61 | -- [ ] T014 [P] [US1] Test unitaire : checkout copie un dossier parent avec tous ses sous-dossiers ensemble (jamais un sous-dossier isolé), chaque fichier vérifié par empreinte, dans `packages/regine-core/tests/unit/test_checkout.py` | |
| 62 | -- [ ] T015 [US1] Test d'intégration (partiel) : checkout crée un manifeste complet et pose le verrou, dans `packages/regine-core/tests/integration/test_cycle_checkout_reconciliation.py` | |
| 61 | +- [X] T014 [P] [US1] Test unitaire : checkout copie un dossier parent avec tous ses sous-dossiers ensemble (jamais un sous-dossier isolé), chaque fichier vérifié par empreinte, dans `packages/regine-core/tests/unit/test_checkout.py` | |
| 62 | +- [X] T015 [US1] Test d'intégration (partiel) : checkout crée un manifeste complet et pose le verrou, dans `packages/regine-core/tests/integration/test_cycle_checkout_reconciliation.py` | |
| 63 | 63 | |
| 64 | 64 | ### Implementation for User Story 1 |
| 65 | 65 | |
| 66 | -- [ ] T016 [US1] Implémenter `checkout(dossier_archive, dest_locale, formats=None) -> ManifestSnapshot` dans `packages/regine-core/src/regine_core/archive/checkout.py` (FR-001/002/015 ; le paramètre `formats` est géré en Phase 8, ignoré ici) (dépend de T010) | |
| 67 | -- [ ] T017 [US1] Intégrer la pose du verrou avant la copie dans `checkout()`, lever une exception dédiée si déjà verrouillé (FR-003/004, dépend de T012) | |
| 68 | -- [ ] T018 [US1] Orchestrer `regine checkout <dossier>` dans `packages/regine-cli/src/regine_cli/archive_cmd.py` | |
| 66 | +- [X] T016 [US1] Implémenter `checkout(dossier_archive, dest_locale, formats=None) -> ManifestSnapshot` dans `packages/regine-core/src/regine_core/archive/checkout.py` (FR-001/002/015 ; le paramètre `formats` est géré en Phase 8, ignoré ici) (dépend de T010) — `formats` implémenté dès cette phase (cf. T036) | |
| 67 | +- [X] T017 [US1] Intégrer la pose du verrou avant la copie dans `checkout()`, lever une exception dédiée si déjà verrouillé (FR-003/004, dépend de T012) | |
| 68 | +- [X] T018 [US1] Orchestrer `regine checkout <dossier>` dans `packages/regine-cli/src/regine_cli/archive_cmd.py` | |
| 69 | 69 | |
| 70 | 70 | **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP). |
| 71 | 71 | |
| @@ -79,14 +79,14 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | ||
| 79 | 79 | |
| 80 | 80 | ### Tests for User Story 2 |
| 81 | 81 | |
| 82 | -- [ ] T019 [P] [US2] Test unitaire de classification "normal" (fichier maître inchangé + sidecar nouveau/modifié) dans `packages/regine-core/tests/unit/test_reconciliation_classification.py` | |
| 83 | -- [ ] T020 [US2] Test d'intégration complétant `test_cycle_checkout_reconciliation.py` : édition sidecar → réconciliation → résumé → confirmation → réarchivage → verrou levé | |
| 82 | +- [X] T019 [P] [US2] Test unitaire de classification "normal" (fichier maître inchangé + sidecar nouveau/modifié) dans `packages/regine-core/tests/unit/test_reconciliation_classification.py` | |
| 83 | +- [X] T020 [US2] Test d'intégration complétant `test_cycle_checkout_reconciliation.py` : édition sidecar → réconciliation → résumé → confirmation → réarchivage → verrou levé | |
| 84 | 84 | |
| 85 | 85 | ### Implementation for User Story 2 |
| 86 | 86 | |
| 87 | -- [ ] T021 [US2] Implémenter `comparer(manifest, copie_locale) -> RapportReconciliation` dans `packages/regine-core/src/regine_core/archive/reconciliation.py` : construit les index hash→chemin, classe "normal" (dépend de T007/T009) | |
| 88 | -- [ ] T022 [US2] Implémenter `archiver(dossier_archive, rapport, decisions) -> None` dans `reconciliation.py` : écrit les changements confirmés, vérifie chaque transfert (FR-015), met à jour le manifeste, lève le verrou une fois toutes les écritures terminées (FR-016, dépend de T012/T021) | |
| 89 | -- [ ] T023 [US2] Orchestrer `regine reconcile <dossier>` dans `archive_cmd.py` : affichage du point avant archive et confirmation globale avant écriture (FR-013) | |
| 87 | +- [X] T021 [US2] Implémenter `comparer(manifest, copie_locale) -> RapportReconciliation` dans `packages/regine-core/src/regine_core/archive/reconciliation.py` : construit les index hash→chemin, classe "normal" (dépend de T007/T009) | |
| 88 | +- [X] T022 [US2] Implémenter `archiver(dossier_archive, rapport, decisions) -> None` dans `reconciliation.py` : écrit les changements confirmés, vérifie chaque transfert (FR-015), met à jour le manifeste, lève le verrou une fois toutes les écritures terminées (FR-016, dépend de T012/T021) | |
| 89 | +- [X] T023 [US2] Orchestrer `regine reconcile <dossier>` dans `archive_cmd.py` : affichage du point avant archive et confirmation globale avant écriture (FR-013) | |
| 90 | 90 | |
| 91 | 91 | **Checkpoint**: User Stories 1 ET 2 fonctionnelles indépendamment. |
| 92 | 92 | |
| @@ -100,14 +100,14 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | ||
| 100 | 100 | |
| 101 | 101 | ### Tests for User Story 3 |
| 102 | 102 | |
| 103 | -- [ ] T024 [P] [US3] Test unitaire anomalie sur RAW propriétaire (hash fichier entier changé) dans `test_reconciliation_classification.py` | |
| 104 | -- [ ] T025 [P] [US3] Test unitaire DNG/TIFF/JPEG : édition de métadonnées seule → pas d'anomalie (hash image-only inchangé) ; édition des pixels → anomalie, dans `test_reconciliation_classification.py` | |
| 105 | -- [ ] T026 [US3] Test d'intégration : décision explicite (confirmer/restaurer) par anomalie, sans bloquer l'archivage des changements normaux déjà confirmés (FR-014), complétant `test_cycle_checkout_reconciliation.py` | |
| 103 | +- [X] T024 [P] [US3] Test unitaire anomalie sur RAW propriétaire (hash fichier entier changé) dans `test_reconciliation_classification.py` | |
| 104 | +- [X] T025 [P] [US3] Test unitaire DNG/TIFF/JPEG : édition de métadonnées seule → pas d'anomalie (hash image-only inchangé) ; édition des pixels → anomalie, dans `test_reconciliation_classification.py` — validé avec de vraies éditions exiftool | |
| 105 | +- [X] T026 [US3] Test d'intégration : décision explicite (confirmer/restaurer) par anomalie, sans bloquer l'archivage des changements normaux déjà confirmés (FR-014), complétant `test_cycle_checkout_reconciliation.py` | |
| 106 | 106 | |
| 107 | 107 | ### Implementation for User Story 3 |
| 108 | 108 | |
| 109 | -- [ ] T027 [US3] Étendre `comparer()` : classification "anomalie" via `est_maitre_modifie` (dépend de T009/T021) | |
| 110 | -- [ ] T028 [US3] Implémenter la résolution par anomalie (confirmer malgré tout / restaurer depuis l'archive) dans `reconciliation.py` et `archive_cmd.py`, sans bloquer les autres changements déjà confirmés (FR-014) | |
| 109 | +- [X] T027 [US3] Étendre `comparer()` : classification "anomalie" via `est_maitre_modifie` (dépend de T009/T021) | |
| 110 | +- [X] T028 [US3] Implémenter la résolution par anomalie (confirmer malgré tout / restaurer depuis l'archive) dans `reconciliation.py` et `archive_cmd.py`, sans bloquer les autres changements déjà confirmés (FR-014) | |
| 111 | 111 | |
| 112 | 112 | **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles. |
| 113 | 113 | |
| @@ -121,12 +121,12 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | ||
| 121 | 121 | |
| 122 | 122 | ### Tests for User Story 4 |
| 123 | 123 | |
| 124 | -- [ ] T029 [P] [US4] Test unitaire renommage/promotion détecté par contenu dans `test_reconciliation_classification.py` | |
| 125 | -- [ ] T030 [US4] Test unitaire déplacement d'un dossier entier entre répertoires racine (année→catégorie) détecté par contenu, dans `test_reconciliation_classification.py` — **débloque `specs/004-categorisation-dossiers` T024b** | |
| 124 | +- [X] T029 [P] [US4] Test unitaire renommage/promotion détecté par contenu dans `test_reconciliation_classification.py` | |
| 125 | +- [X] T030 [US4] Test unitaire déplacement d'un dossier entier entre répertoires racine (année→catégorie) détecté par contenu, dans `test_reconciliation_classification.py` — **débloque `specs/004-categorisation-dossiers` T024b** (le test démontre que plusieurs fichiers déplacés ensemble sont détectés individuellement par le même mécanisme, sans code dédié à l'échelle d'un dossier — cf. docstring du test pour la limite exacte de ce qui relève de `comparer` vs de l'orchestration d'un futur module) | |
| 126 | 126 | |
| 127 | 127 | ### Implementation for User Story 4 |
| 128 | 128 | |
| 129 | -- [ ] T031 [US4] Étendre `comparer()` : classification "deplacement" via l'index hash→chemin (manifeste vs copie locale), couvrant indifféremment un fichier ou un dossier entier (FR-009/010, dépend de T021) | |
| 129 | +- [X] T031 [US4] Étendre `comparer()` : classification "deplacement" via l'index hash→chemin (manifeste vs copie locale), couvrant indifféremment un fichier ou un dossier entier (FR-009/010, dépend de T021) | |
| 130 | 130 | |
| 131 | 131 | **Checkpoint**: User Stories 1 à 4 fonctionnelles. |
| 132 | 132 | |
| @@ -140,11 +140,11 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | ||
| 140 | 140 | |
| 141 | 141 | ### Tests for User Story 5 |
| 142 | 142 | |
| 143 | -- [ ] T032 [US5] Test d'intégration bout-en-bout : second `regine checkout` refusé pendant qu'un premier est en cours (code de sortie non-zéro, message explicite), accepté après réconciliation du premier, dans `packages/regine-core/tests/integration/test_double_checkout.py` | |
| 143 | +- [X] T032 [US5] Test d'intégration bout-en-bout : second `regine checkout` refusé pendant qu'un premier est en cours (code de sortie non-zéro, message explicite), accepté après réconciliation du premier, dans `packages/regine-core/tests/integration/test_double_checkout.py` — **bug réel trouvé et corrigé** : une réconciliation sans aucun changement ne levait jamais le verrou (`archive_cmd._cmd_reconcile` retournait avant d'appeler `lever()`) | |
| 144 | 144 | |
| 145 | 145 | ### Implementation for User Story 5 |
| 146 | 146 | |
| 147 | -- [ ] T033 [US5] Vérifier/compléter le message d'erreur explicite de `archive_cmd.py` pour le cas de verrou déjà posé (comportement déjà couvert par T017 ; cette tâche ne fait qu'exposer un message clair côté CLI) | |
| 147 | +- [X] T033 [US5] Vérifier/compléter le message d'erreur explicite de `archive_cmd.py` pour le cas de verrou déjà posé (comportement déjà couvert par T017 ; cette tâche ne fait qu'exposer un message clair côté CLI) | |
| 148 | 148 | |
| 149 | 149 | **Checkpoint**: User Stories 1 à 5 fonctionnelles. |
| 150 | 150 | |
| @@ -158,13 +158,13 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | ||
| 158 | 158 | |
| 159 | 159 | ### Tests for User Story 6 |
| 160 | 160 | |
| 161 | -- [ ] T034 [P] [US6] Test d'intégration checkout partiel (`formats=["jpeg"]` → seuls JPEG et racine copiés) dans `packages/regine-core/tests/integration/test_checkout_partiel.py` | |
| 162 | -- [ ] T035 [US6] Test d'intégration : réconciliation après checkout partiel ne signale pas les fichiers exclus comme supprimés (FR-020), complétant `test_checkout_partiel.py` | |
| 161 | +- [X] T034 [P] [US6] Test d'intégration checkout partiel (`formats=["jpeg"]` → seuls JPEG et racine copiés) dans `packages/regine-core/tests/integration/test_checkout_partiel.py` | |
| 162 | +- [X] T035 [US6] Test d'intégration : réconciliation après checkout partiel ne signale pas les fichiers exclus comme supprimés (FR-020), complétant `test_checkout_partiel.py` — première version du test erronée (attendait "normal" sur une réécriture complète d'un JPEG racine, qui est en réalité correctement classée "anomalie" — pas un bug, corrigé côté test avec un scénario de sidecar) | |
| 163 | 163 | |
| 164 | 164 | ### Implementation for User Story 6 |
| 165 | 165 | |
| 166 | -- [ ] T036 [US6] Étendre `checkout()` : paramètre `formats`, copie restreinte aux sous-répertoires demandés plus la racine (FR-019, dépend de T016) | |
| 167 | -- [ ] T037 [US6] Étendre `comparer()`/le manifeste pour exclure les fichiers hors du périmètre d'un checkout partiel de la détection de suppression (FR-020, dépend de T021) | |
| 166 | +- [X] T036 [US6] Étendre `checkout()` : paramètre `formats`, copie restreinte aux sous-répertoires demandés plus la racine (FR-019, dépend de T016) — implémenté dès T016 (Phase 3) | |
| 167 | +- [X] T037 [US6] Étendre `comparer()`/le manifeste pour exclure les fichiers hors du périmètre d'un checkout partiel de la détection de suppression (FR-020, dépend de T021) — implémenté dès T021 via `dans_perimetre` partagé avec `checkout.py` | |
| 168 | 168 | |
| 169 | 169 | **Checkpoint**: Les six user stories fonctionnelles. |
| 170 | 170 | |
| @@ -172,9 +172,9 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | ||
| 172 | 172 | |
| 173 | 173 | ## Phase 9: Polish & Cross-Cutting Concerns |
| 174 | 174 | |
| 175 | -- [ ] T038 [P] Exécuter manuellement les 5 scénarios de `specs/005-checkout-reconciliation/quickstart.md` et consigner le résultat | |
| 176 | -- [ ] T039 [P] `ruff check --fix` sur `packages/regine-core` et `packages/regine-cli` | |
| 177 | -- [ ] T040 Mettre à jour `contracts/regine-core-api.md` si écart d'implémentation ; puis lever les dépendances documentées dans `specs/001-import-photos/tasks.md` (T033) et `specs/004-categorisation-dossiers/tasks.md` (T024b), désormais implémentables | |
| 175 | +- [X] T038 [P] Exécuter manuellement les 5 scénarios de `specs/005-checkout-reconciliation/quickstart.md` et consigner le résultat — **2026-09-19 : les 5 scénarios passent**, déroulés via la vraie CLI (`python -m regine_cli.archive_cmd`) avec de vrais fichiers ; un bug réel a été trouvé et corrigé en cours de route (verrou non levé après une réconciliation sans changement, cf. T032) | |
| 176 | +- [X] T039 [P] `ruff check --fix` sur `packages/regine-core` et `packages/regine-cli` — 0 erreur restante, code formaté | |
| 177 | +- [X] T040 Mettre à jour `contracts/regine-core-api.md` si écart d'implémentation ; puis lever les dépendances documentées dans `specs/001-import-photos/tasks.md` (T033) et `specs/004-categorisation-dossiers/tasks.md` (T024b), désormais implémentables | |
| 178 | 178 | |
| 179 | 179 | --- |
| 180 | 180 | |
| @@ -36,15 +36,15 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | |||
| 36 | 36 | ||
| 37 | **⚠️ CRITICAL**: Aucune user story ne peut être implémentée avant la fin de cette phase. | 37 | **⚠️ CRITICAL**: Aucune user story ne peut être implémentée avant la fin de cette phase. |
| 38 | 38 | ||
| 39 | -- [ ] T005 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` avec `read_image_data_hash(chemin) -> str | None` (`exiftool -api ImageHashType=SHA256 -ImageDataHash`, cf. `research.md` § 5) | 39 | +- [X] T005 Étendre `packages/regine-core/src/regine_core/metadata/exif.py` avec `read_image_data_hash(chemin) -> str | None` (`exiftool -api ImageHashType=SHA256 -ImageDataHash`, cf. `research.md` § 5) |
| 40 | -- [ ] T006 [P] Implémenter `hash_fichier_entier(chemin) -> str` (SHA-256 stdlib) dans `packages/regine-core/src/regine_core/integrity/hash.py` | 40 | +- [X] T006 [P] Implémenter `hash_fichier_entier(chemin) -> str` (SHA-256 stdlib) dans `packages/regine-core/src/regine_core/integrity/hash.py` |
| 41 | -- [ ] T007 Implémenter `hash_image_only(chemin) -> str | None` dans `integrity/hash.py` (délègue à `read_image_data_hash` pour DNG/TIFF/JPEG uniquement, dépend de T005) | 41 | +- [X] T007 Implémenter `hash_image_only(chemin) -> str | None` dans `integrity/hash.py` (délègue à `read_image_data_hash` pour DNG/TIFF/JPEG uniquement, dépend de T005) |
| 42 | -- [ ] T008 [P] Test unitaire du hash à deux niveaux (RAW propriétaire → un seul hash ; DNG/TIFF/JPEG → deux hash) dans `packages/regine-core/tests/unit/test_hash_deux_niveaux.py` | 42 | +- [X] T008 [P] Test unitaire du hash à deux niveaux (RAW propriétaire → un seul hash ; DNG/TIFF/JPEG → deux hash) dans `packages/regine-core/tests/unit/test_hash_deux_niveaux.py` — validé avec de vraies éditions de métadonnées exiftool |
| 43 | -- [ ] T009 Implémenter `est_maitre_modifie(format, ref, actuel) -> bool` dans `packages/regine-core/src/regine_core/integrity/anomalie.py` (FR-007/008, Principe I — bascule fichier entier / image-only selon le format, dépend de T006/T007) | 43 | +- [X] T009 Implémenter `est_maitre_modifie(format, ref, actuel) -> bool` dans `packages/regine-core/src/regine_core/integrity/anomalie.py` (FR-007/008, Principe I — bascule fichier entier / image-only selon le format, dépend de T006/T007) |
| 44 | -- [ ] T010 Implémenter `manifest.ouvrir_ou_creer(dossier) -> ManifestHandle` dans `packages/regine-core/src/regine_core/archive/manifest.py` : table `fichiers`, table `verrou`, `PRAGMA user_version` (`structurel*1000+additif`, cf. `research.md` § 2), `PRAGMA application_id`, refus explicite si version structurelle non supportée | 44 | +- [X] T010 Implémenter `manifest.ouvrir_ou_creer(dossier) -> ManifestHandle` dans `packages/regine-core/src/regine_core/archive/manifest.py` : table `fichiers`, table `verrou`, `PRAGMA user_version` (`structurel*1000+additif`, cf. `research.md` § 2), `PRAGMA application_id`, refus explicite si version structurelle non supportée |
| 45 | -- [ ] T011 [P] Test unitaire du versionnement de schéma (tolérance additive, refus structurel, réouverture idempotente sans perte) dans `packages/regine-core/tests/unit/test_manifest_versioning.py` | 45 | +- [X] T011 [P] Test unitaire du versionnement de schéma (tolérance additive, refus structurel, réouverture idempotente sans perte) dans `packages/regine-core/tests/unit/test_manifest_versioning.py` |
| 46 | -- [ ] T012 Implémenter `verrou.poser/verifier/lever(manifest)` dans `packages/regine-core/src/regine_core/archive/verrou.py` (FR-003/004/016, dépend de T010) | 46 | +- [X] T012 Implémenter `verrou.poser/verifier/lever(manifest)` dans `packages/regine-core/src/regine_core/archive/verrou.py` (FR-003/004/016, dépend de T010) |
| 47 | -- [ ] T013 [P] Test unitaire du verrou (pose, refus de double pose, levée après réconciliation) dans `packages/regine-core/tests/unit/test_verrou.py` | 47 | +- [X] T013 [P] Test unitaire du verrou (pose, refus de double pose, levée après réconciliation) dans `packages/regine-core/tests/unit/test_verrou.py` |
| 48 | 48 | ||
| 49 | **Checkpoint**: Fondations prêtes — les phases User Story peuvent commencer. | 49 | **Checkpoint**: Fondations prêtes — les phases User Story peuvent commencer. |
| 50 | 50 | ||
| @@ -58,14 +58,14 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | |||
| 58 | 58 | ||
| 59 | ### Tests for User Story 1 | 59 | ### Tests for User Story 1 |
| 60 | 60 | ||
| 61 | -- [ ] T014 [P] [US1] Test unitaire : checkout copie un dossier parent avec tous ses sous-dossiers ensemble (jamais un sous-dossier isolé), chaque fichier vérifié par empreinte, dans `packages/regine-core/tests/unit/test_checkout.py` | 61 | +- [X] T014 [P] [US1] Test unitaire : checkout copie un dossier parent avec tous ses sous-dossiers ensemble (jamais un sous-dossier isolé), chaque fichier vérifié par empreinte, dans `packages/regine-core/tests/unit/test_checkout.py` |
| 62 | -- [ ] T015 [US1] Test d'intégration (partiel) : checkout crée un manifeste complet et pose le verrou, dans `packages/regine-core/tests/integration/test_cycle_checkout_reconciliation.py` | 62 | +- [X] T015 [US1] Test d'intégration (partiel) : checkout crée un manifeste complet et pose le verrou, dans `packages/regine-core/tests/integration/test_cycle_checkout_reconciliation.py` |
| 63 | 63 | ||
| 64 | ### Implementation for User Story 1 | 64 | ### Implementation for User Story 1 |
| 65 | 65 | ||
| 66 | -- [ ] T016 [US1] Implémenter `checkout(dossier_archive, dest_locale, formats=None) -> ManifestSnapshot` dans `packages/regine-core/src/regine_core/archive/checkout.py` (FR-001/002/015 ; le paramètre `formats` est géré en Phase 8, ignoré ici) (dépend de T010) | 66 | +- [X] T016 [US1] Implémenter `checkout(dossier_archive, dest_locale, formats=None) -> ManifestSnapshot` dans `packages/regine-core/src/regine_core/archive/checkout.py` (FR-001/002/015 ; le paramètre `formats` est géré en Phase 8, ignoré ici) (dépend de T010) — `formats` implémenté dès cette phase (cf. T036) |
| 67 | -- [ ] T017 [US1] Intégrer la pose du verrou avant la copie dans `checkout()`, lever une exception dédiée si déjà verrouillé (FR-003/004, dépend de T012) | 67 | +- [X] T017 [US1] Intégrer la pose du verrou avant la copie dans `checkout()`, lever une exception dédiée si déjà verrouillé (FR-003/004, dépend de T012) |
| 68 | -- [ ] T018 [US1] Orchestrer `regine checkout <dossier>` dans `packages/regine-cli/src/regine_cli/archive_cmd.py` | 68 | +- [X] T018 [US1] Orchestrer `regine checkout <dossier>` dans `packages/regine-cli/src/regine_cli/archive_cmd.py` |
| 69 | 69 | ||
| 70 | **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP). | 70 | **Checkpoint**: User Story 1 fonctionnelle et testable indépendamment (MVP). |
| 71 | 71 | ||
| @@ -79,14 +79,14 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | |||
| 79 | 79 | ||
| 80 | ### Tests for User Story 2 | 80 | ### Tests for User Story 2 |
| 81 | 81 | ||
| 82 | -- [ ] T019 [P] [US2] Test unitaire de classification "normal" (fichier maître inchangé + sidecar nouveau/modifié) dans `packages/regine-core/tests/unit/test_reconciliation_classification.py` | 82 | +- [X] T019 [P] [US2] Test unitaire de classification "normal" (fichier maître inchangé + sidecar nouveau/modifié) dans `packages/regine-core/tests/unit/test_reconciliation_classification.py` |
| 83 | -- [ ] T020 [US2] Test d'intégration complétant `test_cycle_checkout_reconciliation.py` : édition sidecar → réconciliation → résumé → confirmation → réarchivage → verrou levé | 83 | +- [X] T020 [US2] Test d'intégration complétant `test_cycle_checkout_reconciliation.py` : édition sidecar → réconciliation → résumé → confirmation → réarchivage → verrou levé |
| 84 | 84 | ||
| 85 | ### Implementation for User Story 2 | 85 | ### Implementation for User Story 2 |
| 86 | 86 | ||
| 87 | -- [ ] T021 [US2] Implémenter `comparer(manifest, copie_locale) -> RapportReconciliation` dans `packages/regine-core/src/regine_core/archive/reconciliation.py` : construit les index hash→chemin, classe "normal" (dépend de T007/T009) | 87 | +- [X] T021 [US2] Implémenter `comparer(manifest, copie_locale) -> RapportReconciliation` dans `packages/regine-core/src/regine_core/archive/reconciliation.py` : construit les index hash→chemin, classe "normal" (dépend de T007/T009) |
| 88 | -- [ ] T022 [US2] Implémenter `archiver(dossier_archive, rapport, decisions) -> None` dans `reconciliation.py` : écrit les changements confirmés, vérifie chaque transfert (FR-015), met à jour le manifeste, lève le verrou une fois toutes les écritures terminées (FR-016, dépend de T012/T021) | 88 | +- [X] T022 [US2] Implémenter `archiver(dossier_archive, rapport, decisions) -> None` dans `reconciliation.py` : écrit les changements confirmés, vérifie chaque transfert (FR-015), met à jour le manifeste, lève le verrou une fois toutes les écritures terminées (FR-016, dépend de T012/T021) |
| 89 | -- [ ] T023 [US2] Orchestrer `regine reconcile <dossier>` dans `archive_cmd.py` : affichage du point avant archive et confirmation globale avant écriture (FR-013) | 89 | +- [X] T023 [US2] Orchestrer `regine reconcile <dossier>` dans `archive_cmd.py` : affichage du point avant archive et confirmation globale avant écriture (FR-013) |
| 90 | 90 | ||
| 91 | **Checkpoint**: User Stories 1 ET 2 fonctionnelles indépendamment. | 91 | **Checkpoint**: User Stories 1 ET 2 fonctionnelles indépendamment. |
| 92 | 92 | ||
| @@ -100,14 +100,14 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | |||
| 100 | 100 | ||
| 101 | ### Tests for User Story 3 | 101 | ### Tests for User Story 3 |
| 102 | 102 | ||
| 103 | -- [ ] T024 [P] [US3] Test unitaire anomalie sur RAW propriétaire (hash fichier entier changé) dans `test_reconciliation_classification.py` | 103 | +- [X] T024 [P] [US3] Test unitaire anomalie sur RAW propriétaire (hash fichier entier changé) dans `test_reconciliation_classification.py` |
| 104 | -- [ ] T025 [P] [US3] Test unitaire DNG/TIFF/JPEG : édition de métadonnées seule → pas d'anomalie (hash image-only inchangé) ; édition des pixels → anomalie, dans `test_reconciliation_classification.py` | 104 | +- [X] T025 [P] [US3] Test unitaire DNG/TIFF/JPEG : édition de métadonnées seule → pas d'anomalie (hash image-only inchangé) ; édition des pixels → anomalie, dans `test_reconciliation_classification.py` — validé avec de vraies éditions exiftool |
| 105 | -- [ ] T026 [US3] Test d'intégration : décision explicite (confirmer/restaurer) par anomalie, sans bloquer l'archivage des changements normaux déjà confirmés (FR-014), complétant `test_cycle_checkout_reconciliation.py` | 105 | +- [X] T026 [US3] Test d'intégration : décision explicite (confirmer/restaurer) par anomalie, sans bloquer l'archivage des changements normaux déjà confirmés (FR-014), complétant `test_cycle_checkout_reconciliation.py` |
| 106 | 106 | ||
| 107 | ### Implementation for User Story 3 | 107 | ### Implementation for User Story 3 |
| 108 | 108 | ||
| 109 | -- [ ] T027 [US3] Étendre `comparer()` : classification "anomalie" via `est_maitre_modifie` (dépend de T009/T021) | 109 | +- [X] T027 [US3] Étendre `comparer()` : classification "anomalie" via `est_maitre_modifie` (dépend de T009/T021) |
| 110 | -- [ ] T028 [US3] Implémenter la résolution par anomalie (confirmer malgré tout / restaurer depuis l'archive) dans `reconciliation.py` et `archive_cmd.py`, sans bloquer les autres changements déjà confirmés (FR-014) | 110 | +- [X] T028 [US3] Implémenter la résolution par anomalie (confirmer malgré tout / restaurer depuis l'archive) dans `reconciliation.py` et `archive_cmd.py`, sans bloquer les autres changements déjà confirmés (FR-014) |
| 111 | 111 | ||
| 112 | **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles. | 112 | **Checkpoint**: User Stories 1, 2 et 3 fonctionnelles. |
| 113 | 113 | ||
| @@ -121,12 +121,12 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | |||
| 121 | 121 | ||
| 122 | ### Tests for User Story 4 | 122 | ### Tests for User Story 4 |
| 123 | 123 | ||
| 124 | -- [ ] T029 [P] [US4] Test unitaire renommage/promotion détecté par contenu dans `test_reconciliation_classification.py` | 124 | +- [X] T029 [P] [US4] Test unitaire renommage/promotion détecté par contenu dans `test_reconciliation_classification.py` |
| 125 | -- [ ] T030 [US4] Test unitaire déplacement d'un dossier entier entre répertoires racine (année→catégorie) détecté par contenu, dans `test_reconciliation_classification.py` — **débloque `specs/004-categorisation-dossiers` T024b** | 125 | +- [X] T030 [US4] Test unitaire déplacement d'un dossier entier entre répertoires racine (année→catégorie) détecté par contenu, dans `test_reconciliation_classification.py` — **débloque `specs/004-categorisation-dossiers` T024b** (le test démontre que plusieurs fichiers déplacés ensemble sont détectés individuellement par le même mécanisme, sans code dédié à l'échelle d'un dossier — cf. docstring du test pour la limite exacte de ce qui relève de `comparer` vs de l'orchestration d'un futur module) |
| 126 | 126 | ||
| 127 | ### Implementation for User Story 4 | 127 | ### Implementation for User Story 4 |
| 128 | 128 | ||
| 129 | -- [ ] T031 [US4] Étendre `comparer()` : classification "deplacement" via l'index hash→chemin (manifeste vs copie locale), couvrant indifféremment un fichier ou un dossier entier (FR-009/010, dépend de T021) | 129 | +- [X] T031 [US4] Étendre `comparer()` : classification "deplacement" via l'index hash→chemin (manifeste vs copie locale), couvrant indifféremment un fichier ou un dossier entier (FR-009/010, dépend de T021) |
| 130 | 130 | ||
| 131 | **Checkpoint**: User Stories 1 à 4 fonctionnelles. | 131 | **Checkpoint**: User Stories 1 à 4 fonctionnelles. |
| 132 | 132 | ||
| @@ -140,11 +140,11 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | |||
| 140 | 140 | ||
| 141 | ### Tests for User Story 5 | 141 | ### Tests for User Story 5 |
| 142 | 142 | ||
| 143 | -- [ ] T032 [US5] Test d'intégration bout-en-bout : second `regine checkout` refusé pendant qu'un premier est en cours (code de sortie non-zéro, message explicite), accepté après réconciliation du premier, dans `packages/regine-core/tests/integration/test_double_checkout.py` | 143 | +- [X] T032 [US5] Test d'intégration bout-en-bout : second `regine checkout` refusé pendant qu'un premier est en cours (code de sortie non-zéro, message explicite), accepté après réconciliation du premier, dans `packages/regine-core/tests/integration/test_double_checkout.py` — **bug réel trouvé et corrigé** : une réconciliation sans aucun changement ne levait jamais le verrou (`archive_cmd._cmd_reconcile` retournait avant d'appeler `lever()`) |
| 144 | 144 | ||
| 145 | ### Implementation for User Story 5 | 145 | ### Implementation for User Story 5 |
| 146 | 146 | ||
| 147 | -- [ ] T033 [US5] Vérifier/compléter le message d'erreur explicite de `archive_cmd.py` pour le cas de verrou déjà posé (comportement déjà couvert par T017 ; cette tâche ne fait qu'exposer un message clair côté CLI) | 147 | +- [X] T033 [US5] Vérifier/compléter le message d'erreur explicite de `archive_cmd.py` pour le cas de verrou déjà posé (comportement déjà couvert par T017 ; cette tâche ne fait qu'exposer un message clair côté CLI) |
| 148 | 148 | ||
| 149 | **Checkpoint**: User Stories 1 à 5 fonctionnelles. | 149 | **Checkpoint**: User Stories 1 à 5 fonctionnelles. |
| 150 | 150 | ||
| @@ -158,13 +158,13 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | |||
| 158 | 158 | ||
| 159 | ### Tests for User Story 6 | 159 | ### Tests for User Story 6 |
| 160 | 160 | ||
| 161 | -- [ ] T034 [P] [US6] Test d'intégration checkout partiel (`formats=["jpeg"]` → seuls JPEG et racine copiés) dans `packages/regine-core/tests/integration/test_checkout_partiel.py` | 161 | +- [X] T034 [P] [US6] Test d'intégration checkout partiel (`formats=["jpeg"]` → seuls JPEG et racine copiés) dans `packages/regine-core/tests/integration/test_checkout_partiel.py` |
| 162 | -- [ ] T035 [US6] Test d'intégration : réconciliation après checkout partiel ne signale pas les fichiers exclus comme supprimés (FR-020), complétant `test_checkout_partiel.py` | 162 | +- [X] T035 [US6] Test d'intégration : réconciliation après checkout partiel ne signale pas les fichiers exclus comme supprimés (FR-020), complétant `test_checkout_partiel.py` — première version du test erronée (attendait "normal" sur une réécriture complète d'un JPEG racine, qui est en réalité correctement classée "anomalie" — pas un bug, corrigé côté test avec un scénario de sidecar) |
| 163 | 163 | ||
| 164 | ### Implementation for User Story 6 | 164 | ### Implementation for User Story 6 |
| 165 | 165 | ||
| 166 | -- [ ] T036 [US6] Étendre `checkout()` : paramètre `formats`, copie restreinte aux sous-répertoires demandés plus la racine (FR-019, dépend de T016) | 166 | +- [X] T036 [US6] Étendre `checkout()` : paramètre `formats`, copie restreinte aux sous-répertoires demandés plus la racine (FR-019, dépend de T016) — implémenté dès T016 (Phase 3) |
| 167 | -- [ ] T037 [US6] Étendre `comparer()`/le manifeste pour exclure les fichiers hors du périmètre d'un checkout partiel de la détection de suppression (FR-020, dépend de T021) | 167 | +- [X] T037 [US6] Étendre `comparer()`/le manifeste pour exclure les fichiers hors du périmètre d'un checkout partiel de la détection de suppression (FR-020, dépend de T021) — implémenté dès T021 via `dans_perimetre` partagé avec `checkout.py` |
| 168 | 168 | ||
| 169 | **Checkpoint**: Les six user stories fonctionnelles. | 169 | **Checkpoint**: Les six user stories fonctionnelles. |
| 170 | 170 | ||
| @@ -172,9 +172,9 @@ Ce que ce module débloque une fois implémenté : `specs/001-import-photos` (T0 | |||
| 172 | 172 | ||
| 173 | ## Phase 9: Polish & Cross-Cutting Concerns | 173 | ## Phase 9: Polish & Cross-Cutting Concerns |
| 174 | 174 | ||
| 175 | -- [ ] T038 [P] Exécuter manuellement les 5 scénarios de `specs/005-checkout-reconciliation/quickstart.md` et consigner le résultat | 175 | +- [X] T038 [P] Exécuter manuellement les 5 scénarios de `specs/005-checkout-reconciliation/quickstart.md` et consigner le résultat — **2026-09-19 : les 5 scénarios passent**, déroulés via la vraie CLI (`python -m regine_cli.archive_cmd`) avec de vrais fichiers ; un bug réel a été trouvé et corrigé en cours de route (verrou non levé après une réconciliation sans changement, cf. T032) |
| 176 | -- [ ] T039 [P] `ruff check --fix` sur `packages/regine-core` et `packages/regine-cli` | 176 | +- [X] T039 [P] `ruff check --fix` sur `packages/regine-core` et `packages/regine-cli` — 0 erreur restante, code formaté |
| 177 | -- [ ] T040 Mettre à jour `contracts/regine-core-api.md` si écart d'implémentation ; puis lever les dépendances documentées dans `specs/001-import-photos/tasks.md` (T033) et `specs/004-categorisation-dossiers/tasks.md` (T024b), désormais implémentables | 177 | +- [X] T040 Mettre à jour `contracts/regine-core-api.md` si écart d'implémentation ; puis lever les dépendances documentées dans `specs/001-import-photos/tasks.md` (T033) et `specs/004-categorisation-dossiers/tasks.md` (T024b), désormais implémentables |
| 178 | 178 | ||
| 179 | --- | 179 | --- |
| 180 | 180 | ||