Skip to content

WIP: add crossrefs checker for documentation linting #9082

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
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
169 changes: 169 additions & 0 deletions doc/en/_crossrefcheck.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import sys
from typing import Any
from typing import cast
from typing import Iterable
from typing import Iterator
from typing import NamedTuple
from typing import TYPE_CHECKING

import docutils.nodes
import sphinx.builders.dummy
import sphinx.transforms.post_transforms
import sphinx.util.logging
import sphinx.util.nodes


if TYPE_CHECKING:
import sphinx.application
import sphinx.domains

if sys.version_info >= (3, 9):
Inventory = dict[str, dict[str, tuple[str, str, str, str]]]
else:
from typing import Dict, Tuple

Inventory = Dict[str, Dict[str, Tuple[str, str, str, str]]]


logger = sphinx.util.logging.getLogger(__name__)


class URIInfo(NamedTuple):
uri: str
document_name: str
line_number: int


class LinkCollector(sphinx.transforms.post_transforms.SphinxPostTransform):
"""
Traverse the document and collect all ``reference`` nodes that do not have the ``internal`` attribute set.

Typically, they will look like this:

.. code-block::

<reference name="link text" refuri="https://uri">link text, maybe further styled</reference>

We know those were not generated by Sphinx because they are missing the ``internal`` attribute.

This transform doesn't actually modify the document tree, only collecting stuff.
"""

builders = ("crossrefcheck",)
default_priority = 900

def run(self, **kwargs: Any) -> None:
builder = cast(ExternalLinkChecker, self.app.builder)

for refnode in self.document.traverse(docutils.nodes.reference):
if "internal" in refnode or "refuri" not in refnode:
continue
uri = refnode["refuri"]
lineno = sphinx.util.nodes.get_node_line(refnode)

uri_info = URIInfo(uri, self.env.docname, lineno)
builder.uris.append(uri_info)


class ExternalLinkChecker(sphinx.builders.dummy.DummyBuilder):
"""
Custom builder that does not build anything, only analyzes the built result.

It is invoked when the user selects ``crossrefcheck`` as builder name
in the terminal:

.. code-block:: sh

$ sphinx-build -b crossrefcheck doc/en/ build

For every link not generated by Sphinx, it compares whether it matches
an inventory URL configured in ``intersphinx_mapping`` and warns if the
link can be replaced by an cross-reference.

.. note:: The matching is done by simply comparing URLs as strings
via ``str.startswith``. This means that with e.g. ``x`` project
configured as

.. code-block:: python

intersphinx_mapping = {
"x": ("https://x.readthedocs.io/en/stable", None),
}

no warning will be emitted for links ``https://x.readthedocs.io/en/latest``
or ``https://x.readthedocs.io/de/stable``.

Those links can be included by adding the missing docs
for ``x`` to ``intersphinx_mapping``:

.. code-block:: python

intersphinx_mapping = {
"x": ("https://x.readthedocs.io/en/stable", None),
"x-dev": ("https://x.readthedocs.io/en/latest", None),
"x-german": ("https://x.readthedocs.io/de/stable", None),
}

"""

name = "crossrefcheck"

def __init__(self, app: "sphinx.application.Sphinx") -> None:
super().__init__(app)
self.uris: list[URIInfo] = []

def finish(self) -> None:
intersphinx_cache = getattr(self.app.env, "intersphinx_cache", dict())
for uri_info in self.uris:
for inventory_uri, (
inventory_name,
_,
inventory,
) in intersphinx_cache.items():
if uri_info.uri.startswith(inventory_uri):
# build a replacement suggestion
try:
replacement = next(
replacements(
uri_info.uri, inventory, self.app.env.domains.values()
)
)
suggestion = f"try using {replacement!r} instead"
except StopIteration:
suggestion = "no suggestion"

location = (uri_info.document_name, uri_info.line_number)
logger.warning(
"hardcoded link %r could be replaced by a cross-reference to %r inventory (%s)",
uri_info.uri,
inventory_name,
suggestion,
location=location,
)


def replacements(
uri: str, inventory: Inventory, domains: Iterable["sphinx.domains.Domain"]
) -> Iterator[str]:
"""
Create a crossreference to replace hardcoded ``uri``.

This is straightforward: search the given inventory
for an entry that points to the ``uri`` and build
a ReST markup that should replace ``uri`` with a crossref.
"""
for key, entries in inventory.items():
domain_name, directive_type = key.split(":")
for target, (_, _, target_uri, _) in entries.items():
if uri == target_uri:
role = "any"
for domain in domains:
if domain_name == domain.name:
role = domain.role_for_objtype(directive_type) or "any"
yield f":{domain_name}:{role}:`{target}`"


def setup(app: "sphinx.application.Sphinx") -> None:
"""Register this extension."""
app.add_builder(ExternalLinkChecker)
app.add_post_transform(LinkCollector)
3 changes: 2 additions & 1 deletion doc/en/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
# sys.path.insert(0, os.path.abspath('.'))
sys.path.insert(0, os.path.abspath("."))

autodoc_member_order = "bysource"
autodoc_typehints = "description"
Expand All @@ -55,6 +55,7 @@
"sphinx.ext.viewcode",
"sphinx_removed_in",
"sphinxcontrib_trio",
"_crossrefcheck",
]

# Add any paths that contain templates here, relative to this directory.
Expand Down
9 changes: 9 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ envlist =
py37-freeze
docs
docs-checklinks
docs-checkcrossrefs

[testenv]
commands =
Expand Down Expand Up @@ -80,6 +81,14 @@ deps = -r{toxinidir}/doc/en/requirements.txt
commands =
sphinx-build -W -q --keep-going -b linkcheck . _build

[testenv:docs-checkcrossrefs]
basepython = python3
usedevelop = True
changedir = doc/en
deps = -r{toxinidir}/doc/en/requirements.txt
commands =
sphinx-build -W -q --keep-going -b crossrefcheck . _build

[testenv:regen]
changedir = doc/en
basepython = python3
Expand Down