cfn-check 0.3.2__py3-none-any.whl → 0.8.1__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.

@@ -0,0 +1,1316 @@
1
+ from __future__ import annotations
2
+ import base64
3
+ import json
4
+ import re
5
+ from typing import Callable, Any
6
+ from collections import deque
7
+ from ruamel.yaml.tag import Tag
8
+ from ruamel.yaml.comments import TaggedScalar, CommentedMap, CommentedSeq
9
+ from .cidr_solver import IPv4CIDRSolver
10
+ from .utils import assign
11
+
12
+ from cfn_check.shared.types import (
13
+ Data,
14
+ Items,
15
+ YamlObject,
16
+ )
17
+
18
+ Resolver = Callable[
19
+ [
20
+ CommentedMap,
21
+ CommentedMap | CommentedSeq | TaggedScalar | YamlObject
22
+ ],
23
+ CommentedMap | CommentedSeq | TaggedScalar | YamlObject,
24
+ ]
25
+
26
+ class Renderer:
27
+
28
+ def __init__(self):
29
+ self.items: Items = deque()
30
+ self._sub_pattern = re.compile(r'\$\{([\w+::]+)\}')
31
+ self._sub_inner_text_pattern = re.compile(r'[\$|\{|\}]+')
32
+ self._visited: list[str | int] = []
33
+ self._data: YamlObject = {}
34
+ self._parameters = CommentedMap()
35
+ self._mappings = CommentedMap()
36
+ self._parameters_with_defaults: dict[str, str | int | float | bool | None] = {}
37
+ self._selected_mappings = CommentedMap()
38
+ self._conditions = CommentedMap()
39
+ self._references: dict[str, str] = {}
40
+ self._resources: dict[str, YamlObject] = CommentedMap()
41
+ self._attributes: dict[str, str] = {}
42
+ self._import_values: dict[
43
+ str,
44
+ CommentedMap | CommentedSeq | TaggedScalar | YamlObject,
45
+ ] = {}
46
+ self._availability_zones = CommentedSeq()
47
+
48
+ self._inline_functions = {
49
+ 'Fn::ForEach': re.compile(r'Fn::ForEach::\w+'),
50
+ 'Fn::If': re.compile(r'Fn::If'),
51
+ 'Fn::And': re.compile(r'Fn::And'),
52
+ 'Fn::Equals': re.compile(r'Fn::Equals'),
53
+ 'Fn::Not': re.compile(r'Fn::Not'),
54
+ 'Fn::Or': re.compile(r'Fn::Or'),
55
+ 'Fn:GetAtt': re.compile(r'Fn::GetAtt'),
56
+ 'Fn::Join': re.compile(r'Fn::Join'),
57
+ 'Fn::Sub': re.compile(r'Fn::Sub'),
58
+ 'Fn::Base64': re.compile(r'Fn::Base64'),
59
+ 'Fn::Split': re.compile(r'Fn::Split'),
60
+ 'Fn::Select': re.compile(r'Fn::Select'),
61
+ 'Fn::ToJsonString': re.compile(r'Fn::ToJsonString'),
62
+ 'Fn::Condition': re.compile(r'Fn::Condition'),
63
+ 'Fn::Cidr': re.compile(r'Fn::Cidr'),
64
+ 'Fn::Length': re.compile(r'Fn::Length'),
65
+ 'Fn::GetAZs': re.compile(r'Fn::GetAZs'),
66
+ 'Fn::ImportValue': re.compile(r'Fn::ImportValue'),
67
+ }
68
+
69
+ self._inline_resolvers = {
70
+ 'Fn::ForEach': self._resolve_foreach,
71
+ 'Fn::If': self._resolve_if,
72
+ 'Fn::And': self._resolve_and,
73
+ 'Fn::Equals': self._resolve_equals,
74
+ 'Fn::Not': self._resolve_not,
75
+ 'Fn::Or': self._resolve_or,
76
+ 'Fn:GetAtt': self._resolve_getatt,
77
+ 'Fn::Join': self._resolve_join,
78
+ 'Fn::Sub': self._resolve_sub,
79
+ 'Fn::Base64': self._resolve_base64,
80
+ 'Fn::Split': self._resolve_split,
81
+ 'Fn::Select': self._resolve_select,
82
+ 'Fn::ToJsonString': self._resolve_tree_to_json,
83
+ 'Fn::Condition': self._resolve_condition,
84
+ 'Fn::Cidr': self._resolve_cidr,
85
+ 'Fn::Length': self._resolve_length,
86
+ 'Fn::GetAZs': self._resolve_get_availability_zones,
87
+ 'Fn::ImportValue': self._resolve_import_value,
88
+ }
89
+
90
+ self._resolvers: dict[str, Callable[[CommentedMap, str], YamlObject]] = {
91
+ '!Ref': self._resolve_ref,
92
+ '!FindInMap': self._resolve_by_subset_query,
93
+ '!GetAtt': self._resolve_getatt,
94
+ '!Join': self._resolve_join,
95
+ '!Sub': self._resolve_sub,
96
+ '!Base64': self._resolve_base64,
97
+ '!Split': self._resolve_split,
98
+ '!Select': self._resolve_select,
99
+ '!ToJsonString': self._resolve_tree_to_json,
100
+ '!Equals': self._resolve_equals,
101
+ '!If': self._resolve_if,
102
+ '!Condition': self._resolve_condition,
103
+ '!And': self._resolve_and,
104
+ '!Not': self._resolve_not,
105
+ '!Or': self._resolve_or,
106
+ '!Cidr': self._resolve_cidr,
107
+ '!GetAZs': self._resolve_get_availability_zones,
108
+ '!ImportValue': self._resolve_import_value,
109
+ }
110
+
111
+ def render(
112
+ self,
113
+ template: YamlObject,
114
+ attributes: dict[str, Any] | None = None,
115
+ availability_zones: list[str] | None = None,
116
+ import_values: dict[str, tuple[str, CommentedMap]] | None = None,
117
+ mappings: dict[str, str] | None = None,
118
+ parameters: dict[str, Any] | None = None,
119
+ references: dict[str, str] | None = None,
120
+ ):
121
+
122
+ self._sources = list(template.keys())
123
+
124
+ self._assemble_parameters(template)
125
+
126
+ if attributes:
127
+ self._attributes = self._process_attributes(attributes)
128
+
129
+ if availability_zones:
130
+ self._availability_zones = CommentedSeq(availability_zones)
131
+
132
+ if import_values:
133
+ for _, (import_key, imported_template) in import_values.items():
134
+ self._import_values[import_key] = self._resolve_external_export(import_key, imported_template)
135
+
136
+ self._mappings = template.get('Mappings', CommentedMap())
137
+ if mappings:
138
+ self._selected_mappings = self._assemble_mappings(mappings)
139
+
140
+ self._parameters = template.get('Parameters', CommentedMap())
141
+ if parameters:
142
+ self._parameters_with_defaults.update(parameters)
143
+
144
+ if references:
145
+ self._references.update(references)
146
+
147
+ self._resources = template.get('Resources', CommentedMap())
148
+ self._conditions = template.get('Conditions', CommentedMap())
149
+
150
+ return self._resolve_tree(template)
151
+
152
+ def _resolve_tree(self, root: YamlObject):
153
+ self.items.clear()
154
+ self.items.append((None, None, root))
155
+
156
+ while self.items:
157
+ parent, accessor, node = self.items.pop()
158
+ if match := self._match_and_resolve_accessor_fn(
159
+ root,
160
+ parent,
161
+ accessor,
162
+ node,
163
+ ):
164
+ root.update(match)
165
+
166
+ if isinstance(node, TaggedScalar):
167
+ # Replace in parent
168
+ if parent is not None and (
169
+ resolved := self._resolve_tagged(root, node)
170
+ ):
171
+ parent[accessor] = resolved
172
+
173
+ elif isinstance(node, CommentedMap):
174
+ if isinstance(node.tag, Tag) and node.tag.value is not None and parent:
175
+ resolved_node = self._resolve_tagged(root, node)
176
+ parent[accessor] = resolved_node
177
+
178
+ elif isinstance(node.tag, Tag) and node.tag.value is not None:
179
+ node = self._resolve_tagged(root, node)
180
+ for k in reversed(list(node.keys())):
181
+ self.items.append((node, k, node[k]))
182
+
183
+ root = node
184
+
185
+ else:
186
+ # Process keys in reverse order for proper DFS
187
+ for k in reversed(list(node.keys())):
188
+ self.items.append((node, k, node[k]))
189
+
190
+ elif isinstance(node, CommentedSeq):
191
+
192
+ if isinstance(node.tag, Tag) and node.tag.value is not None and parent:
193
+ resolved_node = self._resolve_tagged(root, node)
194
+ parent[accessor] = resolved_node
195
+
196
+ elif isinstance(node.tag, Tag) and node.tag.value is not None:
197
+ node = self._resolve_tagged(root, node)
198
+
199
+ for idx, val in enumerate(reversed(node)):
200
+ self.items.append((node, idx, val))
201
+
202
+ root = node
203
+
204
+ else:
205
+ # Process indices in reverse order for proper DFS
206
+ for idx, val in enumerate(reversed(node)):
207
+ self.items.append((node, idx, val))
208
+
209
+ return root
210
+
211
+ def _match_and_resolve_accessor_fn(
212
+ self,
213
+ root: CommentedMap,
214
+ parent: CommentedMap | CommentedSeq | TaggedScalar | YamlObject | None,
215
+ accessor: str | int | None,
216
+ node: CommentedMap | CommentedSeq | TaggedScalar | YamlObject,
217
+ ):
218
+ if not isinstance(accessor, str):
219
+ return None
220
+
221
+ resolver: Resolver | None = None
222
+ matcher_pattern: re.Pattern | None = None
223
+ for key, pattern in self._inline_functions.items():
224
+ if pattern.match(accessor):
225
+ matcher_pattern = pattern
226
+ resolver = self._inline_resolvers[key]
227
+
228
+ if resolver is None:
229
+ return None
230
+
231
+ result = resolver(root, node)
232
+
233
+ return self._replace_target(
234
+ root,
235
+ parent,
236
+ result,
237
+ matcher_pattern,
238
+ )
239
+
240
+ def _resolve_tagged(self, root: CommentedMap, node: TaggedScalar | CommentedMap | CommentedSeq):
241
+ resolver: Callable[[CommentedMap, str], YamlObject] | None = None
242
+
243
+ if isinstance(node.tag, Tag) and (
244
+ resolver := self._resolvers.get(node.tag.value)
245
+ ):
246
+ return resolver(root, node)
247
+
248
+ def _resolve_ref(self, root: YamlObject, scalar: TaggedScalar):
249
+ '''
250
+ Sometimes we can resolve a !Ref if it has an explicit correlation
251
+ to a Resources key or input Parameter. This helps reduce the amount
252
+ of work we have to do when resolving later.
253
+ '''
254
+ if val := self._parameters_with_defaults.get(scalar.value):
255
+ return val
256
+
257
+ elif scalar.value in self._parameters:
258
+ return scalar
259
+
260
+ elif scalar.value in self._resources:
261
+ return scalar.value
262
+
263
+ elif ref := self._references.get(scalar.value):
264
+ return ref
265
+
266
+ else:
267
+ return self._resolve_subtree(
268
+ root,
269
+ self._find_matching_key(root, scalar.value),
270
+ )
271
+
272
+ def _resolve_getatt(
273
+ self,
274
+ root: CommentedMap,
275
+ query: TaggedScalar | CommentedMap | CommentedSeq,
276
+ ) -> YamlObject | None:
277
+ steps: list[str] = []
278
+
279
+ if isinstance(query, TaggedScalar):
280
+ steps_string: str = query.value
281
+ steps = steps_string.split('.')
282
+
283
+ elif (
284
+ resolved := self._resolve_subtree(root, query)
285
+ ) and isinstance(
286
+ resolved,
287
+ CommentedSeq,
288
+ ):
289
+ steps = resolved
290
+
291
+ if value := self._attributes.get(
292
+ '.'.join(steps)
293
+ ):
294
+ return value
295
+
296
+ current = self._resources.get(steps[0], CommentedMap()).get(
297
+ 'Properties',
298
+ CommentedMap(),
299
+ )
300
+
301
+ for step in steps[1:]:
302
+ if step == 'Value':
303
+ return current
304
+ # Mapping
305
+ if isinstance(current, (CommentedMap, dict)):
306
+ if step in current:
307
+ current = current[step]
308
+ else:
309
+ return None
310
+ # Sequence
311
+ elif isinstance(current, (CommentedSeq, list)):
312
+ try:
313
+ idx = int(step)
314
+ except ValueError:
315
+ return None
316
+ if 0 <= idx < len(current):
317
+ current = current[idx]
318
+ else:
319
+ return None
320
+ else:
321
+ # Hit a scalar (including TaggedScalar) before consuming all steps
322
+ return None
323
+
324
+ return current
325
+
326
+ def _resolve_join(
327
+ self,
328
+ root: CommentedMap,
329
+ source: CommentedSeq,
330
+ ) -> Any:
331
+ if len(source) < 2:
332
+ return ''
333
+
334
+ delimiter = source[0]
335
+ if isinstance(delimiter, (TaggedScalar, CommentedMap, CommentedSeq)):
336
+ delimiter = str(self._resolve_tagged(root, delimiter))
337
+
338
+ else:
339
+ delimiter = str(delimiter)
340
+
341
+ subselction = source[1:]
342
+ resolved = self._resolve_subtree(root, subselction)
343
+
344
+ if not isinstance(resolved, CommentedSeq):
345
+ return source
346
+
347
+ return delimiter.join([
348
+ str(self._resolve_tagged(
349
+ root,
350
+ node,
351
+ ))
352
+ if isinstance(
353
+ node,
354
+ (TaggedScalar, CommentedMap, CommentedSeq)
355
+ ) else node
356
+ for subset in resolved
357
+ for node in subset
358
+ ])
359
+
360
+ def _resolve_sub(
361
+ self,
362
+ root: CommentedMap,
363
+ source: CommentedSeq | TaggedScalar,
364
+ ):
365
+ if isinstance(source, TaggedScalar) and isinstance(
366
+ source.tag,
367
+ Tag,
368
+ ):
369
+ source_string = source.value
370
+ variables = self._resolve_template_string(source_string)
371
+ return self._resolve_sub_ref_queries(
372
+ variables,
373
+ source_string,
374
+ )
375
+
376
+ elif len(source) > 1:
377
+ source_string: str = source[0]
378
+ template_vars = self._resolve_template_string(source_string)
379
+ variables = source[1:]
380
+ resolved: list[dict[str, Any]] = self._resolve_subtree(root, variables)
381
+
382
+ for resolve_var in resolved:
383
+ for template_var, accessor in template_vars:
384
+ if val := resolve_var.get(accessor):
385
+ source_string = source_string.replace(template_var, val)
386
+
387
+ return source_string
388
+
389
+ return source
390
+
391
+ def _resolve_base64(
392
+ self,
393
+ root: CommentedMap,
394
+ source: CommentedMap | CommentedSeq | TaggedScalar,
395
+ ):
396
+ if isinstance(source, TaggedScalar) and isinstance(
397
+ source.tag,
398
+ Tag,
399
+ ) and isinstance(
400
+ source.tag.value,
401
+ str,
402
+ ):
403
+ return base64.b64encode(source.tag.value.encode()).decode('ascii')
404
+
405
+ elif (
406
+ resolved := self._resolve_subtree(root, source)
407
+ ) and isinstance(
408
+ resolved,
409
+ str
410
+ ):
411
+ return base64.b64encode(resolved.encode()).decode('ascii')
412
+
413
+ return source
414
+
415
+ def _resolve_foreach(
416
+ self,
417
+ root: CommentedMap,
418
+ source: CommentedSeq | CommentedMap | TaggedScalar,
419
+ ):
420
+ if not isinstance(source, CommentedSeq) or len(source) < 3:
421
+ return source
422
+
423
+ identifier = source[0]
424
+ if not isinstance(identifier, str):
425
+ identifier = self._resolve_subtree(root, identifier)
426
+
427
+ collection = source[1]
428
+ if not isinstance(collection, list):
429
+ return source
430
+
431
+ collection: list[str] = self._resolve_subtree(root, collection)
432
+
433
+ output = source[2]
434
+ if not isinstance(output, CommentedMap):
435
+ return source
436
+
437
+ resolved_items = CommentedMap()
438
+ for item in collection:
439
+ self._references[identifier] = item
440
+ resolved_items.update(
441
+ self._resolve_foreach_item(
442
+ root,
443
+ self._copy_subtree(output),
444
+ )
445
+ )
446
+
447
+ return resolved_items
448
+
449
+ def _resolve_foreach_item(
450
+ self,
451
+ root: CommentedMap,
452
+ output_item: CommentedMap,
453
+ ):
454
+ output_map: dict[str, CommentedMap] = {}
455
+ for output_key, output_value in output_item.items():
456
+ variables = self._resolve_template_string(output_key)
457
+ resolved_key = self._resolve_sub_ref_queries(
458
+ variables,
459
+ output_key,
460
+ )
461
+
462
+ output_map[resolved_key] = self._resolve_subtree(
463
+ root,
464
+ output_value,
465
+ )
466
+
467
+ return output_map
468
+
469
+ def _resolve_split(
470
+ self,
471
+ root: CommentedMap,
472
+ source: CommentedSeq | CommentedMap | TaggedScalar,
473
+ ):
474
+ if isinstance(
475
+ source,
476
+ (CommentedMap, TaggedScalar),
477
+ ) or len(source) != 2:
478
+ return source
479
+
480
+ delimiter = source[0]
481
+ if not isinstance(
482
+ delimiter,
483
+ str,
484
+ ):
485
+ delimiter = self._resolve_subtree(root, delimiter)
486
+
487
+ target = source[1]
488
+ if not isinstance(
489
+ target,
490
+ str,
491
+ ):
492
+ target = self._resolve_subtree(root, target)
493
+
494
+ if isinstance(delimiter, str) and isinstance(target, str):
495
+ return CommentedSeq(target.split(delimiter))
496
+
497
+ return target
498
+
499
+ def _resolve_select(
500
+ self,
501
+ root: CommentedMap,
502
+ source: CommentedSeq | CommentedMap | TaggedScalar,
503
+ ):
504
+ if isinstance(
505
+ source,
506
+ (CommentedMap, TaggedScalar),
507
+ ) or len(source) != 2:
508
+ return source
509
+
510
+
511
+ index = source[0]
512
+ if not isinstance(
513
+ index,
514
+ int,
515
+ ):
516
+ index = self._resolve_subtree(root, index)
517
+
518
+ target = self._resolve_subtree(root, source[1])
519
+ if index > len(target):
520
+ return source
521
+
522
+ return target[index]
523
+
524
+ def _resolve_equals(
525
+ self,
526
+ root: CommentedMap,
527
+ source: CommentedSeq | CommentedMap | TaggedScalar,
528
+ ):
529
+ if isinstance(
530
+ source,
531
+ (CommentedMap, TaggedScalar),
532
+ ) or len(source) != 2:
533
+ return source
534
+
535
+ item_a = source[0]
536
+ if isinstance(
537
+ item_a,
538
+ (CommentedMap, CommentedSeq, TaggedScalar),
539
+ ):
540
+ item_a = self._resolve_subtree(root, item_a)
541
+
542
+ item_b = source[1]
543
+ if isinstance(
544
+ item_b,
545
+ (CommentedMap, CommentedSeq, TaggedScalar),
546
+ ):
547
+ item_b = self._resolve_subtree(root, item_b)
548
+
549
+ return item_a == item_b
550
+
551
+ def _resolve_if(
552
+ self,
553
+ root: CommentedMap,
554
+ source: CommentedSeq | CommentedMap | TaggedScalar,
555
+ ):
556
+ if isinstance(
557
+ source,
558
+ (CommentedMap, TaggedScalar),
559
+ ) or len(source) != 3:
560
+ return source
561
+
562
+ condition_key = source[0]
563
+ if isinstance(
564
+ condition_key,
565
+ (CommentedMap, CommentedSeq, TaggedScalar),
566
+ ):
567
+ condition_key = self._resolve_subtree(root, condition_key)
568
+
569
+ result = self._resolve_subtree(root, self._conditions.get(condition_key))
570
+
571
+ true_result = source[1]
572
+ if isinstance(
573
+ true_result,
574
+ (CommentedMap, CommentedSeq, TaggedScalar),
575
+ ):
576
+ true_result = self._resolve_subtree(root, true_result)
577
+
578
+ false_result = source[2]
579
+
580
+ return true_result if isinstance(result, bool) and result else false_result
581
+
582
+ def _resolve_condition(
583
+ self,
584
+ root: CommentedMap,
585
+ source: CommentedSeq | CommentedMap | TaggedScalar,
586
+ ):
587
+ if isinstance(
588
+ source,
589
+ (CommentedMap, CommentedSeq),
590
+ ):
591
+ return source
592
+
593
+ if (
594
+ condition := self._conditions.get(source.value)
595
+ ) and isinstance(
596
+ condition,
597
+ (CommentedMap, CommentedSeq, TaggedScalar)
598
+ ) and (
599
+ result := self._resolve_subtree(root, condition)
600
+ ) and isinstance(
601
+ result,
602
+ bool,
603
+ ):
604
+ return result
605
+
606
+ elif (
607
+ condition := self._conditions.get(source.value)
608
+ ) and isinstance(
609
+ condition,
610
+ bool,
611
+ ):
612
+ return condition
613
+
614
+ return source
615
+
616
+ def _resolve_and(
617
+ self,
618
+ root: CommentedMap,
619
+ source: CommentedSeq | CommentedMap | TaggedScalar,
620
+ ):
621
+ if isinstance(
622
+ source,
623
+ (CommentedMap, TaggedScalar),
624
+ ):
625
+ return source
626
+
627
+ resolved = self._resolve_subtree(root, CommentedSeq([
628
+ item for item in source
629
+ ]))
630
+ if not isinstance(resolved, CommentedSeq):
631
+ return source
632
+
633
+
634
+ for node in resolved:
635
+ if not isinstance(node, bool):
636
+ return source
637
+
638
+ return all(resolved)
639
+
640
+ def _resolve_not(
641
+ self,
642
+ root: CommentedMap,
643
+ source: CommentedSeq | CommentedMap | TaggedScalar,
644
+ ):
645
+ if isinstance(
646
+ source,
647
+ (CommentedMap, TaggedScalar),
648
+ ):
649
+ return source
650
+
651
+ resolved = self._resolve_subtree(root, CommentedSeq([
652
+ item for item in source
653
+ ]))
654
+ if not isinstance(resolved, CommentedSeq):
655
+ return source
656
+
657
+ for node in resolved:
658
+ if not isinstance(node, bool):
659
+ return source
660
+
661
+ return not all(resolved)
662
+
663
+ def _resolve_or(
664
+ self,
665
+ root: CommentedMap,
666
+ source: CommentedSeq | CommentedMap | TaggedScalar,
667
+ ):
668
+ if isinstance(
669
+ source,
670
+ (CommentedMap, TaggedScalar),
671
+ ):
672
+ return source
673
+
674
+ resolved = self._resolve_subtree(root, CommentedSeq([
675
+ item for item in source
676
+ ]))
677
+ if not isinstance(resolved, CommentedSeq):
678
+ return source
679
+
680
+
681
+ for node in resolved:
682
+ if not isinstance(node, bool):
683
+ return source
684
+
685
+ return any(resolved)
686
+
687
+ def _resolve_cidr(
688
+ self,
689
+ root: CommentedMap,
690
+ source: CommentedSeq | CommentedMap | TaggedScalar,
691
+ ):
692
+ if not isinstance(
693
+ source,
694
+ CommentedSeq,
695
+ ) or len(source) < 3:
696
+ return source
697
+
698
+ cidr = self._resolve_subtree(root, source[0])
699
+ if not isinstance(cidr, str):
700
+ return source
701
+
702
+ subnets_requested = source[1]
703
+ subnet_cidr_bits = source[2]
704
+
705
+ ipv4_solver = IPv4CIDRSolver(
706
+ cidr,
707
+ subnets_requested,
708
+ subnet_cidr_bits,
709
+ )
710
+
711
+ return CommentedSeq(ipv4_solver.provision_subnets())
712
+
713
+ def _resolve_tree_to_json(
714
+ self,
715
+ root: CommentedMap,
716
+ source: CommentedSeq | CommentedMap | TaggedScalar,
717
+ ):
718
+
719
+ stack: list[tuple[CommentedMap | CommentedSeq | None, Any | None, Any]] = [(None, None, source)]
720
+
721
+ while stack:
722
+ parent, accessor, node = stack.pop()
723
+ if isinstance(node, TaggedScalar):
724
+ # Replace in parent
725
+ if parent is not None and (
726
+ resolved := self._resolve_tagged(root, node)
727
+ ):
728
+ parent[accessor] = resolved
729
+
730
+ elif (
731
+ resolved := self._resolve_tagged(root, node)
732
+ ):
733
+ source = resolved
734
+
735
+ elif isinstance(node, CommentedMap):
736
+ if isinstance(node.tag, Tag) and node.tag.value is not None and parent and (
737
+ resolved_node := self._resolve_tagged(root, node)
738
+ ) and node != source:
739
+ parent[accessor] = resolved_node
740
+
741
+ elif isinstance(node.tag, Tag) and node.tag.value is not None and node != source:
742
+ node = self._resolve_tagged(root, node)
743
+ for k in reversed(list(node.keys())):
744
+ stack.append((node, k, node[k]))
745
+
746
+ source = node
747
+
748
+ else:
749
+ # Push children (keys) in reverse for DFS order
750
+ for k in reversed(list(node.keys())):
751
+ stack.append((node, k, node[k]))
752
+
753
+ elif isinstance(node, CommentedSeq):
754
+ if isinstance(node.tag, Tag) and node.tag.value is not None and parent and (
755
+ resolved_node := self._resolve_tagged(root, node)
756
+ ) and node != source :
757
+ parent[accessor] = resolved_node
758
+
759
+ elif isinstance(node.tag, Tag) and node.tag.value is not None and node != source:
760
+ node = self._resolve_tagged(root, node)
761
+ for idx, val in enumerate(reversed(node)):
762
+ stack.append((node, idx, val))
763
+
764
+ source = node
765
+
766
+ else:
767
+ # Process indices in reverse order for proper DFS
768
+ for idx, val in enumerate(reversed(node)):
769
+ stack.append((node, idx, val))
770
+
771
+ return json.dumps(source)
772
+
773
+ def _resolve_length(
774
+ self,
775
+ root: CommentedMap,
776
+ source: CommentedMap | CommentedSeq | TaggedScalar | YamlObject,
777
+ ):
778
+ items = CommentedSeq()
779
+ if isinstance(source, TaggedScalar):
780
+ items = self._resolve_tagged(root, source)
781
+
782
+ elif isinstance(source, (CommentedMap, CommentedSeq)):
783
+ items = self._resolve_subtree(root, source)
784
+
785
+ elif isinstance(source, (str, list, dict)):
786
+ items = source
787
+
788
+ else:
789
+ return source
790
+
791
+ return len(items)
792
+
793
+ def _resolve_get_availability_zones(
794
+ self,
795
+ _: CommentedMap,
796
+ source: CommentedMap | CommentedSeq | TaggedScalar | YamlObject,
797
+ ):
798
+ if not isinstance(source, TaggedScalar):
799
+ return source
800
+
801
+ return self._availability_zones
802
+
803
+ def _resolve_import_value(
804
+ self,
805
+ _: CommentedMap,
806
+ source: CommentedMap | CommentedSeq | TaggedScalar | YamlObject,
807
+ ):
808
+ if not isinstance(source, TaggedScalar):
809
+ return source
810
+
811
+ return self._import_values.get(source.value)
812
+
813
+ def _resolve_external_export(
814
+ self,
815
+ key: str,
816
+ template: CommentedMap,
817
+ ):
818
+ outputs: CommentedMap = template.get('Outputs', CommentedMap())
819
+ exports: CommentedMap = outputs.get('Export', CommentedMap())
820
+
821
+ if subtree := exports.get(key):
822
+ return self._resolve_subtree(template, subtree)
823
+
824
+ return None
825
+
826
+ def _copy_subtree(
827
+ self,
828
+ root: CommentedMap | CommentedSeq | TaggedScalar | YamlObject,
829
+ ) -> Any:
830
+ """
831
+ Depth-first clone of a ruamel.yaml tree.
832
+ - Rebuilds CommentedMap/CommentedSeq
833
+ - Copies TaggedScalar (preserves tag and value)
834
+ - Scalars are copied as-is
835
+ Note: does not preserve comments/anchors.
836
+ """
837
+ if isinstance(root, CommentedMap):
838
+ root_clone: Any = CommentedMap()
839
+ elif isinstance(root, CommentedSeq):
840
+ root_clone = CommentedSeq()
841
+ elif isinstance(root, TaggedScalar):
842
+ return TaggedScalar(
843
+ value=root.value,
844
+ tag=root.tag,
845
+ )
846
+ else:
847
+ return root
848
+
849
+ stack: list[
850
+ tuple[
851
+ Any,
852
+ CommentedMap | CommentedSeq | None,
853
+ Any | None,
854
+ ]
855
+ ] = [(root, None, None)]
856
+
857
+ built: dict[
858
+ int,
859
+ CommentedMap | CommentedSeq,
860
+ ] = {id(root): root_clone}
861
+
862
+ while stack:
863
+ in_node, out_parent, out_key = stack.pop()
864
+
865
+ if isinstance(in_node, CommentedMap):
866
+ out_container = built.get(id(in_node))
867
+ if out_container is None:
868
+ out_container = CommentedMap()
869
+ built[id(in_node)] = out_container
870
+ assign(out_parent, out_key, out_container)
871
+
872
+ for k in reversed(list(in_node.keys())):
873
+ v = in_node[k]
874
+ if isinstance(v, CommentedMap):
875
+ child = CommentedMap()
876
+ built[id(v)] = child
877
+
878
+ stack.append((v, out_container, k))
879
+ elif isinstance(v, CommentedSeq):
880
+ child = CommentedSeq()
881
+ built[id(v)] = child
882
+
883
+ stack.append((v, out_container, k))
884
+ elif isinstance(v, TaggedScalar):
885
+ ts = TaggedScalar(
886
+ value=v.value,
887
+ tag=v.tag,
888
+ )
889
+
890
+ out_container[k] = ts
891
+ else:
892
+ out_container[k] = v
893
+
894
+ elif isinstance(in_node, CommentedSeq):
895
+ out_container = built.get(id(in_node))
896
+ if out_container is None:
897
+ out_container = CommentedSeq()
898
+ built[id(in_node)] = out_container
899
+ assign(out_parent, out_key, out_container)
900
+
901
+ for idx in reversed(range(len(in_node))):
902
+ v = in_node[idx]
903
+
904
+ if isinstance(v, CommentedMap):
905
+ child = CommentedMap()
906
+ built[id(v)] = child
907
+
908
+ stack.append((v, out_container, idx))
909
+ elif isinstance(v, CommentedSeq):
910
+ child = CommentedSeq()
911
+ built[id(v)] = child
912
+
913
+ stack.append((v, out_container, idx))
914
+ elif isinstance(v, TaggedScalar):
915
+ ts = TaggedScalar(
916
+ value=v.value,
917
+ tag=v.tag,
918
+ )
919
+
920
+ out_container.append(ts)
921
+ else:
922
+ out_container.append(v)
923
+
924
+ elif isinstance(in_node, TaggedScalar):
925
+ ts = TaggedScalar(
926
+ value=in_node.value,
927
+ tag=in_node.tag,
928
+ )
929
+
930
+ assign(out_parent, out_key, ts)
931
+
932
+ else:
933
+ assign(out_parent, out_key, in_node)
934
+
935
+ return root_clone
936
+
937
+ def _replace_target(
938
+ self,
939
+ root: CommentedMap,
940
+ target: CommentedMap,
941
+ replacement: Any,
942
+ matcher_pattern: re.Pattern
943
+ ) -> CommentedMap:
944
+ if not isinstance(target, CommentedMap):
945
+ return root
946
+
947
+ if root is target:
948
+ return replacement
949
+
950
+ stack: list[tuple[Any, Any | None, Any | None]] = [(root, None, None)]
951
+
952
+ while stack:
953
+ node, parent, accessor = stack.pop()
954
+
955
+ if isinstance(node, CommentedMap):
956
+ for k in reversed(list(node.keys())):
957
+ child = node[k]
958
+ if child is target and isinstance(child, CommentedMap):
959
+ for key in list(target.keys()):
960
+ if matcher_pattern.match(key):
961
+ del child[key]
962
+
963
+ if isinstance(replacement, CommentedMap):
964
+ child.update(replacement)
965
+ node[k] = child
966
+
967
+ else:
968
+ node[k] = replacement
969
+
970
+ if parent:
971
+ parent[accessor] = node
972
+
973
+ return root
974
+
975
+ stack.append((child, node, k))
976
+
977
+ elif isinstance(node, CommentedSeq):
978
+ for idx in reversed(range(len(node))):
979
+ child = node[idx]
980
+ if child is target and isinstance(child, CommentedMap):
981
+ for key in list(target.keys()):
982
+ if matcher_pattern.match(key):
983
+ del child[key]
984
+
985
+ if isinstance(replacement, CommentedMap):
986
+ child.update(replacement)
987
+ node[idx] = child
988
+
989
+ else:
990
+ node[idx] = replacement
991
+
992
+ if parent:
993
+ parent[accessor] = node
994
+
995
+ return root
996
+
997
+ stack.append((child, node, idx))
998
+
999
+ return root
1000
+
1001
+ def _resolve_subtree(
1002
+ self,
1003
+ root: CommentedMap,
1004
+ source: CommentedSeq
1005
+ ) -> Any:
1006
+ """
1007
+ Iterative DFS over a ruamel.yaml tree.
1008
+ - CommentedMap/CommentedSeq are traversed.
1009
+ """
1010
+ stack: list[tuple[CommentedMap | CommentedSeq | None, Any | None, Any]] = [(None, None, source)]
1011
+
1012
+ source_parent, source_index = self._find_parent(root, source)
1013
+
1014
+ while stack:
1015
+ parent, accessor, node = stack.pop()
1016
+ if match := self._match_and_resolve_accessor_fn(
1017
+ root,
1018
+ parent,
1019
+ accessor,
1020
+ node,
1021
+ ):
1022
+ root.update(match)
1023
+ # At this point we've likely (and completely)
1024
+ # successfully nuked the source from orbit
1025
+ # so we need to fetch it from the source parent
1026
+ # to get it back (i.e. the ref is no longer
1027
+ # correct).
1028
+ source = source_parent[source_index]
1029
+
1030
+ if isinstance(node, TaggedScalar):
1031
+ # Replace in parent
1032
+ if parent is not None and (
1033
+ resolved := self._resolve_tagged(root, node)
1034
+ ):
1035
+ parent[accessor] = resolved
1036
+
1037
+ elif (
1038
+ resolved := self._resolve_tagged(root, node)
1039
+ ):
1040
+ source = resolved
1041
+
1042
+ elif isinstance(node, CommentedMap):
1043
+ if isinstance(node.tag, Tag) and node.tag.value is not None and parent:
1044
+ resolved_node = self._resolve_tagged(root, node)
1045
+ parent[accessor] = resolved_node
1046
+
1047
+ elif isinstance(node.tag, Tag) and node.tag.value is not None:
1048
+ node = self._resolve_tagged(root, node)
1049
+ for k in reversed(list(node.keys())):
1050
+ stack.append((node, k, node[k]))
1051
+
1052
+ source = node
1053
+
1054
+ else:
1055
+ # Push children (keys) in reverse for DFS order
1056
+ for k in reversed(list(node.keys())):
1057
+ stack.append((node, k, node[k]))
1058
+
1059
+ elif isinstance(node, CommentedSeq):
1060
+ if isinstance(node.tag, Tag) and node.tag.value is not None and parent:
1061
+ resolved_node = self._resolve_tagged(root, node)
1062
+ parent[accessor] = resolved_node
1063
+
1064
+ elif isinstance(node.tag, Tag) and node.tag.value is not None:
1065
+ node = self._resolve_tagged(root, node)
1066
+ for idx, val in enumerate(reversed(node)):
1067
+ stack.append((node, idx, val))
1068
+
1069
+ source = node
1070
+
1071
+ else:
1072
+ # Process indices in reverse order for proper DFS
1073
+ for idx, val in enumerate(reversed(node)):
1074
+ stack.append((node, idx, val))
1075
+
1076
+ return source
1077
+
1078
+ def _resolve_by_subset_query(
1079
+ self,
1080
+ root: CommentedMap,
1081
+ subset: CommentedMap | CommentedSeq,
1082
+ ) -> YamlObject | None:
1083
+ """
1084
+ Traverse `subset` iteratively. For every leaf (scalar or TaggedScalar) encountered in `subset`,
1085
+ use its value as the next key/index into `root`. Return (path, value) where:
1086
+ - path: list of keys/indices used to reach into `root`
1087
+ - value: the value at the end of traversal, or None if a step was missing (early return)
1088
+ TaggedScalar is treated as a leaf and its .value is used as the key component.
1089
+ """
1090
+ current = self._mappings
1091
+ path = []
1092
+
1093
+ stack = [(subset, [])]
1094
+ while stack:
1095
+ node, _ = stack.pop()
1096
+
1097
+ if isinstance(node, CommentedMap):
1098
+
1099
+ if isinstance(node.tag, Tag) and node.tag.value is not None and (
1100
+ node != subset
1101
+ ):
1102
+ resolved_node = self._resolve_tagged(root, node)
1103
+ stack.append((resolved_node, []))
1104
+
1105
+ else:
1106
+ for k in reversed(list(node.keys())):
1107
+ stack.append((node[k], []))
1108
+
1109
+ elif isinstance(node, CommentedSeq):
1110
+
1111
+ if isinstance(node.tag, Tag) and node.tag.value is not None and (
1112
+ node != subset
1113
+ ):
1114
+ resolved_node = self._resolve_tagged(root, node)
1115
+ stack.append((resolved_node, []))
1116
+
1117
+ else:
1118
+ for val in reversed(node):
1119
+ stack.append((val, []))
1120
+ else:
1121
+ # Leaf: scalar or TaggedScalar
1122
+ key = self._resolve_tagged(
1123
+ self._selected_mappings,
1124
+ node,
1125
+ ) if isinstance(node, TaggedScalar) else node
1126
+ path.append(key)
1127
+
1128
+ if isinstance(current, CommentedMap):
1129
+ if key in current:
1130
+ current = current[key]
1131
+ else:
1132
+ return None
1133
+ elif isinstance(current, CommentedSeq) and isinstance(key, int) and 0 <= key < len(current):
1134
+ current = current[key]
1135
+ else:
1136
+ return None
1137
+
1138
+ if isinstance(current, TaggedScalar):
1139
+ return path, self._resolve_tagged(
1140
+ self._selected_mappings,
1141
+ current,
1142
+ )
1143
+
1144
+ return current
1145
+
1146
+ def _find_matching_key(
1147
+ self,
1148
+ root: CommentedMap,
1149
+ search_key: str,
1150
+ ):
1151
+ """Returns the first path (list of keys/indices) to a mapping with key == search_key, and the value at that path."""
1152
+ stack = [(root, [])]
1153
+ while stack:
1154
+ node, path = stack.pop()
1155
+ if isinstance(node, CommentedMap):
1156
+ for k in reversed(list(node.keys())):
1157
+ if k == search_key:
1158
+ return node[k]
1159
+ stack.append((node[k], path + [k]))
1160
+ elif isinstance(node, CommentedSeq):
1161
+ for idx, item in reversed(list(enumerate(node))):
1162
+ stack.append((item, path + [idx]))
1163
+
1164
+ return None # No match found
1165
+
1166
+ def _find_parent(
1167
+ self,
1168
+ root: CommentedMap,
1169
+ target: CommentedMap,
1170
+ ) -> CommentedMap:
1171
+
1172
+ stack: list[tuple[Any, Any | None, Any | None]] = [(root, None, None)]
1173
+
1174
+ while stack:
1175
+ node, parent, accessor = stack.pop()
1176
+
1177
+ if isinstance(node, CommentedMap):
1178
+ for k in reversed(list(node.keys())):
1179
+ child = node[k]
1180
+ if child is target and isinstance(child, CommentedMap):
1181
+ return node, k
1182
+
1183
+ stack.append((child, node, k))
1184
+
1185
+ elif isinstance(node, CommentedSeq):
1186
+ for idx in reversed(range(len(node))):
1187
+ child = node[idx]
1188
+ if child is target and isinstance(child, CommentedMap):
1189
+ return node, node.index(child)
1190
+
1191
+ stack.append((child, node, idx))
1192
+
1193
+ return None, None
1194
+
1195
+ def _assemble_parameters(self, resources: YamlObject):
1196
+ params: dict[str, Data] = resources.get("Parameters", {})
1197
+ for param_name, param in params.items():
1198
+ if isinstance(param, CommentedMap) and (
1199
+ default := param.get("Default")
1200
+ ):
1201
+ self._parameters_with_defaults[param_name] = default
1202
+
1203
+ def _assemble_mappings(self, mappings: dict[str, str]):
1204
+ for mapping, value in mappings.items():
1205
+ if (
1206
+ map_data := self._mappings.get(mapping)
1207
+ ) and (
1208
+ selected := map_data.get(value)
1209
+ ):
1210
+ self._selected_mappings[mapping] = selected
1211
+
1212
+ def _process_attributes(
1213
+ self,
1214
+ attributes: dict[str, Any],
1215
+ ):
1216
+ return {
1217
+ key: self._process_python_structure(value)
1218
+ for key, value in attributes.items()
1219
+ }
1220
+
1221
+ def _process_python_structure(
1222
+ self,
1223
+ obj: Any
1224
+ ) -> Any:
1225
+ """
1226
+ Convert arbitrarily nested Python data (dict/list/scalars) into ruamel.yaml
1227
+ CommentedMap/CommentedSeq equivalents using iterative DFS. Scalars are returned as-is.
1228
+ """
1229
+ # Fast path for scalars
1230
+ if not isinstance(obj, (dict, list)):
1231
+ return obj
1232
+
1233
+ # Create root container
1234
+ if isinstance(obj, dict):
1235
+ root_out: Any = CommentedMap()
1236
+ work: list[tuple[Any, CommentedMap | CommentedSeq | None, Any | None]] = [(obj, None, None)]
1237
+ else:
1238
+ root_out = CommentedSeq()
1239
+ work = [(obj, None, None)]
1240
+
1241
+
1242
+
1243
+ # Map from input container id to output container to avoid recreating
1244
+ created: dict[int, CommentedMap | CommentedSeq] = {id(obj): root_out}
1245
+
1246
+
1247
+ while work:
1248
+ in_node, out_parent, out_key = work.pop()
1249
+
1250
+ if isinstance(in_node, dict):
1251
+ out_container = created.get(id(in_node))
1252
+ if out_container is None:
1253
+ out_container = CommentedMap()
1254
+ created[id(in_node)] = out_container
1255
+ assign(out_parent, out_key, out_container)
1256
+ else:
1257
+ # Root case: already created and assigned
1258
+ assign(out_parent, out_key, out_container)
1259
+
1260
+ # Push children in reverse to process first child next (DFS)
1261
+ items = list(in_node.items())
1262
+ for k, v in reversed(items):
1263
+ if isinstance(v, (dict, list)):
1264
+ # Create child container placeholder now for correct parent linkage
1265
+ child_container = CommentedMap() if isinstance(v, dict) else CommentedSeq()
1266
+ created[id(v)] = child_container
1267
+ work.append((v, out_container, k))
1268
+ else:
1269
+ # Scalar, assign directly
1270
+ out_container[k] = v
1271
+
1272
+ elif isinstance(in_node, list):
1273
+ out_container = created.get(id(in_node))
1274
+ if out_container is None:
1275
+ out_container = CommentedSeq()
1276
+ created[id(in_node)] = out_container
1277
+ assign(out_parent, out_key, out_container)
1278
+ else:
1279
+ assign(out_parent, out_key, out_container)
1280
+
1281
+ # Push children in reverse order
1282
+ for idx in reversed(range(len(in_node))):
1283
+ v = in_node[idx]
1284
+ if isinstance(v, (dict, list)):
1285
+ child_container = CommentedMap() if isinstance(v, dict) else CommentedSeq()
1286
+ created[id(v)] = child_container
1287
+ work.append((v, out_container, idx))
1288
+ else:
1289
+ out_container.append(v)
1290
+
1291
+ else:
1292
+ # Scalar node
1293
+ assign(out_parent, out_key, in_node)
1294
+
1295
+ return root_out
1296
+
1297
+ def _resolve_template_string(self, template: str):
1298
+ variables: list[tuple[str, str]] = []
1299
+ for match in self._sub_pattern.finditer(template):
1300
+ variables.append((
1301
+ match.group(0),
1302
+ self._sub_inner_text_pattern.sub('', match.group(0)),
1303
+ ))
1304
+
1305
+ return variables
1306
+
1307
+ def _resolve_sub_ref_queries(
1308
+ self,
1309
+ variables: list[tuple[str, str]],
1310
+ source_string: str,
1311
+ ):
1312
+ for variable, accessor in variables:
1313
+ if val := self._references.get(accessor):
1314
+ source_string = source_string.replace(variable, val)
1315
+
1316
+ return source_string