"""Couche logique de l'éditeur de profils (persistance via profile_defaults). Wrappers testables sans display : assemblage de la spec, détection runtime/éditable vs defaut/lecture-seule, sauvegarde dans config/profiles.yml. Aucun profil par défaut (profiles.default.yml) n'est jamais modifié ici. """ from __future__ import annotations from pathlib import Path from typing import Any, Iterable, Optional from profile_defaults import ( delete_runtime_profile, get_default_profile_key, list_effective_profiles, load_runtime_profiles_overlay_dict, save_runtime_profile, set_runtime_default_profile, ) def _clean_list(values: Optional[Iterable]) -> list[str]: return [str(v).strip() for v in (values or []) if str(v).strip()] def build_profile_spec( *, label: str, description: str = "", require_manual_mask: bool = False, force_disable_vlm: bool = False, preferred_manual_mask_template: str = "", whitelist: Optional[Iterable] = None, blacklist: Optional[Iterable] = None, stopwords: Optional[Iterable] = None, ) -> dict[str, Any]: """Assemble une spec de profil persistable (3 listes normalisées).""" return { "label": str(label or "").strip(), "description": str(description or ""), "require_manual_mask": bool(require_manual_mask), "force_disable_vlm": bool(force_disable_vlm), "preferred_manual_mask_template": str(preferred_manual_mask_template or "").strip(), "has_preferred_manual_mask_template": True, "has_param_lists": True, "param_lists": { "whitelist_phrases": _clean_list(whitelist), "blacklist_force_mask_terms": _clean_list(blacklist), "additional_stopwords": _clean_list(stopwords), }, } def runtime_profile_keys(path: Path | None = None) -> set[str]: """Clés des profils définis dans l'overlay runtime (config/profiles.yml).""" try: data = load_runtime_profiles_overlay_dict(path) or {} except Exception: return set() profiles = data.get("profiles") if isinstance(data, dict) else None return set(profiles) if isinstance(profiles, dict) else set() def profile_is_editable(key: str, path: Path | None = None) -> bool: """Un profil est éditable s'il est dans l'overlay runtime (pas un defaut pur).""" return key in runtime_profile_keys(path) def _default_key(path: Path | None = None) -> Optional[str]: try: data = load_runtime_profiles_overlay_dict(path) or {} if isinstance(data, dict) and data.get("default_profile"): return str(data["default_profile"]) except Exception: pass try: return get_default_profile_key() except Exception: return None def list_profile_choices(path: Path | None = None) -> list[dict]: """Liste triée des profils avec méta : ``key``, ``label``, ``editable``, ``is_default``.""" profiles = list_effective_profiles(path) runtime = runtime_profile_keys(path) default = _default_key(path) return [ { "key": key, "label": str(profiles[key].get("label") or key), "editable": key in runtime, "is_default": key == default, } for key in sorted(profiles) ] def slug_for_copy(key: str, existing: Iterable[str]) -> str: """Clé de copie unique : ``{key}_copie`` puis ``_2``, ``_3``…""" existing = set(existing) base = f"{key}_copie" if base not in existing: return base index = 2 while f"{base}_{index}" in existing: index += 1 return f"{base}_{index}" def save_profile(key: str, spec: dict, path: Path | None = None, *, set_default: bool = False) -> Path: return save_runtime_profile(key, spec, path, set_default=set_default) def set_default_profile(key: str, path: Path | None = None) -> Path: return set_runtime_default_profile(key, path) def delete_profile(key: str, path: Path | None = None) -> Path: return delete_runtime_profile(key, path)