Major additions: - Web UI: Jinja2 templates in FastAPI (login, dashboard, catalog, corporate memory, admin) - API: catalog profiles/metrics, telegram verify/unlink/status, admin table registry CRUD - Corporate memory governance: approve/reject/mandate/revoke/edit/batch + audit log - Sync: real DataSyncManager trigger, sync-settings, table-subscriptions - CLI: setup (init/test/deploy/verify), server (logs/restart/deploy/backup), explore - Instance config integration (instance.yaml loaded at startup) - 140 tests passing (25 new)
104 lines
3.5 KiB
Python
104 lines
3.5 KiB
Python
"""Explore commands — da explore {table}."""
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import typer
|
|
|
|
explore_app = typer.Typer(help="Explore data tables")
|
|
|
|
|
|
@explore_app.callback(invoke_without_command=True)
|
|
def explore(
|
|
table: str = typer.Argument(..., help="Table name to explore"),
|
|
remote: bool = typer.Option(False, "--remote", help="Fetch from server"),
|
|
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
):
|
|
"""Show profile and sample data for a table."""
|
|
if remote:
|
|
_explore_remote(table, as_json)
|
|
else:
|
|
_explore_local(table, as_json)
|
|
|
|
|
|
def _explore_local(table: str, as_json: bool):
|
|
import duckdb
|
|
|
|
local_dir = Path(os.environ.get("DA_LOCAL_DIR", "."))
|
|
db_path = local_dir / "user" / "duckdb" / "analytics.duckdb"
|
|
if not db_path.exists():
|
|
typer.echo("Local DuckDB not found. Run: da sync", err=True)
|
|
raise typer.Exit(1)
|
|
|
|
conn = duckdb.connect(str(db_path), read_only=True)
|
|
try:
|
|
# Check table exists
|
|
tables = [r[0] for r in conn.execute(
|
|
"SELECT table_name FROM information_schema.tables WHERE table_name = ?", [table]
|
|
).fetchall()]
|
|
if not tables:
|
|
# Also check views
|
|
tables = [r[0] for r in conn.execute(
|
|
"SELECT table_name FROM information_schema.tables WHERE table_name = ? AND table_type='VIEW'", [table]
|
|
).fetchall()]
|
|
if not tables:
|
|
typer.echo(f"Table '{table}' not found. Available:", err=True)
|
|
for r in conn.execute("SELECT table_name FROM information_schema.tables ORDER BY table_name").fetchall():
|
|
typer.echo(f" {r[0]}")
|
|
raise typer.Exit(1)
|
|
|
|
# Row count
|
|
count = conn.execute(f'SELECT count(*) FROM "{table}"').fetchone()[0]
|
|
|
|
# Column info
|
|
columns = conn.execute(f"DESCRIBE \"{table}\"").fetchall()
|
|
col_info = [{"name": c[0], "type": c[1], "nullable": c[2]} for c in columns]
|
|
|
|
# Sample rows
|
|
sample = conn.execute(f'SELECT * FROM "{table}" LIMIT 5').fetchall()
|
|
sample_cols = [desc[0] for desc in conn.description]
|
|
|
|
info = {
|
|
"table": table,
|
|
"row_count": count,
|
|
"columns": col_info,
|
|
"sample_rows": [dict(zip(sample_cols, row)) for row in sample],
|
|
}
|
|
|
|
if as_json:
|
|
typer.echo(json.dumps(info, indent=2, default=str))
|
|
else:
|
|
typer.echo(f"Table: {table}")
|
|
typer.echo(f"Rows: {count:,}")
|
|
typer.echo(f"Columns ({len(col_info)}):")
|
|
for c in col_info:
|
|
typer.echo(f" {c['name']:30s} {c['type']}")
|
|
typer.echo(f"\nSample ({min(5, count)} rows):")
|
|
from rich.console import Console
|
|
from rich.table import Table
|
|
console = Console()
|
|
t = Table()
|
|
for c in sample_cols:
|
|
t.add_column(c)
|
|
for row in sample:
|
|
t.add_row(*(str(v) if v is not None else "" for v in row))
|
|
console.print(t)
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def _explore_remote(table: str, as_json: bool):
|
|
from cli.client import api_get
|
|
|
|
resp = api_get(f"/api/catalog/profile/{table}")
|
|
if resp.status_code != 200:
|
|
typer.echo(f"Profile not found: {resp.json().get('detail', resp.text)}", err=True)
|
|
raise typer.Exit(1)
|
|
|
|
if as_json:
|
|
typer.echo(json.dumps(resp.json(), indent=2))
|
|
else:
|
|
profile = resp.json()
|
|
typer.echo(f"Table: {table}")
|
|
typer.echo(json.dumps(profile, indent=2, default=str))
|