Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions docs/changes/newsfragments/8783.underthehood
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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__)
128 changes: 128 additions & 0 deletions src/qcodes/sphinx_extensions/github_source_links.py
Original file line number Diff line number Diff line change
@@ -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
113 changes: 113 additions & 0 deletions tests/sphinx_extension/test_github_source_links.py
Original file line number Diff line number Diff line change
@@ -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
Loading