Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
146 changes: 146 additions & 0 deletions docs/hooks/jupyterlite.py
Original file line number Diff line number Diff line change
@@ -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)
93 changes: 93 additions & 0 deletions docs/jupyterlite/contents/ggml-python-playground.ipynb
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions docs/playground.md
Original file line number Diff line number Diff line change
@@ -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.

<iframe id="ggml-python-playground" title="ggml-python JupyterLite playground" width="100%" height="820" style="border: 1px solid var(--md-default-fg-color--lightest); border-radius: 4px;"></iframe>

Open the <a id="ggml-python-playground-link" href="#">JupyterLite workspace</a> in a full page if the embedded view is too small.

<script>
const ggmlPythonPlayground = "lite-2026/lab/index.html?path=ggml-python-playground.ipynb";
document.getElementById("ggml-python-playground").src = ggmlPythonPlayground;
document.getElementById("ggml-python-playground-link").href = ggmlPythonPlayground;
</script>
8 changes: 6 additions & 2 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -70,5 +73,6 @@ watch:

nav:
- "Getting Started": "index.md"
- "Playground": "playground.md"
- "API Reference": "api-reference.md"
- "Changelog": "changelog.md"
12 changes: 11 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading