Skip to content

Commit 5ec25e6

Browse files
Fix line numbers of code fences in docstrings (#75)
The docstring line offset assumed that the opening quotes are on their own line. Fences in docstrings with a summary on the opening line reported the line after the actual one, and fences in module docstrings reported the line before it. Docstrings with a string prefix (r""") were matched against their closing quotes, or not collected at all when the closing quotes share a line with other text.
1 parent ac7022b commit 5ec25e6

2 files changed

Lines changed: 90 additions & 2 deletions

File tree

‎src/pytest_markdown_docs/plugin.py‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,41 @@ class FenceSyntax(Enum):
3636
superfences = "superfences"
3737

3838

39+
# Opening quotes of a docstring, with an optional string prefix (e.g. r""")
40+
DOCSTRING_OPENINGS = tuple(
41+
prefix + quotes for prefix in ("", "r", "R", "u", "U") for quotes in ('"""', "'''")
42+
)
43+
44+
3945
def get_docstring_start_line(obj) -> typing.Optional[int]:
46+
"""Get the line offset of the docstring text returned by `inspect.getdoc`
47+
48+
The offset is the line number of the first line of that text, minus one.
49+
"""
4050
# Get the source lines and the starting line number of the object
4151
try:
4252
source_lines, start_line = inspect.getsourcelines(obj)
4353
except OSError:
4454
return None
4555

56+
# getsourcelines returns 0 as the starting line number of a module
57+
start_line = max(start_line, 1)
58+
59+
# inspect.getdoc strips the leading blank lines of the docstring
60+
leading_blank_lines = 0
61+
raw_docstring = getattr(obj, "__doc__", None)
62+
if isinstance(raw_docstring, str):
63+
for raw_line in raw_docstring.split("\n"):
64+
if raw_line.strip():
65+
break
66+
leading_blank_lines += 1
67+
4668
# Find the line in the source code that starts with triple quotes (""" or ''')
4769
for idx, line in enumerate(source_lines):
4870
line = line.strip()
49-
if line.startswith(('"""', "'''")):
50-
return start_line + idx # Return the starting line number
71+
if line.startswith(DOCSTRING_OPENINGS):
72+
# idx is 0-based, so start_line + idx is the line of the opening quotes
73+
return start_line + idx - 1 + leading_blank_lines
5174

5275
return None # Docstring not found in source
5376

‎tests/plugin_test.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,71 @@ def test_error_origin_before_docstring_traceback(testdir, support_dir):
491491
)
492492

493493

494+
def test_docstring_line_numbers(testdir):
495+
testdir.makepyfile(
496+
docstring_lines='''
497+
def summary_on_first_line():
498+
"""Summary line.
499+
500+
```python
501+
raise Exception("foo")
502+
```
503+
"""
504+
505+
506+
def raw_docstring():
507+
r"""Matches \\d.
508+
509+
```python
510+
raise Exception("bar")
511+
```"""
512+
'''
513+
)
514+
result = testdir.runpytest("-v", "--markdown-docs")
515+
result.assert_outcomes(failed=2)
516+
result.stdout.re_match_lines(
517+
[
518+
r".*::raw_docstring\[CodeFence#1\]\[line:13\] FAILED.*",
519+
r".*::summary_on_first_line\[CodeFence#1\]\[line:4\] FAILED.*",
520+
],
521+
consecutive=True,
522+
)
523+
result.stdout.re_match_lines(
524+
[
525+
r'\s*File ".*/docstring_lines.py", line 14, in <module>',
526+
r'\s*raise Exception\("bar"\)',
527+
],
528+
consecutive=True,
529+
)
530+
result.stdout.re_match_lines(
531+
[
532+
r'\s*File ".*/docstring_lines.py", line 5, in <module>',
533+
r'\s*raise Exception\("foo"\)',
534+
],
535+
consecutive=True,
536+
)
537+
538+
539+
def test_module_docstring_line_numbers(testdir):
540+
testdir.makepyfile(
541+
module_docstring='''
542+
"""
543+
```python
544+
raise Exception("foo")
545+
```
546+
"""
547+
'''
548+
)
549+
result = testdir.runpytest("-v", "--markdown-docs")
550+
result.assert_outcomes(failed=1)
551+
result.stdout.re_match_lines(
552+
[
553+
r".*::module_docstring\[CodeFence#1\]\[line:2\] FAILED.*",
554+
r'\s*File ".*/module_docstring.py", line 3, in <module>',
555+
]
556+
)
557+
558+
494559
def test_custom_runner(testdir):
495560
testdir.makeconftest(
496561
"""

0 commit comments

Comments
 (0)