cfn-check 0.3.3__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.

@@ -0,0 +1,740 @@
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 .utils import assign
10
+
11
+ from cfn_check.shared.types import (
12
+ Data,
13
+ Items,
14
+ YamlObject,
15
+ )
16
+
17
+ class Renderer:
18
+
19
+ def __init__(self):
20
+ self.items: Items = deque()
21
+ self._sub_pattern = re.compile(r'\$\{([\w+::]+)\}')
22
+ self._sub_inner_text_pattern = re.compile(r'[\$|\{|\}]+')
23
+ self._visited: list[str | int] = []
24
+ self._data: YamlObject = {}
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
+ }
44
+
45
+ def render(
46
+ self,
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,
52
+ ):
53
+
54
+ self._sources = list(template.keys())
55
+
56
+ self._assemble_parameters(template)
57
+
58
+ attributes = {
59
+ 'LambdaExecutionRole.Arn': 'This is a test',
60
+ 'AllSecurityGroups.Value': [
61
+ '123456',
62
+ '112211'
63
+ ]
64
+
65
+ }
66
+ if attributes:
67
+ self._attributes = self._process_attributes(attributes)
68
+
69
+ self._parameters = template.get('Parameters', CommentedMap())
70
+ if parameters:
71
+ self._parameters_with_defaults.update(parameters)
72
+
73
+ if references:
74
+ self._references.update(references)
75
+
76
+ self._mappings = template.get('Mappings', CommentedMap())
77
+
78
+ if mappings:
79
+ self._selected_mappings = mappings
80
+
81
+ self._resources = template.get('Resources', CommentedMap())
82
+
83
+ return self._resolve_tree(template)
84
+
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))
89
+
90
+ while self.items:
91
+ parent, accessor, node = self.items.pop()
92
+
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
99
+
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
105
+
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]))
110
+
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]))
117
+
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
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))
129
+
130
+ root = node
131
+
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]))
156
+
157
+ return None # No match found
158
+
159
+ def _assemble_parameters(self, resources: YamlObject):
160
+ params: dict[str, Data] = resources.get("Parameters", {})
161
+ for param_name, param in params.items():
162
+ if default := param.get("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()
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] = []
268
+
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(
432
+ self,
433
+ root: CommentedMap,
434
+ source: CommentedSeq | CommentedMap | TaggedScalar,
435
+ ):
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():
628
+ if (
629
+ map_data := self._mappings.get(mapping)
630
+ ) and (
631
+ selected := map_data.get(value)
632
+ ):
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