GH-99749: Add optional feature to suggest correct names (ArgumentParser) (GH-124456)

This commit is contained in:
Savannah Ostrowski 2024-10-17 00:07:37 -07:00 committed by GitHub
parent a5a7f5e16d
commit 624be8699a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 144 additions and 23 deletions

View File

@ -61,7 +61,8 @@ ArgumentParser objects
formatter_class=argparse.HelpFormatter, \
prefix_chars='-', fromfile_prefix_chars=None, \
argument_default=None, conflict_handler='error', \
add_help=True, allow_abbrev=True, exit_on_error=True)
add_help=True, allow_abbrev=True, exit_on_error=True, \
suggest_on_error=False)
Create a new :class:`ArgumentParser` object. All parameters should be passed
as keyword arguments. Each parameter has its own more detailed description
@ -103,6 +104,10 @@ ArgumentParser objects
* exit_on_error_ - Determines whether or not ArgumentParser exits with
error info when an error occurs. (default: ``True``)
* suggest_on_error_ - Enables suggestions for mistyped argument choices
and subparser names (default: ``False``)
.. versionchanged:: 3.5
*allow_abbrev* parameter was added.
@ -559,6 +564,27 @@ If the user would like to catch errors manually, the feature can be enabled by s
.. versionadded:: 3.9
suggest_on_error
^^^^^^^^^^^^^^^^
By default, when a user passes an invalid argument choice or subparser name,
:class:`ArgumentParser` will exit with error info and list the permissible
argument choices (if specified) or subparser names as part of the error message.
If the user would like to enable suggestions for mistyped argument choices and
subparser names, the feature can be enabled by setting ``suggest_on_error`` to
``True``. Note that this only applies for arguments when the choices specified
are strings::
>>> parser = argparse.ArgumentParser(description='Process some integers.', suggest_on_error=True)
>>> parser.add_argument('--action', choices=['sum', 'max'])
>>> parser.add_argument('integers', metavar='N', type=int, nargs='+',
... help='an integer for the accumulator')
>>> parser.parse_args(['--action', 'sumn', 1, 2, 3])
tester.py: error: argument --action: invalid choice: 'sumn', maybe you meant 'sum'? (choose from 'sum', 'max')
.. versionadded:: 3.14
The add_argument() method
-------------------------

View File

@ -1773,6 +1773,8 @@ class ArgumentParser(_AttributeHolder, _ActionsContainer):
- allow_abbrev -- Allow long options to be abbreviated unambiguously
- exit_on_error -- Determines whether or not ArgumentParser exits with
error info when an error occurs
- suggest_on_error - Enables suggestions for mistyped argument choices
and subparser names. (default: ``False``)
"""
def __init__(self,
@ -1788,7 +1790,8 @@ class ArgumentParser(_AttributeHolder, _ActionsContainer):
conflict_handler='error',
add_help=True,
allow_abbrev=True,
exit_on_error=True):
exit_on_error=True,
suggest_on_error=False):
superinit = super(ArgumentParser, self).__init__
superinit(description=description,
@ -1804,6 +1807,7 @@ class ArgumentParser(_AttributeHolder, _ActionsContainer):
self.add_help = add_help
self.allow_abbrev = allow_abbrev
self.exit_on_error = exit_on_error
self.suggest_on_error = suggest_on_error
add_group = self.add_argument_group
self._positionals = add_group(_('positional arguments'))
@ -2601,14 +2605,27 @@ class ArgumentParser(_AttributeHolder, _ActionsContainer):
def _check_value(self, action, value):
# converted value must be one of the choices (if specified)
choices = action.choices
if choices is not None:
if isinstance(choices, str):
choices = iter(choices)
if value not in choices:
args = {'value': str(value),
'choices': ', '.join(map(str, action.choices))}
msg = _('invalid choice: %(value)r (choose from %(choices)s)')
raise ArgumentError(action, msg % args)
if choices is None:
return
if isinstance(choices, str):
choices = iter(choices)
if value not in choices:
args = {'value': str(value),
'choices': ', '.join(map(str, action.choices))}
msg = _('invalid choice: %(value)r (choose from %(choices)s)')
if self.suggest_on_error and isinstance(value, str):
if all(isinstance(choice, str) for choice in action.choices):
import difflib
suggestions = difflib.get_close_matches(value, action.choices, 1)
if suggestions:
args['closest'] = suggestions[0]
msg = _('invalid choice: %(value)r, maybe you meant %(closest)r? '
'(choose from %(choices)s)')
raise ArgumentError(action, msg % args)
# =======================
# Help-formatting methods

View File

@ -2253,6 +2253,95 @@ class TestNegativeNumber(ParserTestCase):
('--complex -1e-3j', NS(int=None, float=None, complex=-0.001j)),
]
class TestArgumentAndSubparserSuggestions(TestCase):
"""Test error handling and suggestion when a user makes a typo"""
def test_wrong_argument_error_with_suggestions(self):
parser = ErrorRaisingArgumentParser(suggest_on_error=True)
parser.add_argument('foo', choices=['bar', 'baz'])
with self.assertRaises(ArgumentParserError) as excinfo:
parser.parse_args(('bazz',))
self.assertIn(
"error: argument foo: invalid choice: 'bazz', maybe you meant 'baz'? (choose from bar, baz)",
excinfo.exception.stderr
)
def test_wrong_argument_error_no_suggestions(self):
parser = ErrorRaisingArgumentParser(suggest_on_error=False)
parser.add_argument('foo', choices=['bar', 'baz'])
with self.assertRaises(ArgumentParserError) as excinfo:
parser.parse_args(('bazz',))
self.assertIn(
"error: argument foo: invalid choice: 'bazz' (choose from bar, baz)",
excinfo.exception.stderr,
)
def test_wrong_argument_subparsers_with_suggestions(self):
parser = ErrorRaisingArgumentParser(suggest_on_error=True)
subparsers = parser.add_subparsers(required=True)
subparsers.add_parser('foo')
subparsers.add_parser('bar')
with self.assertRaises(ArgumentParserError) as excinfo:
parser.parse_args(('baz',))
self.assertIn(
"error: argument {foo,bar}: invalid choice: 'baz', maybe you meant"
" 'bar'? (choose from foo, bar)",
excinfo.exception.stderr,
)
def test_wrong_argument_subparsers_no_suggestions(self):
parser = ErrorRaisingArgumentParser(suggest_on_error=False)
subparsers = parser.add_subparsers(required=True)
subparsers.add_parser('foo')
subparsers.add_parser('bar')
with self.assertRaises(ArgumentParserError) as excinfo:
parser.parse_args(('baz',))
self.assertIn(
"error: argument {foo,bar}: invalid choice: 'baz' (choose from foo, bar)",
excinfo.exception.stderr,
)
def test_wrong_argument_no_suggestion_implicit(self):
parser = ErrorRaisingArgumentParser()
parser.add_argument('foo', choices=['bar', 'baz'])
with self.assertRaises(ArgumentParserError) as excinfo:
parser.parse_args(('bazz',))
self.assertIn(
"error: argument foo: invalid choice: 'bazz' (choose from bar, baz)",
excinfo.exception.stderr,
)
def test_suggestions_choices_empty(self):
parser = ErrorRaisingArgumentParser(suggest_on_error=True)
parser.add_argument('foo', choices=[])
with self.assertRaises(ArgumentParserError) as excinfo:
parser.parse_args(('bazz',))
self.assertIn(
"error: argument foo: invalid choice: 'bazz' (choose from )",
excinfo.exception.stderr,
)
def test_suggestions_choices_int(self):
parser = ErrorRaisingArgumentParser(suggest_on_error=True)
parser.add_argument('foo', choices=[1, 2])
with self.assertRaises(ArgumentParserError) as excinfo:
parser.parse_args(('3',))
self.assertIn(
"error: argument foo: invalid choice: '3' (choose from 1, 2)",
excinfo.exception.stderr,
)
def test_suggestions_choices_mixed_types(self):
parser = ErrorRaisingArgumentParser(suggest_on_error=True)
parser.add_argument('foo', choices=[1, '2'])
with self.assertRaises(ArgumentParserError) as excinfo:
parser.parse_args(('3',))
self.assertIn(
"error: argument foo: invalid choice: '3' (choose from 1, 2)",
excinfo.exception.stderr,
)
class TestInvalidAction(TestCase):
"""Test invalid user defined Action"""
@ -2505,18 +2594,6 @@ class TestAddSubparsers(TestCase):
'error: the following arguments are required: {foo,bar}\n$'
)
def test_wrong_argument_subparsers_no_destination_error(self):
parser = ErrorRaisingArgumentParser()
subparsers = parser.add_subparsers(required=True)
subparsers.add_parser('foo')
subparsers.add_parser('bar')
with self.assertRaises(ArgumentParserError) as excinfo:
parser.parse_args(('baz',))
self.assertRegex(
excinfo.exception.stderr,
r"error: argument {foo,bar}: invalid choice: 'baz' \(choose from foo, bar\)\n$"
)
def test_optional_subparsers(self):
parser = ErrorRaisingArgumentParser()
subparsers = parser.add_subparsers(dest='command', required=False)
@ -2862,7 +2939,7 @@ class TestParentParsers(TestCase):
parser = ErrorRaisingArgumentParser(parents=[self.ab_mutex_parent])
self._test_mutex_ab(parser.parse_args)
def test_single_granparent_mutex(self):
def test_single_grandparent_mutex(self):
parents = [self.ab_mutex_parent]
parser = ErrorRaisingArgumentParser(add_help=False, parents=parents)
parser = ErrorRaisingArgumentParser(parents=[parser])

View File

@ -0,0 +1 @@
Adds a feature to optionally enable suggestions for argument choices and subparser names if mistyped by the user.