Created
May 13, 2026 16:01
-
-
Save bbelderbos/4921cc7aba4d78f99b9bacbd49c8f934 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """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