Skip to content

Use streaming in all assertion comparisons consumers#14523

Open
Pierre-Sassoulas wants to merge 6 commits into
pytest-dev:mainfrom
Pierre-Sassoulas:stream-comparisons
Open

Use streaming in all assertion comparisons consumers#14523
Pierre-Sassoulas wants to merge 6 commits into
pytest-dev:mainfrom
Pierre-Sassoulas:stream-comparisons

Conversation

@Pierre-Sassoulas

@Pierre-Sassoulas Pierre-Sassoulas commented May 26, 2026

Copy link
Copy Markdown
Member

Follow-up to the streaming-comparison chain: #14521 (comparators → generators), #14546 (base comparisons return an iterator), #14587 (set comparison → Iterator[str]), #14588 (PrettyPrinter formats lazily, budget-cappable). Those moved the assertion machinery to generators and a lazy pretty-printer; this PR wires the truncation budget through the remaining consumers so a large diff is never fully built when truncation is going to clip it anyway.

The truncation footer no longer reports the hidden-line count ...Full output truncated (N lines hidden), use '-vv' to show becomes ...Full output truncated, use '-vv' to show. Counting the hidden lines means materialising the whole diff, which is costly. -vv (no truncation) stays exact, and anything within the budget is unchanged.

Benchmark

Small / common case (tiny inputs — the realistic failing assert)

case 9.1.0 this PR Δ
list [1,2,3] vs [1,2,4] 0.149 ms 0.153 ms +2.9%
tuple 3 elems 0.145 ms 0.154 ms +6.1%
set 3 elems 0.155 ms 0.113 ms -27.0%
dict 3 keys, 1 differ 0.178 ms 0.190 ms +7.0%
dict extra key 0.110 ms 0.120 ms +9.1%
str short 0.057 ms 0.061 ms +7.2%
str 5-line multiline 0.073 ms 0.075 ms +3.7%
list of 10 dicts 0.269 ms 0.201 ms -25.3%

Common asserts cost +3% to +9% (a few µs of per-chunk budget bookkeeping); set and list-of-dicts are faster (set-sort fast path + lazy chunked pformat). Absolute cost stays
0.06–0.27 ms.

N = 2000

comparison shape 9.1.0 this PR Δ speedup
dict == dict (values differ) 821,887 ms 0.76 ms -99.9999% ~1,077,000×
str == str (lines differ) 6,953 ms 0.37 ms -99.995% ~18,800×
list of 3 × 100k-char strings 216.7 ms 0.50 ms -99.77% ~433×
tuple == tuple 8.54 ms 0.18 ms -97.8% ~47×
list == list 8.70 ms 0.19 ms -97.8% ~46×
nested [{N}] == [{}] 17.1 ms 0.49 ms -97.1% ~35×
dict extra keys ({N}=={}) 28.7 ms 0.95 ms -96.7% ~30×
set == set 9.87 ms 0.42 ms -95.7% ~23×

N = 20000 (fast shapes; dict==dict values & str==str skipped — O(N²) on main)

comparison shape 9.1.0 this PR Δ speedup
tuple == tuple 84.0 ms 0.16 ms -99.81% ~517×
list == list 84.0 ms 0.16 ms -99.80% ~511×
nested [{N}] == [{}] 203.7 ms 4.48 ms -97.8% ~46×
dict extra keys ({N}=={}) 352.7 ms 9.32 ms -97.4% ~38×
set == set 97.9 ms 2.71 ms -96.9% ~36×

list/tuple stay flat (~0.16 ms) — fully bounded. set (sort O(N log N)), dict extra (nsmallest O(N) scan) and nested still scale with N — far below main, but not constant.
not in (needle not in huge_text) joins the bounded shapes: ~2.8 s → ~3 ms for a 1M-char haystack now that its ndiff inputs are capped.

dict == dict is the extreme: near-identical value lines (1999: 1999, vs 1999: 2000,) trip difflib's intraline _fancy_replace, making the full path ~O(N²). This is
pre-existing
— on pytest 8.4.2, pytest -v on assert {i: i for i in range(500)} == {i: i + 1 for i in range(500)} takes 83 s; this PR brings it to ~1 ms.

Benchmark (branch-adaptive; tiers: small / n2000 / n20000)
"""Branch-adaptive benchmark of the assertion-repr pipeline.

Times the full format+truncate path of an assertion explanation for each
comparison shape, at -v (assertion verbosity 1 — the path that both
formats a diff and truncates it), default limits (8 lines / 640 chars),
CI detection off. Adapts to main's API (list + truncate_if_required) vs
the PR (iterator + materialize_with_truncation + pformat_cap).

Tiers: ``small`` (common case), ``n2000``, ``n20000`` (fast shapes only).
Run all, or pass a subset on the command line:

    python bench_assert.py                 # all tiers
    python bench_assert.py small n20000    # skip the slow n2000 tier
"""

import inspect
import os
import sys
import time


os.environ.pop("CI", None)
os.environ.pop("GITHUB_ACTIONS", None)
os.environ.pop("BUILD_NUMBER", None)

import _pytest.assertion.truncate as T  # noqa: E402
import _pytest.assertion.util as U  # noqa: E402
from _pytest.assertion.highlight import dummy_highlighter as H  # noqa: E402


_SIG = inspect.signature(U.assertrepr_compare)
HAS_CAP = "pformat_cap" in _SIG.parameters
HAS_MATERIALIZE = hasattr(T, "materialize_with_truncation")


class FakeConfig:
    def getini(self, name):
        return None  # defaults: 8 lines / 640 chars

    def get_verbosity(self, *a, **k):
        return 1  # -v


class FakeItem:
    config = FakeConfig()


def run(left, right):
    allk = dict(
        op="==",
        left=left,
        right=right,
        verbose=1,
        highlighter=H,
        assertion_text_diff_style=getattr(U, "ASSERTION_TEXT_DIFF_STYLE_NDIFF", "ndiff"),
    )
    if HAS_CAP:
        st, tl, tc = T._get_truncation_parameters(FakeConfig())
        allk["pformat_cap"] = (
            (tl + 3 if tl > 0 else None, tc + 70 if tc > 0 else None)
            if st
            else (None, None)
        )
    kw = {k: v for k, v in allk.items() if k in _SIG.parameters}
    src = U.assertrepr_compare(**kw)
    if HAS_MATERIALIZE:
        return T.materialize_with_truncation(src, FakeConfig())
    return T.truncate_if_required(list(src), FakeItem())


def bench(fn, n):
    return min(
        (lambda: (t := time.perf_counter(), fn(), time.perf_counter() - t)[2])()
        for _ in range(n)
    ) * 1000


def scaled(n):
    return {
        "list == list": (list(range(n)), list(range(1, n + 1))),
        "tuple == tuple": (tuple(range(n)), tuple(range(1, n + 1))),
        "set == set": (set(range(n)), set(range(1, n + 1))),
        "dict diff values": ({i: i for i in range(n)}, {i: i + 1 for i in range(n)}),
        "dict extra ({n}=={})": ({i: i for i in range(n)}, {}),
        "str == str (lines)": ("x\n" * n, "y\n" * n),
        "nested [{n}]==[{}]": ([{i: i for i in range(n)}], [{}]),
    }


# Tiers: name -> {label: (left, right, reps)}
TIERS = {
    "small": {
        "list [1,2,3] vs [1,2,4]": ([1, 2, 3], [1, 2, 4], 20000),
        "tuple 3 elems": ((1, 2, 3), (1, 2, 4), 20000),
        "set 3 elems": ({1, 2, 3}, {1, 2, 4}, 20000),
        "dict 3 keys, 1 differ": (
            {"a": 1, "b": 2, "c": 3},
            {"a": 1, "b": 9, "c": 3},
            20000,
        ),
        "dict extra key": ({"a": 1, "b": 2}, {"a": 1}, 20000),
        "str short": ("hello world", "hello there", 20000),
        "str multiline 5 lines": ("a\nb\nc\nd\ne", "a\nb\nX\nd\ne", 20000),
        "list of 10 dicts": (
            [{"k": i} for i in range(10)],
            [{"k": i + 1} for i in range(10)],
            20000,
        ),
    },
    "n2000": {
        **{k: (left, right, 3) for k, (left, right) in scaled(2000).items()},
        "list of 3 huge strs": (["x" * 100_000] * 3, ["y" * 100_000] * 3, 10),
    },
    # n20000: skip dict==dict (values) and str==str — O(N^2) on main.
    "n20000": {
        k: (left, right, 2)
        for k, (left, right) in scaled(20000).items()
        if k not in ("dict diff values", "str == str (lines)")
    },
}

selected = [a for a in sys.argv[1:] if a in TIERS] or list(TIERS)
api = "stream" if (HAS_CAP and HAS_MATERIALIZE) else "main"
print(f"# API: {api}")
for tier in selected:
    print(f"\n## {tier}")
    print(f"{'case':28}{'ms':>13}  out_lines")
    for label, (left, right, reps) in TIERS[tier].items():
        out = run(left, right)
        ms = bench(lambda left=left, right=right: run(left, right), reps)
        print(f"{label:28}{ms:13.4f}  {len(out)}")
Raw benchmark results:

On main:

~/git/pytest   main  python bench_assert.py

# API: main

## small
case                                   ms  out_lines
list [1,2,3] vs [1,2,4]            0.1476  10
tuple 3 elems                      0.1424  10
set 3 elems                        0.1490  10
dict 3 keys, 1 differ              0.1776  10
dict extra key                     0.1042  10
str short                          0.0545  4
str multiline 5 lines              0.0684  8
list of 10 dicts                   0.2851  10

## n2000
case                                   ms  out_lines
list == list                       9.3782  10
tuple == tuple                     9.1402  10
set == set                         9.2911  10
dict diff values              861306.3471  10
dict extra ({n}=={})              29.0514  10
str == str (lines)              7178.3846  10
nested [{n}]==[{}]                17.6646  5
list of 3 huge strs              221.5416  5

## n20000
case                                   ms  out_lines
list == list                     129.7909  10
tuple == tuple                    94.1027  10
set == set                       104.6896  10
dict extra ({n}=={})             324.3957  10
nested [{n}]==[{}]               179.6054  5

On branch

~/git/pytest   stream-comparisons  python bench_assert.py
# API: stream

## small
case                                   ms  out_lines
list [1,2,3] vs [1,2,4]            0.1598  10
tuple 3 elems                      0.1620  10
set 3 elems                        0.1193  10
dict 3 keys, 1 differ              0.1899  10
dict extra key                     0.1193  10
str short                          0.0608  4
str multiline 5 lines              0.0792  8
list of 10 dicts                   0.2011  10

## n2000
case                                   ms  out_lines
list == list                       0.1897  10
tuple == tuple                     0.1916  10
set == set                         0.4535  10
dict diff values                   0.7497  10
dict extra ({n}=={})               1.0092  10
str == str (lines)                 0.3799  10
nested [{n}]==[{}]                 0.5439  5
list of 3 huge strs                0.5796  5

## n20000
case                                   ms  out_lines
list == list                       0.2136  10
tuple == tuple                     0.3341  10
set == set                         3.5187  10
dict extra ({n}=={})              10.9883  10
nested [{n}]==[{}]                 5.0259  5

@Pierre-Sassoulas Pierre-Sassoulas added type: performance performance or memory problem/improvement type: refactoring internal improvements to the code labels May 26, 2026
@Pierre-Sassoulas
Pierre-Sassoulas marked this pull request as draft May 26, 2026 10:17
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request May 26, 2026
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided (automation) changelog entry is part of PR label May 26, 2026
@Pierre-Sassoulas
Pierre-Sassoulas marked this pull request as ready for review May 26, 2026 11:39

@nicoddemus nicoddemus left a comment

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.

Great work!

@Pierre-Sassoulas

Copy link
Copy Markdown
Member Author

Thank you ! Do you have an opinion about the next step ? (3 options in the PR description)

@bluetech bluetech left a comment

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.

See my last comment, I think it might affect the plan, so I'll wait before reviewing the rest.

Comment thread src/_pytest/assertion/util.py Outdated
Comment thread src/_pytest/assertion/util.py Outdated
Comment thread src/_pytest/assertion/__init__.py Outdated
Comment on lines +220 to +228
) -> list[str] | None:
) -> Iterator[str]:

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.

Unfortunately we can't change the return type here, it is stable API (see hookspec.py).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Maybe we can truncate in util.assertrepr_compare and still get the performance improvements without modifying the return type of the stable API ?

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 haven't looked at the truncation part, but if you can avoid the API break I'd be happy to take a look.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

So I moved the truncation to keep the performance without touching the API, but had to hack truncation a little.

First, we have to truncate twice because plugin maintainer expect the truncation to be handled where it was before I suppose.

Second, if we choose to drop the detail in the truncation message ("x lines hidden") then we can also drop the hacky check to see if there's the truncation header in the string for the second truncation. (I would favor that, small price to pay, imo), but right now I don't see an elegant way to do that as we have multiple way to truncate and it's not idempotent.

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.

if we choose to drop the detail in the truncation message ("x lines hidden")

I'm OK with dropping that detail; it is a small price to pay comparing to the benefits.

Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request May 27, 2026
Addresses review feedback on PR pytest-dev#14523:
* drop the redundant ``: Iterator[str]`` annotation on ``source`` —
  every branch already produces an ``Iterator[str]``.
* return ``Iterator[str]`` from ``SetComparisonFunction`` instead of
  ``Iterable[str]`` so the call site no longer needs ``iter(...)``;
  the ``!=`` branch is promoted from a list-returning lambda to a
  named generator so the new contract holds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request May 28, 2026
Adds two regression tests to close the patch-coverage gaps in
``callbinrepr`` reported by codecov on PR pytest-dev#14523:
* a plugin returning a truthy-but-empty iterator (``iter([])``) to
  exercise the second ``if not new_expl: continue`` after
  ``materialize_with_truncation``.
* a ``--assert=plain`` run to exercise the false branch of the
  ``assertmode == "rewrite"`` guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request May 28, 2026
When every ``pytest_assertrepr_compare`` impl returns ``None`` (e.g.
``assert 1 == 2`` — no specialised comparator applies), the dispatcher
exhausts ``hook_result``, exits the loop normally, and returns
``None``. The previously-uncovered ``continue → loop exit`` arc on
the first ``if not new_expl: continue`` line was the last patch
coverage gap on PR pytest-dev#14523.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Pierre-Sassoulas
Pierre-Sassoulas requested a review from bluetech May 28, 2026 06:19
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request May 30, 2026
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request May 30, 2026
Addresses review feedback on PR pytest-dev#14523:
* drop the redundant ``: Iterator[str]`` annotation on ``source`` —
  every branch already produces an ``Iterator[str]``.
* return ``Iterator[str]`` from ``SetComparisonFunction`` instead of
  ``Iterable[str]`` so the call site no longer needs ``iter(...)``;
  the ``!=`` branch is promoted from a list-returning lambda to a
  named generator so the new contract holds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request May 30, 2026
Adds two regression tests to close the patch-coverage gaps in
``callbinrepr`` reported by codecov on PR pytest-dev#14523:
* a plugin returning a truthy-but-empty iterator (``iter([])``) to
  exercise the second ``if not new_expl: continue`` after
  ``materialize_with_truncation``.
* a ``--assert=plain`` run to exercise the false branch of the
  ``assertmode == "rewrite"`` guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request May 30, 2026
When every ``pytest_assertrepr_compare`` impl returns ``None`` (e.g.
``assert 1 == 2`` — no specialised comparator applies), the dispatcher
exhausts ``hook_result``, exits the loop normally, and returns
``None``. The previously-uncovered ``continue → loop exit`` arc on
the first ``if not new_expl: continue`` line was the last patch
coverage gap on PR pytest-dev#14523.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Pierre-Sassoulas

Copy link
Copy Markdown
Member Author

I removed the number of removed line in the message. Benchmark on pathological case with very big set show big improvement, but I benchmarked on pylint test suite for a realistic perf change:

round1-upstream: 118.29s
round1-HEAD : 115.39s
round2-upstream: 114.87s
round2-HEAD : 106.43s
round3-upstream: 116.51s
round3-HEAD : 118.69s
round4-upstream: 114.14s
round4-HEAD : 116.28s

Look like it's within noise. pylint does not compare big data structure (the bigger checks are probably the functional test output ~=80 lines).

Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request Jun 13, 2026
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request Jun 13, 2026
Addresses review feedback on PR pytest-dev#14523:
* drop the redundant ``: Iterator[str]`` annotation on ``source`` —
  every branch already produces an ``Iterator[str]``.
* return ``Iterator[str]`` from ``SetComparisonFunction`` instead of
  ``Iterable[str]`` so the call site no longer needs ``iter(...)``;
  the ``!=`` branch is promoted from a list-returning lambda to a
  named generator so the new contract holds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request Jun 13, 2026
Adds two regression tests to close the patch-coverage gaps in
``callbinrepr`` reported by codecov on PR pytest-dev#14523:
* a plugin returning a truthy-but-empty iterator (``iter([])``) to
  exercise the second ``if not new_expl: continue`` after
  ``materialize_with_truncation``.
* a ``--assert=plain`` run to exercise the false branch of the
  ``assertmode == "rewrite"`` guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request Jun 13, 2026
When every ``pytest_assertrepr_compare`` impl returns ``None`` (e.g.
``assert 1 == 2`` — no specialised comparator applies), the dispatcher
exhausts ``hook_result``, exits the loop normally, and returns
``None``. The previously-uncovered ``continue → loop exit`` arc on
the first ``if not new_expl: continue`` line was the last patch
coverage gap on PR pytest-dev#14523.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Pierre-Sassoulas
Pierre-Sassoulas marked this pull request as draft June 13, 2026 13:27
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request Jun 13, 2026
Addresses review feedback on PR pytest-dev#14523:
* drop the redundant ``: Iterator[str]`` annotation on ``source`` —
  every branch already produces an ``Iterator[str]``.
* return ``Iterator[str]`` from ``SetComparisonFunction`` instead of
  ``Iterable[str]`` so the call site no longer needs ``iter(...)``;
  the ``!=`` branch is promoted from a list-returning lambda to a
  named generator so the new contract holds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request Jun 13, 2026
…4523)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pierre-Sassoulas added a commit to Pierre-Sassoulas/pytest that referenced this pull request Jun 22, 2026
…4523)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Pierre-Sassoulas
Pierre-Sassoulas force-pushed the stream-comparisons branch 6 times, most recently from e1691ca to 15cc828 Compare June 25, 2026 15:57
@Pierre-Sassoulas
Pierre-Sassoulas force-pushed the stream-comparisons branch 9 times, most recently from c3c0a4a to 04ac28a Compare June 28, 2026 14:08
@Pierre-Sassoulas
Pierre-Sassoulas marked this pull request as ready for review June 28, 2026 14:31
@Pierre-Sassoulas

Copy link
Copy Markdown
Member Author

@bluetech @nicoddemus this is ready to review ! I could separate the first commit into its own MR, but I don't think it's worth it.

…ters

The assertion-repr hook only has the Config, not the Item, so accept a
Config directly; the sole caller (truncate_if_required) passes item.config.
Pure refactor, no behaviour change — prepares for deriving the truncation
budget in the hook.

@bluetech bluetech left a comment

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.

Thanks!

The code mostly looks good to me.

Most of my comments are about the comments, which are a bit too verbose in some cases. Sorry for being pedantic on this, but in some cases conciseness is a virtue :)

Comment thread src/_pytest/assertion/_compare_mapping.py Outdated
Comment thread testing/test_assertion.py Outdated
Comment thread testing/test_assertion.py Outdated
Comment on lines +953 to +954
# "N more items" subdict is not pretty-printed in full (that work
# would be thrown away by truncation); only the smallest budget

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.

Do we not have tests for truncation already? IIUC this commit does not change behavior really so I'm surprised new tests are needed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'm not sure but we didn't have a test about not materializing the whole list. I tried to cover lines that weren't covered and might have gone too far.

Comment thread testing/test_assertion.py Outdated
Comment thread testing/test_assertion.py Outdated
assert len(uncapped) > 8

def test_mapping_extra_items_formatting_work_is_bounded(self) -> None:
# Non-regression guard: capping the *output* to N lines is not

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.

This test is cool but maybe a bit excessive 🤔 We don't usually test for O(N) stuff, although in principle it's nice to have so I'm not against it.

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.

BTW I maybe mistaken but I think this test is added in a commit, then removed in a commit, then added again, maybe some rebase accident.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I tried to clean up the history then rebase (merged all the comment simplification with pre-existing commit and resolved the review comment linked to them)

).splitlines()
# Highlight each ndiff line individually so the streaming truncator can
# stop pulling from ``difflib.ndiff`` once its budget is full; the diff
# lexer is line-oriented, so per-line highlighting is equivalent.

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.

If pygments/pygments#1043 is ever resolved, this won't be correct anymore. But I guess it's fine for now.

Comment thread src/_pytest/assertion/_typing.py Outdated
Comment thread src/_pytest/assertion/_typing.py Outdated
Comment on lines +31 to +37
def cap_chars(self, text: str) -> str:
"""Clip ``text`` to ``max_chars`` (``0`` leaves it unbounded)."""
return text[: self.max_chars] if self.max_chars > 0 else text

def cap_lines(self, lines: list[str]) -> list[str]:
"""Clip ``lines`` to ``max_lines`` (``0`` leaves it unbounded)."""
return lines[: self.max_lines] if self.max_lines > 0 else lines

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 think it's a bit strange for "truncation budget" (which is a piece of data) to do actual truncation. Maybe a standalone function?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fair enough

Comment thread testing/test_assertion.py Outdated
Comment thread testing/test_assertion.py Outdated
Pierre-Sassoulas and others added 5 commits July 24, 2026 19:01
A dict comparison with many keys only on one side ("Left/Right contains
N more items") pretty-printed the whole extra subdict even when the
explanation was about to be truncated, spending O(N) formatting lines
that are immediately discarded.

Thread the truncation line budget into ``_compare_eq_mapping``: past the
budget, emit only the smallest ``max_lines`` keys (one per line, via the
same safe sort ``pprint`` uses) instead of formatting the full subdict.
The item count stays in the header and the truncation footer is
unchanged, so no information the user would have seen is lost.

``assert {i: i for i in range(2000)} == {}`` drops from ~18 ms to ~1 ms
for the mapping portion of the explanation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The assertion-repr hook returns a lazy iterator; feed it through a new
``materialize_with_truncation`` that pulls only until the truncation
budget is reached, instead of building the whole explanation as a list
and truncating it afterwards.

Because the diff is no longer materialised in full, the exact hidden-line
count can no longer be computed: the truncation footer drops it
("...Full output truncated (N lines hidden)..." becomes "...Full output
truncated..."). Docs and the approx test are updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Widen the mapping ``extra_items_max_lines`` budget into a per-side
``TruncationBudget`` of ``(max_lines, max_chars)`` and thread it through
every formatting path: iterable/mapping ``pformat`` is capped before
``ndiff``, the text (``ndiff``) diff caps its inputs, and ``not in``
reuses the same cap. When truncation will clip the output anyway, the
helpers no longer spend O(N) formatting lines and chars that are about
to be dropped.

A truncated diff now reflects a local alignment of the bounded prefix;
``-vv`` (no truncation) still produces the exact full diff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add tests for ``materialize_with_truncation`` (lazy stop, sized vs
iterator input, idempotence) and the budget caps on the text paths,
plus deterministic, timing-free non-regression guards: the explanation
stream is not over-consumed, the pulled-line count is independent of
input size, and the formatting work behind a 10-line display stays
bounded as the input grows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The callequal/callop helpers run pytest_assertrepr_compare with
truncation disabled, so the branch that derives a TruncationBudget from
the truncation limits and feeds it to the comparison helpers went
untested. Extend mock_config with truncation limits and a
has_terminalreporter flag, then add TestAssertReprCompareDispatcher
covering: both limits (capped formatting work), each partial-budget arm
(only lines / only chars), and the no-terminalreporter plaintext
fallback (pytest-dev#14377), which now asserts the terminal writer is never
reached.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:chronographer:provided (automation) changelog entry is part of PR type: performance performance or memory problem/improvement type: refactoring internal improvements to the code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants