Skip to content

Instantly share code, notes, and snippets.

@bbelderbos
Created May 13, 2026 16:01
Show Gist options
  • Select an option

  • Save bbelderbos/4921cc7aba4d78f99b9bacbd49c8f934 to your computer and use it in GitHub Desktop.

Select an option

Save bbelderbos/4921cc7aba4d78f99b9bacbd49c8f934 to your computer and use it in GitHub Desktop.
"""Single-file Pyodide exercise starter.
Run:
python pyodide_starter.py
Writes index.html next to this file and serves it on http://localhost:8765.
Open the URL, hit "Run tests", and watch pytest run inside the browser
against a small stdlib Pybites Bite (anagrams). Edit the HTML to swap in
your own exercises.
Companion post:
https://belderbos.dev/blog/python-exercises-browser-pyodide/
"""
import http.server
import socketserver
import webbrowser
from pathlib import Path
PORT = 8765
HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Pyodide exercise starter</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.css">
<style>
body { font: 15px/1.5 system-ui, sans-serif; max-width: 820px; margin: 2rem auto; padding: 0 1rem; color: #222; }
h1 { margin: 0 0 .25rem; }
p.lede { color: #555; margin: 0 0 1.5rem; }
.tabs { display: flex; gap: .25rem; border-bottom: 1px solid #ccc; margin: 0 0 -1px; }
.tab { padding: .4rem .9rem; cursor: pointer; border: 1px solid transparent;
border-bottom: 0; border-radius: 6px 6px 0 0; color: #555; font-size: 14px; background: transparent; }
.tab.active { background: #fff; border-color: #ccc; color: #222; font-weight: 600; }
.panel { border: 1px solid #ccc; border-radius: 0 6px 6px 6px; padding: 0; overflow: hidden; }
.panel[hidden] { display: none; }
.CodeMirror { height: 300px; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
button { padding: .6rem 1.2rem; font-size: 15px; border: 0; border-radius: 6px;
background: #1a73e8; color: #fff; cursor: pointer; }
button:disabled { background: #888; cursor: wait; }
#status { margin-left: .75rem; color: #555; }
pre.output { background: #111; color: #eee; padding: 1rem; border-radius: 6px;
white-space: pre-wrap; min-height: 80px; margin-top: 1rem;
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
pre.output.pass { box-shadow: inset 0 0 0 2px #2ea043; }
pre.output.fail { box-shadow: inset 0 0 0 2px #cf222e; }
</style>
</head>
<body>
<h1>Pyodide exercise starter</h1>
<p class="lede">Minimal in-browser Python runner. Pyodide + pytest, no backend, no install.
Implement <code>is_anagram(word1, word2)</code> in the Code tab, click Run, watch the tests go green.</p>
<div class="tabs">
<button class="tab active" data-tab="code">Code</button>
<button class="tab" data-tab="tests">Tests</button>
</div>
<div class="panel" id="panel-code">
<textarea id="editor">from collections import Counter
def is_anagram(word1: str, word2: str) -> bool:
"Return True if word2 is an anagram of word1 (ignore case and spacing)."
# your code here
return False
</textarea>
</div>
<div class="panel" id="panel-tests" hidden></div>
<p><button id="run">Run tests</button><span id="status">idle</span></p>
<pre class="output" id="output">(output will appear here)</pre>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/python/python.js"></script>
<script>
// ---- Pyodide config --------------------------------------------------------
// Pin a Pyodide version (~5MB over the CDN, cached on first hit).
const PYODIDE_VERSION = "0.27.7";
const PYODIDE_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
// ---- The exercise's hidden test suite --------------------------------------
// Lives in JS so we can rewrite it to Pyodide's virtual filesystem on every Run.
// The Tests tab renders this same string in a read-only CodeMirror so learners
// can see what they're being graded against.
const TEST_CODE = `
from exercise import is_anagram
def test_is_anagram():
assert is_anagram("rail safety", "fairy tales")
def test_is_not_anagram():
assert not is_anagram("restful", "fluester")
`;
// ---- Lazy Pyodide boot -----------------------------------------------------
// Pyodide isn't loaded with the page — it only boots when someone is about to
// run code. `bootPromise` deduplicates: focus + hover both await the same
// in-flight promise, so we never start two parallel boots.
let pyodide = null;
let bootPromise = null;
async function ensurePyodide() {
if (pyodide) return pyodide;
if (bootPromise) return bootPromise;
bootPromise = (async () => {
// Inject pyodide.js from the CDN on demand (kept out of the HTML <head>).
if (typeof loadPyodide !== "function") {
await new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = PYODIDE_URL + "pyodide.js";
s.onload = resolve;
s.onerror = () => reject(new Error("Failed to load pyodide.js"));
document.head.appendChild(s);
});
}
pyodide = await loadPyodide({ indexURL: PYODIDE_URL });
// pytest isn't shipped in the base distro — pull it as a separate package.
await pyodide.loadPackage(["pytest"]);
// /home/pyodide/work is Pyodide's in-browser filesystem. We'll write
// exercise.py and test_exercise.py here on every Run.
pyodide.FS.mkdirTree("/home/pyodide/work");
pyodide.runPython(`
import sys
if "/home/pyodide/work" not in sys.path:
sys.path.insert(0, "/home/pyodide/work")
`);
return pyodide;
})();
return bootPromise;
}
const runBtn = document.getElementById("run");
const statusEl = document.getElementById("status");
const output = document.getElementById("output");
// ---- CodeMirror editors ----------------------------------------------------
// Code: editable, Python-highlighted. Tests: read-only view of TEST_CODE.
const cm = CodeMirror.fromTextArea(document.getElementById("editor"), {
mode: "python",
lineNumbers: true,
indentUnit: 4,
});
const cmTests = CodeMirror(document.getElementById("panel-tests"), {
mode: "python",
lineNumbers: true,
readOnly: true,
value: TEST_CODE.trim(),
});
// ---- Tab switching ---------------------------------------------------------
document.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((b) => b.classList.toggle("active", b === btn));
const target = btn.dataset.tab;
document.getElementById("panel-code").hidden = target !== "code";
document.getElementById("panel-tests").hidden = target !== "tests";
// CodeMirror miscalculates layout when its container starts hidden;
// refresh on show so gutters/cursor line up.
if (target === "code") cm.refresh();
if (target === "tests") cmTests.refresh();
});
});
function setStatus(msg) { statusEl.textContent = msg; }
async function prewarm() {
setStatus("loading runtime…");
try { await ensurePyodide(); setStatus("ready"); }
catch (e) { setStatus("load failed: " + e.message); }
}
// ---- Prewarm ---------------------------------------------------------------
// Cold start is ~3s. Kick it off the moment a learner shows intent — tabbing
// into the editor or hovering the Run button — so the runtime is usually
// ready by the time they actually click Run.
cm.on("focus", prewarm);
runBtn.addEventListener("mouseenter", prewarm, { once: true });
// ---- Run handler -----------------------------------------------------------
// 1. Make sure Pyodide is up.
// 2. Write the learner's code + the test file to Pyodide's in-browser FS.
// 3. Pipe pytest's stdout/stderr into a string we render in the output panel.
// 4. Invoke pytest.main() and color the result by exit code.
runBtn.addEventListener("click", async () => {
runBtn.disabled = true;
output.textContent = "";
output.classList.remove("pass", "fail");
setStatus("running…");
try {
const py = await ensurePyodide();
py.FS.writeFile("/home/pyodide/work/exercise.py", cm.getValue());
py.FS.writeFile("/home/pyodide/work/test_exercise.py", TEST_CODE);
// Capture stdout/stderr from Python so we can show pytest's report.
// Pyodide flushes per write — pytest writes each progress dot separately —
// so don't add our own newline here, just concatenate what Python emits.
let buf = "";
py.setStdout({ batched: (s) => { buf += s; } });
py.setStderr({ batched: (s) => { buf += s; } });
const exitCode = py.runPython(`
import os, sys
os.chdir("/home/pyodide/work")
# Drop cached modules so edits to exercise.py take effect across runs.
for name in ("exercise", "test_exercise"):
sys.modules.pop(name, None)
import pytest
int(pytest.main(["-q", "test_exercise.py"]))
`);
output.textContent = buf || "(no output)";
const passed = exitCode === 0;
output.classList.add(passed ? "pass" : "fail");
setStatus(passed ? "✓ passed" : "✗ failed");
} catch (e) {
output.textContent = String(e);
output.classList.add("fail");
setStatus("error");
} finally {
runBtn.disabled = false;
}
});
</script>
</body>
</html>
"""
def main() -> None:
out = Path(__file__).parent / "index.html"
out.write_text(HTML)
print(f"wrote {out}")
handler = http.server.SimpleHTTPRequestHandler
with socketserver.TCPServer(("", PORT), handler) as httpd:
url = f"http://localhost:{PORT}/index.html"
print(f"serving at {url} (Ctrl-C to stop)")
webbrowser.open(url)
httpd.serve_forever()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment