fix(fn-run): propagar stdout/stderr de bash functions library-style #1

Open
dataforge wants to merge 537 commits from auto/0077-fn-run-bash-mudo into master
5 changed files with 531 additions and 0 deletions
Showing only changes of commit b4db4e4ef5 - Show all commits
+2
View File
@@ -13,6 +13,7 @@ from .setup import metabase_setup
from .maintenance import metabase_fix_null_ratio, metabase_pair_n_n1_columns
from .metabase_mbql_validate import metabase_mbql_validate
from .metabase_update_dashboard_safe import metabase_update_dashboard_safe
from .smartscalar import metabase_smartscalar_kpi_sql, metabase_smartscalar_dimension_tag, metabase_smartscalar_kpi_payload
__all__ = [
"MetabaseClient",
@@ -38,4 +39,5 @@ __all__ = [
"metabase_pair_n_n1_columns",
"metabase_mbql_validate",
"metabase_update_dashboard_safe",
"metabase_smartscalar_kpi_sql", "metabase_smartscalar_dimension_tag", "metabase_smartscalar_kpi_payload",
]
@@ -0,0 +1,74 @@
---
name: metabase_smartscalar_dimension_tag
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def metabase_smartscalar_dimension_tag(*, name: str, field_id: int, base_type: str = 'type/Text', widget_type: str = 'string/=', display_name: str = '') -> dict"
description: "Construye un template-tag de tipo dimension con la firma exacta que Metabase v0.59 espera (incluyendo lib/uuid, base-type, effective-type). Sin estos flags Metabase devuelve PUT 200 pero descarta silenciosamente el dataset_query."
tags: [metabase, smartscalar, template-tag, field-filter, kpi, pure, builder]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports:
- "import uuid"
params:
- name: name
desc: "Identificador snake_case del template-tag; tambien se usa en el SQL como [[AND {{name}}]]"
- name: field_id
desc: "ID numerico del Field de Metabase al que apunta el filtro; consultable via GET /api/field/<id> o el data model"
- name: base_type
desc: "Tipo base del Field. Valores comunes: type/Text, type/Date, type/Integer, type/Decimal, type/DateTime"
- name: widget_type
desc: "Widget de filtro en el dashboard. Comunes: string/= (multivalor texto), date/all-options, number/=, string/contains"
- name: display_name
desc: "Etiqueta legible mostrada en la UI; vacio = autogenerada title-casing del name"
output: "dict con la estructura {name, id, display-name, type, widget-type, dimension: ['field', {base-type, effective-type, lib/uuid}, field_id]} listo para usar como entrada del map template-tags de un dataset_query nativo"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/smartscalar.py"
---
## Por que existe
Metabase v0.59 valida silenciosamente la estructura de los ``template-tags``
en cards nativas: si falta ``lib/uuid``, ``base-type`` o ``effective-type``
en el ``dimension`` array, el ``PUT /api/card/:id`` devuelve 200 pero al
releer la card el ``dataset_query`` aparece vacio (``stages: [{}]``) y la
card no se puede ejecutar.
Este helper rellena la firma exacta y ademas genera el ``lib/uuid``
deterministicamente con ``uuid5`` sobre el ``name``, garantizando que la
funcion sea pura: dos llamadas con los mismos argumentos producen el mismo
dict bit a bit.
## Ejemplo
```python
tags = {
"fecha": metabase_smartscalar_dimension_tag(
name="fecha", field_id=322392,
base_type="type/Date", widget_type="date/all-options",
display_name="Fecha",
),
"categoria": metabase_smartscalar_dimension_tag(
name="categoria", field_id=270702,
widget_type="string/=",
),
}
# tags["fecha"]["dimension"] == ["field", {"base-type": "type/Date", ...}, 322392]
```
## Notas
- El ``id`` interno se rellena con ``f"{name}_tag"`` por consistencia con las
cards de la suite Lean.
- Para campos calculados (expresiones MBQL) o template-tags de tipo ``text``,
``snippet`` o ``card``, NO usar este helper — fueron disenados solo para
dimension/field-filters.
- Si la card se va a guardar una vez y editar a mano luego, los UUIDs
deterministicos no causan colisiones porque solo importan dentro de la card.
@@ -0,0 +1,96 @@
---
name: metabase_smartscalar_kpi_payload
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def metabase_smartscalar_kpi_payload(*, name: str, database_id: int, sql: str, template_tags: dict | None = None, description: str = '', collection_id: int = 0, currency: bool = False, currency_code: str = 'EUR', decimals: int = 0, comparison_label: str = 'vs n-1', extra_visualization_settings: dict | None = None) -> dict"
description: "Construye el payload completo para POST /api/card de un KPI smartscalar con formato y comparacion previousValue. Combina SQL nativo (idealmente generado por metabase_smartscalar_kpi_sql) con template-tags y visualization_settings predefinidos. Listo para metabase_create_card_raw."
tags: [metabase, smartscalar, kpi, card, payload, pure, builder]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: name
desc: "Nombre de la card mostrado en el dashboard"
- name: database_id
desc: "ID de la database de Metabase contra la que se ejecuta el SQL"
- name: sql
desc: "Query nativa que devuelve columnas periodo (DATE) y valor (numero) con 2 filas (n-1, actual). Generable con metabase_smartscalar_kpi_sql"
- name: template_tags
desc: "Dict {tag_name: tag_dict} para field-filters; None para card sin filtros. Construir con metabase_smartscalar_dimension_tag"
- name: description
desc: "Descripcion mostrada en la card; vacio = autorrellenada"
- name: collection_id
desc: "ID de la coleccion destino; 0 = Our analytics (root)"
- name: currency
desc: "Si True formatea valor como moneda con simbolo currency_code"
- name: currency_code
desc: "Codigo ISO de moneda. Default EUR. Solo aplica si currency=True"
- name: decimals
desc: "Decimales mostrados en valor"
- name: comparison_label
desc: "Etiqueta del bloque de comparacion bajo el numero principal. Default 'vs n-1'"
- name: extra_visualization_settings
desc: "Settings adicionales fusionados (top-level override) en visualization_settings; util para anadir scalar.title, card.title.alignment, etc."
output: "dict con payload completo (name, description, type=question, display=smartscalar, dataset_query nativo, visualization_settings con scalar.field/scalar.comparisons/column_settings, [collection_id]) directamente posteable a /api/card"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/smartscalar.py"
---
## Por que existe
Encapsular la combinacion exacta de campos que Metabase v0.59 necesita para
un KPI ``smartscalar`` con comparacion ``previousValue``: ``scalar.field``,
``scalar.comparisons`` con ``id``, ``type`` y ``label``, y ``column_settings``
con la clave de columna serializada como JSON ``'["name","valor"]'``.
Cualquier desviacion (clave de column_settings sin escapar, ausencia de
``scalar.field``, ``type`` distinto de ``previousValue/anotherColumn/staticNumber``)
hace que el frontend muestre el numero pero descarta silenciosamente la
comparacion o pide breakout.
## Ejemplo
```python
sql = metabase_smartscalar_kpi_sql(
act_expr="ROUND(SUM(v.venta_n), 2)",
n1_expr="ROUND(SUM(v.venta_n1), 2)",
body_sql=ventas_cte_sql,
date_expr="MIN(v.fecha)",
)
tags = {
"fecha": metabase_smartscalar_dimension_tag(
name="fecha", field_id=322392,
base_type="type/Date", widget_type="date/all-options",
),
}
payload = metabase_smartscalar_kpi_payload(
name="Venta total",
database_id=6,
sql=sql,
template_tags=tags,
currency=True, decimals=0,
description="Venta del periodo seleccionado vs ano anterior.",
collection_id=583,
)
card = metabase_create_card_raw(client, payload)
print(card["id"])
```
## Notas
- ``visualization_settings.column_settings`` usa la clave con formato JSON
exacto ``'["name","valor"]'`` (sin espacios entre comillas y coma) — Metabase
no normaliza variaciones.
- Si necesitas la card como ``model`` o ``metric`` en lugar de ``question``,
override via ``extra_visualization_settings`` no aplica — clona el resultado
y modifica ``payload["type"]`` antes de enviar.
- Para reemplazar una card existente usa ``metabase_update_card`` con los
mismos campos del payload (display, dataset_query, visualization_settings).
@@ -0,0 +1,77 @@
---
name: metabase_smartscalar_kpi_sql
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def metabase_smartscalar_kpi_sql(*, act_expr: str, n1_expr: str, body_sql: str, date_expr: str = 'MIN(fecha)') -> str"
description: "Envuelve agregaciones actual+n-1 en el patron de 2 filas (periodo, valor) que el display smartscalar de Metabase v0.59 requiere para mostrar comparacion vs ano anterior sin pedir breakout temporal. Genera SQL nativo BigQuery con UNION ALL d_min/d_min-52w."
tags: [metabase, smartscalar, kpi, sql, bigquery, pure, builder]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: act_expr
desc: "Expresion SQL de agregado para el periodo actual, ej. 'ROUND(SUM(v.venta_n), 2)' o 'SAFE_DIVIDE(SUM(a), NULLIF(SUM(b),0))'"
- name: n1_expr
desc: "Expresion SQL para el mismo agregado del ano anterior, ej. 'ROUND(SUM(v.venta_n1), 2)'"
- name: body_sql
desc: "Cuerpo SQL desde 'FROM' que define las tablas/CTEs y filtros con template-tags Metabase ([[AND {{tag}}]])"
- name: date_expr
desc: "Expresion para extraer la fecha minima del periodo; usada como periodo en la fila actual y ancla para DATE_SUB de la fila n-1. Default 'MIN(fecha)'"
output: "string con SQL nativo BigQuery que devuelve 2 filas con columnas periodo (DATE) y valor (numero); fila 1 = periodo n-1, fila 2 = periodo actual"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/smartscalar.py"
---
## Por que existe
El display ``smartscalar`` de Metabase v0.59.4 con ``previousValue`` requiere
una serie temporal para mostrar comparacion. Si el query devuelve un solo
numero el frontend muestra el error "Agrupa solo por un campo de tiempo para
ver como ha cambiado con el tiempo".
El truco probado en el dashboard ``Informe Lean`` (cards 9340-9373) es
producir 2 filas sinteticas: una para el periodo actual (``d_min``, ``act``)
y otra para n-1 (``d_min - 52 weeks``, ``n1``). Smartscalar lo interpreta como
serie de 2 puntos y compara naturalmente.
Esta funcion automatiza ese patron: el caller solo provee el cuerpo SQL con
sus filtros y las dos expresiones de agregado.
## Ejemplo
```python
body = """
FROM `proj.ds.base_margenes_aa` `proj.ds.base_margenes_aa` v
LEFT JOIN `proj.ds.Objeto_productos` p ON p.nav_id = v.prod_nav_id
WHERE 1=1
[[AND {{fecha}}]]
[[AND {{categoria}}]]
[[AND {{tipo}}]]
"""
sql = metabase_smartscalar_kpi_sql(
act_expr="ROUND(SUM(v.venta_n), 2)",
n1_expr="ROUND(SUM(v.venta_n1), 2)",
body_sql=body,
date_expr="MIN(v.fecha)",
)
# sql tiene UNION ALL con periodo y valor
```
## Notas
- Por convencion los template-tags estan envueltos en ``[[ ]]`` para que sean
opcionales: si el dashboard no aplica el filtro la clausula desaparece.
- Para que los field-filters de Metabase resuelvan correctamente las tablas
dentro de CTEs, alias cada tabla con la cadena ``schema.table`` exacta que
Metabase usa al expandir el template-tag (ej. ``\`proj.ds.tabla\` \`ds.tabla\``).
- El SQL generado usa ``DATE_SUB(d_min, INTERVAL 52 WEEK)`` (BigQuery dialect).
Para Postgres adaptar a ``d_min - INTERVAL '52 weeks'``.
+282
View File
@@ -0,0 +1,282 @@
"""Helpers puros para construir KPIs con display=smartscalar en Metabase.
El display ``smartscalar`` de Metabase v0.59 muestra un numero grande con
comparacion (% variacion + flecha verde/roja) cuando el query devuelve una serie
temporal y ``scalar.comparisons`` esta configurado. Para mostrar "total del
periodo seleccionado vs mismo periodo del ano anterior" sin que el motor pida
un breakout temporal, el truco probado en el dashboard ``Informe Lean`` es
producir 2 filas: ``(d_min - 52 semanas, n-1_total)`` y ``(d_min, total_actual)``,
con columnas ``periodo`` y ``valor``. Smartscalar lo interpreta como serie de
2 puntos y compara natural con ``previousValue``.
"""
from __future__ import annotations
import uuid
_TAG_NAMESPACE = uuid.UUID("c0ffee00-6e75-4b14-9e8e-bea7ed5ca1ab")
def metabase_smartscalar_kpi_sql(
*,
act_expr: str,
n1_expr: str,
body_sql: str,
date_expr: str = "MIN(fecha)",
) -> str:
"""Envuelve una agregacion en el patron de 2 filas que smartscalar requiere.
Recibe las expresiones de agregado para periodo actual y n-1, mas el cuerpo
SQL (CTEs + JOINs + WHERE con template-tags) que produce la tabla a agregar,
y devuelve la query nativa completa lista para incrustar en una card. La
salida tiene siempre el mismo shape: 2 filas con columnas ``periodo``
(DATE) y ``valor`` (numero).
Args:
act_expr: Expresion SQL para el total del periodo actual. Tipicamente
``SUM(...)`` o ``SAFE_DIVIDE(SUM(a), NULLIF(SUM(b), 0))``. Debe poder
evaluarse contra el alias del cuerpo (por ejemplo ``v.venta_n``).
n1_expr: Equivalente para el mismo periodo del ano anterior.
body_sql: Cuerpo SQL que define las tablas/CTEs y termina con un
``FROM ... [JOIN ...] [WHERE ...]`` referenciable como ``ventas v``,
o equivalente. Ejemplo en docstring de salida.
date_expr: Expresion para extraer la fecha minima del periodo, usada
como ``periodo`` en la fila actual y como ancla para calcular la
fecha n-1 con ``DATE_SUB(..., INTERVAL 52 WEEK)``. Default
``MIN(fecha)``. Para queries con alias usar ``MIN(v.fecha)``.
Returns:
SQL nativo (BigQuery dialect por defecto) con la estructura:
WITH agg AS (
SELECT COALESCE({date_expr}, CURRENT_DATE()) AS d_min,
{act_expr} AS act,
{n1_expr} AS n1
{body_sql}
)
SELECT DATE_SUB(d_min, INTERVAL 52 WEEK) AS periodo, n1 AS valor FROM agg
UNION ALL
SELECT d_min, act FROM agg
ORDER BY periodo
Example:
>>> body = '''
... FROM `proj.ds.base_margenes_aa` v
... WHERE 1=1 [[AND {{fecha}}]] [[AND {{tipo}}]]'''
>>> sql = metabase_smartscalar_kpi_sql(
... act_expr="ROUND(SUM(v.venta_n), 2)",
... n1_expr="ROUND(SUM(v.venta_n1), 2)",
... body_sql=body,
... date_expr="MIN(v.fecha)",
... )
>>> assert "UNION ALL" in sql
>>> assert "periodo" in sql and "valor" in sql
"""
inner_select = (
f" SELECT\n"
f" COALESCE({date_expr}, CURRENT_DATE()) AS d_min,\n"
f" {act_expr} AS act,\n"
f" {n1_expr} AS n1\n"
f" {body_sql.strip()}"
)
return (
"WITH agg AS (\n"
f"{inner_select}\n"
")\n"
"SELECT DATE_SUB(d_min, INTERVAL 52 WEEK) AS periodo, n1 AS valor FROM agg\n"
"UNION ALL\n"
"SELECT d_min, act FROM agg\n"
"ORDER BY periodo"
)
def metabase_smartscalar_dimension_tag(
*,
name: str,
field_id: int,
base_type: str = "type/Text",
widget_type: str = "string/=",
display_name: str = "",
) -> dict:
"""Construye un template-tag de tipo ``dimension`` listo para Metabase v0.59.
Las cards nativas con filtros (field-filters) requieren un dict por
template-tag con la firma exacta que espera el frontend MBQL5. Esta funcion
rellena ``id``, ``display-name``, ``type``, ``widget-type`` y ``dimension``
con los flags ``base-type``, ``effective-type`` y ``lib/uuid`` que sin los
cuales Metabase silenciosamente descarta el query al guardar (PUT 200 sin
persistir el ``dataset_query``).
El ``lib/uuid`` se deriva deterministicamente del nombre del tag con
``uuid5`` para que la funcion sea pura: dos llamadas con el mismo ``name``
producen el mismo UUID. Esto es seguro porque ``lib/uuid`` solo necesita ser
unico dentro de la misma card.
Args:
name: Identificador del template-tag (snake_case). Tambien sirve como
nombre del filtro en el SQL: ``[[AND {{name}}]]``.
field_id: ID numerico del Field de Metabase al que apunta el filtro.
Se obtiene con ``GET /api/field/<id>`` o navegando el data model.
base_type: Tipo base del Field (ej. ``type/Text``, ``type/Date``,
``type/Integer``, ``type/Decimal``).
widget_type: Tipo de widget en el dashboard. Comunes:
``string/=`` (multivalor texto), ``date/all-options`` (fecha),
``number/=``, ``string/contains``.
display_name: Etiqueta legible mostrada en la UI cuando alguien
ejecuta la card directamente. Vacio = se autogenera title-casing
del ``name``.
Returns:
Dict con la estructura ``{"name", "id", "display-name", "type",
"widget-type", "dimension": ["field", {...}, field_id]}``.
Example:
>>> tag = metabase_smartscalar_dimension_tag(
... name="fecha", field_id=322392,
... base_type="type/Date", widget_type="date/all-options",
... display_name="Fecha",
... )
>>> assert tag["dimension"][2] == 322392
>>> assert tag["widget-type"] == "date/all-options"
>>> # UUID determinista: misma llamada produce mismo dict
>>> assert tag == metabase_smartscalar_dimension_tag(
... name="fecha", field_id=322392,
... base_type="type/Date", widget_type="date/all-options",
... display_name="Fecha",
... )
"""
label = display_name or name.replace("_", " ").title()
tag_uuid = str(uuid.uuid5(_TAG_NAMESPACE, name))
return {
"name": name,
"id": f"{name}_tag",
"display-name": label,
"type": "dimension",
"widget-type": widget_type,
"dimension": [
"field",
{
"base-type": base_type,
"effective-type": base_type,
"lib/uuid": tag_uuid,
},
field_id,
],
}
def metabase_smartscalar_kpi_payload(
*,
name: str,
database_id: int,
sql: str,
template_tags: dict | None = None,
description: str = "",
collection_id: int = 0,
currency: bool = False,
currency_code: str = "EUR",
decimals: int = 0,
comparison_label: str = "vs n-1",
extra_visualization_settings: dict | None = None,
) -> dict:
"""Construye el payload para POST /api/card de un KPI smartscalar.
Combina el SQL nativo (idealmente generado por
``metabase_smartscalar_kpi_sql``) con la configuracion de visualizacion
smartscalar que muestra ``valor`` con comparacion ``previousValue`` y
formato numero/divisa. El payload resultante puede pasarse directamente
a ``metabase_create_card_raw``.
Args:
name: Nombre de la card.
database_id: ID de la database de Metabase contra la que se ejecuta.
sql: Query nativa que devuelve columnas ``periodo`` (DATE) y ``valor``
(numero) con 2 filas (n-1, actual). Generable con
``metabase_smartscalar_kpi_sql``.
template_tags: Dict de template-tags para field-filters
(``{tag_name: tag_dict}``). Construir con
``metabase_smartscalar_dimension_tag``. None = sin filtros.
description: Descripcion mostrada en la card. Vacio se autorrellena.
collection_id: ID de la coleccion destino. 0 = root.
currency: Si True formatea ``valor`` como moneda con ``currency_code``.
currency_code: Codigo ISO de moneda. Default EUR.
decimals: Decimales mostrados en ``valor``.
comparison_label: Etiqueta del bloque de comparacion. Default "vs n-1".
extra_visualization_settings: Settings adicionales a fusionar (top-level
override) en ``visualization_settings``. Util para anadir
``scalar.title``, ``card.title.alignment``, etc.
Returns:
Dict con estructura:
{
"name": ..., "description": ..., "type": "question",
"display": "smartscalar",
"dataset_query": {"database": ..., "type": "native",
"native": {"query": ..., "template-tags": ...}},
"visualization_settings": {
"scalar.field": "valor",
"scalar.comparisons": [
{"id": "cmp_n1", "type": "previousValue", "label": ...}],
"column_settings": {'["name","valor"]': {...formato...}}
},
["collection_id"]: ...
}
Example:
>>> sql = metabase_smartscalar_kpi_sql(
... act_expr="ROUND(SUM(v.venta_n), 2)",
... n1_expr="ROUND(SUM(v.venta_n1), 2)",
... body_sql="FROM ventas v",
... date_expr="MIN(v.fecha)",
... )
>>> tags = {"fecha": metabase_smartscalar_dimension_tag(
... name="fecha", field_id=322392,
... base_type="type/Date", widget_type="date/all-options",
... )}
>>> payload = metabase_smartscalar_kpi_payload(
... name="Venta total", database_id=6, sql=sql,
... template_tags=tags, currency=True, decimals=0,
... )
>>> assert payload["display"] == "smartscalar"
>>> assert payload["visualization_settings"]["scalar.field"] == "valor"
"""
fmt: dict = {"decimals": decimals}
if currency:
fmt.update(
{
"number_style": "currency",
"currency": currency_code,
"currency_in_header": False,
}
)
viz: dict = {
"scalar.field": "valor",
"scalar.comparisons": [
{"id": "cmp_n1", "type": "previousValue", "label": comparison_label}
],
"column_settings": {'["name","valor"]': fmt},
}
if extra_visualization_settings:
viz.update(extra_visualization_settings)
payload: dict = {
"name": name,
"description": description
or f"KPI {name} con comparacion vs mismo periodo del ano anterior.",
"type": "question",
"display": "smartscalar",
"dataset_query": {
"database": database_id,
"type": "native",
"native": {
"query": sql,
"template-tags": template_tags or {},
},
},
"visualization_settings": viz,
}
if collection_id > 0:
payload["collection_id"] = collection_id
return payload