Concepts 🚀 Project 🧮 Labs
📚 Learn / Build OpenBenchML — Step by Step / Notebook Kernel — Run User Python in the Browser
🚀 Step 9 of 10
← Previous: Step 8 — WebSocket — Live Leaderboard Updates

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.

🎯 Goal — what you'll achieve in this step Visit /notebook, type print('hello world'), click Run, and see 'hello world' in the output panel. Then type x = 42 in one cell and print(x) in another — the second cell sees x.
📖 The basic concept Learn this first. It's the pure Python/HTML idea, stripped of all app complexity.
# 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.
🧮 Apply it to OpenBenchML
📄 main.py python
# 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})
📄 templates/notebook.html html
{% 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.

✏️ Try yourself Add a timeout: wrap _run_sync in a threading.Timer that kills the exec after 10 seconds. Add a 'Reset Kernel' button that clears the namespace dict. Add matplotlib support: if the code calls plt.savefig(), capture the PNG and display it.
⚠️ Common mistakes to avoid
  • 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.
🔍 Where this is used in OpenBenchML app/routes/notebook.py — /notebook page + /api/notebook/cell endpoint. The real version has timeout enforcement, package installation, WebSocket terminal, file management, and runs in a Docker sandbox.
Next — Step 10
Docker + Render — Ship It to the Internet
→