From a8509dfefcd12bfbd0bd9271b8433d9faaee243f Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Fri, 24 Jul 2026 12:50:09 -0700 Subject: [PATCH] GH-142035: Fix wrapping of colorized argparse help text (GH-154634) (cherry picked from commit 998fc4a973b2b5ac925d13e01693360c24803330) Co-authored-by: Savannah Ostrowski Co-authored-by: blurb-it[bot] <43283697+blurb-it[bot]@users.noreply.github.com> Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- Lib/argparse.py | 30 +++++++++- Lib/test/test_argparse.py | 56 +++++++++++++++++++ ...-07-24-15-54-35.gh-issue-142035.ZpNfl6.rst | 1 + 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-24-15-54-35.gh-issue-142035.ZpNfl6.rst diff --git a/Lib/argparse.py b/Lib/argparse.py index 29e6ebb9634261..42891516b3bb2a 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -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() diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 1dc3f538f4ad8b..fbeb933841129e 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -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) diff --git a/Misc/NEWS.d/next/Library/2026-07-24-15-54-35.gh-issue-142035.ZpNfl6.rst b/Misc/NEWS.d/next/Library/2026-07-24-15-54-35.gh-issue-142035.ZpNfl6.rst new file mode 100644 index 00000000000000..f11f3a9e55200d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-24-15-54-35.gh-issue-142035.ZpNfl6.rst @@ -0,0 +1 @@ +Fix incorrect wrapping of :mod:`argparse` help text when color is enabled.