Skip to content

Latest commit

 

History

37 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

artesian

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.

What it does

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.

Install

pip install artesian

First: can your model run in the browser at all?

This 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 yourmodel
note  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.

Write the app

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.

Make the figure fill its container, without changing its shape

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.

Build it

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.

Do not write width="100%" on the frame

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.

The design width lives in the app

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.

Your model must be importable where the build runs

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.txt

If you miss this, artesian raises and says so; panel convert on its own fails in a way that is easy to misread.

A demo that never scales fails quietly

Scaling depends on the app declaring a design width, which build_app records in the compiled page:

DESIGN_WIDTH = 900     # module level, in your app

Without 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_WIDTH and 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.

Known limitations

  • The Pyodide runtime still comes from a CDN. The wheels are self-hosted, but pyodide.js is fetched from cdn.jsdelivr.net at load time. panel convert hardcodes 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 panel alone 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 convert failure. 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.

Scope: what this is not

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.

License

GPL-3.0-or-later. Copyright © 2026 Andrew D. Wickert and contributors.

About

Compile a Python model into a demo that runs in the reader's browser, with no server

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages