feat: add notebook execution endpoints (/notebooks)
Some checks are pending
Build Docker Image / merge (push) Blocked by required conditions
Build Docker Image / build (linux/amd64) (push) Waiting to run
Build Docker Image / build (linux/arm64) (push) Waiting to run
Release / release (push) Waiting to run

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
This commit is contained in:
Timothy Jaeryang Baek 2026-03-05 15:51:41 -06:00
parent 59bfc4ff5e
commit 79ba67cf00
5 changed files with 310 additions and 3 deletions

View file

@ -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

View file

@ -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")

View file

@ -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))

283
open_terminal/notebooks.py Normal file
View file

@ -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

View file

@ -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"