Claude commited on
Commit
0c80c8c
·
unverified ·
1 Parent(s): 38207fd

chore(audit): Phase 0 quick wins — entry point + numérotation couches

Browse files

Trois corrections triviales identifiées par l'audit code-quality :

1. **app.py** : ``picarones.web.app:app`` (paquet supprimé v2.0) →
``picarones.interfaces.web.app:app``. Le Dockerfile masquait le
bug en lançant ``picarones serve`` ; quiconque exécutait
``python app.py`` localement obtenait ``ModuleNotFoundError``.

2. **picarones/{8 couches}/__init__.py** : renumérotation
``Cercle N`` (incohérent : 5 max, doublons) → ``Couche N``
(1 à 8, ordre du manifeste CLAUDE.md). domain=1, formats=2,
evaluation=3, pipeline=4, adapters=5, app=6, reports=7,
interfaces=8.

3. **.env.example** : commenter ``PICARONES_PORT`` comme variable
docker-compose uniquement (le code Python ne la lit pas — le
port serveur passe par ``picarones serve --port`` ou le CMD
Dockerfile).

**Test ajouté** : ``tests/release/test_entry_points.py`` (3 tests)
- ``app.py`` ``uvicorn.run('module:attr', ...)`` est importable
- ``[project.scripts]`` de pyproject.toml résolvent
- Aucune référence résiduelle à ``picarones.web`` dans les
fichiers de déploiement (app.py, Dockerfile, docker-compose,
picarones.spec).

Suite : 4 689 passed, 14 skipped, 8 deselected, 2 xfailed (+3 vs
baseline 4 686). Ruff propre.

.env.example CHANGED
@@ -88,7 +88,13 @@ AZURE_DOC_INTEL_ENDPOINT=
88
  AZURE_DOC_INTEL_KEY=
89
 
90
  # ──────────────────────────────────────────────────────────────────
91
- # Réseau / port d'exposition (utilisé par docker-compose.yml)
 
 
 
 
 
 
92
  # ──────────────────────────────────────────────────────────────────
93
  PICARONES_PORT=7860
94
 
 
88
  AZURE_DOC_INTEL_KEY=
89
 
90
  # ──────────────────────────────────────────────────────────────────
91
+ # Réseau / port d'exposition.
92
+ #
93
+ # NB : variable utilisée UNIQUEMENT par ``docker-compose.yml`` pour
94
+ # mapper le port hôte (cf. ligne ``${PICARONES_PORT:-7860}:7860``).
95
+ # Le code Python ne la lit pas — pour changer le port serveur,
96
+ # utiliser ``picarones serve --port <N>`` ou modifier le CMD du
97
+ # Dockerfile.
98
  # ──────────────────────────────────────────────────────────────────
99
  PICARONES_PORT=7860
100
 
app.py CHANGED
@@ -2,4 +2,4 @@
2
  import uvicorn
3
 
4
  if __name__ == "__main__":
5
- uvicorn.run("picarones.web.app:app", host="0.0.0.0", port=7860)
 
2
  import uvicorn
3
 
4
  if __name__ == "__main__":
5
+ uvicorn.run("picarones.interfaces.web.app:app", host="0.0.0.0", port=7860)
picarones/adapters/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Cercle 3 — Adapters.
2
 
3
  Implémentations concrètes des contrats du domain. C'est ici que
4
  vivent les dépendances externes lourdes (pytesseract, pero_ocr,
 
1
+ """Couche 5 — Adapters.
2
 
3
  Implémentations concrètes des contrats du domain. C'est ici que
4
  vivent les dépendances externes lourdes (pytesseract, pero_ocr,
picarones/app/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Cercle 4 — Application services.
2
 
3
  Couche d'orchestration : reçoit des requêtes (DTO Pydantic) depuis
4
  ``interfaces/``, valide tout (chemins sandboxés, quotas, mode
 
1
+ """Couche 6 — Application services.
2
 
3
  Couche d'orchestration : reçoit des requêtes (DTO Pydantic) depuis
4
  ``interfaces/``, valide tout (chemins sandboxés, quotas, mode
picarones/domain/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Cercle 1 — Domain.
2
 
3
  Types purs et abstractions du modèle métier de Picarones.
4
 
 
1
+ """Couche 1 — Domain.
2
 
3
  Types purs et abstractions du modèle métier de Picarones.
4
 
picarones/evaluation/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Cercle 2 — Evaluation.
2
 
3
  Vues d'évaluation, projecteurs et calculs de métriques.
4
 
 
1
+ """Couche 3 — Evaluation.
2
 
3
  Vues d'évaluation, projecteurs et calculs de métriques.
4
 
picarones/formats/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Cercle 2 — Formats documentaires.
2
 
3
  Parsers, writers et validateurs pour les formats d'entrée/sortie
4
  patrimoniaux. Tout le code XML / namespaces / parsing vit ici, à
 
1
+ """Couche 2 — Formats documentaires.
2
 
3
  Parsers, writers et validateurs pour les formats d'entrée/sortie
4
  patrimoniaux. Tout le code XML / namespaces / parsing vit ici, à
picarones/interfaces/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Cercle 5 — Interfaces (CLI, web).
2
 
3
  Couches de transport. Code mince qui parse des arguments / des
4
  requêtes HTTP, appelle un service applicatif, retourne une réponse.
 
1
+ """Couche 8 — Interfaces (CLI, web).
2
 
3
  Couches de transport. Code mince qui parse des arguments / des
4
  requêtes HTTP, appelle un service applicatif, retourne une réponse.
picarones/pipeline/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Cercle 2 — Pipeline execution.
2
 
3
  Exécution séquentielle ou DAG-branchante d'une chaîne de modules
4
  tiers (``StepExecutor``). Picarones ne fournit **aucun module
 
1
+ """Couche 4 — Pipeline execution.
2
 
3
  Exécution séquentielle ou DAG-branchante d'une chaîne de modules
4
  tiers (``StepExecutor``). Picarones ne fournit **aucun module
picarones/reports/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """Cercle 3 — Reports.
2
 
3
  Sortie en différents formats à partir d'un ``RunResult`` persisté.
4
  Le rapport est une **vue** des artefacts et des résultats
 
1
+ """Couche 7 — Reports.
2
 
3
  Sortie en différents formats à partir d'un ``RunResult`` persisté.
4
  Le rapport est une **vue** des artefacts et des résultats
tests/release/test_entry_points.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Garde-fou : tout entry point déclaré doit pointer vers un module
2
+ réellement importable.
3
+
4
+ Audit de mai 2026 — ``app.py`` (entry point HuggingFace Spaces)
5
+ référençait ``picarones.web.app:app``, paquet supprimé au sprint H.4
6
+ mais jamais rebranché. ``python app.py`` produisait un
7
+ ``ModuleNotFoundError`` ; le Dockerfile masquait le bug en lançant
8
+ ``picarones serve`` à la place.
9
+
10
+ Ce test vérifie qu'au fil du temps, **chaque entry point déclaré**
11
+ (``app.py`` racine, ``[project.scripts]`` dans ``pyproject.toml``,
12
+ ``huggingface``, ``uvicorn``...) pointe vers un module qui
13
+ ``importlib.import_module()`` accepte.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import ast
19
+ import importlib
20
+ import re
21
+ from pathlib import Path
22
+
23
+ import pytest
24
+
25
+ REPO_ROOT = Path(__file__).resolve().parents[2]
26
+
27
+
28
+ def _extract_uvicorn_targets(path: Path) -> list[str]:
29
+ """Extrait les chaînes ``module:attr`` passées à ``uvicorn.run(...)``.
30
+
31
+ Plutôt que de regex à la louche, on parse l'AST pour ne capturer
32
+ que les littéraux passés au premier argument positionnel d'un
33
+ appel ``uvicorn.run(...)``.
34
+ """
35
+ tree = ast.parse(path.read_text(encoding="utf-8"))
36
+ targets: list[str] = []
37
+ for node in ast.walk(tree):
38
+ if not isinstance(node, ast.Call):
39
+ continue
40
+ func = node.func
41
+ is_uvicorn_run = (
42
+ isinstance(func, ast.Attribute)
43
+ and func.attr == "run"
44
+ and isinstance(func.value, ast.Name)
45
+ and func.value.id == "uvicorn"
46
+ )
47
+ if not is_uvicorn_run or not node.args:
48
+ continue
49
+ first = node.args[0]
50
+ if isinstance(first, ast.Constant) and isinstance(first.value, str):
51
+ targets.append(first.value)
52
+ return targets
53
+
54
+
55
+ def _check_target_importable(target: str) -> None:
56
+ """Vérifie que ``module:attr`` est résolvable."""
57
+ if ":" in target:
58
+ module_name, attr = target.split(":", 1)
59
+ else:
60
+ module_name, attr = target, None
61
+ module = importlib.import_module(module_name)
62
+ if attr is not None:
63
+ assert hasattr(module, attr), (
64
+ f"Entry point '{target}' : le module '{module_name}' "
65
+ f"existe mais n'expose pas l'attribut '{attr}'."
66
+ )
67
+
68
+
69
+ def test_hf_spaces_app_py_resolves() -> None:
70
+ """``app.py`` à la racine doit pointer vers un module importable.
71
+
72
+ Régression : ``picarones.web.app:app`` (legacy supprimé v2.0)
73
+ → ``picarones.interfaces.web.app:app`` (Phase 0.1 audit code-quality).
74
+ """
75
+ app_py = REPO_ROOT / "app.py"
76
+ if not app_py.exists():
77
+ pytest.skip("app.py absent (déploiement non-HF)")
78
+
79
+ targets = _extract_uvicorn_targets(app_py)
80
+ assert targets, (
81
+ f"{app_py} ne contient aucun appel ``uvicorn.run('module:attr', ...)`` "
82
+ f"— le test ne peut pas vérifier la cible."
83
+ )
84
+
85
+ for target in targets:
86
+ _check_target_importable(target)
87
+
88
+
89
+ def test_pyproject_console_scripts_resolve() -> None:
90
+ """Chaque ``[project.scripts]`` doit pointer vers un module importable.
91
+
92
+ Format pyproject : ``picarones = "picarones.interfaces.cli:cli"``.
93
+ """
94
+ pyproject = REPO_ROOT / "pyproject.toml"
95
+ content = pyproject.read_text(encoding="utf-8")
96
+
97
+ # Sous-section [project.scripts] — capture tout jusqu'à la
98
+ # prochaine section [...] ou fin de fichier.
99
+ match = re.search(
100
+ r"\[project\.scripts\]\s*\n(.*?)(?=^\[|\Z)",
101
+ content,
102
+ re.DOTALL | re.MULTILINE,
103
+ )
104
+ if not match:
105
+ pytest.skip("Aucune section [project.scripts] dans pyproject.toml")
106
+
107
+ # Chaque ligne ``key = "module:attr"``.
108
+ script_re = re.compile(r'^\s*[\w_-]+\s*=\s*"([^"]+)"\s*$', re.MULTILINE)
109
+ targets = script_re.findall(match.group(1))
110
+ assert targets, "Section [project.scripts] vide ou mal formatée"
111
+
112
+ for target in targets:
113
+ _check_target_importable(target)
114
+
115
+
116
+ def test_no_legacy_picarones_web_references() -> None:
117
+ """``picarones.web`` a été supprimé en v2.0 ; aucune chaîne
118
+ ``picarones.web.app`` ne doit subsister dans les entry points
119
+ ou fichiers de déploiement.
120
+ """
121
+ suspicious_paths = [
122
+ REPO_ROOT / "app.py",
123
+ REPO_ROOT / "Dockerfile",
124
+ REPO_ROOT / "docker-compose.yml",
125
+ REPO_ROOT / "picarones.spec",
126
+ ]
127
+ pattern = re.compile(r"\bpicarones\.web(?:\.|\b)")
128
+ for path in suspicious_paths:
129
+ if not path.exists():
130
+ continue
131
+ text = path.read_text(encoding="utf-8")
132
+ match = pattern.search(text)
133
+ assert match is None, (
134
+ f"{path.name} référence encore le paquet legacy 'picarones.web' "
135
+ f"(supprimé v2.0) à la position {match.start()}. "
136
+ f"Remplacer par 'picarones.interfaces.web'."
137
+ )