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