Merge pull request #3776 from alysivji/attrs-n-dataclasses
Detailed assert failure introspection for attrs and dataclasses objects
This commit is contained in:
commit
f987b368e8
|
@ -44,3 +44,4 @@ coverage.xml
|
||||||
.pydevproject
|
.pydevproject
|
||||||
.project
|
.project
|
||||||
.settings
|
.settings
|
||||||
|
.vscode
|
||||||
|
|
1
AUTHORS
1
AUTHORS
|
@ -11,6 +11,7 @@ Alan Velasco
|
||||||
Alexander Johnson
|
Alexander Johnson
|
||||||
Alexei Kozlenok
|
Alexei Kozlenok
|
||||||
Allan Feldman
|
Allan Feldman
|
||||||
|
Aly Sivji
|
||||||
Anatoly Bubenkoff
|
Anatoly Bubenkoff
|
||||||
Anders Hovmöller
|
Anders Hovmöller
|
||||||
Andras Tim
|
Andras Tim
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Richer equality comparison introspection on ``AssertionError`` for objects created using `attrs <http://www.attrs.org/en/stable/>`_ or `dataclasses <https://docs.python.org/3/library/dataclasses.html>`_ (Python 3.7+, `backported to 3.6 <https://pypi.org/project/dataclasses>`_).
|
|
@ -98,6 +98,30 @@ class TestSpecialisedExplanations(object):
|
||||||
text = "head " * 50 + "f" * 70 + "tail " * 20
|
text = "head " * 50 + "f" * 70 + "tail " * 20
|
||||||
assert "f" * 70 not in text
|
assert "f" * 70 not in text
|
||||||
|
|
||||||
|
def test_eq_dataclass(self):
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Foo(object):
|
||||||
|
a: int
|
||||||
|
b: str
|
||||||
|
|
||||||
|
left = Foo(1, "b")
|
||||||
|
right = Foo(1, "c")
|
||||||
|
assert left == right
|
||||||
|
|
||||||
|
def test_eq_attrs(self):
|
||||||
|
import attr
|
||||||
|
|
||||||
|
@attr.s
|
||||||
|
class Foo(object):
|
||||||
|
a = attr.ib()
|
||||||
|
b = attr.ib()
|
||||||
|
|
||||||
|
left = Foo(1, "b")
|
||||||
|
right = Foo(1, "c")
|
||||||
|
assert left == right
|
||||||
|
|
||||||
|
|
||||||
def test_attribute():
|
def test_attribute():
|
||||||
class Foo(object):
|
class Foo(object):
|
||||||
|
|
|
@ -9,5 +9,5 @@ def test_failure_demo_fails_properly(testdir):
|
||||||
failure_demo.copy(target)
|
failure_demo.copy(target)
|
||||||
failure_demo.copy(testdir.tmpdir.join(failure_demo.basename))
|
failure_demo.copy(testdir.tmpdir.join(failure_demo.basename))
|
||||||
result = testdir.runpytest(target, syspathinsert=True)
|
result = testdir.runpytest(target, syspathinsert=True)
|
||||||
result.stdout.fnmatch_lines(["*42 failed*"])
|
result.stdout.fnmatch_lines(["*44 failed*"])
|
||||||
assert result.ret != 0
|
assert result.ret != 0
|
||||||
|
|
|
@ -122,6 +122,12 @@ def assertrepr_compare(config, op, left, right):
|
||||||
def isset(x):
|
def isset(x):
|
||||||
return isinstance(x, (set, frozenset))
|
return isinstance(x, (set, frozenset))
|
||||||
|
|
||||||
|
def isdatacls(obj):
|
||||||
|
return getattr(obj, "__dataclass_fields__", None) is not None
|
||||||
|
|
||||||
|
def isattrs(obj):
|
||||||
|
return getattr(obj, "__attrs_attrs__", None) is not None
|
||||||
|
|
||||||
def isiterable(obj):
|
def isiterable(obj):
|
||||||
try:
|
try:
|
||||||
iter(obj)
|
iter(obj)
|
||||||
|
@ -142,6 +148,9 @@ def assertrepr_compare(config, op, left, right):
|
||||||
explanation = _compare_eq_set(left, right, verbose)
|
explanation = _compare_eq_set(left, right, verbose)
|
||||||
elif isdict(left) and isdict(right):
|
elif isdict(left) and isdict(right):
|
||||||
explanation = _compare_eq_dict(left, right, verbose)
|
explanation = _compare_eq_dict(left, right, verbose)
|
||||||
|
elif type(left) == type(right) and (isdatacls(left) or isattrs(left)):
|
||||||
|
type_fn = (isdatacls, isattrs)
|
||||||
|
explanation = _compare_eq_cls(left, right, verbose, type_fn)
|
||||||
if isiterable(left) and isiterable(right):
|
if isiterable(left) and isiterable(right):
|
||||||
expl = _compare_eq_iterable(left, right, verbose)
|
expl = _compare_eq_iterable(left, right, verbose)
|
||||||
if explanation is not None:
|
if explanation is not None:
|
||||||
|
@ -315,6 +324,38 @@ def _compare_eq_dict(left, right, verbose=False):
|
||||||
return explanation
|
return explanation
|
||||||
|
|
||||||
|
|
||||||
|
def _compare_eq_cls(left, right, verbose, type_fns):
|
||||||
|
isdatacls, isattrs = type_fns
|
||||||
|
if isdatacls(left):
|
||||||
|
all_fields = left.__dataclass_fields__
|
||||||
|
fields_to_check = [field for field, info in all_fields.items() if info.compare]
|
||||||
|
elif isattrs(left):
|
||||||
|
all_fields = left.__attrs_attrs__
|
||||||
|
fields_to_check = [field.name for field in all_fields if field.cmp]
|
||||||
|
|
||||||
|
same = []
|
||||||
|
diff = []
|
||||||
|
for field in fields_to_check:
|
||||||
|
if getattr(left, field) == getattr(right, field):
|
||||||
|
same.append(field)
|
||||||
|
else:
|
||||||
|
diff.append(field)
|
||||||
|
|
||||||
|
explanation = []
|
||||||
|
if same and verbose < 2:
|
||||||
|
explanation.append(u"Omitting %s identical items, use -vv to show" % len(same))
|
||||||
|
elif same:
|
||||||
|
explanation += [u"Matching attributes:"]
|
||||||
|
explanation += pprint.pformat(same).splitlines()
|
||||||
|
if diff:
|
||||||
|
explanation += [u"Differing attributes:"]
|
||||||
|
for field in diff:
|
||||||
|
explanation += [
|
||||||
|
(u"%s: %r != %r") % (field, getattr(left, field), getattr(right, field))
|
||||||
|
]
|
||||||
|
return explanation
|
||||||
|
|
||||||
|
|
||||||
def _notin_text(term, text, verbose=False):
|
def _notin_text(term, text, verbose=False):
|
||||||
index = text.find(term)
|
index = text.find(term)
|
||||||
head = text[:index]
|
head = text[:index]
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from dataclasses import field
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataclasses():
|
||||||
|
@dataclass
|
||||||
|
class SimpleDataObject(object):
|
||||||
|
field_a: int = field()
|
||||||
|
field_b: int = field()
|
||||||
|
|
||||||
|
left = SimpleDataObject(1, "b")
|
||||||
|
right = SimpleDataObject(1, "c")
|
||||||
|
|
||||||
|
assert left == right
|
|
@ -0,0 +1,14 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from dataclasses import field
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataclasses_with_attribute_comparison_off():
|
||||||
|
@dataclass
|
||||||
|
class SimpleDataObject(object):
|
||||||
|
field_a: int = field()
|
||||||
|
field_b: int = field(compare=False)
|
||||||
|
|
||||||
|
left = SimpleDataObject(1, "b")
|
||||||
|
right = SimpleDataObject(1, "c")
|
||||||
|
|
||||||
|
assert left == right
|
|
@ -0,0 +1,14 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from dataclasses import field
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataclasses_verbose():
|
||||||
|
@dataclass
|
||||||
|
class SimpleDataObject(object):
|
||||||
|
field_a: int = field()
|
||||||
|
field_b: int = field()
|
||||||
|
|
||||||
|
left = SimpleDataObject(1, "b")
|
||||||
|
right = SimpleDataObject(1, "c")
|
||||||
|
|
||||||
|
assert left == right
|
|
@ -0,0 +1,19 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from dataclasses import field
|
||||||
|
|
||||||
|
|
||||||
|
def test_comparing_two_different_data_classes():
|
||||||
|
@dataclass
|
||||||
|
class SimpleDataObjectOne(object):
|
||||||
|
field_a: int = field()
|
||||||
|
field_b: int = field()
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SimpleDataObjectTwo(object):
|
||||||
|
field_a: int = field()
|
||||||
|
field_b: int = field()
|
||||||
|
|
||||||
|
left = SimpleDataObjectOne(1, "b")
|
||||||
|
right = SimpleDataObjectTwo(1, "c")
|
||||||
|
|
||||||
|
assert left != right
|
|
@ -6,6 +6,7 @@ from __future__ import print_function
|
||||||
import sys
|
import sys
|
||||||
import textwrap
|
import textwrap
|
||||||
|
|
||||||
|
import attr
|
||||||
import py
|
import py
|
||||||
import six
|
import six
|
||||||
|
|
||||||
|
@ -548,6 +549,115 @@ class TestAssert_reprcompare(object):
|
||||||
assert msg
|
assert msg
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssert_reprcompare_dataclass(object):
|
||||||
|
@pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+")
|
||||||
|
def test_dataclasses(self, testdir):
|
||||||
|
p = testdir.copy_example("dataclasses/test_compare_dataclasses.py")
|
||||||
|
result = testdir.runpytest(p)
|
||||||
|
result.assert_outcomes(failed=1, passed=0)
|
||||||
|
result.stdout.fnmatch_lines(
|
||||||
|
[
|
||||||
|
"*Omitting 1 identical items, use -vv to show*",
|
||||||
|
"*Differing attributes:*",
|
||||||
|
"*field_b: 'b' != 'c'*",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+")
|
||||||
|
def test_dataclasses_verbose(self, testdir):
|
||||||
|
p = testdir.copy_example("dataclasses/test_compare_dataclasses_verbose.py")
|
||||||
|
result = testdir.runpytest(p, "-vv")
|
||||||
|
result.assert_outcomes(failed=1, passed=0)
|
||||||
|
result.stdout.fnmatch_lines(
|
||||||
|
[
|
||||||
|
"*Matching attributes:*",
|
||||||
|
"*['field_a']*",
|
||||||
|
"*Differing attributes:*",
|
||||||
|
"*field_b: 'b' != 'c'*",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+")
|
||||||
|
def test_dataclasses_with_attribute_comparison_off(self, testdir):
|
||||||
|
p = testdir.copy_example(
|
||||||
|
"dataclasses/test_compare_dataclasses_field_comparison_off.py"
|
||||||
|
)
|
||||||
|
result = testdir.runpytest(p, "-vv")
|
||||||
|
result.assert_outcomes(failed=0, passed=1)
|
||||||
|
|
||||||
|
@pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+")
|
||||||
|
def test_comparing_two_different_data_classes(self, testdir):
|
||||||
|
p = testdir.copy_example(
|
||||||
|
"dataclasses/test_compare_two_different_dataclasses.py"
|
||||||
|
)
|
||||||
|
result = testdir.runpytest(p, "-vv")
|
||||||
|
result.assert_outcomes(failed=0, passed=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAssert_reprcompare_attrsclass(object):
|
||||||
|
def test_attrs(self):
|
||||||
|
@attr.s
|
||||||
|
class SimpleDataObject(object):
|
||||||
|
field_a = attr.ib()
|
||||||
|
field_b = attr.ib()
|
||||||
|
|
||||||
|
left = SimpleDataObject(1, "b")
|
||||||
|
right = SimpleDataObject(1, "c")
|
||||||
|
|
||||||
|
lines = callequal(left, right)
|
||||||
|
assert lines[1].startswith("Omitting 1 identical item")
|
||||||
|
assert "Matching attributes" not in lines
|
||||||
|
for line in lines[1:]:
|
||||||
|
assert "field_a" not in line
|
||||||
|
|
||||||
|
def test_attrs_verbose(self):
|
||||||
|
@attr.s
|
||||||
|
class SimpleDataObject(object):
|
||||||
|
field_a = attr.ib()
|
||||||
|
field_b = attr.ib()
|
||||||
|
|
||||||
|
left = SimpleDataObject(1, "b")
|
||||||
|
right = SimpleDataObject(1, "c")
|
||||||
|
|
||||||
|
lines = callequal(left, right, verbose=2)
|
||||||
|
assert lines[1].startswith("Matching attributes:")
|
||||||
|
assert "Omitting" not in lines[1]
|
||||||
|
assert lines[2] == "['field_a']"
|
||||||
|
|
||||||
|
def test_attrs_with_attribute_comparison_off(self):
|
||||||
|
@attr.s
|
||||||
|
class SimpleDataObject(object):
|
||||||
|
field_a = attr.ib()
|
||||||
|
field_b = attr.ib(cmp=False)
|
||||||
|
|
||||||
|
left = SimpleDataObject(1, "b")
|
||||||
|
right = SimpleDataObject(1, "b")
|
||||||
|
|
||||||
|
lines = callequal(left, right, verbose=2)
|
||||||
|
assert lines[1].startswith("Matching attributes:")
|
||||||
|
assert "Omitting" not in lines[1]
|
||||||
|
assert lines[2] == "['field_a']"
|
||||||
|
for line in lines[2:]:
|
||||||
|
assert "field_b" not in line
|
||||||
|
|
||||||
|
def test_comparing_two_different_attrs_classes(self):
|
||||||
|
@attr.s
|
||||||
|
class SimpleDataObjectOne(object):
|
||||||
|
field_a = attr.ib()
|
||||||
|
field_b = attr.ib()
|
||||||
|
|
||||||
|
@attr.s
|
||||||
|
class SimpleDataObjectTwo(object):
|
||||||
|
field_a = attr.ib()
|
||||||
|
field_b = attr.ib()
|
||||||
|
|
||||||
|
left = SimpleDataObjectOne(1, "b")
|
||||||
|
right = SimpleDataObjectTwo(1, "c")
|
||||||
|
|
||||||
|
lines = callequal(left, right)
|
||||||
|
assert lines is None
|
||||||
|
|
||||||
|
|
||||||
class TestFormatExplanation(object):
|
class TestFormatExplanation(object):
|
||||||
def test_special_chars_full(self, testdir):
|
def test_special_chars_full(self, testdir):
|
||||||
# Issue 453, for the bug this would raise IndexError
|
# Issue 453, for the bug this would raise IndexError
|
||||||
|
|
Loading…
Reference in New Issue