Repository: Sceptre/sceptre

Issue:
Sceptre fails when multiple resolvers return None
### Sceptre fails when multiple resolvers return None
When multiple resolvers in a stack config return None sceptre fails.

### Your environment
* version of sceptre (3.1.0)
* version of python (3.8.10)
* which OS/distro -- windows 10, linux

### Steps to reproduce
```
sceptre_user_data:
  some_structure:
    abc0: !resolver1
    abc1: !resolver2
    abc3: !resolver3
```

### Expected behaviour
Variables in the stack should get None (the actual values that the resolvers returned)

### Actual behaviour
sceptre fails with
```
Traceback (most recent call last):

  File "scripts/render-templates.py", line 73, in main

    for stack, template_body in plan.generate().items():

  File "C:\Users\ykhalyavin\venv\cfn\lib\site-packages\sceptre\plan\plan.py", line 351, in generate

    return self._execute(*args)

  File "C:\Users\ykhalyavin\venv\cfn\lib\site-packages\sceptre\plan\plan.py", line 43, in _execute

    return executor.execute(*args)

  File "C:\Users\ykhalyavin\venv\cfn\lib\site-packages\sceptre\plan\executor.py", line 55, in execute

    stack, status = future.result()

  File "C:\Users\ykhalyavin\AppData\Local\Programs\Python\Python38\lib\concurrent\futures\_base.py", line 432, in result

    return self.__get_result()

  File "C:\Users\ykhalyavin\AppData\Local\Programs\Python\Python38\lib\concurrent\futures\_base.py", line 388, in __get_result

    raise self._exception

  File "C:\Users\ykhalyavin\AppData\Local\Programs\Python\Python38\lib\concurrent\futures\thread.py", line 57, in run

    result = self.fn(*self.args, **self.kwargs)

  File "C:\Users\ykhalyavin\venv\cfn\lib\site-packages\sceptre\plan\executor.py", line 62, in _execute

    result = getattr(actions, self.command)(*args)

  File "C:\Users\ykhalyavin\venv\cfn\lib\site-packages\sceptre\hooks\__init__.py", line 104, in decorated

    response = func(self, *args, **kwargs)

  File "C:\Users\ykhalyavin\venv\cfn\lib\site-packages\sceptre\plan\actions.py", line 634, in generate

    return self.stack.template.body

  File "C:\Users\ykhalyavin\venv\cfn\lib\site-packages\sceptre\stack.py", line 352, in template

    sceptre_user_data=self.sceptre_user_data,

  File "C:\Users\ykhalyavin\venv\cfn\lib\site-packages\sceptre\resolvers\__init__.py", line 191, in __get__

    container = super().__get__(stack, stack_class)

  File "C:\Users\ykhalyavin\venv\cfn\lib\site-packages\sceptre\resolvers\__init__.py", line 93, in __get__

    return self.get_resolved_value(stack, stack_class)

  File "C:\Users\ykhalyavin\venv\cfn\lib\site-packages\sceptre\resolvers\__init__.py", line 245, in get_resolved_value

    del attr[key]

IndexError: list assignment index out of rangeTraceback (most recent call last):
```


Discussion:

Patch:
diff --git a/sceptre/resolvers/__init__.py b/sceptre/resolvers/__init__.py
index 5dde952..1515d61 100644
--- a/sceptre/resolvers/__init__.py
+++ b/sceptre/resolvers/__init__.py
@@ -238,8 +238,19 @@ class ResolvableContainerProperty(ResolvableProperty):
             resolve, container, Resolver
         )
         # Remove keys and indexes from their containers that had resolvers resolve to None.
+        list_items_to_delete = []
         for attr, key in keys_to_delete:
-            del attr[key]
+            if isinstance(attr, list):
+                # If it's a list, we want to gather up the items to remove from the list.
+                # We don't want to modify the list length yet.
+                # Since removals will change all the other list indexes,
+                # we don't wan't to modify lists yet.
+                list_items_to_delete.append((attr, attr[key]))
+            else:
+                del attr[key]
+
+        for containing_list, item in list_items_to_delete:
+            containing_list.remove(item)
 
         return container
 


Test patch:
diff --git a/tests/test_resolvers/test_resolver.py b/tests/test_resolvers/test_resolver.py
index e7fc553..48c2507 100644
--- a/tests/test_resolvers/test_resolver.py
+++ b/tests/test_resolvers/test_resolver.py
@@ -341,6 +341,42 @@ class TestResolvableContainerPropertyDescriptor:
             'resolver': 'stack1'
         }
 
+    def test_get__resolver_resolves_to_none__value_is_dict__deletes_those_items_from_dict(self):
+        class MyResolver(Resolver):
+            def resolve(self):
+                return None
+
+        resolver = MyResolver()
+        self.mock_object.resolvable_container_property = {
+            'a': 4,
+            'b': resolver,
+            'c': 3,
+            'd': resolver,
+            'e': resolver,
+            'f': 5,
+        }
+        expected = {'a': 4, 'c': 3, 'f': 5}
+        assert self.mock_object.resolvable_container_property == expected
+
+    def test_get__resolver_resolves_to_none__value_is_dict__deletes_those_items_from_complex_structure(self):
+        class MyResolver(Resolver):
+            def resolve(self):
+                return None
+
+        resolver = MyResolver()
+        self.mock_object.resolvable_container_property = {
+            'a': 4,
+            'b': [
+                resolver,
+            ],
+            'c': [{
+                'v': resolver
+            }],
+            'd': 3
+        }
+        expected = {'a': 4, 'b': [], 'c': [{}], 'd': 3}
+        assert self.mock_object.resolvable_container_property == expected
+
     def test_get__resolver_resolves_to_none__value_is_list__deletes_that_item_from_list(self):
         class MyResolver(Resolver):
             def resolve(self):
@@ -368,6 +404,39 @@ class TestResolvableContainerPropertyDescriptor:
         expected = {'some key': 'some value'}
         assert self.mock_object.resolvable_container_property == expected
 
+    def test_get__resolvers_resolves_to_none__value_is_list__deletes_those_items_from_list(self):
+        class MyResolver(Resolver):
+            def resolve(self):
+                return None
+
+        resolver = MyResolver()
+
+        self.mock_object.resolvable_container_property = [
+            1,
+            resolver,
+            3,
+            resolver,
+            resolver,
+            6
+        ]
+        expected = [1, 3, 6]
+        assert self.mock_object.resolvable_container_property == expected
+
+    def test_get__resolvers_resolves_to_none__value_is_list__deletes_all_items_from_list(self):
+        class MyResolver(Resolver):
+            def resolve(self):
+                return None
+
+        resolver = MyResolver()
+
+        self.mock_object.resolvable_container_property = [
+            resolver,
+            resolver,
+            resolver
+        ]
+        expected = []
+        assert self.mock_object.resolvable_container_property == expected
+
     def test_get__value_in_list_is_none__returns_list_with_none(self):
         self.mock_object.resolvable_container_property = [
             1,