# 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()
),
)
+ 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)