Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
54aed55
feat: add --output json flag to sam build for structured machine-read…
madhavdonthula1 Jul 17, 2026
2fd6125
fix: address code review feedback on --output json
madhavdonthula1 Jul 22, 2026
b104652
fix: move resource_name tagging to strategy layer for universal coverage
madhavdonthula1 Jul 22, 2026
c5a0769
fix: broaden exception catch to tag resource_name for all build error…
madhavdonthula1 Jul 22, 2026
4a25be1
Merge branch 'develop' into feat/build-output-json-clean
madhavdonthula1 Jul 22, 2026
4e8752f
fix: formatting, lint, and schema for CI checks
madhavdonthula1 Jul 23, 2026
127c008
fix: add output param to samconfig test assertions
madhavdonthula1 Jul 23, 2026
de503bf
fix: remove unnecessary log suppression (logs go to stderr, not stdout)
madhavdonthula1 Jul 23, 2026
514edc2
refactor(build): address review feedback on --output json
madhavdonthula1 Jul 30, 2026
0bd5c26
test(build): add unit tests for --output json reporting methods
madhavdonthula1 Jul 31, 2026
5d85acd
fix: address code review feedback on --output json
madhavdonthula1 Aug 5, 2026
c33c005
fix: centralize JSON failure serialization in do_cli
madhavdonthula1 Aug 5, 2026
9a951d2
test: consolidate and harden _print_build_failure tests
madhavdonthula1 Aug 5, 2026
588ff68
fix: route UnsupportedBuilderException through JSON failure handler
madhavdonthula1 Aug 5, 2026
985e7a4
fix: attribute resource on FunctionNotFound and MissingBuildMethod fa…
madhavdonthula1 Aug 5, 2026
f46d027
fix: wrap rmtree in _setup_build_dir; soften do_cli failure-coverage …
madhavdonthula1 Aug 6, 2026
90dd7f6
fix: broaden do_cli JSON failure handler to all exceptions; drop dead…
madhavdonthula1 Aug 6, 2026
0b14a08
Merge branch 'develop' into feat/build-output-json-clean
madhavdonthula1 Aug 6, 2026
050e1ec
fix: move option preprocessing inside do_cli try for JSON serialization
madhavdonthula1 Aug 6, 2026
20b09c0
docs: scope the JSON failure guarantee to execution errors
madhavdonthula1 Aug 6, 2026
a64f926
feat: add package_type discriminator; don't blame resource-agnostic f…
madhavdonthula1 Aug 6, 2026
8c485b0
Resolve error.resource to full path for namespace consistency
madhavdonthula1 Aug 6, 2026
5ad0867
Correct representative-resource comment for JSON contract
madhavdonthula1 Aug 6, 2026
77f417f
Skip interactive prompts in JSON output mode
madhavdonthula1 Aug 6, 2026
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
2 changes: 1 addition & 1 deletion samcli/commands/_utils/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ def prompt_experimental(
if is_experimental_enabled(config_entry):
update_experimental_context()
return True
confirmed = click.confirm(Colored().yellow(prompt), default=False)
confirmed = click.confirm(Colored().yellow(prompt), default=False, err=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I get we're sending this to stderr, but it's not clear to me why and in what scenario that's useful.

if confirmed:
set_experimental(config_entry=config_entry, enabled=True)
update_experimental_context()
Expand Down
19 changes: 19 additions & 0 deletions samcli/commands/_utils/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,25 @@ def common_observability_options(f):
return f


def structured_output_click_option():
"""Shared --output option for commands that support structured (JSON) output.

Uses text|json choices matching the OutputOption enum in samcli.lib.observability.util.
Intended as the single shared contract for all commands adopting --output json.
"""
return click.option(
"--output",
default="text",
help="Output the results from the command in a given output format. "
"Supported formats: text (default), json.",
type=click.Choice(["text", "json"], case_sensitive=False),
)


def structured_output_option(f):
return structured_output_click_option()(f)


def metadata_click_option():
return click.option(
"--metadata",
Expand Down
200 changes: 171 additions & 29 deletions samcli/commands/build/build_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import copy
import itertools
import json
import logging
import os
import pathlib
Expand All @@ -14,7 +15,7 @@
import click

from samcli.commands._utils.constants import DEFAULT_BUILD_DIR
from samcli.commands._utils.experimental import ExperimentalFlag, prompt_experimental
from samcli.commands._utils.experimental import ExperimentalFlag, is_experimental_enabled, prompt_experimental
from samcli.commands._utils.template import (
FOREACH_REQUIRED_ELEMENTS,
get_template_data,
Expand All @@ -36,7 +37,7 @@
BuildInsideContainerError,
InvalidBuildGraphException,
)
from samcli.lib.build.workflow_config import UnsupportedRuntimeException
from samcli.lib.build.workflow_config import UnsupportedBuilderException, UnsupportedRuntimeException
from samcli.lib.cfn_language_extensions.models import PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES
from samcli.lib.cfn_language_extensions.sam_integration import (
contains_loop_variable,
Expand All @@ -47,13 +48,14 @@
)
from samcli.lib.cfn_language_extensions.utils import is_foreach_key
from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable
from samcli.lib.observability.util import OutputOption
from samcli.lib.package.language_extensions_packaging import (
_get_prop_value,
_leaf_prop_name,
_resolve_property_paths,
_set_prop_value,
)
from samcli.lib.providers.provider import LayerVersion, ResourcesToBuildCollector, Stack, get_full_path
from samcli.lib.providers.provider import Function, LayerVersion, ResourcesToBuildCollector, Stack, get_full_path
from samcli.lib.providers.sam_api_provider import SamApiProvider
from samcli.lib.providers.sam_function_provider import SamFunctionProvider
from samcli.lib.providers.sam_layer_provider import SamLayerProvider
Expand All @@ -69,6 +71,27 @@
LOG = logging.getLogger(__name__)


def build_failure_json(ex: Exception) -> str:
"""Serialize a build failure into the structured JSON error document.

Single source of truth for the failure wire format, shared by do_cli and any other
caller so the schema lives in exactly one place. This covers execution failures once
the command body runs; errors raised during click option processing (bad flags, hook
prepare failures) surface as click's standard usage/stderr output, not JSON.
"""
error_type = getattr(ex, "wrapped_from", None) or type(ex).__name__
return json.dumps(
{
"status": "failure",
"error": {
"type": error_type,
"message": str(ex),
"resource": getattr(ex, "resource_name", None),
},
}
)


class BuildContext:
def __init__(
self,
Expand Down Expand Up @@ -104,6 +127,7 @@ def __init__(
mount_symlinks: Optional[bool] = False,
use_buildkit: Optional[bool] = False,
language_extensions: Optional[bool] = None,
output: str = "text",
) -> None:
"""
Initialize the class
Expand Down Expand Up @@ -213,6 +237,7 @@ def __init__(
self._mount_symlinks = mount_symlinks
self._use_buildkit = use_buildkit
self._language_extensions_enabled = resolve_language_extensions_enabled(language_extensions)
self._output = OutputOption(output)

def __enter__(self) -> "BuildContext":
self.set_up()
Expand Down Expand Up @@ -277,21 +302,25 @@ def run(self) -> None:
caught_exception: Optional[Exception] = None

try:
resources_to_build = self.get_resources_to_build()

# boolean value indicates if mount with write or not, defaults to READ ONLY
mount_with_write = False
if self._use_container:
if self._mount_with == MountMode.WRITE:
mount_with_write = True
else:
elif self._output is not OutputOption.json:
# if self._mount_with is NOT WRITE
# check the need of mounting with write permissions and prompt user to enable it if needed
# check the need of mounting with write permissions and prompt user to enable it if needed.
# Skipped in JSON mode: a non-interactive consumer cannot answer the confirm, so fall back
# to the documented READ-only default rather than blocking on stdin (which aborts the build).
mount_with_write = prompt_user_to_enable_mount_with_write_if_needed(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Two interactive prompts in the build path write to stdout, so they interleave with the JSON document and make it unparseable:

  • prompt_user_to_enable_mount_with_write_if_neededclick.confirm(...) at samcli/commands/build/utils.py:106, no err= (defaults to False). Triggered by sam build --use-container for any workflow with must_mount_with_write_in_container — which includes all the dotnet ones.
  • _check_build_method_experimental_flag() (line 320) → prompt_experimentalclick.confirm(Colored().yellow(prompt), default=False) at samcli/commands/_utils/experimental.py:275.

Confirmed click.confirm() without err=True writes the prompt text to stdout, and it does so before the confirm aborts in a non-interactive environment, so stdout is corrupted either way.

The rest of the CLI already keeps human-facing output off stdout — LOG handlers go to stderr, and both the telemetry prompt (samcli/cli/main.py:161) and the update notice (samcli/lib/utils/version_checker.py:94-96) pass err=True. Following that here keeps stdout reserved for the JSON:

if click.confirm(
    f"\nBuilding functions with {config.language} inside containers needs "
    ...
    err=True,
):

Worth a test that runs --use-container --output json and asserts stdout is still parseable.

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.

Fixed using your suggested approach added err=True to both click.confirm() calls (prompt_user_to_enable_mount_with_write_if_needed) in build/utils.py and prompt_experimental in _utils/experimental.py. This keeps the prompt behavior intact but routes the text to stderr, matching the telemetry prompt and update notice. stdout stays reserved for JSON. Updated the one test that asserted the old call signature.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this not a problem with the --output text as well?

self.get_resources_to_build(),
resources_to_build,
self.base_dir,
)

builder = ApplicationBuilder(
self.get_resources_to_build(),
resources_to_build,
self.build_dir,
self.base_dir,
self.cache_dir,
Expand All @@ -314,15 +343,13 @@ def run(self) -> None:
self._check_exclude_warning()
self._check_build_method_experimental_flag()

for f in self.get_resources_to_build().functions:
for f in resources_to_build.functions:
EventTracker.track_event(EventName.BUILD_FUNCTION_RUNTIME.value, f.runtime)

self._build_result = builder.build()

self._handle_build_post_processing(builder, self._build_result)

click.secho("\nBuild Succeeded", fg="green")

# try to use relpath so the command is easier to understand, however,
# under Windows, when SAM and (build_dir or output_template_path) are
# on different drive, relpath() fails.
Expand All @@ -336,37 +363,53 @@ def run(self) -> None:
build_dir_in_success_message = self.build_dir
output_template_path_in_success_message = out_template_path

if self._print_success_message:
msg = self._gen_success_msg(
build_dir_in_success_message,
output_template_path_in_success_message,
os.path.abspath(self.build_dir) == os.path.abspath(DEFAULT_BUILD_DIR),
)

click.secho(msg, fg="yellow")
self._print_build_success(
build_dir_in_success_message,
output_template_path_in_success_message,
resources_to_build,
)
except FunctionNotFound as function_not_found_ex:
caught_exception = function_not_found_ex

raise UserException(
str(function_not_found_ex), wrapped_from=function_not_found_ex.__class__.__name__
) from function_not_found_ex
user_ex = UserException(str(function_not_found_ex), wrapped_from=function_not_found_ex.__class__.__name__)
# The failure is attributable to the specific resource the user asked to build. Resolve
# it to a full path so it matches the resource_id namespace of the success document.
user_ex.resource_name = getattr(
function_not_found_ex, "resource_name", None
) or self._resolve_resource_full_path(self._resource_identifier)
raise user_ex from function_not_found_ex
except (
UnsupportedRuntimeException,
UnsupportedBuilderException,
BuildError,
BuildInsideContainerError,
UnsupportedBuilderLibraryVersionError,
InvalidBuildGraphException,
MissingBuildMethodException,
ResourceNotFound,
) as ex:
caught_exception = ex

click.secho("\nBuild Failed", fg="red")

# Some Exceptions have a deeper wrapped exception that needs to be surfaced
# from deeper than just one level down.
deep_wrap = getattr(ex, "wrapped_from", None)
wrapped_from = deep_wrap if deep_wrap else ex.__class__.__name__
raise UserException(str(ex), wrapped_from=wrapped_from) from ex

self._print_build_failure()

user_ex = UserException(str(ex), wrapped_from=wrapped_from)
# Prefer the resource the exception attributes the failure to. Otherwise fall back to
# the resource the user asked to build - but only for failures actually tied to a
# resource. Resource-agnostic errors (corrupt build graph, outdated builder container)
# keep resource=None rather than blaming whatever resource the user happened to name.
resource_name = getattr(ex, "resource_name", None)
if resource_name is None and not isinstance(
ex, (InvalidBuildGraphException, UnsupportedBuilderLibraryVersionError)
):
# Resolve to a full path so it matches the resource_id namespace of the success document.
resource_name = self._resolve_resource_full_path(self._resource_identifier)
user_ex.resource_name = resource_name
raise user_ex from ex
finally:
if self.build_in_source:
exception_name = type(caught_exception).__name__ if caught_exception else None
Expand Down Expand Up @@ -1076,6 +1119,96 @@ def _copy_artifact_paths(self, original_resource: Dict, modified_resource: Dict)
if value is not None:
_set_prop_value(original_props, prop_name, value)

def _resolve_resource_full_path(self, resource_identifier: Optional[str]) -> Optional[str]:
"""
Resolve a resource identifier (a CLI argument, which may be a bare logical ID) to its
full path, so error.resource stays in the same namespace as resources[].resource_id in
the success document (both nested-stack-qualified, e.g. ChildStack/MyFn). Falls back to
the raw identifier if the resource cannot be looked up.
"""
if not resource_identifier:
return resource_identifier
function = self.function_provider.get(resource_identifier) if self.function_provider else None
if function:
return function.full_path
layer = self.layer_provider.get(resource_identifier) if self.layer_provider else None
if layer:
return layer.full_path
return resource_identifier

@staticmethod
def _function_to_json(function: Function) -> Dict[str, Any]:
"""
Serialize a built function for the JSON success document. Includes a package_type
discriminator so Image functions (which have runtime: None) are distinguishable from
Zip functions rather than both appearing as runtime: null.
"""
return {
"resource_id": function.full_path,
"type": "function",
"package_type": function.packagetype,
"runtime": function.runtime,
"architecture": function.architecture,
}

def _print_build_success(
self, artifacts_dir: str, output_template_path: str, resources_to_build: ResourcesToBuildCollector
) -> None:
"""
Reports a successful build, either as structured JSON or as a human readable message.

Parameters
----------
artifacts_dir: str
A string path representing the folder of built artifacts
output_template_path: str
A string path representing the final template file
resources_to_build: ResourcesToBuildCollector
The functions and layers that were built
"""
if self._output is OutputOption.json:
resources: List[Dict[str, Any]] = [
self._function_to_json(function) for function in resources_to_build.functions
] + [
{
"resource_id": layer.full_path,
"type": "layer",
"compatible_runtimes": layer.compatible_runtimes,
}
for layer in resources_to_build.layers
]
click.echo(
json.dumps(
{
"status": "success",
"build_dir": artifacts_dir,
"template_file": output_template_path,
"resources": resources,
}
)
)
return

click.secho("\nBuild Succeeded", fg="green")
if self._print_success_message:
msg = self._gen_success_msg(
artifacts_dir,
output_template_path,
os.path.abspath(self.build_dir) == os.path.abspath(DEFAULT_BUILD_DIR),
)
click.secho(msg, fg="yellow")

def _print_build_failure(self) -> None:
"""
Prints the human-readable "Build Failed" banner in text mode.

JSON-mode failure serialization is handled centrally in do_cli, which catches
execution failures raised from run()/set_up() (including exceptions raised before
run()'s try block, e.g. template parse errors or missing layer BuildMethod).
"""
if self._output is not OutputOption.json:
click.secho("\nBuild Failed", fg="red")

def _gen_success_msg(self, artifacts_dir: str, output_template_path: str, is_default_build_dir: bool) -> str:
"""
Generates a success message containing some suggested commands to run
Expand Down Expand Up @@ -1141,11 +1274,14 @@ def _setup_build_dir(build_dir: str, clean: bool) -> str:
)
raise InvalidBuildDirException(exception_message)

if build_path.exists() and os.listdir(build_dir) and clean:
# build folder contains something inside. Clear everything.
shutil.rmtree(build_dir)
try:
if build_path.exists() and os.listdir(build_dir) and clean:
# build folder contains something inside. Clear everything.
shutil.rmtree(build_dir)

build_path.mkdir(mode=BUILD_DIR_PERMISSIONS, parents=True, exist_ok=True)
build_path.mkdir(mode=BUILD_DIR_PERMISSIONS, parents=True, exist_ok=True)
except OSError as ex:
raise InvalidBuildDirException(f"Unable to use build dir {build_dir}. Reason: {str(ex)}") from ex

# ensure path resolving is done after creation: https://bugs.python.org/issue32434
return str(build_path.resolve())
Expand Down Expand Up @@ -1386,13 +1522,19 @@ def _check_build_method_experimental_flag(self) -> None:
for function in resources_to_build.functions:
if function.metadata and function.metadata.get("BuildMethod", "") in EXPERIMENTAL_BUILD_METHODS:
build_method = function.metadata.get("BuildMethod", "")
experimental_flag = EXPERIMENTAL_BUILD_METHODS[build_method]
# A JSON consumer cannot answer the interactive beta confirmation. Skip it unless the
# feature is already enabled (via --beta-features / env), in which case prompt_experimental
# just updates the telemetry context and returns without prompting.
if self._output is OutputOption.json and not is_experimental_enabled(experimental_flag):
continue
WARNING_MESSAGE = (
f'Build method "{build_method}" is a beta feature.\n'
"Please confirm if you would like to proceed\n"
'You can also enable this beta feature with "sam build --beta-features".'
)

prompt_experimental(EXPERIMENTAL_BUILD_METHODS[build_method], WARNING_MESSAGE)
prompt_experimental(experimental_flag, WARNING_MESSAGE)

@property
def build_in_source(self) -> Optional[bool]:
Expand Down
Loading