US01-02: Establish Application and Database Foundation (#47)

This commit was merged in pull request #47.
This commit is contained in:
2026-07-15 16:33:25 +02:00
parent ffafa308de
commit a723b63d55
22 changed files with 821 additions and 1 deletions

View File

@@ -0,0 +1,9 @@
"""Photo pipeline application package.
Target architecture for the integrated photo-library pipeline (see
INTEGRATED_PIPELINE_CONCEPT.md). US01-02 establishes the foundation: typed
configuration, database engine/session lifecycle, versioned migrations, the
FastAPI application factory, structured logging, and readiness.
"""
__version__ = "0.1.0"

View File

@@ -0,0 +1,35 @@
"""Application management CLI: ``python -m photo_pipeline {serve,migrate}``."""
from __future__ import annotations
import argparse
from typing import Sequence
from photo_pipeline.config import Config
from photo_pipeline.db import run_migrations
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="photo_pipeline")
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("serve", help="Run the API server")
commands.add_parser("migrate", help="Upgrade the database to the latest revision")
args = parser.parse_args(argv)
config = Config.from_env()
config.database_path.parent.mkdir(parents=True, exist_ok=True)
if args.command == "migrate":
run_migrations(config.database_url)
return 0
import uvicorn
from photo_pipeline.api.app import create_app
uvicorn.run(create_app(config), host=config.host, port=config.port)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

41
photo_pipeline/api/app.py Normal file
View File

@@ -0,0 +1,41 @@
"""FastAPI application factory and lifecycle.
Startup runs migrations and opens the database engine; shutdown disposes it so no
connection or engine resource leaks. The engine, session factory, and config live
on ``app.state`` for dependencies to use. The app binds to 127.0.0.1 by default
and exposes the versioned ``/api/v1`` surface; US01-02 ships only health.
"""
from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from photo_pipeline.api.routes import health
from photo_pipeline.config import Config
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
from photo_pipeline.logging import configure_logging
def create_app(config: Config | None = None) -> FastAPI:
config = config or Config.from_env()
configure_logging(config.log_level, config.log_format)
@asynccontextmanager
async def lifespan(app: FastAPI):
config.database_path.parent.mkdir(parents=True, exist_ok=True)
run_migrations(config.database_url)
engine = create_db_engine(config.database_url)
app.state.config = config
app.state.engine = engine
app.state.session_factory = create_session_factory(engine)
try:
yield
finally:
engine.dispose()
app.state.engine = None
app = FastAPI(title="Photo Pipeline", version="0.1.0", lifespan=lifespan)
app.include_router(health.router, prefix="/api/v1")
return app

View File

View File

@@ -0,0 +1,45 @@
"""Liveness and readiness endpoints.
``/health/live`` answers whether the process is up. ``/health/ready`` answers
whether it can serve: the database is reachable and configured correctly (WAL,
foreign keys). Tests and any future orchestration wait on readiness rather than a
sleep. Readiness returns 503 until the checks pass.
"""
from __future__ import annotations
from fastapi import APIRouter, Request, Response
from sqlalchemy import text
router = APIRouter(tags=["health"])
@router.get("/health/live")
def live() -> dict:
return {"status": "alive"}
@router.get("/health/ready")
def ready(request: Request, response: Response) -> dict:
checks: dict[str, object] = {}
ok = True
engine = getattr(request.app.state, "engine", None)
if engine is None:
ok = False
checks["database"] = "unavailable"
else:
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
journal_mode = conn.execute(text("PRAGMA journal_mode")).scalar()
foreign_keys = conn.execute(text("PRAGMA foreign_keys")).scalar()
checks["database"] = "ok"
checks["journal_mode"] = journal_mode
checks["foreign_keys"] = int(foreign_keys)
if str(journal_mode).lower() != "wal" or int(foreign_keys) != 1:
ok = False
except Exception:
ok = False
checks["database"] = "error"
response.status_code = 200 if ok else 503
return {"status": "ready" if ok else "not_ready", "checks": checks}

52
photo_pipeline/config.py Normal file
View File

@@ -0,0 +1,52 @@
"""Typed application configuration.
Values come from ``PHOTO_PIPELINE_*`` environment variables. Secrets use
``SecretStr`` so they are masked in logs, reprs, and model dumps, and are never
returned by the API. Only ``.get_secret_value()`` exposes them, and only where a
real external call needs them.
pydantic-settings would do this too, but a prefix-scan over the declared fields
is a few lines and one fewer dependency.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Mapping
from pydantic import BaseModel, ConfigDict, SecretStr
ENV_PREFIX = "PHOTO_PIPELINE_"
class Config(BaseModel):
model_config = ConfigDict(frozen=True)
data_dir: Path = Path("data")
db_path: Path | None = None # defaults to data_dir/photo_pipeline.db
host: str = "127.0.0.1"
port: int = 8000
log_level: str = "INFO"
log_format: str = "json" # "json" or "text"
vision_api_key: SecretStr | None = None
immich_api_key: SecretStr | None = None
@property
def database_path(self) -> Path:
return self.db_path or (self.data_dir / "photo_pipeline.db")
@property
def database_url(self) -> str:
return f"sqlite:///{self.database_path}"
@classmethod
def from_env(cls, environ: Mapping[str, str] | None = None) -> "Config":
env = os.environ if environ is None else environ
data = {
name: env[ENV_PREFIX + name.upper()]
for name in cls.model_fields
if env.get(ENV_PREFIX + name.upper())
}
return cls(**data)

52
photo_pipeline/db.py Normal file
View File

@@ -0,0 +1,52 @@
"""Database engine, sessions, and migration entry point.
SQLite is opened in WAL mode with foreign keys enforced on every connection
(foreign keys and busy_timeout are per-connection; WAL is persistent but cheap to
re-assert). Migrations run through Alembic so the same versioned schema is used at
startup and in tests. Repository and service code depends only on ``Base`` and the
session factory, never on SQLite specifics, so PostgreSQL stays a future option.
"""
from __future__ import annotations
from pathlib import Path
from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import DeclarativeBase, sessionmaker
_REPO_ROOT = Path(__file__).resolve().parent.parent
class Base(DeclarativeBase):
pass
def create_db_engine(url: str, *, echo: bool = False) -> Engine:
connect_args = {"check_same_thread": False} if url.startswith("sqlite") else {}
engine = create_engine(url, echo=echo, future=True, connect_args=connect_args)
if url.startswith("sqlite"):
event.listen(engine, "connect", _apply_sqlite_pragmas)
return engine
def _apply_sqlite_pragmas(dbapi_connection, _connection_record) -> None:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.close()
def create_session_factory(engine: Engine) -> sessionmaker:
return sessionmaker(bind=engine, expire_on_commit=False, future=True)
def run_migrations(url: str) -> None:
"""Upgrade the database at ``url`` to the latest revision."""
from alembic import command
from alembic.config import Config as AlembicConfig
cfg = AlembicConfig(str(_REPO_ROOT / "alembic.ini"))
cfg.set_main_option("script_location", str(_REPO_ROOT / "migrations"))
cfg.set_main_option("sqlalchemy.url", url)
command.upgrade(cfg, "head")

44
photo_pipeline/logging.py Normal file
View File

@@ -0,0 +1,44 @@
"""Structured logging.
JSON lines carrying ``job_id``, ``asset_id``, and ``operation_id`` when present,
so later stages can correlate work across the API, worker, and jobs. Secrets are
never passed to the logger; ``SecretStr`` masks them even if one slips through.
"""
from __future__ import annotations
import json
import logging
import sys
from datetime import datetime, timezone
CONTEXT_FIELDS = ("job_id", "asset_id", "operation_id")
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": datetime.fromtimestamp(record.created, timezone.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
for field in CONTEXT_FIELDS:
value = getattr(record, field, None)
if value is not None:
payload[field] = value
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload, default=str)
def configure_logging(level: str = "INFO", fmt: str = "json") -> None:
handler = logging.StreamHandler(sys.stderr)
if fmt == "json":
handler.setFormatter(JsonFormatter())
else:
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s"))
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level.upper())

View File

@@ -0,0 +1,9 @@
"""SQLAlchemy persistence models.
Importing this package registers every model on ``Base.metadata``, which the
Alembic environment relies on.
"""
from photo_pipeline.models.assets import Asset, AssetPath
__all__ = ["Asset", "AssetPath"]

View File

@@ -0,0 +1,59 @@
"""Stable asset identity and path history.
A file path is mutable metadata, never identity: ``assets.id`` is a generated
UUID that never changes across moves, renames, or archival. ``asset_paths``
records every path an asset has occupied. ``state_version`` supports optimistic
concurrency; ``created_at``/``updated_at`` are the audit timestamps.
Later-stage columns (canonical link, safety score, archive location) are added by
the migrations of the stories that own them, not speculatively here.
"""
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from photo_pipeline.db import Base
class Asset(Base):
__tablename__ = "assets"
id: Mapped[str] = mapped_column(String, primary_key=True)
original_path: Mapped[str] = mapped_column(String, nullable=False)
current_path: Mapped[str | None] = mapped_column(String, unique=True)
current_sha256: Mapped[str | None] = mapped_column(String)
pixel_sha256: Mapped[str | None] = mapped_column(String)
phash: Mapped[str | None] = mapped_column(String)
hash_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
byte_size: Mapped[int | None] = mapped_column(Integer)
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
missing_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
availability_state: Mapped[str] = mapped_column(String, nullable=False, default="active")
state_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
class AssetPath(Base):
__tablename__ = "asset_paths"
asset_id: Mapped[str] = mapped_column(
ForeignKey("assets.id"), primary_key=True
)
path: Mapped[str] = mapped_column(String, primary_key=True)
valid_from: Mapped[datetime] = mapped_column(
DateTime(timezone=True), primary_key=True
)
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
reason: Mapped[str | None] = mapped_column(String)