feat(infra): auto-commit con 8 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"jupyter": {
|
"jupyter": {
|
||||||
"command": "bash",
|
"command": "bash",
|
||||||
"args": ["/home/enmanuel/fn_registry/bash/functions/infra/jupyter_mcp_serve.sh"]
|
"args": ["-c", "exec bash \"$(git rev-parse --show-toplevel)/bash/functions/infra/jupyter_mcp_serve.sh\""]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Submodule
+1
Submodule cpp/apps/chart_demo added at 026f514bb7
Submodule
+1
Submodule cpp/apps/shaders_lab added at dc9a970aff
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: sign_metabase_embed_jwt
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def sign_metabase_embed_jwt(secret: str, resource_type: str, resource_id: int, base_url: str, params: dict | None = None, exp_seconds: int = 3600, theme: str | None = None, bordered: bool = True, titled: bool = True) -> dict"
|
||||||
|
description: "Firma un JWT de static-embedding de Metabase (HS256 con PyJWT) y construye la URL del iframe (/embed/<type>/<token>#opciones). Soporta question y dashboard, params locked/enabled, TTL configurable y tema opcional."
|
||||||
|
tags: [metabase, embed, jwt]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["jwt", "time"]
|
||||||
|
tested: true
|
||||||
|
tests: ["test_firma_y_decodifica_round_trip", "test_resource_type_invalido_lanza_valueerror", "test_embed_url_dashboard"]
|
||||||
|
test_file_path: "python/functions/infra/sign_metabase_embed_jwt_test.py"
|
||||||
|
file_path: "python/functions/infra/sign_metabase_embed_jwt.py"
|
||||||
|
params:
|
||||||
|
- name: secret
|
||||||
|
desc: "Secret de embedding de Metabase (Settings > Embedding). NUNCA va al cliente."
|
||||||
|
- name: resource_type
|
||||||
|
desc: '"question" o "dashboard" — tipo del recurso a embeber.'
|
||||||
|
- name: resource_id
|
||||||
|
desc: "Id numerico de la card o dashboard en Metabase."
|
||||||
|
- name: base_url
|
||||||
|
desc: 'URL base de la instancia, ej. "https://reports.autingo.es".'
|
||||||
|
- name: params
|
||||||
|
desc: "Parametros de embedding locked/enabled (dict). Default {}."
|
||||||
|
- name: exp_seconds
|
||||||
|
desc: "TTL del token en segundos desde ahora. Default 3600 (1h)."
|
||||||
|
- name: theme
|
||||||
|
desc: 'Tema opcional: "night" | "transparent" | None.'
|
||||||
|
- name: bordered
|
||||||
|
desc: "Si el iframe muestra borde. Default True."
|
||||||
|
- name: titled
|
||||||
|
desc: "Si el iframe muestra titulo. Default True."
|
||||||
|
output: 'dict con {"token": jwt str, "embed_url": url completa /embed/<type>/<token>#opciones, "exp": unix int}.'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from infra import sign_metabase_embed_jwt
|
||||||
|
|
||||||
|
# El secret lo pasa el caller (p.ej. desde pass: metabase/aurgi-embed-secret)
|
||||||
|
result = sign_metabase_embed_jwt(
|
||||||
|
secret=os.environ["MB_EMBED_SECRET"],
|
||||||
|
resource_type="question",
|
||||||
|
resource_id=8048,
|
||||||
|
base_url="https://reports.autingo.es",
|
||||||
|
)
|
||||||
|
print(result["embed_url"])
|
||||||
|
# https://reports.autingo.es/embed/question/<jwt>#bordered=true&titled=true
|
||||||
|
print(result["exp"]) # unix timestamp de expiracion
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando necesites embeder una card/dashboard de Metabase en un iframe sin exponerla publicamente: firma server-side en cada carga, nunca hardcodees el token.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- El `secret` NUNCA va al cliente — la firma se hace server-side y solo viaja el token al iframe.
|
||||||
|
- El token expira (`exp_seconds`, default 1h). Refirma en cada carga; no caches el token.
|
||||||
|
- La card/dashboard necesita `enable_embedding=true` en Metabase, o el endpoint de embed devuelve error.
|
||||||
|
- Los `params` deben estar registrados en `embedding_params` como `locked` o `enabled` en Metabase, o se ignoran silenciosamente.
|
||||||
|
- Verificado contra reports.autingo.es: el endpoint `/api/embed/card/<token>/query` devuelve 202 + filas con token valido, 400 con token manipulado.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Firma un JWT de static-embedding de Metabase y construye la URL del iframe."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
|
||||||
|
def sign_metabase_embed_jwt(
|
||||||
|
secret: str,
|
||||||
|
resource_type: str,
|
||||||
|
resource_id: int,
|
||||||
|
base_url: str,
|
||||||
|
params: dict | None = None,
|
||||||
|
exp_seconds: int = 3600,
|
||||||
|
theme: str | None = None,
|
||||||
|
bordered: bool = True,
|
||||||
|
titled: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Firma un JWT de static-embedding de Metabase (HS256) y arma la URL del iframe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
secret: secret de embedding de Metabase (Settings > Embedding). NUNCA va al cliente.
|
||||||
|
resource_type: "question" o "dashboard".
|
||||||
|
resource_id: id numerico de la card/dashboard en Metabase.
|
||||||
|
base_url: URL base de la instancia, ej. "https://reports.autingo.es".
|
||||||
|
params: parametros de embedding locked/enabled. Default {}.
|
||||||
|
exp_seconds: TTL del token en segundos desde ahora. Default 3600 (1h).
|
||||||
|
theme: tema opcional ("night" | "transparent" | None).
|
||||||
|
bordered: si el iframe muestra borde. Default True.
|
||||||
|
titled: si el iframe muestra titulo. Default True.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con {"token": <jwt str>, "embed_url": <url completa>, "exp": <unix int>}.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: si resource_type no es "question" ni "dashboard".
|
||||||
|
"""
|
||||||
|
if resource_type not in ("question", "dashboard"):
|
||||||
|
raise ValueError(
|
||||||
|
f'resource_type debe ser "question" o "dashboard", recibido: {resource_type!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
exp = int(time.time()) + exp_seconds
|
||||||
|
payload = {
|
||||||
|
"resource": {resource_type: resource_id},
|
||||||
|
"params": params or {},
|
||||||
|
"exp": exp,
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, secret, algorithm="HS256")
|
||||||
|
|
||||||
|
options = [f"bordered={str(bordered).lower()}", f"titled={str(titled).lower()}"]
|
||||||
|
if theme is not None:
|
||||||
|
options.append(f"theme={theme}")
|
||||||
|
fragment = "&".join(options)
|
||||||
|
|
||||||
|
embed_url = f"{base_url.rstrip('/')}/embed/{resource_type}/{token}#{fragment}"
|
||||||
|
|
||||||
|
return {"token": token, "embed_url": embed_url, "exp": exp}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
result = sign_metabase_embed_jwt(
|
||||||
|
secret="0" * 64,
|
||||||
|
resource_type="question",
|
||||||
|
resource_id=8048,
|
||||||
|
base_url="https://reports.autingo.es",
|
||||||
|
)
|
||||||
|
print(result["embed_url"])
|
||||||
|
print(f"exp={result['exp']}")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Tests para sign_metabase_embed_jwt."""
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sign_metabase_embed_jwt import sign_metabase_embed_jwt
|
||||||
|
|
||||||
|
|
||||||
|
def test_firma_y_decodifica_round_trip():
|
||||||
|
secret = "test-secret-1234567890"
|
||||||
|
result = sign_metabase_embed_jwt(
|
||||||
|
secret=secret,
|
||||||
|
resource_type="question",
|
||||||
|
resource_id=8048,
|
||||||
|
base_url="https://reports.autingo.es",
|
||||||
|
params={"category": "shoes"},
|
||||||
|
)
|
||||||
|
decoded = jwt.decode(result["token"], secret, algorithms=["HS256"])
|
||||||
|
assert decoded["resource"] == {"question": 8048}
|
||||||
|
assert decoded["params"] == {"category": "shoes"}
|
||||||
|
assert decoded["exp"] == result["exp"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_type_invalido_lanza_valueerror():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
sign_metabase_embed_jwt(
|
||||||
|
secret="s",
|
||||||
|
resource_type="bogus",
|
||||||
|
resource_id=1,
|
||||||
|
base_url="https://reports.autingo.es",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_embed_url_dashboard():
|
||||||
|
result = sign_metabase_embed_jwt(
|
||||||
|
secret="s",
|
||||||
|
resource_type="dashboard",
|
||||||
|
resource_id=42,
|
||||||
|
base_url="https://reports.autingo.es",
|
||||||
|
)
|
||||||
|
assert "/embed/dashboard/" in result["embed_url"]
|
||||||
|
assert result["embed_url"].endswith("#bordered=true&titled=true")
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
name: sync_xlsx_to_dq_checks
|
||||||
|
kind: function
|
||||||
|
lang: py
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "def sync_xlsx_to_dq_checks(xlsx_path: str, project: str = 'autingo-159109', dataset: str = 'data_quality', table: str = 'dq_checks', sheet_name: str = 'catalogo', validate_sql: bool = True) -> dict"
|
||||||
|
description: "Sincroniza un catalogo de data quality checks desde un archivo Excel (.xlsx) hacia una tabla de BigQuery. Lee la hoja con openpyxl localizando columnas por cabecera, valida/normaliza cada fila (tipo fiabilidad|negocio, severity info|warning|critical, threshold int>=0, enabled booleano flexible), auto-deriva check_id unico cuando viene vacio, hace dry-run de cada assert_sql contra BigQuery para cazar SQL invalido, y carga las filas validas con WRITE_TRUNCATE (reemplazo total: el Excel es la fuente de verdad). Devuelve resumen con loaded/skipped/sql_errors/rows."
|
||||||
|
tags: [bigquery, data-quality, excel, sync]
|
||||||
|
uses_functions: [bq_auth_py_infra, bq_query_py_infra, bq_load_from_file_py_infra]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: ["openpyxl", "csv", "tempfile", "re", "os", "datetime"]
|
||||||
|
tested: true
|
||||||
|
tests: ["test_fila_valida_minima_se_carga", "test_check_id_se_autoderiva_cuando_vacio", "test_check_id_explicito_se_respeta", "test_check_id_colision_se_sufija", "test_tipo_invalido_se_skipea", "test_tipo_case_insensitive_se_normaliza", "test_pregunta_vacia_se_skipea", "test_assert_sql_vacia_se_skipea", "test_severity_invalida_se_skipea", "test_threshold_negativo_se_skipea", "test_threshold_no_numerico_se_skipea", "test_fila_totalmente_vacia_se_ignora_sin_skip", "test_parse_bool_variantes", "test_parse_threshold_defaults_y_floats_enteros", "test_slug_colapsa_y_recorta", "test_assert_sql_con_comas_y_comillas_se_conserva"]
|
||||||
|
test_file_path: "python/functions/infra/sync_xlsx_to_dq_checks_test.py"
|
||||||
|
file_path: "python/functions/infra/sync_xlsx_to_dq_checks.py"
|
||||||
|
params:
|
||||||
|
- name: xlsx_path
|
||||||
|
desc: "Ruta absoluta al archivo .xlsx del catalogo de asserts (fila 1 = cabeceras)."
|
||||||
|
- name: project
|
||||||
|
desc: "Proyecto GCP destino. Default 'autingo-159109'."
|
||||||
|
- name: dataset
|
||||||
|
desc: "Dataset BigQuery destino. Default 'data_quality'."
|
||||||
|
- name: table
|
||||||
|
desc: "Tabla BigQuery destino. Default 'dq_checks'."
|
||||||
|
- name: sheet_name
|
||||||
|
desc: "Nombre de la hoja del libro a leer. Default 'catalogo'."
|
||||||
|
- name: validate_sql
|
||||||
|
desc: "Si True, hace un dry-run de cada assert_sql contra BigQuery antes de cargarlo; las filas con SQL invalido NO se cargan y van a sql_errors. Default True."
|
||||||
|
output: 'dict con {"loaded": int filas cargadas, "skipped": int filas descartadas por validacion, "sql_errors": [{"check_id": str, "error": str}] filas con assert_sql invalido, "rows": [dict] filas cargadas con sus 11 columnas normalizadas}.'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys, os
|
||||||
|
sys.path.insert(0, os.path.join("python", "functions"))
|
||||||
|
from infra.sync_xlsx_to_dq_checks import sync_xlsx_to_dq_checks
|
||||||
|
|
||||||
|
# El xlsx es la fuente de verdad del catalogo de checks.
|
||||||
|
result = sync_xlsx_to_dq_checks(
|
||||||
|
xlsx_path="/mnt/c/Users/egutierrez/Documents/dq_catalogo_asserts.xlsx",
|
||||||
|
project="autingo-159109",
|
||||||
|
dataset="data_quality",
|
||||||
|
table="dq_checks",
|
||||||
|
sheet_name="catalogo",
|
||||||
|
validate_sql=True,
|
||||||
|
)
|
||||||
|
print(f"cargadas={result['loaded']} descartadas={result['skipped']}")
|
||||||
|
for err in result["sql_errors"]:
|
||||||
|
print(f"SQL invalido en {err['check_id']}: {err['error']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
O directamente con `fn run` (lee el path por defecto si no se pasa argumento):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./fn run sync_xlsx_to_dq_checks /mnt/c/Users/egutierrez/Documents/dq_catalogo_asserts.xlsx
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cuando usarla
|
||||||
|
|
||||||
|
Cuando edites el Excel del catalogo de asserts (anadir/quitar/modificar checks) y quieras propagar esos cambios a la tabla `dq_checks` de BigQuery, antes de la corrida horaria que ejecuta los asserts. Es el paso de sincronizacion que convierte el Excel humano en la tabla que consume el motor de data quality.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Impura: requiere ADC de gcloud configurado (`gcloud auth application-default login`) con acceso de escritura a BigQuery sobre el proyecto/dataset destino.
|
||||||
|
- `WRITE_TRUNCATE` borra y reemplaza la tabla `dq_checks` ENTERA en cada corrida. El Excel es la unica fuente de verdad: lo que no este en el Excel desaparece de BigQuery. No es un upsert.
|
||||||
|
- El dry-run (`validate_sql=True`) consume cuota minima pero hace un round-trip a BigQuery por cada fila — con catalogos grandes la corrida tarda proporcionalmente. Pasa `validate_sql=False` para saltarlo si ya validaste el SQL por otra via.
|
||||||
|
- `assert_sql` con comas, comillas o saltos de linea se escribe al CSV temporal con `csv.QUOTE_ALL`, de modo que el CSV que carga BigQuery siempre es valido. El archivo temporal se borra al terminar (tambien en caso de error).
|
||||||
|
- Las columnas se localizan por nombre de cabecera (fila 1), no por posicion: puedes reordenar columnas en el Excel sin romper el sync, pero las cabeceras deben llamarse exactamente `check_id, tipo, subcategoria, pregunta, assert_sql, threshold, severity, enabled, owner`.
|
||||||
|
- `created_at`/`updated_at` se generan en el momento del sync (mismo timestamp UTC para todas las filas de la corrida) — no se leen del Excel.
|
||||||
|
- Las filas que no pasan validacion (tipo/pregunta/assert_sql/severity/threshold) NO se cargan y se cuentan en `skipped`; las filas totalmente vacias se ignoran sin contarse.
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"""Sincroniza un catalogo de data quality checks desde un .xlsx a BigQuery.
|
||||||
|
|
||||||
|
El Excel es la fuente de verdad: cada corrida reemplaza la tabla destino entera
|
||||||
|
(WRITE_TRUNCATE). Antes de cargar, valida cada fila y (opcionalmente) hace un
|
||||||
|
dry-run del assert_sql contra BigQuery para cazar SQL invalido en el momento.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
# Columnas del schema destino, en orden. El CSV temporal y el load respetan
|
||||||
|
# exactamente este orden.
|
||||||
|
SCHEMA_COLUMNS = [
|
||||||
|
"check_id",
|
||||||
|
"tipo",
|
||||||
|
"subcategoria",
|
||||||
|
"pregunta",
|
||||||
|
"assert_sql",
|
||||||
|
"threshold",
|
||||||
|
"severity",
|
||||||
|
"enabled",
|
||||||
|
"owner",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Cabeceras que se leen del Excel (sin created_at/updated_at, que se generan).
|
||||||
|
INPUT_HEADERS = [
|
||||||
|
"check_id",
|
||||||
|
"tipo",
|
||||||
|
"subcategoria",
|
||||||
|
"pregunta",
|
||||||
|
"assert_sql",
|
||||||
|
"threshold",
|
||||||
|
"severity",
|
||||||
|
"enabled",
|
||||||
|
"owner",
|
||||||
|
]
|
||||||
|
|
||||||
|
VALID_TIPOS = {"fiabilidad", "negocio"}
|
||||||
|
VALID_SEVERITIES = {"info", "warning", "critical"}
|
||||||
|
TRUE_TOKENS = {"true", "1", "si", "sí", "x", "yes", "verdadero"}
|
||||||
|
FALSE_TOKENS = {"false", "0", "no", "", "falso"}
|
||||||
|
|
||||||
|
|
||||||
|
def _slug(text: str) -> str:
|
||||||
|
"""Pasa un texto a slug: minusculas, no-alfanumericos -> _, colapsa y recorta."""
|
||||||
|
s = (text or "").strip().lower()
|
||||||
|
s = re.sub(r"[^a-z0-9]+", "_", s)
|
||||||
|
s = re.sub(r"_+", "_", s)
|
||||||
|
return s.strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(value) -> str:
|
||||||
|
"""Normaliza una celda a string strip-eado. None -> ''."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool(value) -> bool:
|
||||||
|
"""Interpreta TRUE/1/si/sí/x como True; FALSE/0/vacio como False. Default True."""
|
||||||
|
token = _norm(value).lower()
|
||||||
|
if token in TRUE_TOKENS:
|
||||||
|
return True
|
||||||
|
if token in FALSE_TOKENS:
|
||||||
|
return False
|
||||||
|
# Valor desconocido no vacio: tratar como True (la celda trae algo afirmativo).
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_threshold(value):
|
||||||
|
"""Devuelve (int>=0, None) si valido, o (None, motivo) si no.
|
||||||
|
|
||||||
|
Vacio/None -> 0. Acepta enteros y floats que sean enteros.
|
||||||
|
"""
|
||||||
|
token = _norm(value)
|
||||||
|
if token == "":
|
||||||
|
return 0, None
|
||||||
|
try:
|
||||||
|
num = float(token.replace(",", "."))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None, f"threshold no es numerico: {token!r}"
|
||||||
|
if num != int(num):
|
||||||
|
return None, f"threshold debe ser entero, recibido: {token!r}"
|
||||||
|
num = int(num)
|
||||||
|
if num < 0:
|
||||||
|
return None, f"threshold debe ser >= 0, recibido: {num}"
|
||||||
|
return num, None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_catalog_rows(rows: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""Parsea, valida y normaliza filas crudas del catalogo. Funcion pura.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: lista de dicts cabecera->valor crudo, una por fila del Excel.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(valid, skipped) donde:
|
||||||
|
- valid: lista de dicts con las 11 columnas del schema destino,
|
||||||
|
check_id unico garantizado, created_at/updated_at en ISO 8601 UTC.
|
||||||
|
- skipped: lista de dicts {"row": dict_original, "reason": str}.
|
||||||
|
"""
|
||||||
|
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||||
|
|
||||||
|
valid: list[dict] = []
|
||||||
|
skipped: list[dict] = []
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
|
||||||
|
for raw in rows:
|
||||||
|
# Ignora filas totalmente vacias.
|
||||||
|
if all(_norm(raw.get(h)) == "" for h in INPUT_HEADERS):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tipo = _norm(raw.get("tipo")).lower()
|
||||||
|
subcategoria = _norm(raw.get("subcategoria"))
|
||||||
|
pregunta = _norm(raw.get("pregunta"))
|
||||||
|
assert_sql = _norm(raw.get("assert_sql"))
|
||||||
|
severity = _norm(raw.get("severity")).lower()
|
||||||
|
owner = _norm(raw.get("owner"))
|
||||||
|
check_id = _norm(raw.get("check_id"))
|
||||||
|
|
||||||
|
# Validaciones.
|
||||||
|
if tipo not in VALID_TIPOS:
|
||||||
|
skipped.append({"row": raw, "reason": f"tipo invalido: {tipo!r} (esperado fiabilidad|negocio)"})
|
||||||
|
continue
|
||||||
|
if pregunta == "":
|
||||||
|
skipped.append({"row": raw, "reason": "pregunta vacia"})
|
||||||
|
continue
|
||||||
|
if assert_sql == "":
|
||||||
|
skipped.append({"row": raw, "reason": "assert_sql vacia"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
if severity == "":
|
||||||
|
severity = "warning"
|
||||||
|
elif severity not in VALID_SEVERITIES:
|
||||||
|
skipped.append({"row": raw, "reason": f"severity invalida: {severity!r} (esperado info|warning|critical)"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
threshold, threshold_err = _parse_threshold(raw.get("threshold"))
|
||||||
|
if threshold_err is not None:
|
||||||
|
skipped.append({"row": raw, "reason": threshold_err})
|
||||||
|
continue
|
||||||
|
|
||||||
|
enabled = _parse_bool(raw.get("enabled"))
|
||||||
|
|
||||||
|
# Auto-deriva check_id si viene vacio.
|
||||||
|
if check_id == "":
|
||||||
|
check_id = f"{tipo}__{_slug(subcategoria)}_{_slug(pregunta)[:40]}"
|
||||||
|
|
||||||
|
# Garantiza unicidad sufijando _2, _3, ...
|
||||||
|
base_id = check_id
|
||||||
|
suffix = 2
|
||||||
|
while check_id in seen_ids:
|
||||||
|
check_id = f"{base_id}_{suffix}"
|
||||||
|
suffix += 1
|
||||||
|
seen_ids.add(check_id)
|
||||||
|
|
||||||
|
valid.append({
|
||||||
|
"check_id": check_id,
|
||||||
|
"tipo": tipo,
|
||||||
|
"subcategoria": subcategoria,
|
||||||
|
"pregunta": pregunta,
|
||||||
|
"assert_sql": assert_sql,
|
||||||
|
"threshold": threshold,
|
||||||
|
"severity": severity,
|
||||||
|
"enabled": enabled,
|
||||||
|
"owner": owner,
|
||||||
|
"created_at": now_iso,
|
||||||
|
"updated_at": now_iso,
|
||||||
|
})
|
||||||
|
|
||||||
|
return valid, skipped
|
||||||
|
|
||||||
|
|
||||||
|
def _read_xlsx_rows(xlsx_path: str, sheet_name: str) -> list[dict]:
|
||||||
|
"""Lee la hoja indicada del .xlsx y devuelve filas como dicts cabecera->valor.
|
||||||
|
|
||||||
|
La fila 1 son las cabeceras. Localiza las columnas por nombre de cabecera,
|
||||||
|
no por posicion. Devuelve solo las cabeceras conocidas (INPUT_HEADERS).
|
||||||
|
"""
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
wb = load_workbook(filename=xlsx_path, read_only=True, data_only=True)
|
||||||
|
if sheet_name not in wb.sheetnames:
|
||||||
|
wb.close()
|
||||||
|
raise ValueError(
|
||||||
|
f"hoja {sheet_name!r} no encontrada en {xlsx_path}. Hojas: {wb.sheetnames}"
|
||||||
|
)
|
||||||
|
ws = wb[sheet_name]
|
||||||
|
|
||||||
|
rows_iter = ws.iter_rows(values_only=True)
|
||||||
|
try:
|
||||||
|
header_row = next(rows_iter)
|
||||||
|
except StopIteration:
|
||||||
|
wb.close()
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Mapa cabecera-normalizada -> indice de columna.
|
||||||
|
header_index: dict[str, int] = {}
|
||||||
|
for idx, cell in enumerate(header_row):
|
||||||
|
name = _norm(cell).lower()
|
||||||
|
if name in INPUT_HEADERS:
|
||||||
|
header_index[name] = idx
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
for excel_row in rows_iter:
|
||||||
|
row_dict = {}
|
||||||
|
for header in INPUT_HEADERS:
|
||||||
|
idx = header_index.get(header)
|
||||||
|
if idx is not None and idx < len(excel_row):
|
||||||
|
row_dict[header] = excel_row[idx]
|
||||||
|
else:
|
||||||
|
row_dict[header] = None
|
||||||
|
rows.append(row_dict)
|
||||||
|
|
||||||
|
wb.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def sync_xlsx_to_dq_checks(
|
||||||
|
xlsx_path: str,
|
||||||
|
project: str = "autingo-159109",
|
||||||
|
dataset: str = "data_quality",
|
||||||
|
table: str = "dq_checks",
|
||||||
|
sheet_name: str = "catalogo",
|
||||||
|
validate_sql: bool = True,
|
||||||
|
) -> dict:
|
||||||
|
"""Sincroniza un catalogo de data quality checks desde un .xlsx a BigQuery.
|
||||||
|
|
||||||
|
Lee la hoja del Excel, valida/normaliza cada fila, (opcionalmente) hace un
|
||||||
|
dry-run de cada assert_sql contra BigQuery, y carga las filas validas a
|
||||||
|
project.dataset.table con WRITE_TRUNCATE (reemplazo total: el xlsx manda).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
xlsx_path: ruta al archivo .xlsx del catalogo.
|
||||||
|
project: proyecto GCP destino. Default "autingo-159109".
|
||||||
|
dataset: dataset BigQuery destino. Default "data_quality".
|
||||||
|
table: tabla BigQuery destino. Default "dq_checks".
|
||||||
|
sheet_name: nombre de la hoja a leer. Default "catalogo".
|
||||||
|
validate_sql: si True, dry-run de cada assert_sql contra BQ antes de cargar.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict con {"loaded": int, "skipped": int, "sql_errors": [...], "rows": [...]}
|
||||||
|
donde rows es la lista de filas cargadas (con sus campos normalizados).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: si xlsx_path no existe.
|
||||||
|
ValueError: si la hoja no existe en el libro.
|
||||||
|
google.api_core.exceptions.GoogleAPICallError: si la carga a BQ falla.
|
||||||
|
"""
|
||||||
|
from bigquery import bq_auth, bq_query, bq_load_from_file
|
||||||
|
|
||||||
|
if not os.path.exists(xlsx_path):
|
||||||
|
raise FileNotFoundError(f"no existe el archivo xlsx: {xlsx_path}")
|
||||||
|
|
||||||
|
raw_rows = _read_xlsx_rows(xlsx_path, sheet_name)
|
||||||
|
valid, skipped = _parse_catalog_rows(raw_rows)
|
||||||
|
|
||||||
|
client = bq_auth(project_id=project)
|
||||||
|
|
||||||
|
sql_errors: list[dict] = []
|
||||||
|
loadable: list[dict] = []
|
||||||
|
|
||||||
|
if validate_sql:
|
||||||
|
for row in valid:
|
||||||
|
try:
|
||||||
|
bq_query(client, row["assert_sql"], dry_run=True)
|
||||||
|
loadable.append(row)
|
||||||
|
except Exception as exc: # noqa: BLE001 — capturamos cualquier fallo de BQ
|
||||||
|
sql_errors.append({"check_id": row["check_id"], "error": str(exc)})
|
||||||
|
else:
|
||||||
|
loadable = valid
|
||||||
|
|
||||||
|
# Escribe las filas cargables a un CSV temporal con QUOTE_ALL: el assert_sql
|
||||||
|
# puede contener comas, comillas y saltos de linea.
|
||||||
|
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".csv", prefix="dq_checks_")
|
||||||
|
os.close(tmp_fd)
|
||||||
|
try:
|
||||||
|
with open(tmp_path, "w", newline="", encoding="utf-8") as f:
|
||||||
|
writer = csv.writer(f, quoting=csv.QUOTE_ALL)
|
||||||
|
writer.writerow(SCHEMA_COLUMNS)
|
||||||
|
for row in loadable:
|
||||||
|
writer.writerow([
|
||||||
|
row["check_id"],
|
||||||
|
row["tipo"],
|
||||||
|
row["subcategoria"],
|
||||||
|
row["pregunta"],
|
||||||
|
row["assert_sql"],
|
||||||
|
row["threshold"],
|
||||||
|
row["severity"],
|
||||||
|
# BigQuery CSV BOOL acepta true/false en minusculas.
|
||||||
|
"true" if row["enabled"] else "false",
|
||||||
|
row["owner"],
|
||||||
|
row["created_at"],
|
||||||
|
row["updated_at"],
|
||||||
|
])
|
||||||
|
|
||||||
|
bq_load_from_file(
|
||||||
|
client,
|
||||||
|
tmp_path,
|
||||||
|
dataset,
|
||||||
|
table,
|
||||||
|
source_format="CSV",
|
||||||
|
write_disposition="WRITE_TRUNCATE",
|
||||||
|
skip_leading_rows=1,
|
||||||
|
autodetect=False,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"loaded": len(loadable),
|
||||||
|
"skipped": len(skipped),
|
||||||
|
"sql_errors": sql_errors,
|
||||||
|
"rows": loadable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = sys.argv[1] if len(sys.argv) > 1 else "/mnt/c/Users/egutierrez/Documents/dq_catalogo_asserts.xlsx"
|
||||||
|
result = sync_xlsx_to_dq_checks(path)
|
||||||
|
print(json.dumps({k: v for k, v in result.items() if k != "rows"}, ensure_ascii=False, indent=2))
|
||||||
|
print(f"loaded={result['loaded']} skipped={result['skipped']} sql_errors={len(result['sql_errors'])}")
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Tests para sync_xlsx_to_dq_checks (logica pura: parseo/validacion/derivacion).
|
||||||
|
|
||||||
|
No tocan BigQuery: la autenticacion, el dry-run y la carga quedan fuera del
|
||||||
|
test unitario. Solo se ejercita _parse_catalog_rows y sus helpers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sync_xlsx_to_dq_checks import _parse_catalog_rows, _slug, _parse_bool, _parse_threshold
|
||||||
|
|
||||||
|
|
||||||
|
def _row(**kwargs) -> dict:
|
||||||
|
base = {
|
||||||
|
"check_id": "",
|
||||||
|
"tipo": "",
|
||||||
|
"subcategoria": "",
|
||||||
|
"pregunta": "",
|
||||||
|
"assert_sql": "",
|
||||||
|
"threshold": "",
|
||||||
|
"severity": "",
|
||||||
|
"enabled": "",
|
||||||
|
"owner": "",
|
||||||
|
}
|
||||||
|
base.update(kwargs)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def test_fila_valida_minima_se_carga():
|
||||||
|
valid, skipped = _parse_catalog_rows([
|
||||||
|
_row(tipo="fiabilidad", pregunta=" hay nulos? ", assert_sql="SELECT 1", enabled="TRUE"),
|
||||||
|
])
|
||||||
|
assert len(valid) == 1
|
||||||
|
assert len(skipped) == 0
|
||||||
|
r = valid[0]
|
||||||
|
assert r["tipo"] == "fiabilidad"
|
||||||
|
assert r["pregunta"] == "hay nulos?"
|
||||||
|
assert r["severity"] == "warning" # default
|
||||||
|
assert r["threshold"] == 0 # default
|
||||||
|
assert r["enabled"] is True
|
||||||
|
assert r["owner"] == ""
|
||||||
|
assert r["created_at"] == r["updated_at"]
|
||||||
|
assert r["created_at"].endswith("Z")
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_id_se_autoderiva_cuando_vacio():
|
||||||
|
valid, _ = _parse_catalog_rows([
|
||||||
|
_row(tipo="negocio", subcategoria="Ventas Diarias",
|
||||||
|
pregunta="¿La venta es positiva?", assert_sql="SELECT 1"),
|
||||||
|
])
|
||||||
|
cid = valid[0]["check_id"]
|
||||||
|
assert cid.startswith("negocio__ventas_diarias_")
|
||||||
|
assert "la_venta_es_positiva" in cid
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_id_explicito_se_respeta():
|
||||||
|
valid, _ = _parse_catalog_rows([
|
||||||
|
_row(check_id="custom_id_1", tipo="fiabilidad",
|
||||||
|
pregunta="x", assert_sql="SELECT 1"),
|
||||||
|
])
|
||||||
|
assert valid[0]["check_id"] == "custom_id_1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_id_colision_se_sufija():
|
||||||
|
valid, _ = _parse_catalog_rows([
|
||||||
|
_row(check_id="dup", tipo="fiabilidad", pregunta="a", assert_sql="SELECT 1"),
|
||||||
|
_row(check_id="dup", tipo="negocio", pregunta="b", assert_sql="SELECT 2"),
|
||||||
|
_row(check_id="dup", tipo="negocio", pregunta="c", assert_sql="SELECT 3"),
|
||||||
|
])
|
||||||
|
ids = [r["check_id"] for r in valid]
|
||||||
|
assert ids == ["dup", "dup_2", "dup_3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tipo_invalido_se_skipea():
|
||||||
|
valid, skipped = _parse_catalog_rows([
|
||||||
|
_row(tipo="otracosa", pregunta="x", assert_sql="SELECT 1"),
|
||||||
|
])
|
||||||
|
assert valid == []
|
||||||
|
assert len(skipped) == 1
|
||||||
|
assert "tipo invalido" in skipped[0]["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tipo_case_insensitive_se_normaliza():
|
||||||
|
valid, skipped = _parse_catalog_rows([
|
||||||
|
_row(tipo=" Fiabilidad ", pregunta="x", assert_sql="SELECT 1"),
|
||||||
|
])
|
||||||
|
assert len(valid) == 1
|
||||||
|
assert skipped == []
|
||||||
|
assert valid[0]["tipo"] == "fiabilidad"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pregunta_vacia_se_skipea():
|
||||||
|
valid, skipped = _parse_catalog_rows([
|
||||||
|
_row(tipo="negocio", pregunta=" ", assert_sql="SELECT 1"),
|
||||||
|
])
|
||||||
|
assert valid == []
|
||||||
|
assert skipped[0]["reason"] == "pregunta vacia"
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_sql_vacia_se_skipea():
|
||||||
|
valid, skipped = _parse_catalog_rows([
|
||||||
|
_row(tipo="negocio", pregunta="x", assert_sql=""),
|
||||||
|
])
|
||||||
|
assert valid == []
|
||||||
|
assert skipped[0]["reason"] == "assert_sql vacia"
|
||||||
|
|
||||||
|
|
||||||
|
def test_severity_invalida_se_skipea():
|
||||||
|
valid, skipped = _parse_catalog_rows([
|
||||||
|
_row(tipo="negocio", pregunta="x", assert_sql="SELECT 1", severity="urgente"),
|
||||||
|
])
|
||||||
|
assert valid == []
|
||||||
|
assert "severity invalida" in skipped[0]["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_threshold_negativo_se_skipea():
|
||||||
|
valid, skipped = _parse_catalog_rows([
|
||||||
|
_row(tipo="negocio", pregunta="x", assert_sql="SELECT 1", threshold="-5"),
|
||||||
|
])
|
||||||
|
assert valid == []
|
||||||
|
assert "threshold debe ser >= 0" in skipped[0]["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_threshold_no_numerico_se_skipea():
|
||||||
|
valid, skipped = _parse_catalog_rows([
|
||||||
|
_row(tipo="negocio", pregunta="x", assert_sql="SELECT 1", threshold="abc"),
|
||||||
|
])
|
||||||
|
assert valid == []
|
||||||
|
assert "threshold no es numerico" in skipped[0]["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fila_totalmente_vacia_se_ignora_sin_skip():
|
||||||
|
valid, skipped = _parse_catalog_rows([
|
||||||
|
_row(), # todo vacio
|
||||||
|
_row(tipo="negocio", pregunta="x", assert_sql="SELECT 1"),
|
||||||
|
])
|
||||||
|
assert len(valid) == 1
|
||||||
|
assert skipped == [] # la vacia se ignora, no se cuenta como skipped
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_bool_variantes():
|
||||||
|
assert _parse_bool("TRUE") is True
|
||||||
|
assert _parse_bool("True") is True
|
||||||
|
assert _parse_bool(1) is True
|
||||||
|
assert _parse_bool("si") is True
|
||||||
|
assert _parse_bool("sí") is True
|
||||||
|
assert _parse_bool("x") is True
|
||||||
|
assert _parse_bool("FALSE") is False
|
||||||
|
assert _parse_bool("0") is False
|
||||||
|
assert _parse_bool("") is False
|
||||||
|
assert _parse_bool(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_threshold_defaults_y_floats_enteros():
|
||||||
|
assert _parse_threshold("") == (0, None)
|
||||||
|
assert _parse_threshold(None) == (0, None)
|
||||||
|
assert _parse_threshold("3") == (3, None)
|
||||||
|
assert _parse_threshold("3.0") == (3, None)
|
||||||
|
val, err = _parse_threshold("3.5")
|
||||||
|
assert val is None and "entero" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_slug_colapsa_y_recorta():
|
||||||
|
assert _slug(" Hola, Mundo!! ") == "hola_mundo"
|
||||||
|
assert _slug("A---B___C") == "a_b_c"
|
||||||
|
assert _slug("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_assert_sql_con_comas_y_comillas_se_conserva():
|
||||||
|
sql = 'SELECT COUNT(*) FROM t WHERE col IN ("a", "b"), foo = 1'
|
||||||
|
valid, _ = _parse_catalog_rows([
|
||||||
|
_row(tipo="fiabilidad", pregunta="x", assert_sql=sql),
|
||||||
|
])
|
||||||
|
assert valid[0]["assert_sql"] == sql
|
||||||
Reference in New Issue
Block a user