chore: auto-commit (26 archivos)

- python/functions/bigquery/bq_auth.md
- python/functions/bigquery/bq_load_from_file.md
- python/functions/bigquery/bq_load_from_gcs.md
- python/functions/bigquery/client.py
- python/functions/bigquery/queries.py
- python/functions/datascience/__init__.py
- python/functions/datascience/decode_qr_image.py
- python/functions/datascience/load_bq_table_to_duckdb.md
- python/functions/datascience/load_bq_table_to_duckdb.py
- python/functions/pipelines/profile_bq_table.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 19:00:13 +02:00
parent 2ebc9efeb2
commit 5a4f82cf76
26 changed files with 2573 additions and 94 deletions
+6
View File
@@ -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"