Skip to content

Commit 63ab079

Browse files
serhiy-storchakamiss-islington
authored andcommitted
pythongh-142346: Fix usage formatting for mutually exclusive groups in argparse (pythonGH-142381)
Support groups preceded by positional arguments or followed or intermixed with other optional arguments. Support empty groups. (cherry picked from commit 1db9f56) Co-authored-by: Serhiy Storchaka <[email protected]>
1 parent b188ebe commit 63ab079

File tree

3 files changed

+131
-146
lines changed

3 files changed

+131
-146
lines changed

Lib/argparse.py

Lines changed: 92 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -337,31 +337,15 @@ def _format_usage(self, usage, actions, groups, prefix):
337337
elif usage is None:
338338
prog = '%(prog)s' % dict(prog=self._prog)
339339

340-
# split optionals from positionals
341-
optionals = []
342-
positionals = []
343-
for action in actions:
344-
if action.option_strings:
345-
optionals.append(action)
346-
else:
347-
positionals.append(action)
348-
340+
parts, pos_start = self._get_actions_usage_parts(actions, groups)
349341
# build full usage string
350-
format = self._format_actions_usage
351-
action_usage = format(optionals + positionals, groups)
352-
usage = ' '.join([s for s in [prog, action_usage] if s])
342+
usage = ' '.join(filter(None, [prog, *parts]))
353343

354344
# wrap the usage parts if it's too long
355345
text_width = self._width - self._current_indent
356346
if len(prefix) + len(self._decolor(usage)) > text_width:
357347

358348
# break usage into wrappable parts
359-
# keep optionals and positionals together to preserve
360-
# mutually exclusive group formatting (gh-75949)
361-
all_actions = optionals + positionals
362-
parts, pos_start = self._get_actions_usage_parts_with_split(
363-
all_actions, groups, len(optionals)
364-
)
365349
opt_parts = parts[:pos_start]
366350
pos_parts = parts[pos_start:]
367351

@@ -420,125 +404,114 @@ def get_lines(parts, indent, prefix=None):
420404
# prefix with 'usage:'
421405
return f'{t.usage}{prefix}{t.reset}{usage}\n\n'
422406

423-
def _format_actions_usage(self, actions, groups):
424-
return ' '.join(self._get_actions_usage_parts(actions, groups))
425-
426407
def _is_long_option(self, string):
427408
return len(string) > 2
428409

429410
def _get_actions_usage_parts(self, actions, groups):
430-
parts, _ = self._get_actions_usage_parts_with_split(actions, groups)
431-
return parts
432-
433-
def _get_actions_usage_parts_with_split(self, actions, groups, opt_count=None):
434411
"""Get usage parts with split index for optionals/positionals.
435412
436413
Returns (parts, pos_start) where pos_start is the index in parts
437-
where positionals begin. When opt_count is None, pos_start is None.
414+
where positionals begin.
438415
This preserves mutually exclusive group formatting across the
439416
optionals/positionals boundary (gh-75949).
440417
"""
441-
# find group indices and identify actions in groups
442-
group_actions = set()
443-
inserts = {}
418+
actions = [action for action in actions if action.help is not SUPPRESS]
419+
# group actions by mutually exclusive groups
420+
action_groups = dict.fromkeys(actions)
444421
for group in groups:
445-
if not group._group_actions:
446-
raise ValueError(f'empty group {group}')
447-
448-
if all(action.help is SUPPRESS for action in group._group_actions):
449-
continue
450-
451-
try:
452-
start = min(actions.index(item) for item in group._group_actions)
453-
except ValueError:
454-
continue
455-
else:
456-
end = start + len(group._group_actions)
457-
if set(actions[start:end]) == set(group._group_actions):
458-
group_actions.update(group._group_actions)
459-
inserts[start, end] = group
422+
for action in group._group_actions:
423+
if action in action_groups:
424+
action_groups[action] = group
425+
# positional arguments keep their position
426+
positionals = []
427+
for action in actions:
428+
if not action.option_strings:
429+
group = action_groups.pop(action)
430+
if group:
431+
group_actions = [
432+
action2 for action2 in group._group_actions
433+
if action2.option_strings and
434+
action_groups.pop(action2, None)
435+
] + [action]
436+
positionals.append((group.required, group_actions))
437+
else:
438+
positionals.append((None, [action]))
439+
# the remaining optional arguments are sorted by the position of
440+
# the first option in the group
441+
optionals = []
442+
for action in actions:
443+
if action.option_strings and action in action_groups:
444+
group = action_groups.pop(action)
445+
if group:
446+
group_actions = [action] + [
447+
action2 for action2 in group._group_actions
448+
if action2.option_strings and
449+
action_groups.pop(action2, None)
450+
]
451+
optionals.append((group.required, group_actions))
452+
else:
453+
optionals.append((None, [action]))
460454

461455
# collect all actions format strings
462456
parts = []
463457
t = self._theme
464-
for action in actions:
465-
466-
# suppressed arguments are marked with None
467-
if action.help is SUPPRESS:
468-
part = None
469-
470-
# produce all arg strings
471-
elif not action.option_strings:
472-
default = self._get_default_metavar_for_positional(action)
473-
part = self._format_args(action, default)
474-
# if it's in a group, strip the outer []
475-
if action in group_actions:
476-
if part[0] == '[' and part[-1] == ']':
477-
part = part[1:-1]
478-
part = t.summary_action + part + t.reset
479-
480-
# produce the first way to invoke the option in brackets
481-
else:
482-
option_string = action.option_strings[0]
483-
if self._is_long_option(option_string):
484-
option_color = t.summary_long_option
458+
pos_start = None
459+
for i, (required, group) in enumerate(optionals + positionals):
460+
start = len(parts)
461+
if i == len(optionals):
462+
pos_start = start
463+
in_group = len(group) > 1
464+
for action in group:
465+
# produce all arg strings
466+
if not action.option_strings:
467+
default = self._get_default_metavar_for_positional(action)
468+
part = self._format_args(action, default)
469+
# if it's in a group, strip the outer []
470+
if in_group:
471+
if part[0] == '[' and part[-1] == ']':
472+
part = part[1:-1]
473+
part = t.summary_action + part + t.reset
474+
475+
# produce the first way to invoke the option in brackets
485476
else:
486-
option_color = t.summary_short_option
487-
488-
# if the Optional doesn't take a value, format is:
489-
# -s or --long
490-
if action.nargs == 0:
491-
part = action.format_usage()
492-
part = f"{option_color}{part}{t.reset}"
477+
option_string = action.option_strings[0]
478+
if self._is_long_option(option_string):
479+
option_color = t.summary_long_option
480+
else:
481+
option_color = t.summary_short_option
493482

494-
# if the Optional takes a value, format is:
495-
# -s ARGS or --long ARGS
496-
else:
497-
default = self._get_default_metavar_for_optional(action)
498-
args_string = self._format_args(action, default)
499-
part = (
500-
f"{option_color}{option_string} "
501-
f"{t.summary_label}{args_string}{t.reset}"
502-
)
503-
504-
# make it look optional if it's not required or in a group
505-
if not action.required and action not in group_actions:
506-
part = '[%s]' % part
507-
508-
# add the action string to the list
509-
parts.append(part)
510-
511-
# group mutually exclusive actions
512-
inserted_separators_indices = set()
513-
for start, end in sorted(inserts, reverse=True):
514-
group = inserts[start, end]
515-
group_parts = [item for item in parts[start:end] if item is not None]
516-
group_size = len(group_parts)
517-
if group.required:
518-
open, close = "()" if group_size > 1 else ("", "")
519-
else:
520-
open, close = "[]"
521-
group_parts[0] = open + group_parts[0]
522-
group_parts[-1] = group_parts[-1] + close
523-
for i, part in enumerate(group_parts[:-1], start=start):
524-
# insert a separator if not already done in a nested group
525-
if i not in inserted_separators_indices:
526-
parts[i] = part + ' |'
527-
inserted_separators_indices.add(i)
528-
parts[start + group_size - 1] = group_parts[-1]
529-
for i in range(start + group_size, end):
530-
parts[i] = None
531-
532-
# if opt_count is provided, calculate where positionals start in
533-
# the final parts list (for wrapping onto separate lines).
534-
# Count before filtering None entries since indices shift after.
535-
if opt_count is not None:
536-
pos_start = sum(1 for p in parts[:opt_count] if p is not None)
537-
else:
538-
pos_start = None
483+
# if the Optional doesn't take a value, format is:
484+
# -s or --long
485+
if action.nargs == 0:
486+
part = action.format_usage()
487+
part = f"{option_color}{part}{t.reset}"
539488

540-
# return the usage parts and split point (gh-75949)
541-
return [item for item in parts if item is not None], pos_start
489+
# if the Optional takes a value, format is:
490+
# -s ARGS or --long ARGS
491+
else:
492+
default = self._get_default_metavar_for_optional(action)
493+
args_string = self._format_args(action, default)
494+
part = (
495+
f"{option_color}{option_string} "
496+
f"{t.summary_label}{args_string}{t.reset}"
497+
)
498+
499+
# make it look optional if it's not required or in a group
500+
if not (action.required or required or in_group):
501+
part = '[%s]' % part
502+
503+
# add the action string to the list
504+
parts.append(part)
505+
506+
if in_group:
507+
parts[start] = ('(' if required else '[') + parts[start]
508+
for i in range(start, len(parts) - 1):
509+
parts[i] += ' |'
510+
parts[-1] += ')' if required else ']'
511+
512+
if pos_start is None:
513+
pos_start = len(parts)
514+
return parts, pos_start
542515

543516
def _format_text(self, text):
544517
if '%(prog)' in text:

Lib/test/test_argparse.py

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3307,12 +3307,11 @@ def test_help_subparser_all_mutually_exclusive_group_members_suppressed(self):
33073307
'''
33083308
self.assertEqual(cmd_foo.format_help(), textwrap.dedent(expected))
33093309

3310-
def test_empty_group(self):
3310+
def test_usage_empty_group(self):
33113311
# See issue 26952
3312-
parser = argparse.ArgumentParser()
3312+
parser = ErrorRaisingArgumentParser(prog='PROG')
33133313
group = parser.add_mutually_exclusive_group()
3314-
with self.assertRaises(ValueError):
3315-
parser.parse_args(['-h'])
3314+
self.assertEqual(parser.format_usage(), 'usage: PROG [-h]\n')
33163315

33173316
def test_nested_mutex_groups(self):
33183317
parser = argparse.ArgumentParser(prog='PROG')
@@ -3580,25 +3579,29 @@ def get_parser(self, required):
35803579
group.add_argument('-b', action='store_true', help='b help')
35813580
parser.add_argument('-y', action='store_true', help='y help')
35823581
group.add_argument('-c', action='store_true', help='c help')
3582+
parser.add_argument('-z', action='store_true', help='z help')
35833583
return parser
35843584

35853585
failures = ['-a -b', '-b -c', '-a -c', '-a -b -c']
35863586
successes = [
3587-
('-a', NS(a=True, b=False, c=False, x=False, y=False)),
3588-
('-b', NS(a=False, b=True, c=False, x=False, y=False)),
3589-
('-c', NS(a=False, b=False, c=True, x=False, y=False)),
3590-
('-a -x', NS(a=True, b=False, c=False, x=True, y=False)),
3591-
('-y -b', NS(a=False, b=True, c=False, x=False, y=True)),
3592-
('-x -y -c', NS(a=False, b=False, c=True, x=True, y=True)),
3587+
('-a', NS(a=True, b=False, c=False, x=False, y=False, z=False)),
3588+
('-b', NS(a=False, b=True, c=False, x=False, y=False, z=False)),
3589+
('-c', NS(a=False, b=False, c=True, x=False, y=False, z=False)),
3590+
('-a -x', NS(a=True, b=False, c=False, x=True, y=False, z=False)),
3591+
('-y -b', NS(a=False, b=True, c=False, x=False, y=True, z=False)),
3592+
('-x -y -c', NS(a=False, b=False, c=True, x=True, y=True, z=False)),
35933593
]
35943594
successes_when_not_required = [
3595-
('', NS(a=False, b=False, c=False, x=False, y=False)),
3596-
('-x', NS(a=False, b=False, c=False, x=True, y=False)),
3597-
('-y', NS(a=False, b=False, c=False, x=False, y=True)),
3595+
('', NS(a=False, b=False, c=False, x=False, y=False, z=False)),
3596+
('-x', NS(a=False, b=False, c=False, x=True, y=False, z=False)),
3597+
('-y', NS(a=False, b=False, c=False, x=False, y=True, z=False)),
35983598
]
35993599

3600-
usage_when_required = usage_when_not_required = '''\
3601-
usage: PROG [-h] [-x] [-a] [-b] [-y] [-c]
3600+
usage_when_not_required = '''\
3601+
usage: PROG [-h] [-x] [-a | -b | -c] [-y] [-z]
3602+
'''
3603+
usage_when_required = '''\
3604+
usage: PROG [-h] [-x] (-a | -b | -c) [-y] [-z]
36023605
'''
36033606
help = '''\
36043607
@@ -3609,6 +3612,7 @@ def get_parser(self, required):
36093612
-b b help
36103613
-y y help
36113614
-c c help
3615+
-z z help
36123616
'''
36133617

36143618

@@ -3662,23 +3666,27 @@ def get_parser(self, required):
36623666
group.add_argument('a', nargs='?', help='a help')
36633667
group.add_argument('-b', action='store_true', help='b help')
36643668
group.add_argument('-c', action='store_true', help='c help')
3669+
parser.add_argument('-z', action='store_true', help='z help')
36653670
return parser
36663671

36673672
failures = ['X A -b', '-b -c', '-c X A']
36683673
successes = [
3669-
('X A', NS(a='A', b=False, c=False, x='X', y=False)),
3670-
('X -b', NS(a=None, b=True, c=False, x='X', y=False)),
3671-
('X -c', NS(a=None, b=False, c=True, x='X', y=False)),
3672-
('X A -y', NS(a='A', b=False, c=False, x='X', y=True)),
3673-
('X -y -b', NS(a=None, b=True, c=False, x='X', y=True)),
3674+
('X A', NS(a='A', b=False, c=False, x='X', y=False, z=False)),
3675+
('X -b', NS(a=None, b=True, c=False, x='X', y=False, z=False)),
3676+
('X -c', NS(a=None, b=False, c=True, x='X', y=False, z=False)),
3677+
('X A -y', NS(a='A', b=False, c=False, x='X', y=True, z=False)),
3678+
('X -y -b', NS(a=None, b=True, c=False, x='X', y=True, z=False)),
36743679
]
36753680
successes_when_not_required = [
3676-
('X', NS(a=None, b=False, c=False, x='X', y=False)),
3677-
('X -y', NS(a=None, b=False, c=False, x='X', y=True)),
3681+
('X', NS(a=None, b=False, c=False, x='X', y=False, z=False)),
3682+
('X -y', NS(a=None, b=False, c=False, x='X', y=True, z=False)),
36783683
]
36793684

3680-
usage_when_required = usage_when_not_required = '''\
3681-
usage: PROG [-h] [-y] [-b] [-c] x [a]
3685+
usage_when_not_required = '''\
3686+
usage: PROG [-h] [-y] [-z] x [-b | -c | a]
3687+
'''
3688+
usage_when_required = '''\
3689+
usage: PROG [-h] [-y] [-z] x (-b | -c | a)
36823690
'''
36833691
help = '''\
36843692
@@ -3691,6 +3699,7 @@ def get_parser(self, required):
36913699
-y y help
36923700
-b b help
36933701
-c c help
3702+
-z z help
36943703
'''
36953704

36963705

@@ -4898,9 +4907,9 @@ def test_mutex_groups_with_mixed_optionals_positionals_wrap(self):
48984907
g.add_argument('positional', nargs='?')
48994908

49004909
usage = textwrap.dedent('''\
4901-
usage: PROG [-h] [-v | -q | -x [EXTRA_LONG_OPTION_NAME] |
4902-
-y [YET_ANOTHER_LONG_OPTION] |
4903-
positional]
4910+
usage: PROG [-h]
4911+
[-v | -q | -x [EXTRA_LONG_OPTION_NAME] |
4912+
-y [YET_ANOTHER_LONG_OPTION] | positional]
49044913
''')
49054914
self.assertEqual(parser.format_usage(), usage)
49064915

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix usage formatting for mutually exclusive groups in :mod:`argparse`
2+
when they are preceded by positional arguments or followed or intermixed
3+
with other optional arguments.

0 commit comments

Comments
 (0)