"""`fintracker` command line: serve / worker / create-user / openapi / sync.""" from __future__ import annotations import asyncio import json import logging from pathlib import Path from typing import Annotated import typer from fintracker.config import get_settings app = typer.Typer(no_args_is_help=True, add_completion=False) def _logging() -> None: logging.basicConfig( level=get_settings().log_level.upper(), format="%(asctime)s %(levelname)-5s %(name)s: %(message)s", ) @app.command() def serve( host: str = "127.0.0.1", port: int = 8000, reload: bool = typer.Option(False, "--reload", help="dev only"), ) -> None: """Run the HTTP API.""" import uvicorn uvicorn.run("fintracker.api.app:create_app", factory=True, host=host, port=port, reload=reload) @app.command() def worker() -> None: """Run the scheduled-sync worker (one instance per deployment).""" _logging() from fintracker.worker.scheduler import serve_forever asyncio.run(serve_forever()) @app.command("create-user") def create_user( email: str, password: str = typer.Option(..., prompt=True, hide_input=True, confirmation_prompt=True), ) -> None: """Create (or reset the password of) the application user.""" from sqlalchemy import select from fintracker.api.security import hash_password from fintracker.db import get_sessionmaker, reset_engine from fintracker.models import AppUser async def _run() -> None: async with get_sessionmaker()() as session: user = ( await session.execute(select(AppUser).where(AppUser.email == email.lower())) ).scalar_one_or_none() if user is None: session.add(AppUser(email=email.lower(), password_hash=hash_password(password))) action = "created" else: user.password_hash = hash_password(password) action = "password reset" await session.commit() await reset_engine() typer.echo(f"{email.lower()}: {action}") asyncio.run(_run()) @app.command() def openapi(out: Annotated[Path, typer.Argument()] = Path("openapi.json")) -> None: """Write the OpenAPI schema to a file (committed as openapi/openapi.json).""" from fintracker.api.app import create_app schema = create_app().openapi() out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(schema, indent=2, ensure_ascii=False, sort_keys=True) + "\n") typer.echo(f"wrote {out}") @app.command() def sync(source: str) -> None: """Run one source sync in-process (uses the same lock as the worker).""" _logging() from fintracker.db import reset_engine from fintracker.sources import registry from fintracker.worker.runner import Skipped, run_source if source not in registry.names(): raise typer.BadParameter(f"unknown source {source!r}; known: {registry.names()}") async def _run() -> None: try: run = await run_source(source, triggered_by="cli") except Skipped: typer.echo(f"{source}: already running elsewhere") raise typer.Exit(1) from None finally: await reset_engine() typer.echo(f"{source}: {run.status.value} counts={run.counts} warnings={run.warnings}") if run.error: typer.echo(run.error, err=True) raise typer.Exit(1) asyncio.run(_run()) metrics_app = typer.Typer(help="Precomputed metrics.") app.add_typer(metrics_app, name="metrics") @metrics_app.command("refresh") def metrics_refresh() -> None: """Rebuild every metric_* table from core data (idempotent).""" _logging() from fintracker.db import get_sessionmaker, reset_engine from fintracker.metrics.refresh import refresh_all async def _run() -> None: try: async with get_sessionmaker()() as session: entry = await refresh_all(session, trigger="cli") finally: await reset_engine() if entry.error: typer.echo(entry.error, err=True) raise typer.Exit(1) typer.echo(f"metrics refreshed at {entry.finished_at}") asyncio.run(_run()) if __name__ == "__main__": app()