feat(auto_metabase): push-all + describe/sql + auto-inject de dashcards
- push_all(): pushea todos los YAMLs de un proyecto (cards primero,
dashboards despues), solo CREATE/UPDATE, resiliente a fallos por item
- explore.py: comandos describe (schema de DB) y sql (query ad-hoc con
limite, cap 5MB, bloqueo de escrituras destructivas)
- payload.py: auto-inyecta id:-N, visualization_settings:{} y
parameter_mappings:[] en dashcards nuevas para evitar 500 en push
- test_local: 11 cards + 3 dashboards sobre Sample Database de Metabase
- registry.db regenerado con auto_metabase_py_analytics indexada
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""Validate: lee un YAML local, construye el payload, valida estructura y SQL.
|
||||
|
||||
Read-only — nunca escribe nada en Metabase ni en disco. Es la red de
|
||||
seguridad antes de `push`.
|
||||
|
||||
Tres niveles de validacion (todos se ejecutan, recolectando issues):
|
||||
|
||||
1. Carga del YAML y consistencia de _meta vs args + _meta.id vs index.
|
||||
(R9 + R11 — abortan si fallan, son corruption checks).
|
||||
2. Resolucion de _refs: todos los slugs deben existir en index.
|
||||
(R10 — aborta).
|
||||
3. Estructura del payload: usa metabase_validate_card_payload /
|
||||
_dashboard_payload del registry. Reporta issues como warnings,
|
||||
no aborta.
|
||||
4. SQL dry-run (solo cards native, opcional con --check-sql).
|
||||
Usa metabase_validate_sql. Aborta de la lista de issues si SQL falla.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from metabase.validation import (
|
||||
metabase_validate_card_payload,
|
||||
metabase_validate_dashboard_payload,
|
||||
metabase_validate_sql,
|
||||
)
|
||||
|
||||
from payload import (
|
||||
assert_id_matches_index,
|
||||
assert_meta,
|
||||
build_payload,
|
||||
item_path,
|
||||
known_card_ids,
|
||||
load_item_yaml,
|
||||
)
|
||||
|
||||
|
||||
# Codigo de salida: 0 = OK, 1 = warnings, 2 = errores fatales (R9/R10/R11)
|
||||
|
||||
|
||||
class ValidationResult:
|
||||
def __init__(self):
|
||||
self.errors: list[str] = [] # fatales (corruption, refs rotas)
|
||||
self.warnings: list[str] = [] # estructurales (validators puros)
|
||||
self.sql_status: str | None = None # "ok" / "failed" / "skipped"
|
||||
self.payload: dict | None = None
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.errors and not self.warnings and self.sql_status != "failed"
|
||||
|
||||
def exit_code(self) -> int:
|
||||
if self.errors or self.sql_status == "failed":
|
||||
return 2
|
||||
if self.warnings:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_native_sql(payload: dict) -> str | None:
|
||||
"""Extrae SQL de un payload de card si es native query. Soporta legacy y MBQL5."""
|
||||
dq = payload.get("dataset_query")
|
||||
if not isinstance(dq, dict):
|
||||
return None
|
||||
# Legacy: dq.native.query
|
||||
native = dq.get("native")
|
||||
if isinstance(native, dict) and isinstance(native.get("query"), str):
|
||||
return native["query"]
|
||||
# MBQL5: dq.stages[0].native (string directo)
|
||||
stages = dq.get("stages")
|
||||
if isinstance(stages, list) and stages:
|
||||
first = stages[0]
|
||||
if isinstance(first, dict):
|
||||
n = first.get("native")
|
||||
if isinstance(n, str):
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def validate_one(
|
||||
project, kind: str, slug: str,
|
||||
*, check_sql: bool = False, client=None,
|
||||
) -> ValidationResult:
|
||||
"""Punto de entrada. `project` es main.Project."""
|
||||
result = ValidationResult()
|
||||
|
||||
# ---- Capa 1: carga + meta consistency
|
||||
path = item_path(project.dir, kind, slug)
|
||||
if not path.exists():
|
||||
result.errors.append(f"YAML no existe: {path.relative_to(project.dir.parent.parent)}")
|
||||
return result
|
||||
|
||||
try:
|
||||
doc = load_item_yaml(path)
|
||||
except ValueError as e:
|
||||
result.errors.append(str(e))
|
||||
return result
|
||||
|
||||
index = project.load_index()
|
||||
|
||||
try:
|
||||
assert_meta(doc, kind, slug, path)
|
||||
except ValueError as e:
|
||||
result.errors.append(f"R9 violado: {e}")
|
||||
|
||||
try:
|
||||
assert_id_matches_index(doc, kind, slug, index, path)
|
||||
except ValueError as e:
|
||||
result.errors.append(f"R11 violado: {e}")
|
||||
|
||||
if result.errors:
|
||||
return result # corruption — no seguir
|
||||
|
||||
# ---- Capa 2: build payload (resuelve refs)
|
||||
try:
|
||||
payload = build_payload(kind, doc, index, env=project.load_env())
|
||||
except ValueError as e:
|
||||
result.errors.append(f"R10 violado: {e}")
|
||||
return result
|
||||
result.payload = payload
|
||||
|
||||
# ---- Capa 3: validacion estructural (puras del registry)
|
||||
if kind == "card":
|
||||
result.warnings.extend(metabase_validate_card_payload(payload))
|
||||
elif kind == "dashboard":
|
||||
result.warnings.extend(
|
||||
metabase_validate_dashboard_payload(payload, known_card_ids(index))
|
||||
)
|
||||
# databases/collections: no tienen validators todavia (pocos campos, bajo riesgo)
|
||||
|
||||
# ---- Capa 4: SQL dry-run (opcional, solo cards native)
|
||||
if check_sql and kind == "card":
|
||||
sql = _extract_native_sql(payload)
|
||||
if sql is None:
|
||||
result.sql_status = "skipped"
|
||||
else:
|
||||
if client is None:
|
||||
result.warnings.append("--check-sql pedido pero client no inicializado")
|
||||
result.sql_status = "skipped"
|
||||
else:
|
||||
db_id = payload.get("database_id")
|
||||
if db_id is None:
|
||||
result.warnings.append("no se puede check-sql: payload sin database_id")
|
||||
result.sql_status = "skipped"
|
||||
else:
|
||||
sql_result = metabase_validate_sql(client, db_id, sql)
|
||||
if sql_result["ok"]:
|
||||
result.sql_status = "ok"
|
||||
else:
|
||||
result.sql_status = "failed"
|
||||
result.errors.append(f"SQL invalido: {sql_result['error']}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def print_result(kind: str, slug: str, result: ValidationResult) -> None:
|
||||
"""Imprime el resultado de la validacion en formato humano."""
|
||||
print(f"validate {kind} {slug}")
|
||||
|
||||
if result.errors:
|
||||
print(f" ERRORS ({len(result.errors)}):")
|
||||
for e in result.errors:
|
||||
print(f" ✗ {e}")
|
||||
if result.warnings:
|
||||
print(f" WARNINGS ({len(result.warnings)}):")
|
||||
for w in result.warnings:
|
||||
print(f" ! {w}")
|
||||
if result.sql_status:
|
||||
marker = {"ok": "✓", "failed": "✗", "skipped": "-"}[result.sql_status]
|
||||
print(f" SQL: {marker} {result.sql_status}")
|
||||
|
||||
if not result.errors and not result.warnings:
|
||||
print(" ✓ payload valido")
|
||||
|
||||
print(f" exit_code: {result.exit_code()}")
|
||||
Reference in New Issue
Block a user