localstack-core 4.2.1.dev80__py3-none-any.whl → 4.2.1.dev81__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.
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import abc
4
4
  import enum
5
5
  from itertools import zip_longest
6
- from typing import Any, Final, Generator, Optional, Union
6
+ from typing import Any, Final, Generator, Optional, Union, cast
7
7
 
8
8
  from typing_extensions import TypeVar
9
9
 
@@ -29,11 +29,34 @@ class NothingType:
29
29
  def __repr__(self) -> str:
30
30
  return "Nothing"
31
31
 
32
+ def __bool__(self):
33
+ return False
34
+
35
+ def __iter__(self):
36
+ return iter(())
37
+
32
38
 
33
39
  Maybe = Union[T, NothingType]
34
40
  Nothing = NothingType()
35
41
 
36
42
 
43
+ class Scope(str):
44
+ _ROOT_SCOPE: Final[str] = str()
45
+ _SEPARATOR: Final[str] = "/"
46
+
47
+ def __new__(cls, scope: str = _ROOT_SCOPE) -> Scope:
48
+ return cast(Scope, super().__new__(cls, scope))
49
+
50
+ def open_scope(self, name: Scope | str) -> Scope:
51
+ return Scope(self._SEPARATOR.join([self, name]))
52
+
53
+ def open_index(self, index: int) -> Scope:
54
+ return Scope(self._SEPARATOR.join([self, str(index)]))
55
+
56
+ def unwrap(self) -> list[str]:
57
+ return self.split(self._SEPARATOR)
58
+
59
+
37
60
  class ChangeType(enum.Enum):
38
61
  UNCHANGED = "Unchanged"
39
62
  CREATED = "Created"
@@ -43,11 +66,21 @@ class ChangeType(enum.Enum):
43
66
  def __str__(self):
44
67
  return self.value
45
68
 
69
+ def for_child(self, child_change_type: ChangeType) -> ChangeType:
70
+ if child_change_type == self:
71
+ return self
72
+ elif self == ChangeType.UNCHANGED:
73
+ return child_change_type
74
+ else:
75
+ return ChangeType.MODIFIED
76
+
46
77
 
47
78
  class ChangeSetEntity(abc.ABC):
79
+ scope: Final[Scope]
48
80
  change_type: Final[ChangeType]
49
81
 
50
- def __init__(self, change_type: ChangeType):
82
+ def __init__(self, scope: Scope, change_type: ChangeType):
83
+ self.scope = scope
51
84
  self.change_type = change_type
52
85
 
53
86
  def get_children(self) -> Generator[ChangeSetEntity]:
@@ -80,44 +113,114 @@ class ChangeSetTerminal(ChangeSetEntity, abc.ABC): ...
80
113
 
81
114
 
82
115
  class NodeTemplate(ChangeSetNode):
116
+ parameters: Final[NodeParameters]
117
+ conditions: Final[NodeConditions]
83
118
  resources: Final[NodeResources]
84
119
 
85
- def __init__(self, change_type: ChangeType, resources: NodeResources):
86
- super().__init__(change_type=change_type)
120
+ def __init__(
121
+ self,
122
+ scope: Scope,
123
+ change_type: ChangeType,
124
+ parameters: NodeParameters,
125
+ conditions: NodeConditions,
126
+ resources: NodeResources,
127
+ ):
128
+ super().__init__(scope=scope, change_type=change_type)
129
+ self.parameters = parameters
130
+ self.conditions = conditions
87
131
  self.resources = resources
88
132
 
89
133
 
134
+ class NodeDivergence(ChangeSetNode):
135
+ value: Final[ChangeSetEntity]
136
+ divergence: Final[ChangeSetEntity]
137
+
138
+ def __init__(self, scope: Scope, value: ChangeSetEntity, divergence: ChangeSetEntity):
139
+ super().__init__(scope=scope, change_type=ChangeType.MODIFIED)
140
+ self.value = value
141
+ self.divergence = divergence
142
+
143
+
144
+ class NodeParameter(ChangeSetNode):
145
+ name: Final[str]
146
+ value: Final[ChangeSetEntity]
147
+ dynamic_value: Final[ChangeSetEntity]
148
+
149
+ def __init__(
150
+ self,
151
+ scope: Scope,
152
+ change_type: ChangeType,
153
+ name: str,
154
+ value: ChangeSetEntity,
155
+ dynamic_value: ChangeSetEntity,
156
+ ):
157
+ super().__init__(scope=scope, change_type=change_type)
158
+ self.name = name
159
+ self.value = value
160
+ self.dynamic_value = dynamic_value
161
+
162
+
163
+ class NodeParameters(ChangeSetNode):
164
+ parameters: Final[list[NodeParameter]]
165
+
166
+ def __init__(self, scope: Scope, change_type: ChangeType, parameters: list[NodeParameter]):
167
+ super().__init__(scope=scope, change_type=change_type)
168
+ self.parameters = parameters
169
+
170
+
171
+ class NodeCondition(ChangeSetNode):
172
+ name: Final[str]
173
+ body: Final[ChangeSetEntity]
174
+
175
+ def __init__(self, scope: Scope, change_type: ChangeType, name: str, body: ChangeSetEntity):
176
+ super().__init__(scope=scope, change_type=change_type)
177
+ self.name = name
178
+ self.body = body
179
+
180
+
181
+ class NodeConditions(ChangeSetNode):
182
+ conditions: Final[list[NodeCondition]]
183
+
184
+ def __init__(self, scope: Scope, change_type: ChangeType, conditions: list[NodeCondition]):
185
+ super().__init__(scope=scope, change_type=change_type)
186
+ self.conditions = conditions
187
+
188
+
90
189
  class NodeResources(ChangeSetNode):
91
190
  resources: Final[list[NodeResource]]
92
191
 
93
- def __init__(self, change_type: ChangeType, resources: list[NodeResource]):
94
- super().__init__(change_type=change_type)
192
+ def __init__(self, scope: Scope, change_type: ChangeType, resources: list[NodeResource]):
193
+ super().__init__(scope=scope, change_type=change_type)
95
194
  self.resources = resources
96
195
 
97
196
 
98
197
  class NodeResource(ChangeSetNode):
99
198
  name: Final[str]
100
199
  type_: Final[ChangeSetTerminal]
200
+ condition_reference: Final[TerminalValue]
101
201
  properties: Final[NodeProperties]
102
202
 
103
203
  def __init__(
104
204
  self,
205
+ scope: Scope,
105
206
  change_type: ChangeType,
106
207
  name: str,
107
208
  type_: ChangeSetTerminal,
209
+ condition_reference: TerminalValue,
108
210
  properties: NodeProperties,
109
211
  ):
110
- super().__init__(change_type=change_type)
212
+ super().__init__(scope=scope, change_type=change_type)
111
213
  self.name = name
112
214
  self.type_ = type_
215
+ self.condition_reference = condition_reference
113
216
  self.properties = properties
114
217
 
115
218
 
116
219
  class NodeProperties(ChangeSetNode):
117
220
  properties: Final[list[NodeProperty]]
118
221
 
119
- def __init__(self, change_type: ChangeType, properties: list[NodeProperty]):
120
- super().__init__(change_type=change_type)
222
+ def __init__(self, scope: Scope, change_type: ChangeType, properties: list[NodeProperty]):
223
+ super().__init__(scope=scope, change_type=change_type)
121
224
  self.properties = properties
122
225
 
123
226
 
@@ -125,8 +228,8 @@ class NodeProperty(ChangeSetNode):
125
228
  name: Final[str]
126
229
  value: Final[ChangeSetEntity]
127
230
 
128
- def __init__(self, change_type: ChangeType, name: str, value: ChangeSetEntity):
129
- super().__init__(change_type=change_type)
231
+ def __init__(self, scope: Scope, change_type: ChangeType, name: str, value: ChangeSetEntity):
232
+ super().__init__(scope=scope, change_type=change_type)
130
233
  self.name = name
131
234
  self.value = value
132
235
 
@@ -136,9 +239,13 @@ class NodeIntrinsicFunction(ChangeSetNode):
136
239
  arguments: Final[ChangeSetEntity]
137
240
 
138
241
  def __init__(
139
- self, change_type: ChangeType, intrinsic_function: str, arguments: ChangeSetEntity
242
+ self,
243
+ scope: Scope,
244
+ change_type: ChangeType,
245
+ intrinsic_function: str,
246
+ arguments: ChangeSetEntity,
140
247
  ):
141
- super().__init__(change_type=change_type)
248
+ super().__init__(scope=scope, change_type=change_type)
142
249
  self.intrinsic_function = intrinsic_function
143
250
  self.arguments = arguments
144
251
 
@@ -146,56 +253,63 @@ class NodeIntrinsicFunction(ChangeSetNode):
146
253
  class NodeObject(ChangeSetNode):
147
254
  bindings: Final[dict[str, ChangeSetEntity]]
148
255
 
149
- def __init__(self, change_type: ChangeType, bindings: dict[str, ChangeSetEntity]):
150
- super().__init__(change_type=change_type)
256
+ def __init__(self, scope: Scope, change_type: ChangeType, bindings: dict[str, ChangeSetEntity]):
257
+ super().__init__(scope=scope, change_type=change_type)
151
258
  self.bindings = bindings
152
259
 
153
260
 
154
261
  class NodeArray(ChangeSetNode):
155
262
  array: Final[list[ChangeSetEntity]]
156
263
 
157
- def __init__(self, change_type: ChangeType, array: list[ChangeSetEntity]):
158
- super().__init__(change_type=change_type)
264
+ def __init__(self, scope: Scope, change_type: ChangeType, array: list[ChangeSetEntity]):
265
+ super().__init__(scope=scope, change_type=change_type)
159
266
  self.array = array
160
267
 
161
268
 
162
269
  class TerminalValue(ChangeSetTerminal, abc.ABC):
163
270
  value: Final[Any]
164
271
 
165
- def __init__(self, change_type: ChangeType, value: Any):
166
- super().__init__(change_type=change_type)
272
+ def __init__(self, scope: Scope, change_type: ChangeType, value: Any):
273
+ super().__init__(scope=scope, change_type=change_type)
167
274
  self.value = value
168
275
 
169
276
 
170
277
  class TerminalValueModified(TerminalValue):
171
278
  modified_value: Final[Any]
172
279
 
173
- def __init__(self, value: Any, modified_value: Any):
174
- super().__init__(change_type=ChangeType.MODIFIED, value=value)
280
+ def __init__(self, scope: Scope, value: Any, modified_value: Any):
281
+ super().__init__(scope=scope, change_type=ChangeType.MODIFIED, value=value)
175
282
  self.modified_value = modified_value
176
283
 
177
284
 
178
285
  class TerminalValueCreated(TerminalValue):
179
- def __init__(self, value: Any):
180
- super().__init__(change_type=ChangeType.CREATED, value=value)
286
+ def __init__(self, scope: Scope, value: Any):
287
+ super().__init__(scope=scope, change_type=ChangeType.CREATED, value=value)
181
288
 
182
289
 
183
290
  class TerminalValueRemoved(TerminalValue):
184
- def __init__(self, value: Any):
185
- super().__init__(change_type=ChangeType.REMOVED, value=value)
291
+ def __init__(self, scope: Scope, value: Any):
292
+ super().__init__(scope=scope, change_type=ChangeType.REMOVED, value=value)
186
293
 
187
294
 
188
295
  class TerminalValueUnchanged(TerminalValue):
189
- def __init__(self, value: Any):
190
- super().__init__(change_type=ChangeType.UNCHANGED, value=value)
296
+ def __init__(self, scope: Scope, value: Any):
297
+ super().__init__(scope=scope, change_type=ChangeType.UNCHANGED, value=value)
191
298
 
192
299
 
300
+ TypeKey: Final[str] = "Type"
301
+ ConditionKey: Final[str] = "Condition"
302
+ ConditionsKey: Final[str] = "Conditions"
193
303
  ResourcesKey: Final[str] = "Resources"
194
304
  PropertiesKey: Final[str] = "Properties"
195
- TypeKey: Final[str] = "Type"
305
+ ParametersKey: Final[str] = "Parameters"
196
306
  # TODO: expand intrinsic functions set.
307
+ RefKey: Final[str] = "Ref"
308
+ FnIf: Final[str] = "Fn::If"
309
+ FnNot: Final[str] = "Fn::Not"
197
310
  FnGetAttKey: Final[str] = "Fn::GetAtt"
198
- INTRINSIC_FUNCTIONS: Final[set[str]] = {FnGetAttKey}
311
+ FnEqualsKey: Final[str] = "Fn::Equals"
312
+ INTRINSIC_FUNCTIONS: Final[set[str]] = {RefKey, FnIf, FnNot, FnEqualsKey, FnGetAttKey}
199
313
 
200
314
 
201
315
  class ChangeSetModel:
@@ -208,14 +322,23 @@ class ChangeSetModel:
208
322
 
209
323
  _before_template: Final[Maybe[dict]]
210
324
  _after_template: Final[Maybe[dict]]
211
- # TODO: generalise this lookup for other goto visitable types such as parameters and conditions
212
- _visited_resources: Final[dict[str, NodeResource]]
325
+ _before_parameters: Final[Maybe[dict]]
326
+ _after_parameters: Final[Maybe[dict]]
327
+ _visited_scopes: Final[dict[str, ChangeSetEntity]]
213
328
  _node_template: Final[NodeTemplate]
214
329
 
215
- def __init__(self, before_template: Optional[dict], after_template: Optional[dict]):
330
+ def __init__(
331
+ self,
332
+ before_template: Optional[dict],
333
+ after_template: Optional[dict],
334
+ before_parameters: Optional[dict],
335
+ after_parameters: Optional[dict],
336
+ ):
216
337
  self._before_template = before_template or Nothing
217
338
  self._after_template = after_template or Nothing
218
- self._visited_resources = dict()
339
+ self._before_parameters = before_parameters or Nothing
340
+ self._after_parameters = after_parameters or Nothing
341
+ self._visited_scopes = dict()
219
342
  self._node_template = self._model(
220
343
  before_template=self._before_template, after_template=self._after_template
221
344
  )
@@ -226,21 +349,37 @@ class ChangeSetModel:
226
349
  return self._node_template
227
350
 
228
351
  def _visit_terminal_value(
229
- self, before_value: Maybe[Any], after_value: Maybe[Any]
352
+ self, scope: Scope, before_value: Maybe[Any], after_value: Maybe[Any]
230
353
  ) -> TerminalValue:
354
+ terminal_value = self._visited_scopes.get(scope)
355
+ if isinstance(terminal_value, TerminalValue):
356
+ return terminal_value
231
357
  if self._is_created(before=before_value, after=after_value):
232
- return TerminalValueCreated(value=after_value)
233
- if self._is_removed(before=before_value, after=after_value):
234
- return TerminalValueRemoved(value=before_value)
235
- if before_value == after_value:
236
- return TerminalValueUnchanged(value=before_value)
237
- return TerminalValueModified(value=before_value, modified_value=after_value)
358
+ terminal_value = TerminalValueCreated(scope=scope, value=after_value)
359
+ elif self._is_removed(before=before_value, after=after_value):
360
+ terminal_value = TerminalValueRemoved(scope=scope, value=before_value)
361
+ elif before_value == after_value:
362
+ terminal_value = TerminalValueUnchanged(scope=scope, value=before_value)
363
+ else:
364
+ terminal_value = TerminalValueModified(
365
+ scope=scope, value=before_value, modified_value=after_value
366
+ )
367
+ self._visited_scopes[scope] = terminal_value
368
+ return terminal_value
238
369
 
239
370
  def _visit_intrinsic_function(
240
- self, intrinsic_function: str, before_arguments: Maybe[Any], after_arguments: Maybe[Any]
371
+ self,
372
+ scope: Scope,
373
+ intrinsic_function: str,
374
+ before_arguments: Maybe[Any],
375
+ after_arguments: Maybe[Any],
241
376
  ) -> NodeIntrinsicFunction:
242
- arguments = self._visit_value(before_value=before_arguments, after_value=after_arguments)
243
-
377
+ node_intrinsic_function = self._visited_scopes.get(scope)
378
+ if isinstance(node_intrinsic_function, NodeIntrinsicFunction):
379
+ return node_intrinsic_function
380
+ arguments = self._visit_value(
381
+ scope=scope, before_value=before_arguments, after_value=after_arguments
382
+ )
244
383
  if self._is_created(before=before_arguments, after=after_arguments):
245
384
  change_type = ChangeType.CREATED
246
385
  elif self._is_removed(before=before_arguments, after=after_arguments):
@@ -248,13 +387,20 @@ class ChangeSetModel:
248
387
  else:
249
388
  function_name = intrinsic_function.replace("::", "_")
250
389
  function_name = camel_to_snake_case(function_name)
251
- visit_function_name = f"_resolve_intrinsic_function_{function_name}"
252
- visit_function = getattr(self, visit_function_name)
253
- change_type = visit_function(arguments)
254
-
255
- return NodeIntrinsicFunction(
256
- change_type=change_type, intrinsic_function=intrinsic_function, arguments=arguments
390
+ resolve_function_name = f"_resolve_intrinsic_function_{function_name}"
391
+ if hasattr(self, resolve_function_name):
392
+ resolve_function = getattr(self, resolve_function_name)
393
+ change_type = resolve_function(arguments)
394
+ else:
395
+ change_type = arguments.change_type
396
+ node_intrinsic_function = NodeIntrinsicFunction(
397
+ scope=scope,
398
+ change_type=change_type,
399
+ intrinsic_function=intrinsic_function,
400
+ arguments=arguments,
257
401
  )
402
+ self._visited_scopes[scope] = node_intrinsic_function
403
+ return node_intrinsic_function
258
404
 
259
405
  def _resolve_intrinsic_function_fn_get_att(self, arguments: ChangeSetEntity) -> ChangeType:
260
406
  # TODO: add support for nested intrinsic functions.
@@ -288,119 +434,217 @@ class ChangeSetModel:
288
434
 
289
435
  return ChangeType.UNCHANGED
290
436
 
291
- def _retrieve_or_visit_resource(self, resource_name: str) -> NodeResource:
292
- before_resources, after_resources = self._sample_from(
293
- ResourcesKey, self._before_template, self._after_template
294
- )
295
- before_resource, after_resource = self._sample_from(
296
- resource_name, before_resources, after_resources
297
- )
298
- return self._visit_resource(
299
- resource_name=resource_name,
300
- before_resource=before_resource,
301
- after_resource=after_resource,
437
+ def _resolve_intrinsic_function_ref(self, arguments: ChangeSetEntity) -> ChangeType:
438
+ if arguments.change_type != ChangeType.UNCHANGED:
439
+ return arguments.change_type
440
+ # TODO: add support for nested functions, here we assume the argument is a logicalID.
441
+ if not isinstance(arguments, TerminalValue):
442
+ return arguments.change_type
443
+
444
+ logical_id = arguments.value
445
+
446
+ node_condition = self._retrieve_condition_if_exists(condition_name=logical_id)
447
+ if isinstance(node_condition, NodeCondition):
448
+ return node_condition.change_type
449
+
450
+ node_parameter = self._retrieve_parameter_if_exists(parameter_name=logical_id)
451
+ if isinstance(node_parameter, NodeParameter):
452
+ return node_parameter.dynamic_value.change_type
453
+
454
+ # TODO: this should check the replacement flag for a resource update.
455
+ node_resource = self._retrieve_or_visit_resource(resource_name=logical_id)
456
+ return node_resource.change_type
457
+
458
+ def _resolve_intrinsic_function_fn_if(self, arguments: ChangeSetEntity) -> ChangeType:
459
+ # TODO: validate arguments structure and type.
460
+ if not isinstance(arguments, NodeArray) or not arguments.array:
461
+ raise RuntimeError()
462
+ logical_name_of_condition_entity = arguments.array[0]
463
+ if not isinstance(logical_name_of_condition_entity, TerminalValue):
464
+ raise RuntimeError()
465
+ logical_name_of_condition: str = logical_name_of_condition_entity.value
466
+ if not isinstance(logical_name_of_condition, str):
467
+ raise RuntimeError()
468
+
469
+ node_condition = self._retrieve_condition_if_exists(
470
+ condition_name=logical_name_of_condition
302
471
  )
472
+ if not isinstance(node_condition, NodeCondition):
473
+ raise RuntimeError()
474
+ change_types = [node_condition.change_type, *arguments.array[1:]]
475
+ change_type = self._change_type_for_parent_of(change_types=change_types)
476
+ return change_type
303
477
 
304
- def _visit_array(self, before_array: Maybe[list], after_array: Maybe[list]) -> NodeArray:
478
+ def _visit_array(
479
+ self, scope: Scope, before_array: Maybe[list], after_array: Maybe[list]
480
+ ) -> NodeArray:
305
481
  change_type = ChangeType.UNCHANGED
306
482
  array: list[ChangeSetEntity] = list()
307
- for before_value, after_value in zip_longest(before_array, after_array, fillvalue=Nothing):
308
- value = self._visit_value(before_value=before_value, after_value=after_value)
483
+ for index, (before_value, after_value) in enumerate(
484
+ zip_longest(before_array, after_array, fillvalue=Nothing)
485
+ ):
486
+ # TODO: should extract this scoping logic.
487
+ value_scope = scope.open_index(index=index)
488
+ value = self._visit_value(
489
+ scope=value_scope, before_value=before_value, after_value=after_value
490
+ )
309
491
  array.append(value)
310
492
  if value.change_type != ChangeType.UNCHANGED:
311
493
  change_type = ChangeType.MODIFIED
312
- return NodeArray(change_type=change_type, array=array)
494
+ return NodeArray(scope=scope, change_type=change_type, array=array)
495
+
496
+ def _visit_object(
497
+ self, scope: Scope, before_object: Maybe[dict], after_object: Maybe[dict]
498
+ ) -> NodeObject:
499
+ node_object = self._visited_scopes.get(scope)
500
+ if isinstance(node_object, NodeObject):
501
+ return node_object
313
502
 
314
- def _visit_object(self, before_object: Maybe[dict], after_object: Maybe[dict]) -> NodeObject:
315
503
  change_type = ChangeType.UNCHANGED
316
- binding_names = self._keys_of(before_object, after_object)
504
+ binding_names = self._safe_keys_of(before_object, after_object)
317
505
  bindings: dict[str, ChangeSetEntity] = dict()
318
506
  for binding_name in binding_names:
319
- before_value, after_value = self._sample_from(binding_name, before_object, after_object)
507
+ binding_scope, (before_value, after_value) = self._safe_access_in(
508
+ scope, binding_name, before_object, after_object
509
+ )
320
510
  if self._is_intrinsic_function_name(function_name=binding_name):
321
511
  value = self._visit_intrinsic_function(
512
+ scope=binding_scope,
322
513
  intrinsic_function=binding_name,
323
514
  before_arguments=before_value,
324
515
  after_arguments=after_value,
325
516
  )
326
517
  else:
327
- value = self._visit_value(before_value=before_value, after_value=after_value)
518
+ value = self._visit_value(
519
+ scope=binding_scope, before_value=before_value, after_value=after_value
520
+ )
328
521
  bindings[binding_name] = value
329
- if value.change_type != ChangeType.UNCHANGED:
330
- change_type = ChangeType.MODIFIED
331
- return NodeObject(change_type=change_type, bindings=bindings)
332
-
333
- def _visit_value(self, before_value: Maybe[Any], after_value: Maybe[Any]) -> ChangeSetEntity:
334
- before_type = type(before_value)
335
- after_type = type(after_value)
336
-
337
- if self._is_created(before=before_value, after=after_value):
338
- return TerminalValueCreated(value=after_value)
339
- if self._is_removed(before=before_value, after=after_value):
340
- return TerminalValueRemoved(value=before_value)
522
+ change_type = change_type.for_child(value.change_type)
523
+ node_object = NodeObject(scope=scope, change_type=change_type, bindings=bindings)
524
+ self._visited_scopes[scope] = node_object
525
+ return node_object
526
+
527
+ def _visit_divergence(
528
+ self, scope: Scope, before_value: Maybe[Any], after_value: Maybe[Any]
529
+ ) -> NodeDivergence:
530
+ scope_value = scope.open_scope("value")
531
+ value = self._visit_value(scope=scope_value, before_value=before_value, after_value=Nothing)
532
+ scope_divergence = scope.open_scope("divergence")
533
+ divergence = self._visit_value(
534
+ scope=scope_divergence, before_value=Nothing, after_value=after_value
535
+ )
536
+ return NodeDivergence(scope=scope, value=value, divergence=divergence)
341
537
 
342
- # Case: update on the same type.
343
- if before_type == after_type:
344
- if self._is_terminal(value=before_value):
538
+ def _visit_value(
539
+ self, scope: Scope, before_value: Maybe[Any], after_value: Maybe[Any]
540
+ ) -> ChangeSetEntity:
541
+ value = self._visited_scopes.get(scope)
542
+ if isinstance(value, ChangeSetEntity):
543
+ return value
544
+ unset = object()
545
+ if type(before_value) is type(after_value):
546
+ dominant_value = before_value
547
+ elif self._is_created(before=before_value, after=after_value):
548
+ dominant_value = after_value
549
+ elif self._is_removed(before=before_value, after=after_value):
550
+ dominant_value = before_value
551
+ else:
552
+ dominant_value = unset
553
+ if dominant_value is not unset:
554
+ if self._is_terminal(value=dominant_value):
345
555
  value = self._visit_terminal_value(
346
- before_value=before_value, after_value=after_value
556
+ scope=scope, before_value=before_value, after_value=after_value
557
+ )
558
+ elif self._is_object(value=dominant_value):
559
+ value = self._visit_object(
560
+ scope=scope, before_object=before_value, after_object=after_value
561
+ )
562
+ elif self._is_array(value=dominant_value):
563
+ value = self._visit_array(
564
+ scope=scope, before_array=before_value, after_array=after_value
347
565
  )
348
- elif self._is_object(value=before_value):
349
- value = self._visit_object(before_object=before_value, after_object=after_value)
350
- elif self._is_array(value=before_value):
351
- value = self._visit_array(before_array=before_value, after_array=after_value)
352
566
  else:
353
- raise RuntimeError(f"Unsupported type {before_type}")
354
- return value
355
- # Case: update to new type.
567
+ raise RuntimeError(f"Unsupported type {type(dominant_value)}")
568
+ # Case: type divergence.
356
569
  else:
357
- return TerminalValueModified(value=before_value, modified_value=after_value)
570
+ value = self._visit_divergence(
571
+ scope=scope, before_value=before_value, after_value=after_value
572
+ )
573
+ self._visited_scopes[scope] = value
574
+ return value
358
575
 
359
576
  def _visit_property(
360
- self, property_name: str, before_property: Maybe[Any], after_property: Maybe[Any]
577
+ self,
578
+ scope: Scope,
579
+ property_name: str,
580
+ before_property: Maybe[Any],
581
+ after_property: Maybe[Any],
361
582
  ) -> NodeProperty:
583
+ node_property = self._visited_scopes.get(scope)
584
+ if isinstance(node_property, NodeProperty):
585
+ return node_property
586
+
362
587
  if self._is_created(before=before_property, after=after_property):
363
- return NodeProperty(
588
+ node_property = NodeProperty(
589
+ scope=scope,
364
590
  change_type=ChangeType.CREATED,
365
591
  name=property_name,
366
- value=TerminalValueCreated(value=after_property),
592
+ value=TerminalValueCreated(scope=scope, value=after_property),
367
593
  )
368
- if self._is_removed(before=before_property, after=after_property):
369
- return NodeProperty(
594
+ elif self._is_removed(before=before_property, after=after_property):
595
+ node_property = NodeProperty(
596
+ scope=scope,
370
597
  change_type=ChangeType.REMOVED,
371
598
  name=property_name,
372
- value=TerminalValueRemoved(value=before_property),
599
+ value=TerminalValueRemoved(scope=scope, value=before_property),
600
+ )
601
+ else:
602
+ value = self._visit_value(
603
+ scope=scope, before_value=before_property, after_value=after_property
604
+ )
605
+ node_property = NodeProperty(
606
+ scope=scope, change_type=value.change_type, name=property_name, value=value
373
607
  )
374
- value = self._visit_value(before_value=before_property, after_value=after_property)
375
- return NodeProperty(change_type=value.change_type, name=property_name, value=value)
608
+ self._visited_scopes[scope] = node_property
609
+ return node_property
376
610
 
377
611
  def _visit_properties(
378
- self, before_properties: Maybe[dict], after_properties: Maybe[dict]
612
+ self, scope: Scope, before_properties: Maybe[dict], after_properties: Maybe[dict]
379
613
  ) -> NodeProperties:
614
+ node_properties = self._visited_scopes.get(scope)
615
+ if isinstance(node_properties, NodeProperties):
616
+ return node_properties
380
617
  # TODO: double check we are sure not to have this be a NodeObject
381
- property_names: set[str] = self._keys_of(before_properties, after_properties)
618
+ property_names: list[str] = self._safe_keys_of(before_properties, after_properties)
382
619
  properties: list[NodeProperty] = list()
383
620
  change_type = ChangeType.UNCHANGED
384
621
  for property_name in property_names:
385
- before_property, after_property = self._sample_from(
386
- property_name, before_properties, after_properties
622
+ property_scope, (before_property, after_property) = self._safe_access_in(
623
+ scope, property_name, before_properties, after_properties
387
624
  )
388
625
  property_ = self._visit_property(
626
+ scope=property_scope,
389
627
  property_name=property_name,
390
628
  before_property=before_property,
391
629
  after_property=after_property,
392
630
  )
393
631
  properties.append(property_)
394
- # TODO: compute the properties change type properly.
395
- if property_.change_type != ChangeType.UNCHANGED:
396
- change_type = change_type.MODIFIED
397
- return NodeProperties(change_type=change_type, properties=properties)
632
+ change_type = change_type.for_child(property_.change_type)
633
+ node_properties = NodeProperties(
634
+ scope=scope, change_type=change_type, properties=properties
635
+ )
636
+ self._visited_scopes[scope] = node_properties
637
+ return node_properties
398
638
 
399
639
  def _visit_resource(
400
- self, resource_name: str, before_resource: Maybe[dict], after_resource: Maybe[dict]
640
+ self,
641
+ scope: Scope,
642
+ resource_name: str,
643
+ before_resource: Maybe[dict],
644
+ after_resource: Maybe[dict],
401
645
  ) -> NodeResource:
402
- node_resource = self._visited_resources.get(resource_name)
403
- if node_resource is not None:
646
+ node_resource = self._visited_scopes.get(scope)
647
+ if isinstance(node_resource, NodeResource):
404
648
  return node_resource
405
649
 
406
650
  if self._is_created(before=before_resource, after=after_resource):
@@ -411,68 +655,294 @@ class ChangeSetModel:
411
655
  change_type = ChangeType.UNCHANGED
412
656
 
413
657
  # TODO: investigate behaviour with type changes, for now this is filler code.
414
- type_str = self._sample_from(TypeKey, before_resource)
658
+ _, type_str = self._safe_access_in(scope, TypeKey, before_resource)
415
659
 
416
- before_properties, after_properties = self._sample_from(
417
- PropertiesKey, before_resource, after_resource
660
+ scope_condition, (before_condition, after_condition) = self._safe_access_in(
661
+ scope, ConditionKey, before_resource, after_resource
418
662
  )
419
- properties = self._visit_properties(
420
- before_properties=before_properties, after_properties=after_properties
663
+ condition_reference = self._visit_terminal_value(
664
+ scope_condition, before_condition, after_condition
421
665
  )
422
666
 
423
- if change_type == ChangeType.UNCHANGED and properties.change_type != ChangeType.UNCHANGED:
424
- change_type = ChangeType.MODIFIED
425
-
667
+ scope_properties, (before_properties, after_properties) = self._safe_access_in(
668
+ scope, PropertiesKey, before_resource, after_resource
669
+ )
670
+ properties = self._visit_properties(
671
+ scope=scope_properties,
672
+ before_properties=before_properties,
673
+ after_properties=after_properties,
674
+ )
675
+ change_type = change_type.for_child(properties.change_type)
426
676
  node_resource = NodeResource(
677
+ scope=scope,
427
678
  change_type=change_type,
428
679
  name=resource_name,
429
- type_=TerminalValueUnchanged(value=type_str),
680
+ type_=TerminalValueUnchanged(scope=scope, value=type_str),
681
+ condition_reference=condition_reference,
430
682
  properties=properties,
431
683
  )
432
- self._visited_resources[resource_name] = node_resource
684
+ self._visited_scopes[scope] = node_resource
433
685
  return node_resource
434
686
 
435
687
  def _visit_resources(
436
- self, before_resources: Maybe[dict], after_resources: Maybe[dict]
688
+ self, scope: Scope, before_resources: Maybe[dict], after_resources: Maybe[dict]
437
689
  ) -> NodeResources:
438
690
  # TODO: investigate type changes behavior.
439
691
  change_type = ChangeType.UNCHANGED
440
692
  resources: list[NodeResource] = list()
441
- resource_names = self._keys_of(before_resources, after_resources)
693
+ resource_names = self._safe_keys_of(before_resources, after_resources)
442
694
  for resource_name in resource_names:
443
- before_resource, after_resource = self._sample_from(
444
- resource_name, before_resources, after_resources
695
+ resource_scope, (before_resource, after_resource) = self._safe_access_in(
696
+ scope, resource_name, before_resources, after_resources
445
697
  )
446
698
  resource = self._visit_resource(
699
+ scope=resource_scope,
447
700
  resource_name=resource_name,
448
701
  before_resource=before_resource,
449
702
  after_resource=after_resource,
450
703
  )
451
704
  resources.append(resource)
452
- # TODO: compute the properties change type properly.
453
- if resource.change_type != ChangeType.UNCHANGED:
454
- change_type = ChangeType.MODIFIED
455
- return NodeResources(change_type=change_type, resources=resources)
705
+ change_type = change_type.for_child(resource.change_type)
706
+ return NodeResources(scope=scope, change_type=change_type, resources=resources)
707
+
708
+ def _visit_dynamic_parameter(self, parameter_name: str) -> ChangeSetEntity:
709
+ scope = Scope("Dynamic").open_scope("Parameters")
710
+ scope_parameter, (before_parameter, after_parameter) = self._safe_access_in(
711
+ scope, parameter_name, self._before_parameters, self._after_parameters
712
+ )
713
+ parameter = self._visit_value(
714
+ scope=scope_parameter, before_value=before_parameter, after_value=after_parameter
715
+ )
716
+ return parameter
717
+
718
+ def _visit_parameter(
719
+ self,
720
+ scope: Scope,
721
+ parameter_name: str,
722
+ before_parameter: Maybe[dict],
723
+ after_parameter: Maybe[dict],
724
+ ) -> NodeParameter:
725
+ node_parameter = self._visited_scopes.get(scope)
726
+ if isinstance(node_parameter, NodeParameter):
727
+ return node_parameter
728
+ # TODO: add logic to compute defaults already in the graph building process?
729
+ dynamic_value = self._visit_dynamic_parameter(parameter_name=parameter_name)
730
+ if self._is_created(before=before_parameter, after=after_parameter):
731
+ node_parameter = NodeParameter(
732
+ scope=scope,
733
+ change_type=ChangeType.CREATED,
734
+ name=parameter_name,
735
+ value=TerminalValueCreated(scope=scope, value=after_parameter),
736
+ dynamic_value=dynamic_value,
737
+ )
738
+ elif self._is_removed(before=before_parameter, after=after_parameter):
739
+ node_parameter = NodeParameter(
740
+ scope=scope,
741
+ change_type=ChangeType.REMOVED,
742
+ name=parameter_name,
743
+ value=TerminalValueRemoved(scope=scope, value=before_parameter),
744
+ dynamic_value=dynamic_value,
745
+ )
746
+ else:
747
+ value = self._visit_value(
748
+ scope=scope, before_value=before_parameter, after_value=after_parameter
749
+ )
750
+ change_type = self._change_type_for_parent_of(
751
+ change_types=[dynamic_value.change_type, value.change_type]
752
+ )
753
+ node_parameter = NodeParameter(
754
+ scope=scope,
755
+ change_type=change_type,
756
+ name=parameter_name,
757
+ value=value,
758
+ dynamic_value=dynamic_value,
759
+ )
760
+ self._visited_scopes[scope] = node_parameter
761
+ return node_parameter
762
+
763
+ def _visit_parameters(
764
+ self, scope: Scope, before_parameters: Maybe[dict], after_parameters: Maybe[dict]
765
+ ) -> NodeParameters:
766
+ node_parameters = self._visited_scopes.get(scope)
767
+ if isinstance(node_parameters, NodeParameters):
768
+ return node_parameters
769
+ parameter_names: list[str] = self._safe_keys_of(before_parameters, after_parameters)
770
+ parameters: list[NodeParameter] = list()
771
+ change_type = ChangeType.UNCHANGED
772
+ for parameter_name in parameter_names:
773
+ parameter_scope, (before_parameter, after_parameter) = self._safe_access_in(
774
+ scope, parameter_name, before_parameters, after_parameters
775
+ )
776
+ parameter = self._visit_parameter(
777
+ scope=parameter_scope,
778
+ parameter_name=parameter_name,
779
+ before_parameter=before_parameter,
780
+ after_parameter=after_parameter,
781
+ )
782
+ parameters.append(parameter)
783
+ change_type = change_type.for_child(parameter.change_type)
784
+ node_parameters = NodeParameters(
785
+ scope=scope, change_type=change_type, parameters=parameters
786
+ )
787
+ self._visited_scopes[scope] = node_parameters
788
+ return node_parameters
789
+
790
+ def _visit_condition(
791
+ self,
792
+ scope: Scope,
793
+ condition_name: str,
794
+ before_condition: Maybe[dict],
795
+ after_condition: Maybe[dict],
796
+ ) -> NodeCondition:
797
+ node_condition = self._visited_scopes.get(scope)
798
+ if isinstance(node_condition, NodeCondition):
799
+ return node_condition
800
+
801
+ # TODO: is schema validation/check necessary or can we trust the input at this point?
802
+ function_names: list[str] = self._safe_keys_of(before_condition, after_condition)
803
+ if len(function_names) == 1:
804
+ body = self._visit_object(
805
+ scope=scope, before_object=before_condition, after_object=after_condition
806
+ )
807
+ else:
808
+ body = self._visit_divergence(
809
+ scope=scope, before_value=before_condition, after_value=after_condition
810
+ )
811
+
812
+ node_condition = NodeCondition(
813
+ scope=scope, change_type=body.change_type, name=condition_name, body=body
814
+ )
815
+ self._visited_scopes[scope] = node_condition
816
+ return node_condition
817
+
818
+ def _visit_conditions(
819
+ self, scope: Scope, before_conditions: Maybe[dict], after_conditions: Maybe[dict]
820
+ ) -> NodeConditions:
821
+ node_conditions = self._visited_scopes.get(scope)
822
+ if isinstance(node_conditions, NodeConditions):
823
+ return node_conditions
824
+ condition_names: list[str] = self._safe_keys_of(before_conditions, after_conditions)
825
+ conditions: list[NodeCondition] = list()
826
+ change_type = ChangeType.UNCHANGED
827
+ for condition_name in condition_names:
828
+ condition_scope, (before_condition, after_condition) = self._safe_access_in(
829
+ scope, condition_name, before_conditions, after_conditions
830
+ )
831
+ condition = self._visit_condition(
832
+ scope=condition_scope,
833
+ condition_name=condition_name,
834
+ before_condition=before_condition,
835
+ after_condition=after_condition,
836
+ )
837
+ conditions.append(condition)
838
+ change_type = change_type.for_child(child_change_type=condition.change_type)
839
+ node_conditions = NodeConditions(
840
+ scope=scope, change_type=change_type, conditions=conditions
841
+ )
842
+ self._visited_scopes[scope] = node_conditions
843
+ return node_conditions
456
844
 
457
845
  def _model(self, before_template: Maybe[dict], after_template: Maybe[dict]) -> NodeTemplate:
846
+ root_scope = Scope()
458
847
  # TODO: visit other child types
459
- before_resources, after_resources = self._sample_from(
460
- ResourcesKey, before_template, after_template
848
+ parameters_scope, (before_parameters, after_parameters) = self._safe_access_in(
849
+ root_scope, ParametersKey, before_template, after_template
850
+ )
851
+ parameters = self._visit_parameters(
852
+ scope=parameters_scope,
853
+ before_parameters=before_parameters,
854
+ after_parameters=after_parameters,
855
+ )
856
+
857
+ conditions_scope, (before_conditions, after_conditions) = self._safe_access_in(
858
+ root_scope, ConditionsKey, before_template, after_template
859
+ )
860
+ conditions = self._visit_conditions(
861
+ scope=conditions_scope,
862
+ before_conditions=before_conditions,
863
+ after_conditions=after_conditions,
864
+ )
865
+
866
+ resources_scope, (before_resources, after_resources) = self._safe_access_in(
867
+ root_scope, ResourcesKey, before_template, after_template
461
868
  )
462
869
  resources = self._visit_resources(
463
- before_resources=before_resources, after_resources=after_resources
870
+ scope=resources_scope,
871
+ before_resources=before_resources,
872
+ after_resources=after_resources,
873
+ )
874
+
875
+ # TODO: compute the change_type of the template properly.
876
+ return NodeTemplate(
877
+ scope=root_scope,
878
+ change_type=resources.change_type,
879
+ parameters=parameters,
880
+ conditions=conditions,
881
+ resources=resources,
882
+ )
883
+
884
+ def _retrieve_condition_if_exists(self, condition_name: str) -> Optional[NodeCondition]:
885
+ conditions_scope, (before_conditions, after_conditions) = self._safe_access_in(
886
+ Scope(), ConditionsKey, self._before_template, self._after_template
887
+ )
888
+ before_conditions = before_conditions or dict()
889
+ after_conditions = after_conditions or dict()
890
+ if condition_name in before_conditions or condition_name in after_conditions:
891
+ condition_scope, (before_condition, after_condition) = self._safe_access_in(
892
+ conditions_scope, condition_name, before_conditions, after_conditions
893
+ )
894
+ node_condition = self._visit_condition(
895
+ conditions_scope,
896
+ condition_name,
897
+ before_condition=before_condition,
898
+ after_condition=after_condition,
899
+ )
900
+ return node_condition
901
+ return None
902
+
903
+ def _retrieve_parameter_if_exists(self, parameter_name: str) -> Optional[NodeParameter]:
904
+ parameters_scope, (before_parameters, after_parameters) = self._safe_access_in(
905
+ Scope(), ParametersKey, self._before_template, self._after_template
906
+ )
907
+ before_parameters = before_parameters or dict()
908
+ after_parameters = after_parameters or dict()
909
+ if parameter_name in before_parameters or parameter_name in after_parameters:
910
+ parameter_scope, (before_parameter, after_parameter) = self._safe_access_in(
911
+ parameters_scope, parameter_name, before_parameters, after_parameters
912
+ )
913
+ node_parameter = self._visit_parameter(
914
+ parameters_scope,
915
+ parameter_name,
916
+ before_parameter=before_parameter,
917
+ after_parameter=after_parameter,
918
+ )
919
+ return node_parameter
920
+ return None
921
+
922
+ def _retrieve_or_visit_resource(self, resource_name: str) -> NodeResource:
923
+ resources_scope, (before_resources, after_resources) = self._safe_access_in(
924
+ Scope(),
925
+ ResourcesKey,
926
+ self._before_template,
927
+ self._after_template,
928
+ )
929
+ resource_scope, (before_resource, after_resource) = self._safe_access_in(
930
+ resources_scope, resource_name, before_resources, after_resources
931
+ )
932
+ return self._visit_resource(
933
+ scope=resource_scope,
934
+ resource_name=resource_name,
935
+ before_resource=before_resource,
936
+ after_resource=after_resource,
464
937
  )
465
- # TODO: what is a change type for templates?
466
- return NodeTemplate(change_type=resources.change_type, resources=resources)
467
938
 
468
939
  @staticmethod
469
940
  def _is_intrinsic_function_name(function_name: str) -> bool:
470
- # TODO: expand vocabulary.
471
941
  # TODO: are intrinsic functions soft keywords?
472
942
  return function_name in INTRINSIC_FUNCTIONS
473
943
 
474
944
  @staticmethod
475
- def _sample_from(key: str, *objects: Maybe[dict]) -> Maybe[Any]:
945
+ def _safe_access_in(scope: Scope, key: str, *objects: Maybe[dict]) -> tuple[Scope, Maybe[Any]]:
476
946
  results = list()
477
947
  for obj in objects:
478
948
  # TODO: raise errors if not dict
@@ -480,20 +950,33 @@ class ChangeSetModel:
480
950
  results.append(obj.get(key, Nothing))
481
951
  else:
482
952
  results.append(obj)
483
- return results[0] if len(objects) == 1 else tuple(results)
953
+ new_scope = scope.open_scope(name=key)
954
+ return new_scope, results[0] if len(objects) == 1 else tuple(results)
484
955
 
485
956
  @staticmethod
486
- def _keys_of(*objects: Maybe[dict]) -> set[str]:
487
- keys: set[str] = set()
957
+ def _safe_keys_of(*objects: Maybe[dict]) -> list[str]:
958
+ key_set: set[str] = set()
488
959
  for obj in objects:
489
960
  # TODO: raise errors if not dict
490
961
  if isinstance(obj, dict):
491
- keys.update(obj.keys())
492
- return set(keys)
962
+ key_set.update(obj.keys())
963
+ # The keys list is sorted to increase reproducibility of the
964
+ # update graph build process or downstream logics.
965
+ keys = sorted(key_set)
966
+ return keys
967
+
968
+ @staticmethod
969
+ def _change_type_for_parent_of(change_types: list[ChangeType]) -> ChangeType:
970
+ parent_change_type = ChangeType.UNCHANGED
971
+ for child_change_type in change_types:
972
+ parent_change_type = parent_change_type.for_child(child_change_type)
973
+ if parent_change_type == ChangeType.MODIFIED:
974
+ break
975
+ return parent_change_type
493
976
 
494
977
  @staticmethod
495
978
  def _is_terminal(value: Any) -> bool:
496
- return type(value) in {int, float, bool, str, None}
979
+ return type(value) in {int, float, bool, str, None, NothingType}
497
980
 
498
981
  @staticmethod
499
982
  def _is_object(value: Any) -> bool: