diff --git a/doc/conf.py b/doc/conf.py index d7ad2c5673..876e9b8ffe 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -27,8 +27,11 @@ ) sys.path.append(os.path.dirname(__file__)) +import github_linkcode import sphinx_contrib_exhale_multiproject # noqa: F401 +linkcode_resolve = github_linkcode.linkcode_resolve + # -- Project information ----------------------------------------------------- project = "DeePMD-kit" @@ -59,7 +62,7 @@ "myst_nb", "sphinx.ext.autosummary", "sphinx.ext.mathjax", - "sphinx.ext.viewcode", + "sphinx.ext.linkcode", "sphinx.ext.imgconverter", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", @@ -276,5 +279,13 @@ def _cap_cli_secnumbers(app: Sphinx, doctree: nodes.document, docname: str) -> N def setup(app: Sphinx) -> dict[str, bool]: + # AutoAPI records exact source locations without importing backend modules. + # Reuse that metadata for commit-pinned GitHub links after AutoAPI's default + # priority-500 ``builder-inited`` callback has populated the environment. + app.connect( + "builder-inited", + github_linkcode.collect_autoapi_source_locations, + priority=600, + ) app.connect("doctree-resolved", _cap_cli_secnumbers) return {"parallel_read_safe": True, "parallel_write_safe": True} diff --git a/doc/github_linkcode.py b/doc/github_linkcode.py new file mode 100644 index 0000000000..ba40c1fac0 --- /dev/null +++ b/doc/github_linkcode.py @@ -0,0 +1,236 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later +"""Create permanent GitHub source links from Sphinx AutoAPI metadata.""" + +from __future__ import ( + annotations, +) + +import logging +import os +import re +import subprocess +from functools import ( + cache, +) +from pathlib import ( + Path, +) +from typing import ( + TYPE_CHECKING, + Any, + NamedTuple, + Protocol, +) +from urllib.parse import ( + quote, +) + +if TYPE_CHECKING: + from collections.abc import ( + Mapping, + ) + + from sphinx.application import ( + Sphinx, + ) + + +GITHUB_REPOSITORY = "deepmodeling/deepmd-kit" +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +_FULL_GIT_HASH = re.compile(r"[0-9a-f]{40,64}") +_LOGGER = logging.getLogger(__name__) + + +class SourceLocation(NamedTuple): + """Location of a documented object within the source repository.""" + + path: str + first_line: int | None + last_line: int | None + + +class AutoapiObject(Protocol): + """Subset of an AutoAPI object needed for static source resolution.""" + + id: str + obj: dict[str, Any] + + +_source_locations: dict[str, SourceLocation] = {} +_unresolved_source_objects: set[str] = set() + + +@cache +def get_git_commit() -> str | None: + """Return the immutable commit represented by the documentation build.""" + commit = os.environ.get("READTHEDOCS_GIT_COMMIT_HASH") + if commit is None: + try: + commit = subprocess.check_output( + ["git", "rev-parse", "HEAD"], + cwd=REPOSITORY_ROOT, + stderr=subprocess.DEVNULL, + text=True, + ).strip() + except (OSError, subprocess.CalledProcessError): + _LOGGER.warning( + "Cannot determine a Git commit for permanent documentation links" + ) + return None + + commit = commit.strip().lower() + if not _FULL_GIT_HASH.fullmatch(commit): + _LOGGER.warning("Ignoring invalid documentation Git commit %r", commit) + return None + return commit + + +def _original_target_name( + object_name: str, + source_object: AutoapiObject, + all_objects: Mapping[str, AutoapiObject], +) -> str | None: + """Return the defining AutoAPI name for an object or inherited member.""" + original_path = source_object.obj.get("original_path") + if original_path: + return original_path + if source_object.obj.get("from_line_no") is not None: + return None + + # AutoAPI relocates children of a re-exported class without copying the + # class's original_path metadata onto those children. Walk upward to that + # class and append the stripped member suffix to its defining path. + ancestor_name = object_name + suffix: list[str] = [] + while "." in ancestor_name: + ancestor_name, _, member_name = ancestor_name.rpartition(".") + suffix.insert(0, member_name) + ancestor = all_objects.get(ancestor_name) + if ancestor is None: + continue + ancestor_original_path = ancestor.obj.get("original_path") + if ancestor_original_path: + return ".".join((ancestor_original_path, *suffix)) + return None + + +def _resolve_original_object( + documented_name: str, + documented_object: AutoapiObject, + all_objects: Mapping[str, AutoapiObject], +) -> AutoapiObject: + """Follow AutoAPI re-exports to the object that owns the source lines.""" + source_name = documented_name + source_object = documented_object + visited: set[str] = set() + while original_path := _original_target_name( + source_name, source_object, all_objects + ): + if original_path in visited: + break + visited.add(original_path) + original_object = all_objects.get(original_path) + if original_object is None: + break + source_name = original_path + source_object = original_object + return source_object + + +def _containing_module( + object_name: str, + modules: Mapping[str, AutoapiObject], +) -> AutoapiObject | None: + """Find the nearest AutoAPI module or package containing an object.""" + candidate = object_name + while candidate: + if candidate in modules: + return modules[candidate] + candidate = candidate.rpartition(".")[0] + return None + + +def collect_autoapi_source_locations(app: Sphinx) -> None: + """Cache source locations after AutoAPI finishes its static analysis. + + AutoAPI runs on ``builder-inited`` with the default priority of 500. This + callback is registered at priority 600 so its object graph is available + before Sphinx forks any parallel document-reading workers. + """ + _source_locations.clear() + _unresolved_source_objects.clear() + try: + # This is stable across sphinx-autoapi 3.x but remains internal state; + # fail visibly if a future dependency update changes the contract. + all_objects = app.env.autoapi_all_objects + except AttributeError as exc: + raise RuntimeError("AutoAPI source metadata is unavailable") from exc + if not all_objects: + raise RuntimeError("AutoAPI produced an empty object graph") + + modules = {name: obj for name, obj in all_objects.items() if "file_path" in obj.obj} + if not modules: + raise RuntimeError("AutoAPI object graph contains no module source paths") + + for documented_name, documented_object in all_objects.items(): + source_object = _resolve_original_object( + documented_name, documented_object, all_objects + ) + if ( + source_object.id not in modules + and source_object.obj.get("from_line_no") is None + ): + # A known object with no trustworthy source lines must not fall + # back to the importing package's __init__.py. + _unresolved_source_objects.add(documented_name) + continue + module = _containing_module(source_object.id, modules) + if module is None: + _unresolved_source_objects.add(documented_name) + continue + + source_path = Path(module.obj["file_path"]).resolve() + try: + relative_path = source_path.relative_to(REPOSITORY_ROOT).as_posix() + except ValueError: + # Never create a repository URL for objects imported from outside + # the checked-out DeePMD-kit source tree. + _unresolved_source_objects.add(documented_name) + continue + + _source_locations[documented_name] = SourceLocation( + relative_path, + source_object.obj.get("from_line_no"), + source_object.obj.get("to_line_no"), + ) + + +def linkcode_resolve(domain: str, info: dict[str, str]) -> str | None: + """Resolve a Python object to a commit-pinned GitHub source URL.""" + if domain != "py": + return None + + module = info.get("module", "") + fullname = info.get("fullname", "") + if not module: + return None + + object_name = ( + fullname if fullname.startswith(f"{module}.") else f"{module}.{fullname}" + ).rstrip(".") + if object_name in _unresolved_source_objects: + return None + location = _source_locations.get(object_name) or _source_locations.get(module) + commit = get_git_commit() + if location is None or commit is None: + return None + + url = ( + f"https://github.com/{GITHUB_REPOSITORY}/blob/{commit}/" + f"{quote(location.path, safe='/')}" + ) + if location.first_line is not None: + url += f"#L{location.first_line}" + if location.last_line not in (None, location.first_line): + url += f"-L{location.last_line}" + return url diff --git a/pyproject.toml b/pyproject.toml index 6216438ac4..176e2c7c21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,7 +118,7 @@ docs = [ "sphinx-argparse<0.5.0", "pygments-lammps", "sphinxcontrib-bibtex", - "sphinx-autoapi>=3.0.0", + "sphinx-autoapi>=3.0.0,<4", "sphinxcontrib-programoutput", "sphinxcontrib-moderncmakedomain", "sphinx-remove-toctrees", diff --git a/source/tests/test_doc_linkcode.py b/source/tests/test_doc_linkcode.py new file mode 100644 index 0000000000..1d4cc8ebad --- /dev/null +++ b/source/tests/test_doc_linkcode.py @@ -0,0 +1,310 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later +import subprocess +from pathlib import ( + Path, +) +from types import ( + SimpleNamespace, +) + +import pytest + +from doc import ( + github_linkcode, +) + +COMMIT = "1f94e04b7f596c309b7efab4e7630ed78e85a1f1" + + +class AutoapiObject: + """Minimal AutoAPI object used to exercise source-link resolution.""" + + def __init__(self, object_id: str, **metadata) -> None: + self.id = object_id + self.obj = metadata + + +@pytest.fixture(autouse=True) +def reset_linkcode_state(): + """Keep module caches isolated between source-link tests.""" + github_linkcode._source_locations.clear() + github_linkcode._unresolved_source_objects.clear() + github_linkcode.get_git_commit.cache_clear() + yield + github_linkcode._source_locations.clear() + github_linkcode._unresolved_source_objects.clear() + clear_cache = getattr(github_linkcode.get_git_commit, "cache_clear", None) + if clear_cache is not None: + clear_cache() + + +def test_linkcode_uses_original_object_and_rtd_commit( + monkeypatch, tmp_path: Path +) -> None: + repository_root = tmp_path.resolve() + module_path = repository_root / "deepmd" / "implementation.py" + module_path.parent.mkdir() + module_path.touch() + + objects = { + "deepmd.api": AutoapiObject( + "deepmd.api", file_path=str(repository_root / "deepmd" / "api.py") + ), + "deepmd.api.public_function": AutoapiObject( + "deepmd.api.public_function", + original_path="deepmd.implementation.public_function", + ), + "deepmd.implementation": AutoapiObject( + "deepmd.implementation", file_path=str(module_path) + ), + "deepmd.implementation.public_function": AutoapiObject( + "deepmd.implementation.public_function", + from_line_no=12, + to_line_no=24, + ), + } + app = SimpleNamespace( + env=SimpleNamespace(autoapi_all_objects=objects), + ) + monkeypatch.setattr(github_linkcode, "REPOSITORY_ROOT", repository_root) + monkeypatch.setenv("READTHEDOCS_GIT_COMMIT_HASH", COMMIT) + github_linkcode.collect_autoapi_source_locations(app) + + assert github_linkcode.linkcode_resolve( + "py", + {"module": "deepmd.api", "fullname": "public_function"}, + ) == ( + "https://github.com/deepmodeling/deepmd-kit/blob/" + f"{COMMIT}/deepmd/implementation.py#L12-L24" + ) + + +def test_linkcode_rejects_non_python_domains(monkeypatch) -> None: + monkeypatch.setattr( + github_linkcode, + "get_git_commit", + lambda: pytest.fail("domain rejection must not inspect Git state"), + ) + + assert ( + github_linkcode.linkcode_resolve( + "cpp", {"module": "deepmd.api", "fullname": "public_function"} + ) + is None + ) + + +def test_reexported_class_member_uses_the_defining_module( + monkeypatch, tmp_path: Path +) -> None: + """Members inherit a re-exported class's original source namespace.""" + repository_root = tmp_path.resolve() + public_module = repository_root / "deepmd" / "__init__.py" + implementation_module = repository_root / "deepmd" / "implementation.py" + public_module.parent.mkdir() + public_module.touch() + implementation_module.touch() + objects = { + "deepmd": AutoapiObject("deepmd", file_path=str(public_module)), + "deepmd.Widget": AutoapiObject( + "deepmd.Widget", + original_path="deepmd.implementation.Widget", + ), + "deepmd.Widget.spin": AutoapiObject("deepmd.Widget.spin"), + "deepmd.implementation": AutoapiObject( + "deepmd.implementation", + file_path=str(implementation_module), + ), + "deepmd.implementation.Widget": AutoapiObject( + "deepmd.implementation.Widget", + from_line_no=4, + to_line_no=17, + ), + "deepmd.implementation.Widget.spin": AutoapiObject( + "deepmd.implementation.Widget.spin", + from_line_no=8, + to_line_no=10, + ), + } + + monkeypatch.setattr(github_linkcode, "REPOSITORY_ROOT", repository_root) + monkeypatch.setenv("READTHEDOCS_GIT_COMMIT_HASH", COMMIT) + github_linkcode.collect_autoapi_source_locations( + SimpleNamespace(env=SimpleNamespace(autoapi_all_objects=objects)) + ) + + assert github_linkcode.linkcode_resolve( + "py", {"module": "deepmd", "fullname": "Widget.spin"} + ) == ( + "https://github.com/deepmodeling/deepmd-kit/blob/" + f"{COMMIT}/deepmd/implementation.py#L8-L10" + ) + + +def test_unresolved_member_does_not_fall_back_to_importing_module( + monkeypatch, tmp_path: Path +) -> None: + """Known line-less members produce no link instead of a wrong file link.""" + repository_root = tmp_path.resolve() + module_path = repository_root / "deepmd" / "__init__.py" + module_path.parent.mkdir() + module_path.touch() + objects = { + "deepmd": AutoapiObject("deepmd", file_path=str(module_path)), + "deepmd.Widget": AutoapiObject("deepmd.Widget", from_line_no=4, to_line_no=9), + "deepmd.Widget.ATTR": AutoapiObject("deepmd.Widget.ATTR"), + } + + monkeypatch.setattr(github_linkcode, "REPOSITORY_ROOT", repository_root) + monkeypatch.setenv("READTHEDOCS_GIT_COMMIT_HASH", COMMIT) + github_linkcode.collect_autoapi_source_locations( + SimpleNamespace(env=SimpleNamespace(autoapi_all_objects=objects)) + ) + + assert ( + github_linkcode.linkcode_resolve( + "py", {"module": "deepmd", "fullname": "Widget.ATTR"} + ) + is None + ) + + +def test_collect_rejects_missing_or_empty_autoapi_metadata() -> None: + """AutoAPI API drift must fail visibly instead of deleting all links.""" + with pytest.raises(RuntimeError, match="metadata is unavailable"): + github_linkcode.collect_autoapi_source_locations( + SimpleNamespace(env=SimpleNamespace()) + ) + with pytest.raises(RuntimeError, match="empty object graph"): + github_linkcode.collect_autoapi_source_locations( + SimpleNamespace(env=SimpleNamespace(autoapi_all_objects={})) + ) + with pytest.raises(RuntimeError, match="no module source paths"): + github_linkcode.collect_autoapi_source_locations( + SimpleNamespace( + env=SimpleNamespace( + autoapi_all_objects={"deepmd.value": AutoapiObject("deepmd.value")} + ) + ) + ) + + +def test_collect_rejects_sources_outside_the_repository( + monkeypatch, tmp_path: Path +) -> None: + """Imported external objects must never receive repository URLs.""" + repository_root = (tmp_path / "repository").resolve() + repository_root.mkdir() + external_module = (tmp_path / "external.py").resolve() + external_module.touch() + objects = { + "external": AutoapiObject("external", file_path=str(external_module)), + "external.value": AutoapiObject("external.value", from_line_no=1, to_line_no=1), + } + + monkeypatch.setattr(github_linkcode, "REPOSITORY_ROOT", repository_root) + monkeypatch.setenv("READTHEDOCS_GIT_COMMIT_HASH", COMMIT) + github_linkcode.collect_autoapi_source_locations( + SimpleNamespace(env=SimpleNamespace(autoapi_all_objects=objects)) + ) + + assert ( + github_linkcode.linkcode_resolve( + "py", {"module": "external", "fullname": "value"} + ) + is None + ) + + +def test_original_resolution_stops_on_cycles_and_dangling_paths() -> None: + """Malformed re-export metadata cannot loop or discard the last safe object.""" + cycle_a = AutoapiObject("pkg.a", original_path="pkg.b") + cycle_b = AutoapiObject("pkg.b", original_path="pkg.a") + dangling = AutoapiObject("pkg.dangling", original_path="pkg.missing") + objects = {"pkg.a": cycle_a, "pkg.b": cycle_b, "pkg.dangling": dangling} + + assert ( + github_linkcode._resolve_original_object("pkg.a", cycle_a, objects) is cycle_a + ) + assert ( + github_linkcode._resolve_original_object("pkg.dangling", dangling, objects) + is dangling + ) + + +def test_get_git_commit_falls_back_to_the_checkout(monkeypatch) -> None: + """Local documentation builds use git rev-parse when RTD metadata is absent.""" + monkeypatch.delenv("READTHEDOCS_GIT_COMMIT_HASH", raising=False) + monkeypatch.setattr( + github_linkcode.subprocess, + "check_output", + lambda *args, **kwargs: f"{COMMIT.upper()}\n", + ) + + assert github_linkcode.get_git_commit() == COMMIT + + +@pytest.mark.parametrize( + "error", + [OSError("git missing"), subprocess.CalledProcessError(1, ["git"])], +) +def test_get_git_commit_reports_checkout_failures(monkeypatch, caplog, error) -> None: + """A checkout without usable Git metadata emits an actionable warning.""" + monkeypatch.delenv("READTHEDOCS_GIT_COMMIT_HASH", raising=False) + + def fail(*args, **kwargs): + raise error + + monkeypatch.setattr(github_linkcode.subprocess, "check_output", fail) + + assert github_linkcode.get_git_commit() is None + assert "Cannot determine a Git commit" in caplog.text + + +def test_get_git_commit_rejects_malformed_hashes(monkeypatch, caplog) -> None: + """Only immutable full hashes are accepted in generated source URLs.""" + monkeypatch.setenv("READTHEDOCS_GIT_COMMIT_HASH", "main") + + assert github_linkcode.get_git_commit() is None + assert "Ignoring invalid documentation Git commit" in caplog.text + + +@pytest.mark.parametrize( + ("location", "expected_suffix"), + [ + ( + github_linkcode.SourceLocation("deepmd/a b.py", 5, None), + "deepmd/a%20b.py#L5", + ), + (github_linkcode.SourceLocation("deepmd/a.py", 5, 5), "deepmd/a.py#L5"), + (github_linkcode.SourceLocation("deepmd/a.py", 5, 9), "deepmd/a.py#L5-L9"), + ], +) +def test_linkcode_formats_line_ranges(monkeypatch, location, expected_suffix) -> None: + """Single-line, open-ended, and ranged locations form stable anchors.""" + monkeypatch.setenv("READTHEDOCS_GIT_COMMIT_HASH", COMMIT) + github_linkcode._source_locations["deepmd.api.value"] = location + + result = github_linkcode.linkcode_resolve( + "py", {"module": "deepmd.api", "fullname": "deepmd.api.value"} + ) + + assert result == ( + f"https://github.com/deepmodeling/deepmd-kit/blob/{COMMIT}/{expected_suffix}" + ) + + +def test_linkcode_uses_module_fallback_and_requires_a_module(monkeypatch) -> None: + """Unknown members fall back to their module, but empty module names do not.""" + monkeypatch.setenv("READTHEDOCS_GIT_COMMIT_HASH", COMMIT) + github_linkcode._source_locations["deepmd.api"] = github_linkcode.SourceLocation( + "deepmd/api.py", None, None + ) + + assert github_linkcode.linkcode_resolve( + "py", {"module": "deepmd.api", "fullname": "missing"} + ) == (f"https://github.com/deepmodeling/deepmd-kit/blob/{COMMIT}/deepmd/api.py") + assert ( + github_linkcode.linkcode_resolve("py", {"module": "", "fullname": "missing"}) + is None + )