Notebook Kernel — Run User Python in the Browser
exec() user code in a per-user namespace, capture stdout, return the output. The sandboxed kernel behind the /notebook page.
# The basic concept: exec() runs Python code inside a namespace
namespace = {} # shared state between cells
# Cell 1
exec('x = 42', namespace)
print(namespace.keys()) # dict_keys(['x', '__builtins__'])
print(namespace['x']) # 42
# Cell 2 (sees x from cell 1)
exec('print(x * 2)', namespace) # prints 84
# Capture stdout
import io, contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
exec('print("captured!")', namespace)
print(buf.getvalue()) # 'captured!\n'
# The namespace dict IS the kernel state — variables persist across cells.
# Step 9: Add /notebook + /api/notebook/cell on top of steps 1-8
import io, contextlib, traceback
from concurrent.futures import ThreadPoolExecutor
from fastapi import Body
from pydantic import BaseModel
# Per-user kernel state
_sessions: dict[int, dict] = {} # user_id -> {'namespace': dict, 'lock': Lock}
executor = ThreadPoolExecutor(max_workers=4)
class CellRequest(BaseModel):
code: str
def get_session(user_id: int) -> dict:
if user_id not in _sessions:
_sessions[user_id] = {'namespace': {}, 'lock': __import__('threading').Lock()}
return _sessions[user_id]
def _run_sync(code: str, namespace: dict) -> dict:
'''Run code synchronously, capture stdout + errors.'''
buf = io.StringIO()
try:
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
exec(code, namespace)
return {'status': 'ok', 'output': buf.getvalue()}
except Exception:
return {'status': 'error', 'output': buf.getvalue() + traceback.format_exc()}
@app.post('/api/notebook/cell')
async def run_cell(
request: Request,
body: CellRequest,
db: Session = Depends(get_db),
):
# Auth: get the user
token = request.cookies.get('access_token')
username = decode_token(token)
if not username:
return {'error': 'not logged in'}
user = db.query(User).filter(User.username == username).first()
# Get (or create) this user's kernel session
session = get_session(user.id)
# Run the code in a thread (so we don't block the event loop)
# Use a lock so only one cell runs at a time per user
with session['lock']:
import asyncio
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
executor, _run_sync, body.code, session['namespace']
)
return result
@app.get('/notebook', response_class=HTMLResponse)
async def notebook_page(request: Request, db: Session = Depends(get_db)):
token = request.cookies.get('access_token')
username = decode_token(token)
if not username:
return RedirectResponse(url='/login?next=/notebook', status_code=303)
return templates.TemplateResponse('notebook.html', {'request': request})
{% extends 'base.html' %}
{% block title %}Notebook{% endblock %}
{% block content %}
<h1>Notebook</h1>
<textarea id='code' rows='6' style='width:100%; font-family:monospace;'
>print("hello world")
x = 42
print(x)</textarea>
<br>
<button onclick='runCell()' style='margin:0.5rem 0;'>Run</button>
<pre id='output' style='background:#0d1117; color:#e6edf3; padding:1rem; border-radius:6px; min-height:100px; white-space:pre-wrap;'></pre>
<script>
async function runCell() {
const code = document.getElementById('code').value;
const output = document.getElementById('output');
output.textContent = 'running...';
const res = await fetch('/api/notebook/cell', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({code}),
});
const data = await res.json();
output.textContent = data.output || data.error || '(no output)';
}
</script>
{% endblock %}
How it works
A notebook kernel is just exec() with a persistent namespace. exec(code, namespace) runs the code string as Python, and any variables it creates stay in namespace. The next cell call passes the SAME namespace, so variables persist across cells — that's the magic. Three critical details: (1) Capture stdout with contextlib.redirect_stdout — without it, print() goes to the server's terminal, not the browser. (2) Use a per-user lock so two cells from the same user don't run simultaneously and corrupt the namespace. (3) Run in a thread pool via run_in_executor — exec() is synchronous and CPU-bound; if you run it directly in an async route, it blocks the entire event loop (no other request can be served while a cell runs). The ThreadPoolExecutor offloads it so the event loop stays responsive. This is a simplified version — the real OpenBenchML notebook has timeout enforcement, package installation, a WebSocket terminal, and file management.
- Running exec() directly in an async route — it blocks the event loop. ALWAYS use run_in_executor for CPU-bound work.
- Not capturing stdout — print() goes to the server terminal, the browser sees nothing. Use contextlib.redirect_stdout.
- Sharing one namespace across all users — user A's variables leak to user B. Use a per-user dict keyed by user_id.
- No timeout — `while True: pass` hangs the server forever. Use a thread with a timeout, or signal.alarm.
- Security: exec() runs ARBITRARY code. In production, sandbox with Docker, seccomp, or a separate VM. Never exec() untrusted code in the main process.