Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
60 changes: 60 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,65 @@ 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
assert (Exotic([2]) == Exotic([2])) is True
assert Exotic([2]).__eq__([2]) is NotImplemented

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