"""FastAPI interface for Contact database.""" import shutil import subprocess import tempfile from collections.abc import AsyncIterator from contextlib import asynccontextmanager from os import environ from pathlib import Path from typing import Annotated import typer import uvicorn from fastapi import FastAPI from python.api.routers import contact_router, create_frontend_router from python.orm.base import get_postgres_engine def create_app(frontend_dir: Path | None = None) -> FastAPI: """Create and configure the FastAPI application.""" @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Manage application lifespan.""" app.state.engine = get_postgres_engine() yield app.state.engine.dispose() app = FastAPI(title="Contact Database API", lifespan=lifespan) app.include_router(contact_router) if frontend_dir: print(f"Serving frontend from {frontend_dir}") frontend_router = create_frontend_router(frontend_dir) app.include_router(frontend_router) return app cli = typer.Typer() def build_frontend(source_dir: Path | None, cache_dir: Path | None = None) -> Path | None: """Run npm build and copy output to a temp directory. Works even if source_dir is read-only by copying to a temp directory first. Args: source_dir: Frontend source directory. cache_dir: Optional npm cache directory for faster repeated builds. Returns: Path to frontend build directory, or None if no source_dir provided. """ if not source_dir: return None if not source_dir.exists(): error = f"Error: Frontend directory {source_dir} does not exist" raise FileExistsError(error) print(f"Building frontend from {source_dir}...") # Copy source to a writable temp directory build_dir = Path(tempfile.mkdtemp(prefix="contact_frontend_build_")) shutil.copytree(source_dir, build_dir, dirs_exist_ok=True) env = dict(environ) if cache_dir: cache_dir.mkdir(parents=True, exist_ok=True) env["npm_config_cache"] = str(cache_dir) subprocess.run(["npm", "install"], cwd=build_dir, env=env, check=True) subprocess.run(["npm", "run", "build"], cwd=build_dir, env=env, check=True) dist_dir = build_dir / "dist" if not dist_dir.exists(): error = f"Build output not found at {dist_dir}" raise FileNotFoundError(error) output_dir = Path(tempfile.mkdtemp(prefix="contact_frontend_")) shutil.copytree(dist_dir, output_dir, dirs_exist_ok=True) print(f"Frontend built and copied to {output_dir}") shutil.rmtree(build_dir) return output_dir @cli.command() def serve( frontend_dir: Annotated[ Path | None, typer.Option( "--frontend-dir", "-f", help="Frontend source directory. If provided, runs npm build and serves from temp dir.", ), ] = None, host: Annotated[str, typer.Option("--host", "-h", help="Host to bind to")] = "0.0.0.0", port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to")] = 8000, ) -> None: """Start the Contact API server.""" serve_dir = build_frontend(frontend_dir) app = create_app(frontend_dir=serve_dir) uvicorn.run(app, host=host, port=port) if __name__ == "__main__": cli()