5a4f82cf76
- 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>
246 lines
9.4 KiB
Python
246 lines
9.4 KiB
Python
"""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()
|