From 42e8112953094251254fced3158cc49363f17e36 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Wed, 21 Oct 2026 13:34:18 +1000 Subject: [PATCH] fix(lite): the notebooks install the wheel built with the site, and a browser check proves it The bootstrap looked for the bundled wheel in /pypi, which does not exist in the kernel, so JupyterLite always installed the last release from PyPI. Install through piplite, which looks in the site's own index first. The Node check stands in for piplite and also plots a trajectory under ipympl; a new Playwright check builds a site with the wheel and checks the install, ipywidgets, an ipympl figure and an animation in Chromium. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/docs.yml | 22 ++ docs/lite/browser_check/README.md | 35 +++ .../browser_check/check_lite_in_browser.py | 284 ++++++++++++++++++ docs/lite/wasm_check/README.md | 31 +- .../lite/wasm_check/check_pyodide_install.mjs | 57 +++- docs/notebooks/README.md | 6 +- docs/notebooks/_rtb_nb_bootstrap.py | 31 +- docs/notebooks/branched-robot.ipynb | 20 +- docs/notebooks/dynamics.ipynb | 20 +- docs/notebooks/ets.ipynb | 20 +- docs/notebooks/graphics-test.ipynb | 20 +- docs/notebooks/icra2021.ipynb | 20 +- docs/notebooks/icra2026.ipynb | 20 +- docs/notebooks/ik-symbolic.ipynb | 20 +- docs/notebooks/ik_benchmark.ipynb | 20 +- docs/notebooks/iros2020.ipynb | 20 +- docs/notebooks/kinematics.ipynb | 20 +- docs/notebooks/symbolic.ipynb | 20 +- docs/notebooks/two-link-dynamics.ipynb | 20 +- tests/test_notebook_bootstrap.py | 9 + 20 files changed, 546 insertions(+), 169 deletions(-) create mode 100644 docs/lite/browser_check/README.md create mode 100755 docs/lite/browser_check/check_lite_in_browser.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 0eb2f7b19..61014f70b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -156,6 +156,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 f85b5f75d..bfa963194 100644 --- a/docs/notebooks/branched-robot.ipynb +++ b/docs/notebooks/branched-robot.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/dynamics.ipynb b/docs/notebooks/dynamics.ipynb index 9b5b46c4c..4b4fa1ae1 100644 --- a/docs/notebooks/dynamics.ipynb +++ b/docs/notebooks/dynamics.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/ets.ipynb b/docs/notebooks/ets.ipynb index 3e3e66ade..a8e1ac2ca 100644 --- a/docs/notebooks/ets.ipynb +++ b/docs/notebooks/ets.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/graphics-test.ipynb b/docs/notebooks/graphics-test.ipynb index d8c66e31f..c66097aad 100644 --- a/docs/notebooks/graphics-test.ipynb +++ b/docs/notebooks/graphics-test.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/icra2021.ipynb b/docs/notebooks/icra2021.ipynb index 2f2e86322..44696d3af 100644 --- a/docs/notebooks/icra2021.ipynb +++ b/docs/notebooks/icra2021.ipynb @@ -40,20 +40,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 7fa18c843..59029fb0f 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 83e59f74c..ee1c652c5 100644 --- a/docs/notebooks/ik-symbolic.ipynb +++ b/docs/notebooks/ik-symbolic.ipynb @@ -55,20 +55,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_benchmark.ipynb b/docs/notebooks/ik_benchmark.ipynb index ffe69585d..7fa1d30fd 100644 --- a/docs/notebooks/ik_benchmark.ipynb +++ b/docs/notebooks/ik_benchmark.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/iros2020.ipynb b/docs/notebooks/iros2020.ipynb index 6443f17b3..7f6f3908e 100644 --- a/docs/notebooks/iros2020.ipynb +++ b/docs/notebooks/iros2020.ipynb @@ -46,20 +46,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 b509a5a47..4b2dd45c6 100644 --- a/docs/notebooks/symbolic.ipynb +++ b/docs/notebooks/symbolic.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/two-link-dynamics.ipynb b/docs/notebooks/two-link-dynamics.ipynb index 0a38cd268..42aaab56e 100644 --- a/docs/notebooks/two-link-dynamics.ipynb +++ b/docs/notebooks/two-link-dynamics.ipynb @@ -52,20 +52,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