mirror of
https://github.com/python/cpython.git
synced 2025-12-08 06:10:17 +00:00
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:
parent
d6d850df89
commit
1db9f56bff
3 changed files with 126 additions and 141 deletions
137
Lib/argparse.py
137
Lib/argparse.py
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue