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