Skip to content
Draft
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
16 changes: 14 additions & 2 deletions .github/workflows/publish-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +35,7 @@ jobs:
with:
filter: tree:0
fetch-depth: 0
ref: main

- name: Setup Pages
uses: actions/configure-pages@v5
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions api-docs/_static/version-selector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
document.addEventListener("DOMContentLoaded", () => {
const selector = document.getElementById("docs-version");
if (selector) {
selector.addEventListener("change", () => {
window.location.assign(selector.value);
});
}
});
14 changes: 14 additions & 0 deletions api-docs/_templates/version-selector.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<div class="sidebar-tree">
<select id="docs-version" aria-label="Documentation version">
{% for item in docs_versions %}
<option value="{{ item.url | e }}"{% if item.tag == docs_tag %} selected{% endif %}>{{ item.tag | e }}</option>
{% endfor %}
</select>
<noscript>
<ul>
{% for item in docs_versions %}
<li><a href="{{ item.url | e }}">{{ item.tag | e }}</a></li>
{% endfor %}
</ul>
</noscript>
</div>
39 changes: 37 additions & 2 deletions api-docs/conf.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import os
import json
import posixpath
from pathlib import Path

# -- Project information -----------------------------------------------------
Expand Down Expand Up @@ -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"

Expand All @@ -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}


6 changes: 6 additions & 0 deletions api-docs/versions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"versions": [
"v0.38.0",
"v0.37.0"
]
}
42 changes: 40 additions & 2 deletions docs/project-contributions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<tag>/`.
- 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.
150 changes: 150 additions & 0 deletions scripts/generate_versioned_api_docs.py
Original file line number Diff line number Diff line change
@@ -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()}")
Loading
Loading