]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-69113: Fix doctest to report line numbers for __test__ strings (#141624)
authorSanyam Khurana <8039608+CuriousLearner@users.noreply.github.com>
Sat, 6 Dec 2025 20:47:08 +0000 (15:47 -0500)
committerGitHub <noreply@github.com>
Sat, 6 Dec 2025 20:47:08 +0000 (15:47 -0500)
Enhanced the _find_lineno method in doctest to correctly identify and
report line numbers for doctests defined in __test__ dictionaries when
formatted as triple-quoted strings.

Finds a non-blank line in the test string and matches it in the source
file, verifying subsequent lines also match to handle duplicate lines.

Previously, doctest would report "line None" for __test__ dictionary
strings, making it difficult to debug failing tests.

Co-authored-by: Jurjen N.E. Bos <jneb@users.sourceforge.net>
Co-authored-by: R. David Murray <rdmurray@bitdance.com>
Lib/doctest.py
Lib/test/test_doctest/test_doctest.py
Misc/NEWS.d/next/Library/2025-11-16-04-40-06.gh-issue-69113.Xy7Fmn.rst [new file with mode: 0644]

index ad8fb900f692c7361ac139f996b4d41ac40051ae..0fcfa1e3e9714467b0a399044154b29d2c74947d 100644 (file)
@@ -1167,6 +1167,32 @@ class DocTestFinder:
                 if pat.match(source_lines[lineno]):
                     return lineno
 
+        # Handle __test__ string doctests formatted as triple-quoted
+        # strings. Find a non-blank line in the test string and match it
+        # in the source, verifying subsequent lines also match to handle
+        # duplicate lines.
+        if isinstance(obj, str) and source_lines is not None:
+            obj_lines = obj.splitlines(keepends=True)
+            # Skip the first line (may be on same line as opening quotes)
+            # and any blank lines to find a meaningful line to match.
+            start_index = 1
+            while (start_index < len(obj_lines)
+                   and not obj_lines[start_index].strip()):
+                start_index += 1
+            if start_index < len(obj_lines):
+                target_line = obj_lines[start_index]
+                for lineno, source_line in enumerate(source_lines):
+                    if source_line == target_line:
+                        # Verify subsequent lines also match
+                        for i in range(start_index + 1, len(obj_lines) - 1):
+                            source_idx = lineno + i - start_index
+                            if source_idx >= len(source_lines):
+                                break
+                            if obj_lines[i] != source_lines[source_idx]:
+                                break
+                        else:
+                            return lineno - start_index
+
         # We couldn't find the line number.
         return None
 
index 0fa74407e3c43651f8bfbcb4a5f9cd9d2e9fb351..241d09db1fa70e6c65212bcfba23a8cdf07d1c2a 100644 (file)
@@ -833,6 +833,118 @@ class TestDocTestFinder(unittest.TestCase):
             self.assertEqual(len(include_empty_finder.find(mod)), 1)
             self.assertEqual(len(exclude_empty_finder.find(mod)), 0)
 
+    def test_lineno_of_test_dict_strings(self):
+        """Test line numbers are found for __test__ dict strings."""
+        module_content = '''\
+"""Module docstring."""
+
+def dummy_function():
+    """Dummy function docstring."""
+    pass
+
+__test__ = {
+    'test_string': """
+    This is a test string.
+    >>> 1 + 1
+    2
+    """,
+}
+'''
+        with tempfile.TemporaryDirectory() as tmpdir:
+            module_path = os.path.join(tmpdir, 'test_module_lineno.py')
+            with open(module_path, 'w') as f:
+                f.write(module_content)
+
+            sys.path.insert(0, tmpdir)
+            try:
+                import test_module_lineno
+                finder = doctest.DocTestFinder()
+                tests = finder.find(test_module_lineno)
+
+                test_dict_test = None
+                for test in tests:
+                    if '__test__' in test.name:
+                        test_dict_test = test
+                        break
+
+                self.assertIsNotNone(
+                    test_dict_test,
+                    "__test__ dict test not found"
+                )
+                # gh-69113: line number should not be None for __test__ strings
+                self.assertIsNotNone(
+                    test_dict_test.lineno,
+                    "Line number should not be None for __test__ dict strings"
+                )
+                self.assertGreater(
+                    test_dict_test.lineno,
+                    0,
+                    "Line number should be positive"
+                )
+            finally:
+                if 'test_module_lineno' in sys.modules:
+                    del sys.modules['test_module_lineno']
+                sys.path.pop(0)
+
+    def test_lineno_multiline_matching(self):
+        """Test multi-line matching when no unique line exists."""
+        # gh-69113: test that line numbers are found even when lines
+        # appear multiple times (e.g., ">>> x = 1" in both test entries)
+        module_content = '''\
+"""Module docstring."""
+
+__test__ = {
+    'test_one': """
+    >>> x = 1
+    >>> x
+    1
+    """,
+    'test_two': """
+    >>> x = 1
+    >>> x
+    2
+    """,
+}
+'''
+        with tempfile.TemporaryDirectory() as tmpdir:
+            module_path = os.path.join(tmpdir, 'test_module_multiline.py')
+            with open(module_path, 'w') as f:
+                f.write(module_content)
+
+            sys.path.insert(0, tmpdir)
+            try:
+                import test_module_multiline
+                finder = doctest.DocTestFinder()
+                tests = finder.find(test_module_multiline)
+
+                test_one = None
+                test_two = None
+                for test in tests:
+                    if 'test_one' in test.name:
+                        test_one = test
+                    elif 'test_two' in test.name:
+                        test_two = test
+
+                self.assertIsNotNone(test_one, "test_one not found")
+                self.assertIsNotNone(test_two, "test_two not found")
+                self.assertIsNotNone(
+                    test_one.lineno,
+                    "Line number should not be None for test_one"
+                )
+                self.assertIsNotNone(
+                    test_two.lineno,
+                    "Line number should not be None for test_two"
+                )
+                self.assertNotEqual(
+                    test_one.lineno,
+                    test_two.lineno,
+                    "test_one and test_two should have different line numbers"
+                )
+            finally:
+                if 'test_module_multiline' in sys.modules:
+                    del sys.modules['test_module_multiline']
+                sys.path.pop(0)
+
 def test_DocTestParser(): r"""
 Unit tests for the `DocTestParser` class.
 
@@ -2434,7 +2546,8 @@ def test_DocTestSuite_errors():
          <BLANKLINE>
          >>> print(result.failures[1][1]) # doctest: +ELLIPSIS
          Traceback (most recent call last):
-           File "...sample_doctest_errors.py", line None, in test.test_doctest.sample_doctest_errors.__test__.bad
+           File "...sample_doctest_errors.py", line 37, in test.test_doctest.sample_doctest_errors.__test__.bad
+             >...>> 2 + 2
          AssertionError: Failed example:
              2 + 2
          Expected:
@@ -2464,7 +2577,8 @@ def test_DocTestSuite_errors():
          <BLANKLINE>
          >>> print(result.errors[1][1]) # doctest: +ELLIPSIS
          Traceback (most recent call last):
-           File "...sample_doctest_errors.py", line None, in test.test_doctest.sample_doctest_errors.__test__.bad
+           File "...sample_doctest_errors.py", line 39, in test.test_doctest.sample_doctest_errors.__test__.bad
+             >...>> 1/0
            File "<doctest test.test_doctest.sample_doctest_errors.__test__.bad[1]>", line 1, in <module>
              1/0
              ~^~
@@ -3256,7 +3370,7 @@ Tests for error reporting in the testmod() function.
             ~^~
         ZeroDivisionError: division by zero
     **********************************************************************
-    File "...sample_doctest_errors.py", line ?, in test.test_doctest.sample_doctest_errors.__test__.bad
+    File "...sample_doctest_errors.py", line 37, in test.test_doctest.sample_doctest_errors.__test__.bad
     Failed example:
         2 + 2
     Expected:
@@ -3264,7 +3378,7 @@ Tests for error reporting in the testmod() function.
     Got:
         4
     **********************************************************************
-    File "...sample_doctest_errors.py", line ?, in test.test_doctest.sample_doctest_errors.__test__.bad
+    File "...sample_doctest_errors.py", line 39, in test.test_doctest.sample_doctest_errors.__test__.bad
     Failed example:
         1/0
     Exception raised:
diff --git a/Misc/NEWS.d/next/Library/2025-11-16-04-40-06.gh-issue-69113.Xy7Fmn.rst b/Misc/NEWS.d/next/Library/2025-11-16-04-40-06.gh-issue-69113.Xy7Fmn.rst
new file mode 100644 (file)
index 0000000..cd76ae9
--- /dev/null
@@ -0,0 +1 @@
+Fix :mod:`doctest` to correctly report line numbers for doctests in ``__test__`` dictionary when formatted as triple-quoted strings by finding unique lines in the string and matching them in the source file.