Skip to content

Commit 1c18fb8

Browse files
authored
Merge pull request #7553 from tirkarthi/namedtuple-diff
Add support to display field names in namedtuple diffs.
2 parents 2753859 + 9a0f4e5 commit 1c18fb8

File tree

4 files changed

+59
-12
lines changed

4 files changed

+59
-12
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ Justyna Janczyszyn
156156
Kale Kundert
157157
Kamran Ahmad
158158
Karl O. Pinc
159+
Karthikeyan Singaravelan
159160
Katarzyna Jachim
160161
Katarzyna Król
161162
Katerina Koukiou

changelog/7527.improvement.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
When a comparison between `namedtuple` instances of the same type fails, pytest now shows the differing field names (possibly nested) instead of their indexes.

src/_pytest/assertion/util.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from typing import Mapping
1010
from typing import Optional
1111
from typing import Sequence
12-
from typing import Tuple
1312

1413
import _pytest._code
1514
from _pytest import outcomes
@@ -111,6 +110,10 @@ def isset(x: Any) -> bool:
111110
return isinstance(x, (set, frozenset))
112111

113112

113+
def isnamedtuple(obj: Any) -> bool:
114+
return isinstance(obj, tuple) and getattr(obj, "_fields", None) is not None
115+
116+
114117
def isdatacls(obj: Any) -> bool:
115118
return getattr(obj, "__dataclass_fields__", None) is not None
116119

@@ -172,15 +175,20 @@ def _compare_eq_any(left: Any, right: Any, verbose: int = 0) -> List[str]:
172175
if istext(left) and istext(right):
173176
explanation = _diff_text(left, right, verbose)
174177
else:
175-
if issequence(left) and issequence(right):
178+
if type(left) == type(right) and (
179+
isdatacls(left) or isattrs(left) or isnamedtuple(left)
180+
):
181+
# Note: unlike dataclasses/attrs, namedtuples compare only the
182+
# field values, not the type or field names. But this branch
183+
# intentionally only handles the same-type case, which was often
184+
# used in older code bases before dataclasses/attrs were available.
185+
explanation = _compare_eq_cls(left, right, verbose)
186+
elif issequence(left) and issequence(right):
176187
explanation = _compare_eq_sequence(left, right, verbose)
177188
elif isset(left) and isset(right):
178189
explanation = _compare_eq_set(left, right, verbose)
179190
elif isdict(left) and isdict(right):
180191
explanation = _compare_eq_dict(left, right, verbose)
181-
elif type(left) == type(right) and (isdatacls(left) or isattrs(left)):
182-
type_fn = (isdatacls, isattrs)
183-
explanation = _compare_eq_cls(left, right, verbose, type_fn)
184192
elif verbose > 0:
185193
explanation = _compare_eq_verbose(left, right)
186194
if isiterable(left) and isiterable(right):
@@ -403,19 +411,17 @@ def _compare_eq_dict(
403411
return explanation
404412

405413

406-
def _compare_eq_cls(
407-
left: Any,
408-
right: Any,
409-
verbose: int,
410-
type_fns: Tuple[Callable[[Any], bool], Callable[[Any], bool]],
411-
) -> List[str]:
412-
isdatacls, isattrs = type_fns
414+
def _compare_eq_cls(left: Any, right: Any, verbose: int) -> List[str]:
413415
if isdatacls(left):
414416
all_fields = left.__dataclass_fields__
415417
fields_to_check = [field for field, info in all_fields.items() if info.compare]
416418
elif isattrs(left):
417419
all_fields = left.__attrs_attrs__
418420
fields_to_check = [field.name for field in all_fields if getattr(field, "eq")]
421+
elif isnamedtuple(left):
422+
fields_to_check = left._fields
423+
else:
424+
assert False
419425

420426
indent = " "
421427
same = []

testing/test_assertion.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import collections
12
import sys
23
import textwrap
34
from typing import Any
@@ -987,6 +988,44 @@ class SimpleDataObjectTwo:
987988
assert lines is None
988989

989990

991+
class TestAssert_reprcompare_namedtuple:
992+
def test_namedtuple(self) -> None:
993+
NT = collections.namedtuple("NT", ["a", "b"])
994+
995+
left = NT(1, "b")
996+
right = NT(1, "c")
997+
998+
lines = callequal(left, right)
999+
assert lines == [
1000+
"NT(a=1, b='b') == NT(a=1, b='c')",
1001+
"",
1002+
"Omitting 1 identical items, use -vv to show",
1003+
"Differing attributes:",
1004+
"['b']",
1005+
"",
1006+
"Drill down into differing attribute b:",
1007+
" b: 'b' != 'c'",
1008+
" - c",
1009+
" + b",
1010+
"Use -v to get the full diff",
1011+
]
1012+
1013+
def test_comparing_two_different_namedtuple(self) -> None:
1014+
NT1 = collections.namedtuple("NT1", ["a", "b"])
1015+
NT2 = collections.namedtuple("NT2", ["a", "b"])
1016+
1017+
left = NT1(1, "b")
1018+
right = NT2(2, "b")
1019+
1020+
lines = callequal(left, right)
1021+
# Because the types are different, uses the generic sequence matcher.
1022+
assert lines == [
1023+
"NT1(a=1, b='b') == NT2(a=2, b='b')",
1024+
"At index 0 diff: 1 != 2",
1025+
"Use -v to get the full diff",
1026+
]
1027+
1028+
9901029
class TestFormatExplanation:
9911030
def test_special_chars_full(self, pytester: Pytester) -> None:
9921031
# Issue 453, for the bug this would raise IndexError

0 commit comments

Comments
 (0)