Spaces:
Running
Running
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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 18 |
gallica_sru_base_url: str = "https://gallica.bnf.fr/SRU"
|
| 19 |
-
bodleian_use_fixtures: bool =
|
| 20 |
bodleian_api_base_url: str = "https://api.bodleian.ox.ac.uk/iiif"
|
| 21 |
-
europeana_use_fixtures: bool =
|
| 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
|