web-dev-qa-db-fra.com

Définition des options des variables d'environnement lors de l'utilisation de argparse

J'ai un script qui a certaines options qui peuvent être passées sur la ligne de commande ou à partir de variables d'environnement. La CLI doit avoir la priorité si les deux sont présentes et une erreur se produit si aucune n'est définie.

Je pourrais vérifier que l'option est attribuée après l'analyse, mais je préfère laisser argparse faire le gros du travail et être responsable de l'affichage de la déclaration d'utilisation si l'analyse échoue.

J'ai trouvé quelques approches alternatives à cela (que je posterai ci-dessous en tant que réponses afin qu'elles puissent être discutées séparément) mais elles me semblent assez compliquées et je pense que je manque quelque chose.

Existe-t-il une "meilleure" façon acceptée de procéder?

(Modifier pour rendre le comportement souhaité clair lorsque l'option CLI et la variable d'environnement ne sont pas définies)

39
Russell Heilling

J'utilise ce modèle assez fréquemment pour avoir mis en place une classe d'action simple pour le gérer:

import argparse
import os

class EnvDefault(argparse.Action):
    def __init__(self, envvar, required=True, default=None, **kwargs):
        if not default and envvar:
            if envvar in os.environ:
                default = os.environ[envvar]
        if required and default:
            required = False
        super(EnvDefault, self).__init__(default=default, required=required, 
                                         **kwargs)

    def __call__(self, parser, namespace, values, option_string=None):
        setattr(namespace, self.dest, values)

Je peux ensuite appeler cela depuis mon code avec:

import argparse
from envdefault import EnvDefault

parser=argparse.ArgumentParser()
parser.add_argument(
    "-u", "--url", action=EnvDefault, envvar='URL', 
    help="Specify the URL to process (can also be specified using URL environment variable)")
args=parser.parse_args()
45
Russell Heilling

Je voudrais simplement définir la variable default lors de l'ajout d'un argument à un get d'os.environ avec la variable que vous souhaitez saisir. Le 2e argument de l'appel .get() est la valeur par défaut si .get() ne trouve pas de variable d'environnement de ce nom.

import argparse
import os

parser = argparse.ArgumentParser(description='test')
parser.add_argument('--url', default=os.environ.get('URL', None))

args = parser.parse_args()
if not args.url:
    exit(parser.print_usage())
55
Christian Witts

ConfigArgParse ajoute la prise en charge des variables d'environnement à argparse, vous pouvez donc faire des choses comme:

p = configargparse.ArgParser()
p.add('-m', '--moo', help='Path of cow', env_var='MOO_PATH') 
options = p.parse_args()
21
user553965

Je dois généralement le faire pour plusieurs arguments (authentification et clés API) .. c'est simple et direct. Utilise ** kwargs.

def environ_or_required(key):
    return (
        {'default': os.environ.get(key)} if os.environ.get(key)
        else {'required': True}
    )

parser.add_argument('--thing', **environ_or_required('THING'))
18
whardier

Une option consiste à vérifier si la variable d'environnement est définie et à modifier les appels à add_argument en conséquence, par exemple.

import argparse
import os

parser=argparse.ArgumentParser()
if 'CVSWEB_URL' in os.environ:
    cvsopt = { 'default': os.environ['CVSWEB_URL'] }
else:
    cvsopt = { 'required': True }
parser.add_argument(
    "-u", "--cvsurl", help="Specify url (overrides CVSWEB_URL environment variable)", 
    **cvsopt)
args=parser.parse_args()
4
Russell Heilling

Vous pouvez utiliser OptionParser()

from optparse import OptionParser

def argument_parser(self, parser):
    parser.add_option('--foo', dest="foo", help="foo", default=os.environ.get('foo', None))
    parser.add_option('--bar', dest="bar", help="bar", default=os.environ.get('bar', None))
    return(parser.parse_args())

parser = OptionParser()
(options, args) = argument_parser(parser)
foo = options.foo
bar = options.bar
print("foo: {}".format(foo))
print("bar: {}".format(bar))

Coquille:

export foo=1
export bar=2
python3 script.py
3
Berlin

Le sujet est assez ancien, mais j'ai eu un problème similaire et j'ai pensé partager ma solution avec vous. Malheureusement, la solution d'action personnalisée suggérée par @Russell Heilling ne fonctionne pas pour moi pour deux raisons:

  • Cela m'empêche d'utiliser actions prédéfinies (comme store_true)
  • Je préférerais qu'il revienne à default lorsque envvar n'est pas dans os.environ (Cela pourrait être facilement corrigé)
  • Je voudrais avoir ce comportement pour tous mes arguments sans spécifier action ou envvar (qui devrait toujours être action.dest.upper())

Voici ma solution (en Python 3):

class CustomArgumentParser(argparse.ArgumentParser):
    class _CustomHelpFormatter(argparse.ArgumentDefaultsHelpFormatter):
        def _get_help_string(self, action):
            help = super()._get_help_string(action)
            if action.dest != 'help':
                help += ' [env: {}]'.format(action.dest.upper())
            return help

    def __init__(self, *, formatter_class=_CustomHelpFormatter, **kwargs):
        super().__init__(formatter_class=formatter_class, **kwargs)

    def _add_action(self, action):
        action.default = os.environ.get(action.dest.upper(), action.default)
        return super()._add_action(action)
3
Tomasz Elendt

Je pensais publier ma solution car la question/réponse d'origine m'a beaucoup aidé.

Mon problème est un peu différent de celui de Russell. J'utilise OptionParser et au lieu d'une variable d'environnement pour chaque argument, j'en ai juste un qui simule la ligne de commande.

c'est à dire.

MY_ENVIRONMENT_ARGS = --arg1 "maltais" --arg2 "Falcon" -r "1930" -h

Solution:

def set_defaults_from_environment(oparser):

    if 'MY_ENVIRONMENT_ARGS' in os.environ:

        environmental_args = os.environ[ 'MY_ENVIRONMENT_ARGS' ].split()

        opts, _ = oparser.parse_args( environmental_args )

        oparser.defaults = opts.__dict__

oparser = optparse.OptionParser()
oparser.add_option('-a', '--arg1', action='store', default="Consider")
oparser.add_option('-b', '--arg2', action='store', default="Phlebas")
oparser.add_option('-r', '--release', action='store', default='1987')
oparser.add_option('-h', '--hardback', action='store_true', default=False)

set_defaults_from_environment(oparser)

options, _ = oparser.parse_args(sys.argv[1:])

Ici, je ne lance pas d'erreur si aucun argument n'est trouvé. Mais si je le souhaite, je pourrais simplement faire quelque chose comme

for key in options.__dict__:
    if options.__dict__[key] is None:
        # raise error/log problem/print to console/etc
1
Shane Gannon

Il existe un exemple d'utilisation pour ChainMap où vous fusionnez les valeurs par défaut, les variables d'environnement et les arguments de ligne de commande.

import os, argparse

defaults = {'color': 'red', 'user': 'guest'}

parser = argparse.ArgumentParser()
parser.add_argument('-u', '--user')
parser.add_argument('-c', '--color')
namespace = parser.parse_args()
command_line_args = {k:v for k, v in vars(namespace).items() if v}

combined = ChainMap(command_line_args, os.environ, defaults)

Je suis venu de n grand discours sur beau et idiomatique python.

Cependant, je ne sais pas comment faire la différence entre les clés de dictionnaire minuscules et majuscules. Dans le cas où les deux -u foobar est passé en argument et l'environnement est défini sur USER=bazbaz, le dictionnaire combined ressemblera à {'user': 'foobar', 'USER': 'bazbaz'}.

1
sshow

Voici une solution relativement simple (qui semble plus longue car elle est bien commentée) et pourtant complète qui évite de surcharger default en utilisant l'argument d'espace de nom de parse_args. Par défaut, il n'analyse pas les variables d'environnement différemment des arguments de ligne de commande, mais cela peut facilement être modifié.

import shlex

# Notes:
#   * Based on https://github.com/python/cpython/blob/
#               15bde92e47e824369ee71e30b07f1624396f5cdc/
#               Lib/argparse.py
#   * Haven't looked into handling "required" for mutually exclusive groups
#   * Probably should make new attributes private even though it's ugly.
class EnvArgParser(argparse.ArgumentParser):
    # env_k:    The keyword to "add_argument" as well as the attribute stored
    #           on matching actions.
    # env_f:    The keyword to "add_argument". Defaults to "env_var_parse" if
    #           not provided.
    # env_i:    Basic container type to identify unfilled arguments.
    env_k = "env_var"
    env_f = "env_var_parse"
    env_i = type("env_i", (object,), {})

    def add_argument(self, *args, **kwargs):
        map_f = (lambda m,k,f=None,d=False:
                    (k, k in m, m.pop(k,f) if d else m.get(k,f)))

        env_k = map_f(kwargs, self.env_k, d=True, f="")
        env_f = map_f(kwargs, self.env_f, d=True, f=self.env_var_parse)

        if env_k[1] and not isinstance(env_k[2], str):
            raise ValueError(f"Parameter '{env_k[0]}' must be a string.")

        if env_f[1] and not env_k[1]:
            raise ValueError(f"Parameter '{env_f[0]}' requires '{env_k[0]}'.")

        if env_f[1] and not callable(env_f[2]):
            raise ValueError(f"Parameter '{env_f[0]}' must be callable.")

        action = super().add_argument(*args, **kwargs)

        if env_k[1] and not action.option_strings:
            raise ValueError(f"Positional parameters may not specify '{env_k[0]}'.")

        # We can get the environment now:
        #   * We need to know now if the keys exist anyway
        #   * os.environ is static
        env_v = map_f(os.environ, env_k[2], f="")

        # Examples:
        # env_k:
        #   ("env_var", True,  "FOO_KEY")
        # env_v:
        #   ("FOO_KEY", False, "")
        #   ("FOO_KEY", True,  "FOO_VALUE")
        #
        # env_k:
        #   ("env_var", False, "")
        # env_v:
        #   (""       , False, "")
        #   ("",        True,  "RIDICULOUS_VALUE")

        # Add the identifier to all valid environment variable actions for
        # later access by i.e. the help formatter.
        if env_k[1]:
            if env_v[1] and action.required:
                action.required = False
            i = self.env_i()
            i.a = action
            i.k = env_k[2]
            i.f = env_f[2]
            i.v = env_v[2]
            i.p = env_v[1]
            setattr(action, env_k[0], i)

        return action

    # Overriding "_parse_known_args" is better than "parse_known_args":
    #   * The namespace will already have been created.
    #   * This method runs in an exception handler.
    def _parse_known_args(self, arg_strings, namespace):
        """precedence: cmd args > env var > preexisting namespace > defaults"""

        for action in self._actions:
            if action.dest is argparse.SUPPRESS:
                continue
            try:
                i = getattr(action, self.env_k)
            except AttributeError:
                continue
            if not i.p:
                continue
            setattr(namespace, action.dest, i)

        namespace, arg_extras = super()._parse_known_args(arg_strings, namespace)

        for k,v in vars(namespace).copy().items():
            # Setting "env_i" on the action is more effective than using an
            # empty unique object() and mapping namespace attributes back to
            # actions.
            if isinstance(v, self.env_i):
                fv = v.f(v.a, v.k, v.v, arg_extras)
                if fv is argparse.SUPPRESS:
                    delattr(namespace, k)
                else:
                    # "_parse_known_args::take_action" checks for action
                    # conflicts. For simplicity we don't.
                    v.a(self, namespace, fv, v.k)

        return (namespace, arg_extras)

    def env_var_parse(self, a, k, v, e):
        # Use shlex, yaml, whatever.
        v = shlex.split(v)

        # From "_parse_known_args::consume_optional".
        n = self._match_argument(a, "A"*len(v))

        # From the main loop of "_parse_known_args". Treat additional
        # environment variable arguments just like additional command-line
        # arguments (which will eventually raise an exception).
        e.extend(v[n:])

        return self._get_values(a, v[:n])


# Derived from "ArgumentDefaultsHelpFormatter".
class EnvArgHelpFormatter(argparse.HelpFormatter):
    """Help message formatter which adds environment variable keys to
    argument help.
    """

    env_k = EnvArgParser.env_k

    # This is supposed to return a %-style format string for "_expand_help".
    # Since %-style strings don't support attribute access we instead expand
    # "env_k" ourselves.
    def _get_help_string(self, a):
        h = super()._get_help_string(a)
        try:
            i = getattr(a, self.env_k)
        except AttributeError:
            return h
        s = f" ({self.env_k}: {i.k})"
        if s not in h:
            h += s
        return h


# An example mix-in.
class DefEnvArgHelpFormatter\
        ( EnvArgHelpFormatter
        , argparse.ArgumentDefaultsHelpFormatter
        ):
    pass

Exemple de programme:

parser = EnvArgParser\
        ( prog="Test Program"
        , formatter_class=DefEnvArgHelpFormatter
        )

parser.add_argument\
        ( '--bar'
        , required=True
        , env_var="BAR"
        , type=int
        , nargs="+"
        , default=22
        , help="Help message for bar."
        )

parser.add_argument\
        ( 'baz'
        , type=int
        )

args = parser.parse_args()
print(args)

Exemple de sortie de programme:

$ BAR="1 2 3 '45  ' 6 7" ./envargparse.py 123
Namespace(bar=[1, 2, 3, 45, 6, 7], baz=123)

$ ./envargparse.py -h
usage: Test Program [-h] --bar BAR [BAR ...] baz

positional arguments:
  baz

optional arguments:
  -h, --help           show this help message and exit
  --bar BAR [BAR ...]  Help message for bar. (default: 22) (env_var: BAR)
0
user19087