"""Push per-item: aplica UN cambio a Metabase. Implementa las 20 reglas duras. Resumen de las reglas que este modulo garantiza: - R1: target unico (validado por argparse en main.py). - R2: 1 sola request HTTP por invocacion en la fase de apply (excepcion: dashboards nuevos con dashcards = POST + PUT, documentado en metabase_create_dashboard_raw). - R3: push de dashboard NO toca cards. Solo dashcards refs + layout + meta. - R4: push de card NO toca dashboards. - R5: dry-run por defecto. --apply requerido para enviar. - R6: backup obligatorio antes de UPDATE (no en CREATE). - R7: payload se construye solo desde el YAML del item. - R8: payload PUT/POST contiene solo lo del YAML, sin merge con remoto. - R9: _meta.kind/slug deben coincidir con args (validado en validate_one). - R10: _refs deben resolver a ids del index (validado en validate_one). - R11: _meta.id debe coincidir con index (validado en validate_one). - R12: cap de tamano de payload — pide confirmacion si supera 100KB. - R13: log de cada push en state/push.log (jsonl). - R14, R15, R16: garantizadas en sync_pull.py. - R17: freshness check (compara remote.updated_at vs _meta.remote_updated_at). - R18: count check para dashboards (dashcards/tabs/parameters no menores en local). - R19: --force-overwrite para saltar R17 + R18 explicitamente. - R20: cubierto por R18 (cuenta tabs y parameters tambien). """ from __future__ import annotations import datetime as dt import json import shutil import sys from pathlib import Path from typing import Any import yaml from metabase.cards import ( metabase_create_card_raw, metabase_get_card, metabase_update_card, ) from metabase.dashboards import ( metabase_create_dashboard_raw, metabase_get_dashboard, metabase_update_dashboard, ) from payload import item_path, load_item_yaml from sync_pull import pull_one from sync_validate import print_result, validate_one # Limite del payload para R12 _PAYLOAD_SIZE_WARN_BYTES = 100_000 # ---------------------------------------------------------------- Helpers def _utc_now_iso() -> str: return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _ts_for_path() -> str: return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d_%H%M%S") def _yaml_dump(path: Path, data: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w") as f: yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True, default_flow_style=False, width=120) def _log_push(project, entry: dict) -> None: """R13: append-only jsonl log de cada push (dry-run o apply).""" log_path = project.state_dir / "push.log" project.state_dir.mkdir(exist_ok=True) with log_path.open("a") as f: f.write(json.dumps(entry) + "\n") _active_log_entry: dict | None = None _active_project = None def _abort(msg: str) -> None: """Aborta con exit 2. Si hay un log_entry activo, lo persiste como 'aborted'.""" print(f"\nABORT — {msg}", file=sys.stderr) if _active_log_entry is not None and _active_project is not None: _active_log_entry["status"] = "aborted" _active_log_entry["abort_reason"] = msg.split("\n", 1)[0] _log_push(_active_project, _active_log_entry) sys.exit(2) # ---------------------------------------------------------------- R6: backup def _backup_or_abort(project, kind: str, slug: str, current_remote: dict) -> Path: """R6: serializa el estado remoto actual a state/backups/{ts}/{kind}/{slug}.yaml. Si la escritura falla, aborta antes de tocar Metabase.""" ts = _ts_for_path() backup_path = project.state_dir / "backups" / ts / (kind + "s") / f"{slug}.yaml" try: backup_path.parent.mkdir(parents=True, exist_ok=True) with backup_path.open("w") as f: yaml.safe_dump( {"_backup_of": {"kind": kind, "slug": slug, "ts": ts}, "remote_state": current_remote}, f, sort_keys=False, allow_unicode=True, default_flow_style=False, width=120, ) except Exception as e: _abort(f"R6 backup fallo, no se aplicara nada. Error: {e}") print(f" backup: {backup_path.relative_to(project.dir.parent.parent)}") return backup_path # ---------------------------------------------------------------- R17 + R18 + R20 def _freshness_check_or_abort( kind: str, slug: str, local_doc: dict, remote: dict, force: bool, ) -> None: """R17: si remote.updated_at != _meta.remote_updated_at, abortar (salvo --force).""" local_remote_ts = local_doc.get("_meta", {}).get("remote_updated_at") current_remote_ts = remote.get("updated_at") if local_remote_ts is None: # Item nuevo o pull antiguo sin metadata — proceder con cuidado return if current_remote_ts != local_remote_ts: if force: print( f" ! force-overwrite ACTIVO: ignorando R17 — " f"local snapshot={local_remote_ts}, metabase={current_remote_ts}" ) return _abort( f"R17 freshness check fallido para {kind} {slug}.\n" f" Tu snapshot: remote_updated_at = {local_remote_ts}\n" f" Metabase ahora: updated_at = {current_remote_ts}\n" f" → alguien (o tu mismo) cambio este {kind} en Metabase entre tu pull y este push.\n" f" → para no sobrescribir esos cambios: python main.py pull {kind} {slug}\n" f" → para sobrescribir igualmente: --force-overwrite (NO recomendado)" ) def _count_check_or_abort( slug: str, local_payload: dict, remote: dict, force: bool, ) -> None: """R18+R20: para dashboards, si remoto tiene mas dashcards/tabs/parameters que el YAML local, abortar (salvo --force).""" keys = ("dashcards", "tabs", "parameters") losses = [] for k in keys: local_count = len(local_payload.get(k, []) or []) remote_count = len(remote.get(k, []) or []) if remote_count > local_count: losses.append(f"{k}: local={local_count}, metabase={remote_count} (perderias {remote_count - local_count})") if losses: if force: print(f" ! force-overwrite ACTIVO: ignorando R18 — perdidas: {losses}") return _abort( f"R18 count check fallido para dashboard {slug}:\n " + "\n ".join(losses) + f"\n → si genuinamente quieres eliminar elementos: --force-overwrite (NO recomendado)\n" f" → si no, haz pull primero: python main.py pull dashboard {slug}" ) # ---------------------------------------------------------------- Push paths def _push_card_create(client, payload: dict) -> dict: """R2: 1 request, POST /api/card. Devuelve la card creada con su id.""" return metabase_create_card_raw(client, payload) def _push_card_update(client, card_id: int, payload: dict) -> dict: """R2: 1 request, PUT /api/card/:id. R4: solo toca esta card.""" return metabase_update_card(client, card_id, **payload) def _push_dashboard_create(client, payload: dict) -> dict: """POST + PUT (si hay dashcards) — documentado en metabase_create_dashboard_raw.""" return metabase_create_dashboard_raw(client, payload) def _push_dashboard_update(client, dash_id: int, payload: dict) -> dict: """R2: 1 request, PUT /api/dashboard/:id. R3: solo toca este dashboard.""" return metabase_update_dashboard(client, dash_id, **payload) # ---------------------------------------------------------------- Orchestrator def push_one( project, client, kind: str, slug: str, *, apply: bool = False, force_overwrite: bool = False, allow_warnings: bool = False, ) -> dict: """Punto de entrada. Devuelve un dict con el resultado.""" log_entry: dict = { "ts": _utc_now_iso(), "kind": kind, "slug": slug, "apply": apply, "force_overwrite": force_overwrite, } # Hacer el entry visible para _abort() para que pueda loguear si aborta global _active_log_entry, _active_project _active_log_entry = log_entry _active_project = project # Fase 1: validate (R7+R9+R10+R11+estructura+SQL opcional) val_client = client if (apply and kind == "card") else None val_result = validate_one( project, kind, slug, check_sql=(apply and kind == "card"), client=val_client, ) print_result(kind, slug, val_result) if val_result.errors: log_entry["status"] = "validation_errors" log_entry["issues"] = val_result.errors _log_push(project, log_entry) sys.exit(2) if val_result.warnings and not allow_warnings: if apply: print( f"\n ! hay {len(val_result.warnings)} warnings. " f"Para aplicar igualmente: --allow-warnings" ) log_entry["status"] = "warnings_blocking_apply" log_entry["warnings"] = val_result.warnings _log_push(project, log_entry) sys.exit(1) payload = val_result.payload assert payload is not None # R12: tamano del payload payload_size = len(json.dumps(payload, default=str)) log_entry["payload_bytes"] = payload_size if payload_size > _PAYLOAD_SIZE_WARN_BYTES: print(f"\n ! payload size = {payload_size} bytes (>{_PAYLOAD_SIZE_WARN_BYTES})") if apply: resp = input(" ¿Continuar con apply? (escribir 'si'): ") if resp.strip().lower() != "si": _abort("usuario cancelo por tamano") # Cargar el doc para tener _meta doc = load_item_yaml(item_path(project.dir, kind, slug)) meta = doc.get("_meta", {}) item_id = meta.get("id") is_create = item_id is None # ---- Dry-run path if not apply: method, url = _resolve_method_url(kind, item_id) print(f"\n--- DRY-RUN ({method} {url}) ---") print(json.dumps(payload, indent=2, default=str)) print(f"\n payload: {payload_size} bytes") print(f" para aplicar: añade --apply") log_entry["status"] = "dry_run" log_entry["method"] = method log_entry["url"] = url _log_push(project, log_entry) return {"dry_run": True, "payload": payload} # ---- Apply path print(f"\n--- APPLY ---") if is_create: # R6 no aplica: nada que respaldar print(" modo: CREATE (no hay backup, item nuevo)") if kind == "card": response = _push_card_create(client, payload) elif kind == "dashboard": response = _push_dashboard_create(client, payload) else: _abort(f"create por push de '{kind}' no soportado todavia (solo card/dashboard)") new_id = response["id"] print(f" creado con id={new_id}") # Actualizar index + _meta del YAML local idx = project.load_index() idx.setdefault(kind + "s", {})[slug] = new_id project.save_index(idx) # Re-pull para refrescar _meta con synced_at + remote_updated_at + counts print(" re-pull para refrescar _meta...") pull_one(client, project, kind, new_id) log_entry["status"] = "created" log_entry["new_id"] = new_id else: # UPDATE path: R6 backup obligatorio + R17/R18 checks print(f" modo: UPDATE (id={item_id})") # Fetch estado remoto actual if kind == "card": remote = metabase_get_card(client, item_id) elif kind == "dashboard": remote = metabase_get_dashboard(client, item_id) else: _abort(f"update por push de '{kind}' no soportado todavia") # R6: backup ANTES de hacer nada destructivo backup_path = _backup_or_abort(project, kind, slug, remote) log_entry["backup"] = str(backup_path.relative_to(project.dir.parent.parent)) # R17: freshness _freshness_check_or_abort(kind, slug, doc, remote, force_overwrite) # R18: count check (solo dashboards) if kind == "dashboard": _count_check_or_abort(slug, payload, remote, force_overwrite) # Apply if kind == "card": response = _push_card_update(client, item_id, payload) elif kind == "dashboard": response = _push_dashboard_update(client, item_id, payload) print(f" aplicado.") # Re-pull para refrescar _meta print(" re-pull para refrescar _meta...") pull_one(client, project, kind, item_id) log_entry["status"] = "updated" log_entry["id"] = item_id _log_push(project, log_entry) print("OK") return {"applied": True, "response": response} def _resolve_method_url(kind: str, item_id: int | None) -> tuple[str, str]: """Devuelve (method, url) que usariamos en apply, para logs.""" if item_id is None: return "POST", f"/api/{kind}" return "PUT", f"/api/{kind}/{item_id}" # ---------------------------------------------------------------- push_all def _list_slugs(project, kind: str) -> list[str]: """Lista slugs (filenames sin .yaml) en projects/{name}/{kind}s/.""" sub = project.dir / (kind + "s") if not sub.exists(): return [] return sorted(p.stem for p in sub.glob("*.yaml")) def push_all( project, client, *, apply: bool = False, force_overwrite: bool = False, allow_warnings: bool = False, kinds: tuple[str, ...] = ("card", "dashboard"), ) -> dict: """Pushea todos los YAMLs de cards y dashboards de un proyecto. Solo CREATE o UPDATE (reusa push_one) — nunca DELETE. Cards primero, dashboards despues, para que los slugs esten en el index cuando se resuelven las dashcards. Por defecto dry-run. Pasa apply=True para realmente enviar. Si un item falla (SystemExit desde push_one), se captura y se continua con el siguiente. Devuelve un resumen con el resultado por item. """ print(f"\n=== push all ({'APPLY' if apply else 'DRY-RUN'}) project={project.name} ===") summary = {"ok": [], "failed": [], "skipped": []} for kind in kinds: slugs = _list_slugs(project, kind) if not slugs: print(f"\n[{kind}] (sin YAMLs en {kind}s/)") continue print(f"\n[{kind}] {len(slugs)} item(s): {', '.join(slugs)}") for slug in slugs: print(f"\n--- {kind} {slug} ---") try: push_one( project, client, kind, slug, apply=apply, force_overwrite=force_overwrite, allow_warnings=allow_warnings, ) summary["ok"].append(f"{kind}:{slug}") except SystemExit as e: code = e.code if isinstance(e.code, int) else 1 print(f" ! {kind} {slug} fallo (exit_code={code}) — continuo") summary["failed"].append(f"{kind}:{slug} (exit={code})") except Exception as e: print(f" ! {kind} {slug} excepcion: {type(e).__name__}: {e}") summary["failed"].append(f"{kind}:{slug} ({type(e).__name__})") print(f"\n=== resumen push all ===") print(f" OK: {len(summary['ok'])} {summary['ok']}") print(f" FAILED: {len(summary['failed'])} {summary['failed']}") if not apply: print(f" (dry-run — para aplicar de verdad: --apply)") return summary