File size: 5,249 Bytes
0137610
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
"""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."
        )