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,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()
|
||||
Reference in New Issue
Block a user