Picarones / tests /interfaces /cli /test_s9_serve_module_path.py
Claude
fix(prod-hotfix)!: serve_cmd referenced deleted picarones.web package
0137610 unverified
"""Sprint S9 — garde-fou anti-régression pour la commande ``serve``.
Bug observé en prod (HuggingFace Space, 2026-05-10) :
``picarones serve`` plantait avec ``ModuleNotFoundError: No module
named 'picarones.web'`` parce que ``_serve.py:70`` passait
``"picarones.web.app:app"`` à ``uvicorn.run()``. Le paquet
``picarones.web/`` a été supprimé au sprint H.4 (mai 2026) lors
du retrait complet du legacy ; la string passée à uvicorn n'a
pas été migrée car aucun test ne l'exerçait.
Ce fichier verrouille deux contrats :
1. La string passée à ``uvicorn.run`` dans ``serve_cmd`` doit
référencer un module **réellement importable** au format
``module.path:attribute``.
2. Le module et l'attribut ``app`` (instance FastAPI) doivent
tous deux exister.
Le test n'exécute pas uvicorn — il extrait la string par
introspection AST du fichier source pour rester rapide et ne
pas dépendre du serveur réel.
"""
from __future__ import annotations
import ast
import importlib
from pathlib import Path
def _extract_uvicorn_target() -> str:
"""Parse ``_serve.py`` pour extraire le 1er argument de l'appel
à ``uvicorn.run(...)``.
Implémentation par AST plutôt que par exécution : on ne veut
pas démarrer un serveur ni dépendre de uvicorn pour tester
la validité de la string.
"""
serve_path = (
Path(__file__).resolve().parents[3]
/ "picarones" / "interfaces" / "cli" / "_serve.py"
)
tree = ast.parse(serve_path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if isinstance(node, ast.Call):
func = node.func
if (
isinstance(func, ast.Attribute)
and func.attr == "run"
and isinstance(func.value, ast.Name)
and func.value.id == "uvicorn"
):
# 1er arg positionnel = target.
if node.args:
first_arg = node.args[0]
if isinstance(first_arg, ast.Constant):
return str(first_arg.value)
raise AssertionError(
"Pas d'appel ``uvicorn.run(<string>, ...)`` trouvé dans "
"_serve.py — la commande serve a-t-elle été refactorée ?"
)
class TestUvicornTargetIsImportable:
"""Le bug ``ModuleNotFoundError: picarones.web`` observé en
prod ne doit jamais réapparaître : la string ``module:attr``
passée à uvicorn doit être valide à tout instant."""
def test_target_format_is_module_colon_attr(self) -> None:
target = _extract_uvicorn_target()
assert ":" in target, (
f"Target uvicorn invalide : {target!r} — "
"format attendu ``module.path:attr``."
)
module_path, _, attr = target.partition(":")
assert module_path, "Module path vide avant ':'"
assert attr, "Attribut vide après ':'"
def test_target_module_is_actually_importable(self) -> None:
"""Le module nommé doit pouvoir être importé — c'est
exactement ce qui plantait en prod (``picarones.web``
n'existait plus depuis le sprint H.4)."""
target = _extract_uvicorn_target()
module_path, _, _ = target.partition(":")
try:
importlib.import_module(module_path)
except ImportError as exc:
raise AssertionError(
f"``uvicorn.run({target!r}, ...)`` référence un "
f"module non-importable : {exc}. "
"Probable régression : le module a été renommé "
"sans mise à jour de _serve.py."
) from exc
def test_target_attribute_exists_on_module(self) -> None:
target = _extract_uvicorn_target()
module_path, _, attr = target.partition(":")
module = importlib.import_module(module_path)
assert hasattr(module, attr), (
f"Module {module_path!r} n'a pas l'attribut {attr!r} — "
f"uvicorn planterait à l'instanciation."
)
def test_target_attribute_is_a_fastapi_app(self) -> None:
"""Garantit que l'attribut est bien une instance FastAPI
(pas un module, pas une fonction) — sinon uvicorn
rapporterait une erreur cryptique."""
from fastapi import FastAPI
target = _extract_uvicorn_target()
module_path, _, attr = target.partition(":")
module = importlib.import_module(module_path)
app = getattr(module, attr)
assert isinstance(app, FastAPI), (
f"{target!r}{type(app).__name__} ; FastAPI attendu."
)
def test_target_uses_canonical_v2_path(self) -> None:
"""Garde-fou explicite : depuis v2.0, la web app vit dans
``picarones.interfaces.web.app``, pas dans
``picarones.web.app`` (paquet supprimé au sprint H.4)."""
target = _extract_uvicorn_target()
assert "picarones.interfaces.web" in target, (
f"Target {target!r} n'utilise pas le chemin canonique "
"v2.0 ``picarones.interfaces.web.app:app``."
)
assert "picarones.web." not in target, (
f"Target {target!r} référence le paquet legacy "
"``picarones.web.*`` supprimé au sprint H.4."
)