gh-138697: Fix inferring dest from a single-dash long option in argparse (#138699)

* gh-138697: Fix inferring dest from a single-dash long option in argparse

If a short option and a single-dash long option are passed to add_argument(),
dest is now inferred from the single-dash long option.

* Make double-dash options taking priority over single-dash long options.

---------

Co-authored-by: Savannah Ostrowski <savannah@python.org>
This commit is contained in:
Serhiy Storchaka 2025-11-20 20:41:58 +02:00 committed by GitHub
parent b3383085f9
commit 77cb39e0c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 58 additions and 22 deletions

View file

@ -1322,8 +1322,12 @@ attribute is determined by the ``dest`` keyword argument of
For optional argument actions, the value of ``dest`` is normally inferred from
the option strings. :class:`ArgumentParser` generates the value of ``dest`` by
taking the first long option string and stripping away the initial ``--``
string. If no long option strings were supplied, ``dest`` will be derived from
taking the first double-dash long option string and stripping away the initial
``-`` characters.
If no double-dash long option strings were supplied, ``dest`` will be derived
from the first single-dash long option string by stripping the initial ``-``
character.
If no long option strings were supplied, ``dest`` will be derived from
the first short option string by stripping the initial ``-`` character. Any
internal ``-`` characters will be converted to ``_`` characters to make sure
the string is a valid attribute name. The examples below illustrate this
@ -1331,11 +1335,12 @@ behavior::
>>> parser = argparse.ArgumentParser()
>>> parser.add_argument('-f', '--foo-bar', '--foo')
>>> parser.add_argument('-q', '-quz')
>>> parser.add_argument('-x', '-y')
>>> parser.parse_args('-f 1 -x 2'.split())
Namespace(foo_bar='1', x='2')
>>> parser.parse_args('--foo 1 -y 2'.split())
Namespace(foo_bar='1', x='2')
>>> parser.parse_args('-f 1 -q 2 -x 3'.split())
Namespace(foo_bar='1', quz='2', x='3')
>>> parser.parse_args('--foo 1 -quz 2 -y 3'.split())
Namespace(foo_bar='1', quz='2', x='2')
``dest`` allows a custom attribute name to be provided::
@ -1344,6 +1349,9 @@ behavior::
>>> parser.parse_args('--foo XXX'.split())
Namespace(bar='XXX')
.. versionchanged:: next
Single-dash long option now takes precedence over short options.
.. _deprecated:

View file

@ -1275,3 +1275,10 @@ that may require changes to your code.
Use its :meth:`!close` method or the :func:`contextlib.closing` context
manager to close it.
(Contributed by Osama Abdelkader and Serhiy Storchaka in :gh:`140601`.)
* If a short option and a single-dash long option are passed to
:meth:`argparse.ArgumentParser.add_argument`, *dest* is now inferred from
the single-dash long option. For example, in ``add_argument('-f', '-foo')``,
*dest* is now ``'foo'`` instead of ``'f'``.
Pass an explicit *dest* argument to preserve the old behavior.
(Contributed by Serhiy Storchaka in :gh:`138697`.)

View file

@ -1660,29 +1660,35 @@ def _get_positional_kwargs(self, dest, **kwargs):
def _get_optional_kwargs(self, *args, **kwargs):
# determine short and long option strings
option_strings = []
long_option_strings = []
for option_string in args:
# error on strings that don't start with an appropriate prefix
if not option_string[0] in self.prefix_chars:
raise ValueError(
f'invalid option string {option_string!r}: '
f'must start with a character {self.prefix_chars!r}')
# strings starting with two prefix characters are long options
option_strings.append(option_string)
if len(option_string) > 1 and option_string[1] in self.prefix_chars:
long_option_strings.append(option_string)
# infer destination, '--foo-bar' -> 'foo_bar' and '-x' -> 'x'
dest = kwargs.pop('dest', None)
if dest is None:
if long_option_strings:
dest_option_string = long_option_strings[0]
else:
dest_option_string = option_strings[0]
dest = dest_option_string.lstrip(self.prefix_chars)
priority = 0
for option_string in option_strings:
if len(option_string) <= 2:
# short option: '-x' -> 'x'
if priority < 1:
dest = option_string.lstrip(self.prefix_chars)
priority = 1
elif option_string[1] not in self.prefix_chars:
# single-dash long option: '-foo' -> 'foo'
if priority < 2:
dest = option_string.lstrip(self.prefix_chars)
priority = 2
else:
# two-dash long option: '--foo' -> 'foo'
dest = option_string.lstrip(self.prefix_chars)
break
if not dest:
msg = f'dest= is required for options like {option_string!r}'
msg = f'dest= is required for options like {repr(option_strings)[1:-1]}'
raise TypeError(msg)
dest = dest.replace('-', '_')

View file

@ -581,13 +581,22 @@ class TestOptionalsShortLong(ParserTestCase):
class TestOptionalsDest(ParserTestCase):
"""Tests various means of setting destination"""
argument_signatures = [Sig('--foo-bar'), Sig('--baz', dest='zabbaz')]
argument_signatures = [
Sig('-x', '-foobar', '--foo-bar', '-barfoo', '-X'),
Sig('--baz', dest='zabbaz'),
Sig('-y', '-qux', '-Y'),
Sig('-z'),
]
failures = ['a']
successes = [
('--foo-bar f', NS(foo_bar='f', zabbaz=None)),
('--baz g', NS(foo_bar=None, zabbaz='g')),
('--foo-bar h --baz i', NS(foo_bar='h', zabbaz='i')),
('--baz j --foo-bar k', NS(foo_bar='k', zabbaz='j')),
('--foo-bar f', NS(foo_bar='f', zabbaz=None, qux=None, z=None)),
('-x f', NS(foo_bar='f', zabbaz=None, qux=None, z=None)),
('--baz g', NS(foo_bar=None, zabbaz='g', qux=None, z=None)),
('--foo-bar h --baz i', NS(foo_bar='h', zabbaz='i', qux=None, z=None)),
('--baz j --foo-bar k', NS(foo_bar='k', zabbaz='j', qux=None, z=None)),
('-qux l', NS(foo_bar=None, zabbaz=None, qux='l', z=None)),
('-y l', NS(foo_bar=None, zabbaz=None, qux='l', z=None)),
('-z m', NS(foo_bar=None, zabbaz=None, qux=None, z='m')),
]
@ -5611,6 +5620,8 @@ def test_invalid_option_strings(self):
self.assertTypeError('-', errmsg='dest= is required')
self.assertTypeError('--', errmsg='dest= is required')
self.assertTypeError('---', errmsg='dest= is required')
self.assertTypeError('-', '--', '---',
errmsg="dest= is required for options like '-', '--', '---'")
def test_invalid_prefix(self):
self.assertValueError('--foo', '+foo',

View file

@ -0,0 +1,4 @@
Fix inferring *dest* from a single-dash long option in :mod:`argparse`. If a
short option and a single-dash long option are passed to
:meth:`!add_argument`, *dest* is now inferred from the single-dash long
option.