Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
12 changes: 11 additions & 1 deletion doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@
)

sys.path.append(os.path.dirname(__file__))
import github_linkcode

Check notice

Code scanning / CodeQL

Module is imported with 'import' and 'import from' Note documentation

Module 'github_linkcode' is imported with both 'import' and 'import from'.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
import sphinx_contrib_exhale_multiproject # noqa: F401
from github_linkcode import (
collect_autoapi_source_locations,
)

linkcode_resolve = github_linkcode.linkcode_resolve

# -- Project information -----------------------------------------------------

Expand Down Expand Up @@ -59,7 +65,7 @@
"myst_nb",
"sphinx.ext.autosummary",
"sphinx.ext.mathjax",
"sphinx.ext.viewcode",
"sphinx.ext.linkcode",
"sphinx.ext.imgconverter",
"sphinx.ext.intersphinx",
"sphinx.ext.napoleon",
Expand Down Expand Up @@ -276,5 +282,9 @@


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", collect_autoapi_source_locations, priority=600)
app.connect("doctree-resolved", _cap_cli_secnumbers)
return {"parallel_read_safe": True, "parallel_write_safe": True}
167 changes: 167 additions & 0 deletions doc/github_linkcode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
"""Create permanent GitHub source links from Sphinx AutoAPI metadata."""

from __future__ import (
annotations,
)

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}")


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] = {}


@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,
text=True,
).strip()
except (OSError, subprocess.CalledProcessError):
return None

commit = commit.lower()
return commit if _FULL_GIT_HASH.fullmatch(commit) else None


def _resolve_original_object(
Comment thread
njzjz-bot marked this conversation as resolved.
documented_object: AutoapiObject,
all_objects: Mapping[str, AutoapiObject],
) -> AutoapiObject:
"""Follow AutoAPI re-exports to the object that owns the source lines."""
source_object = documented_object
visited = set()
while original_path := source_object.obj.get("original_path"):
if original_path in visited:
break
visited.add(original_path)
original_object = all_objects.get(original_path)
if original_object is None:
break
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.
"""
all_objects = getattr(app.env, "autoapi_all_objects", {})
Comment thread
njzjz-bot marked this conversation as resolved.
Outdated
modules = {name: obj for name, obj in all_objects.items() if "file_path" in obj.obj}

_source_locations.clear()
for documented_name, documented_object in all_objects.items():
source_object = _resolve_original_object(documented_object, all_objects)
module = _containing_module(source_object.id, modules)
if module is None:
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.
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(".")
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
78 changes: 78 additions & 0 deletions source/tests/test_doc_linkcode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
from pathlib import (
Path,
)
from types import (
SimpleNamespace,
)

from doc import (
github_linkcode,
)


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


def test_linkcode_uses_original_object_and_rtd_commit(
Comment thread
njzjz-bot marked this conversation as resolved.
monkeypatch, tmp_path: Path
) -> None:
repository_root = tmp_path
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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),
)
commit = "1f94e04b7f596c309b7efab4e7630ed78e85a1f1"

monkeypatch.setattr(github_linkcode, "REPOSITORY_ROOT", repository_root)
monkeypatch.setenv("READTHEDOCS_GIT_COMMIT_HASH", commit)
github_linkcode.get_git_commit.cache_clear()
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.setenv(
"READTHEDOCS_GIT_COMMIT_HASH",
"1f94e04b7f596c309b7efab4e7630ed78e85a1f1",
)
github_linkcode.get_git_commit.cache_clear()

assert (
github_linkcode.linkcode_resolve(
"cpp", {"module": "deepmd.api", "fullname": "public_function"}
)
is None
)
Loading