Skip to content

Commit 57a0070

Browse files
authored
Update mappings to support transforms at the root level (#3439)
1 parent 625d38a commit 57a0070

File tree

9 files changed

+198
-104
lines changed

9 files changed

+198
-104
lines changed

src/cfnlint/context/_mappings.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""
2+
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
SPDX-License-Identifier: MIT-0
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import logging
9+
from dataclasses import dataclass, field
10+
from typing import Any, Iterator
11+
12+
LOGGER = logging.getLogger(__name__)
13+
14+
15+
@dataclass(frozen=True)
16+
class Mappings:
17+
"""
18+
This class holds a mapping
19+
"""
20+
21+
maps: dict[str, Map] = field(init=True, default_factory=dict)
22+
is_transform: bool = field(init=True, default=False)
23+
24+
@classmethod
25+
def create_from_dict(cls, instance: Any) -> Mappings:
26+
27+
if not isinstance(instance, dict):
28+
return cls({})
29+
try:
30+
result = {}
31+
is_transform = False
32+
for k, v in instance.items():
33+
if k == "Fn::Transform":
34+
is_transform = True
35+
else:
36+
result[k] = Map.create_from_dict(v)
37+
return cls(result, is_transform)
38+
except (ValueError, AttributeError) as e:
39+
LOGGER.debug(e, exc_info=True)
40+
return cls({})
41+
42+
43+
@dataclass(frozen=True)
44+
class _MappingSecondaryKey:
45+
"""
46+
This class holds a mapping value
47+
"""
48+
49+
keys: dict[str, list[Any] | str | int | float] = field(
50+
init=True, default_factory=dict
51+
)
52+
is_transform: bool = field(init=True, default=False)
53+
54+
def value(self, secondary_key: str):
55+
if secondary_key not in self.keys:
56+
raise KeyError(secondary_key)
57+
return self.keys[secondary_key]
58+
59+
@classmethod
60+
def create_from_dict(cls, instance: Any) -> _MappingSecondaryKey:
61+
if not isinstance(instance, dict):
62+
return cls({})
63+
is_transform = False
64+
keys = {}
65+
for k, v in instance.items():
66+
if k == "Fn::Transform":
67+
is_transform = True
68+
elif isinstance(v, (str, list, int, float)):
69+
keys[k] = v
70+
else:
71+
continue
72+
return cls(keys, is_transform)
73+
74+
75+
@dataclass(frozen=True)
76+
class Map:
77+
"""
78+
This class holds a mapping
79+
"""
80+
81+
keys: dict[str, _MappingSecondaryKey] = field(init=True, default_factory=dict)
82+
is_transform: bool = field(init=True, default=False)
83+
84+
def find_in_map(self, top_key: str, secondary_key: str) -> Iterator[Any]:
85+
if top_key not in self.keys:
86+
raise KeyError(top_key)
87+
yield self.keys[top_key].value(secondary_key)
88+
89+
@classmethod
90+
def create_from_dict(cls, instance: Any) -> Map:
91+
if not isinstance(instance, dict):
92+
return cls({})
93+
is_transform = False
94+
keys = {}
95+
for k, v in instance.items():
96+
if k == "Fn::Transform":
97+
is_transform = True
98+
else:
99+
keys[k] = _MappingSecondaryKey.create_from_dict(v)
100+
return cls(keys, is_transform)

src/cfnlint/context/context.py

Lines changed: 3 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from typing import Any, Deque, Iterator, Sequence, Set, Tuple
1212

1313
from cfnlint.context._conditions import Conditions
14+
from cfnlint.context._mappings import Mappings
1415
from cfnlint.helpers import (
1516
BOOLEAN_STRINGS_TRUE,
1617
FUNCTIONS,
@@ -138,7 +139,7 @@ class Context:
138139
parameters: dict[str, "Parameter"] = field(init=True, default_factory=dict)
139140
resources: dict[str, "Resource"] = field(init=True, default_factory=dict)
140141
conditions: Conditions = field(init=True, default_factory=Conditions)
141-
mappings: dict[str, "Map"] = field(init=True, default_factory=dict)
142+
mappings: Mappings = field(init=True, default_factory=Mappings)
142143

143144
strict_types: bool = field(init=True, default=True)
144145

@@ -374,61 +375,6 @@ def ref(self, context: Context) -> Iterator[Any]:
374375
yield
375376

376377

377-
@dataclass
378-
class _MappingSecondaryKey:
379-
"""
380-
This class holds a mapping value
381-
"""
382-
383-
keys: dict[str, list[Any] | str | int | float] = field(
384-
init=False, default_factory=dict
385-
)
386-
instance: InitVar[Any]
387-
is_transform: bool = field(init=False, default=False)
388-
389-
def __post_init__(self, instance) -> None:
390-
if not isinstance(instance, dict):
391-
raise ValueError("Secondary keys must be a object")
392-
for k, v in instance.items():
393-
if k == "Fn::Transform":
394-
self.is_transform = True
395-
continue
396-
if isinstance(v, (str, list, int, float)):
397-
self.keys[k] = v
398-
else:
399-
raise ValueError("Third keys must not be an object")
400-
401-
def value(self, secondary_key: str):
402-
if secondary_key not in self.keys:
403-
raise KeyError(secondary_key)
404-
return self.keys[secondary_key]
405-
406-
407-
@dataclass
408-
class Map:
409-
"""
410-
This class holds a mapping
411-
"""
412-
413-
keys: dict[str, _MappingSecondaryKey] = field(init=False, default_factory=dict)
414-
resource: InitVar[Any]
415-
is_transform: bool = field(init=False, default=False)
416-
417-
def __post_init__(self, mapping) -> None:
418-
if not isinstance(mapping, dict):
419-
raise ValueError("Mapping must be a object")
420-
for k, v in mapping.items():
421-
if k == "Fn::Transform":
422-
self.is_transform = True
423-
else:
424-
self.keys[k] = _MappingSecondaryKey(v)
425-
426-
def find_in_map(self, top_key: str, secondary_key: str) -> Iterator[Any]:
427-
if top_key not in self.keys:
428-
raise KeyError(top_key)
429-
yield self.keys[top_key].value(secondary_key)
430-
431-
432378
def _init_parameters(parameters: Any) -> dict[str, Parameter]:
433379
obj = {}
434380
if not isinstance(parameters, dict):
@@ -460,19 +406,6 @@ def _init_transforms(transforms: Any) -> Transforms:
460406
return Transforms([])
461407

462408

463-
def _init_mappings(mappings: Any) -> dict[str, Map]:
464-
obj = {}
465-
if not isinstance(mappings, dict):
466-
raise ValueError("Mappings must be a object")
467-
for k, v in mappings.items():
468-
try:
469-
obj[k] = Map(v)
470-
except ValueError:
471-
pass
472-
473-
return obj
474-
475-
476409
def create_context_for_template(cfn):
477410
parameters = {}
478411
try:
@@ -494,11 +427,7 @@ def create_context_for_template(cfn):
494427
except (ValueError, AttributeError):
495428
pass
496429

497-
mappings = {}
498-
try:
499-
mappings = _init_mappings(cfn.template.get("Mappings", {}))
500-
except (ValueError, AttributeError):
501-
pass
430+
mappings = Mappings.create_from_dict(cfn.template.get("Mappings", {}))
502431

503432
return Context(
504433
parameters=parameters,

src/cfnlint/jsonschema/_resolvers_cfn.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,13 @@ def find_in_map(validator: Validator, instance: Any) -> ResolutionResult:
5454
), None
5555
default_value_found = True
5656

57-
if not default_value_found and not validator.context.mappings:
57+
if not default_value_found and not validator.context.mappings.maps:
58+
if validator.context.mappings.is_transform:
59+
return
5860
yield None, validator, ValidationError(
5961
(
6062
f"{instance[0]!r} is not one of "
61-
f"{list(validator.context.mappings.keys())!r}"
63+
f"{list(validator.context.mappings.maps.keys())!r}"
6264
),
6365
path=deque([0]),
6466
)
@@ -71,13 +73,13 @@ def find_in_map(validator: Validator, instance: Any) -> ResolutionResult:
7173
)
7274
and validator.is_type(instance[2], "string")
7375
):
74-
map = validator.context.mappings.get(instance[0])
76+
map = validator.context.mappings.maps.get(instance[0])
7577
if map is None:
7678
if not default_value_found:
7779
yield None, validator, ValidationError(
7880
(
7981
f"{instance[0]!r} is not one of "
80-
f"{list(validator.context.mappings.keys())!r}"
82+
f"{list(validator.context.mappings.maps.keys())!r}"
8183
),
8284
path=deque([0]),
8385
)

src/cfnlint/rules/mappings/Used.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"""
55

66
from cfnlint._typing import RuleMatches
7+
from cfnlint.helpers import is_function
78
from cfnlint.rules import CloudFormationLintRule, RuleMatch
89
from cfnlint.template import Template
910

@@ -22,6 +23,12 @@ def match(self, cfn: Template) -> RuleMatches:
2223
findinmap_mappings = []
2324

2425
mappings = cfn.template.get("Mappings", {})
26+
k, _ = is_function(mappings)
27+
if k == "Fn::Transform":
28+
self.logger.debug(
29+
(f"Mapping Name has a transform. Disabling check {self.id!r}"),
30+
)
31+
return matches
2532

2633
if mappings:
2734
# Get all "FindInMaps" that reference a Mapping

test/unit/module/context/test_create_context.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
},
6464
},
6565
},
66-
_Counts(resources=1, parameters=1, conditions=0, mappings=1),
66+
_Counts(resources=1, parameters=1, conditions=0, mappings=2),
6767
),
6868
(
6969
"Invalid mapping second key",
@@ -75,7 +75,7 @@
7575
"Map": {"us-east-1": {"foo": "bar"}},
7676
},
7777
},
78-
_Counts(resources=0, parameters=0, conditions=0, mappings=1),
78+
_Counts(resources=0, parameters=0, conditions=0, mappings=2),
7979
),
8080
(
8181
"Invalid mapping third key",
@@ -89,7 +89,7 @@
8989
"Map": {"us-east-1": {"foo": "bar"}},
9090
},
9191
},
92-
_Counts(resources=0, parameters=0, conditions=0, mappings=1),
92+
_Counts(resources=0, parameters=0, conditions=0, mappings=2),
9393
),
9494
],
9595
)
@@ -101,10 +101,15 @@ def test_create_context(name, instance, counts):
101101
if i == "conditions":
102102
assert len(context.conditions.conditions) == getattr(counts, i), (
103103
f"Test {name} has {i} {len(getattr(context, i))} "
104-
"and expected {getattr(counts, i)}"
104+
f"and expected {getattr(counts, i)}"
105+
)
106+
elif i == "mappings":
107+
assert len(context.mappings.maps) == getattr(counts, i), (
108+
f"Test {name} has {i} {len(context.mappings.maps)} "
109+
f"and expected {getattr(counts, i)}"
105110
)
106111
else:
107112
assert len(getattr(context, i)) == getattr(counts, i), (
108113
f"Test {name} has {i} {len(getattr(context, i))} "
109-
"and expected {getattr(counts, i)}"
114+
f"and expected {getattr(counts, i)}"
110115
)

test/unit/module/context/test_mappings.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import pytest
77

8-
from cfnlint.context.context import Map
8+
from cfnlint.context._mappings import Map, Mappings, _MappingSecondaryKey
99

1010

1111
@pytest.mark.parametrize(
@@ -17,7 +17,7 @@
1717
],
1818
)
1919
def test_mapping_value(name, get_key_1, get_key_2, expected):
20-
mapping = Map({"A": {"B": "C"}})
20+
mapping = Map.create_from_dict({"A": {"B": "C"}})
2121

2222
if isinstance(expected, Exception):
2323
with pytest.raises(type(expected)):
@@ -27,10 +27,64 @@ def test_mapping_value(name, get_key_1, get_key_2, expected):
2727

2828

2929
def test_transforms():
30-
mapping = Map({"A": {"Fn::Transform": "C"}})
30+
mapping = Map.create_from_dict({"A": {"Fn::Transform": "C"}})
3131

3232
assert mapping.keys.get("A").is_transform is True
3333

34-
mapping = Map({"Fn::Transform": {"B": "C"}})
34+
mapping = Map.create_from_dict({"Fn::Transform": {"B": "C"}})
3535

3636
assert mapping.is_transform is True
37+
38+
mapping = Mappings.create_from_dict({"Fn::Transform": {"B": {"C": "D"}}})
39+
40+
assert mapping.is_transform is True
41+
42+
43+
@pytest.mark.parametrize(
44+
"name,mappings,expected",
45+
[
46+
(
47+
"Valid mappings",
48+
{
49+
"A": {"B": {"C": "D"}},
50+
"1": {"2": {"3": "4"}},
51+
"Z": [],
52+
"9": {"8": []},
53+
"M": {"N": {"O": {"P": "Q"}}},
54+
},
55+
Mappings(
56+
{
57+
"A": Map({"B": _MappingSecondaryKey({"C": "D"})}),
58+
"1": Map({"2": _MappingSecondaryKey({"3": "4"})}),
59+
"Z": Map({}),
60+
"9": Map({"8": _MappingSecondaryKey({})}),
61+
"M": Map({"N": _MappingSecondaryKey({})}),
62+
}
63+
),
64+
),
65+
(
66+
"Valid mappings with transforms",
67+
{
68+
"A": {"Fn::Transform": "MyTransform"},
69+
"1": {"2": {"Fn::Transform": "MyTransform"}},
70+
},
71+
Mappings(
72+
{
73+
"A": Map({}, True),
74+
"1": Map({"2": _MappingSecondaryKey({}, True)}),
75+
}
76+
),
77+
),
78+
(
79+
"Valid mappings with transforms for mappings",
80+
{
81+
"Fn::Transform": "MyTransform",
82+
},
83+
Mappings({}, True),
84+
),
85+
],
86+
)
87+
def test_mapping_creation(name, mappings, expected):
88+
results = Mappings.create_from_dict(mappings)
89+
90+
assert results == expected, f"{name!r} failed got {results!r}"

0 commit comments

Comments
 (0)