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
30 changes: 29 additions & 1 deletion Lib/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -776,7 +776,35 @@ def _split_lines(self, text, width):
# The textwrap module is used only for formatting help.
# Delay its import for speeding up the common usage of argparse.
import textwrap
return textwrap.wrap(text, width)
decolored = self._decolor(text)
if decolored == text:
return textwrap.wrap(text, width)

# gh-142035: colors inflate textwrap's length counts, so wrap
# the decolored text and re-apply colors per word; if textwrap
# split a word, keep the plain lines (colors can't be mapped).
plain = self._whitespace_matcher.sub(' ', decolored).strip()
if not plain:
# nothing visible to wrap (e.g. an empty interpolated value)
return [text]
plain_lines = textwrap.wrap(plain, width)
plain_words = plain.split()
colored_words = text.split()
# Drop escape-only tokens (e.g. an empty interpolated value).
if len(colored_words) != len(plain_words):
colored_words = [
word for word in colored_words if self._decolor(word)
]
colored_lines = []
start = 0
for plain_line in plain_lines:
plain_line_words = plain_line.split()
end = start + len(plain_line_words)
if plain_words[start:end] != plain_line_words:
return plain_lines
colored_lines.append(' '.join(colored_words[start:end]))
start = end
return colored_lines

def _fill_text(self, text, width, indent):
text = self._whitespace_matcher.sub(' ', text).strip()
Expand Down
56 changes: 56 additions & 0 deletions Lib/test/test_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -7580,6 +7580,62 @@ def test_argparse_color_custom_usage(self):
),
)

def test_argparse_color_wrapping_matches_uncolored(self):
# gh-142035: color codes must not affect where help text wraps.
# Stripping the escapes from colored help must yield exactly the
# same text as the uncolored help across representative widths.
def build(color, path="output.txt"):
parser = argparse.ArgumentParser(prog="PROG", color=color)
parser.add_argument(
"--mode",
default="auto",
choices=("auto", "fast", "slow"),
help="select the operating mode from the available choices "
"%(choices)s and note the default is %(default)s here",
)
parser.add_argument(
"--path",
default=path,
help="write output to %(default)s and continue processing",
)
return parser

env = self.enterContext(os_helper.EnvironmentVarGuard())
paths = (
"output.txt",
"/var/lib/application/cache/unusually_long_generated_filename",
"production-read-only-replica",
)
for path in paths:
for columns in ("80", "60", "45", "30", "20"):
with self.subTest(path=path, columns=columns):
env["COLUMNS"] = columns
colored = build(color=True, path=path).format_help()
plain = build(color=False, path=path).format_help()
self.assertIn(
f"{self.theme.interpolated_value}auto"
f"{self.theme.reset}",
colored,
)
self.assertEqual(_colorize.decolor(colored), plain)

def test_argparse_color_preserved_when_wrapping_between_words(self):
parser = argparse.ArgumentParser(prog="PROG", color=True)
parser.add_argument(
"--mode", default="auto",
help="select the %(default)s operating mode from the available "
"options and continue with several more words",
)

env = self.enterContext(os_helper.EnvironmentVarGuard())
env["COLUMNS"] = "40"
help_text = parser.format_help()

self.assertIn(
f"{self.theme.interpolated_value}auto{self.theme.reset}",
help_text,
)

def test_custom_formatter_function(self):
def custom_formatter(prog):
return argparse.RawTextHelpFormatter(prog, indent_increment=5)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix incorrect wrapping of :mod:`argparse` help text when color is enabled.
Loading