Run and visualize Python directly in a LiaScript course with the browser-based glpython editor.
The macros use LiaScript's native editor as the source of truth and a separately hosted iframe for execution and visualization. Python runs locally in the learner's browser through Pyodide; the current source is sent for each action through an origin-checked, versioned handshake.
Add the template to the header of a LiaScript course:
import: https://raw.githubusercontent.com/glpython-edu/glpython-liascript/main/README.md
Attach @GLPython.eval to a Python code block:
import sys
for i in range(5):
print("Hello", "World #" + str(i))
sys.version@GLPython.eval
LiaScript owns the visible editor and preserves its source through its course and PWA
state. The ▶ Run button below the block runs the current source through glpython:
it triggers LiaScript's own run action, whose </> button is hidden, so LiaScript still
saves edits and keeps its code history (the version arrows stay). Tests and
Visualize go through LiaScript's run action too, so they store the block in the same
way - a new version only if the code changed - and then test or visualize it instead of
running it: Python runs once. The glpython runtime
provides output and the step-by-step visualizer without mounting a second editor. It
does not reserve space for turtle graphics.
The macro makes the block's editor indent with 4 spaces: Tab inserts spaces, not
LiaScript's default tab character, which would stop Python with a TabError in a block
indented with spaces. It also switches off the completion as one types and the
snippets LiaScript turns on since version 2.1.1, so Enter is always a new line and Tab
never expands a word (if + Enter became a whole if statement, also in a
docstring); Ctrl+Space still completes a name on request. A block's own
data-tabsize or data-ace-options (e.g. <!-- data-ace-options="useSoftTabs=false" -->
or enableLiveAutocompletion=true) still wins.
The fenced source belongs to LiaScript. Edits therefore survive slide navigation and leaving the course followed by browser Back in the same way as the official Pyodide macro. A hard reload returns to the Markdown source baseline, which also matches the official macro's behavior.
Each macro has an isolated runtime iframe. If LiaScript recreates that iframe, the macro reattaches a clean worker and reloads the current LiaScript source; Python globals, output, turtle state, and visualization position are intentionally not serialized. Reset also terminates the current worker and creates a clean one. The post-run REPL below the output uses this per-block worker lifecycle: Run keeps the program's namespace while the worker survives, and Reset discards it without changing LiaScript's source.
When the iframe is ready, the macro sends it the block's source - for
@GLPython.visual that starts the visualization. A block inside a fragment
({{1}}) gets its editor only when the fragment appears, and LiaScript fills
the editor a moment later; so while the block's code is still empty, the macro
waits for it, at most 2 seconds (a block may be empty on purpose). Run, Tests
and the REPL don't wait.
Use @GLPython.turtle for a graphics-focused layout:
from gturtle import *
makeTurtle()
for _ in range(4):
forward(100)
right(90)@GLPython.turtle
Use @GLPython.visual when learners should explore a prepared program without
editing it. The fenced source is hidden and the visualization starts
automatically. The visualizer keeps its step buttons, scrubber, box/substitution
switch, options menu (animated steps), and synchronized output.
total = 0
for value in range(1, 5):
total += value
print(total)@GLPython.visual
Put consecutive code blocks with a file name in their header before the macro;
LiaScript shows them as one project, and glpython loads all of them, so one file
can import another. Start a file's name with - to show it collapsed (and +
to show it open):
def doppelt(x):
return 2 * xfrom helpers import doppelt
print(doppelt(21))@GLPython.eval
The last file runs. ▶ Run runs it, and for several files it gets a ▾ menu that chooses another file to run; Visualize and Tests follow that choice. Only the file that runs is visualized step by step; imported files run as a black box.
Shared files that live elsewhere, e.g. a parser used in several lessons, are
loaded by URL: list them in the macro's argument, separated by spaces and
together with box/substitution if needed. The file name of the URL is the
module name, e.g. for from parser import parse:
@GLPython.eval(https://raw.githubusercontent.com/<user>/<repo>/main/materials/00-auswerter/parser.py)
@GLPython.eval(box https://…/parser.py https://…/auswerter.py)
A working example, loading Python's own colorsys.py from GitHub:
from colorsys import rgb_to_hsv
print(rgb_to_hsv(1, 0, 0))@GLPython.eval(https://raw.githubusercontent.com/python/cpython/main/Lib/colorsys.py)
Such a library is listed below the buttons with View file to read it. If it can't be loaded (e.g. HTTP 404, no network, or a server without CORS), the list says so and the program runs without it, so Python reports the missing module. Raw GitHub URLs work; URLs must not contain parentheses.
The Tests button runs the examples written as doctests in the docstrings of
the file that would run (with several files: the chosen one), as
python -m doctest does. It shows how many examples passed and each failing
example in doctest's own words (Expected: / Got:), with a button to
visualize that example step by step. An example that only printed the expected
value while the function returned None passes, as in doctest, with a hint
that it probably should return instead.
def quadrat(x):
"""
Gibt das Quadrat von x zurück.
>>> quadrat(3)
9
>>> quadrat(-2)
4
"""
return x * x@GLPython.eval
The visualizer shows a call to a function whose body is a single return either
as a box or replaced by its return expression (substitution, the default), and
learners can switch with the toggle in its toolbar. To preset the mode for one
block, pass box or substitution (or, with the feature tree, tree) to
@GLPython.eval or @GLPython.visual:
def quadrat(x):
return x * x
quadrat(7)@GLPython.visual(box)
Without an argument the block uses the mode the learner last chose, and substitution at first.
Below each block sits one row of buttons, left to right ▶ Run · Tests ·
Visualize · Reset · Copy IDE link ▾ («Ausführen · Tests · Visualisieren ·
Zurücksetzen · IDE-Link kopieren ▾» in German courses), framed together with the
output panel. Every button has a text label and the classes glp-button plus
one per function (glp-button--run, glp-button--tests,
glp-button--visualize, glp-button--reset, glp-button--ide); ▶ Run also
has glp-button--primary, its ▾ menu glp-button--run-menu, and Copy IDE
link's ▾ menu glp-button--ide-menu. The row has
glp-buttons, so a course can restyle all of it with @style.
While Python is busy (a program, a REPL input or Tests that run for more than
a moment, e.g. while True:), ▶ Run becomes ■ Stop («■ Stoppen») in the
same place and width: it restarts Python, the code stays, and the console notes
it. This needs an embed of protocol version 4; with an older embed ▶ Run stays
as it was.
The block has glpython's own look (the course design: white, light grey code
areas, ink accent, the system's fonts) without any @style: ▶ Run is filled,
the other buttons are outlined, and the block stays light in LiaScript's dark
mode, like LiaScript's own code editor. The macro adds its rules once per page
as the first stylesheet, so any @style rule overrides them, and every value is
a --glp-* variable (see glpython-editor's README) that a course may set on the
page for the button row. The frame is the rule .glpython-embed { border: … }.
To draw one frame around LiaScript's code block, its history row and the
glpython block together:
.lia-code--block:has(+ p.lia-paragraph:empty + .glpython-embed) {
border: 1px solid var(--glp-border, #D1D5DB); border-bottom: none;
border-radius: var(--glp-radius, 4px) var(--glp-radius, 4px) 0 0;
margin-bottom: 0; padding: 8px 8px 0;
background: var(--glp-bg, #FFFFFF);
}
p.lia-paragraph:empty:has(+ .glpython-embed) { display: none; }
.lia-code--block + p.lia-paragraph:empty + .glpython-embed {
border: 1px solid var(--glp-border, #D1D5DB); border-top: none;
border-radius: 0 0 var(--glp-radius, 4px) var(--glp-radius, 4px);
margin-bottom: 20px; padding: 0 8px 8px;
}This depends on LiaScript's internal classes and should be checked after LiaScript updates.
Copy IDE link («IDE-Link kopieren») is the hand-in: it copies a link to a
copy of the block's current code in the glpython IDE
(https://glpython-edu.github.io/) and confirms with «Link kopiert ✓», or,
where the browser doesn't allow copying, shows the link in a field to copy by
hand. Its ▾ menu has Open in IDE («In IDE öffnen»), which opens the same
link in a new tab; its tooltip says that changes there don't come back to the
course. The link is an IDE
share link: every file of the block and the file that runs, and the block's
libraries by their URL - the IDE fetches them, so a block with a library keeps
a short link (an Etappe 4 hand-in measured 1,980 characters in the IDE's old
form and 1,185 in the compact one).
Libraries are read-only in the IDE (a lock, no editing, renaming or deleting),
as they are here. A block with features= passes them on: the IDE then shows
the same parts (▶, Tests, Visualisieren, the console's input line, the →
chain export, coverage, listtree, «Baum»; ide and reset mean nothing
there), as the starting state of that IDE tab - its ⋮ «Optionen» can switch
each part, e.g. for a teacher looking at a hand-in. A block without
features= passes nothing, and the IDE shows what it always does. A link longer than 2,083 characters - OneNote, for one, cuts
links there - is still copied, with a note to choose «In IDE öffnen» and
download the project there for a hand-in.
A link longer than GitHub Pages accepts (8192 characters of path and query, a
program of roughly 5,000 to 30,000 characters) carries the project after #
instead of ?. The link is the IDE's compact form (?p=: raw deflate in
base64url, about 40 % shorter than before), for which the macro loads
fflate 0.8.3 from jsDelivr; where that
can't load, it writes the IDE's older form (?project=, with
lz-string 1.5.0), which the IDE reads
as well. Until one of them has loaded, the button stays disabled.
features= in the macro's argument shows only the listed parts: run, tests,
visualize, reset, repl (the input line below the output), export (the
visualizer's «→-Kette kopieren») and ide (Copy IDE link ▾). Without it, everything shows. Hidden parts are
not rendered at all. An unknown name shows a short warning below the buttons.
coverage is the one name that isn't part of «everything»: only a block that
lists it gets it. After Tests, when all examples pass, the report then
lists the lines of the block's functions that no example executed, each with
its code («Von keinem Beispiel ausgeführt: Zeile 11: return "ungenügend"»),
or, when every line ran, a neutral note that this doesn't show whether the
examples check the edge cases. Coverage finds untested code, not wrong code:
return note > 4 is fully covered by 5.5 and 3.5. Only the file that runs
counts, not libraries or other files, and only lines in function bodies (not
def lines, docstrings or code outside functions). Nothing is marked in the
block's code editor; the report gives the line and its code.
listtree isn't part of «everything» either. It draws a list whose first
element is a string, such as ["+", 2, ["*", 3, 4]], also as a tree: in the
visualizer's variables (a wide tree behind «Baum zeigen»), in the full value's
window, and in the console, where such a value gets a «Baum» button next to
«Visualisieren». The visualizer's ⋮ menu can switch the trees off («Listen als
Baum zeigen»). Each tree is described by the list's own text for screen
readers.
tree isn't part of «everything» either. It gives the visualizer a third
mode, Baum, next to «Kasten» and «Ersetzung»: an expression is shown as
its tree while Python evaluates it. One step replaces the expression by its
tree, then each step of «Ersetzung» turns a subtree into its value, and one
step puts the value back in the line - x = 2 + 3 * 4 takes 5 steps instead
of 3. A function with a single return <expression> is replaced by that
expression's tree; any other call keeps its box below the tree, which says why
(«notenschnitt hat lokale Variablen – der Aufruf wird als Kasten gezeigt.»,
also in «Ersetzung»). Labels are Python's (+, quadrat, [ ]), with no
parentheses. Without tree there's no «Baum» button. To start a block in
«Baum», pass tree too: @GLPython.visual(tree features=visualize,tree).
large makes the code 20px (line height 1.6) and, while visualizing, the
block as tall as it may be (see below), e.g. for the projector in presentation
mode.
The block is as tall as its content, but at most 80% of the height the course shows right now (LiaScript's content area, without its top bar; in fullscreen, after a rotation or with the top bar hidden in LiaScript's settings, it adapts at once). So the whole block, with its buttons, fits into view with some text around it. A taller visualization scrolls its code inside the visualizer, following the current step, while the step bar above and the global variables below stay in view; a taller output scrolls the console. Scrolling past the end of the code goes on with the page. While stepping through a visualization, the block keeps one height, so the course page doesn't move: the visualizer is as tall as the tallest step from the start, and the console as tall as the output at the last step.
A course builds its own profiles from these options as macros in its header,
passing @0 on so a block can add more options (a mode or library URLs):
@Code.erkunden: @GLPython.eval(features=run,repl,visualize,ide @0)
@Code.rezept: @GLPython.eval(features=run,repl,visualize,tests,ide @0)
@Code.rezept3: @GLPython.eval(features=run,repl,visualize,tests,ide,coverage @0)
@Code.pruefung: @GLPython.eval(features=run @0)
@Code.demo: @GLPython.visual(large @0)
All options can be combined in any order, separated by spaces; LiaScript's
commas between macro arguments are fine. examples/profiles.md shows each of
these profiles.
The buttons and the embed follow the course's language: header: German for
de (including regional variants such as de-CH), English for en. For a
language glpython doesn't support (e.g. fr), they follow the browser's
language instead, and English if that isn't supported either. LiaScript reports
a course without a language: header as English, so such a course is shown in
English. Python's own values and error messages are never translated.
The template currently targets:
https://glpython-edu.github.io/glpython-editor/
The embed protocol is versioned independently of the course template.
Synchronous Python input() depends on cross-origin isolation in the complete
top-level LiaScript embedding context. The iframe reports that capability during
its handshake. LiaScript's player isn't isolated, so there input() doesn't
work: the program stops with RuntimeError: input() is not available here and a
hint below it. If a snippet uses input() where the capability is unavailable,
the macro also says that such programs run in the glpython IDE, whose console
asks for the input, with an «In IDE öffnen» button when the block has the
ide feature (else only that input() doesn't work here). Editing, normal
execution, visualization, and turtle graphics remain available.
glpython has a test version next to the live one: the embed at
https://glpython-edu.github.io/glpython-editor/dev/index.html and the IDE at
https://glpython-edu.github.io/dev/, each built from its repo's dev
branch. Both show a «DEV» badge. A course keeps loading the live embed; a
browser loads the dev embed in every course instead, and its IDE links
(«In der IDE öffnen», «IDE-Link kopieren») open the dev IDE, once the flag is
set for the course's origin (e.g. liascript.github.io):
- Set it: open any course with
#glpython=devat the end of its address, e.g.https://liascript.github.io/course/?<course URL>#glpython=dev, or in the browser's console on a course page:localStorage.glpythonChannel = "dev", then reload. - Clear it:
#glpython=liveat the end of the address, orlocalStorage.removeItem("glpythonChannel")in the console, then reload.
The flag only lives in that browser, so learners never get the dev channel,
and a link copied with the flag set points to the dev IDE (the badge says
so) - don't share it. To try a change to the macro itself, a test course
imports https://raw.githubusercontent.com/glpython-edu/glpython-liascript/dev/README.md
instead of main; with the flag it also loads the dev embed.
Open this file through the LiaScript LiveEditor to exercise the real macro environment:
https://liascript.github.io/LiveEditor/?/show/file/https://raw.githubusercontent.com/glpython-edu/glpython-liascript/main/README.md
node scripts/check-macro.mjs checks the syntax of the template's <script>
blocks with node --check. npm run check:ready (after npm ci; it needs
Chrome) runs the @onload code on a test page with stand-ins for LiaScript's
editor and the embed, and checks the first program sent on "ready": with text
that arrives after "ready", with an empty block, with several files. CI runs
both on every pull request and on main and dev, since a merge to main
reaches every course.
npm run check:player checks the same with the real LiaScript player, the live
embed and a small course (scripts/player/course.md: a visualization in a
fragment, visited several times so the embed comes from the cache). It needs
those sites, so it isn't part of CI; run it before a release.
For local embed development, change embedOrigin and embedUrl in the template
header to the origin printed by npm run dev:embed in glpython-editor.
MIT – see LICENSE. The macro includes no third-party code; it loads the glpython embed (MIT), fflate and lz-string (both MIT, for the IDE links) when a course runs. The checks install both as devDependencies.