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:
2026-07-02 19:00:13 +02:00
parent 2ebc9efeb2
commit 5a4f82cf76
26 changed files with 2573 additions and 94 deletions
@@ -0,0 +1,245 @@
"""build_column_dictionary — diccionario de columnas BUSCABLE de una base entera.
Funcion pura, stdlib-only. No hace I/O, no depende de nada externo y NO muta el
input. Toma el ``db_profile`` (DatabaseProfile) que emite ``profile_database`` del
grupo de capacidad ``eda`` y aplana su ``table_profiles`` (lista de TableProfile,
cada uno con ``table`` y ``columns``: lista de ColumnProfile) en una entrada por
columna. Es la pieza que responde, a nivel de BASE, "donde esta el customer_id /
telefono / IBAN en este dataset?": un indice grep-able tabla.columna con su tipo,
tipo semantico inferido, marca de PII, % de nulos, cardinalidad y valores top.
Ademas del listado plano emite:
- ``pii_columns``: subconjunto marcado como dato personal (RGPD/LOPDGDD).
- ``markdown``: tabla grep-able ordenada por nombre de columna, precedida de una
seccion que agrupa columnas con el MISMO nombre presentes en varias tablas
(candidatas a clave de join cross-tabla).
Estilo dict-no-throw del grupo ``eda``: nunca lanza. Lee cada clave de forma
defensiva con ``.get(...)`` y tolera valores None / estructuras malformadas; ante
una entrada vacia o corrupta devuelve el resultado vacio en estado ``ok``.
Criterio de PII (politica [POL-MMNSEG-001-1.0]): se marca ``is_pii=True`` cuando el
``semantic_type`` real que emite el grupo ``eda`` (ver ``infer_semantic_type``)
pertenece al conjunto de tipos de dato personal detectables hoy: email, telefono
internacional, IBAN, tarjeta de credito y codigo postal (componente de direccion).
El catalogo de regex del grupo NO detecta hoy nombre de persona ni DNI/NIE, asi que
esas columnas caen como texto/categorico y no se marcan automaticamente: ante
cualquier duda sobre si una columna contiene datos personales, tratala como PII y
avisa antes de exponerla.
"""
# semantic_types del grupo eda (infer_semantic_type) que son dato personal.
# El grupo emite hoy: email, url, ipv4, ipv6, uuid, iban, credit_card, phone_intl,
# postal_code_es, currency, datetime_iso, date_eu, integer, decimal, boolean,
# hex_color. De esos, los que identifican a una persona fisica (RGPD/LOPDGDD) son:
_PII_SEMANTIC_TYPES = frozenset(
{
"email",
"phone_intl",
"iban",
"credit_card",
"postal_code_es", # codigo postal: componente de direccion (dato de localizacion)
}
)
# Numero maximo de valores frecuentes que se listan por columna categorica.
_TOP_VALUES_LIMIT = 5
def _empty_result() -> dict:
"""Resultado vacio en estado ok para entradas vacias o malformadas."""
return {
"status": "ok",
"n_tables": 0,
"n_columns": 0,
"entries": [],
"pii_columns": [],
"markdown": "",
}
def _top_values(col: dict) -> list | None:
"""Extrae hasta _TOP_VALUES_LIMIT valores frecuentes del bloque categorical.
``summarize_categorical`` deja ``col["categorical"]["top"]`` como lista de
``{value, count, pct}`` ordenada por frecuencia. Devuelve solo los valores
como strings, o None si la columna no tiene bloque categorical util.
"""
cat = col.get("categorical")
if not isinstance(cat, dict):
return None
top = cat.get("top")
if not isinstance(top, list) or not top:
return None
values = []
for item in top[:_TOP_VALUES_LIMIT]:
if isinstance(item, dict):
values.append(str(item.get("value")))
else:
values.append(str(item))
return values or None
def _column_entry(table_name, col: dict) -> dict:
"""Construye la entrada del diccionario para un ColumnProfile.
Lee las claves del contrato eda de forma defensiva: name, inferred_type,
semantic_type ("" se normaliza a None), null_pct (fraccion 0-1),
distinct_count (se expone como n_distinct) y el bloque categorical (top).
"""
sem_raw = col.get("semantic_type")
semantic_type = sem_raw if sem_raw else None # "" -> None
null_pct = col.get("null_pct")
if isinstance(null_pct, bool) or not isinstance(null_pct, (int, float)):
null_pct = None
else:
null_pct = float(null_pct)
n_distinct = col.get("distinct_count")
if isinstance(n_distinct, bool) or not isinstance(n_distinct, int):
n_distinct = None
return {
"table": table_name,
"column": col.get("name"),
"inferred_type": col.get("inferred_type"),
"semantic_type": semantic_type,
"is_pii": semantic_type in _PII_SEMANTIC_TYPES,
"null_pct": null_pct,
"n_distinct": n_distinct,
"top_values": _top_values(col),
}
def _render_markdown(entries: list) -> str:
"""Renderiza el diccionario en markdown grep-able.
Primero una seccion que agrupa columnas con el MISMO nombre presentes en
varias tablas (candidatas a clave de join cross-tabla), luego la tabla
completa ordenada por nombre de columna.
"""
lines = ["# Diccionario de columnas", ""]
# Seccion: columnas compartidas por nombre (candidatas a join key).
by_name: dict = {}
for e in entries:
by_name.setdefault(e["column"], set()).add(e["table"])
shared = {
name: tables
for name, tables in by_name.items()
if name is not None and len(tables) > 1
}
lines.append("## Columnas presentes en varias tablas (candidatas a join key)")
lines.append("")
if shared:
lines.append("| Columna | Tablas |")
lines.append("|---|---|")
for name in sorted(shared, key=lambda s: str(s).lower()):
tbls = ", ".join(sorted((str(t) for t in shared[name]), key=str.lower))
lines.append(f"| {name} | {tbls} |")
else:
lines.append(
"_Ninguna columna aparece con el mismo nombre en mas de una tabla._"
)
lines.append("")
# Tabla completa ordenada por nombre de columna (y tabla como desempate).
lines.append("## Columnas")
lines.append("")
lines.append(
"| Columna | Tabla | Tipo | Tipo semantico | PII | %null | Distinct |"
)
lines.append("|---|---|---|---|---|---|---|")
for e in sorted(
entries, key=lambda e: (str(e["column"]).lower(), str(e["table"]).lower())
):
sem = e["semantic_type"] or ""
pii = "SI" if e["is_pii"] else ""
null_s = (
f"{e['null_pct'] * 100:.1f}%"
if isinstance(e["null_pct"], (int, float))
else ""
)
distinct_s = str(e["n_distinct"]) if e["n_distinct"] is not None else ""
itype = e["inferred_type"] or ""
lines.append(
f"| {e['column']} | {e['table']} | {itype} | {sem} | {pii} "
f"| {null_s} | {distinct_s} |"
)
lines.append("")
return "\n".join(lines)
def build_column_dictionary(db_profile: dict) -> dict:
"""Construye el diccionario de columnas buscable de una base entera.
Recorre ``db_profile["table_profiles"]`` (lista de TableProfile del grupo eda,
cada uno con ``table`` y ``columns``) y emite una entrada por columna con su
tipo fisico inferido, tipo semantico, marca de PII, % de nulos, cardinalidad y
valores frecuentes. Responde, a nivel de base, donde vive cada dato.
Args:
db_profile: DatabaseProfile tal como lo devuelve
``profile_database`` en su clave ``db_profile`` (el dict con
``table_profiles``). Se lee de forma defensiva; una entrada vacia,
None o malformada produce el resultado vacio en estado ``ok``.
Returns:
Dict dict-no-throw (nunca lanza) con las claves:
- ``status`` (str): siempre ``"ok"``.
- ``n_tables`` (int): tablas con columnas procesadas.
- ``n_columns`` (int): total de columnas indexadas.
- ``entries`` (list[dict]): una entrada por columna con
``{table, column, inferred_type, semantic_type|None, is_pii,
null_pct|None, n_distinct|None, top_values|None}``.
- ``pii_columns`` (list[dict]): subconjunto de ``entries`` con
``is_pii=True`` (dato personal segun [POL-MMNSEG-001-1.0]).
- ``markdown`` (str): tabla grep-able ordenada por nombre de columna,
precedida de las columnas compartidas por nombre entre tablas.
"""
try:
if not isinstance(db_profile, dict):
return _empty_result()
table_profiles = db_profile.get("table_profiles")
if not isinstance(table_profiles, list) or not table_profiles:
return _empty_result()
entries: list = []
n_tables = 0
for tp in table_profiles:
if not isinstance(tp, dict):
continue
columns = tp.get("columns")
if not isinstance(columns, list):
continue
n_tables += 1
table_name = tp.get("table")
for col in columns:
if not isinstance(col, dict):
continue
entries.append(_column_entry(table_name, col))
if not entries:
return {
"status": "ok",
"n_tables": n_tables,
"n_columns": 0,
"entries": [],
"pii_columns": [],
"markdown": "",
}
pii_columns = [e for e in entries if e["is_pii"]]
return {
"status": "ok",
"n_tables": n_tables,
"n_columns": len(entries),
"entries": entries,
"pii_columns": pii_columns,
"markdown": _render_markdown(entries),
}
except Exception: # noqa: BLE001
return _empty_result()