summaryrefslogtreecommitdiffstats
path: root/Lib/argparse.py
diff options
context:
space:
mode:
authorAli Hamdan <ali.hamdan.dev@gmail.com>2024-05-07 07:28:51 (GMT)
committerGitHub <noreply@github.com>2024-05-07 07:28:51 (GMT)
commitde1428f8c234a8731ced99cbfe5cd6c5c719e31d (patch)
treeee30a62229c516a98e5addd041a0f5b83bc7718a /Lib/argparse.py
parent49258efada0cb0fc58ccffc018ff310b8f7f4570 (diff)
downloadcpython-de1428f8c234a8731ced99cbfe5cd6c5c719e31d.zip
cpython-de1428f8c234a8731ced99cbfe5cd6c5c719e31d.tar.gz
cpython-de1428f8c234a8731ced99cbfe5cd6c5c719e31d.tar.bz2
gh-62090: Simplify argparse usage formatting (GH-105039)
Rationale ========= argparse performs a complex formatting of the usage for argument grouping and for line wrapping to fit the terminal width. This formatting has been a constant source of bugs for at least 10 years (see linked issues below) where defensive assertion errors are triggered or brackets and paranthesis are not properly handeled. Problem ======= The current implementation of argparse usage formatting relies on regular expressions to group arguments usage only to separate them again later with another set of regular expressions. This is a complex and error prone approach that caused all the issues linked below. Special casing certain argument formats has not solved the problem. The following are some of the most common issues: - empty `metavar` - mutually exclusive groups with `SUPPRESS`ed arguments - metavars with whitespace - metavars with brackets or paranthesis Solution ======== The following two comments summarize the solution: - https://github.com/python/cpython/issues/82091#issuecomment-1093832187 - https://github.com/python/cpython/issues/77048#issuecomment-1093776995 Mainly, the solution is to rewrite the usage formatting to avoid the group-then-separate approach. Instead, the usage parts are kept separate and only joined together at the end. This allows for a much simpler implementation that is easier to understand and maintain. It avoids the regular expressions approach and fixes the corresponding issues. This closes the following GitHub issues: - #62090 - #62549 - #77048 - #82091 - #89743 - #96310 - #98666 These PRs become obsolete: - #15372 - #96311
Diffstat (limited to 'Lib/argparse.py')
-rw-r--r--Lib/argparse.py100
1 files changed, 28 insertions, 72 deletions
diff --git a/Lib/argparse.py b/Lib/argparse.py
index 0dbdd67..55bf8cd 100644
--- a/Lib/argparse.py
+++ b/Lib/argparse.py
@@ -328,17 +328,8 @@ class HelpFormatter(object):
if len(prefix) + len(usage) > text_width:
# break usage into wrappable parts
- part_regexp = (
- r'\(.*?\)+(?=\s|$)|'
- r'\[.*?\]+(?=\s|$)|'
- r'\S+'
- )
- opt_usage = format(optionals, groups)
- pos_usage = format(positionals, groups)
- opt_parts = _re.findall(part_regexp, opt_usage)
- pos_parts = _re.findall(part_regexp, pos_usage)
- assert ' '.join(opt_parts) == opt_usage
- assert ' '.join(pos_parts) == pos_usage
+ opt_parts = self._get_actions_usage_parts(optionals, groups)
+ pos_parts = self._get_actions_usage_parts(positionals, groups)
# helper for wrapping lines
def get_lines(parts, indent, prefix=None):
@@ -391,6 +382,9 @@ class HelpFormatter(object):
return '%s%s\n\n' % (prefix, usage)
def _format_actions_usage(self, actions, groups):
+ return ' '.join(self._get_actions_usage_parts(actions, groups))
+
+ def _get_actions_usage_parts(self, actions, groups):
# find group indices and identify actions in groups
group_actions = set()
inserts = {}
@@ -398,58 +392,26 @@ class HelpFormatter(object):
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 = actions.index(group._group_actions[0])
except ValueError:
continue
else:
- group_action_count = len(group._group_actions)
- end = start + group_action_count
+ end = start + len(group._group_actions)
if actions[start:end] == group._group_actions:
-
- suppressed_actions_count = 0
- for action in group._group_actions:
- group_actions.add(action)
- if action.help is SUPPRESS:
- suppressed_actions_count += 1
-
- exposed_actions_count = group_action_count - suppressed_actions_count
- if not exposed_actions_count:
- continue
-
- if not group.required:
- if start in inserts:
- inserts[start] += ' ['
- else:
- inserts[start] = '['
- if end in inserts:
- inserts[end] += ']'
- else:
- inserts[end] = ']'
- elif exposed_actions_count > 1:
- if start in inserts:
- inserts[start] += ' ('
- else:
- inserts[start] = '('
- if end in inserts:
- inserts[end] += ')'
- else:
- inserts[end] = ')'
- for i in range(start + 1, end):
- inserts[i] = '|'
+ group_actions.update(group._group_actions)
+ inserts[start, end] = group
# collect all actions format strings
parts = []
- for i, action in enumerate(actions):
+ for action in actions:
# suppressed arguments are marked with None
- # remove | separators for suppressed arguments
if action.help is SUPPRESS:
- parts.append(None)
- if inserts.get(i) == '|':
- inserts.pop(i)
- elif inserts.get(i + 1) == '|':
- inserts.pop(i + 1)
+ part = None
# produce all arg strings
elif not action.option_strings:
@@ -461,9 +423,6 @@ class HelpFormatter(object):
if part[0] == '[' and part[-1] == ']':
part = part[1:-1]
- # add the action string to the list
- parts.append(part)
-
# produce the first way to invoke the option in brackets
else:
option_string = action.option_strings[0]
@@ -484,26 +443,23 @@ class HelpFormatter(object):
if not action.required and action not in group_actions:
part = '[%s]' % part
- # add the action string to the list
- parts.append(part)
+ # add the action string to the list
+ parts.append(part)
- # insert things at the necessary indices
- for i in sorted(inserts, reverse=True):
- parts[i:i] = [inserts[i]]
-
- # join all the action items with spaces
- text = ' '.join([item for item in parts if item is not None])
-
- # clean up separators for mutually exclusive groups
- open = r'[\[(]'
- close = r'[\])]'
- text = _re.sub(r'(%s) ' % open, r'\1', text)
- text = _re.sub(r' (%s)' % close, r'\1', text)
- text = _re.sub(r'%s *%s' % (open, close), r'', text)
- text = text.strip()
+ # group mutually exclusive actions
+ 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]
+ if group.required:
+ open, close = "()" if len(group_parts) > 1 else ("", "")
+ else:
+ open, close = "[]"
+ parts[start] = open + " | ".join(group_parts) + close
+ for i in range(start + 1, end):
+ parts[i] = None
- # return the text
- return text
+ # return the usage parts
+ return [item for item in parts if item is not None]
def _format_text(self, text):
if '%(prog)' in text: