chore: auto-commit (26 archivos)
- python/functions/bigquery/bq_auth.md - python/functions/bigquery/bq_load_from_file.md - python/functions/bigquery/bq_load_from_gcs.md - python/functions/bigquery/client.py - python/functions/bigquery/queries.py - python/functions/datascience/__init__.py - python/functions/datascience/decode_qr_image.py - python/functions/datascience/load_bq_table_to_duckdb.md - python/functions/datascience/load_bq_table_to_duckdb.py - python/functions/pipelines/profile_bq_table.md - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
"""profile_bq_dataset — EDA one-shot de un dataset BigQuery ENTERO (cross-tabla).
|
||||
|
||||
Pipeline impuro: perfila un dataset de Google BigQuery COMPLETO con descubrimiento
|
||||
cross-tabla (relaciones FK inter-tabla + join graph + diccionario de columnas). Es
|
||||
el analogo a nivel de dataset de `profile_bq_table` (que perfila UNA tabla) y el
|
||||
adaptador BigQuery de `profile_database` (que perfila una base DuckDB entera),
|
||||
resuelto por composicion estricta de funciones del registry — sin reimplementar
|
||||
ni el descubrimiento, ni el perfilado, ni la inferencia de relaciones.
|
||||
|
||||
Clave del diseno: materializa CADA tabla del dataset en UN MISMO archivo DuckDB
|
||||
compartido, para que la inferencia de FK por containment (que necesita todas las
|
||||
tablas en la misma base) opere cross-tabla. El DuckDB compartido queda como el
|
||||
artefacto explorable post-EDA (keep_duckdb=True por defecto).
|
||||
|
||||
Funciones del registry compuestas (NO se reimplementa su logica):
|
||||
- list_bq_dataset_tables : catalogo de tablas/vistas del dataset (descubrimiento).
|
||||
- load_bq_table_to_duckdb: materializa cada tabla BigQuery al DuckDB compartido
|
||||
(completa por defecto, muestra si sample_frac; PII
|
||||
seudonimizada por tabla antes de escribir a disco).
|
||||
- profile_database : perfila el DuckDB compartido entero (perfiles por
|
||||
tabla + FK por containment + join graph Mermaid +
|
||||
report markdown/JSON + PDF movil DB-level).
|
||||
- build_column_dictionary: diccionario de columnas buscable (tipo semantico, PII)
|
||||
a partir del DatabaseProfile ensamblado.
|
||||
|
||||
Modo por defecto = FULL: `sample_frac=None` trae cada tabla entera (preferencia
|
||||
estandar del usuario: los EDA se corren sobre el total salvo que se pida lo
|
||||
contrario). El muestreo es opt-in explicito por dataset: `sample_frac=0.05` trae
|
||||
~5 % de cada tabla; `max_rows` es un tope duro por tabla (0 = sin tope).
|
||||
|
||||
Seudonimizacion LOPDGDD/RGPD [POL-MMNSEG-001-1.0]: `pseudonymize_cols` es un dict
|
||||
`{"tabla": ["col1", "col2"]}` que se aplica por tabla ANTES de materializar.
|
||||
|
||||
Estilo dict-no-throw del grupo `eda`: nunca lanza; captura cualquier error y
|
||||
devuelve {status:'error', error, stage}. Los fallos por tabla individual se
|
||||
toleran: se anotan en errors[]/tables_skipped[] y se sigue con las demas.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from datascience import (
|
||||
build_column_dictionary,
|
||||
list_bq_dataset_tables,
|
||||
load_bq_table_to_duckdb,
|
||||
)
|
||||
from pipelines.profile_database import profile_database
|
||||
|
||||
|
||||
def profile_bq_dataset(
|
||||
project_id: str,
|
||||
dataset: str,
|
||||
tables: list = None,
|
||||
include_views: bool = False,
|
||||
sample_frac: float = None,
|
||||
max_rows: int = 0,
|
||||
pseudonymize_cols: dict = None,
|
||||
report_dir: str = "reports",
|
||||
duckdb_path: str = "",
|
||||
keep_duckdb: bool = True,
|
||||
min_inclusion: float = 0.9,
|
||||
emit_pdf: bool = True,
|
||||
run_llm: bool = False,
|
||||
) -> dict:
|
||||
"""EDA one-shot de un dataset BigQuery entero, con descubrimiento cross-tabla.
|
||||
|
||||
Materializa cada tabla del dataset a un DuckDB compartido, lo perfila entero
|
||||
con `profile_database` (perfiles por tabla + FK inter-tabla + join graph +
|
||||
reports + PDF) y construye el diccionario de columnas del dataset. Por defecto
|
||||
perfila TODAS las filas de cada tabla (`sample_frac=None`, modo FULL) y solo
|
||||
las BASE TABLE (las vistas se excluyen salvo `include_views=True`).
|
||||
|
||||
Args:
|
||||
project_id: proyecto GCP (facturacion + primer segmento del FQN).
|
||||
dataset: dataset BigQuery a perfilar.
|
||||
tables: lista de NOMBRES de tabla del dataset. None (default) = todas las
|
||||
del dataset (filtradas por include_views).
|
||||
include_views: si True incluye las VIEW ademas de las BASE TABLE cuando
|
||||
tables=None. Default False (solo BASE TABLE, coherente con
|
||||
profile_database que salta las VIEWs).
|
||||
sample_frac: None (default) = FULL, perfila todas las filas de cada tabla.
|
||||
Un float en (0,1) activa el muestreo opt-in por tabla.
|
||||
max_rows: tope duro de filas por tabla (LIMIT). 0 (default) = sin tope.
|
||||
pseudonymize_cols: dict {"tabla": ["col1", "col2"]} de columnas PII a
|
||||
seudonimizar (hash) por tabla ANTES de materializar (LOPDGDD/RGPD).
|
||||
report_dir: directorio de salida de los reports + del DuckDB por defecto.
|
||||
duckdb_path: ruta del DuckDB compartido. Vacio = report_dir/eda_bq_<dataset>.duckdb.
|
||||
keep_duckdb: si True (default) conserva el DuckDB materializado (es el
|
||||
artefacto explorable post-EDA). Con False se borra al terminar.
|
||||
min_inclusion: umbral de inclusion (0-1) para emitir una FK candidata
|
||||
(se pasa a profile_database -> infer_fk_containment_duckdb).
|
||||
emit_pdf: si True (default) emite el PDF movil DB-level (se pasa a
|
||||
profile_database).
|
||||
run_llm: si True (default False) activa la capa LLM interpretativa por
|
||||
tabla (se pasa a profile_database -> profile_table; una llamada LLM
|
||||
por tabla sobre el perfil agregado, nunca filas crudas).
|
||||
|
||||
Returns:
|
||||
dict dict-no-throw con el resultado del pipeline (ver output del .md).
|
||||
"""
|
||||
try:
|
||||
# 1) Resolver la lista de tablas a materializar como (nombre, fqn).
|
||||
if tables is None:
|
||||
lst = list_bq_dataset_tables(
|
||||
project_id, dataset, include_views=include_views
|
||||
)
|
||||
if lst.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": lst.get("error", "list_bq_dataset_tables fallo"),
|
||||
"stage": "list_tables",
|
||||
}
|
||||
table_specs = [
|
||||
(t["table"], t.get("fqn") or f"{project_id}.{dataset}.{t['table']}")
|
||||
for t in lst.get("tables", [])
|
||||
]
|
||||
elif isinstance(tables, list):
|
||||
table_specs = [
|
||||
(name, f"{project_id}.{dataset}.{name}") for name in tables
|
||||
]
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "tables debe ser una lista de nombres o None",
|
||||
"stage": "list_tables",
|
||||
}
|
||||
|
||||
if not table_specs:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"el dataset no tiene tablas que perfilar "
|
||||
"(revisa include_views / el nombre del dataset)"
|
||||
),
|
||||
"stage": "list_tables",
|
||||
}
|
||||
|
||||
# 2) Resolver el DuckDB compartido. Vacio -> report_dir/eda_bq_<dataset>.duckdb.
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
created_default = False
|
||||
if not duckdb_path:
|
||||
duckdb_path = os.path.join(report_dir, f"eda_bq_{dataset}.duckdb")
|
||||
created_default = True
|
||||
# Si es el path por defecto y ya existe de una corrida previa, empezar
|
||||
# limpio para que no se mezclen tablas de datasets distintos en la base.
|
||||
if created_default and os.path.exists(duckdb_path):
|
||||
try:
|
||||
os.remove(duckdb_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 3) Materializar CADA tabla en el MISMO DuckDB (tolerando fallos).
|
||||
pseudo_map = pseudonymize_cols or {}
|
||||
loaded_tables = [] # nombres de tabla dentro del DuckDB
|
||||
tables_skipped = []
|
||||
errors = []
|
||||
for name, fqn in table_specs:
|
||||
load = load_bq_table_to_duckdb(
|
||||
fqn,
|
||||
duckdb_path,
|
||||
sample_frac=sample_frac,
|
||||
max_rows=max_rows,
|
||||
project_id=project_id,
|
||||
pseudonymize_cols=pseudo_map.get(name),
|
||||
)
|
||||
if load.get("status") == "ok":
|
||||
loaded_tables.append(load["table"])
|
||||
else:
|
||||
errors.append(
|
||||
{
|
||||
"table": name,
|
||||
"stage": "load",
|
||||
"error": load.get("error", "load fallo"),
|
||||
}
|
||||
)
|
||||
tables_skipped.append(name)
|
||||
|
||||
if not loaded_tables:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": "ninguna tabla del dataset se pudo materializar",
|
||||
"stage": "load",
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
# 4) Perfilar el DuckDB compartido entero: perfiles por tabla + FK
|
||||
# cross-tabla + join graph + report markdown/JSON + PDF (si emit_pdf).
|
||||
prof = profile_database(
|
||||
duckdb_path,
|
||||
tables=loaded_tables,
|
||||
report_dir=report_dir,
|
||||
write_report=True,
|
||||
min_inclusion=min_inclusion,
|
||||
emit_pdf=emit_pdf,
|
||||
run_llm=run_llm,
|
||||
)
|
||||
if prof.get("status") != "ok":
|
||||
return {
|
||||
"status": "error",
|
||||
"error": prof.get("error", "profile_database fallo"),
|
||||
"stage": "profile",
|
||||
}
|
||||
db_profile = prof.get("db_profile", {})
|
||||
|
||||
# 5) Diccionario de columnas del dataset sobre el DatabaseProfile.
|
||||
dict_md_path = None
|
||||
dict_json_path = None
|
||||
column_dictionary = None
|
||||
dict_res = build_column_dictionary(db_profile)
|
||||
if dict_res.get("status") == "ok":
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
dict_md_path = os.path.join(
|
||||
report_dir, f"eda_bq_dataset_{dataset}_{ts}_dict.md"
|
||||
)
|
||||
dict_json_path = os.path.join(
|
||||
report_dir, f"eda_bq_dataset_{dataset}_{ts}_dict.json"
|
||||
)
|
||||
with open(dict_md_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(dict_res.get("markdown", ""))
|
||||
# JSON sidecar del diccionario sin el markdown (compacto).
|
||||
dict_payload = {k: v for k, v in dict_res.items() if k != "markdown"}
|
||||
with open(dict_json_path, "w", encoding="utf-8") as fh:
|
||||
fh.write(
|
||||
json.dumps(dict_payload, ensure_ascii=False, indent=1, default=str)
|
||||
)
|
||||
column_dictionary = dict_payload
|
||||
else:
|
||||
errors.append(
|
||||
{
|
||||
"stage": "column_dictionary",
|
||||
"error": dict_res.get("error", "build_column_dictionary fallo"),
|
||||
}
|
||||
)
|
||||
|
||||
# 6) Limpieza del DuckDB compartido salvo que se pida conservarlo.
|
||||
final_duckdb = duckdb_path
|
||||
if not keep_duckdb and os.path.exists(duckdb_path):
|
||||
try:
|
||||
os.remove(duckdb_path)
|
||||
except OSError:
|
||||
pass
|
||||
final_duckdb = None
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"project_id": project_id,
|
||||
"dataset": dataset,
|
||||
"n_tables_loaded": len(loaded_tables),
|
||||
"n_tables_profiled": db_profile.get("n_tables", 0),
|
||||
"tables_skipped": tables_skipped,
|
||||
"errors": errors,
|
||||
"duckdb_path": final_duckdb,
|
||||
"db_profile": db_profile,
|
||||
"column_dictionary": column_dictionary,
|
||||
"report_md_path": prof.get("report_md_path"),
|
||||
"report_json_path": prof.get("report_json_path"),
|
||||
"report_pdf_path": prof.get("report_pdf_path"),
|
||||
"dict_md_path": dict_md_path,
|
||||
"dict_json_path": dict_json_path,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e), "stage": "unexpected"}
|
||||
Reference in New Issue
Block a user