GH-75949: Fix argparse dropping '|' in mutually exclusive groups on line wrap (#142312)

This commit is contained in:
Savannah Ostrowski 2025-12-06 07:12:21 -08:00 committed by GitHub
parent 61823a5382
commit 5be3405e4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 49 additions and 4 deletions

View file

@ -353,8 +353,14 @@ def _format_usage(self, usage, actions, groups, prefix):
if len(prefix) + len(self._decolor(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):
@ -418,6 +424,17 @@ def _is_long_option(self, string):
return len(string) > 2
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 = {}
@ -513,8 +530,16 @@ def _get_actions_usage_parts(self, actions, groups):
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:

View file

@ -4966,6 +4966,25 @@ def test_long_mutex_groups_wrap(self):
''')
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"""

View file

@ -0,0 +1 @@
Fix :mod:`argparse` to preserve ``|`` separators in mutually exclusive groups when the usage line wraps due to length.