From 8b5ad75e80ae82c2f491b29600c0b3435d5e1109 Mon Sep 17 00:00:00 2001 From: Rand McKinney Date: Wed, 14 Oct 2026 18:01:36 -0700 Subject: [PATCH] Add API doc versioning --- .github/workflows/publish-docs.yml | 16 +- .gitignore | 3 +- Makefile | 3 + README.md | 4 + api-docs/_static/version-selector.js | 8 + api-docs/_templates/version-selector.html | 14 ++ api-docs/conf.py | 39 +++- api-docs/versions.json | 6 + docs/project-contributions.md | 42 ++++- scripts/generate_versioned_api_docs.py | 150 ++++++++++++++++ tests/test_versioned_docs.py | 209 ++++++++++++++++++++++ 11 files changed, 487 insertions(+), 7 deletions(-) create mode 100644 api-docs/_static/version-selector.js create mode 100644 api-docs/_templates/version-selector.html create mode 100644 api-docs/versions.json create mode 100644 scripts/generate_versioned_api_docs.py create mode 100644 tests/test_versioned_docs.py diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index d3c3b427..523f13ac 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -6,6 +6,15 @@ on: workflow_dispatch: + push: + branches: [main] + paths: + - 'api-docs/**' + - 'scripts/generate_api_docs.py' + - 'scripts/generate_versioned_api_docs.py' + - 'requirements-dev.txt' + - '.github/workflows/publish-docs.yml' + permissions: contents: read pages: write @@ -26,6 +35,7 @@ jobs: with: filter: tree:0 fetch-depth: 0 + ref: main - name: Setup Pages uses: actions/configure-pages@v5 @@ -40,12 +50,14 @@ jobs: run: python3 -m pip install -r requirements-dev.txt - name: Build docs - run: python3 scripts/generate_api_docs.py + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 scripts/generate_versioned_api_docs.py - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: 'api-docs/_build/html/' + path: 'api-docs/_build/versioned/' - name: Deploy to GitHub Pages id: deployment diff --git a/.gitignore b/.gitignore index 4c4140d5..9fa3c1eb 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,8 @@ wheels/ # Sphinx docs api-docs/api api-docs/_build -api-docs/_static +api-docs/_static/* +!api-docs/_static/version-selector.js api-docs/_preprocessed # PyInstaller diff --git a/Makefile b/Makefile index e53e1f2e..d4e8ae5c 100644 --- a/Makefile +++ b/Makefile @@ -137,6 +137,9 @@ build-from-source: docs: $(PYTHON) scripts/generate_api_docs.py +docs-versioned: + $(PYTHON) scripts/generate_versioned_api_docs.py + # Memory profiling with memray (runs in Docker, reports go to tests/perf/reports/) # More details for usage are in tests/perf/README.md PERF_ENV ?= python-3.12-slim diff --git a/README.md b/README.md index 043cac50..52c01bf9 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,10 @@ See the [`examples` directory](https://github.com/contentauth/c2pa-python/tree/m Documentation is published at [github.io/c2pa-python/api/c2pa](https://contentauth.github.io/c2pa-python/api/c2pa/index.html). +Use the documentation version dropdown to choose a published release. The versions +are selected in [api-docs/versions.json](api-docs/versions.json); the first entry +is the default at the existing documentation URL. + To build documentation locally, refer to [this section in Contributing to the project](https://github.com/contentauth/c2pa-python/blob/main/docs/project-contributions.md#api-reference-documentation). ## Contributing diff --git a/api-docs/_static/version-selector.js b/api-docs/_static/version-selector.js new file mode 100644 index 00000000..a4f031e0 --- /dev/null +++ b/api-docs/_static/version-selector.js @@ -0,0 +1,8 @@ +document.addEventListener("DOMContentLoaded", () => { + const selector = document.getElementById("docs-version"); + if (selector) { + selector.addEventListener("change", () => { + window.location.assign(selector.value); + }); + } +}); diff --git a/api-docs/_templates/version-selector.html b/api-docs/_templates/version-selector.html new file mode 100644 index 00000000..95d438cb --- /dev/null +++ b/api-docs/_templates/version-selector.html @@ -0,0 +1,14 @@ + diff --git a/api-docs/conf.py b/api-docs/conf.py index 9bb4b30a..d66a8d12 100644 --- a/api-docs/conf.py +++ b/api-docs/conf.py @@ -1,4 +1,6 @@ import os +import json +import posixpath from pathlib import Path # -- Project information ----------------------------------------------------- @@ -54,6 +56,24 @@ html_theme = "furo" html_static_path = ["_static"] +docs_versions = json.loads(os.environ.get("C2PA_DOCS_VERSIONS", "[]")) +docs_tag = os.environ.get("C2PA_DOCS_TAG", "") +docs_prefix = os.environ.get("C2PA_DOCS_PREFIX", "") +if docs_versions: + release = docs_tag + version = docs_tag + html_sidebars = { + "**": [ + "sidebar/brand.html", + "version-selector.html", + "sidebar/search.html", + "sidebar/scroll-start.html", + "sidebar/navigation.html", + "sidebar/scroll-end.html", + "sidebar/variant-selector.html", + ] + } + # Avoid executing package imports during docs build autodoc_typehints = "description" @@ -80,8 +100,23 @@ def _add_html_meta_tags(app, pagename, templatename, context, doctree): context["metatags"] = context.get("metatags", "") + rendered +def _add_version_selector(app, pagename, templatename, context, doctree): + current_directory = posixpath.dirname( + posixpath.join(docs_prefix, pagename + ".html") + ) or "." + context["docs_tag"] = docs_tag + context["docs_versions"] = [ + { + "tag": tag, + "url": posixpath.relpath(f"versions/{tag}/index.html", current_directory), + } + for tag in docs_versions + ] + + def setup(app): app.connect("html-page-context", _add_html_meta_tags) + if docs_versions: + app.connect("html-page-context", _add_version_selector) + app.add_js_file("version-selector.js") return {"parallel_read_safe": True, "parallel_write_safe": True} - - diff --git a/api-docs/versions.json b/api-docs/versions.json new file mode 100644 index 00000000..aab48ff7 --- /dev/null +++ b/api-docs/versions.json @@ -0,0 +1,6 @@ +{ + "versions": [ + "v0.38.0", + "v0.37.0" + ] +} diff --git a/docs/project-contributions.md b/docs/project-contributions.md index f2f4b3d7..315873ba 100644 --- a/docs/project-contributions.md +++ b/docs/project-contributions.md @@ -141,14 +141,52 @@ cd c2pa-python python3 -m pip install -r requirements-dev.txt ``` -Build docs by entering `make -C docs` or: +Build docs for your working tree by entering `make docs` or: ``` python3 scripts/generate_api_docs.py ``` -View the output by loading `api-docs/build/html/index.html` in a web browser. +View the output by loading `api-docs/_build/html/index.html` in a web browser. This uses `sphinx-autoapi` to parse `src/c2pa` directly, avoiding imports of native libs. - Entry script: `scripts/generate_api_docs.py` - Config: `api-docs/conf.py`; index: `api-docs/index.rst` + +### Versioned release documentation + +Edit [api-docs/versions.json](../api-docs/versions.json) to choose the exact +GitHub release tags shown in the sidebar dropdown. Entries must be unique +tags such as `v0.38.0`. The first entry is the default served at the existing +documentation URL; new releases are not automatically added to the list. + +Fetch the release tags and build the complete site: + +```sh +git fetch origin --tags +make docs-versioned +``` + +The builder verifies that every tag has a published release in +`contentauth/c2pa-python` and fails if a release or local tag is missing. +It needs network access to the GitHub API; optionally set `GITHUB_TOKEN` +to avoid unauthenticated rate limits. + +Each version uses the Python sources from its exact tag with the current +documentation configuration, templates, and build dependencies. It parses +sources without importing the package or executing scripts from old releases. +Builds run in temporary directories and leave the working tree's generated +API pages untouched. + +The complete site is written to `api-docs/_build/versioned/`: + +- The default version is also served at the root, preserving existing API URLs. +- Each selected release has a stable URL under `versions//`. +- Switching versions opens that release's documentation home page. +- A regular `make docs` build remains an unversioned working-tree preview. + +The publishing workflow always checks out `main` for the release list and +build tooling, including when triggered by a release. It rebuilds all selected +versions when a release is published, when relevant documentation files change +on `main`, or when manually dispatched. Publish changes to the list on `main` +to add, remove, or reorder dropdown entries. diff --git a/scripts/generate_versioned_api_docs.py b/scripts/generate_versioned_api_docs.py new file mode 100644 index 00000000..db2c4118 --- /dev/null +++ b/scripts/generate_versioned_api_docs.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Build a GitHub Pages site from the explicitly selected GitHub releases.""" + +import io +import json +import os +from pathlib import Path, PurePosixPath +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import zipfile + +import requests + + +ROOT = Path(__file__).resolve().parents[1] +RELEASES_URL = "https://api.github.com/repos/contentauth/c2pa-python/releases/tags" +TAG_PATTERN = re.compile(r"v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?") + + +def load_versions(path: Path) -> list[str]: + config = json.loads(path.read_text(encoding="utf-8")) + versions = config.get("versions") if isinstance(config, dict) else None + if not isinstance(versions, list) or not versions: + raise ValueError("Documentation versions must be a non-empty list.") + for tag in versions: + if not isinstance(tag, str) or not TAG_PATTERN.fullmatch(tag): + raise ValueError("Documentation versions must be release tags such as v0.38.0.") + if len(set(versions)) != len(versions): + raise ValueError("Documentation versions must not contain duplicate tags.") + return versions + + +def verify_release(tag: str) -> None: + headers = {"Accept": "application/vnd.github+json"} + token = os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + response = requests.get( + f"{RELEASES_URL}/{tag}", headers=headers, timeout=30, allow_redirects=False + ) + if response.status_code != 200: + raise RuntimeError( + f"Cannot verify GitHub release {tag}: HTTP {response.status_code}." + ) + release = response.json() + if ( + not isinstance(release, dict) + or release.get("tag_name") != tag + or release.get("draft") is not False + ): + raise ValueError(f"{tag} is not a published GitHub release.") + + +def export_sources(root: Path, tag: str, destination: Path) -> None: + # Resolve the exact tag, not a similarly named branch, and never run tagged code. + commit = subprocess.run( + ["git", "rev-parse", "--verify", f"refs/tags/{tag}^{{commit}}"], + cwd=root, check=True, capture_output=True, text=True, + ).stdout.strip() + archive = subprocess.run( + ["git", "archive", "--format=zip", commit, "src/c2pa"], + cwd=root, check=True, capture_output=True, + ).stdout + with zipfile.ZipFile(io.BytesIO(archive)) as sources: + count = 0 + for entry in sources.infolist(): + path = PurePosixPath(entry.filename) + if ( + path.is_absolute() + or ".." in path.parts + or any("\\" in part or ":" in part for part in path.parts) + or ( + path.parts[:2] != ("src", "c2pa") + and not (entry.is_dir() and path.parts == ("src",)) + ) + or stat.S_ISLNK(entry.external_attr >> 16) + ): + raise ValueError(f"Unsafe source archive in release {tag}.") + if entry.is_dir() or path.suffix != ".py": + continue + target = destination.joinpath(*path.parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(sources.read(entry)) + count += 1 + if not count: + raise ValueError(f"Release {tag} contains no Python API sources.") + + +def build_site() -> Path: + docs = ROOT / "api-docs" + versions = load_versions(docs / "versions.json") + for tag in versions: + verify_release(tag) + + build = docs / "_build" + build.mkdir(exist_ok=True) + output = build / "versioned" + with tempfile.TemporaryDirectory(prefix="versioned-docs-", dir=build) as temporary: + workspace = Path(temporary) + site = workspace / "site" + for tag in versions: + stage = workspace / tag + stage.mkdir() + shutil.copytree( + docs, stage / "api-docs", + ignore=shutil.ignore_patterns( + "_build", "_preprocessed", "api", "__pycache__", ".DS_Store" + ), + ) + scripts = stage / "scripts" + scripts.mkdir() + shutil.copy2(ROOT / "scripts" / "generate_api_docs.py", scripts) + export_sources(ROOT, tag, stage) + environment = os.environ.copy() + environment.update( + C2PA_DOCS_TAG=tag, + C2PA_DOCS_VERSIONS=json.dumps(versions), + C2PA_DOCS_PREFIX=f"versions/{tag}", + ) + subprocess.run( + [sys.executable, str(scripts / "generate_api_docs.py")], + cwd=stage, env=environment, check=True, + ) + html = stage / "api-docs" / "_build" / "html" + shutil.copytree(html, site / "versions" / tag) + + # Rebuild the default at the root so all switcher links remain relative. + default = workspace / versions[0] + environment.update(C2PA_DOCS_TAG=versions[0], C2PA_DOCS_PREFIX="") + shutil.rmtree(default / "api-docs" / "_build" / "html") + subprocess.run( + [sys.executable, str(default / "scripts" / "generate_api_docs.py")], + cwd=default, env=environment, check=True, + ) + shutil.copytree( + default / "api-docs" / "_build" / "html", site, dirs_exist_ok=True + ) + (site / ".nojekyll").touch() + if output.exists(): + shutil.rmtree(output) + shutil.move(str(site), str(output)) + return output + + +if __name__ == "__main__": + print(f"Versioned API docs generated at: {build_site()}") diff --git a/tests/test_versioned_docs.py b/tests/test_versioned_docs.py new file mode 100644 index 00000000..abff04af --- /dev/null +++ b/tests/test_versioned_docs.py @@ -0,0 +1,209 @@ +import io +import json +import os +from pathlib import Path +import posixpath +import runpy +import stat +import subprocess +import tempfile +import unittest +from unittest.mock import Mock, patch +import zipfile + +from scripts import generate_versioned_api_docs as docs + + +class VersionedDocsTests(unittest.TestCase): + def test_version_list_preserves_default_and_order(self): + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / "versions.json" + config.write_text('{"versions": ["v0.38.0", "v0.37.0"]}') + self.assertEqual(docs.load_versions(config), ["v0.38.0", "v0.37.0"]) + + def test_invalid_versions_are_rejected(self): + invalid = [ + [], ["v0.38.0", "v0.38.0"], ["../../main"], ["--help"], + ["main"], [38], [None], ["v0.38.0\n"], + ] + with tempfile.TemporaryDirectory() as directory: + config = Path(directory) / "versions.json" + for versions in invalid: + with self.subTest(versions=versions): + config.write_text(json.dumps({"versions": versions})) + with self.assertRaises(ValueError): + docs.load_versions(config) + for value in [[], {}, {"versions": "v0.38.0"}]: + config.write_text(json.dumps(value)) + with self.assertRaises(ValueError): + docs.load_versions(config) + + @patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"}) + @patch.object(docs.requests, "get") + def test_published_release_is_verified(self, get): + get.return_value = Mock( + status_code=200, + json=Mock(return_value={"tag_name": "v0.38.0", "draft": False}), + ) + docs.verify_release("v0.38.0") + get.assert_called_once_with( + f"{docs.RELEASES_URL}/v0.38.0", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": "Bearer test-token", + }, + timeout=30, allow_redirects=False, + ) + + @patch.object(docs.requests, "get") + def test_unpublished_or_mismatched_release_fails(self, get): + for release in [ + {"tag_name": "v0.38.0", "draft": True}, + {"tag_name": "v0.37.0", "draft": False}, + {"tag_name": "v0.38.0"}, [], + ]: + with self.subTest(release=release): + get.return_value = Mock(status_code=200, json=Mock(return_value=release)) + with self.assertRaises(ValueError): + docs.verify_release("v0.38.0") + get.return_value = Mock(status_code=404) + with self.assertRaisesRegex(RuntimeError, "HTTP 404"): + docs.verify_release("v0.38.0") + + def make_archive(self, filename, mode=stat.S_IFREG): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr("src/", b"") + archive.writestr("src/c2pa/", b"") + entry = zipfile.ZipInfo(filename) + entry.external_attr = mode << 16 + archive.writestr(entry, b"TAGGED_SOURCE = True\n") + return buffer.getvalue() + + @patch.object(docs.subprocess, "run") + def test_tagged_sources_are_exported_without_running_release_code(self, run): + run.side_effect = [ + subprocess.CompletedProcess([], 0, stdout="a" * 40 + "\n"), + subprocess.CompletedProcess([], 0, stdout=self.make_archive("src/c2pa/api.py")), + ] + with tempfile.TemporaryDirectory() as directory: + destination = Path(directory) + docs.export_sources(docs.ROOT, "v0.37.0", destination) + self.assertEqual( + (destination / "src/c2pa/api.py").read_text(), "TAGGED_SOURCE = True\n" + ) + self.assertEqual( + run.call_args_list[0].args[0], + ["git", "rev-parse", "--verify", "refs/tags/v0.37.0^{commit}"], + ) + self.assertEqual(run.call_args_list[1].args[0][0:3], ["git", "archive", "--format=zip"]) + + @patch.object(docs.subprocess, "run") + def test_unsafe_or_empty_source_archives_fail(self, run): + cases = [ + ("src/c2pa/../../escape.py", stat.S_IFREG), + ("/src/c2pa/api.py", stat.S_IFREG), + ("other/api.py", stat.S_IFREG), + ("src/c2pa/..\\escape.py", stat.S_IFREG), + ("src/c2pa/C:/escape.py", stat.S_IFREG), + ("src/c2pa/api.py", stat.S_IFLNK), + ("src/c2pa/README.md", stat.S_IFREG), + ] + with tempfile.TemporaryDirectory() as directory: + for filename, mode in cases: + with self.subTest(filename=filename, mode=mode): + run.side_effect = [ + subprocess.CompletedProcess([], 0, stdout="a" * 40), + subprocess.CompletedProcess( + [], 0, stdout=self.make_archive(filename, mode) + ), + ] + with self.assertRaises(ValueError): + docs.export_sources(docs.ROOT, "v0.38.0", Path(directory)) + + def test_switcher_links_resolve_from_root_and_nested_version_pages(self): + versions = ["v0.38.0", "v0.37.0"] + for prefix in ["", "versions/v0.38.0", "versions/v0.37.0"]: + with self.subTest(prefix=prefix), patch.dict( + os.environ, + { + "C2PA_DOCS_TAG": "v0.38.0", + "C2PA_DOCS_VERSIONS": json.dumps(versions), + "C2PA_DOCS_PREFIX": prefix, + }, + ): + config = runpy.run_path(str(docs.ROOT / "api-docs/conf.py")) + for page in ["index", "api/c2pa/index", "api/c2pa/c2pa/index"]: + context = {} + config["_add_version_selector"](None, page, None, context, None) + parent = posixpath.dirname(posixpath.join(prefix, page + ".html")) + for item in context["docs_versions"]: + self.assertEqual( + posixpath.normpath(posixpath.join(parent, item["url"])), + f"versions/{item['tag']}/index.html", + ) + + @patch.dict(os.environ, {}, clear=True) + def test_working_tree_build_has_no_version_switcher(self): + config = runpy.run_path(str(docs.ROOT / "api-docs/conf.py")) + app = Mock() + config["setup"](app) + self.assertEqual(app.connect.call_count, 1) + app.add_js_file.assert_not_called() + + @patch.object(docs, "verify_release") + @patch.object(docs, "export_sources") + @patch.object(docs.subprocess, "run") + def test_site_build_preserves_default_and_cleans_temporary_builds(self, run, export, verify): + def build(*args, cwd, env, **kwargs): + html = cwd / "api-docs/_build/html" + html.mkdir(parents=True) + (html / "index.html").write_text( + f"{env['C2PA_DOCS_TAG']}:{env['C2PA_DOCS_PREFIX']}" + ) + + run.side_effect = build + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "api-docs").mkdir() + (root / "api-docs/versions.json").write_text( + '{"versions": ["v0.38.0", "v0.37.0"]}' + ) + (root / "scripts").mkdir() + (root / "scripts/generate_api_docs.py").touch() + with patch.object(docs, "ROOT", root): + site = docs.build_site() + self.assertEqual((site / "index.html").read_text(), "v0.38.0:") + for tag in ["v0.38.0", "v0.37.0"]: + self.assertEqual( + (site / "versions" / tag / "index.html").read_text(), + f"{tag}:versions/{tag}", + ) + self.assertTrue((site / ".nojekyll").is_file()) + self.assertEqual(list((root / "api-docs/_build").iterdir()), [site]) + self.assertFalse((root / "api-docs/api").exists()) + self.assertEqual(verify.call_count, 2) + self.assertEqual(export.call_count, 2) + self.assertEqual(run.call_count, 3) + + @patch.object(docs, "verify_release") + @patch.object(docs, "export_sources") + @patch.object(docs.subprocess, "run", side_effect=subprocess.CalledProcessError(1, "sphinx")) + def test_failed_build_leaves_previous_site_intact(self, run, export, verify): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + site = root / "api-docs/_build/versioned" + site.mkdir(parents=True) + (site / "index.html").write_text("previous site") + (root / "api-docs/versions.json").write_text('{"versions": ["v0.38.0"]}') + (root / "scripts").mkdir() + (root / "scripts/generate_api_docs.py").touch() + with patch.object(docs, "ROOT", root): + with self.assertRaises(subprocess.CalledProcessError): + docs.build_site() + self.assertEqual((site / "index.html").read_text(), "previous site") + self.assertEqual(list((root / "api-docs/_build").iterdir()), [site]) + + +if __name__ == "__main__": + unittest.main()