"""Interface web Picarones — orchestrateur FastAPI. Lance avec : .. code-block:: bash picarones serve [--port 8000] [--host 127.0.0.1] # ou directement : uvicorn picarones.interfaces.web.app:app --reload --port 8000 L'application est intentionnellement minimaliste : elle se contente d'instancier ``FastAPI``, de monter le middleware de sécurité (CSP, en-têtes durcis), de servir les fichiers statiques, puis d'inclure les 11 routers thématiques de :mod:`picarones.interfaces.web.routers`. Toute la logique métier vit dans les sous-modules : - :mod:`picarones.interfaces.web.state` — singletons et helpers transverses - :mod:`picarones.interfaces.web.models` — Pydantic schemas - :mod:`picarones.interfaces.web.corpus_utils` — parsing XML, analyse corpus - :mod:`picarones.interfaces.web.engine_utils` — détection moteurs, capacités - :mod:`picarones.interfaces.web.benchmark_utils` — workers threadés - :mod:`picarones.interfaces.web.config_utils` — validation config utilisateur - :mod:`picarones.interfaces.web.routers.*` — 11 ``APIRouter`` thématiques """ from __future__ import annotations import logging import sqlite3 from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI # Sprint F (plan v2.0) — interfaces/ ne peut pas importer ``picarones`` racine. _picarones = __import__("importlib").import_module("picarones") __version__ = getattr(_picarones, "__version__", "unknown") from picarones.interfaces.web import state from picarones.interfaces.web.routers import ( benchmark as _benchmark_router, config as _config_router, corpus as _corpus_router, engines as _engines_router, history as _history_router, home as _home_router, importers as _importers_router, normalization as _normalization_router, reports as _reports_router, synthesis as _synthesis_router, system as _system_router, ) from picarones.interfaces.web.security import csp_middleware, csrf_middleware _logger = logging.getLogger(__name__) # ────────────────────────────────────────────────────────────────────────── # Lifespan # ────────────────────────────────────────────────────────────────────────── @asynccontextmanager async def _lifespan(app: FastAPI): """Hook de démarrage : valide la config + nettoie les jobs orphelins + démarre la tâche RGPD de purge des uploads. 1. Sprint S6.9 — ``validate_csrf_config()`` : refuse de démarrer si ``PICARONES_CSRF_REQUIRED=1`` sans ``PICARONES_CSRF_SECRET`` stable (sinon tous les tokens CSRF sont invalidés à chaque restart, UX cassée et signal de mauvaise config masqué). 2. Au démarrage, tous les jobs encore en statut ``pending`` ou ``running`` en base sont forcément orphelins (le processus précédent est mort sans les finir). On les bascule en ``interrupted`` pour ne pas laisser d'état mensonger sur le tableau de bord. 3. Phase 4 du chantier post-rewrite — démarrage explicite de :func:`upload_purge_task` (RGPD). Auparavant définie dans ``maintenance.py`` mais jamais lancée par ce lifespan, elle était du code zombie. Désormais lancée comme tâche asyncio de fond ; annulation propre au shutdown. """ import asyncio # Étape 1 — validation config (échec rapide si dangereux). from picarones.interfaces.web.security import validate_csrf_config validate_csrf_config() # Étape 2 — nettoyage jobs orphelins. # NB : on accède via ``state.JOB_STORE`` (pas un import direct) pour # que les fixtures de tests qui ré-affectent ``state.JOB_STORE`` à # un store isolé soient effectivement vues par le lifespan. try: state.JOB_STORE.mark_orphaned_jobs_interrupted() except sqlite3.Error as exc: # pragma: no cover — défense en profondeur # Si la base de jobs est cassée au démarrage, on log en ``error`` # (pas ``warning``) — c'est un signal opérationnel : l'app # tourne dans un état dégradé, le tableau de bord va être incorrect. _logger.error( "[jobs] mark_orphaned_jobs_interrupted ÉCHOUÉ — " "base SQLite inaccessible (%s) : le tableau de bord " "affichera des jobs zombies.", exc, ) # Étape 3 — démarrage tâche de purge RGPD. from picarones.interfaces.web.maintenance import upload_purge_task purge_task = asyncio.create_task(upload_purge_task(state.UPLOADS_DIR)) try: yield finally: # Annulation propre au shutdown ; on attend l'acquittement de # la CancelledError pour éviter le warning "Task was destroyed # but it is pending". ``asyncio.shield`` n'est pas nécessaire : # on accepte la perte d'une éventuelle passe de purge en cours # (idempotente, sera reprise au prochain démarrage). purge_task.cancel() try: await purge_task except (asyncio.CancelledError, Exception) as exc: # noqa: BLE001 if not isinstance(exc, asyncio.CancelledError): _logger.warning( "[maintenance] tâche de purge arrêtée sur erreur : %s", exc, ) # ────────────────────────────────────────────────────────────────────────── # Instance FastAPI # ────────────────────────────────────────────────────────────────────────── app = FastAPI( title="Picarones", description=( "Plateforme de comparaison de moteurs OCR/HTR pour documents patrimoniaux" ), version=__version__, docs_url="/api/docs", redoc_url="/api/redoc", lifespan=_lifespan, ) # ────────────────────────────────────────────────────────────────────────── # Sprint S3.2 — Handler exception global # ────────────────────────────────────────────────────────────────────────── def register_global_exception_handler(target_app: FastAPI) -> None: """Enregistre un handler ``Exception`` qui : 1. Logue le détail (message + traceback) au niveau ERROR avec le ``request_id`` quand disponible — l'équipe ops peut corréler la 500 au log. 2. Retourne au client un JSON minimaliste sans stack trace ni message interne. Format : ``{"error": "internal_error", "request_id": "...", "detail": "..."}``. Sans ce handler, FastAPI renvoie soit la stack trace au client (en mode debug), soit un 500 vide non corrélable côté ops. """ import uuid from fastapi import Request from fastapi.responses import JSONResponse @target_app.exception_handler(Exception) async def _handle_unexpected(request: Request, exc: Exception) -> JSONResponse: # Tente de récupérer un request_id si un middleware l'a posé # dans request.state ; sinon en génère un éphémère pour # corréler client ↔ log. rid = getattr(request.state, "request_id", None) or uuid.uuid4().hex[:12] _logger.error( "[web] exception non capturée — request_id=%s path=%s method=%s : %s", rid, request.url.path, request.method, exc, exc_info=True, ) return JSONResponse( status_code=500, content={ "error": "internal_error", "request_id": rid, "detail": ( "Une erreur interne est survenue. Le détail technique " "a été loggé côté serveur ; communiquer ce request_id " "au support." ), }, ) register_global_exception_handler(app) # Sprint S6.5 — logs JSON structurés si ``PICARONES_LOG_FORMAT=json``. # Opt-in pour ne pas casser les déploiements existants qui parsent # le format texte humain. from picarones.interfaces.web.observability import ( install_json_logging, is_json_logging_requested, request_id_middleware, ) if is_json_logging_requested(): install_json_logging() _logger.info("[observability] JsonLogFormatter installé.") # Middleware request_id — pose ``request.state.request_id`` et # expose ``X-Request-Id`` en réponse. Toujours actif (coût négligeable). app.middleware("http")(request_id_middleware) # Middleware CSP + en-têtes durcis (X-Frame-Options, etc.) app.middleware("http")(csp_middleware) # Sprint A4 (B-11) — protection CSRF, gated par PICARONES_CSRF_REQUIRED. # En mode public (HuggingFace Space) : bypass complet, pas de cookie. # En mode institutionnel : double-submit cookie + signature HMAC-SHA256. # Le middleware s'enregistre toujours mais devient no-op si la variable # d'env n'est pas activée — coût ~0 en mode public. app.middleware("http")(csrf_middleware) # ────────────────────────────────────────────────────────────────────────── # Fichiers statiques # ────────────────────────────────────────────────────────────────────────── _STATIC_DIR = Path(__file__).parent / "static" if _STATIC_DIR.is_dir(): from fastapi.staticfiles import StaticFiles app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") # ────────────────────────────────────────────────────────────────────────── # Routers thématiques # ────────────────────────────────────────────────────────────────────────── # Ordre indifférent fonctionnellement, mais regroupé par domaine # pour la lisibilité (info → données → processus → présentation). app.include_router(_system_router.router) app.include_router(_engines_router.router) app.include_router(_corpus_router.router) app.include_router(_normalization_router.router) app.include_router(_config_router.router) app.include_router(_synthesis_router.router) app.include_router(_history_router.router) app.include_router(_reports_router.router) app.include_router(_importers_router.router) app.include_router(_benchmark_router.router) app.include_router(_home_router.router)