Skip to content

Instantly share code, notes, and snippets.

@jwodder
Last active August 13, 2020 22:36
Show Gist options
  • Save jwodder/8ce218892331229c9116ed6c3e16a5bb to your computer and use it in GitHub Desktop.
Save jwodder/8ce218892331229c9116ed6c3e16a5bb to your computer and use it in GitHub Desktop.
Setting Default Option Values from Config Files with Click

Setting Default Option Values from Config Files with Click

Author: John T. Wodder II (@jwodder)
Date: 2020-07-17
Updates:Updated version posted at <https://jwodder.github.io/kbits/posts/click-config/>
Copyright: Copyright © 2020 John T. Wodder II. This work is licensed under a Creative Commons Attribution 4.0 International License.

When developing a command-line application in Python with Click, you may find yourself wanting to use a configuration file to set the parameters' default values at runtime and to be able to specify such a file with a --config option. There are various libraries of varying quality that provide such functionality in different ways; however, implementing such functionality on one's own is simple enough (as this document will show), and knowing the basics will help you develop more advanced implementations when the libraries out there don't meet your needs.

All code in this document has been tested against Click version 7.1.2.

default_map

The key feature of Click that will allow us to set parameter values from a configuration file is the default_map attribute of click.Context, documented at [1]. Any values set in this dict before parameters are processed will become the parameters' new default values. Any extra keys that do not correspond to defined parameters are ignored.

Moreover, Click will map values in default_map through the parameters' normal type conversion & validation routines. This means that, if you have an option --foo defined with type=int, you can set ctx.default_map["foo"] = "5", and the value will be converted to an int by the time it's passed to the command callback. This even works for boolean flags: a value set for a boolean flag option will be processed by the click.BOOL type, which maps boolean-like strings to bool values. This eases your workload when reading from a configuration file type like INI where values don't come with type information; just pass the values straight to Click, and it'll do the conversion for you the same as it does for values on the command line.

Areas to be careful in include parameters defined with multiple=True. The default value for such parameters (whether declared with default= in the parameter's decorator or set in default_map) must be a list or tuple; setting such a default to a string will cause the string to be interpreted as a list of single-character strings. Also requiring special attention are options defined with nargs or with a tuple of types; correct handling for all of these is left as an exercise for the reader.

Implementing a --config option

In order to define a --config FILE option that reads from FILE and sets other parameters' default values, the option first of all needs to be eager so that it can modify ctx.default_map before the other options read it, and it needs to be defined with a callback that does the actual work. Everything else after that is straightforward.

Here is a sample Python script with a --config option that reads from a given config file (or from config.ini in the current directory if no config file is given). The config file must be an INI file, and the values for the options are read from the [options] section. The command callback simply dumps out its arguments so you can see what's being passed to it.

from   configparser import ConfigParser
import json
import click

DEFAULT_CFG = 'config.ini'

def configure(ctx, param, filename):
    cfg = ConfigParser()
    cfg.read(filename)
    try:
        options = dict(cfg['options'])
    except KeyError:
        options = {}
    ctx.default_map = options

@click.command()
@click.option(
    '-c', '--config',
    type         = click.Path(dir_okay=False),
    default      = DEFAULT_CFG,
    callback     = configure,
    is_eager     = True,
    expose_value = False,
    help         = 'Read option defaults from the specified INI file',
    show_default = True,
)
@click.option('--integer', type=int, default=42)
@click.option('--flag/--no-flag', default=False)
@click.option('--str', default='foo')
@click.option('--choice', type=click.Choice(['red', 'green', 'blue']))
def main(**kwargs):
    print(json.dumps(kwargs, sort_keys=True, indent=4))

if __name__ == '__main__':
    main()

If we run this script with no options when config.ini does not exist or is empty, we get the parameters' built-in default values:

$ python3 config01.py
{
    "choice": null,
    "flag": false,
    "integer": 42,
    "str": "foo"
}

That's boring! Try populating example.ini with the below text:

[options]
integer = 23
flag = yes
str = bar
choice = green

… and then run with --config example.ini:

$ python3 config01.py --config example.ini
{
    "choice": "green",
    "flag": true,
    "integer": 23,
    "str": "bar"
}

Note that the values set for the flag and integer options have been converted to their appropriate types.

Of course, options set in the config file are overridden by command-line options, no matter where the options occur in relation to --config:

$ python3 config01.py --integer 17 --config example.ini --str glarch
{
    "choice": "green",
    "flag": true,
    "integer": 17,
    "str": "glarch"
}

What if a value in the config file is invalid? Try saving the following text to bad.ini:

[options]
choice = mauve

The script will then error when passed this config file:

$ python3 config01.py --config bad.ini
Usage: config01.py [OPTIONS]
Try 'config01.py --help' for help.

Error: Invalid value for '--choice': invalid choice: mauve. (choose from red, green, blue)

Not the best possible error message (It doesn't tell us the bad value was in the config file), but it's better than a stack trace.

Note that, with this code, parameters in the config file must be named using the same name & spelling as the parameter's corresponding argument to the command callback. For example, the --integer option must be written integer, not --integer or -i or i; any entries in the config file with an invalid spelling will be ignored. For options with medial hyphens on the command line, like --log-level, the hyphens must become underscores in the configuration file, like log_level. If you want to support the spelling log-level as well, insert the following line after cfg = ConfigParser() to make the ConfigParser object convert hyphens in option names to underscores:

cfg.optionxform = lambda s: s.replace('-', '_')

Configuring command groups

default_map supports passing values to subcommands in command groups in a very simple way: if the main command has a subcommand named "foo", then ctx.default_map["foo"] can be set to a dict of parameter names & values for foo. For example, the following assignment:

ctx.default_map = {
    "color": "red",
    "foo": {
        "speed": "high",
    },
    "bar": {
        "speed": "low",
        "baz": {
            "time": "late",
        },
    },
}

sets the default value for the main command's --color option to red, the default value of the foo subcommand's --speed option to high, the default value of the bar subcommand's --speed option to low, and the default value of the bar baz sub-subcommand's --time option to late. As you can see, this comes with one major drawback: a command can't have a subcommand with the same name as one of its parameters.

Here is a sample Python script with command groups that reads configuration from an INI file. Settings in the [options] section are applied to the top-level command, settings in the [options.CMD] section are applied to the subcommand CMD, settings in [options.CMD1.CMD2] are applied to the CMD2 sub-subcommand of the CMD1 subcommand, and so forth. As above, each command prints out the parameters it receives.

from   configparser import ConfigParser
import json
import click

DEFAULT_CFG = 'config.ini'

def configure(ctx, param, filename):
    cfg = ConfigParser()
    cfg.read(filename)
    ctx.default_map = {}
    for sect in cfg.sections():
        command_path = sect.split('.')
        if command_path[0] != 'options':
            continue
        defaults = ctx.default_map
        for cmdname in command_path[1:]:
            defaults = defaults.setdefault(cmdname, {})
        defaults.update(cfg[sect])

@click.group(invoke_without_command=True)
@click.option(
    '-c', '--config',
    type         = click.Path(dir_okay=False),
    default      = DEFAULT_CFG,
    callback     = configure,
    is_eager     = True,
    expose_value = False,
    help         = 'Read option defaults from the specified INI file',
    show_default = True,
)
@click.option('--integer', type=int, default=42)
@click.option('--flag/--no-flag', default=False)
@click.option('--str', default='foo')
@click.option('--choice', type=click.Choice(['red', 'green', 'blue']))
def main(**kwargs):
    print('# main')
    print(json.dumps(kwargs, sort_keys=True, indent=4))

@main.command()
@click.option('--speed', type=click.Choice(['low', 'medium', 'high', 'ludicrous']), default='medium')
def foo(**kwargs):
    print('# foo')
    print(json.dumps(kwargs, sort_keys=True, indent=4))

@main.group(invoke_without_command=True)
@click.option('--speed', type=click.Choice(['low', 'medium', 'high', 'ludicrous']), default='medium')
def bar(**kwargs):
    print('# bar')
    print(json.dumps(kwargs, sort_keys=True, indent=4))

@bar.command()
@click.option('--time', type=click.Choice(['early', 'late', 'exact']), default='early')
def baz(**kwargs):
    print('# baz')
    print(json.dumps(kwargs, sort_keys=True, indent=4))

if __name__ == '__main__':
    main()

Set config.ini to the following:

[options]
integer = 23
flag = yes
str = bar
choice = green

[options.foo]
speed = high

[options.bar]
speed = low

[options.bar.baz]
time = late

… and then invoke some commands to see the results:

$ python3 config02.py
# main
{
    "choice": "green",
    "flag": true,
    "integer": 23,
    "str": "bar"
}
$ python3 config02.py foo
# main
{
    "choice": "green",
    "flag": true,
    "integer": 23,
    "str": "bar"
}
# foo
{
    "speed": "high"
}
$ python3 config02.py bar
# main
{
    "choice": "green",
    "flag": true,
    "integer": 23,
    "str": "bar"
}
# bar
{
    "speed": "low"
}
$ python3 config02.py bar baz
# main
{
    "choice": "green",
    "flag": true,
    "integer": 23,
    "str": "bar"
}
# bar
{
    "speed": "low"
}
# baz
{
    "time": "late"
}
$ python3 config02.py --choice red foo --speed medium
# main
{
    "choice": "red",
    "flag": true,
    "integer": 23,
    "str": "bar"
}
# foo
{
    "speed": "medium"
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment