v1.3.5
This commit is contained in:
@@ -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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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__.)"
|
||||
Reference in New Issue
Block a user