Skip to content

Commit e03a57b

Browse files
authored
Add foreach compatibility (#2807)
1 parent 854452d commit e03a57b

28 files changed

+1362
-360
lines changed

src/cfnlint/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from cfnlint.rules import TransformError as _TransformError
2121
from cfnlint.runner import Runner as _Runner
2222
from cfnlint.template import Template as _Template
23-
from cfnlint.transform import Transform
23+
from cfnlint.template.transforms._sam import Transform
2424

2525
LOGGER = logging.getLogger(__name__)
2626

src/cfnlint/decode/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@
1010
decode,
1111
decode_str,
1212
)
13+
from cfnlint.decode.utils import convert_dict

src/cfnlint/decode/cfn_yaml.py

+13-14
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,18 @@ def __init__(self, stream, filename):
192192
Resolver.__init__(self)
193193
NodeConstructor.__init__(self, filename)
194194

195+
def construct_getatt(self, node):
196+
"""
197+
Reconstruct !GetAtt into a list
198+
"""
199+
200+
if isinstance(node.value, (str)):
201+
return list_node(node.value.split(".", 1), node.start_mark, node.end_mark)
202+
if isinstance(node.value, list):
203+
return [self.construct_object(child, deep=False) for child in node.value]
204+
205+
raise ValueError(f"Unexpected node type: {type(node.value)}")
206+
195207

196208
def multi_constructor(loader, tag_suffix, node):
197209
"""
@@ -203,7 +215,7 @@ def multi_constructor(loader, tag_suffix, node):
203215

204216
constructor = None
205217
if tag_suffix == "Fn::GetAtt":
206-
constructor = construct_getatt
218+
constructor = loader.construct_getatt
207219
elif isinstance(node, ScalarNode):
208220
constructor = loader.construct_scalar
209221
elif isinstance(node, SequenceNode):
@@ -219,19 +231,6 @@ def multi_constructor(loader, tag_suffix, node):
219231
return dict_node({tag_suffix: constructor(node)}, node.start_mark, node.end_mark)
220232

221233

222-
def construct_getatt(node):
223-
"""
224-
Reconstruct !GetAtt into a list
225-
"""
226-
227-
if isinstance(node.value, (str)):
228-
return list_node(node.value.split(".", 1), node.start_mark, node.end_mark)
229-
if isinstance(node.value, list):
230-
return list_node([s.value for s in node.value], node.start_mark, node.end_mark)
231-
232-
raise ValueError(f"Unexpected node type: {type(node.value)}")
233-
234-
235234
def loads(yaml_string, fname=None):
236235
"""
237236
Load the given YAML string

src/cfnlint/decode/decode.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from yaml.scanner import ScannerError
1212

1313
from cfnlint.decode import cfn_json, cfn_yaml
14-
from cfnlint.rules import Match, ParseError
14+
from cfnlint.match import Match
1515

1616
LOGGER = logging.getLogger(__name__)
1717

@@ -118,6 +118,9 @@ def _decode(
118118
matches = [create_match_file_error(filename, str(err))]
119119

120120
if not isinstance(template, dict) and not matches:
121+
# pylint: disable=import-outside-toplevel
122+
from cfnlint.rules import ParseError
123+
121124
# Template isn't a dict which means nearly nothing will work
122125
matches = [
123126
Match(
@@ -135,6 +138,9 @@ def _decode(
135138

136139
def create_match_yaml_parser_error(parser_error, filename):
137140
"""Create a Match for a parser error"""
141+
# pylint: disable=import-outside-toplevel
142+
from cfnlint.rules import ParseError
143+
138144
lineno = parser_error.problem_mark.line + 1
139145
colno = parser_error.problem_mark.column + 1
140146
msg = parser_error.problem
@@ -143,6 +149,9 @@ def create_match_yaml_parser_error(parser_error, filename):
143149

144150
def create_match_file_error(filename, msg):
145151
"""Create a Match for a parser error"""
152+
# pylint: disable=import-outside-toplevel
153+
from cfnlint.rules import ParseError
154+
146155
return Match(
147156
linenumber=1,
148157
columnnumber=1,
@@ -156,6 +165,9 @@ def create_match_file_error(filename, msg):
156165

157166
def create_match_json_parser_error(parser_error, filename):
158167
"""Create a Match for a parser error"""
168+
# pylint: disable=import-outside-toplevel
169+
from cfnlint.rules import ParseError
170+
159171
lineno = parser_error.lineno
160172
colno = parser_error.colno
161173
msg = parser_error.msg

src/cfnlint/decode/utils.py

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""
2+
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
SPDX-License-Identifier: MIT-0
4+
"""
5+
6+
from cfnlint.decode.node import dict_node, list_node, str_node
7+
8+
9+
def convert_dict(template, start_mark=(0, 0), end_mark=(0, 0)):
10+
"""Convert dict to template"""
11+
if isinstance(template, dict):
12+
if not isinstance(template, dict_node):
13+
template = dict_node(template, start_mark, end_mark)
14+
for k, v in template.copy().items():
15+
k_start_mark = start_mark
16+
k_end_mark = end_mark
17+
if isinstance(k, str_node):
18+
k_start_mark = k.start_mark
19+
k_end_mark = k.end_mark
20+
new_k = str_node(k, k_start_mark, k_end_mark)
21+
del template[k]
22+
template[new_k] = convert_dict(v, k_start_mark, k_end_mark)
23+
elif isinstance(template, list):
24+
if not isinstance(template, list_node):
25+
template = list_node(template, start_mark, end_mark)
26+
for i, v in enumerate(template):
27+
template[i] = convert_dict(v, start_mark, end_mark)
28+
29+
return template

src/cfnlint/formatters/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
from jschema_to_python.to_json import to_json
1414
from junit_xml import TestCase, TestSuite, to_xml_report_string
1515

16-
from cfnlint.rules import Match, ParseError, RuleError, RulesCollection, TransformError
16+
from cfnlint.match import Match
17+
from cfnlint.rules import ParseError, RuleError, RulesCollection, TransformError
1718
from cfnlint.version import __version__
1819

1920
Matches = List[Match]

src/cfnlint/helpers.py

+1-25
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
import regex as re
2323

2424
from cfnlint.data import CloudSpecs
25-
from cfnlint.decode.node import dict_node, list_node, str_node
2625

2726
LOGGER = logging.getLogger(__name__)
2827

@@ -82,7 +81,6 @@
8281
r"^.*{{resolve:ssm-secure:[a-zA-Z0-9_\.\-/]+(:\d+)?}}.*$"
8382
)
8483

85-
8684
FUNCTIONS = [
8785
"Fn::Base64",
8886
"Fn::GetAtt",
@@ -111,6 +109,7 @@
111109
FUNCTION_OR = "Fn::Or"
112110
FUNCTION_NOT = "Fn::Not"
113111
FUNCTION_EQUALS = "Fn::Equals"
112+
FUNCTION_FOR_EACH = re.compile(r"^Fn::ForEach::[a-zA-Z0-9]+$")
114113

115114
PSEUDOPARAMS = [
116115
"AWS::AccountId",
@@ -543,29 +542,6 @@ def onerror(os_error):
543542
return result
544543

545544

546-
def convert_dict(template, start_mark=(0, 0), end_mark=(0, 0)):
547-
"""Convert dict to template"""
548-
if isinstance(template, dict):
549-
if not isinstance(template, dict_node):
550-
template = dict_node(template, start_mark, end_mark)
551-
for k, v in template.copy().items():
552-
k_start_mark = start_mark
553-
k_end_mark = end_mark
554-
if isinstance(k, str_node):
555-
k_start_mark = k.start_mark
556-
k_end_mark = k.end_mark
557-
new_k = str_node(k, k_start_mark, k_end_mark)
558-
del template[k]
559-
template[new_k] = convert_dict(v, k_start_mark, k_end_mark)
560-
elif isinstance(template, list):
561-
if not isinstance(template, list_node):
562-
template = list_node(template, start_mark, end_mark)
563-
for i, v in enumerate(template):
564-
template[i] = convert_dict(v, start_mark, end_mark)
565-
566-
return template
567-
568-
569545
def override_specs(override_spec_file):
570546
"""Override specs file"""
571547
try:

src/cfnlint/match.py

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""
2+
Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
SPDX-License-Identifier: MIT-0
4+
"""
5+
6+
7+
class Match:
8+
"""Match Classes"""
9+
10+
def __init__(
11+
self,
12+
linenumber,
13+
columnnumber,
14+
linenumberend,
15+
columnnumberend,
16+
filename,
17+
rule,
18+
message=None,
19+
rulematch_obj=None,
20+
):
21+
"""Init"""
22+
self.linenumber = linenumber
23+
"""Starting line number of the region this match spans"""
24+
self.columnnumber = columnnumber
25+
"""Starting line number of the region this match spans"""
26+
self.linenumberend = linenumberend
27+
"""Ending line number of the region this match spans"""
28+
self.columnnumberend = columnnumberend
29+
"""Ending column number of the region this match spans"""
30+
self.filename = filename
31+
"""Name of the filename associated with this match, or None if there is no such file"""
32+
self.rule = rule
33+
"""The rule of this match"""
34+
self.message = message # or rule.shortdesc
35+
"""The message of this match"""
36+
if rulematch_obj:
37+
for k, v in vars(rulematch_obj).items():
38+
if not hasattr(self, k):
39+
setattr(self, k, v)
40+
41+
def __repr__(self):
42+
"""Represent"""
43+
file_str = self.filename + ":" if self.filename else ""
44+
return f"[{self.rule}] ({self.message}) matched {file_str}{self.linenumber}"
45+
46+
def __eq__(self, item):
47+
"""Override equal to compare matches"""
48+
return (self.linenumber, self.columnnumber, self.rule.id, self.message) == (
49+
item.linenumber,
50+
item.columnnumber,
51+
item.rule.id,
52+
item.message,
53+
)

src/cfnlint/rules/__init__.py

+1-49
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import cfnlint.rules.custom
1414
from cfnlint.decode.node import TemplateAttributeError
1515
from cfnlint.exceptions import DuplicateRuleError
16+
from cfnlint.match import Match
1617
from cfnlint.template import Template
1718

1819
LOGGER = logging.getLogger(__name__)
@@ -628,55 +629,6 @@ def __hash__(self):
628629
return hash((self.path, self.message))
629630

630631

631-
class Match: # pylint: disable=R0902
632-
"""Match Classes"""
633-
634-
def __init__(
635-
self,
636-
linenumber,
637-
columnnumber,
638-
linenumberend,
639-
columnnumberend,
640-
filename,
641-
rule,
642-
message=None,
643-
rulematch_obj=None,
644-
):
645-
"""Init"""
646-
self.linenumber = linenumber
647-
"""Starting line number of the region this match spans"""
648-
self.columnnumber = columnnumber
649-
"""Starting line number of the region this match spans"""
650-
self.linenumberend = linenumberend
651-
"""Ending line number of the region this match spans"""
652-
self.columnnumberend = columnnumberend
653-
"""Ending column number of the region this match spans"""
654-
self.filename = filename
655-
"""Name of the filename associated with this match, or None if there is no such file"""
656-
self.rule = rule
657-
"""The rule of this match"""
658-
self.message = message # or rule.shortdesc
659-
"""The message of this match"""
660-
if rulematch_obj:
661-
for k, v in vars(rulematch_obj).items():
662-
if not hasattr(self, k):
663-
setattr(self, k, v)
664-
665-
def __repr__(self):
666-
"""Represent"""
667-
file_str = self.filename + ":" if self.filename else ""
668-
return f"[{self.rule}] ({self.message}) matched {file_str}{self.linenumber}"
669-
670-
def __eq__(self, item):
671-
"""Override equal to compare matches"""
672-
return (self.linenumber, self.columnnumber, self.rule.id, self.message) == (
673-
item.linenumber,
674-
item.columnnumber,
675-
item.rule.id,
676-
item.message,
677-
)
678-
679-
680632
class ParseError(CloudFormationLintRule):
681633
"""Parse Lint Rule"""
682634

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""
2+
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
SPDX-License-Identifier: MIT-0
4+
"""
5+
import logging
6+
7+
from cfnlint.rules import CloudFormationLintRule
8+
9+
LOGGER = logging.getLogger("cfnlint")
10+
11+
12+
class ForEach(CloudFormationLintRule):
13+
id = "E1032"
14+
shortdesc = "Validates ForEach functions"
15+
description = "Validates that ForEach parameters have a valid configuration"
16+
source_url = "https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html"
17+
tags = ["functions", "foreach"]
18+
19+
# pylint: disable=unused-argument
20+
def match(self, cfn):
21+
return []

src/cfnlint/rules/outputs/Value.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def match(self, cfn):
4141
objtype = (
4242
template.get("Resources", {}).get(obj[0], {}).get("Type")
4343
)
44-
if objtype:
44+
if objtype and isinstance(obj[1], str):
4545
attribute = (
4646
self.resourcetypes.get(objtype, {})
4747
.get("Attributes", {})

src/cfnlint/rules/resources/iam/Permissions.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import json
66

77
from cfnlint.data import AdditionalSpecs
8-
from cfnlint.helpers import convert_dict, load_resource
8+
from cfnlint.decode import convert_dict
9+
from cfnlint.helpers import load_resource
910
from cfnlint.rules import CloudFormationLintRule, RuleMatch
1011

1112

src/cfnlint/rules/resources/iam/Policy.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import json
66
from datetime import date
77

8-
from cfnlint.helpers import FUNCTIONS_SINGLE, convert_dict
8+
from cfnlint.decode import convert_dict
9+
from cfnlint.helpers import FUNCTIONS_SINGLE
910
from cfnlint.rules import CloudFormationLintRule, RuleMatch
1011

1112

0 commit comments

Comments
 (0)