Skip to content

Commit

Permalink
pythonGH-99749: Add optional feature to suggest correct names (Argume…
Browse files Browse the repository at this point in the history
…ntParser) (pythonGH-124456)
  • Loading branch information
savannahostrowski authored Oct 17, 2024
1 parent a5a7f5e commit 624be86
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 23 deletions.
28 changes: 27 additions & 1 deletion Doc/library/argparse.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
-------------------------
Expand Down
35 changes: 26 additions & 9 deletions Lib/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1788,7 +1790,8 @@ def __init__(self,
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,
Expand All @@ -1804,6 +1807,7 @@ def __init__(self,
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'))
Expand Down Expand Up @@ -2601,14 +2605,27 @@ def _get_value(self, action, arg_string):
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
Expand Down
103 changes: 90 additions & 13 deletions Lib/test/test_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down Expand Up @@ -2505,18 +2594,6 @@ def test_required_subparsers_no_destination_error(self):
'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)
Expand Down Expand Up @@ -2862,7 +2939,7 @@ def test_single_parent_mutex(self):
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])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Adds a feature to optionally enable suggestions for argument choices and subparser names if mistyped by the user.

0 comments on commit 624be86

Please sign in to comment.