Runnable Python cells
<RunnablePython> embeds a CodeMirror editor + a Run button + an output
panel directly in a lesson. Python runs in the browser via Pyodide
(WebAssembly), so there's no server round-trip and no shared state across
learners.
import RunnablePython from '@/components/pyodide/RunnablePython.tsx';
<RunnablePython
client:visible
caption="Edit and run β your changes don't leave the browser."
initialCode={`name = "world"
print(f"Hello, {name}!")
`}
/>
What the learner sees:
- A code editor with the
initialCodepre-filled. - A Run button (or your
runLabeloverride). - A status indicator while Pyodide boots / the snippet runs.
- After Run: stdout and stderr panels, plus exception traceback if Python raised.
Propsβ
| Prop | Type | Required | Default | Notes |
|---|---|---|---|---|
initialCode | string | yes | β | The Python source the learner sees |
caption | string | no | β | Short description rendered above the editor |
runLabel | string | no | "Run" | Button label override (e.g. "Fit model") |
Source: apps/docs/src/components/pyodide/RunnablePython.tsx.
How it worksβ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Main thread β React island β
β <RunnablePython> β
β β uses β
β useRunner() hook β
β β spawns β
β Worker(pyodide.worker.ts, type: 'module') β
ββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββ
β postMessage
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Worker thread β
β import('../pyodide/pyodide.module.js') β
β β β
β loadPyodide({ indexURL: '../pyodide/' }) β
β β β
β On run: β
β captureStdoutStderr() β
β pyodide.runPython(code) β
β return { stdout, stderr, error? } β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Key files:
pyodide.worker.tsβ the Pyodide worker.useRunner.tsβ the React hook that mediates between island and worker.
First-run costβ
Pyodide is ~13 MB (WASM + stdlib). The first Run click on a page boots
Pyodide; expect 2β5 s on a warm cache, longer on cold. Subsequent runs are
fast (50β200 ms for small snippets).
The component shows status messages during boot:
Loading Pyodideβ¦
Pyodide ready.
Runningβ¦
Done.
So the learner knows what's happening.
Examplesβ
Simple expressionβ
<RunnablePython
client:visible
initialCode={`# Try changing the operator
print(2 ** 8)
`}
/>
Multi-line scriptβ
<RunnablePython
client:visible
caption="Pure functions, no I/O β fast on every run."
initialCode={`def fib(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
for i in range(10):
print(f"fib({i}) = {fib(i)}")
`}
/>
Stdin / input β not yetβ
input() doesn't work in Pyodide-in-worker yet because it's a synchronous
blocking call and the worker has no terminal. Stub it for the lesson:
<RunnablePython
client:visible
initialCode={`# In a real terminal you'd use input(). Here, hardcode:
name = "Robot"
print(f"Hello, {name}!")
`}
/>
The framework's runner roadmap includes COOP/COEP-page support that
unblocks input() (Phase 3 per the source's notes). Until then, this
limitation is real.
Imports from the standard libraryβ
Most pure-Python stdlib modules work:
<RunnablePython
client:visible
initialCode={`from collections import Counter
words = "the quick brown fox jumps over the lazy dog".split()
counts = Counter(words)
print(counts.most_common(3))
`}
/>
Imports from third-party packagesβ
Pyodide can pip-install pure-Python wheels and a curated set of native
packages (numpy, pandas, scipy, etc.). The base bundle doesn't include
them β you'd await pyodide.loadPackage('numpy') from JS first. The
current RunnablePython MVP doesn't expose this; for now, stick to the
stdlib. If you need numpy/pandas/etc. in a lesson, file an issue.
Long-running cellβ
<RunnablePython
client:visible
runLabel="Fit model"
initialCode={`import time
print("Trainingβ¦")
time.sleep(2)
print("Done.")
`}
/>
The button label changes from Run to Fit model. While running, the button shows Running⦠and is disabled.
What the learner can and can't doβ
β
Edit any character of initialCode and re-run.
β
Use any pure-Python stdlib module.
β
See full traceback on errors.
β
See stdout and stderr in separate panels.
β
Iterate freely β runs are sandboxed; nothing escapes the worker.
β Use input() (deferred to Phase 3).
β Persist state across page reloads (deferred).
β Fetch arbitrary URLs without CORS shenanigans (use pyodide.loadPackage for known mirrors).
β Spawn subprocesses, open sockets, or write to disk (Pyodide is
sandboxed).
Authoring tipsβ
- Show
printoutput, not return values. Pyodide doesn't echo the last expression (no REPL). Wrap the interesting value in aprint(...)so the learner sees something. - Keep snippets to ~20 lines. Longer snippets feel like cheating in a lesson; if you need 50 lines of setup, it belongs in a real notebook, not a lesson cell.
- Surface the gotchas. Python's
0.1 + 0.2 == 0.3 β Falseis perfect cell material. So is[1, 2] == [1.0, 2.0] β True(type coercion). - Don't
import osand ask learners to do filesystem things. Pyodide's filesystem is virtual;os.listdir('/')shows Pyodide's internal layout, which is a distraction. - Consider replacing with
<RunnableRobot>if your lesson is about Robot Framework specifically. RF in Pyodide gives the learner real RF semantics (test cases, keywords, log.html / report.html).
SCORM packaging implicationsβ
Pyodide is heavy. By default the SCORM zip excludes Pyodide; the runner falls back to fetching it from the same origin (which fails in a zero-network LMS). To bundle Pyodide for offline-capable LMS delivery:
INCLUDE_PYODIDE_RUNTIME=1 \
node apps/docs/scripts/package-scorm12.mjs
This adds ~6.3 MB compressed to the zip. See SCORM 1.2 packaging for the full surface.
Where to go nextβ
- Runnable Robot Framework cells β full RF in-browser via the same Pyodide infrastructure, but with the RF wheel + CodeMirror's RF grammar pre-wired.
- Code blocks β when you only need to show Python, not run it.