Skip to content

Commit 8de3a29

Browse files
committed
refactor(cfn-lang-ext): consolidate copy helpers and relocate prop-name helpers
PR #9009 (issue #9005) left two near-duplicate copy helpers in the language- extensions code: _copy_artifact_uris_for_type in samcli/lib/package/ language_extensions_packaging.py and _copy_artifact_paths in samcli/commands/ build/build_context.py. They were 90% identical and differed only in the optional dynamic_prop_keys skip-set. Bot review #1 on that PR caught the exact bug class this duplication enables — a flat-key .get() slipping into one helper while the other had been jmespath-aware. Promote the four prop-name helpers (_get_prop_value, _set_prop_value, _leaf_prop_name, _resolve_property_paths) — until now underscore-private helpers in language_extensions_packaging.py imported across packages by samcli/commands/build and samcli/lib/cfn_language_extensions/sam_integration — into a properly-named module samcli/lib/cfn_language_extensions/property_paths with no leading underscore. Add a fifth helper, copy_artifact_properties, that consolidates the two duplicated copy implementations into one. Add a contract test that asserts every entry in PACKAGEABLE_RESOURCE_ ARTIFACT_PROPERTIES round-trips through set_prop_value / get_prop_value and that every leaf is alphanumeric (a precondition for use in CloudFormation Mapping names and Fn::FindInMap third-arg keys). Future additions to the canonical packageable-resource list are now caught at the contract layer rather than at user-template execution time. Mechanical changes: - new samcli/lib/cfn_language_extensions/property_paths.py - new tests/unit/lib/cfn_language_extensions/test_property_paths.py - delete _copy_artifact_uris_for_type from samcli/lib/package/language_extensions_packaging.py; collapse its two in-file callers (_copy_artifact_uris and _update_foreach_with_s3_uris) to call copy_artifact_properties directly - delete BuildContext._copy_artifact_paths from samcli/commands/build/ build_context.py; inline the single call site - update inline imports in build_context.py and sam_integration.py to point at the new module - rename test_copy_artifact_uris_for_type_* to test_copy_artifact_properties_* in tests/unit/commands/package/test_package_context.py and update function-import sites in three test files - delete duplicate get_prop_value / set_prop_value tests from test_package_context.py (now in test_property_paths.py) Verification: - baseline 9191 passed, 25 skipped → after 9203 passed, 25 skipped (+12) - ruff check, mypy, black --check all clean - end-to-end #9005 repro (sam package against language-extensions/tc-026-nested-application) still rewrites Properties.Location to an https://s3... URL
1 parent 9ffa5ed commit 8de3a29

8 files changed

Lines changed: 429 additions & 284 deletions

File tree

samcli/commands/build/build_context.py

Lines changed: 31 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,13 @@ def _update_original_template_paths(self, original_template: Dict, modified_temp
504504
elif isinstance(resource_value, dict) and resource_key in modified_resources:
505505
# Regular resource - copy updated paths from modified template
506506
modified_resource = modified_resources.get(resource_key, {})
507-
self._copy_artifact_paths(resource_value, modified_resource)
507+
from samcli.lib.cfn_language_extensions.property_paths import copy_artifact_properties
508+
509+
copy_artifact_properties(
510+
resource_value.get("Properties", {}),
511+
modified_resource.get("Properties", {}),
512+
resource_value.get("Type", ""),
513+
)
508514

509515
# Merge generated Mappings into the template
510516
if all_generated_mappings:
@@ -552,13 +558,13 @@ def _update_foreach_artifact_paths(
552558
Generated Mappings section for dynamic artifact properties (empty dict if none)
553559
"""
554560
from samcli.lib.cfn_language_extensions.models import PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES
555-
from samcli.lib.cfn_language_extensions.sam_integration import resolve_collection
556-
from samcli.lib.package.language_extensions_packaging import (
557-
_get_prop_value,
558-
_leaf_prop_name,
559-
_resolve_property_paths,
560-
_set_prop_value,
561+
from samcli.lib.cfn_language_extensions.property_paths import (
562+
get_prop_value,
563+
leaf_prop_name,
564+
resolve_property_paths,
565+
set_prop_value,
561566
)
567+
from samcli.lib.cfn_language_extensions.sam_integration import resolve_collection
562568

563569
generated_mappings: Dict[str, Dict[str, Dict[str, str]]] = {}
564570

@@ -606,15 +612,15 @@ def _update_foreach_artifact_paths(
606612
continue
607613

608614
candidate_paths = PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES.get(resource_type, [])
609-
for prop_name in _resolve_property_paths(candidate_paths, properties):
610-
prop_value = _get_prop_value(properties, prop_name)
615+
for prop_name in resolve_property_paths(candidate_paths, properties):
616+
prop_value = get_prop_value(properties, prop_name)
611617
if prop_value is None:
612618
continue
613619

614620
# CloudFormation Mapping names and FindInMap third-args must be alphanumeric,
615621
# so dotted property names (e.g. "Command.ScriptLocation") use the leaf segment
616622
# for those identifiers while the dotted form is preserved for property addressing.
617-
leaf_name = _leaf_prop_name(prop_name)
623+
leaf_name = leaf_prop_name(prop_name)
618624

619625
if contains_loop_variable(prop_value, loop_variable) and collection_values:
620626
# Determine which outer loop variables the property references
@@ -653,7 +659,7 @@ def _update_foreach_artifact_paths(
653659
else:
654660
lookup_key = {"Ref": loop_variable}
655661

656-
_set_prop_value(properties, prop_name, {"Fn::FindInMap": [mapping_name, lookup_key, leaf_name]})
662+
set_prop_value(properties, prop_name, {"Fn::FindInMap": [mapping_name, lookup_key, leaf_name]})
657663
else:
658664
expanded_key = self._build_expanded_key(
659665
resource_template_key,
@@ -664,7 +670,7 @@ def _update_foreach_artifact_paths(
664670
if expanded_key:
665671
artifact_value = self._get_artifact_value(modified_resources, expanded_key, prop_name)
666672
if artifact_value is not None:
667-
_set_prop_value(properties, prop_name, artifact_value)
673+
set_prop_value(properties, prop_name, artifact_value)
668674

669675
# Propagate auto dependency layer references from expanded functions
670676
# to the ForEach body. Each expanded function may have Layers entries
@@ -723,10 +729,10 @@ def _count_dynamic_properties(
723729
share the same property name (e.g., two resources both with DefinitionUri).
724730
"""
725731
from samcli.lib.cfn_language_extensions.models import PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES
726-
from samcli.lib.package.language_extensions_packaging import (
727-
_get_prop_value,
728-
_leaf_prop_name,
729-
_resolve_property_paths,
732+
from samcli.lib.cfn_language_extensions.property_paths import (
733+
get_prop_value,
734+
leaf_prop_name,
735+
resolve_property_paths,
730736
)
731737

732738
count: Counter = Counter()
@@ -740,12 +746,12 @@ def _count_dynamic_properties(
740746
if not isinstance(rprops, dict):
741747
continue
742748
candidate_paths = PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES.get(rtype, [])
743-
for pname in _resolve_property_paths(candidate_paths, rprops):
744-
pval = _get_prop_value(rprops, pname)
749+
for pname in resolve_property_paths(candidate_paths, rprops):
750+
pval = get_prop_value(rprops, pname)
745751
if pval is not None and contains_loop_variable(pval, loop_variable) and collection_values:
746752
# Count by leaf so collisions across resource types with
747753
# different dotted paths but the same leaf are detected.
748-
count[_leaf_prop_name(pname)] += 1
754+
count[leaf_prop_name(pname)] += 1
749755
return count
750756

751757
@staticmethod
@@ -788,10 +794,10 @@ def _collect_dynamic_mapping_entries(
788794
in the Mapping uses the leaf segment so it stays alphanumeric and
789795
matches the third argument of the generated Fn::FindInMap.
790796
"""
791-
from samcli.lib.package.language_extensions_packaging import _leaf_prop_name
797+
from samcli.lib.cfn_language_extensions.property_paths import leaf_prop_name
792798

793799
mapping_entries: Dict[str, Dict[str, str]] = {}
794-
leaf_name = _leaf_prop_name(prop_name)
800+
leaf_name = leaf_prop_name(prop_name)
795801

796802
for coll_value in collection_values:
797803
if outer_context:
@@ -830,9 +836,9 @@ def _collect_nested_mapping_entry(
830836
"""
831837
import itertools
832838

833-
from samcli.lib.package.language_extensions_packaging import _leaf_prop_name
839+
from samcli.lib.cfn_language_extensions.property_paths import leaf_prop_name
834840

835-
leaf_name = _leaf_prop_name(prop_name)
841+
leaf_name = leaf_prop_name(prop_name)
836842
outer_collections = [oc[1] for oc in outer_context]
837843
outer_vars = [oc[0] for oc in outer_context]
838844

@@ -936,45 +942,15 @@ def _get_artifact_value(modified_resources: Dict, expanded_key: str, prop_name:
936942
937943
``prop_name`` may be a jmespath dotted path (e.g. "Command.ScriptLocation").
938944
"""
939-
from samcli.lib.package.language_extensions_packaging import _get_prop_value
945+
from samcli.lib.cfn_language_extensions.property_paths import get_prop_value
940946

941947
modified_resource = modified_resources.get(expanded_key, {})
942948
if not isinstance(modified_resource, dict):
943949
return None
944950
modified_props = modified_resource.get("Properties", {})
945951
if not isinstance(modified_props, dict):
946952
return None
947-
return _get_prop_value(modified_props, prop_name)
948-
949-
def _copy_artifact_paths(self, original_resource: Dict, modified_resource: Dict) -> None:
950-
"""
951-
Copy artifact paths from modified resource to original resource.
952-
953-
Uses PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES to determine which
954-
properties to copy, avoiding a hardcoded elif chain.
955-
956-
Parameters
957-
----------
958-
original_resource : Dict
959-
The original resource (will be modified in place)
960-
modified_resource : Dict
961-
The modified resource with updated artifact paths
962-
"""
963-
from samcli.lib.cfn_language_extensions.models import PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES
964-
from samcli.lib.package.language_extensions_packaging import _get_prop_value, _set_prop_value
965-
966-
original_props = original_resource.get("Properties", {})
967-
modified_props = modified_resource.get("Properties", {})
968-
resource_type = original_resource.get("Type", "")
969-
970-
prop_names = PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES.get(resource_type)
971-
if not prop_names:
972-
return
973-
974-
for prop_name in prop_names:
975-
value = _get_prop_value(modified_props, prop_name)
976-
if value is not None:
977-
_set_prop_value(original_props, prop_name, value)
953+
return get_prop_value(modified_props, prop_name)
978954

979955
def _gen_success_msg(self, artifacts_dir: str, output_template_path: str, is_default_build_dir: bool) -> str:
980956
"""
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""
2+
Helpers for addressing CloudFormation resource artifact properties by jmespath.
3+
4+
CloudFormation resource artifact properties are most-naturally addressed as
5+
jmespath paths because some live at flat keys (``CodeUri``) and others at
6+
nested locations (``Command.ScriptLocation`` on ``AWS::Glue::Job``,
7+
``Code.ImageUri`` on a Lambda image function). The canonical packageable-
8+
property registry (`PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES`) records each
9+
in its addressing form, so consumers across SAM CLI must use jmespath get/set
10+
to read and write them without clobbering siblings.
11+
12+
This module provides those small, jmespath-aware helpers plus
13+
``copy_artifact_properties``, the single helper that copies packaged
14+
artifact values from an exported resource onto the corresponding original
15+
resource for a given resource type. It is shared by the build-time and
16+
package-time pipelines so the two cannot drift in their treatment of
17+
property addressing.
18+
"""
19+
20+
from typing import Any, Dict, List, Optional, Set, Tuple
21+
22+
import jmespath
23+
from botocore.utils import set_value_from_jmespath
24+
25+
26+
def get_prop_value(props: Dict[str, Any], path: str) -> Optional[Any]:
27+
"""Read a property by jmespath path. Supports flat keys ("CodeUri") and
28+
dotted paths ("Command.ScriptLocation"). Returns None if missing.
29+
"""
30+
return jmespath.search(path, props)
31+
32+
33+
def set_prop_value(props: Dict[str, Any], path: str, value: Any) -> None:
34+
"""Write a property by jmespath path. Creates intermediate dicts as
35+
needed. Supports flat keys and dotted paths.
36+
"""
37+
set_value_from_jmespath(props, path, value)
38+
39+
40+
def leaf_prop_name(path: str) -> str:
41+
"""Return the last segment of a jmespath property path.
42+
43+
CloudFormation Mapping names must be alphanumeric, and the third argument
44+
of ``Fn::FindInMap`` and the keys of Mapping value-dicts must match each
45+
other as plain strings. Dotted property paths (e.g. ``Command.ScriptLocation``)
46+
address the property *on* a resource; the *identifier* used in Mapping
47+
names and FindInMap lookups must use only the leaf segment so the
48+
generated CloudFormation is well-formed.
49+
"""
50+
return path.rsplit(".", 1)[-1]
51+
52+
53+
def resolve_property_paths(paths: List[str], properties: Dict[str, Any]) -> List[str]:
54+
"""Filter ``paths`` so a parent path is dropped when a more specific
55+
child path is present in ``properties``.
56+
57+
Some resource types declare multiple alternative artifact paths in
58+
``PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES``. For example,
59+
``AWS::Lambda::Function`` lists ``Code`` (used for ZIP packages) and
60+
``Code.ImageUri`` (used for image packages). A given user template uses
61+
only one of these shapes, but ``Code`` is a prefix of ``Code.ImageUri``,
62+
so a naive iteration would address both paths and treat the same nested
63+
value twice. Returning only the most specific resolved path picks the
64+
correct interpretation for the user's actual property shape.
65+
"""
66+
# Sort longest-first so child paths win over their parents.
67+
sorted_paths = sorted(paths, key=lambda p: -p.count("."))
68+
consumed_prefixes: Set[str] = set()
69+
selected: List[str] = []
70+
for path in sorted_paths:
71+
if get_prop_value(properties, path) is None:
72+
continue
73+
# If a more specific path under this prefix has already been selected, skip.
74+
if any(other.startswith(path + ".") for other in consumed_prefixes):
75+
continue
76+
selected.append(path)
77+
consumed_prefixes.add(path)
78+
# Preserve original ordering for callers that care.
79+
order = {p: i for i, p in enumerate(paths)}
80+
selected.sort(key=lambda p: order.get(p, 0))
81+
return selected
82+
83+
84+
def copy_artifact_properties(
85+
original_props: Dict[str, Any],
86+
exported_props: Dict[str, Any],
87+
resource_type: str,
88+
*,
89+
foreach_key: Optional[str] = None,
90+
dynamic_prop_keys: Optional[Set[Tuple[str, str]]] = None,
91+
) -> bool:
92+
"""Copy packaged artifact property values from ``exported_props`` onto
93+
``original_props`` for the given resource type.
94+
95+
Returns True if any property was copied. When called from the
96+
language-extensions merge path, pass ``foreach_key`` and
97+
``dynamic_prop_keys`` so dynamic-loop properties (handled separately by
98+
Mapping/FindInMap rewrites) are skipped. Build-time and non-ForEach
99+
callers omit both kwargs.
100+
101+
Both input dicts are addressed with jmespath, so dotted property paths
102+
like ``Command.ScriptLocation`` or ``Code.ImageUri`` round-trip correctly
103+
without creating literal flat keys.
104+
"""
105+
# Lazy import to avoid forcing the canonical-list module on every consumer
106+
# of this small helper module at import time.
107+
from samcli.lib.cfn_language_extensions.models import PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES
108+
109+
paths = PACKAGEABLE_RESOURCE_ARTIFACT_PROPERTIES.get(resource_type)
110+
if not paths:
111+
return False
112+
113+
copied = False
114+
for path in paths:
115+
exported_value = get_prop_value(exported_props, path)
116+
if exported_value is None:
117+
continue
118+
if dynamic_prop_keys and foreach_key and (foreach_key, path) in dynamic_prop_keys:
119+
continue
120+
set_prop_value(original_props, path, exported_value)
121+
copied = True
122+
123+
return copied
124+
125+
126+
__all__ = [
127+
"copy_artifact_properties",
128+
"get_prop_value",
129+
"leaf_prop_name",
130+
"resolve_property_paths",
131+
"set_prop_value",
132+
]

samcli/lib/cfn_language_extensions/sam_integration.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -412,10 +412,10 @@ def detect_foreach_dynamic_properties(
412412
if not isinstance(properties, dict):
413413
continue
414414

415-
from samcli.lib.package.language_extensions_packaging import _get_prop_value, _resolve_property_paths
415+
from samcli.lib.cfn_language_extensions.property_paths import get_prop_value, resolve_property_paths
416416

417-
for prop_name in _resolve_property_paths(artifact_properties, properties):
418-
prop_value = _get_prop_value(properties, prop_name)
417+
for prop_name in resolve_property_paths(artifact_properties, properties):
418+
prop_value = get_prop_value(properties, prop_name)
419419
if prop_value is not None:
420420
if contains_loop_variable(prop_value, loop_variable):
421421
dynamic_properties.append(

0 commit comments

Comments
 (0)