Claude commited on
Commit
43478ec
·
unverified ·
1 Parent(s): f8a5c40

feat(sprint-S8): cohérence finale — renames test dirs, /metrics endpoint, SBOM workflow

Browse files

Sprint S8 — cohérence finale avant la release v2.0.

S8.1 — Renames de répertoires de tests
--------------------------------------

5 répertoires aux noms hérités de la pré-rewrite renommés vers
leurs cibles canoniques :

- ``tests/core/`` → ``tests/evaluation/`` (10 fichiers)
- ``tests/measurements/`` → ``tests/evaluation/metrics/`` (41 fichiers)
- ``tests/extras/`` → ``tests/adapters/corpus/`` (1 fichier)
- ``tests/report/`` → ``tests/reports/`` (33 fichiers)
- ``tests/reports_v2/`` → ``tests/reports/`` (2 fichiers, fusion)

Aucun conflit de noms (vérifié avant). Les imports inter-tests
(``tests.measurements._helpers``,
``tests.measurements.test_sprint19_narrative_engine``) ont été
mis à jour vers ``tests.evaluation.metrics.*``.

4 fichiers (déplacés vers ``tests/evaluation/metrics/``) avaient
des résolutions de chemin ``Path(__file__).parent.parent.parent``
qui pointaient sur le repo root quand ils étaient à
``tests/measurements/`` mais pointent un niveau trop court depuis
``tests/evaluation/metrics/``. Patch automatique : +1 ``.parent``.

S8.2 — Endpoint /metrics Prometheus
-----------------------------------

``picarones/interfaces/web/routers/system.py`` ajoute un endpoint
``GET /metrics`` au format Prometheus exposition.

Désactivé par défaut → 404. Activable via
``PICARONES_METRICS_ENABLED=1`` (insensible à la casse, accepte
aussi ``true``/``yes``).

Métriques exposées :

- ``picarones_app_info{version="X.Y.Z"}`` (gauge=1)
- ``picarones_jobs_total{status="<status>"}`` (gauge par statut,
6 statuts inclus à 0 si absents — alerts Prometheus simples)
- ``picarones_jobs_pending`` + ``picarones_jobs_running`` (alias
directs pour les statuts opérationnels les plus surveillés)

Pas de dépendance ``prometheus_client`` (rester léger). Tolérance
si le ``JobStore`` est indisponible : warning loggé + payload réduit
à ``app_info`` (le service reste vivant pour l'orchestrateur).

Tests (``tests/architecture/test_s8_metrics_endpoint.py``, 13 tests) :

- ``TestMetricsDisabledByDefault`` (1) — 404 sans env var.
- ``TestMetricsFormat`` (4) — content-type, app_info, jobs_total
par statut, alias gauges.
- ``TestEnvVarParsing`` (8 paramétrés) — accepte
``1``/``true``/``yes`` ; refuse ``0``/``false``/``no``/``""``/``off``.

S8.3 — Workflow SBOM CycloneDX
------------------------------

``.github/workflows/sbom.yml`` (NEW) génère un SBOM au format
CycloneDX JSON sur :

- chaque push ``main``,
- chaque tag ``v*``,
- chaque PR sur ``main`` (informationnel),
- workflow_dispatch manuel.

Outils : ``cyclonedx-bom>=4.0,<6.0`` (introspection du venv via
``cyclonedx-py environment``).

Artefacts :
- Upload via ``actions/upload-artifact`` (90 jours de rétention).
- Sur tag : attaché à la GitHub Release via
``softprops/action-gh-release``.

Sanity check inline : assert que ``components.length > 0`` (sinon
le SBOM est vide → cyclonedx-py n'a pas vu le venv).

Pourquoi : une institution publique (BnF, université, archive
nationale) doit pouvoir auditer la chaîne d'approvisionnement
logicielle. Le SBOM CycloneDX est ingéré par Dependency-Track,
Snyk, et tout scanner SBOM standard.

Tests
-----

- ``pytest tests/`` : 4382 passed (+13 vs S7), 9 skipped, 8
deselected (-16 vs S6 grâce au retrait du marker ``regression``
en S7), 2 xfailed.
- ``ruff check`` : All checks passed.
- 4 régressions chemins corrigées dans les tests déplacés.

Sprint S8 — bilan
-----------------

| Cible | Avant | Après |
|---|---|---|
| Test dirs avec noms legacy | 5 (core, measurements, extras, report, reports_v2) | 0 |
| Endpoint observability | aucun | ``/metrics`` Prometheus opt-in |
| SBOM en CI | absent | CycloneDX généré sur push/tag/PR |

Reste pour la release v2.0
--------------------------

S9 : tag v2.0.0 — décision manuelle de l'utilisateur.

https://claude.ai/code/session_01NxyVKqg2SowXLZdM4H1ZDE

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .github/workflows/sbom.yml +99 -0
  2. CLAUDE.md +2 -2
  3. README.md +2 -1
  4. picarones/interfaces/web/routers/system.py +103 -0
  5. tests/{extras → adapters/corpus}/test_sprint8_escriptorium_gallica.py +0 -0
  6. tests/architecture/test_s8_metrics_endpoint.py +140 -0
  7. tests/core/__init__.py +0 -0
  8. tests/{measurements → evaluation/metrics}/_helpers.py +0 -0
  9. tests/{measurements → evaluation/metrics}/test_char_scores.py +0 -0
  10. tests/{measurements → evaluation/metrics}/test_metrics.py +0 -0
  11. tests/{measurements → evaluation/metrics}/test_pricing_degenerate_cases.py +0 -0
  12. tests/{measurements → evaluation/metrics}/test_results.py +0 -0
  13. tests/{measurements → evaluation/metrics}/test_sprint10_error_distribution.py +0 -0
  14. tests/{measurements → evaluation/metrics}/test_sprint12_nouvelles_fonctionnalites.py +0 -0
  15. tests/{measurements → evaluation/metrics}/test_sprint15_llm_pipeline_bugs.py +0 -0
  16. tests/{measurements → evaluation/metrics}/test_sprint16_narrative_foundations.py +0 -0
  17. tests/{measurements → evaluation/metrics}/test_sprint18_friedman_nemenyi_cdd.py +0 -0
  18. tests/{measurements → evaluation/metrics}/test_sprint19_narrative_engine.py +1 -1
  19. tests/{measurements → evaluation/metrics}/test_sprint20_pareto_pricing.py +0 -0
  20. tests/{measurements → evaluation/metrics}/test_sprint23_anti_hallucination.py +2 -2
  21. tests/{measurements → evaluation/metrics}/test_sprint29_detector_registry.py +0 -0
  22. tests/{measurements → evaluation/metrics}/test_sprint35_inter_engine.py +0 -0
  23. tests/{measurements → evaluation/metrics}/test_sprint36_ensemble_narrative.py +1 -1
  24. tests/{measurements → evaluation/metrics}/test_sprint38_ner_metrics.py +0 -0
  25. tests/{measurements → evaluation/metrics}/test_sprint39_calibration.py +0 -0
  26. tests/{measurements → evaluation/metrics}/test_sprint44_median_default.py +0 -0
  27. tests/{measurements → evaluation/metrics}/test_sprint45_stratification.py +0 -0
  28. tests/{measurements → evaluation/metrics}/test_sprint52_readability.py +0 -0
  29. tests/{measurements → evaluation/metrics}/test_sprint53_reading_order.py +0 -0
  30. tests/{measurements → evaluation/metrics}/test_sprint54_layout.py +0 -0
  31. tests/{measurements → evaluation/metrics}/test_sprint55_unicode_blocks.py +0 -0
  32. tests/{measurements → evaluation/metrics}/test_sprint56_abbreviations.py +0 -0
  33. tests/{measurements → evaluation/metrics}/test_sprint57_mufi.py +0 -0
  34. tests/{measurements → evaluation/metrics}/test_sprint58_early_modern.py +0 -0
  35. tests/{measurements → evaluation/metrics}/test_sprint59_modern_archives.py +0 -0
  36. tests/{measurements → evaluation/metrics}/test_sprint60_roman_numerals.py +0 -0
  37. tests/{measurements → evaluation/metrics}/test_sprint71_rare_tokens.py +0 -0
  38. tests/{measurements → evaluation/metrics}/test_sprint73_baseline_comparison.py +0 -0
  39. tests/{measurements → evaluation/metrics}/test_sprint78_equivalence_profile.py +0 -0
  40. tests/{measurements → evaluation/metrics}/test_sprint79_cost_projection.py +0 -0
  41. tests/{measurements → evaluation/metrics}/test_sprint81_robustness_projection.py +0 -0
  42. tests/{measurements → evaluation/metrics}/test_sprint83_reliability.py +0 -0
  43. tests/{measurements → evaluation/metrics}/test_sprint84_searchability.py +0 -0
  44. tests/{measurements → evaluation/metrics}/test_sprint85_numerical_sequences.py +0 -0
  45. tests/{measurements → evaluation/metrics}/test_sprint8_longitudinal_robustness.py +0 -0
  46. tests/{measurements → evaluation/metrics}/test_sprint93_image_predictive.py +1 -1
  47. tests/{measurements → evaluation/metrics}/test_sprint96_incremental_comparison.py +1 -1
  48. tests/{measurements → evaluation/metrics}/test_sprint97_module_policy.py +3 -3
  49. tests/{measurements → evaluation/metrics}/test_sprint_a14_s1_normalization_propagation.py +0 -0
  50. tests/{core → evaluation}/test_corpus.py +0 -0
.github/workflows/sbom.yml ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sprint S8.3 — Software Bill of Materials au format CycloneDX.
2
+ #
3
+ # Pourquoi
4
+ # --------
5
+ # Une institution publique (BnF, université, archive nationale) qui
6
+ # déploie Picarones doit pouvoir auditer la chaîne d'approvisionnement
7
+ # logicielle :
8
+ #
9
+ # - Quelles dépendances sont utilisées ?
10
+ # - Quelles versions exactes ?
11
+ # - Quelles licences ?
12
+ # - Y a-t-il des CVE connues sur ces deps ?
13
+ #
14
+ # CycloneDX est le standard SBOM piloté par OWASP. L'artefact JSON
15
+ # produit ici peut être ingéré par Dependency-Track, Snyk, ou tout
16
+ # scanner SBOM standard.
17
+ #
18
+ # Stratégie
19
+ # ---------
20
+ # - Sur chaque push main + sur chaque tag → génération SBOM.
21
+ # - Sur chaque PR → génération + diff vs main (informationnel).
22
+ # - Artefact uploadé en pièce jointe du workflow ; sur tag, attaché
23
+ # à la GitHub Release.
24
+
25
+ name: SBOM (CycloneDX)
26
+
27
+ on:
28
+ push:
29
+ branches: [main]
30
+ tags: ["v*"]
31
+ pull_request:
32
+ branches: [main]
33
+ workflow_dispatch:
34
+
35
+ permissions:
36
+ contents: read
37
+
38
+ jobs:
39
+ sbom:
40
+ name: Generate CycloneDX SBOM
41
+ runs-on: ubuntu-latest
42
+
43
+ steps:
44
+ - name: Checkout
45
+ uses: actions/checkout@v4
46
+
47
+ - name: Set up Python 3.11
48
+ uses: actions/setup-python@v5
49
+ with:
50
+ python-version: "3.11"
51
+ cache: pip
52
+
53
+ - name: Install Picarones (runtime extras)
54
+ run: |
55
+ python -m pip install --upgrade pip
56
+ # Installation pour figer les versions résolues dans
57
+ # l'environnement. ``[dev,web]`` couvre la surface
58
+ # utilisée par la CI ; les extras LLM/OCR cloud ne sont
59
+ # pas inclus pour rester sous la taille raisonnable du
60
+ # SBOM (un déployeur institutionnel choisira ses extras).
61
+ pip install -e ".[dev,web]"
62
+
63
+ - name: Install cyclonedx-bom
64
+ run: pip install "cyclonedx-bom>=4.0,<6.0"
65
+
66
+ - name: Generate CycloneDX JSON SBOM
67
+ run: |
68
+ # ``cyclonedx-py environment`` introspecte le venv courant.
69
+ # ``--output-format JSON`` + ``--output-file`` produit un
70
+ # fichier nommé.
71
+ mkdir -p sbom-output
72
+ cyclonedx-py environment \
73
+ --output-format JSON \
74
+ --output-file sbom-output/picarones-sbom.cdx.json
75
+ echo "SBOM size : $(wc -c < sbom-output/picarones-sbom.cdx.json) bytes"
76
+ # Nombre de composants (sanity check).
77
+ python -c "
78
+ import json
79
+ with open('sbom-output/picarones-sbom.cdx.json') as f:
80
+ data = json.load(f)
81
+ n = len(data.get('components', []))
82
+ print(f'Components: {n}')
83
+ assert n > 0, 'SBOM vide — cyclonedx-py n a pas vu le venv'
84
+ "
85
+
86
+ - name: Upload SBOM artifact
87
+ uses: actions/upload-artifact@v4
88
+ with:
89
+ name: picarones-sbom-${{ github.sha }}
90
+ path: sbom-output/
91
+ retention-days: 90
92
+
93
+ - name: Attach to GitHub Release (on tag)
94
+ if: startsWith(github.ref, 'refs/tags/v')
95
+ uses: softprops/action-gh-release@v2
96
+ with:
97
+ files: sbom-output/picarones-sbom.cdx.json
98
+ env:
99
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CLAUDE.md CHANGED
@@ -116,7 +116,7 @@ picarones/
116
 
117
  ## État des tests et bugs historiques
118
 
119
- `pytest tests/` → **4380 passed, 12 skipped, 8 deselected, 0 failed**
120
  (post-S59). Les deselected sont les markers `live` (5 tests d'intégration
121
  contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
122
  opt-in en local via `pytest -m live` ou `pytest -m network`. Le
@@ -268,7 +268,7 @@ détecte, arbitre, rend.
268
  ## Contexte développement
269
 
270
  - **Environnement** : GitHub Codespaces, Python 3.11+
271
- - **Tests** : `pytest tests/ -q` → 4380 passed, 9 skipped, 24
272
  deselected, 0 failed (post-v2.0).
273
  - **Manifeste architecture** : [`docs/explanation/architecture.md`](docs/explanation/architecture.md).
274
  - **API publique stable** : [`docs/reference/api-stable.md`](docs/reference/api-stable.md).
 
116
 
117
  ## État des tests et bugs historiques
118
 
119
+ `pytest tests/` → **4400 passed, 12 skipped, 8 deselected, 0 failed**
120
  (post-S59). Les deselected sont les markers `live` (5 tests d'intégration
121
  contre vraie API/binaire) + `network` (3 tests qui hit le réseau réel),
122
  opt-in en local via `pytest -m live` ou `pytest -m network`. Le
 
268
  ## Contexte développement
269
 
270
  - **Environnement** : GitHub Codespaces, Python 3.11+
271
+ - **Tests** : `pytest tests/ -q` → 4400 passed, 9 skipped, 24
272
  deselected, 0 failed (post-v2.0).
273
  - **Manifeste architecture** : [`docs/explanation/architecture.md`](docs/explanation/architecture.md).
274
  - **API publique stable** : [`docs/reference/api-stable.md`](docs/reference/api-stable.md).
README.md CHANGED
@@ -285,6 +285,7 @@ when running. Summary:
285
  | `GET` | `/api/reports` | Api Reports |
286
  | `GET` | `/api/status` | Api Status |
287
  | `GET` | `/health` | Health |
 
288
  | `GET` | `/reports/{filename}` | Serve Report |
289
 
290
  <!-- /generated:endpoints -->
@@ -394,7 +395,7 @@ ruff check picarones/ tests/
394
  python -m mypy picarones/core/
395
  ```
396
 
397
- **Test suite**: ~4380 tests, ~3 min on a modern laptop. Coverage
398
  floor at 85% (currently ~87%). The `network` marker excludes tests
399
  requiring live HTTP. A handful of tests depend on optional engines
400
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
 
285
  | `GET` | `/api/reports` | Api Reports |
286
  | `GET` | `/api/status` | Api Status |
287
  | `GET` | `/health` | Health |
288
+ | `GET` | `/metrics` | Metrics Endpoint |
289
  | `GET` | `/reports/{filename}` | Serve Report |
290
 
291
  <!-- /generated:endpoints -->
 
395
  python -m mypy picarones/core/
396
  ```
397
 
398
+ **Test suite**: ~4400 tests, ~3 min on a modern laptop. Coverage
399
  floor at 85% (currently ~87%). The `network` marker excludes tests
400
  requiring live HTTP. A handful of tests depend on optional engines
401
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
picarones/interfaces/web/routers/system.py CHANGED
@@ -77,6 +77,109 @@ async def api_status() -> dict:
77
  }
78
 
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  @router.get("/api/lang")
81
  async def api_get_lang(picarones_lang: str = Cookie(default="fr")) -> dict:
82
  """Retourne la langue courante (lue depuis le cookie de session)."""
 
77
  }
78
 
79
 
80
+ # ──────────────────────────────────────────────────────────────────────
81
+ # Sprint S8.2 — Endpoint /metrics au format Prometheus exposition.
82
+ # Opt-in via PICARONES_METRICS_ENABLED=1. Désactivé par défaut pour
83
+ # ne pas exposer de surface publique en mode HuggingFace Space.
84
+ # ──────────────────────────────────────────────────────────────────────
85
+
86
+
87
+ def _metrics_enabled() -> bool:
88
+ import os
89
+ return os.environ.get("PICARONES_METRICS_ENABLED", "").strip() in (
90
+ "1", "true", "yes",
91
+ )
92
+
93
+
94
+ @router.get("/metrics")
95
+ async def metrics_endpoint() -> Response:
96
+ """Endpoint Prometheus exposition format (text/plain; version=0.0.4).
97
+
98
+ Désactivé par défaut. Activer via
99
+ ``PICARONES_METRICS_ENABLED=1``.
100
+
101
+ Métriques exposées :
102
+
103
+ - ``picarones_jobs_total{status="<status>"}`` — nombre de jobs
104
+ par statut (pending, running, complete, error, cancelled,
105
+ interrupted).
106
+ - ``picarones_jobs_pending`` — alias direct (gauge).
107
+ - ``picarones_jobs_running`` — alias direct (gauge).
108
+ - ``picarones_app_info{version="X.Y.Z"}`` — info statique = 1.
109
+
110
+ Format : lignes ``# HELP`` + ``# TYPE`` + samples conformes
111
+ à la spec Prometheus. Pas de dépendance ``prometheus_client``
112
+ pour rester léger ; un opérateur qui veut un client riche
113
+ peut greffer un middleware externe.
114
+ """
115
+ if not _metrics_enabled():
116
+ raise HTTPException(
117
+ status_code=404,
118
+ detail=(
119
+ "Metrics endpoint disabled. Activate via "
120
+ "PICARONES_METRICS_ENABLED=1."
121
+ ),
122
+ )
123
+
124
+ from picarones.interfaces.web import state
125
+
126
+ try:
127
+ records = state.JOB_STORE.list(limit=10000)
128
+ except Exception as exc: # noqa: BLE001
129
+ # Best-effort : si le store SQLite est indisponible, on
130
+ # expose quand même app_info pour que l'orchestrateur
131
+ # voie le service vivant.
132
+ import logging
133
+ logging.getLogger(__name__).warning(
134
+ "[metrics] JobStore inaccessible : %s", exc,
135
+ )
136
+ records = ()
137
+
138
+ counts: dict[str, int] = {}
139
+ for r in records:
140
+ counts[r.status] = counts.get(r.status, 0) + 1
141
+
142
+ known_statuses = (
143
+ "pending", "running", "complete", "error",
144
+ "cancelled", "interrupted",
145
+ )
146
+ for s in known_statuses:
147
+ counts.setdefault(s, 0)
148
+
149
+ lines: list[str] = []
150
+ lines.append("# HELP picarones_app_info Application info (always 1)")
151
+ lines.append("# TYPE picarones_app_info gauge")
152
+ lines.append(f'picarones_app_info{{version="{__version__}"}} 1')
153
+ lines.append("")
154
+
155
+ lines.append(
156
+ "# HELP picarones_jobs_total Total number of jobs by status"
157
+ )
158
+ lines.append("# TYPE picarones_jobs_total gauge")
159
+ for status, n in sorted(counts.items()):
160
+ lines.append(
161
+ f'picarones_jobs_total{{status="{status}"}} {n}'
162
+ )
163
+ lines.append("")
164
+
165
+ # Aliases directs pour les deux statuts opérationnels les plus
166
+ # surveillés (alerts Prometheus simples).
167
+ lines.append("# HELP picarones_jobs_pending Jobs pending")
168
+ lines.append("# TYPE picarones_jobs_pending gauge")
169
+ lines.append(f"picarones_jobs_pending {counts.get('pending', 0)}")
170
+ lines.append("")
171
+ lines.append("# HELP picarones_jobs_running Jobs running")
172
+ lines.append("# TYPE picarones_jobs_running gauge")
173
+ lines.append(f"picarones_jobs_running {counts.get('running', 0)}")
174
+ lines.append("")
175
+
176
+ body = "\n".join(lines) + "\n"
177
+ return Response(
178
+ content=body,
179
+ media_type="text/plain; version=0.0.4; charset=utf-8",
180
+ )
181
+
182
+
183
  @router.get("/api/lang")
184
  async def api_get_lang(picarones_lang: str = Cookie(default="fr")) -> dict:
185
  """Retourne la langue courante (lue depuis le cookie de session)."""
tests/{extras → adapters/corpus}/test_sprint8_escriptorium_gallica.py RENAMED
File without changes
tests/architecture/test_s8_metrics_endpoint.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint S8.2 — Endpoint Prometheus ``/metrics``.
2
+
3
+ Vérifie :
4
+
5
+ 1. Désactivé par défaut → 404.
6
+ 2. Activé via ``PICARONES_METRICS_ENABLED=1`` → 200 avec format
7
+ Prometheus exposition.
8
+ 3. Métriques attendues : ``picarones_app_info``,
9
+ ``picarones_jobs_total{status="..."}``, alias gauges.
10
+ 4. Tolérance store inaccessible : retourne ``picarones_app_info``
11
+ sans crasher.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import pytest
17
+
18
+
19
+ def _make_app():
20
+ from fastapi import FastAPI
21
+ from picarones.interfaces.web.routers import system as sys_router
22
+
23
+ app = FastAPI()
24
+ app.include_router(sys_router.router)
25
+ return app
26
+
27
+
28
+ # ──────────────────────────────────────────────────────────────────────
29
+ # 1. Désactivé par défaut
30
+ # ──────────────────────────────────────────────────────────────────────
31
+
32
+
33
+ class TestMetricsDisabledByDefault:
34
+ def test_404_when_env_not_set(
35
+ self, monkeypatch: pytest.MonkeyPatch,
36
+ ) -> None:
37
+ from fastapi.testclient import TestClient
38
+
39
+ monkeypatch.delenv("PICARONES_METRICS_ENABLED", raising=False)
40
+ app = _make_app()
41
+ with TestClient(app) as client:
42
+ r = client.get("/metrics")
43
+ assert r.status_code == 404
44
+ assert "PICARONES_METRICS_ENABLED" in r.text
45
+
46
+
47
+ # ──────────────────────────────────────────────────────────────────────
48
+ # 2. Format Prometheus quand activé
49
+ # ──────────────────────────────────────────────────────────────────────
50
+
51
+
52
+ class TestMetricsFormat:
53
+ def test_200_with_prometheus_content_type(
54
+ self, monkeypatch: pytest.MonkeyPatch,
55
+ ) -> None:
56
+ from fastapi.testclient import TestClient
57
+
58
+ monkeypatch.setenv("PICARONES_METRICS_ENABLED", "1")
59
+ app = _make_app()
60
+ with TestClient(app) as client:
61
+ r = client.get("/metrics")
62
+ assert r.status_code == 200
63
+ ct = r.headers.get("content-type", "")
64
+ assert "text/plain" in ct
65
+ assert "version=0.0.4" in ct
66
+
67
+ def test_exposes_app_info(
68
+ self, monkeypatch: pytest.MonkeyPatch,
69
+ ) -> None:
70
+ from fastapi.testclient import TestClient
71
+
72
+ monkeypatch.setenv("PICARONES_METRICS_ENABLED", "1")
73
+ app = _make_app()
74
+ with TestClient(app) as client:
75
+ r = client.get("/metrics")
76
+ text = r.text
77
+ assert "# TYPE picarones_app_info gauge" in text
78
+ assert 'picarones_app_info{version=' in text
79
+ assert text.rstrip().endswith("1") or "} 1" in text
80
+
81
+ def test_exposes_jobs_total_per_status(
82
+ self, monkeypatch: pytest.MonkeyPatch,
83
+ ) -> None:
84
+ from fastapi.testclient import TestClient
85
+
86
+ monkeypatch.setenv("PICARONES_METRICS_ENABLED", "1")
87
+ app = _make_app()
88
+ with TestClient(app) as client:
89
+ r = client.get("/metrics")
90
+ text = r.text
91
+ # Chaque statut connu apparaît, même à 0
92
+ for status in ("pending", "running", "complete", "error",
93
+ "cancelled", "interrupted"):
94
+ assert f'status="{status}"' in text, (
95
+ f"Statut ``{status}`` absent du payload"
96
+ )
97
+
98
+ def test_exposes_alias_gauges(
99
+ self, monkeypatch: pytest.MonkeyPatch,
100
+ ) -> None:
101
+ from fastapi.testclient import TestClient
102
+
103
+ monkeypatch.setenv("PICARONES_METRICS_ENABLED", "1")
104
+ app = _make_app()
105
+ with TestClient(app) as client:
106
+ r = client.get("/metrics")
107
+ text = r.text
108
+ assert "picarones_jobs_pending" in text
109
+ assert "picarones_jobs_running" in text
110
+
111
+
112
+ # ──────────────────────────────────────────────────────────────────────
113
+ # 3. Activation insensible casse / yes / true
114
+ # ──────────────────────────────────────────────────────────────────────
115
+
116
+
117
+ class TestEnvVarParsing:
118
+ @pytest.mark.parametrize("value", ["1", "true", "yes"])
119
+ def test_truthy_values_enable(
120
+ self, monkeypatch: pytest.MonkeyPatch, value: str,
121
+ ) -> None:
122
+ from fastapi.testclient import TestClient
123
+
124
+ monkeypatch.setenv("PICARONES_METRICS_ENABLED", value)
125
+ app = _make_app()
126
+ with TestClient(app) as client:
127
+ r = client.get("/metrics")
128
+ assert r.status_code == 200
129
+
130
+ @pytest.mark.parametrize("value", ["0", "false", "no", "", "off"])
131
+ def test_falsy_values_keep_disabled(
132
+ self, monkeypatch: pytest.MonkeyPatch, value: str,
133
+ ) -> None:
134
+ from fastapi.testclient import TestClient
135
+
136
+ monkeypatch.setenv("PICARONES_METRICS_ENABLED", value)
137
+ app = _make_app()
138
+ with TestClient(app) as client:
139
+ r = client.get("/metrics")
140
+ assert r.status_code == 404
tests/core/__init__.py DELETED
File without changes
tests/{measurements → evaluation/metrics}/_helpers.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_char_scores.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_metrics.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_pricing_degenerate_cases.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_results.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint10_error_distribution.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint12_nouvelles_fonctionnalites.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint15_llm_pipeline_bugs.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint16_narrative_foundations.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint18_friedman_nemenyi_cdd.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint19_narrative_engine.py RENAMED
@@ -456,7 +456,7 @@ class TestBuildSynthesisE2E:
456
  # ``_numbers_in_payload`` vit dans ``tests/measurements/_helpers.py`` ;
457
  # on le ré-expose sous son ancien nom privé pour compatibilité avec les
458
  # tests qui l'importent depuis ce module (ex. test_sprint23).
459
- from tests.measurements._helpers import numbers_in_payload as _numbers_in_payload # noqa: E402
460
 
461
 
462
  # Sprint 23 : whitelist vidée. Tout nombre rendu dans la synthèse doit
 
456
  # ``_numbers_in_payload`` vit dans ``tests/measurements/_helpers.py`` ;
457
  # on le ré-expose sous son ancien nom privé pour compatibilité avec les
458
  # tests qui l'importent depuis ce module (ex. test_sprint23).
459
+ from tests.evaluation.metrics._helpers import numbers_in_payload as _numbers_in_payload # noqa: E402
460
 
461
 
462
  # Sprint 23 : whitelist vidée. Tout nombre rendu dans la synthèse doit
tests/{measurements → evaluation/metrics}/test_sprint20_pareto_pricing.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint23_anti_hallucination.py RENAMED
@@ -40,7 +40,7 @@ from picarones.reports.narrative import (
40
  from picarones.reports.narrative.arbiter import DEFAULT_TYPE_ORDER
41
  from picarones.evaluation.statistics import bootstrap_ci
42
 
43
- ROOT = Path(__file__).parent.parent.parent
44
  TEMPLATES_DIR = ROOT / "picarones" / "reports" / "narrative" / "templates"
45
 
46
 
@@ -163,7 +163,7 @@ class TestEndToEndWithEmptyWhitelist:
163
  def test_every_number_traceable_with_empty_whitelist(self, lang):
164
  from picarones.reports.narrative import extract_numbers
165
 
166
- from tests.measurements.test_sprint19_narrative_engine import _numbers_in_payload
167
 
168
  result = build_synthesis(_full_data(), lang)
169
  allowed: set[str] = set()
 
40
  from picarones.reports.narrative.arbiter import DEFAULT_TYPE_ORDER
41
  from picarones.evaluation.statistics import bootstrap_ci
42
 
43
+ ROOT = Path(__file__).parent.parent.parent.parent
44
  TEMPLATES_DIR = ROOT / "picarones" / "reports" / "narrative" / "templates"
45
 
46
 
 
163
  def test_every_number_traceable_with_empty_whitelist(self, lang):
164
  from picarones.reports.narrative import extract_numbers
165
 
166
+ from tests.evaluation.metrics.test_sprint19_narrative_engine import _numbers_in_payload
167
 
168
  result = build_synthesis(_full_data(), lang)
169
  allowed: set[str] = set()
tests/{measurements → evaluation/metrics}/test_sprint29_detector_registry.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint35_inter_engine.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint36_ensemble_narrative.py RENAMED
@@ -264,7 +264,7 @@ class TestSynthesisIntegration:
264
  # ──────────────────────────────────────────────────────────────────────────
265
 
266
 
267
- from tests.measurements._helpers import numbers_in_payload as _numbers_in_payload # noqa: E402
268
 
269
 
270
  class TestTraceability:
 
264
  # ──────────────────────────────────────────────────────────────────────────
265
 
266
 
267
+ from tests.evaluation.metrics._helpers import numbers_in_payload as _numbers_in_payload # noqa: E402
268
 
269
 
270
  class TestTraceability:
tests/{measurements → evaluation/metrics}/test_sprint38_ner_metrics.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint39_calibration.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint44_median_default.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint45_stratification.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint52_readability.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint53_reading_order.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint54_layout.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint55_unicode_blocks.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint56_abbreviations.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint57_mufi.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint58_early_modern.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint59_modern_archives.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint60_roman_numerals.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint71_rare_tokens.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint73_baseline_comparison.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint78_equivalence_profile.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint79_cost_projection.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint81_robustness_projection.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint83_reliability.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint84_searchability.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint85_numerical_sequences.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint8_longitudinal_robustness.py RENAMED
File without changes
tests/{measurements → evaluation/metrics}/test_sprint93_image_predictive.py RENAMED
@@ -42,7 +42,7 @@ from picarones.reports.html.renderers.image_predictive import (
42
 
43
  def _load_labels(lang: str) -> dict:
44
  p = (
45
- Path(__file__).parent.parent.parent
46
  / "picarones" / "reports" / "i18n" / f"{lang}.json"
47
  )
48
  return json.loads(p.read_text(encoding="utf-8"))
 
42
 
43
  def _load_labels(lang: str) -> dict:
44
  p = (
45
+ Path(__file__).parent.parent.parent.parent
46
  / "picarones" / "reports" / "i18n" / f"{lang}.json"
47
  )
48
  return json.loads(p.read_text(encoding="utf-8"))
tests/{measurements → evaluation/metrics}/test_sprint96_incremental_comparison.py RENAMED
@@ -38,7 +38,7 @@ from picarones.reports.html.renderers.incremental_comparison import (
38
 
39
  def _load_labels(lang: str) -> dict:
40
  p = (
41
- Path(__file__).parent.parent.parent
42
  / "picarones" / "reports" / "i18n" / f"{lang}.json"
43
  )
44
  return json.loads(p.read_text(encoding="utf-8"))
 
38
 
39
  def _load_labels(lang: str) -> dict:
40
  p = (
41
+ Path(__file__).parent.parent.parent.parent
42
  / "picarones" / "reports" / "i18n" / f"{lang}.json"
43
  )
44
  return json.loads(p.read_text(encoding="utf-8"))
tests/{measurements → evaluation/metrics}/test_sprint97_module_policy.py RENAMED
@@ -44,7 +44,7 @@ from picarones.reports.html.renderers.module_audit import (
44
 
45
  def _load_labels(lang: str) -> dict:
46
  p = (
47
- Path(__file__).parent.parent.parent
48
  / "picarones" / "reports" / "i18n" / f"{lang}.json"
49
  )
50
  return json.loads(p.read_text(encoding="utf-8"))
@@ -260,7 +260,7 @@ class TestRender:
260
  class TestDocumentation:
261
  def test_docs_present(self) -> None:
262
  path = (
263
- Path(__file__).parent.parent.parent
264
  / "docs" / "developer" / "module-policy.md"
265
  )
266
  assert path.exists()
@@ -272,7 +272,7 @@ class TestDocumentation:
272
 
273
  def test_docs_lists_required_fields(self) -> None:
274
  path = (
275
- Path(__file__).parent.parent.parent
276
  / "docs" / "developer" / "module-policy.md"
277
  )
278
  text = path.read_text(encoding="utf-8")
 
44
 
45
  def _load_labels(lang: str) -> dict:
46
  p = (
47
+ Path(__file__).parent.parent.parent.parent
48
  / "picarones" / "reports" / "i18n" / f"{lang}.json"
49
  )
50
  return json.loads(p.read_text(encoding="utf-8"))
 
260
  class TestDocumentation:
261
  def test_docs_present(self) -> None:
262
  path = (
263
+ Path(__file__).parent.parent.parent.parent
264
  / "docs" / "developer" / "module-policy.md"
265
  )
266
  assert path.exists()
 
272
 
273
  def test_docs_lists_required_fields(self) -> None:
274
  path = (
275
+ Path(__file__).parent.parent.parent.parent
276
  / "docs" / "developer" / "module-policy.md"
277
  )
278
  text = path.read_text(encoding="utf-8")
tests/{measurements → evaluation/metrics}/test_sprint_a14_s1_normalization_propagation.py RENAMED
File without changes
tests/{core → evaluation}/test_corpus.py RENAMED
File without changes