From 9d17a69721f81cd1fae78f2132dbea06f47101c7 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 9 Jul 2026 05:10:50 +0000 Subject: [PATCH 01/10] fix: resolve Ref-based Fn::ForEach collections in artifact merge (#9117) --- .../package/language_extensions_packaging.py | 11 +++- ...est_package_context_language_extensions.py | 61 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/samcli/lib/package/language_extensions_packaging.py b/samcli/lib/package/language_extensions_packaging.py index 46123ef2994..750fa0eebfb 100644 --- a/samcli/lib/package/language_extensions_packaging.py +++ b/samcli/lib/package/language_extensions_packaging.py @@ -23,6 +23,7 @@ ) from samcli.lib.cfn_language_extensions.sam_integration import ( contains_loop_variable, + resolve_collection, sanitize_resource_key_for_mapping, substitute_loop_variable, ) @@ -228,6 +229,9 @@ def _update_foreach_with_s3_uris( exported_resources: Dict[str, Any], dynamic_prop_keys: Optional[set] = None, outer_context: Optional[List[Tuple[str, List[str]]]] = None, + template: Optional[Dict[str, Any]] = None, + parameter_values: Optional[Dict[str, Any]] = None, + deferred_dynamic: Optional[List] = None, ) -> None: """ Update artifact URIs in a Fn::ForEach construct. @@ -245,9 +249,7 @@ def _update_foreach_with_s3_uris( if not isinstance(loop_variable, str) or not isinstance(body, dict): return - collection_values: List[str] = [] - if isinstance(collection, list): - collection_values = [str(item) for item in collection if item is not None] + collection_values = resolve_collection(collection, template or {}, parameter_values) if outer_context is None: outer_context = [] @@ -261,6 +263,9 @@ def _update_foreach_with_s3_uris( exported_resources, dynamic_prop_keys, outer_context=current_outer_context, + template=template, + parameter_values=parameter_values, + deferred_dynamic=deferred_dynamic, ) continue diff --git a/tests/unit/commands/package/test_package_context_language_extensions.py b/tests/unit/commands/package/test_package_context_language_extensions.py index 3ae83ade7ec..5194cc2f562 100644 --- a/tests/unit/commands/package/test_package_context_language_extensions.py +++ b/tests/unit/commands/package/test_package_context_language_extensions.py @@ -1436,3 +1436,64 @@ def test_cross_context_same_resource_key_suffix(self): # No suffixed variants self.assertNotIn("SAMDefinitionUriRegionAPIsServicesApi", mappings) self.assertNotIn("SAMDefinitionUriEnvAPIsServicesApi", mappings) + + +class TestForEachRefCollectionResolution(TestCase): + """Ref-based Fn::ForEach collections must resolve so static artifacts get rewritten.""" + + def test_ref_collection_single_value_rewrites_static_imageuri(self): + foreach_value = [ + "FunctionName", + {"Ref": "FuncType"}, + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "foo"}, + } + }, + ] + exported_resources = { + "func1Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "123.dkr.ecr.us-east-1.amazonaws.com/r:func1Function-latest"}, + }, + } + template = {"Parameters": {"FuncType": {"Default": "func1"}}} + _update_foreach_with_s3_uris( + "Fn::ForEach::LoopFunction", + foreach_value, + exported_resources, + None, + template=template, + ) + props = foreach_value[2]["${FunctionName}Function"]["Properties"] + self.assertEqual(props["ImageUri"], "123.dkr.ecr.us-east-1.amazonaws.com/r:func1Function-latest") + + def test_ref_collection_uses_parameter_overrides(self): + foreach_value = [ + "FunctionName", + {"Ref": "FuncType"}, + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "src"}, + } + }, + ] + exported_resources = { + "func1Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/SAMEHASH"}, + }, + } + template = {"Parameters": {"FuncType": {"Default": "unused"}}} + _update_foreach_with_s3_uris( + "Fn::ForEach::LoopFunction", + foreach_value, + exported_resources, + None, + template=template, + parameter_values={"FuncType": ["func1"]}, + ) + props = foreach_value[2]["${FunctionName}Function"]["Properties"] + self.assertEqual(props["CodeUri"], "s3://bucket/SAMEHASH") From 207dade82e47183379f60de452ae1af16528d583 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 9 Jul 2026 05:16:29 +0000 Subject: [PATCH 02/10] fix: defer differing per-iteration Fn::ForEach artifact URIs to Mappings (#9117) --- .../package/language_extensions_packaging.py | 93 ++++++++++++++- ...est_package_context_language_extensions.py | 111 ++++++++++++++++++ 2 files changed, 201 insertions(+), 3 deletions(-) diff --git a/samcli/lib/package/language_extensions_packaging.py b/samcli/lib/package/language_extensions_packaging.py index 750fa0eebfb..21fd3a8c8b5 100644 --- a/samcli/lib/package/language_extensions_packaging.py +++ b/samcli/lib/package/language_extensions_packaging.py @@ -286,13 +286,100 @@ def _update_foreach_with_s3_uris( exported_resource = exported_resources[expanded_key] if not isinstance(exported_resource, dict): continue - exported_props = exported_resource.get("Properties", {}) - _copy_artifact_uris_for_type( - properties, exported_props, resource_template.get("Type", ""), foreach_key, dynamic_prop_keys + resource_type = resource_template.get("Type", "") + _merge_or_defer_foreach_artifacts( + properties=properties, + resource_type=resource_type, + resource_template_key=resource_template_key, + foreach_key=foreach_key, + loop_variable=loop_variable, + collection_values=collection_values, + outer_context=outer_context, + exported_resources=exported_resources, + dynamic_prop_keys=dynamic_prop_keys, + deferred_dynamic=deferred_dynamic, ) +def _merge_or_defer_foreach_artifacts( + properties: Dict[str, Any], + resource_type: str, + resource_template_key: str, + foreach_key: str, + loop_variable: str, + collection_values: List[str], + outer_context: Optional[List[Tuple[str, List[str]]]], + exported_resources: Dict[str, Any], + dynamic_prop_keys: Optional[set], + deferred_dynamic: Optional[List], +) -> None: + """Decide, per artifact property, whether all Fn::ForEach iterations resolved + to the same exported URI (static copy) or to distinct URIs (defer to Mappings). + + - All iterations identical -> copy the shared raw value onto the ForEach body. + - Iterations differ -> append a synthetic DynamicArtifactProperty to + ``deferred_dynamic`` (handled later by generate_and_apply_artifact_mappings) + and leave the body value untouched. + - No accumulator / nested loop -> fall back to copying the first resolved + iteration's value (legacy behavior); nested ForEach with static differing + values is a documented limitation. + """ + prop_names = PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES.get(resource_type) + if not prop_names: + return + + for prop_name in _resolve_property_paths(prop_names, properties): + # Loop-variable properties are handled by the existing dynamic path. + if dynamic_prop_keys and (foreach_key, prop_name) in dynamic_prop_keys: + continue + + # Track each iteration's normalized URI (for the identical-vs-differing + # comparison) paired with the RAW exported value (for copying). The + # comparison uses the normalized string, but the value written back must + # preserve the original shape (e.g. a {S3Bucket, S3Key} object) so the + # resulting CloudFormation stays valid. + resolved: List[Tuple[str, Any]] = [] + for value in collection_values: + expanded_key = _build_expanded_key( + resource_template_key, loop_variable, [value], outer_context + ) + if not expanded_key: + continue + uri = _find_artifact_uri_for_resource( + exported_resources, expanded_key, resource_type, prop_name + ) + if uri is None: + continue + exported_resource = exported_resources.get(expanded_key, {}) + raw = _get_prop_value(exported_resource.get("Properties", {}), prop_name) + resolved.append((uri, raw)) + + if not resolved: + continue + + distinct_uris = {uri for uri, _ in resolved} + first_raw = resolved[0][1] + if len(distinct_uris) == 1: + _set_prop_value(properties, prop_name, first_raw) + elif deferred_dynamic is not None and not outer_context: + deferred_dynamic.append( + DynamicArtifactProperty( + foreach_key=foreach_key, + loop_name=foreach_key.replace("Fn::ForEach::", ""), + loop_variable=loop_variable, + collection=collection_values, + resource_key=resource_template_key, + resource_type=resource_type, + property_name=prop_name, + property_value=_get_prop_value(properties, prop_name), + outer_loops=[], + ) + ) + else: + _set_prop_value(properties, prop_name, first_raw) + + def _build_expanded_key( resource_template_key: str, loop_variable: str, diff --git a/tests/unit/commands/package/test_package_context_language_extensions.py b/tests/unit/commands/package/test_package_context_language_extensions.py index 5194cc2f562..3ba31ca632e 100644 --- a/tests/unit/commands/package/test_package_context_language_extensions.py +++ b/tests/unit/commands/package/test_package_context_language_extensions.py @@ -1497,3 +1497,114 @@ def test_ref_collection_uses_parameter_overrides(self): ) props = foreach_value[2]["${FunctionName}Function"]["Properties"] self.assertEqual(props["CodeUri"], "s3://bucket/SAMEHASH") + + +class TestForEachValueDrivenMerge(TestCase): + """Per-iteration URI comparison: identical -> copy; differing -> defer to Mappings.""" + + def _foreach(self, prop_name, static_value): + return [ + "FunctionName", + ["func1", "func2"], + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {prop_name: static_value}, + } + }, + ] + + def test_identical_uris_copied_as_static(self): + foreach_value = self._foreach("CodeUri", "src") + exported_resources = { + "func1Function": {"Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/SAME"}}, + "func2Function": {"Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/SAME"}}, + } + deferred = [] + _update_foreach_with_s3_uris( + "Fn::ForEach::L", foreach_value, exported_resources, None, + deferred_dynamic=deferred, + ) + props = foreach_value[2]["${FunctionName}Function"]["Properties"] + self.assertEqual(props["CodeUri"], "s3://bucket/SAME") + self.assertEqual(deferred, []) + + def test_differing_uris_deferred_not_copied(self): + foreach_value = self._foreach("ImageUri", "foo") + exported_resources = { + "func1Function": {"Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func1Function-latest"}}, + "func2Function": {"Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func2Function-latest"}}, + } + deferred = [] + _update_foreach_with_s3_uris( + "Fn::ForEach::L", foreach_value, exported_resources, None, + deferred_dynamic=deferred, + ) + props = foreach_value[2]["${FunctionName}Function"]["Properties"] + self.assertEqual(props["ImageUri"], "foo") + self.assertEqual(len(deferred), 1) + self.assertEqual(deferred[0].property_name, "ImageUri") + self.assertEqual(deferred[0].resource_key, "${FunctionName}Function") + self.assertEqual(deferred[0].loop_variable, "FunctionName") + self.assertEqual(deferred[0].collection, ["func1", "func2"]) + + def test_differing_uris_without_accumulator_falls_back_to_copy(self): + foreach_value = self._foreach("ImageUri", "foo") + exported_resources = { + "func1Function": {"Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func1Function-latest"}}, + "func2Function": {"Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func2Function-latest"}}, + } + _update_foreach_with_s3_uris("Fn::ForEach::L", foreach_value, exported_resources, None) + props = foreach_value[2]["${FunctionName}Function"]["Properties"] + self.assertEqual(props["ImageUri"], "repo:func1Function-latest") + + def test_dynamic_prop_key_still_skipped(self): + foreach_value = self._foreach("CodeUri", "${FunctionName}/") + exported_resources = { + "func1Function": {"Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/func1"}}, + "func2Function": {"Type": "AWS::Serverless::Function", + "Properties": {"CodeUri": "s3://bucket/func2"}}, + } + deferred = [] + _update_foreach_with_s3_uris( + "Fn::ForEach::L", foreach_value, exported_resources, + {("Fn::ForEach::L", "CodeUri")}, deferred_dynamic=deferred, + ) + props = foreach_value[2]["${FunctionName}Function"]["Properties"] + self.assertEqual(props["CodeUri"], "${FunctionName}/") + self.assertEqual(deferred, []) + + def test_identical_dict_form_artifact_preserves_object_shape(self): + # Raw AWS::Lambda::Function.Code is a {S3Bucket,S3Key} object, not a string. + # Identical across iterations must copy the OBJECT, not a normalized s3:// string. + foreach_value = [ + "FunctionName", + ["func1", "func2"], + { + "${FunctionName}Function": { + "Type": "AWS::Lambda::Function", + "Properties": {"Code": {"S3Bucket": "b", "S3Key": "local"}}, + } + }, + ] + exported_resources = { + "func1Function": {"Type": "AWS::Lambda::Function", + "Properties": {"Code": {"S3Bucket": "b", "S3Key": "SAME"}}}, + "func2Function": {"Type": "AWS::Lambda::Function", + "Properties": {"Code": {"S3Bucket": "b", "S3Key": "SAME"}}}, + } + deferred = [] + _update_foreach_with_s3_uris( + "Fn::ForEach::L", foreach_value, exported_resources, None, + deferred_dynamic=deferred, + ) + code = foreach_value[2]["${FunctionName}Function"]["Properties"]["Code"] + self.assertEqual(code, {"S3Bucket": "b", "S3Key": "SAME"}) + self.assertEqual(deferred, []) From 4228022fff41faf8d8475c757bc71bca1ed4e950 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 9 Jul 2026 05:28:42 +0000 Subject: [PATCH 03/10] fix: thread parameter_values and deferred accumulator through merge (#9117) --- .../package/language_extensions_packaging.py | 24 ++++++++++- ...est_package_context_language_extensions.py | 40 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/samcli/lib/package/language_extensions_packaging.py b/samcli/lib/package/language_extensions_packaging.py index 21fd3a8c8b5..2d214840634 100644 --- a/samcli/lib/package/language_extensions_packaging.py +++ b/samcli/lib/package/language_extensions_packaging.py @@ -45,6 +45,8 @@ def merge_language_extensions_s3_uris( original_template: Dict[str, Any], exported_template: Dict[str, Any], dynamic_properties: Optional[List[DynamicArtifactProperty]] = None, + parameter_values: Optional[Dict[str, Any]] = None, + deferred_dynamic: Optional[List] = None, ) -> Dict[str, Any]: """ Update the original template (with Fn::ForEach intact) with S3 URIs from the exported template. @@ -82,7 +84,14 @@ def merge_language_extensions_s3_uris( original_resources = result.get("Resources", {}) exported_resources = exported_template.get("Resources", {}) - _update_resources_with_s3_uris(original_resources, exported_resources, dynamic_prop_keys) + _update_resources_with_s3_uris( + original_resources, + exported_resources, + dynamic_prop_keys, + template=original_template, + parameter_values=parameter_values, + deferred_dynamic=deferred_dynamic, + ) _merge_metadata(result.get("Metadata", {}), exported_template.get("Metadata", {})) @@ -209,6 +218,9 @@ def _update_resources_with_s3_uris( original_resources: Dict[str, Any], exported_resources: Dict[str, Any], dynamic_prop_keys: Optional[set] = None, + template: Optional[Dict[str, Any]] = None, + parameter_values: Optional[Dict[str, Any]] = None, + deferred_dynamic: Optional[List] = None, ) -> None: """ Update resources in the original template with S3 URIs from the exported template. @@ -217,7 +229,15 @@ def _update_resources_with_s3_uris( """ for resource_key, resource_value in original_resources.items(): if is_foreach_key(resource_key): - _update_foreach_with_s3_uris(resource_key, resource_value, exported_resources, dynamic_prop_keys) + _update_foreach_with_s3_uris( + resource_key, + resource_value, + exported_resources, + dynamic_prop_keys, + template=template, + parameter_values=parameter_values, + deferred_dynamic=deferred_dynamic, + ) elif isinstance(resource_value, dict) and resource_key in exported_resources: exported_resource = exported_resources.get(resource_key, {}) _copy_artifact_uris(resource_value, exported_resource) diff --git a/tests/unit/commands/package/test_package_context_language_extensions.py b/tests/unit/commands/package/test_package_context_language_extensions.py index 3ba31ca632e..bb36f91a348 100644 --- a/tests/unit/commands/package/test_package_context_language_extensions.py +++ b/tests/unit/commands/package/test_package_context_language_extensions.py @@ -1608,3 +1608,43 @@ def test_identical_dict_form_artifact_preserves_object_shape(self): code = foreach_value[2]["${FunctionName}Function"]["Properties"]["Code"] self.assertEqual(code, {"S3Bucket": "b", "S3Key": "SAME"}) self.assertEqual(deferred, []) + + +class TestMergeThreadsParametersAndDeferred(TestCase): + """merge_language_extensions_s3_uris forwards parameter_values + accumulator.""" + + def test_merge_resolves_ref_and_collects_deferred(self): + from samcli.lib.package.language_extensions_packaging import merge_language_extensions_s3_uris + + original = { + "Parameters": {"FuncType": {"Default": "func1,func2"}}, + "Resources": { + "Fn::ForEach::L": [ + "FunctionName", + {"Ref": "FuncType"}, + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "foo"}, + } + }, + ] + }, + } + exported = { + "Resources": { + "func1Function": {"Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func1Function-latest"}}, + "func2Function": {"Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func2Function-latest"}}, + } + } + deferred = [] + result = merge_language_extensions_s3_uris( + original, exported, None, deferred_dynamic=deferred + ) + body = result["Resources"]["Fn::ForEach::L"][2]["${FunctionName}Function"]["Properties"] + # Differing image URIs deferred; body value untouched pre-Mapping. + self.assertEqual(body["ImageUri"], "foo") + self.assertEqual(len(deferred), 1) + self.assertEqual(deferred[0].property_name, "ImageUri") From 75fb40ff9cc0493922245c5d95ad9ec05b8595fc Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 9 Jul 2026 05:32:27 +0000 Subject: [PATCH 04/10] fix: apply deferred Fn::ForEach image Mappings in package callers (#9117) --- samcli/commands/package/package_context.py | 14 +- samcli/lib/package/artifact_exporter.py | 10 +- .../commands/package/test_package_context.py | 143 ++++++++++++++++++ 3 files changed, 160 insertions(+), 7 deletions(-) diff --git a/samcli/commands/package/package_context.py b/samcli/commands/package/package_context.py index ac76e9b4279..dca836f2ae6 100644 --- a/samcli/commands/package/package_context.py +++ b/samcli/commands/package/package_context.py @@ -269,17 +269,23 @@ def _export_with_language_extensions(self, template_path, original_template_dict return exported_template LOG.debug("Template uses language extensions, preserving Fn::ForEach structure") + deferred_dynamic: List = [] output_template = merge_language_extensions_s3_uris( - result.original_template, exported_template, result.dynamic_artifact_properties + result.original_template, + exported_template, + result.dynamic_artifact_properties, + parameter_values=parameter_values, + deferred_dynamic=deferred_dynamic, ) - if result.dynamic_artifact_properties: + all_dynamic_properties = list(result.dynamic_artifact_properties or []) + deferred_dynamic + if all_dynamic_properties: LOG.debug( "Generating Mappings for %d dynamic artifact properties", - len(result.dynamic_artifact_properties), + len(all_dynamic_properties), ) output_template = generate_and_apply_artifact_mappings( output_template, - result.dynamic_artifact_properties, + all_dynamic_properties, exported_template.get("Resources", {}), template_dir, ) diff --git a/samcli/lib/package/artifact_exporter.py b/samcli/lib/package/artifact_exporter.py index 05ac43ce314..3049c5d25cb 100644 --- a/samcli/lib/package/artifact_exporter.py +++ b/samcli/lib/package/artifact_exporter.py @@ -375,21 +375,25 @@ def _do_export_with_language_extensions( exported_template = template.export() + deferred_dynamic: List = [] exported_template_dict = merge_language_extensions_s3_uris( result.original_template, exported_template, result.dynamic_artifact_properties, + parameter_values=parameter_values, + deferred_dynamic=deferred_dynamic, ) - if result.dynamic_artifact_properties: + all_dynamic_properties = list(result.dynamic_artifact_properties or []) + deferred_dynamic + if all_dynamic_properties: LOG.debug( "Generating Mappings for %d dynamic artifact properties in child template", - len(result.dynamic_artifact_properties), + len(all_dynamic_properties), ) exported_resources = exported_template.get("Resources", {}) exported_template_dict = generate_and_apply_artifact_mappings( exported_template_dict, - result.dynamic_artifact_properties, + all_dynamic_properties, exported_resources, child_template_dir, ) diff --git a/tests/unit/commands/package/test_package_context.py b/tests/unit/commands/package/test_package_context.py index a9e255dace8..b5e3399be61 100644 --- a/tests/unit/commands/package/test_package_context.py +++ b/tests/unit/commands/package/test_package_context.py @@ -4589,3 +4589,146 @@ def test_off_path_does_not_invoke_pre_le_global_transform_pass( ctx._export("template.yaml", use_json=False) mock_pre_le_pass.assert_not_called() + + +class TestForEachImagePackagingEndToEnd(TestCase): + """End-to-end pipeline: Fn::ForEach + !Ref collection + PackageType: Image (#9117).""" + + def _run(self, template_dict, param_values): + import copy + from unittest.mock import patch + from botocore.utils import set_value_from_jmespath + from samcli.lib.package.artifact_exporter import Template + from samcli.lib.package.uploaders import Uploaders + from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions + from samcli.lib.package.language_extensions_packaging import ( + merge_language_extensions_s3_uris, + generate_and_apply_artifact_mappings, + ) + import samcli.lib.package.packageable_resources as pr + + result = expand_language_extensions(template_dict, param_values, enabled=True) + + def fake_image_export(self, resource_id, resource_dict, parent_dir): + set_value_from_jmespath( + resource_dict, self.PROPERTY_NAME, f"repo:{resource_id}-latest" + ) + + with patch.object(pr.ResourceImage, "do_export", fake_image_export): + template = Template( + "t.yaml", ".", Uploaders(object(), object()), None, + normalize_template=True, normalize_parameters=True, + template_dict=copy.deepcopy(result.expanded_template), + parameter_values=param_values, language_extensions_enabled=True, + ) + exported = template.export() + + deferred = [] + output = merge_language_extensions_s3_uris( + result.original_template, exported, result.dynamic_artifact_properties, + parameter_values=param_values, deferred_dynamic=deferred, + ) + all_dynamic = list(result.dynamic_artifact_properties or []) + deferred + if all_dynamic: + output = generate_and_apply_artifact_mappings( + output, all_dynamic, exported.get("Resources", {}), "." + ) + return output + + def test_multivalue_ref_image_generates_findinmap(self): + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Parameters": {"FuncType": {"Type": "CommaDelimitedList", "Default": "func1,func2"}}, + "Resources": { + "Fn::ForEach::LoopFunction": [ + "FunctionName", + {"Ref": "FuncType"}, + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"PackageType": "Image", "ImageUri": "foo"}, + } + }, + ] + }, + } + from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable + output = self._run(template, {**IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES}) + + body = output["Resources"]["Fn::ForEach::LoopFunction"][2]["${FunctionName}Function"]["Properties"] + self.assertIn("Fn::FindInMap", body["ImageUri"]) + mapping_name = body["ImageUri"]["Fn::FindInMap"][0] + mapping = output["Mappings"][mapping_name] + self.assertEqual(mapping["func1"]["ImageUri"], "repo:func1Function-latest") + self.assertEqual(mapping["func2"]["ImageUri"], "repo:func2Function-latest") + + def test_package_context_wires_deferred_image_mappings(self): + # Drives the REAL production caller (_export_with_language_extensions), + # not the helpers directly, so it guards the caller wiring itself: + # parameter_values passed + deferred_dynamic collected + combined into + # generate_and_apply_artifact_mappings. + import copy + import tempfile + import os + from unittest.mock import patch, MagicMock + from botocore.utils import set_value_from_jmespath + from samcli.commands._utils.template import yaml_parse + from samcli.yamlhelper import yaml_dump + import samcli.lib.package.packageable_resources as pr + + template_str = yaml_dump({ + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Parameters": {"FuncType": {"Type": "CommaDelimitedList", "Default": "func1,func2"}}, + "Resources": { + "Fn::ForEach::LoopFunction": [ + "FunctionName", + {"Ref": "FuncType"}, + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"PackageType": "Image", "ImageUri": "foo"}, + } + }, + ] + }, + }) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tf: + tf.write(template_str) + template_path = tf.name + + def fake_image_export(self, resource_id, resource_dict, parent_dir): + set_value_from_jmespath(resource_dict, self.PROPERTY_NAME, f"repo:{resource_id}-latest") + + try: + ctx = PackageContext( + template_file=template_path, + s3_bucket="bucket", + s3_prefix="prefix", + image_repository=None, + image_repositories=None, + output_template_file=None, + kms_key_id=None, + use_json=False, + force_upload=False, + no_progressbar=False, + metadata=None, + region=None, + profile=None, + language_extensions=True, + ) + ctx.uploaders = MagicMock() + ctx.code_signer = MagicMock() + with patch.object(pr.ResourceImage, "do_export", fake_image_export): + original_template_dict = yaml_parse(template_str) + output = ctx._export_with_language_extensions(template_path, original_template_dict) + finally: + os.unlink(template_path) + + body = output["Resources"]["Fn::ForEach::LoopFunction"][2]["${FunctionName}Function"]["Properties"] + self.assertIn("Fn::FindInMap", body["ImageUri"]) + mapping_name = body["ImageUri"]["Fn::FindInMap"][0] + mapping = output["Mappings"][mapping_name] + self.assertEqual(mapping["func1"]["ImageUri"], "repo:func1Function-latest") + self.assertEqual(mapping["func2"]["ImageUri"], "repo:func2Function-latest") From 51b6b84f7035fcf60bc423bc93a6b564dd8cb27d Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 9 Jul 2026 05:40:00 +0000 Subject: [PATCH 05/10] test: cover all packageable artifact shapes for Fn::ForEach merge (#9117) --- ...est_package_context_language_extensions.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/unit/commands/package/test_package_context_language_extensions.py b/tests/unit/commands/package/test_package_context_language_extensions.py index bb36f91a348..cb78783be38 100644 --- a/tests/unit/commands/package/test_package_context_language_extensions.py +++ b/tests/unit/commands/package/test_package_context_language_extensions.py @@ -1648,3 +1648,91 @@ def test_merge_resolves_ref_and_collects_deferred(self): self.assertEqual(body["ImageUri"], "foo") self.assertEqual(len(deferred), 1) self.assertEqual(deferred[0].property_name, "ImageUri") + + +class TestForEachArtifactShapes(TestCase): + """Decision matrix across the packageable artifact value shapes.""" + + def _body(self, resource_type, prop_path, value): + # prop_path may be dotted (e.g. "Code.ImageUri"); build nested dict. + props: dict = {} + cur = props + parts = prop_path.split(".") + for p in parts[:-1]: + cur = cur.setdefault(p, {}) + cur[parts[-1]] = value + return [ + "Name", + ["a", "b"], + {"${Name}Res": {"Type": resource_type, "Properties": props}}, + ] + + def _exported(self, resource_type, prop_path, val_a, val_b): + def mk(v): + props: dict = {} + cur = props + parts = prop_path.split(".") + for p in parts[:-1]: + cur = cur.setdefault(p, {}) + cur[parts[-1]] = v + return {"Type": resource_type, "Properties": props} + return {"aRes": mk(val_a), "bRes": mk(val_b)} + + def _leaf(self, props, prop_path): + cur = props + for p in prop_path.split("."): + cur = cur[p] + return cur + + # --- string URI shape (CodeUri, DefinitionUri) --- + def test_string_uri_identical_copies(self): + fe = self._body("AWS::Serverless::StateMachine", "DefinitionUri", "def.asl.json") + exp = self._exported("AWS::Serverless::StateMachine", "DefinitionUri", + "s3://b/SAME", "s3://b/SAME") + deferred = [] + _update_foreach_with_s3_uris("Fn::ForEach::L", fe, exp, None, deferred_dynamic=deferred) + self.assertEqual(self._leaf(fe[2]["${Name}Res"]["Properties"], "DefinitionUri"), "s3://b/SAME") + self.assertEqual(deferred, []) + + # --- dotted path shape (Code.ImageUri, Command.ScriptLocation) --- + def test_dotted_imageuri_differing_defers(self): + fe = self._body("AWS::Lambda::Function", "Code.ImageUri", "foo") + exp = self._exported("AWS::Lambda::Function", "Code.ImageUri", + "repo:aRes", "repo:bRes") + deferred = [] + _update_foreach_with_s3_uris("Fn::ForEach::L", fe, exp, None, deferred_dynamic=deferred) + self.assertEqual(len(deferred), 1) + self.assertEqual(deferred[0].property_name, "Code.ImageUri") + + def test_dotted_scriptlocation_identical_copies(self): + fe = self._body("AWS::Glue::Job", "Command.ScriptLocation", "script.py") + exp = self._exported("AWS::Glue::Job", "Command.ScriptLocation", + "s3://b/SAME", "s3://b/SAME") + deferred = [] + _update_foreach_with_s3_uris("Fn::ForEach::L", fe, exp, None, deferred_dynamic=deferred) + self.assertEqual(self._leaf(fe[2]["${Name}Res"]["Properties"], "Command.ScriptLocation"), "s3://b/SAME") + self.assertEqual(deferred, []) + + # --- image tag shape (ImageUri) --- + def test_imageuri_differing_defers(self): + fe = self._body("AWS::Serverless::Function", "ImageUri", "foo") + exp = self._exported("AWS::Serverless::Function", "ImageUri", + "repo:aFunc", "repo:bFunc") + deferred = [] + _update_foreach_with_s3_uris("Fn::ForEach::L", fe, exp, None, deferred_dynamic=deferred) + self.assertEqual(len(deferred), 1) + + # --- structured object shape ({S3Bucket,S3Key}) --- + def test_structured_content_identical_preserves_object(self): + fe = self._body("AWS::Lambda::LayerVersion", "Content", "layer/") + exp = self._exported("AWS::Lambda::LayerVersion", "Content", + {"S3Bucket": "b", "S3Key": "SAME"}, + {"S3Bucket": "b", "S3Key": "SAME"}) + deferred = [] + _update_foreach_with_s3_uris("Fn::ForEach::L", fe, exp, None, deferred_dynamic=deferred) + # Identical across iterations -> copy the RAW object shape (not a normalized s3:// string). + self.assertEqual( + self._leaf(fe[2]["${Name}Res"]["Properties"], "Content"), + {"S3Bucket": "b", "S3Key": "SAME"}, + ) + self.assertEqual(deferred, []) From 0227a5f36f7779aaea24c93c72c81b34b88d2824 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 9 Jul 2026 05:44:44 +0000 Subject: [PATCH 06/10] test: non-function ForEach coverage and nested static-image limitation guard (#9117) --- .../commands/package/test_package_context.py | 63 +++++++++++++++++++ ...est_package_context_language_extensions.py | 42 +++++++++++++ 2 files changed, 105 insertions(+) diff --git a/tests/unit/commands/package/test_package_context.py b/tests/unit/commands/package/test_package_context.py index b5e3399be61..7c23285b561 100644 --- a/tests/unit/commands/package/test_package_context.py +++ b/tests/unit/commands/package/test_package_context.py @@ -4732,3 +4732,66 @@ def fake_image_export(self, resource_id, resource_dict, parent_dir): mapping = output["Mappings"][mapping_name] self.assertEqual(mapping["func1"]["ImageUri"], "repo:func1Function-latest") self.assertEqual(mapping["func2"]["ImageUri"], "repo:func2Function-latest") + + def _run_zip(self, template_dict, param_values): + import copy + from unittest.mock import patch + from botocore.utils import set_value_from_jmespath + from samcli.lib.package.artifact_exporter import Template + from samcli.lib.package.uploaders import Uploaders + from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions + from samcli.lib.package.language_extensions_packaging import ( + merge_language_extensions_s3_uris, + generate_and_apply_artifact_mappings, + ) + import samcli.lib.package.packageable_resources as pr + + result = expand_language_extensions(template_dict, param_values, enabled=True) + + def fake_zip_export(self, resource_id, resource_dict, parent_dir): + set_value_from_jmespath(resource_dict, self.PROPERTY_NAME, "s3://bucket/SAME") + + with patch.object(pr.ResourceWithS3UrlDict, "do_export", fake_zip_export): + template = Template( + "t.yaml", ".", Uploaders(object(), object()), None, + normalize_template=True, normalize_parameters=True, + template_dict=copy.deepcopy(result.expanded_template), + parameter_values=param_values, language_extensions_enabled=True, + ) + exported = template.export() + + deferred = [] + output = merge_language_extensions_s3_uris( + result.original_template, exported, result.dynamic_artifact_properties, + parameter_values=param_values, deferred_dynamic=deferred, + ) + all_dynamic = list(result.dynamic_artifact_properties or []) + deferred + if all_dynamic: + output = generate_and_apply_artifact_mappings( + output, all_dynamic, exported.get("Resources", {}), "." + ) + return output + + def test_ref_collection_statemachine_definitionuri_rewritten(self): + from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable + template = { + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": "Alpha,Beta"}}, + "Resources": { + "Fn::ForEach::SM": [ + "Name", + {"Ref": "Names"}, + { + "${Name}Machine": { + "Type": "AWS::Serverless::StateMachine", + "Properties": {"DefinitionUri": "sm.asl.json"}, + } + }, + ] + }, + } + output = self._run_zip(template, {**IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES}) + body = output["Resources"]["Fn::ForEach::SM"][2]["${Name}Machine"]["Properties"] + # Identical S3 URI across iterations -> shared static copy, not a Mapping. + self.assertEqual(body["DefinitionUri"], "s3://bucket/SAME") + self.assertNotIn("Mappings", output) diff --git a/tests/unit/commands/package/test_package_context_language_extensions.py b/tests/unit/commands/package/test_package_context_language_extensions.py index cb78783be38..3c3001cd192 100644 --- a/tests/unit/commands/package/test_package_context_language_extensions.py +++ b/tests/unit/commands/package/test_package_context_language_extensions.py @@ -1736,3 +1736,45 @@ def test_structured_content_identical_preserves_object(self): {"S3Bucket": "b", "S3Key": "SAME"}, ) self.assertEqual(deferred, []) + + +class TestForEachNestedStaticImageLimitation(TestCase): + """Nested ForEach + static image value falls back to legacy copy (documented limitation). + + Image identity varies by outer AND inner loop, but a static value carries no + loop variable, so the walk cannot build compound Mapping keys. This guards the + documented fallback. Nested ForEach with dynamic values is unaffected and + continues through the existing dynamic path. + """ + + def test_nested_static_image_falls_back_to_copy(self): + foreach_value = [ + "Env", + ["dev", "prod"], + { + "Fn::ForEach::Svc": [ + "Svc", + ["Users", "Orders"], + { + "${Env}${Svc}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "foo"}, + } + }, + ] + }, + ] + exported = { + "devUsersFunction": {"Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:devUsersFunction"}}, + "devOrdersFunction": {"Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:devOrdersFunction"}}, + } + deferred = [] + _update_foreach_with_s3_uris( + "Fn::ForEach::Env", foreach_value, exported, None, deferred_dynamic=deferred + ) + inner = foreach_value[2]["Fn::ForEach::Svc"][2]["${Env}${Svc}Function"]["Properties"] + # Legacy fallback copies the first inner iteration's URI; nothing deferred. + self.assertEqual(inner["ImageUri"], "repo:devUsersFunction") + self.assertEqual(deferred, []) From 103cc40e017c92cc3384250615974e54d134ef49 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 9 Jul 2026 05:48:07 +0000 Subject: [PATCH 07/10] style: format Fn::ForEach artifact URI fix (#9117) --- .../package/language_extensions_packaging.py | 8 +- .../commands/package/test_package_context.py | 86 +++++++----- ...est_package_context_language_extensions.py | 124 +++++++++++------- 3 files changed, 126 insertions(+), 92 deletions(-) diff --git a/samcli/lib/package/language_extensions_packaging.py b/samcli/lib/package/language_extensions_packaging.py index 2d214840634..bd8523fde25 100644 --- a/samcli/lib/package/language_extensions_packaging.py +++ b/samcli/lib/package/language_extensions_packaging.py @@ -361,14 +361,10 @@ def _merge_or_defer_foreach_artifacts( # resulting CloudFormation stays valid. resolved: List[Tuple[str, Any]] = [] for value in collection_values: - expanded_key = _build_expanded_key( - resource_template_key, loop_variable, [value], outer_context - ) + expanded_key = _build_expanded_key(resource_template_key, loop_variable, [value], outer_context) if not expanded_key: continue - uri = _find_artifact_uri_for_resource( - exported_resources, expanded_key, resource_type, prop_name - ) + uri = _find_artifact_uri_for_resource(exported_resources, expanded_key, resource_type, prop_name) if uri is None: continue exported_resource = exported_resources.get(expanded_key, {}) diff --git a/tests/unit/commands/package/test_package_context.py b/tests/unit/commands/package/test_package_context.py index 7c23285b561..a7de023c1d2 100644 --- a/tests/unit/commands/package/test_package_context.py +++ b/tests/unit/commands/package/test_package_context.py @@ -4610,29 +4610,33 @@ def _run(self, template_dict, param_values): result = expand_language_extensions(template_dict, param_values, enabled=True) def fake_image_export(self, resource_id, resource_dict, parent_dir): - set_value_from_jmespath( - resource_dict, self.PROPERTY_NAME, f"repo:{resource_id}-latest" - ) + set_value_from_jmespath(resource_dict, self.PROPERTY_NAME, f"repo:{resource_id}-latest") with patch.object(pr.ResourceImage, "do_export", fake_image_export): template = Template( - "t.yaml", ".", Uploaders(object(), object()), None, - normalize_template=True, normalize_parameters=True, + "t.yaml", + ".", + Uploaders(object(), object()), + None, + normalize_template=True, + normalize_parameters=True, template_dict=copy.deepcopy(result.expanded_template), - parameter_values=param_values, language_extensions_enabled=True, + parameter_values=param_values, + language_extensions_enabled=True, ) exported = template.export() deferred = [] output = merge_language_extensions_s3_uris( - result.original_template, exported, result.dynamic_artifact_properties, - parameter_values=param_values, deferred_dynamic=deferred, + result.original_template, + exported, + result.dynamic_artifact_properties, + parameter_values=param_values, + deferred_dynamic=deferred, ) all_dynamic = list(result.dynamic_artifact_properties or []) + deferred if all_dynamic: - output = generate_and_apply_artifact_mappings( - output, all_dynamic, exported.get("Resources", {}), "." - ) + output = generate_and_apply_artifact_mappings(output, all_dynamic, exported.get("Resources", {}), ".") return output def test_multivalue_ref_image_generates_findinmap(self): @@ -4653,6 +4657,7 @@ def test_multivalue_ref_image_generates_findinmap(self): }, } from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable + output = self._run(template, {**IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES}) body = output["Resources"]["Fn::ForEach::LoopFunction"][2]["${FunctionName}Function"]["Properties"] @@ -4676,23 +4681,25 @@ def test_package_context_wires_deferred_image_mappings(self): from samcli.yamlhelper import yaml_dump import samcli.lib.package.packageable_resources as pr - template_str = yaml_dump({ - "AWSTemplateFormatVersion": "2010-09-09", - "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], - "Parameters": {"FuncType": {"Type": "CommaDelimitedList", "Default": "func1,func2"}}, - "Resources": { - "Fn::ForEach::LoopFunction": [ - "FunctionName", - {"Ref": "FuncType"}, - { - "${FunctionName}Function": { - "Type": "AWS::Serverless::Function", - "Properties": {"PackageType": "Image", "ImageUri": "foo"}, - } - }, - ] - }, - }) + template_str = yaml_dump( + { + "AWSTemplateFormatVersion": "2010-09-09", + "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], + "Parameters": {"FuncType": {"Type": "CommaDelimitedList", "Default": "func1,func2"}}, + "Resources": { + "Fn::ForEach::LoopFunction": [ + "FunctionName", + {"Ref": "FuncType"}, + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"PackageType": "Image", "ImageUri": "foo"}, + } + }, + ] + }, + } + ) with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tf: tf.write(template_str) @@ -4753,27 +4760,34 @@ def fake_zip_export(self, resource_id, resource_dict, parent_dir): with patch.object(pr.ResourceWithS3UrlDict, "do_export", fake_zip_export): template = Template( - "t.yaml", ".", Uploaders(object(), object()), None, - normalize_template=True, normalize_parameters=True, + "t.yaml", + ".", + Uploaders(object(), object()), + None, + normalize_template=True, + normalize_parameters=True, template_dict=copy.deepcopy(result.expanded_template), - parameter_values=param_values, language_extensions_enabled=True, + parameter_values=param_values, + language_extensions_enabled=True, ) exported = template.export() deferred = [] output = merge_language_extensions_s3_uris( - result.original_template, exported, result.dynamic_artifact_properties, - parameter_values=param_values, deferred_dynamic=deferred, + result.original_template, + exported, + result.dynamic_artifact_properties, + parameter_values=param_values, + deferred_dynamic=deferred, ) all_dynamic = list(result.dynamic_artifact_properties or []) + deferred if all_dynamic: - output = generate_and_apply_artifact_mappings( - output, all_dynamic, exported.get("Resources", {}), "." - ) + output = generate_and_apply_artifact_mappings(output, all_dynamic, exported.get("Resources", {}), ".") return output def test_ref_collection_statemachine_definitionuri_rewritten(self): from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable + template = { "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], "Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": "Alpha,Beta"}}, diff --git a/tests/unit/commands/package/test_package_context_language_extensions.py b/tests/unit/commands/package/test_package_context_language_extensions.py index 3c3001cd192..9e8f064507b 100644 --- a/tests/unit/commands/package/test_package_context_language_extensions.py +++ b/tests/unit/commands/package/test_package_context_language_extensions.py @@ -1517,14 +1517,15 @@ def _foreach(self, prop_name, static_value): def test_identical_uris_copied_as_static(self): foreach_value = self._foreach("CodeUri", "src") exported_resources = { - "func1Function": {"Type": "AWS::Serverless::Function", - "Properties": {"CodeUri": "s3://bucket/SAME"}}, - "func2Function": {"Type": "AWS::Serverless::Function", - "Properties": {"CodeUri": "s3://bucket/SAME"}}, + "func1Function": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "s3://bucket/SAME"}}, + "func2Function": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "s3://bucket/SAME"}}, } deferred = [] _update_foreach_with_s3_uris( - "Fn::ForEach::L", foreach_value, exported_resources, None, + "Fn::ForEach::L", + foreach_value, + exported_resources, + None, deferred_dynamic=deferred, ) props = foreach_value[2]["${FunctionName}Function"]["Properties"] @@ -1534,14 +1535,21 @@ def test_identical_uris_copied_as_static(self): def test_differing_uris_deferred_not_copied(self): foreach_value = self._foreach("ImageUri", "foo") exported_resources = { - "func1Function": {"Type": "AWS::Serverless::Function", - "Properties": {"ImageUri": "repo:func1Function-latest"}}, - "func2Function": {"Type": "AWS::Serverless::Function", - "Properties": {"ImageUri": "repo:func2Function-latest"}}, + "func1Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func1Function-latest"}, + }, + "func2Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func2Function-latest"}, + }, } deferred = [] _update_foreach_with_s3_uris( - "Fn::ForEach::L", foreach_value, exported_resources, None, + "Fn::ForEach::L", + foreach_value, + exported_resources, + None, deferred_dynamic=deferred, ) props = foreach_value[2]["${FunctionName}Function"]["Properties"] @@ -1555,10 +1563,14 @@ def test_differing_uris_deferred_not_copied(self): def test_differing_uris_without_accumulator_falls_back_to_copy(self): foreach_value = self._foreach("ImageUri", "foo") exported_resources = { - "func1Function": {"Type": "AWS::Serverless::Function", - "Properties": {"ImageUri": "repo:func1Function-latest"}}, - "func2Function": {"Type": "AWS::Serverless::Function", - "Properties": {"ImageUri": "repo:func2Function-latest"}}, + "func1Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func1Function-latest"}, + }, + "func2Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func2Function-latest"}, + }, } _update_foreach_with_s3_uris("Fn::ForEach::L", foreach_value, exported_resources, None) props = foreach_value[2]["${FunctionName}Function"]["Properties"] @@ -1567,15 +1579,16 @@ def test_differing_uris_without_accumulator_falls_back_to_copy(self): def test_dynamic_prop_key_still_skipped(self): foreach_value = self._foreach("CodeUri", "${FunctionName}/") exported_resources = { - "func1Function": {"Type": "AWS::Serverless::Function", - "Properties": {"CodeUri": "s3://bucket/func1"}}, - "func2Function": {"Type": "AWS::Serverless::Function", - "Properties": {"CodeUri": "s3://bucket/func2"}}, + "func1Function": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "s3://bucket/func1"}}, + "func2Function": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "s3://bucket/func2"}}, } deferred = [] _update_foreach_with_s3_uris( - "Fn::ForEach::L", foreach_value, exported_resources, - {("Fn::ForEach::L", "CodeUri")}, deferred_dynamic=deferred, + "Fn::ForEach::L", + foreach_value, + exported_resources, + {("Fn::ForEach::L", "CodeUri")}, + deferred_dynamic=deferred, ) props = foreach_value[2]["${FunctionName}Function"]["Properties"] self.assertEqual(props["CodeUri"], "${FunctionName}/") @@ -1595,14 +1608,21 @@ def test_identical_dict_form_artifact_preserves_object_shape(self): }, ] exported_resources = { - "func1Function": {"Type": "AWS::Lambda::Function", - "Properties": {"Code": {"S3Bucket": "b", "S3Key": "SAME"}}}, - "func2Function": {"Type": "AWS::Lambda::Function", - "Properties": {"Code": {"S3Bucket": "b", "S3Key": "SAME"}}}, + "func1Function": { + "Type": "AWS::Lambda::Function", + "Properties": {"Code": {"S3Bucket": "b", "S3Key": "SAME"}}, + }, + "func2Function": { + "Type": "AWS::Lambda::Function", + "Properties": {"Code": {"S3Bucket": "b", "S3Key": "SAME"}}, + }, } deferred = [] _update_foreach_with_s3_uris( - "Fn::ForEach::L", foreach_value, exported_resources, None, + "Fn::ForEach::L", + foreach_value, + exported_resources, + None, deferred_dynamic=deferred, ) code = foreach_value[2]["${FunctionName}Function"]["Properties"]["Code"] @@ -1633,16 +1653,18 @@ def test_merge_resolves_ref_and_collects_deferred(self): } exported = { "Resources": { - "func1Function": {"Type": "AWS::Serverless::Function", - "Properties": {"ImageUri": "repo:func1Function-latest"}}, - "func2Function": {"Type": "AWS::Serverless::Function", - "Properties": {"ImageUri": "repo:func2Function-latest"}}, + "func1Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func1Function-latest"}, + }, + "func2Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func2Function-latest"}, + }, } } deferred = [] - result = merge_language_extensions_s3_uris( - original, exported, None, deferred_dynamic=deferred - ) + result = merge_language_extensions_s3_uris(original, exported, None, deferred_dynamic=deferred) body = result["Resources"]["Fn::ForEach::L"][2]["${FunctionName}Function"]["Properties"] # Differing image URIs deferred; body value untouched pre-Mapping. self.assertEqual(body["ImageUri"], "foo") @@ -1676,6 +1698,7 @@ def mk(v): cur = cur.setdefault(p, {}) cur[parts[-1]] = v return {"Type": resource_type, "Properties": props} + return {"aRes": mk(val_a), "bRes": mk(val_b)} def _leaf(self, props, prop_path): @@ -1687,8 +1710,7 @@ def _leaf(self, props, prop_path): # --- string URI shape (CodeUri, DefinitionUri) --- def test_string_uri_identical_copies(self): fe = self._body("AWS::Serverless::StateMachine", "DefinitionUri", "def.asl.json") - exp = self._exported("AWS::Serverless::StateMachine", "DefinitionUri", - "s3://b/SAME", "s3://b/SAME") + exp = self._exported("AWS::Serverless::StateMachine", "DefinitionUri", "s3://b/SAME", "s3://b/SAME") deferred = [] _update_foreach_with_s3_uris("Fn::ForEach::L", fe, exp, None, deferred_dynamic=deferred) self.assertEqual(self._leaf(fe[2]["${Name}Res"]["Properties"], "DefinitionUri"), "s3://b/SAME") @@ -1697,8 +1719,7 @@ def test_string_uri_identical_copies(self): # --- dotted path shape (Code.ImageUri, Command.ScriptLocation) --- def test_dotted_imageuri_differing_defers(self): fe = self._body("AWS::Lambda::Function", "Code.ImageUri", "foo") - exp = self._exported("AWS::Lambda::Function", "Code.ImageUri", - "repo:aRes", "repo:bRes") + exp = self._exported("AWS::Lambda::Function", "Code.ImageUri", "repo:aRes", "repo:bRes") deferred = [] _update_foreach_with_s3_uris("Fn::ForEach::L", fe, exp, None, deferred_dynamic=deferred) self.assertEqual(len(deferred), 1) @@ -1706,8 +1727,7 @@ def test_dotted_imageuri_differing_defers(self): def test_dotted_scriptlocation_identical_copies(self): fe = self._body("AWS::Glue::Job", "Command.ScriptLocation", "script.py") - exp = self._exported("AWS::Glue::Job", "Command.ScriptLocation", - "s3://b/SAME", "s3://b/SAME") + exp = self._exported("AWS::Glue::Job", "Command.ScriptLocation", "s3://b/SAME", "s3://b/SAME") deferred = [] _update_foreach_with_s3_uris("Fn::ForEach::L", fe, exp, None, deferred_dynamic=deferred) self.assertEqual(self._leaf(fe[2]["${Name}Res"]["Properties"], "Command.ScriptLocation"), "s3://b/SAME") @@ -1716,8 +1736,7 @@ def test_dotted_scriptlocation_identical_copies(self): # --- image tag shape (ImageUri) --- def test_imageuri_differing_defers(self): fe = self._body("AWS::Serverless::Function", "ImageUri", "foo") - exp = self._exported("AWS::Serverless::Function", "ImageUri", - "repo:aFunc", "repo:bFunc") + exp = self._exported("AWS::Serverless::Function", "ImageUri", "repo:aFunc", "repo:bFunc") deferred = [] _update_foreach_with_s3_uris("Fn::ForEach::L", fe, exp, None, deferred_dynamic=deferred) self.assertEqual(len(deferred), 1) @@ -1725,9 +1744,12 @@ def test_imageuri_differing_defers(self): # --- structured object shape ({S3Bucket,S3Key}) --- def test_structured_content_identical_preserves_object(self): fe = self._body("AWS::Lambda::LayerVersion", "Content", "layer/") - exp = self._exported("AWS::Lambda::LayerVersion", "Content", - {"S3Bucket": "b", "S3Key": "SAME"}, - {"S3Bucket": "b", "S3Key": "SAME"}) + exp = self._exported( + "AWS::Lambda::LayerVersion", + "Content", + {"S3Bucket": "b", "S3Key": "SAME"}, + {"S3Bucket": "b", "S3Key": "SAME"}, + ) deferred = [] _update_foreach_with_s3_uris("Fn::ForEach::L", fe, exp, None, deferred_dynamic=deferred) # Identical across iterations -> copy the RAW object shape (not a normalized s3:// string). @@ -1765,15 +1787,17 @@ def test_nested_static_image_falls_back_to_copy(self): }, ] exported = { - "devUsersFunction": {"Type": "AWS::Serverless::Function", - "Properties": {"ImageUri": "repo:devUsersFunction"}}, - "devOrdersFunction": {"Type": "AWS::Serverless::Function", - "Properties": {"ImageUri": "repo:devOrdersFunction"}}, + "devUsersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:devUsersFunction"}, + }, + "devOrdersFunction": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:devOrdersFunction"}, + }, } deferred = [] - _update_foreach_with_s3_uris( - "Fn::ForEach::Env", foreach_value, exported, None, deferred_dynamic=deferred - ) + _update_foreach_with_s3_uris("Fn::ForEach::Env", foreach_value, exported, None, deferred_dynamic=deferred) inner = foreach_value[2]["Fn::ForEach::Svc"][2]["${Env}${Svc}Function"]["Properties"] # Legacy fallback copies the first inner iteration's URI; nothing deferred. self.assertEqual(inner["ImageUri"], "repo:devUsersFunction") From e4b61eb59c4a16054799276a274f3999a981516f Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 9 Jul 2026 06:02:32 +0000 Subject: [PATCH 08/10] fix: propagate parameter-ref metadata for deferred ForEach artifacts (#9117) --- .../package/language_extensions_packaging.py | 17 +++++ ...est_package_context_language_extensions.py | 70 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/samcli/lib/package/language_extensions_packaging.py b/samcli/lib/package/language_extensions_packaging.py index bd8523fde25..6cf2d4b43ad 100644 --- a/samcli/lib/package/language_extensions_packaging.py +++ b/samcli/lib/package/language_extensions_packaging.py @@ -271,6 +271,17 @@ def _update_foreach_with_s3_uris( collection_values = resolve_collection(collection, template or {}, parameter_values) + # Detect whether the collection is a parameter reference so deferred artifacts + # carry the metadata that warn_parameter_based_collections uses to advise + # re-packaging (mirrors detect_foreach_dynamic_properties in sam_integration.py). + collection_is_parameter_ref = False + collection_parameter_name: Optional[str] = None + if isinstance(collection, dict) and "Ref" in collection: + param_name = collection["Ref"] + if param_name in (template or {}).get("Parameters", {}): + collection_is_parameter_ref = True + collection_parameter_name = param_name + if outer_context is None: outer_context = [] current_outer_context = outer_context + [(loop_variable, collection_values)] @@ -319,6 +330,8 @@ def _update_foreach_with_s3_uris( exported_resources=exported_resources, dynamic_prop_keys=dynamic_prop_keys, deferred_dynamic=deferred_dynamic, + collection_is_parameter_ref=collection_is_parameter_ref, + collection_parameter_name=collection_parameter_name, ) @@ -333,6 +346,8 @@ def _merge_or_defer_foreach_artifacts( exported_resources: Dict[str, Any], dynamic_prop_keys: Optional[set], deferred_dynamic: Optional[List], + collection_is_parameter_ref: bool = False, + collection_parameter_name: Optional[str] = None, ) -> None: """Decide, per artifact property, whether all Fn::ForEach iterations resolved to the same exported URI (static copy) or to distinct URIs (defer to Mappings). @@ -390,6 +405,8 @@ def _merge_or_defer_foreach_artifacts( property_name=prop_name, property_value=_get_prop_value(properties, prop_name), outer_loops=[], + collection_is_parameter_ref=collection_is_parameter_ref, + collection_parameter_name=collection_parameter_name, ) ) else: diff --git a/tests/unit/commands/package/test_package_context_language_extensions.py b/tests/unit/commands/package/test_package_context_language_extensions.py index 9e8f064507b..a9506bcf9a4 100644 --- a/tests/unit/commands/package/test_package_context_language_extensions.py +++ b/tests/unit/commands/package/test_package_context_language_extensions.py @@ -1629,6 +1629,76 @@ def test_identical_dict_form_artifact_preserves_object_shape(self): self.assertEqual(code, {"S3Bucket": "b", "S3Key": "SAME"}) self.assertEqual(deferred, []) + def test_deferred_ref_collection_marks_parameter_ref(self): + # Deferred artifacts from a !Ref collection must carry the parameter-ref + # metadata so warn_parameter_based_collections can advise re-packaging. + foreach_value = [ + "FunctionName", + {"Ref": "FuncType"}, + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "foo"}, + } + }, + ] + exported_resources = { + "func1Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func1Function-latest"}, + }, + "func2Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func2Function-latest"}, + }, + } + template = {"Parameters": {"FuncType": {"Default": "func1,func2"}}} + deferred = [] + _update_foreach_with_s3_uris( + "Fn::ForEach::L", + foreach_value, + exported_resources, + None, + template=template, + deferred_dynamic=deferred, + ) + self.assertEqual(len(deferred), 1) + self.assertTrue(deferred[0].collection_is_parameter_ref) + self.assertEqual(deferred[0].collection_parameter_name, "FuncType") + + def test_deferred_literal_collection_not_parameter_ref(self): + foreach_value = [ + "FunctionName", + ["func1", "func2"], + { + "${FunctionName}Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "foo"}, + } + }, + ] + exported_resources = { + "func1Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func1Function-latest"}, + }, + "func2Function": { + "Type": "AWS::Serverless::Function", + "Properties": {"ImageUri": "repo:func2Function-latest"}, + }, + } + deferred = [] + _update_foreach_with_s3_uris( + "Fn::ForEach::L", + foreach_value, + exported_resources, + None, + deferred_dynamic=deferred, + ) + self.assertEqual(len(deferred), 1) + self.assertFalse(deferred[0].collection_is_parameter_ref) + self.assertIsNone(deferred[0].collection_parameter_name) + class TestMergeThreadsParametersAndDeferred(TestCase): """merge_language_extensions_s3_uris forwards parameter_values + accumulator.""" From 5991cafaddaeeb6f03d0871f509f3b5b34933acf Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 9 Jul 2026 06:23:54 +0000 Subject: [PATCH 09/10] fix: expand language extensions in sync_ecr_stack for --resolve-image-repos (#9117) --- samcli/commands/deploy/command.py | 8 +++- samcli/commands/deploy/guided_context.py | 8 +++- samcli/commands/package/package_context.py | 8 +++- .../companion_stack_manager.py | 12 +++++- .../test_companion_stack_manager.py | 38 +++++++++++++++++++ 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/samcli/commands/deploy/command.py b/samcli/commands/deploy/command.py index c1d05561543..9b2d06c3d30 100644 --- a/samcli/commands/deploy/command.py +++ b/samcli/commands/deploy/command.py @@ -333,7 +333,13 @@ def do_cli( # after we figure out how to enable resolve-images-repos in package if resolve_image_repos: image_repositories = sync_ecr_stack( - template_file, stack_name, region, s3_bucket, s3_prefix, image_repositories + template_file, + stack_name, + region, + s3_bucket, + s3_prefix, + image_repositories, + language_extensions_enabled=language_extensions_enabled, ) with osutils.tempfile_platform_independent() as output_template_file: diff --git a/samcli/commands/deploy/guided_context.py b/samcli/commands/deploy/guided_context.py index 1a3335687ae..03ee6cd1b69 100644 --- a/samcli/commands/deploy/guided_context.py +++ b/samcli/commands/deploy/guided_context.py @@ -189,7 +189,13 @@ def guided_prompts(self, parameter_override_keys): image_repositories = ( sync_ecr_stack( - self.template_file, stack_name, region, managed_s3_bucket, self.s3_prefix, self.image_repositories + self.template_file, + stack_name, + region, + managed_s3_bucket, + self.s3_prefix, + self.image_repositories, + language_extensions_enabled=self._language_extensions_enabled, ) if self.resolve_image_repositories else self.prompt_image_repository( diff --git a/samcli/commands/package/package_context.py b/samcli/commands/package/package_context.py index dca836f2ae6..f02efcf395e 100644 --- a/samcli/commands/package/package_context.py +++ b/samcli/commands/package/package_context.py @@ -121,7 +121,13 @@ def run(self): stack_name = f"sam-app-{template_basename}" self.image_repositories = sync_ecr_stack( - self.template_file, stack_name, self.region, self.s3_bucket, self.s3_prefix, self.image_repositories + self.template_file, + stack_name, + self.region, + self.s3_bucket, + self.s3_prefix, + self.image_repositories, + language_extensions_enabled=self._language_extensions_enabled, ) stacks, _ = SamLocalStackProvider.get_stacks( diff --git a/samcli/lib/bootstrap/companion_stack/companion_stack_manager.py b/samcli/lib/bootstrap/companion_stack/companion_stack_manager.py index 66dd6a290e1..52ba15ffc24 100644 --- a/samcli/lib/bootstrap/companion_stack/companion_stack_manager.py +++ b/samcli/lib/bootstrap/companion_stack/companion_stack_manager.py @@ -279,7 +279,13 @@ def is_repo_uri(self, repo_uri: Optional[str], function_logical_id: str) -> bool def sync_ecr_stack( - template_file: str, stack_name: str, region: str, s3_bucket: str, s3_prefix: str, image_repositories: Dict[str, str] + template_file: str, + stack_name: str, + region: str, + s3_bucket: str, + s3_prefix: str, + image_repositories: Dict[str, str], + language_extensions_enabled: bool = False, ) -> Dict[str, str]: """Blocking call to sync local functions with ECR Companion Stack @@ -297,6 +303,8 @@ def sync_ecr_stack( S3 prefix for the bucket image_repositories : Dict[str, str] Mapping between function logical ID and ECR URI + language_extensions_enabled : bool + Whether AWS::LanguageExtensions transform processing (e.g. Fn::ForEach expansion) is enabled Returns ------- @@ -307,7 +315,7 @@ def sync_ecr_stack( image_repositories = image_repositories.copy() if image_repositories else {} manager = CompanionStackManager(stack_name, region, s3_bucket, s3_prefix) - stacks = SamLocalStackProvider.get_stacks(template_file, language_extensions_enabled=False)[0] + stacks = SamLocalStackProvider.get_stacks(template_file, language_extensions_enabled=language_extensions_enabled)[0] function_provider = SamFunctionProvider(stacks, ignore_code_extraction_warnings=True) function_logical_ids = [ function.full_path for function in function_provider.get_all() if function.packagetype == IMAGE diff --git a/tests/unit/lib/bootstrap/companion_stack/test_companion_stack_manager.py b/tests/unit/lib/bootstrap/companion_stack/test_companion_stack_manager.py index 69f7d76ff65..1414c12dfbf 100644 --- a/tests/unit/lib/bootstrap/companion_stack/test_companion_stack_manager.py +++ b/tests/unit/lib/bootstrap/companion_stack/test_companion_stack_manager.py @@ -281,3 +281,41 @@ def test_sync_ecr_stack(self, function_provider_mock, stack_provider_mock, manag manager_mock.return_value.sync_repos.assert_called_once_with() self.assertEqual(result, {"Function1": "uri1", "Function2": "uri2"}) + + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.CompanionStackManager") + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.SamLocalStackProvider") + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.SamFunctionProvider") + def test_sync_ecr_stack_language_extensions_enabled( + self, function_provider_mock, stack_provider_mock, manager_mock + ): + image_repositories = {"Function1": "uri1"} + stacks = MagicMock() + stack_provider_mock.get_stacks.return_value = (stacks, None) + manager_mock.return_value.get_repository_mapping.return_value = {"Function2": "uri2"} + + sync_ecr_stack( + "template.yaml", + "stack-name", + "region", + "s3-bucket", + "s3-prefix", + image_repositories, + language_extensions_enabled=True, + ) + + stack_provider_mock.get_stacks.assert_called_once_with("template.yaml", language_extensions_enabled=True) + + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.CompanionStackManager") + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.SamLocalStackProvider") + @patch("samcli.lib.bootstrap.companion_stack.companion_stack_manager.SamFunctionProvider") + def test_sync_ecr_stack_language_extensions_disabled_by_default( + self, function_provider_mock, stack_provider_mock, manager_mock + ): + image_repositories = {"Function1": "uri1"} + stacks = MagicMock() + stack_provider_mock.get_stacks.return_value = (stacks, None) + manager_mock.return_value.get_repository_mapping.return_value = {"Function2": "uri2"} + + sync_ecr_stack("template.yaml", "stack-name", "region", "s3-bucket", "s3-prefix", image_repositories) + + stack_provider_mock.get_stacks.assert_called_once_with("template.yaml", language_extensions_enabled=False) From 3c2804acb5f864eff452f50ed381d838127e7b34 Mon Sep 17 00:00:00 2001 From: Harold Sun Date: Thu, 9 Jul 2026 06:56:34 +0000 Subject: [PATCH 10/10] refactor: hoist test imports to module level (#9117) --- .../commands/package/test_package_context.py | 45 +++---------------- ...est_package_context_language_extensions.py | 3 +- 2 files changed, 8 insertions(+), 40 deletions(-) diff --git a/tests/unit/commands/package/test_package_context.py b/tests/unit/commands/package/test_package_context.py index a7de023c1d2..50cc2a48565 100644 --- a/tests/unit/commands/package/test_package_context.py +++ b/tests/unit/commands/package/test_package_context.py @@ -1,5 +1,6 @@ """Test sam package command""" +import copy import os from pathlib import Path from unittest import TestCase @@ -10,14 +11,19 @@ TEST_DATA_PATH = Path(__file__).resolve().parent / "test_data" +import samcli.lib.package.packageable_resources as pr +from botocore.utils import set_value_from_jmespath from samcli.commands.package.package_context import PackageContext from samcli.commands.package.exceptions import PackageFailedError from samcli.lib.cfn_language_extensions.sam_integration import ( contains_loop_variable, detect_dynamic_artifact_properties, + expand_language_extensions, ) +from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable from samcli.lib.package.artifact_exporter import Template from samcli.lib.package.language_extensions_packaging import ( + generate_and_apply_artifact_mappings, merge_language_extensions_s3_uris, warn_parameter_based_collections, _update_resources_with_s3_uris, @@ -35,7 +41,7 @@ from samcli.lib.providers.sam_stack_provider import SamLocalStackProvider from samcli.lib.samlib.resource_metadata_normalizer import ResourceMetadataNormalizer from samcli.lib.utils.resources import AWS_LAMBDA_FUNCTION, AWS_SERVERLESS_FUNCTION -from samcli.yamlhelper import yaml_parse +from samcli.yamlhelper import yaml_dump, yaml_parse class TestPackageCommand(TestCase): @@ -4595,18 +4601,6 @@ class TestForEachImagePackagingEndToEnd(TestCase): """End-to-end pipeline: Fn::ForEach + !Ref collection + PackageType: Image (#9117).""" def _run(self, template_dict, param_values): - import copy - from unittest.mock import patch - from botocore.utils import set_value_from_jmespath - from samcli.lib.package.artifact_exporter import Template - from samcli.lib.package.uploaders import Uploaders - from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions - from samcli.lib.package.language_extensions_packaging import ( - merge_language_extensions_s3_uris, - generate_and_apply_artifact_mappings, - ) - import samcli.lib.package.packageable_resources as pr - result = expand_language_extensions(template_dict, param_values, enabled=True) def fake_image_export(self, resource_id, resource_dict, parent_dir): @@ -4656,8 +4650,6 @@ def test_multivalue_ref_image_generates_findinmap(self): ] }, } - from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable - output = self._run(template, {**IntrinsicsSymbolTable.DEFAULT_PSEUDO_PARAM_VALUES}) body = output["Resources"]["Fn::ForEach::LoopFunction"][2]["${FunctionName}Function"]["Properties"] @@ -4672,15 +4664,6 @@ def test_package_context_wires_deferred_image_mappings(self): # not the helpers directly, so it guards the caller wiring itself: # parameter_values passed + deferred_dynamic collected + combined into # generate_and_apply_artifact_mappings. - import copy - import tempfile - import os - from unittest.mock import patch, MagicMock - from botocore.utils import set_value_from_jmespath - from samcli.commands._utils.template import yaml_parse - from samcli.yamlhelper import yaml_dump - import samcli.lib.package.packageable_resources as pr - template_str = yaml_dump( { "AWSTemplateFormatVersion": "2010-09-09", @@ -4741,18 +4724,6 @@ def fake_image_export(self, resource_id, resource_dict, parent_dir): self.assertEqual(mapping["func2"]["ImageUri"], "repo:func2Function-latest") def _run_zip(self, template_dict, param_values): - import copy - from unittest.mock import patch - from botocore.utils import set_value_from_jmespath - from samcli.lib.package.artifact_exporter import Template - from samcli.lib.package.uploaders import Uploaders - from samcli.lib.cfn_language_extensions.sam_integration import expand_language_extensions - from samcli.lib.package.language_extensions_packaging import ( - merge_language_extensions_s3_uris, - generate_and_apply_artifact_mappings, - ) - import samcli.lib.package.packageable_resources as pr - result = expand_language_extensions(template_dict, param_values, enabled=True) def fake_zip_export(self, resource_id, resource_dict, parent_dir): @@ -4786,8 +4757,6 @@ def fake_zip_export(self, resource_id, resource_dict, parent_dir): return output def test_ref_collection_statemachine_definitionuri_rewritten(self): - from samcli.lib.intrinsic_resolver.intrinsics_symbol_table import IntrinsicsSymbolTable - template = { "Transform": ["AWS::LanguageExtensions", "AWS::Serverless-2016-10-31"], "Parameters": {"Names": {"Type": "CommaDelimitedList", "Default": "Alpha,Beta"}}, diff --git a/tests/unit/commands/package/test_package_context_language_extensions.py b/tests/unit/commands/package/test_package_context_language_extensions.py index a9506bcf9a4..88a34ad8700 100644 --- a/tests/unit/commands/package/test_package_context_language_extensions.py +++ b/tests/unit/commands/package/test_package_context_language_extensions.py @@ -10,6 +10,7 @@ from samcli.lib.cfn_language_extensions.exceptions import InvalidTemplateException from samcli.lib.package.language_extensions_packaging import ( + merge_language_extensions_s3_uris, _compute_mapping_name, _copy_artifact_uris_for_type, _nesting_path, @@ -1704,8 +1705,6 @@ class TestMergeThreadsParametersAndDeferred(TestCase): """merge_language_extensions_s3_uris forwards parameter_values + accumulator.""" def test_merge_resolves_ref_and_collects_deferred(self): - from samcli.lib.package.language_extensions_packaging import merge_language_extensions_s3_uris - original = { "Parameters": {"FuncType": {"Default": "func1,func2"}}, "Resources": {