Skip to content

Commit e5c78c0

Browse files
Improved line/col information (38)
1 parent f7d54ca commit e5c78c0

File tree

8 files changed

+186
-22
lines changed

8 files changed

+186
-22
lines changed

.idea/garpy.mkdocstrings.iml

+7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

CHANGELOG.md

+8-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
# mkdocstring-python-xref changes
22

3+
*Note that versions roughly correspond to the version of mkdocstrings-python that they
4+
are compatible with.*
5+
6+
## 1.16.2
7+
8+
* Improved source locations for errors in docstrings now including column offset.
9+
310
## 1.16.1
411

512
* Fix sdist distributions (should enable conda-forge to build)
613

714
## 1.16.0
815

916
* Compatibility with mkdocstrings-python 1.16.*
10-
* Removed some deprecated imports from mkdoctrings
17+
* Removed some deprecated imports from mkdocstrings
1118

1219
## 1.14.1
1320

docs/index.md

+1-5
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,7 @@ If `relative_crossrefs` and `check_crossrefs` are both enabled (the latter is tr
107107
then all cross-reference expressions will be checked to ensure that they exist and failures
108108
will be reported with the source location. Otherwise, missing cross-references will be reported
109109
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
110+
of the error. Note that the errors generated by this feature are in addition to the errors
115111
from mkdocstrings.
116112

117113
The current implementation of this feature can produce false errors for definitions from the
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.16.1
1+
1.16.2

src/mkdocstrings_handlers/python_xref/crossref.py

+61-7
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from __future__ import annotations
1717

1818
import re
19+
from ast import literal_eval
1920
from typing import Callable, List, Optional, cast
2021

2122
from griffe import Docstring, Object
@@ -303,14 +304,12 @@ def _error(self, msg: str, just_warn: bool = False) -> None:
303304
# We include the file:// prefix because it helps IDEs such as PyCharm
304305
# recognize that this is a navigable location it can highlight.
305306
prefix = f"file://{parent.filepath}:"
306-
line = doc.lineno
307-
if line is not None: # pragma: no branch
308-
# Add line offset to match in docstring. This can still be
309-
# short if the doc string has leading newlines.
310-
line += doc.value.count("\n", 0, self._cur_offset)
307+
line, col = doc_value_offset_to_location(doc, self._cur_offset)
308+
if line >= 0:
311309
prefix += f"{line}:"
312-
# It would be nice to add the column as well, but we cannot determine
313-
# that without knowing how much the doc string was unindented.
310+
if col >= 0:
311+
prefix += f"{col}:"
312+
314313
prefix += " \n"
315314

316315
logger.warning(prefix + msg)
@@ -334,3 +333,58 @@ def substitute_relative_crossrefs(obj: Object, checkref: Optional[Callable[[str]
334333
for member in obj.members.values():
335334
if isinstance(member, Object): # pragma: no branch
336335
substitute_relative_crossrefs(member, checkref=checkref)
336+
337+
def doc_value_offset_to_location(doc: Docstring, offset: int) -> tuple[int,int]:
338+
"""
339+
Converts offset into doc.value to line and column in source file.
340+
341+
Returns:
342+
line and column or else (-1,-1) if it cannot be computed
343+
"""
344+
linenum = -1
345+
colnum = -1
346+
347+
if doc.lineno is not None:
348+
linenum = doc.lineno # start of the docstring source
349+
# line offset with respect to start of cleaned up docstring
350+
lineoffset = clean_lineoffset = doc.value.count("\n", 0, offset)
351+
352+
# look at original doc source, if available
353+
try:
354+
source = doc.source
355+
# compute docstring without cleaning up spaces and indentation
356+
rawvalue = str(literal_eval(source))
357+
358+
# adjust line offset by number of lines removed from front of docstring
359+
lineoffset += leading_space(rawvalue).count("\n")
360+
361+
if lineoffset == 0 and (m := re.match(r"(\s*['\"]{3}\s*)\S", source)):
362+
# is on the same line as opening triple quote
363+
colnum = offset + len(m.group(1))
364+
else:
365+
# indentation of first non-empty line in raw and cleaned up strings
366+
raw_line = rawvalue.splitlines()[lineoffset]
367+
clean_line = doc.value.splitlines()[clean_lineoffset]
368+
raw_indent = len(leading_space(raw_line))
369+
clean_indent = len(leading_space(clean_line))
370+
try:
371+
linestart = doc.value.rindex("\n", 0, offset) + 1
372+
except ValueError: # pragma: no cover
373+
linestart = 0 # paranoid check, should not really happen
374+
colnum = offset - linestart + raw_indent - clean_indent
375+
376+
except Exception:
377+
# Don't expect to get here, but just in case, it is better to
378+
# not fix up the line/column than to die.
379+
pass
380+
381+
linenum += lineoffset
382+
383+
return linenum, colnum
384+
385+
386+
def leading_space(s: str) -> str:
387+
"""Returns whitespace at the front of string."""
388+
if m := re.match(r"\s*", s):
389+
return m[0]
390+
return "" # pragma: no cover

tests/project/src/myproj/bar.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2022. Analog Devices Inc.
1+
# Copyright (c) 2022-2025. Analog Devices Inc.
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.
@@ -33,3 +33,11 @@ def foo(self) -> None:
3333
def func() -> None:
3434
"""This is a function in the [bar][(m)] module."""
3535

36+
37+
class Bad:
38+
"""More bad references"""
39+
def bad_ref_leading_space(self) -> None:
40+
"""
41+
42+
This is a [bad][.] reference with leading space
43+
"""

tests/test_crossref.py

+91-2
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,20 @@
1818
import inspect
1919
import logging
2020
import re
21+
from ast import literal_eval
2122
from pathlib import Path
23+
from textwrap import dedent
2224
from typing import Callable, Optional
2325

2426
import pytest
25-
from griffe import Class, Docstring, Function, Module, Object
27+
from griffe import Class, Docstring, Function, Module, Object, LinesCollection
2628

2729
# noinspection PyProtectedMember
2830
from mkdocstrings_handlers.python_xref.crossref import (
2931
_RE_CROSSREF,
3032
_RE_REL_CROSSREF,
3133
_RelativeCrossrefProcessor,
32-
substitute_relative_crossrefs,
34+
substitute_relative_crossrefs, doc_value_offset_to_location,
3335
)
3436

3537
def test_RelativeCrossrefProcessor(caplog: pytest.LogCaptureFixture) -> None:
@@ -153,6 +155,7 @@ def test_substitute_relative_crossrefs(caplog: pytest.LogCaptureFixture) -> None
153155
""",
154156
parent=meth1,
155157
lineno=42,
158+
endlineno=45,
156159
)
157160

158161
mod1.docstring = Docstring(
@@ -161,6 +164,7 @@ def test_substitute_relative_crossrefs(caplog: pytest.LogCaptureFixture) -> None
161164
""",
162165
parent=mod1,
163166
lineno=23,
167+
endlineno=25,
164168
)
165169

166170
substitute_relative_crossrefs(mod1)
@@ -173,3 +177,88 @@ def test_substitute_relative_crossrefs(caplog: pytest.LogCaptureFixture) -> None
173177
)
174178

175179
assert len(caplog.records) == 0
180+
181+
def make_docstring_from_source(
182+
source: str,
183+
*,
184+
lineno: int = 1,
185+
mod_name: str = "mod",
186+
mod_dir: Path = Path(""),
187+
) -> Docstring:
188+
"""
189+
Create a docstring object from source code.
190+
191+
Args:
192+
source: raw source code containing docstring source lines
193+
lineno: line number of docstring starting quotes
194+
mod_name: name of module
195+
mod_dir: module directory
196+
"""
197+
filepath = mod_dir.joinpath(mod_name).with_suffix(".py")
198+
parent = Object("", lines_collection=LinesCollection())
199+
mod = Module(name=mod_name, filepath=filepath, parent=parent)
200+
lines = source.splitlines(keepends=False)
201+
if lineno > 1:
202+
# Insert empty lines to pad to the desired line number
203+
lines = [""] * (lineno - 1) + lines
204+
mod.lines_collection[filepath] = lines
205+
doc = Docstring(
206+
parent=mod,
207+
value=inspect.cleandoc(literal_eval(source)),
208+
lineno=lineno,
209+
endlineno=len(lines)
210+
)
211+
return doc
212+
213+
def test_doc_value_offset_to_location() -> None:
214+
"""Unit test for _doc_value_offset_to_location."""
215+
doc1 = make_docstring_from_source(
216+
dedent(
217+
'''
218+
"""first
219+
second
220+
third
221+
"""
222+
'''
223+
).lstrip("\n"),
224+
)
225+
226+
assert doc_value_offset_to_location(doc1, 0) == (1, 3)
227+
assert doc_value_offset_to_location(doc1, 3) == (1, 6)
228+
assert doc_value_offset_to_location(doc1, 7) == (2, 1)
229+
assert doc_value_offset_to_location(doc1, 15) == (3, 2)
230+
231+
doc2 = make_docstring_from_source(
232+
dedent(
233+
'''
234+
""" first
235+
second
236+
third
237+
""" # a comment
238+
# another comment
239+
'''
240+
).lstrip("\n"),
241+
lineno=3,
242+
)
243+
244+
assert doc_value_offset_to_location(doc2, 0) == (3, 9)
245+
assert doc_value_offset_to_location(doc2, 6) == (4, 6)
246+
assert doc_value_offset_to_location(doc2, 15) == (5, 8)
247+
248+
# Remove parent so that source is not available
249+
doc2.parent = None
250+
assert doc_value_offset_to_location(doc2, 0) == (3, -1)
251+
252+
doc3 = make_docstring_from_source(
253+
dedent(
254+
"""
255+
'''
256+
first
257+
second
258+
'''
259+
"""
260+
).lstrip("\n"),
261+
)
262+
263+
assert doc_value_offset_to_location(doc3, 0) == (2, 4)
264+
assert doc_value_offset_to_location(doc3, 6) == (3, 2)

tests/test_integration.py

+8-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (c) 2022-2024. Analog Devices Inc.
1+
# Copyright (c) 2022-2025. Analog Devices Inc.
22
#
33
# Licensed under the Apache License, Version 2.0 (the "License");
44
# you may not use this file except in compliance with the License.
@@ -77,17 +77,20 @@ def test_integration(tmpdir: PathLike) -> None:
7777
assert result.returncode == 0
7878

7979
m = re.search(
80-
r"WARNING.*file://(/.*/myproj/bar.py):(\d+):\s*\n\s*Cannot load reference '(.*)'",
80+
r"WARNING.*file://(/.*/myproj/bar.py):(\d+):(\d+):\s*\n\s*Cannot load reference '(.*)'",
8181
result.stderr
8282
)
8383
assert m is not None
8484
if os.path.sep == '/':
8585
assert m[1] == str(bar_src_file)
86-
assert m[3] == 'myproj.bar.bad'
86+
assert m[4] == 'myproj.bar.bad'
8787
# Source location not accurate in python 3.7
88-
bad_line = int(m[2])
88+
bad_linenum = int(m[2])
89+
bad_col = int(m[3])
8990
bar_lines = bar_src_file.read_text().splitlines()
90-
assert '[bad]' in bar_lines[bad_line - 1]
91+
bad_line = bar_lines[bad_linenum - 1]
92+
assert '[bad]' in bad_line
93+
assert bad_line[bad_col:].startswith('[bad]')
9194

9295
bar_html = site_dir.joinpath('bar', 'index.html').read_text()
9396
bar_bs = bs4.BeautifulSoup(bar_html, 'html.parser')

0 commit comments

Comments
 (0)