Repository: Sceptre/sceptre

Issue:
Intermittent AttributeError when resolving dependencies using the sceptre.plan.plan module.
### Subject of the issue

Hello,

I've got an intermittent AttributeError when resolving dependencies using the sceptre.plan.plan module. It only occurs when using a particular hierarchy of dependencies. The error does not occur when using equivalent Sceptre CLI commands. I'm not sure if there's something I'm overlooking in my usage of the API. My config appears to be consistent with the CLI. I didn't spot anything similar when looking through existing issues regarding dependencies.

Cheers,

Pat

The dependency hierarchy:
- Stack 1 depends on Stack 2 and Stack 3.
- Stack 2 depends on Stack 3.

Workarounds:
- Removing either Stack 1s dependency on Stack 2, or Stack 2s dependency on Stack 3 stops the issue occurring.
- Adding `ignore_dependencies` to the SceptreContext will prevent the error. This seems to be practical for what I need to do.

The trace:
```
Traceback (most recent call last):
  File "/home/pjls/repos/sceptre-bug/sceptre-describe.py", line 10, in <module>
    change_sets = plan.describe()
  File "/home/pjls/.local/lib/python3.9/site-packages/sceptre/plan/plan.py", line 208, in describe
    self.resolve(command=self.describe.__name__)
  File "/home/pjls/.local/lib/python3.9/site-packages/sceptre/plan/plan.py", line 114, in resolve
    self.launch_order = self._generate_launch_order(reverse)
  File "/home/pjls/.local/lib/python3.9/site-packages/sceptre/plan/plan.py", line 62, in _generate_launch_order
    graph = self.graph.filtered(self.command_stacks, reverse)
  File "/home/pjls/.local/lib/python3.9/site-packages/sceptre/config/graph.py", line 47, in filtered
    relevant |= nx.algorithms.dag.ancestors(graph, stack)
  File "/home/pjls/.local/lib/python3.9/site-packages/sceptre/stack.py", line 251, in __eq__
    self.dependencies == stack.dependencies and
  File "/home/pjls/.local/lib/python3.9/site-packages/sceptre/stack.py", line 242, in __eq__
    self.name == stack.name and
AttributeError: 'str' object has no attribute 'name'
```

### Your environment
* version of sceptre (sceptre --version): 3.2.0
* version of python (python --version): 3.9.2
* which OS/distro: Debian Bullseye

### Steps to reproduce
Using the sample repository here: https://github.com/pjsmith404/sceptre-bug

1. Create the stacks using `sceptre launch .`
2. Create a change set using `sceptre create experimental test-change-set`
3. Describe the change set using `sceptre describe change-set experimental test-change-set
4. Describe the change set using `python sceptre-describe.py`. Sometimes it will work, most times it will throw the AttributeError

### Expected behaviour
I would expect dependency resolution via the CLI or the API to return the same result.

### Actual behaviour
Dependency resolution via the API causes intermittent AttributeErrors


Discussion:

Patch:
diff --git a/sceptre/context.py b/sceptre/context.py
index c958c97..73b86f9 100644
--- a/sceptre/context.py
+++ b/sceptre/context.py
@@ -60,7 +60,7 @@ class SceptreContext(object):
     ):
         # project_path: absolute path to the base sceptre project folder
         # e.g. absolute_path/to/sceptre_directory
-        self.project_path = normalise_path(project_path)
+        self.project_path = path.abspath(normalise_path(project_path))
 
         # config_path: holds the project stack_groups
         # e.g {project_path}/config


Test patch:
diff --git a/tests/test_context.py b/tests/test_context.py
index bb9694a..45cffbc 100644
--- a/tests/test_context.py
+++ b/tests/test_context.py
@@ -1,4 +1,4 @@
-from os import path
+from os import getcwd, path
 from unittest.mock import sentinel
 from sceptre.context import SceptreContext
 
@@ -9,7 +9,7 @@ class TestSceptreContext(object):
         self.config_path = "config"
         self.config_file = "config.yaml"
 
-    def test_context_with_path(self):
+    def test_context_with_path_in_cwd(self):
         self.context = SceptreContext(
             project_path="project_path/to/sceptre",
             command_path="command-path",
@@ -21,9 +21,39 @@ class TestSceptreContext(object):
             ignore_dependencies=sentinel.ignore_dependencies,
         )
 
-        sentinel.project_path = "project_path/to/sceptre"
+        sentinel.project_path = f"{getcwd()}/project_path/to/sceptre"
         assert self.context.project_path.replace(path.sep, "/") == sentinel.project_path
 
+    def test_context_with_relative_path(self):
+        self.context = SceptreContext(
+            project_path="./project_path/to/sceptre",
+            command_path="command-path",
+            command_params=sentinel.command_params,
+            user_variables=sentinel.user_variables,
+            options=sentinel.options,
+            output_format=sentinel.output_format,
+            no_colour=sentinel.no_colour,
+            ignore_dependencies=sentinel.ignore_dependencies,
+        )
+
+        expected = f"{getcwd()}/project_path/to/sceptre"
+        assert self.context.project_path.replace(path.sep, "/") == expected
+
+    def test_context_with_absolute_path(self):
+        self.context = SceptreContext(
+            project_path=f"{getcwd()}/project_path/to/sceptre",
+            command_path="command-path",
+            command_params=sentinel.command_params,
+            user_variables=sentinel.user_variables,
+            options=sentinel.options,
+            output_format=sentinel.output_format,
+            no_colour=sentinel.no_colour,
+            ignore_dependencies=sentinel.ignore_dependencies,
+        )
+
+        expected = f"{getcwd()}/project_path/to/sceptre"
+        assert self.context.project_path.replace(path.sep, "/") == expected
+
     def test_full_config_path_returns_correct_path(self):
         context = SceptreContext(
             project_path="project_path",
@@ -36,7 +66,7 @@ class TestSceptreContext(object):
             ignore_dependencies=sentinel.ignore_dependencies,
         )
 
-        full_config_path = path.join("project_path", self.config_path)
+        full_config_path = path.join(f"{getcwd()}/project_path", self.config_path)
         assert context.full_config_path() == full_config_path
 
     def test_full_command_path_returns_correct_path(self):
@@ -50,7 +80,9 @@ class TestSceptreContext(object):
             no_colour=sentinel.no_colour,
             ignore_dependencies=sentinel.ignore_dependencies,
         )
-        full_command_path = path.join("project_path", self.config_path, "command")
+        full_command_path = path.join(
+            f"{getcwd()}/project_path", self.config_path, "command"
+        )
 
         assert context.full_command_path() == full_command_path
 
@@ -65,7 +97,7 @@ class TestSceptreContext(object):
             no_colour=sentinel.no_colour,
             ignore_dependencies=sentinel.ignore_dependencies,
         )
-        full_templates_path = path.join("project_path", self.templates_path)
+        full_templates_path = path.join(f"{getcwd()}/project_path", self.templates_path)
         assert context.full_templates_path() == full_templates_path
 
     def test_clone__returns_full_clone_of_context(self):