Repository: Sceptre/sceptre

Issue:
Stack differ raises if a value is None
### Subject of the issue
The Stack Differ raises an exception if a parameter value is None.

### Your environment
* version of sceptre - master branch
* version of python (python --version) - 3.10
* which OS/distro - Ubuntu

### Steps to reproduce
Create a config like this:

```yaml
parameters:
  GlueDatabase:
```

Run 
```
sceptre diff -t difflib myapp.yaml
```

### Expected behaviour
This should work.

### Actual behaviour

```
Traceback (most recent call last):
  File "/usr/local/bin/sceptre", line 8, in <module>
    sys.exit(cli())
  File "/usr/local/lib/python3.10/site-packages/click/core.py", line 1130, in __call__
    return self.main(*args, **kwargs)
  File "/usr/local/lib/python3.10/site-packages/click/core.py", line 1055, in main
    rv = self.invoke(ctx)
  File "/usr/local/lib/python3.10/site-packages/click/core.py", line 1657, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
  File "/usr/local/lib/python3.10/site-packages/click/core.py", line 1404, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/usr/local/lib/python3.10/site-packages/click/core.py", line 760, in invoke
    return __callback(*args, **kwargs)
  File "/usr/local/lib/python3.10/site-packages/click/decorators.py", line 26, in new_func
    return f(get_current_context(), *args, **kwargs)
  File "/app/sceptre/sceptre/cli/helpers.py", line 43, in decorated
    return func(*args, **kwargs)
  File "/app/sceptre/sceptre/cli/diff.py", line 140, in diff_command
    diffs: Dict[Stack, StackDiff] = plan.diff(stack_differ)
  File "/app/sceptre/sceptre/plan/plan.py", line 417, in diff
    return self._execute(*args)
  File "/app/sceptre/sceptre/plan/plan.py", line 29, in wrapped
    return func(self, *args, **kwargs)
  File "/app/sceptre/sceptre/plan/plan.py", line 55, in _execute
    return executor.execute(*args)
  File "/app/sceptre/sceptre/plan/executor.py", line 52, in execute
    stack, status = future.result()
  File "/usr/local/lib/python3.10/concurrent/futures/_base.py", line 439, in result
    return self.__get_result()
  File "/usr/local/lib/python3.10/concurrent/futures/_base.py", line 391, in __get_result
    raise self._exception
  File "/usr/local/lib/python3.10/concurrent/futures/thread.py", line 58, in run
    result = self.fn(*self.args, **self.kwargs)
  File "/app/sceptre/sceptre/plan/executor.py", line 59, in _execute
    result = getattr(actions, self.command)(*args)
  File "/app/sceptre/sceptre/hooks/__init__.py", line 95, in decorated
    response = func(self, *args, **kwargs)
  File "/app/sceptre/sceptre/plan/actions.py", line 997, in diff
    return stack_differ.diff(self)
  File "/app/sceptre/sceptre/diffing/stack_differ.py", line 122, in diff
    generated_config = self._create_generated_config(stack_actions.stack)
  File "/app/sceptre/sceptre/diffing/stack_differ.py", line 150, in _create_generated_config
    parameters = self._extract_parameters_from_generated_stack(stack)
  File "/app/sceptre/sceptre/diffing/stack_differ.py", line 172, in _extract_parameters_from_generated_stack
    formatted_parameters[key] = value.rstrip("\n")
AttributeError: 'NoneType' object has no attribute 'rstrip'
```



Discussion:

Patch:
diff --git a/sceptre/diffing/stack_differ.py b/sceptre/diffing/stack_differ.py
index eeb942e..6712aa6 100644
--- a/sceptre/diffing/stack_differ.py
+++ b/sceptre/diffing/stack_differ.py
@@ -167,6 +167,12 @@ class StackDiffer(Generic[DiffType]):
         """
         formatted_parameters = {}
         for key, value in stack.parameters.items():
+            # When boto3 receives "None" for a cloudformation parameter, it treats it as if the
+            # value is not passed at all. To be consistent in our diffing, we need to skip Nones
+            # altogether.
+            if value is None:
+                continue
+
             if isinstance(value, list):
                 value = ",".join(item.rstrip("\n") for item in value)
             formatted_parameters[key] = value.rstrip("\n")
@@ -219,6 +225,7 @@ class StackDiffer(Generic[DiffType]):
             self._remove_deployed_default_parameters_that_arent_passed(
                 deployed_template_summary, generated_config, deployed_config
             )
+
         if not self.show_no_echo:
             # We don't actually want to show parameters Sceptre is passing that the local template
             # marks as NoEcho parameters (unless show_no_echo is set to true). Therefore those


Test patch:
diff --git a/tests/test_diffing/test_stack_differ.py b/tests/test_diffing/test_stack_differ.py
index 0329195..4f852f7 100644
--- a/tests/test_diffing/test_stack_differ.py
+++ b/tests/test_diffing/test_stack_differ.py
@@ -158,7 +158,7 @@ class TestStackDiffer:
     def expected_generated_config(self):
         return StackConfiguration(
             stack_name=self.external_name,
-            parameters=self.parameters_on_stack_config,
+            parameters=deepcopy(self.parameters_on_stack_config),
             stack_tags=deepcopy(self.tags),
             notifications=deepcopy(self.notifications),
             cloudformation_service_role=self.cloudformation_service_role,
@@ -168,7 +168,7 @@ class TestStackDiffer:
     def expected_deployed_config(self):
         return StackConfiguration(
             stack_name=self.external_name,
-            parameters=self.deployed_parameters,
+            parameters=deepcopy(self.deployed_parameters),
             stack_tags=deepcopy(self.deployed_tags),
             notifications=deepcopy(self.deployed_notification_arns),
             cloudformation_service_role=self.deployed_cloudformation_service_role,
@@ -332,6 +332,17 @@ class TestStackDiffer:
             self.expected_deployed_config, self.expected_generated_config
         )
 
+    def test_diff__generated_stack_has_none_for_parameter_value__its_treated_like_its_not_specified(
+        self,
+    ):
+        self.parameters_on_stack_config["new"] = None
+        expected_generated = self.expected_generated_config
+        del expected_generated.parameters["new"]
+        self.differ.diff(self.actions)
+        self.command_capturer.compare_stack_configurations.assert_called_with(
+            self.expected_deployed_config, expected_generated
+        )
+
     def test_diff__stack_exists_with_same_config_but_template_does_not__compares_identical_configs(
         self,
     ):