Spaces:
Sleeping
feat(sprint-S8): cohérence finale — renames test dirs, /metrics endpoint, SBOM workflow
Browse filesSprint 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
- .github/workflows/sbom.yml +99 -0
- CLAUDE.md +2 -2
- README.md +2 -1
- picarones/interfaces/web/routers/system.py +103 -0
- tests/{extras → adapters/corpus}/test_sprint8_escriptorium_gallica.py +0 -0
- tests/architecture/test_s8_metrics_endpoint.py +140 -0
- tests/core/__init__.py +0 -0
- tests/{measurements → evaluation/metrics}/_helpers.py +0 -0
- tests/{measurements → evaluation/metrics}/test_char_scores.py +0 -0
- tests/{measurements → evaluation/metrics}/test_metrics.py +0 -0
- tests/{measurements → evaluation/metrics}/test_pricing_degenerate_cases.py +0 -0
- tests/{measurements → evaluation/metrics}/test_results.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint10_error_distribution.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint12_nouvelles_fonctionnalites.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint15_llm_pipeline_bugs.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint16_narrative_foundations.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint18_friedman_nemenyi_cdd.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint19_narrative_engine.py +1 -1
- tests/{measurements → evaluation/metrics}/test_sprint20_pareto_pricing.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint23_anti_hallucination.py +2 -2
- tests/{measurements → evaluation/metrics}/test_sprint29_detector_registry.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint35_inter_engine.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint36_ensemble_narrative.py +1 -1
- tests/{measurements → evaluation/metrics}/test_sprint38_ner_metrics.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint39_calibration.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint44_median_default.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint45_stratification.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint52_readability.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint53_reading_order.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint54_layout.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint55_unicode_blocks.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint56_abbreviations.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint57_mufi.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint58_early_modern.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint59_modern_archives.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint60_roman_numerals.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint71_rare_tokens.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint73_baseline_comparison.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint78_equivalence_profile.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint79_cost_projection.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint81_robustness_projection.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint83_reliability.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint84_searchability.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint85_numerical_sequences.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint8_longitudinal_robustness.py +0 -0
- tests/{measurements → evaluation/metrics}/test_sprint93_image_predictive.py +1 -1
- tests/{measurements → evaluation/metrics}/test_sprint96_incremental_comparison.py +1 -1
- tests/{measurements → evaluation/metrics}/test_sprint97_module_policy.py +3 -3
- tests/{measurements → evaluation/metrics}/test_sprint_a14_s1_normalization_propagation.py +0 -0
- tests/{core → evaluation}/test_corpus.py +0 -0
|
@@ -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 }}
|
|
@@ -116,7 +116,7 @@ picarones/
|
|
| 116 |
|
| 117 |
## État des tests et bugs historiques
|
| 118 |
|
| 119 |
-
`pytest tests/` → **
|
| 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` →
|
| 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).
|
|
@@ -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**: ~
|
| 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
|
|
@@ -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)."""
|
|
File without changes
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -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.
|
| 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
|
|
File without changes
|
|
@@ -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.
|
| 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()
|
|
File without changes
|
|
File without changes
|
|
@@ -264,7 +264,7 @@ class TestSynthesisIntegration:
|
|
| 264 |
# ──────────────────────────────────────────────────────────────────────────
|
| 265 |
|
| 266 |
|
| 267 |
-
from tests.
|
| 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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -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"))
|
|
@@ -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"))
|
|
@@ -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")
|
|
File without changes
|
|
File without changes
|