cloud-radar 0.13.3a16__py3-none-any.whl → 0.14.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import re
5
+ import uuid
5
6
  from pathlib import Path
6
7
  from typing import Any, Callable, Dict, Generator, List, Optional, Tuple, Union
7
8
 
@@ -26,8 +27,8 @@ class Template:
26
27
  NoValue: str = "" # Not yet implemented
27
28
  Partition: str = "aws" # Other regions not implemented
28
29
  Region: str = "us-east-1"
29
- StackId: str = "" # Not yet implemented
30
- StackName: str = "" # Not yet implemented
30
+ StackId: str = "" # If left blank this will be generated
31
+ StackName: str = "my-cloud-radar-stack"
31
32
  URLSuffix: str = "amazonaws.com" # Other regions not implemented
32
33
 
33
34
  def __init__(
@@ -80,6 +81,7 @@ class Template:
80
81
  self.transforms: Optional[Union[str, List[str]]] = self.template.get(
81
82
  "Transform", None
82
83
  )
84
+ self.allowed_functions: functions.Dispatch = self.load_allowed_functions()
83
85
 
84
86
  # All loaded, validate against any template level hooks
85
87
  # that have been configured
@@ -206,7 +208,7 @@ class Template:
206
208
  # If we get this far then we do not support this type of configuration file
207
209
  raise ValueError("Parameter file is not in a supported format")
208
210
 
209
- def load_allowed_functions(self) -> functions.Dispatch:
211
+ def load_allowed_functions(self):
210
212
  """Loads the allowed functions for this template.
211
213
 
212
214
  Raises:
@@ -230,8 +232,9 @@ class Template:
230
232
  return {**functions.ALL_FUNCTIONS, **transform_functions}
231
233
 
232
234
  if isinstance(self.transforms, list):
233
- # dict of transform functions
235
+
234
236
  transform_functions = {}
237
+
235
238
  for transform in self.transforms:
236
239
  if transform not in functions.TRANSFORMS:
237
240
  raise ValueError(f"Transform {transform} not supported")
@@ -248,13 +251,8 @@ class Template:
248
251
  def render_all_sections(self, template: Dict[str, Any]) -> Dict[str, Any]:
249
252
  """Solves all conditionals, references and pseudo variables for all sections"""
250
253
 
251
- allowed_functions = self.load_allowed_functions()
252
-
253
254
  if "Conditions" in template:
254
- template["Conditions"] = self.resolve_values(
255
- template["Conditions"],
256
- allowed_functions,
257
- )
255
+ template["Conditions"] = self.resolve_values(template["Conditions"])
258
256
 
259
257
  template_sections = ["Resources", "Outputs"]
260
258
 
@@ -277,10 +275,7 @@ class Template:
277
275
  if not condition_value:
278
276
  continue
279
277
 
280
- template[section][r_name] = self.resolve_values(
281
- r_value,
282
- allowed_functions,
283
- )
278
+ template[section][r_name] = self.resolve_values(r_value)
284
279
 
285
280
  return template
286
281
 
@@ -310,6 +305,19 @@ class Template:
310
305
 
311
306
  return template
312
307
 
308
+ # If the StackId variable is not set, generate a value for it
309
+ def _get_populated_stack_id(self) -> str:
310
+ if not Template.StackId:
311
+ # Not explicitly set, generate a value
312
+ unique_uuid = uuid.uuid4()
313
+
314
+ return (
315
+ f"arn:{Template.Partition}:cloudformation:{self.Region}:"
316
+ f"{Template.AccountId}:stack/{Template.StackName}/{unique_uuid}"
317
+ )
318
+
319
+ return Template.StackId
320
+
313
321
  def create_stack(
314
322
  self,
315
323
  params: Optional[Dict[str, str]] = None,
@@ -318,6 +326,7 @@ class Template:
318
326
  ):
319
327
  if region:
320
328
  self.Region = region
329
+ self.StackId = self._get_populated_stack_id()
321
330
 
322
331
  self.render(params, parameters_file=parameters_file)
323
332
 
@@ -331,7 +340,6 @@ class Template:
331
340
  def resolve_values( # noqa: max-complexity: 13
332
341
  self,
333
342
  data: Any,
334
- allowed_func: functions.Dispatch,
335
343
  ) -> Any:
336
344
  """Recurses through a Cloudformation template. Solving all
337
345
  references and variables along the way.
@@ -351,10 +359,7 @@ class Template:
351
359
  # This takes care of keys that not intrinsic functions,
352
360
  # except for the condition func
353
361
  if "Fn::" not in key and key != "Condition":
354
- data[key] = self.resolve_values(
355
- value,
356
- allowed_func,
357
- )
362
+ data[key] = self.resolve_values(value)
358
363
  continue
359
364
 
360
365
  # Takes care of the tricky 'Condition' key
@@ -372,21 +377,15 @@ class Template:
372
377
  return functions.condition(self, value)
373
378
 
374
379
  # Normal key like in an IAM role
375
- data[key] = self.resolve_values(
376
- value,
377
- allowed_func,
378
- )
380
+ data[key] = self.resolve_values(value)
379
381
  continue
380
382
 
381
- if key not in allowed_func:
383
+ if key not in self.allowed_functions:
382
384
  raise ValueError(f"{key} with value ({value}) not allowed here")
383
385
 
384
- value = self.resolve_values(
385
- value,
386
- functions.ALLOWED_FUNCTIONS[key],
387
- )
386
+ value = self.resolve_values(value)
388
387
 
389
- funct_result = allowed_func[key](self, value)
388
+ funct_result = self.allowed_functions[key](self, value)
390
389
 
391
390
  if isinstance(funct_result, str):
392
391
  # If the result is a string then process any
@@ -397,13 +396,7 @@ class Template:
397
396
 
398
397
  return data
399
398
  elif isinstance(data, list):
400
- return [
401
- self.resolve_values(
402
- item,
403
- allowed_func,
404
- )
405
- for item in data
406
- ]
399
+ return [self.resolve_values(item) for item in data]
407
400
  elif isinstance(data, str):
408
401
  return self.resolve_dynamic_references(data)
409
402
  else:
@@ -283,9 +283,7 @@ def condition(template: "Template", name: Any) -> bool:
283
283
  condition_value = template.template["Conditions"][name]
284
284
 
285
285
  if not isinstance(condition_value, bool):
286
- condition_value: bool = template.resolve_values( # type: ignore
287
- condition_value, allowed_func=ALLOWED_NESTED_CONDITIONS
288
- )
286
+ condition_value: bool = template.resolve_values(condition_value) # type: ignore
289
287
 
290
288
  return condition_value
291
289
 
@@ -455,7 +453,18 @@ def get_att(template: "Template", values: Any) -> str:
455
453
  if resource_name not in template.template["Resources"]:
456
454
  raise KeyError(f"Fn::GetAtt - Resource {resource_name} not found in template.")
457
455
 
458
- return f"{resource_name}.{att_name}"
456
+ # Get the resource definition
457
+ resource = template.template["Resources"][resource_name]
458
+
459
+ # Check if there is a value in the resource Metadata for this attribute.
460
+ # If the attribute requested is in the metadata, return it.
461
+ # Otherwise use the string value of "{resource_name}.{att_name}"
462
+
463
+ metadata = resource.get("Metadata", {})
464
+ cloud_radar_metadata = metadata.get("Cloud-Radar", {})
465
+ attribute_values = cloud_radar_metadata.get("attribute-values", {})
466
+
467
+ return attribute_values.get(att_name, f"{resource_name}.{att_name}")
459
468
 
460
469
 
461
470
  def get_azs(_t: "Template", region: Any) -> List[str]:
@@ -931,106 +940,6 @@ ALL_FUNCTIONS: Dispatch = {
931
940
  **INTRINSICS,
932
941
  }
933
942
 
934
- ALLOWED_NESTED_CONDITIONS: Dispatch = {
935
- "Fn::FindInMap": find_in_map,
936
- "Ref": ref,
937
- **CONDITIONS,
938
- }
939
-
940
- # Cloudformation only allows certain functions to be called from inside
941
- # other functions. The keys are the function name and the values are the
942
- # functions that are allowed to be nested inside it.
943
- ALLOWED_FUNCTIONS: Dict[str, Dispatch] = {
944
- "Fn::And": ALLOWED_NESTED_CONDITIONS,
945
- "Fn::Equals": {**ALLOWED_NESTED_CONDITIONS, "Fn::Join": join, "Fn::Select": select},
946
- "Fn::If": {
947
- "Fn::Base64": base64,
948
- "Fn::FindInMap": find_in_map,
949
- "Fn::GetAtt": get_att,
950
- "Fn::GetAZs": get_azs,
951
- "Fn::If": if_,
952
- "Fn::Join": join,
953
- "Fn::Select": select,
954
- "Fn::Sub": sub,
955
- "Ref": ref,
956
- "Fn::ImportValue": import_value,
957
- },
958
- "Fn::Not": ALLOWED_NESTED_CONDITIONS,
959
- "Fn::Or": ALLOWED_NESTED_CONDITIONS,
960
- "Condition": {}, # Only allows strings
961
- "Fn::Base64": ALL_FUNCTIONS,
962
- "Fn::Cidr": {
963
- "Fn::Select": select,
964
- "Ref": ref,
965
- },
966
- "Fn::FindInMap": {
967
- "Fn::FindInMap": find_in_map,
968
- "Ref": ref,
969
- },
970
- "Fn::GetAtt": {}, # This one is complicated =/
971
- "Fn::GetAZs": {
972
- "Ref": ref,
973
- },
974
- "Fn::ImportValue": {
975
- "Fn::Base64": base64,
976
- "Fn::FindInMap": find_in_map,
977
- "Fn::If": if_,
978
- "Fn::Join": join,
979
- "Fn::Select": select,
980
- "Fn::Split": split,
981
- "Fn::Sub": sub,
982
- "Ref": ref,
983
- }, # Import value can't depend on resources (not implemented)
984
- "Fn::Join": {
985
- "Fn::Base64": base64,
986
- "Fn::FindInMap": find_in_map,
987
- "Fn::GetAtt": get_att,
988
- "Fn::GetAZs": get_azs,
989
- "Fn::If": if_,
990
- "Fn::ImportValue": import_value,
991
- "Fn::Join": join,
992
- "Fn::Split": split,
993
- "Fn::Select": select,
994
- "Fn::Sub": sub,
995
- "Ref": ref,
996
- },
997
- "Fn::Select": {
998
- "Fn::FindInMap": find_in_map,
999
- "Fn::GetAtt": get_att,
1000
- "Fn::GetAZs": get_azs,
1001
- "Fn::If": if_,
1002
- "Fn::Split": split,
1003
- "Ref": ref,
1004
- },
1005
- "Fn::Split": {
1006
- "Fn::Base64": base64,
1007
- "Fn::FindInMap": find_in_map,
1008
- "Fn::GetAtt": get_att,
1009
- "Fn::GetAZs": get_azs,
1010
- "Fn::If": if_,
1011
- "Fn::ImportValue": import_value,
1012
- "Fn::Join": join,
1013
- "Fn::Split": split,
1014
- "Fn::Select": select,
1015
- "Fn::Sub": sub,
1016
- "Ref": ref,
1017
- },
1018
- "Fn::Sub": {
1019
- "Fn::Base64": base64,
1020
- "Fn::FindInMap": find_in_map,
1021
- "Fn::GetAtt": get_att,
1022
- "Fn::GetAZs": get_azs,
1023
- "Fn::If": if_,
1024
- "Fn::ImportValue": import_value,
1025
- "Fn::Join": join,
1026
- "Fn::Select": select,
1027
- "Ref": ref,
1028
- "Fn::Sub": sub,
1029
- },
1030
- "Fn::Transform": {}, # Transform isn't fully implemented
1031
- "Ref": {}, # String only.
1032
- }
1033
-
1034
943
  # Extra functions that are allowed if the template is using a transform.
1035
944
  TRANSFORMS: Dict[str, Dispatch] = {
1036
945
  "AWS::CodeDeployBlueGreen": {},
@@ -7,40 +7,38 @@ from cloud_radar.cf.unit._template import Template
7
7
 
8
8
  def test_load_allowed_functions_no_transforms():
9
9
  template = Template({})
10
- result = template.load_allowed_functions()
11
- assert result == functions.ALL_FUNCTIONS
10
+ template.load_allowed_functions()
11
+ assert template.allowed_functions == functions.ALL_FUNCTIONS
12
12
 
13
13
 
14
14
  def test_load_allowed_functions_single_transform():
15
15
  template = Template({"Transform": "AWS::Serverless-2016-10-31"})
16
- result = template.load_allowed_functions()
16
+ template.load_allowed_functions()
17
17
  expected = {
18
18
  **functions.ALL_FUNCTIONS,
19
19
  **functions.TRANSFORMS["AWS::Serverless-2016-10-31"],
20
20
  }
21
- assert result == expected
21
+ assert template.allowed_functions == expected
22
22
 
23
23
 
24
24
  def test_load_allowed_functions_multiple_transforms():
25
25
  template = Template({"Transform": ["AWS::Serverless-2016-10-31", "AWS::Include"]})
26
- result = template.load_allowed_functions()
26
+ template.load_allowed_functions()
27
27
  expected = {
28
28
  **functions.ALL_FUNCTIONS,
29
29
  **functions.TRANSFORMS["AWS::Serverless-2016-10-31"],
30
30
  **functions.TRANSFORMS["AWS::Include"],
31
31
  }
32
- assert result == expected
32
+ assert template.allowed_functions == expected
33
33
 
34
34
 
35
35
  def test_load_allowed_functions_invalid_transform():
36
- template = Template({"Transform": "InvalidTransform"})
36
+
37
37
  with pytest.raises(ValueError):
38
- template.load_allowed_functions()
38
+ Template({"Transform": "InvalidTransform"})
39
39
 
40
40
 
41
41
  def test_load_allowed_functions_invalid_transforms():
42
- template = Template(
43
- {"Transform": ["AWS::Serverless-2016-10-31", "InvalidTransform"]}
44
- )
42
+
45
43
  with pytest.raises(ValueError):
46
- template.load_allowed_functions()
44
+ Template({"Transform": ["AWS::Serverless-2016-10-31", "InvalidTransform"]})
@@ -1,13 +1,12 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.3
2
2
  Name: cloud-radar
3
- Version: 0.13.3a16
3
+ Version: 0.14.0
4
4
  Summary: Run functional tests on cloudformation stacks.
5
- Home-page: https://github.com/DontShaveTheYak/cloud-radar
6
5
  License: Apache-2.0
7
6
  Keywords: aws,cloudformation,cloud-radar,testing,taskcat,cloud,radar
8
7
  Author: Levi Blaney
9
- Author-email: shadycuz@gmail.com
10
- Requires-Python: >=3.9,<3.14
8
+ Author-email: shadycuz+dev@gmail.com
9
+ Requires-Python: >=3.9,<4.0
11
10
  Classifier: Development Status :: 2 - Pre-Alpha
12
11
  Classifier: License :: OSI Approved :: Apache Software License
13
12
  Classifier: Operating System :: OS Independent
@@ -15,17 +14,15 @@ Classifier: Programming Language :: Python :: 3
15
14
  Classifier: Programming Language :: Python :: 3.9
16
15
  Classifier: Programming Language :: Python :: 3.10
17
16
  Classifier: Programming Language :: Python :: 3.11
18
- Classifier: Programming Language :: Python :: 3
19
- Classifier: Programming Language :: Python :: 3.10
20
- Classifier: Programming Language :: Python :: 3.11
21
17
  Classifier: Programming Language :: Python :: 3.12
22
18
  Classifier: Programming Language :: Python :: 3.13
23
- Classifier: Programming Language :: Python :: 3.9
24
19
  Classifier: Topic :: Software Development :: Libraries
25
20
  Classifier: Topic :: Software Development :: Testing
26
- Requires-Dist: botocore (>=1.35.36) ; python_version >= "3.13"
21
+ Requires-Dist: botocore (>=1.35.36,<2.0.0)
27
22
  Requires-Dist: cfn-flip (>=1.3.0,<2.0.0)
28
- Requires-Dist: taskcat (>=0.9.41,<0.10.0)
23
+ Requires-Dist: taskcat (>=0.9.41,<1.0.0)
24
+ Project-URL: Changelog, https://github.com/DontShaveTheYak/cloud-radar/releases
25
+ Project-URL: Issues, https://github.com/DontShaveTheYak/cloud-radar/issues
29
26
  Project-URL: Repository, https://github.com/DontShaveTheYak/cloud-radar
30
27
  Description-Content-Type: text/markdown
31
28
 
@@ -37,7 +34,7 @@ Description-Content-Type: text/markdown
37
34
  *** for contributors-url, forks-url, etc. This is an optional, concise syntax you may use.
38
35
  *** https://www.markdownguide.org/basic-syntax/#reference-style-links
39
36
  -->
40
- [![Python][python-shield]][pypi-url]
37
+ [![Python][py-versions-shield]][pypi-url]
41
38
  [![Latest][version-shield]][pypi-url]
42
39
  [![Tests][test-shield]][test-url]
43
40
  [![Coverage][codecov-shield]][codecov-url]
@@ -200,8 +197,8 @@ The default values for pseudo parameters:
200
197
  | **NoValue** | "" |
201
198
  | **Partition** | "aws" |
202
199
  | Region | "us-east-1" |
203
- | **StackId** | "" |
204
- | **StackName** | "" |
200
+ | StackId | (generated based on other values) |
201
+ | StackName | "my-cloud-radar-stack" |
205
202
  | **URLSuffix** | "amazonaws.com" |
206
203
  _Note: Bold variables are not fully implemented yet see the [Roadmap](#roadmap)_
207
204
 
@@ -242,6 +239,24 @@ dynamic_references = {
242
239
  template = Template(template_content, dynamic_references=dynamic_references)
243
240
  ```
244
241
 
242
+ There are cases where the default behaviour of our `GetAtt` implementation may not be sufficient and you need a more accurate returned value. When unit testing there are no real AWS resources created, and cloud-radar does not attempt to realistically generate attribute values - a string is always returned. This works good enough most of the time, but there are some cases where if you are attempting to apply intrinsic functions against the attribute value it needs to be more correct. When this occurs, you can add Metadata to the template to provide test values to use.
243
+
244
+ ```
245
+ Resources:
246
+ MediaPackageV2Channel:
247
+ Type: AWS::MediaPackageV2::Channel
248
+ Metadata:
249
+ Cloud-Radar:
250
+ attribute-values:
251
+ # Default behaviour of a string is not good enough here, the attribute value is expected to be a List.
252
+ IngestEndpointUrls:
253
+ - http://one.example.com
254
+ - http://two.example.com
255
+ Properties:
256
+ ChannelGroupName: dev_video_1
257
+ ChannelName: !Sub ${AWS::StackName}-MediaPackageChannel
258
+ ```
259
+
245
260
  A real unit testing example using Pytest can be seen [here](./tests/test_cf/test_examples/test_unit.py)
246
261
 
247
262
  </details>
@@ -338,7 +353,6 @@ A real functional testing example using Pytest can be seen [here](./tests/test_c
338
353
  ### Unit
339
354
  - Add full functionality to pseudo variables.
340
355
  * Variables like `Partition`, `URLSuffix` should change if the region changes.
341
- * Variables like `StackName` and `StackId` should have a better default than ""
342
356
  - Handle References to resources that shouldn't exist.
343
357
  * It's currently possible that a `!Ref` to a Resource stays in the final template even if that resource is later removed because of a conditional.
344
358
 
@@ -374,11 +388,12 @@ Levi - [@shady_cuz](https://twitter.com/shady_cuz)
374
388
  * [Taskcat](https://aws-quickstart.github.io/taskcat/)
375
389
  * [Hypermodern Python](https://cjolowicz.github.io/posts/hypermodern-python-01-setup/)
376
390
  * [Best-README-Template](https://github.com/othneildrew/Best-README-Template)
377
- * @dhutchison - He was the first contributor to this project and finished the last couple of features to make this project complete. Thank you!
391
+ * [David Hutchison (@dhutchison)](https://github.com/dhutchison) - He was the first contributor to this project and finished the last couple of features to make this project complete. Thank you!
378
392
 
379
393
  <!-- MARKDOWN LINKS & IMAGES -->
380
394
  <!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
381
395
  [python-shield]: https://img.shields.io/pypi/pyversions/cloud-radar?style=for-the-badge
396
+ [py-versions-shield]: https://img.shields.io/pypi/pyversions/cloud-radar?style=for-the-badge
382
397
  [version-shield]: https://img.shields.io/pypi/v/cloud-radar?label=latest&style=for-the-badge
383
398
  [pypi-url]: https://pypi.org/project/cloud-radar/
384
399
  [test-shield]: https://img.shields.io/github/actions/workflow/status/DontShaveTheYak/cloud-radar/test.yml?label=Tests&style=for-the-badge
@@ -9,10 +9,10 @@ cloud_radar/cf/unit/_output.py,sha256=rhQQ4aJu06bxAoG7GOXkuOPzAmLCrTA2Ytb8rNXA4V
9
9
  cloud_radar/cf/unit/_parameter.py,sha256=AdPIqc2ggSW1VR0USF30zjphX3z1HHbGKQt8n-Kzhfo,3199
10
10
  cloud_radar/cf/unit/_resource.py,sha256=dWaR-5s6ea5mIu6Dhf1hY31Wd4WLHbHsbyxnu2Tz6QI,8512
11
11
  cloud_radar/cf/unit/_stack.py,sha256=_S0L9O7Lw-QAJDKubClp2b6UYtYfyzg272_7WQkUdo8,5785
12
- cloud_radar/cf/unit/_template.py,sha256=u1r1RkUPI0t8SGolWEjb3doavFwUQRaE0wOKCi_ijiI,32242
13
- cloud_radar/cf/unit/functions.py,sha256=XtYiEG3PZT-JjQwRls-59XKHYdw-hWH9QaOZcb9zd_Y,28719
14
- cloud_radar/cf/unit/test__template.py,sha256=jVPMJTn6Q0sSZ8BjRGyutuR9-NjdHdwDTVsd2kvjQbs,1491
15
- cloud_radar-0.13.3a16.dist-info/LICENSE.txt,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
- cloud_radar-0.13.3a16.dist-info/METADATA,sha256=6m-Hnxa1j7JZlpHSC5nxWKyNUUWvq9pS1TQ1XmFCrfE,15745
17
- cloud_radar-0.13.3a16.dist-info/WHEEL,sha256=7Z8_27uaHI_UZAc4Uox4PpBhQ9Y5_modZXWMxtUi4NU,88
18
- cloud_radar-0.13.3a16.dist-info/RECORD,,
12
+ cloud_radar/cf/unit/_template.py,sha256=RSZjqD0FgBYMgyZb_KUkIOaO_mTODLJH9czvhZ_ii3U,32196
13
+ cloud_radar/cf/unit/functions.py,sha256=NSJh14wJK4dmTibmGqB0aK_kAP1RFW9RjdkZZsD11tc,26332
14
+ cloud_radar/cf/unit/test__template.py,sha256=nHc9WziIeGDo0d-rsn3HV4X7BsoKmwwHUxcp3qzuZo8,1414
15
+ cloud_radar-0.14.0.dist-info/LICENSE.txt,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
16
+ cloud_radar-0.14.0.dist-info/METADATA,sha256=o-hK1VPCzxFr2WIZfHAM34-jmS91vz41kz9byYR9jVo,16782
17
+ cloud_radar-0.14.0.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
18
+ cloud_radar-0.14.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 1.5.2
2
+ Generator: poetry-core 2.1.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any