78

I have a script which has certain options that can either be passed on the command line, or from environment variables. The CLI should take precedence if both are present, and an error occur if neither are set.

I could check that the option is assigned after parsing, but I prefer to let argparse to do the heavy lifting and be responsible for displaying the usage statement if parsing fails.

I have come up with a couple of alternative approaches to this (which I will post below as answers so they can be discussed separately) but they feel pretty kludgey to me and I think that I am missing something.

Is there an accepted "best" way of doing this?

(Edit to make the desired behaviour clear when both the CLI option and environment variable are unset)

Russell Heilling
  • 1,829
  • 1
  • 12
  • 10

12 Answers12

92

You can set the default= of the argument to a .get() of os.environ with the environment variable you want to grab.

You can also pass a 2nd argument in the .get() call, which is the default value if .get() doesn't find an environment variable by that name (by default .get() returns None in that case).

import argparse
import os

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

args = parser.parse_args()
if not args.url:
    exit(parser.print_usage())
Boris Verkhovskiy
  • 14,854
  • 11
  • 100
  • 103
Christian Witts
  • 11,375
  • 1
  • 33
  • 46
  • 3
    None is the default for .get(), so doesn't need to be explicitly stated like this. I should probably have been more clear in the question - the option needs to be in at least one of the environment variable or the CLI. If you set the default like this, then args.url may well end up as None, which is what I want to avoid... – Russell Heilling May 11 '12 at 12:37
  • 1
    Ah, I see what you are looking for. I would honestly just use what I wrote and after parsing the args just check `if not args.url: exit(parser.print_usage())` and exit. – Christian Witts May 11 '12 at 12:51
  • 1
    A nice quick way of handling it. I have packaged up my own action handler because I use this pattern a lot, but this would certainly be my fallback for a quick and simple script. – Russell Heilling May 14 '12 at 09:14
  • In my eyes this looks like the more expressive way. If there is not a very specific reason to get more complex, this is just elegant and short. Therefore I think this should be the "correct" answer, although @RussellHeilling also proposed a good alternative. – erikbstack Nov 11 '13 at 13:42
  • 37
    The way this answer proposes is bad UX. "default" means "default for the application", not "default in the current environment". People may look at --help, see a particular default, assume it's default forever, go to another machine/session, get kaboom. – pfalcon Jan 04 '18 at 22:35
  • @pfalcon: ArgumentDefaultsHelpFormatter automatically adds information about default values to each of the argument help messages. – samwyse Apr 25 '22 at 16:12
82

I use this pattern frequently enough that I have packaged a simple action class to handle it:

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)

I can then call this from my code with:

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()
Russell Heilling
  • 1,829
  • 1
  • 12
  • 10
  • 1
    Should default this be edited to look up in os.environ? "if envvar in os.environ: default = envvar" --> "if envvar in os.environ: default = os.environ[envvar]" – spazm Apr 04 '13 at 19:48
  • 3
    Why do you only use the value specified by the envvar if there's no default? Shouldn't it override the default, as the caller has explicitly provided a value? – Michael Nelson Jan 15 '15 at 05:29
  • 3
    This solution is great. However, it has a side-effect : depending on your environment (whether or not the env var is set), the usage text may differ. – Eric Citaire Mar 01 '17 at 14:38
  • This is a great answer, as you can easily add in additional functionality to this class as your desires change. I use this solution all the time, and it works great. – Rezkin Mar 22 '21 at 01:13
38

I usually have to do this for multiple arguments (authentication and API keys).. this is simple and straight forward. Uses **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'))
Robert Pollak
  • 3,751
  • 4
  • 30
  • 54
whardier
  • 665
  • 5
  • 8
  • Unfortunately doesn't really work with real `default` value (if I want to have fallback to argparse default value when neither env nor param are present) – The Godfather Sep 03 '19 at 08:13
  • 13
    @TheGodfather : if you want to specify a "real" `default`, then there is no point in making the argument required ! – Lucas Cimon Nov 20 '19 at 16:26
28

ConfigArgParse adds support for environment variables to argparse, so you can do things like:

p = configargparse.ArgParser()
p.add('-m', '--moo', help='Path of cow', env_var='MOO_PATH') 
options = p.parse_args()
user553965
  • 1,199
  • 14
  • 15
7

One option is to check whether the environment variable is set, and to modify the calls to add_argument accordingly e.g.

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()
Russell Heilling
  • 1,829
  • 1
  • 12
  • 10
6

The topic is quite old, but I had similar problem and I thought I would share my solution with you. Unfortunately custom action solution suggested by @Russell Heilling doesn't work for me for couple of reasons:

  • It prevents me from using predefined actions (like store_true)
  • I would rather like it to fallback to default when envvar is not in os.environ (that could be easily fixed)
  • I would like to have this behaviour for all of my arguments without specifying action or envvar (which should always be action.dest.upper())

Here's my solution (in 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)
Tomasz Elendt
  • 1,475
  • 13
  • 14
3

There is an example use-case for ChainMap where you merge together defaults, environment variables and command line arguments.

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)

Came to me from a great talk about beautiful and idiomatic python.

However, I'm not sure how to go about the difference of lower- and uppercase dictionary keys. In the case where both -u foobar is passed as an argument and environment is set to USER=bazbaz, the combined dictionary will look like {'user': 'foobar', 'USER': 'bazbaz'}.

sshow
  • 8,820
  • 4
  • 51
  • 82
  • (Years later...) you can possibly do something like `{k.lower():v for k,v in os.environ}`, with the added assumption that your arguments/options are all lowercase (or similarly add `.lower()` when setting `command_line_args`). – Pyrocater Nov 11 '22 at 02:22
2

Thought I'd post my solution as the original question/answer gave me a lot of help.

My problem is a little different to Russell's. I'm using OptionParser and instead of an environmental variable for each argument I have just one which simulates the command line.

i.e.

MY_ENVIRONMENT_ARGS = --arg1 "Maltese" --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:])

Here I don't throw an error if an argument is not found. But if I wish to I could just do something like

for key in options.__dict__:
    if options.__dict__[key] is None:
        # raise error/log problem/print to console/etc
Shane Gannon
  • 6,770
  • 7
  • 41
  • 64
1

You can use 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))

shell:

export foo=1
export bar=2
python3 script.py
Berlin
  • 1,456
  • 1
  • 21
  • 43
  • 2
    `optparse` has now been deprecated and the instructions say to use `argparse` instead - it has all the same functionality and more: https://docs.python.org/3/library/argparse.html – Oly Dungey Jan 14 '20 at 16:19
1

The Click library handles this explicitly:

import click

@click.command()
@click.argument('src', envvar='SRC', type=click.File('r'))
def echo(src):
    """Print value of SRC environment variable."""
    click.echo(src.read())

And from the command line:

$ export SRC=hello.txt
$ echo
Hello World!

https://click.palletsprojects.com/en/master/arguments/#environment-variables

You can install it with

pip install click
Boris Verkhovskiy
  • 14,854
  • 11
  • 100
  • 103
0

Here's a relatively simple (looks longer because it's well-commented) yet complete solution that avoids kludging default by using the namespace argument of parse_args. By default it parses environment variables no differently than command-line arguments though that can easily be changed.

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

Example program:

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)

Example program output:

$ 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)
user19087
  • 1,899
  • 1
  • 16
  • 21
0

Another option:

    parser = argparse.ArgumentParser()
    env = os.environ
    def add_argument(key, *args, **kwargs):
        if key in env:
            kwargs['default'] = env[key]
        parser.add_argument(*args, **kwargs)

    add_argument('--type', type=str)

Or this one, using os.getenv for setting default value:

parser = argparse.ArgumentParser()
parser.add_argument('--type', type=int, default=os.getenv('type',100))
user2503775
  • 4,267
  • 1
  • 23
  • 41