12

This is slightly related to the topic covered in a question about allowing an argument to be specified multiple times.

I'd like to be able to specify an option multiple times like this:

 tool --foo 1 --foo 2 --foo 3

And also like this:

 tool a b c

I'd also like to support both at the same time:

 tool a b c --foo 1 --foo2 --foo 3

This works fine with:

import argparse
parser = argparse.ArgumentParser()
parser.add_argument('foo', nargs='*', action='append')
parser.add_argument('--foo', nargs='*', dest='foo', action='append')

The result list can be easily flattened out:

args = parser.parse_args('a b c --foo 1 --foo 2 --foo 3'.split())
args.foo = [el for elements in args.foo for el in elements]

yields:

>>> args
Namespace(foo=['a', 'b', 'c', '1', '2', '3'])

How do I add a default value in a way that the default is not being used as soon as one argument is specified by the user?

If adding just default=[['spam']] to one of the add_argument() calls, the default is always part of the result. I cannot get argparse to remove it by itself as soon as a user provides an argument herself.

I'm hoping that there's a solution with what argparse already provides itself.

Community
  • 1
  • 1
cfi
  • 10,915
  • 8
  • 57
  • 103
  • 2
    Late to the party: While I understand the motive of keeping it all within `argparse`, for most applications of this, wouldn't a simple `if len(foo) > 1: foo.pop(0)` take care of the problem? – hBy2Py Aug 23 '16 at 16:45
  • 2
    @hBy2Py: Understand your point (yes), and no I'd like to keep it within the library ;-). I'm often an idealist going beyond the "point of good enough" for the fun of it and to learn. To the actual proposed code, it may rather be `if len(foo) == 0: foo.append(default)` (and specify no default in code). Otherwise there may be strange cornercases (order not guaranteed, dunno? Sth else?). Funnily before mgilson answered, I wrote an answer myself doing what you suggested in a subclass of `Action`. I deleted it because the accepted solution worked fine for me and was more concise. – cfi Aug 23 '16 at 21:17

1 Answers1

8

I think this is a slightly more clean variation on the other answer (relying on the self.default attribute of custom actions):

import argparse
import sys

class Extender(argparse.Action):
    def __call__(self,parser,namespace,values,option_strings=None):
        #Need None here incase `argparse.SUPPRESS` was supplied for `dest`
        dest = getattr(namespace,self.dest,None) 
        #print dest,self.default,values,option_strings
        if(not hasattr(dest,'extend') or dest == self.default):
            dest = []
            setattr(namespace,self.dest,dest)
            #if default isn't set to None, this method might be called
            # with the default as `values` for other arguements which
            # share this destination.
            parser.set_defaults(**{self.dest:None}) 

        try:
            dest.extend(values)
        except ValueError:
            dest.append(values)

        #another option:
        #if not isinstance(values,basestring):
        #    dest.extend(values)
        #else:
        #    dest.append(values) #It's a string.  Oops.

def new_parser(args):
    parser = argparse.ArgumentParser()
    parser.add_argument('foo', nargs='*',action=Extender)
    parser.add_argument('--foo', nargs='*', dest='foo', action=Extender)
    parser.set_defaults(foo = [['spam']])
    return parser.parse_args(args.split())

tests = {'a b c --foo 1 --foo 2 --foo 3':['a','b','c','1','2','3'],
         '':[['spam']],
         'a b c --foo 1 2 3':['a','b','c','1','2','3'],
         '--foo 1':['1'],
         'a':['a']}

for s,r in tests.items():
    print ( "parsing: {0}".format(s) )
    args = new_parser(s)
    if(args.foo != r):
        print ("ERROR")
        print (args.foo)
        print (r)
        sys.exit(1)
    print ( args )
    print ('*'*80)

Also note that I've used parser.set_defaults(...) to set the default for the foo attribute.

mgilson
  • 300,191
  • 65
  • 633
  • 696
  • I'm struggling with the details of this. The first `getattr` would raise an exception if no default was specified in `add_argument()`. Also why do you check `hasattr(dest,'extend')` when that attribute is never set? – cfi Sep 17 '12 at 14:30
  • @cfi -- I believe that `argparse` supplies the default value of `None` for the namespace if it isn't already there, although you can always do `dest = getattr(namespace,self.dest,None)` if you really want to. Next, I check `extend` to make sure that the object is a `list` (or is list-like). If it's None (hasn't been set yet via a default), then we need to create a list (which then gets used in the `try`/`except` block. – mgilson Sep 17 '12 at 14:40
  • Ah. Hm. If users wrongly specify e.g. a string default it would then silently be overridden with an empty list during the processing of the first argument, instead of raising an exception. – cfi Sep 17 '12 at 14:49
  • @cfi -- I've changed it slightly. I added `None` to `getattr` since `argparse.SUPPRESS` would cause the Exception you described. – mgilson Sep 17 '12 at 14:50
  • @cfi -- Isn't that the point? Isn't the default supposed to be silently overwritten whenever a first argument is supplied? – mgilson Sep 17 '12 at 14:52
  • I phrased it wrongly: If a coder does specify a `default` parameter to `add_argument` but it is not a list, then it will be replaced with the empty list because it has no `extend` method. Wouldn't `if dest==None or dest==self.default:` work? It seems ok in my trials with your code. – cfi Sep 17 '12 at 15:02
  • @cfi -- not quite. in this case, if the user did `parser.set_defaults(foo = 'spam')`, and then gave no 'foo' commandline arguments, then they would get `Namespace(foo=['s','p','a','m'])` (which you're correct -- that still isn't desireable, but can easily be checked inside `__call__`). For example, instead of `try/except`, you could do `if not isinstance(values,basestring): dest.extend(values); else: dest.append(values)` – mgilson Sep 17 '12 at 15:08
  • You're right. This is an excellent improvement over what I had. Let's see if there are other ideas, too. For now I'm going with your code, and will remove my answer. – cfi Sep 17 '12 at 15:15
  • Can you change your edit with the for loop to cope with Py3 syntax, please? I know it's minor, but that's the context of the question. Thanks! – cfi Sep 18 '12 at 14:30
  • @cfi -- `print` converted to a function. – mgilson Sep 18 '12 at 14:32
  • Hm, it's actually not fully working: On `args = parser.parse_args('--foo 1'.split())` it parses `Namespace(foo=['1', ['spam']])` – cfi Sep 18 '12 at 14:33
  • Regarding your comment about the setting of the default: For this example/topic there is no difference between using `parser.set_defaults()` and `parser.add_argument(..., default=...)`, is it? – cfi Sep 18 '12 at 14:35
  • @cfi --I've updated (notice the `parser.set_defaults` was added inside the action). I also made a test-suite, and I create a new parser each time to run the tests (my action now alters the state of the parser). I'm not 100% sure what the difference is between `set_defaults` and providing a default in `add_argument`. I'm willing to bet that there is a difference in some situations though. As a matter of style though, I definitely prefer `set_defaults` as it makes it abundantly clear that `args.foo` will be `[['spam']]` as opposed to the other way (is it None, `[['spam']]`?) Who knows? – mgilson Sep 18 '12 at 15:04
  • 1
    This is nasty behavior: I did `traceback.print_stacktrace()` and saw that _if_ there's any default for a positional (even with using `parser.set_defaults()`, and the user invokes the tool only with the optional flavour, then `argparse` schedules a final call through `consume_positionals` to our `__call__` method with the default value in `values`. Cannot distinguish from a call by a user arg. The removal of the parser default is a great idea! Much better than polluting the arg result namespace. Your solution is still sensitive to having a default in the positional `add_argument()`. That's ok. – cfi Sep 18 '12 at 15:31
  • I apologize for the scope of this - I totally underestimated the fickleness of the situation! – cfi Sep 18 '12 at 15:32
  • If you've ever looked into the source code for `argparse`, it really seems to be more complex than it needs to be ... (But what do I know?) – mgilson Sep 18 '12 at 15:34
  • The problem likely is that `argparse` does not see that two options point to the same `dest`. Since it can only decide that an option wasn't provided by the commandline _after_ it has parsed everything, we get a final call to `Action.__call__` with the default. That's why the setting of the default must be done with `parser.set_defaults()` for this case. Maybe you could emphasize this in your answer? Great work and patience! – cfi Sep 18 '12 at 15:54
  • I didn't look. For better or for worse. – cfi Sep 18 '12 at 15:54