From: Miss Islington (bot) <31488909+miss-islington@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:24:37 +0000 (+0200) Subject: [3.15] GH-142035: Fix wrapping of colorized argparse help text (GH-154634) (#154644) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=fe08b7029b0698074a59d7c43c49c6a5ae85239d;p=thirdparty%2FPython%2Fcpython.git [3.15] GH-142035: Fix wrapping of colorized argparse help text (GH-154634) (#154644) 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> --- diff --git a/Lib/argparse.py b/Lib/argparse.py index 29e6ebb96342..42891516b3bb 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -776,7 +776,35 @@ class HelpFormatter(object): # 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 1dc3f538f4ad..fbeb93384112 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -7580,6 +7580,62 @@ class TestColorized(TestCase): ), ) + 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 000000000000..f11f3a9e5520 --- /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.