diff -r 9ea84f006892 Lib/argparse.py --- a/Lib/argparse.py Wed May 01 15:15:50 2013 +0200 +++ b/Lib/argparse.py Mon Jul 15 23:49:21 2013 -0700 @@ -72,6 +72,7 @@ 'RawDescriptionHelpFormatter', 'RawTextHelpFormatter', 'MetavarTypeHelpFormatter', + 'MultiGroupHelpFormatter', 'Namespace', 'Action', 'ONE_OR_MORE', @@ -311,21 +312,16 @@ # 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]) + action_parts = format(optionals + positionals, groups) + usage = ' '.join([s for s in [prog]+action_parts if s]) # wrap the usage parts if it's too long text_width = self._width - self._current_indent if len(prefix) + len(usage) > text_width: # break usage into wrappable parts - part_regexp = 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 = format(optionals, groups) + pos_parts = format(positionals, groups) # helper for wrapping lines def get_lines(parts, indent, prefix=None): @@ -377,61 +373,75 @@ return '%s%s\n\n' % (prefix, usage) def _format_actions_usage(self, actions, groups): - # find group indices and identify actions in groups - group_actions = set() - inserts = {} - for group in groups: - try: - start = actions.index(group._group_actions[0]) - except ValueError: - continue - else: - end = start + len(group._group_actions) - if actions[start:end] == group._group_actions: - for action in group._group_actions: - group_actions.add(action) - if not group.required: - if start in inserts: - inserts[start] += ' [' - else: - inserts[start] = '[' - inserts[end] = ']' - else: - if start in inserts: - inserts[start] += ' (' - else: - inserts[start] = '(' - inserts[end] = ')' - for i in range(start + 1, end): - inserts[i] = '|' - - # collect all actions format strings + # format the usage using the actions list. Where possible + # format the groups that include those actions + # The actions list has priority + # This is a new version that formats the groups directly without + # needing inserts, (most) cleanup, or parsing into parts parts = [] - for i, action in enumerate(actions): - - # suppressed arguments are marked with None - # remove | separators for suppressed arguments + i = 0 + # step through the actions list + while i1: + parts[-1] = ')' if group.required else ']' + else: + # nothing added + parts = [] + arg_parts = [''.join(parts)] + + def cleanup(text): + # remove unnecessary () + text = _re.sub(r'^\(([^|]*)\)$', r'\1', text) + return text + arg_parts = [cleanup(t) for t in arg_parts] + return arg_parts + + def _format_just_actions_usage(self, actions): + # actions, without any group markings + parts = [] + for action in actions: if action.help is SUPPRESS: - parts.append(None) - if inserts.get(i) == '|': - inserts.pop(i) - elif inserts.get(i + 1) == '|': - inserts.pop(i + 1) - - # produce all arg strings + pass 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] - - # 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] @@ -447,31 +457,11 @@ args_string = self._format_args(action, default) part = '%s %s' % (option_string, args_string) - # make it look optional if it's not required or in a group - if not action.required and action not in group_actions: + # make it look optional if it's not required + if not action.required: part = '[%s]' % 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 = _re.sub(r'\(([^|]*)\)', r'\1', text) - text = text.strip() - - # return the text - return text + return parts def _format_text(self, text): if '%(prog)' in text: @@ -681,6 +671,163 @@ def _get_default_metavar_for_positional(self, action): return action.type.__name__ +class MultiGroupHelpFormatter(HelpFormatter): + """Help message formatter that handles overlapping mutually exclusive + groups. + + Only the name of this class is considered a public API. All the methods + provided by the class are considered an implementation detail. + + This formats all the groups, even if they share actions, or the actions + do not occur in the other in which they were defined (in parse._actions) + Thus an action may appear in more than one group + Groups are presented in an order that preserves the order of positionals + """ + + def _format_usage(self, usage, actions, groups, prefix): + # + if prefix is None: + prefix = _('usage: ') + + # if usage is specified, use that + if usage is not None: + usage = usage % dict(prog=self._prog) + + # if no optionals or positionals are available, usage is just prog + elif usage is None and not actions: + usage = '%(prog)s' % dict(prog=self._prog) + + # if optionals and positionals are available, calculate usage + elif usage is None: + prog = '%(prog)s' % dict(prog=self._prog) + optionals = [action for action in actions if action.option_strings] + positionals = [action for action in actions if not action.option_strings] + + # build full usage string + format = self._format_actions_usage + (opt_parts, pos_parts) = format(optionals + positionals, groups) + usage = ' '.join([s for s in [prog]+opt_parts+pos_parts if s]) + + # the rest is the same as in the parent formatter + # wrap the usage parts if it's too long + text_width = self._width - self._current_indent + if len(prefix) + len(usage) > text_width: + # helper for wrapping lines + def get_lines(parts, indent, prefix=None): + lines = [] + line = [] + if prefix is not None: + line_len = len(prefix) - 1 + else: + line_len = len(indent) - 1 + for part in parts: + if line and line_len + 1 + len(part) > text_width: + lines.append(indent + ' '.join(line)) + line = [] + line_len = len(indent) - 1 + line.append(part) + line_len += len(part) + 1 + if line: + lines.append(indent + ' '.join(line)) + if prefix is not None: + lines[0] = lines[0][len(indent):] + return lines + + # if prog is short, follow it with optionals or positionals + if len(prefix) + len(prog) <= 0.75 * text_width: + indent = ' ' * (len(prefix) + len(prog) + 1) + if opt_parts: + lines = get_lines([prog] + opt_parts, indent, prefix) + lines.extend(get_lines(pos_parts, indent)) + elif pos_parts: + lines = get_lines([prog] + pos_parts, indent, prefix) + else: + lines = [prog] + + # if prog is long, put it on its own line + else: + indent = ' ' * len(prefix) + parts = opt_parts + pos_parts + lines = get_lines(parts, indent) + if len(lines) > 1: + lines = [] + lines.extend(get_lines(opt_parts, indent)) + lines.extend(get_lines(pos_parts, indent)) + lines = [prog] + lines + + # join lines into usage + usage = '\n'.join(lines) + + # prefix with 'usage:' + return '%s%s\n\n' % (prefix, usage) + + def _format_actions_usage(self, actions, groups): + # usage will list + # optionals that are not in a group + # actions in groups, with possible repetitions + # positionals that not in a group + # It orders groups with positionals to preserved the parsing order + + groups = self._group_sort(actions, groups) + group_actions = set() + arg_parts = [] + for group in groups: + gactions = group._group_actions + if not set(gactions).issubset(set(actions)): + # do not format this group if not all its actions are not in actions + # in contrast with the default formatter, order does not matter + continue + group_actions.update(gactions) + # group.no_usage = False + group_parts = self._format_group_usage(group) + # expect 1 element, or 0 if all suppressed + # or more elements if group cannot be formatted - get actions instead + arg_parts += group_parts + + # now format all remaining actions + for act in group_actions: + actions.remove(act) + # find optionals and positionals in the remaining actions list + # i.e. ones that are not in any group + optionals = [action for action in actions if action.option_strings] + positionals = [action for action in actions if not action.option_strings] + + parts = self._format_just_actions_usage(optionals) + arg_parts = parts + arg_parts + + pos_parts = self._format_just_actions_usage(positionals) + # keep pos_parts separate, so they can be handled separately in long lines + return (arg_parts, pos_parts) + + def _group_sort(self, actions, groups): + # sort groups by order of positionals, if any + from operator import itemgetter + if len(groups)==0: + return groups + optionals = [action for action in actions if action.option_strings] + positionals = [action for action in actions if not action.option_strings] + + # create a sort key, based on position of action in actions + posdex = [-1]*len(groups) + noInGroups = set(positionals) + for i,group in enumerate(groups): + for action in group._group_actions: + if action in positionals: + posdex[i] = positionals.index(action) + noInGroups.discard(action) + sortGroups = groups[:] + # actions not found in any group are put in their own tempory groups + samplegroup = group + for action in noInGroups: + g = _copy.copy(samplegroup) + g.required = action.required + g._group_actions = [action] + sortGroups.append(g) + posdex.append(positionals.index(action)) + + sortGroups = sorted(zip(sortGroups,posdex), key=itemgetter(1)) + sortGroups = [i[0] for i in sortGroups] + return sortGroups # ===================== @@ -1339,9 +1486,12 @@ self._action_groups.append(group) return group - def add_mutually_exclusive_group(self, **kwargs): + def add_mutually_exclusive_group(self, *args, **kwargs): group = _MutuallyExclusiveGroup(self, **kwargs) self._mutually_exclusive_groups.append(group) + for action in args: + group._group_actions.append(action) + group.no_usage = True return group def _add_action(self, action): @@ -1590,6 +1740,7 @@ epilog=None, parents=[], formatter_class=HelpFormatter, + #formatter_class=MultiGroupHelpFormatter, prefix_chars='-', fromfile_prefix_chars=None, argument_default=None, diff -r 9ea84f006892 Lib/test/test_argparse.py --- a/Lib/test/test_argparse.py Wed May 01 15:15:50 2013 +0200 +++ b/Lib/test/test_argparse.py Mon Jul 15 23:49:21 2013 -0700 @@ -2566,10 +2566,48 @@ ''' +class TestMutuallyExclusiveOptionalAndPositionalWithWrap(MEMixin, TestCase): + """When wrapped, groups that include a positional cannot be displayed + because positional(s) is displayed on its own line + """ + def get_parser(self, required): + parser = ErrorRaisingArgumentParser(prog='PROG',formatter_class=argparse.HelpFormatter) + group = parser.add_mutually_exclusive_group(required=required) + group.add_argument('--foo', nargs=3, help='FOO') + group.add_argument('--spam', nargs=4, help='SPAM') + group.add_argument('badger', nargs='*', default='X', help='BADGER') + return parser + + failures = [] + successes = [] + successes_when_not_required = [] + + usage_when_not_required = '''\ + usage: PROG [-h] [--foo FOO FOO FOO] [--spam SPAM SPAM SPAM SPAM] + [badger [badger ...]] + ''' + usage_when_required = '''\ + usage: PROG [-h] [--foo FOO FOO FOO] [--spam SPAM SPAM SPAM SPAM] + [badger [badger ...]] + ''' + help = '''\ + + positional arguments: + badger BADGER + + optional arguments: + -h, --help show this help message and exit + --foo FOO FOO FOO FOO + --spam SPAM SPAM SPAM SPAM + SPAM + ''' + + + class TestMutuallyExclusiveOptionalsMixed(MEMixin, TestCase): def get_parser(self, required): - parser = ErrorRaisingArgumentParser(prog='PROG') + parser = ErrorRaisingArgumentParser(prog='PROG',formatter_class=argparse.HelpFormatter) parser.add_argument('-x', action='store_true', help='x help') group = parser.add_mutually_exclusive_group(required=required) group.add_argument('-a', action='store_true', help='a help') @@ -2651,7 +2689,7 @@ class TestMutuallyExclusiveOptionalsAndPositionalsMixed(MEMixin, TestCase): def get_parser(self, required): - parser = ErrorRaisingArgumentParser(prog='PROG') + parser = ErrorRaisingArgumentParser(prog='PROG',formatter_class=argparse.HelpFormatter) parser.add_argument('x', help='x help') parser.add_argument('-y', action='store_true', help='y help') group = parser.add_mutually_exclusive_group(required=required) @@ -2689,6 +2727,42 @@ -c c help ''' +class TestMutuallyExclusiveGroupWithExistingArguments(MEMixin, TestCase): + + def get_parser(self, required): + parser = ErrorRaisingArgumentParser(prog='PROG',formatter_class=argparse.HelpFormatter) + a_action = parser.add_argument('-a', action='store_true', help='a help') + b_action = parser.add_argument('-b', action='store_true', help='b help') + c_action = parser.add_argument('-c', action='store_true', help='c help') + d_action = parser.add_argument('-d', action='store_true', help='d help') + parser.add_mutually_exclusive_group(a_action, c_action, required=required) + parser.add_mutually_exclusive_group(a_action, d_action, required=required) + return parser + + failures = ['-a -c', '-a -d', '-a -c -d', '-a -b -c', '-a -b -d', '-a -b -c -d'] + successes = [ + ('-a -b', NS(a=True, b=True, c=False, d=False)), + ('-c -d', NS(a=False, b=False, c=True, d=True)), + ('-c -b -d', NS(a=False, b=True, c=True, d=True)), + ] + successes_when_not_required = [ + ('', NS(a=False, b=False, c=False, d=False)), + ('-b', NS(a=False, b=True, c=False, d=False)), + ] + + usage_when_required = usage_when_not_required = '''\ + usage: PROG [-h] [-a] [-b] [-c] [-d] + ''' + help = '''\ + + optional arguments: + -h, --help show this help message and exit + -a a help + -b b help + -c c help + -d d help + ''' + # ================================================= # Mutually exclusive group in parent parser tests # ================================================= @@ -2698,7 +2772,8 @@ def get_parser(self, required=None): parent = super(MEPBase, self).get_parser(required=required) parser = ErrorRaisingArgumentParser( - prog=parent.prog, add_help=False, parents=[parent]) + prog=parent.prog, add_help=False, parents=[parent], + formatter_class=argparse.HelpFormatter) return parser @@ -4117,6 +4192,285 @@ ''' version = '' +# ===================================== +# MultiGroupHelpFormatter tests +# ===================================== + +class TestMutuallyExclusiveOptionalsMixedMultiGroupHelpFormatter(MEMixin, TestCase): + + def get_parser(self, required): + parser = ErrorRaisingArgumentParser(prog='PROG', + formatter_class=argparse.MultiGroupHelpFormatter) + parser.add_argument('-x', action='store_true', help='x help') + group = parser.add_mutually_exclusive_group(required=required) + group.add_argument('-a', action='store_true', help='a help') + group.add_argument('-b', action='store_true', help='b help') + parser.add_argument('-y', action='store_true', help='y help') + group.add_argument('-c', action='store_true', help='c help') + return parser + + failures = [] + successes = [] + successes_when_not_required = [] + + usage_when_required = '''\ + usage: PROG [-h] [-x] [-y] (-a | -b | -c) + ''' + usage_when_not_required = '''\ + usage: PROG [-h] [-x] [-y] [-a | -b | -c] + ''' + help = '''\ + + optional arguments: + -h, --help show this help message and exit + -x x help + -a a help + -b b help + -y y help + -c c help + ''' + + +class TestMutuallyExclusiveOptionalsAndPositionalsMixedMultiGroupHelpFormatter(MEMixin, TestCase): + + def get_parser(self, required): + parser = ErrorRaisingArgumentParser(prog='PROG', + formatter_class=argparse.MultiGroupHelpFormatter) + parser.add_argument('x', help='x help') + parser.add_argument('-y', action='store_true', help='y help') + group = parser.add_mutually_exclusive_group(required=required) + group.add_argument('a', nargs='?', help='a help') + group.add_argument('-b', action='store_true', help='b help') + group.add_argument('-c', action='store_true', help='c help') + return parser + + failures = [] + successes = [] + successes_when_not_required = [] + + usage_when_required = '''\ + usage: PROG [-h] [-y] x (a | -b | -c) + ''' + usage_when_not_required = '''\ + usage: PROG [-h] [-y] x [a | -b | -c] + ''' + help = '''\ + + positional arguments: + x x help + a a help + + optional arguments: + -h, --help show this help message and exit + -y y help + -b b help + -c c help + ''' + +class TestMutuallyExclusiveGroupWithExistingArgumentsMultiGroupHelpFormatter(MEMixin, TestCase): + + def get_parser(self, required): + parser = ErrorRaisingArgumentParser(prog='PROG', + formatter_class=argparse.MultiGroupHelpFormatter) + a_action = parser.add_argument('-a', action='store_true', help='a help') + b_action = parser.add_argument('-b', action='store_true', help='b help') + c_action = parser.add_argument('-c', action='store_true', help='c help') + d_action = parser.add_argument('-d', action='store_true', help='d help') + parser.add_mutually_exclusive_group(a_action, c_action, required=required) + parser.add_mutually_exclusive_group(a_action, d_action, required=required) + return parser + + failures = [] + successes = [] + successes_when_not_required = [] + + usage_when_required = '''\ + usage: PROG [-h] [-b] (-a | -c) (-a | -d) + ''' + usage_when_not_required = '''\ + usage: PROG [-h] [-b] [-a | -c] [-a | -d] + ''' + help = '''\ + + optional arguments: + -h, --help show this help message and exit + -a a help + -b b help + -c c help + -d d help + ''' + +class TestMutuallyExclusiveGroupWithExistingArgumentsMultiGroupHelpFormatterLong(MEMixin, TestCase): + """long enough to wrap the usage""" + maxDiff = None + def get_parser(self, required): + parser = ErrorRaisingArgumentParser(prog='PROG', + formatter_class=argparse.MultiGroupHelpFormatter) + a_action = parser.add_argument('-a', help='a help') + b_action = parser.add_argument('-b', help='b help') + c_action = parser.add_argument('-c', help='c help') + d_action = parser.add_argument('-d', help='d help') + parser.add_mutually_exclusive_group(a_action, c_action, required=required) + parser.add_mutually_exclusive_group(a_action, d_action, required=required) + parser.add_mutually_exclusive_group(a_action, b_action, required=required) + parser.add_mutually_exclusive_group(b_action, d_action, required=required) + parser.add_mutually_exclusive_group(b_action, c_action, required=required) + parser.add_mutually_exclusive_group(d_action, c_action, required=required) + parser.add_argument('x', help='x help') + return parser + + failures = [] + successes = [] + successes_when_not_required = [] + + usage_when_required = '''\ + usage: PROG [-h] (-a A | -c C) (-a A | -d D) (-a A | -b B) (-b B | -d D) + (-b B | -c C) (-d D | -c C) x + ''' + usage_when_not_required = '''\ + usage: PROG [-h] [-a A | -c C] [-a A | -d D] [-a A | -b B] [-b B | -d D] + [-b B | -c C] [-d D | -c C] x + ''' + help = '''\ + + positional arguments: + x x help + + optional arguments: + -h, --help show this help message and exit + -a A a help + -b B b help + -c C c help + -d D d help + ''' + +class TestMutuallyExclusiveGroupWithExistingArgumentsMultiGroupHelpFormatterLong1(MEMixin, TestCase): + """long enough to wrap the usage; positional in group + A group with a positional is not show when usage is wrapped + the positionals are on their own line(s) + This is true even if arguments are added to the groups normally + """ + maxDiff = None + def get_parser(self, required): + parser = ErrorRaisingArgumentParser(prog='PROG', + formatter_class=argparse.MultiGroupHelpFormatter) + a_action = parser.add_argument('-a', help='a help') + b_action = parser.add_argument('-b', help='b help') + c_action = parser.add_argument('-c', help='c help') + d_action = parser.add_argument('-d', help='d help') + x_action = parser.add_argument('x', nargs='?', help='x help') + foo_action = parser.add_argument('foo', help='foo help') + y_action = parser.add_argument('y', nargs='?', help='y help') + parser.add_mutually_exclusive_group(a_action, c_action, required=required) + parser.add_mutually_exclusive_group(a_action, d_action, required=required) + parser.add_mutually_exclusive_group(a_action, b_action, required=required) + parser.add_mutually_exclusive_group(b_action, d_action, required=required) + parser.add_mutually_exclusive_group(b_action, y_action, required=required) + parser.add_mutually_exclusive_group(d_action, x_action, required=required) + return parser + + failures = [] + successes = [] + successes_when_not_required = [] + + usage_when_required = '''\ + usage: PROG [-h] (-a A | -c C) (-a A | -d D) (-a A | -b B) (-b B | -d D) + (-d D | x) foo (-b B | y) + ''' + + usage_when_not_required = '''\ + usage: PROG [-h] [-a A | -c C] [-a A | -d D] [-a A | -b B] [-b B | -d D] + [-d D | x] foo [-b B | y] + ''' + help = '''\ + + positional arguments: + x x help + foo foo help + y y help + + optional arguments: + -h, --help show this help message and exit + -a A a help + -b B b help + -c C c help + -d D d help + ''' + +class TestHelpMetavarArgumentsInnerBracketSplitLines0(HelpTestCase): + """http://bugs.python.org/issue11874""" + """with extralong metavars (76) this was producing a blank line between + optionals and positionals. This goes away with (60). A tweak to nested + get_lines addresses this""" + def custom_type(string): + return string + + parser_signature = Sig(prog='PROG', + formatter_class=argparse.MultiGroupHelpFormatter) + argument_signatures = [Sig('--a', metavar='a' * 76), + Sig('--b', metavar='range(20)'), + Sig('c', metavar='c' * 76), + Sig('d', nargs='*'), + ] + argument_group_signatures = [] + usage = '''\ + usage: PROG [-h] + [--a {0}] + [--b range(20)] + {1} + [d [d ...]] + '''.format('a' * 76, 'c' * 76) + help = usage + '''\ + + positional arguments: + {0} + d + + optional arguments: + -h, --help show this help message and exit + --a {1} + --b range(20) + '''.format('c' * 76, 'a' * 76) + version = '' + +class TestHelpMetavarArgumentsInnerBracketSplitLines(HelpTestCase): + """""" + + def custom_type(string): + return string + + parser_signature = Sig(prog='PROG') + long_a = 'a' * 60 + long_d = 'd' * 60 + argument_signatures = [Sig('--a', metavar=long_a), + Sig('--b', metavar='[innerpart]outerpart'), + Sig('--c'), + Sig('d', metavar=long_d), + Sig('e', metavar='[innerpart2]outerpart2'), + Sig('f'), + ] + argument_group_signatures = [] + usage = '''\ + usage: PROG [-h] + [--a {0}] + [--b [innerpart]outerpart] [--c C] + {1} + [innerpart2]outerpart2 f + '''.format(long_a, long_d) + help = usage + '''\ + + positional arguments: + {0} + [innerpart2]outerpart2 + f + + optional arguments: + -h, --help show this help message and exit + --a {1} + --b [innerpart]outerpart + --c C + '''.format(long_d, long_a) + version = '' # ===================================== # Optional/Positional constructor tests