From b5c3275aa07df4c5a36b3942296c089f87bf5733 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 11 Apr 2026 19:20:16 +0300 Subject: [PATCH 1/3] 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/3] 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/3] 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)