Skip to content

Commit

Permalink
Add SequenceComparison objects.
Browse files Browse the repository at this point in the history
  • Loading branch information
cjw296 committed Dec 3, 2020
1 parent 6a797bf commit 18c3360
Show file tree
Hide file tree
Showing 7 changed files with 572 additions and 102 deletions.
3 changes: 3 additions & 0 deletions docs/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ Comparisons
.. autoclass:: RangeComparison
:members:

.. autoclass:: SequenceComparison
:members:

.. autoclass:: StringComparison
:members:

Expand Down
57 changes: 57 additions & 0 deletions docs/comparing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,63 @@ Here's an example:

:class:`RangeComparison` is inclusive of both the lower and upper bound.

Sequence Comparison objects
---------------------------

When comparing sequences, you may not care about the order of items in the
sequence. While this type of comparison can often be achieved by pouring
the sequence into a :class:`set`, this may not be possible if the items in the
sequence are unhashable, or part of a nested data structure.
:class:`SequenceComparison` objects can be used in this case:

>>> from testfixtures import compare, SequenceComparison as S
>>> compare(expected={'k': S({1}, {2}, ordered=False)}, actual={'k': [{2}, {1}]})

You may also only care about certain items being present in a sequence, but where
it is important that those items are in the order you expected. This
can also be achieved with :class:`SequenceComparison` objects:

>>> compare(expected=S(1, 3, 5, partial=True), actual=[1, 2, 3, 4, 6])
Traceback (most recent call last):
...
AssertionError:...
<SequenceComparison(ordered=True, partial=True)(failed)>
ignored:
[2, 4, 6]
<BLANKLINE>
same:
[1, 3]
<BLANKLINE>
expected:
[5]
<BLANKLINE>
actual:
[]
</SequenceComparison(ordered=True, partial=True)> (expected) != [1, 2, 3, 4, 6] (actual)

Where there are differences, they may be hard to spot. In this case, you can ask for a more
detailed explanation of what wasn't as expected:

>>> compare(expected=S({1}, {1, 2}, {1, 2, 3}, recursive=True), actual=[{1}, {1}, {1, 2, 3}])
Traceback (most recent call last):
...
AssertionError:...
<SequenceComparison(ordered=True, partial=False)(failed)>
same:
[{1}]
<BLANKLINE>
expected:
[{1, 2}, {1, 2, 3}]
<BLANKLINE>
actual:
[{1}, {1, 2, 3}]
<BLANKLINE>
While comparing [1]: set not as expected:
<BLANKLINE>
in expected but not actual:
[2]
</SequenceComparison(ordered=True, partial=False)> (expected) != [{1}, {1}, {1, 2, 3}] (actual)

String Comparison objects
-------------------------

Expand Down
3 changes: 2 additions & 1 deletion testfixtures/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ def __repr__(self):
not_there = singleton('not_there')

from testfixtures.comparison import (
Comparison, StringComparison, RoundComparison, compare, diff, RangeComparison
Comparison, StringComparison, RoundComparison, compare, diff, RangeComparison,
SequenceComparison
)
from testfixtures.tdatetime import test_datetime, test_date, test_time
from testfixtures.logcapture import LogCapture, log_capture
Expand Down
120 changes: 115 additions & 5 deletions testfixtures/comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ def compare_with_type(x, y, context):
return '{x} != {y}'.format(**to_render)


def compare_sequence(x, y, context):
def compare_sequence(x, y, context, prefix=True):
"""
Returns a textual description of the differences between the two
supplied sequences.
Expand All @@ -160,7 +160,7 @@ def compare_sequence(x, y, context):
if l_x == l_y and i == l_x:
return

return ('sequence not as expected:\n\n'
return (('sequence not as expected:\n\n' if prefix else '')+
'same:\n%s\n\n'
'%s:\n%s\n\n'
'%s:\n%s') % (pformat(x[:i]),
Expand Down Expand Up @@ -703,16 +703,21 @@ class StatefulComparison(object):
A base class for stateful comparison objects.
"""

failed = None
failed = ''
expected = None
name_attrs = ()

def __eq__(self, other):
return not(self != other)

def name(self):
return type(self).__name__
name = type(self).__name__
if self.name_attrs:
name += '(%s)' % ', '.join('%s=%r' % (n, getattr(self, n)) for n in self.name_attrs)
return name

def body(self):
raise NotImplementedError()
return pformat(self.expected)[1:-1]

def __repr__(self):
name = self.name()
Expand Down Expand Up @@ -830,6 +835,111 @@ def body(self):
return ''


class SequenceComparison(StatefulComparison):
"""
An object that can be used in comparisons of expected and actual
sequences.
:param expected: The items expected to be in the sequence.
:param ordered:
If the items are expected to be in the order specified.
Defaults to ``True``.
:param partial:
If any items not expected should be ignored.
Defaults to ``False``.
:param recursive:
If a difference is found, recursively compare the item where
the difference was found to highlight exactly what was different.
Defaults to ``False``.
"""

name_attrs = ('ordered', 'partial')

def __init__(self, *expected, **kw):
self.expected = expected
# py2 :-(
self.ordered = kw.pop('ordered', True)
self.partial = kw.pop('partial', False)
self.recursive = kw.pop('recursive', False)
assert not kw, 'unexpected parameter'
self.checked_indices = set()

def __ne__(self, other):
try:
actual = original_actual = list(other)
except TypeError:
self.failed = 'bad type'
return True
expected = list(self.expected)
actual = list(actual)

matched = []
matched_expected_indices = []
matched_actual_indices = []

missing_from_expected = actual
missing_from_expected_indices = actual_indices = list(range(len(actual)))

missing_from_actual = []
missing_from_actual_indices = []

for e_i, e in enumerate(expected):
try:
i = actual.index(e)
a_i = actual_indices.pop(i)
except ValueError:
missing_from_actual.append(e)
missing_from_actual_indices.append(e_i)
else:
matched.append(missing_from_expected.pop(i))
matched_expected_indices.append(e_i)
matched_actual_indices.append(a_i)
self.checked_indices.add(a_i)

matches_in_order = matched_actual_indices == sorted(matched_actual_indices)
all_matched = not (missing_from_actual or missing_from_expected)
partial_match = self.partial and not missing_from_actual

if (matches_in_order or not self.ordered) and (all_matched or partial_match):
return False

expected_indices = matched_expected_indices+missing_from_actual_indices
actual_indices = matched_actual_indices

if self.partial:
# try to give a clue as to what didn't match:
if self.recursive and self.ordered and missing_from_expected:
actual_indices.append(missing_from_expected_indices.pop(0))
missing_from_expected.pop(0)

ignored = missing_from_expected
missing_from_expected = None
else:
actual_indices += missing_from_expected_indices
ignored = None

message = []

def add_section(name, content):
if content:
message.append(name+':\n'+pformat(content))

add_section('ignored', ignored)

if self.ordered:
message.append(compare(
expected=[self.expected[i] for i in sorted(expected_indices)],
actual=[original_actual[i] for i in sorted(actual_indices)],
recursive=self.recursive,
raises=False
).split('\n\n', 1)[1])
else:
add_section('same', matched)
add_section('in expected but not actual', missing_from_actual)
add_section('in actual but not expected', missing_from_expected)

self.failed = '\n\n'.join(message)
return True


class StringComparison:
Expand Down
53 changes: 9 additions & 44 deletions testfixtures/logcapture.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import warnings
from pprint import pformat

from testfixtures.comparison import compare
from testfixtures.utils import wrap
from .comparison import SequenceComparison, compare
from .utils import wrap


class LogCapture(logging.Handler):
Expand Down Expand Up @@ -265,48 +265,13 @@ def check_present(self, *expected, **kw):
order_matters = kw.pop('order_matters', True)
assert not kw, 'order_matters is the only keyword parameter'
actual = self.actual()
if order_matters:
matched_indices = [0]
matched = []
for entry in expected:
try:
index = actual.index(entry, matched_indices[-1])
except ValueError:
if len(matched_indices) > 1:
matched_indices.pop()
matched.pop()
break
else:
self.records[index].checked = True
matched_indices.append(index+1)
matched.append(entry)
else:
return

compare(expected,
actual=matched+actual[matched_indices[-1]:],
recursive=self.recursive_check)
else:
expected = list(expected)
matched = []
unmatched = []
for i, entry in enumerate(actual):
try:
index = expected.index(entry)
except ValueError:
unmatched.append(entry)
else:
self.records[i].checked = True
matched.append(expected.pop(index))
if not expected:
break
if expected:
raise AssertionError((
'entries not as expected:\n\n'
'expected and found:\n%s\n\n'
'expected but not found:\n%s\n\n'
'other entries:\n%s'
) % (pformat(matched), pformat(expected), pformat(unmatched)))
expected = SequenceComparison(
*expected, ordered=order_matters, partial=True, recursive=self.recursive_check
)
if expected != actual:
raise AssertionError(expected.failed)
for index in expected.checked_indices:
self.records[index].checked = True

def __enter__(self):
return self
Expand Down
Loading

0 comments on commit 18c3360

Please sign in to comment.