Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions samcli/cli/cli_config_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ def save_command_line_args_to_config(
"save_params", # don't save the provided save-params
"config_file", # don't save config specs to prevent confusion
"config_env",
# --output describes how this one run reports, not what to build. Persisting it would
# change the output format of every later run in this directory, and for sam init it
# would make the interactive flow unreachable.
"output",
Comment thread
roger-zhangg marked this conversation as resolved.
Outdated
]

saved_params = {}
Expand Down
4 changes: 4 additions & 0 deletions samcli/commands/_utils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Comment thread
roger-zhangg marked this conversation as resolved.
3 changes: 2 additions & 1 deletion samcli/commands/_utils/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
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
Expand Down Expand Up @@ -65,7 +66,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)
Expand Down
255 changes: 206 additions & 49 deletions samcli/commands/init/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@
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

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
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -256,6 +281,7 @@ def cli(
tracing,
application_insights,
structured_logging,
output,
save_params,
config_file,
config_env,
Expand All @@ -281,6 +307,7 @@ def cli(
tracing,
application_insights,
structured_logging,
output,
) # pragma: no cover


Expand All @@ -303,6 +330,7 @@ def do_cli(
tracing,
application_insights,
structured_logging,
output="text",
):
"""
Implementation of the ``cli`` method
Expand All @@ -312,52 +340,115 @@ 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)

# check for required parameters
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. Mirrors sam build's do_cli, which wraps its option preprocessing.

# 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 straight to our stdout, which would leave a JSON
# consumer with unparseable output. Re-emitted as JSON 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. A failing hook prints its diagnostics to
# stdout, and cookiecutter's own error does not carry them, so dropping this would
# leave the failure undiagnosable.
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 the
# defaults we hold here would contradict the generated project.
"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:
Comment thread
roger-zhangg marked this conversation as resolved.
# 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)

Expand All @@ -380,6 +471,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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[BUG] The fd-level redirect does not capture subprocess output on Windows, which is the case this helper exists for.

sys.stdout.flush()
saved_stdout_fd = os.dup(1)
try:
   os.dup2(buffer.fileno(), 1)
   yield capture

On Windows, os.dup2 only rebinds the CRT file descriptor; it does not call SetStdHandle, so the process's STD_OUTPUT_HANDLE still points at the original console/pipe. subprocess.Popen on Windows resolves an inherited stdout from GetStdHandle(STD_OUTPUT_HANDLE) when stdout=None, and cookiecutter's hooks.run_script launches hooks with Popen(script_command, shell=..., cwd=cwd) — no stdout redirection. So a post_gen_project hook's plain-text output bypasses the buffer and lands directly on stdout in JSON mode, interleaved with the info/result documents, which is exactly what the docstring says the fd redirect prevents ("Redirecting file descriptor 1 covers subprocesses too"). In-process writes through sys.stdout are unaffected, since that object is built on fd 1.

The integration test test_init_command_output_json_with_template_hook_output asserts every stdout line parses as JSON, so this should surface on Windows CI rather than only in the field.

A Windows branch that also swaps the standard handle for the duration of the block would close the gap, e.g. msvcrt.get_osfhandle(buffer.fileno()) passed to kernel32.SetStdHandle(-11, ...), restoring the saved handle in the same finally that restores fd 1.

Everything raised in the earlier review rounds is addressed in this revision: the capture buffer now decodes with errors="replace", captured output is re-emitted from a finally so it survives a failing hook, failure_result_json is reused instead of a local copy, SAM_TEMPLATE_FILE_NAMES is shared with get_or_default_template_file_name, the hints are derived from NON_INTERACTIVE_PARAM_COMBINATIONS, and the --save-params exclusion matches by option type so sam list, sam remote invoke, sam logs and sam traces keep persisting their own unrelated --output (verified: those define plain click.Options, only build/deploy/init use the shared decorator).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this one reproduces. The scenario is already covered by an integration test that runs on Windows in this PR's own CI, and it passes against the unmodified descriptor swap.
The evidence

test_init_command_output_json_with_template_hook_output
(tests/integration/init/test_init_command.py:166) passed in
Build And Test / Integ / windows-latest / 3.11 / integ-all-other, run
31755566595, on Windows
Server 2025 / Python 3.11.15, against 335ac46. The 3.10 leg passed too.

That commit contains no SetStdHandle, no STD_OUTPUT_HANDLE, no kernel32 and no
ctypes. The capture is exactly the code quoted above, os.dup(1) at
command.py:500 and os.dup2 at 502 and 506, with nothing touching the standard handle.

The job filter is -m 'not pr_skip'. The test carries no marker and no platform guard,
and the run summary is 57 passed, 847 skipped with no deselections, so it was selected
and executed rather than filtered out.

The test asserts in both directions, so neither failure mode can slip through. The
template's post_gen_project.py hook (line 179) does:

import os
print("hook said hello")
os.write(1, b"raw descriptor write\n")

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

Expand Down
Loading