scholarr/app/main.py
Justin Visser bf04c77aa9 ci: add ruff linting and mypy type checking
Add ruff and mypy to dev dependencies with configuration in pyproject.toml.
Add a lint CI job that runs ruff check, ruff format --check, and mypy.
Auto-fix import sorting and formatting across the codebase. Exclude
alembic/versions from linting (auto-generated migrations). Ignore B008
(FastAPI Depends pattern) and RUF001 (unicode in user-facing strings).

21 ruff lint errors and 50 mypy errors remain for manual review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 22:11:41 +01:00

164 lines
6 KiB
Python

from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app.api.errors import register_api_exception_handlers
from app.api.media import router as media_router
from app.api.router import router as api_router
from app.db.session import check_database, close_engine
from app.http.middleware import (
RequestLoggingMiddleware,
SecurityHeadersMiddleware,
parse_skip_paths,
)
from app.logging_config import configure_logging, parse_redact_fields
from app.logging_utils import structured_log
from app.security.csrf import CSRFMiddleware
from app.services.domains.ingestion.scheduler import SchedulerService
from app.settings import settings
logger = logging.getLogger(__name__)
BUILD_MARKER = "2026-02-19.phase2.direct-pdf-import-export-dashboard-sync"
configure_logging(
level=settings.log_level,
log_format=settings.log_format,
redact_fields=parse_redact_fields(settings.log_redact_fields),
include_uvicorn_access=settings.log_uvicorn_access,
)
scheduler_service = SchedulerService(
enabled=settings.scheduler_enabled,
tick_seconds=settings.scheduler_tick_seconds,
network_error_retries=settings.ingestion_network_error_retries,
retry_backoff_seconds=settings.ingestion_retry_backoff_seconds,
max_pages_per_scholar=settings.ingestion_max_pages_per_scholar,
page_size=settings.ingestion_page_size,
continuation_queue_enabled=settings.ingestion_continuation_queue_enabled,
continuation_base_delay_seconds=settings.ingestion_continuation_base_delay_seconds,
continuation_max_delay_seconds=settings.ingestion_continuation_max_delay_seconds,
continuation_max_attempts=settings.ingestion_continuation_max_attempts,
queue_batch_size=settings.scheduler_queue_batch_size,
)
@asynccontextmanager
async def lifespan(_: FastAPI):
structured_log(
logger,
"info",
"app.startup_build_marker",
build_marker=BUILD_MARKER,
frontend_enabled=settings.frontend_enabled,
scheduler_enabled=settings.scheduler_enabled,
log_format=settings.log_format,
)
from sqlalchemy import text
from app.db.session import get_session_factory
try:
session_factory = get_session_factory()
async with session_factory() as session:
await session.execute(
text("UPDATE crawl_runs SET status = 'failed' WHERE status::text IN ('running', 'resolving')")
)
await session.commit()
structured_log(logger, "info", "app.startup_orphaned_runs_cleaned")
except Exception as e:
logger.error(f"Failed to clean orphaned runs at startup: {e}")
await scheduler_service.start()
yield
await scheduler_service.stop()
await close_engine()
app = FastAPI(title=settings.app_name, lifespan=lifespan)
register_api_exception_handlers(app)
app.add_middleware(CSRFMiddleware)
app.add_middleware(
SessionMiddleware,
secret_key=settings.session_secret_key,
same_site="lax",
https_only=settings.session_cookie_secure,
)
app.add_middleware(
RequestLoggingMiddleware,
log_requests=settings.log_requests,
skip_paths=parse_skip_paths(settings.log_request_skip_paths),
)
app.add_middleware(
SecurityHeadersMiddleware,
enabled=settings.security_headers_enabled,
x_content_type_options=settings.security_x_content_type_options,
x_frame_options=settings.security_x_frame_options,
referrer_policy=settings.security_referrer_policy,
permissions_policy=settings.security_permissions_policy,
cross_origin_opener_policy=settings.security_cross_origin_opener_policy,
cross_origin_resource_policy=settings.security_cross_origin_resource_policy,
content_security_policy_enabled=settings.security_csp_enabled,
content_security_policy=settings.security_csp_policy,
content_security_policy_docs=settings.security_csp_docs_policy,
content_security_policy_report_only=settings.security_csp_report_only,
strict_transport_security_enabled=settings.security_strict_transport_security_enabled,
strict_transport_security_max_age=settings.security_strict_transport_security_max_age,
strict_transport_security_include_subdomains=(settings.security_strict_transport_security_include_subdomains),
strict_transport_security_preload=settings.security_strict_transport_security_preload,
)
app.include_router(api_router)
app.include_router(media_router)
@app.get("/healthz")
async def healthz() -> dict[str, str]:
if await check_database():
return {"status": "ok"}
raise HTTPException(status_code=500, detail="database unavailable")
def _configure_frontend_routes(application: FastAPI) -> None:
if not settings.frontend_enabled:
return
dist_dir = Path(settings.frontend_dist_dir)
index_file = dist_dir / "index.html"
if not index_file.is_file():
return
assets_dir = dist_dir / "assets"
if assets_dir.is_dir():
application.mount("/assets", StaticFiles(directory=assets_dir), name="frontend-assets")
@application.get("/", include_in_schema=False)
async def frontend_index() -> FileResponse:
return FileResponse(index_file)
@application.get("/{full_path:path}", include_in_schema=False)
async def frontend_entry(full_path: str) -> FileResponse:
normalized = full_path.lstrip("/")
if normalized in {"healthz", "api"} or normalized.startswith("api/"):
raise HTTPException(status_code=404, detail="Not Found")
candidate = (dist_dir / normalized).resolve()
try:
candidate.relative_to(dist_dir.resolve())
except ValueError as error:
raise HTTPException(status_code=404, detail="Not Found") from error
if candidate.is_file():
return FileResponse(candidate)
return FileResponse(index_file)
_configure_frontend_routes(app)