Claude commited on
Commit
0137610
·
unverified ·
1 Parent(s): 11f7ef2

fix(prod-hotfix)!: serve_cmd referenced deleted picarones.web package

Browse files

Bug 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 CHANGED
@@ -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 anciens callers
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
  ------------
picarones/interfaces/cli/_serve.py CHANGED
@@ -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,
picarones/interfaces/web/app.py CHANGED
@@ -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`. Toute la
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
picarones/interfaces/web/jobs.py CHANGED
@@ -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
picarones/interfaces/web/maintenance.py CHANGED
@@ -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
 
picarones/interfaces/web/observability.py CHANGED
@@ -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
tests/interfaces/cli/test_s9_serve_module_path.py ADDED
@@ -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
+ )