Skip to content

Commit a1f8756

Browse files
committed
Remove the fake line numbers from tracebacks by always using the line number from the source file
This is done by inserting blank lines in the Python code that's exec:ed to match the source file location, letting all of the trace frames use the line number from the traceback object
1 parent becd506 commit a1f8756

File tree

4 files changed

+105
-45
lines changed

4 files changed

+105
-45
lines changed

src/pytest_markdown_docs/plugin.py

Lines changed: 42 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class FenceSyntax(Enum):
3535

3636
@dataclass
3737
class FenceTest:
38-
code_block: str
38+
source: str
3939
fixture_names: typing.List[str]
4040
start_line: int
4141

@@ -47,6 +47,19 @@ class ObjectTest:
4747
fence_test: FenceTest
4848

4949

50+
def get_docstring_start_line(obj) -> int:
51+
# Get the source lines and the starting line number of the object
52+
source_lines, start_line = inspect.getsourcelines(obj)
53+
54+
# Find the line in the source code that starts with triple quotes (""" or ''')
55+
for idx, line in enumerate(source_lines):
56+
line = line.strip()
57+
if line.startswith(('"""', "'''")):
58+
return start_line + idx # Return the starting line number
59+
60+
return None # Docstring not found in source
61+
62+
5063
class MarkdownInlinePythonItem(pytest.Item):
5164
def __init__(
5265
self,
@@ -55,15 +68,13 @@ def __init__(
5568
code: str,
5669
fixture_names: typing.List[str],
5770
start_line: int,
58-
fake_line_numbers: bool,
5971
) -> None:
6072
super().__init__(name, parent)
6173
self.add_marker(MARKER_NAME)
6274
self.code = code
6375
self.obj = None
6476
self.user_properties.append(("code", code))
6577
self.start_line = start_line
66-
self.fake_line_numbers = fake_line_numbers
6778
self.fixturenames = fixture_names
6879
self.nofuncargs = True
6980

@@ -115,61 +126,47 @@ def repr_failure(
115126
excinfo: ExceptionInfo[BaseException],
116127
style=None,
117128
):
118-
rawlines = self.code.split("\n")
129+
rawlines = self.code.rstrip("\n").split("\n")
119130

120131
# custom formatted traceback to translate line numbers and markdown files
121132
traceback_lines = []
122133
stack_summary = traceback.StackSummary.extract(traceback.walk_tb(excinfo.tb))
123134
start_capture = False
124135

125-
start_line = 0 if self.fake_line_numbers else self.start_line
136+
start_line = self.start_line
126137

127138
for frame_summary in stack_summary:
128139
if frame_summary.filename == str(self.path):
129-
lineno = (frame_summary.lineno or 0) + start_line
130-
start_capture = (
131-
True # start capturing frames the first time we enter user code
132-
)
133-
line = (
134-
rawlines[frame_summary.lineno - 1]
135-
if frame_summary.lineno is not None
136-
and 1 <= frame_summary.lineno <= len(rawlines)
137-
else ""
138-
)
139-
else:
140-
lineno = frame_summary.lineno or 0
141-
line = frame_summary.line or ""
140+
# start capturing frames the first time we enter user code
141+
start_capture = True
142142

143143
if start_capture:
144+
lineno = frame_summary.lineno
145+
line = frame_summary.line
144146
linespec = f"line {lineno}"
145-
if self.fake_line_numbers:
146-
linespec = f"code block line {lineno}*"
147-
148147
traceback_lines.append(
149148
f""" File "{frame_summary.filename}", {linespec}, in {frame_summary.name}"""
150149
)
151150
traceback_lines.append(f" {line.lstrip()}")
152151

153-
maxnum = len(str(len(rawlines) + start_line + 1))
152+
maxdigits = len(str(len(rawlines)))
153+
code_margin = " "
154154
numbered_code = "\n".join(
155155
[
156-
f"{i:>{maxnum}} {line}"
157-
for i, line in enumerate(rawlines, start_line + 1)
156+
f"{i:>{maxdigits}}{code_margin}{line}"
157+
for i, line in enumerate(rawlines[start_line:], start_line + 1)
158158
]
159159
)
160160

161161
pretty_traceback = "\n".join(traceback_lines)
162-
note = ""
163-
if self.fake_line_numbers:
164-
note = ", *-denoted line numbers refer to code block"
165-
pt = f"""Traceback (most recent call last{note}):
162+
pt = f"""Traceback (most recent call last):
166163
{pretty_traceback}
167164
{excinfo.exconly()}"""
168165

169166
return f"""Error in code block:
170-
```
167+
{maxdigits * " "}{code_margin}```
171168
{numbered_code}
172-
```
169+
{maxdigits * " "}{code_margin}```
173170
{pt}
174171
"""
175172

@@ -179,6 +176,7 @@ def reportinfo(self):
179176

180177
def extract_fence_tests(
181178
markdown_string: str,
179+
start_line_offset: int,
182180
markdown_type: str = "md",
183181
fence_syntax: FenceSyntax = FenceSyntax.default,
184182
) -> typing.Generator[FenceTest, None, None]:
@@ -192,7 +190,6 @@ def extract_fence_tests(
192190
if block.type != "fence" or not block.map:
193191
continue
194192

195-
start_line = block.map[0] + 1 # skip the info line when counting
196193
if fence_syntax == FenceSyntax.superfences:
197194
code_info = parse_superfences_block_info(block.info)
198195
else:
@@ -216,11 +213,14 @@ def extract_fence_tests(
216213
code_options |= extract_options_from_mdx_comment(tokens[i - 2].content)
217214

218215
if lang in ("py", "python", "python3") and "notest" not in code_options:
219-
code_block = block.content
216+
start_line = (
217+
start_line_offset + block.map[0] + 1
218+
) # actual code starts on +1 from the "info" line
219+
if "continuation" not in code_options:
220+
prev = ""
220221

221-
if "continuation" in code_options:
222-
code_block = prev + code_block
223-
start_line = -1 # this disables proper line numbers, TODO: adjust line numbers *per snippet*
222+
add_blank_lines = start_line - prev.count("\n")
223+
code_block = prev + ("\n" * add_blank_lines) + block.content
224224

225225
fixture_names = [
226226
f[len("fixture:") :] for f in code_options if f.startswith("fixture:")
@@ -291,11 +291,10 @@ def collect(self):
291291
fence_test = object_test.fence_test
292292
yield MarkdownInlinePythonItem.from_parent(
293293
self,
294-
name=f"{object_test.object_name}[CodeBlock#{object_test.intra_object_index+1}][rel.line:{fence_test.start_line}]",
295-
code=fence_test.code_block,
294+
name=f"{object_test.object_name}[CodeFence#{object_test.intra_object_index+1}][line:{fence_test.start_line}]",
295+
code=fence_test.source,
296296
fixture_names=fence_test.fixture_names,
297297
start_line=fence_test.start_line,
298-
fake_line_numbers=True, # TODO: figure out where docstrings are in file to offset line numbers properly
299298
)
300299

301300
def find_object_tests_recursive(
@@ -304,14 +303,15 @@ def find_object_tests_recursive(
304303
docstr = inspect.getdoc(object)
305304

306305
if docstr:
306+
docstring_offset = get_docstring_start_line(object)
307307
obj_name = (
308308
getattr(object, "__qualname__", None)
309309
or getattr(object, "__name__", None)
310310
or "<Unnamed obj>"
311311
)
312312
fence_syntax = FenceSyntax(self.config.option.markdowndocs_syntax)
313313
for i, fence_test in enumerate(
314-
extract_fence_tests(docstr, fence_syntax=fence_syntax)
314+
extract_fence_tests(docstr, docstring_offset, fence_syntax=fence_syntax)
315315
):
316316
yield ObjectTest(i, obj_name, fence_test)
317317

@@ -335,17 +335,17 @@ def collect(self):
335335
for i, fence_test in enumerate(
336336
extract_fence_tests(
337337
markdown_content,
338+
start_line_offset=0,
338339
markdown_type=self.path.suffix.replace(".", ""),
339340
fence_syntax=fence_syntax,
340341
)
341342
):
342343
yield MarkdownInlinePythonItem.from_parent(
343344
self,
344-
name=f"[CodeBlock#{i+1}][line:{fence_test.start_line}]",
345-
code=fence_test.code_block,
345+
name=f"[CodeFence#{i+1}][line:{fence_test.start_line}]",
346+
code=fence_test.source,
346347
fixture_names=fence_test.fixture_names,
347348
start_line=fence_test.start_line,
348-
fake_line_numbers=fence_test.start_line == -1,
349349
)
350350

351351

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,10 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
15
pytest_plugins = ["pytester"]
6+
7+
8+
@pytest.fixture()
9+
def support_dir():
10+
return Path(__file__).parent / "support"

tests/plugin_test.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import re
2+
3+
from _pytest.pytester import LineMatcher
4+
25
import pytest_markdown_docs # hack: used for storing a side effect in one of the tests
36

47

@@ -122,16 +125,15 @@ def bar():
122125
# we check the traceback vs a regex pattern since the file paths can change
123126
expected_output_pattern = r"""
124127
Error in code block:
125-
```
128+
```
126129
4 def foo\(\):
127130
5 raise Exception\("doh"\)
128131
6
129132
7 def bar\(\):
130133
8 foo\(\)
131134
9
132135
10 foo\(\)
133-
11
134-
```
136+
```
135137
Traceback \(most recent call last\):
136138
File ".*/test_traceback.md", line 10, in <module>
137139
foo\(\)
@@ -391,3 +393,41 @@ def simple():
391393
)
392394
result = testdir.runpytest("--markdown-docs", "--markdown-docs-syntax=superfences")
393395
result.assert_outcomes(passed=2)
396+
397+
398+
def test_error_origin_after_docstring_traceback(testdir, support_dir):
399+
sample_file = support_dir / "docstring_error_after.py"
400+
testdir.makepyfile(**{sample_file.stem: sample_file.read_text()})
401+
result = testdir.runpytest("-v", "--markdown-docs")
402+
403+
data: LineMatcher = result.stdout
404+
data.re_match_lines(
405+
[
406+
r"Traceback \(most recent call last\):",
407+
r'\s*File ".*/docstring_error_after.py", line 5, in <module>',
408+
r"\s*docstring_error_after.error_after\(\)",
409+
r'\s*File ".*/docstring_error_after.py", line 10, in error_after',
410+
r'\s*raise Exception\("bar"\)',
411+
r"\s*Exception: bar",
412+
],
413+
consecutive=True,
414+
)
415+
416+
417+
def test_error_origin_before_docstring_traceback(testdir, support_dir):
418+
sample_file = support_dir / "docstring_error_before.py"
419+
testdir.makepyfile(**{sample_file.stem: sample_file.read_text()})
420+
result = testdir.runpytest("-v", "--markdown-docs")
421+
422+
data: LineMatcher = result.stdout
423+
data.re_match_lines(
424+
[
425+
r"Traceback \(most recent call last\):",
426+
r'\s*File ".*/docstring_error_before.py", line 9, in <module>',
427+
r"\s*docstring_error_before.error_before\(\)",
428+
r'\s*File ".*/docstring_error_before.py", line 2, in error_before',
429+
r'\s*raise Exception\("foo"\)',
430+
r"\s*Exception: foo",
431+
],
432+
consecutive=True,
433+
)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
def func():
2+
"""
3+
```python
4+
import docstring_error_after
5+
docstring_error_after.error_after()
6+
```
7+
"""
8+
9+
10+
def error_after():
11+
raise Exception("bar")

0 commit comments

Comments
 (0)