https://github.com/python/cpython/commit/998fc4a973b2b5ac925d13e01693360c24803330
commit: 998fc4a973b2b5ac925d13e01693360c24803330
branch: main
author: Savannah Ostrowski <[email protected]>
committer: savannahostrowski <[email protected]>
date: 2026-07-24T19:50:09Z
summary:

GH-142035: Fix wrapping of colorized argparse help text (#154634)

Co-authored-by: blurb-it[bot] <43283697+blurb-it[bot]@users.noreply.github.com>
Co-authored-by: Hugo van Kemenade <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-07-24-15-54-35.gh-issue-142035.ZpNfl6.rst
M Lib/argparse.py
M Lib/test/test_argparse.py

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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to