Repository: Sceptre/sceptre

Issue:
Cyclic dependencies should result in a clearer error message
### Subject of the issue
When a cyclical dependency is detected in the dependency graph, a slightly unclear error message appears like:
```
sceptre.exceptions.CircularDependenciesError: Dependency cycle detected: configmap-lambda eks-securitygroups                                                                                          
```
It would be better if Sceptre behaved more like Terraform here and displayed the edges from the graph to make it clearer as to the path of the circular dependency in the graph.

### Your environment
* version of sceptre (sceptre --version) latest
* version of python (python --version) 3.10
* which OS/distro Ubuntu


Comments:

Patch:
diff --git a/sceptre/config/graph.py b/sceptre/config/graph.py
index 34d6b79..1ad11da 100644
--- a/sceptre/config/graph.py
+++ b/sceptre/config/graph.py
@@ -90,14 +90,18 @@ class StackGraph(object):
         :param stack: A Sceptre Stack
         :param dependencies: a collection of dependency paths
         """
-        self.logger.debug("Generate dependencies for stack {0}".format(stack))
+        self.logger.debug(f"Generate dependencies for stack {stack}")
         for dependency in set(dependencies):
             self.graph.add_edge(dependency, stack)
-            if not nx.is_directed_acyclic_graph(self.graph):
+            try:
+                cycle = nx.find_cycle(self.graph, orientation="original")
+                cycle_str = ", ".join([f"{edge[0]} -> {edge[1]}" for edge in cycle])
                 raise CircularDependenciesError(
-                    f"Dependency cycle detected: {stack} {dependency}"
+                    f"Dependency cycle detected: {cycle_str}"
                 )
-            self.logger.debug("  Added dependency: {}".format(dependency))
+            except nx.NetworkXNoCycle:
+                pass  # No cycle, continue
+            self.logger.debug(f"  Added dependency: {dependency}")
 
         if not dependencies:
             self.graph.add_node(stack)
diff --git a/sceptre/stack.py b/sceptre/stack.py
index 8a63309..fa5639a 100644
--- a/sceptre/stack.py
+++ b/sceptre/stack.py
@@ -9,7 +9,7 @@ This module implements a Stack class, which stores a Stack's data.
 
 import logging
 
-from typing import List, Any, Optional
+from typing import List, Dict, Union, Any, Optional
 from deprecation import deprecated
 
 from sceptre import __version__
@@ -26,6 +26,7 @@ from sceptre.resolvers import (
     ResolvableValueProperty,
     RecursiveResolve,
     PlaceholderType,
+    Resolver,
 )
 from sceptre.template import Template
 
@@ -262,7 +263,7 @@ class Stack:
         )
 
         self.s3_details = s3_details
-        self.parameters = parameters or {}
+        self.parameters = self._ensure_parameters(parameters or {})
         self.sceptre_user_data = sceptre_user_data or {}
         self.notifications = notifications or []
 
@@ -275,6 +276,30 @@ class Stack:
             )
         return value
 
+    def _ensure_parameters(
+        self, parameters: Dict[str, Any]
+    ) -> Dict[str, Union[str, List[Union[str, Resolver]], Resolver]]:
+        """Ensure CloudFormation parameters are of valid types"""
+
+        def is_valid(value: Any) -> bool:
+            return (
+                isinstance(value, str)
+                or (
+                    isinstance(value, list)
+                    and all(
+                        isinstance(item, str) or isinstance(item, Resolver)
+                        for item in value
+                    )
+                )
+                or isinstance(value, Resolver)
+            )
+
+        if not all(is_valid(value) for value in parameters.values()):
+            raise InvalidConfigFileError(
+                f"{self.name}: Values for parameters must be strings, lists or resolvers, got {parameters}"
+            )
+        return parameters
+
     def __repr__(self):
         return (
             "sceptre.stack.Stack("


Test patch:
diff --git a/tests/test_stack.py b/tests/test_stack.py
index 7e843e4..6f67e08 100644
--- a/tests/test_stack.py
+++ b/tests/test_stack.py
@@ -40,6 +40,11 @@ def stack_factory(**kwargs):
     return Stack(**call_kwargs)
 
 
+class FakeResolver(Resolver):
+    def resolve(self):
+        return "Fake"
+
+
 class TestStack(object):
     def setup_method(self, test_method):
         self.stack = Stack(
@@ -183,6 +188,46 @@ class TestStack(object):
                 obsolete="true",
             )
 
+    @pytest.mark.parametrize(
+        "parameters",
+        [
+            {"someNum": 1},
+            {"someBool": True},
+            {"aBadList": [1, 2, 3]},
+            {"aDict": {"foo": "bar"}},
+        ],
+    )
+    def test_init__invalid_parameters_raise_invalid_config_file_error(self, parameters):
+        with pytest.raises(InvalidConfigFileError):
+            Stack(
+                name="stack_name",
+                project_code="project_code",
+                template_handler_config={"type": "file"},
+                region="region",
+                parameters=parameters,
+            )
+
+    @pytest.mark.parametrize(
+        "parameters",
+        [
+            {"someNum": "1"},
+            {"someBool": "true"},
+            {"aList": ["aString", FakeResolver()]},
+            {"aResolver": FakeResolver()},
+        ],
+    )
+    def test_init__valid_parameters_do_not_raise_invalid_config_file_error(
+        self, parameters
+    ):
+        stack = Stack(
+            name="stack_name",
+            project_code="project_code",
+            template_handler_config={"type": "file"},
+            region="region",
+            parameters=parameters,
+        )
+        assert isinstance(stack, Stack)
+
     def test_stack_repr(self):
         assert (
             self.stack.__repr__() == "sceptre.stack.Stack("
@@ -248,14 +293,10 @@ class TestStack(object):
     def test_configuration_manager__sceptre_role_returns_value__returns_connection_manager_with_that_role(
         self,
     ):
-        class FakeResolver(Resolver):
-            def resolve(self):
-                return "role"
-
         self.stack.sceptre_role = FakeResolver()
 
         connection_manager = self.stack.connection_manager
-        assert connection_manager.sceptre_role == "role"
+        assert connection_manager.sceptre_role == "Fake"
 
     @fail_if_not_removed
     def test_iam_role__is_removed_on_removal_version(self):