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
201
Lib/argparse.py
201
Lib/argparse.py
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue