← Back to team overview

testtools-dev team mailing list archive

[Merge] lp:~jml/testtools/dict-matcher into lp:testtools

 

Jonathan Lange has proposed merging lp:~jml/testtools/dict-matcher into lp:testtools.

Requested reviews:
  testtools committers (testtools-committers)

For more details, see:
https://code.launchpad.net/~jml/testtools/dict-matcher/+merge/118567

Very often, because we use Python dicts like structures a lot of the time, 
I want to be able to match against those dicts.

This adds:
  Dict
  SuperDict
  SubDict
  MatchesAllDict

The first three take a dict of matchers and expect a dict of observed data.
The data is then matched key by key against the matchers.  Another name for
them would be MatchesDictWise, but then I wouldn't know how to name the 
super- and sub-versions.

The last takes a dict of matchers and then matches a single thing against 
everything in that dict.  It's a lot like MatchesAll, but the results are
labelled.

I've spent ages tweaking with this.  It's not going to get any better without
feedback from someone else.

jml
-- 
https://code.launchpad.net/~jml/testtools/dict-matcher/+merge/118567
Your team testtools developers is subscribed to branch lp:testtools.
=== modified file 'testtools/helpers.py'
--- testtools/helpers.py	2012-01-29 14:03:59 +0000
+++ testtools/helpers.py	2012-08-07 14:19:21 +0000
@@ -85,3 +85,22 @@
     properties.
     """
     return getattr(obj, attr, _marker) is not _marker
+
+
+def map_values(function, dictionary):
+    """Map ``function`` across the values of ``dictionary``.
+
+    :return: A dict with the same keys as ``dictionary``, where the value
+        of each key ``k`` is ``function(dictionary[k])``.
+    """
+    return dict((k, function(dictionary[k])) for k in dictionary)
+
+
+def filter_values(function, dictionary):
+    """Filter ``dictionary`` by its values using ``function``."""
+    return dict((k, v) for k, v in dictionary.items() if function(v))
+
+
+def dict_subtract(a, b):
+    """Return the part of ``a`` that's not in ``b``."""
+    return dict((k, a[k]) for k in set(a) - set(b))

=== modified file 'testtools/matchers.py'
--- testtools/matchers.py	2012-06-07 18:17:07 +0000
+++ testtools/matchers.py	2012-08-07 14:19:21 +0000
@@ -65,6 +65,11 @@
     str_is_unicode,
     text_repr
     )
+from testtools.helpers import (
+    dict_subtract,
+    filter_values,
+    map_values,
+    )
 
 
 class Matcher(object):
@@ -558,17 +563,71 @@
 class MismatchesAll(Mismatch):
     """A mismatch with many child mismatches."""
 
-    def __init__(self, mismatches):
+    def __init__(self, mismatches, wrap=True):
         self.mismatches = mismatches
+        self._wrap = wrap
 
     def describe(self):
-        descriptions = ["Differences: ["]
+        descriptions = []
+        if self._wrap:
+            descriptions = ["Differences: ["]
         for mismatch in self.mismatches:
             descriptions.append(mismatch.describe())
-        descriptions.append("]")
+        if self._wrap:
+            descriptions.append("]")
         return '\n'.join(descriptions)
 
 
+class MatchesAllDict(Matcher):
+
+    def __init__(self, matchers):
+        super(MatchesAllDict, self).__init__()
+        self.matchers = matchers
+
+    def __str__(self):
+        return 'MatchesAllDict({%s})' % (
+            ', '.join('%r: %s' % (k, v) for k, v in self.matchers.items()))
+
+    def match(self, observed):
+        mismatches = {}
+        for label in self.matchers:
+            mismatches[label] = self.matchers[label].match(observed)
+        return _dict_to_mismatch(
+            mismatches, result_mismatch=LabelledMismatches)
+
+
+class DictMismatches(Mismatch):
+    """A mismatch with a dict of child mismatches."""
+
+    def __init__(self, mismatches, details=None):
+        super(DictMismatches, self).__init__(None, details=details)
+        self.mismatches = mismatches
+
+    def describe(self):
+        lines = ['{']
+        lines.extend(
+            ['  %r: %s,' % (key, mismatch.describe())
+             for (key, mismatch) in sorted(self.mismatches.items())])
+        lines.append('}')
+        return '\n'.join(lines)
+
+
+def LabelledMismatches(mismatches, details=None):
+    """A collection of mismatches, each labelled."""
+    return MismatchesAll(
+        (PrefixedMismatch(k, v) for (k, v) in sorted(mismatches.items())),
+        wrap=False)
+
+
+def _dict_to_mismatch(data, to_mismatch=None,
+                      result_mismatch=DictMismatches):
+    if to_mismatch:
+        data = map_values(to_mismatch, data)
+    mismatches = filter_values(bool, data)
+    if mismatches:
+        return result_mismatch(mismatches)
+
+
 class Not(object):
     """Inverts a matcher."""
 
@@ -714,15 +773,9 @@
 
 
 class KeysEqual(Matcher):
-    """Checks whether a dict has particular keys."""
 
     def __init__(self, *expected):
-        """Create a `KeysEqual` Matcher.
-
-        :param expected: The keys the dict is expected to have.  If a dict,
-            then we use the keys of that dict, if a collection, we assume it
-            is a collection of expected keys.
-        """
+        super(KeysEqual, self).__init__()
         try:
             self.expected = expected.keys()
         except AttributeError:
@@ -767,11 +820,11 @@
             return AnnotatedMismatch(self.annotation, mismatch)
 
 
-class AnnotatedMismatch(MismatchDecorator):
+class PostfixedMismatch(MismatchDecorator):
     """A mismatch annotated with a descriptive string."""
 
     def __init__(self, annotation, mismatch):
-        super(AnnotatedMismatch, self).__init__(mismatch)
+        super(PostfixedMismatch, self).__init__(mismatch)
         self.annotation = annotation
         self.mismatch = mismatch
 
@@ -779,6 +832,19 @@
         return '%s: %s' % (self.original.describe(), self.annotation)
 
 
+AnnotatedMismatch = PostfixedMismatch
+
+
+class PrefixedMismatch(MismatchDecorator):
+
+    def __init__(self, prefix, mismatch):
+        super(PrefixedMismatch, self).__init__(mismatch)
+        self.prefix = prefix
+
+    def describe(self):
+        return '%s: %s' % (self.prefix, self.original.describe())
+
+
 class Raises(Matcher):
     """Match if the matchee raises an exception when called.
 
@@ -919,8 +985,7 @@
         Similar to the constructor, except that the provided matcher is used
         to match all of the values.
         """
-        return cls(
-            **dict((name, matcher(value)) for name, value in kwargs.items()))
+        return cls(**map_values(matcher, kwargs))
 
     @classmethod
     def fromExample(cls, example, *attributes):
@@ -1319,6 +1384,114 @@
         return Equals(self.octal_permissions).match(permissions)
 
 
+class _MatchCommonKeys(Matcher):
+
+    def __init__(self, dict_of_matchers):
+        super(_MatchCommonKeys, self).__init__()
+        self._matchers = dict_of_matchers
+
+    def _compare_dicts(self, expected, observed):
+        common_keys = set(expected.keys()) & set(observed.keys())
+        mismatches = {}
+        for key in common_keys:
+            mismatch = expected[key].match(observed[key])
+            if mismatch:
+                mismatches[key] = mismatch
+        return mismatches
+
+    def match(self, observed):
+        mismatches = self._compare_dicts(self._matchers, observed)
+        if mismatches:
+            return DictMismatches(mismatches)
+
+
+class _SubDictOf(Matcher):
+
+    def __init__(self, super_dict, format_value=repr):
+        super(_SubDictOf, self).__init__()
+        self.super_dict = super_dict
+        self.format_value = format_value
+
+    def match(self, observed):
+        excess = dict_subtract(observed, self.super_dict)
+        return _dict_to_mismatch(
+            excess, lambda v: Mismatch(self.format_value(v)))
+
+
+class _SuperDictOf(Matcher):
+
+    def __init__(self, sub_dict, format_value=repr):
+        super(_SuperDictOf, self).__init__()
+        self.sub_dict = sub_dict
+        self.format_value = format_value
+
+    def match(self, super_dict):
+        return _SubDictOf(super_dict, self.format_value).match(self.sub_dict)
+
+
+def _format_matcher_dict(matchers):
+    return '{%s}' % (
+        ', '.join('%r: %s' % (k, v) for k, v in matchers.items()))
+
+
+class _DictMatcher(Matcher):
+
+    matcher_factories = {}
+
+    def __init__(self, dict_of_matchers):
+        super(_DictMatcher, self).__init__()
+        self._matchers = dict_of_matchers
+
+    def __str__(self):
+        return '%s(%s)' % (
+            self.__class__.__name__, _format_matcher_dict(self._matchers))
+
+    def match(self, observed):
+        matchers = dict(
+            (k, v(self._matchers)) for k, v in self.matcher_factories.items())
+        return MatchesAllDict(matchers).match(observed)
+
+
+class Dict(_DictMatcher):
+    """Match a dictionary exactly, by its keys.
+
+    Each key in the dictionary must match the matcher for the equivalent key
+    in the dict of matchers.
+    """
+
+    matcher_factories = {
+        'Extra': _SubDictOf,
+        'Missing': lambda m: _SuperDictOf(m, format_value=str),
+        'Differences': _MatchCommonKeys,
+        }
+
+
+class SubDict(_DictMatcher):
+    """Match a dictionary for which this is a sub-dictionary.
+
+    Does not check for strict sub-dictionary.  That is, equal dictionaries
+    match.
+    """
+
+    matcher_factories = {
+        'Missing': lambda m: _SuperDictOf(m, format_value=str),
+        'Differences': _MatchCommonKeys,
+        }
+
+
+class SuperDict(_DictMatcher):
+    """Match a dictionary for which this is a super-dictionary.
+
+    Does not check for strict super-dictionary.  That is, equal dictionaries
+    match.
+    """
+
+    matcher_factories = {
+        'Extra': _SubDictOf,
+        'Differences': _MatchCommonKeys,
+        }
+
+
 # Signal that this is part of the testing framework, and that code from this
 # should not normally appear in tracebacks.
 __unittest = True

=== modified file 'testtools/tests/test_matchers.py'
--- testtools/tests/test_matchers.py	2012-06-07 18:17:07 +0000
+++ testtools/tests/test_matchers.py	2012-08-07 14:19:21 +0000
@@ -29,6 +29,7 @@
     _BinaryMismatch,
     Contains,
     ContainsAll,
+    Dict,
     DirContains,
     DirExists,
     DocTestMatches,
@@ -46,6 +47,7 @@
     GreaterThan,
     MatchesAny,
     MatchesAll,
+    MatchesAllDict,
     MatchesException,
     MatchesListwise,
     MatchesPredicate,
@@ -63,6 +65,9 @@
     SameMembers,
     SamePath,
     StartsWith,
+    _SubDictOf,
+    SubDict,
+    SuperDict,
     TarballContains,
     )
 from testtools.tests.helpers import FullStackRunTest
@@ -575,6 +580,21 @@
         ]
 
 
+class TestMatchesAllDictInterface(TestCase, TestMatchersInterface):
+
+    matches_matcher = MatchesAllDict({'a': NotEquals(1), 'b': NotEquals(2)})
+    matches_matches = [3, 4]
+    matches_mismatches = [1, 2]
+
+    str_examples = [
+        ("MatchesAllDict({'a': NotEquals(1), 'b': NotEquals(2)})",
+         matches_matcher)]
+
+    describe_examples = [
+        ("""a: 1 == 1""", 1, matches_matcher),
+        ]
+
+
 class TestKeysEqual(TestCase, TestMatchersInterface):
 
     matches_matcher = KeysEqual('foo', 'bar')
@@ -1369,6 +1389,162 @@
         self.assertThat(filename, HasPermissions(permissions))
 
 
+class TestSubDictOf(TestCase, TestMatchersInterface):
+
+    matches_matcher = _SubDictOf({'foo': 'bar', 'baz': 'qux'})
+
+    matches_matches = [
+        {'foo': 'bar', 'baz': 'qux'},
+        {'foo': 'bar'},
+        ]
+
+    matches_mismatches = [
+        {'foo': 'bar', 'baz': 'qux', 'cat': 'dog'},
+        {'foo': 'bar', 'cat': 'dog'},
+        ]
+
+    str_examples = []
+    describe_examples = []
+
+
+class TestDict(TestCase, TestMatchersInterface):
+
+    matches_matcher = Dict(
+        {'foo': Equals('bar'), 'baz': Not(Equals('qux'))})
+
+    matches_matches = [
+        {'foo': 'bar', 'baz': None},
+        {'foo': 'bar', 'baz': 'quux'},
+        ]
+    matches_mismatches = [
+        {},
+        {'foo': 'bar', 'baz': 'qux'},
+        {'foo': 'bop', 'baz': 'qux'},
+        {'foo': 'bar', 'baz': 'quux', 'cat': 'dog'},
+        {'foo': 'bar', 'cat': 'dog'},
+        ]
+
+    str_examples = [
+        ("Dict({'foo': %s, 'baz': %s})" % (Equals('bar'), Not(Equals('qux'))),
+         matches_matcher),
+        ]
+
+    describe_examples = [
+        ("Missing: {\n"
+         "  'baz': Not(Equals('qux')),\n"
+         "  'foo': Equals('bar'),\n"
+         "}",
+         {}, matches_matcher),
+        ("Differences: {\n"
+         "  'baz': 'qux' matches Equals('qux'),\n"
+         "}",
+         {'foo': 'bar', 'baz': 'qux'}, matches_matcher),
+        ("Differences: {\n"
+         "  'baz': 'qux' matches Equals('qux'),\n"
+         "  'foo': 'bar' != 'bop',\n"
+         "}",
+         {'foo': 'bop', 'baz': 'qux'}, matches_matcher),
+        ("Extra: {\n"
+         "  'cat': 'dog',\n"
+         "}",
+         {'foo': 'bar', 'baz': 'quux', 'cat': 'dog'}, matches_matcher),
+        ("Extra: {\n"
+         "  'cat': 'dog',\n"
+         "}\n"
+         "Missing: {\n"
+         "  'baz': Not(Equals('qux')),\n"
+         "}",
+         {'foo': 'bar', 'cat': 'dog'}, matches_matcher),
+        ]
+
+
+class TestSubDict(TestCase, TestMatchersInterface):
+
+    matches_matcher = SubDict(
+        {'foo': Equals('bar'), 'baz': Not(Equals('qux'))})
+
+    matches_matches = [
+        {'foo': 'bar', 'baz': None},
+        {'foo': 'bar', 'baz': 'quux'},
+        {'foo': 'bar', 'baz': 'quux', 'cat': 'dog'},
+        ]
+    matches_mismatches = [
+        {},
+        {'foo': 'bar', 'baz': 'qux'},
+        {'foo': 'bop', 'baz': 'qux'},
+        {'foo': 'bar', 'cat': 'dog'},
+        {'foo': 'bar'},
+        ]
+
+    str_examples = [
+        ("SubDict({'foo': %s, 'baz': %s})" % (
+                Equals('bar'), Not(Equals('qux'))),
+         matches_matcher),
+        ]
+
+    describe_examples = [
+        ("Missing: {\n"
+         "  'baz': Not(Equals('qux')),\n"
+         "  'foo': Equals('bar'),\n"
+         "}",
+         {}, matches_matcher),
+        ("Differences: {\n"
+         "  'baz': 'qux' matches Equals('qux'),\n"
+         "}",
+         {'foo': 'bar', 'baz': 'qux'}, matches_matcher),
+        ("Differences: {\n"
+         "  'baz': 'qux' matches Equals('qux'),\n"
+         "  'foo': 'bar' != 'bop',\n"
+         "}",
+         {'foo': 'bop', 'baz': 'qux'}, matches_matcher),
+        ("Missing: {\n"
+         "  'baz': Not(Equals('qux')),\n"
+         "}",
+         {'foo': 'bar', 'cat': 'dog'}, matches_matcher),
+        ]
+
+
+class TestSuperDict(TestCase, TestMatchersInterface):
+
+    matches_matcher = SuperDict(
+        {'foo': Equals('bar'), 'baz': Not(Equals('qux'))})
+
+    matches_matches = [
+        {},
+        {'foo': 'bar'},
+        {'foo': 'bar', 'baz': 'quux'},
+        {'baz': 'quux'},
+        ]
+    matches_mismatches = [
+        {'foo': 'bar', 'baz': 'quux', 'cat': 'dog'},
+        {'foo': 'bar', 'baz': 'qux'},
+        {'foo': 'bop', 'baz': 'qux'},
+        {'foo': 'bar', 'cat': 'dog'},
+        ]
+
+    str_examples = [
+        ("SuperDict({'foo': %s, 'baz': %s})" % (
+                Equals('bar'), Not(Equals('qux'))),
+         matches_matcher),
+        ]
+
+    describe_examples = [
+        ("Differences: {\n"
+         "  'baz': 'qux' matches Equals('qux'),\n"
+         "}",
+         {'foo': 'bar', 'baz': 'qux'}, matches_matcher),
+        ("Differences: {\n"
+         "  'baz': 'qux' matches Equals('qux'),\n"
+         "  'foo': 'bar' != 'bop',\n"
+         "}",
+         {'foo': 'bop', 'baz': 'qux'}, matches_matcher),
+        ("Extra: {\n"
+         "  'cat': 'dog',\n"
+         "}",
+         {'foo': 'bar', 'cat': 'dog'}, matches_matcher),
+        ]
+
+
 def test_suite():
     from unittest import TestLoader
     return TestLoader().loadTestsFromName(__name__)


Follow ups