diff --git a/changelog/15032.bugfix.rst b/changelog/15032.bugfix.rst new file mode 100644 index 00000000000..dea67df8979 --- /dev/null +++ b/changelog/15032.bugfix.rst @@ -0,0 +1,4 @@ +Help texts for command-line options and ini options now keep their indentation +and blank lines. Previously each line was stripped before wrapping, so a wrapped +list item was indistinguishable from a new one and blank lines were dropped; ini +option help lost its line breaks entirely. diff --git a/src/_pytest/config/argparsing.py b/src/_pytest/config/argparsing.py index d435715785d..7180cde2c4c 100644 --- a/src/_pytest/config/argparsing.py +++ b/src/_pytest/config/argparsing.py @@ -6,6 +6,7 @@ from collections.abc import Sequence import dataclasses import os +import re import sys import textwrap import types @@ -568,14 +569,57 @@ def _format_action_invocation(self, action: argparse.Action) -> str: return ", ".join(return_list) def _split_lines(self, text: str, width: int) -> list[str]: - """Wrap lines after splitting on original newlines. + return _split_help_text(text, width) - This allows to have explicit line breaks in the help text. - """ - lines = [] - for line in text.splitlines(): - lines.extend(textwrap.wrap(line.strip(), width)) - return lines + def _format_action(self, action: argparse.Action) -> str: + # A blank line in a help text is padded out to the help column by + # argparse; strip the trailing whitespace that leaves behind. + return re.sub( + r"[ \t]+$", "", super()._format_action(action), flags=re.MULTILINE + ) + + +# A list item marker, e.g. "- ", "* ", "1. " or "(1) ". +_BULLET = re.compile(r"^([-*+]|\(?\d+[.)])\s+") + + +def _split_help_text(text: str, width: int) -> list[str]: + """Wrap help text to ``width`` while preserving its line structure. + + Explicit line breaks are kept, so that a help text can use paragraphs and + lists instead of being reflowed into one blob (see #6817). Each line keeps + its own indentation, and the continuation lines of a list item are indented + to hang under the item's text. + """ + # Dedent the body, but not the first line: it usually sits inline after the + # opening quotes of a docstring-style help text, and so contributes no + # common indentation of its own. + first, newline, rest = text.partition("\n") + lines: list[str] = [] + for line in (first.strip() + newline + textwrap.dedent(rest)).splitlines(): + stripped = line.lstrip() + if not stripped: + lines.append("") + continue + indent = line[: len(line) - len(stripped)] + bullet = _BULLET.match(stripped) + subsequent_indent = indent + " " * len(bullet.group()) if bullet else indent + lines.extend( + textwrap.wrap( + stripped, + width, + initial_indent=indent, + subsequent_indent=subsequent_indent, + break_on_hyphens=False, + ) + ) + # Leading and trailing blank lines would only offset the text from the + # option it documents. + while lines and not lines[0]: + del lines[0] + while lines and not lines[-1]: + del lines[-1] + return lines class OverrideIniAction(argparse.Action): diff --git a/src/_pytest/helpconfig.py b/src/_pytest/helpconfig.py index 1bceb05558c..8d9d44699c1 100644 --- a/src/_pytest/helpconfig.py +++ b/src/_pytest/helpconfig.py @@ -14,6 +14,7 @@ from _pytest.config import ExitCode from _pytest.config import PrintHelp from _pytest.config.argparsing import _ini_type_repr +from _pytest.config.argparsing import _split_help_text from _pytest.config.argparsing import Parser from _pytest.terminal import TerminalReporter import pytest @@ -179,8 +180,6 @@ def pytest_cmdline_main(config: Config) -> int | ExitCode | None: def showhelp(config: Config) -> None: - import textwrap - reporter: TerminalReporter | None = config.pluginmanager.get_plugin( "terminalreporter" ) @@ -204,28 +203,19 @@ def showhelp(config: Config) -> None: spec = f"{name} ({_ini_type_repr(type)}):" tw.write(f" {spec}") spec_len = len(spec) + wrapped = _split_help_text(help, columns - indent_len) if spec_len > (indent_len - 3): # Display help starting at a new line. tw.line() - helplines = textwrap.wrap( - help, - columns, - initial_indent=indent, - subsequent_indent=indent, - break_on_hyphens=False, - ) - - for line in helplines: - tw.line(line) + for line in wrapped: + tw.line(indent + line if line else "") else: # Display help starting after the spec, following lines indented. tw.write(" " * (indent_len - spec_len - 2)) - wrapped = textwrap.wrap(help, columns - indent_len, break_on_hyphens=False) - if wrapped: tw.line(wrapped[0]) for line in wrapped[1:]: - tw.line(indent + line) + tw.line(indent + line if line else "") tw.line() tw.line("Environment variables:") diff --git a/testing/test_helpconfig.py b/testing/test_helpconfig.py index 5a6d4d16b4a..a833e0e6fa2 100644 --- a/testing/test_helpconfig.py +++ b/testing/test_helpconfig.py @@ -83,6 +83,33 @@ def pytest_addoption(parser): ) +def test_help_ini_keeps_line_structure(pytester: Pytester) -> None: + """Ini help keeps its explicit line breaks and indentation, like option help.""" + pytester.makeconftest( + """ + def pytest_addoption(parser): + parser.addini( + "ini_list", + "strategy for the thing\\n" + "- short: values over 100 chars fall back to argname plus index\\n" + "- sha256: replace the value with its sha256 hex digest", + default=None, + ) + """ + ) + result = pytester.runpytest("--help") + assert result.ret == ExitCode.OK + result.stdout.fnmatch_lines( + [ + " ini_list (string): strategy for the thing", + " - short: values over 100 chars fall back to argname plus", + " index", + " - sha256: replace the value with its sha256 hex digest", + ], + consecutive=True, + ) + + def test_none_help_param_raises_exception(pytester: Pytester) -> None: """Test that a None help param raises a TypeError.""" pytester.makeconftest( diff --git a/testing/test_parseopt.py b/testing/test_parseopt.py index f58754aae27..8457cc6e6ba 100644 --- a/testing/test_parseopt.py +++ b/testing/test_parseopt.py @@ -286,6 +286,31 @@ def test_drop_short_help0(self, parser: parseopt.Parser) -> None: help = parser.optparser.format_help() assert "--func-args, --doit foo" in help + def test_help_keeps_indentation(self, parser: parseopt.Parser) -> None: + parser.addoption( + "--funcarg", + action="store_true", + help="do the thing\n" + "- fast: skip the expensive validation pass entirely\n" + "- slow: run every check, including the very expensive ones", + ) + parser.parse([]) + help = parser.optparser.format_help() + # The list items keep their own line, and a wrapped item hangs under + # its text rather than looking like a new item. + assert "- fast: skip the expensive validation pass entirely" in help + assert "- slow: run every check, including the very expensive ones" in help + + def test_help_keeps_blank_lines(self, parser: parseopt.Parser) -> None: + parser.addoption( + "--funcarg", action="store_true", help="first paragraph\n\nsecond paragraph" + ) + parser.parse([]) + lines = parser.optparser.format_help().splitlines() + first = next(i for i, line in enumerate(lines) if "first paragraph" in line) + assert lines[first + 1] == "" + assert lines[first + 2].strip() == "second paragraph" + # testing would be more helpful with all help generated def test_drop_short_help1(self, parser: parseopt.Parser) -> None: group = parser.getgroup("general") @@ -394,3 +419,90 @@ def test_argument_repr_initialized(parser: parseopt.Parser) -> None: repr(option) == "Argument(opts: ['--count'], dest: 'count', type: , default: None)" ) + + +class TestSplitHelpText: + def test_wraps_each_line_separately(self) -> None: + assert parseopt._split_help_text("one two three\nfour five", 8) == [ + "one two", + "three", + "four", + "five", + ] + + def test_keeps_blank_lines_between_paragraphs(self) -> None: + assert parseopt._split_help_text("first\n\nsecond", 40) == [ + "first", + "", + "second", + ] + + def test_strips_surrounding_blank_lines(self) -> None: + assert parseopt._split_help_text("\n\nonly\n\n", 40) == ["only"] + + def test_dedents_docstring_style_help(self) -> None: + # The common indentation of a triple-quoted help text is source + # indentation and must not reach the terminal, but the relative + # indentation inside it must (#6817). + help = """ + Select tests by marker expression. + + Examples: + -m 'slow' + """ + assert parseopt._split_help_text(help, 40) == [ + "Select tests by marker expression.", + "", + "Examples:", + " -m 'slow'", + ] + + def test_dedents_help_starting_on_the_first_line(self) -> None: + # The first line shares no indentation with the body, so dedenting the + # text as a whole would find a common prefix of "" and do nothing. + help = """Select tests by marker expression. + + Examples: + -m 'slow' + """ + assert parseopt._split_help_text(help, 40) == [ + "Select tests by marker expression.", + "", + "Examples:", + " -m 'slow'", + ] + + @pytest.mark.parametrize("marker", ["-", "*", "+", "1.", "(1)"]) + def test_list_continuation_hangs_under_the_item(self, marker: str) -> None: + hang = " " * (len(marker) + 1) + assert parseopt._split_help_text(f"{marker} one two three four", 14) == [ + f"{marker} one two", + f"{hang}three four", + ] + + def test_relative_indentation_is_preserved_when_wrapping(self) -> None: + help = "head\n one two three four\nfoot" + assert parseopt._split_help_text(help, 12) == [ + "head", + " one two", + " three", + " four", + "foot", + ] + + def test_single_line_help_is_not_indented(self) -> None: + # A lone line carries only source indentation, never structure. + assert parseopt._split_help_text(" one two three four", 12) == [ + "one two", + "three four", + ] + + def test_does_not_break_on_hyphens(self) -> None: + assert parseopt._split_help_text("pass --no-header to it", 14) == [ + "pass", + "--no-header to", + "it", + ] + + def test_empty_help(self) -> None: + assert parseopt._split_help_text("", 40) == []