Skip to content

Commit 1bf9d1d

Browse files
Merge pull request #39 from analog-garage/38-better-location
Improved line/col information (38)
2 parents f7d54ca + 0730a23 commit 1bf9d1d

File tree

8 files changed

+201
-23
lines changed

8 files changed

+201
-23
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

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
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 numbers
9+
(starting at 1).
10+
311
## 1.16.1
412

513
* Fix sdist distributions (should enable conda-forge to build)
614

715
## 1.16.0
816

917
* Compatibility with mkdocstrings-python 1.16.*
10-
* Removed some deprecated imports from mkdoctrings
18+
* Removed some deprecated imports from mkdocstrings
1119

1220
## 1.14.1
1321

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

+73-8
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515

1616
from __future__ import annotations
1717

18+
import ast
1819
import re
19-
from typing import Callable, List, Optional, cast
20+
import sys
21+
from typing import Any, Callable, List, Optional, cast
2022

2123
from griffe import Docstring, Object
2224
from mkdocstrings import get_logger
@@ -303,14 +305,12 @@ def _error(self, msg: str, just_warn: bool = False) -> None:
303305
# We include the file:// prefix because it helps IDEs such as PyCharm
304306
# recognize that this is a navigable location it can highlight.
305307
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)
308+
line, col = doc_value_offset_to_location(doc, self._cur_offset)
309+
if line >= 0:
311310
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.
311+
if col >= 0:
312+
prefix += f"{col}:"
313+
314314
prefix += " \n"
315315

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

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

+93-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,90 @@ 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(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+
# note columns start with 1
227+
assert doc_value_offset_to_location(doc1, 0) == (1, 4)
228+
assert doc_value_offset_to_location(doc1, 3) == (1, 7)
229+
assert doc_value_offset_to_location(doc1, 7) == (2, 2)
230+
assert doc_value_offset_to_location(doc1, 15) == (3, 3)
231+
232+
doc2 = make_docstring_from_source(
233+
dedent(
234+
'''
235+
""" first
236+
second
237+
third
238+
""" # a comment
239+
240+
# another comment
241+
'''
242+
).lstrip("\n"),
243+
lineno=3,
244+
)
245+
246+
assert doc_value_offset_to_location(doc2, 0) == (3, 10)
247+
assert doc_value_offset_to_location(doc2, 6) == (4, 7)
248+
assert doc_value_offset_to_location(doc2, 15) == (5, 9)
249+
250+
# Remove parent so that source is not available
251+
doc2.parent = None
252+
assert doc_value_offset_to_location(doc2, 0) == (3, -1)
253+
254+
doc3 = make_docstring_from_source(
255+
dedent(
256+
"""
257+
'''
258+
first
259+
second
260+
'''
261+
"""
262+
).lstrip("\n"),
263+
)
264+
265+
assert doc_value_offset_to_location(doc3, 0) == (2, 5)
266+
assert doc_value_offset_to_location(doc3, 6) == (3, 3)

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]) - 1 # 1-based indexing
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)