From d5071ac6a441f63571edad9483cb225318840c4b Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Mon, 9 Nov 2026 08:22:35 +0100 Subject: [PATCH 1/5] Link to source on GitHub instead of using viewcode in docs sphinx.ext.viewcode renders a highlighted source page for each of the ~230 documented modules. Each of these pages includes the full furo navigation tree, which is expensive to compute, so they added about a minute to a serial (Windows) docs build. Use sphinx.ext.linkcode to link to the source on GitHub instead. The link points to the tag of a release, or to the commit embedded in the version of a development build, so that it matches the documented code. Line numbers are found with the same parser that viewcode uses. Unlike viewcode, inherited members now link to the base class definition. --- docs/conf.py | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 0514625c4bd1..a6f8b8d47cac 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,8 +15,13 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. # import os +import re +import sys from abc import ABCMeta +from functools import cached_property from importlib import reload +from inspect import unwrap +from pathlib import Path # Import matplotlib and set the backend # before qcodes imports pyplot and automatically @@ -24,6 +29,7 @@ import matplotlib from intersphinx_registry import get_intersphinx_mapping from packaging.version import parse +from sphinx.pycode import ModuleAnalyzer # setting the metaclass will cause sphinx # to document the signature of `__call__` @@ -73,7 +79,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 +497,72 @@ towncrier_draft_working_directory = ".." issues_github_path = "microsoft/Qcodes" + + +# -- Links to source code on GitHub --------------------------------------- +# 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. + + +def _github_ref(version_str: str) -> str: + """ + Return the git ref on GitHub matching the qcodes version being documented: + the tag for a release, otherwise the commit embedded in the version by + versioningit (e.g. ``0.63.0.dev12+main.g76c540bef``). + """ + version = parse(version_str) + if version.local is None and not version.is_devrelease: + return f"v{version.public}" + for segment in reversed((version.local or "").split(".")): + if re.fullmatch(r"g[0-9a-f]{7,40}", segment): + return segment[1:] + return "main" + + +_github_source_ref = _github_ref(qcodes.__version__) +_github_source_root = ( + f"https://github.com/{issues_github_path}/blob/{_github_source_ref}/src" +) +_qcodes_package_parent = Path(qcodes.__file__).parent.parent + + +def linkcode_resolve(domain: str, info: dict[str, str]) -> str | None: + """ + Return the GitHub URL of the source of the object documented by ``info``. + + The lines are looked up in the module that defines the object (so that + inherited members link to the base class) using the same parser as + sphinx.ext.viewcode. + """ + if domain != "py": + return None + obj: object = sys.modules.get(info["module"]) + try: + for part in info["fullname"].split("."): + obj = getattr(obj, part) + if isinstance(obj, property): + obj = obj.fget + elif isinstance(obj, cached_property): + obj = obj.func + if callable(obj): + obj = unwrap(obj) + except Exception: + return None + + modname = getattr(obj, "__module__", None) + qualname = getattr(obj, "__qualname__", None) + if not isinstance(modname, str) or not isinstance(qualname, str): + return None + if modname != "qcodes" and not modname.startswith("qcodes."): + return None + try: + analyzer = ModuleAnalyzer.for_module(modname) + tags = analyzer.find_tags() + path = Path(analyzer.srcname).relative_to(_qcodes_package_parent) + except Exception: + return None + if qualname not in tags: + return None + _, start, end = tags[qualname] + return f"{_github_source_root}/{path.as_posix()}#L{start}-L{end}" From 19cc53e1818aeda87a9706ce4b0264b5ae9f8663 Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Mon, 9 Nov 2026 08:35:31 +0100 Subject: [PATCH 2/5] Add newsfragment for source links on GitHub --- docs/changes/newsfragments/XXXX.underthehood | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/changes/newsfragments/XXXX.underthehood diff --git a/docs/changes/newsfragments/XXXX.underthehood b/docs/changes/newsfragments/XXXX.underthehood new file mode 100644 index 000000000000..91b3a078b352 --- /dev/null +++ b/docs/changes/newsfragments/XXXX.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. From 6cb60e01d99fed05aa2f25c4c758f45bf7ff8681 Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Mon, 9 Nov 2026 08:51:45 +0100 Subject: [PATCH 3/5] Move resolution of source links to a tested module Move the linkcode_resolve logic out of docs/conf.py into qcodes.sphinx_extensions.github_source_links so that it is type checked and can be tested, and add tests for it. conf.py now only creates the function with make_linkcode_resolve. Also drop the handling of decorated functions and cached properties. functools.wraps already copies the module and qualified name used for the lookup, and cached properties are only used for private attributes, which are not documented. --- docs/conf.py | 75 +--------- .../sphinx_extensions/github_source_links.py | 129 ++++++++++++++++++ .../test_github_source_links.py | 110 +++++++++++++++ 3 files changed, 243 insertions(+), 71 deletions(-) create mode 100644 src/qcodes/sphinx_extensions/github_source_links.py create mode 100644 tests/sphinx_extension/test_github_source_links.py diff --git a/docs/conf.py b/docs/conf.py index a6f8b8d47cac..f370b0ff9002 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -15,13 +15,8 @@ # documentation root, use os.path.abspath to make it absolute, like shown here. # import os -import re -import sys from abc import ABCMeta -from functools import cached_property from importlib import reload -from inspect import unwrap -from pathlib import Path # Import matplotlib and set the backend # before qcodes imports pyplot and automatically @@ -29,7 +24,6 @@ import matplotlib from intersphinx_registry import get_intersphinx_mapping from packaging.version import parse -from sphinx.pycode import ModuleAnalyzer # setting the metaclass will cause sphinx # to document the signature of `__call__` @@ -49,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 @@ -498,71 +495,7 @@ issues_github_path = "microsoft/Qcodes" - -# -- Links to source code on GitHub --------------------------------------- # 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. - - -def _github_ref(version_str: str) -> str: - """ - Return the git ref on GitHub matching the qcodes version being documented: - the tag for a release, otherwise the commit embedded in the version by - versioningit (e.g. ``0.63.0.dev12+main.g76c540bef``). - """ - version = parse(version_str) - if version.local is None and not version.is_devrelease: - return f"v{version.public}" - for segment in reversed((version.local or "").split(".")): - if re.fullmatch(r"g[0-9a-f]{7,40}", segment): - return segment[1:] - return "main" - - -_github_source_ref = _github_ref(qcodes.__version__) -_github_source_root = ( - f"https://github.com/{issues_github_path}/blob/{_github_source_ref}/src" -) -_qcodes_package_parent = Path(qcodes.__file__).parent.parent - - -def linkcode_resolve(domain: str, info: dict[str, str]) -> str | None: - """ - Return the GitHub URL of the source of the object documented by ``info``. - - The lines are looked up in the module that defines the object (so that - inherited members link to the base class) using the same parser as - sphinx.ext.viewcode. - """ - if domain != "py": - return None - obj: object = sys.modules.get(info["module"]) - try: - for part in info["fullname"].split("."): - obj = getattr(obj, part) - if isinstance(obj, property): - obj = obj.fget - elif isinstance(obj, cached_property): - obj = obj.func - if callable(obj): - obj = unwrap(obj) - except Exception: - return None - - modname = getattr(obj, "__module__", None) - qualname = getattr(obj, "__qualname__", None) - if not isinstance(modname, str) or not isinstance(qualname, str): - return None - if modname != "qcodes" and not modname.startswith("qcodes."): - return None - try: - analyzer = ModuleAnalyzer.for_module(modname) - tags = analyzer.find_tags() - path = Path(analyzer.srcname).relative_to(_qcodes_package_parent) - except Exception: - return None - if qualname not in tags: - return None - _, start, end = tags[qualname] - return f"{_github_source_root}/{path.as_posix()}#L{start}-L{end}" +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..3ad8af325d4a --- /dev/null +++ b/src/qcodes/sphinx_extensions/github_source_links.py @@ -0,0 +1,129 @@ +""" +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 Exception: + # looking up an attribute may raise anything, and instance + # attributes cannot be looked up on the class at all + 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..36ab6147f4e2 --- /dev/null +++ b/tests/sphinx_extension/test_github_source_links.py @@ -0,0 +1,110 @@ +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 From aa23c9e08e9ba7c9d0a60f8b1434266ba3cfd817 Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Mon, 9 Nov 2026 08:51:45 +0100 Subject: [PATCH 4/5] Use PR number for newsfragment --- .../newsfragments/{XXXX.underthehood => 8783.underthehood} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changes/newsfragments/{XXXX.underthehood => 8783.underthehood} (100%) diff --git a/docs/changes/newsfragments/XXXX.underthehood b/docs/changes/newsfragments/8783.underthehood similarity index 100% rename from docs/changes/newsfragments/XXXX.underthehood rename to docs/changes/newsfragments/8783.underthehood From 1f2e635a361bbe6653d21465baa8a82099aefb1a Mon Sep 17 00:00:00 2001 From: "Jens H. Nielsen" Date: Mon, 9 Nov 2026 08:52:15 +0100 Subject: [PATCH 5/5] Only catch AttributeError when looking up documented objects Fixes the blind-except lint error and formats the new tests. --- src/qcodes/sphinx_extensions/github_source_links.py | 5 ++--- tests/sphinx_extension/test_github_source_links.py | 5 ++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/qcodes/sphinx_extensions/github_source_links.py b/src/qcodes/sphinx_extensions/github_source_links.py index 3ad8af325d4a..25086a5a4927 100644 --- a/src/qcodes/sphinx_extensions/github_source_links.py +++ b/src/qcodes/sphinx_extensions/github_source_links.py @@ -72,9 +72,8 @@ def find_source(modname: str, fullname: str) -> tuple[str, int, int] | None: try: for part in fullname.split("."): obj = getattr(obj, part) - except Exception: - # looking up an attribute may raise anything, and instance - # attributes cannot be looked up on the class at all + except AttributeError: + # e.g. instance attributes cannot be looked up on the class return None if isinstance(obj, property): obj = obj.fget diff --git a/tests/sphinx_extension/test_github_source_links.py b/tests/sphinx_extension/test_github_source_links.py index 36ab6147f4e2..60189ff79419 100644 --- a/tests/sphinx_extension/test_github_source_links.py +++ b/tests/sphinx_extension/test_github_source_links.py @@ -94,7 +94,10 @@ def test_linkcode_resolve() -> None: url = linkcode_resolve( "py", - {"module": "qcodes.instrument", "fullname": "VisaInstrument.print_readable_snapshot"}, + { + "module": "qcodes.instrument", + "fullname": "VisaInstrument.print_readable_snapshot", + }, ) assert url == (