From af1fa129f757be3ec2b4b7712f8fe21c16033294 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 5 Apr 2026 17:11:50 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20mejoras=20notebook=20functions=20?= =?UTF-8?q?=E2=80=94=20discover=20multi-servidor,=20write=20batch=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- python/functions/notebook/README.md | 231 ++++++++++++++++++ python/functions/notebook/jupyter_discover.md | 53 +++- python/functions/notebook/jupyter_discover.py | 144 ++++++++++- python/functions/notebook/jupyter_exec.md | 1 + python/functions/notebook/jupyter_exec.py | 31 +++ python/functions/notebook/jupyter_write.md | 58 ++++- python/functions/notebook/jupyter_write.py | 178 +++++++++++++- 7 files changed, 668 insertions(+), 28 deletions(-) create mode 100644 python/functions/notebook/README.md diff --git a/python/functions/notebook/README.md b/python/functions/notebook/README.md new file mode 100644 index 00000000..615b1d4c --- /dev/null +++ b/python/functions/notebook/README.md @@ -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 +$PYTHON jupyter_kernel.py shutdown +$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 +``` + +### "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) diff --git a/python/functions/notebook/jupyter_discover.md b/python/functions/notebook/jupyter_discover.md index a01d6168..82434e8e 100644 --- a/python/functions/notebook/jupyter_discover.md +++ b/python/functions/notebook/jupyter_discover.md @@ -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. diff --git a/python/functions/notebook/jupyter_discover.py b/python/functions/notebook/jupyter_discover.py index 9c760c75..a1afe717 100644 --- a/python/functions/notebook/jupyter_discover.py +++ b/python/functions/notebook/jupyter_discover.py @@ -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() diff --git a/python/functions/notebook/jupyter_exec.md b/python/functions/notebook/jupyter_exec.md index 182d1b1c..8cbacc7f 100644 --- a/python/functions/notebook/jupyter_exec.md +++ b/python/functions/notebook/jupyter_exec.md @@ -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. diff --git a/python/functions/notebook/jupyter_exec.py b/python/functions/notebook/jupyter_exec.py index 16bcaf32..fa14f5e6 100644 --- a/python/functions/notebook/jupyter_exec.py +++ b/python/functions/notebook/jupyter_exec.py @@ -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( diff --git a/python/functions/notebook/jupyter_write.md b/python/functions/notebook/jupyter_write.md index 397f1df2..72e2c57f 100644 --- a/python/functions/notebook/jupyter_write.md +++ b/python/functions/notebook/jupyter_write.md @@ -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. diff --git a/python/functions/notebook/jupyter_write.py b/python/functions/notebook/jupyter_write.py index aafc1672..9a8d3ba5 100644 --- a/python/functions/notebook/jupyter_write.py +++ b/python/functions/notebook/jupyter_write.py @@ -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