From 79ba67cf00d582f91001921a2e84d123b90f8cd0 Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Thu, 5 Mar 2026 15:51:41 -0600 Subject: [PATCH] feat: add notebook execution endpoints (/notebooks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-session Jupyter notebook execution via REST endpoints. Each session gets its own kernel via nbclient. Supports per-cell execution with rich outputs (images, HTML, LaTeX). - POST /notebooks — create session (starts kernel) - POST /notebooks/{id}/execute — execute cell - GET /notebooks/{id} — session status - DELETE /notebooks/{id} — stop kernel nbclient and ipykernel are now core dependencies. OPEN_TERMINAL_ENABLE_NOTEBOOKS env var to enable/disable. Bump version 0.9.3 → 0.10.0 --- CHANGELOG.md | 7 + open_terminal/env.py | 5 + open_terminal/main.py | 13 +- open_terminal/notebooks.py | 283 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 5 +- 5 files changed, 310 insertions(+), 3 deletions(-) create mode 100644 open_terminal/notebooks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d269b56..078a322 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.10.0] - 2026-03-05 + +### Added + +- 📓 **Notebook execution** (`/notebooks`) — multi-session Jupyter notebook execution via REST endpoints. Each session gets its own kernel via `nbclient`. Supports per-cell execution with rich outputs (images, HTML, LaTeX). `nbclient` and `ipykernel` are now core dependencies. +- ⚙️ **`OPEN_TERMINAL_ENABLE_NOTEBOOKS`** — environment variable (or `enable_notebooks` in config.toml) to enable/disable notebook execution endpoints. Defaults to `true`. Exposed in `GET /api/config` features. + ## [0.9.3] - 2026-03-05 ### Added diff --git a/open_terminal/env.py b/open_terminal/env.py index 5ef12ef..a4c0957 100644 --- a/open_terminal/env.py +++ b/open_terminal/env.py @@ -88,3 +88,8 @@ EXECUTE_DESCRIPTION = os.environ.get( "OPEN_TERMINAL_EXECUTE_DESCRIPTION", config.get("execute_description", ""), ) + +ENABLE_NOTEBOOKS = os.environ.get( + "OPEN_TERMINAL_ENABLE_NOTEBOOKS", + str(config.get("enable_notebooks", True)), +).lower() not in ("false", "0", "no") diff --git a/open_terminal/main.py b/open_terminal/main.py index baa9243..896b9fa 100644 --- a/open_terminal/main.py +++ b/open_terminal/main.py @@ -24,7 +24,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from pydantic import BaseModel, Field from pypdf import PdfReader -from open_terminal.env import API_KEY, BINARY_FILE_MIME_PREFIXES, CORS_ALLOWED_ORIGINS, ENABLE_TERMINAL, EXECUTE_DESCRIPTION, EXECUTE_TIMEOUT, LOG_DIR, MAX_TERMINAL_SESSIONS, TERMINAL_TERM +from open_terminal.env import API_KEY, BINARY_FILE_MIME_PREFIXES, CORS_ALLOWED_ORIGINS, ENABLE_NOTEBOOKS, ENABLE_TERMINAL, EXECUTE_DESCRIPTION, EXECUTE_TIMEOUT, LOG_DIR, MAX_TERMINAL_SESSIONS, TERMINAL_TERM from open_terminal.runner import PipeRunner, ProcessRunner, create_runner try: @@ -343,6 +343,7 @@ async def get_config(): return { "features": { "terminal": ENABLE_TERMINAL, + "notebooks": ENABLE_NOTEBOOKS, }, } @@ -1577,3 +1578,13 @@ if ENABLE_TERMINAL: # Clean up session on disconnect _cleanup_session(session_id) + +# --------------------------------------------------------------------------- +# Notebook execution (optional) +# --------------------------------------------------------------------------- + +if ENABLE_NOTEBOOKS: + from open_terminal.notebooks import create_notebooks_router + + app.include_router(create_notebooks_router(verify_api_key)) + diff --git a/open_terminal/notebooks.py b/open_terminal/notebooks.py new file mode 100644 index 0000000..5659c5d --- /dev/null +++ b/open_terminal/notebooks.py @@ -0,0 +1,283 @@ +"""Jupyter notebook execution endpoints. + +Provides per-cell execution with multi-session support. Each session gets its +own Jupyter kernel via nbclient. Requires the ``notebooks`` optional extra:: + + pip install open-terminal[notebooks] +""" + +import asyncio +import json +import os +import time +import uuid +from typing import Optional + +import aiofiles +import aiofiles.os +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +import nbformat +from nbclient import NotebookClient + + +# --------------------------------------------------------------------------- +# Session manager +# --------------------------------------------------------------------------- + +_IDLE_TIMEOUT = 30 * 60 # 30 minutes + + +class _Session: + """Wraps a NotebookClient for a specific notebook.""" + + __slots__ = ("id", "path", "nb", "client", "busy", "created_at", "last_used") + + def __init__(self, session_id: str, path: str, nb, client): + self.id = session_id + self.path = path + self.nb = nb + self.client = client + self.busy = False + self.created_at = time.time() + self.last_used = time.time() + + +_sessions: dict[str, _Session] = {} +_cleanup_task: Optional[asyncio.Task] = None + + +async def _idle_cleanup_loop(): + """Periodically remove sessions idle for more than _IDLE_TIMEOUT.""" + while True: + await asyncio.sleep(60) + now = time.time() + stale = [ + sid + for sid, s in _sessions.items() + if now - s.last_used > _IDLE_TIMEOUT and not s.busy + ] + for sid in stale: + await _destroy_session(sid) + + +async def _destroy_session(session_id: str): + session = _sessions.pop(session_id, None) + if session and session.client: + try: + await session.client._async_cleanup_kernel() + except Exception: + pass + + +def _ensure_cleanup_task(): + global _cleanup_task + if _cleanup_task is None or _cleanup_task.done(): + _cleanup_task = asyncio.create_task(_idle_cleanup_loop()) + + +# --------------------------------------------------------------------------- +# Request / response models +# --------------------------------------------------------------------------- + + +class CreateSessionRequest(BaseModel): + path: str = Field(..., description="Absolute path to the .ipynb file.") + + +class CreateSessionResponse(BaseModel): + id: str + kernel: str + status: str + + +class ExecuteCellRequest(BaseModel): + cell_index: int = Field(..., description="Zero-based cell index to execute.") + source: Optional[str] = Field( + None, description="Override cell source. If omitted, uses the source already in the notebook." + ) + + +class ExecuteCellResponse(BaseModel): + status: str + execution_count: Optional[int] = None + outputs: list = Field(default_factory=list) + + +class SessionStatusResponse(BaseModel): + id: str + path: str + kernel: str + status: str + + +# --------------------------------------------------------------------------- +# Router +# --------------------------------------------------------------------------- + + +def create_notebooks_router(verify_api_key) -> APIRouter: + """Create the notebooks router with the given auth dependency.""" + + router = APIRouter( + prefix="/notebooks", + tags=["notebooks"], + dependencies=[Depends(verify_api_key)], + ) + + + + @router.post( + "", + response_model=CreateSessionResponse, + operation_id="create_notebook_session", + summary="Create a notebook session", + description="Start a Jupyter kernel for the given notebook. Returns a session ID for subsequent execute calls.", + include_in_schema=False, + ) + async def create_session(req: CreateSessionRequest): + + _ensure_cleanup_task() + + path = os.path.abspath(req.path) + if not await aiofiles.os.path.isfile(path): + raise HTTPException(status_code=404, detail=f"Notebook not found: {path}") + + # Read and parse the notebook + async with aiofiles.open(path) as f: + content = await f.read() + + try: + nb = nbformat.reads(content, as_version=4) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid notebook: {e}") + + kernel_name = nb.metadata.get("kernelspec", {}).get("name", "python3") + + # Start kernel — follow nbclient's setup_kernel pattern + client = NotebookClient(nb, kernel_name=kernel_name, timeout=120) + try: + client.create_kernel_manager() + await client.async_start_new_kernel() + await client.async_start_new_kernel_client() + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to start kernel '{kernel_name}': {e}", + ) + + session_id = uuid.uuid4().hex[:12] + _sessions[session_id] = _Session(session_id, path, nb, client) + + return CreateSessionResponse( + id=session_id, kernel=kernel_name, status="ready" + ) + + @router.post( + "/{session_id}/execute", + response_model=ExecuteCellResponse, + operation_id="execute_notebook_cell", + summary="Execute a notebook cell", + description="Execute a single cell in the given session. Optionally override the cell source. " + "Updates the .ipynb file in place after execution.", + include_in_schema=False, + ) + async def execute_cell(session_id: str, req: ExecuteCellRequest): + + + session = _sessions.get(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + if session.busy: + raise HTTPException(status_code=409, detail="Cell already executing") + + nb = session.nb + if req.cell_index < 0 or req.cell_index >= len(nb.cells): + raise HTTPException( + status_code=400, + detail=f"cell_index {req.cell_index} out of range (0..{len(nb.cells) - 1})", + ) + + cell = nb.cells[req.cell_index] + if req.source is not None: + cell.source = req.source + + session.busy = True + session.last_used = time.time() + + try: + await session.client.async_execute_cell(cell, req.cell_index) + except Exception as e: + session.busy = False + # Return the error as a cell output rather than HTTP error + return ExecuteCellResponse( + status="error", + outputs=[ + { + "output_type": "error", + "ename": type(e).__name__, + "evalue": str(e), + "traceback": [str(e)], + } + ], + ) + + session.busy = False + session.last_used = time.time() + + # Serialize outputs + outputs = [] + for o in cell.outputs: + od = dict(o) + if "data" in od: + od["data"] = dict(od["data"]) + outputs.append(od) + + ec = cell.get("execution_count") + + # Save notebook to disk + try: + nb_json = nbformat.writes(nb) + async with aiofiles.open(session.path, "w") as f: + await f.write(nb_json) + except Exception: + pass # non-fatal + + return ExecuteCellResponse( + status="ok", execution_count=ec, outputs=outputs + ) + + @router.get( + "/{session_id}", + response_model=SessionStatusResponse, + operation_id="get_notebook_session", + summary="Get notebook session status", + include_in_schema=False, + ) + async def get_session(session_id: str): + session = _sessions.get(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + kernel_name = session.nb.metadata.get("kernelspec", {}).get( + "name", "python3" + ) + status = "busy" if session.busy else "ready" + return SessionStatusResponse( + id=session.id, path=session.path, kernel=kernel_name, status=status + ) + + @router.delete( + "/{session_id}", + operation_id="delete_notebook_session", + summary="Stop a notebook session", + include_in_schema=False, + ) + async def delete_session(session_id: str): + if session_id not in _sessions: + raise HTTPException(status_code=404, detail="Session not found") + await _destroy_session(session_id) + return {"status": "stopped"} + + return router diff --git a/pyproject.toml b/pyproject.toml index 8a27919..076b50c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "open-terminal" -version = "0.9.3" +version = "0.10.0" description = "A remote terminal API." readme = "README.md" authors = [ @@ -15,12 +15,13 @@ dependencies = [ "python-multipart>=0.0.22", "aiofiles>=25.1.0", "pypdf>=5.0.0", + "nbclient>=0.10.0", + "ipykernel>=6.0.0", "pywinpty>=2.0.0; sys_platform == 'win32'", ] [project.optional-dependencies] mcp = ["fastmcp>=2.0.0"] -notebooks = ["nbclient>=0.10.0", "ipykernel>=6.0.0"] [project.scripts] open-terminal = "open_terminal.cli:main"