Picarones / tests /release /test_entry_points.py
Claude
chore(audit): Phase 0 quick wins — entry point + numérotation couches
0c80c8c unverified
Raw
History Blame Contribute Delete
4.75 kB
"""Garde-fou : tout entry point déclaré doit pointer vers un module
réellement importable.
Audit de mai 2026 — ``app.py`` (entry point HuggingFace Spaces)
référençait ``picarones.web.app:app``, paquet supprimé au sprint H.4
mais jamais rebranché. ``python app.py`` produisait un
``ModuleNotFoundError`` ; le Dockerfile masquait le bug en lançant
``picarones serve`` à la place.
Ce test vérifie qu'au fil du temps, **chaque entry point déclaré**
(``app.py`` racine, ``[project.scripts]`` dans ``pyproject.toml``,
``huggingface``, ``uvicorn``...) pointe vers un module qui
``importlib.import_module()`` accepte.
"""
from __future__ import annotations
import ast
import importlib
import re
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
def _extract_uvicorn_targets(path: Path) -> list[str]:
"""Extrait les chaînes ``module:attr`` passées à ``uvicorn.run(...)``.
Plutôt que de regex à la louche, on parse l'AST pour ne capturer
que les littéraux passés au premier argument positionnel d'un
appel ``uvicorn.run(...)``.
"""
tree = ast.parse(path.read_text(encoding="utf-8"))
targets: list[str] = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
is_uvicorn_run = (
isinstance(func, ast.Attribute)
and func.attr == "run"
and isinstance(func.value, ast.Name)
and func.value.id == "uvicorn"
)
if not is_uvicorn_run or not node.args:
continue
first = node.args[0]
if isinstance(first, ast.Constant) and isinstance(first.value, str):
targets.append(first.value)
return targets
def _check_target_importable(target: str) -> None:
"""Vérifie que ``module:attr`` est résolvable."""
if ":" in target:
module_name, attr = target.split(":", 1)
else:
module_name, attr = target, None
module = importlib.import_module(module_name)
if attr is not None:
assert hasattr(module, attr), (
f"Entry point '{target}' : le module '{module_name}' "
f"existe mais n'expose pas l'attribut '{attr}'."
)
def test_hf_spaces_app_py_resolves() -> None:
"""``app.py`` à la racine doit pointer vers un module importable.
Régression : ``picarones.web.app:app`` (legacy supprimé v2.0)
→ ``picarones.interfaces.web.app:app`` (Phase 0.1 audit code-quality).
"""
app_py = REPO_ROOT / "app.py"
if not app_py.exists():
pytest.skip("app.py absent (déploiement non-HF)")
targets = _extract_uvicorn_targets(app_py)
assert targets, (
f"{app_py} ne contient aucun appel ``uvicorn.run('module:attr', ...)`` "
f"— le test ne peut pas vérifier la cible."
)
for target in targets:
_check_target_importable(target)
def test_pyproject_console_scripts_resolve() -> None:
"""Chaque ``[project.scripts]`` doit pointer vers un module importable.
Format pyproject : ``picarones = "picarones.interfaces.cli:cli"``.
"""
pyproject = REPO_ROOT / "pyproject.toml"
content = pyproject.read_text(encoding="utf-8")
# Sous-section [project.scripts] — capture tout jusqu'à la
# prochaine section [...] ou fin de fichier.
match = re.search(
r"\[project\.scripts\]\s*\n(.*?)(?=^\[|\Z)",
content,
re.DOTALL | re.MULTILINE,
)
if not match:
pytest.skip("Aucune section [project.scripts] dans pyproject.toml")
# Chaque ligne ``key = "module:attr"``.
script_re = re.compile(r'^\s*[\w_-]+\s*=\s*"([^"]+)"\s*$', re.MULTILINE)
targets = script_re.findall(match.group(1))
assert targets, "Section [project.scripts] vide ou mal formatée"
for target in targets:
_check_target_importable(target)
def test_no_legacy_picarones_web_references() -> None:
"""``picarones.web`` a été supprimé en v2.0 ; aucune chaîne
``picarones.web.app`` ne doit subsister dans les entry points
ou fichiers de déploiement.
"""
suspicious_paths = [
REPO_ROOT / "app.py",
REPO_ROOT / "Dockerfile",
REPO_ROOT / "docker-compose.yml",
REPO_ROOT / "picarones.spec",
]
pattern = re.compile(r"\bpicarones\.web(?:\.|\b)")
for path in suspicious_paths:
if not path.exists():
continue
text = path.read_text(encoding="utf-8")
match = pattern.search(text)
assert match is None, (
f"{path.name} référence encore le paquet legacy 'picarones.web' "
f"(supprimé v2.0) à la position {match.start()}. "
f"Remplacer par 'picarones.interfaces.web'."
)