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
+15 -5
View File
@@ -3,11 +3,11 @@ name: bq_auth
kind: function kind: function
lang: py lang: py
domain: infra domain: infra
version: "1.0.0" version: "1.1.0"
purity: impure purity: impure
signature: "def bq_auth(project_id: str = '', credentials_path: str = '') -> BQClient" signature: "def bq_auth(project_id: str = '', credentials_path: str = '', drop_quota_project: bool = False) -> BQClient"
description: "Autentica contra Google BigQuery con ADC o service account JSON. Retorna un BQClient listo para usar con todas las funciones CRUD." description: "Autentica contra Google BigQuery con ADC o service account JSON. Retorna un BQClient listo para usar con todas las funciones CRUD. Con drop_quota_project=True descarta el quota project del ADC del usuario (creds.with_quota_project(None)) para evitar el 403 USER_PROJECT_DENIED cuando el ADC lleva un quota_project_id ajeno."
tags: [bigquery, gcp, auth, google-cloud, python, pendiente-usar] tags: [bigquery, gcp, auth, google-cloud, python, forecast, pendiente-usar]
uses_functions: [] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
@@ -19,6 +19,8 @@ params:
desc: "ID del proyecto GCP (vacio = detectar de credenciales/entorno)" desc: "ID del proyecto GCP (vacio = detectar de credenciales/entorno)"
- name: credentials_path - name: credentials_path
desc: "ruta a archivo JSON de service account (vacio = Application Default Credentials)" desc: "ruta a archivo JSON de service account (vacio = Application Default Credentials)"
- name: drop_quota_project
desc: "si True y sin credentials_path, resuelve ADC via google.auth.default y descarta el quota project del ADC (with_quota_project(None)); evita el 403 USER_PROJECT_DENIED cuando el ADC del usuario lleva un quota_project_id ajeno. Default False = comportamiento original"
output: "BQClient: cliente autenticado con proyecto resuelto" output: "BQClient: cliente autenticado con proyecto resuelto"
tested: false tested: false
tests: [] tests: []
@@ -40,6 +42,9 @@ client = bq_auth("my-project-id")
# Service account # Service account
client = bq_auth(credentials_path="/path/to/service-account.json") client = bq_auth(credentials_path="/path/to/service-account.json")
# Sin quota project (evita 403 USER_PROJECT_DENIED con ADC de usuario)
client = bq_auth("autingo-159109", drop_quota_project=True)
# Context manager # Context manager
with bq_auth() as client: with bq_auth() as client:
# client se cierra automaticamente # client se cierra automaticamente
@@ -48,9 +53,14 @@ with bq_auth() as client:
## Notas ## Notas
Tres modos de autenticacion: Modos de autenticacion:
- Sin argumentos: usa Application Default Credentials (ADC) — requiere `gcloud auth application-default login` - Sin argumentos: usa Application Default Credentials (ADC) — requiere `gcloud auth application-default login`
- Con project_id: usa ADC pero fuerza el proyecto - Con project_id: usa ADC pero fuerza el proyecto
- Con credentials_path: lee el JSON de service account directamente - Con credentials_path: lee el JSON de service account directamente
- Con drop_quota_project=True (y sin credentials_path): resuelve ADC via `google.auth.default(scopes=[".../bigquery"])`, aplica `creds.with_quota_project(None)` si el atributo existe y construye el cliente con ese creds. Es el fix del gotcha conocido: el ADC del usuario (`egutierrez`) lleva `quota_project_id=autingo` ajeno y BigQuery devuelve `403 USER_PROJECT_DENIED`; descartar el quota project lo resuelve.
El BQClient wrappea `google.cloud.bigquery.Client` y expone `_client` para que las funciones del modulo lo usen internamente. El BQClient wrappea `google.cloud.bigquery.Client` y expone `_client` para que las funciones del modulo lo usen internamente.
## Capability growth log
- v1.1.0 (2026-07-02) — anade `drop_quota_project` para descartar el quota project del ADC del usuario (`creds.with_quota_project(None)`) y evitar el 403 USER_PROJECT_DENIED. Default False = comportamiento identico al anterior.
@@ -3,7 +3,7 @@ name: bq_load_from_file
kind: function kind: function
lang: py lang: py
domain: infra domain: infra
version: "1.0.0" version: "1.0.1"
purity: impure purity: impure
signature: "def bq_load_from_file(client: BQClient, file_path: str, dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict" signature: "def bq_load_from_file(client: BQClient, file_path: str, dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict"
description: "Carga datos desde un archivo local a una tabla BigQuery usando load_table_from_file del SDK. Equivalente a bq_load_from_gcs pero para disco local." description: "Carga datos desde un archivo local a una tabla BigQuery usando load_table_from_file del SDK. Equivalente a bq_load_from_gcs pero para disco local."
@@ -73,3 +73,7 @@ cargar desde ahi es mas eficiente y permite paralelismo.
La funcion bloquea hasta que el job termina (`job.result()`). Los archivos Parquet y La funcion bloquea hasta que el job termina (`job.result()`). Los archivos Parquet y
Avro no admiten `skip_leading_rows` — ese parametro solo aplica para CSV. Avro no admiten `skip_leading_rows` — ese parametro solo aplica para CSV.
## Capability growth log
- v1.0.1 (2026-07-02) — fix: `skip_leading_rows` solo se envía al LoadJobConfig cuando `source_format` es CSV; BigQuery rechazaba el job para JSON/Avro/Parquet incluso con valor 0.
@@ -3,7 +3,7 @@ name: bq_load_from_gcs
kind: function kind: function
lang: py lang: py
domain: infra domain: infra
version: "1.0.0" version: "1.0.1"
purity: impure purity: impure
signature: "def bq_load_from_gcs(client: BQClient, uri: str | list[str], dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict" signature: "def bq_load_from_gcs(client: BQClient, uri: str | list[str], dataset_id: str, table_id: str, source_format: str = 'CSV', write_disposition: str = 'WRITE_APPEND', autodetect: bool = True, skip_leading_rows: int = 0) -> dict"
description: "Carga datos desde uno o varios URIs de Google Cloud Storage a una tabla BigQuery configurando un LoadJob. Espera la finalizacion del job." description: "Carga datos desde uno o varios URIs de Google Cloud Storage a una tabla BigQuery configurando un LoadJob. Espera la finalizacion del job."
@@ -75,3 +75,7 @@ acepta la lista de archivos resultante como una sola carga atomica.
`autodetect=True` es conveniente pero puede inferir tipos incorrectamente para columnas `autodetect=True` es conveniente pero puede inferir tipos incorrectamente para columnas
con valores nulos o mixtos. Para produccion, definir el schema explicitamente via con valores nulos o mixtos. Para produccion, definir el schema explicitamente via
`job_config.schema`. `job_config.schema`.
## Capability growth log
- v1.0.1 (2026-07-02) — fix: `skip_leading_rows` solo se envía al LoadJobConfig cuando `source_format` es CSV; BigQuery rechazaba el job para JSON/Avro/Parquet incluso con valor 0.
+23 -1
View File
@@ -1,6 +1,7 @@
"""Cliente base para Google BigQuery.""" """Cliente base para Google BigQuery."""
from dataclasses import dataclass, field from dataclasses import dataclass, field
import google.auth
from google.cloud import bigquery from google.cloud import bigquery
from google.oauth2 import service_account from google.oauth2 import service_account
@@ -27,7 +28,11 @@ class BQClient:
self.close() self.close()
def bq_auth(project_id: str = "", credentials_path: str = "") -> BQClient: def bq_auth(
project_id: str = "",
credentials_path: str = "",
drop_quota_project: bool = False,
) -> BQClient:
"""Autentica contra Google BigQuery. """Autentica contra Google BigQuery.
Tres modos de autenticacion: Tres modos de autenticacion:
@@ -35,9 +40,18 @@ def bq_auth(project_id: str = "", credentials_path: str = "") -> BQClient:
2. Service account JSON: con credentials_path 2. Service account JSON: con credentials_path
3. Proyecto explicito: con project_id (usa ADC para credenciales) 3. Proyecto explicito: con project_id (usa ADC para credenciales)
Con drop_quota_project=True (y sin credentials_path) resuelve las credenciales
ADC via google.auth.default y elimina el quota project fijado en el ADC del
usuario (creds.with_quota_project(None)). Esto evita el error 403
USER_PROJECT_DENIED cuando el ADC lleva un quota_project_id ajeno al proyecto
contra el que se consulta.
Args: Args:
project_id: ID del proyecto GCP. Vacio = detectar de credenciales. project_id: ID del proyecto GCP. Vacio = detectar de credenciales.
credentials_path: Ruta a archivo JSON de service account. Vacio = ADC. credentials_path: Ruta a archivo JSON de service account. Vacio = ADC.
drop_quota_project: Si True y sin credentials_path, resuelve ADC con
google.auth.default y descarta el quota project del ADC
(with_quota_project(None)). Default False = comportamiento original.
Returns: Returns:
BQClient autenticado listo para usar. BQClient autenticado listo para usar.
@@ -50,11 +64,19 @@ def bq_auth(project_id: str = "", credentials_path: str = "") -> BQClient:
>>> client = bq_auth() # ADC >>> client = bq_auth() # ADC
>>> client = bq_auth("my-project") # ADC con proyecto explicito >>> client = bq_auth("my-project") # ADC con proyecto explicito
>>> client = bq_auth(credentials_path="/path/to/sa.json") # Service account >>> client = bq_auth(credentials_path="/path/to/sa.json") # Service account
>>> client = bq_auth("autingo-159109", drop_quota_project=True) # sin quota project
""" """
if credentials_path: if credentials_path:
creds = service_account.Credentials.from_service_account_file(credentials_path) creds = service_account.Credentials.from_service_account_file(credentials_path)
proj = project_id or creds.project_id proj = project_id or creds.project_id
client = bigquery.Client(credentials=creds, project=proj) client = bigquery.Client(credentials=creds, project=proj)
elif drop_quota_project:
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)
client = bigquery.Client(project=project_id or adc_project, credentials=creds)
elif project_id: elif project_id:
client = bigquery.Client(project=project_id) client = bigquery.Client(project=project_id)
else: else:
+11 -4
View File
@@ -173,11 +173,14 @@ def bq_load_from_gcs(
job_config = bigquery.LoadJobConfig( job_config = bigquery.LoadJobConfig(
source_format=format_map.get(source_format, bigquery.SourceFormat.CSV), source_format=format_map.get(source_format, bigquery.SourceFormat.CSV),
write_disposition=disposition_map.get(source_format, bigquery.WriteDisposition.WRITE_APPEND), write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND),
autodetect=autodetect, autodetect=autodetect,
skip_leading_rows=skip_leading_rows,
) )
job_config.write_disposition = disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND) # skip_leading_rows solo es valido para CSV: BigQuery rechaza el job
# ("Only CSV imports may specify leading rows to skip") si el campo va
# seteado con cualquier otro formato, incluso a 0.
if source_format == "CSV":
job_config.skip_leading_rows = skip_leading_rows
table_ref = client._client.dataset(dataset_id).table(table_id) table_ref = client._client.dataset(dataset_id).table(table_id)
uris = uri if isinstance(uri, list) else [uri] uris = uri if isinstance(uri, list) else [uri]
@@ -251,8 +254,12 @@ def bq_load_from_file(
source_format=format_map.get(source_format, bigquery.SourceFormat.CSV), source_format=format_map.get(source_format, bigquery.SourceFormat.CSV),
write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND), write_disposition=disposition_map.get(write_disposition, bigquery.WriteDisposition.WRITE_APPEND),
autodetect=autodetect, autodetect=autodetect,
skip_leading_rows=skip_leading_rows,
) )
# skip_leading_rows solo es valido para CSV: BigQuery rechaza el job
# ("Only CSV imports may specify leading rows to skip") si el campo va
# seteado con cualquier otro formato, incluso a 0.
if source_format == "CSV":
job_config.skip_leading_rows = skip_leading_rows
table_ref = client._client.dataset(dataset_id).table(table_id) table_ref = client._client.dataset(dataset_id).table(table_id)
+6
View File
@@ -25,6 +25,7 @@ from .describe_numeric import describe_numeric
from .summarize_categorical import summarize_categorical from .summarize_categorical import summarize_categorical
from .infer_semantic_type import infer_semantic_type from .infer_semantic_type import infer_semantic_type
from .column_quality_score import column_quality_score 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 .select_groupby_keys import select_groupby_keys
from .render_eda_markdown import render_eda_markdown from .render_eda_markdown import render_eda_markdown
from .detect_distribution_type import detect_distribution_type 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_table import generate_synthetic_eda_table
from .generate_synthetic_eda_folder import generate_synthetic_eda_folder from .generate_synthetic_eda_folder import generate_synthetic_eda_folder
from .load_bq_table_to_duckdb import load_bq_table_to_duckdb 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__ = [ __all__ = [
"forecast_seasonal_median",
"load_bq_table_to_duckdb", "load_bq_table_to_duckdb",
"list_bq_dataset_tables",
"generate_synthetic_eda_table", "generate_synthetic_eda_table",
"generate_synthetic_eda_folder", "generate_synthetic_eda_folder",
"render_paper_pdf", "render_paper_pdf",
@@ -141,6 +146,7 @@ __all__ = [
"summarize_categorical", "summarize_categorical",
"infer_semantic_type", "infer_semantic_type",
"column_quality_score", "column_quality_score",
"build_column_dictionary",
"select_groupby_keys", "select_groupby_keys",
"render_eda_markdown", "render_eda_markdown",
"detect_distribution_type", "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 sys
import cv2
import numpy as np 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. # Detectores. Cada uno se normaliza a una función run(img) -> list[str] que nunca lanza.
# -------------------------------------------------------------------------------------------- # --------------------------------------------------------------------------------------------
def _make_opencv_runner(detector): def _make_opencv_runner(detector):
"""Envuelve un cv2.QRCodeDetector(Aruco) en run(img) -> list[str].""" """Envuelve un cv2.QRCodeDetector(Aruco) en run(img) -> list[str]."""
import cv2
def run(img): def run(img):
out: list[str] = [] out: list[str] = []
@@ -89,6 +94,8 @@ def _make_pyzbar_runner(zbar_decode):
def _build_detectors(debug=False): def _build_detectors(debug=False):
"""Construye la lista de (nombre, runner) de detectores disponibles, en orden de preferencia.""" """Construye la lista de (nombre, runner) de detectores disponibles, en orden de preferencia."""
import cv2
detectors = [] detectors = []
# OpenCV Aruco (preferido): no requiere libs de sistema ni descarga de modelos. # 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): def _load_bgr(image_path):
"""Carga la imagen como BGR (uint8). Devuelve None si no se puede leer.""" """Carga la imagen como BGR (uint8). Devuelve None si no se puede leer."""
import cv2
bgr = cv2.imread(image_path, cv2.IMREAD_COLOR) bgr = cv2.imread(image_path, cv2.IMREAD_COLOR)
if bgr is not None: if bgr is not None:
return bgr return bgr
@@ -150,6 +159,8 @@ def _load_bgr(image_path):
def _build_variants(image_path, upscale): def _build_variants(image_path, upscale):
"""Genera (nombre, ndarray) de variantes preprocesadas, en orden de prioridad.""" """Genera (nombre, ndarray) de variantes preprocesadas, en orden de prioridad."""
import cv2
bgr = _load_bgr(image_path) bgr = _load_bgr(image_path)
if bgr is None: if bgr is None:
return [] 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 kind: function
lang: py lang: py
domain: datascience domain: datascience
version: "1.1.0" version: "1.3.0"
purity: impure 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" 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. Fetch via BigQuery Storage Read API (Arrow) con fallback REST. Seudonimiza columnas PII con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD)." 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] tags: [eda, bigquery, duckdb, datascience]
params: params:
- name: table_fqn - name: table_fqn
@@ -22,17 +22,36 @@ params:
- name: project_id - name: project_id
desc: "Proyecto GCP de facturación. Vacío = primer segmento del FQN o el del ADC." desc: "Proyecto GCP de facturación. Vacío = primer segmento del FQN o el del ADC."
- name: pseudonymize_cols - name: pseudonymize_cols
desc: "Lista de columnas PII a seudonimizar con hash SHA-1 truncado antes de materializar (LOPDGDD/RGPD). Preserva nulos y cardinalidad." 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."
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}." - 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_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
returns_optional: false returns_optional: false
error_type: "error_go_core" error_type: "error_go_core"
imports: [] imports: []
tested: false tested: true
tests: [] tests:
test_file_path: "" - "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" 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, sample_frac=0.05,
pseudonymize_cols=["document_number", "full_name", "email", "phone"], 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 ## 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`). - **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`). - **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. - **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).
- **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. - **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)`).
- **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. - **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`.
- **dict-no-throw**: nunca lanza excepción; ante cualquier fallo (FQN inválido, auth, query) devuelve `{status:'error', error:str}`. - **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 ## Notas
Adaptador del grupo de capacidad `eda`: el resto de funciones del grupo perfilan 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 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 hueco materializando una sola tabla DuckDB desde el resultado de la query BigQuery,
query BigQuery. El nombre de tabla destino se sanea (`^[A-Za-z_][A-Za-z0-9_]*$`) por batches Arrow cuando es posible. El SELECT sobre el origen lo compone el helper
antes de citarlo en el `CREATE OR REPLACE TABLE`. 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 ## 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. - 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 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) las funciones del grupo de capacidad `eda` (que perfilan DuckDB/PostgreSQL)
puedan analizarla sin un adaptador BigQuery nativo. Materializa una sola tabla 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 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 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` 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 es un tope duro opcional (0 = sin tope).
(Arrow) cuando está disponible, con fallback al conversor REST.
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 Seudonimización LOPDGDD/RGPD: las columnas listadas en `pseudonymize_cols` se
transforman con un hash SHA-1 truncado ANTES de escribir a disco, preservando 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, 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 nombre, email, teléfono, etc.). En el camino streaming se aplica POR BATCH antes de
datos personales reales. 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 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', ...}. Estilo dict-no-throw del grupo `eda`: nunca lanza; devuelve {status:'error', ...}.
""" """
@@ -53,6 +66,138 @@ def _safe_isna(v):
return False 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( def load_bq_table_to_duckdb(
table_fqn: str, table_fqn: str,
duckdb_path: str, duckdb_path: str,
@@ -61,6 +206,8 @@ def load_bq_table_to_duckdb(
max_rows: int = 0, max_rows: int = 0,
project_id: str = "", project_id: str = "",
pseudonymize_cols: list = None, pseudonymize_cols: list = None,
where_sql: str = "",
select_sql: str = "",
) -> dict: ) -> dict:
try: try:
import duckdb import duckdb
@@ -70,10 +217,8 @@ def load_bq_table_to_duckdb(
if not table_fqn or not _FQN_RE.match(table_fqn): if not table_fqn or not _FQN_RE.match(table_fqn):
return {"status": "error", "error": f"table_fqn inválido: {table_fqn!r}"} 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_table: derivar del último segmento del FQN si no se pasa, saneado.
dest = dest_table or table_fqn.split(".")[-1] dest = _sanitize_dest_table(dest_table, table_fqn)
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"
# Auth ADC con fix de quota project (403 USER_PROJECT_DENIED). # Auth ADC con fix de quota project (403 USER_PROJECT_DENIED).
creds, adc_project = google.auth.default( 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 proj = project_id or table_fqn.split(".")[0] or adc_project
client = bigquery.Client(project=proj, credentials=creds) 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( cnt = client.query(
f"SELECT COUNT(*) AS n FROM `{table_fqn}`" f"SELECT COUNT(*) AS n FROM `{table_fqn}`{count_where}"
).result() ).result()
n_source = 0 n_source = 0
for row in cnt: 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 # 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 # 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 # `rand() < frac`, combinado con `where_sql` vía AND. max_rows>0 es un tope
# en vistas). max_rows>0 es un tope duro opcional (LIMIT); 0 = sin tope. # duro opcional (LIMIT). `select_sql` proyecta expresiones (vacío = `*`).
sampled = False sampled = sample_frac is not None and 0 < float(sample_frac) < 1
where = "" sql = _build_source_sql(table_fqn, select_sql, where_sql, sample_frac, max_rows)
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}"
# Fetch: BigQuery Storage Read API (Arrow, rápido para millones de filas) # ¿Está pyarrow disponible? Decide el camino de ingesta ANTES de consumir
# con fallback al conversor REST si la lib no está o falla. # el resultado (streaming Arrow por batches vs DataFrame completo).
try: try:
df = client.query(sql).result().to_dataframe(create_bqstorage_client=True) import pyarrow # noqa: F401
has_pyarrow = True
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
df = client.query(sql).result().to_dataframe(create_bqstorage_client=False) has_pyarrow = False
n_fetched = len(df)
# Normalizar dtypes de db-dtypes: el conversor REST de BigQuery mapea las job = client.query(sql)
# columnas DATE/TIME a las extension dtypes `dbdate`/`dbtime` de db-dtypes, result = job.result()
# que DuckDB NO reconoce al registrar el DataFrame ("Data type 'dbdate' not use_stream = has_pyarrow and hasattr(result, "to_arrow_iterable")
# 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)
# Seudonimización de columnas PII antes de escribir a disco. pseudo_set = set(pseudonymize_cols or [])
pseudo_applied = [] pseudo_applied = []
for col in (pseudonymize_cols or []): n_fetched = 0
if col in df.columns: columns = []
df[col] = _pseudonymize_series(df[col].tolist()) streamed = False
pseudo_applied.append(col)
# Materializar a DuckDB (una tabla desde el DataFrame).
con = duckdb.connect(duckdb_path) con = duckdb.connect(duckdb_path)
try: try:
con.register("_src_df", df) if use_stream:
con.execute(f'CREATE OR REPLACE TABLE "{dest}" AS SELECT * FROM _src_df') # Cliente BigQuery Storage con las MISMAS creds corregidas
con.unregister("_src_df") # (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: finally:
con.close() con.close()
@@ -150,8 +410,10 @@ def load_bq_table_to_duckdb(
"n_rows_fetched": n_fetched, "n_rows_fetched": n_fetched,
"sampled": sampled, "sampled": sampled,
"sample_frac": float(sample_frac) if sampled else None, "sample_frac": float(sample_frac) if sampled else None,
"columns": list(df.columns), "columns": columns,
"pseudonymized": pseudo_applied, "pseudonymized": pseudo_applied,
"streamed": streamed,
"auto_casts": auto_casts,
} }
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e)} 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"
@@ -0,0 +1,133 @@
---
name: profile_bq_dataset
kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.1.0"
signature: "def profile_bq_dataset(project_id: str, dataset: str, tables: list = None, include_views: bool = False, sample_frac: float = None, max_rows: int = 0, pseudonymize_cols: dict = None, report_dir: str = \"reports\", duckdb_path: str = \"\", keep_duckdb: bool = True, min_inclusion: float = 0.9, emit_pdf: bool = True, run_llm: bool = False) -> dict"
description: "EDA one-shot de un dataset BigQuery ENTERO con descubrimiento cross-tabla: materializa CADA tabla del dataset (COMPLETA por defecto; muestreo opt-in con sample_frac; seudonimizacion PII por tabla, LOPDGDD/RGPD) en UN MISMO DuckDB compartido con load_bq_table_to_duckdb, lo perfila entero con profile_database (perfiles por tabla + relaciones FK inter-tabla por containment + join graph Mermaid + report markdown/JSON + PDF movil), y construye el diccionario de columnas del dataset con build_column_dictionary. Es el analogo BigQuery de profile_database a nivel de dataset, resuelto por composicion estricta (list_bq_dataset_tables -> load_bq_table_to_duckdb x N -> profile_database -> build_column_dictionary) sin duplicar descubrimiento, perfilado ni inferencia de relaciones. Es el hazme un EDA de este dataset BigQuery entero y descubre como se relacionan sus tablas, en una sola llamada."
tags: [eda, bigquery, relations, launcher]
uses_functions:
- list_bq_dataset_tables_py_datascience
- load_bq_table_to_duckdb_py_datascience
- profile_database_py_pipelines
- build_column_dictionary_py_datascience
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: []
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/profile_bq_dataset.py"
params:
- name: project_id
desc: "Proyecto GCP (facturacion + primer segmento del FQN). Ej: 'autingo-159109'."
- name: dataset
desc: "Dataset BigQuery a perfilar entero. Ej: 'customer_marts'."
- name: tables
desc: "Lista de NOMBRES de tabla del dataset. None (DEFAULT) = todas las del dataset (filtradas por include_views)."
- name: include_views
desc: "Si True incluye las VIEW ademas de las BASE TABLE cuando tables=None. Default False (solo BASE TABLE, coherente con profile_database que salta las VIEWs)."
- name: sample_frac
desc: "None (DEFAULT) = FULL, perfila TODAS las filas de cada tabla. Un float en (0,1) activa el muestreo opt-in por tabla (`WHERE rand() < frac`)."
- name: max_rows
desc: "Tope duro de filas por tabla (LIMIT). 0 (DEFAULT) = sin tope. Se aplica a cada tabla materializada."
- name: pseudonymize_cols
desc: "Dict {\"tabla\": [\"col1\", \"col2\"]} de columnas PII a seudonimizar (hash) por tabla ANTES de materializar (LOPDGDD/RGPD [POL-MMNSEG-001-1.0]). Preserva nulos y cardinalidad."
- name: report_dir
desc: "Directorio de salida de los reports + del DuckDB compartido por defecto. Default 'reports' (artefacto local gitignored). Se crea si no existe."
- name: duckdb_path
desc: "Ruta del DuckDB compartido donde se materializan todas las tablas. Vacio (DEFAULT) = report_dir/eda_bq_<dataset>.duckdb (se limpia si ya existia)."
- name: keep_duckdb
desc: "Si True (DEFAULT) conserva el DuckDB materializado: es el artefacto explorable post-EDA (notebook Jupyter, joins ad-hoc). Con False se borra al terminar."
- name: min_inclusion
desc: "Umbral de inclusion (0-1) para emitir una FK candidata cross-tabla (se pasa a profile_database -> infer_fk_containment_duckdb). Default 0.9."
- name: emit_pdf
desc: "Si True (DEFAULT) emite el PDF movil DB-level (resumen de tablas + relaciones FK + join graph). Se pasa a profile_database."
- name: run_llm
desc: "Si True (default False) activa la capa LLM interpretativa por tabla (se pasa a profile_database -> profile_table): una llamada LLM por tabla sobre el perfil agregado, nunca filas crudas."
output: "dict dict-no-throw. En exito {status:'ok', project_id, dataset, n_tables_loaded, n_tables_profiled, tables_skipped:[...], errors:[...], duckdb_path, db_profile:<DatabaseProfile con tables[resumen], table_profiles[completos], fk_candidates, join_graph{nodes,edges,mermaid,hubs}>, column_dictionary:{entries,pii_columns} (sin markdown), report_md_path, report_json_path, report_pdf_path, dict_md_path, dict_json_path}. En error {status:'error', error, stage}."
---
## Ejemplo
```python
from pipelines.profile_bq_dataset import profile_bq_dataset
# FULL por defecto: EDA del dataset ENTERO (todas las tablas, todas las filas),
# con FK cross-tabla + join graph + diccionario de columnas. El DuckDB compartido
# queda en reports/eda_bq_customer_marts.duckdb para seguir explorando.
r = profile_bq_dataset("autingo-159109", "customer_marts")
print(r["n_tables_loaded"], "tablas materializadas,", r["n_tables_profiled"], "perfiladas")
print("FKs:", [f"{fk['from_table']}.{fk['from_col']}->{fk['to_table']}.{fk['to_col']}"
for fk in r["db_profile"]["fk_candidates"]])
print(r["report_md_path"]); print(r["report_pdf_path"]); print(r["dict_md_path"])
print("DuckDB explorable:", r["duckdb_path"])
# Dataset con tablas enormes: muestreo opt-in + PII seudonimizada por tabla.
r = profile_bq_dataset(
"autingo-159109",
"customer_marts",
sample_frac=0.05,
pseudonymize_cols={
"customer_profile": ["document_number", "full_name", "email", "phone"],
},
)
```
## Cuando usarla
Cuando pidan un EDA de un DATASET BigQuery entero y no solo de una tabla: quieres
el perfil de todas sus tablas MAS su esquema relacional (que tabla referencia a
cual, con que cardinalidad) descubierto cross-tabla en una sola llamada. Es el
escalon a nivel de dataset sobre `profile_bq_table` (una tabla) y el adaptador
BigQuery de `profile_database` (una base DuckDB). Usala al recibir un dataset
BigQuery desconocido, para documentar un data mart, para descubrir el star schema
(las tablas hub del join graph) o antes de escribir joins sin tener el modelo
declarado. Para datasets con tablas enormes, pasa `sample_frac` o `max_rows` y
dejalo declarado en el report.
## Gotchas
- Impura: requiere ADC de BigQuery configurado (Application Default Credentials).
Si el ADC del usuario lleva un quota project ajeno, `load_bq_table_to_duckdb` /
`list_bq_dataset_tables` aplican `creds.with_quota_project(None)` para evitar el
403 USER_PROJECT_DENIED — remitido a los gotchas de esas funciones.
- Coste de traer un DATASET entero: FULL por defecto materializa TODAS las filas
de CADA tabla a RAM (via BigQuery Storage Read API/Arrow) antes de volcar al
DuckDB compartido. Un dataset con varias tablas de millones de filas puede
costar en tiempo, bytes escaneados de BigQuery y GBs de RAM/disco. Acota con
`sample_frac` in (0,1) (muestreo opt-in por tabla) o `max_rows` (tope duro por
tabla). Si por limite de recursos no cabe el total, dilo explicito en el report
con el maximo que si se cargo.
- El DuckDB compartido puede ocupar GBs: todas las tablas del dataset viven en un
MISMO archivo (necesario para que la inferencia de FK opere cross-tabla). Con
`keep_duckdb=True` (default) queda en disco como artefacto explorable; pasa
`keep_duckdb=False` para borrarlo al terminar. Con `duckdb_path` explicito la
ruta se respeta; el path por defecto se limpia al inicio para no mezclar tablas
de corridas anteriores.
- FK por containment es una HEURISTICA (falsos positivos/negativos posibles) y
`profile_database` SALTA los pares de FK hacia tablas con mas de 200k filas (el
lado caro del INTERSECT): esas relaciones quedan sin evaluar. Es un mapa de
partida del esquema, no un DDL autoritativo.
- Vistas excluidas por defecto (`include_views=False`, coherente con
profile_database que salta VIEWs — perfilarlas infla n_tables y multiplica FK
falsas). Pasa `include_views=True` solo si necesitas perfilarlas como si fueran
tablas materializadas.
- Seudonimiza PII con `pseudonymize_cols` (dict por tabla) para cumplir LOPDGDD/
RGPD [POL-MMNSEG-001-1.0] ANTES de escribir a disco: nombres, DNI/NIE, email,
telefono, direccion, IDs de cliente, IBAN, etc. Sin seudonimizar, el DuckDB
compartido + los reports contienen datos personales reales.
- Tolera fallos por tabla: si una carga falla, se anota en `errors[]` +
`tables_skipped[]` y el pipeline sigue con las demas; `n_tables_profiled` cuenta
solo las perfiladas con exito. Revisa `errors` para saber que quedo fuera.
- Escribe a `report_dir` (default 'reports', artefacto local gitignored): el report
DB-level de `profile_database` (markdown + JSON + PDF si emit_pdf) MAS el
diccionario de columnas (`..._dict.md` + `..._dict.json`).
## Capability growth log
- v1.1.0 (2026-07-02) — añade `run_llm` (passthrough a profile_database -> profile_table: una llamada LLM por tabla sobre el perfil agregado). Default False, sin breaking changes.
@@ -0,0 +1,263 @@
"""profile_bq_dataset — EDA one-shot de un dataset BigQuery ENTERO (cross-tabla).
Pipeline impuro: perfila un dataset de Google BigQuery COMPLETO con descubrimiento
cross-tabla (relaciones FK inter-tabla + join graph + diccionario de columnas). Es
el analogo a nivel de dataset de `profile_bq_table` (que perfila UNA tabla) y el
adaptador BigQuery de `profile_database` (que perfila una base DuckDB entera),
resuelto por composicion estricta de funciones del registry — sin reimplementar
ni el descubrimiento, ni el perfilado, ni la inferencia de relaciones.
Clave del diseno: materializa CADA tabla del dataset en UN MISMO archivo DuckDB
compartido, para que la inferencia de FK por containment (que necesita todas las
tablas en la misma base) opere cross-tabla. El DuckDB compartido queda como el
artefacto explorable post-EDA (keep_duckdb=True por defecto).
Funciones del registry compuestas (NO se reimplementa su logica):
- list_bq_dataset_tables : catalogo de tablas/vistas del dataset (descubrimiento).
- load_bq_table_to_duckdb: materializa cada tabla BigQuery al DuckDB compartido
(completa por defecto, muestra si sample_frac; PII
seudonimizada por tabla antes de escribir a disco).
- profile_database : perfila el DuckDB compartido entero (perfiles por
tabla + FK por containment + join graph Mermaid +
report markdown/JSON + PDF movil DB-level).
- build_column_dictionary: diccionario de columnas buscable (tipo semantico, PII)
a partir del DatabaseProfile ensamblado.
Modo por defecto = FULL: `sample_frac=None` trae cada tabla entera (preferencia
estandar del usuario: los EDA se corren sobre el total salvo que se pida lo
contrario). El muestreo es opt-in explicito por dataset: `sample_frac=0.05` trae
~5 % de cada tabla; `max_rows` es un tope duro por tabla (0 = sin tope).
Seudonimizacion LOPDGDD/RGPD [POL-MMNSEG-001-1.0]: `pseudonymize_cols` es un dict
`{"tabla": ["col1", "col2"]}` que se aplica por tabla ANTES de materializar.
Estilo dict-no-throw del grupo `eda`: nunca lanza; captura cualquier error y
devuelve {status:'error', error, stage}. Los fallos por tabla individual se
toleran: se anotan en errors[]/tables_skipped[] y se sigue con las demas.
"""
import json
import os
from datetime import datetime, timezone
from datascience import (
build_column_dictionary,
list_bq_dataset_tables,
load_bq_table_to_duckdb,
)
from pipelines.profile_database import profile_database
def profile_bq_dataset(
project_id: str,
dataset: str,
tables: list = None,
include_views: bool = False,
sample_frac: float = None,
max_rows: int = 0,
pseudonymize_cols: dict = None,
report_dir: str = "reports",
duckdb_path: str = "",
keep_duckdb: bool = True,
min_inclusion: float = 0.9,
emit_pdf: bool = True,
run_llm: bool = False,
) -> dict:
"""EDA one-shot de un dataset BigQuery entero, con descubrimiento cross-tabla.
Materializa cada tabla del dataset a un DuckDB compartido, lo perfila entero
con `profile_database` (perfiles por tabla + FK inter-tabla + join graph +
reports + PDF) y construye el diccionario de columnas del dataset. Por defecto
perfila TODAS las filas de cada tabla (`sample_frac=None`, modo FULL) y solo
las BASE TABLE (las vistas se excluyen salvo `include_views=True`).
Args:
project_id: proyecto GCP (facturacion + primer segmento del FQN).
dataset: dataset BigQuery a perfilar.
tables: lista de NOMBRES de tabla del dataset. None (default) = todas las
del dataset (filtradas por include_views).
include_views: si True incluye las VIEW ademas de las BASE TABLE cuando
tables=None. Default False (solo BASE TABLE, coherente con
profile_database que salta las VIEWs).
sample_frac: None (default) = FULL, perfila todas las filas de cada tabla.
Un float en (0,1) activa el muestreo opt-in por tabla.
max_rows: tope duro de filas por tabla (LIMIT). 0 (default) = sin tope.
pseudonymize_cols: dict {"tabla": ["col1", "col2"]} de columnas PII a
seudonimizar (hash) por tabla ANTES de materializar (LOPDGDD/RGPD).
report_dir: directorio de salida de los reports + del DuckDB por defecto.
duckdb_path: ruta del DuckDB compartido. Vacio = report_dir/eda_bq_<dataset>.duckdb.
keep_duckdb: si True (default) conserva el DuckDB materializado (es el
artefacto explorable post-EDA). Con False se borra al terminar.
min_inclusion: umbral de inclusion (0-1) para emitir una FK candidata
(se pasa a profile_database -> infer_fk_containment_duckdb).
emit_pdf: si True (default) emite el PDF movil DB-level (se pasa a
profile_database).
run_llm: si True (default False) activa la capa LLM interpretativa por
tabla (se pasa a profile_database -> profile_table; una llamada LLM
por tabla sobre el perfil agregado, nunca filas crudas).
Returns:
dict dict-no-throw con el resultado del pipeline (ver output del .md).
"""
try:
# 1) Resolver la lista de tablas a materializar como (nombre, fqn).
if tables is None:
lst = list_bq_dataset_tables(
project_id, dataset, include_views=include_views
)
if lst.get("status") != "ok":
return {
"status": "error",
"error": lst.get("error", "list_bq_dataset_tables fallo"),
"stage": "list_tables",
}
table_specs = [
(t["table"], t.get("fqn") or f"{project_id}.{dataset}.{t['table']}")
for t in lst.get("tables", [])
]
elif isinstance(tables, list):
table_specs = [
(name, f"{project_id}.{dataset}.{name}") for name in tables
]
else:
return {
"status": "error",
"error": "tables debe ser una lista de nombres o None",
"stage": "list_tables",
}
if not table_specs:
return {
"status": "error",
"error": (
"el dataset no tiene tablas que perfilar "
"(revisa include_views / el nombre del dataset)"
),
"stage": "list_tables",
}
# 2) Resolver el DuckDB compartido. Vacio -> report_dir/eda_bq_<dataset>.duckdb.
os.makedirs(report_dir, exist_ok=True)
created_default = False
if not duckdb_path:
duckdb_path = os.path.join(report_dir, f"eda_bq_{dataset}.duckdb")
created_default = True
# Si es el path por defecto y ya existe de una corrida previa, empezar
# limpio para que no se mezclen tablas de datasets distintos en la base.
if created_default and os.path.exists(duckdb_path):
try:
os.remove(duckdb_path)
except OSError:
pass
# 3) Materializar CADA tabla en el MISMO DuckDB (tolerando fallos).
pseudo_map = pseudonymize_cols or {}
loaded_tables = [] # nombres de tabla dentro del DuckDB
tables_skipped = []
errors = []
for name, fqn in table_specs:
load = load_bq_table_to_duckdb(
fqn,
duckdb_path,
sample_frac=sample_frac,
max_rows=max_rows,
project_id=project_id,
pseudonymize_cols=pseudo_map.get(name),
)
if load.get("status") == "ok":
loaded_tables.append(load["table"])
else:
errors.append(
{
"table": name,
"stage": "load",
"error": load.get("error", "load fallo"),
}
)
tables_skipped.append(name)
if not loaded_tables:
return {
"status": "error",
"error": "ninguna tabla del dataset se pudo materializar",
"stage": "load",
"errors": errors,
}
# 4) Perfilar el DuckDB compartido entero: perfiles por tabla + FK
# cross-tabla + join graph + report markdown/JSON + PDF (si emit_pdf).
prof = profile_database(
duckdb_path,
tables=loaded_tables,
report_dir=report_dir,
write_report=True,
min_inclusion=min_inclusion,
emit_pdf=emit_pdf,
run_llm=run_llm,
)
if prof.get("status") != "ok":
return {
"status": "error",
"error": prof.get("error", "profile_database fallo"),
"stage": "profile",
}
db_profile = prof.get("db_profile", {})
# 5) Diccionario de columnas del dataset sobre el DatabaseProfile.
dict_md_path = None
dict_json_path = None
column_dictionary = None
dict_res = build_column_dictionary(db_profile)
if dict_res.get("status") == "ok":
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
dict_md_path = os.path.join(
report_dir, f"eda_bq_dataset_{dataset}_{ts}_dict.md"
)
dict_json_path = os.path.join(
report_dir, f"eda_bq_dataset_{dataset}_{ts}_dict.json"
)
with open(dict_md_path, "w", encoding="utf-8") as fh:
fh.write(dict_res.get("markdown", ""))
# JSON sidecar del diccionario sin el markdown (compacto).
dict_payload = {k: v for k, v in dict_res.items() if k != "markdown"}
with open(dict_json_path, "w", encoding="utf-8") as fh:
fh.write(
json.dumps(dict_payload, ensure_ascii=False, indent=1, default=str)
)
column_dictionary = dict_payload
else:
errors.append(
{
"stage": "column_dictionary",
"error": dict_res.get("error", "build_column_dictionary fallo"),
}
)
# 6) Limpieza del DuckDB compartido salvo que se pida conservarlo.
final_duckdb = duckdb_path
if not keep_duckdb and os.path.exists(duckdb_path):
try:
os.remove(duckdb_path)
except OSError:
pass
final_duckdb = None
return {
"status": "ok",
"project_id": project_id,
"dataset": dataset,
"n_tables_loaded": len(loaded_tables),
"n_tables_profiled": db_profile.get("n_tables", 0),
"tables_skipped": tables_skipped,
"errors": errors,
"duckdb_path": final_duckdb,
"db_profile": db_profile,
"column_dictionary": column_dictionary,
"report_md_path": prof.get("report_md_path"),
"report_json_path": prof.get("report_json_path"),
"report_pdf_path": prof.get("report_pdf_path"),
"dict_md_path": dict_md_path,
"dict_json_path": dict_json_path,
}
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e), "stage": "unexpected"}
+31 -9
View File
@@ -4,8 +4,8 @@ kind: pipeline
lang: py lang: py
domain: pipelines domain: pipelines
purity: impure purity: impure
version: "1.1.0" version: "1.2.0"
signature: "def profile_bq_table(table_fqn: str, sample_frac: float = None, max_rows: int = 0, pseudonymize_cols: list = None, run_models: bool = True, run_series: bool = False, run_llm: bool = False, project_id: str = \"\", report_dir: str = \"reports\", duckdb_path: str = \"\", keep_duckdb: bool = False) -> dict" signature: "def profile_bq_table(table_fqn: str, sample_frac: float = None, max_rows: int = 0, pseudonymize_cols: list = None, run_models: bool = True, run_series: bool = False, run_llm: bool = False, project_id: str = \"\", report_dir: str = \"reports\", duckdb_path: str = \"\", keep_duckdb: bool = False, where_sql: str = \"\", select_sql: str = \"\") -> dict"
description: "EDA one-shot de una tabla o vista de BigQuery: materializa el origen COMPLETO por defecto (todas las filas; muestreo opt-in con sample_frac; seudonimizacion PII opcional, LOPDGDD/RGPD) a un DuckDB local con load_bq_table_to_duckdb y lo perfila end-to-end con profile_table del grupo de capacidad eda, emitiendo el informe AutomaticEDA (PDF A5 movil + PPTX 16:9), Markdown y JSON sidecar. Es el adaptador BigQuery que faltaba en el grupo eda, resuelto por composicion (BigQuery -> DuckDB local -> profile_table) sin duplicar la logica de perfilado ni de render. Es el hazme un EDA de esta tabla BigQuery en una sola llamada, sobre el total de filas por defecto." description: "EDA one-shot de una tabla o vista de BigQuery: materializa el origen COMPLETO por defecto (todas las filas; muestreo opt-in con sample_frac; seudonimizacion PII opcional, LOPDGDD/RGPD) a un DuckDB local con load_bq_table_to_duckdb y lo perfila end-to-end con profile_table del grupo de capacidad eda, emitiendo el informe AutomaticEDA (PDF A5 movil + PPTX 16:9), Markdown y JSON sidecar. Es el adaptador BigQuery que faltaba en el grupo eda, resuelto por composicion (BigQuery -> DuckDB local -> profile_table) sin duplicar la logica de perfilado ni de render. Es el hazme un EDA de esta tabla BigQuery en una sola llamada, sobre el total de filas por defecto."
tags: [eda, bigquery, launcher] tags: [eda, bigquery, launcher]
uses_functions: uses_functions:
@@ -43,7 +43,11 @@ params:
desc: "Ruta DuckDB a usar. Vacio = temporal autogestionado." desc: "Ruta DuckDB a usar. Vacio = temporal autogestionado."
- name: keep_duckdb - name: keep_duckdb
desc: "Si True conserva el DuckDB materializado (para el notebook Jupyter). Default False." desc: "Si True conserva el DuckDB materializado (para el notebook Jupyter). Default False."
output: "dict dict-no-throw. En exito {status:'ok', table_fqn, load:{n_rows_source,n_rows_fetched,sampled,sample_frac,pseudonymized,table}, duckdb_path, report_md_path, report_json_path, aeda_pdf_path, aeda_pptx_path, aeda_manifest_path, profile}. En error {status:'error', error, stage}." - name: where_sql
desc: "Clausula WHERE SQL (sin la palabra WHERE) aplicada al origen y a su COUNT. Pass-through a load_bq_table_to_duckdb; se combina con 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: "Expresiones del SELECT (sin la palabra SELECT); vacio (DEFAULT) = `*`. Pass-through a load_bq_table_to_duckdb. Util para castear tipos problematicos (p. ej. BIGNUMERIC->FLOAT64) antes de perfilar. Se interpola tal cual: no usar con input no confiable."
output: "dict dict-no-throw. En exito {status:'ok', table_fqn, load:{n_rows_source,n_rows_fetched,sampled,sample_frac,pseudonymized,table,streamed, where_sql?, select_sql?}, duckdb_path, report_md_path, report_json_path, aeda_pdf_path, aeda_pptx_path, aeda_manifest_path, profile}. En error {status:'error', error, stage}. where_sql/select_sql aparecen en load solo si vienen informados."
--- ---
## Ejemplo ## Ejemplo
@@ -66,6 +70,16 @@ r = profile_bq_table(
sample_frac=0.05, sample_frac=0.05,
pseudonymize_cols=["document_number", "full_name", "email", "phone", "postal_code", "salesforce_customer_id"], pseudonymize_cols=["document_number", "full_name", "email", "phone", "postal_code", "salesforce_customer_id"],
) )
# Filtrar el origen + castear una columna BIGNUMERIC antes de perfilar. where_sql y
# select_sql se pasan al loader (que ingiere por batches Arrow, RAM acotada).
r = profile_bq_table(
"autingo-159109.data.ventas_39M",
where_sql="fecha <= CURRENT_DATE() AND venta_n IS NOT NULL",
select_sql="fecha, idCentro, CAST(importe_bignumeric AS FLOAT64) AS importe",
run_models=True,
)
print(r["load"]["n_rows_fetched"], "filas, streamed=", r["load"].get("streamed"))
``` ```
## Cuando usarla ## Cuando usarla
@@ -83,12 +97,19 @@ en RAM, pasa `sample_frac` (muestreo opt-in).
- Impura: requiere ADC de BigQuery configurado (Application Default Credentials) - Impura: requiere ADC de BigQuery configurado (Application Default Credentials)
para que `load_bq_table_to_duckdb` autentique contra el proyecto. para que `load_bq_table_to_duckdb` autentique contra el proyecto.
- FULL por defecto: `sample_frac=None` perfila TODAS las filas del origen. Una - FULL por defecto: `sample_frac=None` perfila TODAS las filas del origen. El
vista de millones de filas se trae entera a RAM (varios GB posibles) antes de loader `load_bq_table_to_duckdb` (v1.2.0) ingiere por batches Arrow ->
materializar en DuckDB; el fetch usa el BigQuery Storage Read API (Arrow) cuando DuckDB cuando `pyarrow` esta disponible, con la RAM acotada al tamano de un
esta disponible, mucho mas rapido que REST. Para acotar coste/memoria, pasa batch (una tabla de decenas de millones de filas cabe sin cargarse entera); si
`sample_frac` in (0,1) (muestreo opt-in) o `max_rows` (tope duro). Si por limite no, cae al camino DataFrame completo (todo en RAM, varios GB posibles). Para
de recursos no cabe el total, dilo explicito con el maximo que si se cargo. acotar coste/memoria pasa `sample_frac` in (0,1), `max_rows` (tope duro) o
`where_sql` (filtra el origen). Si por limite de recursos no cabe el total,
dilo explicito con el maximo que si se cargo.
- `where_sql` / `select_sql` (pass-through al loader) se interpolan TAL CUAL en la
query BigQuery: NO los construyas a partir de input no confiable (inyeccion SQL).
`select_sql` es la via para castear columnas BIGNUMERIC (Arrow decimal256, que
DuckDB no ingiere) a FLOAT64 antes de perfilar; si no las casteas, el loader
devuelve `{status:'error', stage:'stream_schema'|'stream_insert'}`.
- Seudonimiza PII con `pseudonymize_cols` para cumplir LOPDGDD/RGPD ANTES de - Seudonimiza PII con `pseudonymize_cols` para cumplir LOPDGDD/RGPD ANTES de
escribir a disco: nombres, DNI/NIE, email, telefono, direccion, IDs de cliente, escribir a disco: nombres, DNI/NIE, email, telefono, direccion, IDs de cliente,
etc. Se hashean preservando nulos y cardinalidad. Sin seudonimizar, la muestra etc. Se hashean preservando nulos y cardinalidad. Sin seudonimizar, la muestra
@@ -103,4 +124,5 @@ en RAM, pasa `sample_frac` (muestreo opt-in).
## Capability growth log ## Capability growth log
- v1.2.0 (2026-07-02) — Añade `where_sql` y `select_sql` como pass-through al loader `load_bq_table_to_duckdb`: filtran/proyectan el origen antes de perfilar (`where_sql` tambien acota el COUNT del origen; `select_sql` permite castear BIGNUMERIC->FLOAT64). Ambos se reflejan en el bloque `load` del retorno (solo si vienen informados), junto con la nueva clave `streamed`. Hereda del loader v1.2.0 la ingesta streaming Arrow -> DuckDB por batches (RAM acotada) para tablas que no caben en RAM, con fallback DataFrame completo.
- v1.1.0 (2026-07-01) — FULL pasa a ser el DEFAULT del pipeline: se sustituye `max_rows=300000, sample=True` por `sample_frac=None` (None = perfila todas las filas) + `max_rows=0` (tope duro opcional). El muestreo es opt-in explicito (`sample_frac`). Alinea con la preferencia estandar del usuario: los EDA se corren sobre el total salvo que se pida lo contrario. Hereda el fetch acelerado (Arrow/bqstorage) de `load_bq_table_to_duckdb` v1.1.0. - v1.1.0 (2026-07-01) — FULL pasa a ser el DEFAULT del pipeline: se sustituye `max_rows=300000, sample=True` por `sample_frac=None` (None = perfila todas las filas) + `max_rows=0` (tope duro opcional). El muestreo es opt-in explicito (`sample_frac`). Alinea con la preferencia estandar del usuario: los EDA se corren sobre el total salvo que se pida lo contrario. Hereda el fetch acelerado (Arrow/bqstorage) de `load_bq_table_to_duckdb` v1.1.0.
+26 -5
View File
@@ -42,6 +42,8 @@ def profile_bq_table(
report_dir: str = "reports", report_dir: str = "reports",
duckdb_path: str = "", duckdb_path: str = "",
keep_duckdb: bool = False, keep_duckdb: bool = False,
where_sql: str = "",
select_sql: str = "",
) -> dict: ) -> dict:
"""EDA one-shot de una tabla/vista BigQuery. """EDA one-shot de una tabla/vista BigQuery.
@@ -63,6 +65,13 @@ def profile_bq_table(
report_dir: Directorio de salida de los reports. report_dir: Directorio de salida de los reports.
duckdb_path: Ruta DuckDB a usar. Vacio = temporal autogestionado. duckdb_path: Ruta DuckDB a usar. Vacio = temporal autogestionado.
keep_duckdb: Si True conserva el DuckDB materializado. keep_duckdb: Si True conserva el DuckDB materializado.
where_sql: Clausula WHERE SQL (sin la palabra WHERE) aplicada al origen y a
su COUNT. Pass-through a `load_bq_table_to_duckdb`. Ej:
"fecha <= CURRENT_DATE() AND venta_n IS NOT NULL". Se interpola tal cual:
no usar con input no confiable.
select_sql: Expresiones del SELECT (sin la palabra SELECT); vacio = `*`.
Pass-through a `load_bq_table_to_duckdb`. Util para castear tipos
problematicos (p. ej. BIGNUMERIC->FLOAT64) antes de perfilar.
Returns: Returns:
dict dict-no-throw con el resultado del pipeline (ver output del .md). dict dict-no-throw con el resultado del pipeline (ver output del .md).
@@ -83,6 +92,8 @@ def profile_bq_table(
max_rows=max_rows, max_rows=max_rows,
project_id=project_id, project_id=project_id,
pseudonymize_cols=pseudonymize_cols, pseudonymize_cols=pseudonymize_cols,
where_sql=where_sql,
select_sql=select_sql,
) )
if load.get("status") != "ok": if load.get("status") != "ok":
return { return {
@@ -111,14 +122,24 @@ def profile_bq_table(
"load": load, "load": load,
} }
load_block = {
k: load[k]
for k in (
"n_rows_source", "n_rows_fetched", "sampled", "sample_frac",
"pseudonymized", "table", "streamed",
)
if k in load
}
# Trazabilidad de los filtros de origen (solo si vienen informados).
if where_sql:
load_block["where_sql"] = where_sql
if select_sql:
load_block["select_sql"] = select_sql
return { return {
"status": "ok", "status": "ok",
"table_fqn": table_fqn, "table_fqn": table_fqn,
"load": { "load": load_block,
k: load[k]
for k in ("n_rows_source", "n_rows_fetched", "sampled", "sample_frac", "pseudonymized", "table")
if k in load
},
"duckdb_path": duckdb_path if keep_duckdb else None, "duckdb_path": duckdb_path if keep_duckdb else None,
"report_md_path": prof.get("report_md_path"), "report_md_path": prof.get("report_md_path"),
"report_json_path": prof.get("report_json_path"), "report_json_path": prof.get("report_json_path"),
+10 -2
View File
@@ -4,8 +4,8 @@ kind: pipeline
lang: py lang: py
domain: pipelines domain: pipelines
purity: impure purity: impure
version: "1.0.0" version: "1.1.0"
signature: "def profile_database(db_path: str, tables: list = None, sample: int = 5000, report_dir: str = \"reports\", write_report: bool = True, min_inclusion: float = 0.9) -> dict" signature: "def profile_database(db_path: str, tables: list = None, sample: int = 5000, report_dir: str = \"reports\", write_report: bool = True, min_inclusion: float = 0.9, emit_pdf: bool = False, run_llm: bool = False) -> dict"
description: "Orquestador one-shot del grupo eda a nivel de BASE: perfila TODA una base DuckDB (todas las tablas o las indicadas) componiendo profile_table por tabla, infiere las relaciones FK inter-tabla por containment y construye el join graph con diagrama Mermaid. Ensambla un DatabaseProfile (resumen por tabla + TableProfiles completos + fk_candidates + join_graph) y opcionalmente emite un report markdown DB-level + JSON sidecar. Es la composicion canonica para hazme un EDA de esta base de datos y entender su esquema relacional." description: "Orquestador one-shot del grupo eda a nivel de BASE: perfila TODA una base DuckDB (todas las tablas o las indicadas) componiendo profile_table por tabla, infiere las relaciones FK inter-tabla por containment y construye el join graph con diagrama Mermaid. Ensambla un DatabaseProfile (resumen por tabla + TableProfiles completos + fk_candidates + join_graph) y opcionalmente emite un report markdown DB-level + JSON sidecar. Es la composicion canonica para hazme un EDA de esta base de datos y entender su esquema relacional."
tags: [eda, relations, duckdb, profiling, data-quality, pipeline, dataops] tags: [eda, relations, duckdb, profiling, data-quality, pipeline, dataops]
uses_functions: uses_functions:
@@ -38,6 +38,10 @@ params:
desc: "Si True (default) escribe report markdown DB-level + JSON sidecar timestamped en report_dir; si False no toca disco y los paths del retorno son None." desc: "Si True (default) escribe report markdown DB-level + JSON sidecar timestamped en report_dir; si False no toca disco y los paths del retorno son None."
- name: min_inclusion - name: min_inclusion
desc: "Umbral minimo de inclusion (0-1) para emitir una FK candidata (se pasa a infer_fk_containment_duckdb). Default 0.9." desc: "Umbral minimo de inclusion (0-1) para emitir una FK candidata (se pasa a infer_fk_containment_duckdb). Default 0.9."
- name: emit_pdf
desc: "Si True (default False) renderiza el PDF movil DB-level con render_eda_pdf_relational junto a los reports (report_pdf_path en el retorno)."
- name: run_llm
desc: "Si True (default False) activa la capa LLM interpretativa de profile_table para CADA tabla: una llamada LLM por tabla sobre el perfil agregado, nunca filas crudas."
output: "dict {status:'ok', db_profile:<DatabaseProfile con db_path, profiled_at, n_tables, tables[resumen], table_profiles[completos], fk_candidates, join_graph{nodes,edges,mermaid,hubs}, errors>, report_md_path:str|None, report_json_path:str|None} o {status:'error', error:str} (dict-no-throw)." output: "dict {status:'ok', db_profile:<DatabaseProfile con db_path, profiled_at, n_tables, tables[resumen], table_profiles[completos], fk_candidates, join_graph{nodes,edges,mermaid,hubs}, errors>, report_md_path:str|None, report_json_path:str|None} o {status:'error', error:str} (dict-no-throw)."
--- ---
@@ -101,3 +105,7 @@ se infieren las FK y se dibuja el diagrama de relaciones.
perfiladas con exito. Revisa `errors` para saber que quedo fuera. perfiladas con exito. Revisa `errors` para saber que quedo fuera.
- `db_path` debe existir: DuckDB read-only NO crea la base. El muestreo de cada - `db_path` debe existir: DuckDB read-only NO crea la base. El muestreo de cada
tabla usa el sandbox read-only por defecto (sin acceso a FS/red). tabla usa el sandbox read-only por defecto (sin acceso a FS/red).
## Capability growth log
- v1.1.0 (2026-07-02) — añade `run_llm` (passthrough a profile_table: capa LLM interpretativa por tabla) y documenta `emit_pdf` en el frontmatter (existía en el código desde el renderer relational). Sin breaking changes: ambos default False.
@@ -121,6 +121,7 @@ def profile_database(
write_report: bool = True, write_report: bool = True,
min_inclusion: float = 0.9, min_inclusion: float = 0.9,
emit_pdf: bool = False, emit_pdf: bool = False,
run_llm: bool = False,
) -> dict: ) -> dict:
"""Perfila una base DuckDB entera + sus relaciones inter-tabla. """Perfila una base DuckDB entera + sus relaciones inter-tabla.
@@ -141,6 +142,9 @@ def profile_database(
render_eda_pdf_relational (resumen de tablas + relaciones FK + join render_eda_pdf_relational (resumen de tablas + relaciones FK + join
graph) junto a los reports y devuelve su ruta en report_pdf_path. Con graph) junto a los reports y devuelve su ruta en report_pdf_path. Con
False no se toca el PDF (retrocompatible) y report_pdf_path es None. False no se toca el PDF (retrocompatible) y report_pdf_path es None.
run_llm: si True (default False) activa la capa LLM interpretativa de
profile_table para CADA tabla (una llamada LLM por tabla sobre el
perfil agregado, nunca filas crudas).
Returns: Returns:
dict dict-no-throw. En exito: dict dict-no-throw. En exito:
@@ -177,7 +181,9 @@ def profile_database(
# 2) Perfilar cada tabla (tolerando fallos individuales). # 2) Perfilar cada tabla (tolerando fallos individuales).
for table in tables: for table in tables:
r = profile_table(db_path, table, sample=sample, write_report=False) r = profile_table(
db_path, table, sample=sample, write_report=False, run_llm=run_llm
)
if r.get("status") == "ok": if r.get("status") == "ok":
prof = r["profile"] prof = r["profile"]
table_profiles.append(prof) table_profiles.append(prof)
@@ -0,0 +1,103 @@
---
name: run_sales_forecast
kind: pipeline
lang: py
domain: pipelines
purity: impure
version: "1.1.0"
signature: "def run_sales_forecast(as_of: str = '', horizon: int = 7, model: str = 'baseline_v1', author: str = 'egutierrez', dry_run: bool = False) -> dict"
description: "Forecast diario de ventas Aurgi (dia x centro x subcategoria CGQ) escrito en BigQuery autingo-159109.sales_forecast.predictions, en una sola llamada. Compone funciones del registry: bq_auth(drop_quota_project=True) para el cliente sin quota project ajeno, bq_query para leer la historia agregada del mart bi_ventas_mart.base_margenes_aa (18 semanas, venta_n saneado) y ejecutar el DELETE de idempotencia, forecast_seasonal_median (modelo PURO mediana estacional + tendencia acotada) para generar todas las predicciones, y bq_load_from_file para cargar el JSONL a la tabla de predicciones. Historia utilizable hasta as_of-1 (el dia as_of esta parcial cuando corre el cron a las 21:00); predice as_of+1..as_of+horizon; run_date=as_of. Solo predice series activas (venta>0 en las ultimas 8 semanas). Idempotente por (run_date, model, author). --dry-run no escribe."
tags: [forecast, bigquery, sales, aurgi, pipeline, launcher]
uses_functions:
- forecast_seasonal_median_py_datascience
- bq_auth_py_infra
- bq_query_py_infra
- bq_load_from_file_py_infra
uses_types: []
returns: []
returns_optional: false
error_type: error_go_core
imports: [google-cloud-bigquery]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/run_sales_forecast.py"
params:
- name: as_of
desc: "fecha de corte 'YYYY-MM-DD' (dia de la corrida). Vacio (DEFAULT) = hoy. La historia utilizable llega hasta as_of-1 dia (el dia as_of esta parcial en el cron 21:00); se predice as_of+1..as_of+horizon; run_date=as_of"
- name: horizon
desc: "numero de dias futuros a predecir a partir de as_of+1. Default 7"
- name: model
desc: "etiqueta del modelo escrita en la columna model de cada fila. Default 'baseline_v1'. Forma parte de la clave de idempotencia"
- name: author
desc: "autor de la corrida (columna author). Default 'egutierrez'. Forma parte de la clave de idempotencia"
- name: dry_run
desc: "si True no escribe en BigQuery (ni DELETE ni load): devuelve el resumen + una muestra de 5 filas. Default False"
output: "dict dict-no-throw. En exito {status:'ok', run_date, series:N (series activas), rows:N (filas predichas), model, author, rows_loaded, job_id}; con dry_run=True incluye sample:[5 filas] y omite rows_loaded/job_id. En error {status:'error', error, stage}. Por stdout imprime el JSON del resumen; exit 0 si ok, 1 si error"
---
## Ejemplo
```bash
# Corrida real (cron 21:00): predice los 7 dias siguientes a hoy y carga a BigQuery.
./fn run run_sales_forecast
# Fecha de corte y horizonte explicitos, sin escribir (revisar la muestra):
./fn run run_sales_forecast --as-of 2026-07-01 --horizon 7 --dry-run
# Modelo alternativo (clave de idempotencia distinta: no pisa baseline_v1):
./fn run run_sales_forecast --model baseline_v2 --author egutierrez
```
```python
# Uso programatico (venv del proyecto, PYTHONPATH=python/functions):
from pipelines.run_sales_forecast import run_sales_forecast
r = run_sales_forecast(as_of="2026-07-01", horizon=7, dry_run=True)
print(r["series"], "series activas,", r["rows"], "filas")
for row in r["sample"]:
print(row["forecast_date"], row["center_id"], row["subcat_cgq"], row["y_pred"])
```
## Cuando usarla
Cuando quieras (re)generar el forecast diario de ventas Aurgi por centro y
subcategoria CGQ y dejarlo en `autingo-159109.sales_forecast.predictions` en una
sola llamada. Es el pipeline que dispara el cron nocturno (21:00): lee la historia
del mart, aplica el baseline estacional, y carga las predicciones de forma
idempotente. Usa `--dry-run` para inspeccionar la muestra antes de escribir, o
para probar tras un cambio en el mart o en el modelo. Cambia `--model` para probar
una variante sin pisar las predicciones del modelo actual (la clave de
idempotencia es run_date + model + author).
## Gotchas
- Impura: requiere ADC de BigQuery configurado (`gcloud auth application-default
login`) con acceso a `autingo-159109`. Usa `bq_auth(drop_quota_project=True)`
para descartar el quota project del ADC del usuario `egutierrez` y evitar el
`403 USER_PROJECT_DENIED` (gotcha conocido del repo).
- Escribe en produccion: en modo real hace `DELETE` de las predicciones previas de
`(run_date, model, author)` y luego carga (WRITE_APPEND). Es idempotente para esa
combinacion: re-ejecutar la misma corrida no duplica. Cambiar `model` o `author`
crea un conjunto de predicciones paralelo. Usa `--dry-run` si solo quieres mirar.
- La tabla `sales_forecast.predictions` debe existir con schema fijo y con las
columnas exactas que emite el pipeline: `run_ts` (TIMESTAMP), `run_date` (DATE),
`forecast_date` (DATE), `lag_days` (INT64), `center_id` (STRING), `center_name`
(STRING), `ambito` (STRING), `subcat_cgq` (STRING), `model` (STRING), `author`
(STRING), `y_pred` (FLOAT64). El load usa `autodetect=False`: los nombres del
JSONL deben coincidir con los de la tabla o el load falla.
- `center_id` se emite como STRING (str(idCentro)); `subcat_cgq` toma el valor de
la columna `subcat_cqq` del mart (el nombre difiere entre origen y destino a
proposito). center_name/ambito son los ultimos conocidos por serie (fecha maxima).
- Historia hasta as_of-1: el dia `as_of` NO entra en la historia (esta parcial en
el cron de las 21:00). Si necesitas incluir el dia en curso, pasa `--as-of` con
el dia siguiente.
- Solo predice series con venta > 0 en las ultimas 8 semanas: las series muertas se
omiten (no aparecen en la tabla). `series` en la salida cuenta las activas.
- Guarda 18 semanas de historia del mart: cubre la ventana estacional (8 semanas
del mismo dia) mas la tendencia (4+4 semanas) con margen. venta_n se filtra
`ABS < 1e9` para descartar las filas veneno del mart.
## Capability growth log
- v1.1.0 (2026-07-02) — añade paso 9: refresh de `sales_forecast.actuals_daily` (tabla física de venta real, ventana móvil de 10 días) tras cargar las predicciones; `forecast_eval` y el dashboard de competición comparan contra ella.
@@ -0,0 +1,298 @@
"""run_sales_forecast — forecast diario de ventas Aurgi a BigQuery (one-shot).
Pipeline IMPURO que produce el forecast diario de ventas (dia x centro x
subcategoria CGQ) y lo escribe en `autingo-159109.sales_forecast.predictions`.
Compone funciones del registry sin reimplementar su logica:
- bq_auth(..., drop_quota_project=True): cliente BigQuery sin quota project ajeno
(evita el 403 USER_PROJECT_DENIED del ADC del usuario).
- bq_query: lee la historia agregada del mart `bi_ventas_mart.base_margenes_aa`
y ejecuta el DELETE de idempotencia (parametros tipados).
- forecast_seasonal_median: modelo PURO (mediana estacional + tendencia acotada)
que genera todas las predicciones de golpe.
- bq_load_from_file: carga las filas (JSONL) a la tabla de predicciones.
Cron previsto: 21:00. Por eso la historia utilizable llega hasta as_of - 1 dia
(el dia as_of aun esta parcial) y se predice as_of + 1 .. as_of + horizon.
Estilo dict-no-throw: nunca lanza; captura errores y devuelve
{status:'error', error, stage}. Idempotente por (run_date, model, author):
borra las predicciones previas de esa combinacion antes de cargar.
"""
import json
import os
import sys
import tempfile
from datetime import date, datetime, timedelta, timezone
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from bigquery import bq_auth, bq_query, bq_load_from_file
from datascience import forecast_seasonal_median
PROJECT_ID = "autingo-159109"
SOURCE_TABLE = "autingo-159109.bi_ventas_mart.base_margenes_aa"
DEST_DATASET = "sales_forecast"
DEST_TABLE = "predictions"
HISTORY_SQL = f"""
SELECT fecha, idCentro, subcat_cqq,
ANY_VALUE(NombreCentro) AS center_name, ANY_VALUE(Ambito) AS ambito,
SUM(CAST(venta_n AS FLOAT64)) AS venta
FROM `{SOURCE_TABLE}`
WHERE fecha BETWEEN DATE_SUB(@as_of, INTERVAL 18 WEEK) AND DATE_SUB(@as_of, INTERVAL 1 DAY)
AND venta_n IS NOT NULL AND ABS(CAST(venta_n AS FLOAT64)) < 1e9
AND subcat_cqq IS NOT NULL AND idCentro IS NOT NULL
GROUP BY fecha, idCentro, subcat_cqq
"""
DELETE_SQL = (
f"DELETE FROM `{PROJECT_ID}.{DEST_DATASET}.{DEST_TABLE}` "
"WHERE run_date = @d AND model = @m AND author = @a"
)
# Refresh de la tabla fisica de reales (sales_forecast.actuals_daily), consumida
# por la vista forecast_eval y por los dashboards de competicion. Ventana movil
# para recoger correcciones retroactivas del mart.
ACTUALS_DELETE_SQL = (
f"DELETE FROM `{PROJECT_ID}.{DEST_DATASET}.actuals_daily` "
"WHERE fecha BETWEEN DATE_SUB(@as_of, INTERVAL @w DAY) AND DATE_SUB(@as_of, INTERVAL 1 DAY)"
)
ACTUALS_INSERT_SQL = f"""
INSERT INTO `{PROJECT_ID}.{DEST_DATASET}.actuals_daily`
(fecha, center_id, center_name, ambito, subcat_cgq, y_real, unidades, loaded_ts)
SELECT forecast_date, IFNULL(center_id, 'SIN_CENTRO'), center_name, ambito,
IFNULL(subcat_cgq, 'Sin subcategoria'),
y_real, unidades, CURRENT_TIMESTAMP()
FROM `{PROJECT_ID}.{DEST_DATASET}.actuals`
WHERE forecast_date BETWEEN DATE_SUB(@as_of, INTERVAL @w DAY) AND DATE_SUB(@as_of, INTERVAL 1 DAY)
"""
def _refresh_actuals(client, as_of: date, window_days: int = 10) -> None:
"""Rehace los ultimos `window_days` dias de actuals_daily desde la vista actuals."""
params = [
{"name": "as_of", "type": "DATE", "value": as_of},
{"name": "w", "type": "INT64", "value": window_days},
]
bq_query(client, ACTUALS_DELETE_SQL, params=params)
bq_query(client, ACTUALS_INSERT_SQL, params=params)
def _as_date(value) -> date:
if isinstance(value, date) and not isinstance(value, datetime):
return value
if isinstance(value, datetime):
return value.date()
return datetime.strptime(str(value)[:10], "%Y-%m-%d").date()
def run_sales_forecast(
as_of: str = "",
horizon: int = 7,
model: str = "baseline_v1",
author: str = "egutierrez",
dry_run: bool = False,
) -> dict:
"""Genera el forecast diario de ventas y lo escribe en BigQuery.
Args:
as_of: fecha de corte 'YYYY-MM-DD' (dia de la corrida). Vacio = hoy. La
historia utilizable llega hasta as_of - 1 dia; se predice
as_of + 1 .. as_of + horizon. run_date = as_of.
horizon: numero de dias futuros a predecir. Default 7.
model: etiqueta del modelo escrita en cada fila (columna model). Default
'baseline_v1'.
author: autor de la corrida (columna author). Default 'egutierrez'.
dry_run: si True no escribe en BigQuery; devuelve el resumen + una muestra
de filas.
Returns:
dict dict-no-throw. En exito {status:'ok', run_date, series, rows, model,
author, (sample si dry_run)}. En error {status:'error', error, stage}.
"""
try:
run_d = _as_date(as_of) if as_of else date.today()
# Ultimo dia de historia utilizable (inclusive): as_of - 1 dia.
hist_as_of = run_d - timedelta(days=1)
horizon_dates = [
(run_d + timedelta(days=k)).isoformat() for k in range(1, horizon + 1)
]
# 1) Cliente BigQuery sin quota project (evita 403 USER_PROJECT_DENIED).
client = bq_auth(PROJECT_ID, drop_quota_project=True)
# 2) Historia agregada del mart (hasta run_d - 1 via el WHERE de la query).
q = bq_query(
client,
HISTORY_SQL,
params=[{"name": "as_of", "type": "DATE", "value": run_d}],
)
cols = {name: i for i, name in enumerate(q["columns"])}
# Historia por serie + ultimos center_name/ambito conocidos + venta 8 semanas.
history = []
last_meta = {} # series_id -> (max_date, center_name, ambito, center_id, subcat)
recent_sum = {} # series_id -> venta acumulada en las ultimas 8 semanas
active_cutoff = hist_as_of - timedelta(weeks=8)
for row in q["rows"]:
fecha = _as_date(row[cols["fecha"]])
center_id = str(row[cols["idCentro"]])
subcat = row[cols["subcat_cqq"]]
center_name = row[cols["center_name"]]
ambito = row[cols["ambito"]]
venta = float(row[cols["venta"]] or 0.0)
series_id = f"{center_id}|{subcat}"
history.append(
{"series_id": series_id, "date": fecha.isoformat(), "value": venta}
)
prev = last_meta.get(series_id)
if prev is None or fecha > prev[0]:
last_meta[series_id] = (fecha, center_name, ambito, center_id, subcat)
if fecha > active_cutoff:
recent_sum[series_id] = recent_sum.get(series_id, 0.0) + venta
# 3) Series activas: venta > 0 en las ultimas 8 semanas.
active = {sid for sid, s in recent_sum.items() if s > 0.0}
history = [h for h in history if h["series_id"] in active]
if not history:
result = {
"status": "ok",
"run_date": run_d.isoformat(),
"series": 0,
"rows": 0,
"model": model,
"author": author,
}
if dry_run:
result["sample"] = []
return result
# 4) Modelo puro: todas las predicciones de golpe.
preds = forecast_seasonal_median(
history, horizon_dates, as_of=hist_as_of.isoformat()
)
# 5) Filas para la tabla de predicciones.
run_ts = datetime.now(timezone.utc).isoformat()
rows_out = []
for p in preds:
sid = p["series_id"]
meta = last_meta.get(sid)
_, center_name, ambito, center_id, subcat = meta
forecast_date = _as_date(p["date"])
rows_out.append(
{
"run_ts": run_ts,
"run_date": run_d.isoformat(),
"forecast_date": forecast_date.isoformat(),
"lag_days": (forecast_date - run_d).days,
"center_id": center_id,
"center_name": center_name,
"ambito": ambito,
"subcat_cgq": subcat,
"model": model,
"author": author,
"y_pred": round(float(p["y_pred"]), 4),
}
)
summary = {
"status": "ok",
"run_date": run_d.isoformat(),
"series": len(active),
"rows": len(rows_out),
"model": model,
"author": author,
}
# 6) dry-run: no escribe; devuelve resumen + muestra.
if dry_run:
summary["sample"] = rows_out[:5]
return summary
# 7) Idempotencia: borra las predicciones previas de (run_date, model, author).
bq_query(
client,
DELETE_SQL,
params=[
{"name": "d", "type": "DATE", "value": run_d},
{"name": "m", "type": "STRING", "value": model},
{"name": "a", "type": "STRING", "value": author},
],
)
# 8) Carga JSONL a la tabla (WRITE_APPEND, schema fijo de la tabla).
tmp_path = None
try:
fd, tmp_path = tempfile.mkstemp(prefix="sales_forecast_", suffix=".jsonl")
with os.fdopen(fd, "w", encoding="utf-8") as fh:
for r in rows_out:
fh.write(json.dumps(r, ensure_ascii=False) + "\n")
load = bq_load_from_file(
client,
tmp_path,
DEST_DATASET,
DEST_TABLE,
source_format="NEWLINE_DELIMITED_JSON",
write_disposition="WRITE_APPEND",
autodetect=False,
)
finally:
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
if load.get("status") != "DONE":
return {
"status": "error",
"error": f"load job no termino DONE: {load}",
"stage": "load",
}
summary["rows_loaded"] = load.get("rows_loaded")
summary["job_id"] = load.get("job_id")
# 9) Refresca la tabla fisica de reales (ventana movil de 10 dias) para
# que forecast_eval y el dashboard de competicion comparen contra el
# ultimo estado del mart.
try:
_refresh_actuals(client, run_d)
summary["actuals_refreshed"] = True
except Exception as e: # noqa: BLE001
# No invalida las predicciones ya cargadas: se reporta y se sigue.
summary["actuals_refreshed"] = False
summary["actuals_error"] = str(e)
return summary
except Exception as e: # noqa: BLE001
return {"status": "error", "error": str(e), "stage": "unexpected"}
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Forecast diario de ventas Aurgi -> BigQuery sales_forecast.predictions."
)
parser.add_argument("--as-of", default="", help="Fecha de corte YYYY-MM-DD (vacio = hoy).")
parser.add_argument("--horizon", type=int, default=7, help="Dias a predecir. Default 7.")
parser.add_argument("--model", default="baseline_v1", help="Etiqueta del modelo.")
parser.add_argument("--author", default="egutierrez", help="Autor de la corrida.")
parser.add_argument(
"--dry-run", action="store_true", help="No escribe en BigQuery; imprime muestra."
)
args = parser.parse_args()
out = run_sales_forecast(
as_of=args.as_of,
horizon=args.horizon,
model=args.model,
author=args.author,
dry_run=args.dry_run,
)
print(json.dumps(out, ensure_ascii=False, default=str))
sys.exit(0 if out.get("status") == "ok" else 1)