Files
fin-tracker/backend/src/fintracker/cli.py
T
Dmitry 295438914d feat(backend): каркас — конфиг, движок БД, базовые модели и первая миграция
SQLAlchemy 2 async на asyncpg, Alembic, pydantic-settings. Деньги везде
NUMERIC(24,10) с колонкой валюты рядом (db/base.py), postgres-enum'ы хранят
значения, а не имена, чтобы база читалась так же, как API.

Первая миграция: app_user, account, portfolio, instrument, sync_run, sync_job.
metrics/refresh.py задаёт порядок пересборки metric_* и сериализует параллельные
пересчёты advisory-локом на отдельном соединении: каждый шаг заменяет свою
таблицу целиком, два одновременных прогона затёрли бы друг друга.
2026-09-18 13:43:31 +03:00

141 lines
4.2 KiB
Python

"""`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()