Skip to content

[refactor] Make base assertion comparisons return an iterator instead of a list of string#14546

Open
Pierre-Sassoulas wants to merge 3 commits into
pytest-dev:mainfrom
Pierre-Sassoulas:consensual-iterators
Open

[refactor] Make base assertion comparisons return an iterator instead of a list of string#14546
Pierre-Sassoulas wants to merge 3 commits into
pytest-dev:mainfrom
Pierre-Sassoulas:consensual-iterators

Conversation

@Pierre-Sassoulas
Copy link
Copy Markdown
Member

The consensual refactor prior to #14523 to make the next review easier. No performance increase yet, because we call list on it in the end.

Following Ronny's review comment on pytest-dev#13762, switch the set comparison
helpers in ``_compare_set.py`` to return ``Iterator[str]`` so the
composition is direct: ``_set_one_sided_diff`` ``yield``s, and the
other helpers ``yield from`` it. This avoids the manual
``explanation = []; .append/.extend`` boilerplate.

The "equal sets" branch of ``_compare_gt_set`` / ``_compare_lt_set``
used to peek at the diff for emptiness; replace that with a direct
``left == right`` check so the generator form stays idiomatic.

``SET_COMPARISON_FUNCTIONS`` and ``_compare_eq_set`` now return
``Iterable[str]`` / ``Iterator[str]``; the consumers in
``_compare_eq_any`` materialise with ``list(...)``.
Drop the ``list(...)`` wraps around each per-type comparator call in
the match dispatch and ``yield from`` instead. ``_compare_eq_any``
becomes an ``Iterator[str]`` that yields nothing when no specialised
explanation applies (replaces the previous ``list[str] | None``
sentinel).

The two callers materialise:

* ``util.assertrepr_compare`` does
  ``list(_compare_eq_any(...))`` before its empty/summary check.
* ``_compare_eq_cls`` iterates the generator directly via
  ``for line in _compare_eq_any(...)``.

No behavior change yet — this is the stepping stone for letting the
truncator upstream consume the iterator lazily so huge diffs don't
materialise just to be thrown away.
Turn ``assertrepr_compare`` into a generator. The first line yielded is
the summary; subsequent lines are the explanation produced by
``_compare_eq_any``. Yields nothing when no specialised explanation
applies — the consumer maps an empty iterator to ``None``.

The ``pytest_assertrepr_compare`` hook impl in ``assertion/__init__``
materialises the iterator and returns ``list[str] | None`` so the
public hook contract is unchanged. A follow-up commit replaces the
``list(...)`` call with a streaming truncator so an enormous diff
doesn't have to be built in full just to be discarded.

Behaviour change: previously, if an exception was raised while
building the explanation (e.g. a faulty ``__repr__``), the partial
output was discarded and only the failure notice was returned. The
generator can't unyield lines it has already produced, so the new
form preserves the partial output and appends the failure notice
after it. This is arguably more useful — the reader sees what was
being compared at the point the comparison failed.

``test_list_bad_repr`` is updated to assert that the failure notice
appears at the end of the explanation instead of replacing the body.
@Pierre-Sassoulas Pierre-Sassoulas added the skip news used on prs to opt out of the changelog requirement label Jun 1, 2026
Copy link
Copy Markdown
Member

@bluetech bluetech left a comment

Choose a reason for hiding this comment

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

LGTM.

Regarding the behavior change on exception, I do think the previous behavior was a bit preferable, because I think showing a partial explanation and then the failed message can be confusing. But I think it's acceptable given the advantage.

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.

"""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.

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

explanation: list[str] | None = 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.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

skip news used on prs to opt out of the changelog requirement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants