from contextlib import asynccontextmanager import asyncio import logging from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from .config import Settings, load_settings from .engine import Engine from .models import Project, TranslationRequest from .protection import BUILTIN_RULES from .provider import Provider, ProviderError, autostart from .storage import Storage API_VERSION = 2 def create_app(settings: Settings | None = None, provider_factory=Provider): settings = settings or load_settings() storage = Storage(settings.database) engine = Engine(storage, provider_factory) startup_errors = {} @asynccontextmanager async def lifespan(app): with storage.exclusive_worker(): for name, config in settings.providers.items(): if config.autostart: try: await autostart(config) except ProviderError as exc: startup_errors[name] = str(exc) logging.getLogger(__name__).warning("%s: %s", name, exc) engine.start() try: yield finally: await engine.stop() app = FastAPI(title="Local Translator", version="0.1.0", lifespan=lifespan, description="Local translation projects, glossary, durable jobs and protected placeholders.") if settings.allowed_origins: # allow_private_network answers Chrome's preflight for https page -> localhost. app.add_middleware(CORSMiddleware, allow_origins=settings.allowed_origins, allow_methods=["GET", "POST", "PUT"], allow_headers=["Content-Type"], expose_headers=["Location"], allow_private_network=True, max_age=600) app.state.storage = storage app.state.engine = engine def project_or_404(identity): project = storage.get_project(identity) if not project: raise HTTPException(404, "Project not found") return project def job_or_404(identity): job = storage.get_job(identity) if not job: raise HTTPException(404, "Job not found") return job def validate_provider(project): if project.provider not in settings.providers: raise HTTPException(422, "Provider is not configured") def enqueue(project_id, request): project = project_or_404(project_id) config = settings.providers.get(project["provider"]) if config is None: raise HTTPException(422, "Provider is not configured") result = storage.create_job(project, config.model_dump(), request.model_dump()) engine.wakeup.set() return result @app.get("/health") async def health(): return {"status": "ok", "api_version": API_VERSION, "worker_running": engine.task is not None and not engine.task.done(), "startup_errors": startup_errors} @app.get("/providers") async def providers(): return {name: {"base_url": config.base_url, "model": config.model} for name, config in settings.providers.items()} @app.get("/providers/{name}/models") async def models(name: str): config = settings.providers.get(name) if config is None: raise HTTPException(404, "Provider not found") provider = provider_factory(config) try: return await provider.request("GET", "models") except ProviderError as exc: raise HTTPException(502, str(exc)) from exc finally: await provider.close() @app.get("/protection-rules") async def protection_rules(): return BUILTIN_RULES @app.post("/projects", status_code=201) async def create_project(project: Project): validate_provider(project) return storage.create_project(project.model_dump()) @app.get("/projects") async def projects(): return storage.projects() @app.get("/projects/{project_id}") async def get_project(project_id: str): return project_or_404(project_id) @app.put("/projects/{project_id}") async def replace_project(project_id: str, project: Project): project_or_404(project_id) validate_provider(project) return storage.update_project(project_id, project.model_dump()) @app.post("/projects/{project_id}/jobs", status_code=202) async def create_job(project_id: str, request: TranslationRequest): return enqueue(project_id, request) @app.post("/projects/{project_id}/translate") async def translate(project_id: str, request: TranslationRequest): job = enqueue(project_id, request) # Bounded HTTP wait; the durable job continues after timeout/disconnect. deadline = asyncio.get_running_loop().time() + 30 while asyncio.get_running_loop().time() < deadline: job = storage.get_job(job["id"]) if job["status"] not in {"queued", "running"}: return job await asyncio.sleep(0.1) from fastapi.responses import JSONResponse return JSONResponse(status_code=202, content=job, headers={"Location": f"/jobs/{job['id']}"}) @app.get("/projects/{project_id}/jobs") async def list_jobs(project_id: str, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0)): project_or_404(project_id) return storage.list_jobs(project_id, limit, offset) @app.get("/jobs/{job_id}") async def get_job(job_id: str): return job_or_404(job_id) @app.post("/jobs/{job_id}/retry", status_code=202) async def retry_job(job_id: str): job = job_or_404(job_id) if job["status"] in {"queued", "running"}: raise HTTPException(409, "Job is already queued or running") if job["status"] == "completed": raise HTTPException(409, "All items are already completed") storage.retry(job_id) engine.wakeup.set() return storage.get_job(job_id) @app.post("/jobs/{job_id}/cancel") async def cancel_job(job_id: str): job = job_or_404(job_id) if job["status"] in {"queued", "running"}: storage.cancel(job_id) return storage.get_job(job_id) return job return app