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:
@@ -25,6 +25,7 @@ from .describe_numeric import describe_numeric
|
||||
from .summarize_categorical import summarize_categorical
|
||||
from .infer_semantic_type import infer_semantic_type
|
||||
from .column_quality_score import column_quality_score
|
||||
from .build_column_dictionary import build_column_dictionary
|
||||
from .select_groupby_keys import select_groupby_keys
|
||||
from .render_eda_markdown import render_eda_markdown
|
||||
from .detect_distribution_type import detect_distribution_type
|
||||
@@ -80,9 +81,13 @@ from .draw_join_graph_figure import draw_join_graph_figure
|
||||
from .generate_synthetic_eda_table import generate_synthetic_eda_table
|
||||
from .generate_synthetic_eda_folder import generate_synthetic_eda_folder
|
||||
from .load_bq_table_to_duckdb import load_bq_table_to_duckdb
|
||||
from .list_bq_dataset_tables import list_bq_dataset_tables
|
||||
from .forecast_seasonal_median import forecast_seasonal_median
|
||||
|
||||
__all__ = [
|
||||
"forecast_seasonal_median",
|
||||
"load_bq_table_to_duckdb",
|
||||
"list_bq_dataset_tables",
|
||||
"generate_synthetic_eda_table",
|
||||
"generate_synthetic_eda_folder",
|
||||
"render_paper_pdf",
|
||||
@@ -141,6 +146,7 @@ __all__ = [
|
||||
"summarize_categorical",
|
||||
"infer_semantic_type",
|
||||
"column_quality_score",
|
||||
"build_column_dictionary",
|
||||
"select_groupby_keys",
|
||||
"render_eda_markdown",
|
||||
"detect_distribution_type",
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
id: build_column_dictionary_py_datascience
|
||||
name: build_column_dictionary
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def build_column_dictionary(db_profile: dict) -> dict"
|
||||
description: "Construye el diccionario de columnas BUSCABLE de una base entera a partir del DatabaseProfile que emite profile_database (grupo eda). Aplana db_profile['table_profiles'] (lista de TableProfile con table y columns) en una entrada por columna con tabla, tipo inferido, tipo semantico, marca de PII (RGPD/LOPDGDD), %null, cardinalidad y valores top. Responde a nivel de base 'donde esta el customer_id / telefono / IBAN'. Emite tambien pii_columns y un markdown grep-able ordenado por columna, precedido de las columnas compartidas por nombre entre tablas (candidatas a join key cross-tabla). Funcion pura, dict-no-throw, no muta el input."
|
||||
tags: [eda, relations]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
example: |
|
||||
from datascience import build_column_dictionary
|
||||
db_profile = {"table_profiles": [
|
||||
{"table": "clientes", "columns": [
|
||||
{"name": "email", "inferred_type": "text", "semantic_type": "email",
|
||||
"null_pct": 0.05, "distinct_count": 990}]}]}
|
||||
res = build_column_dictionary(db_profile)
|
||||
# res["pii_columns"] -> [{"table": "clientes", "column": "email", "is_pii": True, ...}]
|
||||
tested: true
|
||||
tests:
|
||||
- "test_golden_flattens_two_tables"
|
||||
- "test_pii_flagged_from_semantic_type"
|
||||
- "test_empty_semantic_type_maps_to_none_and_not_pii"
|
||||
- "test_shared_column_names_detected_as_join_keys"
|
||||
- "test_top_values_from_categorical_block"
|
||||
- "test_empty_profile_returns_empty_ok"
|
||||
- "test_malformed_input_returns_empty_ok"
|
||||
- "test_missing_keys_read_defensively"
|
||||
- "test_does_not_mutate_input"
|
||||
test_file_path: "python/functions/datascience/build_column_dictionary_test.py"
|
||||
file_path: "python/functions/datascience/build_column_dictionary.py"
|
||||
params:
|
||||
- name: db_profile
|
||||
desc: >
|
||||
DatabaseProfile del grupo eda tal como lo devuelve profile_database en su
|
||||
clave db_profile (el dict con table_profiles). table_profiles es una lista
|
||||
de TableProfile; de cada uno se leen table (nombre) y columns (lista de
|
||||
ColumnProfile). De cada ColumnProfile se leen defensivamente con .get(...):
|
||||
name, inferred_type (numeric|categorical|datetime|text|boolean),
|
||||
semantic_type ("" que se normaliza a None; los que emite infer_semantic_type:
|
||||
email, iban, credit_card, phone_intl, postal_code_es, ...), null_pct
|
||||
(fraccion 0-1), distinct_count (cardinalidad, expuesta como n_distinct) y el
|
||||
bloque categorical.top (para top_values). Una entrada vacia, None o
|
||||
malformada produce el resultado vacio en estado ok (nunca lanza).
|
||||
output: >
|
||||
dict dict-no-throw con status ("ok" siempre), n_tables (int, tablas con columnas
|
||||
procesadas), n_columns (int total de columnas), entries (list[dict] una por
|
||||
columna con table, column, inferred_type, semantic_type|None, is_pii (bool),
|
||||
null_pct (float 0-1|None), n_distinct (int|None), top_values (list[str]|None)),
|
||||
pii_columns (subconjunto de entries con is_pii=True: dato personal segun
|
||||
[POL-MMNSEG-001-1.0]) y markdown (str, tabla grep-able ordenada por nombre de
|
||||
columna precedida de las columnas compartidas por nombre entre tablas). Entrada
|
||||
vacia o malformada -> n_tables/n_columns 0, listas vacias, markdown "".
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import build_column_dictionary
|
||||
|
||||
# db_profile minimo de juguete (forma de la clave db_profile de profile_database).
|
||||
db_profile = {
|
||||
"table_profiles": [
|
||||
{
|
||||
"table": "clientes",
|
||||
"columns": [
|
||||
{"name": "customer_id", "inferred_type": "numeric",
|
||||
"semantic_type": "", "null_pct": 0.0, "distinct_count": 1000},
|
||||
{"name": "email", "inferred_type": "text",
|
||||
"semantic_type": "email", "null_pct": 0.05, "distinct_count": 990},
|
||||
{"name": "ciudad", "inferred_type": "categorical",
|
||||
"semantic_type": "", "null_pct": 0.0, "distinct_count": 3,
|
||||
"categorical": {"top": [
|
||||
{"value": "Madrid", "count": 5, "pct": 0.5},
|
||||
{"value": "Bilbao", "count": 3, "pct": 0.3}]}},
|
||||
],
|
||||
},
|
||||
{
|
||||
"table": "pedidos",
|
||||
"columns": [
|
||||
{"name": "customer_id", "inferred_type": "numeric",
|
||||
"semantic_type": "", "null_pct": 0.0, "distinct_count": 800},
|
||||
{"name": "iban", "inferred_type": "text",
|
||||
"semantic_type": "iban", "null_pct": 0.1, "distinct_count": 795},
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
res = build_column_dictionary(db_profile)
|
||||
print(res["n_tables"], res["n_columns"]) # 2 5
|
||||
print([(e["table"], e["column"]) for e in res["pii_columns"]])
|
||||
# [('clientes', 'email'), ('pedidos', 'iban')]
|
||||
print(res["markdown"]) # tabla grep-able + seccion de join keys (customer_id)
|
||||
```
|
||||
|
||||
Uso real componiendo con `profile_database` (perfila la base y construye el diccionario):
|
||||
|
||||
```python
|
||||
from pipelines.profile_database import profile_database
|
||||
from datascience import build_column_dictionary
|
||||
|
||||
r = profile_database("mi_base.duckdb", write_report=False)
|
||||
if r["status"] == "ok":
|
||||
dicc = build_column_dictionary(r["db_profile"])
|
||||
# grep sobre dicc["markdown"] para localizar donde vive cada dato,
|
||||
# dicc["pii_columns"] para el inventario RGPD de la base.
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Usala cuando necesites un indice tabla.columna de una base ENTERA: para localizar
|
||||
por busqueda "donde esta el customer_id / telefono / IBAN" antes de escribir un
|
||||
join, para descubrir claves de join cross-tabla (columnas con el mismo nombre en
|
||||
varias tablas) o para levantar un inventario de columnas con datos personales
|
||||
(RGPD/LOPDGDD) sobre el que auditar. Es el paso natural despues de
|
||||
`profile_database`: toma su `db_profile` y lo convierte en diccionario buscable.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- El criterio de PII se basa SOLO en el `semantic_type` que hoy emite el grupo
|
||||
`eda` (`infer_semantic_type`): se marcan email, phone_intl, iban, credit_card y
|
||||
postal_code_es. El catalogo de regex NO detecta hoy nombre de persona ni DNI/NIE,
|
||||
asi que esas columnas caen como texto/categorico y NO se marcan automaticamente.
|
||||
Politica [POL-MMNSEG-001-1.0]: ante cualquier duda sobre si una columna contiene
|
||||
datos personales, tratala como PII y avisa antes de exponerla; `pii_columns` es
|
||||
una ayuda, no un inventario RGPD exhaustivo.
|
||||
- `n_distinct` se lee de la clave `distinct_count` del ColumnProfile (no de
|
||||
`categorical.n_distinct`); en tablas grandes puede venir de `approx_unique`
|
||||
(HyperLogLog) capado a n_rows, no exacto.
|
||||
- `top_values` solo se rellena si la columna trae bloque `categorical` (lo pone
|
||||
`profile_table` para columnas categorical/text); las numericas/datetime lo
|
||||
dejan en None.
|
||||
- Funcion pura: no toca disco ni muta el input. NO perfila la base — eso lo hace
|
||||
`profile_database`; aqui solo se APLANA su salida.
|
||||
@@ -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()
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Tests para build_column_dictionary.
|
||||
|
||||
Verifica el aplanado de un DatabaseProfile del grupo eda a un diccionario de
|
||||
columnas buscable: entradas por columna, marca de PII desde el semantic_type,
|
||||
deteccion de columnas compartidas por nombre (join keys), lectura defensiva y
|
||||
que la funcion es pura (no muta el input).
|
||||
"""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from build_column_dictionary import build_column_dictionary
|
||||
|
||||
|
||||
def _col(name, inferred_type="categorical", semantic_type="", null_pct=0.0,
|
||||
distinct_count=10, categorical=None) -> dict:
|
||||
"""ColumnProfile minimo con las claves del contrato eda usadas por la funcion."""
|
||||
return {
|
||||
"name": name,
|
||||
"physical_type": "VARCHAR",
|
||||
"inferred_type": inferred_type,
|
||||
"semantic_type": semantic_type,
|
||||
"null_pct": null_pct,
|
||||
"distinct_count": distinct_count,
|
||||
"flags": [],
|
||||
"numeric": None,
|
||||
"categorical": categorical,
|
||||
"datetime": None,
|
||||
}
|
||||
|
||||
|
||||
def _db_profile() -> dict:
|
||||
"""DatabaseProfile de juguete con dos tablas y una columna de join comun."""
|
||||
return {
|
||||
"db_path": "toy.duckdb",
|
||||
"n_tables": 2,
|
||||
"table_profiles": [
|
||||
{
|
||||
"table": "clientes",
|
||||
"columns": [
|
||||
_col("customer_id", "numeric", "", 0.0, 1000),
|
||||
_col("email", "text", "email", 0.05, 990),
|
||||
_col(
|
||||
"ciudad",
|
||||
"categorical",
|
||||
"",
|
||||
0.0,
|
||||
3,
|
||||
categorical={
|
||||
"top": [
|
||||
{"value": "Madrid", "count": 5, "pct": 0.5},
|
||||
{"value": "Bilbao", "count": 3, "pct": 0.3},
|
||||
]
|
||||
},
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
"table": "pedidos",
|
||||
"columns": [
|
||||
_col("customer_id", "numeric", "", 0.0, 800),
|
||||
_col("iban", "text", "iban", 0.1, 795),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Golden
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_golden_flattens_two_tables():
|
||||
res = build_column_dictionary(_db_profile())
|
||||
assert res["status"] == "ok"
|
||||
assert res["n_tables"] == 2
|
||||
assert res["n_columns"] == 5
|
||||
# Una entrada por columna, con las claves del contrato.
|
||||
keys = {
|
||||
"table", "column", "inferred_type", "semantic_type",
|
||||
"is_pii", "null_pct", "n_distinct", "top_values",
|
||||
}
|
||||
for e in res["entries"]:
|
||||
assert keys.issubset(e.keys())
|
||||
# El markdown tiene la tabla y la seccion de join keys.
|
||||
assert "## Columnas" in res["markdown"]
|
||||
assert "candidatas a join key" in res["markdown"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# PII desde el semantic_type real del grupo
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_pii_flagged_from_semantic_type():
|
||||
res = build_column_dictionary(_db_profile())
|
||||
pii_cols = {(e["table"], e["column"]) for e in res["pii_columns"]}
|
||||
assert ("clientes", "email") in pii_cols
|
||||
assert ("pedidos", "iban") in pii_cols
|
||||
# customer_id / ciudad NO son PII.
|
||||
assert ("clientes", "customer_id") not in pii_cols
|
||||
assert ("clientes", "ciudad") not in pii_cols
|
||||
# Coherencia entre is_pii en entries y la lista pii_columns.
|
||||
assert res["pii_columns"] == [e for e in res["entries"] if e["is_pii"]]
|
||||
|
||||
|
||||
def test_empty_semantic_type_maps_to_none_and_not_pii():
|
||||
res = build_column_dictionary(_db_profile())
|
||||
ciudad = next(
|
||||
e for e in res["entries"]
|
||||
if e["table"] == "clientes" and e["column"] == "ciudad"
|
||||
)
|
||||
assert ciudad["semantic_type"] is None
|
||||
assert ciudad["is_pii"] is False
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Columnas compartidas por nombre = candidatas a join key
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_shared_column_names_detected_as_join_keys():
|
||||
res = build_column_dictionary(_db_profile())
|
||||
md = res["markdown"]
|
||||
# customer_id aparece en las dos tablas -> listada en la seccion de join keys.
|
||||
join_section = md.split("## Columnas\n")[0]
|
||||
assert "customer_id" in join_section
|
||||
assert "clientes" in join_section and "pedidos" in join_section
|
||||
# email solo esta en una tabla -> no aparece en la seccion de join keys.
|
||||
assert "email" not in join_section
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# top_values desde el bloque categorical
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_top_values_from_categorical_block():
|
||||
res = build_column_dictionary(_db_profile())
|
||||
ciudad = next(e for e in res["entries"] if e["column"] == "ciudad")
|
||||
assert ciudad["top_values"] == ["Madrid", "Bilbao"]
|
||||
# Columnas sin bloque categorical -> None.
|
||||
email = next(e for e in res["entries"] if e["column"] == "email")
|
||||
assert email["top_values"] is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Entrada vacia / malformada -> resultado vacio en ok
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_empty_profile_returns_empty_ok():
|
||||
empty = build_column_dictionary({})
|
||||
assert empty == {
|
||||
"status": "ok", "n_tables": 0, "n_columns": 0,
|
||||
"entries": [], "pii_columns": [], "markdown": "",
|
||||
}
|
||||
|
||||
|
||||
def test_malformed_input_returns_empty_ok():
|
||||
for bad in (None, [], "nope", 42, {"table_profiles": "x"}):
|
||||
res = build_column_dictionary(bad)
|
||||
assert res["status"] == "ok"
|
||||
assert res["n_columns"] == 0
|
||||
assert res["entries"] == []
|
||||
assert res["markdown"] == ""
|
||||
|
||||
|
||||
def test_missing_keys_read_defensively():
|
||||
# TableProfiles y columnas con claves ausentes / basura no rompen.
|
||||
profile = {
|
||||
"table_profiles": [
|
||||
{"table": "t1", "columns": [{"name": "a"}, "no-dict", None]},
|
||||
"no-dict",
|
||||
{"table": "t2"}, # sin columns
|
||||
{"columns": [{}]}, # sin table, columna vacia
|
||||
]
|
||||
}
|
||||
res = build_column_dictionary(profile)
|
||||
assert res["status"] == "ok"
|
||||
# t1 (1 col dict valida; "no-dict" y None se saltan) + tabla sin table
|
||||
# (1 col {}). t2 no tiene columns -> no cuenta como tabla.
|
||||
assert res["n_tables"] == 2
|
||||
assert res["n_columns"] == 2
|
||||
a = next(e for e in res["entries"] if e["column"] == "a")
|
||||
assert a["semantic_type"] is None
|
||||
assert a["null_pct"] is None
|
||||
assert a["n_distinct"] is None
|
||||
assert a["top_values"] is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pureza
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_does_not_mutate_input():
|
||||
profile = _db_profile()
|
||||
snapshot = copy.deepcopy(profile)
|
||||
build_column_dictionary(profile)
|
||||
assert profile == snapshot
|
||||
@@ -23,15 +23,20 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
# OpenCV (cv2) se importa de forma perezosa dentro de las funciones que lo usan:
|
||||
# un import a nivel de módulo rompería `import datascience` en entornos sin
|
||||
# opencv instalado (p. ej. venvs de analysis que solo usan las funciones de
|
||||
# series temporales o perfilado del paquete).
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# Detectores. Cada uno se normaliza a una función run(img) -> list[str] que nunca lanza.
|
||||
# --------------------------------------------------------------------------------------------
|
||||
def _make_opencv_runner(detector):
|
||||
"""Envuelve un cv2.QRCodeDetector(Aruco) en run(img) -> list[str]."""
|
||||
import cv2
|
||||
|
||||
def run(img):
|
||||
out: list[str] = []
|
||||
@@ -89,6 +94,8 @@ def _make_pyzbar_runner(zbar_decode):
|
||||
|
||||
def _build_detectors(debug=False):
|
||||
"""Construye la lista de (nombre, runner) de detectores disponibles, en orden de preferencia."""
|
||||
import cv2
|
||||
|
||||
detectors = []
|
||||
|
||||
# OpenCV Aruco (preferido): no requiere libs de sistema ni descarga de modelos.
|
||||
@@ -135,6 +142,8 @@ def _build_detectors(debug=False):
|
||||
# --------------------------------------------------------------------------------------------
|
||||
def _load_bgr(image_path):
|
||||
"""Carga la imagen como BGR (uint8). Devuelve None si no se puede leer."""
|
||||
import cv2
|
||||
|
||||
bgr = cv2.imread(image_path, cv2.IMREAD_COLOR)
|
||||
if bgr is not None:
|
||||
return bgr
|
||||
@@ -150,6 +159,8 @@ def _load_bgr(image_path):
|
||||
|
||||
def _build_variants(image_path, upscale):
|
||||
"""Genera (nombre, ndarray) de variantes preprocesadas, en orden de prioridad."""
|
||||
import cv2
|
||||
|
||||
bgr = _load_bgr(image_path)
|
||||
if bgr is None:
|
||||
return []
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: forecast_seasonal_median
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "def forecast_seasonal_median(history: list[dict], horizon_dates: list[str], as_of: str, dow_weeks: int = 8, trend_recent_weeks: int = 4, trend_clip: tuple = (0.5, 2.0)) -> list[dict]"
|
||||
description: "Forecast diario por mediana estacional (mismo dia de semana) mas factor de tendencia acotado, para una o varias series temporales. Base estacional = mediana del valor en las ultimas dow_weeks fechas con el mismo dia de semana que la fecha objetivo (dias ausentes = 0, para series intermitentes). Factor de tendencia por serie = razon de la suma de las ultimas trend_recent_weeks semanas frente a las trend_recent_weeks anteriores, clipped a trend_clip. y_pred = max(0, base * factor). Funcion pura y determinista (solo stdlib, sin I/O ni datetime.now). Nucleo del forecast de ventas diarias Aurgi (dia x centro x subcategoria CGQ)."
|
||||
tags: [forecast, bigquery, timeseries, seasonal, median, baseline, sales, aurgi, python]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: history
|
||||
desc: "lista de observaciones {series_id: str, date: 'YYYY-MM-DD', value: float}. Filas duplicadas (misma serie+fecha) se suman. Los dias sin fila dentro de las ventanas cuentan como valor 0 (series intermitentes: sin fila = sin venta)"
|
||||
- name: horizon_dates
|
||||
desc: "fechas futuras a predecir, strings ISO 'YYYY-MM-DD'. Tipicamente as_of+1..as_of+horizon"
|
||||
- name: as_of
|
||||
desc: "fecha de corte 'YYYY-MM-DD': ultimo dia de historia utilizable, inclusive. Todas las ventanas se calculan hacia atras desde aqui"
|
||||
- name: dow_weeks
|
||||
desc: "numero de fechas del mismo dia de semana que la objetivo a promediar (mediana) para la base estacional. Default 8 (8 semanas)"
|
||||
- name: trend_recent_weeks
|
||||
desc: "tamano en semanas de cada una de las dos ventanas de tendencia (reciente y anterior). Default 4: compara 4 semanas recientes vs las 4 previas"
|
||||
- name: trend_clip
|
||||
desc: "tupla (min, max) al que se acota el factor de tendencia. Default (0.5, 2.0): la prediccion no puede caer a menos de la mitad ni superar el doble por tendencia"
|
||||
output: "list[dict]: una fila {series_id: str, date: str, y_pred: float} por cada serie presente en history y cada fecha de horizon_dates. Ordenada por series_id (asc) y luego por el orden de horizon_dates. y_pred siempre >= 0.0"
|
||||
tested: true
|
||||
tests:
|
||||
- "serie regular con patron semanal claro da la mediana correcta"
|
||||
- "serie intermitente: los dias ausentes cuentan como 0 en la mediana"
|
||||
- "serie con tendencia creciente aplica factor >1 acotado a trend_clip"
|
||||
- "sin datos en la ventana anterior, el factor de tendencia es 1.0"
|
||||
- "horizon de 7 dias produce una fila por serie y fecha, ordenadas"
|
||||
test_file_path: "python/functions/datascience/forecast_seasonal_median_test.py"
|
||||
file_path: "python/functions/datascience/forecast_seasonal_median.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import forecast_seasonal_median
|
||||
|
||||
# Historia diaria por serie (centro|subcategoria). Sin fila = sin venta = 0.
|
||||
history = [
|
||||
{"series_id": "12|NEUMATICOS", "date": "2026-06-23", "value": 1450.0},
|
||||
{"series_id": "12|NEUMATICOS", "date": "2026-06-16", "value": 1380.0},
|
||||
{"series_id": "12|NEUMATICOS", "date": "2026-06-09", "value": 1500.0},
|
||||
# ... mas historia (idealmente >= 8 semanas para la base estacional) ...
|
||||
]
|
||||
|
||||
# as_of = ultimo dia cerrado; predice los 7 dias siguientes.
|
||||
horizon = ["2026-06-30", "2026-07-01", "2026-07-02", "2026-07-03",
|
||||
"2026-07-04", "2026-07-05", "2026-07-06"]
|
||||
|
||||
preds = forecast_seasonal_median(history, horizon, as_of="2026-06-29")
|
||||
for p in preds:
|
||||
print(p["series_id"], p["date"], round(p["y_pred"], 2))
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
Cuando necesites un baseline de forecast diario robusto y explicable para series
|
||||
con estacionalidad semanal fuerte (ventas por dia de la semana) y posibles huecos
|
||||
(dias sin venta). Es el nucleo puro del pipeline `run_sales_forecast`: se llama una
|
||||
vez con toda la historia agregada y devuelve todas las predicciones de golpe.
|
||||
Usala como punto de partida antes de modelos mas pesados (Prophet, ARIMA, gradient
|
||||
boosting): captura el patron dia-de-semana + una correccion de tendencia acotada
|
||||
sin dependencias externas ni entrenamiento. Ideal para muchas series a la vez
|
||||
(miles de pares centro x subcategoria) donde entrenar un modelo por serie no
|
||||
compensa.
|
||||
|
||||
## Notas
|
||||
|
||||
- Funcion pura y determinista: no hace I/O, no llama `datetime.now()`; el corte
|
||||
temporal siempre es el argumento `as_of` explicito. Solo stdlib (datetime,
|
||||
statistics), sin numpy ni pandas.
|
||||
- La base estacional toma las fechas EXACTAS del calendario: la mas reciente
|
||||
<= as_of con el mismo dia de semana que la objetivo, y de ahi 7 dias hacia atras
|
||||
por punto (hasta `dow_weeks` puntos). Una fecha ausente en `history` cuenta como
|
||||
0, por lo que la mediana refleja bien las series intermitentes.
|
||||
- El factor de tendencia se calcula UNA vez por serie (no depende de la fecha
|
||||
objetivo) como razon de sumas de dos ventanas contiguas de `trend_recent_weeks`
|
||||
semanas. Denominador 0 => factor 1.0 (evita division por cero y no infla series
|
||||
que arrancan). El clip a `trend_clip` evita que un pico reciente dispare la
|
||||
prediccion.
|
||||
- `y_pred = max(0.0, base * factor)`: nunca negativo. No modela festivos ni eventos
|
||||
puntuales; para eso se necesitaria una capa de calendario adicional.
|
||||
- Para que la base estacional sea fiable conviene aportar >= `dow_weeks` semanas de
|
||||
historia. Con menos historia, los puntos ausentes (=0) empujan la mediana hacia
|
||||
abajo.
|
||||
@@ -0,0 +1,126 @@
|
||||
"""forecast_seasonal_median — forecast diario por mediana estacional + tendencia.
|
||||
|
||||
Funcion PURA (sin I/O, sin datetime.now(), determinista). Predice el valor futuro
|
||||
de una o varias series temporales diarias combinando dos senales:
|
||||
|
||||
1. Base estacional: la mediana del valor en las ultimas `dow_weeks` fechas con el
|
||||
MISMO dia de semana que la fecha objetivo (dias ausentes = 0, para series
|
||||
intermitentes donde "sin fila" significa "sin venta").
|
||||
2. Factor de tendencia por serie: cuanto ha crecido/caido la actividad reciente
|
||||
respecto al periodo inmediatamente anterior (razon de sumas), acotado a un
|
||||
rango para no amplificar ruido.
|
||||
|
||||
Disenada para el forecast de ventas diarias de Aurgi (dia x centro x subcategoria
|
||||
CGQ): cada serie es un par centro|subcategoria y el patron semanal domina la
|
||||
demanda (los sabados venden distinto que los martes). Solo usa stdlib
|
||||
(datetime, statistics).
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
from statistics import median
|
||||
|
||||
|
||||
def _to_date(value: str) -> date:
|
||||
"""Convierte una fecha ISO 'YYYY-MM-DD' (o datetime.date) a datetime.date."""
|
||||
if isinstance(value, date) and not isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
return datetime.strptime(value[:10], "%Y-%m-%d").date()
|
||||
|
||||
|
||||
def forecast_seasonal_median(
|
||||
history: list[dict],
|
||||
horizon_dates: list[str],
|
||||
as_of: str,
|
||||
dow_weeks: int = 8,
|
||||
trend_recent_weeks: int = 4,
|
||||
trend_clip: tuple = (0.5, 2.0),
|
||||
) -> list[dict]:
|
||||
"""Predice el valor de cada serie para cada fecha del horizonte.
|
||||
|
||||
Para cada serie presente en `history` y cada fecha objetivo del horizonte:
|
||||
|
||||
1. Base estacional = mediana del valor en las ultimas `dow_weeks` fechas con el
|
||||
MISMO dia de semana que la fecha objetivo, todas <= `as_of`. Se toman las
|
||||
fechas EXACTAS del calendario (la mas reciente <= as_of con ese dia de
|
||||
semana, y de ahi 7 dias hacia atras por punto); una fecha ausente en la
|
||||
historia cuenta como 0 (series intermitentes).
|
||||
2. Factor de tendencia por serie = suma de los valores de las ultimas
|
||||
`trend_recent_weeks` semanas (desde `as_of` hacia atras) dividida entre la
|
||||
suma de las `trend_recent_weeks` semanas anteriores a esas. Si el
|
||||
denominador es 0 el factor es 1.0. Se acota a `trend_clip`.
|
||||
3. y_pred = max(0.0, base * factor).
|
||||
|
||||
Args:
|
||||
history: observaciones {"series_id": str, "date": "YYYY-MM-DD",
|
||||
"value": float}. Filas duplicadas (misma serie y fecha) se suman. Los
|
||||
dias sin fila dentro de las ventanas se tratan como valor 0.
|
||||
horizon_dates: fechas futuras a predecir (strings ISO 'YYYY-MM-DD').
|
||||
as_of: fecha de corte (ultimo dia de historia utilizable, inclusive).
|
||||
dow_weeks: numero de fechas del mismo dia de semana a promediar para la
|
||||
base estacional. Default 8.
|
||||
trend_recent_weeks: tamano (en semanas) de cada una de las dos ventanas de
|
||||
tendencia (reciente y anterior). Default 4.
|
||||
trend_clip: (min, max) al que se acota el factor de tendencia. Default
|
||||
(0.5, 2.0): la prediccion no puede menos que caer a la mitad ni mas
|
||||
que duplicarse por tendencia.
|
||||
|
||||
Returns:
|
||||
Lista de {"series_id": str, "date": str, "y_pred": float}, una fila por
|
||||
cada serie presente en `history` y cada fecha del horizonte. Ordenada por
|
||||
series_id (asc) y luego por el orden de `horizon_dates`.
|
||||
"""
|
||||
as_of_d = _to_date(as_of)
|
||||
lo_clip, hi_clip = trend_clip
|
||||
|
||||
# Mapa (series_id, date) -> valor acumulado + conjunto de series presentes.
|
||||
values: dict[tuple[str, date], float] = {}
|
||||
series_ids: set[str] = set()
|
||||
for obs in history:
|
||||
sid = obs["series_id"]
|
||||
d = _to_date(obs["date"])
|
||||
v = float(obs.get("value", 0.0) or 0.0)
|
||||
series_ids.add(sid)
|
||||
values[(sid, d)] = values.get((sid, d), 0.0) + v
|
||||
|
||||
# Ventanas de tendencia (en dias) relativas a as_of.
|
||||
span = 7 * trend_recent_weeks
|
||||
recent_lo = as_of_d - timedelta(days=span) # reciente: recent_lo < d <= as_of
|
||||
prior_lo = as_of_d - timedelta(days=2 * span) # anterior: prior_lo < d <= recent_lo
|
||||
|
||||
# Factor de tendencia por serie (una sola vez por serie, no depende del horizonte).
|
||||
trend_factor: dict[str, float] = {}
|
||||
for sid in series_ids:
|
||||
recent_sum = 0.0
|
||||
prior_sum = 0.0
|
||||
for (s, d), v in values.items():
|
||||
if s != sid:
|
||||
continue
|
||||
if recent_lo < d <= as_of_d:
|
||||
recent_sum += v
|
||||
elif prior_lo < d <= recent_lo:
|
||||
prior_sum += v
|
||||
if prior_sum == 0.0:
|
||||
factor = 1.0
|
||||
else:
|
||||
factor = recent_sum / prior_sum
|
||||
trend_factor[sid] = min(hi_clip, max(lo_clip, factor))
|
||||
|
||||
horizon = [_to_date(h) for h in horizon_dates]
|
||||
out: list[dict] = []
|
||||
for sid in sorted(series_ids):
|
||||
factor = trend_factor[sid]
|
||||
for h_str, h_d in zip(horizon_dates, horizon):
|
||||
# Fecha mas reciente <= as_of con el mismo dia de semana que la objetivo.
|
||||
back = (as_of_d.weekday() - h_d.weekday()) % 7
|
||||
anchor = as_of_d - timedelta(days=back)
|
||||
dow_values = [
|
||||
values.get((sid, anchor - timedelta(days=7 * i)), 0.0)
|
||||
for i in range(dow_weeks)
|
||||
]
|
||||
base = median(dow_values)
|
||||
y_pred = max(0.0, base * factor)
|
||||
out.append({"series_id": sid, "date": h_str, "y_pred": y_pred})
|
||||
|
||||
return out
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests para forecast_seasonal_median."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import date, timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from forecast_seasonal_median import forecast_seasonal_median
|
||||
|
||||
|
||||
def _iso(d: date) -> str:
|
||||
return d.isoformat()
|
||||
|
||||
|
||||
def test_serie_regular_patron_semanal_mediana_correcta():
|
||||
"""serie regular con patron semanal claro da la mediana correcta."""
|
||||
# as_of martes; historia diaria de 12 semanas con valor fijo por dia de semana
|
||||
# y patron constante (tendencia neutra -> factor 1).
|
||||
as_of = date(2026, 6, 30) # martes
|
||||
by_weekday = {0: 100.0, 1: 10.0, 2: 20.0, 3: 30.0, 4: 40.0, 5: 5.0, 6: 0.0}
|
||||
history = []
|
||||
for i in range(84): # 12 semanas de dias
|
||||
d = as_of - timedelta(days=i)
|
||||
history.append({"series_id": "c1|sub", "date": _iso(d), "value": by_weekday[d.weekday()]})
|
||||
|
||||
horizon = [_iso(as_of + timedelta(days=k)) for k in range(1, 8)] # 7 dias
|
||||
result = forecast_seasonal_median(history, horizon, _iso(as_of))
|
||||
|
||||
# 1 serie x 7 fechas de horizonte
|
||||
assert len(result) == 7
|
||||
for row in result:
|
||||
wd = date.fromisoformat(row["date"]).weekday()
|
||||
# base = mediana de 8 semanas del mismo valor constante; factor = 1.
|
||||
assert row["y_pred"] == by_weekday[wd]
|
||||
assert row["series_id"] == "c1|sub"
|
||||
|
||||
|
||||
def test_serie_intermitente_con_ceros():
|
||||
"""serie intermitente: los dias ausentes cuentan como 0 en la mediana."""
|
||||
# as_of martes. La serie solo vende martes alternos (w=0,2,4,6), el resto 0.
|
||||
as_of = date(2026, 6, 30) # martes
|
||||
history = []
|
||||
for w in (0, 2, 4, 6):
|
||||
history.append({"series_id": "s", "date": _iso(as_of - timedelta(days=7 * w)), "value": 40.0})
|
||||
|
||||
horizon = [_iso(as_of + timedelta(days=7))] # proximo martes
|
||||
result = forecast_seasonal_median(history, horizon, _iso(as_of))
|
||||
|
||||
# dow_weeks=8 martes: [40,0,40,0,40,0,40,0] -> mediana (0+40)/2 = 20.
|
||||
# tendencia: reciente (w0..3)=40+40=80, anterior (w4..7)=40+40=80 -> factor 1.
|
||||
assert len(result) == 1
|
||||
assert result[0]["y_pred"] == 20.0
|
||||
|
||||
|
||||
def test_serie_con_tendencia_creciente_factor_clipped():
|
||||
"""serie con tendencia creciente aplica factor >1 acotado a trend_clip."""
|
||||
as_of = date(2026, 6, 30) # martes
|
||||
# Reciente (4 martes) = 30 c/u, anterior (4 martes) = 10 c/u.
|
||||
vals = {0: 30.0, 1: 30.0, 2: 30.0, 3: 30.0, 4: 10.0, 5: 10.0, 6: 10.0, 7: 10.0}
|
||||
history = [
|
||||
{"series_id": "s", "date": _iso(as_of - timedelta(days=7 * w)), "value": v}
|
||||
for w, v in vals.items()
|
||||
]
|
||||
|
||||
horizon = [_iso(as_of + timedelta(days=7))]
|
||||
result = forecast_seasonal_median(history, horizon, _iso(as_of))
|
||||
|
||||
# base = mediana de [30,30,30,30,10,10,10,10] = 20.
|
||||
# factor = 120/40 = 3.0 -> clipped a 2.0 (trend_clip=(0.5,2.0)).
|
||||
# y_pred = 20 * 2.0 = 40.
|
||||
assert result[0]["y_pred"] == 40.0
|
||||
|
||||
|
||||
def test_serie_sin_datos_en_denominador_tendencia_factor_1():
|
||||
"""sin datos en la ventana anterior, el factor de tendencia es 1.0."""
|
||||
as_of = date(2026, 6, 30) # martes
|
||||
# Solo hay datos en las 4 semanas recientes (w=0..3); nada mas antiguo.
|
||||
history = [
|
||||
{"series_id": "s", "date": _iso(as_of - timedelta(days=7 * w)), "value": 50.0}
|
||||
for w in range(4)
|
||||
]
|
||||
|
||||
horizon = [_iso(as_of + timedelta(days=7))]
|
||||
result = forecast_seasonal_median(history, horizon, _iso(as_of))
|
||||
|
||||
# denominador (semanas anteriores) = 0 -> factor 1.0 (no crashea).
|
||||
# base = mediana [50,50,50,50,0,0,0,0] = (0+50)/2 = 25 -> y_pred = 25.
|
||||
assert result[0]["y_pred"] == 25.0
|
||||
|
||||
|
||||
def test_horizon_de_7_dias_una_fila_por_serie_y_fecha():
|
||||
"""horizon de 7 dias produce una fila por serie y fecha, ordenadas."""
|
||||
as_of = date(2026, 6, 30)
|
||||
history = [
|
||||
{"series_id": "b|x", "date": _iso(as_of - timedelta(days=7 * w)), "value": 12.0}
|
||||
for w in range(8)
|
||||
] + [
|
||||
{"series_id": "a|x", "date": _iso(as_of - timedelta(days=7 * w)), "value": 8.0}
|
||||
for w in range(8)
|
||||
]
|
||||
|
||||
horizon = [_iso(as_of + timedelta(days=k)) for k in range(1, 8)]
|
||||
result = forecast_seasonal_median(history, horizon, _iso(as_of))
|
||||
|
||||
# 2 series x 7 fechas = 14 filas, ordenadas por series_id asc.
|
||||
assert len(result) == 14
|
||||
assert [r["series_id"] for r in result[:7]] == ["a|x"] * 7
|
||||
assert [r["series_id"] for r in result[7:]] == ["b|x"] * 7
|
||||
# el orden de fechas dentro de cada serie respeta horizon_dates
|
||||
assert [r["date"] for r in result[:7]] == horizon
|
||||
# y_pred >= 0 siempre
|
||||
assert all(r["y_pred"] >= 0.0 for r in result)
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: list_bq_dataset_tables
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "def list_bq_dataset_tables(project_id: str, dataset: str, include_views: bool = True, location: str = None) -> dict"
|
||||
description: "Lista todas las tablas y vistas de un dataset BigQuery y enriquece las BASE TABLE con conteo de filas y tamaño en disco. Capa de descubrimiento del grupo eda: qué hay en el dataset, cuánto pesa cada tabla, qué es tabla vs vista, antes de perfilar una concreta. Query 1 sobre INFORMATION_SCHEMA.TABLES (catálogo completo) + query 2 sobre __TABLES__ (row_count, size_bytes). Las vistas dejan n_rows/size_mb en None (contarlas exigiría full scan). Auth ADC con fix de quota project (403 USER_PROJECT_DENIED)."
|
||||
tags: [eda, bigquery]
|
||||
params:
|
||||
- name: project_id
|
||||
desc: "Proyecto GCP que contiene el dataset (ej. `autingo-159109`). Se usa como proyecto de facturación de las dos queries."
|
||||
- name: dataset
|
||||
desc: "Nombre del dataset BigQuery a listar (ej. `customer_marts`). Solo el dataset, sin proyecto ni tabla."
|
||||
- name: include_views
|
||||
desc: "True (DEFAULT) incluye tablas y vistas. False filtra y devuelve solo las BASE TABLE."
|
||||
- name: location
|
||||
desc: "Región del dataset para las queries (ej. `europe-west1`, `EU`). None (DEFAULT) deja que el cliente resuelva la ubicación. Necesario si el dataset vive en una región no-US."
|
||||
output: "dict dict-no-throw. En éxito {status:'ok', project_id, dataset, n_tables:int, tables:[{table, fqn:'project.dataset.table', table_type:'BASE TABLE'|'VIEW'|..., n_rows:int|None, size_mb:float|None, created:str|None}]}. En error {status:'error', error:str}."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
file_path: "python/functions/datascience/list_bq_dataset_tables.py"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```python
|
||||
from datascience import list_bq_dataset_tables
|
||||
|
||||
# Catálogo completo del dataset (tablas + vistas) con filas y tamaño.
|
||||
r = list_bq_dataset_tables("autingo-159109", "customer_marts")
|
||||
print(r["status"], r["n_tables"])
|
||||
for t in r["tables"]:
|
||||
print(t["table"], t["table_type"], t["n_rows"], t["size_mb"], "MB")
|
||||
|
||||
# Solo tablas base, dataset en europe-west1 (necesita location).
|
||||
r = list_bq_dataset_tables(
|
||||
"autingo-159109", "customer_marts",
|
||||
include_views=False, location="europe-west1",
|
||||
)
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
|
||||
- Antes de perfilar una tabla concreta con el grupo `eda` (`profile_bq_table`, `load_bq_table_to_duckdb`): descubre qué tablas y vistas hay en el dataset y cuánto pesa cada una para decidir cuál analizar.
|
||||
- Cuando necesites un inventario rápido de un dataset BigQuery (nombre, tipo, filas, tamaño, fecha de creación) sin abrir la consola de GCP.
|
||||
- Cuando quieras distinguir tablas base de vistas antes de una carga o un cruce (las vistas no traen conteo de filas).
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Impura**: hace I/O de red contra la API de BigQuery (dos queries). Requiere ADC configurado (`gcloud auth application-default login`).
|
||||
- **403 USER_PROJECT_DENIED**: se evita aplicando `creds.with_quota_project(None)` cuando el ADC del usuario arrastra un quota project ajeno (memoria `bq_direct_quota_project`). Mismo patrón que `load_bq_table_to_duckdb`.
|
||||
- **Región del dataset**: si el dataset vive en `europe-west1` (o cualquier región distinta de la que asume el cliente por defecto) y no pasas `location`, las queries fallan con "Not found: Dataset ... was not found in location US". Pasa `location="europe-west1"` o `location="EU"` según corresponda. Muchos datasets de Aurgi están en `europe-west1`; otros en `EU` multi-region.
|
||||
- **Las vistas no traen n_rows ni size_mb**: `__TABLES__` no da conteo fiable para vistas y contarlas exigiría un full scan por vista (coste + latencia). Por eso `n_rows`/`size_mb` van a None para todo lo que no sea `BASE TABLE`.
|
||||
- **size_mb es tamaño lógico en disco** (bytes de `__TABLES__` / 1024²), no el coste de una query sobre la tabla.
|
||||
- **dict-no-throw**: nunca lanza excepción; ante cualquier fallo (project/dataset inválido, auth, región, permisos) devuelve `{status:'error', error:str}`.
|
||||
|
||||
## Notas
|
||||
|
||||
Capa de descubrimiento del grupo de capacidad `eda`. Complementa a
|
||||
`load_bq_table_to_duckdb` (que trae UNA tabla a DuckDB) y a `profile_bq_table`
|
||||
(que perfila UNA tabla end-to-end): esta función responde "¿qué tablas hay en
|
||||
este dataset y cuáles merece la pena perfilar?". `project_id` y `dataset` se
|
||||
validan con regex (`^[A-Za-z0-9\-]+$` y `^[A-Za-z0-9_]+$`) antes de
|
||||
interpolarlos en los identificadores con backticks de las dos queries, para
|
||||
cerrar la superficie de inyección.
|
||||
|
||||
A diferencia de `bq_list_tables_py_infra` (dominio infra, usa el wrapper
|
||||
`BQClient` del SDK y no enriquece con filas ni tamaño), esta función es
|
||||
standalone (auth ADC propia con el fix de quota project) y devuelve el conteo de
|
||||
filas y el tamaño por tabla en el estilo dict-no-throw del grupo `eda`.
|
||||
@@ -0,0 +1,134 @@
|
||||
"""list_bq_dataset_tables — catálogo de tablas y vistas de un dataset BigQuery.
|
||||
|
||||
Lista todas las tablas y vistas de un dataset de Google BigQuery y enriquece las
|
||||
BASE TABLE con su conteo de filas y su tamaño en disco. Es la capa de
|
||||
descubrimiento del grupo `eda`: antes de perfilar una tabla concreta (con
|
||||
`profile_bq_table` / `load_bq_table_to_duckdb`) necesitas saber qué hay en el
|
||||
dataset, cuántas filas pesa cada tabla y qué es tabla vs vista.
|
||||
|
||||
Estrategia de dos queries:
|
||||
1. `INFORMATION_SCHEMA.TABLES` del dataset -> table_name, table_type,
|
||||
creation_time de TODOS los objetos (tablas y vistas).
|
||||
2. `__TABLES__` del dataset (una sola query adicional) -> row_count y
|
||||
size_bytes por tabla. Solo las BASE TABLE se enriquecen; las vistas
|
||||
dejan n_rows y size_mb en None (contarlas exigiría un full scan por vista,
|
||||
con coste y latencia que no compensan para un catálogo).
|
||||
|
||||
Autenticación: ADC (gcloud auth). Aplica `creds.with_quota_project(None)` para
|
||||
evitar el 403 USER_PROJECT_DENIED cuando el ADC del usuario lleva un quota
|
||||
project ajeno — mismo patrón que `load_bq_table_to_duckdb`.
|
||||
|
||||
Estilo dict-no-throw del grupo `eda`: nunca lanza; devuelve
|
||||
{status:'error', ...} en cualquier fallo.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
_PROJECT_RE = re.compile(r"^[A-Za-z0-9\-]+$")
|
||||
_DATASET_RE = re.compile(r"^[A-Za-z0-9_]+$")
|
||||
|
||||
|
||||
def list_bq_dataset_tables(
|
||||
project_id: str,
|
||||
dataset: str,
|
||||
include_views: bool = True,
|
||||
location: str = None,
|
||||
) -> dict:
|
||||
try:
|
||||
import google.auth
|
||||
from google.cloud import bigquery
|
||||
|
||||
if not project_id or not _PROJECT_RE.match(project_id):
|
||||
return {"status": "error", "error": f"project_id inválido: {project_id!r}"}
|
||||
if not dataset or not _DATASET_RE.match(dataset):
|
||||
return {"status": "error", "error": f"dataset inválido: {dataset!r}"}
|
||||
|
||||
# Auth ADC con fix de quota project (403 USER_PROJECT_DENIED).
|
||||
creds, adc_project = google.auth.default(
|
||||
scopes=["https://www.googleapis.com/auth/bigquery"]
|
||||
)
|
||||
if hasattr(creds, "with_quota_project"):
|
||||
creds = creds.with_quota_project(None)
|
||||
proj = project_id or adc_project
|
||||
client = bigquery.Client(project=proj, credentials=creds)
|
||||
|
||||
# Query 1: catálogo de objetos (tablas + vistas) del dataset.
|
||||
info_sql = (
|
||||
"SELECT table_name, table_type, creation_time "
|
||||
f"FROM `{proj}.{dataset}`.INFORMATION_SCHEMA.TABLES "
|
||||
"ORDER BY table_name"
|
||||
)
|
||||
info_rows = list(client.query(info_sql, location=location).result())
|
||||
|
||||
# Query 2: enriquecimiento (row_count, size_bytes) desde __TABLES__.
|
||||
stats_sql = (
|
||||
"SELECT table_id, row_count, size_bytes "
|
||||
f"FROM `{proj}.{dataset}`.__TABLES__"
|
||||
)
|
||||
stats = {}
|
||||
for row in client.query(stats_sql, location=location).result():
|
||||
stats[row["table_id"]] = (row["row_count"], row["size_bytes"])
|
||||
|
||||
tables = []
|
||||
for row in info_rows:
|
||||
table_name = row["table_name"]
|
||||
table_type = row["table_type"]
|
||||
is_base_table = table_type == "BASE TABLE"
|
||||
|
||||
if not include_views and not is_base_table:
|
||||
continue
|
||||
|
||||
created = row["creation_time"]
|
||||
created_iso = created.isoformat() if created is not None else None
|
||||
|
||||
n_rows = None
|
||||
size_mb = None
|
||||
if is_base_table and table_name in stats:
|
||||
raw_rows, raw_bytes = stats[table_name]
|
||||
if raw_rows is not None:
|
||||
n_rows = int(raw_rows)
|
||||
if raw_bytes is not None:
|
||||
size_mb = round(int(raw_bytes) / (1024 * 1024), 3)
|
||||
|
||||
tables.append(
|
||||
{
|
||||
"table": table_name,
|
||||
"fqn": f"{proj}.{dataset}.{table_name}",
|
||||
"table_type": table_type,
|
||||
"n_rows": n_rows,
|
||||
"size_mb": size_mb,
|
||||
"created": created_iso,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"project_id": proj,
|
||||
"dataset": dataset,
|
||||
"n_tables": len(tables),
|
||||
"tables": tables,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
|
||||
args = sys.argv[1:]
|
||||
if len(args) < 2:
|
||||
print(
|
||||
"uso: list_bq_dataset_tables.py <project_id> <dataset> [--no-views] [--location LOC]",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
proj_arg, dataset_arg = args[0], args[1]
|
||||
include_views_arg = "--no-views" not in args
|
||||
loc_arg = None
|
||||
if "--location" in args:
|
||||
loc_arg = args[args.index("--location") + 1]
|
||||
result = list_bq_dataset_tables(
|
||||
proj_arg, dataset_arg, include_views=include_views_arg, location=loc_arg
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
||||
@@ -3,10 +3,10 @@ name: load_bq_table_to_duckdb
|
||||
kind: function
|
||||
lang: py
|
||||
domain: datascience
|
||||
version: "1.1.0"
|
||||
version: "1.3.0"
|
||||
purity: impure
|
||||
signature: "def load_bq_table_to_duckdb(table_fqn: str, duckdb_path: str, dest_table: str = '', sample_frac: float = None, max_rows: int = 0, project_id: str = '', pseudonymize_cols: list = None) -> dict"
|
||||
description: "Adaptador BigQuery -> DuckDB local para el grupo eda. Trae una tabla o vista de Google BigQuery a un archivo DuckDB local (por defecto COMPLETA, todas las filas; muestreo opt-in con sample_frac), de modo que las funciones del grupo de capacidad eda (que solo hablan DuckDB/PostgreSQL) puedan perfilarla. Fetch via BigQuery Storage Read API (Arrow) con fallback REST. Seudonimiza columnas PII con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD)."
|
||||
signature: "def load_bq_table_to_duckdb(table_fqn: str, duckdb_path: str, dest_table: str = '', sample_frac: float = None, max_rows: int = 0, project_id: str = '', pseudonymize_cols: list = None, where_sql: str = '', select_sql: str = '') -> dict"
|
||||
description: "Adaptador BigQuery -> DuckDB local para el grupo eda. Trae una tabla o vista de Google BigQuery a un archivo DuckDB local (por defecto COMPLETA, todas las filas; muestreo opt-in con sample_frac), de modo que las funciones del grupo de capacidad eda (que solo hablan DuckDB/PostgreSQL) puedan perfilarla. Ingesta streaming Arrow -> DuckDB por batches (pyarrow.RecordBatch) para RAM acotada en tablas de decenas de millones de filas; fallback al camino DataFrame completo si pyarrow no esta. Filtra el origen con where_sql y proyecta/castea con select_sql. Seudonimiza columnas PII con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD)."
|
||||
tags: [eda, bigquery, duckdb, datascience]
|
||||
params:
|
||||
- name: table_fqn
|
||||
@@ -22,17 +22,36 @@ params:
|
||||
- name: project_id
|
||||
desc: "Proyecto GCP de facturación. Vacío = primer segmento del FQN o el del ADC."
|
||||
- name: pseudonymize_cols
|
||||
desc: "Lista de columnas PII a seudonimizar con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD). Preserva nulos y cardinalidad."
|
||||
output: "dict dict-no-throw. En éxito {status:'ok', duckdb_path, table, n_rows_source, n_rows_fetched, sampled, sample_frac, columns, pseudonymized}. En error {status:'error', error}."
|
||||
desc: "Lista de columnas PII a seudonimizar con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD). Preserva nulos y cardinalidad. En el camino streaming se aplica POR BATCH antes de insertar."
|
||||
- name: where_sql
|
||||
desc: "Clausula WHERE SQL (SIN la palabra WHERE) aplicada al SELECT sobre el origen y tambien al COUNT de n_rows_source (cuenta el origen filtrado). Se combina con el muestreo (sample_frac) via AND. Ej: `fecha <= CURRENT_DATE() AND venta_n IS NOT NULL`. Se interpola tal cual: NO usar con input no confiable."
|
||||
- name: select_sql
|
||||
desc: "Lista de expresiones del SELECT (SIN la palabra SELECT). Vacio (DEFAULT) = `*`. Permite proyectar/castear tipos problematicos, ej. `fecha, idCentro, CAST(venta_n AS FLOAT64) AS venta_n` (util para castear BIGNUMERIC a FLOAT64 antes de ingerir). Se interpola tal cual: NO usar con input no confiable."
|
||||
output: "dict dict-no-throw. En éxito {status:'ok', duckdb_path, table, n_rows_source, n_rows_fetched, sampled, sample_frac, columns, pseudonymized, streamed, auto_casts}. En error {status:'error', error, stage?}. streamed=True si la ingesta fue por batches Arrow; n_rows_fetched = suma de filas de los batches insertados."
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: []
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
tested: true
|
||||
tests:
|
||||
- "test_default_selects_star_no_where_no_limit"
|
||||
- "test_select_sql_replaces_star"
|
||||
- "test_select_sql_blank_and_whitespace_fall_back_to_star"
|
||||
- "test_where_sql_only"
|
||||
- "test_sample_frac_only"
|
||||
- "test_where_sql_and_sample_frac_combined_with_and_parenthesized"
|
||||
- "test_single_condition_not_parenthesized"
|
||||
- "test_max_rows_appends_limit"
|
||||
- "test_max_rows_zero_or_negative_no_limit"
|
||||
- "test_all_combined_order_where_then_limit"
|
||||
- "test_sample_frac_out_of_range_ignored"
|
||||
- "test_dest_empty_uses_last_fqn_segment"
|
||||
- "test_dest_explicit_valid_kept"
|
||||
- "test_dest_invalid_chars_replaced_with_underscore"
|
||||
- "test_dest_from_fqn_segment_with_hyphen_sanitized"
|
||||
test_file_path: "python/functions/datascience/load_bq_table_to_duckdb_test.py"
|
||||
file_path: "python/functions/datascience/load_bq_table_to_duckdb.py"
|
||||
---
|
||||
|
||||
@@ -56,6 +75,17 @@ r = load_bq_table_to_duckdb(
|
||||
sample_frac=0.05,
|
||||
pseudonymize_cols=["document_number", "full_name", "email", "phone"],
|
||||
)
|
||||
|
||||
# Filtrar el origen + castear columnas problematicas antes de ingerir. El COUNT de
|
||||
# n_rows_source respeta el mismo where_sql (cuenta el origen filtrado). Streaming
|
||||
# Arrow por batches: RAM acotada aunque la tabla tenga decenas de millones de filas.
|
||||
r = load_bq_table_to_duckdb(
|
||||
"autingo-159109.data.ventas_39M",
|
||||
"/tmp/eda_ventas.duckdb",
|
||||
where_sql="fecha <= CURRENT_DATE() AND venta_n IS NOT NULL",
|
||||
select_sql="fecha, idCentro, CAST(importe_bignumeric AS FLOAT64) AS importe",
|
||||
)
|
||||
print(r["n_rows_fetched"], "de", r["n_rows_source"], "streamed=", r["streamed"])
|
||||
```
|
||||
|
||||
## Cuando usarla
|
||||
@@ -68,19 +98,27 @@ r = load_bq_table_to_duckdb(
|
||||
|
||||
- **Impura**: hace I/O de red (BigQuery) + escritura a disco (DuckDB). Requiere ADC configurado (`gcloud auth application-default login`).
|
||||
- **403 USER_PROJECT_DENIED**: se evita aplicando `creds.with_quota_project(None)` cuando el ADC arrastra un quota project ajeno (memoria `bq_direct_quota_project`).
|
||||
- **TABLESAMPLE no funciona en vistas**: el muestreo (opt-in, `sample_frac`) usa `WHERE rand() < frac` (aplicable a tablas y vistas). `max_rows` es un `LIMIT` como tope duro opcional.
|
||||
- **FULL por defecto**: `sample_frac=None` trae TODAS las filas. Trae el resultado a RAM como DataFrame de pandas antes de materializar en DuckDB, así que una tabla de muchos millones × muchas columnas puede consumir varios GB. Para tablas enormes que no quepan, pasa `sample_frac` (muestra) o `max_rows` (tope). El fetch usa el BigQuery Storage Read API (Arrow) cuando `google-cloud-bigquery-storage` + `pyarrow` están disponibles — mucho más rápido que REST para millones de filas; si no, cae al conversor REST automáticamente.
|
||||
- **La seudonimización es un hash unidireccional** (SHA-1 truncado a 12 hex): no es reversible, correcto para EDA. Preserva nulos, cardinalidad y patrón de faltantes, pero NO permite recuperar el valor original.
|
||||
- **dict-no-throw**: nunca lanza excepción; ante cualquier fallo (FQN inválido, auth, query) devuelve `{status:'error', error:str}`.
|
||||
- **TABLESAMPLE no funciona en vistas**: el muestreo (opt-in, `sample_frac`) usa `WHERE rand() < frac` (aplicable a tablas y vistas). `max_rows` es un `LIMIT` como tope duro opcional. `where_sql` y el muestreo se combinan con AND (cada condición entre paréntesis cuando hay varias, para respetar precedencia).
|
||||
- **Ingesta streaming Arrow (RAM acotada)**: cuando `pyarrow` + `to_arrow_iterable` están disponibles, el resultado se materializa por `pyarrow.RecordBatch` (primer batch `CREATE OR REPLACE TABLE ... AS SELECT`, siguientes `INSERT INTO`), con `streamed=True` en el retorno. Así una tabla de decenas de millones de filas no se carga entera en RAM. El cliente BigQuery Storage se crea con las mismas credenciales corregidas (`with_quota_project(None)`).
|
||||
- **Fallback DataFrame completo (carga TODO en RAM)**: si `pyarrow` o `to_arrow_iterable` no están disponibles, se cae al camino antiguo — `to_dataframe()` completo antes de materializar (`streamed=False`), que puede consumir varios GB en tablas grandes. Para acotar, pasa `sample_frac`, `max_rows` o `where_sql`.
|
||||
- **Auto-cast de tipos problemáticos (v1.3.0)**: si NO se pasa `select_sql`, la función inspecciona el schema del origen (`client.get_table`) y castea automáticamente en el SELECT: BIGNUMERIC -> `CAST(col AS FLOAT64)` (Arrow decimal256, DuckDB no lo ingiere), REPEATED/RECORD/JSON -> `TO_JSON_STRING(col)` (los LIST/STRUCT rompen el perfilado aguas abajo con "unhashable type: 'list'"), GEOGRAPHY -> `ST_ASTEXT(col)`. Las transformaciones aplicadas se reportan en `auto_casts` del retorno. Si se pasa `select_sql` explícito, se respeta tal cual (sin auto-cast). Si el schema no se puede leer, degrada a `SELECT *`. El guard decimal256 en la ingesta se conserva como backstop (`{status:'error', stage:'stream_schema'|'stream_insert'}`).
|
||||
- **Inyección SQL**: `where_sql` y `select_sql` (igual que `table_fqn`) se interpolan TAL CUAL en la query, sin escapar. NO los construyas a partir de input no confiable.
|
||||
- **db-dtypes solo en el camino DataFrame**: la normalización de `dbdate`/`dbtime` a tipos que DuckDB reconoce solo aplica al fallback pandas. En el camino Arrow los DATE/TIME llegan como tipos Arrow nativos que DuckDB ingiere directamente.
|
||||
- **La seudonimización es un hash unidireccional** (SHA-1 truncado a 12 hex): no es reversible, correcto para EDA. Preserva nulos, cardinalidad y patrón de faltantes, pero NO permite recuperar el valor original. En streaming se aplica por batch (columnas no PII conservan su tipo Arrow; las PII se reescriben a string).
|
||||
- **dict-no-throw**: nunca lanza excepción; ante cualquier fallo (FQN inválido, auth, query, ingesta) devuelve `{status:'error', error:str}` (con `stage` en fallos de ingesta streaming).
|
||||
|
||||
## Notas
|
||||
|
||||
Adaptador del grupo de capacidad `eda`: el resto de funciones del grupo perfilan
|
||||
DuckDB/PostgreSQL, pero no hablan BigQuery de forma nativa. Esta función cubre ese
|
||||
hueco materializando una sola tabla DuckDB desde el DataFrame resultante de la
|
||||
query BigQuery. El nombre de tabla destino se sanea (`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
antes de citarlo en el `CREATE OR REPLACE TABLE`.
|
||||
hueco materializando una sola tabla DuckDB desde el resultado de la query BigQuery,
|
||||
por batches Arrow cuando es posible. El SELECT sobre el origen lo compone el helper
|
||||
puro `_build_source_sql` (testeable sin red) y el nombre de tabla destino se sanea
|
||||
con `_sanitize_dest_table` (`^[A-Za-z_][A-Za-z0-9_]*$`) antes de citarlo en el
|
||||
`CREATE OR REPLACE TABLE`.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v1.3.0 (2026-07-02) — Auto-cast de tipos problemáticos cuando no se pasa `select_sql`: inspecciona el schema del origen y castea BIGNUMERIC->FLOAT64, REPEATED/RECORD/JSON->TO_JSON_STRING y GEOGRAPHY->ST_ASTEXT (elimina el gotcha decimal256 y el "unhashable type: 'list'" de profile_table sobre columnas array). Nueva clave `auto_casts` en el retorno. Descubierto en el piloto AEDA del dataset external_datasets (product_info_mat con BIGNUMERIC, product_object con arrays).
|
||||
- v1.2.0 (2026-07-02) — Añade `where_sql` (cláusula WHERE en origen, combinada con el muestreo vía AND y aplicada también al COUNT de `n_rows_source`) y `select_sql` (proyección/casteo de columnas, útil para castear BIGNUMERIC->FLOAT64). Ingesta streaming Arrow -> DuckDB por batches (`pyarrow.RecordBatch`, RAM acotada al tamaño del batch) para tablas de decenas de millones de filas que no caben como DataFrame; fallback al camino DataFrame completo si pyarrow/`to_arrow_iterable` no están. Gotcha decimal256 (BIGNUMERIC) devuelto como error con recomendación de castear vía `select_sql`. Nueva clave `streamed` en el retorno. Tests unitarios sin red del builder de SQL y del saneado del destino.
|
||||
- v1.1.0 (2026-07-01) — FULL pasa a ser el DEFAULT: se sustituye `max_rows=300000, sample=True` por `sample_frac=None` (None = todas las filas) + `max_rows=0` (tope duro opcional). El muestreo es opt-in explícito. Fetch acelerado via BigQuery Storage Read API (Arrow) con fallback REST. Preferencia estándar del usuario: los EDA se corren sobre el total salvo que se pida lo contrario.
|
||||
|
||||
@@ -4,22 +4,35 @@ Trae una tabla o vista de Google BigQuery a un archivo DuckDB local (por defecto
|
||||
COMPLETA — todas las filas — o una muestra si se pasa `sample_frac`), de modo que
|
||||
las funciones del grupo de capacidad `eda` (que perfilan DuckDB/PostgreSQL)
|
||||
puedan analizarla sin un adaptador BigQuery nativo. Materializa una sola tabla
|
||||
DuckDB desde un DataFrame de pandas.
|
||||
DuckDB desde el resultado de la query.
|
||||
|
||||
Modo por defecto = FULL: `sample_frac=None` trae la vista/tabla entera (preferencia
|
||||
estándar del usuario: los EDA se corren sobre el total salvo que se pida lo
|
||||
contrario). El muestreo es opt-in explícito: `sample_frac=0.05` trae ~5 %; `max_rows`
|
||||
es un tope duro opcional (0 = sin tope). El fetch usa el BigQuery Storage Read API
|
||||
(Arrow) cuando está disponible, con fallback al conversor REST.
|
||||
es un tope duro opcional (0 = sin tope).
|
||||
|
||||
Ingesta streaming Arrow -> DuckDB por batches: cuando `pyarrow` y el iterador
|
||||
`to_arrow_iterable` están disponibles, el resultado se trae y materializa por
|
||||
`pyarrow.RecordBatch`, insertando batch a batch en DuckDB. Así la RAM queda
|
||||
acotada al tamaño de un batch y una tabla de decenas de millones de filas cabe sin
|
||||
cargarse entera como DataFrame de pandas. Si `pyarrow`/`to_arrow_iterable` no están
|
||||
disponibles, cae al camino DataFrame completo (que sí carga todo en RAM).
|
||||
|
||||
Filtrado en origen: `where_sql` aplica una cláusula WHERE SQL sobre la tabla origen
|
||||
(y también al COUNT del origen, para contar las filas filtradas). `select_sql`
|
||||
permite proyectar/castear expresiones concretas en el SELECT (vacío = `*`), útil
|
||||
para castear tipos problemáticos (p. ej. BIGNUMERIC -> FLOAT64) antes de ingerir.
|
||||
|
||||
Seudonimización LOPDGDD/RGPD: las columnas listadas en `pseudonymize_cols` se
|
||||
transforman con un hash SHA-1 truncado ANTES de escribir a disco, preservando
|
||||
nulos, cardinalidad y patrón de faltantes pero sin volcar el valor real (DNI,
|
||||
nombre, email, teléfono, etc.). El EDA conserva su valor analítico sin incrustar
|
||||
datos personales reales.
|
||||
nombre, email, teléfono, etc.). En el camino streaming se aplica POR BATCH antes de
|
||||
insertar. El EDA conserva su valor analítico sin incrustar datos personales reales.
|
||||
|
||||
Autenticación: ADC (gcloud auth). Aplica creds.with_quota_project(None) para
|
||||
evitar el 403 USER_PROJECT_DENIED cuando el ADC lleva quota project ajeno.
|
||||
evitar el 403 USER_PROJECT_DENIED cuando el ADC lleva quota project ajeno. El
|
||||
cliente BigQuery Storage (usado por el streaming Arrow) se crea con esas MISMAS
|
||||
credenciales corregidas.
|
||||
|
||||
Estilo dict-no-throw del grupo `eda`: nunca lanza; devuelve {status:'error', ...}.
|
||||
"""
|
||||
@@ -53,6 +66,138 @@ def _safe_isna(v):
|
||||
return False
|
||||
|
||||
|
||||
def _sanitize_dest_table(dest_table: str, table_fqn: str) -> str:
|
||||
"""Nombre de tabla DuckDB destino saneado (helper puro, testeable sin red).
|
||||
|
||||
Reglas:
|
||||
- `dest_table` vacío -> último segmento del FQN.
|
||||
- Si el resultado no casa `^[A-Za-z_][A-Za-z0-9_]*$`, cada carácter inválido
|
||||
se sustituye por `_`; si quedara vacío se usa `bq_table`.
|
||||
"""
|
||||
dest = dest_table or table_fqn.split(".")[-1]
|
||||
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", dest):
|
||||
dest = re.sub(r"[^A-Za-z0-9_]", "_", dest) or "bq_table"
|
||||
return dest
|
||||
|
||||
|
||||
def _build_source_sql(
|
||||
table_fqn: str,
|
||||
select_sql: str = "",
|
||||
where_sql: str = "",
|
||||
sample_frac: float = None,
|
||||
max_rows: int = 0,
|
||||
) -> str:
|
||||
"""Compone el SELECT sobre la tabla/vista origen de BigQuery (helper puro).
|
||||
|
||||
Sin efectos: solo construye la cadena SQL, testeable sin red.
|
||||
|
||||
SEGURIDAD: `select_sql` y `where_sql` se interpolan TAL CUAL (no se escapan),
|
||||
igual que `table_fqn`, por lo que NO deben construirse a partir de input no
|
||||
confiable (riesgo de inyección SQL).
|
||||
|
||||
Reglas:
|
||||
- `select_sql` vacío -> `SELECT *`; en otro caso `SELECT <select_sql>`.
|
||||
- `where_sql` y el muestreo (`rand() < sample_frac`, para `sample_frac` en
|
||||
(0,1)) se combinan con AND. Si hay más de una condición cada una se
|
||||
envuelve en paréntesis para respetar la precedencia de operadores.
|
||||
- `max_rows` > 0 añade un `LIMIT` como tope duro.
|
||||
"""
|
||||
select_expr = select_sql.strip() if (select_sql and select_sql.strip()) else "*"
|
||||
|
||||
conditions = []
|
||||
ws = (where_sql or "").strip()
|
||||
if ws:
|
||||
conditions.append(ws)
|
||||
if sample_frac is not None and 0 < float(sample_frac) < 1:
|
||||
conditions.append(f"rand() < {float(sample_frac)}")
|
||||
|
||||
if len(conditions) > 1:
|
||||
where = " WHERE " + " AND ".join(f"({c})" for c in conditions)
|
||||
elif conditions:
|
||||
where = " WHERE " + conditions[0]
|
||||
else:
|
||||
where = ""
|
||||
|
||||
limit = f" LIMIT {int(max_rows)}" if max_rows and int(max_rows) > 0 else ""
|
||||
return f"SELECT {select_expr} FROM `{table_fqn}`{where}{limit}"
|
||||
|
||||
|
||||
def _decimal256_columns(schema) -> list:
|
||||
"""Nombres de columnas Arrow de tipo decimal256 (BigQuery BIGNUMERIC).
|
||||
|
||||
DuckDB no ingiere decimal256 directamente; se usa para dar un error claro que
|
||||
recomiende castear esas columnas a FLOAT64 vía `select_sql`.
|
||||
"""
|
||||
import pyarrow as pa
|
||||
return [f.name for f in schema if pa.types.is_decimal256(f.type)]
|
||||
|
||||
|
||||
def _auto_select_exprs(schema_fields) -> tuple:
|
||||
"""Construye el SELECT auto-casteado desde el schema BigQuery (helper puro).
|
||||
|
||||
Recibe la lista de campos top-level del schema de BigQuery
|
||||
(`google.cloud.bigquery.SchemaField` o cualquier objeto con `.name`,
|
||||
`.field_type` y `.mode`) y devuelve `(select_sql, auto_casts)`:
|
||||
|
||||
- BIGNUMERIC -> CAST(col AS FLOAT64) (Arrow decimal256, DuckDB no lo ingiere)
|
||||
- REPEATED / RECORD / JSON -> TO_JSON_STRING(col) (arrays/structs rompen profile_table:
|
||||
"unhashable type: 'list'")
|
||||
- GEOGRAPHY -> ST_ASTEXT(col) (WKT string)
|
||||
- resto -> col sin tocar
|
||||
|
||||
Si ninguna columna necesita transformación devuelve ("", {}) para que el
|
||||
caller use `SELECT *` (comportamiento previo intacto).
|
||||
"""
|
||||
exprs = []
|
||||
auto_casts = {}
|
||||
for f in schema_fields:
|
||||
name = f.name
|
||||
ftype = (f.field_type or "").upper()
|
||||
mode = (getattr(f, "mode", "") or "").upper()
|
||||
if mode == "REPEATED" or ftype in ("RECORD", "STRUCT", "JSON"):
|
||||
exprs.append(f"TO_JSON_STRING(`{name}`) AS `{name}`")
|
||||
auto_casts[name] = "TO_JSON_STRING"
|
||||
elif ftype == "BIGNUMERIC":
|
||||
exprs.append(f"CAST(`{name}` AS FLOAT64) AS `{name}`")
|
||||
auto_casts[name] = "CAST_FLOAT64"
|
||||
elif ftype == "GEOGRAPHY":
|
||||
exprs.append(f"ST_ASTEXT(`{name}`) AS `{name}`")
|
||||
auto_casts[name] = "ST_ASTEXT"
|
||||
else:
|
||||
exprs.append(f"`{name}`")
|
||||
if not auto_casts:
|
||||
return "", {}
|
||||
return ", ".join(exprs), auto_casts
|
||||
|
||||
|
||||
def _pseudonymize_arrow_table(batch, pseudo_set: set, pseudo_applied: list):
|
||||
"""Envuelve un `pyarrow.RecordBatch` en una `pyarrow.Table`, hasheando las PII.
|
||||
|
||||
Las columnas no listadas en `pseudo_set` conservan su tipo Arrow NATIVO (DATE,
|
||||
TIME, TIMESTAMP incluidos), que DuckDB ingiere directamente sin normalización.
|
||||
Solo las columnas PII se reescriben a string con el hash SHA-1 truncado.
|
||||
|
||||
Muta `pseudo_applied` in situ (añade el nombre de cada columna seudonimizada la
|
||||
primera vez que aparece).
|
||||
"""
|
||||
import pyarrow as pa
|
||||
if not pseudo_set:
|
||||
return pa.Table.from_batches([batch])
|
||||
names = list(batch.schema.names)
|
||||
arrays = []
|
||||
for i, name in enumerate(names):
|
||||
col = batch.column(i)
|
||||
if name in pseudo_set:
|
||||
hashed = _pseudonymize_series(col.to_pylist())
|
||||
arrays.append(pa.array(hashed, type=pa.string()))
|
||||
if name not in pseudo_applied:
|
||||
pseudo_applied.append(name)
|
||||
else:
|
||||
arrays.append(col)
|
||||
new_batch = pa.RecordBatch.from_arrays(arrays, names=names)
|
||||
return pa.Table.from_batches([new_batch])
|
||||
|
||||
|
||||
def load_bq_table_to_duckdb(
|
||||
table_fqn: str,
|
||||
duckdb_path: str,
|
||||
@@ -61,6 +206,8 @@ def load_bq_table_to_duckdb(
|
||||
max_rows: int = 0,
|
||||
project_id: str = "",
|
||||
pseudonymize_cols: list = None,
|
||||
where_sql: str = "",
|
||||
select_sql: str = "",
|
||||
) -> dict:
|
||||
try:
|
||||
import duckdb
|
||||
@@ -70,10 +217,8 @@ def load_bq_table_to_duckdb(
|
||||
if not table_fqn or not _FQN_RE.match(table_fqn):
|
||||
return {"status": "error", "error": f"table_fqn inválido: {table_fqn!r}"}
|
||||
|
||||
# dest_table: derivar del último segmento del FQN si no se pasa.
|
||||
dest = dest_table or table_fqn.split(".")[-1]
|
||||
if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", dest):
|
||||
dest = re.sub(r"[^A-Za-z0-9_]", "_", dest) or "bq_table"
|
||||
# dest_table: derivar del último segmento del FQN si no se pasa, saneado.
|
||||
dest = _sanitize_dest_table(dest_table, table_fqn)
|
||||
|
||||
# Auth ADC con fix de quota project (403 USER_PROJECT_DENIED).
|
||||
creds, adc_project = google.auth.default(
|
||||
@@ -84,9 +229,31 @@ def load_bq_table_to_duckdb(
|
||||
proj = project_id or table_fqn.split(".")[0] or adc_project
|
||||
client = bigquery.Client(project=proj, credentials=creds)
|
||||
|
||||
# Conteo de filas de origen.
|
||||
# Auto-cast de tipos problemáticos: si el caller no proyecta un
|
||||
# select_sql propio, se inspecciona el schema del origen y se castean
|
||||
# automáticamente BIGNUMERIC -> FLOAT64 (Arrow decimal256 que DuckDB no
|
||||
# ingiere), REPEATED/RECORD/JSON -> TO_JSON_STRING (los LIST/STRUCT
|
||||
# rompen el perfilado aguas abajo) y GEOGRAPHY -> ST_ASTEXT. Best-effort:
|
||||
# si el schema no se puede leer, se sigue con SELECT * como antes.
|
||||
auto_casts = {}
|
||||
if not (select_sql and select_sql.strip()):
|
||||
try:
|
||||
src = client.get_table(table_fqn)
|
||||
auto_sel, auto_casts = _auto_select_exprs(src.schema)
|
||||
if auto_sel:
|
||||
select_sql = auto_sel
|
||||
except Exception: # noqa: BLE001
|
||||
auto_casts = {}
|
||||
|
||||
# Conteo de filas del origen FILTRADO: aplica el mismo `where_sql` (cuenta
|
||||
# las filas que se van a traer, no la tabla entera). El muestreo NO entra
|
||||
# en el conteo (es un submuestreo aparte del origen filtrado).
|
||||
count_where = ""
|
||||
_ws = (where_sql or "").strip()
|
||||
if _ws:
|
||||
count_where = f" WHERE {_ws}"
|
||||
cnt = client.query(
|
||||
f"SELECT COUNT(*) AS n FROM `{table_fqn}`"
|
||||
f"SELECT COUNT(*) AS n FROM `{table_fqn}`{count_where}"
|
||||
).result()
|
||||
n_source = 0
|
||||
for row in cnt:
|
||||
@@ -94,51 +261,144 @@ def load_bq_table_to_duckdb(
|
||||
|
||||
# Modo por defecto = FULL (sample_frac=None -> todas las filas). El
|
||||
# muestreo es opt-in: sample_frac in (0,1) muestrea esa fracción con
|
||||
# `WHERE rand() < frac` (aplicable a tablas y vistas; TABLESAMPLE no va
|
||||
# en vistas). max_rows>0 es un tope duro opcional (LIMIT); 0 = sin tope.
|
||||
sampled = False
|
||||
where = ""
|
||||
if sample_frac is not None and 0 < float(sample_frac) < 1:
|
||||
where = f" WHERE rand() < {float(sample_frac)}"
|
||||
sampled = True
|
||||
limit = f" LIMIT {int(max_rows)}" if max_rows and int(max_rows) > 0 else ""
|
||||
sql = f"SELECT * FROM `{table_fqn}`{where}{limit}"
|
||||
# `rand() < frac`, combinado con `where_sql` vía AND. max_rows>0 es un tope
|
||||
# duro opcional (LIMIT). `select_sql` proyecta expresiones (vacío = `*`).
|
||||
sampled = sample_frac is not None and 0 < float(sample_frac) < 1
|
||||
sql = _build_source_sql(table_fqn, select_sql, where_sql, sample_frac, max_rows)
|
||||
|
||||
# Fetch: BigQuery Storage Read API (Arrow, rápido para millones de filas)
|
||||
# con fallback al conversor REST si la lib no está o falla.
|
||||
# ¿Está pyarrow disponible? Decide el camino de ingesta ANTES de consumir
|
||||
# el resultado (streaming Arrow por batches vs DataFrame completo).
|
||||
try:
|
||||
df = client.query(sql).result().to_dataframe(create_bqstorage_client=True)
|
||||
import pyarrow # noqa: F401
|
||||
has_pyarrow = True
|
||||
except Exception: # noqa: BLE001
|
||||
df = client.query(sql).result().to_dataframe(create_bqstorage_client=False)
|
||||
n_fetched = len(df)
|
||||
has_pyarrow = False
|
||||
|
||||
# Normalizar dtypes de db-dtypes: el conversor REST de BigQuery mapea las
|
||||
# columnas DATE/TIME a las extension dtypes `dbdate`/`dbtime` de db-dtypes,
|
||||
# que DuckDB NO reconoce al registrar el DataFrame ("Data type 'dbdate' not
|
||||
# recognized"). Se convierten a tipos estándar que DuckDB sí ingiere: DATE
|
||||
# -> datetime64[ns], TIME -> string. El resto de dtypes (datetime64 de
|
||||
# TIMESTAMP, Int64/boolean nullable, object) los acepta DuckDB tal cual.
|
||||
import pandas as pd
|
||||
for col in df.columns:
|
||||
dt = str(df[col].dtype)
|
||||
if dt == "dbdate":
|
||||
df[col] = pd.to_datetime(df[col], errors="coerce")
|
||||
elif dt == "dbtime":
|
||||
df[col] = df[col].astype("string").astype(object)
|
||||
job = client.query(sql)
|
||||
result = job.result()
|
||||
use_stream = has_pyarrow and hasattr(result, "to_arrow_iterable")
|
||||
|
||||
# Seudonimización de columnas PII antes de escribir a disco.
|
||||
pseudo_set = set(pseudonymize_cols or [])
|
||||
pseudo_applied = []
|
||||
for col in (pseudonymize_cols or []):
|
||||
if col in df.columns:
|
||||
df[col] = _pseudonymize_series(df[col].tolist())
|
||||
pseudo_applied.append(col)
|
||||
n_fetched = 0
|
||||
columns = []
|
||||
streamed = False
|
||||
|
||||
# Materializar a DuckDB (una tabla desde el DataFrame).
|
||||
con = duckdb.connect(duckdb_path)
|
||||
try:
|
||||
con.register("_src_df", df)
|
||||
con.execute(f'CREATE OR REPLACE TABLE "{dest}" AS SELECT * FROM _src_df')
|
||||
con.unregister("_src_df")
|
||||
if use_stream:
|
||||
# Cliente BigQuery Storage con las MISMAS creds corregidas
|
||||
# (quota None). Si la lib no está, to_arrow_iterable cae al
|
||||
# transporte REST-Arrow con bqstorage_client=None.
|
||||
try:
|
||||
from google.cloud import bigquery_storage
|
||||
bqstorage_client = bigquery_storage.BigQueryReadClient(
|
||||
credentials=creds
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
bqstorage_client = None
|
||||
|
||||
first = True
|
||||
for batch in result.to_arrow_iterable(
|
||||
bqstorage_client=bqstorage_client
|
||||
):
|
||||
# Seudonimización PII POR BATCH; no PII conserva tipo Arrow.
|
||||
tbl = _pseudonymize_arrow_table(batch, pseudo_set, pseudo_applied)
|
||||
|
||||
# Gotcha BIGNUMERIC: decimal256 no lo ingiere DuckDB. Detectar
|
||||
# en el primer batch y devolver un error claro que recomiende
|
||||
# castear a FLOAT64 vía select_sql (no intentar magia de tipos).
|
||||
if first:
|
||||
dcols = _decimal256_columns(tbl.schema)
|
||||
if dcols:
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"Ingesta Arrow bloqueada: columnas BIGNUMERIC "
|
||||
f"(Arrow decimal256) que DuckDB no ingiere: {dcols}. "
|
||||
"Castéalas a FLOAT64 con select_sql, p. ej. "
|
||||
"select_sql='..., CAST(col AS FLOAT64) AS col, ...'."
|
||||
),
|
||||
"stage": "stream_schema",
|
||||
}
|
||||
|
||||
con.register("_batch_arrow", tbl)
|
||||
try:
|
||||
if first:
|
||||
con.execute(
|
||||
f'CREATE OR REPLACE TABLE "{dest}" '
|
||||
f"AS SELECT * FROM _batch_arrow"
|
||||
)
|
||||
columns = list(tbl.schema.names)
|
||||
first = False
|
||||
else:
|
||||
con.execute(
|
||||
f'INSERT INTO "{dest}" SELECT * FROM _batch_arrow'
|
||||
)
|
||||
except Exception as ie: # noqa: BLE001
|
||||
msg = str(ie).lower()
|
||||
if "decimal256" in msg or ("decimal" in msg and "256" in msg):
|
||||
return {
|
||||
"status": "error",
|
||||
"error": (
|
||||
"Ingesta Arrow falló por columna BIGNUMERIC "
|
||||
"(Arrow decimal256) que DuckDB no ingiere. Castea "
|
||||
"esas columnas a FLOAT64 con select_sql. Detalle: "
|
||||
+ str(ie)
|
||||
),
|
||||
"stage": "stream_insert",
|
||||
}
|
||||
raise
|
||||
finally:
|
||||
con.unregister("_batch_arrow")
|
||||
n_fetched += tbl.num_rows
|
||||
|
||||
# Origen vacío: si el iterable no emitió ningún batch, materializa
|
||||
# una tabla vacía con el esquema del origen (evita que aguas abajo
|
||||
# falle por "tabla inexistente"). job.result() da un iterador fresco.
|
||||
if first:
|
||||
empty_df = job.result().to_dataframe(create_bqstorage_client=False)
|
||||
con.register("_empty_df", empty_df)
|
||||
con.execute(
|
||||
f'CREATE OR REPLACE TABLE "{dest}" AS SELECT * FROM _empty_df'
|
||||
)
|
||||
con.unregister("_empty_df")
|
||||
columns = list(empty_df.columns)
|
||||
streamed = True
|
||||
else:
|
||||
# Fallback: camino DataFrame completo (carga TODO el resultado en
|
||||
# RAM). Mismo comportamiento que antes del streaming Arrow.
|
||||
try:
|
||||
df = result.to_dataframe(create_bqstorage_client=True)
|
||||
except Exception: # noqa: BLE001
|
||||
df = job.result().to_dataframe(create_bqstorage_client=False)
|
||||
n_fetched = len(df)
|
||||
|
||||
# Normalizar dtypes de db-dtypes (solo camino pandas): el conversor
|
||||
# REST mapea DATE/TIME a las extension dtypes `dbdate`/`dbtime` de
|
||||
# db-dtypes, que DuckDB NO reconoce al registrar el DataFrame. Se
|
||||
# convierten a tipos estándar: DATE -> datetime64[ns], TIME ->
|
||||
# string. En el camino Arrow esto no aplica (tipos Arrow nativos).
|
||||
import pandas as pd
|
||||
for col in df.columns:
|
||||
dt = str(df[col].dtype)
|
||||
if dt == "dbdate":
|
||||
df[col] = pd.to_datetime(df[col], errors="coerce")
|
||||
elif dt == "dbtime":
|
||||
df[col] = df[col].astype("string").astype(object)
|
||||
|
||||
# Seudonimización de columnas PII antes de escribir a disco.
|
||||
for col in (pseudonymize_cols or []):
|
||||
if col in df.columns:
|
||||
df[col] = _pseudonymize_series(df[col].tolist())
|
||||
pseudo_applied.append(col)
|
||||
|
||||
con.register("_src_df", df)
|
||||
con.execute(
|
||||
f'CREATE OR REPLACE TABLE "{dest}" AS SELECT * FROM _src_df'
|
||||
)
|
||||
con.unregister("_src_df")
|
||||
columns = list(df.columns)
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
@@ -150,8 +410,10 @@ def load_bq_table_to_duckdb(
|
||||
"n_rows_fetched": n_fetched,
|
||||
"sampled": sampled,
|
||||
"sample_frac": float(sample_frac) if sampled else None,
|
||||
"columns": list(df.columns),
|
||||
"columns": columns,
|
||||
"pseudonymized": pseudo_applied,
|
||||
"streamed": streamed,
|
||||
"auto_casts": auto_casts,
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {"status": "error", "error": str(e)}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Tests para load_bq_table_to_duckdb.
|
||||
|
||||
Cubre la lógica PURA extraíble sin red ni BigQuery: la construcción del SELECT
|
||||
sobre el origen (`_build_source_sql` — combinación de where_sql + sample_frac con
|
||||
AND, select_sql sustituyendo a `*`, límite duro) y el saneado del nombre de tabla
|
||||
destino (`_sanitize_dest_table`). No se toca la red: importar el módulo solo carga
|
||||
`hashlib`/`re` a nivel superior (BigQuery/DuckDB/pyarrow se importan dentro de la
|
||||
función impura, que aquí no se invoca).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from load_bq_table_to_duckdb import _build_source_sql, _sanitize_dest_table
|
||||
|
||||
_FQN = "autingo-159109.data.ventas"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _build_source_sql — golden / defaults
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_default_selects_star_no_where_no_limit():
|
||||
sql = _build_source_sql(_FQN)
|
||||
assert sql == "SELECT * FROM `autingo-159109.data.ventas`"
|
||||
|
||||
|
||||
def test_select_sql_replaces_star():
|
||||
sql = _build_source_sql(
|
||||
_FQN,
|
||||
select_sql="fecha, idCentro, CAST(venta_n AS FLOAT64) AS venta_n",
|
||||
)
|
||||
assert sql == (
|
||||
"SELECT fecha, idCentro, CAST(venta_n AS FLOAT64) AS venta_n "
|
||||
"FROM `autingo-159109.data.ventas`"
|
||||
)
|
||||
|
||||
|
||||
def test_select_sql_blank_and_whitespace_fall_back_to_star():
|
||||
assert _build_source_sql(_FQN, select_sql="").startswith("SELECT * FROM")
|
||||
assert _build_source_sql(_FQN, select_sql=" ").startswith("SELECT * FROM")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# where_sql y sample_frac — solos y combinados con AND
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_where_sql_only():
|
||||
sql = _build_source_sql(_FQN, where_sql="fecha <= CURRENT_DATE()")
|
||||
assert sql == (
|
||||
"SELECT * FROM `autingo-159109.data.ventas` "
|
||||
"WHERE fecha <= CURRENT_DATE()"
|
||||
)
|
||||
|
||||
|
||||
def test_sample_frac_only():
|
||||
sql = _build_source_sql(_FQN, sample_frac=0.05)
|
||||
assert sql == "SELECT * FROM `autingo-159109.data.ventas` WHERE rand() < 0.05"
|
||||
|
||||
|
||||
def test_where_sql_and_sample_frac_combined_with_and_parenthesized():
|
||||
sql = _build_source_sql(
|
||||
_FQN,
|
||||
where_sql="fecha <= CURRENT_DATE() AND venta_n IS NOT NULL",
|
||||
sample_frac=0.1,
|
||||
)
|
||||
# Dos condiciones -> cada una entre paréntesis, unidas con AND.
|
||||
assert sql == (
|
||||
"SELECT * FROM `autingo-159109.data.ventas` "
|
||||
"WHERE (fecha <= CURRENT_DATE() AND venta_n IS NOT NULL) "
|
||||
"AND (rand() < 0.1)"
|
||||
)
|
||||
|
||||
|
||||
def test_single_condition_not_parenthesized():
|
||||
# Con una sola condición no se envuelve en paréntesis (más limpio).
|
||||
assert " WHERE fecha = 1" in _build_source_sql(_FQN, where_sql="fecha = 1")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# max_rows (LIMIT) — solo y combinado
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_max_rows_appends_limit():
|
||||
sql = _build_source_sql(_FQN, max_rows=1000)
|
||||
assert sql == "SELECT * FROM `autingo-159109.data.ventas` LIMIT 1000"
|
||||
|
||||
|
||||
def test_max_rows_zero_or_negative_no_limit():
|
||||
assert "LIMIT" not in _build_source_sql(_FQN, max_rows=0)
|
||||
assert "LIMIT" not in _build_source_sql(_FQN, max_rows=-5)
|
||||
|
||||
|
||||
def test_all_combined_order_where_then_limit():
|
||||
sql = _build_source_sql(
|
||||
_FQN,
|
||||
select_sql="a, b",
|
||||
where_sql="a > 0",
|
||||
sample_frac=0.2,
|
||||
max_rows=500,
|
||||
)
|
||||
assert sql == (
|
||||
"SELECT a, b FROM `autingo-159109.data.ventas` "
|
||||
"WHERE (a > 0) AND (rand() < 0.2) LIMIT 500"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# sample_frac fuera de rango -> no muestrea
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_sample_frac_out_of_range_ignored():
|
||||
# >=1, <=0 y None no añaden la cláusula rand().
|
||||
assert "rand()" not in _build_source_sql(_FQN, sample_frac=1.0)
|
||||
assert "rand()" not in _build_source_sql(_FQN, sample_frac=0.0)
|
||||
assert "rand()" not in _build_source_sql(_FQN, sample_frac=None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# _sanitize_dest_table
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_dest_empty_uses_last_fqn_segment():
|
||||
assert _sanitize_dest_table("", "proj.dataset.customer_profile") == "customer_profile"
|
||||
|
||||
|
||||
def test_dest_explicit_valid_kept():
|
||||
assert _sanitize_dest_table("mi_tabla", _FQN) == "mi_tabla"
|
||||
|
||||
|
||||
def test_dest_invalid_chars_replaced_with_underscore():
|
||||
assert _sanitize_dest_table("my-table", _FQN) == "my_table"
|
||||
assert _sanitize_dest_table("weird!!name", _FQN) == "weird__name"
|
||||
|
||||
|
||||
def test_dest_from_fqn_segment_with_hyphen_sanitized():
|
||||
# El último segmento con guiones se sanea (guion no es válido en identificador).
|
||||
assert _sanitize_dest_table("", "proj.dataset.tabla-con-guiones") == "tabla_con_guiones"
|
||||
Reference in New Issue
Block a user