Skip to content

Commit 8e30126

Browse files
committed
add local examples script
1 parent b9cc2ec commit 8e30126

2 files changed

Lines changed: 292 additions & 1 deletion

File tree

examples/serve_browser_examples.py

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
"""
2+
Serve browser examples locally.
3+
===============================
4+
5+
6+
A little script to serve pygfx examples on localhost so you can try them in the browser.
7+
This is an iteration of the same script in rendercanvas and wgpu-py although some changes:
8+
9+
* swapped in pathlib for os.path (mostly)
10+
* avoided pyscript for now, but likely a good idea to use for webworker recovery etc.
11+
12+
Files are loaded from disk on each request, so you can leave the server running
13+
and just update examples, update pygfx and build the wheel, etc.
14+
"""
15+
16+
import os
17+
import sys
18+
import webbrowser
19+
from http.server import BaseHTTPRequestHandler, HTTPServer
20+
from pathlib import Path
21+
22+
import flit
23+
24+
import fastplotlib
25+
26+
27+
# sphinx_gallery_pygfx_docs = 'hidden'
28+
# sphinx_gallery_pygfx_test = 'off'
29+
30+
# "hosted" upstream deps, will break in the future
31+
wgpu_wheel = "https://wgpu-py--753.org.readthedocs.build/en/753/_static/wgpu-0.31.0-py3-none-any.whl" # very hacky way to serve this but it does work...
32+
uharfbuzz_wheel = "https://pygfx--1273.org.readthedocs.build/1273/_static/uharfbuzz-0.54.1-cp310-abi3-pyodide_2025_0_wasm32.whl" # try to get it from the github release, so we don't need to include it...
33+
pygfx_wheel = "https://pygfx--1273.org.readthedocs.build/1273/_static/pygfx-0.16.0-py3-none-any.whl"
34+
35+
# the pygfx wheel will be listed after this. it might be possible to still get deps from pyproject.toml
36+
fpl_deps = [wgpu_wheel, uharfbuzz_wheel, pygfx_wheel, "hsluv", "pylinalg", "jinja2", "httpx", "trimesh", "gltflib", "imageio"]
37+
38+
root = Path(__file__).parent.parent.absolute()
39+
40+
short_version = ".".join(str(i) for i in fastplotlib.version_info[:3])
41+
wheel_name = f"fastplotlib-{short_version}-py3-none-any.whl"
42+
43+
example_files = list((root / "examples").glob("**/*.py"))
44+
45+
def get_html_index():
46+
"""Create a landing page."""
47+
48+
examples_list = [f"<li><a href='{str(name.relative_to(root / "examples")).replace('.py', '.html')}'>{name.relative_to(root / "examples")!s}</a></li>" for name in example_files]
49+
50+
html = """<!doctype html>
51+
<html>
52+
<head>
53+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
54+
<title>fastplotlib browser examples</title>
55+
<script type="module" src="https://pyscript.net/releases/2025.11.2/core.js"></script>
56+
</head>
57+
<body>
58+
59+
<a href='/build'>Rebuild the wheel</a><br><br>
60+
"""
61+
62+
html += "List of examples that might run in Pyodide:\n"
63+
html += f"<ul>{''.join(examples_list)}</ul><br>\n\n"
64+
65+
html += "</body>\n</html>\n"
66+
return html
67+
68+
69+
html_index = get_html_index()
70+
71+
# TODO: a pyodide example for the compute examples (so we can capture output?)
72+
# modified from _pyodide_iframe.html from rendercanvas, and then again from pygfx
73+
pyodide_compute_template = """
74+
<!doctype html>
75+
<html>
76+
<head>
77+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
78+
<title>{example_script} via Pyodide</title>
79+
<script src="https://cdn.jsdelivr.net/pyodide/v0.29.4/full/pyodide.js"></script>
80+
</head>
81+
<base href="/">
82+
83+
<dialog id="loading" style='outline: none; border: none; background: transparent;'>
84+
<h1>Loading...</h1>
85+
</dialog>
86+
<body>
87+
<a href="/">Back to list</a><br><br>
88+
<!-- TODO: can we get a rebuild and rerun this example button? like go to /build with a redirect or something? -->
89+
<p>
90+
{docstring}
91+
</p>
92+
<canvas id='canvas' style='width:calc(90% - 40px); height:640px; background-color: #ddd;'></canvas>
93+
<div id="output" style="white-space: per-line; overflow-y: auto; height:300px; background:#eee; padding:4px; margin:4px; border:1px solid #ccc;">
94+
<p>Output:</p>
95+
</div>
96+
<script type="text/javascript">
97+
async function main() {{
98+
let loading = document.getElementById('loading');
99+
loading.showModal();
100+
try {{
101+
let example_name = {example_script!r};
102+
pythonCode = await (await fetch(example_name)).text();
103+
// this env var is really only used for the pygfx examples - so maybe we make a script for that gallery instead?
104+
pyodide = await loadPyodide();
105+
pyodide.setStdout({{
106+
batched: (s) => {{
107+
// TODO: newline, scrollable, echo to console?
108+
el = document.getElementById("output");
109+
el.innerHTML += "<br>" + s.replace(/</g, "&lt;").replace(/>/g, "&gt;");
110+
el.scrollTop = el.scrollHeight; // scroll to bottom
111+
console.log(s); // so we also have it formatted
112+
}}
113+
}});
114+
115+
await pyodide.loadPackage("micropip");
116+
const micropip = pyodide.pyimport("micropip");
117+
{dependencies}
118+
await pyodide.loadPackagesFromImports(pythonCode);
119+
// I feel like some errors around stack switching are worse now -.-
120+
pyodide.setDebug(true);
121+
let ret = await pyodide.runPythonAsync(pythonCode);
122+
console.log("Example finished:", ret);
123+
loading.close();
124+
}} catch (err) {{
125+
// TODO: this could be formatted better as this overlaps and is unreadable...
126+
loading.innerHTML = "Failed to load: " + err;
127+
console.error(err); // so we have it here too
128+
}}
129+
}}
130+
let pyodide; // make it global for the console -> pyodide.globals.get("py_var").toJs()
131+
main();
132+
</script>
133+
</body>
134+
135+
</html>
136+
"""
137+
138+
139+
imageio_patch = """
140+
from pyodide.http import pyfetch
141+
from pyodide.ffi import run_sync
142+
143+
async def _imread_async(filename, **kwargs):
144+
# pyodide working version of iio.imread, uses fetch and waiting on it, also replaced the "imageio:"
145+
filename = str(filename) # maybe breaks some Path stuff?
146+
if filename.startswith("imageio:"):
147+
filename = filename.replace("imageio:", "https://raw.githubusercontent.com/imageio/imageio-binaries/master/images/")
148+
149+
if "." in filename:
150+
kwargs["extension"] = "." + filename.rsplit(".", 1)[-1]
151+
152+
response = await pyfetch(filename)
153+
res = iio.imread((await response.bytes()), **kwargs)
154+
return res
155+
156+
def _imread(filename:str, **kwargs):
157+
print(f"Loading image: {filename!r} with kwargs: {kwargs}")
158+
res = run_sync(_imread_async(filename, **kwargs))
159+
return res
160+
"""
161+
162+
def patch_imageio_for_pyodide(python_code:str) -> str:
163+
if "iio.imread(" not in python_code:
164+
return python_code
165+
166+
return imageio_patch + "\n\n" + python_code.replace("iio.imread(", "_imread(")
167+
168+
169+
170+
def build_wheel():
171+
toml_filename = (root / "pyproject.toml")
172+
# spews more and more with each build... might need to clear stdout or something?
173+
flit.main(["-f", str(toml_filename.resolve()), "build", "--no-use-vcs", "--format", "wheel"])
174+
wheel_filename = root / "dist" / wheel_name
175+
assert wheel_filename.is_file(), f"{wheel_name} does not exist"
176+
# also copy the wgpu wheel if it's in a repo nearby... so make it a bit less work to update both.
177+
178+
179+
def get_docstring_from_py_file(fname):
180+
filename = root / "examples" / fname
181+
docstate = 0
182+
doc = ""
183+
with open(filename, "rb") as f:
184+
for line in f:
185+
line = line.decode()
186+
if docstate == 0:
187+
if line.lstrip().startswith('"""'):
188+
docstate = 1
189+
else:
190+
if docstate == 1 and line.lstrip().startswith(("---", "===")):
191+
docstate = 2
192+
doc = ""
193+
elif '"""' in line:
194+
doc += line.partition('"""')[0]
195+
break
196+
else:
197+
doc += line
198+
199+
return doc.replace("\n", "<br>")
200+
201+
202+
class MyHandler(BaseHTTPRequestHandler):
203+
def do_GET(self):
204+
if self.path == "/":
205+
self.respond(200, html_index, "text/html")
206+
elif self.path == "/build":
207+
try:
208+
self.respond(200, "Building wheel...<br>", "text/html")
209+
build_wheel()
210+
except Exception as err:
211+
self.respond(500, str(err), "text/plain")
212+
else:
213+
html = f"<br>Wheel build: {wheel_name}<br><br><a href='/'>Back to list</a>"
214+
self.respond(200, html, "text/html")
215+
elif self.path.endswith(".whl"):
216+
requested_path = Path(self.path)
217+
filename = root / "dist" / requested_path.name
218+
if os.path.isfile(filename):
219+
with open(filename, "rb") as f:
220+
data = f.read()
221+
self.respond(200, data, "application/octet-stream")
222+
else:
223+
self.respond(404, "wheel not found")
224+
elif self.path.endswith(".html"):
225+
# name = self.path.strip("/")
226+
pyname = self.path.replace(".html", ".py").lstrip("/")
227+
try:
228+
doc = get_docstring_from_py_file(pyname)
229+
deps = [*fpl_deps, f"/{wheel_name}"]
230+
html = pyodide_compute_template.format(
231+
docstring=doc,
232+
example_script=pyname,
233+
# todo: refactor this to a list and maybe get other deps from pyodide.loadPackagesFromImports
234+
dependencies="\n".join(
235+
[f"await micropip.install({dep!r});" for dep in deps]
236+
),
237+
)
238+
self.respond(200, html, "text/html")
239+
except Exception as err:
240+
self.respond(404, f"example not found: {err}")
241+
elif self.path.endswith(".py"):
242+
filename = os.path.join(root, "examples", self.path.strip("/"))
243+
if os.path.isfile(filename):
244+
with open(filename, "rb") as f:
245+
data = f.read()
246+
data = patch_imageio_for_pyodide(data.decode()).encode()
247+
self.respond(200, data, "text/plain")
248+
else:
249+
self.respond(404, "py file not found")
250+
251+
# TODO: we could try to mount a virtual filesystem and fill it... but I think using fetch and serving the files could work easier.
252+
elif self.path.startswith("/home/data/"):
253+
requested_path = Path(self.path)
254+
# this is for the pyodide examples that need to load data files - we mount the examples/data folder to /home/data in pyodide, so we can serve those files here.
255+
filename = root / "examples" / "data" / requested_path.relative_to("/home/data")
256+
if os.path.isfile(filename):
257+
with open(filename, "rb") as f:
258+
data = f.read()
259+
self.respond(200, data, "application/octet-stream")
260+
else:
261+
self.respond(404, "data file not found")
262+
263+
else:
264+
self.respond(404, "not found")
265+
266+
def respond(self, code, body, content_type="text/plain"):
267+
self.send_response(code)
268+
self.send_header("Content-type", content_type)
269+
self.send_header("Access-Control-Allow-Origin", "*")
270+
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
271+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
272+
self.end_headers()
273+
if isinstance(body, str):
274+
body = body.encode()
275+
self.wfile.write(body)
276+
277+
278+
if __name__ == "__main__":
279+
port = 8000
280+
if len(sys.argv) > 1:
281+
try:
282+
port = int(sys.argv[-1])
283+
except ValueError:
284+
pass
285+
286+
build_wheel()
287+
print("Opening page in web browser ...")
288+
webbrowser.open(f"http://localhost:{port}/")
289+
HTTPServer(("", port), MyHandler).serve_forever()
290+
291+

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ keywords = [
2020
requires-python = ">= 3.10"
2121
dependencies = [
2222
"numpy>=1.23.0",
23-
"pygfx==0.15.3",
23+
"pygfx==0.16.0",
2424
"wgpu", # Let pygfx constrain the wgpu version
2525
"cmap>=0.1.3",
2626
# (this comment keeps this list multiline in VSCode)

0 commit comments

Comments
 (0)