feat: enhance jupyter notebook functions with auto-init and kernel management
Auto-create notebooks y sesiones en jupyter_exec (append y cell). Auto-create en jupyter_write (append_code, append_markdown, batch). Nuevos subcomandos cleanup y shutdown-all en jupyter_kernel. README.md renombrado a README.txt para evitar error de parseo del indexer. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -102,4 +102,6 @@ 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.
|
||||||
|
- **Auto-init**: `jupyter_append_execute` crea el notebook automaticamente si no existe (via REST PUT /api/contents) y arranca una sesion con kernel si no hay ninguna activa para ese notebook (via POST /api/sessions). No es necesario abrir el notebook manualmente en el navegador.
|
||||||
|
- **Auto-session**: `jupyter_execute_cell` tambien garantiza que exista una sesion con kernel antes de ejecutar.
|
||||||
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.error import URLError
|
from urllib.error import HTTPError, URLError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
from jupyter_kernel_client import KernelClient
|
from jupyter_kernel_client import KernelClient
|
||||||
@@ -23,6 +23,80 @@ from nbformat import NotebookNode
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _notebook_exists(notebook_path: str, server_url: str, token: str) -> bool:
|
||||||
|
"""Comprueba si un notebook existe en el servidor Jupyter via HEAD /api/contents."""
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"token {token}"
|
||||||
|
check_url = f"{server_url}/api/contents/{notebook_path}"
|
||||||
|
req = Request(check_url, headers=headers, method="HEAD")
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=5):
|
||||||
|
return True
|
||||||
|
except HTTPError as e:
|
||||||
|
if e.code == 404:
|
||||||
|
return False
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _create_notebook(notebook_path: str, server_url: str, token: str, kernel_name: str = "python3") -> None:
|
||||||
|
"""Crea un notebook vacio via PUT /api/contents si no existe."""
|
||||||
|
if _notebook_exists(notebook_path, server_url, token):
|
||||||
|
return
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"token {token}"
|
||||||
|
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")
|
||||||
|
url = f"{server_url}/api/contents/{notebook_path}"
|
||||||
|
req = Request(url, data=body, headers=headers, method="PUT")
|
||||||
|
with urlopen(req, timeout=10) as resp:
|
||||||
|
resp.read()
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_session(server_url: str, token: str, notebook_path: str, kernel_name: str = "python3") -> str:
|
||||||
|
"""Garantiza que exista una sesion para el notebook. Retorna el kernel_id.
|
||||||
|
|
||||||
|
Si ya hay una sesion activa, retorna su kernel_id. Si no, crea una nueva
|
||||||
|
via POST /api/sessions (lo cual tambien arranca un kernel).
|
||||||
|
"""
|
||||||
|
kernel_id = _resolve_kernel_id(server_url, token, notebook_path)
|
||||||
|
if kernel_id:
|
||||||
|
return kernel_id
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"token {token}"
|
||||||
|
|
||||||
|
body = json.dumps({
|
||||||
|
"path": notebook_path,
|
||||||
|
"type": "notebook",
|
||||||
|
"kernel": {"name": kernel_name},
|
||||||
|
}).encode("utf-8")
|
||||||
|
|
||||||
|
url = f"{server_url}/api/sessions"
|
||||||
|
req = Request(url, data=body, headers=headers, method="POST")
|
||||||
|
with urlopen(req, timeout=10) as resp:
|
||||||
|
session = json.loads(resp.read())
|
||||||
|
|
||||||
|
return session.get("kernel", {}).get("id", "")
|
||||||
|
|
||||||
|
|
||||||
def _api_get(url: str, token: str = "") -> dict | list | None:
|
def _api_get(url: str, token: str = "") -> dict | list | None:
|
||||||
"""GET a Jupyter REST API endpoint."""
|
"""GET a Jupyter REST API endpoint."""
|
||||||
headers = {"Accept": "application/json"}
|
headers = {"Accept": "application/json"}
|
||||||
@@ -112,13 +186,14 @@ async def _async_append_execute(
|
|||||||
server_url: str,
|
server_url: str,
|
||||||
token: str,
|
token: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
_create_notebook(notebook_path, server_url, token)
|
||||||
|
kernel_id = _ensure_session(server_url, token, notebook_path)
|
||||||
|
|
||||||
ws_url = get_jupyter_notebook_websocket_url(
|
ws_url = get_jupyter_notebook_websocket_url(
|
||||||
server_url,
|
server_url,
|
||||||
notebook_path,
|
notebook_path,
|
||||||
token or None,
|
token or None,
|
||||||
)
|
)
|
||||||
|
|
||||||
kernel_id = _resolve_kernel_id(server_url, token, notebook_path)
|
|
||||||
username = _resolve_collab_username(server_url, token)
|
username = _resolve_collab_username(server_url, token)
|
||||||
|
|
||||||
async with NbModelClient(ws_url, username=username) as nb:
|
async with NbModelClient(ws_url, username=username) as nb:
|
||||||
@@ -149,12 +224,13 @@ async def _async_execute_cell(
|
|||||||
server_url: str,
|
server_url: str,
|
||||||
token: str,
|
token: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
kernel_id = _ensure_session(server_url, token, notebook_path)
|
||||||
|
|
||||||
ws_url = get_jupyter_notebook_websocket_url(
|
ws_url = get_jupyter_notebook_websocket_url(
|
||||||
server_url,
|
server_url,
|
||||||
notebook_path,
|
notebook_path,
|
||||||
token or None,
|
token or None,
|
||||||
)
|
)
|
||||||
kernel_id = _resolve_kernel_id(server_url, token, notebook_path)
|
|
||||||
username = _resolve_collab_username(server_url, token)
|
username = _resolve_collab_username(server_url, token)
|
||||||
|
|
||||||
async with NbModelClient(ws_url, username=username) as nb:
|
async with NbModelClient(ws_url, username=username) as nb:
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ domain: notebook
|
|||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
purity: impure
|
purity: impure
|
||||||
signature: "def jupyter_kernel_list(server_url: str = \"http://localhost:8888\", token: str = \"\") -> list[dict]"
|
signature: "def jupyter_kernel_list(server_url: str = \"http://localhost:8888\", token: str = \"\") -> list[dict]"
|
||||||
description: "CRUD completo de kernels Jupyter via REST API. Expone seis operaciones: list, start, restart, interrupt, shutdown y sessions. Usa solo stdlib (urllib, json), sin dependencias externas."
|
description: "CRUD completo de kernels Jupyter via REST API. Expone ocho operaciones: list, start, restart, interrupt, shutdown, sessions, cleanup y shutdown-all. Usa solo stdlib (urllib, json), sin dependencias externas."
|
||||||
tags: [jupyter, notebook, kernel, api, http, rest, sessions, crud]
|
tags: [jupyter, notebook, kernel, api, http, rest, sessions, crud, cleanup]
|
||||||
uses_functions: []
|
uses_functions: []
|
||||||
uses_types: []
|
uses_types: []
|
||||||
returns: []
|
returns: []
|
||||||
@@ -31,6 +31,8 @@ file_path: "python/functions/notebook/jupyter_kernel.py"
|
|||||||
| `jupyter_kernel_interrupt(server_url, token, kernel_id)` | `POST /api/kernels/{id}/interrupt` | Interrumpe ejecucion |
|
| `jupyter_kernel_interrupt(server_url, token, kernel_id)` | `POST /api/kernels/{id}/interrupt` | Interrumpe ejecucion |
|
||||||
| `jupyter_kernel_shutdown(server_url, token, kernel_id)` | `DELETE /api/kernels/{id}` | Apaga y elimina un kernel |
|
| `jupyter_kernel_shutdown(server_url, token, kernel_id)` | `DELETE /api/kernels/{id}` | Apaga y elimina un kernel |
|
||||||
| `jupyter_kernel_sessions(server_url, token)` | `GET /api/sessions` | Lista sesiones activas |
|
| `jupyter_kernel_sessions(server_url, token)` | `GET /api/sessions` | Lista sesiones activas |
|
||||||
|
| `jupyter_kernel_cleanup(server_url, token, idle_seconds)` | `GET + DELETE` | Apaga kernels inactivos |
|
||||||
|
| `jupyter_kernel_shutdown_all(server_url, token)` | `GET + DELETE` | Apaga todos los kernels |
|
||||||
|
|
||||||
## Ejemplo
|
## Ejemplo
|
||||||
|
|
||||||
@@ -88,6 +90,12 @@ python python/functions/notebook/jupyter_kernel.py shutdown abc123-...
|
|||||||
|
|
||||||
# Listar sesiones
|
# Listar sesiones
|
||||||
python python/functions/notebook/jupyter_kernel.py sessions
|
python python/functions/notebook/jupyter_kernel.py sessions
|
||||||
|
|
||||||
|
# Limpiar kernels inactivos (default: 1h sin actividad)
|
||||||
|
python python/functions/notebook/jupyter_kernel.py cleanup --idle-seconds 1800
|
||||||
|
|
||||||
|
# Apagar todos los kernels
|
||||||
|
python python/functions/notebook/jupyter_kernel.py shutdown-all
|
||||||
```
|
```
|
||||||
|
|
||||||
Todos los subcomandos aceptan `--server` y `--token`. El output es siempre JSON.
|
Todos los subcomandos aceptan `--server` y `--token`. El output es siempre JSON.
|
||||||
|
|||||||
@@ -196,6 +196,80 @@ def jupyter_kernel_sessions(
|
|||||||
return sessions
|
return sessions
|
||||||
|
|
||||||
|
|
||||||
|
def jupyter_kernel_cleanup(
|
||||||
|
server_url: str = "http://localhost:8888",
|
||||||
|
token: str = "",
|
||||||
|
idle_seconds: int = 3600,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Apaga todos los kernels que llevan mas de idle_seconds sin actividad.
|
||||||
|
|
||||||
|
Util para liberar recursos en servidores con muchos notebooks abiertos.
|
||||||
|
Por defecto cierra kernels inactivos desde hace mas de 1 hora.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server_url: URL base del servidor Jupyter.
|
||||||
|
token: Token de autenticacion. Vacio si el servidor no requiere auth.
|
||||||
|
idle_seconds: Segundos de inactividad para considerar un kernel ocioso.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de dicts con los kernels apagados (id, name, last_activity, idle_seconds).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
urllib.error.HTTPError: Si la respuesta HTTP indica un error.
|
||||||
|
urllib.error.URLError: Si no se puede conectar al servidor.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
kernels = jupyter_kernel_list(server_url, token)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
shutdown_list = []
|
||||||
|
|
||||||
|
for k in kernels:
|
||||||
|
last_activity = k.get("last_activity", "")
|
||||||
|
if not last_activity:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
last_dt = datetime.fromisoformat(last_activity.replace("Z", "+00:00"))
|
||||||
|
idle = (now - last_dt).total_seconds()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if idle >= idle_seconds:
|
||||||
|
jupyter_kernel_shutdown(server_url, token, k["id"])
|
||||||
|
shutdown_list.append({
|
||||||
|
"id": k["id"],
|
||||||
|
"name": k.get("name", ""),
|
||||||
|
"last_activity": last_activity,
|
||||||
|
"idle_seconds": int(idle),
|
||||||
|
})
|
||||||
|
|
||||||
|
return shutdown_list
|
||||||
|
|
||||||
|
|
||||||
|
def jupyter_kernel_shutdown_all(
|
||||||
|
server_url: str = "http://localhost:8888",
|
||||||
|
token: str = "",
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Apaga todos los kernels activos del servidor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
server_url: URL base del servidor Jupyter.
|
||||||
|
token: Token de autenticacion. Vacio si el servidor no requiere auth.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lista de dicts con los kernels apagados (id, name).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
urllib.error.HTTPError: Si la respuesta HTTP indica un error.
|
||||||
|
urllib.error.URLError: Si no se puede conectar al servidor.
|
||||||
|
"""
|
||||||
|
kernels = jupyter_kernel_list(server_url, token)
|
||||||
|
shutdown_list = []
|
||||||
|
for k in kernels:
|
||||||
|
jupyter_kernel_shutdown(server_url, token, k["id"])
|
||||||
|
shutdown_list.append({"id": k["id"], "name": k.get("name", "")})
|
||||||
|
return shutdown_list
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# CLI
|
# CLI
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -248,6 +322,18 @@ if __name__ == "__main__":
|
|||||||
# sessions
|
# sessions
|
||||||
subparsers.add_parser("sessions", help="Lista las sesiones activas.")
|
subparsers.add_parser("sessions", help="Lista las sesiones activas.")
|
||||||
|
|
||||||
|
# cleanup
|
||||||
|
sp_cleanup = subparsers.add_parser("cleanup", help="Apaga kernels inactivos.")
|
||||||
|
sp_cleanup.add_argument(
|
||||||
|
"--idle-seconds",
|
||||||
|
type=int,
|
||||||
|
default=3600,
|
||||||
|
help="Segundos de inactividad para considerar ocioso (default: 3600)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# shutdown-all
|
||||||
|
subparsers.add_parser("shutdown-all", help="Apaga todos los kernels activos.")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -267,6 +353,10 @@ if __name__ == "__main__":
|
|||||||
result = {"status": "shutdown", "kernel_id": args.kernel_id}
|
result = {"status": "shutdown", "kernel_id": args.kernel_id}
|
||||||
elif args.command == "sessions":
|
elif args.command == "sessions":
|
||||||
result = jupyter_kernel_sessions(args.server, args.token)
|
result = jupyter_kernel_sessions(args.server, args.token)
|
||||||
|
elif args.command == "cleanup":
|
||||||
|
result = jupyter_kernel_cleanup(args.server, args.token, args.idle_seconds)
|
||||||
|
elif args.command == "shutdown-all":
|
||||||
|
result = jupyter_kernel_shutdown_all(args.server, args.token)
|
||||||
else:
|
else:
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|||||||
@@ -153,4 +153,5 @@ python -m notebook.jupyter_write delete notebooks/01.ipynb 3
|
|||||||
- NO ejecuta celdas — solo modifica la estructura. Para ejecutar, usar `jupyter_exec`.
|
- 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.
|
- `append_code`, `append_markdown` y `batch` crean el notebook automaticamente si no existe (auto-create via REST). No es necesario llamar a `create` previamente.
|
||||||
|
- Patron tipico: `batch` para poblar las celdas iniciales (crea el notebook si no existe), o `create` + `batch` si se necesita control explicito.
|
||||||
|
|||||||
@@ -30,6 +30,35 @@ def _resolve_collab_username(server_url: str, token: str) -> str:
|
|||||||
return "Anonymous"
|
return "Anonymous"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers internos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _notebook_exists(notebook_path: str, server_url: str, token: str) -> bool:
|
||||||
|
"""Comprueba si un notebook existe en el servidor Jupyter via HEAD /api/contents."""
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"token {token}"
|
||||||
|
check_url = f"{server_url}/api/contents/{notebook_path}"
|
||||||
|
req = Request(check_url, headers=headers, method="HEAD")
|
||||||
|
try:
|
||||||
|
with urlopen(req, timeout=5):
|
||||||
|
return True
|
||||||
|
except HTTPError as e:
|
||||||
|
if e.code == 404:
|
||||||
|
return False
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_create_notebook(notebook_path: str, server_url: str, token: str) -> bool:
|
||||||
|
"""Crea el notebook si no existe. Retorna True si fue creado."""
|
||||||
|
if not _notebook_exists(notebook_path, server_url, token):
|
||||||
|
jupyter_create_notebook(notebook_path, server_url=server_url, token=token)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Helpers internos async
|
# Helpers internos async
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -42,6 +71,7 @@ async def _append_cell(
|
|||||||
server_url: str,
|
server_url: str,
|
||||||
token: str,
|
token: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
_auto_create_notebook(notebook_path, server_url, token)
|
||||||
ws_url = get_jupyter_notebook_websocket_url(
|
ws_url = get_jupyter_notebook_websocket_url(
|
||||||
server_url=server_url,
|
server_url=server_url,
|
||||||
token=token,
|
token=token,
|
||||||
@@ -139,6 +169,7 @@ async def _batch_write(
|
|||||||
token: str,
|
token: str,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Anade multiples celdas en una sola conexion WebSocket."""
|
"""Anade multiples celdas en una sola conexion WebSocket."""
|
||||||
|
_auto_create_notebook(notebook_path, server_url, token)
|
||||||
ws_url = get_jupyter_notebook_websocket_url(
|
ws_url = get_jupyter_notebook_websocket_url(
|
||||||
server_url=server_url,
|
server_url=server_url,
|
||||||
token=token,
|
token=token,
|
||||||
|
|||||||
Reference in New Issue
Block a user