diff --git a/.gitignore b/.gitignore
index f5cd0145c..e2d59502c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,3 +44,4 @@ coverage.xml
.pydevproject
.project
.settings
+.vscode
diff --git a/AUTHORS b/AUTHORS
index 777eda324..f5ba603c2 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -11,6 +11,7 @@ Alan Velasco
Alexander Johnson
Alexei Kozlenok
Allan Feldman
+Aly Sivji
Anatoly Bubenkoff
Anders Hovmöller
Andras Tim
diff --git a/changelog/3632.feature.rst b/changelog/3632.feature.rst
new file mode 100644
index 000000000..cb1d93750
--- /dev/null
+++ b/changelog/3632.feature.rst
@@ -0,0 +1 @@
+Richer equality comparison introspection on ``AssertionError`` for objects created using `attrs `_ or `dataclasses `_ (Python 3.7+, `backported to 3.6 `_).
diff --git a/doc/en/example/assertion/failure_demo.py b/doc/en/example/assertion/failure_demo.py
index a9615c215..5bd95a37b 100644
--- a/doc/en/example/assertion/failure_demo.py
+++ b/doc/en/example/assertion/failure_demo.py
@@ -98,6 +98,30 @@ class TestSpecialisedExplanations(object):
text = "head " * 50 + "f" * 70 + "tail " * 20
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():
class Foo(object):
diff --git a/doc/en/example/assertion/test_failures.py b/doc/en/example/assertion/test_failures.py
index 9ffe31664..30ebc72dc 100644
--- a/doc/en/example/assertion/test_failures.py
+++ b/doc/en/example/assertion/test_failures.py
@@ -9,5 +9,5 @@ def test_failure_demo_fails_properly(testdir):
failure_demo.copy(target)
failure_demo.copy(testdir.tmpdir.join(failure_demo.basename))
result = testdir.runpytest(target, syspathinsert=True)
- result.stdout.fnmatch_lines(["*42 failed*"])
+ result.stdout.fnmatch_lines(["*44 failed*"])
assert result.ret != 0
diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py
index 451e45495..3ec9a365a 100644
--- a/src/_pytest/assertion/util.py
+++ b/src/_pytest/assertion/util.py
@@ -122,6 +122,12 @@ def assertrepr_compare(config, op, left, right):
def isset(x):
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):
try:
iter(obj)
@@ -142,6 +148,9 @@ def assertrepr_compare(config, op, left, right):
explanation = _compare_eq_set(left, right, verbose)
elif isdict(left) and isdict(right):
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):
expl = _compare_eq_iterable(left, right, verbose)
if explanation is not None:
@@ -315,6 +324,38 @@ def _compare_eq_dict(left, right, verbose=False):
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):
index = text.find(term)
head = text[:index]
diff --git a/testing/example_scripts/dataclasses/test_compare_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_dataclasses.py
new file mode 100644
index 000000000..3bbebe2aa
--- /dev/null
+++ b/testing/example_scripts/dataclasses/test_compare_dataclasses.py
@@ -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
diff --git a/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py b/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py
new file mode 100644
index 000000000..63b9f534e
--- /dev/null
+++ b/testing/example_scripts/dataclasses/test_compare_dataclasses_field_comparison_off.py
@@ -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
diff --git a/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py b/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py
new file mode 100644
index 000000000..17835c0c3
--- /dev/null
+++ b/testing/example_scripts/dataclasses/test_compare_dataclasses_verbose.py
@@ -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
diff --git a/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py
new file mode 100644
index 000000000..24f185d8a
--- /dev/null
+++ b/testing/example_scripts/dataclasses/test_compare_two_different_dataclasses.py
@@ -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
diff --git a/testing/test_assertion.py b/testing/test_assertion.py
index b6c31aba2..bb54e394f 100644
--- a/testing/test_assertion.py
+++ b/testing/test_assertion.py
@@ -6,6 +6,7 @@ from __future__ import print_function
import sys
import textwrap
+import attr
import py
import six
@@ -548,6 +549,115 @@ class TestAssert_reprcompare(object):
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):
def test_special_chars_full(self, testdir):
# Issue 453, for the bug this would raise IndexError
diff --git a/tox.ini b/tox.ini
index 92cdfc85a..4d2d910ab 100644
--- a/tox.ini
+++ b/tox.ini
@@ -133,7 +133,7 @@ commands =
sphinx-build -W -b html . _build
[testenv:doctesting]
-basepython = python
+basepython = python3
skipsdist = True
deps =
PyYAML