From 6ff5450e7e3224b6397442eceff3d9a72c0e8e78 Mon Sep 17 00:00:00 2001 From: Junru Shao Date: Fri, 12 Dec 2025 23:25:20 -0800 Subject: [PATCH] doc: Add version switcher --- docs/README.md | 12 ++ docs/_static/versions.json | 8 ++ docs/conf.py | 41 +++++- docs/run_sphinx_multiversion.py | 225 ++++++++++++++++++++++++++++++++ 4 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 docs/_static/versions.json create mode 100644 docs/run_sphinx_multiversion.py diff --git a/docs/README.md b/docs/README.md index d790e075..45460cdd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -95,6 +95,18 @@ BUILD_CPP_DOCS=1 BUILD_RUST_DOCS=1 uv run --group docs sphinx-autobuild docs doc BUILD_CPP_DOCS=1 BUILD_RUST_DOCS=1 uv run --group docs sphinx-build -M html docs docs/_build ``` +### Multi-version build (main + tags) + +Build the documentation for a fixed list of versions (currently `v0.1.6-rc0`, `v0.1.5`, `main`). The multi-version helper builds from git archives, sets a pretend version, and regenerates the switcher JSON automatically (edit `docs/run_sphinx_multiversion.py` to change the list): + +```bash +BUILD_CPP_DOCS=1 BUILD_RUST_DOCS=1 uv run --group docs python docs/run_sphinx_multiversion.py --base-url "/" +``` + +If the site is hosted under a subpath (for example `https://tvm.apache.org/ffi/`), set `--base-url "/ffi"` in that invocation to keep the switcher JSON and root redirect pointing at the correct prefix. + +The JSON (`_static/versions.json`) is read by the book theme’s version switcher across every built version; the root `index.html` redirects to the preferred version. + ## Cleanup Remove generated artifacts when they are no longer needed: diff --git a/docs/_static/versions.json b/docs/_static/versions.json new file mode 100644 index 00000000..ddb006df --- /dev/null +++ b/docs/_static/versions.json @@ -0,0 +1,8 @@ +[ + { + "name": "main", + "version": "main", + "url": "/main/", + "preferred": true + } +] diff --git a/docs/conf.py b/docs/conf.py index bb7f1202..f3a27d44 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -34,6 +34,7 @@ build_exhale = os.environ.get("BUILD_CPP_DOCS", "0") == "1" build_rust_docs = os.environ.get("BUILD_RUST_DOCS", "0") == "1" +build_version = os.environ.get("TVM_FFI_DOCS_VERSION", None) # Auto-detect sphinx-autobuild: Check if sphinx-autobuild is in the execution path is_autobuild = any("sphinx-autobuild" in str(arg) for arg in sys.argv) @@ -44,15 +45,27 @@ # -- General configuration ------------------------------------------------ # Determine version without reading pyproject.toml -# Always use setuptools_scm (assumed available in docs env) -__version__ = setuptools_scm.get_version(root="..") +# Multi-version builds run from git archives (no .git), so allow a fallback +# using the version name provided via environment variables. -project = "tvm-ffi" -author = "Apache TVM FFI contributors" +def _get_version() -> str: + if build_version: + return build_version + try: + return setuptools_scm.get_version(root="..", fallback_version="0.0.0") + except Exception: + return "0.0.0" + +__version__ = _get_version() + +project = "tvm-ffi" +author = "Apache TVM FFI contributors" version = __version__ release = __version__ +_github_ref = build_version or "main" +_base_url = ("/" + os.environ.get("BASE_URL", "").strip("/") + "/").replace("//", "/") # -- Extensions and extension configurations -------------------------------- @@ -189,6 +202,7 @@ def _build_rust_docs() -> None: if not build_rust_docs: return + (_DOCS_DIR / "reference" / "rust" / "generated").mkdir(parents=True, exist_ok=True) print("Building Rust documentation...") try: target_doc = _RUST_DIR / "target" / "doc" @@ -214,10 +228,14 @@ def _build_rust_docs() -> None: print("Warning: cargo not found, skipping Rust documentation build") -def _apply_config_overrides(_: object, config: object) -> None: +def _apply_config_overrides(app: sphinx.application.Sphinx, config: sphinx.config.Config) -> None: """Apply runtime configuration overrides derived from environment variables.""" config.build_exhale = build_exhale config.build_rust_docs = build_rust_docs + if build_exhale: + config.exhale_args["containmentFolder"] = str( + Path(app.srcdir) / "reference" / "cpp" / "generated" + ) def _copy_rust_docs_to_output(app: sphinx.application.Sphinx, exception: Exception | None) -> None: @@ -450,11 +468,22 @@ def footer_html() -> str: "show_toc_level": 2, "extra_footer": footer_html(), } +if build_version: # multi-version build, enable version switcher to navbar + html_theme_options.update( + { + "navbar_end": ["version-switcher", "navbar-icon-links"], + "switcher": { + "json_url": f"{_base_url}_static/versions.json", + "version_match": version, + }, + "show_version_warning_banner": True, + } + ) html_context = { "display_github": True, "github_user": "apache", - "github_version": "main", + "github_version": _github_ref, "conf_py_path": "/docs/", } diff --git a/docs/run_sphinx_multiversion.py b/docs/run_sphinx_multiversion.py new file mode 100644 index 00000000..6bff2d1b --- /dev/null +++ b/docs/run_sphinx_multiversion.py @@ -0,0 +1,225 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Build multiple versions of the docs into a single output directory. + +Versions are configured in `VERSIONS` (edit in-place). +""" + +from __future__ import annotations + +import argparse +import json +import logging +import os +import subprocess +import sys +import tarfile +import tempfile +from pathlib import Path + +VERSIONS: tuple[str, ...] = ("v0.1.6-rc0", "v0.1.5", "main") + +ENV_DOCS_VERSION = "TVM_FFI_DOCS_VERSION" +ENV_BASE_URL = "BASE_URL" +ENV_PRETEND_VERSION = "SETUPTOOLS_SCM_PRETEND_VERSION" + +VERSIONS_JSON_NAME = "versions.json" + +_STUB_FILES: dict[Path, Path] = { + Path("_stubs/cpp_index.rst"): Path("reference/cpp/generated/index.rst"), +} + +logger = logging.getLogger(__name__) + + +def _git(*args: str, cwd: Path) -> str: + return subprocess.check_output(("git", *args), cwd=cwd).decode().strip() + + +def _git_toplevel() -> Path: + start = Path(__file__).resolve().parent + return Path(_git("rev-parse", "--show-toplevel", cwd=start)).resolve() + + +def _normalize_base_url(raw: str) -> str: + value = raw.strip() + if not value: + return "/" + if not value.startswith("/"): + value = f"/{value}" + if value != "/": + value = value.rstrip("/") + return value + + +def _preferred_version(versions: tuple[str, ...], *, latest: str) -> str: + for v in versions: + if v != latest: + return v + return latest + + +def _write_versions_json(*, output_root: Path, base_url: str, latest_version: str) -> str: + base = base_url.rstrip("/") + preferred = _preferred_version(VERSIONS, latest=latest_version) + + versions_json: list[dict[str, object]] = [] + for name in VERSIONS: + entry: dict[str, object] = { + "name": name, + "version": name, + "url": f"{base}/{name}/" if base else f"/{name}/", + } + if name == preferred: + entry["preferred"] = True + versions_json.append(entry) + + out_path = output_root / "_static" / VERSIONS_JSON_NAME + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(f"{json.dumps(versions_json, indent=2)}\n", encoding="utf-8") + return preferred + + +def _write_root_index(*, output_root: Path, base_url: str, preferred: str) -> None: + base = base_url.rstrip("/") or "/" + target = f"{base}/{preferred}/" if base != "/" else f"/{preferred}/" + (output_root / "index.html").write_text( + "\n".join( + [ + "", + '', + "tvm-ffi docs", + f'', + "", + f'

Redirecting to {target}.

', + ] + ), + encoding="utf-8", + ) + + +def _resolve_commit(gitroot: Path, ref: str) -> str: + try: + return _git("rev-parse", f"{ref}^{{commit}}", cwd=gitroot) + except subprocess.CalledProcessError: + if ref == "main": + return _git("rev-parse", "HEAD", cwd=gitroot) + raise + + +def _archive_extract(gitroot: Path, *, commit: str, dst: Path) -> None: + dst.mkdir(parents=True, exist_ok=True) + with tempfile.SpooledTemporaryFile() as fp: + subprocess.check_call(("git", "archive", "--format", "tar", commit), cwd=gitroot, stdout=fp) + fp.seek(0) + with tarfile.open(fileobj=fp) as tar_fp: + try: + tar_fp.extractall(dst, filter="fully_trusted") + except TypeError: + tar_fp.extractall(dst) + + +def _ensure_stub_files(docs_dir: Path) -> None: + for src_rel, dst_rel in _STUB_FILES.items(): + dst = docs_dir / dst_rel + if dst.exists(): + continue + src = docs_dir / src_rel + if not src.exists(): + raise FileNotFoundError(f"Missing stub source: {src}") + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") + + +def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: + parser = argparse.ArgumentParser() + parser.add_argument( + "--outputdir", + default="docs/_build/html", + help="Output root directory (default: docs/_build/html)", + ) + parser.add_argument( + "--base-url", + default="/", + help="Base URL prefix for generated links, e.g. '/' or '/ffi' (default: '/')", + ) + return parser.parse_known_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Entrypoint.""" + if argv is None: + argv = sys.argv[1:] + + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + args, sphinx_argv = _parse_args(argv) + + gitroot = _git_toplevel() + docs_confdir = Path(__file__).resolve().parent + base_url = _normalize_base_url(args.base_url) + + output_root = Path(args.outputdir) + if not output_root.is_absolute(): + output_root = (gitroot / output_root).resolve() + output_root.mkdir(parents=True, exist_ok=True) + + preferred = _write_versions_json( + output_root=output_root, base_url=base_url, latest_version="main" + ) + _write_root_index(output_root=output_root, base_url=base_url, preferred=preferred) + + with tempfile.TemporaryDirectory() as tmp: + tmp_root = Path(tmp) + for ref in VERSIONS: + commit = _resolve_commit(gitroot, ref) + repopath = tmp_root / f"{ref}-{commit}" + _archive_extract(gitroot, commit=commit, dst=repopath) + + version_docs = repopath / "docs" + if not version_docs.exists(): + raise FileNotFoundError(f"Missing docs/ for {ref} ({commit})") + _ensure_stub_files(version_docs) + + version_out = output_root / ref + version_out.mkdir(parents=True, exist_ok=True) + + env = os.environ.copy() + env.setdefault(ENV_PRETEND_VERSION, "0.0.0") + env[ENV_BASE_URL] = base_url + env[ENV_DOCS_VERSION] = ref + + cmd = ( + sys.executable, + "-m", + "sphinx", + *sphinx_argv, + "-c", + str(docs_confdir), + str(version_docs), + str(version_out), + ) + logger.info("Building %s -> %s", ref, version_out) + subprocess.check_call(cmd, cwd=repopath, env=env) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())