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
17 changes: 10 additions & 7 deletions src/_pytest/assertion/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,14 @@ def pytest_assertrepr_compare(
else:
# Keep it plaintext when not using terminalrepoterer (#14377).
highlighter = util.dummy_highlighter
return util.assertrepr_compare(
op=op,
left=left,
right=right,
verbose=config.get_verbosity(Config.VERBOSITY_ASSERTIONS),
highlighter=highlighter,
assertion_text_diff_style=util.get_assertion_text_diff_style(config),
explanation = list(
util.assertrepr_compare(
op=op,
left=left,
right=right,
verbose=config.get_verbosity(Config.VERBOSITY_ASSERTIONS),
highlighter=highlighter,
assertion_text_diff_style=util.get_assertion_text_diff_style(config),
)
)
return explanation or None
52 changes: 25 additions & 27 deletions src/_pytest/assertion/_compare_any.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,54 +28,52 @@ def _compare_eq_any(
highlighter: _HighlightFunc,
verbose: int,
assertion_text_diff_style: _AssertionTextDiffStyle,
) -> list[str]:
explanation = []
) -> Iterator[str]:
"""Yield the per-line explanation for ``left == right`` (without summary).

Yields nothing when no specialised explanation applies, so consumers
can stream the output and bail out early (e.g. for truncation) without
materialising the entire diff first.
"""
if istext(left) and istext(right):
explanation = list(
_compare_eq_text(
left,
right,
highlighter,
verbose,
assertion_text_diff_style,
)
yield from _compare_eq_text(
left,
right,
highlighter,
verbose,
assertion_text_diff_style,
)
else:
from _pytest.python_api import ApproxBase

# Although the common order should be obtained == approx(...), allow both ways.
if isinstance(right, ApproxBase):
explanation = right._repr_compare(left)
yield from right._repr_compare(left)
elif isinstance(left, ApproxBase):
explanation = left._repr_compare(right)
yield from left._repr_compare(right)
elif type(left) is type(right) and (
isdatacls(left) or isattrs(left) or isnamedtuple(left)
):
# Note: unlike dataclasses/attrs, namedtuples compare only the
# field values, not the type or field names. But this branch
# intentionally only handles the same-type case, which was often
# used in older code bases before dataclasses/attrs were available.
explanation = list(
_compare_eq_cls(
left,
right,
highlighter,
verbose,
assertion_text_diff_style,
)
yield from _compare_eq_cls(
left,
right,
highlighter,
verbose,
assertion_text_diff_style,
)
elif issequence(left) and issequence(right):
explanation = list(_compare_eq_sequence(left, right, highlighter, verbose))
yield from _compare_eq_sequence(left, right, highlighter, verbose)
elif isset(left) and isset(right):
explanation = _compare_eq_set(left, right, highlighter, verbose)
yield from _compare_eq_set(left, right, highlighter, verbose)
elif ismapping(left) and ismapping(right):
explanation = list(_compare_eq_mapping(left, right, highlighter, verbose))
yield from _compare_eq_mapping(left, right, highlighter, verbose)

if isiterable(left) and isiterable(right):
expl = _compare_eq_iterable(left, right, highlighter, verbose)
explanation.extend(expl)

return explanation
yield from _compare_eq_iterable(left, right, highlighter, verbose)


def _compare_eq_cls(
Expand Down
56 changes: 27 additions & 29 deletions src/_pytest/assertion/_compare_set.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from __future__ import annotations

from collections.abc import Callable
from collections.abc import Iterable
from collections.abc import Iterator
from collections.abc import Set as AbstractSet
from typing import TypeAlias

Expand All @@ -13,73 +15,69 @@ def _set_one_sided_diff(
set1: AbstractSet[object],
set2: AbstractSet[object],
highlighter: _HighlightFunc,
) -> list[str]:
explanation = []
) -> Iterator[str]:
diff = set1 - set2
if diff:
explanation.append(f"Extra items in the {posn} set:")
yield f"Extra items in the {posn} set:"
for item in diff:
explanation.append(highlighter(saferepr(item)))
return explanation
yield highlighter(saferepr(item))


def _compare_eq_set(
left: AbstractSet[object],
right: AbstractSet[object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
explanation = []
explanation.extend(_set_one_sided_diff("left", left, right, highlighter))
explanation.extend(_set_one_sided_diff("right", right, left, highlighter))
return explanation
) -> Iterator[str]:
yield from _set_one_sided_diff("left", left, right, highlighter)
yield from _set_one_sided_diff("right", right, left, highlighter)


def _compare_gt_set(
def _compare_gte_set(
left: AbstractSet[object],
right: AbstractSet[object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
explanation = _compare_gte_set(left, right, highlighter)
if not explanation:
return ["Both sets are equal"]
return explanation
) -> Iterator[str]:
yield from _set_one_sided_diff("right", right, left, highlighter)


def _compare_lt_set(
def _compare_lte_set(
left: AbstractSet[object],
right: AbstractSet[object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
explanation = _compare_lte_set(left, right, highlighter)
if not explanation:
return ["Both sets are equal"]
return explanation
) -> Iterator[str]:
yield from _set_one_sided_diff("left", left, right, highlighter)


def _compare_gte_set(
def _compare_gt_set(
left: AbstractSet[object],
right: AbstractSet[object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
return _set_one_sided_diff("right", right, left, highlighter)
) -> Iterator[str]:
if left == right:
yield "Both sets are equal"
else:
yield from _set_one_sided_diff("right", right, left, highlighter)


def _compare_lte_set(
def _compare_lt_set(
left: AbstractSet[object],
right: AbstractSet[object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> list[str]:
return _set_one_sided_diff("left", left, right, highlighter)
) -> Iterator[str]:
if left == right:
yield "Both sets are equal"
else:
yield from _set_one_sided_diff("left", left, right, highlighter)


SetComparisonFunction: TypeAlias = Callable[
[AbstractSet[object], AbstractSet[object], _HighlightFunc, int],
list[str],
Iterable[str],
]

SET_COMPARISON_FUNCTIONS: dict[str, SetComparisonFunction] = {
Expand Down
64 changes: 40 additions & 24 deletions src/_pytest/assertion/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

from collections.abc import Callable
from collections.abc import Iterator
from collections.abc import Sequence
from typing import Literal
from unicodedata import normalize
Expand Down Expand Up @@ -139,8 +140,19 @@ def assertrepr_compare(
verbose: int,
highlighter: _HighlightFunc,
assertion_text_diff_style: _AssertionTextDiffStyle,
) -> list[str] | None:
"""Return specialised explanations for some operators/operands."""
) -> Iterator[str]:
"""Yield specialised explanations for some operators/operands.

The first line yielded is always the summary (``left op right``);
subsequent lines are the per-line explanation. Yields nothing when no
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

"are the per-line explanation" -> "is the detailed explanation".

I don't think "per-line" is entirely accurate.

specialised explanation applies, which lets consumers map an empty
iterator to "no explanation" without materialising anything.

The iterator is lazy on purpose: a streaming consumer (e.g. the
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should drop the "e.g." part since it doesn't hold as of this commit.

truncator in ``pytest_assertrepr_compare``) can stop pulling lines as
soon as it has enough to show, so an enormous diff doesn't have to be
built in full just to be thrown away.
"""
# Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier.
# See issue #3246.
use_ascii = (
Expand All @@ -164,37 +176,41 @@ def assertrepr_compare(

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

explanation = None
summary_yielded = False
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Would move this above the for line in source, since it's not set or used in this block of code.

try:
if op == "==":
explanation = _compare_eq_any(
source: Iterator[str] = _compare_eq_any(
left,
right,
highlighter,
verbose,
assertion_text_diff_style,
)
elif op == "not in":
if istext(left) and istext(right):
explanation = list(_notin_text(left, right, verbose))
elif op in {"!=", ">=", "<=", ">", "<"}:
if isset(left) and isset(right):
explanation = SET_COMPARISON_FUNCTIONS[op](
left, right, highlighter, verbose
)

elif op == "not in" and istext(left) and istext(right):
source = _notin_text(left, right, verbose)
elif op in {"!=", ">=", "<=", ">", "<"} and isset(left) and isset(right):
source = iter(
SET_COMPARISON_FUNCTIONS[op](left, right, highlighter, verbose)
)
else:
source = iter(())

for line in source:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I suggest a comment like this: Only yield summary if there is a detailed explanation. Make sure there's a separating empty line after the summary.

Usually I avoid comments which just describe what the code does, but in this case it took me a bit of effort to understand, so I think a comment would be helpful.

if not summary_yielded:
yield summary
if line != "":
yield ""
summary_yielded = True
yield line
except outcomes.Exit:
raise
except Exception:
repr_crash = _pytest._code.ExceptionInfo.from_current()._getreprcrash()
explanation = [
f"(pytest_assertion plugin: representation of details failed: {repr_crash}.",
" Probably an object has a faulty __repr__.)",
]

if not explanation:
return None

if explanation[0] != "":
explanation = ["", *explanation]
return [summary, *explanation]
if not summary_yielded:
yield summary
yield ""
summary_yielded = True
yield (
f"(pytest_assertion plugin: representation of details failed: {repr_crash}."
)
yield " Probably an object has a faulty __repr__.)"
4 changes: 3 additions & 1 deletion testing/test_assertion.py
Original file line number Diff line number Diff line change
Expand Up @@ -1043,7 +1043,9 @@ def __repr__(self):
assert expl is not None
assert expl[0].startswith("{} == <[ValueError")
assert "raised in repr" in expl[0]
assert expl[2:] == [
# Streaming explanation: any per-line output produced before the
# bad repr is preserved, then the failure notice is appended.
assert expl[-2:] == [
"(pytest_assertion plugin: representation of details failed:"
f" {__file__}:{A.__repr__.__code__.co_firstlineno + 1}: ValueError: 42.",
" Probably an object has a faulty __repr__.)",
Expand Down
Loading