Skip to content

Commit 23b9876

Browse files
feat: relative cross-references
Fixes mkdocstrings#27 - Move subsitution to from collect to render - Warn w/ source location about missing reference after relative path resolution - Work around issue with bad source information in python 3.7 - Add debug logging - Fix bug in regular expression
1 parent 0ec859c commit 23b9876

File tree

2 files changed

+75
-25
lines changed

2 files changed

+75
-25
lines changed

src/mkdocstrings_handlers/python/crossref.py

+55-20
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
1+
# Copyright (c) 2022. Analog Devices Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
114
"""Support for translating compact relative crossreferences in docstrings."""
2-
# author: Christopher Barber, Analog Devices | Analog Garage
315

416
from __future__ import annotations
517

618
import re
7-
from typing import List, Optional
19+
import sys
20+
from typing import Callable, List, Optional, Union, cast
821

922
from griffe.dataclasses import Docstring, Object
1023
from mkdocstrings.loggers import get_logger
@@ -13,6 +26,9 @@
1326

1427
logger = get_logger(__name__)
1528

29+
# line numbers from griffe are not reliable before python 3.8; this may eventually be fixed...
30+
_supports_linenums = sys.version_info >= (3, 8)
31+
1632

1733
def _re_or(*exps: str) -> str:
1834
"""Construct an "or" regular expression from a sequence of regular expressions.
@@ -41,7 +57,7 @@ def _re_named(name: str, exp: str, optional: bool = False) -> str:
4157
return f"(?P<{name}>{exp}){optchar}"
4258

4359

44-
_RE_REL_CROSSREF = re.compile(r"\[(.+?)\]\[([\.^\(][^\]]*?|[^\]]*?\.)\]")
60+
_RE_REL_CROSSREF = re.compile(r"\[([^\[\]]+?)\]\[([\.^\(][^\]]*?|[^\]]*?\.)\]")
4561
"""Regular expression that matches relative cross-reference expressions in doc-string.
4662
4763
This will match a cross reference where the path expression either ends in '.'
@@ -79,6 +95,10 @@ def _re_named(name: str, exp: str, optional: bool = False) -> str:
7995
"""Regular expression that matches a qualified python identifier."""
8096

8197

98+
def _always_ok(_ref: str) -> bool:
99+
return True
100+
101+
82102
class _RelativeCrossrefProcessor:
83103
"""
84104
A callable object that substitutes relative cross-reference expressions.
@@ -93,13 +113,15 @@ class _RelativeCrossrefProcessor:
93113
_cur_offset: int
94114
_cur_ref_parts: List[str]
95115
_ok: bool
116+
_check_ref: Union[Callable[[str], bool], Callable[[str], bool]]
96117

97-
def __init__(self, doc: Docstring):
118+
def __init__(self, doc: Docstring, checkref: Optional[Callable[[str], bool]] = None):
98119
self._doc = doc
99120
self._cur_match = None
100121
self._cur_input = ""
101122
self._cur_offset = 0
102123
self._cur_ref_parts = []
124+
self._check_ref = checkref or _always_ok
103125
self._ok = True
104126

105127
def __call__(self, match: re.Match) -> str:
@@ -117,13 +139,23 @@ def __call__(self, match: re.Match) -> str:
117139
self._process_append_from_title(ref_match, title)
118140

119141
if self._ok:
120-
result = f"[{title}][{'.'.join(self._cur_ref_parts)}]"
142+
new_ref = ".".join(self._cur_ref_parts)
143+
logger.debug(
144+
"cross-reference substitution\nin %s:\n[%s][%s] -> [...][%s]", # noqa: WPS323
145+
cast(Object, self._doc.parent).canonical_path,
146+
title,
147+
ref,
148+
new_ref,
149+
)
150+
if not self._check_ref(new_ref):
151+
self._error(f"Cannot load reference '{new_ref}'")
152+
result = f"[{title}][{new_ref}]"
121153
else:
122154
result = match.group(0)
123155

124156
return result
125157

126-
def _start_match(self, match: re.Match):
158+
def _start_match(self, match: re.Match) -> None:
127159
self._cur_match = match
128160
self._cur_offset = match.start(0)
129161
self._cur_input = match[0]
@@ -143,7 +175,7 @@ def _process_append_from_title(self, ref_match: re.Match, title_text: str) -> No
143175
return
144176
self._cur_ref_parts.append(id_from_title)
145177

146-
def _process_parent_specifier(self, ref_match: re.Match):
178+
def _process_parent_specifier(self, ref_match: re.Match) -> None:
147179
if not ref_match.group("parent"):
148180
return
149181

@@ -196,11 +228,11 @@ def _process_up_specifier(self, obj: Object, ref_match: re.Match) -> Optional[Ob
196228
level = len(ref_match.group("up"))
197229
rel_obj = obj
198230
for _ in range(level):
199-
if rel_obj.parent is None:
231+
if rel_obj.parent is not None:
232+
rel_obj = rel_obj.parent
233+
else:
200234
self._error(f"'{ref_match.group('up')}' has too many levels for {obj.canonical_path}")
201235
break
202-
else:
203-
rel_obj = rel_obj.parent
204236
return rel_obj
205237

206238
def _error(self, msg: str) -> None:
@@ -219,9 +251,10 @@ def _error(self, msg: str) -> None:
219251
# recognize that this is a navigable location it can highlight.
220252
prefix = f"file://{parent.filepath}:"
221253
line = doc.lineno
222-
if line is not None:
223-
# Add line offset to match in docstring
224-
line += doc.value.count("\n", 0, self._cur_offset)
254+
if line is not None: # pragma: no branch
255+
if _supports_linenums: # pragma: no branch
256+
# Add line offset to match in docstring
257+
line += doc.value.count("\n", 0, self._cur_offset)
225258
prefix += f"{line}:"
226259
# It would be nice to add the column as well, but we cannot determine
227260
# that without knowing how much the doc string was unindented.
@@ -232,17 +265,19 @@ def _error(self, msg: str) -> None:
232265
self._ok = False
233266

234267

235-
def substitute_relative_crossrefs(obj: Object):
268+
def substitute_relative_crossrefs(obj: Object, checkref: Optional[Callable[[str], bool]] = None) -> None:
236269
"""Recursively expand relative cross-references in all docstrings in tree.
237270
238271
Arguments:
239-
obj: root object. The object's docstring will be be processed as well
240-
as all of its children recursively.
272+
obj: a Griffe [Object][griffe.dataclasses.] whose docstrings should be modified
273+
checkref: optional function to check whether computed cross-reference is valid.
274+
Should return True if valid, False if not valid.
241275
"""
242276
doc = obj.docstring
243-
if doc:
244-
doc.value = _RE_REL_CROSSREF.sub(_RelativeCrossrefProcessor(doc), doc.value)
277+
278+
if doc is not None:
279+
doc.value = _RE_REL_CROSSREF.sub(_RelativeCrossrefProcessor(doc, checkref=checkref), doc.value)
245280

246281
for member in obj.members.values():
247-
if isinstance(member, Object):
248-
substitute_relative_crossrefs(member)
282+
if isinstance(member, Object): # pragma: no branch
283+
substitute_relative_crossrefs(member, checkref=checkref)

src/mkdocstrings_handlers/python/handler.py

+20-5
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,7 @@ def collect(self, identifier: str, config: dict) -> CollectorItem: # noqa: D102
191191
lines_collection=self._lines_collection,
192192
)
193193
try: # noqa: WPS229
194-
module = loader.load_module(module_name)
195-
196-
if final_config["relative_crossrefs"]:
197-
substitute_relative_crossrefs(module)
198-
194+
loader.load_module(module_name)
199195
except ImportError as error:
200196
raise CollectionError(str(error)) from error
201197

@@ -219,6 +215,9 @@ def collect(self, identifier: str, config: dict) -> CollectorItem: # noqa: D102
219215
def render(self, data: CollectorItem, config: dict) -> str: # noqa: D102 (ignore missing docstring)
220216
final_config = ChainMap(config, self.default_config)
221217

218+
if final_config["relative_crossrefs"]:
219+
substitute_relative_crossrefs(data, checkref=self._check_ref)
220+
222221
template = self.env.get_template(f"{data.kind.value}.html")
223222

224223
# Heading level is a "state" variable, that will change at each step
@@ -258,6 +257,22 @@ def get_anchors(self, data: CollectorItem) -> list[str]: # noqa: D102 (ignore m
258257
except AliasResolutionError:
259258
return [data.path]
260259

260+
def _check_ref(self, ref: str) -> bool:
261+
"""Check for existence of reference.
262+
263+
Arguments:
264+
ref: reference to check
265+
266+
Returns:
267+
true if reference exists
268+
"""
269+
try:
270+
self.collect(ref, {})
271+
except Exception: # pylint: disable=broad-except
272+
# Only expect a CollectionError but we may as well catch everything.
273+
return False
274+
return True
275+
261276

262277
def get_handler(
263278
theme: str, # noqa: W0613 (unused argument config)

0 commit comments

Comments
 (0)