diff --git a/.cspell.json b/.cspell.json index 90dfc07..a7118cd 100644 --- a/.cspell.json +++ b/.cspell.json @@ -39,6 +39,7 @@ "ignoreWords": [ "PyPI", "commitlint", + "linkcode", "prereleased", "refdomain", "refspecific", diff --git a/README.md b/README.md index d6b5762..986ac9a 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/pyproject.toml b/pyproject.toml index 0f280ea..186e9e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] @@ -51,7 +53,9 @@ lint = [ ] mypy = [ "mypy", + "types-colorama", "types-docutils", + "types-requests", ] sty = [ "pre-commit >=1.4.0", diff --git a/src/sphinx_api_relink/__init__.py b/src/sphinx_api_relink/__init__.py index 8240987..26c3ab5 100644 --- a/src/sphinx_api_relink/__init__.py +++ b/src/sphinx_api_relink/__init__.py @@ -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) diff --git a/src/sphinx_api_relink/linkcode.py b/src/sphinx_api_relink/linkcode.py new file mode 100644 index 0000000..12e0927 --- /dev/null +++ b/src/sphinx_api_relink/linkcode.py @@ -0,0 +1,171 @@ +"""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, debug) + 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, debug: bool) -> str | None: + obj = __get_object(domain, info) + if obj is None: + return None + try: + source_file = inspect.getsourcefile(obj) + except TypeError: + if debug: + msg = f" Cannot source file for {info['fullname']!r} of type {type(obj)}" + print_once(msg, color=Fore.MAGENTA) + return None + 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