Files
fn_registry/apps/metabase_registry/main.py
T
egutierrez 5d80e5fd57 chore: schema rápido en CLAUDE.md, sync Metabase en CLI, fix main.py
Agrega documentación de schema rápido en CLAUDE.md, regla sources en INDEX.
CLI fn index sincroniza registry.db a directorio Metabase si existe.
fn show muestra campos source_*. Fix import en metabase_registry/main.py.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 14:24:54 +02:00

412 lines
14 KiB
Python

"""
apps/metabase_registry/main.py
==============================
Setup completo de fn-registry en Metabase: datasource, cards y dashboard.
USO
---
Via variables de entorno:
METABASE_URL=http://localhost:3000 \
METABASE_ADMIN_EMAIL=admin@example.com \
METABASE_ADMIN_PASSWORD=secret \
REGISTRY_DB_PATH=/data/registry/registry.db \
python main.py
Via argumentos CLI:
python main.py \
--url http://localhost:3000 \
--admin-email admin@example.com \
--admin-password secret \
--registry-db-path /registry.db
Para crear un usuario nuevo (opcional):
python main.py ... \
--new-user-email dev@example.com \
--new-user-first-name Dev \
--new-user-last-name User \
--new-user-password devpass
NOTA SOBRE registry.db EN DOCKER
----------------------------------
Metabase corre en Docker y necesita acceder a registry.db. El path que
se configura en Metabase (--registry-db-path) debe ser la ruta DENTRO
del contenedor. Usa setup_volume.sh para copiar el archivo al contenedor
antes de ejecutar este script.
./setup_volume.sh /home/lucas/fn_registry/registry.db
Tras copiar, el archivo queda en /registry.db dentro del contenedor,
que es el valor por defecto de --registry-db-path.
DEPENDENCIAS
------------
Instalar con: pip install -r requirements.txt
O con uv: uv pip install -r requirements.txt
Las funciones metabase_add_database y metabase_list_databases deben
existir en python/functions/metabase/databases.py (creadas por el
fn-constructor).
"""
import argparse
import os
import sys
import json
# Agregar el directorio de funciones Python al path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
from metabase import (
MetabaseClient,
metabase_create_user,
metabase_create_card,
metabase_create_dashboard,
metabase_update_dashboard,
)
from metabase.client import metabase_auth
from metabase.databases import metabase_add_database, metabase_list_databases
# ---------------------------------------------------------------------------
# Configuracion de cards de ejemplo
# ---------------------------------------------------------------------------
CARDS_CONFIG = [
{
"name": "Funciones por dominio",
"description": "Cuenta de funciones agrupadas por dominio del registry",
"sql": "SELECT domain, count(*) AS total FROM functions GROUP BY domain ORDER BY total DESC",
"display": "bar",
},
{
"name": "Funciones puras vs impuras",
"description": "Distribucion de pureza funcional en el registry",
"sql": "SELECT purity, count(*) AS total FROM functions GROUP BY purity ORDER BY total DESC",
"display": "pie",
},
{
"name": "Funciones por kind",
"description": "Distribucion por tipo: function, pipeline, component",
"sql": "SELECT kind, count(*) AS total FROM functions GROUP BY kind ORDER BY total DESC",
"display": "bar",
},
{
"name": "Buscar funciones",
"description": "Lista completa de funciones con id, dominio, pureza y descripcion",
"sql": (
"SELECT id, domain, kind, purity, signature, description "
"FROM functions ORDER BY domain, name"
),
"display": "table",
},
{
"name": "Tipos por dominio",
"description": "Cuenta de tipos registrados por dominio",
"sql": "SELECT domain, count(*) AS total FROM types GROUP BY domain ORDER BY total DESC",
"display": "bar",
},
{
"name": "Proposals pendientes",
"description": "Proposals del registry que estan pendientes de revision",
"sql": (
"SELECT id, kind, title, created_by, created_at "
"FROM proposals WHERE status = 'pending' ORDER BY created_at DESC"
),
"display": "table",
},
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def log(msg: str) -> None:
print(f"[metabase_registry] {msg}", flush=True)
def log_ok(msg: str) -> None:
print(f"[metabase_registry] OK {msg}", flush=True)
def log_err(msg: str) -> None:
print(f"[metabase_registry] ERR {msg}", file=sys.stderr, flush=True)
def find_existing_database(client: MetabaseClient, name: str) -> dict | None:
"""Busca una database por nombre en Metabase. Retorna None si no existe."""
try:
databases = metabase_list_databases(client)
for db in databases:
if db.get("name") == name:
return db
except Exception as e:
log(f"No se pudo listar databases: {e}")
return None
def build_dataset_query(database_id: int, sql: str) -> dict:
"""Construye el dataset_query para una card SQL nativa."""
return {
"type": "native",
"database": database_id,
"native": {
"query": sql,
"template-tags": {},
},
}
def create_cards(client: MetabaseClient, database_id: int) -> list[dict]:
"""Crea todas las cards de ejemplo. Retorna lista de cards creadas."""
created = []
for cfg in CARDS_CONFIG:
log(f"Creando card: {cfg['name']!r} ...")
try:
card = metabase_create_card(
client=client,
name=cfg["name"],
dataset_query=build_dataset_query(database_id, cfg["sql"]),
display=cfg["display"],
description=cfg["description"],
)
log_ok(f"Card creada: id={card['id']} nombre={card['name']!r}")
created.append(card)
except Exception as e:
log_err(f"No se pudo crear card {cfg['name']!r}: {e}")
return created
def create_overview_dashboard(client: MetabaseClient, cards: list[dict]) -> dict | None:
"""Crea el dashboard 'fn-registry Overview' con todas las cards."""
log("Creando dashboard 'fn-registry Overview' ...")
try:
dashboard = metabase_create_dashboard(
client=client,
name="fn-registry Overview",
description="Vista general del function registry: funciones, tipos, proposals y metricas.",
)
log_ok(f"Dashboard creado: id={dashboard['id']}")
except Exception as e:
log_err(f"No se pudo crear dashboard: {e}")
return None
if not cards:
log("Sin cards para agregar al dashboard.")
return dashboard
# Posicionar cards en una grilla de 3 columnas, 24 unidades de ancho total.
# Cada card ocupa 8 columnas x 6 filas.
cols = 3
card_w = 8
card_h = 6
dashcards = []
for idx, card in enumerate(cards):
col = (idx % cols) * card_w
row = (idx // cols) * card_h
dashcards.append({
"id": -(idx + 1), # ID negativo = nueva dashcard
"card_id": card["id"],
"size_x": card_w,
"size_y": card_h,
"col": col,
"row": row,
"parameter_mappings": [],
"visualization_settings": {},
})
try:
metabase_update_dashboard(
client=client,
dashboard_id=dashboard["id"],
dashcards=dashcards,
)
log_ok(f"Dashboard actualizado con {len(dashcards)} cards.")
except Exception as e:
log_err(f"No se pudo poblar el dashboard con cards: {e}")
return dashboard
# ---------------------------------------------------------------------------
# Flujo principal
# ---------------------------------------------------------------------------
def run(args: argparse.Namespace) -> int:
"""Ejecuta el setup completo. Retorna 0 en exito, 1 en fallo critico."""
# 1. Autenticar como admin
log(f"Autenticando en {args.url} como {args.admin_email} ...")
try:
client = metabase_auth(args.url, args.admin_email, args.admin_password)
log_ok("Autenticacion exitosa.")
except Exception as e:
log_err(f"Fallo de autenticacion: {e}")
return 1
with client:
# 2. Crear usuario nuevo (opcional)
if args.new_user_email:
if not args.new_user_first_name or not args.new_user_last_name:
log_err("Para crear usuario se requieren --new-user-first-name y --new-user-last-name.")
return 1
log(f"Creando usuario {args.new_user_email} ...")
try:
user = metabase_create_user(
client=client,
first_name=args.new_user_first_name,
last_name=args.new_user_last_name,
email=args.new_user_email,
password=args.new_user_password or "",
)
log_ok(f"Usuario creado: id={user['id']} email={user['email']}")
except Exception as e:
log_err(f"No se pudo crear usuario: {e}")
# No es critico, continuar
# 3. Agregar registry.db como datasource SQLite
db_name = "fn-registry"
log(f"Verificando si ya existe la database {db_name!r} en Metabase ...")
existing_db = find_existing_database(client, db_name)
if existing_db:
db_id = existing_db["id"]
log_ok(f"Database ya existe: id={db_id} nombre={db_name!r}. Se reutiliza.")
else:
log(f"Registrando registry.db ({args.registry_db_path}) como datasource SQLite ...")
try:
new_db = metabase_add_database(
client=client,
name=db_name,
engine="sqlite",
details={"db": args.registry_db_path},
)
db_id = new_db["id"]
log_ok(f"Database registrada: id={db_id} path={args.registry_db_path}")
except Exception as e:
log_err(f"No se pudo registrar la database: {e}")
log_err(
"Verifica que registry.db este accesible desde el contenedor Docker. "
"Usa setup_volume.sh para copiar el archivo."
)
return 1
# 4. Crear cards de ejemplo
log(f"Creando {len(CARDS_CONFIG)} cards con database_id={db_id} ...")
cards = create_cards(client, db_id)
log(f"Cards creadas: {len(cards)}/{len(CARDS_CONFIG)}")
# 5. Crear dashboard
dashboard = create_overview_dashboard(client, cards)
if dashboard:
log_ok(
f"Dashboard disponible en: {args.url}/dashboard/{dashboard['id']}"
)
else:
log_err("El dashboard no pudo crearse.")
summary = {
"url": args.url,
"database_id": db_id,
"cards_created": len(cards),
"dashboard_id": dashboard["id"] if dashboard else None,
"dashboard_url": f"{args.url}/dashboard/{dashboard['id']}" if dashboard else None,
}
print("\n--- Resumen ---")
print(json.dumps(summary, indent=2))
return 0
# ---------------------------------------------------------------------------
# CLI / env vars
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="metabase_registry",
description="Setup de fn-registry como datasource en Metabase.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
# Conexion Metabase
p.add_argument(
"--url",
default=os.environ.get("METABASE_URL", "http://localhost:3000"),
help="URL base de Metabase (env: METABASE_URL). Default: http://localhost:3000",
)
p.add_argument(
"--admin-email",
default=os.environ.get("METABASE_ADMIN_EMAIL", "admin@example.com"),
dest="admin_email",
help="Email del admin (env: METABASE_ADMIN_EMAIL).",
)
p.add_argument(
"--admin-password",
default=os.environ.get("METABASE_ADMIN_PASSWORD", ""),
dest="admin_password",
help="Password del admin (env: METABASE_ADMIN_PASSWORD).",
)
# Registry DB path (ruta dentro del contenedor Docker)
p.add_argument(
"--registry-db-path",
default=os.environ.get("REGISTRY_DB_PATH", "/data/registry/registry.db"),
dest="registry_db_path",
help=(
"Ruta al registry.db DENTRO del contenedor Docker "
"(env: REGISTRY_DB_PATH). Default: /registry.db"
),
)
# Nuevo usuario (todos opcionales)
p.add_argument(
"--new-user-email",
default=os.environ.get("NEW_USER_EMAIL", ""),
dest="new_user_email",
help="Email del nuevo usuario a crear (env: NEW_USER_EMAIL). Opcional.",
)
p.add_argument(
"--new-user-first-name",
default=os.environ.get("NEW_USER_FIRST_NAME", ""),
dest="new_user_first_name",
help="Nombre del nuevo usuario (env: NEW_USER_FIRST_NAME).",
)
p.add_argument(
"--new-user-last-name",
default=os.environ.get("NEW_USER_LAST_NAME", ""),
dest="new_user_last_name",
help="Apellido del nuevo usuario (env: NEW_USER_LAST_NAME).",
)
p.add_argument(
"--new-user-password",
default=os.environ.get("NEW_USER_PASSWORD", ""),
dest="new_user_password",
help="Password del nuevo usuario (env: NEW_USER_PASSWORD). Opcional.",
)
return p
def main() -> None:
parser = build_parser()
args = parser.parse_args()
if not args.admin_password:
parser.error(
"Se requiere la password del admin. "
"Usa --admin-password o la variable de entorno METABASE_ADMIN_PASSWORD."
)
sys.exit(run(args))
if __name__ == "__main__":
main()