Repository: cloudtools/troposphere (python)

Issue:
add comparison capability to CloudFormation intrinsic function objects
When writing tests for Troposphere templates, I often check that resource properties contain the appropriate value references. Currently, only the `Ref` object has comparison capabilities (`__eq__` only) requiring me to compare attributes/method outputs instead of comparing the objects directly. This functionality should be extended to the remainder of the intrinsic functions.

From briefly looking through all of the subclasses of `AWSHelperFn` , it appears this could be implemented on the base class as all objects will have a `data` attribute that could be assessed or the `.to_dict()` method could be used to compare to raw dictionaries. 

Pull request:
add support for object comparison
# Summary

Adds support to compare objects.

# Why This Is Needed

An advantage to using troposphere is being able to unit test various iterations of a dynamic template. However, the lack of support for being able to compare objects with one another makes these test cumbersome to write.

Resolves #1821

# What Changed

## Added

- added `.to_json()` method to base classes
- added `.__eq__()` and `.__ne__()` methods to base classes
  - these use the newly added `.to_json()` method for comparison of same objects created from the same class to ensure properties are sorted in the same order
  - comparing against a dict (or list for `AWSHelperFn` to support `Tags`) uses `.to_dict()` instead of converting both to a JSON string
- added `AWSHelperFn.__hash__()`
  - `tests/test_tags.py:TestTags.test_Unsortable` was failing with `TypeError: unhashable type: 'Sub'` without it 

Patch:
diff --git a/scripts/patches/events.py b/scripts/patches/events.py
index de37f3a8..4e11fdb2 100644
--- a/scripts/patches/events.py
+++ b/scripts/patches/events.py
@@ -13,11 +13,7 @@ patches = [
             "Required": False,
         },
     },
-    # Remove unused Tag and TagEntry (remapped to troposphere Tags)
-    {
-        "op": "remove",
-        "path": "/PropertyTypes/AWS::Events::Rule.Tag",
-    },
+    # Remove unused TagEntry
     {
         "op": "remove",
         "path": "/PropertyTypes/AWS::Events::EventBus.TagEntry",
diff --git a/troposphere/__init__.py b/troposphere/__init__.py
index d745add3..05b1592c 100644
--- a/troposphere/__init__.py
+++ b/troposphere/__init__.py
@@ -351,6 +351,10 @@ class BaseAWSObject:
         else:
             return {}
 
+    def to_json(self, *, indent: int = 4, sort_keys: bool = True) -> str:
+        """Object as JSON."""
+        return json.dumps(self.to_dict(), indent=indent, sort_keys=sort_keys)
+
     @classmethod
     def _from_dict(
         cls: Type[__BaseAWSObjectTypeVar], title: Optional[str] = None, **kwargs: Any
@@ -411,6 +415,16 @@ class BaseAWSObject:
                     msg += " (title: %s)" % title
                 raise ValueError(msg)
 
+    def __eq__(self, other: object) -> bool:
+        if isinstance(other, self.__class__):
+            return self.title == other.title and self.to_json() == other.to_json()
+        if isinstance(other, dict):
+            return {"title": self.title, **self.to_dict()} == other
+        return False
+
+    def __ne__(self, other: object) -> bool:
+        return not self == other
+
 
 class AWSObject(BaseAWSObject):
     dictname = "Properties"
@@ -491,6 +505,23 @@ class AWSHelperFn:
     def to_dict(self) -> Any:
         return encode_to_dict(self.data)
 
+    def to_json(self, *, indent: int = 4, sort_keys: bool = True) -> str:
+        """Object as JSON."""
+        return json.dumps(self.to_dict(), indent=indent, sort_keys=sort_keys)
+
+    def __eq__(self, other: object) -> bool:
+        if isinstance(other, self.__class__):
+            return self.to_json() == other.to_json()
+        if isinstance(other, (dict, list)):
+            return self.to_dict() == other
+        return False
+
+    def __hash__(self) -> int:
+        return hash(self.to_json(indent=0))
+
+    def __ne__(self, other: object) -> bool:
+        return not self == other
+
 
 class GenericHelperFn(AWSHelperFn):
     """Used as a fallback for the template generator"""
@@ -498,9 +529,6 @@ class GenericHelperFn(AWSHelperFn):
     def __init__(self, data: Any):
         self.data = self.getdata(data)
 
-    def to_dict(self) -> Any:
-        return encode_to_dict(self.data)
-
 
 class Base64(AWSHelperFn):
     def __init__(self, data: Any) -> None:
diff --git a/troposphere/appconfig.py b/troposphere/appconfig.py
index ef3c0b90..b2a73b15 100644
--- a/troposphere/appconfig.py
+++ b/troposphere/appconfig.py
@@ -20,7 +20,7 @@ class Application(AWSObject):
     `Application <http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-appconfig-application.html>`__
     """
 
-    resource_type = "Dev::AppConfig::Application"
+    resource_type = "AWS::AppConfig::Application"
 
     props: PropsDictType = {
         "Description": (str, False),
diff --git a/troposphere/events.py b/troposphere/events.py
index cedf80ca..8fb585ac 100644
--- a/troposphere/events.py
+++ b/troposphere/events.py
@@ -426,7 +426,8 @@ class RedshiftDataParameters(AWSProperty):
         "Database": (str, True),
         "DbUser": (str, False),
         "SecretManagerArn": (str, False),
-        "Sql": (str, False),
+        "Sql": (str, True),
+        "Sqls": ([str], False),
         "StatementName": (str, False),
         "WithEvent": (boolean, False),
     }


Test patch:
diff --git a/tests/test_basic.py b/tests/test_basic.py
index 0b5b13a9..09ad41e1 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -5,6 +5,7 @@ from troposphere import (
     AWSObject,
     AWSProperty,
     Cidr,
+    GenericHelperFn,
     If,
     Join,
     NoValue,
@@ -24,7 +25,74 @@ from troposphere.s3 import Bucket, PublicRead
 from troposphere.validators import positive_integer
 
 
+def double(x):
+    return positive_integer(x) * 2
+
+
+def call_correct(x):
+    return x
+
+
+def call_incorrect(x):
+    raise ValueError
+
+
+class FakeAWSProperty(AWSProperty):
+    props = {}
+
+
+class FakeAWSObject(AWSObject):
+    type = "Fake::AWS::Object"
+
+    props = {
+        "callcorrect": (call_correct, False),
+        "callincorrect": (call_incorrect, False),
+        "singlelist": (list, False),
+        "multilist": ([bool, int, float], False),
+        "multituple": ((bool, int), False),
+        "helperfun": (positive_integer, False),
+        "listhelperfun": ([double], False),
+    }
+
+    def validate(self):
+        properties = self.properties
+        title = self.title
+        type = self.type
+        if "callcorrect" in properties and "singlelist" in properties:
+            raise ValueError(
+                (
+                    "Cannot specify both 'callcorrect and 'singlelist' in "
+                    "object %s (type %s)" % (title, type)
+                )
+            )
+
+
 class TestBasic(unittest.TestCase):
+    def test___eq__(self):
+        """Test __eq__."""
+        assert FakeAWSObject("foobar", callcorrect=True) == FakeAWSObject(
+            "foobar", callcorrect=True
+        )
+        assert FakeAWSObject("foobar", callcorrect=True) == {
+            "title": "foobar",
+            "Properties": {"callcorrect": True},
+        }
+        assert GenericHelperFn("foobar") == GenericHelperFn("foobar")
+        assert GenericHelperFn({"foo": "bar"}) == {"foo": "bar"}
+
+    def test___ne__(self):
+        """Test __ne__."""
+        assert FakeAWSObject("foo", callcorrect=True) != FakeAWSObject(
+            "bar", callcorrect=True
+        )
+        assert FakeAWSObject("foobar", callcorrect=True) != FakeAWSObject(
+            "foobar", callcorrect=False
+        )
+        assert FakeAWSObject("foobar", callcorrect=True) != FakeAWSProperty("foobar")
+        assert GenericHelperFn("foobar") != GenericHelperFn("bar")
+        assert GenericHelperFn("foobar") != "foobar"
+        assert GenericHelperFn("foobar") != FakeAWSProperty("foobar")
+
     def test_badproperty(self):
         with self.assertRaises(AttributeError):
             Instance(
@@ -89,48 +157,6 @@ class TestBasic(unittest.TestCase):
         self.assertEqual(b2.BucketName, b.BucketName)
 
 
-def double(x):
-    return positive_integer(x) * 2
-
-
-def call_correct(x):
-    return x
-
-
-def call_incorrect(x):
-    raise ValueError
-
-
-class FakeAWSObject(AWSObject):
-    type = "Fake::AWS::Object"
-
-    props = {
-        "callcorrect": (call_correct, False),
-        "callincorrect": (call_incorrect, False),
-        "singlelist": (list, False),
-        "multilist": ([bool, int, float], False),
-        "multituple": ((bool, int), False),
-        "helperfun": (positive_integer, False),
-        "listhelperfun": ([double], False),
-    }
-
-    def validate(self):
-        properties = self.properties
-        title = self.title
-        type = self.type
-        if "callcorrect" in properties and "singlelist" in properties:
-            raise ValueError(
-                (
-                    "Cannot specify both 'callcorrect and 'singlelist' in "
-                    "object %s (type %s)" % (title, type)
-                )
-            )
-
-
-class FakeAWSProperty(AWSProperty):
-    props = {}
-
-
 class TestValidators(unittest.TestCase):
     def test_callcorrect(self):
         FakeAWSObject("fake", callcorrect=True)