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:
@@ -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)
|
||||
@@ -3,11 +3,11 @@ name: jupyter_discover
|
||||
kind: function
|
||||
lang: py
|
||||
domain: notebook
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
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."
|
||||
tags: [jupyter, notebook, discovery, api, http, kernels, sessions, analysis]
|
||||
description: "Descubre instancias de Jupyter Lab activas escaneando archivos .jupyter-port en analysis/ y puertos comunes (8888-8892). Detecta el root_dir real de cada instancia via /proc/pid/cmdline (Linux) para identificar correctamente el analisis en escenarios multi-instancia. Para cada instancia consulta /api/status, /api/config, /api/kernels y /api/sessions via HTTP REST."
|
||||
tags: [jupyter, notebook, discovery, api, http, kernels, sessions, analysis, multi-instance, proc]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -32,8 +32,8 @@ instances = jupyter_discover(registry_root="/home/lucas/fn_registry")
|
||||
instances = jupyter_discover(ports=[8888, 8900])
|
||||
|
||||
for inst in instances:
|
||||
print(inst["url"], inst["collaborative"], len(inst["kernels"]))
|
||||
# http://localhost:8888 True 2
|
||||
print(inst["url"], inst["analysis"], inst["root_dir"], inst["collaborative"])
|
||||
# http://localhost:8888 estudio_mercados /home/lucas/fn_registry/analysis/estudio_mercados True
|
||||
```
|
||||
|
||||
## Estructura del dict retornado
|
||||
@@ -44,8 +44,9 @@ Cada elemento de la lista tiene la siguiente forma:
|
||||
{
|
||||
"url": "http://localhost:8888",
|
||||
"port": 8888,
|
||||
"analysis": "finanzas_personales", # nombre del subdirectorio en analysis/, o ""
|
||||
"collaborative": True, # True si YDocExtension esta activo
|
||||
"analysis": "estudio_mercados", # nombre del subdirectorio en analysis/, detectado via /proc
|
||||
"root_dir": "/home/lucas/fn_registry/analysis/estudio_mercados", # path absoluto real del proceso
|
||||
"collaborative": True, # True si YDocExtension esta activo
|
||||
"kernels": [
|
||||
{
|
||||
"id": "abc123...",
|
||||
@@ -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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Solo usa stdlib: `urllib`, `json`, `pathlib`, `os`. No requiere `requests` ni clientes Jupyter especializados.
|
||||
|
||||
El escaneo de puertos tiene un timeout de 2 segundos por instancia para no bloquear en puertos cerrados.
|
||||
|
||||
### Deteccion de root_dir via /proc
|
||||
|
||||
En Linux, `_find_jupyter_pid_for_port()` escanea `/proc/*/cmdline` buscando el proceso Jupyter que tenga `--port=N` o `--ServerApp.port=N` en sus argumentos. Para el puerto default 8888, acepta cualquier proceso jupyter sin `--port` explicito.
|
||||
|
||||
Una vez encontrado el PID, `_get_root_dir_from_proc()` lee los argumentos buscando `--ServerApp.root_dir=` o `--notebook-dir=`. Si ninguno esta presente, usa el cwd del proceso (`/proc/{pid}/cwd`) como fallback.
|
||||
|
||||
La funcion `_extract_analysis_name()` extrae el nombre del analisis del root_dir: si el path contiene `analysis/{nombre}`, retorna `{nombre}`; en caso contrario retorna el ultimo componente del path.
|
||||
|
||||
Esta cadena es mas fiable que confiar solo en `.jupyter-port` porque detecta el directorio real del proceso, no el registrado al arranque.
|
||||
|
||||
### Prioridad de fuentes para analysis name
|
||||
|
||||
1. root_dir detectado via /proc (mas fiable)
|
||||
2. Hint del archivo .jupyter-port (fallback si /proc no esta disponible)
|
||||
3. Cadena vacia si ninguno funciona
|
||||
|
||||
### Modo colaborativo
|
||||
|
||||
La deteccion de modo colaborativo busca `YDocExtension` o `collaborative` en el JSON de `/api/config`. Esto cubre tanto jupyter-collaboration >= 2.x (que expone la extension bajo `LabApp`) como configuraciones antiguas.
|
||||
|
||||
Archivos `.jupyter-port`: el pipeline `init_jupyter_analysis` escribe este archivo en cada analisis al lanzar Jupyter, permitiendo que `jupyter_discover` los encuentre sin escanear todos los puertos.
|
||||
|
||||
@@ -30,9 +30,101 @@ def _is_collaborative(config: dict | list | None) -> bool:
|
||||
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.
|
||||
|
||||
Detecta el root_dir real del proceso via /proc (Linux) para identificar
|
||||
correctamente el analisis que esta sirviendo.
|
||||
|
||||
Retorna None si la instancia no responde o no es Jupyter.
|
||||
"""
|
||||
status = _get(f"{base_url}/api/status")
|
||||
@@ -43,6 +135,12 @@ def _query_instance(base_url: str) -> dict | None:
|
||||
kernels_raw = _get(f"{base_url}/api/kernels") or []
|
||||
sessions_raw = _get(f"{base_url}/api/sessions") or []
|
||||
|
||||
# Detectar root_dir via /proc
|
||||
root_dir = ""
|
||||
pid = _find_jupyter_pid_for_port(port)
|
||||
if pid is not None:
|
||||
root_dir = _get_root_dir_from_proc(pid)
|
||||
|
||||
kernels = []
|
||||
if isinstance(kernels_raw, list):
|
||||
for k in kernels_raw:
|
||||
@@ -67,6 +165,7 @@ def _query_instance(base_url: str) -> dict | None:
|
||||
})
|
||||
|
||||
return {
|
||||
"root_dir": root_dir,
|
||||
"kernels": kernels,
|
||||
"sessions": sessions,
|
||||
"collaborative": _is_collaborative(config),
|
||||
@@ -110,6 +209,10 @@ def jupyter_discover(
|
||||
comunes (8888-8892). Para cada instancia que responde consulta /api/status,
|
||||
/api/config, /api/kernels y /api/sessions.
|
||||
|
||||
En Linux detecta el root_dir real del proceso Jupyter via /proc/pid/cmdline,
|
||||
lo que permite identificar correctamente el analisis en escenarios
|
||||
multi-instancia donde varios Jupyter corren en puertos distintos.
|
||||
|
||||
Args:
|
||||
registry_root: Raiz del fn_registry. Si vacio usa el directorio actual
|
||||
o la variable de entorno FN_REGISTRY_ROOT.
|
||||
@@ -117,8 +220,9 @@ def jupyter_discover(
|
||||
en .jupyter-port mas los defaults (8888-8892).
|
||||
|
||||
Returns:
|
||||
Lista de dicts con: url, port, analysis, collaborative, kernels, sessions.
|
||||
Cada sesion incluye: notebook, kernel_id, kernel_state.
|
||||
Lista de dicts con: url, port, analysis, root_dir, collaborative,
|
||||
kernels, sessions. Cada sesion incluye: notebook, kernel_id,
|
||||
kernel_state.
|
||||
"""
|
||||
if not registry_root:
|
||||
registry_root = os.environ.get("FN_REGISTRY_ROOT", "")
|
||||
@@ -139,15 +243,25 @@ def jupyter_discover(
|
||||
port_analysis[p] = ""
|
||||
|
||||
results = []
|
||||
for port, analysis_name in port_analysis.items():
|
||||
for port, analysis_hint in port_analysis.items():
|
||||
base_url = f"http://localhost:{port}"
|
||||
info = _query_instance(base_url)
|
||||
info = _query_instance(base_url, port)
|
||||
if info is None:
|
||||
continue
|
||||
|
||||
# Determinar analysis name: preferir deteccion via /proc sobre .jupyter-port
|
||||
root_dir = info["root_dir"]
|
||||
if root_dir:
|
||||
analysis_name = _extract_analysis_name(root_dir)
|
||||
else:
|
||||
# Fallback al hint del .jupyter-port
|
||||
analysis_name = analysis_hint
|
||||
|
||||
results.append({
|
||||
"url": base_url,
|
||||
"port": port,
|
||||
"analysis": analysis_name,
|
||||
"root_dir": root_dir,
|
||||
"collaborative": info["collaborative"],
|
||||
"kernels": info["kernels"],
|
||||
"sessions": info["sessions"],
|
||||
@@ -201,19 +315,25 @@ if __name__ == "__main__":
|
||||
sys.exit(0)
|
||||
|
||||
for inst in instances:
|
||||
label = f" analysis: {inst['analysis']}" if inst["analysis"] else ""
|
||||
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"]:
|
||||
print(f" Kernels ({len(inst['kernels'])}):")
|
||||
print(f" kernels ({kernel_count}):")
|
||||
for k in inst["kernels"]:
|
||||
print(f" - {k['name']} estado={k['execution_state']} id={k['id'][:8]}...")
|
||||
else:
|
||||
print(" Kernels: ninguno")
|
||||
print(" kernels: ninguno")
|
||||
if inst["sessions"]:
|
||||
print(f" Sesiones ({len(inst['sessions'])}):")
|
||||
print(f" sesiones ({len(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:
|
||||
print(" Sesiones: ninguna")
|
||||
print(" sesiones: ninguna")
|
||||
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.
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
@@ -15,6 +15,7 @@ from urllib.request import Request, urlopen
|
||||
|
||||
from jupyter_kernel_client import KernelClient
|
||||
from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url
|
||||
from nbformat import NotebookNode
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -60,6 +61,24 @@ def _resolve_collab_username(server_url: str, token: str) -> str:
|
||||
return "Anonymous"
|
||||
|
||||
|
||||
def _normalize_code_cell(cell: NotebookNode) -> dict:
|
||||
"""Devuelve un dict de celda de codigo con todos los campos requeridos por nbformat.
|
||||
|
||||
Celdas creadas manualmente (no via Jupyter UI) pueden omitir 'outputs' o
|
||||
'execution_count'. El modelo CRDT de jupyter_nbmodel_client accede a estos
|
||||
campos sin comprobar su existencia, produciendo KeyError al ejecutar.
|
||||
Este helper garantiza que el dict tenga la estructura completa.
|
||||
"""
|
||||
return {
|
||||
"id": cell.get("id", ""),
|
||||
"cell_type": "code",
|
||||
"metadata": cell.get("metadata", {}),
|
||||
"source": cell.get("source", ""),
|
||||
"outputs": cell.get("outputs", []),
|
||||
"execution_count": cell.get("execution_count", None),
|
||||
}
|
||||
|
||||
|
||||
def _extract_outputs(raw_outputs: list[dict]) -> list[str]:
|
||||
"""Convierte outputs de nbformat a lista de strings legibles."""
|
||||
result: list[str] = []
|
||||
@@ -141,6 +160,18 @@ async def _async_execute_cell(
|
||||
async with NbModelClient(ws_url, username=username) as nb:
|
||||
await nb.wait_until_synced()
|
||||
|
||||
# Normalizar la celda antes de ejecutar. Las celdas creadas manualmente
|
||||
# (sin pasar por la UI de Jupyter) pueden carecer de los campos 'outputs'
|
||||
# o 'execution_count' en el modelo CRDT, lo que provoca KeyError dentro
|
||||
# de execute_cell al intentar hacer `del ycell["outputs"][:]`.
|
||||
# Reemplazar la celda via __setitem__ fuerza la re-creacion completa del
|
||||
# mapa CRDT con todos los campos requeridos por nbformat.
|
||||
cell = nb[cell_index]
|
||||
if cell.get("cell_type") == "code" and (
|
||||
"outputs" not in cell or "execution_count" not in cell
|
||||
):
|
||||
nb[cell_index] = _normalize_code_cell(cell)
|
||||
|
||||
with KernelClient(server_url=server_url, token=token, kernel_id=kernel_id) as kernel:
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
|
||||
@@ -3,11 +3,11 @@ name: jupyter_write
|
||||
kind: function
|
||||
lang: py
|
||||
domain: notebook
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
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."
|
||||
tags: [jupyter, notebook, websocket, cell, write, append, insert, edit, delete, nbmodel]
|
||||
description: "Operaciones de escritura sobre celdas de un notebook Jupyter via colaboracion en tiempo real (WebSocket) y API REST. Expone siete operaciones: append_code, append_markdown, insert, edit, delete, create y batch. NO ejecuta celdas — solo modifica la estructura del notebook. create usa PUT /api/contents para crear notebooks nuevos sin necesidad de websocket. batch abre una unica conexion WebSocket para insertar N celdas en una sola operacion."
|
||||
tags: [jupyter, notebook, websocket, cell, write, append, insert, edit, delete, create, batch, nbmodel, rest]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
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_edit_cell(notebook_path, cell_index, source, server_url, token)` | Sobrescribe contenido de celda existente |
|
||||
| `jupyter_delete_cell(notebook_path, cell_index, server_url, token)` | Elimina una celda |
|
||||
| `jupyter_create_notebook(notebook_path, kernel_name, server_url, token, force)` | Crea un notebook vacio nbformat 4 via API REST |
|
||||
| `jupyter_batch_write(notebook_path, cells, server_url, token)` | Anade N celdas en una sola conexion WebSocket |
|
||||
|
||||
## Ejemplo
|
||||
|
||||
@@ -39,14 +41,38 @@ from notebook.jupyter_write import (
|
||||
jupyter_insert_cell,
|
||||
jupyter_edit_cell,
|
||||
jupyter_delete_cell,
|
||||
jupyter_create_notebook,
|
||||
jupyter_batch_write,
|
||||
)
|
||||
|
||||
# Crear notebook nuevo
|
||||
result = jupyter_create_notebook(
|
||||
notebook_path="notebooks/01_analisis.ipynb",
|
||||
kernel_name="python3",
|
||||
server_url="http://localhost:8888",
|
||||
)
|
||||
# {"action": "create", "notebook": "notebooks/01_analisis.ipynb", "created": true}
|
||||
|
||||
# Si ya existe, lanza FileExistsError. Usar force=True para sobreescribir:
|
||||
result = jupyter_create_notebook("notebooks/01.ipynb", force=True)
|
||||
|
||||
# Anadir multiples celdas de golpe (una sola conexion WebSocket)
|
||||
cells = [
|
||||
{"type": "markdown", "source": "## Analisis inicial"},
|
||||
{"type": "code", "source": "import pandas as pd"},
|
||||
{"type": "code", "source": "df = pd.read_csv('data.csv')\ndf.head()"},
|
||||
]
|
||||
result = jupyter_batch_write(
|
||||
notebook_path="notebooks/01_analisis.ipynb",
|
||||
cells=cells,
|
||||
server_url="http://localhost:8888",
|
||||
)
|
||||
# {"action": "batch", "cells_added": 3, "notebook": "notebooks/01_analisis.ipynb"}
|
||||
|
||||
# Anadir celda de codigo al final
|
||||
result = jupyter_append_code(
|
||||
notebook_path="notebooks/01_analisis.ipynb",
|
||||
source="import pandas as pd\ndf = pd.read_csv('data.csv')",
|
||||
server_url="http://localhost:8888",
|
||||
token="mi-token",
|
||||
)
|
||||
# {"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",
|
||||
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
|
||||
result = jupyter_insert_cell(
|
||||
@@ -85,6 +111,21 @@ result = jupyter_delete_cell(
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
# Crear notebook nuevo
|
||||
python -m notebook.jupyter_write create notebooks/01.ipynb
|
||||
python -m notebook.jupyter_write create notebooks/01.ipynb --kernel python3
|
||||
python -m notebook.jupyter_write create notebooks/01.ipynb --force
|
||||
|
||||
# Anadir multiples celdas desde archivo JSON
|
||||
python -m notebook.jupyter_write batch notebooks/01.ipynb --from cells.json
|
||||
|
||||
# O via stdin
|
||||
cat cells.json | python -m notebook.jupyter_write batch notebooks/01.ipynb --from -
|
||||
echo '[{"type":"code","source":"import pandas"}]' | python -m notebook.jupyter_write batch notebooks/01.ipynb
|
||||
|
||||
# Formato JSON de entrada para batch:
|
||||
# [{"type": "markdown", "source": "# Titulo"}, {"type": "code", "source": "import pandas"}]
|
||||
|
||||
# Anadir celda de codigo
|
||||
python -m notebook.jupyter_write append-code notebooks/01.ipynb "print('hola')" --server http://localhost:8888 --token mi-token
|
||||
|
||||
@@ -104,8 +145,11 @@ python -m notebook.jupyter_write delete notebooks/01.ipynb 3
|
||||
## Notas
|
||||
|
||||
- Todas las funciones son sincronas publicamente. Internamente usan `asyncio.run()` sobre corutinas async que se comunican via WebSocket con `NbModelClient`.
|
||||
- `create` es la excepcion: usa urllib (PUT /api/contents) sin WebSocket. Crea un nbformat 4 con celdas vacias. Lanza `FileExistsError` si el notebook ya existe y `force=False`.
|
||||
- `batch` es mucho mas eficiente que N llamadas a `append-code`/`append-markdown`: abre una sola conexion WebSocket y hace un unico `asyncio.sleep(2)` de sincronizacion al final.
|
||||
- El `notebook_path` es relativo al servidor Jupyter (no al filesystem local).
|
||||
- Si el servidor no esta corriendo o el token es incorrecto, lanza excepcion de conexion de `jupyter_nbmodel_client`.
|
||||
- NO ejecuta celdas — solo modifica la estructura. Para ejecutar, usar 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).
|
||||
- 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.
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""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.
|
||||
Para crear notebooks usa la API REST de Jupyter (urllib PUT /api/contents).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import argparse
|
||||
from urllib.error import URLError
|
||||
import sys
|
||||
from urllib.error import URLError, HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from jupyter_nbmodel_client import NbModelClient, get_jupyter_notebook_websocket_url
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -299,6 +438,25 @@ def _build_parser() -> argparse.ArgumentParser:
|
||||
p_del.add_argument("index", type=int, help="Indice de la celda (0-based)")
|
||||
add_common(p_del)
|
||||
|
||||
# create
|
||||
p_cr = sub.add_parser("create", help="Crea un notebook vacio via API REST")
|
||||
p_cr.add_argument("notebook", help="Ruta del notebook a crear")
|
||||
p_cr.add_argument("--kernel", default="python3", help="Nombre del kernel (default: python3)")
|
||||
p_cr.add_argument("--force", action="store_true", help="Sobreescribir si ya existe")
|
||||
add_common(p_cr)
|
||||
|
||||
# batch
|
||||
p_bt = sub.add_parser("batch", help="Anade multiples celdas en una sola conexion WebSocket")
|
||||
p_bt.add_argument("notebook", help="Ruta del notebook")
|
||||
p_bt.add_argument(
|
||||
"--from",
|
||||
dest="cells_source",
|
||||
default="-",
|
||||
metavar="FILE",
|
||||
help="Archivo JSON con las celdas, o '-' para leer de stdin (default: -)",
|
||||
)
|
||||
add_common(p_bt)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
@@ -318,6 +476,22 @@ def main() -> None:
|
||||
result = jupyter_edit_cell(args.notebook, args.index, args.source, args.server, args.token)
|
||||
elif args.command == "delete":
|
||||
result = jupyter_delete_cell(args.notebook, args.index, args.server, args.token)
|
||||
elif args.command == "create":
|
||||
result = jupyter_create_notebook(
|
||||
args.notebook,
|
||||
kernel_name=args.kernel,
|
||||
server_url=args.server,
|
||||
token=args.token,
|
||||
force=args.force,
|
||||
)
|
||||
elif args.command == "batch":
|
||||
if args.cells_source == "-":
|
||||
raw = sys.stdin.read()
|
||||
else:
|
||||
with open(args.cells_source, "r", encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
cells = json.loads(raw)
|
||||
result = jupyter_batch_write(args.notebook, cells, args.server, args.token)
|
||||
else:
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user