Repository: Sceptre/sceptre

Issue:
Add coloured diffs
### Subject of the issue
Sceptre's diff output is a useful tool but many us are probably feeding its output into `colordiff` in order to make its diffs more readable. A feature that colours that diffs and can be disabled via `--no-color` is needed.


Comments:

Patch:
diff --git a/sceptre/cli/diff.py b/sceptre/cli/diff.py
index a7f2697..4a723c8 100644
--- a/sceptre/cli/diff.py
+++ b/sceptre/cli/diff.py
@@ -8,7 +8,7 @@ from click import Context
 
 from sceptre.cli.helpers import catch_exceptions
 from sceptre.context import SceptreContext
-from sceptre.diffing.diff_writer import DeepDiffWriter, DiffLibWriter, DiffWriter
+from sceptre.diffing.diff_writer import DeepDiffWriter, DiffLibWriter, ColouredDiffLibWriter, DiffWriter
 from sceptre.diffing.stack_differ import DeepDiffStackDiffer, DifflibStackDiffer, StackDiff
 from sceptre.helpers import null_context
 from sceptre.plan.plan import SceptrePlan
@@ -95,14 +95,16 @@ def diff_command(
     Particularly in cases where the replaced value doesn't work in the template as the template logic
     requires and causes an error, there is nothing further Sceptre can do and diffing will fail.
     """
+    no_colour = ctx.obj.get("no_colour")
+
     context = SceptreContext(
         command_path=path,
         project_path=ctx.obj.get("project_path"),
         user_variables=ctx.obj.get("user_variables"),
         options=ctx.obj.get("options"),
         ignore_dependencies=ctx.obj.get("ignore_dependencies"),
-        output_format=ctx.obj.get('output_format'),
-        no_colour=ctx.obj.get('no_colour')
+        output_format=ctx.obj.get("output_format"),
+        no_colour=no_colour
     )
     output_format = context.output_format
     plan = SceptrePlan(context)
@@ -112,9 +114,9 @@ def diff_command(
     if differ == "deepdiff":
         stack_differ = DeepDiffStackDiffer(show_no_echo)
         writer_class = DeepDiffWriter
-    elif differ == 'difflib':
+    elif differ == "difflib":
         stack_differ = DifflibStackDiffer(show_no_echo)
-        writer_class = DiffLibWriter
+        writer_class = DiffLibWriter if no_colour else ColouredDiffLibWriter
     else:
         raise ValueError(f"Unexpected differ type: {differ}")
 
diff --git a/sceptre/diffing/diff_writer.py b/sceptre/diffing/diff_writer.py
index d4c79d3..8d2d672 100644
--- a/sceptre/diffing/diff_writer.py
+++ b/sceptre/diffing/diff_writer.py
@@ -3,6 +3,7 @@ import json
 import re
 from abc import abstractmethod
 from typing import TextIO, Generic, List
+from colorama import Fore
 
 import cfn_flip
 import yaml
@@ -207,3 +208,22 @@ class DiffLibWriter(DiffWriter[List[str]]):
         # Difflib doesn't care about the output format since it only outputs strings. We would have
         # accounted for the output format in the differ itself rather than here.
         return '\n'.join(diff)
+
+
+class ColouredDiffLibWriter(DiffLibWriter):
+    """A DiffWriter for StackDiffs where the DiffType is a a list of strings with coloured diffs."""
+
+    def _colour_diff(self, diff: List[str]):
+        for line in diff:
+            if line.startswith('+'):
+                yield Fore.GREEN + line + Fore.RESET
+            elif line.startswith('-'):
+                yield Fore.RED + line + Fore.RESET
+            elif line.startswith('^'):
+                yield Fore.BLUE + line + Fore.RESET
+            else:
+                yield line
+
+    def dump_diff(self, diff: List[str]) -> str:
+        coloured_diff = self._colour_diff(diff)
+        return super().dump_diff(coloured_diff)


Test patch:
diff --git a/tests/test_diffing/test_diff_writer.py b/tests/test_diffing/test_diff_writer.py
index 41c4679..769a2f5 100644
--- a/tests/test_diffing/test_diff_writer.py
+++ b/tests/test_diffing/test_diff_writer.py
@@ -9,8 +9,10 @@ import pytest
 import yaml
 from deepdiff import DeepDiff
 
-from sceptre.diffing.diff_writer import DiffWriter, DeepDiffWriter, deepdiff_json_defaults, DiffLibWriter
+from sceptre.diffing.diff_writer import DiffWriter, DeepDiffWriter, deepdiff_json_defaults, \
+    DiffLibWriter, ColouredDiffLibWriter
 from sceptre.diffing.stack_differ import StackDiff, DiffType, StackConfiguration
+from colorama import Fore
 
 
 class ImplementedDiffWriter(DiffWriter):
@@ -368,3 +370,74 @@ class TestDiffLibWriter:
         result = self.writer.dump_diff(self.diff.config_diff)
         expected = '\n'.join(self.diff.config_diff)
         assert result == expected
+
+
+class TestColouredDiffLibWriter:
+    def setup_method(self, method):
+        self.stack_name = 'stack'
+
+        self.is_deployed = True
+        self.output_format = 'yaml'
+
+        self.output_stream = StringIO()
+
+        self.config1 = StackConfiguration(
+            stack_name=self.stack_name,
+            parameters={},
+            stack_tags={},
+            notifications=[],
+            role_arn=None
+        )
+
+        self.template1 = "foo"
+
+    @property
+    def template_diff(self):
+        return [
+            '--- file1.txt   2018-01-11 10:39:38.237464052 +0000\n',
+            '+++ file2.txt   2018-01-11 10:40:00.323423021 +0000\n',
+            '@@ -1,4 +1,4 @@\n',
+            ' cat\n',
+            '-mv\n',
+            '-comm\n',
+            ' cp\n',
+            '+diff\n',
+            '+comm\n'
+        ]
+
+    @property
+    def config_diff(self):
+        return []
+
+    @property
+    def diff(self):
+        return StackDiff(
+            self.stack_name,
+            self.template_diff,
+            self.config_diff,
+            self.is_deployed,
+            self.config1,
+            self.template1
+        )
+
+    @property
+    def writer(self):
+        return ColouredDiffLibWriter(
+            self.diff,
+            self.output_stream,
+            self.output_format
+        )
+
+    def test_lines_are_coloured(self):
+        coloured = (
+            f'{Fore.RED}--- file1.txt   2018-01-11 10:39:38.237464052 +0000\n{Fore.RESET}\n'
+            f"{Fore.GREEN}+++ file2.txt   2018-01-11 10:40:00.323423021 +0000\n{Fore.RESET}\n"
+            '@@ -1,4 +1,4 @@\n\n'
+            " cat\n\n"
+            f'{Fore.RED}-mv\n{Fore.RESET}\n'
+            f"{Fore.RED}-comm\n{Fore.RESET}\n"
+            ' cp\n\n'
+            f"{Fore.GREEN}+diff\n{Fore.RESET}\n"
+            f'{Fore.GREEN}+comm\n{Fore.RESET}'
+        )
+        assert self.writer.dump_diff(self.template_diff) == coloured