modified
packages/regine-cli/pyproject.toml +3 -0 | @@ -14,3 +14,6 @@ build-backend = "hatchling.build" | ||
| 14 | 14 | |
| 15 | 15 | [tool.hatch.build.targets.wheel] |
| 16 | 16 | packages = ["src/regine_cli"] |
| 17 | + | |
| 18 | +[tool.pytest.ini_options] | |
| 19 | +testpaths = ["tests"] | |
| @@ -14,3 +14,6 @@ build-backend = "hatchling.build" | |||
| 14 | 14 | ||
| 15 | [tool.hatch.build.targets.wheel] | 15 | [tool.hatch.build.targets.wheel] |
| 16 | packages = ["src/regine_cli"] | 16 | packages = ["src/regine_cli"] |
| 17 | + | ||
| 18 | +[tool.pytest.ini_options] | ||
| 19 | +testpaths = ["tests"] | ||
added
packages/regine-cli/src/regine_cli/config_cmd.py +263 -0 | new file mode 100644 | ||
| @@ -0,0 +1,263 @@ | ||
| 1 | +"""Commandes `regine config ...` (cf. contracts/cli-config.md). | |
| 2 | + | |
| 3 | +Façade fine : orchestre `regine_core.config`, 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 json | |
| 11 | +import sys | |
| 12 | +from pathlib import Path | |
| 13 | + | |
| 14 | +from regine_core.config.cameras_screen import BoitierInconnuError, lister_boitiers, nommer_boitier | |
| 15 | +from regine_core.config.context import ( | |
| 16 | + ArchiveInaccessibleError, | |
| 17 | + CheminArchiveInvalideError, | |
| 18 | + CheminsAmbigusError, | |
| 19 | + ContexteTravail, | |
| 20 | + RepertoireAbsentError, | |
| 21 | + TravailNonReconcilieError, | |
| 22 | + charger_contexte, | |
| 23 | + enregistrer_chemins, | |
| 24 | +) | |
| 25 | +from regine_core.config.db import open_context_db | |
| 26 | + | |
| 27 | + | |
| 28 | +def _ouvrir_conn(args: argparse.Namespace): | |
| 29 | + return open_context_db(Path(args.contexte_db) if args.contexte_db else None) | |
| 30 | + | |
| 31 | + | |
| 32 | +def _afficher_contexte(contexte: ContexteTravail) -> None: | |
| 33 | + print(f" temp_dir : {contexte.temp_dir or '(non configuré)'}") | |
| 34 | + print(f" local_work_dir : {contexte.local_work_dir or '(non configuré)'}") | |
| 35 | + print(f" archive_smb_path : {contexte.archive_smb_path or '(non configuré)'}") | |
| 36 | + print(f" état SMB : {contexte.smb_last_known_state}") | |
| 37 | + | |
| 38 | + | |
| 39 | +def _cmd_set_paths(args: argparse.Namespace) -> int: | |
| 40 | + conn = _ouvrir_conn(args) | |
| 41 | + try: | |
| 42 | + contexte = enregistrer_chemins( | |
| 43 | + conn, | |
| 44 | + temp_dir=Path(args.temp_dir) if args.temp_dir else None, | |
| 45 | + local_work_dir=Path(args.local_work_dir) if args.local_work_dir else None, | |
| 46 | + archive_smb_path=args.archive_smb, | |
| 47 | + creer_repertoires_manquants=args.yes, | |
| 48 | + forcer_collision=args.force, | |
| 49 | + ignorer_verification_archive=args.ignorer_verification_archive, | |
| 50 | + ) | |
| 51 | + except RepertoireAbsentError as exc: | |
| 52 | + print(f"{exc.chemin} n'existe pas. Relancer avec --yes pour le créer.", file=sys.stderr) | |
| 53 | + return 1 | |
| 54 | + except CheminsAmbigusError as exc: | |
| 55 | + print(f"{exc} — relancer avec --force pour confirmer explicitement.", file=sys.stderr) | |
| 56 | + return 1 | |
| 57 | + except CheminArchiveInvalideError as exc: | |
| 58 | + print(str(exc), file=sys.stderr) | |
| 59 | + return 1 | |
| 60 | + except TravailNonReconcilieError as exc: | |
| 61 | + dossiers = "\n".join(f" - {d}" for d in exc.dossiers) | |
| 62 | + print( | |
| 63 | + f"Changement refusé : réconcilier ou archiver d'abord :\n{dossiers}", | |
| 64 | + file=sys.stderr, | |
| 65 | + ) | |
| 66 | + return 1 | |
| 67 | + except ArchiveInaccessibleError as exc: | |
| 68 | + print( | |
| 69 | + f"{exc} — impossible de vérifier l'absence de travail non réconcilié. " | |
| 70 | + "Rétablir l'accès au NAS, ou relancer avec --ignorer-verification-archive.", | |
| 71 | + file=sys.stderr, | |
| 72 | + ) | |
| 73 | + return 1 | |
| 74 | + finally: | |
| 75 | + conn.close() | |
| 76 | + | |
| 77 | + print("Contexte de travail enregistré :") | |
| 78 | + _afficher_contexte(contexte) | |
| 79 | + return 0 | |
| 80 | + | |
| 81 | + | |
| 82 | +def _cmd_show(args: argparse.Namespace) -> int: | |
| 83 | + conn = _ouvrir_conn(args) | |
| 84 | + try: | |
| 85 | + contexte = charger_contexte(conn) | |
| 86 | + if contexte.archive_smb_path: | |
| 87 | + from regine_core.config.smb import assurer_disponibilite # noqa: PLC0415 | |
| 88 | + | |
| 89 | + assurer_disponibilite(conn, contexte.archive_smb_path) | |
| 90 | + contexte = charger_contexte(conn) | |
| 91 | + finally: | |
| 92 | + conn.close() | |
| 93 | + | |
| 94 | + manquants = [ | |
| 95 | + nom | |
| 96 | + for nom, valeur in ( | |
| 97 | + ("temp_dir", contexte.temp_dir), | |
| 98 | + ("local_work_dir", contexte.local_work_dir), | |
| 99 | + ("archive_smb_path", contexte.archive_smb_path), | |
| 100 | + ) | |
| 101 | + if valeur is None | |
| 102 | + ] | |
| 103 | + | |
| 104 | + if args.json: | |
| 105 | + print( | |
| 106 | + json.dumps( | |
| 107 | + { | |
| 108 | + "temp_dir": str(contexte.temp_dir) if contexte.temp_dir else None, | |
| 109 | + "local_work_dir": str(contexte.local_work_dir) | |
| 110 | + if contexte.local_work_dir | |
| 111 | + else None, | |
| 112 | + "archive_smb_path": contexte.archive_smb_path, | |
| 113 | + "smb_last_known_state": contexte.smb_last_known_state, | |
| 114 | + } | |
| 115 | + ) | |
| 116 | + ) | |
| 117 | + elif manquants: | |
| 118 | + print(f"Contexte de travail incomplet, chemins manquants : {', '.join(manquants)}") | |
| 119 | + else: | |
| 120 | + _afficher_contexte(contexte) | |
| 121 | + | |
| 122 | + return 1 if manquants else 0 | |
| 123 | + | |
| 124 | + | |
| 125 | +def _cmd_cameras_list(args: argparse.Namespace) -> int: | |
| 126 | + conn = _ouvrir_conn(args) | |
| 127 | + try: | |
| 128 | + boitiers = lister_boitiers(conn) | |
| 129 | + finally: | |
| 130 | + conn.close() | |
| 131 | + | |
| 132 | + if args.json: | |
| 133 | + print( | |
| 134 | + json.dumps( | |
| 135 | + [ | |
| 136 | + { | |
| 137 | + "id": b.id, | |
| 138 | + "modele": b.modele, | |
| 139 | + "numero_serie": b.numero_serie, | |
| 140 | + "nom_lisible": b.nom_lisible, | |
| 141 | + "source": b.source, | |
| 142 | + } | |
| 143 | + for b in boitiers | |
| 144 | + ] | |
| 145 | + ) | |
| 146 | + ) | |
| 147 | + return 0 | |
| 148 | + | |
| 149 | + if not boitiers: | |
| 150 | + print("Aucun boîtier connu (aucun import effectué pour l'instant).") | |
| 151 | + return 0 | |
| 152 | + | |
| 153 | + for b in boitiers: | |
| 154 | + nom = b.nom_lisible or "(non nommé)" | |
| 155 | + serie = f", {b.numero_serie}" if b.numero_serie else "" | |
| 156 | + print(f" [{b.id}] {nom} — {b.modele}{serie} (source={b.source})") | |
| 157 | + return 0 | |
| 158 | + | |
| 159 | + | |
| 160 | +def _cmd_cameras_rename(args: argparse.Namespace) -> int: | |
| 161 | + conn = _ouvrir_conn(args) | |
| 162 | + try: | |
| 163 | + nommer_boitier(conn, args.id, args.nom) | |
| 164 | + except BoitierInconnuError as exc: | |
| 165 | + print(str(exc), file=sys.stderr) | |
| 166 | + return 1 | |
| 167 | + finally: | |
| 168 | + conn.close() | |
| 169 | + | |
| 170 | + print(f"Boîtier {args.id} renommé en « {args.nom} ».") | |
| 171 | + return 0 | |
| 172 | + | |
| 173 | + | |
| 174 | +def _cmd_cameras_scan_nas(args: argparse.Namespace) -> int: | |
| 175 | + if not args.confirm: | |
| 176 | + print( | |
| 177 | + "Le parcours complet de l'archive peut être long sur une grosse archive. " | |
| 178 | + "Relancer avec --confirm pour l'exécuter.", | |
| 179 | + ) | |
| 180 | + return 1 | |
| 181 | + | |
| 182 | + from regine_core.config.cameras_screen import parcourir_nas # noqa: PLC0415 | |
| 183 | + from regine_core.config.smb import ( # noqa: PLC0415 | |
| 184 | + assurer_disponibilite, | |
| 185 | + chemin_local_du_partage, | |
| 186 | + ) | |
| 187 | + | |
| 188 | + conn = _ouvrir_conn(args) | |
| 189 | + try: | |
| 190 | + contexte = charger_contexte(conn) | |
| 191 | + if contexte.archive_smb_path is None: | |
| 192 | + print("Aucune archive configurée (regine config set-paths).", file=sys.stderr) | |
| 193 | + return 1 | |
| 194 | + if not assurer_disponibilite(conn, contexte.archive_smb_path): | |
| 195 | + print( | |
| 196 | + f"{contexte.archive_smb_path} n'est pas accessible — montage natif déclenché, " | |
| 197 | + "réessayer une fois l'accès rétabli.", | |
| 198 | + file=sys.stderr, | |
| 199 | + ) | |
| 200 | + return 1 | |
| 201 | + archive_root_local = chemin_local_du_partage(contexte.archive_smb_path) | |
| 202 | + nouveaux = parcourir_nas(conn, archive_root_local) | |
| 203 | + finally: | |
| 204 | + conn.close() | |
| 205 | + | |
| 206 | + print(f"Parcours terminé : {nouveaux} nouveau(x) boîtier(s) détecté(s).") | |
| 207 | + return 0 | |
| 208 | + | |
| 209 | + | |
| 210 | +def construire_analyseur() -> argparse.ArgumentParser: | |
| 211 | + analyseur = argparse.ArgumentParser(prog="regine") | |
| 212 | + sous_commandes = analyseur.add_subparsers(dest="commande", required=True) | |
| 213 | + | |
| 214 | + config_parser = sous_commandes.add_parser("config", help="Configure le contexte de travail") | |
| 215 | + config_sous = config_parser.add_subparsers(dest="sous_commande", required=True) | |
| 216 | + | |
| 217 | + set_paths_parser = config_sous.add_parser("set-paths", help="Configure les emplacements") | |
| 218 | + set_paths_parser.add_argument("--temp-dir", default=None) | |
| 219 | + set_paths_parser.add_argument("--local-work-dir", default=None) | |
| 220 | + set_paths_parser.add_argument("--archive-smb", default=None) | |
| 221 | + set_paths_parser.add_argument("--yes", action="store_true") | |
| 222 | + set_paths_parser.add_argument("--force", action="store_true") | |
| 223 | + set_paths_parser.add_argument("--ignorer-verification-archive", action="store_true") | |
| 224 | + set_paths_parser.add_argument("--contexte-db", default=None) | |
| 225 | + set_paths_parser.set_defaults(func=_cmd_set_paths) | |
| 226 | + | |
| 227 | + show_parser = config_sous.add_parser("show", help="Affiche le contexte de travail actif") | |
| 228 | + show_parser.add_argument("--json", action="store_true") | |
| 229 | + show_parser.add_argument("--contexte-db", default=None) | |
| 230 | + show_parser.set_defaults(func=_cmd_show) | |
| 231 | + | |
| 232 | + cameras_parser = config_sous.add_parser("cameras", help="Gère les boîtiers connus") | |
| 233 | + cameras_sous = cameras_parser.add_subparsers(dest="cameras_sous_commande", required=True) | |
| 234 | + | |
| 235 | + cameras_list_parser = cameras_sous.add_parser("list", help="Liste les boîtiers connus") | |
| 236 | + cameras_list_parser.add_argument("--json", action="store_true") | |
| 237 | + cameras_list_parser.add_argument("--contexte-db", default=None) | |
| 238 | + cameras_list_parser.set_defaults(func=_cmd_cameras_list) | |
| 239 | + | |
| 240 | + cameras_rename_parser = cameras_sous.add_parser("rename", help="Nomme un boîtier") | |
| 241 | + cameras_rename_parser.add_argument("--id", type=int, required=True) | |
| 242 | + cameras_rename_parser.add_argument("--nom", required=True) | |
| 243 | + cameras_rename_parser.add_argument("--contexte-db", default=None) | |
| 244 | + cameras_rename_parser.set_defaults(func=_cmd_cameras_rename) | |
| 245 | + | |
| 246 | + cameras_scan_parser = cameras_sous.add_parser( | |
| 247 | + "scan-nas", help="Parcours volontaire de l'archive (FR-012)" | |
| 248 | + ) | |
| 249 | + cameras_scan_parser.add_argument("--confirm", action="store_true") | |
| 250 | + cameras_scan_parser.add_argument("--contexte-db", default=None) | |
| 251 | + cameras_scan_parser.set_defaults(func=_cmd_cameras_scan_nas) | |
| 252 | + | |
| 253 | + return analyseur | |
| 254 | + | |
| 255 | + | |
| 256 | +def main(argv: list[str] | None = None) -> int: | |
| 257 | + analyseur = construire_analyseur() | |
| 258 | + args = analyseur.parse_args(argv) | |
| 259 | + return args.func(args) | |
| 260 | + | |
| 261 | + | |
| 262 | +if __name__ == "__main__": | |
| 263 | + raise SystemExit(main()) | |
| new file mode 100644 | |||
| @@ -0,0 +1,263 @@ | |||
| 1 | +"""Commandes `regine config ...` (cf. contracts/cli-config.md). | ||
| 2 | + | ||
| 3 | +Façade fine : orchestre `regine_core.config`, 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 json | ||
| 11 | +import sys | ||
| 12 | +from pathlib import Path | ||
| 13 | + | ||
| 14 | +from regine_core.config.cameras_screen import BoitierInconnuError, lister_boitiers, nommer_boitier | ||
| 15 | +from regine_core.config.context import ( | ||
| 16 | + ArchiveInaccessibleError, | ||
| 17 | + CheminArchiveInvalideError, | ||
| 18 | + CheminsAmbigusError, | ||
| 19 | + ContexteTravail, | ||
| 20 | + RepertoireAbsentError, | ||
| 21 | + TravailNonReconcilieError, | ||
| 22 | + charger_contexte, | ||
| 23 | + enregistrer_chemins, | ||
| 24 | +) | ||
| 25 | +from regine_core.config.db import open_context_db | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +def _ouvrir_conn(args: argparse.Namespace): | ||
| 29 | + return open_context_db(Path(args.contexte_db) if args.contexte_db else None) | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +def _afficher_contexte(contexte: ContexteTravail) -> None: | ||
| 33 | + print(f" temp_dir : {contexte.temp_dir or '(non configuré)'}") | ||
| 34 | + print(f" local_work_dir : {contexte.local_work_dir or '(non configuré)'}") | ||
| 35 | + print(f" archive_smb_path : {contexte.archive_smb_path or '(non configuré)'}") | ||
| 36 | + print(f" état SMB : {contexte.smb_last_known_state}") | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +def _cmd_set_paths(args: argparse.Namespace) -> int: | ||
| 40 | + conn = _ouvrir_conn(args) | ||
| 41 | + try: | ||
| 42 | + contexte = enregistrer_chemins( | ||
| 43 | + conn, | ||
| 44 | + temp_dir=Path(args.temp_dir) if args.temp_dir else None, | ||
| 45 | + local_work_dir=Path(args.local_work_dir) if args.local_work_dir else None, | ||
| 46 | + archive_smb_path=args.archive_smb, | ||
| 47 | + creer_repertoires_manquants=args.yes, | ||
| 48 | + forcer_collision=args.force, | ||
| 49 | + ignorer_verification_archive=args.ignorer_verification_archive, | ||
| 50 | + ) | ||
| 51 | + except RepertoireAbsentError as exc: | ||
| 52 | + print(f"{exc.chemin} n'existe pas. Relancer avec --yes pour le créer.", file=sys.stderr) | ||
| 53 | + return 1 | ||
| 54 | + except CheminsAmbigusError as exc: | ||
| 55 | + print(f"{exc} — relancer avec --force pour confirmer explicitement.", file=sys.stderr) | ||
| 56 | + return 1 | ||
| 57 | + except CheminArchiveInvalideError as exc: | ||
| 58 | + print(str(exc), file=sys.stderr) | ||
| 59 | + return 1 | ||
| 60 | + except TravailNonReconcilieError as exc: | ||
| 61 | + dossiers = "\n".join(f" - {d}" for d in exc.dossiers) | ||
| 62 | + print( | ||
| 63 | + f"Changement refusé : réconcilier ou archiver d'abord :\n{dossiers}", | ||
| 64 | + file=sys.stderr, | ||
| 65 | + ) | ||
| 66 | + return 1 | ||
| 67 | + except ArchiveInaccessibleError as exc: | ||
| 68 | + print( | ||
| 69 | + f"{exc} — impossible de vérifier l'absence de travail non réconcilié. " | ||
| 70 | + "Rétablir l'accès au NAS, ou relancer avec --ignorer-verification-archive.", | ||
| 71 | + file=sys.stderr, | ||
| 72 | + ) | ||
| 73 | + return 1 | ||
| 74 | + finally: | ||
| 75 | + conn.close() | ||
| 76 | + | ||
| 77 | + print("Contexte de travail enregistré :") | ||
| 78 | + _afficher_contexte(contexte) | ||
| 79 | + return 0 | ||
| 80 | + | ||
| 81 | + | ||
| 82 | +def _cmd_show(args: argparse.Namespace) -> int: | ||
| 83 | + conn = _ouvrir_conn(args) | ||
| 84 | + try: | ||
| 85 | + contexte = charger_contexte(conn) | ||
| 86 | + if contexte.archive_smb_path: | ||
| 87 | + from regine_core.config.smb import assurer_disponibilite # noqa: PLC0415 | ||
| 88 | + | ||
| 89 | + assurer_disponibilite(conn, contexte.archive_smb_path) | ||
| 90 | + contexte = charger_contexte(conn) | ||
| 91 | + finally: | ||
| 92 | + conn.close() | ||
| 93 | + | ||
| 94 | + manquants = [ | ||
| 95 | + nom | ||
| 96 | + for nom, valeur in ( | ||
| 97 | + ("temp_dir", contexte.temp_dir), | ||
| 98 | + ("local_work_dir", contexte.local_work_dir), | ||
| 99 | + ("archive_smb_path", contexte.archive_smb_path), | ||
| 100 | + ) | ||
| 101 | + if valeur is None | ||
| 102 | + ] | ||
| 103 | + | ||
| 104 | + if args.json: | ||
| 105 | + print( | ||
| 106 | + json.dumps( | ||
| 107 | + { | ||
| 108 | + "temp_dir": str(contexte.temp_dir) if contexte.temp_dir else None, | ||
| 109 | + "local_work_dir": str(contexte.local_work_dir) | ||
| 110 | + if contexte.local_work_dir | ||
| 111 | + else None, | ||
| 112 | + "archive_smb_path": contexte.archive_smb_path, | ||
| 113 | + "smb_last_known_state": contexte.smb_last_known_state, | ||
| 114 | + } | ||
| 115 | + ) | ||
| 116 | + ) | ||
| 117 | + elif manquants: | ||
| 118 | + print(f"Contexte de travail incomplet, chemins manquants : {', '.join(manquants)}") | ||
| 119 | + else: | ||
| 120 | + _afficher_contexte(contexte) | ||
| 121 | + | ||
| 122 | + return 1 if manquants else 0 | ||
| 123 | + | ||
| 124 | + | ||
| 125 | +def _cmd_cameras_list(args: argparse.Namespace) -> int: | ||
| 126 | + conn = _ouvrir_conn(args) | ||
| 127 | + try: | ||
| 128 | + boitiers = lister_boitiers(conn) | ||
| 129 | + finally: | ||
| 130 | + conn.close() | ||
| 131 | + | ||
| 132 | + if args.json: | ||
| 133 | + print( | ||
| 134 | + json.dumps( | ||
| 135 | + [ | ||
| 136 | + { | ||
| 137 | + "id": b.id, | ||
| 138 | + "modele": b.modele, | ||
| 139 | + "numero_serie": b.numero_serie, | ||
| 140 | + "nom_lisible": b.nom_lisible, | ||
| 141 | + "source": b.source, | ||
| 142 | + } | ||
| 143 | + for b in boitiers | ||
| 144 | + ] | ||
| 145 | + ) | ||
| 146 | + ) | ||
| 147 | + return 0 | ||
| 148 | + | ||
| 149 | + if not boitiers: | ||
| 150 | + print("Aucun boîtier connu (aucun import effectué pour l'instant).") | ||
| 151 | + return 0 | ||
| 152 | + | ||
| 153 | + for b in boitiers: | ||
| 154 | + nom = b.nom_lisible or "(non nommé)" | ||
| 155 | + serie = f", {b.numero_serie}" if b.numero_serie else "" | ||
| 156 | + print(f" [{b.id}] {nom} — {b.modele}{serie} (source={b.source})") | ||
| 157 | + return 0 | ||
| 158 | + | ||
| 159 | + | ||
| 160 | +def _cmd_cameras_rename(args: argparse.Namespace) -> int: | ||
| 161 | + conn = _ouvrir_conn(args) | ||
| 162 | + try: | ||
| 163 | + nommer_boitier(conn, args.id, args.nom) | ||
| 164 | + except BoitierInconnuError as exc: | ||
| 165 | + print(str(exc), file=sys.stderr) | ||
| 166 | + return 1 | ||
| 167 | + finally: | ||
| 168 | + conn.close() | ||
| 169 | + | ||
| 170 | + print(f"Boîtier {args.id} renommé en « {args.nom} ».") | ||
| 171 | + return 0 | ||
| 172 | + | ||
| 173 | + | ||
| 174 | +def _cmd_cameras_scan_nas(args: argparse.Namespace) -> int: | ||
| 175 | + if not args.confirm: | ||
| 176 | + print( | ||
| 177 | + "Le parcours complet de l'archive peut être long sur une grosse archive. " | ||
| 178 | + "Relancer avec --confirm pour l'exécuter.", | ||
| 179 | + ) | ||
| 180 | + return 1 | ||
| 181 | + | ||
| 182 | + from regine_core.config.cameras_screen import parcourir_nas # noqa: PLC0415 | ||
| 183 | + from regine_core.config.smb import ( # noqa: PLC0415 | ||
| 184 | + assurer_disponibilite, | ||
| 185 | + chemin_local_du_partage, | ||
| 186 | + ) | ||
| 187 | + | ||
| 188 | + conn = _ouvrir_conn(args) | ||
| 189 | + try: | ||
| 190 | + contexte = charger_contexte(conn) | ||
| 191 | + if contexte.archive_smb_path is None: | ||
| 192 | + print("Aucune archive configurée (regine config set-paths).", file=sys.stderr) | ||
| 193 | + return 1 | ||
| 194 | + if not assurer_disponibilite(conn, contexte.archive_smb_path): | ||
| 195 | + print( | ||
| 196 | + f"{contexte.archive_smb_path} n'est pas accessible — montage natif déclenché, " | ||
| 197 | + "réessayer une fois l'accès rétabli.", | ||
| 198 | + file=sys.stderr, | ||
| 199 | + ) | ||
| 200 | + return 1 | ||
| 201 | + archive_root_local = chemin_local_du_partage(contexte.archive_smb_path) | ||
| 202 | + nouveaux = parcourir_nas(conn, archive_root_local) | ||
| 203 | + finally: | ||
| 204 | + conn.close() | ||
| 205 | + | ||
| 206 | + print(f"Parcours terminé : {nouveaux} nouveau(x) boîtier(s) détecté(s).") | ||
| 207 | + return 0 | ||
| 208 | + | ||
| 209 | + | ||
| 210 | +def construire_analyseur() -> argparse.ArgumentParser: | ||
| 211 | + analyseur = argparse.ArgumentParser(prog="regine") | ||
| 212 | + sous_commandes = analyseur.add_subparsers(dest="commande", required=True) | ||
| 213 | + | ||
| 214 | + config_parser = sous_commandes.add_parser("config", help="Configure le contexte de travail") | ||
| 215 | + config_sous = config_parser.add_subparsers(dest="sous_commande", required=True) | ||
| 216 | + | ||
| 217 | + set_paths_parser = config_sous.add_parser("set-paths", help="Configure les emplacements") | ||
| 218 | + set_paths_parser.add_argument("--temp-dir", default=None) | ||
| 219 | + set_paths_parser.add_argument("--local-work-dir", default=None) | ||
| 220 | + set_paths_parser.add_argument("--archive-smb", default=None) | ||
| 221 | + set_paths_parser.add_argument("--yes", action="store_true") | ||
| 222 | + set_paths_parser.add_argument("--force", action="store_true") | ||
| 223 | + set_paths_parser.add_argument("--ignorer-verification-archive", action="store_true") | ||
| 224 | + set_paths_parser.add_argument("--contexte-db", default=None) | ||
| 225 | + set_paths_parser.set_defaults(func=_cmd_set_paths) | ||
| 226 | + | ||
| 227 | + show_parser = config_sous.add_parser("show", help="Affiche le contexte de travail actif") | ||
| 228 | + show_parser.add_argument("--json", action="store_true") | ||
| 229 | + show_parser.add_argument("--contexte-db", default=None) | ||
| 230 | + show_parser.set_defaults(func=_cmd_show) | ||
| 231 | + | ||
| 232 | + cameras_parser = config_sous.add_parser("cameras", help="Gère les boîtiers connus") | ||
| 233 | + cameras_sous = cameras_parser.add_subparsers(dest="cameras_sous_commande", required=True) | ||
| 234 | + | ||
| 235 | + cameras_list_parser = cameras_sous.add_parser("list", help="Liste les boîtiers connus") | ||
| 236 | + cameras_list_parser.add_argument("--json", action="store_true") | ||
| 237 | + cameras_list_parser.add_argument("--contexte-db", default=None) | ||
| 238 | + cameras_list_parser.set_defaults(func=_cmd_cameras_list) | ||
| 239 | + | ||
| 240 | + cameras_rename_parser = cameras_sous.add_parser("rename", help="Nomme un boîtier") | ||
| 241 | + cameras_rename_parser.add_argument("--id", type=int, required=True) | ||
| 242 | + cameras_rename_parser.add_argument("--nom", required=True) | ||
| 243 | + cameras_rename_parser.add_argument("--contexte-db", default=None) | ||
| 244 | + cameras_rename_parser.set_defaults(func=_cmd_cameras_rename) | ||
| 245 | + | ||
| 246 | + cameras_scan_parser = cameras_sous.add_parser( | ||
| 247 | + "scan-nas", help="Parcours volontaire de l'archive (FR-012)" | ||
| 248 | + ) | ||
| 249 | + cameras_scan_parser.add_argument("--confirm", action="store_true") | ||
| 250 | + cameras_scan_parser.add_argument("--contexte-db", default=None) | ||
| 251 | + cameras_scan_parser.set_defaults(func=_cmd_cameras_scan_nas) | ||
| 252 | + | ||
| 253 | + return analyseur | ||
| 254 | + | ||
| 255 | + | ||
| 256 | +def main(argv: list[str] | None = None) -> int: | ||
| 257 | + analyseur = construire_analyseur() | ||
| 258 | + args = analyseur.parse_args(argv) | ||
| 259 | + return args.func(args) | ||
| 260 | + | ||
| 261 | + | ||
| 262 | +if __name__ == "__main__": | ||
| 263 | + raise SystemExit(main()) | ||
added
packages/regine-cli/tests/contract/test_cli_config.py +156 -0 | new file mode 100644 | ||
| @@ -0,0 +1,156 @@ | ||
| 1 | +"""Contrat de la surface CLI `regine config` (cf. contracts/cli-config.md, T015/T025).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import json | |
| 6 | +from pathlib import Path | |
| 7 | + | |
| 8 | +from regine_cli.config_cmd import main | |
| 9 | +from regine_core.camera_profile.db import get_or_create_boitier | |
| 10 | +from regine_core.config.db import open_context_db | |
| 11 | + | |
| 12 | + | |
| 13 | +def _contexte_db(tmp_path: Path) -> list[str]: | |
| 14 | + return ["--contexte-db", str(tmp_path / "contexte.sqlite3")] | |
| 15 | + | |
| 16 | + | |
| 17 | +def test_set_paths_puis_show_reflete_le_contexte(tmp_path: Path, capsys) -> None: | |
| 18 | + temp_dir = tmp_path / "tmp" | |
| 19 | + local_work_dir = tmp_path / "travail" | |
| 20 | + | |
| 21 | + code = main( | |
| 22 | + [ | |
| 23 | + "config", | |
| 24 | + "set-paths", | |
| 25 | + "--temp-dir", | |
| 26 | + str(temp_dir), | |
| 27 | + "--local-work-dir", | |
| 28 | + str(local_work_dir), | |
| 29 | + "--archive-smb", | |
| 30 | + "smb://nas.local/regine-archive", | |
| 31 | + "--yes", | |
| 32 | + *_contexte_db(tmp_path), | |
| 33 | + ] | |
| 34 | + ) | |
| 35 | + assert code == 0 | |
| 36 | + capsys.readouterr() | |
| 37 | + | |
| 38 | + code = main(["config", "show", "--json", *_contexte_db(tmp_path)]) | |
| 39 | + assert code == 0 | |
| 40 | + sortie = json.loads(capsys.readouterr().out) | |
| 41 | + assert sortie["temp_dir"] == str(temp_dir) | |
| 42 | + assert sortie["local_work_dir"] == str(local_work_dir) | |
| 43 | + assert sortie["archive_smb_path"] == "smb://nas.local/regine-archive" | |
| 44 | + | |
| 45 | + | |
| 46 | +def test_show_sans_contexte_configure_signale_les_chemins_manquants(tmp_path: Path, capsys) -> None: | |
| 47 | + code = main(["config", "show", *_contexte_db(tmp_path)]) | |
| 48 | + | |
| 49 | + assert code != 0 | |
| 50 | + sortie = capsys.readouterr().out | |
| 51 | + assert "temp_dir" in sortie | |
| 52 | + assert "local_work_dir" in sortie | |
| 53 | + assert "archive_smb_path" in sortie | |
| 54 | + | |
| 55 | + | |
| 56 | +def test_set_paths_repertoire_manquant_sans_yes_echoue(tmp_path: Path, capsys) -> None: | |
| 57 | + code = main( | |
| 58 | + [ | |
| 59 | + "config", | |
| 60 | + "set-paths", | |
| 61 | + "--temp-dir", | |
| 62 | + str(tmp_path / "absent"), | |
| 63 | + *_contexte_db(tmp_path), | |
| 64 | + ] | |
| 65 | + ) | |
| 66 | + | |
| 67 | + assert code != 0 | |
| 68 | + assert "n'existe pas" in capsys.readouterr().err | |
| 69 | + | |
| 70 | + | |
| 71 | +def test_set_paths_collision_sans_force_echoue(tmp_path: Path, capsys) -> None: | |
| 72 | + meme_chemin = tmp_path / "meme" | |
| 73 | + | |
| 74 | + code = main( | |
| 75 | + [ | |
| 76 | + "config", | |
| 77 | + "set-paths", | |
| 78 | + "--temp-dir", | |
| 79 | + str(meme_chemin), | |
| 80 | + "--local-work-dir", | |
| 81 | + str(meme_chemin), | |
| 82 | + "--yes", | |
| 83 | + *_contexte_db(tmp_path), | |
| 84 | + ] | |
| 85 | + ) | |
| 86 | + | |
| 87 | + assert code != 0 | |
| 88 | + assert "force" in capsys.readouterr().err.lower() | |
| 89 | + | |
| 90 | + | |
| 91 | +def test_set_paths_archive_smb_invalide_echoue(tmp_path: Path, capsys) -> None: | |
| 92 | + code = main( | |
| 93 | + [ | |
| 94 | + "config", | |
| 95 | + "set-paths", | |
| 96 | + "--archive-smb", | |
| 97 | + "/Volumes/regine-archive", | |
| 98 | + *_contexte_db(tmp_path), | |
| 99 | + ] | |
| 100 | + ) | |
| 101 | + | |
| 102 | + assert code != 0 | |
| 103 | + assert capsys.readouterr().err | |
| 104 | + | |
| 105 | + | |
| 106 | +def test_cameras_list_vide_sans_erreur(tmp_path: Path, capsys) -> None: | |
| 107 | + code = main(["config", "cameras", "list", *_contexte_db(tmp_path)]) | |
| 108 | + | |
| 109 | + assert code == 0 | |
| 110 | + assert "Aucun boîtier" in capsys.readouterr().out | |
| 111 | + | |
| 112 | + | |
| 113 | +def test_cameras_list_puis_rename(tmp_path: Path, capsys) -> None: | |
| 114 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | |
| 115 | + boitier_id = get_or_create_boitier(conn, "Fujifilm X100V", "12345", source="modele") | |
| 116 | + conn.close() | |
| 117 | + | |
| 118 | + code = main(["config", "cameras", "list", "--json", *_contexte_db(tmp_path)]) | |
| 119 | + assert code == 0 | |
| 120 | + boitiers = json.loads(capsys.readouterr().out) | |
| 121 | + assert boitiers[0]["nom_lisible"] is None | |
| 122 | + | |
| 123 | + code = main( | |
| 124 | + [ | |
| 125 | + "config", | |
| 126 | + "cameras", | |
| 127 | + "rename", | |
| 128 | + "--id", | |
| 129 | + str(boitier_id), | |
| 130 | + "--nom", | |
| 131 | + "Fuji principal", | |
| 132 | + *_contexte_db(tmp_path), | |
| 133 | + ] | |
| 134 | + ) | |
| 135 | + assert code == 0 | |
| 136 | + capsys.readouterr() | |
| 137 | + | |
| 138 | + code = main(["config", "cameras", "list", "--json", *_contexte_db(tmp_path)]) | |
| 139 | + boitiers = json.loads(capsys.readouterr().out) | |
| 140 | + assert boitiers[0]["nom_lisible"] == "Fuji principal" | |
| 141 | + | |
| 142 | + | |
| 143 | +def test_cameras_rename_id_inconnu_echoue(tmp_path: Path, capsys) -> None: | |
| 144 | + code = main( | |
| 145 | + ["config", "cameras", "rename", "--id", "999", "--nom", "X", *_contexte_db(tmp_path)] | |
| 146 | + ) | |
| 147 | + | |
| 148 | + assert code != 0 | |
| 149 | + assert "inconnu" in capsys.readouterr().err | |
| 150 | + | |
| 151 | + | |
| 152 | +def test_cameras_scan_nas_sans_confirm_n_execute_rien(tmp_path: Path, capsys) -> None: | |
| 153 | + code = main(["config", "cameras", "scan-nas", *_contexte_db(tmp_path)]) | |
| 154 | + | |
| 155 | + assert code != 0 | |
| 156 | + assert "--confirm" in capsys.readouterr().out | |
| new file mode 100644 | |||
| @@ -0,0 +1,156 @@ | |||
| 1 | +"""Contrat de la surface CLI `regine config` (cf. contracts/cli-config.md, T015/T025).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +import json | ||
| 6 | +from pathlib import Path | ||
| 7 | + | ||
| 8 | +from regine_cli.config_cmd import main | ||
| 9 | +from regine_core.camera_profile.db import get_or_create_boitier | ||
| 10 | +from regine_core.config.db import open_context_db | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +def _contexte_db(tmp_path: Path) -> list[str]: | ||
| 14 | + return ["--contexte-db", str(tmp_path / "contexte.sqlite3")] | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +def test_set_paths_puis_show_reflete_le_contexte(tmp_path: Path, capsys) -> None: | ||
| 18 | + temp_dir = tmp_path / "tmp" | ||
| 19 | + local_work_dir = tmp_path / "travail" | ||
| 20 | + | ||
| 21 | + code = main( | ||
| 22 | + [ | ||
| 23 | + "config", | ||
| 24 | + "set-paths", | ||
| 25 | + "--temp-dir", | ||
| 26 | + str(temp_dir), | ||
| 27 | + "--local-work-dir", | ||
| 28 | + str(local_work_dir), | ||
| 29 | + "--archive-smb", | ||
| 30 | + "smb://nas.local/regine-archive", | ||
| 31 | + "--yes", | ||
| 32 | + *_contexte_db(tmp_path), | ||
| 33 | + ] | ||
| 34 | + ) | ||
| 35 | + assert code == 0 | ||
| 36 | + capsys.readouterr() | ||
| 37 | + | ||
| 38 | + code = main(["config", "show", "--json", *_contexte_db(tmp_path)]) | ||
| 39 | + assert code == 0 | ||
| 40 | + sortie = json.loads(capsys.readouterr().out) | ||
| 41 | + assert sortie["temp_dir"] == str(temp_dir) | ||
| 42 | + assert sortie["local_work_dir"] == str(local_work_dir) | ||
| 43 | + assert sortie["archive_smb_path"] == "smb://nas.local/regine-archive" | ||
| 44 | + | ||
| 45 | + | ||
| 46 | +def test_show_sans_contexte_configure_signale_les_chemins_manquants(tmp_path: Path, capsys) -> None: | ||
| 47 | + code = main(["config", "show", *_contexte_db(tmp_path)]) | ||
| 48 | + | ||
| 49 | + assert code != 0 | ||
| 50 | + sortie = capsys.readouterr().out | ||
| 51 | + assert "temp_dir" in sortie | ||
| 52 | + assert "local_work_dir" in sortie | ||
| 53 | + assert "archive_smb_path" in sortie | ||
| 54 | + | ||
| 55 | + | ||
| 56 | +def test_set_paths_repertoire_manquant_sans_yes_echoue(tmp_path: Path, capsys) -> None: | ||
| 57 | + code = main( | ||
| 58 | + [ | ||
| 59 | + "config", | ||
| 60 | + "set-paths", | ||
| 61 | + "--temp-dir", | ||
| 62 | + str(tmp_path / "absent"), | ||
| 63 | + *_contexte_db(tmp_path), | ||
| 64 | + ] | ||
| 65 | + ) | ||
| 66 | + | ||
| 67 | + assert code != 0 | ||
| 68 | + assert "n'existe pas" in capsys.readouterr().err | ||
| 69 | + | ||
| 70 | + | ||
| 71 | +def test_set_paths_collision_sans_force_echoue(tmp_path: Path, capsys) -> None: | ||
| 72 | + meme_chemin = tmp_path / "meme" | ||
| 73 | + | ||
| 74 | + code = main( | ||
| 75 | + [ | ||
| 76 | + "config", | ||
| 77 | + "set-paths", | ||
| 78 | + "--temp-dir", | ||
| 79 | + str(meme_chemin), | ||
| 80 | + "--local-work-dir", | ||
| 81 | + str(meme_chemin), | ||
| 82 | + "--yes", | ||
| 83 | + *_contexte_db(tmp_path), | ||
| 84 | + ] | ||
| 85 | + ) | ||
| 86 | + | ||
| 87 | + assert code != 0 | ||
| 88 | + assert "force" in capsys.readouterr().err.lower() | ||
| 89 | + | ||
| 90 | + | ||
| 91 | +def test_set_paths_archive_smb_invalide_echoue(tmp_path: Path, capsys) -> None: | ||
| 92 | + code = main( | ||
| 93 | + [ | ||
| 94 | + "config", | ||
| 95 | + "set-paths", | ||
| 96 | + "--archive-smb", | ||
| 97 | + "/Volumes/regine-archive", | ||
| 98 | + *_contexte_db(tmp_path), | ||
| 99 | + ] | ||
| 100 | + ) | ||
| 101 | + | ||
| 102 | + assert code != 0 | ||
| 103 | + assert capsys.readouterr().err | ||
| 104 | + | ||
| 105 | + | ||
| 106 | +def test_cameras_list_vide_sans_erreur(tmp_path: Path, capsys) -> None: | ||
| 107 | + code = main(["config", "cameras", "list", *_contexte_db(tmp_path)]) | ||
| 108 | + | ||
| 109 | + assert code == 0 | ||
| 110 | + assert "Aucun boîtier" in capsys.readouterr().out | ||
| 111 | + | ||
| 112 | + | ||
| 113 | +def test_cameras_list_puis_rename(tmp_path: Path, capsys) -> None: | ||
| 114 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | ||
| 115 | + boitier_id = get_or_create_boitier(conn, "Fujifilm X100V", "12345", source="modele") | ||
| 116 | + conn.close() | ||
| 117 | + | ||
| 118 | + code = main(["config", "cameras", "list", "--json", *_contexte_db(tmp_path)]) | ||
| 119 | + assert code == 0 | ||
| 120 | + boitiers = json.loads(capsys.readouterr().out) | ||
| 121 | + assert boitiers[0]["nom_lisible"] is None | ||
| 122 | + | ||
| 123 | + code = main( | ||
| 124 | + [ | ||
| 125 | + "config", | ||
| 126 | + "cameras", | ||
| 127 | + "rename", | ||
| 128 | + "--id", | ||
| 129 | + str(boitier_id), | ||
| 130 | + "--nom", | ||
| 131 | + "Fuji principal", | ||
| 132 | + *_contexte_db(tmp_path), | ||
| 133 | + ] | ||
| 134 | + ) | ||
| 135 | + assert code == 0 | ||
| 136 | + capsys.readouterr() | ||
| 137 | + | ||
| 138 | + code = main(["config", "cameras", "list", "--json", *_contexte_db(tmp_path)]) | ||
| 139 | + boitiers = json.loads(capsys.readouterr().out) | ||
| 140 | + assert boitiers[0]["nom_lisible"] == "Fuji principal" | ||
| 141 | + | ||
| 142 | + | ||
| 143 | +def test_cameras_rename_id_inconnu_echoue(tmp_path: Path, capsys) -> None: | ||
| 144 | + code = main( | ||
| 145 | + ["config", "cameras", "rename", "--id", "999", "--nom", "X", *_contexte_db(tmp_path)] | ||
| 146 | + ) | ||
| 147 | + | ||
| 148 | + assert code != 0 | ||
| 149 | + assert "inconnu" in capsys.readouterr().err | ||
| 150 | + | ||
| 151 | + | ||
| 152 | +def test_cameras_scan_nas_sans_confirm_n_execute_rien(tmp_path: Path, capsys) -> None: | ||
| 153 | + code = main(["config", "cameras", "scan-nas", *_contexte_db(tmp_path)]) | ||
| 154 | + | ||
| 155 | + assert code != 0 | ||
| 156 | + assert "--confirm" in capsys.readouterr().out | ||
added
packages/regine-core/src/regine_core/config/cameras_screen.py +73 -0 | new file mode 100644 | ||
| @@ -0,0 +1,73 @@ | ||
| 1 | +"""Écran de nommage des boîtiers (specs/003) : délègue à regine_core.camera_profile. | |
| 2 | + | |
| 3 | +Ne possède aucune logique de désambiguïsation propre (cf. plan.md « Correction du | |
| 4 | +2026-09-18 ») : la table `boitiers` et l'algorithme de résolution de collision | |
| 5 | +appartiennent à `regine_core.camera_profile` (specs/002-profil-boitiers-optionnel). | |
| 6 | +""" | |
| 7 | + | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import sqlite3 | |
| 11 | +from pathlib import Path | |
| 12 | + | |
| 13 | +from regine_core.camera_profile.db import Boitier, ensure_schema, get_or_create_boitier | |
| 14 | +from regine_core.camera_profile.db import list_boitiers as _list_boitiers | |
| 15 | +from regine_core.camera_profile.db import rename_boitier as _rename_boitier | |
| 16 | + | |
| 17 | + | |
| 18 | +class BoitierInconnuError(Exception): | |
| 19 | + """Aucun boîtier ne correspond à l'identifiant fourni (contracts/cli-config.md).""" | |
| 20 | + | |
| 21 | + def __init__(self, boitier_id: int) -> None: | |
| 22 | + self.boitier_id = boitier_id | |
| 23 | + super().__init__(f"boîtier {boitier_id} inconnu") | |
| 24 | + | |
| 25 | + | |
| 26 | +def lister_boitiers(conn: sqlite3.Connection) -> list[Boitier]: | |
| 27 | + """Liste les boîtiers connus dans la base de contexte centralisée (FR-008), | |
| 28 | + alimentée à chaque import — aucun accès au NAS ici (SC-007).""" | |
| 29 | + return _list_boitiers(conn) | |
| 30 | + | |
| 31 | + | |
| 32 | +def nommer_boitier(conn: sqlite3.Connection, boitier_id: int, nom: str) -> None: | |
| 33 | + """Attribue un nom lisible à un boîtier déjà détecté (FR-008/FR-009). | |
| 34 | + | |
| 35 | + Lève `BoitierInconnuError` si `boitier_id` ne correspond à aucun boîtier — | |
| 36 | + `camera_profile.db.rename_boitier` ne le signale pas lui-même (`UPDATE` sans | |
| 37 | + ligne correspondante n'est pas une erreur SQL), donc cette vérification est | |
| 38 | + portée ici, au niveau attendu par le contrat CLI de cette spec. | |
| 39 | + """ | |
| 40 | + ensure_schema(conn) | |
| 41 | + existe = conn.execute("SELECT 1 FROM boitiers WHERE id = ?", (boitier_id,)).fetchone() | |
| 42 | + if existe is None: | |
| 43 | + raise BoitierInconnuError(boitier_id) | |
| 44 | + _rename_boitier(conn, boitier_id, nom) | |
| 45 | + | |
| 46 | + | |
| 47 | +def parcourir_nas(conn: sqlite3.Connection, archive_root: Path) -> int: | |
| 48 | + """Parcours volontaire de l'archive pour détecter des boîtiers non encore | |
| 49 | + connus (FR-012) — jamais déclenché par défaut (SC-007), seulement à la demande | |
| 50 | + explicite de l'utilisateur. Retourne le nombre de boîtiers réellement nouveaux. | |
| 51 | + """ | |
| 52 | + from regine_core.import_carte.copie import EXTENSIONS_MAITRES # noqa: PLC0415 | |
| 53 | + from regine_core.metadata.exif import read_camera_tags # noqa: PLC0415 | |
| 54 | + | |
| 55 | + ensure_schema(conn) | |
| 56 | + nouveaux = 0 | |
| 57 | + for chemin in sorted(p for p in archive_root.rglob("*") if p.is_file()): | |
| 58 | + if chemin.suffix.lstrip(".").lower() not in EXTENSIONS_MAITRES: | |
| 59 | + continue | |
| 60 | + tags = read_camera_tags(chemin) | |
| 61 | + if tags.modele is None: | |
| 62 | + continue | |
| 63 | + deja_connu = ( | |
| 64 | + conn.execute( | |
| 65 | + "SELECT 1 FROM boitiers WHERE modele IS ? AND numero_serie IS ?", | |
| 66 | + (tags.modele, tags.numero_serie), | |
| 67 | + ).fetchone() | |
| 68 | + is not None | |
| 69 | + ) | |
| 70 | + get_or_create_boitier(conn, tags.modele, tags.numero_serie, source="parcours_nas") | |
| 71 | + if not deja_connu: | |
| 72 | + nouveaux += 1 | |
| 73 | + return nouveaux | |
| new file mode 100644 | |||
| @@ -0,0 +1,73 @@ | |||
| 1 | +"""Écran de nommage des boîtiers (specs/003) : délègue à regine_core.camera_profile. | ||
| 2 | + | ||
| 3 | +Ne possède aucune logique de désambiguïsation propre (cf. plan.md « Correction du | ||
| 4 | +2026-09-18 ») : la table `boitiers` et l'algorithme de résolution de collision | ||
| 5 | +appartiennent à `regine_core.camera_profile` (specs/002-profil-boitiers-optionnel). | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +from __future__ import annotations | ||
| 9 | + | ||
| 10 | +import sqlite3 | ||
| 11 | +from pathlib import Path | ||
| 12 | + | ||
| 13 | +from regine_core.camera_profile.db import Boitier, ensure_schema, get_or_create_boitier | ||
| 14 | +from regine_core.camera_profile.db import list_boitiers as _list_boitiers | ||
| 15 | +from regine_core.camera_profile.db import rename_boitier as _rename_boitier | ||
| 16 | + | ||
| 17 | + | ||
| 18 | +class BoitierInconnuError(Exception): | ||
| 19 | + """Aucun boîtier ne correspond à l'identifiant fourni (contracts/cli-config.md).""" | ||
| 20 | + | ||
| 21 | + def __init__(self, boitier_id: int) -> None: | ||
| 22 | + self.boitier_id = boitier_id | ||
| 23 | + super().__init__(f"boîtier {boitier_id} inconnu") | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +def lister_boitiers(conn: sqlite3.Connection) -> list[Boitier]: | ||
| 27 | + """Liste les boîtiers connus dans la base de contexte centralisée (FR-008), | ||
| 28 | + alimentée à chaque import — aucun accès au NAS ici (SC-007).""" | ||
| 29 | + return _list_boitiers(conn) | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +def nommer_boitier(conn: sqlite3.Connection, boitier_id: int, nom: str) -> None: | ||
| 33 | + """Attribue un nom lisible à un boîtier déjà détecté (FR-008/FR-009). | ||
| 34 | + | ||
| 35 | + Lève `BoitierInconnuError` si `boitier_id` ne correspond à aucun boîtier — | ||
| 36 | + `camera_profile.db.rename_boitier` ne le signale pas lui-même (`UPDATE` sans | ||
| 37 | + ligne correspondante n'est pas une erreur SQL), donc cette vérification est | ||
| 38 | + portée ici, au niveau attendu par le contrat CLI de cette spec. | ||
| 39 | + """ | ||
| 40 | + ensure_schema(conn) | ||
| 41 | + existe = conn.execute("SELECT 1 FROM boitiers WHERE id = ?", (boitier_id,)).fetchone() | ||
| 42 | + if existe is None: | ||
| 43 | + raise BoitierInconnuError(boitier_id) | ||
| 44 | + _rename_boitier(conn, boitier_id, nom) | ||
| 45 | + | ||
| 46 | + | ||
| 47 | +def parcourir_nas(conn: sqlite3.Connection, archive_root: Path) -> int: | ||
| 48 | + """Parcours volontaire de l'archive pour détecter des boîtiers non encore | ||
| 49 | + connus (FR-012) — jamais déclenché par défaut (SC-007), seulement à la demande | ||
| 50 | + explicite de l'utilisateur. Retourne le nombre de boîtiers réellement nouveaux. | ||
| 51 | + """ | ||
| 52 | + from regine_core.import_carte.copie import EXTENSIONS_MAITRES # noqa: PLC0415 | ||
| 53 | + from regine_core.metadata.exif import read_camera_tags # noqa: PLC0415 | ||
| 54 | + | ||
| 55 | + ensure_schema(conn) | ||
| 56 | + nouveaux = 0 | ||
| 57 | + for chemin in sorted(p for p in archive_root.rglob("*") if p.is_file()): | ||
| 58 | + if chemin.suffix.lstrip(".").lower() not in EXTENSIONS_MAITRES: | ||
| 59 | + continue | ||
| 60 | + tags = read_camera_tags(chemin) | ||
| 61 | + if tags.modele is None: | ||
| 62 | + continue | ||
| 63 | + deja_connu = ( | ||
| 64 | + conn.execute( | ||
| 65 | + "SELECT 1 FROM boitiers WHERE modele IS ? AND numero_serie IS ?", | ||
| 66 | + (tags.modele, tags.numero_serie), | ||
| 67 | + ).fetchone() | ||
| 68 | + is not None | ||
| 69 | + ) | ||
| 70 | + get_or_create_boitier(conn, tags.modele, tags.numero_serie, source="parcours_nas") | ||
| 71 | + if not deja_connu: | ||
| 72 | + nouveaux += 1 | ||
| 73 | + return nouveaux | ||
added
packages/regine-core/src/regine_core/config/context.py +230 -0 | new file mode 100644 | ||
| @@ -0,0 +1,230 @@ | ||
| 1 | +"""Contexte de travail : persistance des 3 emplacements et de l'état SMB (specs/003). | |
| 2 | + | |
| 3 | +Réutilise le manifeste persistant et le verrouillage de `regine_core.archive` | |
| 4 | +(`specs/005-checkout-reconciliation`) pour détecter un travail non réconcilié | |
| 5 | +(FR-010) plutôt que d'inventer un second mécanisme de suivi (Principe VI). | |
| 6 | +""" | |
| 7 | + | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +import sqlite3 | |
| 11 | +from dataclasses import dataclass | |
| 12 | +from pathlib import Path | |
| 13 | +from typing import Literal | |
| 14 | + | |
| 15 | +from regine_core.archive.manifest import NOM_FICHIER_MANIFESTE, ouvrir_ou_creer | |
| 16 | +from regine_core.archive.verrou import verifier | |
| 17 | + | |
| 18 | +EtatSmb = Literal["mounted", "unavailable", "unknown"] | |
| 19 | + | |
| 20 | +_CREATE_CONTEXTE = """ | |
| 21 | +CREATE TABLE IF NOT EXISTS contexte ( | |
| 22 | + id INTEGER PRIMARY KEY CHECK (id = 1), | |
| 23 | + temp_dir TEXT, | |
| 24 | + local_work_dir TEXT, | |
| 25 | + archive_smb_path TEXT, | |
| 26 | + smb_last_known_state TEXT NOT NULL DEFAULT 'unknown' | |
| 27 | +) | |
| 28 | +""" | |
| 29 | + | |
| 30 | + | |
| 31 | +class RepertoireAbsentError(Exception): | |
| 32 | + """Un répertoire configuré n'existe pas encore et sa création n'a pas été | |
| 33 | + explicitement demandée (FR-003).""" | |
| 34 | + | |
| 35 | + def __init__(self, chemin: Path) -> None: | |
| 36 | + self.chemin = chemin | |
| 37 | + super().__init__(f"{chemin} n'existe pas") | |
| 38 | + | |
| 39 | + | |
| 40 | +class CheminsAmbigusError(Exception): | |
| 41 | + """`temp_dir` et `local_work_dir` pointent vers le même emplacement (Edge Case).""" | |
| 42 | + | |
| 43 | + | |
| 44 | +class CheminArchiveInvalideError(Exception): | |
| 45 | + """`archive_smb_path` ne correspond pas à un partage réseau SMB (Edge Case).""" | |
| 46 | + | |
| 47 | + | |
| 48 | +class TravailNonReconcilieError(Exception): | |
| 49 | + """Un changement de `local_work_dir` est refusé tant qu'un travail n'est pas | |
| 50 | + réconcilié ou archivé sous l'ancien chemin (FR-010, Clarification n°1).""" | |
| 51 | + | |
| 52 | + def __init__(self, dossiers: list[Path]) -> None: | |
| 53 | + self.dossiers = dossiers | |
| 54 | + noms = ", ".join(str(d) for d in dossiers) | |
| 55 | + super().__init__(f"travail non réconcilié sous : {noms}") | |
| 56 | + | |
| 57 | + | |
| 58 | +class ArchiveInaccessibleError(Exception): | |
| 59 | + """L'archive configurée n'est pas accessible : impossible de vérifier l'absence | |
| 60 | + de travail non réconcilié avant un changement de `local_work_dir` (FR-010).""" | |
| 61 | + | |
| 62 | + | |
| 63 | +@dataclass(frozen=True) | |
| 64 | +class ContexteTravail: | |
| 65 | + """Contexte de travail actif — unique par installation (cf. spec.md Assumptions).""" | |
| 66 | + | |
| 67 | + temp_dir: Path | None | |
| 68 | + local_work_dir: Path | None | |
| 69 | + archive_smb_path: str | None | |
| 70 | + smb_last_known_state: EtatSmb | |
| 71 | + | |
| 72 | + | |
| 73 | +def _ensure_schema(conn: sqlite3.Connection) -> None: | |
| 74 | + conn.execute(_CREATE_CONTEXTE) | |
| 75 | + conn.commit() | |
| 76 | + | |
| 77 | + | |
| 78 | +def charger_contexte(conn: sqlite3.Connection) -> ContexteTravail: | |
| 79 | + """Charge le contexte de travail actif ; tous les champs sont `None`/`unknown` | |
| 80 | + tant qu'aucune configuration n'a encore été enregistrée.""" | |
| 81 | + _ensure_schema(conn) | |
| 82 | + ligne = conn.execute( | |
| 83 | + "SELECT temp_dir, local_work_dir, archive_smb_path, smb_last_known_state " | |
| 84 | + "FROM contexte WHERE id = 1" | |
| 85 | + ).fetchone() | |
| 86 | + if ligne is None: | |
| 87 | + return ContexteTravail( | |
| 88 | + temp_dir=None, | |
| 89 | + local_work_dir=None, | |
| 90 | + archive_smb_path=None, | |
| 91 | + smb_last_known_state="unknown", | |
| 92 | + ) | |
| 93 | + temp_dir, local_work_dir, archive_smb_path, smb_last_known_state = ligne | |
| 94 | + return ContexteTravail( | |
| 95 | + temp_dir=Path(temp_dir) if temp_dir else None, | |
| 96 | + local_work_dir=Path(local_work_dir) if local_work_dir else None, | |
| 97 | + archive_smb_path=archive_smb_path, | |
| 98 | + smb_last_known_state=smb_last_known_state, | |
| 99 | + ) | |
| 100 | + | |
| 101 | + | |
| 102 | +def lister_dossiers_non_reconcilies(local_work_dir: Path, archive_root_local: Path) -> list[Path]: | |
| 103 | + """Dossiers sous `local_work_dir` correspondant à un manifeste encore verrouillé | |
| 104 | + côté archive (checkout ou import non réconcilié, FR-010). | |
| 105 | + | |
| 106 | + L'état de verrouillage vit uniquement côté archive, jamais dupliqué dans la copie | |
| 107 | + de travail locale (cf. specs/005-checkout-reconciliation : le manifeste n'est | |
| 108 | + jamais recopié lors d'un checkout) — ce parcours part donc des manifestes de | |
| 109 | + `archive_root_local` (le point de montage local du partage SMB configuré), pas de | |
| 110 | + `local_work_dir` lui-même, et ne retient que ceux ayant une copie de travail | |
| 111 | + effectivement présente en local. | |
| 112 | + """ | |
| 113 | + if not archive_root_local.exists(): | |
| 114 | + raise ArchiveInaccessibleError(f"{archive_root_local} n'est pas accessible") | |
| 115 | + resultat: list[Path] = [] | |
| 116 | + for chemin_manifeste in archive_root_local.rglob(NOM_FICHIER_MANIFESTE): | |
| 117 | + dossier_archive = chemin_manifeste.parent | |
| 118 | + dossier_local = local_work_dir / dossier_archive.relative_to(archive_root_local) | |
| 119 | + if not dossier_local.exists(): | |
| 120 | + continue | |
| 121 | + handle = ouvrir_ou_creer(dossier_archive) | |
| 122 | + try: | |
| 123 | + if verifier(handle): | |
| 124 | + resultat.append(dossier_local) | |
| 125 | + finally: | |
| 126 | + handle.conn.close() | |
| 127 | + return resultat | |
| 128 | + | |
| 129 | + | |
| 130 | +def _valider_chemin_archive(archive_smb_path: str) -> None: | |
| 131 | + if not archive_smb_path.startswith("smb://"): | |
| 132 | + raise CheminArchiveInvalideError( | |
| 133 | + f"{archive_smb_path} ne correspond pas à un partage réseau SMB " | |
| 134 | + "(attendu : un chemin commençant par 'smb://')" | |
| 135 | + ) | |
| 136 | + | |
| 137 | + | |
| 138 | +def enregistrer_chemins( | |
| 139 | + conn: sqlite3.Connection, | |
| 140 | + *, | |
| 141 | + temp_dir: Path | None = None, | |
| 142 | + local_work_dir: Path | None = None, | |
| 143 | + archive_smb_path: str | None = None, | |
| 144 | + creer_repertoires_manquants: bool = False, | |
| 145 | + forcer_collision: bool = False, | |
| 146 | + ignorer_verification_archive: bool = False, | |
| 147 | +) -> ContexteTravail: | |
| 148 | + """Configure ou modifie un ou plusieurs des 3 emplacements de travail (FR-001/002/010). | |
| 149 | + | |
| 150 | + Ne modifie que les emplacements explicitement fournis ; les autres conservent | |
| 151 | + leur valeur actuelle. Lève `CheminArchiveInvalideError`, `CheminsAmbigusError`, | |
| 152 | + `TravailNonReconcilieError` ou `RepertoireAbsentError` plutôt que d'appliquer un | |
| 153 | + changement dangereux ou silencieux. | |
| 154 | + | |
| 155 | + Un changement de `local_work_dir` alors qu'aucune archive n'est encore configurée | |
| 156 | + n'a rien à vérifier (aucun travail archivé possible sans archive). Alors qu'une | |
| 157 | + archive est configurée mais inaccessible au moment du changement, le changement | |
| 158 | + est refusé par défaut (`ArchiveInaccessibleError` remontée) plutôt que supposé | |
| 159 | + sûr à tort — `ignorer_verification_archive=True` permet de passer outre en | |
| 160 | + connaissance de cause, même geste explicite que `forcer_collision`. | |
| 161 | + """ | |
| 162 | + actuel = charger_contexte(conn) | |
| 163 | + | |
| 164 | + if archive_smb_path is not None: | |
| 165 | + _valider_chemin_archive(archive_smb_path) | |
| 166 | + | |
| 167 | + nouveau_temp = temp_dir if temp_dir is not None else actuel.temp_dir | |
| 168 | + nouveau_local = local_work_dir if local_work_dir is not None else actuel.local_work_dir | |
| 169 | + nouveau_smb = archive_smb_path if archive_smb_path is not None else actuel.archive_smb_path | |
| 170 | + | |
| 171 | + if nouveau_temp is not None and nouveau_temp == nouveau_local and not forcer_collision: | |
| 172 | + raise CheminsAmbigusError( | |
| 173 | + f"temp_dir et local_work_dir pointent vers le même emplacement ({nouveau_temp}) " | |
| 174 | + "— passer forcer_collision=True pour confirmer explicitement" | |
| 175 | + ) | |
| 176 | + | |
| 177 | + if ( | |
| 178 | + local_work_dir is not None | |
| 179 | + and actuel.local_work_dir is not None | |
| 180 | + and local_work_dir != actuel.local_work_dir | |
| 181 | + and actuel.archive_smb_path is not None | |
| 182 | + and not ignorer_verification_archive | |
| 183 | + ): | |
| 184 | + from regine_core.config.smb import chemin_local_du_partage # noqa: PLC0415 | |
| 185 | + | |
| 186 | + archive_root_local = chemin_local_du_partage(actuel.archive_smb_path) | |
| 187 | + non_reconcilies = lister_dossiers_non_reconcilies(actuel.local_work_dir, archive_root_local) | |
| 188 | + if non_reconcilies: | |
| 189 | + raise TravailNonReconcilieError(non_reconcilies) | |
| 190 | + | |
| 191 | + for chemin in (temp_dir, local_work_dir): | |
| 192 | + if chemin is not None and not chemin.exists(): | |
| 193 | + if not creer_repertoires_manquants: | |
| 194 | + raise RepertoireAbsentError(chemin) | |
| 195 | + chemin.mkdir(parents=True, exist_ok=True) | |
| 196 | + | |
| 197 | + if local_work_dir is not None: | |
| 198 | + # FR-006/SC-003 : base de travail prête à la racine, avant tout autre besoin. | |
| 199 | + handle = ouvrir_ou_creer(local_work_dir) | |
| 200 | + handle.conn.close() | |
| 201 | + | |
| 202 | + _ensure_schema(conn) | |
| 203 | + conn.execute( | |
| 204 | + "INSERT INTO contexte " | |
| 205 | + "(id, temp_dir, local_work_dir, archive_smb_path, smb_last_known_state) " | |
| 206 | + "VALUES (1, ?, ?, ?, ?) " | |
| 207 | + "ON CONFLICT(id) DO UPDATE SET " | |
| 208 | + "temp_dir = excluded.temp_dir, " | |
| 209 | + "local_work_dir = excluded.local_work_dir, " | |
| 210 | + "archive_smb_path = excluded.archive_smb_path", | |
| 211 | + ( | |
| 212 | + str(nouveau_temp) if nouveau_temp else None, | |
| 213 | + str(nouveau_local) if nouveau_local else None, | |
| 214 | + nouveau_smb, | |
| 215 | + actuel.smb_last_known_state, | |
| 216 | + ), | |
| 217 | + ) | |
| 218 | + conn.commit() | |
| 219 | + return charger_contexte(conn) | |
| 220 | + | |
| 221 | + | |
| 222 | +def mettre_a_jour_etat_smb(conn: sqlite3.Connection, etat: EtatSmb) -> None: | |
| 223 | + """Met à jour l'état de disponibilité connu du partage SMB (FR-004/005).""" | |
| 224 | + _ensure_schema(conn) | |
| 225 | + conn.execute( | |
| 226 | + "INSERT INTO contexte (id, smb_last_known_state) VALUES (1, ?) " | |
| 227 | + "ON CONFLICT(id) DO UPDATE SET smb_last_known_state = excluded.smb_last_known_state", | |
| 228 | + (etat,), | |
| 229 | + ) | |
| 230 | + conn.commit() | |
| new file mode 100644 | |||
| @@ -0,0 +1,230 @@ | |||
| 1 | +"""Contexte de travail : persistance des 3 emplacements et de l'état SMB (specs/003). | ||
| 2 | + | ||
| 3 | +Réutilise le manifeste persistant et le verrouillage de `regine_core.archive` | ||
| 4 | +(`specs/005-checkout-reconciliation`) pour détecter un travail non réconcilié | ||
| 5 | +(FR-010) plutôt que d'inventer un second mécanisme de suivi (Principe VI). | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +from __future__ import annotations | ||
| 9 | + | ||
| 10 | +import sqlite3 | ||
| 11 | +from dataclasses import dataclass | ||
| 12 | +from pathlib import Path | ||
| 13 | +from typing import Literal | ||
| 14 | + | ||
| 15 | +from regine_core.archive.manifest import NOM_FICHIER_MANIFESTE, ouvrir_ou_creer | ||
| 16 | +from regine_core.archive.verrou import verifier | ||
| 17 | + | ||
| 18 | +EtatSmb = Literal["mounted", "unavailable", "unknown"] | ||
| 19 | + | ||
| 20 | +_CREATE_CONTEXTE = """ | ||
| 21 | +CREATE TABLE IF NOT EXISTS contexte ( | ||
| 22 | + id INTEGER PRIMARY KEY CHECK (id = 1), | ||
| 23 | + temp_dir TEXT, | ||
| 24 | + local_work_dir TEXT, | ||
| 25 | + archive_smb_path TEXT, | ||
| 26 | + smb_last_known_state TEXT NOT NULL DEFAULT 'unknown' | ||
| 27 | +) | ||
| 28 | +""" | ||
| 29 | + | ||
| 30 | + | ||
| 31 | +class RepertoireAbsentError(Exception): | ||
| 32 | + """Un répertoire configuré n'existe pas encore et sa création n'a pas été | ||
| 33 | + explicitement demandée (FR-003).""" | ||
| 34 | + | ||
| 35 | + def __init__(self, chemin: Path) -> None: | ||
| 36 | + self.chemin = chemin | ||
| 37 | + super().__init__(f"{chemin} n'existe pas") | ||
| 38 | + | ||
| 39 | + | ||
| 40 | +class CheminsAmbigusError(Exception): | ||
| 41 | + """`temp_dir` et `local_work_dir` pointent vers le même emplacement (Edge Case).""" | ||
| 42 | + | ||
| 43 | + | ||
| 44 | +class CheminArchiveInvalideError(Exception): | ||
| 45 | + """`archive_smb_path` ne correspond pas à un partage réseau SMB (Edge Case).""" | ||
| 46 | + | ||
| 47 | + | ||
| 48 | +class TravailNonReconcilieError(Exception): | ||
| 49 | + """Un changement de `local_work_dir` est refusé tant qu'un travail n'est pas | ||
| 50 | + réconcilié ou archivé sous l'ancien chemin (FR-010, Clarification n°1).""" | ||
| 51 | + | ||
| 52 | + def __init__(self, dossiers: list[Path]) -> None: | ||
| 53 | + self.dossiers = dossiers | ||
| 54 | + noms = ", ".join(str(d) for d in dossiers) | ||
| 55 | + super().__init__(f"travail non réconcilié sous : {noms}") | ||
| 56 | + | ||
| 57 | + | ||
| 58 | +class ArchiveInaccessibleError(Exception): | ||
| 59 | + """L'archive configurée n'est pas accessible : impossible de vérifier l'absence | ||
| 60 | + de travail non réconcilié avant un changement de `local_work_dir` (FR-010).""" | ||
| 61 | + | ||
| 62 | + | ||
| 63 | +@dataclass(frozen=True) | ||
| 64 | +class ContexteTravail: | ||
| 65 | + """Contexte de travail actif — unique par installation (cf. spec.md Assumptions).""" | ||
| 66 | + | ||
| 67 | + temp_dir: Path | None | ||
| 68 | + local_work_dir: Path | None | ||
| 69 | + archive_smb_path: str | None | ||
| 70 | + smb_last_known_state: EtatSmb | ||
| 71 | + | ||
| 72 | + | ||
| 73 | +def _ensure_schema(conn: sqlite3.Connection) -> None: | ||
| 74 | + conn.execute(_CREATE_CONTEXTE) | ||
| 75 | + conn.commit() | ||
| 76 | + | ||
| 77 | + | ||
| 78 | +def charger_contexte(conn: sqlite3.Connection) -> ContexteTravail: | ||
| 79 | + """Charge le contexte de travail actif ; tous les champs sont `None`/`unknown` | ||
| 80 | + tant qu'aucune configuration n'a encore été enregistrée.""" | ||
| 81 | + _ensure_schema(conn) | ||
| 82 | + ligne = conn.execute( | ||
| 83 | + "SELECT temp_dir, local_work_dir, archive_smb_path, smb_last_known_state " | ||
| 84 | + "FROM contexte WHERE id = 1" | ||
| 85 | + ).fetchone() | ||
| 86 | + if ligne is None: | ||
| 87 | + return ContexteTravail( | ||
| 88 | + temp_dir=None, | ||
| 89 | + local_work_dir=None, | ||
| 90 | + archive_smb_path=None, | ||
| 91 | + smb_last_known_state="unknown", | ||
| 92 | + ) | ||
| 93 | + temp_dir, local_work_dir, archive_smb_path, smb_last_known_state = ligne | ||
| 94 | + return ContexteTravail( | ||
| 95 | + temp_dir=Path(temp_dir) if temp_dir else None, | ||
| 96 | + local_work_dir=Path(local_work_dir) if local_work_dir else None, | ||
| 97 | + archive_smb_path=archive_smb_path, | ||
| 98 | + smb_last_known_state=smb_last_known_state, | ||
| 99 | + ) | ||
| 100 | + | ||
| 101 | + | ||
| 102 | +def lister_dossiers_non_reconcilies(local_work_dir: Path, archive_root_local: Path) -> list[Path]: | ||
| 103 | + """Dossiers sous `local_work_dir` correspondant à un manifeste encore verrouillé | ||
| 104 | + côté archive (checkout ou import non réconcilié, FR-010). | ||
| 105 | + | ||
| 106 | + L'état de verrouillage vit uniquement côté archive, jamais dupliqué dans la copie | ||
| 107 | + de travail locale (cf. specs/005-checkout-reconciliation : le manifeste n'est | ||
| 108 | + jamais recopié lors d'un checkout) — ce parcours part donc des manifestes de | ||
| 109 | + `archive_root_local` (le point de montage local du partage SMB configuré), pas de | ||
| 110 | + `local_work_dir` lui-même, et ne retient que ceux ayant une copie de travail | ||
| 111 | + effectivement présente en local. | ||
| 112 | + """ | ||
| 113 | + if not archive_root_local.exists(): | ||
| 114 | + raise ArchiveInaccessibleError(f"{archive_root_local} n'est pas accessible") | ||
| 115 | + resultat: list[Path] = [] | ||
| 116 | + for chemin_manifeste in archive_root_local.rglob(NOM_FICHIER_MANIFESTE): | ||
| 117 | + dossier_archive = chemin_manifeste.parent | ||
| 118 | + dossier_local = local_work_dir / dossier_archive.relative_to(archive_root_local) | ||
| 119 | + if not dossier_local.exists(): | ||
| 120 | + continue | ||
| 121 | + handle = ouvrir_ou_creer(dossier_archive) | ||
| 122 | + try: | ||
| 123 | + if verifier(handle): | ||
| 124 | + resultat.append(dossier_local) | ||
| 125 | + finally: | ||
| 126 | + handle.conn.close() | ||
| 127 | + return resultat | ||
| 128 | + | ||
| 129 | + | ||
| 130 | +def _valider_chemin_archive(archive_smb_path: str) -> None: | ||
| 131 | + if not archive_smb_path.startswith("smb://"): | ||
| 132 | + raise CheminArchiveInvalideError( | ||
| 133 | + f"{archive_smb_path} ne correspond pas à un partage réseau SMB " | ||
| 134 | + "(attendu : un chemin commençant par 'smb://')" | ||
| 135 | + ) | ||
| 136 | + | ||
| 137 | + | ||
| 138 | +def enregistrer_chemins( | ||
| 139 | + conn: sqlite3.Connection, | ||
| 140 | + *, | ||
| 141 | + temp_dir: Path | None = None, | ||
| 142 | + local_work_dir: Path | None = None, | ||
| 143 | + archive_smb_path: str | None = None, | ||
| 144 | + creer_repertoires_manquants: bool = False, | ||
| 145 | + forcer_collision: bool = False, | ||
| 146 | + ignorer_verification_archive: bool = False, | ||
| 147 | +) -> ContexteTravail: | ||
| 148 | + """Configure ou modifie un ou plusieurs des 3 emplacements de travail (FR-001/002/010). | ||
| 149 | + | ||
| 150 | + Ne modifie que les emplacements explicitement fournis ; les autres conservent | ||
| 151 | + leur valeur actuelle. Lève `CheminArchiveInvalideError`, `CheminsAmbigusError`, | ||
| 152 | + `TravailNonReconcilieError` ou `RepertoireAbsentError` plutôt que d'appliquer un | ||
| 153 | + changement dangereux ou silencieux. | ||
| 154 | + | ||
| 155 | + Un changement de `local_work_dir` alors qu'aucune archive n'est encore configurée | ||
| 156 | + n'a rien à vérifier (aucun travail archivé possible sans archive). Alors qu'une | ||
| 157 | + archive est configurée mais inaccessible au moment du changement, le changement | ||
| 158 | + est refusé par défaut (`ArchiveInaccessibleError` remontée) plutôt que supposé | ||
| 159 | + sûr à tort — `ignorer_verification_archive=True` permet de passer outre en | ||
| 160 | + connaissance de cause, même geste explicite que `forcer_collision`. | ||
| 161 | + """ | ||
| 162 | + actuel = charger_contexte(conn) | ||
| 163 | + | ||
| 164 | + if archive_smb_path is not None: | ||
| 165 | + _valider_chemin_archive(archive_smb_path) | ||
| 166 | + | ||
| 167 | + nouveau_temp = temp_dir if temp_dir is not None else actuel.temp_dir | ||
| 168 | + nouveau_local = local_work_dir if local_work_dir is not None else actuel.local_work_dir | ||
| 169 | + nouveau_smb = archive_smb_path if archive_smb_path is not None else actuel.archive_smb_path | ||
| 170 | + | ||
| 171 | + if nouveau_temp is not None and nouveau_temp == nouveau_local and not forcer_collision: | ||
| 172 | + raise CheminsAmbigusError( | ||
| 173 | + f"temp_dir et local_work_dir pointent vers le même emplacement ({nouveau_temp}) " | ||
| 174 | + "— passer forcer_collision=True pour confirmer explicitement" | ||
| 175 | + ) | ||
| 176 | + | ||
| 177 | + if ( | ||
| 178 | + local_work_dir is not None | ||
| 179 | + and actuel.local_work_dir is not None | ||
| 180 | + and local_work_dir != actuel.local_work_dir | ||
| 181 | + and actuel.archive_smb_path is not None | ||
| 182 | + and not ignorer_verification_archive | ||
| 183 | + ): | ||
| 184 | + from regine_core.config.smb import chemin_local_du_partage # noqa: PLC0415 | ||
| 185 | + | ||
| 186 | + archive_root_local = chemin_local_du_partage(actuel.archive_smb_path) | ||
| 187 | + non_reconcilies = lister_dossiers_non_reconcilies(actuel.local_work_dir, archive_root_local) | ||
| 188 | + if non_reconcilies: | ||
| 189 | + raise TravailNonReconcilieError(non_reconcilies) | ||
| 190 | + | ||
| 191 | + for chemin in (temp_dir, local_work_dir): | ||
| 192 | + if chemin is not None and not chemin.exists(): | ||
| 193 | + if not creer_repertoires_manquants: | ||
| 194 | + raise RepertoireAbsentError(chemin) | ||
| 195 | + chemin.mkdir(parents=True, exist_ok=True) | ||
| 196 | + | ||
| 197 | + if local_work_dir is not None: | ||
| 198 | + # FR-006/SC-003 : base de travail prête à la racine, avant tout autre besoin. | ||
| 199 | + handle = ouvrir_ou_creer(local_work_dir) | ||
| 200 | + handle.conn.close() | ||
| 201 | + | ||
| 202 | + _ensure_schema(conn) | ||
| 203 | + conn.execute( | ||
| 204 | + "INSERT INTO contexte " | ||
| 205 | + "(id, temp_dir, local_work_dir, archive_smb_path, smb_last_known_state) " | ||
| 206 | + "VALUES (1, ?, ?, ?, ?) " | ||
| 207 | + "ON CONFLICT(id) DO UPDATE SET " | ||
| 208 | + "temp_dir = excluded.temp_dir, " | ||
| 209 | + "local_work_dir = excluded.local_work_dir, " | ||
| 210 | + "archive_smb_path = excluded.archive_smb_path", | ||
| 211 | + ( | ||
| 212 | + str(nouveau_temp) if nouveau_temp else None, | ||
| 213 | + str(nouveau_local) if nouveau_local else None, | ||
| 214 | + nouveau_smb, | ||
| 215 | + actuel.smb_last_known_state, | ||
| 216 | + ), | ||
| 217 | + ) | ||
| 218 | + conn.commit() | ||
| 219 | + return charger_contexte(conn) | ||
| 220 | + | ||
| 221 | + | ||
| 222 | +def mettre_a_jour_etat_smb(conn: sqlite3.Connection, etat: EtatSmb) -> None: | ||
| 223 | + """Met à jour l'état de disponibilité connu du partage SMB (FR-004/005).""" | ||
| 224 | + _ensure_schema(conn) | ||
| 225 | + conn.execute( | ||
| 226 | + "INSERT INTO contexte (id, smb_last_known_state) VALUES (1, ?) " | ||
| 227 | + "ON CONFLICT(id) DO UPDATE SET smb_last_known_state = excluded.smb_last_known_state", | ||
| 228 | + (etat,), | ||
| 229 | + ) | ||
| 230 | + conn.commit() | ||
modified
packages/regine-core/src/regine_core/config/db.py +9 -2 | @@ -19,14 +19,21 @@ APPLICATION_ID = 0x52454749 | ||
| 19 | 19 | #: Numéro de schéma courant. À incrémenter à chaque évolution du schéma partagé. |
| 20 | 20 | SCHEMA_VERSION = 1 |
| 21 | 21 | |
| 22 | +#: Emplacement par défaut de la base de contexte centralisée : fixe, par utilisateur, | |
| 23 | +#: indépendant de ``local_work_dir`` (specs/003-config-contexte-travail) — ce dernier | |
| 24 | +#: est modifiable (FR-010) et la base de contexte (boîtiers, catégories, chemins) ne | |
| 25 | +#: doit jamais se perdre ni nécessiter de migration à un tel changement. | |
| 26 | +CHEMIN_CONTEXTE_PAR_DEFAUT = Path.home() / ".regine" / "contexte.sqlite3" | |
| 22 | 27 | |
| 23 | -def open_context_db(path: Path) -> sqlite3.Connection: | |
| 28 | + | |
| 29 | +def open_context_db(path: Path | None = None) -> sqlite3.Connection: | |
| 24 | 30 | """Ouvre la base de contexte centralisée, en l'initialisant si elle n'existe pas. |
| 25 | 31 | |
| 26 | 32 | Refuse explicitement d'ouvrir un fichier SQLite qui n'est pas déjà une base de |
| 27 | 33 | contexte Régine (``application_id`` différent), plutôt que de l'interpréter à |
| 28 | - tort. | |
| 34 | + tort. Sans ``path`` explicite, utilise ``CHEMIN_CONTEXTE_PAR_DEFAUT``. | |
| 29 | 35 | """ |
| 36 | + path = path or CHEMIN_CONTEXTE_PAR_DEFAUT | |
| 30 | 37 | path.parent.mkdir(parents=True, exist_ok=True) |
| 31 | 38 | conn = sqlite3.connect(path) |
| 32 | 39 | conn.execute("PRAGMA foreign_keys = ON") |
| @@ -19,14 +19,21 @@ APPLICATION_ID = 0x52454749 | |||
| 19 | #: Numéro de schéma courant. À incrémenter à chaque évolution du schéma partagé. | 19 | #: Numéro de schéma courant. À incrémenter à chaque évolution du schéma partagé. |
| 20 | SCHEMA_VERSION = 1 | 20 | SCHEMA_VERSION = 1 |
| 21 | 21 | ||
| 22 | +#: Emplacement par défaut de la base de contexte centralisée : fixe, par utilisateur, | ||
| 23 | +#: indépendant de ``local_work_dir`` (specs/003-config-contexte-travail) — ce dernier | ||
| 24 | +#: est modifiable (FR-010) et la base de contexte (boîtiers, catégories, chemins) ne | ||
| 25 | +#: doit jamais se perdre ni nécessiter de migration à un tel changement. | ||
| 26 | +CHEMIN_CONTEXTE_PAR_DEFAUT = Path.home() / ".regine" / "contexte.sqlite3" | ||
| 22 | 27 | ||
| 23 | -def open_context_db(path: Path) -> sqlite3.Connection: | 28 | + |
| 29 | +def open_context_db(path: Path | None = None) -> sqlite3.Connection: | ||
| 24 | """Ouvre la base de contexte centralisée, en l'initialisant si elle n'existe pas. | 30 | """Ouvre la base de contexte centralisée, en l'initialisant si elle n'existe pas. |
| 25 | 31 | ||
| 26 | Refuse explicitement d'ouvrir un fichier SQLite qui n'est pas déjà une base de | 32 | Refuse explicitement d'ouvrir un fichier SQLite qui n'est pas déjà une base de |
| 27 | contexte Régine (``application_id`` différent), plutôt que de l'interpréter à | 33 | contexte Régine (``application_id`` différent), plutôt que de l'interpréter à |
| 28 | - tort. | 34 | + tort. Sans ``path`` explicite, utilise ``CHEMIN_CONTEXTE_PAR_DEFAUT``. |
| 29 | """ | 35 | """ |
| 36 | + path = path or CHEMIN_CONTEXTE_PAR_DEFAUT | ||
| 30 | path.parent.mkdir(parents=True, exist_ok=True) | 37 | path.parent.mkdir(parents=True, exist_ok=True) |
| 31 | conn = sqlite3.connect(path) | 38 | conn = sqlite3.connect(path) |
| 32 | conn.execute("PRAGMA foreign_keys = ON") | 39 | conn.execute("PRAGMA foreign_keys = ON") |
added
packages/regine-core/src/regine_core/config/smb.py +53 -0 | new file mode 100644 | ||
| @@ -0,0 +1,53 @@ | ||
| 1 | +"""Détection de disponibilité et déclenchement du montage natif SMB (macOS, specs/003). | |
| 2 | + | |
| 3 | +Aucune gestion ni stockage d'identifiants réseau (FR-004) : l'authentification et la | |
| 4 | +sécurité des paramètres de connexion restent entièrement déléguées à l'OS via son | |
| 5 | +mécanisme natif de montage (`open smb://...`, cf. research.md § 1). Une seule | |
| 6 | +vérification par appel — pas de nouvelle tentative automatique en boucle (US2 | |
| 7 | +Acceptance Scenario 3, cohérent avec l'exécution sans démon de la constitution). | |
| 8 | +""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import sqlite3 | |
| 13 | +import subprocess | |
| 14 | +from pathlib import Path | |
| 15 | + | |
| 16 | + | |
| 17 | +def chemin_local_du_partage(archive_smb_path: str) -> Path: | |
| 18 | + """Dérive le point de montage local macOS d'un partage `smb://hôte/partage`. | |
| 19 | + | |
| 20 | + Convention macOS : un partage SMB monté apparaît sous `/Volumes/<partage>` | |
| 21 | + (cf. research.md § 1). Best-effort : ne garantit pas que le partage soit | |
| 22 | + effectivement monté, seulement où le chercher. | |
| 23 | + """ | |
| 24 | + partage = archive_smb_path.rstrip("/").rsplit("/", 1)[-1] | |
| 25 | + return Path("/Volumes") / partage | |
| 26 | + | |
| 27 | + | |
| 28 | +def disponible(archive_smb_path: str) -> bool: | |
| 29 | + """Le partage est-il actuellement monté et accessible (FR-004) ?""" | |
| 30 | + return chemin_local_du_partage(archive_smb_path).exists() | |
| 31 | + | |
| 32 | + | |
| 33 | +def declencher_montage(archive_smb_path: str) -> None: | |
| 34 | + """Déclenche le mécanisme natif de montage macOS (FR-004) — authentification | |
| 35 | + entièrement gérée par l'OS, Régine ne voit ni ne stocke aucun identifiant.""" | |
| 36 | + subprocess.run(["open", archive_smb_path], check=False) # noqa: S603, S607 | |
| 37 | + | |
| 38 | + | |
| 39 | +def assurer_disponibilite( | |
| 40 | + conn: sqlite3.Connection, archive_smb_path: str, *, declencher: bool = True | |
| 41 | +) -> bool: | |
| 42 | + """Vérifie la disponibilité du partage, met à jour l'état connu (FR-005) et | |
| 43 | + déclenche le montage natif si indisponible. Retourne `True` si déjà disponible.""" | |
| 44 | + from regine_core.config.context import mettre_a_jour_etat_smb # noqa: PLC0415 | |
| 45 | + | |
| 46 | + if disponible(archive_smb_path): | |
| 47 | + mettre_a_jour_etat_smb(conn, "mounted") | |
| 48 | + return True | |
| 49 | + | |
| 50 | + mettre_a_jour_etat_smb(conn, "unavailable") | |
| 51 | + if declencher: | |
| 52 | + declencher_montage(archive_smb_path) | |
| 53 | + return False | |
| new file mode 100644 | |||
| @@ -0,0 +1,53 @@ | |||
| 1 | +"""Détection de disponibilité et déclenchement du montage natif SMB (macOS, specs/003). | ||
| 2 | + | ||
| 3 | +Aucune gestion ni stockage d'identifiants réseau (FR-004) : l'authentification et la | ||
| 4 | +sécurité des paramètres de connexion restent entièrement déléguées à l'OS via son | ||
| 5 | +mécanisme natif de montage (`open smb://...`, cf. research.md § 1). Une seule | ||
| 6 | +vérification par appel — pas de nouvelle tentative automatique en boucle (US2 | ||
| 7 | +Acceptance Scenario 3, cohérent avec l'exécution sans démon de la constitution). | ||
| 8 | +""" | ||
| 9 | + | ||
| 10 | +from __future__ import annotations | ||
| 11 | + | ||
| 12 | +import sqlite3 | ||
| 13 | +import subprocess | ||
| 14 | +from pathlib import Path | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +def chemin_local_du_partage(archive_smb_path: str) -> Path: | ||
| 18 | + """Dérive le point de montage local macOS d'un partage `smb://hôte/partage`. | ||
| 19 | + | ||
| 20 | + Convention macOS : un partage SMB monté apparaît sous `/Volumes/<partage>` | ||
| 21 | + (cf. research.md § 1). Best-effort : ne garantit pas que le partage soit | ||
| 22 | + effectivement monté, seulement où le chercher. | ||
| 23 | + """ | ||
| 24 | + partage = archive_smb_path.rstrip("/").rsplit("/", 1)[-1] | ||
| 25 | + return Path("/Volumes") / partage | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +def disponible(archive_smb_path: str) -> bool: | ||
| 29 | + """Le partage est-il actuellement monté et accessible (FR-004) ?""" | ||
| 30 | + return chemin_local_du_partage(archive_smb_path).exists() | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +def declencher_montage(archive_smb_path: str) -> None: | ||
| 34 | + """Déclenche le mécanisme natif de montage macOS (FR-004) — authentification | ||
| 35 | + entièrement gérée par l'OS, Régine ne voit ni ne stocke aucun identifiant.""" | ||
| 36 | + subprocess.run(["open", archive_smb_path], check=False) # noqa: S603, S607 | ||
| 37 | + | ||
| 38 | + | ||
| 39 | +def assurer_disponibilite( | ||
| 40 | + conn: sqlite3.Connection, archive_smb_path: str, *, declencher: bool = True | ||
| 41 | +) -> bool: | ||
| 42 | + """Vérifie la disponibilité du partage, met à jour l'état connu (FR-005) et | ||
| 43 | + déclenche le montage natif si indisponible. Retourne `True` si déjà disponible.""" | ||
| 44 | + from regine_core.config.context import mettre_a_jour_etat_smb # noqa: PLC0415 | ||
| 45 | + | ||
| 46 | + if disponible(archive_smb_path): | ||
| 47 | + mettre_a_jour_etat_smb(conn, "mounted") | ||
| 48 | + return True | ||
| 49 | + | ||
| 50 | + mettre_a_jour_etat_smb(conn, "unavailable") | ||
| 51 | + if declencher: | ||
| 52 | + declencher_montage(archive_smb_path) | ||
| 53 | + return False | ||
added
packages/regine-core/tests/integration/test_cameras_scan_nas.py +74 -0 | new file mode 100644 | ||
| @@ -0,0 +1,74 @@ | ||
| 1 | +"""Test d'intégration du parcours volontaire du NAS (T023, FR-012).""" | |
| 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.camera_profile.db import get_or_create_boitier, list_boitiers | |
| 12 | +from regine_core.config.cameras_screen import parcourir_nas | |
| 13 | +from regine_core.config.db import open_context_db | |
| 14 | +from regine_core.metadata.exif import close_session | |
| 15 | + | |
| 16 | +_JPEG_1X1_BASE64 = ( | |
| 17 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | |
| 18 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | |
| 19 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | |
| 20 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | |
| 21 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | |
| 22 | +) | |
| 23 | + | |
| 24 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | |
| 25 | + | |
| 26 | + | |
| 27 | +@pytest.fixture(autouse=True) | |
| 28 | +def _close_shared_session(): | |
| 29 | + yield | |
| 30 | + close_session() | |
| 31 | + | |
| 32 | + | |
| 33 | +def _fichier_avec_modele(chemin: Path, modele: str) -> None: | |
| 34 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | |
| 35 | + subprocess.run( # noqa: S603, S607 | |
| 36 | + ["exiftool", f"-Model={modele}", "-overwrite_original", str(chemin)], | |
| 37 | + check=True, | |
| 38 | + capture_output=True, | |
| 39 | + ) | |
| 40 | + | |
| 41 | + | |
| 42 | +def test_parcourir_nas_ajoute_les_boitiers_non_encore_connus(tmp_path: Path) -> None: | |
| 43 | + archive_root = tmp_path / "archive" | |
| 44 | + dossier_ancien = archive_root / "2020" / "2020-01-01_Ancien" | |
| 45 | + dossier_ancien.mkdir(parents=True) | |
| 46 | + _fichier_avec_modele(dossier_ancien / "IMG_0001.JPG", "Canon EOS 90D") | |
| 47 | + | |
| 48 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | |
| 49 | + # Boîtier déjà connu via un import (specs/001), à ne pas dupliquer ni modifier. | |
| 50 | + get_or_create_boitier(conn, "Fujifilm X100V", "12345", source="modele") | |
| 51 | + | |
| 52 | + nouveaux = parcourir_nas(conn, archive_root) | |
| 53 | + | |
| 54 | + assert nouveaux == 1 | |
| 55 | + boitiers = {b.modele: b.source for b in list_boitiers(conn)} | |
| 56 | + assert boitiers["Canon EOS 90D"] == "parcours_nas" | |
| 57 | + assert boitiers["Fujifilm X100V"] == "modele" # inchangé | |
| 58 | + conn.close() | |
| 59 | + | |
| 60 | + | |
| 61 | +def test_parcourir_nas_est_idempotent(tmp_path: Path) -> None: | |
| 62 | + archive_root = tmp_path / "archive" | |
| 63 | + dossier = archive_root / "2020" / "2020-01-01_Ancien" | |
| 64 | + dossier.mkdir(parents=True) | |
| 65 | + _fichier_avec_modele(dossier / "IMG_0001.JPG", "Canon EOS 90D") | |
| 66 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | |
| 67 | + | |
| 68 | + premier_parcours = parcourir_nas(conn, archive_root) | |
| 69 | + second_parcours = parcourir_nas(conn, archive_root) | |
| 70 | + | |
| 71 | + assert premier_parcours == 1 | |
| 72 | + assert second_parcours == 0 | |
| 73 | + assert len(list_boitiers(conn)) == 1 | |
| 74 | + conn.close() | |
| new file mode 100644 | |||
| @@ -0,0 +1,74 @@ | |||
| 1 | +"""Test d'intégration du parcours volontaire du NAS (T023, FR-012).""" | ||
| 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.camera_profile.db import get_or_create_boitier, list_boitiers | ||
| 12 | +from regine_core.config.cameras_screen import parcourir_nas | ||
| 13 | +from regine_core.config.db import open_context_db | ||
| 14 | +from regine_core.metadata.exif import close_session | ||
| 15 | + | ||
| 16 | +_JPEG_1X1_BASE64 = ( | ||
| 17 | + "/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8M" | ||
| 18 | + "CgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQ" | ||
| 19 | + "EBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAA" | ||
| 20 | + "AAAAAAAAAAAAAAAAAAj/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAU" | ||
| 21 | + "EQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=" | ||
| 22 | +) | ||
| 23 | + | ||
| 24 | +pytestmark = pytest.mark.skipif(shutil.which("exiftool") is None, reason="exiftool non installé") | ||
| 25 | + | ||
| 26 | + | ||
| 27 | +@pytest.fixture(autouse=True) | ||
| 28 | +def _close_shared_session(): | ||
| 29 | + yield | ||
| 30 | + close_session() | ||
| 31 | + | ||
| 32 | + | ||
| 33 | +def _fichier_avec_modele(chemin: Path, modele: str) -> None: | ||
| 34 | + chemin.write_bytes(base64.b64decode(_JPEG_1X1_BASE64)) | ||
| 35 | + subprocess.run( # noqa: S603, S607 | ||
| 36 | + ["exiftool", f"-Model={modele}", "-overwrite_original", str(chemin)], | ||
| 37 | + check=True, | ||
| 38 | + capture_output=True, | ||
| 39 | + ) | ||
| 40 | + | ||
| 41 | + | ||
| 42 | +def test_parcourir_nas_ajoute_les_boitiers_non_encore_connus(tmp_path: Path) -> None: | ||
| 43 | + archive_root = tmp_path / "archive" | ||
| 44 | + dossier_ancien = archive_root / "2020" / "2020-01-01_Ancien" | ||
| 45 | + dossier_ancien.mkdir(parents=True) | ||
| 46 | + _fichier_avec_modele(dossier_ancien / "IMG_0001.JPG", "Canon EOS 90D") | ||
| 47 | + | ||
| 48 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | ||
| 49 | + # Boîtier déjà connu via un import (specs/001), à ne pas dupliquer ni modifier. | ||
| 50 | + get_or_create_boitier(conn, "Fujifilm X100V", "12345", source="modele") | ||
| 51 | + | ||
| 52 | + nouveaux = parcourir_nas(conn, archive_root) | ||
| 53 | + | ||
| 54 | + assert nouveaux == 1 | ||
| 55 | + boitiers = {b.modele: b.source for b in list_boitiers(conn)} | ||
| 56 | + assert boitiers["Canon EOS 90D"] == "parcours_nas" | ||
| 57 | + assert boitiers["Fujifilm X100V"] == "modele" # inchangé | ||
| 58 | + conn.close() | ||
| 59 | + | ||
| 60 | + | ||
| 61 | +def test_parcourir_nas_est_idempotent(tmp_path: Path) -> None: | ||
| 62 | + archive_root = tmp_path / "archive" | ||
| 63 | + dossier = archive_root / "2020" / "2020-01-01_Ancien" | ||
| 64 | + dossier.mkdir(parents=True) | ||
| 65 | + _fichier_avec_modele(dossier / "IMG_0001.JPG", "Canon EOS 90D") | ||
| 66 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | ||
| 67 | + | ||
| 68 | + premier_parcours = parcourir_nas(conn, archive_root) | ||
| 69 | + second_parcours = parcourir_nas(conn, archive_root) | ||
| 70 | + | ||
| 71 | + assert premier_parcours == 1 | ||
| 72 | + assert second_parcours == 0 | ||
| 73 | + assert len(list_boitiers(conn)) == 1 | ||
| 74 | + conn.close() | ||
added
packages/regine-core/tests/integration/test_config_context.py +82 -0 | new file mode 100644 | ||
| @@ -0,0 +1,82 @@ | ||
| 1 | +"""Test d'intégration du cycle configuration -> initialisation -> changement bloqué (T014). | |
| 2 | + | |
| 3 | +Couvre les Acceptance Scenarios 1 à 5 de User Story 1 (spec.md) comme un seul | |
| 4 | +parcours cohérent, plutôt que de re-tester chaque règle isolément (déjà couvert au | |
| 5 | +niveau unitaire par test_context_paths.py). | |
| 6 | +""" | |
| 7 | + | |
| 8 | +from __future__ import annotations | |
| 9 | + | |
| 10 | +from pathlib import Path | |
| 11 | + | |
| 12 | +import pytest | |
| 13 | +from regine_core.archive.checkout import checkout | |
| 14 | +from regine_core.config.context import ( | |
| 15 | + TravailNonReconcilieError, | |
| 16 | + charger_contexte, | |
| 17 | + enregistrer_chemins, | |
| 18 | +) | |
| 19 | +from regine_core.config.db import open_context_db | |
| 20 | + | |
| 21 | + | |
| 22 | +def test_cycle_configuration_initialisation_et_changement_bloque(tmp_path: Path) -> None: | |
| 23 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | |
| 24 | + | |
| 25 | + # Scenario 1 : installation neuve, configuration des 3 emplacements. | |
| 26 | + temp_dir = tmp_path / "tmp" | |
| 27 | + local_work_dir = tmp_path / "travail" | |
| 28 | + archive_smb_path = "smb://nas.local/regine-archive" | |
| 29 | + | |
| 30 | + # Scenario 2 : les répertoires n'existent pas encore -> proposés à la création. | |
| 31 | + assert not temp_dir.exists() | |
| 32 | + assert not local_work_dir.exists() | |
| 33 | + | |
| 34 | + contexte = enregistrer_chemins( | |
| 35 | + conn, | |
| 36 | + temp_dir=temp_dir, | |
| 37 | + local_work_dir=local_work_dir, | |
| 38 | + archive_smb_path=archive_smb_path, | |
| 39 | + creer_repertoires_manquants=True, | |
| 40 | + ) | |
| 41 | + assert temp_dir.exists() | |
| 42 | + assert local_work_dir.exists() | |
| 43 | + | |
| 44 | + # Scenario 3 : une base de travail est prête à la racine du répertoire local, | |
| 45 | + # avant qu'aucun import/checkout n'en ait eu besoin (FR-006/SC-003). | |
| 46 | + assert (local_work_dir / ".regine-manifest.sqlite3").exists() | |
| 47 | + | |
| 48 | + # Une autre fonctionnalité (import, cf. specs/001) peut relire ce contexte sans | |
| 49 | + # reconfiguration supplémentaire : simulé ici par un simple rechargement. | |
| 50 | + relu = charger_contexte(conn) | |
| 51 | + assert relu.temp_dir == temp_dir | |
| 52 | + assert relu.local_work_dir == local_work_dir | |
| 53 | + assert relu.archive_smb_path == archive_smb_path | |
| 54 | + | |
| 55 | + # Scenario 4 : modifier un chemin ne perd pas les autres paramètres déjà définis. | |
| 56 | + nouveau_temp = tmp_path / "tmp2" | |
| 57 | + contexte = enregistrer_chemins(conn, temp_dir=nouveau_temp, creer_repertoires_manquants=True) | |
| 58 | + assert contexte.temp_dir == nouveau_temp | |
| 59 | + assert contexte.local_work_dir == local_work_dir | |
| 60 | + assert contexte.archive_smb_path == archive_smb_path | |
| 61 | + | |
| 62 | + # Scenario 5 : un checkout non réconcilié sous local_work_dir bloque son | |
| 63 | + # changement, en indiquant précisément le dossier concerné. | |
| 64 | + archive_root_local = tmp_path / "archive" | |
| 65 | + (archive_root_local / "2026-08-15_Sortie").mkdir(parents=True) | |
| 66 | + (archive_root_local / "2026-08-15_Sortie" / "photo.jpg").write_bytes(b"contenu") | |
| 67 | + dest_locale = local_work_dir / "2026-08-15_Sortie" | |
| 68 | + checkout(archive_root_local / "2026-08-15_Sortie", dest_locale) | |
| 69 | + | |
| 70 | + import regine_core.config.smb as smb_module | |
| 71 | + | |
| 72 | + original_chemin_local_du_partage = smb_module.chemin_local_du_partage | |
| 73 | + smb_module.chemin_local_du_partage = lambda _chemin: archive_root_local | |
| 74 | + try: | |
| 75 | + with pytest.raises(TravailNonReconcilieError) as exc_info: | |
| 76 | + enregistrer_chemins( | |
| 77 | + conn, local_work_dir=tmp_path / "autre-travail", creer_repertoires_manquants=True | |
| 78 | + ) | |
| 79 | + assert dest_locale in exc_info.value.dossiers | |
| 80 | + finally: | |
| 81 | + smb_module.chemin_local_du_partage = original_chemin_local_du_partage | |
| 82 | + conn.close() | |
| new file mode 100644 | |||
| @@ -0,0 +1,82 @@ | |||
| 1 | +"""Test d'intégration du cycle configuration -> initialisation -> changement bloqué (T014). | ||
| 2 | + | ||
| 3 | +Couvre les Acceptance Scenarios 1 à 5 de User Story 1 (spec.md) comme un seul | ||
| 4 | +parcours cohérent, plutôt que de re-tester chaque règle isolément (déjà couvert au | ||
| 5 | +niveau unitaire par test_context_paths.py). | ||
| 6 | +""" | ||
| 7 | + | ||
| 8 | +from __future__ import annotations | ||
| 9 | + | ||
| 10 | +from pathlib import Path | ||
| 11 | + | ||
| 12 | +import pytest | ||
| 13 | +from regine_core.archive.checkout import checkout | ||
| 14 | +from regine_core.config.context import ( | ||
| 15 | + TravailNonReconcilieError, | ||
| 16 | + charger_contexte, | ||
| 17 | + enregistrer_chemins, | ||
| 18 | +) | ||
| 19 | +from regine_core.config.db import open_context_db | ||
| 20 | + | ||
| 21 | + | ||
| 22 | +def test_cycle_configuration_initialisation_et_changement_bloque(tmp_path: Path) -> None: | ||
| 23 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | ||
| 24 | + | ||
| 25 | + # Scenario 1 : installation neuve, configuration des 3 emplacements. | ||
| 26 | + temp_dir = tmp_path / "tmp" | ||
| 27 | + local_work_dir = tmp_path / "travail" | ||
| 28 | + archive_smb_path = "smb://nas.local/regine-archive" | ||
| 29 | + | ||
| 30 | + # Scenario 2 : les répertoires n'existent pas encore -> proposés à la création. | ||
| 31 | + assert not temp_dir.exists() | ||
| 32 | + assert not local_work_dir.exists() | ||
| 33 | + | ||
| 34 | + contexte = enregistrer_chemins( | ||
| 35 | + conn, | ||
| 36 | + temp_dir=temp_dir, | ||
| 37 | + local_work_dir=local_work_dir, | ||
| 38 | + archive_smb_path=archive_smb_path, | ||
| 39 | + creer_repertoires_manquants=True, | ||
| 40 | + ) | ||
| 41 | + assert temp_dir.exists() | ||
| 42 | + assert local_work_dir.exists() | ||
| 43 | + | ||
| 44 | + # Scenario 3 : une base de travail est prête à la racine du répertoire local, | ||
| 45 | + # avant qu'aucun import/checkout n'en ait eu besoin (FR-006/SC-003). | ||
| 46 | + assert (local_work_dir / ".regine-manifest.sqlite3").exists() | ||
| 47 | + | ||
| 48 | + # Une autre fonctionnalité (import, cf. specs/001) peut relire ce contexte sans | ||
| 49 | + # reconfiguration supplémentaire : simulé ici par un simple rechargement. | ||
| 50 | + relu = charger_contexte(conn) | ||
| 51 | + assert relu.temp_dir == temp_dir | ||
| 52 | + assert relu.local_work_dir == local_work_dir | ||
| 53 | + assert relu.archive_smb_path == archive_smb_path | ||
| 54 | + | ||
| 55 | + # Scenario 4 : modifier un chemin ne perd pas les autres paramètres déjà définis. | ||
| 56 | + nouveau_temp = tmp_path / "tmp2" | ||
| 57 | + contexte = enregistrer_chemins(conn, temp_dir=nouveau_temp, creer_repertoires_manquants=True) | ||
| 58 | + assert contexte.temp_dir == nouveau_temp | ||
| 59 | + assert contexte.local_work_dir == local_work_dir | ||
| 60 | + assert contexte.archive_smb_path == archive_smb_path | ||
| 61 | + | ||
| 62 | + # Scenario 5 : un checkout non réconcilié sous local_work_dir bloque son | ||
| 63 | + # changement, en indiquant précisément le dossier concerné. | ||
| 64 | + archive_root_local = tmp_path / "archive" | ||
| 65 | + (archive_root_local / "2026-08-15_Sortie").mkdir(parents=True) | ||
| 66 | + (archive_root_local / "2026-08-15_Sortie" / "photo.jpg").write_bytes(b"contenu") | ||
| 67 | + dest_locale = local_work_dir / "2026-08-15_Sortie" | ||
| 68 | + checkout(archive_root_local / "2026-08-15_Sortie", dest_locale) | ||
| 69 | + | ||
| 70 | + import regine_core.config.smb as smb_module | ||
| 71 | + | ||
| 72 | + original_chemin_local_du_partage = smb_module.chemin_local_du_partage | ||
| 73 | + smb_module.chemin_local_du_partage = lambda _chemin: archive_root_local | ||
| 74 | + try: | ||
| 75 | + with pytest.raises(TravailNonReconcilieError) as exc_info: | ||
| 76 | + enregistrer_chemins( | ||
| 77 | + conn, local_work_dir=tmp_path / "autre-travail", creer_repertoires_manquants=True | ||
| 78 | + ) | ||
| 79 | + assert dest_locale in exc_info.value.dossiers | ||
| 80 | + finally: | ||
| 81 | + smb_module.chemin_local_du_partage = original_chemin_local_du_partage | ||
| 82 | + conn.close() | ||
added
packages/regine-core/tests/unit/test_cameras_screen.py +40 -0 | new file mode 100644 | ||
| @@ -0,0 +1,40 @@ | ||
| 1 | +"""Tests de `regine_core.config.cameras_screen` (T022).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +import pytest | |
| 8 | +from regine_core.camera_profile.db import get_or_create_boitier | |
| 9 | +from regine_core.config.cameras_screen import BoitierInconnuError, lister_boitiers, nommer_boitier | |
| 10 | +from regine_core.config.db import open_context_db | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_lister_boitiers_reflete_ceux_deja_enregistres(tmp_path: Path) -> None: | |
| 14 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | |
| 15 | + get_or_create_boitier(conn, "Fujifilm X100V", "12345", source="modele") | |
| 16 | + | |
| 17 | + boitiers = lister_boitiers(conn) | |
| 18 | + | |
| 19 | + assert len(boitiers) == 1 | |
| 20 | + assert boitiers[0].modele == "Fujifilm X100V" | |
| 21 | + assert boitiers[0].nom_lisible is None | |
| 22 | + conn.close() | |
| 23 | + | |
| 24 | + | |
| 25 | +def test_nommer_boitier_connu(tmp_path: Path) -> None: | |
| 26 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | |
| 27 | + boitier_id = get_or_create_boitier(conn, "Fujifilm X100V", None, source="modele") | |
| 28 | + | |
| 29 | + nommer_boitier(conn, boitier_id, "Fuji principal") | |
| 30 | + | |
| 31 | + assert lister_boitiers(conn)[0].nom_lisible == "Fuji principal" | |
| 32 | + conn.close() | |
| 33 | + | |
| 34 | + | |
| 35 | +def test_nommer_boitier_inconnu_leve(tmp_path: Path) -> None: | |
| 36 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | |
| 37 | + | |
| 38 | + with pytest.raises(BoitierInconnuError): | |
| 39 | + nommer_boitier(conn, 999, "Nom") | |
| 40 | + conn.close() | |
| new file mode 100644 | |||
| @@ -0,0 +1,40 @@ | |||
| 1 | +"""Tests de `regine_core.config.cameras_screen` (T022).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +import pytest | ||
| 8 | +from regine_core.camera_profile.db import get_or_create_boitier | ||
| 9 | +from regine_core.config.cameras_screen import BoitierInconnuError, lister_boitiers, nommer_boitier | ||
| 10 | +from regine_core.config.db import open_context_db | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +def test_lister_boitiers_reflete_ceux_deja_enregistres(tmp_path: Path) -> None: | ||
| 14 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | ||
| 15 | + get_or_create_boitier(conn, "Fujifilm X100V", "12345", source="modele") | ||
| 16 | + | ||
| 17 | + boitiers = lister_boitiers(conn) | ||
| 18 | + | ||
| 19 | + assert len(boitiers) == 1 | ||
| 20 | + assert boitiers[0].modele == "Fujifilm X100V" | ||
| 21 | + assert boitiers[0].nom_lisible is None | ||
| 22 | + conn.close() | ||
| 23 | + | ||
| 24 | + | ||
| 25 | +def test_nommer_boitier_connu(tmp_path: Path) -> None: | ||
| 26 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | ||
| 27 | + boitier_id = get_or_create_boitier(conn, "Fujifilm X100V", None, source="modele") | ||
| 28 | + | ||
| 29 | + nommer_boitier(conn, boitier_id, "Fuji principal") | ||
| 30 | + | ||
| 31 | + assert lister_boitiers(conn)[0].nom_lisible == "Fuji principal" | ||
| 32 | + conn.close() | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +def test_nommer_boitier_inconnu_leve(tmp_path: Path) -> None: | ||
| 36 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | ||
| 37 | + | ||
| 38 | + with pytest.raises(BoitierInconnuError): | ||
| 39 | + nommer_boitier(conn, 999, "Nom") | ||
| 40 | + conn.close() | ||
added
packages/regine-core/tests/unit/test_config_db_defaut.py +30 -0 | new file mode 100644 | ||
| @@ -0,0 +1,30 @@ | ||
| 1 | +"""Test du chemin par défaut de la base de contexte centralisée (T006).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +import regine_core.config.db as db_module | |
| 8 | +from regine_core.config.db import APPLICATION_ID, open_context_db | |
| 9 | + | |
| 10 | + | |
| 11 | +def test_open_context_db_sans_argument_utilise_le_chemin_par_defaut( | |
| 12 | + tmp_path: Path, monkeypatch | |
| 13 | +) -> None: | |
| 14 | + chemin_attendu = tmp_path / "home" / ".regine" / "contexte.sqlite3" | |
| 15 | + monkeypatch.setattr(db_module, "CHEMIN_CONTEXTE_PAR_DEFAUT", chemin_attendu) | |
| 16 | + | |
| 17 | + conn = open_context_db() | |
| 18 | + | |
| 19 | + assert chemin_attendu.exists() | |
| 20 | + assert conn.execute("PRAGMA application_id").fetchone()[0] == APPLICATION_ID | |
| 21 | + conn.close() | |
| 22 | + | |
| 23 | + | |
| 24 | +def test_open_context_db_avec_argument_explicite_ignore_le_defaut(tmp_path: Path) -> None: | |
| 25 | + chemin_explicite = tmp_path / "autre" / "contexte.sqlite3" | |
| 26 | + | |
| 27 | + conn = open_context_db(chemin_explicite) | |
| 28 | + | |
| 29 | + assert chemin_explicite.exists() | |
| 30 | + conn.close() | |
| new file mode 100644 | |||
| @@ -0,0 +1,30 @@ | |||
| 1 | +"""Test du chemin par défaut de la base de contexte centralisée (T006).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +import regine_core.config.db as db_module | ||
| 8 | +from regine_core.config.db import APPLICATION_ID, open_context_db | ||
| 9 | + | ||
| 10 | + | ||
| 11 | +def test_open_context_db_sans_argument_utilise_le_chemin_par_defaut( | ||
| 12 | + tmp_path: Path, monkeypatch | ||
| 13 | +) -> None: | ||
| 14 | + chemin_attendu = tmp_path / "home" / ".regine" / "contexte.sqlite3" | ||
| 15 | + monkeypatch.setattr(db_module, "CHEMIN_CONTEXTE_PAR_DEFAUT", chemin_attendu) | ||
| 16 | + | ||
| 17 | + conn = open_context_db() | ||
| 18 | + | ||
| 19 | + assert chemin_attendu.exists() | ||
| 20 | + assert conn.execute("PRAGMA application_id").fetchone()[0] == APPLICATION_ID | ||
| 21 | + conn.close() | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +def test_open_context_db_avec_argument_explicite_ignore_le_defaut(tmp_path: Path) -> None: | ||
| 25 | + chemin_explicite = tmp_path / "autre" / "contexte.sqlite3" | ||
| 26 | + | ||
| 27 | + conn = open_context_db(chemin_explicite) | ||
| 28 | + | ||
| 29 | + assert chemin_explicite.exists() | ||
| 30 | + conn.close() | ||
added
packages/regine-core/tests/unit/test_context_paths.py +257 -0 | new file mode 100644 | ||
| @@ -0,0 +1,257 @@ | ||
| 1 | +"""Tests de `regine_core.config.context` (T012, T013).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +import pytest | |
| 8 | +from regine_core.archive.checkout import checkout | |
| 9 | +from regine_core.archive.reconciliation import DecisionsUtilisateur, archiver, comparer | |
| 10 | +from regine_core.config.context import ( | |
| 11 | + ArchiveInaccessibleError, | |
| 12 | + CheminArchiveInvalideError, | |
| 13 | + CheminsAmbigusError, | |
| 14 | + RepertoireAbsentError, | |
| 15 | + TravailNonReconcilieError, | |
| 16 | + charger_contexte, | |
| 17 | + enregistrer_chemins, | |
| 18 | + lister_dossiers_non_reconcilies, | |
| 19 | + mettre_a_jour_etat_smb, | |
| 20 | +) | |
| 21 | +from regine_core.config.db import open_context_db | |
| 22 | + | |
| 23 | + | |
| 24 | +def _conn(tmp_path: Path): | |
| 25 | + return open_context_db(tmp_path / "contexte.sqlite3") | |
| 26 | + | |
| 27 | + | |
| 28 | +def test_charger_contexte_vide_par_defaut(tmp_path: Path) -> None: | |
| 29 | + conn = _conn(tmp_path) | |
| 30 | + | |
| 31 | + contexte = charger_contexte(conn) | |
| 32 | + | |
| 33 | + assert contexte.temp_dir is None | |
| 34 | + assert contexte.local_work_dir is None | |
| 35 | + assert contexte.archive_smb_path is None | |
| 36 | + assert contexte.smb_last_known_state == "unknown" | |
| 37 | + conn.close() | |
| 38 | + | |
| 39 | + | |
| 40 | +def test_enregistrer_chemins_configuration_initiale(tmp_path: Path) -> None: | |
| 41 | + conn = _conn(tmp_path) | |
| 42 | + temp_dir = tmp_path / "tmp" | |
| 43 | + local_work_dir = tmp_path / "travail" | |
| 44 | + | |
| 45 | + contexte = enregistrer_chemins( | |
| 46 | + conn, | |
| 47 | + temp_dir=temp_dir, | |
| 48 | + local_work_dir=local_work_dir, | |
| 49 | + archive_smb_path="smb://nas.local/regine-archive", | |
| 50 | + creer_repertoires_manquants=True, | |
| 51 | + ) | |
| 52 | + | |
| 53 | + assert contexte.temp_dir == temp_dir | |
| 54 | + assert contexte.local_work_dir == local_work_dir | |
| 55 | + assert contexte.archive_smb_path == "smb://nas.local/regine-archive" | |
| 56 | + assert temp_dir.exists() | |
| 57 | + assert local_work_dir.exists() | |
| 58 | + assert (local_work_dir / ".regine-manifest.sqlite3").exists() # FR-006/SC-003 | |
| 59 | + conn.close() | |
| 60 | + | |
| 61 | + | |
| 62 | +def test_enregistrer_chemins_repertoire_absent_sans_creation_leve(tmp_path: Path) -> None: | |
| 63 | + conn = _conn(tmp_path) | |
| 64 | + | |
| 65 | + with pytest.raises(RepertoireAbsentError): | |
| 66 | + enregistrer_chemins(conn, temp_dir=tmp_path / "absent") | |
| 67 | + conn.close() | |
| 68 | + | |
| 69 | + | |
| 70 | +def test_enregistrer_chemins_repertoire_absent_avec_creation_reussit(tmp_path: Path) -> None: | |
| 71 | + conn = _conn(tmp_path) | |
| 72 | + chemin = tmp_path / "nouveau" | |
| 73 | + | |
| 74 | + enregistrer_chemins(conn, temp_dir=chemin, creer_repertoires_manquants=True) | |
| 75 | + | |
| 76 | + assert chemin.exists() | |
| 77 | + conn.close() | |
| 78 | + | |
| 79 | + | |
| 80 | +def test_enregistrer_chemins_collision_sans_force_leve(tmp_path: Path) -> None: | |
| 81 | + conn = _conn(tmp_path) | |
| 82 | + meme_chemin = tmp_path / "meme" | |
| 83 | + | |
| 84 | + with pytest.raises(CheminsAmbigusError): | |
| 85 | + enregistrer_chemins( | |
| 86 | + conn, | |
| 87 | + temp_dir=meme_chemin, | |
| 88 | + local_work_dir=meme_chemin, | |
| 89 | + creer_repertoires_manquants=True, | |
| 90 | + ) | |
| 91 | + conn.close() | |
| 92 | + | |
| 93 | + | |
| 94 | +def test_enregistrer_chemins_collision_avec_force_reussit(tmp_path: Path) -> None: | |
| 95 | + conn = _conn(tmp_path) | |
| 96 | + meme_chemin = tmp_path / "meme" | |
| 97 | + | |
| 98 | + contexte = enregistrer_chemins( | |
| 99 | + conn, | |
| 100 | + temp_dir=meme_chemin, | |
| 101 | + local_work_dir=meme_chemin, | |
| 102 | + creer_repertoires_manquants=True, | |
| 103 | + forcer_collision=True, | |
| 104 | + ) | |
| 105 | + | |
| 106 | + assert contexte.temp_dir == meme_chemin | |
| 107 | + assert contexte.local_work_dir == meme_chemin | |
| 108 | + conn.close() | |
| 109 | + | |
| 110 | + | |
| 111 | +def test_enregistrer_chemins_archive_smb_invalide_leve(tmp_path: Path) -> None: | |
| 112 | + conn = _conn(tmp_path) | |
| 113 | + | |
| 114 | + with pytest.raises(CheminArchiveInvalideError): | |
| 115 | + enregistrer_chemins(conn, archive_smb_path="/Volumes/regine-archive") | |
| 116 | + conn.close() | |
| 117 | + | |
| 118 | + | |
| 119 | +def test_enregistrer_chemins_conserve_les_champs_non_fournis(tmp_path: Path) -> None: | |
| 120 | + conn = _conn(tmp_path) | |
| 121 | + temp_dir = tmp_path / "tmp" | |
| 122 | + local_work_dir = tmp_path / "travail" | |
| 123 | + enregistrer_chemins( | |
| 124 | + conn, | |
| 125 | + temp_dir=temp_dir, | |
| 126 | + local_work_dir=local_work_dir, | |
| 127 | + archive_smb_path="smb://nas.local/regine-archive", | |
| 128 | + creer_repertoires_manquants=True, | |
| 129 | + ) | |
| 130 | + | |
| 131 | + nouveau_temp = tmp_path / "tmp2" | |
| 132 | + contexte = enregistrer_chemins(conn, temp_dir=nouveau_temp, creer_repertoires_manquants=True) | |
| 133 | + | |
| 134 | + assert contexte.temp_dir == nouveau_temp | |
| 135 | + assert contexte.local_work_dir == local_work_dir # SC-005 : inchangé | |
| 136 | + assert contexte.archive_smb_path == "smb://nas.local/regine-archive" | |
| 137 | + conn.close() | |
| 138 | + | |
| 139 | + | |
| 140 | +def test_mettre_a_jour_etat_smb(tmp_path: Path) -> None: | |
| 141 | + conn = _conn(tmp_path) | |
| 142 | + | |
| 143 | + mettre_a_jour_etat_smb(conn, "unavailable") | |
| 144 | + | |
| 145 | + assert charger_contexte(conn).smb_last_known_state == "unavailable" | |
| 146 | + conn.close() | |
| 147 | + | |
| 148 | + | |
| 149 | +def test_lister_dossiers_non_reconcilies_detecte_un_dossier_verrouille(tmp_path: Path) -> None: | |
| 150 | + archive_root_local = tmp_path / "archive" | |
| 151 | + archive_root = archive_root_local / "2026-08-15_Sortie" | |
| 152 | + archive_root.mkdir(parents=True) | |
| 153 | + (archive_root / "photo.jpg").write_bytes(b"contenu") | |
| 154 | + local_work_dir = tmp_path / "travail" | |
| 155 | + dest_locale = local_work_dir / "2026-08-15_Sortie" | |
| 156 | + | |
| 157 | + checkout(archive_root, dest_locale) # pose le verrou, jamais réconcilié ensuite | |
| 158 | + | |
| 159 | + assert lister_dossiers_non_reconcilies(local_work_dir, archive_root_local) == [dest_locale] | |
| 160 | + | |
| 161 | + | |
| 162 | +def test_lister_dossiers_non_reconcilies_ignore_un_dossier_reconcilie(tmp_path: Path) -> None: | |
| 163 | + archive_root_local = tmp_path / "archive" | |
| 164 | + archive_root = archive_root_local / "2026-08-15_Sortie" | |
| 165 | + archive_root.mkdir(parents=True) | |
| 166 | + (archive_root / "photo.jpg").write_bytes(b"contenu") | |
| 167 | + local_work_dir = tmp_path / "travail" | |
| 168 | + dest_locale = local_work_dir / "2026-08-15_Sortie" | |
| 169 | + | |
| 170 | + snapshot = checkout(archive_root, dest_locale) | |
| 171 | + rapport = comparer(snapshot, dest_locale) | |
| 172 | + archiver(snapshot, rapport, DecisionsUtilisateur()) # lève le verrou | |
| 173 | + | |
| 174 | + assert lister_dossiers_non_reconcilies(local_work_dir, archive_root_local) == [] | |
| 175 | + | |
| 176 | + | |
| 177 | +def test_lister_dossiers_non_reconcilies_leve_si_archive_inaccessible(tmp_path: Path) -> None: | |
| 178 | + with pytest.raises(ArchiveInaccessibleError): | |
| 179 | + lister_dossiers_non_reconcilies(tmp_path / "travail", tmp_path / "archive-absente") | |
| 180 | + | |
| 181 | + | |
| 182 | +def test_enregistrer_chemins_refuse_local_work_dir_si_travail_non_reconcilie( | |
| 183 | + tmp_path: Path, monkeypatch | |
| 184 | +) -> None: | |
| 185 | + conn = _conn(tmp_path) | |
| 186 | + archive_root_local = tmp_path / "archive" | |
| 187 | + archive_root = archive_root_local / "2026-08-15_Sortie" | |
| 188 | + archive_root.mkdir(parents=True) | |
| 189 | + (archive_root / "photo.jpg").write_bytes(b"contenu") | |
| 190 | + ancien_local = tmp_path / "travail" | |
| 191 | + dest_locale = ancien_local / "2026-08-15_Sortie" | |
| 192 | + checkout(archive_root, dest_locale) # verrouillé, jamais réconcilié | |
| 193 | + | |
| 194 | + import regine_core.config.smb as smb_module | |
| 195 | + | |
| 196 | + monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _chemin: archive_root_local) | |
| 197 | + | |
| 198 | + enregistrer_chemins( | |
| 199 | + conn, | |
| 200 | + local_work_dir=ancien_local, | |
| 201 | + archive_smb_path="smb://nas.local/archive", | |
| 202 | + creer_repertoires_manquants=True, | |
| 203 | + ) | |
| 204 | + | |
| 205 | + with pytest.raises(TravailNonReconcilieError) as exc_info: | |
| 206 | + enregistrer_chemins( | |
| 207 | + conn, local_work_dir=tmp_path / "autre-travail", creer_repertoires_manquants=True | |
| 208 | + ) | |
| 209 | + assert exc_info.value.dossiers == [dest_locale] | |
| 210 | + conn.close() | |
| 211 | + | |
| 212 | + | |
| 213 | +def test_enregistrer_chemins_sans_archive_configuree_ne_verifie_rien(tmp_path: Path) -> None: | |
| 214 | + """Aucune archive configurée : rien à vérifier, le changement est autorisé.""" | |
| 215 | + conn = _conn(tmp_path) | |
| 216 | + ancien_local = tmp_path / "travail" | |
| 217 | + enregistrer_chemins(conn, local_work_dir=ancien_local, creer_repertoires_manquants=True) | |
| 218 | + | |
| 219 | + contexte = enregistrer_chemins( | |
| 220 | + conn, local_work_dir=tmp_path / "autre-travail", creer_repertoires_manquants=True | |
| 221 | + ) | |
| 222 | + | |
| 223 | + assert contexte.local_work_dir == tmp_path / "autre-travail" | |
| 224 | + conn.close() | |
| 225 | + | |
| 226 | + | |
| 227 | +def test_enregistrer_chemins_ignorer_verification_archive_passe_outre( | |
| 228 | + tmp_path: Path, monkeypatch | |
| 229 | +) -> None: | |
| 230 | + conn = _conn(tmp_path) | |
| 231 | + archive_root_local = tmp_path / "archive" | |
| 232 | + archive_root = archive_root_local / "2026-08-15_Sortie" | |
| 233 | + archive_root.mkdir(parents=True) | |
| 234 | + (archive_root / "photo.jpg").write_bytes(b"contenu") | |
| 235 | + ancien_local = tmp_path / "travail" | |
| 236 | + dest_locale = ancien_local / "2026-08-15_Sortie" | |
| 237 | + checkout(archive_root, dest_locale) # verrouillé, jamais réconcilié | |
| 238 | + | |
| 239 | + import regine_core.config.smb as smb_module | |
| 240 | + | |
| 241 | + monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _chemin: archive_root_local) | |
| 242 | + enregistrer_chemins( | |
| 243 | + conn, | |
| 244 | + local_work_dir=ancien_local, | |
| 245 | + archive_smb_path="smb://nas.local/archive", | |
| 246 | + creer_repertoires_manquants=True, | |
| 247 | + ) | |
| 248 | + | |
| 249 | + contexte = enregistrer_chemins( | |
| 250 | + conn, | |
| 251 | + local_work_dir=tmp_path / "autre-travail", | |
| 252 | + creer_repertoires_manquants=True, | |
| 253 | + ignorer_verification_archive=True, | |
| 254 | + ) | |
| 255 | + | |
| 256 | + assert contexte.local_work_dir == tmp_path / "autre-travail" | |
| 257 | + conn.close() | |
| new file mode 100644 | |||
| @@ -0,0 +1,257 @@ | |||
| 1 | +"""Tests de `regine_core.config.context` (T012, T013).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +import pytest | ||
| 8 | +from regine_core.archive.checkout import checkout | ||
| 9 | +from regine_core.archive.reconciliation import DecisionsUtilisateur, archiver, comparer | ||
| 10 | +from regine_core.config.context import ( | ||
| 11 | + ArchiveInaccessibleError, | ||
| 12 | + CheminArchiveInvalideError, | ||
| 13 | + CheminsAmbigusError, | ||
| 14 | + RepertoireAbsentError, | ||
| 15 | + TravailNonReconcilieError, | ||
| 16 | + charger_contexte, | ||
| 17 | + enregistrer_chemins, | ||
| 18 | + lister_dossiers_non_reconcilies, | ||
| 19 | + mettre_a_jour_etat_smb, | ||
| 20 | +) | ||
| 21 | +from regine_core.config.db import open_context_db | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +def _conn(tmp_path: Path): | ||
| 25 | + return open_context_db(tmp_path / "contexte.sqlite3") | ||
| 26 | + | ||
| 27 | + | ||
| 28 | +def test_charger_contexte_vide_par_defaut(tmp_path: Path) -> None: | ||
| 29 | + conn = _conn(tmp_path) | ||
| 30 | + | ||
| 31 | + contexte = charger_contexte(conn) | ||
| 32 | + | ||
| 33 | + assert contexte.temp_dir is None | ||
| 34 | + assert contexte.local_work_dir is None | ||
| 35 | + assert contexte.archive_smb_path is None | ||
| 36 | + assert contexte.smb_last_known_state == "unknown" | ||
| 37 | + conn.close() | ||
| 38 | + | ||
| 39 | + | ||
| 40 | +def test_enregistrer_chemins_configuration_initiale(tmp_path: Path) -> None: | ||
| 41 | + conn = _conn(tmp_path) | ||
| 42 | + temp_dir = tmp_path / "tmp" | ||
| 43 | + local_work_dir = tmp_path / "travail" | ||
| 44 | + | ||
| 45 | + contexte = enregistrer_chemins( | ||
| 46 | + conn, | ||
| 47 | + temp_dir=temp_dir, | ||
| 48 | + local_work_dir=local_work_dir, | ||
| 49 | + archive_smb_path="smb://nas.local/regine-archive", | ||
| 50 | + creer_repertoires_manquants=True, | ||
| 51 | + ) | ||
| 52 | + | ||
| 53 | + assert contexte.temp_dir == temp_dir | ||
| 54 | + assert contexte.local_work_dir == local_work_dir | ||
| 55 | + assert contexte.archive_smb_path == "smb://nas.local/regine-archive" | ||
| 56 | + assert temp_dir.exists() | ||
| 57 | + assert local_work_dir.exists() | ||
| 58 | + assert (local_work_dir / ".regine-manifest.sqlite3").exists() # FR-006/SC-003 | ||
| 59 | + conn.close() | ||
| 60 | + | ||
| 61 | + | ||
| 62 | +def test_enregistrer_chemins_repertoire_absent_sans_creation_leve(tmp_path: Path) -> None: | ||
| 63 | + conn = _conn(tmp_path) | ||
| 64 | + | ||
| 65 | + with pytest.raises(RepertoireAbsentError): | ||
| 66 | + enregistrer_chemins(conn, temp_dir=tmp_path / "absent") | ||
| 67 | + conn.close() | ||
| 68 | + | ||
| 69 | + | ||
| 70 | +def test_enregistrer_chemins_repertoire_absent_avec_creation_reussit(tmp_path: Path) -> None: | ||
| 71 | + conn = _conn(tmp_path) | ||
| 72 | + chemin = tmp_path / "nouveau" | ||
| 73 | + | ||
| 74 | + enregistrer_chemins(conn, temp_dir=chemin, creer_repertoires_manquants=True) | ||
| 75 | + | ||
| 76 | + assert chemin.exists() | ||
| 77 | + conn.close() | ||
| 78 | + | ||
| 79 | + | ||
| 80 | +def test_enregistrer_chemins_collision_sans_force_leve(tmp_path: Path) -> None: | ||
| 81 | + conn = _conn(tmp_path) | ||
| 82 | + meme_chemin = tmp_path / "meme" | ||
| 83 | + | ||
| 84 | + with pytest.raises(CheminsAmbigusError): | ||
| 85 | + enregistrer_chemins( | ||
| 86 | + conn, | ||
| 87 | + temp_dir=meme_chemin, | ||
| 88 | + local_work_dir=meme_chemin, | ||
| 89 | + creer_repertoires_manquants=True, | ||
| 90 | + ) | ||
| 91 | + conn.close() | ||
| 92 | + | ||
| 93 | + | ||
| 94 | +def test_enregistrer_chemins_collision_avec_force_reussit(tmp_path: Path) -> None: | ||
| 95 | + conn = _conn(tmp_path) | ||
| 96 | + meme_chemin = tmp_path / "meme" | ||
| 97 | + | ||
| 98 | + contexte = enregistrer_chemins( | ||
| 99 | + conn, | ||
| 100 | + temp_dir=meme_chemin, | ||
| 101 | + local_work_dir=meme_chemin, | ||
| 102 | + creer_repertoires_manquants=True, | ||
| 103 | + forcer_collision=True, | ||
| 104 | + ) | ||
| 105 | + | ||
| 106 | + assert contexte.temp_dir == meme_chemin | ||
| 107 | + assert contexte.local_work_dir == meme_chemin | ||
| 108 | + conn.close() | ||
| 109 | + | ||
| 110 | + | ||
| 111 | +def test_enregistrer_chemins_archive_smb_invalide_leve(tmp_path: Path) -> None: | ||
| 112 | + conn = _conn(tmp_path) | ||
| 113 | + | ||
| 114 | + with pytest.raises(CheminArchiveInvalideError): | ||
| 115 | + enregistrer_chemins(conn, archive_smb_path="/Volumes/regine-archive") | ||
| 116 | + conn.close() | ||
| 117 | + | ||
| 118 | + | ||
| 119 | +def test_enregistrer_chemins_conserve_les_champs_non_fournis(tmp_path: Path) -> None: | ||
| 120 | + conn = _conn(tmp_path) | ||
| 121 | + temp_dir = tmp_path / "tmp" | ||
| 122 | + local_work_dir = tmp_path / "travail" | ||
| 123 | + enregistrer_chemins( | ||
| 124 | + conn, | ||
| 125 | + temp_dir=temp_dir, | ||
| 126 | + local_work_dir=local_work_dir, | ||
| 127 | + archive_smb_path="smb://nas.local/regine-archive", | ||
| 128 | + creer_repertoires_manquants=True, | ||
| 129 | + ) | ||
| 130 | + | ||
| 131 | + nouveau_temp = tmp_path / "tmp2" | ||
| 132 | + contexte = enregistrer_chemins(conn, temp_dir=nouveau_temp, creer_repertoires_manquants=True) | ||
| 133 | + | ||
| 134 | + assert contexte.temp_dir == nouveau_temp | ||
| 135 | + assert contexte.local_work_dir == local_work_dir # SC-005 : inchangé | ||
| 136 | + assert contexte.archive_smb_path == "smb://nas.local/regine-archive" | ||
| 137 | + conn.close() | ||
| 138 | + | ||
| 139 | + | ||
| 140 | +def test_mettre_a_jour_etat_smb(tmp_path: Path) -> None: | ||
| 141 | + conn = _conn(tmp_path) | ||
| 142 | + | ||
| 143 | + mettre_a_jour_etat_smb(conn, "unavailable") | ||
| 144 | + | ||
| 145 | + assert charger_contexte(conn).smb_last_known_state == "unavailable" | ||
| 146 | + conn.close() | ||
| 147 | + | ||
| 148 | + | ||
| 149 | +def test_lister_dossiers_non_reconcilies_detecte_un_dossier_verrouille(tmp_path: Path) -> None: | ||
| 150 | + archive_root_local = tmp_path / "archive" | ||
| 151 | + archive_root = archive_root_local / "2026-08-15_Sortie" | ||
| 152 | + archive_root.mkdir(parents=True) | ||
| 153 | + (archive_root / "photo.jpg").write_bytes(b"contenu") | ||
| 154 | + local_work_dir = tmp_path / "travail" | ||
| 155 | + dest_locale = local_work_dir / "2026-08-15_Sortie" | ||
| 156 | + | ||
| 157 | + checkout(archive_root, dest_locale) # pose le verrou, jamais réconcilié ensuite | ||
| 158 | + | ||
| 159 | + assert lister_dossiers_non_reconcilies(local_work_dir, archive_root_local) == [dest_locale] | ||
| 160 | + | ||
| 161 | + | ||
| 162 | +def test_lister_dossiers_non_reconcilies_ignore_un_dossier_reconcilie(tmp_path: Path) -> None: | ||
| 163 | + archive_root_local = tmp_path / "archive" | ||
| 164 | + archive_root = archive_root_local / "2026-08-15_Sortie" | ||
| 165 | + archive_root.mkdir(parents=True) | ||
| 166 | + (archive_root / "photo.jpg").write_bytes(b"contenu") | ||
| 167 | + local_work_dir = tmp_path / "travail" | ||
| 168 | + dest_locale = local_work_dir / "2026-08-15_Sortie" | ||
| 169 | + | ||
| 170 | + snapshot = checkout(archive_root, dest_locale) | ||
| 171 | + rapport = comparer(snapshot, dest_locale) | ||
| 172 | + archiver(snapshot, rapport, DecisionsUtilisateur()) # lève le verrou | ||
| 173 | + | ||
| 174 | + assert lister_dossiers_non_reconcilies(local_work_dir, archive_root_local) == [] | ||
| 175 | + | ||
| 176 | + | ||
| 177 | +def test_lister_dossiers_non_reconcilies_leve_si_archive_inaccessible(tmp_path: Path) -> None: | ||
| 178 | + with pytest.raises(ArchiveInaccessibleError): | ||
| 179 | + lister_dossiers_non_reconcilies(tmp_path / "travail", tmp_path / "archive-absente") | ||
| 180 | + | ||
| 181 | + | ||
| 182 | +def test_enregistrer_chemins_refuse_local_work_dir_si_travail_non_reconcilie( | ||
| 183 | + tmp_path: Path, monkeypatch | ||
| 184 | +) -> None: | ||
| 185 | + conn = _conn(tmp_path) | ||
| 186 | + archive_root_local = tmp_path / "archive" | ||
| 187 | + archive_root = archive_root_local / "2026-08-15_Sortie" | ||
| 188 | + archive_root.mkdir(parents=True) | ||
| 189 | + (archive_root / "photo.jpg").write_bytes(b"contenu") | ||
| 190 | + ancien_local = tmp_path / "travail" | ||
| 191 | + dest_locale = ancien_local / "2026-08-15_Sortie" | ||
| 192 | + checkout(archive_root, dest_locale) # verrouillé, jamais réconcilié | ||
| 193 | + | ||
| 194 | + import regine_core.config.smb as smb_module | ||
| 195 | + | ||
| 196 | + monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _chemin: archive_root_local) | ||
| 197 | + | ||
| 198 | + enregistrer_chemins( | ||
| 199 | + conn, | ||
| 200 | + local_work_dir=ancien_local, | ||
| 201 | + archive_smb_path="smb://nas.local/archive", | ||
| 202 | + creer_repertoires_manquants=True, | ||
| 203 | + ) | ||
| 204 | + | ||
| 205 | + with pytest.raises(TravailNonReconcilieError) as exc_info: | ||
| 206 | + enregistrer_chemins( | ||
| 207 | + conn, local_work_dir=tmp_path / "autre-travail", creer_repertoires_manquants=True | ||
| 208 | + ) | ||
| 209 | + assert exc_info.value.dossiers == [dest_locale] | ||
| 210 | + conn.close() | ||
| 211 | + | ||
| 212 | + | ||
| 213 | +def test_enregistrer_chemins_sans_archive_configuree_ne_verifie_rien(tmp_path: Path) -> None: | ||
| 214 | + """Aucune archive configurée : rien à vérifier, le changement est autorisé.""" | ||
| 215 | + conn = _conn(tmp_path) | ||
| 216 | + ancien_local = tmp_path / "travail" | ||
| 217 | + enregistrer_chemins(conn, local_work_dir=ancien_local, creer_repertoires_manquants=True) | ||
| 218 | + | ||
| 219 | + contexte = enregistrer_chemins( | ||
| 220 | + conn, local_work_dir=tmp_path / "autre-travail", creer_repertoires_manquants=True | ||
| 221 | + ) | ||
| 222 | + | ||
| 223 | + assert contexte.local_work_dir == tmp_path / "autre-travail" | ||
| 224 | + conn.close() | ||
| 225 | + | ||
| 226 | + | ||
| 227 | +def test_enregistrer_chemins_ignorer_verification_archive_passe_outre( | ||
| 228 | + tmp_path: Path, monkeypatch | ||
| 229 | +) -> None: | ||
| 230 | + conn = _conn(tmp_path) | ||
| 231 | + archive_root_local = tmp_path / "archive" | ||
| 232 | + archive_root = archive_root_local / "2026-08-15_Sortie" | ||
| 233 | + archive_root.mkdir(parents=True) | ||
| 234 | + (archive_root / "photo.jpg").write_bytes(b"contenu") | ||
| 235 | + ancien_local = tmp_path / "travail" | ||
| 236 | + dest_locale = ancien_local / "2026-08-15_Sortie" | ||
| 237 | + checkout(archive_root, dest_locale) # verrouillé, jamais réconcilié | ||
| 238 | + | ||
| 239 | + import regine_core.config.smb as smb_module | ||
| 240 | + | ||
| 241 | + monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _chemin: archive_root_local) | ||
| 242 | + enregistrer_chemins( | ||
| 243 | + conn, | ||
| 244 | + local_work_dir=ancien_local, | ||
| 245 | + archive_smb_path="smb://nas.local/archive", | ||
| 246 | + creer_repertoires_manquants=True, | ||
| 247 | + ) | ||
| 248 | + | ||
| 249 | + contexte = enregistrer_chemins( | ||
| 250 | + conn, | ||
| 251 | + local_work_dir=tmp_path / "autre-travail", | ||
| 252 | + creer_repertoires_manquants=True, | ||
| 253 | + ignorer_verification_archive=True, | ||
| 254 | + ) | ||
| 255 | + | ||
| 256 | + assert contexte.local_work_dir == tmp_path / "autre-travail" | ||
| 257 | + conn.close() | ||
added
packages/regine-core/tests/unit/test_smb_disponibilite.py +77 -0 | new file mode 100644 | ||
| @@ -0,0 +1,77 @@ | ||
| 1 | +"""Tests de `regine_core.config.smb` (T019).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from pathlib import Path | |
| 6 | + | |
| 7 | +import regine_core.config.smb as smb_module | |
| 8 | +from regine_core.config.context import charger_contexte | |
| 9 | +from regine_core.config.db import open_context_db | |
| 10 | +from regine_core.config.smb import assurer_disponibilite, disponible | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_chemin_local_du_partage_derive_le_point_de_montage_macos() -> None: | |
| 14 | + assert smb_module.chemin_local_du_partage("smb://nas.local/regine-archive") == Path( | |
| 15 | + "/Volumes/regine-archive" | |
| 16 | + ) | |
| 17 | + | |
| 18 | + | |
| 19 | +def test_disponible_vrai_si_le_point_de_montage_existe(tmp_path: Path, monkeypatch) -> None: | |
| 20 | + point_de_montage = tmp_path / "regine-archive" | |
| 21 | + point_de_montage.mkdir() | |
| 22 | + monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _c: point_de_montage) | |
| 23 | + | |
| 24 | + assert disponible("smb://nas.local/regine-archive") is True | |
| 25 | + | |
| 26 | + | |
| 27 | +def test_disponible_faux_si_le_point_de_montage_absent(tmp_path: Path, monkeypatch) -> None: | |
| 28 | + monkeypatch.setattr( | |
| 29 | + smb_module, "chemin_local_du_partage", lambda _c: tmp_path / "absent-du-tout" | |
| 30 | + ) | |
| 31 | + | |
| 32 | + assert disponible("smb://nas.local/regine-archive") is False | |
| 33 | + | |
| 34 | + | |
| 35 | +def test_assurer_disponibilite_met_a_jour_l_etat_mounted(tmp_path: Path, monkeypatch) -> None: | |
| 36 | + point_de_montage = tmp_path / "regine-archive" | |
| 37 | + point_de_montage.mkdir() | |
| 38 | + monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _c: point_de_montage) | |
| 39 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | |
| 40 | + | |
| 41 | + resultat = assurer_disponibilite(conn, "smb://nas.local/regine-archive") | |
| 42 | + | |
| 43 | + assert resultat is True | |
| 44 | + assert charger_contexte(conn).smb_last_known_state == "mounted" | |
| 45 | + conn.close() | |
| 46 | + | |
| 47 | + | |
| 48 | +def test_assurer_disponibilite_declenche_le_montage_si_indisponible( | |
| 49 | + tmp_path: Path, monkeypatch | |
| 50 | +) -> None: | |
| 51 | + monkeypatch.setattr( | |
| 52 | + smb_module, "chemin_local_du_partage", lambda _c: tmp_path / "absent-du-tout" | |
| 53 | + ) | |
| 54 | + appels: list[str] = [] | |
| 55 | + monkeypatch.setattr(smb_module, "declencher_montage", appels.append) | |
| 56 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | |
| 57 | + | |
| 58 | + resultat = assurer_disponibilite(conn, "smb://nas.local/regine-archive") | |
| 59 | + | |
| 60 | + assert resultat is False | |
| 61 | + assert appels == ["smb://nas.local/regine-archive"] | |
| 62 | + assert charger_contexte(conn).smb_last_known_state == "unavailable" | |
| 63 | + conn.close() | |
| 64 | + | |
| 65 | + | |
| 66 | +def test_assurer_disponibilite_sans_declenchement(tmp_path: Path, monkeypatch) -> None: | |
| 67 | + monkeypatch.setattr( | |
| 68 | + smb_module, "chemin_local_du_partage", lambda _c: tmp_path / "absent-du-tout" | |
| 69 | + ) | |
| 70 | + appels: list[str] = [] | |
| 71 | + monkeypatch.setattr(smb_module, "declencher_montage", appels.append) | |
| 72 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | |
| 73 | + | |
| 74 | + assurer_disponibilite(conn, "smb://nas.local/regine-archive", declencher=False) | |
| 75 | + | |
| 76 | + assert appels == [] | |
| 77 | + conn.close() | |
| new file mode 100644 | |||
| @@ -0,0 +1,77 @@ | |||
| 1 | +"""Tests de `regine_core.config.smb` (T019).""" | ||
| 2 | + | ||
| 3 | +from __future__ import annotations | ||
| 4 | + | ||
| 5 | +from pathlib import Path | ||
| 6 | + | ||
| 7 | +import regine_core.config.smb as smb_module | ||
| 8 | +from regine_core.config.context import charger_contexte | ||
| 9 | +from regine_core.config.db import open_context_db | ||
| 10 | +from regine_core.config.smb import assurer_disponibilite, disponible | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +def test_chemin_local_du_partage_derive_le_point_de_montage_macos() -> None: | ||
| 14 | + assert smb_module.chemin_local_du_partage("smb://nas.local/regine-archive") == Path( | ||
| 15 | + "/Volumes/regine-archive" | ||
| 16 | + ) | ||
| 17 | + | ||
| 18 | + | ||
| 19 | +def test_disponible_vrai_si_le_point_de_montage_existe(tmp_path: Path, monkeypatch) -> None: | ||
| 20 | + point_de_montage = tmp_path / "regine-archive" | ||
| 21 | + point_de_montage.mkdir() | ||
| 22 | + monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _c: point_de_montage) | ||
| 23 | + | ||
| 24 | + assert disponible("smb://nas.local/regine-archive") is True | ||
| 25 | + | ||
| 26 | + | ||
| 27 | +def test_disponible_faux_si_le_point_de_montage_absent(tmp_path: Path, monkeypatch) -> None: | ||
| 28 | + monkeypatch.setattr( | ||
| 29 | + smb_module, "chemin_local_du_partage", lambda _c: tmp_path / "absent-du-tout" | ||
| 30 | + ) | ||
| 31 | + | ||
| 32 | + assert disponible("smb://nas.local/regine-archive") is False | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +def test_assurer_disponibilite_met_a_jour_l_etat_mounted(tmp_path: Path, monkeypatch) -> None: | ||
| 36 | + point_de_montage = tmp_path / "regine-archive" | ||
| 37 | + point_de_montage.mkdir() | ||
| 38 | + monkeypatch.setattr(smb_module, "chemin_local_du_partage", lambda _c: point_de_montage) | ||
| 39 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | ||
| 40 | + | ||
| 41 | + resultat = assurer_disponibilite(conn, "smb://nas.local/regine-archive") | ||
| 42 | + | ||
| 43 | + assert resultat is True | ||
| 44 | + assert charger_contexte(conn).smb_last_known_state == "mounted" | ||
| 45 | + conn.close() | ||
| 46 | + | ||
| 47 | + | ||
| 48 | +def test_assurer_disponibilite_declenche_le_montage_si_indisponible( | ||
| 49 | + tmp_path: Path, monkeypatch | ||
| 50 | +) -> None: | ||
| 51 | + monkeypatch.setattr( | ||
| 52 | + smb_module, "chemin_local_du_partage", lambda _c: tmp_path / "absent-du-tout" | ||
| 53 | + ) | ||
| 54 | + appels: list[str] = [] | ||
| 55 | + monkeypatch.setattr(smb_module, "declencher_montage", appels.append) | ||
| 56 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | ||
| 57 | + | ||
| 58 | + resultat = assurer_disponibilite(conn, "smb://nas.local/regine-archive") | ||
| 59 | + | ||
| 60 | + assert resultat is False | ||
| 61 | + assert appels == ["smb://nas.local/regine-archive"] | ||
| 62 | + assert charger_contexte(conn).smb_last_known_state == "unavailable" | ||
| 63 | + conn.close() | ||
| 64 | + | ||
| 65 | + | ||
| 66 | +def test_assurer_disponibilite_sans_declenchement(tmp_path: Path, monkeypatch) -> None: | ||
| 67 | + monkeypatch.setattr( | ||
| 68 | + smb_module, "chemin_local_du_partage", lambda _c: tmp_path / "absent-du-tout" | ||
| 69 | + ) | ||
| 70 | + appels: list[str] = [] | ||
| 71 | + monkeypatch.setattr(smb_module, "declencher_montage", appels.append) | ||
| 72 | + conn = open_context_db(tmp_path / "contexte.sqlite3") | ||
| 73 | + | ||
| 74 | + assurer_disponibilite(conn, "smb://nas.local/regine-archive", declencher=False) | ||
| 75 | + | ||
| 76 | + assert appels == [] | ||
| 77 | + conn.close() | ||
modified
specs/003-config-contexte-travail/contracts/cli-config.md +8 -0 | @@ -67,3 +67,11 @@ regine config cameras scan-nas [--confirm] | ||
| 67 | 67 | **Sorties** : |
| 68 | 68 | - Sans `--confirm` : avertissement sur stdout indiquant que l'opération peut être longue sur une grosse archive, et invite à relancer avec `--confirm` ; code de sortie non-zéro (rien n'est exécuté), cohérent avec le caractère volontaire de FR-012. |
| 69 | 69 | - Avec `--confirm` : parcours exécuté, nouveaux boîtiers ajoutés avec `source=parcours_nas`, récapitulatif sur stdout, code `0`. Nécessite que l'archive NAS soit accessible (sinon comportement de User Story 2 : détection d'indisponibilité + assistance au montage). |
| 70 | + | |
| 71 | +## Notes d'implémentation | |
| 72 | + | |
| 73 | +- `regine config set-paths` accepte aussi `--ignorer-verification-archive` (non prévu dans la version initiale de ce contrat) : permet de passer outre le refus `TravailNonReconcilieError`/`ArchiveInaccessibleError` en connaissance de cause, même geste explicite que `--force` pour la collision de chemins. | |
| 74 | +- `regine config show` sans `--json`, contexte incomplet : le message sur stdout liste explicitement les 3 noms de champs manquants (`temp_dir`, `local_work_dir`, `archive_smb_path`), code de sortie `1`. | |
| 75 | +- `regine config cameras list --json` retourne un tableau d'objets `{id, modele, numero_serie, nom_lisible, source}` (`source` peut valoir `modele`, `numero_serie`, `manuel` — désambiguïsation à l'import, specs/002 — ou `parcours_nas`, cf. `contracts/regine-core-api.md`). | |
| 76 | +- Toutes les sous-commandes acceptent `--contexte-db CHEMIN` (optionnel, défaut `~/.regine/contexte.sqlite3`) pour cibler une base de contexte différente — utile notamment pour les tests. | |
| 77 | +- **Scénario 4 du quickstart (montage/démontage SMB réel) non vérifiable dans l'environnement de développement de ce projet** (pas de partage SMB physique disponible) : le mécanisme (`smb.disponible`/`assurer_disponibilite`/`declencher_montage`) est validé unitairement avec un point de montage injecté, et `regine config show` détecte correctement un partage réellement inexistant comme `unavailable` en conditions réelles (validé manuellement, cf. `tasks.md` T026). | |
| @@ -67,3 +67,11 @@ regine config cameras scan-nas [--confirm] | |||
| 67 | **Sorties** : | 67 | **Sorties** : |
| 68 | - Sans `--confirm` : avertissement sur stdout indiquant que l'opération peut être longue sur une grosse archive, et invite à relancer avec `--confirm` ; code de sortie non-zéro (rien n'est exécuté), cohérent avec le caractère volontaire de FR-012. | 68 | - Sans `--confirm` : avertissement sur stdout indiquant que l'opération peut être longue sur une grosse archive, et invite à relancer avec `--confirm` ; code de sortie non-zéro (rien n'est exécuté), cohérent avec le caractère volontaire de FR-012. |
| 69 | - Avec `--confirm` : parcours exécuté, nouveaux boîtiers ajoutés avec `source=parcours_nas`, récapitulatif sur stdout, code `0`. Nécessite que l'archive NAS soit accessible (sinon comportement de User Story 2 : détection d'indisponibilité + assistance au montage). | 69 | - Avec `--confirm` : parcours exécuté, nouveaux boîtiers ajoutés avec `source=parcours_nas`, récapitulatif sur stdout, code `0`. Nécessite que l'archive NAS soit accessible (sinon comportement de User Story 2 : détection d'indisponibilité + assistance au montage). |
| 70 | + | ||
| 71 | +## Notes d'implémentation | ||
| 72 | + | ||
| 73 | +- `regine config set-paths` accepte aussi `--ignorer-verification-archive` (non prévu dans la version initiale de ce contrat) : permet de passer outre le refus `TravailNonReconcilieError`/`ArchiveInaccessibleError` en connaissance de cause, même geste explicite que `--force` pour la collision de chemins. | ||
| 74 | +- `regine config show` sans `--json`, contexte incomplet : le message sur stdout liste explicitement les 3 noms de champs manquants (`temp_dir`, `local_work_dir`, `archive_smb_path`), code de sortie `1`. | ||
| 75 | +- `regine config cameras list --json` retourne un tableau d'objets `{id, modele, numero_serie, nom_lisible, source}` (`source` peut valoir `modele`, `numero_serie`, `manuel` — désambiguïsation à l'import, specs/002 — ou `parcours_nas`, cf. `contracts/regine-core-api.md`). | ||
| 76 | +- Toutes les sous-commandes acceptent `--contexte-db CHEMIN` (optionnel, défaut `~/.regine/contexte.sqlite3`) pour cibler une base de contexte différente — utile notamment pour les tests. | ||
| 77 | +- **Scénario 4 du quickstart (montage/démontage SMB réel) non vérifiable dans l'environnement de développement de ce projet** (pas de partage SMB physique disponible) : le mécanisme (`smb.disponible`/`assurer_disponibilite`/`declencher_montage`) est validé unitairement avec un point de montage injecté, et `regine config show` détecte correctement un partage réellement inexistant comme `unavailable` en conditions réelles (validé manuellement, cf. `tasks.md` T026). | ||
added
specs/003-config-contexte-travail/contracts/regine-core-api.md +53 -0 | new file mode 100644 | ||
| @@ -0,0 +1,53 @@ | ||
| 1 | +# Contrat d'API interne : `regine_core.config` | |
| 2 | + | |
| 3 | +Fonctions pures/orchestratrices consommées par `regine-cli` (`regine config ...`, cf. `contracts/cli-config.md`) — objets structurés, jamais de texte à parser (Principe VI). | |
| 4 | + | |
| 5 | +## `db.open_context_db(path: Path | None = None) -> sqlite3.Connection` | |
| 6 | + | |
| 7 | +Ouvre la base de contexte centralisée (chemins, boîtiers, catégories), en l'initialisant si nécessaire. Sans `path`, utilise `db.CHEMIN_CONTEXTE_PAR_DEFAUT` (`~/.regine/contexte.sqlite3`) — emplacement fixe par utilisateur, indépendant de `local_work_dir` (qui, lui, est modifiable). Déjà utilisée par `specs/001/002/004` avec un `path` explicite : l'ajout du défaut est rétrocompatible. | |
| 8 | + | |
| 9 | +## `context.charger_contexte(conn: sqlite3.Connection) -> ContexteTravail` | |
| 10 | + | |
| 11 | +Charge le contexte de travail actif. Retourne toujours un objet (jamais `None`) ; les champs valent `None`/`"unknown"` tant qu'aucune configuration n'a été enregistrée. | |
| 12 | + | |
| 13 | +## `context.enregistrer_chemins(conn, *, temp_dir=None, local_work_dir=None, archive_smb_path=None, creer_repertoires_manquants=False, forcer_collision=False, ignorer_verification_archive=False) -> ContexteTravail` | |
| 14 | + | |
| 15 | +Configure ou modifie un ou plusieurs des 3 emplacements de travail (FR-001/002/010). Ne modifie que les champs explicitement fournis (`None` = inchangé). Lève : | |
| 16 | +- `CheminArchiveInvalideError` si `archive_smb_path` ne commence pas par `smb://` ; | |
| 17 | +- `CheminsAmbigusError` si `temp_dir == local_work_dir` sans `forcer_collision=True` ; | |
| 18 | +- `TravailNonReconcilieError` (porte `.dossiers: list[Path]`) si un changement de `local_work_dir` laisserait du travail non réconcilié derrière lui ; | |
| 19 | +- `ArchiveInaccessibleError` si cette vérification est impossible faute d'accès à l'archive (sauf `ignorer_verification_archive=True`) ; | |
| 20 | +- `RepertoireAbsentError` (porte `.chemin`) si un répertoire n'existe pas et `creer_repertoires_manquants=False`. | |
| 21 | + | |
| 22 | +Initialise la base de travail à la racine d'un nouveau `local_work_dir` (`regine_core.archive.manifest.ouvrir_ou_creer`, FR-006/SC-003). | |
| 23 | + | |
| 24 | +## `context.lister_dossiers_non_reconcilies(local_work_dir: Path, archive_root_local: Path) -> list[Path]` | |
| 25 | + | |
| 26 | +Retourne les dossiers sous `local_work_dir` correspondant à un manifeste encore verrouillé côté archive (checkout/import non réconcilié, FR-010). Lève `ArchiveInaccessibleError` si `archive_root_local` n'est pas accessible. | |
| 27 | + | |
| 28 | +## `context.mettre_a_jour_etat_smb(conn, etat: Literal["mounted","unavailable","unknown"]) -> None` | |
| 29 | + | |
| 30 | +Met à jour l'état de disponibilité connu du partage SMB (FR-004/005). | |
| 31 | + | |
| 32 | +## `smb.chemin_local_du_partage(archive_smb_path: str) -> Path` | |
| 33 | + | |
| 34 | +Dérive le point de montage local macOS (`/Volumes/<partage>`) d'une URL `smb://hôte/partage` — convention best-effort, ne garantit pas que le partage soit effectivement monté. | |
| 35 | + | |
| 36 | +## `smb.disponible(archive_smb_path: str) -> bool` / `smb.declencher_montage(archive_smb_path: str) -> None` / `smb.assurer_disponibilite(conn, archive_smb_path, *, declencher=True) -> bool` | |
| 37 | + | |
| 38 | +Détection de disponibilité et déclenchement du montage natif (FR-004/005). `assurer_disponibilite` met à jour l'état connu et retourne `True` si déjà disponible, sinon déclenche le montage (si `declencher=True`) et retourne `False`. Une seule vérification par appel — pas de nouvelle tentative automatique en boucle. | |
| 39 | + | |
| 40 | +## `cameras_screen.lister_boitiers(conn) -> list[Boitier]` / `cameras_screen.nommer_boitier(conn, boitier_id: int, nom: str) -> None` | |
| 41 | + | |
| 42 | +Délèguent entièrement à `regine_core.camera_profile.db` (specs/002, qui possède la table `boitiers`). `nommer_boitier` lève `BoitierInconnuError` si `boitier_id` n'existe pas — vérification portée ici, car `camera_profile.db.rename_boitier` ne le signale pas lui-même (`UPDATE` sans ligne correspondante n'est pas une erreur SQL). | |
| 43 | + | |
| 44 | +## `cameras_screen.parcourir_nas(conn, archive_root: Path) -> int` | |
| 45 | + | |
| 46 | +Parcours volontaire de l'archive (FR-012) : lit le tag `Model` de chaque fichier maître (`regine_core.import_carte.copie.EXTENSIONS_MAITRES`), enregistre les boîtiers non encore connus avec `source="parcours_nas"`. Retourne le nombre de boîtiers réellement nouveaux. Idempotent (un second parcours identique n'ajoute rien). | |
| 47 | + | |
| 48 | +## Notes d'implémentation | |
| 49 | + | |
| 50 | +- **Écart architectural trouvé à l'implémentation (T009)** : la conception initiale de `lister_dossiers_non_reconcilies` prévoyait de parcourir `local_work_dir` à la recherche de manifestes. Le verrou/manifeste vit en réalité uniquement côté archive (`specs/005-checkout-reconciliation` ne le recopie jamais dans la copie de travail locale lors d'un checkout) : une recherche sous `local_work_dir` seul ne peut donc jamais rien trouver. Corrigé pour partir des manifestes de l'archive (via son point de montage local, `smb.chemin_local_du_partage`) et ne retenir que ceux ayant une copie locale présente. | |
| 51 | +- **Réutilisation de la colonne `source` de `camera_profile.db.boitiers`** : `data-model.md` (rédigé avant `specs/002`) envisageait `source` à deux valeurs (`import`/`parcours_nas`). Le schéma réellement livré par specs/002 utilise déjà cette colonne pour une distinction différente (`modele`/`numero_serie`/`manuel` — **comment** un boîtier a été désambiguïsé). Cette colonne texte libre n'a pas été migrée ni renommée (déjà testée par specs/002) : `parcourir_nas` y ajoute simplement la valeur `parcours_nas` en plus des valeurs existantes. À l'affichage (`cameras list`), toute valeur différente de `parcours_nas` est implicitement « détecté via import ». | |
| 52 | +- **Emplacement de la base de contexte centralisée** : non fixé par `research.md`. Décision prise à l'implémentation (T005) : `~/.regine/contexte.sqlite3`, fixe par utilisateur, indépendant de `local_work_dir` (qui reste modifiable, FR-010/SC-005, sans jamais risquer de perdre boîtiers/catégories déjà enregistrés). | |
| 53 | +- **Intégration avec `regine_cli.import_cmd`/`archive_cmd` (specs/001/005) non construite ici** : ces façades prennent aujourd'hui `--archive-root`/`--local-root` en flags CLI explicites, indépendamment de ce contexte configuré. `FR-010` ne protège donc que le travail effectivement réalisé sous le `local_work_dir` configuré ici — cohérent tant que cette intégration (lire `regine config show` par défaut plutôt que d'exiger les flags) n'a pas été demandée séparément. | |
| new file mode 100644 | |||
| @@ -0,0 +1,53 @@ | |||
| 1 | +# Contrat d'API interne : `regine_core.config` | ||
| 2 | + | ||
| 3 | +Fonctions pures/orchestratrices consommées par `regine-cli` (`regine config ...`, cf. `contracts/cli-config.md`) — objets structurés, jamais de texte à parser (Principe VI). | ||
| 4 | + | ||
| 5 | +## `db.open_context_db(path: Path | None = None) -> sqlite3.Connection` | ||
| 6 | + | ||
| 7 | +Ouvre la base de contexte centralisée (chemins, boîtiers, catégories), en l'initialisant si nécessaire. Sans `path`, utilise `db.CHEMIN_CONTEXTE_PAR_DEFAUT` (`~/.regine/contexte.sqlite3`) — emplacement fixe par utilisateur, indépendant de `local_work_dir` (qui, lui, est modifiable). Déjà utilisée par `specs/001/002/004` avec un `path` explicite : l'ajout du défaut est rétrocompatible. | ||
| 8 | + | ||
| 9 | +## `context.charger_contexte(conn: sqlite3.Connection) -> ContexteTravail` | ||
| 10 | + | ||
| 11 | +Charge le contexte de travail actif. Retourne toujours un objet (jamais `None`) ; les champs valent `None`/`"unknown"` tant qu'aucune configuration n'a été enregistrée. | ||
| 12 | + | ||
| 13 | +## `context.enregistrer_chemins(conn, *, temp_dir=None, local_work_dir=None, archive_smb_path=None, creer_repertoires_manquants=False, forcer_collision=False, ignorer_verification_archive=False) -> ContexteTravail` | ||
| 14 | + | ||
| 15 | +Configure ou modifie un ou plusieurs des 3 emplacements de travail (FR-001/002/010). Ne modifie que les champs explicitement fournis (`None` = inchangé). Lève : | ||
| 16 | +- `CheminArchiveInvalideError` si `archive_smb_path` ne commence pas par `smb://` ; | ||
| 17 | +- `CheminsAmbigusError` si `temp_dir == local_work_dir` sans `forcer_collision=True` ; | ||
| 18 | +- `TravailNonReconcilieError` (porte `.dossiers: list[Path]`) si un changement de `local_work_dir` laisserait du travail non réconcilié derrière lui ; | ||
| 19 | +- `ArchiveInaccessibleError` si cette vérification est impossible faute d'accès à l'archive (sauf `ignorer_verification_archive=True`) ; | ||
| 20 | +- `RepertoireAbsentError` (porte `.chemin`) si un répertoire n'existe pas et `creer_repertoires_manquants=False`. | ||
| 21 | + | ||
| 22 | +Initialise la base de travail à la racine d'un nouveau `local_work_dir` (`regine_core.archive.manifest.ouvrir_ou_creer`, FR-006/SC-003). | ||
| 23 | + | ||
| 24 | +## `context.lister_dossiers_non_reconcilies(local_work_dir: Path, archive_root_local: Path) -> list[Path]` | ||
| 25 | + | ||
| 26 | +Retourne les dossiers sous `local_work_dir` correspondant à un manifeste encore verrouillé côté archive (checkout/import non réconcilié, FR-010). Lève `ArchiveInaccessibleError` si `archive_root_local` n'est pas accessible. | ||
| 27 | + | ||
| 28 | +## `context.mettre_a_jour_etat_smb(conn, etat: Literal["mounted","unavailable","unknown"]) -> None` | ||
| 29 | + | ||
| 30 | +Met à jour l'état de disponibilité connu du partage SMB (FR-004/005). | ||
| 31 | + | ||
| 32 | +## `smb.chemin_local_du_partage(archive_smb_path: str) -> Path` | ||
| 33 | + | ||
| 34 | +Dérive le point de montage local macOS (`/Volumes/<partage>`) d'une URL `smb://hôte/partage` — convention best-effort, ne garantit pas que le partage soit effectivement monté. | ||
| 35 | + | ||
| 36 | +## `smb.disponible(archive_smb_path: str) -> bool` / `smb.declencher_montage(archive_smb_path: str) -> None` / `smb.assurer_disponibilite(conn, archive_smb_path, *, declencher=True) -> bool` | ||
| 37 | + | ||
| 38 | +Détection de disponibilité et déclenchement du montage natif (FR-004/005). `assurer_disponibilite` met à jour l'état connu et retourne `True` si déjà disponible, sinon déclenche le montage (si `declencher=True`) et retourne `False`. Une seule vérification par appel — pas de nouvelle tentative automatique en boucle. | ||
| 39 | + | ||
| 40 | +## `cameras_screen.lister_boitiers(conn) -> list[Boitier]` / `cameras_screen.nommer_boitier(conn, boitier_id: int, nom: str) -> None` | ||
| 41 | + | ||
| 42 | +Délèguent entièrement à `regine_core.camera_profile.db` (specs/002, qui possède la table `boitiers`). `nommer_boitier` lève `BoitierInconnuError` si `boitier_id` n'existe pas — vérification portée ici, car `camera_profile.db.rename_boitier` ne le signale pas lui-même (`UPDATE` sans ligne correspondante n'est pas une erreur SQL). | ||
| 43 | + | ||
| 44 | +## `cameras_screen.parcourir_nas(conn, archive_root: Path) -> int` | ||
| 45 | + | ||
| 46 | +Parcours volontaire de l'archive (FR-012) : lit le tag `Model` de chaque fichier maître (`regine_core.import_carte.copie.EXTENSIONS_MAITRES`), enregistre les boîtiers non encore connus avec `source="parcours_nas"`. Retourne le nombre de boîtiers réellement nouveaux. Idempotent (un second parcours identique n'ajoute rien). | ||
| 47 | + | ||
| 48 | +## Notes d'implémentation | ||
| 49 | + | ||
| 50 | +- **Écart architectural trouvé à l'implémentation (T009)** : la conception initiale de `lister_dossiers_non_reconcilies` prévoyait de parcourir `local_work_dir` à la recherche de manifestes. Le verrou/manifeste vit en réalité uniquement côté archive (`specs/005-checkout-reconciliation` ne le recopie jamais dans la copie de travail locale lors d'un checkout) : une recherche sous `local_work_dir` seul ne peut donc jamais rien trouver. Corrigé pour partir des manifestes de l'archive (via son point de montage local, `smb.chemin_local_du_partage`) et ne retenir que ceux ayant une copie locale présente. | ||
| 51 | +- **Réutilisation de la colonne `source` de `camera_profile.db.boitiers`** : `data-model.md` (rédigé avant `specs/002`) envisageait `source` à deux valeurs (`import`/`parcours_nas`). Le schéma réellement livré par specs/002 utilise déjà cette colonne pour une distinction différente (`modele`/`numero_serie`/`manuel` — **comment** un boîtier a été désambiguïsé). Cette colonne texte libre n'a pas été migrée ni renommée (déjà testée par specs/002) : `parcourir_nas` y ajoute simplement la valeur `parcours_nas` en plus des valeurs existantes. À l'affichage (`cameras list`), toute valeur différente de `parcours_nas` est implicitement « détecté via import ». | ||
| 52 | +- **Emplacement de la base de contexte centralisée** : non fixé par `research.md`. Décision prise à l'implémentation (T005) : `~/.regine/contexte.sqlite3`, fixe par utilisateur, indépendant de `local_work_dir` (qui reste modifiable, FR-010/SC-005, sans jamais risquer de perdre boîtiers/catégories déjà enregistrés). | ||
| 53 | +- **Intégration avec `regine_cli.import_cmd`/`archive_cmd` (specs/001/005) non construite ici** : ces façades prennent aujourd'hui `--archive-root`/`--local-root` en flags CLI explicites, indépendamment de ce contexte configuré. `FR-010` ne protège donc que le travail effectivement réalisé sous le `local_work_dir` configuré ici — cohérent tant que cette intégration (lire `regine config show` par défaut plutôt que d'exiger les flags) n'a pas été demandée séparément. | ||
added
specs/003-config-contexte-travail/tasks.md +140 -0 | new file mode 100644 | ||
| @@ -0,0 +1,140 @@ | ||
| 1 | +--- | |
| 2 | + | |
| 3 | +description: "Task list for feature implementation" | |
| 4 | +--- | |
| 5 | + | |
| 6 | +# Tasks: Configuration du contexte de travail de Régine | |
| 7 | + | |
| 8 | +**Input**: Design documents from `/specs/003-config-contexte-travail/` | |
| 9 | +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md | |
| 10 | + | |
| 11 | +**Tests**: Incluses — `plan.md` § Project Structure nomme explicitement les fichiers de test attendus, ce qui vaut demande explicite au sens des règles de génération. | |
| 12 | + | |
| 13 | +**Organisation**: Tâches groupées par user story (spec.md) pour une implémentation et une validation indépendantes de chacune. | |
| 14 | + | |
| 15 | +## Dépendances inter-specs (à lire avant de commencer) | |
| 16 | + | |
| 17 | +| Dépendance | Fournit | État | | |
| 18 | +|---|---|---| | |
| 19 | +| `specs/004-categorisation-dossiers` | `regine_core.config.db.open_context_db` (base de contexte centralisée, déjà créée) | Implémentée | | |
| 20 | +| `specs/002-profil-boitiers-optionnel` | `regine_core.camera_profile.db` (table `boitiers`, `list_boitiers`, `rename_boitier`, `get_or_create_boitier`) — **possède** la table `boitiers`, cette spec ne fait que l'exposer (cf. plan.md « Correction du 2026-09-18 ») | Implémentée | | |
| 21 | +| `specs/005-checkout-reconciliation` | `regine_core.archive.manifest` (manifeste persistant par dossier, `PRAGMA user_version`/`application_id`) et `regine_core.archive.verrou` (verrouillage) — réutilisés tels quels pour FR-006/FR-010 (détection de travail non réconcilié) | Implémentée | | |
| 22 | +| `specs/001-import-photos` | `regine_core.import_carte.copie.EXTENSIONS_MAITRES` (réutilisée par le parcours NAS, US3/FR-012, pour reconnaître un fichier maître sans dupliquer la liste) | Implémentée | | |
| 23 | + | |
| 24 | +## Écarts entre les documents de conception et l'état réel du code (à respecter pendant l'implémentation) | |
| 25 | + | |
| 26 | +- **`data-model.md` § Base de contexte centralisée** a été rédigé avant l'implémentation de `specs/002-profil-boitiers-optionnel` et décrit une colonne `source` à deux valeurs (`import`/`parcours_nas`). Le schéma réellement livré par specs/002 (`camera_profile/db.py`) utilise déjà `source` pour une distinction différente et orthogonale (`modele`/`numero_serie`/`manuel` — **comment** un boîtier a été désambiguïsé). Ne PAS renommer ni migrer cette colonne déjà testée : réutiliser la même colonne texte libre en y ajoutant la seule valeur manquante `parcours_nas` (FR-012) ; à l'affichage (`cameras list`), traiter toute valeur différente de `parcours_nas` comme « détecté via import » (FR-008). | |
| 27 | +- **research.md ne fixe pas l'emplacement de fichier de la base de contexte centralisée** (seulement son schéma). Décision prise dans cette liste de tâches (T005) : un emplacement fixe par utilisateur, indépendant de `local_work_dir` (qui, lui, est modifiable, cf. FR-010/SC-005 — la base de contexte ne doit jamais se perdre à un changement de chemin). | |
| 28 | +- **FR-006/Acceptance Scenario 3 (US1)** : lu littéralement (« une base de données de travail est créée... à la racine de ce répertoire »). Réutilise directement `regine_core.archive.manifest.ouvrir_ou_creer` sur `local_work_dir` lui-même (déjà générique, accepte n'importe quel dossier) plutôt que d'inventer un second mécanisme — cohérent avec l'Assumption de spec.md (« cette spécification couvre son initialisation... pas son schéma détaillé »). Le manifeste par dossier archivé (FR-007) reste géré indépendamment par `specs/001`/`specs/005`, déjà correct. | |
| 29 | + | |
| 30 | +## Phase 1: Setup | |
| 31 | + | |
| 32 | +- [X] T001 [P] Créer `packages/regine-core/src/regine_core/config/context.py` (squelette, docstring de module uniquement) | |
| 33 | +- [X] T002 [P] Créer `packages/regine-core/src/regine_core/config/smb.py` (squelette, docstring de module uniquement) | |
| 34 | +- [X] T003 [P] Créer `packages/regine-core/src/regine_core/config/cameras_screen.py` (squelette, docstring de module uniquement) | |
| 35 | +- [X] T004 [P] Créer `packages/regine-cli/src/regine_cli/config_cmd.py` (squelette, docstring de module uniquement) — et `packages/regine-cli/tests/contract/`, `[tool.pytest.ini_options] testpaths = ["tests"]` ajouté à `packages/regine-cli/pyproject.toml` (aucun test CLI automatisé n'existait encore dans le dépôt) | |
| 36 | + | |
| 37 | +--- | |
| 38 | + | |
| 39 | +## Phase 2: Foundational (Blocking Prerequisites) | |
| 40 | + | |
| 41 | +**Objectif** : la persistance du contexte de travail (chemins + état SMB) et la détection de travail non réconcilié, dont dépendent les 3 user stories. | |
| 42 | + | |
| 43 | +**⚠️ CRITIQUE** : aucune user story ne peut être implémentée avant la fin de cette phase. | |
| 44 | + | |
| 45 | +- [X] T005 Étendre `packages/regine-core/src/regine_core/config/db.py` : ajouter `CHEMIN_CONTEXTE_PAR_DEFAUT = Path.home() / ".regine" / "contexte.sqlite3"` et rendre `open_context_db(path: Path | None = None)` optionnel (défaut : `CHEMIN_CONTEXTE_PAR_DEFAUT`), sans changer le comportement pour les appelants existants qui passent déjà un chemin explicite (specs/001/002/004) | |
| 46 | +- [X] T006 [P] Test unitaire de `open_context_db(None)` (utilise le chemin par défaut, le crée si absent) dans `packages/regine-core/tests/unit/test_config_db_defaut.py` — le test patch la constante de module `CHEMIN_CONTEXTE_PAR_DEFAUT` plutôt que `Path.home` (celle-ci est déjà évaluée à l'import, un monkeypatch après coup n'aurait aucun effet) | |
| 47 | +- [X] T007 Implémenter `ContexteTravail` (dataclass), le schéma de la table `contexte` (ligne unique, `id INTEGER PRIMARY KEY CHECK (id = 1)`) et `charger_contexte(conn) -> ContexteTravail` dans `packages/regine-core/src/regine_core/config/context.py` (data-model.md § Contexte de travail) | |
| 48 | +- [X] T008 Implémenter les exceptions dédiées dans `context.py` : `RepertoireAbsentError`, `CheminsAmbigusError`, `CheminArchiveInvalideError`, `TravailNonReconcilieError` (porte la liste des dossiers concernés) — `ArchiveInaccessibleError` ajoutée en plus (cf. note T009) | |
| 49 | +- [X] T009 Implémenter `lister_dossiers_non_reconcilies(local_work_dir: Path, archive_root_local: Path) -> list[Path]` dans `context.py` — **écart avec la conception initiale découvert à l'implémentation** : le verrou/manifeste vit uniquement côté archive (`specs/005`, jamais recopié dans la copie de travail locale lors d'un checkout), donc parcourir `local_work_dir` seul ne peut jamais rien trouver. Corrigé : parcourt les manifestes de `archive_root_local` (point de montage local du partage SMB, `smb.chemin_local_du_partage`), ne retient que ceux ayant une copie locale présente sous `local_work_dir`. Lève `ArchiveInaccessibleError` si l'archive n'est pas accessible plutôt que de conclure à tort qu'il n'y a rien à signaler | |
| 50 | +- [X] T010 Implémenter `enregistrer_chemins(conn, *, temp_dir=None, local_work_dir=None, archive_smb_path=None, creer_repertoires_manquants=False, forcer_collision=False, ignorer_verification_archive=False) -> ContexteTravail` dans `context.py` : valide `archive_smb_path` (DOIT commencer par `smb://`, sinon `CheminArchiveInvalideError`), détecte la collision `temp_dir == local_work_dir` (`CheminsAmbigusError` sauf `forcer_collision`), bloque un changement de `local_work_dir` via `lister_dossiers_non_reconcilies` (`TravailNonReconcilieError`) — sauf si aucune archive n'est encore configurée (rien à vérifier) ou `ignorer_verification_archive=True` (geste explicite, même famille que `forcer_collision`) ; une archive configurée mais inaccessible refuse le changement par défaut (`ArchiveInaccessibleError` remontée telle quelle). Crée les répertoires manquants seulement si `creer_repertoires_manquants` (sinon `RepertoireAbsentError`), initialise la base de travail à la racine d'un nouveau `local_work_dir` (`regine_core.archive.manifest.ouvrir_ou_creer`, FR-006/SC-003), puis persiste | |
| 51 | +- [X] T011 Implémenter `mettre_a_jour_etat_smb(conn, etat: Literal["mounted","unavailable","unknown"]) -> None` dans `context.py` | |
| 52 | +- [X] T012 [P] Tests unitaires de `context.py` dans `packages/regine-core/tests/unit/test_context_paths.py` : configuration initiale, répertoire manquant sans/avec `creer_repertoires_manquants`, collision `temp_dir`/`local_work_dir` sans/avec `forcer_collision`, `archive_smb_path` invalide (ne commence pas par `smb://`), FR-006 (base de travail présente à la racine après configuration), champs non fournis conservés (SC-005) | |
| 53 | +- [X] T013 [P] Tests unitaires de `lister_dossiers_non_reconcilies`/`enregistrer_chemins` dans `test_context_paths.py` : dossier verrouillé détecté, dossier réconcilié ignoré, archive inaccessible lève, aucune archive configurée n'exige aucune vérification, `ignorer_verification_archive` passe outre explicitement — 15/15 tests passent (`packages/regine-core/src/regine_core/config/smb.py` reçoit dès cette phase `chemin_local_du_partage`, requis par T009/T010 ; le reste de `smb.py` — `disponible`/`declencher_montage`/`assurer_disponibilite` — reste prévu en Phase 4/T020) | |
| 54 | + | |
| 55 | +**Checkpoint** : la persistance du contexte est prête ; les 3 user stories peuvent commencer. | |
| 56 | + | |
| 57 | +--- | |
| 58 | + | |
| 59 | +## Phase 3: User Story 1 - Configuration initiale des emplacements de travail (Priority: P1) 🎯 MVP | |
| 60 | + | |
| 61 | +**Objectif** : configurer et consulter les 3 emplacements de travail, avec initialisation de la base de travail et refus explicite d'un changement dangereux. | |
| 62 | + | |
| 63 | +**Test indépendant** : configurer les 3 chemins sur un contexte vide (`regine config set-paths`), vérifier leur persistance (`regine config show`), vérifier qu'un changement de `local_work_dir` avec un dossier verrouillé dessous est refusé. | |
| 64 | + | |
| 65 | +### Tests for User Story 1 | |
| 66 | + | |
| 67 | +- [X] T014 [P] [US1] Test d'intégration du cycle configuration → initialisation de la base de travail → changement de chemin bloqué dans `packages/regine-core/tests/integration/test_config_context.py` (Acceptance Scenarios 1 à 5 de US1) | |
| 68 | +- [X] T015 [P] [US1] Test de contrat de la surface CLI (`set-paths`, `show`, codes de sortie, `--json`) dans `packages/regine-cli/tests/contract/test_cli_config.py` | |
| 69 | + | |
| 70 | +### Implementation for User Story 1 | |
| 71 | + | |
| 72 | +- [X] T016 [US1] Implémenter la sous-commande `regine config set-paths` dans `packages/regine-cli/src/regine_cli/config_cmd.py` : `--temp-dir`, `--local-work-dir`, `--archive-smb`, `--yes` (confirme la création de répertoire, FR-003), `--force` (confirme la collision de chemins), `--ignorer-verification-archive` ; traduit chaque exception de `context.py` en message stderr + code de sortie non-zéro (cf. `contracts/cli-config.md`) | |
| 73 | +- [X] T017 [US1] Implémenter la sous-commande `regine config show [--json]` dans `config_cmd.py` : affiche les 3 chemins et l'état SMB connu, ou la liste des chemins manquants si le contexte n'est pas encore configuré (Edge Case) — **implémentée en même temps que T021 (US2)** : `show` déclenche `smb.assurer_disponibilite` dès qu'une archive est configurée, testé par `test_cli_config.py` | |
| 74 | +- [X] T018 [US1] Construire l'analyseur d'arguments (`construire_analyseur`/`main`) dans `config_cmd.py`, cohérent avec `import_cmd.py`/`archive_cmd.py` | |
| 75 | + | |
| 76 | +**Checkpoint** : User Story 1 fonctionnelle et testable indépendamment (MVP) — 125/125 tests passent. | |
| 77 | + | |
| 78 | +--- | |
| 79 | + | |
| 80 | +## Phase 4: User Story 2 - Assistance quand l'archive NAS n'est pas accessible (Priority: P2) | |
| 81 | + | |
| 82 | +**Objectif** : détecter l'indisponibilité du partage SMB avant qu'une opération échoue, déclencher le montage natif, permettre une reprise sans reconfiguration. | |
| 83 | + | |
| 84 | +**Test indépendant** : configurer un `archive_smb_path` pointant vers un partage démonté, lancer `regine config show`, vérifier qu'un montage est déclenché et que l'état SMB est mis à jour ; remonter manuellement puis relancer, vérifier la reprise sans reconfiguration. | |
| 85 | + | |
| 86 | +**Note d'ordonnancement** : implémentée en avance sur l'ordre de priorité initial, dès la Phase 3 — `regine config show` (T017) en a besoin directement (cf. quickstart.md scénario 4) et `context.enregistrer_chemins`/`lister_dossiers_non_reconcilies` (T009/T010, Phase 2) dépendaient déjà de `smb.chemin_local_du_partage`. | |
| 87 | + | |
| 88 | +### Tests for User Story 2 | |
| 89 | + | |
| 90 | +- [X] T019 [P] [US2] Test unitaire de `smb.py` dans `packages/regine-core/tests/unit/test_smb_disponibilite.py` : `disponible()` sur un point de montage présent/absent (via un répertoire temporaire simulant `/Volumes/<partage>`, injecté par dépendance plutôt que codé en dur), `assurer_disponibilite()` met à jour `smb_last_known_state`, déclenche/ne déclenche pas le montage selon `declencher` | |
| 91 | + | |
| 92 | +### Implementation for User Story 2 | |
| 93 | + | |
| 94 | +- [X] T020 [US2] Implémenter `packages/regine-core/src/regine_core/config/smb.py` : `disponible(archive_smb_path: str) -> bool` (vérifie l'accessibilité du point de montage macOS `/Volumes/<partage>`, dérivé de l'URL `smb://...`), `declencher_montage(archive_smb_path: str) -> None` (`open smb://...`, cf. research.md § 1), `assurer_disponibilite(conn, archive_smb_path, *, declencher=True) -> bool` (met à jour l'état via `context.mettre_a_jour_etat_smb`, déclenche le montage si indisponible) | |
| 95 | +- [X] T021 [US2] Intégrer `smb.assurer_disponibilite` dans `regine config show` (`config_cmd.py`) : si un `archive_smb_path` est configuré, vérifier sa disponibilité avant d'afficher l'état, déclencher le montage natif si nécessaire (Acceptance Scenario 1/2 de US2) — pas de nouvelle tentative automatique en boucle (Acceptance Scenario 3, une seule vérification par invocation, cohérent avec l'exécution sans démon) | |
| 96 | + | |
| 97 | +**Checkpoint** : User Stories 1 et 2 fonctionnelles indépendamment. | |
| 98 | + | |
| 99 | +--- | |
| 100 | + | |
| 101 | +## Phase 5: User Story 3 - Nommer les boîtiers à partir des photos déjà archivées (Priority: P3) | |
| 102 | + | |
| 103 | +**Objectif** : lister et nommer les boîtiers déjà enregistrés dans la base de contexte centralisée, sans réimport ni parcours du NAS ; parcours volontaire du NAS en complément optionnel (FR-012). | |
| 104 | + | |
| 105 | +**Test indépendant** : sur une base de contexte contenant déjà des boîtiers enregistrés par un import (cf. specs/001), lister les boîtiers puis en nommer un, sans déclencher d'accès au NAS. | |
| 106 | + | |
| 107 | +### Tests for User Story 3 | |
| 108 | + | |
| 109 | +- [X] T022 [P] [US3] Test unitaire de `cameras_screen.py` dans `packages/regine-core/tests/unit/test_cameras_screen.py` : liste des boîtiers déjà enregistrés (réutilise `camera_profile.db`), nommage d'un boîtier connu, `BoitierInconnuError` sur un identifiant absent | |
| 110 | +- [X] T023 [P] [US3] Test d'intégration du parcours NAS dans `packages/regine-core/tests/integration/test_cameras_scan_nas.py` : une arborescence simulant une archive avec des fichiers maîtres de boîtiers non encore connus (tags EXIF réels via `exiftool`) ; vérifie que `parcourir_nas` les ajoute avec `source="parcours_nas"` et n'affecte pas les boîtiers déjà connus (source `modele`), plus idempotence (second parcours ne redouble rien) | |
| 111 | + | |
| 112 | +### Implementation for User Story 3 | |
| 113 | + | |
| 114 | +- [X] T024 [US3] Implémenter `packages/regine-core/src/regine_core/config/cameras_screen.py` : `lister_boitiers(conn) -> list[Boitier]` (délègue à `camera_profile.db.list_boitiers`, FR-008), `nommer_boitier(conn, boitier_id: int, nom: str) -> None` (délègue à `camera_profile.db.rename_boitier` après avoir vérifié l'existence de `boitier_id`, sinon `BoitierInconnuError` — cf. `contracts/cli-config.md`), `parcourir_nas(conn, archive_root: Path) -> int` (parcourt `archive_root` à la recherche de fichiers d'extension `regine_core.import_carte.copie.EXTENSIONS_MAITRES`, lit `Model` via `regine_core.metadata.exif.read_camera_tags`, appelle `camera_profile.db.get_or_create_boitier(..., source="parcours_nas")`, retourne le nombre de boîtiers réellement nouveaux — FR-012, PEUT donc optionnel mais implémenté ici) | |
| 115 | +- [X] T025 [US3] Implémenter les sous-commandes `regine config cameras list [--json]`, `regine config cameras rename --id ID --nom NOM`, `regine config cameras scan-nas [--confirm]` dans `config_cmd.py` — `scan-nas` sans `--confirm` avertit et sort en code non-zéro sans rien exécuter (FR-012, cf. `contracts/cli-config.md`) ; vérifie la disponibilité de l'archive (`smb.assurer_disponibilite`) puis dérive son point de montage local via `smb.chemin_local_du_partage` (même convention que US2, pas de second mécanisme de résolution). 9/9 tests de contrat CLI passent (`test_cli_config.py`, étendu pour `cameras list`/`rename`/`scan-nas`) | |
| 116 | + | |
| 117 | +**Checkpoint** : les 3 user stories sont fonctionnelles indépendamment — 134/134 tests passent (`packages/regine-core` + `packages/regine-cli`). | |
| 118 | + | |
| 119 | +--- | |
| 120 | + | |
| 121 | +## Phase 6: Polish & Cross-Cutting Concerns | |
| 122 | + | |
| 123 | +- [X] T026 [P] Exécuter manuellement les 6 scénarios de `specs/003-config-contexte-travail/quickstart.md` (adaptés à la surface CLI réellement livrée) et consigner le résultat — scénarios 1, 2, 3, 5, 6 exécutés via la vraie CLI (`uv run python -m regine_cli.config_cmd config ...`), tous conformes. Scénario 4 (montage/démontage SMB réel) non exécutable dans cet environnement de développement (pas de partage SMB physique disponible) — le mécanisme sous-jacent (`smb.disponible`/`assurer_disponibilite`/`declencher_montage`) est validé unitairement avec un point de montage injecté (T019), et `config show` a correctement détecté `smb://nas.local/regine-archive` (inexistant dans cet environnement) comme `unavailable` lors du scénario 1, confirmant la détection réelle | |
| 124 | +- [X] T027 [P] `ruff check --fix` + `ruff format` sur `packages/regine-core` et `packages/regine-cli` | |
| 125 | +- [X] T028 Mettre à jour `contracts/cli-config.md` et créer `contracts/regine-core-api.md` — signatures réelles documentées, écarts de conception consignés (cf. § Écarts en tête de fichier) ; note explicite que `regine_cli.import_cmd`/`archive_cmd` (specs/001/005) ne consomment pas encore ce contexte configuré (ils prennent `--archive-root`/`--local-root` en flags explicites) — intégration future hors périmètre de cette spec, non construite ici sans nouvelle demande explicite | |
| 126 | + | |
| 127 | +--- | |
| 128 | + | |
| 129 | +## Dependencies & Execution Order | |
| 130 | + | |
| 131 | +- **Setup (Phase 1)** : aucune dépendance, tâches `[P]` en parallèle. | |
| 132 | +- **Foundational (Phase 2)** : dépend de Setup ; bloque toutes les user stories. | |
| 133 | +- **US1 (Phase 3)** : dépend de Foundational uniquement. MVP. | |
| 134 | +- **US2 (Phase 4)** : dépend de Foundational ; s'intègre à `regine config show` (US1) mais reste testable indépendamment (`smb.py` seul). | |
| 135 | +- **US3 (Phase 5)** : dépend de Foundational et de `specs/002` (déjà livrée) ; indépendante de US1/US2. | |
| 136 | +- **Polish (Phase 6)** : dépend des user stories livrées (au minimum US1). | |
| 137 | + | |
| 138 | +## Implementation Strategy | |
| 139 | + | |
| 140 | +MVP = Phase 1 + 2 + 3 (US1). US2 et US3 sont des incréments indépendants livrables ensuite dans l'ordre de priorité. | |
| new file mode 100644 | |||
| @@ -0,0 +1,140 @@ | |||
| 1 | +--- | ||
| 2 | + | ||
| 3 | +description: "Task list for feature implementation" | ||
| 4 | +--- | ||
| 5 | + | ||
| 6 | +# Tasks: Configuration du contexte de travail de Régine | ||
| 7 | + | ||
| 8 | +**Input**: Design documents from `/specs/003-config-contexte-travail/` | ||
| 9 | +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md | ||
| 10 | + | ||
| 11 | +**Tests**: Incluses — `plan.md` § Project Structure nomme explicitement les fichiers de test attendus, ce qui vaut demande explicite au sens des règles de génération. | ||
| 12 | + | ||
| 13 | +**Organisation**: Tâches groupées par user story (spec.md) pour une implémentation et une validation indépendantes de chacune. | ||
| 14 | + | ||
| 15 | +## Dépendances inter-specs (à lire avant de commencer) | ||
| 16 | + | ||
| 17 | +| Dépendance | Fournit | État | | ||
| 18 | +|---|---|---| | ||
| 19 | +| `specs/004-categorisation-dossiers` | `regine_core.config.db.open_context_db` (base de contexte centralisée, déjà créée) | Implémentée | | ||
| 20 | +| `specs/002-profil-boitiers-optionnel` | `regine_core.camera_profile.db` (table `boitiers`, `list_boitiers`, `rename_boitier`, `get_or_create_boitier`) — **possède** la table `boitiers`, cette spec ne fait que l'exposer (cf. plan.md « Correction du 2026-09-18 ») | Implémentée | | ||
| 21 | +| `specs/005-checkout-reconciliation` | `regine_core.archive.manifest` (manifeste persistant par dossier, `PRAGMA user_version`/`application_id`) et `regine_core.archive.verrou` (verrouillage) — réutilisés tels quels pour FR-006/FR-010 (détection de travail non réconcilié) | Implémentée | | ||
| 22 | +| `specs/001-import-photos` | `regine_core.import_carte.copie.EXTENSIONS_MAITRES` (réutilisée par le parcours NAS, US3/FR-012, pour reconnaître un fichier maître sans dupliquer la liste) | Implémentée | | ||
| 23 | + | ||
| 24 | +## Écarts entre les documents de conception et l'état réel du code (à respecter pendant l'implémentation) | ||
| 25 | + | ||
| 26 | +- **`data-model.md` § Base de contexte centralisée** a été rédigé avant l'implémentation de `specs/002-profil-boitiers-optionnel` et décrit une colonne `source` à deux valeurs (`import`/`parcours_nas`). Le schéma réellement livré par specs/002 (`camera_profile/db.py`) utilise déjà `source` pour une distinction différente et orthogonale (`modele`/`numero_serie`/`manuel` — **comment** un boîtier a été désambiguïsé). Ne PAS renommer ni migrer cette colonne déjà testée : réutiliser la même colonne texte libre en y ajoutant la seule valeur manquante `parcours_nas` (FR-012) ; à l'affichage (`cameras list`), traiter toute valeur différente de `parcours_nas` comme « détecté via import » (FR-008). | ||
| 27 | +- **research.md ne fixe pas l'emplacement de fichier de la base de contexte centralisée** (seulement son schéma). Décision prise dans cette liste de tâches (T005) : un emplacement fixe par utilisateur, indépendant de `local_work_dir` (qui, lui, est modifiable, cf. FR-010/SC-005 — la base de contexte ne doit jamais se perdre à un changement de chemin). | ||
| 28 | +- **FR-006/Acceptance Scenario 3 (US1)** : lu littéralement (« une base de données de travail est créée... à la racine de ce répertoire »). Réutilise directement `regine_core.archive.manifest.ouvrir_ou_creer` sur `local_work_dir` lui-même (déjà générique, accepte n'importe quel dossier) plutôt que d'inventer un second mécanisme — cohérent avec l'Assumption de spec.md (« cette spécification couvre son initialisation... pas son schéma détaillé »). Le manifeste par dossier archivé (FR-007) reste géré indépendamment par `specs/001`/`specs/005`, déjà correct. | ||
| 29 | + | ||
| 30 | +## Phase 1: Setup | ||
| 31 | + | ||
| 32 | +- [X] T001 [P] Créer `packages/regine-core/src/regine_core/config/context.py` (squelette, docstring de module uniquement) | ||
| 33 | +- [X] T002 [P] Créer `packages/regine-core/src/regine_core/config/smb.py` (squelette, docstring de module uniquement) | ||
| 34 | +- [X] T003 [P] Créer `packages/regine-core/src/regine_core/config/cameras_screen.py` (squelette, docstring de module uniquement) | ||
| 35 | +- [X] T004 [P] Créer `packages/regine-cli/src/regine_cli/config_cmd.py` (squelette, docstring de module uniquement) — et `packages/regine-cli/tests/contract/`, `[tool.pytest.ini_options] testpaths = ["tests"]` ajouté à `packages/regine-cli/pyproject.toml` (aucun test CLI automatisé n'existait encore dans le dépôt) | ||
| 36 | + | ||
| 37 | +--- | ||
| 38 | + | ||
| 39 | +## Phase 2: Foundational (Blocking Prerequisites) | ||
| 40 | + | ||
| 41 | +**Objectif** : la persistance du contexte de travail (chemins + état SMB) et la détection de travail non réconcilié, dont dépendent les 3 user stories. | ||
| 42 | + | ||
| 43 | +**⚠️ CRITIQUE** : aucune user story ne peut être implémentée avant la fin de cette phase. | ||
| 44 | + | ||
| 45 | +- [X] T005 Étendre `packages/regine-core/src/regine_core/config/db.py` : ajouter `CHEMIN_CONTEXTE_PAR_DEFAUT = Path.home() / ".regine" / "contexte.sqlite3"` et rendre `open_context_db(path: Path | None = None)` optionnel (défaut : `CHEMIN_CONTEXTE_PAR_DEFAUT`), sans changer le comportement pour les appelants existants qui passent déjà un chemin explicite (specs/001/002/004) | ||
| 46 | +- [X] T006 [P] Test unitaire de `open_context_db(None)` (utilise le chemin par défaut, le crée si absent) dans `packages/regine-core/tests/unit/test_config_db_defaut.py` — le test patch la constante de module `CHEMIN_CONTEXTE_PAR_DEFAUT` plutôt que `Path.home` (celle-ci est déjà évaluée à l'import, un monkeypatch après coup n'aurait aucun effet) | ||
| 47 | +- [X] T007 Implémenter `ContexteTravail` (dataclass), le schéma de la table `contexte` (ligne unique, `id INTEGER PRIMARY KEY CHECK (id = 1)`) et `charger_contexte(conn) -> ContexteTravail` dans `packages/regine-core/src/regine_core/config/context.py` (data-model.md § Contexte de travail) | ||
| 48 | +- [X] T008 Implémenter les exceptions dédiées dans `context.py` : `RepertoireAbsentError`, `CheminsAmbigusError`, `CheminArchiveInvalideError`, `TravailNonReconcilieError` (porte la liste des dossiers concernés) — `ArchiveInaccessibleError` ajoutée en plus (cf. note T009) | ||
| 49 | +- [X] T009 Implémenter `lister_dossiers_non_reconcilies(local_work_dir: Path, archive_root_local: Path) -> list[Path]` dans `context.py` — **écart avec la conception initiale découvert à l'implémentation** : le verrou/manifeste vit uniquement côté archive (`specs/005`, jamais recopié dans la copie de travail locale lors d'un checkout), donc parcourir `local_work_dir` seul ne peut jamais rien trouver. Corrigé : parcourt les manifestes de `archive_root_local` (point de montage local du partage SMB, `smb.chemin_local_du_partage`), ne retient que ceux ayant une copie locale présente sous `local_work_dir`. Lève `ArchiveInaccessibleError` si l'archive n'est pas accessible plutôt que de conclure à tort qu'il n'y a rien à signaler | ||
| 50 | +- [X] T010 Implémenter `enregistrer_chemins(conn, *, temp_dir=None, local_work_dir=None, archive_smb_path=None, creer_repertoires_manquants=False, forcer_collision=False, ignorer_verification_archive=False) -> ContexteTravail` dans `context.py` : valide `archive_smb_path` (DOIT commencer par `smb://`, sinon `CheminArchiveInvalideError`), détecte la collision `temp_dir == local_work_dir` (`CheminsAmbigusError` sauf `forcer_collision`), bloque un changement de `local_work_dir` via `lister_dossiers_non_reconcilies` (`TravailNonReconcilieError`) — sauf si aucune archive n'est encore configurée (rien à vérifier) ou `ignorer_verification_archive=True` (geste explicite, même famille que `forcer_collision`) ; une archive configurée mais inaccessible refuse le changement par défaut (`ArchiveInaccessibleError` remontée telle quelle). Crée les répertoires manquants seulement si `creer_repertoires_manquants` (sinon `RepertoireAbsentError`), initialise la base de travail à la racine d'un nouveau `local_work_dir` (`regine_core.archive.manifest.ouvrir_ou_creer`, FR-006/SC-003), puis persiste | ||
| 51 | +- [X] T011 Implémenter `mettre_a_jour_etat_smb(conn, etat: Literal["mounted","unavailable","unknown"]) -> None` dans `context.py` | ||
| 52 | +- [X] T012 [P] Tests unitaires de `context.py` dans `packages/regine-core/tests/unit/test_context_paths.py` : configuration initiale, répertoire manquant sans/avec `creer_repertoires_manquants`, collision `temp_dir`/`local_work_dir` sans/avec `forcer_collision`, `archive_smb_path` invalide (ne commence pas par `smb://`), FR-006 (base de travail présente à la racine après configuration), champs non fournis conservés (SC-005) | ||
| 53 | +- [X] T013 [P] Tests unitaires de `lister_dossiers_non_reconcilies`/`enregistrer_chemins` dans `test_context_paths.py` : dossier verrouillé détecté, dossier réconcilié ignoré, archive inaccessible lève, aucune archive configurée n'exige aucune vérification, `ignorer_verification_archive` passe outre explicitement — 15/15 tests passent (`packages/regine-core/src/regine_core/config/smb.py` reçoit dès cette phase `chemin_local_du_partage`, requis par T009/T010 ; le reste de `smb.py` — `disponible`/`declencher_montage`/`assurer_disponibilite` — reste prévu en Phase 4/T020) | ||
| 54 | + | ||
| 55 | +**Checkpoint** : la persistance du contexte est prête ; les 3 user stories peuvent commencer. | ||
| 56 | + | ||
| 57 | +--- | ||
| 58 | + | ||
| 59 | +## Phase 3: User Story 1 - Configuration initiale des emplacements de travail (Priority: P1) 🎯 MVP | ||
| 60 | + | ||
| 61 | +**Objectif** : configurer et consulter les 3 emplacements de travail, avec initialisation de la base de travail et refus explicite d'un changement dangereux. | ||
| 62 | + | ||
| 63 | +**Test indépendant** : configurer les 3 chemins sur un contexte vide (`regine config set-paths`), vérifier leur persistance (`regine config show`), vérifier qu'un changement de `local_work_dir` avec un dossier verrouillé dessous est refusé. | ||
| 64 | + | ||
| 65 | +### Tests for User Story 1 | ||
| 66 | + | ||
| 67 | +- [X] T014 [P] [US1] Test d'intégration du cycle configuration → initialisation de la base de travail → changement de chemin bloqué dans `packages/regine-core/tests/integration/test_config_context.py` (Acceptance Scenarios 1 à 5 de US1) | ||
| 68 | +- [X] T015 [P] [US1] Test de contrat de la surface CLI (`set-paths`, `show`, codes de sortie, `--json`) dans `packages/regine-cli/tests/contract/test_cli_config.py` | ||
| 69 | + | ||
| 70 | +### Implementation for User Story 1 | ||
| 71 | + | ||
| 72 | +- [X] T016 [US1] Implémenter la sous-commande `regine config set-paths` dans `packages/regine-cli/src/regine_cli/config_cmd.py` : `--temp-dir`, `--local-work-dir`, `--archive-smb`, `--yes` (confirme la création de répertoire, FR-003), `--force` (confirme la collision de chemins), `--ignorer-verification-archive` ; traduit chaque exception de `context.py` en message stderr + code de sortie non-zéro (cf. `contracts/cli-config.md`) | ||
| 73 | +- [X] T017 [US1] Implémenter la sous-commande `regine config show [--json]` dans `config_cmd.py` : affiche les 3 chemins et l'état SMB connu, ou la liste des chemins manquants si le contexte n'est pas encore configuré (Edge Case) — **implémentée en même temps que T021 (US2)** : `show` déclenche `smb.assurer_disponibilite` dès qu'une archive est configurée, testé par `test_cli_config.py` | ||
| 74 | +- [X] T018 [US1] Construire l'analyseur d'arguments (`construire_analyseur`/`main`) dans `config_cmd.py`, cohérent avec `import_cmd.py`/`archive_cmd.py` | ||
| 75 | + | ||
| 76 | +**Checkpoint** : User Story 1 fonctionnelle et testable indépendamment (MVP) — 125/125 tests passent. | ||
| 77 | + | ||
| 78 | +--- | ||
| 79 | + | ||
| 80 | +## Phase 4: User Story 2 - Assistance quand l'archive NAS n'est pas accessible (Priority: P2) | ||
| 81 | + | ||
| 82 | +**Objectif** : détecter l'indisponibilité du partage SMB avant qu'une opération échoue, déclencher le montage natif, permettre une reprise sans reconfiguration. | ||
| 83 | + | ||
| 84 | +**Test indépendant** : configurer un `archive_smb_path` pointant vers un partage démonté, lancer `regine config show`, vérifier qu'un montage est déclenché et que l'état SMB est mis à jour ; remonter manuellement puis relancer, vérifier la reprise sans reconfiguration. | ||
| 85 | + | ||
| 86 | +**Note d'ordonnancement** : implémentée en avance sur l'ordre de priorité initial, dès la Phase 3 — `regine config show` (T017) en a besoin directement (cf. quickstart.md scénario 4) et `context.enregistrer_chemins`/`lister_dossiers_non_reconcilies` (T009/T010, Phase 2) dépendaient déjà de `smb.chemin_local_du_partage`. | ||
| 87 | + | ||
| 88 | +### Tests for User Story 2 | ||
| 89 | + | ||
| 90 | +- [X] T019 [P] [US2] Test unitaire de `smb.py` dans `packages/regine-core/tests/unit/test_smb_disponibilite.py` : `disponible()` sur un point de montage présent/absent (via un répertoire temporaire simulant `/Volumes/<partage>`, injecté par dépendance plutôt que codé en dur), `assurer_disponibilite()` met à jour `smb_last_known_state`, déclenche/ne déclenche pas le montage selon `declencher` | ||
| 91 | + | ||
| 92 | +### Implementation for User Story 2 | ||
| 93 | + | ||
| 94 | +- [X] T020 [US2] Implémenter `packages/regine-core/src/regine_core/config/smb.py` : `disponible(archive_smb_path: str) -> bool` (vérifie l'accessibilité du point de montage macOS `/Volumes/<partage>`, dérivé de l'URL `smb://...`), `declencher_montage(archive_smb_path: str) -> None` (`open smb://...`, cf. research.md § 1), `assurer_disponibilite(conn, archive_smb_path, *, declencher=True) -> bool` (met à jour l'état via `context.mettre_a_jour_etat_smb`, déclenche le montage si indisponible) | ||
| 95 | +- [X] T021 [US2] Intégrer `smb.assurer_disponibilite` dans `regine config show` (`config_cmd.py`) : si un `archive_smb_path` est configuré, vérifier sa disponibilité avant d'afficher l'état, déclencher le montage natif si nécessaire (Acceptance Scenario 1/2 de US2) — pas de nouvelle tentative automatique en boucle (Acceptance Scenario 3, une seule vérification par invocation, cohérent avec l'exécution sans démon) | ||
| 96 | + | ||
| 97 | +**Checkpoint** : User Stories 1 et 2 fonctionnelles indépendamment. | ||
| 98 | + | ||
| 99 | +--- | ||
| 100 | + | ||
| 101 | +## Phase 5: User Story 3 - Nommer les boîtiers à partir des photos déjà archivées (Priority: P3) | ||
| 102 | + | ||
| 103 | +**Objectif** : lister et nommer les boîtiers déjà enregistrés dans la base de contexte centralisée, sans réimport ni parcours du NAS ; parcours volontaire du NAS en complément optionnel (FR-012). | ||
| 104 | + | ||
| 105 | +**Test indépendant** : sur une base de contexte contenant déjà des boîtiers enregistrés par un import (cf. specs/001), lister les boîtiers puis en nommer un, sans déclencher d'accès au NAS. | ||
| 106 | + | ||
| 107 | +### Tests for User Story 3 | ||
| 108 | + | ||
| 109 | +- [X] T022 [P] [US3] Test unitaire de `cameras_screen.py` dans `packages/regine-core/tests/unit/test_cameras_screen.py` : liste des boîtiers déjà enregistrés (réutilise `camera_profile.db`), nommage d'un boîtier connu, `BoitierInconnuError` sur un identifiant absent | ||
| 110 | +- [X] T023 [P] [US3] Test d'intégration du parcours NAS dans `packages/regine-core/tests/integration/test_cameras_scan_nas.py` : une arborescence simulant une archive avec des fichiers maîtres de boîtiers non encore connus (tags EXIF réels via `exiftool`) ; vérifie que `parcourir_nas` les ajoute avec `source="parcours_nas"` et n'affecte pas les boîtiers déjà connus (source `modele`), plus idempotence (second parcours ne redouble rien) | ||
| 111 | + | ||
| 112 | +### Implementation for User Story 3 | ||
| 113 | + | ||
| 114 | +- [X] T024 [US3] Implémenter `packages/regine-core/src/regine_core/config/cameras_screen.py` : `lister_boitiers(conn) -> list[Boitier]` (délègue à `camera_profile.db.list_boitiers`, FR-008), `nommer_boitier(conn, boitier_id: int, nom: str) -> None` (délègue à `camera_profile.db.rename_boitier` après avoir vérifié l'existence de `boitier_id`, sinon `BoitierInconnuError` — cf. `contracts/cli-config.md`), `parcourir_nas(conn, archive_root: Path) -> int` (parcourt `archive_root` à la recherche de fichiers d'extension `regine_core.import_carte.copie.EXTENSIONS_MAITRES`, lit `Model` via `regine_core.metadata.exif.read_camera_tags`, appelle `camera_profile.db.get_or_create_boitier(..., source="parcours_nas")`, retourne le nombre de boîtiers réellement nouveaux — FR-012, PEUT donc optionnel mais implémenté ici) | ||
| 115 | +- [X] T025 [US3] Implémenter les sous-commandes `regine config cameras list [--json]`, `regine config cameras rename --id ID --nom NOM`, `regine config cameras scan-nas [--confirm]` dans `config_cmd.py` — `scan-nas` sans `--confirm` avertit et sort en code non-zéro sans rien exécuter (FR-012, cf. `contracts/cli-config.md`) ; vérifie la disponibilité de l'archive (`smb.assurer_disponibilite`) puis dérive son point de montage local via `smb.chemin_local_du_partage` (même convention que US2, pas de second mécanisme de résolution). 9/9 tests de contrat CLI passent (`test_cli_config.py`, étendu pour `cameras list`/`rename`/`scan-nas`) | ||
| 116 | + | ||
| 117 | +**Checkpoint** : les 3 user stories sont fonctionnelles indépendamment — 134/134 tests passent (`packages/regine-core` + `packages/regine-cli`). | ||
| 118 | + | ||
| 119 | +--- | ||
| 120 | + | ||
| 121 | +## Phase 6: Polish & Cross-Cutting Concerns | ||
| 122 | + | ||
| 123 | +- [X] T026 [P] Exécuter manuellement les 6 scénarios de `specs/003-config-contexte-travail/quickstart.md` (adaptés à la surface CLI réellement livrée) et consigner le résultat — scénarios 1, 2, 3, 5, 6 exécutés via la vraie CLI (`uv run python -m regine_cli.config_cmd config ...`), tous conformes. Scénario 4 (montage/démontage SMB réel) non exécutable dans cet environnement de développement (pas de partage SMB physique disponible) — le mécanisme sous-jacent (`smb.disponible`/`assurer_disponibilite`/`declencher_montage`) est validé unitairement avec un point de montage injecté (T019), et `config show` a correctement détecté `smb://nas.local/regine-archive` (inexistant dans cet environnement) comme `unavailable` lors du scénario 1, confirmant la détection réelle | ||
| 124 | +- [X] T027 [P] `ruff check --fix` + `ruff format` sur `packages/regine-core` et `packages/regine-cli` | ||
| 125 | +- [X] T028 Mettre à jour `contracts/cli-config.md` et créer `contracts/regine-core-api.md` — signatures réelles documentées, écarts de conception consignés (cf. § Écarts en tête de fichier) ; note explicite que `regine_cli.import_cmd`/`archive_cmd` (specs/001/005) ne consomment pas encore ce contexte configuré (ils prennent `--archive-root`/`--local-root` en flags explicites) — intégration future hors périmètre de cette spec, non construite ici sans nouvelle demande explicite | ||
| 126 | + | ||
| 127 | +--- | ||
| 128 | + | ||
| 129 | +## Dependencies & Execution Order | ||
| 130 | + | ||
| 131 | +- **Setup (Phase 1)** : aucune dépendance, tâches `[P]` en parallèle. | ||
| 132 | +- **Foundational (Phase 2)** : dépend de Setup ; bloque toutes les user stories. | ||
| 133 | +- **US1 (Phase 3)** : dépend de Foundational uniquement. MVP. | ||
| 134 | +- **US2 (Phase 4)** : dépend de Foundational ; s'intègre à `regine config show` (US1) mais reste testable indépendamment (`smb.py` seul). | ||
| 135 | +- **US3 (Phase 5)** : dépend de Foundational et de `specs/002` (déjà livrée) ; indépendante de US1/US2. | ||
| 136 | +- **Polish (Phase 6)** : dépend des user stories livrées (au minimum US1). | ||
| 137 | + | ||
| 138 | +## Implementation Strategy | ||
| 139 | + | ||
| 140 | +MVP = Phase 1 + 2 + 3 (US1). US2 et US3 sont des incréments indépendants livrables ensuite dans l'ordre de priorité. | ||