Repository: Sceptre/sceptre

Issue:
Not enough values to unpack given a badly formatted stack ref
### Subject of the issue
If a stack output is incorrectly formatted, and missing `::`, a confusing and unhandled exception is seen:

```
[2023-11-24 01:53:39] - REDACTED - Successfully initiated creation of Change Set 'sceptre-foundations-3551-834819d8978ad61146f7c9d9d30cae792d4fe192'
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 "/usr/local/lib/python3.10/site-packages/sceptre/cli/helpers.py", line 46, in decorated
    return func(*args, **kwargs)
  File "/usr/local/lib/python3.10/site-packages/sceptre/cli/create.py", line 49, in create_command
    plan.create_change_set(change_set_name)
  File "/usr/local/lib/python3.10/site-packages/sceptre/plan/plan.py", line 286, in create_change_set
    return self._execute(*args)
  File "/usr/local/lib/python3.10/site-packages/sceptre/plan/plan.py", line 30, in wrapped
    return func(self, *args, **kwargs)
  File "/usr/local/lib/python3.10/site-packages/sceptre/plan/plan.py", line 56, in _execute
    return executor.execute(*args)
  File "/usr/local/lib/python3.10/site-packages/sceptre/plan/executor.py", line 52, in execute
    stack, status = future.result()
  File "/usr/local/lib/python3.10/concurrent/futures/_base.py", line 451, in result
    return self.__get_result()
  File "/usr/local/lib/python3.10/concurrent/futures/_base.py", line 403, 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 "/usr/local/lib/python3.10/site-packages/sceptre/plan/executor.py", line 59, in _execute
    result = getattr(actions, self.command)(*args)
  File "/usr/local/lib/python3.10/site-packages/sceptre/hooks/__init__.py", line 95, in decorated
    response = func(self, *args, **kwargs)
  File "/usr/local/lib/python3.10/site-packages/sceptre/plan/actions.py", line 445, in create_change_set
    "Parameters": self._format_parameters(self.stack.parameters),
  File "/usr/local/lib/python3.10/site-packages/sceptre/resolvers/__init__.py", line 321, in __get__
    container = super().__get__(stack, stack_class)
  File "/usr/local/lib/python3.10/site-packages/sceptre/resolvers/__init__.py", line 230, in __get__
    return self.get_resolved_value(stack, stack_class)
  File "/usr/local/lib/python3.10/site-packages/sceptre/resolvers/__init__.py", line 371, in get_resolved_value
    _call_func_on_values(resolve, container, Resolver)
  File "/usr/local/lib/python3.10/site-packages/sceptre/helpers.py", line 91, in _call_func_on_values
    func_on_instance(key)
  File "/usr/local/lib/python3.10/site-packages/sceptre/helpers.py", line 85, in func_on_instance
    func(attr, key, value)
  File "/usr/local/lib/python3.10/site-packages/sceptre/resolvers/__init__.py", line 345, in resolve
    result = self.resolve_resolver_value(value)
  File "/usr/local/lib/python3.10/site-packages/sceptre/resolvers/__init__.py", line 2[84](https://github.com/REDACTED/actions/runs/6975938755/job/18983741778?pr=3551#step:9:86), in resolve_resolver_value
    return resolver.resolve()
  File "/usr/local/lib/python3.10/site-packages/sceptre/resolvers/stack_output.py", line 176, in resolve
    dependency_stack_name, output_key = stack_argument.split("::")
ValueError: not enough values to unpack (expected 2, got 1)
```

### Your environment
* version of sceptre (sceptre --version) 4.3.0
* version of python (python --version) 3.10
* which OS/distro

### Steps to reproduce

Create a stack reference like 
```
!stack_output_external myStackName
```

Noting that this is incomplete and missing the `::`.


### Expected behaviour
It should error out with a more helpful error message informing the expected format of the reference compared to the actual format.

### Actual behaviour
```
ValueError: not enough values to unpack (expected 2, got 1)
```

Comments:

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9c25517..da8c1e4 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -47,7 +47,7 @@ repos:
         language_version: python3.10
         args: ['--check']
   - repo: https://github.com/sirosen/check-jsonschema
-    rev: 0.27.3
+    rev: 0.27.4
     hooks:
       - id: check-github-workflows
       - id: check-github-actions
diff --git a/sceptre/resolvers/stack_output.py b/sceptre/resolvers/stack_output.py
index 6b22541..994636c 100644
--- a/sceptre/resolvers/stack_output.py
+++ b/sceptre/resolvers/stack_output.py
@@ -6,7 +6,12 @@ import shlex
 
 from botocore.exceptions import ClientError
 
-from sceptre.exceptions import DependencyStackMissingOutputError, StackDoesNotExistError
+from sceptre.exceptions import (
+    DependencyStackMissingOutputError,
+    StackDoesNotExistError,
+    SceptreException,
+)
+
 from sceptre.helpers import normalise_path, sceptreise_path
 from sceptre.resolvers import Resolver
 
@@ -108,7 +113,13 @@ class StackOutput(StackOutputBase):
         """
         Adds dependency to a Stack.
         """
-        dep_stack_name, self.output_key = self.argument.split("::")
+        try:
+            dep_stack_name, self.output_key = self.argument.split("::")
+        except ValueError as err:
+            raise SceptreException(
+                "!stack_output arg should match STACK_NAME::OUTPUT_KEY"
+            ) from err
+
         self.dependency_stack_name = sceptreise_path(normalise_path(dep_stack_name))
         self.stack.dependencies.append(self.dependency_stack_name)
 
@@ -120,7 +131,6 @@ class StackOutput(StackOutputBase):
         :rtype: str
         """
         self.logger.debug("Resolving Stack output: {0}".format(self.argument))
-
         friendly_stack_name = self.dependency_stack_name.replace(TEMPLATE_EXTENSION, "")
 
         stack = next(
@@ -163,21 +173,43 @@ class StackOutputExternal(StackOutputBase):
         """
         self.logger.debug("Resolving external Stack output: {0}".format(self.argument))
 
-        profile = None
-        region = None
-        sceptre_role = None
         arguments = shlex.split(self.argument)
 
+        if not arguments:
+            message = "!stack_output_external requires at least one argument"
+            raise SceptreException(message)
+
         stack_argument = arguments[0]
+        stack_args = iter(stack_argument.split("::"))
+
+        try:
+            dependency_stack_name = next(stack_args)
+            output_key = next(stack_args)
+
+        except StopIteration as err:
+            message = "!stack_output_external arg should match STACK_NAME::OUTPUT_KEY"
+            raise SceptreException(message) from err
+
+        profile = region = sceptre_role = None
+
         if len(arguments) > 1:
-            extra_args = arguments[1].split("::", 2)
-            profile, region, sceptre_role = extra_args + (3 - len(extra_args)) * [None]
+            extra_args = iter(arguments[1].split("::"))
+
+            profile = next(extra_args, None)
+            region = next(extra_args, None)
+            sceptre_role = next(extra_args, None)
+
+            try:
+                next(extra_args)
+                message = (
+                    "!stack_output_external second arg should be "
+                    "in the format 'PROFILE[::REGION[::SCEPTRE_ROLE]]'"
+                )
+                raise SceptreException(message)
+
+            except StopIteration:
+                pass
 
-        dependency_stack_name, output_key = stack_argument.split("::")
         return self._get_output_value(
-            dependency_stack_name,
-            output_key,
-            profile or None,
-            region or None,
-            sceptre_role or None,
+            dependency_stack_name, output_key, profile, region, sceptre_role
         )


Test patch:
diff --git a/tests/test_resolvers/test_stack_output.py b/tests/test_resolvers/test_stack_output.py
index ff20eff..0d671ac 100644
--- a/tests/test_resolvers/test_stack_output.py
+++ b/tests/test_resolvers/test_stack_output.py
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, patch, sentinel
 
 from sceptre.exceptions import DependencyStackMissingOutputError
 from sceptre.exceptions import StackDoesNotExistError
+from sceptre.exceptions import SceptreException
 
 from botocore.exceptions import ClientError
 
@@ -58,7 +59,10 @@ class TestStackOutputResolver(object):
 
         stack_output_resolver = StackOutput("not_a_valid_stack_output", stack)
 
-        with pytest.raises(ValueError, match="not enough values to unpack"):
+        with pytest.raises(
+            SceptreException,
+            match="!stack_output arg should match STACK_NAME::OUTPUT_KEY",
+        ):
             stack_output_resolver.setup()
 
     @patch("sceptre.resolvers.stack_output.StackOutput._get_output_value")
@@ -191,7 +195,10 @@ class TestStackOutputExternalResolver(object):
             "not_a_valid_stack_output", stack
         )
 
-        with pytest.raises(ValueError, match="not enough values to unpack"):
+        with pytest.raises(
+            SceptreException,
+            match="!stack_output_external arg should match STACK_NAME::OUTPUT_KEY",
+        ):
             stack_output_external_resolver.resolve()
 
     @patch("sceptre.resolvers.stack_output.StackOutputExternal._get_output_value")