From e2e7f15b719f480c4d2a3aea028c55f2dc3f0b75 Mon Sep 17 00:00:00 2001 From: ibriquem Date: Tue, 2 Jun 2020 15:38:41 +0200 Subject: [PATCH 1/5] Make dataclasses/attrs comparison recursive, fixes #4675 --- changelog/4675.bugfix.rst | 1 + src/_pytest/assertion/util.py | 48 ++++++----- .../test_compare_recursive_dataclasses.py | 34 ++++++++ testing/test_assertion.py | 80 +++++++++++++++++++ 4 files changed, 142 insertions(+), 21 deletions(-) create mode 100644 changelog/4675.bugfix.rst create mode 100644 testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py diff --git a/changelog/4675.bugfix.rst b/changelog/4675.bugfix.rst new file mode 100644 index 000000000..9f857622f --- /dev/null +++ b/changelog/4675.bugfix.rst @@ -0,0 +1 @@ +Make dataclasses/attrs comparison recursive. diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index 7d525aa4c..c2f0431d4 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -148,26 +148,7 @@ def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[ explanation = None try: if op == "==": - if istext(left) and istext(right): - explanation = _diff_text(left, right, verbose) - else: - if issequence(left) and issequence(right): - explanation = _compare_eq_sequence(left, right, verbose) - elif isset(left) and isset(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) - elif verbose > 0: - explanation = _compare_eq_verbose(left, right) - if isiterable(left) and isiterable(right): - expl = _compare_eq_iterable(left, right, verbose) - if explanation is not None: - explanation.extend(expl) - else: - explanation = expl + explanation = _compare_eq_any(left, right, verbose) elif op == "not in": if istext(left) and istext(right): explanation = _notin_text(left, right, verbose) @@ -187,6 +168,28 @@ def assertrepr_compare(config, op: str, left: Any, right: Any) -> Optional[List[ return [summary] + explanation +def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]: + explanation = [] # type: List[str] + if istext(left) and istext(right): + explanation = _diff_text(left, right, verbose) + else: + if issequence(left) and issequence(right): + explanation = _compare_eq_sequence(left, right, verbose) + elif isset(left) and isset(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) + elif verbose > 0: + explanation = _compare_eq_verbose(left, right) + if isiterable(left) and isiterable(right): + expl = _compare_eq_iterable(left, right, verbose) + explanation.extend(expl) + return explanation + + def _diff_text(left: str, right: str, verbose: int = 0) -> List[str]: """Return the explanation for the diff between text. @@ -439,7 +442,10 @@ def _compare_eq_cls( explanation += ["Differing attributes:"] for field in diff: explanation += [ - ("%s: %r != %r") % (field, getattr(left, field), getattr(right, field)) + ("%s: %r != %r") % (field, getattr(left, field), getattr(right, field)), + "", + "Drill down into differing attribute %s:" % field, + *_compare_eq_any(getattr(left, field), getattr(right, field), verbose), ] return explanation diff --git a/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py new file mode 100644 index 000000000..98385379e --- /dev/null +++ b/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from dataclasses import field + + +@dataclass +class SimpleDataObject: + field_a: int = field() + field_b: int = field() + + +@dataclass +class ComplexDataObject2: + field_a: SimpleDataObject = field() + field_b: SimpleDataObject = field() + + +@dataclass +class ComplexDataObject: + field_a: SimpleDataObject = field() + field_b: ComplexDataObject2 = field() + + +def test_recursive_dataclasses(): + + left = ComplexDataObject( + SimpleDataObject(1, "b"), + ComplexDataObject2(SimpleDataObject(1, "b"), SimpleDataObject(2, "c"),), + ) + right = ComplexDataObject( + SimpleDataObject(1, "b"), + ComplexDataObject2(SimpleDataObject(1, "b"), SimpleDataObject(3, "c"),), + ) + + assert left == right diff --git a/testing/test_assertion.py b/testing/test_assertion.py index f28876edc..4b1df89c9 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -781,6 +781,48 @@ class TestAssert_reprcompare_dataclass: "*Omitting 1 identical items, use -vv to show*", "*Differing attributes:*", "*field_b: 'b' != 'c'*", + "*- c*", + "*+ b*", + ] + ) + + @pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+") + def test_recursive_dataclasses(self, testdir): + p = testdir.copy_example("dataclasses/test_compare_recursive_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: ComplexDataObject2(*SimpleDataObject(field_a=2, field_b='c')) != ComplexDataObject2(*SimpleDataObject(field_a=3, field_b='c'))*", # noqa + "*Drill down into differing attribute field_b:*", + "*Omitting 1 identical items, use -vv to show*", + "*Differing attributes:*", + "*Full output truncated*", + ] + ) + + @pytest.mark.skipif(sys.version_info < (3, 7), reason="Dataclasses in Python3.7+") + def test_recursive_dataclasses_verbose(self, testdir): + p = testdir.copy_example("dataclasses/test_compare_recursive_dataclasses.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: ComplexDataObject2(*SimpleDataObject(field_a=2, field_b='c')) != ComplexDataObject2(*SimpleDataObject(field_a=3, field_b='c'))*", # noqa + "*Matching attributes:*", + "*['field_a']*", + "*Differing attributes:*", + "*field_b: SimpleDataObject(field_a=2, field_b='c') " + "!= SimpleDataObject(field_a=3, field_b='c')*", + "*Matching attributes:*", + "*['field_b']*", + "*Differing attributes:*", + "*field_a: 2 != 3", ] ) @@ -832,6 +874,44 @@ class TestAssert_reprcompare_attrsclass: for line in lines[1:]: assert "field_a" not in line + def test_attrs_recursive(self) -> None: + @attr.s + class OtherDataObject: + field_c = attr.ib() + field_d = attr.ib() + + @attr.s + class SimpleDataObject: + field_a = attr.ib() + field_b = attr.ib() + + left = SimpleDataObject(OtherDataObject(1, "a"), "b") + right = SimpleDataObject(OtherDataObject(1, "b"), "b") + + lines = callequal(left, right) + assert "Matching attributes" not in lines + for line in lines[1:]: + assert "field_b:" not in line + assert "field_c:" not in line + + def test_attrs_recursive_verbose(self) -> None: + @attr.s + class OtherDataObject: + field_c = attr.ib() + field_d = attr.ib() + + @attr.s + class SimpleDataObject: + field_a = attr.ib() + field_b = attr.ib() + + left = SimpleDataObject(OtherDataObject(1, "a"), "b") + right = SimpleDataObject(OtherDataObject(1, "b"), "b") + + lines = callequal(left, right) + assert "field_d: 'a' != 'b'" in lines + print("\n".join(lines)) + def test_attrs_verbose(self) -> None: @attr.s class SimpleDataObject: From 09988f3ed1aec29a94f3ac662ef11e99fe1ffafb Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Wed, 3 Jun 2020 16:06:22 +0300 Subject: [PATCH 2/5] Update testing/test_assertion.py --- testing/test_assertion.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/testing/test_assertion.py b/testing/test_assertion.py index 4b1df89c9..fcfcf430d 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -817,8 +817,7 @@ class TestAssert_reprcompare_dataclass: "*Matching attributes:*", "*['field_a']*", "*Differing attributes:*", - "*field_b: SimpleDataObject(field_a=2, field_b='c') " - "!= SimpleDataObject(field_a=3, field_b='c')*", + "*field_b: SimpleDataObject(field_a=2, field_b='c') != SimpleDataObject(field_a=3, field_b='c')*", # noqa "*Matching attributes:*", "*['field_b']*", "*Differing attributes:*", From 5a78df4bd059d4c6103217ba9146dcf9d08f989c Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 9 Jun 2020 14:43:04 -0300 Subject: [PATCH 3/5] Update CHANGELOG --- changelog/4675.bugfix.rst | 1 - changelog/4675.improvement.rst | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 changelog/4675.bugfix.rst create mode 100644 changelog/4675.improvement.rst diff --git a/changelog/4675.bugfix.rst b/changelog/4675.bugfix.rst deleted file mode 100644 index 9f857622f..000000000 --- a/changelog/4675.bugfix.rst +++ /dev/null @@ -1 +0,0 @@ -Make dataclasses/attrs comparison recursive. diff --git a/changelog/4675.improvement.rst b/changelog/4675.improvement.rst new file mode 100644 index 000000000..d26e24da2 --- /dev/null +++ b/changelog/4675.improvement.rst @@ -0,0 +1 @@ +Rich comparision for dataclasses and `attrs`-classes is now recursive. From c229d6f46ffc77c21ee8773cd341d25d4f8291ba Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 9 Jun 2020 14:48:49 -0300 Subject: [PATCH 4/5] Fix mypy checks --- .../dataclasses/test_compare_recursive_dataclasses.py | 2 +- testing/test_assertion.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py b/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py index 98385379e..516e36e5c 100644 --- a/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py +++ b/testing/example_scripts/dataclasses/test_compare_recursive_dataclasses.py @@ -5,7 +5,7 @@ from dataclasses import field @dataclass class SimpleDataObject: field_a: int = field() - field_b: int = field() + field_b: str = field() @dataclass diff --git a/testing/test_assertion.py b/testing/test_assertion.py index fcfcf430d..ae5e75dbf 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -888,6 +888,7 @@ class TestAssert_reprcompare_attrsclass: right = SimpleDataObject(OtherDataObject(1, "b"), "b") lines = callequal(left, right) + assert lines is not None assert "Matching attributes" not in lines for line in lines[1:]: assert "field_b:" not in line @@ -908,8 +909,8 @@ class TestAssert_reprcompare_attrsclass: right = SimpleDataObject(OtherDataObject(1, "b"), "b") lines = callequal(left, right) + assert lines is not None assert "field_d: 'a' != 'b'" in lines - print("\n".join(lines)) def test_attrs_verbose(self) -> None: @attr.s From 10cee92955f9fbd5c39ba2b02e7d8d206458c0eb Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 9 Jun 2020 14:58:57 -0300 Subject: [PATCH 5/5] Fix typo --- changelog/4675.improvement.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog/4675.improvement.rst b/changelog/4675.improvement.rst index d26e24da2..c90cd3591 100644 --- a/changelog/4675.improvement.rst +++ b/changelog/4675.improvement.rst @@ -1 +1 @@ -Rich comparision for dataclasses and `attrs`-classes is now recursive. +Rich comparison for dataclasses and `attrs`-classes is now recursive.