https://github.com/python/cpython/commit/c15980b57b411dd1492f8bfe68d5c87538bd006f
commit: c15980b57b411dd1492f8bfe68d5c87538bd006f
branch: 3.14
author: Miss Islington (bot) <31488909+miss-isling...@users.noreply.github.com>
committer: serhiy-storchaka <storch...@gmail.com>
date: 2025-05-12T17:54:07Z
summary:

[3.14] gh-133653: Fix argparse.ArgumentParser with the formatter_class argument 
(GH-133813) (GH-133941)

* Fix TypeError when formatter_class is a custom subclass of
  HelpFormatter.
* Fix TypeError when formatter_class is not a subclass of
  HelpFormatter and non-standard prefix_char is used.
* Fix support of colorizing when formatter_class is not a subclass of
  HelpFormatter.
* Remove the prefix_chars parameter of HelpFormatter.
(cherry picked from commit 734e15b70dc044f57df4049a22dd769dffdb7d18)

Co-authored-by: Serhiy Storchaka <storch...@gmail.com>

files:
A Misc/NEWS.d/next/Library/2025-05-10-12-06-55.gh-issue-133653.Gb2aG4.rst
M Lib/argparse.py
M Lib/test/test_argparse.py

diff --git a/Lib/argparse.py b/Lib/argparse.py
index f13ac82dbc50b3..f688c38d0d1ae5 100644
--- a/Lib/argparse.py
+++ b/Lib/argparse.py
@@ -167,7 +167,6 @@ def __init__(
         indent_increment=2,
         max_help_position=24,
         width=None,
-        prefix_chars='-',
         color=False,
     ):
         # default setting for width
@@ -176,16 +175,7 @@ def __init__(
             width = shutil.get_terminal_size().columns
             width -= 2
 
-        from _colorize import can_colorize, decolor, get_theme
-
-        if color and can_colorize():
-            self._theme = get_theme(force_color=True).argparse
-            self._decolor = decolor
-        else:
-            self._theme = get_theme(force_no_color=True).argparse
-            self._decolor = lambda text: text
-
-        self._prefix_chars = prefix_chars
+        self._set_color(color)
         self._prog = prog
         self._indent_increment = indent_increment
         self._max_help_position = min(max_help_position,
@@ -202,6 +192,16 @@ def __init__(
         self._whitespace_matcher = _re.compile(r'\s+', _re.ASCII)
         self._long_break_matcher = _re.compile(r'\n\n\n+')
 
+    def _set_color(self, color):
+        from _colorize import can_colorize, decolor, get_theme
+
+        if color and can_colorize():
+            self._theme = get_theme(force_color=True).argparse
+            self._decolor = decolor
+        else:
+            self._theme = get_theme(force_no_color=True).argparse
+            self._decolor = lambda text: text
+
     # ===============================
     # Section and indentation methods
     # ===============================
@@ -415,14 +415,7 @@ def _format_actions_usage(self, actions, groups):
         return ' '.join(self._get_actions_usage_parts(actions, groups))
 
     def _is_long_option(self, string):
-        return len(string) >= 2 and string[1] in self._prefix_chars
-
-    def _is_short_option(self, string):
-        return (
-            not self._is_long_option(string)
-            and len(string) >= 1
-            and string[0] in self._prefix_chars
-        )
+        return len(string) > 2
 
     def _get_actions_usage_parts(self, actions, groups):
         # find group indices and identify actions in groups
@@ -471,25 +464,22 @@ def _get_actions_usage_parts(self, actions, groups):
             # produce the first way to invoke the option in brackets
             else:
                 option_string = action.option_strings[0]
+                if self._is_long_option(option_string):
+                    option_color = t.summary_long_option
+                else:
+                    option_color = t.summary_short_option
 
                 # if the Optional doesn't take a value, format is:
                 #    -s or --long
                 if action.nargs == 0:
                     part = action.format_usage()
-                    if self._is_long_option(part):
-                        part = f"{t.summary_long_option}{part}{t.reset}"
-                    elif self._is_short_option(part):
-                        part = f"{t.summary_short_option}{part}{t.reset}"
+                    part = f"{option_color}{part}{t.reset}"
 
                 # if the Optional takes a value, format is:
                 #    -s ARGS or --long ARGS
                 else:
                     default = self._get_default_metavar_for_optional(action)
                     args_string = self._format_args(action, default)
-                    if self._is_long_option(option_string):
-                        option_color = t.summary_long_option
-                    elif self._is_short_option(option_string):
-                        option_color = t.summary_short_option
                     part = (
                         f"{option_color}{option_string} "
                         f"{t.summary_label}{args_string}{t.reset}"
@@ -606,10 +596,8 @@ def color_option_strings(strings):
                 for s in strings:
                     if self._is_long_option(s):
                         parts.append(f"{t.long_option}{s}{t.reset}")
-                    elif self._is_short_option(s):
-                        parts.append(f"{t.short_option}{s}{t.reset}")
                     else:
-                        parts.append(s)
+                        parts.append(f"{t.short_option}{s}{t.reset}")
                 return parts
 
             # if the Optional doesn't take a value, format is:
@@ -2723,16 +2711,9 @@ def format_help(self):
         return formatter.format_help()
 
     def _get_formatter(self):
-        if isinstance(self.formatter_class, type) and issubclass(
-            self.formatter_class, HelpFormatter
-        ):
-            return self.formatter_class(
-                prog=self.prog,
-                prefix_chars=self.prefix_chars,
-                color=self.color,
-            )
-        else:
-            return self.formatter_class(prog=self.prog)
+        formatter = self.formatter_class(prog=self.prog)
+        formatter._set_color(self.color)
+        return formatter
 
     # =====================
     # Help-printing methods
diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py
index 8a264b101bc3ba..58853ba4eb3674 100644
--- a/Lib/test/test_argparse.py
+++ b/Lib/test/test_argparse.py
@@ -5469,11 +5469,60 @@ def custom_type(string):
     version = ''
 
 
-class TestHelpUsageLongSubparserCommand(TestCase):
-    """Test that subparser commands are formatted correctly in help"""
+class TestHelpCustomHelpFormatter(TestCase):
     maxDiff = None
 
-    def test_parent_help(self):
+    def test_custom_formatter_function(self):
+        def custom_formatter(prog):
+            return argparse.RawTextHelpFormatter(prog, indent_increment=5)
+
+        parser = argparse.ArgumentParser(
+                prog='PROG',
+                prefix_chars='-+',
+                formatter_class=custom_formatter
+        )
+        parser.add_argument('+f', '++foo', help="foo help")
+        parser.add_argument('spam', help="spam help")
+
+        parser_help = parser.format_help()
+        self.assertEqual(parser_help, textwrap.dedent('''\
+            usage: PROG [-h] [+f FOO] spam
+
+            positional arguments:
+                 spam           spam help
+
+            options:
+                 -h, --help     show this help message and exit
+                 +f, ++foo FOO  foo help
+        '''))
+
+    def test_custom_formatter_class(self):
+        class CustomFormatter(argparse.RawTextHelpFormatter):
+            def __init__(self, prog):
+                super().__init__(prog, indent_increment=5)
+
+        parser = argparse.ArgumentParser(
+                prog='PROG',
+                prefix_chars='-+',
+                formatter_class=CustomFormatter
+        )
+        parser.add_argument('+f', '++foo', help="foo help")
+        parser.add_argument('spam', help="spam help")
+
+        parser_help = parser.format_help()
+        self.assertEqual(parser_help, textwrap.dedent('''\
+            usage: PROG [-h] [+f FOO] spam
+
+            positional arguments:
+                 spam           spam help
+
+            options:
+                 -h, --help     show this help message and exit
+                 +f, ++foo FOO  foo help
+        '''))
+
+    def test_usage_long_subparser_command(self):
+        """Test that subparser commands are formatted correctly in help"""
         def custom_formatter(prog):
             return argparse.RawTextHelpFormatter(prog, max_help_position=50)
 
@@ -7053,6 +7102,7 @@ def test_translations(self):
 
 
 class TestColorized(TestCase):
+    maxDiff = None
 
     def setUp(self):
         super().setUp()
@@ -7211,6 +7261,79 @@ def test_argparse_color_usage(self):
             ),
         )
 
+    def test_custom_formatter_function(self):
+        def custom_formatter(prog):
+            return argparse.RawTextHelpFormatter(prog, indent_increment=5)
+
+        parser = argparse.ArgumentParser(
+            prog="PROG",
+            prefix_chars="-+",
+            formatter_class=custom_formatter,
+            color=True,
+        )
+        parser.add_argument('+f', '++foo', help="foo help")
+        parser.add_argument('spam', help="spam help")
+
+        prog = self.theme.prog
+        heading = self.theme.heading
+        short = self.theme.summary_short_option
+        label = self.theme.summary_label
+        pos = self.theme.summary_action
+        long_b = self.theme.long_option
+        short_b = self.theme.short_option
+        label_b = self.theme.label
+        pos_b = self.theme.action
+        reset = self.theme.reset
+
+        parser_help = parser.format_help()
+        self.assertEqual(parser_help, textwrap.dedent(f'''\
+            {heading}usage: {reset}{prog}PROG{reset} [{short}-h{reset}] 
[{short}+f {label}FOO{reset}] {pos}spam{reset}
+
+            {heading}positional arguments:{reset}
+                 {pos_b}spam{reset}               spam help
+
+            {heading}options:{reset}
+                 {short_b}-h{reset}, {long_b}--help{reset}         show this 
help message and exit
+                 {short_b}+f{reset}, {long_b}++foo{reset} {label_b}FOO{reset}  
    foo help
+        '''))
+
+    def test_custom_formatter_class(self):
+        class CustomFormatter(argparse.RawTextHelpFormatter):
+            def __init__(self, prog):
+                super().__init__(prog, indent_increment=5)
+
+        parser = argparse.ArgumentParser(
+            prog="PROG",
+            prefix_chars="-+",
+            formatter_class=CustomFormatter,
+            color=True,
+        )
+        parser.add_argument('+f', '++foo', help="foo help")
+        parser.add_argument('spam', help="spam help")
+
+        prog = self.theme.prog
+        heading = self.theme.heading
+        short = self.theme.summary_short_option
+        label = self.theme.summary_label
+        pos = self.theme.summary_action
+        long_b = self.theme.long_option
+        short_b = self.theme.short_option
+        label_b = self.theme.label
+        pos_b = self.theme.action
+        reset = self.theme.reset
+
+        parser_help = parser.format_help()
+        self.assertEqual(parser_help, textwrap.dedent(f'''\
+            {heading}usage: {reset}{prog}PROG{reset} [{short}-h{reset}] 
[{short}+f {label}FOO{reset}] {pos}spam{reset}
+
+            {heading}positional arguments:{reset}
+                 {pos_b}spam{reset}               spam help
+
+            {heading}options:{reset}
+                 {short_b}-h{reset}, {long_b}--help{reset}         show this 
help message and exit
+                 {short_b}+f{reset}, {long_b}++foo{reset} {label_b}FOO{reset}  
    foo help
+        '''))
+
 
 def tearDownModule():
     # Remove global references to avoid looking like we have refleaks.
diff --git 
a/Misc/NEWS.d/next/Library/2025-05-10-12-06-55.gh-issue-133653.Gb2aG4.rst 
b/Misc/NEWS.d/next/Library/2025-05-10-12-06-55.gh-issue-133653.Gb2aG4.rst
new file mode 100644
index 00000000000000..56fb1dc31dc22f
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2025-05-10-12-06-55.gh-issue-133653.Gb2aG4.rst
@@ -0,0 +1,7 @@
+Fix :class:`argparse.ArgumentParser` with the *formatter_class* argument.
+Fix TypeError when *formatter_class* is a custom subclass of
+:class:`!HelpFormatter`.
+Fix TypeError when *formatter_class* is not a subclass of
+:class:`!HelpFormatter` and non-standard *prefix_char* is used.
+Fix support of colorizing when *formatter_class* is not a subclass of
+:class:`!HelpFormatter`.

_______________________________________________
Python-checkins mailing list -- python-checkins@python.org
To unsubscribe send an email to python-checkins-le...@python.org
https://mail.python.org/mailman3/lists/python-checkins.python.org/
Member address: arch...@mail-archive.com

Reply via email to