Skip to content

Commit

Permalink
FEAT: link to source code on GitHub with linkcode
Browse files Browse the repository at this point in the history
  • Loading branch information
redeboer committed Dec 5, 2023
1 parent e620ff2 commit 2a0bdb3
Show file tree
Hide file tree
Showing 5 changed files with 198 additions and 0 deletions.
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"ignoreWords": [
"PyPI",
"commitlint",
"linkcode",
"prereleased",
"refdomain",
"refspecific",
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ api_target_types: dict[str, str] = {
}
```

The extension also links to the source code on GitHub through the [`sphinx.ext.linkcode`](https://www.sphinx-doc.org/en/master/usage/extensions/linkcode.html) extension. You need to specify the GitHub organization and the repository name as follows:

```
api_github_repo: str = "ComPWA/sphinx-api-relink"
```

Set `api_linkcode_debug = True` to print the generated URLs to the console.

## Generate API

To generate the API for [`sphinx.ext.autodoc`](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html), add this to your `conf.py`:
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ classifiers = [
]
dependencies = [
"Sphinx>=4.4",
"colorama",
"docutils",
'importlib-metadata; python_version <"3.8.0"',
'typing-extensions; python_version <"3.8.0"',
]
description = "Relink type hints in your Sphinx API"
dynamic = ["version"]
Expand All @@ -51,7 +53,9 @@ lint = [
]
mypy = [
"mypy",
"types-colorama",
"types-docutils",
"types-requests",
]
sty = [
"pre-commit >=1.4.0",
Expand Down
19 changes: 19 additions & 0 deletions src/sphinx_api_relink/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,45 @@
from sphinx.domains.python import parse_reftarget
from sphinx.ext.apidoc import main as sphinx_apidoc

from sphinx_api_relink.linkcode import get_linkcode_resolve

if TYPE_CHECKING:
from sphinx.application import Sphinx
from sphinx.environment import BuildEnvironment


def setup(app: Sphinx) -> dict[str, Any]:
app.add_config_value("api_github_repo", default=None, rebuild="env")
app.add_config_value("api_linkcode_debug", default=False, rebuild="env")
app.add_config_value("api_target_substitutions", default={}, rebuild="env")
app.add_config_value("api_target_types", default={}, rebuild="env")
app.add_config_value("generate_apidoc_directory", default="api", rebuild="env")
app.add_config_value("generate_apidoc_excludes", default=None, rebuild="env")
app.add_config_value("generate_apidoc_package_path", default=None, rebuild="env")
app.add_config_value("generate_apidoc_use_compwa_template", True, rebuild="env")
app.connect("config-inited", set_linkcode_resolve)
app.connect("config-inited", generate_apidoc)
app.connect("config-inited", replace_type_to_xref)
app.setup_extension("sphinx.ext.linkcode")
return {
"parallel_read_safe": True,
"parallel_write_safe": True,
}


def set_linkcode_resolve(app: Sphinx, _: BuildEnvironment) -> None:
raw_config = app.config._raw_config # pyright: ignore[reportPrivateUsage]
github_repo: str | None = app.config.api_github_repo
if github_repo is None:
msg = (
"Please set api_github_repo in conf.py, e.g. api_github_repo ="
' "ComPWA/sphinx-api-relink"'
)
raise ValueError(msg)
debug: bool = app.config.api_linkcode_debug
raw_config["linkcode_resolve"] = get_linkcode_resolve(github_repo, debug)


def generate_apidoc(app: Sphinx, _: BuildEnvironment) -> None:
config_key = "generate_apidoc_package_path"
package_path: str | None = getattr(app.config, config_key, None)
Expand Down
166 changes: 166 additions & 0 deletions src/sphinx_api_relink/linkcode.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""A linkcode resolver for using :code:`sphinx.ext.linkcode` with GitHub."""

from __future__ import annotations

import inspect
import subprocess
import sys
from functools import lru_cache
from os.path import dirname, relpath
from typing import TYPE_CHECKING, Any, Callable, TypedDict
from urllib.parse import quote

import requests
from colorama import Fore, Style

if TYPE_CHECKING:
from types import ModuleType


class LinkcodeInfo(TypedDict, total=True):
module: str
fullname: str


def get_linkcode_resolve(
github_repo: str, debug: bool
) -> Callable[[str, LinkcodeInfo], str | None]:
def linkcode_resolve(domain: str, info: LinkcodeInfo) -> str | None:
path = _get_path(domain, info)
if path is None:
return None
blob_url = _get_blob_url(github_repo)
if debug:
msg = f" {info['fullname']} --> {blob_url}/src/{path}"
print_once(msg, color=Fore.BLUE)
return f"{blob_url}/src/{path}"

return linkcode_resolve


def _get_path(domain: str, info: LinkcodeInfo) -> str | None:
obj = __get_object(domain, info)
if obj is None:
return None

source_file = inspect.getsourcefile(obj)
if not source_file:
return None

module_name = info["module"]
main_module_path = _get_package(module_name).__file__
if main_module_path is None:
msg = f"Could not find file for module {module_name!r}"
raise ValueError(msg)
path = quote(relpath(source_file, start=dirname(dirname(main_module_path))))
source, start_lineno = inspect.getsourcelines(obj)
end_lineno = start_lineno + len(source) - 1
linenumbers = f"L{start_lineno}-L{end_lineno}"
return f"{path}#{linenumbers}"


@lru_cache(maxsize=None)
def _get_package(module_name: str) -> ModuleType:
package_name = module_name.split(".")[0]
return __get_module(package_name)


@lru_cache(maxsize=None)
def __get_module(module_name: str) -> ModuleType:
module = sys.modules.get(module_name)
if module is None:
msg = f"Could not find module {module_name!r}"
raise ImportError(msg)
return module


def __get_object(domain: str, info: LinkcodeInfo) -> Any | None:
if domain != "py":
print_once(f"Can't get the object for domain {domain!r}")
return None

module_name: str = info["module"]
fullname: str = info["fullname"]

obj = _get_object_from_module(module_name, fullname)
if obj is None:
print_once(f"Module {module_name} does not contain {fullname}")
return None
return inspect.unwrap(obj)


def _get_object_from_module(module_name: str, fullname: str) -> Any | None:
module = __get_module(module_name)
name_parts = fullname.split(".")
if len(name_parts) == 1:
return getattr(module, fullname, None)
obj: Any = module
for sub_attr in name_parts[:-1]:
obj = getattr(obj, sub_attr, None)
if obj is None:
print_once(f"Module {module_name} does not contain {fullname}")
return None
return obj


@lru_cache(maxsize=1)
def _get_blob_url(github_repo: str) -> str:
ref = _get_commit_sha()
repo_url = f"https://github.com/{github_repo}"
blob_url = f"{repo_url}/blob/{ref}"
if _url_exists(blob_url):
return blob_url
print_once(f"The URL {blob_url} seems not to exist", color=Fore.MAGENTA)
tag = _get_latest_tag()
if tag is not None:
blob_url = f"{repo_url}/tree/{tag}"
print_once(f"--> falling back to {blob_url}", color=Fore.MAGENTA)
if _url_exists(blob_url):
return blob_url
blob_url = f"{repo_url}/tree/main"
print_once(f"--> falling back to {blob_url}", color=Fore.MAGENTA)
if _url_exists(blob_url):
return blob_url
blob_url = f"{repo_url}/tree/master"
print_once(f"--> falling back to {blob_url}", color=Fore.MAGENTA)
return blob_url


@lru_cache(maxsize=1)
def _get_commit_sha() -> str:
result = subprocess.run(
["git", "rev-parse", "HEAD"], # noqa: S603, S607
capture_output=True,
check=True,
text=True,
)
commit_hash = result.stdout.strip()
return commit_hash[:7]


def _get_latest_tag() -> str | None:
try:
result = subprocess.check_output(
["git", "describe", "--tags", "--exact-match"], # noqa: S603, S607
stderr=subprocess.PIPE,
universal_newlines=True,
)

return result.strip()
except subprocess.CalledProcessError:
return None


@lru_cache(maxsize=None)
def _url_exists(url: str) -> bool:
try:
response = requests.head(url) # noqa: S113
return response.status_code < 300 # noqa: PLR2004, TRY300
except requests.RequestException:
return False


@lru_cache(maxsize=None)
def print_once(message: str, *, color: str = Fore.RED) -> None:
colored_text = f"{color}{message}{Style.RESET_ALL}"
print(colored_text) # noqa: T201

0 comments on commit 2a0bdb3

Please sign in to comment.