diff --git a/.gitignore b/.gitignore index 383c36cab..2e610c7c5 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,4 @@ docs/source/_imgui_images/ # generated by docs/source/generate_api.py on every docs build docs/source/api/ docs/source/user_guide/event_tables.rst +docs/source/_static/*.whl diff --git a/docs/source/_static/_pyodide_iframe.html b/docs/source/_static/_pyodide_iframe.html new file mode 100644 index 000000000..be0dd92b3 --- /dev/null +++ b/docs/source/_static/_pyodide_iframe.html @@ -0,0 +1,65 @@ + + + + + + Fastplotlib examples in the browser using Pyodide + + + + +
+ +

click the button to load pyodide

+ +
+
+
+

Output:

+
+ + + + \ No newline at end of file diff --git a/docs/source/_static/style.css b/docs/source/_static/style.css index e69de29bb..de72de3c1 100644 --- a/docs/source/_static/style.css +++ b/docs/source/_static/style.css @@ -0,0 +1,5 @@ +section[id^="interactive-example"] iframe { + width: 100%; + height: 720px; + border: none; +} diff --git a/docs/source/conf.py b/docs/source/conf.py index 4a9cbe8ab..c47b1510b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -50,6 +50,94 @@ "generate_api", ] +# note this is largely copied from the pygfx PR branch: https://github.com/pygfx/pygfx/pull/1273 +# -- Build wheel so Pyodide examples can use exactly this version of fpl ----------------------------------------------------- +import subprocess +import shutil + +short_version = ".".join(str(i) for i in fastplotlib.version_info[:3]) +wheel_name = f"fastplotlib-{short_version}-py3-none-any.whl" + +# Build the wheel +subprocess.run([sys.executable, "-m", "build", "-nw"], cwd=ROOT_DIR) +wheel_filename = os.path.join(ROOT_DIR, "dist", wheel_name) +assert os.path.isfile(wheel_filename), f"{wheel_name} does not exist" + +# Copy into static +# TODO: you can use --outdir on the build command directly. also use the html_static_path in this namespace +print("Copy wheel to _static dir") +shutil.copy( + wheel_filename, + os.path.join(ROOT_DIR, "docs", "source", "_static", wheel_name), +) + +# -- Sphix Gallery ----------------------------------------------------- + +## pyodide demos, adapted from wgpu, adapted from rendercanvas... might make sense to put this in the scaper? +iframe_placeholder_rst = """ +.. only:: html + + Interactive example + ------------------- + + Try this example in your browser using Pyodide. Might not work with all examples and all devices. Check the output and your browser's console for details. + + .. raw:: html + + +""" +python_files = {} + +# I have a feeling this import might be really sketchy to have on CI... but hey - this is a hack for a hack +from examples.serve_browser_examples import patch_imageio_for_pyodide + + +def add_pyodide_to_examples(app): + if app.builder.name != "html": + return + + gallery_dir = ROOT_DIR / "docs" / "source" / "_gallery" + example_files = gallery_dir.glob("**/*.py") + + for py_file in example_files: + fname = py_file.name + with open(py_file, "rb") as f: + py = f.read().decode() + py = patch_imageio_for_pyodide(py) + if fname: + print("Adding Pyodide example to", fname) + fname_rst = py_file.with_suffix(".rst") + # Update rst file + rst = iframe_placeholder_rst.replace("example.py", fname) + # we likely don't want append here? + with open(fname_rst, "ab") as f: + # TODO: skip if it already ends with the placeholder? otherwise the append will keep on appending (we have to hook this into the gen_rst to skip if possible?) + f.write(rst.encode()) + python_files[py_file.relative_to(gallery_dir)] = py + +def add_files_to_run_pyodide_examples(app, exception): + if app.builder.name != "html": + return + + gallery_build_dir = os.path.join(app.outdir, "_gallery") + + # Write html file that can load pyodide examples + with open( + os.path.join(ROOT_DIR, "docs", "source", "_static", "_pyodide_iframe.html"), "rb" + ) as f: + html = f.read().decode() + html = html.replace('"fastplotlib"', f'"../_static/{wheel_name}"') + with open(os.path.join(gallery_build_dir, "pyodide.html"), "wb") as f: + f.write(html.encode()) + + # Write the python files + for fname, py in python_files.items(): + print("Writing", fname) + with open(os.path.join(gallery_build_dir, fname), "wb") as f: + f.write(py.encode()) + + + sphinx_gallery_conf = { "gallery_dirs": "_gallery", "notebook_extensions": {}, # remove the download notebook button @@ -150,3 +238,11 @@ "rendercanvas": ("https://rendercanvas.readthedocs.io/stable/", None), # "fastplotlib": ("https://www.fastplotlib.org/", None), } + +html_css_files = [ + "style.css", +] + +def setup(app): + app.connect("builder-inited", add_pyodide_to_examples) + app.connect("build-finished", add_files_to_run_pyodide_examples) diff --git a/examples/serve_browser_examples.py b/examples/serve_browser_examples.py new file mode 100644 index 000000000..23683c059 --- /dev/null +++ b/examples/serve_browser_examples.py @@ -0,0 +1,323 @@ +""" +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.32.0-py3-none-any.whl" # very hacky way to serve this but it does work... +# pygfx_wheel = "https://pygfx--1273.org.readthedocs.build/1273/_static/pygfx-0.17.0-py3-none-any.whl" + +# local wheels (in dist/) +wgpu_wheel = "wgpu-0.32.0-py3-none-any.whl" +pygfx_wheel = "pygfx-0.17.0-py3-none-any.whl" + +# rendercanvas_wheel = "rendercanvas-2.7.2-py3-none-any.whl" # still moved manually ... +rendercanvas_wheel = "https://rendercanvas.readthedocs.io/latest/_static/rendercanvas-2.7.2-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 = [rendercanvas_wheel, wgpu_wheel, pygfx_wheel, "imgui-bundle", "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 + + + + + + Back to list

    + +

    + {docstring} +

    +
    +

    Loading ...

    +
    +
    +

    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. + try: + # would also be fun if this actually built the wheel too... but that might be too much atm. + wgpu_wheel_filename = root.parent / "wgpu-py" / "dist" / wgpu_wheel + if wgpu_wheel_filename.is_file(): + # TODO: can use Pathlib copy instead? + target = root / "dist" / wgpu_wheel + with open(wgpu_wheel_filename, "rb") as src, open(target, "wb") as dst: + dst.write(src.read()) + print(f"Copied {wgpu_wheel} to dist folder.") + else: + print(f"{wgpu_wheel} not found in nearby repo, skipping copy. falling 'back' to hosted wheel") + # maye fall back to online wheel? + except Exception as e: + print(f"Error copying {wgpu_wheel}: {e}") + # alsp pygfx + try: + # would also be fun if this actually built the wheel too... but that might be too much atm. + pygfx_wheel_filename = root.parent / "pygfx" / "dist" / pygfx_wheel + if pygfx_wheel_filename.is_file(): + # TODO: can use Pathlib copy instead? + target = root / "dist" / pygfx_wheel + with open(pygfx_wheel_filename, "rb") as src, open(target, "wb") as dst: + dst.write(src.read()) + print(f"Copied {pygfx_wheel} to dist folder.") + else: + print(f"{pygfx_wheel} not found in nearby repo, skipping copy. falling 'back' to hosted wheel") + # maye fall back to online wheel? + except Exception as e: + print(f"Error copying {pygfx_wheel}: {e}") + + +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/fastplotlib/graphics/shaders/_highlight_shaders.py b/fastplotlib/graphics/shaders/_highlight_shaders.py index 6b114bf7a..ed96a55f0 100644 --- a/fastplotlib/graphics/shaders/_highlight_shaders.py +++ b/fastplotlib/graphics/shaders/_highlight_shaders.py @@ -248,8 +248,7 @@ def get_bindings(self, wobject, shared, scene): return {0: group0} def get_code(self): - wgsl = super().get_code() - + wgsl = super().get_code().replace("\r\n", "\n") # newlines on windows were \r\n breaking the matching here # Pre-sample: inject visibility LUT remapping. fpl_texcoord is always # declared here so the highlight block below can safely reference it # regardless of whether visibility is active diff --git a/pyproject.toml b/pyproject.toml index be331b5fe..ae9562be0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ dependencies = [ [project.optional-dependencies] docs = [ + "build", + "flit", "sphinx", "sphinx-gallery", "pydata-sphinx-theme", @@ -74,7 +76,7 @@ Repository = "https://github.com/fastplotlib/fastplotlib" # ===== Building [build-system] -requires = ["flit_core >=3.2,<4"] +requires = ["flit_core >=3.2,<5"] build-backend = "flit_core.buildapi" # ===== Tooling