• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

localstack / localstack / 17144436094

21 Aug 2025 11:28PM UTC coverage: 86.843% (-0.03%) from 86.876%
17144436094

push

github

web-flow
APIGW: internalize DeleteIntegrationResponse (#13046)

40 of 45 new or added lines in 1 file covered. (88.89%)

235 existing lines in 11 files now uncovered.

67068 of 77229 relevant lines covered (86.84%)

0.87 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.9
/localstack-core/localstack/services/cloudformation/engine/v2/change_set_model_preproc.py
1
from __future__ import annotations
1✔
2

3
import base64
1✔
4
import copy
1✔
5
import re
1✔
6
from collections.abc import Callable
1✔
7
from typing import Any, Final, Generic, TypeVar
1✔
8

9
from botocore.exceptions import ClientError
1✔
10

11
from localstack import config
1✔
12
from localstack.aws.api.cloudformation import ResourceStatus
1✔
13
from localstack.aws.api.ec2 import AvailabilityZoneList, DescribeAvailabilityZonesResult
1✔
14
from localstack.aws.connect import connect_to
1✔
15
from localstack.services.cloudformation.engine.v2.change_set_model import (
1✔
16
    ChangeSetEntity,
17
    ChangeType,
18
    Maybe,
19
    NodeArray,
20
    NodeCondition,
21
    NodeDependsOn,
22
    NodeDivergence,
23
    NodeIntrinsicFunction,
24
    NodeMapping,
25
    NodeObject,
26
    NodeOutput,
27
    NodeOutputs,
28
    NodeParameter,
29
    NodeParameters,
30
    NodeProperties,
31
    NodeProperty,
32
    NodeResource,
33
    NodeTemplate,
34
    Nothing,
35
    NothingType,
36
    Scope,
37
    TerminalValue,
38
    TerminalValueCreated,
39
    TerminalValueModified,
40
    TerminalValueRemoved,
41
    TerminalValueUnchanged,
42
    is_nothing,
43
)
44
from localstack.services.cloudformation.engine.v2.change_set_model_visitor import (
1✔
45
    ChangeSetModelVisitor,
46
)
47
from localstack.services.cloudformation.engine.v2.resolving import (
1✔
48
    extract_dynamic_reference,
49
    perform_dynamic_reference_lookup,
50
)
51
from localstack.services.cloudformation.engine.validations import ValidationError
1✔
52
from localstack.services.cloudformation.stores import (
1✔
53
    exports_map,
54
)
55
from localstack.services.cloudformation.v2.entities import ChangeSet
1✔
56
from localstack.services.cloudformation.v2.types import ResolvedResource
1✔
57
from localstack.utils.aws.arns import get_partition
1✔
58
from localstack.utils.objects import get_value_from_path
1✔
59
from localstack.utils.run import to_str
1✔
60
from localstack.utils.strings import to_bytes
1✔
61
from localstack.utils.urls import localstack_host
1✔
62

63
_AWS_URL_SUFFIX = localstack_host().host  # The value in AWS is "amazonaws.com"
1✔
64

65
_PSEUDO_PARAMETERS: Final[set[str]] = {
1✔
66
    "AWS::Partition",
67
    "AWS::AccountId",
68
    "AWS::Region",
69
    "AWS::StackName",
70
    "AWS::StackId",
71
    "AWS::URLSuffix",
72
    "AWS::NoValue",
73
    "AWS::NotificationARNs",
74
}
75

76
TBefore = TypeVar("TBefore")
1✔
77
TAfter = TypeVar("TAfter")
1✔
78

79
MOCKED_REFERENCE = "unknown"
1✔
80

81
VALID_LOGICAL_RESOURCE_ID_RE = re.compile(r"^[A-Za-z0-9]+$")
1✔
82

83

84
class PreprocEntityDelta(Generic[TBefore, TAfter]):
1✔
85
    before: Maybe[TBefore]
1✔
86
    after: Maybe[TAfter]
1✔
87

88
    def __init__(self, before: Maybe[TBefore] = Nothing, after: Maybe[TAfter] = Nothing):
1✔
89
        self.before = before
1✔
90
        self.after = after
1✔
91

92
    def __eq__(self, other):
1✔
93
        if not isinstance(other, PreprocEntityDelta):
×
UNCOV
94
            return False
×
UNCOV
95
        return self.before == other.before and self.after == other.after
×
96

97

98
class PreprocProperties:
1✔
99
    properties: dict[str, Any]
1✔
100

101
    def __init__(self, properties: dict[str, Any]):
1✔
102
        self.properties = properties
1✔
103

104
    def __eq__(self, other):
1✔
105
        if not isinstance(other, PreprocProperties):
1✔
UNCOV
106
            return False
×
107
        return self.properties == other.properties
1✔
108

109

110
class PreprocResource:
1✔
111
    logical_id: str
1✔
112
    physical_resource_id: str | None
1✔
113
    condition: bool | None
1✔
114
    resource_type: str
1✔
115
    properties: PreprocProperties
1✔
116
    depends_on: list[str] | None
1✔
117
    requires_replacement: bool
1✔
118
    status: ResourceStatus | None
1✔
119

120
    def __init__(
1✔
121
        self,
122
        logical_id: str,
123
        physical_resource_id: str,
124
        condition: bool | None,
125
        resource_type: str,
126
        properties: PreprocProperties,
127
        depends_on: list[str] | None,
128
        requires_replacement: bool,
129
        status: ResourceStatus | None = None,
130
    ):
131
        self.logical_id = logical_id
1✔
132
        self.physical_resource_id = physical_resource_id
1✔
133
        self.condition = condition
1✔
134
        self.resource_type = resource_type
1✔
135
        self.properties = properties
1✔
136
        self.depends_on = depends_on
1✔
137
        self.requires_replacement = requires_replacement
1✔
138
        self.status = status
1✔
139

140
    @staticmethod
1✔
141
    def _compare_conditions(c1: bool, c2: bool):
1✔
142
        # The lack of condition equates to a true condition.
143
        c1 = c1 if isinstance(c1, bool) else True
1✔
144
        c2 = c2 if isinstance(c2, bool) else True
1✔
145
        return c1 == c2
1✔
146

147
    def __eq__(self, other):
1✔
148
        if not isinstance(other, PreprocResource):
1✔
149
            return False
1✔
150
        return all(
1✔
151
            [
152
                self.logical_id == other.logical_id,
153
                self._compare_conditions(self.condition, other.condition),
154
                self.resource_type == other.resource_type,
155
                self.properties == other.properties,
156
            ]
157
        )
158

159

160
class PreprocOutput:
1✔
161
    name: str
1✔
162
    value: Any
1✔
163
    export: Any | None
1✔
164
    condition: bool | None
1✔
165

166
    def __init__(self, name: str, value: Any, export: Any | None, condition: bool | None):
1✔
167
        self.name = name
1✔
168
        self.value = value
1✔
169
        self.export = export
1✔
170
        self.condition = condition
1✔
171

172
    def __eq__(self, other):
1✔
UNCOV
173
        if not isinstance(other, PreprocOutput):
×
UNCOV
174
            return False
×
UNCOV
175
        return all(
×
176
            [
177
                self.name == other.name,
178
                self.value == other.value,
179
                self.export == other.export,
180
                self.condition == other.condition,
181
            ]
182
        )
183

184

185
class ChangeSetModelPreproc(ChangeSetModelVisitor):
1✔
186
    _change_set: Final[ChangeSet]
1✔
187
    _before_resolved_resources: Final[dict]
1✔
188
    _before_cache: Final[dict[Scope, Any]]
1✔
189
    _after_cache: Final[dict[Scope, Any]]
1✔
190

191
    def __init__(self, change_set: ChangeSet):
1✔
192
        self._change_set = change_set
1✔
193
        self._before_resolved_resources = change_set.stack.resolved_resources
1✔
194
        self._before_cache = {}
1✔
195
        self._after_cache = {}
1✔
196

197
    def _setup_runtime_cache(self) -> None:
1✔
198
        runtime_cache_key = self.__class__.__name__
1✔
199

200
        self._before_cache.clear()
1✔
201
        self._after_cache.clear()
1✔
202

203
        before_runtime_cache = self._change_set.update_model.before_runtime_cache
1✔
204
        if cache := before_runtime_cache.get(runtime_cache_key):
1✔
205
            self._before_cache.update(cache)
1✔
206

207
        after_runtime_cache = self._change_set.update_model.after_runtime_cache
1✔
208
        if cache := after_runtime_cache.get(runtime_cache_key):
1✔
UNCOV
209
            self._after_cache.update(cache)
×
210

211
    def _save_runtime_cache(self) -> None:
1✔
212
        runtime_cache_key = self.__class__.__name__
1✔
213

214
        before_runtime_cache = self._change_set.update_model.before_runtime_cache
1✔
215
        before_runtime_cache[runtime_cache_key] = copy.deepcopy(self._before_cache)
1✔
216

217
        after_runtime_cache = self._change_set.update_model.after_runtime_cache
1✔
218
        after_runtime_cache[runtime_cache_key] = copy.deepcopy(self._after_cache)
1✔
219

220
    def process(self) -> None:
1✔
221
        self._setup_runtime_cache()
1✔
222
        node_template = self._change_set.update_model.node_template
1✔
223
        self.visit(node_template)
1✔
224
        self._save_runtime_cache()
1✔
225

226
    def _get_node_resource_for(
1✔
227
        self, resource_name: str, node_template: NodeTemplate
228
    ) -> NodeResource:
229
        # TODO: this could be improved with hashmap lookups if the Node contained bindings and not lists.
230
        for node_resource in node_template.resources.resources:
1✔
231
            if node_resource.name == resource_name:
1✔
232
                self.visit(node_resource)
1✔
233
                return node_resource
1✔
234
        raise ValidationError(
1✔
235
            f"Template format error: Unresolved resource dependencies [{resource_name}] in the Resources block of the template"
236
        )
237

238
    def _get_node_property_for(
1✔
239
        self, property_name: str, node_resource: NodeResource
240
    ) -> NodeProperty | None:
241
        # TODO: this could be improved with hashmap lookups if the Node contained bindings and not lists.
242
        for node_property in node_resource.properties.properties:
1✔
243
            if node_property.name == property_name:
1✔
244
                self.visit(node_property)
1✔
245
                return node_property
1✔
246
        return None
1✔
247

248
    def _deployed_property_value_of(
1✔
249
        self, resource_logical_id: str, property_name: str, resolved_resources: dict
250
    ) -> Any:
251
        # We have to override this function to make sure it does not try to access the
252
        # resolved resource
253

254
        # Before we can obtain deployed value for a resource, we need to first ensure to
255
        # process the resource if this wasn't processed already. Ideally, values should only
256
        # be accessible through delta objects, to ensure computation is always complete at
257
        # every level.
258
        _ = self._get_node_resource_for(
1✔
259
            resource_name=resource_logical_id,
260
            node_template=self._change_set.update_model.node_template,
261
        )
262
        resolved_resource = resolved_resources.get(resource_logical_id)
1✔
263
        if resolved_resource is None:
1✔
264
            raise RuntimeError(
1✔
265
                f"No deployed instances of resource '{resource_logical_id}' were found"
266
            )
267
        properties = resolved_resource.get("Properties", {})
1✔
268
        # support structured properties, e.g. NestedStack.Outputs.OutputName
269
        property_value: Any | None = get_value_from_path(properties, property_name)
1✔
270

271
        if property_value:
1✔
272
            if not isinstance(property_value, str):
1✔
273
                # TODO: is this correct? If there is a bug in the logic here, it's probably
274
                #  better to know about it with a clear error message than to receive some form
275
                #  of message about trying to use a dictionary in place of a string
UNCOV
276
                raise RuntimeError(
×
277
                    f"Accessing property '{property_name}' from '{resource_logical_id}' resulted in a non-string value"
278
                )
279
            return property_value
1✔
280
        elif config.CFN_IGNORE_UNSUPPORTED_RESOURCE_TYPES:
1✔
281
            return MOCKED_REFERENCE
1✔
282

UNCOV
283
        return property_value
×
284

285
    def _before_deployed_property_value_of(
1✔
286
        self, resource_logical_id: str, property_name: str
287
    ) -> Any:
288
        return self._deployed_property_value_of(
1✔
289
            resource_logical_id=resource_logical_id,
290
            property_name=property_name,
291
            resolved_resources=self._before_resolved_resources,
292
        )
293

294
    def _after_deployed_property_value_of(
1✔
295
        self, resource_logical_id: str, property_name: str
296
    ) -> str | None:
297
        return self._before_deployed_property_value_of(
1✔
298
            resource_logical_id=resource_logical_id, property_name=property_name
299
        )
300

301
    def _get_node_mapping(self, map_name: str) -> NodeMapping:
1✔
302
        mappings: list[NodeMapping] = self._change_set.update_model.node_template.mappings.mappings
1✔
303
        # TODO: another scenarios suggesting property lookups might be preferable.
304
        for mapping in mappings:
1✔
305
            if mapping.name == map_name:
1✔
306
                self.visit(mapping)
1✔
307
                return mapping
1✔
UNCOV
308
        raise RuntimeError(f"Undefined '{map_name}' mapping")
×
309

310
    def _get_node_parameter_if_exists(self, parameter_name: str) -> Maybe[NodeParameter]:
1✔
311
        parameters: list[NodeParameter] = (
1✔
312
            self._change_set.update_model.node_template.parameters.parameters
313
        )
314
        # TODO: another scenarios suggesting property lookups might be preferable.
315
        for parameter in parameters:
1✔
316
            if parameter.name == parameter_name:
1✔
317
                self.visit(parameter)
1✔
318
                return parameter
1✔
319
        return Nothing
1✔
320

321
    def _get_node_condition_if_exists(self, condition_name: str) -> Maybe[NodeCondition]:
1✔
322
        conditions: list[NodeCondition] = (
1✔
323
            self._change_set.update_model.node_template.conditions.conditions
324
        )
325
        # TODO: another scenarios suggesting property lookups might be preferable.
326
        for condition in conditions:
1✔
327
            if condition.name == condition_name:
1✔
328
                self.visit(condition)
1✔
329
                return condition
1✔
UNCOV
330
        return Nothing
×
331

332
    def _resolve_condition(self, logical_id: str) -> PreprocEntityDelta:
1✔
333
        node_condition = self._get_node_condition_if_exists(condition_name=logical_id)
1✔
334
        if isinstance(node_condition, NodeCondition):
1✔
335
            condition_delta = self.visit(node_condition)
1✔
336
            return condition_delta
1✔
UNCOV
337
        raise RuntimeError(f"No condition '{logical_id}' was found.")
×
338

339
    def _resolve_pseudo_parameter(self, pseudo_parameter_name: str) -> Any:
1✔
340
        match pseudo_parameter_name:
1✔
341
            case "AWS::Partition":
1✔
342
                return get_partition(self._change_set.region_name)
1✔
343
            case "AWS::AccountId":
1✔
344
                return self._change_set.stack.account_id
1✔
345
            case "AWS::Region":
1✔
346
                return self._change_set.stack.region_name
1✔
347
            case "AWS::StackName":
1✔
348
                return self._change_set.stack.stack_name
1✔
349
            case "AWS::StackId":
1✔
350
                return self._change_set.stack.stack_id
1✔
351
            case "AWS::URLSuffix":
1✔
352
                return _AWS_URL_SUFFIX
1✔
UNCOV
353
            case "AWS::NoValue":
×
UNCOV
354
                return None
×
UNCOV
355
            case _:
×
UNCOV
356
                raise RuntimeError(f"The use of '{pseudo_parameter_name}' is currently unsupported")
×
357

358
    def _resolve_reference(self, logical_id: str) -> PreprocEntityDelta:
1✔
359
        if logical_id in _PSEUDO_PARAMETERS:
1✔
360
            pseudo_parameter_value = self._resolve_pseudo_parameter(
1✔
361
                pseudo_parameter_name=logical_id
362
            )
363
            # Pseudo parameters are constants within the lifecycle of a template.
364
            return PreprocEntityDelta(before=pseudo_parameter_value, after=pseudo_parameter_value)
1✔
365

366
        node_parameter = self._get_node_parameter_if_exists(parameter_name=logical_id)
1✔
367
        if isinstance(node_parameter, NodeParameter):
1✔
368
            parameter_delta = self.visit(node_parameter)
1✔
369
            return parameter_delta
1✔
370

371
        node_resource = self._get_node_resource_for(
1✔
372
            resource_name=logical_id, node_template=self._change_set.update_model.node_template
373
        )
374
        resource_delta = self.visit(node_resource)
1✔
375
        before = resource_delta.before
1✔
376
        after = resource_delta.after
1✔
377
        return PreprocEntityDelta(before=before, after=after)
1✔
378

379
    def _resolve_mapping(
1✔
380
        self, map_name: str, top_level_key: str, second_level_key
381
    ) -> PreprocEntityDelta:
382
        # TODO: add support for nested intrinsic functions, and KNOWN AFTER APPLY logical ids.
383
        node_mapping: NodeMapping = self._get_node_mapping(map_name=map_name)
1✔
384
        top_level_value = node_mapping.bindings.bindings.get(top_level_key)
1✔
385
        if not isinstance(top_level_value, NodeObject):
1✔
386
            error_key = "::".join([map_name, top_level_key, second_level_key])
1✔
387
            raise ValidationError(f"Template error: Unable to get mapping for {error_key}")
1✔
388
        second_level_value = top_level_value.bindings.get(second_level_key)
1✔
389
        if not isinstance(second_level_value, (TerminalValue, NodeArray, NodeObject)):
1✔
390
            error_key = "::".join([map_name, top_level_key, second_level_key])
1✔
391
            raise ValidationError(f"Template error: Unable to get mapping for {error_key}")
1✔
392
        mapping_value_delta = self.visit(second_level_value)
1✔
393
        return mapping_value_delta
1✔
394

395
    def visit(self, change_set_entity: ChangeSetEntity) -> PreprocEntityDelta:
1✔
396
        entity_scope = change_set_entity.scope
1✔
397
        if entity_scope in self._before_cache and entity_scope in self._after_cache:
1✔
398
            before = self._before_cache[entity_scope]
1✔
399
            after = self._after_cache[entity_scope]
1✔
400
            return PreprocEntityDelta(before=before, after=after)
1✔
401
        delta = super().visit(change_set_entity=change_set_entity)
1✔
402
        if isinstance(delta, PreprocEntityDelta):
1✔
403
            self._before_cache[entity_scope] = delta.before
1✔
404
            self._after_cache[entity_scope] = delta.after
1✔
405
        return delta
1✔
406

407
    def _cached_apply(
1✔
408
        self, scope: Scope, arguments_delta: PreprocEntityDelta, resolver: Callable[[Any], Any]
409
    ) -> PreprocEntityDelta:
410
        """
411
        Applies the resolver function to the given input delta if and only if the required
412
        values are not already present in the runtime caches. This function handles both
413
        the 'before' and 'after' components of the delta independently.
414

415
        The resolver function receives either the 'before' or 'after' value from the input
416
        delta and returns a resolved value. If the result returned by the resolver is
417
        itself a PreprocEntityDelta, the function automatically extracts the appropriate
418
        component from it:  the 'before' value if the input was 'before', and the 'after'
419
        value if the input was 'after'.
420

421
        This function only reads from the cache and does not update it. It is the caller's
422
        responsibility to handle caching, either manually or via the upstream visit method
423
        of this class.
424

425
        Args:
426
            scope (Scope): The current scope used as a key for cache lookup.
427
            arguments_delta (PreprocEntityDelta): The delta containing 'before' and 'after' values to resolve.
428
            resolver (Callable[[Any], Any]): Function to apply on uncached 'before' or 'after' argument values.
429

430
        Returns:
431
            PreprocEntityDelta: A new delta with resolved 'before' and 'after' values.
432
        """
433

434
        # TODO: Update all visit_* methods in this class and its subclasses to use this function.
435
        #       This ensures maximal reuse of precomputed 'before' (and 'after') values from
436
        #       prior runtimes on the change sets template, thus avoiding unnecessary recomputation.
437

438
        arguments_before = arguments_delta.before
1✔
439
        arguments_after = arguments_delta.after
1✔
440

441
        before = self._before_cache.get(scope, Nothing)
1✔
442
        if is_nothing(before) and not is_nothing(arguments_before):
1✔
443
            before = resolver(arguments_before)
1✔
444
            if isinstance(before, PreprocEntityDelta):
1✔
445
                before = before.before
1✔
446

447
        after = self._after_cache.get(scope, Nothing)
1✔
448
        if is_nothing(after) and not is_nothing(arguments_after):
1✔
449
            after = resolver(arguments_after)
1✔
450
            if isinstance(after, PreprocEntityDelta):
1✔
451
                after = after.after
1✔
452

453
        return PreprocEntityDelta(before=before, after=after)
1✔
454

455
    def visit_terminal_value_modified(
1✔
456
        self, terminal_value_modified: TerminalValueModified
457
    ) -> PreprocEntityDelta:
458
        return PreprocEntityDelta(
1✔
459
            before=terminal_value_modified.value,
460
            after=terminal_value_modified.modified_value,
461
        )
462

463
    def visit_terminal_value_created(
1✔
464
        self, terminal_value_created: TerminalValueCreated
465
    ) -> PreprocEntityDelta:
466
        return PreprocEntityDelta(after=terminal_value_created.value)
1✔
467

468
    def visit_terminal_value_removed(
1✔
469
        self, terminal_value_removed: TerminalValueRemoved
470
    ) -> PreprocEntityDelta:
471
        return PreprocEntityDelta(before=terminal_value_removed.value)
1✔
472

473
    def visit_terminal_value_unchanged(
1✔
474
        self, terminal_value_unchanged: TerminalValueUnchanged
475
    ) -> PreprocEntityDelta:
476
        return PreprocEntityDelta(
1✔
477
            before=terminal_value_unchanged.value,
478
            after=terminal_value_unchanged.value,
479
        )
480

481
    def visit_node_divergence(self, node_divergence: NodeDivergence) -> PreprocEntityDelta:
1✔
482
        before_delta = self.visit(node_divergence.value)
1✔
483
        after_delta = self.visit(node_divergence.divergence)
1✔
484
        return PreprocEntityDelta(before=before_delta.before, after=after_delta.after)
1✔
485

486
    def visit_node_object(self, node_object: NodeObject) -> PreprocEntityDelta:
1✔
487
        node_change_type = node_object.change_type
1✔
488
        before = {} if node_change_type != ChangeType.CREATED else Nothing
1✔
489
        after = {} if node_change_type != ChangeType.REMOVED else Nothing
1✔
490
        for name, change_set_entity in node_object.bindings.items():
1✔
491
            delta: PreprocEntityDelta = self.visit(change_set_entity=change_set_entity)
1✔
492
            delta_before = delta.before
1✔
493
            delta_after = delta.after
1✔
494
            if not is_nothing(before) and not is_nothing(delta_before):
1✔
495
                before[name] = delta_before
1✔
496
            if not is_nothing(after) and not is_nothing(delta_after):
1✔
497
                after[name] = delta_after
1✔
498
        return PreprocEntityDelta(before=before, after=after)
1✔
499

500
    def _resolve_attribute(self, arguments: str | list[str], select_before: bool) -> str:
1✔
501
        # TODO: add arguments validation.
502
        arguments_list: list[str]
503
        if isinstance(arguments, str):
1✔
504
            arguments_list = arguments.split(".")
1✔
505
        else:
506
            arguments_list = arguments
1✔
507
        logical_name_of_resource = arguments_list[0]
1✔
508
        attribute_name = arguments_list[1]
1✔
509

510
        node_resource = self._get_node_resource_for(
1✔
511
            resource_name=logical_name_of_resource,
512
            node_template=self._change_set.update_model.node_template,
513
        )
514
        node_property: NodeProperty | None = self._get_node_property_for(
1✔
515
            property_name=attribute_name, node_resource=node_resource
516
        )
517
        if node_property is not None:
1✔
518
            # The property is statically defined in the template and its value can be computed.
519
            property_delta = self.visit(node_property)
1✔
520
            value = property_delta.before if select_before else property_delta.after
1✔
521
        else:
522
            # The property is not statically defined and must therefore be available in
523
            # the properties deployed set.
524
            if select_before:
1✔
525
                value = self._before_deployed_property_value_of(
1✔
526
                    resource_logical_id=logical_name_of_resource,
527
                    property_name=attribute_name,
528
                )
529
            else:
530
                value = self._after_deployed_property_value_of(
1✔
531
                    resource_logical_id=logical_name_of_resource,
532
                    property_name=attribute_name,
533
                )
534
        return value
1✔
535

536
    def visit_node_intrinsic_function_fn_get_att(
1✔
537
        self, node_intrinsic_function: NodeIntrinsicFunction
538
    ) -> PreprocEntityDelta:
539
        # TODO: validate the return value according to the spec.
540
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
541
        before_arguments: Maybe[str | list[str]] = arguments_delta.before
1✔
542
        after_arguments: Maybe[str | list[str]] = arguments_delta.after
1✔
543

544
        before = self._before_cache.get(node_intrinsic_function.scope, Nothing)
1✔
545
        if is_nothing(before) and not is_nothing(before_arguments):
1✔
546
            before = self._resolve_attribute(arguments=before_arguments, select_before=True)
1✔
547

548
        after = self._after_cache.get(node_intrinsic_function.scope, Nothing)
1✔
549
        if is_nothing(after) and not is_nothing(after_arguments):
1✔
550
            after = self._resolve_attribute(arguments=after_arguments, select_before=False)
1✔
551

552
        return PreprocEntityDelta(before=before, after=after)
1✔
553

554
    def visit_node_intrinsic_function_fn_equals(
1✔
555
        self, node_intrinsic_function: NodeIntrinsicFunction
556
    ) -> PreprocEntityDelta:
557
        # TODO: add argument shape validation.
558
        def _compute_fn_equals(args: list[Any]) -> bool:
1✔
559
            return args[0] == args[1]
1✔
560

561
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
562
        delta = self._cached_apply(
1✔
563
            scope=node_intrinsic_function.scope,
564
            arguments_delta=arguments_delta,
565
            resolver=_compute_fn_equals,
566
        )
567
        return delta
1✔
568

569
    def visit_node_intrinsic_function_fn_if(
1✔
570
        self, node_intrinsic_function: NodeIntrinsicFunction
571
    ) -> PreprocEntityDelta:
572
        # `if` needs to be short-circuiting i.e. if the condition is True we don't evaluate the
573
        # False branch. If the condition is False, we don't evaluate the True branch.
574
        if len(node_intrinsic_function.arguments.array) != 3:
1✔
UNCOV
575
            raise ValueError(
×
576
                f"Incorrectly constructed Fn::If usage, expected 3 arguments, found {len(node_intrinsic_function.arguments.array)}"
577
            )
578

579
        condition_delta = self.visit(node_intrinsic_function.arguments.array[0])
1✔
580
        if_delta = PreprocEntityDelta()
1✔
581
        if not is_nothing(condition_delta.before):
1✔
582
            node_condition = self._get_node_condition_if_exists(
1✔
583
                condition_name=condition_delta.before
584
            )
585
            condition_value = self.visit(node_condition).before
1✔
586
            if condition_value:
1✔
587
                arg_delta = self.visit(node_intrinsic_function.arguments.array[1])
1✔
588
            else:
589
                arg_delta = self.visit(node_intrinsic_function.arguments.array[2])
1✔
590
            if_delta.before = arg_delta.before
1✔
591

592
        if not is_nothing(condition_delta.after):
1✔
593
            node_condition = self._get_node_condition_if_exists(
1✔
594
                condition_name=condition_delta.after
595
            )
596
            condition_value = self.visit(node_condition).after
1✔
597
            if condition_value:
1✔
598
                arg_delta = self.visit(node_intrinsic_function.arguments.array[1])
1✔
599
            else:
600
                arg_delta = self.visit(node_intrinsic_function.arguments.array[2])
1✔
601
            if_delta.after = arg_delta.after
1✔
602

603
        return if_delta
1✔
604

605
    def visit_node_intrinsic_function_fn_and(
1✔
606
        self, node_intrinsic_function: NodeIntrinsicFunction
607
    ) -> PreprocEntityDelta:
608
        def _compute_fn_and(args: list[bool]) -> bool:
1✔
609
            result = all(args)
1✔
610
            return result
1✔
611

612
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
613
        delta = self._cached_apply(
1✔
614
            scope=node_intrinsic_function.scope,
615
            arguments_delta=arguments_delta,
616
            resolver=_compute_fn_and,
617
        )
618
        return delta
1✔
619

620
    def visit_node_intrinsic_function_fn_or(
1✔
621
        self, node_intrinsic_function: NodeIntrinsicFunction
622
    ) -> PreprocEntityDelta:
623
        def _compute_fn_or(args: list[bool]):
1✔
624
            result = any(args)
1✔
625
            return result
1✔
626

627
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
628
        delta = self._cached_apply(
1✔
629
            scope=node_intrinsic_function.scope,
630
            arguments_delta=arguments_delta,
631
            resolver=_compute_fn_or,
632
        )
633
        return delta
1✔
634

635
    def visit_node_intrinsic_function_fn_not(
1✔
636
        self, node_intrinsic_function: NodeIntrinsicFunction
637
    ) -> PreprocEntityDelta:
638
        def _compute_fn_not(arg: list[bool] | bool) -> bool:
1✔
639
            # Is the argument ever a lone boolean?
640
            if isinstance(arg, list):
1✔
641
                return not arg[0]
1✔
642
            else:
UNCOV
643
                return not arg
×
644

645
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
646
        delta = self._cached_apply(
1✔
647
            scope=node_intrinsic_function.scope,
648
            arguments_delta=arguments_delta,
649
            resolver=_compute_fn_not,
650
        )
651
        return delta
1✔
652

653
    def visit_node_intrinsic_function_fn_sub(
1✔
654
        self, node_intrinsic_function: NodeIntrinsicFunction
655
    ) -> PreprocEntityDelta:
656
        def _compute_sub(args: str | list[Any], select_before: bool) -> str:
1✔
657
            # TODO: add further schema validation.
658
            string_template: str
659
            sub_parameters: dict
660
            if isinstance(args, str):
1✔
661
                string_template = args
1✔
662
                sub_parameters = {}
1✔
663
            elif (
1✔
664
                isinstance(args, list)
665
                and len(args) == 2
666
                and isinstance(args[0], str)
667
                and isinstance(args[1], dict)
668
            ):
669
                string_template = args[0]
1✔
670
                sub_parameters = args[1]
1✔
671
            else:
UNCOV
672
                raise RuntimeError(
×
673
                    "Invalid arguments shape for Fn::Sub, expected a String "
674
                    f"or a Tuple of String and Map but got '{args}'"
675
                )
676
            sub_string = string_template
1✔
677
            template_variable_names = re.findall("\\${([^}]+)}", string_template)
1✔
678
            for template_variable_name in template_variable_names:
1✔
679
                template_variable_value = Nothing
1✔
680

681
                # Try to resolve the variable name as pseudo parameter.
682
                if template_variable_name in _PSEUDO_PARAMETERS:
1✔
683
                    template_variable_value = self._resolve_pseudo_parameter(
1✔
684
                        pseudo_parameter_name=template_variable_name
685
                    )
686

687
                # Try to resolve the variable name as an entry to the defined parameters.
688
                elif template_variable_name in sub_parameters:
1✔
689
                    template_variable_value = sub_parameters[template_variable_name]
1✔
690

691
                # Try to resolve the variable name as GetAtt.
692
                elif "." in template_variable_name:
1✔
693
                    try:
1✔
694
                        template_variable_value = self._resolve_attribute(
1✔
695
                            arguments=template_variable_name, select_before=select_before
696
                        )
697
                    except RuntimeError:
1✔
698
                        pass
1✔
699

700
                # Try to resolve the variable name as Ref.
701
                else:
702
                    try:
1✔
703
                        resource_delta = self._resolve_reference(logical_id=template_variable_name)
1✔
704
                        template_variable_value = (
1✔
705
                            resource_delta.before if select_before else resource_delta.after
706
                        )
707
                        if isinstance(template_variable_value, PreprocResource):
1✔
708
                            template_variable_value = template_variable_value.physical_resource_id
1✔
UNCOV
709
                    except RuntimeError:
×
UNCOV
710
                        pass
×
711

712
                if is_nothing(template_variable_value):
1✔
713
                    raise RuntimeError(
1✔
714
                        f"Undefined variable name in Fn::Sub string template '{template_variable_name}'"
715
                    )
716

717
                if not isinstance(template_variable_value, str):
1✔
718
                    template_variable_value = str(template_variable_value)
1✔
719

720
                sub_string = sub_string.replace(
1✔
721
                    f"${{{template_variable_name}}}", template_variable_value
722
                )
723

724
            # FIXME: the following type reduction is ported from v1; however it appears as though such
725
            #        reduction is not performed by the engine, and certainly not at this depth given the
726
            #        lack of context. This section should be removed with Fn::Sub always retuning a string
727
            #        and the resource providers reviewed.
728
            account_id = self._change_set.account_id
1✔
729
            is_another_account_id = sub_string.isdigit() and len(sub_string) == len(account_id)
1✔
730
            if sub_string == account_id or is_another_account_id:
1✔
731
                result = sub_string
1✔
732
            elif sub_string.isdigit():
1✔
733
                result = int(sub_string)
1✔
734
            else:
735
                try:
1✔
736
                    result = float(sub_string)
1✔
737
                except ValueError:
1✔
738
                    result = sub_string
1✔
739
            return result
1✔
740

741
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
742
        arguments_before = arguments_delta.before
1✔
743
        arguments_after = arguments_delta.after
1✔
744
        before = self._before_cache.get(node_intrinsic_function.scope, Nothing)
1✔
745
        if is_nothing(before) and not is_nothing(arguments_before):
1✔
746
            before = _compute_sub(args=arguments_before, select_before=True)
1✔
747
        after = self._after_cache.get(node_intrinsic_function.scope, Nothing)
1✔
748
        if is_nothing(after) and not is_nothing(arguments_after):
1✔
749
            after = _compute_sub(args=arguments_after, select_before=False)
1✔
750
        return PreprocEntityDelta(before=before, after=after)
1✔
751

752
    def visit_node_intrinsic_function_fn_join(
1✔
753
        self, node_intrinsic_function: NodeIntrinsicFunction
754
    ) -> PreprocEntityDelta:
755
        # TODO: add support for schema validation.
756
        # TODO: add tests for joining non string values.
757
        def _compute_fn_join(args: list[Any]) -> str | NothingType:
1✔
758
            if not (isinstance(args, list) and len(args) == 2):
1✔
759
                return Nothing
1✔
760
            delimiter: str = str(args[0])
1✔
761
            values: list[Any] = args[1]
1✔
762
            if not isinstance(values, list):
1✔
763
                # shortcut if values is the empty string, for example:
764
                # {"Fn::Join": ["", {"Ref": <parameter>}]}
765
                # CDK bootstrap does this
UNCOV
766
                if values == "":
×
UNCOV
767
                    return ""
×
UNCOV
768
                raise RuntimeError(f"Invalid arguments list definition for Fn::Join: '{args}'")
×
769
            str_values: list[str] = []
1✔
770
            for value in values:
1✔
771
                if value is None:
1✔
772
                    continue
1✔
773
                str_value = str(value)
1✔
774
                str_values.append(str_value)
1✔
775
            join_result = delimiter.join(str_values)
1✔
776
            return join_result
1✔
777

778
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
779
        delta = self._cached_apply(
1✔
780
            scope=node_intrinsic_function.scope,
781
            arguments_delta=arguments_delta,
782
            resolver=_compute_fn_join,
783
        )
784
        return delta
1✔
785

786
    def visit_node_intrinsic_function_fn_select(
1✔
787
        self, node_intrinsic_function: NodeIntrinsicFunction
788
    ):
789
        # TODO: add further support for schema validation
790
        def _compute_fn_select(args: list[Any]) -> Any:
1✔
791
            values: list[Any] = args[1]
1✔
792
            if not isinstance(values, list) or not values:
1✔
UNCOV
793
                raise RuntimeError(f"Invalid arguments list value for Fn::Select: '{values}'")
×
794
            values_len = len(values)
1✔
795
            index: int = int(args[0])
1✔
796
            if not isinstance(index, int) or index < 0 or index > values_len:
1✔
797
                raise RuntimeError(f"Invalid or out of range index value for Fn::Select: '{index}'")
1✔
798
            selection = values[index]
1✔
799
            return selection
1✔
800

801
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
802
        delta = self._cached_apply(
1✔
803
            scope=node_intrinsic_function.scope,
804
            arguments_delta=arguments_delta,
805
            resolver=_compute_fn_select,
806
        )
807
        return delta
1✔
808

809
    def visit_node_intrinsic_function_fn_split(
1✔
810
        self, node_intrinsic_function: NodeIntrinsicFunction
811
    ):
812
        # TODO: add further support for schema validation
813
        def _compute_fn_split(args: list[Any]) -> Any:
1✔
814
            delimiter = args[0]
1✔
815
            if not isinstance(delimiter, str) or not delimiter:
1✔
UNCOV
816
                raise RuntimeError(f"Invalid delimiter value for Fn::Split: '{delimiter}'")
×
817
            source_string = args[1]
1✔
818
            if not isinstance(source_string, str):
1✔
819
                raise RuntimeError(f"Invalid source string value for Fn::Split: '{source_string}'")
1✔
820
            split_string = source_string.split(delimiter)
1✔
821
            return split_string
1✔
822

823
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
824
        delta = self._cached_apply(
1✔
825
            scope=node_intrinsic_function.scope,
826
            arguments_delta=arguments_delta,
827
            resolver=_compute_fn_split,
828
        )
829
        return delta
1✔
830

831
    def visit_node_intrinsic_function_fn_get_a_zs(
1✔
832
        self, node_intrinsic_function: NodeIntrinsicFunction
833
    ) -> PreprocEntityDelta:
834
        # TODO: add further support for schema validation
835

836
        def _compute_fn_get_a_zs(region) -> Any:
1✔
837
            if not isinstance(region, str):
1✔
UNCOV
838
                raise RuntimeError(f"Invalid region value for Fn::GetAZs: '{region}'")
×
839

840
            if not region:
1✔
841
                region = self._change_set.region_name
1✔
842

843
            account_id = self._change_set.account_id
1✔
844
            ec2_client = connect_to(aws_access_key_id=account_id, region_name=region).ec2
1✔
845
            try:
1✔
846
                get_availability_zones_result: DescribeAvailabilityZonesResult = (
1✔
847
                    ec2_client.describe_availability_zones()
848
                )
UNCOV
849
            except ClientError:
×
UNCOV
850
                raise RuntimeError(
×
851
                    "Could not describe zones availability whilst evaluating Fn::GetAZs"
852
                )
853
            availability_zones: AvailabilityZoneList = get_availability_zones_result[
1✔
854
                "AvailabilityZones"
855
            ]
856
            azs = [az["ZoneName"] for az in availability_zones]
1✔
857
            return azs
1✔
858

859
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
860
        delta = self._cached_apply(
1✔
861
            scope=node_intrinsic_function.scope,
862
            arguments_delta=arguments_delta,
863
            resolver=_compute_fn_get_a_zs,
864
        )
865
        return delta
1✔
866

867
    def visit_node_intrinsic_function_fn_base64(
1✔
868
        self, node_intrinsic_function: NodeIntrinsicFunction
869
    ) -> PreprocEntityDelta:
870
        # TODO: add further support for schema validation
871
        def _compute_fn_base_64(string) -> Any:
1✔
872
            if not isinstance(string, str):
1✔
UNCOV
873
                raise RuntimeError(f"Invalid valueToEncode for Fn::Base64: '{string}'")
×
874
            # Ported from v1:
875
            base64_string = to_str(base64.b64encode(to_bytes(string)))
1✔
876
            return base64_string
1✔
877

878
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
879
        delta = self._cached_apply(
1✔
880
            scope=node_intrinsic_function.scope,
881
            arguments_delta=arguments_delta,
882
            resolver=_compute_fn_base_64,
883
        )
884
        return delta
1✔
885

886
    def visit_node_intrinsic_function_fn_find_in_map(
1✔
887
        self, node_intrinsic_function: NodeIntrinsicFunction
888
    ) -> PreprocEntityDelta:
889
        # TODO: add type checking/validation for result unit?
890
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
891
        before_arguments = arguments_delta.before
1✔
892
        after_arguments = arguments_delta.after
1✔
893
        before = Nothing
1✔
894
        if before_arguments:
1✔
895
            before_value_delta = self._resolve_mapping(*before_arguments)
1✔
896
            before = before_value_delta.before
1✔
897
        after = Nothing
1✔
898
        if after_arguments:
1✔
899
            after_value_delta = self._resolve_mapping(*after_arguments)
1✔
900
            after = after_value_delta.after
1✔
901
        return PreprocEntityDelta(before=before, after=after)
1✔
902

903
    def visit_node_mapping(self, node_mapping: NodeMapping) -> PreprocEntityDelta:
1✔
904
        bindings_delta = self.visit(node_mapping.bindings)
1✔
905
        return bindings_delta
1✔
906

907
    def visit_node_parameters(
1✔
908
        self, node_parameters: NodeParameters
909
    ) -> PreprocEntityDelta[dict[str, Any], dict[str, Any]]:
910
        before_parameters = {}
×
911
        after_parameters = {}
×
912
        for parameter in node_parameters.parameters:
×
913
            parameter_delta = self.visit(parameter)
×
914
            parameter_before = parameter_delta.before
×
915
            if not is_nothing(parameter_before):
×
UNCOV
916
                before_parameters[parameter.name] = parameter_before
×
UNCOV
917
            parameter_after = parameter_delta.after
×
UNCOV
918
            if not is_nothing(parameter_after):
×
UNCOV
919
                after_parameters[parameter.name] = parameter_after
×
UNCOV
920
        return PreprocEntityDelta(before=before_parameters, after=after_parameters)
×
921

922
    def visit_node_parameter(self, node_parameter: NodeParameter) -> PreprocEntityDelta:
1✔
923
        dynamic_value = node_parameter.dynamic_value
1✔
924
        dynamic_delta = self.visit(dynamic_value)
1✔
925

926
        default_value = node_parameter.default_value
1✔
927
        default_delta = self.visit(default_value)
1✔
928

929
        before = dynamic_delta.before or default_delta.before
1✔
930
        after = dynamic_delta.after or default_delta.after
1✔
931

932
        parameter_type = self.visit(node_parameter.type_)
1✔
933

934
        def _resolve_parameter_type(value: str, type_: str) -> Any:
1✔
935
            match type_:
1✔
936
                case "List<String>" | "CommaDelimitedList":
1✔
937
                    return [item.strip() for item in value.split(",")]
1✔
938
            return value
1✔
939

940
        if not is_nothing(after):
1✔
941
            after = _resolve_parameter_type(after, parameter_type.after)
1✔
942

943
        return PreprocEntityDelta(before=before, after=after)
1✔
944

945
    def visit_node_depends_on(self, node_depends_on: NodeDependsOn) -> PreprocEntityDelta:
1✔
946
        array_identifiers_delta = self.visit(node_depends_on.depends_on)
1✔
947
        return array_identifiers_delta
1✔
948

949
    def visit_node_condition(self, node_condition: NodeCondition) -> PreprocEntityDelta:
1✔
950
        delta = self.visit(node_condition.body)
1✔
951
        return delta
1✔
952

953
    def _resource_physical_resource_id_from(
1✔
954
        self, logical_resource_id: str, resolved_resources: dict[str, ResolvedResource]
955
    ) -> str | None:
956
        # TODO: typing around resolved resources is needed and should be reflected here.
957
        resolved_resource = resolved_resources.get(logical_resource_id, {})
1✔
958
        if resolved_resource.get("ResourceStatus") not in {
1✔
959
            ResourceStatus.CREATE_COMPLETE,
960
            ResourceStatus.UPDATE_COMPLETE,
961
        }:
962
            return None
1✔
963

964
        physical_resource_id = resolved_resource.get("PhysicalResourceId")
1✔
965
        if not isinstance(physical_resource_id, str):
1✔
UNCOV
966
            raise RuntimeError(f"No PhysicalResourceId found for resource '{logical_resource_id}'")
×
967
        return physical_resource_id
1✔
968

969
    def _before_resource_physical_id(self, resource_logical_id: str) -> str:
1✔
970
        # TODO: typing around resolved resources is needed and should be reflected here.
971
        return self._resource_physical_resource_id_from(
1✔
972
            logical_resource_id=resource_logical_id,
973
            resolved_resources=self._before_resolved_resources,
974
        )
975

976
    def _after_resource_physical_id(self, resource_logical_id: str) -> str:
1✔
977
        return self._before_resource_physical_id(resource_logical_id=resource_logical_id)
1✔
978

979
    def visit_node_intrinsic_function_ref(
1✔
980
        self, node_intrinsic_function: NodeIntrinsicFunction
981
    ) -> PreprocEntityDelta:
982
        def _compute_fn_ref(logical_id: str) -> PreprocEntityDelta:
1✔
983
            if logical_id == "AWS::NoValue":
1✔
UNCOV
984
                return Nothing
×
985

986
            reference_delta: PreprocEntityDelta = self._resolve_reference(logical_id=logical_id)
1✔
987
            if isinstance(before := reference_delta.before, PreprocResource):
1✔
988
                reference_delta.before = before.physical_resource_id
1✔
989
            if isinstance(after := reference_delta.after, PreprocResource):
1✔
990
                reference_delta.after = after.physical_resource_id
1✔
991
            return reference_delta
1✔
992

993
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
994
        delta = self._cached_apply(
1✔
995
            scope=node_intrinsic_function.scope,
996
            arguments_delta=arguments_delta,
997
            resolver=_compute_fn_ref,
998
        )
999
        return delta
1✔
1000

1001
    def visit_node_intrinsic_function_condition(
1✔
1002
        self, node_intrinsic_function: NodeIntrinsicFunction
1003
    ) -> PreprocEntityDelta:
1004
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
1005

1006
        def _delta_of_condition(name: str) -> PreprocEntityDelta:
1✔
1007
            node_condition = self._get_node_condition_if_exists(condition_name=name)
1✔
1008
            if is_nothing(node_condition):
1✔
UNCOV
1009
                raise RuntimeError(f"Undefined condition '{name}'")
×
1010
            condition_delta = self.visit(node_condition)
1✔
1011
            return condition_delta
1✔
1012

1013
        delta = self._cached_apply(
1✔
1014
            resolver=_delta_of_condition,
1015
            scope=node_intrinsic_function.scope,
1016
            arguments_delta=arguments_delta,
1017
        )
1018
        return delta
1✔
1019

1020
    def visit_node_array(self, node_array: NodeArray) -> PreprocEntityDelta:
1✔
1021
        node_change_type = node_array.change_type
1✔
1022
        before = [] if node_change_type != ChangeType.CREATED else Nothing
1✔
1023
        after = [] if node_change_type != ChangeType.REMOVED else Nothing
1✔
1024
        for change_set_entity in node_array.array:
1✔
1025
            delta: PreprocEntityDelta = self.visit(change_set_entity=change_set_entity)
1✔
1026
            delta_before = delta.before
1✔
1027
            delta_after = delta.after
1✔
1028
            if not is_nothing(before) and not is_nothing(delta_before):
1✔
1029
                before.append(delta_before)
1✔
1030
            if not is_nothing(after) and not is_nothing(delta_after):
1✔
1031
                after.append(delta_after)
1✔
1032
        return PreprocEntityDelta(before=before, after=after)
1✔
1033

1034
    def visit_node_property(self, node_property: NodeProperty) -> PreprocEntityDelta:
1✔
1035
        # TODO: what about other positions?
1036
        value = self.visit(node_property.value)
1✔
1037
        if not is_nothing(value.before):
1✔
1038
            if dynamic_ref := extract_dynamic_reference(value.before):
1✔
1039
                value.before = perform_dynamic_reference_lookup(
1✔
1040
                    reference=dynamic_ref,
1041
                    account_id=self._change_set.account_id,
1042
                    region_name=self._change_set.region_name,
1043
                )
1044
        if not is_nothing(value.after):
1✔
1045
            if dynamic_ref := extract_dynamic_reference(value.after):
1✔
1046
                value.after = perform_dynamic_reference_lookup(
1✔
1047
                    reference=dynamic_ref,
1048
                    account_id=self._change_set.account_id,
1049
                    region_name=self._change_set.region_name,
1050
                )
1051
        return value
1✔
1052

1053
    def visit_node_properties(
1✔
1054
        self, node_properties: NodeProperties
1055
    ) -> PreprocEntityDelta[PreprocProperties, PreprocProperties]:
1056
        node_change_type = node_properties.change_type
1✔
1057
        before_bindings = {} if node_change_type != ChangeType.CREATED else Nothing
1✔
1058
        after_bindings = {} if node_change_type != ChangeType.REMOVED else Nothing
1✔
1059
        for node_property in node_properties.properties:
1✔
1060
            property_name = node_property.name
1✔
1061
            delta = self.visit(node_property)
1✔
1062
            delta_before = delta.before
1✔
1063
            delta_after = delta.after
1✔
1064
            if (
1✔
1065
                not is_nothing(before_bindings)
1066
                and not is_nothing(delta_before)
1067
                and delta_before is not None
1068
            ):
1069
                before_bindings[property_name] = delta_before
1✔
1070
            if (
1✔
1071
                not is_nothing(after_bindings)
1072
                and not is_nothing(delta_after)
1073
                and delta_after is not None
1074
            ):
1075
                after_bindings[property_name] = delta_after
1✔
1076
        before = Nothing
1✔
1077
        if not is_nothing(before_bindings):
1✔
1078
            before = PreprocProperties(properties=before_bindings)
1✔
1079
        after = Nothing
1✔
1080
        if not is_nothing(after_bindings):
1✔
1081
            after = PreprocProperties(properties=after_bindings)
1✔
1082
        return PreprocEntityDelta(before=before, after=after)
1✔
1083

1084
    def _resolve_resource_condition_reference(self, reference: TerminalValue) -> PreprocEntityDelta:
1✔
1085
        reference_delta = self.visit(reference)
1✔
1086
        before_reference = reference_delta.before
1✔
1087
        before = Nothing
1✔
1088
        if isinstance(before_reference, str):
1✔
1089
            before_delta = self._resolve_condition(logical_id=before_reference)
1✔
1090
            before = before_delta.before
1✔
1091
        after = Nothing
1✔
1092
        after_reference = reference_delta.after
1✔
1093
        if isinstance(after_reference, str):
1✔
1094
            after_delta = self._resolve_condition(logical_id=after_reference)
1✔
1095
            after = after_delta.after
1✔
1096
        return PreprocEntityDelta(before=before, after=after)
1✔
1097

1098
    def visit_node_resource(
1✔
1099
        self, node_resource: NodeResource
1100
    ) -> PreprocEntityDelta[PreprocResource, PreprocResource]:
1101
        if not VALID_LOGICAL_RESOURCE_ID_RE.match(node_resource.name):
1✔
1102
            raise ValidationError(
1✔
1103
                f"Template format error: Resource name {node_resource.name} is non alphanumeric."
1104
            )
1105
        change_type = node_resource.change_type
1✔
1106
        condition_before = Nothing
1✔
1107
        condition_after = Nothing
1✔
1108
        if not is_nothing(node_resource.condition_reference):
1✔
1109
            condition_delta = self._resolve_resource_condition_reference(
1✔
1110
                node_resource.condition_reference
1111
            )
1112
            condition_before = condition_delta.before
1✔
1113
            condition_after = condition_delta.after
1✔
1114

1115
        depends_on_before = Nothing
1✔
1116
        depends_on_after = Nothing
1✔
1117
        if not is_nothing(node_resource.depends_on):
1✔
1118
            depends_on_delta = self.visit(node_resource.depends_on)
1✔
1119
            depends_on_before = depends_on_delta.before
1✔
1120
            depends_on_after = depends_on_delta.after
1✔
1121

1122
        type_delta = self.visit(node_resource.type_)
1✔
1123
        properties_delta: PreprocEntityDelta[PreprocProperties, PreprocProperties] = self.visit(
1✔
1124
            node_resource.properties
1125
        )
1126

1127
        before = Nothing
1✔
1128
        after = Nothing
1✔
1129
        if change_type != ChangeType.CREATED and is_nothing(condition_before) or condition_before:
1✔
1130
            logical_resource_id = node_resource.name
1✔
1131
            before_physical_resource_id = self._before_resource_physical_id(
1✔
1132
                resource_logical_id=logical_resource_id
1133
            )
1134
            before = PreprocResource(
1✔
1135
                logical_id=logical_resource_id,
1136
                physical_resource_id=before_physical_resource_id,
1137
                condition=condition_before,
1138
                resource_type=type_delta.before,
1139
                properties=properties_delta.before,
1140
                depends_on=depends_on_before,
1141
                requires_replacement=False,
1142
            )
1143
        if change_type != ChangeType.REMOVED and is_nothing(condition_after) or condition_after:
1✔
1144
            logical_resource_id = node_resource.name
1✔
1145
            try:
1✔
1146
                after_physical_resource_id = self._after_resource_physical_id(
1✔
1147
                    resource_logical_id=logical_resource_id
1148
                )
UNCOV
1149
            except RuntimeError:
×
UNCOV
1150
                after_physical_resource_id = None
×
1151
            after = PreprocResource(
1✔
1152
                logical_id=logical_resource_id,
1153
                physical_resource_id=after_physical_resource_id,
1154
                condition=condition_after,
1155
                resource_type=type_delta.after,
1156
                properties=properties_delta.after,
1157
                depends_on=depends_on_after,
1158
                requires_replacement=node_resource.requires_replacement,
1159
            )
1160
        return PreprocEntityDelta(before=before, after=after)
1✔
1161

1162
    def visit_node_output(
1✔
1163
        self, node_output: NodeOutput
1164
    ) -> PreprocEntityDelta[PreprocOutput, PreprocOutput]:
1165
        change_type = node_output.change_type
1✔
1166
        value_delta = self.visit(node_output.value)
1✔
1167

1168
        condition_delta = Nothing
1✔
1169
        if not is_nothing(node_output.condition_reference):
1✔
1170
            condition_delta = self._resolve_resource_condition_reference(
1✔
1171
                node_output.condition_reference
1172
            )
1173
            condition_before = condition_delta.before
1✔
1174
            condition_after = condition_delta.after
1✔
1175
            if not condition_before and condition_after:
1✔
1176
                change_type = ChangeType.CREATED
1✔
1177
            elif condition_before and not condition_after:
1✔
1178
                change_type = ChangeType.REMOVED
1✔
1179

1180
        export_delta = Nothing
1✔
1181
        if not is_nothing(node_output.export):
1✔
1182
            export_delta = self.visit(node_output.export)
1✔
1183

1184
        before: Maybe[PreprocOutput] = Nothing
1✔
1185
        if change_type != ChangeType.CREATED:
1✔
1186
            before = PreprocOutput(
1✔
1187
                name=node_output.name,
1188
                value=value_delta.before,
1189
                export=export_delta.before if export_delta else None,
1190
                condition=condition_delta.before if condition_delta else None,
1191
            )
1192
        after: Maybe[PreprocOutput] = Nothing
1✔
1193
        if change_type != ChangeType.REMOVED:
1✔
1194
            after = PreprocOutput(
1✔
1195
                name=node_output.name,
1196
                value=value_delta.after,
1197
                export=export_delta.after if export_delta else None,
1198
                condition=condition_delta.after if condition_delta else None,
1199
            )
1200
        return PreprocEntityDelta(before=before, after=after)
1✔
1201

1202
    def visit_node_outputs(
1✔
1203
        self, node_outputs: NodeOutputs
1204
    ) -> PreprocEntityDelta[list[PreprocOutput], list[PreprocOutput]]:
1205
        before: list[PreprocOutput] = []
1✔
1206
        after: list[PreprocOutput] = []
1✔
1207
        for node_output in node_outputs.outputs:
1✔
1208
            output_delta: PreprocEntityDelta[PreprocOutput, PreprocOutput] = self.visit(node_output)
1✔
1209
            output_before = output_delta.before
1✔
1210
            output_after = output_delta.after
1✔
1211
            if not is_nothing(output_before):
1✔
1212
                before.append(output_before)
1✔
1213
            if not is_nothing(output_after):
1✔
1214
                after.append(output_after)
1✔
1215
        return PreprocEntityDelta(before=before, after=after)
1✔
1216

1217
    def visit_node_intrinsic_function_fn_import_value(
1✔
1218
        self, node_intrinsic_function: NodeIntrinsicFunction
1219
    ) -> PreprocEntityDelta:
1220
        def _compute_fn_import_value(string) -> str:
1✔
1221
            if not isinstance(string, str):
1✔
UNCOV
1222
                raise RuntimeError(f"Invalid parameter for import: '{string}'")
×
1223

1224
            exports = exports_map(
1✔
1225
                account_id=self._change_set.account_id, region_name=self._change_set.region_name
1226
            )
1227

1228
            return exports.get(string, {}).get("Value") or Nothing
1✔
1229

1230
        arguments_delta = self.visit(node_intrinsic_function.arguments)
1✔
1231
        delta = self._cached_apply(
1✔
1232
            scope=node_intrinsic_function.scope,
1233
            arguments_delta=arguments_delta,
1234
            resolver=_compute_fn_import_value,
1235
        )
1236
        return delta
1✔
1237

1238
    def visit_node_intrinsic_function_fn_transform(
1✔
1239
        self, node_intrinsic_function: NodeIntrinsicFunction
1240
    ):
UNCOV
1241
        raise RuntimeError("Fn::Transform should have been handled by the Transformer")
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc