diff --git a/changelog/14377.bugfix.rst b/changelog/14377.bugfix.rst new file mode 100644 index 00000000000..5d94fed0f54 --- /dev/null +++ b/changelog/14377.bugfix.rst @@ -0,0 +1 @@ +Fixed crash in `Config.get_terminal_writer` when an assertion fails with the ``terminalreporter`` plugin disabled. diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 22f3ca8e258..4b946bc7074 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -205,4 +205,15 @@ def pytest_sessionfinish(session: Session) -> None: def pytest_assertrepr_compare( config: Config, op: str, left: Any, right: Any ) -> list[str] | None: - return util.assertrepr_compare(config=config, op=op, left=left, right=right) + 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 + return util.assertrepr_compare( + op=op, + left=left, + right=right, + verbose=config.get_verbosity(Config.VERBOSITY_ASSERTIONS), + highlighter=highlighter, + ) diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index f35d83a6fe4..07918a66284 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -9,10 +9,11 @@ from collections.abc import Mapping from collections.abc import Sequence from collections.abc import Set as AbstractSet +import dataclasses import pprint -from typing import Any from typing import Literal from typing import Protocol +from typing import TypeGuard from unicodedata import normalize from _pytest import outcomes @@ -118,45 +119,42 @@ def _format_lines(lines: Sequence[str]) -> list[str]: return result -def issequence(x: Any) -> bool: +def issequence(x: object) -> TypeGuard[collections.abc.Sequence[object]]: return isinstance(x, collections.abc.Sequence) and not isinstance(x, str) -def istext(x: Any) -> bool: +def istext(x: object) -> TypeGuard[str]: return isinstance(x, str) -def isdict(x: Any) -> bool: +def isdict(x: object) -> TypeGuard[dict[object, object]]: return isinstance(x, dict) -def isset(x: Any) -> bool: +def isset(x: object) -> TypeGuard[set[object] | frozenset[object]]: return isinstance(x, set | frozenset) -def isnamedtuple(obj: Any) -> bool: +def isnamedtuple(obj: object) -> bool: return isinstance(obj, tuple) and getattr(obj, "_fields", None) is not None -def isdatacls(obj: Any) -> bool: - return getattr(obj, "__dataclass_fields__", None) is not None +isdatacls = dataclasses.is_dataclass -def isattrs(obj: Any) -> bool: +def isattrs(obj: object) -> bool: return getattr(obj, "__attrs_attrs__", None) is not None -def isiterable(obj: Any) -> bool: +def isiterable(obj: object) -> TypeGuard[collections.abc.Iterable[object]]: try: - iter(obj) + iter(obj) # type: ignore[call-overload] return not istext(obj) except Exception: return False -def has_default_eq( - obj: object, -) -> bool: +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__, @@ -176,11 +174,14 @@ def has_default_eq( def assertrepr_compare( - config, op: str, left: Any, right: Any, use_ascii: bool = False + op: str, + left: object, + right: object, + *, + verbose: int, + highlighter: _HighlightFunc, ) -> list[str] | None: """Return specialised explanations for some operators/operands.""" - verbose = config.get_verbosity(Config.VERBOSITY_ASSERTIONS) - # Strings which normalize equal are often hard to distinguish when printed; use ascii() to make this easier. # See issue #3246. use_ascii = ( @@ -203,7 +204,6 @@ def assertrepr_compare( right_repr = saferepr(right, maxsize=maxsize, use_ascii=use_ascii) summary = f"{left_repr} {op} {right_repr}" - highlighter = config.get_terminal_writer()._highlight explanation = None try: @@ -246,7 +246,7 @@ def assertrepr_compare( def _compare_eq_any( - left: Any, right: Any, highlighter: _HighlightFunc, verbose: int = 0 + left: object, right: object, highlighter: _HighlightFunc, verbose: int = 0 ) -> list[str]: explanation = [] if istext(left) and istext(right): @@ -254,12 +254,11 @@ def _compare_eq_any( else: from _pytest.python_api import ApproxBase - if isinstance(left, ApproxBase) or isinstance(right, ApproxBase): - # Although the common order should be obtained == expected, this ensures both ways - approx_side = left if isinstance(left, ApproxBase) else right - other_side = right if isinstance(left, ApproxBase) else left - - explanation = approx_side._repr_compare(other_side) + # Although the common order should be obtained == approx(...), allow both ways. + if isinstance(right, ApproxBase): + explanation = right._repr_compare(left) + elif isinstance(left, ApproxBase): + explanation = left._repr_compare(right) elif type(left) is type(right) and ( isdatacls(left) or isattrs(left) or isnamedtuple(left) ): @@ -338,8 +337,8 @@ def _diff_text( def _compare_eq_iterable( - left: Iterable[Any], - right: Iterable[Any], + left: Iterable[object], + right: Iterable[object], highlighter: _HighlightFunc, verbose: int = 0, ) -> list[str]: @@ -367,8 +366,8 @@ def _compare_eq_iterable( def _compare_eq_sequence( - left: Sequence[Any], - right: Sequence[Any], + left: Sequence[object], + right: Sequence[object], highlighter: _HighlightFunc, verbose: int = 0, ) -> list[str]: @@ -387,8 +386,8 @@ def _compare_eq_sequence( # 102 # >>> s[0:1] # b'f' - left_value = left[i : i + 1] - right_value = right[i : i + 1] + left_value: object = left[i : i + 1] + right_value: object = right[i : i + 1] else: left_value = left[i] right_value = right[i] @@ -427,8 +426,8 @@ def _compare_eq_sequence( def _compare_eq_set( - left: AbstractSet[Any], - right: AbstractSet[Any], + left: AbstractSet[object], + right: AbstractSet[object], highlighter: _HighlightFunc, verbose: int = 0, ) -> list[str]: @@ -439,8 +438,8 @@ def _compare_eq_set( def _compare_gt_set( - left: AbstractSet[Any], - right: AbstractSet[Any], + left: AbstractSet[object], + right: AbstractSet[object], highlighter: _HighlightFunc, verbose: int = 0, ) -> list[str]: @@ -451,8 +450,8 @@ def _compare_gt_set( def _compare_lt_set( - left: AbstractSet[Any], - right: AbstractSet[Any], + left: AbstractSet[object], + right: AbstractSet[object], highlighter: _HighlightFunc, verbose: int = 0, ) -> list[str]: @@ -463,8 +462,8 @@ def _compare_lt_set( def _compare_gte_set( - left: AbstractSet[Any], - right: AbstractSet[Any], + left: AbstractSet[object], + right: AbstractSet[object], highlighter: _HighlightFunc, verbose: int = 0, ) -> list[str]: @@ -472,8 +471,8 @@ def _compare_gte_set( def _compare_lte_set( - left: AbstractSet[Any], - right: AbstractSet[Any], + left: AbstractSet[object], + right: AbstractSet[object], highlighter: _HighlightFunc, verbose: int = 0, ) -> list[str]: @@ -482,8 +481,8 @@ def _compare_lte_set( def _set_one_sided_diff( posn: str, - set1: AbstractSet[Any], - set2: AbstractSet[Any], + set1: AbstractSet[object], + set2: AbstractSet[object], highlighter: _HighlightFunc, ) -> list[str]: explanation = [] @@ -496,8 +495,8 @@ def _set_one_sided_diff( def _compare_eq_dict( - left: Mapping[Any, Any], - right: Mapping[Any, Any], + left: Mapping[object, object], + right: Mapping[object, object], highlighter: _HighlightFunc, verbose: int = 0, ) -> list[str]: @@ -542,20 +541,18 @@ def _compare_eq_dict( def _compare_eq_cls( - left: Any, right: Any, highlighter: _HighlightFunc, verbose: int + left: object, right: object, highlighter: _HighlightFunc, verbose: int ) -> list[str]: if not has_default_eq(left): return [] if isdatacls(left): - import dataclasses - 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__ + 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 + fields_to_check = left._fields # type: ignore[attr-defined] else: assert False diff --git a/testing/python/approx.py b/testing/python/approx.py index bfbb59fb61d..bf9fad6cb56 100644 --- a/testing/python/approx.py +++ b/testing/python/approx.py @@ -1104,6 +1104,19 @@ def test_approx_on_unordered_mapping_matching(): result = pytester.runpytest() result.assert_outcomes(passed=1) + def test_assertion_rewriting_works_with_approx_on_lhs( + self, pytestconfig: pytest.Config + ) -> None: + """Assertion rewriting works also when approx() is on the left-hand side.""" + with temporary_verbosity(pytestconfig, verbosity=0): + with pytest.raises(AssertionError) as e: + assert pytest.approx(1) == 2 + obtained_message = str(e.value).splitlines()[-2:] + assert obtained_message == [ + " Obtained: 2", + " Expected: 1 ± 1.0e-06", + ] + class MyVec3: # incomplete """sequence like""" diff --git a/testing/test_assertion.py b/testing/test_assertion.py index d68fd0b1fba..9a7305a2905 100644 --- a/testing/test_assertion.py +++ b/testing/test_assertion.py @@ -24,7 +24,13 @@ class TerminalWriter: def _highlight(self, source, lexer="python"): return source + class PluginManager: + def has_plugin(self, name: str) -> bool: + return True + class Config: + pluginmanager = PluginManager() + def get_terminal_writer(self): return TerminalWriter() diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 92664354470..2668001af65 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -2404,3 +2404,22 @@ def test_saferepr_unbounded(self): _saferepr(self.Help) == f"" ) + + +def test_assertion_failure_when_terminalreporter_is_disabled( + pytester: Pytester, +) -> None: + """Assertion rewriting doesn't crash when the terminalreporter plugin is + disabled (#14378).""" + pytester.makepyfile( + """ + import pytest + + def test(): + with pytest.raises(AssertionError) as excinfo: + assert 0 == 1 + assert excinfo.value.args[0] == 'assert 0 == 1' + """ + ) + reprec = pytester.inline_run("-p", "no:terminalreporter") + reprec.assertoutcome(passed=1)