Skip to content

Commit

Permalink
bpo-14191 Add parse_intermixed_args. (#3319)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
bitdancer authored Sep 7, 2017
1 parent ad0ffa0 commit 0f6b9d2
Show file tree
Hide file tree
Showing 5 changed files with 235 additions and 3 deletions.
44 changes: 41 additions & 3 deletions Doc/library/argparse.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions Doc/whatsnew/3.7.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
--------

Expand Down
95 changes: 95 additions & 0 deletions Lib/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,8 @@ def _format_args(self, action, default_metavar):
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)
Expand Down Expand Up @@ -2212,6 +2214,10 @@ def _get_nargs_pattern(self, action):
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)
Expand All @@ -2224,6 +2230,91 @@ def _get_nargs_pattern(self, action):
# return the pattern
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
# ========================
Expand Down Expand Up @@ -2270,6 +2361,10 @@ def _get_values(self, action, arg_strings):
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]
Expand Down
87 changes: 87 additions & 0 deletions Lib/test/test_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -4804,6 +4804,93 @@ def test_mixed(self):
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
# ==========================
Expand Down
Original file line number Diff line number Diff line change
@@ -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.

0 comments on commit 0f6b9d2

Please sign in to comment.