# functions

id: embedding_load_model_py_infra
name: embedding_load_model
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def embedding_load_model(path: str) -> SentenceTransformer
description: Carga modelo de embeddings desde path local. Retorna instancia lista para encode.
tags: ["embedding","model","load","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["sentence_transformers"]
tests: []
file_path: python/functions/embedding/model.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: c54b9c97b3eb859590e5773787425729c3210c23bb13d4b0e66f85c3df7eab1e
example:
  ```python
  model = embedding_load_model(".local/models/e5-small")
  # model listo para usar con embedding_encode
  ```

notes:
  Carga desde path local (~1.8s) es mas rapida que desde HF cache (~4.1s).
  El modelo retornado es compatible con embedding_encode.

code:
  """Embedding model management — save, load, and encode with multilingual-e5-small."""

  import os

  from sentence_transformers import SentenceTransformer


  def embedding_save_model(model_id: str, path: str) -> str:
      """Descarga modelo de HuggingFace y lo guarda en path local.

      Args:
          model_id: ID del modelo en HuggingFace (ej: "intfloat/multilingual-e5-small").
          path: Directorio destino para guardar el modelo.

      Returns:
          Path absoluto donde se guardo el modelo.

      Raises:
          OSError: Si no se puede escribir en el path.
          Exception: Si el modelo no existe en HuggingFace.
      """
      os.makedirs(path, exist_ok=True)
      model = SentenceTransformer(model_id)
      model.save(path)
      return os.path.abspath(path)


  def embedding_load_model(path: str) -> SentenceTransformer:
      """Carga modelo de embeddings desde path local.

      Args:
          path: Directorio con el modelo guardado por embedding_save_model.

      Returns:
          Instancia de SentenceTransformer lista para encode.

      Raises:
          OSError: Si el path no existe o no contiene un modelo valido.
      """
      return SentenceTransformer(path)


  def embedding_encode(model: SentenceTransformer, texts: list, mode: str = "document") -> list:
      """Genera embeddings normalizados para una lista de textos.

      Aplica automaticamente los prefijos requeridos por modelos e5:
      - mode="document" -> "passage: " prefix
      - mode="query"    -> "query: " prefix

      Args:
          model: Modelo cargado con embedding_load_model.
          texts: Lista de strings a codificar.
          mode: "document" para indexar, "query" para buscar.

      Returns:
          Lista de arrays numpy float32 normalizados (dim depende del modelo).

      Raises:
          ValueError: Si mode no es "document" ni "query".
      """
      if mode not in ("document", "query"):
          raise ValueError(f"mode must be 'document' or 'query', got '{mode}'")

      prefix = "passage: " if mode == "document" else "query: "
      prefixed = [f"{prefix}{t}" for t in texts]
      embeddings = model.encode(prefixed, normalize_embeddings=True, show_progress_bar=False)
      return embeddings

params_schema:
  {"params":[{"name":"path","desc":"ruta al directorio donde está guardado el modelo (ej: .local/models/e5-small)"}],"output":"SentenceTransformer: instancia lista para embedding_encode"}


id: embedding_save_model_py_infra
name: embedding_save_model
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def embedding_save_model(model_id: str, path: str) -> str
description: Descarga modelo de embeddings de HuggingFace y lo guarda en path local para carga rapida sin red.
tags: ["embedding","model","save","huggingface","e5","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["sentence_transformers"]
tests: []
file_path: python/functions/embedding/model.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: 2bbf7c530bf0487da8b888825e2458e364a4665e95ffdc128a9342653c364830
example:
  ```python
  path = embedding_save_model("intfloat/multilingual-e5-small", ".local/models/e5-small")
  # path = "/home/lucas/fn_registry/.local/models/e5-small"
  ```

notes:
  El modelo se guarda en formato sentence-transformers (safetensors + tokenizer).
  Para multilingual-e5-small ocupa ~465 MB en disco.
  Carga local es ~2.3x mas rapida que desde HF cache.

code:
  """Embedding model management — save, load, and encode with multilingual-e5-small."""

  import os

  from sentence_transformers import SentenceTransformer


  def embedding_save_model(model_id: str, path: str) -> str:
      """Descarga modelo de HuggingFace y lo guarda en path local.

      Args:
          model_id: ID del modelo en HuggingFace (ej: "intfloat/multilingual-e5-small").
          path: Directorio destino para guardar el modelo.

      Returns:
          Path absoluto donde se guardo el modelo.

      Raises:
          OSError: Si no se puede escribir en el path.
          Exception: Si el modelo no existe en HuggingFace.
      """
      os.makedirs(path, exist_ok=True)
      model = SentenceTransformer(model_id)
      model.save(path)
      return os.path.abspath(path)


  def embedding_load_model(path: str) -> SentenceTransformer:
      """Carga modelo de embeddings desde path local.

      Args:
          path: Directorio con el modelo guardado por embedding_save_model.

      Returns:
          Instancia de SentenceTransformer lista para encode.

      Raises:
          OSError: Si el path no existe o no contiene un modelo valido.
      """
      return SentenceTransformer(path)


  def embedding_encode(model: SentenceTransformer, texts: list, mode: str = "document") -> list:
      """Genera embeddings normalizados para una lista de textos.

      Aplica automaticamente los prefijos requeridos por modelos e5:
      - mode="document" -> "passage: " prefix
      - mode="query"    -> "query: " prefix

      Args:
          model: Modelo cargado con embedding_load_model.
          texts: Lista de strings a codificar.
          mode: "document" para indexar, "query" para buscar.

      Returns:
          Lista de arrays numpy float32 normalizados (dim depende del modelo).

      Raises:
          ValueError: Si mode no es "document" ni "query".
      """
      if mode not in ("document", "query"):
          raise ValueError(f"mode must be 'document' or 'query', got '{mode}'")

      prefix = "passage: " if mode == "document" else "query: "
      prefixed = [f"{prefix}{t}" for t in texts]
      embeddings = model.encode(prefixed, normalize_embeddings=True, show_progress_bar=False)
      return embeddings

params_schema:
  {"params":[{"name":"model_id","desc":"identificador de modelo en HuggingFace (ej: intfloat/multilingual-e5-small)"},{"name":"path","desc":"ruta local donde guardar el modelo descargado"}],"output":"string: ruta absoluta donde se guardó el modelo"}


id: embedding_search_sqlvec_py_infra
name: embedding_search_sqlvec
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def embedding_search_sqlvec(db_path: str, table: str, query_embedding: list, k: int = 10) -> list
description: Busca los k vecinos mas cercanos en tabla sqlite-vec. Retorna rowids y distancias ordenados.
tags: ["embedding","sqlite","vector","search","retrieval","sqlite-vec","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["sqlite3","sqlite_vec","numpy"]
tests: []
file_path: python/functions/embedding/sqlvec.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: a0d3793f46ce1fed772d16b44e69df12d90c5fd0624510577eccda10e18ab6f5
example:
  ```python
  model = embedding_load_model(".local/models/e5-small")
  q_emb = embedding_encode(model, ["¿Que es machine learning?"], mode="query")[0]

  results = embedding_search_sqlvec("vectors.db", "doc_embeddings", q_emb, k=5)
  # [{"rowid": 0, "distance": 0.23}, {"rowid": 1, "distance": 0.45}, ...]
  ```

notes:
  Busqueda brute-force (exacta, no aproximada). Para 50k vectores tarda ~19ms/query.
  El campo distance es distancia coseno (menor = mas similar) porque los embeddings estan normalizados.
  Cold start rapido (~18ms) porque SQLite no carga todo el indice a RAM.

code:
  """Vector storage and retrieval with sqlite-vec."""

  import sqlite3

  import numpy as np
  import sqlite_vec


  def embedding_store_sqlvec(db_path: str, table: str, ids: list, embeddings: list, dim: int = 384) -> int:
      """Inserta embeddings en una tabla sqlite-vec.

      Crea la tabla virtual si no existe. Inserta en batches de 500.

      Args:
          db_path: Path al archivo SQLite.
          table: Nombre de la tabla virtual vec0.
          ids: Lista de IDs (int) para cada embedding.
          embeddings: Lista de arrays numpy float32.
          dim: Dimension de los embeddings (default 384 para e5-small).

      Returns:
          Numero de embeddings insertados.

      Raises:
          sqlite3.Error: Si hay error de escritura en la BD.
      """
      db = sqlite3.connect(db_path)
      db.enable_load_extension(True)
      sqlite_vec.load(db)

      db.execute(f"CREATE VIRTUAL TABLE IF NOT EXISTS [{table}] USING vec0(embedding float[{dim}])")

      batch_size = 500
      count = 0
      for i in range(0, len(ids), batch_size):
          batch = [
              (int(ids[j]), np.asarray(embeddings[j], dtype=np.float32).tobytes())
              for j in range(i, min(i + batch_size, len(ids)))
          ]
          db.executemany(f"INSERT INTO [{table}](rowid, embedding) VALUES (?, ?)", batch)
          count += len(batch)

      db.commit()
      db.close()
      return count


  def embedding_search_sqlvec(db_path: str, table: str, query_embedding: list, k: int = 10) -> list:
      """Busca los k vecinos mas cercanos en una tabla sqlite-vec.

      Args:
          db_path: Path al archivo SQLite con la tabla vec0.
          table: Nombre de la tabla virtual.
          query_embedding: Array numpy float32 del query.
          k: Numero de resultados a retornar.

      Returns:
          Lista de dicts con 'rowid' y 'distance' ordenados por cercania.

      Raises:
          sqlite3.Error: Si la tabla no existe o hay error de lectura.
      """
      db = sqlite3.connect(db_path)
      db.enable_load_extension(True)
      sqlite_vec.load(db)

      q_bytes = np.asarray(query_embedding, dtype=np.float32).tobytes()
      rows = db.execute(
          f"SELECT rowid, distance FROM [{table}] WHERE embedding MATCH ? ORDER BY distance LIMIT ?",
          (q_bytes, k),
      ).fetchall()

      db.close()
      return [{"rowid": r[0], "distance": r[1]} for r in rows]

params_schema:
  {"params":[{"name":"db_path","desc":"ruta a la base de datos SQLite con tabla virtual sqlite-vec"},{"name":"table","desc":"nombre de la tabla virtual donde buscar vectores"},{"name":"query_embedding","desc":"vector de embedding (list[float]) a usar como query"},{"name":"k","desc":"cantidad de vecinos más cercanos a retornar"}],"output":"list[dict]: resultados ordenados con 'rowid' e 'distance' (coseno, menor=más similar)"}


id: embedding_search_usearch_py_infra
name: embedding_search_usearch
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def embedding_search_usearch(path: str, query_embedding: list, k: int = 10, dim: int = 384) -> list
description: Busca los k vecinos mas cercanos en indice USearch persistido. Busqueda sub-milisegundo.
tags: ["embedding","usearch","vector","search","retrieval","ann","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["usearch","numpy"]
tests: []
file_path: python/functions/embedding/usearch_store.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: 476b72b106271583a7850c193fa94b40caef7849d7a1b8734c10bb4efdcd7a8f
example:
  ```python
  model = embedding_load_model(".local/models/e5-small")
  q_emb = embedding_encode(model, ["¿Que es machine learning?"], mode="query")[0]

  results = embedding_search_usearch("docs.usearch", q_emb, k=5)
  # [{"key": 0, "distance": 0.82}, {"key": 1, "distance": 0.65}, ...]
  ```

notes:
  Carga el indice completo a RAM antes de buscar. Cold start ~190ms para 50k vectores.
  Busqueda aproximada (HNSW) — puede no encontrar el vecino exacto pero es 150x mas rapido que brute-force.
  Distance es inner product (mayor = mas similar, al reves que sqlite-vec).

code:
  """Vector storage and retrieval with USearch."""

  import numpy as np
  from usearch.index import Index


  def embedding_store_usearch(path: str, ids: list, embeddings: list, dim: int = 384) -> int:
      """Crea o sobreescribe un indice USearch con los embeddings dados.

      Args:
          path: Path del archivo .usearch para persistir el indice.
          ids: Lista de IDs (int) para cada embedding.
          embeddings: Lista de arrays numpy float32.
          dim: Dimension de los embeddings (default 384 para e5-small).

      Returns:
          Numero de embeddings insertados.

      Raises:
          OSError: Si no se puede escribir en el path.
      """
      index = Index(ndim=dim, metric="ip", dtype="f32")
      keys = np.array(ids, dtype=np.uint64)
      vecs = np.array(embeddings, dtype=np.float32)
      index.add(keys, vecs)
      index.save(path)
      return len(ids)


  def embedding_search_usearch(path: str, query_embedding: list, k: int = 10, dim: int = 384) -> list:
      """Busca los k vecinos mas cercanos en un indice USearch persistido.

      Args:
          path: Path del archivo .usearch.
          query_embedding: Array numpy float32 del query.
          k: Numero de resultados a retornar.
          dim: Dimension de los embeddings.

      Returns:
          Lista de dicts con 'key' y 'distance' ordenados por cercania.

      Raises:
          OSError: Si el archivo no existe.
      """
      index = Index(ndim=dim, metric="ip", dtype="f32")
      index.load(path)
      q = np.asarray(query_embedding, dtype=np.float32)
      results = index.search(q, k)
      keys = np.atleast_1d(results.keys)
      distances = np.atleast_1d(results.distances)
      return [{"key": int(keys[i]), "distance": float(distances[i])} for i in range(len(keys))]

params_schema:
  {"params":[{"name":"path","desc":"ruta al archivo de índice USearch persistido"},{"name":"query_embedding","desc":"vector de embedding (list[float]) a usar como query"},{"name":"k","desc":"cantidad de vecinos aproximados a retornar"},{"name":"dim","desc":"dimensión del espacio de embeddings (por defecto 384 para e5-small)"}],"output":"list[dict]: resultados con 'key' y 'distance' (inner product, mayor=más similar)"}


id: embedding_store_sqlvec_py_infra
name: embedding_store_sqlvec
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
description: Inserta embeddings en tabla sqlite-vec. Crea la tabla virtual si no existe. Insercion en batches.
tags: ["embedding","sqlite","vector","store","sqlite-vec","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["sqlite3","sqlite_vec","numpy"]
tests: []
file_path: python/functions/embedding/sqlvec.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: 390090a73220fbe6887db3849af27f913a6a7039677cfc9b39357ff2f7b75701
signature:
  def embedding_store_sqlvec(db_path: str, table: str, ids: list, embeddings: list, dim: int = 384) -> int

example:
  ```python
  model = embedding_load_model(".local/models/e5-small")
  docs = ["La IA transforma la industria", "Python es versatil"]
  embs = embedding_encode(model, docs, mode="document")

  n = embedding_store_sqlvec("vectors.db", "doc_embeddings", [0, 1], embs)
  # n = 2
  ```

notes:
  Usa sqlite-vec (extension pura C para SQLite). Los vectores se almacenan como blobs float32.
  Compatible con cualquier SQLite — se puede usar el mismo archivo para metadata con tablas normales.
  Insercion en batches de 500 para evitar limits de SQLite.
  Para 50k vectores dim=384: ~75 MB en disco, busqueda ~19ms/query.

code:
  """Vector storage and retrieval with sqlite-vec."""

  import sqlite3

  import numpy as np
  import sqlite_vec


  def embedding_store_sqlvec(db_path: str, table: str, ids: list, embeddings: list, dim: int = 384) -> int:
      """Inserta embeddings en una tabla sqlite-vec.

      Crea la tabla virtual si no existe. Inserta en batches de 500.

      Args:
          db_path: Path al archivo SQLite.
          table: Nombre de la tabla virtual vec0.
          ids: Lista de IDs (int) para cada embedding.
          embeddings: Lista de arrays numpy float32.
          dim: Dimension de los embeddings (default 384 para e5-small).

      Returns:
          Numero de embeddings insertados.

      Raises:
          sqlite3.Error: Si hay error de escritura en la BD.
      """
      db = sqlite3.connect(db_path)
      db.enable_load_extension(True)
      sqlite_vec.load(db)

      db.execute(f"CREATE VIRTUAL TABLE IF NOT EXISTS [{table}] USING vec0(embedding float[{dim}])")

      batch_size = 500
      count = 0
      for i in range(0, len(ids), batch_size):
          batch = [
              (int(ids[j]), np.asarray(embeddings[j], dtype=np.float32).tobytes())
              for j in range(i, min(i + batch_size, len(ids)))
          ]
          db.executemany(f"INSERT INTO [{table}](rowid, embedding) VALUES (?, ?)", batch)
          count += len(batch)

      db.commit()
      db.close()
      return count


  def embedding_search_sqlvec(db_path: str, table: str, query_embedding: list, k: int = 10) -> list:
      """Busca los k vecinos mas cercanos en una tabla sqlite-vec.

      Args:
          db_path: Path al archivo SQLite con la tabla vec0.
          table: Nombre de la tabla virtual.
          query_embedding: Array numpy float32 del query.
          k: Numero de resultados a retornar.

      Returns:
          Lista de dicts con 'rowid' y 'distance' ordenados por cercania.

      Raises:
          sqlite3.Error: Si la tabla no existe o hay error de lectura.
      """
      db = sqlite3.connect(db_path)
      db.enable_load_extension(True)
      sqlite_vec.load(db)

      q_bytes = np.asarray(query_embedding, dtype=np.float32).tobytes()
      rows = db.execute(
          f"SELECT rowid, distance FROM [{table}] WHERE embedding MATCH ? ORDER BY distance LIMIT ?",
          (q_bytes, k),
      ).fetchall()

      db.close()
      return [{"rowid": r[0], "distance": r[1]} for r in rows]

params_schema:
  {"params":[{"name":"db_path","desc":"ruta a la base de datos SQLite (se crea si no existe)"},{"name":"table","desc":"nombre de la tabla virtual sqlite-vec a crear"},{"name":"ids","desc":"lista de identificadores enteros para los embeddings"},{"name":"embeddings","desc":"lista de vectores (list[list[float]]) a almacenar"},{"name":"dim","desc":"dimensión de los vectores (por defecto 384 para e5-small)"}],"output":"int: cantidad de vectores insertados"}


id: embedding_store_usearch_py_infra
name: embedding_store_usearch
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def embedding_store_usearch(path: str, ids: list, embeddings: list, dim: int = 384) -> int
description: Crea indice USearch con embeddings y lo persiste a archivo. Busqueda sub-milisegundo.
tags: ["embedding","usearch","vector","store","ann","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["usearch","numpy"]
tests: []
file_path: python/functions/embedding/usearch_store.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: 67b27b4d2806329009ff704d80d23c3336b68788eb746c05d44eb6d7a3ca7a84
example:
  ```python
  model = embedding_load_model(".local/models/e5-small")
  docs = ["La IA transforma la industria", "Python es versatil"]
  embs = embedding_encode(model, docs, mode="document")

  n = embedding_store_usearch("docs.usearch", [0, 1], embs)
  # n = 2
  ```

notes:
  USearch usa HNSW (approximate nearest neighbors). Para 50k vectores dim=384:
  ~80 MB en disco, busqueda ~0.13ms/query (150x mas rapido que sqlite-vec).
  El tradeoff es que no soporta metadata nativa — usar junto con SQLite para metadata.
  Sobreescribe el archivo si ya existe.

code:
  """Vector storage and retrieval with USearch."""

  import numpy as np
  from usearch.index import Index


  def embedding_store_usearch(path: str, ids: list, embeddings: list, dim: int = 384) -> int:
      """Crea o sobreescribe un indice USearch con los embeddings dados.

      Args:
          path: Path del archivo .usearch para persistir el indice.
          ids: Lista de IDs (int) para cada embedding.
          embeddings: Lista de arrays numpy float32.
          dim: Dimension de los embeddings (default 384 para e5-small).

      Returns:
          Numero de embeddings insertados.

      Raises:
          OSError: Si no se puede escribir en el path.
      """
      index = Index(ndim=dim, metric="ip", dtype="f32")
      keys = np.array(ids, dtype=np.uint64)
      vecs = np.array(embeddings, dtype=np.float32)
      index.add(keys, vecs)
      index.save(path)
      return len(ids)


  def embedding_search_usearch(path: str, query_embedding: list, k: int = 10, dim: int = 384) -> list:
      """Busca los k vecinos mas cercanos en un indice USearch persistido.

      Args:
          path: Path del archivo .usearch.
          query_embedding: Array numpy float32 del query.
          k: Numero de resultados a retornar.
          dim: Dimension de los embeddings.

      Returns:
          Lista de dicts con 'key' y 'distance' ordenados por cercania.

      Raises:
          OSError: Si el archivo no existe.
      """
      index = Index(ndim=dim, metric="ip", dtype="f32")
      index.load(path)
      q = np.asarray(query_embedding, dtype=np.float32)
      results = index.search(q, k)
      keys = np.atleast_1d(results.keys)
      distances = np.atleast_1d(results.distances)
      return [{"key": int(keys[i]), "distance": float(distances[i])} for i in range(len(keys))]

params_schema:
  {"params":[{"name":"path","desc":"ruta donde guardar el archivo de índice USearch"},{"name":"ids","desc":"lista de identificadores enteros para los embeddings"},{"name":"embeddings","desc":"lista de vectores (list[list[float]]) a indexar"},{"name":"dim","desc":"dimensión de los vectores (por defecto 384 para e5-small)"}],"output":"int: cantidad de vectores indexados"}


id: annualized_volatility_py_finance
name: annualized_volatility
kind: function
lang: py
domain: finance
version: 1.0.0
purity: pure
signature: def annualized_volatility(returns: list, periods_per_year: float) -> float
description: Calcula la volatilidad anualizada de una serie de retornos.
tags: ["finance","volatility","risk","python"]
uses_functions: []
uses_types: []
returns: []
imports: ["math"]
tests: []
file_path: python/functions/finance/finance.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 396ebc359a1a4e6176e8546120e0a7da756dff98e14dcbd0cb34306408e30e95
example:
  ```python
  daily_returns = [0.01, -0.005, 0.008, 0.003, -0.002, 0.006, 0.004]
  vol = annualized_volatility(daily_returns, 252.0)
  # Volatilidad anualizada (std * sqrt(252))
  ```

notes:
  Formula: std_muestral(returns) * sqrt(periods_per_year).
  Usa desviacion estandar muestral (n-1) para ser consistente con la practica financiera.
  Retorna 0.0 si hay menos de 2 retornos o periods_per_year es menor o igual a cero.

code:
  """Finance domain — pure functions for financial indicators and calculations."""

  import math


  def sma(data: list, period: int) -> list:
      """Calcula la media movil simple (SMA) de una serie de precios."""
      if period <= 0 or period > len(data):
          return []
      result = []
      for i in range(period - 1, len(data)):
          window = data[i - period + 1 : i + 1]
          result.append(sum(window) / period)
      return result


  def ema(data: list, period: int) -> list:
      """Calcula la media movil exponencial (EMA) de una serie de precios."""
      if period <= 0 or period > len(data):
          return []
      multiplier = 2.0 / (period + 1)
      # Primer valor es SMA del primer periodo
      first_sma = sum(data[:period]) / period
      result = [first_sma]
      for i in range(period, len(data)):
          val = (data[i] - result[-1]) * multiplier + result[-1]
          result.append(val)
      return result


  def rsi(data: list, period: int) -> list:
      """Calcula el Relative Strength Index (RSI) de una serie de precios."""
      if period <= 0 or len(data) < period + 1:
          return []
      deltas = [data[i] - data[i - 1] for i in range(1, len(data))]
      gains = [d if d > 0 else 0.0 for d in deltas]
      losses = [-d if d < 0 else 0.0 for d in deltas]

      avg_gain = sum(gains[:period]) / period
      avg_loss = sum(losses[:period]) / period

      result = []
      if avg_loss == 0:
          result.append(100.0)
      else:
          rs = avg_gain / avg_loss
          result.append(100.0 - 100.0 / (1.0 + rs))

      for i in range(period, len(deltas)):
          avg_gain = (avg_gain * (period - 1) + gains[i]) / period
          avg_loss = (avg_loss * (period - 1) + losses[i]) / period
          if avg_loss == 0:
              result.append(100.0)
          else:
              rs = avg_gain / avg_loss
              result.append(100.0 - 100.0 / (1.0 + rs))

      return result


  def bollinger_bands(data: list, period: int, num_std: float) -> tuple:
      """Calcula las Bandas de Bollinger (upper, middle, lower)."""
      if period <= 0 or period > len(data):
          return ([], [], [])
      middle = sma(data, period)
      upper = []
      lower = []
      for i in range(len(middle)):
          window = data[i : i + period]
          mean = middle[i]
          variance = sum((x - mean) ** 2 for x in window) / period
          std = math.sqrt(variance)
          upper.append(mean + num_std * std)
          lower.append(mean - num_std * std)
      return (upper, middle, lower)


  def sharpe_ratio(returns: list, risk_free_rate: float, periods_per_year: float) -> float:
      """Calcula el Sharpe Ratio anualizado."""
      if len(returns) == 0 or periods_per_year <= 0:
          return 0.0
      n = len(returns)
      mean_return = sum(returns) / n
      excess = mean_return - risk_free_rate / periods_per_year
      variance = sum((r - mean_return) ** 2 for r in returns) / n
      std = math.sqrt(variance)
      if std == 0:
          return 0.0
      return (excess / std) * math.sqrt(periods_per_year)


  def max_drawdown(values: list) -> tuple:
      """Calcula el max drawdown y los indices de inicio y fin."""
      if len(values) < 2:
          return (0.0, 0, 0)
      peak = values[0]
      peak_idx = 0
      max_dd = 0.0
      dd_start = 0
      dd_end = 0
      for i in range(1, len(values)):
          if values[i] > peak:
              peak = values[i]
              peak_idx = i
          dd = (peak - values[i]) / peak if peak != 0 else 0.0
          if dd > max_dd:
              max_dd = dd
              dd_start = peak_idx
              dd_end = i
      return (max_dd, dd_start, dd_end)


  def vwap(prices: list, volumes: list) -> float:
      """Calcula el Volume-Weighted Average Price (VWAP)."""
      if len(prices) == 0 or len(prices) != len(volumes):
          return 0.0
      total_volume = sum(volumes)
      if total_volume == 0:
          return 0.0
      return sum(p * v for p, v in zip(prices, volumes)) / total_volume


  def log_return(price_start: float, price_end: float) -> float:
      """Calcula el retorno logaritmico entre dos precios."""
      if price_start <= 0 or price_end <= 0:
          return 0.0
      return math.log(price_end / price_start)


  def annualized_volatility(returns: list, periods_per_year: float) -> float:
      """Calcula la volatilidad anualizada de una serie de retornos."""
      if len(returns) < 2 or periods_per_year <= 0:
          return 0.0
      n = len(returns)
      mean = sum(returns) / n
      variance = sum((r - mean) ** 2 for r in returns) / (n - 1)
      return math.sqrt(variance) * math.sqrt(periods_per_year)


  def generate_gbm_prices(
      initial_price: float,
      n_ticks: int,
      sigma: float,
      mu: float = 0.0,
      jump_intensity: float = 0.0,
      jump_size_std: float = 0.05,
      seed: int = 42,
  ) -> list:
      """Genera serie de precios fundamentales con Geometric Brownian Motion + jump-diffusion.

      S(t+1) = S(t) * exp((mu - sigma^2/2)*dt + sigma*sqrt(dt)*Z + J*N)
      donde Z ~ N(0,1), N ~ Bernoulli(jump_intensity), J ~ N(0, jump_size_std)
      """
      import numpy as np
      rng = np.random.default_rng(seed)
      prices = [0.0] * n_ticks
      prices[0] = initial_price
      dt = 1.0
      for t in range(1, n_ticks):
          z = rng.standard_normal()
          gbm = (mu - 0.5 * sigma**2) * dt + sigma * np.sqrt(dt) * z
          jump = 0.0
          if jump_intensity > 0 and rng.random() < jump_intensity:
              jump = rng.normal(0, jump_size_std)
          prices[t] = prices[t - 1] * np.exp(gbm + jump)
      return prices


  def avellaneda_stoikov_quotes(
      mid_price: float,
      inventory: float,
      gamma: float,
      sigma: float,
      spread_base: float,
      n_levels: int = 3,
      qty_base: float = 10.0,
  ) -> list:
      """Genera ordenes de market maker usando el modelo Avellaneda-Stoikov.

      Precio de reserva: r = mid - inventory * gamma * sigma^2
      Half spread: delta = spread_base/2 + gamma * sigma^2/2

      Retorna lista de dicts con keys: side, price, qty
      """
      reservation = mid_price - inventory * gamma * sigma**2
      half_spread = spread_base / 2 + gamma * sigma**2 / 2
      orders = []
      for level in range(n_levels):
          offset = level * half_spread * 0.5
          qty = qty_base * (1 + level * 0.5)
          bid_price = round(reservation - half_spread - offset, 2)
          ask_price = round(reservation + half_spread + offset, 2)
          if bid_price > 0:
              orders.append({'side': 'buy', 'price': bid_price, 'qty': qty})
          if ask_price > 0:
              orders.append({'side': 'sell', 'price': ask_price, 'qty': qty})
      return orders


  def generate_taker_order(
      alpha: float = 2.0,
      size_min: float = 1.0,
      size_max: float = 100.0,
      buy_prob: float = 0.5,
      seed: int | None = None,
  ) -> dict:
      """Genera una market order de taker con tamano power-law (Pareto).

      P(size > x) ~ x^(-alpha). Alpha bajo = mas ballenas.
      Retorna dict con keys: side, qty
      """
      import numpy as np
      rng = np.random.default_rng(seed)
      side = 'buy' if rng.random() < buy_prob else 'sell'
      raw_size = (rng.pareto(alpha) + 1) * size_min
      size = min(round(raw_size, 1), size_max)
      return {'side': side, 'qty': size}


  def hawkes_intensity(
      base_rate: float,
      hawkes_alpha: float,
      hawkes_beta: float,
      event_times: list,
      current_time: float,
  ) -> float:
      """Calcula la intensidad lambda(t) de un proceso de Hawkes en el tiempo actual.

      lambda(t) = base_rate + sum(alpha * exp(-beta * (t - ti)))
      donde ti son los tiempos de eventos pasados.
      """
      import numpy as np
      excitation = sum(
          hawkes_alpha * np.exp(-hawkes_beta * (current_time - ti))
          for ti in event_times
          if ti < current_time
      )
      return max(0.0, base_rate + excitation)

params_schema:
  {"params":[{"name":"returns","desc":"lista de retornos diarios o periodicos (ej: [0.01, -0.005, 0.008]). Valores en rango [-1, 1] tipicamente."},{"name":"periods_per_year","desc":"numero de periodos por año (252 para datos diarios, 12 para mensuales, 52 para semanales)"}],"output":"volatilidad anualizada en forma decimal (ej: 0.15 para 15% de volatilidad anualizada)"}


id: avellaneda_stoikov_quotes_py_finance
name: avellaneda_stoikov_quotes
kind: function
lang: py
domain: finance
version: 1.0.0
purity: pure
tags: ["simulation","market-making","avellaneda-stoikov","montecarlo","finance","order-book"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: python/functions/finance/finance.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 1a660b7339369317adcb3987aa82f9e6d32efdd780a3612fed5b93c30dfbb41c
signature:
  avellaneda_stoikov_quotes(mid_price: float, inventory: float, gamma: float, sigma: float, spread_base: float, n_levels: int, qty_base: float) -> list[dict]

description:
  Genera ordenes de market maker usando el modelo Avellaneda-Stoikov. Calcula precio de reserva y half spread optimos segun inventario y volatilidad.

example:
  ```python
  orders = avellaneda_stoikov_quotes(
      mid_price=100.0,
      inventory=0.0,
      gamma=0.1,
      sigma=0.02,
      spread_base=0.5,
      n_levels=3,
      qty_base=10.0,
  )
  # [
  #   {'side': 'buy',  'price': 99.75, 'qty': 10.0},
  #   {'side': 'sell', 'price': 100.25, 'qty': 10.0},
  #   ...
  # ]
  ```

notes:
  Funcion pura — sin aleatoriedad.
  `gamma` controla la aversion al riesgo de inventario: mayor gamma = spreads mas amplios.
  `inventory` positivo sesga los quotes hacia venta (reduce inventario largo).
  Cada nivel adicional ensancha el spread en `half_spread * 0.5` y aumenta la cantidad en `qty_base * 0.5`.
  Ordenes con precio <= 0 se descartan automaticamente.

code:
  """Finance domain — pure functions for financial indicators and calculations."""

  import math


  def sma(data: list, period: int) -> list:
      """Calcula la media movil simple (SMA) de una serie de precios."""
      if period <= 0 or period > len(data):
          return []
      result = []
      for i in range(period - 1, len(data)):
          window = data[i - period + 1 : i + 1]
          result.append(sum(window) / period)
      return result


  def ema(data: list, period: int) -> list:
      """Calcula la media movil exponencial (EMA) de una serie de precios."""
      if period <= 0 or period > len(data):
          return []
      multiplier = 2.0 / (period + 1)
      # Primer valor es SMA del primer periodo
      first_sma = sum(data[:period]) / period
      result = [first_sma]
      for i in range(period, len(data)):
          val = (data[i] - result[-1]) * multiplier + result[-1]
          result.append(val)
      return result


  def rsi(data: list, period: int) -> list:
      """Calcula el Relative Strength Index (RSI) de una serie de precios."""
      if period <= 0 or len(data) < period + 1:
          return []
      deltas = [data[i] - data[i - 1] for i in range(1, len(data))]
      gains = [d if d > 0 else 0.0 for d in deltas]
      losses = [-d if d < 0 else 0.0 for d in deltas]

      avg_gain = sum(gains[:period]) / period
      avg_loss = sum(losses[:period]) / period

      result = []
      if avg_loss == 0:
          result.append(100.0)
      else:
          rs = avg_gain / avg_loss
          result.append(100.0 - 100.0 / (1.0 + rs))

      for i in range(period, len(deltas)):
          avg_gain = (avg_gain * (period - 1) + gains[i]) / period
          avg_loss = (avg_loss * (period - 1) + losses[i]) / period
          if avg_loss == 0:
              result.append(100.0)
          else:
              rs = avg_gain / avg_loss
              result.append(100.0 - 100.0 / (1.0 + rs))

      return result


  def bollinger_bands(data: list, period: int, num_std: float) -> tuple:
      """Calcula las Bandas de Bollinger (upper, middle, lower)."""
      if period <= 0 or period > len(data):
          return ([], [], [])
      middle = sma(data, period)
      upper = []
      lower = []
      for i in range(len(middle)):
          window = data[i : i + period]
          mean = middle[i]
          variance = sum((x - mean) ** 2 for x in window) / period
          std = math.sqrt(variance)
          upper.append(mean + num_std * std)
          lower.append(mean - num_std * std)
      return (upper, middle, lower)


  def sharpe_ratio(returns: list, risk_free_rate: float, periods_per_year: float) -> float:
      """Calcula el Sharpe Ratio anualizado."""
      if len(returns) == 0 or periods_per_year <= 0:
          return 0.0
      n = len(returns)
      mean_return = sum(returns) / n
      excess = mean_return - risk_free_rate / periods_per_year
      variance = sum((r - mean_return) ** 2 for r in returns) / n
      std = math.sqrt(variance)
      if std == 0:
          return 0.0
      return (excess / std) * math.sqrt(periods_per_year)


  def max_drawdown(values: list) -> tuple:
      """Calcula el max drawdown y los indices de inicio y fin."""
      if len(values) < 2:
          return (0.0, 0, 0)
      peak = values[0]
      peak_idx = 0
      max_dd = 0.0
      dd_start = 0
      dd_end = 0
      for i in range(1, len(values)):
          if values[i] > peak:
              peak = values[i]
              peak_idx = i
          dd = (peak - values[i]) / peak if peak != 0 else 0.0
          if dd > max_dd:
              max_dd = dd
              dd_start = peak_idx
              dd_end = i
      return (max_dd, dd_start, dd_end)


  def vwap(prices: list, volumes: list) -> float:
      """Calcula el Volume-Weighted Average Price (VWAP)."""
      if len(prices) == 0 or len(prices) != len(volumes):
          return 0.0
      total_volume = sum(volumes)
      if total_volume == 0:
          return 0.0
      return sum(p * v for p, v in zip(prices, volumes)) / total_volume


  def log_return(price_start: float, price_end: float) -> float:
      """Calcula el retorno logaritmico entre dos precios."""
      if price_start <= 0 or price_end <= 0:
          return 0.0
      return math.log(price_end / price_start)


  def annualized_volatility(returns: list, periods_per_year: float) -> float:
      """Calcula la volatilidad anualizada de una serie de retornos."""
      if len(returns) < 2 or periods_per_year <= 0:
          return 0.0
      n = len(returns)
      mean = sum(returns) / n
      variance = sum((r - mean) ** 2 for r in returns) / (n - 1)
      return math.sqrt(variance) * math.sqrt(periods_per_year)


  def generate_gbm_prices(
      initial_price: float,
      n_ticks: int,
      sigma: float,
      mu: float = 0.0,
      jump_intensity: float = 0.0,
      jump_size_std: float = 0.05,
      seed: int = 42,
  ) -> list:
      """Genera serie de precios fundamentales con Geometric Brownian Motion + jump-diffusion.

      S(t+1) = S(t) * exp((mu - sigma^2/2)*dt + sigma*sqrt(dt)*Z + J*N)
      donde Z ~ N(0,1), N ~ Bernoulli(jump_intensity), J ~ N(0, jump_size_std)
      """
      import numpy as np
      rng = np.random.default_rng(seed)
      prices = [0.0] * n_ticks
      prices[0] = initial_price
      dt = 1.0
      for t in range(1, n_ticks):
          z = rng.standard_normal()
          gbm = (mu - 0.5 * sigma**2) * dt + sigma * np.sqrt(dt) * z
          jump = 0.0
          if jump_intensity > 0 and rng.random() < jump_intensity:
              jump = rng.normal(0, jump_size_std)
          prices[t] = prices[t - 1] * np.exp(gbm + jump)
      return prices


  def avellaneda_stoikov_quotes(
      mid_price: float,
      inventory: float,
      gamma: float,
      sigma: float,
      spread_base: float,
      n_levels: int = 3,
      qty_base: float = 10.0,
  ) -> list:
      """Genera ordenes de market maker usando el modelo Avellaneda-Stoikov.

      Precio de reserva: r = mid - inventory * gamma * sigma^2
      Half spread: delta = spread_base/2 + gamma * sigma^2/2

      Retorna lista de dicts con keys: side, price, qty
      """
      reservation = mid_price - inventory * gamma * sigma**2
      half_spread = spread_base / 2 + gamma * sigma**2 / 2
      orders = []
      for level in range(n_levels):
          offset = level * half_spread * 0.5
          qty = qty_base * (1 + level * 0.5)
          bid_price = round(reservation - half_spread - offset, 2)
          ask_price = round(reservation + half_spread + offset, 2)
          if bid_price > 0:
              orders.append({'side': 'buy', 'price': bid_price, 'qty': qty})
          if ask_price > 0:
              orders.append({'side': 'sell', 'price': ask_price, 'qty': qty})
      return orders


  def generate_taker_order(
      alpha: float = 2.0,
      size_min: float = 1.0,
      size_max: float = 100.0,
      buy_prob: float = 0.5,
      seed: int | None = None,
  ) -> dict:
      """Genera una market order de taker con tamano power-law (Pareto).

      P(size > x) ~ x^(-alpha). Alpha bajo = mas ballenas.
      Retorna dict con keys: side, qty
      """
      import numpy as np
      rng = np.random.default_rng(seed)
      side = 'buy' if rng.random() < buy_prob else 'sell'
      raw_size = (rng.pareto(alpha) + 1) * size_min
      size = min(round(raw_size, 1), size_max)
      return {'side': side, 'qty': size}


  def hawkes_intensity(
      base_rate: float,
      hawkes_alpha: float,
      hawkes_beta: float,
      event_times: list,
      current_time: float,
  ) -> float:
      """Calcula la intensidad lambda(t) de un proceso de Hawkes en el tiempo actual.

      lambda(t) = base_rate + sum(alpha * exp(-beta * (t - ti)))
      donde ti son los tiempos de eventos pasados.
      """
      import numpy as np
      excitation = sum(
          hawkes_alpha * np.exp(-hawkes_beta * (current_time - ti))
          for ti in event_times
          if ti < current_time
      )
      return max(0.0, base_rate + excitation)

params_schema:
  {"params":[{"name":"mid_price","desc":"precio medio actual del activo subyacente (punto de referencia para la cotizacion)"},{"name":"inventory","desc":"inventario actual neto del market maker (cantidad de activos tenidos, positivo=long)"},{"name":"gamma","desc":"coeficiente de aversion al riesgo de inventario (rango tipico: 0.01-1.0). Mayor gamma = spreads mas amplios."},{"name":"sigma","desc":"volatilidad estimada del activo en la ventana de tiempo actual (ej: 0.02 para 2% diario)"},{"name":"spread_base","desc":"spread base en unidades de precio para el nivel 0 (ej: 0.5 para +/-0.5 alrededor del mid_price)"},{"name":"n_levels","desc":"numero de niveles de cotizacion a generar (tipico: 3-5). Cada nivel se ensancha a partir del anterior."},{"name":"qty_base","desc":"cantidad base de unidades por nivel (se reduce en cada nivel: qty_base, qty_base*0.5, qty_base*0.25, ...)"}],"output":"lista de ordenes con campos {'side': 'buy'|'sell', 'price': float, 'qty': float}"}


id: bollinger_bands_py_finance
name: bollinger_bands
kind: function
lang: py
domain: finance
version: 1.0.0
purity: pure
signature: def bollinger_bands(data: list, period: int, num_std: float) -> tuple
description: Calcula las Bandas de Bollinger (upper, middle, lower) de una serie de precios.
tags: ["finance","bollinger","volatility","indicator","python"]
uses_functions: ["sma_py_finance"]
uses_types: []
returns: []
imports: []
tests: []
file_path: python/functions/finance/finance.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 1cd4a9bd8e2cffc9447a2a04a53af487afb974e2f9bfdf7b7c49ee265009fd42
example:
  ```python
  prices = [10, 11, 12, 13, 14, 15, 14, 13, 12, 11]
  upper, middle, lower = bollinger_bands(prices, 5, 2.0)
  # middle es la SMA(5), upper/lower son middle +/- 2*std
  ```

notes:
  Retorna tupla de tres listas (upper, middle, lower). Cada lista tiene len(data) - period + 1 elementos.
  La desviacion estandar se calcula sobre la ventana de tamanio period (poblacional, no muestral).
  Usa internamente la funcion sma para la banda media.

code:
  """Finance domain — pure functions for financial indicators and calculations."""

  import math


  def sma(data: list, period: int) -> list:
      """Calcula la media movil simple (SMA) de una serie de precios."""
      if period <= 0 or period > len(data):
          return []
      result = []
      for i in range(period - 1, len(data)):
          window = data[i - period + 1 : i + 1]
          result.append(sum(window) / period)
      return result


  def ema(data: list, period: int) -> list:
      """Calcula la media movil exponencial (EMA) de una serie de precios."""
      if period <= 0 or period > len(data):
          return []
      multiplier = 2.0 / (period + 1)
      # Primer valor es SMA del primer periodo
      first_sma = sum(data[:period]) / period
      result = [first_sma]
      for i in range(period, len(data)):
          val = (data[i] - result[-1]) * multiplier + result[-1]
          result.append(val)
      return result


  def rsi(data: list, period: int) -> list:
      """Calcula el Relative Strength Index (RSI) de una serie de precios."""
      if period <= 0 or len(data) < period + 1:
          return []
      deltas = [data[i] - data[i - 1] for i in range(1, len(data))]
      gains = [d if d > 0 else 0.0 for d in deltas]
      losses = [-d if d < 0 else 0.0 for d in deltas]

      avg_gain = sum(gains[:period]) / period
      avg_loss = sum(losses[:period]) / period

      result = []
      if avg_loss == 0:
          result.append(100.0)
      else:
          rs = avg_gain / avg_loss
          result.append(100.0 - 100.0 / (1.0 + rs))

      for i in range(period, len(deltas)):
          avg_gain = (avg_gain * (period - 1) + gains[i]) / period
          avg_loss = (avg_loss * (period - 1) + losses[i]) / period
          if avg_loss == 0:
              result.append(100.0)
          else:
              rs = avg_gain / avg_loss
              result.append(100.0 - 100.0 / (1.0 + rs))

      return result


  def bollinger_bands(data: list, period: int, num_std: float) -> tuple:
      """Calcula las Bandas de Bollinger (upper, middle, lower)."""
      if period <= 0 or period > len(data):
          return ([], [], [])
      middle = sma(data, period)
      upper = []
      lower = []
      for i in range(len(middle)):
          window = data[i : i + period]
          mean = middle[i]
          variance = sum((x - mean) ** 2 for x in window) / period
          std = math.sqrt(variance)
          upper.append(mean + num_std * std)
          lower.append(mean - num_std * std)
      return (upper, middle, lower)


  def sharpe_ratio(returns: list, risk_free_rate: float, periods_per_year: float) -> float:
      """Calcula el Sharpe Ratio anualizado."""
      if len(returns) == 0 or periods_per_year <= 0:
          return 0.0
      n = len(returns)
      mean_return = sum(returns) / n
      excess = mean_return - risk_free_rate / periods_per_year
      variance = sum((r - mean_return) ** 2 for r in returns) / n
      std = math.sqrt(variance)
      if std == 0:
          return 0.0
      return (excess / std) * math.sqrt(periods_per_year)


  def max_drawdown(values: list) -> tuple:
      """Calcula el max drawdown y los indices de inicio y fin."""
      if len(values) < 2:
          return (0.0, 0, 0)
      peak = values[0]
      peak_idx = 0
      max_dd = 0.0
      dd_start = 0
      dd_end = 0
      for i in range(1, len(values)):
          if values[i] > peak:
              peak = values[i]
              peak_idx = i
          dd = (peak - values[i]) / peak if peak != 0 else 0.0
          if dd > max_dd:
              max_dd = dd
              dd_start = peak_idx
              dd_end = i
      return (max_dd, dd_start, dd_end)


  def vwap(prices: list, volumes: list) -> float:
      """Calcula el Volume-Weighted Average Price (VWAP)."""
      if len(prices) == 0 or len(prices) != len(volumes):
          return 0.0
      total_volume = sum(volumes)
      if total_volume == 0:
          return 0.0
      return sum(p * v for p, v in zip(prices, volumes)) / total_volume


  def log_return(price_start: float, price_end: float) -> float:
      """Calcula el retorno logaritmico entre dos precios."""
      if price_start <= 0 or price_end <= 0:
          return 0.0
      return math.log(price_end / price_start)


  def annualized_volatility(returns: list, periods_per_year: float) -> float:
      """Calcula la volatilidad anualizada de una serie de retornos."""
      if len(returns) < 2 or periods_per_year <= 0:
          return 0.0
      n = len(returns)
      mean = sum(returns) / n
      variance = sum((r - mean) ** 2 for r in returns) / (n - 1)
      return math.sqrt(variance) * math.sqrt(periods_per_year)


  def generate_gbm_prices(
      initial_price: float,
      n_ticks: int,
      sigma: float,
      mu: float = 0.0,
      jump_intensity: float = 0.0,
      jump_size_std: float = 0.05,
      seed: int = 42,
  ) -> list:
      """Genera serie de precios fundamentales con Geometric Brownian Motion + jump-diffusion.

      S(t+1) = S(t) * exp((mu - sigma^2/2)*dt + sigma*sqrt(dt)*Z + J*N)
      donde Z ~ N(0,1), N ~ Bernoulli(jump_intensity), J ~ N(0, jump_size_std)
      """
      import numpy as np
      rng = np.random.default_rng(seed)
      prices = [0.0] * n_ticks
      prices[0] = initial_price
      dt = 1.0
      for t in range(1, n_ticks):
          z = rng.standard_normal()
          gbm = (mu - 0.5 * sigma**2) * dt + sigma * np.sqrt(dt) * z
          jump = 0.0
          if jump_intensity > 0 and rng.random() < jump_intensity:
              jump = rng.normal(0, jump_size_std)
          prices[t] = prices[t - 1] * np.exp(gbm + jump)
      return prices


  def avellaneda_stoikov_quotes(
      mid_price: float,
      inventory: float,
      gamma: float,
      sigma: float,
      spread_base: float,
      n_levels: int = 3,
      qty_base: float = 10.0,
  ) -> list:
      """Genera ordenes de market maker usando el modelo Avellaneda-Stoikov.

      Precio de reserva: r = mid - inventory * gamma * sigma^2
      Half spread: delta = spread_base/2 + gamma * sigma^2/2

      Retorna lista de dicts con keys: side, price, qty
      """
      reservation = mid_price - inventory * gamma * sigma**2
      half_spread = spread_base / 2 + gamma * sigma**2 / 2
      orders = []
      for level in range(n_levels):
          offset = level * half_spread * 0.5
          qty = qty_base * (1 + level * 0.5)
          bid_price = round(reservation - half_spread - offset, 2)
          ask_price = round(reservation + half_spread + offset, 2)
          if bid_price > 0:
              orders.append({'side': 'buy', 'price': bid_price, 'qty': qty})
          if ask_price > 0:
              orders.append({'side': 'sell', 'price': ask_price, 'qty': qty})
      return orders


  def generate_taker_order(
      alpha: float = 2.0,
      size_min: float = 1.0,
      size_max: float = 100.0,
      buy_prob: float = 0.5,
      seed: int | None = None,
  ) -> dict:
      """Genera una market order de taker con tamano power-law (Pareto).

      P(size > x) ~ x^(-alpha). Alpha bajo = mas ballenas.
      Retorna dict con keys: side, qty
      """
      import numpy as np
      rng = np.random.default_rng(seed)
      side = 'buy' if rng.random() < buy_prob else 'sell'
      raw_size = (rng.pareto(alpha) + 1) * size_min
      size = min(round(raw_size, 1), size_max)
      return {'side': side, 'qty': size}


  def hawkes_intensity(
      base_rate: float,
      hawkes_alpha: float,
      hawkes_beta: float,
      event_times: list,
      current_time: float,
  ) -> float:
      """Calcula la intensidad lambda(t) de un proceso de Hawkes en el tiempo actual.

      lambda(t) = base_rate + sum(alpha * exp(-beta * (t - ti)))
      donde ti son los tiempos de eventos pasados.
      """
      import numpy as np
      excitation = sum(
          hawkes_alpha * np.exp(-hawkes_beta * (current_time - ti))
          for ti in event_times
          if ti < current_time
      )
      return max(0.0, base_rate + excitation)

params_schema:
  {"params":[{"name":"data","desc":"lista de precios o valores (ej: precios de cierre diarios)"},{"name":"period","desc":"tamanio de la ventana movil en numero de periodos (tipico: 5, 10, 20). Mayor periodo = bandas mas suave."},{"name":"num_std","desc":"numero de desviaciones estandar para las bandas (tipico: 2.0 para 95% de los datos en rango)"}],"output":"tupla (upper, middle, lower) donde cada elemento es una lista de floats con len(data) - period + 1 elementos"}


id: ema_py_finance
name: ema
kind: function
lang: py
domain: finance
version: 1.0.0
purity: pure
signature: def ema(data: list, period: int) -> list
description: Calcula la media movil exponencial (EMA) de una serie de precios.
tags: ["finance","moving-average","exponential","indicator","python"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: python/functions/finance/finance.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 68bb5458ca2deff2e4ab9ee59ee905cb3ea1921765f1969ccd8806c676598726
example:
  ```python
  prices = [10, 11, 12, 13, 14, 15]
  result = ema(prices, 3)
  # [11.0, 11.5, 12.25, 13.125]
  ```

notes:
  El primer valor de la EMA es el SMA del primer periodo. El multiplicador es 2 / (period + 1).
  Retorna len(data) - period + 1 elementos. Lista vacia si period invalido.

code:
[cell repeats an earlier one in this file: 7656 characters]

params_schema:
  {"params":[{"name":"data","desc":"lista de precios o valores a suavizar (ej: precios de cierre diarios)"},{"name":"period","desc":"numero de periodos para el calculo de EMA (tipico: 5, 12, 26, 50, 200). Mayor periodo = mas suavizado."}],"output":"lista de valores EMA con len(data) - period + 1 elementos (mas corta que la entrada)"}


id: generate_gbm_prices_py_finance
name: generate_gbm_prices
kind: function
lang: py
domain: finance
version: 1.0.0
purity: pure
tags: ["simulation","gbm","price","montecarlo","finance","stochastic"]
uses_functions: []
uses_types: []
returns: []
imports: ["numpy"]
tests: []
file_path: python/functions/finance/finance.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 98157b4a92515b8c41c694d7ed909ae894d13aeae5ca8c8db29c61e5e9fb35f6
signature:
  generate_gbm_prices(initial_price: float, n_ticks: int, sigma: float, mu: float, jump_intensity: float, jump_size_std: float, seed: int) -> list[float]

description:
  Genera serie de precios fundamentales con Geometric Brownian Motion + jump-diffusion. S(t+1) = S(t) * exp((mu - sigma^2/2)*dt + sigma*sqrt(dt)*Z + J*N).

example:
  ```python
  prices = generate_gbm_prices(
      initial_price=100.0,
      n_ticks=1000,
      sigma=0.02,
      mu=0.0,
      jump_intensity=0.01,
      jump_size_std=0.05,
      seed=42,
  )
  # prices[0] == 100.0
  # len(prices) == 1000
  ```

notes:
  Funcion pura — el seed fija el resultado deterministicamente.
  `jump_intensity=0.0` desactiva los saltos (GBM puro).
  `dt=1.0` por tick (tiempo discreto). Para tiempo continuo, ajustar sigma y mu en consecuencia.
  Requiere numpy para la generacion de numeros aleatorios y el calculo de exp.

code:
[cell repeats an earlier one in this file: 7656 characters]

params_schema:
  {"params":[{"name":"initial_price","desc":"precio inicial S(0) del activo subyacente (ej: 100.0)"},{"name":"n_ticks","desc":"numero de ticks/pasos en la simulacion (ej: 1000). Determina len(resultado)."},{"name":"sigma","desc":"volatilidad anualizada (ej: 0.02 para 2% por tick). Controlador clave del ruido."},{"name":"mu","desc":"drift anualizado (ej: 0.0 para random walk, 0.05 para uptrend). Retorno esperado por tick."},{"name":"jump_intensity","desc":"probabilidad de salto por tick en rango [0, 1] (ej: 0.01 para 1% por tick). 0.0 = GBM puro."},{"name":"jump_size_std","desc":"desviacion estandar del tamanio de los saltos (ej: 0.05 para jumps de ~5%). Normal(0, jump_size_std)."},{"name":"seed","desc":"semilla para reproducibilidad deterministica (ej: 42)"}],"output":"lista de precios con len(resultado) == n_ticks. resultado[0] == initial_price."}


id: generate_taker_order_py_finance
name: generate_taker_order
kind: function
lang: py
domain: finance
version: 1.0.0
purity: pure
tags: ["simulation","taker","power-law","montecarlo","finance","order-book"]
uses_functions: []
uses_types: []
returns: []
imports: ["numpy"]
tests: []
file_path: python/functions/finance/finance.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: e0d06eef1c6fbc8a21f40bd684c6eb46e355853190cefc5de92a3205c843d095
signature:
  generate_taker_order(alpha: float, size_min: float, size_max: float, buy_prob: float, seed: int | None) -> dict

description:
  Genera una market order de taker con tamano distribuido segun power-law (Pareto). Alpha bajo produce ordenes mas grandes (ballenas).

example:
  ```python
  order = generate_taker_order(
      alpha=2.0,
      size_min=1.0,
      size_max=100.0,
      buy_prob=0.5,
      seed=42,
  )
  # {'side': 'buy', 'qty': 3.7}
  ```

notes:
  Funcion pura cuando se fija seed. Con seed=None el resultado es no deterministico.
  La distribucion Pareto con alpha=2 modela bien la distribucion empirica de tamaños de ordenes en mercados reales.
  `size_max` actua como techo (clipping) para evitar ordenes extremas.
  Retorna dict con keys: `side` ('buy' o 'sell') y `qty` (float redondeado a 1 decimal).

code:
[cell repeats an earlier one in this file: 7656 characters]

params_schema:
  {"params":[{"name":"alpha","desc":"exponente de la distribucion Pareto (rango tipico: 1.5-3.0). Menor alpha = colas mas pesadas y ordenes mas grandes."},{"name":"size_min","desc":"tamanio minimo de orden (ej: 1.0)"},{"name":"size_max","desc":"tamanio maximo de orden (clipping superior, ej: 100.0)"},{"name":"buy_prob","desc":"probabilidad de que la orden sea BUY en rango [0, 1] (ej: 0.5 para igual BUY/SELL)"},{"name":"seed","desc":"semilla para reproducibilidad (int o None). Si None, no deterministica."}],"output":"dict con campos {'side': 'buy'|'sell', 'qty': float redondeado a 1 decimal}"}


id: hawkes_intensity_py_finance
name: hawkes_intensity
kind: function
lang: py
domain: finance
version: 1.0.0
purity: pure
tags: ["simulation","hawkes","stochastic-process","montecarlo","finance","point-process"]
uses_functions: []
uses_types: []
returns: []
imports: ["numpy"]
tests: []
file_path: python/functions/finance/finance.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 867368e2340bf7570a8319cd2b5cf53df00ca60c63108ab290dd09565f4410cd
signature:
  hawkes_intensity(base_rate: float, hawkes_alpha: float, hawkes_beta: float, event_times: list[float], current_time: float) -> float

description:
  Calcula la intensidad lambda(t) de un proceso de Hawkes en el tiempo actual. Modela la autocorrelacion temporal de eventos de mercado (rafagas de ordenes).

example:
  ```python
  intensity = hawkes_intensity(
      base_rate=1.0,
      hawkes_alpha=0.8,
      hawkes_beta=2.0,
      event_times=[0.5, 1.2, 1.8],
      current_time=2.5,
  )
  # Intensidad > base_rate por excitacion de eventos pasados
  ```

notes:
  Funcion pura — determinista dado el mismo historial de eventos.
  `hawkes_alpha` controla la magnitud del salto de intensidad por evento.
  `hawkes_beta` controla la velocidad de decaimiento (mayor beta = decaimiento mas rapido).
  La condicion de estabilidad del proceso es hawkes_alpha < hawkes_beta.
  Eventos con ti >= current_time se ignoran automaticamente.
  Retorna max(0.0, ...) para garantizar intensidad no negativa.

code:
[cell repeats an earlier one in this file: 7656 characters]

params_schema:
  {"params":[{"name":"base_rate","desc":"intensidad de linea base lambda(0) del proceso Hawkes (ej: 1.0 orden/minuto). Tasa base de eventos sin influencia."},{"name":"hawkes_alpha","desc":"magnitud del salto de intensidad por evento (rango: 0.0-1.0). Mayor alpha = mayor excitacion por eventos pasados."},{"name":"hawkes_beta","desc":"velocidad de decaimiento de la memoria (rango tipico: 0.5-3.0). Mayor beta = decaimiento mas rapido, memoria mas corta."},{"name":"event_times","desc":"lista de timestamps de eventos pasados (ej: [0.5, 1.2, 1.8]). Milisegundos o segundos segun escala."},{"name":"current_time","desc":"timestamp actual en la misma escala que event_times (ej: 2.5)"}],"output":"intensidad lambda(current_time) en rango [0.0, infinito). Valores \u003e base_rate indican excitacion por eventos recientes."}


id: log_return_py_finance
name: log_return
kind: function
lang: py
domain: finance
version: 1.0.0
purity: pure
signature: def log_return(price_start: float, price_end: float) -> float
description: Calcula el retorno logaritmico entre dos precios.
tags: ["finance","return","logarithmic","python"]
uses_functions: []
uses_types: []
returns: []
imports: ["math"]
example:
  ```python
  r = log_return(100.0, 110.0)
  # 0.09531... (aprox 9.53%)
  ```

tests: []
file_path: python/functions/finance/finance.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 332b2a0c4e5bfc6d8809daedfde8e593a5491f7aee45ece04aa3893936c3c9f7
notes:
  Formula: ln(price_end / price_start).
  Retorna 0.0 si alguno de los precios es menor o igual a cero.
  Los retornos logaritmicos son aditivos en el tiempo, a diferencia de los retornos simples.

code:
[cell repeats an earlier one in this file: 7656 characters]

params_schema:
  {"params":[{"name":"price_start","desc":"precio inicial (ej: 100.0). Debe ser \u003e 0."},{"name":"price_end","desc":"precio final (ej: 110.0). Debe ser \u003e 0."}],"output":"retorno logaritmico en forma decimal (ej: 0.0953 para aprox +9.53%). Aditivo en el tiempo."}


id: max_drawdown_py_finance
name: max_drawdown
kind: function
lang: py
domain: finance
version: 1.0.0
purity: pure
signature: def max_drawdown(values: list) -> tuple
description: Calcula el maximo drawdown y los indices de inicio y fin del peor periodo.
tags: ["finance","drawdown","risk","performance","python"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: python/functions/finance/finance.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 5a10772812104563e53fe5ce5cd936f42a1607d2bf1139ed58ec29b65de5b2d4
example:
  ```python
  portfolio = [100, 110, 105, 95, 102, 108, 90, 95]
  dd, start, end = max_drawdown(portfolio)
  # dd = 0.1818..., start = 1, end = 6  (de 110 a 90)
  ```

notes:
  Retorna tupla (max_dd, start_idx, end_idx) donde max_dd es fraccion (0.0 a 1.0).
  start_idx es el indice del pico previo, end_idx es el indice del valle.
  Retorna (0.0, 0, 0) si la lista tiene menos de 2 elementos.

code:
[cell repeats an earlier one in this file: 7656 characters]

params_schema:
  {"params":[{"name":"values","desc":"lista de valores acumulativos (ej: valores de cartera a lo largo del tiempo, precios, o equity curve)"}],"output":"tupla (max_dd, start_idx, end_idx) donde max_dd es fraccion (0.0-1.0), start_idx es indice del pico, end_idx es indice del valle"}


id: rsi_py_finance
name: rsi
kind: function
lang: py
domain: finance
version: 1.0.0
purity: pure
signature: def rsi(data: list, period: int) -> list
description: Calcula el Relative Strength Index (RSI) de una serie de precios.
tags: ["finance","rsi","momentum","indicator","python"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: python/functions/finance/finance.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 447d2dcd83224253b94f4ec6e500a5c7fabaf3a2857f94d328e841c12c242aa6
example:
  ```python
  prices = [44, 44.34, 44.09, 43.61, 44.33, 44.83, 45.10, 45.42, 45.84, 46.08,
            45.89, 46.03, 45.61, 46.28, 46.28, 46.00, 46.03, 46.41, 46.22, 45.64]
  result = rsi(prices, 14)
  # Lista de valores RSI entre 0 y 100
  ```

notes:
  Usa el metodo de suavizado de Wilder (media exponencial modificada).
  Requiere al menos period + 1 datos de entrada. Retorna len(data) - period valores.
  RSI = 100 si no hay perdidas en el periodo (avg_loss == 0).

code:
[cell repeats an earlier one in this file: 7656 characters]

params_schema:
  {"params":[{"name":"data","desc":"lista de precios (ej: precios de cierre diarios). Debe tener al menos period+1 elementos."},{"name":"period","desc":"numero de periodos para el calculo de RSI (tipico: 14). Mayor periodo = menos volatil, menos sensible."}],"output":"lista de valores RSI en rango [0, 100]. Valores \u003e70 indican sobrecompra, \u003c30 sobreventa."}


id: sharpe_ratio_py_finance
name: sharpe_ratio
kind: function
lang: py
domain: finance
version: 1.0.0
purity: pure
signature: def sharpe_ratio(returns: list, risk_free_rate: float, periods_per_year: float) -> float
description: Calcula el Sharpe Ratio anualizado de una serie de retornos.
tags: ["finance","sharpe","risk","performance","python"]
uses_functions: []
uses_types: []
returns: []
imports: ["math"]
tests: []
file_path: python/functions/finance/finance.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 8f818c2a8874a1c0432e66b4bace088975682e332677872870537621db01d33e
example:
  ```python
  daily_returns = [0.01, -0.005, 0.008, 0.003, -0.002, 0.006, 0.004]
  sr = sharpe_ratio(daily_returns, 0.02, 252.0)
  # Sharpe ratio anualizado
  ```

notes:
  risk_free_rate es la tasa anual (ej: 0.02 para 2%). Se convierte a tasa por periodo internamente.
  periods_per_year indica la frecuencia de los retornos (252 para diarios, 12 para mensuales).
  Retorna 0.0 si la desviacion estandar es cero o la lista esta vacia.

code:
[cell repeats an earlier one in this file: 7656 characters]

params_schema:
  {"params":[{"name":"returns","desc":"lista de retornos en la misma periodicidad (ej: retornos diarios [0.01, -0.005, ...])."},{"name":"risk_free_rate","desc":"tasa de interes libre de riesgo anualizada en forma decimal (ej: 0.02 para 2% anual)."},{"name":"periods_per_year","desc":"numero de periodos por año (252 para diarios, 12 para mensuales, 52 para semanales)."}],"output":"Sharpe ratio anualizado en forma decimal (ej: 1.5 indica 1.5x retorno por unidad de riesgo). Mayor es mejor."}


id: sma_py_finance
name: sma
kind: function
lang: py
domain: finance
version: 1.0.0
purity: pure
signature: def sma(data: list, period: int) -> list
description: Calcula la media movil simple (SMA) de una serie de precios.
tags: ["finance","moving-average","indicator","python"]
uses_functions: []
uses_types: []
returns: []
imports: []
example:
  ```python
  prices = [10, 11, 12, 13, 14, 15]
  result = sma(prices, 3)
  # [11.0, 12.0, 13.0, 14.0]
  ```

tests: []
file_path: python/functions/finance/finance.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 3570ed13d461465d16c4259a0155fc19d618d338c918e1ee9c9d8c10240be366
notes:
  Retorna lista mas corta que la entrada (len - period + 1 elementos).
  Si period es mayor que len(data) o menor/igual a 0, retorna lista vacia.

code:
[cell repeats an earlier one in this file: 7656 characters]

params_schema:
  {"params":[{"name":"data","desc":"lista de precios o valores a suavizar (ej: precios de cierre diarios)"},{"name":"period","desc":"tamanio de la ventana movil en numero de periodos (tipico: 5, 10, 20, 50, 200). Mayor periodo = mas suavizado."}],"output":"lista de valores SMA con len(data) - period + 1 elementos (mas corta que la entrada)"}


id: vwap_py_finance
name: vwap
kind: function
lang: py
domain: finance
version: 1.0.0
purity: pure
signature: def vwap(prices: list, volumes: list) -> float
description: Calcula el Volume-Weighted Average Price (VWAP).
tags: ["finance","vwap","volume","indicator","python"]
uses_functions: []
uses_types: []
returns: []
imports: []
tests: []
file_path: python/functions/finance/finance.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: cbff32791f9ec38c7a3397594282252601eaf2b1ee1b151b3bf0f9b8214c2302
example:
  ```python
  prices = [100.0, 101.0, 102.0, 101.5]
  volumes = [1000, 1500, 1200, 800]
  result = vwap(prices, volumes)
  # 101.0888...
  ```

notes:
  Formula: sum(price_i * volume_i) / sum(volume_i).
  Retorna 0.0 si las listas estan vacias, tienen distinto tamanio, o el volumen total es cero.

code:
[cell repeats an earlier one in this file: 7656 characters]

params_schema:
  {"params":[{"name":"prices","desc":"lista de precios de transaccion (ej: [100.0, 101.0, 102.0]). Debe tener misma longitud que volumes."},{"name":"volumes","desc":"lista de volumenes de transaccion (ej: [1000, 1500, 1200]). Debe tener misma longitud que prices."}],"output":"VWAP en forma decimal (ej: 101.0888). Precio promedio ponderado por volumen acumulado."}


id: cache_to_file_py_infra
name: cache_to_file
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def cache_to_file(cache_dir: str, namespace: str = 'default') -> FileCache
tags: ["cache","file","persistence","ttl","key-value","sha256"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["os","json","hashlib","time","threading"]
test_file_path: python/functions/infra/cache_to_file_test.py
file_path: python/functions/infra/cache_to_file.py
created_at: 2026-04-05T14:43:57Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 30497436e5cd6d3d85f5fce1eb35665c8cd1f6bde59c1c22dbc5d7136845358b
description:
  Cache key-value donde cada entry es un archivo JSON en disco. Keys se hashean con SHA-256 para generar nombres de archivo seguros. Metadata (ttl, created_at, original_key) en sidecar .meta. Mejor que SQLite para valores grandes (PDFs procesados, embeddings).

example:
  ```python
  from infra.cache_to_file import cache_to_file

  store = cache_to_file("/tmp/my_cache", namespace="embeddings")

  # Almacenar un embedding grande
  store.set("doc:123", embedding_vector, ttl=86400)

  # Recuperar
  vec = store.get("doc:123")

  # Factory pattern
  result = store.get_or_set(
      "pdf:page_42",
      factory=lambda: extract_pdf_text("doc.pdf", page=42),
      ttl=0,  # sin expiracion
  )
  ```

tests:
  ["Set y get basico","TTL expirado → None","Archivo .meta con metadata correcta","Clear elimina el directorio del namespace","Key con caracteres especiales → hash seguro"]

notes:
  Cada entry genera exactamente dos archivos: `.json` para el valor y `.meta` para la metadata. La key original se guarda en `.meta["original_key"]` para facilitar debugging. Thread-safe mediante `threading.Lock`. La eviction es lazy: se verifica expires_at al hacer `get`.

documentation:
  ## Estructura en disco

  ```
  cache_dir/
    namespace/
      {sha256_key}.json    # valor serializado como JSON
      {sha256_key}.meta    # {"created_at": ..., "expires_at": ..., "original_key": ...}
  ```

code:
  """Cache key-value donde cada entry es un archivo JSON en disco."""

  import hashlib
  import json
  import os
  import threading
  import time


  class FileCache:
      """Cache key-value respaldado en archivos JSON, con metadata sidecar .meta."""

      def __init__(self, cache_dir: str, namespace: str = "default") -> None:
          self._base = os.path.join(cache_dir, namespace)
          self._hits = 0
          self._misses = 0
          self._lock = threading.Lock()
          os.makedirs(self._base, exist_ok=True)

      def _hash_key(self, key: str) -> str:
          return hashlib.sha256(key.encode("utf-8")).hexdigest()

      def _value_path(self, hashed: str) -> str:
          return os.path.join(self._base, f"{hashed}.json")

      def _meta_path(self, hashed: str) -> str:
          return os.path.join(self._base, f"{hashed}.meta")

      def _is_expired(self, meta: dict) -> bool:
          expires_at = meta.get("expires_at")
          if expires_at is None:
              return False
          return time.time() >= expires_at

      def _load_meta(self, hashed: str) -> dict | None:
          path = self._meta_path(hashed)
          if not os.path.exists(path):
              return None
          with open(path, "r", encoding="utf-8") as f:
              return json.load(f)

      def get(self, key: str) -> object:
          """Retorna el valor o None si no existe o esta expirado."""
          hashed = self._hash_key(key)
          with self._lock:
              meta = self._load_meta(hashed)
              if meta is None:
                  self._misses += 1
                  return None
              if self._is_expired(meta):
                  self._delete_files(hashed)
                  self._misses += 1
                  return None
              value_path = self._value_path(hashed)
              if not os.path.exists(value_path):
                  self._misses += 1
                  return None
              with open(value_path, "r", encoding="utf-8") as f:
                  self._hits += 1
                  return json.load(f)

      def set(self, key: str, value: object, ttl: float = 0) -> None:
          """Almacena un valor. ttl en segundos; 0 = sin expiracion."""
          hashed = self._hash_key(key)
          now = time.time()
          expires_at = (now + ttl) if ttl > 0 else None
          meta = {"created_at": now, "expires_at": expires_at, "original_key": key}
          with self._lock:
              with open(self._value_path(hashed), "w", encoding="utf-8") as f:
                  json.dump(value, f)
              with open(self._meta_path(hashed), "w", encoding="utf-8") as f:
                  json.dump(meta, f)

      def _delete_files(self, hashed: str) -> bool:
          vp = self._value_path(hashed)
          mp = self._meta_path(hashed)
          deleted = False
          if os.path.exists(vp):
              os.remove(vp)
              deleted = True
          if os.path.exists(mp):
              os.remove(mp)
              deleted = True
          return deleted

      def delete(self, key: str) -> bool:
          """Elimina una entrada. Retorna True si existia."""
          hashed = self._hash_key(key)
          with self._lock:
              return self._delete_files(hashed)

      def clear(self) -> int:
          """Elimina todas las entradas del namespace. Retorna pares eliminados."""
          with self._lock:
              count = 0
              if not os.path.isdir(self._base):
                  return 0
              for fname in os.listdir(self._base):
                  if fname.endswith(".json"):
                      count += 1
                  fpath = os.path.join(self._base, fname)
                  os.remove(fpath)
              return count

      def stats(self) -> dict:
          """Retorna estadisticas del store: hits, misses y size actual."""
          with self._lock:
              if not os.path.isdir(self._base):
                  size = 0
              else:
                  size = sum(
                      1 for f in os.listdir(self._base) if f.endswith(".json")
                  )
              return {"hits": self._hits, "misses": self._misses, "size": size}

      def get_or_set(self, key: str, factory: callable, ttl: float = 0) -> object:
          """Retorna el valor cacheado o llama factory() y lo almacena."""
          value = self.get(key)
          if value is None:
              value = factory()
              self.set(key, value, ttl)
          return value


  def cache_to_file(cache_dir: str, namespace: str = "default") -> FileCache:
      """Crea un FileCache respaldado en archivos JSON en disco.

      Args:
          cache_dir: Directorio raiz donde se almacenan los archivos de cache.
          namespace: Subdirectorio logico dentro de cache_dir.

      Returns:
          FileCache con metodos get/set/delete/clear/stats/get_or_set.
      """
      return FileCache(cache_dir, namespace)

params_schema:
  {"params":[{"name":"cache_dir","desc":"directorio raiz donde se almacenan los archivos de cache"},{"name":"namespace","desc":"namespace logico dentro de cache_dir para aislar diferentes cachés"}],"output":"instancia FileCache con metodos set, get, get_or_set, clear y stats"}


id: cache_to_sqlite_py_infra
name: cache_to_sqlite
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def cache_to_sqlite(db_path: str, namespace: str = 'default') -> CacheStore
tags: ["cache","sqlite","persistence","ttl","memoize","key-value"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["sqlite3","json","time","threading"]
test_file_path: python/functions/infra/cache_to_sqlite_test.py
file_path: python/functions/infra/cache_to_sqlite.py
created_at: 2026-04-05T11:25:56Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: c6c16b297df735724134f0f018f855b26ddf074fca1c7d044f4c50b6ac3143c0
description:
  Cache key-value persistido en SQLite con TTL y lazy eviction. Cada namespace es un espacio logico dentro de la misma BD. Keys son strings, values se serializan con JSON. TTL en segundos, 0 = sin expiracion. Thread-safe mediante mutex.

example:
  ```python
  from infra.cache_to_sqlite import cache_to_sqlite

  store = cache_to_sqlite("my_cache.db", namespace="llm")

  # Almacenar con TTL de 1 hora
  store.set("prompt:explain_x", "explanation...", ttl=3600)

  # Recuperar (None si miss o expirado)
  val = store.get("prompt:explain_x")

  # Factory pattern: solo computa si no esta en cache
  result = store.get_or_set(
      "prompt:explain_y",
      factory=lambda: call_llm("explain y"),
      ttl=3600,
  )

  # Estadisticas
  print(store.stats())  # {"hits": 2, "misses": 1, "size": 5}
  ```

tests:
  ["Set y get basico","TTL expirado → None","TTL 0 → nunca expira","get_or_set con factory que solo se llama en miss","Namespaces independientes","Clear elimina solo el namespace","Stats contadores correctos","Concurrencia (threading basico)"]

notes:
  La eviction de entradas expiradas es lazy: se ejecuta en cada llamada a `get` o `stats`, no en background. El schema SQLite usa `(namespace, key)` como PRIMARY KEY para garantizar upserts atomicos. Usa WAL mode para mejor concurrencia de lecturas. Cada thread mantiene su propia conexion SQLite (thread-local), sincronizada via `threading.Lock` para escrituras.

code:
  """Cache key-value persistido en SQLite con TTL y lazy eviction."""

  import json
  import sqlite3
  import threading
  import time


  class CacheStore:
      """Cache key-value respaldado en SQLite con soporte de TTL y namespaces."""

      _schema = """
      CREATE TABLE IF NOT EXISTS cache (
          namespace TEXT NOT NULL,
          key TEXT NOT NULL,
          value TEXT NOT NULL,
          created_at REAL NOT NULL,
          expires_at REAL,
          PRIMARY KEY (namespace, key)
      );
      """

      def __init__(self, db_path: str, namespace: str = "default") -> None:
          self._db_path = db_path
          self._namespace = namespace
          self._hits = 0
          self._misses = 0
          self._lock = threading.Lock()
          self._local = threading.local()
          self._init_db()

      def _conn(self) -> sqlite3.Connection:
          """Retorna una conexion SQLite thread-local."""
          if not hasattr(self._local, "conn"):
              conn = sqlite3.connect(self._db_path, check_same_thread=False)
              conn.execute("PRAGMA journal_mode=WAL")
              self._local.conn = conn
          return self._local.conn

      def _init_db(self) -> None:
          conn = self._conn()
          conn.execute(self._schema)
          conn.commit()

      def _evict_expired(self, conn: sqlite3.Connection) -> None:
          """Elimina entradas expiradas del namespace actual (lazy eviction)."""
          now = time.time()
          conn.execute(
              "DELETE FROM cache WHERE namespace = ? AND expires_at IS NOT NULL AND expires_at <= ?",
              (self._namespace, now),
          )

      def get(self, key: str) -> object:
          """Retorna el valor o None si no existe o esta expirado."""
          with self._lock:
              conn = self._conn()
              self._evict_expired(conn)
              conn.commit()
              row = conn.execute(
                  "SELECT value FROM cache WHERE namespace = ? AND key = ?",
                  (self._namespace, key),
              ).fetchone()
              if row is None:
                  self._misses += 1
                  return None
              self._hits += 1
              return json.loads(row[0])

      def set(self, key: str, value: object, ttl: float = 0) -> None:
          """Almacena un valor. ttl en segundos; 0 = sin expiracion."""
          now = time.time()
          expires_at = (now + ttl) if ttl > 0 else None
          with self._lock:
              conn = self._conn()
              conn.execute(
                  """
                  INSERT INTO cache (namespace, key, value, created_at, expires_at)
                  VALUES (?, ?, ?, ?, ?)
                  ON CONFLICT(namespace, key) DO UPDATE SET
                      value = excluded.value,
                      created_at = excluded.created_at,
                      expires_at = excluded.expires_at
                  """,
                  (self._namespace, key, json.dumps(value), now, expires_at),
              )
              conn.commit()

      def delete(self, key: str) -> bool:
          """Elimina una entrada. Retorna True si existia."""
          with self._lock:
              conn = self._conn()
              cursor = conn.execute(
                  "DELETE FROM cache WHERE namespace = ? AND key = ?",
                  (self._namespace, key),
              )
              conn.commit()
              return cursor.rowcount > 0

      def clear(self) -> int:
          """Elimina todas las entradas del namespace. Retorna filas eliminadas."""
          with self._lock:
              conn = self._conn()
              cursor = conn.execute(
                  "DELETE FROM cache WHERE namespace = ?",
                  (self._namespace,),
              )
              conn.commit()
              return cursor.rowcount

      def stats(self) -> dict:
          """Retorna estadisticas del store: hits, misses y size actual."""
          with self._lock:
              conn = self._conn()
              self._evict_expired(conn)
              conn.commit()
              row = conn.execute(
                  "SELECT COUNT(*) FROM cache WHERE namespace = ?",
                  (self._namespace,),
              ).fetchone()
              size = row[0] if row else 0
              return {"hits": self._hits, "misses": self._misses, "size": size}

      def get_or_set(self, key: str, factory: callable, ttl: float = 0) -> object:
          """Retorna el valor cacheado o llama factory() y lo almacena."""
          value = self.get(key)
          if value is None:
              value = factory()
              self.set(key, value, ttl)
          return value


  def cache_to_sqlite(db_path: str, namespace: str = "default") -> CacheStore:
      """Crea un CacheStore respaldado en SQLite.

      Args:
          db_path: Ruta al archivo SQLite (se crea si no existe).
          namespace: Espacio de nombres logico dentro de la base de datos.

      Returns:
          CacheStore con metodos get/set/delete/clear/stats/get_or_set.
      """
      return CacheStore(db_path, namespace)

params_schema:
  {"params":[{"name":"db_path","desc":"ruta al archivo SQLite de cache"},{"name":"namespace","desc":"namespace logico dentro de la BD para aislar cachés"}],"output":"instancia CacheStore con metodos set, get, get_or_set, clear y stats"}


id: config_from_env_py_infra
name: config_from_env
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: config_from_env(target_class: type) -> object
tags: ["config","env","dataclass","infra","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["os","dataclasses"]
test_file_path: python/functions/infra/config_from_env_test.py
file_path: python/functions/infra/config_from_env.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: ac34c5c9462484dd77058f54d16285b71498e6ddb799258297ce4083d1583158
description:
  Crea una instancia de un dataclass poblada desde variables de entorno usando field metadata (env, required, default, secret). Soporta str, int, float, bool, list. Acumula todos los errores antes de lanzar ValueError.

example:
  ```python
  from dataclasses import dataclass, field
  from config_from_env import config_from_env

  @dataclass
  class AppConfig:
      host: str = field(default="localhost", metadata={"env": "HOST", "default": "localhost"})
      port: int = field(default=8080,        metadata={"env": "PORT", "default": "8080", "required": True})
      api_key: str = field(default="",       metadata={"env": "API_KEY", "required": True, "secret": True})
      tags: list = field(default_factory=list, metadata={"env": "TAGS", "default": "prod,stable"})

  cfg = config_from_env(AppConfig)
  print(cfg.port)   # 8080 o el valor de PORT
  ```

tests:
  ["populate string desde env","populate int con conversion","populate bool true","populate float field","populate list comma separated","default usado cuando env no seteada","required sin valor lanza error","no dataclass lanza type error"]

notes:
  Solo stdlib (os, dataclasses). Conversion de tipos: str sin cambio, int/float con parseo, bool desde "1"/"true"/"yes", list desde split por coma. El tag secret no tiene efecto en runtime. La funcion inspecciona todos los fields via dataclasses.fields() y solo procesa los que tienen metadata["env"].

code:
  """config_from_env — popula dataclasses desde variables de entorno usando field metadata."""

  import os
  from dataclasses import fields as dc_fields


  def config_from_env(target_class: type) -> object:
      """Crea una instancia de un dataclass poblada desde variables de entorno.

      Cada campo del dataclass puede tener metadata que guia el mapeo:
        - ``env``: nombre de la variable de entorno (obligatorio para usar este sistema)
        - ``required``: bool, si True y la var no esta seteada lanza ValueError
        - ``default``: valor string usado cuando la var no esta seteada (opcional)
        - ``secret``: bool, documentacion solamente, sin efecto en runtime

      La conversion de tipo es automatica segun el tipo anotado del campo:
        - ``str``: sin conversion
        - ``int``: int(value)
        - ``float``: float(value)
        - ``bool``: True para "1", "true", "yes" (case-insensitive); False para el resto
        - ``list[str]`` o ``list``: split por coma, stripping espacios

      Args:
          target_class: clase dataclass con campo metadata en sus fields.

      Returns:
          instancia de target_class con todos los campos poblados.

      Raises:
          ValueError: si un campo requerido no esta seteado, o si la conversion de tipo falla.
          TypeError: si target_class no es un dataclass.
      """
      try:
          all_fields = dc_fields(target_class)  # raises TypeError if not dataclass
      except TypeError:
          raise TypeError(f"config_from_env: {target_class.__name__} is not a dataclass")

      kwargs: dict = {}
      errors: list[str] = []

      for f in all_fields:
          meta = f.metadata
          env_key: str | None = meta.get("env")
          if env_key is None:
              # no env tag → leave at dataclass default (will be provided via default_factory)
              continue

          required: bool = meta.get("required", False)
          default_str: str | None = meta.get("default")

          raw = os.environ.get(env_key, "")
          if not raw:
              if default_str is not None:
                  raw = default_str
              elif required:
                  errors.append(f"field '{f.name}': env var '{env_key}' is required but not set")
                  continue
              else:
                  continue  # optional, unset — use dataclass default

          # type conversion
          try:
              kwargs[f.name] = _coerce(raw, f.type, f.name)
          except (ValueError, TypeError) as exc:
              errors.append(str(exc))

      if errors:
          raise ValueError("config_from_env: " + "; ".join(errors))

      return target_class(**kwargs)


  def _coerce(raw: str, annotation: type | str, field_name: str) -> object:
      """Coerce raw string to the annotated type."""
      # handle string annotations (from __future__ import annotations or forward refs)
      if isinstance(annotation, str):
          ann_str = annotation.lower().strip()
      else:
          # Get the type name for comparison
          ann_str = getattr(annotation, "__name__", str(annotation)).lower()

      if ann_str in ("str", "string"):
          return raw

      if ann_str == "int":
          try:
              return int(raw)
          except ValueError:
              raise ValueError(f"field '{field_name}': cannot convert {raw!r} to int")

      if ann_str == "float":
          try:
              return float(raw)
          except ValueError:
              raise ValueError(f"field '{field_name}': cannot convert {raw!r} to float")

      if ann_str == "bool":
          return raw.strip().lower() in ("1", "true", "yes")

      # list / list[str]
      if "list" in ann_str:
          parts = [p.strip() for p in raw.split(",") if p.strip()]
          return parts

      # fallback: return as string
      return raw

params_schema:
  {"params":[{"name":"target_class","desc":"clase dataclass con field metadata declarando env, required, default y/o secret por cada campo"}],"output":"instancia del dataclass con todos los campos poblados desde env o defaults"}


id: dotenv_load_py_infra
name: dotenv_load
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: dotenv_load(path: str) -> None
tags: ["dotenv","env","config","os","infra","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["os"]
test_file_path: python/functions/infra/dotenv_load_test.py
file_path: python/functions/infra/dotenv_load.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: eb29781d9455894a2136708c8496ded3af1a2da5f81cc37fabfd0357f37b5301
description:
  Parsea un archivo .env (KEY=VALUE) e inyecta pares en os.environ. Ignora comentarios # y lineas vacias. No sobreescribe variables ya presentes. Soporta valores con comillas simples o dobles.

example:
  ```python
  from dotenv_load import dotenv_load
  import os

  dotenv_load(".env")
  db_url = os.environ["DATABASE_URL"]
  ```

tests:
  ["parsea key value y setea variable","ignora comentarios y lineas vacias","no sobreescribe variables existentes","valores con comillas dobles se stripean","valores con comillas simples se stripean","archivo inexistente lanza error","linea sin igual lanza value error"]

notes:
  Solo stdlib (os). Semantica de no-sobreescritura: variables inyectadas por Docker/CI/CD tienen precedencia sobre el .env local. Separador es el primer '=' encontrado en la linea (permite valores con '='). Formato: KEY=VALUE, KEY="val", KEY='val'.

code:
  """dotenv_load — parsea un archivo .env y popula os.environ sin sobreescribir."""

  import os


  def dotenv_load(path: str) -> None:
      """Parsea un archivo .env y setea variables en os.environ.

      Reglas de parseo:
        - Lineas que empiezan con # (tras strip) son comentarios y se ignoran.
        - Lineas vacias se ignoran.
        - Formato esperado: KEY=VALUE (separador es el primer =).
        - Valores con comillas simples o dobles externas se stripean.
        - Variables ya presentes en os.environ NO se sobreescriben.

      Args:
          path: ruta al archivo .env.

      Returns:
          None

      Raises:
          FileNotFoundError: si el archivo no existe.
          ValueError: si una linea no tiene el separador '='.
      """
      with open(path, encoding="utf-8") as fh:
          for lineno, raw_line in enumerate(fh, start=1):
              line = raw_line.strip()

              # skip blanks and comments
              if not line or line.startswith("#"):
                  continue

              if "=" not in line:
                  raise ValueError(
                      f"dotenv_load: {path!r} line {lineno}: missing '=' in {line!r}"
                  )

              key, _, val = line.partition("=")
              key = key.strip()
              val = _strip_quotes(val)

              # only set if not already present
              if key not in os.environ or os.environ[key] == "":
                  os.environ[key] = val


  def _strip_quotes(s: str) -> str:
      """Remove surrounding single or double quotes."""
      if len(s) >= 2:
          if (s[0] == '"' and s[-1] == '"') or (s[0] == "'" and s[-1] == "'"):
              return s[1:-1]
      return s

params_schema:
  {"params":[{"name":"path","desc":"ruta al archivo .env a parsear (ej: '.env', 'config/.env.production')"}],"output":"None — efecto lateral: variables seteadas en os.environ"}


id: email_build_html_py_infra
name: email_build_html
kind: function
lang: py
domain: infra
version: 1.0.0
purity: pure
signature: email_build_html(from_addr: str, to: list[str], subject: str, body_html: str) -> EmailMessagePy
tags: ["email","html","builder","pure","python","dataclass"]
uses_functions: []
uses_types: []
returns: []
imports: ["dataclasses"]
test_file_path: python/functions/infra/email_build_html_test.py
file_path: python/functions/infra/email_build_html.py
created_at: 2026-04-13T00:03:20Z
updated_at: 2026-04-13T00:03:20Z
props: []
emits: []
variant: []
content_hash: 0233d96c234c16cbcc8091cc5cbaa92f80b0fc214c66a4f1ed366568c28bec92
description:
  Construye un EmailMessagePy inmutable con cuerpo HTML. El campo body_text queda vacio. Funcion pura sin efectos secundarios.

example:
  ```python
  from infra.email_build_html import email_build_html

  msg = email_build_html(
      from_addr="alice@example.com",
      to=["bob@example.com", "carol@example.com"],
      subject="Reporte mensual",
      body_html="<h1>Hola</h1><p>Ver adjunto.</p>",
  )
  assert msg.body_html == "<h1>Hola</h1><p>Ver adjunto.</p>"
  assert msg.body_text == ""
  assert isinstance(msg.to, tuple)
  ```

tests:
  ["construye mensaje html con campos correctos","convierte lista to a tupla inmutable","body_text queda vacio"]

notes:
  Funcion pura. `EmailMessagePy` es un dataclass frozen — inmutable por construccion. La lista `to` se convierte a tupla para evitar mutacion externa. Para usar con smtp_send, pasar los campos del mensaje como argumentos individuales.

code:
  """Construccion de EmailMessage Python con cuerpo HTML — funcion pura."""

  from dataclasses import dataclass, field


  @dataclass(frozen=True)
  class EmailMessagePy:
      """Mensaje de email inmutable listo para enviar.

      Construir con email_build_html() o email_build_text().
      """
      from_addr: str
      to: tuple[str, ...]
      subject: str
      body_html: str = ""
      body_text: str = ""
      cc: tuple[str, ...] = field(default_factory=tuple)
      bcc: tuple[str, ...] = field(default_factory=tuple)
      headers: tuple[tuple[str, str], ...] = field(default_factory=tuple)


  def email_build_html(
      from_addr: str,
      to: list[str],
      subject: str,
      body_html: str,
  ) -> EmailMessagePy:
      """Construye un EmailMessagePy con cuerpo HTML.

      Funcion pura — no tiene efectos secundarios. El slice `to` se convierte a
      tupla para garantizar inmutabilidad.

      Args:
          from_addr: Direccion del remitente.
          to: Lista de destinatarios principales.
          subject: Asunto del correo.
          body_html: Contenido HTML del cuerpo del mensaje.

      Returns:
          EmailMessagePy inmutable con cuerpo HTML. body_text queda vacio.
      """
      return EmailMessagePy(
          from_addr=from_addr,
          to=tuple(to),
          subject=subject,
          body_html=body_html,
          body_text="",
      )

params_schema:
  {"params":[{"name":"from_addr","desc":"direccion del remitente (ej: 'alice@example.com')"},{"name":"to","desc":"lista de direcciones de destinatarios principales"},{"name":"subject","desc":"asunto del correo"},{"name":"body_html","desc":"contenido HTML del cuerpo del mensaje"}],"output":"EmailMessagePy inmutable con from_addr, to (como tupla), subject y body_html; body_text vacio"}


id: get_logger_py_infra
name: get_logger
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def get_logger(name: str = 'app') -> logging.Logger
tags: ["logging","logger","infra","utility"]
uses_functions: ["setup_logger_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["logging"]
tests: ["get_logger retorna logger configurado"]
test_file_path: python/functions/infra/setup_logger_test.py
file_path: python/functions/infra/setup_logger.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 1126594db99d9176dc244c6c1e7cd15fb7a2a0caee2da73de9f1328118cdd03f
description:
  Devuelve un logger existente si ya tiene handlers, o lo crea con setup_logger. Util en modulos internos que no controlan la inicializacion del logger.

example:
  ```python
  from setup_logger import get_logger

  # En cualquier modulo, sin preocuparse de si el logger ya fue inicializado
  log = get_logger("mi_app")
  log.info("Mensaje desde un modulo interno")
  ```

notes:
  Companion de `setup_logger`. Si el logger tiene handlers (ya fue configurado), lo devuelve tal cual. Si no, llama a `setup_logger` con valores por defecto (log_dir="logs"). Comparten el mismo archivo de implementacion.

code:
  """Configuracion de logger con rotacion de archivo y salida a consola."""

  import logging
  import logging.handlers
  import os
  import sys
  from datetime import datetime


  def setup_logger(
      name: str = "app",
      log_dir: str = "logs",
      level: int = logging.DEBUG,
  ) -> logging.Logger:
      """Configura un logger con dual output: archivo rotante y consola.

      Crea el directorio de logs si no existe. El archivo usa nivel DEBUG con
      formato detallado y rotacion diaria (maxBytes=10MB, backupCount=5).
      La consola usa nivel INFO con formato simplificado. Es idempotente: si el
      logger ya tiene handlers no se duplican.

      Args:
          name: Nombre del logger (identifica la instancia en el sistema de logging).
          log_dir: Directorio donde se guardan los archivos de log.
          level: Nivel minimo del logger principal (por defecto DEBUG).

      Returns:
          Logger configurado con handler de archivo y handler de consola.
      """
      os.makedirs(log_dir, exist_ok=True)

      logger = logging.getLogger(name)
      logger.setLevel(level)
      logger.propagate = False

      # Idempotente: si ya tiene handlers no agregar mas
      if logger.handlers:
          return logger

      fmt_detailed = logging.Formatter(
          "[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s"
      )
      fmt_simple = logging.Formatter(
          "[%(asctime)s] %(levelname)s: %(message)s"
      )

      # File handler con rotacion por tamano
      log_filename = os.path.join(log_dir, f"{datetime.now():%Y-%m-%d}.log")
      file_handler = logging.handlers.RotatingFileHandler(
          log_filename,
          maxBytes=10 * 1024 * 1024,  # 10 MB
          backupCount=5,
          encoding="utf-8",
      )
      file_handler.setLevel(logging.DEBUG)
      file_handler.setFormatter(fmt_detailed)

      # Console handler
      if sys.platform == "win32":
          try:
              sys.stdout.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[attr-defined]
          except AttributeError:
              pass

      console_handler = logging.StreamHandler(sys.stdout)
      console_handler.setLevel(logging.INFO)
      console_handler.setFormatter(fmt_simple)

      logger.addHandler(file_handler)
      logger.addHandler(console_handler)

      return logger


  def get_logger(name: str = "app") -> logging.Logger:
      """Devuelve un logger existente o lo crea con setup_logger.

      Args:
          name: Nombre del logger.

      Returns:
          Logger configurado.
      """
      logger = logging.getLogger(name)
      return logger if logger.handlers else setup_logger(name)

params_schema:
  {"params":[{"name":"name","desc":"nombre del logger a obtener o crear"}],"output":"instancia logging.Logger configurada con handlers"}


id: http_download_file_py_infra
name: http_download_file
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["http","download","file","streaming","network","stdlib","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["os","urllib.error","urllib.request"]
test_file_path: python/functions/infra/http_download_file_test.py
file_path: python/functions/infra/http_download_file.py
created_at: 2026-04-05T11:24:33Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 152aa86c46a150bb6f9072c0ab0a52f59a6189512d1cdec188347b3fe204cd59
signature:
  http_download_file(url: str, dest_path: str, headers: dict[str, str] | None = None, timeout: float = 120.0, chunk_size: int = 8192) -> dict

description:
  Descarga un archivo por HTTP en streaming (sin cargar todo en memoria). Crea directorios intermedios si no existen. Retorna dict con path, size_bytes y content_type.

example:
  ```python
  result = http_download_file(
      "https://example.com/report.pdf",
      dest_path="/tmp/reports/report.pdf",
      timeout=60.0,
  )
  print(f"Downloaded {result['size_bytes']} bytes to {result['path']}")
  ```

tests:
  ["mock de descarga con contenido binario","directorio destino creado automaticamente","retorno con size correcto","timeout configurado en el request"]

notes:
  Solo usa stdlib (urllib, os). La descarga se hace en chunks de `chunk_size` bytes para evitar consumo de memoria con archivos grandes. El timeout de 120s por defecto es mayor que http_get_json porque los archivos pueden ser pesados. Los directorios intermedios se crean con os.makedirs(exist_ok=True).

code:
  """Descarga de archivos en streaming — HTTP client sin dependencias externas."""

  import os
  import urllib.error
  import urllib.request


  def http_download_file(
      url: str,
      dest_path: str,
      headers: dict[str, str] | None = None,
      timeout: float = 120.0,
      chunk_size: int = 8192,
  ) -> dict:
      """Descarga un archivo por HTTP en streaming (sin cargar todo en memoria).

      Crea los directorios intermedios si no existen. Si el archivo destino
      ya existe lo sobreescribe. La descarga se hace en chunks para evitar
      consumo de memoria excesivo con archivos grandes.

      Args:
          url: URL del archivo a descargar.
          dest_path: Ruta local destino donde guardar el archivo.
          headers: Headers HTTP adicionales.
          timeout: Segundos maximo de espera para la conexion (default 120).
          chunk_size: Tamano de cada chunk en bytes (default 8192).

      Returns:
          dict con campos ``path`` (str), ``size_bytes`` (int) y
          ``content_type`` (str).

      Raises:
          RuntimeError: Si el status HTTP es >= 400.
      """
      req = urllib.request.Request(url, headers=headers or {}, method="GET")

      os.makedirs(os.path.dirname(os.path.abspath(dest_path)), exist_ok=True)

      try:
          with urllib.request.urlopen(req, timeout=timeout) as resp:
              content_type: str = resp.headers.get("Content-Type", "")
              size_bytes = 0
              with open(dest_path, "wb") as f:
                  while True:
                      chunk = resp.read(chunk_size)
                      if not chunk:
                          break
                      f.write(chunk)
                      size_bytes += len(chunk)
      except urllib.error.HTTPError as e:
          short_url = url[:100] if len(url) > 100 else url
          raise RuntimeError(
              f"http_download_file: HTTP {e.code} at {short_url!r}"
          ) from e

      return {
          "path": dest_path,
          "size_bytes": size_bytes,
          "content_type": content_type,
      }

params_schema:
  {"params":[{"name":"url","desc":"URL del archivo a descargar"},{"name":"dest_path","desc":"ruta destino donde guardar el archivo descargado"},{"name":"headers","desc":"diccionario opcional de headers HTTP personalizados"},{"name":"timeout","desc":"timeout en segundos para la descarga (defecto 120.0)"},{"name":"chunk_size","desc":"tamaño en bytes de cada chunk descargado (defecto 8192)"}],"output":"diccionario con keys path, size_bytes y content_type"}


id: http_get_json_py_infra
name: http_get_json
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["http","json","get","client","network","stdlib","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["json","urllib.error","urllib.parse","urllib.request"]
test_file_path: python/functions/infra/http_get_json_test.py
file_path: python/functions/infra/http_get_json.py
created_at: 2026-04-05T11:24:20Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: f7ff20b03c6235f315f20052c79857aa65478182cbb72c82c4fbbacd80a2f037
signature:
  http_get_json(url: str, headers: dict[str, str] | None = None, params: dict[str, str] | None = None, timeout: float = 30.0) -> dict

description:
  GET request que espera JSON. Agrega Accept: application/json automaticamente. Lanza RuntimeError si status >= 400 con status code, url truncada y primeros 200 chars del body.

example:
  ```python
  data = http_get_json(
      "https://api.example.com/users",
      params={"page": "1", "limit": "50"},
      headers={"X-Api-Key": "secret"},
  )
  print(data["total"])
  ```

tests:
  ["mock de respuesta 200 con JSON","mock de respuesta 404 → error con status code","mock de respuesta con JSON invalido → error descriptivo","params serializados como query string","headers custom enviados"]

notes:
  Solo usa stdlib (urllib). Sin dependencias externas. El error incluye los primeros 200 chars del body para facilitar debugging en produccion. Params se serializa con urlencode antes de concatenar a la URL.

code:
  """GET request JSON — HTTP client sin dependencias externas."""

  import json
  import urllib.error
  import urllib.parse
  import urllib.request


  def http_get_json(
      url: str,
      headers: dict[str, str] | None = None,
      params: dict[str, str] | None = None,
      timeout: float = 30.0,
  ) -> dict:
      """Realiza un GET request y parsea la respuesta como JSON.

      Agrega automaticamente el header ``Accept: application/json``.
      Si el status es >= 400 lanza RuntimeError con status code, url y
      los primeros 200 caracteres del body para facilitar el debugging.

      Args:
          url: URL del endpoint.
          headers: Headers HTTP adicionales. Se fusionan con Accept por defecto.
          params: Query string params. Se serializa con urllib.parse.urlencode.
          timeout: Segundos maximo de espera (default 30).

      Returns:
          Respuesta parseada como dict o list.

      Raises:
          RuntimeError: Si status >= 400 o si el body no es JSON valido.
      """
      if params:
          url = f"{url}?{urllib.parse.urlencode(params)}"

      all_headers: dict[str, str] = {"Accept": "application/json"}
      if headers:
          all_headers.update(headers)

      req = urllib.request.Request(url, headers=all_headers, method="GET")

      try:
          with urllib.request.urlopen(req, timeout=timeout) as resp:
              raw = resp.read()
      except urllib.error.HTTPError as e:
          body_preview = e.read(200).decode("utf-8", errors="replace")
          short_url = url[:100] if len(url) > 100 else url
          raise RuntimeError(
              f"http_get_json: HTTP {e.code} at {short_url!r} — {body_preview}"
          ) from e

      try:
          return json.loads(raw)
      except json.JSONDecodeError as e:
          preview = raw[:200].decode("utf-8", errors="replace")
          raise RuntimeError(
              f"http_get_json: response is not valid JSON — {preview}"
          ) from e

params_schema:
  {"params":[{"name":"url","desc":"URL base para la solicitud GET"},{"name":"headers","desc":"diccionario opcional de headers HTTP personalizados"},{"name":"params","desc":"diccionario opcional de parametros de query"},{"name":"timeout","desc":"timeout en segundos para la solicitud (defecto 30.0)"}],"output":"diccionario con la respuesta JSON parseada"}


id: http_post_json_py_infra
name: http_post_json
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["http","json","post","client","network","stdlib","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["json","urllib.error","urllib.request"]
test_file_path: python/functions/infra/http_post_json_test.py
file_path: python/functions/infra/http_post_json.py
created_at: 2026-04-05T11:24:27Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 95810188f02ce1e2c6ac0cbb861be53fd2a7f7ee2553133a95a640212dbd06fa
signature:
  http_post_json(url: str, body: dict, headers: dict[str, str] | None = None, timeout: float = 30.0) -> dict

description:
  POST request con body JSON. Agrega Content-Type: application/json y Accept: application/json. Lanza RuntimeError si status >= 400 con status code, url truncada y primeros 200 chars del body.

example:
  ```python
  response = http_post_json(
      "https://api.example.com/users",
      body={"name": "Alice", "role": "admin"},
      headers={"X-Api-Key": "secret"},
  )
  print(response["id"])
  ```

tests:
  ["mock de POST con body serializado correctamente","mock de respuesta 201","mock de respuesta 500 → error","body con unicode"]

notes:
  Solo usa stdlib (urllib). El body se serializa con json.dumps(ensure_ascii=False) y se codifica a UTF-8. Headers custom se fusionan con Content-Type y Accept por defecto (los custom tienen precedencia).

code:
  """POST request JSON — HTTP client sin dependencias externas."""

  import json
  import urllib.error
  import urllib.request


  def http_post_json(
      url: str,
      body: dict,
      headers: dict[str, str] | None = None,
      timeout: float = 30.0,
  ) -> dict:
      """Realiza un POST request con body JSON y parsea la respuesta como JSON.

      Agrega automaticamente ``Content-Type: application/json`` y
      ``Accept: application/json``. Si el status es >= 400 lanza RuntimeError
      con status code, url y los primeros 200 caracteres del body.

      Args:
          url: URL del endpoint.
          body: Datos a serializar como JSON en el cuerpo del request.
          headers: Headers HTTP adicionales. Se fusionan con los defaults.
          timeout: Segundos maximo de espera (default 30).

      Returns:
          Respuesta parseada como dict o list.

      Raises:
          RuntimeError: Si status >= 400 o si el body de respuesta no es JSON valido.
      """
      all_headers: dict[str, str] = {
          "Content-Type": "application/json",
          "Accept": "application/json",
      }
      if headers:
          all_headers.update(headers)

      data = json.dumps(body, ensure_ascii=False).encode("utf-8")
      req = urllib.request.Request(url, data=data, headers=all_headers, method="POST")

      try:
          with urllib.request.urlopen(req, timeout=timeout) as resp:
              raw = resp.read()
      except urllib.error.HTTPError as e:
          body_preview = e.read(200).decode("utf-8", errors="replace")
          short_url = url[:100] if len(url) > 100 else url
          raise RuntimeError(
              f"http_post_json: HTTP {e.code} at {short_url!r} — {body_preview}"
          ) from e

      try:
          return json.loads(raw)
      except json.JSONDecodeError as e:
          preview = raw[:200].decode("utf-8", errors="replace")
          raise RuntimeError(
              f"http_post_json: response is not valid JSON — {preview}"
          ) from e

params_schema:
  {"params":[{"name":"url","desc":"URL destino para la solicitud POST"},{"name":"body","desc":"diccionario a serializar como JSON en el body"},{"name":"headers","desc":"diccionario opcional de headers HTTP personalizados"},{"name":"timeout","desc":"timeout en segundos para la solicitud (defecto 30.0)"}],"output":"diccionario con la respuesta JSON parseada"}


id: nominatim_reverse_geocode_py_infra
name: nominatim_reverse_geocode
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def nominatim_reverse_geocode(lat: float, lon: float, lang: str = 'es') -> dict
tags: ["geocoding","nominatim","openstreetmap","osm","reverse-geocoding","geo","location","address"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["urllib.request","urllib.parse","json"]
tests: []
file_path: python/functions/infra/nominatim_reverse_geocode.py
created_at: 2026-04-06T16:22:51Z
updated_at: 2026-04-06T16:22:51Z
props: []
emits: []
variant: []
content_hash: 738188be0e84ba6552b7f93ba0ae49d5270daf857247a9b7e7e6a1e46f72797b
description:
  Reverse geocoding usando Nominatim (OpenStreetMap). Convierte coordenadas lat/lon a una dirección estructurada con calle, ciudad, país y otros campos normalizados.

example:
  ```python
  from infra.nominatim_reverse_geocode import nominatim_reverse_geocode

  addr = nominatim_reverse_geocode(40.4168, -3.7038)
  # {
  #   "display_name": "Puerta del Sol, Sol, Madrid, ...",
  #   "street": "Puerta del Sol",
  #   "house_number": "",
  #   "neighbourhood": "Sol",
  #   "city": "Madrid",
  #   "state": "Comunidad de Madrid",
  #   "country": "España",
  #   "postcode": "28013",
  #   "lat": 40.4168,
  #   "lon": -3.7038,
  #   "osm_type": "way",
  #   "osm_id": 12345678
  # }

  # Con idioma inglés
  addr_en = nominatim_reverse_geocode(48.8566, 2.3522, lang="en")
  # {"city": "Paris", "country": "France", ...}
  ```

notes:
  - Usa `urllib.request` de stdlib — sin dependencias externas.
  - El header `User-Agent: fn_registry/1.0` es obligatorio según la política de uso de Nominatim.
  - Timeout de 5 segundos. Para coordenadas en zonas remotas puede necesitarse más.
  - Campo `city` se resuelve con fallback: `city → town → village → ""`.
  - `lat`/`lon` del resultado son los del objeto OSM encontrado, que puede diferir ligeramente de los de entrada.
  - Nominatim tiene límite de 1 request/segundo por IP. Para uso intensivo, considerar un servidor propio.
  - Lanza `RuntimeError` en errores HTTP o de red.

code:
  """Reverse geocoding usando Nominatim (OpenStreetMap)."""

  import json
  import urllib.request
  import urllib.parse


  def nominatim_reverse_geocode(lat: float, lon: float, lang: str = "es") -> dict:
      """Convierte coordenadas lat/lon a una dirección estructurada usando Nominatim.

      Args:
          lat: Latitud en grados decimales.
          lon: Longitud en grados decimales.
          lang: Código de idioma para la respuesta (ISO 639-1). Por defecto "es".

      Returns:
          Diccionario con campos normalizados de la dirección.

      Raises:
          RuntimeError: Si la petición HTTP falla o el servidor retorna error.
      """
      params = urllib.parse.urlencode({
          "format": "jsonv2",
          "lat": str(lat),
          "lon": str(lon),
          "accept-language": lang,
          "zoom": "18",
      })
      url = f"https://nominatim.openstreetmap.org/reverse?{params}"

      req = urllib.request.Request(
          url,
          headers={"User-Agent": "fn_registry/1.0"},
      )

      try:
          with urllib.request.urlopen(req, timeout=5) as resp:
              if resp.status != 200:
                  raise RuntimeError(
                      f"nominatim_reverse_geocode: HTTP {resp.status} para lat={lat}, lon={lon}"
                  )
              data = json.loads(resp.read().decode("utf-8"))
      except urllib.error.HTTPError as e:
          raise RuntimeError(
              f"nominatim_reverse_geocode: HTTP {e.code} para lat={lat}, lon={lon}"
          ) from e
      except urllib.error.URLError as e:
          raise RuntimeError(
              f"nominatim_reverse_geocode: error de red — {e.reason}"
          ) from e

      address = data.get("address", {})

      city = (
          address.get("city")
          or address.get("town")
          or address.get("village")
          or ""
      )

      return {
          "display_name": data.get("display_name", ""),
          "street": address.get("road", ""),
          "house_number": address.get("house_number", ""),
          "neighbourhood": address.get("neighbourhood", ""),
          "city": city,
          "state": address.get("state", ""),
          "country": address.get("country", ""),
          "postcode": address.get("postcode", ""),
          "lat": float(data.get("lat", lat)),
          "lon": float(data.get("lon", lon)),
          "osm_type": data.get("osm_type", ""),
          "osm_id": int(data.get("osm_id", 0)),
      }

params_schema:
  {"params":[{"name":"lat","desc":"Latitud en grados decimales (rango -90 a 90)."},{"name":"lon","desc":"Longitud en grados decimales (rango -180 a 180)."},{"name":"lang","desc":"Código de idioma ISO 639-1 para la respuesta (ej: 'es', 'en', 'fr'). Por defecto 'es'."}],"output":"Diccionario con campos normalizados: display_name (dirección completa legible), street (calle/road), house_number, neighbourhood, city (city→town→village), state, country, postcode, lat, lon, osm_type (node/way/relation), osm_id. Campos ausentes retornan string vacío."}


id: normalize_zip_filenames_py_infra
name: normalize_zip_filenames
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def normalize_zip_filenames(zipf: zipfile.ZipFile) -> None
tags: ["zip","encoding","utf-8","cjk","mojibake","normalize","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["zipfile"]
tests: ["ZIP con nombres UTF-8 correctos no se modifican","ZIP con nombres CJK mojibake se reparan"]
test_file_path: python/functions/infra/safe_extract_zip_test.py
file_path: python/functions/infra/safe_extract_zip.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 321a617d869b803356b96299ccb034439c58256c4cbbbc7f34f58259a8d94285
description:
  Repara nombres de archivos UTF-8 en ZIPs que no tienen el flag UTF-8 seteado (0x800). Comun en archivos creados en Windows con nombres CJK (chino, japones, coreano). Detecta mojibake comparando rangos Unicode y recodifica CP437 -> UTF-8.

example:
  ```python
  import zipfile
  from normalize_zip_filenames import normalize_zip_filenames

  with zipfile.ZipFile("archivo_windows.zip", "r") as zipf:
      normalize_zip_filenames(zipf)
      for info in zipf.infolist():
          print(info.filename)  # nombres CJK correctos
  ```

notes:
  Funcion impure: modifica los `ZipInfo` del objeto ZipFile in-place.

  El flag `0x800` en `flag_bits` indica que el filename ya esta codificado en UTF-8 segun la especificacion PKZip. Si esta seteado, el nombre no se toca.

  Deteccion de CJK: rangos `\u3400-\u4dbf`, `\u4e00-\u9fff`, `\u3000-\u303f`, `\uff00-\uffef`.

  Deteccion de mojibake: rangos Greek (`\u0370-\u03ff`), Math (`\u2200-\u22ff`), Box Drawing (`\u2500-\u257f`). Estos caracteres aparecen cuando bytes UTF-8 se interpretan como CP437.

  Si se reparo algun nombre, se setea `zipf.metadata_encoding = "utf-8"`.

  El codigo fuente de ambas funciones vive en `safe_extract_zip.py`.

code:
  """Safe ZIP extraction with Zip Slip protection and filename normalization."""

  import os
  import zipfile
  from pathlib import Path


  def normalize_zip_filenames(zipf: zipfile.ZipFile) -> None:
      """Repara nombres de archivos UTF-8 en ZIPs sin el flag UTF-8 seteado.

      Args:
          zipf: Objeto ZipFile abierto en modo lectura.

      Returns:
          None. Modifica los infolist del ZipFile in-place.
      """
      def _is_cjk(s: str) -> bool:
          return any(
              "\u3400" <= c <= "\u4dbf"
              or "\u4e00" <= c <= "\u9fff"
              or "\u3000" <= c <= "\u303f"
              or "\uff00" <= c <= "\uffef"
              for c in s
          )

      def _is_mojibake(s: str) -> bool:
          return any(
              "\u0370" <= c <= "\u03ff"  # Greek
              or "\u2200" <= c <= "\u22ff"  # Math
              or "\u2500" <= c <= "\u257f"  # Box Drawing
              for c in s
          )

      repaired = False
      for info in zipf.infolist():
          # Flag 0x800 indica que el filename ya esta en UTF-8
          if info.flag_bits & 0x800:
              continue
          try:
              repaired_name = info.filename.encode("cp437").decode("utf-8")
              if _is_cjk(repaired_name) and _is_mojibake(info.filename):
                  info.filename = repaired_name
                  repaired = True
          except (UnicodeEncodeError, UnicodeDecodeError):
              pass

      if repaired:
          zipf.metadata_encoding = "utf-8"


  def safe_extract_zip(zip_path: str, dest_dir: str) -> None:
      """Extrae un archivo ZIP con proteccion contra Zip Slip (path traversal).

      Valida que cada archivo extraido quede dentro del directorio destino antes
      de extraerlo. Normaliza los nombres de archivo UTF-8 antes de extraer.

      Args:
          zip_path: Ruta al archivo ZIP a extraer.
          dest_dir: Directorio de destino para la extraccion.

      Raises:
          ValueError: Si se detecta un intento de Zip Slip (path traversal).
          zipfile.BadZipFile: Si el archivo no es un ZIP valido.
          FileNotFoundError: Si zip_path no existe.
      """
      dest = Path(dest_dir).resolve()

      with zipfile.ZipFile(zip_path, "r") as zipf:
          normalize_zip_filenames(zipf)

          for member in zipf.infolist():
              member_path = (dest / member.filename).resolve()

              # Verificar que el path resultante este dentro de dest_dir
              if not str(member_path).startswith(str(dest) + os.sep):
                  raise ValueError(
                      f"Zip Slip attempt detected: {member.filename!r} would extract to {member_path}"
                  )

              zipf.extract(member, dest)

params_schema:
  {"params":[{"name":"zipf","desc":"instancia zipfile.ZipFile abierta para reparacion de nombres UTF-8"}],"output":"None (modifica in-place los ZipInfo del objeto ZipFile)"}


id: ollama_chat_py_infra
name: ollama_chat
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["ollama","llm","chat","inference","local-ai","http"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["json","urllib.request","urllib.error"]
tests: []
file_path: python/functions/infra/ollama_chat.py
created_at: 2026-04-06T16:22:51Z
updated_at: 2026-04-06T16:22:51Z
props: []
emits: []
variant: []
content_hash: 3fc54968a2453e6ab1f87ae352e9340788b2db1b70e19e7836ab4ff24f6440bc
signature:
  def ollama_chat(messages: list[dict], model: str = 'llama3.1:8b', base_url: str = 'http://localhost:11434', temperature: float = 0.7, max_tokens: int = 1024) -> dict

description:
  Envía una solicitud de chat completion a Ollama (API local compatible con OpenAI). Retorna el contenido generado junto a métricas de duración y tokens.

example:
  ```python
  resp = ollama_chat([
      {"role": "system", "content": "Eres un guía turístico experto."},
      {"role": "user", "content": "¿Qué puedo ver cerca del Museo del Prado?"}
  ], model="llama3.1:8b")
  # {"content": "Cerca del Museo del Prado encontrarás...", "model": "llama3.1:8b", "total_duration_ms": 3200, "eval_count": 150}
  ```

notes:
  Función impura: hace una solicitud HTTP POST a la API de Ollama. Solo usa stdlib Python (urllib.request, json) — sin dependencias externas.

  La generación de LLMs puede ser lenta; el timeout está fijo en 60 segundos. Para modelos grandes o respuestas largas, ajustar `max_tokens` o considerar streaming.

  Manejo de errores:
  - Connection refused → `RuntimeError("Ollama no está corriendo en {base_url}")`
  - HTTP error → `RuntimeError("Ollama retornó HTTP {code}: {reason}")`
  - Otros errores de red → `RuntimeError("Error de conexión con Ollama: {reason}")`

  El campo `total_duration` que retorna Ollama está en nanosegundos; se convierte a milisegundos para mayor legibilidad. El campo `eval_count` puede no estar presente en todas las versiones de Ollama (default 0).

code:
  """Envía una solicitud de chat completion a Ollama (API local compatible con OpenAI)."""

  import json
  import urllib.request
  import urllib.error
  from typing import Any


  def ollama_chat(
      messages: list[dict],
      model: str = "llama3.1:8b",
      base_url: str = "http://localhost:11434",
      temperature: float = 0.7,
      max_tokens: int = 1024,
  ) -> dict:
      """Envía una solicitud de chat completion a Ollama.

      Args:
          messages: Lista de mensajes con formato {"role": "system"|"user"|"assistant", "content": str}.
          model: Nombre del modelo Ollama a usar.
          base_url: URL base de la instancia Ollama.
          temperature: Temperatura de generación (0.0 - 1.0).
          max_tokens: Número máximo de tokens a generar.

      Returns:
          Dict con content, model, total_duration_ms y eval_count.

      Raises:
          RuntimeError: Si Ollama no está corriendo o retorna error HTTP.
      """
      url = f"{base_url}/api/chat"
      payload = {
          "model": model,
          "messages": messages,
          "stream": False,
          "options": {
              "temperature": temperature,
              "num_predict": max_tokens,
          },
      }
      body = json.dumps(payload).encode("utf-8")
      req = urllib.request.Request(
          url,
          data=body,
          headers={"Content-Type": "application/json"},
          method="POST",
      )
      try:
          with urllib.request.urlopen(req, timeout=60) as resp:
              raw = resp.read()
      except urllib.error.URLError as exc:
          reason = str(exc.reason) if hasattr(exc, "reason") else str(exc)
          if "connection refused" in reason.lower() or "111" in reason:
              raise RuntimeError(f"Ollama no está corriendo en {base_url}") from exc
          raise RuntimeError(f"Error de conexión con Ollama: {reason}") from exc
      except urllib.error.HTTPError as exc:
          raise RuntimeError(f"Ollama retornó HTTP {exc.code}: {exc.reason}") from exc

      response: dict[str, Any] = json.loads(raw)
      content: str = response["message"]["content"]
      total_duration_ns: int = response.get("total_duration", 0)
      total_duration_ms: int = int(total_duration_ns / 1_000_000)
      eval_count: int = response.get("eval_count", 0)

      return {
          "content": content,
          "model": response.get("model", model),
          "total_duration_ms": total_duration_ms,
          "eval_count": eval_count,
      }

params_schema:
  {"params":[{"name":"messages","desc":"Lista de mensajes de la conversación. Cada mensaje es un dict con 'role' (system|user|assistant) y 'content' (texto del mensaje)."},{"name":"model","desc":"Nombre del modelo Ollama a usar (ej: llama3.1:8b, mistral:7b, codellama:13b)."},{"name":"base_url","desc":"URL base de la instancia Ollama local. Por defecto http://localhost:11434."},{"name":"temperature","desc":"Temperatura de generación entre 0.0 (determinista) y 1.0 (creativo). Por defecto 0.7."},{"name":"max_tokens","desc":"Número máximo de tokens a generar en la respuesta. Por defecto 1024."}],"output":"Dict con 'content' (texto generado), 'model' (modelo usado), 'total_duration_ms' (duración total en milisegundos) y 'eval_count' (tokens generados)."}


id: overpass_nearby_pois_py_infra
name: overpass_nearby_pois
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["osm","openstreetmap","overpass","poi","geolocation","nearby","geocoding","infra","http","stdlib"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["json","urllib.error","urllib.parse","urllib.request"]
tests: []
file_path: python/functions/infra/overpass_nearby_pois.py
created_at: 2026-04-06T16:23:12Z
updated_at: 2026-04-06T16:23:12Z
props: []
emits: []
variant: []
content_hash: 03a7b59fa3bf5fafa3fd68b9490372dde28fbe00c267fdc3f6c06b7e435fb360
signature:
  overpass_nearby_pois(lat: float, lon: float, radius_m: int = 500, categories: list[str] | None = None) -> list[dict]

description:
  Consulta la Overpass API (OpenStreetMap) para obtener POIs cercanos a una coordenada. Soporta 16 categorias mapeadas a tags OSM (amenity, tourism, historic, leisure, shop). Sin dependencias externas, solo stdlib.

example:
  ```python
  from infra.overpass_nearby_pois import overpass_nearby_pois

  # POIs en el centro de Madrid (radio 300m)
  pois = overpass_nearby_pois(40.4168, -3.7038, radius_m=300, categories=["museum", "monument"])
  # [{"id": 123, "lat": 40.417, "lon": -3.704, "name": "Museo del Prado", "category": "museum", "tags": {...}}, ...]

  # Todos los tipos comunes en radio 500m (defecto)
  all_pois = overpass_nearby_pois(48.8566, 2.3522)

  # Bares y restaurantes cerca de la Sagrada Familia
  food = overpass_nearby_pois(41.4036, 2.1744, radius_m=200, categories=["restaurant", "cafe", "bar"])
  ```

notes:
  Solo usa stdlib (urllib.request, urllib.parse, json). Sin dependencias externas.

  La query Overpass QL usa union de nodos `(node[...]; node[...];)` con `out body` para obtener coordenadas y tags completos.

  El endpoint publico `https://overpass-api.de/api/interpreter` tiene rate limits informales — para uso intensivo considerar un servidor Overpass propio o la instancia de kumi.systems.

  El campo `category` refleja la primera coincidencia en el diccionario interno `_CATEGORY_TAGS`, que sigue el orden de insercion de Python 3.7+. Si un nodo tiene multiples tags relevantes (ej: un bar que tambien es restaurante), la categoria asignada sera la primera que matchee.

  Timeout hardcodeado a 10 segundos tanto en la directiva Overpass QL `[timeout:10]` como en `urlopen(timeout=10)`.

documentation:
  ## Mapeo de categorias a tags OSM

  | Categoria   | Tag OSM              |
  |-------------|----------------------|
  | restaurant  | amenity=restaurant   |
  | cafe        | amenity=cafe         |
  | bar         | amenity=bar          |
  | museum      | tourism=museum       |
  | monument    | historic=monument    |
  | park        | leisure=park         |
  | library     | amenity=library      |
  | theatre     | amenity=theatre      |
  | cinema      | amenity=cinema       |
  | gallery     | tourism=gallery      |
  | historic    | historic=*           |
  | tourism     | tourism=*            |
  | shop        | shop=*               |
  | hotel       | tourism=hotel        |
  | pharmacy    | amenity=pharmacy     |
  | hospital    | amenity=hospital     |

code:
  """Consulta la Overpass API para obtener POIs cercanos a una coordenada."""

  import json
  import urllib.error
  import urllib.parse
  import urllib.request

  # Mapeo de categoría legible → (key OSM, value OSM)
  # None como value significa wildcard (key=*)
  _CATEGORY_TAGS: dict[str, tuple[str, str | None]] = {
      "restaurant": ("amenity", "restaurant"),
      "cafe": ("amenity", "cafe"),
      "bar": ("amenity", "bar"),
      "museum": ("tourism", "museum"),
      "monument": ("historic", "monument"),
      "park": ("leisure", "park"),
      "library": ("amenity", "library"),
      "theatre": ("amenity", "theatre"),
      "cinema": ("amenity", "cinema"),
      "gallery": ("tourism", "gallery"),
      "historic": ("historic", None),
      "tourism": ("tourism", None),
      "shop": ("shop", None),
      "hotel": ("tourism", "hotel"),
      "pharmacy": ("amenity", "pharmacy"),
      "hospital": ("amenity", "hospital"),
  }

  _DEFAULT_CATEGORIES = [
      "restaurant", "cafe", "bar", "museum", "monument", "park",
      "library", "theatre", "cinema", "gallery", "historic", "tourism", "shop",
  ]

  _OVERPASS_ENDPOINT = "https://overpass-api.de/api/interpreter"


  def _build_query(lat: float, lon: float, radius_m: int, categories: list[str]) -> str:
      """Construye una query Overpass QL con union de nodos."""
      lines = ["[out:json][timeout:10];", "("]
      for cat in categories:
          if cat not in _CATEGORY_TAGS:
              continue
          key, value = _CATEGORY_TAGS[cat]
          if value is None:
              lines.append(f'  node["{key}"](around:{radius_m},{lat},{lon});')
          else:
              lines.append(f'  node["{key}"="{value}"](around:{radius_m},{lat},{lon});')
      lines.append(");")
      lines.append("out body;")
      return "\n".join(lines)


  def _extract_category(tags: dict) -> str:
      """Determina la categoría legible a partir de los tags OSM del nodo."""
      for cat, (key, value) in _CATEGORY_TAGS.items():
          if key not in tags:
              continue
          if value is None or tags[key] == value:
              return cat
      return "unknown"


  def overpass_nearby_pois(
      lat: float,
      lon: float,
      radius_m: int = 500,
      categories: list[str] | None = None,
  ) -> list[dict]:
      """Obtiene POIs cercanos a una coordenada usando la Overpass API.

      Args:
          lat: latitud del punto central.
          lon: longitud del punto central.
          radius_m: radio de busqueda en metros (defecto 500).
          categories: lista de categorias a buscar. Si es None, usa todas las
              categorias comunes definidas internamente.

      Returns:
          lista de dicts con id, lat, lon, name, category y tags de cada POI.

      Raises:
          RuntimeError: si la request falla por error HTTP, timeout u otro error de red.
      """
      active_categories = categories if categories is not None else _DEFAULT_CATEGORIES
      query = _build_query(lat, lon, radius_m, active_categories)

      encoded_query = urllib.parse.quote(query)
      form_body = f"data={encoded_query}".encode("utf-8")
      req = urllib.request.Request(
          _OVERPASS_ENDPOINT,
          data=form_body,
          method="POST",
          headers={"Content-Type": "application/x-www-form-urlencoded"},
      )

      try:
          with urllib.request.urlopen(req, timeout=10) as response:
              raw = response.read()
      except urllib.error.HTTPError as e:
          body_preview = e.read(200).decode("utf-8", errors="replace")
          raise RuntimeError(
              f"Overpass API HTTP error {e.code} for ({lat},{lon}) r={radius_m}m: {body_preview}"
          ) from e
      except urllib.error.URLError as e:
          raise RuntimeError(
              f"Overpass API request failed for ({lat},{lon}) r={radius_m}m: {e.reason}"
          ) from e

      try:
          result = json.loads(raw)
      except json.JSONDecodeError as e:
          raise RuntimeError(
              f"Overpass API returned invalid JSON for ({lat},{lon}): {e}"
          ) from e

      pois: list[dict] = []
      for element in result.get("elements", []):
          if element.get("type") != "node":
              continue
          tags = element.get("tags", {})
          category = _extract_category(tags)
          # Nombre: tag "name", o valor del tag de categoría, o "Unknown"
          if "name" in tags:
              name = tags["name"]
          else:
              key = _CATEGORY_TAGS.get(category, (None, None))[0]
              name = tags.get(key, "Unknown") if key else "Unknown"

          pois.append({
              "id": element["id"],
              "lat": element["lat"],
              "lon": element["lon"],
              "name": name,
              "category": category,
              "tags": tags,
          })

      return pois

params_schema:
  {"params":[{"name":"lat","desc":"latitud del punto central de busqueda (grados decimales, -90 a 90)"},{"name":"lon","desc":"longitud del punto central de busqueda (grados decimales, -180 a 180)"},{"name":"radius_m","desc":"radio de busqueda en metros alrededor del punto central (defecto 500)"},{"name":"categories","desc":"lista de categorias a buscar entre: restaurant, cafe, bar, museum, monument, park, library, theatre, cinema, gallery, historic, tourism, shop, hotel, pharmacy, hospital. Si es None, usa todas las categorias comunes excepto hotel, pharmacy, hospital"}],"output":"lista de dicts con campos id (int), lat (float), lon (float), name (str), category (str) y tags (dict con todos los tags OSM del nodo)"}


id: pdf_add_header_footer_py_infra
name: pdf_add_header_footer
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def pdf_add_header_footer(doc, header_text, footer_text, page_numbers) -> PDFDoc
tags: ["pdf","header","footer","pagination","builder","infra"]
uses_functions: []
uses_types: ["pdf_doc_py_infra"]
returns: ["pdf_doc_py_infra"]
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/infra/pdf_add_header_footer.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: aba2294e1bb1b77b94244dbc94450ff3e45f361658c9be6331bdea0f97f13bb8
description:
  Configura header y/o footer recurrente para todas las paginas del documento. Debe llamarse antes de pdf_add_page. El footer acepta {n} y {total} como placeholders para numeracion automatica.

example:
  ```python
  doc = pdf_create(title="Reporte Mensual")

  # Header con nombre del reporte, footer con numeracion
  doc = pdf_add_header_footer(
      doc,
      header_text="Reporte de Ejecuciones — Abril 2026",
      footer_text="Pagina {n} de {total}",
      page_numbers=True,
  )

  # Las paginas añadidas despues tendran header y footer
  doc = pdf_add_page(doc)
  doc = pdf_add_text(doc, "Contenido...")
  ```

notes:
  Internamente configura los campos `header_text`, `footer_text` y `page_numbers` del PDFDoc. La subclase `_PDFWithHeaderFooter` de `pdf_create` lee estos campos en sus metodos `header()` y `footer()` que fpdf2 llama automaticamente en cada `add_page()`. El alias `{nb}` de fpdf2 se activa via `alias_nb_pages` y fpdf2 lo reemplaza al renderizar el PDF final. Debe llamarse antes de cualquier `pdf_add_page` para que el header/footer se aplique a todas las paginas.

code:
  """pdf_add_header_footer — configura header/footer recurrente con numeracion de pagina."""

  import sys
  import os

  _types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
  sys.path.insert(0, _types_dir)
  from pdf_doc import PDFDoc


  def pdf_add_header_footer(
      doc: PDFDoc,
      header_text: str = "",
      footer_text: str = "",
      page_numbers: bool = True,
  ) -> PDFDoc:
      """Configura header y/o footer recurrente para todas las paginas del documento.

      El header/footer se aplicara automaticamente en cada pagina nueva.
      Debe llamarse ANTES de añadir paginas con pdf_add_page para que surta efecto.
      El footer acepta {n} (numero de pagina actual) y {total} (total de paginas).

      Args:
          doc: PDFDoc inicializado con pdf_create.
          header_text: texto a mostrar en el header de cada pagina. Vacio = sin header.
          footer_text: texto del footer. Acepta {n} y {total} como placeholders.
                       Si page_numbers=True y footer_text esta vacio, usa 'Pagina {n} de {total}'.
          page_numbers: si True incluye numeracion automatica en el footer.

      Returns:
          PDFDoc con la configuracion de header/footer establecida.
      """
      doc.header_text = header_text
      doc.footer_text = footer_text
      doc.page_numbers = page_numbers

      # Activar alias de paginas totales en fpdf2 para {nb}
      if page_numbers:
          doc.fpdf.alias_nb_pages("{nb}")

      return doc

params_schema:
  {"params":[{"name":"doc","desc":"PDFDoc inicializado con pdf_create, sin paginas aun (llamar antes de pdf_add_page)"},{"name":"header_text","desc":"texto centrado en el header de cada pagina; vacio deshabilita el header"},{"name":"footer_text","desc":"texto del footer; soporta {n} = numero de pagina y {total} = total de paginas"},{"name":"page_numbers","desc":"si True activa numeracion automatica; si footer_text esta vacio usa 'Pagina {n} de {total}'"}],"output":"PDFDoc con la configuracion de header/footer guardada; se aplica en cada pdf_add_page posterior"}


id: pdf_add_image_py_infra
name: pdf_add_image
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def pdf_add_image(doc, image, x, y, width, height, keep_aspect) -> PDFDoc
tags: ["pdf","image","embed","builder","infra"]
uses_functions: []
uses_types: ["pdf_doc_py_infra"]
returns: ["pdf_doc_py_infra"]
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/infra/pdf_add_image.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 1a978b318c5cc978098bae75548ed811a0514eab4e8e728dc6879064038c9e5b
description:
  Añade una imagen PNG o JPEG al documento PDF desde un path de archivo o bytes. Calcula automaticamente la dimension faltante si solo se especifica width o height para mantener la relacion de aspecto.

example:
  ```python
  # Imagen desde path con ancho fijo (height automatico)
  doc = pdf_add_image(doc, "assets/logo.png", x=15, y=10, width=40)

  # Imagen desde bytes
  with open("chart.png", "rb") as f:
      img_bytes = f.read()
  doc = pdf_add_image(doc, img_bytes, width=120)

  # Imagen en posicion actual del cursor
  doc = pdf_add_image(doc, "diagrama.png", width=fpdf_doc.fpdf.epw)
  ```

notes:
  Soporta PNG y JPEG nativamente en fpdf2. Para bytes, usa `io.BytesIO` como wrapper. fpdf2 gestiona automaticamente la relacion de aspecto cuando solo se especifica una dimension (fpdf calcula la otra si `h=0`). El cursor se mueve 5mm debajo de la imagen tras insertarla. Para SVG necesitaria `fpdf2[svg]` como extra opcional.

code:
  """pdf_add_image — añade una imagen al documento PDF desde path o bytes."""

  import io
  import sys
  import os

  _types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
  sys.path.insert(0, _types_dir)
  from pdf_doc import PDFDoc


  def pdf_add_image(
      doc: PDFDoc,
      image: str | bytes,
      x: float | None = None,
      y: float | None = None,
      width: float | None = None,
      height: float | None = None,
      keep_aspect: bool = True,
  ) -> PDFDoc:
      """Añade una imagen al documento PDF desde un path de archivo o bytes.

      Si solo se especifica width, la altura se calcula manteniendo el ratio.
      Si solo se especifica height, el ancho se calcula manteniendo el ratio.
      Si ninguno se especifica, se usa el ancho disponible entre margenes.

      Args:
          doc: PDFDoc con pagina activa.
          image: path absoluto o relativo a la imagen, o bytes del contenido.
          x: posicion X en mm. None usa la posicion actual del cursor.
          y: posicion Y en mm. None usa la posicion actual del cursor.
          width: ancho de la imagen en mm. None calcula desde height o usa ancho disponible.
          height: alto de la imagen en mm. None calcula desde width.
          keep_aspect: si True mantiene la relacion de aspecto (siempre aplicable con fpdf2).

      Returns:
          PDFDoc con la imagen añadida.
      """
      fpdf = doc.fpdf

      cur_x = x if x is not None else fpdf.get_x()
      cur_y = y if y is not None else fpdf.get_y()

      # Ancho por defecto: ancho disponible
      img_width = width if width is not None else (fpdf.epw if height is None else 0)
      img_height = height if height is not None else 0

      if isinstance(image, bytes):
          img_io = io.BytesIO(image)
          fpdf.image(img_io, x=cur_x, y=cur_y, w=img_width, h=img_height)
      else:
          fpdf.image(image, x=cur_x, y=cur_y, w=img_width, h=img_height)

      # Mover cursor debajo de la imagen
      fpdf.ln(5)

      return doc

params_schema:
  {"params":[{"name":"doc","desc":"PDFDoc con pagina activa"},{"name":"image","desc":"path absoluto o relativo a la imagen (str) o bytes del contenido de la imagen"},{"name":"x","desc":"posicion X en mm desde el borde izquierdo; None usa posicion actual del cursor"},{"name":"y","desc":"posicion Y en mm desde el borde superior; None usa posicion actual del cursor"},{"name":"width","desc":"ancho de la imagen en mm; None calcula desde height o usa ancho disponible"},{"name":"height","desc":"alto de la imagen en mm; None calcula desde width para mantener ratio"},{"name":"keep_aspect","desc":"si True fpdf2 calcula automaticamente la dimension faltante"}],"output":"PDFDoc con la imagen embebida en la pagina activa"}


id: pdf_add_page_py_infra
name: pdf_add_page
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def pdf_add_page(doc, orientation, width, height) -> PDFDoc
tags: ["pdf","page","layout","builder","infra"]
uses_functions: []
uses_types: ["pdf_doc_py_infra"]
returns: ["pdf_doc_py_infra"]
error_type: error_go_core
imports: []
tests: ["añadir pagina portrait A4","añadir pagina landscape","pagina con dimensiones personalizadas"]
test_file_path: python/functions/infra/pdf_add_page_test.py
file_path: python/functions/infra/pdf_add_page.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 3936d671cf82eae9a9cee1ed7a3859ca433fdbc5675258d07cbcbe585b0fab87
description:
  Añade una pagina nueva al documento PDF con orientacion y tamaño configurables. Sin dimensiones personalizadas usa A4 con la orientacion indicada.

example:
  ```python
  doc = pdf_create(title="Demo")
  doc = pdf_add_page(doc)                           # A4 portrait
  doc = pdf_add_page(doc, orientation="landscape")  # A4 landscape
  doc = pdf_add_page(doc, width=148, height=210)    # A5 portrait
  ```

notes:
  Llama a `fpdf.add_page()` con el formato adecuado. El header/footer configurado en `pdf_add_header_footer` se aplica automaticamente en cada pagina nueva (via subclase FPDF). Las dimensiones reales post-add se registran en `doc.pages` con las medidas internas de fpdf2 (que incluye los margenes).

code:
  """pdf_add_page — añade una pagina al documento PDF."""

  import sys
  import os

  _types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
  sys.path.insert(0, _types_dir)
  from pdf_doc import PDFDoc


  def pdf_add_page(
      doc: PDFDoc,
      orientation: str = "portrait",
      width: float | None = None,
      height: float | None = None,
  ) -> PDFDoc:
      """Añade una pagina nueva al documento PDF.

      Usa orientacion y dimensiones especificadas, o hereda las del documento.
      Si se especifica width/height se usa formato personalizado;
      si no, se usa A4 con la orientacion indicada.

      Args:
          doc: PDFDoc retornado por pdf_create.
          orientation: orientacion de la pagina: 'portrait' o 'landscape'.
          width: ancho personalizado en mm. Si None, usa A4 (210 mm).
          height: alto personalizado en mm. Si None, usa A4 (297 mm).

      Returns:
          PDFDoc con la nueva pagina añadida (mismo objeto modificado).
      """
      fpdf = doc.fpdf

      orient_char = "P" if orientation == "portrait" else "L"

      if width is not None and height is not None:
          fpdf.add_page(orientation=orient_char, format=(width, height))
      else:
          fpdf.add_page(orientation=orient_char, format="A4")

      actual_w = fpdf.w
      actual_h = fpdf.h
      doc.pages.append({
          "orientation": orientation,
          "width": actual_w,
          "height": actual_h,
      })

      return doc

params_schema:
  {"params":[{"name":"doc","desc":"PDFDoc inicializado con pdf_create"},{"name":"orientation","desc":"orientacion de la pagina: 'portrait' (vertical) o 'landscape' (horizontal)"},{"name":"width","desc":"ancho personalizado en mm; None usa A4 (210 mm)"},{"name":"height","desc":"alto personalizado en mm; None usa A4 (297 mm)"}],"output":"PDFDoc con la nueva pagina añadida; metadata de la pagina en doc.pages"}


id: pdf_add_table_py_infra
name: pdf_add_table
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["pdf","table","data","builder","infra"]
uses_functions: []
uses_types: ["pdf_doc_py_infra","pdf_style_py_infra"]
returns: ["pdf_doc_py_infra"]
error_type: error_go_core
imports: []
tests: ["tabla con headers y datos","tabla con col_widths personalizados","tabla sin borde"]
test_file_path: python/functions/infra/pdf_add_table_test.py
file_path: python/functions/infra/pdf_add_table.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 4375eec79c0a3ab5918490745bbf33274a833ea822ecc9d03a631431ea1385ad
signature:
  def pdf_add_table(doc, headers, rows, col_widths, header_style, row_style, border, row_height, alternate_bg) -> PDFDoc

description:
  Añade una tabla con fila de headers y filas de datos al documento PDF. Soporta anchos de columna personalizados, estilos diferenciados para headers/datos, bordes y colores alternados en filas.

example:
  ```python
  headers = ["Dominio", "Funciones", "Purity"]
  rows = [
      ["core", "45", "pure"],
      ["infra", "38", "impure"],
      ["finance", "22", "mixed"],
  ]
  doc = pdf_add_table(doc, headers, rows, col_widths=[70, 40, 60])
  ```

notes:
  Las columnas se distribuyen uniformemente si `col_widths` es None. Si la lista tiene menos elementos que columnas, se completa con distribucion uniforme. El fondo alternado usa gris muy claro (245, 245, 245) en filas impares para mejorar la lectura de tablas largas. No gestiona overflow de celdas — textos muy largos se truncan en fpdf2 con `cell()`.

code:
  """pdf_add_table — añade una tabla con headers y filas al documento PDF."""

  import sys
  import os

  _types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
  sys.path.insert(0, _types_dir)
  from pdf_doc import PDFDoc
  from pdf_style import PDFStyle


  def _parse_hex_color(hex_color: str) -> tuple[int, int, int]:
      h = hex_color.lstrip("#")
      if len(h) == 3:
          h = "".join(c * 2 for c in h)
      return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)


  def pdf_add_table(
      doc: PDFDoc,
      headers: list[str],
      rows: list[list[str]],
      col_widths: list[float] | None = None,
      header_style: PDFStyle | None = None,
      row_style: PDFStyle | None = None,
      border: int = 1,
      row_height: float = 8.0,
      alternate_bg: bool = True,
  ) -> PDFDoc:
      """Añade una tabla con headers y filas de datos al documento PDF.

      Las columnas se distribuyen uniformemente si no se especifican anchos.
      Soporta colores alternados en filas, bordes configurables y estilos
      diferenciados para headers y filas de datos.

      Args:
          doc: PDFDoc con pagina activa.
          headers: lista de strings con los nombres de columna.
          rows: lista de listas de strings con los datos de cada fila.
          col_widths: lista de anchos de columna en mm. None = distribucion uniforme.
          header_style: PDFStyle para la fila de headers. None usa bold 10pt.
          row_style: PDFStyle para filas de datos. None usa 10pt normal.
          border: grosor/tipo de borde (0=sin borde, 1=borde completo).
          row_height: altura de cada celda en mm. Por defecto 8.
          alternate_bg: si True, alterna fondo gris claro en filas pares.

      Returns:
          PDFDoc con la tabla añadida.
      """
      fpdf = doc.fpdf

      if not headers:
          return doc

      # Anchos de columna
      available_width = fpdf.epw
      n_cols = len(headers)
      if col_widths is None:
          col_widths = [available_width / n_cols] * n_cols
      else:
          # Rellenar si faltan columnas
          while len(col_widths) < n_cols:
              col_widths.append(available_width / n_cols)

      # Estilos
      if header_style is None:
          header_style = PDFStyle(
              font_family="Helvetica-Bold",
              font_size=10.0,
              color="#000000",
              alignment="left",
          )
      if row_style is None:
          row_style = PDFStyle(font_size=10.0, color="#000000", alignment="left")

      def set_font_from_style(s: PDFStyle):
          family = s.font_family
          bold = "-Bold" in family or "-BoldOblique" in family
          italic = "-Oblique" in family or "-BoldOblique" in family
          base = family.split("-")[0]
          st = ("B" if bold else "") + ("I" if italic else "")
          fpdf.set_font(base, style=st, size=s.font_size)
          r, g, b = _parse_hex_color(s.color)
          fpdf.set_text_color(r, g, b)

      def align_char(a: str) -> str:
          return {"left": "L", "center": "C", "right": "R", "justify": "J"}.get(a, "L")

      # Header row
      set_font_from_style(header_style)
      fpdf.set_fill_color(230, 230, 230)
      fpdf.set_draw_color(180, 180, 180)

      for i, (header, width) in enumerate(zip(headers, col_widths)):
          fpdf.cell(
              width,
              row_height,
              str(header),
              border=border,
              align=align_char(header_style.alignment),
              fill=True,
          )
      fpdf.ln()

      # Data rows
      set_font_from_style(row_style)
      fpdf.set_text_color(*_parse_hex_color(row_style.color))

      for row_idx, row in enumerate(rows):
          fill = alternate_bg and (row_idx % 2 == 1)
          if fill:
              fpdf.set_fill_color(245, 245, 245)
          else:
              fpdf.set_fill_color(255, 255, 255)

          for col_idx, (cell, width) in enumerate(zip(row, col_widths)):
              fpdf.cell(
                  width,
                  row_height,
                  str(cell) if cell is not None else "",
                  border=border,
                  align=align_char(row_style.alignment),
                  fill=True,
              )
          fpdf.ln()

      fpdf.ln(2)
      return doc

params_schema:
  {"params":[{"name":"doc","desc":"PDFDoc con pagina activa"},{"name":"headers","desc":"lista de strings con nombres de columna de la tabla"},{"name":"rows","desc":"lista de listas de strings con los datos de cada fila"},{"name":"col_widths","desc":"lista de anchos de columna en mm; None distribuye uniformemente el ancho disponible"},{"name":"header_style","desc":"PDFStyle para la fila de headers; None usa Helvetica-Bold 10pt"},{"name":"row_style","desc":"PDFStyle para filas de datos; None usa Helvetica 10pt"},{"name":"border","desc":"tipo de borde: 0=sin borde, 1=borde completo"},{"name":"row_height","desc":"altura de cada celda en mm, por defecto 8"},{"name":"alternate_bg","desc":"si True alterna fondo gris claro en filas pares para mejorar legibilidad"}],"output":"PDFDoc con la tabla renderizada en la pagina activa"}


id: pdf_add_text_py_infra
name: pdf_add_text
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def pdf_add_text(doc, text, style, x, y, width) -> PDFDoc
tags: ["pdf","text","typography","builder","infra"]
uses_functions: []
uses_types: ["pdf_doc_py_infra","pdf_style_py_infra"]
returns: ["pdf_doc_py_infra"]
error_type: error_go_core
imports: []
test_file_path: python/functions/infra/pdf_add_text_test.py
file_path: python/functions/infra/pdf_add_text.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: bed0855576c7d556f49a1ea196429c23d64305b721961423e650cf896c4b658c
description:
  Escribe un bloque de texto en el documento PDF con fuente, tamaño, color y alineacion del PDFStyle dado. Soporta saltos de linea y word wrap automatico.

example:
  ```python
  from pdf_style import PDFStyle

  titulo = PDFStyle(font_family="Helvetica-Bold", font_size=18.0, alignment="center")
  cuerpo = PDFStyle(font_size=11.0, alignment="justify", margin_bottom=4.0)

  doc = pdf_add_text(doc, "Informe Mensual", style=titulo)
  doc = pdf_add_text(doc, "Este reporte resume las ejecuciones de Abril 2026.", style=cuerpo)
  ```

tests:
  ["texto simple con estilo defecto","texto con estilo personalizado bold centrado","texto multilinea con saltos"]

notes:
  Usa `multi_cell` internamente para soportar word wrap automatico y saltos de linea `\n`. La altura de linea se calcula en mm a partir de `font_size * line_height`. Las fuentes built-in soportan variantes `-Bold`, `-Oblique`, `-BoldOblique` en el nombre de `font_family` (se separan internamente para fpdf2).

code:
  """pdf_add_text — escribe un bloque de texto en el documento PDF."""

  import sys
  import os

  _types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
  sys.path.insert(0, _types_dir)
  from pdf_doc import PDFDoc
  from pdf_style import PDFStyle


  def _parse_hex_color(hex_color: str) -> tuple[int, int, int]:
      """Convierte color hex (#RRGGBB) a tupla (r, g, b)."""
      h = hex_color.lstrip("#")
      if len(h) == 3:
          h = "".join(c * 2 for c in h)
      r = int(h[0:2], 16)
      g = int(h[2:4], 16)
      b = int(h[4:6], 16)
      return r, g, b


  def _align_char(alignment: str) -> str:
      """Convierte nombre de alineacion al caracter fpdf2."""
      mapping = {
          "left": "L",
          "center": "C",
          "right": "R",
          "justify": "J",
      }
      return mapping.get(alignment, "L")


  def pdf_add_text(
      doc: PDFDoc,
      text: str,
      style: PDFStyle | None = None,
      x: float | None = None,
      y: float | None = None,
      width: float | None = None,
  ) -> PDFDoc:
      """Escribe un bloque de texto en el documento PDF con el estilo dado.

      Posiciona el cursor en (x, y) si se especifican, o continua desde
      la posicion actual. Aplica fuente, tamaño, color y alineacion del estilo.
      Respeta saltos de linea en el texto.

      Args:
          doc: PDFDoc con una pagina activa.
          text: texto a escribir. Soporta saltos de linea con '\\n'.
          style: PDFStyle con fuente, tamaño, color, alineacion. None usa defaults.
          x: posicion X en mm. None mantiene la posicion actual (margen izquierdo).
          y: posicion Y en mm. None mantiene la posicion actual (posicion del cursor).
          width: ancho del bloque de texto en mm. None usa el ancho disponible.

      Returns:
          PDFDoc con el texto añadido (mismo objeto modificado).
      """
      if style is None:
          style = PDFStyle()

      fpdf = doc.fpdf

      # Aplicar margen superior
      if style.margin_top > 0:
          fpdf.ln(style.margin_top)

      # Posicion
      if x is not None or y is not None:
          cur_x = x if x is not None else fpdf.get_x()
          cur_y = y if y is not None else fpdf.get_y()
          fpdf.set_xy(cur_x, cur_y)

      # Fuente
      family = style.font_family
      # Detectar bold/italic del nombre
      bold = "-Bold" in family or "-BoldOblique" in family
      italic = "-Oblique" in family or "-BoldOblique" in family
      base_family = family.split("-")[0]
      style_str = ""
      if bold:
          style_str += "B"
      if italic:
          style_str += "I"
      fpdf.set_font(base_family, style=style_str, size=style.font_size)

      # Color
      r, g, b = _parse_hex_color(style.color)
      fpdf.set_text_color(r, g, b)

      # Alineacion
      align = _align_char(style.alignment)

      # Ancho disponible
      cell_width = width if width is not None else (fpdf.epw)

      # Altura de linea
      line_h = style.font_size * style.line_height * 0.352778  # pt a mm aprox

      # Escribir texto con multi_cell para soportar saltos de linea y wrap
      fpdf.multi_cell(w=cell_width, h=line_h, text=text, align=align)

      # Margen inferior
      if style.margin_bottom > 0:
          fpdf.ln(style.margin_bottom)

      return doc

params_schema:
  {"params":[{"name":"doc","desc":"PDFDoc con al menos una pagina activa"},{"name":"text","desc":"texto a escribir; soporta \\n para saltos de linea"},{"name":"style","desc":"PDFStyle con fuente, tamaño, color, alineacion; None usa defaults (Helvetica 12pt negro)"},{"name":"x","desc":"posicion X en mm desde el borde izquierdo; None mantiene posicion actual"},{"name":"y","desc":"posicion Y en mm desde el borde superior; None mantiene posicion del cursor"},{"name":"width","desc":"ancho del bloque en mm; None usa el ancho disponible entre margenes"}],"output":"PDFDoc con el texto añadido en la pagina activa"}


id: pdf_create_py_infra
name: pdf_create
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["pdf","create","fpdf2","builder","infra"]
uses_functions: []
uses_types: ["pdf_doc_py_infra"]
returns: ["pdf_doc_py_infra"]
error_type: error_go_core
imports: ["fpdf2"]
tests: ["crear documento con titulo y autor","margenes personalizados aplicados"]
test_file_path: python/functions/infra/pdf_create_test.py
file_path: python/functions/infra/pdf_create.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 994d9c2d1a94909fb2ebd2320a6e1ff5a6b1b821826f52de9021bda0c1cb031a
signature:
  def pdf_create(title, author, subject, default_font, margin_left, margin_right, margin_top, margin_bottom) -> PDFDoc

description:
  Inicializa un documento PDF nuevo usando fpdf2. Crea un objeto FPDF configurado con metadatos y margenes. Retorna un PDFDoc listo para añadir paginas con pdf_add_page.

example:
  ```python
  import sys
  sys.path.insert(0, "python/functions/infra")
  sys.path.insert(0, "python/types/infra")

  from pdf_create import pdf_create
  from pdf_add_page import pdf_add_page
  from pdf_save import pdf_save

  doc = pdf_create(title="Mi Reporte", author="Agente")
  doc = pdf_add_page(doc)
  pdf_save(doc, "reporte.pdf")
  ```

notes:
  Funcion impura: inicializa un objeto FPDF con estado mutable. Usa una subclase interna `_PDFWithHeaderFooter` que sobreescribe `header()` y `footer()` de FPDF para soportar header/footer recurrente configurado via `pdf_add_header_footer`. Los margenes se aplican con `set_margins` y `set_auto_page_break`. Requiere `fpdf2` instalado (`uv add fpdf2`).

code:
  """pdf_create — inicializa un documento PDF nuevo con fpdf2."""

  import sys
  import os

  sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types"))

  from fpdf import FPDF

  # Import type from types directory
  _types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
  sys.path.insert(0, _types_dir)
  from pdf_doc import PDFDoc


  class _PDFWithHeaderFooter(FPDF):
      """Subclase de FPDF con soporte para header/footer recurrente."""

      def __init__(self, doc_ref: PDFDoc, *args, **kwargs):
          super().__init__(*args, **kwargs)
          self._doc_ref = doc_ref

      def header(self):
          if not self._doc_ref.header_text:
              return
          self.set_font(self._doc_ref.default_font, size=9)
          self.set_text_color(100, 100, 100)
          self.cell(0, 8, self._doc_ref.header_text, align="C", new_x="LMARGIN", new_y="NEXT")
          self.ln(2)

      def footer(self):
          if not self._doc_ref.footer_text and not self._doc_ref.page_numbers:
              return
          self.set_y(-15)
          self.set_font(self._doc_ref.default_font, size=9)
          self.set_text_color(100, 100, 100)
          text = self._doc_ref.footer_text
          if self._doc_ref.page_numbers:
              if "{n}" in text or "{total}" in text:
                  text = text.replace("{n}", str(self.page_no())).replace("{total}", "{nb}")
              else:
                  text = f"Pagina {self.page_no()} de {{nb}}" if not text else text
          self.cell(0, 10, text, align="C")


  def pdf_create(
      title: str = "",
      author: str = "",
      subject: str = "",
      default_font: str = "Helvetica",
      margin_left: float = 20.0,
      margin_right: float = 20.0,
      margin_top: float = 20.0,
      margin_bottom: float = 20.0,
  ) -> PDFDoc:
      """Inicializa un documento PDF nuevo usando fpdf2.

      Crea un objeto FPDF configurado con los margenes y metadatos especificados.
      Retorna un PDFDoc listo para añadir paginas con pdf_add_page.

      Args:
          title: titulo del documento (aparece en metadata del PDF reader).
          author: autor del documento (metadata).
          subject: asunto del documento (metadata).
          default_font: fuente por defecto. Built-ins: Helvetica, Times, Courier.
          margin_left: margen izquierdo en mm. Por defecto 20.
          margin_right: margen derecho en mm. Por defecto 20.
          margin_top: margen superior en mm. Por defecto 20.
          margin_bottom: margen inferior en mm. Por defecto 20.

      Returns:
          PDFDoc inicializado con el objeto fpdf2 configurado.
      """
      doc = PDFDoc(
          title=title,
          author=author,
          subject=subject,
          default_font=default_font,
          margin_left=margin_left,
          margin_right=margin_right,
          margin_top=margin_top,
          margin_bottom=margin_bottom,
      )

      fpdf = _PDFWithHeaderFooter(doc_ref=doc)
      fpdf.set_margins(margin_left, margin_top, margin_right)
      fpdf.set_auto_page_break(auto=True, margin=margin_bottom)
      fpdf.set_title(title)
      fpdf.set_author(author)
      fpdf.set_subject(subject)
      fpdf.set_creator("fn-registry/pdf_create")

      doc.fpdf = fpdf
      return doc

params_schema:
  {"params":[{"name":"title","desc":"titulo del documento, aparece en metadata del PDF reader"},{"name":"author","desc":"autor del documento (metadata)"},{"name":"subject","desc":"asunto del documento (metadata)"},{"name":"default_font","desc":"fuente por defecto: Helvetica, Times, Courier (built-ins sin .ttf)"},{"name":"margin_left","desc":"margen izquierdo en mm, por defecto 20"},{"name":"margin_right","desc":"margen derecho en mm, por defecto 20"},{"name":"margin_top","desc":"margen superior en mm, por defecto 20"},{"name":"margin_bottom","desc":"margen inferior (page break) en mm, por defecto 20"}],"output":"PDFDoc inicializado con objeto fpdf2 configurado, listo para pdf_add_page"}


id: pdf_from_html_py_infra
name: pdf_from_html
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def pdf_from_html(html_string, output_path, page_size, css) -> str
tags: ["pdf","html","weasyprint","conversion","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["weasyprint"]
tests: []
file_path: python/functions/infra/pdf_from_html.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 044122ed2a48daf1a294b8c8bed583189f1d279d56562036c9ff254626bca6d1
description:
  Convierte un HTML string a PDF usando weasyprint con soporte completo de CSS layout. Retorna error claro si weasyprint no esta instalado (requiere dependencias de sistema: pango, cairo).

example:
  ```python
  html = """
  <!DOCTYPE html>
  <html>
  <body>
      <h1>Reporte</h1>
      <table>
          <tr><th>Dominio</th><th>Funciones</th></tr>
          <tr><td>core</td><td>45</td></tr>
      </table>
  </body>
  </html>
  """
  css = "body { font-family: sans-serif; } h1 { color: #333; }"
  output = pdf_from_html(html, "reporte.pdf", css=css)
  ```

notes:
  Stub activo: si `weasyprint` no esta instalado, lanza `RuntimeError` con instrucciones de instalacion claras. Para la mayoria de reportes simples, preferir el builder fpdf2 (`pdf_create` + `pdf_add_text` + `pdf_add_table`) que no requiere dependencias de sistema. weasyprint es util para HTML complejo con CSS avanzado (columnas, grid, fuentes web, estilos corporativos).

documentation:
  ## Instalacion de dependencias

  ```bash
  # Python
  pip install weasyprint

  # Linux (Ubuntu/Debian)
  sudo apt install libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0

  # macOS
  brew install pango cairo gdk-pixbuf
  ```

code:
  """pdf_from_html — convierte HTML string a PDF usando weasyprint (stub si no disponible)."""


  def pdf_from_html(
      html_string: str,
      output_path: str,
      page_size: str = "A4",
      css: str = "",
  ) -> str:
      """Convierte un HTML string a PDF usando weasyprint.

      Soporta CSS inline y externo (pasado como string). Produce PDFs de alta
      calidad tipografica con soporte completo de CSS layout (columnas, grids,
      fuentes web).

      NOTA: Requiere weasyprint y sus dependencias de sistema (pango, cairo,
      gdk-pixbuf). Instalar con: pip install weasyprint
      En Linux: apt install libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0

      Args:
          html_string: contenido HTML completo como string.
          output_path: ruta donde guardar el PDF generado.
          page_size: tamaño de pagina CSS: 'A4', 'letter', 'A3'. Por defecto 'A4'.
          css: CSS adicional como string para aplicar sobre el HTML.

      Returns:
          output_path con el PDF guardado.

      Raises:
          RuntimeError: si weasyprint no esta instalado.
      """
      try:
          import weasyprint
      except ImportError as exc:
          raise RuntimeError(
              "weasyprint no esta instalado. "
              "Instalar con: pip install weasyprint\n"
              "Dependencias de sistema (Linux): "
              "apt install libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0"
          ) from exc

      page_css = f"@page {{ size: {page_size}; }}"
      full_css = page_css + "\n" + css if css else page_css

      html = weasyprint.HTML(string=html_string)
      stylesheet = weasyprint.CSS(string=full_css)
      html.write_pdf(output_path, stylesheets=[stylesheet])

      return output_path

params_schema:
  {"params":[{"name":"html_string","desc":"contenido HTML completo como string, incluyendo head y body"},{"name":"output_path","desc":"ruta del archivo PDF a generar"},{"name":"page_size","desc":"tamaño de pagina CSS: A4, letter, A3. Por defecto A4"},{"name":"css","desc":"CSS adicional como string para aplicar sobre el HTML, puede incluir fuentes y layouts"}],"output":"output_path con el PDF guardado en disco"}


id: pdf_from_markdown_py_infra
name: pdf_from_markdown
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def pdf_from_markdown(markdown_string, output_path, css, page_size) -> str
tags: ["pdf","markdown","conversion","weasyprint","infra"]
uses_functions: ["pdf_from_html_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["weasyprint","markdown"]
example:
  ```python
  md_content = """
  # Especificacion de la API

tests: []
file_path: python/functions/infra/pdf_from_markdown.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 7553306e7c25cbe328edf61c3688517a9e9ffc2f2d77d95a93897472e3014c7c
description:
  Convierte un string Markdown a PDF via HTML + weasyprint. Incluye CSS por defecto para tipografia legible. Soporta tablas, codigo y headers. Requiere weasyprint y markdown instalados.

notes:
  Usa las extensiones `tables`, `fenced_code` y `nl2br` de la libreria `markdown`. El CSS por defecto incluye estilos para: tipografia base (Helvetica 11pt), headers jerarquicos, bloques de codigo con fondo gris, tablas con bordes y blockquotes. Stub activo: lanza `RuntimeError` con instrucciones si alguna dependencia falta.

documentation:
  ## Endpoints

  | Metodo | Path | Descripcion |
  |--------|------|-------------|
  | GET    | /fn  | Lista funciones |
  | POST   | /fn  | Crea funcion |

  ## Instalacion

  ```bash
  pip install weasyprint markdown

  # Linux
  sudo apt install libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0
  ```

code:
  """pdf_from_markdown — convierte Markdown a HTML y luego a PDF usando weasyprint."""

  import sys
  import os

  _infra_dir = os.path.dirname(__file__)
  sys.path.insert(0, _infra_dir)


  def pdf_from_markdown(
      markdown_string: str,
      output_path: str,
      css: str = "",
      page_size: str = "A4",
  ) -> str:
      """Convierte un string Markdown a PDF via HTML + weasyprint.

      Convierte Markdown a HTML con la libreria `markdown`, luego genera
      el PDF con weasyprint. Incluye CSS por defecto para tipografia legible.

      NOTA: Requiere `weasyprint` y `markdown` instalados:
        pip install weasyprint markdown
      Y dependencias de sistema weasyprint: pango, cairo, gdk-pixbuf.

      Args:
          markdown_string: contenido Markdown como string.
          output_path: ruta donde guardar el PDF generado.
          css: CSS adicional a aplicar sobre los estilos por defecto.
          page_size: tamaño de pagina: 'A4', 'letter'. Por defecto 'A4'.

      Returns:
          output_path con el PDF guardado.

      Raises:
          RuntimeError: si weasyprint o markdown no estan instalados.
      """
      try:
          import markdown as md_lib
      except ImportError as exc:
          raise RuntimeError(
              "markdown no esta instalado. Instalar con: pip install markdown"
          ) from exc

      try:
          import weasyprint
      except ImportError as exc:
          raise RuntimeError(
              "weasyprint no esta instalado. "
              "Instalar con: pip install weasyprint\n"
              "Dependencias de sistema (Linux): "
              "apt install libpango-1.0-0 libcairo2 libgdk-pixbuf-2.0-0"
          ) from exc

      # Convertir Markdown a HTML
      html_body = md_lib.markdown(
          markdown_string,
          extensions=["tables", "fenced_code", "nl2br"],
      )

      default_css = """
          body {
              font-family: Helvetica, Arial, sans-serif;
              font-size: 11pt;
              line-height: 1.5;
              color: #222;
              max-width: 170mm;
              margin: 0 auto;
          }
          h1, h2, h3 { color: #333; margin-top: 1.2em; }
          h1 { font-size: 18pt; border-bottom: 1px solid #ccc; }
          h2 { font-size: 14pt; }
          h3 { font-size: 12pt; }
          code { background: #f5f5f5; padding: 1px 4px; border-radius: 3px; font-size: 9pt; }
          pre { background: #f5f5f5; padding: 8px; border-radius: 4px; overflow: auto; }
          table { border-collapse: collapse; width: 100%; }
          th, td { border: 1px solid #ccc; padding: 6px; text-align: left; }
          th { background: #eee; }
          blockquote { border-left: 3px solid #ccc; margin: 0; padding-left: 12px; color: #555; }
      """

      page_css = f"@page {{ size: {page_size}; margin: 20mm; }}"
      combined_css = page_css + "\n" + default_css + "\n" + css

      html = f"""<!DOCTYPE html>
  <html><head><meta charset="utf-8"></head>
  <body>{html_body}</body></html>"""

      wp_html = weasyprint.HTML(string=html)
      stylesheet = weasyprint.CSS(string=combined_css)
      wp_html.write_pdf(output_path, stylesheets=[stylesheet])

      return output_path

params_schema:
  {"params":[{"name":"markdown_string","desc":"contenido Markdown como string; soporta tablas GFM, codigo cercado y listas"},{"name":"output_path","desc":"ruta del archivo PDF a generar"},{"name":"css","desc":"CSS adicional a añadir sobre los estilos por defecto (tipografia, tablas, codigo)"},{"name":"page_size","desc":"tamaño de pagina: A4, letter. Por defecto A4"}],"output":"output_path con el PDF generado en disco"}


id: pdf_merge_py_infra
name: pdf_merge
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def pdf_merge(pdf_paths, output_path) -> str
tags: ["pdf","merge","combine","pypdf","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["pypdf"]
tests: ["fusionar dos PDFs","error si archivo no existe","error si lista vacia"]
test_file_path: python/functions/infra/pdf_merge_test.py
file_path: python/functions/infra/pdf_merge.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: efc3aefa969f2f8c0dfef1ca022894f1bda515d650613bd07cafad4761ac7f25
description:
  Fusiona una lista de archivos PDF en un unico PDF combinado usando pypdf. Mantiene el orden de la lista. Lanza FileNotFoundError si alguno de los archivos no existe.

example:
  ```python
  # Fusionar capitulos en un documento unico
  paths = ["cap1.pdf", "cap2.pdf", "cap3.pdf", "apendice.pdf"]
  output = pdf_merge(paths, "documento_completo.pdf")
  print(f"PDF generado: {output}")
  ```

notes:
  Usa `pypdf` (sucesor de PyPDF2). El merge copia pagina a pagina — preserva contenido pero puede perder algunos metadatos avanzados (formularios interactivos, bookmarks anidados). Para PDFs protegidos con contrasena, pypdf necesita que se pase la password en el `PdfReader`. La lista debe tener al menos un elemento o lanza `ValueError`.

code:
  """pdf_merge — fusiona multiples archivos PDF en uno solo usando pypdf."""

  import os


  def pdf_merge(
      pdf_paths: list[str],
      output_path: str,
  ) -> str:
      """Fusiona una lista de archivos PDF en un unico PDF.

      Une los PDFs en el orden de la lista. Requiere pypdf instalado.

      Args:
          pdf_paths: lista de rutas a los archivos PDF a fusionar (en orden).
          output_path: ruta del archivo PDF combinado a generar.

      Returns:
          output_path con el PDF fusionado guardado.

      Raises:
          FileNotFoundError: si alguno de los archivos no existe.
          RuntimeError: si pypdf no esta instalado.
          ValueError: si pdf_paths esta vacio.
      """
      if not pdf_paths:
          raise ValueError("pdf_paths no puede estar vacio")

      for path in pdf_paths:
          if not os.path.exists(path):
              raise FileNotFoundError(f"PDF no encontrado: {path}")

      try:
          from pypdf import PdfWriter, PdfReader
      except ImportError as exc:
          raise RuntimeError(
              "pypdf no esta instalado. Instalar con: pip install pypdf"
          ) from exc

      writer = PdfWriter()

      for path in pdf_paths:
          reader = PdfReader(path)
          for page in reader.pages:
              writer.add_page(page)

      with open(output_path, "wb") as f:
          writer.write(f)

      return output_path

params_schema:
  {"params":[{"name":"pdf_paths","desc":"lista de rutas a los archivos PDF a fusionar, en el orden deseado"},{"name":"output_path","desc":"ruta del archivo PDF combinado a generar"}],"output":"output_path con todos los PDFs fusionados en orden"}


id: pdf_save_py_infra
name: pdf_save
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def pdf_save(doc, output_path) -> str | bytes
tags: ["pdf","save","output","builder","infra"]
uses_functions: []
uses_types: ["pdf_doc_py_infra"]
returns: []
error_type: error_go_core
imports: []
tests: ["guardar PDF a archivo","retornar bytes del PDF","PDF valido comienza con %PDF"]
test_file_path: python/functions/infra/pdf_save_test.py
file_path: python/functions/infra/pdf_save.py
created_at: 2026-04-13T00:06:12Z
updated_at: 2026-04-13T00:06:12Z
props: []
emits: []
variant: []
content_hash: 2f706f055d326e976fd0dbe98b36b670c45ad85d6d35f3163869e565b659fe7a
description:
  Serializa el documento PDF a disco o retorna los bytes en memoria. Si output_path es None retorna bytes para transmision directa (HTTP response, almacenamiento en BD). Si se especifica, escribe el archivo y retorna el path.

example:
  ```python
  # Guardar a disco
  path = pdf_save(doc, "reporte.pdf")
  print(f"PDF guardado en: {path}")

  # Obtener bytes (para HTTP response o guardar en BD)
  pdf_bytes = pdf_save(doc)
  assert pdf_bytes[:4] == b"%PDF"

  # En un endpoint web (ejemplo conceptual)
  return Response(pdf_save(doc), content_type="application/pdf")
  ```

notes:
  Llama a `fpdf2.output()`. Si el documento usa `alias_nb_pages()` (activado por `pdf_add_header_footer` con `page_numbers=True`), fpdf2 reemplaza el alias con el total real de paginas al serializar. Para retornar bytes, `output()` retorna un `bytearray` que se convierte a `bytes` para compatibilidad con APIs que esperan `bytes`.

code:
  """pdf_save — guarda el documento PDF a un archivo o retorna bytes."""

  import sys
  import os

  _types_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "python", "types", "infra")
  sys.path.insert(0, _types_dir)
  from pdf_doc import PDFDoc


  def pdf_save(
      doc: PDFDoc,
      output_path: str | None = None,
  ) -> str | bytes:
      """Guarda el documento PDF a un archivo o retorna los bytes del PDF.

      Si se especifica output_path, escribe el PDF en disco y retorna el path.
      Si output_path es None, retorna los bytes del PDF sin escribir a disco.

      Args:
          doc: PDFDoc con al menos una pagina de contenido.
          output_path: ruta del archivo PDF a crear. None retorna bytes.

      Returns:
          output_path si se especifico, o bytes del PDF si output_path es None.
      """
      if output_path is not None:
          doc.fpdf.output(output_path)
          return output_path
      else:
          return bytes(doc.fpdf.output())

params_schema:
  {"params":[{"name":"doc","desc":"PDFDoc con al menos una pagina y contenido añadido"},{"name":"output_path","desc":"ruta del archivo PDF a crear en disco; None retorna bytes del PDF en memoria"}],"output":"output_path (str) si se escribio a disco, o bytes del PDF si output_path es None"}


id: read_file_with_encoding_py_infra
name: read_file_with_encoding
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: read_file_with_encoding(path: str, encodings: list[str] | None = None) -> str
tags: ["file","encoding","io","text","utf8","latin1","cp1252","decode"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
test_file_path: python/functions/infra/read_file_with_encoding_test.py
file_path: python/functions/infra/read_file_with_encoding.py
created_at: 2026-04-05T11:24:27Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 128dcf2c30d4b5b73051d88d772d97e198f1977fe7ee3ba772cae29791a9e2a7
description:
  Lee un archivo de texto intentando multiples encodings en orden hasta encontrar uno que funcione. Util para archivos de origen desconocido (Windows, Latin-1, con BOM, etc.).

example:
  ```python
  # Leer archivo de origen desconocido
  content = read_file_with_encoding("/tmp/datos.csv")

  # Leer archivo Windows con BOM explicitamente
  content = read_file_with_encoding("/tmp/report.txt", encodings=["utf-8-sig", "cp1252"])
  ```

tests:
  ["archivo utf-8 valido","archivo utf-8 con BOM eliminado con utf-8-sig","archivo latin-1","archivo binario falla con ValueError","encodings personalizados","archivo no existe lanza FileNotFoundError"]

notes:
  Los encodings por defecto son `["utf-8", "utf-8-sig", "latin-1", "cp1252"]`. El orden importa: `utf-8` se intenta primero porque es el mas comun. Si el archivo tiene BOM y se quiere que sea eliminado automaticamente, pasar `encodings=["utf-8-sig"]` o anteponerlo a `utf-8` en la lista personalizada.

  `latin-1` nunca lanza `UnicodeDecodeError` porque mapea todos los bytes 0x00-0xFF, por lo que actua como fallback universal. Si `latin-1` es el ultimo encoding y falla con `cp1252` tambien, solo un archivo binario puro (sin mapeo posible) disparara el `ValueError`.

  Raises `FileNotFoundError` u `OSError` nativas si el archivo no existe o hay error de I/O — estos no se envuelven en `ValueError`.

code:
  """Lee un archivo de texto intentando multiples encodings en orden."""

  from __future__ import annotations


  def read_file_with_encoding(
      path: str,
      encodings: list[str] | None = None,
  ) -> str:
      """Lee un archivo de texto intentando multiples encodings en orden.

      Intenta abrir el archivo con cada encoding de la lista hasta que
      uno tenga exito. Util para archivos de origen desconocido (Windows,
      Latin-1, archivos con BOM, etc.).

      Args:
          path: Ruta al archivo a leer.
          encodings: Lista de encodings a intentar en orden. Por defecto
              ["utf-8", "utf-8-sig", "latin-1", "cp1252"].

      Returns:
          Contenido del archivo como string.

      Raises:
          ValueError: Si ningun encoding logra decodificar el archivo.
          FileNotFoundError: Si el archivo no existe.
          OSError: Si hay un error de I/O al abrir el archivo.
      """
      if encodings is None:
          encodings = ["utf-8", "utf-8-sig", "latin-1", "cp1252"]

      last_error: UnicodeDecodeError | None = None

      for encoding in encodings:
          try:
              with open(path, encoding=encoding) as fh:
                  return fh.read()
          except UnicodeDecodeError as exc:
              last_error = exc
              continue

      raise ValueError(
          f"Unable to decode file '{path}' with encodings {encodings}. "
          f"Last error: {last_error}"
      )

params_schema:
  {"params":[{"name":"path","desc":"ruta del archivo a leer"},{"name":"encodings","desc":"lista opcional de encodings a intentar en orden (defecto: ['utf-8', 'utf-8-sig', 'latin-1', 'cp1252'])"}],"output":"string con el contenido del archivo decodificado"}


id: safe_extract_zip_py_infra
name: safe_extract_zip
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def safe_extract_zip(zip_path: str, dest_dir: str) -> None
tags: ["zip","extract","security","zip-slip","path-traversal","infra","io"]
uses_functions: ["normalize_zip_filenames_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["os","zipfile","pathlib"]
test_file_path: python/functions/infra/safe_extract_zip_test.py
file_path: python/functions/infra/safe_extract_zip.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 75a82201c41c2e11b929f309e54f57cacaa62acd4061c93f02a591f7b76ff791
description:
  Extrae un archivo ZIP con proteccion contra Zip Slip (path traversal attack). Valida que cada archivo extraido quede dentro del directorio destino antes de extraerlo. Normaliza nombres de archivo UTF-8 antes de extraer.

example:
  ```python
  from safe_extract_zip import safe_extract_zip

  # Extraccion segura
  try:
      safe_extract_zip("archive.zip", "/tmp/output")
  except ValueError as e:
      print(f"Zip Slip bloqueado: {e}")
  except zipfile.BadZipFile:
      print("Archivo ZIP invalido")
  ```

tests:
  ["ZIP normal extrae correctamente dentro del destino","ZIP con path traversal lanza ValueError","ZIP con paths absolutos lanza ValueError"]

notes:
  Funcion impura: escribe archivos en disco.

  La proteccion contra Zip Slip consiste en resolver el path absoluto de cada miembro antes de extraerlo y verificar que empiece con `str(dest_dir) + os.sep`. Esto bloquea tanto `../../etc/passwd` como `/etc/passwd`.

  La normalizacion de nombres UTF-8 se delega a `normalize_zip_filenames` y se ejecuta antes de la validacion de paths.

code:
  """Safe ZIP extraction with Zip Slip protection and filename normalization."""

  import os
  import zipfile
  from pathlib import Path


  def normalize_zip_filenames(zipf: zipfile.ZipFile) -> None:
      """Repara nombres de archivos UTF-8 en ZIPs sin el flag UTF-8 seteado.

      Args:
          zipf: Objeto ZipFile abierto en modo lectura.

      Returns:
          None. Modifica los infolist del ZipFile in-place.
      """
      def _is_cjk(s: str) -> bool:
          return any(
              "\u3400" <= c <= "\u4dbf"
              or "\u4e00" <= c <= "\u9fff"
              or "\u3000" <= c <= "\u303f"
              or "\uff00" <= c <= "\uffef"
              for c in s
          )

      def _is_mojibake(s: str) -> bool:
          return any(
              "\u0370" <= c <= "\u03ff"  # Greek
              or "\u2200" <= c <= "\u22ff"  # Math
              or "\u2500" <= c <= "\u257f"  # Box Drawing
              for c in s
          )

      repaired = False
      for info in zipf.infolist():
          # Flag 0x800 indica que el filename ya esta en UTF-8
          if info.flag_bits & 0x800:
              continue
          try:
              repaired_name = info.filename.encode("cp437").decode("utf-8")
              if _is_cjk(repaired_name) and _is_mojibake(info.filename):
                  info.filename = repaired_name
                  repaired = True
          except (UnicodeEncodeError, UnicodeDecodeError):
              pass

      if repaired:
          zipf.metadata_encoding = "utf-8"


  def safe_extract_zip(zip_path: str, dest_dir: str) -> None:
      """Extrae un archivo ZIP con proteccion contra Zip Slip (path traversal).

      Valida que cada archivo extraido quede dentro del directorio destino antes
      de extraerlo. Normaliza los nombres de archivo UTF-8 antes de extraer.

      Args:
          zip_path: Ruta al archivo ZIP a extraer.
          dest_dir: Directorio de destino para la extraccion.

      Raises:
          ValueError: Si se detecta un intento de Zip Slip (path traversal).
          zipfile.BadZipFile: Si el archivo no es un ZIP valido.
          FileNotFoundError: Si zip_path no existe.
      """
      dest = Path(dest_dir).resolve()

      with zipfile.ZipFile(zip_path, "r") as zipf:
          normalize_zip_filenames(zipf)

          for member in zipf.infolist():
              member_path = (dest / member.filename).resolve()

              # Verificar que el path resultante este dentro de dest_dir
              if not str(member_path).startswith(str(dest) + os.sep):
                  raise ValueError(
                      f"Zip Slip attempt detected: {member.filename!r} would extract to {member_path}"
                  )

              zipf.extract(member, dest)

params_schema:
  {"params":[{"name":"zip_path","desc":"ruta del archivo ZIP a extraer"},{"name":"dest_dir","desc":"directorio destino para la extraccion"}],"output":"None (extrae archivos al disco en dest_dir)"}


id: scan_directory_py_infra
name: scan_directory
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["directory","scan","filesystem","classification","infra","walk","files"]
uses_functions: []
uses_types: ["classified_file_py_infra","directory_scan_result_py_infra"]
returns: ["directory_scan_result_py_infra"]
error_type: error_go_core
imports: ["os","pathlib","fnmatch","sys","dataclasses"]
test_file_path: python/functions/infra/scan_directory_test.py
file_path: python/functions/infra/scan_directory.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 9a77ae70b8a2a0facf20820d7c1df2523c0520b06fa4242191ff9ee1599a6f94
signature:
  def scan_directory(root: str, supported_extensions: set[str] | None = None, ignore_dirs: set[str] | None = None, include: str | None = None, exclude: str | None = None, strict: bool = False) -> DirectoryScanResult

description:
  Recorre un arbol de directorios y clasifica cada archivo como procesable o no soportado. Util para validacion pre-importacion de directorios. Ignora dot files, symlinks, archivos vacios y directorios de build/venv/cache predefinidos. Soporta filtros include/exclude con globs.

example:
  ```python
  from scan_directory import scan_directory

  # Escanear directorio de documentos, solo PDF y Markdown
  result = scan_directory(
      "/data/proyecto",
      supported_extensions={".pdf", ".md"},
      ignore_dirs={"archive"},
      exclude="*.tmp,drafts/",
      strict=False,
  )

  print(f"Procesables: {len(result.processable)}")
  print(f"No soportados: {len(result.unsupported)}")

  for f in result.processable:
      print(f"  {f.rel_path}")
  ```

tests:
  ["directorio con mezcla de archivos","directorio con dot files","directorio con subdirs ignorados","filtros include/exclude","modo strict"]

notes:
  Funcion impura: realiza I/O de sistema de archivos con `os.walk`.

  **Directorios ignorados por defecto (`IGNORE_DIRS`):**
  `__pycache__`, `node_modules`, `.git`, `.svn`, `.hg`, `venv`, `.venv`, `env`, `.env`, `.tox`, `.nox`, `.mypy_cache`, `.pytest_cache`, `.ruff_cache`, `dist`, `build`, `.next`, `.nuxt`, `target`, `vendor`.

  **Logica de include/exclude:**
  - `include`: patrones glob separados por coma (ej: `"*.pdf,*.md"`). Si se provee, solo se incluyen archivos que coincidan con al menos un patron.
  - `exclude`: patrones glob separados por coma. Si el patron termina con `/` es un prefijo de path relativo (ej: `"drafts/"`); sin `/` es un glob de nombre (ej: `"*.tmp"`).

  **Modo strict:** si `strict=True` y hay archivos no soportados, lanza `ValueError` con la lista de archivos no soportados. Util para pipelines que requieren directorio 100% homogeneo.

  **Orden de resultados:** `processable` y `unsupported` se ordenan por `rel_path` ascendente para salida determinista.

  Los paths relativos en `ClassifiedFile.rel_path` siempre usan forward slashes (`/`) independientemente del OS.

code:
  """scan_directory — recorre un arbol de directorios y clasifica cada archivo."""

  import fnmatch
  import os
  import sys
  from pathlib import Path

  # Importar tipos cuando el modulo se carga desde su directorio o via PYTHONPATH
  _HERE = Path(__file__).parent
  _TYPES_INFRA = Path(__file__).parent.parent.parent / "types" / "infra"
  for _p in [str(_HERE), str(_TYPES_INFRA)]:
      if _p not in sys.path:
          sys.path.insert(0, _p)

  from classified_file import ClassifiedFile  # noqa: E402
  from directory_scan_result import DirectoryScanResult  # noqa: E402

  # Directorios ignorados por defecto
  IGNORE_DIRS: set[str] = {
      "__pycache__",
      "node_modules",
      ".git",
      ".svn",
      ".hg",
      "venv",
      ".venv",
      "env",
      ".env",
      ".tox",
      ".nox",
      ".mypy_cache",
      ".pytest_cache",
      ".ruff_cache",
      "dist",
      "build",
      ".next",
      ".nuxt",
      "target",
      "vendor",
  }


  def scan_directory(
      root: str,
      supported_extensions: set[str] | None = None,
      ignore_dirs: set[str] | None = None,
      include: str | None = None,
      exclude: str | None = None,
      strict: bool = False,
  ) -> DirectoryScanResult:
      """Recorre un arbol de directorios y clasifica cada archivo como procesable o no soportado.

      Util para validacion pre-importacion de directorios: identifica que archivos
      podran procesarse y cuales seran ignorados antes de iniciar cualquier pipeline.

      Args:
          root: Path al directorio raiz a escanear.
          supported_extensions: Conjunto de extensiones procesables (ej: {".pdf", ".md"}).
              Si es None, todos los archivos no filtrados se marcan como "processable".
          ignore_dirs: Nombres o paths relativos de directorios adicionales a ignorar.
              Se suman a IGNORE_DIRS. Los paths relativos usan forward slashes.
          include: Patrones glob separados por coma (ej: "*.pdf,*.md"). Si se provee,
              solo se incluyen archivos que coincidan con al menos un patron.
          exclude: Patrones glob separados por coma. Patrones con "/" final son prefijos
              de path (ej: "drafts/"); sin "/" son globs de nombre (ej: "*.tmp").
          strict: Si True, lanza ValueError si hay archivos no soportados al final.

      Returns:
          DirectoryScanResult con listas de archivos procesables, no soportados,
          paths saltados y warnings.

      Raises:
          FileNotFoundError: Si root no existe.
          NotADirectoryError: Si root no es un directorio.
          ValueError: Si strict=True y hay archivos no soportados.
      """
      root_path = Path(root).resolve()

      if not root_path.exists():
          raise FileNotFoundError(f"Directorio no encontrado: {root}")
      if not root_path.is_dir():
          raise NotADirectoryError(f"No es un directorio: {root}")

      # Construir conjuntos de filtro
      extra_ignore = ignore_dirs or set()
      all_ignore = IGNORE_DIRS | extra_ignore

      include_patterns: list[str] = (
          [p.strip() for p in include.split(",") if p.strip()] if include else []
      )
      exclude_patterns: list[str] = (
          [p.strip() for p in exclude.split(",") if p.strip()] if exclude else []
      )

      processable: list[ClassifiedFile] = []
      unsupported: list[ClassifiedFile] = []
      skipped: list[str] = []
      warnings: list[str] = []

      for dirpath, dirnames, filenames in os.walk(str(root_path), topdown=True):
          dir_path = Path(dirpath)
          rel_dir = dir_path.relative_to(root_path)

          # Podar directorios (modificar in-place para que os.walk no los visite)
          pruned: list[str] = []
          kept: list[str] = []
          for d in dirnames:
              dir_abs = dir_path / d
              rel_d = rel_dir / d
              rel_d_str = rel_d.as_posix()

              # Skip dot dirs
              if d.startswith("."):
                  skipped.append(f"{dir_abs} (dot directory)")
                  pruned.append(d)
                  continue

              # Skip symlinks
              if dir_abs.is_symlink():
                  skipped.append(f"{dir_abs} (symlink)")
                  pruned.append(d)
                  continue

              # Skip IGNORE_DIRS (por nombre o por path relativo)
              if d in all_ignore or rel_d_str in all_ignore:
                  skipped.append(f"{dir_abs} (ignored directory)")
                  pruned.append(d)
                  continue

              kept.append(d)

          dirnames[:] = kept

          # Procesar archivos
          for filename in sorted(filenames):
              file_abs = dir_path / filename
              rel_file = (rel_dir / filename).as_posix()

              # Skip dot files
              if filename.startswith("."):
                  skipped.append(f"{file_abs} (dot file)")
                  continue

              # Skip symlinks
              if file_abs.is_symlink():
                  skipped.append(f"{file_abs} (symlink)")
                  continue

              # Skip archivos vacios
              try:
                  if file_abs.stat().st_size == 0:
                      skipped.append(f"{file_abs} (empty file)")
                      continue
              except OSError as exc:
                  warnings.append(f"No se pudo leer {file_abs}: {exc}")
                  continue

              # Aplicar filtro include (si hay patrones, debe coincidir con al menos uno)
              if include_patterns:
                  if not any(fnmatch.fnmatch(filename, p) for p in include_patterns):
                      skipped.append(f"{file_abs} (no coincide con include)")
                      continue

              # Aplicar filtro exclude
              excluded = False
              for pat in exclude_patterns:
                  if pat.endswith("/"):
                      # Es un prefijo de path relativo
                      prefix = pat  # ej: "drafts/"
                      if rel_file.startswith(prefix):
                          excluded = True
                          break
                  else:
                      # Es un glob de nombre de archivo
                      if fnmatch.fnmatch(filename, pat):
                          excluded = True
                          break
              if excluded:
                  skipped.append(f"{file_abs} (excluido por exclude)")
                  continue

              # Clasificar por extension
              ext = Path(filename).suffix.lower()
              if supported_extensions is None or ext in supported_extensions:
                  classification = "processable"
              else:
                  classification = "unsupported"

              cf = ClassifiedFile(
                  path=str(file_abs),
                  rel_path=rel_file,
                  classification=classification,
              )
              if classification == "processable":
                  processable.append(cf)
              else:
                  unsupported.append(cf)

      # Ordenar por rel_path
      processable.sort(key=lambda f: f.rel_path)
      unsupported.sort(key=lambda f: f.rel_path)

      result = DirectoryScanResult(
          root=str(root_path),
          processable=processable,
          unsupported=unsupported,
          skipped=skipped,
          warnings=warnings,
      )

      if strict and unsupported:
          unsupported_paths = [f.rel_path for f in unsupported]
          raise ValueError(
              f"strict=True: {len(unsupported)} archivos no soportados: {unsupported_paths}"
          )

      return result

params_schema:
  {"params":[{"name":"root","desc":"directorio raiz a escanear"},{"name":"supported_extensions","desc":"conjunto opcional de extensiones soportadas (ej: {'.pdf', '.md'})"},{"name":"ignore_dirs","desc":"conjunto opcional de nombres de directorio a ignorar"},{"name":"include","desc":"patrones glob opcionales separados por coma para incluir archivos"},{"name":"exclude","desc":"patrones glob opcionales separados por coma para excluir archivos"},{"name":"strict","desc":"si True, lanza ValueError si hay archivos no soportados"}],"output":"instancia DirectoryScanResult con listas processable y unsupported de ClassifiedFile"}


id: setup_logger_py_infra
name: setup_logger
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["logging","logger","rotation","file","console","infra","debug"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["logging","logging.handlers","os","sys","datetime"]
test_file_path: python/functions/infra/setup_logger_test.py
file_path: python/functions/infra/setup_logger.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 00f88394c0049f97fa6759b5c5965d3512e6b77005c6d431e175c9bf0b354307
signature:
  def setup_logger(name: str = 'app', log_dir: str = 'logs', level: int = logging.DEBUG) -> logging.Logger

description:
  Configura un logger con dual output: archivo con rotacion por tamano (DEBUG+, 10MB, 5 backups) y consola (INFO+). Crea log_dir si no existe. Idempotente: no duplica handlers si el logger ya esta configurado.

example:
  ```python
  from setup_logger import setup_logger, get_logger

  # Configurar al inicio de la aplicacion
  logger = setup_logger(name="mi_app", log_dir="logs", level=logging.DEBUG)
  logger.info("Aplicacion iniciada")
  logger.debug("Detalle de debug")

  # En modulos internos: obtener logger ya configurado
  log = get_logger("mi_app")
  log.warning("Algo inesperado ocurrio")
  ```

tests:
  ["logger se crea con 2 handlers","segundo call no duplica handlers","archivo se crea en log_dir","get_logger retorna logger configurado","logger level es debug"]

notes:
  Funcion impura: crea el directorio `log_dir` en disco y modifica el estado global del sistema de logging de Python.

  El archivo de log tiene nombre `YYYY-MM-DD.log` segun la fecha de inicio. La rotacion es por tamano (10 MB), no por tiempo — por eso el nombre es fijo para cada dia de inicio de la aplicacion.

  En Windows se reconfigura `sys.stdout` a UTF-8 para evitar mojibake con caracteres no-ASCII.

  La funcion companion `get_logger` es util en modulos que no controlan la inicializacion: devuelve el logger si ya fue configurado, o lo crea con defaults.

code:
  """Configuracion de logger con rotacion de archivo y salida a consola."""

  import logging
  import logging.handlers
  import os
  import sys
  from datetime import datetime


  def setup_logger(
      name: str = "app",
      log_dir: str = "logs",
      level: int = logging.DEBUG,
  ) -> logging.Logger:
      """Configura un logger con dual output: archivo rotante y consola.

      Crea el directorio de logs si no existe. El archivo usa nivel DEBUG con
      formato detallado y rotacion diaria (maxBytes=10MB, backupCount=5).
      La consola usa nivel INFO con formato simplificado. Es idempotente: si el
      logger ya tiene handlers no se duplican.

      Args:
          name: Nombre del logger (identifica la instancia en el sistema de logging).
          log_dir: Directorio donde se guardan los archivos de log.
          level: Nivel minimo del logger principal (por defecto DEBUG).

      Returns:
          Logger configurado con handler de archivo y handler de consola.
      """
      os.makedirs(log_dir, exist_ok=True)

      logger = logging.getLogger(name)
      logger.setLevel(level)
      logger.propagate = False

      # Idempotente: si ya tiene handlers no agregar mas
      if logger.handlers:
          return logger

      fmt_detailed = logging.Formatter(
          "[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d] %(message)s"
      )
      fmt_simple = logging.Formatter(
          "[%(asctime)s] %(levelname)s: %(message)s"
      )

      # File handler con rotacion por tamano
      log_filename = os.path.join(log_dir, f"{datetime.now():%Y-%m-%d}.log")
      file_handler = logging.handlers.RotatingFileHandler(
          log_filename,
          maxBytes=10 * 1024 * 1024,  # 10 MB
          backupCount=5,
          encoding="utf-8",
      )
      file_handler.setLevel(logging.DEBUG)
      file_handler.setFormatter(fmt_detailed)

      # Console handler
      if sys.platform == "win32":
          try:
              sys.stdout.reconfigure(encoding="utf-8", errors="replace")  # type: ignore[attr-defined]
          except AttributeError:
              pass

      console_handler = logging.StreamHandler(sys.stdout)
      console_handler.setLevel(logging.INFO)
      console_handler.setFormatter(fmt_simple)

      logger.addHandler(file_handler)
      logger.addHandler(console_handler)

      return logger


  def get_logger(name: str = "app") -> logging.Logger:
      """Devuelve un logger existente o lo crea con setup_logger.

      Args:
          name: Nombre del logger.

      Returns:
          Logger configurado.
      """
      logger = logging.getLogger(name)
      return logger if logger.handlers else setup_logger(name)

params_schema:
  {"params":[{"name":"name","desc":"nombre del logger a configurar"},{"name":"log_dir","desc":"directorio donde guardar archivos de log (defecto 'logs')"},{"name":"level","desc":"nivel de logging (defecto logging.DEBUG)"}],"output":"instancia logging.Logger configurada con handlers de archivo y consola"}


id: smtp_send_py_infra
name: smtp_send
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["email","smtp","send","python","smtplib","mime","tls"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
test_file_path: python/functions/infra/smtp_send_test.py
file_path: python/functions/infra/smtp_send.py
created_at: 2026-04-13T00:03:20Z
updated_at: 2026-04-13T00:03:20Z
props: []
emits: []
variant: []
content_hash: 3f13e1f3f3f8f253b805ca0743d65a01fa9134e5ac48b82b4506b75941a28988
signature:
  smtp_send(cfg: SMTPConfigPy, from_addr: str, to: list[str], subject: str, body_html: str = '', body_text: str = '', cc: list[str] | None = None, bcc: list[str] | None = None, attachments: list[EmailAttachmentPy] | None = None, headers: dict[str, str] | None = None) -> None

description:
  Conecta al servidor SMTP, construye el mensaje MIME y envia el email en un solo paso. Soporta TLS directo (port 465), STARTTLS (port 587) y sin cifrado (port 25). Cierra la conexion automaticamente.

imports:
  ["smtplib","email.mime.multipart","email.mime.text","email.mime.base","email.encoders","dataclasses"]

example:
  ```python
  from infra.smtp_send import smtp_send, SMTPConfigPy, EmailAttachmentPy

  cfg = SMTPConfigPy(host="smtp.gmail.com", port=587, username="u@gmail.com", password="app-pw")
  smtp_send(
      cfg,
      from_addr="u@gmail.com",
      to=["dest@example.com"],
      subject="Reporte",
      body_html="<h1>Hola</h1>",
      body_text="Hola",
  )
  ```

tests:
  ["envia texto plano via mock smtpd","envia html via mock smtpd","envia con adjunto via mock smtpd","error si host no existe"]

notes:
  Funcion impura — abre conexion TCP real. Usa solo stdlib Python (smtplib, email). Para TLS directo (port 465) usa `SMTP_SSL`; para STARTTLS (port 587) usa `SMTP` + `starttls()`. Los adjuntos se codifican en base64. BCC se incluye en `sendmail` pero no en las cabeceras MIME visibles.

code:
  """Envio de email via SMTP con smtplib — sin dependencias externas."""

  import smtplib
  from dataclasses import dataclass, field
  from email.mime.base import MIMEBase
  from email.mime.multipart import MIMEMultipart
  from email.mime.text import MIMEText
  from email import encoders


  @dataclass(frozen=True)
  class EmailAttachmentPy:
      """Archivo adjunto a un email."""
      filename: str
      content_type: str  # MIME type, ej: "application/pdf"
      data: bytes


  @dataclass(frozen=True)
  class SMTPConfigPy:
      """Parametros de conexion SMTP.

      tls_mode puede ser:
          "tls"      — TLS directo (port 465)
          "starttls" — upgrade STARTTLS (port 587)
          ""         — sin cifrado (port 25)
      """
      host: str
      port: int
      username: str = ""
      password: str = ""
      tls_mode: str = "starttls"  # "tls", "starttls" o ""


  def smtp_send(
      cfg: SMTPConfigPy,
      from_addr: str,
      to: list[str],
      subject: str,
      body_html: str = "",
      body_text: str = "",
      cc: list[str] | None = None,
      bcc: list[str] | None = None,
      attachments: list[EmailAttachmentPy] | None = None,
      headers: dict[str, str] | None = None,
  ) -> None:
      """Conecta al servidor SMTP, envia el email y cierra la conexion.

      Construye el mensaje MIME segun el contenido:
      - Solo texto: text/plain
      - Solo HTML: text/html
      - Ambos: multipart/alternative
      - Con adjuntos: multipart/mixed

      Args:
          cfg: Configuracion SMTP (host, port, usuario, password, tls_mode).
          from_addr: Direccion del remitente.
          to: Lista de destinatarios principales.
          subject: Asunto del correo.
          body_html: Cuerpo HTML (opcional).
          body_text: Cuerpo texto plano (opcional).
          cc: Lista de destinatarios en copia (opcional).
          bcc: Lista de destinatarios en copia oculta (opcional).
          attachments: Lista de adjuntos EmailAttachmentPy (opcional).
          headers: Headers MIME adicionales como diccionario (opcional).

      Raises:
          RuntimeError: Si la conexion SMTP falla, la autenticacion es incorrecta
                        o el envio no se puede completar.
      """
      cc = cc or []
      bcc = bcc or []
      attachments = attachments or []
      headers = headers or {}

      msg = _build_mime(from_addr, to, cc, subject, body_html, body_text, attachments, headers)

      all_recipients = list(to) + list(cc) + list(bcc)

      try:
          if cfg.tls_mode == "tls":
              smtp = smtplib.SMTP_SSL(cfg.host, cfg.port)
          else:
              smtp = smtplib.SMTP(cfg.host, cfg.port)

          with smtp:
              if cfg.tls_mode == "starttls":
                  smtp.starttls()
              if cfg.username:
                  smtp.login(cfg.username, cfg.password)
              smtp.sendmail(from_addr, all_recipients, msg.as_string())

      except smtplib.SMTPException as e:
          raise RuntimeError(f"smtp_send: SMTP error: {e}") from e
      except OSError as e:
          raise RuntimeError(f"smtp_send: connection error to {cfg.host}:{cfg.port}: {e}") from e


  def _build_mime(
      from_addr: str,
      to: list[str],
      cc: list[str],
      subject: str,
      body_html: str,
      body_text: str,
      attachments: list[EmailAttachmentPy],
      headers: dict[str, str],
  ) -> MIMEMultipart | MIMEText:
      """Construye la estructura MIME del mensaje."""
      has_text = bool(body_text)
      has_html = bool(body_html)
      has_atts = bool(attachments)

      if has_atts:
          outer = MIMEMultipart("mixed")
          _set_headers(outer, from_addr, to, cc, subject, headers)
          body_part = _build_body_part(body_html, body_text, has_html, has_text)
          outer.attach(body_part)
          for att in attachments:
              outer.attach(_build_attachment(att))
          return outer

      if has_html and has_text:
          alt = MIMEMultipart("alternative")
          _set_headers(alt, from_addr, to, cc, subject, headers)
          alt.attach(MIMEText(body_text, "plain", "utf-8"))
          alt.attach(MIMEText(body_html, "html", "utf-8"))
          return alt

      if has_html:
          msg = MIMEText(body_html, "html", "utf-8")
          _set_headers(msg, from_addr, to, cc, subject, headers)
          return msg

      msg = MIMEText(body_text or "", "plain", "utf-8")
      _set_headers(msg, from_addr, to, cc, subject, headers)
      return msg


  def _build_body_part(
      body_html: str, body_text: str, has_html: bool, has_text: bool
  ) -> MIMEBase:
      if has_html and has_text:
          alt = MIMEMultipart("alternative")
          alt.attach(MIMEText(body_text, "plain", "utf-8"))
          alt.attach(MIMEText(body_html, "html", "utf-8"))
          return alt
      if has_html:
          return MIMEText(body_html, "html", "utf-8")
      return MIMEText(body_text, "plain", "utf-8")


  def _set_headers(msg: MIMEBase, from_addr: str, to: list[str], cc: list[str], subject: str, extra: dict[str, str]) -> None:
      msg["From"] = from_addr
      msg["To"] = ", ".join(to)
      if cc:
          msg["Cc"] = ", ".join(cc)
      msg["Subject"] = subject
      for k, v in extra.items():
          msg[k] = v


  def _build_attachment(att: EmailAttachmentPy) -> MIMEBase:
      maintype, subtype = att.content_type.split("/", 1) if "/" in att.content_type else ("application", "octet-stream")
      part = MIMEBase(maintype, subtype)
      part.set_payload(att.data)
      encoders.encode_base64(part)
      part.add_header("Content-Disposition", "attachment", filename=att.filename)
      return part

params_schema:
  {"params":[{"name":"cfg","desc":"configuracion SMTP: host, port, username, password, tls_mode ('tls', 'starttls' o '')"},{"name":"from_addr","desc":"direccion del remitente"},{"name":"to","desc":"lista de destinatarios principales"},{"name":"subject","desc":"asunto del correo"},{"name":"body_html","desc":"cuerpo HTML (opcional; puede estar vacio si body_text esta presente)"},{"name":"body_text","desc":"cuerpo de texto plano (opcional; puede estar vacio si body_html esta presente)"},{"name":"cc","desc":"lista de destinatarios en copia visible (opcional)"},{"name":"bcc","desc":"lista de destinatarios en copia oculta (opcional)"},{"name":"attachments","desc":"lista de EmailAttachmentPy con filename, content_type y data binarios (opcional)"},{"name":"headers","desc":"diccionario de headers MIME adicionales como X-Mailer (opcional)"}],"output":"None si el envio fue exitoso; lanza RuntimeError con descripcion del fallo SMTP"}


id: metabase_add_dashboard_filter_py_infra
name: metabase_add_dashboard_filter
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","dashboard","filter","parameter","create","api","python"]
uses_functions: ["metabase_update_dashboard_safe_py_infra","metabase_get_dashboard_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["uuid"]
tests: []
file_path: python/functions/metabase/dashboard_filters.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 1bceafe736a9c3a667d3ea9e2c07c972eae63030a0a9eeb3c6aab321d4ba1dbf
signature:
  def metabase_add_dashboard_filter(client: MetabaseClient, dashboard_id: int, name: str, slug: str, filter_type: str, card_mappings: list[dict], *, default: str | None = None) -> dict

description:
  Añade un filtro a un dashboard de Metabase y lo conecta a las cards indicadas. Crea el parameter a nivel de dashboard y actualiza los parameter_mappings de cada dashcard.

example:
  ```python
  result = metabase_add_dashboard_filter(
      client,
      dashboard_id=887,
      name="Fecha desde",
      slug="fecha_desde",
      filter_type="string/=",
      card_mappings=[
          {"card_id": 7711, "template_tag": "fecha_desde"},
          {"card_id": 7719, "template_tag": "fecha_desde"},
      ],
  )

  # Con valor por defecto
  result = metabase_add_dashboard_filter(
      client,
      dashboard_id=887,
      name="Estado",
      slug="estado",
      filter_type="category",
      card_mappings=[{"card_id": 7711, "template_tag": "estado"}],
      default="activo",
  )
  ```

notes:
  Realiza 2 requests internamente via metabase_update_dashboard_safe (GET + PUT).

  El `param_id` se genera como UUID hex de 8 caracteres — formato que usa la UI de Metabase.

  `card_mappings` solo conecta cards que ya existen como dashcards en el dashboard. Cards no presentes en el dashboard se ignoran silenciosamente.

  Para filtros de tipo date, los valores en `default` deben seguir el formato de Metabase: "2024-01-01" para `date/single`, "2024-01-01~2024-12-31" para `date/range`.

code:
  """Helpers para añadir filtros a dashboards de Metabase."""

  import uuid as _uuid

  from .client import MetabaseClient
  from .metabase_update_dashboard_safe import metabase_update_dashboard_safe


  def metabase_add_dashboard_filter(
      client: MetabaseClient,
      dashboard_id: int,
      name: str,
      slug: str,
      filter_type: str,
      card_mappings: list[dict],
      *,
      default: str | None = None,
  ) -> dict:
      """Añade un filtro a un dashboard y lo conecta a las cards indicadas.

      Crea un parametro a nivel de dashboard y actualiza los parameter_mappings
      de cada dashcard indicada. Usa metabase_update_dashboard_safe internamente.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.
          name: Nombre visible del filtro (ej: "Fecha desde").
          slug: Slug URL-friendly (ej: "fecha_desde").
          filter_type: Tipo del filtro. Comunes: "string/=", "number/=",
              "date/single", "date/range", "date/relative", "category".
          card_mappings: Lista de dicts con la conexión filtro→card. Formato:
              [{"card_id": 42, "template_tag": "fecha_desde"}, ...]
              Cada dict indica qué card y qué template-tag SQL debe recibir el valor.
          default: Valor por defecto del filtro (opcional).

      Returns:
          Dict con resultado de metabase_update_dashboard_safe.

      Example:
          >>> result = metabase_add_dashboard_filter(
          ...     client, 887, "Fecha desde", "fecha_desde", "string/=",
          ...     [{"card_id": 7711, "template_tag": "fecha_desde"},
          ...      {"card_id": 7719, "template_tag": "fecha_desde"}],
          ... )
      """
      from .dashboards import metabase_get_dashboard

      # 1. Leer dashboard actual
      dash = metabase_get_dashboard(client, dashboard_id)
      current_params = dash.get("parameters", [])
      current_dashcards = dash.get("dashcards", [])

      # 2. Crear el parameter
      param_id = _uuid.uuid4().hex[:8]
      new_param = {
          "id": param_id,
          "type": filter_type,
          "name": name,
          "slug": slug,
      }
      if default is not None:
          new_param["default"] = default

      updated_params = current_params + [new_param]

      # 3. Build card_id → template_tag mapping
      tag_by_card = {m["card_id"]: m["template_tag"] for m in card_mappings}

      # 4. Update dashcards with parameter_mappings
      updated_dashcards = []
      for dc in current_dashcards:
          dc_card_id = dc.get("card_id")
          if dc_card_id in tag_by_card:
              mappings = list(dc.get("parameter_mappings", []))
              mappings.append({
                  "parameter_id": param_id,
                  "card_id": dc_card_id,
                  "target": ["variable", ["template-tag", tag_by_card[dc_card_id]]],
              })
              dc_copy = {**dc, "parameter_mappings": mappings}
              updated_dashcards.append(dc_copy)
          else:
              updated_dashcards.append(dc)

      # 5. Update via safe function
      return metabase_update_dashboard_safe(
          client, dashboard_id,
          dashcards_update=updated_dashcards,
          extra_fields={"parameters": updated_params},
      )

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"dashboard_id","desc":"ID del dashboard al que se añade el filtro"},{"name":"name","desc":"nombre visible del filtro en la UI de Metabase (ej: 'Fecha desde')"},{"name":"slug","desc":"slug URL-friendly del filtro, unico dentro del dashboard (ej: 'fecha_desde')"},{"name":"filter_type","desc":"tipo del filtro: 'string/=', 'number/=', 'date/single', 'date/range', 'date/relative', 'category'"},{"name":"card_mappings","desc":"lista de dicts [{card_id: int, template_tag: str}] conectando el filtro a template-tags SQL de las cards"},{"name":"default","desc":"valor por defecto del filtro; None = sin default"}],"output":"dict con resultado de metabase_update_dashboard_safe: {added, updated, removed, response}"}


id: metabase_add_database_py_infra
name: metabase_add_database
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_add_database(client: MetabaseClient, name: str, engine: str, details: dict) -> dict
tags: ["metabase","database","add","create","api","python"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/databases.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: 4e1f8590792b10e8f276024a964c9a98fd5d46e8e2a4fb6f6a56ced986d5a93a
description:
  Agrega una nueva database a Metabase via POST /api/database. Soporta cualquier engine (sqlite, postgres, mysql, etc.).

example:
  ```python
  # SQLite
  db = metabase_add_database(client, "Ops DB", "sqlite", {"db": "/data/operations.db"})
  print(db["id"])

  # Postgres
  db = metabase_add_database(client, "Prod PG", "postgres", {
      "host": "localhost",
      "port": 5432,
      "dbname": "myapp",
      "user": "reader",
      "password": "secret",
  })
  ```

notes:
  Requiere permisos de superusuario. El campo `details` depende del engine:
  para SQLite solo necesita `{"db": "/ruta/archivo.db"}`.
  Retorna la database creada con su `id` asignado por Metabase.

code:
  """CRUD de databases de Metabase."""

  from .client import MetabaseClient


  def metabase_list_databases(
      client: MetabaseClient,
      include_tables: bool = False,
  ) -> list:
      """Lista las databases configuradas en Metabase.

      Endpoint: GET /api/database.

      Args:
          client: Cliente autenticado.
          include_tables: Si True, incluye las tablas de cada database en la respuesta.

      Returns:
          Lista de dicts, cada uno con campos de la database:
          - id: ID numerico de la database
          - name: Nombre dado en Metabase
          - engine: Motor de BD (sqlite, postgres, mysql, etc.)
          - details: Dict con parametros de conexion
          - is_full_sync: Si sincroniza el schema automaticamente
          - tables: Lista de tablas (solo si include_tables=True)

      Example:
          >>> dbs = metabase_list_databases(client)
          >>> for db in dbs:
          ...     print(db["id"], db["name"], db["engine"])

          >>> dbs = metabase_list_databases(client, include_tables=True)
          >>> for db in dbs:
          ...     print(db["name"], [t["name"] for t in db.get("tables", [])])
      """
      params = {}
      if include_tables:
          params["include"] = "tables"
      result = client.request("GET", "/api/database", params=params)
      if isinstance(result, dict) and "data" in result:
          return result["data"]
      return result


  def metabase_add_database(
      client: MetabaseClient,
      name: str,
      engine: str,
      details: dict,
  ) -> dict:
      """Agrega una nueva database a Metabase.

      Endpoint: POST /api/database. Requiere permisos de superusuario.

      Args:
          client: Cliente autenticado con permisos admin.
          name: Nombre descriptivo para la database en Metabase.
          engine: Motor de base de datos. Ejemplos: "sqlite", "postgres",
              "mysql", "h2", "mongo", "bigquery".
          details: Dict con parametros de conexion especificos del engine.
              Para SQLite: {"db": "/ruta/al/archivo.db"}
              Para Postgres: {"host": "...", "port": 5432, "dbname": "...",
                             "user": "...", "password": "..."}

      Returns:
          Dict con la database creada, incluyendo el campo "id" asignado
          por Metabase y todos los campos de configuracion.

      Raises:
          httpx.HTTPStatusError: 400 si los datos de conexion son invalidos.

      Example:
          >>> db = metabase_add_database(client, "Mi SQLite", "sqlite", {"db": "/data/ops.db"})
          >>> print(db["id"], db["name"])
      """
      body = {
          "name": name,
          "engine": engine,
          "details": details,
      }
      return client.request("POST", "/api/database", json=body)


  def metabase_get_database(client: MetabaseClient, database_id: int) -> dict:
      """Obtiene los detalles de una database de Metabase por su ID.

      Endpoint: GET /api/database/:id.

      Args:
          client: Cliente autenticado.
          database_id: ID numerico de la database.

      Returns:
          Dict con campos de la database: id, name, engine, details,
          is_full_sync, is_on_demand, auto_run_queries, created_at,
          updated_at, features, etc.

      Raises:
          httpx.HTTPStatusError: 404 si la database no existe.

      Example:
          >>> db = metabase_get_database(client, 2)
          >>> print(db["name"], db["engine"])
      """
      return client.request("GET", f"/api/database/{database_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"name","desc":"nombre descriptivo de la base de datos en Metabase"},{"name":"engine","desc":"tipo de base de datos (sqlite, postgres, mysql, etc.)"},{"name":"details","desc":"dict con parámetros de conexión específicos del engine"}],"output":"dict: objeto database creado con id asignado por Metabase"}


id: metabase_add_membership_py_infra
name: metabase_add_membership
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","permissions","membership","groups","users","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/permissions.py
created_at: 2026-04-13T13:35:56Z
updated_at: 2026-04-13T13:35:56Z
props: []
emits: []
variant: []
content_hash: 89d04720b4e20e7651652b127801d4f04587bd8e760c3733f8e21e9ab7760372
signature:
  def metabase_add_membership(client: MetabaseClient, user_id: int, group_id: int, is_group_manager: bool = False) -> list[dict]

description:
  Añade un usuario a un Permission Group de Metabase. Endpoint: POST /api/permissions/membership. Retorna la lista completa de membresías del grupo tras la operación.

example:
  ```python
  # Añadir usuario como miembro regular
  members = metabase_add_membership(client, user_id=5, group_id=3)
  print(len(members), "miembros en el grupo")

  # Añadir como manager del grupo
  members = metabase_add_membership(client, user_id=5, group_id=3, is_group_manager=True)
  ```

notes:
  Metabase responde con la lista completa de membresías del grupo (no solo la nueva). Lanza HTTPStatusError 400 si el usuario ya es miembro del grupo. El `membership_id` de la entrada creada está en el elemento nuevo de la lista retornada.

code:
  """CRUD de Permission Groups de Metabase."""

  from .client import MetabaseClient


  def metabase_list_groups(client: MetabaseClient) -> list[dict]:
      """Lista todos los Permission Groups de Metabase.

      Endpoint: GET /api/permissions/group. Requiere superusuario.

      Args:
          client: Cliente autenticado con permisos admin.

      Returns:
          Lista de dicts, cada uno con: id, name, member_count.

      Example:
          >>> groups = metabase_list_groups(client)
          >>> for g in groups:
          ...     print(g["id"], g["name"], g["member_count"])
      """
      return client.request("GET", "/api/permissions/group")


  def metabase_get_group(client: MetabaseClient, group_id: int) -> dict:
      """Obtiene un Permission Group por su ID, incluyendo la lista completa de miembros.

      Endpoint: GET /api/permissions/group/:id. Requiere superusuario.

      Args:
          client: Cliente autenticado con permisos admin.
          group_id: ID numerico del grupo.

      Returns:
          Dict con: id, name, members (lista de dicts con user_id, email,
          first_name, last_name, membership_id).

      Raises:
          httpx.HTTPStatusError: 404 si el grupo no existe.

      Example:
          >>> group = metabase_get_group(client, 3)
          >>> print(group["name"])
          >>> for m in group["members"]:
          ...     print(m["email"])
      """
      return client.request("GET", f"/api/permissions/group/{group_id}")


  def metabase_create_group(client: MetabaseClient, name: str) -> dict:
      """Crea un nuevo Permission Group en Metabase.

      Endpoint: POST /api/permissions/group. Requiere superusuario.

      Args:
          client: Cliente autenticado con permisos admin.
          name: Nombre del grupo. Debe ser unico.

      Returns:
          Dict con el grupo creado: id, name.

      Raises:
          httpx.HTTPStatusError: 400 si el nombre ya existe.

      Example:
          >>> group = metabase_create_group(client, "Analytics Team")
          >>> print(group["id"], group["name"])
      """
      return client.request("POST", "/api/permissions/group", json={"name": name})


  def metabase_update_group(client: MetabaseClient, group_id: int, name: str) -> dict:
      """Renombra un Permission Group existente en Metabase.

      Endpoint: PUT /api/permissions/group/:id. Requiere superusuario.
      La API solo permite modificar el nombre del grupo.

      Args:
          client: Cliente autenticado con permisos admin.
          group_id: ID numerico del grupo a renombrar.
          name: Nuevo nombre del grupo.

      Returns:
          Dict con el grupo actualizado: id, name.

      Raises:
          httpx.HTTPStatusError: 404 si el grupo no existe.

      Example:
          >>> group = metabase_update_group(client, 3, "Data Team")
          >>> print(group["name"])
      """
      return client.request("PUT", f"/api/permissions/group/{group_id}", json={"name": name})


  def metabase_delete_group(client: MetabaseClient, group_id: int) -> None:
      """Elimina permanentemente un Permission Group de Metabase.

      Endpoint: DELETE /api/permissions/group/:id. IRREVERSIBLE.
      Requiere superusuario.

      Los grupos especiales del sistema no deben borrarse:
      - id=1: "All Users" (todos los usuarios pertenecen a este grupo)
      - id=2: "Administrators"
      Esta funcion NO bloquea el borrado de esos IDs — es responsabilidad
      del caller verificar que no se pasen IDs protegidos.

      Args:
          client: Cliente autenticado con permisos admin.
          group_id: ID numerico del grupo a eliminar.

      Raises:
          httpx.HTTPStatusError: 404 si el grupo no existe.

      Example:
          >>> metabase_delete_group(client, 5)
          >>> # CUIDADO: no pasar group_id=1 (All Users) ni group_id=2 (Administrators)
      """
      client.request("DELETE", f"/api/permissions/group/{group_id}")


  # --- Memberships ---


  def metabase_list_memberships(client: MetabaseClient) -> dict[str, list[dict]]:
      """Lista todas las membresías de grupos de Metabase.

      Endpoint: GET /api/permissions/membership. Requiere superusuario.

      La respuesta nativa de Metabase es un dict con group_id (str) como clave,
      y una lista de membresías como valor — no una lista plana.

      Args:
          client: Cliente autenticado con permisos admin.

      Returns:
          Dict mapeando group_id (str) a lista de dicts, cada uno con:
          membership_id, user_id, group_id, is_group_manager.

      Example:
          >>> memberships = metabase_list_memberships(client)
          >>> for group_id, members in memberships.items():
          ...     for m in members:
          ...         print(m["user_id"], m["membership_id"], m["is_group_manager"])
      """
      return client.request("GET", "/api/permissions/membership")


  def metabase_add_membership(
      client: MetabaseClient,
      user_id: int,
      group_id: int,
      is_group_manager: bool = False,
  ) -> list[dict]:
      """Añade un usuario a un Permission Group de Metabase.

      Endpoint: POST /api/permissions/membership. Requiere superusuario.

      Args:
          client: Cliente autenticado con permisos admin.
          user_id: ID del usuario a añadir al grupo.
          group_id: ID del grupo destino.
          is_group_manager: Si True, el usuario es manager del grupo.

      Returns:
          Lista de dicts con todas las membresias actuales del grupo tras la operacion.
          Cada elemento tiene: membership_id, user_id, group_id, is_group_manager.

      Raises:
          httpx.HTTPStatusError: 400 si el usuario ya es miembro del grupo.

      Example:
          >>> members = metabase_add_membership(client, user_id=5, group_id=3)
          >>> print(len(members), "miembros en el grupo")
          >>> # Como manager:
          >>> members = metabase_add_membership(client, user_id=5, group_id=3, is_group_manager=True)
      """
      body = {
          "user_id": user_id,
          "group_id": group_id,
          "is_group_manager": is_group_manager,
      }
      return client.request("POST", "/api/permissions/membership", json=body)


  def metabase_delete_membership(client: MetabaseClient, membership_id: int) -> None:
      """Elimina una membresía de grupo en Metabase por su membership_id.

      Endpoint: DELETE /api/permissions/membership/:id. Requiere superusuario.

      IMPORTANTE: No se borra por user_id + group_id. Hay que conocer el
      membership_id exacto, que se obtiene via metabase_list_memberships.

      Args:
          client: Cliente autenticado con permisos admin.
          membership_id: ID de la membresía a eliminar (no el user_id ni group_id).

      Raises:
          httpx.HTTPStatusError: 404 si la membresía no existe.

      Example:
          >>> # Primero obtener el membership_id
          >>> all_memberships = metabase_list_memberships(client)
          >>> group_members = all_memberships.get("3", [])
          >>> membership_id = next(m["membership_id"] for m in group_members if m["user_id"] == 5)
          >>> metabase_delete_membership(client, membership_id)
      """
      client.request("DELETE", f"/api/permissions/membership/{membership_id}")


  # --- Data Permission Graph ---


  def metabase_get_permission_graph(client: MetabaseClient) -> dict:
      """Obtiene el grafo de permisos de datos (databases/schemas/tables) de Metabase.

      Endpoint: GET /api/permissions/graph. Requiere superusuario.

      El campo `revision` es CRITICO para concurrency control: el servidor rechaza
      PUT /api/permissions/graph si el revision no coincide con el actual (HTTP 409).
      Siempre traer el graph fresco antes de modificar.

      Args:
          client: Cliente autenticado con permisos admin.

      Returns:
          Dict con:
          - revision (int): numero de revision actual. Obligatorio para el PUT.
          - groups (dict): mapa group_id -> db_id -> permisos. Estructura por db:
              - schemas: "all" | "none" | dict de schema -> tabla -> permisos
              - native: "write" | "read" | "none" (acceso a SQL nativo)

      Example:
          >>> graph = metabase_get_permission_graph(client)
          >>> print("revision:", graph["revision"])
          >>> for group_id, dbs in graph["groups"].items():
          ...     for db_id, perms in dbs.items():
          ...         print(f"group={group_id} db={db_id}: {perms}")
      """
      return client.request("GET", "/api/permissions/graph")


  def metabase_update_permission_graph(client: MetabaseClient, graph: dict) -> dict:
      """Actualiza el grafo de permisos de datos en Metabase.

      Endpoint: PUT /api/permissions/graph. Requiere superusuario.

      ## Control de concurrencia por revision

      El campo `graph["revision"]` es obligatorio y debe ser el valor actual del
      servidor. Si otro proceso modifico el graph entre tu GET y este PUT, Metabase
      devuelve HTTP 409 Conflict. El patron correcto es:

          1. graph = metabase_get_permission_graph(client)       # GET fresco
          2. Modificar graph["groups"][group_id][db_id] = ...    # editar en memoria
          3. graph = metabase_update_permission_graph(client, graph)  # PUT con revision

      Nunca cachear el graph — siempre hacer GET justo antes del PUT.

      Args:
          client: Cliente autenticado con permisos admin.
          graph: Dict con el graph completo incluyendo el campo `revision` actual.
                 Obtenerlo via metabase_get_permission_graph antes de modificar.

      Returns:
          Dict con el nuevo graph tras la actualizacion, con `revision` incrementado.

      Raises:
          httpx.HTTPStatusError: 409 si el revision en el body no coincide con el actual.

      Example:
          >>> graph = metabase_get_permission_graph(client)
          >>> # Dar acceso completo al grupo 3 sobre la database 1
          >>> graph["groups"]["3"]["1"] = {"schemas": "all", "native": "write"}
          >>> updated = metabase_update_permission_graph(client, graph)
          >>> print("nueva revision:", updated["revision"])
      """
      return client.request("PUT", "/api/permissions/graph", json=graph)


  # --- Collection Permission Graph ---


  def metabase_get_collection_graph(
      client: MetabaseClient,
      namespace: str | None = None,
  ) -> dict:
      """Obtiene el grafo de permisos de colecciones de Metabase.

      Endpoint: GET /api/collection/graph. Requiere superusuario.

      El campo `revision` es CRITICO para concurrency control: el servidor rechaza
      PUT si el revision no coincide con el actual.

      Args:
          client: Cliente autenticado con permisos admin.
          namespace: Namespace opcional. "snippets" para snippet collections.
                     None = colecciones regulares.

      Returns:
          Dict con:
          - revision (int): numero de revision actual. Obligatorio para el PUT.
          - groups (dict): mapa group_id -> collection_id -> nivel de acceso.
              Nivel de acceso: "read" | "write" | "none".

      Example:
          >>> graph = metabase_get_collection_graph(client)
          >>> print("revision:", graph["revision"])
          >>> for group_id, colls in graph["groups"].items():
          ...     for coll_id, access in colls.items():
          ...         print(f"group={group_id} collection={coll_id}: {access}")
          >>> # Snippet collections:
          >>> snippet_graph = metabase_get_collection_graph(client, namespace="snippets")
      """
      params = {}
      if namespace is not None:
          params["namespace"] = namespace
      return client.request("GET", "/api/collection/graph", params=params or None)


  def metabase_update_collection_graph(
      client: MetabaseClient,
      graph: dict,
      namespace: str | None = None,
  ) -> dict:
      """Actualiza el grafo de permisos de colecciones en Metabase.

      Endpoint: PUT /api/collection/graph. Requiere superusuario.

      ## Control de concurrencia por revision

      El campo `graph["revision"]` es obligatorio y debe ser el valor actual del
      servidor. Si otro proceso modifico el graph entre tu GET y este PUT, Metabase
      devuelve HTTP 409 Conflict. El patron correcto es:

          1. graph = metabase_get_collection_graph(client)            # GET fresco
          2. Modificar graph["groups"][group_id][collection_id] = ... # editar en memoria
          3. graph = metabase_update_collection_graph(client, graph)  # PUT con revision

      Nunca cachear el graph — siempre hacer GET justo antes del PUT.

      Args:
          client: Cliente autenticado con permisos admin.
          graph: Dict con el graph completo incluyendo el campo `revision` actual.
                 Obtenerlo via metabase_get_collection_graph antes de modificar.
          namespace: Namespace opcional. "snippets" para snippet collections.
                     None = colecciones regulares.

      Returns:
          Dict con el nuevo graph tras la actualizacion, con `revision` incrementado.

      Raises:
          httpx.HTTPStatusError: 409 si el revision en el body no coincide con el actual.

      Example:
          >>> graph = metabase_get_collection_graph(client)
          >>> # Dar acceso write al grupo 3 sobre la coleccion 5
          >>> graph["groups"]["3"]["5"] = "write"
          >>> updated = metabase_update_collection_graph(client, graph)
          >>> print("nueva revision:", updated["revision"])
          >>> # Para snippet collections:
          >>> graph = metabase_get_collection_graph(client, namespace="snippets")
          >>> graph["groups"]["3"]["root"] = "write"
          >>> updated = metabase_update_collection_graph(client, graph, namespace="snippets")
      """
      params = {}
      if namespace is not None:
          params["namespace"] = namespace
      return client.request("PUT", "/api/collection/graph", json=graph, params=params or None)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient con permisos de superusuario"},{"name":"user_id","desc":"ID numérico del usuario a añadir al grupo"},{"name":"group_id","desc":"ID numérico del grupo destino"},{"name":"is_group_manager","desc":"si True, el usuario obtiene rol de manager del grupo (puede gestionar sus miembros)"}],"output":"list[dict]: lista de todas las membresías actuales del grupo tras la operación. Cada elemento contiene membership_id, user_id, group_id, is_group_manager"}


id: metabase_archive_document_py_infra
name: metabase_archive_document
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict
description: Archiva un document (PUT archived=True). Prerequisito para poder eliminarlo.
tags: ["metabase","document","archive","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/documents.py
created_at: 2026-04-13T12:54:37Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 5aa772c82bb3c46ceaa02dd53ef8ddba0f4c4a3f7c5e35a9c33db18a0e26de93
example:
  ```python
  metabase_archive_document(client, 1)
  metabase_delete_document(client, 1)  # ahora si puede eliminarse
  ```

code:
  """CRUD de documents de Metabase (feature reciente, Metabase 0.57+).

  Un document es un texto editable en Metabase (tipo Notion) serializado como
  ProseMirror JSON (content_type: application/json+vnd.prose-mirror). Permite
  embeber cards, smart links y flex containers.

  NODOS QUE RENDERIZAN (whitelist TipTap v0.59):
    doc, paragraph, text, heading (level 1-6),
    bulletList, orderedList, listItem,
    blockquote, codeBlock (attrs.language), horizontalRule, hardBreak,
    cardEmbed (attrs.id), flexContainer, smartLink (attrs.entityId),
    resizeNode, mention.

  NODOS QUE LA API ACEPTA PERO EL FRONTEND IGNORA (doc queda vacio):
    callout, taskList, taskItem, details, table, tableRow, tableCell,
    image, iframe.

  MARKS QUE RENDERIZAN: bold, italic, strike, code, link (attrs.href).
  MARKS IGNORADOS: underline, highlight, subscript, textStyle.

  IMPORTANTE — cardEmbed:
    Un cardEmbed desnudo renderiza pero queda muy pequeño (alto ~50px).
    Para que se vea correctamente, envolver en un resizeNode:

      {"type": "resizeNode",
       "attrs": {"height": 400, "minHeight": 280},
       "content": [
           {"type": "cardEmbed", "attrs": {"id": <card_id>}}
       ]}

    Usar el helper `prosemirror_card_embed(card_id)` para generar esto
    automaticamente.
  """

  import uuid

  from .client import MetabaseClient


  def prosemirror_card_embed(card_id: int, height: int = 400) -> dict:
      """Genera un nodo cardEmbed envuelto en resizeNode listo para ProseMirror.

      Un cardEmbed desnudo renderiza pero queda muy pequeño (~50px). Metabase
      espera que vaya dentro de un resizeNode con height/minHeight para que
      se vea con tamaño adecuado.

      Args:
          card_id: ID de la card/pregunta de Metabase a embeber.
          height: Altura en pixeles del embed (default 400). minHeight se
                  fija en 280 (lo que usa la UI de Metabase).

      Returns:
          Dict ProseMirror: resizeNode > cardEmbed, insertable directamente
          en el array content de un document.

      Example:
          >>> node = prosemirror_card_embed(7711, height=500)
          >>> doc = {"type": "doc", "content": [
          ...     {"type": "heading", "attrs": {"level": 1},
          ...      "content": [{"type": "text", "text": "Mi reporte"}]},
          ...     node,
          ... ]}
      """
      return {
          "type": "resizeNode",
          "attrs": {"height": height, "minHeight": 280},
          "content": [
              {
                  "type": "cardEmbed",
                  "attrs": {"id": card_id, "name": None, "_id": str(uuid.uuid4())},
              }
          ],
      }


  def _validate_before_send(name: str, document: dict | str) -> None:
      """Valida el payload del document antes de enviarlo. Raises ValueError."""
      if not document or document == "":
          return
      if not isinstance(document, dict):
          return
      from .validation import metabase_validate_document_payload

      issues = metabase_validate_document_payload({"name": name, "document": document})
      if issues:
          raise ValueError(
              f"Document no renderizará correctamente en Metabase "
              f"({len(issues)} issues):\n" + "\n".join(f"  - {i}" for i in issues)
          )


  def metabase_list_documents(client: MetabaseClient) -> list[dict]:
      """Lista documents de Metabase.

      Endpoint: GET /api/document. Retorna `{"items": [...]}` segun la API.

      Args:
          client: Cliente autenticado.

      Returns:
          Lista de dicts con: id, name, collection_id, archived, created_at,
          updated_at, creator_id, content_type, entity_id.

      Example:
          >>> docs = metabase_list_documents(client)
          >>> for d in docs:
          ...     print(d["id"], d["name"])
      """
      resp = client.request("GET", "/api/document")
      if isinstance(resp, dict) and "items" in resp:
          return resp["items"]
      return resp or []


  def metabase_get_document(client: MetabaseClient, document_id: int) -> dict:
      """Obtiene un document completo con su contenido ProseMirror.

      Endpoint: GET /api/document/:id.

      Args:
          client: Cliente autenticado.
          document_id: ID del document.

      Returns:
          Dict con: id, name, document (ProseMirror tree), collection_id,
          archived, creator, created_at, updated_at, entity_id, content_type.

      Example:
          >>> doc = metabase_get_document(client, 1)
          >>> print(doc["name"])
          >>> tree = doc["document"]  # {"type": "doc", "content": [...]}
      """
      return client.request("GET", f"/api/document/{document_id}")


  def metabase_create_document(
      client: MetabaseClient,
      name: str,
      document: dict,
      collection_id: int = 0,
      *,
      validate: bool = True,
  ) -> dict:
      """Crea un document nuevo.

      Endpoint: POST /api/document.

      Valida el arbol ProseMirror ANTES de enviar (por defecto). Si el
      documento contiene nodos que la API acepta pero el frontend ignora
      (callout, taskList, image, etc.), lanza ValueError con los issues.
      Pasar validate=False para desactivar (uso bajo tu riesgo).

      Para embeber cards, usar prosemirror_card_embed(card_id) que genera
      el nodo resizeNode > cardEmbed con la altura correcta.

      Args:
          client: Cliente autenticado.
          name: Titulo del document (1-254 chars, no blank).
          document: Arbol ProseMirror JSON. Formato minimo:
              {"type": "doc", "content": [{"type": "paragraph", "content": [...]}]}
              O cadena vacia "" si se quiere arrancar en blanco.
          collection_id: ID de coleccion destino. 0 = root.
          validate: Si True (default), valida el ProseMirror antes de enviar.

      Returns:
          Document creado con su id asignado.

      Raises:
          ValueError: Si validate=True y el arbol ProseMirror contiene
              nodos o marks que el frontend de Metabase no renderiza.

      Example:
          >>> from metabase.documents import prosemirror_card_embed
          >>> doc = metabase_create_document(client, "Reporte", {
          ...     "type": "doc",
          ...     "content": [
          ...         {"type": "heading", "attrs": {"level": 1},
          ...          "content": [{"type": "text", "text": "KPIs"}]},
          ...         prosemirror_card_embed(42, height=450),
          ...     ]
          ... })
      """
      if validate:
          _validate_before_send(name, document)
      body: dict = {"name": name, "document": document}
      if collection_id > 0:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/document", json=body)


  def metabase_update_document(
      client: MetabaseClient,
      document_id: int,
      *,
      validate: bool = True,
      **fields,
  ) -> dict:
      """Actualiza un document. Solo se envian los campos pasados.

      Endpoint: PUT /api/document/:id.

      Si se pasa el campo 'document', valida el arbol ProseMirror antes de
      enviar (por defecto). Pasar validate=False para desactivar.

      Campos tipicos: name, document, collection_id, archived.

      Args:
          client: Cliente autenticado.
          document_id: ID del document a actualizar.
          validate: Si True (default), valida el ProseMirror antes de enviar.
          **fields: Campos a modificar.

      Returns:
          Document actualizado.

      Raises:
          ValueError: Si validate=True y el campo 'document' contiene
              nodos o marks no soportados por el frontend de Metabase.

      Example:
          >>> metabase_update_document(client, 1, name="Nuevo titulo")
          >>> metabase_update_document(client, 1, document={"type":"doc","content":[...]})
      """
      if validate and "document" in fields:
          name = fields.get("name", f"document_{document_id}")
          _validate_before_send(name, fields["document"])
      return client.request("PUT", f"/api/document/{document_id}", json=fields)


  def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict:
      """Archiva un document (equivalente a PUT con archived=True).

      Metabase exige archive previo para poder eliminar.

      Args:
          client: Cliente autenticado.
          document_id: ID del document.

      Returns:
          Document con archived=True.
      """
      return client.request("PUT", f"/api/document/{document_id}", json={"archived": True})


  def metabase_list_document_comments(
      client: MetabaseClient,
      document_id: int,
      *,
      include_resolved: bool = True,
      include_deleted: bool = False,
  ) -> list[dict]:
      """Lista los comentarios de un document.

      Endpoint: GET /api/comment?target_type=document&target_id=:id.

      Un comentario puede estar anclado a un bloque especifico via
      `child_target_id` (UUID que matchea `attrs._id` de un parrafo) o al
      document entero si `child_target_id` es null. Las respuestas tipo thread
      cuelgan via `parent_comment_id`.

      Args:
          client: Cliente autenticado.
          document_id: ID del document.
          include_resolved: si False, filtra comentarios con is_resolved=True.
          include_deleted: si False, filtra comentarios con deleted_at no null.

      Returns:
          Lista de dicts con: id, content (arbol ProseMirror), creator, creator_id,
          target_type, target_id, child_target_id, parent_comment_id, is_resolved,
          deleted_at, reactions, created_at, updated_at.

      Example:
          >>> comments = metabase_list_document_comments(client, 29)
          >>> for c in comments:
          ...     text = _comment_plaintext(c["content"])
          ...     print(f'{c["creator"]["common_name"]}: {text}')
      """
      resp = client.request(
          "GET",
          "/api/comment",
          params={"target_type": "document", "target_id": document_id},
      )
      items = resp.get("comments", []) if isinstance(resp, dict) else []
      if not include_resolved:
          items = [c for c in items if not c.get("is_resolved")]
      if not include_deleted:
          items = [c for c in items if c.get("deleted_at") is None]
      return items


  def metabase_create_document_comment(
      client: MetabaseClient,
      document_id: int,
      content: dict,
      *,
      child_target_id: str | None = None,
      parent_comment_id: int | None = None,
  ) -> dict:
      """Crea un comentario en un document.

      Endpoint: POST /api/comment.

      Args:
          client: Cliente autenticado.
          document_id: ID del document donde comentar.
          content: Arbol ProseMirror del comentario. Ejemplo minimo:
              {"type":"doc","content":[{"type":"paragraph","content":[
                  {"type":"text","text":"mi comentario"}]}]}
          child_target_id: UUID del bloque del document al que se ancla el
              comentario (matchea `attrs._id` de un parrafo). None = a nivel doc.
          parent_comment_id: id del comentario al que se responde. None = top-level.

      Returns:
          Comentario creado.
      """
      body: dict = {
          "target_type": "document",
          "target_id": document_id,
          "content": content,
      }
      if child_target_id is not None:
          body["child_target_id"] = child_target_id
      if parent_comment_id is not None:
          body["parent_comment_id"] = parent_comment_id
      return client.request("POST", "/api/comment", json=body)


  def metabase_resolve_document_comment(client: MetabaseClient, comment_id: int) -> dict:
      """Marca un comentario como resuelto (is_resolved=True).

      Endpoint: PUT /api/comment/:id.
      """
      return client.request("PUT", f"/api/comment/{comment_id}", json={"is_resolved": True})


  def metabase_delete_document(client: MetabaseClient, document_id: int) -> None:
      """Elimina un document. REQUIERE archivarlo primero.

      Endpoint: DELETE /api/document/:id. Si el document no esta archivado,
      Metabase responde: "Document must be archived before it can be deleted."

      Args:
          client: Cliente autenticado.
          document_id: ID del document a eliminar.
      """
      client.request("DELETE", f"/api/document/{document_id}")


  def metabase_move_document(
      client: MetabaseClient,
      document_id: int,
      collection_id: int | None,
  ) -> dict:
      """Mueve un document a otra coleccion.

      Thin wrapper sobre metabase_update_document: envia solo collection_id.
      Endpoint: PUT /api/document/:id con {collection_id: ...}.

      Args:
          client: Cliente autenticado.
          document_id: ID del document a mover.
          collection_id: ID de coleccion destino. None mueve a la raiz ("Our analytics").

      Returns:
          Document actualizado con el nuevo collection_id.

      Example:
          >>> doc = metabase_move_document(client, 42, collection_id=7)
          >>> print(doc["collection_id"])  # 7
          >>> # Mover a raiz:
          >>> doc = metabase_move_document(client, 42, collection_id=None)
      """
      return client.request(
          "PUT",
          f"/api/document/{document_id}",
          json={"collection_id": collection_id},
      )


  def metabase_copy_document(
      client: MetabaseClient,
      document_id: int,
      name: str | None = None,
      collection_id: int | None = None,
  ) -> dict:
      """Copia un document usando el endpoint nativo de Metabase.

      Endpoint: POST /api/document/:id/copy.

      Args:
          client: Cliente autenticado.
          document_id: ID del document a copiar.
          name: Nombre del nuevo document. None = Metabase asigna nombre automatico.
          collection_id: Coleccion destino. None = misma coleccion del original.

      Returns:
          Document nuevo recien creado con su id asignado.

      Example:
          >>> copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5)
          >>> print(copy["id"])
      """
      body: dict = {}
      if name is not None:
          body["name"] = name
      if collection_id is not None:
          body["collection_id"] = collection_id
      return client.request("POST", f"/api/document/{document_id}/copy", json=body)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"document_id","desc":"ID del document a archivar"}],"output":"dict: document con archived=True"}


id: metabase_archive_snippet_py_infra
name: metabase_archive_snippet
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_archive_snippet(client: MetabaseClient, snippet_id: int) -> dict
description: Archiva un SQL snippet en Metabase. Wrapper sobre metabase_update_snippet con archived=True.
tags: ["metabase","snippet","archive","api","python"]
uses_functions: ["metabase_update_snippet_py_infra"]
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/snippets.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: f34fd0310bb73c28c408c9af24ca2aaee54c6af4c8df2e6111801b28a614f136
example:
  ```python
  # Archivar snippet obsoleto
  result = metabase_archive_snippet(client, 42)
  print(result["archived"])  # True

  # Listar snippets activos despues de archivar
  active = metabase_list_snippets(client, archived=False)
  assert all(not s["archived"] for s in active)
  ```

notes:
  Los snippets archivados no aparecen en el autocomplete de queries nativas en el editor de Metabase.
  Las cards que ya referencian el snippet siguen funcionando correctamente despues de archivar.
  Para desarchivar, usar metabase_update_snippet(client, snippet_id, archived=False).

code:
  """CRUD de SQL snippets de Metabase.

  Un snippet es un fragmento SQL reutilizable que se referencia en queries
  nativas con la sintaxis {{snippet: nombre}}. Ideal para CTEs comunes
  que se repiten en multiples cards.
  """

  from .client import MetabaseClient


  def metabase_list_snippets(client: MetabaseClient, archived: bool = False) -> list[dict]:
      """Lista SQL snippets de Metabase.

      Endpoint: GET /api/native-query-snippet.

      Args:
          client: Cliente autenticado.
          archived: Si True, incluye snippets archivados.

      Returns:
          Lista de dicts con: id, name, content, description, collection_id,
          creator_id, archived, created_at, updated_at.

      Example:
          >>> snippets = metabase_list_snippets(client)
          >>> for s in snippets:
          ...     print(s["name"], len(s["content"]))
      """
      params = {}
      if archived:
          params["archived"] = "true"
      return client.request("GET", "/api/native-query-snippet", params=params)


  def metabase_get_snippet(client: MetabaseClient, snippet_id: int) -> dict:
      """Obtiene un SQL snippet de Metabase por su ID.

      Endpoint: GET /api/native-query-snippet/:id.

      Args:
          client: Cliente autenticado.
          snippet_id: ID numerico del snippet.

      Returns:
          Dict con campos completos del snippet: id, name, content, description,
          collection_id, creator_id, archived, created_at, updated_at.

      Raises:
          httpx.HTTPStatusError: 404 si el snippet no existe.

      Example:
          >>> snippet = metabase_get_snippet(client, 42)
          >>> print(snippet["name"], snippet["content"])
      """
      return client.request("GET", f"/api/native-query-snippet/{snippet_id}")


  def metabase_create_snippet(
      client: MetabaseClient,
      name: str,
      content: str,
      description: str = "",
      collection_id: int = 0,
  ) -> dict:
      """Crea un nuevo SQL snippet en Metabase.

      Endpoint: POST /api/native-query-snippet.

      Args:
          client: Cliente autenticado.
          name: Nombre del snippet. Se usa en queries con {{snippet: nombre}}.
          content: SQL del snippet. Puede ser una CTE completa, una subquery,
              o cualquier fragmento SQL reutilizable.
          description: Descripcion opcional del snippet.
          collection_id: ID de la coleccion donde guardar el snippet.
              Si es 0, se guarda en la raiz (Our analytics).

      Returns:
          Dict con el snippet creado, incluyendo el campo "id" asignado
          por Metabase.

      Raises:
          httpx.HTTPStatusError: 400 si el nombre ya existe o el SQL es invalido.

      Example:
          >>> snippet = metabase_create_snippet(
          ...     client,
          ...     "supply_orders_cte",
          ...     '''WITH supply_full AS (
          ...         SELECT so.id, so.service_request_id
          ...         FROM supply_orders so
          ...         LEFT JOIN service_requests sr ON so.service_request_id = sr.id
          ...     )''',
          ...     description="CTE base de supply_orders con JOINs para cruce con NAV",
          ... )
          >>> print(snippet["id"], snippet["name"])
          >>> # Usar en una card:
          >>> # "{{snippet: supply_orders_cte}} SELECT ... FROM supply_full"
      """
      body: dict = {"name": name, "content": content}
      if description:
          body["description"] = description
      if collection_id:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/native-query-snippet", json=body)


  def metabase_update_snippet(
      client: MetabaseClient,
      snippet_id: int,
      **fields,
  ) -> dict:
      """Actualiza campos de un SQL snippet en Metabase.

      Endpoint: PUT /api/native-query-snippet/:id.

      Args:
          client: Cliente autenticado.
          snippet_id: ID numerico del snippet a actualizar.
          **fields: Campos a modificar. Campos validos:
              - name (str): Nuevo nombre del snippet.
              - content (str): Nuevo SQL del snippet.
              - description (str): Nueva descripcion.
              - collection_id (int): Nueva coleccion.
              - archived (bool): True para archivar, False para desarchivar.

      Returns:
          Dict con el snippet actualizado.

      Raises:
          httpx.HTTPStatusError: 404 si el snippet no existe.
          httpx.HTTPStatusError: 400 si los campos son invalidos.

      Example:
          >>> updated = metabase_update_snippet(
          ...     client, 42,
          ...     content="WITH supply_full AS (SELECT * FROM supply_orders)",
          ...     description="Version simplificada",
          ... )
          >>> print(updated["updated_at"])
      """
      valid_fields = {"name", "content", "description", "collection_id", "archived"}
      body = {k: v for k, v in fields.items() if k in valid_fields}
      return client.request("PUT", f"/api/native-query-snippet/{snippet_id}", json=body)


  def metabase_archive_snippet(client: MetabaseClient, snippet_id: int) -> dict:
      """Archiva un SQL snippet en Metabase.

      Wrapper sobre metabase_update_snippet con archived=True.
      Los snippets archivados no aparecen en el autocomplete de queries
      pero sus referencias existentes siguen funcionando.

      Endpoint: PUT /api/native-query-snippet/:id.

      Args:
          client: Cliente autenticado.
          snippet_id: ID numerico del snippet a archivar.

      Returns:
          Dict con el snippet archivado.

      Raises:
          httpx.HTTPStatusError: 404 si el snippet no existe.

      Example:
          >>> result = metabase_archive_snippet(client, 42)
          >>> print(result["archived"])  # True
      """
      return metabase_update_snippet(client, snippet_id, archived=True)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"snippet_id","desc":"ID numerico del snippet a archivar"}],"output":"dict: snippet con archived=True y updated_at actualizado"}


id: metabase_auth_py_infra
name: metabase_auth
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_auth(base_url: str, email: str, password: str) -> MetabaseClient
tags: ["metabase","auth","session","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/client.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-13T17:05:57Z
props: []
emits: []
variant: []
content_hash: 861c102d86e3ca467f6f7f4845655a6ca122023071732aae6d005e8466929639
description:
  Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias. Endpoint: POST /api/session.

example:
  ```python
  from functions.metabase import metabase_auth

  client = metabase_auth("http://localhost:3000", "admin@example.com", "pass")
  # client listo para usar con todas las funciones CRUD

  # Alternativa con API key:
  from functions.metabase import MetabaseClient
  client = MetabaseClient("http://localhost:3000", "mb_api_key_xxxxx")
  ```

notes:
  Dos formas de obtener un client:
  - `metabase_auth()`: login con email/password, obtiene session token via POST /api/session
  - `MetabaseClient(base_url, api_key)`: constructor directo con API key (recomendado para automatizacion)

  El client es un context manager: `with metabase_auth(...) as client:`

  Errores comunes:
  - 401: credenciales invalidas
  - Rate limiting en intentos fallidos de login

code:
  """Cliente base para la API REST de Metabase."""

  import httpx


  class MetabaseClient:
      """Cliente HTTP para una instancia Metabase.

      Attributes:
          base_url: URL base sin trailing slash (ej: "http://localhost:3000").
          token: Session token o API key.
          _http: Cliente httpx reutilizable con headers de auth.
      """

      def __init__(self, base_url: str, token: str, timeout: float = 120.0) -> None:
          self.base_url = base_url.rstrip("/")
          self.token = token
          # API keys de Metabase empiezan por "mb_" y usan X-API-KEY.
          # Session tokens usan X-Metabase-Session.
          auth_header = "X-API-KEY" if token.startswith("mb_") else "X-Metabase-Session"
          self._http = httpx.Client(
              base_url=self.base_url,
              headers={
                  "Content-Type": "application/json",
                  auth_header: token,
              },
              timeout=timeout,
          )

      def request(self, method: str, path: str, **kwargs) -> dict | list | None:
          """Ejecuta una peticion HTTP contra la API de Metabase.

          Args:
              method: HTTP method (GET, POST, PUT, DELETE).
              path: Ruta relativa (ej: "/api/user").
              **kwargs: Argumentos extra para httpx (json, params, etc.).

          Returns:
              Respuesta deserializada como dict/list, o None si el body esta vacio.

          Raises:
              httpx.HTTPStatusError: Si el status code no es 2xx.
          """
          resp = self._http.request(method, path, **kwargs)
          resp.raise_for_status()
          if not resp.content:
              return None
          return resp.json()

      def close(self) -> None:
          """Cierra el cliente HTTP."""
          self._http.close()

      def __enter__(self):
          return self

      def __exit__(self, *args):
          self.close()


  def metabase_auth(base_url: str, email: str, password: str) -> MetabaseClient:
      """Autentica contra Metabase con email y password.

      Crea una sesion via POST /api/session y retorna un MetabaseClient
      con el session token listo para usar. El token expira en 14 dias
      por defecto (configurable con MAX_SESSION_AGE en Metabase).

      Args:
          base_url: URL base de la instancia (ej: "http://localhost:3000").
          email: Email del usuario Metabase.
          password: Password del usuario.

      Returns:
          MetabaseClient autenticado con session token.

      Raises:
          httpx.HTTPStatusError: Si las credenciales son invalidas (401)
              o hay rate limiting.

      Example:
          >>> client = metabase_auth("http://localhost:3000", "admin@example.com", "pass")
          >>> # client listo para usar con todas las funciones CRUD
      """
      resp = httpx.post(
          f"{base_url.rstrip('/')}/api/session",
          json={"username": email, "password": password},
      )
      resp.raise_for_status()
      token = resp.json()["id"]
      return MetabaseClient(base_url, token)

params_schema:
  {"params":[{"name":"base_url","desc":"URL base de Metabase (ej: http://localhost:3000)"},{"name":"email","desc":"correo del usuario para autenticar"},{"name":"password","desc":"contraseña del usuario"}],"output":"MetabaseClient: cliente autenticado con token válido por 14 días"}


id: metabase_copy_card_py_infra
name: metabase_copy_card
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","card","question","copy","duplicate","collection","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-13T13:34:41Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: fdcaf53c0a7f74bb71d34a9c6337ec53859321aa362640f4d0381e47e6875044
signature:
  def metabase_copy_card(client: MetabaseClient, card_id: int, name: str | None = None, collection_id: int | None = None, description: str | None = None) -> dict

description:
  Crea una copia de una card/pregunta en Metabase via el endpoint nativo POST /api/card/:id/copy. Permite sobrescribir nombre, coleccion y descripcion en la copia.

example:
  ```python
  # Copia simple (nombre automatico)
  copy = metabase_copy_card(client, 42)
  print(copy["id"], copy["name"])  # "Copy of ..."

  # Copia a otra coleccion con nombre propio
  copy = metabase_copy_card(client, 42, name="Revenue Q2", collection_id=7)
  print(copy["collection_id"])  # 7
  ```

notes:
  Usa el endpoint nativo de Metabase que copia dataset_query, display y
  visualization_settings. Los campos opcionales del body se omiten si son None
  para que Metabase aplique sus defaults (herencia del original).

code:
  """CRUD de cards/preguntas de Metabase y ejecucion de queries."""

  from .client import MetabaseClient


  def metabase_list_cards(
      client: MetabaseClient,
      filter: str = "",
      model_id: int = 0,
  ) -> list[dict]:
      """Lista preguntas/cards de Metabase con filtro opcional.

      Endpoint: GET /api/card. No tiene paginacion offset/limit.

      Args:
          client: Cliente autenticado.
          filter: "all", "mine", "fav", "archived", "recent", "popular",
                  "database", "table". Vacio = todas.
          model_id: ID de database/tabla. Solo aplica con filter "database" o "table".

      Returns:
          Lista de dicts, cada uno con: id, name, description, display,
          collection_id, database_id, creator_id, archived, dataset_query.

      Example:
          >>> cards = metabase_list_cards(client, filter="mine")
          >>> for c in cards:
          ...     print(c["id"], c["name"], c["display"])
      """
      params = {}
      if filter:
          params["f"] = filter
      if model_id > 0:
          params["model_id"] = model_id
      return client.request("GET", "/api/card", params=params)


  def metabase_get_card(client: MetabaseClient, card_id: int) -> dict:
      """Obtiene los detalles completos de una card/pregunta.

      Endpoint: GET /api/card/:id.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.

      Returns:
          Dict con: id, name, description, display, dataset_query,
          visualization_settings, collection_id, database_id, archived,
          creator, created_at, updated_at.

      Example:
          >>> card = metabase_get_card(client, 42)
          >>> print(card["name"], card["display"])
          >>> print(card["dataset_query"]["native"]["query"])  # SQL
      """
      return client.request("GET", f"/api/card/{card_id}")


  def metabase_create_card(
      client: MetabaseClient,
      name: str,
      dataset_query: dict,
      display: str = "table",
      collection_id: int = 0,
      description: str = "",
  ) -> dict:
      """Crea una nueva card/pregunta en Metabase.

      Endpoint: POST /api/card.

      Args:
          client: Cliente autenticado.
          name: Nombre de la pregunta.
          dataset_query: Query de la card. Estructura:
              SQL nativo: {"database": 1, "type": "native", "native": {"query": "SELECT ..."}}
              MBQL: {"database": 1, "type": "query", "query": {"source-table": 4, ...}}
          display: Tipo de visualizacion: "table", "bar", "line", "pie", "scalar",
                   "area", "row", "combo", "funnel", "scatter", "waterfall", etc.
          collection_id: ID de coleccion destino. 0 = root.
          description: Descripcion opcional.

      Returns:
          Dict con la card creada.

      Example:
          >>> card = metabase_create_card(client, "Revenue by Month", {
          ...     "database": 1,
          ...     "type": "native",
          ...     "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
          ... }, display="line", description="Monthly revenue trend")
      """
      body: dict = {
          "name": name,
          "dataset_query": dataset_query,
          "display": display,
          "visualization_settings": {},
      }
      if collection_id > 0:
          body["collection_id"] = collection_id
      if description:
          body["description"] = description
      return client.request("POST", "/api/card", json=body)


  def metabase_update_card(client: MetabaseClient, card_id: int, **fields) -> dict:
      """Actualiza campos de una card/pregunta en Metabase.

      Endpoint: PUT /api/card/:id. Solo se modifican los campos pasados.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.
          **fields: Campos a actualizar. Validos:
              name (str), description (str), display (str),
              dataset_query (dict), visualization_settings (dict),
              collection_id (int), archived (bool),
              enable_embedding (bool), embedding_params (dict).

      Returns:
          Dict con la card actualizada.

      Example:
          >>> metabase_update_card(client, 42, name="Updated Name", archived=True)
          >>> metabase_update_card(client, 42, dataset_query={
          ...     "database": 1, "type": "native",
          ...     "native": {"query": "SELECT * FROM users LIMIT 100"},
          ... })
      """
      return client.request("PUT", f"/api/card/{card_id}", json=fields)


  def metabase_delete_card(client: MetabaseClient, card_id: int) -> None:
      """Elimina permanentemente una card/pregunta.

      Endpoint: DELETE /api/card/:id. IRREVERSIBLE.
      Para soft-delete preferir: metabase_update_card(client, card_id, archived=True)

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a eliminar.

      Example:
          >>> metabase_delete_card(client, 42)
          >>> # Preferir soft-delete: metabase_update_card(client, 42, archived=True)
      """
      client.request("DELETE", f"/api/card/{card_id}")


  def metabase_execute_card(
      client: MetabaseClient,
      card_id: int,
      parameters: list[dict] | None = None,
  ) -> dict:
      """Ejecuta la query de una card/pregunta guardada.

      Endpoint: POST /api/card/:id/query.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a ejecutar.
          parameters: Parametros para queries parametrizadas. Cada parametro:
              {"type": "category", "target": ["variable", ["template-tag", "tag"]], "value": "val"}

      Returns:
          Dict con resultados:
          - status: "completed" o "failed"
          - row_count: numero de filas
          - running_time: tiempo en ms
          - data.columns: nombres de columnas
          - data.rows: filas de datos (lista de listas)
          - data.cols: metadata de columnas
          - data.native_form.query: SQL ejecutado

      Example:
          >>> result = metabase_execute_card(client, 42)
          >>> for row in result["data"]["rows"]:
          ...     print(row)
          >>> # Con parametros:
          >>> result = metabase_execute_card(client, 42, parameters=[
          ...     {"type": "category", "target": ["variable", ["template-tag", "status"]], "value": "active"},
          ... ])
      """
      body = {}
      if parameters:
          body["parameters"] = parameters
      return client.request("POST", f"/api/card/{card_id}/query", json=body or None)


  def metabase_execute_query(
      client: MetabaseClient,
      database_id: int,
      sql: str,
      max_results: int = 0,
  ) -> dict:
      """Ejecuta una query SQL ad-hoc sin guardarla como card.

      Endpoint: POST /api/dataset. Util para exploracion rapida y consultas
      que no necesitan persistirse.

      Args:
          client: Cliente autenticado.
          database_id: ID de la database en Metabase.
          sql: Query SQL a ejecutar.
          max_results: Limite de filas. 0 = default 2000.

      Returns:
          Dict con misma estructura que metabase_execute_card:
          data.columns, data.rows, row_count, running_time, status.

      Example:
          >>> result = metabase_execute_query(client, 1, "SELECT * FROM users LIMIT 10")
          >>> print(f"{result['row_count']} filas en {result['running_time']}ms")
          >>> for row in result["data"]["rows"]:
          ...     print(row)
      """
      body: dict = {
          "database": database_id,
          "type": "native",
          "native": {"query": sql},
      }
      if max_results > 0:
          body["constraints"] = {
              "max-results": max_results,
              "max-results-bare-rows": max_results,
          }
      return client.request("POST", "/api/dataset", json=body)


  def metabase_copy_card(
      client: MetabaseClient,
      card_id: int,
      name: str | None = None,
      collection_id: int | None = None,
      description: str | None = None,
  ) -> dict:
      """Crea una copia de una card/pregunta existente en Metabase.

      Endpoint: POST /api/card/:id/copy. Usa el endpoint nativo de Metabase para
      duplicar la card, copiando dataset_query, display y visualization_settings.
      Los campos name, collection_id y description se pueden sobrescribir via body.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a copiar.
          name: Nombre para la copia. None = Metabase asigna "Copy of <nombre>".
          collection_id: Coleccion destino. None = misma coleccion que el original.
          description: Descripcion de la copia. None = misma que el original.

      Returns:
          Dict con la card nueva creada por Metabase. Incluye el campo `id`
          asignado a la copia y todos los campos heredados del original.

      Example:
          >>> copy = metabase_copy_card(client, 42)
          >>> print(copy["id"], copy["name"])  # "Copy of ..."
          >>> # Copiar a otra coleccion con nombre propio:
          >>> copy = metabase_copy_card(client, 42, name="Revenue Q2", collection_id=7)
      """
      body: dict = {}
      if name is not None:
          body["name"] = name
      if collection_id is not None:
          body["collection_id"] = collection_id
      if description is not None:
          body["description"] = description
      return client.request("POST", f"/api/card/{card_id}/copy", json=body or None)


  def metabase_move_card(
      client: MetabaseClient,
      card_id: int,
      collection_id: int | None,
  ) -> dict:
      """Mueve una card/pregunta a otra coleccion.

      Wrapper thin sobre PUT /api/card/:id que solo actualiza collection_id.
      Equivalente a metabase_update_card(client, card_id, collection_id=...) pero
      con intencion explicita y soporte para mover a root con None.

      Endpoint: PUT /api/card/:id.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a mover.
          collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root).

      Returns:
          Dict con la card actualizada, incluyendo el nuevo collection_id.

      Example:
          >>> card = metabase_move_card(client, 42, collection_id=7)
          >>> print(card["collection_id"])  # 7
          >>> # Mover a root:
          >>> card = metabase_move_card(client, 42, collection_id=None)
      """
      return client.request("PUT", f"/api/card/{card_id}", json={"collection_id": collection_id})


  def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict:
      """Crea una card en Metabase con payload completo ya construido por el caller.

      Version raw de metabase_create_card. El caller es responsable de construir
      el payload completo antes de llamar a esta funcion — no se realiza ninguna
      validacion ni transformacion local. Util para flujos "Metabase as code"
      donde el YAML define todos los campos de la card tal como los espera la API.

      Endpoint: POST /api/card.

      El payload minimo necesita:
          - name (str): nombre de la card.
          - dataset_query (dict): query SQL nativa o MBQL.
          - display (str): tipo de visualizacion (table, bar, scalar, etc.).

      Campos opcionales que esta funcion preserva (a diferencia de metabase_create_card):
          - visualization_settings (dict): configuracion detallada del grafico.
          - parameters (list[dict]): parametros de la query con template tags.
          - parameter_mappings (list[dict]): mapeo de parametros a dashboard filters.
          - type (str): "question" (default), "model", "metric".
          - collection_id (int): ID de coleccion destino.
          - description (str): descripcion de la card.
          - archived (bool): estado de archivo inicial.
          - enable_embedding (bool): habilitar embedding publico.
          - embedding_params (dict): configuracion de embedding.

      Si Metabase devuelve 4xx/5xx, httpx lanza HTTPStatusError sin capturar.

      Args:
          client: Cliente autenticado con sesion activa.
          payload: Dict con el payload completo de la card tal como lo espera
                   la API de Metabase. Se envia sin modificaciones.

      Returns:
          Dict con la card recien creada. Incluye el campo `id` asignado por
          Metabase y todos los campos normalizados (display, dataset_query,
          visualization_settings, created_at, etc.).

      Example:
          >>> card = metabase_create_card_raw(client, {
          ...     "name": "Revenue by Month",
          ...     "dataset_query": {
          ...         "database": 1,
          ...         "type": "native",
          ...         "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
          ...     },
          ...     "display": "line",
          ...     "visualization_settings": {
          ...         "graph.x_axis.title_text": "Month",
          ...         "graph.y_axis.title_text": "Revenue",
          ...     },
          ...     "description": "Monthly revenue trend",
          ...     "collection_id": 5,
          ... })
          >>> print(card["id"])  # ID asignado por Metabase
      """
      return client.request("POST", "/api/card", json=payload)


  def metabase_export_card(
      client: MetabaseClient,
      card_id: int,
      format: str = "csv",
  ) -> bytes:
      """Exporta los resultados de una card en CSV, XLSX o JSON.

      Endpoint: POST /api/card/:id/query/:format.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.
          format: Formato de exportación: "csv", "xlsx" o "json".

      Returns:
          bytes con el contenido del archivo exportado.

      Example:
          >>> data = metabase_export_card(client, 42, format="csv")
          >>> with open("export.csv", "wb") as f:
          ...     f.write(data)
      """
      resp = client._http.request("POST", f"/api/card/{card_id}/query/{format}")
      resp.raise_for_status()
      return resp.content


  def metabase_create_model(
      client: MetabaseClient,
      name: str,
      sql: str,
      database_id: int,
      collection_id: int = 0,
      description: str = "",
  ) -> dict:
      """Crea un modelo (card tipo model) que otras cards pueden referenciar.

      Un modelo es una card con type="model". Otras cards MBQL pueden usarlo
      como fuente con source-table: "card__<model_id>".

      Endpoint: POST /api/card con type="model".

      Args:
          client: Cliente autenticado.
          name: Nombre del modelo.
          sql: Query SQL del modelo.
          database_id: ID de la database en Metabase.
          collection_id: Coleccion destino. 0 = root.
          description: Descripcion opcional.

      Returns:
          Dict con el modelo creado (id, name, type="model").

      Example:
          >>> model = metabase_create_model(client, "supply_orders_base",
          ...     "SELECT * FROM supply_orders WHERE ...", database_id=6)
          >>> # Usar en otra card MBQL:
          >>> card = metabase_create_card(client, "Revenue", {
          ...     "database": 6, "type": "query",
          ...     "query": {"source-table": f"card__{model['id']}"}
          ... })
      """
      body: dict = {
          "name": name,
          "type": "model",
          "dataset_query": {
              "database": database_id,
              "type": "native",
              "native": {"query": sql},
          },
          "display": "table",
          "visualization_settings": {},
      }
      if collection_id > 0:
          body["collection_id"] = collection_id
      if description:
          body["description"] = description
      return client.request("POST", "/api/card", json=body)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"card_id","desc":"ID de la card a copiar"},{"name":"name","desc":"nombre para la copia; None = Metabase asigna 'Copy of \u003cnombre\u003e'"},{"name":"collection_id","desc":"ID de la coleccion destino; None = misma coleccion que el original"},{"name":"description","desc":"descripcion de la copia; None = hereda la del original"}],"output":"dict: objeto card nueva creada por Metabase, con el id asignado y todos los campos heredados del original"}


id: metabase_copy_dashboard_py_infra
name: metabase_copy_dashboard
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","dashboard","copy","duplicate","collection","deep_copy","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/dashboards.py
created_at: 2026-04-13T13:34:41Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
content_hash: 3cfae405a7f60b9552aaec3745a5baffdb446be3b87442affaa2f42428405686
signature:
  def metabase_copy_dashboard(client: MetabaseClient, dashboard_id: int, name: str | None = None, collection_id: int | None = None, description: str | None = None, is_deep_copy: bool = False) -> dict

description:
  Crea una copia de un dashboard en Metabase via POST /api/dashboard/:id/copy. Con is_deep_copy=True tambien clona las cards referenciadas.

example:
  ```python
  # Copia simple (referencia cards originales)
  copy = metabase_copy_dashboard(client, 1)
  print(copy["id"], copy["name"])  # "Copy of ..."

  # Deep copy a otra coleccion con nombre propio
  copy = metabase_copy_dashboard(client, 1, name="Sales Q2", collection_id=7, is_deep_copy=True)
  print(copy["collection_id"])  # 7
  ```

notes:
  `is_deep_copy=True` hace que Metabase clone tambien las cards internas del
  dashboard, util para crear instancias completamente independientes. Con
  `is_deep_copy=False` (default), las dashcards del clon apuntan a las mismas
  cards que el original — cambios en esas cards afectan ambos dashboards.

code:
  """CRUD de dashboards de Metabase."""

  from .client import MetabaseClient


  def metabase_list_dashboards(
      client: MetabaseClient,
      filter: str = "",
  ) -> list[dict]:
      """Lista dashboards de Metabase con filtro opcional.

      Endpoint: GET /api/dashboard. Retorna dashboards resumidos (sin dashcards).

      Args:
          client: Cliente autenticado.
          filter: "all", "mine" o "archived". Vacio = todas.

      Returns:
          Lista de dicts con: id, name, description, collection_id,
          creator_id, archived, created_at.

      Example:
          >>> dashboards = metabase_list_dashboards(client, filter="mine")
          >>> for d in dashboards:
          ...     print(d["id"], d["name"])
      """
      params = {}
      if filter:
          params["f"] = filter
      return client.request("GET", "/api/dashboard", params=params)


  def metabase_get_dashboard(client: MetabaseClient, dashboard_id: int) -> dict:
      """Obtiene un dashboard completo incluyendo sus cards.

      Endpoint: GET /api/dashboard/:id.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.

      Returns:
          Dict con: id, name, description, dashcards (lista de cards posicionadas),
          parameters (filtros), tabs, collection_id, archived.

          Cada dashcard tiene: id, card_id, card (objeto completo), size_x, size_y,
          col, row, dashboard_tab_id, parameter_mappings, visualization_settings.

      Example:
          >>> dash = metabase_get_dashboard(client, 1)
          >>> for dc in dash["dashcards"]:
          ...     print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})")
      """
      return client.request("GET", f"/api/dashboard/{dashboard_id}")


  def metabase_create_dashboard(
      client: MetabaseClient,
      name: str,
      description: str = "",
      collection_id: int = 0,
  ) -> dict:
      """Crea un nuevo dashboard vacio en Metabase.

      Endpoint: POST /api/dashboard.
      Para agregar cards usar metabase_update_dashboard con dashcards.

      Args:
          client: Cliente autenticado.
          name: Nombre del dashboard.
          description: Descripcion opcional.
          collection_id: Coleccion destino. 0 = root.

      Returns:
          Dict con el dashboard creado.

      Example:
          >>> dash = metabase_create_dashboard(client, "Sales Overview", "KPIs de ventas")
          >>> # Agregar cards:
          >>> metabase_update_dashboard(client, dash["id"], dashcards=[
          ...     {"id": -1, "card_id": 42, "size_x": 6, "size_y": 4, "col": 0, "row": 0},
          ... ])
      """
      body: dict = {"name": name}
      if description:
          body["description"] = description
      if collection_id > 0:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/dashboard", json=body)


  def metabase_update_dashboard(client: MetabaseClient, dashboard_id: int, **fields) -> dict:
      """Actualiza un dashboard incluyendo metadata, cards y tabs.

      Endpoint: PUT /api/dashboard/:id.

      El campo dashcards representa el ESTADO COMPLETO DESEADO del dashboard:
      - Agregar card: incluirla con ID negativo (-1, -2, etc.)
      - Actualizar card existente: incluirla con su ID positivo
      - Eliminar card: omitirla del array

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.
          **fields: Campos a actualizar. Validos:
              name (str), description (str), archived (bool),
              dashcards (list[dict]), tabs (list[dict]),
              parameters (list[dict]), collection_id (int).

      Returns:
          Dict con el dashboard actualizado.

      Example:
          >>> # Cambiar nombre
          >>> metabase_update_dashboard(client, 1, name="Updated Name")
          >>>
          >>> # Agregar card (primero obtener existentes)
          >>> dash = metabase_get_dashboard(client, 1)
          >>> cards = list(dash["dashcards"])
          >>> cards.append({"id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0})
          >>> metabase_update_dashboard(client, 1, dashcards=cards)
          >>>
          >>> # Archivar (soft-delete)
          >>> metabase_update_dashboard(client, 1, archived=True)
      """
      return client.request("PUT", f"/api/dashboard/{dashboard_id}", json=fields)


  def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None:
      """Elimina permanentemente un dashboard.

      Endpoint: DELETE /api/dashboard/:id. IRREVERSIBLE.
      Para soft-delete preferir: metabase_update_dashboard(client, id, archived=True)

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a eliminar.

      Example:
          >>> metabase_delete_dashboard(client, 1)
          >>> # Preferir: metabase_update_dashboard(client, 1, archived=True)
      """
      client.request("DELETE", f"/api/dashboard/{dashboard_id}")


  def metabase_copy_dashboard(
      client: MetabaseClient,
      dashboard_id: int,
      name: str | None = None,
      collection_id: int | None = None,
      description: str | None = None,
      is_deep_copy: bool = False,
  ) -> dict:
      """Crea una copia de un dashboard existente en Metabase.

      Endpoint: POST /api/dashboard/:id/copy. Usa el endpoint nativo de Metabase para
      duplicar el dashboard junto con su layout de dashcards y parametros.
      Con is_deep_copy=True tambien clona las cards referenciadas.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a copiar.
          name: Nombre para la copia. None = Metabase asigna "Copy of <nombre>".
          collection_id: Coleccion destino. None = misma coleccion que el original.
          description: Descripcion de la copia. None = misma que el original.
          is_deep_copy: Si True, clona tambien todas las cards referenciadas por el
              dashboard (deep copy). Si False, la copia referencia las cards originales.

      Returns:
          Dict con el dashboard nuevo creado por Metabase. Incluye el campo `id`
          asignado a la copia y el layout de dashcards copiado.

      Example:
          >>> copy = metabase_copy_dashboard(client, 1)
          >>> print(copy["id"], copy["name"])  # "Copy of ..."
          >>> # Deep copy a otra coleccion:
          >>> copy = metabase_copy_dashboard(client, 1, name="Sales Q2", collection_id=7, is_deep_copy=True)
      """
      body: dict = {"is_deep_copy": is_deep_copy}
      if name is not None:
          body["name"] = name
      if collection_id is not None:
          body["collection_id"] = collection_id
      if description is not None:
          body["description"] = description
      return client.request("POST", f"/api/dashboard/{dashboard_id}/copy", json=body)


  def metabase_move_dashboard(
      client: MetabaseClient,
      dashboard_id: int,
      collection_id: int | None,
  ) -> dict:
      """Mueve un dashboard a otra coleccion.

      Wrapper thin sobre PUT /api/dashboard/:id que solo actualiza collection_id.
      Equivalente a metabase_update_dashboard(client, id, collection_id=...) pero
      con intencion explicita y soporte para mover a root con None.

      Endpoint: PUT /api/dashboard/:id.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a mover.
          collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root).

      Returns:
          Dict con el dashboard actualizado, incluyendo el nuevo collection_id.

      Example:
          >>> dash = metabase_move_dashboard(client, 1, collection_id=7)
          >>> print(dash["collection_id"])  # 7
          >>> # Mover a root:
          >>> dash = metabase_move_dashboard(client, 1, collection_id=None)
      """
      return client.request("PUT", f"/api/dashboard/{dashboard_id}", json={"collection_id": collection_id})


  def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict:
      """Crea un dashboard en Metabase con payload completo ya construido por el caller.

      Version raw de metabase_create_dashboard. El caller es responsable de
      construir el payload completo — no se realiza validacion ni transformacion
      local. Util para flujos "Metabase as code" donde el YAML define todos los
      campos del dashboard tal como los espera la API.

      COMPORTAMIENTO EN DOS PASOS (limitacion de la API de Metabase):
      El endpoint POST /api/dashboard NO acepta `dashcards` en el body inicial;
      solo crea el dashboard vacio. Para añadir cards es necesario un PUT posterior.
      Esta funcion maneja esa limitacion automaticamente:

        1. Si el payload contiene `dashcards`, se extraen antes del POST.
        2. POST /api/dashboard crea el dashboard vacio (sin dashcards).
        3. Si habia dashcards, PUT /api/dashboard/:id con {"dashcards": [...]}
           las añade al dashboard recien creado.
        4. Retorna la respuesta del PUT (con dashcards pobladas), o la del POST
           si el payload original no contenia dashcards.

      Endpoint inicial: POST /api/dashboard.
      Endpoint secundario (condicional): PUT /api/dashboard/:id.

      El payload puede incluir:
          - name (str, requerido): nombre del dashboard.
          - description (str): descripcion.
          - collection_id (int): ID de coleccion destino.
          - parameters (list[dict]): filtros/parametros del dashboard.
          - dashcards (list[dict]): cards posicionadas. Cada dashcard necesita:
              id (int negativo para nuevas, ej: -1, -2),
              card_id (int), size_x (int), size_y (int), col (int), row (int).
              Campos opcionales: visualization_settings, parameter_mappings.
          - tabs (list[dict]): pestañas del dashboard.
          - enable_embedding (bool): habilitar embedding publico.
          - embedding_params (dict): configuracion de embedding.

      Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza HTTPStatusError.

      Args:
          client: Cliente autenticado con sesion activa.
          payload: Dict con el payload completo del dashboard tal como lo espera
                   la API de Metabase. Puede incluir dashcards.

      Returns:
          Dict con el dashboard creado. Si habia dashcards, la respuesta es la
          del PUT final e incluye el campo `dashcards` con las cards posicionadas.
          Si no habia dashcards, es la respuesta del POST inicial.

      Example:
          >>> dash = metabase_create_dashboard_raw(client, {
          ...     "name": "Sales Overview",
          ...     "description": "KPIs de ventas mensuales",
          ...     "collection_id": 5,
          ...     "parameters": [],
          ...     "dashcards": [
          ...         {
          ...             "id": -1,
          ...             "card_id": 42,
          ...             "size_x": 6,
          ...             "size_y": 4,
          ...             "col": 0,
          ...             "row": 0,
          ...             "visualization_settings": {},
          ...             "parameter_mappings": [],
          ...         },
          ...     ],
          ... })
          >>> print(dash["id"])          # ID asignado por Metabase
          >>> print(len(dash["dashcards"]))  # 1
      """
      body = {k: v for k, v in payload.items() if k != "dashcards"}
      dashcards = payload.get("dashcards")

      created = client.request("POST", "/api/dashboard", json=body)

      if dashcards:
          dashboard_id = created["id"]
          return client.request(
              "PUT",
              f"/api/dashboard/{dashboard_id}",
              json={"dashcards": dashcards},
          )

      return created

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"dashboard_id","desc":"ID del dashboard a copiar"},{"name":"name","desc":"nombre para la copia; None = Metabase asigna 'Copy of \u003cnombre\u003e'"},{"name":"collection_id","desc":"ID de la coleccion destino; None = misma coleccion que el original"},{"name":"description","desc":"descripcion de la copia; None = hereda la del original"},{"name":"is_deep_copy","desc":"si True, clona tambien todas las cards referenciadas (deep copy); si False, la copia referencia las cards originales"}],"output":"dict: objeto dashboard nuevo creado por Metabase, con id asignado y layout de dashcards copiado"}


id: metabase_copy_document_py_infra
name: metabase_copy_document
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
description: Copia un document usando el endpoint nativo POST /api/document/:id/copy. Un solo request HTTP.
tags: ["metabase","document","copy","clone","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/documents.py
created_at: 2026-04-13T13:34:41Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 415ae8c800f6884ac6548afcec9ca91a1e1ea0245712b5cb96f51f09dae0244c
signature:
  def metabase_copy_document(client: MetabaseClient, document_id: int, name: str | None = None, collection_id: int | None = None) -> dict

example:
  ```python
  # Copia con nombre personalizado a otra coleccion
  copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5)
  print(copy["id"])  # ID del nuevo document

  # Copia a la misma coleccion con nombre automatico de Metabase
  copy = metabase_copy_document(client, 42)
  print(copy["name"])  # nombre asignado por Metabase
  ```

notes:
  Usa el endpoint nativo `POST /api/document/:id/copy` — un solo request HTTP.

  Metabase asigna el nombre automaticamente si no se especifica `name`. El contenido
  ProseMirror (incluyendo cardEmbeds) se copia tal cual; las cards embebidas no se duplican.

code:
  """CRUD de documents de Metabase (feature reciente, Metabase 0.57+).

  Un document es un texto editable en Metabase (tipo Notion) serializado como
  ProseMirror JSON (content_type: application/json+vnd.prose-mirror). Permite
  embeber cards, smart links y flex containers.

  NODOS QUE RENDERIZAN (whitelist TipTap v0.59):
    doc, paragraph, text, heading (level 1-6),
    bulletList, orderedList, listItem,
    blockquote, codeBlock (attrs.language), horizontalRule, hardBreak,
    cardEmbed (attrs.id), flexContainer, smartLink (attrs.entityId),
    resizeNode, mention.

  NODOS QUE LA API ACEPTA PERO EL FRONTEND IGNORA (doc queda vacio):
    callout, taskList, taskItem, details, table, tableRow, tableCell,
    image, iframe.

  MARKS QUE RENDERIZAN: bold, italic, strike, code, link (attrs.href).
  MARKS IGNORADOS: underline, highlight, subscript, textStyle.

  IMPORTANTE — cardEmbed:
    Un cardEmbed desnudo renderiza pero queda muy pequeño (alto ~50px).
    Para que se vea correctamente, envolver en un resizeNode:

      {"type": "resizeNode",
       "attrs": {"height": 400, "minHeight": 280},
       "content": [
           {"type": "cardEmbed", "attrs": {"id": <card_id>}}
       ]}

    Usar el helper `prosemirror_card_embed(card_id)` para generar esto
    automaticamente.
  """

  import uuid

  from .client import MetabaseClient


  def prosemirror_card_embed(card_id: int, height: int = 400) -> dict:
      """Genera un nodo cardEmbed envuelto en resizeNode listo para ProseMirror.

      Un cardEmbed desnudo renderiza pero queda muy pequeño (~50px). Metabase
      espera que vaya dentro de un resizeNode con height/minHeight para que
      se vea con tamaño adecuado.

      Args:
          card_id: ID de la card/pregunta de Metabase a embeber.
          height: Altura en pixeles del embed (default 400). minHeight se
                  fija en 280 (lo que usa la UI de Metabase).

      Returns:
          Dict ProseMirror: resizeNode > cardEmbed, insertable directamente
          en el array content de un document.

      Example:
          >>> node = prosemirror_card_embed(7711, height=500)
          >>> doc = {"type": "doc", "content": [
          ...     {"type": "heading", "attrs": {"level": 1},
          ...      "content": [{"type": "text", "text": "Mi reporte"}]},
          ...     node,
          ... ]}
      """
      return {
          "type": "resizeNode",
          "attrs": {"height": height, "minHeight": 280},
          "content": [
              {
                  "type": "cardEmbed",
                  "attrs": {"id": card_id, "name": None, "_id": str(uuid.uuid4())},
              }
          ],
      }


  def _validate_before_send(name: str, document: dict | str) -> None:
      """Valida el payload del document antes de enviarlo. Raises ValueError."""
      if not document or document == "":
          return
      if not isinstance(document, dict):
          return
      from .validation import metabase_validate_document_payload

      issues = metabase_validate_document_payload({"name": name, "document": document})
      if issues:
          raise ValueError(
              f"Document no renderizará correctamente en Metabase "
              f"({len(issues)} issues):\n" + "\n".join(f"  - {i}" for i in issues)
          )


  def metabase_list_documents(client: MetabaseClient) -> list[dict]:
      """Lista documents de Metabase.

      Endpoint: GET /api/document. Retorna `{"items": [...]}` segun la API.

      Args:
          client: Cliente autenticado.

      Returns:
          Lista de dicts con: id, name, collection_id, archived, created_at,
          updated_at, creator_id, content_type, entity_id.

      Example:
          >>> docs = metabase_list_documents(client)
          >>> for d in docs:
          ...     print(d["id"], d["name"])
      """
      resp = client.request("GET", "/api/document")
      if isinstance(resp, dict) and "items" in resp:
          return resp["items"]
      return resp or []


  def metabase_get_document(client: MetabaseClient, document_id: int) -> dict:
      """Obtiene un document completo con su contenido ProseMirror.

      Endpoint: GET /api/document/:id.

      Args:
          client: Cliente autenticado.
          document_id: ID del document.

      Returns:
          Dict con: id, name, document (ProseMirror tree), collection_id,
          archived, creator, created_at, updated_at, entity_id, content_type.

      Example:
          >>> doc = metabase_get_document(client, 1)
          >>> print(doc["name"])
          >>> tree = doc["document"]  # {"type": "doc", "content": [...]}
      """
      return client.request("GET", f"/api/document/{document_id}")


  def metabase_create_document(
      client: MetabaseClient,
      name: str,
      document: dict,
      collection_id: int = 0,
      *,
      validate: bool = True,
  ) -> dict:
      """Crea un document nuevo.

      Endpoint: POST /api/document.

      Valida el arbol ProseMirror ANTES de enviar (por defecto). Si el
      documento contiene nodos que la API acepta pero el frontend ignora
      (callout, taskList, image, etc.), lanza ValueError con los issues.
      Pasar validate=False para desactivar (uso bajo tu riesgo).

      Para embeber cards, usar prosemirror_card_embed(card_id) que genera
      el nodo resizeNode > cardEmbed con la altura correcta.

      Args:
          client: Cliente autenticado.
          name: Titulo del document (1-254 chars, no blank).
          document: Arbol ProseMirror JSON. Formato minimo:
              {"type": "doc", "content": [{"type": "paragraph", "content": [...]}]}
              O cadena vacia "" si se quiere arrancar en blanco.
          collection_id: ID de coleccion destino. 0 = root.
          validate: Si True (default), valida el ProseMirror antes de enviar.

      Returns:
          Document creado con su id asignado.

      Raises:
          ValueError: Si validate=True y el arbol ProseMirror contiene
              nodos o marks que el frontend de Metabase no renderiza.

      Example:
          >>> from metabase.documents import prosemirror_card_embed
          >>> doc = metabase_create_document(client, "Reporte", {
          ...     "type": "doc",
          ...     "content": [
          ...         {"type": "heading", "attrs": {"level": 1},
          ...          "content": [{"type": "text", "text": "KPIs"}]},
          ...         prosemirror_card_embed(42, height=450),
          ...     ]
          ... })
      """
      if validate:
          _validate_before_send(name, document)
      body: dict = {"name": name, "document": document}
      if collection_id > 0:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/document", json=body)


  def metabase_update_document(
      client: MetabaseClient,
      document_id: int,
      *,
      validate: bool = True,
      **fields,
  ) -> dict:
      """Actualiza un document. Solo se envian los campos pasados.

      Endpoint: PUT /api/document/:id.

      Si se pasa el campo 'document', valida el arbol ProseMirror antes de
      enviar (por defecto). Pasar validate=False para desactivar.

      Campos tipicos: name, document, collection_id, archived.

      Args:
          client: Cliente autenticado.
          document_id: ID del document a actualizar.
          validate: Si True (default), valida el ProseMirror antes de enviar.
          **fields: Campos a modificar.

      Returns:
          Document actualizado.

      Raises:
          ValueError: Si validate=True y el campo 'document' contiene
              nodos o marks no soportados por el frontend de Metabase.

      Example:
          >>> metabase_update_document(client, 1, name="Nuevo titulo")
          >>> metabase_update_document(client, 1, document={"type":"doc","content":[...]})
      """
      if validate and "document" in fields:
          name = fields.get("name", f"document_{document_id}")
          _validate_before_send(name, fields["document"])
      return client.request("PUT", f"/api/document/{document_id}", json=fields)


  def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict:
      """Archiva un document (equivalente a PUT con archived=True).

      Metabase exige archive previo para poder eliminar.

      Args:
          client: Cliente autenticado.
          document_id: ID del document.

      Returns:
          Document con archived=True.
      """
      return client.request("PUT", f"/api/document/{document_id}", json={"archived": True})


  def metabase_list_document_comments(
      client: MetabaseClient,
      document_id: int,
      *,
      include_resolved: bool = True,
      include_deleted: bool = False,
  ) -> list[dict]:
      """Lista los comentarios de un document.

      Endpoint: GET /api/comment?target_type=document&target_id=:id.

      Un comentario puede estar anclado a un bloque especifico via
      `child_target_id` (UUID que matchea `attrs._id` de un parrafo) o al
      document entero si `child_target_id` es null. Las respuestas tipo thread
      cuelgan via `parent_comment_id`.

      Args:
          client: Cliente autenticado.
          document_id: ID del document.
          include_resolved: si False, filtra comentarios con is_resolved=True.
          include_deleted: si False, filtra comentarios con deleted_at no null.

      Returns:
          Lista de dicts con: id, content (arbol ProseMirror), creator, creator_id,
          target_type, target_id, child_target_id, parent_comment_id, is_resolved,
          deleted_at, reactions, created_at, updated_at.

      Example:
          >>> comments = metabase_list_document_comments(client, 29)
          >>> for c in comments:
          ...     text = _comment_plaintext(c["content"])
          ...     print(f'{c["creator"]["common_name"]}: {text}')
      """
      resp = client.request(
          "GET",
          "/api/comment",
          params={"target_type": "document", "target_id": document_id},
      )
      items = resp.get("comments", []) if isinstance(resp, dict) else []
      if not include_resolved:
          items = [c for c in items if not c.get("is_resolved")]
      if not include_deleted:
          items = [c for c in items if c.get("deleted_at") is None]
      return items


  def metabase_create_document_comment(
      client: MetabaseClient,
      document_id: int,
      content: dict,
      *,
      child_target_id: str | None = None,
      parent_comment_id: int | None = None,
  ) -> dict:
      """Crea un comentario en un document.

      Endpoint: POST /api/comment.

      Args:
          client: Cliente autenticado.
          document_id: ID del document donde comentar.
          content: Arbol ProseMirror del comentario. Ejemplo minimo:
              {"type":"doc","content":[{"type":"paragraph","content":[
                  {"type":"text","text":"mi comentario"}]}]}
          child_target_id: UUID del bloque del document al que se ancla el
              comentario (matchea `attrs._id` de un parrafo). None = a nivel doc.
          parent_comment_id: id del comentario al que se responde. None = top-level.

      Returns:
          Comentario creado.
      """
      body: dict = {
          "target_type": "document",
          "target_id": document_id,
          "content": content,
      }
      if child_target_id is not None:
          body["child_target_id"] = child_target_id
      if parent_comment_id is not None:
          body["parent_comment_id"] = parent_comment_id
      return client.request("POST", "/api/comment", json=body)


  def metabase_resolve_document_comment(client: MetabaseClient, comment_id: int) -> dict:
      """Marca un comentario como resuelto (is_resolved=True).

      Endpoint: PUT /api/comment/:id.
      """
      return client.request("PUT", f"/api/comment/{comment_id}", json={"is_resolved": True})


  def metabase_delete_document(client: MetabaseClient, document_id: int) -> None:
      """Elimina un document. REQUIERE archivarlo primero.

      Endpoint: DELETE /api/document/:id. Si el document no esta archivado,
      Metabase responde: "Document must be archived before it can be deleted."

      Args:
          client: Cliente autenticado.
          document_id: ID del document a eliminar.
      """
      client.request("DELETE", f"/api/document/{document_id}")


  def metabase_move_document(
      client: MetabaseClient,
      document_id: int,
      collection_id: int | None,
  ) -> dict:
      """Mueve un document a otra coleccion.

      Thin wrapper sobre metabase_update_document: envia solo collection_id.
      Endpoint: PUT /api/document/:id con {collection_id: ...}.

      Args:
          client: Cliente autenticado.
          document_id: ID del document a mover.
          collection_id: ID de coleccion destino. None mueve a la raiz ("Our analytics").

      Returns:
          Document actualizado con el nuevo collection_id.

      Example:
          >>> doc = metabase_move_document(client, 42, collection_id=7)
          >>> print(doc["collection_id"])  # 7
          >>> # Mover a raiz:
          >>> doc = metabase_move_document(client, 42, collection_id=None)
      """
      return client.request(
          "PUT",
          f"/api/document/{document_id}",
          json={"collection_id": collection_id},
      )


  def metabase_copy_document(
      client: MetabaseClient,
      document_id: int,
      name: str | None = None,
      collection_id: int | None = None,
  ) -> dict:
      """Copia un document usando el endpoint nativo de Metabase.

      Endpoint: POST /api/document/:id/copy.

      Args:
          client: Cliente autenticado.
          document_id: ID del document a copiar.
          name: Nombre del nuevo document. None = Metabase asigna nombre automatico.
          collection_id: Coleccion destino. None = misma coleccion del original.

      Returns:
          Document nuevo recien creado con su id asignado.

      Example:
          >>> copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5)
          >>> print(copy["id"])
      """
      body: dict = {}
      if name is not None:
          body["name"] = name
      if collection_id is not None:
          body["collection_id"] = collection_id
      return client.request("POST", f"/api/document/{document_id}/copy", json=body)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"document_id","desc":"ID del document original a copiar"},{"name":"name","desc":"nombre del nuevo document; None = Metabase asigna nombre automatico"},{"name":"collection_id","desc":"coleccion destino; None copia a la misma coleccion del original"}],"output":"dict: document nuevo recien creado con id asignado y metadata completa"}


id: metabase_create_card_py_infra
name: metabase_create_card
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
description: Crea una card/pregunta en Metabase con query SQL nativa o MBQL. Endpoint: POST /api/card.
tags: ["metabase","card","question","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 7b0969ea4b92c2c04b2ec6787a276c5431fa892a5ce1f1269e02949e83999c07
signature:
  def metabase_create_card(client: MetabaseClient, name: str, dataset_query: dict, display: str = 'table', collection_id: int = 0, description: str = '') -> dict

example:
  ```python
  card = metabase_create_card(client, "Revenue filtrable", {
      "database": 6, "type": "native",
      "native": {
          "query": "SELECT * FROM orders WHERE 1=1 [[AND date >= {{fecha_desde}}]]",
          "template-tags": {
              "fecha_desde": {
                  "name": "fecha_desde",
                  "display-name": "Fecha desde",
                  "id": "fecha_desde_tag",
                  "type": "text"
              }
          }
      },
  }, display="table")
  ```

notes:
  - `display` válidos: scalar, table, line, bar, pie, area, row, funnel, combo, pivot, map, scatter, waterfall, gauge, progress, smartscalar, sankey.
  - Para queries que se repiten como base de otras cards, considerar crear un "model" (type="model") y referenciar desde otras cards con `source-table: "card__<id>"`.

documentation:
  ## Formato dataset_query — IMPORTANTE

  Hay DOS formatos y hay que saber cuál usar:

  **Para CREAR cards (POST /api/card) — formato legacy:**
  ```python
  {"database": id, "type": "native", "native": {"query": "SELECT ..."}}
  ```

  **Lo que DEVUELVE Metabase al LEER cards (GET /api/card/:id) — formato MBQL5:**
  ```python
  {"lib/type": "mbql/query", "database": id,
   "stages": [{"lib/type": "mbql.stage/native", "native": "SELECT ..."}]}
  ```

  Son representaciones distintas de lo mismo. Al leer una card existente y querer extraer el SQL:
  ```python
  card = metabase_get_card(client, 42)
  ds = card["dataset_query"]

  # Formato MBQL5 (Metabase reciente)
  stages = ds.get("stages", [])
  if stages:
      sql = stages[0].get("native", "")

  # Formato legacy
  else:
      sql = ds.get("native", {}).get("query", "")
  ```

  Al crear/actualizar cards, usar SIEMPRE el formato legacy — Metabase lo acepta y lo convierte internamente.

code:
  """CRUD de cards/preguntas de Metabase y ejecucion de queries."""

  from .client import MetabaseClient


  def metabase_list_cards(
      client: MetabaseClient,
      filter: str = "",
      model_id: int = 0,
  ) -> list[dict]:
      """Lista preguntas/cards de Metabase con filtro opcional.

      Endpoint: GET /api/card. No tiene paginacion offset/limit.

      Args:
          client: Cliente autenticado.
          filter: "all", "mine", "fav", "archived", "recent", "popular",
                  "database", "table". Vacio = todas.
          model_id: ID de database/tabla. Solo aplica con filter "database" o "table".

      Returns:
          Lista de dicts, cada uno con: id, name, description, display,
          collection_id, database_id, creator_id, archived, dataset_query.

      Example:
          >>> cards = metabase_list_cards(client, filter="mine")
          >>> for c in cards:
          ...     print(c["id"], c["name"], c["display"])
      """
      params = {}
      if filter:
          params["f"] = filter
      if model_id > 0:
          params["model_id"] = model_id
      return client.request("GET", "/api/card", params=params)


  def metabase_get_card(client: MetabaseClient, card_id: int) -> dict:
      """Obtiene los detalles completos de una card/pregunta.

      Endpoint: GET /api/card/:id.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.

      Returns:
          Dict con: id, name, description, display, dataset_query,
          visualization_settings, collection_id, database_id, archived,
          creator, created_at, updated_at.

      Example:
          >>> card = metabase_get_card(client, 42)
          >>> print(card["name"], card["display"])
          >>> print(card["dataset_query"]["native"]["query"])  # SQL
      """
      return client.request("GET", f"/api/card/{card_id}")


  def metabase_create_card(
      client: MetabaseClient,
      name: str,
      dataset_query: dict,
      display: str = "table",
      collection_id: int = 0,
      description: str = "",
  ) -> dict:
      """Crea una nueva card/pregunta en Metabase.

      Endpoint: POST /api/card.

      Args:
          client: Cliente autenticado.
          name: Nombre de la pregunta.
          dataset_query: Query de la card. Estructura:
              SQL nativo: {"database": 1, "type": "native", "native": {"query": "SELECT ..."}}
              MBQL: {"database": 1, "type": "query", "query": {"source-table": 4, ...}}
          display: Tipo de visualizacion: "table", "bar", "line", "pie", "scalar",
                   "area", "row", "combo", "funnel", "scatter", "waterfall", etc.
          collection_id: ID de coleccion destino. 0 = root.
          description: Descripcion opcional.

      Returns:
          Dict con la card creada.

      Example:
          >>> card = metabase_create_card(client, "Revenue by Month", {
          ...     "database": 1,
          ...     "type": "native",
          ...     "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
          ... }, display="line", description="Monthly revenue trend")
      """
      body: dict = {
          "name": name,
          "dataset_query": dataset_query,
          "display": display,
          "visualization_settings": {},
      }
      if collection_id > 0:
          body["collection_id"] = collection_id
      if description:
          body["description"] = description
      return client.request("POST", "/api/card", json=body)


  def metabase_update_card(client: MetabaseClient, card_id: int, **fields) -> dict:
      """Actualiza campos de una card/pregunta en Metabase.

      Endpoint: PUT /api/card/:id. Solo se modifican los campos pasados.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.
          **fields: Campos a actualizar. Validos:
              name (str), description (str), display (str),
              dataset_query (dict), visualization_settings (dict),
              collection_id (int), archived (bool),
              enable_embedding (bool), embedding_params (dict).

      Returns:
          Dict con la card actualizada.

      Example:
          >>> metabase_update_card(client, 42, name="Updated Name", archived=True)
          >>> metabase_update_card(client, 42, dataset_query={
          ...     "database": 1, "type": "native",
          ...     "native": {"query": "SELECT * FROM users LIMIT 100"},
          ... })
      """
      return client.request("PUT", f"/api/card/{card_id}", json=fields)


  def metabase_delete_card(client: MetabaseClient, card_id: int) -> None:
      """Elimina permanentemente una card/pregunta.

      Endpoint: DELETE /api/card/:id. IRREVERSIBLE.
      Para soft-delete preferir: metabase_update_card(client, card_id, archived=True)

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a eliminar.

      Example:
          >>> metabase_delete_card(client, 42)
          >>> # Preferir soft-delete: metabase_update_card(client, 42, archived=True)
      """
      client.request("DELETE", f"/api/card/{card_id}")


  def metabase_execute_card(
      client: MetabaseClient,
      card_id: int,
      parameters: list[dict] | None = None,
  ) -> dict:
      """Ejecuta la query de una card/pregunta guardada.

      Endpoint: POST /api/card/:id/query.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a ejecutar.
          parameters: Parametros para queries parametrizadas. Cada parametro:
              {"type": "category", "target": ["variable", ["template-tag", "tag"]], "value": "val"}

      Returns:
          Dict con resultados:
          - status: "completed" o "failed"
          - row_count: numero de filas
          - running_time: tiempo en ms
          - data.columns: nombres de columnas
          - data.rows: filas de datos (lista de listas)
          - data.cols: metadata de columnas
          - data.native_form.query: SQL ejecutado

      Example:
          >>> result = metabase_execute_card(client, 42)
          >>> for row in result["data"]["rows"]:
          ...     print(row)
          >>> # Con parametros:
          >>> result = metabase_execute_card(client, 42, parameters=[
          ...     {"type": "category", "target": ["variable", ["template-tag", "status"]], "value": "active"},
          ... ])
      """
      body = {}
      if parameters:
          body["parameters"] = parameters
      return client.request("POST", f"/api/card/{card_id}/query", json=body or None)


  def metabase_execute_query(
      client: MetabaseClient,
      database_id: int,
      sql: str,
      max_results: int = 0,
  ) -> dict:
      """Ejecuta una query SQL ad-hoc sin guardarla como card.

      Endpoint: POST /api/dataset. Util para exploracion rapida y consultas
      que no necesitan persistirse.

      Args:
          client: Cliente autenticado.
          database_id: ID de la database en Metabase.
          sql: Query SQL a ejecutar.
          max_results: Limite de filas. 0 = default 2000.

      Returns:
          Dict con misma estructura que metabase_execute_card:
          data.columns, data.rows, row_count, running_time, status.

      Example:
          >>> result = metabase_execute_query(client, 1, "SELECT * FROM users LIMIT 10")
          >>> print(f"{result['row_count']} filas en {result['running_time']}ms")
          >>> for row in result["data"]["rows"]:
          ...     print(row)
      """
      body: dict = {
          "database": database_id,
          "type": "native",
          "native": {"query": sql},
      }
      if max_results > 0:
          body["constraints"] = {
              "max-results": max_results,
              "max-results-bare-rows": max_results,
          }
      return client.request("POST", "/api/dataset", json=body)


  def metabase_copy_card(
      client: MetabaseClient,
      card_id: int,
      name: str | None = None,
      collection_id: int | None = None,
      description: str | None = None,
  ) -> dict:
      """Crea una copia de una card/pregunta existente en Metabase.

      Endpoint: POST /api/card/:id/copy. Usa el endpoint nativo de Metabase para
      duplicar la card, copiando dataset_query, display y visualization_settings.
      Los campos name, collection_id y description se pueden sobrescribir via body.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a copiar.
          name: Nombre para la copia. None = Metabase asigna "Copy of <nombre>".
          collection_id: Coleccion destino. None = misma coleccion que el original.
          description: Descripcion de la copia. None = misma que el original.

      Returns:
          Dict con la card nueva creada por Metabase. Incluye el campo `id`
          asignado a la copia y todos los campos heredados del original.

      Example:
          >>> copy = metabase_copy_card(client, 42)
          >>> print(copy["id"], copy["name"])  # "Copy of ..."
          >>> # Copiar a otra coleccion con nombre propio:
          >>> copy = metabase_copy_card(client, 42, name="Revenue Q2", collection_id=7)
      """
      body: dict = {}
      if name is not None:
          body["name"] = name
      if collection_id is not None:
          body["collection_id"] = collection_id
      if description is not None:
          body["description"] = description
      return client.request("POST", f"/api/card/{card_id}/copy", json=body or None)


  def metabase_move_card(
      client: MetabaseClient,
      card_id: int,
      collection_id: int | None,
  ) -> dict:
      """Mueve una card/pregunta a otra coleccion.

      Wrapper thin sobre PUT /api/card/:id que solo actualiza collection_id.
      Equivalente a metabase_update_card(client, card_id, collection_id=...) pero
      con intencion explicita y soporte para mover a root con None.

      Endpoint: PUT /api/card/:id.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a mover.
          collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root).

      Returns:
          Dict con la card actualizada, incluyendo el nuevo collection_id.

      Example:
          >>> card = metabase_move_card(client, 42, collection_id=7)
          >>> print(card["collection_id"])  # 7
          >>> # Mover a root:
          >>> card = metabase_move_card(client, 42, collection_id=None)
      """
      return client.request("PUT", f"/api/card/{card_id}", json={"collection_id": collection_id})


  def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict:
      """Crea una card en Metabase con payload completo ya construido por el caller.

      Version raw de metabase_create_card. El caller es responsable de construir
      el payload completo antes de llamar a esta funcion — no se realiza ninguna
      validacion ni transformacion local. Util para flujos "Metabase as code"
      donde el YAML define todos los campos de la card tal como los espera la API.

      Endpoint: POST /api/card.

      El payload minimo necesita:
          - name (str): nombre de la card.
          - dataset_query (dict): query SQL nativa o MBQL.
          - display (str): tipo de visualizacion (table, bar, scalar, etc.).

      Campos opcionales que esta funcion preserva (a diferencia de metabase_create_card):
          - visualization_settings (dict): configuracion detallada del grafico.
          - parameters (list[dict]): parametros de la query con template tags.
          - parameter_mappings (list[dict]): mapeo de parametros a dashboard filters.
          - type (str): "question" (default), "model", "metric".
          - collection_id (int): ID de coleccion destino.
          - description (str): descripcion de la card.
          - archived (bool): estado de archivo inicial.
          - enable_embedding (bool): habilitar embedding publico.
          - embedding_params (dict): configuracion de embedding.

      Si Metabase devuelve 4xx/5xx, httpx lanza HTTPStatusError sin capturar.

      Args:
          client: Cliente autenticado con sesion activa.
          payload: Dict con el payload completo de la card tal como lo espera
                   la API de Metabase. Se envia sin modificaciones.

      Returns:
          Dict con la card recien creada. Incluye el campo `id` asignado por
          Metabase y todos los campos normalizados (display, dataset_query,
          visualization_settings, created_at, etc.).

      Example:
          >>> card = metabase_create_card_raw(client, {
          ...     "name": "Revenue by Month",
          ...     "dataset_query": {
          ...         "database": 1,
          ...         "type": "native",
          ...         "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
          ...     },
          ...     "display": "line",
          ...     "visualization_settings": {
          ...         "graph.x_axis.title_text": "Month",
          ...         "graph.y_axis.title_text": "Revenue",
          ...     },
          ...     "description": "Monthly revenue trend",
          ...     "collection_id": 5,
          ... })
          >>> print(card["id"])  # ID asignado por Metabase
      """
      return client.request("POST", "/api/card", json=payload)


  def metabase_export_card(
      client: MetabaseClient,
      card_id: int,
      format: str = "csv",
  ) -> bytes:
      """Exporta los resultados de una card en CSV, XLSX o JSON.

      Endpoint: POST /api/card/:id/query/:format.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.
          format: Formato de exportación: "csv", "xlsx" o "json".

      Returns:
          bytes con el contenido del archivo exportado.

      Example:
          >>> data = metabase_export_card(client, 42, format="csv")
          >>> with open("export.csv", "wb") as f:
          ...     f.write(data)
      """
      resp = client._http.request("POST", f"/api/card/{card_id}/query/{format}")
      resp.raise_for_status()
      return resp.content


  def metabase_create_model(
      client: MetabaseClient,
      name: str,
      sql: str,
      database_id: int,
      collection_id: int = 0,
      description: str = "",
  ) -> dict:
      """Crea un modelo (card tipo model) que otras cards pueden referenciar.

      Un modelo es una card con type="model". Otras cards MBQL pueden usarlo
      como fuente con source-table: "card__<model_id>".

      Endpoint: POST /api/card con type="model".

      Args:
          client: Cliente autenticado.
          name: Nombre del modelo.
          sql: Query SQL del modelo.
          database_id: ID de la database en Metabase.
          collection_id: Coleccion destino. 0 = root.
          description: Descripcion opcional.

      Returns:
          Dict con el modelo creado (id, name, type="model").

      Example:
          >>> model = metabase_create_model(client, "supply_orders_base",
          ...     "SELECT * FROM supply_orders WHERE ...", database_id=6)
          >>> # Usar en otra card MBQL:
          >>> card = metabase_create_card(client, "Revenue", {
          ...     "database": 6, "type": "query",
          ...     "query": {"source-table": f"card__{model['id']}"}
          ... })
      """
      body: dict = {
          "name": name,
          "type": "model",
          "dataset_query": {
              "database": database_id,
              "type": "native",
              "native": {"query": sql},
          },
          "display": "table",
          "visualization_settings": {},
      }
      if collection_id > 0:
          body["collection_id"] = collection_id
      if description:
          body["description"] = description
      return client.request("POST", "/api/card", json=body)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"name","desc":"nombre descriptivo de la pregunta/card"},{"name":"dataset_query","desc":"dict con configuración de query (SQL nativa o MBQL)"},{"name":"display","desc":"tipo de visualización (table, scalar, bar, etc.)"},{"name":"collection_id","desc":"ID de colección donde guardar la card (0=root)"},{"name":"description","desc":"descripción opcional de la card"}],"output":"dict: objeto card creado con id y toda su configuración"}


id: metabase_create_card_alert_py_infra
name: metabase_create_card_alert
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","notification","alert","card","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/notifications.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: f0e6178027ecee1e3b94794b22d7f053dda3d4f9eed46e7324bb6a6e965be7d8
signature:
  def metabase_create_card_alert(client: MetabaseClient, card_id: int, cron_schedule: str, recipients: list[dict], send_condition: str = 'has_result', send_once: bool = False) -> dict

description:
  Crea una alerta sobre los resultados de una card en Metabase. Envia email segun cron cuando la card cumple la condicion (has_result, goal_above, goal_below). Endpoint: POST /api/notification.

example:
  ```python
  # Alerta: enviar email los lunes 9am si la card tiene resultados
  alert = metabase_create_card_alert(
      client,
      card_id=7711,
      cron_schedule="0 9 * * 1",
      recipients=[
          {"type": "notification-recipient/user", "user_id": 1},
          {"type": "notification-recipient/raw-value", "details": {"email": "team@aurgi.com"}},
      ],
      send_condition="has_result",
  )
  print(alert["id"], alert["active"])
  ```

notes:
  Requiere Metabase 0.57+. Reemplaza el antiguo /api/pulse.
  El campo `subscriptions` acepta solo el tipo `notification-subscription/cron`.
  Para alertas de goal configurar previamente el goal en la visualizacion de la card.
  `send_once=True` es util para alertas puntuales que no deben repetirse.

code:
  """CRUD de notificaciones de Metabase (alertas y suscripciones).

  Dos tipos:
    - Card alerts (notification/card): alertas cuando una card tiene resultados,
      supera un goal, etc. Cron configurable.
    - Dashboard subscriptions (notification/dashboard): envio periodico de un
      dashboard completo por email/slack.

  API: /api/notification (Metabase 0.57+, reemplaza el antiguo /api/pulse).
  """

  from .client import MetabaseClient


  def metabase_list_notifications(
      client: MetabaseClient,
      card_id: int = 0,
      dashboard_id: int = 0,
  ) -> list[dict]:
      """Lista notificaciones activas de Metabase.

      Endpoint: GET /api/notification.
      Permite filtrar por card o por dashboard. Si no se pasan filtros,
      retorna todas las notificaciones accesibles por el usuario autenticado.

      Args:
          client: Cliente autenticado.
          card_id: Si > 0, filtra notificaciones de tipo notification/card
              asociadas a esta card.
          dashboard_id: Si > 0, filtra notificaciones de tipo
              notification/dashboard asociadas a este dashboard.

      Returns:
          Lista de dicts con: id, active, payload_type, payload,
          subscriptions, handlers, created_at, updated_at.

      Example:
          >>> # Todas las notificaciones
          >>> notifs = metabase_list_notifications(client)
          >>> # Solo alertas de una card especifica
          >>> alerts = metabase_list_notifications(client, card_id=7711)
          >>> for a in alerts:
          ...     print(a["id"], a["payload_type"], a["active"])
      """
      params: dict = {}
      if card_id > 0:
          params["card_id"] = card_id
      if dashboard_id > 0:
          params["dashboard_id"] = dashboard_id
      return client.request("GET", "/api/notification", params=params)


  def metabase_create_card_alert(
      client: MetabaseClient,
      card_id: int,
      cron_schedule: str,
      recipients: list[dict],
      send_condition: str = "has_result",
      send_once: bool = False,
  ) -> dict:
      """Crea una alerta sobre los resultados de una card.

      Endpoint: POST /api/notification.
      Envia un email cuando la card cumple la condicion especificada
      (tiene resultados, supera un goal, etc.) segun el cron configurado.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card que dispara la alerta.
          cron_schedule: Expresion cron de 5 campos (ej: "0 9 * * 1" = lunes 9am).
          recipients: Lista de destinatarios. Cada dict puede ser:
              - Usuario Metabase: {"type": "notification-recipient/user", "user_id": 1}
              - Email externo: {"type": "notification-recipient/raw-value",
                                "details": {"email": "x@y.com"}}
          send_condition: Condicion que dispara el envio:
              "has_result" (tiene filas), "goal_above" (supera goal),
              "goal_below" (cae bajo goal). Default: "has_result".
          send_once: Si True, se envia una sola vez y se desactiva.
              Default: False.

      Returns:
          Dict con la notificacion creada: id, active, payload_type,
          payload, subscriptions, handlers, created_at.

      Example:
          >>> alert = metabase_create_card_alert(
          ...     client,
          ...     card_id=7711,
          ...     cron_schedule="0 9 * * 1",
          ...     recipients=[
          ...         {"type": "notification-recipient/user", "user_id": 1},
          ...         {"type": "notification-recipient/raw-value",
          ...          "details": {"email": "team@aurgi.com"}},
          ...     ],
          ...     send_condition="has_result",
          ... )
          >>> print(alert["id"], alert["active"])
      """
      body = {
          "payload_type": "notification/card",
          "payload": {
              "card_id": card_id,
              "send_condition": send_condition,
              "send_once": send_once,
          },
          "subscriptions": [
              {
                  "type": "notification-subscription/cron",
                  "cron_schedule": cron_schedule,
              }
          ],
          "handlers": [
              {
                  "channel_type": "channel/email",
                  "recipients": recipients,
              }
          ],
          "active": True,
      }
      return client.request("POST", "/api/notification", json=body)


  def metabase_create_dashboard_subscription(
      client: MetabaseClient,
      dashboard_id: int,
      cron_schedule: str,
      recipients: list[dict],
  ) -> dict:
      """Crea una suscripcion periodica a un dashboard.

      Endpoint: POST /api/notification.
      Envia el dashboard completo por email segun el cron configurado.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a enviar.
          cron_schedule: Expresion cron de 5 campos (ej: "0 8 * * 1-5" = lun-vie 8am).
          recipients: Lista de destinatarios. Cada dict puede ser:
              - Usuario Metabase: {"type": "notification-recipient/user", "user_id": 1}
              - Email externo: {"type": "notification-recipient/raw-value",
                                "details": {"email": "x@y.com"}}

      Returns:
          Dict con la suscripcion creada: id, active, payload_type,
          payload, subscriptions, handlers, created_at.

      Example:
          >>> sub = metabase_create_dashboard_subscription(
          ...     client,
          ...     dashboard_id=42,
          ...     cron_schedule="0 8 * * 1",
          ...     recipients=[
          ...         {"type": "notification-recipient/user", "user_id": 5},
          ...     ],
          ... )
          >>> print(sub["id"], sub["payload"]["dashboard_id"])
      """
      body = {
          "payload_type": "notification/dashboard",
          "payload": {
              "dashboard_id": dashboard_id,
          },
          "subscriptions": [
              {
                  "type": "notification-subscription/cron",
                  "cron_schedule": cron_schedule,
              }
          ],
          "handlers": [
              {
                  "channel_type": "channel/email",
                  "recipients": recipients,
              }
          ],
          "active": True,
      }
      return client.request("POST", "/api/notification", json=body)


  def metabase_update_notification(
      client: MetabaseClient,
      notification_id: int,
      **fields,
  ) -> dict:
      """Actualiza una notificacion existente (alerta o suscripcion).

      Endpoint: PUT /api/notification/:id.
      Permite modificar campos como active, handlers, subscriptions,
      payload, etc. Solo se envian los campos proporcionados.

      Args:
          client: Cliente autenticado.
          notification_id: ID de la notificacion a modificar.
          **fields: Campos a actualizar. Ejemplos:
              active=False para desactivar.
              handlers=[...] para cambiar destinatarios.
              subscriptions=[...] para cambiar el cron.

      Returns:
          Dict con la notificacion actualizada: id, active, payload_type,
          payload, subscriptions, handlers, updated_at.

      Example:
          >>> # Desactivar una alerta
          >>> updated = metabase_update_notification(client, 99, active=False)
          >>> # Cambiar destinatarios
          >>> updated = metabase_update_notification(
          ...     client,
          ...     99,
          ...     handlers=[{
          ...         "channel_type": "channel/email",
          ...         "recipients": [
          ...             {"type": "notification-recipient/user", "user_id": 2},
          ...         ],
          ...     }],
          ... )
          >>> print(updated["active"])
      """
      return client.request("PUT", f"/api/notification/{notification_id}", json=fields)


  def metabase_delete_notification(
      client: MetabaseClient,
      notification_id: int,
  ) -> None:
      """Elimina una notificacion (alerta o suscripcion) de Metabase.

      Endpoint: DELETE /api/notification/:id.
      La operacion es irreversible. Afecta tanto a alertas de cards
      como a suscripciones de dashboards.

      Args:
          client: Cliente autenticado.
          notification_id: ID de la notificacion a eliminar.

      Returns:
          None.

      Example:
          >>> metabase_delete_notification(client, 99)
          >>> # La notificacion ya no existe
      """
      client.request("DELETE", f"/api/notification/{notification_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"card_id","desc":"ID de la card que dispara la alerta"},{"name":"cron_schedule","desc":"expresion cron de 5 campos (ej: '0 9 * * 1' = lunes 9am, '0 8 * * 1-5' = lun-vie 8am)"},{"name":"recipients","desc":"lista de destinatarios: usuario Metabase {'type': 'notification-recipient/user', 'user_id': N} o email externo {'type': 'notification-recipient/raw-value', 'details': {'email': 'x@y.com'}}"},{"name":"send_condition","desc":"condicion que dispara el envio: 'has_result' (tiene filas), 'goal_above' (supera goal), 'goal_below' (cae bajo goal)"},{"name":"send_once","desc":"si True, se envia una sola vez y se desactiva automaticamente"}],"output":"dict: notificacion creada con id, active, payload_type, payload, subscriptions, handlers, created_at"}


id: metabase_create_card_raw_py_infra
name: metabase_create_card_raw
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict
tags: ["metabase","card","question","create","api","python","raw","as-code"]
uses_functions: ["metabase_auth_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-13T10:26:52Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 4b91fb8b0f7aad1af0e862c4a25b959a04906526118ca37393e1f4ec28bfef68
description:
  Crea una card en Metabase enviando el payload completo sin modificaciones. Version raw para flujos Metabase-as-code donde el caller construye el body entero. Endpoint: POST /api/card.

example:
  ```python
  card = metabase_create_card_raw(client, {
      "name": "Revenue by Month",
      "dataset_query": {
          "database": 1,
          "type": "native",
          "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
      },
      "display": "line",
      "visualization_settings": {
          "graph.x_axis.title_text": "Month",
          "graph.y_axis.title_text": "Revenue",
      },
      "description": "Monthly revenue trend",
      "collection_id": 5,
  })
  print(card["id"])  # ID asignado por Metabase
  ```

notes:
  No se escriben tests automaticos porque requiere una instancia real de Metabase.
  Los intentos de mockear `client.request` quedan fuera del alcance del registry
  por ahora — la validacion se hace en integracion contra un entorno Metabase real.

  A diferencia de `metabase_create_card`, esta funcion no descarta ni transforma
  ningun campo del payload: lo envia tal cual al endpoint. Esto permite pasar
  `visualization_settings`, `parameters`, `embedding_params`, `type`, etc. sin
  que la funcion los filtre.

  Si Metabase devuelve 4xx/5xx, httpx lanza `HTTPStatusError` sin capturar.
  El caller debe manejar errores si necesita recuperacion.

code:
  """CRUD de cards/preguntas de Metabase y ejecucion de queries."""

  from .client import MetabaseClient


  def metabase_list_cards(
      client: MetabaseClient,
      filter: str = "",
      model_id: int = 0,
  ) -> list[dict]:
      """Lista preguntas/cards de Metabase con filtro opcional.

      Endpoint: GET /api/card. No tiene paginacion offset/limit.

      Args:
          client: Cliente autenticado.
          filter: "all", "mine", "fav", "archived", "recent", "popular",
                  "database", "table". Vacio = todas.
          model_id: ID de database/tabla. Solo aplica con filter "database" o "table".

      Returns:
          Lista de dicts, cada uno con: id, name, description, display,
          collection_id, database_id, creator_id, archived, dataset_query.

      Example:
          >>> cards = metabase_list_cards(client, filter="mine")
          >>> for c in cards:
          ...     print(c["id"], c["name"], c["display"])
      """
      params = {}
      if filter:
          params["f"] = filter
      if model_id > 0:
          params["model_id"] = model_id
      return client.request("GET", "/api/card", params=params)


  def metabase_get_card(client: MetabaseClient, card_id: int) -> dict:
      """Obtiene los detalles completos de una card/pregunta.

      Endpoint: GET /api/card/:id.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.

      Returns:
          Dict con: id, name, description, display, dataset_query,
          visualization_settings, collection_id, database_id, archived,
          creator, created_at, updated_at.

      Example:
          >>> card = metabase_get_card(client, 42)
          >>> print(card["name"], card["display"])
          >>> print(card["dataset_query"]["native"]["query"])  # SQL
      """
      return client.request("GET", f"/api/card/{card_id}")


  def metabase_create_card(
      client: MetabaseClient,
      name: str,
      dataset_query: dict,
      display: str = "table",
      collection_id: int = 0,
      description: str = "",
  ) -> dict:
      """Crea una nueva card/pregunta en Metabase.

      Endpoint: POST /api/card.

      Args:
          client: Cliente autenticado.
          name: Nombre de la pregunta.
          dataset_query: Query de la card. Estructura:
              SQL nativo: {"database": 1, "type": "native", "native": {"query": "SELECT ..."}}
              MBQL: {"database": 1, "type": "query", "query": {"source-table": 4, ...}}
          display: Tipo de visualizacion: "table", "bar", "line", "pie", "scalar",
                   "area", "row", "combo", "funnel", "scatter", "waterfall", etc.
          collection_id: ID de coleccion destino. 0 = root.
          description: Descripcion opcional.

      Returns:
          Dict con la card creada.

      Example:
          >>> card = metabase_create_card(client, "Revenue by Month", {
          ...     "database": 1,
          ...     "type": "native",
          ...     "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
          ... }, display="line", description="Monthly revenue trend")
      """
      body: dict = {
          "name": name,
          "dataset_query": dataset_query,
          "display": display,
          "visualization_settings": {},
      }
      if collection_id > 0:
          body["collection_id"] = collection_id
      if description:
          body["description"] = description
      return client.request("POST", "/api/card", json=body)


  def metabase_update_card(client: MetabaseClient, card_id: int, **fields) -> dict:
      """Actualiza campos de una card/pregunta en Metabase.

      Endpoint: PUT /api/card/:id. Solo se modifican los campos pasados.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.
          **fields: Campos a actualizar. Validos:
              name (str), description (str), display (str),
              dataset_query (dict), visualization_settings (dict),
              collection_id (int), archived (bool),
              enable_embedding (bool), embedding_params (dict).

      Returns:
          Dict con la card actualizada.

      Example:
          >>> metabase_update_card(client, 42, name="Updated Name", archived=True)
          >>> metabase_update_card(client, 42, dataset_query={
          ...     "database": 1, "type": "native",
          ...     "native": {"query": "SELECT * FROM users LIMIT 100"},
          ... })
      """
      return client.request("PUT", f"/api/card/{card_id}", json=fields)


  def metabase_delete_card(client: MetabaseClient, card_id: int) -> None:
      """Elimina permanentemente una card/pregunta.

      Endpoint: DELETE /api/card/:id. IRREVERSIBLE.
      Para soft-delete preferir: metabase_update_card(client, card_id, archived=True)

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a eliminar.

      Example:
          >>> metabase_delete_card(client, 42)
          >>> # Preferir soft-delete: metabase_update_card(client, 42, archived=True)
      """
      client.request("DELETE", f"/api/card/{card_id}")


  def metabase_execute_card(
      client: MetabaseClient,
      card_id: int,
      parameters: list[dict] | None = None,
  ) -> dict:
      """Ejecuta la query de una card/pregunta guardada.

      Endpoint: POST /api/card/:id/query.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a ejecutar.
          parameters: Parametros para queries parametrizadas. Cada parametro:
              {"type": "category", "target": ["variable", ["template-tag", "tag"]], "value": "val"}

      Returns:
          Dict con resultados:
          - status: "completed" o "failed"
          - row_count: numero de filas
          - running_time: tiempo en ms
          - data.columns: nombres de columnas
          - data.rows: filas de datos (lista de listas)
          - data.cols: metadata de columnas
          - data.native_form.query: SQL ejecutado

      Example:
          >>> result = metabase_execute_card(client, 42)
          >>> for row in result["data"]["rows"]:
          ...     print(row)
          >>> # Con parametros:
          >>> result = metabase_execute_card(client, 42, parameters=[
          ...     {"type": "category", "target": ["variable", ["template-tag", "status"]], "value": "active"},
          ... ])
      """
      body = {}
      if parameters:
          body["parameters"] = parameters
      return client.request("POST", f"/api/card/{card_id}/query", json=body or None)


  def metabase_execute_query(
      client: MetabaseClient,
      database_id: int,
      sql: str,
      max_results: int = 0,
  ) -> dict:
      """Ejecuta una query SQL ad-hoc sin guardarla como card.

      Endpoint: POST /api/dataset. Util para exploracion rapida y consultas
      que no necesitan persistirse.

      Args:
          client: Cliente autenticado.
          database_id: ID de la database en Metabase.
          sql: Query SQL a ejecutar.
          max_results: Limite de filas. 0 = default 2000.

      Returns:
          Dict con misma estructura que metabase_execute_card:
          data.columns, data.rows, row_count, running_time, status.

      Example:
          >>> result = metabase_execute_query(client, 1, "SELECT * FROM users LIMIT 10")
          >>> print(f"{result['row_count']} filas en {result['running_time']}ms")
          >>> for row in result["data"]["rows"]:
          ...     print(row)
      """
      body: dict = {
          "database": database_id,
          "type": "native",
          "native": {"query": sql},
      }
      if max_results > 0:
          body["constraints"] = {
              "max-results": max_results,
              "max-results-bare-rows": max_results,
          }
      return client.request("POST", "/api/dataset", json=body)


  def metabase_copy_card(
      client: MetabaseClient,
      card_id: int,
      name: str | None = None,
      collection_id: int | None = None,
      description: str | None = None,
  ) -> dict:
      """Crea una copia de una card/pregunta existente en Metabase.

      Endpoint: POST /api/card/:id/copy. Usa el endpoint nativo de Metabase para
      duplicar la card, copiando dataset_query, display y visualization_settings.
      Los campos name, collection_id y description se pueden sobrescribir via body.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a copiar.
          name: Nombre para la copia. None = Metabase asigna "Copy of <nombre>".
          collection_id: Coleccion destino. None = misma coleccion que el original.
          description: Descripcion de la copia. None = misma que el original.

      Returns:
          Dict con la card nueva creada por Metabase. Incluye el campo `id`
          asignado a la copia y todos los campos heredados del original.

      Example:
          >>> copy = metabase_copy_card(client, 42)
          >>> print(copy["id"], copy["name"])  # "Copy of ..."
          >>> # Copiar a otra coleccion con nombre propio:
          >>> copy = metabase_copy_card(client, 42, name="Revenue Q2", collection_id=7)
      """
      body: dict = {}
      if name is not None:
          body["name"] = name
      if collection_id is not None:
          body["collection_id"] = collection_id
      if description is not None:
          body["description"] = description
      return client.request("POST", f"/api/card/{card_id}/copy", json=body or None)


  def metabase_move_card(
      client: MetabaseClient,
      card_id: int,
      collection_id: int | None,
  ) -> dict:
      """Mueve una card/pregunta a otra coleccion.

      Wrapper thin sobre PUT /api/card/:id que solo actualiza collection_id.
      Equivalente a metabase_update_card(client, card_id, collection_id=...) pero
      con intencion explicita y soporte para mover a root con None.

      Endpoint: PUT /api/card/:id.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a mover.
          collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root).

      Returns:
          Dict con la card actualizada, incluyendo el nuevo collection_id.

      Example:
          >>> card = metabase_move_card(client, 42, collection_id=7)
          >>> print(card["collection_id"])  # 7
          >>> # Mover a root:
          >>> card = metabase_move_card(client, 42, collection_id=None)
      """
      return client.request("PUT", f"/api/card/{card_id}", json={"collection_id": collection_id})


  def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict:
      """Crea una card en Metabase con payload completo ya construido por el caller.

      Version raw de metabase_create_card. El caller es responsable de construir
      el payload completo antes de llamar a esta funcion — no se realiza ninguna
      validacion ni transformacion local. Util para flujos "Metabase as code"
      donde el YAML define todos los campos de la card tal como los espera la API.

      Endpoint: POST /api/card.

      El payload minimo necesita:
          - name (str): nombre de la card.
          - dataset_query (dict): query SQL nativa o MBQL.
          - display (str): tipo de visualizacion (table, bar, scalar, etc.).

      Campos opcionales que esta funcion preserva (a diferencia de metabase_create_card):
          - visualization_settings (dict): configuracion detallada del grafico.
          - parameters (list[dict]): parametros de la query con template tags.
          - parameter_mappings (list[dict]): mapeo de parametros a dashboard filters.
          - type (str): "question" (default), "model", "metric".
          - collection_id (int): ID de coleccion destino.
          - description (str): descripcion de la card.
          - archived (bool): estado de archivo inicial.
          - enable_embedding (bool): habilitar embedding publico.
          - embedding_params (dict): configuracion de embedding.

      Si Metabase devuelve 4xx/5xx, httpx lanza HTTPStatusError sin capturar.

      Args:
          client: Cliente autenticado con sesion activa.
          payload: Dict con el payload completo de la card tal como lo espera
                   la API de Metabase. Se envia sin modificaciones.

      Returns:
          Dict con la card recien creada. Incluye el campo `id` asignado por
          Metabase y todos los campos normalizados (display, dataset_query,
          visualization_settings, created_at, etc.).

      Example:
          >>> card = metabase_create_card_raw(client, {
          ...     "name": "Revenue by Month",
          ...     "dataset_query": {
          ...         "database": 1,
          ...         "type": "native",
          ...         "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
          ...     },
          ...     "display": "line",
          ...     "visualization_settings": {
          ...         "graph.x_axis.title_text": "Month",
          ...         "graph.y_axis.title_text": "Revenue",
          ...     },
          ...     "description": "Monthly revenue trend",
          ...     "collection_id": 5,
          ... })
          >>> print(card["id"])  # ID asignado por Metabase
      """
      return client.request("POST", "/api/card", json=payload)


  def metabase_export_card(
      client: MetabaseClient,
      card_id: int,
      format: str = "csv",
  ) -> bytes:
      """Exporta los resultados de una card en CSV, XLSX o JSON.

      Endpoint: POST /api/card/:id/query/:format.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.
          format: Formato de exportación: "csv", "xlsx" o "json".

      Returns:
          bytes con el contenido del archivo exportado.

      Example:
          >>> data = metabase_export_card(client, 42, format="csv")
          >>> with open("export.csv", "wb") as f:
          ...     f.write(data)
      """
      resp = client._http.request("POST", f"/api/card/{card_id}/query/{format}")
      resp.raise_for_status()
      return resp.content


  def metabase_create_model(
      client: MetabaseClient,
      name: str,
      sql: str,
      database_id: int,
      collection_id: int = 0,
      description: str = "",
  ) -> dict:
      """Crea un modelo (card tipo model) que otras cards pueden referenciar.

      Un modelo es una card con type="model". Otras cards MBQL pueden usarlo
      como fuente con source-table: "card__<model_id>".

      Endpoint: POST /api/card con type="model".

      Args:
          client: Cliente autenticado.
          name: Nombre del modelo.
          sql: Query SQL del modelo.
          database_id: ID de la database en Metabase.
          collection_id: Coleccion destino. 0 = root.
          description: Descripcion opcional.

      Returns:
          Dict con el modelo creado (id, name, type="model").

      Example:
          >>> model = metabase_create_model(client, "supply_orders_base",
          ...     "SELECT * FROM supply_orders WHERE ...", database_id=6)
          >>> # Usar en otra card MBQL:
          >>> card = metabase_create_card(client, "Revenue", {
          ...     "database": 6, "type": "query",
          ...     "query": {"source-table": f"card__{model['id']}"}
          ... })
      """
      body: dict = {
          "name": name,
          "type": "model",
          "dataset_query": {
              "database": database_id,
              "type": "native",
              "native": {"query": sql},
          },
          "display": "table",
          "visualization_settings": {},
      }
      if collection_id > 0:
          body["collection_id"] = collection_id
      if description:
          body["description"] = description
      return client.request("POST", "/api/card", json=body)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient con sesion activa"},{"name":"payload","desc":"dict con el payload completo de la card tal como lo espera la API de Metabase; campos minimos: name, dataset_query, display; campos opcionales preservados: visualization_settings, parameters, parameter_mappings, type, collection_id, description, archived, enable_embedding, embedding_params"}],"output":"dict: objeto card recien creado con id asignado por Metabase y todos los campos normalizados (display, dataset_query, visualization_settings, created_at, etc.)"}


id: metabase_create_dashboard_py_infra
name: metabase_create_dashboard
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","dashboard","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/dashboards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
notes: Se crea vacio. Agregar cards con metabase_update_dashboard(dashcards=[...]).
content_hash: 57edac69f90433bbd6630d3751e22244d922fafe5db210af1f6407d926ea90eb
signature:
  def metabase_create_dashboard(client: MetabaseClient, name: str, description: str = '', collection_id: int = 0) -> dict

description:
  Crea dashboard vacio en Metabase. Para agregar cards usar metabase_update_dashboard con dashcards. Endpoint: POST /api/dashboard.

example:
  ```python
  dash = metabase_create_dashboard(client, "Sales Overview", "KPIs")
  # Agregar cards con metabase_update_dashboard
  ```

code:
  """CRUD de dashboards de Metabase."""

  from .client import MetabaseClient


  def metabase_list_dashboards(
      client: MetabaseClient,
      filter: str = "",
  ) -> list[dict]:
      """Lista dashboards de Metabase con filtro opcional.

      Endpoint: GET /api/dashboard. Retorna dashboards resumidos (sin dashcards).

      Args:
          client: Cliente autenticado.
          filter: "all", "mine" o "archived". Vacio = todas.

      Returns:
          Lista de dicts con: id, name, description, collection_id,
          creator_id, archived, created_at.

      Example:
          >>> dashboards = metabase_list_dashboards(client, filter="mine")
          >>> for d in dashboards:
          ...     print(d["id"], d["name"])
      """
      params = {}
      if filter:
          params["f"] = filter
      return client.request("GET", "/api/dashboard", params=params)


  def metabase_get_dashboard(client: MetabaseClient, dashboard_id: int) -> dict:
      """Obtiene un dashboard completo incluyendo sus cards.

      Endpoint: GET /api/dashboard/:id.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.

      Returns:
          Dict con: id, name, description, dashcards (lista de cards posicionadas),
          parameters (filtros), tabs, collection_id, archived.

          Cada dashcard tiene: id, card_id, card (objeto completo), size_x, size_y,
          col, row, dashboard_tab_id, parameter_mappings, visualization_settings.

      Example:
          >>> dash = metabase_get_dashboard(client, 1)
          >>> for dc in dash["dashcards"]:
          ...     print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})")
      """
      return client.request("GET", f"/api/dashboard/{dashboard_id}")


  def metabase_create_dashboard(
      client: MetabaseClient,
      name: str,
      description: str = "",
      collection_id: int = 0,
  ) -> dict:
      """Crea un nuevo dashboard vacio en Metabase.

      Endpoint: POST /api/dashboard.
      Para agregar cards usar metabase_update_dashboard con dashcards.

      Args:
          client: Cliente autenticado.
          name: Nombre del dashboard.
          description: Descripcion opcional.
          collection_id: Coleccion destino. 0 = root.

      Returns:
          Dict con el dashboard creado.

      Example:
          >>> dash = metabase_create_dashboard(client, "Sales Overview", "KPIs de ventas")
          >>> # Agregar cards:
          >>> metabase_update_dashboard(client, dash["id"], dashcards=[
          ...     {"id": -1, "card_id": 42, "size_x": 6, "size_y": 4, "col": 0, "row": 0},
          ... ])
      """
      body: dict = {"name": name}
      if description:
          body["description"] = description
      if collection_id > 0:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/dashboard", json=body)


  def metabase_update_dashboard(client: MetabaseClient, dashboard_id: int, **fields) -> dict:
      """Actualiza un dashboard incluyendo metadata, cards y tabs.

      Endpoint: PUT /api/dashboard/:id.

      El campo dashcards representa el ESTADO COMPLETO DESEADO del dashboard:
      - Agregar card: incluirla con ID negativo (-1, -2, etc.)
      - Actualizar card existente: incluirla con su ID positivo
      - Eliminar card: omitirla del array

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.
          **fields: Campos a actualizar. Validos:
              name (str), description (str), archived (bool),
              dashcards (list[dict]), tabs (list[dict]),
              parameters (list[dict]), collection_id (int).

      Returns:
          Dict con el dashboard actualizado.

      Example:
          >>> # Cambiar nombre
          >>> metabase_update_dashboard(client, 1, name="Updated Name")
          >>>
          >>> # Agregar card (primero obtener existentes)
          >>> dash = metabase_get_dashboard(client, 1)
          >>> cards = list(dash["dashcards"])
          >>> cards.append({"id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0})
          >>> metabase_update_dashboard(client, 1, dashcards=cards)
          >>>
          >>> # Archivar (soft-delete)
          >>> metabase_update_dashboard(client, 1, archived=True)
      """
      return client.request("PUT", f"/api/dashboard/{dashboard_id}", json=fields)


  def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None:
      """Elimina permanentemente un dashboard.

      Endpoint: DELETE /api/dashboard/:id. IRREVERSIBLE.
      Para soft-delete preferir: metabase_update_dashboard(client, id, archived=True)

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a eliminar.

      Example:
          >>> metabase_delete_dashboard(client, 1)
          >>> # Preferir: metabase_update_dashboard(client, 1, archived=True)
      """
      client.request("DELETE", f"/api/dashboard/{dashboard_id}")


  def metabase_copy_dashboard(
      client: MetabaseClient,
      dashboard_id: int,
      name: str | None = None,
      collection_id: int | None = None,
      description: str | None = None,
      is_deep_copy: bool = False,
  ) -> dict:
      """Crea una copia de un dashboard existente en Metabase.

      Endpoint: POST /api/dashboard/:id/copy. Usa el endpoint nativo de Metabase para
      duplicar el dashboard junto con su layout de dashcards y parametros.
      Con is_deep_copy=True tambien clona las cards referenciadas.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a copiar.
          name: Nombre para la copia. None = Metabase asigna "Copy of <nombre>".
          collection_id: Coleccion destino. None = misma coleccion que el original.
          description: Descripcion de la copia. None = misma que el original.
          is_deep_copy: Si True, clona tambien todas las cards referenciadas por el
              dashboard (deep copy). Si False, la copia referencia las cards originales.

      Returns:
          Dict con el dashboard nuevo creado por Metabase. Incluye el campo `id`
          asignado a la copia y el layout de dashcards copiado.

      Example:
          >>> copy = metabase_copy_dashboard(client, 1)
          >>> print(copy["id"], copy["name"])  # "Copy of ..."
          >>> # Deep copy a otra coleccion:
          >>> copy = metabase_copy_dashboard(client, 1, name="Sales Q2", collection_id=7, is_deep_copy=True)
      """
      body: dict = {"is_deep_copy": is_deep_copy}
      if name is not None:
          body["name"] = name
      if collection_id is not None:
          body["collection_id"] = collection_id
      if description is not None:
          body["description"] = description
      return client.request("POST", f"/api/dashboard/{dashboard_id}/copy", json=body)


  def metabase_move_dashboard(
      client: MetabaseClient,
      dashboard_id: int,
      collection_id: int | None,
  ) -> dict:
      """Mueve un dashboard a otra coleccion.

      Wrapper thin sobre PUT /api/dashboard/:id que solo actualiza collection_id.
      Equivalente a metabase_update_dashboard(client, id, collection_id=...) pero
      con intencion explicita y soporte para mover a root con None.

      Endpoint: PUT /api/dashboard/:id.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a mover.
          collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root).

      Returns:
          Dict con el dashboard actualizado, incluyendo el nuevo collection_id.

      Example:
          >>> dash = metabase_move_dashboard(client, 1, collection_id=7)
          >>> print(dash["collection_id"])  # 7
          >>> # Mover a root:
          >>> dash = metabase_move_dashboard(client, 1, collection_id=None)
      """
      return client.request("PUT", f"/api/dashboard/{dashboard_id}", json={"collection_id": collection_id})


  def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict:
      """Crea un dashboard en Metabase con payload completo ya construido por el caller.

      Version raw de metabase_create_dashboard. El caller es responsable de
      construir el payload completo — no se realiza validacion ni transformacion
      local. Util para flujos "Metabase as code" donde el YAML define todos los
      campos del dashboard tal como los espera la API.

      COMPORTAMIENTO EN DOS PASOS (limitacion de la API de Metabase):
      El endpoint POST /api/dashboard NO acepta `dashcards` en el body inicial;
      solo crea el dashboard vacio. Para añadir cards es necesario un PUT posterior.
      Esta funcion maneja esa limitacion automaticamente:

        1. Si el payload contiene `dashcards`, se extraen antes del POST.
        2. POST /api/dashboard crea el dashboard vacio (sin dashcards).
        3. Si habia dashcards, PUT /api/dashboard/:id con {"dashcards": [...]}
           las añade al dashboard recien creado.
        4. Retorna la respuesta del PUT (con dashcards pobladas), o la del POST
           si el payload original no contenia dashcards.

      Endpoint inicial: POST /api/dashboard.
      Endpoint secundario (condicional): PUT /api/dashboard/:id.

      El payload puede incluir:
          - name (str, requerido): nombre del dashboard.
          - description (str): descripcion.
          - collection_id (int): ID de coleccion destino.
          - parameters (list[dict]): filtros/parametros del dashboard.
          - dashcards (list[dict]): cards posicionadas. Cada dashcard necesita:
              id (int negativo para nuevas, ej: -1, -2),
              card_id (int), size_x (int), size_y (int), col (int), row (int).
              Campos opcionales: visualization_settings, parameter_mappings.
          - tabs (list[dict]): pestañas del dashboard.
          - enable_embedding (bool): habilitar embedding publico.
          - embedding_params (dict): configuracion de embedding.

      Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza HTTPStatusError.

      Args:
          client: Cliente autenticado con sesion activa.
          payload: Dict con el payload completo del dashboard tal como lo espera
                   la API de Metabase. Puede incluir dashcards.

      Returns:
          Dict con el dashboard creado. Si habia dashcards, la respuesta es la
          del PUT final e incluye el campo `dashcards` con las cards posicionadas.
          Si no habia dashcards, es la respuesta del POST inicial.

      Example:
          >>> dash = metabase_create_dashboard_raw(client, {
          ...     "name": "Sales Overview",
          ...     "description": "KPIs de ventas mensuales",
          ...     "collection_id": 5,
          ...     "parameters": [],
          ...     "dashcards": [
          ...         {
          ...             "id": -1,
          ...             "card_id": 42,
          ...             "size_x": 6,
          ...             "size_y": 4,
          ...             "col": 0,
          ...             "row": 0,
          ...             "visualization_settings": {},
          ...             "parameter_mappings": [],
          ...         },
          ...     ],
          ... })
          >>> print(dash["id"])          # ID asignado por Metabase
          >>> print(len(dash["dashcards"]))  # 1
      """
      body = {k: v for k, v in payload.items() if k != "dashcards"}
      dashcards = payload.get("dashcards")

      created = client.request("POST", "/api/dashboard", json=body)

      if dashcards:
          dashboard_id = created["id"]
          return client.request(
              "PUT",
              f"/api/dashboard/{dashboard_id}",
              json={"dashcards": dashcards},
          )

      return created

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"name","desc":"nombre del dashboard"},{"name":"description","desc":"descripción opcional del dashboard"},{"name":"collection_id","desc":"ID de colección donde guardar el dashboard (0=root)"}],"output":"dict: objeto dashboard vacío con id asignado"}


id: metabase_create_dashboard_raw_py_infra
name: metabase_create_dashboard_raw
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict
tags: ["metabase","dashboard","create","api","python","raw","as-code","dashcards"]
uses_functions: ["metabase_auth_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/dashboards.py
created_at: 2026-04-13T10:26:52Z
updated_at: 2026-04-13T13:34:41Z
props: []
emits: []
variant: []
content_hash: 44703871b7bdce5b48a14923cab3a062b9d25ca0716ec85f8410c7074b9dda92
description:
  Crea un dashboard en Metabase enviando el payload completo sin modificaciones. Maneja automaticamente la limitacion de la API que no acepta dashcards en el POST inicial: si el payload contiene dashcards, hace POST para crear el dashboard y luego PUT para añadir las cards. Endpoint: POST /api/dashboard (+ PUT condicional).

example:
  ```python
  # Sin dashcards (solo POST)
  dash = metabase_create_dashboard_raw(client, {
      "name": "Sales Overview",
      "description": "KPIs de ventas mensuales",
      "collection_id": 5,
      "parameters": [],
  })
  print(dash["id"])

  # Con dashcards (POST + PUT automatico)
  dash = metabase_create_dashboard_raw(client, {
      "name": "Sales Overview",
      "description": "KPIs de ventas mensuales",
      "collection_id": 5,
      "parameters": [],
      "dashcards": [
          {
              "id": -1,
              "card_id": 42,
              "size_x": 6,
              "size_y": 4,
              "col": 0,
              "row": 0,
              "visualization_settings": {},
              "parameter_mappings": [],
          },
      ],
  })
  print(dash["id"])
  print(len(dash["dashcards"]))  # 1
  ```

notes:
  No se escriben tests automaticos porque requiere una instancia real de Metabase.
  Los intentos de mockear `client.request` quedan fuera del alcance del registry
  por ahora — la validacion se hace en integracion contra un entorno Metabase real.

  La API de Metabase (al menos hasta v0.49) ignora el campo `dashcards` en el
  POST inicial de creacion de dashboard. Esta funcion absorbe esa limitacion
  internamente: extrae las dashcards del payload antes del POST y las envia en
  un PUT separado usando el id que Metabase asigno al nuevo dashboard.

  Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza `HTTPStatusError`
  sin capturar. Si el POST tiene exito pero el PUT falla, el dashboard queda
  creado pero vacio — el caller debe manejar este caso si necesita atomicidad.

code:
  """CRUD de dashboards de Metabase."""

  from .client import MetabaseClient


  def metabase_list_dashboards(
      client: MetabaseClient,
      filter: str = "",
  ) -> list[dict]:
      """Lista dashboards de Metabase con filtro opcional.

      Endpoint: GET /api/dashboard. Retorna dashboards resumidos (sin dashcards).

      Args:
          client: Cliente autenticado.
          filter: "all", "mine" o "archived". Vacio = todas.

      Returns:
          Lista de dicts con: id, name, description, collection_id,
          creator_id, archived, created_at.

      Example:
          >>> dashboards = metabase_list_dashboards(client, filter="mine")
          >>> for d in dashboards:
          ...     print(d["id"], d["name"])
      """
      params = {}
      if filter:
          params["f"] = filter
      return client.request("GET", "/api/dashboard", params=params)


  def metabase_get_dashboard(client: MetabaseClient, dashboard_id: int) -> dict:
      """Obtiene un dashboard completo incluyendo sus cards.

      Endpoint: GET /api/dashboard/:id.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.

      Returns:
          Dict con: id, name, description, dashcards (lista de cards posicionadas),
          parameters (filtros), tabs, collection_id, archived.

          Cada dashcard tiene: id, card_id, card (objeto completo), size_x, size_y,
          col, row, dashboard_tab_id, parameter_mappings, visualization_settings.

      Example:
          >>> dash = metabase_get_dashboard(client, 1)
          >>> for dc in dash["dashcards"]:
          ...     print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})")
      """
      return client.request("GET", f"/api/dashboard/{dashboard_id}")


  def metabase_create_dashboard(
      client: MetabaseClient,
      name: str,
      description: str = "",
      collection_id: int = 0,
  ) -> dict:
      """Crea un nuevo dashboard vacio en Metabase.

      Endpoint: POST /api/dashboard.
      Para agregar cards usar metabase_update_dashboard con dashcards.

      Args:
          client: Cliente autenticado.
          name: Nombre del dashboard.
          description: Descripcion opcional.
          collection_id: Coleccion destino. 0 = root.

      Returns:
          Dict con el dashboard creado.

      Example:
          >>> dash = metabase_create_dashboard(client, "Sales Overview", "KPIs de ventas")
          >>> # Agregar cards:
          >>> metabase_update_dashboard(client, dash["id"], dashcards=[
          ...     {"id": -1, "card_id": 42, "size_x": 6, "size_y": 4, "col": 0, "row": 0},
          ... ])
      """
      body: dict = {"name": name}
      if description:
          body["description"] = description
      if collection_id > 0:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/dashboard", json=body)


  def metabase_update_dashboard(client: MetabaseClient, dashboard_id: int, **fields) -> dict:
      """Actualiza un dashboard incluyendo metadata, cards y tabs.

      Endpoint: PUT /api/dashboard/:id.

      El campo dashcards representa el ESTADO COMPLETO DESEADO del dashboard:
      - Agregar card: incluirla con ID negativo (-1, -2, etc.)
      - Actualizar card existente: incluirla con su ID positivo
      - Eliminar card: omitirla del array

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.
          **fields: Campos a actualizar. Validos:
              name (str), description (str), archived (bool),
              dashcards (list[dict]), tabs (list[dict]),
              parameters (list[dict]), collection_id (int).

      Returns:
          Dict con el dashboard actualizado.

      Example:
          >>> # Cambiar nombre
          >>> metabase_update_dashboard(client, 1, name="Updated Name")
          >>>
          >>> # Agregar card (primero obtener existentes)
          >>> dash = metabase_get_dashboard(client, 1)
          >>> cards = list(dash["dashcards"])
          >>> cards.append({"id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0})
          >>> metabase_update_dashboard(client, 1, dashcards=cards)
          >>>
          >>> # Archivar (soft-delete)
          >>> metabase_update_dashboard(client, 1, archived=True)
      """
      return client.request("PUT", f"/api/dashboard/{dashboard_id}", json=fields)


  def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None:
      """Elimina permanentemente un dashboard.

      Endpoint: DELETE /api/dashboard/:id. IRREVERSIBLE.
      Para soft-delete preferir: metabase_update_dashboard(client, id, archived=True)

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a eliminar.

      Example:
          >>> metabase_delete_dashboard(client, 1)
          >>> # Preferir: metabase_update_dashboard(client, 1, archived=True)
      """
      client.request("DELETE", f"/api/dashboard/{dashboard_id}")


  def metabase_copy_dashboard(
      client: MetabaseClient,
      dashboard_id: int,
      name: str | None = None,
      collection_id: int | None = None,
      description: str | None = None,
      is_deep_copy: bool = False,
  ) -> dict:
      """Crea una copia de un dashboard existente en Metabase.

      Endpoint: POST /api/dashboard/:id/copy. Usa el endpoint nativo de Metabase para
      duplicar el dashboard junto con su layout de dashcards y parametros.
      Con is_deep_copy=True tambien clona las cards referenciadas.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a copiar.
          name: Nombre para la copia. None = Metabase asigna "Copy of <nombre>".
          collection_id: Coleccion destino. None = misma coleccion que el original.
          description: Descripcion de la copia. None = misma que el original.
          is_deep_copy: Si True, clona tambien todas las cards referenciadas por el
              dashboard (deep copy). Si False, la copia referencia las cards originales.

      Returns:
          Dict con el dashboard nuevo creado por Metabase. Incluye el campo `id`
          asignado a la copia y el layout de dashcards copiado.

      Example:
          >>> copy = metabase_copy_dashboard(client, 1)
          >>> print(copy["id"], copy["name"])  # "Copy of ..."
          >>> # Deep copy a otra coleccion:
          >>> copy = metabase_copy_dashboard(client, 1, name="Sales Q2", collection_id=7, is_deep_copy=True)
      """
      body: dict = {"is_deep_copy": is_deep_copy}
      if name is not None:
          body["name"] = name
      if collection_id is not None:
          body["collection_id"] = collection_id
      if description is not None:
          body["description"] = description
      return client.request("POST", f"/api/dashboard/{dashboard_id}/copy", json=body)


  def metabase_move_dashboard(
      client: MetabaseClient,
      dashboard_id: int,
      collection_id: int | None,
  ) -> dict:
      """Mueve un dashboard a otra coleccion.

      Wrapper thin sobre PUT /api/dashboard/:id que solo actualiza collection_id.
      Equivalente a metabase_update_dashboard(client, id, collection_id=...) pero
      con intencion explicita y soporte para mover a root con None.

      Endpoint: PUT /api/dashboard/:id.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a mover.
          collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root).

      Returns:
          Dict con el dashboard actualizado, incluyendo el nuevo collection_id.

      Example:
          >>> dash = metabase_move_dashboard(client, 1, collection_id=7)
          >>> print(dash["collection_id"])  # 7
          >>> # Mover a root:
          >>> dash = metabase_move_dashboard(client, 1, collection_id=None)
      """
      return client.request("PUT", f"/api/dashboard/{dashboard_id}", json={"collection_id": collection_id})


  def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict:
      """Crea un dashboard en Metabase con payload completo ya construido por el caller.

      Version raw de metabase_create_dashboard. El caller es responsable de
      construir el payload completo — no se realiza validacion ni transformacion
      local. Util para flujos "Metabase as code" donde el YAML define todos los
      campos del dashboard tal como los espera la API.

      COMPORTAMIENTO EN DOS PASOS (limitacion de la API de Metabase):
      El endpoint POST /api/dashboard NO acepta `dashcards` en el body inicial;
      solo crea el dashboard vacio. Para añadir cards es necesario un PUT posterior.
      Esta funcion maneja esa limitacion automaticamente:

        1. Si el payload contiene `dashcards`, se extraen antes del POST.
        2. POST /api/dashboard crea el dashboard vacio (sin dashcards).
        3. Si habia dashcards, PUT /api/dashboard/:id con {"dashcards": [...]}
           las añade al dashboard recien creado.
        4. Retorna la respuesta del PUT (con dashcards pobladas), o la del POST
           si el payload original no contenia dashcards.

      Endpoint inicial: POST /api/dashboard.
      Endpoint secundario (condicional): PUT /api/dashboard/:id.

      El payload puede incluir:
          - name (str, requerido): nombre del dashboard.
          - description (str): descripcion.
          - collection_id (int): ID de coleccion destino.
          - parameters (list[dict]): filtros/parametros del dashboard.
          - dashcards (list[dict]): cards posicionadas. Cada dashcard necesita:
              id (int negativo para nuevas, ej: -1, -2),
              card_id (int), size_x (int), size_y (int), col (int), row (int).
              Campos opcionales: visualization_settings, parameter_mappings.
          - tabs (list[dict]): pestañas del dashboard.
          - enable_embedding (bool): habilitar embedding publico.
          - embedding_params (dict): configuracion de embedding.

      Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza HTTPStatusError.

      Args:
          client: Cliente autenticado con sesion activa.
          payload: Dict con el payload completo del dashboard tal como lo espera
                   la API de Metabase. Puede incluir dashcards.

      Returns:
          Dict con el dashboard creado. Si habia dashcards, la respuesta es la
          del PUT final e incluye el campo `dashcards` con las cards posicionadas.
          Si no habia dashcards, es la respuesta del POST inicial.

      Example:
          >>> dash = metabase_create_dashboard_raw(client, {
          ...     "name": "Sales Overview",
          ...     "description": "KPIs de ventas mensuales",
          ...     "collection_id": 5,
          ...     "parameters": [],
          ...     "dashcards": [
          ...         {
          ...             "id": -1,
          ...             "card_id": 42,
          ...             "size_x": 6,
          ...             "size_y": 4,
          ...             "col": 0,
          ...             "row": 0,
          ...             "visualization_settings": {},
          ...             "parameter_mappings": [],
          ...         },
          ...     ],
          ... })
          >>> print(dash["id"])          # ID asignado por Metabase
          >>> print(len(dash["dashcards"]))  # 1
      """
      body = {k: v for k, v in payload.items() if k != "dashcards"}
      dashcards = payload.get("dashcards")

      created = client.request("POST", "/api/dashboard", json=body)

      if dashcards:
          dashboard_id = created["id"]
          return client.request(
              "PUT",
              f"/api/dashboard/{dashboard_id}",
              json={"dashcards": dashcards},
          )

      return created

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient con sesion activa"},{"name":"payload","desc":"dict con el payload completo del dashboard tal como lo espera la API de Metabase; campos soportados: name (requerido), description, collection_id, parameters, tabs, enable_embedding, embedding_params; dashcards (list[dict]): si presente, se extrae del body POST y se añade en un PUT posterior con id negativo para cards nuevas"}],"output":"dict: objeto dashboard creado; si habia dashcards en el payload, retorna la respuesta del PUT final con el campo dashcards poblado; si no habia dashcards, retorna la respuesta del POST inicial"}


id: metabase_create_dashboard_subscription_py_infra
name: metabase_create_dashboard_subscription
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","notification","subscription","dashboard","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/metabase/notifications.py
created_at: 2026-04-14T15:24:30Z
updated_at: 2026-04-14T15:24:30Z
props: []
emits: []
variant: []
content_hash: 32929cadfb0776f79a00218aced770fc668f44580bd2253af937cd70ecaf0526
signature:
  def metabase_create_dashboard_subscription(client: MetabaseClient, dashboard_id: int, cron_schedule: str, recipients: list[dict]) -> dict

description:
  Crea una suscripcion periodica a un dashboard de Metabase. Envia el dashboard completo por email segun el cron configurado. Endpoint: POST /api/notification.

example:
  ```python
  # Suscripcion: enviar dashboard de ventas cada lunes a las 8am
  sub = metabase_create_dashboard_subscription(
      client,
      dashboard_id=42,
      cron_schedule="0 8 * * 1",
      recipients=[
          {"type": "notification-recipient/user", "user_id": 5},
          {"type": "notification-recipient/raw-value", "details": {"email": "gerencia@aurgi.com"}},
      ],
  )
  print(sub["id"], sub["payload"]["dashboard_id"])
  ```

notes:
  Requiere Metabase 0.57+. Reemplaza el antiguo /api/pulse.
  El dashboard se envia completo con todas sus cards renderizadas.
  Para suscripciones diarias laborales usar cron "0 8 * * 1-5".

code:
  """CRUD de notificaciones de Metabase (alertas y suscripciones).

  Dos tipos:
    - Card alerts (notification/card): alertas cuando una card tiene resultados,
      supera un goal, etc. Cron configurable.
    - Dashboard subscriptions (notification/dashboard): envio periodico de un
      dashboard completo por email/slack.

  API: /api/notification (Metabase 0.57+, reemplaza el antiguo /api/pulse).
  """

  from .client import MetabaseClient


  def metabase_list_notifications(
      client: MetabaseClient,
      card_id: int = 0,
      dashboard_id: int = 0,
  ) -> list[dict]:
      """Lista notificaciones activas de Metabase.

      Endpoint: GET /api/notification.
      Permite filtrar por card o por dashboard. Si no se pasan filtros,
      retorna todas las notificaciones accesibles por el usuario autenticado.

      Args:
          client: Cliente autenticado.
          card_id: Si > 0, filtra notificaciones de tipo notification/card
              asociadas a esta card.
          dashboard_id: Si > 0, filtra notificaciones de tipo
              notification/dashboard asociadas a este dashboard.

      Returns:
          Lista de dicts con: id, active, payload_type, payload,
          subscriptions, handlers, created_at, updated_at.

      Example:
          >>> # Todas las notificaciones
          >>> notifs = metabase_list_notifications(client)
          >>> # Solo alertas de una card especifica
          >>> alerts = metabase_list_notifications(client, card_id=7711)
          >>> for a in alerts:
          ...     print(a["id"], a["payload_type"], a["active"])
      """
      params: dict = {}
      if card_id > 0:
          params["card_id"] = card_id
      if dashboard_id > 0:
          params["dashboard_id"] = dashboard_id
      return client.request("GET", "/api/notification", params=params)


  def metabase_create_card_alert(
      client: MetabaseClient,
      card_id: int,
      cron_schedule: str,
      recipients: list[dict],
      send_condition: str = "has_result",
      send_once: bool = False,
  ) -> dict:
      """Crea una alerta sobre los resultados de una card.

      Endpoint: POST /api/notification.
      Envia un email cuando la card cumple la condicion especificada
      (tiene resultados, supera un goal, etc.) segun el cron configurado.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card que dispara la alerta.
          cron_schedule: Expresion cron de 5 campos (ej: "0 9 * * 1" = lunes 9am).
          recipients: Lista de destinatarios. Cada dict puede ser:
              - Usuario Metabase: {"type": "notification-recipient/user", "user_id": 1}
              - Email externo: {"type": "notification-recipient/raw-value",
                                "details": {"email": "x@y.com"}}
          send_condition: Condicion que dispara el envio:
              "has_result" (tiene filas), "goal_above" (supera goal),
              "goal_below" (cae bajo goal). Default: "has_result".
          send_once: Si True, se envia una sola vez y se desactiva.
              Default: False.

      Returns:
          Dict con la notificacion creada: id, active, payload_type,
          payload, subscriptions, handlers, created_at.

      Example:
          >>> alert = metabase_create_card_alert(
          ...     client,
          ...     card_id=7711,
          ...     cron_schedule="0 9 * * 1",
          ...     recipients=[
          ...         {"type": "notification-recipient/user", "user_id": 1},
          ...         {"type": "notification-recipient/raw-value",
          ...          "details": {"email": "team@aurgi.com"}},
          ...     ],
          ...     send_condition="has_result",
          ... )
          >>> print(alert["id"], alert["active"])
      """
      body = {
          "payload_type": "notification/card",
          "payload": {
              "card_id": card_id,
              "send_condition": send_condition,
              "send_once": send_once,
          },
          "subscriptions": [
              {
                  "type": "notification-subscription/cron",
                  "cron_schedule": cron_schedule,
              }
          ],
          "handlers": [
              {
                  "channel_type": "channel/email",
                  "recipients": recipients,
              }
          ],
          "active": True,
      }
      return client.request("POST", "/api/notification", json=body)


  def metabase_create_dashboard_subscription(
      client: MetabaseClient,
      dashboard_id: int,
      cron_schedule: str,
      recipients: list[dict],
  ) -> dict:
      """Crea una suscripcion periodica a un dashboard.

      Endpoint: POST /api/notification.
      Envia el dashboard completo por email segun el cron configurado.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a enviar.
          cron_schedule: Expresion cron de 5 campos (ej: "0 8 * * 1-5" = lun-vie 8am).
          recipients: Lista de destinatarios. Cada dict puede ser:
              - Usuario Metabase: {"type": "notification-recipient/user", "user_id": 1}
              - Email externo: {"type": "notification-recipient/raw-value",
                                "details": {"email": "x@y.com"}}

      Returns:
          Dict con la suscripcion creada: id, active, payload_type,
          payload, subscriptions, handlers, created_at.

      Example:
          >>> sub = metabase_create_dashboard_subscription(
          ...     client,
          ...     dashboard_id=42,
          ...     cron_schedule="0 8 * * 1",
          ...     recipients=[
          ...         {"type": "notification-recipient/user", "user_id": 5},
          ...     ],
          ... )
          >>> print(sub["id"], sub["payload"]["dashboard_id"])
      """
      body = {
          "payload_type": "notification/dashboard",
          "payload": {
              "dashboard_id": dashboard_id,
          },
          "subscriptions": [
              {
                  "type": "notification-subscription/cron",
                  "cron_schedule": cron_schedule,
              }
          ],
          "handlers": [
              {
                  "channel_type": "channel/email",
                  "recipients": recipients,
              }
          ],
          "active": True,
      }
      return client.request("POST", "/api/notification", json=body)


  def metabase_update_notification(
      client: MetabaseClient,
      notification_id: int,
      **fields,
  ) -> dict:
      """Actualiza una notificacion existente (alerta o suscripcion).

      Endpoint: PUT /api/notification/:id.
      Permite modificar campos como active, handlers, subscriptions,
      payload, etc. Solo se envian los campos proporcionados.

      Args:
          client: Cliente autenticado.
          notification_id: ID de la notificacion a modificar.
          **fields: Campos a actualizar. Ejemplos:
              active=False para desactivar.
              handlers=[...] para cambiar destinatarios.
              subscriptions=[...] para cambiar el cron.

      Returns:
          Dict con la notificacion actualizada: id, active, payload_type,
          payload, subscriptions, handlers, updated_at.

      Example:
          >>> # Desactivar una alerta
          >>> updated = metabase_update_notification(client, 99, active=False)
          >>> # Cambiar destinatarios
          >>> updated = metabase_update_notification(
          ...     client,
          ...     99,
          ...     handlers=[{
          ...         "channel_type": "channel/email",
          ...         "recipients": [
          ...             {"type": "notification-recipient/user", "user_id": 2},
          ...         ],
          ...     }],
          ... )
          >>> print(updated["active"])
      """
      return client.request("PUT", f"/api/notification/{notification_id}", json=fields)


  def metabase_delete_notification(
      client: MetabaseClient,
      notification_id: int,
  ) -> None:
      """Elimina una notificacion (alerta o suscripcion) de Metabase.

      Endpoint: DELETE /api/notification/:id.
      La operacion es irreversible. Afecta tanto a alertas de cards
      como a suscripciones de dashboards.

      Args:
          client: Cliente autenticado.
          notification_id: ID de la notificacion a eliminar.

      Returns:
          None.

      Example:
          >>> metabase_delete_notification(client, 99)
          >>> # La notificacion ya no existe
      """
      client.request("DELETE", f"/api/notification/{notification_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"dashboard_id","desc":"ID del dashboard a enviar periodicamente"},{"name":"cron_schedule","desc":"expresion cron de 5 campos (ej: '0 8 * * 1-5' = lun-vie 8am, '0 9 * * 1' = lunes 9am)"},{"name":"recipients","desc":"lista de destinatarios: usuario Metabase {'type': 'notification-recipient/user', 'user_id': N} o email externo {'type': 'notification-recipient/raw-value', 'details': {'email': 'x@y.com'}}"}],"output":"dict: suscripcion creada con id, active, payload_type, payload, subscriptions, handlers, created_at"}


