From 5cddb5573acca6e98831ef338c786b209f23a33d Mon Sep 17 00:00:00 2001 From: ZdenekSrotyr Date: Fri, 10 Apr 2026 19:28:51 +0200 Subject: [PATCH] feat: add da metrics CLI subcommand (list, show, import, export, validate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task 4 — five Typer commands under `da metrics`: - list/show use api_get() to query the server API - import/export/validate access DuckDB directly via MetricRepository and TableRegistryRepository (no server required) Co-Authored-By: Claude Sonnet 4.6 --- cli/commands/metrics.py | 169 ++++++++++++++++++++++++++++++++++++++++ cli/main.py | 2 + tests/test_cli.py | 9 +++ 3 files changed, 180 insertions(+) create mode 100644 cli/commands/metrics.py diff --git a/cli/commands/metrics.py b/cli/commands/metrics.py new file mode 100644 index 0000000..ce0e265 --- /dev/null +++ b/cli/commands/metrics.py @@ -0,0 +1,169 @@ +"""Metrics commands — da metrics.""" + +import json +from pathlib import Path +from typing import Optional + +import typer + +from cli.client import api_get + +metrics_app = typer.Typer(help="Metric definitions — list, show, import, export, validate") + + +@metrics_app.command("list") +def list_metrics( + category: Optional[str] = typer.Option(None, "--category", help="Filter by category"), + as_json: bool = typer.Option(False, "--json", help="Output as JSON"), +): + """List metric definitions from the server.""" + params = {} + if category: + params["category"] = category + + resp = api_get("/api/metrics", params=params) + if resp.status_code != 200: + typer.echo(f"Failed: {resp.json().get('detail', resp.text)}", err=True) + raise typer.Exit(1) + + data = resp.json() + metrics = data if isinstance(data, list) else data.get("metrics", []) + + if as_json: + typer.echo(json.dumps(metrics, indent=2, default=str)) + return + + if not metrics: + typer.echo("No metrics found.") + return + + # Group by category for display + by_category: dict = {} + for m in metrics: + cat = m.get("category", "uncategorized") + by_category.setdefault(cat, []).append(m) + + for cat, items in sorted(by_category.items()): + typer.echo(f"\n[{cat}]") + for m in items: + name = m.get("name", m.get("id", "?")) + display = m.get("display_name", name) + unit = m.get("unit", "") + unit_str = f" ({unit})" if unit else "" + typer.echo(f" {name:30s} {display}{unit_str}") + + +@metrics_app.command("show") +def show_metric( + metric_id: str = typer.Argument(..., help="Metric ID (e.g. revenue/mrr)"), + as_json: bool = typer.Option(False, "--json", help="Output as JSON"), +): + """Show details for a single metric.""" + resp = api_get(f"/api/metrics/{metric_id}") + if resp.status_code == 404: + typer.echo(f"Metric not found: {metric_id}", err=True) + raise typer.Exit(1) + if resp.status_code != 200: + typer.echo(f"Failed: {resp.json().get('detail', resp.text)}", err=True) + raise typer.Exit(1) + + m = resp.json() + + if as_json: + typer.echo(json.dumps(m, indent=2, default=str)) + return + + typer.echo(f"ID: {m.get('id', metric_id)}") + typer.echo(f"Name: {m.get('name', '')}") + typer.echo(f"Display Name: {m.get('display_name', '')}") + typer.echo(f"Category: {m.get('category', '')}") + typer.echo(f"Type: {m.get('type', '')}") + if m.get("unit"): + typer.echo(f"Unit: {m['unit']}") + if m.get("grain"): + typer.echo(f"Grain: {m['grain']}") + if m.get("table_name"): + typer.echo(f"Table: {m['table_name']}") + if m.get("description"): + typer.echo(f"Description: {m['description']}") + if m.get("sql"): + typer.echo(f"SQL:\n {m['sql']}") + if m.get("synonyms"): + typer.echo(f"Synonyms: {', '.join(m['synonyms'])}") + + +@metrics_app.command("import") +def import_metrics( + path: str = typer.Argument(..., help="Path to a YAML file or directory of YAML files"), +): + """Import metric definitions from YAML into DuckDB (direct, no API).""" + from src.db import get_system_db + from src.repositories.metrics import MetricRepository + + import_path = Path(path) + if not import_path.exists(): + typer.echo(f"Path not found: {path}", err=True) + raise typer.Exit(1) + + conn = get_system_db() + try: + repo = MetricRepository(conn) + count = repo.import_from_yaml(import_path) + typer.echo(f"Imported {count} metric(s) from {path}") + finally: + conn.close() + + +@metrics_app.command("export") +def export_metrics( + output_dir: str = typer.Option("./export/", "--dir", help="Output directory for YAML files"), +): + """Export metric definitions from DuckDB to YAML files (direct, no API).""" + from src.db import get_system_db + from src.repositories.metrics import MetricRepository + + conn = get_system_db() + try: + repo = MetricRepository(conn) + count = repo.export_to_yaml(output_dir) + typer.echo(f"Exported {count} metric(s) to {output_dir}") + finally: + conn.close() + + +@metrics_app.command("validate") +def validate_metrics(): + """Check each metric's table reference against registered tables (direct, no API).""" + from src.db import get_system_db + from src.repositories.metrics import MetricRepository + from src.repositories.table_registry import TableRegistryRepository + + conn = get_system_db() + try: + metric_repo = MetricRepository(conn) + registry_repo = TableRegistryRepository(conn) + + metrics = metric_repo.list() + registered_tables = {t["name"] for t in registry_repo.list_all()} + + ok_count = 0 + warn_count = 0 + + for m in metrics: + name = m.get("name", m.get("id", "?")) + table = m.get("table_name") + if not table: + typer.echo(f" OK {name:30s} (no table reference)") + ok_count += 1 + elif table in registered_tables: + typer.echo(f" OK {name:30s} table={table}") + ok_count += 1 + else: + typer.echo(f" WARN {name:30s} table={table} (not registered)") + warn_count += 1 + + typer.echo(f"\nTotal: {len(metrics)} metric(s) — {ok_count} OK, {warn_count} WARN") + if warn_count > 0: + raise typer.Exit(1) + finally: + conn.close() diff --git a/cli/main.py b/cli/main.py index 7a7d2df..427e598 100644 --- a/cli/main.py +++ b/cli/main.py @@ -15,6 +15,7 @@ from cli.commands.skills import skills_app from cli.commands.setup import setup_app from cli.commands.server import server_app from cli.commands.explore import explore_app +from cli.commands.metrics import metrics_app app = typer.Typer( name="da", @@ -33,6 +34,7 @@ app.add_typer(skills_app, name="skills") app.add_typer(setup_app, name="setup") app.add_typer(server_app, name="server") app.add_typer(explore_app, name="explore") +app.add_typer(metrics_app, name="metrics") if __name__ == "__main__": diff --git a/tests/test_cli.py b/tests/test_cli.py index 1c9d362..d4bd0d8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -232,3 +232,12 @@ class TestAdminCommands: with patch("cli.commands.admin.api_get", return_value=mock_resp): result = runner.invoke(app, ["admin", "list-tables"]) assert result.exit_code == 1 + + +class TestMetricsHelp: + def test_metrics_help(self): + result = runner.invoke(app, ["metrics", "--help"]) + assert result.exit_code == 0 + assert "list" in result.output + assert "show" in result.output + assert "import" in result.output