Source code for httk.core.docs.sphinx_ext

#
#    The high-throughput toolkit (httk)
#    Copyright (C) 2012-2024 the httk AUTHORS
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU Affero General Public License as
#    published by the Free Software Foundation, either version 3 of the
#    License, or (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU Affero General Public License for more details.
#
#    You should have received a copy of the GNU Affero General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.

"""Sphinx glue for version labels, development warnings, and the selector UI.

The module itself has no Sphinx import dependency. Sphinx is imported only by
:func:`setup`, allowing the rest of the documentation library to remain
stdlib-only and usable by release workflows.
"""

import json
import os
import re
from collections.abc import Mapping
from pathlib import Path
from typing import Literal

from .config import VersioningConfig, load_versioning_config
from .inventories import InventoryError, fetch_inventory, read_inventory_header
from .lockfile import LockError, read_lock_pins
from .release import dependency_doc_targets
from .semver import parse_version

__all__ = [
    "channel_for_label",
    "derive_internal_intersphinx_mapping",
    "document_label",
    "selector_config_literal",
    "setup",
    "version_depth",
]


[docs] def document_label(value: str | None) -> str: """Return a validated documentation label, defaulting to ``dev:local``.""" label = value or "dev:local" if label == "dev:local" or label == "dev:main": return label if len(label) > 1 and label.startswith("v"): from .semver import parse_tag parse_tag(label) return label raise ValueError(f"invalid HTTK_DOCS_VERSION label: {label!r}")
[docs] def channel_for_label(label: str) -> Literal["release", "dev"]: """Return ``release`` for a version tag and ``dev`` for a development label.""" return "release" if label.startswith("v") else "dev"
[docs] def version_depth(label: str) -> int: """Return the URL path depth used by the selector for *label*.""" if label.startswith("v"): return 1 if label == "dev:main": return 2 return 0
[docs] def selector_config_literal(label: str) -> str: """Render the tiny JSON configuration literal consumed by ``selector.js``.""" normalized = document_label(label) value = { "version": normalized, "channel": channel_for_label(normalized), "versionPathDepth": version_depth(normalized), } return "window.HTTK_DOCS_VERSIONING = " + json.dumps(value, separators=(",", ":"), sort_keys=True) + ";"
[docs] def derive_internal_intersphinx_mapping( mapping: Mapping[str, tuple[str, str | list[str]]], config: VersioningConfig, pins: Mapping[str, str], base_url: str, label: str, *, temporary_inventory_dir: str | Path | None = None, committed_inventory_dir: str | Path | None = None, ) -> dict[str, tuple[str, str | list[str]]]: """Return *mapping* with declared internal dependencies versioned. The function is deliberately side-effect free. The Sphinx callback fetches the development inventories separately, then supplies their paths here. ``dev:local`` is handled by the callback before this function is called so that the configuration object remains byte-for-byte untouched for local builds. """ normalized = document_label(label) if normalized == "dev:local": return dict(mapping) channel = channel_for_label(normalized) result = dict(mapping) if channel == "release": targets = dependency_doc_targets(config, pins, base_url, "release") inventory_dir = Path(committed_inventory_dir or "docs/_inventories").resolve() for dependency in config.internal_dependencies: result[dependency.slug] = ( targets[dependency.distribution], str(inventory_dir / f"{dependency.slug}.inv"), ) return result if temporary_inventory_dir is None: raise ValueError("temporary_inventory_dir is required for dev:main intersphinx mappings") targets = dependency_doc_targets(config, pins, base_url, "dev") temporary = Path(temporary_inventory_dir) for dependency in config.internal_dependencies: result[dependency.slug] = ( targets[dependency.distribution], str(temporary / f"{dependency.slug}.inv"), ) return result
def _project_versioning_paths(app: object) -> tuple[Path, Path, Path]: """Return repository root, docs directory, and versioning file. ``confdir`` is authoritative here: Sphinx permits the source directory to be separate from the directory containing ``conf.py`` (including ``-c`` and ``docs/source`` layouts). """ confdir = Path(app.confdir).resolve() # type: ignore[attr-defined] candidates = ( confdir / "versioning.toml", confdir.parent / "versioning.toml", confdir.parent / "docs" / "versioning.toml", ) versioning_path = next((path for path in candidates if path.is_file()), candidates[0]) docs_dir = versioning_path.parent return docs_dir.parent, docs_dir, versioning_path def _normalized_distribution(name: str) -> str: return re.sub(r"[-_.]+", "-", name).lower() def _validate_release_inventories(config: VersioningConfig, pins: Mapping[str, str], inventory_dir: Path) -> None: """Validate every committed internal inventory for the locked release.""" dependency_doc_targets(config, pins, "https://docs.httk.org", "release") for dependency in config.internal_dependencies: key = _normalized_distribution(dependency.distribution) expected_version = str(parse_version(pins[key])) path = inventory_dir / f"{dependency.slug}.inv" if not path.is_file(): raise InventoryError( f"committed inventory {path} is missing; expected project {dependency.slug!r} " f"and version {expected_version!r}" ) try: found_project, found_version = read_inventory_header(path) except InventoryError as exc: raise InventoryError(f"committed inventory {path}: {exc}") from exc if found_project != dependency.slug or found_version != expected_version: raise InventoryError( f"committed inventory {path}: expected project {dependency.slug!r} and version " f"{expected_version!r}, found project {found_project!r} and version {found_version!r}" ) def _rewrite_internal_mappings(app: object, _config: object) -> None: """Apply channel-aware internal dependency mappings before intersphinx loads.""" root, docs_dir, versioning_path = _project_versioning_paths(app) if not versioning_path.is_file(): return config = load_versioning_config(versioning_path) if not config.internal_dependencies: return label = document_label(os.environ.get("HTTK_DOCS_VERSION")) if label == "dev:local": return pins: dict[str, str] = {} if channel_for_label(label) == "release": lock_path = root / "docs" / "requirements.lock" if not lock_path.is_file(): raise LockError(f"documentation lock is missing: {lock_path}; required for release intersphinx mappings") try: pins = read_lock_pins(lock_path) except OSError as exc: raise LockError(f"cannot read documentation lock {lock_path}: {exc}") from exc _validate_release_inventories(config, pins, docs_dir / "_inventories") temporary_directory = None if label == "dev:main": temporary_directory = Path(app.doctreedir) / "__httk_internal_inventories" # type: ignore[attr-defined] temporary_directory.mkdir(parents=True, exist_ok=True) base_url = os.environ.get("HTTK_DOCS_BASE_URL", "https://docs.httk.org") for dependency in config.internal_dependencies: destination = temporary_directory / f"{dependency.slug}.inv" inventory_url = f"{base_url.rstrip('/')}/{dependency.slug}/dev/main/objects.inv" try: fetch_inventory( inventory_url, destination, expected_project=dependency.slug, expected_version="dev:main", ) except (InventoryError, OSError) as exc: raise RuntimeError( f"failed to fetch dev:main inventory for internal dependency {dependency.slug!r} " f"from {inventory_url}: {exc}" ) from exc app.config.intersphinx_mapping = derive_internal_intersphinx_mapping( # type: ignore[attr-defined] app.config.intersphinx_mapping, # type: ignore[attr-defined] config, pins, os.environ.get("HTTK_DOCS_BASE_URL", "https://docs.httk.org"), label, temporary_inventory_dir=temporary_directory, committed_inventory_dir=docs_dir / "_inventories", )
[docs] def setup(app: object) -> dict[str, object]: """Register the extension with Sphinx and inject version-selector assets.""" # This is the sanctioned optional edge: importing this module remains # possible in the stdlib-only lock and release tooling. from sphinx.application import ( # pyright: ignore[reportMissingImports] Sphinx, ) del Sphinx # sphinx.ext.intersphinx validates its mapping at priority 800 and loads # inventories at builder-inited. Rewrite at 700 so both phases see the # channel-aware values, including fetched dev:main inventories. app.connect("config-inited", _rewrite_internal_mappings, priority=700) # type: ignore[attr-defined] label = document_label(os.environ.get("HTTK_DOCS_VERSION")) channel = channel_for_label(label) if channel == "release": version = label[1:] release = version else: version = label release = label app.config.version = version # type: ignore[attr-defined] app.config.release = release # type: ignore[attr-defined] if channel == "dev" and app.config.html_theme == "furo": # type: ignore[attr-defined] options = app.config.html_theme_options # type: ignore[attr-defined] if options is None: options = {} app.config.html_theme_options = options # type: ignore[attr-defined] if "announcement" not in options: options["announcement"] = f"Development documentation ({label}) — content may differ from any release." static_path = Path(__file__).resolve().parent / "assets" configured = app.config.html_static_path # type: ignore[attr-defined] if configured is None: configured = [] app.config.html_static_path = configured # type: ignore[attr-defined] if str(static_path) not in configured: configured.append(str(static_path)) app.add_js_file("selector.js") # type: ignore[attr-defined] app.add_css_file("selector.css") # type: ignore[attr-defined] app.add_js_file(None, body=selector_config_literal(label)) # type: ignore[attr-defined] return {"version": "1", "parallel_read_safe": True, "parallel_write_safe": True}