From 055b8fd1ee98138c6ea9ab59b987a78ee3d9c857 Mon Sep 17 00:00:00 2001 From: Friday Date: Wed, 18 Feb 2026 03:27:52 +0000 Subject: [PATCH 1/3] Fix ExceptionInfo.for_later() not populating _striptext When ExceptionInfo is created via for_later() and later filled with fill_unfilled(), the _striptext attribute was never populated. This caused exconly(tryshort=True) to not strip the "AssertionError: " prefix for rewritten assertions. The fix applies the same _striptext logic from from_exc_info() inside fill_unfilled(), so that ExceptionInfo objects created through the for_later() path behave consistently. Fixes #12175. Co-Authored-By: Claude Opus 4.6 --- changelog/12175.bugfix.rst | 1 + src/_pytest/_code/code.py | 6 ++++++ testing/code/test_excinfo.py | 21 +++++++++++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 changelog/12175.bugfix.rst diff --git a/changelog/12175.bugfix.rst b/changelog/12175.bugfix.rst new file mode 100644 index 00000000000..140e602d3c4 --- /dev/null +++ b/changelog/12175.bugfix.rst @@ -0,0 +1 @@ +Fixed :meth:`ExceptionInfo.exconly(tryshort=True) ` not stripping the ``AssertionError`` prefix when the :class:`ExceptionInfo` was created via :meth:`ExceptionInfo.for_later` (e.g. when using :func:`pytest.raises`). diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 4cf99a77340..3618e9d83d3 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -591,6 +591,12 @@ def fill_unfilled(self, exc_info: tuple[type[E], E, TracebackType]) -> None: """Fill an unfilled ExceptionInfo created with ``for_later()``.""" assert self._excinfo is None, "ExceptionInfo was already filled" self._excinfo = exc_info + if isinstance(exc_info[1], AssertionError): + exprinfo = getattr(exc_info[1], "msg", None) + if exprinfo is None: + exprinfo = saferepr(exc_info[1]) + if exprinfo and exprinfo.startswith(self._assert_start_repr): + self._striptext = "AssertionError: " @property def type(self) -> type[E]: diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index 70499fec893..b0d7199cd58 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -365,6 +365,27 @@ def test_excinfo_for_later() -> None: assert "for raises" in str(e) +def test_excinfo_for_later_strips_assertion(pytester: Pytester) -> None: + """ExceptionInfo created via for_later() should strip AssertionError prefix + with exconly(tryshort=True) for rewritten assertions (#12175).""" + pytester.makepyfile( + """ + import pytest + + def test_tryshort(): + with pytest.raises(AssertionError) as exc_info: + assert 1 == 2 + # tryshort should strip 'AssertionError: ' from rewritten assertions + result = exc_info.exconly(tryshort=True) + assert not result.startswith("AssertionError"), ( + f"Expected stripped prefix, got: {result!r}" + ) + """ + ) + result = pytester.runpytest() + result.assert_outcomes(passed=1) + + def test_excinfo_errisinstance(): excinfo = pytest.raises(ValueError, h) assert excinfo.errisinstance(ValueError) From 0ce07e6ac3bc0a4937607e6a924685b2dd3044f9 Mon Sep 17 00:00:00 2001 From: Friday Date: Wed, 18 Feb 2026 09:08:03 +0000 Subject: [PATCH 2/3] Deduplicate _striptext logic into _compute_striptext classmethod Address review feedback: extract the AssertionError prefix detection into a shared _compute_striptext() classmethod used by both from_exc_info() and fill_unfilled(). Co-Authored-By: Claude Opus 4.6 --- src/_pytest/_code/code.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/_pytest/_code/code.py b/src/_pytest/_code/code.py index 3618e9d83d3..e7ddde37f31 100644 --- a/src/_pytest/_code/code.py +++ b/src/_pytest/_code/code.py @@ -545,6 +545,20 @@ def from_exception( exc_info = (type(exception), exception, exception.__traceback__) return cls.from_exc_info(exc_info, exprinfo) + @classmethod + def _compute_striptext( + cls, + exc_info: tuple[type[E], E, TracebackType], + ) -> str: + """Determine if AssertionError prefix should be stripped from output.""" + if isinstance(exc_info[1], AssertionError): + exprinfo = getattr(exc_info[1], "msg", None) + if exprinfo is None: + exprinfo = saferepr(exc_info[1]) + if exprinfo and exprinfo.startswith(cls._assert_start_repr): + return "AssertionError: " + return "" + @classmethod def from_exc_info( cls, @@ -553,12 +567,8 @@ def from_exc_info( ) -> ExceptionInfo[E]: """Like :func:`from_exception`, but using old-style exc_info tuple.""" _striptext = "" - if exprinfo is None and isinstance(exc_info[1], AssertionError): - exprinfo = getattr(exc_info[1], "msg", None) - if exprinfo is None: - exprinfo = saferepr(exc_info[1]) - if exprinfo and exprinfo.startswith(cls._assert_start_repr): - _striptext = "AssertionError: " + if exprinfo is None: + _striptext = cls._compute_striptext(exc_info) return cls(exc_info, _striptext, _ispytest=True) @@ -591,12 +601,7 @@ def fill_unfilled(self, exc_info: tuple[type[E], E, TracebackType]) -> None: """Fill an unfilled ExceptionInfo created with ``for_later()``.""" assert self._excinfo is None, "ExceptionInfo was already filled" self._excinfo = exc_info - if isinstance(exc_info[1], AssertionError): - exprinfo = getattr(exc_info[1], "msg", None) - if exprinfo is None: - exprinfo = saferepr(exc_info[1]) - if exprinfo and exprinfo.startswith(self._assert_start_repr): - self._striptext = "AssertionError: " + self._striptext = self._compute_striptext(exc_info) @property def type(self) -> type[E]: From 0884661336a05fef7517a299c92574ce65f2b47d Mon Sep 17 00:00:00 2001 From: Friday Date: Wed, 18 Feb 2026 16:16:26 +0000 Subject: [PATCH 3/3] Add test coverage for _compute_striptext edge cases Cover non-AssertionError path (returns "") and AssertionError with explicit .msg attribute that doesn't match _assert_start_repr. Co-Authored-By: Claude Opus 4.6 --- testing/code/test_excinfo.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/testing/code/test_excinfo.py b/testing/code/test_excinfo.py index b0d7199cd58..1c3453231b9 100644 --- a/testing/code/test_excinfo.py +++ b/testing/code/test_excinfo.py @@ -386,6 +386,30 @@ def test_tryshort(): result.assert_outcomes(passed=1) +def test_excinfo_for_later_no_strip_non_assertion() -> None: + """fill_unfilled() should not strip prefix for non-AssertionError exceptions.""" + excinfo: ExceptionInfo[ValueError] = ExceptionInfo.for_later() + try: + raise ValueError("test error") + except ValueError: + excinfo.fill_unfilled(sys.exc_info()) # type: ignore[arg-type] + assert excinfo.exconly(tryshort=True).startswith("ValueError") + + +def test_excinfo_for_later_strips_manual_assertion() -> None: + """fill_unfilled() handles AssertionError with explicit .msg attribute.""" + excinfo: ExceptionInfo[AssertionError] = ExceptionInfo.for_later() + try: + err = AssertionError("manual error") + err.msg = "assert something" # type: ignore[attr-defined] + raise err + except AssertionError: + excinfo.fill_unfilled(sys.exc_info()) # type: ignore[arg-type] + # Manual .msg that doesn't match _assert_start_repr should not strip + result = excinfo.exconly(tryshort=True) + assert result.startswith("AssertionError") + + def test_excinfo_errisinstance(): excinfo = pytest.raises(ValueError, h) assert excinfo.errisinstance(ValueError)