Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detailed assert failure introspection for attrs and dataclasses objects #3776

Merged
merged 14 commits into from
Nov 22, 2018
Merged
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ coverage.xml
.pydevproject
.project
.settings
.vscode
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Alan Velasco
Alexander Johnson
Alexei Kozlenok
Allan Feldman
Aly Sivji
Anatoly Bubenkoff
Anders Hovmöller
Andras Tim
Expand Down
1 change: 1 addition & 0 deletions changelog/3632.feature.rst
Original file line number Diff line number Diff line change
@@ -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>`_).
24 changes: 24 additions & 0 deletions doc/en/example/assertion/failure_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,30 @@ def test_not_in_text_single_long_term(self):
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):
Expand Down
2 changes: 1 addition & 1 deletion doc/en/example/assertion/test_failures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
41 changes: 41 additions & 0 deletions src/_pytest/assertion/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ def isdict(x):
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)
Expand All @@ -142,6 +148,9 @@ def isiterable(obj):
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:
Expand Down Expand Up @@ -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 += [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i disagree with the chosen display style but cant provide any meaningful alternative yet - i would like to see some kind of diff style display using multiple lines

(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]
Expand Down
14 changes: 14 additions & 0 deletions testing/example_scripts/dataclasses/test_compare_dataclasses.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
110 changes: 110 additions & 0 deletions testing/test_assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import sys
import textwrap

import attr
import py
import six

Expand Down Expand Up @@ -548,6 +549,115 @@ def test_mojibake(self):
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
Expand Down
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ commands =
sphinx-build -W -b html . _build

[testenv:doctesting]
basepython = python
basepython = python3
skipsdist = True
deps =
PyYAML
Expand Down