From 266997ce22ad7184486d21f85cc10194f19afe7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Mon, 25 Aug 2025 13:40:38 +0200 Subject: [PATCH 1/7] allow to ignore line breaks in doctest output --- Doc/library/doctest.rst | 28 +++++++++- Doc/whatsnew/3.15.rst | 17 +++++++ Lib/doctest.py | 22 +++++++- Lib/test/test_doctest/test_doctest.py | 51 ++++++++++++++++++- ...-08-25-13-31-25.gh-issue-138135.yo5w-T.rst | 3 ++ 5 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-08-25-13-31-25.gh-issue-138135.yo5w-T.rst diff --git a/Doc/library/doctest.rst b/Doc/library/doctest.rst index 02b73ccd3f3d19..f72b7e37e8e5d6 100644 --- a/Doc/library/doctest.rst +++ b/Doc/library/doctest.rst @@ -616,7 +616,8 @@ doctest decides whether actual output matches an example's expected output: sequence of whitespace within the actual output. By default, whitespace must match exactly. :const:`NORMALIZE_WHITESPACE` is especially useful when a line of expected output is very long, and you want to wrap it across multiple lines in - your source. + your source. If the expected output does not contain any whitespace, consider + using :data:`IGNORE_LINEBREAK` or :data:`ELLIPSIS`. .. index:: single: ...; in doctests @@ -666,6 +667,31 @@ doctest decides whether actual output matches an example's expected output: to the module containing the exception under test. +.. data:: IGNORE_LINEBREAK + + When specified, single line breaks in the expected output are eliminated, + thereby allowing strings without whitespaces to span multiple lines. + + .. doctest:: + :no-trim-doctest-flags: + + >>> "foobar123456" # doctest: +IGNORE_LINEBREAK + 'foobar + 123456' + + Consider using :data:`NORMALIZE_WHITESPACE` when strings with whitespaces + need to be split across multiple lines: + + .. doctest:: + :no-trim-doctest-flags: + + >>> "the string to split" # doctest: +NORMALIZE_WHITESPACE + 'the string + to split' + + .. versionadded:: next + + .. data:: SKIP When specified, do not run the example at all. This can be useful in contexts diff --git a/Doc/whatsnew/3.15.rst b/Doc/whatsnew/3.15.rst index 54a7d0f3c57dad..3e7854add0b202 100644 --- a/Doc/whatsnew/3.15.rst +++ b/Doc/whatsnew/3.15.rst @@ -311,6 +311,23 @@ difflib (Contributed by Jiahao Li in :gh:`134580`.) +doctest +------- + +* Add :data:`~doctest.IGNORE_LINEBREAK` option to allow breaking expected + output strings without whitespaces into multiple lines: + + .. doctest:: + :no-trim-doctest-flags: + + >>> import string + >>> print(string.ascii_letters) # doctest: +IGNORE_LINEBREAK + abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ + + (Contributed by Bénédikt Tran in :gh:`138135`.) + + hashlib ------- diff --git a/Lib/doctest.py b/Lib/doctest.py index 92a2ab4f7e66f8..fd0d61470225cd 100644 --- a/Lib/doctest.py +++ b/Lib/doctest.py @@ -53,6 +53,7 @@ def _test(): 'DONT_ACCEPT_TRUE_FOR_1', 'DONT_ACCEPT_BLANKLINE', 'NORMALIZE_WHITESPACE', + 'IGNORE_LINEBREAK', 'ELLIPSIS', 'SKIP', 'IGNORE_EXCEPTION_DETAIL', @@ -156,6 +157,7 @@ def register_optionflag(name): DONT_ACCEPT_TRUE_FOR_1 = register_optionflag('DONT_ACCEPT_TRUE_FOR_1') DONT_ACCEPT_BLANKLINE = register_optionflag('DONT_ACCEPT_BLANKLINE') NORMALIZE_WHITESPACE = register_optionflag('NORMALIZE_WHITESPACE') +IGNORE_LINEBREAK = register_optionflag('IGNORE_LINEBREAK') ELLIPSIS = register_optionflag('ELLIPSIS') SKIP = register_optionflag('SKIP') IGNORE_EXCEPTION_DETAIL = register_optionflag('IGNORE_EXCEPTION_DETAIL') @@ -1751,9 +1753,18 @@ def check_output(self, want, got, optionflags): if got == want: return True + # This flag causes doctest to ignore '\n' in `want`. + # Note that this can be used in conjunction with + # the NORMALIZE_WHITESPACE and ELLIPSIS flags. + if optionflags & IGNORE_LINEBREAK: + # `want` originally ends with '\n' so we add it back + want = ''.join(want.split('\n')) + '\n' + if got == want: + return True + # This flag causes doctest to ignore any differences in the # contents of whitespace strings. Note that this can be used - # in conjunction with the ELLIPSIS flag. + # in conjunction with the IGNORE_LINEBREAK and ELLIPSIS flags. if optionflags & NORMALIZE_WHITESPACE: got = ' '.join(got.split()) want = ' '.join(want.split()) @@ -2268,7 +2279,7 @@ def set_unittest_reportflags(flags): >>> doctest.set_unittest_reportflags(ELLIPSIS) Traceback (most recent call last): ... - ValueError: ('Only reporting flags allowed', 8) + ValueError: ('Only reporting flags allowed', 16) >>> doctest.set_unittest_reportflags(old) == (REPORT_NDIFF | ... REPORT_ONLY_FIRST_FAILURE) @@ -2924,6 +2935,13 @@ def get(self): 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29] """, + + "line break elimination": r""" + >>> "foobar" # doctest: +IGNORE_LINEBREAK + 'foo + bar + ' + """, } diff --git a/Lib/test/test_doctest/test_doctest.py b/Lib/test/test_doctest/test_doctest.py index 0fa74407e3c436..40d48d9a504f38 100644 --- a/Lib/test/test_doctest/test_doctest.py +++ b/Lib/test/test_doctest/test_doctest.py @@ -204,7 +204,7 @@ def test_Example(): r""" ... options={doctest.ELLIPSIS: True}) >>> (example.source, example.want, example.exc_msg, ... example.lineno, example.indent, example.options) - ('[].pop()\n', '', 'IndexError: pop from an empty list\n', 5, 4, {8: True}) + ('[].pop()\n', '', 'IndexError: pop from an empty list\n', 5, 4, {16: True}) The constructor normalizes the `source` string to end in a newline: @@ -1396,6 +1396,55 @@ def optionflags(): r""" [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19] +The IGNORE_LINEBREAK flag causes all sequences of newlines to be removed: + + >>> def f(x): + ... '\n>>> "foobar"\n\'foo\nbar\'' + + >>> # Without the flag: + >>> test = doctest.DocTestFinder().find(f)[0] + >>> doctest.DocTestRunner(verbose=False).run(test) + ... # doctest: +ELLIPSIS + ********************************************************************** + File ..., line 3, in f + Failed example: + "foobar" + Expected: + 'foo + bar' + Got: + 'foobar' + TestResults(failed=1, attempted=1) + + >>> # With the flag: + >>> test = doctest.DocTestFinder().find(f)[0] + >>> flags = doctest.IGNORE_LINEBREAK + >>> doctest.DocTestRunner(verbose=False, optionflags=flags).run(test) + TestResults(failed=0, attempted=1) + + ... ignore surrounding new lines + + >>> "foobar" # doctest: +IGNORE_LINEBREAK + ' + foo + bar' + >>> "foobar" # doctest: +IGNORE_LINEBREAK + 'foo + bar + ' + >>> "foobar" # doctest: +IGNORE_LINEBREAK + ' + foo + bar + ' + + ... non-quoted output: + + >>> import string + >>> print(string.ascii_letters) # doctest: +IGNORE_LINEBREAK + abcdefghijklmnopqrstuvwxyz + ABCDEFGHIJKLMNOPQRSTUVWXYZ + The ELLIPSIS flag causes ellipsis marker ("...") in the expected output to match any substring in the actual output: diff --git a/Misc/NEWS.d/next/Library/2025-08-25-13-31-25.gh-issue-138135.yo5w-T.rst b/Misc/NEWS.d/next/Library/2025-08-25-13-31-25.gh-issue-138135.yo5w-T.rst new file mode 100644 index 00000000000000..18f41952de0a12 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-08-25-13-31-25.gh-issue-138135.yo5w-T.rst @@ -0,0 +1,3 @@ +:mod:`doctest`: Add :data:`~doctest.IGNORE_LINEBREAK` option to allow +breaking expected output strings without whitespaces into multiple lines. +Patch by Bénédikt Tran. From 19c440d053be79f1695f94d2948a9701feb84be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Tue, 26 Aug 2025 12:00:48 +0200 Subject: [PATCH 2/7] extend testing --- Lib/doctest.py | 2 +- Lib/test/test_doctest/test_doctest.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Lib/doctest.py b/Lib/doctest.py index fd0d61470225cd..ad78f014ed3b91 100644 --- a/Lib/doctest.py +++ b/Lib/doctest.py @@ -1758,7 +1758,7 @@ def check_output(self, want, got, optionflags): # the NORMALIZE_WHITESPACE and ELLIPSIS flags. if optionflags & IGNORE_LINEBREAK: # `want` originally ends with '\n' so we add it back - want = ''.join(want.split('\n')) + '\n' + want = ''.join(want.splitlines()) + '\n' if got == want: return True diff --git a/Lib/test/test_doctest/test_doctest.py b/Lib/test/test_doctest/test_doctest.py index 40d48d9a504f38..dbe4f8888eba63 100644 --- a/Lib/test/test_doctest/test_doctest.py +++ b/Lib/test/test_doctest/test_doctest.py @@ -1445,6 +1445,22 @@ def optionflags(): r""" abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ + ... mixing flags: + + >>> import string + >>> print(string.ascii_letters) # doctest: +ELLIPSIS, +IGNORE_LINEBREAK + abc...xyz + ABC... + + ... mixing flags: + + >>> import string + >>> print(list(string.ascii_letters)) # doctest: +IGNORE_LINEBREAK + ... # doctest: +ELLIPSIS + ... # doctest: +NORMALIZE_WHITESPACE + ['a', ..., 'z', + 'A', ..., 'Z'] + The ELLIPSIS flag causes ellipsis marker ("...") in the expected output to match any substring in the actual output: From b16cab97f10fc46a62e483b419469abb947895b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 29 Aug 2025 11:18:21 +0200 Subject: [PATCH 3/7] improve docs & tests --- Doc/library/doctest.rst | 23 ++++++++++ Lib/test/test_doctest/test_doctest.py | 64 ++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/Doc/library/doctest.rst b/Doc/library/doctest.rst index f72b7e37e8e5d6..32891491224a50 100644 --- a/Doc/library/doctest.rst +++ b/Doc/library/doctest.rst @@ -689,6 +689,29 @@ doctest decides whether actual output matches an example's expected output: 'the string to split' + Note that any leading whitespaces on each expected output line are retained. + In other words, the following expected outputs are equivalent under the + :data:`!IGNORE_LINEBREAK`: + + [ + 'a', 'b', 'c', + '1', '2', '3' + ] + + [ 'a', 'b', 'c', '1', '2', '3'] + + To break a list-like output with :data:`!IGNORE_LINEBREAK`, + leading whitespaces for visual indentation purposes should + be avoided, for instance: + + .. code-block:: pycon + + >>> list("abc123") # doctest: +IGNORE_LINEBREAK + ['a', 'b', 'c', + '1', '2', '3'] + + For more complex outputs, consider using :func:`pprint.pprint` instead. + .. versionadded:: next diff --git a/Lib/test/test_doctest/test_doctest.py b/Lib/test/test_doctest/test_doctest.py index dbe4f8888eba63..cdd8c7b829d90d 100644 --- a/Lib/test/test_doctest/test_doctest.py +++ b/Lib/test/test_doctest/test_doctest.py @@ -1396,17 +1396,23 @@ def optionflags(): r""" [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19] -The IGNORE_LINEBREAK flag causes all sequences of newlines to be removed: +The IGNORE_LINEBREAK flag causes all sequences of newlines to be removed, +but retains the leading whitespaces as they cannot be distinguished from +real textual whitespaces: - >>> def f(x): - ... '\n>>> "foobar"\n\'foo\nbar\'' + >>> def f(x): pass + >>> f.__doc__ = ''' + ... >>> "foobar" + ... 'foo + ... bar' + ... '''.strip() >>> # Without the flag: >>> test = doctest.DocTestFinder().find(f)[0] >>> doctest.DocTestRunner(verbose=False).run(test) ... # doctest: +ELLIPSIS ********************************************************************** - File ..., line 3, in f + File ..., line ?, in f Failed example: "foobar" Expected: @@ -1454,12 +1460,50 @@ def optionflags(): r""" ... mixing flags: - >>> import string - >>> print(list(string.ascii_letters)) # doctest: +IGNORE_LINEBREAK - ... # doctest: +ELLIPSIS - ... # doctest: +NORMALIZE_WHITESPACE - ['a', ..., 'z', - 'A', ..., 'Z'] + >>> print(list("abc123")) # doctest: +IGNORE_LINEBREAK + ... # doctest: +ELLIPSIS + ... # doctest: +NORMALIZE_WHITESPACE + ['a', ..., 'c', + '1', ..., '3'] + + >>> prelude = r''' + ... >>> print(list("abc123")) # doctest: +IGNORE_LINEBREAK + ... ... # doctest: +ELLIPSIS + ... ... # doctest: +NORMALIZE_WHITESPACE + ... '''.strip() + + >>> def good(x): pass + >>> good.__doc__ = '\n'.join([prelude, r''' + ... ['a', ..., 'c', + ... '1', ..., '3'] + ... '''.lstrip()]).lstrip() + >>> test = doctest.DocTestFinder().find(good)[0] + >>> doctest.DocTestRunner(verbose=False).run(test) + TestResults(failed=0, attempted=1) + + >>> def fail(x): pass + >>> fail.__doc__ = '\n'.join([prelude, ''' + ... [ + ... 'a', ..., 'c', + ... '1', ..., '3' + ... ]\n'''.lstrip()]) + >>> test = doctest.DocTestFinder().find(fail)[0] + >>> doctest.DocTestRunner(verbose=False).run(test) + ... # doctest: +ELLIPSIS + ********************************************************************** + File ..., line ?, in fail + Failed example: + print(list("abc123")) # doctest: +IGNORE_LINEBREAK + # doctest: +ELLIPSIS + # doctest: +NORMALIZE_WHITESPACE + Expected: + [ + 'a', ..., 'c', + '1', ..., '3' + ] + Got: + ['a', 'b', 'c', '1', '2', '3'] + TestResults(failed=1, attempted=1) The ELLIPSIS flag causes ellipsis marker ("...") in the expected output to match any substring in the actual output: From 3b13872f09604b792bcaadeb4ac1f8ffed2431a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 29 Aug 2025 11:52:03 +0200 Subject: [PATCH 4/7] fix docs --- Doc/library/doctest.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Doc/library/doctest.rst b/Doc/library/doctest.rst index 32891491224a50..b3ddc8f1943a73 100644 --- a/Doc/library/doctest.rst +++ b/Doc/library/doctest.rst @@ -693,12 +693,14 @@ doctest decides whether actual output matches an example's expected output: In other words, the following expected outputs are equivalent under the :data:`!IGNORE_LINEBREAK`: + .. code-block:: + [ - 'a', 'b', 'c', - '1', '2', '3' + 'a', 'b', 'c', + '1', '2', '3' ] - [ 'a', 'b', 'c', '1', '2', '3'] + [ 'a', 'b', 'c', '1', '2', '3'] To break a list-like output with :data:`!IGNORE_LINEBREAK`, leading whitespaces for visual indentation purposes should From 6bc41205127604af692af49f7bdb1a1403ed30b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 29 Aug 2025 12:11:19 +0200 Subject: [PATCH 5/7] move section below NORMALIZE_WHITESPACE instead --- Doc/library/doctest.rst | 94 ++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 47 deletions(-) diff --git a/Doc/library/doctest.rst b/Doc/library/doctest.rst index b3ddc8f1943a73..fc3f3e8488ddc7 100644 --- a/Doc/library/doctest.rst +++ b/Doc/library/doctest.rst @@ -620,53 +620,6 @@ doctest decides whether actual output matches an example's expected output: using :data:`IGNORE_LINEBREAK` or :data:`ELLIPSIS`. -.. index:: single: ...; in doctests -.. data:: ELLIPSIS - - When specified, an ellipsis marker (``...``) in the expected output can match - any substring in the actual output. This includes substrings that span line - boundaries, and empty substrings, so it's best to keep usage of this simple. - Complicated uses can lead to the same kinds of "oops, it matched too much!" - surprises that ``.*`` is prone to in regular expressions. - - -.. data:: IGNORE_EXCEPTION_DETAIL - - When specified, doctests expecting exceptions pass so long as an exception - of the expected type is raised, even if the details - (message and fully qualified exception name) don't match. - - For example, an example expecting ``ValueError: 42`` will pass if the actual - exception raised is ``ValueError: 3*14``, but will fail if, say, a - :exc:`TypeError` is raised instead. - It will also ignore any fully qualified name included before the - exception class, which can vary between implementations and versions - of Python and the code/libraries in use. - Hence, all three of these variations will work with the flag specified: - - .. code-block:: pycon - - >>> raise Exception('message') - Traceback (most recent call last): - Exception: message - - >>> raise Exception('message') - Traceback (most recent call last): - builtins.Exception: message - - >>> raise Exception('message') - Traceback (most recent call last): - __main__.Exception: message - - Note that :const:`ELLIPSIS` can also be used to ignore the - details of the exception message, but such a test may still fail based - on whether the module name is present or matches exactly. - - .. versionchanged:: 3.2 - :const:`IGNORE_EXCEPTION_DETAIL` now also ignores any information relating - to the module containing the exception under test. - - .. data:: IGNORE_LINEBREAK When specified, single line breaks in the expected output are eliminated, @@ -717,6 +670,53 @@ doctest decides whether actual output matches an example's expected output: .. versionadded:: next +.. index:: single: ...; in doctests +.. data:: ELLIPSIS + + When specified, an ellipsis marker (``...``) in the expected output can match + any substring in the actual output. This includes substrings that span line + boundaries, and empty substrings, so it's best to keep usage of this simple. + Complicated uses can lead to the same kinds of "oops, it matched too much!" + surprises that ``.*`` is prone to in regular expressions. + + +.. data:: IGNORE_EXCEPTION_DETAIL + + When specified, doctests expecting exceptions pass so long as an exception + of the expected type is raised, even if the details + (message and fully qualified exception name) don't match. + + For example, an example expecting ``ValueError: 42`` will pass if the actual + exception raised is ``ValueError: 3*14``, but will fail if, say, a + :exc:`TypeError` is raised instead. + It will also ignore any fully qualified name included before the + exception class, which can vary between implementations and versions + of Python and the code/libraries in use. + Hence, all three of these variations will work with the flag specified: + + .. code-block:: pycon + + >>> raise Exception('message') + Traceback (most recent call last): + Exception: message + + >>> raise Exception('message') + Traceback (most recent call last): + builtins.Exception: message + + >>> raise Exception('message') + Traceback (most recent call last): + __main__.Exception: message + + Note that :const:`ELLIPSIS` can also be used to ignore the + details of the exception message, but such a test may still fail based + on whether the module name is present or matches exactly. + + .. versionchanged:: 3.2 + :const:`IGNORE_EXCEPTION_DETAIL` now also ignores any information relating + to the module containing the exception under test. + + .. data:: SKIP When specified, do not run the example at all. This can be useful in contexts From 6d2159b00cd186c875d3da983c70fb3836ba5a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 29 Aug 2025 12:12:44 +0200 Subject: [PATCH 6/7] fixup --- Doc/library/doctest.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Doc/library/doctest.rst b/Doc/library/doctest.rst index fc3f3e8488ddc7..2bf3d86336dc53 100644 --- a/Doc/library/doctest.rst +++ b/Doc/library/doctest.rst @@ -643,7 +643,7 @@ doctest decides whether actual output matches an example's expected output: to split' Note that any leading whitespaces on each expected output line are retained. - In other words, the following expected outputs are equivalent under the + In other words, the following expected outputs are equivalent under :data:`!IGNORE_LINEBREAK`: .. code-block:: @@ -659,7 +659,8 @@ doctest decides whether actual output matches an example's expected output: leading whitespaces for visual indentation purposes should be avoided, for instance: - .. code-block:: pycon + .. doctest:: + :no-trim-doctest-flags: >>> list("abc123") # doctest: +IGNORE_LINEBREAK ['a', 'b', 'c', From adc2eccb9b1003f41e6b019dc80cb8ee6a0755d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=A9n=C3=A9dikt=20Tran?= <10796600+picnixz@users.noreply.github.com> Date: Fri, 29 Aug 2025 12:46:18 +0200 Subject: [PATCH 7/7] recommend pp() instead of pprint() to avoid `sort_dict=True` --- Doc/library/doctest.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Doc/library/doctest.rst b/Doc/library/doctest.rst index 2bf3d86336dc53..c7ef65ed557573 100644 --- a/Doc/library/doctest.rst +++ b/Doc/library/doctest.rst @@ -666,7 +666,8 @@ doctest decides whether actual output matches an example's expected output: ['a', 'b', 'c', '1', '2', '3'] - For more complex outputs, consider using :func:`pprint.pprint` instead. + For more complex outputs, consider using :func:`pprint.pp` and matching + its output directly. .. versionadded:: next