From fac077342eb6cb3d35a98e49f08d3c0ac1f2699d Mon Sep 17 00:00:00 2001 From: abetlen Date: Sun, 21 Jun 2026 11:10:11 -0700 Subject: [PATCH 1/4] docs: add JupyterLite playground --- CHANGELOG.md | 2 + docs/hooks/jupyterlite.py | 39 +++++++++ docs/jupyterlite/contents/ggml-python.ipynb | 93 +++++++++++++++++++++ docs/playground.md | 17 ++++ mkdocs.yml | 8 +- pyproject.toml | 12 ++- 6 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 docs/hooks/jupyterlite.py create mode 100644 docs/jupyterlite/contents/ggml-python.ipynb create mode 100644 docs/playground.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 80aa248..9d50cf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- docs: add JupyterLite browser playground by @abetlen in #173 + ## [0.0.44] - ci: add Pyodide wheel builds by @abetlen in #171 diff --git a/docs/hooks/jupyterlite.py b/docs/hooks/jupyterlite.py new file mode 100644 index 0000000..229003f --- /dev/null +++ b/docs/hooks/jupyterlite.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from pathlib import Path + + +def on_post_build(config, **kwargs) -> None: + docs_dir = Path(config["docs_dir"]) + site_dir = Path(config["site_dir"]) + contents_dir = docs_dir / "jupyterlite" / "contents" + output_dir = site_dir / "playground" / "lite" + + if not contents_dir.exists(): + return + + if output_dir.exists(): + shutil.rmtree(output_dir) + + with tempfile.TemporaryDirectory(prefix="ggml-python-jupyterlite-") as temp_dir: + subprocess.run( + [ + "jupyter", + "lite", + "build", + "--apps", + "lab", + "--contents", + str(contents_dir), + "--output-dir", + str(output_dir), + "--no-libarchive", + "--no-sourcemaps", + "--no-unused-shared-packages", + ], + cwd=temp_dir, + check=True, + ) diff --git a/docs/jupyterlite/contents/ggml-python.ipynb b/docs/jupyterlite/contents/ggml-python.ipynb new file mode 100644 index 0000000..cc1e668 --- /dev/null +++ b/docs/jupyterlite/contents/ggml-python.ipynb @@ -0,0 +1,93 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# ggml-python in Pyodide\n", + "\n", + "This notebook installs the Pyodide wheel and runs a small ggml graph in the browser." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import micropip\n", + "\n", + "await micropip.install([\"numpy\", \"typing_extensions\"])\n", + "await micropip.install(\n", + " \"ggml-python\",\n", + " deps=False,\n", + " index_urls=[\"https://abetlen.github.io/ggml-python/whl/cpu\"],\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import ggml\n", + "\n", + "params = ggml.ggml_init_params(mem_size=16 * 1024 * 1024, mem_buffer=None)\n", + "ctx = ggml.ggml_init(params)\n", + "assert ctx is not None\n", + "\n", + "x = ggml.ggml_new_tensor_1d(ctx, ggml.GGML_TYPE_F32, 1)\n", + "a = ggml.ggml_new_tensor_1d(ctx, ggml.GGML_TYPE_F32, 1)\n", + "b = ggml.ggml_new_tensor_1d(ctx, ggml.GGML_TYPE_F32, 1)\n", + "\n", + "x2 = ggml.ggml_mul(ctx, x, x)\n", + "f = ggml.ggml_add(ctx, ggml.ggml_mul(ctx, a, x2), b)\n", + "\n", + "gf = ggml.ggml_new_graph(ctx)\n", + "ggml.ggml_build_forward_expand(gf, f)\n", + "\n", + "ggml.ggml_set_f32(x, 2.0)\n", + "ggml.ggml_set_f32(a, 3.0)\n", + "ggml.ggml_set_f32(b, 4.0)\n", + "\n", + "ggml.ggml_graph_compute_with_ctx(ctx, gf, 1)\n", + "output = ggml.ggml_get_f32_1d(f, 0)\n", + "ggml.ggml_free(ctx)\n", + "\n", + "output" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "assert output == 16.0\n", + "ggml.ggml_version().decode()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python (Pyodide)", + "language": "python", + "name": "python" + }, + "language_info": { + "codemirror_mode": { + "name": "python", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/playground.md b/docs/playground.md new file mode 100644 index 0000000..c1ab6f4 --- /dev/null +++ b/docs/playground.md @@ -0,0 +1,17 @@ +--- +title: Playground +--- + +# Playground + +This JupyterLite notebook runs entirely in your browser and installs the Pyodide wheel from the ggml-python wheel index. + + + +Open the JupyterLite workspace in a full page if the embedded view is too small. + + diff --git a/mkdocs.yml b/mkdocs.yml index 23b8e34..719f9ad 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -48,12 +48,15 @@ plugins: - search - social +hooks: + - docs/hooks/jupyterlite.py + markdown_extensions: - tables - attr_list - pymdownx.emoji: - emoji_index: !!python/name:materialx.emoji.twemoji - emoji_generator: !!python/name:materialx.emoji.to_svg + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg - pymdownx.tilde - pymdownx.superfences - pymdownx.inlinehilite @@ -70,5 +73,6 @@ watch: nav: - "Getting Started": "index.md" + - "Playground": "playground.md" - "API Reference": "api-reference.md" - "Changelog": "changelog.md" diff --git a/pyproject.toml b/pyproject.toml index af5f681..a6bbbbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,17 @@ extend-exclude = ["ggml/contrib/onnx.py", "tests/test_ggml_onnx.py"] [project.optional-dependencies] test = ["pytest"] -docs = ["mkdocs", "mkdocstrings[python]", "mkdocs-material", "pillow", "cairosvg"] +docs = [ + "mkdocs", + "mkdocstrings[python]", + "mkdocs-material", + "pillow", + "cairosvg", + "jupyter-server>=2", + "jupyterlab-server>=2", + "jupyterlite-core>=0.7.0,<0.8", + "jupyterlite-pyodide-kernel>=0.7.0,<0.8", +] publish = ["build"] convert = [ "accelerate==0.30.1", From e5b6e38a2bdac76e8a8e11c4f7c6e753f1578b1f Mon Sep 17 00:00:00 2001 From: abetlen Date: Sun, 21 Jun 2026 12:10:12 -0700 Subject: [PATCH 2/4] docs: fix JupyterLite Pyodide install --- docs/hooks/jupyterlite.py | 67 +++++++++++++++++++++ docs/jupyterlite/contents/ggml-python.ipynb | 4 +- docs/playground.md | 2 +- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/docs/hooks/jupyterlite.py b/docs/hooks/jupyterlite.py index 229003f..7aba014 100644 --- a/docs/hooks/jupyterlite.py +++ b/docs/hooks/jupyterlite.py @@ -1,10 +1,69 @@ from __future__ import annotations +import json import shutil import subprocess import tempfile +import urllib.request +from html.parser import HTMLParser from pathlib import Path +GGML_PYTHON_WHEEL_INDEX = "https://abetlen.github.io/ggml-python/whl/cpu/ggml-python/" +# The current Pyodide wheel is tagged for the 2026 ABI, while JupyterLite 0.7 +# defaults to a 2025 ABI Pyodide runtime. +PYODIDE_URL = "https://cdn.jsdelivr.net/pyodide/dev/full/pyodide.js" +PYODIDE_WHEEL_SUFFIX = "-py3-none-pyemscripten_2026_0_wasm32.whl" + + +class _LinkParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.hrefs: list[str] = [] + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag != "a": + return + + for name, value in attrs: + if name == "href" and value is not None: + self.hrefs.append(value) + + +def _find_latest_pyodide_wheel_url() -> str: + with urllib.request.urlopen(GGML_PYTHON_WHEEL_INDEX, timeout=30) as response: + html = response.read().decode("utf-8") + + parser = _LinkParser() + parser.feed(html) + + for href in parser.hrefs: + if href.endswith(PYODIDE_WHEEL_SUFFIX): + return href + + msg = f"could not find Pyodide wheel in {GGML_PYTHON_WHEEL_INDEX}" + raise RuntimeError(msg) + + +def _download_pyodide_wheel(wheels_dir: Path) -> Path: + wheel_url = _find_latest_pyodide_wheel_url() + wheel_path = wheels_dir / wheel_url.rsplit("/", 1)[-1] + with urllib.request.urlopen(wheel_url, timeout=30) as response: + wheel_path.write_bytes(response.read()) + return wheel_path + + +def _configure_pyodide_runtime(output_dir: Path) -> None: + config_path = output_dir / "jupyter-lite.json" + config_data = json.loads(config_path.read_text()) + lite_settings = config_data["jupyter-config-data"].setdefault( + "litePluginSettings", {} + ) + kernel_settings = lite_settings.setdefault( + "@jupyterlite/pyodide-kernel-extension:kernel", {} + ) + kernel_settings["pyodideUrl"] = PYODIDE_URL + config_path.write_text(json.dumps(config_data, indent=2) + "\n") + def on_post_build(config, **kwargs) -> None: docs_dir = Path(config["docs_dir"]) @@ -19,6 +78,10 @@ def on_post_build(config, **kwargs) -> None: shutil.rmtree(output_dir) with tempfile.TemporaryDirectory(prefix="ggml-python-jupyterlite-") as temp_dir: + wheels_dir = Path(temp_dir) / "wheels" + wheels_dir.mkdir() + pyodide_wheel = _download_pyodide_wheel(wheels_dir) + subprocess.run( [ "jupyter", @@ -30,6 +93,8 @@ def on_post_build(config, **kwargs) -> None: str(contents_dir), "--output-dir", str(output_dir), + "--piplite-wheels", + str(pyodide_wheel), "--no-libarchive", "--no-sourcemaps", "--no-unused-shared-packages", @@ -37,3 +102,5 @@ def on_post_build(config, **kwargs) -> None: cwd=temp_dir, check=True, ) + + _configure_pyodide_runtime(output_dir) diff --git a/docs/jupyterlite/contents/ggml-python.ipynb b/docs/jupyterlite/contents/ggml-python.ipynb index cc1e668..fa95b51 100644 --- a/docs/jupyterlite/contents/ggml-python.ipynb +++ b/docs/jupyterlite/contents/ggml-python.ipynb @@ -16,12 +16,12 @@ "outputs": [], "source": [ "import micropip\n", + "import piplite\n", "\n", "await micropip.install([\"numpy\", \"typing_extensions\"])\n", - "await micropip.install(\n", + "await piplite.install(\n", " \"ggml-python\",\n", " deps=False,\n", - " index_urls=[\"https://abetlen.github.io/ggml-python/whl/cpu\"],\n", ")" ] }, diff --git a/docs/playground.md b/docs/playground.md index c1ab6f4..fb3ea98 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -4,7 +4,7 @@ title: Playground # Playground -This JupyterLite notebook runs entirely in your browser and installs the Pyodide wheel from the ggml-python wheel index. +This JupyterLite notebook runs entirely in your browser and installs the bundled Pyodide wheel from the local playground package index. From 46f6d138375e800e9c354ef43c414dab31ed4822 Mon Sep 17 00:00:00 2001 From: abetlen Date: Sun, 21 Jun 2026 12:12:04 -0700 Subject: [PATCH 3/4] docs: avoid stale JupyterLite notebook state --- .../{ggml-python.ipynb => ggml-python-playground.ipynb} | 0 docs/playground.md | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename docs/jupyterlite/contents/{ggml-python.ipynb => ggml-python-playground.ipynb} (100%) diff --git a/docs/jupyterlite/contents/ggml-python.ipynb b/docs/jupyterlite/contents/ggml-python-playground.ipynb similarity index 100% rename from docs/jupyterlite/contents/ggml-python.ipynb rename to docs/jupyterlite/contents/ggml-python-playground.ipynb diff --git a/docs/playground.md b/docs/playground.md index fb3ea98..01212e6 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -11,7 +11,7 @@ This JupyterLite notebook runs entirely in your browser and installs the bundled Open the JupyterLite workspace in a full page if the embedded view is too small. From 4fad2fc8899d29612f60e85e974f815e4f4ef861 Mon Sep 17 00:00:00 2001 From: abetlen Date: Sun, 21 Jun 2026 19:42:47 -0700 Subject: [PATCH 4/4] docs: fix JupyterLite Pyodide runtime --- docs/hooks/jupyterlite.py | 46 ++++++++++++++++++++++++++++++++++++--- docs/playground.md | 2 +- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/hooks/jupyterlite.py b/docs/hooks/jupyterlite.py index 7aba014..53d74a5 100644 --- a/docs/hooks/jupyterlite.py +++ b/docs/hooks/jupyterlite.py @@ -11,7 +11,11 @@ GGML_PYTHON_WHEEL_INDEX = "https://abetlen.github.io/ggml-python/whl/cpu/ggml-python/" # The current Pyodide wheel is tagged for the 2026 ABI, while JupyterLite 0.7 # defaults to a 2025 ABI Pyodide runtime. -PYODIDE_URL = "https://cdn.jsdelivr.net/pyodide/dev/full/pyodide.js" +PYODIDE_INDEX_URL = "https://cdn.jsdelivr.net/pyodide/v314.0.0/full/" +PYODIDE_CORE_URL = ( + "https://github.com/pyodide/pyodide/releases/download/314.0.0/" + "pyodide-core-314.0.0.tar.bz2" +) PYODIDE_WHEEL_SUFFIX = "-py3-none-pyemscripten_2026_0_wasm32.whl" @@ -61,15 +65,48 @@ def _configure_pyodide_runtime(output_dir: Path) -> None: kernel_settings = lite_settings.setdefault( "@jupyterlite/pyodide-kernel-extension:kernel", {} ) - kernel_settings["pyodideUrl"] = PYODIDE_URL + kernel_settings["pyodideUrl"] = "./static/pyodide/pyodide.mjs" + load_options = kernel_settings.setdefault("loadPyodideOptions", {}) + load_options["indexURL"] = PYODIDE_INDEX_URL config_path.write_text(json.dumps(config_data, indent=2) + "\n") +def _patch_pyodide_kernel_extension(output_dir: Path) -> None: + extension_dir = ( + output_dir + / "extensions" + / "@jupyterlite" + / "pyodide-kernel-extension" + / "static" + ) + dynamic_import_stub = ( + '476:e=>{function t(e){return Promise.resolve().then((()=>{var t=new ' + "Error(\"Cannot find module '\"+e+\"'\");throw " + 't.code="MODULE_NOT_FOUND",t}))}t.keys=()=>[],t.resolve=t,t.id=476,' + "e.exports=t}" + ) + + for path in extension_dir.glob("*.js"): + text = path.read_text(errors="replace") + patched = text.replace("{type:void 0}", '{type:"module"}') + patched = patched.replace( + dynamic_import_stub, + "476:e=>{e.exports=e=>import(e)}", + ) + patched = patched.replace( + '["sqlite3","ipykernel","comm","pyodide_kernel","jedi","ipython"]', + '["ipykernel","comm","pyodide_kernel","jedi","ipython"]', + ) + + if patched != text: + path.write_text(patched) + + def on_post_build(config, **kwargs) -> None: docs_dir = Path(config["docs_dir"]) site_dir = Path(config["site_dir"]) contents_dir = docs_dir / "jupyterlite" / "contents" - output_dir = site_dir / "playground" / "lite" + output_dir = site_dir / "playground" / "lite-2026" if not contents_dir.exists(): return @@ -95,6 +132,8 @@ def on_post_build(config, **kwargs) -> None: str(output_dir), "--piplite-wheels", str(pyodide_wheel), + "--pyodide", + PYODIDE_CORE_URL, "--no-libarchive", "--no-sourcemaps", "--no-unused-shared-packages", @@ -104,3 +143,4 @@ def on_post_build(config, **kwargs) -> None: ) _configure_pyodide_runtime(output_dir) + _patch_pyodide_kernel_extension(output_dir) diff --git a/docs/playground.md b/docs/playground.md index 01212e6..bafbda4 100644 --- a/docs/playground.md +++ b/docs/playground.md @@ -11,7 +11,7 @@ This JupyterLite notebook runs entirely in your browser and installs the bundled Open the JupyterLite workspace in a full page if the embedded view is too small.