feat: add --output json flag (sam build) - #9136
Conversation
…able output Add a --output option to `sam build` that supports "text" (default, unchanged behavior) and "json" (structured output for programmatic consumers). When --output json is specified: - Success output is a JSON object with status, build_dir, template_file, and a resources array listing each built function's logical_id, runtime, and architecture. - Error output is a JSON object with status, error type, message, and the failing resource name when available. - Build progress logs are suppressed from stdout (sent to stderr only) so that stdout contains only valid JSON. This enables CI/CD pipelines, IDE extensions, and AI-assisted developer tools to consume build results programmatically without fragile text parsing.
There was a problem hiding this comment.
Code Review Results
Reviewed: 56510b1..54aed55
Files: 5
Comments: 3
Comments on lines outside the diff:
[samcli/commands/build/build_context.py:381] [BUG] In JSON mode both error handlers call sys.exit(1) instead of raising UserException. SystemExit inherits from BaseException, not Exception, so the @track_command decorator on the build CLI (see samcli/lib/telemetry/metric.py, which only catches (UserException, click.Abort, ...) and Exception) will not catch it. That means:
_send_command_run_metricsis never called for failedsam build --output jsonruns, soexit_reasonandexit_codefor these failures are silently dropped from telemetry.- The exit path diverges from every other
samcommand, which uniformly funnels errors throughUserException.
Consider raising UserException after emitting the JSON (the top-level Click framework already turns it into exit code 1), or at minimum route through click_ctx.exit(1) after ensuring telemetry has been flushed. A minimal fix:
if self._output == "json":
click.echo(json.dumps(error_result, indent=2))
# still raise so track_command records the failure;
# suppress the default click error output separately if needed
raise UserException(str(ex), wrapped_from=wrapped_from) from exThe same issue applies to the FunctionNotFound handler around line 381 and the multi-exception handler around line 407.
Changed so that we raise |
- Replace sys.exit(1) with raise UserException in error handlers to
preserve telemetry tracking via @track_command decorator
- Wrap both container and in-process build paths in try/except so
resource_name is tagged on BuildError regardless of build mode
- Include layers in JSON success output alongside functions
- Rename field from logical_id to resource_id (accurate for nested stacks)
- Add type field ("function" or "layer") to each resource entry
madhavdonthula1
left a comment
There was a problem hiding this comment.
- Replace sys.exit(1) with raise UserException in error handlers to
preserve telemetry tracking via @track_command decorator - Wrap both container and in-process build paths in try/except so
resource_name is tagged on BuildError regardless of build mode - Include layers in JSON success output alongside functions
- Rename field from logical_id to resource_id (accurate for nested stacks)
- Add type field ("function" or "layer") to each resource entry
| ) | ||
| except BuildError as ex: | ||
| ex.resource_name = function_name | ||
| raise |
There was a problem hiding this comment.
[BUG] The new try/except BuildError only wraps the ZIP build path (_build_function_on_container and _build_function_in_process). It is placed inside the if packagetype == ZIP: block, so several other BuildError sources remain unwrapped and will produce JSON error payloads with no resource field — which is the PR's headline improvement:
- _build_lambda_image (called for packagetype == IMAGE at line 741) raises DockerBuildFailed at lines 440, 443, 449, 493 and DockerfileOutSideOfContext at lines 496, 505.
- _build_layer (lines 541–678) calls _build_function_on_container / _build_function_in_process for layers with no surrounding try/except.
- build_single_layer_definition in samcli/lib/build/build_strategy.py:216 raises MissingBuildMethodException (a BuildError subclass).
For an Image function or a layer that fails to build, the JSON output will be:
{
"status": "failure",
"error": {
"type": "DockerBuildFailed",
"message": "..."
}
}...with no resource key, contradicting the PR description. Consider moving the assignment closer to where the resource is known — for example, attach resource_name at the strategy layer where function.full_path / layer.full_path is already available, so it covers Zip functions, Image functions, and Layers uniformly:
try:
... # build_single_function_definition / build_single_layer_definition body
except BuildError as ex:
if ex.resource_name is None:
ex.resource_name = resource.full_path
raiseAlternatively, duplicate the current try/except in _build_lambda_image and _build_layer.
Move the try/except BuildError from app_builder._build_function (which only covered the ZIP in-process path) to DefaultBuildStrategy's build_single_function_definition and build_single_layer_definition. This ensures resource_name is tagged for ALL build paths: - ZIP functions (in-process and container) - IMAGE functions (Docker builds) - Layer builds Known limitation: BuildInsideContainerError does not inherit from BuildError (it inherits from Exception directly), so it won't get resource_name tagged by this mechanism. Fixing this would require changing the exception hierarchy which is out of scope for this PR. The error type and message are still present in JSON output for this case — only the resource field is missing. Also fixes unit test assertion to expect the new output="text" kwarg.
| """ | ||
| try: | ||
| return self._do_build_single_function_definition(build_definition) | ||
| except BuildError as ex: |
There was a problem hiding this comment.
[BUG] The new wrapper only catches BuildError, so the resource field is still missing from JSON error output for exceptions that are raised during a per-function build but are not BuildError subclasses. The outer except in build_context.py catches five additional types, three of which originate from a specific function build:
UnsupportedRuntimeException— raised inApplicationBuilder._build_functionwhen a deprecated runtime is used (app_builder.py:752).BuildInsideContainerError— raised in_build_function_on_containeron Docker image pull failures (app_builder.py:1072, 1097).UnsupportedBuilderLibraryVersionError— raised in_build_function_on_containerwhen the container is missing the builder executable (app_builder.py:1049, 1105).
For any of these, the JSON payload emitted from build_context's error handler will not contain a resource field, which is precisely the PR's headline improvement ("the text error today does not identify which function failed"). Users on the container path or on a deprecated runtime will still see anonymous JSON failures.
Broaden the wrapper to tag any exception that surfaces from the per-definition build with the failing resource, for example:
def build_single_function_definition(self, build_definition: FunctionBuildDefinition) -> Dict[str, str]:
try:
return self._do_build_single_function_definition(build_definition)
except (
BuildError,
UnsupportedRuntimeException,
BuildInsideContainerError,
UnsupportedBuilderLibraryVersionError,
) as ex:
if getattr(ex, "resource_name", None) is None:
ex.resource_name = build_definition.get_full_path()
raiseNote that UnsupportedRuntimeException, BuildInsideContainerError, and UnsupportedBuilderLibraryVersionError do not currently define resource_name in their __init__, so either add the attribute to them (mirroring the change to BuildError) or rely on dynamic attribute assignment. The same broadening is needed for build_single_layer_definition.
… types Expand the except clause in DefaultBuildStrategy to also catch UnsupportedRuntimeException, BuildInsideContainerError, and UnsupportedBuilderLibraryVersionError. These exceptions don't inherit from BuildError but can originate from a specific function build (deprecated runtime, Docker pull failure, missing builder in container). Uses dynamic attribute assignment (ex.resource_name = ...) since these classes don't define resource_name in __init__. The consumer in build_context.py already uses getattr(ex, "resource_name", None) which handles both cases safely.
madhavdonthula1
left a comment
There was a problem hiding this comment.
broaden exception catch to add more build error types
- Apply black formatting to command.py and build_strategy.py - Fix ruff import ordering in build_strategy.py - Add type: ignore comments for mypy union-attr and index errors (dynamic resource_name assignment on non-BuildError exceptions) - Regenerate schema/samcli.json to include the new --output parameter
madhavdonthula1
left a comment
There was a problem hiding this comment.
- Apply black formatting to command.py and build_strategy.py
- Fix ruff import ordering in build_strategy.py
- Add type: ignore comments for mypy union-attr and index errors
(dynamic resource_name assignment on non-BuildError exceptions) - Regenerate schema/samcli.json to include the new --output parameter
|
|
||
| def run(self) -> None: | ||
| """Runs the building process by creating an ApplicationBuilder.""" | ||
| if self._output == "json": |
There was a problem hiding this comment.
[BUG] The unconditional logging.getLogger("samcli").setLevel(logging.WARNING) in run() silently overrides the --debug flag when a user runs sam build --debug --output json.
Flow:
- debug_option is defined with is_eager=True (
samcli/cli/options.py:26). Its callback setsctx.debug = True, which triggers the setter insamcli/cli/context.py:75and reconfigures thesamclilogger toDEBUG. - BuildContext.run() then unconditionally resets the same logger to
WARNING, suppressing every INFO/DEBUG log the user explicitly requested.
This is easy to miss because the change is silent — the user sees no warning and no debug output. It defeats the primary reason someone would combine --debug with --output json (troubleshooting an automated build).
Also note the suppression is not strictly required for "pure JSON on stdout": SAM CLI's log handlers (samcli/lib/utils/sam_logging.py:42-52) write to stderr, not stdout, so stdout is already clean of log lines regardless of level.
Two reasonable fixes:
Option A — drop the line entirely (logs go to stderr, so stdout is unaffected):
def run(self) -> None:
"""Runs the building process by creating an ApplicationBuilder."""
if self._is_sam_template():
SamApiProvider.check_implicit_api_resource_ids(self.stacks)Option B — only lower verbosity, never raise it above what the user asked for:
def run(self) -> None:
"""Runs the building process by creating an ApplicationBuilder."""
if self._output == "json":
samcli_logger = logging.getLogger("samcli")
# Don't override an explicit --debug (or any level already <= WARNING)
if samcli_logger.getEffectiveLevel() > logging.WARNING:
samcli_logger.setLevel(logging.WARNING)Either preserves clean JSON on stdout without breaking --debug.
reedham-aws
left a comment
There was a problem hiding this comment.
Overall, we need to add new tests for this output behavior (meaning, with json enabled). I think at a base level we need unit tests, not sure if we really need integration tests since the underlying behavior is not changing.
| result = { | ||
| "status": "success", | ||
| "build_dir": build_dir_in_success_message, | ||
| "template_file": output_template_path_in_success_message, | ||
| "resources": resources, |
There was a problem hiding this comment.
One thing is that in the old message, we had something like:
Commands you can use next
=========================
[*] Validate SAM template: sam validate
[*] Invoke Function: sam local invoke
[*] Test Function in the Cloud: sam sync --stack-name {{stack-name}} --watch
[*] Deploy: sam deploy --guided
Which I think is actually kind of useful for agents. I wonder if we can simplify and put that into the result? Might be doing too much and I don't really think it's to important, so up to you/other reviewers.
There was a problem hiding this comment.
Yeah this is a valid point and I actually explored it previously as a possible extension, but for sam deploy output. I tested three different types of extensions including one where next_steps was the main feature and while checking the benchmarks I noticed it did not improve the agent experience and in fact caused to add more tools called. The agent already has the inputs. It knows which flags it passed, and it knows the standard SAM commands. But to be honest I have not tested this with sam build so I'm open to doing that if anyone still finds that as something that would work specifically for sam build because theres definitely a lot more next steps for sam build.
| Scenario | With extension | Without |
|---|---|---|
Edge-layer 403 debug (defined_routes, Opus) |
13 calls / 363s | 10 calls / 215s |
Handler-mismatch debug (function_readiness, Opus) |
27 calls / 461s | 13 calls / 240s |
| Happy-path deploy (Opus) | 6 calls | 7 calls |
| IAM-permission debug (Opus) | 18 calls | 18 calls |
There was a problem hiding this comment.
I'd be curious to know what tools are actually being called after the sam deploy. What were you recommending the agent do next? Some of those might have been worthwhile so not sure if raw number of calls being higher is necessarily bad.
That being said, I think you're right that the agent probably knows enough to do those anyway. We can leave it.
| UnsupportedRuntimeException, | ||
| BuildError, | ||
| BuildInsideContainerError, | ||
| UnsupportedBuilderLibraryVersionError, | ||
| InvalidBuildGraphException, | ||
| ResourceNotFound, |
There was a problem hiding this comment.
There are 6 different exceptions here, but looking in build_strategy.py shows that we only potentially add resource name to 4 of them. Why is that?
There was a problem hiding this comment.
One of the exceptions that we do not add resource_name extension to isInvalidBuildGraphException which has no resource to find. It is raised inbuild_graph.py:648 when a build definition has zero functions in it, so there is nothing to name. get_full_path() reads self.functions[0], which would fail on the same empty list that caused the exception in the first place.
The other one is ResourceNotFound. That is raised in build_context.py:1361 inside collect_build_resources(), which runs before the build starts. So build_strategy.py is never entered and adding it to that tuple would be dead code. It also means no resource matched the identifier, so there is no build definition to read a name from.
Honestly I should've listed this information in the PR and will start doing that whenever I come across something that has differing cases.
| def __init__(self, wrapped_from: str, msg: str, resource_name: Optional[str] = None) -> None: | ||
| self.wrapped_from = wrapped_from | ||
| self.resource_name = resource_name | ||
| Exception.__init__(self, msg) |
There was a problem hiding this comment.
Is there a reason we're not adding a resource_name field to other error types that could exist?
There was a problem hiding this comment.
Yep this is valid. I dropped it. The initial thought was that a raise site might want to pass the resource in at construction, so I made it a proper field onBuildError. But in practice nothing does: build_strategy.py sets ex.resource_name at runtime for all four exception types, so the declared param was never actually used.
| "status": "failure", | ||
| "error": { | ||
| "type": "FunctionNotFound", | ||
| "message": str(function_not_found_ex), |
There was a problem hiding this comment.
I feel like it's not good to inconsistently apply the resource field. Is there a way that you can included the missing resource here even if it's not real?
There was a problem hiding this comment.
This is valid. I changed it so that it rather populates the resource or says null so that we dont have to be inconsistent.
| ) | ||
|
|
||
| click.secho(msg, fg="yellow") | ||
| if self._output == "json": |
There was a problem hiding this comment.
I would prefer that this whole block about printing gets moved to a method just to keep the BuildContext.run simple.
There was a problem hiding this comment.
Good catch! After looking into it I realized the run() method increased by 50 lines by just adding the json output code blocks. I just made two separate methods _print_build_success and _print_build_failure that I placed under the '=_gen_success_msg so that the output-related code can stay together. I think this should improve readability and will make sure to keep this in mind when developing further.
- Move --output into a shared @output_option decorator in _utils/options.py so deploy/init can reuse it instead of redeclaring the option - Give --output its own "Output Options" help group rather than filing it under Build Strategy, which is for options that change how the build runs - Extract the JSON/text reporting out of BuildContext.run() into _print_build_success and _print_build_failure - Collect get_resources_to_build() once in run() and reuse it - Always emit the "resource" key in error output (null when the failure is not attributable to a single resource) so the schema is predictable - Hoist the wrapped_from lookup to the top of the except block instead of computing it in both branches - Drop the unused resource_name param from BuildError; build_strategy sets the attribute at runtime for all handled exception types
madhavdonthula1
left a comment
There was a problem hiding this comment.
Changes made based on comments
reedham-aws
left a comment
There was a problem hiding this comment.
Looks good to me now, but we still need tests that actually touch the behavior.
Cover _print_build_success and _print_build_failure across JSON and text modes: resource list shapes for functions/layers, architecture edge cases, the always-present "resource" error key, error-type passthrough, and the text-banner suppression path. 23 tests following the existing TestBuildContext_<method> convention.
| @@ -286,12 +291,12 @@ def run(self) -> None: | |||
| # if self._mount_with is NOT WRITE | |||
| # check the need of mounting with write permissions and prompt user to enable it if needed | |||
| mount_with_write = prompt_user_to_enable_mount_with_write_if_needed( | |||
There was a problem hiding this comment.
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_needed→click.confirm(...)atsamcli/commands/build/utils.py:106, noerr=(defaults toFalse). Triggered bysam build --use-containerfor any workflow withmust_mount_with_write_in_container— which includes all the dotnet ones._check_build_method_experimental_flag()(line 320) →prompt_experimental→click.confirm(Colored().yellow(prompt), default=False)atsamcli/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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Is this not a problem with the --output text as well?
| output=output, | ||
| ) as ctx: | ||
| ctx.run() | ||
| except InvalidBuildDirException as ex: |
There was a problem hiding this comment.
[ERROR_HANDLING] The JSON failure contract still has holes beyond the InvalidBuildDirException case that was special-cased here. Two concrete paths where sam build --output json exits 1 with completely empty stdout:
samcli.commands.build.exceptions.MissingBuildMethodException(raised at build_context.py:1347 from _collect_single_buildable_layer) is a UserException, not a BuildError subclass, so it is not in run()'s except tuple. Reachable via sam build MyLayer when the layer has no BuildMethod metadata — and it is now raised inside the try block since resources_to_build is evaluated at the top.- self._is_sam_template() and self._handle_build_pre_processing() run before the try in run(), so anything they raise (e.g. template read/parse failures, check_implicit_api_resource_ids) bypasses both handlers.
This is the unresolved remainder of the earlier review comment on this topic — only the --build-dir instance was fixed. Since do_cli already wraps the whole context, a single handler there covers all of it:
except UserException as ex:
if OutputOption(output) is OutputOption.json:
echofailure_json(type(ex).__name__, str(ex), getattr(ex, "resource_name", None))
raiseNote that _print_build_failure in BuildContext would then need to avoid double-emitting, so pushing all failure serialization up to do_cli (or all of it down into BuildContext) is the cleaner split.
| ) as ctx: | ||
| ctx.run() | ||
| except InvalidBuildDirException as ex: | ||
| if output == "json": |
There was a problem hiding this comment.
[GENERAL] The failure document shape (status / error.type / error.message / error.resource) is hand-duplicated here, and the mode check uses the raw string literal output == "json" rather than the OutputOption enum that BuildContext.init now converts to. This is the same literal-comparison pattern that was flagged earlier and resolved inside BuildContext — the wire format now lives in two places that must be kept in sync, and a typo in either literal silently selects text mode with no test coverage to catch it.
Extracting the serialization into one helper used by both do_cli and _print_build_failure, and comparing OutputOption(output) is OutputOption.json, removes both problems.
Verified as correctly addressed and not re-raised: err=True on both interactive prompts, function.architecture for the x86_64 default, single-line json.dumps, resource_name class-level defaults on all four caught exception types (Optional is already imported in workflow_config.py), the DefaultBuildStrategy wrappers (CachedBuildStrategy and IncrementalBuildStrategy both delegate through them, so attribution holds for cached/incremental builds), and test_run_json_mode_emits_single_parseable_document. I did not run the test suite.
Move all --output json failure serialization to a single except UserException handler in do_cli, covering every failure path uniformly (missing layer BuildMethod, invalid build dir, pre-processing errors) instead of only the exception types caught inside run(). - Add build_failure_json() helper in build_context as the single source of truth for the failure wire format - do_cli uses the helper and checks OutputOption(output) is OutputOption.json (enum, not raw string literal) - run() only prints the text "Build Failed" banner and re-raises UserException carrying resource_name; no double-emit - Add resource_name class attribute to UserException - Move JSON failure tests to test_command.py (do_cli level); simplify _print_build_failure tests to text-banner only
- Merge two near-identical "stays silent" tests into one parameterized test - Use OutputOption enum instead of raw "text"/"json" strings so the tests match how the real code sets _output (via enum conversion in __init__), removing a latent fragility where raw strings passed only by coincidence
| output=output, | ||
| ) as ctx: | ||
| ctx.run() | ||
| except UserException as ex: |
There was a problem hiding this comment.
[ERROR_HANDLING] The central handler catches UserException, which closes the InvalidBuildDirException / MissingBuildMethodException gaps, but there is still a reachable user-triggerable path that exits 1 with completely empty stdout in JSON mode: UnsupportedBuilderException.
It is raised at samcli/lib/build/workflow_config.py:72 when get_selector() receives a specified_workflow that isn't in the selector map — i.e. a layer (or function) with Metadata.BuildMethod: . It reaches there via ApplicationBuilder._build_layer → get_workflow_config(None, code_dir, base_dir, specified_workflow) (app_builder.py:593). It subclasses plain Exception, so it is neither in run()'s except tuple nor a UserException, and it escapes this handler.
That means an agent parsing stdout gets nothing for a plain template typo, which is exactly the class of error the feature is meant to report. Adding UnsupportedBuilderException to run()'s existing except tuple would convert it to a UserException and route it through build_failure_json with the rest — the same treatment UnsupportedRuntimeException (its sibling in that module) already gets.
| raise UserException( | ||
| str(function_not_found_ex), wrapped_from=function_not_found_ex.__class__.__name__ | ||
| ) from function_not_found_ex | ||
| self._print_build_failure(print_text_banner=False) |
There was a problem hiding this comment.
[GENERAL] Both new statements in the FunctionNotFound handler are unconditionally no-ops:
self._print_build_failure(print_text_banner=False)
user_ex = UserException(str(function_not_found_ex), wrapped_from=function_not_found_ex.__class__.__name__)
user_ex.resource_name = getattr(function_not_found_ex, "resource_name", None)_print_build_failure's only branch is if self._output is not OutputOption.json and print_text_banner:, so passing print_text_banner=False guarantees it does nothing in either output mode — the call is dead and reads as though it emits something. And FunctionNotFound (samcli/lib/providers/exceptions.py) never carries resource_name; nothing in the codebase sets it on that type, so the getattr always yields None.
The net effect is that sam build MyFunction --output json for a bad identifier reports "resource": null even though self._resource_identifier holds the exact resource that could not be found. Using self._resource_identifier here would populate it correctly; if null is the intended contract for this case, the two dead statements should just be dropped.
| @patch("samcli.commands.build.build_context.click.secho") | ||
| @patch("samcli.commands.build.build_context.click.echo") | ||
| def test_text_mode_prints_banner_and_message(self, echo_mock, secho_mock): | ||
| self.build_context._output = "text" |
There was a problem hiding this comment.
[GENERAL] test_text_mode_prints_banner_and_message and test_text_mode_banner_only_when_success_message_disabled set self.build_context._output = "text" — the raw string, not OutputOption.text. Production code compares with self._output is OutputOption.json, so any non-enum value falls through to the text branch and these tests pass for the wrong reason: they would pass equally with _output = "json".
That leaves the enum invariant introduced to fix the earlier string-literal comparison untested on the success path. The sibling TestBuildContext_print_build_failure class does this correctly (self.build_context._output = OutputOption.text); these two should match it.
UnsupportedBuilderException (raised for an invalid Metadata.BuildMethod, e.g. a template typo) subclasses plain Exception, so it escaped both run()'s except tuple and do_cli's UserException handler - exiting 1 with empty stdout in --output json. - Add it to run()'s except tuple so it converts to UserException and routes through build_failure_json, matching its sibling UnsupportedRuntimeException - Add it to the DefaultBuildStrategy except tuples so resource_name is attributed - Add resource_name class attribute (Comment aws#5 pattern) - Extend test_must_catch_known_exceptions to cover it
There was a problem hiding this comment.
Code Review Results
Reviewed: fe8f8db..588ff68
Files: 15
Comments: 3
Comments on lines outside the diff:
[samcli/lib/build/build_strategy.py:233] [GENERAL] MissingBuildMethodException is raised inside _do_build_single_layer_definition (the if layer.build_method is None check, now at line ~215 of the extracted method) but is absent from the attribution tuple in the new wrapper:
except (
BuildError,
UnsupportedRuntimeException,
UnsupportedBuilderException,
BuildInsideContainerError,
UnsupportedBuilderLibraryVersionError,
) as ex:
if getattr(ex, "resource_name", None) is None:
ex.resource_name = layer_definition.full_pathSince it is a UserException, do_cli's central handler does emit a JSON failure document (so this is not the empty-stdout gap flagged previously), but it reports "resource": null for a failure whose resource is named in the exception message itself and available as layer_definition.full_path in the enclosing scope. Adding it to the tuple closes the last easily-attributable layer failure. MissingBuildMethodException already declares no resource_name, so it would need the same class-level resource_name: Optional[str] = None default the other four exception types received in samcli/lib/build/exceptions.py.
Verified and not flagged: the --output value round-trip (click.Choice(case_sensitive=False) normalizes to the lowercase choice, so OutputOption(output) is safe in both do_cli and BuildContext.init); the stdout-purity contract for JSON mode (the only remaining click writers reachable from the build path are the two prompts now carrying err=True; container build logs go through a StreamWriter bound to osutils.stderr(), UserException.show() writes to stderr, and unsupported_command_cdk / track_template_warnings are not applied to sam build); InvalidBuildDirException from _setup_build_dir is reachable by the new handler since set_up() runs inside the with expression, itself inside the try; the resource_name attribution survives ParallelBuildStrategy because AsyncContext.run_async re-raises the original exception object; and schema/samcli.json places output exactly where the decorator order would generate it.
| ) from function_not_found_ex | ||
| self._print_build_failure(print_text_banner=False) | ||
| user_ex = UserException(str(function_not_found_ex), wrapped_from=function_not_found_ex.__class__.__name__) | ||
| user_ex.resource_name = getattr(function_not_found_ex, "resource_name", None) |
There was a problem hiding this comment.
[BUG] The FunctionNotFound handler cannot ever populate error.resource, and one of its two new statements is unreachable code:
self._print_build_failure(print_text_banner=False)
user_ex = UserException(str(function_not_found_ex), wrapped_from=function_not_found_ex.__class__.__name__)
user_ex.resource_name = getattr(function_not_found_ex, "resource_name", None)_print_build_failure's only branch is if self._output is not OutputOption.json and print_text_banner: — with print_text_banner=False it is a guaranteed no-op regardless of mode.
FunctionNotFound (samcli/local/lambdafn/exceptions.py:6) is a plain Exception and is never assigned resource_name anywhere, so the getattr always yields None, which is already the class default on UserException. Net effect: sam build SomeFunction --output json for a non-existent function reports "resource": null even though the failing resource identifier is the one thing that is known with certainty at that point — it is self._resource_identifier, which is what collect_build_resources was called with. Given that resource attribution on failure is the headline of this PR, this is the one failure path where it is trivially available and still missing:
user_ex.resource_name = self._resource_identifierThis was raised in earlier review and not addressed.
| @patch("samcli.commands.build.build_context.click.secho") | ||
| @patch("samcli.commands.build.build_context.click.echo") | ||
| def test_text_mode_prints_banner_and_message(self, echo_mock, secho_mock): | ||
| self.build_context._output = "text" |
There was a problem hiding this comment.
[GENERAL] test_text_mode_prints_banner_and_message and test_text_mode_banner_only_when_success_message_disabled set the raw string:
self.build_context._output = "text"Production code discriminates with if self._output is OutputOption.json, so any non-enum value — including the string "json" — falls through to the text branch. Both tests pass for the wrong reason and would pass identically with _output = "json", meaning the text-mode branch of _print_build_success has no test that actually depends on the mode value. The sibling TestBuildContext_print_build_failure class in the same diff correctly uses OutputOption.text and OutputOption.json; these two should use OutputOption.text for consistency and to make the assertions meaningful. Also raised earlier and not addressed.
…ilures Failure paths that know exactly which resource failed were reporting "resource": null in --output json: - FunctionNotFound handler had a dead _print_build_failure(print_text_banner=False) no-op and a getattr that FunctionNotFound never carries. Both failure handlers now fall back to self._resource_identifier, so sam build <bad-name> reports that name. - MissingBuildMethodException (raised from _collect_single_buildable_layer, a UserException) was not in run()'s except tuple, so it reached do_cli with no resource attribution. Added it; the layer name now populates via the fallback. Tests: use OutputOption.text (not the raw string) in the two success-path text-mode tests so they actually exercise the enum comparison, and assert resource_name fallback in test_must_catch_known_exceptions.
| """ | ||
| Build the unique definition and then copy the artifact to the corresponding layer folder | ||
| """ | ||
| try: |
There was a problem hiding this comment.
[GENERAL] The new resource-attribution wrapper on build_single_layer_definition omits MissingBuildMethodException, which is raised inside _do_build_single_layer_definition itself (if layer.build_method is None) — the most common way a layer build fails. It is caught in run()'s known-exception tuple and re-wrapped, so JSON consumers get "type": "MissingBuildMethodException" but "resource": null unless the user happened to pass an explicit resource id (the self._resource_identifier fallback). The layer's full_path is available at the wrapper, so adding it to the tuple closes the gap:
except (
BuildError,
UnsupportedRuntimeException,
UnsupportedBuilderException,
BuildInsideContainerError,
UnsupportedBuilderLibraryVersionError,
MissingBuildMethodException,
) as ex:(MissingBuildMethodException is already imported in this module, and it inherits resource_name from UserException.)
There was a problem hiding this comment.
This isn't needed, the MissingBuildMethodException raised atbuild_strategy.py:256 is imported from samcli.lib.build.exceptions, where it's a BuildErro subclass (not the UserException one). Since BuildError is already in the layer wrapper's except tuple, that exception is already caught and gets resource_name = layer_definition.full_pathadding it explicitly would be redundant with the parent catch. The "resource": null case you're describing is actually the other MissingBuildMethodException. The UserException in samcli.commands.build.exceptions, raised during get_resources_to_build() before the strategy runs. Verified end-to-end: sam build MyLayer --output json on a BuildMethod-less layer reports "resource": "MyLayer".
| use_buildkit=use_buildkit, | ||
| language_extensions=language_extensions, | ||
| output=output, | ||
| ) as ctx: |
There was a problem hiding this comment.
[ERROR_HANDLING] The comment states this handler "covers every failure path uniformly," but it only catches UserException. Anything that reaches the CLI layer as a non-UserException still exits 1 with completely empty stdout in JSON mode.
A concrete instance sits immediately above the code this PR just hardened — in _setup_build_dir the PR wraps build_path.mkdir() in InvalidBuildDirException, but the shutil.rmtree(build_dir) two lines earlier is left unwrapped:
if build_path.exists() and os.listdir(build_dir) and clean:
shutil.rmtree(build_dir) # OSError here is not converted
try:
build_path.mkdir(mode=BUILD_DIR_PERMISSIONS, parents=True, exist_ok=True)
except OSError as ex:
raise InvalidBuildDirException(...) from exAn OSError from rmtree (permission denied, file held open — routine on Windows) propagates as an unhandled exception, so sam build --output json produces no JSON document at all. Either extend the try to cover rmtree, or soften the comment so it doesn't promise a guarantee the handler cannot make. Given that agent consumers will parse stdout unconditionally, I'd lean toward the former plus a note in the docstring that unexpected internal errors are not serialized.
…comment - Extend the try in _setup_build_dir to cover shutil.rmtree, not just mkdir. An OSError while clearing the build dir (permission denied, file held open on Windows) now surfaces as InvalidBuildDirException -> JSON error, instead of a bare OSError that exits 1 with empty stdout in --output json. - Soften the do_cli handler comment: it covers user-facing failures, not "every failure path"; note that an unexpected internal error (bare Exception) is not serialized. - Add test_rmtree_oserror_is_converted_to_invalid_build_dir.
| output=output, | ||
| ) as ctx: | ||
| ctx.run() | ||
| except UserException as ex: |
There was a problem hiding this comment.
[ERROR_HANDLING] The central handler only catches UserException, and the inline comment scopes the remaining gap to "an unexpected internal error (a bug surfacing as a bare Exception)". That framing understates it — there are user-triggerable template errors in the build path that are plain Exception subclasses, so sam build --output json exits non-zero with completely empty stdout for them.
Concrete instance: samcli/lib/providers/exceptions.py declares InvalidLayerReference, RemoteStackLocationNotSupported, MissingCodeUri and InvalidTemplateFile as bare Exception, not UserException. InvalidLayerReference is raised at samcli/lib/providers/sam_function_provider.py:777 when a function's Layers list references a resource that isn't AWS::Serverless::LayerVersion / AWS::Lambda::LayerVersion — a plain typo in the template. That runs inside SamFunctionProvider(...) during BuildContext.set_up(), i.e. inside enter, inside this try, and falls straight through the except UserException. RemoteStackLocationNotSupported (sam_stack_provider.py:156/160/190/194) is reachable the same way for a nested stack whose TemplateURL points at S3.
These are the same class of hole as the InvalidBuildDirException and shutil.rmtree cases already fixed in this PR, but they can't be closed by special-casing individual raise sites. Either broaden the catch here (e.g. except Exception → emit JSON → raise, letting samcli/cli/main.py continue to wrap non-UserException into UnhandledException for telemetry), or state in the --output help text and the comment that the failure document is only guaranteed for UserException paths so consumers don't treat empty stdout as unexpected.
| ) | ||
| click.secho(msg, fg="yellow") | ||
|
|
||
| def _print_build_failure(self, print_text_banner: bool = True) -> None: |
There was a problem hiding this comment.
[GENERAL] print_text_banner has no production caller that passes False — the FunctionNotFound handler that used to call _print_build_failure(print_text_banner=False) was reduced to setting resource_name only, and the one remaining call site in run() uses the default. The parameter and the and print_text_banner conjunct are now dead, and test_print_build_failure_stays_silent locks in a (OutputOption.text, False) case that no code path can produce, which reads as if some caller does suppress the banner.
Dropping the parameter makes the method body just if self._output is not OutputOption.json: click.secho(...), which matches what actually happens.
… param - Change do_cli's except UserException to except Exception so every build-path failure emits a JSON document, including bare-Exception template errors (InvalidLayerReference, RemoteStackLocationNotSupported, MissingCodeUri, InvalidTemplateFile) that are not UserException subclasses. Re-raises unconditionally so @track_command still records telemetry and wraps non-UserException as UnhandledException. - Drop the now-dead print_text_banner parameter from _print_build_failure; no caller passes False since the FunctionNotFound handler stopped calling it. - Add test_json_failure_for_bare_exception; simplify the silence test.
There was a problem hiding this comment.
Code Review Results
Reviewed: 9101836..0b14a08
Files: 15
Comments: 2
Comments on lines outside the diff:
[samcli/commands/build/command.py:253] [ERROR_HANDLING] The two option-preprocessing calls sit just outside the new try, so they are not covered by the central JSON serializer — which contradicts the inline comment's claim that catching broadly here "guarantees a JSON failure document for every error reachable from the build."
processed_env_vars = process_env_var(container_env_var)
processed_build_images = process_image_options(build_image) # <-- can raise, outside try
try:
with BuildContext(...) as ctx:
ctx.run()
except Exception as ex:
if OutputOption(output) is OutputOption.json:
click.echo(build_failure_json(ex))
raiseprocess_image_options (samcli/commands/_utils/option_value_processor.py:71) raises InvalidImageException when an entry has an empty URI, so sam build --output json --build-image MyFunction= exits 1 with completely empty stdout. This is the same class of hole as the InvalidBuildDirException / MissingBuildMethodException cases that were already special-cased and then generalized, so it should not need another round.
Moving both lines inside the try closes it:
try:
processed_env_vars = process_env_var(container_env_var)
processed_build_images = process_image_options(build_image)
with BuildContext(...) as ctx:
ctx.run()
except Exception as ex:
...I verified the other stdout-interleaving risks are handled: SAM's log handlers, the loading-pattern writer, ApplicationBuilder's default stream writer, the version-check notice, and the telemetry prompt all target stderr, and the two click.confirm calls reachable from build now pass err=True.
| "type": "string", | ||
| "description": "Name or ID of an existing docker network for AWS Lambda docker containers to connect to, along with the default bridge network. If not specified, the Lambda containers will only connect to the default bridge docker network." | ||
| }, | ||
| "output": { |
There was a problem hiding this comment.
[GENERAL] Re-raising the earlier unanswered question, with an answer to its premise: the target does exist — make schema runs python -m schema.make_schema, and make pr invokes it. Please confirm the output property and the regenerated description string came from running that target rather than being edited by hand, otherwise the next regeneration will produce a diff.
I also traced the resource-attribution path end to end and it holds up: the new wrappers are on DefaultBuildStrategy, CachedBuildStrategy / CachedOrIncrementalBuildStrategyWrapper / ParallelBuildStrategy all delegate down to it, AsyncContext.run_async re-raises the original exception object (so --parallel preserves resource_name), and ApplicationBuilder.build() does not re-wrap on the way out. The author's claim that samcli.lib.build.exceptions.MissingBuildMethodException is a BuildError subclass and therefore already covered by the layer wrapper's except tuple is correct.
process_env_var and process_image_options ran outside the try, so an InvalidImageException from a malformed --build-image (e.g. --build-image MyFunc=) exited 1 with empty stdout in --output json. Moving both inside the try routes them through the central JSON failure handler.
| output=output, | ||
| ) as ctx: | ||
| ctx.run() | ||
| except Exception as ex: |
There was a problem hiding this comment.
[ERROR_HANDLING] The comment on this handler claims it "guarantees a JSON failure document for every error reachable from the build." That is true for everything reachable from do_cli inward, but click-level parameter processing runs before cli()'s body, so those errors still exit non-zero with completely empty stdout in JSON mode.
Concrete, user-triggerable instance: sam build --output json --skip-prepare-infra (without --hook-name). skip_prepare_infra_callback at samcli/commands/_utils/options.py:263 raises click.BadOptionUsage during parameter callback evaluation, so do_cli is never entered. Click prints usage to stderr and exits 2; stdout is empty. Same for a malformed --parameter-overrides and for --output jsn itself.
This is a different class from the in-process gaps already closed here, and arguably acceptable CLI behavior — but the discussion thread has repeatedly framed the guarantee as "consumers never get empty stdout," so the two need to be reconciled. Either handle click.UsageError at the command class level (the build command already subclasses CoreCommand, so overriding invoke/main is available), or scope the promise explicitly in the --output help text and PR description to execution failures rather than invocation errors.
| 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 (relevant even when it doesn't exist). | ||
| user_ex.resource_name = getattr(ex, "resource_name", None) or self._resource_identifier |
There was a problem hiding this comment.
[GENERAL] error.resource and resources[].resource_id are not drawn from the same identifier namespace, so a consumer cannot reliably correlate a failure back to a resource it saw in a success document.
Success reports function.full_path / layer.full_path (nested-stack-qualified, e.g. ChildStack/MyFn), and build_strategy.py also attributes via full paths (build_definition.get_full_path(), layer_definition.full_path). But this fallback — and the identical one at line 372 — uses self._resource_identifier, which is the raw CLI argument echoed verbatim. collect_build_resources accepts either a bare logical ID or a full path, so sam build MyFn against a nested-stack function yields "resource": "MyFn" while the corresponding success document would have reported "resource_id": "ChildStack/MyFn".
Resolving the identifier to the full path before assigning it (the collector already has the resolved Function/LayerVersion at this point) would make the two fields consistent.
| { | ||
| "resource_id": function.full_path, | ||
| "type": "function", | ||
| "runtime": function.runtime, |
There was a problem hiding this comment.
[GENERAL] The success document has no way to express an Image package-type function. Function.runtime is None for PackageType: Image resources (no Runtime property in the template), and function.architecture still resolves to x86_64, so an image function serializes as:
{"resource_id": "MyImageFn", "type": "function", "runtime": null, "architecture": "x86_64"}indistinguishable from a malformed ZIP entry. sam build fully supports image functions, and the layer branch already carries a type-specific field (compatible_runtimes), so adding packagetype — or imageuri/image_tag for the image case — would close this. Worth settling before the format ships, since adding a discriminator later is a breaking change for consumers that key off runtime.
There was a problem hiding this comment.
Code Review Results
Reviewed: 9101836..050e1ec
Files: 15 (focused on build_context.py, command.py, build_strategy.py, exceptions.py, options.py; skipped generated schema/samcli.json after confirming it matches make_schema output — sorted enum, default, and the regenerated description bullet are all in the position the generator produces)
Comments: 2
| output=output, | ||
| ) as ctx: | ||
| ctx.run() | ||
| except Exception as ex: |
There was a problem hiding this comment.
[ERROR_HANDLING] The comment on this handler claims it "guarantees a JSON failure document for every error reachable from the build," and build_context.py repeats the claim ("a single handler covers every failure path", "Single source of truth for the failure wire format"). There is a reachable class of failures it structurally cannot cover: anything raised during click option processing, because that runs before cli()/do_cli() are ever entered.
Concretely, sam build is decorated with @hook_name_click_option(force_prepare=True, ...), whose HookNameOption.handle_parse_result runs the IaC prepare hook and stores any failure on the click context instead of raising:
try:
self._call_prepare_hook(iac_hook_wrapper, opts, ctx)
except Exception as ex:
# capture exceptions from prepare hook to emit in track_command
c = Context.get_current_context()
c.exception = extrack_command then re-raises it before invoking the command function (samcli/lib/telemetry/metric.py:149-151):
if ctx and ctx.exception:
# re-raise here to handle exception captured in context and not run func()
raise ctx.exceptionSo sam build --hook-name terraform --output json on a project where terraform init/plan fails exits 1 with completely empty stdout — the exact failure mode the earlier round of comments was closing. Same for the click.BadParameter raised by _validate_coexist_options / _validate_build_command_parameters and for an unknown --hook-name value.
Either serialize at a level that wraps the whole invocation (e.g. the BuildCommand class already set as cls=, which can inspect Context.exception), or narrow the three comments so consumers aren't told the schema is guaranteed on every non-zero exit. Given the stated agent-consumption motivation, an agent that assumes "exit != 0 ⇒ parseable JSON on stdout" will break on a documented Terraform workflow.
| UnsupportedBuilderLibraryVersionError, | ||
| ) as ex: | ||
| if getattr(ex, "resource_name", None) is None: | ||
| ex.resource_name = build_definition.get_full_path() |
There was a problem hiding this comment.
[GENERAL] build_definition.get_full_path() returns only the first function of the build definition:
def get_full_path(self) -> str:
"""
Return the build identifier of the first function
"""
self._validate_functions()
return self.functions[0].full_pathFunctions that share runtime + CodeUri + metadata collapse into one FunctionBuildDefinition, so a single failed build affects all of them, but error.resource names just one — silently, with no indication that others were affected. This directly undercuts the feature's headline benefit ("the text error today does not identify which function failed"): for the shared-CodeUri layout it identifies one of N, and which one depends on collection order.
The codebase already has the right accessor for this — get_resource_full_paths() (used in the LOG.info calls in _do_build_single_function_definition a few lines below) returns every function in the definition. Consider using it, or making resource a list, or at minimum documenting that it is a representative resource rather than an exhaustive one. The layer wrapper is unaffected: LayerBuildDefinition.full_path maps to exactly one layer.
There was a problem hiding this comment.
Can we make resource_name a list? I agree with this comment and that preserves the json.
The three comments claimed do_cli's handler serializes "every error reachable from the build". That is only true for execution failures once do_cli runs. Errors raised during click option processing (invalid flags, or a --hook-name prepare-hook failure re-raised by track_command) occur before do_cli and surface as click's standard usage/stderr output, not JSON. Soften the three comments to state the guarantee covers execution failures and note that invocation errors surface via click's stderr, so consumers treat "exit != 0 with empty stdout" as an invocation error. No behavior change.
| UnsupportedBuilderLibraryVersionError, | ||
| ) as ex: | ||
| if getattr(ex, "resource_name", None) is None: | ||
| ex.resource_name = build_definition.get_full_path() |
There was a problem hiding this comment.
[BUG] ex.resource_name = build_definition.get_full_path() attributes the failure to only the first function in the build definition:
def get_full_path(self) -> str:
"""
Return the build identifier of the first function
"""
self._validate_functions()
return self.functions[0].full_pathFunctions that share runtime + CodeUri + metadata collapse into a single FunctionBuildDefinition (build_graph.py:623), so one failed build affects every function in build_definition.functions, but error.resource names one arbitrarily. A consumer that retries or reports only the named resource will silently miss the others.
FunctionBuildDefinition.get_resource_full_paths() (build_graph.py:642) already returns all of them, and it is what the surrounding LOG.info calls in this same file use for exactly this reason (CachedBuildStrategy lines 273, 297). Consider using it, or emitting a list so the field stays machine-readable:
if getattr(ex, "resource_name", None) is None:
ex.resource_name = build_definition.get_resource_full_paths()This was raised in the earlier review round and was not addressed or dismissed.
| { | ||
| "resource_id": function.full_path, | ||
| "type": "function", | ||
| "runtime": function.runtime, |
There was a problem hiding this comment.
[GENERAL] The success document cannot express an Image package-type function. Function.runtime is None for PackageType: Image (no Runtime property in the template) and function.architecture still resolves to x86_64, so an image function serializes as:
{"resource_id": "MyImageFn", "type": "function", "runtime": null, "architecture": "x86_64"}which is indistinguishable from a malformed ZIP entry. sam build supports image functions, and Function.packagetype (provider.py:118) is already available on the object being serialized. Adding it as a discriminator makes the entry self-describing:
{
"resource_id": function.full_path,
"type": "function",
"package_type": function.packagetype,
"runtime": function.runtime,
"architecture": function.architecture,
}Also raised earlier without a response.
| 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 (relevant even when it doesn't exist). | ||
| user_ex.resource_name = getattr(ex, "resource_name", None) or self._resource_identifier |
There was a problem hiding this comment.
[GENERAL] error.resource and resources[].resource_id are still not guaranteed to come from the same identifier namespace, so a consumer cannot reliably correlate a failure back to a resource it saw in a success document.
The success path emits function.full_path / layer.full_path (nested-stack qualified, e.g. ChildStack/MyFn), and build_strategy.py now attributes via full paths too. But both fallbacks here use self._resource_identifier — the raw CLI argument as the user typed it (resource_logical_id), which is a bare logical ID in the common case. Same at line 374 in the FunctionNotFound handler.
Secondary concern with the same expression: the fallback fires for failures that are not resource-specific. InvalidBuildGraphException (corrupt build.toml) and UnsupportedBuilderLibraryVersionError (outdated Docker container) are both in this except tuple and both carry resource_name = None, yet they will be reported against self._resource_identifier whenever the user happened to pass one — pointing consumers at a resource that is not at fault.
Consider resolving the fallback through get_full_path(stack_path, resource_identifier) (already imported at line 56), and restricting the fallback to the exception types where resource attribution is meaningful.
Previously raised; the build_strategy.py half was fixed, but the fallback in this handler was not.
Notes on things I checked and found sound: the MissingBuildMethodException / UnsupportedBuilderException gaps are closed (hoisting get_resources_to_build() into run()'s try plus the broadened except in do_cli); Optional is imported in every file that gained an Optional[str] class attribute; resource_name survives ParallelBuildStrategy because async_utils._run_given_tasks_async re-raises the original exception object; all logging and the two prompts now go to stderr, and samcli/lib/build/** contains no click.echo/secho, so stdout stays a single JSON document; and schema/samcli.json matches what schema/make_schema.py produces (sorted enum, title, default, and position consistent with the decorator order in command.py), so the earlier hand-edit concern appears resolved.
…ailures - Add package_type to each function in the JSON success document so Image functions (runtime: null) are distinguishable from Zip, via a new _function_to_json helper. - Restrict the resource_name fallback: InvalidBuildGraphException and UnsupportedBuilderLibraryVersionError are not resource-specific, so they keep resource=None instead of being attributed to whatever resource the user named. - Document that error.resource is a representative id when functions share a build definition (points to get_resource_full_paths for the full set). - Tests: package_type for image functions; per-type resource_name expectations.
| if resource_name is None and not isinstance( | ||
| ex, (InvalidBuildGraphException, UnsupportedBuilderLibraryVersionError) | ||
| ): | ||
| resource_name = self._resource_identifier |
There was a problem hiding this comment.
[GENERAL] error.resource and resources[].resource_id are still drawn from two different identifier namespaces, so a consumer cannot reliably correlate a failure back to a resource it saw in a success document. This was raised in earlier rounds and is not addressed in the current revision.
The success path emits nested-stack-qualified full paths — _function_to_json uses function.full_path and the layer branch uses layer.full_path — and build_strategy.py attributes failures the same way (build_definition.get_full_path(), layer_definition.full_path). But both fallbacks in this file use self._resource_identifier, which is the raw resource_logical_id CLI argument:
resource_name = getattr(ex, "resource_name", None)
if resource_name is None and not isinstance(
ex, (InvalidBuildGraphException, UnsupportedBuilderLibraryVersionError)
):
resource_name = self._resource_identifierand at line 374 in the FunctionNotFound handler:
user_ex.resource_name = getattr(function_not_found_ex, "resource_name", None) or self._resource_identifierSamFunctionProvider.get() resolves a bare logical ID for a nested-stack resource, so sam build MyFn on a function that lives in ChildStack produces "resource": "MyFn" on the fallback paths while a successful run of the same template reports "resource_id": "ChildStack/MyFn". A consumer keying off resource_id gets no match.
Suggested fix: resolve the identifier to a full path once before using it as a fallback, e.g. look the resource up via self.function_provider.get(self._resource_identifier) / self.layer_provider.get(...) and use its full_path, falling back to the raw string only if the lookup fails. That keeps error.resource in the same namespace as resources[].resource_id in every case.
Notes on what I verified while reviewing, so the scope is clear: the single-JSON-document contract on stdout holds for the build path — SamCliLogger attaches its handler to stderr (RichHandler(console=Console(stderr=True)) or bare logging.StreamHandler()), the version-check notice and telemetry prompt already pass err=True, the two click.confirm calls now do too, samcli/hook_packages has no click.echo/secho, and unsupported_command_cdk (which writes warnings to stdout) is not applied to build. LayerVersion.compatible_runtimes, Function.packagetype, and Function.architecture all exist. Click's Choice(case_sensitive=False) normalizes to the declared lowercase choice, so OutputOption(output) is safe for --output JSON. AsyncContext re-raises the original exception object, so resource_name survives --parallel. Telemetry still keys off wrapped_from, so re-wrapping MissingBuildMethodException does not change the reported exit reason. The schema/samcli.json entry is consistent with decorator order and the regenerated description string.
The JSON success document reports resources[].resource_id as the resource's full_path (nested-stack-qualified, e.g. ChildStack/MyFn), but the failure fallback reported the raw CLI identifier (MyFn). Consumers could not correlate a failure back to the resource_id they saw on success. Add _resolve_resource_full_path(), which looks the identifier up via the same function/layer providers the success document uses and returns its full_path, falling back to the raw identifier only when lookup fails. Both fallback paths (FunctionNotFound and the general handler) now route through it.
| # collapse into one build definition, so a single failure can affect several; | ||
| # get_full_path() names the first. get_resource_full_paths() lists all if a | ||
| # consumer needs the full set. | ||
| ex.resource_name = build_definition.get_full_path() |
There was a problem hiding this comment.
[GENERAL] The single-resource attribution for collapsed build definitions is still unresolved, and the new comment points at a mitigation that JSON consumers cannot reach.
if getattr(ex, "resource_name", None) is None:
# Representative resource id. Functions sharing runtime + CodeUri + metadata
# collapse into one build definition, so a single failure can affect several;
# get_full_path() names the first. get_resource_full_paths() lists all if a
# consumer needs the full set.
ex.resource_name = build_definition.get_full_path()I confirmed FunctionBuildDefinition.get_full_path() returns self.functions[0].full_path (build_graph.py:623), and functions sharing runtime + CodeUri + metadata are merged into one definition, so one BuildError genuinely fails all of them. get_resource_full_paths() (build_graph.py:642) does return the complete list — but it is an internal Python method, and nothing in the failure document exposes it. The consumer of --output json sees exactly one error.resource and has no way to learn the other affected functions, which is the correlation problem this feature is meant to solve.
Two options, either is fine:
- Carry the full set through, e.g. set an additional attribute from build_definition.get_resource_full_paths() and emit it in build_failure_json as error.resources alongside the existing scalar error.resource (keeps the current shape backward compatible).
- If naming one representative is the deliberate contract, drop the get_resource_full_paths() sentence — as written it describes an escape hatch that does not exist for this output format.
Notes on what I verified and did not flag:
- error.resource / resources[].resource_id namespaces now match. _resolve_resource_full_path resolves bare CLI ids via the function/layer providers, and LayerBuildDefinition.full_path is constructed from layer.full_path (app_builder.py:305), so both sides are nested-stack-qualified.
- stdout purity in JSON mode holds end to end. ApplicationBuilder's default stream writer is stderr (app_builder.py:181), container build logs go to osutils.stderr() (app_builder.py:1056), ContainerManager pull output defaults to stderr (manager.py:154), the telemetry prompt (main.py:161), version notice (version_checker.py:93), and UnhandledException.show() all use err=True, and the two click.confirm calls are now routed to stderr by this PR. No click.echo/secho remains on the build path that writes to stdout.
- Attribution reaches every strategy: Cached, Incremental, Parallel, and CachedOrIncremental all delegate down to DefaultBuildStrategy, and AsyncContext re-raises the original exception object, so resource_name survives --parallel.
- MissingBuildMethodException in run()'s except tuple resolves to the UserException variant (build_context.py:23); the BuildError variant raised in build_strategy.py:256 is already covered by BuildError. Telemetry exit_reason is unchanged because metric.py:169 prefers wrapped_from.
- schema/samcli.json is consistent with python -m schema.make_schema: the sorted ["json","text"] enum matches SamCliParameterSchema.to_schema()'s choices.sort(), and the property position matches decorator order. ALL_OPTIONS also still satisfies tests/unit/commands/buildcmd/core/test_options.py.
- Optional is imported in both files that gained resource_name: Optional[str] = None, and Any/List are already imported in build_context.py.
- The click-option-processing gap (e.g. --skip-prepare-infra without --hook-name) is now explicitly scoped in the do_cli handler comment, so I did not re-raise it as a defect.
The comment pointed JSON consumers at get_resource_full_paths() as an escape hatch for the full set of collapsed functions, but that method is internal and nothing in the failure document exposes it. Naming one representative is the deliberate contract — error.resource is a single value. Update the comment to say so and stop describing an escape hatch that does not exist for this output.
There was a problem hiding this comment.
Code Review Results
Reviewed: 9101836..5ad0867
Files: 15
Comments: 2
Comments on lines outside the diff:
[samcli/commands/build/build_context.py:315] [ERROR_HANDLING] JSON mode still issues blocking interactive confirms, and the resulting failure document is empty of useful information.
mount_with_write = prompt_user_to_enable_mount_with_write_if_needed(
resources_to_build,
self.base_dir,
)Routing the prompt text to stderr (err=True in build/utils.py and _utils/experimental.py) keeps stdout clean, but the prompt itself is unconditional — neither call site checks self._output or whether stdin is a TTY. prompt() in build/utils.py has no TTY guard, and _check_build_method_experimental_flag → prompt_experimental is reached for BuildMethod: python-uv.
For the primary consumer of this flag (CI/agents, non-interactive stdin), sam build --use-container --output json on a workflow with must_mount_with_write_in_container hits click.confirm, which converts EOFError into click.Abort. Abort is a RuntimeError subclass, so do_cli's broad handler catches it and build_failure_json produces:
{"status": "failure", "error": {"type": "Abort", "message": "", "resource": null}}str(click.Abort()) is the empty string, so the consumer gets a syntactically valid document with no diagnosable content — arguably worse than the empty-stdout cases the PR set out to close, because it looks like a legitimate execution failure. Two options: skip the prompt in JSON mode and fall back to the documented default (READ-only mount / experimental flag not enabled), or map click.Abort to an explicit message such as "Interactive confirmation required; pass --mount-with WRITE / --beta-features when using --output json".
[samcli/commands/build/utils.py:68] [GENERAL] get_workflow_config() is called here while iterating a known function (and again at line 80 for a known layer), and it can raise UnsupportedRuntimeException / UnsupportedBuilderException:
for function in resources_to_build.functions:
...
config = get_workflow_config(runtime, code_dir, base_dir, specified_workflow)build_strategy.py now attributes exactly these two exception types via its new wrappers, but this path sets nothing. Because it runs inside run()'s try, the exception reaches the known-exception handler with resource_name is None, and the fallback resolves self._resource_identifier — which is None for a full build. So sam build --use-container --output json on a template with one unsupported runtime emits "resource": null even though the offending resource is the loop variable that is right there.
Setting ex.resource_name = function.full_path / layer.full_path around these two get_workflow_config calls closes the gap and keeps attribution consistent with the wrappers in build_strategy.py. Note this is the --use-container pre-check only; the same exception raised later from ApplicationBuilder is already attributed correctly by _do_build_single_function_definition's wrapper.
Verified and not flagged: Optional is imported in every file gaining a resource_name: Optional[str] annotation; MissingBuildMethodException in run()'s except tuple resolves to the commands.build.exceptions (UserException) variant while the lib.build.exceptions variant is already covered by BuildError, so both are caught; telemetry exit_reason is preserved through the re-wrap because metric.py prefers wrapped_from; SAM's log handlers write to stderr, and the only stdout writers on the build path were the two prompts this PR fixed; --exclude is filtered in collect_all_build_resources, so the resources array reflects what was actually built; the schema/samcli.json enum ordering (["json", "text"]) matches make_schema.py's choices.sort(), consistent with a regenerated file; and empty option sections are skipped by CoreCommand._format_options, so the new "Output Options" group does not break tests/unit/commands/buildcmd/core/test_command.py.
sam build --use-container --output json could hit two blocking confirms that a
non-interactive consumer cannot answer: the mount-with-write prompt and the
python-uv beta-feature confirm. On dead stdin click.confirm raises EOFError ->
click.Abort, which the broad do_cli handler serialized as
{"type": "Abort", "message": "", "resource": null} - a valid-looking
failure document with no diagnosable content.
Skip both prompts in JSON mode: the mount prompt falls back to the documented
READ-only default, and the beta confirm is skipped unless the feature is
already enabled (where prompt_experimental only updates telemetry context).
Skipping the mount pre-check also fixes attribution for unsupported-runtime
errors: get_workflow_config in build/utils.py ran outside the resource-tagging
wrappers and reported resource=null on a full build. With the pre-check
skipped, the same exception surfaces from ApplicationBuilder._build_function,
which is inside build_single_function_definition's wrapper, so error.resource
is populated - no change to build/utils.py needed.
| update_experimental_context() | ||
| return True | ||
| confirmed = click.confirm(Colored().yellow(prompt), default=False) | ||
| confirmed = click.confirm(Colored().yellow(prompt), default=False, err=True) |
There was a problem hiding this comment.
I get we're sending this to stderr, but it's not clear to me why and in what scenario that's useful.
| # Central JSON failure serialization for sam build --output json. Catching broadly here | ||
| # (rather than per-exception inside run()) emits a JSON failure document for every | ||
| # execution error surfaced from BuildContext, including bare-Exception template errors | ||
| # raised during __enter__/set_up (e.g. InvalidLayerReference, RemoteStackLocationNotSupported) | ||
| # that are not UserException subclasses. We re-raise unconditionally so @track_command still | ||
| # records telemetry and wraps non-UserException errors as UnhandledException, and so run()'s | ||
| # text "Build Failed" banner path is unaffected (no double-emit: run() no longer emits JSON). | ||
| # | ||
| # Scope: this covers failures once do_cli runs. Errors raised earlier during click option | ||
| # processing (invalid flags, or a --hook-name prepare-hook failure re-raised by | ||
| # track_command) surface as click's standard usage/stderr output, not JSON. Consumers should | ||
| # treat "exit != 0 with empty stdout" as an invocation error, not an execution failure. |
There was a problem hiding this comment.
I find this comment way too hard to read and understand. Cutting it down would help and probably would trigger less AI review.
| @@ -286,12 +291,12 @@ def run(self) -> None: | |||
| # if self._mount_with is NOT WRITE | |||
| # check the need of mounting with write permissions and prompt user to enable it if needed | |||
| mount_with_write = prompt_user_to_enable_mount_with_write_if_needed( | |||
There was a problem hiding this comment.
Is this not a problem with the --output text as well?
| UnsupportedBuilderLibraryVersionError, | ||
| ) as ex: | ||
| if getattr(ex, "resource_name", None) is None: | ||
| ex.resource_name = build_definition.get_full_path() |
There was a problem hiding this comment.
Can we make resource_name a list? I agree with this comment and that preserves the json.
Before
Error output is similarly unstructured:
Note: The error message does not identify which function failed.
After
Success output:
{ "status": "success", "build_dir": ".aws-sam/build", "template_file": ".aws-sam/build/template.yaml", "resources": [ {"resource_id": "OrderProcessorFunction", "type": "function", "runtime": "python3.12", "architecture": "x86_64"}, {"resource_id": "SharedDepsLayer", "type": "layer", "compatible_runtimes": ["python3.12"]} ] }Failure output (note the
resourcefield — the text error today does not identify which function failed):{ "status": "failure", "error": { "type": "WorkflowFailedError", "message": "PythonPipBuilder:ResolveDependencies - Could not satisfy the requirement: nonexistent-package==1.0.0", "resource": "PaymentHandlerFunction" } }File Changes
samcli/commands/build/command.py--outputClick option accepting"text"(default) or"json". Passes the value throughcli()→do_cli()BuildContext.samcli/commands/build/build_context.pyself._outputUserExceptionto preserve telemetry trackingsamcli/commands/build/core/options.pyRegisters
"output"inBUILD_STRATEGY_OPTIONSso it appears in--help.samcli/lib/build/build_strategy.pyWraps
DefaultBuildStrategy.build_single_function_definitionandbuild_single_layer_definitionwith try/except to tagresource_nameon any build exception. CatchesBuildError,UnsupportedRuntimeException,BuildInsideContainerError, andUnsupportedBuilderLibraryVersionError— covering ZIP, Image, container, and layer build paths uniformly.samcli/lib/build/exceptions.pyAdds an optional
resource_namefield toBuildError. Defaults toNone; existing raisers are unaffected.Benchmark Notes
Adding
--output jsontosam builddoes not dramatically change agent performance metrics. Tokens, tool calls, and time are roughly equivalent between text and JSON for this command. This is expected:sam buildis a relatively simple command with clear, short output, and LLM agents parse both formats effectively. We implemented it to keep scope consistent across the project (which adds--output jsonto all major SAM CLI commands), and because the structured error output with theresourcefield provides explicit failure attribution that text mode lacks.resourcefieldjson.loads()directlyScreenshots