maribakulj commited on
Commit
84e44a9
·
1 Parent(s): b3f9c2a

Add startup import guardrails for HF Space env robustness

Browse files
app/backend/app/config/settings.py CHANGED
@@ -1,9 +1,45 @@
1
  """Application settings loaded from environment variables."""
2
 
3
- from pydantic import AliasChoices, Field
 
 
 
 
4
  from pydantic_settings import BaseSettings, SettingsConfigDict
5
 
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  class Settings(BaseSettings):
8
  """Runtime settings for the backend application."""
9
 
@@ -12,13 +48,15 @@ class Settings(BaseSettings):
12
  debug: bool = False
13
  request_timeout_seconds: float = Field(default=8.0, gt=0)
14
  cors_allow_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"])
 
 
15
 
16
- # Connector settings
17
- gallica_use_fixtures: bool = False
18
  gallica_sru_base_url: str = "https://gallica.bnf.fr/SRU"
19
- bodleian_use_fixtures: bool = False
20
  bodleian_api_base_url: str = "https://api.bodleian.ox.ac.uk/iiif"
21
- europeana_use_fixtures: bool = False
22
  europeana_api_base_url: str = "https://api.europeana.eu/record/v2/search.json"
23
  europeana_api_key: str = Field(
24
  default="",
@@ -31,6 +69,23 @@ class Settings(BaseSettings):
31
  capability_probe_timeout_seconds: float = Field(default=3.0, gt=0)
32
  capability_probe_cache_ttl_seconds: int = Field(default=300, ge=0)
33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
  model_config = SettingsConfigDict(env_prefix="CLAFOUTIS_", extra="ignore")
35
 
36
 
 
1
  """Application settings loaded from environment variables."""
2
 
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from pydantic import AliasChoices, Field, field_validator
8
  from pydantic_settings import BaseSettings, SettingsConfigDict
9
 
10
 
11
+ _TRUTHY_TOKENS = {"1", "true", "yes", "on", "enable", "enabled"}
12
+ _FALSY_TOKENS = {"0", "false", "no", "off", "disable", "disabled"}
13
+
14
+
15
+ def _parse_bool_with_fallback(value: object, default: bool) -> bool:
16
+ """Parse permissive boolean-like values and fall back to default when ambiguous."""
17
+
18
+ if isinstance(value, bool):
19
+ return value
20
+ if isinstance(value, (int, float)):
21
+ return bool(value)
22
+ if not isinstance(value, str):
23
+ return default
24
+
25
+ normalized = value.strip().lower()
26
+ if normalized in _TRUTHY_TOKENS:
27
+ return True
28
+ if normalized in _FALSY_TOKENS:
29
+ return False
30
+
31
+ tokens = re.findall(r"[a-z0-9]+", normalized)
32
+ has_truthy = any(token in _TRUTHY_TOKENS for token in tokens)
33
+ has_falsy = any(token in _FALSY_TOKENS for token in tokens)
34
+
35
+ if has_truthy and not has_falsy:
36
+ return True
37
+ if has_falsy and not has_truthy:
38
+ return False
39
+
40
+ return default
41
+
42
+
43
  class Settings(BaseSettings):
44
  """Runtime settings for the backend application."""
45
 
 
48
  debug: bool = False
49
  request_timeout_seconds: float = Field(default=8.0, gt=0)
50
  cors_allow_origins: list[str] = Field(default_factory=lambda: ["http://localhost:5173"])
51
+ serve_frontend: bool = True
52
+ frontend_dist_dir: str = "app/frontend/dist"
53
 
54
+ # Connector settings (fixture-first for MVP demo stability)
55
+ gallica_use_fixtures: bool = True
56
  gallica_sru_base_url: str = "https://gallica.bnf.fr/SRU"
57
+ bodleian_use_fixtures: bool = True
58
  bodleian_api_base_url: str = "https://api.bodleian.ox.ac.uk/iiif"
59
+ europeana_use_fixtures: bool = True
60
  europeana_api_base_url: str = "https://api.europeana.eu/record/v2/search.json"
61
  europeana_api_key: str = Field(
62
  default="",
 
69
  capability_probe_timeout_seconds: float = Field(default=3.0, gt=0)
70
  capability_probe_cache_ttl_seconds: int = Field(default=300, ge=0)
71
 
72
+ @field_validator(
73
+ "debug",
74
+ "serve_frontend",
75
+ "gallica_use_fixtures",
76
+ "bodleian_use_fixtures",
77
+ "europeana_use_fixtures",
78
+ "enable_capability_probing",
79
+ "capability_probe_use_fixtures",
80
+ mode="before",
81
+ )
82
+ @classmethod
83
+ def normalize_bool_env_values(cls, value: object, info):
84
+ """Normalize permissive boolean-like environment values for robust deployments."""
85
+
86
+ default = bool(cls.model_fields[info.field_name].default)
87
+ return _parse_bool_with_fallback(value=value, default=default)
88
+
89
  model_config = SettingsConfigDict(env_prefix="CLAFOUTIS_", extra="ignore")
90
 
91
 
app/backend/app/main.py CHANGED
@@ -1,8 +1,11 @@
1
  """FastAPI application entrypoint for backend lot 1."""
2
 
 
 
3
  from fastapi import FastAPI, Request
4
  from fastapi.middleware.cors import CORSMiddleware
5
- from fastapi.responses import JSONResponse
 
6
 
7
  from app.api.router import api_router
8
  from app.config.settings import settings
@@ -38,6 +41,33 @@ def create_app() -> FastAPI:
38
  payload = ErrorResponse(error="application_error", details=str(exc)).model_dump()
39
  return JSONResponse(status_code=500, content=payload)
40
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  return application
42
 
43
 
 
1
  """FastAPI application entrypoint for backend lot 1."""
2
 
3
+ from pathlib import Path
4
+
5
  from fastapi import FastAPI, Request
6
  from fastapi.middleware.cors import CORSMiddleware
7
+ from fastapi.responses import FileResponse, JSONResponse
8
+ from fastapi.staticfiles import StaticFiles
9
 
10
  from app.api.router import api_router
11
  from app.config.settings import settings
 
41
  payload = ErrorResponse(error="application_error", details=str(exc)).model_dump()
42
  return JSONResponse(status_code=500, content=payload)
43
 
44
+ if settings.serve_frontend:
45
+ frontend_dist = Path(settings.frontend_dist_dir)
46
+ index_file = frontend_dist / "index.html"
47
+
48
+ if index_file.exists():
49
+ assets_dir = frontend_dist / "assets"
50
+ if assets_dir.exists():
51
+ application.mount(
52
+ "/assets",
53
+ StaticFiles(directory=str(assets_dir)),
54
+ name="frontend-assets",
55
+ )
56
+
57
+ @application.get("/", include_in_schema=False)
58
+ async def serve_frontend_index() -> FileResponse:
59
+ return FileResponse(index_file)
60
+
61
+ @application.get("/{full_path:path}", include_in_schema=False)
62
+ async def serve_frontend_spa(full_path: str):
63
+ if full_path.startswith("api/"):
64
+ return JSONResponse(status_code=404, content={"detail": "Not Found"})
65
+
66
+ candidate = frontend_dist / full_path
67
+ if candidate.is_file():
68
+ return FileResponse(candidate)
69
+ return FileResponse(index_file)
70
+
71
  return application
72
 
73
 
app/tests/unit/backend/test_frontend_serving.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frontend static serving behavior tests."""
2
+
3
+ from fastapi.testclient import TestClient
4
+
5
+ from app.config.settings import settings
6
+ from app.main import create_app
7
+
8
+
9
+ def test_root_serves_frontend_index_when_configured(tmp_path) -> None:
10
+ """Root path should serve SPA index when frontend serving is enabled."""
11
+
12
+ original_serve_frontend = settings.serve_frontend
13
+ original_frontend_dist_dir = settings.frontend_dist_dir
14
+
15
+ try:
16
+ dist_dir = tmp_path / "dist"
17
+ dist_dir.mkdir()
18
+ (dist_dir / "index.html").write_text("<html><body>Clafoutis UI</body></html>", encoding="utf-8")
19
+
20
+ settings.serve_frontend = True
21
+ settings.frontend_dist_dir = str(dist_dir)
22
+
23
+ client = TestClient(create_app())
24
+ response = client.get("/")
25
+
26
+ assert response.status_code == 200
27
+ assert "Clafoutis UI" in response.text
28
+ finally:
29
+ settings.serve_frontend = original_serve_frontend
30
+ settings.frontend_dist_dir = original_frontend_dist_dir
app/tests/unit/backend/test_settings.py CHANGED
@@ -25,3 +25,50 @@ def test_europeana_api_key_accepts_hf_token_fallback(monkeypatch) -> None:
25
  loaded = Settings()
26
 
27
  assert loaded.europeana_api_key == "hf-secret-token"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  loaded = Settings()
26
 
27
  assert loaded.europeana_api_key == "hf-secret-token"
28
+
29
+
30
+ def test_bodleian_use_fixtures_accepts_descriptive_value(monkeypatch) -> None:
31
+ """Boolean settings should stay robust with descriptive env values from platform UIs."""
32
+
33
+ monkeypatch.setenv(
34
+ "CLAFOUTIS_BODLEIAN_USE_FIXTURES",
35
+ "Use fixture data for Bodleian connector to keep the demo stable",
36
+ )
37
+
38
+ loaded = Settings()
39
+
40
+ assert loaded.bodleian_use_fixtures is True
41
+
42
+
43
+ def test_serve_frontend_accepts_descriptive_value(monkeypatch) -> None:
44
+ """Frontend serving flag should tolerate descriptive values from Space UI."""
45
+
46
+ monkeypatch.setenv("CLAFOUTIS_SERVE_FRONTEND", "Serve the built frontend from the backend")
47
+
48
+ loaded = Settings()
49
+
50
+ assert loaded.serve_frontend is True
51
+
52
+
53
+ def test_fixture_flags_default_to_true_for_mvp(monkeypatch) -> None:
54
+ """Fixture flags should default to true to match MVP fixture-first deployment."""
55
+
56
+ monkeypatch.delenv("CLAFOUTIS_GALLICA_USE_FIXTURES", raising=False)
57
+ monkeypatch.delenv("CLAFOUTIS_BODLEIAN_USE_FIXTURES", raising=False)
58
+ monkeypatch.delenv("CLAFOUTIS_EUROPEANA_USE_FIXTURES", raising=False)
59
+
60
+ loaded = Settings()
61
+
62
+ assert loaded.gallica_use_fixtures is True
63
+ assert loaded.bodleian_use_fixtures is True
64
+ assert loaded.europeana_use_fixtures is True
65
+
66
+
67
+ def test_serve_frontend_accepts_disable_token(monkeypatch) -> None:
68
+ """Explicit disable token should disable frontend serving."""
69
+
70
+ monkeypatch.setenv("CLAFOUTIS_SERVE_FRONTEND", "disabled")
71
+
72
+ loaded = Settings()
73
+
74
+ assert loaded.serve_frontend is False
app/tests/unit/backend/test_startup_import.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Startup import smoke tests for deployment robustness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+
10
+ def test_importing_main_with_descriptive_env_values_does_not_crash() -> None:
11
+ """Importing app.main should not fail even with descriptive HF Space env values."""
12
+
13
+ env = os.environ.copy()
14
+ env["CLAFOUTIS_SERVE_FRONTEND"] = "Serve the built frontend from the backend"
15
+ env["CLAFOUTIS_BODLEIAN_USE_FIXTURES"] = "Use fixture data for Bodleian connector to keep the demo stable"
16
+
17
+ completed = subprocess.run(
18
+ [sys.executable, "-c", "from app.main import app; print(app.title)"],
19
+ cwd="app/backend",
20
+ env=env,
21
+ capture_output=True,
22
+ text=True,
23
+ check=False,
24
+ )
25
+
26
+ assert completed.returncode == 0, completed.stderr
27
+ assert "Clafoutis Backend" in completed.stdout