如何使用Python argparse解析多层嵌套的子命令?

112
我正在实现一个类似于这样的命令行程序接口:
cmd [GLOBAL_OPTIONS] {command [COMMAND_OPTS]} [{command [COMMAND_OPTS]} ...]

我已经阅读了argparse文档。我可以使用argparse中的add_argumentGLOBAL_OPTIONS实现为可选参数。同时,使用Sub-commands可以实现{command [COMMAND_OPTS]}

从文档中看来,好像只能有一个子命令。但是正如你所看到的,我必须实现一个或多个子命令。使用argparse解析这样的命令行参数的最佳方法是什么?


2
我认为这不是子命令的预期用途。从文档中可以看出,这实质上是为了控制单独的子程序。你有没有看过参数组 - Chris
distutils的./setup.py也有这种CLI界面风格,研究一下他们的源代码会很有趣。 - Ciro Santilli OurBigBook.com
13个回答

39

我提出了同样的问题,似乎我得到了一个更好的答案。

解决方案是我们不应该简单地将子解析器嵌套在另一个子解析器中,而是可以添加一个跟随另一个子解析器的解析器。

代码告诉你如何实现:

parent_parser = argparse.ArgumentParser(add_help=False)
parent_parser.add_argument('--user', '-u',
                    default=getpass.getuser(),
                    help='username')
parent_parser.add_argument('--debug', default=False, required=False,
                        action='store_true', dest="debug", help='debug flag')
main_parser = argparse.ArgumentParser()
service_subparsers = main_parser.add_subparsers(title="service",
                    dest="service_command")
service_parser = service_subparsers.add_parser("first", help="first",
                    parents=[parent_parser])
action_subparser = service_parser.add_subparsers(title="action",
                    dest="action_command")
action_parser = action_subparser.add_parser("second", help="second",
                    parents=[parent_parser])

args = main_parser.parse_args()

是的,argparse 允许嵌套子解析器。但我只看到他们在一个地方使用 - 在 Python 问题的测试用例中,http://bugs.python.org/issue14365。 - hpaulj
14
这假定命令具有嵌套结构,但问题要求“并行”命令。 - augurar
我正在尝试创建多个子解析器,例如在此情况下的service,因为我标记了命令。它报错说我不能有重复的命令?我想要像这样的东西command --user us pass --debug false subcommandone subcommandoptioncommand --user us pass --debug false subcommandtwo subcommandoptioncommandtwo --user us pass subcommandthree subcommandoption。我打算相应地添加完整的帮助文档。代码将作为python test.py command --user us pass --debug false subcommandtwo subcommandoption运行。 - Gary

28

@mgilson有一个很好的答案来回答这个问题。但是自己分割sys.argv的问题在于我失去了Argparse为用户生成的所有漂亮的帮助信息。所以我最终做了这个:

import argparse

## This function takes the 'extra' attribute from global namespace and re-parses it to create separate namespaces for all other chained commands.
def parse_extra (parser, namespace):
  namespaces = []
  extra = namespace.extra
  while extra:
    n = parser.parse_args(extra)
    extra = n.extra
    namespaces.append(n)

  return namespaces

argparser=argparse.ArgumentParser()
subparsers = argparser.add_subparsers(help='sub-command help', dest='subparser_name')

parser_a = subparsers.add_parser('command_a', help = "command_a help")
## Setup options for parser_a

## Add nargs="*" for zero or more other commands
argparser.add_argument('extra', nargs = "*", help = 'Other commands')

## Do similar stuff for other sub-parsers

现在,在第一次解析后,所有链接命令都存储在extra中。当extra不为空时,我重新解析它以获取所有链接命令,并为它们创建单独的命名空间。然后我获得了argparse生成的更好的使用字符串。


2
@Flavius,在我通过调用namespace = argparser.parse_args()从解析器中获取了namespace之后,我使用parsernamespace调用parse_extraextra_namespaces = parse_extra(argparser, namespace) - Vikas
我觉得我理解了这个逻辑,但你的代码中的 parser 是什么?我只看到它被用于添加 extra 参数。然后你在上面的评论中再次提到了它。它应该是 argparser 吗? - jmlopez
@jmlopez 是的,应该是 argparser。我会进行编辑。 - Vikas
1
请注意,此解决方案不适用于子命令特定的可选参数。请参阅我的另一种解决方案(https://dev59.com/nGkv5IYBdhLWcg3wqSb1#49977713)以获取替代解决方案。 - MacFreek
1
以下是它失败的示例。请添加以下3行: parser_b = subparsers.add_parser('command_b', help='command_b help'); parser_b.add_argument('--baz', choices='XYZ', help='baz help'); options = argparser.parse_args(['--foo', 'command_a', 'command_b', '--baz', 'Z']); 这个错误是 PROG: error: unrecognized arguments: --baz Z。原因是在解析 command_a 期间,已经解析了 command_b 的可选参数(并且在 command_a 的子解析器中未知)。 - MacFreek
我能够使用 import argparse 进行工作。解析器 = argparse.ArgumentParser() 解析器.add_argument('--bar', type=int, help='bar 帮助') 解析器.add_argument('commands', nargs='*', metavar='COMMAND', choices=['command_a', 'command_b']) - Gary

18

parse_known_args 返回一个 Namespace 对象和一列未知的字符串。这类似于被确认答案中的 extra

import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--foo')
sub = parser.add_subparsers()
for i in range(1,4):
    sp = sub.add_parser('cmd%i'%i)
    sp.add_argument('--foo%i'%i) # optionals have to be distinct

rest = '--foo 0 cmd2 --foo2 2 cmd3 --foo3 3 cmd1 --foo1 1'.split() # or sys.argv
args = argparse.Namespace()
while rest:
    args,rest =  parser.parse_known_args(rest,namespace=args)
    print args, rest

产生:

Namespace(foo='0', foo2='2') ['cmd3', '--foo3', '3', 'cmd1', '--foo1', '1']
Namespace(foo='0', foo2='2', foo3='3') ['cmd1', '--foo1', '1']
Namespace(foo='0', foo1='1', foo2='2', foo3='3') []

另一种循环将为每个子解析器提供其自己的命名空间。这允许位置名称重叠。

argslist = []
while rest:
    args,rest =  parser.parse_known_args(rest)
    argslist.append(args)

工作得很好。然而有一个缺陷:如果某个地方有拼写错误的选项(例如 rest = '--foo 0 cmd2 --foo2 2 --bar cmd3 --foo3 3 cmd1 --foo1 1'.split()),那么argparse将以“错误:参数太少”结束,而不是指出无效的选项。这是因为坏的选项会一直留在rest中,直到我们离开命令参数。 - Adrian W
注释 # or sys.argv 应该改为 # or sys.argv[1:] - Adrian W

17

@Vikas 提供的解决方案对于特定子命令的可选参数无效,但是该方法是有效的。这里是改进版:

import argparse

# create the top-level parser
parser = argparse.ArgumentParser(prog='PROG')
parser.add_argument('--foo', action='store_true', help='foo help')
subparsers = parser.add_subparsers(help='sub-command help', dest='subparser_name')

# create the parser for the "command_a" command
parser_a = subparsers.add_parser('command_a', help='command_a help')
parser_a.add_argument('bar', type=int, help='bar help')

# create the parser for the "command_b" command
parser_b = subparsers.add_parser('command_b', help='command_b help')
parser_b.add_argument('--baz', choices='XYZ', help='baz help')

# parse some argument lists
argv = ['--foo', 'command_a', '12', 'command_b', '--baz', 'Z']
while argv:
    print(argv)
    options, argv = parser.parse_known_args(argv)
    print(options)
    if not options.subparser_name:
        break

这里使用parse_known_args代替了parse_args。当遇到当前子解析器不认识的参数时,parse_args会立即中止,而parse_known_args则会将它们作为元组的第二个返回值返回。在这种方法中,剩余的参数再次被提供给解析器。因此,对于每个命令,都会创建一个新的Namespace。

请注意,在这个基本示例中,所有全局选项都只添加到第一个选项Namespace中,而不是后续的Namespaces。

这种方法适用于大多数情况,但有三个重要限制:

  • 不可能为不同的子命令使用相同的可选参数,比如myprog.py command_a --foo=bar command_b --foo=bar
  • 无法在子命令中使用任何可变长度的位置参数(nargs='?'nargs='+'nargs='*')。
  • 任何已知的参数都会被解析,而不会在新命令处“中断”。例如,在上面的代码中,对于PROG --foo command_b command_a --baz Z 12--baz Z将被command_b消耗,而不是command_a

这些限制是argparse的一个直接限制。下面是一个简单的示例,展示了argparse的限制,即使只使用一个子命令也是如此:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument('spam', nargs='?')
subparsers = parser.add_subparsers(help='sub-command help', dest='subparser_name')

# create the parser for the "command_a" command
parser_a = subparsers.add_parser('command_a', help='command_a help')
parser_a.add_argument('bar', type=int, help='bar help')

# create the parser for the "command_b" command
parser_b = subparsers.add_parser('command_b', help='command_b help')

options = parser.parse_args('command_a 42'.split())
print(options)

这将引发错误:error: argument subparser_name: invalid choice: '42' (choose from 'command_a', 'command_b')

问题的原因是内部方法argparse.ArgParser._parse_known_args()过于贪心,假设可选参数spam的值为command_a。特别地,在“拆分”可选和位置参数时,_parse_known_args()不查看参数的名称(如command_acommand_b),而只是查看它们在参数列表中出现的位置。它还假设任何子命令都将使用所有剩余的参数。 argparse的这种限制也阻止了多命令子解析器的正确实现。不幸的是,这意味着一个正确的实现需要完全重写argparse.ArgParser._parse_known_args()方法,该方法有200多行代码。

鉴于这些限制,可能选择简单地回到一个单一的多选项参数而不是子命令:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument('--bar', type=int, help='bar help')
parser.add_argument('commands', nargs='*', metavar='COMMAND',
                 choices=['command_a', 'command_b'])

options = parser.parse_args('--bar 2 command_a command_b'.split())
print(options)
#options = parser.parse_args(['--help'])

甚至可以在使用信息中列出不同的命令,详情请参见我的回答https://stackoverflow.com/a/49999185/428542


5
你可以随时自己拆分命令行(在命令名称上拆分sys.argv),然后仅将与特定命令对应的部分传递给parse_args - 如果需要,甚至可以使用相同的Namespace关键字。使用itertools.groupby轻松分组命令行:
import sys
import itertools
import argparse    

mycommands=['cmd1','cmd2','cmd3']

def groupargs(arg,currentarg=[None]):
    if(arg in mycommands):currentarg[0]=arg
    return currentarg[0]

commandlines=[list(args) for cmd,args in intertools.groupby(sys.argv,groupargs)]

#setup parser here...
parser=argparse.ArgumentParser()
#...

namespace=argparse.Namespace()
for cmdline in commandlines:
    parser.parse_args(cmdline,namespace=namespace)

#Now do something with namespace...

未经测试的


1
谢谢mgilson。这是一个很好的解决方案,但我最终做了一些不同的事情。我添加了另一个答案 - Vikas
1
很好地使用了 itertools.groupby()!在我知道 groupby() 之前,我是这样做的:链接 - kzyapkov

5

在@mgilson的答案基础上,我编写了一个小的解析方法,将argv拆分为不同部分,并将命令参数的值放入命名空间层次结构中:

import sys
import argparse


def parse_args(parser, commands):
    # Divide argv by commands
    split_argv = [[]]
    for c in sys.argv[1:]:
        if c in commands.choices:
            split_argv.append([c])
        else:
            split_argv[-1].append(c)
    # Initialize namespace
    args = argparse.Namespace()
    for c in commands.choices:
        setattr(args, c, None)
    # Parse each command
    parser.parse_args(split_argv[0], namespace=args)  # Without command
    for argv in split_argv[1:]:  # Commands
        n = argparse.Namespace()
        setattr(args, argv[0], n)
        parser.parse_args(argv, namespace=n)
    return args


parser = argparse.ArgumentParser()
commands = parser.add_subparsers(title='sub-commands')

cmd1_parser = commands.add_parser('cmd1')
cmd1_parser.add_argument('--foo')

cmd2_parser = commands.add_parser('cmd2')
cmd2_parser.add_argument('--foo')

cmd2_parser = commands.add_parser('cmd3')
cmd2_parser.add_argument('--foo')


args = parse_args(parser, commands)
print(args)

它表现良好,提供了良好的argparse帮助:
对于./test.py --help
usage: test.py [-h] {cmd1,cmd2,cmd3} ...

optional arguments:
  -h, --help        show this help message and exit

sub-commands:
  {cmd1,cmd2,cmd3}

对于./test.py cmd1 --help

usage: test.py cmd1 [-h] [--foo FOO]

optional arguments:
  -h, --help  show this help message and exit
  --foo FOO

它创建一个命名空间层次结构,其中包含参数值:

./test.py cmd1 --foo 3 cmd3 --foo 4
Namespace(cmd1=Namespace(foo='3'), cmd2=None, cmd3=Namespace(foo='4'))

在审查你上面的代码时,我遇到了一个问题。在第18行,你引用了split_argv[0],但实际上在split_argv中是空的,因为你将[c]附加到了split_argv(最初设置为 [[]])。如果你将第7行更改为 split_argv = [],则一切都会按预期工作。 - HEADLESS_0NE
2
我对你分享的代码进行了一些修复(再次修复),解决了我遇到的一些问题,最终得到了这个:https://gist.github.com/anonymous/f4be805fc3ff9e132eb1e1aa0b4f7d4b - HEADLESS_0NE
这个答案相当不错,你可以通过将dest添加到add_subparsers方法中来确定使用了哪个'subparser'。https://dev59.com/7Gsy5IYBdhLWcg3w4x_c#9286586 - wizebin

4
你可以尝试使用 arghandler。这是一个扩展了argparse并明确支持子命令的工具。

3
arghandler 提供了一种很好的声明子命令的方式。然而,我不清楚这对解决 OP 的问题有什么帮助:解析“多个”子命令。第一个解析的子命令将会消耗掉所有剩余的参数,所以进一步的命令将永远不会被解析。请给出如何使用 arghandler 解决这个问题的提示。谢谢。 - Adrian W

4

建立了一个完整的 Python 2/3 示例,使用了 子命令parse_known_argsparse_args(在 IDEone 上运行):

from __future__ import print_function

from argparse import ArgumentParser
from random import randint


def main():
    parser = get_parser()

    input_sum_cmd = ['sum_cmd', '--sum']
    input_min_cmd = ['min_cmd', '--min']

    args, rest = parser.parse_known_args(
        # `sum`
        input_sum_cmd +
        ['-a', str(randint(21, 30)),
         '-b', str(randint(51, 80))] +
        # `min`
        input_min_cmd +
        ['-y', str(float(randint(64, 79))),
         '-z', str(float(randint(91, 120)) + .5)]
    )

    print('args:\t ', args,
          '\nrest:\t ', rest, '\n', sep='')

    sum_cmd_result = args.sm((args.a, args.b))
    print(
        'a:\t\t {:02d}\n'.format(args.a),
        'b:\t\t {:02d}\n'.format(args.b),
        'sum_cmd: {:02d}\n'.format(sum_cmd_result), sep='')

    assert rest[0] == 'min_cmd'
    args = parser.parse_args(rest)
    min_cmd_result = args.mn((args.y, args.z))
    print(
        'y:\t\t {:05.2f}\n'.format(args.y),
        'z:\t\t {:05.2f}\n'.format(args.z),
        'min_cmd: {:05.2f}'.format(min_cmd_result), sep='')

def get_parser():
    # create the top-level parser
    parser = ArgumentParser(prog='PROG')
    subparsers = parser.add_subparsers(help='sub-command help')

    # create the parser for the "sum" command
    parser_a = subparsers.add_parser('sum_cmd', help='sum some integers')
    parser_a.add_argument('-a', type=int,
                          help='an integer for the accumulator')
    parser_a.add_argument('-b', type=int,
                          help='an integer for the accumulator')
    parser_a.add_argument('--sum', dest='sm', action='store_const',
                          const=sum, default=max,
                          help='sum the integers (default: find the max)')

    # create the parser for the "min" command
    parser_b = subparsers.add_parser('min_cmd', help='min some integers')
    parser_b.add_argument('-y', type=float,
                          help='an float for the accumulator')
    parser_b.add_argument('-z', type=float,
                          help='an float for the accumulator')
    parser_b.add_argument('--min', dest='mn', action='store_const',
                          const=min, default=0,
                          help='smallest integer (default: 0)')
    return parser


if __name__ == '__main__':
    main()

2

我有更多或少相同的要求:能够设置全局参数并能够链接命令,按照命令行顺序执行它们

最终我得到了以下代码。我使用了一些来自这个和其他线程的代码片段。

# argtest.py
import sys
import argparse

def init_args():

    def parse_args_into_namespaces(parser, commands):
        '''
        Split all command arguments (without prefix, like --) in
        own namespaces. Each command accepts extra options for
        configuration.
        Example: `add 2 mul 5 --repeat 3` could be used to a sequencial
                 addition of 2, then multiply with 5 repeated 3 times.
        '''
        class OrderNamespace(argparse.Namespace):
            '''
            Add `command_order` attribute - a list of command
            in order on the command line. This allows sequencial
            processing of arguments.
            '''
            globals = None
            def __init__(self, **kwargs):
                self.command_order = []
                super(OrderNamespace, self).__init__(**kwargs)

            def __setattr__(self, attr, value):
                attr = attr.replace('-', '_')
                if value and attr not in self.command_order:
                    self.command_order.append(attr)
                super(OrderNamespace, self).__setattr__(attr, value)

        # Divide argv by commands
        split_argv = [[]]
        for c in sys.argv[1:]:
            if c in commands.choices:
                split_argv.append([c])
            else:
                split_argv[-1].append(c)

        # Globals arguments without commands
        args = OrderNamespace()
        cmd, args_raw = 'globals', split_argv.pop(0)
        args_parsed = parser.parse_args(args_raw, namespace=OrderNamespace())
        setattr(args, cmd, args_parsed)

        # Split all commands to separate namespace
        pos = 0
        while len(split_argv):
            pos += 1
            cmd, *args_raw = split_argv.pop(0)
            assert cmd[0].isalpha(), 'Command must start with a letter.'
            args_parsed = commands.choices[cmd].parse_args(args_raw, namespace=OrderNamespace())
            setattr(args, f'{cmd}~{pos}', args_parsed)

        return args


    #
    # Supported commands and options
    #
    parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument('--print', action='store_true')

    commands = parser.add_subparsers(title='Operation chain')

    cmd1_parser = commands.add_parser('add', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
    cmd1_parser.add_argument('add', help='Add this number.', type=float)
    cmd1_parser.add_argument('-r', '--repeat', help='Repeat this operation N times.',
                                               default=1, type=int)

    cmd2_parser = commands.add_parser('mult', formatter_class=argparse.ArgumentDefaultsHelpFormatter)
    cmd2_parser.add_argument('mult', help='Multiply with this number.', type=float)
    cmd2_parser.add_argument('-r', '--repeat', help='Repeat this operation N times.',
                                               default=1, type=int)

    args = parse_args_into_namespaces(parser, commands)
    return args


#
# DEMO
#

args = init_args()

# print('Parsed arguments:')
# for cmd in args.command_order:
#     namespace = getattr(args, cmd)
#     for option_name in namespace.command_order:
#         option_value = getattr(namespace, option_name)
#         print((cmd, option_name, option_value))

print('Execution:')
result = 0
for cmd in args.command_order:
    namespace = getattr(args, cmd)
    cmd_name, cmd_position = cmd.split('~') if cmd.find('~') > -1 else (cmd, 0)
    if cmd_name == 'globals':
        pass
    elif cmd_name == 'add':
        for r in range(namespace.repeat):
            if args.globals.print:
                print(f'+ {namespace.add}')
            result = result + namespace.add
    elif cmd_name == 'mult':
        for r in range(namespace.repeat):
            if args.globals.print:
                print(f'* {namespace.mult}')
            result = result * namespace.mult
    else:
        raise NotImplementedError(f'Namespace `{cmd}` is not implemented.')
print(10*'-')
print(result)

以下是一个例子:
$ python argstest.py --print add 1 -r 2 mult 5 add 3 mult -r 5 5

Execution:
+ 1.0
+ 1.0
* 5.0
+ 3.0
* 5.0
* 5.0
* 5.0
* 5.0
* 5.0
----------
40625.0

1

另一个支持并行解析器的软件包是“declarative_parser”。

import argparse
from declarative_parser import Parser, Argument

supported_formats = ['png', 'jpeg', 'gif']

class InputParser(Parser):
    path = Argument(type=argparse.FileType('rb'), optional=False)
    format = Argument(default='png', choices=supported_formats)

class OutputParser(Parser):
    format = Argument(default='jpeg', choices=supported_formats)

class ImageConverter(Parser):
    description = 'This app converts images'

    verbose = Argument(action='store_true')
    input = InputParser()
    output = OutputParser()

parser = ImageConverter()

commands = '--verbose input image.jpeg --format jpeg output --format gif'.split()

namespace = parser.parse_args(commands)

并且命名空间变成了:

Namespace(
    input=Namespace(format='jpeg', path=<_io.BufferedReader name='image.jpeg'>),
    output=Namespace(format='gif'),
    verbose=True
)

免责声明:本文作者为我。需要Python 3.6及以上版本。安装请使用以下命令:
pip3 install declarative_parser

这里是文档,这里是GitHub上的代码库


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接