# functions

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: 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: 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: 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_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-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: e50d4b53a6ac92a7cfb42f2dff2906bdac3a60e40238bf869b5e41d5333f70e1
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) -> None:
          self.base_url = base_url.rstrip("/")
          self.token = token
          self._http = httpx.Client(
              base_url=self.base_url,
              headers={
                  "Content-Type": "application/json",
                  "X-Metabase-Session": token,
              },
              timeout=30.0,
          )

      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_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-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: 05503ee06217a3f7b3af380cbbff2adbce9f50bdb33ffc7653dc9276d12107fc
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", {
      "database": 1, "type": "native",
      "native": {"query": "SELECT SUM(total) FROM orders"},
  }, display="scalar")
  ```

notes:
  dataset_query SQL nativo: `{"database": id, "type": "native", "native": {"query": "..."}}`
  dataset_query MBQL: `{"database": id, "type": "query", "query": {"source-table": id, ...}}`

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)

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_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-05T16:44:15Z
props: []
emits: []
variant: []
notes: Se crea vacio. Agregar cards con metabase_update_dashboard(dashcards=[...]).
content_hash: adff47902d1d3b2f5b3117fb0d3ea1054dec3260d4f1b896a9bb75a20186f659
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}")

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_user_py_infra
name: metabase_create_user
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","user","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/users.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
notes: Email debe ser unico. Error 400 si ya existe.
content_hash: 2e89f34d147d8240040dfde6024d8277b5bde5a8e96c136d95c64c4ae419cfd9
signature:
  def metabase_create_user(client: MetabaseClient, first_name: str, last_name: str, email: str, password: str = '', group_ids: list[int] | None = None) -> dict

description:
  Crea un nuevo usuario en Metabase. Sin password envia invitacion por email. Requiere superusuario. Endpoint: POST /api/user.

example:
  ```python
  user = metabase_create_user(client, "John", "Doe", "john@example.com", "pass123")
  user = metabase_create_user(client, "Jane", "Smith", "jane@example.com", group_ids=[1, 3])
  ```

code:
  """CRUD de usuarios de Metabase."""

  from .client import MetabaseClient


  def metabase_list_users(
      client: MetabaseClient,
      status: str = "",
      query: str = "",
      limit: int = 0,
      offset: int = 0,
  ) -> dict:
      """Lista usuarios de Metabase con filtros opcionales.

      Endpoint: GET /api/user. Requiere permisos de superusuario.

      Args:
          client: Cliente autenticado.
          status: "active" (default), "deactivated" o "all".
          query: Filtro por nombre o email.
          limit: Tamanio de pagina (0 = default Metabase).
          offset: Offset para paginacion.

      Returns:
          Dict con estructura paginada:
          - data: lista de usuarios (id, email, first_name, last_name, is_superuser, etc.)
          - total: numero total de usuarios que coinciden
          - limit: tamanio de pagina usado
          - offset: offset usado

      Example:
          >>> users = metabase_list_users(client, status="active", query="john@")
          >>> for u in users["data"]:
          ...     print(u["email"], u["first_name"])
      """
      params = {}
      if status:
          params["status"] = status
      if query:
          params["query"] = query
      if limit > 0:
          params["limit"] = limit
      if offset > 0:
          params["offset"] = offset
      return client.request("GET", "/api/user", params=params)


  def metabase_get_user(client: MetabaseClient, user_id: int) -> dict:
      """Obtiene un usuario de Metabase por su ID.

      Endpoint: GET /api/user/:id.

      Args:
          client: Cliente autenticado.
          user_id: ID numerico del usuario.

      Returns:
          Dict con datos del usuario: id, email, first_name, last_name,
          is_superuser, is_active, common_name, date_joined, last_login,
          group_ids, locale.

      Raises:
          httpx.HTTPStatusError: 404 si el usuario no existe.

      Example:
          >>> user = metabase_get_user(client, 1)
          >>> print(user["email"], user["is_superuser"])
      """
      return client.request("GET", f"/api/user/{user_id}")


  def metabase_create_user(
      client: MetabaseClient,
      first_name: str,
      last_name: str,
      email: str,
      password: str = "",
      group_ids: list[int] | None = None,
  ) -> dict:
      """Crea un nuevo usuario en Metabase.

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

      Args:
          client: Cliente autenticado con permisos admin.
          first_name: Nombre del usuario.
          last_name: Apellido del usuario.
          email: Email unico del usuario.
          password: Password. Vacio = Metabase envia invitacion por email.
          group_ids: IDs de grupos a asignar. None = solo grupo default.

      Returns:
          Dict con el usuario creado (mismos campos que metabase_get_user).

      Raises:
          httpx.HTTPStatusError: 400 si el email ya existe.

      Example:
          >>> user = metabase_create_user(client, "John", "Doe", "john@example.com", "pass123")
          >>> print(user["id"])
      """
      body: dict = {
          "first_name": first_name,
          "last_name": last_name,
          "email": email,
      }
      if password:
          body["password"] = password
      if group_ids:
          body["group_ids"] = group_ids
      return client.request("POST", "/api/user", json=body)


  def metabase_update_user(client: MetabaseClient, user_id: int, **fields) -> dict:
      """Actualiza campos de un usuario en Metabase.

      Endpoint: PUT /api/user/:id. Requiere permisos de superusuario.
      Solo se modifican los campos pasados como keyword arguments.

      Args:
          client: Cliente autenticado con permisos admin.
          user_id: ID del usuario a actualizar.
          **fields: Campos a actualizar. Validos:
              first_name (str), last_name (str), email (str),
              is_superuser (bool), group_ids (list[int]),
              locale (str), login_attributes (dict).

      Returns:
          Dict con el usuario actualizado.

      Example:
          >>> user = metabase_update_user(client, 5, first_name="Jane", is_superuser=True)
          >>> user = metabase_update_user(client, 5, group_ids=[1, 3, 5])
      """
      return client.request("PUT", f"/api/user/{user_id}", json=fields)


  def metabase_deactivate_user(client: MetabaseClient, user_id: int) -> None:
      """Desactiva (soft-delete) un usuario en Metabase.

      Endpoint: DELETE /api/user/:id. Requiere permisos de superusuario.
      El usuario no se elimina permanentemente, solo se marca como inactivo.
      Para reactivar: PUT /api/user/:id/reactivate.

      Args:
          client: Cliente autenticado con permisos admin.
          user_id: ID del usuario a desactivar.

      Example:
          >>> metabase_deactivate_user(client, 5)
          >>> # Para ver desactivados: metabase_list_users(client, status="deactivated")
      """
      client.request("DELETE", f"/api/user/{user_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"first_name","desc":"nombre del usuario"},{"name":"last_name","desc":"apellido del usuario"},{"name":"email","desc":"correo único del usuario"},{"name":"password","desc":"contraseña (vacío envía invitación por email)"},{"name":"group_ids","desc":"lista de IDs de grupos a los que agregar el usuario"}],"output":"dict: objeto usuario creado con id y metadata"}


id: metabase_deactivate_user_py_infra
name: metabase_deactivate_user
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_deactivate_user(client: MetabaseClient, user_id: int) -> None
tags: ["metabase","user","delete","deactivate","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
example:
  ```python
  metabase_deactivate_user(client, 5)
  ```

tests: []
file_path: python/functions/metabase/users.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
notes: Soft-delete. El usuario se puede reactivar.
content_hash: 99ef9d8c029ad38753b4d39dd89de9909ca9c4c531d9c91d675b0c7e24a6427b
description:
  Desactiva (soft-delete) un usuario en Metabase. Reactivar con PUT /api/user/:id/reactivate. Endpoint: DELETE /api/user/:id.

code:
  """CRUD de usuarios de Metabase."""

  from .client import MetabaseClient


  def metabase_list_users(
      client: MetabaseClient,
      status: str = "",
      query: str = "",
      limit: int = 0,
      offset: int = 0,
  ) -> dict:
      """Lista usuarios de Metabase con filtros opcionales.

      Endpoint: GET /api/user. Requiere permisos de superusuario.

      Args:
          client: Cliente autenticado.
          status: "active" (default), "deactivated" o "all".
          query: Filtro por nombre o email.
          limit: Tamanio de pagina (0 = default Metabase).
          offset: Offset para paginacion.

      Returns:
          Dict con estructura paginada:
          - data: lista de usuarios (id, email, first_name, last_name, is_superuser, etc.)
          - total: numero total de usuarios que coinciden
          - limit: tamanio de pagina usado
          - offset: offset usado

      Example:
          >>> users = metabase_list_users(client, status="active", query="john@")
          >>> for u in users["data"]:
          ...     print(u["email"], u["first_name"])
      """
      params = {}
      if status:
          params["status"] = status
      if query:
          params["query"] = query
      if limit > 0:
          params["limit"] = limit
      if offset > 0:
          params["offset"] = offset
      return client.request("GET", "/api/user", params=params)


  def metabase_get_user(client: MetabaseClient, user_id: int) -> dict:
      """Obtiene un usuario de Metabase por su ID.

      Endpoint: GET /api/user/:id.

      Args:
          client: Cliente autenticado.
          user_id: ID numerico del usuario.

      Returns:
          Dict con datos del usuario: id, email, first_name, last_name,
          is_superuser, is_active, common_name, date_joined, last_login,
          group_ids, locale.

      Raises:
          httpx.HTTPStatusError: 404 si el usuario no existe.

      Example:
          >>> user = metabase_get_user(client, 1)
          >>> print(user["email"], user["is_superuser"])
      """
      return client.request("GET", f"/api/user/{user_id}")


  def metabase_create_user(
      client: MetabaseClient,
      first_name: str,
      last_name: str,
      email: str,
      password: str = "",
      group_ids: list[int] | None = None,
  ) -> dict:
      """Crea un nuevo usuario en Metabase.

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

      Args:
          client: Cliente autenticado con permisos admin.
          first_name: Nombre del usuario.
          last_name: Apellido del usuario.
          email: Email unico del usuario.
          password: Password. Vacio = Metabase envia invitacion por email.
          group_ids: IDs de grupos a asignar. None = solo grupo default.

      Returns:
          Dict con el usuario creado (mismos campos que metabase_get_user).

      Raises:
          httpx.HTTPStatusError: 400 si el email ya existe.

      Example:
          >>> user = metabase_create_user(client, "John", "Doe", "john@example.com", "pass123")
          >>> print(user["id"])
      """
      body: dict = {
          "first_name": first_name,
          "last_name": last_name,
          "email": email,
      }
      if password:
          body["password"] = password
      if group_ids:
          body["group_ids"] = group_ids
      return client.request("POST", "/api/user", json=body)


  def metabase_update_user(client: MetabaseClient, user_id: int, **fields) -> dict:
      """Actualiza campos de un usuario en Metabase.

      Endpoint: PUT /api/user/:id. Requiere permisos de superusuario.
      Solo se modifican los campos pasados como keyword arguments.

      Args:
          client: Cliente autenticado con permisos admin.
          user_id: ID del usuario a actualizar.
          **fields: Campos a actualizar. Validos:
              first_name (str), last_name (str), email (str),
              is_superuser (bool), group_ids (list[int]),
              locale (str), login_attributes (dict).

      Returns:
          Dict con el usuario actualizado.

      Example:
          >>> user = metabase_update_user(client, 5, first_name="Jane", is_superuser=True)
          >>> user = metabase_update_user(client, 5, group_ids=[1, 3, 5])
      """
      return client.request("PUT", f"/api/user/{user_id}", json=fields)


  def metabase_deactivate_user(client: MetabaseClient, user_id: int) -> None:
      """Desactiva (soft-delete) un usuario en Metabase.

      Endpoint: DELETE /api/user/:id. Requiere permisos de superusuario.
      El usuario no se elimina permanentemente, solo se marca como inactivo.
      Para reactivar: PUT /api/user/:id/reactivate.

      Args:
          client: Cliente autenticado con permisos admin.
          user_id: ID del usuario a desactivar.

      Example:
          >>> metabase_deactivate_user(client, 5)
          >>> # Para ver desactivados: metabase_list_users(client, status="deactivated")
      """
      client.request("DELETE", f"/api/user/{user_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"user_id","desc":"ID del usuario a desactivar"}],"output":"None"}


id: metabase_delete_card_py_infra
name: metabase_delete_card
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_delete_card(client: MetabaseClient, card_id: int) -> None
tags: ["metabase","card","question","delete","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-05T16:44:15Z
props: []
emits: []
variant: []
notes: IRREVERSIBLE. Preferir soft-delete con metabase_update_card(archived=True).
content_hash: 4756e56351751837d624cf3f574c56ef16ada1337c74a22f266d4a35c61e4247
description:
  Elimina permanentemente una card/pregunta. IRREVERSIBLE. Preferir archived=True. Endpoint: DELETE /api/card/:id.

example:
  ```python
  metabase_delete_card(client, 42)
  # Preferir: metabase_update_card(client, 42, archived=True)
  ```

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)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"card_id","desc":"ID de la card a eliminar permanentemente"}],"output":"None"}


id: metabase_delete_dashboard_py_infra
name: metabase_delete_dashboard
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None
tags: ["metabase","dashboard","delete","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-05T16:44:15Z
props: []
emits: []
variant: []
notes: IRREVERSIBLE. Preferir soft-delete con metabase_update_dashboard(archived=True).
content_hash: f217f91f3d75ccb6f1d1d704a9535e01bab7ac07299a9ecbcbc74a9ceb814c98
description:
  Elimina permanentemente un dashboard. IRREVERSIBLE. Preferir archived=True. Endpoint: DELETE /api/dashboard/:id.

example:
  ```python
  metabase_delete_dashboard(client, 1)
  # Preferir: metabase_update_dashboard(client, 1, archived=True)
  ```

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}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"dashboard_id","desc":"ID del dashboard a eliminar permanentemente"}],"output":"None"}


id: metabase_execute_card_py_infra
name: metabase_execute_card
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","card","question","execute","query","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-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: 2b3875fa4841f0a7036b37cbbe22142b8e38856fa646642e24e3e779f61a0485
signature:
  def metabase_execute_card(client: MetabaseClient, card_id: int, parameters: list[dict] | None = None) -> dict

description:
  Ejecuta la query de una card guardada y retorna resultados con columnas y filas. Soporta parametros. Endpoint: POST /api/card/:id/query.

example:
  ```python
  result = metabase_execute_card(client, 42)
  for row in result["data"]["rows"]:
      print(row)
  ```

notes:
  Respuesta: status, row_count, running_time, data.columns, data.rows, data.cols.
  Limite default: 2000 filas. Para ad-hoc sin card usar metabase_execute_query.

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)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"card_id","desc":"ID de la card guardada a ejecutar"},{"name":"parameters","desc":"lista de parámetros para la query (filtros dinámicos)"}],"output":"dict: resultados con status, row_count, running_time, data.columns, data.rows"}


id: metabase_execute_query_py_infra
name: metabase_execute_query
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","query","execute","sql","dataset","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-05T16:44:15Z
props: []
emits: []
variant: []
notes: Misma respuesta que metabase_execute_card. Default 2000 filas, override con max_results.
content_hash: fd7da304b88b150b121cc8961562b79424e2474c33f7f3ba3efa07d3b0366a7d
signature:
  def metabase_execute_query(client: MetabaseClient, database_id: int, sql: str, max_results: int = 0) -> dict

description:
  Ejecuta query SQL ad-hoc contra Metabase sin guardarla como card. Util para exploracion rapida. Endpoint: POST /api/dataset.

example:
  ```python
  result = metabase_execute_query(client, 1, "SELECT * FROM users LIMIT 10")
  print(f"{result['row_count']} filas en {result['running_time']}ms")
  ```

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

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"database_id","desc":"ID de la base de datos contra la que ejecutar la query"},{"name":"sql","desc":"sentencia SQL a ejecutar (sin guardar como card)"},{"name":"max_results","desc":"límite de filas a retornar (0=default 2000)"}],"output":"dict: resultados con status, row_count, running_time, data.columns, data.rows"}


id: metabase_get_card_py_infra
name: metabase_get_card
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_get_card(client: MetabaseClient, card_id: int) -> dict
tags: ["metabase","card","question","get","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
example:
  ```python
  card = metabase_get_card(client, 42)
  print(card["name"], card["display"])
  ```

tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
notes: Error 404 si no existe.
content_hash: cf61ed5f01693153a0db311442520b6ae444392e3502d268721d82f48dcb008d
description:
  Obtiene detalles completos de una card/pregunta de Metabase incluyendo query, visualizacion y metadata. Endpoint: GET /api/card/:id.

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

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"card_id","desc":"ID de la card a obtener"}],"output":"dict: objeto card completo con query, visualización y metadata"}


id: metabase_get_dashboard_py_infra
name: metabase_get_dashboard
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_get_dashboard(client: MetabaseClient, dashboard_id: int) -> dict
tags: ["metabase","dashboard","get","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-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: dcb8c2a0de723724a967d04b94756ebffe56caa0116a6f56b90e615b92f8f33b
description:
  Obtiene dashboard completo con dashcards (cards posicionadas), tabs y parametros. Endpoint: GET /api/dashboard/:id.

example:
  ```python
  dash = metabase_get_dashboard(client, 1)
  for dc in dash["dashcards"]:
      print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})")
  ```

notes:
  Cada dashcard tiene: id, card_id, card, size_x, size_y, col, row, dashboard_tab_id, parameter_mappings.

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}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"dashboard_id","desc":"ID del dashboard a obtener"}],"output":"dict: objeto dashboard completo con dashcards, tabs y parámetros"}


id: metabase_get_database_py_infra
name: metabase_get_database
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_get_database(client: MetabaseClient, database_id: int) -> dict
description: Obtiene los detalles de una database de Metabase por su ID. Endpoint: GET /api/database/:id.
tags: ["metabase","database","get","api","python"]
uses_functions: []
uses_types: ["MetabaseClient_go_infra"]
returns: []
error_type: error_go_core
imports: ["httpx"]
example:
  ```python
  db = metabase_get_database(client, 2)
  print(db["name"], db["engine"])
  ```

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: 068bf9159113f6214becb9598cea524d75af60eb2bd51443cc955fd6c853c509
notes:
  Error 404 si la database no existe. Retorna campos completos incluyendo
  id, name, engine, details, is_full_sync, auto_run_queries, created_at, features.

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":"database_id","desc":"ID de la base de datos a obtener"}],"output":"dict: objeto database con id, name, engine, details, features y metadata"}


id: metabase_get_user_py_infra
name: metabase_get_user
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_get_user(client: MetabaseClient, user_id: int) -> dict
description: Obtiene los detalles de un usuario de Metabase por su ID. Endpoint: GET /api/user/:id.
tags: ["metabase","user","get","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
example:
  ```python
  user = metabase_get_user(client, 1)
  print(user["email"], user["is_superuser"])
  ```

tests: []
file_path: python/functions/metabase/users.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
notes: Error 404 si el usuario no existe.
content_hash: 47dcd68926dc4c2564cec0bc579353bf741211eecd614aa4e03444c908b21b64
code:
  """CRUD de usuarios de Metabase."""

  from .client import MetabaseClient


  def metabase_list_users(
      client: MetabaseClient,
      status: str = "",
      query: str = "",
      limit: int = 0,
      offset: int = 0,
  ) -> dict:
      """Lista usuarios de Metabase con filtros opcionales.

      Endpoint: GET /api/user. Requiere permisos de superusuario.

      Args:
          client: Cliente autenticado.
          status: "active" (default), "deactivated" o "all".
          query: Filtro por nombre o email.
          limit: Tamanio de pagina (0 = default Metabase).
          offset: Offset para paginacion.

      Returns:
          Dict con estructura paginada:
          - data: lista de usuarios (id, email, first_name, last_name, is_superuser, etc.)
          - total: numero total de usuarios que coinciden
          - limit: tamanio de pagina usado
          - offset: offset usado

      Example:
          >>> users = metabase_list_users(client, status="active", query="john@")
          >>> for u in users["data"]:
          ...     print(u["email"], u["first_name"])
      """
      params = {}
      if status:
          params["status"] = status
      if query:
          params["query"] = query
      if limit > 0:
          params["limit"] = limit
      if offset > 0:
          params["offset"] = offset
      return client.request("GET", "/api/user", params=params)


  def metabase_get_user(client: MetabaseClient, user_id: int) -> dict:
      """Obtiene un usuario de Metabase por su ID.

      Endpoint: GET /api/user/:id.

      Args:
          client: Cliente autenticado.
          user_id: ID numerico del usuario.

      Returns:
          Dict con datos del usuario: id, email, first_name, last_name,
          is_superuser, is_active, common_name, date_joined, last_login,
          group_ids, locale.

      Raises:
          httpx.HTTPStatusError: 404 si el usuario no existe.

      Example:
          >>> user = metabase_get_user(client, 1)
          >>> print(user["email"], user["is_superuser"])
      """
      return client.request("GET", f"/api/user/{user_id}")


  def metabase_create_user(
      client: MetabaseClient,
      first_name: str,
      last_name: str,
      email: str,
      password: str = "",
      group_ids: list[int] | None = None,
  ) -> dict:
      """Crea un nuevo usuario en Metabase.

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

      Args:
          client: Cliente autenticado con permisos admin.
          first_name: Nombre del usuario.
          last_name: Apellido del usuario.
          email: Email unico del usuario.
          password: Password. Vacio = Metabase envia invitacion por email.
          group_ids: IDs de grupos a asignar. None = solo grupo default.

      Returns:
          Dict con el usuario creado (mismos campos que metabase_get_user).

      Raises:
          httpx.HTTPStatusError: 400 si el email ya existe.

      Example:
          >>> user = metabase_create_user(client, "John", "Doe", "john@example.com", "pass123")
          >>> print(user["id"])
      """
      body: dict = {
          "first_name": first_name,
          "last_name": last_name,
          "email": email,
      }
      if password:
          body["password"] = password
      if group_ids:
          body["group_ids"] = group_ids
      return client.request("POST", "/api/user", json=body)


  def metabase_update_user(client: MetabaseClient, user_id: int, **fields) -> dict:
      """Actualiza campos de un usuario en Metabase.

      Endpoint: PUT /api/user/:id. Requiere permisos de superusuario.
      Solo se modifican los campos pasados como keyword arguments.

      Args:
          client: Cliente autenticado con permisos admin.
          user_id: ID del usuario a actualizar.
          **fields: Campos a actualizar. Validos:
              first_name (str), last_name (str), email (str),
              is_superuser (bool), group_ids (list[int]),
              locale (str), login_attributes (dict).

      Returns:
          Dict con el usuario actualizado.

      Example:
          >>> user = metabase_update_user(client, 5, first_name="Jane", is_superuser=True)
          >>> user = metabase_update_user(client, 5, group_ids=[1, 3, 5])
      """
      return client.request("PUT", f"/api/user/{user_id}", json=fields)


  def metabase_deactivate_user(client: MetabaseClient, user_id: int) -> None:
      """Desactiva (soft-delete) un usuario en Metabase.

      Endpoint: DELETE /api/user/:id. Requiere permisos de superusuario.
      El usuario no se elimina permanentemente, solo se marca como inactivo.
      Para reactivar: PUT /api/user/:id/reactivate.

      Args:
          client: Cliente autenticado con permisos admin.
          user_id: ID del usuario a desactivar.

      Example:
          >>> metabase_deactivate_user(client, 5)
          >>> # Para ver desactivados: metabase_list_users(client, status="deactivated")
      """
      client.request("DELETE", f"/api/user/{user_id}")

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"user_id","desc":"ID del usuario a obtener"}],"output":"dict: objeto usuario con email, nombre, permisos y metadata"}


id: metabase_list_cards_py_infra
name: metabase_list_cards
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_list_cards(client: MetabaseClient, filter: str = '', model_id: int = 0) -> list[dict]
tags: ["metabase","card","question","list","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-05T16:44:15Z
props: []
emits: []
variant: []
notes: No tiene paginacion offset/limit. Retorna todas las cards que coinciden.
content_hash: 434bcb18668b2ba3b626afc61d6ac620f219e7f15e1aeedb5cd068809364af83
description:
  Lista preguntas/cards de Metabase. Filtros: all, mine, fav, archived, recent, popular, database, table. Endpoint: GET /api/card.

example:
  ```python
  cards = metabase_list_cards(client, filter="mine")
  cards = metabase_list_cards(client, filter="database", model_id=1)
  ```

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

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"filter","desc":"filtro: all, mine, fav, archived, recent, popular, database, table"},{"name":"model_id","desc":"ID de modelo para filtros database/table"}],"output":"list[dict]: lista de cards que coinciden con el filtro"}


id: metabase_list_dashboards_py_infra
name: metabase_list_dashboards
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_list_dashboards(client: MetabaseClient, filter: str = '') -> list[dict]
tags: ["metabase","dashboard","list","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-05T16:44:15Z
props: []
emits: []
variant: []
notes: Para ver cards de un dashboard usar metabase_get_dashboard.
content_hash: 51d77167481682846ff57d69b078c2184a9d85195d1462bbf6fb098bc8d357fb
description:
  Lista dashboards de Metabase. Filtros: all, mine, archived. Retorna resumen sin dashcards. Endpoint: GET /api/dashboard.

example:
  ```python
  dashboards = metabase_list_dashboards(client, filter="mine")
  for d in dashboards:
      print(d["id"], d["name"])
  ```

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

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"filter","desc":"filtro: all, mine, archived"}],"output":"list[dict]: lista de dashboards en resumen (sin dashcards)"}


id: metabase_list_databases_py_infra
name: metabase_list_databases
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_list_databases(client: MetabaseClient, include_tables: bool = False) -> list
tags: ["metabase","database","list","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: 7ad9aac47ea5dd1107690405bf7503f6c76c2a743c6356b89b336fd959e371e0
description:
  Lista las databases configuradas en Metabase. Endpoint: GET /api/database. Soporta incluir tablas con include_tables=True.

example:
  ```python
  dbs = metabase_list_databases(client)
  for db in dbs:
      print(db["id"], db["name"], db["engine"])

  # Con tablas incluidas
  dbs = metabase_list_databases(client, include_tables=True)
  for db in dbs:
      print(db["name"], [t["name"] for t in db.get("tables", [])])
  ```

notes:
  Si include_tables=True agrega el query param `?include=tables` al request.
  Retorna lista directa (no paginada) de todas las databases accesibles.

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":"include_tables","desc":"incluir lista de tablas en cada database"}],"output":"list[dict]: lista de databases configuradas con id, name, engine y opcionalmente tables"}


id: metabase_list_users_py_infra
name: metabase_list_users
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","user","list","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/users.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
notes: Retorna dict paginado con data, total, limit, offset.
content_hash: b2ef0d4b509682bac96b0733575776ff056f2fc9ffbc3ecf44aa55898143955b
signature:
  def metabase_list_users(client: MetabaseClient, status: str = '', query: str = '', limit: int = 0, offset: int = 0) -> dict

description:
  Lista usuarios de Metabase con filtros opcionales por estado, nombre/email y paginacion. Endpoint: GET /api/user.

example:
  ```python
  users = metabase_list_users(client, status="active", query="john@")
  for u in users["data"]:
      print(u["email"], u["first_name"])
  ```

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

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"status","desc":"filtro por estado de usuario (active, deactivated, etc.)"},{"name":"query","desc":"búsqueda por nombre o email"},{"name":"limit","desc":"límite de resultados por página"},{"name":"offset","desc":"desplazamiento para paginación"}],"output":"dict: objeto paginado con data (usuarios), total, limit, offset"}


id: metabase_setup_py_infra
name: metabase_setup
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","setup","api","infra"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/setup.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: 9207215e1c4ae7ba2fbc19fb013f659cce35a3ea51b6fe87f679bb3e60d1455b
signature:
  metabase_setup(base_url: str, admin_email: str, admin_password: str, admin_first_name: str, admin_last_name: str, site_name: str, site_locale: str) -> dict

description:
  Ejecuta el setup inicial de una instancia Metabase nueva via POST /api/setup. Obtiene el setup-token automaticamente y crea el usuario admin con preferencias del sitio.

example:
  ```python
  from metabase.setup import metabase_setup

  result = metabase_setup(
      base_url="http://localhost:3000",
      admin_email="admin@fnregistry.local",
      admin_password="FnRegistry2024!",
      admin_first_name="Lucas",
      admin_last_name="Admin",
      site_name="fn-registry",
      site_locale="es",
  )
  print(result["id"])  # session token
  ```

notes:
  Solo funciona en instancias sin configurar (setup-token disponible). Si Metabase ya tiene un usuario, lanza RuntimeError.

  El setup-token se obtiene automaticamente de GET /api/session/properties. Una vez usado, Metabase invalida el token.

code:
  """Setup inicial de Metabase via API."""

  import httpx


  def metabase_setup(
      base_url: str,
      admin_email: str,
      admin_password: str,
      admin_first_name: str = "Admin",
      admin_last_name: str = "User",
      site_name: str = "Metabase",
      site_locale: str = "en",
  ) -> dict:
      """Ejecuta el setup inicial de una instancia Metabase nueva.

      Usa el setup-token de una instancia sin configurar para crear el
      usuario admin y configurar preferencias del sitio. Solo funciona
      una vez — si Metabase ya tiene un usuario, retorna error.

      Endpoint: POST /api/setup (requiere setup-token valido).

      Args:
          base_url: URL base de la instancia (ej: "http://localhost:3000").
          admin_email: Email del usuario admin a crear.
          admin_password: Password del admin (min 8 chars con complejidad).
          admin_first_name: Nombre del admin.
          admin_last_name: Apellido del admin.
          site_name: Nombre del sitio Metabase.
          site_locale: Locale del sitio (ej: "es", "en").

      Returns:
          Dict con el session id del admin recien creado.

      Raises:
          httpx.HTTPStatusError: 400 si el setup-token es invalido o
              Metabase ya fue configurado.
          RuntimeError: Si no se puede obtener el setup-token.

      Example:
          >>> result = metabase_setup(
          ...     "http://localhost:3000",
          ...     "admin@example.com",
          ...     "SecurePass123!",
          ...     site_name="fn-registry",
          ...     site_locale="es",
          ... )
          >>> print(result["id"])  # session token
      """
      url = base_url.rstrip("/")

      # Obtener setup-token
      resp = httpx.get(f"{url}/api/session/properties")
      resp.raise_for_status()
      props = resp.json()
      setup_token = props.get("setup-token")
      if not setup_token:
          raise RuntimeError(
              "No setup-token disponible. Metabase ya fue configurado "
              "o has-user-setup es True."
          )

      # Ejecutar setup
      resp = httpx.post(
          f"{url}/api/setup",
          json={
              "token": setup_token,
              "user": {
                  "first_name": admin_first_name,
                  "last_name": admin_last_name,
                  "email": admin_email,
                  "password": admin_password,
              },
              "prefs": {
                  "site_name": site_name,
                  "site_locale": site_locale,
                  "allow_tracking": False,
              },
          },
          timeout=30.0,
      )
      resp.raise_for_status()
      return resp.json()

params_schema:
  {"params":[{"name":"base_url","desc":"URL base de Metabase a configurar"},{"name":"admin_email","desc":"correo del usuario administrador a crear"},{"name":"admin_password","desc":"contraseña del administrador"},{"name":"admin_first_name","desc":"nombre del administrador"},{"name":"admin_last_name","desc":"apellido del administrador"},{"name":"site_name","desc":"nombre del sitio Metabase"},{"name":"site_locale","desc":"código de idioma (ej: es, en, fr)"}],"output":"dict: resultado del setup con session token de administrador"}


id: metabase_update_card_py_infra
name: metabase_update_card
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_update_card(client: MetabaseClient, card_id: int, **fields) -> dict
tags: ["metabase","card","question","update","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
example:
  ```python
  metabase_update_card(client, 42, name="New Name", archived=True)
  ```

tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:15Z
props: []
emits: []
variant: []
notes: Soft-delete con `archived=True`. Para delete permanente usar metabase_delete_card.
content_hash: fb650a5c5b802b189108e942ebe48bf4415cf977dcd14511f958f15e002ff3aa
description:
  Actualiza campos de una card/pregunta via kwargs. Campos: name, description, display, dataset_query, collection_id, archived. Endpoint: PUT /api/card/:id.

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

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"card_id","desc":"ID de la card a actualizar"},{"name":"fields","desc":"kwargs con campos a actualizar (name, description, display, dataset_query, collection_id, archived)"}],"output":"dict: objeto card actualizado"}


id: metabase_update_dashboard_py_infra
name: metabase_update_dashboard
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_update_dashboard(client: MetabaseClient, dashboard_id: int, **fields) -> dict
tags: ["metabase","dashboard","update","cards","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-05T16:44:15Z
props: []
emits: []
variant: []
content_hash: d289d265df5ef3ea6bc96582c6f544e1657f2a7cbd65758c8a8bd307d6003077
description:
  Actualiza dashboard incluyendo metadata, cards y tabs via kwargs. dashcards es el estado completo deseado: nuevas con ID negativo, existentes con positivo, omitidas se eliminan. Endpoint: PUT /api/dashboard/:id.

example:
  ```python
  # Agregar card
  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
  metabase_update_dashboard(client, 1, archived=True)
  ```

notes:
  dashcards = estado completo. ID negativo = nueva, positivo = existente, omitida = eliminada.
  Campos: name, description, archived, dashcards, tabs, parameters, collection_id.

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

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"dashboard_id","desc":"ID del dashboard a actualizar"},{"name":"fields","desc":"kwargs con campos a actualizar (name, description, archived, dashcards, tabs, parameters, collection_id)"}],"output":"dict: objeto dashboard actualizado"}


id: metabase_update_user_py_infra
name: metabase_update_user
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_update_user(client: MetabaseClient, user_id: int, **fields) -> dict
tags: ["metabase","user","update","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/users.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:44:26Z
props: []
emits: []
variant: []
notes: Solo se modifican los campos pasados como kwargs.
content_hash: 7c30deba0f07546cc3eb2c6068768243fabc537cf9c9827fa2cc4a3bf8caa12f
description:
  Actualiza campos de un usuario en Metabase via keyword arguments. Campos: first_name, last_name, email, is_superuser, group_ids, locale. Endpoint: PUT /api/user/:id.

example:
  ```python
  metabase_update_user(client, 5, first_name="Jane", is_superuser=True)
  metabase_update_user(client, 5, group_ids=[1, 3, 5])
  ```

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

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"user_id","desc":"ID del usuario a actualizar"},{"name":"fields","desc":"kwargs con campos a actualizar (first_name, last_name, email, is_superuser, group_ids, locale)"}],"output":"dict: objeto usuario actualizado"}


id: jupyter_discover_py_notebook
name: jupyter_discover
kind: function
lang: py
domain: notebook
version: 1.1.0
purity: impure
signature: def jupyter_discover(registry_root: str = "", ports: list[int] | None = None) -> list[dict]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["json","os","urllib.error","urllib.request","pathlib"]
tests: []
file_path: python/functions/notebook/jupyter_discover.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: b3d41f06d086bb40d7d10cb1380a8009ce0b9b78f27f50fa00d03bf7b60e6fa8
description:
  Descubre instancias de Jupyter Lab activas escaneando archivos .jupyter-port en analysis/ y puertos comunes (8888-8892). Detecta el root_dir real de cada instancia via /proc/pid/cmdline (Linux) para identificar correctamente el analisis en escenarios multi-instancia. Para cada instancia consulta /api/status, /api/config, /api/kernels y /api/sessions via HTTP REST.

tags:
  ["jupyter","notebook","discovery","api","http","kernels","sessions","analysis","multi-instance","proc"]

example:
  ```python
  from notebook.jupyter_discover import jupyter_discover

  # Descubrir con deteccion automatica de puertos
  instances = jupyter_discover(registry_root="/home/lucas/fn_registry")

  # Escanear puertos especificos
  instances = jupyter_discover(ports=[8888, 8900])

  for inst in instances:
      print(inst["url"], inst["analysis"], inst["root_dir"], inst["collaborative"])
  # http://localhost:8888 estudio_mercados /home/lucas/fn_registry/analysis/estudio_mercados True
  ```

notes:
  Solo usa stdlib: `urllib`, `json`, `pathlib`, `os`. No requiere `requests` ni clientes Jupyter especializados.

  El escaneo de puertos tiene un timeout de 2 segundos por instancia para no bloquear en puertos cerrados.

  ### Deteccion de root_dir via /proc

  En Linux, `_find_jupyter_pid_for_port()` escanea `/proc/*/cmdline` buscando el proceso Jupyter que tenga `--port=N` o `--ServerApp.port=N` en sus argumentos. Para el puerto default 8888, acepta cualquier proceso jupyter sin `--port` explicito.

  Una vez encontrado el PID, `_get_root_dir_from_proc()` lee los argumentos buscando `--ServerApp.root_dir=` o `--notebook-dir=`. Si ninguno esta presente, usa el cwd del proceso (`/proc/{pid}/cwd`) como fallback.

  La funcion `_extract_analysis_name()` extrae el nombre del analisis del root_dir: si el path contiene `analysis/{nombre}`, retorna `{nombre}`; en caso contrario retorna el ultimo componente del path.

  Esta cadena es mas fiable que confiar solo en `.jupyter-port` porque detecta el directorio real del proceso, no el registrado al arranque.

  ### Prioridad de fuentes para analysis name

  1. root_dir detectado via /proc (mas fiable)
  2. Hint del archivo .jupyter-port (fallback si /proc no esta disponible)
  3. Cadena vacia si ninguno funciona

  ### Modo colaborativo

  La deteccion de modo colaborativo busca `YDocExtension` o `collaborative` en el JSON de `/api/config`. Esto cubre tanto jupyter-collaboration >= 2.x (que expone la extension bajo `LabApp`) como configuraciones antiguas.

  Archivos `.jupyter-port`: el pipeline `init_jupyter_analysis` escribe este archivo en cada analisis al lanzar Jupyter, permitiendo que `jupyter_discover` los encuentre sin escanear todos los puertos.

documentation:
  ## Estructura del dict retornado

  Cada elemento de la lista tiene la siguiente forma:

  ```python
  {
      "url": "http://localhost:8888",
      "port": 8888,
      "analysis": "estudio_mercados",        # nombre del subdirectorio en analysis/, detectado via /proc
      "root_dir": "/home/lucas/fn_registry/analysis/estudio_mercados",  # path absoluto real del proceso
      "collaborative": True,                 # True si YDocExtension esta activo
      "kernels": [
          {
              "id": "abc123...",
              "name": "python3",
              "execution_state": "idle",
              "last_activity": "2026-04-01T10:00:00.000Z"
          }
      ],
      "sessions": [
          {
              "notebook": "notebooks/01_exploracion.ipynb",
              "kernel_id": "abc123...",
              "kernel_state": "idle"
          }
      ]
  }
  ```

  ## CLI

  ```bash
  # Descubrir con deteccion automatica
  python python/functions/notebook/jupyter_discover.py --registry-root /home/lucas/fn_registry

  # Puertos especificos, salida JSON
  python python/functions/notebook/jupyter_discover.py --port 8888 --port 8889 --json

  # Usando variable de entorno
  FN_REGISTRY_ROOT=/home/lucas/fn_registry python python/functions/notebook/jupyter_discover.py
  ```

  Ejemplo de salida en modo texto con multi-instancia:

  ```
  Puerto 8888  [colaborativo]
    url:      http://localhost:8888
    analysis: estudio_mercados
    root_dir: /home/lucas/fn_registry/analysis/estudio_mercados
    kernels (1):
      - python3  estado=idle  id=abc12345...
    sesiones (1):
      - notebooks/01.ipynb  kernel=abc12345...  estado=idle

  Puerto 8889  [estandar]
    url:      http://localhost:8889
    analysis: estudio_embeddings
    root_dir: /home/lucas/fn_registry/analysis/estudio_embeddings
    kernels: ninguno
    sesiones: ninguna
  ```

code:
  """Descubrimiento de instancias Jupyter Lab activas via API REST."""

  import json
  import os
  import urllib.error
  import urllib.request
  from pathlib import Path


  _DEFAULT_PORTS = [8888, 8889, 8890, 8891, 8892]


  def _get(url: str, timeout: float = 2.0) -> dict | list | None:
      """Hace GET a url y retorna el JSON parseado, o None si falla."""
      try:
          with urllib.request.urlopen(url, timeout=timeout) as resp:
              return json.loads(resp.read().decode())
      except Exception:
          return None


  def _is_collaborative(config: dict | list | None) -> bool:
      """Detecta si el servidor tiene jupyter-collaboration/YDocExtension activo."""
      if not isinstance(config, dict):
          return False
      # Jupyter Lab expone la config de extensiones bajo claves como
      # 'LabApp' o similares; la presencia de 'collaborative' o 'YDocExtension'
      # en cualquier valor de primer nivel indica modo colaborativo.
      raw = json.dumps(config).lower()
      return "ydocextension" in raw or "collaborative" in raw


  def _find_jupyter_pid_for_port(port: int) -> int | None:
      """Busca en /proc el PID del proceso jupyter que escucha en el puerto dado.

      Solo funciona en Linux (donde /proc existe). Retorna None si no encuentra
      el proceso o si /proc no esta disponible.
      """
      proc_dir = Path("/proc")
      if not proc_dir.is_dir():
          return None

      for pid_entry in proc_dir.iterdir():
          if not pid_entry.name.isdigit():
              continue
          cmdline_path = pid_entry / "cmdline"
          try:
              raw = cmdline_path.read_bytes().decode("utf-8", errors="replace")
          except OSError:
              continue

          if "jupyter" not in raw:
              continue

          # Los argumentos estan separados por \0 en /proc/pid/cmdline
          parts = raw.split("\0")
          port_found = False
          for part in parts:
              if part in (f"--port={port}", f"--ServerApp.port={port}"):
                  port_found = True
                  break

          # Para el puerto default 8888, el proceso puede no tener --port explícito.
          # En ese caso verificamos que sea un proceso jupyter y no tenga otro puerto.
          if not port_found and port == 8888:
              has_other_port = any(
                  p.startswith("--port=") or p.startswith("--ServerApp.port=")
                  for p in parts
              )
              if not has_other_port and any("jupyter" in p for p in parts):
                  port_found = True

          if port_found:
              try:
                  return int(pid_entry.name)
              except ValueError:
                  continue

      return None


  def _get_root_dir_from_proc(pid: int) -> str:
      """Extrae el root_dir del proceso Jupyter a partir de su cmdline en /proc.

      Busca --ServerApp.root_dir= o --notebook-dir= en los argumentos del proceso.
      Si no los encuentra, usa el cwd del proceso como fallback.
      Retorna cadena vacia si no puede leer /proc.
      """
      try:
          cmdline_path = f"/proc/{pid}/cmdline"
          with open(cmdline_path, "rb") as f:
              parts = f.read().decode("utf-8", errors="replace").split("\0")
          for part in parts:
              if part.startswith("--ServerApp.root_dir="):
                  return part.split("=", 1)[1].rstrip("/")
              if part.startswith("--notebook-dir="):
                  return part.split("=", 1)[1].rstrip("/")
          # Fallback: cwd del proceso
          cwd = os.readlink(f"/proc/{pid}/cwd")
          return cwd.rstrip("/")
      except OSError:
          return ""


  def _extract_analysis_name(root_dir: str) -> str:
      """Extrae el nombre del analisis del root_dir.

      Si root_dir contiene 'analysis/{nombre}', retorna '{nombre}'.
      En caso contrario retorna el ultimo componente del path.
      """
      if not root_dir:
          return ""
      parts = root_dir.replace("\\", "/").split("/")
      # Buscar el segmento 'analysis' y tomar el siguiente
      for i, part in enumerate(parts):
          if part == "analysis" and i + 1 < len(parts) and parts[i + 1]:
              return parts[i + 1]
      # Fallback: ultimo componente del path
      return parts[-1] if parts else ""


  def _query_instance(base_url: str, port: int) -> dict | None:
      """Consulta la API REST de una instancia Jupyter y retorna su estado.

      Detecta el root_dir real del proceso via /proc (Linux) para identificar
      correctamente el analisis que esta sirviendo.

      Retorna None si la instancia no responde o no es Jupyter.
      """
      status = _get(f"{base_url}/api/status")
      if status is None:
          return None

      config = _get(f"{base_url}/api/config")
      kernels_raw = _get(f"{base_url}/api/kernels") or []
      sessions_raw = _get(f"{base_url}/api/sessions") or []

      # Detectar root_dir via /proc
      root_dir = ""
      pid = _find_jupyter_pid_for_port(port)
      if pid is not None:
          root_dir = _get_root_dir_from_proc(pid)

      kernels = []
      if isinstance(kernels_raw, list):
          for k in kernels_raw:
              if isinstance(k, dict):
                  kernels.append({
                      "id": k.get("id", ""),
                      "name": k.get("name", ""),
                      "execution_state": k.get("execution_state", ""),
                      "last_activity": k.get("last_activity", ""),
                  })

      sessions = []
      if isinstance(sessions_raw, list):
          for s in sessions_raw:
              if isinstance(s, dict):
                  kernel = s.get("kernel") or {}
                  path = s.get("path") or s.get("notebook", {}).get("path", "")
                  sessions.append({
                      "notebook": path,
                      "kernel_id": kernel.get("id", ""),
                      "kernel_state": kernel.get("execution_state", ""),
                  })

      return {
          "root_dir": root_dir,
          "kernels": kernels,
          "sessions": sessions,
          "collaborative": _is_collaborative(config),
      }


  def _scan_analysis_ports(registry_root: str) -> list[tuple[int, str]]:
      """Escanea subdirectorios de analysis/ buscando archivos .jupyter-port.

      Retorna lista de (puerto, nombre_analisis).
      """
      root = Path(registry_root) if registry_root else Path.cwd()
      analysis_dir = root / "analysis"
      results: list[tuple[int, str]] = []

      if not analysis_dir.is_dir():
          return results

      for entry in analysis_dir.iterdir():
          if not entry.is_dir():
              continue
          port_file = entry / ".jupyter-port"
          if port_file.is_file():
              try:
                  port = int(port_file.read_text().strip())
                  results.append((port, entry.name))
              except (ValueError, OSError):
                  pass

      return results


  def jupyter_discover(
      registry_root: str = "",
      ports: list[int] | None = None,
  ) -> list[dict]:
      """Descubre instancias de Jupyter Lab activas consultando su API REST.

      Escanea primero los archivos .jupyter-port en subdirectorios de analysis/
      para encontrar puertos registrados, y luego aplica un fallback sobre puertos
      comunes (8888-8892). Para cada instancia que responde consulta /api/status,
      /api/config, /api/kernels y /api/sessions.

      En Linux detecta el root_dir real del proceso Jupyter via /proc/pid/cmdline,
      lo que permite identificar correctamente el analisis en escenarios
      multi-instancia donde varios Jupyter corren en puertos distintos.

      Args:
          registry_root: Raiz del fn_registry. Si vacio usa el directorio actual
                         o la variable de entorno FN_REGISTRY_ROOT.
          ports: Lista de puertos a escanear. Si None, usa los puertos encontrados
                 en .jupyter-port mas los defaults (8888-8892).

      Returns:
          Lista de dicts con: url, port, analysis, root_dir, collaborative,
          kernels, sessions. Cada sesion incluye: notebook, kernel_id,
          kernel_state.
      """
      if not registry_root:
          registry_root = os.environ.get("FN_REGISTRY_ROOT", "")

      # Recopilar puertos a escanear
      port_analysis: dict[int, str] = {}

      if ports is not None:
          for p in ports:
              port_analysis[p] = ""
      else:
          # Primero los registrados en .jupyter-port
          for port, analysis_name in _scan_analysis_ports(registry_root):
              port_analysis[port] = analysis_name
          # Fallback: puertos comunes que no estén ya en la lista
          for p in _DEFAULT_PORTS:
              if p not in port_analysis:
                  port_analysis[p] = ""

      results = []
      for port, analysis_hint in port_analysis.items():
          base_url = f"http://localhost:{port}"
          info = _query_instance(base_url, port)
          if info is None:
              continue

          # Determinar analysis name: preferir deteccion via /proc sobre .jupyter-port
          root_dir = info["root_dir"]
          if root_dir:
              analysis_name = _extract_analysis_name(root_dir)
          else:
              # Fallback al hint del .jupyter-port
              analysis_name = analysis_hint

          results.append({
              "url": base_url,
              "port": port,
              "analysis": analysis_name,
              "root_dir": root_dir,
              "collaborative": info["collaborative"],
              "kernels": info["kernels"],
              "sessions": info["sessions"],
          })

      return results


  # ---------------------------------------------------------------------------
  # CLI
  # ---------------------------------------------------------------------------

  if __name__ == "__main__":
      import argparse
      import sys

      parser = argparse.ArgumentParser(
          description="Descubre instancias de Jupyter Lab activas."
      )
      parser.add_argument(
          "--registry-root",
          default="",
          help="Raiz del fn_registry (default: FN_REGISTRY_ROOT env o cwd)",
      )
      parser.add_argument(
          "--port",
          dest="ports",
          type=int,
          action="append",
          metavar="PORT",
          help="Puerto a escanear (puede repetirse). Default: .jupyter-port + 8888-8892",
      )
      parser.add_argument(
          "--json",
          action="store_true",
          help="Emitir salida en JSON",
      )
      args = parser.parse_args()

      instances = jupyter_discover(
          registry_root=args.registry_root,
          ports=args.ports,
      )

      if args.json:
          print(json.dumps(instances, indent=2))
          sys.exit(0)

      if not instances:
          print("No se encontraron instancias de Jupyter Lab activas.")
          sys.exit(0)

      for inst in instances:
          collab = "colaborativo" if inst["collaborative"] else "estandar"
          analysis = inst["analysis"] or "(desconocido)"
          root_dir = inst["root_dir"] or "(no detectado)"
          print(f"Puerto {inst['port']}  [{collab}]")
          print(f"  url:      {inst['url']}")
          print(f"  analysis: {analysis}")
          print(f"  root_dir: {root_dir}")
          kernel_count = len(inst["kernels"])
          if inst["kernels"]:
              print(f"  kernels ({kernel_count}):")
              for k in inst["kernels"]:
                  print(f"    - {k['name']}  estado={k['execution_state']}  id={k['id'][:8]}...")
          else:
              print("  kernels: ninguno")
          if inst["sessions"]:
              print(f"  sesiones ({len(inst['sessions'])}):")
              for s in inst["sessions"]:
                  kid = s["kernel_id"][:8] + "..." if s["kernel_id"] else "(sin kernel)"
                  print(f"    - {s['notebook']}  kernel={kid}  estado={s['kernel_state']}")
          else:
              print("  sesiones: ninguna")
          print()

params_schema:
  {"params":[{"name":"registry_root","desc":"Raíz del registry para detectar análisis (opcional, usa FN_REGISTRY_ROOT si no se proporciona)"},{"name":"ports","desc":"Puertos a escanear (default: 8888-8892, detecta automáticamente)"}],"output":"Lista de dicts con información de instancias Jupyter: url, port, analysis, root_dir, collaborative, kernels, sessions"}


id: jupyter_exec_py_notebook
name: jupyter_exec
kind: function
lang: py
domain: notebook
version: 1.0.0
purity: impure
signature: jupyter_append_execute(notebook_path: str, code: str, server_url: str, token: str) -> dict
tags: ["jupyter","notebook","kernel","websocket","execution","cells"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["jupyter_kernel_client","jupyter_nbmodel_client"]
tests: []
file_path: python/functions/notebook/jupyter_exec.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-07T20:58:02Z
props: []
emits: []
variant: []
content_hash: 4b0217f5f6d1a505120c67f69ed8b294007cd11808dc043719d579479c1b1c15
description:
  Ejecuta codigo en kernels de Jupyter via WebSocket. Tres modos: append (añade celda al notebook y la ejecuta), cell (ejecuta celda existente por indice), kernel (ejecuta en el kernel sin tocar ningun notebook).

notes:
  - Las funciones `append` y `cell` son async internamente; las publicas usan `asyncio.run()`.
  - `jupyter_kernel_execute` es sincrona directamente porque `KernelClient.execute` es bloqueante.
  - El token puede ser cadena vacia si el servidor tiene autenticacion deshabilitada.
  - `NbModelClient` requiere que el servidor tenga habilitado el endpoint colaborativo (`/api/collaboration/`), disponible en JupyterLab >= 4 con `jupyter-collaboration` instalado.
  - **Auto-init**: `jupyter_append_execute` crea el notebook automaticamente si no existe (via REST PUT /api/contents) y arranca una sesion con kernel si no hay ninguna activa para ese notebook (via POST /api/sessions). No es necesario abrir el notebook manualmente en el navegador.
  - **Auto-session**: `jupyter_execute_cell` tambien garantiza que exista una sesion con kernel antes de ejecutar.
  - **Fix Issue 006**: `jupyter_execute_cell` normaliza la celda antes de ejecutar. Las celdas creadas manualmente (no via la UI de Jupyter) pueden carecer de `outputs` o `execution_count` en el modelo CRDT, lo que causaba `KeyError: 'outputs'` dentro de `execute_cell` al hacer `del ycell["outputs"][:]`. El fix lee la celda con `nb[cell_index]`, detecta los campos faltantes, y reemplaza la celda via `nb[cell_index] = _normalize_code_cell(cell)` — que usa `set_cell` internamente para re-crear el mapa CRDT completo preservando el source original.

documentation:
  ## Funciones

  ### `jupyter_append_execute(notebook_path, code, server_url, token)`

  Añade una celda de codigo al final del notebook y la ejecuta. Usa el protocolo
  colaborativo de Jupyter, por lo que tanto el agente como el usuario ven la celda
  y su output en tiempo real en JupyterLab.

  ```python
  from notebook.jupyter_exec import jupyter_append_execute

  result = jupyter_append_execute(
      "notebooks/analisis.ipynb",
      "import pandas as pd\nprint(pd.__version__)",
      server_url="http://localhost:8888",
      token="",
  )
  # {"cell_index": 5, "outputs": ["2.2.1"]}
  ```

  ### `jupyter_execute_cell(notebook_path, cell_index, server_url, token)`

  Ejecuta una celda existente del notebook por su indice (0-based).

  ```python
  from notebook.jupyter_exec import jupyter_execute_cell

  result = jupyter_execute_cell("notebooks/analisis.ipynb", 3)
  # {"cell_index": 3, "outputs": ["42"]}
  ```

  ### `jupyter_kernel_execute(code, server_url, token)`

  Ejecuta codigo directamente en el kernel sin modificar ningun notebook. Util para
  consultas rapidas, inspeccion de variables o verificacion de estado del kernel.

  ```python
  from notebook.jupyter_exec import jupyter_kernel_execute

  result = jupyter_kernel_execute("len(df)")
  # {"outputs": ["1500"], "status": "ok"}
  ```

  ## CLI

  ```bash
  # Añadir celda y ejecutar
  python -m notebook.jupyter_exec append notebooks/mi.ipynb "print('hola')" --server http://localhost:8888 --token mytoken

  # Ejecutar celda existente
  python -m notebook.jupyter_exec cell notebooks/mi.ipynb 2 --server http://localhost:8888

  # Ejecutar en kernel directamente
  python -m notebook.jupyter_exec kernel "x = 42; print(x)"
  ```

  Output siempre JSON. En error retorna `{"error": "..."}` por stderr con exit code 1.

  ## Extraccion de outputs

  | output_type | campo leido |
  |---|---|
  | stream | `text` |
  | display_data / execute_result | `data.text/plain` |
  | error | `traceback` (joined con `\n`) |

code:
  """Ejecuta codigo en kernels de Jupyter via WebSocket.

  Tres modos de ejecucion:
  - append: añade una celda al final del notebook y la ejecuta
  - cell: ejecuta una celda existente por indice
  - kernel: ejecuta codigo directamente en el kernel sin modificar ningun notebook
  """

  import asyncio
  import json
  from functools import partial
  from typing import Any
  from urllib.error import HTTPError, URLError
  from urllib.request import Request, urlopen

  from jupyter_kernel_client import KernelClient
  from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url
  from nbformat import NotebookNode


  # ---------------------------------------------------------------------------
  # Helpers internos
  # ---------------------------------------------------------------------------


  def _notebook_exists(notebook_path: str, server_url: str, token: str) -> bool:
      """Comprueba si un notebook existe en el servidor Jupyter via HEAD /api/contents."""
      headers = {"Accept": "application/json"}
      if token:
          headers["Authorization"] = f"token {token}"
      check_url = f"{server_url}/api/contents/{notebook_path}"
      req = Request(check_url, headers=headers, method="HEAD")
      try:
          with urlopen(req, timeout=5):
              return True
      except HTTPError as e:
          if e.code == 404:
              return False
          raise


  def _create_notebook(notebook_path: str, server_url: str, token: str, kernel_name: str = "python3") -> None:
      """Crea un notebook vacio via PUT /api/contents si no existe."""
      if _notebook_exists(notebook_path, server_url, token):
          return
      headers = {
          "Content-Type": "application/json",
          "Accept": "application/json",
      }
      if token:
          headers["Authorization"] = f"token {token}"
      kernel_display = {"python3": "Python 3 (ipykernel)", "python": "Python 3"}.get(kernel_name, kernel_name)
      notebook_content = {
          "nbformat": 4,
          "nbformat_minor": 5,
          "metadata": {
              "kernelspec": {"name": kernel_name, "display_name": kernel_display, "language": "python"},
              "language_info": {"name": "python"},
          },
          "cells": [],
      }
      body = json.dumps({"type": "notebook", "content": notebook_content}).encode("utf-8")
      url = f"{server_url}/api/contents/{notebook_path}"
      req = Request(url, data=body, headers=headers, method="PUT")
      with urlopen(req, timeout=10) as resp:
          resp.read()


  def _ensure_session(server_url: str, token: str, notebook_path: str, kernel_name: str = "python3") -> str:
      """Garantiza que exista una sesion para el notebook. Retorna el kernel_id.

      Si ya hay una sesion activa, retorna su kernel_id. Si no, crea una nueva
      via POST /api/sessions (lo cual tambien arranca un kernel).
      """
      kernel_id = _resolve_kernel_id(server_url, token, notebook_path)
      if kernel_id:
          return kernel_id

      headers = {
          "Accept": "application/json",
          "Content-Type": "application/json",
      }
      if token:
          headers["Authorization"] = f"token {token}"

      body = json.dumps({
          "path": notebook_path,
          "type": "notebook",
          "kernel": {"name": kernel_name},
      }).encode("utf-8")

      url = f"{server_url}/api/sessions"
      req = Request(url, data=body, headers=headers, method="POST")
      with urlopen(req, timeout=10) as resp:
          session = json.loads(resp.read())

      return session.get("kernel", {}).get("id", "")


  def _api_get(url: str, token: str = "") -> dict | list | None:
      """GET a Jupyter REST API endpoint."""
      headers = {"Accept": "application/json"}
      if token:
          headers["Authorization"] = f"token {token}"
      try:
          req = Request(url, headers=headers)
          with urlopen(req, timeout=5) as resp:
              return json.loads(resp.read())
      except (URLError, OSError, json.JSONDecodeError):
          return None


  def _resolve_kernel_id(server_url: str, token: str, notebook_path: str) -> str | None:
      """Find the kernel_id associated with a notebook via the sessions API."""
      sessions = _api_get(f"{server_url}/api/sessions", token) or []
      for session in sessions:
          nb = session.get("notebook", session.get("path", {}))
          nb_path = nb.get("path", nb) if isinstance(nb, dict) else str(nb)
          if nb_path == notebook_path:
              kernel = session.get("kernel", {})
              return kernel.get("id")
      return None


  def _resolve_collab_username(server_url: str, token: str) -> str:
      """Resolve the display name of the active user in Jupyter collaboration.

      Queries /api/me to get the identity Jupyter assigned to the browser user.
      Falls back to 'Anonymous' if unavailable.
      """
      me = _api_get(f"{server_url}/api/me", token)
      if me:
          identity = me.get("identity", {})
          return identity.get("display_name", "") or identity.get("username", "") or identity.get("name", "Anonymous")
      return "Anonymous"


  def _normalize_code_cell(cell: NotebookNode) -> dict:
      """Devuelve un dict de celda de codigo con todos los campos requeridos por nbformat.

      Celdas creadas manualmente (no via Jupyter UI) pueden omitir 'outputs' o
      'execution_count'. El modelo CRDT de jupyter_nbmodel_client accede a estos
      campos sin comprobar su existencia, produciendo KeyError al ejecutar.
      Este helper garantiza que el dict tenga la estructura completa.
      """
      return {
          "id": cell.get("id", ""),
          "cell_type": "code",
          "metadata": cell.get("metadata", {}),
          "source": cell.get("source", ""),
          "outputs": cell.get("outputs", []),
          "execution_count": cell.get("execution_count", None),
      }


  def _extract_outputs(raw_outputs: list[dict]) -> list[str]:
      """Convierte outputs de nbformat a lista de strings legibles."""
      result: list[str] = []
      for output in raw_outputs:
          output_type = output.get("output_type", "")
          if output_type == "stream":
              text = output.get("text", "")
              if isinstance(text, list):
                  text = "".join(text)
              result.append(text.rstrip("\n"))
          elif output_type in ("display_data", "execute_result"):
              data = output.get("data", {})
              text = data.get("text/plain", "")
              if isinstance(text, list):
                  text = "".join(text)
              result.append(text.rstrip("\n"))
          elif output_type == "error":
              traceback = output.get("traceback", [])
              result.append("\n".join(traceback))
      return result


  # ---------------------------------------------------------------------------
  # Modo append (async interno)
  # ---------------------------------------------------------------------------


  async def _async_append_execute(
      notebook_path: str,
      code: str,
      server_url: str,
      token: str,
  ) -> dict[str, Any]:
      _create_notebook(notebook_path, server_url, token)
      kernel_id = _ensure_session(server_url, token, notebook_path)

      ws_url = get_jupyter_notebook_websocket_url(
          server_url,
          notebook_path,
          token or None,
      )
      username = _resolve_collab_username(server_url, token)

      async with NbModelClient(ws_url, username=username) as nb:
          await nb.wait_until_synced()

          with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
              cell_index = nb.add_code_cell(code)
              loop = asyncio.get_event_loop()
              result = await loop.run_in_executor(
                  None, partial(nb.execute_cell, cell_index, kernel),
              )

          # Let Y.js propagate changes to other clients (browser)
          await asyncio.sleep(2)

      outputs = _extract_outputs(result.get("outputs", []))
      return {"cell_index": cell_index, "outputs": outputs}


  # ---------------------------------------------------------------------------
  # Modo cell (async interno)
  # ---------------------------------------------------------------------------


  async def _async_execute_cell(
      notebook_path: str,
      cell_index: int,
      server_url: str,
      token: str,
  ) -> dict[str, Any]:
      kernel_id = _ensure_session(server_url, token, notebook_path)

      ws_url = get_jupyter_notebook_websocket_url(
          server_url,
          notebook_path,
          token or None,
      )
      username = _resolve_collab_username(server_url, token)

      async with NbModelClient(ws_url, username=username) as nb:
          await nb.wait_until_synced()

          # Normalizar la celda antes de ejecutar. Las celdas creadas manualmente
          # (sin pasar por la UI de Jupyter) pueden carecer de los campos 'outputs'
          # o 'execution_count' en el modelo CRDT, lo que provoca KeyError dentro
          # de execute_cell al intentar hacer `del ycell["outputs"][:]`.
          # Reemplazar la celda via __setitem__ fuerza la re-creacion completa del
          # mapa CRDT con todos los campos requeridos por nbformat.
          cell = nb[cell_index]
          if cell.get("cell_type") == "code" and (
              "outputs" not in cell or "execution_count" not in cell
          ):
              nb[cell_index] = _normalize_code_cell(cell)

          with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
              loop = asyncio.get_event_loop()
              result = await loop.run_in_executor(
                  None, partial(nb.execute_cell, cell_index, kernel),
              )

          await asyncio.sleep(2)

      outputs = _extract_outputs(result.get("outputs", []))
      return {"cell_index": cell_index, "outputs": outputs}


  # ---------------------------------------------------------------------------
  # API publica
  # ---------------------------------------------------------------------------


  def jupyter_append_execute(
      notebook_path: str,
      code: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict[str, Any]:
      """Añade una celda de codigo al final del notebook y la ejecuta.

      Tanto el agente como el usuario ven la celda y su output en tiempo real
      porque la escritura se realiza a traves del protocolo colaborativo de Jupyter.

      Args:
          notebook_path: Ruta al notebook relativa a la raiz del servidor Jupyter.
          code: Codigo Python a insertar y ejecutar.
          server_url: URL del servidor Jupyter; por defecto http://localhost:8888.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con 'cell_index' (indice de la nueva celda) y 'outputs' (lista de strings).

      Raises:
          Exception: si no se puede conectar al servidor o al kernel.
      """
      return asyncio.run(_async_append_execute(notebook_path, code, server_url, token))


  def jupyter_execute_cell(
      notebook_path: str,
      cell_index: int,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict[str, Any]:
      """Ejecuta una celda existente del notebook por indice.

      Args:
          notebook_path: Ruta al notebook relativa a la raiz del servidor Jupyter.
          cell_index: Indice de la celda a ejecutar (0-based).
          server_url: URL del servidor Jupyter; por defecto http://localhost:8888.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con 'cell_index' y 'outputs' (lista de strings).

      Raises:
          IndexError: si cell_index esta fuera de rango.
          Exception: si no se puede conectar al servidor o al kernel.
      """
      return asyncio.run(_async_execute_cell(notebook_path, cell_index, server_url, token))


  def jupyter_kernel_execute(
      code: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict[str, Any]:
      """Ejecuta codigo directamente en el kernel sin modificar ningun notebook.

      Util para consultas rapidas, inspeccion de variables, comprobaciones de estado.

      Args:
          code: Codigo Python a ejecutar en el kernel activo.
          server_url: URL del servidor Jupyter; por defecto http://localhost:8888.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con 'outputs' (lista de strings) y 'status' ('ok' o 'error').

      Raises:
          Exception: si no se puede conectar al servidor o al kernel.
      """
      with KernelClient(server_url=server_url, token=token) as kernel:
          result = kernel.execute(code)

      outputs = _extract_outputs(result.get("outputs", []))
      return {"outputs": outputs, "status": result.get("status", "unknown")}


  # ---------------------------------------------------------------------------
  # CLI
  # ---------------------------------------------------------------------------

  if __name__ == "__main__":
      import argparse
      import sys

      parser = argparse.ArgumentParser(
          description="Ejecuta codigo en kernels de Jupyter",
      )
      sub = parser.add_subparsers(dest="command", required=True)

      # append
      p_append = sub.add_parser("append", help="Añade celda al notebook y la ejecuta")
      p_append.add_argument("notebook", help="Ruta al notebook relativa al servidor")
      p_append.add_argument("code", help="Codigo a insertar y ejecutar")
      p_append.add_argument("--server", default="http://localhost:8888")
      p_append.add_argument("--token", default="")

      # cell
      p_cell = sub.add_parser("cell", help="Ejecuta celda existente por indice")
      p_cell.add_argument("notebook", help="Ruta al notebook relativa al servidor")
      p_cell.add_argument("index", type=int, help="Indice de la celda (0-based)")
      p_cell.add_argument("--server", default="http://localhost:8888")
      p_cell.add_argument("--token", default="")

      # kernel
      p_kernel = sub.add_parser("kernel", help="Ejecuta codigo en el kernel sin tocar notebook")
      p_kernel.add_argument("code", help="Codigo a ejecutar")
      p_kernel.add_argument("--server", default="http://localhost:8888")
      p_kernel.add_argument("--token", default="")

      args = parser.parse_args()

      try:
          if args.command == "append":
              result = jupyter_append_execute(args.notebook, args.code, args.server, args.token)
          elif args.command == "cell":
              result = jupyter_execute_cell(args.notebook, args.index, args.server, args.token)
          elif args.command == "kernel":
              result = jupyter_kernel_execute(args.code, args.server, args.token)
          else:
              parser.print_help()
              sys.exit(1)

          print(json.dumps(result, ensure_ascii=False, indent=2))
      except Exception as exc:
          print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
          sys.exit(1)

params_schema:
  {"params":[{"name":"notebook_path","desc":"Ruta relativa al notebook"},{"name":"code","desc":"Código a ejecutar"},{"name":"server_url","desc":"URL del servidor Jupyter (default localhost:8888)"},{"name":"token","desc":"Token de autenticación (default vacío)"}],"output":"Dict con cell_index y outputs del código ejecutado, o resultados del kernel"}


id: jupyter_kernel_py_notebook
name: jupyter_kernel
kind: function
lang: py
domain: notebook
version: 1.0.0
purity: impure
signature: def jupyter_kernel_list(server_url: str = "http://localhost:8888", token: str = "") -> list[dict]
tags: ["jupyter","notebook","kernel","api","http","rest","sessions","crud","cleanup"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["json","urllib.error","urllib.request"]
tests: []
file_path: python/functions/notebook/jupyter_kernel.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-07T20:58:02Z
props: []
emits: []
variant: []
content_hash: 0342f5112c540bc035f6173a4f95b5ce1568d762838bf1be0cd67d00cec46546
description:
  CRUD completo de kernels Jupyter via REST API. Expone ocho operaciones: list, start, restart, interrupt, shutdown, sessions, cleanup y shutdown-all. Usa solo stdlib (urllib, json), sin dependencias externas.

example:
  ```python
  from notebook.jupyter_kernel import (
      jupyter_kernel_list,
      jupyter_kernel_start,
      jupyter_kernel_restart,
      jupyter_kernel_interrupt,
      jupyter_kernel_shutdown,
      jupyter_kernel_sessions,
  )

  # Listar kernels activos
  kernels = jupyter_kernel_list("http://localhost:8888", token="mi_token")
  # [{"id": "abc123", "name": "python3", "execution_state": "idle",
  #   "last_activity": "2026-04-01T10:00:00.000Z", "connections": 1}]

  # Iniciar un kernel nuevo
  kernel = jupyter_kernel_start("http://localhost:8888", token="mi_token", name="python3")
  # {"id": "def456", "name": "python3", "execution_state": "starting"}

  # Reiniciar
  jupyter_kernel_restart("http://localhost:8888", "mi_token", kernel["id"])

  # Interrumpir ejecucion en curso
  jupyter_kernel_interrupt("http://localhost:8888", "mi_token", kernel["id"])

  # Apagar
  jupyter_kernel_shutdown("http://localhost:8888", "mi_token", kernel["id"])

  # Listar sesiones (mapeo notebook <-> kernel)
  sessions = jupyter_kernel_sessions("http://localhost:8888", token="mi_token")
  # [{"id": "s1", "notebook": "notebooks/01_analisis.ipynb",
  #   "kernel_id": "abc123", "kernel_state": "idle", "type": "notebook", "name": "01_analisis"}]
  ```

notes:
  Solo usa stdlib: `urllib.request`, `urllib.error`, `json`. No requiere `requests`, `jupyter_client` ni ningun paquete externo.

  La funcion interna `_make_request` centraliza la construccion de headers y serializacion JSON. El header `Authorization: token {token}` se omite si `token` esta vacio, permitiendo conectar a servidores sin autenticacion.

  `jupyter_kernel_interrupt` y `jupyter_kernel_shutdown` retornan `None` porque la API de Jupyter devuelve 204 No Content en esos casos.

  Compatible con Jupyter Lab 3.x, 4.x y Jupyter Notebook 6.x/7.x — todos exponen la misma REST API en `/api/kernels` y `/api/sessions`.

documentation:
  ## Funciones expuestas

  | Funcion | Endpoint | Descripcion |
  |---------|----------|-------------|
  | `jupyter_kernel_list(server_url, token)` | `GET /api/kernels` | Lista kernels activos |
  | `jupyter_kernel_start(server_url, token, name)` | `POST /api/kernels` | Inicia un kernel nuevo |
  | `jupyter_kernel_restart(server_url, token, kernel_id)` | `POST /api/kernels/{id}/restart` | Reinicia un kernel |
  | `jupyter_kernel_interrupt(server_url, token, kernel_id)` | `POST /api/kernels/{id}/interrupt` | Interrumpe ejecucion |
  | `jupyter_kernel_shutdown(server_url, token, kernel_id)` | `DELETE /api/kernels/{id}` | Apaga y elimina un kernel |
  | `jupyter_kernel_sessions(server_url, token)` | `GET /api/sessions` | Lista sesiones activas |
  | `jupyter_kernel_cleanup(server_url, token, idle_seconds)` | `GET + DELETE` | Apaga kernels inactivos |
  | `jupyter_kernel_shutdown_all(server_url, token)` | `GET + DELETE` | Apaga todos los kernels |

  ## CLI

  ```bash
  # Listar kernels
  python python/functions/notebook/jupyter_kernel.py list --server http://localhost:8888 --token TK

  # Iniciar kernel (por defecto python3)
  python python/functions/notebook/jupyter_kernel.py start --name python3

  # Reiniciar
  python python/functions/notebook/jupyter_kernel.py restart abc123-...

  # Interrumpir
  python python/functions/notebook/jupyter_kernel.py interrupt abc123-...

  # Apagar
  python python/functions/notebook/jupyter_kernel.py shutdown abc123-...

  # Listar sesiones
  python python/functions/notebook/jupyter_kernel.py sessions

  # Limpiar kernels inactivos (default: 1h sin actividad)
  python python/functions/notebook/jupyter_kernel.py cleanup --idle-seconds 1800

  # Apagar todos los kernels
  python python/functions/notebook/jupyter_kernel.py shutdown-all
  ```

  Todos los subcomandos aceptan `--server` y `--token`. El output es siempre JSON.

  ## Estructura de los dicts retornados

  **`jupyter_kernel_list`** — cada elemento:
  ```python
  {
      "id": "abc123-...",
      "name": "python3",
      "execution_state": "idle",   # idle | busy | starting
      "last_activity": "2026-04-01T10:00:00.000Z",
      "connections": 1
  }
  ```

  **`jupyter_kernel_start`** — elemento retornado:
  ```python
  {
      "id": "def456-...",
      "name": "python3",
      "execution_state": "starting"
  }
  ```

  **`jupyter_kernel_sessions`** — cada elemento:
  ```python
  {
      "id": "session-id",
      "notebook": "notebooks/01_analisis.ipynb",  # path relativo al root del servidor
      "kernel_id": "abc123-...",
      "kernel_state": "idle",
      "type": "notebook",
      "name": "01_analisis"
  }
  ```

code:
  """CRUD de kernels Jupyter via REST API."""

  import json
  import urllib.error
  import urllib.request


  def _make_request(
      method: str,
      url: str,
      token: str = "",
      body: dict | None = None,
  ) -> dict | list | None:
      """Ejecuta una request HTTP a la API de Jupyter.

      Args:
          method: Metodo HTTP (GET, POST, DELETE).
          url: URL completa del endpoint.
          token: Token de autenticacion de Jupyter. Vacio si no se requiere.
          body: Cuerpo de la request para metodos POST.

      Returns:
          Respuesta deserializada como dict o list, o None si la respuesta esta vacia.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error.
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      data = json.dumps(body).encode("utf-8") if body is not None else None

      headers = {
          "Accept": "application/json",
          "Content-Type": "application/json",
      }
      if token:
          headers["Authorization"] = f"token {token}"

      req = urllib.request.Request(url, data=data, headers=headers, method=method)

      with urllib.request.urlopen(req) as resp:
          raw = resp.read()
          if not raw:
              return None
          return json.loads(raw.decode("utf-8"))


  def jupyter_kernel_list(
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> list[dict]:
      """Lista todos los kernels activos en el servidor Jupyter.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.

      Returns:
          Lista de dicts con los campos: id, name, execution_state,
          last_activity, connections.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error.
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      url = f"{server_url.rstrip('/')}/api/kernels"
      result = _make_request("GET", url, token)
      return result if isinstance(result, list) else []


  def jupyter_kernel_start(
      server_url: str = "http://localhost:8888",
      token: str = "",
      name: str = "python3",
  ) -> dict:
      """Inicia un kernel nuevo en el servidor Jupyter.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.
          name: Nombre del kernel a iniciar (p.ej. "python3", "ir").

      Returns:
          Dict con los campos: id, name, execution_state.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error.
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      url = f"{server_url.rstrip('/')}/api/kernels"
      result = _make_request("POST", url, token, body={"name": name})
      return result if isinstance(result, dict) else {}


  def jupyter_kernel_restart(
      server_url: str = "http://localhost:8888",
      token: str = "",
      kernel_id: str = "",
  ) -> dict:
      """Reinicia un kernel existente.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.
          kernel_id: ID del kernel a reiniciar.

      Returns:
          Dict con la informacion actualizada del kernel.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error (p.ej. 404 si no existe).
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      url = f"{server_url.rstrip('/')}/api/kernels/{kernel_id}/restart"
      result = _make_request("POST", url, token)
      return result if isinstance(result, dict) else {}


  def jupyter_kernel_interrupt(
      server_url: str = "http://localhost:8888",
      token: str = "",
      kernel_id: str = "",
  ) -> None:
      """Interrumpe la ejecucion actual de un kernel.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.
          kernel_id: ID del kernel a interrumpir.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error (p.ej. 404 si no existe).
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      url = f"{server_url.rstrip('/')}/api/kernels/{kernel_id}/interrupt"
      _make_request("POST", url, token)


  def jupyter_kernel_shutdown(
      server_url: str = "http://localhost:8888",
      token: str = "",
      kernel_id: str = "",
  ) -> None:
      """Apaga y elimina un kernel.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.
          kernel_id: ID del kernel a apagar.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error (p.ej. 404 si no existe).
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      url = f"{server_url.rstrip('/')}/api/kernels/{kernel_id}"
      _make_request("DELETE", url, token)


  def jupyter_kernel_sessions(
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> list[dict]:
      """Lista las sesiones activas del servidor Jupyter.

      Cada sesion mapea un notebook a su kernel y usuario actuales.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.

      Returns:
          Lista de dicts con los campos: id, notebook (path), kernel_id,
          kernel_state, type, name.

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error.
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      url = f"{server_url.rstrip('/')}/api/sessions"
      raw = _make_request("GET", url, token)
      if not isinstance(raw, list):
          return []

      sessions = []
      for s in raw:
          kernel = s.get("kernel") or {}
          sessions.append(
              {
                  "id": s.get("id", ""),
                  "notebook": s.get("path", ""),
                  "kernel_id": kernel.get("id", ""),
                  "kernel_state": kernel.get("execution_state", ""),
                  "type": s.get("type", ""),
                  "name": s.get("name", ""),
              }
          )
      return sessions


  def jupyter_kernel_cleanup(
      server_url: str = "http://localhost:8888",
      token: str = "",
      idle_seconds: int = 3600,
  ) -> list[dict]:
      """Apaga todos los kernels que llevan mas de idle_seconds sin actividad.

      Util para liberar recursos en servidores con muchos notebooks abiertos.
      Por defecto cierra kernels inactivos desde hace mas de 1 hora.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.
          idle_seconds: Segundos de inactividad para considerar un kernel ocioso.

      Returns:
          Lista de dicts con los kernels apagados (id, name, last_activity, idle_seconds).

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error.
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      from datetime import datetime, timezone

      kernels = jupyter_kernel_list(server_url, token)
      now = datetime.now(timezone.utc)
      shutdown_list = []

      for k in kernels:
          last_activity = k.get("last_activity", "")
          if not last_activity:
              continue
          try:
              last_dt = datetime.fromisoformat(last_activity.replace("Z", "+00:00"))
              idle = (now - last_dt).total_seconds()
          except (ValueError, TypeError):
              continue
          if idle >= idle_seconds:
              jupyter_kernel_shutdown(server_url, token, k["id"])
              shutdown_list.append({
                  "id": k["id"],
                  "name": k.get("name", ""),
                  "last_activity": last_activity,
                  "idle_seconds": int(idle),
              })

      return shutdown_list


  def jupyter_kernel_shutdown_all(
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> list[dict]:
      """Apaga todos los kernels activos del servidor.

      Args:
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion. Vacio si el servidor no requiere auth.

      Returns:
          Lista de dicts con los kernels apagados (id, name).

      Raises:
          urllib.error.HTTPError: Si la respuesta HTTP indica un error.
          urllib.error.URLError: Si no se puede conectar al servidor.
      """
      kernels = jupyter_kernel_list(server_url, token)
      shutdown_list = []
      for k in kernels:
          jupyter_kernel_shutdown(server_url, token, k["id"])
          shutdown_list.append({"id": k["id"], "name": k.get("name", "")})
      return shutdown_list


  # ---------------------------------------------------------------------------
  # CLI
  # ---------------------------------------------------------------------------

  if __name__ == "__main__":
      import argparse
      import sys

      parser = argparse.ArgumentParser(
          description="CRUD de kernels Jupyter via REST API."
      )
      parser.add_argument(
          "--server",
          default="http://localhost:8888",
          help="URL base del servidor Jupyter (default: http://localhost:8888)",
      )
      parser.add_argument(
          "--token",
          default="",
          help="Token de autenticacion de Jupyter (default: vacio)",
      )

      subparsers = parser.add_subparsers(dest="command", required=True)

      # list
      subparsers.add_parser("list", help="Lista todos los kernels activos.")

      # start
      sp_start = subparsers.add_parser("start", help="Inicia un kernel nuevo.")
      sp_start.add_argument(
          "--name",
          default="python3",
          help="Nombre del kernel (default: python3)",
      )

      # restart
      sp_restart = subparsers.add_parser("restart", help="Reinicia un kernel existente.")
      sp_restart.add_argument("kernel_id", help="ID del kernel a reiniciar.")

      # interrupt
      sp_interrupt = subparsers.add_parser(
          "interrupt", help="Interrumpe la ejecucion de un kernel."
      )
      sp_interrupt.add_argument("kernel_id", help="ID del kernel a interrumpir.")

      # shutdown
      sp_shutdown = subparsers.add_parser("shutdown", help="Apaga y elimina un kernel.")
      sp_shutdown.add_argument("kernel_id", help="ID del kernel a apagar.")

      # sessions
      subparsers.add_parser("sessions", help="Lista las sesiones activas.")

      # cleanup
      sp_cleanup = subparsers.add_parser("cleanup", help="Apaga kernels inactivos.")
      sp_cleanup.add_argument(
          "--idle-seconds",
          type=int,
          default=3600,
          help="Segundos de inactividad para considerar ocioso (default: 3600)",
      )

      # shutdown-all
      subparsers.add_parser("shutdown-all", help="Apaga todos los kernels activos.")

      args = parser.parse_args()

      try:
          if args.command == "list":
              result = jupyter_kernel_list(args.server, args.token)
          elif args.command == "start":
              result = jupyter_kernel_start(args.server, args.token, args.name)
          elif args.command == "restart":
              result = jupyter_kernel_restart(args.server, args.token, args.kernel_id)
              if result is None:
                  result = {"status": "restarted", "kernel_id": args.kernel_id}
          elif args.command == "interrupt":
              jupyter_kernel_interrupt(args.server, args.token, args.kernel_id)
              result = {"status": "interrupted", "kernel_id": args.kernel_id}
          elif args.command == "shutdown":
              jupyter_kernel_shutdown(args.server, args.token, args.kernel_id)
              result = {"status": "shutdown", "kernel_id": args.kernel_id}
          elif args.command == "sessions":
              result = jupyter_kernel_sessions(args.server, args.token)
          elif args.command == "cleanup":
              result = jupyter_kernel_cleanup(args.server, args.token, args.idle_seconds)
          elif args.command == "shutdown-all":
              result = jupyter_kernel_shutdown_all(args.server, args.token)
          else:
              parser.print_help()
              sys.exit(1)

          print(json.dumps(result, indent=2))
      except urllib.error.HTTPError as e:
          body = e.read().decode("utf-8", errors="replace")
          print(
              json.dumps({"error": f"HTTP {e.code}: {e.reason}", "detail": body}),
              file=sys.stderr,
          )
          sys.exit(1)
      except urllib.error.URLError as e:
          print(
              json.dumps({"error": f"URLError: {e.reason}"}),
              file=sys.stderr,
          )
          sys.exit(1)

params_schema:
  {"output":"Múltiples funciones para CRUD de kernels: list, start, restart, interrupt, shutdown, sessions (retornan dicts con metadatos)"}


id: jupyter_read_py_notebook
name: jupyter_read
kind: function
lang: py
domain: notebook
version: 1.0.0
purity: impure
tags: ["jupyter","notebook","crdt","yjs","websocket","cells","read","realtime"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["jupyter_nbmodel_client"]
tests: []
file_path: python/functions/notebook/jupyter_read.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 1d2bf72fe0b605f452cb429d0f19b2478fd75f834b274c4e225c266f69529d69
signature:
  def jupyter_read_cells(notebook_path: str, server_url: str = 'http://localhost:8888', token: str = '', cell_index: int | None = None) -> list[dict]

description:
  Lee celdas de un notebook Jupyter abierto via el protocolo de colaboracion en tiempo real (CRDT/Y.js). Devuelve el estado actual incluyendo cambios no guardados. Expone tambien jupyter_notebook_info() para metadata rapida.

example:
  ```python
  from notebook.jupyter_read import jupyter_read_cells, jupyter_notebook_info

  # Leer todas las celdas
  cells = jupyter_read_cells(
      "notebooks/analysis.ipynb",
      server_url="http://localhost:8888",
      token="mi-token",
  )
  for cell in cells:
      print(f"[{cell['index']}] {cell['type']}: {cell['source'][:60]}")

  # Leer solo la celda 3
  cell = jupyter_read_cells("notebooks/analysis.ipynb", token="mi-token", cell_index=3)

  # Solo metadata
  info = jupyter_notebook_info("notebooks/analysis.ipynb", token="mi-token")
  print(f"Total celdas: {info['total_cells']}")
  ```

notes:
  - Usa `NbModelClient` de `jupyter_nbmodel_client` con protocolo CRDT/Y.js.
  - Las funciones internas son `async`; las publicas envuelven con `asyncio.run()` para ser sincronas.
  - Lee el estado **en memoria del servidor**, no el archivo `.ipynb` en disco — captura cambios no guardados.
  - `notebook_path` debe ser relativo a la raiz del servidor Jupyter, no al sistema de archivos local.
  - Para servidores sin token, usar `token=""` (default).
  - El CLI muestra preview de hasta 8 lineas de source y 4 lineas por output en modo legible.

documentation:
  ## Funciones exportadas

  ### `jupyter_read_cells`

  ```python
  def jupyter_read_cells(
      notebook_path: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
      cell_index: int | None = None,
  ) -> list[dict]
  ```

  Lee todas las celdas o una especifica de un notebook Jupyter en vivo.
  Retorna lista de dicts:

  ```python
  {"index": 0, "type": "code", "source": "import pandas as pd", "outputs": ["..."]}
  ```

  - Para celdas markdown y raw, el campo `outputs` no se incluye.
  - Para code cells, los outputs se convierten a texto legible:
    - `stream` -> texto plano
    - `display_data`/`execute_result` -> `text/plain`, o `[HTML Output]`, `[Image Output (PNG)]`
    - `error` -> traceback limpio (sin codigos ANSI)

  ### `jupyter_notebook_info`

  ```python
  def jupyter_notebook_info(
      notebook_path: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict
  ```

  Retorna metadata del notebook:

  ```python
  {
      "notebook_path": "notebooks/analysis.ipynb",
      "server_url": "http://localhost:8888",
      "total_cells": 12,
      "cell_counts": {"code": 9, "markdown": 3}
  }
  ```

  ## CLI

  ```bash
  # Ver todas las celdas (formato legible)
  python jupyter_read.py notebooks/analysis.ipynb --token MI_TOKEN

  # Ver solo la celda 5
  python jupyter_read.py notebooks/analysis.ipynb --token MI_TOKEN --cell 5

  # Solo metadata
  python jupyter_read.py notebooks/analysis.ipynb --token MI_TOKEN --info

  # Salida JSON (todas las celdas)
  python jupyter_read.py notebooks/analysis.ipynb --token MI_TOKEN --json

  # Servidor remoto
  python jupyter_read.py notebooks/analysis.ipynb --server http://mi-servidor:8888 --token MI_TOKEN
  ```

code:
  """Lee celdas de un notebook Jupyter via protocolo de colaboracion en tiempo real (CRDT/Y.js)."""

  from __future__ import annotations

  import asyncio
  import json
  import sys
  from typing import Any
  from urllib.error import URLError
  from urllib.request import Request, urlopen

  from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url


  def _resolve_collab_username(server_url: str, token: str) -> str:
      """Resolve the display name of the active user in Jupyter collaboration."""
      headers = {"Accept": "application/json"}
      if token:
          headers["Authorization"] = f"token {token}"
      try:
          req = Request(f"{server_url}/api/me", headers=headers)
          with urlopen(req, timeout=5) as resp:
              me = json.loads(resp.read())
          identity = me.get("identity", {})
          return identity.get("display_name", "") or identity.get("username", "") or identity.get("name", "Anonymous")
      except (URLError, OSError, json.JSONDecodeError):
          return "Anonymous"


  # ---------------------------------------------------------------------------
  # Helpers internos (async)
  # ---------------------------------------------------------------------------

  def _extract_outputs(outputs: list[dict]) -> list[str]:
      """Convierte outputs de celda en representaciones legibles."""
      result = []
      for out in outputs:
          out_type = out.get("output_type", "")
          if out_type == "stream":
              text = out.get("text", "")
              if isinstance(text, list):
                  text = "".join(text)
              result.append(text.rstrip())
          elif out_type in ("display_data", "execute_result"):
              data = out.get("data", {})
              if "text/plain" in data:
                  plain = data["text/plain"]
                  if isinstance(plain, list):
                      plain = "".join(plain)
                  result.append(plain.rstrip())
              elif "text/html" in data:
                  result.append("[HTML Output]")
              elif "image/png" in data:
                  result.append("[Image Output (PNG)]")
              else:
                  result.append(f"[Output: {list(data.keys())}]")
          elif out_type == "error":
              traceback = out.get("traceback", [])
              # Strip ANSI codes for clean text
              import re
              ansi_escape = re.compile(r"\x1b\[[0-9;]*m")
              clean = [ansi_escape.sub("", line) for line in traceback]
              result.append("\n".join(clean))
      return result


  def _cell_to_dict(index: int, cell: Any) -> dict:
      """Convierte una NotebookNode en un dict normalizado."""
      cell_type = cell.get("cell_type", "code")
      source = cell.get("source", "")
      if isinstance(source, list):
          source = "".join(source)

      entry: dict = {
          "index": index,
          "type": cell_type,
          "source": source,
      }

      if cell_type == "code":
          raw_outputs = cell.get("outputs", [])
          entry["outputs"] = _extract_outputs(raw_outputs)

      return entry


  async def _read_cells_async(
      notebook_path: str,
      server_url: str,
      token: str,
      cell_index: int | None,
  ) -> list[dict]:
      """Conecta al servidor Jupyter y lee las celdas del notebook."""
      ws_url = get_jupyter_notebook_websocket_url(
          server_url,
          notebook_path,
          token,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as client:
          await client.wait_until_synced()
          total = len(client)
          if cell_index is not None:
              if cell_index < 0 or cell_index >= total:
                  raise IndexError(
                      f"cell_index {cell_index} fuera de rango (0-{total - 1})"
                  )
              return [_cell_to_dict(cell_index, client[cell_index])]
          return [_cell_to_dict(i, client[i]) for i in range(total)]


  async def _notebook_info_async(
      notebook_path: str,
      server_url: str,
      token: str,
  ) -> dict:
      """Conecta al servidor Jupyter y retorna metadata del notebook."""
      ws_url = get_jupyter_notebook_websocket_url(
          server_url,
          notebook_path,
          token,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as client:
          await client.wait_until_synced()
          total = len(client)
          counts: dict[str, int] = {}
          for i in range(total):
              ct = client[i].get("cell_type", "code")
              counts[ct] = counts.get(ct, 0) + 1
          return {
              "notebook_path": notebook_path,
              "server_url": server_url,
              "total_cells": total,
              "cell_counts": counts,
          }


  # ---------------------------------------------------------------------------
  # API publica (sincrona)
  # ---------------------------------------------------------------------------

  def jupyter_read_cells(
      notebook_path: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
      cell_index: int | None = None,
  ) -> list[dict]:
      """Lee todas las celdas de un notebook Jupyter o una celda especifica.

      Conecta via el protocolo de colaboracion en tiempo real (CRDT/Y.js) y
      devuelve el estado actual del notebook incluyendo cambios no guardados.

      Args:
          notebook_path: Ruta relativa al notebook desde la raiz del servidor
                         (ej: "notebooks/analysis.ipynb").
          server_url: URL base del servidor Jupyter (default http://localhost:8888).
          token: Token de autenticacion del servidor Jupyter.
          cell_index: Si se indica, retorna solo esa celda (0-based). Si es None,
                      retorna todas las celdas.

      Returns:
          Lista de dicts con campos:
          - index (int): posicion de la celda en el notebook
          - type (str): "code", "markdown" o "raw"
          - source (str): contenido de la celda
          - outputs (list[str]): solo para code cells; representacion legible
            de cada output (stream, texto plano, [HTML Output], [Image Output (PNG)],
            traceback de errores).

      Raises:
          IndexError: Si cell_index esta fuera del rango del notebook.
          Exception: Si no se puede conectar al servidor Jupyter o al notebook.
      """
      return asyncio.run(
          _read_cells_async(notebook_path, server_url, token, cell_index)
      )


  def jupyter_notebook_info(
      notebook_path: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Retorna metadata de un notebook Jupyter abierto.

      Args:
          notebook_path: Ruta relativa al notebook desde la raiz del servidor.
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion.

      Returns:
          Dict con:
          - notebook_path (str): ruta del notebook
          - server_url (str): URL del servidor
          - total_cells (int): numero total de celdas
          - cell_counts (dict): conteo por tipo {"code": N, "markdown": M, ...}

      Raises:
          Exception: Si no se puede conectar al servidor Jupyter o al notebook.
      """
      return asyncio.run(
          _notebook_info_async(notebook_path, server_url, token)
      )


  # ---------------------------------------------------------------------------
  # CLI
  # ---------------------------------------------------------------------------

  def _format_readable(cells: list[dict]) -> str:
      """Formatea celdas en texto legible con preview de hasta 8 lineas."""
      lines = []
      for cell in cells:
          header = f"[{cell['index']}] {cell['type'].upper()}"
          lines.append(header)
          lines.append("-" * len(header))
          source_lines = cell["source"].splitlines()
          preview = source_lines[:8]
          lines.extend(preview)
          if len(source_lines) > 8:
              lines.append(f"... ({len(source_lines) - 8} lineas mas)")
          if "outputs" in cell and cell["outputs"]:
              lines.append("  -> outputs:")
              for out in cell["outputs"]:
                  out_preview = out.splitlines()[:4]
                  for ol in out_preview:
                      lines.append(f"     {ol}")
                  if len(out.splitlines()) > 4:
                      lines.append("     ...")
          lines.append("")
      return "\n".join(lines)


  def main() -> None:
      import argparse

      parser = argparse.ArgumentParser(
          description="Lee celdas de un notebook Jupyter via CRDT/Y.js"
      )
      parser.add_argument("notebook", help="Ruta del notebook relativa al servidor")
      parser.add_argument(
          "--server",
          default="http://localhost:8888",
          help="URL del servidor Jupyter (default: http://localhost:8888)",
      )
      parser.add_argument("--token", default="", help="Token de autenticacion")
      parser.add_argument(
          "--cell",
          type=int,
          default=None,
          metavar="INDEX",
          help="Indice de celda especifica (0-based)",
      )
      parser.add_argument(
          "--info",
          action="store_true",
          help="Mostrar solo metadata del notebook",
      )
      parser.add_argument(
          "--json",
          action="store_true",
          dest="as_json",
          help="Salida en formato JSON",
      )
      args = parser.parse_args()

      try:
          if args.info:
              result = jupyter_notebook_info(args.notebook, args.server, args.token)
              if args.as_json:
                  print(json.dumps(result, ensure_ascii=False, indent=2))
              else:
                  print(f"Notebook: {result['notebook_path']}")
                  print(f"Servidor: {result['server_url']}")
                  print(f"Total celdas: {result['total_cells']}")
                  for ct, count in result["cell_counts"].items():
                      print(f"  {ct}: {count}")
          else:
              cells = jupyter_read_cells(
                  args.notebook, args.server, args.token, args.cell
              )
              if args.as_json:
                  print(json.dumps(cells, ensure_ascii=False, indent=2))
              else:
                  print(_format_readable(cells))
      except Exception as exc:
          print(f"Error: {exc}", file=sys.stderr)
          sys.exit(1)


  if __name__ == "__main__":
      main()

params_schema:
  {"params":[{"name":"notebook_path","desc":"Ruta relativa del notebook"},{"name":"server_url","desc":"URL del servidor Jupyter (default localhost:8888)"},{"name":"token","desc":"Token de autenticación"},{"name":"cell_index","desc":"Índice de celda específica a leer (opcional)"}],"output":"Lista de dicts con info de celdas (index, type, source, outputs) o dict con metadata del notebook"}


id: jupyter_write_py_notebook
name: jupyter_write
kind: function
lang: py
domain: notebook
version: 1.1.0
purity: impure
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["jupyter_nbmodel_client"]
tests: []
file_path: python/functions/notebook/jupyter_write.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-07T20:58:02Z
props: []
emits: []
variant: []
content_hash: 91db1e29e603ae6567d05a9374e6decb9822c0ac9f77dc636e33e6d9efcd9325
signature:
  def jupyter_append_code(notebook_path: str, source: str, server_url: str = 'http://localhost:8888', token: str = '') -> dict

description:
  Operaciones de escritura sobre celdas de un notebook Jupyter via colaboracion en tiempo real (WebSocket) y API REST. Expone siete operaciones: append_code, append_markdown, insert, edit, delete, create y batch. NO ejecuta celdas — solo modifica la estructura del notebook. create usa PUT /api/contents para crear notebooks nuevos sin necesidad de websocket. batch abre una unica conexion WebSocket para insertar N celdas en una sola operacion.

tags:
  ["jupyter","notebook","websocket","cell","write","append","insert","edit","delete","create","batch","nbmodel","rest"]

example:
  ```python
  from notebook.jupyter_write import (
      jupyter_append_code,
      jupyter_append_markdown,
      jupyter_insert_cell,
      jupyter_edit_cell,
      jupyter_delete_cell,
      jupyter_create_notebook,
      jupyter_batch_write,
  )

  # Crear notebook nuevo
  result = jupyter_create_notebook(
      notebook_path="notebooks/01_analisis.ipynb",
      kernel_name="python3",
      server_url="http://localhost:8888",
  )
  # {"action": "create", "notebook": "notebooks/01_analisis.ipynb", "created": true}

  # Si ya existe, lanza FileExistsError. Usar force=True para sobreescribir:
  result = jupyter_create_notebook("notebooks/01.ipynb", force=True)

  # Anadir multiples celdas de golpe (una sola conexion WebSocket)
  cells = [
      {"type": "markdown", "source": "## Analisis inicial"},
      {"type": "code", "source": "import pandas as pd"},
      {"type": "code", "source": "df = pd.read_csv('data.csv')\ndf.head()"},
  ]
  result = jupyter_batch_write(
      notebook_path="notebooks/01_analisis.ipynb",
      cells=cells,
      server_url="http://localhost:8888",
  )
  # {"action": "batch", "cells_added": 3, "notebook": "notebooks/01_analisis.ipynb"}

  # Anadir celda de codigo al final
  result = jupyter_append_code(
      notebook_path="notebooks/01_analisis.ipynb",
      source="import pandas as pd\ndf = pd.read_csv('data.csv')",
  )
  # {"action": "append_code", "cell_index": 5, "notebook": "notebooks/01_analisis.ipynb"}

  # Anadir celda markdown
  result = jupyter_append_markdown(
      notebook_path="notebooks/01_analisis.ipynb",
      source="## Resultados\n\nAnalisis de los datos obtenidos.",
  )
  # {"action": "append_markdown", "cell_index": 6, "notebook": "..."}

  # Insertar celda en posicion 2
  result = jupyter_insert_cell(
      notebook_path="notebooks/01_analisis.ipynb",
      cell_index=2,
      source="# celda insertada",
      cell_type="code",
  )
  # {"action": "insert", "cell_index": 2, "cell_type": "code", "notebook": "..."}

  # Editar celda existente (indice 0)
  result = jupyter_edit_cell(
      notebook_path="notebooks/01_analisis.ipynb",
      cell_index=0,
      source="# Titulo actualizado",
  )
  # {"action": "edit", "cell_index": 0, "notebook": "..."}

  # Eliminar celda
  result = jupyter_delete_cell(
      notebook_path="notebooks/01_analisis.ipynb",
      cell_index=3,
  )
  # {"action": "delete", "cell_index": 3, "notebook": "..."}
  ```

notes:
  - Todas las funciones son sincronas publicamente. Internamente usan `asyncio.run()` sobre corutinas async que se comunican via WebSocket con `NbModelClient`.
  - `create` es la excepcion: usa urllib (PUT /api/contents) sin WebSocket. Crea un nbformat 4 con celdas vacias. Lanza `FileExistsError` si el notebook ya existe y `force=False`.
  - `batch` es mucho mas eficiente que N llamadas a `append-code`/`append-markdown`: abre una sola conexion WebSocket y hace un unico `asyncio.sleep(2)` de sincronizacion al final.
  - El `notebook_path` es relativo al servidor Jupyter (no al filesystem local).
  - Si el servidor no esta corriendo o el token es incorrecto, lanza excepcion de conexion de `jupyter_nbmodel_client`.
  - NO ejecuta celdas — solo modifica la estructura. Para ejecutar, usar `jupyter_exec`.
  - `server_url` y `token` tienen defaults convenientes para desarrollo local (`http://localhost:8888`, token vacio).
  - El campo `cell_index` en el resultado refleja la posicion final de la celda en el notebook.
  - `append_code`, `append_markdown` y `batch` crean el notebook automaticamente si no existe (auto-create via REST). No es necesario llamar a `create` previamente.
  - Patron tipico: `batch` para poblar las celdas iniciales (crea el notebook si no existe), o `create` + `batch` si se necesita control explicito.

documentation:
  ## Funciones expuestas

  | Funcion | Descripcion |
  |---------|-------------|
  | `jupyter_append_code(notebook_path, source, server_url, token)` | Anade celda de codigo al final |
  | `jupyter_append_markdown(notebook_path, source, server_url, token)` | Anade celda markdown al final |
  | `jupyter_insert_cell(notebook_path, cell_index, source, cell_type, server_url, token)` | Inserta celda en posicion especifica |
  | `jupyter_edit_cell(notebook_path, cell_index, source, server_url, token)` | Sobrescribe contenido de celda existente |
  | `jupyter_delete_cell(notebook_path, cell_index, server_url, token)` | Elimina una celda |
  | `jupyter_create_notebook(notebook_path, kernel_name, server_url, token, force)` | Crea un notebook vacio nbformat 4 via API REST |
  | `jupyter_batch_write(notebook_path, cells, server_url, token)` | Anade N celdas en una sola conexion WebSocket |

  ## CLI

  ```bash
  # Crear notebook nuevo
  python -m notebook.jupyter_write create notebooks/01.ipynb
  python -m notebook.jupyter_write create notebooks/01.ipynb --kernel python3
  python -m notebook.jupyter_write create notebooks/01.ipynb --force

  # Anadir multiples celdas desde archivo JSON
  python -m notebook.jupyter_write batch notebooks/01.ipynb --from cells.json

  # O via stdin
  cat cells.json | python -m notebook.jupyter_write batch notebooks/01.ipynb --from -
  echo '[{"type":"code","source":"import pandas"}]' | python -m notebook.jupyter_write batch notebooks/01.ipynb

  # Formato JSON de entrada para batch:
  # [{"type": "markdown", "source": "# Titulo"}, {"type": "code", "source": "import pandas"}]

  # Anadir celda de codigo
  python -m notebook.jupyter_write append-code notebooks/01.ipynb "print('hola')" --server http://localhost:8888 --token mi-token

  # Anadir celda markdown
  python -m notebook.jupyter_write append-markdown notebooks/01.ipynb "## Titulo"

  # Insertar en posicion 2
  python -m notebook.jupyter_write insert notebooks/01.ipynb 2 "x = 42" --type code

  # Editar celda 0
  python -m notebook.jupyter_write edit notebooks/01.ipynb 0 "# Nuevo titulo"

  # Eliminar celda 3
  python -m notebook.jupyter_write delete notebooks/01.ipynb 3
  ```

code:
  """Operaciones de escritura sobre celdas de un notebook Jupyter via colaboracion en tiempo real.

  NO ejecuta celdas — solo modifica la estructura del notebook (append, insert, edit, delete, create, batch).
  Usa jupyter_nbmodel_client para comunicarse con el servidor Jupyter via WebSocket.
  Para crear notebooks usa la API REST de Jupyter (urllib PUT /api/contents).
  """

  import asyncio
  import json
  import argparse
  import sys
  from urllib.error import URLError, HTTPError
  from urllib.request import Request, urlopen

  from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url


  def _resolve_collab_username(server_url: str, token: str) -> str:
      """Resolve the display name of the active user in Jupyter collaboration."""
      headers = {"Accept": "application/json"}
      if token:
          headers["Authorization"] = f"token {token}"
      try:
          req = Request(f"{server_url}/api/me", headers=headers)
          with urlopen(req, timeout=5) as resp:
              me = json.loads(resp.read())
          identity = me.get("identity", {})
          return identity.get("display_name", "") or identity.get("username", "") or identity.get("name", "Anonymous")
      except (URLError, OSError, json.JSONDecodeError):
          return "Anonymous"


  # ---------------------------------------------------------------------------
  # Helpers internos
  # ---------------------------------------------------------------------------


  def _notebook_exists(notebook_path: str, server_url: str, token: str) -> bool:
      """Comprueba si un notebook existe en el servidor Jupyter via HEAD /api/contents."""
      headers = {"Accept": "application/json"}
      if token:
          headers["Authorization"] = f"token {token}"
      check_url = f"{server_url}/api/contents/{notebook_path}"
      req = Request(check_url, headers=headers, method="HEAD")
      try:
          with urlopen(req, timeout=5):
              return True
      except HTTPError as e:
          if e.code == 404:
              return False
          raise


  def _auto_create_notebook(notebook_path: str, server_url: str, token: str) -> bool:
      """Crea el notebook si no existe. Retorna True si fue creado."""
      if not _notebook_exists(notebook_path, server_url, token):
          jupyter_create_notebook(notebook_path, server_url=server_url, token=token)
          return True
      return False


  # ---------------------------------------------------------------------------
  # Helpers internos async
  # ---------------------------------------------------------------------------


  async def _append_cell(
      notebook_path: str,
      source: str,
      cell_type: str,
      server_url: str,
      token: str,
  ) -> dict:
      _auto_create_notebook(notebook_path, server_url, token)
      ws_url = get_jupyter_notebook_websocket_url(
          server_url=server_url,
          token=token,
          path=notebook_path,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as nb:
          if cell_type == "markdown":
              nb.add_markdown_cell(source)
          else:
              nb.add_code_cell(source)
          cell_index = len(nb) - 1
          await asyncio.sleep(2)
      return {
          "action": f"append_{cell_type}",
          "cell_index": cell_index,
          "notebook": notebook_path,
      }


  async def _insert_cell(
      notebook_path: str,
      cell_index: int,
      source: str,
      cell_type: str,
      server_url: str,
      token: str,
  ) -> dict:
      ws_url = get_jupyter_notebook_websocket_url(
          server_url=server_url,
          token=token,
          path=notebook_path,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as nb:
          nb.insert_cell(cell_index, cell_type=cell_type, source=source)
          await asyncio.sleep(2)
      return {
          "action": "insert",
          "cell_index": cell_index,
          "cell_type": cell_type,
          "notebook": notebook_path,
      }


  async def _edit_cell(
      notebook_path: str,
      cell_index: int,
      source: str,
      server_url: str,
      token: str,
  ) -> dict:
      ws_url = get_jupyter_notebook_websocket_url(
          server_url=server_url,
          token=token,
          path=notebook_path,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as nb:
          nb.set_cell_source(cell_index, source)
          await asyncio.sleep(2)
      return {
          "action": "edit",
          "cell_index": cell_index,
          "notebook": notebook_path,
      }


  async def _delete_cell(
      notebook_path: str,
      cell_index: int,
      server_url: str,
      token: str,
  ) -> dict:
      ws_url = get_jupyter_notebook_websocket_url(
          server_url=server_url,
          token=token,
          path=notebook_path,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as nb:
          nb.delete_cell(cell_index)
          await asyncio.sleep(2)
      return {
          "action": "delete",
          "cell_index": cell_index,
          "notebook": notebook_path,
      }


  async def _batch_write(
      notebook_path: str,
      cells: list,
      server_url: str,
      token: str,
  ) -> dict:
      """Anade multiples celdas en una sola conexion WebSocket."""
      _auto_create_notebook(notebook_path, server_url, token)
      ws_url = get_jupyter_notebook_websocket_url(
          server_url=server_url,
          token=token,
          path=notebook_path,
      )
      username = _resolve_collab_username(server_url, token)
      async with NbModelClient(ws_url, username=username) as nb:
          for cell in cells:
              cell_type = cell.get("type", "code")
              source = cell.get("source", "")
              if cell_type == "markdown":
                  nb.add_markdown_cell(source)
              else:
                  nb.add_code_cell(source)
          await asyncio.sleep(2)
      return {
          "action": "batch",
          "cells_added": len(cells),
          "notebook": notebook_path,
      }


  # ---------------------------------------------------------------------------
  # API publica sincrona
  # ---------------------------------------------------------------------------


  def jupyter_append_code(
      notebook_path: str,
      source: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Anade una celda de codigo al final del notebook.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          source: Codigo fuente de la celda.
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con action, cell_index y notebook.
      """
      return asyncio.run(_append_cell(notebook_path, source, "code", server_url, token))


  def jupyter_append_markdown(
      notebook_path: str,
      source: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Anade una celda markdown al final del notebook.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          source: Contenido markdown de la celda.
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con action, cell_index y notebook.
      """
      return asyncio.run(
          _append_cell(notebook_path, source, "markdown", server_url, token)
      )


  def jupyter_insert_cell(
      notebook_path: str,
      cell_index: int,
      source: str,
      cell_type: str = "code",
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Inserta una celda en una posicion especifica del notebook.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          cell_index: Indice donde insertar (0 = primer posicion).
          source: Contenido de la celda.
          cell_type: Tipo de celda: "code" o "markdown".
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con action, cell_index, cell_type y notebook.
      """
      return asyncio.run(
          _insert_cell(notebook_path, cell_index, source, cell_type, server_url, token)
      )


  def jupyter_edit_cell(
      notebook_path: str,
      cell_index: int,
      source: str,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Sobrescribe el contenido de una celda existente.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          cell_index: Indice de la celda a editar (0-based).
          source: Nuevo contenido de la celda.
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con action, cell_index y notebook.
      """
      return asyncio.run(
          _edit_cell(notebook_path, cell_index, source, server_url, token)
      )


  def jupyter_delete_cell(
      notebook_path: str,
      cell_index: int,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Elimina una celda del notebook.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          cell_index: Indice de la celda a eliminar (0-based).
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con action, cell_index y notebook.
      """
      return asyncio.run(
          _delete_cell(notebook_path, cell_index, server_url, token)
      )


  def jupyter_create_notebook(
      notebook_path: str,
      kernel_name: str = "python3",
      server_url: str = "http://localhost:8888",
      token: str = "",
      force: bool = False,
  ) -> dict:
      """Crea un notebook vacio (nbformat 4) via la API REST de Jupyter.

      Usa PUT /api/contents/{path} con type: notebook. Si el notebook ya existe
      y force=False, lanza un error en vez de sobreescribirlo.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          kernel_name: Nombre del kernel a asociar (default: python3).
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.
          force: Si True, sobreescribe el notebook si ya existe.

      Returns:
          dict con action, notebook y created.
      """
      headers = {
          "Content-Type": "application/json",
          "Accept": "application/json",
      }
      if token:
          headers["Authorization"] = f"token {token}"

      # Verificar si ya existe (HEAD request)
      check_url = f"{server_url}/api/contents/{notebook_path}"
      check_req = Request(check_url, headers=headers, method="HEAD")
      already_exists = False
      try:
          with urlopen(check_req, timeout=5):
              already_exists = True
      except HTTPError as e:
          if e.code != 404:
              raise
      except URLError:
          raise

      if already_exists and not force:
          raise FileExistsError(
              f"Notebook ya existe: {notebook_path}. Usa force=True para sobreescribir."
          )

      kernel_display = {
          "python3": "Python 3 (ipykernel)",
          "python": "Python 3",
      }.get(kernel_name, kernel_name)

      notebook_content = {
          "nbformat": 4,
          "nbformat_minor": 5,
          "metadata": {
              "kernelspec": {
                  "name": kernel_name,
                  "display_name": kernel_display,
                  "language": "python",
              },
              "language_info": {
                  "name": "python",
              },
          },
          "cells": [],
      }

      body = json.dumps({
          "type": "notebook",
          "content": notebook_content,
      }).encode("utf-8")

      put_req = Request(check_url, data=body, headers=headers, method="PUT")
      with urlopen(put_req, timeout=10) as resp:
          resp.read()

      return {
          "action": "create",
          "notebook": notebook_path,
          "created": True,
      }


  def jupyter_batch_write(
      notebook_path: str,
      cells: list,
      server_url: str = "http://localhost:8888",
      token: str = "",
  ) -> dict:
      """Anade multiples celdas al notebook en una sola conexion WebSocket.

      Mucho mas eficiente que llamar append-code/append-markdown N veces porque
      abre una unica conexion WebSocket y hace un solo sleep de sincronizacion
      al final de todas las inserciones.

      Args:
          notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
          cells: Lista de dicts con claves "type" ("code"|"markdown") y "source" (str).
          server_url: URL base del servidor Jupyter.
          token: Token de autenticacion del servidor Jupyter.

      Returns:
          dict con action, cells_added y notebook.
      """
      return asyncio.run(_batch_write(notebook_path, cells, server_url, token))


  # ---------------------------------------------------------------------------
  # CLI
  # ---------------------------------------------------------------------------


  def _build_parser() -> argparse.ArgumentParser:
      parser = argparse.ArgumentParser(
          prog="jupyter_write",
          description="Operaciones de escritura sobre celdas de un notebook Jupyter.",
      )
      sub = parser.add_subparsers(dest="command", required=True)

      # Argumentos comunes
      def add_common(p: argparse.ArgumentParser) -> None:
          p.add_argument("--server", default="http://localhost:8888", help="URL del servidor Jupyter")
          p.add_argument("--token", default="", help="Token de autenticacion")

      # append-code
      p_ac = sub.add_parser("append-code", help="Anade celda de codigo al final")
      p_ac.add_argument("notebook", help="Ruta del notebook")
      p_ac.add_argument("source", help="Codigo fuente")
      add_common(p_ac)

      # append-markdown
      p_am = sub.add_parser("append-markdown", help="Anade celda markdown al final")
      p_am.add_argument("notebook", help="Ruta del notebook")
      p_am.add_argument("source", help="Contenido markdown")
      add_common(p_am)

      # insert
      p_ins = sub.add_parser("insert", help="Inserta celda en posicion especifica")
      p_ins.add_argument("notebook", help="Ruta del notebook")
      p_ins.add_argument("index", type=int, help="Indice de insercion (0-based)")
      p_ins.add_argument("source", help="Contenido de la celda")
      p_ins.add_argument("--type", dest="cell_type", choices=["code", "markdown"], default="code")
      add_common(p_ins)

      # edit
      p_ed = sub.add_parser("edit", help="Sobrescribe el contenido de una celda")
      p_ed.add_argument("notebook", help="Ruta del notebook")
      p_ed.add_argument("index", type=int, help="Indice de la celda (0-based)")
      p_ed.add_argument("source", help="Nuevo contenido")
      add_common(p_ed)

      # delete
      p_del = sub.add_parser("delete", help="Elimina una celda")
      p_del.add_argument("notebook", help="Ruta del notebook")
      p_del.add_argument("index", type=int, help="Indice de la celda (0-based)")
      add_common(p_del)

      # create
      p_cr = sub.add_parser("create", help="Crea un notebook vacio via API REST")
      p_cr.add_argument("notebook", help="Ruta del notebook a crear")
      p_cr.add_argument("--kernel", default="python3", help="Nombre del kernel (default: python3)")
      p_cr.add_argument("--force", action="store_true", help="Sobreescribir si ya existe")
      add_common(p_cr)

      # batch
      p_bt = sub.add_parser("batch", help="Anade multiples celdas en una sola conexion WebSocket")
      p_bt.add_argument("notebook", help="Ruta del notebook")
      p_bt.add_argument(
          "--from",
          dest="cells_source",
          default="-",
          metavar="FILE",
          help="Archivo JSON con las celdas, o '-' para leer de stdin (default: -)",
      )
      add_common(p_bt)

      return parser


  def main() -> None:
      parser = _build_parser()
      args = parser.parse_args()

      if args.command == "append-code":
          result = jupyter_append_code(args.notebook, args.source, args.server, args.token)
      elif args.command == "append-markdown":
          result = jupyter_append_markdown(args.notebook, args.source, args.server, args.token)
      elif args.command == "insert":
          result = jupyter_insert_cell(
              args.notebook, args.index, args.source, args.cell_type, args.server, args.token
          )
      elif args.command == "edit":
          result = jupyter_edit_cell(args.notebook, args.index, args.source, args.server, args.token)
      elif args.command == "delete":
          result = jupyter_delete_cell(args.notebook, args.index, args.server, args.token)
      elif args.command == "create":
          result = jupyter_create_notebook(
              args.notebook,
              kernel_name=args.kernel,
              server_url=args.server,
              token=args.token,
              force=args.force,
          )
      elif args.command == "batch":
          if args.cells_source == "-":
              raw = sys.stdin.read()
          else:
              with open(args.cells_source, "r", encoding="utf-8") as f:
                  raw = f.read()
          cells = json.loads(raw)
          result = jupyter_batch_write(args.notebook, cells, args.server, args.token)
      else:
          parser.print_help()
          return

      print(json.dumps(result, indent=2))


  if __name__ == "__main__":
      main()

params_schema:
  {"output":"Múltiples funciones para escribir en notebooks: append_code/markdown, insert, edit, delete, create, batch (retornan dicts con action y posición)"}


id: extraction_pipeline_py_pipelines
name: extraction_pipeline
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
tags: ["pipeline","extraction","entities","relations","llm","nlp","fuzzygraph","datascience"]
returns: ["extraction_result_py_datascience"]
error_type: error_go_core
imports: ["time","warnings","typing.Callable"]
test_file_path: python/functions/pipelines/extraction_pipeline_test.py
file_path: python/functions/pipelines/extraction_pipeline.py
created_at: 2026-04-05T14:45:08Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: acfba2dd2501c2112856b4cd572ef697f42a6afc68fef80b53835add4ad8047c
signature:
  def extraction_pipeline(file_path: str, entity_presets: list[dict], relation_types: list[str], llm_chat_json: Callable[[list[dict]], dict], chunk_size: int = 500, chunk_overlap: int = 50, confidence_threshold: float = 0.5, dedup_threshold: float = 0.85, on_progress: Callable[[str, float], None] | None = None) -> ExtractionResult

description:
  Pipeline completa de extraccion de entidades y relaciones desde un documento. Orquesta extract_text_from_file -> preprocess_text -> split_text_into_chunks -> extract_entities_llm por chunk -> deduplicate_entities -> extract_relations_llm por chunk -> deduplicate_relations.

uses_functions:
  ["extract_text_from_file_py_core","preprocess_text_py_core","split_text_into_chunks_py_core","build_entity_schema_prompt_py_datascience","build_relation_schema_prompt_py_datascience","extract_entities_llm_py_datascience","extract_relations_llm_py_datascience","deduplicate_entities_py_datascience","deduplicate_relations_py_datascience"]

uses_types:
  ["entity_candidate_py_datascience","extraction_result_py_datascience","extraction_stats_py_datascience","relation_candidate_py_datascience"]

example:
  ```python
  from python.functions.pipelines.extraction_pipeline import extraction_pipeline

  entity_presets = [
      {
          "type_ref": "osint_person_go_cybersecurity",
          "label": "Person",
          "metadata_fields": ["full_name", "alias", "nationality"],
      },
      {
          "type_ref": "osint_domain_go_cybersecurity",
          "label": "Domain",
          "metadata_fields": ["fqdn", "registrar"],
      },
  ]

  relation_types = ["operates", "owns", "funds", "communicates_with", "related_to"]

  # Inyectar un cliente LLM real
  def llm_chat_json(messages):
      # llamada al proveedor LLM elegido
      ...

  result = extraction_pipeline(
      file_path="report.pdf",
      entity_presets=entity_presets,
      relation_types=relation_types,
      llm_chat_json=llm_chat_json,
      chunk_size=500,
      chunk_overlap=50,
      confidence_threshold=0.5,
      dedup_threshold=0.85,
      on_progress=lambda msg, pct: print(f"[{pct:.0%}] {msg}"),
  )

  print(f"Entities: {len(result.entities)}, Relations: {len(result.relations)}")
  print(f"Stats: {result.stats}")

  # Integrar con fuzzygraph / operations.db
  for entity in result.entities:
      db.add_entity(
          name=entity.name,
          type_ref=entity.type_ref,
          metadata=entity.attributes,
      )

  for relation in result.relations:
      db.add_relation(
          name=relation.relation_type,
          from_entity=relation.from_id,
          to_entity=relation.to_id,
      )
  ```

tests:
  ["documento con entidades y relaciones retorna ExtractionResult completo","documento vacio retorna ExtractionResult con listas vacias","documento sin entidades detectables retorna listas vacias","archivo no encontrado lanza FileNotFoundError","entity presets vacio lanza ValueError","progress callback se invoca durante la ejecucion","stats se rellenan correctamente con conteos y tiempo"]

notes:
  - El parametro `llm_chat_json` inyecta el cliente LLM, sin acoplamiento a ningun proveedor (OpenAI, Anthropic, Ollama, etc.)
  - El progress callback cubre: 0-40% extraccion de entidades, 40-80% extraccion de relaciones, 80-100% deduplicacion
  - Si el archivo no existe lanza `FileNotFoundError` antes de cualquier llamada al LLM
  - Si `entity_presets` esta vacio lanza `ValueError`
  - Errores en chunks individuales se capturan con warnings y continuan (robustez)
  - Los `entity_id_map` de `deduplicate_entities` conectan nombres originales del texto con IDs UUID finales para `deduplicate_relations`
  - La retorna `ExtractionResult` esta lista para insertar en `operations.db` via `fn ops entity add` / `fn ops relation add`

documentation:
  ## Algoritmo

  1. **Extract:** `extract_text_from_file(file_path)` — texto crudo desde PDF, TXT, Markdown
  2. **Preprocess:** `preprocess_text(text)` — normaliza espacios, caracteres especiales
  3. **Split:** `split_text_into_chunks(text, chunk_size, chunk_overlap)` — divide en ventanas solapadas
  4. **Extract entities per chunk (0-40%):** Para cada chunk llama `extract_entities_llm` con el schema de presets. Anota `source_chunk_index` en cada candidato
  5. **Filter:** filtra por `confidence >= confidence_threshold`
  6. **Deduplicate entities (40%):** `deduplicate_entities` con fuzzy matching, produce `entity_id_map`
  7. **Extract relations per chunk (40-80%):** Para cada chunk obtiene las entidades de ese chunk y llama `extract_relations_llm`
  8. **Deduplicate relations (80-100%):** `deduplicate_relations` resuelve nombres a IDs y colapsa duplicados
  9. **Return:** `ExtractionResult` con entidades, relaciones y stats del proceso

code:
  """Pipeline de extraccion de entidades y relaciones desde un documento."""

  from __future__ import annotations

  import sys
  import os
  import time
  import warnings
  from typing import Callable

  # Soporte para ejecucion desde la raiz del registry o desde el directorio del archivo
  _ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
  if _ROOT not in sys.path:
      sys.path.insert(0, _ROOT)

  from python.functions.core.extract_text_from_file import extract_text_from_file
  from python.functions.core.core import preprocess_text
  from python.functions.core.split_text_into_chunks import split_text_into_chunks
  from python.functions.datascience.build_entity_schema_prompt import build_entity_schema_prompt
  from python.functions.datascience.build_relation_schema_prompt import build_relation_schema_prompt
  from python.functions.datascience.extract_entities_llm import extract_entities_llm
  from python.functions.datascience.extract_relations_llm import extract_relations_llm
  from python.functions.datascience.deduplicate_entities import deduplicate_entities
  from python.functions.datascience.deduplicate_relations import deduplicate_relations
  from python.types.datascience.entity_candidate import EntityCandidate
  from python.types.datascience.extraction_result import ExtractionResult
  from python.types.datascience.extraction_stats import ExtractionStats


  def extraction_pipeline(
      file_path: str,
      entity_presets: list[dict],
      relation_types: list[str],
      llm_chat_json: Callable[[list[dict]], dict],
      chunk_size: int = 500,
      chunk_overlap: int = 50,
      confidence_threshold: float = 0.5,
      dedup_threshold: float = 0.85,
      on_progress: Callable[[str, float], None] | None = None,
  ) -> ExtractionResult:
      """Pipeline completa de extraccion de entidades y relaciones desde un documento.

      Orquesta extract_text_from_file -> preprocess_text -> split_text_into_chunks
      -> extract_entities_llm por chunk -> deduplicate_entities ->
      extract_relations_llm por chunk -> deduplicate_relations.

      Args:
          file_path: ruta al archivo a procesar (PDF, Markdown, TXT).
          entity_presets: lista de dicts con type_ref, label y metadata_fields.
              Ejemplo: [{"type_ref": "osint_person_go_cybersecurity",
                          "label": "Person",
                          "metadata_fields": ["full_name", "nationality"]}]
          relation_types: tipos de relacion permitidos para extraccion.
              Ejemplo: ["funds", "employs", "communicates_with", "owns"]
          llm_chat_json: funcion inyectada que recibe messages OpenAI y retorna dict
              con la respuesta JSON ya parseada. Sin acoplamiento a ningun proveedor.
          chunk_size: numero de caracteres por chunk (default 500).
          chunk_overlap: overlap entre chunks consecutivos (default 50).
          confidence_threshold: umbral minimo de confidence para aceptar entidades
              candidatas antes de deduplicar (default 0.5).
          dedup_threshold: score minimo de similitud para mergear entidades (default 0.85).
          on_progress: callback opcional de progreso (message: str, pct: float 0-1).
              0-40%: extraccion de entidades, 40-80%: extraccion de relaciones,
              80-100%: deduplicacion.

      Returns:
          ExtractionResult con entidades y relaciones deduplicadas y stats del proceso.

      Raises:
          FileNotFoundError: si file_path no existe.
          ValueError: si entity_presets esta vacio.
      """
      if not entity_presets:
          raise ValueError("entity_presets no puede estar vacio")

      if not os.path.exists(file_path):
          raise FileNotFoundError(f"Archivo no encontrado: {file_path}")

      def _progress(msg: str, pct: float) -> None:
          if on_progress is not None:
              try:
                  on_progress(msg, pct)
              except Exception:
                  pass

      start_time = time.monotonic()
      stats = ExtractionStats()

      # ── Paso 1: Extraer texto ──────────────────────────────────────────────────
      _progress("Extracting text from file...", 0.0)
      try:
          raw_text = extract_text_from_file(file_path)
      except Exception as exc:
          warnings.warn(f"extraction_pipeline: error al extraer texto: {exc}")
          raw_text = ""

      # ── Paso 2: Preprocesar ────────────────────────────────────────────────────
      clean_text = preprocess_text(raw_text)
      stats.total_chars = len(clean_text)

      # ── Paso 3: Dividir en chunks ──────────────────────────────────────────────
      chunks = split_text_into_chunks(clean_text, chunk_size=chunk_size, overlap=chunk_overlap)
      n = len(chunks)
      stats.total_chunks = n

      if n == 0:
          stats.processing_time_seconds = time.monotonic() - start_time
          return ExtractionResult(entities=[], relations=[], stats=stats)

      # ── Paso 4: Extraer entidades por chunk ────────────────────────────────────
      all_raw_entities: list[EntityCandidate] = []

      for i, chunk in enumerate(chunks):
          _progress(f"Extracting entities from chunk {i + 1}/{n}", (i / n) * 0.4)
          try:
              candidates = extract_entities_llm(
                  text=chunk,
                  entity_schema=entity_presets,
                  llm_chat_json=llm_chat_json,
              )
          except Exception as exc:
              warnings.warn(
                  f"extraction_pipeline: error en extract_entities_llm chunk {i}: {exc}"
              )
              candidates = []

          for candidate in candidates:
              # Anotar el chunk de origen
              if i not in candidate.source_chunk_indices:
                  candidate.source_chunk_indices.append(i)
              all_raw_entities.append(candidate)

      # ── Paso 5: Filtrar por confidence ─────────────────────────────────────────
      filtered_entities = [
          e for e in all_raw_entities if e.confidence >= confidence_threshold
      ]
      stats.raw_entities_count = len(filtered_entities)

      # Actualizar stats de tipos
      for ent in filtered_entities:
          stats.entity_types_found[ent.type_ref] = (
              stats.entity_types_found.get(ent.type_ref, 0) + 1
          )

      # ── Paso 6: Deduplicar entidades ───────────────────────────────────────────
      _progress("Deduplicating entities...", 0.4)
      dedup_result = deduplicate_entities(filtered_entities, name_threshold=dedup_threshold)

      stats.final_entities_count = dedup_result.total_after
      stats.entities_merged = dedup_result.total_before - dedup_result.total_after

      final_entities = dedup_result.entities
      entity_id_map = dedup_result.name_to_id  # nombre_original -> entity_id

      # ── Paso 7: Extraer relaciones por chunk ───────────────────────────────────
      all_raw_relations = []

      for i, chunk in enumerate(chunks):
          _progress(f"Extracting relations...", 0.4 + (i / n) * 0.4)

          # Obtener entidades relevantes de este chunk
          chunk_entities = [
              e for e in final_entities if i in e.source_chunk_indices
          ]
          # Si no hay entidades en este chunk especifico, usar todas
          if not chunk_entities:
              chunk_entities = final_entities

          if len(chunk_entities) < 2:
              continue

          try:
              chunk_relations = extract_relations_llm(
                  text=chunk,
                  entities=chunk_entities,
                  relation_types=relation_types,
                  llm_chat_json=llm_chat_json,
              )
          except Exception as exc:
              warnings.warn(
                  f"extraction_pipeline: error en extract_relations_llm chunk {i}: {exc}"
              )
              chunk_relations = []

          for rel in chunk_relations:
              rel.source_chunk_index = i
          all_raw_relations.extend(chunk_relations)

      stats.raw_relations_count = len(all_raw_relations)

      # Actualizar stats de tipos de relacion
      for rel in all_raw_relations:
          stats.relation_types_found[rel.relation_type] = (
              stats.relation_types_found.get(rel.relation_type, 0) + 1
          )

      # ── Paso 8: Deduplicar relaciones ──────────────────────────────────────────
      _progress("Deduplicating relations...", 0.8)
      final_relations = deduplicate_relations(all_raw_relations, entity_id_map)

      stats.final_relations_count = len(final_relations)
      stats.relations_merged = stats.raw_relations_count - len(final_relations)
      stats.processing_time_seconds = time.monotonic() - start_time

      _progress("Done", 1.0)

      return ExtractionResult(
          entities=final_entities,
          relations=final_relations,
          stats=stats,
      )

params_schema:
  {"params":[{"name":"file_path","desc":"Ruta del documento (PDF, TXT, Markdown) a procesar"},{"name":"entity_presets","desc":"Configuración de tipos de entidades a extraer con sus metadatos"},{"name":"relation_types","desc":"Tipos de relaciones a extraer (ej: 'owns', 'operates', 'communicates_with')"},{"name":"llm_chat_json","desc":"Función inyectada para llamadas al LLM (sin acoplamiento a proveedor)"},{"name":"chunk_size","desc":"Tamaño de chunks para procesamiento (default 500)"},{"name":"chunk_overlap","desc":"Solapamiento entre chunks (default 50)"},{"name":"confidence_threshold","desc":"Confianza mínima para incluir entidades (default 0.5)"},{"name":"dedup_threshold","desc":"Umbral fuzzy para deduplicación (default 0.85)"},{"name":"on_progress","desc":"Callback opcional para progreso (msg, percentage)"}],"output":"ExtractionResult con entidades, relaciones y estadísticas del proceso de extracción"}


id: metabase_add_ops_db_py_pipelines
name: metabase_add_ops_db
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
signature: metabase_add_ops_db(app_name: str) -> None
tags: ["metabase","operations","sqlite","docker","pipeline","infra","launcher"]
uses_functions: ["metabase_auth_py_infra","metabase_list_databases_py_infra","metabase_add_database_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/pipelines/metabase_add_ops_db.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 2f144a4bfd598d6284da2c36430b99cb651ad8f1387a650f8f0b0009880a6c78
description:
  Registra la operations.db de una app en Metabase como database SQLite. Verifica duplicados y muestra el mount necesario para el contenedor Docker.

example:
  ```bash
  # Registrar una app
  python python/functions/pipelines/metabase_add_ops_db.py docker_tui

  # Listar estado de todas las apps
  python python/functions/pipelines/metabase_add_ops_db.py --list
  ```

documentation:
  ## Flujo

  1. `metabase_auth` - autentica contra Metabase
  2. `metabase_list_databases` - verifica si la database ya existe
  3. `metabase_add_database` - registra la operations.db como SQLite

  ## Requisitos

  El contenedor Metabase debe tener montado el directorio de la app como volumen RW:

  ```
  -v /home/lucas/fn_registry/apps/<app_name>:/data/ops-<app-name>
  ```

  Ademas, el directorio debe tener permisos para el usuario metabase (UID 2000):

  ```bash
  docker exec -u root <container> chown -R metabase:metabase /data/ops-<app-name>/
  ```

  ## Convencion de nombres

  - Database en Metabase: `ops-<app-name>` (guiones en vez de underscores)
  - Path en contenedor: `/data/ops-<app-name>/operations.db`

code:
  """Pipeline: registra la operations.db de una app en Metabase.

  Compone: metabase_auth + metabase_list_databases + metabase_add_database.

  Requiere que el contenedor Metabase tenga montado el directorio de la app
  como volumen RW para que SQLite pueda crear journal files.

  Uso:
      python metabase_add_ops_db.py <app_name>
      python metabase_add_ops_db.py docker_tui
      python metabase_add_ops_db.py --list
  """

  import argparse
  import os
  import sys

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

  from metabase.client import metabase_auth
  from metabase import metabase_list_databases, metabase_add_database

  METABASE_URL = os.environ.get("METABASE_URL", "http://localhost:3000")
  EMAIL = os.environ.get("METABASE_ADMIN_EMAIL", "admin@fnregistry.local")
  PASSWORD = os.environ.get("METABASE_ADMIN_PASSWORD", "FnRegistry2024!")

  CONTAINER_DATA_PREFIX = "/data/ops-"


  def find_apps(registry_root: str) -> list[str]:
      """Lista apps que tienen operations.db."""
      apps_dir = os.path.join(registry_root, "apps")
      if not os.path.isdir(apps_dir):
          return []
      return sorted(
          d
          for d in os.listdir(apps_dir)
          if os.path.isfile(os.path.join(apps_dir, d, "operations.db"))
      )


  def db_name_for_app(app_name: str) -> str:
      return f"ops-{app_name.replace('_', '-')}"


  def container_path_for_app(app_name: str) -> str:
      return f"{CONTAINER_DATA_PREFIX}{app_name.replace('_', '-')}/operations.db"


  def main():
      parser = argparse.ArgumentParser(description="Registra operations.db de una app en Metabase")
      parser.add_argument("app_name", nargs="?", help="Nombre de la app (directorio en apps/)")
      parser.add_argument("--list", action="store_true", help="Lista apps disponibles y su estado en Metabase")
      args = parser.parse_args()

      registry_root = os.environ.get(
          "FN_REGISTRY_ROOT",
          os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..")),
      )

      if args.list:
          client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
          dbs = metabase_list_databases(client)
          db_names = {d["name"] for d in dbs}
          apps = find_apps(registry_root)
          print(f"Apps con operations.db ({len(apps)}):\n")
          for app in apps:
              name = db_name_for_app(app)
              status = "registrada" if name in db_names else "NO registrada"
              print(f"  {app:30s} -> {name:30s} [{status}]")
          client.close()
          return

      if not args.app_name:
          parser.error("Se requiere app_name o --list")

      app_name = args.app_name
      ops_path = os.path.join(registry_root, "apps", app_name, "operations.db")
      if not os.path.isfile(ops_path):
          print(f"ERROR: No existe {ops_path}")
          print(f"Apps disponibles: {', '.join(find_apps(registry_root))}")
          sys.exit(1)

      name = db_name_for_app(app_name)
      container_path = container_path_for_app(app_name)

      print(f"Registrando {app_name} en Metabase...")
      print(f"  Nombre:    {name}")
      print(f"  Path:      {container_path}")

      client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)

      # Verificar si ya existe
      dbs = metabase_list_databases(client)
      for db in dbs:
          if db["name"] == name:
              print(f"  Ya existe con id={db['id']}, nada que hacer.")
              client.close()
              return

      # Registrar
      result = metabase_add_database(client, name, "sqlite", {"db": container_path})
      db_id = result["id"]
      print(f"  Registrada con id={db_id}")

      # Recordar: el contenedor necesita el volumen montado
      print(f"\nIMPORTANTE: El contenedor Metabase debe tener montado:")
      print(f"  -v {os.path.join(registry_root, 'apps', app_name)}:{CONTAINER_DATA_PREFIX}{app_name.replace('_', '-')}")
      print(f"\nSi el mount no existe, hay que recrear el contenedor.")
      print(f"\nDatabase disponible en: {METABASE_URL}/question#new?database={db_id}")

      client.close()


  if __name__ == "__main__":
      main()

params_schema:
  {"params":[{"name":"app_name","desc":"Nombre de la app cuya operations.db registrar en Metabase"}],"output":"Void - registra la operations.db en Metabase como database SQLite y muestra mount requerido"}


id: metabase_create_ops_dashboard_py_pipelines
name: metabase_create_ops_dashboard
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
signature: metabase_create_ops_dashboard(app_name: str) -> None
tags: ["metabase","operations","dashboard","pipeline","infra","launcher"]
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/pipelines/metabase_create_ops_dashboard.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 781cd39262ff2a4b327855b774c2805538b9127eb7e099efb28e8399bd695bb8
description:
  Crea dashboard operativo en Metabase para una app: KPIs de entities/relations/executions/assertions, distribucion por status y tipo, relaciones frecuentes, resultados de ejecuciones y assertions.

uses_functions:
  ["metabase_auth_py_infra","metabase_list_databases_py_infra","metabase_create_card_py_infra","metabase_create_dashboard_py_infra","metabase_update_dashboard_py_infra","metabase_list_dashboards_py_infra","metabase_delete_dashboard_py_infra"]

example:
  ```bash
  # Dashboard para una app
  python python/functions/pipelines/metabase_create_ops_dashboard.py docker_tui

  # Dashboards para todas las apps registradas
  python python/functions/pipelines/metabase_create_ops_dashboard.py --all
  ```

documentation:
  ## Cards del dashboard

  | Fila | Cards | Tipo |
  |------|-------|------|
  | 0 | Entities, Relations, Executions, Assertions | scalar (KPIs) |
  | 5 | Entities por Status, Entities por Tipo, Relations por Status | pie/bar |
  | 13 | Executions Success/Failure, Assertions por Severity, Assertion Results | pie/bar |
  | 22 | Top Relaciones (via), Executions Recientes | row/table |
  | 30 | Lista de Entities | table |
  | 38 | Assertion Results Detalle | table |

  ## Flujo

  1. `metabase_auth` - autentica contra Metabase
  2. `metabase_list_databases` - busca la database operacional de la app
  3. `metabase_create_card` x 14 - crea las cards con queries SQL nativas
  4. `metabase_create_dashboard` - crea el dashboard
  5. `metabase_update_dashboard` - posiciona las cards en el grid

  ## Requisitos

  La database operacional debe estar registrada previamente con `metabase_add_ops_db`.

  Las tablas `executions`, `assertions` y `assertion_results` requieren la migracion 002. Si no existen, las cards correspondientes mostraran error (no rompen el dashboard).

  ## Convencion

  - Dashboard name: `ops: <app-name>`
  - Si el dashboard ya existe, se elimina y recrea.

code:
  """Pipeline: crea dashboard operativo en Metabase para una app.

  Compone: metabase_auth + metabase_list_databases + metabase_create_card
           + metabase_create_dashboard + metabase_update_dashboard.

  Genera un dashboard estandar con KPIs, distribucion y tablas sobre la
  operations.db de una app: entities, relations, executions, assertions.

  Uso:
      python metabase_create_ops_dashboard.py <app_name>
      python metabase_create_ops_dashboard.py docker_tui
      python metabase_create_ops_dashboard.py --all
  """

  import argparse
  import os
  import sys

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

  from metabase.client import metabase_auth
  from metabase import (
      metabase_list_databases,
      metabase_create_card,
      metabase_create_dashboard,
      metabase_update_dashboard,
      metabase_list_dashboards,
      metabase_delete_dashboard,
  )

  METABASE_URL = os.environ.get("METABASE_URL", "http://localhost:3000")
  EMAIL = os.environ.get("METABASE_ADMIN_EMAIL", "admin@fnregistry.local")
  PASSWORD = os.environ.get("METABASE_ADMIN_PASSWORD", "FnRegistry2024!")


  def dashboard_name(app_name: str) -> str:
      return f"ops: {app_name.replace('_', '-')}"


  def db_name_for_app(app_name: str) -> str:
      return f"ops-{app_name.replace('_', '-')}"


  def build_cards(app_name: str) -> list[dict]:
      """Define las cards del dashboard operativo."""
      return [
          # ---- Fila 0: KPIs (h=5) ----
          {
              "name": f"[{app_name}] Entities",
              "display": "scalar",
              "sql": "SELECT COUNT(*) FROM entities;",
              "size_x": 6, "size_y": 5, "col": 0, "row": 0,
          },
          {
              "name": f"[{app_name}] Relations",
              "display": "scalar",
              "sql": "SELECT COUNT(*) FROM relations;",
              "size_x": 6, "size_y": 5, "col": 6, "row": 0,
          },
          {
              "name": f"[{app_name}] Executions",
              "display": "scalar",
              "sql": "SELECT COUNT(*) FROM executions;",
              "size_x": 6, "size_y": 5, "col": 12, "row": 0,
          },
          {
              "name": f"[{app_name}] Assertions",
              "display": "scalar",
              "sql": "SELECT COUNT(*) FROM assertions;",
              "size_x": 6, "size_y": 5, "col": 18, "row": 0,
          },
          # ---- Fila 5: Distribucion (h=8) ----
          {
              "name": f"[{app_name}] Entities por Status",
              "display": "pie",
              "sql": "SELECT status, COUNT(*) AS cantidad FROM entities GROUP BY status ORDER BY cantidad DESC;",
              "size_x": 8, "size_y": 8, "col": 0, "row": 5,
          },
          {
              "name": f"[{app_name}] Entities por Tipo",
              "display": "bar",
              "sql": "SELECT type_ref, COUNT(*) AS cantidad FROM entities GROUP BY type_ref ORDER BY cantidad DESC;",
              "size_x": 8, "size_y": 8, "col": 8, "row": 5,
          },
          {
              "name": f"[{app_name}] Relations por Status",
              "display": "pie",
              "sql": "SELECT status, COUNT(*) AS cantidad FROM relations GROUP BY status ORDER BY cantidad DESC;",
              "size_x": 8, "size_y": 8, "col": 16, "row": 5,
          },
          # ---- Fila 13: Executions y Assertions (h=9) ----
          {
              "name": f"[{app_name}] Executions: Success vs Failure",
              "display": "pie",
              "sql": "SELECT status, COUNT(*) AS cantidad FROM executions GROUP BY status ORDER BY cantidad DESC;",
              "size_x": 8, "size_y": 9, "col": 0, "row": 13,
          },
          {
              "name": f"[{app_name}] Assertions por Severity",
              "display": "bar",
              "sql": "SELECT severity, COUNT(*) AS cantidad FROM assertions GROUP BY severity ORDER BY cantidad DESC;",
              "size_x": 8, "size_y": 9, "col": 8, "row": 13,
          },
          {
              "name": f"[{app_name}] Assertion Results",
              "display": "pie",
              "sql": "SELECT status, COUNT(*) AS cantidad FROM assertion_results GROUP BY status ORDER BY cantidad DESC;",
              "size_x": 8, "size_y": 9, "col": 16, "row": 13,
          },
          # ---- Fila 22: Relaciones frecuentes (h=8) ----
          {
              "name": f"[{app_name}] Top Relaciones (via)",
              "display": "row",
              "sql": """
                  SELECT
                      CASE WHEN via = '' THEN '(directo)' ELSE via END AS via_funcion,
                      COUNT(*) AS cantidad
                  FROM relations
                  GROUP BY via
                  ORDER BY cantidad DESC
                  LIMIT 15;
              """,
              "size_x": 12, "size_y": 8, "col": 0, "row": 22,
          },
          {
              "name": f"[{app_name}] Executions Recientes",
              "display": "table",
              "sql": """
                  SELECT
                      id,
                      pipeline_id,
                      status,
                      duration_ms,
                      records_in,
                      records_out,
                      CASE WHEN error = '' THEN '-' ELSE error END AS error,
                      started_at
                  FROM executions
                  ORDER BY started_at DESC
                  LIMIT 20;
              """,
              "size_x": 12, "size_y": 8, "col": 12, "row": 22,
          },
          # ---- Fila 30: Tablas detalle (h=8) ----
          {
              "name": f"[{app_name}] Lista de Entities",
              "display": "table",
              "sql": """
                  SELECT
                      id,
                      name,
                      type_ref,
                      status,
                      domain,
                      source,
                      created_at,
                      updated_at
                  FROM entities
                  ORDER BY updated_at DESC;
              """,
              "size_x": 24, "size_y": 8, "col": 0, "row": 30,
          },
          {
              "name": f"[{app_name}] Assertion Results Detalle",
              "display": "table",
              "sql": """
                  SELECT
                      ar.id,
                      a.name AS assertion,
                      a.kind,
                      a.severity,
                      ar.status,
                      ar.value,
                      ar.message,
                      ar.evaluated_at
                  FROM assertion_results ar
                  JOIN assertions a ON a.id = ar.assertion_id
                  ORDER BY ar.evaluated_at DESC
                  LIMIT 50;
              """,
              "size_x": 24, "size_y": 8, "col": 0, "row": 38,
          },
      ]


  def create_ops_dashboard(client, app_name: str, db_id: int) -> int:
      """Crea el dashboard operativo para una app. Retorna dashboard_id."""
      dash_name = dashboard_name(app_name)

      # Eliminar dashboard existente si hay
      for d in metabase_list_dashboards(client):
          if d.get("name") == dash_name:
              print(f"  Dashboard existente (id={d['id']}), recreando...")
              metabase_delete_dashboard(client, d["id"])

      cards_def = build_cards(app_name)

      print(f"  Creando {len(cards_def)} cards...")
      created = []
      for i, card_def in enumerate(cards_def):
          card = metabase_create_card(
              client,
              name=card_def["name"],
              dataset_query={
                  "database": db_id,
                  "type": "native",
                  "native": {"query": card_def["sql"]},
              },
              display=card_def["display"],
              description=f"ops dashboard: {app_name}",
          )
          created.append((card, card_def))
          print(f"    [{i+1}/{len(cards_def)}] {card_def['name']} (id={card['id']})")

      print("  Creando dashboard...")
      dashboard = metabase_create_dashboard(
          client,
          name=dash_name,
          description=f"Dashboard operativo de {app_name}: entities, relations, executions, assertions.",
      )
      dash_id = dashboard["id"]

      dashcards = []
      for idx, (card, card_def) in enumerate(created):
          dashcards.append({
              "id": -(idx + 1),
              "card_id": card["id"],
              "size_x": card_def["size_x"],
              "size_y": card_def["size_y"],
              "col": card_def["col"],
              "row": card_def["row"],
          })

      metabase_update_dashboard(client, dash_id, dashcards=dashcards)
      return dash_id


  def main():
      parser = argparse.ArgumentParser(description="Crea dashboard operativo en Metabase para una app")
      parser.add_argument("app_name", nargs="?", help="Nombre de la app")
      parser.add_argument("--all", action="store_true", help="Crea dashboards para todas las apps registradas en Metabase")
      args = parser.parse_args()

      if not args.app_name and not args.all:
          parser.error("Se requiere app_name o --all")

      print("Autenticando en Metabase...")
      client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
      dbs = metabase_list_databases(client)

      if args.all:
          ops_dbs = [
              (db["name"].replace("ops-", "").replace("-", "_"), db["id"])
              for db in dbs
              if db["name"].startswith("ops-")
          ]
          if not ops_dbs:
              print("No hay databases operacionales registradas en Metabase.")
              sys.exit(1)

          print(f"Creando dashboards para {len(ops_dbs)} apps...\n")
          for app_name, db_id in ops_dbs:
              print(f"--- {app_name} (db_id={db_id}) ---")
              dash_id = create_ops_dashboard(client, app_name, db_id)
              print(f"  Dashboard: {METABASE_URL}/dashboard/{dash_id}\n")
      else:
          app_name = args.app_name
          expected_db_name = f"ops-{app_name.replace('_', '-')}"
          db_id = None
          for db in dbs:
              if db["name"] == expected_db_name:
                  db_id = db["id"]
                  break

          if not db_id:
              print(f"ERROR: Database '{expected_db_name}' no encontrada en Metabase.")
              print("Databases disponibles:")
              for db in dbs:
                  print(f"  - {db['name']} (id={db['id']})")
              print(f"\nRegistra primero con: python metabase_add_ops_db.py {app_name}")
              sys.exit(1)

          print(f"App: {app_name} (db_id={db_id})")
          dash_id = create_ops_dashboard(client, app_name, db_id)
          print(f"\nDashboard listo: {METABASE_URL}/dashboard/{dash_id}")

      client.close()


  if __name__ == "__main__":
      main()

params_schema:
  {"params":[{"name":"app_name","desc":"Nombre de la app para la que crear dashboard operativo"}],"output":"Void - crea dashboard en Metabase con KPIs, distribuciones y tablas de entities/executions/assertions"}


id: metabase_fix_permissions_py_pipelines
name: metabase_fix_permissions
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
signature: metabase_fix_permissions() -> None
tags: ["metabase","sqlite","permissions","docker","pipeline","infra","launcher"]
uses_functions: ["metabase_auth_py_infra","metabase_list_databases_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: []
tests: []
file_path: python/functions/pipelines/metabase_fix_permissions.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 6dfe42e666545a0e1e36ca227d29ef34a400f10571a9fb273e1f6188554670f1
description:
  Arregla permisos SQLITE_READONLY_DIRECTORY en el contenedor Metabase. Hace chmod 777/666 en directorios y archivos .db bajo /data/ para que el usuario metabase (UID 2000) pueda crear journal files.

example:
  ```bash
  # Fix permisos (usa contenedor por defecto: fn_registry-metabase)
  python python/functions/pipelines/metabase_fix_permissions.py

  # Contenedor custom
  python python/functions/pipelines/metabase_fix_permissions.py --container mi-metabase
  ```

documentation:
  ## Problema

  Metabase corre Java como UID 2000 (usuario `metabase`). SQLite necesita crear
  journal/WAL files en el mismo directorio que la BD. Si el directorio no es
  escribible por UID 2000, Metabase devuelve:

  ```
  SQLITE_READONLY_DIRECTORY: Process does not have permission to create a
  journal file in the same directory as the database
  ```

  ## Solucion

  El pipeline ejecuta dentro del contenedor (como root):
  - `chmod 777` en cada directorio que contiene un `.db`
  - `chmod 666` en cada archivo `.db`, `-wal`, `-shm`, `-journal`

  Esto NO cambia ownership (evita que `chown` se propague al host via bind mount).

  ## Cuando ejecutar

  - Despues de recrear el contenedor Metabase
  - Despues de añadir una nueva database con `metabase_add_ops_db`
  - Cuando Metabase muestra error SQLITE_READONLY_DIRECTORY

code:
  """Pipeline: arregla permisos de SQLite en el contenedor Metabase.

  Compone: metabase_auth + metabase_list_databases (para verificar).

  SQLite necesita escribir journal/WAL files en el mismo directorio que la BD.
  El contenedor Metabase corre Java como UID 2000 (usuario metabase).
  Si los directorios o archivos no son escribibles por ese UID, Metabase
  devuelve SQLITE_READONLY_DIRECTORY.

  Este pipeline:
  1. Detecta el contenedor Metabase
  2. Encuentra todos los directorios /data/ que contienen .db
  3. Hace chmod 777 en directorios y chmod 666 en archivos .db
  4. Verifica que Metabase puede leer cada database

  Uso:
      python metabase_fix_permissions.py
      python metabase_fix_permissions.py --container fn_registry-metabase
  """

  import argparse
  import os
  import subprocess
  import sys

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

  from metabase.client import metabase_auth
  from metabase import metabase_list_databases

  METABASE_URL = os.environ.get("METABASE_URL", "http://localhost:3000")
  EMAIL = os.environ.get("METABASE_ADMIN_EMAIL", "admin@fnregistry.local")
  PASSWORD = os.environ.get("METABASE_ADMIN_PASSWORD", "FnRegistry2024!")
  DEFAULT_CONTAINER = "fn_registry-metabase"


  def docker_exec(container: str, cmd: list[str], user: str = "root") -> str:
      """Ejecuta comando dentro del contenedor."""
      result = subprocess.run(
          ["docker", "exec", "-u", user, container] + cmd,
          capture_output=True, text=True,
      )
      return result.stdout.strip()


  def fix_container_permissions(container: str) -> list[str]:
      """Arregla permisos de todos los .db en /data/ del contenedor.

      Returns:
          Lista de paths arreglados.
      """
      # Encontrar todos los .db bajo /data/
      find_output = docker_exec(container, ["find", "/data", "-name", "*.db", "-type", "f"])
      if not find_output:
          print("  No se encontraron archivos .db en /data/")
          return []

      db_files = [f.strip() for f in find_output.splitlines() if f.strip()]
      fixed = []

      for db_path in db_files:
          db_dir = os.path.dirname(db_path)

          # Fix directorio: 777 para que UID 2000 pueda crear journal
          docker_exec(container, ["chmod", "777", db_dir])

          # Fix archivo: 666 para que UID 2000 pueda leer/escribir
          docker_exec(container, ["chmod", "666", db_path])

          # Fix WAL/SHM si existen
          for suffix in ["-wal", "-shm", "-journal"]:
              wal_path = db_path + suffix
              docker_exec(container, ["chmod", "666", wal_path])

          fixed.append(db_path)
          print(f"  Fixed: {db_path} (dir: {db_dir})")

      return fixed


  def verify_databases(client) -> bool:
      """Verifica que todas las databases SQLite responden."""
      import httpx

      dbs = metabase_list_databases(client)
      all_ok = True

      for db in dbs:
          if db.get("engine") != "sqlite":
              continue

          name = db["name"]
          db_id = db["id"]

          try:
              resp = client._http.request("POST", "/api/dataset", json={
                  "database": db_id,
                  "type": "native",
                  "native": {"query": "SELECT 1"},
              }, extensions={"timeout": {"read": 60, "write": 10, "connect": 10, "pool": 10}})

              if resp.status_code in (200, 202):
                  print(f"  OK: {name} (id={db_id})")
              else:
                  error = resp.text[:150]
                  print(f"  FAIL: {name} (id={db_id}) -> {error}")
                  all_ok = False
          except httpx.ReadTimeout:
              print(f"  TIMEOUT: {name} (id={db_id}) -> Metabase aun sincronizando, reintentar en unos segundos")
              all_ok = False

      return all_ok


  def main():
      parser = argparse.ArgumentParser(description="Arregla permisos SQLite en contenedor Metabase")
      parser.add_argument("--container", default=DEFAULT_CONTAINER, help="Nombre del contenedor Metabase")
      args = parser.parse_args()

      container = args.container

      # Verificar que el contenedor existe
      result = subprocess.run(
          ["docker", "inspect", container, "--format", "{{.State.Running}}"],
          capture_output=True, text=True,
      )
      if result.returncode != 0 or result.stdout.strip() != "true":
          print(f"ERROR: Contenedor '{container}' no esta corriendo.")
          sys.exit(1)

      print(f"Contenedor: {container}")
      print(f"\n1. Arreglando permisos en /data/...")
      fixed = fix_container_permissions(container)

      if not fixed:
          print("  Nada que arreglar.")
          return

      print(f"\n2. Verificando databases en Metabase...")
      client = metabase_auth(METABASE_URL, EMAIL, PASSWORD)
      ok = verify_databases(client)
      client.close()

      if ok:
          print(f"\nTodas las databases responden correctamente.")
      else:
          print(f"\nAlgunas databases siguen fallando. Revisar logs del contenedor:")
          print(f"  docker logs {container} --tail 50")


  if __name__ == "__main__":
      main()

params_schema:
  {"output":"Void - arregla permisos SQLITE_READONLY_DIRECTORY en contenedor Metabase vía chmod 777/666"}


id: monte_carlo_market_py_pipelines
name: monte_carlo_market
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
tags: ["montecarlo","simulation","market","launcher","finance","microstructure"]
uses_functions: ["run_market_sim_py_pipelines"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["numpy"]
tests: []
file_path: python/functions/pipelines/monte_carlo_market.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 927980627a68c303b1d87a77345c99cfa2dad303e0c26594d1bf6a9251512614
signature:
  def monte_carlo_market(n_simulations: int, base_params: dict, vary_params: dict, seed_start: int) -> list[dict]

description:
  Ejecuta N simulaciones de mercado con parámetros variados uniformemente. Cada simulación usa run_market_sim y retorna métricas resumen: spreads, trades por tick, volatilidad realizada y PnL total de makers.

example:
  ```bash
  # 10 simulaciones con sigma y gamma variables
  python python/functions/pipelines/monte_carlo_market.py -n 10
  ```

  ```python
  from monte_carlo_market import monte_carlo_market

  results = monte_carlo_market(
      n_simulations=50,
      base_params={'n_ticks': 300, 'n_makers': 3},
      vary_params={
          'sigma': (0.005, 0.05),
          'gamma': (0.01, 1.0),
          'hawkes_alpha': (0.1, 0.9),
      },
      seed_start=42,
  )
  # Cada resultado tiene: sim_id, seed, sigma, gamma, hawkes_alpha,
  # total_trades, mean_spread, std_spread, mean_trades_per_tick,
  # price_return, maker_total_pnl, realized_vol
  ```

notes:
  `vary_params` acepta cualquier parámetro válido de `run_market_sim` como clave, con valor `(min, max)`.
  Los parámetros en `base_params` tienen precedencia sobre los defaults pero son sobreescritos por `vary_params`.

documentation:
  ## Flujo

  1. Para cada simulación i en range(n_simulations):
     - Tomar `base_params` + `seed = seed_start + i`
     - Samplear `vary_params` uniformemente con rng derivado de `seed_start`
     - Llamar `run_market_sim(**params)`
     - Calcular métricas resumen sobre el resultado
  2. Reportar progreso cada 10% de simulaciones
  3. Retornar lista de dicts con params usados + métricas

  ## Métricas por simulación

  | Campo | Descripción |
  |---|---|
  | `total_trades` | Número total de trades en la simulación |
  | `mean_spread` | Spread bid-ask medio |
  | `std_spread` | Desviación estándar del spread |
  | `mean_trades_per_tick` | Intensidad media del flujo de órdenes |
  | `price_return` | Retorno % del precio fundamental |
  | `maker_total_pnl` | PnL agregado de todos los makers |
  | `realized_vol` | Volatilidad realizada de los trade prices (si hay trades) |

code:
  """Ejecuta N simulaciones de mercado con parámetros variables para análisis Monte Carlo."""

  import sys
  import os
  import json


  def monte_carlo_market(
      n_simulations: int = 100,
      base_params: dict | None = None,
      vary_params: dict | None = None,
      seed_start: int = 0,
  ) -> list[dict]:
      """Ejecuta N simulaciones variando parámetros.

      base_params: parámetros fijos para run_market_sim
      vary_params: dict de param_name -> (min, max) para variar uniformemente

      Retorna lista de dicts, cada uno con los params usados + métricas resumen.
      """
      import numpy as np

      sys.path.insert(0, os.path.join(os.environ.get('FN_REGISTRY_ROOT', os.path.expanduser('~/fn_registry')), 'python', 'functions'))
      sys.path.insert(0, os.path.join(os.environ.get('FN_REGISTRY_ROOT', os.path.expanduser('~/fn_registry')), 'python', 'functions', 'pipelines'))
      from run_market_sim import run_market_sim

      if base_params is None:
          base_params = {}
      if vary_params is None:
          vary_params = {}

      rng = np.random.default_rng(seed_start)
      results = []

      for i in range(n_simulations):
          params = dict(base_params)
          params['seed'] = seed_start + i

          # Variar parámetros
          varied = {}
          for pname, (pmin, pmax) in vary_params.items():
              val = rng.uniform(pmin, pmax)
              params[pname] = round(val, 6)
              varied[pname] = params[pname]

          sim = run_market_sim(**params)

          # Métricas resumen
          spreads = sim['spreads']
          trade_prices = sim['trade_prices']
          n_per_tick = sim['n_trades_per_tick']

          result = {
              'sim_id': i,
              'seed': params['seed'],
              **varied,
              'total_trades': sim['total_trades'],
              'mean_spread': round(np.mean(spreads), 6) if spreads else 0,
              'std_spread': round(np.std(spreads), 6) if spreads else 0,
              'mean_trades_per_tick': round(np.mean(n_per_tick), 2),
              'price_return': round((sim['fundamental_prices'][-1] / sim['fundamental_prices'][0] - 1) * 100, 4),
              'maker_total_pnl': round(sum(sim['maker_pnls']), 2),
          }

          if trade_prices:
              tp = np.array(trade_prices)
              log_ret = np.diff(np.log(tp[tp > 0]))
              if len(log_ret) > 1:
                  result['realized_vol'] = round(float(np.std(log_ret)), 6)

          results.append(result)

          if (i + 1) % max(1, n_simulations // 10) == 0:
              print(f'  {i+1}/{n_simulations} simulaciones completadas')

      return results


  if __name__ == '__main__':
      import argparse
      parser = argparse.ArgumentParser()
      parser.add_argument('-n', type=int, default=10)
      args = parser.parse_args()

      results = monte_carlo_market(
          n_simulations=args.n,
          base_params={'n_ticks': 200},
          vary_params={'sigma': (0.005, 0.05), 'gamma': (0.01, 1.0)},
      )
      print(json.dumps(results[-1], indent=2))
      print(f'\n{len(results)} simulaciones completadas')

params_schema:
  {"params":[{"name":"n_simulations","desc":"Número de simulaciones de mercado a ejecutar"},{"name":"base_params","desc":"Parámetros base aplicados a todas las simulaciones"},{"name":"vary_params","desc":"Parámetros a variar uniformemente: {param: (min, max)}"},{"name":"seed_start","desc":"Seed inicial para generador aleatorio (se incrementa por simulación)"}],"output":"Lista de dicts con parámetros usados y métricas: spreads, trades, volatilidad, PnL"}


id: run_market_sim_py_pipelines
name: run_market_sim
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
tags: ["simulation","market","matching-engine","montecarlo","launcher","finance","microstructure"]
uses_functions: ["generate_gbm_prices_py_finance","avellaneda_stoikov_quotes_py_finance"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["numpy"]
tests: []
file_path: python/functions/pipelines/run_market_sim.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-05T16:40:39Z
props: []
emits: []
variant: []
content_hash: 6eca9928c5ab0d24283d3d18660aff9d425bfeab55e272112b41341b9df0ddc8
signature:
  def run_market_sim(initial_price: float, n_ticks: int, sigma: float, mu: float, jump_intensity: float, jump_size_std: float, n_makers: int, maker_spread: float, gamma: float, maker_levels: int, maker_qty: float, n_takers_lambda: float, taker_size_alpha: float, taker_size_min: float, taker_size_max: float, hawkes_alpha: float, hawkes_beta: float, seed: int) -> dict

description:
  Simula un mercado completo con matching engine FIFO. Makers usan Avellaneda-Stoikov, takers llegan según proceso Hawkes con tamaños power-law. Retorna trades, spreads, midprices y PnL de makers.

example:
  ```bash
  python python/functions/pipelines/run_market_sim.py
  # {
  #   "total_trades": 1234,
  #   "mean_spread": 0.4821,
  #   "maker_pnls": [12.5, -3.2, 8.1, 5.6, -1.4]
  # }
  ```

  ```python
  from run_market_sim import run_market_sim

  result = run_market_sim(
      initial_price=100.0,
      n_ticks=200,
      sigma=0.01,
      n_makers=3,
      seed=0,
  )
  print(result['total_trades'])
  print(result['maker_pnls'])
  ```

notes:
  Los parámetros Hawkes (`hawkes_alpha`, `hawkes_beta`) controlan la autocorrelación del flujo de órdenes.
  `branching_ratio = hawkes_alpha / hawkes_beta`; si > 1, el proceso es explosivo.
  El matching es simplificado: no hay cancelaciones intra-tick, el book se reconstituye en cada tick.

documentation:
  ## Flujo

  1. `generate_gbm_prices` — genera la serie de precios fundamentales con GBM + saltos
  2. Loop por ticks:
     - Cada maker coloca quotes via `avellaneda_stoikov_quotes`
     - Takers llegan según Poisson con intensidad modulada por excitación Hawkes
     - Tamaños de taker siguen distribución Pareto (power-law)
     - Matching FIFO sobre el order book simplificado
     - Excitación Hawkes decae exponencialmente entre ticks
  3. Mark-to-market final de inventarios de makers

code:
  """Ejecuta una simulación de mercado completa con matching engine FIFO."""

  import sys
  import os
  import json


  def run_market_sim(
      initial_price: float = 100.0,
      n_ticks: int = 500,
      sigma: float = 0.02,
      mu: float = 0.0,
      jump_intensity: float = 0.02,
      jump_size_std: float = 0.05,
      n_makers: int = 5,
      maker_spread: float = 0.5,
      gamma: float = 0.1,
      maker_levels: int = 3,
      maker_qty: float = 10.0,
      n_takers_lambda: float = 2.0,
      taker_size_alpha: float = 2.0,
      taker_size_min: float = 1.0,
      taker_size_max: float = 100.0,
      hawkes_alpha: float = 0.5,
      hawkes_beta: float = 1.0,
      seed: int = 42,
  ) -> dict:
      """Simula un mercado con makers (Avellaneda-Stoikov) y takers (Hawkes + power-law).

      Retorna dict con:
        - trade_prices, trade_times, trade_sizes: listas de trades
        - spreads, midprices: series por tick
        - n_trades_per_tick: arrivals por tick
        - maker_pnls: PnL final de cada maker
        - total_trades: conteo total
      """
      import numpy as np

      # Importar funciones del registry
      sys.path.insert(0, os.path.join(os.environ.get('FN_REGISTRY_ROOT', os.path.expanduser('~/fn_registry')), 'python', 'functions'))
      from finance.finance import generate_gbm_prices, avellaneda_stoikov_quotes

      rng = np.random.default_rng(seed)

      # Generar precios fundamentales
      fund_prices = generate_gbm_prices(initial_price, n_ticks, sigma, mu, jump_intensity, jump_size_std, seed)

      # Order book simplificado: listas de (price, qty, maker_idx)
      # Matching inline para no depender del notebook
      trade_prices, trade_times, trade_sizes = [], [], []
      spreads, midprices = [], []
      n_trades_per_tick = []
      maker_inventories = [0.0] * n_makers
      maker_pnls = [0.0] * n_makers
      hawkes_excitation = 0.0

      for t in range(n_ticks):
          mid = fund_prices[t]

          # Makers place orders
          all_bids = []  # (price, qty, maker_idx)
          all_asks = []
          for m in range(n_makers):
              noise = rng.uniform(-0.05, 0.05)
              quotes = avellaneda_stoikov_quotes(
                  mid + noise, maker_inventories[m], gamma, sigma, maker_spread, maker_levels, maker_qty
              )
              for q in quotes:
                  if q['side'] == 'buy':
                      all_bids.append((q['price'], q['qty'], m))
                  else:
                      all_asks.append((q['price'], q['qty'], m))

          all_bids.sort(key=lambda x: -x[0])  # best bid first
          all_asks.sort(key=lambda x: x[0])    # best ask first

          # Record book state
          if all_bids and all_asks:
              spreads.append(all_asks[0][0] - all_bids[0][0])
              midprices.append((all_bids[0][0] + all_asks[0][0]) / 2)
          else:
              spreads.append(0.0)
              midprices.append(mid)

          # Takers arrive (Hawkes)
          lam = max(0.1, n_takers_lambda + hawkes_excitation)
          n_takers = rng.poisson(lam)
          tick_trades = 0

          for _ in range(n_takers):
              side = 'buy' if rng.random() < 0.5 else 'sell'
              raw_size = (rng.pareto(taker_size_alpha) + 1) * taker_size_min
              qty_remaining = min(round(raw_size, 1), taker_size_max)

              book = list(all_asks) if side == 'buy' else list(all_bids)

              for i, (price, available, maker_idx) in enumerate(book):
                  if qty_remaining <= 0:
                      break
                  fill = min(qty_remaining, available)
                  trade_prices.append(price)
                  trade_times.append(t)
                  trade_sizes.append(fill)
                  tick_trades += 1
                  qty_remaining -= fill

                  if side == 'buy':
                      maker_inventories[maker_idx] -= fill
                      maker_pnls[maker_idx] += price * fill
                  else:
                      maker_inventories[maker_idx] += fill
                      maker_pnls[maker_idx] -= price * fill

                  book[i] = (price, available - fill, maker_idx)

              if side == 'buy':
                  all_asks = [(p, q, m) for p, q, m in book if q > 0]
              else:
                  all_bids = [(p, q, m) for p, q, m in book if q > 0]

          hawkes_excitation *= np.exp(-hawkes_beta)
          hawkes_excitation += hawkes_alpha * tick_trades
          n_trades_per_tick.append(tick_trades)

      # Mark to market
      final_price = fund_prices[-1]
      for m in range(n_makers):
          maker_pnls[m] += maker_inventories[m] * final_price

      return {
          'trade_prices': trade_prices,
          'trade_times': trade_times,
          'trade_sizes': trade_sizes,
          'spreads': spreads,
          'midprices': midprices,
          'n_trades_per_tick': n_trades_per_tick,
          'fundamental_prices': fund_prices,
          'maker_pnls': [round(p, 2) for p in maker_pnls],
          'total_trades': len(trade_prices),
      }


  if __name__ == '__main__':
      result = run_market_sim()
      print(json.dumps({
          'total_trades': result['total_trades'],
          'mean_spread': round(sum(result['spreads']) / len(result['spreads']), 4),
          'maker_pnls': result['maker_pnls'],
      }, indent=2))

params_schema:
  {"params":[{"name":"initial_price","desc":"Precio inicial del activo"},{"name":"n_ticks","desc":"Número de ticks a simular"},{"name":"sigma","desc":"Volatilidad del precio fundamental"},{"name":"mu","desc":"Drift del precio fundamental"},{"name":"jump_intensity","desc":"Intensidad de saltos de Poisson"},{"name":"jump_size_std","desc":"Desviación estándar de tamaños de salto"},{"name":"n_makers","desc":"Número de market makers"},{"name":"gamma","desc":"Parámetro de aversión al riesgo de Avellaneda-Stoikov"},{"name":"hawkes_alpha","desc":"Parámetro de excitación del proceso Hawkes"},{"name":"seed","desc":"Seed para reproducibilidad"}],"output":"Dict con trades, spreads, midprices, PnL de makers y estadísticas del matching"}


