cfn-check 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of cfn-check might be problematic. Click here for more details.

cfn_check/cli/render.py CHANGED
@@ -7,11 +7,14 @@ from cfn_check.rendering import Renderer
7
7
  from cfn_check.logging.models import InfoLog
8
8
 
9
9
 
10
- @CLI.command()
10
+ @CLI.command(
11
+ display_help_on_error=False
12
+ )
11
13
  async def render(
12
14
  path: str,
13
15
  output_file: str = 'rendered.yml',
14
- mappings: list[str] | None = None,
16
+ parameters: list[str] | None = None,
17
+ references: list[str] | None = None,
15
18
  tags: list[str] = [
16
19
  'Ref',
17
20
  'Sub',
@@ -35,7 +38,8 @@ async def render(
35
38
  Render a Cloud Formation template
36
39
 
37
40
  @param output_file Path to output the rendered CloudFormation template to
38
- @param mappings A list of <key>=<value> string pairs specifying Mappings
41
+ @param parameters A list of <key>=<value> input Parameters to use
42
+ @param references A list of <key>=<value> input !Ref values to use
39
43
  @param tags List of CloudFormation intrinsic function tags
40
44
  @param log_level The log level to use
41
45
  """
@@ -45,11 +49,16 @@ async def render(
45
49
  log_output='stderr',
46
50
  )
47
51
 
48
- selected_mappings: dict[str, str] | None = None
52
+ parsed_parameters: dict[str, str] | None = None
53
+ if parameters:
54
+ parsed_parameters = dict([
55
+ parameter.split('=', maxsplit=1) for parameter in parameters if len(parameter.split('=', maxsplit=1)) > 0
56
+ ])
49
57
 
50
- if mappings:
51
- selected_mappings = dict([
52
- mapping.split('=', maxsplit=1) for mapping in mappings if len(mapping.split('=', maxsplit=1)) > 0
58
+ parsed_references: dict[str, str] | None = None
59
+ if references:
60
+ parsed_references = dict([
61
+ reference.split('=', maxsplit=1) for reference in references if len(reference.split('=', maxsplit=1)) > 0
53
62
  ])
54
63
 
55
64
  logger = Logger()
@@ -63,7 +72,11 @@ async def render(
63
72
 
64
73
  _, template = templates[0]
65
74
  renderer = Renderer()
66
- rendered = renderer.render(template, selected_mappings=selected_mappings)
75
+ rendered = renderer.render(
76
+ template,
77
+ parameters=parsed_parameters,
78
+ references=parsed_references,
79
+ )
67
80
 
68
81
  await write_to_file(output_file, rendered)
69
82
 
@@ -2,22 +2,24 @@
2
2
  import asyncio
3
3
  import os
4
4
  import pathlib
5
- import yaml
6
- from cfn_check.loader.loader import (
7
- Loader,
8
- create_tag,
9
- find_templates,
10
- )
5
+ from ruamel.yaml import YAML
11
6
  from cfn_check.shared.types import YamlObject, Data
12
7
 
8
+
9
+ def find_templates(path, file_pattern):
10
+ return list(pathlib.Path(path).rglob(file_pattern))
11
+
13
12
  def open_template(path: str) -> tuple[str, YamlObject] | None:
14
13
 
15
14
  if os.path.exists(path) is False:
16
15
  return None
17
16
 
18
17
  try:
19
- with open(path, 'r') as f:
20
- return (path, yaml.load(f, Loader=Loader))
18
+ with open(path, 'r') as yml:
19
+ loader = YAML(typ='rt')
20
+ loader.preserve_quotes = True
21
+ loader.indent(mapping=2, sequence=4, offset=2)
22
+ return (path, loader.load(yml))
21
23
  except Exception as e:
22
24
  raise e
23
25
 
@@ -99,16 +101,6 @@ async def load_templates(
99
101
 
100
102
  assert len(template_filepaths) > 0 , '❌ No matching files found'
101
103
 
102
- for tag in tags:
103
- new_tag = await loop.run_in_executor(
104
- None,
105
- create_tag,
106
- tag,
107
- )
108
-
109
- Loader.add_constructor(f'!{tag}', new_tag)
110
-
111
-
112
104
  templates: list[tuple[str, Data]] = await asyncio.gather(*[
113
105
  loop.run_in_executor(
114
106
  None,
@@ -142,5 +134,9 @@ async def write_to_file(path: str, data: YamlObject):
142
134
  )
143
135
 
144
136
  def _write_to_file(path: str, data: YamlObject):
137
+ dumper = YAML(typ='rt')
138
+ dumper.preserve_quotes = True
139
+ dumper.width = 4096
140
+ dumper.indent(mapping=2, sequence=4, offset=2)
145
141
  with open(path, 'w') as yml:
146
- yaml.safe_dump(data, yml, indent=2)
142
+ dumper.dump(data, yml)
@@ -10,7 +10,6 @@ class Collection:
10
10
  self.documents: dict[str, Data] = {}
11
11
  self._evaluator = Evaluator()
12
12
 
13
-
14
13
  def query(
15
14
  self,
16
15
  query: str,
@@ -1,5 +1,12 @@
1
+ from __future__ import annotations
2
+ import base64
3
+ import json
1
4
  import re
5
+ from typing import Callable, Any
2
6
  from collections import deque
7
+ from ruamel.yaml.tag import Tag
8
+ from ruamel.yaml.comments import TaggedScalar, CommentedMap, CommentedSeq
9
+ from .utils import assign
3
10
 
4
11
  from cfn_check.shared.types import (
5
12
  Data,
@@ -7,118 +14,727 @@ from cfn_check.shared.types import (
7
14
  YamlObject,
8
15
  )
9
16
 
10
-
11
17
  class Renderer:
12
18
 
13
19
  def __init__(self):
14
- self.parameter_defaults: dict[str, str | int | float | bool | None] = {}
15
20
  self.items: Items = deque()
16
- self._ref_pattern = re.compile(r'^!Ref\s+')
21
+ self._sub_pattern = re.compile(r'\$\{([\w+::]+)\}')
22
+ self._sub_inner_text_pattern = re.compile(r'[\$|\{|\}]+')
17
23
  self._visited: list[str | int] = []
18
24
  self._data: YamlObject = {}
19
- self._mappings: dict[str, dict[str, YamlObject]] = {}
20
- self._selected_mappings: dict[str, YamlObject] = {}
21
- self._inputs: dict[str, str] = {}
25
+ self._parameters = CommentedMap()
26
+ self._mappings = CommentedMap()
27
+ self._parameters_with_defaults: dict[str, str | int | float | bool | None] = {}
28
+ self._selected_mappings = CommentedMap()
29
+ self._references: dict[str, str] = {}
30
+ self._resources: dict[str, YamlObject] = CommentedMap()
31
+ self._attributes: dict[str, str] = {}
32
+
33
+ self._resolvers: dict[str, Callable[[CommentedMap, str], YamlObject]] = {
34
+ '!Ref': self._resolve_ref,
35
+ '!FindInMap': self._resolve_by_subset_query,
36
+ '!GetAtt': self._resolve_getatt,
37
+ '!Join': self._resolve_join,
38
+ '!Sub': self._resolve_sub,
39
+ '!Base64': self._resolve_base64,
40
+ '!Split': self._resolve_split,
41
+ '!Select': self._resolve_select,
42
+ '!ToJsonString': self._resolve_to_json_string,
43
+ }
22
44
 
23
45
  def render(
24
46
  self,
25
- resources: YamlObject,
26
- selected_mappings: dict[str, str] | None = None,
47
+ template: YamlObject,
48
+ attributes: dict[str, Any] | None = None,
49
+ parameters: dict[str, Any] | None = None,
50
+ references: dict[str, str] | None = None,
51
+ mappings: dict[str, str] | None = None,
27
52
  ):
28
- data = resources.get("Resources", {})
29
- self.items.clear()
30
- self.items.append(data)
31
53
 
32
- self._assemble_parameters(resources)
54
+ self._sources = list(template.keys())
33
55
 
34
- self._mappings = resources.get('Mappings', {})
56
+ self._assemble_parameters(template)
35
57
 
36
- if selected_mappings:
37
- self._assemble_mappings(selected_mappings)
58
+ attributes = {
59
+ 'LambdaExecutionRole.Arn': 'This is a test',
60
+ 'AllSecurityGroups.Value': [
61
+ '123456',
62
+ '112211'
63
+ ]
38
64
 
39
- while len(self.items) > 0:
40
- item = self.items.pop()
65
+ }
66
+ if attributes:
67
+ self._attributes = self._process_attributes(attributes)
41
68
 
42
- if isinstance(item, list):
43
- self._visited.append((None, item))
44
- self.items.extend([
45
- (idx, val) for idx, val in enumerate(item)
46
- ])
69
+ self._parameters = template.get('Parameters', CommentedMap())
70
+ if parameters:
71
+ self._parameters_with_defaults.update(parameters)
47
72
 
48
- elif isinstance(item, dict):
49
- self._visited.append((None, item))
50
- self.items.extend(list(item.items()))
73
+ if references:
74
+ self._references.update(references)
51
75
 
52
- elif isinstance(item, tuple):
53
- key, value = item
54
- self._parse_kv_pair(key, value)
76
+ self._mappings = template.get('Mappings', CommentedMap())
77
+
78
+ if mappings:
79
+ self._selected_mappings = mappings
55
80
 
56
- last_item = data
57
- validator = dict(resources)
58
- validator_data = validator.get("Resources", {})
59
- for key, value in self._visited:
81
+ self._resources = template.get('Resources', CommentedMap())
60
82
 
61
- if isinstance(value, str) and (
62
- _ := self._selected_mappings.get(value)
63
- ):
64
- pass
65
-
66
- if isinstance(key, str) and isinstance(last_item, dict) and key in validator_data:
67
- last_item[key] = value
83
+ return self._resolve_tree(template)
68
84
 
69
- elif isinstance(key, int) and isinstance(last_item, list) and (
70
- value in validator_data or self.parameter_defaults.get(validator_data[key]) is not None
71
- ):
72
- last_item[key] = value
85
+ def _resolve_tree(self, root: YamlObject):
86
+ self.items.clear()
87
+ self.items.append((None, None, root))
88
+ self.items.append((None, None, root))
73
89
 
74
- if key and isinstance(value, (dict, list)):
75
- last_item = value
76
- validator_data = value
77
-
78
-
79
- return resources
90
+ while self.items:
91
+ parent, accessor, node = self.items.pop()
80
92
 
81
- def _parse_kv_pair(self, key: str | int, value: Data):
93
+ if isinstance(node, TaggedScalar):
94
+ # Replace in parent
95
+ if parent is not None and (
96
+ resolved := self._resolve_tagged(root, node)
97
+ ):
98
+ parent[accessor] = resolved
82
99
 
83
- if isinstance(value, list):
84
- self.items.extend([
85
- (idx, val) for idx, val in enumerate(value)
86
- ])
100
+ elif isinstance(node, CommentedMap):
101
+ if isinstance(node.tag, Tag) and node.tag.value is not None and parent and (
102
+ resolved_node := self._resolve_tagged(root, node)
103
+ ):
104
+ parent[accessor] = resolved_node
87
105
 
88
- elif isinstance(value, dict):
89
- self.items.extend(list(value.items()))
106
+ elif isinstance(node.tag, Tag) and node.tag.value is not None:
107
+ node = self._resolve_tagged(root, node)
108
+ for k in reversed(list(node.keys())):
109
+ self.items.append((node, k, node[k]))
90
110
 
91
- else:
92
- key, value = self._parse_value(key, value)
111
+ root = node
112
+
113
+ else:
114
+ # Process keys in reverse order for proper DFS
115
+ for k in reversed(list(node.keys())):
116
+ self.items.append((node, k, node[k]))
93
117
 
94
- self._visited.append((key, value))
118
+ elif isinstance(node, CommentedSeq):
119
+
120
+ if isinstance(node.tag, Tag) and node.tag.value is not None and parent and (
121
+ resolved_node := self._resolve_tagged(root, node)
122
+ ):
123
+ parent[accessor] = resolved_node
95
124
 
125
+ elif isinstance(node.tag, Tag) and node.tag.value is not None:
126
+ node = self._resolve_tagged(root, node)
127
+ for idx, val in enumerate(reversed(node)):
128
+ self.items.append((node, idx, val))
96
129
 
97
- def _parse_value(self, key: str | int, value: str | int | float | bool):
98
-
99
- if val := self.parameter_defaults.get(key):
100
- value = val
130
+ root = node
101
131
 
102
- elif val := self.parameter_defaults.get(value):
103
- value = val
132
+ else:
133
+ # Process indices in reverse order for proper DFS
134
+ for idx, val in enumerate(reversed(node)):
135
+ self.items.append((node, idx, val))
136
+
137
+ return root
138
+
139
+ def _find_matching_key(
140
+ self,
141
+ root: CommentedMap,
142
+ search_key: str,
143
+ ):
144
+ """Returns the first path (list of keys/indices) to a mapping with key == search_key, and the value at that path."""
145
+ stack = [(root, [])]
146
+ while stack:
147
+ node, path = stack.pop()
148
+ if isinstance(node, CommentedMap):
149
+ for k in node.keys():
150
+ if k == search_key:
151
+ return node[k]
152
+ stack.append((node[k], path + [k]))
153
+ elif isinstance(node, CommentedSeq):
154
+ for idx, item in reversed(list(enumerate(node))):
155
+ stack.append((item, path + [idx]))
104
156
 
105
- return key, value
157
+ return None # No match found
106
158
 
107
159
  def _assemble_parameters(self, resources: YamlObject):
108
160
  params: dict[str, Data] = resources.get("Parameters", {})
109
161
  for param_name, param in params.items():
110
162
  if default := param.get("Default"):
111
- self.parameter_defaults[param_name] = default
163
+ self._parameters_with_defaults[param_name] = default
164
+
165
+ def _resolve_tagged(self, root: CommentedMap, node: TaggedScalar | CommentedMap | CommentedSeq):
166
+ resolver: Callable[[CommentedMap, str], YamlObject] | None = None
167
+
168
+ if isinstance(node.tag, Tag) and (
169
+ resolver := self._resolvers.get(node.tag.value)
170
+ ):
171
+ return resolver(root, node)
172
+
173
+ def _resolve_ref(self, root: YamlObject, scalar: TaggedScalar):
174
+ '''
175
+ Sometimes we can resolve a !Ref if it has an explicit correlation
176
+ to a Resources key or input Parameter. This helps reduce the amount
177
+ of work we have to do when resolving later.
178
+ '''
179
+ if val := self._parameters_with_defaults.get(scalar.value):
180
+ return val
181
+
182
+ elif scalar.value in self._parameters:
183
+ return scalar
184
+
185
+ elif scalar.value in self._resources:
186
+ return scalar.value
187
+
188
+ elif ref := self._references.get(scalar.value):
189
+ return ref
190
+
191
+ else:
192
+ return self._find_matching_key(root, scalar.value)
193
+
194
+ def _resolve_by_subset_query(
195
+ self,
196
+ root: CommentedMap,
197
+ subset: CommentedMap | CommentedSeq,
198
+ ) -> YamlObject | None:
199
+ """
200
+ Traverse `subset` iteratively. For every leaf (scalar or TaggedScalar) encountered in `subset`,
201
+ use its value as the next key/index into `root`. Return (path, value) where:
202
+ - path: list of keys/indices used to reach into `root`
203
+ - value: the value at the end of traversal, or None if a step was missing (early return)
204
+ TaggedScalar is treated as a leaf and its .value is used as the key component.
205
+ """
206
+ current = self._mappings
207
+ path = []
208
+
209
+ stack = [(subset, [])]
210
+ while stack:
211
+ node, _ = stack.pop()
112
212
 
213
+ if isinstance(node, CommentedMap):
214
+
215
+ if isinstance(node.tag, Tag) and node.tag.value is not None and (
216
+ node != subset
217
+ ):
218
+ resolved_node = self._resolve_tagged(root, node)
219
+ stack.append((resolved_node, []))
220
+
221
+ else:
222
+ for k in reversed(list(node.keys())):
223
+ stack.append((node[k], []))
224
+
225
+ elif isinstance(node, CommentedSeq):
226
+
227
+ if isinstance(node.tag, Tag) and node.tag.value is not None and (
228
+ node != subset
229
+ ):
230
+ resolved_node = self._resolve_tagged(root, node)
231
+ stack.append((resolved_node, []))
232
+
233
+ else:
234
+ for val in reversed(node):
235
+ stack.append((val, []))
236
+ else:
237
+ # Leaf: scalar or TaggedScalar
238
+ key = self._resolve_tagged(
239
+ self._selected_mappings,
240
+ node,
241
+ ) if isinstance(node, TaggedScalar) else node
242
+ path.append(key)
243
+
244
+ if isinstance(current, CommentedMap):
245
+ if key in current:
246
+ current = current[key]
247
+ else:
248
+ return None
249
+ elif isinstance(current, CommentedSeq) and isinstance(key, int) and 0 <= key < len(current):
250
+ current = current[key]
251
+ else:
252
+ return None
253
+
254
+ if isinstance(current, TaggedScalar):
255
+ return path, self._resolve_tagged(
256
+ self._selected_mappings,
257
+ current,
258
+ )
259
+
260
+ return current
261
+
262
+ def _resolve_getatt(
263
+ self,
264
+ root: CommentedMap,
265
+ query: TaggedScalar | CommentedMap | CommentedSeq,
266
+ ) -> YamlObject | None:
267
+ steps: list[str] = []
113
268
 
114
- def _assemble_mappings(
269
+ if isinstance(query, TaggedScalar):
270
+ steps_string: str = query.value
271
+ steps = steps_string.split('.')
272
+
273
+ elif (
274
+ resolved := self._longest_path(root, query)
275
+ ) and isinstance(
276
+ resolved,
277
+ list,
278
+ ):
279
+ steps = resolved
280
+
281
+ if value := self._attributes.get(
282
+ '.'.join(steps)
283
+ ):
284
+ return value
285
+
286
+ current = self._resources
287
+ for step in steps:
288
+ if step == 'Value':
289
+ return current
290
+ # Mapping
291
+ if isinstance(current, (CommentedMap, dict)):
292
+ if step in current:
293
+ current = current[step]
294
+ else:
295
+ return None
296
+ # Sequence
297
+ elif isinstance(current, (CommentedSeq, list)):
298
+ try:
299
+ idx = int(step)
300
+ except ValueError:
301
+ return None
302
+ if 0 <= idx < len(current):
303
+ current = current[idx]
304
+ else:
305
+ return None
306
+ else:
307
+ # Hit a scalar (including TaggedScalar) before consuming all steps
308
+ return None
309
+
310
+ return current
311
+
312
+ def _resolve_join(
313
+ self,
314
+ root: CommentedMap,
315
+ source: CommentedSeq,
316
+ ) -> Any:
317
+ if len(source) < 2:
318
+ return ''
319
+
320
+ delimiter = source[0]
321
+ if isinstance(delimiter, (TaggedScalar, CommentedMap, CommentedSeq)):
322
+ delimiter = str(self._resolve_tagged(root, delimiter))
323
+
324
+ else:
325
+ delimiter = str(delimiter)
326
+
327
+ subselction = source[1:]
328
+ resolved = self._resolve_subtree(root, subselction)
329
+
330
+ if not isinstance(resolved, CommentedSeq):
331
+ return resolved
332
+
333
+ return delimiter.join([
334
+ str(self._resolve_tagged(
335
+ root,
336
+ node,
337
+ ))
338
+ if isinstance(
339
+ node,
340
+ (TaggedScalar, CommentedMap, CommentedSeq)
341
+ ) else node
342
+ for subset in resolved
343
+ for node in subset
344
+ ])
345
+
346
+ def _resolve_sub(
347
+ self,
348
+ root: CommentedMap,
349
+ source: CommentedSeq | TaggedScalar,
350
+ ):
351
+ if isinstance(source, TaggedScalar) and isinstance(
352
+ source.tag,
353
+ Tag,
354
+ ):
355
+ source_string = source.value
356
+ variables = self._resolve_template_string(source_string)
357
+ return self._resolve_sub_ref_queries(
358
+ variables,
359
+ source_string,
360
+ )
361
+
362
+ elif len(source) > 1:
363
+ source_string: str = source[0]
364
+ template_vars = self._resolve_template_string(source_string)
365
+ variables = source[1:]
366
+ resolved: list[dict[str, Any]] = self._resolve_subtree(root, variables)
367
+
368
+ for resolve_var in resolved:
369
+ for template_var, accessor in template_vars:
370
+ if val := resolve_var.get(accessor):
371
+ source_string = source_string.replace(template_var, val)
372
+
373
+ return source_string
374
+
375
+ return source
376
+
377
+ def _resolve_base64(
378
+ self,
379
+ root: CommentedMap,
380
+ source: CommentedMap | CommentedSeq | TaggedScalar,
381
+ ):
382
+ if isinstance(source, TaggedScalar) and isinstance(
383
+ source.tag,
384
+ Tag,
385
+ ) and isinstance(
386
+ source.tag.value,
387
+ str,
388
+ ):
389
+ return base64.b64encode(source.tag.value.encode()).decode('ascii')
390
+
391
+ elif (
392
+ resolved := self._resolve_subtree(root, source)
393
+ ) and isinstance(
394
+ resolved,
395
+ str
396
+ ):
397
+ return base64.b64encode(resolved.encode()).decode('ascii')
398
+
399
+ return source
400
+
401
+ def _resolve_split(
402
+ self,
403
+ root: CommentedMap,
404
+ source: CommentedSeq | CommentedMap | TaggedScalar,
405
+ ):
406
+ if isinstance(
407
+ source,
408
+ (CommentedMap, TaggedScalar),
409
+ ) or len(source) != 2:
410
+ return source
411
+
412
+ delimiter = source[0]
413
+ if not isinstance(
414
+ delimiter,
415
+ str,
416
+ ):
417
+ delimiter = self._resolve_subtree(root, delimiter)
418
+
419
+ target = source[1]
420
+ if not isinstance(
421
+ target,
422
+ str,
423
+ ):
424
+ target = self._resolve_subtree(root, target)
425
+
426
+ if isinstance(delimiter, str) and isinstance(target, str):
427
+ return CommentedSeq(target.split(delimiter))
428
+
429
+ return target
430
+
431
+ def _resolve_select(
115
432
  self,
116
- selected_keys: dict[str, str]
433
+ root: CommentedMap,
434
+ source: CommentedSeq | CommentedMap | TaggedScalar,
117
435
  ):
118
- for key, value in selected_keys.items():
436
+ if isinstance(
437
+ source,
438
+ (CommentedMap, TaggedScalar),
439
+ ) or len(source) != 2:
440
+ return source
441
+
442
+
443
+ index = source[0]
444
+ if not isinstance(
445
+ index,
446
+ int,
447
+ ):
448
+ index = self._resolve_subtree(root, index)
449
+
450
+ target = self._resolve_subtree(root, source[1])
451
+ if index > len(target):
452
+ return source
453
+
454
+ return target[index]
455
+
456
+ def _resolve_to_json_string(
457
+ self,
458
+ root: CommentedMap,
459
+ source: CommentedSeq | CommentedMap | TaggedScalar,
460
+ ):
461
+
462
+ stack: list[tuple[CommentedMap | CommentedSeq | None, Any | None, Any]] = [(None, None, source)]
463
+
464
+ while stack:
465
+ parent, accessor, node = stack.pop()
466
+ if isinstance(node, TaggedScalar):
467
+ # Replace in parent
468
+ if parent is not None and (
469
+ resolved := self._resolve_tagged(root, node)
470
+ ):
471
+ parent[accessor] = resolved
472
+
473
+ elif isinstance(node, CommentedMap):
474
+ if isinstance(node.tag, Tag) and node.tag.value is not None and parent and (
475
+ resolved_node := self._resolve_tagged(root, node)
476
+ ) and node != source:
477
+ parent[accessor] = resolved_node
478
+
479
+ elif isinstance(node.tag, Tag) and node.tag.value is not None and node != source:
480
+ node = self._resolve_tagged(root, node)
481
+ for k in reversed(list(node.keys())):
482
+ stack.append((node, k, node[k]))
483
+
484
+ source = node
485
+
486
+ else:
487
+ # Push children (keys) in reverse for DFS order
488
+ for k in reversed(list(node.keys())):
489
+ stack.append((node, k, node[k]))
490
+
491
+ elif isinstance(node, CommentedSeq):
492
+ if isinstance(node.tag, Tag) and node.tag.value is not None and parent and (
493
+ resolved_node := self._resolve_tagged(root, node)
494
+ ) and node != source :
495
+ parent[accessor] = resolved_node
496
+
497
+ elif isinstance(node.tag, Tag) and node.tag.value is not None and node != source:
498
+ node = self._resolve_tagged(root, node)
499
+ for idx, val in enumerate(reversed(node)):
500
+ stack.append((node, idx, val))
501
+
502
+ source = node
503
+
504
+ else:
505
+ # Process indices in reverse order for proper DFS
506
+ for idx, val in enumerate(reversed(node)):
507
+ stack.append((node, idx, val))
508
+
509
+ return json.dumps(source)
510
+
511
+ def _resolve_subtree(
512
+ self,
513
+ root: CommentedMap,
514
+ source: CommentedSeq
515
+ ) -> Any:
516
+ """
517
+ Iterative DFS over a ruamel.yaml tree.
518
+ - CommentedMap/CommentedSeq are traversed.
519
+ """
520
+ stack: list[tuple[CommentedMap | CommentedSeq | None, Any | None, Any]] = [(None, None, source)]
521
+
522
+ while stack:
523
+ parent, accessor, node = stack.pop()
524
+ if isinstance(node, TaggedScalar):
525
+ # Replace in parent
526
+ if parent is not None and (
527
+ resolved := self._resolve_tagged(root, node)
528
+ ):
529
+ parent[accessor] = resolved
530
+
531
+ elif isinstance(node, CommentedMap):
532
+ if isinstance(node.tag, Tag) and node.tag.value is not None and parent and (
533
+ resolved_node := self._resolve_tagged(root, node)
534
+ ):
535
+ parent[accessor] = resolved_node
536
+
537
+ elif isinstance(node.tag, Tag) and node.tag.value is not None:
538
+ node = self._resolve_tagged(root, node)
539
+ for k in reversed(list(node.keys())):
540
+ stack.append((node, k, node[k]))
541
+
542
+ source = node
543
+
544
+ else:
545
+ # Push children (keys) in reverse for DFS order
546
+ for k in reversed(list(node.keys())):
547
+ stack.append((node, k, node[k]))
548
+
549
+ elif isinstance(node, CommentedSeq):
550
+ if isinstance(node.tag, Tag) and node.tag.value is not None and parent and (
551
+ resolved_node := self._resolve_tagged(root, node)
552
+ ):
553
+ parent[accessor] = resolved_node
554
+
555
+ elif isinstance(node.tag, Tag) and node.tag.value is not None:
556
+ node = self._resolve_tagged(root, node)
557
+ for idx, val in enumerate(reversed(node)):
558
+ stack.append((node, idx, val))
559
+
560
+ source = node
561
+
562
+ else:
563
+ # Process indices in reverse order for proper DFS
564
+ for idx, val in enumerate(reversed(node)):
565
+ stack.append((node, idx, val))
566
+
567
+ return source
568
+
569
+ def _longest_path(
570
+ self,
571
+ root: CommentedMap,
572
+ source: TaggedScalar | CommentedMap | CommentedSeq
573
+ ):
574
+ """
575
+ Return the longest path from `node` to any leaf as a list of strings.
576
+ - Map keys are appended as strings.
577
+ - Sequence indices are appended as strings.
578
+ - TaggedScalar and other scalars are leafs.
579
+ """
580
+ stack = [(source, [])]
581
+ longest: list[str] = []
582
+
583
+ while stack:
584
+ current, path = stack.pop()
585
+
586
+ if isinstance(current, CommentedMap):
587
+ if not current:
588
+ if len(path) > len(longest):
589
+ longest = path
590
+ else:
591
+
592
+ if isinstance(current.tag, Tag) and current.tag.value is not None and (
593
+ current != source
594
+ ):
595
+ resolved_node = self._resolve_tagged(root, current)
596
+ stack.append((resolved_node, path))
597
+
598
+ else:
599
+ # Iterate in normal order; push in reverse to keep DFS intuitive
600
+ keys = list(current.keys())
601
+ for k in reversed(keys):
602
+ stack.append((current[k], path + [str(k)]))
603
+
604
+ elif isinstance(current, CommentedSeq):
605
+ if not current:
606
+ if len(path) > len(longest):
607
+ longest = path
608
+ else:
609
+ if isinstance(current.tag, Tag) and current.tag.value is not None and (
610
+ current != source
611
+ ):
612
+ resolved_node = self._resolve_tagged(root, current)
613
+ stack.append((resolved_node, path))
614
+
615
+ else:
616
+ for idx in reversed(range(len(current))):
617
+ stack.append((current[idx], path + [str(idx)]))
618
+
619
+ else:
620
+ # Scalar (incl. TaggedScalar) -> leaf
621
+ if len(path) > len(longest):
622
+ longest = path
623
+
624
+ return longest
625
+
626
+ def _assemble_mappings(self, mappings: dict[str, str]):
627
+ for mapping, value in mappings.items():
119
628
  if (
120
- mapping := self._mappings.get(key)
629
+ map_data := self._mappings.get(mapping)
121
630
  ) and (
122
- selected := mapping.get(value)
631
+ selected := map_data.get(value)
123
632
  ):
124
- self._selected_mappings[key] = selected
633
+ self._selected_mappings[mapping] = selected
634
+
635
+ def _process_attributes(
636
+ self,
637
+ attributes: dict[str, Any],
638
+ ):
639
+ return {
640
+ key: self._process_python_structure(value)
641
+ for key, value in attributes.items()
642
+ }
643
+
644
+ def _process_python_structure(
645
+ self,
646
+ obj: Any
647
+ ) -> Any:
648
+ """
649
+ Convert arbitrarily nested Python data (dict/list/scalars) into ruamel.yaml
650
+ CommentedMap/CommentedSeq equivalents using iterative DFS. Scalars are returned as-is.
651
+ """
652
+ # Fast path for scalars
653
+ if not isinstance(obj, (dict, list)):
654
+ return obj
655
+
656
+ # Create root container
657
+ if isinstance(obj, dict):
658
+ root_out: Any = CommentedMap()
659
+ work: list[tuple[Any, CommentedMap | CommentedSeq | None, Any | None]] = [(obj, None, None)]
660
+ else:
661
+ root_out = CommentedSeq()
662
+ work = [(obj, None, None)]
663
+
664
+
665
+
666
+ # Map from input container id to output container to avoid recreating
667
+ created: dict[int, CommentedMap | CommentedSeq] = {id(obj): root_out}
668
+
669
+
670
+ while work:
671
+ in_node, out_parent, out_key = work.pop()
672
+
673
+ if isinstance(in_node, dict):
674
+ out_container = created.get(id(in_node))
675
+ if out_container is None:
676
+ out_container = CommentedMap()
677
+ created[id(in_node)] = out_container
678
+ assign(out_parent, out_key, out_container)
679
+ else:
680
+ # Root case: already created and assigned
681
+ assign(out_parent, out_key, out_container)
682
+
683
+ # Push children in reverse to process first child next (DFS)
684
+ items = list(in_node.items())
685
+ for k, v in reversed(items):
686
+ if isinstance(v, (dict, list)):
687
+ # Create child container placeholder now for correct parent linkage
688
+ child_container = CommentedMap() if isinstance(v, dict) else CommentedSeq()
689
+ created[id(v)] = child_container
690
+ work.append((v, out_container, k))
691
+ else:
692
+ # Scalar, assign directly
693
+ out_container[k] = v
694
+
695
+ elif isinstance(in_node, list):
696
+ out_container = created.get(id(in_node))
697
+ if out_container is None:
698
+ out_container = CommentedSeq()
699
+ created[id(in_node)] = out_container
700
+ assign(out_parent, out_key, out_container)
701
+ else:
702
+ assign(out_parent, out_key, out_container)
703
+
704
+ # Push children in reverse order
705
+ for idx in reversed(range(len(in_node))):
706
+ v = in_node[idx]
707
+ if isinstance(v, (dict, list)):
708
+ child_container = CommentedMap() if isinstance(v, dict) else CommentedSeq()
709
+ created[id(v)] = child_container
710
+ work.append((v, out_container, idx))
711
+ else:
712
+ out_container.append(v)
713
+
714
+ else:
715
+ # Scalar node
716
+ assign(out_parent, out_key, in_node)
717
+
718
+ return root_out
719
+
720
+ def _resolve_template_string(self, template: str):
721
+
722
+ variables: list[tuple[str, str]] = []
723
+ for match in self._sub_pattern.finditer(template):
724
+ variables.append((
725
+ match.group(0),
726
+ self._sub_inner_text_pattern.sub('', match.group(0)),
727
+ ))
728
+
729
+ return variables
730
+
731
+ def _resolve_sub_ref_queries(
732
+ self,
733
+ variables: list[tuple[str, str]],
734
+ source_string: str,
735
+ ):
736
+ for variable, accessor in variables:
737
+ if val := self._references.get(accessor):
738
+ source_string = source_string.replace(variable, val)
739
+
740
+ return source_string
@@ -0,0 +1,13 @@
1
+ from typing import Any
2
+ from ruamel.yaml.comments import CommentedMap, CommentedSeq
3
+
4
+
5
+ def assign(parent: CommentedMap | CommentedSeq | None, key_or_index: Any, value: Any):
6
+ if parent is None:
7
+ return # root already set
8
+ if isinstance(parent, CommentedMap):
9
+ parent[key_or_index] = value
10
+ else:
11
+ # key_or_index is an int for sequences
12
+ # Ensure sequence large enough (iterative approach assigns in order, so append is fine)
13
+ parent.append(value)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cfn-check
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Validate Cloud Formation
5
5
  Author-email: Ada Lundhe <adalundhe@lundhe.audio>
6
6
  License: MIT License
@@ -1,13 +1,13 @@
1
1
  cfn_check/__init__.py,sha256=ccUo2YxBmuEmak1M5o-8J0ECLXNkDDUsLJ4mkm31GvU,96
2
2
  cfn_check/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- cfn_check/cli/render.py,sha256=1Y5FBtLgtis7qgU2AfurdoPt9davSZ8N4_-B9s287_M,1789
3
+ cfn_check/cli/render.py,sha256=7CuXd9e3JdbLxFcd0Yn7Die8XiVAyOUprgi_CFJERak,2232
4
4
  cfn_check/cli/root.py,sha256=Fi-G3nP-HQMY4iPenF2xnkQF798x5cNWDqJZs9TH66A,1727
5
5
  cfn_check/cli/validate.py,sha256=aQF-hCC7vcOpu5VNSkoM8DmrB2hZCgciQvFBHIrpnPc,2178
6
6
  cfn_check/cli/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  cfn_check/cli/utils/attributes.py,sha256=hEMWJfNcTOKqWrleS8idWlZP81wAq2J06yV-JQm_WNw,340
8
- cfn_check/cli/utils/files.py,sha256=GpeaFR12mvhVOPqmgvzr95HrAwpcRf0OeR0xtfHJkV4,3293
8
+ cfn_check/cli/utils/files.py,sha256=OVG95vfAbpfg-WdqoHT8UBGoa7KQima21KZTVp2mm6g,3378
9
9
  cfn_check/collection/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- cfn_check/collection/collection.py,sha256=wfdaUIi8pQnhsZ1nlMVCagVj83IK17Q32APBRCVTv7Y,1493
10
+ cfn_check/collection/collection.py,sha256=Fl5ONtvosLrksJklRoxER9j-YN5RUdPN45yS02Yw5jU,1492
11
11
  cfn_check/evaluation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
12
  cfn_check/evaluation/errors.py,sha256=yPJdtRYo67le4yMC9sYqcboCnkqKsJ3KPbSPFY2-Pi8,773
13
13
  cfn_check/evaluation/evaluator.py,sha256=VtYXydZFkp66VaADB9nDmJOBlPJ6lKASSM8AP1xHBZE,2377
@@ -16,24 +16,24 @@ cfn_check/evaluation/parsing/__init__.py,sha256=s5TxU4mzsbNIpbMynbwibGR8ac0dTcf_
16
16
  cfn_check/evaluation/parsing/query_parser.py,sha256=4J3CJQKAyb11gugfx6OZT-mfSdNDB5Al8Jiy9DbJZMw,3459
17
17
  cfn_check/evaluation/parsing/token.py,sha256=nrg7Tca182WY0VhRqfsZ1UgpxsUX73vdLToSeK50DZE,7055
18
18
  cfn_check/evaluation/parsing/token_type.py,sha256=E5AVBerinBszMLjjc7ejwSSWEc0p0Ju_CNFhpoZi63c,325
19
- cfn_check/loader/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
- cfn_check/loader/loader.py,sha256=7yiDLLW_vNp_8O47erLXjQQtAB47fU3nimb91N5N_R8,532
21
19
  cfn_check/logging/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
20
  cfn_check/logging/models.py,sha256=-tBaK6p8mJ0cO8h2keEJ-VmtFX_VW4XzwAw2PtqbkF0,490
23
21
  cfn_check/rendering/__init__.py,sha256=atcbddYun4YHyY7bVGA9CgEYzzXpYzvkx9_Kg-gnD5w,42
24
- cfn_check/rendering/renderer.py,sha256=ouaKberHGL-mhqefnjTa1AqRQS_Zzm0sTIribotoLNo,3695
22
+ cfn_check/rendering/renderer.py,sha256=mPLyXyv0yUsCZnKuLZg8yDN1NQlFivX-P8_bwhQqzXI,25903
23
+ cfn_check/rendering/utils.py,sha256=MNaKePylbJ9Bs4kjuoV0PpCmPJYttPXXvKQILemCrUI,489
25
24
  cfn_check/rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
25
  cfn_check/rules/rule.py,sha256=_cKNQ5ciJgPj-exmtBUz31cU2lxWYxw2n2NWIlhYc3s,635
27
26
  cfn_check/shared/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
27
  cfn_check/shared/types.py,sha256=-om3DyZsjK_tJd-I8SITkoE55W0nB2WA3LOc87Cs7xI,414
29
28
  cfn_check/validation/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
29
  cfn_check/validation/validator.py,sha256=Z6S6T_4yQW1IUa5Kv3ohR9U8NDrhTvBadW2FEM8TRL8,1478
31
- cfn_check-0.4.0.dist-info/licenses/LICENSE,sha256=EbCpGNzOkyQ53ig7J2Iwgmy4Og0dgHe8COo3WylhIKk,1069
30
+ cfn_check-0.5.0.dist-info/licenses/LICENSE,sha256=EbCpGNzOkyQ53ig7J2Iwgmy4Og0dgHe8COo3WylhIKk,1069
31
+ example/multitag.py,sha256=QQfcRERGEDgTUCGqWRqRbXHrLwSX4jEOFq8ED4NJnz8,636
32
32
  example/pydantic_rules.py,sha256=6NFtDiaqmnYWt6oZIWB7AO_v5LJoZVOGXrmEe2_J_rI,4162
33
- example/renderer_test.py,sha256=vr5Xb-Gk_B6fb9FTsFCANbmTNEv67ab5tNODhPOSXKE,738
33
+ example/renderer_test.py,sha256=XG5PVTSHztYXHrBw4bpwVuuYt1JNZdtLGJ-DZ9wPjFM,741
34
34
  example/rules.py,sha256=mWHB0DK283lb0CeSHgnyO5qiVTJJpybuwWXb4Yoa3zQ,3148
35
- cfn_check-0.4.0.dist-info/METADATA,sha256=5QubOsO2SPcHmlC_n_QqGQgZvsxXsSCDxYRHUMdTwwg,20459
36
- cfn_check-0.4.0.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
37
- cfn_check-0.4.0.dist-info/entry_points.txt,sha256=B4lCHoDHmwisABxKgRLShwqqFv7QwwDAFXoAChOnkwg,53
38
- cfn_check-0.4.0.dist-info/top_level.txt,sha256=hUn9Ya50yY1fpgWxEhG5iMgfMDDVX7qWQnM1xrgZnhM,18
39
- cfn_check-0.4.0.dist-info/RECORD,,
35
+ cfn_check-0.5.0.dist-info/METADATA,sha256=KJXT9Yc29NfwClW04f0TcQe1gI7iu82mNV_MpLb2aRg,20459
36
+ cfn_check-0.5.0.dist-info/WHEEL,sha256=zaaOINJESkSfm_4HQVc5ssNzHCPXhJm0kEUakpsEHaU,91
37
+ cfn_check-0.5.0.dist-info/entry_points.txt,sha256=B4lCHoDHmwisABxKgRLShwqqFv7QwwDAFXoAChOnkwg,53
38
+ cfn_check-0.5.0.dist-info/top_level.txt,sha256=hUn9Ya50yY1fpgWxEhG5iMgfMDDVX7qWQnM1xrgZnhM,18
39
+ cfn_check-0.5.0.dist-info/RECORD,,
example/multitag.py ADDED
@@ -0,0 +1,21 @@
1
+ import ruamel.yaml
2
+ import sys
3
+
4
+ class MultiTaggedObject:
5
+ def __init__(self, value, tags):
6
+ self.value = value
7
+ self.tags = tags
8
+
9
+ def represent_multi_tagged_object(dumper, data):
10
+ return dumper.represent_mapping('!MultiTagged', {'value': data.value, 'tags': data.tags})
11
+
12
+ def construct_multi_tagged_object(constructor, node):
13
+ mapping = constructor.construct_mapping(node)
14
+ return MultiTaggedObject(mapping['value'], mapping['tags'])
15
+
16
+ yaml = ruamel.yaml.YAML()
17
+ yaml.register_class(MultiTaggedObject)
18
+
19
+ # Example usage:
20
+ data = MultiTaggedObject("some_value", ["tag1", "tag2"])
21
+ yaml.dump({'item': data}, sys.stdout)
example/renderer_test.py CHANGED
@@ -1,5 +1,5 @@
1
1
  import yaml
2
- from cfn_check.render import Renderer
2
+ from cfn_check.rendering import Renderer
3
3
  from cfn_check.cli.utils.files import open_template, Loader, create_tag
4
4
  def test():
5
5
 
File without changes
@@ -1,21 +0,0 @@
1
- import yaml
2
- import pathlib
3
-
4
-
5
- class Loader(yaml.SafeLoader):
6
- pass
7
-
8
- def create_tag(tag):
9
- def constructor(loader: Loader, node):
10
- if isinstance(node, yaml.ScalarNode):
11
- return node.value
12
- elif isinstance(node, yaml.SequenceNode):
13
- return loader.construct_sequence(node)
14
- elif isinstance(node, yaml.MappingNode):
15
- return loader.construct_mapping(node)
16
- return constructor
17
-
18
-
19
- def find_templates(path, file_pattern):
20
- return list(pathlib.Path(path).rglob(file_pattern))
21
-