Compile a Python model into a demo that runs in the reader's browser, with no server.
Named for groundwater under enough pressure to reach the surface and flow without a pump. Your model reaches the reader and runs without a server: it is compiled to WebAssembly and executes entirely in the browser via Pyodide. A demo is then a static file – it costs nothing to host, it cannot fall over under load, and it keeps working after the grant ends.
artesian was extracted from the machinery behind
GRLP's interactive demo
and reproduces it exactly.
Two pieces, usable together or separately.
A build tool. Wheels your model straight from its source tree, self-hosts
the panel and bokeh wheels next to the app, runs panel convert, and
rewrites the CDN wheel URLs to the local copies. That last step is not
housekeeping: the holoviz CDN's bokeh wheel currently returns HTTP 403, and
without the rewrite the demo fails to load.
Thin app helpers. Only the play/pause timer and reset button that every live demo rewrites. There is deliberately no model or plotting abstraction – see Scope.
pip install artesianThis is the question to settle before anything else, and it has nothing to do
with artesian. Everything your demo imports must be either bundled with
Pyodide or installable from a pure-Python (py3-none-any) wheel. A package with
compiled extensions needs a wheel built for Emscripten, and for most scientific
packages none exists.
artesian check numpy scipy networkx yourmodelnote numpy 2.5.2 publishes only platform wheels; bundled by Pyodide
note scipy 1.18.1 publishes only platform wheels; bundled by Pyodide
ok networkx 3.6.1 has a pure-Python wheel
FAIL yourmodel no such distribution on PyPI
Not installable in the browser: yourmodel
Read note as "fine if Pyodide bundles it, a blocker otherwise" – that
judgement is left to you rather than guessed at, because Pyodide's bundled set
is version-specific.
The second constraint is speed. A model stepping in milliseconds animates smoothly at 30 fps; one taking seconds per step wants recompute-on-change instead of a timer.
A demo is an ordinary Panel script ending in
.servable(). artesian.live supplies the two bits of boilerplate:
import panel as pn
from artesian.live import animator, reset_button
# `sim`, never `state`: panel exports pn.state, and shadowing it fails silently.
sim = {"model": make_model(), "t": 0.}
def step():
sim["model"].advance(dt)
source.data = {"x": sim["model"].x, "y": sim["model"].z}
pn.Column(
pn.Row(animator(step), reset_button(do_reset)),
slider, fig,
).servable()See examples/hillslope.py for a complete one.
A demo gets embedded in containers you do not control – a documentation page, a
course page, a projected slide, a phone – so a figure fixed at some pixel width
is wrong nearly everywhere. artesian.live.responsive handles it:
fig = figure(height=380, width=680, ...) # the proportions you want
responsive(fig)
pn.Column(..., fig, sizing_mode="stretch_width").servable()The trap worth knowing about: the obvious choice, bokeh's stretch_width, fills
the width but pins the height, so the aspect ratio drifts with the window. A
680×380 plot reads 1.79 as drawn, 2.89 in a 1100 px column, and 4.21 in a
1600 px one. Wherever the meaning lives in a slope – a river's long profile, a
hillslope, a rate through time – that is the same data looking three times
gentler to a reader with a wider monitor.
responsive() uses scale_width instead, scaling both dimensions so the
exaggeration is identical for everyone, and bounds the result with max_width
because preserving the ratio unbounded makes that figure 1342 px tall at
2400 px. Give the enclosing Column sizing_mode="stretch_width" too, or the
figure has nothing to fill.
From the command line:
artesian build examples/hillslope.py -o _build -p . -r numpy --serve-p/--package points at a local source tree to wheel and ship – your model, so
the demo always matches your working tree rather than your last release.
-r/--requirement names anything else to resolve in the browser. --serve
serves the result, which is how to view it: opening the page over file://
trips the browser's cross-origin rules for web workers.
Or from Sphinx, so demos rebuild with the docs. In conf.py:
extensions = ["artesian.sphinxext"]
artesian_apps = [
{
"app": "../interactive_demo/grlp_panel.py",
"packages": [".."],
"requirements": ["numpy", "scipy", "networkx"],
"outdir": "_static/interactive",
},
]Make sure outdir is somewhere Sphinx actually publishes, or the demo is built
into your source tree and never copied into _build:
html_static_path = ["_static"]artesian warns if it is not, since the failure is otherwise silent at build
time and a 404 at run time.
Then embed the result in a page. Every build also writes an
artesian-embed.css and an artesian-embed.js beside the app, shared by every
demo in that directory:
<!-- in <head> -->
<link rel="stylesheet" href="_static/interactive/artesian-embed.css">
<!-- in the body -->
<iframe src="_static/interactive/grlp_panel.html" data-artesian
data-design-width="700" height="400" scrolling="no"></iframe>
<script src="_static/interactive/artesian-embed.js"></script>That is the whole embed.
Both files, and the stylesheet in the head. They act at different times.
The script cannot size the frame until the frame's document has loaded, and
a demo that pulls tens of megabytes of Pyodide leaves that window open for
many seconds – whatever the frame looks like meanwhile is the reader's first
impression. With no stylesheet that is the browser's default iframe, about
300 px wide, stretched to the height you gave it: a narrow, tall box with
the demo squeezed into a column and blank space beneath. It reads as "stuck
loading", and it was shipped that way and reported as exactly that. Give
height a value near the demo's real height, too; it is what the reader sees
until the script measures the real one. The script sizes the frame to its content – no fixed
height can work, since the plot's height follows the reader's window – and
above the app's design width it scales the demo rather than stretching it.
It is the obvious thing and it is wrong, invisibly. Every browser on an iPad is
WebKit underneath – Firefox and Chrome there are skins on Safari's engine – and
WebKit sizes an iframe to its content rather than honouring a percentage
width. Against a Panel app in stretch_width that is a feedback loop with no
fixed point: the app is as wide as the frame, the frame is as wide as the app,
and the demo runs off the side of the page. width: 1px with
min-width: 100% says "the width available, and no more", which WebKit does
honour.
No desktop engine shows this, which is why artesian-embed.js exists rather
than a snippet in this README for everyone to copy. Two live exercises shipped
with the bug before a reader on an iPad found it.
Above some width, enlarging a demo by stretching it goes wrong: the plot grows without limit while the 16 px text and 18 px slider handles stay put, and the controls end up small and fiddly beside the model. Above the design width the frame is zoomed instead, so everything enlarges together, the way zooming a PDF does.
Declare it once, in the app, as a module-level constant – the app needs the number anyway, to cap its own layout:
DESIGN_WIDTH = 900
pn.Column(..., sizing_mode="stretch_width", max_width=DESIGN_WIDTH).servable()artesian build reads it out of the source and records it in the compiled
page. --design-width overrides.
Put it on the frame as well, as data-design-width:
<iframe src="..." data-artesian data-design-width="900"
height="400" scrolling="no"></iframe>Two places, deliberately, and the attribute wins. Reading the width out of the compiled page is tidier, and it does not work on its own: it needs the frame's document to be readable while the embedding page lays itself out, and an iframe starts on a BLANK document. On WebKit – every browser on an iPad – that is what a page script sees. No meta tag, no design width, and the demo is never scaled: it sits at its own layout width inside a wider frame with blank space around it. Both GeomorphOnline exercises worked on an iPad while each page hardcoded its design width, and broke in the commit that replaced that with the meta tag.
With neither, the demo is fitted to the page but never scaled.
scrolling="no" matters too. The frame is sized to its content, so it has
nothing to scroll – and where it can scroll, a touch drag pans the demo off
the edge of its own frame with no obvious way back. On an iPad a pixel or two
of rounding is enough to allow it.
A build takes tens of seconds and reaches PyPI, so set
artesian_skip_build = True (or ARTESIAN_SKIP_BUILD=1) to reuse existing
output while editing prose.
panel convert executes the app in the building environment to discover what
it serves, so shipping your model as a wheel for the browser is not enough – it
must also be installed where the build happens. On Read the Docs that means
installing the package in .readthedocs.yaml:
python:
install:
- method: pip
path: .
- requirements: docs/requirements.txtIf you miss this, artesian raises and says so; panel convert on its own
fails in a way that is easy to misread.
Scaling depends on the app declaring a design width, which build_app records
in the compiled page:
DESIGN_WIDTH = 900 # module level, in your appWithout it the demo still builds, still loads, and still fits its frame – it simply never scales, so its text and controls keep their size while the plot grows. Nothing looks broken. artesian therefore warns in two cases:
- the app declares no
DESIGN_WIDTHand none was passed; - another compiled page in the same output directory records none, which means it predates the width being recorded, or has not been rebuilt since.
The second matters because demos share an output directory. Adding a new exercise beside an older one leaves the older one unscaled, and only rebuilding it fixes that. This is not hypothetical: it is how the GRLP demo on GeomorphOnline silently lost its scaling when a second exercise was added.
- The Pyodide runtime still comes from a CDN. The wheels are self-hosted,
but
pyodide.jsis fetched fromcdn.jsdelivr.netat load time.panel converthardcodes that URL and offers no option to change it, so a demo is not usable fully offline or behind a firewall blocking jsdelivr. Self-hosting it would buy that offline capability and nothing else: the reader fetches the same bytes either way, and a docs host is usually slower than a CDN. - Readers wait 10–30 s on first load while the browser downloads the Python
runtime. It is smooth afterwards, and worth saying so on the page. Measured
for GRLP's demo, that first load is about 63 MB: 11.6 MB of Pyodide core,
16.6 MB of numpy/scipy/networkx, and 35 MB of self-hosted wheels – of which
panelalone is 28.9 MB, the single largest item and the only real lever on load time. - Much of the value here is workarounds to current upstream behaviour – the
403, the silent
panel convertfailure. That is the argument for a shared package (fix once), but it also means this needs to track panel, bokeh, and Pyodide releases. It is not fire-and-forget.
artesian does not abstract your model or your plots. A make_model/step/
draw hook contract is easy to write from one model and tends to fit the next
one badly, so it waits for a real second use case to justify it. Build the
figure with bokeh yourself; the library handles getting it into a browser.
GPL-3.0-or-later. Copyright © 2026 Andrew D. Wickert and contributors.