chore: auto-commit (43 archivos)

- .mcp.json
- bash/functions/infra/write_mcp_jupyter_config.md
- bash/functions/infra/write_mcp_jupyter_config.sh
- cpp/CMakeLists.txt
- cpp/apps/chart_demo
- cpp/apps/shaders_lab
- cpp/functions/gfx/gl_framebuffer.cpp
- cpp/functions/gfx/gl_framebuffer.h
- cpp/functions/gfx/gl_framebuffer.md
- cpp/functions/gfx/mesh_gpu.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 17:28:47 +02:00
parent a2efdcf003
commit fd5787c55f
44 changed files with 3924 additions and 64 deletions
@@ -0,0 +1,80 @@
---
name: clickhouse_insert_rows
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def clickhouse_insert_rows(base_url: str, table: str, rows: list[dict], *, user: str = 'default', password: str = '', database: str = 'analytics', timeout: float = 30.0) -> int"
description: "Inserta una lista de dicts en ClickHouse via HTTP (puerto 8123) usando el formato JSONEachRow. Retorna el numero de filas enviadas."
tags: [clickhouse, analytics, http, insert, ingest, etl]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, urllib.request, urllib.parse, urllib.error]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/clickhouse_insert_rows.py"
params:
- name: base_url
desc: "URL base del servidor ClickHouse sin trailing slash. Ej: 'http://127.0.0.1:18123'. Para tunel SSH, apunta al puerto local reenviado."
- name: table
desc: "Nombre de la tabla destino, con o sin prefijo de base de datos. Ej: 'analytics.gnula_movies' o 'gnula_movies'."
- name: rows
desc: "Lista de dicts a insertar. Cada dict se serializa como una linea JSON. Las claves deben coincidir con columnas existentes; columnas ausentes usan DEFAULT."
- name: user
desc: "Usuario ClickHouse para autenticacion via header X-ClickHouse-User (default: 'default')."
- name: password
desc: "Contrasena ClickHouse para autenticacion via header X-ClickHouse-Key (default: cadena vacia)."
- name: database
desc: "Base de datos ClickHouse enviada como parametro de query (default: 'analytics')."
- name: timeout
desc: "Timeout de socket en segundos (default: 30.0)."
output: "Entero con el numero de filas insertadas (len(rows)). Retorna 0 si rows esta vacio sin contactar el servidor."
---
## Ejemplo
```python
from infra import clickhouse_insert_rows
n = clickhouse_insert_rows(
"http://127.0.0.1:18123",
"analytics.gnula_movies",
[
{
"snapshot_ts": "2026-05-30 14:00:00",
"href": "/pelicula/avatar-el-camino-del-agua",
"title": "Avatar: El camino del agua",
"year": 2022,
"flags": "es.png",
"lang_es": 1,
"status": "pending",
"in_library": 0,
"detected_at": "2026-05-30T14:00:00",
"downloaded_at": "",
}
],
user="analytics",
password="secret",
database="analytics",
)
print(f"Inserted {n} rows")
```
## Cuando usarla
Cuando un ETL empuja snapshots o eventos a ClickHouse via HTTP (puerto 8123), incluyendo a traves de un tunel SSH a un ClickHouse interno no expuesto publicamente. Alternativa ligera (solo stdlib) a `clickhouse-driver` o `clickhouse-connect` cuando no se quieren dependencias externas.
## Gotchas
- `base_url` sin trailing slash: `"http://127.0.0.1:18123"`, no `"http://127.0.0.1:18123/"`.
- Fechas y datetimes deben pasarse como strings en formato que ClickHouse acepte (`"YYYY-MM-DD HH:MM:SS"`) o como enteros epoch. El caller formatea; esta funcion no convierte tipos.
- Arrays van como listas JSON nativas Python: `{"tags": ["drama", "sci-fi"]}`.
- Columnas ausentes en un dict usan el valor DEFAULT de la tabla (JSONEachRow ignora claves faltantes). No falla.
- Para tunel SSH: `ssh -L 18123:localhost:8123 user@host` y usar `base_url="http://127.0.0.1:18123"`.
- En caso de error HTTP, `ValueError` incluye el codigo y los primeros 500 caracteres del cuerpo — util para depurar errores de schema o SQL malformado.
- Lotes grandes: no hay batching interno. Si `rows` tiene miles de elementos, el body puede ser grande. Partir en chunks desde el caller si es necesario.
@@ -0,0 +1,74 @@
"""Insert rows into ClickHouse via the HTTP interface (port 8123)."""
import json
import urllib.error
import urllib.parse
import urllib.request
def clickhouse_insert_rows(
base_url: str,
table: str,
rows: list[dict],
*,
user: str = "default",
password: str = "",
database: str = "analytics",
timeout: float = 30.0,
) -> int:
"""Insert a list of dicts into a ClickHouse table using JSONEachRow format.
Args:
base_url: ClickHouse HTTP base URL without trailing slash,
e.g. "http://127.0.0.1:18123".
table: Fully-qualified or bare table name, e.g. "analytics.gnula_movies".
rows: List of dicts to insert. Each dict becomes one JSON line.
user: ClickHouse username (default "default").
password: ClickHouse password (default empty string).
database: Target database sent as query param (default "analytics").
timeout: Socket timeout in seconds (default 30.0).
Returns:
Number of rows inserted (len(rows)). Returns 0 if rows is empty
without contacting the server.
Raises:
ValueError: On non-200 HTTP response, with status code and first
500 chars of the response body.
urllib.error.URLError: On network-level errors (connection refused,
DNS failure, timeout).
"""
if not rows:
return 0
query = f"INSERT INTO {table} FORMAT JSONEachRow"
params = urllib.parse.urlencode({"database": database, "query": query})
url = f"{base_url}/?{params}"
body = "\n".join(json.dumps(row, ensure_ascii=False) for row in rows)
body_bytes = body.encode("utf-8")
req = urllib.request.Request(
url,
data=body_bytes,
method="POST",
headers={
"Content-Type": "text/plain",
"X-ClickHouse-User": user,
"X-ClickHouse-Key": password,
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status != 200:
body_preview = resp.read(500).decode("utf-8", errors="replace")
raise ValueError(
f"ClickHouse insert failed: HTTP {resp.status}{body_preview}"
)
return len(rows)
except urllib.error.HTTPError as exc:
body_preview = exc.read(500).decode("utf-8", errors="replace")
raise ValueError(
f"ClickHouse insert failed: HTTP {exc.code}{body_preview}"
) from exc
@@ -0,0 +1,76 @@
---
name: clickhouse_query
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def clickhouse_query(base_url: str, sql: str, *, user: str = 'default', password: str = '', database: str = 'analytics', timeout: float = 30.0) -> list[dict]"
description: "Ejecuta un SQL contra ClickHouse via HTTP (puerto 8123) y retorna los resultados como lista de dicts. Para SELECT usa JSONEachRow automaticamente; para DDL/DML retorna []."
tags: [clickhouse, analytics, http, query, select, read]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, urllib.request, urllib.parse, urllib.error]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/clickhouse_query.py"
params:
- name: base_url
desc: "URL base del servidor ClickHouse sin trailing slash. Ej: 'http://127.0.0.1:18123'. Para tunel SSH, apunta al puerto local reenviado."
- name: sql
desc: "SQL completo a ejecutar. El caller escribe el SQL entero; esta funcion no anade nada. Para SELECT retorna filas; para CREATE/INSERT/etc. retorna []."
- name: user
desc: "Usuario ClickHouse para autenticacion via header X-ClickHouse-User (default: 'default')."
- name: password
desc: "Contrasena ClickHouse para autenticacion via header X-ClickHouse-Key (default: cadena vacia)."
- name: database
desc: "Base de datos ClickHouse enviada como parametro de query (default: 'analytics')."
- name: timeout
desc: "Timeout de socket en segundos (default: 30.0)."
output: "Lista de dicts, uno por fila del resultado. Lista vacia para sentencias sin resultado (DDL, INSERT). Los numeros Int64 de ClickHouse llegan como strings JSON — castear con int() si se necesita aritmetica."
---
## Ejemplo
```python
from infra import clickhouse_query
# Contar filas
rows = clickhouse_query(
"http://127.0.0.1:18123",
"SELECT count() AS c FROM analytics.gnula_movies",
user="analytics",
password="secret",
database="analytics",
)
print(int(rows[0]["c"])) # Int64 llega como string → castear
# Leer ultimas inserciones
recent = clickhouse_query(
"http://127.0.0.1:18123",
"SELECT snapshot_ts, title, status FROM analytics.gnula_movies ORDER BY snapshot_ts DESC LIMIT 5",
user="analytics",
password="secret",
)
for r in recent:
print(r["snapshot_ts"], r["title"], r["status"])
```
## Cuando usarla
Leer agregados, validar ingest o inspeccionar datos en ClickHouse via HTTP sin dependencias externas. Util en ETLs de validacion, notebooks, scripts de monitoreo y pipelines que ya usan `clickhouse_insert_rows_py_infra` para escribir y necesitan verificar el resultado. Tambien sirve para ejecutar DDL (CREATE TABLE, etc.) cuando la respuesta vacia es esperada.
## Gotchas
- `base_url` sin trailing slash: `"http://127.0.0.1:18123"`, no `"http://127.0.0.1:18123/"`.
- El caller escribe el SQL completo. Esta funcion no anade FORMAT, LIMIT ni nada — lo que se pasa es lo que se ejecuta.
- **Int64 y UInt64 de ClickHouse llegan como strings JSON** en JSONEachRow. Castear explicitamente: `int(row["c"])`. Float64 llega como numero JSON nativo.
- Para SELECT sin FORMAT explicito, `default_format=JSONEachRow` se aplica automaticamente via query param. No hace falta escribir `FORMAT JSONEachRow` en el SQL.
- DDL y DML (CREATE, INSERT, ALTER) retornan cuerpo vacio → la funcion retorna `[]`. No es un error.
- Para tunel SSH: `ssh -L 18123:localhost:8123 user@host` y usar `base_url="http://127.0.0.1:18123"`.
- En caso de error HTTP, `ValueError` incluye el codigo y los primeros 500 caracteres del cuerpo — util para depurar errores de SQL o permisos.
- Queries con resultado muy grande se leen enteras en memoria. Para resultados masivos, usar LIMIT o streaming manual con `urllib.request.urlopen` directamente.
@@ -0,0 +1,81 @@
"""Query ClickHouse via the HTTP interface (port 8123) and return rows as dicts."""
import json
import urllib.error
import urllib.parse
import urllib.request
def clickhouse_query(
base_url: str,
sql: str,
*,
user: str = "default",
password: str = "",
database: str = "analytics",
timeout: float = 30.0,
) -> list[dict]:
"""Execute a SQL statement against ClickHouse via HTTP and return results.
Args:
base_url: ClickHouse HTTP base URL without trailing slash,
e.g. "http://127.0.0.1:18123".
sql: Full SQL statement. For SELECT queries the server returns
JSONEachRow automatically via the default_format param.
For DDL/DML with no result set (CREATE, INSERT, etc.)
the response body is empty and [] is returned.
user: ClickHouse username (default "default").
password: ClickHouse password (default empty string).
database: Target database sent as query param (default "analytics").
timeout: Socket timeout in seconds (default 30.0).
Returns:
List of dicts, one per result row. Empty list for statements that
produce no result set. Numbers may come back as strings for some
ClickHouse types (e.g. Int64 is returned as a JSON string in
JSONEachRow — cast explicitly if needed: int(row["c"])).
Raises:
ValueError: On non-200 HTTP response, with status code and first
500 chars of the response body.
urllib.error.URLError: On network-level errors (connection refused,
DNS failure, timeout).
"""
params = urllib.parse.urlencode(
{"database": database, "default_format": "JSONEachRow"}
)
url = f"{base_url}/?{params}"
body_bytes = sql.encode("utf-8")
req = urllib.request.Request(
url,
data=body_bytes,
method="POST",
headers={
"Content-Type": "text/plain",
"X-ClickHouse-User": user,
"X-ClickHouse-Key": password,
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
if resp.status != 200:
body_preview = resp.read(500).decode("utf-8", errors="replace")
raise ValueError(
f"ClickHouse query failed: HTTP {resp.status}{body_preview}"
)
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
body_preview = exc.read(500).decode("utf-8", errors="replace")
raise ValueError(
f"ClickHouse query failed: HTTP {exc.code}{body_preview}"
) from exc
rows = []
for line in raw.splitlines():
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
@@ -0,0 +1,60 @@
---
name: popelis_create_user
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def popelis_create_user(base_url: str, admin_token: str, username: str, password: str, timeout: float = 30.0) -> dict"
description: "Crea un usuario en la API de administracion de Popelis (POST /api/admin/users). El backend crea automaticamente un usuario Jellyfin espejo (Modelo B). Registro CERRADO: requiere cabecera X-Admin-Token."
tags: [popelis, http, admin, user, jellyfin, infra]
params:
- name: base_url
desc: "URL base del servicio sin trailing slash. Ej: https://popelis.datardos.com"
- name: admin_token
desc: "Token de administracion. Se envia como cabecera X-Admin-Token. No logear ni exponer."
- name: username
desc: "Nombre de usuario a crear. Debe ser unico en el sistema."
- name: password
desc: "Contrasena inicial del nuevo usuario."
- name: timeout
desc: "Timeout en segundos para la peticion HTTP. Default 30.0."
output: "Dict con los datos del usuario creado: {id: N, username: str, jfUserId: str}"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "urllib.request", "urllib.error"]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/popelis_create_user.py"
---
## Ejemplo
```python
from infra.popelis_create_user import popelis_create_user
result = popelis_create_user(
base_url="https://popelis.datardos.com",
admin_token="<admin-token>",
username="alice",
password="s3cur3pass",
)
# result == {"id": 42, "username": "alice", "jfUserId": "abc123-..."}
print(result)
```
## Cuando usarla
Cuando necesites dar de alta un usuario nuevo en Popelis desde un script de administracion, pipeline de onboarding o agente. Usar ANTES de `popelis_set_password` (que requiere que el usuario ya exista).
## Gotchas
- **Token sensible**: `admin_token` es un secreto. Nunca logear el valor, interpolarlo en URLs ni persistirlo en texto plano.
- **Registro cerrado**: el endpoint rechaza cualquier peticion sin `X-Admin-Token` valido (HTTP 401/403).
- **409 si ya existe**: si el `username` ya esta registrado, el servidor devuelve HTTP 409 y la funcion lanza `ValueError`. Para idempotencia, captura el error y comprueba `"409"` en el mensaje o verifica existencia previa.
- **Jellyfin espejo**: el backend crea automaticamente un usuario Jellyfin con el mismo `username`. Si Jellyfin no esta disponible en el momento de la llamada, la creacion puede fallar en el backend (el error llega como 5xx).
- **Solo stdlib**: no requiere `requests` ni dependencias externas — usa `urllib.request`.
@@ -0,0 +1,62 @@
"""Crea un usuario en la API de administracion de Popelis."""
import json
import urllib.error
import urllib.request
def popelis_create_user(
base_url: str,
admin_token: str,
username: str,
password: str,
timeout: float = 30.0,
) -> dict:
"""Crea un usuario en Popelis via la API de administracion.
Hace POST a {base_url}/api/admin/users con el token de admin y las
credenciales del nuevo usuario. El backend crea ademas automaticamente
un usuario Jellyfin espejo (Modelo B).
Args:
base_url: URL base del servicio, sin trailing slash.
Ej: "https://popelis.datardos.com"
admin_token: Token de administracion (cabecera X-Admin-Token).
Mantenerlo en secreto — no logear.
username: Nombre de usuario a crear. Debe ser unico.
password: Contrasena inicial del usuario.
timeout: Timeout en segundos para la peticion HTTP. Default 30.0.
Returns:
Dict con los datos del usuario creado:
{"id": N, "username": "...", "jfUserId": "..."}
Raises:
ValueError: Si el servidor devuelve 4xx o 5xx. El mensaje incluye
el status code y el campo "error" del body JSON si existe.
urllib.error.URLError: Si no se puede conectar al servidor.
"""
url = f"{base_url.rstrip('/')}/api/admin/users"
payload = json.dumps({"username": username, "password": password}).encode("utf-8")
req = urllib.request.Request(
url,
data=payload,
headers={
"Content-Type": "application/json",
"X-Admin-Token": admin_token,
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
return json.loads(body)
except urllib.error.HTTPError as exc:
raw = exc.read()
try:
detail = json.loads(raw).get("error", raw.decode("utf-8", errors="replace"))
except Exception:
detail = raw.decode("utf-8", errors="replace")
raise ValueError(
f"popelis_create_user: HTTP {exc.code}{detail}"
) from exc
@@ -0,0 +1,122 @@
---
name: popelis_import_media_drop
kind: function
lang: py
domain: infra
version: 1.0.0
purity: impure
description: "Importa drops de Popelis (manual/movies, manual/tv) via Radarr/Sonarr: identifica contra TMDb/TVDB, da de alta peli/serie con metadata y mueve+renombra a la libreria limpia que ve Jellyfin."
signature: "def popelis_import_media_drop(radarr_url: str, radarr_key: str, sonarr_url: str, sonarr_key: str, movies_drop: str = '/data/manual/movies', tv_drop: str = '/data/manual/tv', movie_root: str = '/data/media/movies', tv_root: str = '/data/media/tv', quality_profile_id: int = 4, import_mode: str = 'move', dry_run: bool = False, series_refresh_wait: float = 6.0, timeout: float = 60.0) -> dict"
error_type: error_go_core
returns_optional: false
tags: [popelis, radarr, sonarr, jellyfin, media, import, mediastack, http, infra]
uses_functions: []
uses_types: []
imports: [json, re, time, urllib]
tested: false
file_path: python/functions/infra/popelis_import_media_drop.py
params:
- name: radarr_url
desc: "URL base de Radarr sin trailing slash. Ej http://localhost:7878"
- name: radarr_key
desc: "API key de Radarr (Settings > General > Security). Secreto."
- name: sonarr_url
desc: "URL base de Sonarr. Ej http://localhost:8989"
- name: sonarr_key
desc: "API key de Sonarr. Secreto."
- name: movies_drop
desc: "Carpeta drop de pelis EN EL NAMESPACE de Radarr (/data/manual/movies)."
- name: tv_drop
desc: "Carpeta drop de series EN EL NAMESPACE de Sonarr (/data/manual/tv)."
- name: movie_root
desc: "Root folder de Radarr donde se mueven las pelis (/data/media/movies)."
- name: tv_root
desc: "Root folder de Sonarr donde se mueven las series (/data/media/tv)."
- name: quality_profile_id
desc: "Quality profile id para altas nuevas. 4 = HD-1080p en el stack Popelis."
- name: import_mode
desc: "'move' (default, borra el origen) o 'copy'."
- name: dry_run
desc: "Si True no escribe nada: solo reporta que haria. Default False."
- name: series_refresh_wait
desc: "Segundos a esperar tras dar de alta una serie antes de re-listar el drop (Sonarr refresca episodios async). Default 6.0."
- name: timeout
desc: "Timeout HTTP por peticion en segundos. Default 60.0."
output: "Dict {movies: [...], tv: [...], summary: {...}}: una entrada por fichero/serie con su status (import_queued | added | no_match | still_unmatched | would_*) y contadores agregados en summary."
---
Importa una carpeta drop del stack Popelis (Radarr/Sonarr/Jellyfin) anadiendo
metadata. El usuario suelta ficheros sueltos en `manual/movies` o `manual/tv`;
esta funcion los identifica contra TMDb/TVDB, da de alta la pelicula/serie con
su ficha (poster, fanart, sinopsis) y dispara un ManualImport que mueve+renombra
el fichero a la libreria limpia (`media/movies`, `media/tv`). Radarr/Sonarr son
la unica fuente de verdad de metadata; Jellyfin solo escanea `media/` y nunca
mete fichas fantasma de descargas a medias.
## Ejemplo
```python
from infra.popelis_import_media_drop import popelis_import_media_drop
# Dry-run primero (no escribe nada)
res = popelis_import_media_drop(
radarr_url="http://localhost:7878",
radarr_key="63fb51c8c95746e2a327740baac02f5e",
sonarr_url="http://localhost:8989",
sonarr_key="1c6f380b1cca49b8b1223570e80f0071",
dry_run=True,
)
print(res["summary"])
# {'movies_queued': 1, 'movies_unmatched': 0, 'tv_queued': 0,
# 'tv_added_series': 1, 'tv_unmatched': 0, 'dry_run': True}
# Aplicar de verdad (mueve ficheros + descarga metadata)
res = popelis_import_media_drop(
radarr_url="http://localhost:7878", radarr_key="...",
sonarr_url="http://localhost:8989", sonarr_key="...",
dry_run=False,
)
```
CLI directo (lee keys de env):
```bash
RADARR_KEY=... SONARR_KEY=... \
python/.venv/bin/python3 python/functions/infra/popelis_import_media_drop.py --apply
```
## Cuando usarla
- Cuando sueltas pelis/series sueltas en `F:/POPELIS/manual/{movies,tv}` y quieres
que Radarr/Sonarr las identifiquen, descarguen ficha y las muevan limpias a la
libreria que ve Jellyfin.
- Antes de mirar Jellyfin: pasa esta funcion para que la libreria solo contenga
media identificada (sin fichas fantasma).
- Lanzala on-demand tras cada drop. Para automatizar, envuelvela en un watcher/cron.
## Gotchas
- **Paths en namespace de contenedor**: `movies_drop`/`tv_drop`/`movie_root`/`tv_root`
son paths DENTRO de Radarr/Sonarr (`/data/...`), NO del host. Radarr y Sonarr
montan `F:/POPELIS` en `/data`.
- **Permisos del drop (Windows bind)**: las carpetas drop deben ser escribibles
por el usuario del contenedor (PUID, abc/1000 en linuxserver). Si las creas como
root, `import_mode=move` falla con `UnauthorizedAccessException`. Fix:
`docker exec -u 0 radarr chown -R 1000:1000 /data/manual`.
- **Numeracion DVD vs aired**: releases en orden DVD (ej. Futurama S01 con 13 eps)
chocan con TVDB en orden emision (S01 = 9 eps). Los episodios fuera de rango
quedan `still_unmatched` con reason `Invalid season or episode` y permanecen en
el drop. No es un bug: requiere resolucion manual o cambiar el series type a
'DVD' en Sonarr.
- **Lookup coge el primer resultado**: titulos ambiguos pueden matchear mal.
Revisa el report antes de confiar; usa `dry_run=True` primero.
- **No renombra si la *arr no lo tiene activado**: el fichero se mueve a la carpeta
correcta pero conserva su nombre original salvo que actives "Rename" en la config
de naming de Radarr/Sonarr.
- **import_mode='move' borra el origen**: usa 'copy' si quieres conservar el drop.
- **Secretos**: `radarr_key`/`sonarr_key` son API keys. No logear ni interpolar en URLs.
- **Solo stdlib**: usa `urllib`, sin `requests`.
## Capability growth log
(sin cambios desde v1.0.0)
@@ -0,0 +1,257 @@
"""Importa una carpeta drop de Popelis via Radarr/Sonarr anadiendo metadata.
Escanea las carpetas drop (manual/movies, manual/tv), identifica cada
fichero contra TMDb/TVDB usando los endpoints de lookup de Radarr/Sonarr,
da de alta la pelicula/serie si no existe (descargando metadata: poster,
fanart, fichas), y dispara un ManualImport que mueve+renombra el fichero a
la libreria limpia (media/movies, media/tv). Las *arr son la unica fuente
de verdad de metadata -> Jellyfin solo escanea media/ y nunca mete fichas
fantasma de descargas a medias.
Solo stdlib (urllib). Orquestacion HTTP pura sobre las APIs v3 de
Radarr/Sonarr; la funcion NO toca ficheros (lo hace la *arr en su propio
namespace de contenedor).
"""
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
_VIDEO_RE = re.compile(r"\.(mkv|mp4|avi|m4v|mov|wmv|ts|mpg|mpeg)$", re.I)
def _req(method: str, url: str, key: str, body=None, timeout: float = 60.0):
"""HTTP request a una *arr con X-Api-Key. Lanza ValueError en 4xx/5xx."""
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(
url,
data=data,
method=method,
headers={"X-Api-Key": key, "Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")[:400]
raise ValueError(f"{method} {url} -> HTTP {exc.code}: {detail}") from exc
def _title_from_path(rel: str) -> str:
"""Deriva un termino de busqueda del path relativo de un drop.
Usa la carpeta raiz si existe (caso series: 'Futurama/Season 01/...'),
si no el nombre del fichero. Limpia separadores, sufijos SxxExx y el
anio + lo que venga detras para quedarse con el titulo limpio.
"""
seg = rel.split("/")[0] if "/" in rel else rel
base = _VIDEO_RE.sub("", seg)
base = re.sub(r"[._]", " ", base)
base = re.sub(r"\bS\d{1,2}E\d{1,2}.*$", "", base, flags=re.I).strip()
base = re.sub(r"\b(19|20)\d{2}\b.*$", "", base).strip()
return base or seg
def _import_radarr(url, key, drop, root, qpid, mode, dry, timeout):
enc = urllib.parse.quote(drop)
items = _req(
"GET",
f"{url}/api/v3/manualimport?folder={enc}&filterExistingFiles=true",
key, timeout=timeout,
) or []
existing = {m["tmdbId"]: m for m in _req("GET", f"{url}/api/v3/movie", key, timeout=timeout)}
out, files = [], []
for it in items:
movie = it.get("movie")
if not movie:
term = _title_from_path(it["relativePath"])
look = _req("GET", f"{url}/api/v3/movie/lookup?term={urllib.parse.quote(term)}", key, timeout=timeout)
if not look:
out.append({"file": it["relativePath"], "status": "no_match", "term": term})
continue
cand = look[0]
if cand["tmdbId"] in existing:
movie = existing[cand["tmdbId"]]
elif dry:
out.append({"file": it["relativePath"], "status": "would_add+import",
"match": f'{cand["title"]} ({cand.get("year")})'})
continue
else:
cand.update({
"qualityProfileId": qpid, "rootFolderPath": root,
"monitored": True, "minimumAvailability": "released",
"addOptions": {"searchForMovie": False},
})
movie = _req("POST", f"{url}/api/v3/movie", key, cand, timeout=timeout)
existing[movie["tmdbId"]] = movie
if dry:
out.append({"file": it["relativePath"], "status": "would_import",
"movie": f'{movie["title"]} ({movie.get("year")})'})
continue
files.append({
"path": it["path"], "movieId": movie["id"], "quality": it["quality"],
"languages": it.get("languages") or [{"id": 1, "name": "English"}],
})
out.append({"file": it["relativePath"], "status": "import_queued",
"movie": f'{movie["title"]} ({movie.get("year")})', "movieId": movie["id"]})
if files and not dry:
cmd = _req("POST", f"{url}/api/v3/command", key,
{"name": "ManualImport", "importMode": mode, "files": files}, timeout=timeout)
for o in out:
if o["status"] == "import_queued":
o["commandId"] = cmd["id"]
return out
def _import_sonarr(url, key, drop, root, qpid, mode, dry, refresh_wait, timeout):
enc = urllib.parse.quote(drop)
items = _req("GET", f"{url}/api/v3/manualimport?folder={enc}&filterExistingFiles=true", key, timeout=timeout) or []
existing = {s["tvdbId"]: s for s in _req("GET", f"{url}/api/v3/series", key, timeout=timeout)}
out = []
# 1. dar de alta series desconocidas
unknown = {}
for it in items:
if not it.get("series"):
unknown.setdefault(_title_from_path(it["relativePath"]), True)
added_any = False
for term in unknown:
look = _req("GET", f"{url}/api/v3/series/lookup?term={urllib.parse.quote(term)}", key, timeout=timeout)
if not look:
out.append({"series_term": term, "status": "no_match"})
continue
cand = look[0]
if cand["tvdbId"] in existing:
continue
if dry:
out.append({"series_term": term, "status": "would_add",
"match": f'{cand["title"]} ({cand.get("year")})'})
continue
for s in cand.get("seasons", []):
s["monitored"] = True
cand.update({
"qualityProfileId": qpid, "rootFolderPath": root, "monitored": True,
"addOptions": {"searchForMissingEpisodes": False, "searchForCutoffUnmetEpisodes": False},
})
srv = _req("POST", f"{url}/api/v3/series", key, cand, timeout=timeout)
existing[srv["tvdbId"]] = srv
added_any = True
out.append({"series": f'{srv["title"]} ({srv.get("year")})', "status": "added", "seriesId": srv["id"]})
if dry:
for it in items:
out.append({"file": it["relativePath"], "status": "would_import_after_add"})
return out
if added_any:
time.sleep(refresh_wait) # esperar a que Sonarr refresque la ficha de episodios
# 2. re-listar: ahora los episodios estan matcheados
items = _req("GET", f"{url}/api/v3/manualimport?folder={enc}&filterExistingFiles=true", key, timeout=timeout) or []
files = []
for it in items:
eps = it.get("episodes") or []
ser = it.get("series")
if not ser or not eps:
out.append({"file": it["relativePath"], "status": "still_unmatched",
"rej": [r.get("reason") for r in it.get("rejections", [])]})
continue
files.append({
"path": it["path"], "seriesId": ser["id"],
"episodeIds": [e["id"] for e in eps], "quality": it["quality"],
"languages": it.get("languages") or [{"id": 1, "name": "English"}],
})
out.append({"file": it["relativePath"], "status": "import_queued",
"episodes": [f'S{e.get("seasonNumber"):02d}E{e.get("episodeNumber"):02d}' for e in eps]})
if files:
cmd = _req("POST", f"{url}/api/v3/command", key,
{"name": "ManualImport", "importMode": mode, "files": files}, timeout=timeout)
for o in out:
if o.get("status") == "import_queued":
o["commandId"] = cmd["id"]
return out
def popelis_import_media_drop(
radarr_url: str,
radarr_key: str,
sonarr_url: str,
sonarr_key: str,
movies_drop: str = "/data/manual/movies",
tv_drop: str = "/data/manual/tv",
movie_root: str = "/data/media/movies",
tv_root: str = "/data/media/tv",
quality_profile_id: int = 4,
import_mode: str = "move",
dry_run: bool = False,
series_refresh_wait: float = 6.0,
timeout: float = 60.0,
) -> dict:
"""Importa los drops de pelis y series via Radarr/Sonarr con metadata.
Pelis (Radarr): para cada fichero sin match, hace lookup por titulo,
da de alta la pelicula (poster/fanart/ficha) si no existe y dispara un
ManualImport (movieId explicito) que mueve el fichero a movie_root.
Series (Sonarr): detecta series desconocidas, las da de alta, espera a
que Sonarr refresque la ficha de episodios, re-lista el drop (ahora los
SxxExx matchean) e importa cada episodio con su episodeId. Ficheros con
numeracion fuera de la ficha (ej. orden DVD vs aired) quedan reportados
como 'still_unmatched' y permanecen en el drop para resolucion manual.
Args:
radarr_url: URL base de Radarr sin trailing slash. Ej http://localhost:7878
radarr_key: API key de Radarr (Settings > General).
sonarr_url: URL base de Sonarr. Ej http://localhost:8989
sonarr_key: API key de Sonarr.
movies_drop: Carpeta drop de pelis EN EL NAMESPACE de Radarr (/data/...).
tv_drop: Carpeta drop de series EN EL NAMESPACE de Sonarr (/data/...).
movie_root: Root folder de Radarr donde se mueven las pelis.
tv_root: Root folder de Sonarr donde se mueven las series.
quality_profile_id: Quality profile para altas nuevas (4 = HD-1080p).
import_mode: 'move' (default) o 'copy'. 'move' borra el origen.
dry_run: Si True, no escribe nada: solo reporta que haria.
series_refresh_wait: Segundos a esperar tras dar de alta una serie
antes de re-listar (Sonarr necesita refrescar).
timeout: Timeout HTTP por peticion.
Returns:
Dict {"movies": [...], "tv": [...], "summary": {...}} con una entrada
por fichero/serie y su status (import_queued | added | no_match |
still_unmatched | would_*).
Raises:
ValueError: Si una peticion HTTP devuelve 4xx/5xx.
urllib.error.URLError: Si no se puede conectar a una *arr.
"""
movies = _import_radarr(radarr_url.rstrip("/"), radarr_key, movies_drop,
movie_root, quality_profile_id, import_mode, dry_run, timeout)
tv = _import_sonarr(sonarr_url.rstrip("/"), sonarr_key, tv_drop, tv_root,
quality_profile_id, import_mode, dry_run, series_refresh_wait, timeout)
summary = {
"movies_queued": sum(1 for o in movies if o["status"] in ("import_queued", "would_add+import", "would_import")),
"movies_unmatched": sum(1 for o in movies if o["status"] == "no_match"),
"tv_queued": sum(1 for o in tv if o.get("status") == "import_queued"),
"tv_added_series": sum(1 for o in tv if o.get("status") in ("added", "would_add")),
"tv_unmatched": sum(1 for o in tv if o.get("status") in ("still_unmatched", "no_match")),
"dry_run": dry_run,
}
return {"movies": movies, "tv": tv, "summary": summary}
if __name__ == "__main__":
import argparse
import os
p = argparse.ArgumentParser(description="Importa drops de Popelis via Radarr/Sonarr")
p.add_argument("--radarr-url", default=os.environ.get("RADARR_URL", "http://localhost:7878"))
p.add_argument("--radarr-key", default=os.environ.get("RADARR_KEY", ""))
p.add_argument("--sonarr-url", default=os.environ.get("SONARR_URL", "http://localhost:8989"))
p.add_argument("--sonarr-key", default=os.environ.get("SONARR_KEY", ""))
p.add_argument("--apply", action="store_true", help="Ejecuta de verdad (default dry-run)")
args = p.parse_args()
res = popelis_import_media_drop(
args.radarr_url, args.radarr_key, args.sonarr_url, args.sonarr_key,
dry_run=not args.apply,
)
print(json.dumps(res, indent=2, ensure_ascii=False))
@@ -0,0 +1,60 @@
---
name: popelis_set_password
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def popelis_set_password(base_url: str, admin_token: str, username: str, password: str, timeout: float = 30.0) -> dict"
description: "Cambia la contrasena de un usuario existente en Popelis (POST /api/admin/users/password). Devuelve 404 si el usuario no existe. Registro CERRADO: requiere cabecera X-Admin-Token."
tags: [popelis, http, admin, user, password, infra]
params:
- name: base_url
desc: "URL base del servicio sin trailing slash. Ej: https://popelis.datardos.com"
- name: admin_token
desc: "Token de administracion. Se envia como cabecera X-Admin-Token. No logear ni exponer."
- name: username
desc: "Nombre del usuario al que se le quiere cambiar la contrasena. Debe existir previamente."
- name: password
desc: "Nueva contrasena a establecer."
- name: timeout
desc: "Timeout en segundos para la peticion HTTP. Default 30.0."
output: "Dict con el resultado de la operacion: {status: 'password updated'}"
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: ["json", "urllib.request", "urllib.error"]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/popelis_set_password.py"
---
## Ejemplo
```python
from infra.popelis_set_password import popelis_set_password
result = popelis_set_password(
base_url="https://popelis.datardos.com",
admin_token="<admin-token>",
username="alice",
password="n3wpass2024",
)
# result == {"status": "password updated"}
print(result)
```
## Cuando usarla
Cuando necesites resetear o cambiar la contrasena de un usuario ya existente en Popelis desde un script de administracion o agente. Usar DESPUES de `popelis_create_user` (el usuario debe existir). Tambien util para rotacion periodica de credenciales.
## Gotchas
- **Token sensible**: `admin_token` es un secreto. Nunca logear el valor, interpolarlo en URLs ni persistirlo en texto plano.
- **Registro cerrado**: el endpoint rechaza cualquier peticion sin `X-Admin-Token` valido (HTTP 401/403).
- **404 si no existe**: si el `username` no esta registrado, el servidor devuelve HTTP 404 y la funcion lanza `ValueError` con mensaje explicito (`usuario 'X' no existe`). Verificar existencia previa o capturar el error.
- **No afecta a Jellyfin**: este endpoint cambia solo la contrasena en Popelis. Si el usuario Jellyfin espejo tiene contrasena separada, habra que gestionarla por separado via la API de Jellyfin.
- **Solo stdlib**: no requiere `requests` ni dependencias externas — usa `urllib.request`.
@@ -0,0 +1,66 @@
"""Cambia la contrasena de un usuario en la API de administracion de Popelis."""
import json
import urllib.error
import urllib.request
def popelis_set_password(
base_url: str,
admin_token: str,
username: str,
password: str,
timeout: float = 30.0,
) -> dict:
"""Cambia la contrasena de un usuario existente en Popelis.
Hace POST a {base_url}/api/admin/users/password con el token de admin,
el nombre de usuario y la nueva contrasena. Devuelve 404 si el usuario
no existe.
Args:
base_url: URL base del servicio, sin trailing slash.
Ej: "https://popelis.datardos.com"
admin_token: Token de administracion (cabecera X-Admin-Token).
Mantenerlo en secreto — no logear.
username: Nombre de usuario cuya contrasena se quiere cambiar.
password: Nueva contrasena a establecer.
timeout: Timeout en segundos para la peticion HTTP. Default 30.0.
Returns:
Dict con el resultado: {"status": "password updated"}
Raises:
ValueError: Si el servidor devuelve 4xx o 5xx (incluyendo 404 cuando
el usuario no existe). El mensaje incluye el status code
y el campo "error" del body JSON si existe.
urllib.error.URLError: Si no se puede conectar al servidor.
"""
url = f"{base_url.rstrip('/')}/api/admin/users/password"
payload = json.dumps({"username": username, "password": password}).encode("utf-8")
req = urllib.request.Request(
url,
data=payload,
headers={
"Content-Type": "application/json",
"X-Admin-Token": admin_token,
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read()
return json.loads(body)
except urllib.error.HTTPError as exc:
raw = exc.read()
try:
detail = json.loads(raw).get("error", raw.decode("utf-8", errors="replace"))
except Exception:
detail = raw.decode("utf-8", errors="replace")
if exc.code == 404:
raise ValueError(
f"popelis_set_password: usuario '{username}' no existe (HTTP 404) — {detail}"
) from exc
raise ValueError(
f"popelis_set_password: HTTP {exc.code}{detail}"
) from exc
@@ -0,0 +1,105 @@
---
name: jupyter_run_all
kind: function
lang: py
domain: notebook
version: "1.0.0"
purity: impure
signature: "jupyter_run_all(notebook_path: str, server_url: str, token: str, restart_kernel: bool, stop_on_error: bool, timeout_per_cell_s: int) -> dict"
description: "Ejecuta todas las celdas de codigo de un notebook Jupyter en orden, con reinicio opcional del kernel antes de empezar. Equivalente al boton 'Run All' del UI pero invocable desde CLI/MCP/agente. Persiste outputs a disco via REST."
tags: [jupyter, notebook, kernel, run-all, smoke-test, ci, execution, notebook]
uses_functions: [jupyter_run_cells_py_notebook]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [jupyter_kernel_client, urllib, json, time]
params:
- name: notebook_path
desc: "Ruta relativa al notebook desde la raiz del servidor Jupyter (ej: 'notebooks/analisis.ipynb')"
- name: server_url
desc: "URL base del servidor Jupyter (default http://localhost:8888)"
- name: token
desc: "Token de autenticacion del servidor. Vacio si no se requiere auth"
- name: restart_kernel
desc: "Si True, reinicia el kernel antes de ejecutar para garantizar estado limpio. Default True"
- name: stop_on_error
desc: "Si True, detiene la ejecucion cuando una celda produce error. Default True"
- name: timeout_per_cell_s
desc: "Timeout en segundos por celda. Default 600 (10 minutos)"
output: "Dict con notebook, code_cell_indices, executed (lista de resultados por celda con cell_index/execution_count/outputs/error/duration_s), stopped_at (indice de la celda donde se detuvo si hubo error), kernel_id y total_duration_s"
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/notebook/jupyter_run_all.py"
---
## Ejemplo
```bash
# CLI: ejecutar todas las celdas con kernel limpio
python -m notebook.jupyter_run_all notebooks/analisis.ipynb
# CLI: ejecutar sin reiniciar kernel y continuar si hay errores
python -m notebook.jupyter_run_all notebooks/analisis.ipynb --no-restart --continue-on-error
# CLI: salida JSON completa
python -m notebook.jupyter_run_all notebooks/analisis.ipynb --server http://localhost:8888
```
```python
# Importar y usar desde Python
import sys
sys.path.insert(0, "python/functions")
from notebook.jupyter_run_all import jupyter_run_all
result = jupyter_run_all(
notebook_path="notebooks/analisis.ipynb",
server_url="http://localhost:8888",
token="",
restart_kernel=True,
stop_on_error=True,
)
print(f"Ejecutadas: {len(result['executed'])} celdas en {result['total_duration_s']}s")
if result["stopped_at"] is not None:
print(f"ERROR en celda {result['stopped_at']}")
failed = next(e for e in result["executed"] if e["cell_index"] == result["stopped_at"])
print(failed["error"])
```
Salida ejemplo:
```json
{
"notebook": "notebooks/analisis.ipynb",
"code_cell_indices": [0, 1, 2, 4, 6],
"executed": [
{"cell_index": 0, "execution_count": 1, "outputs": ["pandas 2.2.1"], "error": null, "duration_s": 0.312},
{"cell_index": 1, "execution_count": 2, "outputs": ["(1500, 12)"], "error": null, "duration_s": 0.085}
],
"stopped_at": null,
"kernel_id": "a1b2c3d4-...",
"total_duration_s": 4.217
}
```
## Cuando usarla
Usar `jupyter_run_all` cuando necesitas:
- Smoke test de un notebook despues de cambiar dependencias (confirma que ejecuta de principio a fin).
- Validar un notebook en CI/CD o desde un agente sin abrir el UI de Jupyter Lab.
- Regenerar todos los outputs del notebook con estado limpio (restart_kernel=True).
- Detectar celdas que fallan antes de compartir o publicar el notebook.
No usar para ejecutar una sola celda — usar `jupyter_exec` (modo `cell` o `kernel`) para eso.
## Gotchas
- **Requiere sesion activa**: el kernel del notebook debe estar corriendo. Si el notebook no esta abierto en Jupyter Lab, llamar antes a `jupyter_exec` para crear la sesion, o abrir el notebook manualmente. Error: `RuntimeError: No hay sesion activa`.
- **restart_kernel=True limpia TODO el estado**: variables, imports, estado de modulos. Si el notebook depende de estado previo (interactivo), usar `restart_kernel=False`.
- **stop_on_error=True es el default**: una celda con error detiene el resto. Para runs de diagnostico donde quieres ver todos los errores, pasar `stop_on_error=False`.
- **Timeout por celda**: `timeout_per_cell_s=600` (10 min) es el maximo por celda individual. Celdas con operaciones largas (entrenamiento ML, queries pesadas) pueden necesitar valor mayor.
- **Outputs se persisten a disco**: al terminar, el notebook se guarda via REST con los nuevos outputs. Jupyter Lab puede pedir "Revert to disk" si el usuario tiene cambios no guardados en el browser.
- **Celdas vacias se saltan**: una celda de codigo cuyo `source` es solo espacios o saltos de linea se omite (execution_count queda None, outputs=[]).
- **`jupyter_run_cells` como dependencia futura**: cuando `jupyter_run_cells_py_notebook` este disponible, el batch de ejecucion puede delegarse a esa funcion. Hoy la logica es autonoma.
@@ -0,0 +1,315 @@
"""Ejecuta todas las celdas de codigo de un notebook en orden (Run All).
Equivalente al boton "Run All" del UI de Jupyter Lab, pero invocable desde
CLI/MCP/agente. Opcionalmente reinicia el kernel antes de empezar para
garantizar un estado limpio.
Depende de jupyter_run_cells cuando esta disponible; si no, ejecuta la logica
de batch internamente reutilizando los helpers de jupyter_exec.
"""
from __future__ import annotations
import json
import time
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from jupyter_kernel_client import KernelClient
# ---------------------------------------------------------------------------
# Helpers REST (minimos, alineados con jupyter_exec)
# ---------------------------------------------------------------------------
def _auth_headers(token: str, content_type: bool = False) -> dict[str, str]:
headers = {"Accept": "application/json"}
if content_type:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"token {token}"
return headers
def _api_get(url: str, token: str = "") -> dict | list | None:
try:
req = Request(url, headers=_auth_headers(token))
with urlopen(req, timeout=10) as resp:
return json.loads(resp.read())
except (URLError, OSError, json.JSONDecodeError):
return None
def _api_post(url: str, token: str = "", body: dict | None = None) -> dict | None:
data = json.dumps(body or {}).encode("utf-8")
req = Request(url, data=data, headers=_auth_headers(token, content_type=True), method="POST")
try:
with urlopen(req, timeout=30) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
except (URLError, OSError, json.JSONDecodeError):
return None
def _resolve_kernel_id(server_url: str, token: str, notebook_path: str) -> str | None:
"""Busca el kernel_id activo para el notebook via /api/sessions."""
sessions = _api_get(f"{server_url}/api/sessions", token) or []
for session in sessions:
nb = session.get("notebook", session.get("path", {}))
nb_path = nb.get("path", nb) if isinstance(nb, dict) else str(nb)
if nb_path == notebook_path:
kernel = session.get("kernel", {})
return kernel.get("id")
return None
def _get_notebook_content(notebook_path: str, server_url: str, token: str) -> dict:
"""Lee el notebook completo via /api/contents."""
url = f"{server_url}/api/contents/{notebook_path}?content=1&type=notebook"
req = Request(url, headers=_auth_headers(token))
with urlopen(req, timeout=15) as resp:
return json.loads(resp.read())
def _put_notebook_content(notebook_path: str, server_url: str, token: str, content: dict) -> None:
"""Persiste el notebook via PUT /api/contents."""
body = json.dumps({"type": "notebook", "format": "json", "content": content}).encode("utf-8")
url = f"{server_url}/api/contents/{notebook_path}"
req = Request(url, data=body, headers=_auth_headers(token, content_type=True), method="PUT")
with urlopen(req, timeout=15) as resp:
resp.read()
def _extract_outputs(raw_outputs: list[dict]) -> list[str]:
"""Convierte outputs nbformat a strings legibles."""
result: list[str] = []
for output in raw_outputs:
output_type = output.get("output_type", "")
if output_type == "stream":
text = output.get("text", "")
if isinstance(text, list):
text = "".join(text)
result.append(text.rstrip("\n"))
elif output_type in ("display_data", "execute_result"):
data = output.get("data", {})
text = data.get("text/plain", "")
if isinstance(text, list):
text = "".join(text)
result.append(text.rstrip("\n"))
elif output_type == "error":
traceback = output.get("traceback", [])
result.append("\n".join(traceback))
return result
def _kernel_outputs_to_nbformat(outputs: list[dict]) -> list[dict]:
return [dict(o) for o in outputs]
def _restart_kernel_and_wait(server_url: str, token: str, kernel_id: str, poll_timeout_s: int = 30) -> None:
"""Reinicia el kernel y espera hasta que vuelva a estado idle."""
url = f"{server_url}/api/kernels/{kernel_id}/restart"
_api_post(url, token)
deadline = time.monotonic() + poll_timeout_s
while time.monotonic() < deadline:
kernels = _api_get(f"{server_url}/api/kernels", token) or []
for k in kernels:
if k.get("id") == kernel_id:
state = k.get("execution_state", "")
if state == "idle":
return
break
time.sleep(0.5)
# ---------------------------------------------------------------------------
# API publica
# ---------------------------------------------------------------------------
def jupyter_run_all(
notebook_path: str,
server_url: str = "http://localhost:8888",
token: str = "",
restart_kernel: bool = True,
stop_on_error: bool = True,
timeout_per_cell_s: int = 600,
) -> dict[str, Any]:
"""Ejecuta todas las celdas de codigo del notebook en orden.
Equivalente al boton "Run All" del UI de Jupyter Lab. Si restart_kernel
es True, reinicia el kernel del notebook ANTES de empezar para garantizar
un estado completamente limpio (sin variables residuales de ejecuciones
anteriores).
Args:
notebook_path: Ruta relativa al notebook desde la raiz del servidor
(ej: "notebooks/analisis.ipynb").
server_url: URL base del servidor Jupyter (default http://localhost:8888).
token: Token de autenticacion. Vacio si el servidor no requiere auth.
restart_kernel: Si True, reinicia el kernel antes de empezar.
Garantiza estado limpio. Default True.
stop_on_error: Si True, detiene la ejecucion cuando una celda produce
un error (output_type == "error"). Default True.
timeout_per_cell_s: Timeout en segundos por celda. Default 600 (10 min).
Returns:
{
"notebook": str, # ruta del notebook
"code_cell_indices": [int], # indices de celdas de codigo ejecutadas
"executed": [ # resultado de cada celda ejecutada
{
"cell_index": int,
"execution_count": int | None,
"outputs": [str | dict],
"error": str | None, # mensaje del error si hubo, sino None
"duration_s": float,
}
],
"stopped_at": int | None, # cell_index donde se detuvo por error
"kernel_id": str,
"total_duration_s": float,
}
Raises:
RuntimeError: Si no existe sesion activa para el notebook (el kernel
no esta corriendo). Usar jupyter_exec para crear sesion.
HTTPError: Si el servidor Jupyter devuelve un error HTTP.
URLError: Si no se puede conectar al servidor.
"""
t0 = time.monotonic()
# 1. Obtener kernel_id del notebook
kernel_id = _resolve_kernel_id(server_url, token, notebook_path)
if not kernel_id:
raise RuntimeError(
f"No hay sesion activa para '{notebook_path}'. "
"Abre el notebook en Jupyter Lab o usa jupyter_exec para crear sesion."
)
# 2. Reiniciar kernel si se solicita
if restart_kernel:
_restart_kernel_and_wait(server_url, token, kernel_id)
# 3. Leer notebook y filtrar indices de celdas de codigo
file_node = _get_notebook_content(notebook_path, server_url, token)
nb = file_node["content"]
cells = nb.get("cells", [])
code_cell_indices = [
i for i, cell in enumerate(cells)
if cell.get("cell_type") == "code"
]
# 4. Ejecutar cada celda en orden
executed: list[dict] = []
stopped_at: int | None = None
with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
for cell_index in code_cell_indices:
cell = cells[cell_index]
source = cell.get("source", "")
if isinstance(source, list):
source = "".join(source)
# Saltar celdas vacias
if not source.strip():
executed.append({
"cell_index": cell_index,
"execution_count": None,
"outputs": [],
"error": None,
"duration_s": 0.0,
})
continue
cell_t0 = time.monotonic()
result = kernel.execute(source)
cell_duration = time.monotonic() - cell_t0
raw_outputs = result.get("outputs", [])
cell["outputs"] = _kernel_outputs_to_nbformat(raw_outputs)
cell["execution_count"] = result.get("execution_count")
# Detectar error en outputs
error_msg: str | None = None
for out in raw_outputs:
if out.get("output_type") == "error":
ename = out.get("ename", "Error")
evalue = out.get("evalue", "")
error_msg = f"{ename}: {evalue}"
break
executed.append({
"cell_index": cell_index,
"execution_count": result.get("execution_count"),
"outputs": _extract_outputs(raw_outputs),
"error": error_msg,
"duration_s": round(cell_duration, 3),
})
if error_msg and stop_on_error:
stopped_at = cell_index
break
# 5. Persistir notebook con outputs actualizados
_put_notebook_content(notebook_path, server_url, token, nb)
total_duration = time.monotonic() - t0
return {
"notebook": notebook_path,
"code_cell_indices": code_cell_indices,
"executed": executed,
"stopped_at": stopped_at,
"kernel_id": kernel_id,
"total_duration_s": round(total_duration, 3),
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import argparse
import sys
parser = argparse.ArgumentParser(
description="Ejecuta todas las celdas de codigo de un notebook en orden (Run All)"
)
parser.add_argument("notebook", help="Ruta del notebook relativa al servidor Jupyter")
parser.add_argument("--server", default="http://localhost:8888", help="URL del servidor Jupyter")
parser.add_argument("--token", default="", help="Token de autenticacion")
parser.add_argument(
"--no-restart",
action="store_true",
help="No reiniciar el kernel antes de ejecutar",
)
parser.add_argument(
"--continue-on-error",
action="store_true",
help="Continuar ejecucion aunque una celda produzca error",
)
parser.add_argument(
"--timeout",
type=int,
default=600,
help="Timeout en segundos por celda (default: 600)",
)
args = parser.parse_args()
try:
result = jupyter_run_all(
notebook_path=args.notebook,
server_url=args.server,
token=args.token,
restart_kernel=not args.no_restart,
stop_on_error=not args.continue_on_error,
timeout_per_cell_s=args.timeout,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
except Exception as exc:
print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,94 @@
---
name: jupyter_run_cells
kind: function
lang: py
domain: notebook
version: "1.0.0"
purity: impure
signature: "jupyter_run_cells(notebook_path: str, cell_indices: list[int], server_url: str, token: str, stop_on_error: bool, timeout_per_cell_s: int) -> dict"
description: "Ejecuta un lote de celdas existentes (por indice) en una sola conexion WebSocket. Un GET inicial + un PUT final. Latencia fija ~3s en vez de ~3s * N de jupyter_execute_cell individual."
tags: [jupyter, notebook, kernel, websocket, execution, cells, batch, notebook]
uses_functions: [jupyter_exec_py_notebook]
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [jupyter_kernel_client, notebook.jupyter_exec]
params:
- name: notebook_path
desc: "Ruta relativa al notebook (relativa a la raiz del servidor Jupyter)"
- name: cell_indices
desc: "Lista de indices de celdas a ejecutar (0-based, en orden). Solo celdas de tipo code."
- name: server_url
desc: "URL del servidor Jupyter (default http://localhost:8888)"
- name: token
desc: "Token de autenticacion (default vacio = sin auth)"
- name: stop_on_error
desc: "Si True, para al primer output de tipo error. El PUT se hace con lo ejecutado hasta ese punto."
- name: timeout_per_cell_s
desc: "Timeout en segundos por cada ejecucion individual de celda (default 600)"
output: "Dict con notebook, executed (lista de resultados por celda), stopped_at, kernel_id y total_duration_s"
tested: true
tests:
- "test_run_cells_single_cell_returns_output"
- "test_run_cells_stops_on_error_by_default"
- "test_run_cells_no_stop_on_error_continues"
- "test_run_cells_invalid_index_raises"
- "test_run_cells_non_code_cell_raises"
- "e2e: test_e2e_run_cells_batch"
- "e2e: test_e2e_run_cells_stop_on_error"
test_file_path: "python/functions/notebook/tests/test_jupyter_run_cells.py"
file_path: "python/functions/notebook/jupyter_run_cells.py"
---
## Ejemplo
```python
from notebook.jupyter_run_cells import jupyter_run_cells
result = jupyter_run_cells(
notebook_path="notebooks/analisis.ipynb",
cell_indices=[0, 1, 2, 5],
server_url="http://localhost:8888",
token="",
stop_on_error=True,
)
# {
# "notebook": "notebooks/analisis.ipynb",
# "executed": [
# {"cell_index": 0, "execution_count": 1, "outputs": ["import ok"], "error": None, "duration_s": 1.2},
# {"cell_index": 1, "execution_count": 2, "outputs": ["42"], "error": None, "duration_s": 0.1},
# ...
# ],
# "stopped_at": None,
# "kernel_id": "abc-123",
# "total_duration_s": 4.8,
# }
```
CLI:
```bash
# Indices como argumentos posicionales
python python/functions/notebook/jupyter_run_cells.py notebooks/analisis.ipynb 0 1 2 5
# Indices via stdin JSON (util desde scripts)
echo '[0, 1, 2, 5]' | python python/functions/notebook/jupyter_run_cells.py notebooks/analisis.ipynb
# No parar en error + timeout custom
python python/functions/notebook/jupyter_run_cells.py notebooks/analisis.ipynb 0 1 2 \
--no-stop-on-error --timeout 120
```
## Cuando usarla
Cuando necesites re-ejecutar varias celdas de un notebook existente y el overhead de abrir/cerrar N conexiones WS sea inaceptable (>3 celdas, celdas pesadas, o en pipelines automatizados). Sustituye a llamar `jupyter_execute_cell` N veces en bucle desde el agente.
## Gotchas
- Las celdas deben existir previamente en el notebook. Para anadir y ejecutar celdas nuevas, usar `jupyter_append_execute` de `jupyter_exec`.
- Solo ejecuta celdas de tipo `code`. Pasar el indice de una celda markdown lanza `ValueError` antes de abrir el WS.
- El `PUT` final se hace siempre, incluso si `stop_on_error` detiene el lote. El notebook queda con los outputs de las celdas ejecutadas hasta el punto de parada.
- `KernelClient` ignora `timeout_per_cell_s` si la implementacion subyacente no lo soporta (depende de la version de `jupyter-kernel-client`). En ese caso el timeout global del proceso es el unico limite.
- Si el servidor Jupyter no esta corriendo, `_ensure_session` lanza `URLError` inmediatamente (no hay retry incorporado).
- El WS se abre con el `kernel_id` de la sesion activa del notebook. Si el kernel muere entre el `_ensure_session` y la ejecucion, `KernelClient` lanzara una excepcion.
@@ -0,0 +1,192 @@
"""Ejecuta un lote de celdas existentes en una sola conexion WebSocket.
A diferencia de `jupyter_execute_cell` (que abre/cierra un WS por celda),
esta funcion comparte una unica sesion WS para todas las celdas del lote.
Un solo GET /api/contents al inicio + un solo PUT al final.
Latencia total: ~3s fija (overhead) + tiempo de ejecucion real de las celdas,
en lugar de ~3s * N (una conexion por celda con `jupyter_execute_cell`).
"""
import json
import time
from typing import Any
from jupyter_kernel_client import KernelClient
from notebook.jupyter_exec import (
_ensure_session,
_extract_outputs,
_get_notebook_content,
_kernel_outputs_to_nbformat,
_put_notebook_content,
)
def jupyter_run_cells(
notebook_path: str,
cell_indices: list[int],
server_url: str = "http://localhost:8888",
token: str = "",
stop_on_error: bool = True,
timeout_per_cell_s: int = 600,
) -> dict[str, Any]:
"""Ejecuta una lista de celdas existentes (por indice) en un solo paso.
Abre UNA conexion WebSocket para todo el lote. Lee el notebook una vez al
inicio y lo persiste una vez al final. Si `stop_on_error` es True y una
celda produce un output de tipo 'error', el lote para en esa celda y el
PUT se hace con los outputs ejecutados hasta ese momento.
Args:
notebook_path: Ruta relativa al notebook (relativa a la raiz del
servidor Jupyter).
cell_indices: Lista de indices de celdas a ejecutar (0-based, en
orden). Deben ser celdas de tipo 'code'.
server_url: URL del servidor Jupyter (default 'http://localhost:8888').
token: Token de autenticacion (default vacio = sin auth).
stop_on_error: Si True, para al encontrar el primer error en outputs.
timeout_per_cell_s: Timeout en segundos por cada ejecucion individual
de celda (pasado a KernelClient.execute).
Returns:
{
"notebook": str, # notebook_path recibido
"executed": [ # celdas ejecutadas (en orden)
{
"cell_index": int,
"execution_count": int | None,
"outputs": list[str], # strings legibles (via _extract_outputs)
"error": str | None, # traceback si hubo error, else None
"duration_s": float,
},
...
],
"stopped_at": int | None, # indice donde paro si stop_on_error
"kernel_id": str,
"total_duration_s": float,
}
"""
t_total_start = time.monotonic()
kernel_id = _ensure_session(server_url, token, notebook_path)
file_node = _get_notebook_content(notebook_path, server_url, token)
nb = file_node["content"]
cells = nb.get("cells", [])
# Validacion anticipada: todos los indices deben estar en rango y ser code
for idx in cell_indices:
if idx < 0 or idx >= len(cells):
raise IndexError(
f"cell_index {idx} fuera de rango (notebook tiene {len(cells)} celdas)"
)
if cells[idx].get("cell_type") != "code":
raise ValueError(
f"La celda {idx} no es de codigo (cell_type={cells[idx].get('cell_type')!r})"
)
executed: list[dict[str, Any]] = []
stopped_at: int | None = None
with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
for idx in cell_indices:
cell = cells[idx]
source = cell.get("source", "")
if isinstance(source, list):
source = "".join(source)
t_cell_start = time.monotonic()
result = kernel.execute(source, timeout=timeout_per_cell_s)
duration_s = round(time.monotonic() - t_cell_start, 3)
raw_outputs = result.get("outputs", [])
cell["outputs"] = _kernel_outputs_to_nbformat(raw_outputs)
cell["execution_count"] = result.get("execution_count")
readable_outputs = _extract_outputs(raw_outputs)
# Detectar si hubo error
error_text: str | None = None
for out in raw_outputs:
if out.get("output_type") == "error":
tb = out.get("traceback", [])
error_text = "\n".join(tb) if isinstance(tb, list) else str(tb)
break
executed.append({
"cell_index": idx,
"execution_count": result.get("execution_count"),
"outputs": readable_outputs,
"error": error_text,
"duration_s": duration_s,
})
if stop_on_error and error_text is not None:
stopped_at = idx
break
# Persiste notebook con todos los outputs actualizados de una vez
_put_notebook_content(notebook_path, server_url, token, nb)
return {
"notebook": notebook_path,
"executed": executed,
"stopped_at": stopped_at,
"kernel_id": kernel_id,
"total_duration_s": round(time.monotonic() - t_total_start, 3),
}
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import argparse
import sys
parser = argparse.ArgumentParser(
description="Ejecuta un lote de celdas en un solo paso WS"
)
parser.add_argument("notebook", help="Ruta al notebook relativa al servidor")
parser.add_argument(
"indices",
nargs="*",
type=int,
help="Indices de celdas a ejecutar (0-based). Si se omiten, lee JSON de stdin.",
)
parser.add_argument("--server", default="http://localhost:8888")
parser.add_argument("--token", default="")
parser.add_argument(
"--no-stop-on-error",
action="store_true",
help="Continuar aunque una celda emita error",
)
parser.add_argument("--timeout", type=int, default=600, help="Timeout por celda en segundos")
args = parser.parse_args()
if args.indices:
indices = args.indices
else:
raw = sys.stdin.read().strip()
indices = json.loads(raw) if raw else []
if not indices:
print(json.dumps({"error": "No se especificaron indices de celdas"}), file=sys.stderr)
sys.exit(1)
try:
result = jupyter_run_cells(
notebook_path=args.notebook,
cell_indices=indices,
server_url=args.server,
token=args.token,
stop_on_error=not args.no_stop_on_error,
timeout_per_cell_s=args.timeout,
)
print(json.dumps(result, ensure_ascii=False, indent=2))
except Exception as exc:
print(json.dumps({"error": str(exc)}, ensure_ascii=False), file=sys.stderr)
sys.exit(1)
@@ -0,0 +1,319 @@
"""Tests para jupyter_run_cells.
Cubre:
- Validacion de indices (rango y tipo de celda).
- Comportamiento stop_on_error (True/False).
- E2E contra un Jupyter Lab vivo si esta disponible (skip si no).
"""
from __future__ import annotations
import json
import os
import socket
import subprocess
import sys
import time
import urllib.request
from pathlib import Path
from unittest.mock import MagicMock, call, patch
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
from python.functions.notebook import jupyter_exec as jx
from python.functions.notebook import jupyter_run_cells as jrc
# ---------------------------------------------------------------------------
# Helpers para mocks
# ---------------------------------------------------------------------------
def _make_nb(cells: list[dict]) -> dict:
"""Construye un dict de notebook minimo con las celdas dadas."""
return {
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {},
"cells": cells,
}
def _code_cell(source: str) -> dict:
return {
"cell_type": "code",
"source": source,
"outputs": [],
"execution_count": None,
"metadata": {},
"id": "test-id",
}
def _markdown_cell(source: str) -> dict:
return {
"cell_type": "markdown",
"source": source,
"metadata": {},
"id": "md-id",
}
def _kernel_result(outputs: list[dict], execution_count: int = 1) -> dict:
return {"outputs": outputs, "execution_count": execution_count, "status": "ok"}
def _stream_output(text: str) -> dict:
return {"output_type": "stream", "name": "stdout", "text": text}
def _error_output(ename: str = "ValueError", traceback: list[str] | None = None) -> dict:
return {
"output_type": "error",
"ename": ename,
"evalue": "bad value",
"traceback": traceback or [f"{ename}: bad value"],
}
class _FakeKernel:
"""Simula KernelClient con una lista de resultados predefinidos."""
def __init__(self, results: list[dict]):
self._results = iter(results)
def execute(self, source: str, **kwargs) -> dict:
return next(self._results)
def __enter__(self):
return self
def __exit__(self, *args):
pass
# ---------------------------------------------------------------------------
# Tests unitarios
# ---------------------------------------------------------------------------
def _patch_infra(nb: dict, kernel: _FakeKernel):
"""Context managers que parchean _ensure_session, _get/put_notebook y KernelClient."""
file_node = {"content": nb}
return [
patch.object(jrc, "_ensure_session", return_value="kernel-abc"),
patch.object(jrc, "_get_notebook_content", return_value=file_node),
patch.object(jrc, "_put_notebook_content"),
patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=kernel),
]
def test_run_cells_single_cell_returns_output():
nb = _make_nb([_code_cell("print(42)")])
fake_kernel = _FakeKernel([_kernel_result([_stream_output("42\n")], execution_count=1)])
with (
patch.object(jrc, "_ensure_session", return_value="k1"),
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
patch.object(jrc, "_put_notebook_content") as mock_put,
patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=fake_kernel),
):
result = jrc.jupyter_run_cells(
"nb.ipynb", [0], server_url="http://localhost:8888", token=""
)
assert result["kernel_id"] == "k1"
assert result["stopped_at"] is None
assert len(result["executed"]) == 1
entry = result["executed"][0]
assert entry["cell_index"] == 0
assert entry["execution_count"] == 1
assert entry["outputs"] == ["42"]
assert entry["error"] is None
assert entry["duration_s"] >= 0
mock_put.assert_called_once()
def test_run_cells_stops_on_error_by_default():
nb = _make_nb([_code_cell("x=1"), _code_cell("bad"), _code_cell("ok")])
fake_kernel = _FakeKernel([
_kernel_result([_stream_output("1")], 1),
_kernel_result([_error_output("RuntimeError", ["RuntimeError: bad"])], 2),
# la celda 2 nunca deberia ejecutarse
])
with (
patch.object(jrc, "_ensure_session", return_value="k2"),
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
patch.object(jrc, "_put_notebook_content"),
patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=fake_kernel),
):
result = jrc.jupyter_run_cells("nb.ipynb", [0, 1, 2], stop_on_error=True)
assert result["stopped_at"] == 1
assert len(result["executed"]) == 2
assert result["executed"][1]["error"] is not None
assert "RuntimeError" in result["executed"][1]["error"]
def test_run_cells_no_stop_on_error_continues():
nb = _make_nb([_code_cell("bad"), _code_cell("print('after')")])
fake_kernel = _FakeKernel([
_kernel_result([_error_output()], 1),
_kernel_result([_stream_output("after")], 2),
])
with (
patch.object(jrc, "_ensure_session", return_value="k3"),
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
patch.object(jrc, "_put_notebook_content"),
patch("python.functions.notebook.jupyter_run_cells.KernelClient", return_value=fake_kernel),
):
result = jrc.jupyter_run_cells("nb.ipynb", [0, 1], stop_on_error=False)
assert result["stopped_at"] is None
assert len(result["executed"]) == 2
assert result["executed"][0]["error"] is not None
assert result["executed"][1]["outputs"] == ["after"]
def test_run_cells_invalid_index_raises():
nb = _make_nb([_code_cell("x=1")])
with (
patch.object(jrc, "_ensure_session", return_value="k4"),
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
patch.object(jrc, "_put_notebook_content"),
patch("python.functions.notebook.jupyter_run_cells.KernelClient"),
):
with pytest.raises(IndexError, match="fuera de rango"):
jrc.jupyter_run_cells("nb.ipynb", [5])
def test_run_cells_non_code_cell_raises():
nb = _make_nb([_markdown_cell("# titulo")])
with (
patch.object(jrc, "_ensure_session", return_value="k5"),
patch.object(jrc, "_get_notebook_content", return_value={"content": nb}),
patch.object(jrc, "_put_notebook_content"),
patch("python.functions.notebook.jupyter_run_cells.KernelClient"),
):
with pytest.raises(ValueError, match="no es de codigo"):
jrc.jupyter_run_cells("nb.ipynb", [0])
# ---------------------------------------------------------------------------
# E2E (requiere Jupyter Lab corriendo)
# ---------------------------------------------------------------------------
JUPYTER_VENV_BIN = Path("/home/lucas/fn_registry/analysis/pruebas_jupyter/.venv/bin")
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _wait_http(url: str, timeout: float = 10.0) -> bool:
end = time.time() + timeout
while time.time() < end:
try:
with urllib.request.urlopen(url, timeout=1):
return True
except OSError:
time.sleep(0.3)
return False
@pytest.fixture(scope="module")
def jupyter_server(tmp_path_factory):
"""Arranca un Jupyter Lab en puerto libre. Skip si las deps no estan."""
if not (JUPYTER_VENV_BIN / "jupyter-lab").exists():
pytest.skip("Jupyter Lab no disponible en pruebas_jupyter venv")
workdir = tmp_path_factory.mktemp("jupyter_run_cells_e2e")
(workdir / "notebooks").mkdir()
port = _free_port()
proc = subprocess.Popen(
[
str(JUPYTER_VENV_BIN / "jupyter-lab"),
f"--port={port}",
"--no-browser",
"--ServerApp.token=",
"--ServerApp.password=",
"--ServerApp.disable_check_xsrf=True",
f"--ServerApp.root_dir={workdir}",
"--collaborative",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
server_url = f"http://localhost:{port}"
if not _wait_http(f"{server_url}/api"):
proc.terminate()
pytest.skip("Jupyter Lab no levanto a tiempo")
yield server_url, workdir
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
def test_e2e_run_cells_batch(jupyter_server):
"""Ejecuta 3 celdas en lote y verifica outputs y persistence."""
server_url, workdir = jupyter_server
# Prepara el notebook con 3 celdas via jupyter_exec
jx.jupyter_append_execute("notebooks/batch.ipynb", "x = 10", server_url=server_url)
jx.jupyter_append_execute("notebooks/batch.ipynb", "y = x * 3", server_url=server_url)
jx.jupyter_append_execute("notebooks/batch.ipynb", "print(x + y)", server_url=server_url)
# Ejecuta el lote
result = jrc.jupyter_run_cells(
"notebooks/batch.ipynb",
[0, 1, 2],
server_url=server_url,
)
assert result["stopped_at"] is None
assert len(result["executed"]) == 3
assert result["executed"][2]["outputs"] == ["40"]
assert result["total_duration_s"] > 0
assert result["kernel_id"] != ""
# Verifica persistencia en disco
nb = json.loads((workdir / "notebooks" / "batch.ipynb").read_text())
assert nb["cells"][2]["execution_count"] is not None
def test_e2e_run_cells_stop_on_error(jupyter_server):
"""Verifica que stop_on_error detiene el lote en la celda con error."""
server_url, workdir = jupyter_server
jx.jupyter_append_execute("notebooks/stopper.ipynb", "a = 1", server_url=server_url)
jx.jupyter_append_execute("notebooks/stopper.ipynb", "raise ValueError('boom')", server_url=server_url)
jx.jupyter_append_execute("notebooks/stopper.ipynb", "print('no llego')", server_url=server_url)
result = jrc.jupyter_run_cells(
"notebooks/stopper.ipynb",
[0, 1, 2],
server_url=server_url,
stop_on_error=True,
)
assert result["stopped_at"] == 1
assert len(result["executed"]) == 2
assert result["executed"][1]["error"] is not None
assert "ValueError" in result["executed"][1]["error"]
# La celda 2 no debe aparecer en executed
assert all(e["cell_index"] != 2 for e in result["executed"])
@@ -0,0 +1,91 @@
---
name: extract_hls_from_cdp_tab
kind: function
lang: py
domain: pipelines
version: "1.0.0"
purity: impure
signature: "def extract_hls_from_cdp_tab(debug_port: int = 9222, url_substring: str | None = None, live_capture_s: float = 6.0, timeout_s: float = 15.0) -> dict"
description: "Extrae URLs de manifiestos HLS (master.m3u8, index*.m3u8) de todas las pestañas e iframes de Chrome via CDP. Combina performance.getEntriesByType('resource') con escucha de eventos Network en vivo para capturar manifests ya cargados y los que se piden tras conectar."
tags: [navegator, cdp, hls, m3u8, scraping]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [json, re, sys, os, time, urllib.request, threading, websocket]
params:
- name: debug_port
desc: "Puerto de remote debugging de Chrome (default 9222). Chrome debe lanzarse con --remote-debugging-port=9222."
- name: url_substring
desc: "Si se especifica, solo inspecciona targets (pestañas/iframes) cuyo URL contiene este substring. Ej: 'luluvdo.com'. Si None, inspecciona todos."
- name: live_capture_s
desc: "Segundos de escucha de eventos Network.requestWillBeSent/responseReceived en vivo por target (default 6.0). Caza manifests que se piden despues de conectar."
- name: timeout_s
desc: "Timeout en segundos para conexion WebSocket y operaciones recv por target (default 15.0)."
output: "dict {status: 'ok'|'error', targets: [{url, masters, variants}], all_m3u8: [lista plana deduplicada], error: str}. Si no hay m3u8: status ok, listas vacias."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/pipelines/extract_hls_from_cdp_tab.py"
---
## Ejemplo
```python
import sys
sys.path.insert(0, "python/functions")
from pipelines.extract_hls_from_cdp_tab import extract_hls_from_cdp_tab
# Chrome lanzado con --remote-debugging-port=9222 --remote-allow-origins=*
# reproduciendo un video en luluvdo.com (o cualquier player hls.js)
r = extract_hls_from_cdp_tab(debug_port=9222, url_substring="luluvdo.com")
print(r["all_m3u8"])
# ['https://cdn.luluvdo.com/.../urlset/master.m3u8?e=28800&t=abc123']
# Sin filtro: escanea todas las pestanas e iframes
r = extract_hls_from_cdp_tab(debug_port=9222)
for target in r["targets"]:
if target["masters"]:
print(f"Tab: {target['url']}")
print(f" Master: {target['masters']}")
print(f" Variantes: {target['variants']}")
```
Lanzar Chrome con las flags necesarias:
```bash
# Desde WSL (Chrome Windows)
/mnt/c/Program\ Files/Google/Chrome/Application/chrome.exe \
--remote-debugging-port=9222 \
--remote-allow-origins="*" \
--user-data-dir=/tmp/chrome_cdp_profile
```
Con proxy NordVPN (para sitios bloqueados por ISP):
```bash
# 1. Levantar bridge
source bash/functions/infra/start_nordvpn_socks_bridge.sh
start_nordvpn_socks_bridge --port 8889
# 2. Lanzar Chrome con proxy
chrome.exe --proxy-server=http://127.0.0.1:8889 --remote-debugging-port=9222 --remote-allow-origins="*"
# 3. Extraer HLS
r = extract_hls_from_cdp_tab(9222)
# 4. Descargar con el mismo proxy
# yt-dlp --proxy http://127.0.0.1:8889 --referer https://luluvdo.com/ r["all_m3u8"][0]
```
## Cuando usarla
Cuando un video se esta reproduciendo en Chrome controlado por CDP y quieres la URL del HLS (master.m3u8) sin reversear el player. Funciona con luluvdo.com, streamwish, filemoon y cualquier player hls.js porque lee lo que el navegador YA pidio al CDN, sin tocar codigo ofuscado. El player ya descifro/cargo el manifest — tu solo lo recoges.
Combina con `start_nordvpn_socks_bridge_bash_infra` cuando el sitio esta bloqueado por ISP: Chrome con --proxy-server apunta al bridge local.
## Gotchas
- **`--remote-allow-origins=*` OBLIGATORIO**: Chrome rechaza conexiones CDP con 403 si falta este flag (o el origin concreto). El header `Origin: http://localhost` en `create_connection` tambien es necesario por el mismo motivo.
- **El `<video>` tiene src `blob:`** (hls.js demuxa en memoria) — NO sirve para obtener el manifest. El URL real solo aparece en performance entries o en eventos Network.
- **El player suele vivir en un iframe**, no en la pestana top — la funcion escanea TODOS los targets para no perderlo.
- **El token del master.m3u8 caduca** (ej. `e=28800` = ~8h) y suele estar ligado a la IP de salida. Descargar desde la MISMA IP y con el mismo proxy. Añadir el Referer del host del player: `yt-dlp --proxy <proxy> --referer https://<host>/ <master.m3u8>`.
- **websocket-client** debe estar en el venv: `uv pip install websocket-client`. Si falta, el error indica el comando exacto.
- **live_capture_s=0** desactiva la escucha en vivo (solo lee performance entries). Util si el video ya esta cargado y quieres rapidez.
- Si `url_substring` es demasiado especifico y el iframe tiene otro dominio (CDN), no pasara el filtro. Usar None para ver todos los targets y luego ajustar.
@@ -0,0 +1,258 @@
"""Extrae URLs de manifiestos HLS (master.m3u8, index*.m3u8) de tabs Chrome via CDP."""
import json
import re
import sys
import os
import time
import urllib.request
import urllib.error
import threading
HLS_PATTERN = re.compile(r'\.m3u8', re.IGNORECASE)
DATA_URL_PATTERN = re.compile(r'^data:', re.IGNORECASE)
def _get_targets(debug_port: int) -> list:
"""Obtiene la lista de targets via /json/list."""
url = f"http://127.0.0.1:{debug_port}/json/list"
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read())
except Exception as e:
raise RuntimeError(f"No se pudo conectar a Chrome en {debug_port}: {e}. "
"Asegurate de que Chrome esta corriendo con --remote-debugging-port.") from e
def _send_recv(ws, msg_id: int, method: str, params: dict, timeout: float = 10.0) -> dict:
"""Envía un mensaje CDP y espera la respuesta con el mismo id, ignorando eventos."""
import websocket as ws_module
payload = json.dumps({"id": msg_id, "method": method, "params": params})
ws.send(payload)
deadline = time.time() + timeout
while time.time() < deadline:
remaining = deadline - time.time()
if remaining <= 0:
break
ws.sock.settimeout(min(remaining, 1.0))
try:
raw = ws.recv()
except ws_module.WebSocketTimeoutException:
continue
except Exception:
break
try:
msg = json.loads(raw)
except Exception:
continue
if msg.get("id") == msg_id:
return msg
return {}
def _collect_from_target(ws_url: str, timeout_s: float, live_capture_s: float) -> dict:
"""
Conecta a un target CDP y extrae URLs m3u8 via:
- Method A: performance.getEntriesByType('resource')
- Method B: Network events en vivo durante live_capture_s segundos
"""
try:
import websocket as ws_module
except ImportError as e:
raise ImportError(
"websocket-client no esta instalado. "
"Instalar con: uv pip install websocket-client"
) from e
masters: list[str] = []
variants: list[str] = []
seen: set[str] = set()
def classify(url: str) -> None:
if not url or DATA_URL_PATTERN.match(url):
return
if not HLS_PATTERN.search(url):
return
if url in seen:
return
seen.add(url)
# "master" en el path o sin segmento de variante = master manifest
if re.search(r'master', url, re.IGNORECASE):
masters.append(url)
else:
variants.append(url)
try:
ws = ws_module.create_connection(
ws_url,
timeout=timeout_s,
header=["Origin: http://localhost"],
)
except Exception as e:
return {"error": str(e), "masters": [], "variants": []}
try:
msg_id = 1
# Method A: performance entries ya acumuladas
result = _send_recv(ws, msg_id, "Runtime.evaluate", {
"expression": (
"JSON.stringify("
" performance.getEntriesByType('resource')"
" .map(e => e.name)"
" .filter(n => /\\.m3u8/i.test(n))"
")"
),
"returnByValue": True,
}, timeout=timeout_s)
msg_id += 1
if result and not result.get("error"):
try:
val = result.get("result", {}).get("result", {}).get("value", "[]")
for url in json.loads(val):
classify(url)
except Exception:
pass
# Method B: escucha Network events en vivo
_send_recv(ws, msg_id, "Network.enable", {}, timeout=timeout_s)
msg_id += 1
deadline = time.time() + live_capture_s
while time.time() < deadline:
remaining = deadline - time.time()
if remaining <= 0:
break
ws.sock.settimeout(min(remaining, 0.5))
try:
raw = ws.recv()
except ws_module.WebSocketTimeoutException:
continue
except Exception:
break
try:
msg = json.loads(raw)
except Exception:
continue
method = msg.get("method", "")
params = msg.get("params", {})
if method == "Network.requestWillBeSent":
classify(params.get("request", {}).get("url", ""))
elif method == "Network.responseReceived":
classify(params.get("response", {}).get("url", ""))
finally:
try:
ws.close()
except Exception:
pass
return {"error": "", "masters": masters, "variants": variants}
def extract_hls_from_cdp_tab(
debug_port: int = 9222,
url_substring: str | None = None,
live_capture_s: float = 6.0,
timeout_s: float = 15.0,
) -> dict:
"""
Extrae URLs de manifiestos HLS (.m3u8) de todas las pestañas e iframes
de un Chrome con remote debugging activo.
Args:
debug_port: Puerto de remote debugging de Chrome (default 9222).
url_substring: Si se especifica, solo inspecciona targets cuyo URL contiene este substring.
live_capture_s: Segundos de escucha de eventos Network en vivo por target.
timeout_s: Timeout de conexion websocket y recv por target.
Returns:
dict con status, targets, all_m3u8 (lista plana deduplicada), error.
"""
# Verificar que websocket-client esta disponible antes de empezar
try:
import websocket # noqa: F401
except ImportError as e:
return {
"status": "error",
"targets": [],
"all_m3u8": [],
"error": (
"websocket-client no esta instalado. "
"Instalar con: uv pip install websocket-client"
),
}
# 1. Obtener lista de targets
try:
all_targets = _get_targets(debug_port)
except RuntimeError as e:
return {
"status": "error",
"targets": [],
"all_m3u8": [],
"error": str(e),
}
# 2. Filtrar targets validos (page o iframe con wsUrl)
candidates = []
for t in all_targets:
t_type = t.get("type", "")
ws_url = t.get("webSocketDebuggerUrl", "")
t_url = t.get("url", "")
if t_type not in ("page", "iframe"):
continue
if not ws_url:
continue
if url_substring and url_substring not in t_url:
continue
candidates.append(t)
# 3. Inspeccionar cada target
results = []
all_m3u8_set: set[str] = set()
for t in candidates:
ws_url = t["webSocketDebuggerUrl"]
t_url = t.get("url", "")
data = _collect_from_target(ws_url, timeout_s=timeout_s, live_capture_s=live_capture_s)
for u in data["masters"] + data["variants"]:
all_m3u8_set.add(u)
results.append({
"url": t_url,
"masters": data["masters"],
"variants": data["variants"],
})
return {
"status": "ok",
"targets": results,
"all_m3u8": sorted(all_m3u8_set),
"error": "",
}
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Extrae URLs HLS de Chrome via CDP")
parser.add_argument("--debug-port", type=int, default=9222)
parser.add_argument("--url-substring", type=str, default=None)
parser.add_argument("--live-capture-s", type=float, default=6.0)
parser.add_argument("--timeout-s", type=float, default=15.0)
args = parser.parse_args()
result = extract_hls_from_cdp_tab(
debug_port=args.debug_port,
url_substring=args.url_substring,
live_capture_s=args.live_capture_s,
timeout_s=args.timeout_s,
)
print(json.dumps(result, indent=2))