feat(globals): add per-property merge strategy for list properties - #3945
Conversation
Add a strategy pattern to the Globals merge engine that allows declaring per-property merge behavior via a dot-notation schema. Day-1 scope: Function.Architectures uses REPLACE (local list wins entirely). The engine remains fully backward-compatible: properties not listed in CUSTOM_STRATEGIES default to CONCATENATE (today's behavior). Path-aware recursion tracks the current property path and consults the schema only at LIST+LIST merge nodes. New module: samtranslator/plugins/globals/merge_strategy.py - MergeOp enum: CONCATENATE, REPLACE, MERGE_BY_KEY - MergeRule frozen dataclass with validation - merge_by_key() factory function Resolves: #3939
…cate keys Guard against duplicate key values in global_list (produces duplicate replacements) and local_list (produces duplicate appends) by checking seen_keys before appending in both passes. Adds regression tests for both cases.
|
This PR takes inspiration from #3940 (by @allenheltondev) and expands the approach into a general-purpose framework with a strategy pattern that supports:
The framework is extensible via cc @allenheltondev — thank you for the original fix and for raising #3939. |
…irements.Architectures Extends CUSTOM_STRATEGIES with a nested dot-path rule proving the framework works at 2+ levels of dict recursion. Adds test cases to both the dedicated merge strategy fixture and the existing capacity_provider_global_with_functions fixture.
…sses local_by_key dict comprehension kept last entry per key, but Pass 2 kept first non-seen entry. When global had the same key as duplicate locals, Pass 1 used last-wins (via local_by_key) while Pass 2 used first-wins — inconsistent behavior depending on whether global contained the key. Fix: build local_by_key with first-entry-wins (skip already-present keys) so both passes agree. Adds regression test for Case B.
…ties
Adds a new merge strategy that replaces at the key level (only local's
keys survive) but deep-merges values when both global and local share
the same key. No parameters needed — existing recursion handles all
value types (dicts deep-merge, scalars local-win, lists concatenate).
Registered for CapacityProvider.ManagedResourceTags: when local sets
Tags, global Propagate is dropped; when both have Tags, values merge.
Empty local {} inherits global (falsy guard, matches SAM precedent).
…urceTags - New MergeOp: local key-set wins, shared keys deep-merge values - Intercept DICT+DICT nodes in _do_merge when strategy registered - Add CapacityProvider.ManagedResourceTags to CUSTOM_STRATEGIES - Fix ruff PLC0206 (.items()) and mypy no-untyped-call - Update translator fixtures: CpOverrideStrategyDropsGlobalKey added, CpExplicitTagsConflictWithGlobal removed (no longer an error)
…bals-merge-strategy
|
Consider adding brief docstrings or inline comments to each For example: class MergeOp(Enum):
CONCATENATE = "concatenate" # global_list + local_list (default)
REPLACE = "replace" # local wins entirely, global discarded
MERGE_BY_KEY = "merge_by_key" # list-of-dicts: deduplicate by key field, local overrides same-key global items
REPLACE_KEYS_MERGE_VALUES = "replace_keys_merge_values" # dict: only local's key-set survives, shared keys' values are deep-merged |
|
Question about Is there a concrete SAM Globals property today that would use |
…E, add DEEP_MERGE enum, fix docs Addresses reviewer comments from licjun: - Remove MERGE_BY_KEY strategy (no concrete SAM use case) - Add inline docstrings per MergeOp enum variant - Add dot-path depth test scenarios Additional improvements: - Rename REPLACE_KEYS_MERGE_VALUES -> PRUNE_AND_MERGE (clearer intent) - Add DEEP_MERGE to MergeOp enum as documented default - Organize enum into defaults vs custom strategies sections - Update docs/globals.rst: sync supported resources list with code (add CapacityProvider, StateMachine, WebSocketApi, ~20 missing props) - Add 'Resource Properties with Custom Merge Strategy' docs section - Fix SELECTIVE_MERGE example (Propagate+Tags are mutually exclusive) Closes #3939 71 tests pass.
bnusunny
left a comment
There was a problem hiding this comment.
Thanks for this — the path-threading approach is clean and minimal, and the Function.Architectures → REPLACE fix for #3939 looks correct to me. I verified inheritance (resource omits Architectures → global applies), override, and both intrinsic-function directions all behave as expected. Requesting changes on one issue in the ManagedResourceTags half.
PRUNE_AND_MERGE silently drops tag propagation in the reverse direction
PRUNE_AND_MERGE is symmetric, but the PR only reasons about the direction where Propagate sits in Globals. The mirror case — Globals provides Tags, the resource provides Propagate — is valid today (Propagate: false does not trip the ManagedResourceTags.Propagate=True mutual-exclusion rule), and this change silently alters its meaning.
Running both merge paths and feeding the result through _get_propagate_tags (samtranslator/model/capacity_provider/generators.py:229-235):
Globals:
CapacityProvider:
ManagedResourceTags:
Tags: {env: prod}
Resources:
MyCP:
Type: AWS::Serverless::CapacityProvider
Properties:
ManagedResourceTags:
Propagate: falsebefore → {Tags: {env: prod}, Propagate: False} → {"Mode": "Explicit", "ExplicitTags": [env=prod]}
after → {Propagate: False} → {"Mode": "None"}
The global Tags key is pruned because it isn't declared locally, so the tags silently stop being applied — no error, no warning. That's a quiet correctness change rather than the mutual-exclusivity fix the strategy is aiming for.
No fixture or unit test covers this direction; every existing ManagedResourceTags fixture puts Propagate in Globals and Tags locally, so the symmetry is untested.
Ask: add a test for globals-provides-Tags / local-provides-Propagate, and either accept-and-document the pruning semantics for that case in docs/globals.rst, or narrow the strategy so it only prunes the keys that actually participate in the mutual-exclusion rule.
Worth noting this is very cheap to fix right now: ManagedResourceTags isn't in a release yet (it landed in 49a7a8a, after the 1.111.0 tag), so there's no customer-visible blast radius today — but the semantics should be settled before the property ships.
The rest of my notes are non-blocking, happy to file separately if you'd prefer:
- Latent leading-dot path bug.
_merge_dictbuilds child paths with.lstrip("."), but_prune_and_mergeuses a baref"{path}.{key}". If a rule is ever registered at a section root (path == ""), children become".Tags"instead of"Tags"and every nested rule silently misses. Unreachable with today's registry, but the "How to extend" section invites exactly this — worth using.lstrip(".")in both places. - Typo'd registry keys fail silently.
{"Architecture": REPLACE}(missings) just concatenates with no error. Since correctness depends on strings matchingsupported_properties, consider validatingCUSTOM_STRATEGIESkeys againstsupported_propertiesin a unit test. MergeRuleis a single-field wrapper around oneMergeOp;CUSTOM_STRATEGIES[path]could hold the enum directly unless more fields are planned.- Naming:
tests/translator/input/capacity_provider_managed_resource_tags.yaml:50still has a stale# REPLACE_KEYS_MERGE_VALUES:comment;docs/globals.rst:328says "Selective Merge" while the code saysPRUNE_AND_MERGE; and the PR title says "for list properties" thoughPRUNE_AND_MERGEis dict-only. - No intrinsic-function fixture.
globals_merge_strategy_architectures.yamluses only literal lists. I checkedFn::If-valuedArchitecturesbehaves sanely in both directions, so this is a coverage gap rather than a defect — butFn::If-selected architectures are a realistic pattern worth one case. - Scope: the
ManagedResourceTagsstrategy is a behavior change to a different resource than #3939 covers, and it removes an existing error fixture. The deletion is correct (that scenario genuinely stops erroring) and you did add a positive replacement across all three partitions, so this is disclosed and defensible — but splitting it out would let the #3939 fix land on pureREPLACEsemantics.
…, fix dot-path bug, add validation
- Remove MergeRule dataclass wrapper; CUSTOM_STRATEGIES maps directly to MergeOp enum
- Fix leading-dot path bug in _prune_and_merge (use .lstrip('.') on child_path)
- Fix stale REPLACE_KEYS_MERGE_VALUES comment in test fixture
- Align docs naming: 'Selective Merge' → 'Prune and Merge (PRUNE_AND_MERGE)'
- Add Direction 2 example in docs (global Tags, local Propagate)
- Add reverse-direction test cases for PRUNE_AND_MERGE
Closes #3939
This is intentional — added two test cases covering it. When global has Both directions are now tested and documented in
Item 5 (intrinsic-function fixture) — happy to add if you feel strongly, but |
Summary
Fixes #3939
Adds a per-property merge strategy to the Globals merge engine. Today, all list-type properties concatenate when both Globals and resource-level values exist. This is incorrect for properties like
Architectures(a function runs on one architecture — local should replace, not append).Changes
New module:
samtranslator/plugins/globals/merge_strategy.pyMergeOpenum with 4 strategies:DEEP_MERGE— recursive key union, local wins scalars (default for dicts, implicit)CONCATENATE— global + local list (default for lists, implicit)REPLACE— local wins entirely, global discardedPRUNE_AND_MERGE— drop global keys not declared in local, then deep-merge shared keysModified:
samtranslator/plugins/globals/globals.py_do_mergetracks current property path through dict recursionCUSTOM_STRATEGIESdict declares per-property behavior:_prune_and_merge()method for properties with mutual exclusivity constraints (e.g., ManagedResourceTags wherePropagateandTagscannot coexist)Updated:
docs/globals.rstDesign
The schema uses dot-notation paths (e.g.,
CapacityProvider.InstanceRequirements.Architectures) to target properties at any nesting depth. Properties not listed inCUSTOM_STRATEGIESretain today's behavior — this makes the change fully backward-compatible.Why PRUNE_AND_MERGE?
ManagedResourceTagshas aMUTUALLY_EXCLUSIVEvalidation rule betweenPropagateandTags. Without pruning, DEEP_MERGE would inheritPropagate: truefrom Globals into a resource that only declaresTags, causing a validation error. PRUNE_AND_MERGE drops undeclared keys before merging, preventing the conflict.How to extend
Add entries to
CUSTOM_STRATEGIESinglobals.py:Tests
MergeOpenum andMergeRuledataclass (all 4 strategies)globals_merge_strategy_architectures.yaml) proving REPLACE behavior across all 3 partitions