Skip to content

gh-132558: allow choices to be specified as strings in presence of a type argument #132743

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions Doc/library/argparse.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ ArgumentParser objects
prefix_chars='-', fromfile_prefix_chars=None, \
argument_default=None, conflict_handler='error', \
add_help=True, allow_abbrev=True, exit_on_error=True, \
suggest_on_error=False)
suggest_on_error=False, convert_choices=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 @@ -119,6 +119,9 @@ ArgumentParser objects
* suggest_on_error_ - Enables suggestions for mistyped argument choices
and subparser names (default: ``False``)

* convert_choices_ - Runs the ``choices`` through the ``type`` callable
during checking (default: ``False``)


.. versionchanged:: 3.5
*allow_abbrev* parameter was added.
Expand Down Expand Up @@ -612,6 +615,38 @@ keyword argument::
.. versionadded:: 3.14


convert_choices
^^^^^^^^^^^^^^^

By default, when a user passes both a ``type`` and a ``choices`` argument, the
``choices`` need to be specified in the target type, after conversion.
This can cause confusing ``usage`` and ``help`` strings. If the user would like
to specify ``choices`` in the same vocabulary as the end-user would enter them,
this feature can be enabled by setting ``convert_choices`` to ``True``::

>>> parser = argparse.ArgumentParser(convert_choices=True)
>>> parser.add_argument('when',
... choices=['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'],
... type=to_dow)
>>> parser.print_help()
usage: example_broken.py [-h] [--days {mo,tu,we,th,fr,sa,su}]

options:
-h, --help show this help message and exit
--days {mo,tu,we,th,fr,sa,su}


If you're writing code that needs to be compatible with older Python versions
and want to opportunistically use ``convert_choices`` when it's available, you
can set it as an attribute after initializing the parser instead of using the
keyword argument::

>>> parser = argparse.ArgumentParser()
>>> parser.convert_choices = True

.. versionadded:: next


The add_argument() method
-------------------------

Expand Down Expand Up @@ -1124,9 +1159,12 @@ if the argument was not one of the acceptable values::
game.py: error: argument move: invalid choice: 'fire' (choose from 'rock',
'paper', 'scissors')

Note that inclusion in the *choices* sequence is checked after any type_
conversions have been performed, so the type of the objects in the *choices*
sequence should match the type_ specified.
Comment on lines -1127 to -1129
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it was never a bug ;-)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, you got me. I'm convinced now :-)

Note that, by default, inclusion in the *choices* sequence is checked after
any type_ conversions have been performed, so the type of the objects in the
*choices* sequence should match the type_ specified. This can lead to
confusing ``usage`` messages. If you want to convert *choices* using type_
before checking, set the ``convert_choices`` flag on :class:`~ArgumentParser`.


Any sequence can be passed as the *choices* value, so :class:`list` objects,
:class:`tuple` objects, and custom sequences are all supported.
Expand Down
34 changes: 24 additions & 10 deletions Lib/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -1777,6 +1777,8 @@ class ArgumentParser(_AttributeHolder, _ActionsContainer):
error info when an error occurs
- suggest_on_error - Enables suggestions for mistyped argument choices
and subparser names. (default: ``False``)
- convert_choices - Runs the ``choices`` through the ``type`` callable
during checking. (default: ``False``)
"""

def __init__(self,
Expand All @@ -1793,7 +1795,8 @@ def __init__(self,
add_help=True,
allow_abbrev=True,
exit_on_error=True,
suggest_on_error=False):
suggest_on_error=False,
convert_choices=False):

superinit = super(ArgumentParser, self).__init__
superinit(description=description,
Expand All @@ -1810,6 +1813,7 @@ def __init__(self,
self.allow_abbrev = allow_abbrev
self.exit_on_error = exit_on_error
self.suggest_on_error = suggest_on_error
self.convert_choices = convert_choices

add_group = self.add_argument_group
self._positionals = add_group(_('positional arguments'))
Expand Down Expand Up @@ -2524,7 +2528,7 @@ def _get_values(self, action, arg_strings):
elif len(arg_strings) == 1 and action.nargs in [None, OPTIONAL]:
arg_string, = arg_strings
value = self._get_value(action, arg_string)
self._check_value(action, value)
self._check_value(action, value, arg_string)

# REMAINDER arguments convert all values, checking none
elif action.nargs == REMAINDER:
Expand All @@ -2533,7 +2537,7 @@ def _get_values(self, action, arg_strings):
# PARSER arguments convert all values, but check only the first
elif action.nargs == PARSER:
value = [self._get_value(action, v) for v in arg_strings]
self._check_value(action, value[0])
self._check_value(action, value[0], arg_strings[0])

# SUPPRESS argument does not put anything in the namespace
elif action.nargs == SUPPRESS:
Expand All @@ -2542,8 +2546,8 @@ def _get_values(self, action, arg_strings):
# all other types of nargs produce a list
else:
value = [self._get_value(action, v) for v in arg_strings]
for v in value:
self._check_value(action, v)
for v, s in zip(value, arg_strings):
self._check_value(action, v, s)

# return the converted value
return value
Expand Down Expand Up @@ -2572,24 +2576,34 @@ def _get_value(self, action, arg_string):
# return the converted value
return result

def _check_value(self, action, value):
def _check_value(self, action, value, arg_string=None):
# converted value must be one of the choices (if specified)
choices = action.choices
if choices is None:
return

if arg_string is None:
arg_string = value

if isinstance(choices, str):
choices = iter(choices)

if value not in choices:
args = {'value': str(value),
typed_choices = []
if (self.convert_choices and
action.type and
all(isinstance(choice, str) for choice in choices)
):
typed_choices = [action.type(v) for v in choices]

if value not in choices and value not in typed_choices:
args = {'value': arg_string,
'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 self.suggest_on_error:
if all(isinstance(choice, str) for choice in action.choices):
import difflib
suggestions = difflib.get_close_matches(value, action.choices, 1)
suggestions = difflib.get_close_matches(arg_string, action.choices, 1)
if suggestions:
args['closest'] = suggestions[0]
msg = _('invalid choice: %(value)r, maybe you meant %(closest)r? '
Expand Down
73 changes: 73 additions & 0 deletions Lib/test/test_argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -1934,6 +1934,46 @@ def setUp(self):
('-x - -', NS(x=eq_bstdin, spam=eq_bstdin)),
]

class TestChoices(ParserTestCase):
"""Test integer choices without conversion."""
def to_dow(arg):
days = ["mo", "tu", "we", "th", "fr", "sa", "su"]
if arg in days:
return days.index(arg) + 1
else:
return None

argument_signatures = [
Sig('when',
type=to_dow, choices=[1, 2, 3, 4, 5, 6, 7],
)
]
failures = ['now', '1']
successes = [
('mo', NS(when=1)),
('su', NS(when=7)),
]

class TestTypedChoices(TestChoices):
"""Test a set of string choices that convert to weekdays"""

parser_signature = Sig(convert_choices=True)
argument_signatures = [
Sig('when',
type=TestChoices.to_dow, choices=["mo", "tu", "we" , "th", "fr", "sa", "su"],
)
]

class TestTypedChoicesNoFlag(TestChoices):
"""Without the feature flag we fail"""
argument_signatures = [
Sig('when',
type=TestChoices.to_dow, choices=["mo", "tu", "we" , "th", "fr", "sa", "su"],
)
]
failures = ['mo']
successes = []


class WFile(object):
seen = set()
Expand Down Expand Up @@ -5468,6 +5508,39 @@ def custom_type(string):
version = ''


class TestHelpTypedChoices(HelpTestCase):
from datetime import date, timedelta
def to_date(arg):
if arg == "today":
return date.today()
elif arg == "tomorrow":
return date.today() + timedelta(days=1).date()
else:
return None

parser_signature = Sig(prog='PROG', convert_choices=True)
argument_signatures = [
Sig('when',
type=to_date,
choices=["today", "tomorrow"]
),
]

usage = '''\
usage: PROG [-h] {today,tomorrow}
'''
help = usage + '''\

positional arguments:
{today,tomorrow}

options:
-h, --help show this help message and exit
'''
version = ''



class TestHelpUsageLongSubparserCommand(TestCase):
"""Test that subparser commands are formatted correctly in help"""
maxDiff = None
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:mod:`argparse` Add option ``convert_choices`` to :class:`ArgumentParser`. Combined with ``type``, allows ``choices``
to be entered as strings and converted during argument checking.