Implement fin-hub T24-T26: ingest, runway, coupling, RaaS packaging

Add CSV importers, runway calculator, budget alerts, cross-hub coupling
emitters, finhub CLI, and raas-mvp-packaging docs with full test coverage.
This commit is contained in:
tegwick 2026-07-08 00:59:39 +02:00
parent b6993d4a05
commit 1abbbe85e4
27 changed files with 835 additions and 3 deletions

126
src/fin_hub/cli.py Normal file
View file

@ -0,0 +1,126 @@
"""Fin-hub operator CLI."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from fin_hub.coupling.canon import emit_viability_alert
from fin_hub.coupling.dev_hub import emit_resource_pressure
from fin_hub.coupling.ops_hub import ServiceCostLine, build_service_cost_report
from fin_hub.ingest.anthropic import parse_anthropic_billing_csv
from fin_hub.ingest.cloud import parse_cloud_cost_csv
from fin_hub.ingest.hosteurope import parse_hosteurope_csv
from fin_hub.services.alerts import evaluate_budget_alerts
from fin_hub.services.runway import compute_runway
def _cmd_import_cloud(args: argparse.Namespace) -> int:
rows = parse_cloud_cost_csv(Path(args.path))
print(json.dumps([row.__dict__ for row in rows], indent=2, default=str))
return 0
def _cmd_import_anthropic(args: argparse.Namespace) -> int:
rows = parse_anthropic_billing_csv(Path(args.path))
payload = [
{
**row.__dict__,
"recorded_at": row.recorded_at.isoformat(),
}
for row in rows
]
print(json.dumps(payload, indent=2))
return 0
def _cmd_import_hosteurope(args: argparse.Namespace) -> int:
rows = parse_hosteurope_csv(Path(args.path))
print(json.dumps([row.__dict__ for row in rows], indent=2, default=str))
return 0
def _cmd_runway(args: argparse.Namespace) -> int:
burns = [float(v) for v in args.monthly_burn.split(",") if v.strip()]
runway = compute_runway(
current_balance=args.balance,
monthly_burns=burns,
alert_threshold_months=args.threshold,
currency=args.currency,
)
alerts = evaluate_budget_alerts(
runway=runway,
allocated=args.allocated,
spent=args.spent,
)
report = {
"runway": runway.as_dict(),
"alerts": [alert.as_dict() for alert in alerts],
}
if args.emit:
report["dev_hub"] = emit_resource_pressure(alerts, api_base=args.api_base)
report["canon"] = emit_viability_alert(runway, api_base=args.api_base)
print(json.dumps(report, indent=2, default=str))
return 0
def _cmd_ops_costs(args: argparse.Namespace) -> int:
lines = [
ServiceCostLine(
service_id=row.service_id,
environment=row.environment,
period_month=row.period_month,
amount=row.amount,
currency=row.currency,
source=row.source,
)
for row in parse_hosteurope_csv(Path(args.path))
]
print(json.dumps(build_service_cost_report(lines), indent=2))
return 0
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Fin Hub operator CLI")
sub = parser.add_subparsers(dest="command", required=True)
cloud = sub.add_parser("import-cloud", help="Parse generic cloud cost CSV")
cloud.add_argument("path")
cloud.set_defaults(func=_cmd_import_cloud)
anthropic = sub.add_parser("import-anthropic", help="Parse Anthropic billing CSV")
anthropic.add_argument("path")
anthropic.set_defaults(func=_cmd_import_anthropic)
hosteurope = sub.add_parser("import-hosteurope", help="Parse HostEurope invoice CSV")
hosteurope.add_argument("path")
hosteurope.set_defaults(func=_cmd_import_hosteurope)
runway = sub.add_parser("runway", help="Compute runway and optional alerts")
runway.add_argument("--balance", type=float, required=True)
runway.add_argument("--monthly-burn", required=True, help="Comma-separated monthly burn values")
runway.add_argument("--threshold", type=float, default=3.0)
runway.add_argument("--currency", default="EUR")
runway.add_argument("--allocated", type=float)
runway.add_argument("--spent", type=float)
runway.add_argument("--emit", action="store_true", help="Emit fin→dev and fin→canon signals")
runway.add_argument("--api-base")
runway.set_defaults(func=_cmd_runway)
ops = sub.add_parser("ops-costs", help="Build per-service cost attribution report")
ops.add_argument("path")
ops.set_defaults(func=_cmd_ops_costs)
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())