Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
1783eda
refactor(package): replace METADATA_EXPORT_LIST and GLOBAL_EXPORT_DIC…
bnusunny May 19, 2026
e8bcff3
feat(package): merge SAR Metadata exports back into LE child templates
bnusunny May 19, 2026
bc46915
fix(package): process AWS::Include before language-extension expansio…
bnusunny May 19, 2026
46907cc
test(package): integration coverage for AWS::Include + SAR Metadata i…
bnusunny May 19, 2026
38c5b70
docs(cfn-lang-ext): document AWS::Include processing order vs languag…
bnusunny May 19, 2026
e0ec117
Merge branch 'develop' into fix/9027-le-package-merge
bnusunny May 21, 2026
72bf07a
test(package): align LE tests with opt-in API from #9033
bnusunny May 21, 2026
8109508
test(package): hoist inline imports in TestPackageContextBuriedAWSInc…
bnusunny May 21, 2026
756c502
refactor(package): hoist InvalidSamDocumentException and expand_langu…
bnusunny May 21, 2026
cbb6426
refactor(package): add _export_without_language_extensions (off-path …
bnusunny May 21, 2026
3841151
refactor(package): add _export_with_language_extensions (on-path branch)
bnusunny May 21, 2026
243fff4
refactor(package): split _export() into LE / non-LE branches gated on…
bnusunny May 21, 2026
45dea4b
test(package): repoint expand_language_extensions patches to package_…
bnusunny May 21, 2026
7f3d339
refactor(package): hoist do_export inline imports and repoint test pa…
bnusunny May 21, 2026
cd49579
refactor(package): add _do_export_without_language_extensions branch …
bnusunny May 21, 2026
e3479a7
refactor(package): add _do_export_with_language_extensions branch method
bnusunny May 21, 2026
5aeb449
refactor(package): split do_export into LE / non-LE branches gated on…
bnusunny May 21, 2026
f7e9ba8
chore(package): apply make format to do_export refactor
bnusunny May 21, 2026
618d765
refactor(package): add _build_child_parameter_values helper
bnusunny May 21, 2026
d2a023a
fix(package): tighten parent_parameter_values scoping in LE path
bnusunny May 21, 2026
7d71976
refactor(package): trim Task 2 redundant comments and verbose test do…
bnusunny May 21, 2026
55b7f90
refactor(tests): hoist inline imports added in LE parent-param fix
bnusunny May 21, 2026
70ebba8
refactor(tests): hoist remaining inline imports and extract LE fixtures
bnusunny May 21, 2026
9172003
Merge branch 'develop' into fix/9027-le-package-merge
bnusunny May 21, 2026
2bce3cd
refactor(tests): extract TestPackageContextBuriedAWSInclude inline fi…
bnusunny May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs/cfn-language-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,22 @@ The following intrinsic functions are resolved locally during expansion:

Functions that require deployed resources (`Fn::GetAtt`, `Fn::ImportValue`, `Fn::GetAZs`) are preserved for CloudFormation to resolve at deploy time.

## AWS::Include processing order

When a template uses both `Fn::Transform: AWS::Include` and
`Transform: AWS::LanguageExtensions`, SAM CLI processes the inline
`AWS::Include` macros **before** running language-extension expansion
locally. This mirrors CloudFormation's server-side transform pipeline,
where `Fn::Transform` macros are resolved before
`AWS::LanguageExtensions`.

The practical effect is that `AWS::Include` `Location` rewrites work
correctly even when the include lives buried inside language-extension
functions like `Fn::ToJsonString` or `Fn::ForEach` bodies, because the
include is rewritten while still structurally visible — before
`Fn::ToJsonString` collapses subtrees into JSON-string literals or
`Fn::ForEach` expands resources.

## Validation errors

The following template issues are caught locally before the SAM transform runs:
Expand All @@ -328,7 +344,7 @@ The following template issues are caught locally before the SAM transform runs:
| The `Fn::ForEach` value is malformed — not a list, doesn't have exactly 3 elements, or has a non-string loop identifier. | `Fn::ForEach::<key> layout is incorrect` (raised as `InvalidTemplateException`; see `samcli/lib/cfn_language_extensions/processors/foreach.py`). |
| More than 5 levels of `Fn::ForEach` are nested. | `Fn::ForEach nesting depth of <N> exceeds the maximum allowed depth of 5. CloudFormation supports up to 5 nested Fn::ForEach loops.` |
| The collection resolves to an empty list (e.g., a `CommaDelimitedList` parameter with `Default: ""`). | No error — the loop is silently skipped and no resources are emitted. |
| The `!Ref` in the collection points at a parameter that is not declared in the template. | No error in the typical `sam build` / `sam package` flow. SAM CLI runs intrinsic resolution in PARTIAL mode and preserves the unresolved `{"Ref": "<name>"}`. CloudFormation will reject the unresolved ref at deploy time. |
| The `!Ref` in the collection points at a parameter that is not declared in the template. | No error in the typical `sam build` / `sam package` flow. SAM CLI runs intrinsic resolution in PARTIAL mode and preserves the unresolved `{"Ref": "<name>"}`. At deploy time, CloudFormation will resolve it server side. |

## Limitations

Expand Down
135 changes: 88 additions & 47 deletions samcli/commands/package/package_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,22 @@
import click

from samcli.commands.package.exceptions import PackageFailedError
from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException
from samcli.lib.bootstrap.companion_stack.companion_stack_manager import sync_ecr_stack
from samcli.lib.cfn_language_extensions.sam_integration import resolve_language_extensions_enabled
from samcli.lib.cfn_language_extensions.sam_integration import (
expand_language_extensions,
resolve_language_extensions_enabled,
)
from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable
from samcli.lib.package.artifact_exporter import Template
from samcli.lib.package.artifact_exporter import Template, _export_global_artifacts_pass
from samcli.lib.package.code_signer import CodeSigner
from samcli.lib.package.ecr_uploader import ECRUploader
from samcli.lib.package.language_extensions_packaging import (
generate_and_apply_artifact_mappings,
merge_language_extensions_s3_uris,
)
from samcli.lib.package.s3_uploader import S3Uploader
from samcli.lib.package.uploaders import Uploaders
from samcli.lib.package.uploaders import Destination, Uploaders
from samcli.lib.providers.provider import ResourceIdentifier, Stack, get_resource_full_path_by_id
from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider
from samcli.lib.utils.boto_utils import get_boto_config_with_user_agent
Expand Down Expand Up @@ -174,34 +178,80 @@ def run(self):
raise PackageFailedError(template_file=self.template_file, ex=str(ex)) from ex

def _export(self, template_path, use_json):
from samcli.commands.validate.lib.exceptions import InvalidSamDocumentException
from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions

# Read the original template
# Read + parse once. Branch methods receive the parsed dict and
# return the output dict; this dispatcher owns the I/O bookends.
with open(template_path, "r", encoding="utf-8") as f:
original_template_dict = yaml_parse(f.read())

# Build combined parameter values for expand_language_extensions
parameter_values = {}
parameter_values.update(IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES)
# Structural fork on the opt-in flag (#9033). When LE is disabled
# we never invoke any LE machinery — no expand_language_extensions,
# no pre-LE _export_global_artifacts_pass, no merge or Mappings.
# See aws/aws-sam-cli#9027 for why the on path needs the pre-LE pass.
if self._language_extensions_enabled:
output_template = self._export_with_language_extensions(template_path, original_template_dict)
else:
output_template = self._export_without_language_extensions(template_path, original_template_dict)

if use_json:
return json.dumps(output_template, indent=4, ensure_ascii=False)
return yaml_dump(output_template)

def _export_without_language_extensions(self, template_path, original_template_dict):
"""Export a template with AWS::LanguageExtensions opt-in disabled.

Mirrors pre-1.160.0 sam-cli behavior: AWS::Include and other global
Fn::Transform exporters are handled inside Template.export() via its
own _export_global_artifacts pass — no pre-Template pass is needed
because there is no LE expansion step that would collapse Fn::Transform
into a JSON-string literal first (see aws/aws-sam-cli#9027 for why the
LE path differs).
"""
template = Template(
template_path,
os.getcwd(),
self.uploaders,
self.code_signer,
normalize_template=True,
normalize_parameters=True,
template_dict=original_template_dict,
language_extensions_enabled=False,
)
return template.export()

def _export_with_language_extensions(self, template_path, original_template_dict):
"""Export a template with AWS::LanguageExtensions processing enabled.

Order matters here:
1. Run AWS::Include (and any other GLOBAL_TRANSFORM_EXPORTS) on the
original template *before* LE expansion. LE functions like
Fn::ToJsonString json.dumps() their argument and collapse any
structural Fn::Transform subtree into a JSON-string literal,
which would hide the include from the post-export walker.
See aws/aws-sam-cli#9027.
2. Run expand_language_extensions to materialize Fn::ForEach,
Fn::ToJsonString, etc. into a flat resource list.
3. Run Template.export() on the expanded copy.
4. Merge the S3 URIs back into the original Fn::ForEach structure
and apply Mappings for any dynamic artifact properties.
"""
template_dir = os.path.dirname(os.path.abspath(template_path))
_export_global_artifacts_pass(
original_template_dict,
self.uploaders.get(Destination.S3),
template_dir,
)

parameter_values = {**IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES}
if self.parameter_overrides:
parameter_values.update(self.parameter_overrides)
if self._global_parameter_overrides:
parameter_values.update(self._global_parameter_overrides)

# Use the canonical expand_language_extensions() entry point (Phase 1)
try:
result = expand_language_extensions(
original_template_dict, parameter_values, enabled=self._language_extensions_enabled
)
result = expand_language_extensions(original_template_dict, parameter_values, enabled=True)
except InvalidSamDocumentException as e:
raise PackageFailedError(template_file=self.template_file, ex=str(e)) from e

uses_language_extensions = result.had_language_extensions
dynamic_properties = result.dynamic_artifact_properties

# Create Template with the (possibly expanded) template dict directly,
# avoiding a yaml_dump → yaml_parse round-trip.
template = Template(
template_path,
os.getcwd(),
Expand All @@ -211,38 +261,29 @@ def _export(self, template_path, use_json):
normalize_parameters=True,
template_dict=copy.deepcopy(result.expanded_template),
parameter_values=parameter_values,
language_extensions_enabled=self._language_extensions_enabled,
language_extensions_enabled=True,
)

exported_template = template.export()

# If using language extensions, we need to preserve the original Fn::ForEach structure
# but update the artifact URIs (CodeUri, ContentUri, etc.) with the S3 locations
if uses_language_extensions:
LOG.debug("Template uses language extensions, preserving Fn::ForEach structure")
output_template = merge_language_extensions_s3_uris(
result.original_template, exported_template, dynamic_properties
)

# Generate Mappings for dynamic artifact properties
if dynamic_properties:
LOG.debug("Generating Mappings for %d dynamic artifact properties", len(dynamic_properties))

template_dir = os.path.dirname(os.path.abspath(template_path))
exported_resources = exported_template.get("Resources", {})

output_template = generate_and_apply_artifact_mappings(
output_template, dynamic_properties, exported_resources, template_dir
)
else:
output_template = exported_template

if use_json:
exported_str = json.dumps(output_template, indent=4, ensure_ascii=False)
else:
exported_str = yaml_dump(output_template)
if not result.had_language_extensions:
return exported_template

return exported_str
LOG.debug("Template uses language extensions, preserving Fn::ForEach structure")
output_template = merge_language_extensions_s3_uris(
result.original_template, exported_template, result.dynamic_artifact_properties
)
if result.dynamic_artifact_properties:
LOG.debug(
"Generating Mappings for %d dynamic artifact properties",
len(result.dynamic_artifact_properties),
)
output_template = generate_and_apply_artifact_mappings(
output_template,
result.dynamic_artifact_properties,
exported_template.get("Resources", {}),
template_dir,
)
return output_template

@staticmethod
def _warn_preview_runtime(stacks: List[Stack]) -> None:
Expand Down
Loading
Loading