diff --git a/python/.python-version b/python/.python-version new file mode 100644 index 00000000..e4fba218 --- /dev/null +++ b/python/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/python/functions/__init__.py b/python/functions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/python/functions/__pycache__/__init__.cpython-312.pyc b/python/functions/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..14d1dde4 Binary files /dev/null and b/python/functions/__pycache__/__init__.cpython-312.pyc differ diff --git a/python/functions/metabase/__init__.py b/python/functions/metabase/__init__.py new file mode 100644 index 00000000..bccd53c3 --- /dev/null +++ b/python/functions/metabase/__init__.py @@ -0,0 +1,11 @@ +from .client import MetabaseClient +from .users import metabase_list_users, metabase_get_user, metabase_create_user, metabase_update_user, metabase_deactivate_user +from .cards import metabase_list_cards, metabase_get_card, metabase_create_card, metabase_update_card, metabase_delete_card, metabase_execute_card, metabase_execute_query +from .dashboards import metabase_list_dashboards, metabase_get_dashboard, metabase_create_dashboard, metabase_update_dashboard, metabase_delete_dashboard + +__all__ = [ + "MetabaseClient", + "metabase_list_users", "metabase_get_user", "metabase_create_user", "metabase_update_user", "metabase_deactivate_user", + "metabase_list_cards", "metabase_get_card", "metabase_create_card", "metabase_update_card", "metabase_delete_card", "metabase_execute_card", "metabase_execute_query", + "metabase_list_dashboards", "metabase_get_dashboard", "metabase_create_dashboard", "metabase_update_dashboard", "metabase_delete_dashboard", +] diff --git a/python/functions/metabase/__pycache__/__init__.cpython-312.pyc b/python/functions/metabase/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 00000000..9c715be9 Binary files /dev/null and b/python/functions/metabase/__pycache__/__init__.cpython-312.pyc differ diff --git a/python/functions/metabase/__pycache__/cards.cpython-312.pyc b/python/functions/metabase/__pycache__/cards.cpython-312.pyc new file mode 100644 index 00000000..41986164 Binary files /dev/null and b/python/functions/metabase/__pycache__/cards.cpython-312.pyc differ diff --git a/python/functions/metabase/__pycache__/client.cpython-312.pyc b/python/functions/metabase/__pycache__/client.cpython-312.pyc new file mode 100644 index 00000000..a7495427 Binary files /dev/null and b/python/functions/metabase/__pycache__/client.cpython-312.pyc differ diff --git a/python/functions/metabase/__pycache__/dashboards.cpython-312.pyc b/python/functions/metabase/__pycache__/dashboards.cpython-312.pyc new file mode 100644 index 00000000..4bc63af2 Binary files /dev/null and b/python/functions/metabase/__pycache__/dashboards.cpython-312.pyc differ diff --git a/python/functions/metabase/__pycache__/users.cpython-312.pyc b/python/functions/metabase/__pycache__/users.cpython-312.pyc new file mode 100644 index 00000000..752efbd2 Binary files /dev/null and b/python/functions/metabase/__pycache__/users.cpython-312.pyc differ diff --git a/python/functions/metabase/cards.py b/python/functions/metabase/cards.py new file mode 100644 index 00000000..de22cd3e --- /dev/null +++ b/python/functions/metabase/cards.py @@ -0,0 +1,227 @@ +"""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) diff --git a/python/functions/metabase/client.py b/python/functions/metabase/client.py new file mode 100644 index 00000000..a3b4ceaf --- /dev/null +++ b/python/functions/metabase/client.py @@ -0,0 +1,87 @@ +"""Cliente base para la API REST de Metabase.""" + +import httpx + + +class MetabaseClient: + """Cliente HTTP para una instancia Metabase. + + Attributes: + base_url: URL base sin trailing slash (ej: "http://localhost:3000"). + token: Session token o API key. + _http: Cliente httpx reutilizable con headers de auth. + """ + + def __init__(self, base_url: str, token: str) -> None: + self.base_url = base_url.rstrip("/") + self.token = token + self._http = httpx.Client( + base_url=self.base_url, + headers={ + "Content-Type": "application/json", + "X-Metabase-Session": token, + }, + timeout=30.0, + ) + + def request(self, method: str, path: str, **kwargs) -> dict | list | None: + """Ejecuta una peticion HTTP contra la API de Metabase. + + Args: + method: HTTP method (GET, POST, PUT, DELETE). + path: Ruta relativa (ej: "/api/user"). + **kwargs: Argumentos extra para httpx (json, params, etc.). + + Returns: + Respuesta deserializada como dict/list, o None si el body esta vacio. + + Raises: + httpx.HTTPStatusError: Si el status code no es 2xx. + """ + resp = self._http.request(method, path, **kwargs) + resp.raise_for_status() + if not resp.content: + return None + return resp.json() + + def close(self) -> None: + """Cierra el cliente HTTP.""" + self._http.close() + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + +def metabase_auth(base_url: str, email: str, password: str) -> MetabaseClient: + """Autentica contra Metabase con email y password. + + Crea una sesion via POST /api/session y retorna un MetabaseClient + con el session token listo para usar. El token expira en 14 dias + por defecto (configurable con MAX_SESSION_AGE en Metabase). + + Args: + base_url: URL base de la instancia (ej: "http://localhost:3000"). + email: Email del usuario Metabase. + password: Password del usuario. + + Returns: + MetabaseClient autenticado con session token. + + Raises: + httpx.HTTPStatusError: Si las credenciales son invalidas (401) + o hay rate limiting. + + Example: + >>> client = metabase_auth("http://localhost:3000", "admin@example.com", "pass") + >>> # client listo para usar con todas las funciones CRUD + """ + resp = httpx.post( + f"{base_url.rstrip('/')}/api/session", + json={"username": email, "password": password}, + ) + resp.raise_for_status() + token = resp.json()["id"] + return MetabaseClient(base_url, token) diff --git a/python/functions/metabase/dashboards.py b/python/functions/metabase/dashboards.py new file mode 100644 index 00000000..f298c7ae --- /dev/null +++ b/python/functions/metabase/dashboards.py @@ -0,0 +1,143 @@ +"""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}") diff --git a/python/functions/metabase/metabase_auth.md b/python/functions/metabase/metabase_auth.md new file mode 100644 index 00000000..2b3ef8f9 --- /dev/null +++ b/python/functions/metabase/metabase_auth.md @@ -0,0 +1,46 @@ +--- +name: metabase_auth +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_auth(base_url: str, email: str, password: str) -> MetabaseClient" +description: "Autentica contra la API de Metabase con email y password. Retorna un MetabaseClient con session token valido por 14 dias. Endpoint: POST /api/session." +tags: [metabase, auth, session, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/client.py" +--- + +## Ejemplo + +```python +from functions.metabase import metabase_auth + +client = metabase_auth("http://localhost:3000", "admin@example.com", "pass") +# client listo para usar con todas las funciones CRUD + +# Alternativa con API key: +from functions.metabase import MetabaseClient +client = MetabaseClient("http://localhost:3000", "mb_api_key_xxxxx") +``` + +## Notas + +Dos formas de obtener un client: +- `metabase_auth()`: login con email/password, obtiene session token via POST /api/session +- `MetabaseClient(base_url, api_key)`: constructor directo con API key (recomendado para automatizacion) + +El client es un context manager: `with metabase_auth(...) as client:` + +Errores comunes: +- 401: credenciales invalidas +- Rate limiting en intentos fallidos de login diff --git a/python/functions/metabase/metabase_create_card.md b/python/functions/metabase/metabase_create_card.md new file mode 100644 index 00000000..c99fc347 --- /dev/null +++ b/python/functions/metabase/metabase_create_card.md @@ -0,0 +1,35 @@ +--- +name: metabase_create_card +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +signature: "def metabase_create_card(client: MetabaseClient, name: str, dataset_query: dict, display: str = 'table', collection_id: int = 0, description: str = '') -> dict" +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: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +card = metabase_create_card(client, "Revenue", { + "database": 1, "type": "native", + "native": {"query": "SELECT SUM(total) FROM orders"}, +}, display="scalar") +``` + +## Notas + +dataset_query SQL nativo: `{"database": id, "type": "native", "native": {"query": "..."}}` +dataset_query MBQL: `{"database": id, "type": "query", "query": {"source-table": id, ...}}` diff --git a/python/functions/metabase/metabase_create_dashboard.md b/python/functions/metabase/metabase_create_dashboard.md new file mode 100644 index 00000000..d715227e --- /dev/null +++ b/python/functions/metabase/metabase_create_dashboard.md @@ -0,0 +1,32 @@ +--- +name: metabase_create_dashboard +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +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." +tags: [metabase, dashboard, create, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/dashboards.py" +--- + +## Ejemplo + +```python +dash = metabase_create_dashboard(client, "Sales Overview", "KPIs") +# Agregar cards con metabase_update_dashboard +``` + +## Notas + +Se crea vacio. Agregar cards con metabase_update_dashboard(dashcards=[...]). diff --git a/python/functions/metabase/metabase_create_user.md b/python/functions/metabase/metabase_create_user.md new file mode 100644 index 00000000..688d0890 --- /dev/null +++ b/python/functions/metabase/metabase_create_user.md @@ -0,0 +1,32 @@ +--- +name: metabase_create_user +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +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." +tags: [metabase, user, create, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/users.py" +--- + +## Ejemplo + +```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]) +``` + +## Notas + +Email debe ser unico. Error 400 si ya existe. diff --git a/python/functions/metabase/metabase_deactivate_user.md b/python/functions/metabase/metabase_deactivate_user.md new file mode 100644 index 00000000..21ccd678 --- /dev/null +++ b/python/functions/metabase/metabase_deactivate_user.md @@ -0,0 +1,31 @@ +--- +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" +description: "Desactiva (soft-delete) un usuario en Metabase. Reactivar con PUT /api/user/:id/reactivate. Endpoint: DELETE /api/user/:id." +tags: [metabase, user, delete, deactivate, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/users.py" +--- + +## Ejemplo + +```python +metabase_deactivate_user(client, 5) +``` + +## Notas + +Soft-delete. El usuario se puede reactivar. diff --git a/python/functions/metabase/metabase_delete_card.md b/python/functions/metabase/metabase_delete_card.md new file mode 100644 index 00000000..eea33cf3 --- /dev/null +++ b/python/functions/metabase/metabase_delete_card.md @@ -0,0 +1,32 @@ +--- +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" +description: "Elimina permanentemente una card/pregunta. IRREVERSIBLE. Preferir archived=True. Endpoint: DELETE /api/card/:id." +tags: [metabase, card, question, delete, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +metabase_delete_card(client, 42) +# Preferir: metabase_update_card(client, 42, archived=True) +``` + +## Notas + +IRREVERSIBLE. Preferir soft-delete con metabase_update_card(archived=True). diff --git a/python/functions/metabase/metabase_delete_dashboard.md b/python/functions/metabase/metabase_delete_dashboard.md new file mode 100644 index 00000000..c4bedb14 --- /dev/null +++ b/python/functions/metabase/metabase_delete_dashboard.md @@ -0,0 +1,32 @@ +--- +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" +description: "Elimina permanentemente un dashboard. IRREVERSIBLE. Preferir archived=True. Endpoint: DELETE /api/dashboard/:id." +tags: [metabase, dashboard, delete, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/dashboards.py" +--- + +## Ejemplo + +```python +metabase_delete_dashboard(client, 1) +# Preferir: metabase_update_dashboard(client, 1, archived=True) +``` + +## Notas + +IRREVERSIBLE. Preferir soft-delete con metabase_update_dashboard(archived=True). diff --git a/python/functions/metabase/metabase_execute_card.md b/python/functions/metabase/metabase_execute_card.md new file mode 100644 index 00000000..0c2dbad7 --- /dev/null +++ b/python/functions/metabase/metabase_execute_card.md @@ -0,0 +1,34 @@ +--- +name: metabase_execute_card +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +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." +tags: [metabase, card, question, execute, query, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +result = metabase_execute_card(client, 42) +for row in result["data"]["rows"]: + print(row) +``` + +## Notas + +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. diff --git a/python/functions/metabase/metabase_execute_query.md b/python/functions/metabase/metabase_execute_query.md new file mode 100644 index 00000000..5480c36f --- /dev/null +++ b/python/functions/metabase/metabase_execute_query.md @@ -0,0 +1,32 @@ +--- +name: metabase_execute_query +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +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." +tags: [metabase, query, execute, sql, dataset, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +result = metabase_execute_query(client, 1, "SELECT * FROM users LIMIT 10") +print(f"{result['row_count']} filas en {result['running_time']}ms") +``` + +## Notas + +Misma respuesta que metabase_execute_card. Default 2000 filas, override con max_results. diff --git a/python/functions/metabase/metabase_get_card.md b/python/functions/metabase/metabase_get_card.md new file mode 100644 index 00000000..f568546d --- /dev/null +++ b/python/functions/metabase/metabase_get_card.md @@ -0,0 +1,32 @@ +--- +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" +description: "Obtiene detalles completos de una card/pregunta de Metabase incluyendo query, visualizacion y metadata. Endpoint: GET /api/card/:id." +tags: [metabase, card, question, get, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +card = metabase_get_card(client, 42) +print(card["name"], card["display"]) +``` + +## Notas + +Error 404 si no existe. diff --git a/python/functions/metabase/metabase_get_dashboard.md b/python/functions/metabase/metabase_get_dashboard.md new file mode 100644 index 00000000..073d21df --- /dev/null +++ b/python/functions/metabase/metabase_get_dashboard.md @@ -0,0 +1,33 @@ +--- +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" +description: "Obtiene dashboard completo con dashcards (cards posicionadas), tabs y parametros. Endpoint: GET /api/dashboard/:id." +tags: [metabase, dashboard, get, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/dashboards.py" +--- + +## Ejemplo + +```python +dash = metabase_get_dashboard(client, 1) +for dc in dash["dashcards"]: + print(f"Card {dc['card_id']} at ({dc['col']}, {dc['row']})") +``` + +## Notas + +Cada dashcard tiene: id, card_id, card, size_x, size_y, col, row, dashboard_tab_id, parameter_mappings. diff --git a/python/functions/metabase/metabase_get_user.md b/python/functions/metabase/metabase_get_user.md new file mode 100644 index 00000000..8dcda611 --- /dev/null +++ b/python/functions/metabase/metabase_get_user.md @@ -0,0 +1,32 @@ +--- +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: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/users.py" +--- + +## Ejemplo + +```python +user = metabase_get_user(client, 1) +print(user["email"], user["is_superuser"]) +``` + +## Notas + +Error 404 si el usuario no existe. diff --git a/python/functions/metabase/metabase_list_cards.md b/python/functions/metabase/metabase_list_cards.md new file mode 100644 index 00000000..cb903582 --- /dev/null +++ b/python/functions/metabase/metabase_list_cards.md @@ -0,0 +1,32 @@ +--- +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]" +description: "Lista preguntas/cards de Metabase. Filtros: all, mine, fav, archived, recent, popular, database, table. Endpoint: GET /api/card." +tags: [metabase, card, question, list, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +cards = metabase_list_cards(client, filter="mine") +cards = metabase_list_cards(client, filter="database", model_id=1) +``` + +## Notas + +No tiene paginacion offset/limit. Retorna todas las cards que coinciden. diff --git a/python/functions/metabase/metabase_list_dashboards.md b/python/functions/metabase/metabase_list_dashboards.md new file mode 100644 index 00000000..ae87396e --- /dev/null +++ b/python/functions/metabase/metabase_list_dashboards.md @@ -0,0 +1,33 @@ +--- +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]" +description: "Lista dashboards de Metabase. Filtros: all, mine, archived. Retorna resumen sin dashcards. Endpoint: GET /api/dashboard." +tags: [metabase, dashboard, list, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/dashboards.py" +--- + +## Ejemplo + +```python +dashboards = metabase_list_dashboards(client, filter="mine") +for d in dashboards: + print(d["id"], d["name"]) +``` + +## Notas + +Para ver cards de un dashboard usar metabase_get_dashboard. diff --git a/python/functions/metabase/metabase_list_users.md b/python/functions/metabase/metabase_list_users.md new file mode 100644 index 00000000..8a8361f5 --- /dev/null +++ b/python/functions/metabase/metabase_list_users.md @@ -0,0 +1,33 @@ +--- +name: metabase_list_users +kind: function +lang: py +domain: infra +version: "1.0.0" +purity: impure +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." +tags: [metabase, user, list, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/users.py" +--- + +## Ejemplo + +```python +users = metabase_list_users(client, status="active", query="john@") +for u in users["data"]: + print(u["email"], u["first_name"]) +``` + +## Notas + +Retorna dict paginado con data, total, limit, offset. diff --git a/python/functions/metabase/metabase_update_card.md b/python/functions/metabase/metabase_update_card.md new file mode 100644 index 00000000..8c046148 --- /dev/null +++ b/python/functions/metabase/metabase_update_card.md @@ -0,0 +1,31 @@ +--- +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" +description: "Actualiza campos de una card/pregunta via kwargs. Campos: name, description, display, dataset_query, collection_id, archived. Endpoint: PUT /api/card/:id." +tags: [metabase, card, question, update, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/cards.py" +--- + +## Ejemplo + +```python +metabase_update_card(client, 42, name="New Name", archived=True) +``` + +## Notas + +Soft-delete con `archived=True`. Para delete permanente usar metabase_delete_card. diff --git a/python/functions/metabase/metabase_update_dashboard.md b/python/functions/metabase/metabase_update_dashboard.md new file mode 100644 index 00000000..17e1379b --- /dev/null +++ b/python/functions/metabase/metabase_update_dashboard.md @@ -0,0 +1,39 @@ +--- +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" +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." +tags: [metabase, dashboard, update, cards, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/dashboards.py" +--- + +## Ejemplo + +```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) +``` + +## Notas + +dashcards = estado completo. ID negativo = nueva, positivo = existente, omitida = eliminada. +Campos: name, description, archived, dashcards, tabs, parameters, collection_id. diff --git a/python/functions/metabase/metabase_update_user.md b/python/functions/metabase/metabase_update_user.md new file mode 100644 index 00000000..78e0f4c1 --- /dev/null +++ b/python/functions/metabase/metabase_update_user.md @@ -0,0 +1,32 @@ +--- +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" +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." +tags: [metabase, user, update, api, python] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [httpx] +tested: false +tests: [] +test_file_path: "" +file_path: "python/functions/metabase/users.py" +--- + +## Ejemplo + +```python +metabase_update_user(client, 5, first_name="Jane", is_superuser=True) +metabase_update_user(client, 5, group_ids=[1, 3, 5]) +``` + +## Notas + +Solo se modifican los campos pasados como kwargs. diff --git a/python/functions/metabase/users.py b/python/functions/metabase/users.py new file mode 100644 index 00000000..4133b63b --- /dev/null +++ b/python/functions/metabase/users.py @@ -0,0 +1,153 @@ +"""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}") diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 00000000..03b6a2b3 --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "fn-registry-python" +version = "0.1.0" +description = "Funciones Python del fn-registry: Metabase API, ML, utilidades" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "httpx", +] diff --git a/python/uv.lock b/python/uv.lock new file mode 100644 index 00000000..4ab33db3 --- /dev/null +++ b/python/uv.lock @@ -0,0 +1,91 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "fn-registry-python" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "httpx" }, +] + +[package.metadata] +requires-dist = [{ name = "httpx" }] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]