From 2348a08745117305bd52a01326c600af3df6fc05 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 15 Sep 2026 18:56:34 +0200 Subject: [PATCH 1/2] config: preserve indentation and blank lines in help texts 87423d3cc (#6817) made help texts keep their explicit line breaks by splitting on newlines and wrapping each line separately. Its commit message already named the cost: This might also result in unexpected changes (hard wrapping), when line endings where used unintentionally, e.g. with: help=""" some long help text """ The `line.strip()` it used to contain that is too blunt: it cannot tell source indentation from structure, so it also flattens the indentation that carries meaning. A wrapped list item became indistinguishable from a new item, and a blank line disappeared entirely because `textwrap.wrap("")` returns no lines. Use `textwrap.dedent` instead, which removes exactly the common source indentation that the 2020 commit was worried about while keeping what is relative to it. Each line then wraps with its own indent, continuation lines of a list item hang under the item's text, and blank lines survive. Reuse the result for ini options too. `showhelp()` had a second, simpler wrapping implementation that passed the whole help text to `textwrap.wrap`, so newlines degraded to plain whitespace and the structure was lost altogether -- visible in pytest's own `parametrize_long_str_id_strategy`: parametrize_long_str_id_strategy (string): strategy for long str/bytes parameter values in auto-generated ids - short (default): values over 100 chars fall back to argname+index - sha256: replace value which now renders as the list it was written as. Co-Authored-By: Claude Opus 5 (1M context) via Claude Code --- src/_pytest/config/argparsing.py | 58 ++++++++++++++-- src/_pytest/helpconfig.py | 20 ++---- testing/test_helpconfig.py | 27 ++++++++ testing/test_parseopt.py | 112 +++++++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 22 deletions(-) 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) == [] From b1a6976dcdc03a6f5309368c423a45d911e1f3a9 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 15 Sep 2026 18:57:07 +0200 Subject: [PATCH 2/2] Add changelog for help text wrapping fix Co-Authored-By: Claude Opus 5 (1M context) via Claude Code --- changelog/15032.bugfix.rst | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog/15032.bugfix.rst 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.