From 30e3249ae52b36afa28a2b343e122df21056d581 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:09:11 +0000 Subject: [PATCH] Fix line numbers of code fences in docstrings 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. --- src/pytest_markdown_docs/plugin.py | 27 ++++++++++++- tests/plugin_test.py | 65 ++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/src/pytest_markdown_docs/plugin.py b/src/pytest_markdown_docs/plugin.py index 1592546..4a3f414 100644 --- a/src/pytest_markdown_docs/plugin.py +++ b/src/pytest_markdown_docs/plugin.py @@ -36,18 +36,41 @@ class FenceSyntax(Enum): superfences = "superfences" +# Opening quotes of a docstring, with an optional string prefix (e.g. r""") +DOCSTRING_OPENINGS = tuple( + prefix + quotes for prefix in ("", "r", "R", "u", "U") for quotes in ('"""', "'''") +) + + def get_docstring_start_line(obj) -> typing.Optional[int]: + """Get the line offset of the docstring text returned by `inspect.getdoc` + + The offset is the line number of the first line of that text, minus one. + """ # Get the source lines and the starting line number of the object try: source_lines, start_line = inspect.getsourcelines(obj) except OSError: return None + # getsourcelines returns 0 as the starting line number of a module + start_line = max(start_line, 1) + + # inspect.getdoc strips the leading blank lines of the docstring + leading_blank_lines = 0 + raw_docstring = getattr(obj, "__doc__", None) + if isinstance(raw_docstring, str): + for raw_line in raw_docstring.split("\n"): + if raw_line.strip(): + break + leading_blank_lines += 1 + # Find the line in the source code that starts with triple quotes (""" or ''') for idx, line in enumerate(source_lines): line = line.strip() - if line.startswith(('"""', "'''")): - return start_line + idx # Return the starting line number + if line.startswith(DOCSTRING_OPENINGS): + # idx is 0-based, so start_line + idx is the line of the opening quotes + return start_line + idx - 1 + leading_blank_lines return None # Docstring not found in source diff --git a/tests/plugin_test.py b/tests/plugin_test.py index 34102d2..3dc90f2 100644 --- a/tests/plugin_test.py +++ b/tests/plugin_test.py @@ -489,6 +489,71 @@ def test_error_origin_before_docstring_traceback(testdir, support_dir): ) +def test_docstring_line_numbers(testdir): + testdir.makepyfile( + docstring_lines=''' + def summary_on_first_line(): + """Summary line. + + ```python + raise Exception("foo") + ``` + """ + + + def raw_docstring(): + r"""Matches \\d. + + ```python + raise Exception("bar") + ```""" + ''' + ) + result = testdir.runpytest("-v", "--markdown-docs") + result.assert_outcomes(failed=2) + result.stdout.re_match_lines( + [ + r".*::raw_docstring\[CodeFence#1\]\[line:13\] FAILED.*", + r".*::summary_on_first_line\[CodeFence#1\]\[line:4\] FAILED.*", + ], + consecutive=True, + ) + result.stdout.re_match_lines( + [ + r'\s*File ".*/docstring_lines.py", line 14, in ', + r'\s*raise Exception\("bar"\)', + ], + consecutive=True, + ) + result.stdout.re_match_lines( + [ + r'\s*File ".*/docstring_lines.py", line 5, in ', + r'\s*raise Exception\("foo"\)', + ], + consecutive=True, + ) + + +def test_module_docstring_line_numbers(testdir): + testdir.makepyfile( + module_docstring=''' + """ + ```python + raise Exception("foo") + ``` + """ + ''' + ) + result = testdir.runpytest("-v", "--markdown-docs") + result.assert_outcomes(failed=1) + result.stdout.re_match_lines( + [ + r".*::module_docstring\[CodeFence#1\]\[line:2\] FAILED.*", + r'\s*File ".*/module_docstring.py", line 3, in ', + ] + ) + + def test_custom_runner(testdir): testdir.makeconftest( """