A self-contained Python editor web component powered by Pyodide and Codemirror. Runs entirely in the browser — no server required. No code is ever passed to the server, there is no login or account feature, so the component is safe to use from exam systems such as SafeExamBrowser or isTest2.
Source: github.com/tkilla77/python_editor_wasm
See also: Kara grid world → · Platform integrations (DokuWiki, H5P, Moodle) →
Load the component script, then drop <bottom-editor> anywhere on the page. The initial Python code goes as text content of the element.
<script type="module" src="https://bottom.ch/editor/stable/bottom-editor.js"></script>
<bottom-editor autorun>
for i in range(5):
print(i ** 2, end=' ')
</bottom-editor>
| Attribute | Values | Default | Description |
|---|---|---|---|
layout |
console | canvas | split |
console |
Which output panel(s) to show |
autorun |
boolean | off | Run code automatically on load |
showclear / showrevert |
boolean | off | Show a Revert button (red eraser) that resets editor code to its initial state and clears canvas/output. A Reset button (gray arrow, canvas/world only) is shown automatically when a canvas is present. showclear is a backward-compatible alias for showrevert. |
showswitcher |
boolean | off | Show canvas/console toggle rail (used by the standalone page) |
orientation |
auto | horizontal | vertical |
auto |
Force layout direction regardless of container width |
session |
string | — | Share a Pyodide worker with other editors that use the same session name |
zip |
URL | — | Zip archive to unpack into the virtual filesystem before running |
timeout |
seconds or inf |
30 |
Maximum run time in seconds; inf disables the timeout |
id |
string | — | Enables localStorage persistence; code is saved and restored on reload (see Editor persistence) |
storage |
local | none |
local when id set |
Storage backend; none opts out even when id is present |
sourcecode |
string | — | Set code programmatically (property, not reflected attribute) |
mainfile |
string | main.py |
Tab label for the primary editor file when multi-file mode is active (see Multi-file support) |
multifile |
boolean | off | Show the file tabs even when there is only one file, so users can add their own (see Multi-file support) |
stepmode |
boolean | off | Run plays the program line by line, with the current line and its variables shown in the editor; the step bar is always visible (see Energy, memory and stepping) |
nodebug |
boolean | off | Switch off breakpoints (clicking a line number to pause there) |
showenergy |
boolean | off | Count executed lines and show the total below the editor after each run (see Energy, memory and stepping) |
Set layout="canvas" to show only the canvas, or layout="split" for
canvas and console side by side.
<bottom-editor layout="canvas" showclear>
import turtle
t = turtle.Turtle()
t.speed(6)
colors = ['red', 'orange', 'gold', 'green', 'blue', 'purple']
for i in range(90):
t.pencolor(colors[i % len(colors)])
t.forward(i * 0.5)
t.left(59)
</bottom-editor>
plt.show() renders the figure to the canvas.
<bottom-editor layout="canvas" showswitcher>
import micropip
await micropip.install('matplotlib')
import matplotlib.pyplot as plt
import math
x = [i * 0.1 for i in range(63)]
plt.figure(figsize=(5, 4))
plt.plot(x, [math.sin(v) for v in x], label='sin')
plt.plot(x, [math.cos(v) for v in x], label='cos')
plt.legend()
plt.title('Trigonometric functions')
plt.tight_layout()
plt.show()
</bottom-editor>
Editors with the same session attribute share a single Pyodide worker.
Functions or variables defined in one editor are immediately available in
the other.
<bottom-editor session="demo" autorun>
def greet(name):
print(f"Hi, {name}!")
</bottom-editor>
<bottom-editor session="demo" autorun>
greet("World")
</bottom-editor>
Without a session attribute, all editors on a page share one default session. That
matters for step mode in two ways:
Give editors that should not affect each other different session names.
By default the layout switches from vertical (code above, output below) to
horizontal (code left, output right) when the container is wider than 768 px.
Use orientation="horizontal" or orientation="vertical" to lock it.
<bottom-editor orientation="vertical">
print("lways fills horizontally.")
</bottom-editor>
The default run timeout is 30 s. Set timeout="inf" for long-running code,
or a lower value for stricter sandboxing.
<bottom-editor timeout="5" session="timeout-demo">
# will be interrupted after 5 s
while True:
pass
</bottom-editor>
<bottom-exercise> wraps <bottom-editor> with exercise semantics: a prompt,
starter code, test assertions, and a results panel. The Run button runs the tests;
the Reset button restores the starter code.
Load bottom-exercise.js instead of (or alongside) bottom-editor.js:
<script type="module" src="https://bottom.ch/editor/stable/bottom-exercise.js"></script>
Place starter code and tests in <template> elements inside the component.
The prompt goes in a <div slot="prompt">:
<bottom-exercise>
<div slot="prompt">
<p>Write a function <code>sum_to(n)</code> that returns 1 + 2 + … + n.</p>
</div>
<template data-type="starter">
def sum_to(n):
pass
</template>
<template data-type="test">
assert sum_to(5) == 15, "sum_to(5) should be 15"
assert sum_to(1) == 1
assert sum_to(0) == 0
</template>
</bottom-exercise>
Write a function sum_to(n) that returns 1 + 2 + … + n.
Return 0 for n ≤ 0.
Some CMS platforms strip <template> elements, or wrap the whole block in <p>
which the HTML parser uses to eject block-level children. Use
<script type="text/x-starter"> / <script type="text/x-test"> instead — they
are phrasing content and survive both problems. For the prompt, use inline text
rather than a <div> for the same reason:
<bottom-exercise>
Write a function <code>fizzbuzz(n)</code> that returns <code>"Fizz"</code>,
<code>"Buzz"</code>, <code>"FizzBuzz"</code>, or the number as a string.
<script type="text/x-starter">
def fizzbuzz(n):
pass
</script>
<script type="text/x-test">
assert fizzbuzz(3) == "Fizz"
assert fizzbuzz(5) == "Buzz"
assert fizzbuzz(15) == "FizzBuzz"
assert fizzbuzz(7) == "7"
</script>
</bottom-exercise>
fizzbuzz(n) that returns "Fizz" if divisible
by 3, "Buzz" if divisible by 5, "FizzBuzz" if both, or the
number as a string otherwise.
Each test statement runs as a separate Python assertion in the same namespace as the user's code. Two helpers are automatically available for exercises where the student prints output rather than returning a value:
| Helper | Returns |
|---|---|
output() |
Everything the code printed, as a single string (newlines included) |
output_lines() |
output().splitlines() — each printed line as a clean string |
with_input(*inputs) |
Re-runs the student's code with the given strings queued as input() responses,
updates output() / output_lines() to reflect that run, and returns True |
energy() |
Number of lines the student's code executed (see Energy, memory and stepping) |
memory() |
Number of non-blank, non-comment lines in the student's code |
Output is still shown in the output panel as normal — the helpers just provide a second view of the same text for assertions.
<bottom-exercise>
<div slot="prompt"><p>Print the numbers 1 to 5, one per line.</p></div>
<template data-type="starter">
# your code here
</template>
<template data-type="test">
assert output_lines() == ["1", "2", "3", "4", "5"], \
f"Expected lines 1–5, got {output_lines()!r}"
</template>
</bottom-exercise>
Print the numbers 1 to 5, one per line.
Other useful patterns:
assert "hello" in output().lower() # substring check
assert len(output_lines()) == 10 # line count
assert output().strip() == "42" # ignore trailing newline
Use with_input() to test programs that call input(). It re-runs the student's
code with the supplied strings as answers and updates output_lines() accordingly.
Because it always returns True it can be chained directly in an assertion:
<bottom-exercise>
<div slot="prompt"><p>Read two numbers and print their product.</p></div>
<template data-type="starter">
a = int(input("First number: "))
b = int(input("Second number: "))
print(a * b)
</template>
<template data-type="test">
assert with_input("3", "5") and output_lines()[-1] == "15", \
f"Expected 15 for inputs 3 and 5, got {output_lines()!r}"
assert with_input("0", "99") and output_lines()[-1] == "0", \
f"Expected 0 for inputs 0 and 99, got {output_lines()!r}"
</template>
</bottom-exercise>
Read two numbers and print their product.
Add a <template data-type="solution"> (or <script type="text/x-solution">) to
provide a model solution. A Show solution button appears and requires a
confirmation click before replacing the editor contents. The solution is also
included in shared permalinks by default.
Add the hidesolution boolean attribute to suppress the button and exclude the
solution from permalinks — useful while a lesson is in progress. Remove it
to release the solution to students:
<!-- solution hidden during the lesson -->
<bottom-exercise hidesolution>
...
<template data-type="solution">
def sum_to(n):
return n * (n + 1) // 2
</template>
</bottom-exercise>
<!-- solution released after the lesson (attribute removed) -->
<bottom-exercise>
...
</bottom-exercise>
The attribute can also be toggled at runtime:
document.querySelector('bottom-exercise').hidesolution = false;
Write a function sum_to(n) that returns 1 + 2 + … + n.
Return 0 for n ≤ 0.
For embedding in a CMS, the solution attribute accepts plain text or a
data URL
(useful for multi-line code encoded as base64):
<!-- plain text (simple cases) -->
<bottom-exercise solution="return n * (n + 1) // 2">...</bottom-exercise>
<!-- base64 (multi-line, no escaping needed) -->
<bottom-exercise solution="data:text/plain;base64,ZGVmIHN1bV90...">...</bottom-exercise>
In a multi-file exercise, give a solution a filename to make it the
solution for that file; a solution without filename belongs to the main file. Show solution
replaces each file that has a solution and leaves the others as they are. A file the student
deleted comes back.
<bottom-exercise multifile>
<template data-type="starter">from toolbox import square</template>
<template filename="toolbox.py">def square(n):
pass</template>
<template data-type="solution" filename="toolbox.py">def square(n):
return n * n</template>
</bottom-exercise>
<script type="text/x-solution" filename="…"> works the same way.
solved and viewed-solution are both terminal states: showing the solution
after solving keeps the solved badge; passing tests after viewing the solution
keeps the viewed-solution badge.
Tests are optional. Without a <template data-type="test"> block, the Run
button simply executes the code and shows output — useful for turtle graphics,
open-ended prompts, or stages where students haven't learned functions yet.
A solution can still be provided.
<bottom-exercise id="turtle-square">
<div slot="prompt"><p>Draw a square with side length 100.</p></div>
<template data-type="starter">
import turtle
t = turtle.Turtle()
# your code here
</template>
<template data-type="solution">
import turtle
t = turtle.Turtle()
for _ in range(4):
t.forward(100)
t.right(90)
</template>
</bottom-exercise>
| Attribute | Default | Description |
|---|---|---|
id |
— | localStorage key for persistence; page-scoped (same as <bottom-editor>). Without id, nothing is saved. |
layout |
console |
Which output panel(s) to show (console | canvas | split) |
session |
— | Share a Pyodide worker with other editors on the page |
orientation |
auto |
Layout direction (auto | horizontal | vertical) |
timeout |
30 |
Run timeout in seconds; inf disables it |
zip |
— | Zip archive to pre-load into the virtual filesystem |
hidesolution |
off | Hide the Show solution button and exclude the solution from shared permalinks. Remove to release the solution to students. |
maxenergy |
— | Maximum lines the solution may execute; exceeding it adds a failing energy() <= N result |
showenergy |
off | Show the energy of each run below the editor |
stepmode |
off | Run plays the student's code line by line; the tests then run at full speed |
nodebug |
off | Switch off breakpoints |
multifile |
off | Show file tabs so students can add files (see Multi-file support) |
mainfile |
main.py |
Tab name of the starter code in multi-file mode |
maxmemory |
— | Maximum non-blank, non-comment lines of code; exceeding it adds a failing memory() <= N result |
Add an id attribute to save exercise state to localStorage. The key is
page-scoped (using <link rel="canonical"> or location.pathname+search,
same as <bottom-editor>), so the same id on different pages is safe and
a review chapter always starts fresh.
The state machine:
pristine → started → attempted ──→ solved
↘→ viewed-solution
solved and viewed-solution are terminal. For test-free exercises the state
only advances to started.
Add an id attribute to persist editor contents in localStorage. The code is restored on the next page load. A Revert button appears automatically in the toolbar, restoring the original code and clearing the saved entry.
<bottom-editor id="hello-world">
print("Hello, world!")
</bottom-editor>
The storage key combines the page URL and the element id, so reusing the same id on different pages is safe.
Opt out on a specific editor with storage="none":
<bottom-editor id="demo" storage="none">...</bottom-editor>
Site-wide default — set window.BottomEditorConfig before the script tag to configure all editors on the page (useful for a site-wide include):
<script>window.BottomEditorConfig = { storage: 'none' }</script>
<script type="module" src="bottom-editor.js"></script>
Page identity — the storage key uses <link rel="canonical"> (pathname + search) if present, falling back to location.pathname + location.search. Fragments are excluded so anchor-link navigation never orphans saved state. CMS systems that identify pages by query parameter (e.g. ?id=mypage) work correctly when a canonical tag is present.
<bottom-exercise>)When the deployed build includes cloud credentials, a Sync button appears in every <bottom-exercise>. Students can connect Google Drive or OneDrive so their progress follows them across devices and browsers.
Cloud state is layered on top of localStorage: every edit is written locally first; cloud writes are debounced by 2 s. On page load, localStorage is applied immediately and the cloud copy is fetched in the background.
The build may support multiple cloud providers, but a site administrator or page author can limit which ones are offered — useful when a school mandates a specific provider.
Site-wide (e.g. in a shared header include):
<script>
window.BottomEditorConfig = {
storageBackends: ['microsoft'], // only OneDrive; omit key to allow all compiled-in backends
}
</script>
<script type="module" src="https://bottom.ch/editor/stable/bottom-editor.js"></script>
Page-wide (same pattern, just on one page):
<script>
window.BottomEditorConfig = { storageBackends: ['google'] }
</script>
storageBackends is the subset of ['google', 'microsoft'] to offer. Providers not compiled into the build are silently ignored. Omitting the key (or setting it to a non-array) shows all compiled-in backends.
Four custom properties can be set on the element or any ancestor:
| Property | Default | Description |
|---|---|---|
--be-border |
1px solid #d4d4d4 |
Border of editor, console, and canvas boxes |
--be-border-radius |
4px |
Corner radius of those boxes |
--be-editor-bg |
white |
Background of the code editor and canvas |
--be-output-bg |
#f5f5f5 |
Background of the console output |
<style>
.dark-editor {
--be-border: none;
--be-border-radius: 0.25em;
--be-editor-bg: #1e1e1e;
--be-output-bg: #1e1e1e;
color-scheme: dark;
}
</style>
<bottom-editor class="dark-editor">
print("Dark theme")
</bottom-editor>
A <bottom-editor> or <bottom-exercise> can manage several files at once.
A tab bar appears above the editor; each tab is an independent file in Pyodide's
virtual filesystem, so the active file can import or open() any of the others by name.
Add one or more <template filename="…"> children. Each becomes a tab next
to the main editor. Filenames are free-form — .py modules, .csv data files,
anything the running code needs to read.
<bottom-editor mainfile="main.py">
import csv, io
with open("scores.csv") as f:
rows = list(csv.DictReader(f))
for row in rows:
print(row["name"], "→", row["score"])
<template filename="scores.csv">
name,score
Alice,92
Bob,87
Carol,95
</template>
</bottom-editor>
The + button in the tab bar creates a new blank file. Any file except the
main one can be closed with its × button. Added and deleted files are saved in
localStorage alongside the code. The Revert button goes back to the original files:
it removes added files, brings deleted ones back, and restores every file's original content.
Editing a file that the main program imports takes effect on the next run.
To let users create files without providing any yourself, add multifile. The tab
bar then appears with just the main file:
<bottom-editor multifile>
print("Use + to add a module, then import it here")
</bottom-editor>
The first tab is always the main file (the text content of the element, or its
sourcecode property). Run always executes it, whichever tab is open, and it can't be
deleted. Step mode switches to it, and error messages name it (main.py, line 3: …). Use mainfile="…" to give it a meaningful name — the
default is main.py. The name matters when other files import it:
<bottom-editor mainfile="helpers.py">
# helpers.py is importable from any other tab
def greet(name):
print(f"Hello, {name}!")
<template filename="main.py">
from helpers import greet
greet("world")
</template>
</bottom-editor>
In multi-file mode, all tabs are saved together in localStorage (when an
id attribute is present). The Revert button resets every tab to its initial
template content and removes any user-added tabs.
The permalink button encodes all files as a ?files=[…] JSON parameter
instead of the single-file ?code=… parameter, so the full multi-file state is
captured in the URL. Files that came from a zip are left out unless they were edited:
the link keeps the zip URL, which brings them back.
Long links can fail: many web servers reject URLs over about 8,000 characters. When a copied
link is longer than that, the Share button turns amber and says Long link!. Its
tooltip names the largest files. Large data files are better shipped in a zip.
<bottom-exercise> takes the same <template filename> children,
multifile and mainfile attributes. The starter code is the main file. Tests
run with every file in place, and exercise permalinks carry the extra files.
<bottom-exercise id="stats" mainfile="stats.py">
<template data-type="starter">
rows = open("scores.csv").read().splitlines()[1:]
</template>
<template filename="scores.csv">
name,score
Alice,92
</template>
<template data-type="test">
assert len(rows) == 1
</template>
</bottom-exercise>
Two simple cost measures help students compare solutions:
Some examples:
| Code | Energy | Why |
|---|---|---|
x = 3 | 1 | one line, run once |
x = 3 / print(x) | 2 | two lines, each run once |
for i in range(3): / print(i) | 7 | the loop header runs 4 times (3 iterations plus the final check that ends the loop), the body 3 times |
def f(): / return 1 / f() | 3 | the def line, the call, and the function body once |
Only the student's own lines count. Code inside library functions (including print) and
code injected by the page, such as the Kara world helpers, is free.
Counting slows down every line, so it is off unless something needs it. It is switched on by
showenergy (shows ⚡ N below the editor after each run), by
stepmode, and in exercises by maxenergy or by tests that call energy().
Everything else runs at full speed.
<bottom-editor showenergy>
for i in range(3):
print(i)
</bottom-editor>
Every editor has a debugger. Click a line number to set a breakpoint (a red dot); click it again to remove it. Run then executes normally until it reaches a breakpoint and pauses there. The line about to run is highlighted, and the current variables are shown next to it. A bar below the code appears while paused:
Variables are shown as Python would print them, except for objects without a readable form:
an open file or a turtle appears as <TextIOWrapper> or <Turtle>
instead of <turtle.Turtle object at 0x129df80>. Objects that define
__str__ show that instead.
Breakpoints can be added and removed while paused. Without breakpoints, nothing changes: Run is as
fast as ever and the bar never appears. Add nodebug to switch breakpoints off.
Try it: click line 3, then Run.
With stepmode, Run itself plays the program line by line at the slider's speed, and
the bar is always visible. Its Step button starts a run paused on the first line.
Students see every line execute without having to set breakpoints. The energy counter increases
with every line, so they can see where the cost comes from.
<bottom-editor stepmode>
total = 0
for i in range(1, 4):
total += i
print(total)
</bottom-editor>
Stepping works for any program, including input() and turtle or Kara graphics: the
canvas shows the drawing up to the highlighted line.
Stepping needs a current browser: Chrome or Edge 137+, Firefox 153+, or Safari 27+. It uses WebAssembly JavaScript Promise Integration (JSPI) and needs no special server headers. In older browsers the breakpoint gutter is hidden, and in step mode the bar says that stepping is unavailable.
Tests can check both measures with the energy() and memory() helpers:
<template data-type="test">
assert sum_to(100) == 5050
assert energy() <= 5, "Can you do it without a loop?"
</template>
For the common case, the maxenergy and maxmemory attributes on
<bottom-exercise> add the check for you. It is shown as an extra line in the test results:
<bottom-exercise id="sum-fast" maxenergy="5" maxmemory="3">
…
</bottom-exercise>
energy() counts the student's code only, not the test assertions themselves.
zipUse the zip attribute to pre-load a .zip archive into the virtual
filesystem before the editor runs. Useful for distributing helper modules
or data files alongside an exercise.
<bottom-editor zip="https://example.com/exercises.zip" autorun>
import mymodule
mymodule.run()
</bottom-editor>
The archive is unpacked into /home/pyodide/ (the default Python path),
so any .py files inside are directly importable.
In multi-file mode (with multifile or <template filename> children), the
zip's text files also appear as tabs, so students can read and edit them. Binary files, folders
and hidden files stay in the filesystem without a tab. Revert restores the zip's original contents.
CORS required. The server hosting the zip must send
Access-Control-Allow-Origin: *(or the page origin).
The element exposes a few properties and methods:
const editor = document.querySelector('bottom-editor');
// Wait for Pyodide to be ready
await editor.ready;
// Read / set source code
console.log(editor.sourceCode);
editor.sourceCode = 'print(42)';
// Run programmatically
await editor.evaluatePython();
// Read last output
console.log(editor.outputText);