Skip to content

feat: add --output json flag (sam build) - #9136

Open
madhavdonthula1 wants to merge 10 commits into
aws:developfrom
madhavdonthula1:feat/build-output-json-clean
Open

feat: add --output json flag (sam build)#9136
madhavdonthula1 wants to merge 10 commits into
aws:developfrom
madhavdonthula1:feat/build-output-json-clean

Conversation

@madhavdonthula1

@madhavdonthula1 madhavdonthula1 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Before

Build Succeeded

Built Artifacts  : .aws-sam/build
Built Template   : .aws-sam/build/template.yaml

Commands you can use next
=========================
[*] Validate SAM template: sam validate
...

Error output is similarly unstructured:

Build Failed
Error: PythonPipBuilder:ResolveDependencies - Could not satisfy the requirement: nonexistent-package==1.0.0

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 resource field — 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

  • Adds the --output Click option accepting "text" (default) or "json". Passes the value through cli()do_cli() BuildContext.

samcli/commands/build/build_context.py

  • Store the output format as self._output
  • On success: emit a structured JSON object (status, build_dir, template_file, resources list with both functions and layers) instead of the colored "Build Succeeded" banner
  • On failure: emit a JSON error object (type, message, resource) then raise UserException to preserve telemetry tracking
  • Text mode follows the exact same code paths as before

samcli/commands/build/core/options.py

Registers "output" in BUILD_STRATEGY_OPTIONS so it appears in --help.

samcli/lib/build/build_strategy.py

  • Wraps DefaultBuildStrategy.build_single_function_definition and build_single_layer_definition with try/except to tag

  • resource_name on any build exception. Catches BuildError, UnsupportedRuntimeException, BuildInsideContainerError, and UnsupportedBuilderLibraryVersionError — covering ZIP, Image, container, and layer build paths uniformly.

    samcli/lib/build/exceptions.py

  • Adds an optional resource_name field to BuildError. Defaults to None; existing raisers are unaffected.

Benchmark Notes

Adding --output json to sam build does 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 build is 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 json to all major SAM CLI commands), and because the structured error output with the resource field provides explicit failure attribution that text mode lacks.

Metric Text Output JSON Output
Tokens (avg per task) ~28,800 ~28,400
Tool calls (error diagnosis) 5 4
Time (avg) ~180s ~165s
Error attribution Inferred from log line ordering Explicit resource field
Programmatic parsing Regex/sed required json.loads() directly

Screenshots

Screenshot 2026-07-20 at 12 46 40 PM
Screenshot 2026-07-20 at 12 47 18 PM
Screenshot 2026-07-20 at 12 47 45 PM

…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.
@madhavdonthula1
madhavdonthula1 requested a review from a team as a code owner July 20, 2026 19:48
@github-actions github-actions Bot added area/build sam build command pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at. labels Jul 20, 2026

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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_metrics is never called for failed sam build --output json runs, so exit_reason and exit_code for these failures are silently dropped from telemetry.
  • The exit path diverges from every other sam command, which uniformly funnels errors through UserException.

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 ex

The same issue applies to the FunctionNotFound handler around line 381 and the multi-exception handler around line 407.

Comment thread samcli/lib/build/app_builder.py Outdated
Comment thread samcli/commands/build/build_context.py Outdated
@madhavdonthula1

Copy link
Copy Markdown
Contributor Author

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_metrics is never called for failed sam build --output json runs, so exit_reason and exit_code for these failures are silently dropped from telemetry.
  • The exit path diverges from every other sam command, which uniformly funnels errors through UserException.

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 ex

The same issue applies to the FunctionNotFound handler around line 381 and the multi-exception handler around line 407.

Changed so that we raise UserException now instead of exiting using sys.exit(1)

- 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 madhavdonthula1 left a comment

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.

  • 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

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 56510b1..2fd6125
Files: 5
Comments: 1

Comment thread samcli/lib/build/app_builder.py Outdated
)
except BuildError as ex:
ex.resource_name = function_name
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[BUG] The 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
   raise

Alternatively, 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.

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: 56510b1..b104652
Files: 6
Comments: 1

Comment thread samcli/lib/build/build_strategy.py Outdated
"""
try:
return self._do_build_single_function_definition(build_definition)
except BuildError as ex:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[BUG] The 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 in ApplicationBuilder._build_function when a deprecated runtime is used (app_builder.py:752).
  • BuildInsideContainerError — raised in _build_function_on_container on Docker image pull failures (app_builder.py:1072, 1097).
  • UnsupportedBuilderLibraryVersionError — raised in _build_function_on_container when 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()
       raise

Note 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 madhavdonthula1 left a comment

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.

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 madhavdonthula1 left a comment

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.

  • 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

@aws-sam-tooling-bot aws-sam-tooling-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review Results

Reviewed: fe8f8db..127c008
Files: 8
Comments: 1

Comment thread samcli/commands/build/build_context.py Outdated

def run(self) -> None:
"""Runs the building process by creating an ApplicationBuilder."""
if self._output == "json":

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[BUG] The unconditional logging.getLogger("samcli").setLevel(logging.WARNING) in run() silently overrides the --debug flag when a user runs sam build --debug --output json.

Flow:

  1. debug_option is defined with is_eager=True (samcli/cli/options.py:26). Its callback sets ctx.debug = True, which triggers the setter in samcli/cli/context.py:75 and reconfigures the samcli logger to DEBUG.
  2. 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 reedham-aws left a comment

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.

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.

Comment thread samcli/commands/build/core/options.py Outdated
Comment thread samcli/commands/build/command.py Outdated
Comment thread samcli/commands/build/build_context.py Outdated
Comment thread samcli/commands/build/build_context.py Outdated
Comment on lines +357 to +361
result = {
"status": "success",
"build_dir": build_dir_in_success_message,
"template_file": output_template_path_in_success_message,
"resources": resources,

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.

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.

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.

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

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'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.

Comment on lines 389 to 394
UnsupportedRuntimeException,
BuildError,
BuildInsideContainerError,
UnsupportedBuilderLibraryVersionError,
InvalidBuildGraphException,
ResourceNotFound,

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.

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?

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.

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)

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 there a reason we're not adding a resource_name field to other error types that could exist?

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.

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.

Comment thread samcli/commands/build/build_context.py Outdated
Comment thread samcli/commands/build/build_context.py Outdated
"status": "failure",
"error": {
"type": "FunctionNotFound",
"message": str(function_not_found_ex),

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 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?

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.

This is valid. I changed it so that it rather populates the resource or says null so that we dont have to be inconsistent.

Comment thread samcli/commands/build/build_context.py Outdated
)

click.secho(msg, fg="yellow")
if self._output == "json":

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 would prefer that this whole block about printing gets moved to a method just to keep the BuildContext.run simple.

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.

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 madhavdonthula1 left a comment

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.

Changes made based on comments

@reedham-aws reedham-aws left a comment

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.

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.
return f


def output_click_option():

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.

These are the same function names as the existing output_click_option / output_option in samcli/commands/list/cli_common/options.py, but with different choices (text|json vs json|table) and a different default. Two identically-named output_option symbols is an import-confusion trap.

We now have four --output conventions: json|table (list), the OutputOption enum (observability), RemoteInvokeOutputFormat (remote invoke), and this one.

Since this project is adding --output json across all commands, worth settling here since every following PR will copy it. Ideal end state is one shared contract, a full JSON view for agents and text for humans, which is what OutputOption (text/json) already encodes; list's json|table is the outlier and can converge later. Minimum ask: don't shadow the existing name.

resources_to_build: ResourcesToBuildCollector
The functions and layers that were built
"""
if self._output == "json":

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.

output is threaded through as a str and compared against the "json" literal here and again at line 1148, where it's negated (!= "json"). A typo in either silently selects the wrong mode and no test would catch it. click.Choice only validates the CLI entry point; BuildContext is also constructed directly in tests and by other callers.

samcli/lib/observability/util.py already defines the enum, and samcli/commands/logs/command.py:186 shows the conversion pattern:

# command.py -- convert once at the boundary
output=OutputOption(output)

# build_context.py -- typed, and both call sites become positive checks
if self._output is OutputOption.json:

That drops the string literals from this file and removes the negated comparison on the failure path.

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

# The resource key is always present so consumers can rely on the schema. It is

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.

The "always present" guarantee doesn't quite hold. InvalidBuildDirException is a UserException, not a BuildError subclass, so it's caught by neither handler in run()sam build --output json with a bad --build-dir emits no JSON at all (empty stdout, exit 1).

Either widen the catch or soften the comment so consumers don't rely on a schema that isn't guaranteed.


error: Dict[str, Any] = {"type": error_type, "message": str(ex), "resource": resource_name}

click.echo(json.dumps({"status": "failure", "error": error}, indent=2))

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.

Minor: indent=2 pretty-prints. Given the stated motivation is agent consumption, single-line JSON is often preferred for machine parsing. Intentional?

UnsupportedBuilderLibraryVersionError,
) as ex:
if getattr(ex, "resource_name", None) is None:
ex.resource_name = build_definition.get_full_path() # type: ignore[union-attr]

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.

The PR description says samcli/lib/build/exceptions.py "adds an optional resource_name field to BuildError", but that file isn't in the diff — so the attribute is being attached dynamically here (hence the # type: ignore[union-attr]).

This works today only because none of these exceptions define __slots__. Adding one later, a routine perf change, would break failure attribution silently at runtime in an error path.

Worth noting the four caught types share no base but Exception (UnsupportedRuntimeException, BuildInsideContainerError and UnsupportedBuilderLibraryVersionError are not BuildError subclasses), so the fix as described would only cover one of four. A class-level default on each keeps constructors untouched and lets mypy check it:

class BuildError(Exception):
    resource_name: Optional[str] = None

Was exceptions.py meant to be included in this PR?

self.assertEqual(msg, expected_msg)


class TestBuildContext_print_build_success(TestCase):

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.

These tests call _print_build_success / _print_build_failure directly, so the user-facing contract isn't covered: that run() in JSON mode emits exactly one parseable document on stdout and nothing else. One test doing json.loads(stdout) through run() would cover more than the ~15 helper tests here — and would have caught the InvalidBuildDirException gap noted in build_context.py.

Comment thread schema/samcli.json
"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": {

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.

Is this file generated? I couldn't find a make schema target that regenerates it, but if one exists this should be regenerated rather than hand-edited.

"resource_id": function.full_path,
"type": "function",
"runtime": function.runtime,
"architecture": function.architectures[0] if function.architectures else None,

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.

This reports "architecture": null for the common case of a template that doesn't set Architectures, even though the artifact was built for x86_64.

Function.architecture (samcli/lib/providers/provider.py:194) already resolves this, defaulting to X86_64 when the property is absent — and it's the value the build actually used, since ApplicationBuilder._get_build_graph() passes function.architecture into FunctionBuildDefinition (app_builder.py:293).

So function.architectures[0] if function.architectures else None diverges from what was built in two ways: null instead of x86_64 when absent, and silently reporting the first element of a multi-element list that Function.architecture would reject as invalid. A consumer using this to select a Lambda architecture or container image gets the wrong answer for any template that omits Architectures.

"architecture": function.architecture,

Safe to call here — the success path is only reached after _get_build_graph() already evaluated function.architecture without raising.

Note test_json_architecture_defaults_to_none_when_absent and test_json_multi_architecture_reports_first currently assert the present behaviour, so they'd need updating too.

@@ -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(

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/build sam build command pr/external stage/needs-triage Automatically applied to new issues and PRs, indicating they haven't been looked at.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants