This commit is contained in:
2026-08-02 18:57:40 +02:00
parent 5e5ab8681a
commit 6fa59321c0
5759 changed files with 712133 additions and 66 deletions
@@ -0,0 +1,236 @@
# mypy: allow-untyped-defs
"""Support for presenting detailed information in failing assertions."""
from __future__ import annotations
from collections.abc import Generator
import sys
from typing import Any
from typing import Protocol
from typing import TYPE_CHECKING
from _pytest.assertion import rewrite
from _pytest.assertion import truncate
from _pytest.assertion import util
from _pytest.assertion.rewrite import assertstate_key
from _pytest.config import Config
from _pytest.config import hookimpl
from _pytest.config.argparsing import Parser
from _pytest.nodes import Item
if TYPE_CHECKING:
from _pytest.main import Session
def pytest_addoption(parser: Parser) -> None:
group = parser.getgroup("debugconfig")
group.addoption(
"--assert",
action="store",
dest="assertmode",
choices=("rewrite", "plain"),
default="rewrite",
metavar="MODE",
help=(
"Control assertion debugging tools.\n"
"'plain' performs no assertion debugging.\n"
"'rewrite' (the default) rewrites assert statements in test modules"
" on import to provide assert expression information."
),
)
parser.addini(
"enable_assertion_pass_hook",
type="bool",
default=False,
help="Enables the pytest_assertion_pass hook. "
"Make sure to delete any previously generated pyc cache files.",
)
parser.addini(
"truncation_limit_lines",
default=None,
help="Set threshold of LINES after which truncation will take effect",
)
parser.addini(
"truncation_limit_chars",
default=None,
help=("Set threshold of CHARS after which truncation will take effect"),
)
parser.addini(
"assertion_text_diff_style",
default=util.ASSERTION_TEXT_DIFF_STYLE_NDIFF,
help=(
"Choose how pytest renders diffs for string equality assertions: "
f"{util.ASSERTION_TEXT_DIFF_STYLE_NDIFF} or "
f"{util.ASSERTION_TEXT_DIFF_STYLE_BLOCK}"
),
)
Config._add_verbosity_ini(
parser,
Config.VERBOSITY_ASSERTIONS,
help=(
"Specify a verbosity level for assertions, overriding the main level. "
"Higher levels will provide more detailed explanation when an assertion fails."
),
)
def pytest_configure(config: Config) -> None:
util.validate_assertion_text_diff_style(config)
def register_assert_rewrite(*names: str) -> None:
"""Register one or more module names to be rewritten on import.
This function will make sure that this module or all modules inside
the package will get their assert statements rewritten.
Thus you should make sure to call this before the module is
actually imported, usually in your __init__.py if you are a plugin
using a package.
:param names: The module names to register.
"""
for name in names:
if not isinstance(name, str):
msg = "expected module names as *args, got {0} instead" # type: ignore[unreachable]
raise TypeError(msg.format(repr(names)))
rewrite_hook: RewriteHook
for hook in sys.meta_path:
if isinstance(hook, rewrite.AssertionRewritingHook):
rewrite_hook = hook
break
else:
rewrite_hook = DummyRewriteHook()
rewrite_hook.mark_rewrite(*names)
class RewriteHook(Protocol):
def mark_rewrite(self, *names: str) -> None: ...
class DummyRewriteHook:
"""A no-op import hook for when rewriting is disabled."""
def mark_rewrite(self, *names: str) -> None:
pass
class AssertionState:
"""State for the assertion plugin."""
def __init__(self, config: Config, mode) -> None:
self.mode = mode
self.trace = config.trace.root.get("assertion")
self.hook: rewrite.AssertionRewritingHook | None = None
def install_importhook(config: Config) -> rewrite.AssertionRewritingHook:
"""Try to install the rewrite hook, raise SystemError if it fails."""
config.stash[assertstate_key] = AssertionState(config, "rewrite")
config.stash[assertstate_key].hook = hook = rewrite.AssertionRewritingHook(config)
sys.meta_path.insert(0, hook)
config.stash[assertstate_key].trace("installed rewrite import hook")
def undo() -> None:
hook = config.stash[assertstate_key].hook
if hook is not None and hook in sys.meta_path:
sys.meta_path.remove(hook)
config.add_cleanup(undo)
return hook
def pytest_collection(session: Session) -> None:
# This hook is only called when test modules are collected
# so for example not in the managing process of pytest-xdist
# (which does not collect test modules).
assertstate = session.config.stash.get(assertstate_key, None)
if assertstate:
if assertstate.hook is not None:
assertstate.hook.set_session(session)
@hookimpl(wrapper=True, tryfirst=True)
def pytest_runtest_protocol(item: Item) -> Generator[None, object, object]:
"""Setup the pytest_assertrepr_compare and pytest_assertion_pass hooks.
The rewrite module will use util._reprcompare if it exists to use custom
reporting via the pytest_assertrepr_compare hook. This sets up this custom
comparison for the test.
"""
ihook = item.ihook
def callbinrepr(op, left: object, right: object) -> str | None:
"""Call the pytest_assertrepr_compare hook and prepare the result.
This uses the first result from the hook and then ensures the
following:
* Overly verbose explanations are truncated unless configured otherwise
(eg. if running in verbose mode).
* Embedded newlines are escaped to help util.format_explanation()
later.
* If the rewrite mode is used embedded %-characters are replaced
to protect later % formatting.
The result can be formatted by util.format_explanation() for
pretty printing.
"""
hook_result = ihook.pytest_assertrepr_compare(
config=item.config, op=op, left=left, right=right
)
for new_expl in hook_result:
if new_expl:
new_expl = truncate.truncate_if_required(new_expl, item)
new_expl = [line.replace("\n", "\\n") for line in new_expl]
res = "\n~".join(new_expl)
if item.config.getvalue("assertmode") == "rewrite":
res = res.replace("%", "%%")
return res
return None
saved_assert_hooks = util._reprcompare, util._assertion_pass
util._reprcompare = callbinrepr
util._config = item.config
if ihook.pytest_assertion_pass.get_hookimpls():
def call_assertion_pass_hook(lineno: int, orig: str, expl: str) -> None:
ihook.pytest_assertion_pass(item=item, lineno=lineno, orig=orig, expl=expl)
util._assertion_pass = call_assertion_pass_hook
try:
return (yield)
finally:
util._reprcompare, util._assertion_pass = saved_assert_hooks
util._config = None
def pytest_sessionfinish(session: Session) -> None:
assertstate = session.config.stash.get(assertstate_key, None)
if assertstate:
if assertstate.hook is not None:
assertstate.hook.set_session(None)
def pytest_assertrepr_compare(
config: Config, op: str, left: Any, right: Any
) -> list[str] | None:
if config.pluginmanager.has_plugin("terminalreporter"):
highlighter = config.get_terminal_writer()._highlight
else:
# Keep it plaintext when not using terminalrepoterer (#14377).
highlighter = util.dummy_highlighter
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
@@ -0,0 +1,131 @@
from __future__ import annotations
from collections.abc import Iterator
import dataclasses
import pprint
from _pytest.assertion._compare_mapping import _compare_eq_mapping
from _pytest.assertion._compare_sequence import _compare_eq_iterable
from _pytest.assertion._compare_sequence import _compare_eq_sequence
from _pytest.assertion._compare_set import _compare_eq_set
from _pytest.assertion._guards import has_default_eq
from _pytest.assertion._guards import isattrs
from _pytest.assertion._guards import isdatacls
from _pytest.assertion._guards import isiterable
from _pytest.assertion._guards import ismapping
from _pytest.assertion._guards import isnamedtuple
from _pytest.assertion._guards import issequence
from _pytest.assertion._guards import isset
from _pytest.assertion._guards import istext
from _pytest.assertion._typing import _AssertionTextDiffStyle
from _pytest.assertion._typing import _HighlightFunc
from _pytest.assertion.compare_text import _compare_eq_text
def _compare_eq_any(
left: object,
right: object,
highlighter: _HighlightFunc,
verbose: int,
assertion_text_diff_style: _AssertionTextDiffStyle,
) -> 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):
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):
yield from right._repr_compare(left)
elif isinstance(left, ApproxBase):
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.
yield from _compare_eq_cls(
left,
right,
highlighter,
verbose,
assertion_text_diff_style,
)
elif issequence(left) and issequence(right):
yield from _compare_eq_sequence(left, right, highlighter, verbose)
elif isset(left) and isset(right):
yield from _compare_eq_set(left, right, highlighter, verbose)
elif ismapping(left) and ismapping(right):
yield from _compare_eq_mapping(left, right, highlighter, verbose)
if isiterable(left) and isiterable(right):
yield from _compare_eq_iterable(left, right, highlighter, verbose)
def _compare_eq_cls(
left: object,
right: object,
highlighter: _HighlightFunc,
verbose: int,
assertion_text_diff_style: _AssertionTextDiffStyle,
) -> Iterator[str]:
if not has_default_eq(left):
return
if isdatacls(left):
all_fields = dataclasses.fields(left)
fields_to_check = [info.name for info in all_fields if info.compare]
elif isattrs(left):
all_fields = left.__attrs_attrs__ # type: ignore[attr-defined]
fields_to_check = [field.name for field in all_fields if getattr(field, "eq")]
elif isnamedtuple(left):
fields_to_check = left._fields # type: ignore[attr-defined]
else:
assert False
indent = " "
same = []
diff = []
for field in fields_to_check:
if getattr(left, field) == getattr(right, field):
same.append(field)
else:
diff.append(field)
if same or diff:
yield ""
if same and verbose < 2:
yield f"Omitting {len(same)} identical items, use -vv to show"
elif same:
yield "Matching attributes:"
yield from highlighter(pprint.pformat(same)).splitlines()
if diff:
yield "Differing attributes:"
yield from highlighter(pprint.pformat(diff)).splitlines()
for field in diff:
field_left = getattr(left, field)
field_right = getattr(right, field)
yield ""
yield f"Drill down into differing attribute {field}:"
yield f"{indent}{field}: {highlighter(repr(field_left))} != {highlighter(repr(field_right))}"
for line in _compare_eq_any(
field_left,
field_right,
highlighter,
verbose,
assertion_text_diff_style,
):
yield indent + line
@@ -0,0 +1,48 @@
from __future__ import annotations
from collections.abc import Iterator
from collections.abc import Mapping
import pprint
from _pytest._io.saferepr import saferepr
from _pytest.assertion._typing import _HighlightFunc
def _compare_eq_mapping(
left: Mapping[object, object],
right: Mapping[object, object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> Iterator[str]:
set_left = set(left)
set_right = set(right)
common = set_left.intersection(set_right)
same = {k: left[k] for k in common if left[k] == right[k]}
if same and verbose < 2:
yield f"Omitting {len(same)} identical items, use -vv to show"
elif same:
yield "Common items:"
yield from highlighter(pprint.pformat(same)).splitlines()
diff = {k for k in common if left[k] != right[k]}
if diff:
yield "Differing items:"
for k in diff:
yield (
highlighter(saferepr({k: left[k]}))
+ " != "
+ highlighter(saferepr({k: right[k]}))
)
extra_left = set_left - set_right
len_extra_left = len(extra_left)
if len_extra_left:
yield f"Left contains {len_extra_left} more item{'' if len_extra_left == 1 else 's'}:"
yield from highlighter(
pprint.pformat({k: left[k] for k in extra_left})
).splitlines()
extra_right = set_right - set_left
len_extra_right = len(extra_right)
if len_extra_right:
yield f"Right contains {len_extra_right} more item{'' if len_extra_right == 1 else 's'}:"
yield from highlighter(
pprint.pformat({k: right[k] for k in extra_right})
).splitlines()
@@ -0,0 +1,90 @@
from __future__ import annotations
from collections.abc import Iterable
from collections.abc import Iterator
from collections.abc import Sequence
from _pytest._io.pprint import PrettyPrinter
from _pytest._io.saferepr import saferepr
from _pytest.assertion._typing import _HighlightFunc
from _pytest.compat import running_on_ci
def _compare_eq_iterable(
left: Iterable[object],
right: Iterable[object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> Iterator[str]:
if verbose <= 0 and not running_on_ci():
yield "Use -v to get more diff"
return
# dynamic import to speedup pytest
import difflib
left_formatting = PrettyPrinter().pformat(left).splitlines()
right_formatting = PrettyPrinter().pformat(right).splitlines()
yield ""
yield "Full diff:"
# "right" is the expected base against which we compare "left",
# see https://github.com/pytest-dev/pytest/issues/3333
yield from highlighter(
"\n".join(
line.rstrip() for line in difflib.ndiff(right_formatting, left_formatting)
),
lexer="diff",
).splitlines()
def _compare_eq_sequence(
left: Sequence[object],
right: Sequence[object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> Iterator[str]:
comparing_bytes = isinstance(left, bytes) and isinstance(right, bytes)
len_left = len(left)
len_right = len(right)
for i in range(min(len_left, len_right)):
if left[i] != right[i]:
if comparing_bytes:
# when comparing bytes, we want to see their ascii representation
# instead of their numeric values (#5260)
# using a slice gives us the ascii representation:
# >>> s = b'foo'
# >>> s[0]
# 102
# >>> s[0:1]
# b'f'
left_value: object = left[i : i + 1]
right_value: object = right[i : i + 1]
else:
left_value = left[i]
right_value = right[i]
yield (
f"At index {i} diff:"
f" {highlighter(repr(left_value))} != {highlighter(repr(right_value))}"
)
break
if comparing_bytes:
# when comparing bytes, it doesn't help to show the "sides contain one or more
# items" longer explanation, so skip it
return
len_diff = len_left - len_right
if len_diff:
if len_diff > 0:
dir_with_more = "Left"
extra = saferepr(left[len_right])
else:
len_diff = 0 - len_diff
dir_with_more = "Right"
extra = saferepr(right[len_left])
if len_diff == 1:
yield f"{dir_with_more} contains one more item: {highlighter(extra)}"
else:
yield f"{dir_with_more} contains {len_diff} more items, first extra item: {highlighter(extra)}"
@@ -0,0 +1,101 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Iterator
from collections.abc import Set as AbstractSet
from typing import TypeAlias
from _pytest._io.saferepr import saferepr
from _pytest.assertion._typing import _HighlightFunc
def _set_one_sided_diff(
posn: str,
set1: AbstractSet[object],
set2: AbstractSet[object],
highlighter: _HighlightFunc,
) -> Iterator[str]:
diff = set1 - set2
if diff:
yield f"Extra items in the {posn} set:"
for item in diff:
yield highlighter(saferepr(item))
def _compare_eq_set(
left: AbstractSet[object],
right: AbstractSet[object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> Iterator[str]:
yield from _set_one_sided_diff("left", left, right, highlighter)
yield from _set_one_sided_diff("right", right, left, highlighter)
def _compare_gte_set(
left: AbstractSet[object],
right: AbstractSet[object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> Iterator[str]:
yield from _set_one_sided_diff("right", right, left, highlighter)
def _compare_lte_set(
left: AbstractSet[object],
right: AbstractSet[object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> Iterator[str]:
yield from _set_one_sided_diff("left", left, right, highlighter)
def _compare_gt_set(
left: AbstractSet[object],
right: AbstractSet[object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> Iterator[str]:
if left == right:
yield "Both sets are equal"
else:
yield from _set_one_sided_diff("right", right, left, highlighter)
def _compare_lt_set(
left: AbstractSet[object],
right: AbstractSet[object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> 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],
Iterator[str],
]
def _both_sets_are_equal(
left: AbstractSet[object],
right: AbstractSet[object],
highlighter: _HighlightFunc,
verbose: int = 0,
) -> Iterator[str]:
yield "Both sets are equal"
SET_COMPARISON_FUNCTIONS: dict[str, SetComparisonFunction] = {
# == can't be done here without a prior refactor because there's an additional
# explanation for iterable in _compare_eq_any
# "==": _compare_eq_set,
"!=": _both_sets_are_equal,
">=": _compare_gte_set,
"<=": _compare_lte_set,
">": _compare_gt_set,
"<": _compare_lt_set,
}
@@ -0,0 +1,60 @@
from __future__ import annotations
import collections.abc
from collections.abc import Mapping
import dataclasses
from typing import TypeGuard
def issequence(x: object) -> TypeGuard[collections.abc.Sequence[object]]:
return isinstance(x, collections.abc.Sequence) and not isinstance(x, str)
def istext(x: object) -> TypeGuard[str]:
return isinstance(x, str)
def ismapping(x: object) -> TypeGuard[Mapping[object, object]]:
return isinstance(x, Mapping)
def isset(x: object) -> TypeGuard[set[object] | frozenset[object]]:
return isinstance(x, set | frozenset)
def isnamedtuple(obj: object) -> bool:
return isinstance(obj, tuple) and getattr(obj, "_fields", None) is not None
isdatacls = dataclasses.is_dataclass
def isattrs(obj: object) -> bool:
return getattr(obj, "__attrs_attrs__", None) is not None
def isiterable(obj: object) -> TypeGuard[collections.abc.Iterable[object]]:
try:
iter(obj) # type: ignore[call-overload]
return not istext(obj)
except Exception:
return False
def has_default_eq(obj: object) -> bool:
"""Check if an instance of an object contains the default eq
First, we check if the object's __eq__ attribute has __code__,
if so, we check the equally of the method code filename (__code__.co_filename)
to the default one generated by the dataclass and attr module
for dataclasses the default co_filename is <string>, for attrs class, the __eq__ should contain "attrs eq generated"
"""
# inspired from https://github.com/willmcgugan/rich/blob/07d51ffc1aee6f16bd2e5a25b4e82850fb9ed778/rich/pretty.py#L68
if hasattr(obj.__eq__, "__code__") and hasattr(obj.__eq__.__code__, "co_filename"):
code_filename = obj.__eq__.__code__.co_filename
if isattrs(obj):
return "attrs generated " in code_filename
return code_filename == "<string>" # data class
return True
@@ -0,0 +1,12 @@
from __future__ import annotations
from typing import Literal
from typing import Protocol
_AssertionTextDiffStyle = Literal["ndiff", "block"]
class _HighlightFunc(Protocol): # noqa: PYI046
def __call__(self, source: str, lexer: Literal["diff", "python"] = "python") -> str:
"""Apply highlighting to the given source."""
@@ -0,0 +1,104 @@
from __future__ import annotations
from collections.abc import Iterator
from _pytest._io.saferepr import saferepr
from _pytest.assertion._typing import _AssertionTextDiffStyle
from _pytest.assertion._typing import _HighlightFunc
from _pytest.assertion.highlight import dummy_highlighter
from _pytest.compat import assert_never
def _compare_eq_text(
left: str,
right: str,
highlighter: _HighlightFunc,
verbose: int,
assertion_text_diff_style: _AssertionTextDiffStyle,
) -> Iterator[str]:
match assertion_text_diff_style:
case "block":
yield from _diff_text_block(left, right)
case "ndiff":
yield from _diff_text(left, right, highlighter, verbose)
case unreachable:
assert_never(unreachable)
def _diff_text_block(left: str, right: str) -> Iterator[str]:
yield "Left:"
yield from _format_text_block_lines(left)
yield ""
yield "Right:"
yield from _format_text_block_lines(right)
def _format_text_block_lines(text: str) -> Iterator[str]:
for line in text.split("\n"):
yield f" {line}"
def _diff_text(
left: str, right: str, highlighter: _HighlightFunc, verbose: int = 0
) -> Iterator[str]:
"""Yield the explanation for the diff between text.
Unless --verbose is used this will skip leading and trailing
characters which are identical to keep the diff minimal.
"""
from difflib import ndiff
if verbose < 1:
i = 0 # just in case left or right has zero length
for i in range(min(len(left), len(right))):
if left[i] != right[i]:
break
if i > 42:
i -= 10 # Provide some context
yield f"Skipping {i} identical leading characters in diff, use -v to show"
left = left[i:]
right = right[i:]
if len(left) == len(right):
for i in range(len(left)):
if left[-i] != right[-i]:
break
if i > 42:
i -= 10 # Provide some context
yield (
f"Skipping {i} identical trailing "
"characters in diff, use -v to show"
)
left = left[:-i]
right = right[:-i]
keepends = True
if left.isspace() or right.isspace():
left = repr(str(left))
right = repr(str(right))
yield "Strings contain only whitespace, escaping them using repr()"
# "right" is the expected base against which we compare "left",
# see https://github.com/pytest-dev/pytest/issues/3333
yield from highlighter(
"\n".join(
line.strip("\n")
for line in ndiff(right.splitlines(keepends), left.splitlines(keepends))
),
lexer="diff",
).splitlines()
def _notin_text(term: str, text: str, verbose: int = 0) -> Iterator[str]:
index = text.find(term)
head = text[:index]
tail = text[index + len(term) :]
correct_text = head + tail
diff = _diff_text(text, correct_text, dummy_highlighter, verbose)
yield f"{saferepr(term, maxsize=42)} is contained here:"
for line in diff:
if line.startswith("Skipping"):
continue
if line.startswith("- "):
continue
if line.startswith("+ "):
yield " " + line[2:]
else:
yield line
@@ -0,0 +1,11 @@
from __future__ import annotations
from typing import Literal
def dummy_highlighter(source: str, lexer: Literal["diff", "python"] = "python") -> str:
"""Dummy highlighter that returns the text unprocessed.
Needed for _notin_text, as the diff gets post-processed to only show the "+" part.
"""
return source
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,128 @@
"""Utilities for truncating assertion output.
Current default behaviour is to truncate assertion explanations at
terminal lines, unless running with an assertions verbosity level of at least 2 or running on CI.
"""
from __future__ import annotations
from _pytest.compat import running_on_ci
from _pytest.config import Config
from _pytest.nodes import Item
DEFAULT_MAX_LINES = 8
DEFAULT_MAX_CHARS = DEFAULT_MAX_LINES * 80
USAGE_MSG = "use '-vv' to show"
def truncate_if_required(explanation: list[str], item: Item) -> list[str]:
"""Truncate this assertion explanation if the given test item is eligible."""
should_truncate, max_lines, max_chars = _get_truncation_parameters(item)
if should_truncate:
return _truncate_explanation(
explanation,
max_lines=max_lines,
max_chars=max_chars,
)
return explanation
def _get_truncation_parameters(item: Item) -> tuple[bool, int, int]:
"""Return the truncation parameters related to the given item, as (should truncate, max lines, max chars)."""
# We do not need to truncate if one of conditions is met:
# 1. Verbosity level is 2 or more;
# 2. Test is being run in CI environment;
# 3. Both truncation_limit_lines and truncation_limit_chars
# .ini parameters are set to 0 explicitly.
max_lines = item.config.getini("truncation_limit_lines")
max_lines = int(max_lines if max_lines is not None else DEFAULT_MAX_LINES)
max_chars = item.config.getini("truncation_limit_chars")
max_chars = int(max_chars if max_chars is not None else DEFAULT_MAX_CHARS)
verbose = item.config.get_verbosity(Config.VERBOSITY_ASSERTIONS)
should_truncate = verbose < 2 and not running_on_ci()
should_truncate = should_truncate and (max_lines > 0 or max_chars > 0)
return should_truncate, max_lines, max_chars
def _truncate_explanation(
input_lines: list[str],
max_lines: int,
max_chars: int,
) -> list[str]:
"""Truncate given list of strings that makes up the assertion explanation.
Truncates to either max_lines, or max_chars - whichever the input reaches
first, taking the truncation explanation into account. The remaining lines
will be replaced by a usage message.
If max_chars=0, no truncation by character count is performed.
If max_lines=0, no truncation by line count is performed.
When this function is launched we know max_lines > 0 or max_chars > 0
because _get_truncation_parameters was called first.
"""
# The length of the truncation explanation depends on the number of lines
# removed but is at least 68 characters:
# The real value is
# 64 (for the base message:
# '...\n...Full output truncated (1 line hidden), use '-vv' to show")'
# )
# + 1 (for plural)
# + int(math.log10(len(input_lines) - max_lines)) (number of hidden line, at least 1)
# + 3 for the '...' added to the truncated line
# But if there's more than 100 lines it's very likely that we're going to
# truncate, so we don't need the exact value using log10.
tolerable_max_chars = (
max_chars + 70 # 64 + 1 (for plural) + 2 (for '99') + 3 for '...'
)
# The truncation explanation add two lines to the output
if max_lines == 0 or len(input_lines) <= max_lines + 2:
if max_chars == 0 or sum(len(s) for s in input_lines) <= tolerable_max_chars:
return input_lines
truncated_explanation = input_lines
else:
# Truncate first to max_lines, and then truncate to max_chars if necessary
truncated_explanation = input_lines[:max_lines]
# We reevaluate the need to truncate chars following removal of some lines
need_to_truncate_char = (
max_chars > 0
and sum(len(e) for e in truncated_explanation) > tolerable_max_chars
)
if need_to_truncate_char:
truncated_explanation = _truncate_by_char_count(
truncated_explanation, max_chars
)
# Something was truncated, adding '...' at the end to show that
truncated_explanation[-1] += "..."
truncated_line_count = (
len(input_lines) - len(truncated_explanation) + int(need_to_truncate_char)
)
return [
*truncated_explanation,
"",
f"...Full output truncated ({truncated_line_count} line"
f"{'' if truncated_line_count == 1 else 's'} hidden), {USAGE_MSG}",
]
def _truncate_by_char_count(input_lines: list[str], max_chars: int) -> list[str]:
# Find point at which input length exceeds total allowed length
iterated_char_count = 0
for iterated_index, input_line in enumerate(input_lines):
if iterated_char_count + len(input_line) > max_chars:
break
iterated_char_count += len(input_line)
# Create truncated explanation with modified final line
truncated_result = input_lines[:iterated_index]
final_line = input_lines[iterated_index]
if final_line:
final_line_truncate_point = max_chars - iterated_char_count
final_line = final_line[:final_line_truncate_point]
truncated_result.append(final_line)
return truncated_result
@@ -0,0 +1,215 @@
# mypy: allow-untyped-defs
"""Utilities for assertion debugging."""
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
from _pytest import outcomes
import _pytest._code
from _pytest._io.saferepr import saferepr
from _pytest._io.saferepr import saferepr_unlimited
from _pytest.assertion._compare_any import _compare_eq_any
from _pytest.assertion._compare_set import SET_COMPARISON_FUNCTIONS
from _pytest.assertion._guards import isset
from _pytest.assertion._guards import istext
from _pytest.assertion._typing import _AssertionTextDiffStyle
from _pytest.assertion._typing import _HighlightFunc
from _pytest.assertion.compare_text import _notin_text
from _pytest.assertion.highlight import dummy_highlighter as dummy_highlighter
from _pytest.config import Config
from _pytest.config import UsageError
# The _reprcompare attribute on the util module is used by the new assertion
# interpretation code and assertion rewriter to detect this plugin was
# loaded and in turn call the hooks defined here as part of the
# DebugInterpreter.
_reprcompare: Callable[[str, object, object], str | None] | None = None
# Works similarly as _reprcompare attribute. Is populated with the hook call
# when pytest_runtest_setup is called.
_assertion_pass: Callable[[int, str, str], None] | None = None
# Config object which is assigned during pytest_runtest_protocol.
_config: Config | None = None
ASSERTION_TEXT_DIFF_STYLE_INI = "assertion_text_diff_style"
ASSERTION_TEXT_DIFF_STYLE_NDIFF: Literal["ndiff"] = "ndiff"
ASSERTION_TEXT_DIFF_STYLE_BLOCK: Literal["block"] = "block"
ASSERTION_TEXT_DIFF_STYLE_CHOICES = (
ASSERTION_TEXT_DIFF_STYLE_NDIFF,
ASSERTION_TEXT_DIFF_STYLE_BLOCK,
)
def get_assertion_text_diff_style(config: Config) -> _AssertionTextDiffStyle:
style = str(config.getini(ASSERTION_TEXT_DIFF_STYLE_INI))
match style:
case "ndiff" | "block":
return style
case _:
choices = ", ".join(
repr(choice) for choice in ASSERTION_TEXT_DIFF_STYLE_CHOICES
)
raise UsageError(
f"{ASSERTION_TEXT_DIFF_STYLE_INI} must be one of {choices}; got {style!r}"
)
def validate_assertion_text_diff_style(config: Config) -> None:
get_assertion_text_diff_style(config)
def format_explanation(explanation: str) -> str:
r"""Format an explanation.
Normally all embedded newlines are escaped, however there are
three exceptions: \n{, \n} and \n~. The first two are intended
cover nested explanations, see function and attribute explanations
for examples (.visit_Call(), visit_Attribute()). The last one is
for when one explanation needs to span multiple lines, e.g. when
displaying diffs.
"""
lines = _split_explanation(explanation)
result = _format_lines(lines)
return "\n".join(result)
def _split_explanation(explanation: str) -> list[str]:
r"""Return a list of individual lines in the explanation.
This will return a list of lines split on '\n{', '\n}' and '\n~'.
Any other newlines will be escaped and appear in the line as the
literal '\n' characters.
"""
raw_lines = (explanation or "").split("\n")
lines = [raw_lines[0]]
for values in raw_lines[1:]:
if values and values[0] in ["{", "}", "~", ">"]:
lines.append(values)
else:
lines[-1] += "\\n" + values
return lines
def _format_lines(lines: Sequence[str]) -> list[str]:
"""Format the individual lines.
This will replace the '{', '}' and '~' characters of our mini formatting
language with the proper 'where ...', 'and ...' and ' + ...' text, taking
care of indentation along the way.
Return a list of formatted lines.
"""
result = list(lines[:1])
stack = [0]
stackcnt = [0]
for line in lines[1:]:
if line.startswith("{"):
if stackcnt[-1]:
s = "and "
else:
s = "where "
stack.append(len(result))
stackcnt[-1] += 1
stackcnt.append(0)
result.append(" +" + " " * (len(stack) - 1) + s + line[1:])
elif line.startswith("}"):
stack.pop()
stackcnt.pop()
result[stack[-1]] += line[1:]
else:
assert line[0] in ["~", ">"]
stack[-1] += 1
indent = len(stack) if line.startswith("~") else len(stack) - 1
result.append(" " * indent + line[1:])
assert len(stack) == 1
return result
def assertrepr_compare(
op: str,
left: object,
right: object,
*,
verbose: int,
highlighter: _HighlightFunc,
assertion_text_diff_style: _AssertionTextDiffStyle,
) -> Iterator[str]:
"""Yield specialised explanations for some operators/operands.
The first line yielded is always the summary (``left op right``);
subsequent lines are the detailed explanation. Yields nothing when no
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 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 = (
isinstance(left, str)
and isinstance(right, str)
and normalize("NFD", left) == normalize("NFD", right)
)
if verbose > 1:
left_repr = saferepr_unlimited(left, use_ascii=use_ascii)
right_repr = saferepr_unlimited(right, use_ascii=use_ascii)
else:
# XXX: "15 chars indentation" is wrong
# ("E AssertionError: assert "); should use term width.
maxsize = (
80 - 15 - len(op) - 2
) // 2 # 15 chars indentation, 1 space around op
left_repr = saferepr(left, maxsize=maxsize, use_ascii=use_ascii)
right_repr = saferepr(right, maxsize=maxsize, use_ascii=use_ascii)
summary = f"{left_repr} {op} {right_repr}"
try:
if op == "==":
source = _compare_eq_any(
left,
right,
highlighter,
verbose,
assertion_text_diff_style,
)
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 = SET_COMPARISON_FUNCTIONS[op](left, right, highlighter, verbose)
else:
source = iter(())
# Only yield the summary if there is a detailed explanation.
# Make sure there's a separating empty line after the summary.
summary_yielded = False
for line in source:
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()
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__.)"