Skip to content

Commit f987b36

Browse files
authored
Merge pull request #3776 from alysivji/attrs-n-dataclasses
Detailed assert failure introspection for attrs and dataclasses objects
2 parents d894bf4 + d52ea4b commit f987b36

File tree

12 files changed

+241
-2
lines changed

12 files changed

+241
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,4 @@ coverage.xml
4444
.pydevproject
4545
.project
4646
.settings
47+
.vscode

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Alan Velasco
1111
Alexander Johnson
1212
Alexei Kozlenok
1313
Allan Feldman
14+
Aly Sivji
1415
Anatoly Bubenkoff
1516
Anders Hovmöller
1617
Andras Tim

changelog/3632.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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>`_).

doc/en/example/assertion/failure_demo.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,30 @@ def test_not_in_text_single_long_term(self):
9898
text = "head " * 50 + "f" * 70 + "tail " * 20
9999
assert "f" * 70 not in text
100100

101+
def test_eq_dataclass(self):
102+
from dataclasses import dataclass
103+
104+
@dataclass
105+
class Foo(object):
106+
a: int
107+
b: str
108+
109+
left = Foo(1, "b")
110+
right = Foo(1, "c")
111+
assert left == right
112+
113+
def test_eq_attrs(self):
114+
import attr
115+
116+
@attr.s
117+
class Foo(object):
118+
a = attr.ib()
119+
b = attr.ib()
120+
121+
left = Foo(1, "b")
122+
right = Foo(1, "c")
123+
assert left == right
124+
101125

102126
def test_attribute():
103127
class Foo(object):

doc/en/example/assertion/test_failures.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@ def test_failure_demo_fails_properly(testdir):
99
failure_demo.copy(target)
1010
failure_demo.copy(testdir.tmpdir.join(failure_demo.basename))
1111
result = testdir.runpytest(target, syspathinsert=True)
12-
result.stdout.fnmatch_lines(["*42 failed*"])
12+
result.stdout.fnmatch_lines(["*44 failed*"])
1313
assert result.ret != 0

src/_pytest/assertion/util.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ def isdict(x):
122122
def isset(x):
123123
return isinstance(x, (set, frozenset))
124124

125+
def isdatacls(obj):
126+
return getattr(obj, "__dataclass_fields__", None) is not None
127+
128+
def isattrs(obj):
129+
return getattr(obj, "__attrs_attrs__", None) is not None
130+
125131
def isiterable(obj):
126132
try:
127133
iter(obj)
@@ -142,6 +148,9 @@ def isiterable(obj):
142148
explanation = _compare_eq_set(left, right, verbose)
143149
elif isdict(left) and isdict(right):
144150
explanation = _compare_eq_dict(left, right, verbose)
151+
elif type(left) == type(right) and (isdatacls(left) or isattrs(left)):
152+
type_fn = (isdatacls, isattrs)
153+
explanation = _compare_eq_cls(left, right, verbose, type_fn)
145154
if isiterable(left) and isiterable(right):
146155
expl = _compare_eq_iterable(left, right, verbose)
147156
if explanation is not None:
@@ -315,6 +324,38 @@ def _compare_eq_dict(left, right, verbose=False):
315324
return explanation
316325

317326

327+
def _compare_eq_cls(left, right, verbose, type_fns):
328+
isdatacls, isattrs = type_fns
329+
if isdatacls(left):
330+
all_fields = left.__dataclass_fields__
331+
fields_to_check = [field for field, info in all_fields.items() if info.compare]
332+
elif isattrs(left):
333+
all_fields = left.__attrs_attrs__
334+
fields_to_check = [field.name for field in all_fields if field.cmp]
335+
336+
same = []
337+
diff = []
338+
for field in fields_to_check:
339+
if getattr(left, field) == getattr(right, field):
340+
same.append(field)
341+
else:
342+
diff.append(field)
343+
344+
explanation = []
345+
if same and verbose < 2:
346+
explanation.append(u"Omitting %s identical items, use -vv to show" % len(same))
347+
elif same:
348+
explanation += [u"Matching attributes:"]
349+
explanation += pprint.pformat(same).splitlines()
350+
if diff:
351+
explanation += [u"Differing attributes:"]
352+
for field in diff:
353+
explanation += [
354+
(u"%s: %r != %r") % (field, getattr(left, field), getattr(right, field))
355+
]
356+
return explanation
357+
358+
318359
def _notin_text(term, text, verbose=False):
319360
index = text.find(term)
320361
head = text[:index]
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from dataclasses import dataclass
2+
from dataclasses import field
3+
4+
5+
def test_dataclasses():
6+
@dataclass
7+
class SimpleDataObject(object):
8+
field_a: int = field()
9+
field_b: int = field()
10+
11+
left = SimpleDataObject(1, "b")
12+
right = SimpleDataObject(1, "c")
13+
14+
assert left == right
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from dataclasses import dataclass
2+
from dataclasses import field
3+
4+
5+
def test_dataclasses_with_attribute_comparison_off():
6+
@dataclass
7+
class SimpleDataObject(object):
8+
field_a: int = field()
9+
field_b: int = field(compare=False)
10+
11+
left = SimpleDataObject(1, "b")
12+
right = SimpleDataObject(1, "c")
13+
14+
assert left == right
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from dataclasses import dataclass
2+
from dataclasses import field
3+
4+
5+
def test_dataclasses_verbose():
6+
@dataclass
7+
class SimpleDataObject(object):
8+
field_a: int = field()
9+
field_b: int = field()
10+
11+
left = SimpleDataObject(1, "b")
12+
right = SimpleDataObject(1, "c")
13+
14+
assert left == right
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from dataclasses import dataclass
2+
from dataclasses import field
3+
4+
5+
def test_comparing_two_different_data_classes():
6+
@dataclass
7+
class SimpleDataObjectOne(object):
8+
field_a: int = field()
9+
field_b: int = field()
10+
11+
@dataclass
12+
class SimpleDataObjectTwo(object):
13+
field_a: int = field()
14+
field_b: int = field()
15+
16+
left = SimpleDataObjectOne(1, "b")
17+
right = SimpleDataObjectTwo(1, "c")
18+
19+
assert left != right

testing/test_assertion.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import sys
77
import textwrap
88

9+
import attr
910
import py
1011
import six
1112

@@ -548,6 +549,115 @@ def test_mojibake(self):
548549
assert msg
549550

550551

552+
class TestAssert_reprcompare_dataclass(object):
553+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+")
554+
def test_dataclasses(self, testdir):
555+
p = testdir.copy_example("dataclasses/test_compare_dataclasses.py")
556+
result = testdir.runpytest(p)
557+
result.assert_outcomes(failed=1, passed=0)
558+
result.stdout.fnmatch_lines(
559+
[
560+
"*Omitting 1 identical items, use -vv to show*",
561+
"*Differing attributes:*",
562+
"*field_b: 'b' != 'c'*",
563+
]
564+
)
565+
566+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+")
567+
def test_dataclasses_verbose(self, testdir):
568+
p = testdir.copy_example("dataclasses/test_compare_dataclasses_verbose.py")
569+
result = testdir.runpytest(p, "-vv")
570+
result.assert_outcomes(failed=1, passed=0)
571+
result.stdout.fnmatch_lines(
572+
[
573+
"*Matching attributes:*",
574+
"*['field_a']*",
575+
"*Differing attributes:*",
576+
"*field_b: 'b' != 'c'*",
577+
]
578+
)
579+
580+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+")
581+
def test_dataclasses_with_attribute_comparison_off(self, testdir):
582+
p = testdir.copy_example(
583+
"dataclasses/test_compare_dataclasses_field_comparison_off.py"
584+
)
585+
result = testdir.runpytest(p, "-vv")
586+
result.assert_outcomes(failed=0, passed=1)
587+
588+
@pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+")
589+
def test_comparing_two_different_data_classes(self, testdir):
590+
p = testdir.copy_example(
591+
"dataclasses/test_compare_two_different_dataclasses.py"
592+
)
593+
result = testdir.runpytest(p, "-vv")
594+
result.assert_outcomes(failed=0, passed=1)
595+
596+
597+
class TestAssert_reprcompare_attrsclass(object):
598+
def test_attrs(self):
599+
@attr.s
600+
class SimpleDataObject(object):
601+
field_a = attr.ib()
602+
field_b = attr.ib()
603+
604+
left = SimpleDataObject(1, "b")
605+
right = SimpleDataObject(1, "c")
606+
607+
lines = callequal(left, right)
608+
assert lines[1].startswith("Omitting 1 identical item")
609+
assert "Matching attributes" not in lines
610+
for line in lines[1:]:
611+
assert "field_a" not in line
612+
613+
def test_attrs_verbose(self):
614+
@attr.s
615+
class SimpleDataObject(object):
616+
field_a = attr.ib()
617+
field_b = attr.ib()
618+
619+
left = SimpleDataObject(1, "b")
620+
right = SimpleDataObject(1, "c")
621+
622+
lines = callequal(left, right, verbose=2)
623+
assert lines[1].startswith("Matching attributes:")
624+
assert "Omitting" not in lines[1]
625+
assert lines[2] == "['field_a']"
626+
627+
def test_attrs_with_attribute_comparison_off(self):
628+
@attr.s
629+
class SimpleDataObject(object):
630+
field_a = attr.ib()
631+
field_b = attr.ib(cmp=False)
632+
633+
left = SimpleDataObject(1, "b")
634+
right = SimpleDataObject(1, "b")
635+
636+
lines = callequal(left, right, verbose=2)
637+
assert lines[1].startswith("Matching attributes:")
638+
assert "Omitting" not in lines[1]
639+
assert lines[2] == "['field_a']"
640+
for line in lines[2:]:
641+
assert "field_b" not in line
642+
643+
def test_comparing_two_different_attrs_classes(self):
644+
@attr.s
645+
class SimpleDataObjectOne(object):
646+
field_a = attr.ib()
647+
field_b = attr.ib()
648+
649+
@attr.s
650+
class SimpleDataObjectTwo(object):
651+
field_a = attr.ib()
652+
field_b = attr.ib()
653+
654+
left = SimpleDataObjectOne(1, "b")
655+
right = SimpleDataObjectTwo(1, "c")
656+
657+
lines = callequal(left, right)
658+
assert lines is None
659+
660+
551661
class TestFormatExplanation(object):
552662
def test_special_chars_full(self, testdir):
553663
# Issue 453, for the bug this would raise IndexError

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ commands =
133133
sphinx-build -W -b html . _build
134134

135135
[testenv:doctesting]
136-
basepython = python
136+
basepython = python3
137137
skipsdist = True
138138
deps =
139139
PyYAML

0 commit comments

Comments
 (0)