testtools-dev team mailing list archive
-
testtools-dev team
-
Mailing list archive
-
Message #01070
[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