From 0f6b9d230674da784ca79a0cf1a03d2af5a8b6a8 Mon Sep 17 00:00:00 2001 From: "R. David Murray" Date: Wed, 6 Sep 2017 20:25:40 -0400 Subject: bpo-14191 Add parse_intermixed_args. (#3319) This adds support for parsing a command line where options and positionals are intermixed as is common in many unix commands. This is paul.j3's patch with a few tweaks. --- Doc/library/argparse.rst | 44 +++++++++- Doc/whatsnew/3.7.rst | 9 ++ Lib/argparse.py | 95 ++++++++++++++++++++++ Lib/test/test_argparse.py | 87 ++++++++++++++++++++ .../2017-09-05-17-43-00.bpo-14191.vhh2xx.rst | 3 + 5 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2017-09-05-17-43-00.bpo-14191.vhh2xx.rst diff --git a/Doc/library/argparse.rst b/Doc/library/argparse.rst index a16aa10..ab4bc92 100644 --- a/Doc/library/argparse.rst +++ b/Doc/library/argparse.rst @@ -1985,6 +1985,45 @@ Exiting methods This method prints a usage message including the *message* to the standard error and terminates the program with a status code of 2. + +Intermixed parsing +^^^^^^^^^^^^^^^^^^ + +.. method:: ArgumentParser.parse_intermixed_args(args=None, namespace=None) +.. method:: ArgumentParser.parse_known_intermixed_args(args=None, namespace=None) + +A number of Unix commands allow the user to intermix optional arguments with +positional arguments. The :meth:`~ArgumentParser.parse_intermixed_args` +and :meth:`~ArgumentParser.parse_known_intermixed_args` methods +support this parsing style. + +These parsers do not support all the argparse features, and will raise +exceptions if unsupported features are used. In particular, subparsers, +``argparse.REMAINDER``, and mutually exclusive groups that include both +optionals and positionals are not supported. + +The following example shows the difference between +:meth:`~ArgumentParser.parse_known_args` and +:meth:`~ArgumentParser.parse_intermixed_args`: the former returns ``['2', +'3']`` as unparsed arguments, while the latter collects all the positionals +into ``rest``. :: + + >>> parser = argparse.ArgumentParser() + >>> parser.add_argument('--foo') + >>> parser.add_argument('cmd') + >>> parser.add_argument('rest', nargs='*', type=int) + >>> parser.parse_known_args('doit 1 --foo bar 2 3'.split()) + (Namespace(cmd='doit', foo='bar', rest=[1]), ['2', '3']) + >>> parser.parse_intermixed_args('doit 1 --foo bar 2 3'.split()) + Namespace(cmd='doit', foo='bar', rest=[1, 2, 3]) + +:meth:`~ArgumentParser.parse_known_intermixed_args` returns a two item tuple +containing the populated namespace and the list of remaining argument strings. +:meth:`~ArgumentParser.parse_intermixed_args` raises an error if there are any +remaining unparsed argument strings. + +.. versionadded:: 3.7 + .. _upgrading-optparse-code: Upgrading optparse code @@ -2018,9 +2057,8 @@ A partial upgrade path from :mod:`optparse` to :mod:`argparse`: called ``options``, now in the :mod:`argparse` context is called ``args``. * Replace :meth:`optparse.OptionParser.disable_interspersed_args` - by setting ``nargs`` of a positional argument to `argparse.REMAINDER`_, or - use :meth:`~ArgumentParser.parse_known_args` to collect unparsed argument - strings in a separate list. + by using :meth:`~ArgumentParser.parse_intermixed_args` instead of + :meth:`~ArgumentParser.parse_args`. * Replace callback actions and the ``callback_*`` keyword arguments with ``type`` or ``action`` arguments. diff --git a/Doc/whatsnew/3.7.rst b/Doc/whatsnew/3.7.rst index 7a5d1e5..48c59b2 100644 --- a/Doc/whatsnew/3.7.rst +++ b/Doc/whatsnew/3.7.rst @@ -140,6 +140,15 @@ Improved Modules ================ +argparse +-------- + +The :meth:`~argparse.ArgumentParser.parse_intermixed_args` supports letting +the user intermix options and positional arguments on the command line, +as is possible in many unix commands. It supports most but not all +argparse features. (Contributed by paul.j3 in :issue:`14191`.) + + binascii -------- diff --git a/Lib/argparse.py b/Lib/argparse.py index b69c5ad..d59e645 100644 --- a/Lib/argparse.py +++ b/Lib/argparse.py @@ -587,6 +587,8 @@ class HelpFormatter(object): result = '...' elif action.nargs == PARSER: result = '%s ...' % get_metavar(1) + elif action.nargs == SUPPRESS: + result = '' else: formats = ['%s' for _ in range(action.nargs)] result = ' '.join(formats) % get_metavar(action.nargs) @@ -2212,6 +2214,10 @@ class ArgumentParser(_AttributeHolder, _ActionsContainer): elif nargs == PARSER: nargs_pattern = '(-*A[-AO]*)' + # suppress action, like nargs=0 + elif nargs == SUPPRESS: + nargs_pattern = '(-*-*)' + # all others should be integers else: nargs_pattern = '(-*%s-*)' % '-*'.join('A' * nargs) @@ -2225,6 +2231,91 @@ class ArgumentParser(_AttributeHolder, _ActionsContainer): return nargs_pattern # ======================== + # Alt command line argument parsing, allowing free intermix + # ======================== + + def parse_intermixed_args(self, args=None, namespace=None): + args, argv = self.parse_known_intermixed_args(args, namespace) + if argv: + msg = _('unrecognized arguments: %s') + self.error(msg % ' '.join(argv)) + return args + + def parse_known_intermixed_args(self, args=None, namespace=None): + # returns a namespace and list of extras + # + # positional can be freely intermixed with optionals. optionals are + # first parsed with all positional arguments deactivated. The 'extras' + # are then parsed. If the parser definition is incompatible with the + # intermixed assumptions (e.g. use of REMAINDER, subparsers) a + # TypeError is raised. + # + # positionals are 'deactivated' by setting nargs and default to + # SUPPRESS. This blocks the addition of that positional to the + # namespace + + positionals = self._get_positional_actions() + a = [action for action in positionals + if action.nargs in [PARSER, REMAINDER]] + if a: + raise TypeError('parse_intermixed_args: positional arg' + ' with nargs=%s'%a[0].nargs) + + if [action.dest for group in self._mutually_exclusive_groups + for action in group._group_actions if action in positionals]: + raise TypeError('parse_intermixed_args: positional in' + ' mutuallyExclusiveGroup') + + try: + save_usage = self.usage + try: + if self.usage is None: + # capture the full usage for use in error messages + self.usage = self.format_usage()[7:] + for action in positionals: + # deactivate positionals + action.save_nargs = action.nargs + # action.nargs = 0 + action.nargs = SUPPRESS + action.save_default = action.default + action.default = SUPPRESS + namespace, remaining_args = self.parse_known_args(args, + namespace) + for action in positionals: + # remove the empty positional values from namespace + if (hasattr(namespace, action.dest) + and getattr(namespace, action.dest)==[]): + from warnings import warn + warn('Do not expect %s in %s' % (action.dest, namespace)) + delattr(namespace, action.dest) + finally: + # restore nargs and usage before exiting + for action in positionals: + action.nargs = action.save_nargs + action.default = action.save_default + optionals = self._get_optional_actions() + try: + # parse positionals. optionals aren't normally required, but + # they could be, so make sure they aren't. + for action in optionals: + action.save_required = action.required + action.required = False + for group in self._mutually_exclusive_groups: + group.save_required = group.required + group.required = False + namespace, extras = self.parse_known_args(remaining_args, + namespace) + finally: + # restore parser values before exiting + for action in optionals: + action.required = action.save_required + for group in self._mutually_exclusive_groups: + group.required = group.save_required + finally: + self.usage = save_usage + return namespace, extras + + # ======================== # Value conversion methods # ======================== def _get_values(self, action, arg_strings): @@ -2270,6 +2361,10 @@ class ArgumentParser(_AttributeHolder, _ActionsContainer): value = [self._get_value(action, v) for v in arg_strings] self._check_value(action, value[0]) + # SUPPRESS argument does not put anything in the namespace + elif action.nargs == SUPPRESS: + value = SUPPRESS + # all other types of nargs produce a list else: value = [self._get_value(action, v) for v in arg_strings] diff --git a/Lib/test/test_argparse.py b/Lib/test/test_argparse.py index 9c27f64..d8bcd73 100644 --- a/Lib/test/test_argparse.py +++ b/Lib/test/test_argparse.py @@ -4804,6 +4804,93 @@ class TestParseKnownArgs(TestCase): self.assertEqual(NS(v=3, spam=True, badger="B"), args) self.assertEqual(["C", "--foo", "4"], extras) +# =========================== +# parse_intermixed_args tests +# =========================== + +class TestIntermixedArgs(TestCase): + def test_basic(self): + # test parsing intermixed optionals and positionals + parser = argparse.ArgumentParser(prog='PROG') + parser.add_argument('--foo', dest='foo') + bar = parser.add_argument('--bar', dest='bar', required=True) + parser.add_argument('cmd') + parser.add_argument('rest', nargs='*', type=int) + argv = 'cmd --foo x 1 --bar y 2 3'.split() + args = parser.parse_intermixed_args(argv) + # rest gets [1,2,3] despite the foo and bar strings + self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args) + + args, extras = parser.parse_known_args(argv) + # cannot parse the '1,2,3' + self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[]), args) + self.assertEqual(["1", "2", "3"], extras) + + argv = 'cmd --foo x 1 --error 2 --bar y 3'.split() + args, extras = parser.parse_known_intermixed_args(argv) + # unknown optionals go into extras + self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1]), args) + self.assertEqual(['--error', '2', '3'], extras) + + # restores attributes that were temporarily changed + self.assertIsNone(parser.usage) + self.assertEqual(bar.required, True) + + def test_remainder(self): + # Intermixed and remainder are incompatible + parser = ErrorRaisingArgumentParser(prog='PROG') + parser.add_argument('-z') + parser.add_argument('x') + parser.add_argument('y', nargs='...') + argv = 'X A B -z Z'.split() + # intermixed fails with '...' (also 'A...') + # self.assertRaises(TypeError, parser.parse_intermixed_args, argv) + with self.assertRaises(TypeError) as cm: + parser.parse_intermixed_args(argv) + self.assertRegex(str(cm.exception), r'\.\.\.') + + def test_exclusive(self): + # mutually exclusive group; intermixed works fine + parser = ErrorRaisingArgumentParser(prog='PROG') + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--foo', action='store_true', help='FOO') + group.add_argument('--spam', help='SPAM') + parser.add_argument('badger', nargs='*', default='X', help='BADGER') + args = parser.parse_intermixed_args('1 --foo 2'.split()) + self.assertEqual(NS(badger=['1', '2'], foo=True, spam=None), args) + self.assertRaises(ArgumentParserError, parser.parse_intermixed_args, '1 2'.split()) + self.assertEqual(group.required, True) + + def test_exclusive_incompatible(self): + # mutually exclusive group including positional - fail + parser = ErrorRaisingArgumentParser(prog='PROG') + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--foo', action='store_true', help='FOO') + group.add_argument('--spam', help='SPAM') + group.add_argument('badger', nargs='*', default='X', help='BADGER') + self.assertRaises(TypeError, parser.parse_intermixed_args, []) + self.assertEqual(group.required, True) + +class TestIntermixedMessageContentError(TestCase): + # case where Intermixed gives different error message + # error is raised by 1st parsing step + def test_missing_argument_name_in_message(self): + parser = ErrorRaisingArgumentParser(prog='PROG', usage='') + parser.add_argument('req_pos', type=str) + parser.add_argument('-req_opt', type=int, required=True) + + with self.assertRaises(ArgumentParserError) as cm: + parser.parse_args([]) + msg = str(cm.exception) + self.assertRegex(msg, 'req_pos') + self.assertRegex(msg, 'req_opt') + + with self.assertRaises(ArgumentParserError) as cm: + parser.parse_intermixed_args([]) + msg = str(cm.exception) + self.assertNotRegex(msg, 'req_pos') + self.assertRegex(msg, 'req_opt') + # ========================== # add_argument metavar tests # ========================== diff --git a/Misc/NEWS.d/next/Library/2017-09-05-17-43-00.bpo-14191.vhh2xx.rst b/Misc/NEWS.d/next/Library/2017-09-05-17-43-00.bpo-14191.vhh2xx.rst new file mode 100644 index 0000000..b9e26fb --- /dev/null +++ b/Misc/NEWS.d/next/Library/2017-09-05-17-43-00.bpo-14191.vhh2xx.rst @@ -0,0 +1,3 @@ +A new function ``argparse.ArgumentParser.parse_intermixed_args`` provides the +ability to parse command lines where there user intermixes options and +positional arguments. -- cgit v0.12