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..53d74a5 --- /dev/null +++ b/docs/hooks/jupyterlite.py @@ -0,0 +1,146 @@ +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_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" + + +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"] = "./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-2026" + + if not contents_dir.exists(): + return + + if output_dir.exists(): + 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", + "lite", + "build", + "--apps", + "lab", + "--contents", + str(contents_dir), + "--output-dir", + str(output_dir), + "--piplite-wheels", + str(pyodide_wheel), + "--pyodide", + PYODIDE_CORE_URL, + "--no-libarchive", + "--no-sourcemaps", + "--no-unused-shared-packages", + ], + cwd=temp_dir, + check=True, + ) + + _configure_pyodide_runtime(output_dir) + _patch_pyodide_kernel_extension(output_dir) diff --git a/docs/jupyterlite/contents/ggml-python-playground.ipynb b/docs/jupyterlite/contents/ggml-python-playground.ipynb new file mode 100644 index 0000000..fa95b51 --- /dev/null +++ b/docs/jupyterlite/contents/ggml-python-playground.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", + "import piplite\n", + "\n", + "await micropip.install([\"numpy\", \"typing_extensions\"])\n", + "await piplite.install(\n", + " \"ggml-python\",\n", + " deps=False,\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..bafbda4 --- /dev/null +++ b/docs/playground.md @@ -0,0 +1,17 @@ +--- +title: Playground +--- + +# Playground + +This JupyterLite notebook runs entirely in your browser and installs the bundled Pyodide wheel from the local playground package 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",