# functions

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-13T10:26:52Z
props: []
emits: []
variant: []
content_hash: ff834b22cd7fae3838c8b6d2d271c40b61d72284e67312cd8131f12aa002d7ad
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)


  def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict:
      """Crea una card en Metabase con payload completo ya construido por el caller.

      Version raw de metabase_create_card. El caller es responsable de construir
      el payload completo antes de llamar a esta funcion — no se realiza ninguna
      validacion ni transformacion local. Util para flujos "Metabase as code"
      donde el YAML define todos los campos de la card tal como los espera la API.

      Endpoint: POST /api/card.

      El payload minimo necesita:
          - name (str): nombre de la card.
          - dataset_query (dict): query SQL nativa o MBQL.
          - display (str): tipo de visualizacion (table, bar, scalar, etc.).

      Campos opcionales que esta funcion preserva (a diferencia de metabase_create_card):
          - visualization_settings (dict): configuracion detallada del grafico.
          - parameters (list[dict]): parametros de la query con template tags.
          - parameter_mappings (list[dict]): mapeo de parametros a dashboard filters.
          - type (str): "question" (default), "model", "metric".
          - collection_id (int): ID de coleccion destino.
          - description (str): descripcion de la card.
          - archived (bool): estado de archivo inicial.
          - enable_embedding (bool): habilitar embedding publico.
          - embedding_params (dict): configuracion de embedding.

      Si Metabase devuelve 4xx/5xx, httpx lanza HTTPStatusError sin capturar.

      Args:
          client: Cliente autenticado con sesion activa.
          payload: Dict con el payload completo de la card tal como lo espera
                   la API de Metabase. Se envia sin modificaciones.

      Returns:
          Dict con la card recien creada. Incluye el campo `id` asignado por
          Metabase y todos los campos normalizados (display, dataset_query,
          visualization_settings, created_at, etc.).

      Example:
          >>> card = metabase_create_card_raw(client, {
          ...     "name": "Revenue by Month",
          ...     "dataset_query": {
          ...         "database": 1,
          ...         "type": "native",
          ...         "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
          ...     },
          ...     "display": "line",
          ...     "visualization_settings": {
          ...         "graph.x_axis.title_text": "Month",
          ...         "graph.y_axis.title_text": "Revenue",
          ...     },
          ...     "description": "Monthly revenue trend",
          ...     "collection_id": 5,
          ... })
          >>> print(card["id"])  # ID asignado por Metabase
      """
      return client.request("POST", "/api/card", json=payload)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"name","desc":"nombre descriptivo de la pregunta/card"},{"name":"dataset_query","desc":"dict con configuración de query (SQL nativa o MBQL)"},{"name":"display","desc":"tipo de visualización (table, scalar, bar, etc.)"},{"name":"collection_id","desc":"ID de colección donde guardar la card (0=root)"},{"name":"description","desc":"descripción opcional de la card"}],"output":"dict: objeto card creado con id y toda su configuración"}


id: metabase_create_card_raw_py_infra
name: metabase_create_card_raw
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict
tags: ["metabase","card","question","create","api","python","raw","as-code"]
uses_functions: ["metabase_auth_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/cards.py
created_at: 2026-04-13T10:26:52Z
updated_at: 2026-04-13T10:26:52Z
props: []
emits: []
variant: []
content_hash: 139528876b0d70d42be9bfa9909cfc668bceff167dee3ecf06e5c2436223e844
description:
  Crea una card en Metabase enviando el payload completo sin modificaciones. Version raw para flujos Metabase-as-code donde el caller construye el body entero. Endpoint: POST /api/card.

example:
  ```python
  card = metabase_create_card_raw(client, {
      "name": "Revenue by Month",
      "dataset_query": {
          "database": 1,
          "type": "native",
          "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
      },
      "display": "line",
      "visualization_settings": {
          "graph.x_axis.title_text": "Month",
          "graph.y_axis.title_text": "Revenue",
      },
      "description": "Monthly revenue trend",
      "collection_id": 5,
  })
  print(card["id"])  # ID asignado por Metabase
  ```

notes:
  No se escriben tests automaticos porque requiere una instancia real de Metabase.
  Los intentos de mockear `client.request` quedan fuera del alcance del registry
  por ahora — la validacion se hace en integracion contra un entorno Metabase real.

  A diferencia de `metabase_create_card`, esta funcion no descarta ni transforma
  ningun campo del payload: lo envia tal cual al endpoint. Esto permite pasar
  `visualization_settings`, `parameters`, `embedding_params`, `type`, etc. sin
  que la funcion los filtre.

  Si Metabase devuelve 4xx/5xx, httpx lanza `HTTPStatusError` sin capturar.
  El caller debe manejar errores si necesita recuperacion.

code:
  """CRUD de cards/preguntas de Metabase y ejecucion de queries."""

  from .client import MetabaseClient


  def metabase_list_cards(
      client: MetabaseClient,
      filter: str = "",
      model_id: int = 0,
  ) -> list[dict]:
      """Lista preguntas/cards de Metabase con filtro opcional.

      Endpoint: GET /api/card. No tiene paginacion offset/limit.

      Args:
          client: Cliente autenticado.
          filter: "all", "mine", "fav", "archived", "recent", "popular",
                  "database", "table". Vacio = todas.
          model_id: ID de database/tabla. Solo aplica con filter "database" o "table".

      Returns:
          Lista de dicts, cada uno con: id, name, description, display,
          collection_id, database_id, creator_id, archived, dataset_query.

      Example:
          >>> cards = metabase_list_cards(client, filter="mine")
          >>> for c in cards:
          ...     print(c["id"], c["name"], c["display"])
      """
      params = {}
      if filter:
          params["f"] = filter
      if model_id > 0:
          params["model_id"] = model_id
      return client.request("GET", "/api/card", params=params)


  def metabase_get_card(client: MetabaseClient, card_id: int) -> dict:
      """Obtiene los detalles completos de una card/pregunta.

      Endpoint: GET /api/card/:id.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.

      Returns:
          Dict con: id, name, description, display, dataset_query,
          visualization_settings, collection_id, database_id, archived,
          creator, created_at, updated_at.

      Example:
          >>> card = metabase_get_card(client, 42)
          >>> print(card["name"], card["display"])
          >>> print(card["dataset_query"]["native"]["query"])  # SQL
      """
      return client.request("GET", f"/api/card/{card_id}")


  def metabase_create_card(
      client: MetabaseClient,
      name: str,
      dataset_query: dict,
      display: str = "table",
      collection_id: int = 0,
      description: str = "",
  ) -> dict:
      """Crea una nueva card/pregunta en Metabase.

      Endpoint: POST /api/card.

      Args:
          client: Cliente autenticado.
          name: Nombre de la pregunta.
          dataset_query: Query de la card. Estructura:
              SQL nativo: {"database": 1, "type": "native", "native": {"query": "SELECT ..."}}
              MBQL: {"database": 1, "type": "query", "query": {"source-table": 4, ...}}
          display: Tipo de visualizacion: "table", "bar", "line", "pie", "scalar",
                   "area", "row", "combo", "funnel", "scatter", "waterfall", etc.
          collection_id: ID de coleccion destino. 0 = root.
          description: Descripcion opcional.

      Returns:
          Dict con la card creada.

      Example:
          >>> card = metabase_create_card(client, "Revenue by Month", {
          ...     "database": 1,
          ...     "type": "native",
          ...     "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
          ... }, display="line", description="Monthly revenue trend")
      """
      body: dict = {
          "name": name,
          "dataset_query": dataset_query,
          "display": display,
          "visualization_settings": {},
      }
      if collection_id > 0:
          body["collection_id"] = collection_id
      if description:
          body["description"] = description
      return client.request("POST", "/api/card", json=body)


  def metabase_update_card(client: MetabaseClient, card_id: int, **fields) -> dict:
      """Actualiza campos de una card/pregunta en Metabase.

      Endpoint: PUT /api/card/:id. Solo se modifican los campos pasados.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card.
          **fields: Campos a actualizar. Validos:
              name (str), description (str), display (str),
              dataset_query (dict), visualization_settings (dict),
              collection_id (int), archived (bool),
              enable_embedding (bool), embedding_params (dict).

      Returns:
          Dict con la card actualizada.

      Example:
          >>> metabase_update_card(client, 42, name="Updated Name", archived=True)
          >>> metabase_update_card(client, 42, dataset_query={
          ...     "database": 1, "type": "native",
          ...     "native": {"query": "SELECT * FROM users LIMIT 100"},
          ... })
      """
      return client.request("PUT", f"/api/card/{card_id}", json=fields)


  def metabase_delete_card(client: MetabaseClient, card_id: int) -> None:
      """Elimina permanentemente una card/pregunta.

      Endpoint: DELETE /api/card/:id. IRREVERSIBLE.
      Para soft-delete preferir: metabase_update_card(client, card_id, archived=True)

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a eliminar.

      Example:
          >>> metabase_delete_card(client, 42)
          >>> # Preferir soft-delete: metabase_update_card(client, 42, archived=True)
      """
      client.request("DELETE", f"/api/card/{card_id}")


  def metabase_execute_card(
      client: MetabaseClient,
      card_id: int,
      parameters: list[dict] | None = None,
  ) -> dict:
      """Ejecuta la query de una card/pregunta guardada.

      Endpoint: POST /api/card/:id/query.

      Args:
          client: Cliente autenticado.
          card_id: ID de la card a ejecutar.
          parameters: Parametros para queries parametrizadas. Cada parametro:
              {"type": "category", "target": ["variable", ["template-tag", "tag"]], "value": "val"}

      Returns:
          Dict con resultados:
          - status: "completed" o "failed"
          - row_count: numero de filas
          - running_time: tiempo en ms
          - data.columns: nombres de columnas
          - data.rows: filas de datos (lista de listas)
          - data.cols: metadata de columnas
          - data.native_form.query: SQL ejecutado

      Example:
          >>> result = metabase_execute_card(client, 42)
          >>> for row in result["data"]["rows"]:
          ...     print(row)
          >>> # Con parametros:
          >>> result = metabase_execute_card(client, 42, parameters=[
          ...     {"type": "category", "target": ["variable", ["template-tag", "status"]], "value": "active"},
          ... ])
      """
      body = {}
      if parameters:
          body["parameters"] = parameters
      return client.request("POST", f"/api/card/{card_id}/query", json=body or None)


  def metabase_execute_query(
      client: MetabaseClient,
      database_id: int,
      sql: str,
      max_results: int = 0,
  ) -> dict:
      """Ejecuta una query SQL ad-hoc sin guardarla como card.

      Endpoint: POST /api/dataset. Util para exploracion rapida y consultas
      que no necesitan persistirse.

      Args:
          client: Cliente autenticado.
          database_id: ID de la database en Metabase.
          sql: Query SQL a ejecutar.
          max_results: Limite de filas. 0 = default 2000.

      Returns:
          Dict con misma estructura que metabase_execute_card:
          data.columns, data.rows, row_count, running_time, status.

      Example:
          >>> result = metabase_execute_query(client, 1, "SELECT * FROM users LIMIT 10")
          >>> print(f"{result['row_count']} filas en {result['running_time']}ms")
          >>> for row in result["data"]["rows"]:
          ...     print(row)
      """
      body: dict = {
          "database": database_id,
          "type": "native",
          "native": {"query": sql},
      }
      if max_results > 0:
          body["constraints"] = {
              "max-results": max_results,
              "max-results-bare-rows": max_results,
          }
      return client.request("POST", "/api/dataset", json=body)


  def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict:
      """Crea una card en Metabase con payload completo ya construido por el caller.

      Version raw de metabase_create_card. El caller es responsable de construir
      el payload completo antes de llamar a esta funcion — no se realiza ninguna
      validacion ni transformacion local. Util para flujos "Metabase as code"
      donde el YAML define todos los campos de la card tal como los espera la API.

      Endpoint: POST /api/card.

      El payload minimo necesita:
          - name (str): nombre de la card.
          - dataset_query (dict): query SQL nativa o MBQL.
          - display (str): tipo de visualizacion (table, bar, scalar, etc.).

      Campos opcionales que esta funcion preserva (a diferencia de metabase_create_card):
          - visualization_settings (dict): configuracion detallada del grafico.
          - parameters (list[dict]): parametros de la query con template tags.
          - parameter_mappings (list[dict]): mapeo de parametros a dashboard filters.
          - type (str): "question" (default), "model", "metric".
          - collection_id (int): ID de coleccion destino.
          - description (str): descripcion de la card.
          - archived (bool): estado de archivo inicial.
          - enable_embedding (bool): habilitar embedding publico.
          - embedding_params (dict): configuracion de embedding.

      Si Metabase devuelve 4xx/5xx, httpx lanza HTTPStatusError sin capturar.

      Args:
          client: Cliente autenticado con sesion activa.
          payload: Dict con el payload completo de la card tal como lo espera
                   la API de Metabase. Se envia sin modificaciones.

      Returns:
          Dict con la card recien creada. Incluye el campo `id` asignado por
          Metabase y todos los campos normalizados (display, dataset_query,
          visualization_settings, created_at, etc.).

      Example:
          >>> card = metabase_create_card_raw(client, {
          ...     "name": "Revenue by Month",
          ...     "dataset_query": {
          ...         "database": 1,
          ...         "type": "native",
          ...         "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
          ...     },
          ...     "display": "line",
          ...     "visualization_settings": {
          ...         "graph.x_axis.title_text": "Month",
          ...         "graph.y_axis.title_text": "Revenue",
          ...     },
          ...     "description": "Monthly revenue trend",
          ...     "collection_id": 5,
          ... })
          >>> print(card["id"])  # ID asignado por Metabase
      """
      return client.request("POST", "/api/card", json=payload)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient con sesion activa"},{"name":"payload","desc":"dict con el payload completo de la card tal como lo espera la API de Metabase; campos minimos: name, dataset_query, display; campos opcionales preservados: visualization_settings, parameters, parameter_mappings, type, collection_id, description, archived, enable_embedding, embedding_params"}],"output":"dict: objeto card recien creado con id asignado por Metabase y todos los campos normalizados (display, dataset_query, visualization_settings, created_at, etc.)"}


id: metabase_create_dashboard_py_infra
name: metabase_create_dashboard
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","dashboard","create","api","python"]
uses_functions: []
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/dashboards.py
created_at: 2026-04-05T11:22:32Z
updated_at: 2026-04-13T10:26:52Z
props: []
emits: []
variant: []
notes: Se crea vacio. Agregar cards con metabase_update_dashboard(dashcards=[...]).
content_hash: 6344b001717973cc2a65c8525e33d2aac70a60d236c2f66b60e2f97569fa4389
signature:
  def metabase_create_dashboard(client: MetabaseClient, name: str, description: str = '', collection_id: int = 0) -> dict

description:
  Crea dashboard vacio en Metabase. Para agregar cards usar metabase_update_dashboard con dashcards. Endpoint: POST /api/dashboard.

example:
  ```python
  dash = metabase_create_dashboard(client, "Sales Overview", "KPIs")
  # Agregar cards con metabase_update_dashboard
  ```

code:
  """CRUD de dashboards de Metabase."""

  from .client import MetabaseClient


  def metabase_list_dashboards(
      client: MetabaseClient,
      filter: str = "",
  ) -> list[dict]:
      """Lista dashboards de Metabase con filtro opcional.

      Endpoint: GET /api/dashboard. Retorna dashboards resumidos (sin dashcards).

      Args:
          client: Cliente autenticado.
          filter: "all", "mine" o "archived". Vacio = todas.

      Returns:
          Lista de dicts con: id, name, description, collection_id,
          creator_id, archived, created_at.

      Example:
          >>> dashboards = metabase_list_dashboards(client, filter="mine")
          >>> for d in dashboards:
          ...     print(d["id"], d["name"])
      """
      params = {}
      if filter:
          params["f"] = filter
      return client.request("GET", "/api/dashboard", params=params)


  def metabase_get_dashboard(client: MetabaseClient, dashboard_id: int) -> dict:
      """Obtiene un dashboard completo incluyendo sus cards.

      Endpoint: GET /api/dashboard/:id.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.

      Returns:
          Dict con: id, name, description, dashcards (lista de cards posicionadas),
          parameters (filtros), tabs, collection_id, archived.

          Cada dashcard tiene: id, card_id, card (objeto completo), size_x, size_y,
          col, row, dashboard_tab_id, parameter_mappings, visualization_settings.

      Example:
          >>> dash = metabase_get_dashboard(client, 1)
          >>> for dc in dash["dashcards"]:
          ...     print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})")
      """
      return client.request("GET", f"/api/dashboard/{dashboard_id}")


  def metabase_create_dashboard(
      client: MetabaseClient,
      name: str,
      description: str = "",
      collection_id: int = 0,
  ) -> dict:
      """Crea un nuevo dashboard vacio en Metabase.

      Endpoint: POST /api/dashboard.
      Para agregar cards usar metabase_update_dashboard con dashcards.

      Args:
          client: Cliente autenticado.
          name: Nombre del dashboard.
          description: Descripcion opcional.
          collection_id: Coleccion destino. 0 = root.

      Returns:
          Dict con el dashboard creado.

      Example:
          >>> dash = metabase_create_dashboard(client, "Sales Overview", "KPIs de ventas")
          >>> # Agregar cards:
          >>> metabase_update_dashboard(client, dash["id"], dashcards=[
          ...     {"id": -1, "card_id": 42, "size_x": 6, "size_y": 4, "col": 0, "row": 0},
          ... ])
      """
      body: dict = {"name": name}
      if description:
          body["description"] = description
      if collection_id > 0:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/dashboard", json=body)


  def metabase_update_dashboard(client: MetabaseClient, dashboard_id: int, **fields) -> dict:
      """Actualiza un dashboard incluyendo metadata, cards y tabs.

      Endpoint: PUT /api/dashboard/:id.

      El campo dashcards representa el ESTADO COMPLETO DESEADO del dashboard:
      - Agregar card: incluirla con ID negativo (-1, -2, etc.)
      - Actualizar card existente: incluirla con su ID positivo
      - Eliminar card: omitirla del array

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.
          **fields: Campos a actualizar. Validos:
              name (str), description (str), archived (bool),
              dashcards (list[dict]), tabs (list[dict]),
              parameters (list[dict]), collection_id (int).

      Returns:
          Dict con el dashboard actualizado.

      Example:
          >>> # Cambiar nombre
          >>> metabase_update_dashboard(client, 1, name="Updated Name")
          >>>
          >>> # Agregar card (primero obtener existentes)
          >>> dash = metabase_get_dashboard(client, 1)
          >>> cards = list(dash["dashcards"])
          >>> cards.append({"id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0})
          >>> metabase_update_dashboard(client, 1, dashcards=cards)
          >>>
          >>> # Archivar (soft-delete)
          >>> metabase_update_dashboard(client, 1, archived=True)
      """
      return client.request("PUT", f"/api/dashboard/{dashboard_id}", json=fields)


  def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None:
      """Elimina permanentemente un dashboard.

      Endpoint: DELETE /api/dashboard/:id. IRREVERSIBLE.
      Para soft-delete preferir: metabase_update_dashboard(client, id, archived=True)

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a eliminar.

      Example:
          >>> metabase_delete_dashboard(client, 1)
          >>> # Preferir: metabase_update_dashboard(client, 1, archived=True)
      """
      client.request("DELETE", f"/api/dashboard/{dashboard_id}")


  def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict:
      """Crea un dashboard en Metabase con payload completo ya construido por el caller.

      Version raw de metabase_create_dashboard. El caller es responsable de
      construir el payload completo — no se realiza validacion ni transformacion
      local. Util para flujos "Metabase as code" donde el YAML define todos los
      campos del dashboard tal como los espera la API.

      COMPORTAMIENTO EN DOS PASOS (limitacion de la API de Metabase):
      El endpoint POST /api/dashboard NO acepta `dashcards` en el body inicial;
      solo crea el dashboard vacio. Para añadir cards es necesario un PUT posterior.
      Esta funcion maneja esa limitacion automaticamente:

        1. Si el payload contiene `dashcards`, se extraen antes del POST.
        2. POST /api/dashboard crea el dashboard vacio (sin dashcards).
        3. Si habia dashcards, PUT /api/dashboard/:id con {"dashcards": [...]}
           las añade al dashboard recien creado.
        4. Retorna la respuesta del PUT (con dashcards pobladas), o la del POST
           si el payload original no contenia dashcards.

      Endpoint inicial: POST /api/dashboard.
      Endpoint secundario (condicional): PUT /api/dashboard/:id.

      El payload puede incluir:
          - name (str, requerido): nombre del dashboard.
          - description (str): descripcion.
          - collection_id (int): ID de coleccion destino.
          - parameters (list[dict]): filtros/parametros del dashboard.
          - dashcards (list[dict]): cards posicionadas. Cada dashcard necesita:
              id (int negativo para nuevas, ej: -1, -2),
              card_id (int), size_x (int), size_y (int), col (int), row (int).
              Campos opcionales: visualization_settings, parameter_mappings.
          - tabs (list[dict]): pestañas del dashboard.
          - enable_embedding (bool): habilitar embedding publico.
          - embedding_params (dict): configuracion de embedding.

      Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza HTTPStatusError.

      Args:
          client: Cliente autenticado con sesion activa.
          payload: Dict con el payload completo del dashboard tal como lo espera
                   la API de Metabase. Puede incluir dashcards.

      Returns:
          Dict con el dashboard creado. Si habia dashcards, la respuesta es la
          del PUT final e incluye el campo `dashcards` con las cards posicionadas.
          Si no habia dashcards, es la respuesta del POST inicial.

      Example:
          >>> dash = metabase_create_dashboard_raw(client, {
          ...     "name": "Sales Overview",
          ...     "description": "KPIs de ventas mensuales",
          ...     "collection_id": 5,
          ...     "parameters": [],
          ...     "dashcards": [
          ...         {
          ...             "id": -1,
          ...             "card_id": 42,
          ...             "size_x": 6,
          ...             "size_y": 4,
          ...             "col": 0,
          ...             "row": 0,
          ...             "visualization_settings": {},
          ...             "parameter_mappings": [],
          ...         },
          ...     ],
          ... })
          >>> print(dash["id"])          # ID asignado por Metabase
          >>> print(len(dash["dashcards"]))  # 1
      """
      body = {k: v for k, v in payload.items() if k != "dashcards"}
      dashcards = payload.get("dashcards")

      created = client.request("POST", "/api/dashboard", json=body)

      if dashcards:
          dashboard_id = created["id"]
          return client.request(
              "PUT",
              f"/api/dashboard/{dashboard_id}",
              json={"dashcards": dashcards},
          )

      return created

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"name","desc":"nombre del dashboard"},{"name":"description","desc":"descripción opcional del dashboard"},{"name":"collection_id","desc":"ID de colección donde guardar el dashboard (0=root)"}],"output":"dict: objeto dashboard vacío con id asignado"}


id: metabase_create_dashboard_raw_py_infra
name: metabase_create_dashboard_raw
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
signature: def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict
tags: ["metabase","dashboard","create","api","python","raw","as-code","dashcards"]
uses_functions: ["metabase_auth_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/dashboards.py
created_at: 2026-04-13T10:26:52Z
updated_at: 2026-04-13T10:26:52Z
props: []
emits: []
variant: []
content_hash: 489b72ab834433516c6c5e08e6d5224a91210dd9830ce824f8aaa056a2fefa67
description:
  Crea un dashboard en Metabase enviando el payload completo sin modificaciones. Maneja automaticamente la limitacion de la API que no acepta dashcards en el POST inicial: si el payload contiene dashcards, hace POST para crear el dashboard y luego PUT para añadir las cards. Endpoint: POST /api/dashboard (+ PUT condicional).

example:
  ```python
  # Sin dashcards (solo POST)
  dash = metabase_create_dashboard_raw(client, {
      "name": "Sales Overview",
      "description": "KPIs de ventas mensuales",
      "collection_id": 5,
      "parameters": [],
  })
  print(dash["id"])

  # Con dashcards (POST + PUT automatico)
  dash = metabase_create_dashboard_raw(client, {
      "name": "Sales Overview",
      "description": "KPIs de ventas mensuales",
      "collection_id": 5,
      "parameters": [],
      "dashcards": [
          {
              "id": -1,
              "card_id": 42,
              "size_x": 6,
              "size_y": 4,
              "col": 0,
              "row": 0,
              "visualization_settings": {},
              "parameter_mappings": [],
          },
      ],
  })
  print(dash["id"])
  print(len(dash["dashcards"]))  # 1
  ```

notes:
  No se escriben tests automaticos porque requiere una instancia real de Metabase.
  Los intentos de mockear `client.request` quedan fuera del alcance del registry
  por ahora — la validacion se hace en integracion contra un entorno Metabase real.

  La API de Metabase (al menos hasta v0.49) ignora el campo `dashcards` en el
  POST inicial de creacion de dashboard. Esta funcion absorbe esa limitacion
  internamente: extrae las dashcards del payload antes del POST y las envia en
  un PUT separado usando el id que Metabase asigno al nuevo dashboard.

  Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza `HTTPStatusError`
  sin capturar. Si el POST tiene exito pero el PUT falla, el dashboard queda
  creado pero vacio — el caller debe manejar este caso si necesita atomicidad.

code:
  """CRUD de dashboards de Metabase."""

  from .client import MetabaseClient


  def metabase_list_dashboards(
      client: MetabaseClient,
      filter: str = "",
  ) -> list[dict]:
      """Lista dashboards de Metabase con filtro opcional.

      Endpoint: GET /api/dashboard. Retorna dashboards resumidos (sin dashcards).

      Args:
          client: Cliente autenticado.
          filter: "all", "mine" o "archived". Vacio = todas.

      Returns:
          Lista de dicts con: id, name, description, collection_id,
          creator_id, archived, created_at.

      Example:
          >>> dashboards = metabase_list_dashboards(client, filter="mine")
          >>> for d in dashboards:
          ...     print(d["id"], d["name"])
      """
      params = {}
      if filter:
          params["f"] = filter
      return client.request("GET", "/api/dashboard", params=params)


  def metabase_get_dashboard(client: MetabaseClient, dashboard_id: int) -> dict:
      """Obtiene un dashboard completo incluyendo sus cards.

      Endpoint: GET /api/dashboard/:id.

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.

      Returns:
          Dict con: id, name, description, dashcards (lista de cards posicionadas),
          parameters (filtros), tabs, collection_id, archived.

          Cada dashcard tiene: id, card_id, card (objeto completo), size_x, size_y,
          col, row, dashboard_tab_id, parameter_mappings, visualization_settings.

      Example:
          >>> dash = metabase_get_dashboard(client, 1)
          >>> for dc in dash["dashcards"]:
          ...     print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})")
      """
      return client.request("GET", f"/api/dashboard/{dashboard_id}")


  def metabase_create_dashboard(
      client: MetabaseClient,
      name: str,
      description: str = "",
      collection_id: int = 0,
  ) -> dict:
      """Crea un nuevo dashboard vacio en Metabase.

      Endpoint: POST /api/dashboard.
      Para agregar cards usar metabase_update_dashboard con dashcards.

      Args:
          client: Cliente autenticado.
          name: Nombre del dashboard.
          description: Descripcion opcional.
          collection_id: Coleccion destino. 0 = root.

      Returns:
          Dict con el dashboard creado.

      Example:
          >>> dash = metabase_create_dashboard(client, "Sales Overview", "KPIs de ventas")
          >>> # Agregar cards:
          >>> metabase_update_dashboard(client, dash["id"], dashcards=[
          ...     {"id": -1, "card_id": 42, "size_x": 6, "size_y": 4, "col": 0, "row": 0},
          ... ])
      """
      body: dict = {"name": name}
      if description:
          body["description"] = description
      if collection_id > 0:
          body["collection_id"] = collection_id
      return client.request("POST", "/api/dashboard", json=body)


  def metabase_update_dashboard(client: MetabaseClient, dashboard_id: int, **fields) -> dict:
      """Actualiza un dashboard incluyendo metadata, cards y tabs.

      Endpoint: PUT /api/dashboard/:id.

      El campo dashcards representa el ESTADO COMPLETO DESEADO del dashboard:
      - Agregar card: incluirla con ID negativo (-1, -2, etc.)
      - Actualizar card existente: incluirla con su ID positivo
      - Eliminar card: omitirla del array

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard.
          **fields: Campos a actualizar. Validos:
              name (str), description (str), archived (bool),
              dashcards (list[dict]), tabs (list[dict]),
              parameters (list[dict]), collection_id (int).

      Returns:
          Dict con el dashboard actualizado.

      Example:
          >>> # Cambiar nombre
          >>> metabase_update_dashboard(client, 1, name="Updated Name")
          >>>
          >>> # Agregar card (primero obtener existentes)
          >>> dash = metabase_get_dashboard(client, 1)
          >>> cards = list(dash["dashcards"])
          >>> cards.append({"id": -1, "card_id": 55, "size_x": 6, "size_y": 4, "col": 0, "row": 0})
          >>> metabase_update_dashboard(client, 1, dashcards=cards)
          >>>
          >>> # Archivar (soft-delete)
          >>> metabase_update_dashboard(client, 1, archived=True)
      """
      return client.request("PUT", f"/api/dashboard/{dashboard_id}", json=fields)


  def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None:
      """Elimina permanentemente un dashboard.

      Endpoint: DELETE /api/dashboard/:id. IRREVERSIBLE.
      Para soft-delete preferir: metabase_update_dashboard(client, id, archived=True)

      Args:
          client: Cliente autenticado.
          dashboard_id: ID del dashboard a eliminar.

      Example:
          >>> metabase_delete_dashboard(client, 1)
          >>> # Preferir: metabase_update_dashboard(client, 1, archived=True)
      """
      client.request("DELETE", f"/api/dashboard/{dashboard_id}")


  def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict:
      """Crea un dashboard en Metabase con payload completo ya construido por el caller.

      Version raw de metabase_create_dashboard. El caller es responsable de
      construir el payload completo — no se realiza validacion ni transformacion
      local. Util para flujos "Metabase as code" donde el YAML define todos los
      campos del dashboard tal como los espera la API.

      COMPORTAMIENTO EN DOS PASOS (limitacion de la API de Metabase):
      El endpoint POST /api/dashboard NO acepta `dashcards` en el body inicial;
      solo crea el dashboard vacio. Para añadir cards es necesario un PUT posterior.
      Esta funcion maneja esa limitacion automaticamente:

        1. Si el payload contiene `dashcards`, se extraen antes del POST.
        2. POST /api/dashboard crea el dashboard vacio (sin dashcards).
        3. Si habia dashcards, PUT /api/dashboard/:id con {"dashcards": [...]}
           las añade al dashboard recien creado.
        4. Retorna la respuesta del PUT (con dashcards pobladas), o la del POST
           si el payload original no contenia dashcards.

      Endpoint inicial: POST /api/dashboard.
      Endpoint secundario (condicional): PUT /api/dashboard/:id.

      El payload puede incluir:
          - name (str, requerido): nombre del dashboard.
          - description (str): descripcion.
          - collection_id (int): ID de coleccion destino.
          - parameters (list[dict]): filtros/parametros del dashboard.
          - dashcards (list[dict]): cards posicionadas. Cada dashcard necesita:
              id (int negativo para nuevas, ej: -1, -2),
              card_id (int), size_x (int), size_y (int), col (int), row (int).
              Campos opcionales: visualization_settings, parameter_mappings.
          - tabs (list[dict]): pestañas del dashboard.
          - enable_embedding (bool): habilitar embedding publico.
          - embedding_params (dict): configuracion de embedding.

      Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza HTTPStatusError.

      Args:
          client: Cliente autenticado con sesion activa.
          payload: Dict con el payload completo del dashboard tal como lo espera
                   la API de Metabase. Puede incluir dashcards.

      Returns:
          Dict con el dashboard creado. Si habia dashcards, la respuesta es la
          del PUT final e incluye el campo `dashcards` con las cards posicionadas.
          Si no habia dashcards, es la respuesta del POST inicial.

      Example:
          >>> dash = metabase_create_dashboard_raw(client, {
          ...     "name": "Sales Overview",
          ...     "description": "KPIs de ventas mensuales",
          ...     "collection_id": 5,
          ...     "parameters": [],
          ...     "dashcards": [
          ...         {
          ...             "id": -1,
          ...             "card_id": 42,
          ...             "size_x": 6,
          ...             "size_y": 4,
          ...             "col": 0,
          ...             "row": 0,
          ...             "visualization_settings": {},
          ...             "parameter_mappings": [],
          ...         },
          ...     ],
          ... })
          >>> print(dash["id"])          # ID asignado por Metabase
          >>> print(len(dash["dashcards"]))  # 1
      """
      body = {k: v for k, v in payload.items() if k != "dashcards"}
      dashcards = payload.get("dashcards")

      created = client.request("POST", "/api/dashboard", json=body)

      if dashcards:
          dashboard_id = created["id"]
          return client.request(
              "PUT",
              f"/api/dashboard/{dashboard_id}",
              json={"dashcards": dashcards},
          )

      return created

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient con sesion activa"},{"name":"payload","desc":"dict con el payload completo del dashboard tal como lo espera la API de Metabase; campos soportados: name (requerido), description, collection_id, parameters, tabs, enable_embedding, embedding_params; dashcards (list[dict]): si presente, se extrae del body POST y se añade en un PUT posterior con id negativo para cards nuevas"}],"output":"dict: objeto dashboard creado; si habia dashcards en el payload, retorna la respuesta del PUT final con el campo dashcards poblado; si no habia dashcards, retorna la respuesta del POST inicial"}


id: metabase_create_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-13T10:26:52Z
props: []
emits: []
variant: []
notes: IRREVERSIBLE. Preferir soft-delete con metabase_update_card(archived=True).
content_hash: f42904cb5d95e670255b6239b4aaacd7d94c1adc36977312b5462c7daae85c5b
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)


  def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict:
      """Crea una card en Metabase con payload completo ya construido por el caller.

      Version raw de metabase_create_card. El caller es responsable de construir
      el payload completo antes de llamar a esta funcion — no se realiza ninguna
      validacion ni transformacion local. Util para flujos "Metabase as code"
      donde el YAML define todos los campos de la card tal como los espera la API.

      Endpoint: POST /api/card.

      El payload minimo necesita:
          - name (str): nombre de la card.
          - dataset_query (dict): query SQL nativa o MBQL.
          - display (str): tipo de visualizacion (table, bar, scalar, etc.).

      Campos opcionales que esta funcion preserva (a diferencia de metabase_create_card):
          - visualization_settings (dict): configuracion detallada del grafico.
          - parameters (list[dict]): parametros de la query con template tags.
          - parameter_mappings (list[dict]): mapeo de parametros a dashboard filters.
          - type (str): "question" (default), "model", "metric".
          - collection_id (int): ID de coleccion destino.
          - description (str): descripcion de la card.
          - archived (bool): estado de archivo inicial.
          - enable_embedding (bool): habilitar embedding publico.
          - embedding_params (dict): configuracion de embedding.

      Si Metabase devuelve 4xx/5xx, httpx lanza HTTPStatusError sin capturar.

      Args:
          client: Cliente autenticado con sesion activa.
          payload: Dict con el payload completo de la card tal como lo espera
                   la API de Metabase. Se envia sin modificaciones.

      Returns:
          Dict con la card recien creada. Incluye el campo `id` asignado por
          Metabase y todos los campos normalizados (display, dataset_query,
          visualization_settings, created_at, etc.).

      Example:
          >>> card = metabase_create_card_raw(client, {
          ...     "name": "Revenue by Month",
          ...     "dataset_query": {
          ...         "database": 1,
          ...         "type": "native",
          ...         "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
          ...     },
          ...     "display": "line",
          ...     "visualization_settings": {
          ...         "graph.x_axis.title_text": "Month",
          ...         "graph.y_axis.title_text": "Revenue",
          ...     },
          ...     "description": "Monthly revenue trend",
          ...     "collection_id": 5,
          ... })
          >>> print(card["id"])  # ID asignado por Metabase
      """
      return client.request("POST", "/api/card", json=payload)

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-13T10:26:52Z
props: []
emits: []
variant: []
notes: IRREVERSIBLE. Preferir soft-delete con metabase_update_dashboard(archived=True).
content_hash: 85d4f208fcff40dee4322c950421bf21f7f8a90672bf61a2f7998b65f9f2744a
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}")


  def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict:
      """Crea un dashboard en Metabase con payload completo ya construido por el caller.

      Version raw de metabase_create_dashboard. El caller es responsable de
      construir el payload completo — no se realiza validacion ni transformacion
      local. Util para flujos "Metabase as code" donde el YAML define todos los
      campos del dashboard tal como los espera la API.

      COMPORTAMIENTO EN DOS PASOS (limitacion de la API de Metabase):
      El endpoint POST /api/dashboard NO acepta `dashcards` en el body inicial;
      solo crea el dashboard vacio. Para añadir cards es necesario un PUT posterior.
      Esta funcion maneja esa limitacion automaticamente:

        1. Si el payload contiene `dashcards`, se extraen antes del POST.
        2. POST /api/dashboard crea el dashboard vacio (sin dashcards).
        3. Si habia dashcards, PUT /api/dashboard/:id con {"dashcards": [...]}
           las añade al dashboard recien creado.
        4. Retorna la respuesta del PUT (con dashcards pobladas), o la del POST
           si el payload original no contenia dashcards.

      Endpoint inicial: POST /api/dashboard.
      Endpoint secundario (condicional): PUT /api/dashboard/:id.

      El payload puede incluir:
          - name (str, requerido): nombre del dashboard.
          - description (str): descripcion.
          - collection_id (int): ID de coleccion destino.
          - parameters (list[dict]): filtros/parametros del dashboard.
          - dashcards (list[dict]): cards posicionadas. Cada dashcard necesita:
              id (int negativo para nuevas, ej: -1, -2),
              card_id (int), size_x (int), size_y (int), col (int), row (int).
              Campos opcionales: visualization_settings, parameter_mappings.
          - tabs (list[dict]): pestañas del dashboard.
          - enable_embedding (bool): habilitar embedding publico.
          - embedding_params (dict): configuracion de embedding.

      Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza HTTPStatusError.

      Args:
          client: Cliente autenticado con sesion activa.
          payload: Dict con el payload completo del dashboard tal como lo espera
                   la API de Metabase. Puede incluir dashcards.

      Returns:
          Dict con el dashboard creado. Si habia dashcards, la respuesta es la
          del PUT final e incluye el campo `dashcards` con las cards posicionadas.
          Si no habia dashcards, es la respuesta del POST inicial.

      Example:
          >>> dash = metabase_create_dashboard_raw(client, {
          ...     "name": "Sales Overview",
          ...     "description": "KPIs de ventas mensuales",
          ...     "collection_id": 5,
          ...     "parameters": [],
          ...     "dashcards": [
          ...         {
          ...             "id": -1,
          ...             "card_id": 42,
          ...             "size_x": 6,
          ...             "size_y": 4,
          ...             "col": 0,
          ...             "row": 0,
          ...             "visualization_settings": {},
          ...             "parameter_mappings": [],
          ...         },
          ...     ],
          ... })
          >>> print(dash["id"])          # ID asignado por Metabase
          >>> print(len(dash["dashcards"]))  # 1
      """
      body = {k: v for k, v in payload.items() if k != "dashcards"}
      dashcards = payload.get("dashcards")

      created = client.request("POST", "/api/dashboard", json=body)

      if dashcards:
          dashboard_id = created["id"]
          return client.request(
              "PUT",
              f"/api/dashboard/{dashboard_id}",
              json={"dashcards": dashcards},
          )

      return created

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient"},{"name":"dashboard_id","desc":"ID del dashboard a 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-13T10:26:52Z
props: []
emits: []
variant: []
content_hash: 8c98e8f396a3cdb8fb5ee42619ea2f3542b1167c07062aaa5ade1cff79c1d789
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:
[cell repeats an earlier one in this file: 10210 characters]

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-13T10:26:52Z
props: []
emits: []
variant: []
notes: Misma respuesta que metabase_execute_card. Default 2000 filas, override con max_results.
content_hash: a926c68b9ce12ee9405ebd44fec397c16b52f6d73b0662ff3070356a7532983e
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: 10210 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-13T10:26:52Z
props: []
emits: []
variant: []
notes: Error 404 si no existe.
content_hash: 26eaac45f8797d9b3ece70b50ab08c3f0a069058c7864695e507193810965621
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: 10210 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-13T10:26:52Z
props: []
emits: []
variant: []
content_hash: 059759499aefab1666a1230f769bf359df9dc3417c307b48f22d086cee5d7900
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:
[cell repeats an earlier one in this file: 8465 characters]

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-13T10:26:52Z
props: []
emits: []
variant: []
notes: No tiene paginacion offset/limit. Retorna todas las cards que coinciden.
content_hash: 028bc1eb7152679355c2131d155e879325da8bcae3d3b61e8c17a5732a48579d
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: 10210 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-13T10:26:52Z
props: []
emits: []
variant: []
notes: Para ver cards de un dashboard usar metabase_get_dashboard.
content_hash: 5742d586c2f7d3f4c00f9487890e57adb36c2b4af1d6ca5bc5871e643bde0372
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: 8465 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-13T10:26:52Z
props: []
emits: []
variant: []
notes: Soft-delete con `archived=True`. Para delete permanente usar metabase_delete_card.
content_hash: bc89706bdf0fe62b976d1f6c36d99b5c5eaf36e0ceedf54000d4fcebadf143a6
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: 10210 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-13T10:26:52Z
props: []
emits: []
variant: []
content_hash: 3be88f892ed7850caea635f05e646874470091800caf0a391b6f116c3ba1eab9
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: 8465 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: metabase_validate_card_payload_py_infra
name: metabase_validate_card_payload
kind: function
lang: py
domain: infra
version: 1.0.0
purity: pure
signature: def metabase_validate_card_payload(payload: dict) -> list[str]
tags: ["metabase","validation","card","pure","pre-flight","structural"]
uses_functions: []
uses_types: []
returns: []
imports: []
test_file_path: python/functions/metabase/validation_test.py
file_path: python/functions/metabase/validation.py
created_at: 2026-04-13T10:12:25Z
updated_at: 2026-04-13T10:12:25Z
props: []
emits: []
variant: []
content_hash: 6d58aae7d5d663973a37cd85323595f1dbc8231c5b39bd7cd5e962e4d6fb4bf2
description:
  Valida la estructura de un payload de card de Metabase sin necesidad de red. Recorre todos los checks y acumula todos los issues en vez de abortar al primero. Retorna lista vacia si el payload es valido.

example:
  ```python
  issues = metabase_validate_card_payload({
      "name": "Revenue by Month",
      "display": "line",
      "dataset_query": {
          "database": 1,
          "type": "native",
          "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
      },
  })
  if issues:
      print("Payload invalido:", issues)
  else:
      metabase_update_card(client, card_id, **payload)
  ```

tests:
  ["card valido retorna lista vacia","card display invalido","card display ausente","card name ausente","card name vacio","card dataset query ausente","card dataset query sin database","card nativa sin sql","card nativa mbql5","card type invalido","card type valido","card visualization settings no dict","card parameters no list","card archived no bool","card acumula multiples errores"]

notes:
  Displays validos: scalar, table, line, bar, pie, area, row, funnel, smartscalar, gauge, progress, combo, pivot, map, scatter, waterfall, sankey, object.

  Tipos validos (campo `type`): question, model, metric.

  Soporta dos formatos de SQL nativo:
  - Legacy: `dataset_query.native.query`
  - MBQL5: `dataset_query.stages[0].native`

  No aborta al primer error — recolecta todos los issues para que el caller pueda mostrarlos todos de una vez.

code:
  """Validacion estructural de cards y dashboards de Metabase antes de pusharlos a la API."""

  import httpx

  from .client import MetabaseClient
  from .cards import metabase_execute_query


  _VALID_DISPLAYS = {
      "scalar", "table", "line", "bar", "pie", "area", "row", "funnel",
      "smartscalar", "gauge", "progress", "combo", "pivot", "map", "scatter",
      "waterfall", "sankey", "object",
  }

  _VALID_TYPES = {"question", "model", "metric"}


  def metabase_validate_card_payload(payload: dict) -> list[str]:
      """Valida la estructura de un payload de card antes de enviarlo a Metabase.

      Comprueba invariantes estructurales sin necesidad de red. Recorre todos los
      checks y acumula todos los issues en lugar de abortar al primero.

      Args:
          payload: dict con los campos de la card a validar (name, display,
                   dataset_query, type, visualization_settings, parameters, archived).

      Returns:
          Lista de strings describiendo cada issue encontrado. Lista vacia = payload valido.

      Example:
          >>> issues = metabase_validate_card_payload({"name": "Revenue", "display": "bar",
          ...     "dataset_query": {"database": 1, "type": "native",
          ...                       "native": {"query": "SELECT 1"}}})
          >>> assert issues == []
      """
      issues: list[str] = []

      # --- name ---
      name = payload.get("name")
      if name is None:
          issues.append("campo 'name' ausente")
      elif not isinstance(name, str) or not name.strip():
          issues.append("campo 'name' debe ser un string no vacio")

      # --- display ---
      display = payload.get("display")
      if display is None:
          issues.append("campo 'display' ausente")
      elif display not in _VALID_DISPLAYS:
          validos = ", ".join(sorted(_VALID_DISPLAYS))
          issues.append(f"display '{display}' invalido (validos: {validos})")

      # --- type (opcional) ---
      card_type = payload.get("type")
      if card_type is not None and card_type not in _VALID_TYPES:
          validos = ", ".join(sorted(_VALID_TYPES))
          issues.append(f"type '{card_type}' invalido (validos: {validos})")

      # --- dataset_query ---
      dq = payload.get("dataset_query")
      if dq is None:
          issues.append("campo 'dataset_query' ausente")
      elif not isinstance(dq, dict):
          issues.append("campo 'dataset_query' debe ser un dict")
      else:
          # database presente
          if "database" not in dq:
              issues.append("'dataset_query.database' ausente")

          # deteccion de query nativa
          query_type = payload.get("query_type") or dq.get("type", "")
          is_native = query_type == "native"

          # Tambien chequear si stages[0] tiene clave "native" (formato MBQL5)
          stages = dq.get("stages", [])
          has_mbql5_native = (
              isinstance(stages, list)
              and len(stages) > 0
              and isinstance(stages[0], dict)
              and "native" in stages[0]
          )

          if is_native or has_mbql5_native:
              # Formato legacy: dataset_query.native.query
              legacy_sql = None
              native_block = dq.get("native")
              if isinstance(native_block, dict):
                  legacy_sql = native_block.get("query")

              # Formato MBQL5: dataset_query.stages[0].native
              mbql5_sql = None
              if has_mbql5_native:
                  mbql5_sql = stages[0].get("native")

              legacy_ok = isinstance(legacy_sql, str) and legacy_sql.strip()
              mbql5_ok = isinstance(mbql5_sql, str) and mbql5_sql.strip()

              if not legacy_ok and not mbql5_ok:
                  issues.append(
                      "query nativa sin SQL: falta 'dataset_query.native.query' "
                      "(legacy) o 'dataset_query.stages[0].native' (MBQL5)"
                  )

      # --- visualization_settings (opcional) ---
      vs = payload.get("visualization_settings")
      if vs is not None and not isinstance(vs, dict):
          issues.append("'visualization_settings' debe ser un dict")

      # --- parameters (opcional) ---
      params = payload.get("parameters")
      if params is not None and not isinstance(params, list):
          issues.append("'parameters' debe ser una list")

      # --- archived (opcional) ---
      archived = payload.get("archived")
      if archived is not None and not isinstance(archived, bool):
          issues.append("'archived' debe ser bool")

      return issues


  def metabase_validate_dashboard_payload(
      payload: dict,
      known_card_ids: set[int],
  ) -> list[str]:
      """Valida la estructura de un payload de dashboard antes de enviarlo a Metabase.

      Verifica campos obligatorios, bounds de dashcards, referencias a cards y
      solapamientos entre dashcards. Acumula todos los issues sin abortar.

      Args:
          payload: dict con los campos del dashboard (name, dashcards, tabs, parameters).
          known_card_ids: conjunto de IDs de cards conocidos; las dashcards con
                          card_id entero deben referenciar un ID de este conjunto.

      Returns:
          Lista de strings describiendo cada issue encontrado. Lista vacia = payload valido.

      Example:
          >>> issues = metabase_validate_dashboard_payload(
          ...     {"name": "KPIs", "dashcards": []},
          ...     known_card_ids={1, 2, 3},
          ... )
          >>> assert issues == []
      """
      issues: list[str] = []

      # --- name ---
      name = payload.get("name")
      if name is None:
          issues.append("campo 'name' ausente")
      elif not isinstance(name, str) or not name.strip():
          issues.append("campo 'name' debe ser un string no vacio")

      # --- dashcards (opcional, pero si esta, debe ser list) ---
      dashcards = payload.get("dashcards")
      if dashcards is not None:
          if not isinstance(dashcards, list):
              issues.append("'dashcards' debe ser una list")
          else:
              valid_rects: list[tuple[int, int, int, int, int]] = []  # (idx, row, col, sx, sy)

              for i, dc in enumerate(dashcards):
                  if not isinstance(dc, dict):
                      issues.append(f"dashcard[{i}] debe ser un dict")
                      continue

                  # card_id: si es int debe estar en known_card_ids; null es virtual (ok)
                  card_id = dc.get("card_id")
                  if card_id is not None:
                      if not isinstance(card_id, int):
                          issues.append(f"dashcard[{i}].card_id debe ser int o null")
                      elif card_id not in known_card_ids:
                          issues.append(
                              f"dashcard[{i}].card_id={card_id} no existe en las cards conocidas"
                          )

                  # Campos de posicion y tamanio
                  missing = [f for f in ("row", "col", "size_x", "size_y") if f not in dc]
                  if missing:
                      issues.append(
                          f"dashcard[{i}] falta campos de layout: {', '.join(missing)}"
                      )
                      continue

                  row = dc["row"]
                  col = dc["col"]
                  size_x = dc["size_x"]
                  size_y = dc["size_y"]

                  for fname, val in (("row", row), ("col", col), ("size_x", size_x), ("size_y", size_y)):
                      if not isinstance(val, int):
                          issues.append(f"dashcard[{i}].{fname} debe ser int")

                  if not isinstance(row, int) or not isinstance(col, int) or \
                     not isinstance(size_x, int) or not isinstance(size_y, int):
                      continue  # ya reportado arriba

                  # Bounds
                  if row < 0:
                      issues.append(f"dashcard[{i}].row={row} debe ser >= 0")
                  if not 0 <= col <= 23:
                      issues.append(f"dashcard[{i}].col={col} debe estar en [0, 23]")
                  if not 1 <= size_x <= 24:
                      issues.append(f"dashcard[{i}].size_x={size_x} debe estar en [1, 24]")
                  if not 1 <= size_y <= 100:
                      issues.append(f"dashcard[{i}].size_y={size_y} debe estar en [1, 100]")
                  if col + size_x > 24:
                      issues.append(
                          f"dashcard[{i}] excede el ancho del grid: col={col} + size_x={size_x} = {col + size_x} > 24"
                      )

                  valid_rects.append((i, row, col, size_x, size_y))

              # Deteccion de solapamientos O(n^2) — dashboards tipicos tienen < 50 cards
              for a in range(len(valid_rects)):
                  for b in range(a + 1, len(valid_rects)):
                      ia, ra, ca, sxa, sya = valid_rects[a]
                      ib, rb, cb, sxb, syb = valid_rects[b]

                      # Rectangulos: [ca, ca+sxa) x [ra, ra+sya) y [cb, cb+sxb) x [rb, rb+syb)
                      overlap_x = ca < cb + sxb and cb < ca + sxa
                      overlap_y = ra < rb + syb and rb < ra + sya

                      if overlap_x and overlap_y:
                          issues.append(
                              f"dashcards en posiciones (row={ra},col={ca},{sxa}x{sya}) "
                              f"y (row={rb},col={cb},{sxb}x{syb}) solapan"
                          )

      # --- tabs (opcional) ---
      tabs = payload.get("tabs")
      if tabs is not None:
          if not isinstance(tabs, list):
              issues.append("'tabs' debe ser una list")
          else:
              for i, tab in enumerate(tabs):
                  if not isinstance(tab, dict):
                      issues.append(f"tabs[{i}] debe ser un dict")
                      continue
                  if "id" not in tab:
                      issues.append(f"tabs[{i}] falta campo 'id'")
                  if "name" not in tab:
                      issues.append(f"tabs[{i}] falta campo 'name'")

      # --- parameters (opcional) ---
      params = payload.get("parameters")
      if params is not None and not isinstance(params, list):
          issues.append("'parameters' debe ser una list")

      return issues


  def metabase_validate_sql(
      client: MetabaseClient,
      database_id: int,
      sql: str,
      max_rows: int = 0,
  ) -> dict:
      """Valida sintaxis y referencias de SQL ejecutandolo contra Metabase.

      Ejecuta la query via POST /api/dataset con LIMIT implicito (max_rows=1 si
      el caller no especifica nada, para minimizar carga). Captura tanto errores
      HTTP como errores embebidos en el body (Metabase a veces devuelve 200 + status failed).

      Args:
          client: instancia autenticada de MetabaseClient.
          database_id: ID de la base de datos donde ejecutar el SQL.
          sql: sentencia SQL a validar (SELECT, WITH, etc.).
          max_rows: limite de filas a retornar para la validacion (0 = default de Metabase).

      Returns:
          dict con:
              ok (bool): True si la query se ejecuto sin errores.
              error (str|None): mensaje de error si ok=False, None si ok=True.
              rows_returned (int): numero de filas devueltas si ok=True.

      Example:
          >>> result = metabase_validate_sql(client, 1, "SELECT id FROM orders LIMIT 1")
          >>> if not result["ok"]:
          ...     print("SQL invalido:", result["error"])
      """
      try:
          response = metabase_execute_query(client, database_id, sql, max_rows)
      except httpx.HTTPStatusError as exc:
          # Intentar extraer mensaje del body JSON de Metabase
          error_msg = _extract_metabase_error(exc)
          return {"ok": False, "error": error_msg, "rows_returned": 0}
      except Exception as exc:
          return {"ok": False, "error": str(exc), "rows_returned": 0}

      # Metabase puede devolver 200 con status: "failed" en el body
      status = response.get("status") if isinstance(response, dict) else None
      if status == "failed":
          error_msg = response.get("error") or "query fallida (sin mensaje)"
          return {"ok": False, "error": error_msg, "rows_returned": 0}

      # Contar filas retornadas
      rows_returned = 0
      if isinstance(response, dict):
          data = response.get("data", {})
          if isinstance(data, dict):
              rows = data.get("rows", [])
              if isinstance(rows, list):
                  rows_returned = len(rows)

      return {"ok": True, "error": None, "rows_returned": rows_returned}


  def _extract_metabase_error(exc: httpx.HTTPStatusError) -> str:
      """Extrae el mensaje de error legible del response de Metabase."""
      try:
          body = exc.response.json()
          if isinstance(body, dict):
              return body.get("error") or body.get("message") or str(exc)
      except Exception:
          pass
      return str(exc)

params_schema:
  {"params":[{"name":"payload","desc":"dict con los campos de la card a validar: name, display, dataset_query, type (opcional), visualization_settings (opcional), parameters (opcional), archived (opcional)"}],"output":"lista de strings describiendo cada issue estructural encontrado; lista vacia indica payload valido listo para enviarse a POST/PUT /api/card"}


id: metabase_validate_dashboard_payload_py_infra
name: metabase_validate_dashboard_payload
kind: function
lang: py
domain: infra
version: 1.0.0
purity: pure
signature: def metabase_validate_dashboard_payload(payload: dict, known_card_ids: set[int]) -> list[str]
tags: ["metabase","validation","dashboard","dashcard","overlap","pure","pre-flight","structural"]
uses_functions: []
uses_types: []
returns: []
imports: []
test_file_path: python/functions/metabase/validation_test.py
file_path: python/functions/metabase/validation.py
created_at: 2026-04-13T10:12:25Z
updated_at: 2026-04-13T10:12:25Z
props: []
emits: []
variant: []
content_hash: b9e70f4807e9dd3dc133c4cc3244a1e9ec42dec47541c4f4b6884d6c1d914567
description:
  Valida la estructura de un payload de dashboard de Metabase sin red. Verifica campos obligatorios, bounds de dashcards, referencias a cards conocidas y solapamientos entre dashcards. Acumula todos los issues.

example:
  ```python
  known_ids = {c["id"] for c in metabase_list_cards(client)}
  issues = metabase_validate_dashboard_payload(dashboard_payload, known_card_ids=known_ids)
  if issues:
      print("Dashboard invalido:")
      for issue in issues:
          print(f"  - {issue}")
  else:
      metabase_update_dashboard(client, dashboard_id, **dashboard_payload)
  ```

tests:
  ["dashboard valido sin dashcards","dashboard valido con dashcards","dashboard card id desconocido","dashboard card virtual null permitido","dashboard dashcards solapadas","dashboard dashcards adyacentes no solapan","dashboard col fuera de bounds","dashboard col mas size x excede grid","dashboard size y fuera de bounds","dashboard name ausente","dashboard tabs invalidos","dashboard parameters no list"]

notes:
  Bounds del grid de Metabase:
  - `col` in [0, 23], `row` >= 0
  - `size_x` in [1, 24], `size_y` in [1, 100]
  - `col + size_x <= 24` (no exceder el ancho del grid)

  Deteccion de solapamientos: O(n^2) sobre pares de dashcards. Optimo para dashboards tipicos (< 50 cards).

  Dashcards con `card_id = null` son virtuales (texto, headings, iframes) y se permiten sin verificar contra known_card_ids.

code:
  """Validacion estructural de cards y dashboards de Metabase antes de pusharlos a la API."""

  import httpx

  from .client import MetabaseClient
  from .cards import metabase_execute_query


  _VALID_DISPLAYS = {
      "scalar", "table", "line", "bar", "pie", "area", "row", "funnel",
      "smartscalar", "gauge", "progress", "combo", "pivot", "map", "scatter",
      "waterfall", "sankey", "object",
  }

  _VALID_TYPES = {"question", "model", "metric"}


  def metabase_validate_card_payload(payload: dict) -> list[str]:
      """Valida la estructura de un payload de card antes de enviarlo a Metabase.

      Comprueba invariantes estructurales sin necesidad de red. Recorre todos los
      checks y acumula todos los issues en lugar de abortar al primero.

      Args:
          payload: dict con los campos de la card a validar (name, display,
                   dataset_query, type, visualization_settings, parameters, archived).

      Returns:
          Lista de strings describiendo cada issue encontrado. Lista vacia = payload valido.

      Example:
          >>> issues = metabase_validate_card_payload({"name": "Revenue", "display": "bar",
          ...     "dataset_query": {"database": 1, "type": "native",
          ...                       "native": {"query": "SELECT 1"}}})
          >>> assert issues == []
      """
      issues: list[str] = []

      # --- name ---
      name = payload.get("name")
      if name is None:
          issues.append("campo 'name' ausente")
      elif not isinstance(name, str) or not name.strip():
          issues.append("campo 'name' debe ser un string no vacio")

      # --- display ---
      display = payload.get("display")
      if display is None:
          issues.append("campo 'display' ausente")
      elif display not in _VALID_DISPLAYS:
          validos = ", ".join(sorted(_VALID_DISPLAYS))
          issues.append(f"display '{display}' invalido (validos: {validos})")

      # --- type (opcional) ---
      card_type = payload.get("type")
      if card_type is not None and card_type not in _VALID_TYPES:
          validos = ", ".join(sorted(_VALID_TYPES))
          issues.append(f"type '{card_type}' invalido (validos: {validos})")

      # --- dataset_query ---
      dq = payload.get("dataset_query")
      if dq is None:
          issues.append("campo 'dataset_query' ausente")
      elif not isinstance(dq, dict):
          issues.append("campo 'dataset_query' debe ser un dict")
      else:
          # database presente
          if "database" not in dq:
              issues.append("'dataset_query.database' ausente")

          # deteccion de query nativa
          query_type = payload.get("query_type") or dq.get("type", "")
          is_native = query_type == "native"

          # Tambien chequear si stages[0] tiene clave "native" (formato MBQL5)
          stages = dq.get("stages", [])
          has_mbql5_native = (
              isinstance(stages, list)
              and len(stages) > 0
              and isinstance(stages[0], dict)
              and "native" in stages[0]
          )

          if is_native or has_mbql5_native:
              # Formato legacy: dataset_query.native.query
              legacy_sql = None
              native_block = dq.get("native")
              if isinstance(native_block, dict):
                  legacy_sql = native_block.get("query")

              # Formato MBQL5: dataset_query.stages[0].native
              mbql5_sql = None
              if has_mbql5_native:
                  mbql5_sql = stages[0].get("native")

              legacy_ok = isinstance(legacy_sql, str) and legacy_sql.strip()
              mbql5_ok = isinstance(mbql5_sql, str) and mbql5_sql.strip()

              if not legacy_ok and not mbql5_ok:
                  issues.append(
                      "query nativa sin SQL: falta 'dataset_query.native.query' "
                      "(legacy) o 'dataset_query.stages[0].native' (MBQL5)"
                  )

      # --- visualization_settings (opcional) ---
      vs = payload.get("visualization_settings")
      if vs is not None and not isinstance(vs, dict):
          issues.append("'visualization_settings' debe ser un dict")

      # --- parameters (opcional) ---
      params = payload.get("parameters")
      if params is not None and not isinstance(params, list):
          issues.append("'parameters' debe ser una list")

      # --- archived (opcional) ---
      archived = payload.get("archived")
      if archived is not None and not isinstance(archived, bool):
          issues.append("'archived' debe ser bool")

      return issues


  def metabase_validate_dashboard_payload(
      payload: dict,
      known_card_ids: set[int],
  ) -> list[str]:
      """Valida la estructura de un payload de dashboard antes de enviarlo a Metabase.

      Verifica campos obligatorios, bounds de dashcards, referencias a cards y
      solapamientos entre dashcards. Acumula todos los issues sin abortar.

      Args:
          payload: dict con los campos del dashboard (name, dashcards, tabs, parameters).
          known_card_ids: conjunto de IDs de cards conocidos; las dashcards con
                          card_id entero deben referenciar un ID de este conjunto.

      Returns:
          Lista de strings describiendo cada issue encontrado. Lista vacia = payload valido.

      Example:
          >>> issues = metabase_validate_dashboard_payload(
          ...     {"name": "KPIs", "dashcards": []},
          ...     known_card_ids={1, 2, 3},
          ... )
          >>> assert issues == []
      """
      issues: list[str] = []

      # --- name ---
      name = payload.get("name")
      if name is None:
          issues.append("campo 'name' ausente")
      elif not isinstance(name, str) or not name.strip():
          issues.append("campo 'name' debe ser un string no vacio")

      # --- dashcards (opcional, pero si esta, debe ser list) ---
      dashcards = payload.get("dashcards")
      if dashcards is not None:
          if not isinstance(dashcards, list):
              issues.append("'dashcards' debe ser una list")
          else:
              valid_rects: list[tuple[int, int, int, int, int]] = []  # (idx, row, col, sx, sy)

              for i, dc in enumerate(dashcards):
                  if not isinstance(dc, dict):
                      issues.append(f"dashcard[{i}] debe ser un dict")
                      continue

                  # card_id: si es int debe estar en known_card_ids; null es virtual (ok)
                  card_id = dc.get("card_id")
                  if card_id is not None:
                      if not isinstance(card_id, int):
                          issues.append(f"dashcard[{i}].card_id debe ser int o null")
                      elif card_id not in known_card_ids:
                          issues.append(
                              f"dashcard[{i}].card_id={card_id} no existe en las cards conocidas"
                          )

                  # Campos de posicion y tamanio
                  missing = [f for f in ("row", "col", "size_x", "size_y") if f not in dc]
                  if missing:
                      issues.append(
                          f"dashcard[{i}] falta campos de layout: {', '.join(missing)}"
                      )
                      continue

                  row = dc["row"]
                  col = dc["col"]
                  size_x = dc["size_x"]
                  size_y = dc["size_y"]

                  for fname, val in (("row", row), ("col", col), ("size_x", size_x), ("size_y", size_y)):
                      if not isinstance(val, int):
                          issues.append(f"dashcard[{i}].{fname} debe ser int")

                  if not isinstance(row, int) or not isinstance(col, int) or \
                     not isinstance(size_x, int) or not isinstance(size_y, int):
                      continue  # ya reportado arriba

                  # Bounds
                  if row < 0:
                      issues.append(f"dashcard[{i}].row={row} debe ser >= 0")
                  if not 0 <= col <= 23:
                      issues.append(f"dashcard[{i}].col={col} debe estar en [0, 23]")
                  if not 1 <= size_x <= 24:
                      issues.append(f"dashcard[{i}].size_x={size_x} debe estar en [1, 24]")
                  if not 1 <= size_y <= 100:
                      issues.append(f"dashcard[{i}].size_y={size_y} debe estar en [1, 100]")
                  if col + size_x > 24:
                      issues.append(
                          f"dashcard[{i}] excede el ancho del grid: col={col} + size_x={size_x} = {col + size_x} > 24"
                      )

                  valid_rects.append((i, row, col, size_x, size_y))

              # Deteccion de solapamientos O(n^2) — dashboards tipicos tienen < 50 cards
              for a in range(len(valid_rects)):
                  for b in range(a + 1, len(valid_rects)):
                      ia, ra, ca, sxa, sya = valid_rects[a]
                      ib, rb, cb, sxb, syb = valid_rects[b]

                      # Rectangulos: [ca, ca+sxa) x [ra, ra+sya) y [cb, cb+sxb) x [rb, rb+syb)
                      overlap_x = ca < cb + sxb and cb < ca + sxa
                      overlap_y = ra < rb + syb and rb < ra + sya

                      if overlap_x and overlap_y:
                          issues.append(
                              f"dashcards en posiciones (row={ra},col={ca},{sxa}x{sya}) "
                              f"y (row={rb},col={cb},{sxb}x{syb}) solapan"
                          )

      # --- tabs (opcional) ---
      tabs = payload.get("tabs")
      if tabs is not None:
          if not isinstance(tabs, list):
              issues.append("'tabs' debe ser una list")
          else:
              for i, tab in enumerate(tabs):
                  if not isinstance(tab, dict):
                      issues.append(f"tabs[{i}] debe ser un dict")
                      continue
                  if "id" not in tab:
                      issues.append(f"tabs[{i}] falta campo 'id'")
                  if "name" not in tab:
                      issues.append(f"tabs[{i}] falta campo 'name'")

      # --- parameters (opcional) ---
      params = payload.get("parameters")
      if params is not None and not isinstance(params, list):
          issues.append("'parameters' debe ser una list")

      return issues


  def metabase_validate_sql(
      client: MetabaseClient,
      database_id: int,
      sql: str,
      max_rows: int = 0,
  ) -> dict:
      """Valida sintaxis y referencias de SQL ejecutandolo contra Metabase.

      Ejecuta la query via POST /api/dataset con LIMIT implicito (max_rows=1 si
      el caller no especifica nada, para minimizar carga). Captura tanto errores
      HTTP como errores embebidos en el body (Metabase a veces devuelve 200 + status failed).

      Args:
          client: instancia autenticada de MetabaseClient.
          database_id: ID de la base de datos donde ejecutar el SQL.
          sql: sentencia SQL a validar (SELECT, WITH, etc.).
          max_rows: limite de filas a retornar para la validacion (0 = default de Metabase).

      Returns:
          dict con:
              ok (bool): True si la query se ejecuto sin errores.
              error (str|None): mensaje de error si ok=False, None si ok=True.
              rows_returned (int): numero de filas devueltas si ok=True.

      Example:
          >>> result = metabase_validate_sql(client, 1, "SELECT id FROM orders LIMIT 1")
          >>> if not result["ok"]:
          ...     print("SQL invalido:", result["error"])
      """
      try:
          response = metabase_execute_query(client, database_id, sql, max_rows)
      except httpx.HTTPStatusError as exc:
          # Intentar extraer mensaje del body JSON de Metabase
          error_msg = _extract_metabase_error(exc)
          return {"ok": False, "error": error_msg, "rows_returned": 0}
      except Exception as exc:
          return {"ok": False, "error": str(exc), "rows_returned": 0}

      # Metabase puede devolver 200 con status: "failed" en el body
      status = response.get("status") if isinstance(response, dict) else None
      if status == "failed":
          error_msg = response.get("error") or "query fallida (sin mensaje)"
          return {"ok": False, "error": error_msg, "rows_returned": 0}

      # Contar filas retornadas
      rows_returned = 0
      if isinstance(response, dict):
          data = response.get("data", {})
          if isinstance(data, dict):
              rows = data.get("rows", [])
              if isinstance(rows, list):
                  rows_returned = len(rows)

      return {"ok": True, "error": None, "rows_returned": rows_returned}


  def _extract_metabase_error(exc: httpx.HTTPStatusError) -> str:
      """Extrae el mensaje de error legible del response de Metabase."""
      try:
          body = exc.response.json()
          if isinstance(body, dict):
              return body.get("error") or body.get("message") or str(exc)
      except Exception:
          pass
      return str(exc)

params_schema:
  {"params":[{"name":"payload","desc":"dict con los campos del dashboard a validar: name, dashcards (opcional), tabs (opcional), parameters (opcional)"},{"name":"known_card_ids","desc":"conjunto de IDs enteros de cards que existen en Metabase; las dashcards con card_id entero deben referenciar un ID de este conjunto. Pasar set vacio si no se quiere verificar referencias."}],"output":"lista de strings describiendo cada issue encontrado; lista vacia indica payload valido listo para enviarse a PUT /api/dashboard/:id"}


id: metabase_validate_sql_py_infra
name: metabase_validate_sql
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
tags: ["metabase","validation","sql","pre-flight","impure","query","syntax-check"]
uses_functions: ["metabase_execute_query_py_infra","metabase_auth_py_infra"]
uses_types: []
returns: []
error_type: error_go_core
imports: ["httpx"]
tests: []
file_path: python/functions/metabase/validation.py
created_at: 2026-04-13T10:12:25Z
updated_at: 2026-04-13T10:12:25Z
props: []
emits: []
variant: []
content_hash: bc5925dd82775fc1580cb642023eb808bfa1b268ac75d72be6b4b74bed28fd92
signature:
  def metabase_validate_sql(client: MetabaseClient, database_id: int, sql: str, max_rows: int = 0) -> dict

description:
  Valida sintaxis y referencias de una query SQL ejecutandola contra Metabase via POST /api/dataset. Captura tanto errores HTTP como errores embebidos en el body (Metabase a veces devuelve 200 con status failed). Retorna ok, error y rows_returned.

example:
  ```python
  result = metabase_validate_sql(client, database_id=1, sql="SELECT id FROM orders LIMIT 1")
  if not result["ok"]:
      print(f"SQL invalido: {result['error']}")
  else:
      print(f"SQL valido, {result['rows_returned']} filas retornadas")
  ```

notes:
  No tiene tests automatizados porque requiere una instancia Metabase corriendo con una base de datos real. Para testear manualmente:

  ```python
  client = metabase_auth("http://localhost:3000", "admin@example.com", "pass")
  result = metabase_validate_sql(client, 1, "SELECT * FROM tabla_inexistente")
  # result["ok"] == False, result["error"] == "Table 'tabla_inexistente' not found"
  ```

  Comportamiento ante errores:
  - `httpx.HTTPStatusError` (4xx/5xx): extrae el campo `error` o `message` del JSON del response de Metabase.
  - Body con `status: "failed"` (Metabase devuelve 200 en algunos errores de query): captura el campo `error` del body.
  - Cualquier otra excepcion: convierte a string y la incluye en el campo `error`.

  Usa `metabase_execute_query` internamente, que mapea a POST /api/dataset.

code:
  """Validacion estructural de cards y dashboards de Metabase antes de pusharlos a la API."""

  import httpx

  from .client import MetabaseClient
  from .cards import metabase_execute_query


  _VALID_DISPLAYS = {
      "scalar", "table", "line", "bar", "pie", "area", "row", "funnel",
      "smartscalar", "gauge", "progress", "combo", "pivot", "map", "scatter",
      "waterfall", "sankey", "object",
  }

  _VALID_TYPES = {"question", "model", "metric"}


  def metabase_validate_card_payload(payload: dict) -> list[str]:
      """Valida la estructura de un payload de card antes de enviarlo a Metabase.

      Comprueba invariantes estructurales sin necesidad de red. Recorre todos los
      checks y acumula todos los issues en lugar de abortar al primero.

      Args:
          payload: dict con los campos de la card a validar (name, display,
                   dataset_query, type, visualization_settings, parameters, archived).

      Returns:
          Lista de strings describiendo cada issue encontrado. Lista vacia = payload valido.

      Example:
          >>> issues = metabase_validate_card_payload({"name": "Revenue", "display": "bar",
          ...     "dataset_query": {"database": 1, "type": "native",
          ...                       "native": {"query": "SELECT 1"}}})
          >>> assert issues == []
      """
      issues: list[str] = []

      # --- name ---
      name = payload.get("name")
      if name is None:
          issues.append("campo 'name' ausente")
      elif not isinstance(name, str) or not name.strip():
          issues.append("campo 'name' debe ser un string no vacio")

      # --- display ---
      display = payload.get("display")
      if display is None:
          issues.append("campo 'display' ausente")
      elif display not in _VALID_DISPLAYS:
          validos = ", ".join(sorted(_VALID_DISPLAYS))
          issues.append(f"display '{display}' invalido (validos: {validos})")

      # --- type (opcional) ---
      card_type = payload.get("type")
      if card_type is not None and card_type not in _VALID_TYPES:
          validos = ", ".join(sorted(_VALID_TYPES))
          issues.append(f"type '{card_type}' invalido (validos: {validos})")

      # --- dataset_query ---
      dq = payload.get("dataset_query")
      if dq is None:
          issues.append("campo 'dataset_query' ausente")
      elif not isinstance(dq, dict):
          issues.append("campo 'dataset_query' debe ser un dict")
      else:
          # database presente
          if "database" not in dq:
              issues.append("'dataset_query.database' ausente")

          # deteccion de query nativa
          query_type = payload.get("query_type") or dq.get("type", "")
          is_native = query_type == "native"

          # Tambien chequear si stages[0] tiene clave "native" (formato MBQL5)
          stages = dq.get("stages", [])
          has_mbql5_native = (
              isinstance(stages, list)
              and len(stages) > 0
              and isinstance(stages[0], dict)
              and "native" in stages[0]
          )

          if is_native or has_mbql5_native:
              # Formato legacy: dataset_query.native.query
              legacy_sql = None
              native_block = dq.get("native")
              if isinstance(native_block, dict):
                  legacy_sql = native_block.get("query")

              # Formato MBQL5: dataset_query.stages[0].native
              mbql5_sql = None
              if has_mbql5_native:
                  mbql5_sql = stages[0].get("native")

              legacy_ok = isinstance(legacy_sql, str) and legacy_sql.strip()
              mbql5_ok = isinstance(mbql5_sql, str) and mbql5_sql.strip()

              if not legacy_ok and not mbql5_ok:
                  issues.append(
                      "query nativa sin SQL: falta 'dataset_query.native.query' "
                      "(legacy) o 'dataset_query.stages[0].native' (MBQL5)"
                  )

      # --- visualization_settings (opcional) ---
      vs = payload.get("visualization_settings")
      if vs is not None and not isinstance(vs, dict):
          issues.append("'visualization_settings' debe ser un dict")

      # --- parameters (opcional) ---
      params = payload.get("parameters")
      if params is not None and not isinstance(params, list):
          issues.append("'parameters' debe ser una list")

      # --- archived (opcional) ---
      archived = payload.get("archived")
      if archived is not None and not isinstance(archived, bool):
          issues.append("'archived' debe ser bool")

      return issues


  def metabase_validate_dashboard_payload(
      payload: dict,
      known_card_ids: set[int],
  ) -> list[str]:
      """Valida la estructura de un payload de dashboard antes de enviarlo a Metabase.

      Verifica campos obligatorios, bounds de dashcards, referencias a cards y
      solapamientos entre dashcards. Acumula todos los issues sin abortar.

      Args:
          payload: dict con los campos del dashboard (name, dashcards, tabs, parameters).
          known_card_ids: conjunto de IDs de cards conocidos; las dashcards con
                          card_id entero deben referenciar un ID de este conjunto.

      Returns:
          Lista de strings describiendo cada issue encontrado. Lista vacia = payload valido.

      Example:
          >>> issues = metabase_validate_dashboard_payload(
          ...     {"name": "KPIs", "dashcards": []},
          ...     known_card_ids={1, 2, 3},
          ... )
          >>> assert issues == []
      """
      issues: list[str] = []

      # --- name ---
      name = payload.get("name")
      if name is None:
          issues.append("campo 'name' ausente")
      elif not isinstance(name, str) or not name.strip():
          issues.append("campo 'name' debe ser un string no vacio")

      # --- dashcards (opcional, pero si esta, debe ser list) ---
      dashcards = payload.get("dashcards")
      if dashcards is not None:
          if not isinstance(dashcards, list):
              issues.append("'dashcards' debe ser una list")
          else:
              valid_rects: list[tuple[int, int, int, int, int]] = []  # (idx, row, col, sx, sy)

              for i, dc in enumerate(dashcards):
                  if not isinstance(dc, dict):
                      issues.append(f"dashcard[{i}] debe ser un dict")
                      continue

                  # card_id: si es int debe estar en known_card_ids; null es virtual (ok)
                  card_id = dc.get("card_id")
                  if card_id is not None:
                      if not isinstance(card_id, int):
                          issues.append(f"dashcard[{i}].card_id debe ser int o null")
                      elif card_id not in known_card_ids:
                          issues.append(
                              f"dashcard[{i}].card_id={card_id} no existe en las cards conocidas"
                          )

                  # Campos de posicion y tamanio
                  missing = [f for f in ("row", "col", "size_x", "size_y") if f not in dc]
                  if missing:
                      issues.append(
                          f"dashcard[{i}] falta campos de layout: {', '.join(missing)}"
                      )
                      continue

                  row = dc["row"]
                  col = dc["col"]
                  size_x = dc["size_x"]
                  size_y = dc["size_y"]

                  for fname, val in (("row", row), ("col", col), ("size_x", size_x), ("size_y", size_y)):
                      if not isinstance(val, int):
                          issues.append(f"dashcard[{i}].{fname} debe ser int")

                  if not isinstance(row, int) or not isinstance(col, int) or \
                     not isinstance(size_x, int) or not isinstance(size_y, int):
                      continue  # ya reportado arriba

                  # Bounds
                  if row < 0:
                      issues.append(f"dashcard[{i}].row={row} debe ser >= 0")
                  if not 0 <= col <= 23:
                      issues.append(f"dashcard[{i}].col={col} debe estar en [0, 23]")
                  if not 1 <= size_x <= 24:
                      issues.append(f"dashcard[{i}].size_x={size_x} debe estar en [1, 24]")
                  if not 1 <= size_y <= 100:
                      issues.append(f"dashcard[{i}].size_y={size_y} debe estar en [1, 100]")
                  if col + size_x > 24:
                      issues.append(
                          f"dashcard[{i}] excede el ancho del grid: col={col} + size_x={size_x} = {col + size_x} > 24"
                      )

                  valid_rects.append((i, row, col, size_x, size_y))

              # Deteccion de solapamientos O(n^2) — dashboards tipicos tienen < 50 cards
              for a in range(len(valid_rects)):
                  for b in range(a + 1, len(valid_rects)):
                      ia, ra, ca, sxa, sya = valid_rects[a]
                      ib, rb, cb, sxb, syb = valid_rects[b]

                      # Rectangulos: [ca, ca+sxa) x [ra, ra+sya) y [cb, cb+sxb) x [rb, rb+syb)
                      overlap_x = ca < cb + sxb and cb < ca + sxa
                      overlap_y = ra < rb + syb and rb < ra + sya

                      if overlap_x and overlap_y:
                          issues.append(
                              f"dashcards en posiciones (row={ra},col={ca},{sxa}x{sya}) "
                              f"y (row={rb},col={cb},{sxb}x{syb}) solapan"
                          )

      # --- tabs (opcional) ---
      tabs = payload.get("tabs")
      if tabs is not None:
          if not isinstance(tabs, list):
              issues.append("'tabs' debe ser una list")
          else:
              for i, tab in enumerate(tabs):
                  if not isinstance(tab, dict):
                      issues.append(f"tabs[{i}] debe ser un dict")
                      continue
                  if "id" not in tab:
                      issues.append(f"tabs[{i}] falta campo 'id'")
                  if "name" not in tab:
                      issues.append(f"tabs[{i}] falta campo 'name'")

      # --- parameters (opcional) ---
      params = payload.get("parameters")
      if params is not None and not isinstance(params, list):
          issues.append("'parameters' debe ser una list")

      return issues


  def metabase_validate_sql(
      client: MetabaseClient,
      database_id: int,
      sql: str,
      max_rows: int = 0,
  ) -> dict:
      """Valida sintaxis y referencias de SQL ejecutandolo contra Metabase.

      Ejecuta la query via POST /api/dataset con LIMIT implicito (max_rows=1 si
      el caller no especifica nada, para minimizar carga). Captura tanto errores
      HTTP como errores embebidos en el body (Metabase a veces devuelve 200 + status failed).

      Args:
          client: instancia autenticada de MetabaseClient.
          database_id: ID de la base de datos donde ejecutar el SQL.
          sql: sentencia SQL a validar (SELECT, WITH, etc.).
          max_rows: limite de filas a retornar para la validacion (0 = default de Metabase).

      Returns:
          dict con:
              ok (bool): True si la query se ejecuto sin errores.
              error (str|None): mensaje de error si ok=False, None si ok=True.
              rows_returned (int): numero de filas devueltas si ok=True.

      Example:
          >>> result = metabase_validate_sql(client, 1, "SELECT id FROM orders LIMIT 1")
          >>> if not result["ok"]:
          ...     print("SQL invalido:", result["error"])
      """
      try:
          response = metabase_execute_query(client, database_id, sql, max_rows)
      except httpx.HTTPStatusError as exc:
          # Intentar extraer mensaje del body JSON de Metabase
          error_msg = _extract_metabase_error(exc)
          return {"ok": False, "error": error_msg, "rows_returned": 0}
      except Exception as exc:
          return {"ok": False, "error": str(exc), "rows_returned": 0}

      # Metabase puede devolver 200 con status: "failed" en el body
      status = response.get("status") if isinstance(response, dict) else None
      if status == "failed":
          error_msg = response.get("error") or "query fallida (sin mensaje)"
          return {"ok": False, "error": error_msg, "rows_returned": 0}

      # Contar filas retornadas
      rows_returned = 0
      if isinstance(response, dict):
          data = response.get("data", {})
          if isinstance(data, dict):
              rows = data.get("rows", [])
              if isinstance(rows, list):
                  rows_returned = len(rows)

      return {"ok": True, "error": None, "rows_returned": rows_returned}


  def _extract_metabase_error(exc: httpx.HTTPStatusError) -> str:
      """Extrae el mensaje de error legible del response de Metabase."""
      try:
          body = exc.response.json()
          if isinstance(body, dict):
              return body.get("error") or body.get("message") or str(exc)
      except Exception:
          pass
      return str(exc)

params_schema:
  {"params":[{"name":"client","desc":"instancia autenticada de MetabaseClient con sesion activa"},{"name":"database_id","desc":"ID de la base de datos en Metabase contra la que ejecutar el SQL"},{"name":"sql","desc":"sentencia SQL a validar (SELECT, WITH, etc.) — se ejecuta realmente contra la BD"},{"name":"max_rows","desc":"limite de filas a retornar durante la validacion para minimizar carga (0 = default de Metabase, tipicamente 2000)"}],"output":"dict con tres claves: ok (bool) indica si la query se ejecuto sin error; error (str|None) mensaje de error si ok=False; rows_returned (int) numero de filas devueltas si ok=True"}


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


