Skip to content

Commit 05e22df

Browse files
Vipitisalmarklein
andauthored
Support for Pyodide and PyScript (#115)
Co-authored-by: Almar Klein <[email protected]>
1 parent 263c406 commit 05e22df

File tree

20 files changed

+1177
-28
lines changed

20 files changed

+1177
-28
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,13 +197,13 @@ jobs:
197197
- name: Install dev dependencies
198198
run: |
199199
python -m pip install --upgrade pip
200-
pip install -U flit build twine
200+
pip install -U flit twine
201201
- name: Create source distribution
202202
run: |
203-
python -m build -n -s
203+
python -m flit build --no-use-vcs --format sdist
204204
- name: Build wheel
205205
run: |
206-
python -m build -n -w
206+
python -m flit build --no-use-vcs --format wheel
207207
- name: Test sdist
208208
shell: bash
209209
run: |

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Special for this repo
22
nogit/
33
docs/gallery/
4+
docs/static/*.whl
45
docs/sg_execution_times.rst
56
examples/screenshots/
67

docs/backends.rst

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ The table below gives an overview of the names in the different ``rendercanvas``
4646
| ``loop``
4747
- | Create a standalone canvas using wx, or
4848
| integrate a render canvas in a wx application.
49+
* - ``pyodide``
50+
- | ``PyodideRenderCanvas`` (toplevel)
51+
| ``RenderCanvas`` (alias)
52+
| ``loop`` (an ``AsyncioLoop``)
53+
- | Backend when Python is running in the browser,
54+
| via Pyodide or PyScript.
4955
5056

5157
There are also three loop-backends. These are mainly intended for use with the glfw backend:
@@ -168,7 +174,7 @@ Alternatively, you can select the specific qt library to use, making it easy to
168174
loop.run() # calls app.exec_()
169175
170176
171-
It is technically possible to e.g. use a ``glfw`` canvas with the Qt loop. However, this is not recommended because Qt gets confused in the precense of other windows and may hang or segfault.
177+
It is technically possible to e.g. use a ``glfw`` canvas with the Qt loop. However, this is not recommended because Qt gets confused in the presence of other windows and may hang or segfault.
172178
But the other way around, running a Qt canvas in e.g. the trio loop, works fine:
173179

174180
.. code-block:: py
@@ -265,6 +271,80 @@ subclass implementing a remote frame-buffer. There are also some `wgpu examples
265271
canvas # Use as cell output
266272
267273
274+
Support for Pyodide
275+
-------------------
276+
277+
When Python is running in the browser using Pyodide, the auto backend selects
278+
the ``rendercanvas.pyodide.PyodideRenderCanvas`` class. This backend requires no
279+
additional dependencies. Currently only presenting a bitmap is supported, as
280+
shown in the examples :doc:`noise.py <gallery/noise>` and :doc:`snake.py<gallery/snake>`.
281+
Support for wgpu is underway.
282+
283+
An HTMLCanvasElement is assumed to be present in the
284+
DOM. By default it connects to the canvas with id "canvas", but a
285+
different id or element can also be provided using ``RenderCanvas(canvas_element)``.
286+
287+
An example using PyScript (which uses Pyodide):
288+
289+
.. code-block:: html
290+
291+
<!doctype html>
292+
<html>
293+
<head>
294+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
295+
<script type="module" src="https://pyscript.net/releases/2025.10.3/core.js"></script>
296+
</head>
297+
<body>
298+
<canvas id='canvas' style="background:#aaa; width: 640px; height: 480px;"></canvas>
299+
<br>
300+
<script type="py" src="yourcode.py" config='{"packages": ["numpy", "sniffio", "rendercanvas"]}'>
301+
</script>
302+
</body>
303+
</html>
304+
305+
306+
An example using Pyodide directly:
307+
308+
.. code-block:: html
309+
310+
<!DOCTYPE html>
311+
<html>
312+
<head>
313+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
314+
<script src="https://cdn.jsdelivr.net/pyodide/v0.29.0/full/pyodide.js"></script>
315+
</head>
316+
<body>
317+
<canvas id="canvas" width="640" height="480"></canvas>
318+
<script type="text/javascript">
319+
async function main(){
320+
pythonCode = `
321+
# Use python script as normally
322+
import numpy as np
323+
from rendercanvas.auto import RenderCanvas, loop
324+
325+
canvas = RenderCanvas()
326+
context = canvas.get_context("bitmap")
327+
data = np.random.uniform(127, 255, size=(24, 32, 4)).astype(np.uint8)
328+
329+
@canvas.request_draw
330+
def animate():
331+
context.set_bitmap(data)
332+
`
333+
// load Pyodide and install Rendercanvas
334+
let pyodide = await loadPyodide();
335+
await pyodide.loadPackage("micropip");
336+
const micropip = pyodide.pyimport("micropip");
337+
await micropip.install("numpy");
338+
await micropip.install("sniffio");
339+
await micropip.install("rendercanvas");
340+
// have to call as runPythonAsync
341+
pyodide.runPythonAsync(pythonCode);
342+
}
343+
main();
344+
</script>
345+
</body>
346+
</html>
347+
268348

269349
.. _env_vars:
270350

docs/conf.py

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,20 @@
1212

1313
import os
1414
import sys
15+
import shutil
1516

17+
import flit
1618

1719
ROOT_DIR = os.path.abspath(os.path.join(__file__, "..", ".."))
1820
sys.path.insert(0, ROOT_DIR)
1921

2022
os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] = "true"
2123

2224

23-
# Load wglibu so autodoc can query docstrings
25+
# Load wgpu so autodoc can query docstrings
2426
import rendercanvas # noqa: E402
25-
import rendercanvas.stub # noqa: E402 - we use the stub backend to generate doccs
26-
import rendercanvas._context # noqa: E402 - we use the ContexInterface to generate doccs
27+
import rendercanvas.stub # noqa: E402 - we use the stub backend to generate docs
28+
import rendercanvas._context # noqa: E402 - we use the ContextInterface to generate docs
2729
import rendercanvas.utils.bitmappresentadapter # noqa: E402
2830

2931
# -- Project information -----------------------------------------------------
@@ -40,10 +42,10 @@
4042
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
4143
# ones.
4244
extensions = [
45+
"sphinx_rtd_theme",
4346
"sphinx.ext.autodoc",
4447
"sphinx.ext.napoleon",
4548
"sphinx.ext.intersphinx",
46-
"sphinx_rtd_theme",
4749
"sphinx_gallery.gen_gallery",
4850
]
4951

@@ -64,8 +66,88 @@
6466
master_doc = "index"
6567

6668

69+
# -- Build wheel so Pyodide examples can use exactly this version of rendercanvas -----------------------------------------------------
70+
71+
short_version = ".".join(str(i) for i in rendercanvas.version_info[:3])
72+
wheel_name = f"rendercanvas-{short_version}-py3-none-any.whl"
73+
74+
# Build the wheel
75+
toml_filename = os.path.join(ROOT_DIR, "pyproject.toml")
76+
flit.main(["-f", toml_filename, "build", "--no-use-vcs", "--format", "wheel"])
77+
wheel_filename = os.path.join(ROOT_DIR, "dist", wheel_name)
78+
assert os.path.isfile(wheel_filename), f"{wheel_name} does not exist"
79+
80+
# Copy into static
81+
print("Copy wheel to static dir")
82+
shutil.copy(
83+
wheel_filename,
84+
os.path.join(ROOT_DIR, "docs", "static", wheel_name),
85+
)
86+
87+
6788
# -- Sphinx Gallery -----------------------------------------------------
6889

90+
iframe_placeholder_rst = """
91+
.. only:: html
92+
93+
Interactive example
94+
===================
95+
96+
This uses Pyodide. If this does not work, your browser may not have sufficient support for wasm/pyodide/wgpu (check your browser dev console).
97+
98+
.. raw:: html
99+
100+
<iframe src="pyodide.html#example.py"></iframe>
101+
"""
102+
103+
python_files = {}
104+
105+
106+
def add_pyodide_to_examples(app):
107+
if app.builder.name != "html":
108+
return
109+
110+
gallery_dir = os.path.join(ROOT_DIR, "docs", "gallery")
111+
112+
for fname in os.listdir(gallery_dir):
113+
filename = os.path.join(gallery_dir, fname)
114+
if not fname.endswith(".py"):
115+
continue
116+
with open(filename, "rb") as f:
117+
py = f.read().decode()
118+
if fname in ["drag.py", "noise.py", "snake.py"]:
119+
# todo: later we detect by using a special comment in the py file
120+
print("Adding Pyodide example to", fname)
121+
fname_rst = fname.replace(".py", ".rst")
122+
# Update rst file
123+
rst = iframe_placeholder_rst.replace("example.py", fname)
124+
with open(os.path.join(gallery_dir, fname_rst), "ab") as f:
125+
f.write(rst.encode())
126+
python_files[fname] = py
127+
128+
129+
def add_files_to_run_pyodide_examples(app, exception):
130+
if app.builder.name != "html":
131+
return
132+
133+
gallery_build_dir = os.path.join(app.outdir, "gallery")
134+
135+
# Write html file that can load pyodide examples
136+
with open(
137+
os.path.join(ROOT_DIR, "docs", "static", "_pyodide_iframe.html"), "rb"
138+
) as f:
139+
html = f.read().decode()
140+
html = html.replace('"rendercanvas"', f'"../_static/{wheel_name}"')
141+
with open(os.path.join(gallery_build_dir, "pyodide.html"), "wb") as f:
142+
f.write(html.encode())
143+
144+
# Write the python files
145+
for fname, py in python_files.items():
146+
print("Writing", fname)
147+
with open(os.path.join(gallery_build_dir, fname), "wb") as f:
148+
f.write(py.encode())
149+
150+
69151
# Suppress "cannot cache unpickable configuration value" for sphinx_gallery_conf
70152
# See https://github.com/sphinx-doc/sphinx/issues/12300
71153
suppress_warnings = ["config.cache"]
@@ -75,9 +157,10 @@
75157
"gallery_dirs": "gallery",
76158
"backreferences_dir": "gallery/backreferences",
77159
"doc_module": ("rendercanvas",),
78-
# "image_scrapers": (),,
160+
# "image_scrapers": (),
79161
"remove_config_comments": True,
80162
"examples_dirs": "../examples/",
163+
"ignore_pattern": r"serve_browser_examples\.py",
81164
}
82165

83166
# -- Options for HTML output -------------------------------------------------
@@ -92,3 +175,8 @@
92175
# so a file named "default.css" will overwrite the builtin "default.css".
93176
html_static_path = ["static"]
94177
html_css_files = ["custom.css"]
178+
179+
180+
def setup(app):
181+
app.connect("builder-inited", add_pyodide_to_examples)
182+
app.connect("build-finished", add_files_to_run_pyodide_examples)

docs/contextapi.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ then present the result to the screen. For this, the canvas provides one or more
2424
└─────────┘ └────────┘
2525
2626
This means that for the context to be able to present to any canvas, it must
27-
support *both* the 'image' and 'screen' present-methods. If the context prefers
27+
support *both* the 'bitmap' and 'screen' present-methods. If the context prefers
2828
presenting to the screen, and the canvas supports that, all is well. Similarly,
2929
if the context has a bitmap to present, and the canvas supports the
3030
bitmap-method, there's no problem.
@@ -44,7 +44,7 @@ on the CPU. All GPU API's have ways to do this.
4444
download from gpu to cpu
4545
4646
If the context has a bitmap to present, and the canvas only supports presenting
47-
to screen, you can usse a small utility: the ``BitmapPresentAdapter`` takes a
47+
to screen, you can use a small utility: the ``BitmapPresentAdapter`` takes a
4848
bitmap and presents it to the screen.
4949

5050
.. code-block::
@@ -58,7 +58,7 @@ bitmap and presents it to the screen.
5858
5959
This way, contexts can be made to work with all canvas backens.
6060

61-
Canvases may also provide additionaly present-methods. If a context knows how to
61+
Canvases may also provide additionally present-methods. If a context knows how to
6262
use that present-method, it can make use of it. Examples could be presenting
6363
diff images or video streams.
6464

docs/start.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,12 @@ Async
129129
A render canvas can be used in a fully async setting using e.g. Asyncio or Trio, or in an event-drived framework like Qt.
130130
If you like callbacks, ``loop.call_later()`` always works. If you like async, use ``loop.add_task()``. Event handlers can always be async.
131131

132-
If you make use of async functions (co-routines), and want to keep your code portable accross
132+
If you make use of async functions (co-routines), and want to keep your code portable across
133133
different canvas backends, restrict your use of async features to ``sleep`` and ``Event``;
134-
these are the only features currently implemened in our async adapter utility.
134+
these are the only features currently implemented in our async adapter utility.
135135
We recommend importing these from :doc:`rendercanvas.utils.asyncs <utils_asyncs>` or use ``sniffio`` to detect the library that they can be imported from.
136136

137-
On the other hand, if you know your code always runs on the asyncio loop, you can fully make use of ``asyncio``. Dito for Trio.
137+
On the other hand, if you know your code always runs on the asyncio loop, you can fully make use of ``asyncio``. Ditto for Trio.
138138

139139
If you use Qt and get nervous from async code, no worries, when running on Qt, ``asyncio`` is not even imported. You can regard most async functions
140140
as syntactic sugar for pieces of code chained with ``call_later``. That's more or less how our async adapter works :)

docs/static/_pyodide_iframe.html

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<!doctype html>
2+
<html>
3+
4+
<head>
5+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
6+
<title>Rendercanvas example.py in Pyodide</title>
7+
<script src="https://cdn.jsdelivr.net/pyodide/v0.29.0/full/pyodide.js"></script>
8+
</head>
9+
10+
<body>
11+
<dialog id="loading" style='outline: none; border: none; background: transparent;'>
12+
<h1>Loading...</h1>
13+
</dialog>
14+
<canvas id='canvas' style='width:calc(100% - 20px); height: 450px; background-color: #ddd;'></canvas>
15+
<script type="text/javascript">
16+
async function main() {
17+
let loading = document.getElementById('loading');
18+
loading.showModal();
19+
try {
20+
let example_name = document.location.hash.slice(1);
21+
pythonCode = await (await fetch(example_name)).text();
22+
let pyodide = await loadPyodide();
23+
await pyodide.loadPackage("micropip");
24+
const micropip = pyodide.pyimport("micropip");
25+
await micropip.install('sniffio');
26+
await micropip.install('numpy');
27+
// The below loads rendercanvas from pypi. But we will replace it with the name of the wheel,
28+
// so that it's loaded from the docs (in _static).
29+
await micropip.install("rendercanvas");
30+
// Run the Python code async because some calls are async it seems.
31+
pyodide.runPythonAsync(pythonCode);
32+
loading.close();
33+
} catch (err) {
34+
loading.innerHTML = "Failed to load: " + err;
35+
}
36+
}
37+
main();
38+
</script>
39+
</body>
40+
41+
</html>

docs/static/custom.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
div.sphx-glr-download,
22
div.sphx-glr-download-link-note {
33
display: none;
4+
}
5+
6+
div.document iframe {
7+
width: 100%;
8+
height: 520px;
9+
border: none;
410
}

0 commit comments

Comments
 (0)