Jordan Mackie
Jordan Mackie

Reputation: 2406

ArgParse argument error if default or given value None

When parsing the arguments for my program, I'd like to be able to also take certain arguments from the environment if not specified as arguments to the program.

I currently have:

parser = argparse.ArgumentParser(
    description="Foo Bar Baz")
parser.add_argument('--metadata-api-user', type=str,
                    help='Username for basic authentication access to the metadata API. If not given, will be extracted from env variable {}'.format("API_USER"),
                    default=os.environ.get("API_USER"))
parser.add_argument('--metadata-api-password', type=str,
                    help='Password for basic authentication access to the metadata API. If not given, will be extracted from env variable {}'.format("API_PASS"),
                    default=os.environ.get("API_PASS"))
parser.add_argument('--version', type=int, help='Version number', default=1)
parser.add_argument('--location', type=str, help='Location to place thing (default: ./)',default="./")

args = parser.parse_args(args)

This provides the functionality that I want, but if the env variables are not given and they are not given in the command line, I'd like argparse to raise an ArgumentError.

environ[keyname] would raise a keyerror when creating arguments if it was only specified on the command line and not in the env variables, which isn't great.

Something like 'allow-none'=false would be great as a parameter when creating the argument, but if anyone knows another solution to this that would be awesome.

Upvotes: 5

Views: 6587

Answers (4)

Eric
Eric

Reputation: 1251

An elegant solution is to subclass the argument parser to transparently implement the logic you described:

class SaneArgumentParser(argparse.ArgumentParser):
    """
    Argument parser for which arguments are required on the CLI unless:
      - required=False is provided
        and/or
      - a default value is provided that is not None.
    """
    def add_argument(self, *args, default=None, **kwargs):
        if default is None:
            # Tentatively make this argument required
            kwargs.setdefault("required", True)
        super().add_argument(*args, **kwargs, default=default)

You'd use it as usual:

if __name__ == "__main__":
    parser = SaneArgumentParser()
    parser.add_argument("--foo", default=os.environ.get("FOO"))
    args = parser.parse_args()
    print(args)

Of course, providing an argument on the CLI works as usual:

$ ./prog.py --foo=42
Namespace(foo='42')

Providing an argument in the environment also works:

$ FOO=42 ./prog.py
Namespace(foo='42')

And providing no argument at all raises an ArgumentError:

$ ./prog.py 
usage: prog.py [-h] --foo FOO
prog.py: error: the following arguments are required: --foo

Note that catching ArgumentError is only possible since Python 3.9 when using exit_on_error=False: https://docs.python.org/3/library/argparse.html#exit-on-error

Upvotes: 2

pawel
pawel

Reputation: 1069

why not check for the environment variable while building the arguments?

if "API_USER" in os.environ:
    parser.add_argument('--metadata-api-user', type=str, default=os.environ.get("API_USER"))
else:
    parser.add_argument('--metadata-api-user', type=str)

this seems like the easiest way to get the result you need.

Upvotes: 0

I'll Eat My Hat
I'll Eat My Hat

Reputation: 1344

default will simply return the given value if the user doesn't provide one, however it has a special case for None and for strings.

Passing None to default will short circuit and return None.

Passing a string to default will return a parsed string using the type constructor.

I implemented a custom parameterized type called not_none, which will attempt to parse the string with a given type constructor, or fail loudly if the string is empty.

import argparse
from os import environ
from typing import Callable, TypeVar
T = TypeVar('T')

def not_none(type_constructor: Callable[[str], T] = str):
    """Convert a string to a type T."""
    def parse(s: str) -> T:
        """argparse will call this function if `s` is an empty string, but not when `s` is None."""
        if not s:
            raise ValueError
        else:
            return type_constructor(s)
    return parse

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--foo', help="A foo...", type=not_none(int), default='1')
    parser.add_argument('--bar', help="A bar...", type=not_none(), default=environ.get('BAR', ''))
    # args = parser.parse_args(['--foo', '1'])
    args = parser.parse_args(['--bar', 'BAR'])
    print(args.foo, args.bar)

Upvotes: 4

Jordan Mackie
Jordan Mackie

Reputation: 2406

Unfortunately I was unable to find a solution, so I've had to go with adding following for every argument that can be obtained from env variables:

api_user_action = parser.add_argument('--metadata-api-user', type=str,
                    help='Username for basic authentication access to the metadata API. If not given, will be extracted from env variable {}'.format("API_USER"),
                    default=os.environ.get("API_USER"))

...

if args.metadata_api_user is None:
    raise argparse.ArgumentError(api_user_action,
                                 "API Username was not supplied in either command line or environment variables. \n" + parser.format_help())

This prints the usage and an appropriate error message if they aren't supplied by either, but it needs to be repeated from every argument that this can apply to.

Upvotes: 1

Related Questions