Skip to content

Commit dc89760

Browse files
Check all xrefs (#3)
Add `check_crossrefs` option to disable all checking by this extension. And add ? syntax to turn off checking for individual ref.
1 parent 17ad395 commit dc89760

File tree

9 files changed

+162
-37
lines changed

9 files changed

+162
-37
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# mkdocstring-python-xref changes
22

3+
## 1.6.0
4+
5+
* Added explicit option to disable cross-reference checking.
6+
* When enabled, check all cross-references, not just relative ones
7+
* If reference begins with '?', don't check cross-reference.
8+
39
## 1.5.3
410

511
First public release

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,17 @@ class MyClass:
3838
"""
3939
```
4040

41+
Another benefit of this extension is that it will report source locations for bad references
42+
so that errors are easier to find and fix. For example:
43+
44+
```bash
45+
$ mkdocs build
46+
INFO - Cleaning site directory
47+
INFO - Building documentation to directory: /home/jdoe/my-project/site
48+
WARNING - mkdocstrings_handlers: file:///home/jdoe/my-project/src/myproj/bar.py:16:
49+
Cannot load reference 'myproj.bar.bad'
50+
```
51+
4152
For further details, please see the [Documentation](https://analog-garage.github.io/mkdocstrings-python-xref/)
4253

4354
[MkDocs]: https://mkdocs.readthedocs.io/

docs/config.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,16 @@ that the handler name should be `python_xref` instead of `python`. Because
33
this handler extends the standard [mkdocstrings-python][] handler, the same options are
44
available.
55

6-
Additional options are added by this extension. Currently, there is only one:
6+
Additional options are added by this extension. Currently, there are two:
77

88
* **relative_crossrefs** - if set to true enables use of relative path syntax in
99
cross-references.
10+
11+
* **check_crossrefs** - enables early checking of all cross-references. Note that
12+
this option only takes affect if **relative_crossrefs** is also true. This option is
13+
true by default, so this option is used to disable checking. Checking can
14+
also be disabled on a per-case basis by prefixing the reference with '?', e.g.
15+
`[something][?dontcheckme]`.
1016

1117
!!! Example "mkdocs.yml plugins specification using this handler"
1218

@@ -25,6 +31,7 @@ plugins:
2531
ignore_init_summary: yes
2632
merge_init_into_class: yes
2733
relative_crossrefs: yes
34+
check_crossrefs: no
2835
separate_signature: yes
2936
show_source: no
3037
show_root_full_path: no

docs/index.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,27 @@ These are demonstrated here:
101101
This has been [proposed as a feature in the standard python handler][relative-crossref-issue]
102102
but has not yet been accepted.
103103

104+
## Cross-reference checking
105+
106+
If `relative_crossrefs` and `check_crossrefs` are both enabled (the latter is true by default),
107+
then all cross-reference expressions will be checked to ensure that they exist and failures
108+
will be reported with the source location. Otherwise, missing cross-references will be reported
109+
by mkdocstrings without the source location, in which case it is often difficult to locate the source
110+
of the error. Note that the errors generatoed by this feat[.gitignore](..%2F.gitignore)
111+
112+
113+
114+
ure are in addition to the errors
115+
from mkdocstrings.
116+
117+
The current implementation of this feature can produce false errors for definitions from the
118+
python standard library. You can disable the check on a case-by-case basis by prefixing the
119+
reference expression with a `?`, for example:
120+
121+
```
122+
This function returns a [Path][?pathlib.] instance.
123+
```
124+
104125
[mkdocstrings]: https://mkdocstrings.github.io/
105126
[mkdocstrings_python]: https://mkdocstrings.github.io/python/
106127
[relative-crossref-issue]: https://github.com/mkdocstrings/python/issues/27
Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
1-
1.5.3
2-
3-
1+
1.6.0

src/mkdocstrings_handlers/python_xref/crossref.py

Lines changed: 54 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import re
1919
import sys
20-
from typing import Callable, List, Optional, Union, cast
20+
from typing import Callable, List, Optional, cast
2121

2222
from griffe.dataclasses import Docstring, Object
2323
from mkdocstrings.loggers import get_logger
@@ -57,8 +57,10 @@ def _re_named(name: str, exp: str, optional: bool = False) -> str:
5757
optchar = "?" if optional else ""
5858
return f"(?P<{name}>{exp}){optchar}"
5959

60+
_RE_CROSSREF = re.compile(r"\[([^\[\]]+?)\]\[([^\[\]]*?)\]")
61+
"""Regular expression that matches general cross-references."""
6062

61-
_RE_REL_CROSSREF = re.compile(r"\[([^\[\]]+?)\]\[([\.^\(][^\]]*?|[^\]]*?\.)\]")
63+
_RE_REL_CROSSREF = re.compile(r"\[([^\[\]]+?)\]\[(\??(?:[\.^\(][^\]]*?|[^\]]*?\.))\]")
6264
"""Regular expression that matches relative cross-reference expressions in doc-string.
6365
6466
This will match a cross reference where the path expression either ends in '.'
@@ -98,13 +100,13 @@ def _re_named(name: str, exp: str, optional: bool = False) -> str:
98100
"""Regular expression that matches a qualified python identifier."""
99101

100102

101-
def _always_ok(_ref:str) -> bool:
103+
def _always_ok(_ref: str) -> bool:
102104
return True
103105

104106

105107
class _RelativeCrossrefProcessor:
106108
"""
107-
A callable object that substitutes relative cross-reference expressions.
109+
A callable object that can substitute relative cross-reference expressions.
108110
109111
This is intended to be used as a substitution function by `re.sub`
110112
to process relative cross-references in a doc-string.
@@ -116,7 +118,7 @@ class _RelativeCrossrefProcessor:
116118
_cur_offset: int
117119
_cur_ref_parts: List[str]
118120
_ok: bool
119-
_check_ref: Union[Callable[[str],bool],Callable[[str],bool]]
121+
_check_ref: Callable[[str], bool]
120122

121123
def __init__(self, doc: Docstring, checkref: Optional[Callable[[str], bool]] = None):
122124
self._doc = doc
@@ -128,27 +130,52 @@ def __init__(self, doc: Docstring, checkref: Optional[Callable[[str], bool]] = N
128130
self._ok = True
129131

130132
def __call__(self, match: re.Match) -> str:
133+
"""
134+
Process a cross-reference expression.
135+
136+
This should be called with a match from the _RE_CROSSREF expression
137+
which matches expression of the form [<title>][<ref>].
138+
Group 1 matches the <title> and 2 the <ref>.
139+
"""
131140
self._start_match(match)
132141

133142
title = match[1]
134143
ref = match[2]
135144

136-
ref_match = _RE_REL.fullmatch(ref)
137-
if ref_match is None:
138-
self._error(f"Bad syntax in relative cross reference: '{ref}'")
145+
checkref = self._check_ref
146+
if ref.startswith("?"):
147+
# Turn off cross-ref check
148+
ref = ref[1:]
149+
checkref = _always_ok
150+
151+
new_ref = ""
152+
153+
# TODO support special syntax to turn off checking
154+
155+
if not _RE_REL_CROSSREF.fullmatch(match.group(0)):
156+
# Just a regular cross reference
157+
new_ref = ref if ref else title
139158
else:
140-
self._process_parent_specifier(ref_match)
141-
self._process_relname(ref_match)
142-
self._process_append_from_title(ref_match, title)
143-
144-
if self._ok:
145-
new_ref = '.'.join(self._cur_ref_parts)
146-
logger.debug(
147-
"cross-reference substitution\nin %s:\n[%s][%s] -> [...][%s]",
148-
cast(Object,self._doc.parent).canonical_path, title, ref, new_ref
149-
)
150-
if not self._check_ref(new_ref):
151-
self._error(f"Cannot load reference '{new_ref}'")
159+
ref_match = _RE_REL.fullmatch(ref)
160+
if ref_match is None:
161+
self._error(f"Bad syntax in relative cross reference: '{ref}'")
162+
else:
163+
self._process_parent_specifier(ref_match)
164+
self._process_relname(ref_match)
165+
self._process_append_from_title(ref_match, title)
166+
167+
if self._ok:
168+
new_ref = '.'.join(self._cur_ref_parts)
169+
logger.debug(
170+
"cross-reference substitution\nin %s:\n[%s][%s] -> [...][%s]",
171+
cast(Object, self._doc.parent).canonical_path, title, ref, new_ref
172+
)
173+
174+
# builtin names get handled specially somehow, so don't check here
175+
if new_ref not in __builtins__ and not checkref(new_ref): # type: ignore[operator]
176+
self._error(f"Cannot load reference '{new_ref}'")
177+
178+
if new_ref:
152179
result = f"[{title}][{new_ref}]"
153180
else:
154181
result = match.group(0)
@@ -265,7 +292,7 @@ def _process_up_specifier(self, obj: Object, ref_match: re.Match) -> Optional[Ob
265292
break
266293
return rel_obj
267294

268-
def _error(self, msg: str, just_warn:bool = False) -> None:
295+
def _error(self, msg: str, just_warn: bool = False) -> None:
269296
"""Logs a warning for a specific crossref in a docstring.
270297
271298
This will include the filepath and line number if available.
@@ -281,8 +308,8 @@ def _error(self, msg: str, just_warn:bool = False) -> None:
281308
# recognize that this is a navigable location it can highlight.
282309
prefix = f"file://{parent.filepath}:"
283310
line = doc.lineno
284-
if line is not None: # pragma: no branch
285-
if _supports_linenums: # pragma: no branch
311+
if line is not None: # pragma: no branch
312+
if _supports_linenums: # pragma: no branch
286313
# Add line offset to match in docstring. This can still be
287314
# short if the doc string has leading newlines.
288315
line += doc.value.count("\n", 0, self._cur_offset)
@@ -296,7 +323,7 @@ def _error(self, msg: str, just_warn:bool = False) -> None:
296323
self._ok = just_warn
297324

298325

299-
def substitute_relative_crossrefs(obj: Object, checkref: Optional[Callable[[str],bool]] = None) -> None:
326+
def substitute_relative_crossrefs(obj: Object, checkref: Optional[Callable[[str], bool]] = None) -> None:
300327
"""Recursively expand relative cross-references in all docstrings in tree.
301328
302329
Arguments:
@@ -307,9 +334,8 @@ def substitute_relative_crossrefs(obj: Object, checkref: Optional[Callable[[str]
307334
doc = obj.docstring
308335

309336
if doc is not None:
310-
doc.value = _RE_REL_CROSSREF.sub(_RelativeCrossrefProcessor(doc, checkref=checkref), doc.value)
337+
doc.value = _RE_CROSSREF.sub(_RelativeCrossrefProcessor(doc, checkref=checkref), doc.value)
311338

312339
for member in obj.members.values():
313-
if isinstance(member, Object): # pragma: no branch
314-
substitute_relative_crossrefs(member, checkref= checkref)
315-
340+
if isinstance(member, Object): # pragma: no branch
341+
substitute_relative_crossrefs(member, checkref=checkref)

src/mkdocstrings_handlers/python_xref/handler.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414
"""
15-
Implementation of GarpyPythonHandler
15+
Implementation of python_xref handler
1616
"""
1717

1818
from __future__ import annotations
@@ -35,13 +35,17 @@
3535

3636
class PythonRelXRefHandler(PythonHandler):
3737
"""Extended version of mkdocstrings Python handler
38+
39+
* Converts relative cross-references into full references
40+
* Checks cross-references early in order to produce errors with source location
3841
"""
3942

4043
handler_name: str = __name__.rsplit('.', 2)[1]
4144

4245
default_config = dict(
4346
PythonHandler.default_config,
4447
relative_crossrefs = False,
48+
check_crossrefs = True,
4549
)
4650

4751
def __init__(self,
@@ -65,9 +69,14 @@ def render(self, data: Object, config: Mapping[str,Any]) -> str:
6569
final_config = ChainMap(config, self.default_config) # type: ignore[arg-type]
6670

6771
if final_config["relative_crossrefs"]:
68-
substitute_relative_crossrefs(data, checkref=self._check_ref)
72+
checkref = self._check_ref if final_config["check_crossrefs"] else None
73+
substitute_relative_crossrefs(data, checkref=checkref)
6974

70-
return super().render(data, config)
75+
try:
76+
return super().render(data, config)
77+
except Exception: # pragma: no cover
78+
print(f"{data.path=}")
79+
raise
7180

7281
def get_templates_dir(self, handler: Optional[str] = None) -> Path:
7382
"""See [render][.barf]"""

tests/test_crossref.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
# noinspection PyProtectedMember
2828
import mkdocstrings_handlers.python_xref.crossref
2929
from mkdocstrings_handlers.python_xref.crossref import (
30+
_RE_CROSSREF,
3031
_RE_REL_CROSSREF,
3132
_RelativeCrossrefProcessor,
3233
substitute_relative_crossrefs,
@@ -50,7 +51,9 @@ def test_RelativeCrossrefProcessor(caplog: pytest.LogCaptureFixture, monkeypatch
5051

5152
def assert_sub(parent: Object, title: str, ref: str,
5253
expected: str = "",
54+
*,
5355
warning: str = "",
56+
relative: bool = True,
5457
checkref: Optional[Callable[[str],bool]] = None
5558
) -> None:
5659
"""Tests a relative crossref substitution
@@ -61,14 +64,20 @@ def assert_sub(parent: Object, title: str, ref: str,
6164
ref: the reference path section of the cross-reference expression
6265
expected: the expected new value for the cross-reference
6366
warning: if specified, is regexp matching expected warning message
67+
relative: true if relative reference is expected
6468
checkref: reference checking function
6569
"""
6670
if not expected:
6771
expected = ref
6872
crossref = f"[{title}][{ref}]"
6973
doc = Docstring(parent=parent, value=f"subject\n\n{crossref}\n", lineno=42)
7074
match = _RE_REL_CROSSREF.search(doc.value)
71-
assert match is not None
75+
if relative:
76+
assert match is not None
77+
else:
78+
assert match is None
79+
match = _RE_CROSSREF.search(doc.value)
80+
assert match is not None
7281
caplog.clear()
7382
actual = _RelativeCrossrefProcessor(doc, checkref=checkref)(match)
7483
if warning:
@@ -97,6 +106,16 @@ def assert_sub(parent: Object, title: str, ref: str,
97106
assert_sub(meth1, "Class1", "(p).mod2.", "mod1.mod2.Class1")
98107
assert_sub(mod1, "Class1", "(P).mod2.Class1", "mod1.mod2.Class1")
99108

109+
# disable checking
110+
111+
def assert_nocheck(val: str) -> bool:
112+
pytest.fail(f"unexpected check of '{val}'")
113+
return False
114+
115+
assert_sub(cls1, "foo", "?.", "mod1.mod2.Class1.foo", checkref=assert_nocheck)
116+
assert_sub(cls1, "foo", "?mod1.mod2.Class1.foo", "mod1.mod2.Class1.foo",
117+
checkref=assert_nocheck, relative=False)
118+
100119
# Error cases
101120

102121
assert_sub(meth1, "foo", ".", ".", warning="Cannot use '.'")
@@ -108,6 +127,10 @@ def assert_sub(parent: Object, title: str, ref: str,
108127
assert_sub(meth1, "foo", "..", "mod1.mod2.Class1.foo",
109128
warning = "Cannot load reference 'mod1.mod2.Class1.foo'",
110129
checkref=lambda x: False)
130+
assert_sub(meth1, "foo", "mod1.mod2.Class1.foo", "mod1.mod2.Class1.foo",
131+
warning = "Cannot load reference 'mod1.mod2.Class1.foo'",
132+
relative=False,
133+
checkref=lambda x: False)
111134

112135

113136
def test_substitute_relative_crossrefs(caplog: pytest.LogCaptureFixture) -> None:

tests/test_handler.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ def fake_render(_self: PythonHandler, data: Object, _config: dict) -> str:
8080
assert data.docstring is not None
8181
return data.docstring.value
8282

83+
# Monkeypatch render/collect methods on parent class
8384
monkeypatch.setattr(PythonHandler, 'collect', fake_collect)
8485
monkeypatch.setattr(PythonHandler, 'render', fake_render)
8586

@@ -95,9 +96,32 @@ def fake_render(_self: PythonHandler, data: Object, _config: dict) -> str:
9596

9697
rendered = handler.render(obj, dict(relative_crossrefs=True))
9798
assert rendered == "[foo][mod.foo] [bar][bad.bar]"
99+
assert len(caplog.records) == 1
100+
_, level, msg = caplog.record_tuples[0]
101+
assert level == logging.WARNING
102+
assert "Cannot load reference 'bad.bar'" in msg
103+
caplog.clear()
104+
105+
rendered = handler.render(obj, dict(relative_crossrefs=True, check_crossrefs=False))
106+
assert rendered == "[foo][mod.foo] [bar][bad.bar]"
107+
assert len(caplog.records) == 0
98108

109+
rendered = handler.render(obj, dict(relative_crossrefs=True, check_crossrefs=False))
110+
assert rendered == "[foo][mod.foo] [bar][bad.bar]"
111+
assert len(caplog.records) == 0
112+
113+
docstring = "\n\n[foo][bad.foo]"
114+
obj.docstring = Docstring(docstring, parent=obj)
115+
rendered = handler.render(obj, dict(relative_crossrefs=True))
116+
assert rendered == "[foo][bad.foo]"
99117
assert len(caplog.records) == 1
100118
_, level, msg = caplog.record_tuples[0]
101119
assert level == logging.WARNING
102-
assert 'Cannot load reference' in msg
120+
assert "Cannot load reference 'bad.foo'" in msg
121+
caplog.clear()
103122

123+
docstring = "[foo][?bad.foo] [bar][?bad.]"
124+
obj.docstring = Docstring(docstring, parent=obj)
125+
rendered = handler.render(obj, dict(relative_crossrefs=True, check_crossrefs=True))
126+
assert rendered == "[foo][bad.foo] [bar][bad.bar]"
127+
assert len(caplog.records) == 0

0 commit comments

Comments
 (0)