From 91728cba86b238a4dfec233b498270649347c23b Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sat, 15 Aug 2026 09:14:33 +0200 Subject: [PATCH] feat: bootstrap FastAPI configuration --- app/__init__.py | 0 app/config.py | 36 ++++++++++++++++++++++++++++++++++++ app/main.py | 22 ++++++++++++++++++++++ pyproject.toml | 29 +++++++++++++++++++++++++++++ tests/conftest.py | 0 tests/test_config.py | 32 ++++++++++++++++++++++++++++++++ 6 files changed, 119 insertions(+) create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/main.py create mode 100644 pyproject.toml create mode 100644 tests/conftest.py create mode 100644 tests/test_config.py diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..11b7db1 --- /dev/null +++ b/app/config.py @@ -0,0 +1,36 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic import Field, PositiveInt, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False) + + admin_password: str = Field(alias="ADMIN_PASSWORD", min_length=1) + secret_key: str = Field(alias="SECRET_KEY", min_length=16) + credential_encryption_key: str = Field(alias="CREDENTIAL_ENCRYPTION_KEY", min_length=1) + data_dir: Path = Field(default=Path("/data"), alias="DATA_DIR") + database_url: str | None = Field(default=None, alias="DATABASE_URL") + sync_interval_minutes: PositiveInt = Field(default=5, alias="SYNC_INTERVAL_MINUTES") + + @model_validator(mode="after") + def derive_paths(self) -> "Settings": + self.data_dir = self.data_dir.expanduser().resolve() + if self.database_url is None: + self.database_url = f"sqlite:///{self.data_dir / 'app.db'}" + return self + + @property + def tokens_dir(self) -> Path: + return self.data_dir / "tokens" + + @property + def activities_dir(self) -> Path: + return self.data_dir / "activities" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..4931c44 --- /dev/null +++ b/app/main.py @@ -0,0 +1,22 @@ +from fastapi import FastAPI + +from app.config import Settings, get_settings + + +def create_app(settings: Settings | None = None) -> FastAPI: + resolved = settings or get_settings() + resolved.data_dir.mkdir(parents=True, exist_ok=True) + resolved.tokens_dir.mkdir(parents=True, exist_ok=True) + resolved.activities_dir.mkdir(parents=True, exist_ok=True) + + app = FastAPI(title="MyWhoosh Garmin Sync") + app.state.settings = resolved + + @app.get("/healthz") + def healthz() -> dict[str, str]: + return {"status": "ok"} + + return app + + +app = create_app() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e7e292f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=70", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "mywhoosh-garmin-sync" +version = "0.1.0" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115,<1", + "uvicorn[standard]>=0.30,<1", + "sqlalchemy>=2.0,<3", + "pydantic-settings>=2.0,<3", + "cryptography>=43,<50", + "jinja2>=3.1,<4", + "python-multipart>=0.0.9,<1", +] + +[project.optional-dependencies] +test = [ + "pytest>=8,<9", + "httpx>=0.27,<1", +] + +[tool.setuptools.packages] +find = { where = ["."], include = ["app*"] } + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..4fd2fba --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,32 @@ +from pathlib import Path + +from app.config import Settings + + +def test_settings_build_default_data_paths(tmp_path: Path) -> None: + settings = Settings( + ADMIN_PASSWORD="admin-secret", + SECRET_KEY="session-secret-16chars", + CREDENTIAL_ENCRYPTION_KEY="ZmFrZS1rZXktZm9yLXRlc3RzLW11c3QtYmUtNDQtY2hhcnM=", + DATA_DIR=str(tmp_path), + SYNC_INTERVAL_MINUTES=5, + ) + + assert settings.data_dir == tmp_path + assert settings.database_url == f"sqlite:///{tmp_path / 'app.db'}" + assert settings.tokens_dir == tmp_path / "tokens" + assert settings.activities_dir == tmp_path / "activities" + + +def test_sync_interval_must_be_positive(tmp_path: Path) -> None: + try: + Settings( + ADMIN_PASSWORD="admin-secret", + SECRET_KEY="session-secret-16chars", + CREDENTIAL_ENCRYPTION_KEY="ZmFrZS1rZXktZm9yLXRlc3RzLW11c3QtYmUtNDQtY2hhcnM=", + DATA_DIR=str(tmp_path), + SYNC_INTERVAL_MINUTES=0, + ) + except ValueError: + return + raise AssertionError("Expected validation failure for non-positive interval")