Skip to content

Commit ff77cd8

Browse files
[refactor] Make base assertion comparisons return an iterator instead of a list of string (#14546)
Following Ronny's review comment on #13762, switch the set comparison helpers in ``_compare_set.py`` to return ``Iterator[str]`` so the composition is direct.
1 parent 0d8491a commit ff77cd8

5 files changed

Lines changed: 106 additions & 88 deletions

File tree

src/_pytest/assertion/__init__.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -223,11 +223,14 @@ def pytest_assertrepr_compare(
223223
else:
224224
# Keep it plaintext when not using terminalrepoterer (#14377).
225225
highlighter = util.dummy_highlighter
226-
return util.assertrepr_compare(
227-
op=op,
228-
left=left,
229-
right=right,
230-
verbose=config.get_verbosity(Config.VERBOSITY_ASSERTIONS),
231-
highlighter=highlighter,
232-
assertion_text_diff_style=util.get_assertion_text_diff_style(config),
226+
explanation = list(
227+
util.assertrepr_compare(
228+
op=op,
229+
left=left,
230+
right=right,
231+
verbose=config.get_verbosity(Config.VERBOSITY_ASSERTIONS),
232+
highlighter=highlighter,
233+
assertion_text_diff_style=util.get_assertion_text_diff_style(config),
234+
)
233235
)
236+
return explanation or None

src/_pytest/assertion/_compare_any.py

Lines changed: 25 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -28,54 +28,52 @@ def _compare_eq_any(
2828
highlighter: _HighlightFunc,
2929
verbose: int,
3030
assertion_text_diff_style: _AssertionTextDiffStyle,
31-
) -> list[str]:
32-
explanation = []
31+
) -> Iterator[str]:
32+
"""Yield the per-line explanation for ``left == right`` (without summary).
33+
34+
Yields nothing when no specialised explanation applies, so consumers
35+
can stream the output and bail out early (e.g. for truncation) without
36+
materialising the entire diff first.
37+
"""
3338
if istext(left) and istext(right):
34-
explanation = list(
35-
_compare_eq_text(
36-
left,
37-
right,
38-
highlighter,
39-
verbose,
40-
assertion_text_diff_style,
41-
)
39+
yield from _compare_eq_text(
40+
left,
41+
right,
42+
highlighter,
43+
verbose,
44+
assertion_text_diff_style,
4245
)
4346
else:
4447
from _pytest.python_api import ApproxBase
4548

4649
# Although the common order should be obtained == approx(...), allow both ways.
4750
if isinstance(right, ApproxBase):
48-
explanation = right._repr_compare(left)
51+
yield from right._repr_compare(left)
4952
elif isinstance(left, ApproxBase):
50-
explanation = left._repr_compare(right)
53+
yield from left._repr_compare(right)
5154
elif type(left) is type(right) and (
5255
isdatacls(left) or isattrs(left) or isnamedtuple(left)
5356
):
5457
# Note: unlike dataclasses/attrs, namedtuples compare only the
5558
# field values, not the type or field names. But this branch
5659
# intentionally only handles the same-type case, which was often
5760
# used in older code bases before dataclasses/attrs were available.
58-
explanation = list(
59-
_compare_eq_cls(
60-
left,
61-
right,
62-
highlighter,
63-
verbose,
64-
assertion_text_diff_style,
65-
)
61+
yield from _compare_eq_cls(
62+
left,
63+
right,
64+
highlighter,
65+
verbose,
66+
assertion_text_diff_style,
6667
)
6768
elif issequence(left) and issequence(right):
68-
explanation = list(_compare_eq_sequence(left, right, highlighter, verbose))
69+
yield from _compare_eq_sequence(left, right, highlighter, verbose)
6970
elif isset(left) and isset(right):
70-
explanation = _compare_eq_set(left, right, highlighter, verbose)
71+
yield from _compare_eq_set(left, right, highlighter, verbose)
7172
elif ismapping(left) and ismapping(right):
72-
explanation = list(_compare_eq_mapping(left, right, highlighter, verbose))
73+
yield from _compare_eq_mapping(left, right, highlighter, verbose)
7374

7475
if isiterable(left) and isiterable(right):
75-
expl = _compare_eq_iterable(left, right, highlighter, verbose)
76-
explanation.extend(expl)
77-
78-
return explanation
76+
yield from _compare_eq_iterable(left, right, highlighter, verbose)
7977

8078

8179
def _compare_eq_cls(

src/_pytest/assertion/_compare_set.py

Lines changed: 27 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
from collections.abc import Callable
4+
from collections.abc import Iterable
5+
from collections.abc import Iterator
46
from collections.abc import Set as AbstractSet
57
from typing import TypeAlias
68

@@ -13,73 +15,69 @@ def _set_one_sided_diff(
1315
set1: AbstractSet[object],
1416
set2: AbstractSet[object],
1517
highlighter: _HighlightFunc,
16-
) -> list[str]:
17-
explanation = []
18+
) -> Iterator[str]:
1819
diff = set1 - set2
1920
if diff:
20-
explanation.append(f"Extra items in the {posn} set:")
21+
yield f"Extra items in the {posn} set:"
2122
for item in diff:
22-
explanation.append(highlighter(saferepr(item)))
23-
return explanation
23+
yield highlighter(saferepr(item))
2424

2525

2626
def _compare_eq_set(
2727
left: AbstractSet[object],
2828
right: AbstractSet[object],
2929
highlighter: _HighlightFunc,
3030
verbose: int = 0,
31-
) -> list[str]:
32-
explanation = []
33-
explanation.extend(_set_one_sided_diff("left", left, right, highlighter))
34-
explanation.extend(_set_one_sided_diff("right", right, left, highlighter))
35-
return explanation
31+
) -> Iterator[str]:
32+
yield from _set_one_sided_diff("left", left, right, highlighter)
33+
yield from _set_one_sided_diff("right", right, left, highlighter)
3634

3735

38-
def _compare_gt_set(
36+
def _compare_gte_set(
3937
left: AbstractSet[object],
4038
right: AbstractSet[object],
4139
highlighter: _HighlightFunc,
4240
verbose: int = 0,
43-
) -> list[str]:
44-
explanation = _compare_gte_set(left, right, highlighter)
45-
if not explanation:
46-
return ["Both sets are equal"]
47-
return explanation
41+
) -> Iterator[str]:
42+
yield from _set_one_sided_diff("right", right, left, highlighter)
4843

4944

50-
def _compare_lt_set(
45+
def _compare_lte_set(
5146
left: AbstractSet[object],
5247
right: AbstractSet[object],
5348
highlighter: _HighlightFunc,
5449
verbose: int = 0,
55-
) -> list[str]:
56-
explanation = _compare_lte_set(left, right, highlighter)
57-
if not explanation:
58-
return ["Both sets are equal"]
59-
return explanation
50+
) -> Iterator[str]:
51+
yield from _set_one_sided_diff("left", left, right, highlighter)
6052

6153

62-
def _compare_gte_set(
54+
def _compare_gt_set(
6355
left: AbstractSet[object],
6456
right: AbstractSet[object],
6557
highlighter: _HighlightFunc,
6658
verbose: int = 0,
67-
) -> list[str]:
68-
return _set_one_sided_diff("right", right, left, highlighter)
59+
) -> Iterator[str]:
60+
if left == right:
61+
yield "Both sets are equal"
62+
else:
63+
yield from _set_one_sided_diff("right", right, left, highlighter)
6964

7065

71-
def _compare_lte_set(
66+
def _compare_lt_set(
7267
left: AbstractSet[object],
7368
right: AbstractSet[object],
7469
highlighter: _HighlightFunc,
7570
verbose: int = 0,
76-
) -> list[str]:
77-
return _set_one_sided_diff("left", left, right, highlighter)
71+
) -> Iterator[str]:
72+
if left == right:
73+
yield "Both sets are equal"
74+
else:
75+
yield from _set_one_sided_diff("left", left, right, highlighter)
7876

7977

8078
SetComparisonFunction: TypeAlias = Callable[
8179
[AbstractSet[object], AbstractSet[object], _HighlightFunc, int],
82-
list[str],
80+
Iterable[str],
8381
]
8482

8583
SET_COMPARISON_FUNCTIONS: dict[str, SetComparisonFunction] = {

src/_pytest/assertion/util.py

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from __future__ import annotations
55

66
from collections.abc import Callable
7+
from collections.abc import Iterator
78
from collections.abc import Sequence
89
from typing import Literal
910
from unicodedata import normalize
@@ -139,8 +140,18 @@ def assertrepr_compare(
139140
verbose: int,
140141
highlighter: _HighlightFunc,
141142
assertion_text_diff_style: _AssertionTextDiffStyle,
142-
) -> list[str] | None:
143-
"""Return specialised explanations for some operators/operands."""
143+
) -> Iterator[str]:
144+
"""Yield specialised explanations for some operators/operands.
145+
146+
The first line yielded is always the summary (``left op right``);
147+
subsequent lines are the detailed explanation. Yields nothing when no
148+
specialised explanation applies, which lets consumers map an empty
149+
iterator to "no explanation" without materialising anything.
150+
151+
The iterator is lazy on purpose: a streaming consumer can stop pulling
152+
lines as soon as it has enough to show, so an enormous diff doesn't
153+
have to be built in full just to be thrown away.
154+
"""
144155
# Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier.
145156
# See issue #3246.
146157
use_ascii = (
@@ -164,37 +175,43 @@ def assertrepr_compare(
164175

165176
summary = f"{left_repr} {op} {right_repr}"
166177

167-
explanation = None
168178
try:
169179
if op == "==":
170-
explanation = _compare_eq_any(
180+
source: Iterator[str] = _compare_eq_any(
171181
left,
172182
right,
173183
highlighter,
174184
verbose,
175185
assertion_text_diff_style,
176186
)
177-
elif op == "not in":
178-
if istext(left) and istext(right):
179-
explanation = list(_notin_text(left, right, verbose))
180-
elif op in {"!=", ">=", "<=", ">", "<"}:
181-
if isset(left) and isset(right):
182-
explanation = SET_COMPARISON_FUNCTIONS[op](
183-
left, right, highlighter, verbose
184-
)
185-
187+
elif op == "not in" and istext(left) and istext(right):
188+
source = _notin_text(left, right, verbose)
189+
elif op in {"!=", ">=", "<=", ">", "<"} and isset(left) and isset(right):
190+
source = iter(
191+
SET_COMPARISON_FUNCTIONS[op](left, right, highlighter, verbose)
192+
)
193+
else:
194+
source = iter(())
195+
196+
# Only yield the summary if there is a detailed explanation.
197+
# Make sure there's a separating empty line after the summary.
198+
summary_yielded = False
199+
for line in source:
200+
if not summary_yielded:
201+
yield summary
202+
if line != "":
203+
yield ""
204+
summary_yielded = True
205+
yield line
186206
except outcomes.Exit:
187207
raise
188208
except Exception:
189209
repr_crash = _pytest._code.ExceptionInfo.from_current()._getreprcrash()
190-
explanation = [
191-
f"(pytest_assertion plugin: representation of details failed: {repr_crash}.",
192-
" Probably an object has a faulty __repr__.)",
193-
]
194-
195-
if not explanation:
196-
return None
197-
198-
if explanation[0] != "":
199-
explanation = ["", *explanation]
200-
return [summary, *explanation]
210+
if not summary_yielded:
211+
yield summary
212+
yield ""
213+
summary_yielded = True
214+
yield (
215+
f"(pytest_assertion plugin: representation of details failed: {repr_crash}."
216+
)
217+
yield " Probably an object has a faulty __repr__.)"

testing/test_assertion.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1043,7 +1043,9 @@ def __repr__(self):
10431043
assert expl is not None
10441044
assert expl[0].startswith("{} == <[ValueError")
10451045
assert "raised in repr" in expl[0]
1046-
assert expl[2:] == [
1046+
# Streaming explanation: any per-line output produced before the
1047+
# bad repr is preserved, then the failure notice is appended.
1048+
assert expl[-2:] == [
10471049
"(pytest_assertion plugin: representation of details failed:"
10481050
f" {__file__}:{A.__repr__.__code__.co_firstlineno + 1}: ValueError: 42.",
10491051
" Probably an object has a faulty __repr__.)",

0 commit comments

Comments
 (0)