Anthon
Anthon

Reputation: 76608

argparse positional, nargs='+', and default

While using argparse with positionals, and default provided, nargs='*' works as expected by setting the positional to the default if no positionals are provided. But nargs='+' does not do this. The default provided is a non-empty list, and it does get correctly shown in the help string in both cases, but the default is only used when using nargs='*'.

Is my expectation wrong or is this a bug/inconsistency in argparse?

Program:

import argparse

for narg in '*+':
    parser = argparse.ArgumentParser(description=f'test nargs {narg}')
    parser.add_argument(
        'dir',
        nargs=narg,
        default=['dir_a', 'dir_b'],
        help='provide directories as arguments (default: %(default)s)',
    )
    try:
        args = parser.parse_args(['-h'])
    except SystemExit:
        pass 
    args = parser.parse_args([])
    print(f'>>>>> args {args}\n')

Output:

usage: testnargs.py [-h] [dir ...]

test nargs *

positional arguments:
  dir         provide directories as arguments (default: ['dir_a', 'dir_b'])

options:
  -h, --help  show this help message and exit
>>>>> args Namespace(dir=['dir_a', 'dir_b'])

usage: testnargs.py [-h] dir [dir ...]

test nargs +

positional arguments:
  dir         provide directories as arguments (default: ['dir_a', 'dir_b'])

options:
  -h, --help  show this help message and exit
usage: testnargs.py [-h] dir [dir ...]
testnargs.py: error: the following arguments are required: dir

The real-world program this problem arose in, reads in the defaults from an optional config file. So I don't know if there is always going to be a default, otherwise I could of course change the '+' into '*' permanently. For now I did change to '*', and check down the line, but it is hassle.

This was run with Python 3.10.5 on MacOS.

Upvotes: 1

Views: 777

Answers (3)

smheidrich
smheidrich

Reputation: 4519

I agree that the default shouldn't be shown in the help text if it's not going to be used and in my opinion you should report this (and only this) as a bug. (←not true, see comments)

But regarding the behavior itself, I don't think a default for nargs='+' makes sense as the whole point that differentiates it from nargs='*' is that it requires the user to provide at least one argument. If you have a default, you should just use *, and if you don't know if you have a default in advance as in your case, you could either first check if you have one and then set nargs depending on that or do what you're doing now by using * and applying the default afterwards.

Sorry that this isn't very helpful but I see no way around what you're already doing (or variants thereof).

Upvotes: 1

Anthon
Anthon

Reputation: 76608

A comment block in argparse.py indicates:

assuming that actions that use the default value don't really count as "present"

So contrary to my assumption that the "bug" is in argparse not accepting the default for nargs='+' the error is actually in the help formatter as @hpaulj indicated.

Since what I expected is essentially

assuming that actions that have a default are always counted as "present"

I'll have to work around this. Since I generate the argparse code (using cligen) I can relatively easily build in some check that the argument to nargs changes from '+' to '*' if a default is provided. That would be a short-term solution, it is probably better to generate something simpler than argparse code, that will not generate these kind of surprises.

Upvotes: 1

hpaulj
hpaulj

Reputation: 231375

I'm not as involved with the argparse bug/issues as I used to be, but I don't consider this to be a bug.

Your help line is simply expanded as follows:

In [78]: help='provide directories as arguments (default: %(default)s'
In [79]: help%{'default': ['a','b']}
Out[79]: "provide directories as arguments (default: ['a', 'b']"

The relevant code methods from the source code are:

def _get_help_string(self, action):
    return action.help

def _expand_help(self, action):
    params = dict(vars(action), prog=self._prog)
    for name in list(params):
        if params[name] is SUPPRESS:
            del params[name]
    for name in list(params):
        if hasattr(params[name], '__name__'):
            params[name] = params[name].__name__
    if params.get('choices') is not None:
        choices_str = ', '.join([str(c) for c in params['choices']])
        params['choices'] = choices_str
    return self._get_help_string(action) % params

help, default etc are attributes of the argument's Action object. It just does a dict format. If the help has '{%default}' string, it is displayed.

The formatter variant that automatically adds {%default}, does test whether the Action is positional and whether the nargs allows for a meaningful default.

class ArgumentDefaultsHelpFormatter(HelpFormatter):
    """Help message formatter which adds default values to argument help.
    Only the name of this class is considered a public API. All the methods
    provided by the class are considered an implementation detail.
    """

    def _get_help_string(self, action):
        help = action.help
        if '%(default)' not in action.help:
            if action.default is not SUPPRESS:
                defaulting_nargs = [OPTIONAL, ZERO_OR_MORE]
                if action.option_strings or action.nargs in defaulting_nargs:
                    help += ' (default: %(default)s)'
        return help

Adding something to the help is easy. Removing it in any reliable way is not (though you are welcome to write your own _get_help_string method to do so). The default formatter formats the help as you specify.

Upvotes: 2

Related Questions