merge: quick/metabase-expansion-and-issues — expansion Metabase + issues tracker

This commit is contained in:
2026-04-13 23:32:22 +02:00
63 changed files with 7588 additions and 188 deletions
-183
View File
@@ -1,183 +0,0 @@
# 0008 — SQLite API Web
## Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0008 |
| **Estado** | 🟡 pendiente |
| **Prioridad** | alta |
| **Tipo** | feature |
## Dependencias
Ninguna.
---
## Objetivo
App que expone `registry.db` y los `operations.db` de cada app como API REST HTTP, permitiendo que herramientas externas (dashboards, scripts, agentes, frontends) consulten las bases de datos del registry sin necesidad de acceso directo al filesystem ni SQLite CLI.
## Contexto
- Actualmente para consultar `registry.db` hay que estar en la misma máquina y usar `sqlite3` directamente o funciones Go que abren el archivo.
- Las apps existentes (metabase_registry, registry_dashboard) acceden a SQLite localmente. Cualquier herramienta nueva que necesite datos del registry tiene que reimplementar la conexión.
- Con una API web, cualquier cliente HTTP (curl, fetch, Python requests, frontends React) puede consultar el registry de forma uniforme.
- Metabase ya resuelve visualización, pero no da acceso programático limpio a los datos para agentes y scripts remotos.
## Arquitectura
```
apps/sqlite_api/
├── main.go — NEW: Entry point, configura rutas y arranca servidor
├── handlers.go — NEW: Handlers HTTP (query, tables, schema)
├── config.go — NEW: Configuración (puerto, DBs permitidas, read-only)
├── app.md — NEW: Metadata de la app (tag: service)
└── operations.db — Runtime: operaciones propias
```
### Patrón pure core / impure shell
- **Funciones del registry usadas:** `http_get_json_go_infra`, `http_post_json_go_infra` (para tests/clientes), `cache_to_sqlite_go_infra` (opcional para cache de queries)
- **Core puro:** validación de queries (solo SELECT/PRAGMA permitidos), parsing de parámetros, formateo de resultados JSON
- **Shell impuro:** servidor HTTP, apertura de SQLite, ejecución de queries
## Diseño de API
### Endpoints
```
GET /api/databases — Lista de DBs disponibles
GET /api/databases/:db/tables — Lista tablas de una DB
GET /api/databases/:db/schema — Schema completo (.schema)
POST /api/databases/:db/query — Ejecuta query SQL (solo SELECT)
GET /api/databases/:db/fts?q=texto&table=functions — Búsqueda FTS5 directa
GET /health — Health check
```
### Bases de datos expuestas
| Alias | Path real | Descripción |
|-------|-----------|-------------|
| `registry` | `registry.db` (raíz) | Funciones, tipos, proposals |
| `ops:{app}` | `apps/{app}/operations.db` | Entities, relations, executions de cada app |
### Seguridad
- **Read-only obligatorio:** Solo queries SELECT y PRAGMA. Cualquier INSERT/UPDATE/DELETE/DROP se rechaza antes de ejecutar.
- **Bind por defecto a localhost** (`127.0.0.1:8484`). Flag `--bind` para cambiar.
- **Sin autenticación** en v1 (solo acceso local). Documentar cómo poner detrás de reverse proxy si se necesita auth.
- **Query timeout:** máximo 5 segundos por query para evitar bloqueos.
- **Apertura con `?mode=ro`** en el connection string de SQLite para doble protección.
### Formato de respuesta
```json
// POST /api/databases/registry/query
// Body: {"sql": "SELECT id, name, purity FROM functions WHERE domain = 'core' LIMIT 5"}
{
"columns": ["id", "name", "purity"],
"rows": [
["filter_slice_go_core", "filter_slice", "pure"],
["map_slice_go_core", "map_slice", "pure"]
],
"count": 2,
"duration_ms": 3
}
```
## Tareas
### Fase 1: Servidor base
- [ ] **1.1** Crear `apps/sqlite_api/` con `main.go`, `go.mod` (o usar módulo raíz)
- [ ] **1.2** Handler `/health` y `/api/databases` (lista estática de DBs detectadas)
- [ ] **1.3** Handler `POST /api/databases/:db/query` con validación read-only
- [ ] **1.4** Abrir DBs con `?mode=ro` y `-tags fts5`
- [ ] **1.5** `app.md` con tag `service`, documentar puerto y health check
### Fase 2: Endpoints de exploración
- [ ] **2.1** Handler `/api/databases/:db/tables` (lista tablas vía `sqlite_master`)
- [ ] **2.2** Handler `/api/databases/:db/schema` (output de `.schema`)
- [ ] **2.3** Handler `/api/databases/:db/fts` para búsqueda FTS5 sin escribir SQL
### Fase 3: Operations discovery
- [ ] **3.1** Auto-detectar `apps/*/operations.db` al arrancar
- [ ] **3.2** Exponer cada operations.db como `ops:{app_name}`
- [ ] **3.3** Endpoint `GET /api/databases` incluye las operations detectadas
### Fase 4: Cleanup y docs
- [ ] Crear `app.md` completo
- [ ] Ejecutar `go vet` y `go test`
- [ ] Actualizar issue en `dev/issues/README.md`
---
## Ejemplo de uso
```bash
# Arrancar el servicio
cd apps/sqlite_api && go run . --port 8484
# Health check
curl http://localhost:8484/health
# Listar databases disponibles
curl http://localhost:8484/api/databases
# Query al registry
curl -X POST http://localhost:8484/api/databases/registry/query \
-H "Content-Type: application/json" \
-d '{"sql": "SELECT id, purity, description FROM functions WHERE domain = '\''core'\'' LIMIT 5"}'
# Búsqueda FTS5
curl "http://localhost:8484/api/databases/registry/fts?q=slice&table=functions"
# Schema
curl http://localhost:8484/api/databases/registry/schema
# Query a operations de una app
curl -X POST http://localhost:8484/api/databases/ops:pipeline_launcher/query \
-H "Content-Type: application/json" \
-d '{"sql": "SELECT * FROM executions ORDER BY started_at DESC LIMIT 10"}'
```
```python
# Desde Python
import requests
r = requests.post("http://localhost:8484/api/databases/registry/query", json={
"sql": "SELECT id, name FROM functions WHERE purity = 'pure' AND domain = 'core'"
})
data = r.json()
for row in data["rows"]:
print(row[0], row[1])
```
## Decisiones de diseño
- **Go con net/http estándar**: sin framework externo, coherente con el resto del registry. Router simple con `http.ServeMux`.
- **Puerto 8484**: no colisiona con Metabase (3000), Jupyter (8888), ni otros servicios comunes.
- **Read-only estricto**: la API nunca modifica datos. Para escribir se usan los mecanismos existentes (`fn ops`, `fn index`).
- **Sin ORM**: queries se pasan tal cual a SQLite. El valor es el acceso HTTP, no una capa de abstracción SQL.
- **Auto-discovery de operations.db**: escanea `apps/*/operations.db` al inicio para no tener que configurar cada app manualmente.
## Riesgos
- **SQL injection vía queries arbitrarias**: Mitigado con apertura read-only (`?mode=ro`) + validación de que el statement empieza con SELECT o PRAGMA.
- **Queries pesadas bloquean el servidor**: Mitigado con timeout de 5s por query y context cancelable.
- **Archivos SQLite bloqueados por escritores concurrentes**: Mitigado con `journal_mode=wal` y apertura read-only que no bloquea escritores.
## Criterios de aceptación
- [ ] `curl localhost:8484/health` retorna 200
- [ ] Queries SELECT funcionan contra registry.db
- [ ] Queries INSERT/UPDATE/DELETE son rechazadas con 400
- [ ] Operations.db de apps existentes son accesibles como `ops:{nombre}`
- [ ] FTS5 funciona a través de la API
- [ ] Tag `service` en app.md
- [ ] El servidor arranca con `go run .` sin configuración adicional
+435
View File
@@ -0,0 +1,435 @@
# 0010 — Auth System
## Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0010 |
| **Estado** | pendiente |
| **Prioridad** | alta |
| **Tipo** | feature |
## Dependencias
Depende de 0009 (HTTP Server Foundation) para los middleware de auth (`Middleware` type, `http_middleware_chain`, `http_error_response`).
---
## Objetivo
Crear funciones reutilizables de autenticacion y autorizacion en Go (dominio infra) cubriendo JWT, hashing de passwords, sesiones SQLite, cliente OAuth2 y checks RBAC, de forma que cualquier app nueva del registry pueda tener auth completo componiendo primitivas en vez de implementarlo desde cero.
## Contexto
- Actualmente el registry tiene **CERO funciones de auth genericas**. Solo existe `auth_form_ts_ui` (componente UI de login) y funciones de auth especificas para Metabase (`metabase_auth_go_infra`, `metabase_auth_py_infra`).
- Existen funciones de password store (`pass_get`, `pass_set`, `pass_generate` en Bash) pero son wrappers de `pass`, no primitivas de hashing/verificacion.
- `generate_password_bash_cybersecurity` genera passwords pero no los hashea.
- Cada vez que una app necesita auth (como `sqlite_api` o `deploy_server`), hay que implementar JWT, sesiones y validacion ad-hoc.
- Con estas funciones, una app nueva solo hace: `password_hash` al registrar, `jwt_generate` al login, `jwt_middleware` en las rutas protegidas, y opcionalmente `rbac_middleware` para permisos granulares.
- Las funciones HTTP server de 0009 (`Middleware` type, `http_middleware_chain`) son el punto de integracion natural para los middlewares de auth.
## Arquitectura
```
functions/infra/
├── jwt_generate.go — NEW: genera JWT firmado desde claims + secret
├── jwt_generate.md — NEW
├── jwt_validate.go — NEW: valida JWT, retorna claims o error
├── jwt_validate.md — NEW
├── jwt_middleware.go — NEW: middleware HTTP que valida JWT del header Authorization
├── jwt_middleware.md — NEW
├── password_hash.go — NEW: hashea password con bcrypt
├── password_hash.md — NEW
├── password_verify.go — NEW: verifica password contra hash bcrypt
├── password_verify.md — NEW
├── session_create.go — NEW: crea sesion en SQLite, retorna token
├── session_create.md — NEW
├── session_validate.go — NEW: valida token de sesion, retorna datos de usuario
├── session_validate.md — NEW
├── session_cleanup.go — NEW: elimina sesiones expiradas
├── session_cleanup.md — NEW
├── oauth2_auth_url.go — NEW: construye URL de autorizacion OAuth2
├── oauth2_auth_url.md — NEW
├── oauth2_exchange.go — NEW: intercambia auth code por tokens
├── oauth2_exchange.md — NEW
├── oauth2_refresh.go — NEW: renueva access token con refresh token
├── oauth2_refresh.md — NEW
├── rbac_check.go — NEW: verifica si un rol tiene un permiso (datos puros)
├── rbac_check.md — NEW
├── rbac_middleware.go — NEW: middleware HTTP que verifica permisos tras auth
├── rbac_middleware.md — NEW
types/infra/
├── jwt_claims.md — NEW: metadata del tipo JWTClaims
├── session.md — NEW: metadata del tipo Session
├── oauth_config.md — NEW: metadata del tipo OAuthConfig
├── oauth_tokens.md — NEW: metadata del tipo OAuthTokens
├── permission.md — NEW: metadata del tipo Permission
├── role.md — NEW: metadata del tipo Role
```
### Patron pure core / impure shell
- **Pure:** `oauth2_auth_url` (construye string URL sin I/O), `rbac_check` (evaluacion de datos en memoria)
- **Impure:** todo lo demas — JWT usa `time.Now()` para expiracion/validacion, bcrypt hace trabajo criptografico con entropia del OS, sesiones interactuan con SQLite, OAuth2 hace HTTP requests, middlewares interactuan con `http.Request`/`http.ResponseWriter`
## Diseno
### Tipos
```go
// JWTClaims contiene claims estandar y custom para un JWT
type JWTClaims struct {
Subject string `json:"sub"`
Issuer string `json:"iss,omitempty"`
Audience string `json:"aud,omitempty"`
ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
Custom map[string]any `json:"custom,omitempty"`
}
// Session representa una sesion de usuario almacenada en SQLite
type Session struct {
Token string `json:"token"`
UserID string `json:"user_id"`
ExpiresAt int64 `json:"expires_at"`
CreatedAt int64 `json:"created_at"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// OAuthConfig contiene la configuracion de un proveedor OAuth2
type OAuthConfig struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
AuthURL string `json:"auth_url"`
TokenURL string `json:"token_url"`
RedirectURL string `json:"redirect_url"`
Scopes []string `json:"scopes"`
}
// OAuthTokens contiene los tokens obtenidos de un flujo OAuth2
type OAuthTokens struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresAt int64 `json:"expires_at"`
}
// Permission representa una accion sobre un recurso
type Permission struct {
Resource string `json:"resource"`
Action string `json:"action"`
}
// Role agrupa permisos bajo un nombre
type Role struct {
Name string `json:"name"`
Permissions []Permission `json:"permissions"`
}
```
### Funciones
| Funcion | Purity | Firma (simplificada) |
|---------|--------|---------------------|
| `jwt_generate` | impure | `(claims JWTClaims, secret string) (string, error)` |
| `jwt_validate` | impure | `(token string, secret string) (JWTClaims, error)` |
| `jwt_middleware` | impure | `(secret string) Middleware` |
| `password_hash` | impure | `(password string, cost int) (string, error)` |
| `password_verify` | impure | `(password string, hash string) error` |
| `session_create` | impure | `(db *sql.DB, userID string, ttl time.Duration, metadata map[string]any) (Session, error)` |
| `session_validate` | impure | `(db *sql.DB, token string) (Session, error)` |
| `session_cleanup` | impure | `(db *sql.DB) (int64, error)` |
| `oauth2_auth_url` | pure | `(config OAuthConfig, state string) string` |
| `oauth2_exchange` | impure | `(config OAuthConfig, code string) (OAuthTokens, error)` |
| `oauth2_refresh` | impure | `(config OAuthConfig, refreshToken string) (OAuthTokens, error)` |
| `rbac_check` | pure | `(roles []Role, roleName string, perm Permission) bool` |
| `rbac_middleware` | impure | `(roles []Role, requiredPerm Permission) Middleware` |
## Tareas
### Fase 1: Tipos
- [ ] **1.1** Crear tipo `JWTClaims` en `functions/infra/jwt_claims.go` con `.md` en `types/infra/jwt_claims.md`
- [ ] **1.2** Crear tipo `Session` en `functions/infra/session.go` con `.md` en `types/infra/session.md`
- [ ] **1.3** Crear tipo `OAuthConfig` en `functions/infra/oauth_config.go` con `.md` en `types/infra/oauth_config.md`
- [ ] **1.4** Crear tipo `OAuthTokens` en `functions/infra/oauth_tokens.go` con `.md` en `types/infra/oauth_tokens.md`
- [ ] **1.5** Crear tipo `Permission` en `functions/infra/permission.go` con `.md` en `types/infra/permission.md`
- [ ] **1.6** Crear tipo `Role` en `functions/infra/role.go` con `.md` en `types/infra/role.md`
### Fase 2: JWT + Password (auth core)
- [ ] **2.1** `jwt_generate` — codifica header+payload en base64url, firma con HMAC-SHA256, retorna token string. Setea `iat` automaticamente si no viene en claims.
- [ ] **2.2** `jwt_validate` — split por `.`, verifica firma HMAC-SHA256, decodifica claims, valida `exp` contra `time.Now()`. Error descriptivo si firma invalida, expirado, o malformado.
- [ ] **2.3** `password_hash` — wrapper de `golang.org/x/crypto/bcrypt.GenerateFromPassword`. Cost por defecto: 12.
- [ ] **2.4** `password_verify` — wrapper de `bcrypt.CompareHashAndPassword`. Retorna nil si match, error si no.
### Fase 3: Sesiones SQLite
- [ ] **3.1** `session_create` — genera token con `crypto/rand` (32 bytes hex), inserta en tabla `sessions` (la funcion crea la tabla si no existe via `CREATE TABLE IF NOT EXISTS`), retorna `Session`.
- [ ] **3.2** `session_validate` — busca token en tabla `sessions`, verifica que no este expirado, retorna `Session` o error.
- [ ] **3.3** `session_cleanup``DELETE FROM sessions WHERE expires_at < ?` con timestamp actual. Retorna cantidad de filas eliminadas.
### Fase 4: OAuth2
- [ ] **4.1** `oauth2_auth_url` — construye URL con query params: `client_id`, `redirect_uri`, `response_type=code`, `scope`, `state`. Funcion pura, solo concatena strings.
- [ ] **4.2** `oauth2_exchange` — POST a `token_url` con `grant_type=authorization_code`, `code`, `client_id`, `client_secret`, `redirect_uri`. Parsea JSON response a `OAuthTokens`.
- [ ] **4.3** `oauth2_refresh` — POST a `token_url` con `grant_type=refresh_token`, `refresh_token`, `client_id`, `client_secret`. Parsea JSON response a `OAuthTokens`.
### Fase 5: RBAC + Middlewares
- [ ] **5.1** `rbac_check` — busca el rol por nombre en `[]Role`, itera sus permisos, retorna `true` si encuentra match de `resource` + `action`. Funcion pura.
- [ ] **5.2** `jwt_middleware` — extrae token del header `Authorization: Bearer <token>`, valida con `jwt_validate`, inyecta claims en `r.Context()` con `context.WithValue`, llama `next.ServeHTTP`. Retorna 401 si falta token o es invalido. Usa tipo `Middleware` de 0009.
- [ ] **5.3** `rbac_middleware` — extrae claims del context (puestas por `jwt_middleware`), lee el rol del campo `custom["role"]`, evalua con `rbac_check`. Retorna 403 si no tiene permiso. Requiere que `jwt_middleware` se ejecute antes en la chain.
### Fase 6: Tests y cleanup
- [ ] **6.1** Tests para JWT: generar token, validar token valido, rechazar token expirado, rechazar firma invalida
- [ ] **6.2** Tests para password: hash y verify OK, verify con password incorrecto falla
- [ ] **6.3** Tests para sesiones: crear, validar, validar expirada, cleanup
- [ ] **6.4** Tests para OAuth2: `oauth2_auth_url` genera URL correcta (test puro), exchange/refresh con `httptest.NewServer` mock
- [ ] **6.5** Tests para RBAC: check con permiso, sin permiso, rol inexistente
- [ ] **6.6** Tests para middlewares: jwt_middleware con token valido/invalido/ausente, rbac_middleware con/sin permiso
- [ ] **6.7** `fn index` y verificar que todas las funciones y tipos aparecen en registry.db
- [ ] **6.8** Verificar `go vet -tags fts5` y `go test -tags fts5 ./functions/infra/`
---
## Ejemplo de uso
### Registro de usuario y login con JWT
```go
// Handler de registro
func registerHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := infra.HttpParseBody(r, &input, 1<<20); err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()})
return
}
hash, err := infra.PasswordHash(input.Password, 12)
if err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "hash_error", Message: err.Error()})
return
}
// Guardar email + hash en la BD de la app...
userID := saveUser(input.Email, hash)
infra.HttpJsonResponse(w, 201, map[string]string{"id": userID})
}
// Handler de login
func loginHandler(w http.ResponseWriter, r *http.Request) {
var input struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := infra.HttpParseBody(r, &input, 1<<20); err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()})
return
}
user, err := findUserByEmail(input.Email)
if err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"})
return
}
if err := infra.PasswordVerify(input.Password, user.PasswordHash); err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_credentials", Message: "email o password incorrectos"})
return
}
claims := infra.JWTClaims{
Subject: user.ID,
ExpiresAt: time.Now().Add(24 * time.Hour).Unix(),
Custom: map[string]any{"role": "admin", "email": user.Email},
}
token, err := infra.JwtGenerate(claims, os.Getenv("JWT_SECRET"))
if err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "token_error", Message: err.Error()})
return
}
infra.HttpJsonResponse(w, 200, map[string]string{"token": token})
}
```
### Rutas protegidas con JWT + RBAC
```go
// Definir roles y permisos como datos
roles := []infra.Role{
{
Name: "admin",
Permissions: []infra.Permission{
{Resource: "users", Action: "read"},
{Resource: "users", Action: "write"},
{Resource: "users", Action: "delete"},
},
},
{
Name: "viewer",
Permissions: []infra.Permission{
{Resource: "users", Action: "read"},
},
},
}
// Rutas publicas
publicRoutes := []infra.Route{
{Method: "POST", Path: "/register", Handler: registerHandler},
{Method: "POST", Path: "/login", Handler: loginHandler},
}
// Rutas protegidas con JWT
protectedRoutes := []infra.Route{
{Method: "GET", Path: "/api/me", Handler: meHandler},
}
// Rutas protegidas con JWT + RBAC
adminRoutes := []infra.Route{
{Method: "DELETE", Path: "/api/users/{id}", Handler: deleteUserHandler},
}
secret := os.Getenv("JWT_SECRET")
mux := http.NewServeMux()
// Publicas: sin middleware de auth
for _, r := range publicRoutes {
mux.HandleFunc(r.Method+" "+r.Path, r.Handler)
}
// Protegidas: JWT middleware
jwtProtected := infra.HttpMiddlewareChain(
infra.JwtMiddleware(secret),
)
for _, r := range protectedRoutes {
mux.Handle(r.Method+" "+r.Path, jwtProtected(http.HandlerFunc(r.Handler)))
}
// Admin: JWT + RBAC
adminProtected := infra.HttpMiddlewareChain(
infra.JwtMiddleware(secret),
infra.RbacMiddleware(roles, infra.Permission{Resource: "users", Action: "delete"}),
)
for _, r := range adminRoutes {
mux.Handle(r.Method+" "+r.Path, adminProtected(http.HandlerFunc(r.Handler)))
}
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
infra.HttpServe(":8080", mux, ctx)
```
### Flujo OAuth2 (ej. Google)
```go
googleConfig := infra.OAuthConfig{
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
TokenURL: "https://oauth2.googleapis.com/token",
RedirectURL: "http://localhost:8080/callback",
Scopes: []string{"openid", "email", "profile"},
}
// 1. Redirigir al usuario al proveedor
func oauthLoginHandler(w http.ResponseWriter, r *http.Request) {
state := generateRandomState() // guardar en cookie/session para validar despues
url := infra.Oauth2AuthUrl(googleConfig, state)
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
// 2. Callback: intercambiar code por tokens
func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
// Validar state contra el guardado en cookie/session...
tokens, err := infra.Oauth2Exchange(googleConfig, code)
if err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "oauth_error", Message: err.Error()})
return
}
// Usar tokens.AccessToken para obtener datos del usuario...
// Crear sesion local o JWT propio
infra.HttpJsonResponse(w, 200, map[string]string{"access_token": tokens.AccessToken})
}
// 3. Renovar token cuando expire
func refreshHandler(w http.ResponseWriter, r *http.Request) {
refreshToken := extractRefreshToken(r)
newTokens, err := infra.Oauth2Refresh(googleConfig, refreshToken)
if err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "refresh_failed", Message: err.Error()})
return
}
infra.HttpJsonResponse(w, 200, newTokens)
}
```
### Sesiones SQLite (alternativa a JWT para apps con estado)
```go
db, _ := sql.Open("sqlite3", "app.db?_journal_mode=wal")
// Crear sesion al login
func sessionLoginHandler(w http.ResponseWriter, r *http.Request) {
// ... validar credenciales ...
session, err := infra.SessionCreate(db, user.ID, 24*time.Hour, map[string]any{
"email": user.Email,
"role": "admin",
})
if err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "session_error", Message: err.Error()})
return
}
infra.HttpJsonResponse(w, 200, map[string]string{"session_token": session.Token})
}
// Validar sesion en cada request
func protectedHandler(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("X-Session-Token")
session, err := infra.SessionValidate(db, token)
if err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 401, Code: "invalid_session", Message: "sesion invalida o expirada"})
return
}
infra.HttpJsonResponse(w, 200, map[string]any{"user_id": session.UserID, "metadata": session.Metadata})
}
// Limpiar sesiones expiradas periodicamente
deleted, _ := infra.SessionCleanup(db)
fmt.Printf("sesiones eliminadas: %d\n", deleted)
```
## Decisiones de diseno
- **bcrypt sobre argon2:** bcrypt esta en `golang.org/x/crypto` (dependencia semi-oficial de Go), es robusto y ampliamente adoptado. Argon2 es mas moderno pero agrega complejidad sin beneficio practico para la mayoria de apps del registry. Cost por defecto 12 (buen balance velocidad/seguridad).
- **JWT con HMAC-SHA256 sin libreria externa:** implementacion manual con `crypto/hmac` + `crypto/sha256` + `encoding/base64`. Solo soporta HS256 — suficiente para apps single-server. Si se necesita RS256 (multiples servicios verificando), se crea una funcion separada en el futuro.
- **Sesiones en SQLite:** coherente con la filosofia del registry (SQLite en todas partes). La tabla `sessions` se crea en la BD de la app, no en registry.db. `CREATE TABLE IF NOT EXISTS` evita setup manual.
- **OAuth2 sin `golang.org/x/oauth2`:** las tres operaciones (auth_url, exchange, refresh) son suficientemente simples para implementar con `net/http` y `encoding/json`. Evita una dependencia mas y mantiene el control total del flujo.
- **RBAC como datos puros:** roles y permisos son slices pasados como argumento, no leidos de BD. Cada app decide donde los almacena (hardcoded, JSON, SQLite). `rbac_check` es pura — la unica funcion pura del modulo junto con `oauth2_auth_url`.
- **Claims en context con key privada:** `jwt_middleware` inyecta claims en `r.Context()` usando un tipo no exportado como key (`type contextKey struct{}`) para evitar colisiones. `rbac_middleware` las extrae del mismo context.
## Prerequisitos
- **0009 (HTTP Server Foundation):** tipo `Middleware`, funciones `http_middleware_chain`, `http_error_response`, `http_json_response`, `http_parse_body`. Los middlewares de auth (`jwt_middleware`, `rbac_middleware`) retornan `Middleware` y usan los helpers de response para errores 401/403.
## Riesgos
- **Seguridad de la implementacion JWT manual:** Mitigado manteniendo el scope en HS256, usando `hmac.Equal` para comparacion constant-time, y validando siempre `exp`. Documentar en cada `.md` que NO es apto para escenarios multi-servicio donde se necesita RSA/ECDSA.
- **Almacenamiento de secrets:** Las funciones reciben el secret como parametro (`string`), no lo leen de env ni de archivos. Es responsabilidad de la app obtener el secret de forma segura (env var, `pass_get`, etc.). Documentar este patron.
- **Token en header vs cookie:** `jwt_middleware` solo lee `Authorization: Bearer`. Para apps que necesiten cookies (frontend SSR), se crearia un middleware separado en el futuro. No mezclar ambos patrones en la misma funcion.
- **SQL injection en sesiones:** Mitigado usando prepared statements con `?` placeholders en todas las queries de sesion. Nunca concatenar strings.
- **Session fixation:** `session_create` genera tokens con `crypto/rand` (32 bytes = 256 bits de entropia), haciendo inviable la prediccion o fijacion de tokens.
- **bcrypt timing attacks:** `bcrypt.CompareHashAndPassword` ya es constant-time internamente. No se necesita proteccion adicional.
+327
View File
@@ -0,0 +1,327 @@
# 0011 — WebSocket & SSE Server
## Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0011 |
| **Estado** | pendiente |
| **Prioridad** | alta |
| **Tipo** | feature |
## Dependencias
| ID | Titulo | Estado | Requerido |
|----|--------|--------|-----------|
| 0009 | HTTP Server Foundation | pendiente | si |
**Bloqueada por:** `#0009` — las funciones WebSocket y SSE son handlers HTTP que se montan sobre las primitivas de servidor (router, middleware chain, graceful shutdown).
**Desbloquea:** apps de dashboard en tiempo real, notificaciones push, pipelines con feedback live.
---
## Objetivo
Crear funciones reutilizables de WebSocket y Server-Sent Events en Go (dominio infra) que permitan anadir comunicacion bidireccional (WS) y unidireccional server-to-client (SSE) a cualquier app del registry, componiendo con las primitivas HTTP de `#0009`.
## Contexto
- Actualmente hay **CERO** funciones de WebSocket/SSE server en el registry. Las unicas conexiones WebSocket existentes son de **cliente**: `cdp_connect_go_browser` (conecta a Chrome DevTools), `stream_ticks_go_finance` (consume un stream de ticks), y las funciones de `jupyter_*_py_notebook` (hablan con kernels Jupyter).
- `stream_ticks_go_finance` abre un WebSocket como cliente para recibir datos financieros — es especifico de un dominio, no una primitiva de server.
- Patrones comunes que necesitan WS/SSE server: dashboards con datos en vivo, logs en streaming, notificaciones de estado de pipelines, chat entre agentes.
- WebSocket es bidireccional (cliente y servidor envian mensajes). SSE es unidireccional server-to-client (mas simple, funciona sobre HTTP normal, reconexion automatica en el browser).
- Go stdlib soporta SSE nativamente con `http.Flusher`. Para WebSocket se necesita una dependencia externa: `nhooyr.io/websocket` (moderna, context-aware) o `gorilla/websocket` (clasica, ampliamente usada).
- Con estas funciones, una app nueva que necesite real-time solo hace: montar el `ws_handler` o `sse_handler` como una ruta mas del router de `#0009`.
## Arquitectura
```
functions/infra/
├── ws_upgrader.go — NEW: upgrade HTTP connection a WebSocket
├── ws_upgrader.md — NEW
├── ws_hub.go — NEW: hub de conexiones (register/unregister/broadcast)
├── ws_hub.md — NEW
├── ws_broadcast.go — NEW: enviar mensaje a todos los clientes conectados
├── ws_broadcast.md — NEW
├── ws_send.go — NEW: enviar mensaje a un cliente especifico
├── ws_send.md — NEW
├── ws_handler.go — NEW: HTTP handler que upgradea y gestiona una conexion WS
├── ws_handler.md — NEW
├── sse_handler.go — NEW: HTTP handler para stream SSE con flush
├── sse_handler.md — NEW
├── sse_send.go — NEW: enviar un evento SSE (event, data, id)
├── sse_send.md — NEW
├── sse_keepalive.go — NEW: enviar comentarios keepalive periodicos
├── sse_keepalive.md — NEW
types/infra/
├── ws_hub.md — NEW: metadata del tipo WSHub
├── ws_client.md — NEW: metadata del tipo WSClient
├── ws_message.md — NEW: metadata del tipo WSMessage
├── sse_event.md — NEW: metadata del tipo SSEEvent
```
### Patron pure core / impure shell
Todas las funciones de este issue son **impuras** — manejan conexiones de red, goroutines y estado mutable (el hub mantiene un mapa de clientes). No hay funciones puras en este issue porque la naturaleza del real-time es inherentemente I/O-bound.
El core puro vive en los tipos (structs sin metodos con side effects) y en la logica de serializado/parseado de mensajes que se delega a funciones existentes del registry (`json_marshal`, etc.).
## Diseno
### Tipos
```go
// WSHub gestiona el ciclo de vida de conexiones WebSocket.
// Mantiene un mapa de clientes activos y canales para registro,
// desregistro y broadcast. Se ejecuta como goroutine via Run().
type WSHub struct {
Clients map[*WSClient]bool
Broadcast chan []byte
Register chan *WSClient
Unregister chan *WSClient
}
// WSClient representa una conexion WebSocket individual.
// Cada cliente tiene su propio canal de envio buffereado
// y una referencia al hub al que pertenece.
type WSClient struct {
Hub *WSHub
Conn *websocket.Conn
Send chan []byte
ID string
}
// WSMessage es un mensaje tipado que viaja por WebSocket.
// El campo Type permite al receptor decidir como procesar el payload.
type WSMessage struct {
Type string `json:"type"`
Payload []byte `json:"payload"`
SenderID string `json:"sender_id"`
Ts int64 `json:"ts"`
}
// SSEEvent es un evento Server-Sent Events segun la spec W3C.
// Campos opcionales: si Event esta vacio se envia solo data,
// si ID esta vacio no se incluye campo id, Retry en ms (0 = omitir).
type SSEEvent struct {
Event string `json:"event"`
Data string `json:"data"`
ID string `json:"id"`
Retry int `json:"retry"`
}
```
### Funciones
| Funcion | Purity | Firma (simplificada) | Descripcion |
|---------|--------|---------------------|-------------|
| `ws_upgrader` | impure | `(w http.ResponseWriter, r *http.Request, origins []string) (*websocket.Conn, error)` | Upgrade HTTP a WebSocket con validacion de origen |
| `ws_hub` | impure | `() *WSHub` + `(hub *WSHub) Run()` | Crea hub y lo ejecuta como goroutine (loop select sobre canales) |
| `ws_broadcast` | impure | `(hub *WSHub, msg []byte)` | Envia mensaje al canal Broadcast del hub |
| `ws_send` | impure | `(client *WSClient, msg []byte) error` | Envia mensaje al canal Send de un cliente especifico |
| `ws_handler` | impure | `(hub *WSHub, origins []string) http.HandlerFunc` | Retorna handler que upgradea la conexion, registra el cliente en el hub, y lanza read/write pumps |
| `sse_handler` | impure | `(events <-chan SSEEvent) http.HandlerFunc` | Retorna handler que setea headers SSE, consume del canal y flushea cada evento |
| `sse_send` | impure | `(w http.ResponseWriter, event SSEEvent) error` | Escribe un evento SSE formateado al writer y hace flush |
| `sse_keepalive` | impure | `(w http.ResponseWriter, interval time.Duration, done <-chan struct{})` | Goroutine que envia `: keepalive\n\n` periodicamente hasta que done se cierre |
### Protocolo WebSocket
El hub sigue el patron clasico de Go concurrency:
```
┌──────────┐
HTTP request ──→ │ws_handler│ ──→ ws_upgrader ──→ *websocket.Conn
└────┬─────┘
┌─────▼─────┐
│ WSClient │
│ .Send ch │
└─────┬─────┘
│ Register
┌─────▼─────┐
│ WSHub │ ←── ws_broadcast
│ .Run() │
│ loop { │
│ select │
│ } │
└───────────┘
│ Broadcast
┌─────▼─────┐
│ client.Send│ ──→ writePump ──→ conn.WriteMessage
└───────────┘
```
Cada cliente tiene dos goroutines internas:
- **readPump**: lee mensajes del conn y los envia al hub Broadcast (o los procesa con un callback)
- **writePump**: consume del canal Send y escribe al conn
### Protocolo SSE
```
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
: keepalive
event: price_update
id: 42
data: {"symbol":"BTC","price":67000}
data: simple message without event type
: keepalive
```
`sse_handler` detecta si el `ResponseWriter` implementa `http.Flusher` y hace flush despues de cada evento. Si el cliente se desconecta (context cancelado), el handler retorna limpiamente.
## Tareas
### Fase 1: Tipos
- [ ] **1.1** Crear tipo `WSHub` en `functions/infra/ws_hub.go` con `.md` en `types/infra/ws_hub.md`
- [ ] **1.2** Crear tipo `WSClient` en `functions/infra/ws_client.go` con `.md` en `types/infra/ws_client.md`
- [ ] **1.3** Crear tipo `WSMessage` en `functions/infra/ws_message.go` con `.md` en `types/infra/ws_message.md`
- [ ] **1.4** Crear tipo `SSEEvent` en `functions/infra/sse_event.go` con `.md` en `types/infra/sse_event.md`
### Fase 2: SSE (mas simple, sin dependencia externa)
- [ ] **2.1** `sse_send` — formatea y escribe un SSEEvent al writer, hace flush
- [ ] **2.2** `sse_keepalive` — goroutine que envia comentarios keepalive periodicamente
- [ ] **2.3** `sse_handler` — HTTP handler completo: setea headers, consume canal de eventos, flush, detecta desconexion
### Fase 3: WebSocket
- [ ] **3.1** Elegir dependencia: `nhooyr.io/websocket` (preferida por soporte nativo de context) o `gorilla/websocket` (mas madura). Anadir a `go.mod`.
- [ ] **3.2** `ws_upgrader` — upgrade HTTP a WebSocket con validacion de origenes permitidos
- [ ] **3.3** `ws_hub` — constructor + metodo Run() con loop select sobre Register/Unregister/Broadcast
- [ ] **3.4** `ws_send` — envia bytes al canal Send de un cliente
- [ ] **3.5** `ws_broadcast` — envia bytes al canal Broadcast del hub
- [ ] **3.6** `ws_handler` — handler HTTP que upgradea, crea WSClient, registra en hub, lanza readPump/writePump
### Fase 4: Tests
- [ ] **4.1** Tests de SSE con `httptest.NewRecorder` y pipe para simular flush
- [ ] **4.2** Tests de WebSocket con `httptest.NewServer` y cliente WS de test
- [ ] **4.3** Test de integracion: hub con multiples clientes, broadcast, desconexion
- [ ] **4.4** `fn index` y verificar que todas las funciones y tipos aparecen en registry.db
- [ ] **4.5** `go vet -tags fts5` limpio
---
## Ejemplo de uso
### Chat-like: broadcast de mensajes entre clientes
```go
// Montar WebSocket en una app con las primitivas de #0009
hub := infra.NewWSHub()
go hub.Run()
routes := []infra.Route{
{Method: "GET", Path: "/health", Handler: healthHandler},
{Method: "GET", Path: "/api/data", Handler: dataHandler},
{Method: "GET", Path: "/ws", Handler: infra.WsHandler(hub, []string{"*"})},
}
mux := infra.HttpRouter(routes)
middleware := infra.HttpMiddlewareChain(
infra.HttpCorsMiddleware([]string{"*"}, []string{"GET", "POST"}),
infra.HttpLoggerMiddleware(os.Stdout),
)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
infra.HttpServe(":8080", middleware(mux), ctx)
```
```javascript
// Cliente browser
const ws = new WebSocket("ws://localhost:8080/ws");
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
console.log(`[${msg.sender_id}] ${msg.type}: ${new TextDecoder().decode(msg.payload)}`);
};
ws.send(JSON.stringify({type: "chat", payload: btoa("hola"), sender_id: "user1"}));
```
### Dashboard live updates via SSE
```go
// Canal de eventos que se alimenta desde cualquier goroutine
events := make(chan infra.SSEEvent, 100)
// Goroutine que genera eventos (ej: watch a operations.db)
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for i := 0; ; i++ {
<-ticker.C
events <- infra.SSEEvent{
Event: "metrics_update",
ID: fmt.Sprintf("%d", i),
Data: fmt.Sprintf(`{"cpu": %.1f, "mem": %.1f}`, rand.Float64()*100, rand.Float64()*100),
}
}
}()
routes := []infra.Route{
{Method: "GET", Path: "/health", Handler: healthHandler},
{Method: "GET", Path: "/events", Handler: infra.SseHandler(events)},
{Method: "GET", Path: "/api/snapshot", Handler: snapshotHandler},
}
mux := infra.HttpRouter(routes)
infra.HttpServe(":8080", mux, ctx)
```
```javascript
// Cliente browser — reconexion automatica gratis con EventSource
const es = new EventSource("http://localhost:8080/events");
es.addEventListener("metrics_update", (e) => {
const data = JSON.parse(e.data);
updateDashboard(data.cpu, data.mem);
});
es.onerror = () => console.log("reconectando...");
```
### Notificaciones de estado de pipelines
```go
// En una app que ejecuta pipelines del registry:
hub := infra.NewWSHub()
go hub.Run()
// Cada vez que un step termina, broadcast a todos los clientes
func onStepComplete(step string, status string, durationMs int) {
msg, _ := json.Marshal(infra.WSMessage{
Type: "step_complete",
Payload: []byte(fmt.Sprintf(`{"step":%q,"status":%q,"ms":%d}`, step, status, durationMs)),
SenderID: "pipeline_runner",
Ts: time.Now().UnixMilli(),
})
infra.WsBroadcast(hub, msg)
}
```
## Decisiones de diseno
- **`nhooyr.io/websocket` como primera opcion:** mas moderna que gorilla/websocket, soporta `context.Context` nativamente (encaja con graceful shutdown de `#0009`), API mas simple. Si da problemas de compatibilidad, fallback a gorilla.
- **Hub como goroutine con canales:** patron estandar de Go para gestionar estado compartido sin mutex. Un solo punto de escritura al mapa de clientes evita races.
- **SSE sin dependencias externas:** solo usa `net/http` stdlib + `http.Flusher` interface. Mas simple que WebSocket y suficiente para dashboards y notificaciones unidireccionales.
- **Separar send de broadcast:** `ws_send` (un cliente) y `ws_broadcast` (todos) son funciones distintas porque tienen patrones de uso y error handling diferentes.
- **Canal buffereado en WSClient.Send:** evita que un cliente lento bloquee el broadcast a los demas. Si el canal se llena, el hub desconecta al cliente.
- **SSEEvent.Retry opcional:** el campo retry en SSE le dice al browser cuanto esperar antes de reconectar. Dejarlo en 0 usa el default del browser (~3 segundos).
- **Validacion de origenes en ws_upgrader:** proteccion basica contra cross-origin WebSocket hijacking. Para produccion se complementa con auth middleware de `#0009`.
- **Todas impuras, sin excepciones:** a diferencia de `#0009` donde hay funciones puras (middleware chain, CORS config), aqui todo toca red o estado mutable. No forzar pureza artificial.
## Riesgos
- **Leak de goroutines:** Cada cliente WS genera 2 goroutines (readPump + writePump). Si no se limpian bien al desconectar, se acumulan. Mitigado con el patron hub.Unregister + defer cleanup en cada pump.
- **Clientes lentos saturan el hub:** Un cliente que no consume su canal Send puede bloquear el broadcast. Mitigado con canal buffereado y desconexion forzada si el buffer se llena (write deadline).
- **Dependencia externa para WebSocket:** `nhooyr.io/websocket` anade un import fuera de stdlib. Mitigado porque es una dependencia mantenida, sin subdependencias transitivas, y solo afecta a las funciones `ws_*` (no contamina el resto del paquete infra).
- **Compatibilidad con proxies/load balancers:** WebSocket requiere que proxies soporten upgrade HTTP. SSE funciona sobre HTTP normal sin problema. Documentar en los `.md` que WS detras de nginx/caddy necesita config especifica (`proxy_pass` con upgrade headers).
- **Scope creep hacia un framework de real-time:** Mitigado manteniendo funciones atomicas. El hub es un mapa de conexiones, no un pub/sub con rooms, channels, ni autenticacion. Esas abstracciones se componen encima si se necesitan.
+270
View File
@@ -0,0 +1,270 @@
# 0014 — File Upload & Storage
## Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0014 |
| **Estado** | pendiente |
| **Prioridad** | media |
| **Tipo** | feature |
## Dependencias
- **0009 — HTTP Server Foundation**: las funciones `upload_handler` y `file_serve` son handlers HTTP que dependen de los tipos y patrones definidos en 0009 (Route, Middleware, HTTPError, http_json_response, http_error_response, http_parse_body).
---
## Objetivo
Crear funciones reutilizables en Go (dominio infra) para manejar subida de archivos, almacenamiento en disco, servido de archivos estaticos, y opcionalmente almacenamiento en S3. Cualquier app que necesite gestionar imagenes, documentos o media puede componer estas primitivas en vez de reimplementar el manejo de archivos desde cero.
## Contexto
- No existen funciones de upload/storage en el registry. Cada app que necesita manejar archivos tiene que construir el handler de upload, la validacion, el almacenamiento y el servido desde cero.
- El patron es comun: apps con imagenes de perfil, documentos adjuntos, exports de datos, thumbnails, etc.
- Go stdlib tiene todo lo necesario para multipart parsing (`mime/multipart`), manipulacion de imagenes (`image`, `image/jpeg`, `image/png`), y filesystem. Para S3 se necesita el SDK de AWS, pero como dependencia opcional.
- Con estas funciones, una app nueva que necesite uploads solo hace: montar `upload_handler` como ruta, configurar `StorageConfig`, y usar `file_serve` para servir los archivos.
## Arquitectura
```
functions/infra/
upload_handler.go -- NEW: HTTP handler multipart upload
upload_handler.md -- NEW
upload_parse.go -- NEW: parse multipart form, extraer archivos
upload_parse.md -- NEW
file_save_disk.go -- NEW: guardar archivo en disco con nombre unico
file_save_disk.md -- NEW
file_serve.go -- NEW: HTTP handler para servir archivos estaticos
file_serve.md -- NEW
file_delete.go -- NEW: eliminar archivo del disco
file_delete.md -- NEW
thumbnail_generate.go -- NEW: generar thumbnail de imagen
thumbnail_generate.md -- NEW
file_validate_type.go -- NEW: validar MIME type por magic bytes
file_validate_type.md -- NEW
file_unique_name.go -- NEW: generar nombre unico UUID + extension
file_unique_name.md -- NEW
s3_upload.go -- NEW: subir archivo a S3-compatible
s3_upload.md -- NEW
s3_download.go -- NEW: descargar archivo desde S3-compatible
s3_download.md -- NEW
s3_presign_url.go -- NEW: generar URL presignada S3
s3_presign_url.md -- NEW
types/infra/
uploaded_file.md -- NEW: metadata del tipo UploadedFile
storage_config.md -- NEW: metadata del tipo StorageConfig
s3_config.md -- NEW: metadata del tipo S3Config
```
### Patron pure core / impure shell
- **Pure:** `file_validate_type` (lee bytes en memoria, sin I/O), `file_unique_name` (genera string, determinista dado un UUID)
- **Impure:** todo lo demas — interactuan con disco, red, HTTP requests/responses
## Diseno
### Tipos
```go
// UploadedFile contiene la metadata de un archivo subido y almacenado.
type UploadedFile struct {
Filename string `json:"filename"` // nombre original del archivo
StoredName string `json:"stored_name"` // nombre en disco (UUID-based)
Size int64 `json:"size"` // tamano en bytes
ContentType string `json:"content_type"` // MIME type detectado
Path string `json:"path"` // ruta completa en disco
CreatedAt time.Time `json:"created_at"`
}
// StorageConfig configura el almacenamiento local de archivos.
type StorageConfig struct {
BaseDir string `json:"base_dir"` // directorio base para almacenar archivos
MaxFileSize int64 `json:"max_file_size"` // tamano maximo en bytes (ej: 10<<20 = 10MB)
AllowedTypes []string `json:"allowed_types"` // MIME types permitidos (ej: ["image/png", "image/jpeg", "application/pdf"])
}
// S3Config configura la conexion a almacenamiento S3-compatible.
type S3Config struct {
Endpoint string `json:"endpoint"` // URL del servidor (ej: "s3.amazonaws.com", "minio.local:9000")
Bucket string `json:"bucket"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Region string `json:"region"`
UseSSL bool `json:"use_ssl"`
}
```
### Funciones
| Funcion | Purity | Firma (simplificada) |
|---------|--------|---------------------|
| `upload_handler` | impure | `(cfg StorageConfig) http.HandlerFunc` |
| `upload_parse` | impure | `(r *http.Request, maxSize int64) ([]ParsedFile, error)` |
| `file_save_disk` | impure | `(baseDir string, filename string, data io.Reader) (UploadedFile, error)` |
| `file_serve` | impure | `(dir string, pathPrefix string, maxAge int) http.Handler` |
| `file_delete` | impure | `(path string) error` |
| `thumbnail_generate` | impure | `(srcPath string, dstPath string, maxWidth int, maxHeight int) error` |
| `file_validate_type` | pure | `(header []byte, allowedTypes []string) (string, bool)` |
| `file_unique_name` | pure | `(originalName string) string` |
| `s3_upload` | impure | `(cfg S3Config, key string, data io.Reader, contentType string) error` |
| `s3_download` | impure | `(cfg S3Config, key string, dst io.Writer) error` |
| `s3_presign_url` | impure | `(cfg S3Config, key string, expiry time.Duration) (string, error)` |
### Detalle de cada funcion
**`upload_handler`** — Handler HTTP completo para multipart upload. Recibe un `StorageConfig`, aplica limites de tamano, valida tipos MIME, guarda en disco con nombre unico, y responde con JSON del `UploadedFile`. Compone internamente `upload_parse`, `file_validate_type`, `file_unique_name`, y `file_save_disk`.
**`upload_parse`** — Parsea el request multipart, extrae uno o mas archivos con su metadata (nombre original, tamano, content type, contenido). Aplica `http.MaxBytesReader` para limitar tamano. Retorna un slice de structs intermedios con el contenido listo para guardar.
**`file_save_disk`** — Recibe un `io.Reader` con el contenido y lo escribe a disco en `baseDir` con un nombre unico generado por `file_unique_name`. Crea subdirectorios si no existen. Retorna el `UploadedFile` con la ruta final.
**`file_serve`** — Retorna un `http.Handler` que sirve archivos estaticos desde un directorio. Setea headers de cache (`Cache-Control`, `ETag`). Usa `http.FileServer` internamente pero stripea el prefijo de path y anade seguridad contra path traversal.
**`file_delete`** — Elimina un archivo del disco. Valida que el path es relativo al directorio de storage (no permite `../` traversal). Retorna error si el archivo no existe.
**`thumbnail_generate`** — Lee una imagen del disco, la redimensiona manteniendo aspect ratio al tamano maximo indicado, y la guarda en `dstPath`. Usa `image`, `image/jpeg`, `image/png` de stdlib. Soporta JPEG y PNG.
**`file_validate_type`** — Lee los primeros N bytes (magic bytes / file signature) de un `[]byte` y determina el MIME type real del archivo. Compara contra la lista de tipos permitidos. No confia en el Content-Type del request — siempre verifica los bytes. Retorna el MIME type detectado y si esta en la lista permitida.
**`file_unique_name`** — Genera un nombre de archivo unico combinando un UUID v4 con la extension del archivo original. Ejemplo: `a1b2c3d4-e5f6-7890-abcd-ef1234567890.png`. Sanitiza la extension (solo alfanumericos).
**`s3_upload`** — Sube un archivo a un bucket S3-compatible. Acepta `S3Config` para conectar a AWS S3, MinIO, o cualquier implementacion compatible. Usa el AWS SDK v2.
**`s3_download`** — Descarga un archivo desde S3 a un `io.Writer`. Permite streaming directo a disco o a un HTTP response.
**`s3_presign_url`** — Genera una URL presignada para upload o download directo sin pasar por el servidor. Util para uploads grandes donde el cliente sube directamente a S3.
## Tareas
### Fase 1: Tipos
- [ ] **1.1** Crear tipo `UploadedFile` en `functions/infra/uploaded_file.go` con `.md` en `types/infra/`
- [ ] **1.2** Crear tipo `StorageConfig` en `functions/infra/storage_config.go` con `.md` en `types/infra/`
- [ ] **1.3** Crear tipo `S3Config` en `functions/infra/s3_config.go` con `.md` en `types/infra/`
### Fase 2: Funciones puras (validacion y naming)
- [ ] **2.1** `file_validate_type` — detectar MIME type por magic bytes, comparar contra lista permitida. Tabla interna de signatures: JPEG (`FF D8 FF`), PNG (`89 50 4E 47`), GIF (`47 49 46 38`), PDF (`25 50 44 46`), WebP (`52 49 46 46...57 45 42 50`), ZIP (`50 4B 03 04`)
- [ ] **2.2** `file_unique_name` — UUID v4 + extension sanitizada. Sin I/O, sin estado
- [ ] **2.3** Tests unitarios para ambas funciones puras
### Fase 3: Almacenamiento en disco
- [ ] **3.1** `file_save_disk` — escribir a disco con nombre unico, crear subdirectorios, retornar UploadedFile
- [ ] **3.2** `file_delete` — eliminar archivo, validar path traversal
- [ ] **3.3** `file_serve` — http.Handler con FileServer, cache headers, path traversal protection
- [ ] **3.4** `upload_parse` — parsear multipart form, extraer archivos con metadata
- [ ] **3.5** `upload_handler` — handler HTTP completo que compone parse + validate + save
- [ ] **3.6** `thumbnail_generate` — resize con image stdlib, mantener aspect ratio
- [ ] **3.7** Tests para funciones de disco con `os.MkdirTemp`
### Fase 4: S3-compatible storage
- [ ] **4.1** `s3_upload` — subir archivo a bucket con AWS SDK v2
- [ ] **4.2** `s3_download` — descargar archivo desde bucket
- [ ] **4.3** `s3_presign_url` — generar URL presignada con expiracion configurable
- [ ] **4.4** Tests con stub/mock de S3 (o `go build` + `go vet` si no hay MinIO local)
- [ ] **4.5** `fn index` y verificar que todas las funciones aparecen en registry.db
- [ ] **4.6** Verificar `go vet -tags fts5`
---
## Ejemplo de uso
```go
// Configurar storage
cfg := infra.StorageConfig{
BaseDir: "./uploads",
MaxFileSize: 10 << 20, // 10 MB
AllowedTypes: []string{"image/jpeg", "image/png", "application/pdf"},
}
// Montar rutas (usando funciones de 0009 HTTP Server Foundation)
routes := []infra.Route{
{Method: "POST", Path: "/api/upload", Handler: infra.UploadHandler(cfg)},
}
mux := infra.HttpRouter(routes)
// Servir archivos subidos
mux.Handle("/files/", infra.FileServe("./uploads", "/files/", 3600))
// Graceful shutdown
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
infra.HttpServe(":8080", mux, ctx)
```
```go
// Dentro de un handler custom (sin usar upload_handler):
func customUpload(w http.ResponseWriter, r *http.Request) {
files, err := infra.UploadParse(r, 10<<20)
if err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 400, Code: "parse_error", Message: err.Error()})
return
}
for _, f := range files {
// Validar tipo real (no confiar en Content-Type del request)
mimeType, ok := infra.FileValidateType(f.Header, cfg.AllowedTypes)
if !ok {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 415, Code: "invalid_type", Message: "tipo no permitido"})
return
}
// Guardar en disco
uploaded, err := infra.FileSaveDisk(cfg.BaseDir, f.Filename, f.Content)
if err != nil {
infra.HttpErrorResponse(w, infra.HTTPError{Status: 500, Code: "save_error", Message: err.Error()})
return
}
// Generar thumbnail si es imagen
if mimeType == "image/jpeg" || mimeType == "image/png" {
thumbPath := filepath.Join(cfg.BaseDir, "thumbs", uploaded.StoredName)
infra.ThumbnailGenerate(uploaded.Path, thumbPath, 200, 200)
}
}
infra.HttpJsonResponse(w, 200, map[string]string{"status": "ok"})
}
```
```go
// S3 upload (opcional, para apps que necesiten storage remoto)
s3cfg := infra.S3Config{
Endpoint: "minio.local:9000",
Bucket: "uploads",
AccessKey: os.Getenv("S3_ACCESS_KEY"),
SecretKey: os.Getenv("S3_SECRET_KEY"),
Region: "us-east-1",
UseSSL: false,
}
f, _ := os.Open("./uploads/a1b2c3d4.png")
defer f.Close()
err := infra.S3Upload(s3cfg, "images/a1b2c3d4.png", f, "image/png")
// Generar URL presignada para download directo (1 hora)
url, err := infra.S3PresignUrl(s3cfg, "images/a1b2c3d4.png", time.Hour)
```
## Decisiones de diseno
- **Disco local primero, S3 opcional:** la mayoria de apps del registry corren en un solo servidor. El almacenamiento en disco es suficiente y no requiere infraestructura adicional. S3 es para apps que escalan o necesitan storage distribuido.
- **Validacion por magic bytes, no por Content-Type:** el header Content-Type del request puede ser falso. Los primeros bytes del archivo son la fuente de verdad para determinar el tipo real.
- **UUID para nombres en disco:** evita colisiones de nombres y elimina problemas con caracteres especiales en nombres de archivo originales. Se preserva la extension para que el MIME type sea inferible por el filesystem.
- **Solo stdlib para imagenes:** `image/jpeg` y `image/png` de Go stdlib son suficientes para thumbnails basicos. Si se necesita soporte de mas formatos (WebP, AVIF), se puede extender despues sin romper la interfaz.
- **Sin base de datos de metadata:** las funciones manejan archivos en disco/S3 pero no mantienen un indice de archivos subidos. Cada app decide como trackear sus uploads (puede usar operations.db, una tabla SQL, o simplemente el filesystem).
- **S3Config como struct separado:** permite que apps que no usan S3 no tengan que importar el AWS SDK. Las funciones S3 son independientes del resto.
## Riesgos
- **Path traversal:** Un atacante podria intentar subir archivos con nombres como `../../etc/passwd` o acceder a archivos fuera del directorio de storage. Mitigado: `file_save_disk` ignora el nombre original y usa UUID, `file_serve` valida que el path resuelto esta dentro del directorio base, `file_delete` rechaza paths con `..`.
- **File size DoS:** Un cliente podria enviar archivos enormes para agotar disco o memoria. Mitigado: `upload_parse` usa `http.MaxBytesReader` para cortar la lectura al limite configurado, `upload_handler` rechaza antes de leer si `Content-Length` excede el maximo.
- **MIME type bypass:** Un archivo puede tener magic bytes validos pero contenido malicioso despues. `file_validate_type` solo verifica los primeros bytes — no es un antivirus. Documentar que para apps con requisitos de seguridad altos se necesita escaneo adicional.
- **Dependencia AWS SDK para S3:** Anade un arbol de dependencias significativo. Mitigado: las funciones S3 son opcionales y estan en archivos separados. Si una app no importa S3, no paga el costo.
- **Thumbnails con stdlib limitados:** `image/jpeg` y `image/png` no soportan formatos modernos (WebP, AVIF, HEIC). Para apps que necesiten mas formatos, habra que evaluar dependencias externas o delegar a herramientas de sistema (`imagemagick`).
+174
View File
@@ -0,0 +1,174 @@
# 0016 — Rate Limiting
## Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0016 |
| **Estado** | pendiente |
| **Prioridad** | media |
| **Tipo** | feature |
## Dependencias
- **0009** (HTTP Server Foundation) — el middleware de rate limiting se integra via `http_middleware_chain`.
---
## Objetivo
Proteger cualquier API del registry contra abuso con rate limiting in-memory basado en token bucket, sin dependencias externas (no Redis). Funciones componibles que se enchufan al stack de middlewares de 0009.
## Contexto
- `sqlite_api` y futuras apps HTTP no tienen ninguna proteccion contra abuso. Un cliente puede hacer miles de requests por segundo sin limite.
- Con las funciones de HTTP server de 0009, integrar rate limiting es cuestion de componer un middleware mas en la chain.
- Token bucket es el algoritmo estandar para rate limiting HTTP: permite rafagas controladas (`burst`) mientras mantiene una tasa sostenida (`rate`).
- Go stdlib incluye `golang.org/x/time/rate` pero crear funciones propias permite control total sobre cleanup, headers y key extraction.
## Arquitectura
```
functions/infra/
rate_limiter_new.go — NEW: crea rate limiter in-memory
rate_limiter_new.md — NEW
rate_limiter_check.go — NEW: consulta si un request esta permitido
rate_limiter_check.md — NEW
rate_limit_middleware.go — NEW: middleware HTTP por IP
rate_limit_middleware.md — NEW
rate_limiter_by_key.go — NEW: rate limit por clave custom
rate_limiter_by_key.md — NEW
rate_limiter_cleanup.go — NEW: GC de entries stale
rate_limiter_cleanup.md — NEW
rate_limit_headers.go — NEW: construye headers estandar
rate_limit_headers.md — NEW
types/infra/
rate_limiter.md — NEW
rate_limit_config.md — NEW
rate_limit_result.md — NEW
```
## Diseno
### Tipos
```go
// RateLimiter mantiene estado de todos los clientes
type RateLimiter struct {
rate float64 // tokens por segundo
burst int // capacidad maxima del bucket
mu sync.Mutex
clients map[string]*clientEntry // key -> bucket state
}
type clientEntry struct {
tokens float64
lastSeen time.Time
}
// RateLimitConfig configura el middleware
type RateLimitConfig struct {
RequestsPerSecond float64 // tasa sostenida
BurstSize int // rafaga maxima
KeyFunc func(r *http.Request) string // extractor de clave (nil = IP)
CleanupInterval time.Duration // frecuencia de GC
}
// RateLimitResult es el resultado de un check
type RateLimitResult struct {
Allowed bool // request permitido
Remaining int // tokens restantes
ResetAt time.Time // cuando se rellena el bucket
RetryAfter float64 // segundos hasta que se pueda reintentar (0 si allowed)
}
```
### Funciones
| Funcion | Purity | Firma (simplificada) |
|---------|--------|---------------------|
| `rate_limiter_new` | impure | `(rate float64, burst int) *RateLimiter` |
| `rate_limiter_check` | impure | `(rl *RateLimiter, key string) RateLimitResult` |
| `rate_limit_middleware` | impure | `(rl *RateLimiter) Middleware` |
| `rate_limiter_by_key` | impure | `(rl *RateLimiter, keyFunc func(*http.Request) string) Middleware` |
| `rate_limiter_cleanup` | impure | `(rl *RateLimiter, maxAge time.Duration, interval time.Duration) func()` |
| `rate_limit_headers` | pure | `(result RateLimitResult, limit int) http.Header` |
### Token bucket
Cada key tiene un bucket con `burst` tokens. Se recargan a `rate` tokens/segundo. Un request consume 1 token. Si no quedan tokens, se rechaza con 429.
---
## Tareas
### Fase 1: Core + tipos
- [ ] **1.1** Crear tipos `RateLimiter`, `RateLimitConfig`, `RateLimitResult` en `functions/infra/` con `.md` en `types/infra/`
- [ ] **1.2** `rate_limiter_new` — inicializa `RateLimiter` con rate y burst
- [ ] **1.3** `rate_limiter_check` — evalua token bucket para una key, retorna `RateLimitResult`
- [ ] **1.4** `rate_limit_headers` — construye `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After` a partir de `RateLimitResult`
- [ ] **1.5** `rate_limiter_cleanup` — goroutine que borra entries sin actividad reciente, retorna `func()` para cancelar
### Fase 2: Middlewares + tests
- [ ] **2.1** `rate_limit_middleware` — middleware que limita por IP del cliente (extrae de `RemoteAddr` / `X-Forwarded-For`)
- [ ] **2.2** `rate_limiter_by_key` — middleware configurable con `keyFunc` para limitar por API key, user ID, etc.
- [ ] **2.3** Tests de cada funcion con `httptest.NewRecorder`
- [ ] **2.4** `fn index` y verificar con `fn show`
---
## Ejemplo de uso
```go
// Crear limiter: 10 req/s con burst de 20
rl := infra.RateLimiterNew(10, 20)
// Arrancar cleanup cada 5 minutos, borra entries sin actividad en 10 min
stopCleanup := infra.RateLimiterCleanup(rl, 10*time.Minute, 5*time.Minute)
defer stopCleanup()
// Componer con otros middlewares de 0009
middleware := infra.HttpMiddlewareChain(
infra.HttpCorsMiddleware([]string{"*"}, []string{"GET", "POST"}),
infra.RateLimitMiddleware(rl), // por IP
infra.HttpLoggerMiddleware(os.Stdout),
)
mux := infra.HttpRouter(routes)
infra.HttpServe(":8484", middleware(mux), ctx)
```
```go
// Rate limit por API key en vez de IP
keyMiddleware := infra.RateLimiterByKey(rl, func(r *http.Request) string {
return r.Header.Get("X-API-Key")
})
```
```go
// Respuesta 429 automatica del middleware:
// HTTP/1.1 429 Too Many Requests
// X-RateLimit-Limit: 10
// X-RateLimit-Remaining: 0
// X-RateLimit-Reset: 1713045600
// Retry-After: 1
// Content-Type: application/json
//
// {"status":429,"code":"rate_limited","message":"too many requests"}
```
## Decisiones de diseno
- **In-memory, no Redis:** para el scope del registry (single-process, pocas apps) un `sync.Mutex` + `map` es suficiente y evita una dependencia de infraestructura.
- **Token bucket sobre sliding window:** permite rafagas legitimas (burst) sin penalizar al cliente por picos puntuales, y es trivial de implementar.
- **Headers IETF draft:** sigue `draft-ietf-httpapi-ratelimit-headers` (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`). Los clientes pueden adaptar su ritmo sin adivinar.
- **`rate_limit_headers` como funcion pura:** construir headers no requiere I/O, solo formateo. El middleware la usa internamente pero queda disponible para otros usos.
- **Cleanup explicito:** el GC goroutine se arranca con parametros configurables y se para con la funcion retornada, sin goroutine leaks.
## Riesgos
- **Memoria con muchas IPs unicas:** Mitigado con `rate_limiter_cleanup` que purga entries inactivas periodicamente. Para APIs con millones de IPs distintas habria que migrar a Redis, pero ese no es el caso del registry.
- **IP detras de proxy:** `X-Forwarded-For` puede ser spoofed. Para uso interno es aceptable; para exposicion publica real habria que validar el header contra trusted proxies.
+191
View File
@@ -0,0 +1,191 @@
# 0019 — Structured Logging Go
## Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0019 |
| **Estado** | pendiente |
| **Prioridad** | media |
| **Tipo** | feature |
## Dependencias
- `logger_middleware` depende de issue 0009 (HTTP Server Foundation) para el tipo `Middleware`.
- El resto de funciones no tiene dependencias externas.
---
## Objetivo
Funciones de structured logging en Go (dominio infra) basadas en `log/slog` de stdlib. Logs en JSON con niveles, campos contextuales y middleware HTTP, reemplazando el uso ad-hoc de `fmt.Println` y `log.Printf` en las apps.
## Contexto
- Python ya tiene `get_logger_py_infra` y `setup_logger_py_infra` con rotacion, dual output y niveles.
- Bash tiene `bash_log_bash_shell` con niveles y colores.
- Go tiene **cero** funciones de logging estructurado. Las apps (`deploy_server`, `sqlite_api`, `pipeline_launcher`) loguean con `fmt.Println` o `log.Printf` sin estructura, sin niveles, sin contexto.
- Go 1.21+ incluye `log/slog` en stdlib: JSON handler, niveles, campos key-value, groups. No se necesita zerolog ni zap.
## Arquitectura
```
functions/infra/
├── logger_new.go — NEW: crea logger con nivel, output y formato
├── logger_new.md — NEW
├── logger_with.go — NEW: retorna copia del logger con campos adicionales
├── logger_with.md — NEW
├── logger_middleware.go — NEW: middleware HTTP que loguea requests
├── logger_middleware.md — NEW
├── log_debug.go — NEW: log a nivel debug
├── log_debug.md — NEW
├── log_info.go — NEW: log a nivel info
├── log_info.md — NEW
├── log_warn.go — NEW: log a nivel warn
├── log_warn.md — NEW
├── log_error.go — NEW: log a nivel error
├── log_error.md — NEW
types/infra/
├── logger.md — NEW: metadata del tipo Logger
├── log_level.md — NEW: metadata del tipo LogLevel
├── log_entry.md — NEW: metadata del tipo LogEntry
```
### Patron pure core / impure shell
- **Pure:** `logger_with` (copia inmutable del logger con campos adicionales, sin I/O)
- **Impure:** `logger_new`, `log_debug`, `log_info`, `log_warn`, `log_error`, `logger_middleware` (escriben a un `io.Writer`)
## Diseno
### Tipos
```go
// LogLevel representa los niveles de log soportados.
type LogLevel int
const (
LogLevelDebug LogLevel = iota
LogLevelInfo
LogLevelWarn
LogLevelError
)
// Logger wrappea slog.Logger con config del registry.
type Logger struct {
Level LogLevel
Output io.Writer
Format string // "json" | "text"
Fields map[string]any
inner *slog.Logger
}
// LogEntry representa una entrada de log estructurada.
type LogEntry struct {
Timestamp time.Time `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
Fields map[string]any `json:"fields,omitempty"`
}
```
### Funciones
| Funcion | Purity | Firma (simplificada) |
|---------|--------|---------------------|
| `logger_new` | impure | `(level LogLevel, output io.Writer, format string) (*Logger, error)` |
| `logger_with` | pure | `(logger *Logger, fields map[string]any) *Logger` |
| `log_debug` | impure | `(logger *Logger, msg string, fields ...any)` |
| `log_info` | impure | `(logger *Logger, msg string, fields ...any)` |
| `log_warn` | impure | `(logger *Logger, msg string, fields ...any)` |
| `log_error` | impure | `(logger *Logger, msg string, fields ...any)` |
| `logger_middleware` | impure | `(logger *Logger) Middleware` |
## Tareas
### Fase 1: Tipos y funciones core
- [ ] **1.1** Crear tipos `Logger`, `LogLevel`, `LogEntry` en `functions/infra/` con `.md` en `types/infra/`
- [ ] **1.2** `logger_new` — crea `*Logger` con `slog.NewJSONHandler` o `slog.NewTextHandler` segun `format`
- [ ] **1.3** `logger_with` — clona el logger y anade campos al `slog.Logger` interno via `slog.With()`
- [ ] **1.4** `log_debug`, `log_info`, `log_warn`, `log_error` — delegan al `slog.Logger` interno con el nivel correspondiente
- [ ] **1.5** Tests unitarios: verificar output JSON, niveles filtrados, campos inyectados
### Fase 2: Middleware HTTP (requiere 0009)
- [ ] **2.1** `logger_middleware` — wrappea `http.Handler`, loguea method, path, status, duration_ms al completar cada request
- [ ] **2.2** Tests con `httptest.NewRecorder`
- [ ] **2.3** `fn index` y verificar todas las funciones en registry.db
---
## Ejemplo de uso
```go
package main
import (
"context"
"net/http"
"os"
"os/signal"
"github.com/fn_registry/functions/infra"
)
func main() {
// Crear logger JSON a stdout, nivel info
logger, _ := infra.LoggerNew(infra.LogLevelInfo, os.Stdout, "json")
// Logger con contexto de app
appLog := infra.LoggerWith(logger, map[string]any{
"app": "sqlite_api",
"version": "1.0.0",
})
infra.LogInfo(appLog, "server starting", "port", 8484)
// {"time":"2026-04-13T...","level":"INFO","msg":"server starting","app":"sqlite_api","version":"1.0.0","port":8484}
// Logger por request con campos adicionales
reqLog := infra.LoggerWith(appLog, map[string]any{"request_id": "abc-123"})
infra.LogDebug(reqLog, "parsing body") // filtrado: nivel < info
infra.LogError(reqLog, "db query failed", "err", "connection refused", "table", "functions")
// Middleware HTTP (compone con las funciones de 0009)
routes := []infra.Route{
{Method: "GET", Path: "/health", Handler: healthHandler},
}
mux := infra.HttpRouter(routes)
middleware := infra.HttpMiddlewareChain(
infra.LoggerMiddleware(appLog),
infra.HttpCorsMiddleware([]string{"*"}, []string{"GET"}),
)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
infra.HttpServe(":8484", middleware(mux), ctx)
// Cada request produce:
// {"time":"...","level":"INFO","msg":"http request","app":"sqlite_api","method":"GET","path":"/health","status":200,"duration_ms":1}
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
infra.HttpJsonResponse(w, 200, map[string]string{"status": "ok"})
}
```
## Decisiones de diseno
- **`log/slog` de stdlib (Go 1.21+):** Zero dependencies. `slog` ya resuelve JSON structured logging, niveles, campos key-value y handlers extensibles. No se justifica zerolog ni zap para el scope de este registry.
- **Logger como struct, no global:** Cada app/componente crea su logger con su config. Sin `slog.SetDefault()` ni variables de paquete. Inyeccion explicita.
- **`logger_with` puro:** `slog.Logger.With()` retorna un nuevo logger sin mutar el original. Esto permite crear loggers contextuales (por request, por componente) sin side effects.
- **Funciones de nivel separadas (`log_info`, `log_error`...):** En vez de un unico `Log(level, msg)`, funciones dedicadas por nivel. Mas legibles en el call site y mas buscables en el registry.
- **Formato configurable (JSON/text):** JSON para produccion y pipelines de logs, text para desarrollo local. Un solo parametro en `logger_new`.
## Riesgos
- **Adopcion gradual:** Las apps existentes usan `fmt.Println`/`log.Printf`. Mitigado porque las funciones nuevas no rompen nada — las apps migran a su ritmo.
- **Middleware depende de 0009:** `logger_middleware` usa el tipo `Middleware` de 0009. Si 0009 no esta implementado, la fase 2 se pospone. La fase 1 es independiente.
- **Proliferacion de funciones de log:** 4 funciones de nivel + `logger_new` + `logger_with` = 6 funciones. Aceptable: cada una es trivial y atomica, preferible a una sola funcion con parametro de nivel.
+309
View File
@@ -0,0 +1,309 @@
# 0021 — CRUD Generator
## Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0021 |
| **Estado** | pendiente |
| **Prioridad** | media |
| **Tipo** | feature |
## Dependencias
- **0009** (HTTP Server Foundation) — los handlers generados usan `http_json_response`, `http_error_response`, `http_parse_body` y se registran via `http_router`.
- **0015** (DB Migrations) — la tabla generada por `crud_generate_table_sql` se ejecuta como migracion.
---
## Objetivo
Generar handlers REST completos (list, get, create, update, delete) a partir de una definicion declarativa de recurso, sin escribir codigo repetitivo. El 80% de los endpoints de cualquier API son CRUD identico — solo cambian el nombre de la tabla y los campos.
## Contexto
- Ya existen `wails_bind_crud_go_infra` (genera bindings CRUD para Wails como string de codigo Go) y `crud_page_ts_ui` (layout CRUD en frontend). Pero no hay nada que genere **endpoints HTTP** CRUD funcionales.
- Las apps del registry (`sqlite_api`, `deploy_server`, futuras) construyen sus handlers CRUD a mano cada vez: misma estructura de list con paginacion, mismo get por ID, mismo create con validacion, mismo update parcial, mismo delete.
- Este issue NO genera archivos ni templates — genera **handler factories en runtime**. Le pasas una definicion de recurso y te devuelve `http.HandlerFunc` listas para usar.
- Especifico para SQLite (usa `database/sql` con mattn/go-sqlite3). No abstrae multiples motores de BD.
## Arquitectura
```
functions/infra/
crud_define_resource.go — NEW: construye CRUDResource a partir de nombre y campos
crud_define_resource.md — NEW
crud_generate_table_sql.go — NEW: genera CREATE TABLE a partir de CRUDResource
crud_generate_table_sql.md — NEW
crud_generate_handlers.go — NEW: genera los 5 handlers a partir de CRUDResource + *sql.DB
crud_generate_handlers.md — NEW
crud_register_routes.go — NEW: registra rutas CRUD en router
crud_register_routes.md — NEW
crud_list_handler.go — NEW: handler generico list con paginacion/filtro/sort
crud_list_handler.md — NEW
crud_get_handler.go — NEW: handler get por ID
crud_get_handler.md — NEW
crud_create_handler.go — NEW: handler create con validacion
crud_create_handler.md — NEW
crud_update_handler.go — NEW: handler update parcial por ID
crud_update_handler.md — NEW
crud_delete_handler.go — NEW: handler delete por ID
crud_delete_handler.md — NEW
types/infra/
crud_resource.md — NEW: metadata del tipo CRUDResource
crud_field.md — NEW: metadata del tipo CRUDField
crud_list_params.md — NEW: metadata del tipo CRUDListParams
crud_list_result.md — NEW: metadata del tipo CRUDListResult
```
### Patron pure core / impure shell
- **Pure:** `crud_define_resource` (construye struct), `crud_generate_table_sql` (string SQL), `crud_generate_handlers` (retorna handlers sin ejecutar I/O, pero los handlers en si hacen I/O al invocarse — la funcion factory es pure, el handler resultante es impure)
- **Impure:** `crud_register_routes` (muta router), `crud_list_handler`, `crud_get_handler`, `crud_create_handler`, `crud_update_handler`, `crud_delete_handler` (todos hacen I/O contra SQLite y HTTP)
**Nota sobre `crud_generate_handlers`:** La funcion que genera los handlers es pura (recibe definicion + db, retorna funciones). Pero los handlers retornados son closures impuros que leen/escriben BD y HTTP. Se clasifica como **pure** porque la funcion en si no hace I/O — solo construye closures.
## Diseno
### Tipos
```go
// CRUDResource define un recurso CRUD completo
type CRUDResource struct {
Name string // nombre del recurso (singular, snake_case: "project")
Table string // nombre de la tabla SQLite ("projects")
Fields []CRUDField // campos del recurso (sin ID ni timestamps)
SoftDelete bool // si true, usa deleted_at en vez de DELETE real
}
// CRUDField define un campo del recurso
type CRUDField struct {
Name string // nombre del campo (snake_case: "display_name")
Type string // tipo SQLite: TEXT, INTEGER, REAL, BLOB
Required bool // NOT NULL + validacion en create
Unique bool // UNIQUE constraint
Default string // valor por defecto en CREATE TABLE (vacio = sin default)
Validations map[string]string // reglas: "min_length":"3", "max_length":"255", "pattern":"^[a-z]+"
}
// CRUDListParams parametros de paginacion, orden y filtro
type CRUDListParams struct {
Page int // pagina actual (1-based, default 1)
PerPage int // items por pagina (default 20, max 100)
SortBy string // campo por el que ordenar (default "created_at")
SortDir string // "asc" o "desc" (default "desc")
Filters map[string]string // campo -> valor para WHERE exacto
}
// CRUDListResult resultado paginado
type CRUDListResult struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
}
```
### Funciones
| Funcion | Purity | Firma (simplificada) |
|---------|--------|---------------------|
| `crud_define_resource` | pure | `(name string, table string, fields []CRUDField, softDelete bool) CRUDResource` |
| `crud_generate_table_sql` | pure | `(res CRUDResource) string` |
| `crud_generate_handlers` | pure | `(res CRUDResource, db *sql.DB) map[string]http.HandlerFunc` |
| `crud_register_routes` | impure | `(mux *http.ServeMux, basePath string, res CRUDResource, db *sql.DB)` |
| `crud_list_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
| `crud_get_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
| `crud_create_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
| `crud_update_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
| `crud_delete_handler` | impure | `(res CRUDResource, db *sql.DB) http.HandlerFunc` |
### Comportamiento de cada handler
**List** (`GET /basePath`):
- Query params: `page`, `per_page`, `sort_by`, `sort_dir`, `filter_{campo}={valor}`
- Genera `SELECT * FROM table WHERE ... ORDER BY ... LIMIT ... OFFSET ...`
- Cuenta total con `SELECT COUNT(*) FROM table WHERE ...`
- Retorna `CRUDListResult` como JSON
- Si `soft_delete`, agrega `WHERE deleted_at IS NULL` automaticamente
**Get** (`GET /basePath/{id}`):
- Genera `SELECT * FROM table WHERE id = ?`
- 404 si no existe (o si `soft_delete` y tiene `deleted_at`)
- Retorna el registro como JSON
**Create** (`POST /basePath`):
- Parsea body JSON con `http_parse_body`
- Valida campos required y validaciones de cada campo
- Genera UUID para `id`, timestamp para `created_at` y `updated_at`
- `INSERT INTO table (id, field1, ..., created_at, updated_at) VALUES (?, ?, ..., ?, ?)`
- Retorna 201 con el registro creado
**Update** (`PUT /basePath/{id}`):
- Parsea body JSON — solo campos presentes se actualizan (partial update)
- Valida campos enviados contra sus reglas
- `UPDATE table SET field1=?, updated_at=? WHERE id=?`
- 404 si no existe
- Retorna el registro actualizado
**Delete** (`DELETE /basePath/{id}`):
- Si `soft_delete`: `UPDATE table SET deleted_at=? WHERE id=?`
- Si no: `DELETE FROM table WHERE id=?`
- 404 si no existe
- Retorna 204 sin body
### SQL generado por `crud_generate_table_sql`
```sql
-- Para un recurso "projects" con campos name (TEXT, required, unique) y description (TEXT)
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Si soft_delete:
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
```
### Validaciones soportadas
| Clave | Aplica a | Ejemplo |
|-------|----------|---------|
| `min_length` | TEXT | `"3"` — minimo 3 caracteres |
| `max_length` | TEXT | `"255"` — maximo 255 caracteres |
| `pattern` | TEXT | `"^[a-z_]+$"` — regex match |
| `min` | INTEGER, REAL | `"0"` — valor minimo |
| `max` | INTEGER, REAL | `"1000"` — valor maximo |
| `enum` | TEXT | `"active,inactive,archived"` — valores permitidos |
---
## Tareas
### Fase 1: Tipos y definicion
- [ ] **1.1** Crear tipos `CRUDResource`, `CRUDField`, `CRUDListParams`, `CRUDListResult` en `functions/infra/` con `.md` en `types/infra/`
- [ ] **1.2** `crud_define_resource` — construye `CRUDResource` validando que haya al menos un campo y que los tipos sean validos (TEXT, INTEGER, REAL, BLOB)
- [ ] **1.3** `crud_generate_table_sql` — genera DDL `CREATE TABLE IF NOT EXISTS` con las constraints derivadas de los campos
### Fase 2: Handlers individuales
- [ ] **2.1** `crud_list_handler` — parsea query params a `CRUDListParams`, construye SQL dinamico con paginacion y filtros, retorna `CRUDListResult`
- [ ] **2.2** `crud_get_handler` — busca por ID, escanea columnas dinamicamente a `map[string]any`, responde 404 si no existe
- [ ] **2.3** `crud_create_handler` — valida input contra definicion, genera UUID, inserta, retorna 201
- [ ] **2.4** `crud_update_handler` — partial update con solo los campos enviados, valida los presentes, 404 si no existe
- [ ] **2.5** `crud_delete_handler` — hard/soft delete segun config, 404 si no existe, retorna 204
### Fase 3: Composicion y tests
- [ ] **3.1** `crud_generate_handlers` — llama a los 5 handlers individuales, retorna `map[string]http.HandlerFunc` con keys "list", "get", "create", "update", "delete"
- [ ] **3.2** `crud_register_routes` — registra en `http.ServeMux` las rutas `GET /base`, `GET /base/{id}`, `POST /base`, `PUT /base/{id}`, `DELETE /base/{id}`
- [ ] **3.3** Tests con `httptest.NewServer` + SQLite in-memory (`:memory:`) para cada handler
- [ ] **3.4** Test de integracion: define recurso, genera tabla, registra rutas, CRUD completo via HTTP
- [ ] **3.5** `fn index` y verificar con `fn show` que todas las funciones y tipos aparecen
---
## Ejemplo de uso
```go
// 1. Definir el recurso
resource := infra.CRUDDefineResource("project", "projects", []infra.CRUDField{
{Name: "name", Type: "TEXT", Required: true, Unique: true,
Validations: map[string]string{"min_length": "1", "max_length": "100"}},
{Name: "description", Type: "TEXT"},
{Name: "status", Type: "TEXT", Required: true, Default: "'active'",
Validations: map[string]string{"enum": "active,archived,deleted"}},
{Name: "priority", Type: "INTEGER", Default: "0",
Validations: map[string]string{"min": "0", "max": "10"}},
}, false) // soft_delete = false
// 2. Crear la tabla
ddl := infra.CRUDGenerateTableSQL(resource)
db.Exec(ddl)
// 3. Registrar rutas (una linea)
mux := http.NewServeMux()
infra.CRUDRegisterRoutes(mux, "/api/projects", resource, db)
// Listo. Endpoints disponibles:
// GET /api/projects — list con paginacion
// GET /api/projects/{id} — get por ID
// POST /api/projects — create
// PUT /api/projects/{id} — update parcial
// DELETE /api/projects/{id} — delete
// 4. Multiples recursos en la misma API
infra.CRUDRegisterRoutes(mux, "/api/users", userResource, db)
infra.CRUDRegisterRoutes(mux, "/api/tasks", taskResource, db)
// 5. Componer con middlewares de 0009
middleware := infra.HttpMiddlewareChain(
infra.HttpCorsMiddleware([]string{"*"}, []string{"GET", "POST", "PUT", "DELETE"}),
infra.HttpLoggerMiddleware(os.Stdout),
)
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
infra.HttpServe(":8080", middleware(mux), ctx)
```
```bash
# Uso desde curl:
# List con paginacion y filtros
curl "localhost:8080/api/projects?page=1&per_page=10&sort_by=name&sort_dir=asc&filter_status=active"
# Get
curl "localhost:8080/api/projects/abc-123"
# Create
curl -X POST localhost:8080/api/projects \
-H 'Content-Type: application/json' \
-d '{"name":"mi-proyecto","description":"Desc","status":"active","priority":5}'
# Update parcial (solo cambia description)
curl -X PUT localhost:8080/api/projects/abc-123 \
-H 'Content-Type: application/json' \
-d '{"description":"Nueva descripcion"}'
# Delete
curl -X DELETE localhost:8080/api/projects/abc-123
```
## Decisiones de diseno
- **Runtime handler factories, no code generation:** `wails_bind_crud_go_infra` genera codigo como string para Wails. Este issue toma el camino opuesto: las funciones **son** los handlers, no generan texto. Esto evita templates, archivos generados y el ciclo generate-compile. El tradeoff es que los handlers son genericos (`map[string]any`) en vez de tipados, pero para APIs REST sobre SQLite es aceptable.
- **`map[string]any` en vez de structs tipados:** Como los campos se definen en runtime, no es posible usar structs Go tipados. Toda la serializacion pasa por `map[string]any`. Esto es idiomatico para APIs JSON + SQLite donde el schema es dinamico.
- **SQLite especifico, no multi-motor:** Simplifica enormemente el SQL generado (TEXT para timestamps, sin SERIAL, sin esquemas). Si en el futuro se necesita Postgres, se crea una variante separada.
- **Validacion en Go, no en SQLite:** Las constraints de SQLite (CHECK, NOT NULL) son la ultima linea de defensa. La validacion principal ocurre en el handler antes del INSERT/UPDATE, con mensajes de error descriptivos para el cliente.
- **UUID como ID:** Todos los recursos usan `id TEXT PRIMARY KEY` con UUID generado server-side. No IDs autoincrement — evita problemas de concurrencia y es mas portable.
- **Timestamps como TEXT ISO 8601:** Consistente con como registry.db y operations.db ya almacenan timestamps.
- **Partial update en PUT:** Normalmente PATCH es para partial update y PUT para replace completo. Aqui se usa PUT con partial update por simplicidad — solo los campos presentes en el JSON se actualizan. Es pragmatico para APIs internas.
## Relacion con funciones existentes
| Funcion existente | Relacion |
|---|---|
| `wails_bind_crud_go_infra` | Genera **codigo Go** como string para desktop (Wails). Este issue genera **handlers HTTP** en runtime para REST APIs. Complementarios, no solapados. |
| `crud_page_ts_ui` | Frontend CRUD layout en React. Los handlers generados aqui serian el **backend** que esa pagina consume. Stack completo: `crud_define_resource` (definicion) + handlers (backend) + `crud_page` (frontend). |
## Riesgos
- **SQL injection en filtros:** Los filtros se pasan como query params y se inyectan en WHERE. Mitigado validando que el campo exista en la definicion del recurso y usando siempre `?` placeholders — nunca interpolacion de strings en SQL.
- **Performance en tablas grandes:** El `SELECT COUNT(*)` para paginacion se ejecuta en cada list request. Para tablas con millones de filas esto es lento. Mitigado: las apps del registry manejan miles de registros, no millones. Si se necesita, se anade cache de count como mejora futura.
- **Scope creep hacia un ORM:** Hay tentacion de agregar relaciones, joins, nested resources, hooks before/after. Mitigado limitando el scope a CRUD plano de una tabla. Relaciones y logica de negocio van en handlers custom, no en el generador.
- **Colision con 0009 si cambia la API:** Los handlers usan `http_json_response` y `http_error_response` de 0009. Si esas firmas cambian, hay que actualizar. Mitigado: las funciones de 0009 son primitivas estables con firmas simples.
+554
View File
@@ -0,0 +1,554 @@
# 0022 — Init Pipelines
## Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0022 |
| **Estado** | pendiente |
| **Prioridad** | alta |
| **Tipo** | feature |
## Dependencias
| ID | Titulo | Estado | Requerido |
|----|--------|--------|-----------|
| 0009 | HTTP Server Foundation | pendiente | si |
| 0010 | Auth System | pendiente | si |
| 0015 | Database Migrations | pendiente | si |
**Bloqueada por:** `#0009` (http_serve, http_router, http_json_response, http_middleware_chain), `#0010` (jwt_middleware, password_hash, session_create), `#0015` (migration_create, migration_up, migration_status). Los pipelines de init generan boilerplate que importa y compone estas funciones — sin ellas, el codigo scaffoldeado no compilaria.
**Desbloquea:** cualquier app nueva del registry se puede crear con un solo comando en vez de copiar y adaptar una app existente.
---
## Objetivo
Cuatro bash pipelines que scaffold apps completas en `apps/` con un solo comando. Cada uno genera la estructura de directorios, archivos boilerplate, `app.md` con frontmatter correcto, y verifica que el resultado compila. Misma filosofia que `init_jupyter_analysis_bash_pipelines`: componer funciones atomicas del registry para producir un entorno listo para trabajar.
## Contexto
- Existen tres init pipelines: `init_go_project_bash_pipelines` (repo Go generico), `init_go_module_bash_pipelines` (modulo Go simple), `init_jupyter_analysis_bash_pipelines` (analysis Jupyter). Todos scaffoldean estructuras basicas.
- **No existe ningun pipeline para scaffoldear apps del registry** con HTTP server, auth, DB, frontend, Wails o TUI. Cada app nueva se construye copiando otra y adaptando manualmente.
- Las apps existentes (`deploy_server`, `sqlite_api`, `rapid_dashboards`, `pipeline_launcher`) comparten patrones repetitivos: main.go con graceful shutdown, config desde env vars, health check, migrations, app.md.
- Con las funciones de 0009 (HTTP), 0010 (auth) y 0015 (migrations) disponibles, el boilerplate de una app API es predecible y automatizable.
- El registry ya tiene funciones Wails completas (`scaffold_wails_app_go_infra`, `install_wails_bash_infra`, `wails_build_go_infra`, hooks `use_wails_*_ts_ui`, `wails_provider_ts_ui`) y TUI (`new_base_model_go_tui`, `run_fullscreen_go_tui`, temas, spinners, listas).
## Arquitectura
```
bash/functions/pipelines/
├── init_api_app.sh — NEW: scaffold Go HTTP API app
├── init_api_app.md — NEW
├── init_web_app.sh — NEW: scaffold full-stack app (Go API + React)
├── init_web_app.md — NEW
├── init_desktop_app.sh — NEW: scaffold Wails desktop app
├── init_desktop_app.md — NEW
├── init_cli_app.sh — NEW: scaffold Go CLI/TUI app
├── init_cli_app.md — NEW
```
Todas son `kind: pipeline`, `purity: impure`, `lang: bash`, `domain: pipelines`.
### Patron de composicion
Cada pipeline sigue el mismo patron que `init_jupyter_analysis`:
1. Source funciones atomicas del registry via `source "$REGISTRY_ROOT/bash/functions/..."`
2. Parsear argumentos (nombre obligatorio, flags opcionales)
3. Crear estructura de directorios con `mkdir -p`
4. Escribir archivos boilerplate con heredocs
5. Generar `app.md` con frontmatter correcto
6. Ejecutar `fn index` para registrar la app
7. Verificar con `go vet` / `pnpm build` / `wails build` segun corresponda
---
## Diseno
### Pipeline 1: `init_api_app`
Scaffold de Go HTTP API app en `apps/`.
**Uso:**
```bash
fn run init_api_app my_service
fn run init_api_app my_service --port 8080 --with-auth --with-db
```
**Archivos generados:**
```
apps/{nombre}/
├── main.go — Entry point: config → router → middleware → http_serve con graceful shutdown
├── handlers.go — Handler GET /health + handler de ejemplo GET /api/v1/status
├── config.go — Struct Config con tags + carga desde .env / env vars
├── migrations/
│ └── 001_initial.sql — CREATE TABLE ejemplo con id, created_at, updated_at
├── app.md — Frontmatter con tag service, uses_functions, dir_path
├── Makefile — Targets: build, run, test, vet, clean
├── .env.example — Variables de entorno documentadas (PORT, DB_PATH, etc.)
└── .gitignore — Binario, .env, *.db-shm, *.db-wal
```
**Funciones del registry compuestas:**
| Funcion | Para que |
|---------|---------|
| `assert_command_exists_bash_shell` | Verificar que `go` esta instalado |
| `http_serve_go_infra` (0009) | Codigo de graceful shutdown en main.go |
| `http_router_go_infra` (0009) | Registro de rutas en main.go |
| `http_json_response_go_infra` (0009) | Helper en handlers.go |
| `http_error_response_go_infra` (0009) | Helper en handlers.go |
| `http_middleware_chain_go_infra` (0009) | Composicion de middlewares en main.go |
| `http_logger_middleware_go_infra` (0009) | Logging en main.go |
| `http_cors_middleware_go_infra` (0009) | CORS en main.go |
| `migration_up_go_infra` (0015) | Aplicar migrations en main.go al arrancar |
| `config_load_go_infra` (0018) | Carga de config en config.go (si existe) |
**Flags opcionales:**
| Flag | Efecto |
|------|--------|
| `--port N` | Puerto por defecto en config y .env.example (default: 8080) |
| `--with-auth` | Anade jwt_middleware, handlers de login/register, tabla users en migration |
| `--with-db` | Anade operations.db setup, store.go con helpers CRUD basicos |
| `--with-ops` | Anade `fn ops init` para crear operations.db con schema completo |
**main.go generado (esquema):**
```go
package main
import (
"context"
"log"
"os"
"os/signal"
"fn_registry/functions/infra"
)
func main() {
cfg := LoadConfig()
// Migrations
if err := infra.MigrationUp(cfg.DBPath, "migrations"); err != nil {
log.Fatal(err)
}
// Routes
routes := []infra.Route{
{Method: "GET", Path: "/health", Handler: healthHandler},
{Method: "GET", Path: "/api/v1/status", Handler: statusHandler},
}
mux := infra.HttpRouter(routes)
// Middleware
middleware := infra.HttpMiddlewareChain(
infra.HttpCorsMiddleware(cfg.CORSOrigins, []string{"GET", "POST", "PUT", "DELETE"}),
infra.HttpLoggerMiddleware(os.Stdout),
)
// Serve
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
log.Printf("starting %s on :%s", cfg.AppName, cfg.Port)
if err := infra.HttpServe(":"+cfg.Port, middleware(mux), ctx); err != nil {
log.Fatal(err)
}
}
```
---
### Pipeline 2: `init_web_app`
Scaffold de full-stack app: Go API backend + React frontend con Mantine.
**Uso:**
```bash
fn run init_web_app my_dashboard
fn run init_web_app my_dashboard --port 8080 --with-auth
```
**Archivos generados:**
```
apps/{nombre}/
├── main.go — Igual que init_api_app + serve static files del frontend build
├── handlers.go — Health + API handlers de ejemplo
├── config.go — Config con FRONTEND_DIR
├── migrations/
│ └── 001_initial.sql
├── app.md — tag service, uses frontend
├── Makefile — Targets: build, build-frontend, run, dev, test, clean
├── .env.example
├── .gitignore
├── docker-compose.yml — Dev: Go API hot-reload + frontend dev server
└── frontend/
├── package.json — pnpm, vite, react, @mantine/core, @mantine/charts, @fn_library
├── vite.config.ts — API proxy a localhost:${port}
├── tsconfig.json
├── index.html
├── postcss.config.cjs
└── src/
├── main.tsx — FnMantineProvider + App mount
├── App.tsx — Router basico con pagina de ejemplo
├── theme.ts — createTheme() con colores del proyecto
└── pages/
└── Home.tsx — Pagina de ejemplo usando crud_page_ts_ui o dashboard_layout_ts_ui
```
**Funciones adicionales compuestas (sobre init_api_app):**
| Funcion | Para que |
|---------|---------|
| `mantine_provider_ts_ui` | Provider raiz en main.tsx |
| `crud_page_ts_ui` | Pagina de ejemplo funcional |
| `app_shell_ts_ui` | Layout con navbar y header |
| `data_table_ts_ui` | Tabla de datos en la pagina de ejemplo |
**vite.config.ts generado:**
```ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@fn_library": path.resolve(__dirname, "../../frontend/functions/ui"),
},
},
server: {
proxy: {
"/api": {
target: "http://localhost:${PORT}",
changeOrigin: true,
},
"/health": {
target: "http://localhost:${PORT}",
},
},
},
});
```
**docker-compose.yml generado:**
```yaml
services:
api:
build: .
ports:
- "${PORT}:${PORT}"
env_file: .env
volumes:
- ./migrations:/app/migrations
frontend:
image: node:20-alpine
working_dir: /app
command: sh -c "corepack enable && pnpm install && pnpm dev"
ports:
- "5173:5173"
volumes:
- ./frontend:/app
```
---
### Pipeline 3: `init_desktop_app`
Scaffold de Wails desktop app con Go backend y React frontend con @fn_library.
**Uso:**
```bash
fn run init_desktop_app my_tool
fn run init_desktop_app my_tool --with-db
```
**Archivos generados:**
```
apps/{nombre}/
├── main.go — Wails entry point con embed del frontend
├── app.go — Struct App con bindings base (Greet, GetVersion)
├── wails.json — Config Wails apuntando a frontend/
├── go.mod
├── app.md — framework: wails, uses wails hooks
├── .gitignore
└── frontend/
├── package.json — pnpm, vite, react, @mantine/core, @fn_library
├── vite.config.ts
├── tsconfig.json
├── index.html
└── src/
├── main.tsx — WailsProvider + FnMantineProvider + App
├── App.tsx — Ejemplo usando useWailsQuery + data_table
└── theme.ts — createTheme()
```
**Funciones del registry compuestas:**
| Funcion | Para que |
|---------|---------|
| `scaffold_wails_app_go_infra` | Genera estructura base Wails (main.go, app.go, wails.json, go.mod) |
| `install_wails_bash_infra` | Verifica/instala Wails CLI y deps de sistema |
| `wails_provider_ts_ui` | Provider React para IPC cache |
| `use_wails_query_ts_ui` | Hook de ejemplo en App.tsx |
| `mantine_provider_ts_ui` | Provider Mantine |
| `wails_bind_crud_go_infra` | Genera bindings CRUD si `--with-db` |
**Flags opcionales:**
| Flag | Efecto |
|------|--------|
| `--with-db` | Anade SQLite con migrations, bindings CRUD generados por `wails_bind_crud_go_infra` |
---
### Pipeline 4: `init_cli_app`
Scaffold de Go CLI app con subcomandos y componentes TUI de Bubbletea.
**Uso:**
```bash
fn run init_cli_app my_cli
fn run init_cli_app my_cli --with-tui
```
**Archivos generados:**
```
apps/{nombre}/
├── main.go — Entry point con subcommand routing (os.Args)
├── cmd_version.go — Subcomando: version
├── cmd_status.go — Subcomando de ejemplo: status (imprime info)
├── app.md — framework vacio (CLI puro) o bubbletea (con --with-tui)
├── Makefile — Targets: build, run, install, test, clean
├── .gitignore
└── go.mod
```
**Con `--with-tui`:**
```
apps/{nombre}/
├── main.go — Entry point con run_fullscreen o run_model
├── model.go — BaseModel + Update + View con tema oscuro
├── cmd_version.go — Subcomando no-TUI
├── app.md — framework: bubbletea
├── Makefile
├── .gitignore
└── go.mod
```
**Funciones del registry compuestas:**
| Funcion | Para que |
|---------|---------|
| `assert_command_exists_bash_shell` | Verificar Go |
| `new_base_model_go_tui` | Modelo base en model.go |
| `dark_styles_go_tui` | Tema oscuro por defecto |
| `run_fullscreen_go_tui` | Arranque fullscreen en main.go |
| `new_spinner_go_tui` | Componente de ejemplo |
| `new_filtered_list_go_tui` | Componente de ejemplo |
**main.go generado (sin TUI):**
```go
package main
import (
"fmt"
"os"
)
var version = "dev"
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
switch os.Args[1] {
case "version":
cmdVersion()
case "status":
cmdStatus()
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
printUsage()
os.Exit(1)
}
}
func printUsage() {
fmt.Println("Usage: {nombre} <command>")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" version Print version")
fmt.Println(" status Show status")
}
```
---
## Tareas
### Fase 1: `init_api_app` (pipeline base)
- [ ] **1.1** Crear `bash/functions/pipelines/init_api_app.sh` con source de funciones atomicas, parseo de argumentos, y generacion de estructura
- [ ] **1.2** Escribir heredocs para `main.go`, `handlers.go`, `config.go` que importen funciones de 0009 y 0015
- [ ] **1.3** Generar `app.md` con frontmatter correcto (tag `service`, `uses_functions` con IDs reales, `dir_path`)
- [ ] **1.4** Generar `Makefile`, `.env.example`, `.gitignore`, `migrations/001_initial.sql`
- [ ] **1.5** Flag `--with-auth`: anadir imports de 0010, handlers de login/register, tabla users en migration
- [ ] **1.6** Flag `--with-db`: anadir `store.go` con helpers CRUD, setup de SQLite al arrancar
- [ ] **1.7** Ejecutar `go vet -tags fts5` al final como verificacion
- [ ] **1.8** Crear `init_api_app.md` con frontmatter de pipeline
### Fase 2: `init_web_app` (extiende init_api_app)
- [ ] **2.1** Crear `bash/functions/pipelines/init_web_app.sh` que primero invoca la logica de `init_api_app` y luego anade el frontend
- [ ] **2.2** Generar `frontend/` con `package.json` (pnpm, vite, react, mantine, @fn_library alias)
- [ ] **2.3** Generar `vite.config.ts` con proxy al backend y alias `@fn_library`
- [ ] **2.4** Generar `src/main.tsx` con `FnMantineProvider`, `src/App.tsx` con `AppShell`, `src/pages/Home.tsx` con ejemplo
- [ ] **2.5** Generar `docker-compose.yml` para desarrollo
- [ ] **2.6** Actualizar `main.go` para servir static files del frontend build
- [ ] **2.7** Ejecutar `pnpm install && pnpm build` como verificacion del frontend
- [ ] **2.8** Crear `init_web_app.md` con frontmatter de pipeline
### Fase 3: `init_desktop_app`
- [ ] **3.1** Crear `bash/functions/pipelines/init_desktop_app.sh` que invoca `scaffold_wails_app_go_infra` y anade frontend React
- [ ] **3.2** Verificar/instalar Wails con `install_wails_bash_infra`
- [ ] **3.3** Generar frontend con `WailsProvider` + `FnMantineProvider` y ejemplo con `useWailsQuery`
- [ ] **3.4** Flag `--with-db`: invocar `wails_bind_crud_go_infra` para generar bindings
- [ ] **3.5** Ejecutar `wails build` como verificacion
- [ ] **3.6** Crear `init_desktop_app.md` con frontmatter de pipeline
### Fase 4: `init_cli_app`
- [ ] **4.1** Crear `bash/functions/pipelines/init_cli_app.sh` con generacion de estructura CLI basica
- [ ] **4.2** Generar `main.go` con routing de subcomandos, `cmd_version.go`, `cmd_status.go`
- [ ] **4.3** Flag `--with-tui`: generar `model.go` con `new_base_model`, `dark_styles`, `run_fullscreen`
- [ ] **4.4** Ejecutar `go vet` como verificacion
- [ ] **4.5** Crear `init_cli_app.md` con frontmatter de pipeline
### Fase 5: Integracion
- [ ] **5.1** `fn index` y verificar que los 4 pipelines aparecen en registry.db con kind=pipeline, purity=impure
- [ ] **5.2** Verificar `fn run init_api_app test_app` end-to-end: genera, compila, limpia
- [ ] **5.3** Verificar `fn run init_web_app test_web` end-to-end
- [ ] **5.4** Verificar `fn run init_desktop_app test_desktop` end-to-end
- [ ] **5.5** Verificar `fn run init_cli_app test_cli` end-to-end
### Fase 6: Documentacion de uso rapido
Cada pipeline debe ser usable sin leer el issue completo. La documentacion va en dos niveles: el `.md` de cada funcion (fuente de verdad para `fn show`) y una guia consolidada.
- [ ] **6.1** En cada `.md` de pipeline (`init_api_app.md`, etc.) documentar en la seccion `documentation` del frontmatter:
- Sinopsis: `fn run init_api_app <nombre> [--port N] [--with-auth] [--with-db]`
- Descripcion de cada flag y su efecto concreto (que archivos anade, que imports genera)
- Listado de archivos generados con una linea de descripcion cada uno
- Post-setup: que comandos ejecutar despues (`make run`, `make dev`, `wails dev`, etc.)
- Ejemplo rapido: un bloque copy-paste de 3-4 lineas que crea la app y la arranca
- [ ] **6.2** En el campo `params` del frontmatter de cada pipeline, documentar cada argumento y flag con `name` y `desc` semantico para que `fn check params` pase y la info sea buscable via FTS5
- [ ] **6.3** En el campo `example` del frontmatter, poner el caso de uso mas comun (una linea):
- `init_api_app`: `fn run init_api_app my_service --with-db`
- `init_web_app`: `fn run init_web_app my_dashboard --with-auth`
- `init_desktop_app`: `fn run init_desktop_app my_tool`
- `init_cli_app`: `fn run init_cli_app my_cli --with-tui`
- [ ] **6.4** Crear `docs/init-pipelines.md` como guia consolidada de referencia rapida con:
- Tabla resumen de los 4 pipelines (nombre, que genera, flags disponibles)
- Arbol de decision: "quiero una API" → init_api_app, "quiero frontend" → init_web_app, "quiero desktop" → init_desktop_app, "quiero CLI" → init_cli_app
- Seccion de combinaciones comunes (API + auth + DB, web dashboard, desktop con SQLite, CLI con TUI)
- FAQ: como anadir auth despues, como cambiar el puerto, como anadir operations.db, como agregar mas paginas al frontend
- [ ] **6.5** Verificar que `fn show init_api_app_bash_pipelines` (y los otros 3) muestra la documentacion completa con params, ejemplo y notas de uso
---
## Ejemplo de uso
```bash
# API service con auth y database
fn run init_api_app billing_api --port 8090 --with-auth --with-db
cd apps/billing_api
make run
# → starting billing_api on :8090
# → curl localhost:8090/health → {"status":"ok"}
# Full-stack dashboard
fn run init_web_app inventory_dashboard --with-auth
cd apps/inventory_dashboard
make dev
# → API en :8080, frontend en :5173 con proxy
# Desktop app con base de datos
fn run init_desktop_app data_explorer --with-db
cd apps/data_explorer
wails dev
# → App de escritorio con React + SQLite
# CLI con TUI
fn run init_cli_app deploy_helper --with-tui
cd apps/deploy_helper
make run -- status
# → TUI fullscreen con lista filtrable
```
**Cada pipeline genera su `app.md` listo para `fn index`:**
```yaml
---
name: billing_api
lang: go
domain: tools
description: "API de facturacion."
tags: [service]
uses_functions:
- http_serve_go_infra
- http_router_go_infra
- http_middleware_chain_go_infra
- http_cors_middleware_go_infra
- http_logger_middleware_go_infra
- http_json_response_go_infra
- http_error_response_go_infra
- migration_up_go_infra
uses_types: []
framework: "net/http"
entry_point: "main.go"
dir_path: "apps/billing_api"
---
```
---
## Decisiones de diseno
- **Bash, no Go:** los init pipelines generan archivos con heredocs — bash es el lenguaje natural para esto. Go seria overengineering para scaffolding de texto. Coherente con `init_jupyter_analysis` y los demas init existentes.
- **Composicion sobre monolito:** cada pipeline sourcea funciones atomicas del registry (`assert_command_exists`, `scaffold_wails_app`, etc.) en vez de reimplementar. Si una funcion atomica mejora, todos los pipelines se benefician.
- **init_web_app extiende init_api_app:** el pipeline web reutiliza la logica del API (misma estructura backend) y anade la capa frontend encima. No duplica codigo.
- **Verificacion al final:** cada pipeline termina con `go vet`, `pnpm build`, o `wails build` para garantizar que el scaffold compila. Si falla, el pipeline reporta el error antes de declarar exito.
- **Flags opcionales con defaults sensatos:** el caso base (sin flags) genera una app funcional minima. `--with-auth`, `--with-db`, `--with-tui` anaden capas incrementales. El usuario no necesita decidir todo upfront.
- **@fn_library como alias, no copia:** el frontend generado referencia `@fn_library` via alias en `vite.config.ts` apuntando a `frontend/functions/ui/` del registry. Los componentes se comparten, no se duplican.
- **app.md generado automaticamente:** el frontmatter incluye `uses_functions` con los IDs reales de las funciones que el boilerplate importa. `fn index` los valida al registrar la app.
- **Sin framework CLI externo para init_cli_app:** routing de subcomandos con `os.Args` y switch — consistente con las apps existentes del registry que no usan cobra/urfave. Para TUI se usa Bubbletea que ya esta en el registry.
## Riesgos
- **Dependencias no implementadas:** los tres issues de dependencia (0009, 0010, 0015) estan pendientes. Si alguna funcion cambia de firma durante su implementacion, los heredocs de los pipelines necesitaran ajuste. **Mitigacion:** implementar los pipelines despues de que las dependencias esten merged, o mantener los heredocs parametricos para absorber cambios menores.
- **Heredocs fragiles:** generar Go/TS/YAML con heredocs bash es propenso a errores de indentacion, escape de variables y quoting. **Mitigacion:** cada pipeline incluye verificacion final (`go vet` / `pnpm build`) que detecta errores de sintaxis inmediatamente. Tests end-to-end en fase 5.
- **Frontend desactualizado respecto a @fn_library:** si los componentes de `frontend/functions/ui/` evolucionan, el boilerplate generado puede quedar desactualizado. **Mitigacion:** el boilerplate es minimo (un Provider, un AppShell, una pagina de ejemplo) — el usuario lo extiende con los componentes actuales del registry.
- **Wails como dependencia de sistema:** `init_desktop_app` requiere GTK3 + WebKit2GTK instalados en Linux. `install_wails_bash_infra` lo maneja, pero puede fallar en distros no soportadas. **Mitigacion:** el pipeline verifica la instalacion al inicio y falla rapido con mensaje descriptivo.
- **Colision de nombres:** si el usuario elige un nombre que ya existe en `apps/`, el pipeline sobreescribiria archivos. **Mitigacion:** verificar si `apps/{nombre}/` existe al inicio y abortar con error si ya existe.
@@ -0,0 +1,225 @@
# 0024 — Split dashboard YAMLs por tab
## APP Metadata
| Campo | Valor |
|-------|-------|
| **ID** | 0024 |
| **Estado** | pendiente |
| **Prioridad** | alta |
| **Tipo** | mejora — devex de auto_metabase |
## Dependencias
Ninguna. Modifica `apps/auto_metabase/sync_pull.py`, `sync_push.py`, `sync_validate.py` y `app.md`.
**Desbloquea:** edicion fluida de dashboards grandes (aurgi id=734: 11.282 lineas, 90 dashcards, 11 tabs). Actualmente cada cambio obliga a scrollear el monolitico; tras esta issue cada tab es un archivo independiente.
---
## Objetivo
Dividir el YAML monolitico de cada dashboard en un directorio `{slug}/` con un archivo `_dashboard.yaml` (metadata, parametros, tabs) y un archivo por tab (`tab_{slug_tab}.yaml`) con sus dashcards. El push compone todo de vuelta a un unico body JSON antes de enviar a Metabase — cambio 100% cosmetico del sync local, el API no se entera.
## Contexto
**Situacion actual:**
- `pull_dashboard()` escribe `projects/{name}/dashboards/{slug}.yaml` con todo: metadata + parametros + tabs + 90+ dashcards en un solo archivo.
- Aurgi (proyecto real) tiene un dashboard `bi_ventas_portfolio_producto_en_construccion` que genera **11.282 lineas YAML**. Para editar una dashcard en el tab "Foto Categorias" hay que encontrarla entre 90 entries via `dashboard_tab_id` (que recientemente volvimos a preservar — ver commit de sync_pull que re-inyecta ids de tabs tras el strip).
- Los diffs de git son ilegibles cuando se reordenan dashcards: cambiar 1 dashcard puede mover 40 bloques de YAML y romper el review.
- `validate` corre sobre el archivo completo aunque solo hayas editado una tab.
**Alternativas consideradas:**
- **One file per dashcard** (muy granular) — descartado: demasiados archivos para un dashboard mediano, el tab se pierde como unidad semantica.
- **One file per tab** — elegido: coincide con la unidad semantica que un humano edita ("trabajar en la tab 5 Min View"), diffs acotados a una tab, tamaño manejable (~1000 lineas por tab en el peor caso).
- **Mantener monolitico + jq/yq helpers** — descartado: no resuelve el problema del diff ni de la navegacion cognitiva.
## Arquitectura
```
apps/auto_metabase/
├── sync_pull.py # MOD: pull_dashboard -> split en directorio
├── sync_push.py # MOD: load_dashboard -> compose desde directorio
├── sync_validate.py # MOD: resolver path a dashboard (file o directory)
├── payload.py # MOD si hace falta (builders actuales)
├── app.md # MOD: documentar nuevo layout
└── projects/{name}/
└── dashboards/
├── {slug}.yaml # LEGACY: sigue soportado (backward-compat)
└── {slug}/ # NEW: nueva estructura directory-based
├── _dashboard.yaml # NEW: _meta + _refs + name + description + parameters + tabs (solo names/ids) + dashcards_globales (sin tab_id)
├── tab_5_min_view.yaml # NEW: dashcards con dashboard_tab_id=191
├── tab_foto_centros_compara_semana.yaml # NEW
└── ...
```
### Pure core / impure shell
- **Pure (testable sin Metabase):**
- `split_dashboard_payload(payload: dict) -> dict[str, dict]` — toma el dict de un dashboard tal y como lo escribe el pull actual y devuelve `{"_dashboard": {...}, "tab_xxx": {...}, ...}`. Input → output puro. En `sync_pull.py` o en un nuevo modulo `dashboard_split.py`.
- `merge_dashboard_payload(parts: dict[str, dict]) -> dict` — inverso: toma el dict de partes y reconstruye el payload unico para push. Tambien puro.
- `tab_slug(tab_name: str) -> str` — slugify, reusar la que ya existe en `sync_pull._slugify()`.
- **Impure (I/O):**
- `pull_dashboard()` — escribe directorio en disco tras el split.
- `load_dashboard_from_disk(dir_path)` — lee todos los `tab_*.yaml` + `_dashboard.yaml` y pasa a `merge_dashboard_payload()`.
### Formato de `_dashboard.yaml` (directory-based)
```yaml
_meta:
kind: dashboard
id: 734
slug: bi_ventas_portfolio_producto_en_construccion
synced_at: '2026-04-13T16:04:34Z'
remote_updated_at: '2026-04-09T15:11:20Z'
dashcards_count: 90
tabs_count: 11
parameters_count: 26
split_version: 1 # NEW: marcador de formato splitteado
_refs:
collection: repositorio_central
payload:
archived: false
enable_embedding: false
name: "🚧BI - VENTAS - PORTFOLIO PRODUCTO 🚧 En construccion"
description: ...
width: full
parameters: [...] # 26 parametros
tabs: # lista con nombre + id (para mapear a files)
- id: 191
name: 5 Min View
file: tab_5_min_view.yaml
- id: 192
name: Foto Centros - Compara Semana
file: tab_foto_centros_compara_semana.yaml
- ...
dashcards_globales: [] # dashcards SIN dashboard_tab_id (headings root-level)
```
### Formato de `tab_{slug}.yaml`
```yaml
_meta:
kind: dashboard_tab
parent_slug: bi_ventas_portfolio_producto_en_construccion
tab_id: 191
tab_name: 5 Min View
payload:
dashcards:
- size_x: 6
size_y: 2
col: 0
row: 0
card: ventas_totales_2 # slug resuelto (como ya lo hace R15)
parameter_mappings: [...]
visualization_settings: {}
series: []
- ...
```
## Tareas
### Fase 1: Helpers puros
- [ ] **1.1** Crear `dashboard_split.py` (modulo nuevo) con `split_dashboard_payload()` y `merge_dashboard_payload()`. Input/output puros, sin I/O.
- [ ] **1.2** Implementar `tab_file_slug(tab_name: str) -> str` reusando `_slugify()` de `sync_pull.py`. Prefijo `tab_` obligatorio para reconocer archivos.
- [ ] **1.3** Tests unitarios en `dashboard_split_test.py`: round-trip `split → merge == original`, caso con tabs, sin tabs, con dashcards globales, con parameter_mappings, con series.
### Fase 2: Pull (escribe directorio)
- [ ] **2.1** En `sync_pull.py::pull_dashboard()`, tras construir `body`, llamar `split_dashboard_payload(body)` y escribir:
- `dashboards/{slug}/_dashboard.yaml`
- `dashboards/{slug}/tab_{tab_slug}.yaml` por cada tab
- [ ] **2.2** Si existe el legacy `dashboards/{slug}.yaml` en disco, dejarlo sin tocar pero preferir siempre el directory-based al leer (warning de migracion).
- [ ] **2.3** Log de pull imprime los archivos escritos (cantidad de tabs + globales).
### Fase 3: Push (lee directorio y compone)
- [ ] **3.1** En `sync_push.py`, resolver path del item dashboard: si existe `dashboards/{slug}/_dashboard.yaml` usar directory-based, sino fallback a `dashboards/{slug}.yaml`.
- [ ] **3.2** Cargar todos los `tab_*.yaml` del directorio. Para cada uno:
- Verificar `_meta.parent_slug == slug`
- Leer `payload.dashcards`, inyectar `dashboard_tab_id` desde el mapeo en `_dashboard.yaml::payload.tabs[*].id`
- [ ] **3.3** Llamar `merge_dashboard_payload()` con `_dashboard.yaml` + todos los `tab_*.yaml` → payload unificado igual al del pull monolitico.
- [ ] **3.4** Todo lo demas (freshness R17, count R18, R6 backup, `--patch` diff) sigue funcionando sin cambios.
### Fase 4: Validate + diff
- [ ] **4.1** `sync_validate.py` y `cmd_diff` resuelven el mismo path unificado via un helper `resolve_dashboard_path(project, slug) -> tuple[str, Path]` (`"legacy"|"split"`, path).
- [ ] **4.2** Si es `split`, validar:
- `_dashboard.yaml` existe y tiene `_meta.kind == 'dashboard'`
- Cada `tab_*.yaml` referenciado en `tabs[*].file` existe en el directorio
- Cada archivo de tab tiene `_meta.parent_slug == slug` y `_meta.tab_id` coincide con `_dashboard.yaml`
- No hay archivos `tab_*.yaml` huerfanos (sin entrada en `tabs`)
### Fase 5: Backward-compat + migracion
- [ ] **5.1** Flag opcional en `pull`: `--split` (default true en configs nuevos). Variable de config `split_dashboards: true` en `config.yaml` del proyecto. Si `false`, sigue escribiendo monolitico (solo para casos legacy).
- [ ] **5.2** Script `scripts/migrate_dashboards_to_split.py`: itera `dashboards/*.yaml` de un proyecto y los convierte a directory-based (usando `split_dashboard_payload()` sobre el YAML cargado — no toca Metabase).
- [ ] **5.3** Probar migracion sobre aurgi: `dashboards/bi_ventas_portfolio_producto_en_construccion.yaml` → directorio.
### Fase 6: Tests end-to-end + docs
- [ ] **6.1** Test e2e con dashboard 734 (aurgi): pull (nueva estructura) → commit → editar una celda en `tab_5_min_view.yaml` (p.ej. renombrar description de una dashcard) → `push --patch --apply` → re-pull → diff debe mostrar SOLO la edicion deliberada.
- [ ] **6.2** Actualizar `app.md` seccion "Crear cards y dashboards desde cero" con el nuevo layout de directorio.
- [ ] **6.3** Añadir troubleshooting row a la tabla del readme: "archivo tab_xxx.yaml huerfano" / "tab en `_dashboard.yaml::tabs` sin archivo correspondiente".
## Ejemplo de uso
```bash
# Pull bajo nuevo formato
./main.py -p aurgi pull dashboard bi_ventas_portfolio_producto_en_construccion
# [aurgi] pull dashboard bi_ventas_portfolio_producto_en_construccion (id=734) ->
# projects/aurgi/dashboards/bi_ventas_portfolio_producto_en_construccion/
# _dashboard.yaml (26 parametros, 11 tabs)
# tab_5_min_view.yaml (7 dashcards)
# tab_foto_centros_compara_semana.yaml (7 dashcards)
# tab_foto_categorias_compara_semana.yaml (9 dashcards)
# ... (8 mas)
# Edicion quirurgica: abrir solo la tab
$EDITOR projects/aurgi/dashboards/bi_ventas_portfolio_producto_en_construccion/tab_5_min_view.yaml
# Diff acotado
git diff --stat
# projects/aurgi/dashboards/bi_ventas_portfolio_producto_en_construccion/tab_5_min_view.yaml | 12 ++++++------
# Push atomic, solo lo que cambio
./main.py -p aurgi push dashboard bi_ventas_portfolio_producto_en_construccion --patch --apply
# (patch) enviando 1 keys: ['dashcards']
# aplicado.
```
## Decisiones de diseno
- **Prefijo `tab_` obligatorio** en archivos de tab: permite que `ls dashboards/{slug}/tab_*.yaml` liste exactamente los tabs, separandolos de `_dashboard.yaml`. Sin prefijo seria ambiguo.
- **`_dashboard.yaml` con guion bajo**: se ordena primero alfabeticamente al hacer `ls`, lo cual ayuda a la navegacion.
- **`_meta.split_version: 1`** en `_dashboard.yaml`: abre la puerta a evoluciones del formato sin romper dashboards antiguos. Pull puede detectar versiones antiguas y migrar al vuelo.
- **Mapping `tabs[*].file`** en `_dashboard.yaml` en vez de deducirlo del nombre: protege contra renames manuales y permite nombres de tab con caracteres raros.
- **Dashcards globales en `_dashboard.yaml::payload.dashcards_globales`**: algunos dashboards (sobre todo legacy) tienen dashcards sin `dashboard_tab_id`. Separarlos del array de tabs evita perderlos.
- **NO cambiar el schema del API de Metabase**: merge reconstruye el mismo JSON que el pull monolitico actual. Byte-a-byte equivalente.
- **Legacy YAML monolitico sigue soportado**: un proyecto viejo con `dashboards/x.yaml` sigue pushable. La migracion es opcional / script-assisted.
## Prerequisitos
- Commit actual de `sync_pull.py` que preserva `id` en tabs (ya aplicado en esta sesion).
- `state/index.json` con mapeos de slug→id intactos.
## Riesgos
| Riesgo | Impacto | Mitigacion |
|---|---|---|
| Dashboard con 2 tabs del mismo nombre | Colision de archivo | Slugify + sufijo `_2`, `_3` como en `_slug_for()`; registrar el file mapping en `_dashboard.yaml::tabs[*].file`, no deducirlo |
| Usuario edita `_dashboard.yaml::tabs` y borra una entrada pero no el archivo `tab_*.yaml` | Archivo huerfano queda en disco | Validate avisa "archivo tab_xxx.yaml huerfano" en fase 4.2 |
| Orden de dashcards en YAML no determinista entre pulls | Diffs falsos en git | Ordenar dashcards por `(row, col)` antes de escribir — idempotente |
| Migracion masiva rompe dashboards de otros proyectos | Push falla en `push-all` | Migracion opcional via script, no automatica. Legacy sigue funcionando |
| Dashboard con 0 tabs (solo dashcards root) | Solo `_dashboard.yaml` sin tabs | Soportado via `dashcards_globales`; validate acepta `tabs: []` |
| Rename de tab en Metabase entre pulls | `tab_slug` cambia, archivo anterior queda huerfano | Detectar por `tab_id` en remoto: si coincide pero nombre cambio, renombrar archivo local (no borrar) |
## Verificacion final
- [ ] Pull de dashboard 734 (aurgi) genera el directorio con 11 tabs + `_dashboard.yaml`
- [ ] `git diff` de una edicion en 1 tab solo muestra cambios en ese archivo
- [ ] Push roundtrip: pull → push (sin cambios) → pull → diff vacio
- [ ] Legacy monolitico (`dashboards/x.yaml` antiguo) sigue validable y pushable
- [ ] `app.md` actualizado con el nuevo layout + ejemplo
- [ ] Tests de `dashboard_split` pasan (round-trip + casos edge)
+1
View File
@@ -29,3 +29,4 @@
| [0021](0021-crud-generator.md) | CRUD Generator | pendiente | media | feature | — |
| [0022](0022-init-pipelines.md) | Init Pipelines (scaffolding) | pendiente | alta | feature | — |
| [0023](completed/0023-testing-utils.md) | Testing Utilities Go | completado | media | feature | — |
| [0024](0024-dashboard-yaml-split-por-tab.md) | auto_metabase: split dashboard YAMLs por tab | pendiente | alta | mejora | — |
+22 -2
View File
@@ -1,15 +1,35 @@
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
from .cards import metabase_list_cards, metabase_get_card, metabase_create_card, metabase_update_card, metabase_delete_card, metabase_execute_card, metabase_execute_query, metabase_copy_card, metabase_move_card
from .dashboards import metabase_list_dashboards, metabase_get_dashboard, metabase_create_dashboard, metabase_update_dashboard, metabase_delete_dashboard, metabase_copy_dashboard, metabase_move_dashboard
from .databases import metabase_list_databases, metabase_add_database, metabase_get_database
from .documents import metabase_list_documents, metabase_get_document, metabase_create_document, metabase_update_document, metabase_archive_document, metabase_delete_document, metabase_list_document_comments, metabase_create_document_comment, metabase_resolve_document_comment, metabase_move_document, metabase_copy_document
from .collections import metabase_move_collection
from .permissions import metabase_list_groups, metabase_get_group, metabase_create_group, metabase_update_group, metabase_delete_group, metabase_list_memberships, metabase_add_membership, metabase_delete_membership, metabase_get_permission_graph, metabase_update_permission_graph, metabase_get_collection_graph, metabase_update_collection_graph
from .setup import metabase_setup
from .maintenance import metabase_fix_null_ratio, metabase_pair_n_n1_columns
from .metabase_mbql_validate import metabase_mbql_validate
from .metabase_update_dashboard_safe import metabase_update_dashboard_safe
__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_copy_card", "metabase_move_card",
"metabase_list_dashboards", "metabase_get_dashboard", "metabase_create_dashboard", "metabase_update_dashboard", "metabase_delete_dashboard",
"metabase_copy_dashboard", "metabase_move_dashboard",
"metabase_list_databases", "metabase_add_database", "metabase_get_database",
"metabase_list_documents", "metabase_get_document", "metabase_create_document", "metabase_update_document", "metabase_archive_document", "metabase_delete_document",
"metabase_list_document_comments", "metabase_create_document_comment", "metabase_resolve_document_comment",
"metabase_move_document", "metabase_copy_document",
"metabase_move_collection",
"metabase_list_groups", "metabase_get_group", "metabase_create_group", "metabase_update_group", "metabase_delete_group",
"metabase_list_memberships", "metabase_add_membership", "metabase_delete_membership",
"metabase_get_permission_graph", "metabase_update_permission_graph",
"metabase_get_collection_graph", "metabase_update_collection_graph",
"metabase_setup",
"metabase_fix_null_ratio",
"metabase_pair_n_n1_columns",
"metabase_mbql_validate",
"metabase_update_dashboard_safe",
]
+129
View File
@@ -225,3 +225,132 @@ def metabase_execute_query(
"max-results-bare-rows": max_results,
}
return client.request("POST", "/api/dataset", json=body)
def metabase_copy_card(
client: MetabaseClient,
card_id: int,
name: str | None = None,
collection_id: int | None = None,
description: str | None = None,
) -> dict:
"""Crea una copia de una card/pregunta existente en Metabase.
Endpoint: POST /api/card/:id/copy. Usa el endpoint nativo de Metabase para
duplicar la card, copiando dataset_query, display y visualization_settings.
Los campos name, collection_id y description se pueden sobrescribir via body.
Args:
client: Cliente autenticado.
card_id: ID de la card a copiar.
name: Nombre para la copia. None = Metabase asigna "Copy of <nombre>".
collection_id: Coleccion destino. None = misma coleccion que el original.
description: Descripcion de la copia. None = misma que el original.
Returns:
Dict con la card nueva creada por Metabase. Incluye el campo `id`
asignado a la copia y todos los campos heredados del original.
Example:
>>> copy = metabase_copy_card(client, 42)
>>> print(copy["id"], copy["name"]) # "Copy of ..."
>>> # Copiar a otra coleccion con nombre propio:
>>> copy = metabase_copy_card(client, 42, name="Revenue Q2", collection_id=7)
"""
body: dict = {}
if name is not None:
body["name"] = name
if collection_id is not None:
body["collection_id"] = collection_id
if description is not None:
body["description"] = description
return client.request("POST", f"/api/card/{card_id}/copy", json=body or None)
def metabase_move_card(
client: MetabaseClient,
card_id: int,
collection_id: int | None,
) -> dict:
"""Mueve una card/pregunta a otra coleccion.
Wrapper thin sobre PUT /api/card/:id que solo actualiza collection_id.
Equivalente a metabase_update_card(client, card_id, collection_id=...) pero
con intencion explicita y soporte para mover a root con None.
Endpoint: PUT /api/card/:id.
Args:
client: Cliente autenticado.
card_id: ID de la card a mover.
collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root).
Returns:
Dict con la card actualizada, incluyendo el nuevo collection_id.
Example:
>>> card = metabase_move_card(client, 42, collection_id=7)
>>> print(card["collection_id"]) # 7
>>> # Mover a root:
>>> card = metabase_move_card(client, 42, collection_id=None)
"""
return client.request("PUT", f"/api/card/{card_id}", json={"collection_id": collection_id})
def metabase_create_card_raw(client: MetabaseClient, payload: dict) -> dict:
"""Crea una card en Metabase con payload completo ya construido por el caller.
Version raw de metabase_create_card. El caller es responsable de construir
el payload completo antes de llamar a esta funcion — no se realiza ninguna
validacion ni transformacion local. Util para flujos "Metabase as code"
donde el YAML define todos los campos de la card tal como los espera la API.
Endpoint: POST /api/card.
El payload minimo necesita:
- name (str): nombre de la card.
- dataset_query (dict): query SQL nativa o MBQL.
- display (str): tipo de visualizacion (table, bar, scalar, etc.).
Campos opcionales que esta funcion preserva (a diferencia de metabase_create_card):
- visualization_settings (dict): configuracion detallada del grafico.
- parameters (list[dict]): parametros de la query con template tags.
- parameter_mappings (list[dict]): mapeo de parametros a dashboard filters.
- type (str): "question" (default), "model", "metric".
- collection_id (int): ID de coleccion destino.
- description (str): descripcion de la card.
- archived (bool): estado de archivo inicial.
- enable_embedding (bool): habilitar embedding publico.
- embedding_params (dict): configuracion de embedding.
Si Metabase devuelve 4xx/5xx, httpx lanza HTTPStatusError sin capturar.
Args:
client: Cliente autenticado con sesion activa.
payload: Dict con el payload completo de la card tal como lo espera
la API de Metabase. Se envia sin modificaciones.
Returns:
Dict con la card recien creada. Incluye el campo `id` asignado por
Metabase y todos los campos normalizados (display, dataset_query,
visualization_settings, created_at, etc.).
Example:
>>> card = metabase_create_card_raw(client, {
... "name": "Revenue by Month",
... "dataset_query": {
... "database": 1,
... "type": "native",
... "native": {"query": "SELECT date_trunc('month', created_at), SUM(total) FROM orders GROUP BY 1"},
... },
... "display": "line",
... "visualization_settings": {
... "graph.x_axis.title_text": "Month",
... "graph.y_axis.title_text": "Revenue",
... },
... "description": "Monthly revenue trend",
... "collection_id": 5,
... })
>>> print(card["id"]) # ID asignado por Metabase
"""
return client.request("POST", "/api/card", json=payload)
+6 -3
View File
@@ -12,16 +12,19 @@ class MetabaseClient:
_http: Cliente httpx reutilizable con headers de auth.
"""
def __init__(self, base_url: str, token: str) -> None:
def __init__(self, base_url: str, token: str, timeout: float = 120.0) -> None:
self.base_url = base_url.rstrip("/")
self.token = token
# API keys de Metabase empiezan por "mb_" y usan X-API-KEY.
# Session tokens usan X-Metabase-Session.
auth_header = "X-API-KEY" if token.startswith("mb_") else "X-Metabase-Session"
self._http = httpx.Client(
base_url=self.base_url,
headers={
"Content-Type": "application/json",
"X-Metabase-Session": token,
auth_header: token,
},
timeout=30.0,
timeout=timeout,
)
def request(self, method: str, path: str, **kwargs) -> dict | list | None:
+39
View File
@@ -0,0 +1,39 @@
"""CRUD y operaciones sobre collections de Metabase."""
from .client import MetabaseClient
def metabase_move_collection(
client: MetabaseClient,
collection_id: int,
parent_id: int | None,
) -> dict:
"""Mueve una collection (sub-arbol completo) a otro padre.
Endpoint: PUT /api/collection/:id con {parent_id: ...}.
parent_id=None mueve la collection a la raiz ("Our analytics").
Metabase reubica la collection y todo su sub-arbol (colecciones hijas,
cards, dashboards, documents) atomicamente.
Args:
client: Cliente autenticado.
collection_id: ID de la collection a mover.
parent_id: ID de la collection padre destino. None = raiz.
Returns:
Collection actualizada con el nuevo parent_id y location.
Example:
>>> col = metabase_move_collection(client, 12, parent_id=3)
>>> print(col["location"]) # "/3/"
>>> # Mover a raiz:
>>> col = metabase_move_collection(client, 12, parent_id=None)
>>> print(col["location"]) # "/"
"""
return client.request(
"PUT",
f"/api/collection/{collection_id}",
json={"parent_id": parent_id},
)
+159
View File
@@ -141,3 +141,162 @@ def metabase_delete_dashboard(client: MetabaseClient, dashboard_id: int) -> None
>>> # Preferir: metabase_update_dashboard(client, 1, archived=True)
"""
client.request("DELETE", f"/api/dashboard/{dashboard_id}")
def metabase_copy_dashboard(
client: MetabaseClient,
dashboard_id: int,
name: str | None = None,
collection_id: int | None = None,
description: str | None = None,
is_deep_copy: bool = False,
) -> dict:
"""Crea una copia de un dashboard existente en Metabase.
Endpoint: POST /api/dashboard/:id/copy. Usa el endpoint nativo de Metabase para
duplicar el dashboard junto con su layout de dashcards y parametros.
Con is_deep_copy=True tambien clona las cards referenciadas.
Args:
client: Cliente autenticado.
dashboard_id: ID del dashboard a copiar.
name: Nombre para la copia. None = Metabase asigna "Copy of <nombre>".
collection_id: Coleccion destino. None = misma coleccion que el original.
description: Descripcion de la copia. None = misma que el original.
is_deep_copy: Si True, clona tambien todas las cards referenciadas por el
dashboard (deep copy). Si False, la copia referencia las cards originales.
Returns:
Dict con el dashboard nuevo creado por Metabase. Incluye el campo `id`
asignado a la copia y el layout de dashcards copiado.
Example:
>>> copy = metabase_copy_dashboard(client, 1)
>>> print(copy["id"], copy["name"]) # "Copy of ..."
>>> # Deep copy a otra coleccion:
>>> copy = metabase_copy_dashboard(client, 1, name="Sales Q2", collection_id=7, is_deep_copy=True)
"""
body: dict = {"is_deep_copy": is_deep_copy}
if name is not None:
body["name"] = name
if collection_id is not None:
body["collection_id"] = collection_id
if description is not None:
body["description"] = description
return client.request("POST", f"/api/dashboard/{dashboard_id}/copy", json=body)
def metabase_move_dashboard(
client: MetabaseClient,
dashboard_id: int,
collection_id: int | None,
) -> dict:
"""Mueve un dashboard a otra coleccion.
Wrapper thin sobre PUT /api/dashboard/:id que solo actualiza collection_id.
Equivalente a metabase_update_dashboard(client, id, collection_id=...) pero
con intencion explicita y soporte para mover a root con None.
Endpoint: PUT /api/dashboard/:id.
Args:
client: Cliente autenticado.
dashboard_id: ID del dashboard a mover.
collection_id: ID de la coleccion destino. None mueve a "Our analytics" (root).
Returns:
Dict con el dashboard actualizado, incluyendo el nuevo collection_id.
Example:
>>> dash = metabase_move_dashboard(client, 1, collection_id=7)
>>> print(dash["collection_id"]) # 7
>>> # Mover a root:
>>> dash = metabase_move_dashboard(client, 1, collection_id=None)
"""
return client.request("PUT", f"/api/dashboard/{dashboard_id}", json={"collection_id": collection_id})
def metabase_create_dashboard_raw(client: MetabaseClient, payload: dict) -> dict:
"""Crea un dashboard en Metabase con payload completo ya construido por el caller.
Version raw de metabase_create_dashboard. El caller es responsable de
construir el payload completo — no se realiza validacion ni transformacion
local. Util para flujos "Metabase as code" donde el YAML define todos los
campos del dashboard tal como los espera la API.
COMPORTAMIENTO EN DOS PASOS (limitacion de la API de Metabase):
El endpoint POST /api/dashboard NO acepta `dashcards` en el body inicial;
solo crea el dashboard vacio. Para añadir cards es necesario un PUT posterior.
Esta funcion maneja esa limitacion automaticamente:
1. Si el payload contiene `dashcards`, se extraen antes del POST.
2. POST /api/dashboard crea el dashboard vacio (sin dashcards).
3. Si habia dashcards, PUT /api/dashboard/:id con {"dashcards": [...]}
las añade al dashboard recien creado.
4. Retorna la respuesta del PUT (con dashcards pobladas), o la del POST
si el payload original no contenia dashcards.
Endpoint inicial: POST /api/dashboard.
Endpoint secundario (condicional): PUT /api/dashboard/:id.
El payload puede incluir:
- name (str, requerido): nombre del dashboard.
- description (str): descripcion.
- collection_id (int): ID de coleccion destino.
- parameters (list[dict]): filtros/parametros del dashboard.
- dashcards (list[dict]): cards posicionadas. Cada dashcard necesita:
id (int negativo para nuevas, ej: -1, -2),
card_id (int), size_x (int), size_y (int), col (int), row (int).
Campos opcionales: visualization_settings, parameter_mappings.
- tabs (list[dict]): pestañas del dashboard.
- enable_embedding (bool): habilitar embedding publico.
- embedding_params (dict): configuracion de embedding.
Si Metabase devuelve 4xx/5xx en cualquier paso, httpx lanza HTTPStatusError.
Args:
client: Cliente autenticado con sesion activa.
payload: Dict con el payload completo del dashboard tal como lo espera
la API de Metabase. Puede incluir dashcards.
Returns:
Dict con el dashboard creado. Si habia dashcards, la respuesta es la
del PUT final e incluye el campo `dashcards` con las cards posicionadas.
Si no habia dashcards, es la respuesta del POST inicial.
Example:
>>> dash = metabase_create_dashboard_raw(client, {
... "name": "Sales Overview",
... "description": "KPIs de ventas mensuales",
... "collection_id": 5,
... "parameters": [],
... "dashcards": [
... {
... "id": -1,
... "card_id": 42,
... "size_x": 6,
... "size_y": 4,
... "col": 0,
... "row": 0,
... "visualization_settings": {},
... "parameter_mappings": [],
... },
... ],
... })
>>> print(dash["id"]) # ID asignado por Metabase
>>> print(len(dash["dashcards"])) # 1
"""
body = {k: v for k, v in payload.items() if k != "dashcards"}
dashcards = payload.get("dashcards")
created = client.request("POST", "/api/dashboard", json=body)
if dashcards:
dashboard_id = created["id"]
return client.request(
"PUT",
f"/api/dashboard/{dashboard_id}",
json={"dashcards": dashcards},
)
return created
+319
View File
@@ -0,0 +1,319 @@
"""CRUD de documents de Metabase (feature reciente, Metabase 0.57+).
Un document es un texto editable en Metabase (tipo Notion) serializado como
ProseMirror JSON (content_type: application/json+vnd.prose-mirror). Permite
embeber cards, smart links y flex containers.
Nodos ProseMirror soportados (observados en Metabase v0.59):
- doc, paragraph, heading (attrs.level 1-6), text
- bulletList, orderedList, listItem
- blockquote, codeBlock (attrs.language), horizontalRule, hardBreak
- cardEmbed (attrs.id — card_id), smartLink (attrs.entityId),
flexContainer (attrs.columnWidths), resizeNode, mention
- image, iframe, table/tableRow/tableCell, callout, taskList/taskItem, details
Marks:
- bold, italic, strike, code, link (attrs.href), underline,
highlight, subscript, textStyle
"""
from .client import MetabaseClient
def metabase_list_documents(client: MetabaseClient) -> list[dict]:
"""Lista documents de Metabase.
Endpoint: GET /api/document. Retorna `{"items": [...]}` segun la API.
Args:
client: Cliente autenticado.
Returns:
Lista de dicts con: id, name, collection_id, archived, created_at,
updated_at, creator_id, content_type, entity_id.
Example:
>>> docs = metabase_list_documents(client)
>>> for d in docs:
... print(d["id"], d["name"])
"""
resp = client.request("GET", "/api/document")
if isinstance(resp, dict) and "items" in resp:
return resp["items"]
return resp or []
def metabase_get_document(client: MetabaseClient, document_id: int) -> dict:
"""Obtiene un document completo con su contenido ProseMirror.
Endpoint: GET /api/document/:id.
Args:
client: Cliente autenticado.
document_id: ID del document.
Returns:
Dict con: id, name, document (ProseMirror tree), collection_id,
archived, creator, created_at, updated_at, entity_id, content_type.
Example:
>>> doc = metabase_get_document(client, 1)
>>> print(doc["name"])
>>> tree = doc["document"] # {"type": "doc", "content": [...]}
"""
return client.request("GET", f"/api/document/{document_id}")
def metabase_create_document(
client: MetabaseClient,
name: str,
document: dict,
collection_id: int = 0,
) -> dict:
"""Crea un document nuevo.
Endpoint: POST /api/document.
Args:
client: Cliente autenticado.
name: Titulo del document (1-254 chars, no blank).
document: Arbol ProseMirror JSON. Formato minimo:
{"type": "doc", "content": [{"type": "paragraph", "content": [...]}]}
O cadena vacia "" si se quiere arrancar en blanco.
collection_id: ID de coleccion destino. 0 = root.
Returns:
Document creado con su id asignado.
Example:
>>> doc = metabase_create_document(client, "Notas", {
... "type": "doc",
... "content": [{
... "type": "paragraph",
... "content": [{"type": "text", "text": "Hola mundo"}]
... }]
... })
"""
body: dict = {"name": name, "document": document}
if collection_id > 0:
body["collection_id"] = collection_id
return client.request("POST", "/api/document", json=body)
def metabase_update_document(
client: MetabaseClient,
document_id: int,
**fields,
) -> dict:
"""Actualiza un document. Solo se envian los campos pasados.
Endpoint: PUT /api/document/:id.
Campos tipicos: name, document, collection_id, archived.
Args:
client: Cliente autenticado.
document_id: ID del document a actualizar.
**fields: Campos a modificar.
Returns:
Document actualizado.
Example:
>>> metabase_update_document(client, 1, name="Nuevo titulo")
>>> metabase_update_document(client, 1, document={"type":"doc","content":[...]})
"""
return client.request("PUT", f"/api/document/{document_id}", json=fields)
def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict:
"""Archiva un document (equivalente a PUT con archived=True).
Metabase exige archive previo para poder eliminar.
Args:
client: Cliente autenticado.
document_id: ID del document.
Returns:
Document con archived=True.
"""
return client.request("PUT", f"/api/document/{document_id}", json={"archived": True})
def metabase_list_document_comments(
client: MetabaseClient,
document_id: int,
*,
include_resolved: bool = True,
include_deleted: bool = False,
) -> list[dict]:
"""Lista los comentarios de un document.
Endpoint: GET /api/comment?target_type=document&target_id=:id.
Un comentario puede estar anclado a un bloque especifico via
`child_target_id` (UUID que matchea `attrs._id` de un parrafo) o al
document entero si `child_target_id` es null. Las respuestas tipo thread
cuelgan via `parent_comment_id`.
Args:
client: Cliente autenticado.
document_id: ID del document.
include_resolved: si False, filtra comentarios con is_resolved=True.
include_deleted: si False, filtra comentarios con deleted_at no null.
Returns:
Lista de dicts con: id, content (arbol ProseMirror), creator, creator_id,
target_type, target_id, child_target_id, parent_comment_id, is_resolved,
deleted_at, reactions, created_at, updated_at.
Example:
>>> comments = metabase_list_document_comments(client, 29)
>>> for c in comments:
... text = _comment_plaintext(c["content"])
... print(f'{c["creator"]["common_name"]}: {text}')
"""
resp = client.request(
"GET",
"/api/comment",
params={"target_type": "document", "target_id": document_id},
)
items = resp.get("comments", []) if isinstance(resp, dict) else []
if not include_resolved:
items = [c for c in items if not c.get("is_resolved")]
if not include_deleted:
items = [c for c in items if c.get("deleted_at") is None]
return items
def metabase_create_document_comment(
client: MetabaseClient,
document_id: int,
content: dict,
*,
child_target_id: str | None = None,
parent_comment_id: int | None = None,
) -> dict:
"""Crea un comentario en un document.
Endpoint: POST /api/comment.
Args:
client: Cliente autenticado.
document_id: ID del document donde comentar.
content: Arbol ProseMirror del comentario. Ejemplo minimo:
{"type":"doc","content":[{"type":"paragraph","content":[
{"type":"text","text":"mi comentario"}]}]}
child_target_id: UUID del bloque del document al que se ancla el
comentario (matchea `attrs._id` de un parrafo). None = a nivel doc.
parent_comment_id: id del comentario al que se responde. None = top-level.
Returns:
Comentario creado.
"""
body: dict = {
"target_type": "document",
"target_id": document_id,
"content": content,
}
if child_target_id is not None:
body["child_target_id"] = child_target_id
if parent_comment_id is not None:
body["parent_comment_id"] = parent_comment_id
return client.request("POST", "/api/comment", json=body)
def metabase_resolve_document_comment(client: MetabaseClient, comment_id: int) -> dict:
"""Marca un comentario como resuelto (is_resolved=True).
Endpoint: PUT /api/comment/:id.
"""
return client.request("PUT", f"/api/comment/{comment_id}", json={"is_resolved": True})
def metabase_delete_document(client: MetabaseClient, document_id: int) -> None:
"""Elimina un document. REQUIERE archivarlo primero.
Endpoint: DELETE /api/document/:id. Si el document no esta archivado,
Metabase responde: "Document must be archived before it can be deleted."
Args:
client: Cliente autenticado.
document_id: ID del document a eliminar.
"""
client.request("DELETE", f"/api/document/{document_id}")
def metabase_move_document(
client: MetabaseClient,
document_id: int,
collection_id: int | None,
) -> dict:
"""Mueve un document a otra coleccion.
Thin wrapper sobre metabase_update_document: envia solo collection_id.
Endpoint: PUT /api/document/:id con {collection_id: ...}.
Args:
client: Cliente autenticado.
document_id: ID del document a mover.
collection_id: ID de coleccion destino. None mueve a la raiz ("Our analytics").
Returns:
Document actualizado con el nuevo collection_id.
Example:
>>> doc = metabase_move_document(client, 42, collection_id=7)
>>> print(doc["collection_id"]) # 7
>>> # Mover a raiz:
>>> doc = metabase_move_document(client, 42, collection_id=None)
"""
return client.request(
"PUT",
f"/api/document/{document_id}",
json={"collection_id": collection_id},
)
def metabase_copy_document(
client: MetabaseClient,
document_id: int,
name: str | None = None,
collection_id: int | None = None,
) -> dict:
"""Copia un document (Metabase no tiene endpoint nativo).
Obtiene el document original con metabase_get_document, luego crea uno
nuevo con metabase_create_document clonando el contenido ProseMirror.
Si name=None usa "{original_name} (copia)".
Si collection_id=None copia a la misma coleccion del original.
Realiza 2 requests HTTP: GET /api/document/:id + POST /api/document.
Args:
client: Cliente autenticado.
document_id: ID del document a copiar.
name: Nombre del nuevo document. None = "{original} (copia)".
collection_id: Coleccion destino. None = misma coleccion del original.
Returns:
Document nuevo recien creado con su id asignado.
Example:
>>> copy = metabase_copy_document(client, 42)
>>> print(copy["name"]) # "Mi documento (copia)"
>>> print(copy["id"]) # nuevo ID
>>> # Clonar a otra coleccion con nombre personalizado:
>>> copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5)
"""
original = metabase_get_document(client, document_id)
new_name = name if name is not None else f"{original['name']} (copia)"
dest_collection = collection_id if collection_id is not None else original.get("collection_id", 0)
doc_content = original.get("document", "")
body: dict = {"name": new_name, "document": doc_content}
if dest_collection:
body["collection_id"] = dest_collection
return client.request("POST", "/api/document", json=body)
+419
View File
@@ -0,0 +1,419 @@
"""Mantenimiento y reparacion de cards MBQL de Metabase."""
import copy
import time
import uuid
from .client import MetabaseClient
# ---------------------------------------------------------------------------
# Helpers internos compartidos
# ---------------------------------------------------------------------------
def _new_uuid() -> str:
return str(uuid.uuid4())
def _field_name_of(node) -> tuple[str | None, str | None]:
"""Extrae (name, kind) de un node ['field'|'expression', meta, 'name']."""
if isinstance(node, list) and len(node) >= 3 and node[0] in ("field", "expression"):
nm = node[-1]
if isinstance(nm, str):
return nm, node[0]
return None, None
# ---------------------------------------------------------------------------
# metabase_fix_null_ratio
# ---------------------------------------------------------------------------
def _analyze_stage_null_ratio(stage: dict) -> tuple[dict, dict]:
"""Detecta slots vulnerables al patron SUM(a-b)/SUM(b) en una stage MBQL.
Devuelve:
vulnerable: {slot_diff: (slot_a, slot_b, expr_name)}
subtractions: {expr_name: (op_a_name, op_b_name)}
"""
subtractions = {}
for e in stage.get("expressions", []) or []:
if not (isinstance(e, list) and len(e) == 4 and e[0] == "-"):
continue
meta = e[1] if isinstance(e[1], dict) else {}
name = meta.get("lib/expression-name")
if not name:
continue
a_name, a_kind = _field_name_of(e[2])
b_name, b_kind = _field_name_of(e[3])
if a_name and b_name and a_kind == "field" and b_kind == "field":
subtractions[name] = (a_name, b_name)
aggs = stage.get("aggregation", []) or []
func_counts: dict[str, int] = {}
sum_field_to_slot: dict[str, str] = {}
sum_expr_to_slot: dict[str, str] = {}
for agg in aggs:
if not isinstance(agg, list) or not agg:
continue
func = agg[0]
func_counts[func] = func_counts.get(func, 0) + 1
slot = func if func_counts[func] == 1 else f"{func}_{func_counts[func]}"
if func == "sum" and len(agg) >= 3:
operand = agg[2]
nm, kind = _field_name_of(operand)
if kind == "field" and nm:
sum_field_to_slot[nm] = slot
elif kind == "expression" and nm:
sum_expr_to_slot[nm] = slot
vulnerable = {}
for expr_name, slot_diff in sum_expr_to_slot.items():
if expr_name not in subtractions:
continue
op_a, op_b = subtractions[expr_name]
slot_a = sum_field_to_slot.get(op_a)
slot_b = sum_field_to_slot.get(op_b)
if slot_a and slot_b:
vulnerable[slot_diff] = (slot_a, slot_b, expr_name)
return vulnerable, subtractions
def _rewrite_field_refs(node, slot_map: dict):
"""Reemplaza recursivamente [field, meta, slot] donde slot in slot_map
por [-, new_meta, [field, ..., slot_a], [field, ..., slot_b]]."""
if isinstance(node, list):
if (
len(node) >= 3
and node[0] == "field"
and isinstance(node[-1], str)
and node[-1] in slot_map
):
slot_a, slot_b, _ = slot_map[node[-1]]
base_type = (
node[1].get("base-type", "type/Decimal")
if isinstance(node[1], dict)
else "type/Decimal"
)
return [
"-",
{"lib/uuid": _new_uuid()},
["field", {"base-type": base_type, "lib/uuid": _new_uuid()}, slot_a],
["field", {"base-type": base_type, "lib/uuid": _new_uuid()}, slot_b],
]
return [_rewrite_field_refs(x, slot_map) for x in node]
return node
def _fix_card_query(dq: dict) -> list[str]:
"""Aplica el fix in-place al dataset_query. Devuelve lista de cambios."""
stages = dq.get("stages", [])
changes = []
for si, stage in enumerate(stages):
vulnerable, subtractions = _analyze_stage_null_ratio(stage)
if not vulnerable:
continue
preagg_names = set(subtractions.keys())
new_exprs = []
for e in stage.get("expressions", []) or []:
if (
isinstance(e, list)
and len(e) == 4
and e[0] == "-"
and isinstance(e[1], dict)
and e[1].get("lib/expression-name") in preagg_names
):
new_exprs.append(e)
else:
new_exprs.append(_rewrite_field_refs(e, vulnerable))
stage["expressions"] = new_exprs
for sj in range(si + 1, len(stages)):
for key in ("expressions", "aggregation", "breakout", "filters", "order-by", "fields"):
if key in stages[sj]:
stages[sj][key] = [
_rewrite_field_refs(x, vulnerable) for x in stages[sj][key]
]
for slot, (sa, sb, ename) in vulnerable.items():
changes.append(f"stage[{si}] {slot}=sum({ename!r}) -> ({sa} - {sb})")
return changes
def metabase_fix_null_ratio(
client: MetabaseClient,
*,
dry_run: bool = True,
card_ids: list[int] | None = None,
) -> dict:
"""Detecta y repara el patron SUM(a-b)/SUM(b) en cards MBQL de Metabase.
El patron vulnerable ocurre cuando una agregacion computa SUM(expr_resta)
donde expr_resta es una resta pre-agg de dos campos. Si alguna fila tiene
NULL, SUM(A-B) != SUM(A) - SUM(B). El fix reescribe las referencias
post-agg al slot diferencia para usar (SUM(A) - SUM(B)) en su lugar.
Solo procesa cards MBQL activas (type='query', no archivadas). Las cards
SQL nativas o modelos se omiten silenciosamente.
Args:
client: Cliente Metabase autenticado.
dry_run: Si True (default), escanea y reporta sin modificar nada.
Si False, aplica el fix via PUT /api/card/:id.
card_ids: Lista de IDs a procesar. None = todas las cards MBQL activas.
Returns:
dict con campos:
scanned (int): cards MBQL evaluadas.
affected (int): cards donde se detecto el patron vulnerable.
fixed (int): cards efectivamente actualizadas (0 si dry_run=True).
errors (list[dict]): lista de {card_id, error} para fallos en PUT.
Example:
>>> from metabase import MetabaseClient, metabase_fix_null_ratio
>>> c = MetabaseClient("https://metabase.example.com", "mb_apikey")
>>> report = metabase_fix_null_ratio(c, dry_run=True)
>>> print(report)
{'scanned': 312, 'affected': 4, 'fixed': 0, 'errors': []}
>>> # Para aplicar:
>>> report = metabase_fix_null_ratio(c, dry_run=False)
"""
all_cards = client.request("GET", "/api/card")
if card_ids is not None:
card_id_set = set(card_ids)
all_cards = [c for c in all_cards if c.get("id") in card_id_set]
mbql_cards = [
c for c in all_cards
if not c.get("archived", False)
and isinstance(c.get("dataset_query"), dict)
and c["dataset_query"].get("type") == "query"
and isinstance(c["dataset_query"].get("query", {}).get("stages") if "query" in c["dataset_query"] else c["dataset_query"].get("stages"), list)
]
# Metabase MBQL puede tener stages en dataset_query.query.stages (legacy)
# o en dataset_query.stages (v2). Normalizar:
def _get_stages(dq: dict) -> list | None:
if isinstance(dq.get("stages"), list):
return dq["stages"]
q = dq.get("query", {})
if isinstance(q.get("stages"), list):
return q["stages"]
return None
affected_cards = []
scanned = 0
for card in all_cards:
if card_ids is not None and card.get("id") not in set(card_ids):
continue
if card.get("archived", False):
continue
dq = card.get("dataset_query")
if not isinstance(dq, dict):
continue
stages = _get_stages(dq)
if stages is None:
continue
scanned += 1
dq_copy = copy.deepcopy(dq)
# Operar sobre el stages del objeto correcto
target = dq_copy if isinstance(dq_copy.get("stages"), list) else dq_copy.get("query", {})
changes = _fix_card_query(target)
if changes:
affected_cards.append((card, dq_copy, changes))
fixed = 0
errors: list[dict] = []
if not dry_run:
for card, dq_fixed, _changes in affected_cards:
try:
client.request("PUT", f"/api/card/{card['id']}", json={"dataset_query": dq_fixed})
fixed += 1
except Exception as exc:
errors.append({"card_id": card["id"], "error": str(exc)[:200]})
time.sleep(0.05)
return {
"scanned": scanned,
"affected": len(affected_cards),
"fixed": fixed,
"errors": errors,
}
# ---------------------------------------------------------------------------
# metabase_pair_n_n1_columns
# ---------------------------------------------------------------------------
def _slot_for_sum_field(stage: dict, target_field_name: str) -> str | None:
"""Devuelve el slot MBQL (ej: 'sum', 'sum_4') de sum(target_field) en la stage."""
aggs = stage.get("aggregation", []) or []
func_counts: dict[str, int] = {}
for agg in aggs:
if not isinstance(agg, list) or not agg:
continue
func = agg[0]
func_counts[func] = func_counts.get(func, 0) + 1
slot = func if func_counts[func] == 1 else f"{func}_{func_counts[func]}"
if func == "sum" and len(agg) >= 3:
nm, kind = _field_name_of(agg[2])
if kind == "field" and nm == target_field_name:
return slot
return None
def _find_paired_slots(dq: dict, base_name: str) -> tuple[str | None, str | None]:
"""Busca (slot_base, slot_n1) para sum(base_name) y sum(base_name_1) en el MBQL."""
for stage in (dq.get("stages") or []):
if not stage.get("aggregation"):
continue
slot_n = _slot_for_sum_field(stage, base_name)
slot_n1 = _slot_for_sum_field(stage, f"{base_name}_1")
if slot_n and slot_n1:
return slot_n, slot_n1
return None, None
def _reorder_table_columns(
cols: list[dict],
slot_n: str,
slot_n1: str,
) -> tuple[list[dict], bool, str]:
"""Habilita slot_n1 y lo reubica inmediatamente despues de slot_n.
Returns:
(new_cols, changed, reason)
"""
cols = [dict(c) for c in cols]
idx_n = next((i for i, c in enumerate(cols) if c.get("name") == slot_n), -1)
if idx_n < 0:
return cols, False, "slot_n no presente en table.columns"
idx_n1 = next((i for i, c in enumerate(cols) if c.get("name") == slot_n1), -1)
# Ya en posicion correcta y habilitada: no hay cambio
if idx_n1 == idx_n + 1 and cols[idx_n1].get("enabled") is True:
return cols, False, "ya en la posicion correcta y habilitado"
if idx_n1 < 0:
entry: dict = {"name": slot_n1, "enabled": True}
else:
entry = cols.pop(idx_n1)
entry["enabled"] = True
if idx_n1 < idx_n:
idx_n -= 1
insert_at = idx_n + 1
cols.insert(insert_at, entry)
return cols, True, "reubicado y habilitado"
def metabase_pair_n_n1_columns(
client: MetabaseClient,
*,
dry_run: bool = True,
card_ids: list[int] | None = None,
base_field: str = "Valor_vendido",
) -> dict:
"""Habilita y posiciona la columna _1 junto a su par en cards tabla/pivot de Metabase.
Para cards con display 'table' o 'pivot' que contienen agregaciones
SUM(base_field) y SUM(base_field_1), busca la columna base_field_1 en
visualization_settings.table.columns, la habilita (enabled=True) y la
reubica inmediatamente despues de base_field para comparacion visual.
Solo procesa cards con display 'table' o 'pivot' que tengan ambos slots
y tengan table.columns definido en visualization_settings.
Args:
client: Cliente Metabase autenticado.
dry_run: Si True (default), escanea y reporta sin modificar nada.
Si False, aplica el cambio via PUT /api/card/:id.
card_ids: Lista de IDs a procesar. None = todas las cards activas.
base_field: Nombre del campo base MBQL (sin sufijo _1). Por defecto
'Valor_vendido'. La funcion buscara sum(base_field) y
sum(base_field_1) en las agregaciones.
Returns:
dict con campos:
scanned (int): cards con display table/pivot evaluadas.
affected (int): cards donde se encontro el par y habia que mover.
fixed (int): cards efectivamente actualizadas (0 si dry_run=True).
skipped (int): cards ya correctas o sin table.columns.
errors (list[dict]): lista de {card_id, error} para fallos en PUT.
Example:
>>> from metabase import MetabaseClient, metabase_pair_n_n1_columns
>>> c = MetabaseClient("https://metabase.example.com", "mb_apikey")
>>> report = metabase_pair_n_n1_columns(c, dry_run=True)
>>> print(report)
{'scanned': 45, 'affected': 3, 'fixed': 0, 'skipped': 42, 'errors': []}
>>> # Con campo personalizado:
>>> report = metabase_pair_n_n1_columns(c, dry_run=False, base_field="Importe")
"""
all_cards = client.request("GET", "/api/card")
tabular_displays = {"table", "pivot"}
scanned = 0
skipped = 0
to_update: list[tuple[dict, list[dict], str, str]] = []
for card in all_cards:
if card_ids is not None and card.get("id") not in set(card_ids):
continue
if card.get("archived", False):
continue
if card.get("display") not in tabular_displays:
continue
dq = card.get("dataset_query")
if not isinstance(dq, dict):
continue
slot_n, slot_n1 = _find_paired_slots(dq, base_field)
if not (slot_n and slot_n1):
continue
scanned += 1
vs = card.get("visualization_settings") or {}
cols = vs.get("table.columns")
if not isinstance(cols, list):
skipped += 1
continue
new_cols, changed, _reason = _reorder_table_columns(cols, slot_n, slot_n1)
if not changed:
skipped += 1
continue
to_update.append((card, new_cols, slot_n, slot_n1))
fixed = 0
errors: list[dict] = []
if not dry_run:
for card, new_cols, _slot_n, _slot_n1 in to_update:
new_vs = copy.deepcopy(card.get("visualization_settings") or {})
new_vs["table.columns"] = new_cols
try:
client.request(
"PUT",
f"/api/card/{card['id']}",
json={"visualization_settings": new_vs},
)
fixed += 1
except Exception as exc:
errors.append({"card_id": card["id"], "error": str(exc)[:200]})
time.sleep(0.05)
return {
"scanned": scanned,
"affected": len(to_update),
"fixed": fixed,
"skipped": skipped,
"errors": errors,
}
@@ -0,0 +1,46 @@
---
name: metabase_add_membership
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_add_membership(client: MetabaseClient, user_id: int, group_id: int, is_group_manager: bool = False) -> list[dict]"
description: "Añade un usuario a un Permission Group de Metabase. Endpoint: POST /api/permissions/membership. Retorna la lista completa de membresías del grupo tras la operación."
tags: [metabase, permissions, membership, groups, users, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: user_id
desc: "ID numérico del usuario a añadir al grupo"
- name: group_id
desc: "ID numérico del grupo destino"
- name: is_group_manager
desc: "si True, el usuario obtiene rol de manager del grupo (puede gestionar sus miembros)"
output: "list[dict]: lista de todas las membresías actuales del grupo tras la operación. Cada elemento contiene membership_id, user_id, group_id, is_group_manager"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
# Añadir usuario como miembro regular
members = metabase_add_membership(client, user_id=5, group_id=3)
print(len(members), "miembros en el grupo")
# Añadir como manager del grupo
members = metabase_add_membership(client, user_id=5, group_id=3, is_group_manager=True)
```
## Notas
Metabase responde con la lista completa de membresías del grupo (no solo la nueva). Lanza HTTPStatusError 400 si el usuario ya es miembro del grupo. El `membership_id` de la entrada creada está en el elemento nuevo de la lista retornada.
@@ -0,0 +1,34 @@
---
name: metabase_archive_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_archive_document(client: MetabaseClient, document_id: int) -> dict"
description: "Archiva un document (PUT archived=True). Prerequisito para poder eliminarlo."
tags: [metabase, document, archive, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document a archivar"
output: "dict: document con archived=True"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
metabase_archive_document(client, 1)
metabase_delete_document(client, 1) # ahora si puede eliminarse
```
@@ -0,0 +1,51 @@
---
name: metabase_copy_card
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_copy_card(client: MetabaseClient, card_id: int, name: str | None = None, collection_id: int | None = None, description: str | None = None) -> dict"
description: "Crea una copia de una card/pregunta en Metabase via el endpoint nativo POST /api/card/:id/copy. Permite sobrescribir nombre, coleccion y descripcion en la copia."
tags: [metabase, card, question, copy, duplicate, collection, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: card_id
desc: "ID de la card a copiar"
- name: name
desc: "nombre para la copia; None = Metabase asigna 'Copy of <nombre>'"
- name: collection_id
desc: "ID de la coleccion destino; None = misma coleccion que el original"
- name: description
desc: "descripcion de la copia; None = hereda la del original"
output: "dict: objeto card nueva creada por Metabase, con el id asignado y todos los campos heredados del original"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/cards.py"
---
## Ejemplo
```python
# Copia simple (nombre automatico)
copy = metabase_copy_card(client, 42)
print(copy["id"], copy["name"]) # "Copy of ..."
# Copia a otra coleccion con nombre propio
copy = metabase_copy_card(client, 42, name="Revenue Q2", collection_id=7)
print(copy["collection_id"]) # 7
```
## Notas
Usa el endpoint nativo de Metabase que copia dataset_query, display y
visualization_settings. Los campos opcionales del body se omiten si son None
para que Metabase aplique sus defaults (herencia del original).
@@ -0,0 +1,54 @@
---
name: metabase_copy_dashboard
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_copy_dashboard(client: MetabaseClient, dashboard_id: int, name: str | None = None, collection_id: int | None = None, description: str | None = None, is_deep_copy: bool = False) -> dict"
description: "Crea una copia de un dashboard en Metabase via POST /api/dashboard/:id/copy. Con is_deep_copy=True tambien clona las cards referenciadas."
tags: [metabase, dashboard, copy, duplicate, collection, deep_copy, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: dashboard_id
desc: "ID del dashboard a copiar"
- name: name
desc: "nombre para la copia; None = Metabase asigna 'Copy of <nombre>'"
- name: collection_id
desc: "ID de la coleccion destino; None = misma coleccion que el original"
- name: description
desc: "descripcion de la copia; None = hereda la del original"
- name: is_deep_copy
desc: "si True, clona tambien todas las cards referenciadas (deep copy); si False, la copia referencia las cards originales"
output: "dict: objeto dashboard nuevo creado por Metabase, con id asignado y layout de dashcards copiado"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/dashboards.py"
---
## Ejemplo
```python
# Copia simple (referencia cards originales)
copy = metabase_copy_dashboard(client, 1)
print(copy["id"], copy["name"]) # "Copy of ..."
# Deep copy a otra coleccion con nombre propio
copy = metabase_copy_dashboard(client, 1, name="Sales Q2", collection_id=7, is_deep_copy=True)
print(copy["collection_id"]) # 7
```
## Notas
`is_deep_copy=True` hace que Metabase clone tambien las cards internas del
dashboard, util para crear instancias completamente independientes. Con
`is_deep_copy=False` (default), las dashcards del clon apuntan a las mismas
cards que el original — cambios en esas cards afectan ambos dashboards.
@@ -0,0 +1,52 @@
---
name: metabase_copy_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_copy_document(client: MetabaseClient, document_id: int, name: str | None = None, collection_id: int | None = None) -> dict"
description: "Copia un document clonando su contenido ProseMirror. Metabase no tiene endpoint nativo; realiza GET + POST internamente."
tags: [metabase, document, copy, clone, prosemirror, api, python]
uses_functions: [metabase_get_document_py_infra, metabase_create_document_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document original a copiar"
- name: name
desc: "nombre del nuevo document; None usa '{original} (copia)'"
- name: collection_id
desc: "coleccion destino; None copia a la misma coleccion del original"
output: "dict: document nuevo recien creado con id asignado y metadata completa"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
# Copia simple con nombre automatico a la misma coleccion
copy = metabase_copy_document(client, 42)
print(copy["name"]) # "Mi documento (copia)"
print(copy["id"]) # nuevo ID
# Clonar a otra coleccion con nombre personalizado
copy = metabase_copy_document(client, 42, name="Backup Q1", collection_id=5)
```
## Notas
Realiza 2 requests HTTP: `GET /api/document/:id` para obtener el original y
`POST /api/document` para crear la copia con el mismo arbol ProseMirror.
Metabase no tiene endpoint `POST /api/document/:id/copy` — esta funcion implementa
la copia en cliente. Los `cardEmbed` del documento original apuntaran a los mismos
cards embebidos; no se duplican los cards embebidos.
@@ -0,0 +1,62 @@
---
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"
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."
tags: [metabase, card, question, create, api, python, raw, as-code]
uses_functions: [metabase_auth_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
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.)"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/cards.py"
---
## Ejemplo
```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
```
## Notas
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.
@@ -0,0 +1,77 @@
---
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"
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)."
tags: [metabase, dashboard, create, api, python, raw, as-code, dashcards]
uses_functions: [metabase_auth_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
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"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/dashboards.py"
---
## Ejemplo
```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
```
## Notas
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.
@@ -0,0 +1,49 @@
---
name: metabase_create_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_document(client: MetabaseClient, name: str, document: dict, collection_id: int = 0) -> dict"
description: "Crea un document nuevo con contenido ProseMirror. Endpoint: POST /api/document. Soporta cardEmbed, smartLink, flexContainer, callout, taskList y demas nodos custom de Metabase."
tags: [metabase, document, create, api, prosemirror, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: name
desc: "titulo del document (1-254 caracteres, no blank)"
- name: document
desc: "arbol ProseMirror JSON: {type: 'doc', content: [...]}, o '' para arrancar vacio"
- name: collection_id
desc: "ID de coleccion destino (0 = root)"
output: "dict: document recien creado con id, entity_id y metadata"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
doc = metabase_create_document(client, "Notas", {
"type": "doc",
"content": [
{"type": "paragraph", "content": [{"type": "text", "text": "Hola"}]}
]
})
print(doc["id"])
```
## Notas
Nodos custom de Metabase observados (v0.59): `cardEmbed` (attrs.id=card_id), `smartLink` (attrs.entityId), `flexContainer` (attrs.columnWidths), `resizeNode`, `mention`. Marks estandar + `underline`, `highlight`, `subscript`, `textStyle`.
Cuando embebes un card via `cardEmbed`, Metabase crea una copia interna del card con `document_id` apuntando al document — no referencia el card original.
@@ -0,0 +1,59 @@
---
name: metabase_create_document_comment
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_document_comment(client: MetabaseClient, document_id: int, content: dict, *, child_target_id: str | None = None, parent_comment_id: int | None = None) -> dict"
description: "Crea un comentario en un document. Soporta anclaje a bloque concreto (via UUID de _id) y respuestas en thread (via parent_comment_id). Endpoint: POST /api/comment."
tags: [metabase, document, comments, create, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document donde crear el comentario"
- name: content
desc: "arbol ProseMirror del comentario: {type: doc, content: [...]}"
- name: child_target_id
desc: "UUID de bloque al que se ancla el comentario (matchea attrs._id de un parrafo). None = comentario a nivel doc"
- name: parent_comment_id
desc: "ID del comentario al que se responde. None = comentario top-level"
output: "dict: comentario creado con id, created_at, creator, reactions=[], is_resolved=False"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
# Comentario top-level
metabase_create_document_comment(client, 29, {
"type": "doc",
"content": [{"type": "paragraph", "content": [
{"type": "text", "text": "Deberiamos anadir un paso para configurar Slack"}
]}]
})
# Respuesta en thread
metabase_create_document_comment(client, 29, content=reply_tree,
parent_comment_id=1)
# Anclado a un bloque concreto del documento
metabase_create_document_comment(client, 29, content=tree,
child_target_id="48f9a7a4-79a0-a282-03a1-ffe2f76b9106")
```
## Notas
`target_type` se fija internamente a `"document"` (unico valor aceptado por la API en v0.59).
El `content` sigue el mismo schema ProseMirror que los documents (ver whitelist en `metabase_validate_document_payload`).
@@ -0,0 +1,39 @@
---
name: metabase_create_group
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_create_group(client: MetabaseClient, name: str) -> dict"
description: "Crea un nuevo Permission Group en Metabase. El nombre debe ser unico. Retorna el grupo creado con su id asignado. Endpoint: POST /api/permissions/group."
tags: [metabase, permissions, group, create, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: name
desc: "nombre del grupo, debe ser unico en la instancia Metabase"
output: "dict: grupo creado con id y name"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
group = metabase_create_group(client, "Analytics Team")
print(group["id"], group["name"])
```
## Notas
Error 400 si ya existe un grupo con ese nombre.
Para asignar usuarios al grupo recien creado usar la API de memberships (POST /api/permissions/membership).
@@ -0,0 +1,38 @@
---
name: metabase_delete_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_delete_document(client: MetabaseClient, document_id: int) -> None"
description: "Elimina un document. Requiere archivado previo (Metabase rechaza DELETE si archived=False)."
tags: [metabase, document, delete, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document a eliminar"
output: "None"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
metabase_archive_document(client, 1)
metabase_delete_document(client, 1)
```
## Notas
Si se llama sin archivar antes, Metabase responde: `"Document must be archived before it can be deleted."`
@@ -0,0 +1,47 @@
---
name: metabase_delete_group
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_delete_group(client: MetabaseClient, group_id: int) -> None"
description: "Elimina permanentemente un Permission Group de Metabase. IRREVERSIBLE. No borra los usuarios, solo el grupo. Endpoint: DELETE /api/permissions/group/:id."
tags: [metabase, permissions, group, delete, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: group_id
desc: "ID numerico del grupo a eliminar. ADVERTENCIA: no pasar id=1 (All Users) ni id=2 (Administrators)"
output: "None: retorna None en caso de exito (204 No Content)"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
metabase_delete_group(client, 5)
# CUIDADO: no pasar group_id=1 (All Users) ni group_id=2 (Administrators)
```
## Notas
**OPERACION IRREVERSIBLE.** El grupo se elimina permanentemente sin posibilidad de recuperacion.
Grupos especiales del sistema que NO deben borrarse:
- `id=1`: "All Users" — todos los usuarios de Metabase pertenecen automaticamente a este grupo.
- `id=2`: "Administrators" — grupo de administradores del sistema.
Esta funcion NO valida ni bloquea el borrado de esos IDs. Es responsabilidad del caller verificar que no se pasen IDs protegidos antes de invocar esta funcion.
Al borrar un grupo, los usuarios que pertenecian a el no se eliminan, solo pierden la membresia.
Error 404 si el grupo no existe.
@@ -0,0 +1,41 @@
---
name: metabase_delete_membership
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_delete_membership(client: MetabaseClient, membership_id: int) -> None"
description: "Elimina una membresía de grupo en Metabase por su membership_id. Endpoint: DELETE /api/permissions/membership/:id. NO acepta user_id+group_id — requiere el membership_id exacto."
tags: [metabase, permissions, membership, groups, users, delete, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: membership_id
desc: "ID numérico de la membresía a eliminar. Obtenerse vía metabase_list_memberships — no es el user_id ni el group_id"
output: "None: sin valor de retorno. Lanza HTTPStatusError 404 si la membresía no existe"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
# Primero obtener el membership_id via list_memberships
all_memberships = metabase_list_memberships(client)
group_members = all_memberships.get("3", [])
membership_id = next(m["membership_id"] for m in group_members if m["user_id"] == 5)
metabase_delete_membership(client, membership_id)
```
## Notas
La API de Metabase no expone DELETE por user_id+group_id. El `membership_id` es distinto de ambos y debe obtenerse vía `metabase_list_memberships`. Esta función no bloquea el borrado de membresías en grupos del sistema (All Users, Administrators) — es responsabilidad del caller verificarlo.
@@ -0,0 +1,61 @@
---
name: metabase_fix_null_ratio
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_fix_null_ratio(client: MetabaseClient, *, dry_run: bool = True, card_ids: list[int] | None = None) -> dict"
description: "Detecta y repara el patron vulnerable SUM(a-b)/SUM(b) en cards MBQL de Metabase. Cuando una resta pre-agg tiene operandos con NULL, SUM(A-B)!=SUM(A)-SUM(B). El fix reescribe las referencias post-agg al slot diferencia para usar (SUM(A)-SUM(B)) en su lugar. Soporta dry_run para escanear sin modificar."
tags: [metabase, maintenance, batch, mbql, fix, null, ratio, aggregation, python]
uses_functions: [metabase_list_cards_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [copy, time, uuid, httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient con acceso a GET /api/card y PUT /api/card/:id"
- name: dry_run
desc: "si True (default) solo escanea y reporta sin modificar ninguna card; si False aplica el fix via PUT"
- name: card_ids
desc: "lista de IDs especificos a procesar; None = todas las cards MBQL activas no archivadas"
output: "dict con scanned (cards MBQL evaluadas), affected (cards con el patron detectado), fixed (cards modificadas, 0 en dry_run), errors (lista de {card_id, error} para fallos en PUT)"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/maintenance.py"
---
## Ejemplo
```python
from metabase import MetabaseClient, metabase_fix_null_ratio
c = MetabaseClient("https://metabase.example.com", "mb_apikey")
# Escanear sin modificar
report = metabase_fix_null_ratio(c, dry_run=True)
print(report)
# {'scanned': 312, 'affected': 4, 'fixed': 0, 'errors': []}
# Aplicar solo a cards especificas
report = metabase_fix_null_ratio(c, dry_run=False, card_ids=[101, 205, 307])
print(report)
# {'scanned': 3, 'affected': 2, 'fixed': 2, 'errors': []}
```
## Notas
El patron vulnerable ocurre en cards MBQL con esta estructura:
- expressions: `["-", meta, ["field", m, "campo_a"], ["field", m, "campo_b"]]` con nombre `Diferencia_X`
- aggregation: `["sum", meta, ["expression", m, "Diferencia_X"]]` → slot `sum_N`
- aggregation: `["sum", meta, ["field", m, "campo_a"]]` → slot `sum_A`
- aggregation: `["sum", meta, ["field", m, "campo_b"]]` → slot `sum_B`
El fix reemplaza referencias a `sum_N` en stages posteriores por `(sum_A - sum_B)`.
Las restas pre-agg originales (definiciones en expressions[]) no se tocan.
Solo procesa cards con `dataset_query.type == "query"` y stages MBQL.
Las cards SQL nativas se omiten silenciosamente. Rate-limit: 50ms entre PUTs.
@@ -0,0 +1,45 @@
---
name: metabase_get_collection_graph
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_get_collection_graph(client: MetabaseClient, namespace: str | None = None) -> dict"
description: "Obtiene el grafo de permisos de colecciones de Metabase. Endpoint: GET /api/collection/graph. Soporta namespace opcional para snippet collections. El campo revision es crítico para concurrency control en el PUT posterior."
tags: [metabase, permissions, collections, graph, access-control, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: namespace
desc: "namespace opcional: 'snippets' para snippet collections, None para colecciones regulares"
output: "dict: grafo con revision (int) y groups (group_id -> collection_id -> 'read' | 'write' | 'none'). El campo revision es obligatorio para el PUT."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
# Colecciones regulares
graph = metabase_get_collection_graph(client)
print("revision:", graph["revision"])
for group_id, colls in graph["groups"].items():
for coll_id, access in colls.items():
print(f"group={group_id} collection={coll_id}: {access}")
# Snippet collections
snippet_graph = metabase_get_collection_graph(client, namespace="snippets")
```
## Notas
Siempre hacer GET fresco justo antes del PUT. El `revision` es el mecanismo de concurrency control nativo de Metabase — ver `metabase_update_collection_graph` para el patrón completo. Los niveles de acceso son `"read"`, `"write"` y `"none"`.
@@ -0,0 +1,38 @@
---
name: metabase_get_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_get_document(client: MetabaseClient, document_id: int) -> dict"
description: "Obtiene un document completo con su arbol ProseMirror (campo document). Endpoint: GET /api/document/:id."
tags: [metabase, document, get, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document a obtener"
output: "dict: objeto document con name, document (ProseMirror tree), collection_id, archived, creator, created_at, updated_at, entity_id, content_type"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
doc = metabase_get_document(client, 1)
tree = doc["document"] # {"type": "doc", "content": [...]}
```
## Notas
`content_type` siempre es `application/json+vnd.prose-mirror`.
@@ -0,0 +1,41 @@
---
name: metabase_get_group
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_get_group(client: MetabaseClient, group_id: int) -> dict"
description: "Obtiene un Permission Group de Metabase por ID, incluyendo la lista completa de miembros con sus datos. Endpoint: GET /api/permissions/group/:id."
tags: [metabase, permissions, group, get, members, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: group_id
desc: "ID numerico del grupo a consultar"
output: "dict: grupo con id, name y members (lista con user_id, email, first_name, last_name, membership_id)"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
group = metabase_get_group(client, 3)
print(group["name"])
for m in group["members"]:
print(m["email"], m["user_id"])
```
## Notas
A diferencia de `metabase_list_groups`, este endpoint retorna la lista completa de miembros del grupo.
Error 404 si el grupo no existe.
@@ -0,0 +1,39 @@
---
name: metabase_get_permission_graph
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_get_permission_graph(client: MetabaseClient) -> dict"
description: "Obtiene el grafo de permisos de datos (databases/schemas/tables) de Metabase. Endpoint: GET /api/permissions/graph. El campo revision es crítico para concurrency control en el PUT posterior."
tags: [metabase, permissions, graph, databases, schemas, access-control, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
output: "dict: grafo completo con revision (int) y groups (group_id -> db_id -> {schemas, native}). El campo revision es obligatorio para el PUT."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
graph = metabase_get_permission_graph(client)
print("revision:", graph["revision"])
for group_id, dbs in graph["groups"].items():
for db_id, perms in dbs.items():
print(f"group={group_id} db={db_id}: {perms}")
```
## Notas
Siempre hacer GET fresco justo antes del PUT. El `revision` es el mecanismo de concurrency control nativo de Metabase — ver `metabase_update_permission_graph` para el patrón completo.
@@ -0,0 +1,51 @@
---
name: metabase_list_document_comments
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_list_document_comments(client: MetabaseClient, document_id: int, *, include_resolved: bool = True, include_deleted: bool = False) -> list[dict]"
description: "Lista los comentarios de un document (threads, anclajes por bloque UUID, reacciones). Endpoint no documentado: GET /api/comment?target_type=document&target_id=:id."
tags: [metabase, document, comments, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document del que listar comentarios"
- name: include_resolved
desc: "si False, filtra comentarios con is_resolved=True"
- name: include_deleted
desc: "si False, filtra comentarios soft-deleted (deleted_at != null)"
output: "list[dict]: cada dict con id, content (arbol ProseMirror), creator, creator_id, target_type, target_id, child_target_id (UUID de bloque anclado o null), parent_comment_id (para threads), is_resolved, deleted_at, reactions, created_at, updated_at"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
comments = metabase_list_document_comments(client, 29, include_resolved=False)
for c in comments:
author = c["creator"]["common_name"]
# content es ProseMirror — aplanar si quieres texto plano
text = "".join(n["text"] for n in _walk(c["content"]) if n.get("type") == "text")
anchor = c["child_target_id"] or "doc"
print(f'{author} @ {anchor}: {text}')
```
## Notas
**Endpoint no documentado** en la API publica de Metabase — descubierto inspeccionando trafico. target_type solo acepta el enum `"document"` en v0.59.
**Anclaje por bloque**: cuando un usuario comenta sobre un parrafo concreto en la UI, Metabase inyecta un `attrs._id` UUID en ese parrafo y guarda ese UUID en `child_target_id`. Si editas el parrafo vía YAML sin preservar el `_id`, el comentario queda huerfano.
**Threads**: los comentarios que responden a otros tienen `parent_comment_id` apuntando al padre.
@@ -0,0 +1,37 @@
---
name: metabase_list_documents
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_list_documents(client: MetabaseClient) -> list[dict]"
description: "Lista documents (texto ProseMirror tipo Notion, feature 0.57+). Endpoint: GET /api/document. Desenrolla {items: [...]}."
tags: [metabase, document, list, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
output: "list[dict]: cada dict con id, name, collection_id, archived, created_at, updated_at, creator_id, content_type, entity_id"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
docs = metabase_list_documents(client)
for d in docs:
print(d["id"], d["name"])
```
## Notas
Metabase devuelve `{"items": [...]}`. Esta funcion desenrolla para retornar la lista directamente.
@@ -0,0 +1,38 @@
---
name: metabase_list_groups
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_list_groups(client: MetabaseClient) -> list[dict]"
description: "Lista todos los Permission Groups de Metabase con su member_count. Requiere superusuario. Endpoint: GET /api/permissions/group."
tags: [metabase, permissions, group, list, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
output: "list[dict]: lista de grupos con id, name y member_count"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
groups = metabase_list_groups(client)
for g in groups:
print(g["id"], g["name"], g["member_count"])
```
## Notas
Incluye los grupos especiales del sistema: "All Users" (id=1) y "Administrators" (id=2).
Requiere que el cliente este autenticado como superusuario.
@@ -0,0 +1,38 @@
---
name: metabase_list_memberships
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_list_memberships(client: MetabaseClient) -> dict[str, list[dict]]"
description: "Lista todas las membresías de grupos de Metabase. Endpoint: GET /api/permissions/membership. Retorna dict con group_id (str) como clave y lista de membresías como valor."
tags: [metabase, permissions, membership, groups, users, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
output: "dict[str, list[dict]]: mapa de group_id (str) a lista de membresías, cada una con membership_id, user_id, group_id, is_group_manager"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
memberships = metabase_list_memberships(client)
for group_id, members in memberships.items():
for m in members:
print(m["user_id"], m["membership_id"], m["is_group_manager"])
```
## Notas
La respuesta nativa de Metabase es un dict indexado por group_id como string, no una lista plana. Para buscar la membresía de un usuario en un grupo específico, hay que iterar `memberships.get(str(group_id), [])`.
@@ -0,0 +1,76 @@
---
name: metabase_mbql_validate
kind: function
lang: py
domain: core
version: "1.0.0"
purity: pure
signature: "def metabase_mbql_validate(dataset_query: dict) -> list[str]"
description: "Valida la estructura de un dataset_query MBQL sin I/O. Detecta UUIDs duplicados, stage mixing (aggregations + expressions que referencian slots en la misma stage), slot refs rotas (sum_X inexistente), case structures invalidas y name collisions en expressions. Retorna lista de errores, vacia si el query es valido."
tags: [metabase, mbql, validation, pure, query, dataset_query]
uses_functions: []
uses_types: []
params:
- name: dataset_query
desc: "Dict completo del dataset_query MBQL tal como lo devuelve GET /api/card/:id. Debe tener clave 'stages' con lista de stage dicts. Cada stage puede tener 'expressions', 'aggregation', 'filters'."
output: "Lista de strings con errores encontrados. Lista vacia si el query supera todos los checks. Cada error incluye la ubicacion (stage[N]) y descripcion del problema."
returns: []
returns_optional: false
error_type: ""
imports: []
tested: true
tests:
- "DQ valido retorna lista vacia"
- "UUID duplicado genera error"
- "Stage mixing con slot refs genera error"
- "Slot sum_99 inexistente genera error"
- "Case con casos no pares genera error"
- "Name collision en expressions genera error"
- "stages ausente devuelve error de estructura"
test_file_path: "python/functions/metabase/test_metabase_mbql_validate.py"
file_path: "python/functions/metabase/metabase_mbql_validate.py"
---
## Checks implementados
### 1. UUIDs duplicados
Metabase requiere que cada `lib/uuid` sea unico globalmente dentro del dataset_query. Un UUID repetido (por ejemplo al copiar-pegar un nodo MBQL) causa errores silenciosos o 400 en la API.
### 2. Stage mixing
Si una stage tiene `aggregation` y `expressions`, las expressions NO deben referenciar los slot names generados por las aggregations (`sum`, `avg`, `sum_1`, etc.). Esas references deben ir en la stage siguiente. Si estan en la misma stage, Metabase retorna 500.
### 3. Slot refs rotas
Una expression `["field", {sin base-type}, "sum_X"]` referencia la X-esima aggregation de tipo sum. Si X >= cantidad de sums en la stage, el slot no existe y la query falla.
### 4. Case structure
Los nodos `["case", meta, cases]` deben tener `cases` como lista de pares `[cond, result]`. Una estructura malformada (e.g., lista de un solo elemento) causa errores de parsing en Metabase.
### 5. Name collision
Dos `expressions` con el mismo `lib/expression-name` en la misma stage generan conflictos de alias en la query SQL generada.
## Ejemplo
```python
import sys
sys.path.insert(0, '/home/lucas/fn_registry/python/functions')
from metabase import MetabaseClient
from metabase.metabase_mbql_validate import metabase_mbql_validate
client = MetabaseClient('https://metabase.example.com', 'token...')
card = client.request('GET', '/api/card/5705')
errors = metabase_mbql_validate(card['dataset_query'])
if errors:
for e in errors:
print(f'ERROR: {e}')
else:
print('Query valida')
```
## Notas
Funcion 100% pura: sin I/O, sin estado mutable, determinista. Solo stdlib Python.
Los slots reconocidos como aggregation slots son: `sum`, `avg`, `count`, `min`, `max`, `distinct`, `cum-sum`, `cum-count`, `share`, `stddev` (y sus variantes `_N`).
Un field con `base-type` en su metadata NO se considera slot ref — es una referencia a columna real. Solo los fields sin `base-type` se tratan como slots de aggregation.
@@ -0,0 +1,236 @@
"""Validacion estatica de dataset_query MBQL antes de enviarlo a Metabase."""
from __future__ import annotations
import collections
import re
from typing import Any
def metabase_mbql_validate(dataset_query: dict) -> list[str]:
"""Valida la estructura de un dataset_query MBQL sin hacer I/O.
Detecta los errores mas comunes que causan respuestas 400/500 de la API de
Metabase, permitiendo corregirlos antes del round-trip.
Checks realizados:
1. UUIDs duplicados: cualquier ``lib/uuid`` que aparezca mas de una vez en
el arbol MBQL. Metabase los requiere unicos globalmente por query.
2. Stage mixing: stages que tienen tanto ``aggregation`` como ``expressions``
donde las expressions referencian slot names (``sum``, ``sum_N``, etc.)
generados por aggregations. Esas expressions deben ir en la stage siguiente.
3. Slot refs rotos: expressions que referencian ``sum_X`` deben tener X menor
que la cantidad de sums en la stage previa (o misma).
4. Case structure: nodos ``["case", meta, cases]`` deben tener ``cases``
como lista de pares ``[cond, result]``.
5. Name collision: dos expressions con el mismo ``lib/expression-name`` en
la misma stage.
Args:
dataset_query: Dict con la estructura completa del dataset_query MBQL
tal como lo devuelve GET /api/card/:id o lo construye el caller.
Debe tener clave ``stages`` (lista de stage dicts). Si no tiene
``stages``, se devuelve error de estructura.
Returns:
Lista de strings describiendo errores encontrados. Lista vacia si el
dataset_query es valido segun todos los checks.
Example:
>>> errors = metabase_mbql_validate(card["dataset_query"])
>>> if errors:
... for e in errors:
... print(e)
... else:
... print("Query valida")
"""
errors: list[str] = []
stages = dataset_query.get("stages")
if not isinstance(stages, list):
errors.append("dataset_query.stages ausente o no es lista")
return errors
# ---- Check 1: UUIDs duplicados ------------------------------------------
uuid_locations: list[tuple[str, str]] = []
_collect_uuids(dataset_query, root="", out=uuid_locations)
uuid_counter: dict[str, list[str]] = collections.defaultdict(list)
for uid, path in uuid_locations:
uuid_counter[uid].append(path)
for uid, paths in uuid_counter.items():
if len(paths) > 1:
errors.append(
f"Duplicate lib/uuid '{uid}' aparece {len(paths)} veces: "
+ ", ".join(paths[:3])
+ ("..." if len(paths) > 3 else "")
)
# ---- Checks por stage ---------------------------------------------------
for si, stage in enumerate(stages):
if not isinstance(stage, dict):
continue
tag = f"stage[{si}]"
expressions: list[Any] = stage.get("expressions") or []
aggregations: list[Any] = stage.get("aggregation") or []
# Check 5: name collision en expressions
expr_names: list[str] = []
for expr in expressions:
name = _expr_name(expr)
if name:
if name in expr_names:
errors.append(
f"{tag} tiene dos expressions con mismo "
f"lib/expression-name '{name}'"
)
else:
expr_names.append(name)
# Check 2: stage mixing
if aggregations and expressions:
for expr in expressions:
slot_refs = _find_slot_refs(expr)
if slot_refs:
ename = _expr_name(expr) or "?"
errors.append(
f"{tag} mezcla aggregations con expressions "
f"post-agg que referencian slot names "
f"({', '.join(repr(s) for s in slot_refs)}) "
f"en expression '{ename}'. "
f"Mover esas expressions a la stage siguiente."
)
# Check 3: slot refs rotos
# Contar sums en aggregations de esta stage
sum_count = sum(1 for agg in aggregations if _agg_is_sum(agg))
for expr in expressions:
for slot in _find_slot_refs(expr):
m = re.match(r"sum(?:_(\d+))?$", slot, re.IGNORECASE)
if m:
idx = int(m.group(1)) if m.group(1) else 0
if idx >= sum_count:
ename = _expr_name(expr) or "?"
errors.append(
f"{tag} expression '{ename}' referencia "
f"'{slot}' que no existe "
f"(solo hay {sum_count} sum(s) en aggregation)"
)
# Check 4: case structure
for expr in expressions:
_check_case_structure(expr, tag, errors)
return errors
# ---------------------------------------------------------------------------
# Helpers privados
# ---------------------------------------------------------------------------
def _collect_uuids(
obj: Any,
root: str,
out: list[tuple[str, str]],
) -> None:
"""Recorre obj recursivamente y añade (uuid, path) a out."""
if isinstance(obj, dict):
if "lib/uuid" in obj:
out.append((obj["lib/uuid"], root))
for k, v in obj.items():
_collect_uuids(v, f"{root}.{k}" if root else k, out)
elif isinstance(obj, list):
for i, item in enumerate(obj):
_collect_uuids(item, f"{root}[{i}]", out)
def _expr_name(expr: Any) -> str | None:
"""Extrae lib/expression-name del segundo elemento de un nodo MBQL."""
if isinstance(expr, list) and len(expr) >= 2 and isinstance(expr[1], dict):
return expr[1].get("lib/expression-name")
return None
# Patron de slot name: word chars, puede terminar en _N
_SLOT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*(?:_\d+)?$")
# Slots que corresponden a aggregation functions conocidas
_AGG_SLOTS = {
"sum", "avg", "count", "min", "max",
"distinct", "cum-sum", "cum-count", "share", "stddev",
}
def _find_slot_refs(obj: Any) -> list[str]:
"""Devuelve lista de slot names encontrados en refs tipo ["field", meta, slot]."""
slots: list[str] = []
_collect_slot_refs(obj, slots)
return slots
def _collect_slot_refs(obj: Any, out: list[str]) -> None:
if isinstance(obj, list):
if (
len(obj) == 3
and obj[0] == "field"
and isinstance(obj[1], dict)
and isinstance(obj[2], str)
and not obj[1].get("base-type") # field sin base-type = slot ref
and _is_slot_name(obj[2])
):
out.append(obj[2])
else:
for item in obj:
_collect_slot_refs(item, out)
elif isinstance(obj, dict):
for v in obj.values():
_collect_slot_refs(v, out)
def _is_slot_name(s: str) -> bool:
"""Devuelve True si s parece un slot name de aggregation."""
# Slot: nombre sin espacio que es una funcion de agg o variant con sufijo _N
base = re.sub(r"_\d+$", "", s)
return base in _AGG_SLOTS
def _agg_is_sum(agg: Any) -> bool:
"""Retorna True si el nodo aggregation es de tipo sum."""
if isinstance(agg, list) and len(agg) >= 1:
return str(agg[0]).lower() == "sum"
return False
def _check_case_structure(expr: Any, tag: str, errors: list[str]) -> None:
"""Valida recursivamente nodos case dentro de una expression."""
if not isinstance(expr, list):
return
if expr and expr[0] == "case":
ename = _expr_name(expr) or "?"
# Esperado: ["case", meta, [[cond, result], ...]]
if len(expr) < 3:
errors.append(
f"{tag} expression '{ename}': case con menos de 3 elementos"
)
return
cases = expr[2]
if not isinstance(cases, list):
errors.append(
f"{tag} expression '{ename}': tercer elemento de case "
f"debe ser lista de pares, got {type(cases).__name__}"
)
return
for i, pair in enumerate(cases):
if not (isinstance(pair, list) and len(pair) == 2):
errors.append(
f"{tag} expression '{ename}': case[{i}] no es par "
f"[cond, result], got {pair!r}"
)
# Recursar en ramas
for pair in cases:
if isinstance(pair, list):
for node in pair:
_check_case_structure(node, tag, errors)
else:
for item in expr:
_check_case_structure(item, tag, errors)
@@ -0,0 +1,46 @@
---
name: metabase_move_card
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_move_card(client: MetabaseClient, card_id: int, collection_id: int | None) -> dict"
description: "Mueve una card/pregunta a otra coleccion via PUT /api/card/:id. Wrapper thin que solo actualiza collection_id. collection_id=None mueve a 'Our analytics' (root)."
tags: [metabase, card, question, move, collection, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: card_id
desc: "ID de la card a mover"
- name: collection_id
desc: "ID de la coleccion destino; None mueve a 'Our analytics' (root)"
output: "dict: objeto card actualizado con el nuevo collection_id"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/cards.py"
---
## Ejemplo
```python
# Mover a una coleccion especifica
card = metabase_move_card(client, 42, collection_id=7)
print(card["collection_id"]) # 7
# Mover a root ("Our analytics")
card = metabase_move_card(client, 42, collection_id=None)
```
## Notas
Wrapper con intencion explicita sobre metabase_update_card. Envia solo
`{collection_id: ...}` en el body para no sobreescribir otros campos.
Pasar `collection_id=None` es el mecanismo de Metabase para mover a root.
@@ -0,0 +1,50 @@
---
name: metabase_move_collection
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_move_collection(client: MetabaseClient, collection_id: int, parent_id: int | None) -> dict"
description: "Mueve una collection (sub-arbol completo) a otro padre. Endpoint: PUT /api/collection/:id con {parent_id: ...}."
tags: [metabase, collection, move, tree, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: collection_id
desc: "ID de la collection a mover"
- name: parent_id
desc: "ID de la collection padre destino; None mueve a la raiz (Our analytics)"
output: "dict: collection actualizada con nuevo parent_id y location actualizado"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/collections.py"
---
## Ejemplo
```python
# Mover collection 12 dentro de collection 3
col = metabase_move_collection(client, 12, parent_id=3)
print(col["location"]) # "/3/"
# Mover a raiz
col = metabase_move_collection(client, 12, parent_id=None)
print(col["location"]) # "/"
```
## Notas
Metabase reubica atomicamente la collection y todo su sub-arbol: colecciones
hijas, cards, dashboards y documents contenidos en ellas. El campo `location`
de la respuesta refleja la nueva ruta en formato `"/parent_id/"` o `"/"` para
la raiz.
Para mover un document individual usar `metabase_move_document`.
@@ -0,0 +1,47 @@
---
name: metabase_move_dashboard
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_move_dashboard(client: MetabaseClient, dashboard_id: int, collection_id: int | None) -> dict"
description: "Mueve un dashboard a otra coleccion via PUT /api/dashboard/:id. Wrapper thin que solo actualiza collection_id. collection_id=None mueve a 'Our analytics' (root)."
tags: [metabase, dashboard, move, collection, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: dashboard_id
desc: "ID del dashboard a mover"
- name: collection_id
desc: "ID de la coleccion destino; None mueve a 'Our analytics' (root)"
output: "dict: objeto dashboard actualizado con el nuevo collection_id"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/dashboards.py"
---
## Ejemplo
```python
# Mover a una coleccion especifica
dash = metabase_move_dashboard(client, 1, collection_id=7)
print(dash["collection_id"]) # 7
# Mover a root ("Our analytics")
dash = metabase_move_dashboard(client, 1, collection_id=None)
```
## Notas
Wrapper con intencion explicita sobre metabase_update_dashboard. Envia solo
`{collection_id: ...}` en el body para no sobreescribir dashcards ni otros
campos del dashboard. Pasar `collection_id=None` es el mecanismo de Metabase
para mover a root.
@@ -0,0 +1,45 @@
---
name: metabase_move_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_move_document(client: MetabaseClient, document_id: int, collection_id: int | None) -> dict"
description: "Mueve un document a otra coleccion. Thin wrapper sobre PUT /api/document/:id enviando solo collection_id."
tags: [metabase, document, move, collection, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document a mover"
- name: collection_id
desc: "ID de coleccion destino; None mueve a la raiz (Our analytics)"
output: "dict: document actualizado con el nuevo collection_id"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
# Mover a coleccion 7
doc = metabase_move_document(client, 42, collection_id=7)
print(doc["collection_id"]) # 7
# Mover a raiz
doc = metabase_move_document(client, 42, collection_id=None)
```
## Notas
Equivale a `metabase_update_document(client, document_id, collection_id=collection_id)` pero con
firma explicita para mayor legibilidad en codigo de migracion/reorganizacion.
@@ -0,0 +1,66 @@
---
name: metabase_pair_n_n1_columns
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_pair_n_n1_columns(client: MetabaseClient, *, dry_run: bool = True, card_ids: list[int] | None = None, base_field: str = 'Valor_vendido') -> dict"
description: "Para cards Metabase con display table/pivot que agregan SUM(base_field) y SUM(base_field_1), habilita la columna base_field_1 en visualization_settings.table.columns y la posiciona inmediatamente despues de base_field para comparacion visual. Soporta dry_run y campo base configurable."
tags: [metabase, maintenance, batch, visualization, columns, table, pivot, python]
uses_functions: [metabase_list_cards_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [copy, time, httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient con acceso a GET /api/card y PUT /api/card/:id"
- name: dry_run
desc: "si True (default) solo escanea y reporta sin modificar ninguna card; si False aplica el cambio via PUT"
- name: card_ids
desc: "lista de IDs especificos a procesar; None = todas las cards activas con display table o pivot"
- name: base_field
desc: "nombre del campo MBQL base (sin sufijo _1); la funcion busca sum(base_field) y sum(base_field_1) en las agregaciones; por defecto 'Valor_vendido'"
output: "dict con scanned (cards table/pivot con par detectado), affected (cards con columna a mover), fixed (cards modificadas, 0 en dry_run), skipped (cards ya correctas o sin table.columns), errors (lista de {card_id, error} para fallos en PUT)"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/maintenance.py"
---
## Ejemplo
```python
from metabase import MetabaseClient, metabase_pair_n_n1_columns
c = MetabaseClient("https://metabase.example.com", "mb_apikey")
# Escanear sin modificar (campo por defecto: Valor_vendido)
report = metabase_pair_n_n1_columns(c, dry_run=True)
print(report)
# {'scanned': 45, 'affected': 3, 'fixed': 0, 'skipped': 42, 'errors': []}
# Aplicar con campo personalizado
report = metabase_pair_n_n1_columns(c, dry_run=False, base_field="Importe")
print(report)
# {'scanned': 12, 'affected': 2, 'fixed': 2, 'skipped': 10, 'errors': []}
# Solo cards especificas
report = metabase_pair_n_n1_columns(c, dry_run=False, card_ids=[42, 99])
```
## Notas
Requisitos para que una card sea candidata:
1. display == "table" o "pivot"
2. dataset_query tiene aggregation con sum(base_field) y sum(base_field_1)
3. visualization_settings.table.columns es una lista
La funcion detecta los slots MBQL dinamicamente (ej: "sum", "sum_3") contando
el orden de aparicion de cada funcion de agregacion en el array. Luego busca
esos slots en table.columns por el campo "name" y reordena.
Si base_field_1 no aparece en table.columns se inserta como nueva entrada
`{"name": slot_n1, "enabled": True}`. Rate-limit: 50ms entre PUTs.
@@ -0,0 +1,40 @@
---
name: metabase_resolve_document_comment
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_resolve_document_comment(client: MetabaseClient, comment_id: int) -> dict"
description: "Marca un comentario como resuelto (is_resolved=True). Los comentarios resueltos se ocultan en la UI pero siguen consultables via metabase_list_document_comments(include_resolved=True). Endpoint: PUT /api/comment/:id."
tags: [metabase, document, comments, resolve, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: comment_id
desc: "ID del comentario a marcar como resuelto"
output: "dict: comentario actualizado con is_resolved=True"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
open_comments = metabase_list_document_comments(client, 29, include_resolved=False)
for c in open_comments:
if "obsoleto" in _plaintext(c["content"]):
metabase_resolve_document_comment(client, c["id"])
```
## Notas
El endpoint `PUT /api/comment/:id` acepta cualquier campo actualizable (content, is_resolved, etc.); esta funcion solo envia `is_resolved=True` para mantener contrato estrecho. Para editar contenido usar `PUT /api/comment/:id` directo via client.
@@ -0,0 +1,64 @@
---
name: metabase_update_collection_graph
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_collection_graph(client: MetabaseClient, graph: dict, namespace: str | None = None) -> dict"
description: "Actualiza el grafo de permisos de colecciones en Metabase. Endpoint: PUT /api/collection/graph. El campo revision en el graph es obligatorio — el servidor rechaza con 409 si no coincide con el actual. Soporta namespace para snippet collections."
tags: [metabase, permissions, collections, graph, access-control, update, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: graph
desc: "dict con el grafo completo incluyendo el campo revision actual. Debe obtenerse vía metabase_get_collection_graph justo antes de modificar"
- name: namespace
desc: "namespace opcional: 'snippets' para snippet collections, None para colecciones regulares. Debe coincidir con el namespace usado en el GET"
output: "dict: nuevo grafo tras la actualización con revision incrementado"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
graph = metabase_get_collection_graph(client)
# Dar acceso write al grupo 3 sobre la colección 5
graph["groups"]["3"]["5"] = "write"
updated = metabase_update_collection_graph(client, graph)
print("nueva revision:", updated["revision"])
# Para snippet collections:
graph = metabase_get_collection_graph(client, namespace="snippets")
graph["groups"]["3"]["root"] = "write"
updated = metabase_update_collection_graph(client, graph, namespace="snippets")
```
## Control de concurrencia por revision
El campo `graph["revision"]` es el mecanismo de optimistic locking nativo de Metabase.
**Patrón obligatorio:**
1. `graph = metabase_get_collection_graph(client)` — GET fresco
2. Modificar `graph["groups"][group_id][collection_id] = "read" | "write" | "none"`
3. `graph = metabase_update_collection_graph(client, graph)` — PUT con revision
**Nunca cachear el graph.** Si otro proceso modificó el graph entre el GET y el PUT, Metabase devuelve HTTP 409 Conflict y el caller debe reintentar desde el GET.
### Niveles de acceso para colecciones
- `"write"` — el grupo puede ver y editar contenido de la colección
- `"read"` — el grupo puede ver pero no editar
- `"none"` — sin acceso (la colección es invisible para el grupo)
El campo `"root"` como collection_id hace referencia a la colección raíz "Our analytics".
@@ -0,0 +1,95 @@
---
name: metabase_update_dashboard_safe
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_dashboard_safe(client: MetabaseClient, dashboard_id: int, *, dashcards_update: list[dict] | None = None, dashcards_add: list[dict] | None = None, dashcards_remove: list[int] | None = None, extra_fields: dict | None = None) -> dict"
description: "Wrapper sobre PUT /api/dashboard/:id que maneja los tres gotchas documentados: strip del campo '.card' denormalizado (evita 413), inclusion obligatoria de 'tabs' (evita 500 FK violation) y asignacion de IDs negativos a dashcards nuevos. Soporta reemplazo completo (dashcards_update), operaciones incrementales (add/remove) y actualizacion de campos extra del dashboard."
tags: [metabase, dashboard, dashcard, api, wrapper, safe, put]
uses_functions:
- metabase_get_dashboard_py_infra
uses_types: []
params:
- name: client
desc: "MetabaseClient autenticado con sesion activa."
- name: dashboard_id
desc: "ID entero del dashboard a actualizar."
- name: dashcards_update
desc: "Lista completa de dashcards que reemplaza el estado actual (full replace). Si se da, dashcards_add y dashcards_remove se ignoran. Cada dict puede incluir o no el campo 'card' — se strippea automaticamente."
- name: dashcards_add
desc: "Dashcards a añadir sobre los existentes. No deben incluir 'id' — la funcion asigna IDs negativos (-1, -2, ...). Se ignora si dashcards_update esta presente."
- name: dashcards_remove
desc: "Lista de IDs de dashcards existentes a eliminar del dashboard. Se aplica sobre existentes antes de añadir dashcards_add."
- name: extra_fields
desc: "Campos adicionales del dashboard a actualizar junto con las cards: name, description, parameters, archived, collection_id, etc."
output: "Dict con resumen: {'added': [negative_ids_assigned], 'updated': count_existentes_conservados, 'removed': count_eliminados, 'response': dict_respuesta_PUT}"
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- httpx
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/metabase_update_dashboard_safe.py"
---
## Gotchas que maneja
### 1. 413 Payload Too Large — campo `.card` denormalizado
GET /api/dashboard/:id devuelve cada dashcard con un campo `card` que contiene el objeto completo de la question (dataset_query, visualization_settings, result_metadata, etc.). Ese campo puede pesar varios KB por dashcard. PUT /api/dashboard/:id lo rechaza con 413 si se incluye.
Esta funcion hace strip automatico, conservando solo los campos aceptados por la API:
`id`, `card_id`, `dashboard_tab_id`, `col`, `row`, `size_x`, `size_y`, `parameter_mappings`, `visualization_settings`, `series`, `action_id`, `inline_parameters`.
### 2. 500 FK violation — tabs ausentes
Si el body del PUT no incluye `tabs`, Metabase interpreta que se deben borrar todas las tabs. Los dashcards que referencian esas tabs via `dashboard_tab_id` quedan con FK dangling y se produce 500 Internal Server Error.
Esta funcion siempre hace GET del estado actual y re-incluye `current_tabs` en el body.
### 3. IDs negativos para dashcards nuevos
Los dashcards nuevos deben tener `id` negativo temporal (-1, -2, ...) en el payload del PUT. Sin el campo `id`, Metabase los ignora. Esta funcion asigna IDs negativos automaticamente a cualquier dashcard sin `id` en `dashcards_add` o en `dashcards_update`.
## Ejemplo
```python
from metabase import MetabaseClient, metabase_update_dashboard_safe
client = MetabaseClient('https://metabase.example.com', 'token...')
# Añadir una card al dashboard
result = metabase_update_dashboard_safe(
client,
dashboard_id=42,
dashcards_add=[{
"card_id": 100,
"col": 0,
"row": 0,
"size_x": 6,
"size_y": 4,
"parameter_mappings": [],
"visualization_settings": {},
}],
)
print(result["added"]) # [-1]
print(result["updated"]) # N dashcards existentes conservados
# Reemplazar todos los dashcards y cambiar nombre
dash = client.request("GET", "/api/dashboard/42")
cards = dash["dashcards"]
cards.append({"card_id": 200, "col": 6, "row": 0, "size_x": 6, "size_y": 4})
result = metabase_update_dashboard_safe(
client,
dashboard_id=42,
dashcards_update=cards,
extra_fields={"name": "Dashboard actualizado"},
)
```
## Notas
Realiza siempre 2 requests (GET + PUT). Para operaciones de solo metadata sin tocar cards (ej. cambiar nombre), usar `metabase_update_dashboard` directamente que solo hace PUT.
En caso de 413 o 500, el mensaje de error incluye diagnostico del gotcha mas probable para facilitar debugging.
@@ -0,0 +1,212 @@
"""Wrapper seguro sobre PUT /api/dashboard/:id que maneja los gotchas conocidos."""
from __future__ import annotations
import httpx
from .client import MetabaseClient
# Campos permitidos en un dashcard al enviar a la API de Metabase.
# Cualquier otro campo (especialmente 'card' denormalizado) causa 413.
_DASHCARD_ALLOWED_KEYS = {
"id",
"card_id",
"dashboard_tab_id",
"col",
"row",
"size_x",
"size_y",
"parameter_mappings",
"visualization_settings",
"series",
"action_id",
"inline_parameters",
}
def _strip_dashcard(dc: dict) -> dict:
"""Elimina campos denormalizados de un dashcard, conservando solo los permitidos.
El campo ``card`` contiene el objeto completo de la question (con dataset_query,
visualization_settings, etc.) y puede superar facilmente el limite de payload.
Metabase lo añade en las respuestas GET pero lo rechaza (413) en PUT.
Args:
dc: Dict de dashcard tal como lo devuelve GET /api/dashboard/:id.
Returns:
Dict con solo los campos que acepta PUT /api/dashboard/:id.
"""
return {k: v for k, v in dc.items() if k in _DASHCARD_ALLOWED_KEYS}
def metabase_update_dashboard_safe(
client: MetabaseClient,
dashboard_id: int,
*,
dashcards_update: list[dict] | None = None,
dashcards_add: list[dict] | None = None,
dashcards_remove: list[int] | None = None,
extra_fields: dict | None = None,
) -> dict:
"""Actualiza un dashboard de Metabase manejando los tres gotchas conocidos.
Gotchas que maneja automaticamente:
1. **413 Payload Too Large**: strippea el campo ``.card`` denormalizado de
cada dashcard antes de enviar. Metabase incluye ese objeto en GET pero
lo rechaza en PUT.
2. **500 FK violation (tabs)**: siempre incluye el array ``tabs`` actual en
el body del PUT. Si se omite, Metabase borra las tabs y viola FK
constraints de dashcards que las referencian.
3. **IDs negativos para dashcards nuevos**: los dashcards sin ``id``
(nuevos) reciben IDs negativos temporales (-1, -2, ...) que Metabase
reemplaza con IDs reales en la respuesta.
Flujo:
1. GET /api/dashboard/:id — obtiene estado actual (dashcards + tabs).
2. Construye la lista final de dashcards:
- Si ``dashcards_update`` dado: usarla directamente (tras strip).
- Si ``dashcards_add`` y/o ``dashcards_remove``: aplicar sobre existentes.
- Sin ninguno: mantener existentes sin cambios.
3. Strip de campos denormalizados en todos los dashcards.
4. Asignar IDs negativos a dashcards nuevos (sin ``id``).
5. PUT /api/dashboard/:id con ``{dashcards, tabs, **extra_fields}``.
6. Devolver resumen de operacion.
Args:
client: Cliente autenticado con sesion activa.
dashboard_id: ID del dashboard a actualizar.
dashcards_update: Lista completa de dashcards que REEMPLAZA el estado
actual. Si se da, ``dashcards_add`` y ``dashcards_remove`` se ignoran.
dashcards_add: Dashcards a añadir sobre los existentes. Cada item
no debe incluir ``id`` — la funcion asigna IDs negativos.
dashcards_remove: Lista de IDs de dashcards existentes a eliminar.
extra_fields: Campos adicionales del dashboard a actualizar
(name, description, parameters, archived, collection_id, etc.).
Returns:
Dict con resumen de la operacion::
{
"added": [list of temp negative ids assigned],
"updated": int, # dashcards existentes conservados
"removed": int, # dashcards eliminados
"response": dict, # respuesta raw de PUT /api/dashboard/:id
}
Raises:
httpx.HTTPStatusError: Si la API devuelve 4xx/5xx con mensaje de
diagnostico indicando que gotcha probablemente se activo.
Example:
>>> # Añadir una card al dashboard
>>> result = metabase_update_dashboard_safe(
... client,
... dashboard_id=42,
... dashcards_add=[{
... "card_id": 100,
... "col": 0,
... "row": 0,
... "size_x": 6,
... "size_y": 4,
... "parameter_mappings": [],
... "visualization_settings": {},
... }],
... )
>>> print(result["added"]) # [-1]
>>> # Reemplazar todos los dashcards y cambiar el nombre
>>> result = metabase_update_dashboard_safe(
... client,
... dashboard_id=42,
... dashcards_update=existing_dashcards,
... extra_fields={"name": "Nuevo nombre"},
... )
>>> print(result["updated"])
"""
# ---- Paso 1: GET estado actual ------------------------------------------
current = client.request("GET", f"/api/dashboard/{dashboard_id}")
current_dashcards: list[dict] = current.get("dashcards") or []
current_tabs: list[dict] = current.get("tabs") or []
# ---- Paso 2: Construir lista final de dashcards -------------------------
removed_count = 0
added_ids: list[int] = []
if dashcards_update is not None:
# Reemplazo completo: usar tal cual (tras strip)
final_dashcards = list(dashcards_update)
# Contabilizar: existentes que siguen presentes vs removidos
existing_ids = {dc.get("id") for dc in current_dashcards if dc.get("id")}
final_pos_ids = {dc.get("id") for dc in final_dashcards if dc.get("id") and dc.get("id", 0) > 0}
removed_count = len(existing_ids - final_pos_ids)
else:
# Operacion incremental: partir de existentes
remove_set: set[int] = set(dashcards_remove or [])
final_dashcards = [dc for dc in current_dashcards if dc.get("id") not in remove_set]
removed_count = len(remove_set)
if dashcards_add:
final_dashcards = list(final_dashcards) + list(dashcards_add)
# ---- Paso 3 & 4: Strip + asignar IDs negativos --------------------------
cleaned: list[dict] = []
next_neg_id = -1
existing_count = 0
for dc in final_dashcards:
dc_clean = _strip_dashcard(dc)
if "id" not in dc_clean or dc_clean.get("id") is None:
# Dashcard nuevo sin id: asignar negativo
dc_clean["id"] = next_neg_id
added_ids.append(next_neg_id)
next_neg_id -= 1
elif isinstance(dc_clean.get("id"), int) and dc_clean["id"] < 0:
# Ya tiene id negativo (caller lo asigno): registrar
added_ids.append(dc_clean["id"])
else:
existing_count += 1
cleaned.append(dc_clean)
# ---- Paso 5: Construir body y PUT ----------------------------------------
body: dict = {
"dashcards": cleaned,
"tabs": current_tabs,
}
if extra_fields:
body.update(extra_fields)
try:
response = client.request("PUT", f"/api/dashboard/{dashboard_id}", json=body)
except httpx.HTTPStatusError as exc:
status = exc.response.status_code
if status == 413:
raise httpx.HTTPStatusError(
f"413 Payload Too Large al actualizar dashboard {dashboard_id}. "
"Probable causa: dashcard con campo '.card' denormalizado sin strippear. "
"Revisar que dashcards_update no contenga el objeto 'card' completo.",
request=exc.request,
response=exc.response,
) from exc
if status == 500:
raise httpx.HTTPStatusError(
f"500 Internal Server Error al actualizar dashboard {dashboard_id}. "
"Posibles causas: (a) tabs FK violation — el body no incluyo 'tabs' "
"(no deberia ocurrir con esta funcion); "
"(b) dashcard referencia tab_id inexistente; "
"(c) error interno de Metabase. "
f"Body enviado tenia {len(cleaned)} dashcards y {len(current_tabs)} tabs.",
request=exc.request,
response=exc.response,
) from exc
raise
return {
"added": added_ids,
"updated": existing_count,
"removed": removed_count,
"response": response,
}
@@ -0,0 +1,45 @@
---
name: metabase_update_document
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_document(client: MetabaseClient, document_id: int, **fields) -> dict"
description: "Actualiza un document. Solo envia los campos pasados. Endpoint: PUT /api/document/:id."
tags: [metabase, document, update, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
params:
- name: client
desc: "instancia autenticada de MetabaseClient"
- name: document_id
desc: "ID del document a actualizar"
- name: fields
desc: "kwargs con campos a modificar: name, document (arbol ProseMirror), collection_id, archived"
output: "dict: document actualizado"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/documents.py"
---
## Ejemplo
```python
# Renombrar
metabase_update_document(client, 1, name="Nuevo titulo")
# Reemplazar contenido completo
metabase_update_document(client, 1, document={
"type": "doc",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "Nuevo"}]}]
})
# Mover a coleccion
metabase_update_document(client, 1, collection_id=5)
```
@@ -0,0 +1,42 @@
---
name: metabase_update_group
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_group(client: MetabaseClient, group_id: int, name: str) -> dict"
description: "Renombra un Permission Group en Metabase. La API solo permite modificar el nombre del grupo. Endpoint: PUT /api/permissions/group/:id."
tags: [metabase, permissions, group, update, rename, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: group_id
desc: "ID numerico del grupo a renombrar"
- name: name
desc: "nuevo nombre del grupo"
output: "dict: grupo actualizado con id y name"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
group = metabase_update_group(client, 3, "Data Team")
print(group["name"]) # "Data Team"
```
## Notas
La API de Metabase para grupos solo expone el campo `name` como modificable via PUT.
Para cambiar miembros usar la API de memberships (/api/permissions/membership).
Error 404 si el grupo no existe.
@@ -0,0 +1,73 @@
---
name: metabase_update_permission_graph
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def metabase_update_permission_graph(client: MetabaseClient, graph: dict) -> dict"
description: "Actualiza el grafo de permisos de datos en Metabase. Endpoint: PUT /api/permissions/graph. El campo revision en el graph es obligatorio — el servidor rechaza con 409 si no coincide con el actual."
tags: [metabase, permissions, graph, databases, schemas, access-control, update, api, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: client
desc: "instancia autenticada de MetabaseClient con permisos de superusuario"
- name: graph
desc: "dict con el grafo completo incluyendo el campo revision actual. Debe obtenerse vía metabase_get_permission_graph justo antes de modificar"
output: "dict: nuevo grafo tras la actualización con revision incrementado"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/permissions.py"
---
## Ejemplo
```python
graph = metabase_get_permission_graph(client)
# Dar acceso completo al grupo 3 sobre la database 1
graph["groups"]["3"]["1"] = {"schemas": "all", "native": "write"}
updated = metabase_update_permission_graph(client, graph)
print("nueva revision:", updated["revision"])
```
## Control de concurrencia por revision
El campo `graph["revision"]` es el mecanismo de optimistic locking nativo de Metabase.
**Patrón obligatorio:**
1. `graph = metabase_get_permission_graph(client)` — GET fresco
2. Modificar `graph["groups"][group_id][db_id] = ...` — editar en memoria
3. `graph = metabase_update_permission_graph(client, graph)` — PUT con revision
**Nunca cachear el graph.** Si otro proceso modificó el graph entre el GET y el PUT, Metabase devuelve HTTP 409 Conflict y el caller debe reintentar desde el GET.
### Estructura de permisos por database
```python
# Acceso completo con SQL nativo
graph["groups"]["3"]["1"] = {"schemas": "all", "native": "write"}
# Solo lectura, sin SQL nativo
graph["groups"]["3"]["1"] = {"schemas": "all", "native": "none"}
# Sin acceso
graph["groups"]["3"]["1"] = {"schemas": "none", "native": "none"}
# Acceso granular por schema/tabla
graph["groups"]["3"]["1"] = {
"schemas": {
"public": {
"orders": {"read": "all"},
"users": {"read": "none"},
}
},
"native": "none",
}
```
@@ -0,0 +1,70 @@
---
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]"
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."
tags: [metabase, validation, card, pure, pre-flight, structural]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
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"
tested: true
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"
test_file_path: "python/functions/metabase/validation_test.py"
file_path: "python/functions/metabase/validation.py"
---
## Ejemplo
```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)
```
## Notas
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.
@@ -0,0 +1,63 @@
---
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]"
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."
tags: [metabase, validation, dashboard, dashcard, overlap, pure, pre-flight, structural]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
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"
tested: true
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"
test_file_path: "python/functions/metabase/validation_test.py"
file_path: "python/functions/metabase/validation.py"
---
## Ejemplo
```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)
```
## Notas
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.
@@ -0,0 +1,57 @@
---
name: metabase_validate_document_payload
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: pure
signature: "def metabase_validate_document_payload(payload: dict, known_card_slugs: set[str] | None = None) -> list[str]"
description: "Valida un arbol ProseMirror contra la whitelist de nodos y marks que el editor TipTap de Metabase renderiza. Detecta nodos desconocidos que la API acepta pero el frontend descarta silenciosamente."
tags: [metabase, document, prosemirror, validate, pure, python]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: payload
desc: "payload del document (name, document=arbol ProseMirror, archived)"
- name: known_card_slugs
desc: "set de slugs de cards del index para validar cardEmbed.attrs.card (None = skip)"
output: "list[str]: warnings describiendo nodos/marks no soportados o violaciones del schema. Lista vacia = payload renderizable"
tested: true
tests: [test_document_valido_minimo, test_document_nodo_desconocido_callout, test_document_mark_desconocido_underline, test_document_heading_level_invalido, test_document_cardEmbed_sin_id_ni_slug, test_document_flexContainer_demasiados_hijos, test_document_kitchen_sink_valido]
test_file_path: "python/functions/metabase/validation_test.py"
file_path: "python/functions/metabase/validation.py"
---
## Ejemplo
```python
issues = metabase_validate_document_payload({
"name": "Notas",
"document": {
"type": "doc",
"content": [
{"type": "callout", "content": [...]} # ← no soportado por TipTap
],
},
})
# → ["document.content[0]: nodo 'callout' no soportado..."]
```
## Notas
**Whitelist de nodos** (derivada de inspeccionar el bundle de Metabase v0.59):
`doc, paragraph, text, heading, bulletList, orderedList, listItem, blockquote, codeBlock, horizontalRule, hardBreak, cardEmbed, flexContainer, smartLink, resizeNode, mention`
**Whitelist de marks:** `bold, italic, strike, code, link`
Nodos comunes de ProseMirror que la API acepta pero el editor **no renderiza** (el resultado es un documento vacio o incompleto en la UI): `callout, taskList, taskItem, details, table, image, iframe`. Marks equivalentes: `underline, highlight, subscript, textStyle`.
Restricciones estructurales adicionales:
- `heading.attrs.level` ∈ [1, 6]
- `flexContainer` acepta 1-3 hijos, solo `cardEmbed` o `supportingText`
- `flexContainer.attrs.columnWidths` debe tener el mismo largo que `content`
- `cardEmbed.attrs` requiere `id` (int) o `card` (slug del index)
@@ -0,0 +1,58 @@
---
name: metabase_validate_sql
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
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."
tags: [metabase, validation, sql, pre-flight, impure, query, syntax-check]
uses_functions: [metabase_execute_query_py_infra, metabase_auth_py_infra]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [httpx]
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"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/metabase/validation.py"
---
## Ejemplo
```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")
```
## Notas
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.
+369
View File
@@ -0,0 +1,369 @@
"""CRUD de Permission Groups de Metabase."""
from .client import MetabaseClient
def metabase_list_groups(client: MetabaseClient) -> list[dict]:
"""Lista todos los Permission Groups de Metabase.
Endpoint: GET /api/permissions/group. Requiere superusuario.
Args:
client: Cliente autenticado con permisos admin.
Returns:
Lista de dicts, cada uno con: id, name, member_count.
Example:
>>> groups = metabase_list_groups(client)
>>> for g in groups:
... print(g["id"], g["name"], g["member_count"])
"""
return client.request("GET", "/api/permissions/group")
def metabase_get_group(client: MetabaseClient, group_id: int) -> dict:
"""Obtiene un Permission Group por su ID, incluyendo la lista completa de miembros.
Endpoint: GET /api/permissions/group/:id. Requiere superusuario.
Args:
client: Cliente autenticado con permisos admin.
group_id: ID numerico del grupo.
Returns:
Dict con: id, name, members (lista de dicts con user_id, email,
first_name, last_name, membership_id).
Raises:
httpx.HTTPStatusError: 404 si el grupo no existe.
Example:
>>> group = metabase_get_group(client, 3)
>>> print(group["name"])
>>> for m in group["members"]:
... print(m["email"])
"""
return client.request("GET", f"/api/permissions/group/{group_id}")
def metabase_create_group(client: MetabaseClient, name: str) -> dict:
"""Crea un nuevo Permission Group en Metabase.
Endpoint: POST /api/permissions/group. Requiere superusuario.
Args:
client: Cliente autenticado con permisos admin.
name: Nombre del grupo. Debe ser unico.
Returns:
Dict con el grupo creado: id, name.
Raises:
httpx.HTTPStatusError: 400 si el nombre ya existe.
Example:
>>> group = metabase_create_group(client, "Analytics Team")
>>> print(group["id"], group["name"])
"""
return client.request("POST", "/api/permissions/group", json={"name": name})
def metabase_update_group(client: MetabaseClient, group_id: int, name: str) -> dict:
"""Renombra un Permission Group existente en Metabase.
Endpoint: PUT /api/permissions/group/:id. Requiere superusuario.
La API solo permite modificar el nombre del grupo.
Args:
client: Cliente autenticado con permisos admin.
group_id: ID numerico del grupo a renombrar.
name: Nuevo nombre del grupo.
Returns:
Dict con el grupo actualizado: id, name.
Raises:
httpx.HTTPStatusError: 404 si el grupo no existe.
Example:
>>> group = metabase_update_group(client, 3, "Data Team")
>>> print(group["name"])
"""
return client.request("PUT", f"/api/permissions/group/{group_id}", json={"name": name})
def metabase_delete_group(client: MetabaseClient, group_id: int) -> None:
"""Elimina permanentemente un Permission Group de Metabase.
Endpoint: DELETE /api/permissions/group/:id. IRREVERSIBLE.
Requiere superusuario.
Los grupos especiales del sistema no deben borrarse:
- id=1: "All Users" (todos los usuarios pertenecen a este grupo)
- id=2: "Administrators"
Esta funcion NO bloquea el borrado de esos IDs — es responsabilidad
del caller verificar que no se pasen IDs protegidos.
Args:
client: Cliente autenticado con permisos admin.
group_id: ID numerico del grupo a eliminar.
Raises:
httpx.HTTPStatusError: 404 si el grupo no existe.
Example:
>>> metabase_delete_group(client, 5)
>>> # CUIDADO: no pasar group_id=1 (All Users) ni group_id=2 (Administrators)
"""
client.request("DELETE", f"/api/permissions/group/{group_id}")
# --- Memberships ---
def metabase_list_memberships(client: MetabaseClient) -> dict[str, list[dict]]:
"""Lista todas las membresías de grupos de Metabase.
Endpoint: GET /api/permissions/membership. Requiere superusuario.
La respuesta nativa de Metabase es un dict con group_id (str) como clave,
y una lista de membresías como valor — no una lista plana.
Args:
client: Cliente autenticado con permisos admin.
Returns:
Dict mapeando group_id (str) a lista de dicts, cada uno con:
membership_id, user_id, group_id, is_group_manager.
Example:
>>> memberships = metabase_list_memberships(client)
>>> for group_id, members in memberships.items():
... for m in members:
... print(m["user_id"], m["membership_id"], m["is_group_manager"])
"""
return client.request("GET", "/api/permissions/membership")
def metabase_add_membership(
client: MetabaseClient,
user_id: int,
group_id: int,
is_group_manager: bool = False,
) -> list[dict]:
"""Añade un usuario a un Permission Group de Metabase.
Endpoint: POST /api/permissions/membership. Requiere superusuario.
Args:
client: Cliente autenticado con permisos admin.
user_id: ID del usuario a añadir al grupo.
group_id: ID del grupo destino.
is_group_manager: Si True, el usuario es manager del grupo.
Returns:
Lista de dicts con todas las membresias actuales del grupo tras la operacion.
Cada elemento tiene: membership_id, user_id, group_id, is_group_manager.
Raises:
httpx.HTTPStatusError: 400 si el usuario ya es miembro del grupo.
Example:
>>> members = metabase_add_membership(client, user_id=5, group_id=3)
>>> print(len(members), "miembros en el grupo")
>>> # Como manager:
>>> members = metabase_add_membership(client, user_id=5, group_id=3, is_group_manager=True)
"""
body = {
"user_id": user_id,
"group_id": group_id,
"is_group_manager": is_group_manager,
}
return client.request("POST", "/api/permissions/membership", json=body)
def metabase_delete_membership(client: MetabaseClient, membership_id: int) -> None:
"""Elimina una membresía de grupo en Metabase por su membership_id.
Endpoint: DELETE /api/permissions/membership/:id. Requiere superusuario.
IMPORTANTE: No se borra por user_id + group_id. Hay que conocer el
membership_id exacto, que se obtiene via metabase_list_memberships.
Args:
client: Cliente autenticado con permisos admin.
membership_id: ID de la membresía a eliminar (no el user_id ni group_id).
Raises:
httpx.HTTPStatusError: 404 si la membresía no existe.
Example:
>>> # Primero obtener el membership_id
>>> all_memberships = metabase_list_memberships(client)
>>> group_members = all_memberships.get("3", [])
>>> membership_id = next(m["membership_id"] for m in group_members if m["user_id"] == 5)
>>> metabase_delete_membership(client, membership_id)
"""
client.request("DELETE", f"/api/permissions/membership/{membership_id}")
# --- Data Permission Graph ---
def metabase_get_permission_graph(client: MetabaseClient) -> dict:
"""Obtiene el grafo de permisos de datos (databases/schemas/tables) de Metabase.
Endpoint: GET /api/permissions/graph. Requiere superusuario.
El campo `revision` es CRITICO para concurrency control: el servidor rechaza
PUT /api/permissions/graph si el revision no coincide con el actual (HTTP 409).
Siempre traer el graph fresco antes de modificar.
Args:
client: Cliente autenticado con permisos admin.
Returns:
Dict con:
- revision (int): numero de revision actual. Obligatorio para el PUT.
- groups (dict): mapa group_id -> db_id -> permisos. Estructura por db:
- schemas: "all" | "none" | dict de schema -> tabla -> permisos
- native: "write" | "read" | "none" (acceso a SQL nativo)
Example:
>>> graph = metabase_get_permission_graph(client)
>>> print("revision:", graph["revision"])
>>> for group_id, dbs in graph["groups"].items():
... for db_id, perms in dbs.items():
... print(f"group={group_id} db={db_id}: {perms}")
"""
return client.request("GET", "/api/permissions/graph")
def metabase_update_permission_graph(client: MetabaseClient, graph: dict) -> dict:
"""Actualiza el grafo de permisos de datos en Metabase.
Endpoint: PUT /api/permissions/graph. Requiere superusuario.
## Control de concurrencia por revision
El campo `graph["revision"]` es obligatorio y debe ser el valor actual del
servidor. Si otro proceso modifico el graph entre tu GET y este PUT, Metabase
devuelve HTTP 409 Conflict. El patron correcto es:
1. graph = metabase_get_permission_graph(client) # GET fresco
2. Modificar graph["groups"][group_id][db_id] = ... # editar en memoria
3. graph = metabase_update_permission_graph(client, graph) # PUT con revision
Nunca cachear el graph — siempre hacer GET justo antes del PUT.
Args:
client: Cliente autenticado con permisos admin.
graph: Dict con el graph completo incluyendo el campo `revision` actual.
Obtenerlo via metabase_get_permission_graph antes de modificar.
Returns:
Dict con el nuevo graph tras la actualizacion, con `revision` incrementado.
Raises:
httpx.HTTPStatusError: 409 si el revision en el body no coincide con el actual.
Example:
>>> graph = metabase_get_permission_graph(client)
>>> # Dar acceso completo al grupo 3 sobre la database 1
>>> graph["groups"]["3"]["1"] = {"schemas": "all", "native": "write"}
>>> updated = metabase_update_permission_graph(client, graph)
>>> print("nueva revision:", updated["revision"])
"""
return client.request("PUT", "/api/permissions/graph", json=graph)
# --- Collection Permission Graph ---
def metabase_get_collection_graph(
client: MetabaseClient,
namespace: str | None = None,
) -> dict:
"""Obtiene el grafo de permisos de colecciones de Metabase.
Endpoint: GET /api/collection/graph. Requiere superusuario.
El campo `revision` es CRITICO para concurrency control: el servidor rechaza
PUT si el revision no coincide con el actual.
Args:
client: Cliente autenticado con permisos admin.
namespace: Namespace opcional. "snippets" para snippet collections.
None = colecciones regulares.
Returns:
Dict con:
- revision (int): numero de revision actual. Obligatorio para el PUT.
- groups (dict): mapa group_id -> collection_id -> nivel de acceso.
Nivel de acceso: "read" | "write" | "none".
Example:
>>> graph = metabase_get_collection_graph(client)
>>> print("revision:", graph["revision"])
>>> for group_id, colls in graph["groups"].items():
... for coll_id, access in colls.items():
... print(f"group={group_id} collection={coll_id}: {access}")
>>> # Snippet collections:
>>> snippet_graph = metabase_get_collection_graph(client, namespace="snippets")
"""
params = {}
if namespace is not None:
params["namespace"] = namespace
return client.request("GET", "/api/collection/graph", params=params or None)
def metabase_update_collection_graph(
client: MetabaseClient,
graph: dict,
namespace: str | None = None,
) -> dict:
"""Actualiza el grafo de permisos de colecciones en Metabase.
Endpoint: PUT /api/collection/graph. Requiere superusuario.
## Control de concurrencia por revision
El campo `graph["revision"]` es obligatorio y debe ser el valor actual del
servidor. Si otro proceso modifico el graph entre tu GET y este PUT, Metabase
devuelve HTTP 409 Conflict. El patron correcto es:
1. graph = metabase_get_collection_graph(client) # GET fresco
2. Modificar graph["groups"][group_id][collection_id] = ... # editar en memoria
3. graph = metabase_update_collection_graph(client, graph) # PUT con revision
Nunca cachear el graph — siempre hacer GET justo antes del PUT.
Args:
client: Cliente autenticado con permisos admin.
graph: Dict con el graph completo incluyendo el campo `revision` actual.
Obtenerlo via metabase_get_collection_graph antes de modificar.
namespace: Namespace opcional. "snippets" para snippet collections.
None = colecciones regulares.
Returns:
Dict con el nuevo graph tras la actualizacion, con `revision` incrementado.
Raises:
httpx.HTTPStatusError: 409 si el revision en el body no coincide con el actual.
Example:
>>> graph = metabase_get_collection_graph(client)
>>> # Dar acceso write al grupo 3 sobre la coleccion 5
>>> graph["groups"]["3"]["5"] = "write"
>>> updated = metabase_update_collection_graph(client, graph)
>>> print("nueva revision:", updated["revision"])
>>> # Para snippet collections:
>>> graph = metabase_get_collection_graph(client, namespace="snippets")
>>> graph["groups"]["3"]["root"] = "write"
>>> updated = metabase_update_collection_graph(client, graph, namespace="snippets")
"""
params = {}
if namespace is not None:
params["namespace"] = namespace
return client.request("PUT", "/api/collection/graph", json=graph, params=params or None)
@@ -0,0 +1,275 @@
"""Tests para metabase_mbql_validate."""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from metabase.metabase_mbql_validate import metabase_mbql_validate
# ---------------------------------------------------------------------------
# DQ valido: estructura simplificada basada en card 5705 post-fix
# ---------------------------------------------------------------------------
VALID_DQ = {
"lib/type": "mbql/query",
"database": 6,
"stages": [
{
"lib/type": "mbql.stage/mbql",
"source-card": 100,
"aggregation": [
[
"sum",
{"lib/uuid": "agg-uuid-1"},
[
"field",
{"base-type": "type/Decimal", "lib/uuid": "field-uuid-1"},
"Cantidad",
],
]
],
"expressions": [
[
"-",
{
"lib/uuid": "expr-uuid-1",
"lib/expression-name": "Diferencia_Cantidad",
},
[
"field",
{"base-type": "type/Decimal", "lib/uuid": "field-uuid-2"},
"Valor_A",
],
[
"field",
{"base-type": "type/Decimal", "lib/uuid": "field-uuid-3"},
"Valor_B",
],
]
],
},
{
"lib/type": "mbql.stage/mbql",
"expressions": [
[
"case",
{
"lib/uuid": "case-uuid-1",
"lib/expression-name": "Valor medio de venta",
},
[
[
[
"=",
{"lib/uuid": "cond-uuid-1"},
[
"field",
{
"base-type": "type/Integer",
"lib/uuid": "field-uuid-4",
},
"Tickets",
],
0,
],
0,
],
[
[
"!=",
{"lib/uuid": "cond-uuid-2"},
[
"field",
{
"base-type": "type/Integer",
"lib/uuid": "field-uuid-5",
},
"Tickets",
],
0,
],
[
"/",
{"lib/uuid": "div-uuid-1"},
[
"field",
{
"base-type": "type/Decimal",
"lib/uuid": "field-uuid-6",
},
"Valor_vendido",
],
[
"field",
{
"base-type": "type/Integer",
"lib/uuid": "field-uuid-7",
},
"Tickets",
],
],
],
],
]
],
},
],
}
def test_valid_dq_returns_no_errors():
"""DQ valido retorna lista vacia."""
errors = metabase_mbql_validate(VALID_DQ)
assert errors == [], f"Esperaba 0 errores, got: {errors}"
# ---------------------------------------------------------------------------
# Check 1: UUID duplicado
# ---------------------------------------------------------------------------
def test_duplicate_uuid_detected():
"""UUID repetido en el arbol MBQL genera error."""
dq = {
"stages": [
{
"lib/type": "mbql.stage/mbql",
"expressions": [
[
"-",
{"lib/uuid": "dup-uuid-001", "lib/expression-name": "ExprA"},
["field", {"base-type": "type/Decimal", "lib/uuid": "dup-uuid-001"}, "CampoX"],
["field", {"base-type": "type/Decimal", "lib/uuid": "unique-uuid-002"}, "CampoY"],
]
],
}
]
}
errors = metabase_mbql_validate(dq)
assert any("dup-uuid-001" in e for e in errors), f"Esperaba error de UUID duplicado, got: {errors}"
# ---------------------------------------------------------------------------
# Check 2: Stage mixing — expressions referencian slots de aggregation
# ---------------------------------------------------------------------------
def test_stage_mixing_detected():
"""Stage con aggregations y expressions que referencian slots generados."""
dq = {
"stages": [
{
"lib/type": "mbql.stage/mbql",
"aggregation": [
["sum", {"lib/uuid": "agg1"}, ["field", {"base-type": "type/Decimal", "lib/uuid": "f1"}, "Precio"]]
],
"expressions": [
[
"*",
{"lib/uuid": "expr2", "lib/expression-name": "DobleSum"},
# field sin base-type y nombre 'sum' = slot ref de aggregation
["field", {"lib/uuid": "ref-slot"}, "sum"],
2,
]
],
}
]
}
errors = metabase_mbql_validate(dq)
assert any("mezcla" in e.lower() or "mixing" in e.lower() or "stage" in e.lower() for e in errors), \
f"Esperaba error de stage mixing, got: {errors}"
# ---------------------------------------------------------------------------
# Check 3: Slot ref rota — sum_99 inexistente
# ---------------------------------------------------------------------------
def test_broken_slot_ref_detected():
"""Expression que referencia sum_99 cuando solo hay 1 sum genera error."""
dq = {
"stages": [
{
"lib/type": "mbql.stage/mbql",
"aggregation": [
["sum", {"lib/uuid": "agg-s1"}, ["field", {"base-type": "type/Decimal", "lib/uuid": "f-s1"}, "Precio"]]
],
"expressions": [
[
"+",
{"lib/uuid": "expr-broken", "lib/expression-name": "SumRota"},
# sum_99 no existe (solo hay 1 sum = sum_0)
["field", {"lib/uuid": "ref-broken"}, "sum_99"],
1,
]
],
}
]
}
errors = metabase_mbql_validate(dq)
assert any("sum_99" in e for e in errors), f"Esperaba error de slot ref roto, got: {errors}"
# ---------------------------------------------------------------------------
# Check 4: Case con estructura invalida
# ---------------------------------------------------------------------------
def test_case_malformed_cases_not_pairs():
"""Case con casos que no son pares [cond, result] genera error."""
dq = {
"stages": [
{
"lib/type": "mbql.stage/mbql",
"expressions": [
[
"case",
{"lib/uuid": "case-bad", "lib/expression-name": "CaseMalo"},
# lista con un solo elemento (no par)
[["solo_elemento"]],
]
],
}
]
}
errors = metabase_mbql_validate(dq)
assert any("case" in e.lower() for e in errors), f"Esperaba error de case structure, got: {errors}"
# ---------------------------------------------------------------------------
# Check 5: Name collision en expressions
# ---------------------------------------------------------------------------
def test_name_collision_detected():
"""Dos expressions con mismo lib/expression-name en la misma stage generan error."""
dq = {
"stages": [
{
"lib/type": "mbql.stage/mbql",
"expressions": [
["-", {"lib/uuid": "e1", "lib/expression-name": "MiCalculo"},
["field", {"base-type": "type/Decimal", "lib/uuid": "f1"}, "A"],
["field", {"base-type": "type/Decimal", "lib/uuid": "f2"}, "B"]],
["+", {"lib/uuid": "e2", "lib/expression-name": "MiCalculo"},
["field", {"base-type": "type/Decimal", "lib/uuid": "f3"}, "C"],
1],
],
}
]
}
errors = metabase_mbql_validate(dq)
assert any("MiCalculo" in e for e in errors), f"Esperaba error de name collision, got: {errors}"
# ---------------------------------------------------------------------------
# Check: stages ausente
# ---------------------------------------------------------------------------
def test_missing_stages_returns_error():
"""dataset_query sin 'stages' devuelve error de estructura."""
errors = metabase_mbql_validate({"database": 1})
assert any("stages" in e.lower() for e in errors), f"Esperaba error de stages ausente, got: {errors}"
+505
View File
@@ -0,0 +1,505 @@
"""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
# ---------------------------------------------------------------- Documents
# Nodos ProseMirror que el editor TipTap de Metabase (v0.59) sabe renderizar.
# Si un documento contiene nodos fuera de esta whitelist, el backend los acepta
# pero el frontend silenciosamente descarta contenido y el doc aparece vacio o
# incompleto.
_VALID_DOC_NODES = {
"doc", "paragraph", "text", "heading",
"bulletList", "orderedList", "listItem",
"blockquote", "codeBlock", "horizontalRule", "hardBreak",
"cardEmbed", "flexContainer", "smartLink", "resizeNode", "mention",
}
_VALID_DOC_MARKS = {"bold", "italic", "strike", "code", "link"}
# Rangos de attrs.level en headings
_HEADING_LEVELS = {1, 2, 3, 4, 5, 6}
def metabase_validate_document_payload(
payload: dict,
known_card_slugs: set[str] | None = None,
) -> list[str]:
"""Valida un payload de document antes de pusharlo a Metabase.
El editor TipTap de Metabase solo renderiza un subconjunto concreto de
nodos y marks ProseMirror. La API *acepta* cualquier arbol, pero el
frontend silenciosamente descarta lo que no conoce. Este validador
rechaza (como warning) cualquier nodo o mark fuera de la whitelist.
Comprueba tambien restricciones estructurales:
- `heading.attrs.level` en [1, 6].
- `flexContainer` solo contiene `cardEmbed` o `supportingText`,
y como maximo 3 hijos.
- `cardEmbed.attrs` debe resolverse a un card real (por `id` o por
`card` slug si el caller pasa `known_card_slugs`).
Args:
payload: dict del document listo para POST/PUT (con `name` y `document`).
known_card_slugs: set de slugs conocidos en el index (para validar
`cardEmbed.attrs.card`). None = skip check.
Returns:
Lista de warnings. Lista vacia = payload renderizable.
"""
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")
elif len(name) > 254:
issues.append(f"'name' excede 254 chars ({len(name)})")
# --- archived ---
archived = payload.get("archived")
if archived is not None and not isinstance(archived, bool):
issues.append("'archived' debe ser bool")
# --- document (arbol ProseMirror) ---
tree = payload.get("document")
if tree is None:
issues.append("campo 'document' ausente")
return issues
if tree == "":
# Document vacio es valido (Metabase lo acepta)
return issues
if not isinstance(tree, dict):
issues.append(f"'document' debe ser dict o string vacia, no {type(tree).__name__}")
return issues
if tree.get("type") != "doc":
issues.append(f"'document.type' debe ser 'doc', no '{tree.get('type')}'")
# Walk recursivo acumulando issues con path
_walk_doc_node(tree, "document", issues, known_card_slugs or set())
return issues
def _walk_doc_node(
node: dict,
path: str,
issues: list[str],
known_card_slugs: set[str],
) -> None:
"""Valida un nodo ProseMirror y desciende por sus hijos."""
if not isinstance(node, dict):
issues.append(f"{path}: nodo no es dict ({type(node).__name__})")
return
ntype = node.get("type")
if not isinstance(ntype, str):
issues.append(f"{path}: campo 'type' ausente o no string")
return
if ntype not in _VALID_DOC_NODES:
issues.append(
f"{path}: nodo '{ntype}' no soportado por el editor de Metabase. "
f"Validos: {sorted(_VALID_DOC_NODES)}"
)
# Seguir igualmente — puede haber issues mas adentro
# --- Validaciones especificas por tipo ---
attrs = node.get("attrs") or {}
if ntype == "heading":
level = attrs.get("level")
if level not in _HEADING_LEVELS:
issues.append(f"{path}: heading.level={level!r} debe estar en {sorted(_HEADING_LEVELS)}")
if ntype == "cardEmbed":
# cardEmbed requiere o bien attrs.id (int) o attrs.card (slug del index)
cid = attrs.get("id")
cslug = attrs.get("card")
if cid is None and cslug is None:
issues.append(f"{path}: cardEmbed sin 'attrs.id' ni 'attrs.card'")
elif cid is not None and not isinstance(cid, int):
issues.append(f"{path}: cardEmbed.attrs.id debe ser int, no {type(cid).__name__}")
elif cslug is not None:
if not isinstance(cslug, str):
issues.append(f"{path}: cardEmbed.attrs.card debe ser string slug")
elif known_card_slugs and cslug not in known_card_slugs:
issues.append(
f"{path}: cardEmbed.attrs.card='{cslug}' no existe en el index "
f"(conocidos: {sorted(known_card_slugs)[:10]}...)"
)
if ntype == "flexContainer":
children = node.get("content") or []
if not isinstance(children, list):
issues.append(f"{path}: flexContainer.content debe ser lista")
else:
if not 1 <= len(children) <= 3:
issues.append(
f"{path}: flexContainer debe tener 1-3 hijos (tiene {len(children)})"
)
for i, ch in enumerate(children):
ct = ch.get("type") if isinstance(ch, dict) else None
if ct not in ("cardEmbed", "supportingText"):
issues.append(
f"{path}.content[{i}]: flexContainer solo acepta 'cardEmbed' "
f"o 'supportingText' como hijos (tiene '{ct}')"
)
cw = attrs.get("columnWidths")
if cw is not None:
if not isinstance(cw, list) or not all(isinstance(x, (int, float)) for x in cw):
issues.append(f"{path}: flexContainer.attrs.columnWidths debe ser lista de numeros")
elif isinstance(children, list) and len(cw) != len(children):
issues.append(
f"{path}: columnWidths tiene {len(cw)} valores pero hay {len(children)} hijos"
)
if ntype == "smartLink":
# smartLink necesita entityId (id numerico del card en Metabase)
if attrs.get("entityId") is None:
issues.append(f"{path}: smartLink sin 'attrs.entityId'")
if ntype == "text":
if not isinstance(node.get("text"), str):
issues.append(f"{path}: text sin campo 'text' string")
# Validar marks
marks = node.get("marks") or []
if not isinstance(marks, list):
issues.append(f"{path}: 'marks' debe ser lista")
else:
for i, m in enumerate(marks):
if not isinstance(m, dict):
issues.append(f"{path}.marks[{i}]: mark no es dict")
continue
mt = m.get("type")
if mt not in _VALID_DOC_MARKS:
issues.append(
f"{path}.marks[{i}]: mark '{mt}' no soportado. "
f"Validos: {sorted(_VALID_DOC_MARKS)}"
)
# --- Recursion sobre content ---
content = node.get("content")
if isinstance(content, list):
for i, child in enumerate(content):
_walk_doc_node(child, f"{path}.content[{i}]", issues, known_card_slugs)
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)
@@ -0,0 +1,389 @@
"""Tests para metabase_validate_card_payload y metabase_validate_dashboard_payload."""
import sys
sys.path.insert(0, "/home/lucas/fn_registry/python/functions")
from metabase.validation import (
metabase_validate_card_payload,
metabase_validate_dashboard_payload,
)
# ---------------------------------------------------------------------------
# metabase_validate_card_payload
# ---------------------------------------------------------------------------
def _base_card() -> dict:
return {
"name": "Revenue by Month",
"display": "line",
"dataset_query": {
"database": 1,
"type": "native",
"native": {"query": "SELECT 1"},
},
}
def test_card_valido_retorna_lista_vacia():
issues = metabase_validate_card_payload(_base_card())
assert issues == [], f"Se esperaba lista vacia, got: {issues}"
def test_card_display_invalido():
payload = _base_card()
payload["display"] = "foobar"
issues = metabase_validate_card_payload(payload)
assert any("display" in i and "foobar" in i for i in issues), issues
def test_card_display_ausente():
payload = _base_card()
del payload["display"]
issues = metabase_validate_card_payload(payload)
assert any("display" in i and "ausente" in i for i in issues), issues
def test_card_name_ausente():
payload = _base_card()
del payload["name"]
issues = metabase_validate_card_payload(payload)
assert any("name" in i and "ausente" in i for i in issues), issues
def test_card_name_vacio():
payload = _base_card()
payload["name"] = " "
issues = metabase_validate_card_payload(payload)
assert any("name" in i for i in issues), issues
def test_card_dataset_query_ausente():
payload = _base_card()
del payload["dataset_query"]
issues = metabase_validate_card_payload(payload)
assert any("dataset_query" in i and "ausente" in i for i in issues), issues
def test_card_dataset_query_sin_database():
payload = _base_card()
del payload["dataset_query"]["database"]
issues = metabase_validate_card_payload(payload)
assert any("database" in i for i in issues), issues
def test_card_nativa_sin_sql():
payload = _base_card()
payload["dataset_query"]["native"]["query"] = ""
issues = metabase_validate_card_payload(payload)
assert any("SQL" in i or "native" in i for i in issues), issues
def test_card_nativa_mbql5():
"""Acepta SQL en stages[0].native (formato MBQL5)."""
payload = {
"name": "MBQL5 card",
"display": "table",
"dataset_query": {
"database": 2,
"stages": [{"native": "SELECT 1"}],
},
}
issues = metabase_validate_card_payload(payload)
assert issues == [], f"Se esperaba lista vacia, got: {issues}"
def test_card_type_invalido():
payload = _base_card()
payload["type"] = "unknown_type"
issues = metabase_validate_card_payload(payload)
assert any("type" in i and "unknown_type" in i for i in issues), issues
def test_card_type_valido():
for t in ("question", "model", "metric"):
payload = _base_card()
payload["type"] = t
issues = metabase_validate_card_payload(payload)
assert issues == [], f"type={t!r} no deberia dar issues: {issues}"
def test_card_visualization_settings_no_dict():
payload = _base_card()
payload["visualization_settings"] = "no es un dict"
issues = metabase_validate_card_payload(payload)
assert any("visualization_settings" in i for i in issues), issues
def test_card_parameters_no_list():
payload = _base_card()
payload["parameters"] = {"not": "a list"}
issues = metabase_validate_card_payload(payload)
assert any("parameters" in i for i in issues), issues
def test_card_archived_no_bool():
payload = _base_card()
payload["archived"] = "yes"
issues = metabase_validate_card_payload(payload)
assert any("archived" in i for i in issues), issues
def test_card_acumula_multiples_errores():
"""Un payload con varios errores debe retornar todos los issues, no solo el primero."""
payload = {
"display": "invalid_display",
"dataset_query": "not a dict",
"archived": "yes",
}
issues = metabase_validate_card_payload(payload)
assert len(issues) >= 3, f"Se esperaban >= 3 issues, got {len(issues)}: {issues}"
# ---------------------------------------------------------------------------
# metabase_validate_dashboard_payload
# ---------------------------------------------------------------------------
def _base_dashboard() -> dict:
return {"name": "My Dashboard"}
def test_dashboard_valido_sin_dashcards():
issues = metabase_validate_dashboard_payload(_base_dashboard(), known_card_ids=set())
assert issues == [], issues
def test_dashboard_valido_con_dashcards():
payload = {
"name": "KPIs",
"dashcards": [
{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4},
{"card_id": 2, "row": 0, "col": 6, "size_x": 6, "size_y": 4},
],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
assert issues == [], issues
def test_dashboard_card_id_desconocido():
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": 999, "row": 0, "col": 0, "size_x": 6, "size_y": 4}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
assert any("999" in i for i in issues), issues
def test_dashboard_card_virtual_null_permitido():
"""card_id null = dashcard virtual (texto/heading), siempre permitido."""
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": None, "row": 0, "col": 0, "size_x": 6, "size_y": 2}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids=set())
assert issues == [], issues
def test_dashboard_dashcards_solapadas():
payload = {
"name": "Dashboard",
"dashcards": [
{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4},
{"card_id": 2, "row": 2, "col": 2, "size_x": 4, "size_y": 4},
],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
assert any("solapan" in i for i in issues), issues
def test_dashboard_dashcards_adyacentes_no_solapan():
"""Dos dashcards que se tocan en el borde NO solapan."""
payload = {
"name": "Dashboard",
"dashcards": [
{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 4},
{"card_id": 2, "row": 0, "col": 6, "size_x": 6, "size_y": 4},
],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1, 2})
assert not any("solapan" in i for i in issues), issues
def test_dashboard_col_fuera_de_bounds():
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": 1, "row": 0, "col": 25, "size_x": 1, "size_y": 1}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1})
assert any("col" in i for i in issues), issues
def test_dashboard_col_mas_size_x_excede_grid():
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": 1, "row": 0, "col": 20, "size_x": 6, "size_y": 4}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1})
assert any("24" in i for i in issues), issues
def test_dashboard_size_y_fuera_de_bounds():
payload = {
"name": "Dashboard",
"dashcards": [{"card_id": 1, "row": 0, "col": 0, "size_x": 6, "size_y": 0}],
}
issues = metabase_validate_dashboard_payload(payload, known_card_ids={1})
assert any("size_y" in i for i in issues), issues
def test_dashboard_name_ausente():
issues = metabase_validate_dashboard_payload({}, known_card_ids=set())
assert any("name" in i and "ausente" in i for i in issues), issues
def test_dashboard_tabs_invalidos():
payload = {"name": "Dashboard", "tabs": [{"name": "Tab1"}]} # falta id
issues = metabase_validate_dashboard_payload(payload, known_card_ids=set())
assert any("id" in i for i in issues), issues
def test_dashboard_parameters_no_list():
payload = {"name": "Dashboard", "parameters": "not a list"}
issues = metabase_validate_dashboard_payload(payload, known_card_ids=set())
assert any("parameters" in i for i in issues), issues
# ---------------------------------------------------------------------------
# metabase_validate_document_payload
# ---------------------------------------------------------------------------
from metabase.validation import metabase_validate_document_payload
def _base_doc(content):
return {"name": "Notas", "document": {"type": "doc", "content": content}}
def test_document_valido_minimo():
issues = metabase_validate_document_payload(_base_doc([
{"type": "paragraph", "content": [{"type": "text", "text": "hola"}]}
]))
assert issues == [], issues
def test_document_name_ausente():
issues = metabase_validate_document_payload({"document": {"type": "doc", "content": []}})
assert any("name" in i and "ausente" in i for i in issues), issues
def test_document_nodo_desconocido_callout():
issues = metabase_validate_document_payload(_base_doc([
{"type": "callout", "content": [{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}]}
]))
assert any("callout" in i and "no soportado" in i for i in issues), issues
def test_document_nodo_desconocido_taskList():
issues = metabase_validate_document_payload(_base_doc([
{"type": "taskList", "content": []}
]))
assert any("taskList" in i for i in issues), issues
def test_document_mark_desconocido_underline():
issues = metabase_validate_document_payload(_base_doc([
{"type": "paragraph", "content": [
{"type": "text", "marks": [{"type": "underline"}], "text": "x"}
]}
]))
assert any("underline" in i and "no soportado" in i for i in issues), issues
def test_document_heading_level_invalido():
issues = metabase_validate_document_payload(_base_doc([
{"type": "heading", "attrs": {"level": 9}, "content": [{"type": "text", "text": "x"}]}
]))
assert any("level" in i for i in issues), issues
def test_document_cardEmbed_sin_id_ni_slug():
issues = metabase_validate_document_payload(_base_doc([
{"type": "cardEmbed", "attrs": {}}
]))
assert any("cardEmbed" in i and "id" in i for i in issues), issues
def test_document_cardEmbed_con_id_valido():
issues = metabase_validate_document_payload(_base_doc([
{"type": "cardEmbed", "attrs": {"id": 42}}
]))
assert issues == [], issues
def test_document_cardEmbed_slug_desconocido():
issues = metabase_validate_document_payload(
_base_doc([{"type": "cardEmbed", "attrs": {"card": "inventado"}}]),
known_card_slugs={"real_one", "real_two"},
)
assert any("inventado" in i for i in issues), issues
def test_document_flexContainer_demasiados_hijos():
children = [{"type": "cardEmbed", "attrs": {"id": 1}} for _ in range(4)]
issues = metabase_validate_document_payload(_base_doc([
{"type": "flexContainer", "attrs": {"columnWidths": [25, 25, 25, 25]}, "content": children}
]))
assert any("1-3" in i for i in issues), issues
def test_document_flexContainer_hijo_invalido():
issues = metabase_validate_document_payload(_base_doc([
{"type": "flexContainer", "attrs": {"columnWidths": [100]}, "content": [
{"type": "paragraph", "content": [{"type": "text", "text": "x"}]}
]}
]))
assert any("flexContainer solo acepta" in i for i in issues), issues
def test_document_flexContainer_columnWidths_mismatch():
issues = metabase_validate_document_payload(_base_doc([
{"type": "flexContainer", "attrs": {"columnWidths": [50, 50]}, "content": [
{"type": "cardEmbed", "attrs": {"id": 1}}
]}
]))
assert any("columnWidths" in i for i in issues), issues
def test_document_vacio_string_es_valido():
issues = metabase_validate_document_payload({"name": "vacio", "document": ""})
assert issues == [], issues
def test_document_kitchen_sink_valido():
"""Simula el kitchen_sink real y debe pasar sin issues."""
content = [
{"type": "heading", "attrs": {"level": 1}, "content": [{"type": "text", "text": "T"}]},
{"type": "paragraph", "content": [
{"type": "text", "text": "a "},
{"type": "text", "marks": [{"type": "bold"}], "text": "b"},
{"type": "text", "marks": [{"type": "link", "attrs": {"href": "https://x"}}], "text": "l"},
]},
{"type": "bulletList", "content": [
{"type": "listItem", "content": [
{"type": "paragraph", "content": [{"type": "text", "text": "i"}]}
]}
]},
{"type": "blockquote", "content": [
{"type": "paragraph", "content": [{"type": "text", "text": "q"}]}
]},
{"type": "codeBlock", "attrs": {"language": "py"}, "content": [{"type": "text", "text": "x=1"}]},
{"type": "horizontalRule"},
{"type": "cardEmbed", "attrs": {"id": 1}},
{"type": "flexContainer", "attrs": {"columnWidths": [50, 50]}, "content": [
{"type": "cardEmbed", "attrs": {"id": 1}},
{"type": "cardEmbed", "attrs": {"id": 2}},
]},
]
issues = metabase_validate_document_payload(_base_doc(content))
assert issues == [], issues
+1
View File
@@ -13,6 +13,7 @@ dependencies = [
"openpyxl>=3.1.5",
"pypdf>=6.10.0",
"python-docx>=1.2.0",
"pyyaml>=6.0.3",
"xlrd>=2.0.2",
]
+48
View File
@@ -252,6 +252,7 @@ dependencies = [
{ name = "openpyxl" },
{ name = "pypdf" },
{ name = "python-docx" },
{ name = "pyyaml" },
{ name = "xlrd" },
]
@@ -270,6 +271,7 @@ requires-dist = [
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pypdf", specifier = ">=6.10.0" },
{ name = "python-docx", specifier = ">=1.2.0" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "xlrd", specifier = ">=2.0.2" },
]
@@ -865,6 +867,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.33.1"