|
| 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, "<").replace(/>/g, ">"); |
| 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 | + |
0 commit comments