Add MatchesDictExceptForKeys custom matcher

Add a custom matcher that compares two dictionaries, except for
keys matching an exclusion list.
The matcher returns with a mismatch object design to provide a
useful message in case of failed assertion, which pinpoints the
differences between the two dicts.

Verified via unit tests.

Partially implements: bp/tempest-client-scenarios

Change-Id: Ibdd34fa1f095ee2ee33e11a8e23e654fb6d12b89
diff --git a/tempest/common/custom_matchers.py b/tempest/common/custom_matchers.py
index 996c365..7348a7d 100644
--- a/tempest/common/custom_matchers.py
+++ b/tempest/common/custom_matchers.py
@@ -13,6 +13,9 @@
 #    under the License.
 
 import re
+from unittest import util
+
+from testtools import helpers
 
 
 class ExistsAllResponseHeaders(object):
@@ -172,3 +175,54 @@
 
     def get_details(self):
         return {}
+
+
+class MatchesDictExceptForKeys(object):
+    """Matches two dictionaries. Verifies all items are equals except for those
+    identified by a list of keys.
+    """
+
+    def __init__(self, expected, excluded_keys=None):
+        self.expected = expected
+        self.excluded_keys = excluded_keys if excluded_keys is not None else []
+
+    def match(self, actual):
+        filtered_expected = helpers.dict_subtract(self.expected,
+                                                  self.excluded_keys)
+        filtered_actual = helpers.dict_subtract(actual,
+                                                self.excluded_keys)
+        if filtered_actual != filtered_expected:
+            return DictMismatch(filtered_expected, filtered_actual)
+
+
+class DictMismatch(object):
+    """Mismatch between two dicts describes deltas"""
+
+    def __init__(self, expected, actual):
+        self.expected = expected
+        self.actual = actual
+        self.intersect = set(self.expected) & set(self.actual)
+        self.symmetric_diff = set(self.expected) ^ set(self.actual)
+
+    def describe(self):
+        msg = ""
+        if self.symmetric_diff:
+            only_expected = helpers.dict_subtract(self.expected, self.actual)
+            only_actual = helpers.dict_subtract(self.actual, self.expected)
+            if only_expected:
+                msg += "Only in expected:\n  %s\n" % \
+                       util.safe_repr(only_expected)
+            if only_actual:
+                msg += "Only in actual:\n  %s\n" % \
+                       util.safe_repr(only_actual)
+        diff_set = set(o for o in self.intersect if
+                       self.expected[o] != self.actual[o])
+        if diff_set:
+            msg += "Differences:\n"
+        for o in diff_set:
+            msg += "  %s: expected %s, actual %s\n" % (
+                o, self.expected[o], self.actual[o])
+        return msg
+
+    def get_details(self):
+        return {}
diff --git a/tempest/tests/common/test_custom_matchers.py b/tempest/tests/common/test_custom_matchers.py
new file mode 100644
index 0000000..57217e3
--- /dev/null
+++ b/tempest/tests/common/test_custom_matchers.py
@@ -0,0 +1,66 @@
+# Copyright 2014 Hewlett-Packard Development Company, L.P.
+# All Rights Reserved.
+#
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
+#    not use this file except in compliance with the License. You may obtain
+#    a copy of the License at
+#
+#         http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#    License for the specific language governing permissions and limitations
+#    under the License.
+
+from tempest.common import custom_matchers
+from tempest.tests import base
+
+from testtools.tests.matchers import helpers
+
+
+class TestMatchesDictExceptForKeys(base.TestCase,
+                                   helpers.TestMatchersInterface):
+
+    matches_matcher = custom_matchers.MatchesDictExceptForKeys(
+        {'a': 1, 'b': 2, 'c': 3, 'd': 4}, ['c', 'd'])
+    matches_matches = [
+        {'a': 1, 'b': 2, 'c': 3, 'd': 4},
+        {'a': 1, 'b': 2, 'c': 5},
+        {'a': 1, 'b': 2},
+    ]
+    matches_mismatches = [
+        {},
+        {'foo': 1},
+        {'a': 1, 'b': 3},
+        {'a': 1, 'b': 2, 'foo': 1},
+        {'a': 1, 'b': None, 'foo': 1},
+    ]
+
+    str_examples = []
+    describe_examples = [
+        ("Only in expected:\n"
+         "  {'a': 1, 'b': 2}\n",
+         {},
+         matches_matcher),
+        ("Only in expected:\n"
+         "  {'a': 1, 'b': 2}\n"
+         "Only in actual:\n"
+         "  {'foo': 1}\n",
+         {'foo': 1},
+         matches_matcher),
+        ("Differences:\n"
+         "  b: expected 2, actual 3\n",
+         {'a': 1, 'b': 3},
+         matches_matcher),
+        ("Only in actual:\n"
+         "  {'foo': 1}\n",
+         {'a': 1, 'b': 2, 'foo': 1},
+         matches_matcher),
+        ("Only in actual:\n"
+         "  {'foo': 1}\n"
+         "Differences:\n"
+         "  b: expected 2, actual None\n",
+         {'a': 1, 'b': None, 'foo': 1},
+         matches_matcher)
+    ]
\ No newline at end of file