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:
prog = '%(prog)s' % dict(prog=self._prog)
# split optionals from positionals
optionals = []
positionals = []
for action in actions:
if action.option_strings:
optionals.append(action)
else:
positionals.append(action)
parts, pos_start = self._get_actions_usage_parts(actions, groups)
# build full usage string
format = self._format_actions_usage
action_usage = format(optionals + positionals, groups)
usage = ' '.join([s for s in [prog, action_usage] if s])
usage = ' '.join(filter(None, [prog, *parts]))
# wrap the usage parts if it's too long
text_width = self._width - self._current_indent
if len(prefix) + len(self._decolor(usage)) > text_width:
# 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]
pos_parts = parts[pos_start:]
@ -417,125 +401,114 @@ def get_lines(parts, indent, prefix=None):
# prefix with 'usage:'
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):
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.
where positionals begin.
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 = {}
actions = [action for action in actions if action.help is not SUPPRESS]
# group actions by mutually exclusive groups
action_groups = dict.fromkeys(actions)
for group in groups:
if not group._group_actions:
raise ValueError(f'empty group {group}')
if all(action.help is SUPPRESS for action in group._group_actions):
continue
try:
start = min(actions.index(item) for item in group._group_actions)
except ValueError:
continue
else:
end = start + len(group._group_actions)
if set(actions[start:end]) == set(group._group_actions):
group_actions.update(group._group_actions)
inserts[start, end] = group
for action in group._group_actions:
if action in action_groups:
action_groups[action] = group
# positional arguments keep their position
positionals = []
for action in actions:
if not action.option_strings:
group = action_groups.pop(action)
if group:
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:
positionals.append((None, [action]))
# the remaining optional arguments are sorted by the position of
# the first option in the 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
parts = []
t = self._theme
for action in actions:
pos_start = None
for i, (required, group) in enumerate(optionals + positionals):
start = len(parts)
if i == len(optionals):
pos_start = start
in_group = len(group) > 1
for action in group:
# produce all arg strings
if not action.option_strings:
default = self._get_default_metavar_for_positional(action)
part = self._format_args(action, default)
# if it's in a group, strip the outer []
if in_group:
if part[0] == '[' and part[-1] == ']':
part = part[1:-1]
part = t.summary_action + part + t.reset
# suppressed arguments are marked with None
if action.help is SUPPRESS:
part = None
# produce all arg strings
elif not action.option_strings:
default = self._get_default_metavar_for_positional(action)
part = self._format_args(action, default)
# if it's in a group, strip the outer []
if action in group_actions:
if part[0] == '[' and part[-1] == ']':
part = part[1:-1]
part = t.summary_action + part + t.reset
# produce the first way to invoke the option in brackets
else:
option_string = action.option_strings[0]
if self._is_long_option(option_string):
option_color = t.summary_long_option
# produce the first way to invoke the option in brackets
else:
option_color = t.summary_short_option
option_string = action.option_strings[0]
if self._is_long_option(option_string):
option_color = t.summary_long_option
else:
option_color = t.summary_short_option
# if the Optional doesn't take a value, format is:
# -s or --long
if action.nargs == 0:
part = action.format_usage()
part = f"{option_color}{part}{t.reset}"
# if the Optional doesn't take a value, format is:
# -s or --long
if action.nargs == 0:
part = action.format_usage()
part = f"{option_color}{part}{t.reset}"
# if the Optional takes a value, format is:
# -s ARGS or --long ARGS
else:
default = self._get_default_metavar_for_optional(action)
args_string = self._format_args(action, default)
part = (
f"{option_color}{option_string} "
f"{t.summary_label}{args_string}{t.reset}"
)
# if the Optional takes a value, format is:
# -s ARGS or --long ARGS
else:
default = self._get_default_metavar_for_optional(action)
args_string = self._format_args(action, default)
part = (
f"{option_color}{option_string} "
f"{t.summary_label}{args_string}{t.reset}"
)
# make it look optional if it's not required or in a group
if not action.required and action not in group_actions:
part = '[%s]' % part
# make it look optional if it's not required or in a group
if not (action.required or required or in_group):
part = '[%s]' % part
# add the action string to the list
parts.append(part)
# add the action string to the list
parts.append(part)
# group mutually exclusive actions
inserted_separators_indices = set()
for start, end in sorted(inserts, reverse=True):
group = inserts[start, end]
group_parts = [item for item in parts[start:end] if item is not None]
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 in_group:
parts[start] = ('(' if required else '[') + parts[start]
for i in range(start, len(parts) - 1):
parts[i] += ' |'
parts[-1] += ')' if required else ']'
# 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
if pos_start is None:
pos_start = len(parts)
return parts, pos_start
def _format_text(self, text):
if '%(prog)' in text: