Claude commited on
Commit
2676c9c
·
unverified ·
1 Parent(s): 4c12d6d

feat(interfaces/web): Sprint A14-S35 — squelette FastAPI natif

Browse files

Squelette FastAPI **natif** au nouveau monde, écrit pour consommer
directement les services applicatifs S17+ via DI explicite. Pas un
shim sur le legacy picarones.web.app — c'est une app neuve.

Le legacy reste exposé jusqu'au S46.

picarones/interfaces/web/app.py
-------------------------------
- WebAppState (frozen dataclass) : container immuable des services
injectés (workspace, registry, corpus, benchmark, orchestrator,
version).
- create_app(WebAppState) -> FastAPI : factory qui construit l'app
avec les services injectés. Pas de singleton global — chaque appel
produit une instance indépendante (utile pour les tests isolés).
- Endpoint GET /health : liveness probe, toujours 200 OK si l'app a
démarré (pas de dépendance aux services backends — détecte les
crashes d'app, pas les crashes transitoires).
- Endpoint GET /version : version du code + workspace_root + counts
de métriques/projecteurs (vérifie que le bootstrap a bien eu lieu).
- /api/docs et /api/redoc : OpenAPI/Swagger exposés.

Pas de routers métier en S35
-----------------------------
- Pas de middleware CSP/CSRF (S38 quand on servira HTML).
- Pas de mount static (S38).
- Pas d'endpoints corpus/benchmark/jobs (S36-S37).
- Pas de lifespan (services injectés déjà construits).

Le squelette est intentionnellement minimal — chaque sprint suivant
S36-S38 ajoute incrémentalement sans toucher à app.py.

Tests S35 dédiés (17 nouveaux)
------------------------------
- WebAppStateDataclass : frozen, defaults pour version.
- CreateApp : returns FastAPI instance, state attaché à
app.state.picarones, rejette non-WebAppState (TypeError), chaque
call produit instance indépendante, title/version corrects, /api/docs
et /api/redoc accessibles.
- HealthEndpoint : 200 OK avec {"status": "ok"}, schéma HealthResponse.
- VersionEndpoint : 200 OK avec workspace_root, n_metrics/n_projectors
> 0 (bootstrap par défaut), version reflète state, schéma
VersionResponse.
- SkeletonScope : pas de /api/corpus|benchmark|jobs en S35 (404),
pas de /static/* en S35 (S38).

Tests : 4748 passed, 11 skipped (vs 4731 avant : +17 S35).
Lint : ruff check picarones/ tests/ → All checks passed.

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

README.md CHANGED
@@ -396,7 +396,7 @@ ruff check picarones/ tests/
396
  python -m mypy picarones/core/
397
  ```
398
 
399
- **Test suite**: ~4750 tests, ~3 min on a modern laptop. Coverage
400
  floor at 85% (currently ~87%). The `network` marker excludes tests
401
  requiring live HTTP. A handful of tests depend on optional engines
402
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
 
396
  python -m mypy picarones/core/
397
  ```
398
 
399
+ **Test suite**: ~4760 tests, ~3 min on a modern laptop. Coverage
400
  floor at 85% (currently ~87%). The `network` marker excludes tests
401
  requiring live HTTP. A handful of tests depend on optional engines
402
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
picarones/interfaces/web/__init__.py CHANGED
@@ -1,26 +1,34 @@
1
- """FastAPI appSprint S21.
2
 
3
- Cible : nouvelle implémentation **mince** des routers. Chaque
4
- endpoint = 5-15 lignes max : valide DTO Pydantic, appelle un
5
- service de ``app/``, retourne une réponse.
6
 
7
- Sécurité durcie par défaut au S21 (cohérence avec les 6 P0 du S1) :
 
 
 
 
 
 
 
 
8
 
9
- - CSRF activé par défaut (plus de ``PICARONES_CSRF_REQUIRED``
10
- opt-in).
11
- - CSP sans ``'unsafe-inline'`` (refactor JS pour supprimer les
12
- ``onclick=`` inline).
13
- - Cookie ``Secure`` détecté automatiquement (header
14
- ``X-Forwarded-Proto`` + liste de proxies de confiance).
15
- - Rate limit sur IP **réelle** (proxy chain validée), plus
16
- d'``X-Forwarded-For`` aveugle.
17
- - Workspaces isolés par session via ``WorkspaceManager``.
18
-
19
- Le code de ``picarones.web`` reste en place jusqu'au S22 — au S21
20
- on construit la nouvelle SPA en parallèle, au S22 on bascule
21
- ``picarones`` script entry et on supprime l'ancien ``web/``.
22
  """
23
 
24
  from __future__ import annotations
25
 
26
- __all__: list[str] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Interface web FastAPI Sprints S35-S38.
2
 
3
+ Squelette FastAPI **natif** au nouveau monde, écrit pour consommer
4
+ directement les services applicatifs du Sprint S17+ via DI explicite.
5
+ **Pas un shim** sur le legacy ``picarones.web.app``.
6
 
7
+ Architecture
8
+ ------------
9
+ - ``app.py`` (S35) : factory ``create_app(WebAppState)`` qui
10
+ produit une instance FastAPI consommant les services injectés.
11
+ Endpoints squelette ``/health`` et ``/version``.
12
+ - (S36) routers/corpus.py : import ZIP, listing, validation.
13
+ - (S36) routers/benchmark.py : démarrage/lecture d'un run.
14
+ - (S37) routers/jobs.py : queue + persistance SQLite + cancellation.
15
+ - (S38) ui.py : Jinja2 templates + static + i18n.
16
 
17
+ Le legacy ``picarones.web.app`` reste exposé jusqu'au S46.
 
 
 
 
 
 
 
 
 
 
 
 
18
  """
19
 
20
  from __future__ import annotations
21
 
22
+ from picarones.interfaces.web.app import (
23
+ HealthResponse,
24
+ VersionResponse,
25
+ WebAppState,
26
+ create_app,
27
+ )
28
+
29
+ __all__ = [
30
+ "HealthResponse",
31
+ "VersionResponse",
32
+ "WebAppState",
33
+ "create_app",
34
+ ]
picarones/interfaces/web/app.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``create_app`` — Sprint A14-S35.
2
+
3
+ Squelette FastAPI du nouveau monde. **Pas un shim** sur le legacy
4
+ ``picarones.web.app`` — c'est une app neuve, écrite pour consommer
5
+ directement les services du Sprint S17+ (``BenchmarkService``,
6
+ ``RegistryService``, ``RunOrchestrator``, ``WorkspaceManager``,
7
+ ``CorpusService``).
8
+
9
+ Le legacy ``picarones.web.app`` reste en place jusqu'au S46.
10
+
11
+ Architecture
12
+ ------------
13
+ - ``create_app(app_state) → FastAPI`` : factory qui construit l'app
14
+ avec les services injectés. Pas de singleton global — chaque
15
+ ``create_app`` produit une instance indépendante.
16
+ - ``WebAppState`` : container immuable des services injectés
17
+ (services + workspace root + version).
18
+ - Endpoint ``GET /health`` : liveness probe pour Docker / k8s.
19
+ - Endpoint ``GET /version`` : version + flags (mode public, etc.).
20
+ - Endpoints corpus/benchmark/jobs : ajoutés aux S36-S37 via routers
21
+ dédiés.
22
+
23
+ Anti-sur-ingénierie
24
+ -------------------
25
+ - Pas de middleware CSP/CSRF dans S35 — ajoutés au S38 quand on
26
+ servira des templates HTML (le squelette S35 est API-only).
27
+ - Pas de lifespan (rien à initialiser au démarrage — les services
28
+ sont injectés déjà construits).
29
+ - Pas de mount static (S38).
30
+ - Pas de jobs queue (S37).
31
+
32
+ Chaque sprint S36-S38 ajoute incrémentalement sans toucher au
33
+ squelette : on monte des routers, on attache des middlewares, on
34
+ mount des fichiers statiques.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from dataclasses import dataclass
40
+
41
+ from fastapi import FastAPI
42
+ from pydantic import BaseModel
43
+
44
+ from picarones.app.services import (
45
+ BenchmarkService,
46
+ CorpusService,
47
+ RegistryService,
48
+ RunOrchestrator,
49
+ WorkspaceManager,
50
+ )
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class WebAppState:
55
+ """Container immuable des services injectés dans l'app web.
56
+
57
+ Attributes
58
+ ----------
59
+ workspace:
60
+ ``WorkspaceManager`` du run en cours.
61
+ registry:
62
+ ``RegistryService`` (registres de métriques + projecteurs
63
+ pré-bootstrap).
64
+ corpus:
65
+ ``CorpusService`` (import ZIP, détection patterns).
66
+ benchmark:
67
+ ``BenchmarkService`` (orchestration runner + vues +
68
+ persistance).
69
+ orchestrator:
70
+ ``RunOrchestrator`` (workflow YAML → bench → HTML report).
71
+ version:
72
+ Version du code Picarones à afficher dans
73
+ ``GET /version``.
74
+
75
+ Notes
76
+ -----
77
+ Frozen : aucun service ne change de référence après le démarrage
78
+ de l'app. Pour reconstruire l'état (test isolé), créer une
79
+ nouvelle ``WebAppState``.
80
+ """
81
+
82
+ workspace: WorkspaceManager
83
+ registry: RegistryService
84
+ corpus: CorpusService
85
+ benchmark: BenchmarkService
86
+ orchestrator: RunOrchestrator
87
+ version: str = "1.0.0"
88
+
89
+
90
+ class HealthResponse(BaseModel):
91
+ """Schéma JSON pour ``GET /health``."""
92
+
93
+ status: str = "ok"
94
+
95
+
96
+ class VersionResponse(BaseModel):
97
+ """Schéma JSON pour ``GET /version``."""
98
+
99
+ version: str
100
+ workspace_root: str
101
+ n_metrics: int
102
+ n_projectors: int
103
+
104
+
105
+ def create_app(state: WebAppState) -> FastAPI:
106
+ """Construit une instance FastAPI consommant l'``WebAppState``.
107
+
108
+ Pas de singleton global : chaque appel produit une nouvelle app
109
+ indépendante. Permet aux tests d'instancier des apps avec des
110
+ services mockés sans interférence avec d'autres tests.
111
+
112
+ Parameters
113
+ ----------
114
+ state:
115
+ ``WebAppState`` immuable injectée dans tous les endpoints
116
+ via ``Request.app.state.picarones``.
117
+
118
+ Returns
119
+ -------
120
+ FastAPI
121
+ Instance prête à être lancée par ``uvicorn`` ou consommée
122
+ par ``TestClient``.
123
+ """
124
+ if not isinstance(state, WebAppState):
125
+ raise TypeError(
126
+ f"create_app : state doit être WebAppState, "
127
+ f"reçu {type(state).__name__}.",
128
+ )
129
+
130
+ app = FastAPI(
131
+ title="Picarones",
132
+ description=(
133
+ "Plateforme de benchmark OCR/HTR pour documents patrimoniaux. "
134
+ "API du nouveau monde (Sprint A14-S35)."
135
+ ),
136
+ version=state.version,
137
+ docs_url="/api/docs",
138
+ redoc_url="/api/redoc",
139
+ )
140
+
141
+ # On stocke l'état dans app.state.picarones pour permettre aux
142
+ # endpoints (S36+) d'y accéder via Request.app.state.picarones
143
+ # — namespace explicite pour ne pas collisionner avec d'autres
144
+ # extensions FastAPI.
145
+ app.state.picarones = state
146
+
147
+ # ──────────────────────────────────────────────────────────────
148
+ # Endpoints squelette (sondes santé/version)
149
+ # ──────────────────────────────────────────────────────────────
150
+
151
+ @app.get("/health", response_model=HealthResponse)
152
+ async def health() -> HealthResponse:
153
+ """Liveness probe — toujours ``200 OK`` si l'app a démarré.
154
+
155
+ Pas de dépendance aux services backends : on veut détecter
156
+ un crash de l'app, pas un crash transitoire d'un service.
157
+ """
158
+ return HealthResponse(status="ok")
159
+
160
+ @app.get("/version", response_model=VersionResponse)
161
+ async def version() -> VersionResponse:
162
+ """Affiche la version du code et un compte rapide des
163
+ registres pour vérifier que le bootstrap a bien eu lieu."""
164
+ return VersionResponse(
165
+ version=state.version,
166
+ workspace_root=str(state.workspace.root),
167
+ n_metrics=len(state.registry.metrics),
168
+ n_projectors=len(state.registry.projectors),
169
+ )
170
+
171
+ return app
172
+
173
+
174
+ __all__ = [
175
+ "HealthResponse",
176
+ "VersionResponse",
177
+ "WebAppState",
178
+ "create_app",
179
+ ]
tests/interfaces/__init__.py ADDED
File without changes
tests/interfaces/web/__init__.py ADDED
File without changes
tests/interfaces/web/test_sprint_a14_s35_web_app.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint A14-S35 — squelette FastAPI ``interfaces/web``.
2
+
3
+ Tests du squelette FastAPI natif qui consomme les services
4
+ applicatifs du Sprint S17+.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from unittest.mock import MagicMock
11
+
12
+ import pytest
13
+ from fastapi import FastAPI
14
+ from fastapi.testclient import TestClient
15
+
16
+ from picarones.app.services import (
17
+ BenchmarkService,
18
+ CorpusService,
19
+ RegistryService,
20
+ RunOrchestrator,
21
+ WorkspaceManager,
22
+ )
23
+ from picarones.interfaces.web import (
24
+ HealthResponse,
25
+ VersionResponse,
26
+ WebAppState,
27
+ create_app,
28
+ )
29
+
30
+
31
+ # ──────────────────────────────────────────────────────────────────────
32
+ # Helpers
33
+ # ──────────────────────────────────────────────────────────────────────
34
+
35
+
36
+ def _make_state(tmp_path: Path) -> WebAppState:
37
+ """Construit un ``WebAppState`` avec services réels (registres
38
+ bootstrappés, workspace temporaire)."""
39
+ workspace = WorkspaceManager(
40
+ base_dir=tmp_path,
41
+ session_id="test_session",
42
+ )
43
+ registry = RegistryService.bootstrap_defaults()
44
+
45
+ # Pour les tests S35 squelette, on n'a pas besoin de services
46
+ # complètement fonctionnels — des MagicMock conviennent puisque
47
+ # les endpoints squelette ne les invoquent pas.
48
+ corpus = MagicMock(spec=CorpusService)
49
+ benchmark = MagicMock(spec=BenchmarkService)
50
+ orchestrator = MagicMock(spec=RunOrchestrator)
51
+
52
+ return WebAppState(
53
+ workspace=workspace,
54
+ registry=registry,
55
+ corpus=corpus,
56
+ benchmark=benchmark,
57
+ orchestrator=orchestrator,
58
+ version="1.0.0-s35-test",
59
+ )
60
+
61
+
62
+ # ──────────────────────────────────────────────────────────────────────
63
+ # WebAppState dataclass
64
+ # ──────────────────────────────────────────────────────────────────────
65
+
66
+
67
+ class TestWebAppStateDataclass:
68
+ def test_frozen(self, tmp_path: Path) -> None:
69
+ state = _make_state(tmp_path)
70
+ with pytest.raises(Exception): # FrozenInstanceError
71
+ state.version = "modified" # type: ignore[misc]
72
+
73
+ def test_default_version(self, tmp_path: Path) -> None:
74
+ workspace = WorkspaceManager(base_dir=tmp_path, session_id="test")
75
+ registry = RegistryService.bootstrap_defaults()
76
+ state = WebAppState(
77
+ workspace=workspace,
78
+ registry=registry,
79
+ corpus=MagicMock(),
80
+ benchmark=MagicMock(),
81
+ orchestrator=MagicMock(),
82
+ )
83
+ assert state.version == "1.0.0"
84
+
85
+
86
+ # ──────────────────────────────────────────────────────────────────────
87
+ # create_app factory
88
+ # ──────────────────────────────────────────────────────────────────────
89
+
90
+
91
+ class TestCreateApp:
92
+ def test_returns_fastapi_instance(self, tmp_path: Path) -> None:
93
+ state = _make_state(tmp_path)
94
+ app = create_app(state)
95
+ assert isinstance(app, FastAPI)
96
+
97
+ def test_state_attached_to_app(self, tmp_path: Path) -> None:
98
+ state = _make_state(tmp_path)
99
+ app = create_app(state)
100
+ assert app.state.picarones is state
101
+
102
+ def test_rejects_non_state_input(self) -> None:
103
+ with pytest.raises(TypeError, match="WebAppState"):
104
+ create_app("not a state") # type: ignore[arg-type]
105
+
106
+ def test_each_call_yields_new_app(self, tmp_path: Path) -> None:
107
+ """Pas de singleton global — chaque create_app produit une
108
+ instance indépendante."""
109
+ state = _make_state(tmp_path)
110
+ app1 = create_app(state)
111
+ app2 = create_app(state)
112
+ assert app1 is not app2
113
+
114
+ def test_app_has_title_and_version(self, tmp_path: Path) -> None:
115
+ state = _make_state(tmp_path)
116
+ app = create_app(state)
117
+ assert app.title == "Picarones"
118
+ assert app.version == state.version
119
+
120
+ def test_openapi_doc_endpoints_available(self, tmp_path: Path) -> None:
121
+ state = _make_state(tmp_path)
122
+ app = create_app(state)
123
+ client = TestClient(app)
124
+ # /api/docs et /api/redoc doivent exister.
125
+ r_docs = client.get("/api/docs")
126
+ r_redoc = client.get("/api/redoc")
127
+ assert r_docs.status_code == 200
128
+ assert r_redoc.status_code == 200
129
+
130
+
131
+ # ──────────────────────────────────────────────────────────────────────
132
+ # /health endpoint
133
+ # ──────────────────────────────────────────────────────────────────────
134
+
135
+
136
+ class TestHealthEndpoint:
137
+ def test_health_returns_200_ok(self, tmp_path: Path) -> None:
138
+ state = _make_state(tmp_path)
139
+ app = create_app(state)
140
+ client = TestClient(app)
141
+ response = client.get("/health")
142
+ assert response.status_code == 200
143
+ body = response.json()
144
+ assert body == {"status": "ok"}
145
+
146
+ def test_health_response_schema(self, tmp_path: Path) -> None:
147
+ # Le schéma HealthResponse doit valider {"status": "ok"}.
148
+ h = HealthResponse(status="ok")
149
+ assert h.status == "ok"
150
+
151
+
152
+ # ──────────────────────────────────────────────────────────────────────
153
+ # /version endpoint
154
+ # ──────────────────────────────────────────────────────────────────────
155
+
156
+
157
+ class TestVersionEndpoint:
158
+ def test_version_returns_200_ok(self, tmp_path: Path) -> None:
159
+ state = _make_state(tmp_path)
160
+ app = create_app(state)
161
+ client = TestClient(app)
162
+ response = client.get("/version")
163
+ assert response.status_code == 200
164
+
165
+ def test_version_includes_workspace_root(self, tmp_path: Path) -> None:
166
+ state = _make_state(tmp_path)
167
+ app = create_app(state)
168
+ client = TestClient(app)
169
+ response = client.get("/version")
170
+ body = response.json()
171
+ assert "workspace_root" in body
172
+ # Le root doit pointer dans tmp_path.
173
+ assert tmp_path.name in body["workspace_root"]
174
+
175
+ def test_version_includes_n_metrics_and_projectors(
176
+ self, tmp_path: Path,
177
+ ) -> None:
178
+ state = _make_state(tmp_path)
179
+ app = create_app(state)
180
+ client = TestClient(app)
181
+ response = client.get("/version")
182
+ body = response.json()
183
+ # Bootstrap par défaut enregistre cer/wer/mer/wil + autres → > 0.
184
+ assert body["n_metrics"] > 0
185
+ assert body["n_projectors"] > 0
186
+
187
+ def test_version_string_matches_state(self, tmp_path: Path) -> None:
188
+ state = _make_state(tmp_path)
189
+ app = create_app(state)
190
+ client = TestClient(app)
191
+ response = client.get("/version")
192
+ body = response.json()
193
+ assert body["version"] == "1.0.0-s35-test"
194
+
195
+ def test_version_response_schema(self) -> None:
196
+ v = VersionResponse(
197
+ version="1.0.0",
198
+ workspace_root="/tmp/test",
199
+ n_metrics=5,
200
+ n_projectors=3,
201
+ )
202
+ assert v.version == "1.0.0"
203
+ assert v.n_metrics == 5
204
+
205
+
206
+ # ──────────────────────────────────────────────────────────────────────
207
+ # Pas de routers métier en S35
208
+ # ──────────────────────────────────────────────────────────────────────
209
+
210
+
211
+ class TestSkeletonScope:
212
+ def test_no_corpus_endpoint_yet(self, tmp_path: Path) -> None:
213
+ """S35 ne contient pas encore les endpoints métier (S36+)."""
214
+ state = _make_state(tmp_path)
215
+ app = create_app(state)
216
+ client = TestClient(app)
217
+ # Aucun endpoint corpus / benchmark / jobs n'est attendu en S35.
218
+ for path in ("/api/corpus", "/api/benchmark", "/api/jobs"):
219
+ response = client.get(path)
220
+ assert response.status_code == 404, (
221
+ f"Endpoint {path!r} ne devrait pas exister en S35 — "
222
+ "viendra en S36-S37."
223
+ )
224
+
225
+ def test_no_static_mount_yet(self, tmp_path: Path) -> None:
226
+ """S35 ne sert pas encore de fichiers statiques (S38)."""
227
+ state = _make_state(tmp_path)
228
+ app = create_app(state)
229
+ client = TestClient(app)
230
+ response = client.get("/static/css/main.css")
231
+ assert response.status_code == 404