Skip to content
Open
1 change: 1 addition & 0 deletions changelog/13652.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``contains one more item`` now reports the actual extra item in sequence diffs.
27 changes: 25 additions & 2 deletions src/_pytest/assertion/_compare_sequence.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,17 @@ def _compare_eq_iterable(
yield highlighter(line.rstrip(), lexer="diff")


def _tails_align(
left: Sequence[object],
right: Sequence[object],
) -> bool:
# Exotic __eq__ may return non-bools or raise; fall back instead.
try:
return bool(left == right)
except Exception:
return False


def _compare_eq_sequence(
left: Sequence[object],
right: Sequence[object],
Expand All @@ -52,6 +63,8 @@ def _compare_eq_sequence(
comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes)
len_left = len(left)
len_right = len(right)
found_diff = False
i = -1
for i in range(min(len_left, len_right)):
if left[i] != right[i]:
if comparing_bytes:
Expand All @@ -73,6 +86,7 @@ def _compare_eq_sequence(
f"At index {i} diff:"
f" {highlighter(repr(left_value))} != {highlighter(repr(right_value))}"
)
found_diff = True
break

if comparing_bytes:
Expand All @@ -84,11 +98,20 @@ def _compare_eq_sequence(
if len_diff:
if len_diff > 0:
dir_with_more = "Left"
extra = saferepr(left[len_right])
# If the longer side has exactly one extra item and the tails after
# the first differing index align (offset by one), that item is the
# insertion.
if found_diff and len_diff == 1 and _tails_align(left[i + 1 :], right[i:]):
extra = saferepr(left[i])
else:
extra = saferepr(left[len_right])
else:
len_diff = 0 - len_diff
dir_with_more = "Right"
extra = saferepr(right[len_left])
if found_diff and len_diff == 1 and _tails_align(right[i + 1 :], left[i:]):
extra = saferepr(right[i])
else:
extra = saferepr(right[len_left])

if len_diff == 1:
yield f"{dir_with_more} contains one more item: {highlighter(extra)}"
Expand Down
58 changes: 58 additions & 0 deletions testing/test_assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from collections.abc import Iterator
from collections.abc import Mapping
from collections.abc import MutableSequence
from collections.abc import Sequence
import dataclasses
import sys
import textwrap
Expand Down Expand Up @@ -1105,6 +1106,63 @@ def test_sequence_different_items(self) -> None:
" )",
]

@pytest.mark.parametrize(
("left", "right", "expected"),
[
([], [1], "Right contains one more item: 1"),
([1], [], "Left contains one more item: 1"),
([1, 2, 3], [0, 1, 2, 3], "Right contains one more item: 0"),
([1, 2, 3], [1, 2, 3, 4], "Right contains one more item: 4"),
([1, 2, 3], [1, 2, 0, 3], "Right contains one more item: 0"),
([1, 2, 0, 3], [1, 2, 3], "Left contains one more item: 0"),
([1, 1], [1, 0, 1], "Right contains one more item: 0"),
([3, 4, 5], [1, 2], "Left contains one more item: 5"),
# Fallback: single extra item but tails don't align
([1, 2, 3], [1, 9, 8, 3], "Right contains one more item: 3"),
([1, 9, 8, 3], [1, 2, 3], "Left contains one more item: 3"),
(
[1, 2, 3],
[0, 1, 2, 3, 4],
"Right contains 2 more items, first extra item: 3",
),
],
)
def test_sequence_extra_item_message(
self, left: list[object], right: list[object], expected: str
) -> None:
lines = callequal(left, right, verbose=1)
assert lines is not None
assert expected in lines

def test_sequence_extra_item_exotic_eq_falls_back(self) -> None:
"""Exotic elementwise __eq__ must not break rendering (fallback)."""

class Exotic(Sequence[int]):
def __init__(self, iterable):
self.elements = list(iterable)

def __getitem__(self, item):
result = self.elements[item]
return Exotic(result) if isinstance(item, slice) else result

def __len__(self):
return len(self.elements)

def __eq__(self, other):
if isinstance(other, Exotic):
result = [
a == b
for a, b in zip(self.elements, other.elements, strict=False)
]
if len(result) != 1:
raise ValueError("ambiguous, like numpy")
return result[0]
return NotImplemented

lines = callequal(Exotic([1, 2, 3, 4]), Exotic([1, 9, 2, 3, 4]), verbose=1)
assert lines is not None
assert "Right contains one more item: 4" in lines

def test_set(self) -> None:
expl = callequal({0, 1}, {0, 2})
assert expl is not None
Expand Down
46 changes: 45 additions & 1 deletion testing/test_error_diffs.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def test_this():
> assert result == expected
E assert [1, 3] == [1, 2, 3]
E At index 1 diff: 3 != 2
E Right contains one more item: 3
E Right contains one more item: 2
E Full diff: (-: missing in left side, +: extra in left side)
E [
E 1,
Expand All @@ -76,6 +76,50 @@ def test_this():
""",
id="Compare lists, one item missing",
),
pytest.param(
"""
def test_this():
result = [1, 2, 3]
expected = [1, 2, 0, 3]
assert result == expected
""",
"""
> assert result == expected
E assert [1, 2, 3] == [1, 2, 0, 3]
E At index 2 diff: 3 != 0
E Right contains one more item: 0
E Full diff: (-: missing in left side, +: extra in left side)
E [
E 1,
E 2,
E - 0,
E 3,
E ]
""",
id="Compare lists, one extra item inserted mid-list",
),
pytest.param(
"""
def test_this():
result = [1, 2, 0, 3]
expected = [1, 2, 3]
assert result == expected
""",
"""
> assert result == expected
E assert [1, 2, 0, 3] == [1, 2, 3]
E At index 2 diff: 0 != 3
E Left contains one more item: 0
E Full diff: (-: missing in left side, +: extra in left side)
E [
E 1,
E 2,
E + 0,
E 3,
E ]
""",
id="Compare lists, one extra item inserted mid-list on left",
),
pytest.param(
"""
def test_this():
Expand Down