From 8e301266b93dc441e34a15758668c37b281017af Mon Sep 17 00:00:00 2001 From: Jan Date: Sat, 9 May 2026 21:49:44 +0200 Subject: [PATCH 1/3] add local examples script --- examples/serve_browser_examples.py | 291 +++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 examples/serve_browser_examples.py diff --git a/examples/serve_browser_examples.py b/examples/serve_browser_examples.py new file mode 100644 index 000000000..0b1f6f7d7 --- /dev/null +++ b/examples/serve_browser_examples.py @@ -0,0 +1,291 @@ +""" +Serve browser examples locally. +=============================== + + +A little script to serve pygfx examples on localhost so you can try them in the browser. +This is an iteration of the same script in rendercanvas and wgpu-py although some changes: + +* swapped in pathlib for os.path (mostly) +* avoided pyscript for now, but likely a good idea to use for webworker recovery etc. + +Files are loaded from disk on each request, so you can leave the server running +and just update examples, update pygfx and build the wheel, etc. +""" + +import os +import sys +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +import flit + +import fastplotlib + + +# sphinx_gallery_pygfx_docs = 'hidden' +# sphinx_gallery_pygfx_test = 'off' + +# "hosted" upstream deps, will break in the future +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... +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... +pygfx_wheel = "https://pygfx--1273.org.readthedocs.build/1273/_static/pygfx-0.16.0-py3-none-any.whl" + +# the pygfx wheel will be listed after this. it might be possible to still get deps from pyproject.toml +fpl_deps = [wgpu_wheel, uharfbuzz_wheel, pygfx_wheel, "hsluv", "pylinalg", "jinja2", "httpx", "trimesh", "gltflib", "imageio"] + +root = Path(__file__).parent.parent.absolute() + +short_version = ".".join(str(i) for i in fastplotlib.version_info[:3]) +wheel_name = f"fastplotlib-{short_version}-py3-none-any.whl" + +example_files = list((root / "examples").glob("**/*.py")) + +def get_html_index(): + """Create a landing page.""" + + examples_list = [f"
  • {name.relative_to(root / "examples")!s}
  • " for name in example_files] + + html = """ + + + + fastplotlib browser examples + + + + + Rebuild the wheel

    + """ + + html += "List of examples that might run in Pyodide:\n" + html += f"
    \n\n" + + html += "\n\n" + return html + + +html_index = get_html_index() + +# TODO: a pyodide example for the compute examples (so we can capture output?) +# modified from _pyodide_iframe.html from rendercanvas, and then again from pygfx +pyodide_compute_template = """ + + + + + {example_script} via Pyodide + + + + + +

    Loading...

    +
    + + Back to list

    + +

    + {docstring} +

    + +
    +

    Output:

    +
    + + + + +""" + + +imageio_patch = """ +from pyodide.http import pyfetch +from pyodide.ffi import run_sync + +async def _imread_async(filename, **kwargs): + # pyodide working version of iio.imread, uses fetch and waiting on it, also replaced the "imageio:" + filename = str(filename) # maybe breaks some Path stuff? + if filename.startswith("imageio:"): + filename = filename.replace("imageio:", "https://raw.githubusercontent.com/imageio/imageio-binaries/master/images/") + + if "." in filename: + kwargs["extension"] = "." + filename.rsplit(".", 1)[-1] + + response = await pyfetch(filename) + res = iio.imread((await response.bytes()), **kwargs) + return res + +def _imread(filename:str, **kwargs): + print(f"Loading image: {filename!r} with kwargs: {kwargs}") + res = run_sync(_imread_async(filename, **kwargs)) + return res +""" + +def patch_imageio_for_pyodide(python_code:str) -> str: + if "iio.imread(" not in python_code: + return python_code + + return imageio_patch + "\n\n" + python_code.replace("iio.imread(", "_imread(") + + + +def build_wheel(): + toml_filename = (root / "pyproject.toml") + # spews more and more with each build... might need to clear stdout or something? + flit.main(["-f", str(toml_filename.resolve()), "build", "--no-use-vcs", "--format", "wheel"]) + wheel_filename = root / "dist" / wheel_name + assert wheel_filename.is_file(), f"{wheel_name} does not exist" + # also copy the wgpu wheel if it's in a repo nearby... so make it a bit less work to update both. + + +def get_docstring_from_py_file(fname): + filename = root / "examples" / fname + docstate = 0 + doc = "" + with open(filename, "rb") as f: + for line in f: + line = line.decode() + if docstate == 0: + if line.lstrip().startswith('"""'): + docstate = 1 + else: + if docstate == 1 and line.lstrip().startswith(("---", "===")): + docstate = 2 + doc = "" + elif '"""' in line: + doc += line.partition('"""')[0] + break + else: + doc += line + + return doc.replace("\n", "
    ") + + +class MyHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/": + self.respond(200, html_index, "text/html") + elif self.path == "/build": + try: + self.respond(200, "Building wheel...
    ", "text/html") + build_wheel() + except Exception as err: + self.respond(500, str(err), "text/plain") + else: + html = f"
    Wheel build: {wheel_name}

    Back to list" + self.respond(200, html, "text/html") + elif self.path.endswith(".whl"): + requested_path = Path(self.path) + filename = root / "dist" / requested_path.name + if os.path.isfile(filename): + with open(filename, "rb") as f: + data = f.read() + self.respond(200, data, "application/octet-stream") + else: + self.respond(404, "wheel not found") + elif self.path.endswith(".html"): + # name = self.path.strip("/") + pyname = self.path.replace(".html", ".py").lstrip("/") + try: + doc = get_docstring_from_py_file(pyname) + deps = [*fpl_deps, f"/{wheel_name}"] + html = pyodide_compute_template.format( + docstring=doc, + example_script=pyname, + # todo: refactor this to a list and maybe get other deps from pyodide.loadPackagesFromImports + dependencies="\n".join( + [f"await micropip.install({dep!r});" for dep in deps] + ), + ) + self.respond(200, html, "text/html") + except Exception as err: + self.respond(404, f"example not found: {err}") + elif self.path.endswith(".py"): + filename = os.path.join(root, "examples", self.path.strip("/")) + if os.path.isfile(filename): + with open(filename, "rb") as f: + data = f.read() + data = patch_imageio_for_pyodide(data.decode()).encode() + self.respond(200, data, "text/plain") + else: + self.respond(404, "py file not found") + + # TODO: we could try to mount a virtual filesystem and fill it... but I think using fetch and serving the files could work easier. + elif self.path.startswith("/home/data/"): + requested_path = Path(self.path) + # 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. + filename = root / "examples" / "data" / requested_path.relative_to("/home/data") + if os.path.isfile(filename): + with open(filename, "rb") as f: + data = f.read() + self.respond(200, data, "application/octet-stream") + else: + self.respond(404, "data file not found") + + else: + self.respond(404, "not found") + + def respond(self, code, body, content_type="text/plain"): + self.send_response(code) + self.send_header("Content-type", content_type) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + if isinstance(body, str): + body = body.encode() + self.wfile.write(body) + + +if __name__ == "__main__": + port = 8000 + if len(sys.argv) > 1: + try: + port = int(sys.argv[-1]) + except ValueError: + pass + + build_wheel() + print("Opening page in web browser ...") + webbrowser.open(f"http://localhost:{port}/") + HTTPServer(("", port), MyHandler).serve_forever() + + diff --git a/pyproject.toml b/pyproject.toml index 73dfd7ee3..dc5f3863d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ keywords = [ requires-python = ">= 3.10" dependencies = [ "numpy>=1.23.0", - "pygfx==0.15.3", + "pygfx==0.16.0", "wgpu", # Let pygfx constrain the wgpu version "cmap>=0.1.3", # (this comment keeps this list multiline in VSCode) From 93ec437e4b12f3db6d121e236af7159dcfe828e2 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 10 May 2026 02:27:23 +0200 Subject: [PATCH 2/3] use imgui_bundle for examples --- examples/serve_browser_examples.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/serve_browser_examples.py b/examples/serve_browser_examples.py index 0b1f6f7d7..a5ba1cfc1 100644 --- a/examples/serve_browser_examples.py +++ b/examples/serve_browser_examples.py @@ -33,7 +33,7 @@ pygfx_wheel = "https://pygfx--1273.org.readthedocs.build/1273/_static/pygfx-0.16.0-py3-none-any.whl" # the pygfx wheel will be listed after this. it might be possible to still get deps from pyproject.toml -fpl_deps = [wgpu_wheel, uharfbuzz_wheel, pygfx_wheel, "hsluv", "pylinalg", "jinja2", "httpx", "trimesh", "gltflib", "imageio"] +fpl_deps = [wgpu_wheel, uharfbuzz_wheel, pygfx_wheel, "imgui-bundle", "hsluv", "pylinalg", "jinja2", "httpx", "trimesh", "gltflib", "imageio"] root = Path(__file__).parent.parent.absolute() From 071d57c2946253ccdf7ef310680ae6ecd5c70829 Mon Sep 17 00:00:00 2001 From: Jan Date: Sun, 10 May 2026 02:27:38 +0200 Subject: [PATCH 3/3] fix texture view dimension --- fastplotlib/ui/right_click_menus/_colormap_picker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastplotlib/ui/right_click_menus/_colormap_picker.py b/fastplotlib/ui/right_click_menus/_colormap_picker.py index a80e5b2aa..53d51956e 100644 --- a/fastplotlib/ui/right_click_menus/_colormap_picker.py +++ b/fastplotlib/ui/right_click_menus/_colormap_picker.py @@ -79,7 +79,7 @@ def _create_texture_and_upload(self, data: np.ndarray) -> tuple[int, GPUTexture] ) # get a view - texture_view = texture.create_view() + texture_view = texture.create_view(dimension=wgpu.TextureViewDimension.d2) # return texture ref return self.imgui_renderer.backend.register_texture(texture_view)