Skip to content

Commit b11fc15

Browse files
authored
CLI: Fail command if --config file contains unknown key (#5939)
Up till now, the `--config` option would accept configuration files with keys that did not correspond to existing options on the command. This would lead to incorrect options to be swallowed silently, which can be surprising to users if they accidentally misspelled an option or used one that didn't exist. Here the callback is updated to check the specified keys in the config and cross-reference them to the params that are defined on the command. If any unknown keys are specified a `BadParameter` exception is raised which displays which keys are not supported. The implementation was originally provided by the `click-config-file` dependency, however, in order to customize it with the desired behavior we had to essentially vendor the entire code, since there was no available hook for the customizations. The code is taken from https://github.com/phha/click_config_file/blob/7b93a20b4c79458987fac116418859f30a16d82a which corresponds to `v0.6.0`. Besides the main changes, the default provider is changed to `yaml_config_file_provider`, the docstrings are reformated and type hints are added.
1 parent 0bc8ef0 commit b11fc15

File tree

8 files changed

+186
-14
lines changed

8 files changed

+186
-14
lines changed

aiida/cmdline/params/options/config.py

+123-8
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,19 @@
1111
"""
1212
.. py:module::config
1313
:synopsis: Convenience class for configuration file option
14+
15+
The functions :func:`configuration_callback` and :func:`configuration_option` were directly taken from the repository
16+
https://github.com/phha/click_config_file/blob/7b93a20b4c79458987fac116418859f30a16d82a/click_config_file.py with a
17+
minor modification to ``configuration_callback`` to add a check for unknown parameters in the configuration file and
18+
the default provider is changed to :func:`yaml_config_file_provider`.
1419
"""
15-
import click_config_file
20+
from __future__ import annotations
21+
22+
import functools
23+
import os
24+
import typing as t
25+
26+
import click
1627
import yaml
1728

1829
from .overridable import OverridableOption
@@ -25,9 +36,115 @@ def yaml_config_file_provider(handle, cmd_name): # pylint: disable=unused-argum
2536
return yaml.safe_load(handle)
2637

2738

28-
class ConfigFileOption(OverridableOption):
39+
def configuration_callback(
40+
cmd_name: str | None,
41+
option_name: str,
42+
config_file_name: str,
43+
saved_callback: t.Callable[..., t.Any] | None,
44+
provider: t.Callable[..., t.Any],
45+
implicit: bool,
46+
ctx: click.Context,
47+
param: click.Parameter,
48+
value: t.Any,
49+
):
50+
"""Callback for reading the config file.
51+
52+
Also takes care of calling user specified custom callback afterwards.
53+
54+
:param cmd_name: The command name. This is used to determine the configuration directory.
55+
:param option_name: The name of the option. This is used for error messages.
56+
:param config_file_name: The name of the configuration file.
57+
:param saved_callback: User-specified callback to be called later.
58+
:param provider: A callable that parses the configuration file and returns a dictionary of the configuration
59+
parameters. Will be called as ``provider(file_path, cmd_name)``. Default: ``yaml_config_file_provider``.
60+
:param implicit: Whether a implicit value should be applied if no configuration option value was provided.
61+
:param ctx: ``click`` context.
62+
:param param: ``click`` parameters.
63+
:param value: Value passed to the parameter.
64+
"""
65+
ctx.default_map = ctx.default_map or {}
66+
cmd_name = cmd_name or ctx.info_name
67+
68+
if implicit:
69+
default_value = os.path.join(click.get_app_dir(cmd_name), config_file_name) # type: ignore[arg-type]
70+
param.default = default_value
71+
value = value or default_value
72+
73+
if value:
74+
try:
75+
config = provider(value, cmd_name)
76+
except Exception as exception:
77+
raise click.BadOptionUsage(option_name, f'Error reading configuration file: {exception}', ctx)
78+
79+
valid_params = [param.name for param in ctx.command.params if param.name != option_name]
80+
specified_params = list(config.keys())
81+
unknown_params = set(specified_params).difference(set(valid_params))
82+
83+
if unknown_params:
84+
raise click.BadParameter(
85+
f'Invalid configuration file, the following keys are not supported: {unknown_params}', ctx, param
86+
)
87+
88+
ctx.default_map.update(config)
89+
90+
return saved_callback(ctx, param, value) if saved_callback else value
91+
92+
93+
def configuration_option(*param_decls, **attrs):
94+
"""Adds configuration file support to a click application.
95+
96+
This will create an option of type ``click.File`` expecting the path to a configuration file. When specified, it
97+
overwrites the default values for all other click arguments or options with the corresponding value from the
98+
configuration file. The default name of the option is ``--config``. By default, the configuration will be read from
99+
a configuration directory as determined by ``click.get_app_dir``. This decorator accepts the same arguments as
100+
``click.option`` and ``click.Path``. In addition, the following keyword arguments are available:
101+
102+
:param cmd_name: str
103+
The command name. This is used to determine the configuration directory. Default: ``ctx.info_name``.
104+
:param config_file_name: str
105+
The name of the configuration file. Default: ``config``.
106+
:param implicit: bool
107+
If ``True`` then implicitly create a value for the configuration option using the above parameters. If a
108+
configuration file exists in this path it will be applied even if no configuration option was suppplied as a
109+
CLI argument or environment variable. If ``False`` only apply a configuration file that has been explicitely
110+
specified. Default: ``False``.
111+
:param provider: callable
112+
A callable that parses the configuration file and returns a dictionary of the configuration parameters. Will be
113+
called as ``provider(file_path, cmd_name)``. Default: ``yaml_config_file_provider``.
29114
"""
30-
Wrapper around click_config_file.configuration_option that increases reusability.
115+
param_decls = param_decls or ('--config',)
116+
option_name = param_decls[0]
117+
118+
def decorator(func):
119+
attrs.setdefault('is_eager', True)
120+
attrs.setdefault('help', 'Read configuration from FILE.')
121+
attrs.setdefault('expose_value', False)
122+
implicit = attrs.pop('implicit', True)
123+
cmd_name = attrs.pop('cmd_name', None)
124+
config_file_name = attrs.pop('config_file_name', 'config')
125+
provider = attrs.pop('provider', yaml_config_file_provider)
126+
path_default_params = {
127+
'exists': False,
128+
'file_okay': True,
129+
'dir_okay': False,
130+
'writable': False,
131+
'readable': True,
132+
'resolve_path': False
133+
}
134+
path_params = {k: attrs.pop(k, v) for k, v in path_default_params.items()}
135+
attrs['type'] = attrs.get('type', click.Path(**path_params))
136+
saved_callback = attrs.pop('callback', None)
137+
partial_callback = functools.partial(
138+
configuration_callback, cmd_name, option_name, config_file_name, saved_callback, provider, implicit
139+
)
140+
attrs['callback'] = partial_callback
141+
return click.option(*param_decls, **attrs)(func)
142+
143+
return decorator
144+
145+
146+
class ConfigFileOption(OverridableOption):
147+
"""Reusable option that reads a configuration file containing values for other command parameters.
31148
32149
Example::
33150
@@ -49,8 +166,7 @@ def computer_setup(computer_name):
49166
"""
50167

51168
def __init__(self, *args, **kwargs):
52-
"""
53-
Store the default args and kwargs.
169+
"""Store the default args and kwargs.
54170
55171
:param args: default arguments to be used for the option
56172
:param kwargs: default keyword arguments to be used that can be overridden in the call
@@ -59,8 +175,7 @@ def __init__(self, *args, **kwargs):
59175
super().__init__(*args, **kwargs)
60176

61177
def __call__(self, **kwargs):
62-
"""
63-
Override the stored kwargs, (ignoring args as we do not allow option name changes) and return the option.
178+
"""Override the stored kwargs, (ignoring args as we do not allow option name changes) and return the option.
64179
65180
:param kwargs: keyword arguments that will override those set in the construction
66181
:return: click_config_file.configuration_option constructed with args and kwargs defined during construction
@@ -69,4 +184,4 @@ def __call__(self, **kwargs):
69184
kw_copy = self.kwargs.copy()
70185
kw_copy.update(kwargs)
71186

72-
return click_config_file.configuration_option(*self.args, **kw_copy)
187+
return configuration_option(*self.args, **kw_copy)

environment.yml

-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ dependencies:
1010
- archive-path~=0.4.2
1111
- aio-pika~=6.6
1212
- circus~=0.18.0
13-
- click-config-file~=0.6.0
1413
- click-spinner~=0.1.8
1514
- click~=8.1
1615
- disk-objectstore~=0.6.0

pyproject.toml

-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ dependencies = [
2929
"archive-path~=0.4.2",
3030
"aio-pika~=6.6",
3131
"circus~=0.18.0",
32-
"click-config-file~=0.6.0",
3332
"click-spinner~=0.1.8",
3433
"click~=8.1",
3534
"disk-objectstore~=0.6.0",

requirements/requirements-py-3.10.txt

-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ cffi==1.15.0
2020
charset-normalizer==2.0.8
2121
circus==0.18.0
2222
click==8.1.0
23-
click-config-file==0.6.0
2423
click-spinner==0.1.10
2524
configobj==5.0.6
2625
coverage==6.5.0

requirements/requirements-py-3.11.txt

-1
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ cffi==1.15.1
2222
charset-normalizer==2.1.1
2323
circus==0.18.0
2424
click==8.1.3
25-
click-config-file==0.6.0
2625
click-spinner==0.1.10
2726
configobj==5.0.6
2827
contourpy==1.0.6

requirements/requirements-py-3.8.txt

-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ cffi==1.15.0
2020
charset-normalizer==2.0.8
2121
circus==0.18.0
2222
click==8.1.0
23-
click-config-file==0.6.0
2423
click-spinner==0.1.10
2524
configobj==5.0.6
2625
coverage==6.5.0

requirements/requirements-py-3.9.txt

-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ cffi==1.15.0
2020
charset-normalizer==2.0.8
2121
circus==0.18.0
2222
click==8.1.0
23-
click-config-file==0.6.0
2423
click-spinner==0.1.10
2524
configobj==5.0.6
2625
coverage==6.5.0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# -*- coding: utf-8 -*-
2+
###########################################################################
3+
# Copyright (c), The AiiDA team. All rights reserved. #
4+
# This file is part of the AiiDA code. #
5+
# #
6+
# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core #
7+
# For further information on the license, see the LICENSE.txt file #
8+
# For further information please visit http://www.aiida.net #
9+
###########################################################################
10+
# pylint: disable=redefined-outer-name
11+
"""Unit tests for the :class:`aiida.cmdline.params.options.config.ConfigOption`."""
12+
import functools
13+
import tempfile
14+
import textwrap
15+
16+
import click
17+
import pytest
18+
19+
from aiida.cmdline.params.options import CONFIG_FILE
20+
21+
22+
@pytest.fixture
23+
def run_cli_command(run_cli_command):
24+
"""Override the ``run_cli_command`` fixture to always run with ``use_subprocess=False`` for tests in this module."""
25+
return functools.partial(run_cli_command, use_subprocess=False)
26+
27+
28+
@click.command()
29+
@click.option('--integer', type=int)
30+
@click.option('--boolean', type=bool)
31+
@CONFIG_FILE()
32+
def cmd(integer, boolean):
33+
"""Test command for :class:`aiida.cmdline.params.options.config.ConfigOption`."""
34+
click.echo(f'Integer: {integer}')
35+
click.echo(f'Boolean: {boolean}')
36+
37+
38+
def test_valid(run_cli_command):
39+
"""Test the option for a valid configuration file."""
40+
with tempfile.NamedTemporaryFile('w+') as handle:
41+
handle.write(textwrap.dedent("""
42+
integer: 1
43+
boolean: false
44+
"""))
45+
handle.flush()
46+
47+
result = run_cli_command(cmd, ['--config', handle.name])
48+
assert 'Integer: 1' in result.output_lines[0]
49+
assert 'Boolean: False' in result.output_lines[1]
50+
51+
52+
def test_invalid_unknown_keys(run_cli_command):
53+
"""Test the option for an invalid configuration file containing unknown keys."""
54+
with tempfile.NamedTemporaryFile('w+') as handle:
55+
handle.write(textwrap.dedent("""
56+
integer: 1
57+
unknown: 2.0
58+
"""))
59+
handle.flush()
60+
61+
result = run_cli_command(cmd, ['--config', handle.name], raises=True)
62+
assert "Error: Invalid value for '--config': Invalid configuration file" in result.stderr
63+
assert "the following keys are not supported: {'unknown'}" in result.stderr

0 commit comments

Comments
 (0)