Skip to content

gh-53584: Prevent variable-nargs options from stealing required positional args#146513

Open
stephenfin wants to merge 2 commits intopython:mainfrom
stephenfin:argparse-greedy-opts
Open

gh-53584: Prevent variable-nargs options from stealing required positional args#146513
stephenfin wants to merge 2 commits intopython:mainfrom
stephenfin:argparse-greedy-opts

Conversation

@stephenfin
Copy link
Copy Markdown

argparse supports options with variable numbers of args. gh-53584 tracks a long-standing bug where options with a non-fixed number of args (nargs='?', nargs='*', or nargs='+') will greedily consume argument strings that should have been reserved for required positional arguments. For example:

parser.add_argument('--foo', nargs='?')
parser.add_argument('bar')
parser.parse_args(['--foo', 'abc'])  # bar got nothing

argparse works by pattern-matching, with the bulk of its logic defined in _parse_known_args(). That method encodes each argument string as a single character ('O' for a recognised option flag, 'A' for everything else, and '-' for strings following --) which gives us a resulting string pattern (e.g. 'OAOA'). We then "consume" arguments using this string pattern, handling both positionals (via the consume_positionals() closure) and optionals (via consume_optional()).

The bug arises in the latter. consume_optional() processes options by building a regex based on the option's nargs (e.g. '-*A?-*' for nargs='?') and then runs this regex against the remainder of the string pattern (i.e. anything following the option flag). This is done via _match_argument(). The regex will always stop at the next option flag ('O' token) but for non-fixed nargs values like '?' and '+' may greedily consume every positional ('A' token) up to that point.

This fix works by manipulating the string pattern as part of our optional consumption. Any 'A' tokens that are required by remaining positionals are masked to 'O' to prevent the regex consuming them. Masking will only consider tokens up to the next option flag ('O') and it both accounts for what future options can absorb (to avoid masking more than is necessary) and ensures that at least the minimum arguments required for the optional are actually consumed.

In addition, we also handle the parse_intermixed_args() case. In intermixed mode, positional-typed arguments already collected to the left of the current option are also accounted for, since they will satisfy positionals in the second-pass parse.

Note that this is a rather gnarly issue, and I've done my best to avoid changing the API behavior of the module without layering on too much additional complexity, in the hope that this might actually be backportable. Hopefully my proposed approach is sound but I'm happy to iterate on this if there's something I've missed or there is a better way to do this.

Most of these are marked as expected fail for now, pending the fix.

Signed-off-by: Stephen Finucane <stephen@that.guru>
… positional args

Options with nargs='?', nargs='*', or nargs='+' were greedily consuming
argument strings that should have been reserved for required positional
arguments. For example:

    parser.add_argument('--foo', nargs='?')
    parser.add_argument('bar')
    parser.parse_args(['--foo', 'abc'])  # bar got nothing

argparse works by pattern-matching, with the bulk of its logic defined
in _parse_known_args(). That method encodes each argument string as a
single character ('O' for a recognised option flag, 'A' for everything
else, and '-' for strings following '--') which gives us a resulting
string pattern (e.g. 'OAOA'). We then "consume" arguments using this
string pattern, handling both positionals (via the consume_positionals()
closure) and optionals (via consume_optional()).

The bug arises in the latter. consume_optional() processes options by
building a regex based on the option's nargs (e.g. '-*A?-*' for
nargs='?') and then runs this regex against the remainder of the string
pattern (i.e. anything following the option flag). This is done via
_match_argument(). The regex will always stop at the next option flag
('O' token) but for non-fixed nargs values like '?' and '+' may greedily
consume every positional ('A' token) up to that point.

This fix works by manipulating the string pattern as part of our
optional consumption. Any 'A' tokens that are required by remaining
positionals are masked to 'O' to prevent the regex consuming them.
Masking will only consider tokens up to the next option flag ('O') and
it both accounts for what future options can absorb (to avoid masking
more than is necessary) and ensures that at least the minimum arguments
required for the optional are actually consumed.

In addition, we also handle the parse_intermixed_args() case. In
intermixed mode, positional-typed arguments already collected to the
left of the current option are also accounted for, since they will
satisfy positionals in the second-pass parse.

Signed-off-by: Stephen Finucane <stephen@that.guru>
@Shrey-N
Copy link
Copy Markdown
Contributor

Shrey-N commented Mar 31, 2026

Hiya @stephenfin, shouldn't we pre calculate the required positional counts instead of re scanning them inside the optional loop? I am a little worried about $O(N^2)$ performance on long argument lists.

Copy link
Copy Markdown
Member

@savannahostrowski savannahostrowski left a comment

Choose a reason for hiding this comment

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

Thanks for taking a look at this. I've tested this very extensively and I think it looks pretty solid from a UX perspective.

From a performance perspective, I ran a stress-test benchmark (all nargs='?' options with a required positional) scaling from 10 to 100 options. Even at 100 options, it ran in under 1ms, and realistically, CLIs have far, far fewer options. IMO, this is not a practical concern, but we could explore some optimizations like precomputing the sorted option indices?

Aside from this, I will say that any bigger change to argparse makes me a bit uneasy. Mainly because people are likely working around this bug and this will be a silent behaviour change (i.e. existing programs will parse differently without any error or warning). We definitely need to add a What's New entry (not just a NEWS blurb) calling this out, so users upgrading to 3.15 are aware.

I also left a few comments below.

parser.add_argument('baz')
parser.add_argument('bax')
args = parser.parse_args(['--foo', 'a', '--bar', 'b'])
self.assertEqual(args.baz, 'a')
Copy link
Copy Markdown
Member

@savannahostrowski savannahostrowski Apr 3, 2026

Choose a reason for hiding this comment

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

Can we make sure that every test asserts both positionals and optionals? We should check to make sure that foo and bar ended up with the correct values. There are several tests that would benefit from this.

]

def test_does_not_steal_required_positional(self):
# https://github.com/python/cpython/issues/53584
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you please remove the GitHub issue comment on each test? It's a bit noisy.

Comment on lines +2324 to +2327
extras_arg_count = sum(
1 for c in extras_pattern if c == 'A'
)
min_pos = max(0, min_pos - extras_arg_count)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
extras_arg_count = sum(
1 for c in extras_pattern if c == 'A'
)
min_pos = max(0, min_pos - extras_arg_count)
min_pos = max(0, min_pos - extras_pattern.count('A'))

@@ -664,6 +664,17 @@ class TestOptionalsNargs3(ParserTestCase):
('-x a b c', NS(x=['a', 'b', 'c'])),
]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should add at least one more test for something like ['--foo', '--', 'abc'] with nargs='?' and a required positional, I think.

@bedevere-app
Copy link
Copy Markdown

bedevere-app bot commented Apr 3, 2026

A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated.

Once you have made the requested changes, please leave a comment on this pull request containing the phrase I have made the requested changes; please review again. I will then notify any core developers who have left a review that you're ready for them to take another look at this pull request.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants