diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0eb2f7b19..8fb9252cf 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -91,18 +91,13 @@ jobs: # Copies *.ipynb plus any local asset directories (e.g. # docs/notebooks/figs/, referenced by relative path from markdown # cells) -- but not the Makefile-generated *.py script conversions - # or other loose files. ik_benchmark.ipynb is excluded deliberately: - # it benchmarks numeric IK solver performance and uses the - # websocket-based Swift visualizer, neither of which is meaningful - # or functional inside the Pyodide sandbox -- it stays Colab-only. - # ik-symbolic.ipynb is included: SymPy is one of Pyodide's own packages + # or other loose files. ik-symbolic.ipynb is included: SymPy is one of Pyodide's own packages # and the derivation takes 1 to 10 s without python-flint (timed in # docs/lite/wasm_check, which checks it on every build). run: | mkdir -p docs/lite/files rsync -a --exclude '.ipynb_checkpoints' --exclude '__pycache__' \ --exclude 'Untitled*.ipynb' --exclude '*.py' --exclude 'Makefile' --exclude 'README.md' \ - --exclude 'ik_benchmark.ipynb' \ docs/notebooks/ docs/lite/files/ - name: Adapt staged notebooks for JupyterLite @@ -156,6 +151,28 @@ jobs: pip install jupyterlite-core "jupyterlite-pyodide-kernel==0.6.1" jupyter-server ipympl cd docs/lite && jupyter lite build --output-dir ../build/html/lite + - name: Check JupyterLite in a real browser (install, widgets, animation) + # Builds a small site with the wheel built above and one notebook, opens it in + # Chromium, and checks that the site installs that wheel (not the last release + # from PyPI), draws an ipywidgets slider and an ipympl figure, and that a + # trajectory animates while its cell is running. See + # docs/lite/browser_check/. Informational (continue-on-error) until it has + # run green on main, then remove that line so that it blocks the deploy. + continue-on-error: true + timeout-minutes: 20 + run: | + pip install playwright + playwright install --with-deps chromium + python docs/lite/browser_check/check_lite_in_browser.py --screenshot lite_check_failed.png + + - name: Keep the screenshot if the browser check failed + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: lite-browser-check + path: lite_check_failed.png + if-no-files-found: ignore + - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5 with: diff --git a/docs/lite/browser_check/README.md b/docs/lite/browser_check/README.md new file mode 100644 index 000000000..9799e4f21 --- /dev/null +++ b/docs/lite/browser_check/README.md @@ -0,0 +1,35 @@ +# JupyterLite browser check + +Runs JupyterLite in a real browser (Chromium, with Playwright) and checks what the +Node check in `../wasm_check/` cannot see: that the site installs the toolbox that was +built with it, and that plots and widgets work. + +`check_lite_in_browser.py` builds a small site from `docs/lite` (its configuration and +the wheel in `docs/lite/pypi`) plus one notebook that it writes, `browser_check.ipynb`, +which starts with the real generated bootstrap cell. It opens that in Chromium and runs +the notebook, which requires: + +| Check | Why | +|---|---| +| the bootstrap cell prints `Running in browser using RTB v...` | the install path works at all | +| a hash of the installed `PyPlot.py` equals that of the checkout | the site installs the wheel built with it, not the last release from PyPI (a plain `micropip.install` cannot see the site's own index, `piplite` can; this is what went wrong once and nothing else noticed) | +| an `ipywidgets` slider is drawn | widgets work in JupyterLite | +| an `ipympl` figure of one pose is drawn | `%matplotlib ipympl`, which the notebooks use, works | +| a trajectory shows at least 5 different images while its cell is running | an `ipympl` figure cannot update while a cell runs, so the Toolbox pushes inline frames for an animation (`tests/test_notebook_animation.py` checks the same thing with a kernel and no browser) | + +It runs in `docs.yml` after the site is built, as an informational step until it has +passed on `main`. To run it yourself: + +```bash +pip install playwright && playwright install chromium +pip install jupyterlite-core "jupyterlite-pyodide-kernel==0.6.1" jupyter-server ipympl +RTB_PURE_WHEEL=1 python -m build --wheel --outdir docs/lite/pypi +python docs/lite/browser_check/check_lite_in_browser.py +``` + +`CHROMIUM_PATH` selects a Chromium executable, and `--screenshot` says where to save the +page when a check fails. The run takes about two minutes: most of it is building the +site and downloading Pyodide's packages in the browser. + +The kernel version is pinned in `docs.yml` (`jupyterlite-pyodide-kernel==0.6.1`) for +browsers without WebAssembly JSPI; see the comment there before changing it. diff --git a/docs/lite/browser_check/check_lite_in_browser.py b/docs/lite/browser_check/check_lite_in_browser.py new file mode 100755 index 000000000..e70f235da --- /dev/null +++ b/docs/lite/browser_check/check_lite_in_browser.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python +"""Check JupyterLite in a real browser: the toolbox installs, and plots and widgets work. + +The Node/Pyodide check in ``../wasm_check/`` tests the install and the Python side +without a browser. This one builds a small JupyterLite site that contains the wheel +built from this checkout and one notebook, ``browser_check.ipynb``, opens it in +Chromium, and runs the notebook, which does what the notebooks of the site do: + +1. runs the generated bootstrap cell and prints the version of the toolbox, +2. imports the toolbox and prints a hash of the source of ``PyPlot.py``, +3. shows an ``ipywidgets`` slider, +4. plots a robot with ``%matplotlib ipympl`` (an interactive figure), +5. animates a trajectory with ``%matplotlib ipympl``. + +It requires: the bootstrap line, the hash of the installed ``PyPlot.py`` to be that of +the checkout (the site installs the wheel built with it, not the last release from +PyPI, which is what a plain ``micropip.install`` does), the slider and the figure to be +drawn, and the animation to show at least ``--min-frames`` different images while the +cell is still running (an ``ipympl`` figure cannot update then, so the Toolbox pushes +frames of its own). + +Usage:: + + pip install playwright && playwright install chromium + pip install jupyterlite-core "jupyterlite-pyodide-kernel==0.6.1" jupyter-server ipympl + RTB_PURE_WHEEL=1 python -m build --wheel --outdir docs/lite/pypi + python docs/lite/browser_check/check_lite_in_browser.py + +``CHROMIUM_PATH`` selects a particular Chromium executable. A screenshot of the final +page is saved on failure, to ``--screenshot``. Exits 0 if the checks pass, 1 if not. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +from playwright.sync_api import Error as PlaywrightError +from playwright.sync_api import sync_playwright + +HERE = Path(__file__).resolve().parent +REPO = HERE.parents[2] +NOTEBOOKS = REPO / "docs" / "notebooks" +PYPLOT = REPO / "src" / "roboticstoolbox" / "backends" / "PyPlot" / "PyPlot.py" + +BOOTSTRAP_TEXT = "Running in browser using RTB v" +# the cells of the notebook, in order +BOOTSTRAP, SETUP, WIDGET, FIGURE, ANIMATION = range(5) + +CELLS = [ + None, # the generated bootstrap cell, see make_notebook + """%matplotlib ipympl +import hashlib, importlib +import roboticstoolbox as rtb +module = importlib.import_module("roboticstoolbox.backends.PyPlot.PyPlot") +print("PYPLOT_MD5", hashlib.md5(open(module.__file__, "rb").read()).hexdigest()) +robot = rtb.models.DH.Puma560() +trajectory = rtb.jtraj(robot.qz, robot.qn, 100).q +print("READY")""", + """import ipywidgets +slider = ipywidgets.IntSlider(value=3, description="n") +display(slider)""", + "robot.plot(robot.qn);", + "robot.plot(trajectory);", +] + + +class CheckFailed(Exception): + """A check failed; the message says which.""" + + +def make_notebook(path: Path) -> None: + """Write browser_check.ipynb, with the real generated bootstrap cell.""" + sys.path.insert(0, str(NOTEBOOKS)) + import sync_bootstrap + + template = (NOTEBOOKS / "_rtb_nb_bootstrap.py").read_text(encoding="utf-8") + sources = list(CELLS) + sources[BOOTSTRAP] = "".join(sync_bootstrap.generated_source(template)) + nb = { + "cells": [ + { + "cell_type": "code", + "execution_count": None, + "id": f"cell{i}", + "metadata": {}, + "outputs": [], + "source": source, + } + for i, source in enumerate(sources) + ], + "metadata": { + "kernelspec": { + "name": "python", + "display_name": "Python (Pyodide)", + "language": "python", + } + }, + "nbformat": 4, + "nbformat_minor": 5, + } + path.write_text(json.dumps(nb), encoding="utf-8") + + +def build_site(lite_dir: Path, work: Path) -> Path: + """Build a JupyterLite site with the wheel in lite_dir/pypi and the notebook.""" + source = work / "lite" + (source / "files").mkdir(parents=True) + for name in ("jupyter-lite.json", "jupyter_lite_config.json"): + shutil.copy(lite_dir / name, source / name) + shutil.copytree(lite_dir / "pypi", source / "pypi") + if not list((source / "pypi").glob("roboticstoolbox_python-*.whl")): + raise CheckFailed(f"no wheel in {lite_dir / 'pypi'}: build it first") + make_notebook(source / "files" / "browser_check.ipynb") + + out = work / "site" + subprocess.run( + [sys.executable, "-m", "jupyter", "lite", "build", "--output-dir", str(out)], + cwd=source, + check=True, + capture_output=True, + ) + # let the script drive JupyterLab through its commands + config = out / "jupyter-lite.json" + data = json.loads(config.read_text(encoding="utf-8")) + data.setdefault("jupyter-config-data", {})["exposeAppInBrowser"] = True + config.write_text(json.dumps(data), encoding="utf-8") + return out + + +def run_checks(page, timeout_s: float, min_frames: int) -> list[str]: + """Run the notebook in the page and check it; returns what passed.""" + passed = [] + cells = page.locator(".jp-NotebookPanel:not(.lm-mod-hidden) .jp-Cell") + + page.wait_for_selector( + ".jp-NotebookPanel:not(.lm-mod-hidden) .jp-Notebook", timeout=90_000 + ) + page.wait_for_function("window.jupyterapp !== undefined", timeout=60_000) + time.sleep(3) + page.evaluate( + "() => { window.jupyterapp.commands.execute('notebook:run-all-cells'); return 1 }" + ) + + prompts = lambda: page.evaluate( # noqa: E731 + "[...document.querySelectorAll(" + "'.jp-NotebookPanel:not(.lm-mod-hidden) .jp-Cell .jp-InputPrompt')]" + ".map(e => e.textContent.trim())" + ) + frames: list[str] = [] + deadline = time.monotonic() + timeout_s + finished_at = None + while time.monotonic() < deadline: + time.sleep(1) + state = prompts() + if len(state) == len(CELLS) and "*" in state[ANIMATION]: + # the animation is running: what is on the screen must keep changing + area = cells.nth(ANIMATION).locator(".jp-OutputArea") + try: + if area.count() and area.first.is_visible(): + frames.append( + hashlib.md5(area.first.screenshot(timeout=3000)).hexdigest() + ) + except PlaywrightError: + pass + if len(state) == len(CELLS) and all( + p and "*" not in p and p != "[ ]:" for p in state + ): + finished_at = finished_at or time.monotonic() + if time.monotonic() - finished_at > 3: + break + else: + raise CheckFailed(f"the notebook did not finish within {timeout_s:.0f} s") + + outputs = page.evaluate( + "[...document.querySelectorAll('.jp-NotebookPanel:not(.lm-mod-hidden) .jp-Cell')]" + ".map(c => { const o = c.querySelector('.jp-OutputArea'); return o ? o.innerText : '' })" + ) + for i, text in enumerate(outputs): + if "Traceback" in text or "Error" in text.split("\n")[0]: + raise CheckFailed(f"cell {i + 1} raised:\n{text[-1200:]}") + + if BOOTSTRAP_TEXT not in outputs[BOOTSTRAP]: + raise CheckFailed( + f"no '{BOOTSTRAP_TEXT}' from the bootstrap cell:\n{outputs[BOOTSTRAP][-800:]}" + ) + passed.append( + next(line for line in outputs[BOOTSTRAP].splitlines() if BOOTSTRAP_TEXT in line) + ) + + expected = hashlib.md5(PYPLOT.read_bytes()).hexdigest() + reported = [ + line.split()[1] for line in outputs[SETUP].splitlines() if "PYPLOT_MD5" in line + ] + if reported != [expected]: + raise CheckFailed( + "the toolbox in the browser is not the wheel built from this checkout " + f"(PyPlot.py md5 {reported}, expected {expected}): the site installed " + "the last release from PyPI instead of the wheel built with it" + ) + passed.append("the installed toolbox is the wheel built with the site") + + if cells.nth(WIDGET).locator(".widget-slider, .jupyter-widgets").count() == 0: + raise CheckFailed("the ipywidgets slider was not drawn") + passed.append("ipywidgets slider drawn") + + if cells.nth(FIGURE).locator(".jupyter-matplotlib-canvas-container").count() == 0: + raise CheckFailed("the ipympl figure of a single pose was not drawn") + passed.append("ipympl figure drawn (interactive)") + + distinct = len(set(frames)) + if distinct < min_frames: + raise CheckFailed( + f"the animation showed {distinct} different images in {len(frames)} " + f"samples while it was running, wanted at least {min_frames}" + ) + passed.append(f"animation: {distinct} different images in {len(frames)} samples") + return passed + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + parser.add_argument("--lite-dir", type=Path, default=REPO / "docs" / "lite") + parser.add_argument("--timeout", type=float, default=420, help="seconds to run") + parser.add_argument("--min-frames", type=int, default=5) + parser.add_argument("--screenshot", type=Path, default=Path("lite_check_failed.png")) + args = parser.parse_args() + + with tempfile.TemporaryDirectory() as tmp: + site = build_site(args.lite_dir, Path(tmp)) + server = subprocess.Popen( + [sys.executable, "-m", "http.server", "8890", "--directory", str(site)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + time.sleep(2) + with sync_playwright() as p: + browser = p.chromium.launch( + executable_path=os.environ.get("CHROMIUM_PATH") or None + ) + # a tall window, since JupyterLab draws only the cells that are visible + page = browser.new_context( + viewport={"width": 1300, "height": 5000} + ).new_page() + errors: list[str] = [] + page.on("pageerror", lambda e: errors.append(str(e)[:200])) + try: + page.goto( + "http://localhost:8890/lab/index.html" + "?path=browser_check.ipynb&reset", + wait_until="load", + ) + passed = run_checks(page, args.timeout, args.min_frames) + except (CheckFailed, PlaywrightError) as e: + print(f"FAIL: {e}") + for line in errors[:5]: + print(f" page error: {line}") + try: + page.screenshot(path=str(args.screenshot)) + print(f"screenshot: {args.screenshot}") + except PlaywrightError: + pass + return 1 + finally: + browser.close() + finally: + server.terminate() + + for line in passed: + print(f"PASS: {line}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/lite/wasm_check/README.md b/docs/lite/wasm_check/README.md index 4407e77a3..511a87364 100644 --- a/docs/lite/wasm_check/README.md +++ b/docs/lite/wasm_check/README.md @@ -14,29 +14,28 @@ needs `packaging>=26.3` where Pyodide has 24.2; the bootstrap installs it with `deps=False` for that reason. This check exists so that class of bug fails CI instead of shipping: it runs the real `ensure_installed()` from `docs/notebooks/_rtb_nb_bootstrap.py` against the pure wheel, installs the -dependencies for real, and confirms `import roboticstoolbox` works and a forward -kinematics call returns. +dependencies for real, and confirms `import roboticstoolbox` works, a forward kinematics call +returns, the analytic inverse kinematics of a Puma 560 and a Kinova Jaco work (with SymPy +only), and that a trajectory plotted under the `ipympl` backend is pushed as a +different inline image for every step. It reuses that function unmodified rather than re-listing the install steps here — a second copy of that list would just be a second place for it to drift. ## What this deliberately does NOT catch -Whether the *real* notebook's relative-URL wheel-install path (`pypi/`) -resolves correctly inside a real browser's Pyodide Web Worker. Node has no -equivalent of a Worker's base URL, and faking one convincingly requires already -knowing the right answer — that question needs a real browser, and was already -answered that way (build the site locally with `jupyter lite build` + -`jupyter lite serve`, open it in an actual browser tab — see -`docs/notebooks/README.md`). This check sidesteps that entirely: it mounts the -wheel directly into Pyodide's own virtual filesystem and installs from there -(`emfs:/`, a micropip scheme that reads straight from Pyodide's filesystem, -no HTTP involved) via a narrow, clearly-commented monkeypatch in -`check_pyodide_install.mjs`. Deliberate, not a shortcut — see that file's own -docstring for the reasoning. +Whether the site's own package index really serves the wheel, and so whether the +notebook installs the wheel built with the site and not the last release from PyPI. +In JupyterLite the bootstrap installs through `piplite`, which looks in the site's +index first; there is no site or `piplite` under Node. This check stands in for it with +a small fake `piplite` module that installs the wheel, mounted in Pyodide's own virtual +filesystem (`emfs:/`, a micropip scheme that reads straight from Pyodide's +filesystem), and passes every other package on to micropip, see +`check_pyodide_install.mjs`. Everything else in the bootstrap runs unmodified. -If you ever suspect the relative-URL path itself is broken, this check will not -tell you — go build and serve the real site and check it in a real browser instead. +`../browser_check/` covers that, in a real browser, and also draws a widget, a figure and +an animation. It would have caught the time the bootstrap installed from PyPI whatever +the site contained. ## Usage diff --git a/docs/lite/wasm_check/check_pyodide_install.mjs b/docs/lite/wasm_check/check_pyodide_install.mjs index df7b335a2..3a4f32e75 100644 --- a/docs/lite/wasm_check/check_pyodide_install.mjs +++ b/docs/lite/wasm_check/check_pyodide_install.mjs @@ -45,11 +45,6 @@ const pyodide = await loadPyodide(); await pyodide.loadPackage("micropip"); pyodide.FS.writeFile("/" + wheelName, fs.readFileSync(wheelPath)); -// The bootstrap looks for the site's bundled wheel in /pypi, and falls back to -// installing from PyPI if there is none. Without this the check would test the -// last release, not the wheel just built. -pyodide.FS.mkdirTree("/pypi"); -pyodide.FS.writeFile("/pypi/" + wheelName, fs.readFileSync(wheelPath)); pyodide.FS.writeFile("/_rtb_nb_bootstrap.py", fs.readFileSync(bootstrapPath)); const driver = ` @@ -59,18 +54,20 @@ sys.path.insert(0, "/") import micropip import _rtb_nb_bootstrap as bootstrap -# Test-only scaffolding: redirect the one HTTP/relative-URL install call to -# the wheel already mounted in Pyodide's own virtual filesystem, so this -# check doesn't need to fake a browser Web Worker's base URL (see this -# file's module docstring for why that's out of scope here). Everything -# else -- the install steps, which is what this check actually -# exists to catch drift in -- runs completely unmodified. -_real_install = micropip.install -async def _redirect_wheel_install(requirements, **kwargs): - if isinstance(requirements, str) and requirements.startswith("pypi/"): +# Test-only scaffolding: in JupyterLite the bootstrap installs through piplite, which +# finds the toolbox in the site's own package index, the wheel built with the site. +# There is no piplite or site here, so a stand-in installs that wheel, mounted in +# Pyodide's own virtual filesystem (the \`emfs:\` micropip scheme), and passes every +# other package on to micropip. Everything else, the install steps in the +# bootstrap, which is what this check exists to catch drift in, runs unmodified. +import types +_piplite = types.ModuleType("piplite") +async def _install(requirements, deps=True, **kwargs): + if requirements == "roboticstoolbox-python": requirements = "emfs:/${wheelName}" - return await _real_install(requirements, **kwargs) -micropip.install = _redirect_wheel_install + return await micropip.install(requirements, deps=deps, **kwargs) +_piplite.install = _install +sys.modules["piplite"] = _piplite await bootstrap.ensure_installed(extras=['ik']) @@ -88,6 +85,34 @@ for name in ("Puma560", "Jaco"): sol = ik.solve(robot.fkine([0.1] * robot.n)) assert len(sol.q) > 0, name print(f"analytic_ik {name}: {type(ik).__name__}, {time.time() - t:.1f}s") + +# Plotting a trajectory in a notebook. JupyterLite notebooks say %matplotlib ipympl, +# and an ipympl figure cannot update while a cell runs, so the animation has to be +# pushed frame by frame as inline images. What the Toolbox pushes is captured here. +import matplotlib +matplotlib.use("agg") # there is no browser to draw in under Node +import importlib +# the package also exports a class called PyPlot, so ask for the module by name +module = importlib.import_module("roboticstoolbox.backends.PyPlot.PyPlot") + +class _Matplotlib: + """matplotlib, claiming that the ipympl backend is the one in use.""" + def __getattr__(self, name): + return getattr(matplotlib, name) + def get_backend(self): + return "module://ipympl.backend_nbagg" +module.matplotlib = _Matplotlib() + +frames = [] +module._ipy_display = lambda frame, display_id=None: frames.append(frame.data) +module._ipy_clear_output = lambda wait=False: None + +robot = rtb.models.DH.Puma560() +env = robot.plot(rtb.jtraj(robot.qz, robot.qn, 8).q, dt=0.0) +assert env.render_mode == "notebook-inline", env.render_mode +assert len(set(frames)) >= 8, f"{len(set(frames))} different frames pushed for 8 steps" +assert robot.plot(robot.qn).render_mode == "notebook-widget" +print(f"plot of a trajectory: {env.render_mode}, {len(set(frames))} different frames") print("import roboticstoolbox: OK") `; diff --git a/docs/notebooks/README.md b/docs/notebooks/README.md index 8e41a4067..2376ba40a 100644 --- a/docs/notebooks/README.md +++ b/docs/notebooks/README.md @@ -18,8 +18,10 @@ found by that line, not by its position) and is folded by default. * In the browser the cell installs `spatialmath-python` without its dependencies first, because release 1.1.18 lists `pre-commit` as a runtime dependency, which conflicts with the `packaging` built into Pyodide (rai-opensource/spatialmath-python#250). - Then it installs the toolbox with its dependencies, from the wheel bundled with the - JupyterLite site if there is one, or else from PyPI. + Then it installs the toolbox with its dependencies. In JupyterLite this goes through + `piplite`, which looks in the site's own package index first, so the toolbox is the + wheel that was built with the site and not the last release from PyPI (a plain + `micropip.install` cannot see that index). `docs/lite/browser_check/` checks it. ## The first cell diff --git a/docs/notebooks/_rtb_nb_bootstrap.py b/docs/notebooks/_rtb_nb_bootstrap.py index 914bdc412..3c0d76088 100644 --- a/docs/notebooks/_rtb_nb_bootstrap.py +++ b/docs/notebooks/_rtb_nb_bootstrap.py @@ -11,7 +11,6 @@ import subprocess import sys -from pathlib import Path async def ensure_installed(extras: tuple[str, ...] | list[str] = ()) -> bool: @@ -23,7 +22,18 @@ async def ensure_installed(extras: tuple[str, ...] | list[str] = ()) -> bool: :returns: True if running on Google Colab, False otherwise. """ if sys.platform == "emscripten": - import micropip + # In JupyterLite, piplite looks in this site's own package index first, so the + # toolbox it installs is the wheel that was built with the site. micropip + # alone cannot see that index and would install the last release from PyPI. + # Outside JupyterLite (for example a test under Node) there is no piplite. + try: + import piplite + + install = piplite.install + except ImportError: + import micropip + + install = micropip.install # spatialmath-python <= 1.1.18 lists pre-commit (a dev tool) as a runtime # dependency; its virtualenv requires packaging>=26.3, which conflicts with @@ -32,24 +42,15 @@ async def ensure_installed(extras: tuple[str, ...] | list[str] = ()) -> bool: # toolbox's own dependencies are resolved below. Remove this line once a # spatialmath release without that dependency is out # (rai-opensource/spatialmath-python#250). - await micropip.install("spatialmath-python", deps=False) - await micropip.install("ipympl") + await install("spatialmath-python", deps=False) + await install("ipympl") if "ik" in extras: # SymPy is one of Pyodide's own packages. python-flint is not installed: # the one bundled with Pyodide 0.27 is too old for the toolbox, which # then uses SymPy for the polynomial steps, about 2x slower for a # general 6R arm (10 s to derive a Kinova Jaco). - await micropip.install("sympy") - - wheels = sorted(Path("/pypi").glob("roboticstoolbox_python-*.whl")) - if wheels: - # Prefer the wheel bundled with this JupyterLite site. Relative, - # not absolute: a leading slash resolves against the origin, not - # this site's own base URL (a GitHub Pages project subpath). - await micropip.install(f"pypi/{wheels[-1].name}") - else: - # Fall back to PyPI when running outside the published site layout. - await micropip.install("roboticstoolbox-python") + await install("sympy") + await install("roboticstoolbox-python") where, colab = "in browser", False else: try: diff --git a/docs/notebooks/branched-robot.ipynb b/docs/notebooks/branched-robot.ipynb index 82a8d5c7c..ac2a119fb 100644 --- a/docs/notebooks/branched-robot.ipynb +++ b/docs/notebooks/branched-robot.ipynb @@ -36,20 +36,20 @@ "\n", "import subprocess\n", "import sys\n", - "from pathlib import Path\n", "\n", "async def ensure_installed(extras: tuple[str, ...] | list[str]=()) -> bool:\n", " if sys.platform == 'emscripten':\n", - " import micropip\n", - " await micropip.install('spatialmath-python', deps=False)\n", - " await micropip.install('ipympl')\n", + " try:\n", + " import piplite\n", + " install = piplite.install\n", + " except ImportError:\n", + " import micropip\n", + " install = micropip.install\n", + " await install('spatialmath-python', deps=False)\n", + " await install('ipympl')\n", " if 'ik' in extras:\n", - " await micropip.install('sympy')\n", - " wheels = sorted(Path('/pypi').glob('roboticstoolbox_python-*.whl'))\n", - " if wheels:\n", - " await micropip.install(f'pypi/{wheels[-1].name}')\n", - " else:\n", - " await micropip.install('roboticstoolbox-python')\n", + " await install('sympy')\n", + " await install('roboticstoolbox-python')\n", " where, colab = ('in browser', False)\n", " else:\n", " try:\n", diff --git a/docs/notebooks/dynamics.ipynb b/docs/notebooks/dynamics.ipynb index 99080667e..e403e7936 100644 --- a/docs/notebooks/dynamics.ipynb +++ b/docs/notebooks/dynamics.ipynb @@ -35,20 +35,20 @@ "\n", "import subprocess\n", "import sys\n", - "from pathlib import Path\n", "\n", "async def ensure_installed(extras: tuple[str, ...] | list[str]=()) -> bool:\n", " if sys.platform == 'emscripten':\n", - " import micropip\n", - " await micropip.install('spatialmath-python', deps=False)\n", - " await micropip.install('ipympl')\n", + " try:\n", + " import piplite\n", + " install = piplite.install\n", + " except ImportError:\n", + " import micropip\n", + " install = micropip.install\n", + " await install('spatialmath-python', deps=False)\n", + " await install('ipympl')\n", " if 'ik' in extras:\n", - " await micropip.install('sympy')\n", - " wheels = sorted(Path('/pypi').glob('roboticstoolbox_python-*.whl'))\n", - " if wheels:\n", - " await micropip.install(f'pypi/{wheels[-1].name}')\n", - " else:\n", - " await micropip.install('roboticstoolbox-python')\n", + " await install('sympy')\n", + " await install('roboticstoolbox-python')\n", " where, colab = ('in browser', False)\n", " else:\n", " try:\n", diff --git a/docs/notebooks/ets.ipynb b/docs/notebooks/ets.ipynb index 1f76bfe43..6f6d0a5bb 100644 --- a/docs/notebooks/ets.ipynb +++ b/docs/notebooks/ets.ipynb @@ -37,20 +37,20 @@ "\n", "import subprocess\n", "import sys\n", - "from pathlib import Path\n", "\n", "async def ensure_installed(extras: tuple[str, ...] | list[str]=()) -> bool:\n", " if sys.platform == 'emscripten':\n", - " import micropip\n", - " await micropip.install('spatialmath-python', deps=False)\n", - " await micropip.install('ipympl')\n", + " try:\n", + " import piplite\n", + " install = piplite.install\n", + " except ImportError:\n", + " import micropip\n", + " install = micropip.install\n", + " await install('spatialmath-python', deps=False)\n", + " await install('ipympl')\n", " if 'ik' in extras:\n", - " await micropip.install('sympy')\n", - " wheels = sorted(Path('/pypi').glob('roboticstoolbox_python-*.whl'))\n", - " if wheels:\n", - " await micropip.install(f'pypi/{wheels[-1].name}')\n", - " else:\n", - " await micropip.install('roboticstoolbox-python')\n", + " await install('sympy')\n", + " await install('roboticstoolbox-python')\n", " where, colab = ('in browser', False)\n", " else:\n", " try:\n", diff --git a/docs/notebooks/graphics-test.ipynb b/docs/notebooks/graphics-test.ipynb index e6985b247..0a3374047 100644 --- a/docs/notebooks/graphics-test.ipynb +++ b/docs/notebooks/graphics-test.ipynb @@ -34,20 +34,20 @@ "\n", "import subprocess\n", "import sys\n", - "from pathlib import Path\n", "\n", "async def ensure_installed(extras: tuple[str, ...] | list[str]=()) -> bool:\n", " if sys.platform == 'emscripten':\n", - " import micropip\n", - " await micropip.install('spatialmath-python', deps=False)\n", - " await micropip.install('ipympl')\n", + " try:\n", + " import piplite\n", + " install = piplite.install\n", + " except ImportError:\n", + " import micropip\n", + " install = micropip.install\n", + " await install('spatialmath-python', deps=False)\n", + " await install('ipympl')\n", " if 'ik' in extras:\n", - " await micropip.install('sympy')\n", - " wheels = sorted(Path('/pypi').glob('roboticstoolbox_python-*.whl'))\n", - " if wheels:\n", - " await micropip.install(f'pypi/{wheels[-1].name}')\n", - " else:\n", - " await micropip.install('roboticstoolbox-python')\n", + " await install('sympy')\n", + " await install('roboticstoolbox-python')\n", " where, colab = ('in browser', False)\n", " else:\n", " try:\n", diff --git a/docs/notebooks/icra2021.ipynb b/docs/notebooks/icra2021.ipynb index aeed1dd54..09bfffc11 100644 --- a/docs/notebooks/icra2021.ipynb +++ b/docs/notebooks/icra2021.ipynb @@ -38,20 +38,20 @@ "\n", "import subprocess\n", "import sys\n", - "from pathlib import Path\n", "\n", "async def ensure_installed(extras: tuple[str, ...] | list[str]=()) -> bool:\n", " if sys.platform == 'emscripten':\n", - " import micropip\n", - " await micropip.install('spatialmath-python', deps=False)\n", - " await micropip.install('ipympl')\n", + " try:\n", + " import piplite\n", + " install = piplite.install\n", + " except ImportError:\n", + " import micropip\n", + " install = micropip.install\n", + " await install('spatialmath-python', deps=False)\n", + " await install('ipympl')\n", " if 'ik' in extras:\n", - " await micropip.install('sympy')\n", - " wheels = sorted(Path('/pypi').glob('roboticstoolbox_python-*.whl'))\n", - " if wheels:\n", - " await micropip.install(f'pypi/{wheels[-1].name}')\n", - " else:\n", - " await micropip.install('roboticstoolbox-python')\n", + " await install('sympy')\n", + " await install('roboticstoolbox-python')\n", " where, colab = ('in browser', False)\n", " else:\n", " try:\n", diff --git a/docs/notebooks/icra2026.ipynb b/docs/notebooks/icra2026.ipynb index e39a92502..c4f0dee12 100644 --- a/docs/notebooks/icra2026.ipynb +++ b/docs/notebooks/icra2026.ipynb @@ -36,20 +36,20 @@ "\n", "import subprocess\n", "import sys\n", - "from pathlib import Path\n", "\n", "async def ensure_installed(extras: tuple[str, ...] | list[str]=()) -> bool:\n", " if sys.platform == 'emscripten':\n", - " import micropip\n", - " await micropip.install('spatialmath-python', deps=False)\n", - " await micropip.install('ipympl')\n", + " try:\n", + " import piplite\n", + " install = piplite.install\n", + " except ImportError:\n", + " import micropip\n", + " install = micropip.install\n", + " await install('spatialmath-python', deps=False)\n", + " await install('ipympl')\n", " if 'ik' in extras:\n", - " await micropip.install('sympy')\n", - " wheels = sorted(Path('/pypi').glob('roboticstoolbox_python-*.whl'))\n", - " if wheels:\n", - " await micropip.install(f'pypi/{wheels[-1].name}')\n", - " else:\n", - " await micropip.install('roboticstoolbox-python')\n", + " await install('sympy')\n", + " await install('roboticstoolbox-python')\n", " where, colab = ('in browser', False)\n", " else:\n", " try:\n", diff --git a/docs/notebooks/ik-symbolic.ipynb b/docs/notebooks/ik-symbolic.ipynb index 369c3546f..e712505cb 100644 --- a/docs/notebooks/ik-symbolic.ipynb +++ b/docs/notebooks/ik-symbolic.ipynb @@ -53,20 +53,20 @@ "\n", "import subprocess\n", "import sys\n", - "from pathlib import Path\n", "\n", "async def ensure_installed(extras: tuple[str, ...] | list[str]=()) -> bool:\n", " if sys.platform == 'emscripten':\n", - " import micropip\n", - " await micropip.install('spatialmath-python', deps=False)\n", - " await micropip.install('ipympl')\n", + " try:\n", + " import piplite\n", + " install = piplite.install\n", + " except ImportError:\n", + " import micropip\n", + " install = micropip.install\n", + " await install('spatialmath-python', deps=False)\n", + " await install('ipympl')\n", " if 'ik' in extras:\n", - " await micropip.install('sympy')\n", - " wheels = sorted(Path('/pypi').glob('roboticstoolbox_python-*.whl'))\n", - " if wheels:\n", - " await micropip.install(f'pypi/{wheels[-1].name}')\n", - " else:\n", - " await micropip.install('roboticstoolbox-python')\n", + " await install('sympy')\n", + " await install('roboticstoolbox-python')\n", " where, colab = ('in browser', False)\n", " else:\n", " try:\n", diff --git a/docs/notebooks/iros2020.ipynb b/docs/notebooks/iros2020.ipynb index cc80adb5f..4e37c5511 100644 --- a/docs/notebooks/iros2020.ipynb +++ b/docs/notebooks/iros2020.ipynb @@ -44,20 +44,20 @@ "\n", "import subprocess\n", "import sys\n", - "from pathlib import Path\n", "\n", "async def ensure_installed(extras: tuple[str, ...] | list[str]=()) -> bool:\n", " if sys.platform == 'emscripten':\n", - " import micropip\n", - " await micropip.install('spatialmath-python', deps=False)\n", - " await micropip.install('ipympl')\n", + " try:\n", + " import piplite\n", + " install = piplite.install\n", + " except ImportError:\n", + " import micropip\n", + " install = micropip.install\n", + " await install('spatialmath-python', deps=False)\n", + " await install('ipympl')\n", " if 'ik' in extras:\n", - " await micropip.install('sympy')\n", - " wheels = sorted(Path('/pypi').glob('roboticstoolbox_python-*.whl'))\n", - " if wheels:\n", - " await micropip.install(f'pypi/{wheels[-1].name}')\n", - " else:\n", - " await micropip.install('roboticstoolbox-python')\n", + " await install('sympy')\n", + " await install('roboticstoolbox-python')\n", " where, colab = ('in browser', False)\n", " else:\n", " try:\n", diff --git a/docs/notebooks/kinematics.ipynb b/docs/notebooks/kinematics.ipynb index 7b59c38b1..7047c44ce 100644 --- a/docs/notebooks/kinematics.ipynb +++ b/docs/notebooks/kinematics.ipynb @@ -36,20 +36,20 @@ "\n", "import subprocess\n", "import sys\n", - "from pathlib import Path\n", "\n", "async def ensure_installed(extras: tuple[str, ...] | list[str]=()) -> bool:\n", " if sys.platform == 'emscripten':\n", - " import micropip\n", - " await micropip.install('spatialmath-python', deps=False)\n", - " await micropip.install('ipympl')\n", + " try:\n", + " import piplite\n", + " install = piplite.install\n", + " except ImportError:\n", + " import micropip\n", + " install = micropip.install\n", + " await install('spatialmath-python', deps=False)\n", + " await install('ipympl')\n", " if 'ik' in extras:\n", - " await micropip.install('sympy')\n", - " wheels = sorted(Path('/pypi').glob('roboticstoolbox_python-*.whl'))\n", - " if wheels:\n", - " await micropip.install(f'pypi/{wheels[-1].name}')\n", - " else:\n", - " await micropip.install('roboticstoolbox-python')\n", + " await install('sympy')\n", + " await install('roboticstoolbox-python')\n", " where, colab = ('in browser', False)\n", " else:\n", " try:\n", diff --git a/docs/notebooks/symbolic.ipynb b/docs/notebooks/symbolic.ipynb index 9a89250ec..2a8207972 100644 --- a/docs/notebooks/symbolic.ipynb +++ b/docs/notebooks/symbolic.ipynb @@ -36,20 +36,20 @@ "\n", "import subprocess\n", "import sys\n", - "from pathlib import Path\n", "\n", "async def ensure_installed(extras: tuple[str, ...] | list[str]=()) -> bool:\n", " if sys.platform == 'emscripten':\n", - " import micropip\n", - " await micropip.install('spatialmath-python', deps=False)\n", - " await micropip.install('ipympl')\n", + " try:\n", + " import piplite\n", + " install = piplite.install\n", + " except ImportError:\n", + " import micropip\n", + " install = micropip.install\n", + " await install('spatialmath-python', deps=False)\n", + " await install('ipympl')\n", " if 'ik' in extras:\n", - " await micropip.install('sympy')\n", - " wheels = sorted(Path('/pypi').glob('roboticstoolbox_python-*.whl'))\n", - " if wheels:\n", - " await micropip.install(f'pypi/{wheels[-1].name}')\n", - " else:\n", - " await micropip.install('roboticstoolbox-python')\n", + " await install('sympy')\n", + " await install('roboticstoolbox-python')\n", " where, colab = ('in browser', False)\n", " else:\n", " try:\n", diff --git a/docs/notebooks/two-link-dynamics.ipynb b/docs/notebooks/two-link-dynamics.ipynb index 67ef5e05b..1e20ed95c 100644 --- a/docs/notebooks/two-link-dynamics.ipynb +++ b/docs/notebooks/two-link-dynamics.ipynb @@ -50,20 +50,20 @@ "\n", "import subprocess\n", "import sys\n", - "from pathlib import Path\n", "\n", "async def ensure_installed(extras: tuple[str, ...] | list[str]=()) -> bool:\n", " if sys.platform == 'emscripten':\n", - " import micropip\n", - " await micropip.install('spatialmath-python', deps=False)\n", - " await micropip.install('ipympl')\n", + " try:\n", + " import piplite\n", + " install = piplite.install\n", + " except ImportError:\n", + " import micropip\n", + " install = micropip.install\n", + " await install('spatialmath-python', deps=False)\n", + " await install('ipympl')\n", " if 'ik' in extras:\n", - " await micropip.install('sympy')\n", - " wheels = sorted(Path('/pypi').glob('roboticstoolbox_python-*.whl'))\n", - " if wheels:\n", - " await micropip.install(f'pypi/{wheels[-1].name}')\n", - " else:\n", - " await micropip.install('roboticstoolbox-python')\n", + " await install('sympy')\n", + " await install('roboticstoolbox-python')\n", " where, colab = ('in browser', False)\n", " else:\n", " try:\n", diff --git a/tests/test_notebook_bootstrap.py b/tests/test_notebook_bootstrap.py index 03146be6f..12f387fc0 100644 --- a/tests/test_notebook_bootstrap.py +++ b/tests/test_notebook_bootstrap.py @@ -345,3 +345,12 @@ def test_install_writes_the_hook_and_leaves_another_alone(tmp_path): (hooks / "pre-commit").write_text("#!/bin/sh\necho mine\n", encoding="utf-8") assert "left alone" in pre_commit.install(hooks) assert "mine" in (hooks / "pre-commit").read_text(encoding="utf-8") + + +def test_the_browser_install_goes_through_piplite(): + # micropip cannot see a JupyterLite site's own package index, and a /pypi folder + # does not exist in the kernel, so the wheel built with the site would never be used + template = (NOTEBOOKS_DIR / "_rtb_nb_bootstrap.py").read_text(encoding="utf-8") + + assert "import piplite" in template + assert '"/pypi"' not in template