Custom parsing function for any number of arguments in Python argparse

醉酒当歌 提交于 2019-12-12 00:18:22

问题


I have a script that gets named parameters via command-line. One of arguments may be supplied multiple times. For example I want to run a script:

./script.py --add-net=user1:10.0.0.0/24 --add-net=user2:10.0.1.0/24 --add-net=user3:10.0.2.0/24

Now I want to have an argparse action that will parse every parameter and store results in a dict like:

{ 'user1': '10.0.0.0/24',
  'user2': '10.0.1.0/24',
  'user3': '10.0.2.0/24' }

Also there should be a default value that will be supplied if there's no value provided. Like

./script.py

Should have dict like:

{'user': '192.168.0.0/24'}

I believe that I have to build a custom action for argparse. What I came up with is:

class ParseIPNets(argparse.Action):
    """docstring for ParseIPNets"""
    def __init__(self, option_strings, dest, nargs=None, **kwargs):
        super(ParseIPNets, self).__init__(option_strings, dest, **kwargs)

    def __call__(self, parser, namespace, values, option_string=None):
        for value in values:
            location, subnet = values.split(':')
            namespace.user_nets[location] = subnet

parser = argparse.ArgumentParser(description='foo')
parser.add_argument('--add-net',
                    nargs='*',
                    action=ParseIPNets,
                    dest='user_nets',
                    help='Nets subnets for users. Can be used multiple times',
                    default={"user1": "198.51.100.0/24"})

args = parser.parse_args()

That works fine when I need to use default value:

test.py
Namespace(user_nets={'user1': '198.51.100.0/24'})

However when I add parameters - they got appended to default value. My expectation is that they should be added to an empty dict:

test.py --add-net=a:10.0.0.0/24 --add-net=b:10.1.0.0/24
Namespace(user_nets={'a': '10.0.0.0/24', 'b': '10.1.0.0/24', 'user1': '198.51.100.0/24'})

What's the right way to reach what I need?


回答1:


As it is clear that argparse internally puts the default as initial value of the resulting object, you should not directly set the default in the add_argument call but do some extra processing:

parser.add_argument('--add-net',
                    action=ParseIPNets,
                    dest='user_nets',
                    help='Nets subnets for users. Can be used multiple times',
                    default = {})

args = parser.parse_args()
if len(args.user_nets) == 0:
    args.user_nets['user1'] = "198.51.100.0/24"

Alternatively, if you want a better user experience, you could make use of the way Python processes mutable default arguments:

class ParseIPNets(argparse.Action):
    """docstring for ParseIPNets"""
    def __init__(self, option_strings, dest, nargs=None, **kwargs):
        super(ParseIPNets, self).__init__(option_strings, dest, **kwargs)
    def __call__(self, parser, namespace, values, option_string=None, first=[True]):
        if first[0]:
            namespace.user_nets.clear()
            first[0] = False
        location, subnet = values.split(':')
        namespace.user_nets[location] = subnet

parser.add_argument('--add-net',
                    action=ParseIPNets,
                    dest='user_nets',
                    help='Nets subnets for users. Can be used multiple times',
                    default={"user1": "198.51.100.0/24"})

args = parser.parse_args()

That way, the optional default will be cleared if the option is present.

But BEWARE: this will work only at first call on the script. It is acceptable here because parser.parse_args() should only be called once in a script.

Ancilliary remark: I removed nargs='*' because I find it more dangerous than useful here if you call it that way, and also removed the erroneous loop over values always using values:

test.py --add-net=a:10.0.0.0/24 --add-net=b:10.1.0.0/24

nargs='*' would make sense for following syntax:

test.py --add-net a:10.0.0.0/24 b:10.1.0.0/24

and the code would be:

    def __call__(self, parser, namespace, values, option_string=None, first=[True]):
        if first[0]:
            namespace.user_nets.clear()
            first[0] = False
        for value in values:
            location, subnet = value.split(':')
            namespace.user_nets[location] = subnet



回答2:


It's usually not a good idea to use a mutable default argument (a dict in your case), see here for an explanation:

Create a new object each time the function is called, by using a default arg to signal that no argument was provided (None is often a good choice).




回答3:


My first approach to this problem would be to use action='append', and turn the resulting list into a dictionary after parsing. The amount of code would be similar.

'append' does have this same issue with defaults. If default=['defaultstring'], then the list will start with that value as well. I'd get around this by using the default default ([] see below), and add the default in post processing (if the list is still empty or None).

A note on defaults. At the start of parse_args, all action defaults are added to the namespace (unless a namespace was given as a parameter to parse_args). Commandline is then parsed, with each action doing its own thing to the namespace. At the end, any remaining string defaults are converted with the type function.

In your case namespace.user_nets[location] = subnet finds the user_nets attribute, and adds the new entry. That attribute was initialized as a dictionary by the default, so the default appears in the final dictionary. In fact your code would not work if you left the default to be None or some string.

The call for the _AppendAction class may be instructive:

def __call__(self, parser, namespace, values, option_string=None):
    items = _copy.copy(_ensure_value(namespace, self.dest, []))
    items.append(values)
    setattr(namespace, self.dest, items)

_ensure_value is a function defined in argparse. _copy is the standard copy module that it imported.

_ensure_value acts like a dictionary get(key, value, default), except with a namespace object. In this case it returns an empty list if there isn't already a value for self.dest (or the value is None). So it ensure that the append starts with a list.

_copy.copy ensures that it appends values to a copy. That way, parse_args will not modify the default. It avoids the problem noted by @miles82.

So 'append action' defines the initial empty list in the call itself. And uses copy to avoid modifying any other default.

Did you want values as opposed to value in?

location, subnet = values.split(':')

I'd be inclined to put this conversion in a type function, e.g.

def dict_type(astring):
   key, value = astring.split(':')
   return {key:value}

This would also be a good place to do error checking.

In the action, or post parsing these could added to the existing dictionay with update.



来源:https://stackoverflow.com/questions/33301000/custom-parsing-function-for-any-number-of-arguments-in-python-argparse

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!