From b5c3275aa07df4c5a36b3942296c089f87bf5733 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 11 Apr 2026 19:20:16 +0300 Subject: [PATCH 1/5] assertion/util: improve typing Change `Any`s to `object`. It's better to use `object` for "unknown" -- `object` is type safe, `Any` is not. To aid in this, change the `is*` functions to TypeGuards, so their check is carried over to the typing. Since pytest already uses dataclasses extensively internally, I removed the lazy import of it, this way can more easily utilize the existing type guard from typeshed. --- src/_pytest/assertion/util.py | 89 +++++++++++++++++------------------ 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index f35d83a6fe4..cdc3aabd14e 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,7 +174,7 @@ def has_default_eq( def assertrepr_compare( - config, op: str, left: Any, right: Any, use_ascii: bool = False + config: Config, op: str, left: object, right: object, use_ascii: bool = False ) -> list[str] | None: """Return specialised explanations for some operators/operands.""" verbose = config.get_verbosity(Config.VERBOSITY_ASSERTIONS) @@ -246,7 +244,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 +252,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 +335,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 +364,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 +384,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 +424,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 +436,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 +448,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 +460,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 +469,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 +479,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 +493,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 +539,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 From 95616864235e62701718993ed9b41d22103990e0 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 11 Apr 2026 20:20:41 +0300 Subject: [PATCH 2/5] testing/python/approx: add coverage for assertion rewriting when `approx` is on the LHS --- testing/python/approx.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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""" From 537086ad65ad988772a6da30ace686c699473704 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 13 Apr 2026 23:14:42 +0300 Subject: [PATCH 3/5] assertion/util: remove unused parameter `use_ascii` to `assertrepr_compare` --- src/_pytest/assertion/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/assertion/util.py b/src/_pytest/assertion/util.py index cdc3aabd14e..5d5e6d4777d 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -174,7 +174,7 @@ def has_default_eq(obj: object) -> bool: def assertrepr_compare( - config: Config, op: str, left: object, right: object, use_ascii: bool = False + config: Config, op: str, left: object, right: object ) -> list[str] | None: """Return specialised explanations for some operators/operands.""" verbose = config.get_verbosity(Config.VERBOSITY_ASSERTIONS) From 198b7658053bb8e385336e48fac4f5b979c9ad3a Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Tue, 14 Apr 2026 00:06:23 +0300 Subject: [PATCH 4/5] assertion: push `config` access up from `assertrepr_compare` to the hook Seems better to make the lower-level function more agnostic. --- src/_pytest/assertion/__init__.py | 9 ++++++++- src/_pytest/assertion/util.py | 10 ++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/_pytest/assertion/__init__.py b/src/_pytest/assertion/__init__.py index 22f3ca8e258..f274b9d579c 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -205,4 +205,11 @@ 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) + highlighter = config.get_terminal_writer()._highlight + 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 5d5e6d4777d..07918a66284 100644 --- a/src/_pytest/assertion/util.py +++ b/src/_pytest/assertion/util.py @@ -174,11 +174,14 @@ def has_default_eq(obj: object) -> bool: def assertrepr_compare( - config: Config, op: str, left: object, right: object + 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 = ( @@ -201,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: From 54f863847941b734483926bad0e2da15ee980275 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Mon, 13 Apr 2026 22:43:54 +0300 Subject: [PATCH 5/5] assertion/rewrite: fix test crash on assert failure with `terminalreporter` disabled The `config.get_terminal_writer()` in `assertrepr_compare` (=> the function injected by assertion rewriting for every `assert`) requires the `terminalreporter` plugin, so it crashed when the plugin is disabled. Fix #14377. --- changelog/14377.bugfix.rst | 1 + src/_pytest/assertion/__init__.py | 6 +++++- testing/test_assertion.py | 6 ++++++ testing/test_assertrewrite.py | 19 +++++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 changelog/14377.bugfix.rst 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 f274b9d579c..4b946bc7074 100644 --- a/src/_pytest/assertion/__init__.py +++ b/src/_pytest/assertion/__init__.py @@ -205,7 +205,11 @@ def pytest_sessionfinish(session: Session) -> None: def pytest_assertrepr_compare( config: Config, op: str, left: Any, right: Any ) -> list[str] | None: - highlighter = config.get_terminal_writer()._highlight + 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, 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)