diff --git a/samcli/cli/cli_config_file.py b/samcli/cli/cli_config_file.py index 53a84913fd..9cbb04673c 100644 --- a/samcli/cli/cli_config_file.py +++ b/samcli/cli/cli_config_file.py @@ -16,6 +16,7 @@ from click.core import ParameterSource from samcli.cli.context import Context, get_cmd_names +from samcli.commands._utils.custom_options.structured_output_option import StructuredOutputOption from samcli.commands.exceptions import ConfigException from samcli.lib.config.samconfig import DEFAULT_CONFIG_FILE_NAME, DEFAULT_ENV, SamConfig from samcli.lib.utils.defaults import get_default_aws_region @@ -308,6 +309,14 @@ def save_command_line_args_to_config( "config_env", ] + # The shared structured output flag describes how a single run reports rather than what to + # build, so persisting it would change the output format of later runs, and for sam init it + # would make the interactive flow unreachable. Matched by option type, because sam list and + # sam remote invoke define an unrelated --output that is a display preference worth saving. + params_to_exclude += [ + param.name for param in ctx.command.params if isinstance(param, StructuredOutputOption) and param.name + ] + saved_params = {} for param_name, param_source in ctx._parameter_source.items(): if param_name in params_to_exclude: diff --git a/samcli/commands/_utils/constants.py b/samcli/commands/_utils/constants.py index e411142ad7..0e6ecfd1a8 100644 --- a/samcli/commands/_utils/constants.py +++ b/samcli/commands/_utils/constants.py @@ -9,3 +9,7 @@ DEFAULT_BUILD_DIR_WITH_AUTO_DEPENDENCY_LAYER = os.path.join(".aws-sam", "auto-dependency-layer") DEFAULT_CACHE_DIR = os.path.join(".aws-sam", "cache") DEFAULT_BUILT_TEMPLATE_PATH = os.path.join(".aws-sam", "build", "template.yaml") + +# Template file names SAM CLI recognises, in resolution order. Order matters, so that a template +# path reported by one command is the one another command would resolve to. +SAM_TEMPLATE_FILE_NAMES = ["template.yaml", "template.yml", "template.json"] diff --git a/samcli/commands/_utils/custom_options/structured_output_option.py b/samcli/commands/_utils/custom_options/structured_output_option.py new file mode 100644 index 0000000000..f9152dae2c --- /dev/null +++ b/samcli/commands/_utils/custom_options/structured_output_option.py @@ -0,0 +1,14 @@ +""" +Custom click option for the shared structured output flag +""" + +import click + + +class StructuredOutputOption(click.Option): + """Marks the shared --output option that selects structured (JSON) output. + + Exists so the option can be recognised by type rather than by name. Other commands, such as + sam list and sam remote invoke, have an unrelated --output that selects a display format and + is worth saving to a config file, while this one describes how a single run reports. + """ diff --git a/samcli/commands/_utils/options.py b/samcli/commands/_utils/options.py index 1253aae942..01de507b11 100644 --- a/samcli/commands/_utils/options.py +++ b/samcli/commands/_utils/options.py @@ -26,10 +26,12 @@ DEFAULT_BUILT_TEMPLATE_PATH, DEFAULT_CACHE_DIR, DEFAULT_STACK_NAME, + SAM_TEMPLATE_FILE_NAMES, ) from samcli.commands._utils.custom_options.hook_name_option import HookNameOption from samcli.commands._utils.custom_options.option_nargs import OptionNargs from samcli.commands._utils.custom_options.replace_help_option import ReplaceHelpSummaryOption +from samcli.commands._utils.custom_options.structured_output_option import StructuredOutputOption from samcli.commands._utils.parameterized_option import parameterized_option from samcli.commands._utils.template import TemplateNotFoundException, get_template_artifacts_format, get_template_data from samcli.lib.hook.hook_wrapper import get_available_hook_packages_ids @@ -65,7 +67,7 @@ def get_or_default_template_file_name(ctx, param, provided_value, include_build) original_template_path = os.path.abspath(provided_value) - search_paths = ["template.yaml", "template.yml", "template.json"] + search_paths = list(SAM_TEMPLATE_FILE_NAMES) if include_build: search_paths.insert(0, DEFAULT_BUILT_TEMPLATE_PATH) @@ -460,6 +462,8 @@ def structured_output_click_option(): "Supported formats: text (default), json.", # Derive choices from OutputOption so the accepted CLI values cannot drift from the enum. type=click.Choice([option.value for option in OutputOption], case_sensitive=False), + # Lets --save-params recognise this option by type, so it is not persisted. + cls=StructuredOutputOption, ) diff --git a/samcli/commands/init/command.py b/samcli/commands/init/command.py index cb5d4520b4..e8ce4f55dc 100644 --- a/samcli/commands/init/command.py +++ b/samcli/commands/init/command.py @@ -2,8 +2,12 @@ Init command to scaffold a project app from a template """ +import contextlib import json import logging +import os +import sys +import tempfile from json import JSONDecodeError import click @@ -11,6 +15,8 @@ from samcli.cli.cli_config_file import ConfigProvider, configuration_option, save_params_option from samcli.cli.main import common_options, pass_context, print_cmdline_args from samcli.commands._utils.click_mutex import ClickMutex +from samcli.commands._utils.constants import SAM_TEMPLATE_FILE_NAMES +from samcli.commands._utils.options import structured_output_option from samcli.commands.init.core.command import InitCommand from samcli.commands.init.init_flow_helpers import _get_runtime_from_image, get_architectures, get_sorted_runtimes from samcli.lib.build.constants import DEPRECATED_RUNTIMES @@ -34,15 +40,37 @@ please take a look at our official documentation. """ -INCOMPATIBLE_PARAMS_HINT = """You can run 'sam init' without any options for an interactive initialization flow, \ -or you can provide one of the following required parameter combinations: -\t--name, --location, or -\t--name, --package-type, --base-image, or -\t--name, --runtime, --app-template, --dependency-manager -""" +# The parameter combinations that identify a template without prompting. Enforced by +# --no-interactive below and rendered into the hints, so the guidance cannot drift from the check. +NON_INTERACTIVE_PARAM_COMBINATIONS = [ + ["name", "location"], + ["name", "package_type", "base_image"], + ["name", "runtime", "dependency_manager", "app_template"], +] + + +def _format_param_combinations(): + """Render the non-interactive parameter combinations as indented lists of CLI flags.""" + combinations = [ + "\t" + ", ".join(f"--{param.replace('_', '-')}" for param in combination) + for combination in NON_INTERACTIVE_PARAM_COMBINATIONS + ] + return ", or\n".join(combinations) + "\n" + + +INCOMPATIBLE_PARAMS_HINT = ( + "You can run 'sam init' without any options for an interactive initialization flow, " + "or you can provide one of the following required parameter combinations:\n" + _format_param_combinations() +) REQUIRED_PARAMS_HINT = "You can also re-run without the --no-interactive flag to be prompted for required values." +STRUCTURED_OUTPUT_PARAMS_HINT = ( + "--output json cannot be used with the interactive flow, which prompts for values that cannot " + "be answered when the output is being consumed by another program. Provide one of the " + "following parameter combinations instead:\n" + _format_param_combinations() +) + INIT_INTERACTIVE_OPTION_GUIDE = """ You can preselect a particular runtime or package type when using the `sam init` experience. Call `sam init --help` to learn more. @@ -121,12 +149,8 @@ def wrapped(*args, **kwargs): default=False, help="Disable interactive prompting for init parameters. (fail if any required values are missing)", cls=ClickMutex, - required_param_lists=[ - ["name", "location"], - ["name", "package_type", "base_image"], - ["name", "runtime", "dependency_manager", "app_template"], - # check non_interactive_validation for additional validations - ], + # check non_interactive_validation for additional validations + required_param_lists=NON_INTERACTIVE_PARAM_COMBINATIONS, required_params_hint=REQUIRED_PARAMS_HINT, ) @click.option( @@ -232,6 +256,7 @@ def wrapped(*args, **kwargs): default=None, help="Enable Structured Logging for application.", ) +@structured_output_option @common_options @save_params_option @non_interactive_validation @@ -256,6 +281,7 @@ def cli( tracing, application_insights, structured_logging, + output, save_params, config_file, config_env, @@ -281,6 +307,7 @@ def cli( tracing, application_insights, structured_logging, + output, ) # pragma: no cover @@ -303,6 +330,7 @@ def do_cli( tracing, application_insights, structured_logging, + output="text", ): """ Implementation of the ``cli`` method @@ -312,6 +340,9 @@ def do_cli( from samcli.commands.init.init_generator import do_generate from samcli.commands.init.init_templates import InitTemplates from samcli.commands.init.interactive_init_flow import do_interactive + from samcli.lib.observability.util import OutputOption, failure_result_json + + output_mode = OutputOption(output) _deprecate_notification(runtime) @@ -319,45 +350,103 @@ def do_cli( zip_bool = name and runtime and dependency_manager and app_template image_bool = name and pt_explicit and base_image if location or zip_bool or image_bool: - # need to turn app_template into a location before we generate - templates = InitTemplates() - if package_type == IMAGE and image_bool: - runtime = _get_runtime_from_image(base_image) - if runtime is None: - raise LambdaImagesTemplateException("Unable to infer the runtime from the base image name") - options = templates.init_options(package_type, runtime, base_image, dependency_manager) - if not app_template: - if len(options) == 1: - app_template = options[0].get("appTemplate") - elif len(options) > 1: - raise LambdaImagesTemplateException( - "Multiple lambda image application templates found. " - "Please specify one using the --app-template parameter." + try: + # Wraps template resolution as well as do_generate, so those failures are serialized too. + + # need to turn app_template into a location before we generate + templates = InitTemplates() + if package_type == IMAGE and image_bool: + runtime = _get_runtime_from_image(base_image) + if runtime is None: + raise LambdaImagesTemplateException("Unable to infer the runtime from the base image name") + options = templates.init_options(package_type, runtime, base_image, dependency_manager) + if not app_template: + if len(options) == 1: + app_template = options[0].get("appTemplate") + elif len(options) > 1: + raise LambdaImagesTemplateException( + "Multiple lambda image application templates found. " + "Please specify one using the --app-template parameter." + ) + + if app_template and not location: + location = templates.location_from_app_template( + package_type, runtime, base_image, dependency_manager, app_template + ) + no_input = True + extra_context = _get_cookiecutter_template_context(name, runtime, architecture, extra_context) + + if not output_dir: + output_dir = "." + if output_mode is OutputOption.json: + # The --app-template path sets this above, but --location does not, and + # cookiecutter's prompts cannot be answered when output is being consumed + no_input = True + captured_stdout = None + try: + with contextlib.ExitStack() as stack: + if output_mode is OutputOption.json: + # Template hooks write plain text straight to our stdout, which would leave + # a JSON consumer with unparseable output. Re-emitted as a document below. + captured_stdout = stack.enter_context(_capture_stdout()) + generated_directory = do_generate( + location, + package_type, + runtime, + dependency_manager, + output_dir, + name, + no_input, + extra_context, + tracing, + application_insights, + structured_logging, ) - - if app_template and not location: - location = templates.location_from_app_template( - package_type, runtime, base_image, dependency_manager, app_template - ) - no_input = True - extra_context = _get_cookiecutter_template_context(name, runtime, architecture, extra_context) - - if not output_dir: - output_dir = "." - do_generate( - location, - package_type, - runtime, - dependency_manager, - output_dir, - name, - no_input, - extra_context, - tracing, - application_insights, - structured_logging, - ) + finally: + # Emitted even when generation failed, since a failing hook prints its diagnostics + # to stdout and cookiecutter's own error does not carry them. + if captured_stdout is not None and captured_stdout.text: + click.echo(json.dumps({"type": "info", "source": "template", "message": captured_stdout.text})) + + if output_mode is OutputOption.json: + # Absolute so a consumer never has to guess the process cwd. output_dir/name is + # not a usable substitute, since a template names its own project directory. + # Null when unknown, rather than a fabricated path to a possibly empty directory. + project_directory = os.path.abspath(generated_directory) if generated_directory else None + click.echo( + json.dumps( + { + "type": "result", + "status": "success", + "project_directory": project_directory, + "template_file": _find_template_file(project_directory) if project_directory else None, + "runtime": runtime, + # Only reported for a managed template, identified by a resolved + # runtime. A --location template decides these itself, so our + # defaults would contradict it. + "package_type": package_type if runtime else None, + "dependency_manager": dependency_manager, + "app_template": app_template, + "architectures": get_architectures(architecture) if runtime else None, + } + ) + ) + except click.UsageError: + # Nothing was attempted, so there is no result to describe. Left to click, which + # reports it on stderr like every other usage error, including the guard below. + raise + except Exception as ex: + # Broad catch so any execution failure is serialized for a JSON consumer, which has no + # other way to learn why the command failed. Re-raise to keep exit codes, telemetry + # and text mode unchanged. + if output_mode is OutputOption.json: + click.echo(failure_result_json(ex)) + raise else: + if output_mode is OutputOption.json: + # Rejected here rather than up front so any run reaching the branch above still works, + # with or without --no-interactive. Also keeps the banner below off stdout. + raise click.UsageError(STRUCTURED_OUTPUT_PARAMS_HINT) if not (pt_explicit or runtime or dependency_manager or base_image or architecture): click.secho(INIT_INTERACTIVE_OPTION_GUIDE, fg="yellow", bold=True) @@ -380,6 +469,72 @@ def do_cli( ) +class CapturedStdout: + """Holds whatever was written to stdout while _capture_stdout was active.""" + + def __init__(self): + self.text = "" + + +@contextlib.contextmanager +def _capture_stdout(): + """Redirect stdout into a buffer for the duration of the block. + + Cookiecutter runs a template's hooks as subprocesses inheriting this process's stdout, so + contextlib.redirect_stdout is not enough, as it only replaces sys.stdout within this process. + Redirecting file descriptor 1 covers subprocesses too. A temporary file is used rather than a + pipe so a hook writing a lot of output cannot fill a pipe and block. + + The captured text is available once the block exits, including when the block raised. + + Yields + ------ + CapturedStdout + Object whose ``text`` attribute holds the captured output once the block has exited + """ + capture = CapturedStdout() + # errors="replace" because a hook subprocess writes raw bytes in whatever encoding it likes, + # and a decode failure here would surface as the command's reported outcome + with tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace") as buffer: + sys.stdout.flush() + saved_stdout_fd = os.dup(1) + try: + os.dup2(buffer.fileno(), 1) + yield capture + finally: + sys.stdout.flush() + os.dup2(saved_stdout_fd, 1) + os.close(saved_stdout_fd) + buffer.seek(0) + capture.text = buffer.read().strip() + + +def _find_template_file(project_directory): + """Return the absolute path of the generated project's SAM template, or None if it has none. + + A cookiecutter template picks its own template file name, and a project cloned from + --location may not contain a SAM template at all, so the name cannot be assumed. The + search order matches get_or_default_template_file_name, so the path reported here is the + one a subsequent `sam build` in this project would resolve to. + + Parameters + ---------- + project_directory: str + An absolute path to the generated project + + Returns + ------- + Optional[str] + An absolute path to the template file, or None if the project has no SAM template + """ + for template_name in SAM_TEMPLATE_FILE_NAMES: + candidate = os.path.join(project_directory, template_name) + if os.path.isfile(candidate): + return candidate + + return None + + def _deprecate_notification(runtime): from samcli.lib.utils.colors import Colored diff --git a/samcli/commands/init/core/options.py b/samcli/commands/init/core/options.py index f6c2c8ce2e..4215d1d532 100644 --- a/samcli/commands/init/core/options.py +++ b/samcli/commands/init/core/options.py @@ -24,12 +24,19 @@ # Can be used instead of the options in the first list NON_INTERACTIVE_OPTIONS: List[str] = ["no_interactive", "no_input", "extra_context"] +OUTPUT_OPTIONS: List[str] = ["output"] + CONFIGURATION_OPTION_NAMES: List[str] = ["config_env", "config_file"] + SAVE_PARAMS_OPTIONS ADDITIONAL_OPTIONS: List[str] = ["tracing", "application_insights", "structured_logging"] ALL_OPTIONS: List[str] = ( - APPLICATION_OPTIONS + NON_INTERACTIVE_OPTIONS + CONFIGURATION_OPTION_NAMES + ADDITIONAL_OPTIONS + ALL_COMMON_OPTIONS + APPLICATION_OPTIONS + + NON_INTERACTIVE_OPTIONS + + OUTPUT_OPTIONS + + CONFIGURATION_OPTION_NAMES + + ADDITIONAL_OPTIONS + + ALL_COMMON_OPTIONS ) OPTIONS_INFO: Dict[str, Dict] = { @@ -39,6 +46,7 @@ "Non Interactive Options": { "option_names": {opt: {"rank": idx} for idx, opt in enumerate(NON_INTERACTIVE_OPTIONS)} }, + "Output Options": {"option_names": {opt: {"rank": idx} for idx, opt in enumerate(OUTPUT_OPTIONS)}}, "Configuration Options": { "option_names": {opt: {"rank": idx} for idx, opt in enumerate(CONFIGURATION_OPTION_NAMES)}, "extras": [ diff --git a/samcli/commands/init/init_generator.py b/samcli/commands/init/init_generator.py index 454405615b..44322861c3 100644 --- a/samcli/commands/init/init_generator.py +++ b/samcli/commands/init/init_generator.py @@ -20,8 +20,16 @@ def do_generate( application_insights, structured_logging, ): + """ + Generate a project and return the directory it was created in. + + Returns + ------- + Optional[str] + Path to the generated project directory, or None if it could not be determined + """ try: - generate_project( + return generate_project( location, package_type, runtime, diff --git a/samcli/lib/init/__init__.py b/samcli/lib/init/__init__.py index 922b4fa675..5e8d284ce3 100644 --- a/samcli/lib/init/__init__.py +++ b/samcli/lib/init/__init__.py @@ -75,12 +75,21 @@ def generate_project( structured_logging: Optional[bool] boolean value to determine if Json structured logging should be enabled or not + Returns + ------- + Optional[str] + Path to the generated project directory. This is not always ``output_dir/name``: a + cookiecutter template names its own project directory, so for a ``location`` template + without a ``name`` the project is nested one level below ``output_dir``. None when the + directory could not be determined. + Raises ------ GenerateProjectFailedError If the process of baking a project fails """ template = None + project_directory = None if runtime and not is_custom_runtime(runtime) and package_type == ZIP: for mapping in list(itertools.chain(*(RUNTIME_DEP_TEMPLATE_MAPPING.values()))): @@ -108,7 +117,9 @@ def generate_project( try: LOG.debug("Baking a new template with cookiecutter with all parameters") - cookiecutter(**params) + # cookiecutter returns the directory it created, which is the only reliable way to know + # where the project landed when the template chooses its own project directory name. + project_directory = cookiecutter(**params) # Fixes gradlew line ending issue caused by Windows git # gradlew is a shell script which should not have CR LF line endings # Putting the conversion after cookiecutter as cookiecutter processing will also change the line endings @@ -123,7 +134,9 @@ def generate_project( "it as a cookiecutter template" ) project_output_dir = str(Path(output_dir, name)) if name else output_dir - generate_non_cookiecutter_project(location=params["template"], output_dir=project_output_dir) + project_directory = generate_non_cookiecutter_project( + location=params["template"], output_dir=project_output_dir + ) except UnknownRepoType as e: raise InvalidLocationError(template=params["template"]) from e @@ -140,6 +153,8 @@ def generate_project( _create_default_samconfig(package_type, output_dir, name) + return project_directory + def _apply_tracing(tracing: bool, output_dir: str, name: str) -> None: if tracing: diff --git a/schema/samcli.json b/schema/samcli.json index 3aeba1d204..5f5b0f8be3 100644 --- a/schema/samcli.json +++ b/schema/samcli.json @@ -54,7 +54,7 @@ "properties": { "parameters": { "title": "Parameters for the init command", - "description": "Available parameters for the init command:\n* no_interactive:\nDisable interactive prompting for init parameters. (fail if any required values are missing)\n* architecture:\nArchitectures for Lambda functions.\n\nArchitectures: ['arm64', 'x86_64']\n* location:\nTemplate location (git, mercurial, http(s), zip, path).\n* runtime:\nLambda runtime for application.\n\nRuntimes: dotnet10, dotnet8, dotnet6, go1.x, java25, java21, java17.al2023, java17, java11.al2023, java11, java8.al2023, java8.al2, nodejs24.x, nodejs22.x, nodejs20.x, nodejs18.x, nodejs16.x, provided, provided.al2023, provided.al2, python3.9, python3.8, python3.14, python3.13, python3.12, python3.11, python3.10, ruby4.0, ruby3.4, ruby3.3, ruby3.2\n* package_type:\nLambda deployment package type.\n\nPackage Types: Zip, Image\n* base_image:\nLambda base image for deploying IMAGE based package type.\n\nBase images: amazon/dotnet10-base, amazon/dotnet6-base, amazon/dotnet8-base, amazon/go-provided.al2-base, amazon/go-provided.al2023-base, amazon/go1.x-base, amazon/java11-base, amazon/java11.al2023-base, amazon/java17-base, amazon/java17.al2023-base, amazon/java21-base, amazon/java25-base, amazon/java8.al2-base, amazon/java8.al2023-base, amazon/nodejs16.x-base, amazon/nodejs18.x-base, amazon/nodejs20.x-base, amazon/nodejs22.x-base, amazon/nodejs24.x-base, amazon/python3.10-base, amazon/python3.11-base, amazon/python3.12-base, amazon/python3.13-base, amazon/python3.14-base, amazon/python3.8-base, amazon/python3.9-base, amazon/ruby3.2-base, amazon/ruby3.3-base, amazon/ruby3.4-base, amazon/ruby4.0-base\n* dependency_manager:\nDependency manager for Lambda runtime.\n\nDependency managers: bundler, cli-package, gradle, maven, mod, npm, pip\n* output_dir:\nDirectory to initialize AWS SAM application.\n* name:\nName of AWS SAM Application.\n* app_template:\nIdentifier of the managed application template to be used. Alternatively, run '$ sam init' without options for an interactive workflow.\n* no_input:\nDisable Cookiecutter prompting and accept default values defined in the cookiecutter config.\n* extra_context:\nOverride custom parameters in the template's cookiecutter.json configuration e.g. {\"customParam1\": \"customValue1\", \"customParam2\":\"customValue2\"}\n* tracing:\nEnable AWS X-Ray tracing for application.\n* application_insights:\nEnable CloudWatch Application Insights monitoring for application.\n* structured_logging:\nEnable Structured Logging for application.\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* save_params:\nSave the parameters provided via the command line to the configuration file.", + "description": "Available parameters for the init command:\n* no_interactive:\nDisable interactive prompting for init parameters. (fail if any required values are missing)\n* architecture:\nArchitectures for Lambda functions.\n\nArchitectures: ['arm64', 'x86_64']\n* location:\nTemplate location (git, mercurial, http(s), zip, path).\n* runtime:\nLambda runtime for application.\n\nRuntimes: dotnet10, dotnet8, dotnet6, go1.x, java25, java21, java17.al2023, java17, java11.al2023, java11, java8.al2023, java8.al2, nodejs24.x, nodejs22.x, nodejs20.x, nodejs18.x, nodejs16.x, provided, provided.al2023, provided.al2, python3.9, python3.8, python3.14, python3.13, python3.12, python3.11, python3.10, ruby4.0, ruby3.4, ruby3.3, ruby3.2\n* package_type:\nLambda deployment package type.\n\nPackage Types: Zip, Image\n* base_image:\nLambda base image for deploying IMAGE based package type.\n\nBase images: amazon/dotnet10-base, amazon/dotnet6-base, amazon/dotnet8-base, amazon/go-provided.al2-base, amazon/go-provided.al2023-base, amazon/go1.x-base, amazon/java11-base, amazon/java11.al2023-base, amazon/java17-base, amazon/java17.al2023-base, amazon/java21-base, amazon/java25-base, amazon/java8.al2-base, amazon/java8.al2023-base, amazon/nodejs16.x-base, amazon/nodejs18.x-base, amazon/nodejs20.x-base, amazon/nodejs22.x-base, amazon/nodejs24.x-base, amazon/python3.10-base, amazon/python3.11-base, amazon/python3.12-base, amazon/python3.13-base, amazon/python3.14-base, amazon/python3.8-base, amazon/python3.9-base, amazon/ruby3.2-base, amazon/ruby3.3-base, amazon/ruby3.4-base, amazon/ruby4.0-base\n* dependency_manager:\nDependency manager for Lambda runtime.\n\nDependency managers: bundler, cli-package, gradle, maven, mod, npm, pip\n* output_dir:\nDirectory to initialize AWS SAM application.\n* name:\nName of AWS SAM Application.\n* app_template:\nIdentifier of the managed application template to be used. Alternatively, run '$ sam init' without options for an interactive workflow.\n* no_input:\nDisable Cookiecutter prompting and accept default values defined in the cookiecutter config.\n* extra_context:\nOverride custom parameters in the template's cookiecutter.json configuration e.g. {\"customParam1\": \"customValue1\", \"customParam2\":\"customValue2\"}\n* tracing:\nEnable AWS X-Ray tracing for application.\n* application_insights:\nEnable CloudWatch Application Insights monitoring for application.\n* structured_logging:\nEnable Structured Logging for application.\n* output:\nOutput the results from the command in a given output format. Supported formats: text (default), json.\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* save_params:\nSave the parameters provided via the command line to the configuration file.", "type": "object", "properties": { "no_interactive": { @@ -215,6 +215,16 @@ "type": "boolean", "description": "Enable Structured Logging for application." }, + "output": { + "title": "output", + "type": "string", + "description": "Output the results from the command in a given output format. Supported formats: text (default), json.", + "default": "text", + "enum": [ + "json", + "text" + ] + }, "beta_features": { "title": "beta_features", "type": "boolean", diff --git a/tests/integration/init/test_init_command.py b/tests/integration/init/test_init_command.py index d4f8eed926..e3420963df 100644 --- a/tests/integration/init/test_init_command.py +++ b/tests/integration/init/test_init_command.py @@ -11,6 +11,7 @@ from parameterized import parameterized from subprocess import Popen, TimeoutExpired, PIPE +import json import os import shutil import tempfile @@ -66,6 +67,126 @@ def test_init_command_passes_and_dir_created(self): self.assertTrue(Path(temp, "sam-app").is_dir()) self.assertNotIn(COMMIT_ERROR, stderr) + def test_init_command_output_json(self): + with tempfile.TemporaryDirectory() as temp: + process = Popen( + [ + get_sam_command(), + "init", + "--runtime", + "nodejs18.x", + "--dependency-manager", + "npm", + "--app-template", + "hello-world", + "--name", + "sam-app", + "--no-interactive", + "-o", + temp, + "--output", + "json", + ], + stdout=PIPE, + stderr=PIPE, + ) + try: + stdout_data, stderr_data = process.communicate(timeout=TIMEOUT) + stdout = stdout_data.decode("utf-8") + stderr = stderr_data.decode("utf-8") + except TimeoutExpired: + process.kill() + raise + + self.assertEqual(process.returncode, 0) + + # stdout must hold nothing but the single result document, so a consumer can parse it + self.assertEqual(len(stdout.splitlines()), 1) + document = json.loads(stdout) + + self.assertEqual(document["type"], "result") + self.assertEqual(document["status"], "success") + + # The reported paths must be absolute and must actually exist. Compare against + # abspath rather than resolve(), since the command does not resolve symlinks + # (on macOS /var is a symlink to /private/var). + self.assertEqual(document["project_directory"], os.path.abspath(os.path.join(temp, "sam-app"))) + self.assertTrue(Path(document["project_directory"]).is_dir()) + self.assertTrue(Path(document["template_file"]).is_file()) + + self.assertNotIn(COMMIT_ERROR, stderr) + + def test_init_command_output_json_with_template_hook_output(self): + """A template hook runs as a subprocess writing to our stdout; stdout must stay parseable. + + Also covers a --location template naming its own project directory, so output_dir/name + is not the answer. No --name is passed, which is why --no-interactive is absent too. + """ + with tempfile.TemporaryDirectory() as temp: + template_dir = Path(temp, "hook-template") + template_project_dir = Path(template_dir, "{{cookiecutter.project_name}}") + template_project_dir.mkdir(parents=True) + Path(template_dir, "cookiecutter.json").write_text(json.dumps({"project_name": "template-chosen-app"})) + Path(template_project_dir, "template.yaml").write_text("Resources: {}\n") + + hooks_dir = Path(template_dir, "hooks") + hooks_dir.mkdir() + # print() covers ordinary hook output; os.write covers a hook writing to the descriptor + Path(hooks_dir, "post_gen_project.py").write_text( + 'import os\nprint("hook said hello")\nos.write(1, b"raw descriptor write\\n")\n' + ) + + out_dir = Path(temp, "out") + out_dir.mkdir() + + process = Popen( + [ + get_sam_command(), + "init", + "--location", + str(template_dir), + "-o", + str(out_dir), + "--output", + "json", + ], + stdout=PIPE, + stderr=PIPE, + ) + try: + stdout_data, _ = process.communicate(timeout=TIMEOUT) + stdout = stdout_data.decode("utf-8") + except TimeoutExpired: + process.kill() + raise + + self.assertEqual(process.returncode, 0) + + # THEN every line of stdout is valid JSON, so a consumer reading line by line survives + lines = stdout.splitlines() + documents = [json.loads(line) for line in lines] + + # AND the hook output is reported rather than dropped or left raw on stdout + info_documents = [document for document in documents if document["type"] == "info"] + self.assertEqual(len(info_documents), 1) + self.assertEqual(info_documents[0]["source"], "template") + self.assertIn("hook said hello", info_documents[0]["message"]) + self.assertIn("raw descriptor write", info_documents[0]["message"]) + + # AND the result document is last, so it remains the terminal document + result = documents[-1] + self.assertEqual(result["type"], "result") + self.assertEqual(result["status"], "success") + + # AND the reported directory is the nested one the template named, not the parent + self.assertEqual(result["project_directory"], os.path.join(str(out_dir), "template-chosen-app")) + self.assertNotEqual(result["project_directory"], str(out_dir)) + self.assertTrue(Path(result["project_directory"]).is_dir()) + + # AND the template inside it is found rather than reported as absent + self.assertIsNotNone(result["template_file"]) + self.assertTrue(Path(result["template_file"]).is_file()) + def test_init_command_passes_and_dir_created_image(self): with tempfile.TemporaryDirectory() as temp: process = Popen( diff --git a/tests/unit/cli/test_cli_config_file.py b/tests/unit/cli/test_cli_config_file.py index ce3a9f1fc7..f2f0b139c0 100644 --- a/tests/unit/cli/test_cli_config_file.py +++ b/tests/unit/cli/test_cli_config_file.py @@ -7,9 +7,11 @@ from unittest import TestCase, skipIf from unittest.mock import MagicMock, patch +import click import tomlkit from click.core import ParameterSource +from samcli.commands._utils.custom_options.structured_output_option import StructuredOutputOption from samcli.commands.exceptions import ConfigException from samcli.cli.cli_config_file import ( ConfigProvider, @@ -267,13 +269,14 @@ def mock_put_func(cmd_names, section, key, value, env): mock_put.side_effect = mock_put_func return MagicMock(flush=mock_flush, put=mock_put), mock_config_file - def _setup_context(self, params: dict, parameter_source: dict): + def _setup_context(self, params: dict, parameter_source: dict, command_params: Optional[list] = None): mock_context = MockContext(info_name="sam", parent=None) mock_self_ctx = MagicMock() mock_self_ctx.parent = mock_context mock_self_ctx.info_name = "command" mock_self_ctx.params = params mock_self_ctx._parameter_source = parameter_source + mock_self_ctx.command.params = command_params if command_params is not None else [] return mock_self_ctx def test_dont_save_command_line_args_if_flag_not_set(self): @@ -326,6 +329,49 @@ def test_save_command_line_args(self, mock_context_class, mock_get_default_aws_r self.assertIn("value", params.values(), "Param value should be saved to config file") mock_samconfig.flush.assert_called_once() # everything should be flushed to config file + @patch("samcli.cli.cli_config_file.get_default_aws_region") + @patch("samcli.cli.cli_config_file.Context") + def test_dont_save_structured_output_option(self, mock_context_class, mock_get_default_aws_region): + """The shared structured output flag is excluded, while an unrelated --output is still saved.""" + mock_samconfig, mock_config_file = self._setup_mock_samconfig() + + mock_sam_context = MagicMock() + mock_sam_context.region = None + mock_context_class.get_current_context.return_value = mock_sam_context + mock_get_default_aws_region.return_value = "us-east-1" + + self.ctx = self._setup_context( + params={"save_params": True, "output": "json", "some_param": "value"}, + parameter_source={ + "save_params": ParameterSource.COMMANDLINE, + "output": ParameterSource.COMMANDLINE, + "some_param": ParameterSource.COMMANDLINE, + }, + command_params=[StructuredOutputOption(["--output"])], + ) + + save_command_line_args_to_config(self.ctx, ["command"], "default", mock_samconfig) + + params = mock_config_file["default"]["command"]["parameters"] + self.assertNotIn("output", params.keys(), "Structured output flag should not be saved to config") + self.assertIn("some_param", params.keys(), "Other params on the same command line are still saved") + + # A command such as sam list defines --output as a display preference, which is still saved + mock_samconfig, mock_config_file = self._setup_mock_samconfig() + self.ctx = self._setup_context( + params={"save_params": True, "output": "table"}, + parameter_source={ + "save_params": ParameterSource.COMMANDLINE, + "output": ParameterSource.COMMANDLINE, + }, + command_params=[click.Option(["--output"])], + ) + + save_command_line_args_to_config(self.ctx, ["command"], "default", mock_samconfig) + + params = mock_config_file["default"]["command"]["parameters"] + self.assertIn("output", params.keys(), "An unrelated --output should still be saved to config") + @patch("samcli.cli.cli_config_file.get_default_aws_region") @patch("samcli.cli.cli_config_file.Context") def test_dont_save_arguments_not_from_command_line(self, mock_context_class, mock_get_default_aws_region): diff --git a/tests/unit/commands/init/test_cli.py b/tests/unit/commands/init/test_cli.py index 79ee30d40a..5d6f76d387 100644 --- a/tests/unit/commands/init/test_cli.py +++ b/tests/unit/commands/init/test_cli.py @@ -3263,3 +3263,323 @@ def test_latest_python_fetchers_raises_not_found(self): ): with self.assertRaises(PopularRuntimeNotFoundException): _get_latest_python_runtime() + + def _do_cli_with(self, **overrides): + # Invoke init with the setUp defaults, overriding only the fields a test cares about. + # Defaults to --output json, since every test below but one exercises JSON mode. + kwargs = dict( + ctx=self.ctx, + no_interactive=self.no_interactive, + location=self.location, + pt_explicit=self.pt_explicit, + package_type=self.package_type, + runtime=self.runtime, + architecture=X86_64, + base_image=self.base_image, + dependency_manager=self.dependency_manager, + output_dir=self.output_dir, + name=self.name, + app_template=self.app_template, + no_input=self.no_input, + extra_context=None, + tracing=False, + application_insights=False, + structured_logging=False, + output="json", + ) + kwargs.update(overrides) + init_cli(**kwargs) + + @patch("samcli.commands.init.command.click.echo") + @patch("samcli.commands.init.init_templates.InitTemplates.location_from_app_template") + @patch("samcli.commands.init.init_generator.generate_project") + def test_init_cli_output_json_emits_success_document( + self, generate_project_patch, location_from_app_template_mock, echo_mock + ): + location_from_app_template_mock.return_value = "applocation" + + # WHEN a project is generated with --output json + with osutils.mkdir_temp() as temp_dir: + # Mirror generate_project's real contract: it returns the directory it created + generate_project_patch.return_value = os.path.join(temp_dir, self.name) + + self._do_cli_with(output_dir=temp_dir) + + # THEN exactly one JSON success document is emitted describing what was created + echo_mock.assert_called_once() + document = json.loads(echo_mock.call_args[0][0]) + + self.assertEqual(document["type"], "result") + self.assertEqual(document["status"], "success") + self.assertEqual(document["project_directory"], os.path.join(os.path.abspath(temp_dir), self.name)) + # No template.yaml exists because generate_project is mocked out + self.assertIsNone(document["template_file"]) + self.assertEqual(document["runtime"], self.runtime) + self.assertEqual(document["package_type"], ZIP) + self.assertEqual(document["dependency_manager"], self.dependency_manager) + self.assertEqual(document["app_template"], self.app_template) + self.assertEqual(document["architectures"], [X86_64]) + + @patch("samcli.commands.init.command.click.echo") + @patch("samcli.commands.init.init_generator.generate_project") + def test_init_cli_output_json_forces_no_input(self, generate_project_patch, echo_mock): + generate_project_patch.return_value = None + + # WHEN a project is generated from a location, which does not set no_input itself + self._do_cli_with(location="/some/location", app_template=None, no_input=False) + + # THEN cookiecutter is told not to prompt, since a JSON consumer cannot answer prompts + self.assertTrue(generate_project_patch.call_args[0][6]) + + @patch("samcli.commands.init.command.click.echo") + @patch("samcli.commands.init.init_templates.InitTemplates.location_from_app_template") + @patch("samcli.commands.init.init_generator.generate_project") + def test_init_cli_output_json_emits_failure_document( + self, generate_project_patch, location_from_app_template_mock, echo_mock + ): + location_from_app_template_mock.return_value = "applocation" + generate_project_patch.side_effect = GenerateProjectFailedError(project="testing project", provider_error="ex") + + # WHEN generation fails with --output json + with self.assertRaises(UserException): + self._do_cli_with() + + # THEN the failure document reports the wrapped error type, not the UserException wrapper + echo_mock.assert_called_once() + document = json.loads(echo_mock.call_args[0][0]) + + self.assertEqual(document["type"], "result") + self.assertEqual(document["status"], "failure") + self.assertEqual(document["error"]["type"], "GenerateProjectFailedError") + self.assertIn("testing project", document["error"]["message"]) + + @patch("samcli.commands.init.interactive_init_flow.do_interactive") + def test_init_cli_output_json_requires_no_interactive(self, do_interactive_mock): + # WHEN --output json is used without enough parameters to skip the interactive flow + with self.assertRaises(click.UsageError) as ex: + # Nothing here identifies a template, so the run would reach the interactive flow + self._do_cli_with( + no_interactive=False, + location=None, + pt_explicit=False, + package_type=None, + runtime=None, + architecture=None, + base_image=None, + dependency_manager=None, + name=None, + app_template=None, + no_input=False, + ) + + # THEN it is rejected before any prompting happens, and the message lists the parameter + # combinations that would make the run non-interactive rather than naming a single flag + self.assertIn("--location", str(ex.exception)) + self.assertIn("--app-template", str(ex.exception)) + self.assertIn("--base-image", str(ex.exception)) + self.assertFalse(do_interactive_mock.called) + + @patch("samcli.commands.init.command.click.echo") + @patch("samcli.commands.init.init_templates.InitTemplates.location_from_app_template") + @patch("samcli.commands.init.init_generator.generate_project") + def test_init_cli_text_output_emits_no_json( + self, generate_project_patch, location_from_app_template_mock, echo_mock + ): + location_from_app_template_mock.return_value = "applocation" + + # WHEN a project is generated without --output json + self._do_cli_with(output="text") + + # THEN no structured document is emitted, preserving the existing text behaviour + self.assertFalse(echo_mock.called) + + def _init_cli_json_with_generated_files(self, generate_project_patch, echo_mock, temp_dir, file_names): + """Run init with --output json, having generate_project create the given files in the project.""" + + def create_files(*args, **kwargs): + project_directory = Path(temp_dir, self.name) + project_directory.mkdir(parents=True, exist_ok=True) + for file_name in file_names: + Path(project_directory, file_name).write_text("Resources: {}") + # generate_project returns the directory it created + return str(project_directory) + + generate_project_patch.side_effect = create_files + + self._do_cli_with(output_dir=temp_dir) + + return json.loads(echo_mock.call_args[0][0]) + + @parameterized.expand( + [ + # A template may use any recognised name, and .yaml wins when several exist because + # that is the one a subsequent build resolves to + (["template.yml"], "template.yml"), + (["template.yml", "template.yaml"], "template.yaml"), + (["README.md"], None), + ] + ) + @patch("samcli.commands.init.command.click.echo") + @patch("samcli.commands.init.init_templates.InitTemplates.location_from_app_template") + @patch("samcli.commands.init.init_generator.generate_project") + def test_init_cli_output_json_reports_template_file( + self, + generated_files, + expected_template, + generate_project_patch, + location_from_app_template_mock, + echo_mock, + ): + location_from_app_template_mock.return_value = "applocation" + + with osutils.mkdir_temp() as temp_dir: + document = self._init_cli_json_with_generated_files( + generate_project_patch, echo_mock, temp_dir, generated_files + ) + + expected = os.path.join(temp_dir, self.name, expected_template) if expected_template else None + self.assertEqual(document["template_file"], expected) + + @patch("samcli.commands.init.command.click.echo") + @patch("samcli.commands.init.init_generator.generate_project") + def test_init_cli_output_json_reports_generated_directory(self, generate_project_patch, echo_mock): + # GIVEN a template that creates a project directory of its own choosing, which is what + # happens for a --location template used without --name + with osutils.mkdir_temp() as temp_dir: + generated_directory = os.path.join(temp_dir, "template-chosen-name") + generate_project_patch.return_value = generated_directory + + self._do_cli_with( + location="/some/location", + runtime=None, + architecture=None, + dependency_manager=None, + output_dir=temp_dir, + name=None, + app_template=None, + ) + + document = json.loads(echo_mock.call_args[0][0]) + + # THEN the reported directory is the one the generator created, not output_dir + self.assertEqual(document["project_directory"], generated_directory) + self.assertNotEqual(document["project_directory"], temp_dir) + # AND the fields only a managed template can resolve are not invented for a clone + self.assertIsNone(document["architectures"]) + self.assertIsNone(document["package_type"]) + self.assertIsNone(document["runtime"]) + self.assertIsNone(document["dependency_manager"]) + self.assertIsNone(document["app_template"]) + + @patch("samcli.commands.init.command.click.echo") + @patch("samcli.commands.init.init_templates.InitTemplates.location_from_app_template") + @patch("samcli.commands.init.init_generator.generate_project") + def test_init_cli_output_json_reports_null_when_directory_unknown( + self, generate_project_patch, location_from_app_template_mock, echo_mock + ): + location_from_app_template_mock.return_value = "applocation" + # GIVEN the generator could not determine where the project was created + generate_project_patch.return_value = None + + with osutils.mkdir_temp() as temp_dir: + self._do_cli_with(output_dir=temp_dir) + + document = json.loads(echo_mock.call_args[0][0]) + + # THEN the location is reported as unknown rather than as a fabricated path + self.assertIsNone(document["project_directory"]) + self.assertIsNone(document["template_file"]) + # AND the command still succeeds, matching what text mode does in this case + self.assertEqual(document["status"], "success") + # AND architectures is reported, since a managed template resolved a runtime + self.assertEqual(document["architectures"], [X86_64]) + + @patch("samcli.commands.init.command.click.echo") + @patch("samcli.commands.init.init_templates.InitTemplates.location_from_app_template") + @patch("samcli.commands.init.init_generator.generate_project") + def test_init_cli_output_json_reports_template_output( + self, generate_project_patch, location_from_app_template_mock, echo_mock + ): + location_from_app_template_mock.return_value = "applocation" + + def write_to_stdout(*args, **kwargs): + # Templates print from hook subprocesses, so write to the descriptor rather than print() + os.write(1, b"hook wrote this\n") + + generate_project_patch.side_effect = write_to_stdout + + self._do_cli_with() + + # THEN the template's output is reported as its own document ahead of the result, so every + # line of stdout stays parseable + self.assertEqual(echo_mock.call_count, 2) + + info_document = json.loads(echo_mock.call_args_list[0][0][0]) + self.assertEqual(info_document["type"], "info") + self.assertEqual(info_document["source"], "template") + self.assertIn("hook wrote this", info_document["message"]) + + result_document = json.loads(echo_mock.call_args_list[1][0][0]) + self.assertEqual(result_document["type"], "result") + self.assertEqual(result_document["status"], "success") + + @patch("samcli.commands.init.command.click.echo") + @patch("samcli.commands.init.init_generator.generate_project") + def test_init_cli_output_json_does_not_report_usage_errors(self, generate_project_patch, echo_mock): + # WHEN the command is called incorrectly, here with --extra-context that is not valid JSON + with self.assertRaises(click.UsageError): + self._do_cli_with(location="/some/location", app_template=None, extra_context="{not valid json") + + # THEN no result document is emitted, since nothing was attempted. Usage errors are + # reported by click on stderr, the same way the --output json guard reports them. + self.assertFalse(echo_mock.called) + self.assertFalse(generate_project_patch.called) + + @patch("samcli.commands.init.command.click.echo") + @patch("samcli.commands.init.init_templates.InitTemplates.location_from_app_template") + @patch("samcli.commands.init.init_generator.generate_project") + def test_init_cli_output_json_reports_template_output_on_failure( + self, generate_project_patch, location_from_app_template_mock, echo_mock + ): + location_from_app_template_mock.return_value = "applocation" + + def write_then_fail(*args, **kwargs): + # A failing hook prints its diagnostics before cookiecutter raises + os.write(1, b"hook failed because of a missing tool\n") + raise GenerateProjectFailedError(project="testing project", provider_error="hook failed") + + generate_project_patch.side_effect = write_then_fail + + with self.assertRaises(UserException): + self._do_cli_with() + + # THEN the template output is still reported, since the failure is undiagnosable without it + info_document = json.loads(echo_mock.call_args_list[0][0][0]) + self.assertEqual(info_document["type"], "info") + self.assertIn("missing tool", info_document["message"]) + + failure_document = json.loads(echo_mock.call_args_list[1][0][0]) + self.assertEqual(failure_document["status"], "failure") + self.assertEqual(failure_document["error"]["type"], "GenerateProjectFailedError") + # The shared failure shape carries resources, so a build or deploy consumer can read it + self.assertIn("resources", failure_document["error"]) + + @patch("samcli.commands.init.command.click.echo") + @patch("samcli.commands.init.init_templates.InitTemplates.location_from_app_template") + @patch("samcli.commands.init.init_generator.generate_project") + def test_init_cli_output_json_survives_undecodable_template_output( + self, generate_project_patch, location_from_app_template_mock, echo_mock + ): + location_from_app_template_mock.return_value = "applocation" + + def write_invalid_bytes(*args, **kwargs): + # A hook writes in whatever encoding it likes, which may not decode cleanly + os.write(1, b"\xff\xfe invalid bytes\n") + + generate_project_patch.side_effect = write_invalid_bytes + + self._do_cli_with() + + # THEN the run still reports success, rather than a decode error from the capture + result_document = json.loads(echo_mock.call_args_list[-1][0][0]) + self.assertEqual(result_document["status"], "success") diff --git a/tests/unit/commands/samconfig/test_samconfig.py b/tests/unit/commands/samconfig/test_samconfig.py index 225deb044e..ed8abe6e25 100644 --- a/tests/unit/commands/samconfig/test_samconfig.py +++ b/tests/unit/commands/samconfig/test_samconfig.py @@ -84,6 +84,7 @@ def test_init(self, do_cli_mock): None, ANY, None, + "text", ) @patch("samcli.commands.validate.validate.do_cli")