Skip to content

Commit 759a54d

Browse files
gh-125355: Rewrite parse_intermixed_args() in argparse (GH-125356)
* The parser no longer changes temporarily during parsing. * Default values are not processed twice. * Required mutually exclusive groups containing positional arguments are now supported. * The missing arguments report now includes the names of all required optional and positional arguments. * Unknown options can be intermixed with positional arguments in parse_known_intermixed_args().
1 parent 57e3c59 commit 759a54d

File tree

3 files changed

+80
-82
lines changed

3 files changed

+80
-82
lines changed

Lib/argparse.py

+34-65
Original file line numberDiff line numberDiff line change
@@ -1924,6 +1924,9 @@ def parse_args(self, args=None, namespace=None):
19241924
return args
19251925

19261926
def parse_known_args(self, args=None, namespace=None):
1927+
return self._parse_known_args2(args, namespace, intermixed=False)
1928+
1929+
def _parse_known_args2(self, args, namespace, intermixed):
19271930
if args is None:
19281931
# args default to the system args
19291932
args = _sys.argv[1:]
@@ -1950,18 +1953,18 @@ def parse_known_args(self, args=None, namespace=None):
19501953
# parse the arguments and exit if there are any errors
19511954
if self.exit_on_error:
19521955
try:
1953-
namespace, args = self._parse_known_args(args, namespace)
1956+
namespace, args = self._parse_known_args(args, namespace, intermixed)
19541957
except ArgumentError as err:
19551958
self.error(str(err))
19561959
else:
1957-
namespace, args = self._parse_known_args(args, namespace)
1960+
namespace, args = self._parse_known_args(args, namespace, intermixed)
19581961

19591962
if hasattr(namespace, _UNRECOGNIZED_ARGS_ATTR):
19601963
args.extend(getattr(namespace, _UNRECOGNIZED_ARGS_ATTR))
19611964
delattr(namespace, _UNRECOGNIZED_ARGS_ATTR)
19621965
return namespace, args
19631966

1964-
def _parse_known_args(self, arg_strings, namespace):
1967+
def _parse_known_args(self, arg_strings, namespace, intermixed):
19651968
# replace arg strings that are file references
19661969
if self.fromfile_prefix_chars is not None:
19671970
arg_strings = self._read_args_from_files(arg_strings)
@@ -2052,6 +2055,7 @@ def consume_optional(start_index):
20522055
# if we found no optional action, skip it
20532056
if action is None:
20542057
extras.append(arg_strings[start_index])
2058+
extras_pattern.append('O')
20552059
return start_index + 1
20562060

20572061
# if there is an explicit argument, try to match the
@@ -2087,6 +2091,7 @@ def consume_optional(start_index):
20872091
sep = ''
20882092
else:
20892093
extras.append(char + explicit_arg)
2094+
extras_pattern.append('O')
20902095
stop = start_index + 1
20912096
break
20922097
# if the action expect exactly one argument, we've
@@ -2165,6 +2170,7 @@ def consume_positionals(start_index):
21652170
# consume Positionals and Optionals alternately, until we have
21662171
# passed the last option string
21672172
extras = []
2173+
extras_pattern = []
21682174
start_index = 0
21692175
if option_string_indices:
21702176
max_option_string_index = max(option_string_indices)
@@ -2178,7 +2184,7 @@ def consume_positionals(start_index):
21782184
if next_option_string_index in option_string_indices:
21792185
break
21802186
next_option_string_index += 1
2181-
if start_index != next_option_string_index:
2187+
if not intermixed and start_index != next_option_string_index:
21822188
positionals_end_index = consume_positionals(start_index)
21832189

21842190
# only try to parse the next optional if we didn't consume
@@ -2194,16 +2200,35 @@ def consume_positionals(start_index):
21942200
if start_index not in option_string_indices:
21952201
strings = arg_strings[start_index:next_option_string_index]
21962202
extras.extend(strings)
2203+
extras_pattern.extend(arg_strings_pattern[start_index:next_option_string_index])
21972204
start_index = next_option_string_index
21982205

21992206
# consume the next optional and any arguments for it
22002207
start_index = consume_optional(start_index)
22012208

2202-
# consume any positionals following the last Optional
2203-
stop_index = consume_positionals(start_index)
2209+
if not intermixed:
2210+
# consume any positionals following the last Optional
2211+
stop_index = consume_positionals(start_index)
22042212

2205-
# if we didn't consume all the argument strings, there were extras
2206-
extras.extend(arg_strings[stop_index:])
2213+
# if we didn't consume all the argument strings, there were extras
2214+
extras.extend(arg_strings[stop_index:])
2215+
else:
2216+
extras.extend(arg_strings[start_index:])
2217+
extras_pattern.extend(arg_strings_pattern[start_index:])
2218+
extras_pattern = ''.join(extras_pattern)
2219+
assert len(extras_pattern) == len(extras)
2220+
# consume all positionals
2221+
arg_strings = [s for s, c in zip(extras, extras_pattern) if c != 'O']
2222+
arg_strings_pattern = extras_pattern.replace('O', '')
2223+
stop_index = consume_positionals(0)
2224+
# leave unknown optionals and non-consumed positionals in extras
2225+
for i, c in enumerate(extras_pattern):
2226+
if not stop_index:
2227+
break
2228+
if c != 'O':
2229+
stop_index -= 1
2230+
extras[i] = None
2231+
extras = [s for s in extras if s is not None]
22072232

22082233
# make sure all required actions were present and also convert
22092234
# action defaults which were not given as arguments
@@ -2469,10 +2494,6 @@ def parse_known_intermixed_args(self, args=None, namespace=None):
24692494
# are then parsed. If the parser definition is incompatible with the
24702495
# intermixed assumptions (e.g. use of REMAINDER, subparsers) a
24712496
# TypeError is raised.
2472-
#
2473-
# positionals are 'deactivated' by setting nargs and default to
2474-
# SUPPRESS. This blocks the addition of that positional to the
2475-
# namespace
24762497

24772498
positionals = self._get_positional_actions()
24782499
a = [action for action in positionals
@@ -2481,59 +2502,7 @@ def parse_known_intermixed_args(self, args=None, namespace=None):
24812502
raise TypeError('parse_intermixed_args: positional arg'
24822503
' with nargs=%s'%a[0].nargs)
24832504

2484-
if [action.dest for group in self._mutually_exclusive_groups
2485-
for action in group._group_actions if action in positionals]:
2486-
raise TypeError('parse_intermixed_args: positional in'
2487-
' mutuallyExclusiveGroup')
2488-
2489-
try:
2490-
save_usage = self.usage
2491-
try:
2492-
if self.usage is None:
2493-
# capture the full usage for use in error messages
2494-
self.usage = self.format_usage()[7:]
2495-
for action in positionals:
2496-
# deactivate positionals
2497-
action.save_nargs = action.nargs
2498-
# action.nargs = 0
2499-
action.nargs = SUPPRESS
2500-
action.save_default = action.default
2501-
action.default = SUPPRESS
2502-
namespace, remaining_args = self.parse_known_args(args,
2503-
namespace)
2504-
for action in positionals:
2505-
# remove the empty positional values from namespace
2506-
if (hasattr(namespace, action.dest)
2507-
and getattr(namespace, action.dest)==[]):
2508-
from warnings import warn
2509-
warn('Do not expect %s in %s' % (action.dest, namespace))
2510-
delattr(namespace, action.dest)
2511-
finally:
2512-
# restore nargs and usage before exiting
2513-
for action in positionals:
2514-
action.nargs = action.save_nargs
2515-
action.default = action.save_default
2516-
optionals = self._get_optional_actions()
2517-
try:
2518-
# parse positionals. optionals aren't normally required, but
2519-
# they could be, so make sure they aren't.
2520-
for action in optionals:
2521-
action.save_required = action.required
2522-
action.required = False
2523-
for group in self._mutually_exclusive_groups:
2524-
group.save_required = group.required
2525-
group.required = False
2526-
namespace, extras = self.parse_known_args(remaining_args,
2527-
namespace)
2528-
finally:
2529-
# restore parser values before exiting
2530-
for action in optionals:
2531-
action.required = action.save_required
2532-
for group in self._mutually_exclusive_groups:
2533-
group.required = group.save_required
2534-
finally:
2535-
self.usage = save_usage
2536-
return namespace, extras
2505+
return self._parse_known_args2(args, namespace, intermixed=True)
25372506

25382507
# ========================
25392508
# Value conversion methods

Lib/test/test_argparse.py

+39-17
Original file line numberDiff line numberDiff line change
@@ -6412,12 +6412,23 @@ def test_basic(self):
64126412
# cannot parse the '1,2,3'
64136413
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1]), args)
64146414
self.assertEqual(["2", "3"], extras)
6415+
args, extras = parser.parse_known_intermixed_args(argv)
6416+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args)
6417+
self.assertEqual([], extras)
64156418

6419+
# unknown optionals go into extras
6420+
argv = 'cmd --foo x --error 1 2 --bar y 3'.split()
6421+
args, extras = parser.parse_known_intermixed_args(argv)
6422+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args)
6423+
self.assertEqual(['--error'], extras)
64166424
argv = 'cmd --foo x 1 --error 2 --bar y 3'.split()
64176425
args, extras = parser.parse_known_intermixed_args(argv)
6418-
# unknown optionals go into extras
6419-
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1]), args)
6420-
self.assertEqual(['--error', '2', '3'], extras)
6426+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args)
6427+
self.assertEqual(['--error'], extras)
6428+
argv = 'cmd --foo x 1 2 --error --bar y 3'.split()
6429+
args, extras = parser.parse_known_intermixed_args(argv)
6430+
self.assertEqual(NS(bar='y', cmd='cmd', foo='x', rest=[1, 2, 3]), args)
6431+
self.assertEqual(['--error'], extras)
64216432

64226433
# restores attributes that were temporarily changed
64236434
self.assertIsNone(parser.usage)
@@ -6436,37 +6447,48 @@ def test_remainder(self):
64366447
parser.parse_intermixed_args(argv)
64376448
self.assertRegex(str(cm.exception), r'\.\.\.')
64386449

6439-
def test_exclusive(self):
6440-
# mutually exclusive group; intermixed works fine
6441-
parser = ErrorRaisingArgumentParser(prog='PROG')
6450+
def test_required_exclusive(self):
6451+
# required mutually exclusive group; intermixed works fine
6452+
parser = argparse.ArgumentParser(prog='PROG', exit_on_error=False)
64426453
group = parser.add_mutually_exclusive_group(required=True)
64436454
group.add_argument('--foo', action='store_true', help='FOO')
64446455
group.add_argument('--spam', help='SPAM')
64456456
parser.add_argument('badger', nargs='*', default='X', help='BADGER')
6457+
args = parser.parse_intermixed_args('--foo 1 2'.split())
6458+
self.assertEqual(NS(badger=['1', '2'], foo=True, spam=None), args)
64466459
args = parser.parse_intermixed_args('1 --foo 2'.split())
64476460
self.assertEqual(NS(badger=['1', '2'], foo=True, spam=None), args)
6448-
self.assertRaises(ArgumentParserError, parser.parse_intermixed_args, '1 2'.split())
6461+
self.assertRaisesRegex(argparse.ArgumentError,
6462+
'one of the arguments --foo --spam is required',
6463+
parser.parse_intermixed_args, '1 2'.split())
64496464
self.assertEqual(group.required, True)
64506465

6451-
def test_exclusive_incompatible(self):
6452-
# mutually exclusive group including positional - fail
6453-
parser = ErrorRaisingArgumentParser(prog='PROG')
6466+
def test_required_exclusive_with_positional(self):
6467+
# required mutually exclusive group with positional argument
6468+
parser = argparse.ArgumentParser(prog='PROG', exit_on_error=False)
64546469
group = parser.add_mutually_exclusive_group(required=True)
64556470
group.add_argument('--foo', action='store_true', help='FOO')
64566471
group.add_argument('--spam', help='SPAM')
64576472
group.add_argument('badger', nargs='*', default='X', help='BADGER')
6458-
self.assertRaises(TypeError, parser.parse_intermixed_args, [])
6473+
args = parser.parse_intermixed_args(['--foo'])
6474+
self.assertEqual(NS(foo=True, spam=None, badger='X'), args)
6475+
args = parser.parse_intermixed_args(['a', 'b'])
6476+
self.assertEqual(NS(foo=False, spam=None, badger=['a', 'b']), args)
6477+
self.assertRaisesRegex(argparse.ArgumentError,
6478+
'one of the arguments --foo --spam badger is required',
6479+
parser.parse_intermixed_args, [])
6480+
self.assertRaisesRegex(argparse.ArgumentError,
6481+
'argument badger: not allowed with argument --foo',
6482+
parser.parse_intermixed_args, ['--foo', 'a', 'b'])
6483+
self.assertRaisesRegex(argparse.ArgumentError,
6484+
'argument badger: not allowed with argument --foo',
6485+
parser.parse_intermixed_args, ['a', '--foo', 'b'])
64596486
self.assertEqual(group.required, True)
64606487

64616488
def test_invalid_args(self):
64626489
parser = ErrorRaisingArgumentParser(prog='PROG')
64636490
self.assertRaises(ArgumentParserError, parser.parse_intermixed_args, ['a'])
64646491

6465-
parser = ErrorRaisingArgumentParser(prog='PROG')
6466-
parser.add_argument('--foo', nargs="*")
6467-
parser.add_argument('foo')
6468-
with self.assertWarns(UserWarning):
6469-
parser.parse_intermixed_args(['hello', '--foo'])
64706492

64716493
class TestIntermixedMessageContentError(TestCase):
64726494
# case where Intermixed gives different error message
@@ -6485,7 +6507,7 @@ def test_missing_argument_name_in_message(self):
64856507
with self.assertRaises(ArgumentParserError) as cm:
64866508
parser.parse_intermixed_args([])
64876509
msg = str(cm.exception)
6488-
self.assertNotRegex(msg, 'req_pos')
6510+
self.assertRegex(msg, 'req_pos')
64896511
self.assertRegex(msg, 'req_opt')
64906512

64916513
# ==========================
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Fix several bugs in :meth:`argparse.ArgumentParser.parse_intermixed_args`.
2+
3+
* The parser no longer changes temporarily during parsing.
4+
* Default values are not processed twice.
5+
* Required mutually exclusive groups containing positional arguments are now supported.
6+
* The missing arguments report now includes the names of all required optional and positional arguments.
7+
* Unknown options can be intermixed with positional arguments in parse_known_intermixed_args().

0 commit comments

Comments
 (0)