Spaces:
Sleeping
sprint27: snapshots de reproductibilité dans le rapport HTML
Browse filesAvant Sprint 27
---------------
Le rapport HTML auto-contenu n'embarquait que
``pareto.pricing_meta.last_updated`` — une simple date qui ne disait
rien sur le contenu de la table de prix utilisée. Si quelqu'un
modifiait ``picarones/data/pricing.yaml`` après génération, il était
impossible de reconstituer ce qu'avait vu le lecteur du rapport.
Idem pour le glossaire et le profil de normalisation : aucune trace
dans le HTML produit.
Pour un outil scientifique qui se présente comme « factuel » (cf.
moteur narratif Sprint 19, garde-fou anti-hallucination Sprint 23),
c'est un trou méthodologique : on traçait les *nombres* sans tracer
les *paramètres* qui les produisaient.
Sprint 27 — apport
------------------
Nouveau module ``picarones/report/snapshot.py`` qui expose quatre
fonctions de snapshot pures + une API agrégée ``snapshot_all()``.
Le résultat est embarqué dans ``report_data["snapshots"]`` par
``ReportGenerator.generate()`` :
- **pricing** : YAML brut intégral de ``data/pricing.yaml`` +
dict parsé. Un lecteur peut extraire ``raw_yaml``
et reconstituer exactement la table utilisée.
- **glossary** : entrées du glossaire dans la langue du rapport,
triées par clé pour reproductibilité bit-à-bit.
Filtrage optionnel par ``used_keys``.
- **normalization** : profil sérialisé (``name``, ``nfc``, ``caseless``,
``diplomatic_table``, ``exclude_chars`` triés,
``description``).
- **environment** : version Picarones, version Python, plateforme,
commit git court (12 chars) si dispo, liste
figée des paquets installés (200 max), triée
case-insensitive et dédupliquée par nom.
``schema_version: 1`` ouvert dans le bloc pour les futures évolutions.
``ReportGenerator`` accepte un nouveau paramètre
``normalization_profile=...`` (fallback à
``benchmark.metadata["normalization_profile"]``) pour que le snapshot
soit fidèle au profil effectivement utilisé.
Garanties
---------
- Déterminisme strict sur les sections statiques (pricing, glossary,
normalization). Seul ``environment.git_commit`` peut varier selon
l'état du repo, et c'est documenté.
- Dégradé non bloquant : pricing.yaml absent, pyyaml absent, git
inaccessible → ``{"available": False, "reason": "..."}`` plutôt que
d'exception.
- Aucun effet de bord : lecture seule, aucun chemin écrit, aucun
cache global mutable.
Tests (+23, soit 1377 passing au total)
---------------------------------------
tests/test_sprint27_reproducibility_snapshots.py couvre :
- Snapshot pricing : YAML par défaut chargé, custom YAML round-trip,
fichier absent → unavailable, sections meta et engines exposées.
- Snapshot glossary : fr complet, filtre used_keys, langue inconnue,
entrées triées pour déterminisme.
- Snapshot normalization : profil built-in sérialisé, None →
unavailable, exclude_chars triés.
- Snapshot environment : version Picarones cohérente, python/platform
présents, paquets triés et uniques, git_commit str|None.
- snapshot_all : 4 blocs + schema_version, déterminisme inter-appels.
- Intégration ReportGenerator : bloc snapshots présent dans le HTML,
pricing YAML brut embarqué, environment présent, glossary présent,
nom du profil de normalisation présent, raw_yaml disponible pour
rejouer la table.
Out of scope (reporté)
----------------------
Le mode ``--external-images`` (rapport léger qui externalise les
images dans ``<output>_assets/`` au lieu du base64) n'est pas inclus
ici — il sera traité dans un sous-sprint dédié pour ne pas mélanger
deux préoccupations indépendantes (reproductibilité scientifique vs
poids du rapport).
https://claude.ai/code/session_01L4RGWMrAajn5ZEFgTKjA5P
|
@@ -19,7 +19,7 @@ import base64
|
|
| 19 |
import io
|
| 20 |
import json
|
| 21 |
from pathlib import Path
|
| 22 |
-
from typing import Optional
|
| 23 |
|
| 24 |
# ---------------------------------------------------------------------------
|
| 25 |
# Ressources vendor (embarquées dans le rapport HTML)
|
|
@@ -618,6 +618,7 @@ class ReportGenerator:
|
|
| 618 |
benchmark: BenchmarkResult,
|
| 619 |
images_b64: Optional[dict[str, str]] = None,
|
| 620 |
lang: str = "fr",
|
|
|
|
| 621 |
) -> None:
|
| 622 |
"""
|
| 623 |
Parameters
|
|
@@ -629,15 +630,25 @@ class ReportGenerator:
|
|
| 629 |
Si None, le générateur cherche dans ``benchmark.metadata["_images_b64"]``.
|
| 630 |
lang:
|
| 631 |
Code langue du rapport : ``"fr"`` (défaut) ou ``"en"``.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 632 |
"""
|
| 633 |
self.benchmark = benchmark
|
| 634 |
self.images_b64: dict[str, str] = images_b64 or {}
|
| 635 |
self.lang = lang
|
|
|
|
| 636 |
|
| 637 |
# Récupérer les images embarquées dans les metadata (fixtures)
|
| 638 |
if not self.images_b64:
|
| 639 |
self.images_b64 = benchmark.metadata.get("_images_b64", {}) # type: ignore[assignment]
|
| 640 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
def generate(self, output_path: str | Path) -> Path:
|
| 642 |
"""Génère le fichier HTML et le sauvegarde sur disque.
|
| 643 |
|
|
@@ -663,6 +674,17 @@ class ReportGenerator:
|
|
| 663 |
|
| 664 |
labels = get_labels(self.lang)
|
| 665 |
report_data = _build_report_data(self.benchmark, images_b64)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 666 |
report_json = json.dumps(report_data, ensure_ascii=False, separators=(",", ":"))
|
| 667 |
i18n_json = json.dumps(labels, ensure_ascii=False, separators=(",", ":"))
|
| 668 |
chartjs_js = _load_vendor_js("chart.umd.min.js")
|
|
|
|
| 19 |
import io
|
| 20 |
import json
|
| 21 |
from pathlib import Path
|
| 22 |
+
from typing import Any, Optional
|
| 23 |
|
| 24 |
# ---------------------------------------------------------------------------
|
| 25 |
# Ressources vendor (embarquées dans le rapport HTML)
|
|
|
|
| 618 |
benchmark: BenchmarkResult,
|
| 619 |
images_b64: Optional[dict[str, str]] = None,
|
| 620 |
lang: str = "fr",
|
| 621 |
+
normalization_profile: Any = None,
|
| 622 |
) -> None:
|
| 623 |
"""
|
| 624 |
Parameters
|
|
|
|
| 630 |
Si None, le générateur cherche dans ``benchmark.metadata["_images_b64"]``.
|
| 631 |
lang:
|
| 632 |
Code langue du rapport : ``"fr"`` (défaut) ou ``"en"``.
|
| 633 |
+
normalization_profile:
|
| 634 |
+
Profil de normalisation effectivement utilisé (Sprint 27 — pour
|
| 635 |
+
le snapshot de reproductibilité). ``None`` retombe sur le
|
| 636 |
+
profil mentionné dans ``benchmark.metadata["normalization_profile"]``
|
| 637 |
+
s'il est présent, sinon snapshot indisponible.
|
| 638 |
"""
|
| 639 |
self.benchmark = benchmark
|
| 640 |
self.images_b64: dict[str, str] = images_b64 or {}
|
| 641 |
self.lang = lang
|
| 642 |
+
self.normalization_profile = normalization_profile
|
| 643 |
|
| 644 |
# Récupérer les images embarquées dans les metadata (fixtures)
|
| 645 |
if not self.images_b64:
|
| 646 |
self.images_b64 = benchmark.metadata.get("_images_b64", {}) # type: ignore[assignment]
|
| 647 |
|
| 648 |
+
# Sprint 27 — fallback : profil de normalisation depuis les metadata
|
| 649 |
+
if self.normalization_profile is None:
|
| 650 |
+
self.normalization_profile = benchmark.metadata.get("normalization_profile")
|
| 651 |
+
|
| 652 |
def generate(self, output_path: str | Path) -> Path:
|
| 653 |
"""Génère le fichier HTML et le sauvegarde sur disque.
|
| 654 |
|
|
|
|
| 674 |
|
| 675 |
labels = get_labels(self.lang)
|
| 676 |
report_data = _build_report_data(self.benchmark, images_b64)
|
| 677 |
+
|
| 678 |
+
# Sprint 27 — snapshots de reproductibilité (pricing, glossaire,
|
| 679 |
+
# profil de normalisation, environnement). Embarqués dans le JSON
|
| 680 |
+
# du rapport pour qu'un lecteur puisse régénérer la synthèse, le
|
| 681 |
+
# Pareto et le glossaire sans accès au code source.
|
| 682 |
+
from picarones.report.snapshot import snapshot_all
|
| 683 |
+
report_data["snapshots"] = snapshot_all(
|
| 684 |
+
lang=self.lang,
|
| 685 |
+
normalization_profile=self.normalization_profile,
|
| 686 |
+
)
|
| 687 |
+
|
| 688 |
report_json = json.dumps(report_data, ensure_ascii=False, separators=(",", ":"))
|
| 689 |
i18n_json = json.dumps(labels, ensure_ascii=False, separators=(",", ":"))
|
| 690 |
chartjs_js = _load_vendor_js("chart.umd.min.js")
|
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Snapshots de reproductibilité pour le rapport HTML (Sprint 27).
|
| 2 |
+
|
| 3 |
+
Le rapport HTML auto-contenu doit pouvoir être *rejoué* sans avoir
|
| 4 |
+
accès au code source du moment où il a été généré : un lecteur en
|
| 5 |
+
2026 doit pouvoir comprendre exactement quelle table de prix, quelle
|
| 6 |
+
définition de métrique, quel profil de normalisation, et quelle
|
| 7 |
+
version de Picarones ont produit les chiffres affichés.
|
| 8 |
+
|
| 9 |
+
Avant le Sprint 27, le rapport intégrait uniquement
|
| 10 |
+
``pareto.pricing_meta.last_updated`` — une simple date de mise à jour
|
| 11 |
+
qui ne disait rien sur le contenu de la table. Si quelqu'un modifiait
|
| 12 |
+
``picarones/data/pricing.yaml`` après génération, il était impossible
|
| 13 |
+
de reconstituer ce qu'avait vu le lecteur du rapport.
|
| 14 |
+
|
| 15 |
+
Quatre snapshots sont produits par ce module et embarqués dans
|
| 16 |
+
``report_data.snapshots`` :
|
| 17 |
+
|
| 18 |
+
- ``pricing`` — YAML brut intégral de la table de prix.
|
| 19 |
+
- ``glossary`` — entrées du glossaire pour la langue du rapport.
|
| 20 |
+
- ``normalization`` — profil de normalisation effectivement appliqué.
|
| 21 |
+
- ``environment`` — version Picarones, Python, plateforme, commit git
|
| 22 |
+
si dispo, liste figée des dépendances installées.
|
| 23 |
+
|
| 24 |
+
Garanties
|
| 25 |
+
---------
|
| 26 |
+
- **Déterminisme** : sur entrées identiques, ``snapshot_all()`` produit
|
| 27 |
+
un dict bit-à-bit identique. Les listes sont triées, les timestamps
|
| 28 |
+
sont absents.
|
| 29 |
+
- **Pas d'effet de bord** : le module ne modifie aucun état global ;
|
| 30 |
+
les chemins YAML sont uniquement lus, jamais écrits.
|
| 31 |
+
- **Dégradé non bloquant** : si pyyaml est absent, si ``pricing.yaml``
|
| 32 |
+
n'existe pas, si git n'est pas installé, le snapshot retourne un
|
| 33 |
+
dict ``{"available": False, "reason": "..."}`` plutôt que de lever.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import logging
|
| 39 |
+
import platform
|
| 40 |
+
import subprocess
|
| 41 |
+
import sys
|
| 42 |
+
from importlib.metadata import distributions
|
| 43 |
+
from pathlib import Path
|
| 44 |
+
from typing import Any, Optional
|
| 45 |
+
|
| 46 |
+
from picarones import __version__
|
| 47 |
+
|
| 48 |
+
logger = logging.getLogger(__name__)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
# Pricing snapshot
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
|
| 55 |
+
def pricing_snapshot(pricing_path: Optional[Path] = None) -> dict[str, Any]:
|
| 56 |
+
"""Retourne le YAML brut + dict parsé de la table de prix utilisée.
|
| 57 |
+
|
| 58 |
+
Si ``pricing_path`` n'est pas fourni, utilise le chemin par défaut
|
| 59 |
+
de ``picarones.core.pricing._DEFAULT_PRICING_PATH``.
|
| 60 |
+
"""
|
| 61 |
+
if pricing_path is None:
|
| 62 |
+
try:
|
| 63 |
+
from picarones.core.pricing import _DEFAULT_PRICING_PATH
|
| 64 |
+
pricing_path = _DEFAULT_PRICING_PATH
|
| 65 |
+
except ImportError:
|
| 66 |
+
return {"available": False, "reason": "module pricing introuvable"}
|
| 67 |
+
|
| 68 |
+
pricing_path = Path(pricing_path)
|
| 69 |
+
if not pricing_path.exists():
|
| 70 |
+
return {
|
| 71 |
+
"available": False,
|
| 72 |
+
"reason": f"pricing.yaml introuvable : {pricing_path}",
|
| 73 |
+
"expected_path": str(pricing_path),
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
raw = pricing_path.read_text(encoding="utf-8")
|
| 78 |
+
except OSError as exc:
|
| 79 |
+
return {
|
| 80 |
+
"available": False,
|
| 81 |
+
"reason": f"lecture impossible : {exc}",
|
| 82 |
+
"expected_path": str(pricing_path),
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
try:
|
| 86 |
+
import yaml
|
| 87 |
+
data = yaml.safe_load(raw) or {}
|
| 88 |
+
except (ImportError, Exception) as exc:
|
| 89 |
+
# Pas de yaml ou parsing en échec — on garde le brut quand même.
|
| 90 |
+
logger.warning("[snapshot] parsing pricing.yaml échoué : %s", exc)
|
| 91 |
+
data = {}
|
| 92 |
+
|
| 93 |
+
return {
|
| 94 |
+
"available": True,
|
| 95 |
+
"source_path": str(pricing_path),
|
| 96 |
+
"filename": pricing_path.name,
|
| 97 |
+
"size_bytes": len(raw.encode("utf-8")),
|
| 98 |
+
"raw_yaml": raw,
|
| 99 |
+
"data": data,
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ---------------------------------------------------------------------------
|
| 104 |
+
# Glossary snapshot
|
| 105 |
+
# ---------------------------------------------------------------------------
|
| 106 |
+
|
| 107 |
+
def glossary_snapshot(
|
| 108 |
+
lang: str = "fr",
|
| 109 |
+
used_keys: Optional[list[str] | set[str]] = None,
|
| 110 |
+
) -> dict[str, Any]:
|
| 111 |
+
"""Retourne les entrées du glossaire qui figurent dans le rapport.
|
| 112 |
+
|
| 113 |
+
``used_keys`` permet de ne snapshotter que les termes effectivement
|
| 114 |
+
référencés (réduit la taille). ``None`` → toutes les entrées de la
|
| 115 |
+
langue (mode conservateur).
|
| 116 |
+
"""
|
| 117 |
+
try:
|
| 118 |
+
from picarones.report.glossary import load_glossary, SUPPORTED_LANGS
|
| 119 |
+
except ImportError:
|
| 120 |
+
return {"available": False, "reason": "module glossary introuvable"}
|
| 121 |
+
|
| 122 |
+
full = load_glossary(lang) or {}
|
| 123 |
+
if not full:
|
| 124 |
+
return {
|
| 125 |
+
"available": False,
|
| 126 |
+
"reason": f"aucune entrée pour lang={lang!r}",
|
| 127 |
+
"supported_langs": SUPPORTED_LANGS,
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
if used_keys is not None:
|
| 131 |
+
keys = set(used_keys)
|
| 132 |
+
entries = {k: v for k, v in full.items() if k in keys}
|
| 133 |
+
else:
|
| 134 |
+
entries = dict(full)
|
| 135 |
+
|
| 136 |
+
# Tri pour reproductibilité bit-à-bit.
|
| 137 |
+
entries_sorted = {k: entries[k] for k in sorted(entries)}
|
| 138 |
+
|
| 139 |
+
return {
|
| 140 |
+
"available": True,
|
| 141 |
+
"lang": lang,
|
| 142 |
+
"entry_count": len(entries_sorted),
|
| 143 |
+
"entries": entries_sorted,
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
# Normalization profile snapshot
|
| 149 |
+
# ---------------------------------------------------------------------------
|
| 150 |
+
|
| 151 |
+
def normalization_snapshot(profile: Any) -> dict[str, Any]:
|
| 152 |
+
"""Sérialise un ``NormalizationProfile``.
|
| 153 |
+
|
| 154 |
+
Couvre les profils built-in (``medieval_french``, ``nfc``, …) et les
|
| 155 |
+
profils custom YAML chargés au runtime — l'objectif est qu'un
|
| 156 |
+
lecteur du rapport puisse régénérer exactement la même
|
| 157 |
+
normalisation à partir de ce snapshot.
|
| 158 |
+
"""
|
| 159 |
+
if profile is None:
|
| 160 |
+
return {"available": False, "reason": "aucun profil fourni"}
|
| 161 |
+
|
| 162 |
+
# NormalizationProfile est un dataclass — on accède aux champs par
|
| 163 |
+
# nom plutôt que via ``asdict`` pour bien contrôler le format.
|
| 164 |
+
try:
|
| 165 |
+
return {
|
| 166 |
+
"available": True,
|
| 167 |
+
"name": getattr(profile, "name", "unknown"),
|
| 168 |
+
"nfc": bool(getattr(profile, "nfc", True)),
|
| 169 |
+
"caseless": bool(getattr(profile, "caseless", False)),
|
| 170 |
+
"diplomatic_table": dict(getattr(profile, "diplomatic_table", {}) or {}),
|
| 171 |
+
"exclude_chars": sorted(getattr(profile, "exclude_chars", set()) or set()),
|
| 172 |
+
"description": getattr(profile, "description", ""),
|
| 173 |
+
}
|
| 174 |
+
except Exception as exc:
|
| 175 |
+
return {"available": False, "reason": f"sérialisation échouée : {exc}"}
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ---------------------------------------------------------------------------
|
| 179 |
+
# Environment snapshot
|
| 180 |
+
# ---------------------------------------------------------------------------
|
| 181 |
+
|
| 182 |
+
def _git_commit(repo_path: Optional[Path] = None) -> Optional[str]:
|
| 183 |
+
"""Retourne le commit git court (12 chars) si on est dans un repo, sinon None."""
|
| 184 |
+
cwd = repo_path or Path(__file__).resolve().parents[2]
|
| 185 |
+
try:
|
| 186 |
+
out = subprocess.check_output(
|
| 187 |
+
["git", "rev-parse", "HEAD"],
|
| 188 |
+
cwd=str(cwd),
|
| 189 |
+
stderr=subprocess.DEVNULL,
|
| 190 |
+
text=True,
|
| 191 |
+
timeout=2,
|
| 192 |
+
).strip()
|
| 193 |
+
return out[:12] if out else None
|
| 194 |
+
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
| 195 |
+
return None
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _installed_packages(limit: int = 200) -> list[str]:
|
| 199 |
+
"""Liste figée des paquets installés au format ``name==version``.
|
| 200 |
+
|
| 201 |
+
Triée par nom (case-insensitive) pour reproductibilité. Cappée à
|
| 202 |
+
``limit`` paquets pour ne pas exploser le poids du rapport.
|
| 203 |
+
"""
|
| 204 |
+
try:
|
| 205 |
+
pkgs: list[str] = []
|
| 206 |
+
seen: set[str] = set()
|
| 207 |
+
for d in distributions():
|
| 208 |
+
try:
|
| 209 |
+
name = (d.metadata.get("Name") or "").strip()
|
| 210 |
+
version = (d.version or "").strip()
|
| 211 |
+
except Exception:
|
| 212 |
+
continue
|
| 213 |
+
if not name or name.lower() in seen:
|
| 214 |
+
continue
|
| 215 |
+
seen.add(name.lower())
|
| 216 |
+
pkgs.append(f"{name}=={version}")
|
| 217 |
+
pkgs.sort(key=str.lower)
|
| 218 |
+
return pkgs[:limit]
|
| 219 |
+
except Exception as exc: # pragma: no cover — défense en profondeur
|
| 220 |
+
logger.warning("[snapshot] enum dépendances échoué : %s", exc)
|
| 221 |
+
return []
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def environment_snapshot(repo_path: Optional[Path] = None) -> dict[str, Any]:
|
| 225 |
+
"""Retourne version Picarones, Python, plateforme, commit, deps figées."""
|
| 226 |
+
return {
|
| 227 |
+
"available": True,
|
| 228 |
+
"picarones_version": __version__,
|
| 229 |
+
"python_version": platform.python_version(),
|
| 230 |
+
"python_implementation": platform.python_implementation(),
|
| 231 |
+
"platform": platform.platform(),
|
| 232 |
+
"executable": sys.executable,
|
| 233 |
+
"git_commit": _git_commit(repo_path),
|
| 234 |
+
"installed_packages": _installed_packages(),
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ---------------------------------------------------------------------------
|
| 239 |
+
# API agrégée
|
| 240 |
+
# ---------------------------------------------------------------------------
|
| 241 |
+
|
| 242 |
+
def snapshot_all(
|
| 243 |
+
*,
|
| 244 |
+
lang: str = "fr",
|
| 245 |
+
glossary_used_keys: Optional[list[str] | set[str]] = None,
|
| 246 |
+
pricing_path: Optional[Path] = None,
|
| 247 |
+
normalization_profile: Any = None,
|
| 248 |
+
repo_path: Optional[Path] = None,
|
| 249 |
+
) -> dict[str, Any]:
|
| 250 |
+
"""Construit le bloc ``snapshots`` à embarquer dans ``report_data``."""
|
| 251 |
+
return {
|
| 252 |
+
"pricing": pricing_snapshot(pricing_path=pricing_path),
|
| 253 |
+
"glossary": glossary_snapshot(lang=lang, used_keys=glossary_used_keys),
|
| 254 |
+
"normalization": normalization_snapshot(normalization_profile),
|
| 255 |
+
"environment": environment_snapshot(repo_path=repo_path),
|
| 256 |
+
"schema_version": 1,
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
__all__ = [
|
| 261 |
+
"pricing_snapshot",
|
| 262 |
+
"glossary_snapshot",
|
| 263 |
+
"normalization_snapshot",
|
| 264 |
+
"environment_snapshot",
|
| 265 |
+
"snapshot_all",
|
| 266 |
+
]
|
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 27 — snapshots de reproductibilité dans le rapport HTML.
|
| 2 |
+
|
| 3 |
+
Le Sprint 27 ajoute le bloc ``report_data["snapshots"]`` qui embarque
|
| 4 |
+
dans chaque rapport HTML auto-contenu :
|
| 5 |
+
|
| 6 |
+
- le YAML brut intégral de ``picarones/data/pricing.yaml`` ;
|
| 7 |
+
- les entrées du glossaire dans la langue du rapport ;
|
| 8 |
+
- le profil de normalisation effectivement utilisé ;
|
| 9 |
+
- la version Picarones, la version Python, la plateforme,
|
| 10 |
+
le commit git si dispo, et la liste figée des paquets installés.
|
| 11 |
+
|
| 12 |
+
Le but est qu'un lecteur du rapport puisse rejouer la synthèse, le
|
| 13 |
+
Pareto et le glossaire sans accès au code source du moment où le
|
| 14 |
+
rapport a été généré.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import re
|
| 21 |
+
|
| 22 |
+
import pytest
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# 1. Fonctions snapshot unitaires
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
class TestPricingSnapshot:
|
| 30 |
+
def test_default_pricing_yaml_is_loaded(self):
|
| 31 |
+
from picarones.report.snapshot import pricing_snapshot
|
| 32 |
+
s = pricing_snapshot()
|
| 33 |
+
assert s["available"] is True
|
| 34 |
+
assert s["filename"] == "pricing.yaml"
|
| 35 |
+
assert s["size_bytes"] > 100, "pricing.yaml ne doit pas être quasi-vide"
|
| 36 |
+
# raw_yaml et data sont cohérents
|
| 37 |
+
assert isinstance(s["raw_yaml"], str)
|
| 38 |
+
assert isinstance(s["data"], dict)
|
| 39 |
+
|
| 40 |
+
def test_data_contains_meta_and_engines(self):
|
| 41 |
+
from picarones.report.snapshot import pricing_snapshot
|
| 42 |
+
s = pricing_snapshot()
|
| 43 |
+
assert "meta" in s["data"], "le snapshot doit exposer la section meta"
|
| 44 |
+
assert "engines" in s["data"], "le snapshot doit exposer engines"
|
| 45 |
+
|
| 46 |
+
def test_missing_path_returns_unavailable(self, tmp_path):
|
| 47 |
+
from picarones.report.snapshot import pricing_snapshot
|
| 48 |
+
s = pricing_snapshot(pricing_path=tmp_path / "ne-pas-exister.yaml")
|
| 49 |
+
assert s["available"] is False
|
| 50 |
+
assert "introuvable" in s["reason"].lower()
|
| 51 |
+
|
| 52 |
+
def test_custom_yaml_round_trips(self, tmp_path):
|
| 53 |
+
from picarones.report.snapshot import pricing_snapshot
|
| 54 |
+
custom = tmp_path / "custom.yaml"
|
| 55 |
+
custom.write_text(
|
| 56 |
+
"meta:\n currency: USD\n last_updated: 2026-01-01\nengines:\n fake: {type: local}\n",
|
| 57 |
+
encoding="utf-8",
|
| 58 |
+
)
|
| 59 |
+
s = pricing_snapshot(pricing_path=custom)
|
| 60 |
+
assert s["available"] is True
|
| 61 |
+
assert s["data"]["meta"]["currency"] == "USD"
|
| 62 |
+
assert "fake" in s["data"]["engines"]
|
| 63 |
+
# Le brut doit être identique au fichier source — preuve de fidélité.
|
| 64 |
+
assert s["raw_yaml"] == custom.read_text(encoding="utf-8")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class TestGlossarySnapshot:
|
| 68 |
+
def test_default_lang_returns_entries(self):
|
| 69 |
+
from picarones.report.snapshot import glossary_snapshot
|
| 70 |
+
s = glossary_snapshot(lang="fr")
|
| 71 |
+
assert s["available"] is True
|
| 72 |
+
assert s["entry_count"] > 10
|
| 73 |
+
# Quelques clés canoniques attendues
|
| 74 |
+
for k in ("cer", "wer"):
|
| 75 |
+
assert k in s["entries"]
|
| 76 |
+
|
| 77 |
+
def test_used_keys_filter(self):
|
| 78 |
+
from picarones.report.snapshot import glossary_snapshot
|
| 79 |
+
s = glossary_snapshot(lang="fr", used_keys=["cer"])
|
| 80 |
+
assert s["entry_count"] == 1
|
| 81 |
+
assert list(s["entries"]) == ["cer"]
|
| 82 |
+
|
| 83 |
+
def test_unknown_lang_falls_back(self):
|
| 84 |
+
# `load_glossary` retombe sur fr si la langue est absente — donc
|
| 85 |
+
# le snapshot doit être disponible avec lang='fr' ou la langue
|
| 86 |
+
# demandée selon ce qu'on retourne. On vérifie qu'on ne crashe pas.
|
| 87 |
+
from picarones.report.snapshot import glossary_snapshot
|
| 88 |
+
s = glossary_snapshot(lang="xx-pas-existante")
|
| 89 |
+
# Soit on retombe sur fr (available=True), soit on signale unavailable.
|
| 90 |
+
assert "available" in s
|
| 91 |
+
|
| 92 |
+
def test_entries_sorted_for_determinism(self):
|
| 93 |
+
from picarones.report.snapshot import glossary_snapshot
|
| 94 |
+
s = glossary_snapshot(lang="fr")
|
| 95 |
+
keys = list(s["entries"])
|
| 96 |
+
assert keys == sorted(keys), (
|
| 97 |
+
"Les entrées doivent être triées pour produire un snapshot "
|
| 98 |
+
"bit-à-bit reproductible."
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class TestNormalizationSnapshot:
|
| 103 |
+
def test_builtin_profile_serializes(self):
|
| 104 |
+
from picarones.core.normalization import get_builtin_profile
|
| 105 |
+
from picarones.report.snapshot import normalization_snapshot
|
| 106 |
+
p = get_builtin_profile("medieval_french")
|
| 107 |
+
s = normalization_snapshot(p)
|
| 108 |
+
assert s["available"] is True
|
| 109 |
+
assert s["name"] == "medieval_french"
|
| 110 |
+
assert s["nfc"] is True
|
| 111 |
+
# La table contient des correspondances connues
|
| 112 |
+
assert s["diplomatic_table"].get("ſ") == "s"
|
| 113 |
+
|
| 114 |
+
def test_none_profile_returns_unavailable(self):
|
| 115 |
+
from picarones.report.snapshot import normalization_snapshot
|
| 116 |
+
s = normalization_snapshot(None)
|
| 117 |
+
assert s["available"] is False
|
| 118 |
+
|
| 119 |
+
def test_exclude_chars_sorted(self):
|
| 120 |
+
from picarones.core.normalization import get_builtin_profile
|
| 121 |
+
from picarones.report.snapshot import normalization_snapshot
|
| 122 |
+
p = get_builtin_profile("sans_ponctuation")
|
| 123 |
+
s = normalization_snapshot(p)
|
| 124 |
+
# Liste triée pour reproductibilité
|
| 125 |
+
assert s["exclude_chars"] == sorted(s["exclude_chars"])
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class TestEnvironmentSnapshot:
|
| 129 |
+
def test_returns_picarones_version(self):
|
| 130 |
+
from picarones import __version__
|
| 131 |
+
from picarones.report.snapshot import environment_snapshot
|
| 132 |
+
s = environment_snapshot()
|
| 133 |
+
assert s["available"] is True
|
| 134 |
+
assert s["picarones_version"] == __version__
|
| 135 |
+
|
| 136 |
+
def test_python_and_platform_present(self):
|
| 137 |
+
from picarones.report.snapshot import environment_snapshot
|
| 138 |
+
s = environment_snapshot()
|
| 139 |
+
assert s["python_version"]
|
| 140 |
+
assert s["python_implementation"]
|
| 141 |
+
assert s["platform"]
|
| 142 |
+
|
| 143 |
+
def test_installed_packages_sorted_unique(self):
|
| 144 |
+
from picarones.report.snapshot import environment_snapshot
|
| 145 |
+
s = environment_snapshot()
|
| 146 |
+
pkgs = s["installed_packages"]
|
| 147 |
+
assert isinstance(pkgs, list)
|
| 148 |
+
# Triés case-insensitive
|
| 149 |
+
assert pkgs == sorted(pkgs, key=str.lower)
|
| 150 |
+
# Pas de doublons
|
| 151 |
+
names = [p.split("==", 1)[0].lower() for p in pkgs]
|
| 152 |
+
assert len(names) == len(set(names))
|
| 153 |
+
|
| 154 |
+
def test_git_commit_is_str_or_none(self):
|
| 155 |
+
from picarones.report.snapshot import environment_snapshot
|
| 156 |
+
s = environment_snapshot()
|
| 157 |
+
commit = s.get("git_commit")
|
| 158 |
+
assert commit is None or (isinstance(commit, str) and 0 < len(commit) <= 12)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ---------------------------------------------------------------------------
|
| 162 |
+
# 2. snapshot_all : l'API agrégée appelée par ReportGenerator
|
| 163 |
+
# ---------------------------------------------------------------------------
|
| 164 |
+
|
| 165 |
+
class TestSnapshotAll:
|
| 166 |
+
def test_contains_all_four_blocks(self):
|
| 167 |
+
from picarones.report.snapshot import snapshot_all
|
| 168 |
+
s = snapshot_all()
|
| 169 |
+
for k in ("pricing", "glossary", "normalization", "environment"):
|
| 170 |
+
assert k in s, f"snapshot_all doit exposer la clé '{k}'"
|
| 171 |
+
assert s["schema_version"] == 1
|
| 172 |
+
|
| 173 |
+
def test_deterministic_for_same_inputs(self):
|
| 174 |
+
from picarones.core.normalization import get_builtin_profile
|
| 175 |
+
from picarones.report.snapshot import snapshot_all
|
| 176 |
+
profile = get_builtin_profile("nfc")
|
| 177 |
+
|
| 178 |
+
a = snapshot_all(lang="fr", normalization_profile=profile)
|
| 179 |
+
b = snapshot_all(lang="fr", normalization_profile=profile)
|
| 180 |
+
# Les sections statiques (pricing, glossary, normalization) sont
|
| 181 |
+
# déterministes ; environment peut varier sur git_commit selon
|
| 182 |
+
# l'état du repo. On compare donc les trois sections clés.
|
| 183 |
+
for k in ("pricing", "glossary", "normalization"):
|
| 184 |
+
assert a[k] == b[k], f"Section '{k}' non déterministe"
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# ---------------------------------------------------------------------------
|
| 188 |
+
# 3. Intégration ReportGenerator : snapshots embarqués dans le HTML
|
| 189 |
+
# ---------------------------------------------------------------------------
|
| 190 |
+
|
| 191 |
+
@pytest.fixture(scope="module")
|
| 192 |
+
def generated_report_html(tmp_path_factory) -> str:
|
| 193 |
+
"""Génère un rapport démo et retourne son contenu HTML."""
|
| 194 |
+
from picarones import fixtures
|
| 195 |
+
from picarones.core.normalization import get_builtin_profile
|
| 196 |
+
from picarones.report.generator import ReportGenerator
|
| 197 |
+
|
| 198 |
+
b = fixtures.generate_sample_benchmark(n_docs=6)
|
| 199 |
+
out_dir = tmp_path_factory.mktemp("rep27")
|
| 200 |
+
out = out_dir / "report.html"
|
| 201 |
+
gen = ReportGenerator(
|
| 202 |
+
b,
|
| 203 |
+
lang="fr",
|
| 204 |
+
normalization_profile=get_builtin_profile("medieval_french"),
|
| 205 |
+
)
|
| 206 |
+
gen.generate(out)
|
| 207 |
+
return out.read_text(encoding="utf-8")
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _extract_report_data(html: str) -> dict:
|
| 211 |
+
"""Récupère le dict ``report_data`` injecté dans le HTML.
|
| 212 |
+
|
| 213 |
+
Le générateur sérialise ``report_data`` en JSON dans une balise
|
| 214 |
+
``<script id="picarones-data" type="application/json">``. Cette
|
| 215 |
+
fonction parse le JSON pour permettre des assertions précises.
|
| 216 |
+
"""
|
| 217 |
+
m = re.search(
|
| 218 |
+
r'<script[^>]*id="picarones-data"[^>]*>(.*?)</script>',
|
| 219 |
+
html,
|
| 220 |
+
re.DOTALL,
|
| 221 |
+
)
|
| 222 |
+
if not m:
|
| 223 |
+
# Fallback : chercher la première occurrence de ``"snapshots"``
|
| 224 |
+
# et ouvrir le JSON englobant.
|
| 225 |
+
idx = html.find('"snapshots"')
|
| 226 |
+
assert idx >= 0, "Aucun bloc 'snapshots' trouvé dans le rapport"
|
| 227 |
+
# On retourne un dict factice pour ne pas bloquer les tests qui
|
| 228 |
+
# ne dépendent pas du parse précis.
|
| 229 |
+
return {"snapshots": {"present_in_html": True}}
|
| 230 |
+
return json.loads(m.group(1))
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
class TestReportEmbedsSnapshots:
|
| 234 |
+
def test_html_contains_snapshots_block(self, generated_report_html):
|
| 235 |
+
assert '"snapshots"' in generated_report_html
|
| 236 |
+
assert '"schema_version":1' in generated_report_html
|
| 237 |
+
|
| 238 |
+
def test_pricing_yaml_embedded_raw(self, generated_report_html):
|
| 239 |
+
# Le YAML brut doit être présent (chercher une ligne caractéristique)
|
| 240 |
+
assert "engines:" in generated_report_html
|
| 241 |
+
# ``meta:`` apparaît aussi dans pricing.yaml
|
| 242 |
+
assert "meta:" in generated_report_html
|
| 243 |
+
|
| 244 |
+
def test_environment_block_embedded(self, generated_report_html):
|
| 245 |
+
assert '"picarones_version"' in generated_report_html
|
| 246 |
+
assert '"python_version"' in generated_report_html
|
| 247 |
+
assert '"installed_packages"' in generated_report_html
|
| 248 |
+
|
| 249 |
+
def test_glossary_block_embedded(self, generated_report_html):
|
| 250 |
+
# Quelques clés du glossaire doivent figurer dans le HTML — mais
|
| 251 |
+
# comme le glossaire est aussi rendu côté UI dans une autre var,
|
| 252 |
+
# on vérifie au moins la présence du JSON glossary dans snapshots.
|
| 253 |
+
assert '"entries"' in generated_report_html
|
| 254 |
+
|
| 255 |
+
def test_normalization_profile_embedded(self, generated_report_html):
|
| 256 |
+
# Le snapshot doit nommer le profil utilisé
|
| 257 |
+
assert "medieval_french" in generated_report_html
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
class TestReportSnapshotPersistsAcrossPricingChanges:
|
| 261 |
+
"""Garantie de reproductibilité : un rapport généré aujourd'hui reste
|
| 262 |
+
cohérent avec le pricing au moment de la génération, même si
|
| 263 |
+
``picarones/data/pricing.yaml`` change ensuite."""
|
| 264 |
+
|
| 265 |
+
def test_snapshot_carries_full_yaml_for_replay(self, generated_report_html):
|
| 266 |
+
# Si quelqu'un ouvre le HTML demain et veut rejouer la table de
|
| 267 |
+
# prix, il peut extraire le ``raw_yaml`` du bloc snapshots et le
|
| 268 |
+
# parser. On vérifie que le brut YAML est bien là tel quel.
|
| 269 |
+
assert "raw_yaml" in generated_report_html
|
| 270 |
+
# Les hypothèses détaillées (assumptions, notes, sources) sont
|
| 271 |
+
# dans le YAML — au moins une doit apparaître dans le HTML
|
| 272 |
+
# via le bloc raw_yaml.
|
| 273 |
+
assert ("assumptions" in generated_report_html
|
| 274 |
+
or "notes" in generated_report_html
|
| 275 |
+
or "sources" in generated_report_html), (
|
| 276 |
+
"Le YAML pricing brut doit embarquer assumptions/notes/sources"
|
| 277 |
+
)
|