Skip to content

Commit de1428f

Browse files
authored
pythongh-62090: Simplify argparse usage formatting (pythonGH-105039)
Rationale ========= argparse performs a complex formatting of the usage for argument grouping and for line wrapping to fit the terminal width. This formatting has been a constant source of bugs for at least 10 years (see linked issues below) where defensive assertion errors are triggered or brackets and paranthesis are not properly handeled. Problem ======= The current implementation of argparse usage formatting relies on regular expressions to group arguments usage only to separate them again later with another set of regular expressions. This is a complex and error prone approach that caused all the issues linked below. Special casing certain argument formats has not solved the problem. The following are some of the most common issues: - empty `metavar` - mutually exclusive groups with `SUPPRESS`ed arguments - metavars with whitespace - metavars with brackets or paranthesis Solution ======== The following two comments summarize the solution: - python#82091 (comment) - python#77048 (comment) Mainly, the solution is to rewrite the usage formatting to avoid the group-then-separate approach. Instead, the usage parts are kept separate and only joined together at the end. This allows for a much simpler implementation that is easier to understand and maintain. It avoids the regular expressions approach and fixes the corresponding issues. This closes the following GitHub issues: - python#62090 - python#62549 - python#77048 - python#82091 - python#89743 - python#96310 - python#98666 These PRs become obsolete: - python#15372 - python#96311
1 parent 49258ef commit de1428f

File tree

3 files changed

+164
-72
lines changed

3 files changed

+164
-72
lines changed

Lib/argparse.py

+28-72
Original file line numberDiff line numberDiff line change
@@ -328,17 +328,8 @@ def _format_usage(self, usage, actions, groups, prefix):
328328
if len(prefix) + len(usage) > text_width:
329329

330330
# break usage into wrappable parts
331-
part_regexp = (
332-
r'\(.*?\)+(?=\s|$)|'
333-
r'\[.*?\]+(?=\s|$)|'
334-
r'\S+'
335-
)
336-
opt_usage = format(optionals, groups)
337-
pos_usage = format(positionals, groups)
338-
opt_parts = _re.findall(part_regexp, opt_usage)
339-
pos_parts = _re.findall(part_regexp, pos_usage)
340-
assert ' '.join(opt_parts) == opt_usage
341-
assert ' '.join(pos_parts) == pos_usage
331+
opt_parts = self._get_actions_usage_parts(optionals, groups)
332+
pos_parts = self._get_actions_usage_parts(positionals, groups)
342333

343334
# helper for wrapping lines
344335
def get_lines(parts, indent, prefix=None):
@@ -391,65 +382,36 @@ def get_lines(parts, indent, prefix=None):
391382
return '%s%s\n\n' % (prefix, usage)
392383

393384
def _format_actions_usage(self, actions, groups):
385+
return ' '.join(self._get_actions_usage_parts(actions, groups))
386+
387+
def _get_actions_usage_parts(self, actions, groups):
394388
# find group indices and identify actions in groups
395389
group_actions = set()
396390
inserts = {}
397391
for group in groups:
398392
if not group._group_actions:
399393
raise ValueError(f'empty group {group}')
400394

395+
if all(action.help is SUPPRESS for action in group._group_actions):
396+
continue
397+
401398
try:
402399
start = actions.index(group._group_actions[0])
403400
except ValueError:
404401
continue
405402
else:
406-
group_action_count = len(group._group_actions)
407-
end = start + group_action_count
403+
end = start + len(group._group_actions)
408404
if actions[start:end] == group._group_actions:
409-
410-
suppressed_actions_count = 0
411-
for action in group._group_actions:
412-
group_actions.add(action)
413-
if action.help is SUPPRESS:
414-
suppressed_actions_count += 1
415-
416-
exposed_actions_count = group_action_count - suppressed_actions_count
417-
if not exposed_actions_count:
418-
continue
419-
420-
if not group.required:
421-
if start in inserts:
422-
inserts[start] += ' ['
423-
else:
424-
inserts[start] = '['
425-
if end in inserts:
426-
inserts[end] += ']'
427-
else:
428-
inserts[end] = ']'
429-
elif exposed_actions_count > 1:
430-
if start in inserts:
431-
inserts[start] += ' ('
432-
else:
433-
inserts[start] = '('
434-
if end in inserts:
435-
inserts[end] += ')'
436-
else:
437-
inserts[end] = ')'
438-
for i in range(start + 1, end):
439-
inserts[i] = '|'
405+
group_actions.update(group._group_actions)
406+
inserts[start, end] = group
440407

441408
# collect all actions format strings
442409
parts = []
443-
for i, action in enumerate(actions):
410+
for action in actions:
444411

445412
# suppressed arguments are marked with None
446-
# remove | separators for suppressed arguments
447413
if action.help is SUPPRESS:
448-
parts.append(None)
449-
if inserts.get(i) == '|':
450-
inserts.pop(i)
451-
elif inserts.get(i + 1) == '|':
452-
inserts.pop(i + 1)
414+
part = None
453415

454416
# produce all arg strings
455417
elif not action.option_strings:
@@ -461,9 +423,6 @@ def _format_actions_usage(self, actions, groups):
461423
if part[0] == '[' and part[-1] == ']':
462424
part = part[1:-1]
463425

464-
# add the action string to the list
465-
parts.append(part)
466-
467426
# produce the first way to invoke the option in brackets
468427
else:
469428
option_string = action.option_strings[0]
@@ -484,26 +443,23 @@ def _format_actions_usage(self, actions, groups):
484443
if not action.required and action not in group_actions:
485444
part = '[%s]' % part
486445

487-
# add the action string to the list
488-
parts.append(part)
446+
# add the action string to the list
447+
parts.append(part)
489448

490-
# insert things at the necessary indices
491-
for i in sorted(inserts, reverse=True):
492-
parts[i:i] = [inserts[i]]
493-
494-
# join all the action items with spaces
495-
text = ' '.join([item for item in parts if item is not None])
496-
497-
# clean up separators for mutually exclusive groups
498-
open = r'[\[(]'
499-
close = r'[\])]'
500-
text = _re.sub(r'(%s) ' % open, r'\1', text)
501-
text = _re.sub(r' (%s)' % close, r'\1', text)
502-
text = _re.sub(r'%s *%s' % (open, close), r'', text)
503-
text = text.strip()
449+
# group mutually exclusive actions
450+
for start, end in sorted(inserts, reverse=True):
451+
group = inserts[start, end]
452+
group_parts = [item for item in parts[start:end] if item is not None]
453+
if group.required:
454+
open, close = "()" if len(group_parts) > 1 else ("", "")
455+
else:
456+
open, close = "[]"
457+
parts[start] = open + " | ".join(group_parts) + close
458+
for i in range(start + 1, end):
459+
parts[i] = None
504460

505-
# return the text
506-
return text
461+
# return the usage parts
462+
return [item for item in parts if item is not None]
507463

508464
def _format_text(self, text):
509465
if '%(prog)' in text:

Lib/test/test_argparse.py

+134
Original file line numberDiff line numberDiff line change
@@ -4255,6 +4255,140 @@ class TestHelpUsagePositionalsOnlyWrap(HelpTestCase):
42554255
version = ''
42564256

42574257

4258+
class TestHelpUsageMetavarsSpacesParentheses(HelpTestCase):
4259+
# https://github.com/python/cpython/issues/62549
4260+
# https://github.com/python/cpython/issues/89743
4261+
parser_signature = Sig(prog='PROG')
4262+
argument_signatures = [
4263+
Sig('-n1', metavar='()', help='n1'),
4264+
Sig('-o1', metavar='(1, 2)', help='o1'),
4265+
Sig('-u1', metavar=' (uu) ', help='u1'),
4266+
Sig('-v1', metavar='( vv )', help='v1'),
4267+
Sig('-w1', metavar='(w)w', help='w1'),
4268+
Sig('-x1', metavar='x(x)', help='x1'),
4269+
Sig('-y1', metavar='yy)', help='y1'),
4270+
Sig('-z1', metavar='(zz', help='z1'),
4271+
Sig('-n2', metavar='[]', help='n2'),
4272+
Sig('-o2', metavar='[1, 2]', help='o2'),
4273+
Sig('-u2', metavar=' [uu] ', help='u2'),
4274+
Sig('-v2', metavar='[ vv ]', help='v2'),
4275+
Sig('-w2', metavar='[w]w', help='w2'),
4276+
Sig('-x2', metavar='x[x]', help='x2'),
4277+
Sig('-y2', metavar='yy]', help='y2'),
4278+
Sig('-z2', metavar='[zz', help='z2'),
4279+
]
4280+
4281+
usage = '''\
4282+
usage: PROG [-h] [-n1 ()] [-o1 (1, 2)] [-u1 (uu) ] [-v1 ( vv )] [-w1 (w)w]
4283+
[-x1 x(x)] [-y1 yy)] [-z1 (zz] [-n2 []] [-o2 [1, 2]] [-u2 [uu] ]
4284+
[-v2 [ vv ]] [-w2 [w]w] [-x2 x[x]] [-y2 yy]] [-z2 [zz]
4285+
'''
4286+
help = usage + '''\
4287+
4288+
options:
4289+
-h, --help show this help message and exit
4290+
-n1 () n1
4291+
-o1 (1, 2) o1
4292+
-u1 (uu) u1
4293+
-v1 ( vv ) v1
4294+
-w1 (w)w w1
4295+
-x1 x(x) x1
4296+
-y1 yy) y1
4297+
-z1 (zz z1
4298+
-n2 [] n2
4299+
-o2 [1, 2] o2
4300+
-u2 [uu] u2
4301+
-v2 [ vv ] v2
4302+
-w2 [w]w w2
4303+
-x2 x[x] x2
4304+
-y2 yy] y2
4305+
-z2 [zz z2
4306+
'''
4307+
version = ''
4308+
4309+
4310+
class TestHelpUsageNoWhitespaceCrash(TestCase):
4311+
4312+
def test_all_suppressed_mutex_followed_by_long_arg(self):
4313+
# https://github.com/python/cpython/issues/62090
4314+
# https://github.com/python/cpython/issues/96310
4315+
parser = argparse.ArgumentParser(prog='PROG')
4316+
mutex = parser.add_mutually_exclusive_group()
4317+
mutex.add_argument('--spam', help=argparse.SUPPRESS)
4318+
parser.add_argument('--eggs-eggs-eggs-eggs-eggs-eggs')
4319+
usage = textwrap.dedent('''\
4320+
usage: PROG [-h]
4321+
[--eggs-eggs-eggs-eggs-eggs-eggs EGGS_EGGS_EGGS_EGGS_EGGS_EGGS]
4322+
''')
4323+
self.assertEqual(parser.format_usage(), usage)
4324+
4325+
def test_newline_in_metavar(self):
4326+
# https://github.com/python/cpython/issues/77048
4327+
mapping = ['123456', '12345', '12345', '123']
4328+
parser = argparse.ArgumentParser('11111111111111')
4329+
parser.add_argument('-v', '--verbose',
4330+
help='verbose mode', action='store_true')
4331+
parser.add_argument('targets',
4332+
help='installation targets',
4333+
nargs='+',
4334+
metavar='\n'.join(mapping))
4335+
usage = textwrap.dedent('''\
4336+
usage: 11111111111111 [-h] [-v]
4337+
123456
4338+
12345
4339+
12345
4340+
123 [123456
4341+
12345
4342+
12345
4343+
123 ...]
4344+
''')
4345+
self.assertEqual(parser.format_usage(), usage)
4346+
4347+
def test_empty_metavar_required_arg(self):
4348+
# https://github.com/python/cpython/issues/82091
4349+
parser = argparse.ArgumentParser(prog='PROG')
4350+
parser.add_argument('--nil', metavar='', required=True)
4351+
parser.add_argument('--a', metavar='A' * 70)
4352+
usage = (
4353+
'usage: PROG [-h] --nil \n'
4354+
' [--a AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
4355+
'AAAAAAAAAAAAAAAAAAAAAAA]\n'
4356+
)
4357+
self.assertEqual(parser.format_usage(), usage)
4358+
4359+
def test_all_suppressed_mutex_with_optional_nargs(self):
4360+
# https://github.com/python/cpython/issues/98666
4361+
parser = argparse.ArgumentParser(prog='PROG')
4362+
mutex = parser.add_mutually_exclusive_group()
4363+
mutex.add_argument(
4364+
'--param1',
4365+
nargs='?', const='default', metavar='NAME', help=argparse.SUPPRESS)
4366+
mutex.add_argument(
4367+
'--param2',
4368+
nargs='?', const='default', metavar='NAME', help=argparse.SUPPRESS)
4369+
usage = 'usage: PROG [-h]\n'
4370+
self.assertEqual(parser.format_usage(), usage)
4371+
4372+
def test_nested_mutex_groups(self):
4373+
parser = argparse.ArgumentParser(prog='PROG')
4374+
g = parser.add_mutually_exclusive_group()
4375+
g.add_argument("--spam")
4376+
with warnings.catch_warnings():
4377+
warnings.simplefilter('ignore', DeprecationWarning)
4378+
gg = g.add_mutually_exclusive_group()
4379+
gg.add_argument("--hax")
4380+
gg.add_argument("--hox", help=argparse.SUPPRESS)
4381+
gg.add_argument("--hex")
4382+
g.add_argument("--eggs")
4383+
parser.add_argument("--num")
4384+
4385+
usage = textwrap.dedent('''\
4386+
usage: PROG [-h] [--spam SPAM | [--hax HAX | --hex HEX] | --eggs EGGS]
4387+
[--num NUM]
4388+
''')
4389+
self.assertEqual(parser.format_usage(), usage)
4390+
4391+
42584392
class TestHelpVariableExpansion(HelpTestCase):
42594393
"""Test that variables are expanded properly in help messages"""
42604394

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix assertion errors caused by whitespace in metavars or ``SUPPRESS``-ed groups
2+
in :mod:`argparse` by simplifying usage formatting. Patch by Ali Hamdan.

0 commit comments

Comments
 (0)