feat: mejoras notebook functions — discover multi-servidor, write batch ops

jupyter_discover: soporte multi-servidor, detección de modo colaborativo mejorada.
jupyter_write: operaciones batch (insert, edit, delete), manejo robusto de Y.js.
jupyter_exec: mejoras en ejecución directa al kernel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 17:11:50 +02:00
parent 5a324f6554
commit af1fa129f7
7 changed files with 668 additions and 28 deletions
+231
View File
@@ -0,0 +1,231 @@
# Jupyter Notebook Tools
Cinco funciones Python para operar notebooks Jupyter programaticamente via API REST y WebSocket colaborativo (CRDT/Y.js). Reemplazan al MCP jupyter y funcionan desde cualquier directorio.
## Flujo tipico
```
discover → read → write/exec
```
1. **Descubrir** que Jupyter esta corriendo y en que puerto
2. **Leer** celdas del notebook (estado en memoria, no disco)
3. **Escribir** celdas nuevas o **ejecutar** codigo
```bash
PYTHON="python/.venv/bin/python3"
# 1. Descubrir instancias activas
$PYTHON python/functions/notebook/jupyter_discover.py --json
# 2. Leer notebook
$PYTHON python/functions/notebook/jupyter_read.py notebooks/01.ipynb --json
# 3. Crear notebook nuevo (si no existe)
$PYTHON python/functions/notebook/jupyter_write.py create notebooks/02.ipynb
# 4. Escribir celdas en batch
$PYTHON python/functions/notebook/jupyter_write.py batch notebooks/02.ipynb --from cells.json
# 5. Ejecutar celda
$PYTHON python/functions/notebook/jupyter_exec.py cell notebooks/02.ipynb 0
```
## Funciones
### jupyter_discover
Descubre instancias de Jupyter Lab activas. Escanea `.jupyter-port` en `analysis/` y puertos 8888-8892. Detecta el `root_dir` real parseando `/proc/{pid}/cmdline`.
| Subcomando | Descripcion |
|---|---|
| *(sin subcomando)* | Lista instancias con puerto, analysis, root_dir, kernels, sesiones |
```bash
$PYTHON jupyter_discover.py --json
$PYTHON jupyter_discover.py --port 8888 --port 8889
```
Retorna por instancia: `url`, `port`, `analysis`, `root_dir`, `collaborative`, `kernels`, `sessions`.
### jupyter_read
Lee celdas de un notebook via protocolo CRDT (estado en memoria, incluye cambios no guardados).
| Subcomando | Descripcion |
|---|---|
| *(sin subcomando)* | Lee todas las celdas (formato legible) |
| `--cell N` | Lee solo la celda N |
| `--info` | Solo metadata (total celdas, conteo por tipo) |
| `--json` | Salida JSON |
```bash
$PYTHON jupyter_read.py notebooks/01.ipynb --json
$PYTHON jupyter_read.py notebooks/01.ipynb --cell 3
$PYTHON jupyter_read.py notebooks/01.ipynb --info
```
### jupyter_write
Operaciones de escritura sobre celdas via WebSocket colaborativo. NO ejecuta celdas.
| Subcomando | Descripcion |
|---|---|
| `create` | Crea un notebook .ipynb nuevo (API REST PUT) |
| `append-code` | Anade celda de codigo al final |
| `append-markdown` | Anade celda markdown al final |
| `insert` | Inserta celda en posicion especifica |
| `edit` | Sobrescribe contenido de celda existente |
| `delete` | Elimina una celda |
| `batch` | Escribe multiples celdas en una sola conexion WebSocket |
```bash
# Crear notebook
$PYTHON jupyter_write.py create notebooks/02.ipynb
$PYTHON jupyter_write.py create notebooks/02.ipynb --kernel python3 --force
# Celdas individuales
$PYTHON jupyter_write.py append-code notebooks/02.ipynb "import pandas as pd"
$PYTHON jupyter_write.py append-markdown notebooks/02.ipynb "## Titulo"
$PYTHON jupyter_write.py insert notebooks/02.ipynb 0 "x = 42" --type code
$PYTHON jupyter_write.py edit notebooks/02.ipynb 0 "# Titulo actualizado"
$PYTHON jupyter_write.py delete notebooks/02.ipynb 3
# Batch (una sola conexion para N celdas)
$PYTHON jupyter_write.py batch notebooks/02.ipynb --from cells.json
cat cells.json | $PYTHON jupyter_write.py batch notebooks/02.ipynb --from -
```
Formato `cells.json`:
```json
[
{"type": "markdown", "source": "# Titulo"},
{"type": "code", "source": "import pandas as pd"},
{"type": "code", "source": "df = pd.read_csv('data.csv')"}
]
```
### jupyter_exec
Ejecuta codigo en kernels de Jupyter via WebSocket.
| Subcomando | Descripcion |
|---|---|
| `append` | Anade celda al notebook y la ejecuta |
| `cell` | Ejecuta celda existente por indice |
| `kernel` | Ejecuta en el kernel sin tocar notebook |
```bash
$PYTHON jupyter_exec.py append notebooks/01.ipynb "df.describe()"
$PYTHON jupyter_exec.py cell notebooks/01.ipynb 3
$PYTHON jupyter_exec.py kernel "print(df.shape)"
```
Normaliza automaticamente celdas sin `outputs` o `execution_count` (comun en notebooks creados programaticamente).
### jupyter_kernel
CRUD de kernels Jupyter via API REST.
| Subcomando | Descripcion |
|---|---|
| `list` | Lista kernels activos |
| `start` | Inicia kernel nuevo |
| `restart` | Reinicia kernel |
| `interrupt` | Interrumpe ejecucion |
| `shutdown` | Apaga y elimina kernel |
| `sessions` | Lista sesiones (mapeo notebook-kernel) |
```bash
$PYTHON jupyter_kernel.py list
$PYTHON jupyter_kernel.py start --name python3
$PYTHON jupyter_kernel.py restart <kernel_id>
$PYTHON jupyter_kernel.py shutdown <kernel_id>
$PYTHON jupyter_kernel.py sessions
```
## Troubleshooting
### WebSocket 4404 — notebook no existe
El servidor no puede abrir un documento CRDT para un archivo que no existe en disco.
**Solucion:** Crear el notebook primero con `jupyter_write.py create`.
```bash
$PYTHON jupyter_write.py create notebooks/nuevo.ipynb
$PYTHON jupyter_write.py append-code notebooks/nuevo.ipynb "print('hola')"
```
### KeyError 'outputs' — celda sin estructura completa
Notebooks creados manualmente (no via Jupyter UI) pueden tener celdas de codigo sin los campos `outputs` y `execution_count` que el protocolo CRDT requiere.
**Solucion:** `jupyter_exec.py` normaliza automaticamente estas celdas antes de ejecutar. Si el problema persiste, recrear la celda con `jupyter_write.py edit`.
### "Kernel does not exist" — sesion stale
El kernel referenciado ya no existe (fue apagado o el servidor se reinicio).
**Solucion:**
```bash
# Ver kernels activos
$PYTHON jupyter_kernel.py list
# Ver sesiones (mapeo notebook-kernel)
$PYTHON jupyter_kernel.py sessions
# Reiniciar kernel si necesario
$PYTHON jupyter_kernel.py restart <kernel_id>
```
### "Document not yet synced" — timing de colaboracion
El cliente WebSocket no pudo sincronizar el documento CRDT a tiempo.
**Solucion:** Reintentar. Si persiste, verificar que el servidor tiene `jupyter-collaboration` activo:
```bash
$PYTHON jupyter_discover.py --json | python3 -c "import sys,json; [print(i['collaborative']) for i in json.load(sys.stdin)]"
```
### Discover muestra analysis incorrecto
Versiones anteriores podian confundir instancias. La deteccion actual parsea `--ServerApp.root_dir` del cmdline del proceso via `/proc/{pid}/cmdline`.
**Solucion:** Actualizar a la version actual de `jupyter_discover.py`.
## Parametros comunes
Todos los subcomandos aceptan:
| Flag | Default | Descripcion |
|---|---|---|
| `--server` | `http://localhost:8888` | URL del servidor Jupyter |
| `--token` | `""` (vacio) | Token de autenticacion |
Los paths de notebooks son siempre **relativos a la raiz del servidor Jupyter** (normalmente `analysis/{tema}/`).
## Dependencias
| Paquete | Usado por | Para que |
|---|---|---|
| `jupyter_nbmodel_client` | write, exec, read | WebSocket colaborativo (CRDT/Y.js) |
| `jupyter_kernel_client` | exec | Ejecucion de codigo en kernels |
| stdlib (`urllib`, `json`) | discover, kernel, write (create) | API REST |
## Diferencias con MCP jupyter
| Aspecto | MCP jupyter | Estas funciones |
|---|---|---|
| Requiere registro | Si (.mcp.json) | No |
| Funciona desde cualquier dir | No (solo desde el dir del MCP) | Si |
| Protocolo | MCP sobre stdio | HTTP REST + WebSocket directo |
| Crear notebooks | No | Si (`write create`) |
| Batch de celdas | No | Si (`write batch`) |
| Multi-instancia | No | Si (discover detecta todas) |
## Limitaciones
- Requiere Jupyter Lab >= 4 con `jupyter-collaboration` para las funciones que usan WebSocket (write, exec, read)
- Las funciones REST (discover, kernel) funcionan con cualquier Jupyter Lab 3.x/4.x y Notebook 6.x/7.x
- La deteccion de root_dir via `/proc` solo funciona en Linux
- No soportan autenticacion por cookie, solo por token
- `jupyter_write batch` requiere JSON como entrada (no YAML)
+46 -7
View File
@@ -3,11 +3,11 @@ name: jupyter_discover
kind: function kind: function
lang: py lang: py
domain: notebook domain: notebook
version: "1.0.0" version: "1.1.0"
purity: impure purity: impure
signature: "def jupyter_discover(registry_root: str = \"\", ports: list[int] | None = None) -> list[dict]" signature: "def jupyter_discover(registry_root: str = \"\", ports: list[int] | None = None) -> list[dict]"
description: "Descubre instancias de Jupyter Lab activas escaneando archivos .jupyter-port en analysis/ y puertos comunes (8888-8892). Para cada instancia consulta /api/status, /api/config, /api/kernels y /api/sessions via HTTP REST." description: "Descubre instancias de Jupyter Lab activas escaneando archivos .jupyter-port en analysis/ y puertos comunes (8888-8892). Detecta el root_dir real de cada instancia via /proc/pid/cmdline (Linux) para identificar correctamente el analisis en escenarios multi-instancia. Para cada instancia consulta /api/status, /api/config, /api/kernels y /api/sessions via HTTP REST."
tags: [jupyter, notebook, discovery, api, http, kernels, sessions, analysis] tags: [jupyter, notebook, discovery, api, http, kernels, sessions, analysis, multi-instance, proc]
uses_functions: [] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
@@ -32,8 +32,8 @@ instances = jupyter_discover(registry_root="/home/lucas/fn_registry")
instances = jupyter_discover(ports=[8888, 8900]) instances = jupyter_discover(ports=[8888, 8900])
for inst in instances: for inst in instances:
print(inst["url"], inst["collaborative"], len(inst["kernels"])) print(inst["url"], inst["analysis"], inst["root_dir"], inst["collaborative"])
# http://localhost:8888 True 2 # http://localhost:8888 estudio_mercados /home/lucas/fn_registry/analysis/estudio_mercados True
``` ```
## Estructura del dict retornado ## Estructura del dict retornado
@@ -44,8 +44,9 @@ Cada elemento de la lista tiene la siguiente forma:
{ {
"url": "http://localhost:8888", "url": "http://localhost:8888",
"port": 8888, "port": 8888,
"analysis": "finanzas_personales", # nombre del subdirectorio en analysis/, o "" "analysis": "estudio_mercados", # nombre del subdirectorio en analysis/, detectado via /proc
"collaborative": True, # True si YDocExtension esta activo "root_dir": "/home/lucas/fn_registry/analysis/estudio_mercados", # path absoluto real del proceso
"collaborative": True, # True si YDocExtension esta activo
"kernels": [ "kernels": [
{ {
"id": "abc123...", "id": "abc123...",
@@ -77,12 +78,50 @@ python python/functions/notebook/jupyter_discover.py --port 8888 --port 8889 --j
FN_REGISTRY_ROOT=/home/lucas/fn_registry python python/functions/notebook/jupyter_discover.py FN_REGISTRY_ROOT=/home/lucas/fn_registry python python/functions/notebook/jupyter_discover.py
``` ```
Ejemplo de salida en modo texto con multi-instancia:
```
Puerto 8888 [colaborativo]
url: http://localhost:8888
analysis: estudio_mercados
root_dir: /home/lucas/fn_registry/analysis/estudio_mercados
kernels (1):
- python3 estado=idle id=abc12345...
sesiones (1):
- notebooks/01.ipynb kernel=abc12345... estado=idle
Puerto 8889 [estandar]
url: http://localhost:8889
analysis: estudio_embeddings
root_dir: /home/lucas/fn_registry/analysis/estudio_embeddings
kernels: ninguno
sesiones: ninguna
```
## Notas ## Notas
Solo usa stdlib: `urllib`, `json`, `pathlib`, `os`. No requiere `requests` ni clientes Jupyter especializados. Solo usa stdlib: `urllib`, `json`, `pathlib`, `os`. No requiere `requests` ni clientes Jupyter especializados.
El escaneo de puertos tiene un timeout de 2 segundos por instancia para no bloquear en puertos cerrados. El escaneo de puertos tiene un timeout de 2 segundos por instancia para no bloquear en puertos cerrados.
### Deteccion de root_dir via /proc
En Linux, `_find_jupyter_pid_for_port()` escanea `/proc/*/cmdline` buscando el proceso Jupyter que tenga `--port=N` o `--ServerApp.port=N` en sus argumentos. Para el puerto default 8888, acepta cualquier proceso jupyter sin `--port` explicito.
Una vez encontrado el PID, `_get_root_dir_from_proc()` lee los argumentos buscando `--ServerApp.root_dir=` o `--notebook-dir=`. Si ninguno esta presente, usa el cwd del proceso (`/proc/{pid}/cwd`) como fallback.
La funcion `_extract_analysis_name()` extrae el nombre del analisis del root_dir: si el path contiene `analysis/{nombre}`, retorna `{nombre}`; en caso contrario retorna el ultimo componente del path.
Esta cadena es mas fiable que confiar solo en `.jupyter-port` porque detecta el directorio real del proceso, no el registrado al arranque.
### Prioridad de fuentes para analysis name
1. root_dir detectado via /proc (mas fiable)
2. Hint del archivo .jupyter-port (fallback si /proc no esta disponible)
3. Cadena vacia si ninguno funciona
### Modo colaborativo
La deteccion de modo colaborativo busca `YDocExtension` o `collaborative` en el JSON de `/api/config`. Esto cubre tanto jupyter-collaboration >= 2.x (que expone la extension bajo `LabApp`) como configuraciones antiguas. La deteccion de modo colaborativo busca `YDocExtension` o `collaborative` en el JSON de `/api/config`. Esto cubre tanto jupyter-collaboration >= 2.x (que expone la extension bajo `LabApp`) como configuraciones antiguas.
Archivos `.jupyter-port`: el pipeline `init_jupyter_analysis` escribe este archivo en cada analisis al lanzar Jupyter, permitiendo que `jupyter_discover` los encuentre sin escanear todos los puertos. Archivos `.jupyter-port`: el pipeline `init_jupyter_analysis` escribe este archivo en cada analisis al lanzar Jupyter, permitiendo que `jupyter_discover` los encuentre sin escanear todos los puertos.
+132 -12
View File
@@ -30,9 +30,101 @@ def _is_collaborative(config: dict | list | None) -> bool:
return "ydocextension" in raw or "collaborative" in raw return "ydocextension" in raw or "collaborative" in raw
def _query_instance(base_url: str) -> dict | None: def _find_jupyter_pid_for_port(port: int) -> int | None:
"""Busca en /proc el PID del proceso jupyter que escucha en el puerto dado.
Solo funciona en Linux (donde /proc existe). Retorna None si no encuentra
el proceso o si /proc no esta disponible.
"""
proc_dir = Path("/proc")
if not proc_dir.is_dir():
return None
for pid_entry in proc_dir.iterdir():
if not pid_entry.name.isdigit():
continue
cmdline_path = pid_entry / "cmdline"
try:
raw = cmdline_path.read_bytes().decode("utf-8", errors="replace")
except OSError:
continue
if "jupyter" not in raw:
continue
# Los argumentos estan separados por \0 en /proc/pid/cmdline
parts = raw.split("\0")
port_found = False
for part in parts:
if part in (f"--port={port}", f"--ServerApp.port={port}"):
port_found = True
break
# Para el puerto default 8888, el proceso puede no tener --port explícito.
# En ese caso verificamos que sea un proceso jupyter y no tenga otro puerto.
if not port_found and port == 8888:
has_other_port = any(
p.startswith("--port=") or p.startswith("--ServerApp.port=")
for p in parts
)
if not has_other_port and any("jupyter" in p for p in parts):
port_found = True
if port_found:
try:
return int(pid_entry.name)
except ValueError:
continue
return None
def _get_root_dir_from_proc(pid: int) -> str:
"""Extrae el root_dir del proceso Jupyter a partir de su cmdline en /proc.
Busca --ServerApp.root_dir= o --notebook-dir= en los argumentos del proceso.
Si no los encuentra, usa el cwd del proceso como fallback.
Retorna cadena vacia si no puede leer /proc.
"""
try:
cmdline_path = f"/proc/{pid}/cmdline"
with open(cmdline_path, "rb") as f:
parts = f.read().decode("utf-8", errors="replace").split("\0")
for part in parts:
if part.startswith("--ServerApp.root_dir="):
return part.split("=", 1)[1].rstrip("/")
if part.startswith("--notebook-dir="):
return part.split("=", 1)[1].rstrip("/")
# Fallback: cwd del proceso
cwd = os.readlink(f"/proc/{pid}/cwd")
return cwd.rstrip("/")
except OSError:
return ""
def _extract_analysis_name(root_dir: str) -> str:
"""Extrae el nombre del analisis del root_dir.
Si root_dir contiene 'analysis/{nombre}', retorna '{nombre}'.
En caso contrario retorna el ultimo componente del path.
"""
if not root_dir:
return ""
parts = root_dir.replace("\\", "/").split("/")
# Buscar el segmento 'analysis' y tomar el siguiente
for i, part in enumerate(parts):
if part == "analysis" and i + 1 < len(parts) and parts[i + 1]:
return parts[i + 1]
# Fallback: ultimo componente del path
return parts[-1] if parts else ""
def _query_instance(base_url: str, port: int) -> dict | None:
"""Consulta la API REST de una instancia Jupyter y retorna su estado. """Consulta la API REST de una instancia Jupyter y retorna su estado.
Detecta el root_dir real del proceso via /proc (Linux) para identificar
correctamente el analisis que esta sirviendo.
Retorna None si la instancia no responde o no es Jupyter. Retorna None si la instancia no responde o no es Jupyter.
""" """
status = _get(f"{base_url}/api/status") status = _get(f"{base_url}/api/status")
@@ -43,6 +135,12 @@ def _query_instance(base_url: str) -> dict | None:
kernels_raw = _get(f"{base_url}/api/kernels") or [] kernels_raw = _get(f"{base_url}/api/kernels") or []
sessions_raw = _get(f"{base_url}/api/sessions") or [] sessions_raw = _get(f"{base_url}/api/sessions") or []
# Detectar root_dir via /proc
root_dir = ""
pid = _find_jupyter_pid_for_port(port)
if pid is not None:
root_dir = _get_root_dir_from_proc(pid)
kernels = [] kernels = []
if isinstance(kernels_raw, list): if isinstance(kernels_raw, list):
for k in kernels_raw: for k in kernels_raw:
@@ -67,6 +165,7 @@ def _query_instance(base_url: str) -> dict | None:
}) })
return { return {
"root_dir": root_dir,
"kernels": kernels, "kernels": kernels,
"sessions": sessions, "sessions": sessions,
"collaborative": _is_collaborative(config), "collaborative": _is_collaborative(config),
@@ -110,6 +209,10 @@ def jupyter_discover(
comunes (8888-8892). Para cada instancia que responde consulta /api/status, comunes (8888-8892). Para cada instancia que responde consulta /api/status,
/api/config, /api/kernels y /api/sessions. /api/config, /api/kernels y /api/sessions.
En Linux detecta el root_dir real del proceso Jupyter via /proc/pid/cmdline,
lo que permite identificar correctamente el analisis en escenarios
multi-instancia donde varios Jupyter corren en puertos distintos.
Args: Args:
registry_root: Raiz del fn_registry. Si vacio usa el directorio actual registry_root: Raiz del fn_registry. Si vacio usa el directorio actual
o la variable de entorno FN_REGISTRY_ROOT. o la variable de entorno FN_REGISTRY_ROOT.
@@ -117,8 +220,9 @@ def jupyter_discover(
en .jupyter-port mas los defaults (8888-8892). en .jupyter-port mas los defaults (8888-8892).
Returns: Returns:
Lista de dicts con: url, port, analysis, collaborative, kernels, sessions. Lista de dicts con: url, port, analysis, root_dir, collaborative,
Cada sesion incluye: notebook, kernel_id, kernel_state. kernels, sessions. Cada sesion incluye: notebook, kernel_id,
kernel_state.
""" """
if not registry_root: if not registry_root:
registry_root = os.environ.get("FN_REGISTRY_ROOT", "") registry_root = os.environ.get("FN_REGISTRY_ROOT", "")
@@ -139,15 +243,25 @@ def jupyter_discover(
port_analysis[p] = "" port_analysis[p] = ""
results = [] results = []
for port, analysis_name in port_analysis.items(): for port, analysis_hint in port_analysis.items():
base_url = f"http://localhost:{port}" base_url = f"http://localhost:{port}"
info = _query_instance(base_url) info = _query_instance(base_url, port)
if info is None: if info is None:
continue continue
# Determinar analysis name: preferir deteccion via /proc sobre .jupyter-port
root_dir = info["root_dir"]
if root_dir:
analysis_name = _extract_analysis_name(root_dir)
else:
# Fallback al hint del .jupyter-port
analysis_name = analysis_hint
results.append({ results.append({
"url": base_url, "url": base_url,
"port": port, "port": port,
"analysis": analysis_name, "analysis": analysis_name,
"root_dir": root_dir,
"collaborative": info["collaborative"], "collaborative": info["collaborative"],
"kernels": info["kernels"], "kernels": info["kernels"],
"sessions": info["sessions"], "sessions": info["sessions"],
@@ -201,19 +315,25 @@ if __name__ == "__main__":
sys.exit(0) sys.exit(0)
for inst in instances: for inst in instances:
label = f" analysis: {inst['analysis']}" if inst["analysis"] else ""
collab = "colaborativo" if inst["collaborative"] else "estandar" collab = "colaborativo" if inst["collaborative"] else "estandar"
print(f"Jupyter Lab en {inst['url']} [{collab}]{label}") analysis = inst["analysis"] or "(desconocido)"
root_dir = inst["root_dir"] or "(no detectado)"
print(f"Puerto {inst['port']} [{collab}]")
print(f" url: {inst['url']}")
print(f" analysis: {analysis}")
print(f" root_dir: {root_dir}")
kernel_count = len(inst["kernels"])
if inst["kernels"]: if inst["kernels"]:
print(f" Kernels ({len(inst['kernels'])}):") print(f" kernels ({kernel_count}):")
for k in inst["kernels"]: for k in inst["kernels"]:
print(f" - {k['name']} estado={k['execution_state']} id={k['id'][:8]}...") print(f" - {k['name']} estado={k['execution_state']} id={k['id'][:8]}...")
else: else:
print(" Kernels: ninguno") print(" kernels: ninguno")
if inst["sessions"]: if inst["sessions"]:
print(f" Sesiones ({len(inst['sessions'])}):") print(f" sesiones ({len(inst['sessions'])}):")
for s in inst["sessions"]: for s in inst["sessions"]:
print(f" - {s['notebook']} kernel={s['kernel_id'][:8]}... estado={s['kernel_state']}") kid = s["kernel_id"][:8] + "..." if s["kernel_id"] else "(sin kernel)"
print(f" - {s['notebook']} kernel={kid} estado={s['kernel_state']}")
else: else:
print(" Sesiones: ninguna") print(" sesiones: ninguna")
print() print()
@@ -92,3 +92,4 @@ Output siempre JSON. En error retorna `{"error": "..."}` por stderr con exit cod
- `jupyter_kernel_execute` es sincrona directamente porque `KernelClient.execute` es bloqueante. - `jupyter_kernel_execute` es sincrona directamente porque `KernelClient.execute` es bloqueante.
- El token puede ser cadena vacia si el servidor tiene autenticacion deshabilitada. - El token puede ser cadena vacia si el servidor tiene autenticacion deshabilitada.
- `NbModelClient` requiere que el servidor tenga habilitado el endpoint colaborativo (`/api/collaboration/`), disponible en JupyterLab >= 4 con `jupyter-collaboration` instalado. - `NbModelClient` requiere que el servidor tenga habilitado el endpoint colaborativo (`/api/collaboration/`), disponible en JupyterLab >= 4 con `jupyter-collaboration` instalado.
- **Fix Issue 006**: `jupyter_execute_cell` normaliza la celda antes de ejecutar. Las celdas creadas manualmente (no via la UI de Jupyter) pueden carecer de `outputs` o `execution_count` en el modelo CRDT, lo que causaba `KeyError: 'outputs'` dentro de `execute_cell` al hacer `del ycell["outputs"][:]`. El fix lee la celda con `nb[cell_index]`, detecta los campos faltantes, y reemplaza la celda via `nb[cell_index] = _normalize_code_cell(cell)` — que usa `set_cell` internamente para re-crear el mapa CRDT completo preservando el source original.
+31
View File
@@ -15,6 +15,7 @@ from urllib.request import Request, urlopen
from jupyter_kernel_client import KernelClient from jupyter_kernel_client import KernelClient
from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url
from nbformat import NotebookNode
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -60,6 +61,24 @@ def _resolve_collab_username(server_url: str, token: str) -> str:
return "Anonymous" return "Anonymous"
def _normalize_code_cell(cell: NotebookNode) -> dict:
"""Devuelve un dict de celda de codigo con todos los campos requeridos por nbformat.
Celdas creadas manualmente (no via Jupyter UI) pueden omitir 'outputs' o
'execution_count'. El modelo CRDT de jupyter_nbmodel_client accede a estos
campos sin comprobar su existencia, produciendo KeyError al ejecutar.
Este helper garantiza que el dict tenga la estructura completa.
"""
return {
"id": cell.get("id", ""),
"cell_type": "code",
"metadata": cell.get("metadata", {}),
"source": cell.get("source", ""),
"outputs": cell.get("outputs", []),
"execution_count": cell.get("execution_count", None),
}
def _extract_outputs(raw_outputs: list[dict]) -> list[str]: def _extract_outputs(raw_outputs: list[dict]) -> list[str]:
"""Convierte outputs de nbformat a lista de strings legibles.""" """Convierte outputs de nbformat a lista de strings legibles."""
result: list[str] = [] result: list[str] = []
@@ -141,6 +160,18 @@ async def _async_execute_cell(
async with NbModelClient(ws_url, username=username) as nb: async with NbModelClient(ws_url, username=username) as nb:
await nb.wait_until_synced() await nb.wait_until_synced()
# Normalizar la celda antes de ejecutar. Las celdas creadas manualmente
# (sin pasar por la UI de Jupyter) pueden carecer de los campos 'outputs'
# o 'execution_count' en el modelo CRDT, lo que provoca KeyError dentro
# de execute_cell al intentar hacer `del ycell["outputs"][:]`.
# Reemplazar la celda via __setitem__ fuerza la re-creacion completa del
# mapa CRDT con todos los campos requeridos por nbformat.
cell = nb[cell_index]
if cell.get("cell_type") == "code" and (
"outputs" not in cell or "execution_count" not in cell
):
nb[cell_index] = _normalize_code_cell(cell)
with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel: with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
result = await loop.run_in_executor( result = await loop.run_in_executor(
+51 -7
View File
@@ -3,11 +3,11 @@ name: jupyter_write
kind: function kind: function
lang: py lang: py
domain: notebook domain: notebook
version: "1.0.0" version: "1.1.0"
purity: impure purity: impure
signature: "def jupyter_append_code(notebook_path: str, source: str, server_url: str = 'http://localhost:8888', token: str = '') -> dict" signature: "def jupyter_append_code(notebook_path: str, source: str, server_url: str = 'http://localhost:8888', token: str = '') -> dict"
description: "Operaciones de escritura sobre celdas de un notebook Jupyter via colaboracion en tiempo real (WebSocket). Expone cinco operaciones: append_code, append_markdown, insert, edit, delete. NO ejecuta celdas — solo modifica la estructura del notebook." description: "Operaciones de escritura sobre celdas de un notebook Jupyter via colaboracion en tiempo real (WebSocket) y API REST. Expone siete operaciones: append_code, append_markdown, insert, edit, delete, create y batch. NO ejecuta celdas — solo modifica la estructura del notebook. create usa PUT /api/contents para crear notebooks nuevos sin necesidad de websocket. batch abre una unica conexion WebSocket para insertar N celdas en una sola operacion."
tags: [jupyter, notebook, websocket, cell, write, append, insert, edit, delete, nbmodel] tags: [jupyter, notebook, websocket, cell, write, append, insert, edit, delete, create, batch, nbmodel, rest]
uses_functions: [] uses_functions: []
uses_types: [] uses_types: []
returns: [] returns: []
@@ -29,6 +29,8 @@ file_path: "python/functions/notebook/jupyter_write.py"
| `jupyter_insert_cell(notebook_path, cell_index, source, cell_type, server_url, token)` | Inserta celda en posicion especifica | | `jupyter_insert_cell(notebook_path, cell_index, source, cell_type, server_url, token)` | Inserta celda en posicion especifica |
| `jupyter_edit_cell(notebook_path, cell_index, source, server_url, token)` | Sobrescribe contenido de celda existente | | `jupyter_edit_cell(notebook_path, cell_index, source, server_url, token)` | Sobrescribe contenido de celda existente |
| `jupyter_delete_cell(notebook_path, cell_index, server_url, token)` | Elimina una celda | | `jupyter_delete_cell(notebook_path, cell_index, server_url, token)` | Elimina una celda |
| `jupyter_create_notebook(notebook_path, kernel_name, server_url, token, force)` | Crea un notebook vacio nbformat 4 via API REST |
| `jupyter_batch_write(notebook_path, cells, server_url, token)` | Anade N celdas en una sola conexion WebSocket |
## Ejemplo ## Ejemplo
@@ -39,14 +41,38 @@ from notebook.jupyter_write import (
jupyter_insert_cell, jupyter_insert_cell,
jupyter_edit_cell, jupyter_edit_cell,
jupyter_delete_cell, jupyter_delete_cell,
jupyter_create_notebook,
jupyter_batch_write,
) )
# Crear notebook nuevo
result = jupyter_create_notebook(
notebook_path="notebooks/01_analisis.ipynb",
kernel_name="python3",
server_url="http://localhost:8888",
)
# {"action": "create", "notebook": "notebooks/01_analisis.ipynb", "created": true}
# Si ya existe, lanza FileExistsError. Usar force=True para sobreescribir:
result = jupyter_create_notebook("notebooks/01.ipynb", force=True)
# Anadir multiples celdas de golpe (una sola conexion WebSocket)
cells = [
{"type": "markdown", "source": "## Analisis inicial"},
{"type": "code", "source": "import pandas as pd"},
{"type": "code", "source": "df = pd.read_csv('data.csv')\ndf.head()"},
]
result = jupyter_batch_write(
notebook_path="notebooks/01_analisis.ipynb",
cells=cells,
server_url="http://localhost:8888",
)
# {"action": "batch", "cells_added": 3, "notebook": "notebooks/01_analisis.ipynb"}
# Anadir celda de codigo al final # Anadir celda de codigo al final
result = jupyter_append_code( result = jupyter_append_code(
notebook_path="notebooks/01_analisis.ipynb", notebook_path="notebooks/01_analisis.ipynb",
source="import pandas as pd\ndf = pd.read_csv('data.csv')", source="import pandas as pd\ndf = pd.read_csv('data.csv')",
server_url="http://localhost:8888",
token="mi-token",
) )
# {"action": "append_code", "cell_index": 5, "notebook": "notebooks/01_analisis.ipynb"} # {"action": "append_code", "cell_index": 5, "notebook": "notebooks/01_analisis.ipynb"}
@@ -55,7 +81,7 @@ result = jupyter_append_markdown(
notebook_path="notebooks/01_analisis.ipynb", notebook_path="notebooks/01_analisis.ipynb",
source="## Resultados\n\nAnalisis de los datos obtenidos.", source="## Resultados\n\nAnalisis de los datos obtenidos.",
) )
# {"action": "append_markdown", "cell_index": 6, "notebook": "notebooks/01_analisis.ipynb"} # {"action": "append_markdown", "cell_index": 6, "notebook": "..."}
# Insertar celda en posicion 2 # Insertar celda en posicion 2
result = jupyter_insert_cell( result = jupyter_insert_cell(
@@ -85,6 +111,21 @@ result = jupyter_delete_cell(
## CLI ## CLI
```bash ```bash
# Crear notebook nuevo
python -m notebook.jupyter_write create notebooks/01.ipynb
python -m notebook.jupyter_write create notebooks/01.ipynb --kernel python3
python -m notebook.jupyter_write create notebooks/01.ipynb --force
# Anadir multiples celdas desde archivo JSON
python -m notebook.jupyter_write batch notebooks/01.ipynb --from cells.json
# O via stdin
cat cells.json | python -m notebook.jupyter_write batch notebooks/01.ipynb --from -
echo '[{"type":"code","source":"import pandas"}]' | python -m notebook.jupyter_write batch notebooks/01.ipynb
# Formato JSON de entrada para batch:
# [{"type": "markdown", "source": "# Titulo"}, {"type": "code", "source": "import pandas"}]
# Anadir celda de codigo # Anadir celda de codigo
python -m notebook.jupyter_write append-code notebooks/01.ipynb "print('hola')" --server http://localhost:8888 --token mi-token python -m notebook.jupyter_write append-code notebooks/01.ipynb "print('hola')" --server http://localhost:8888 --token mi-token
@@ -104,8 +145,11 @@ python -m notebook.jupyter_write delete notebooks/01.ipynb 3
## Notas ## Notas
- Todas las funciones son sincronas publicamente. Internamente usan `asyncio.run()` sobre corutinas async que se comunican via WebSocket con `NbModelClient`. - Todas las funciones son sincronas publicamente. Internamente usan `asyncio.run()` sobre corutinas async que se comunican via WebSocket con `NbModelClient`.
- `create` es la excepcion: usa urllib (PUT /api/contents) sin WebSocket. Crea un nbformat 4 con celdas vacias. Lanza `FileExistsError` si el notebook ya existe y `force=False`.
- `batch` es mucho mas eficiente que N llamadas a `append-code`/`append-markdown`: abre una sola conexion WebSocket y hace un unico `asyncio.sleep(2)` de sincronizacion al final.
- El `notebook_path` es relativo al servidor Jupyter (no al filesystem local). - El `notebook_path` es relativo al servidor Jupyter (no al filesystem local).
- Si el servidor no esta corriendo o el token es incorrecto, lanza excepcion de conexion de `jupyter_nbmodel_client`. - Si el servidor no esta corriendo o el token es incorrecto, lanza excepcion de conexion de `jupyter_nbmodel_client`.
- NO ejecuta celdas — solo modifica la estructura. Para ejecutar, usar el MCP de Jupyter o la API REST de Jupyter. - NO ejecuta celdas — solo modifica la estructura. Para ejecutar, usar `jupyter_exec`.
- `server_url` y `token` tienen defaults convenientes para desarrollo local (`http://localhost:8888`, token vacio). - `server_url` y `token` tienen defaults convenientes para desarrollo local (`http://localhost:8888`, token vacio).
- El campo `cell_index` en el resultado refleja la posicion final de la celda en el notebook. - El campo `cell_index` en el resultado refleja la posicion final de la celda en el notebook.
- Patron tipico: `create` para crear el notebook, luego `batch` para poblar las celdas iniciales.
+176 -2
View File
@@ -1,13 +1,15 @@
"""Operaciones de escritura sobre celdas de un notebook Jupyter via colaboracion en tiempo real. """Operaciones de escritura sobre celdas de un notebook Jupyter via colaboracion en tiempo real.
NO ejecuta celdas — solo modifica la estructura del notebook (append, insert, edit, delete). NO ejecuta celdas — solo modifica la estructura del notebook (append, insert, edit, delete, create, batch).
Usa jupyter_nbmodel_client para comunicarse con el servidor Jupyter via WebSocket. Usa jupyter_nbmodel_client para comunicarse con el servidor Jupyter via WebSocket.
Para crear notebooks usa la API REST de Jupyter (urllib PUT /api/contents).
""" """
import asyncio import asyncio
import json import json
import argparse import argparse
from urllib.error import URLError import sys
from urllib.error import URLError, HTTPError
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url
@@ -130,6 +132,35 @@ async def _delete_cell(
} }
async def _batch_write(
notebook_path: str,
cells: list,
server_url: str,
token: str,
) -> dict:
"""Anade multiples celdas en una sola conexion WebSocket."""
ws_url = get_jupyter_notebook_websocket_url(
server_url=server_url,
token=token,
path=notebook_path,
)
username = _resolve_collab_username(server_url, token)
async with NbModelClient(ws_url, username=username) as nb:
for cell in cells:
cell_type = cell.get("type", "code")
source = cell.get("source", "")
if cell_type == "markdown":
nb.add_markdown_cell(source)
else:
nb.add_code_cell(source)
await asyncio.sleep(2)
return {
"action": "batch",
"cells_added": len(cells),
"notebook": notebook_path,
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# API publica sincrona # API publica sincrona
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -249,6 +280,114 @@ def jupyter_delete_cell(
) )
def jupyter_create_notebook(
notebook_path: str,
kernel_name: str = "python3",
server_url: str = "http://localhost:8888",
token: str = "",
force: bool = False,
) -> dict:
"""Crea un notebook vacio (nbformat 4) via la API REST de Jupyter.
Usa PUT /api/contents/{path} con type: notebook. Si el notebook ya existe
y force=False, lanza un error en vez de sobreescribirlo.
Args:
notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
kernel_name: Nombre del kernel a asociar (default: python3).
server_url: URL base del servidor Jupyter.
token: Token de autenticacion del servidor Jupyter.
force: Si True, sobreescribe el notebook si ya existe.
Returns:
dict con action, notebook y created.
"""
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
}
if token:
headers["Authorization"] = f"token {token}"
# Verificar si ya existe (HEAD request)
check_url = f"{server_url}/api/contents/{notebook_path}"
check_req = Request(check_url, headers=headers, method="HEAD")
already_exists = False
try:
with urlopen(check_req, timeout=5):
already_exists = True
except HTTPError as e:
if e.code != 404:
raise
except URLError:
raise
if already_exists and not force:
raise FileExistsError(
f"Notebook ya existe: {notebook_path}. Usa force=True para sobreescribir."
)
kernel_display = {
"python3": "Python 3 (ipykernel)",
"python": "Python 3",
}.get(kernel_name, kernel_name)
notebook_content = {
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {
"kernelspec": {
"name": kernel_name,
"display_name": kernel_display,
"language": "python",
},
"language_info": {
"name": "python",
},
},
"cells": [],
}
body = json.dumps({
"type": "notebook",
"content": notebook_content,
}).encode("utf-8")
put_req = Request(check_url, data=body, headers=headers, method="PUT")
with urlopen(put_req, timeout=10) as resp:
resp.read()
return {
"action": "create",
"notebook": notebook_path,
"created": True,
}
def jupyter_batch_write(
notebook_path: str,
cells: list,
server_url: str = "http://localhost:8888",
token: str = "",
) -> dict:
"""Anade multiples celdas al notebook en una sola conexion WebSocket.
Mucho mas eficiente que llamar append-code/append-markdown N veces porque
abre una unica conexion WebSocket y hace un solo sleep de sincronizacion
al final de todas las inserciones.
Args:
notebook_path: Ruta relativa al notebook dentro del servidor Jupyter.
cells: Lista de dicts con claves "type" ("code"|"markdown") y "source" (str).
server_url: URL base del servidor Jupyter.
token: Token de autenticacion del servidor Jupyter.
Returns:
dict con action, cells_added y notebook.
"""
return asyncio.run(_batch_write(notebook_path, cells, server_url, token))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CLI # CLI
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -299,6 +438,25 @@ def _build_parser() -> argparse.ArgumentParser:
p_del.add_argument("index", type=int, help="Indice de la celda (0-based)") p_del.add_argument("index", type=int, help="Indice de la celda (0-based)")
add_common(p_del) add_common(p_del)
# create
p_cr = sub.add_parser("create", help="Crea un notebook vacio via API REST")
p_cr.add_argument("notebook", help="Ruta del notebook a crear")
p_cr.add_argument("--kernel", default="python3", help="Nombre del kernel (default: python3)")
p_cr.add_argument("--force", action="store_true", help="Sobreescribir si ya existe")
add_common(p_cr)
# batch
p_bt = sub.add_parser("batch", help="Anade multiples celdas en una sola conexion WebSocket")
p_bt.add_argument("notebook", help="Ruta del notebook")
p_bt.add_argument(
"--from",
dest="cells_source",
default="-",
metavar="FILE",
help="Archivo JSON con las celdas, o '-' para leer de stdin (default: -)",
)
add_common(p_bt)
return parser return parser
@@ -318,6 +476,22 @@ def main() -> None:
result = jupyter_edit_cell(args.notebook, args.index, args.source, args.server, args.token) result = jupyter_edit_cell(args.notebook, args.index, args.source, args.server, args.token)
elif args.command == "delete": elif args.command == "delete":
result = jupyter_delete_cell(args.notebook, args.index, args.server, args.token) result = jupyter_delete_cell(args.notebook, args.index, args.server, args.token)
elif args.command == "create":
result = jupyter_create_notebook(
args.notebook,
kernel_name=args.kernel,
server_url=args.server,
token=args.token,
force=args.force,
)
elif args.command == "batch":
if args.cells_source == "-":
raw = sys.stdin.read()
else:
with open(args.cells_source, "r", encoding="utf-8") as f:
raw = f.read()
cells = json.loads(raw)
result = jupyter_batch_write(args.notebook, cells, args.server, args.token)
else: else:
parser.print_help() parser.print_help()
return return