Spaces:
Sleeping
Sleeping
| """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'." | |
| ) | |