diff --git a/requirements/base.txt b/requirements/base.txt index f6ad4daebe..abdd9fd505 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -13,4 +13,5 @@ requests==2.22.0 serverlessrepo==0.1.9 aws_lambda_builders==0.5.0 # https://github.com/mhammond/pywin32/issues/1439 -pywin32 < 226; sys_platform == 'win32' \ No newline at end of file +pywin32 < 226; sys_platform == 'win32' +toml==0.10.0 \ No newline at end of file diff --git a/requirements/isolated.txt b/requirements/isolated.txt index 0dbc04bb8e..f04fc7aa6f 100644 --- a/requirements/isolated.txt +++ b/requirements/isolated.txt @@ -32,6 +32,7 @@ requests==2.22.0 s3transfer==0.2.1 serverlessrepo==0.1.9 six==1.11.0 +toml==0.10.0 tzlocal==2.0.0 urllib3==1.25.3 websocket-client==0.56.0 diff --git a/samcli/cli/cli_config_file.py b/samcli/cli/cli_config_file.py new file mode 100644 index 0000000000..d4cb45aec3 --- /dev/null +++ b/samcli/cli/cli_config_file.py @@ -0,0 +1,188 @@ +""" +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 os +import logging + +import click +import toml + +__all__ = ("TomlProvider", "configuration_option", "get_ctx_defaults") + +LOG = logging.getLogger("samcli") +DEFAULT_CONFIG_FILE_NAME = "samconfig.toml" +DEFAULT_IDENTIFER = "default" + + +class TomlProvider: + """ + A parser for toml configuration files + :param cmd: sam command name as defined by click + :param section: section defined in the configuration file nested within `cmd` + """ + + def __init__(self, section=None): + self.section = section + + def __call__(self, file_path, config_env, cmd_name): + """ + 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`. + + :param file_path: The path to the configuration file + :param config_env: The name of the sectional config_env within configuration file. + :param cmd_name: sam command name as defined by click + :returns dictionary containing the configuration parameters under specified config_env + """ + resolved_config = {} + try: + config = toml.load(file_path) + except Exception as ex: + LOG.error("Error reading configuration file :%s %s", file_path, str(ex)) + return resolved_config + if self.section: + try: + resolved_config = self._get_config_env(config, config_env)[cmd_name][self.section] + except KeyError: + LOG.debug( + "Error reading configuration file at %s with config_env %s, command %s, section %s", + file_path, + config_env, + cmd_name, + self.section, + ) + return resolved_config + + def _get_config_env(self, config, config_env): + """ + + :param config: loaded TOML configuration file into dictionary representation + :param config_env: top level section defined within TOML configuration file + :return: + """ + return config.get(config_env, config.get(DEFAULT_IDENTIFER, {})) + + +def configuration_callback(cmd_name, option_name, config_env_name, saved_callback, provider, ctx, param, value): + """ + Callback for reading the config file. + + Also takes care of calling user specified custom callback afterwards. + + :param cmd_name: `sam` command name derived from click. + :param option_name: The name of the option. This is used for error messages. + :param config_env_name: `top` level section within configuration file + :param saved_callback: User-specified callback to be called later. + :param provider: 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)`. + :param ctx: Click context + :param param: Click parameter + :param value: Specified value for config_env + :returns 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 ctx.info_name + param.default = DEFAULT_IDENTIFER + config_env_name = value or config_env_name + config = get_ctx_defaults(cmd_name, provider, ctx, config_env_name=config_env_name) + ctx.default_map.update(config) + + return saved_callback(ctx, param, value) if saved_callback else value + + +def get_ctx_defaults(cmd_name, provider, ctx, config_env_name=DEFAULT_IDENTIFER): + """ + 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`. + + :param cmd_name: `sam` command name + :param provider: provider to be called for reading configuration file + :param ctx: Click context + :param config_env_name: config-env within configuration file + :return: dictionary of defaults for parameters + """ + + cwd = getattr(ctx, "config_path", None) + config_file = os.path.join(cwd if cwd else os.getcwd(), DEFAULT_CONFIG_FILE_NAME) + config = {} + if os.path.isfile(config_file): + LOG.debug("Config file location: %s", os.path.abspath(config_file)) + + # Find parent of current context + _parent = ctx.parent + _cmd_names = [] + # Need to find the total set of commands that current command is part of. + if cmd_name != ctx.info_name: + _cmd_names = [cmd_name] + _cmd_names.append(ctx.info_name) + # Go through all parents till a parent of a context exists. + while _parent.parent: + info_name = _parent.info_name + _cmd_names.append(info_name) + _parent = _parent.parent + + # construct a parsed name that is of the format: a_b_c_d + parsed_cmd_name = "_".join(reversed([cmd.replace("-", "_").replace(" ", "_") for cmd in _cmd_names])) + + config = provider(config_file, config_env_name, parsed_cmd_name) + + return config + + +def configuration_option(*param_decls, **attrs): + """ + Adds configuration file support to a click application. + + This will create an option of type `STRING` expecting the config_env in the + configuration file, by default this config_env is `default`. When specified, + the requisite portion of the configuration file is considered as the + source of truth. + + The default name of the option is `--config-env`. + + This decorator accepts the same arguments as `click.option`. + In addition, the following keyword arguments are available: + :param cmd_name: The command name. Default: `ctx.info_name` + :param config_env_name: The config_env name. This is used to determine which part of the configuration + needs to be read. + :param provider: 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) + """ + param_decls = param_decls or ("--config-env",) + option_name = param_decls[0] + + def decorator(f): + + attrs.setdefault("is_eager", True) + attrs.setdefault("help", "Read config-env from Configuration File.") + attrs.setdefault("expose_value", False) + # --config-env is hidden and can potentially be opened up in the future. + attrs.setdefault("hidden", True) + # explicitly ignore values passed to --config-env, can be opened up in the future. + config_env_name = DEFAULT_IDENTIFER + provider = attrs.pop("provider") + attrs["type"] = click.STRING + saved_callback = attrs.pop("callback", None) + partial_callback = functools.partial( + configuration_callback, None, option_name, config_env_name, saved_callback, provider + ) + attrs["callback"] = partial_callback + return click.option(*param_decls, **attrs)(f) + + return decorator + + +# End section copied from [[click_config_file][https://github.com/phha/click_config_file/blob/master/click_config_file.py] diff --git a/samcli/cli/types.py b/samcli/cli/types.py index e22b2fec26..58ba341ba6 100644 --- a/samcli/cli/types.py +++ b/samcli/cli/types.py @@ -18,9 +18,17 @@ class CfnParameterOverridesType(click.ParamType): __EXAMPLE_1 = "ParameterKey=KeyPairName,ParameterValue=MyKey ParameterKey=InstanceType,ParameterValue=t1.micro" __EXAMPLE_2 = "KeyPairName=MyKey InstanceType=t1.micro" - # Regex that parses CloudFormation parameter key-value pairs: https://regex101.com/r/xqfSjW/2 - _pattern_1 = r"(?:ParameterKey=([A-Za-z0-9\"]+),ParameterValue=(\"(?:\\.|[^\"\\]+)*\"|(?:\\.|[^ \"\\]+)+))" - _pattern_2 = r"(?:([A-Za-z0-9\"]+)=(\"(?:\\.|[^\"\\]+)*\"|(?:\\.|[^ \"\\]+)+))" + # Regex that parses CloudFormation parameter key-value pairs: + # https://regex101.com/r/xqfSjW/2 + # https://regex101.com/r/xqfSjW/5 + + # If Both ParameterKey pattern and KeyPairName=MyKey, should not be fixed. if they are it can + # result in unpredicatable behavior. + KEY_REGEX = '([A-Za-z0-9\\"]+)' + VALUE_REGEX = '(\\"(?:\\\\.|[^\\"\\\\]+)*\\"|(?:\\\\.|[^ \\"\\\\]+)+))' + + _pattern_1 = r"(?:ParameterKey={key},ParameterValue={value}".format(key=KEY_REGEX, value=VALUE_REGEX) + _pattern_2 = r"(?:(?: ){key}={value}".format(key=KEY_REGEX, value=VALUE_REGEX) ordered_pattern_match = [_pattern_1, _pattern_2] @@ -34,7 +42,11 @@ def convert(self, value, param, ctx): if value == ("",): return result + value = (value,) if isinstance(value, str) else value for val in value: + val.strip() + # Add empty string to start of the string to help match `_pattern2` + val = " " + val try: # NOTE(TheSriram): find the first regex that matched. @@ -159,6 +171,9 @@ def convert(self, value, param, ctx): if value == ("",): return result + # if value comes in a via configuration file, we should still convert it. + # value = (value, ) if not isinstance(value, tuple) else value + for val in value: groups = re.findall(self._pattern, val) diff --git a/samcli/commands/_utils/options.py b/samcli/commands/_utils/options.py index 71152834d4..bc8699779c 100644 --- a/samcli/commands/_utils/options.py +++ b/samcli/commands/_utils/options.py @@ -7,6 +7,7 @@ from functools import partial import click +from click.types import FuncParamType from samcli.cli.types import CfnParameterOverridesType, CfnMetadataType, CfnTags from samcli.commands._utils.custom_options.option_nargs import OptionNargs @@ -43,8 +44,10 @@ def get_or_default_template_file_name(ctx, param, provided_value, include_build) if os.path.exists(option): provided_value = option break - result = os.path.abspath(provided_value) + + if ctx: + setattr(ctx, "config_path", os.path.dirname(result)) LOG.debug("Using SAM Template at %s", result) return result @@ -74,6 +77,7 @@ def template_click_option(include_build=True): Click Option for template option """ return click.option( + "--template-file", "--template", "-t", default=_TEMPLATE_OPTION_DEFAULT_VALUE, @@ -81,6 +85,7 @@ def template_click_option(include_build=True): envvar="SAM_TEMPLATE_FILE", callback=partial(get_or_default_template_file_name, include_build=include_build), show_default=True, + is_eager=True, help="AWS SAM template file", ) @@ -143,7 +148,7 @@ def capabilities_click_option(): return click.option( "--capabilities", cls=OptionNargs, - type=click.STRING, + type=FuncParamType(lambda value: value.split(" ")), required=True, help="A list of capabilities that you must specify" "before AWS Cloudformation can create certain stacks. Some stack tem-" @@ -182,7 +187,7 @@ def notification_arns_click_option(): return click.option( "--notification-arns", cls=OptionNargs, - type=click.STRING, + type=FuncParamType(lambda value: value.split(" ")), required=False, help="Amazon Simple Notification Service topic" "Amazon Resource Names (ARNs) that AWS CloudFormation associates with" diff --git a/samcli/commands/build/command.py b/samcli/commands/build/command.py index b6f9c67f9f..31507a2f32 100644 --- a/samcli/commands/build/command.py +++ b/samcli/commands/build/command.py @@ -10,6 +10,7 @@ parameter_override_option from samcli.cli.main import pass_context, common_options as cli_framework_options, aws_creds_options from samcli.lib.telemetry.metrics import track_command +from samcli.cli.cli_config_file import configuration_option, TomlProvider LOG = logging.getLogger(__name__) @@ -53,6 +54,7 @@ """ +@configuration_option(provider=TomlProvider(section="parameters")) @click.command("build", help=HELP_TEXT, short_help="Build your Lambda function code") @click.option('--build-dir', '-b', default=DEFAULT_BUILD_DIR, @@ -82,7 +84,7 @@ @track_command def cli(ctx, function_identifier, - template, + template_file, base_dir, build_dir, use_container, @@ -95,7 +97,7 @@ def cli(ctx, mode = _get_mode_value_from_envvar("SAM_BUILD_MODE", choices=["debug"]) - do_cli(function_identifier, template, base_dir, build_dir, True, use_container, manifest, docker_network, + do_cli(function_identifier, template_file, base_dir, build_dir, True, use_container, manifest, docker_network, skip_pull_image, parameter_overrides, mode) # pragma: no cover diff --git a/samcli/commands/deploy/command.py b/samcli/commands/deploy/command.py index 68314c48b3..e31a336f5d 100644 --- a/samcli/commands/deploy/command.py +++ b/samcli/commands/deploy/command.py @@ -4,12 +4,13 @@ import click - +from samcli.cli.cli_config_file import configuration_option, TomlProvider from samcli.commands._utils.options import ( parameter_override_option, capabilities_override_option, tags_override_option, notification_arns_override_option, + template_click_option, ) from samcli.cli.main import pass_context, common_options, aws_creds_options from samcli.lib.telemetry.metrics import track_command @@ -27,20 +28,14 @@ """ +@configuration_option(provider=TomlProvider(section="parameters")) @click.command( "deploy", short_help=SHORT_HELP, context_settings={"ignore_unknown_options": False, "allow_interspersed_args": True, "allow_extra_args": True}, help=HELP_TEXT, ) -@click.option( - "--template-file", - "--template", - "-t", - required=True, - type=click.Path(), - help="The path where your AWS SAM template is located", -) +@template_click_option(include_build=False) @click.option( "--stack-name", required=True, diff --git a/samcli/commands/init/__init__.py b/samcli/commands/init/__init__.py index 23c4537fb9..3ce1955da9 100644 --- a/samcli/commands/init/__init__.py +++ b/samcli/commands/init/__init__.py @@ -8,6 +8,7 @@ import click +from samcli.cli.cli_config_file import configuration_option, TomlProvider from samcli.commands.exceptions import UserException from samcli.cli.main import pass_context, common_options, global_cfg from samcli.local.common.runtime_template import RUNTIMES, SUPPORTED_DEP_MANAGERS @@ -55,6 +56,7 @@ """ +@configuration_option(provider=TomlProvider(section="parameters")) @click.command( "init", help=HELP_TEXT, diff --git a/samcli/commands/local/generate_event/event_generation.py b/samcli/commands/local/generate_event/event_generation.py index 12379c5892..e45ea2cd43 100644 --- a/samcli/commands/local/generate_event/event_generation.py +++ b/samcli/commands/local/generate_event/event_generation.py @@ -3,10 +3,12 @@ """ import functools + import click -from samcli.cli.options import debug_option import samcli.commands.local.lib.generated_sample_events.events as events +from samcli.cli.cli_config_file import TomlProvider, get_ctx_defaults +from samcli.cli.options import debug_option from samcli.lib.telemetry.metrics import track_command @@ -150,9 +152,13 @@ def get_command(self, ctx, cmd_name): command_callback = functools.partial( self.cmd_implementation, self.events_lib, self.top_level_cmd_name, cmd_name ) + + config = get_ctx_defaults(cmd_name=cmd_name, provider=TomlProvider(section="parameters"), ctx=ctx) + cmd = click.Command( name=cmd_name, short_help=self.subcmd_definition[cmd_name]["help"], + context_settings={"default_map": config}, params=parameters, callback=command_callback, ) diff --git a/samcli/commands/local/invoke/cli.py b/samcli/commands/local/invoke/cli.py index 311420b9a7..15705935cb 100644 --- a/samcli/commands/local/invoke/cli.py +++ b/samcli/commands/local/invoke/cli.py @@ -8,6 +8,7 @@ from samcli.cli.main import pass_context, common_options as cli_framework_options, aws_creds_options from samcli.commands.local.cli_common.options import invoke_common_options from samcli.lib.telemetry.metrics import track_command +from samcli.cli.cli_config_file import configuration_option, TomlProvider LOG = logging.getLogger(__name__) @@ -30,6 +31,7 @@ @click.command("invoke", help=HELP_TEXT, short_help="Invokes a local Lambda function once.") +@configuration_option(provider=TomlProvider(section="parameters")) @click.option( "--event", "-e", @@ -47,7 +49,7 @@ def cli( ctx, function_identifier, - template, + template_file, event, no_event, env_vars, @@ -68,7 +70,7 @@ def cli( do_cli( ctx, function_identifier, - template, + template_file, event, no_event, env_vars, diff --git a/samcli/commands/local/start_api/cli.py b/samcli/commands/local/start_api/cli.py index 83f699e33b..bfb7447fbe 100644 --- a/samcli/commands/local/start_api/cli.py +++ b/samcli/commands/local/start_api/cli.py @@ -8,6 +8,7 @@ from samcli.cli.main import pass_context, common_options as cli_framework_options, aws_creds_options from samcli.commands.local.cli_common.options import invoke_common_options, service_common_options from samcli.lib.telemetry.metrics import track_command +from samcli.cli.cli_config_file import configuration_option, TomlProvider LOG = logging.getLogger(__name__) @@ -31,6 +32,7 @@ short_help="Sets up a local endpoint you can use to test your API. Supports hot-reloading " "so you don't need to restart this service when you make changes to your function.", ) +@configuration_option(provider=TomlProvider(section="parameters")) @service_common_options(3000) @click.option( "--static-dir", @@ -50,7 +52,7 @@ def cli( port, static_dir, # Common Options for Lambda Invoke - template, + template_file, env_vars, debug_port, debug_args, @@ -70,7 +72,7 @@ def cli( host, port, static_dir, - template, + template_file, env_vars, debug_port, debug_args, diff --git a/samcli/commands/local/start_lambda/cli.py b/samcli/commands/local/start_lambda/cli.py index b607febe2e..1e171fa5e7 100644 --- a/samcli/commands/local/start_lambda/cli.py +++ b/samcli/commands/local/start_lambda/cli.py @@ -8,6 +8,7 @@ from samcli.cli.main import pass_context, common_options as cli_framework_options, aws_creds_options from samcli.commands.local.cli_common.options import invoke_common_options, service_common_options from samcli.lib.telemetry.metrics import track_command +from samcli.cli.cli_config_file import configuration_option, TomlProvider LOG = logging.getLogger(__name__) @@ -49,6 +50,7 @@ help=HELP_TEXT, short_help="Starts a local endpoint you can use to invoke your local Lambda functions.", ) +@configuration_option(provider=TomlProvider(section="parameters")) @service_common_options(3001) @invoke_common_options @cli_framework_options @@ -61,7 +63,7 @@ def cli( host, port, # Common Options for Lambda Invoke - template, + template_file, env_vars, debug_port, debug_args, @@ -80,7 +82,7 @@ def cli( ctx, host, port, - template, + template_file, env_vars, debug_port, debug_args, diff --git a/samcli/commands/logs/command.py b/samcli/commands/logs/command.py index f6b479cb46..9dd1f6620f 100644 --- a/samcli/commands/logs/command.py +++ b/samcli/commands/logs/command.py @@ -7,6 +7,7 @@ from samcli.cli.main import pass_context, common_options as cli_framework_options, aws_creds_options from samcli.lib.telemetry.metrics import track_command +from samcli.cli.cli_config_file import configuration_option, TomlProvider LOG = logging.getLogger(__name__) @@ -32,6 +33,7 @@ @click.command("logs", help=HELP_TEXT, short_help="Fetch logs for a function") +@configuration_option(provider=TomlProvider(section="parameters")) @click.option( "--name", "-n", diff --git a/samcli/commands/package/command.py b/samcli/commands/package/command.py index 3966bd2460..5d6e289aba 100644 --- a/samcli/commands/package/command.py +++ b/samcli/commands/package/command.py @@ -5,11 +5,13 @@ import click +from samcli.cli.cli_config_file import TomlProvider, configuration_option from samcli.cli.main import pass_context, common_options, aws_creds_options from samcli.commands._utils.options import ( metadata_override_option, _TEMPLATE_OPTION_DEFAULT_VALUE, get_or_default_template_file_name, + template_click_option, ) from samcli.commands._utils.resources import resources_generator from samcli.lib.telemetry.metrics import track_command @@ -40,18 +42,8 @@ def resources_and_properties_help_string(): @click.command("package", short_help=SHORT_HELP, help=HELP_TEXT, context_settings=dict(max_content_width=120)) -# TODO(TheSriram): Move to template_common_option across aws-sam-cli -@click.option( - "--template", - "--template-file", - "-t", - default=_TEMPLATE_OPTION_DEFAULT_VALUE, - type=click.Path(), - envvar="SAM_TEMPLATE_FILE", - callback=partial(get_or_default_template_file_name, include_build=True), - show_default=True, - help="AWS SAM template file", -) +@configuration_option(provider=TomlProvider(section="parameters")) +@template_click_option(include_build=True) @click.option( "--s3-bucket", required=True, @@ -97,12 +89,12 @@ def resources_and_properties_help_string(): @aws_creds_options @pass_context @track_command -def cli(ctx, template, s3_bucket, s3_prefix, kms_key_id, output_template_file, use_json, force_upload, metadata): +def cli(ctx, template_file, s3_bucket, s3_prefix, kms_key_id, output_template_file, use_json, force_upload, metadata): # All logic must be implemented in the ``do_cli`` method. This helps with easy unit testing do_cli( - template, + template_file, s3_bucket, s3_prefix, kms_key_id, diff --git a/samcli/commands/publish/command.py b/samcli/commands/publish/command.py index 643895327e..54802aec5c 100644 --- a/samcli/commands/publish/command.py +++ b/samcli/commands/publish/command.py @@ -11,6 +11,7 @@ from samcli.commands._utils.options import template_common_option from samcli.commands._utils.template import get_template_data from samcli.lib.telemetry.metrics import track_command +from samcli.cli.cli_config_file import configuration_option, TomlProvider LOG = logging.getLogger(__name__) @@ -40,16 +41,17 @@ @click.command("publish", help=HELP_TEXT, short_help=SHORT_HELP) +@configuration_option(provider=TomlProvider(section="parameters")) @template_common_option @click.option("--semantic-version", help=SEMANTIC_VERSION_HELP) @aws_creds_options @cli_framework_options @pass_context @track_command -def cli(ctx, template, semantic_version): +def cli(ctx, template_file, semantic_version): # All logic must be implemented in the ``do_cli`` method. This helps with easy unit testing - do_cli(ctx, template, semantic_version) # pragma: no cover + do_cli(ctx, template_file, semantic_version) # pragma: no cover def do_cli(ctx, template, semantic_version): diff --git a/samcli/commands/validate/validate.py b/samcli/commands/validate/validate.py index 4b554b0686..ab908ea2e9 100644 --- a/samcli/commands/validate/validate.py +++ b/samcli/commands/validate/validate.py @@ -10,19 +10,21 @@ from samcli.cli.main import pass_context, common_options as cli_framework_options, aws_creds_options from samcli.commands._utils.options import template_option_without_build from samcli.lib.telemetry.metrics import track_command +from samcli.cli.cli_config_file import configuration_option, TomlProvider @click.command("validate", short_help="Validate an AWS SAM template.") +@configuration_option(provider=TomlProvider(section="parameters")) @template_option_without_build @aws_creds_options @cli_framework_options @pass_context @track_command -def cli(ctx, template): +def cli(ctx, template_file): # All logic must be implemented in the ``do_cli`` method. This helps with easy unit testing - do_cli(ctx, template) # pragma: no cover + do_cli(ctx, template_file) # pragma: no cover def do_cli(ctx, template): diff --git a/tests/unit/cli/test_cli_config_file.py b/tests/unit/cli/test_cli_config_file.py new file mode 100644 index 0000000000..d3764d1365 --- /dev/null +++ b/tests/unit/cli/test_cli_config_file.py @@ -0,0 +1,140 @@ +import os +import tempfile + +from unittest import TestCase +from unittest.mock import MagicMock, patch + + +from samcli.cli.cli_config_file import ( + TomlProvider, + configuration_option, + configuration_callback, + get_ctx_defaults, + DEFAULT_CONFIG_FILE_NAME, +) + + +class MockContext: + def __init__(self, info_name, parent): + self.info_name = info_name + self.parent = parent + + +class TestTomlProvider(TestCase): + def setUp(self): + self.toml_provider = TomlProvider() + self.config_env = "config_env" + self.parameters = "parameters" + self.cmd_name = "topic" + + def test_toml_valid_with_section(self): + with tempfile.NamedTemporaryFile(delete=False) as toml_file: + toml_file.write(b"[config_env.topic.parameters]\nword='clarity'\n") + toml_file.flush() + self.assertEqual( + TomlProvider(section=self.parameters)(toml_file.name, self.config_env, self.cmd_name), + {"word": "clarity"}, + ) + + def test_toml_invalid_empty_dict(self): + with tempfile.NamedTemporaryFile(delete=False) as toml_file: + toml_file.write(b"[topic]\nword=clarity\n") + toml_file.flush() + self.assertEqual(self.toml_provider(toml_file.name, self.config_env, self.cmd_name), {}) + + +class TestCliConfiguration(TestCase): + def setUp(self): + self.cmd_name = "test_cmd" + self.option_name = "test_option" + self.config_env = "test_config_env" + self.saved_callback = MagicMock() + self.provider = MagicMock() + self.ctx = MagicMock() + self.param = MagicMock() + self.value = MagicMock() + + class Dummy: + pass + + @patch("samcli.cli.cli_config_file.os.path.isfile", return_value=True) + @patch("samcli.cli.cli_config_file.os.path.join", return_value=MagicMock()) + @patch("samcli.cli.cli_config_file.os.path.abspath", return_value=MagicMock()) + def test_callback_with_valid_config_env(self, mock_os_path_is_file, mock_os_path_join, mock_os_path_abspath): + mock_context1 = MockContext(info_name="sam", parent=None) + mock_context2 = MockContext(info_name="local", parent=mock_context1) + mock_context3 = MockContext(info_name="start-api", parent=mock_context2) + self.ctx.parent = mock_context3 + self.ctx.info_name = "test_info" + configuration_callback( + cmd_name=self.cmd_name, + option_name=self.option_name, + config_env_name=self.config_env, + saved_callback=self.saved_callback, + provider=self.provider, + ctx=self.ctx, + param=self.param, + value=self.value, + ) + self.assertEqual(self.saved_callback.call_count, 1) + for arg in [self.ctx, self.param, self.value]: + self.assertIn(arg, self.saved_callback.call_args[0]) + + @patch("samcli.cli.cli_config_file.os.path.isfile", return_value=False) + @patch("samcli.cli.cli_config_file.os.path.join", return_value=MagicMock()) + def test_callback_with_config_file_not_file(self, mock_os_isfile, mock_os_path_join): + configuration_callback( + cmd_name=self.cmd_name, + option_name=self.option_name, + config_env_name=self.config_env, + saved_callback=self.saved_callback, + provider=self.provider, + ctx=self.ctx, + param=self.param, + value=self.value, + ) + self.assertEqual(self.provider.call_count, 0) + self.assertEqual(self.saved_callback.call_count, 1) + for arg in [self.ctx, self.param, self.value]: + self.assertIn(arg, self.saved_callback.call_args[0]) + self.assertEqual(mock_os_isfile.call_count, 1) + self.assertEqual(mock_os_path_join.call_count, 1) + + def test_configuration_option(self): + toml_provider = TomlProvider() + click_option = configuration_option(provider=toml_provider) + clc = click_option(self.Dummy()) + self.assertEqual(clc.__click_params__[0].is_eager, True) + self.assertEqual(clc.__click_params__[0].help, "Read config-env from Configuration File.") + self.assertEqual(clc.__click_params__[0].hidden, True) + self.assertEqual(clc.__click_params__[0].expose_value, False) + self.assertEqual(clc.__click_params__[0].callback.args, (None, "--config-env", "default", None, toml_provider)) + + @patch("samcli.cli.cli_config_file.os.path.isfile", return_value=True) + def test_get_ctx_defaults_non_nested(self, mock_os_file): + provider = MagicMock() + + mock_context1 = MockContext(info_name="sam", parent=None) + mock_context2 = MockContext(info_name="local", parent=mock_context1) + mock_context3 = MockContext(info_name="start-api", parent=mock_context2) + + get_ctx_defaults("start-api", provider, mock_context3) + + provider.assert_called_with(os.path.join(os.getcwd(), DEFAULT_CONFIG_FILE_NAME), "default", "local_start_api") + + @patch("samcli.cli.cli_config_file.os.path.isfile", return_value=True) + def test_get_ctx_defaults_nested(self, mock_os_file): + provider = MagicMock() + + mock_context1 = MockContext(info_name="sam", parent=None) + mock_context2 = MockContext(info_name="local", parent=mock_context1) + mock_context3 = MockContext(info_name="generate-event", parent=mock_context2) + mock_context4 = MockContext(info_name="alexa-skills-kit", parent=mock_context3) + + get_ctx_defaults("intent-answer", provider, mock_context4) + + provider.assert_called_with( + os.path.join(os.getcwd(), DEFAULT_CONFIG_FILE_NAME), + "default", + "local_generate_event_alexa_skills_kit_intent_answer", + ) diff --git a/tests/unit/cli/test_types.py b/tests/unit/cli/test_types.py index 985c3e66c1..54f57af616 100644 --- a/tests/unit/cli/test_types.py +++ b/tests/unit/cli/test_types.py @@ -12,19 +12,12 @@ def setUp(self): @parameterized.expand( [ - (("some string"),), - # Key must not contain spaces - (('ParameterKey="Ke y",ParameterValue=Value'),), - # No value - (("ParameterKey=Key,ParameterValue="),), - # No key - (("ParameterKey=,ParameterValue=Value"),), - # Case sensitive - (("parameterkey=Key,ParameterValue=Value"),), - # No space after comma - (("ParameterKey=Key, ParameterValue=Value"),), + # Random string + ("some string",), + # Only commas + (",,",), # Bad separator - (("ParameterKey:Key,ParameterValue:Value"),), + ("ParameterKey:Key,ParameterValue:Value",), ] ) def test_must_fail_on_invalid_format(self, input): @@ -39,6 +32,10 @@ def test_must_fail_on_invalid_format(self, input): ("ParameterKey=KeyPairName,ParameterValue=MyKey ParameterKey=InstanceType,ParameterValue=t1.micro",), {"KeyPairName": "MyKey", "InstanceType": "t1.micro"}, ), + (("KeyPairName=MyKey InstanceType=t1.micro",), {"KeyPairName": "MyKey", "InstanceType": "t1.micro"}), + (("KeyPairName=MyKey, InstanceType=t1.micro,",), {"KeyPairName": "MyKey,", "InstanceType": "t1.micro,"}), + (('ParameterKey="Ke y",ParameterValue=Value',), {"ParameterKey": "Ke y"}), + ((("ParameterKey=Key,ParameterValue="),), {"ParameterKey": "Key,ParameterValue="}), (('ParameterKey="Key",ParameterValue=Val\\ ue',), {"Key": "Val ue"}), (('ParameterKey="Key",ParameterValue="Val\\"ue"',), {"Key": 'Val"ue'}), (("ParameterKey=Key,ParameterValue=Value",), {"Key": "Value"}), @@ -146,38 +143,3 @@ def test_must_fail_on_invalid_format(self, input): def test_successful_parsing(self, input, expected): result = self.param_type.convert(input, None, None) self.assertEqual(result, expected, msg="Failed with Input = " + str(input)) - - -# class TestCfnCapabilitiesType(TestCase): -# def setUp(self): -# self.param_type = CfnCapabilitiesType() -# -# @parameterized.expand( -# [ -# # Just a string -# ("some string"), -# # tuple of string -# ("some string",), -# # non-tuple valid string -# "CAPABILITY_NAMED_IAM", -# ] -# ) -# def test_must_fail_on_invalid_format(self, input): -# self.param_type.fail = Mock() -# self.param_type.convert(input, "param", "ctx") -# -# self.param_type.fail.assert_called_with(ANY, "param", "ctx") -# -# @parameterized.expand( -# [ -# (("CAPABILITY_AUTO_EXPAND",), ("CAPABILITY_AUTO_EXPAND",)), -# (("CAPABILITY_AUTO_EXPAND", "CAPABILITY_NAMED_IAM"), ("CAPABILITY_AUTO_EXPAND", "CAPABILITY_NAMED_IAM")), -# ( -# ("CAPABILITY_AUTO_EXPAND", "CAPABILITY_NAMED_IAM", "CAPABILITY_IAM"), -# ("CAPABILITY_AUTO_EXPAND", "CAPABILITY_NAMED_IAM", "CAPABILITY_IAM"), -# ), -# ] -# ) -# def test_successful_parsing(self, input, expected): -# result = self.param_type.convert(input, None, None) -# self.assertEqual(result, expected, msg="Failed with Input = " + str(input)) diff --git a/tests/unit/commands/_utils/test_options.py b/tests/unit/commands/_utils/test_options.py index 43276c824c..cbcd8b3911 100644 --- a/tests/unit/commands/_utils/test_options.py +++ b/tests/unit/commands/_utils/test_options.py @@ -9,6 +9,10 @@ from samcli.commands._utils.options import get_or_default_template_file_name, _TEMPLATE_OPTION_DEFAULT_VALUE +class Mock: + pass + + class TestGetOrDefaultTemplateFileName(TestCase): def test_must_return_abspath_of_user_provided_value(self): filename = "foo.txt" @@ -50,3 +54,20 @@ def test_must_return_built_template(self, os_mock): result = get_or_default_template_file_name(None, None, _TEMPLATE_OPTION_DEFAULT_VALUE, include_build=True) self.assertEqual(result, "absPath") os_mock.path.abspath.assert_called_with(expected) + + @patch("samcli.commands._utils.options.os") + def test_verify_ctx(self, os_mock): + + ctx = Mock() + + expected = os.path.join(".aws-sam", "build", "template.yaml") + + os_mock.path.exists.return_value = True + os_mock.path.join = os.path.join # Use the real method + os_mock.path.abspath.return_value = "a/b/c/absPath" + os_mock.path.dirname.return_value = "a/b/c" + + result = get_or_default_template_file_name(ctx, None, _TEMPLATE_OPTION_DEFAULT_VALUE, include_build=True) + self.assertEqual(result, "a/b/c/absPath") + self.assertEqual(ctx.config_path, "a/b/c") + os_mock.path.abspath.assert_called_with(expected) diff --git a/tests/unit/commands/local/generate_event/test_event_generation.py b/tests/unit/commands/local/generate_event/test_event_generation.py index d719fb45db..74e998ad52 100644 --- a/tests/unit/commands/local/generate_event/test_event_generation.py +++ b/tests/unit/commands/local/generate_event/test_event_generation.py @@ -124,7 +124,11 @@ def test_subcommand_get_command_return_value(self, click_mock, functools_mock, o s = EventTypeSubCommand(self.events_lib_mock, "hello", all_commands) s.get_command(None, "hi") click_mock.Command.assert_called_once_with( - name="hi", short_help="Generates a hello Event", params=[], callback=callback_object_mock + name="hi", + short_help="Generates a hello Event", + params=[], + callback=callback_object_mock, + context_settings={"default_map": {}}, ) def test_subcommand_list_return_value(self):