Spaces:
Running
fix(prod-hotfix)!: serve_cmd referenced deleted picarones.web package
Browse filesBug en prod (HuggingFace Space, 2026-05-10) : ``picarones serve``
plantait au démarrage uvicorn avec :
ModuleNotFoundError: No module named 'picarones.web'
Cause racine
------------
``picarones/interfaces/cli/_serve.py:70`` passait à ``uvicorn.run``
la string ``"picarones.web.app:app"`` qui pointait vers le paquet
legacy ``picarones/web/`` — supprimé au sprint H.4 (mai 2026) lors
du retrait complet du legacy. Le chemin canonique v2.0 est
``picarones.interfaces.web.app:app``.
Pourquoi le bug n'a pas été pris au CI
--------------------------------------
Aucun test n'exerçait la string passée à uvicorn. Les tests web
existants instancient ``app`` directement via ``TestClient(app)``
(sans uvicorn), donc l'import ``picarones.web.app:app`` n'était
jamais testé même si le code de production en dépendait pour
démarrer.
Fix
---
1. ``_serve.py:70`` : ``picarones.web.app:app`` →
``picarones.interfaces.web.app:app``.
2. Nettoyage des docstrings obsolètes (5 fichiers) qui référençaient
encore le paquet supprimé — induisaient en erreur tout
utilisateur qui copie-collait l'exemple uvicorn.
3. ``tests/interfaces/cli/test_s9_serve_module_path.py`` (5 tests) :
garde-fou anti-régression qui parse ``_serve.py`` par AST,
extrait la string ``module:attr``, et vérifie :
- format valide,
- module importable réellement,
- attribut existe sur le module,
- attribut est bien une instance ``FastAPI``,
- chemin canonique v2.0 utilisé (pas ``picarones.web.*``).
Vérification manuelle : ``import_from_string('picarones.interfaces.web.app:app')``
retourne maintenant un ``FastAPI`` avec 35 routes.
Régression : 4548 passed, 0 failed, ruff clean.
- picarones/app/services/path_security.py +2 -2
- picarones/interfaces/cli/_serve.py +1 -1
- picarones/interfaces/web/app.py +11 -11
- picarones/interfaces/web/jobs.py +1 -1
- picarones/interfaces/web/maintenance.py +1 -1
- picarones/interfaces/web/observability.py +1 -1
- tests/interfaces/cli/test_s9_serve_module_path.py +130 -0
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""``WorkspaceManager`` + helpers de validation de chemin — Sprint A14-S19.
|
| 2 |
|
| 3 |
Foyer définitif des helpers ``validated_path``, ``safe_report_name``,
|
| 4 |
-
``validated_prompt_filename`` créés au S1. Les
|
| 5 |
-
(``picarones.web.security``) ré-importent depuis ce module.
|
| 6 |
|
| 7 |
Pourquoi ici
|
| 8 |
------------
|
|
|
|
| 1 |
"""``WorkspaceManager`` + helpers de validation de chemin — Sprint A14-S19.
|
| 2 |
|
| 3 |
Foyer définitif des helpers ``validated_path``, ``safe_report_name``,
|
| 4 |
+
``validated_prompt_filename`` créés au S1. Les callers web
|
| 5 |
+
(``picarones.interfaces.web.security``) ré-importent depuis ce module.
|
| 6 |
|
| 7 |
Pourquoi ici
|
| 8 |
------------
|
|
@@ -67,7 +67,7 @@ def serve_cmd(host: str, port: int, reload: bool, verbose: bool) -> None:
|
|
| 67 |
|
| 68 |
log_level = "debug" if verbose else "info"
|
| 69 |
uvicorn.run(
|
| 70 |
-
"picarones.web.app:app",
|
| 71 |
host=host,
|
| 72 |
port=port,
|
| 73 |
reload=reload,
|
|
|
|
| 67 |
|
| 68 |
log_level = "debug" if verbose else "info"
|
| 69 |
uvicorn.run(
|
| 70 |
+
"picarones.interfaces.web.app:app",
|
| 71 |
host=host,
|
| 72 |
port=port,
|
| 73 |
reload=reload,
|
|
@@ -6,21 +6,21 @@ Lance avec :
|
|
| 6 |
|
| 7 |
picarones serve [--port 8000] [--host 127.0.0.1]
|
| 8 |
# ou directement :
|
| 9 |
-
uvicorn picarones.web.app:app --reload --port 8000
|
| 10 |
|
| 11 |
L'application est intentionnellement minimaliste : elle se contente
|
| 12 |
d'instancier ``FastAPI``, de monter le middleware de sécurité (CSP,
|
| 13 |
en-têtes durcis), de servir les fichiers statiques, puis d'inclure
|
| 14 |
-
les 11 routers thématiques de :mod:`picarones.web.routers`.
|
| 15 |
-
logique métier vit dans les sous-modules :
|
| 16 |
-
|
| 17 |
-
- :mod:`picarones.web.state` — singletons et helpers transverses
|
| 18 |
-
- :mod:`picarones.web.models` — Pydantic schemas
|
| 19 |
-
- :mod:`picarones.web.corpus_utils` — parsing XML, analyse corpus
|
| 20 |
-
- :mod:`picarones.web.engine_utils` — détection moteurs, capacités
|
| 21 |
-
- :mod:`picarones.web.benchmark_utils` — workers threadés
|
| 22 |
-
- :mod:`picarones.web.config_utils` — validation config utilisateur
|
| 23 |
-
- :mod:`picarones.web.routers.*` — 11 ``APIRouter`` thématiques
|
| 24 |
"""
|
| 25 |
|
| 26 |
from __future__ import annotations
|
|
|
|
| 6 |
|
| 7 |
picarones serve [--port 8000] [--host 127.0.0.1]
|
| 8 |
# ou directement :
|
| 9 |
+
uvicorn picarones.interfaces.web.app:app --reload --port 8000
|
| 10 |
|
| 11 |
L'application est intentionnellement minimaliste : elle se contente
|
| 12 |
d'instancier ``FastAPI``, de monter le middleware de sécurité (CSP,
|
| 13 |
en-têtes durcis), de servir les fichiers statiques, puis d'inclure
|
| 14 |
+
les 11 routers thématiques de :mod:`picarones.interfaces.web.routers`.
|
| 15 |
+
Toute la logique métier vit dans les sous-modules :
|
| 16 |
+
|
| 17 |
+
- :mod:`picarones.interfaces.web.state` — singletons et helpers transverses
|
| 18 |
+
- :mod:`picarones.interfaces.web.models` — Pydantic schemas
|
| 19 |
+
- :mod:`picarones.interfaces.web.corpus_utils` — parsing XML, analyse corpus
|
| 20 |
+
- :mod:`picarones.interfaces.web.engine_utils` — détection moteurs, capacités
|
| 21 |
+
- :mod:`picarones.interfaces.web.benchmark_utils` — workers threadés
|
| 22 |
+
- :mod:`picarones.interfaces.web.config_utils` — validation config utilisateur
|
| 23 |
+
- :mod:`picarones.interfaces.web.routers.*` — 11 ``APIRouter`` thématiques
|
| 24 |
"""
|
| 25 |
|
| 26 |
from __future__ import annotations
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Persistance SQLite des jobs de benchmark (Sprint 26).
|
| 2 |
|
| 3 |
Avant le Sprint 26, l'état des benchmarks vivait uniquement en mémoire dans
|
| 4 |
-
``picarones.web.app._JOBS``. Trois conséquences :
|
| 5 |
|
| 6 |
1. Un redémarrage du worker uvicorn (OOM, déploiement, ``kill -HUP``)
|
| 7 |
perdait l'état de tous les benchmarks en cours et un client SSE qui se
|
|
|
|
| 1 |
"""Persistance SQLite des jobs de benchmark (Sprint 26).
|
| 2 |
|
| 3 |
Avant le Sprint 26, l'état des benchmarks vivait uniquement en mémoire dans
|
| 4 |
+
``picarones.interfaces.web.app._JOBS``. Trois conséquences :
|
| 5 |
|
| 6 |
1. Un redémarrage du worker uvicorn (OOM, déploiement, ``kill -HUP``)
|
| 7 |
perdait l'état de tous les benchmarks en cours et un client SSE qui se
|
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
Sprint A11 (item M-8 du plan de remédiation institutionnelle).
|
| 4 |
|
| 5 |
-
Démarrée par le ``lifespan`` de ``picarones.web.app``, cette tâche
|
| 6 |
asyncio scanne le dossier ``uploads/`` toutes les ``interval_seconds``
|
| 7 |
et supprime tout sous-dossier dont :
|
| 8 |
|
|
|
|
| 2 |
|
| 3 |
Sprint A11 (item M-8 du plan de remédiation institutionnelle).
|
| 4 |
|
| 5 |
+
Démarrée par le ``lifespan`` de ``picarones.interfaces.web.app``, cette tâche
|
| 6 |
asyncio scanne le dossier ``uploads/`` toutes les ``interval_seconds``
|
| 7 |
et supprime tout sous-dossier dont :
|
| 8 |
|
|
@@ -60,7 +60,7 @@ class JsonLogFormatter(logging.Formatter):
|
|
| 60 |
.. code-block:: json
|
| 61 |
|
| 62 |
{"timestamp": "2026-05-09T12:00:00.123Z", "level": "INFO",
|
| 63 |
-
"logger": "picarones.web.app", "message": "...",
|
| 64 |
"request_id": "abc123def456"}
|
| 65 |
|
| 66 |
Champs additionnels : ``exc_type`` + ``exc_message`` aplatis si
|
|
|
|
| 60 |
.. code-block:: json
|
| 61 |
|
| 62 |
{"timestamp": "2026-05-09T12:00:00.123Z", "level": "INFO",
|
| 63 |
+
"logger": "picarones.interfaces.web.app", "message": "...",
|
| 64 |
"request_id": "abc123def456"}
|
| 65 |
|
| 66 |
Champs additionnels : ``exc_type`` + ``exc_message`` aplatis si
|
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sprint S9 — garde-fou anti-régression pour la commande ``serve``.
|
| 2 |
+
|
| 3 |
+
Bug observé en prod (HuggingFace Space, 2026-05-10) :
|
| 4 |
+
``picarones serve`` plantait avec ``ModuleNotFoundError: No module
|
| 5 |
+
named 'picarones.web'`` parce que ``_serve.py:70`` passait
|
| 6 |
+
``"picarones.web.app:app"`` à ``uvicorn.run()``. Le paquet
|
| 7 |
+
``picarones.web/`` a été supprimé au sprint H.4 (mai 2026) lors
|
| 8 |
+
du retrait complet du legacy ; la string passée à uvicorn n'a
|
| 9 |
+
pas été migrée car aucun test ne l'exerçait.
|
| 10 |
+
|
| 11 |
+
Ce fichier verrouille deux contrats :
|
| 12 |
+
|
| 13 |
+
1. La string passée à ``uvicorn.run`` dans ``serve_cmd`` doit
|
| 14 |
+
référencer un module **réellement importable** au format
|
| 15 |
+
``module.path:attribute``.
|
| 16 |
+
2. Le module et l'attribut ``app`` (instance FastAPI) doivent
|
| 17 |
+
tous deux exister.
|
| 18 |
+
|
| 19 |
+
Le test n'exécute pas uvicorn — il extrait la string par
|
| 20 |
+
introspection AST du fichier source pour rester rapide et ne
|
| 21 |
+
pas dépendre du serveur réel.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import ast
|
| 27 |
+
import importlib
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _extract_uvicorn_target() -> str:
|
| 32 |
+
"""Parse ``_serve.py`` pour extraire le 1er argument de l'appel
|
| 33 |
+
à ``uvicorn.run(...)``.
|
| 34 |
+
|
| 35 |
+
Implémentation par AST plutôt que par exécution : on ne veut
|
| 36 |
+
pas démarrer un serveur ni dépendre de uvicorn pour tester
|
| 37 |
+
la validité de la string.
|
| 38 |
+
"""
|
| 39 |
+
serve_path = (
|
| 40 |
+
Path(__file__).resolve().parents[3]
|
| 41 |
+
/ "picarones" / "interfaces" / "cli" / "_serve.py"
|
| 42 |
+
)
|
| 43 |
+
tree = ast.parse(serve_path.read_text(encoding="utf-8"))
|
| 44 |
+
for node in ast.walk(tree):
|
| 45 |
+
if isinstance(node, ast.Call):
|
| 46 |
+
func = node.func
|
| 47 |
+
if (
|
| 48 |
+
isinstance(func, ast.Attribute)
|
| 49 |
+
and func.attr == "run"
|
| 50 |
+
and isinstance(func.value, ast.Name)
|
| 51 |
+
and func.value.id == "uvicorn"
|
| 52 |
+
):
|
| 53 |
+
# 1er arg positionnel = target.
|
| 54 |
+
if node.args:
|
| 55 |
+
first_arg = node.args[0]
|
| 56 |
+
if isinstance(first_arg, ast.Constant):
|
| 57 |
+
return str(first_arg.value)
|
| 58 |
+
raise AssertionError(
|
| 59 |
+
"Pas d'appel ``uvicorn.run(<string>, ...)`` trouvé dans "
|
| 60 |
+
"_serve.py — la commande serve a-t-elle été refactorée ?"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class TestUvicornTargetIsImportable:
|
| 65 |
+
"""Le bug ``ModuleNotFoundError: picarones.web`` observé en
|
| 66 |
+
prod ne doit jamais réapparaître : la string ``module:attr``
|
| 67 |
+
passée à uvicorn doit être valide à tout instant."""
|
| 68 |
+
|
| 69 |
+
def test_target_format_is_module_colon_attr(self) -> None:
|
| 70 |
+
target = _extract_uvicorn_target()
|
| 71 |
+
assert ":" in target, (
|
| 72 |
+
f"Target uvicorn invalide : {target!r} — "
|
| 73 |
+
"format attendu ``module.path:attr``."
|
| 74 |
+
)
|
| 75 |
+
module_path, _, attr = target.partition(":")
|
| 76 |
+
assert module_path, "Module path vide avant ':'"
|
| 77 |
+
assert attr, "Attribut vide après ':'"
|
| 78 |
+
|
| 79 |
+
def test_target_module_is_actually_importable(self) -> None:
|
| 80 |
+
"""Le module nommé doit pouvoir être importé — c'est
|
| 81 |
+
exactement ce qui plantait en prod (``picarones.web``
|
| 82 |
+
n'existait plus depuis le sprint H.4)."""
|
| 83 |
+
target = _extract_uvicorn_target()
|
| 84 |
+
module_path, _, _ = target.partition(":")
|
| 85 |
+
try:
|
| 86 |
+
importlib.import_module(module_path)
|
| 87 |
+
except ImportError as exc:
|
| 88 |
+
raise AssertionError(
|
| 89 |
+
f"``uvicorn.run({target!r}, ...)`` référence un "
|
| 90 |
+
f"module non-importable : {exc}. "
|
| 91 |
+
"Probable régression : le module a été renommé "
|
| 92 |
+
"sans mise à jour de _serve.py."
|
| 93 |
+
) from exc
|
| 94 |
+
|
| 95 |
+
def test_target_attribute_exists_on_module(self) -> None:
|
| 96 |
+
target = _extract_uvicorn_target()
|
| 97 |
+
module_path, _, attr = target.partition(":")
|
| 98 |
+
module = importlib.import_module(module_path)
|
| 99 |
+
assert hasattr(module, attr), (
|
| 100 |
+
f"Module {module_path!r} n'a pas l'attribut {attr!r} — "
|
| 101 |
+
f"uvicorn planterait à l'instanciation."
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
def test_target_attribute_is_a_fastapi_app(self) -> None:
|
| 105 |
+
"""Garantit que l'attribut est bien une instance FastAPI
|
| 106 |
+
(pas un module, pas une fonction) — sinon uvicorn
|
| 107 |
+
rapporterait une erreur cryptique."""
|
| 108 |
+
from fastapi import FastAPI
|
| 109 |
+
|
| 110 |
+
target = _extract_uvicorn_target()
|
| 111 |
+
module_path, _, attr = target.partition(":")
|
| 112 |
+
module = importlib.import_module(module_path)
|
| 113 |
+
app = getattr(module, attr)
|
| 114 |
+
assert isinstance(app, FastAPI), (
|
| 115 |
+
f"{target!r} → {type(app).__name__} ; FastAPI attendu."
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
def test_target_uses_canonical_v2_path(self) -> None:
|
| 119 |
+
"""Garde-fou explicite : depuis v2.0, la web app vit dans
|
| 120 |
+
``picarones.interfaces.web.app``, pas dans
|
| 121 |
+
``picarones.web.app`` (paquet supprimé au sprint H.4)."""
|
| 122 |
+
target = _extract_uvicorn_target()
|
| 123 |
+
assert "picarones.interfaces.web" in target, (
|
| 124 |
+
f"Target {target!r} n'utilise pas le chemin canonique "
|
| 125 |
+
"v2.0 ``picarones.interfaces.web.app:app``."
|
| 126 |
+
)
|
| 127 |
+
assert "picarones.web." not in target, (
|
| 128 |
+
f"Target {target!r} référence le paquet legacy "
|
| 129 |
+
"``picarones.web.*`` supprimé au sprint H.4."
|
| 130 |
+
)
|