在Python中,允许命令行覆盖配置选项的最佳方法是什么?

101

我有一个Python应用程序,需要使用约30个配置参数。以前,我在应用程序本身中使用OptionParser类定义默认值,在调用应用程序时可以更改单个参数的值。

现在,我想使用“真正的”配置文件,例如从ConfigParser类中获取。同时,用户仍然应该能够在命令行更改单个参数。

我想知道是否有任何方法将这两个步骤结合起来,例如使用optparse(或更新的argparse)处理命令行选项,但是从ConfigParse语法的配置文件中读取默认值。

有没有什么简单的方法来完成这个任务?我不想手动调用ConfigParse,然后手动设置所有选项的默认值为相应的值……


8
更新:ConfigArgParse 包是一个可以替代argparse的包,允许通过配置文件和/或环境变量设置选项。请参见@user553965下面的答案。 - nealmcb
11个回答

110
我刚刚发现你可以使用argparse.ArgumentParser.parse_known_args()实现这一点。首先使用parse_known_args()从命令行解析配置文件,然后使用ConfigParser读取并设置默认值,最后使用parse_args()解析其余选项。这将允许您具有默认值,使用配置文件覆盖它,然后使用命令行选项覆盖它。例如:

没有用户输入的默认值:

$ ./argparse-partial.py
Option is "default"

默认配置文件:

$ cat argparse-partial.config 
[Defaults]
option=Hello world!
$ ./argparse-partial.py -c argparse-partial.config 
Option is "Hello world!"

默认值来自配置文件,可以被命令行覆盖:

$ ./argparse-partial.py -c argparse-partial.config --option override
Option is "override"

以下是argparse-partial.py的翻译。为了正确处理帮助选项-h,这个程序有一些复杂之处。
import argparse
import ConfigParser
import sys

def main(argv=None):
    # Do argv default this way, as doing it in the functional
    # declaration sets it at compile time.
    if argv is None:
        argv = sys.argv

    # Parse any conf_file specification
    # We make this parser with add_help=False so that
    # it doesn't parse -h and print help.
    conf_parser = argparse.ArgumentParser(
        description=__doc__, # printed with -h/--help
        # Don't mess with format of description
        formatter_class=argparse.RawDescriptionHelpFormatter,
        # Turn off help, so we print all options in response to -h
        add_help=False
        )
    conf_parser.add_argument("-c", "--conf_file",
                        help="Specify config file", metavar="FILE")
    args, remaining_argv = conf_parser.parse_known_args()

    defaults = { "option":"default" }

    if args.conf_file:
        config = ConfigParser.SafeConfigParser()
        config.read([args.conf_file])
        defaults.update(dict(config.items("Defaults")))

    # Parse rest of arguments
    # Don't suppress add_help here so it will handle -h
    parser = argparse.ArgumentParser(
        # Inherit options from config_parser
        parents=[conf_parser]
        )
    parser.set_defaults(**defaults)
    parser.add_argument("--option")
    args = parser.parse_args(remaining_argv)
    print "Option is \"{}\"".format(args.option)
    return(0)

if __name__ == "__main__":
    sys.exit(main())

27
有人询问是否可以重复使用先前的代码,我在此将其放入公共领域。 - Von
25
“公有领域”让我笑了。我只是个愚蠢的孩子。 - SylvainD
2
哎呀!这是非常酷的代码,但是SafeConfigParser属性插值被命令行覆盖后无法工作。例如,如果您在argparse-partial.config中添加以下行another=%(option)s you are cruel,则another将始终解析为Hello world you are cruel,即使在命令行中将option覆盖为其他内容也是如此。烦死了! - ihadanny
2
请注意,set_defaults仅在参数名称不包含破折号或下划线时才起作用。因此,可以选择--myVar而不是--my-var(这很不幸,相当丑陋)。要为配置文件启用区分大小写,请在解析文件之前使用config.optionxform = str,以便myVar不会被转换为myvar。 - Kevin Bader
1
请注意,如果您想将 --version 选项添加到您的应用程序中,最好将其添加到 conf_parser 而不是 parser,并在打印帮助后退出应用程序。如果您将 --version 添加到 parser 中,并使用 --version 标志启动应用程序,则您的应用程序会不必要地尝试打开和解析 args.conf_file 配置文件(该文件可能格式不正确甚至不存在,这会导致异常)。 - patryk.beza
显示剩余5条评论

27

看一下ConfigArgParse - 这是一个新的PyPI包(开源),它可以替代argparse,并增加了对配置文件和环境变量的支持。


3
刚刚尝试了一下,效果很棒 :) 感谢你指出这个。 - red_tiger
3
谢谢 - 看起来不错!该网页还比较了ConfigArgParse和其他选项,包括argparse、ConfArgParse、appsettings、argparse_cnfig、yconf、hieropt和configuration。 - nealmcb

9
我正在使用ConfigParser和argparse来处理这些任务的子命令。下面代码中重要的一行是:
subp.set_defaults(**dict(conffile.items(subn)))

这将把子命令(来自argparse)的默认设置为配置文件部分中的值。

下面是一个更完整的示例:

####### content of example.cfg:
# [sub1]
# verbosity=10
# gggg=3.5
# [sub2]
# host=localhost

import ConfigParser
import argparse

parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()

parser_sub1 = subparsers.add_parser('sub1')
parser_sub1.add_argument('-V','--verbosity', type=int, dest='verbosity')
parser_sub1.add_argument('-G', type=float, dest='gggg')

parser_sub2 = subparsers.add_parser('sub2')
parser_sub2.add_argument('-H','--host', dest='host')

conffile = ConfigParser.SafeConfigParser()
conffile.read('example.cfg')

for subp, subn in ((parser_sub1, "sub1"), (parser_sub2, "sub2")):
    subp.set_defaults(**dict(conffile.items(subn)))

print parser.parse_args(['sub1',])
# Namespace(gggg=3.5, verbosity=10)
print parser.parse_args(['sub1', '-V', '20'])
# Namespace(gggg=3.5, verbosity=20)
print parser.parse_args(['sub1', '-V', '20', '-G','42'])
# Namespace(gggg=42.0, verbosity=20)
print parser.parse_args(['sub2', '-H', 'www.example.com'])
# Namespace(host='www.example.com')
print parser.parse_args(['sub2',])
# Namespace(host='localhost')

我的问题是argparse设置了配置文件路径,而配置文件又设置了argparse的默认值...这是一个愚蠢的鸡生蛋问题。 - olivervbk

5

我不能说这是最好的方法,但是我有一个OptionParser类,它可以像optparse.OptionParser一样工作,并从配置文件部分获取默认值。你可以使用它...

class OptionParser(optparse.OptionParser):
    def __init__(self, **kwargs):
        import sys
        import os
        config_file = kwargs.pop('config_file',
                                 os.path.splitext(os.path.basename(sys.argv[0]))[0] + '.config')
        self.config_section = kwargs.pop('config_section', 'OPTIONS')

        self.configParser = ConfigParser()
        self.configParser.read(config_file)

        optparse.OptionParser.__init__(self, **kwargs)

    def add_option(self, *args, **kwargs):
        option = optparse.OptionParser.add_option(self, *args, **kwargs)
        name = option.get_opt_string()
        if name.startswith('--'):
            name = name[2:]
            if self.configParser.has_option(self.config_section, name):
                self.set_default(name, self.configParser.get(self.config_section, name))

随意浏览源代码。测试位于兄弟目录中。


4
更新:这个答案仍然存在问题;例如,它无法处理“required”参数,并且需要一种笨拙的配置语法。相反,ConfigArgParse似乎正是这个问题所要求的,是一个透明的、可插拔的替代品。 当前的一个问题是,如果配置文件中的参数无效,它不会出错。这里有一个版本有一个不同的缺点:你需要在键中包含“--”或“-”前缀。
以下是Python代码(Gist link,使用MIT许可证):
# Filename: main.py
import argparse

import configparser

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument('--config_file', help='config file')
    args, left_argv = parser.parse_known_args()
    if args.config_file:
        with open(args.config_file, 'r') as f:
            config = configparser.SafeConfigParser()
            config.read([args.config_file])

    parser.add_argument('--arg1', help='argument 1')
    parser.add_argument('--arg2', type=int, help='argument 2')

    for k, v in config.items("Defaults"):
        parser.parse_args([str(k), str(v)], args)

    parser.parse_args(left_argv, args)
print(args)

这是一个配置文件的示例:

# Filename: config_correct.conf
[Defaults]
--arg1=Hello!
--arg2=3

现在,运行

> python main.py --config_file config_correct.conf --arg1 override
Namespace(arg1='override', arg2=3, config_file='test_argparse.conf')

然而,如果我们的配置文件存在错误:
# config_invalid.conf
--arg1=Hello!
--arg2='not an integer!'

运行脚本将会产生一个错误,这是期望的结果:
> python main.py --config_file config_invalid.conf --arg1 override
usage: test_argparse_conf.py [-h] [--config_file CONFIG_FILE] [--arg1 ARG1]
                             [--arg2 ARG2]
main.py: error: argument --arg2: invalid int value: 'not an integer!'

主要缺点是这种方法有些hackily地使用了parser.parse_args来获取ArgumentParser的错误检查,但我不知道还有其他替代方法。

3

fromfile_prefix_chars

也许不是最干净的API,但值得了解。

main.py

#!/usr/bin/env python3
import argparse
parser = argparse.ArgumentParser(fromfile_prefix_chars='@')
parser.add_argument('-a', default=13)
parser.add_argument('-b', default=42)
print(parser.parse_args())

然后:

$ printf -- '-a\n1\n-b\n2\n' > opts.txt
$ ./main.py
Namespace(a=13, b=42)
$ ./main.py @opts.txt
Namespace(a='1', b='2')
$ ./main.py @opts.txt -a 3 -b 4
Namespace(a='3', b='4')
$ ./main.py -a 3 -b 4 @opts.txt
Namespace(a='1', b='2')

文档:https://docs.python.org/3.6/library/argparse.html#fromfile-prefix-chars

这个@opts.txt的约定在GCC工具链中有一些先例,例如:命令行中的“@”是什么意思?

如何使用适当的CLI选项来指示选项文件而不是那个丑陋的@东西:如何让argparse通过选项从文件中读取参数而不是前缀

在Python 3.6.5和Ubuntu 18.04上进行了测试。


3

你可以使用ChainMap

A ChainMap groups multiple dicts or other mappings together to create a single, updateable view. If no maps are specified, a single empty dictionary is provided so that a new chain always has at least one mapping.

你可以将命令行、环境变量、配置文件中的值进行组合,如果该值不存在,则定义默认值。
import os
from collections import ChainMap, defaultdict

options = ChainMap(command_line_options, os.environ, config_file_options,
               defaultdict(lambda: 'default-value'))
value = options['optname']
value2 = options['other-option']


print(value, value2)
'optvalue', 'default-value'

ChainMap相对于按所需优先顺序更新的字典链而言有什么优势?使用defaultdict可能存在优势,因为可以设置新颖或不支持的选项,但这与ChainMap无关。我想我可能漏掉了什么。 - Dan

1
尝试这种方式。
# encoding: utf-8
import imp
import argparse


class LoadConfigAction(argparse._StoreAction):
    NIL = object()

    def __init__(self, option_strings, dest, **kwargs):
        super(self.__class__, self).__init__(option_strings, dest)
        self.help = "Load configuration from file"

    def __call__(self, parser, namespace, values, option_string=None):
        super(LoadConfigAction, self).__call__(parser, namespace, values, option_string)

        config = imp.load_source('config', values)

        for key in (set(map(lambda x: x.dest, parser._actions)) & set(dir(config))):
            setattr(namespace, key, getattr(config, key))

使用它:
parser.add_argument("-C", "--config", action=LoadConfigAction)
parser.add_argument("-H", "--host", dest="host")

并创建示例配置:

# Example config: /etc/myservice.conf
import os
host = os.getenv("HOST_NAME", "localhost")

imp自3.4版本起已被弃用 https://docs.python.org/3/library/imp.html - palik

1
值得一提的是jsonargparse,它采用MIT许可证,并且可以在PyPI上获取。它是argparse的扩展,支持从配置文件和环境变量中加载参数。与ConfigArgParse类似,但它是较新的版本,具有更多实用的功能并且得到了良好的维护。
一个示例main.py如下:
from jsonargparse import ArgumentParser, ActionConfigFile

parser = ArgumentParser()
parser.add_argument("--config", action=ActionConfigFile)
parser.add_argument("--opt1", default="default 1")
parser.add_argument("--opt2", default="default 2")
args = parser.parse_args()
print(args.opt1, args.opt2)

拥有一个名为config.yaml的配置文件,其内容如下:
opt1: one
opt2: two

然后是一个从命令行运行的示例:
$ python main.py --config config.yaml --opt1 ONE
ONE two

0

parse_args() 可以接收一个已存在的 Namespace 并将其与当前解析的 args/options 合并;"当前解析"中的 options args/options 优先级更高,会覆盖现有 Namespace 中的任何内容:

foo_parser = argparse.ArgumentParser()
foo_parser.add_argument('--foo')

ConfigNamespace = argparse.Namespace()
setattr(ConfigNamespace, 'foo', 'foo')

args = foo_parser.parse_args([], namespace=ConfigNamespace)
print(args)
# Namespace(foo='foo')

# value `bar` will override value `foo` from ConfigNamespace
args = foo_parser.parse_args(['--foo', 'bar'], namespace=ConfigNamespace)
print(args)
# Namespace(foo='bar')

我已经模拟了一个真实的配置文件选项。我进行了两次解析,一次作为“预解析”,以查看用户是否传递了配置文件,然后再进行“最终解析”,将可选的配置文件命名空间集成。

我有一个非常简单的JSON配置文件,config.ini:

[DEFAULT]
delimiter = |

当我运行这个:

import argparse
import configparser

parser = argparse.ArgumentParser()
parser.add_argument('-c', '--config-file', type=str)
parser.add_argument('-d', '--delimiter', type=str, default=',')

# Parse cmd-line args to see if config-file is specified
pre_args = parser.parse_args()

# Even if config is not specified, need empty Namespace to pass to final `parse_args()`
ConfigNamespace = argparse.Namespace()

if pre_args.config_file:
    config = configparser.ConfigParser()
    config.read(pre_args.config_file)

    for name, val in config['DEFAULT'].items():
        setattr(ConfigNamespace, name, val)


# Parse cmd-line args again, merging with ConfigNamespace, 
# cmd-line args take precedence
args = parser.parse_args(namespace=ConfigNamespace)

print(args)

使用不同的命令行设置,我得到了以下结果:
./main.py
Namespace(config_file=None, delimiter=',')

./main.py -c config.ini
Namespace(config_file='config.ini', delimiter='|')

./main.py -c config.ini -d \;
Namespace(config_file='config.ini', delimiter=';')

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