Skip to content

Commit 0f6b9d2

Browse files
authored
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.
1 parent ad0ffa0 commit 0f6b9d2

File tree

5 files changed

+235
-3
lines changed

5 files changed

+235
-3
lines changed

Doc/library/argparse.rst

+41-3
Original file line numberDiff line numberDiff line change
@@ -1985,6 +1985,45 @@ Exiting methods
19851985
This method prints a usage message including the *message* to the
19861986
standard error and terminates the program with a status code of 2.
19871987

1988+
1989+
Intermixed parsing
1990+
^^^^^^^^^^^^^^^^^^
1991+
1992+
.. method:: ArgumentParser.parse_intermixed_args(args=None, namespace=None)
1993+
.. method:: ArgumentParser.parse_known_intermixed_args(args=None, namespace=None)
1994+
1995+
A number of Unix commands allow the user to intermix optional arguments with
1996+
positional arguments. The :meth:`~ArgumentParser.parse_intermixed_args`
1997+
and :meth:`~ArgumentParser.parse_known_intermixed_args` methods
1998+
support this parsing style.
1999+
2000+
These parsers do not support all the argparse features, and will raise
2001+
exceptions if unsupported features are used. In particular, subparsers,
2002+
``argparse.REMAINDER``, and mutually exclusive groups that include both
2003+
optionals and positionals are not supported.
2004+
2005+
The following example shows the difference between
2006+
:meth:`~ArgumentParser.parse_known_args` and
2007+
:meth:`~ArgumentParser.parse_intermixed_args`: the former returns ``['2',
2008+
'3']`` as unparsed arguments, while the latter collects all the positionals
2009+
into ``rest``. ::
2010+
2011+
>>> parser = argparse.ArgumentParser()
2012+
>>> parser.add_argument('--foo')
2013+
>>> parser.add_argument('cmd')
2014+
>>> parser.add_argument('rest', nargs='*', type=int)
2015+
>>> parser.parse_known_args('doit 1 --foo bar 2 3'.split())
2016+
(Namespace(cmd='doit', foo='bar', rest=[1]), ['2', '3'])
2017+
>>> parser.parse_intermixed_args('doit 1 --foo bar 2 3'.split())
2018+
Namespace(cmd='doit', foo='bar', rest=[1, 2, 3])
2019+
2020+
:meth:`~ArgumentParser.parse_known_intermixed_args` returns a two item tuple
2021+
containing the populated namespace and the list of remaining argument strings.
2022+
:meth:`~ArgumentParser.parse_intermixed_args` raises an error if there are any
2023+
remaining unparsed argument strings.
2024+
2025+
.. versionadded:: 3.7
2026+
19882027
.. _upgrading-optparse-code:
19892028

19902029
Upgrading optparse code
@@ -2018,9 +2057,8 @@ A partial upgrade path from :mod:`optparse` to :mod:`argparse`:
20182057
called ``options``, now in the :mod:`argparse` context is called ``args``.
20192058

20202059
* Replace :meth:`optparse.OptionParser.disable_interspersed_args`
2021-
by setting ``nargs`` of a positional argument to `argparse.REMAINDER`_, or
2022-
use :meth:`~ArgumentParser.parse_known_args` to collect unparsed argument
2023-
strings in a separate list.
2060+
by using :meth:`~ArgumentParser.parse_intermixed_args` instead of
2061+
:meth:`~ArgumentParser.parse_args`.
20242062

20252063
* Replace callback actions and the ``callback_*`` keyword arguments with
20262064
``type`` or ``action`` arguments.

Doc/whatsnew/3.7.rst

+9
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,15 @@ Improved Modules
140140
================
141141

142142

143+
argparse
144+
--------
145+
146+
The :meth:`~argparse.ArgumentParser.parse_intermixed_args` supports letting
147+
the user intermix options and positional arguments on the command line,
148+
as is possible in many unix commands. It supports most but not all
149+
argparse features. (Contributed by paul.j3 in :issue:`14191`.)
150+
151+
143152
binascii
144153
--------
145154

Lib/argparse.py

+95
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,8 @@ def _format_args(self, action, default_metavar):
587587
result = '...'
588588
elif action.nargs == PARSER:
589589
result = '%s ...' % get_metavar(1)
590+
elif action.nargs == SUPPRESS:
591+
result = ''
590592
else:
591593
formats = ['%s' for _ in range(action.nargs)]
592594
result = ' '.join(formats) % get_metavar(action.nargs)
@@ -2212,6 +2214,10 @@ def _get_nargs_pattern(self, action):
22122214
elif nargs == PARSER:
22132215
nargs_pattern = '(-*A[-AO]*)'
22142216

2217+
# suppress action, like nargs=0
2218+
elif nargs == SUPPRESS:
2219+
nargs_pattern = '(-*-*)'
2220+
22152221
# all others should be integers
22162222
else:
22172223
nargs_pattern = '(-*%s-*)' % '-*'.join('A' * nargs)
@@ -2224,6 +2230,91 @@ def _get_nargs_pattern(self, action):
22242230
# return the pattern
22252231
return nargs_pattern
22262232

2233+
# ========================
2234+
# Alt command line argument parsing, allowing free intermix
2235+
# ========================
2236+
2237+
def parse_intermixed_args(self, args=None, namespace=None):
2238+
args, argv = self.parse_known_intermixed_args(args, namespace)
2239+
if argv:
2240+
msg = _('unrecognized arguments: %s')
2241+
self.error(msg % ' '.join(argv))
2242+
return args
2243+
2244+
def parse_known_intermixed_args(self, args=None, namespace=None):
2245+
# returns a namespace and list of extras
2246+
#
2247+
# positional can be freely intermixed with optionals. optionals are
2248+
# first parsed with all positional arguments deactivated. The 'extras'
2249+
# are then parsed. If the parser definition is incompatible with the
2250+
# intermixed assumptions (e.g. use of REMAINDER, subparsers) a
2251+
# TypeError is raised.
2252+
#
2253+
# positionals are 'deactivated' by setting nargs and default to
2254+
# SUPPRESS. This blocks the addition of that positional to the
2255+
# namespace
2256+
2257+
positionals = self._get_positional_actions()
2258+
a = [action for action in positionals
2259+
if action.nargs in [PARSER, REMAINDER]]
2260+
if a:
2261+
raise TypeError('parse_intermixed_args: positional arg'
2262+
' with nargs=%s'%a[0].nargs)
2263+
2264+
if [action.dest for group in self._mutually_exclusive_groups
2265+
for action in group._group_actions if action in positionals]:
2266+
raise TypeError('parse_intermixed_args: positional in'
2267+
' mutuallyExclusiveGroup')
2268+
2269+
try:
2270+
save_usage = self.usage
2271+
try:
2272+
if self.usage is None:
2273+
# capture the full usage for use in error messages
2274+
self.usage = self.format_usage()[7:]
2275+
for action in positionals:
2276+
# deactivate positionals
2277+
action.save_nargs = action.nargs
2278+
# action.nargs = 0
2279+
action.nargs = SUPPRESS
2280+
action.save_default = action.default
2281+
action.default = SUPPRESS
2282+
namespace, remaining_args = self.parse_known_args(args,
2283+
namespace)
2284+
for action in positionals:
2285+
# remove the empty positional values from namespace
2286+
if (hasattr(namespace, action.dest)
2287+
and getattr(namespace, action.dest)==[]):
2288+
from warnings import warn
2289+
warn('Do not expect %s in %s' % (action.dest, namespace))
2290+
delattr(namespace, action.dest)
2291+
finally:
2292+
# restore nargs and usage before exiting
2293+
for action in positionals:
2294+
action.nargs = action.save_nargs
2295+
action.default = action.save_default
2296+
optionals = self._get_optional_actions()
2297+
try:
2298+
# parse positionals. optionals aren't normally required, but
2299+
# they could be, so make sure they aren't.
2300+
for action in optionals:
2301+
action.save_required = action.required
2302+
action.required = False
2303+
for group in self._mutually_exclusive_groups:
2304+
group.save_required = group.required
2305+
group.required = False
2306+
namespace, extras = self.parse_known_args(remaining_args,
2307+
namespace)
2308+
finally:
2309+
# restore parser values before exiting
2310+
for action in optionals:
2311+
action.required = action.save_required
2312+
for group in self._mutually_exclusive_groups:
2313+
group.required = group.save_required
2314+
finally:
2315+
self.usage = save_usage
2316+
return namespace, extras
2317+
22272318
# ========================
22282319
# Value conversion methods
22292320
# ========================
@@ -2270,6 +2361,10 @@ def _get_values(self, action, arg_strings):
22702361
value = [self._get_value(action, v) for v in arg_strings]
22712362
self._check_value(action, value[0])
22722363

2364+
# SUPPRESS argument does not put anything in the namespace
2365+
elif action.nargs == SUPPRESS:
2366+
value = SUPPRESS
2367+
22732368
# all other types of nargs produce a list
22742369
else:
22752370
value = [self._get_value(action, v) for v in arg_strings]

Lib/test/test_argparse.py

+87
Original file line numberDiff line numberDiff line change
@@ -4804,6 +4804,93 @@ def test_mixed(self):
48044804
self.assertEqual(NS(v=3, spam=True, badger="B"), args)
48054805
self.assertEqual(["C", "--foo", "4"], extras)
48064806

4807+
# ===========================
4808+
# parse_intermixed_args tests
4809+
# ===========================
4810+
4811+
class TestIntermixedArgs(TestCase):
4812+
def test_basic(self):
4813+
# test parsing intermixed optionals and positionals
4814+
parser = argparse.ArgumentParser(prog='PROG')
4815+
parser.add_argument('--foo', dest='foo')
4816+
bar = parser.add_argument('--bar', dest='bar', required=True)
4817+
parser.add_argument('cmd')
4818+
parser.add_argument('rest', nargs='*', type=int)
4819+
argv = 'cmd --foo x 1 --bar y 2 3'.split()
4820+
args = parser.parse_intermixed_args(argv)
4821+
# rest gets [1,2,3] despite the foo and bar strings
4822+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args)
4823+
4824+
args, extras = parser.parse_known_args(argv)
4825+
# cannot parse the '1,2,3'
4826+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[]), args)
4827+
self.assertEqual(["1", "2", "3"], extras)
4828+
4829+
argv = 'cmd --foo x 1 --error 2 --bar y 3'.split()
4830+
args, extras = parser.parse_known_intermixed_args(argv)
4831+
# unknown optionals go into extras
4832+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1]), args)
4833+
self.assertEqual(['--error', '2', '3'], extras)
4834+
4835+
# restores attributes that were temporarily changed
4836+
self.assertIsNone(parser.usage)
4837+
self.assertEqual(bar.required, True)
4838+
4839+
def test_remainder(self):
4840+
# Intermixed and remainder are incompatible
4841+
parser = ErrorRaisingArgumentParser(prog='PROG')
4842+
parser.add_argument('-z')
4843+
parser.add_argument('x')
4844+
parser.add_argument('y', nargs='...')
4845+
argv = 'X A B -z Z'.split()
4846+
# intermixed fails with '...' (also 'A...')
4847+
# self.assertRaises(TypeError, parser.parse_intermixed_args, argv)
4848+
with self.assertRaises(TypeError) as cm:
4849+
parser.parse_intermixed_args(argv)
4850+
self.assertRegex(str(cm.exception), r'\.\.\.')
4851+
4852+
def test_exclusive(self):
4853+
# mutually exclusive group; intermixed works fine
4854+
parser = ErrorRaisingArgumentParser(prog='PROG')
4855+
group = parser.add_mutually_exclusive_group(required=True)
4856+
group.add_argument('--foo', action='store_true', help='FOO')
4857+
group.add_argument('--spam', help='SPAM')
4858+
parser.add_argument('badger', nargs='*', default='X', help='BADGER')
4859+
args = parser.parse_intermixed_args('1 --foo 2'.split())
4860+
self.assertEqual(NS(badger=['1', '2'], foo=True, spam=None), args)
4861+
self.assertRaises(ArgumentParserError, parser.parse_intermixed_args, '1 2'.split())
4862+
self.assertEqual(group.required, True)
4863+
4864+
def test_exclusive_incompatible(self):
4865+
# mutually exclusive group including positional - fail
4866+
parser = ErrorRaisingArgumentParser(prog='PROG')
4867+
group = parser.add_mutually_exclusive_group(required=True)
4868+
group.add_argument('--foo', action='store_true', help='FOO')
4869+
group.add_argument('--spam', help='SPAM')
4870+
group.add_argument('badger', nargs='*', default='X', help='BADGER')
4871+
self.assertRaises(TypeError, parser.parse_intermixed_args, [])
4872+
self.assertEqual(group.required, True)
4873+
4874+
class TestIntermixedMessageContentError(TestCase):
4875+
# case where Intermixed gives different error message
4876+
# error is raised by 1st parsing step
4877+
def test_missing_argument_name_in_message(self):
4878+
parser = ErrorRaisingArgumentParser(prog='PROG', usage='')
4879+
parser.add_argument('req_pos', type=str)
4880+
parser.add_argument('-req_opt', type=int, required=True)
4881+
4882+
with self.assertRaises(ArgumentParserError) as cm:
4883+
parser.parse_args([])
4884+
msg = str(cm.exception)
4885+
self.assertRegex(msg, 'req_pos')
4886+
self.assertRegex(msg, 'req_opt')
4887+
4888+
with self.assertRaises(ArgumentParserError) as cm:
4889+
parser.parse_intermixed_args([])
4890+
msg = str(cm.exception)
4891+
self.assertNotRegex(msg, 'req_pos')
4892+
self.assertRegex(msg, 'req_opt')
4893+
48074894
# ==========================
48084895
# add_argument metavar tests
48094896
# ==========================
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
A new function ``argparse.ArgumentParser.parse_intermixed_args`` provides the
2+
ability to parse command lines where there user intermixes options and
3+
positional arguments.

0 commit comments

Comments
 (0)