diff --git a/docs/changes/newsfragments/8783.underthehood b/docs/changes/newsfragments/8783.underthehood new file mode 100644 index 000000000000..91b3a078b352 --- /dev/null +++ b/docs/changes/newsfragments/8783.underthehood @@ -0,0 +1,3 @@ +The ``[source]`` links in the API documentation now point to the source code on GitHub instead of +to source pages rendered by ``sphinx.ext.viewcode``. This makes the documentation build faster. +Inherited members now also link to their definition in the base class. diff --git a/docs/conf.py b/docs/conf.py index 0514625c4bd1..f370b0ff9002 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,6 +43,9 @@ reload(qcodes.instrument) import qcodes # noqa: E402 +from qcodes.sphinx_extensions.github_source_links import ( # noqa: E402 + make_linkcode_resolve, +) # sphinx 6.2 -> 7.1 produces a warning # Debugger warning: It seems that frozen modules are being used, which may @@ -73,7 +76,7 @@ "sphinx.ext.todo", "sphinx.ext.coverage", "sphinx.ext.mathjax", - "sphinx.ext.viewcode", + "sphinx.ext.linkcode", "sphinx.ext.githubpages", "sphinx.ext.todo", "qcodes.sphinx_extensions.parse_parameter_attr", @@ -491,3 +494,8 @@ towncrier_draft_working_directory = ".." issues_github_path = "microsoft/Qcodes" + +# We link to the source on GitHub rather than using sphinx.ext.viewcode, +# since rendering a page of highlighted source for every module adds +# significantly to the build time. +linkcode_resolve = make_linkcode_resolve(issues_github_path, qcodes.__version__) diff --git a/src/qcodes/sphinx_extensions/github_source_links.py b/src/qcodes/sphinx_extensions/github_source_links.py new file mode 100644 index 000000000000..25086a5a4927 --- /dev/null +++ b/src/qcodes/sphinx_extensions/github_source_links.py @@ -0,0 +1,128 @@ +""" +Link the API documentation to the source code of QCoDeS on GitHub. + +This provides the ``linkcode_resolve`` function required by ``sphinx.ext.linkcode``. +In ``conf.py``:: + + extensions = [..., "sphinx.ext.linkcode"] + linkcode_resolve = make_linkcode_resolve("microsoft/Qcodes", qcodes.__version__) +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from packaging.version import Version +from sphinx.errors import PycodeError +from sphinx.pycode import ModuleAnalyzer + +import qcodes + +if TYPE_CHECKING: + from collections.abc import Callable + +# The directory containing the qcodes package, i.e. ``src`` in the repository. +_SOURCE_ROOT = Path(qcodes.__file__).parent.parent + + +def github_ref(version: str) -> str: + """ + Return the git ref on GitHub of the source code of a version of QCoDeS. + + Args: + version: The version of QCoDeS as generated by versioningit from git. + + Returns: + The tag of a release (e.g. ``v0.62.0`` for ``0.62.0``), otherwise the + commit that the version is generated from (e.g. ``76c540bef`` for + ``0.63.0.dev12+main.g76c540bef``). Falls back to ``main`` if the version + does not contain a commit. + + """ + parsed = Version(version) + if parsed.local is None and not parsed.is_devrelease: + return f"v{parsed.public}" + for segment in reversed((parsed.local or "").split(".")): + if re.fullmatch(r"g[0-9a-f]{7,40}", segment): + return segment[1:] + return "main" + + +def find_source(modname: str, fullname: str) -> tuple[str, int, int] | None: + """ + Find the source code of an object documented in QCoDeS. + + The object is looked up in the module that defines it rather than in the + module it is documented in, so an inherited member is found in its base class. + + Args: + modname: The module the object is documented in. + fullname: The name of the object within that module, e.g. ``Class.method``. + + Returns: + The path of the file relative to the ``src`` directory of the repository + and the first and last line of the definition, or None if the object is + not a class, function or property defined in QCoDeS. + + """ + obj: object = sys.modules.get(modname) + try: + for part in fullname.split("."): + obj = getattr(obj, part) + except AttributeError: + # e.g. instance attributes cannot be looked up on the class + return None + if isinstance(obj, property): + obj = obj.fget + + defining_modname = getattr(obj, "__module__", None) + qualname = getattr(obj, "__qualname__", None) + if not isinstance(defining_modname, str) or not isinstance(qualname, str): + return None + if defining_modname.split(".")[0] != "qcodes": + return None + + # This is the parser that sphinx.ext.viewcode uses. It is cached per module. + try: + analyzer = ModuleAnalyzer.for_module(defining_modname) + tags = analyzer.find_tags() + except PycodeError: + return None + if qualname not in tags: + return None + _, start, end = tags[qualname] + path = Path(analyzer.srcname).relative_to(_SOURCE_ROOT).as_posix() + return path, start, end + + +def make_linkcode_resolve( + github_repo: str, version: str +) -> Callable[[str, dict[str, str]], str | None]: + """ + Make a ``linkcode_resolve`` function for ``sphinx.ext.linkcode``. + + Args: + github_repo: The repository on GitHub, e.g. ``microsoft/Qcodes``. + version: The version of QCoDeS being documented. The links point to + the source of this version, see :func:`github_ref`. + + Returns: + A function that returns the URL of the source on GitHub of a + documented object, or None if it has no source in QCoDeS. + + """ + source_url = f"https://github.com/{github_repo}/blob/{github_ref(version)}/src" + + def linkcode_resolve(domain: str, info: dict[str, str]) -> str | None: + if domain != "py": + return None + source = find_source(info["module"], info["fullname"]) + if source is None: + return None + path, start, end = source + return f"{source_url}/{path}#L{start}-L{end}" + + return linkcode_resolve diff --git a/tests/sphinx_extension/test_github_source_links.py b/tests/sphinx_extension/test_github_source_links.py new file mode 100644 index 000000000000..60189ff79419 --- /dev/null +++ b/tests/sphinx_extension/test_github_source_links.py @@ -0,0 +1,113 @@ +import inspect +from typing import TYPE_CHECKING, Any + +import pytest + +from qcodes.instrument import InstrumentBase +from qcodes.sphinx_extensions.github_source_links import ( + find_source, + github_ref, + make_linkcode_resolve, +) + +if TYPE_CHECKING: + from collections.abc import Callable + +INSTRUMENT_BASE_PATH = "qcodes/instrument/instrument_base.py" + + +def _lines(obj: "Callable[..., Any] | None") -> tuple[int, int]: + """The first and last line of the definition of obj including decorators.""" + assert obj is not None + lines, start = inspect.getsourcelines(obj) + return start, start + len(lines) - 1 + + +@pytest.mark.parametrize( + ("version", "ref"), + [ + pytest.param("0.62.0", "v0.62.0", id="release"), + pytest.param("0.12.0rc1", "v0.12.0rc1", id="release_candidate"), + pytest.param("0.63.0.dev12+main.g76c540bef", "76c540bef", id="dev"), + pytest.param( + "0.63.0.dev12+my.branch.g76c540bef.dirty", "76c540bef", id="dev_dirty" + ), + pytest.param( + "0.63.0.dev12+main.g0123456789abcdef0123456789abcdef01234567", + "0123456789abcdef0123456789abcdef01234567", + id="dev_full_hash", + ), + pytest.param( + "0.63.0.dev12+gadget.g76c540bef", "76c540bef", id="branch_starting_with_g" + ), + # the default-version that versioningit uses when git is not available + pytest.param("0.63.0dev+Unknown", "main", id="no_git"), + pytest.param("0.63.0.dev12", "main", id="dev_without_commit"), + ], +) +def test_github_ref(version: str, ref: str) -> None: + assert github_ref(version) == ref + + +def test_find_source_of_class() -> None: + assert find_source("qcodes.instrument", "InstrumentBase") == ( + INSTRUMENT_BASE_PATH, + *_lines(InstrumentBase), + ) + + +def test_find_source_of_method() -> None: + assert find_source( + "qcodes.instrument", "InstrumentBase.print_readable_snapshot" + ) == (INSTRUMENT_BASE_PATH, *_lines(InstrumentBase.print_readable_snapshot)) + + +def test_find_source_of_inherited_method_is_in_base_class() -> None: + assert find_source( + "qcodes.instrument", "VisaInstrument.print_readable_snapshot" + ) == (INSTRUMENT_BASE_PATH, *_lines(InstrumentBase.print_readable_snapshot)) + + +def test_find_source_of_property() -> None: + assert find_source("qcodes.instrument", "InstrumentBase.full_name") == ( + INSTRUMENT_BASE_PATH, + *_lines(InstrumentBase.full_name.fget), + ) + + +@pytest.mark.parametrize( + ("modname", "fullname"), + [ + pytest.param("qcodes.instrument", "VisaInstrument.default_timeout", id="attr"), + pytest.param("qcodes.instrument", "InstrumentBase.not_an_attr", id="missing"), + pytest.param("qcodes.not_a_module", "InstrumentBase", id="missing_module"), + pytest.param("json", "dumps", id="not_in_qcodes"), + ], +) +def test_find_source_without_source_in_qcodes(modname: str, fullname: str) -> None: + assert find_source(modname, fullname) is None + + +def test_linkcode_resolve() -> None: + linkcode_resolve = make_linkcode_resolve("microsoft/Qcodes", "0.62.0") + start, end = _lines(InstrumentBase.print_readable_snapshot) + + url = linkcode_resolve( + "py", + { + "module": "qcodes.instrument", + "fullname": "VisaInstrument.print_readable_snapshot", + }, + ) + + assert url == ( + "https://github.com/microsoft/Qcodes/blob/v0.62.0/src/" + f"{INSTRUMENT_BASE_PATH}#L{start}-L{end}" + ) + + +def test_linkcode_resolve_without_source() -> None: + linkcode_resolve = make_linkcode_resolve("microsoft/Qcodes", "0.62.0") + + assert linkcode_resolve("py", {"module": "json", "fullname": "dumps"}) is None + assert linkcode_resolve("c", {"names": "qcodes_function"}) is None