python - por - Analizar los archivos de configuración, el entorno y los argumentos de línea de comandos para obtener una única colección de opciones
interprete de python (9)
Aquí hay algo que he pirateado juntos. Siéntase libre de sugerir mejoras / informes de errores en los comentarios:
import argparse
import ConfigParser
import os
def _identity(x):
return x
_SENTINEL = object()
class AddConfigFile(argparse.Action):
def __call__(self,parser,namespace,values,option_string=None):
# I can never remember if `values` is a list all the time or if it
# can be a scalar string; this takes care of both.
if isinstance(values,basestring):
parser.config_files.append(values)
else:
parser.config_files.extend(values)
class ArgumentConfigEnvParser(argparse.ArgumentParser):
def __init__(self,*args,**kwargs):
"""
Added 2 new keyword arguments to the ArgumentParser constructor:
config --> List of filenames to parse for config goodness
default_section --> name of the default section in the config file
"""
self.config_files = kwargs.pop(''config'',[]) #Must be a list
self.default_section = kwargs.pop(''default_section'',''MAIN'')
self._action_defaults = {}
argparse.ArgumentParser.__init__(self,*args,**kwargs)
def add_argument(self,*args,**kwargs):
"""
Works like `ArgumentParser.add_argument`, except that we''ve added an action:
config: add a config file to the parser
This also adds the ability to specify which section of the config file to pull the
data from, via the `section` keyword. This relies on the (undocumented) fact that
`ArgumentParser.add_argument` actually returns the `Action` object that it creates.
We need this to reliably get `dest` (although we could probably write a simple
function to do this for us).
"""
if ''action'' in kwargs and kwargs[''action''] == ''config'':
kwargs[''action''] = AddConfigFile
kwargs[''default''] = argparse.SUPPRESS
# argparse won''t know what to do with the section, so
# we''ll pop it out and add it back in later.
#
# We also have to prevent argparse from doing any type conversion,
# which is done explicitly in parse_known_args.
#
# This way, we can reliably check whether argparse has replaced the default.
#
section = kwargs.pop(''section'', self.default_section)
type = kwargs.pop(''type'', _identity)
default = kwargs.pop(''default'', _SENTINEL)
if default is not argparse.SUPPRESS:
kwargs.update(default=_SENTINEL)
else:
kwargs.update(default=argparse.SUPPRESS)
action = argparse.ArgumentParser.add_argument(self,*args,**kwargs)
kwargs.update(section=section, type=type, default=default)
self._action_defaults[action.dest] = (args,kwargs)
return action
def parse_known_args(self,args=None, namespace=None):
# `parse_args` calls `parse_known_args`, so we should be okay with this...
ns, argv = argparse.ArgumentParser.parse_known_args(self, args=args, namespace=namespace)
config_parser = ConfigParser.SafeConfigParser()
config_files = [os.path.expanduser(os.path.expandvars(x)) for x in self.config_files]
config_parser.read(config_files)
for dest,(args,init_dict) in self._action_defaults.items():
type_converter = init_dict[''type'']
default = init_dict[''default'']
obj = default
if getattr(ns,dest,_SENTINEL) is not _SENTINEL: # found on command line
obj = getattr(ns,dest)
else: # not found on commandline
try: # get from config file
obj = config_parser.get(init_dict[''section''],dest)
except (ConfigParser.NoSectionError, ConfigParser.NoOptionError): # Nope, not in config file
try: # get from environment
obj = os.environ[dest.upper()]
except KeyError:
pass
if obj is _SENTINEL:
setattr(ns,dest,None)
elif obj is argparse.SUPPRESS:
pass
else:
setattr(ns,dest,type_converter(obj))
return ns, argv
if __name__ == ''__main__'':
fake_config = """
[MAIN]
foo:bar
bar:1
"""
with open(''_config.file'',''w'') as fout:
fout.write(fake_config)
parser = ArgumentConfigEnvParser()
parser.add_argument(''--config-file'', action=''config'', help="location of config file")
parser.add_argument(''--foo'', type=str, action=''store'', default="grape", help="don''t know what foo does ...")
parser.add_argument(''--bar'', type=int, default=7, action=''store'', help="This is an integer (I hope)")
parser.add_argument(''--baz'', type=float, action=''store'', help="This is an float(I hope)")
parser.add_argument(''--qux'', type=int, default=''6'', action=''store'', help="this is another int")
ns = parser.parse_args([])
parser_defaults = {''foo'':"grape",''bar'':7,''baz'':None,''qux'':6}
config_defaults = {''foo'':''bar'',''bar'':1}
env_defaults = {"baz":3.14159}
# This should be the defaults we gave the parser
print ns
assert ns.__dict__ == parser_defaults
# This should be the defaults we gave the parser + config defaults
d = parser_defaults.copy()
d.update(config_defaults)
ns = parser.parse_args([''--config-file'',''_config.file''])
print ns
assert ns.__dict__ == d
os.environ[''BAZ''] = "3.14159"
# This should be the parser defaults + config defaults + env_defaults
d = parser_defaults.copy()
d.update(config_defaults)
d.update(env_defaults)
ns = parser.parse_args([''--config-file'',''_config.file''])
print ns
assert ns.__dict__ == d
# This should be the parser defaults + config defaults + env_defaults + commandline
commandline = {''foo'':''3'',''qux'':4}
d = parser_defaults.copy()
d.update(config_defaults)
d.update(env_defaults)
d.update(commandline)
ns = parser.parse_args([''--config-file'',''_config.file'',''--foo=3'',''--qux=4''])
print ns
assert ns.__dict__ == d
os.remove(''_config.file'')
QUE HACER
Esta implementación aún está incompleta. Aquí hay una lista parcial de TODO:
- (fácil) Interacción con valores predeterminados del analizador
- (fácil) Si la conversión de tipos no funciona, compruebe cómo
argparse
maneja los mensajes de error
Conforme al comportamiento documentado
- (fácil) Escribe una función que
add_argument
dest
deargs
enadd_argument
, en lugar de confiar en el objetoAction
- (trivial) Escribe una función
parse_args
que usaparse_known_args
. (por ejemplo, copieparse_args
de la implementación decpython
para garantizar que llame aparse_known_args
).
Menos cosas fáciles ...
No he probado nada de esto todavía. Es poco probable, pero aún posible, que solo podría funcionar ...
- (¿Difícil?) Exclusión mutua
- (¿Es difícil?) Grupos de argumentos (si se implementan, estos grupos deberían obtener una
section
en el archivo de configuración). - (¿Difícil?) Subcomandos (los subcomandos también deberían tener una
section
en el archivo de configuración).
La biblioteca estándar de Python tiene módulos para el análisis de archivos de configuración ( configparser ), lectura de variables de entorno ( os.environ ) y análisis de argumentos de línea de comandos ( argparse ). Quiero escribir un programa que haga todo eso, y también:
Tiene una cascada de valores de opciones :
- valores de opción predeterminados, anulados por
- opciones de archivo de configuración, anuladas por
- variables de entorno, anuladas por
- opciones de línea de comando.
Permite especificar una o más ubicaciones de archivos de configuración en la línea de comandos, por ejemplo,
--config-file foo.conf
, y lee eso (en lugar de, o adicional a, el archivo de configuración habitual). Esto aún debe obedecer a la cascada anterior.Permite definiciones de opciones en un solo lugar para determinar el comportamiento de análisis para los archivos de configuración y la línea de comandos.
Unifica las opciones analizadas en una sola colección de valores de opciones para que el resto del programa pueda acceder sin importar de dónde vienen.
Todo lo que necesito está aparentemente en la biblioteca estándar de Python, pero no funcionan juntos sin problemas.
¿Cómo puedo lograr esto con una desviación mínima de la biblioteca estándar de Python?
Aquí hay un módulo que he pirateado que lee los argumentos de la línea de comandos, la configuración del entorno, los archivos ini y los valores del anillo de claves. También está disponible en una gist .
"""
Configuration Parser
Configurable parser that will parse config files, environment variables,
keyring, and command-line arguments.
Example test.ini file:
[defaults]
gini=10
[app]
xini = 50
Example test.arg file:
--xfarg=30
Example test.py file:
import os
import sys
import config
def main(argv):
''''''Test.''''''
options = [
config.Option("xpos",
help="positional argument",
nargs=''?'',
default="all",
env="APP_XPOS"),
config.Option("--xarg",
help="optional argument",
default=1,
type=int,
env="APP_XARG"),
config.Option("--xenv",
help="environment argument",
default=1,
type=int,
env="APP_XENV"),
config.Option("--xfarg",
help="@file argument",
default=1,
type=int,
env="APP_XFARG"),
config.Option("--xini",
help="ini argument",
default=1,
type=int,
ini_section="app",
env="APP_XINI"),
config.Option("--gini",
help="global ini argument",
default=1,
type=int,
env="APP_GINI"),
config.Option("--karg",
help="secret keyring arg",
default=-1,
type=int),
]
ini_file_paths = [
''/etc/default/app.ini'',
os.path.join(os.path.dirname(os.path.abspath(__file__)),
''test.ini'')
]
# default usage
conf = config.Config(prog=''app'', options=options,
ini_paths=ini_file_paths)
conf.parse()
print conf
# advanced usage
cli_args = conf.parse_cli(argv=argv)
env = conf.parse_env()
secrets = conf.parse_keyring(namespace="app")
ini = conf.parse_ini(ini_file_paths)
sources = {}
if ini:
for key, value in ini.iteritems():
conf[key] = value
sources[key] = "ini-file"
if secrets:
for key, value in secrets.iteritems():
conf[key] = value
sources[key] = "keyring"
if env:
for key, value in env.iteritems():
conf[key] = value
sources[key] = "environment"
if cli_args:
for key, value in cli_args.iteritems():
conf[key] = value
sources[key] = "command-line"
print ''/n''.join([''%s:/t%s'' % (k, v) for k, v in sources.items()])
if __name__ == "__main__":
if config.keyring:
config.keyring.set_password("app", "karg", "13")
main(sys.argv)
Example results:
$APP_XENV=10 python test.py api --xarg=2 @test.arg
<Config xpos=api, gini=1, xenv=10, xini=50, karg=13, xarg=2, xfarg=30>
xpos: command-line
xenv: environment
xini: ini-file
karg: keyring
xarg: command-line
xfarg: command-line
"""
import argparse
import ConfigParser
import copy
import os
import sys
try:
import keyring
except ImportError:
keyring = None
class Option(object):
"""Holds a configuration option and the names and locations for it.
Instantiate options using the same arguments as you would for an
add_arguments call in argparse. However, you have two additional kwargs
available:
env: the name of the environment variable to use for this option
ini_section: the ini file section to look this value up from
"""
def __init__(self, *args, **kwargs):
self.args = args or []
self.kwargs = kwargs or {}
def add_argument(self, parser, **override_kwargs):
"""Add an option to a an argparse parser."""
kwargs = {}
if self.kwargs:
kwargs = copy.copy(self.kwargs)
try:
del kwargs[''env'']
except KeyError:
pass
try:
del kwargs[''ini_section'']
except KeyError:
pass
kwargs.update(override_kwargs)
parser.add_argument(*self.args, **kwargs)
@property
def type(self):
"""The type of the option.
Should be a callable to parse options.
"""
return self.kwargs.get("type", str)
@property
def name(self):
"""The name of the option as determined from the args."""
for arg in self.args:
if arg.startswith("--"):
return arg[2:].replace("-", "_")
elif arg.startswith("-"):
continue
else:
return arg.replace("-", "_")
@property
def default(self):
"""The default for the option."""
return self.kwargs.get("default")
class Config(object):
"""Parses configuration sources."""
def __init__(self, options=None, ini_paths=None, **parser_kwargs):
"""Initialize with list of options.
:param ini_paths: optional paths to ini files to look up values from
:param parser_kwargs: kwargs used to init argparse parsers.
"""
self._parser_kwargs = parser_kwargs or {}
self._ini_paths = ini_paths or []
self._options = copy.copy(options) or []
self._values = {option.name: option.default
for option in self._options}
self._parser = argparse.ArgumentParser(**parser_kwargs)
self.pass_thru_args = []
@property
def prog(self):
"""Program name."""
return self._parser.prog
def __getitem__(self, key):
return self._values[key]
def __setitem__(self, key, value):
self._values[key] = value
def __delitem__(self, key):
del self._values[key]
def __contains__(self, key):
return key in self._values
def __iter__(self):
return iter(self._values)
def __len__(self):
return len(self._values)
def get(self, key, *args):
"""
Return the value for key if it exists otherwise the default.
"""
return self._values.get(key, *args)
def __getattr__(self, attr):
if attr in self._values:
return self._values[attr]
else:
raise AttributeError("''config'' object has no attribute ''%s''"
% attr)
def build_parser(self, options, **override_kwargs):
"""."""
kwargs = copy.copy(self._parser_kwargs)
kwargs.update(override_kwargs)
if ''fromfile_prefix_chars'' not in kwargs:
kwargs[''fromfile_prefix_chars''] = ''@''
parser = argparse.ArgumentParser(**kwargs)
if options:
for option in options:
option.add_argument(parser)
return parser
def parse_cli(self, argv=None):
"""Parse command-line arguments into values."""
if not argv:
argv = sys.argv
options = []
for option in self._options:
temp = Option(*option.args, **option.kwargs)
temp.kwargs[''default''] = argparse.SUPPRESS
options.append(temp)
parser = self.build_parser(options=options)
parsed, extras = parser.parse_known_args(argv[1:])
if extras:
valid, pass_thru = self.parse_passthru_args(argv[1:])
parsed, extras = parser.parse_known_args(valid)
if extras:
raise AttributeError("Unrecognized arguments: %s" %
'' ,''.join(extras))
self.pass_thru_args = pass_thru + extras
return vars(parsed)
def parse_env(self):
results = {}
for option in self._options:
env_var = option.kwargs.get(''env'')
if env_var and env_var in os.environ:
value = os.environ[env_var]
results[option.name] = option.type(value)
return results
def get_defaults(self):
"""Use argparse to determine and return dict of defaults."""
parser = self.build_parser(options=self._options)
parsed, _ = parser.parse_known_args([])
return vars(parsed)
def parse_ini(self, paths=None):
"""Parse config files and return configuration options.
Expects array of files that are in ini format.
:param paths: list of paths to files to parse (uses ConfigParse logic).
If not supplied, uses the ini_paths value supplied on
initialization.
"""
results = {}
config = ConfigParser.SafeConfigParser()
config.read(paths or self._ini_paths)
for option in self._options:
ini_section = option.kwargs.get(''ini_section'')
if ini_section:
try:
value = config.get(ini_section, option.name)
results[option.name] = option.type(value)
except ConfigParser.NoSectionError:
pass
return results
def parse_keyring(self, namespace=None):
"""."""
results = {}
if not keyring:
return results
if not namespace:
namespace = self.prog
for option in self._options:
secret = keyring.get_password(namespace, option.name)
if secret:
results[option.name] = option.type(secret)
return results
def parse(self, argv=None):
"""."""
defaults = self.get_defaults()
args = self.parse_cli(argv=argv)
env = self.parse_env()
secrets = self.parse_keyring()
ini = self.parse_ini()
results = defaults
results.update(ini)
results.update(secrets)
results.update(env)
results.update(args)
self._values = results
return self
@staticmethod
def parse_passthru_args(argv):
"""Handles arguments to be passed thru to a subprocess using ''--''.
:returns: tuple of two lists; args and pass-thru-args
"""
if ''--'' in argv:
dashdash = argv.index("--")
if dashdash == 0:
return argv[1:], []
elif dashdash > 0:
return argv[0:dashdash], argv[dashdash + 1:]
return argv, []
def __repr__(self):
return "<Config %s>" % '', ''.join([
''%s=%s'' % (k, v) for k, v in self._values.iteritems()])
def comma_separated_strings(value):
"""Handles comma-separated arguments passed in command-line."""
return map(str, value.split(","))
def comma_separated_pairs(value):
"""Handles comma-separated key/values passed in command-line."""
pairs = value.split(",")
results = {}
for pair in pairs:
key, pair_value = pair.split(''='')
results[key] = pair_value
return results
El módulo argparse no hace las tuercas, siempre y cuando estés contento con un archivo de configuración que se parece a la línea de comandos. (Creo que esto es una ventaja, porque los usuarios solo tendrán que aprender una sintaxis). La configuración desde el fromfile_prefix_chars a, por ejemplo, @
, lo hace de forma que,
my_prog --foo=bar
es equivalente a
my_prog @baz.conf
if @baz.conf
es,
--foo
bar
Incluso puede hacer que su código busque foo.conf
automáticamente modificando argv
if os.path.exists(''foo.conf''):
argv = [''@foo.conf''] + argv
args = argparser.parse_args(argv)
El formato de estos archivos de configuración se puede modificar haciendo una subclase de ArgumentParser y agregando un método convert_arg_line_to_args .
Hay una biblioteca que hace exactamente esto llamado configglue .
configglue es una biblioteca que combina los parámetros optparse.OptionParser y ConfigParser.ConfigParser de python, para que no tenga que repetirse cuando quiera exportar las mismas opciones a un archivo de configuración y una interfaz de línea de comando.
También es supports variables de entorno.
También hay otra biblioteca llamada ConfigArgParse que es
Un reemplazo directo para argparse que permite que las opciones también se configuren mediante archivos de configuración y / o variables de entorno.
Usted podría estar interesado en PyCon hablar sobre la configuración de Łukasz Langa - Let Them Configure!
La biblioteca estándar de Python no proporciona esto, hasta donde yo sé. Lo resolví escribiendo código para usar optparse
y ConfigParser
para analizar la línea de comandos y los archivos de configuración, y proporcionar una capa de abstracción sobre ellos. Sin embargo, necesitaría esto como una dependencia separada, que a partir de su comentario anterior parece ser desagradable.
Si quiere ver el código que escribí, está en http://liw.fi/cliapp/ . Está integrado en mi biblioteca de "marco de aplicación de línea de comandos", ya que esa es una gran parte de lo que el marco necesita hacer.
Me probaron algo como esto recientemente, usando "optparse".
Lo configuré como una subclase de OptonParser, con un comando ''--Store'' y ''--Check''.
El siguiente código debería tenerlo cubierto. Solo tiene que definir sus propios métodos de "carga" y "almacenamiento" que aceptan / devuelven diccionarios y está presto de estar configurado.
class SmartParse(optparse.OptionParser):
def __init__(self,defaults,*args,**kwargs):
self.smartDefaults=defaults
optparse.OptionParser.__init__(self,*args,**kwargs)
fileGroup = optparse.OptionGroup(self,''handle stored defaults'')
fileGroup.add_option(
''-S'',''--Store'',
dest=''Action'',
action=''store_const'',const=''Store'',
help=''store command line settings''
)
fileGroup.add_option(
''-C'',''--Check'',
dest=''Action'',
action=''store_const'',const=''Check'',
help =''check stored settings''
)
self.add_option_group(fileGroup)
def parse_args(self,*args,**kwargs):
(options,arguments) = optparse.OptionParser.parse_args(self,*args,**kwargs)
action = options.__dict__.pop(''Action'')
if action == ''Check'':
assert all(
value is None
for (key,value) in options.__dict__.iteritems()
)
print ''defaults:'',self.smartDefaults
print ''config:'',self.load()
sys.exit()
elif action == ''Store'':
self.store(options.__dict__)
sys.exit()
else:
config=self.load()
commandline=dict(
[key,val]
for (key,val) in options.__dict__.iteritems()
if val is not None
)
result = {}
result.update(self.defaults)
result.update(config)
result.update(commandline)
return result,arguments
def load(self):
return {}
def store(self,optionDict):
print ''Storing:'',optionDict
Para cumplir con todos esos requisitos, recomendaría escribir su propia biblioteca que usa tanto [opt | arg] parse y configparser para la funcionalidad subyacente.
Teniendo en cuenta los dos primeros y el último requisito, diría que quieres:
Paso uno: realice un pase de analizador de línea de comandos que solo busque la opción --config-file.
Paso dos: analizar el archivo de configuración.
Paso tres: configure un segundo pase de analizador de línea de comando utilizando la salida del archivo de configuración aprobado como valores predeterminados.
El tercer requisito probablemente signifique que tiene que diseñar su propio sistema de definición de opciones para exponer toda la funcionalidad de optparse y configparser que le interese, y escribir algunas plomería para hacer conversiones en el medio.
Parece que la biblioteca estándar no se ocupa de esto, dejando a cada programador para configparser
y argparse
y os.environ
juntos de manera torpe.
Si bien no lo he probado solo, hay ConfigArgParse biblioteca ConfigArgParse que dice que hace la mayoría de las cosas que desea:
Un reemplazo directo para argparse que permite que las opciones también se configuren mediante archivos de configuración y / o variables de entorno.