samcli/cli/cli_config_file.py (216 lines of code) (raw):
"""
CLI configuration decorator to use TOML configuration files for click commands.
"""
# This section contains code copied and modified from
# [click_config_file](https://github.com/phha/click_config_file/blob/master/click_config_file.py)
# SPDX-License-Identifier: MIT
import functools
import logging
import os
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import click
from click.core import ParameterSource
from samcli.cli.context import get_cmd_names
from samcli.commands.exceptions import ConfigException
from samcli.lib.config.samconfig import DEFAULT_CONFIG_FILE_NAME, DEFAULT_ENV, SamConfig
__all__ = ("ConfigProvider", "configuration_option", "get_ctx_defaults")
LOG = logging.getLogger(__name__)
class ConfigProvider:
"""
A parser for sam configuration files
"""
def __init__(self, section=None, cmd_names=None):
"""
The constructor for ConfigProvider class
Parameters
----------
section
The section defined in the configuration file nested within `cmd`
cmd_names
The cmd_name defined in the configuration file
"""
self.section = section
self.cmd_names = cmd_names
def __call__(self, config_path: Path, config_env: str, cmd_names: List[str]) -> dict:
"""
Get resolved config based on the `file_path` for the configuration file,
`config_env` targeted inside the config file and corresponding `cmd_name`
as denoted by `click`.
Parameters
----------
config_path: Path
The path of configuration file.
config_env: str
The name of the sectional config_env within configuration file.
cmd_names: List[str]
The sam command name as defined by click.
Returns
-------
dict
A dictionary containing the configuration parameters under specified config_env.
"""
resolved_config: dict = {}
# Use default sam config file name if config_path only contain the directory
config_file_path = (
Path(os.path.abspath(config_path))
if config_path
else Path(os.getcwd(), SamConfig.get_default_file(os.getcwd()))
)
config_file_name = config_file_path.name
config_file_dir = config_file_path.parents[0]
samconfig = SamConfig(config_file_dir, config_file_name)
# Enable debug level logging by environment variable "SAM_DEBUG"
if os.environ.get("SAM_DEBUG", "").lower() == "true":
LOG.setLevel(logging.DEBUG)
LOG.debug("Config file location: %s", samconfig.path())
if not samconfig.exists():
LOG.debug("Config file '%s' does not exist", samconfig.path())
return resolved_config
if not self.cmd_names:
self.cmd_names = cmd_names
try:
LOG.debug(
"Loading configuration values from [%s.%s.%s] (env.command_name.section) in config file at '%s'...",
config_env,
self.cmd_names,
self.section,
samconfig.path(),
)
# NOTE(TheSriram): change from tomlkit table type to normal dictionary,
# so that click defaults work out of the box.
resolved_config = dict(samconfig.get_all(self.cmd_names, self.section, env=config_env).items())
handle_parse_options(resolved_config)
LOG.debug("Configuration values successfully loaded.")
LOG.debug("Configuration values are: %s", resolved_config)
except KeyError as ex:
LOG.debug(
"Error reading configuration from [%s.%s.%s] (env.command_name.section) "
"in configuration file at '%s' with : %s",
config_env,
self.cmd_names,
self.section,
samconfig.path(),
str(ex),
)
except Exception as ex:
LOG.debug("Error reading configuration file: %s %s", samconfig.path(), str(ex))
raise ConfigException(f"Error reading configuration: {ex}") from ex
return resolved_config
def handle_parse_options(resolved_config: dict) -> None:
"""
Click does some handling of options to convert them to the intended types.
When injecting the options to click through a samconfig, we should do a similar
parsing of the options to ensure we pass the intended type.
E.g. if multiple is defined in the click option but only a single value is passed,
handle it the same way click does by converting it to a list first.
Mutates the resolved_config object
Parameters
----------
resolved_config: dict
Configuration options extracted from the configuration file
"""
options_map = get_options_map()
for config_name, config_value in resolved_config.items():
if config_name in options_map:
try:
allow_multiple = options_map[config_name].multiple
if allow_multiple and not isinstance(config_value, list):
resolved_config[config_name] = [config_value]
LOG.debug(
f"Adjusting value of {config_name} to be a list "
f"since this option is defined with 'multiple=True'"
)
except (AttributeError, KeyError):
LOG.debug(f"Unable to parse option: {config_name}. Leaving option as inputted")
def get_options_map() -> dict:
"""
Attempt to get all of the options that exist for a command.
Return a mapping from each option name to that options' properties.
Returns
-------
dict
Dict of command options if successful, None otherwise
"""
try:
command_options = click.get_current_context().command.params
return {command_option.name: command_option for command_option in command_options}
except AttributeError:
LOG.debug("Unable to get parameters from click context.")
return {}
def configuration_callback(
cmd_name: str,
option_name: str,
saved_callback: Optional[Callable],
provider: Callable,
ctx: click.Context,
param: click.Parameter,
value,
):
"""
Callback for reading the config file.
Also takes care of calling user specified custom callback afterwards.
Parameters
----------
cmd_name: str
The `sam` command name derived from click.
option_name: str
The name of the option. This is used for error messages.
saved_callback: Optional[Callable]
User-specified callback to be called later.
provider: Callable
A callable that parses the configuration file and returns a dictionary
of the configuration parameters. Will be called as
`provider(file_path, config_env, cmd_name)`.
ctx: click.Context
Click context
param: click.Parameter
Click parameter
value
Specified value for config_env
Returns
-------
The specified callback or the specified value for config_env.
"""
# ctx, param and value are default arguments for click specified callbacks.
ctx.default_map = ctx.default_map or {}
cmd_name = cmd_name or str(ctx.info_name)
param.default = None
config_env_name = ctx.params.get("config_env") or DEFAULT_ENV
config_dir = getattr(ctx, "samconfig_dir", None) or os.getcwd()
config_file = ( # If given by default, check for other `samconfig` extensions first. Else use user-provided value
SamConfig.get_default_file(config_dir=config_dir)
if getattr(ctx.get_parameter_source("config_file"), "name", "") == ParameterSource.DEFAULT.name
else ctx.params.get("config_file") or SamConfig.get_default_file(config_dir=config_dir)
)
# If --config-file is an absolute path, use it, if not, start from config_dir
config_file_path = config_file if os.path.isabs(config_file) else os.path.join(config_dir, config_file)
if (
config_file
and config_file != DEFAULT_CONFIG_FILE_NAME
and not (Path(config_file_path).absolute().is_file() or Path(config_file_path).absolute().is_fifo())
):
error_msg = f"Config file {config_file} does not exist or could not be read!"
LOG.debug(error_msg)
raise ConfigException(error_msg)
config = get_ctx_defaults(
cmd_name,
provider,
ctx,
config_env_name=config_env_name,
config_file=config_file_path,
)
ctx.default_map.update(config)
return saved_callback(ctx, param, config_env_name) if saved_callback else config_env_name
def get_ctx_defaults(
cmd_name: str, provider: Callable, ctx: click.Context, config_env_name: str, config_file: Optional[str] = None
) -> Any:
"""
Get the set of the parameters that are needed to be set into the click command.
This function also figures out the command name by looking up current click context's parent
and constructing the parsed command name that is used in default configuration file.
If a given cmd_name is start-api, the parsed name is "local_start_api".
provider is called with `config_file`, `config_env_name` and `parsed_cmd_name`.
Parameters
----------
cmd_name: str
The `sam` command name.
provider: Callable
The provider to be called for reading configuration file.
ctx: click.Context
Click context
config_env_name: str
The config-env within configuration file, sam configuration file will be relative to the
supplied original template if its path is not specified.
config_file: Optional[str]
The configuration file name.
Returns
-------
Any
A dictionary of defaults for parameters.
"""
return provider(config_file, config_env_name, get_cmd_names(cmd_name, ctx))
def save_command_line_args_to_config(
ctx: click.Context, cmd_names: List[str], config_env_name: str, config_file: SamConfig
):
"""Save the provided command line arguments to the provided config file.
Parameters
----------
ctx: click.Context
Click context of the current session.
cmd_names: List[str]
List of representing the entire command. Ex: ["local", "generate-event", "s3", "put"]
config_env_name: str
Name of the config environment the command is being executed under. It will also serve as the environment that
the parameters are saved under.
config_file: SamConfig
Object representing the SamConfig file being read for this execution. It will also be the file to which the
parameters will be written.
"""
if not ctx.params.get("save_params", False):
return # only save params if flag is set
params_to_exclude = [
"save_params", # don't save the provided save-params
"config_file", # don't save config specs to prevent confusion
"config_env",
]
saved_params = {}
for param_name, param_source in ctx._parameter_source.items():
if param_name in params_to_exclude:
continue # don't save certain params
if param_source != ParameterSource.COMMANDLINE:
continue # only parse params retrieved through CLI
# if param was passed via command line, save to file
param_value = ctx.params.get(param_name, None)
if param_value is None:
LOG.debug(f"Parameter {param_name} was not saved, as its value is None.")
continue # no value given, ignore
config_file.put(cmd_names, "parameters", param_name, param_value, config_env_name)
saved_params.update({param_name: param_value})
config_file.flush()
LOG.info(
f"Saved parameters to config file '{config_file.filepath.name}' "
f"under environment '{config_env_name}': {saved_params}"
)
def save_params(func):
"""Decorator for saving provided parameters to a config file, if the flag is set."""
def wrapper(*args, **kwargs):
ctx = click.get_current_context()
cmd_names = get_cmd_names(ctx.info_name, ctx)
save_command_line_args_to_config(
ctx=ctx,
cmd_names=cmd_names,
config_env_name=ctx.params.get("config_env", None),
config_file=SamConfig(getattr(ctx, "samconfig_dir", os.getcwd()), ctx.params.get("config_file", None)),
)
return func(*args, **kwargs)
return wrapper
def save_params_option(func):
"""Composite decorator to add --save-params flag to a command.
When used, this command should be placed as the LAST of the click option/argument decorators to preserve the flow
of execution. The decorator itself will add the --save-params option, and, if provided, save the provided commands
from the terminal to the config file.
"""
return click.option(
"--save-params",
is_flag=True,
help="Save the parameters provided via the command line to the configuration file.",
)(save_params(func))
def configuration_option(*param_decls, **attrs):
# pylint does not understand the docstring with the presence of **attrs
# pylint: disable=missing-param-doc,differing-param-doc
"""
Adds configuration file support to a click application.
This will create a hidden click option whose callback function loads configuration parameters from default
configuration environment [default] in default configuration file [samconfig.toml] in the template file
directory.
Note
----
This decorator should be added to the top of parameter chain, right below click.command, before
any options are declared.
Example
-------
>>> @click.command("hello")
@configuration_option(provider=ConfigProvider(section="parameters"))
@click.option('--name', type=click.String)
def hello(name):
print("Hello " + name)
Parameters
----------
preconfig_decorator_list: list
A list of click option decorator which need to place before this function. For
example, if we want to add option "--config-file" and "--config-env" to allow customized configuration file
and configuration environment, we will use configuration_option as below:
@configuration_option(
preconfig_decorator_list=[decorator_customize_config_file, decorator_customize_config_env],
provider=ConfigProvider(section=CONFIG_SECTION),
)
By default, we enable these two options.
provider: Callable
A callable that parses the configuration file and returns a dictionary
of the configuration parameters. Will be called as
`provider(file_path, config_env, cmd_name)`
"""
def decorator_configuration_setup(f):
configuration_setup_params = ()
configuration_setup_attrs = {}
configuration_setup_attrs["help"] = (
"This is a hidden click option whose callback function loads configuration parameters."
)
configuration_setup_attrs["is_eager"] = True
configuration_setup_attrs["expose_value"] = False
configuration_setup_attrs["hidden"] = True
configuration_setup_attrs["type"] = click.STRING
provider = attrs.pop("provider")
saved_callback = attrs.pop("callback", None)
partial_callback = functools.partial(configuration_callback, None, None, saved_callback, provider)
configuration_setup_attrs["callback"] = partial_callback
return click.option(*configuration_setup_params, **configuration_setup_attrs)(f)
def composed_decorator(decorators):
def decorator(f):
for deco in decorators:
f = deco(f)
return f
return decorator
# Compose decorators here to make sure the context parameters are updated before callback function
decorator_list = [decorator_configuration_setup]
pre_config_decorators = attrs.pop(
"preconfig_decorator_list", [decorator_customize_config_file, decorator_customize_config_env]
)
for decorator in pre_config_decorators:
decorator_list.append(decorator)
return composed_decorator(decorator_list)
def decorator_customize_config_file(f: Callable) -> Callable:
"""
CLI option to customize configuration file name. By default it is 'samconfig.toml' in project directory.
Ex: --config-file samconfig.toml
Parameters
----------
f: Callable
Callback function passed by Click
Returns
-------
Callable
A Callback function
"""
config_file_attrs: Dict[str, Any] = {}
config_file_param_decls = ("--config-file",)
config_file_attrs["help"] = "Configuration file containing default parameter values."
config_file_attrs["default"] = DEFAULT_CONFIG_FILE_NAME
config_file_attrs["show_default"] = True
config_file_attrs["is_eager"] = True
config_file_attrs["required"] = False
config_file_attrs["type"] = click.STRING
return click.option(*config_file_param_decls, **config_file_attrs)(f)
def decorator_customize_config_env(f: Callable) -> Callable:
"""
CLI option to customize configuration environment name. By default it is 'default'.
Ex: --config-env default
Parameters
----------
f: Callable
Callback function passed by Click
Returns
-------
Callable
A Callback function
"""
config_env_attrs: Dict[str, Any] = {}
config_env_param_decls = ("--config-env",)
config_env_attrs["help"] = "Environment name specifying default parameter values in the configuration file."
config_env_attrs["default"] = DEFAULT_ENV
config_env_attrs["show_default"] = True
config_env_attrs["is_eager"] = True
config_env_attrs["required"] = False
config_env_attrs["type"] = click.STRING
return click.option(*config_env_param_decls, **config_env_attrs)(f)
# End section copied from
# [click_config_file](https://github.com/phha/click_config_file/blob/master/click_config_file.py)