Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/15032.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 51 additions & 7 deletions src/_pytest/config/argparsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from collections.abc import Sequence
import dataclasses
import os
import re
import sys
import textwrap
import types
Expand Down Expand Up @@ -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):
Expand Down
20 changes: 5 additions & 15 deletions src/_pytest/helpconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
)
Expand All @@ -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:")
Expand Down
27 changes: 27 additions & 0 deletions testing/test_helpconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
112 changes: 112 additions & 0 deletions testing/test_parseopt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -394,3 +419,90 @@ def test_argument_repr_initialized(parser: parseopt.Parser) -> None:
repr(option)
== "Argument(opts: ['--count'], dest: 'count', type: <class 'int'>, 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) == []