gh-142346: Fix usage formatting for mutually exclusive groups in argparse (GH-142381)

Support groups preceded by positional arguments or followed or intermixed
with other optional arguments. Support empty groups.
This commit is contained in:
Serhiy Storchaka 2025-12-07 21:36:01 +02:00 committed by GitHub
parent d6d850df89
commit 1db9f56bff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 126 additions and 141 deletions

View file

@ -334,31 +334,15 @@ def _format_usage(self, usage, actions, groups, prefix):
elif usage is None: elif usage is None:
prog = '%(prog)s' % dict(prog=self._prog) prog = '%(prog)s' % dict(prog=self._prog)
# split optionals from positionals parts, pos_start = self._get_actions_usage_parts(actions, groups)
optionals = []
positionals = []
for action in actions:
if action.option_strings:
optionals.append(action)
else:
positionals.append(action)
# build full usage string # build full usage string
format = self._format_actions_usage usage = ' '.join(filter(None, [prog, *parts]))
action_usage = format(optionals + positionals, groups)
usage = ' '.join([s for s in [prog, action_usage] if s])
# wrap the usage parts if it's too long # wrap the usage parts if it's too long
text_width = self._width - self._current_indent text_width = self._width - self._current_indent
if len(prefix) + len(self._decolor(usage)) > text_width: if len(prefix) + len(self._decolor(usage)) > text_width:
# break usage into wrappable parts # break usage into wrappable parts
# 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] opt_parts = parts[:pos_start]
pos_parts = parts[pos_start:] pos_parts = parts[pos_start:]
@ -417,59 +401,70 @@ def get_lines(parts, indent, prefix=None):
# prefix with 'usage:' # prefix with 'usage:'
return f'{t.usage}{prefix}{t.reset}{usage}\n\n' return f'{t.usage}{prefix}{t.reset}{usage}\n\n'
def _format_actions_usage(self, actions, groups):
return ' '.join(self._get_actions_usage_parts(actions, groups))
def _is_long_option(self, string): def _is_long_option(self, string):
return len(string) > 2 return len(string) > 2
def _get_actions_usage_parts(self, 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. """Get usage parts with split index for optionals/positionals.
Returns (parts, pos_start) where pos_start is the index in parts Returns (parts, pos_start) where pos_start is the index in parts
where positionals begin. When opt_count is None, pos_start is None. where positionals begin.
This preserves mutually exclusive group formatting across the This preserves mutually exclusive group formatting across the
optionals/positionals boundary (gh-75949). optionals/positionals boundary (gh-75949).
""" """
# find group indices and identify actions in groups actions = [action for action in actions if action.help is not SUPPRESS]
group_actions = set() # group actions by mutually exclusive groups
inserts = {} action_groups = dict.fromkeys(actions)
for group in groups: for group in groups:
if not group._group_actions: for action in group._group_actions:
raise ValueError(f'empty group {group}') if action in action_groups:
action_groups[action] = group
if all(action.help is SUPPRESS for action in group._group_actions): # positional arguments keep their position
continue positionals = []
for action in actions:
try: if not action.option_strings:
start = min(actions.index(item) for item in group._group_actions) group = action_groups.pop(action)
except ValueError: if group:
continue group_actions = [
action2 for action2 in group._group_actions
if action2.option_strings and
action_groups.pop(action2, None)
] + [action]
positionals.append((group.required, group_actions))
else: else:
end = start + len(group._group_actions) positionals.append((None, [action]))
if set(actions[start:end]) == set(group._group_actions): # the remaining optional arguments are sorted by the position of
group_actions.update(group._group_actions) # the first option in the group
inserts[start, end] = group optionals = []
for action in actions:
if action.option_strings and action in action_groups:
group = action_groups.pop(action)
if group:
group_actions = [action] + [
action2 for action2 in group._group_actions
if action2.option_strings and
action_groups.pop(action2, None)
]
optionals.append((group.required, group_actions))
else:
optionals.append((None, [action]))
# collect all actions format strings # collect all actions format strings
parts = [] parts = []
t = self._theme t = self._theme
for action in actions: pos_start = None
for i, (required, group) in enumerate(optionals + positionals):
# suppressed arguments are marked with None start = len(parts)
if action.help is SUPPRESS: if i == len(optionals):
part = None pos_start = start
in_group = len(group) > 1
for action in group:
# produce all arg strings # produce all arg strings
elif not action.option_strings: if not action.option_strings:
default = self._get_default_metavar_for_positional(action) default = self._get_default_metavar_for_positional(action)
part = self._format_args(action, default) part = self._format_args(action, default)
# if it's in a group, strip the outer [] # if it's in a group, strip the outer []
if action in group_actions: if in_group:
if part[0] == '[' and part[-1] == ']': if part[0] == '[' and part[-1] == ']':
part = part[1:-1] part = part[1:-1]
part = t.summary_action + part + t.reset part = t.summary_action + part + t.reset
@ -499,43 +494,21 @@ def _get_actions_usage_parts_with_split(self, actions, groups, opt_count=None):
) )
# make it look optional if it's not required or in a group # make it look optional if it's not required or in a group
if not action.required and action not in group_actions: if not (action.required or required or in_group):
part = '[%s]' % part part = '[%s]' % part
# add the action string to the list # add the action string to the list
parts.append(part) parts.append(part)
# group mutually exclusive actions if in_group:
inserted_separators_indices = set() parts[start] = ('(' if required else '[') + parts[start]
for start, end in sorted(inserts, reverse=True): for i in range(start, len(parts) - 1):
group = inserts[start, end] parts[i] += ' |'
group_parts = [item for item in parts[start:end] if item is not None] parts[-1] += ')' if required else ']'
group_size = len(group_parts)
if group.required:
open, close = "()" if group_size > 1 else ("", "")
else:
open, close = "[]"
group_parts[0] = open + group_parts[0]
group_parts[-1] = group_parts[-1] + close
for i, part in enumerate(group_parts[:-1], start=start):
# insert a separator if not already done in a nested group
if i not in inserted_separators_indices:
parts[i] = part + ' |'
inserted_separators_indices.add(i)
parts[start + group_size - 1] = group_parts[-1]
for i in range(start + group_size, end):
parts[i] = None
# if opt_count is provided, calculate where positionals start in if pos_start is None:
# the final parts list (for wrapping onto separate lines). pos_start = len(parts)
# Count before filtering None entries since indices shift after. return parts, pos_start
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): def _format_text(self, text):
if '%(prog)' in text: if '%(prog)' in text:

View file

@ -3398,12 +3398,11 @@ def test_help_subparser_all_mutually_exclusive_group_members_suppressed(self):
''' '''
self.assertEqual(cmd_foo.format_help(), textwrap.dedent(expected)) self.assertEqual(cmd_foo.format_help(), textwrap.dedent(expected))
def test_empty_group(self): def test_usage_empty_group(self):
# See issue 26952 # See issue 26952
parser = argparse.ArgumentParser() parser = ErrorRaisingArgumentParser(prog='PROG')
group = parser.add_mutually_exclusive_group() group = parser.add_mutually_exclusive_group()
with self.assertRaises(ValueError): self.assertEqual(parser.format_usage(), 'usage: PROG [-h]\n')
parser.parse_args(['-h'])
def test_nested_mutex_groups(self): def test_nested_mutex_groups(self):
parser = argparse.ArgumentParser(prog='PROG') parser = argparse.ArgumentParser(prog='PROG')
@ -3671,25 +3670,29 @@ def get_parser(self, required):
group.add_argument('-b', action='store_true', help='b help') group.add_argument('-b', action='store_true', help='b help')
parser.add_argument('-y', action='store_true', help='y help') parser.add_argument('-y', action='store_true', help='y help')
group.add_argument('-c', action='store_true', help='c help') group.add_argument('-c', action='store_true', help='c help')
parser.add_argument('-z', action='store_true', help='z help')
return parser return parser
failures = ['-a -b', '-b -c', '-a -c', '-a -b -c'] failures = ['-a -b', '-b -c', '-a -c', '-a -b -c']
successes = [ successes = [
('-a', NS(a=True, b=False, c=False, x=False, y=False)), ('-a', NS(a=True, b=False, c=False, x=False, y=False, z=False)),
('-b', NS(a=False, b=True, c=False, x=False, y=False)), ('-b', NS(a=False, b=True, c=False, x=False, y=False, z=False)),
('-c', NS(a=False, b=False, c=True, x=False, y=False)), ('-c', NS(a=False, b=False, c=True, x=False, y=False, z=False)),
('-a -x', NS(a=True, b=False, c=False, x=True, y=False)), ('-a -x', NS(a=True, b=False, c=False, x=True, y=False, z=False)),
('-y -b', NS(a=False, b=True, c=False, x=False, y=True)), ('-y -b', NS(a=False, b=True, c=False, x=False, y=True, z=False)),
('-x -y -c', NS(a=False, b=False, c=True, x=True, y=True)), ('-x -y -c', NS(a=False, b=False, c=True, x=True, y=True, z=False)),
] ]
successes_when_not_required = [ successes_when_not_required = [
('', NS(a=False, b=False, c=False, x=False, y=False)), ('', NS(a=False, b=False, c=False, x=False, y=False, z=False)),
('-x', NS(a=False, b=False, c=False, x=True, y=False)), ('-x', NS(a=False, b=False, c=False, x=True, y=False, z=False)),
('-y', NS(a=False, b=False, c=False, x=False, y=True)), ('-y', NS(a=False, b=False, c=False, x=False, y=True, z=False)),
] ]
usage_when_required = usage_when_not_required = '''\ usage_when_not_required = '''\
usage: PROG [-h] [-x] [-a] [-b] [-y] [-c] usage: PROG [-h] [-x] [-a | -b | -c] [-y] [-z]
'''
usage_when_required = '''\
usage: PROG [-h] [-x] (-a | -b | -c) [-y] [-z]
''' '''
help = '''\ help = '''\
@ -3700,6 +3703,7 @@ def get_parser(self, required):
-b b help -b b help
-y y help -y y help
-c c help -c c help
-z z help
''' '''
@ -3753,23 +3757,27 @@ def get_parser(self, required):
group.add_argument('a', nargs='?', help='a help') group.add_argument('a', nargs='?', help='a help')
group.add_argument('-b', action='store_true', help='b help') group.add_argument('-b', action='store_true', help='b help')
group.add_argument('-c', action='store_true', help='c help') group.add_argument('-c', action='store_true', help='c help')
parser.add_argument('-z', action='store_true', help='z help')
return parser return parser
failures = ['X A -b', '-b -c', '-c X A'] failures = ['X A -b', '-b -c', '-c X A']
successes = [ successes = [
('X A', NS(a='A', b=False, c=False, x='X', y=False)), ('X A', NS(a='A', b=False, c=False, x='X', y=False, z=False)),
('X -b', NS(a=None, b=True, c=False, x='X', y=False)), ('X -b', NS(a=None, b=True, c=False, x='X', y=False, z=False)),
('X -c', NS(a=None, b=False, c=True, x='X', y=False)), ('X -c', NS(a=None, b=False, c=True, x='X', y=False, z=False)),
('X A -y', NS(a='A', b=False, c=False, x='X', y=True)), ('X A -y', NS(a='A', b=False, c=False, x='X', y=True, z=False)),
('X -y -b', NS(a=None, b=True, c=False, x='X', y=True)), ('X -y -b', NS(a=None, b=True, c=False, x='X', y=True, z=False)),
] ]
successes_when_not_required = [ successes_when_not_required = [
('X', NS(a=None, b=False, c=False, x='X', y=False)), ('X', NS(a=None, b=False, c=False, x='X', y=False, z=False)),
('X -y', NS(a=None, b=False, c=False, x='X', y=True)), ('X -y', NS(a=None, b=False, c=False, x='X', y=True, z=False)),
] ]
usage_when_required = usage_when_not_required = '''\ usage_when_not_required = '''\
usage: PROG [-h] [-y] [-b] [-c] x [a] usage: PROG [-h] [-y] [-z] x [-b | -c | a]
'''
usage_when_required = '''\
usage: PROG [-h] [-y] [-z] x (-b | -c | a)
''' '''
help = '''\ help = '''\
@ -3782,6 +3790,7 @@ def get_parser(self, required):
-y y help -y y help
-b b help -b b help
-c c help -c c help
-z z help
''' '''
@ -4989,9 +4998,9 @@ def test_mutex_groups_with_mixed_optionals_positionals_wrap(self):
g.add_argument('positional', nargs='?') g.add_argument('positional', nargs='?')
usage = textwrap.dedent('''\ usage = textwrap.dedent('''\
usage: PROG [-h] [-v | -q | -x [EXTRA_LONG_OPTION_NAME] | usage: PROG [-h]
-y [YET_ANOTHER_LONG_OPTION] | [-v | -q | -x [EXTRA_LONG_OPTION_NAME] |
positional] -y [YET_ANOTHER_LONG_OPTION] | positional]
''') ''')
self.assertEqual(parser.format_usage(), usage) self.assertEqual(parser.format_usage(), usage)

View file

@ -0,0 +1,3 @@
Fix usage formatting for mutually exclusive groups in :mod:`argparse`
when they are preceded by positional arguments or followed or intermixed
with other optional arguments.