]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
[3.13] GH-75949: Fix argparse dropping '|' in mutually exclusive groups on line wrap...
authorMiss Islington (bot) <31488909+miss-islington@users.noreply.github.com>
Sat, 6 Dec 2025 15:35:01 +0000 (16:35 +0100)
committerGitHub <noreply@github.com>
Sat, 6 Dec 2025 15:35:01 +0000 (15:35 +0000)
Lib/argparse.py
Lib/test/test_argparse.py
Misc/NEWS.d/next/Library/2025-12-05-16-39-17.gh-issue-75949.pHxW98.rst [new file with mode: 0644]

index bd088ea0e66a0484f8e9390d421e769956eb3519..adb35dd0180c0cda97deeef11923db7fa14959ff 100644 (file)
@@ -330,8 +330,14 @@ class HelpFormatter(object):
             if len(prefix) + len(usage) > text_width:
 
                 # break usage into wrappable parts
-                opt_parts = self._get_actions_usage_parts(optionals, groups)
-                pos_parts = self._get_actions_usage_parts(positionals, groups)
+                # keep optionals and positionals together to preserve
+                # mutually exclusive group formatting (gh-75949)
+                all_actions = optionals + positionals
+                parts, pos_start = self._get_actions_usage_parts_with_split(
+                    all_actions, groups, len(optionals)
+                )
+                opt_parts = parts[:pos_start]
+                pos_parts = parts[pos_start:]
 
                 # helper for wrapping lines
                 def get_lines(parts, indent, prefix=None):
@@ -387,6 +393,17 @@ class HelpFormatter(object):
         return ' '.join(self._get_actions_usage_parts(actions, groups))
 
     def _get_actions_usage_parts(self, actions, groups):
+        parts, _ = self._get_actions_usage_parts_with_split(actions, groups)
+        return parts
+
+    def _get_actions_usage_parts_with_split(self, actions, groups, opt_count=None):
+        """Get usage parts with split index for optionals/positionals.
+
+        Returns (parts, pos_start) where pos_start is the index in parts
+        where positionals begin. When opt_count is None, pos_start is None.
+        This preserves mutually exclusive group formatting across the
+        optionals/positionals boundary (gh-75949).
+        """
         # find group indices and identify actions in groups
         group_actions = set()
         inserts = {}
@@ -469,8 +486,16 @@ class HelpFormatter(object):
             for i in range(start + group_size, end):
                 parts[i] = None
 
-        # return the usage parts
-        return [item for item in parts if item is not None]
+        # if opt_count is provided, calculate where positionals start in
+        # the final parts list (for wrapping onto separate lines).
+        # Count before filtering None entries since indices shift after.
+        if opt_count is not None:
+            pos_start = sum(1 for p in parts[:opt_count] if p is not None)
+        else:
+            pos_start = None
+
+        # return the usage parts and split point (gh-75949)
+        return [item for item in parts if item is not None], pos_start
 
     def _format_text(self, text):
         if '%(prog)' in text:
index b7e995334fedb1c004b4624b9c95d9857f23eaf1..8974247e6e955eb88d7053aa15b754b4ec483f98 100644 (file)
@@ -4700,6 +4700,25 @@ class TestHelpUsageNoWhitespaceCrash(TestCase):
         ''')
         self.assertEqual(parser.format_usage(), usage)
 
+    def test_mutex_groups_with_mixed_optionals_positionals_wrap(self):
+        # https://github.com/python/cpython/issues/75949
+        # Mutually exclusive groups containing both optionals and positionals
+        # should preserve pipe separators when the usage line wraps.
+        parser = argparse.ArgumentParser(prog='PROG')
+        g = parser.add_mutually_exclusive_group()
+        g.add_argument('-v', '--verbose', action='store_true')
+        g.add_argument('-q', '--quiet', action='store_true')
+        g.add_argument('-x', '--extra-long-option-name', nargs='?')
+        g.add_argument('-y', '--yet-another-long-option', nargs='?')
+        g.add_argument('positional', nargs='?')
+
+        usage = textwrap.dedent('''\
+        usage: PROG [-h] [-v | -q | -x [EXTRA_LONG_OPTION_NAME] |
+                    -y [YET_ANOTHER_LONG_OPTION] |
+                    positional]
+        ''')
+        self.assertEqual(parser.format_usage(), usage)
+
 
 class TestHelpVariableExpansion(HelpTestCase):
     """Test that variables are expanded properly in help messages"""
diff --git a/Misc/NEWS.d/next/Library/2025-12-05-16-39-17.gh-issue-75949.pHxW98.rst b/Misc/NEWS.d/next/Library/2025-12-05-16-39-17.gh-issue-75949.pHxW98.rst
new file mode 100644 (file)
index 0000000..5ca3fc0
--- /dev/null
@@ -0,0 +1 @@
+Fix :mod:`argparse` to preserve ``|`` separators in mutually exclusive groups when the usage line wraps due to length.