58fab5ad34
- 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>
177 lines
5.8 KiB
Python
177 lines
5.8 KiB
Python
"""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()}")
|