From 5e44c18d4cb9ae6224b9e01bfa7208880f105ee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Fri, 24 Apr 2026 01:08:38 +0100 Subject: [PATCH 1/3] Don't leak testtools internals into stdlib TestResult error reports --- tests/test_testresult.py | 46 +++++++++++++++++++++++++++++++++--- testtools/_types.py | 2 +- testtools/testresult/real.py | 18 +++++++------- 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/tests/test_testresult.py b/tests/test_testresult.py index df5e0f82..5d590219 100644 --- a/tests/test_testresult.py +++ b/tests/test_testresult.py @@ -16,7 +16,15 @@ from itertools import chain, combinations from queue import Queue from typing import Any -from unittest import TestSuite +from unittest import ( + TestCase as StdlibTestCase, +) +from unittest import ( + TestResult as StdlibTestResult, +) +from unittest import ( + TestSuite, +) from testtools import ( CopyStreamResult, @@ -2962,9 +2970,11 @@ def check_outcome_details_to_exec_info(self, outcome, expected=None): event_outcome, event_test, event_err = self.result._events[0] self.assertEqual(expected, event_outcome) self.assertEqual(self, event_test) - # Compare exc type and value; traceback objects differ between calls + # Compare exc type and value; traceback is always None because the + # exception is synthesised, not raised from user code — attaching a + # live traceback would leak testtools internals into error reports. self.assertEqual(expected_err[:2], event_err[:2]) - self.assertIsNotNone(event_err[2]) + self.assertIsNone(event_err[2]) def check_outcome_details_to_nothing(self, outcome, expected=None): """Call an outcome with a details dict to be swallowed.""" @@ -3139,6 +3149,36 @@ class TestExtendedToOriginalAddFailure(TestExtendedToOriginalAddError): outcome = "addFailure" +class TestExtendedToOriginalDetailsStdlibReport(TestCase): + """Regression test: details converted to exc_info for a stdlib TestResult + must not cause testtools' own frames to appear in the error report. + + Before the fix for the 2.9.0 regression, ``_details_to_exc_info`` returned + a live traceback pointing into ``testresult/real.py`` itself, which + ``unittest.TestResult._exc_info_to_string`` then formatted into the + user-visible string. + """ + + def _report_for(self, outcome, events_attr): + client = StdlibTestResult() + decorator = ExtendedToOriginalDecorator(client) + details = {"traceback": text_content("foo.c:53:ERROR invalid state\n")} + getattr(decorator, outcome)(StdlibTestCase("run"), details=details) + events = getattr(client, events_attr) + self.assertEqual(1, len(events)) + return events[0][1] + + def test_add_error(self): + report = self._report_for("addError", "errors") + self.assertNotIn("testresult/real.py", report) + self.assertNotIn("Traceback (most recent call last)", report) + + def test_add_failure(self): + report = self._report_for("addFailure", "failures") + self.assertNotIn("testresult/real.py", report) + self.assertNotIn("Traceback (most recent call last)", report) + + class TestExtendedToOriginalAddExpectedFailure(TestExtendedToOriginalAddError): outcome = "addExpectedFailure" diff --git a/testtools/_types.py b/testtools/_types.py index 2c4769d7..b0c52401 100644 --- a/testtools/_types.py +++ b/testtools/_types.py @@ -4,5 +4,5 @@ from typing import TypeAlias # Type for exc_info tuples from sys.exc_info() -ExcInfo: TypeAlias = tuple[type[BaseException], BaseException, TracebackType] +ExcInfo: TypeAlias = tuple[type[BaseException], BaseException, TracebackType | None] OptExcInfo: TypeAlias = ExcInfo | tuple[None, None, None] diff --git a/testtools/testresult/real.py b/testtools/testresult/real.py index cd20d6ae..bb887cac 100644 --- a/testtools/testresult/real.py +++ b/testtools/testresult/real.py @@ -39,7 +39,6 @@ TypeAlias, TypedDict, TypeVar, - cast, ) if TYPE_CHECKING: @@ -2113,12 +2112,15 @@ def _check_args(self, err: object, details: object) -> None: ) def _details_to_exc_info(self, details: DetailsDict) -> ExcInfo: - """Convert a details dict to an exc_info tuple.""" - try: - raise _StringException(_details_to_str(details, special="traceback")) - except _StringException: - # we know this won't be null - return cast(ExcInfo, sys.exc_info()) + """Convert a details dict to an exc_info tuple. + + The returned tuple has ``tb=None`` on purpose: the exception is + synthesised here, not raised by user code, so attaching a live + traceback would leak testtools' own internals into the user-visible + error report emitted by e.g. ``unittest.TestResult._exc_info_to_string``. + """ + exc = _StringException(_details_to_str(details, special="traceback")) + return (_StringException, exc, None) @property def current_tags(self) -> set[str]: @@ -2755,7 +2757,7 @@ def addSubTest( subtest: unittest.TestCase, err: OptExcInfo | None, ) -> None: - self.decorated.addSubTest(test, subtest, err) + self.decorated.addSubTest(test, subtest, err) # type: ignore[arg-type] def addDuration(self, test: unittest.TestCase, duration: float) -> None: self.decorated.addDuration(test, duration) From 088c98e24961ecf6d94ea5204457f2dcffe2f1c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Fri, 24 Apr 2026 10:38:49 +0100 Subject: [PATCH 2/3] release 2.9.1 --- NEWS | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/NEWS b/NEWS index f45e388c..88c92bcb 100644 --- a/NEWS +++ b/NEWS @@ -3,8 +3,14 @@ testtools NEWS Changes and improvements to testtools_, grouped by release. -NEXT -~~~~ +2.9.1 +~~~~~ + +Improvements +------------ +* Don't leak testtools-specific exception details into + ``unittest.TestResult`` error reports when using a stdlib ``TestResult``. + (Jelmer Vernooij, #607) 2.9.0 ~~~~~ From ef3a2d3c33f1fde27c8c09100d056909246c37c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jelmer=20Vernoo=C4=B3?= Date: Fri, 24 Apr 2026 10:51:40 +0100 Subject: [PATCH 3/3] Add NEXT section after 2.9.1 release --- NEWS | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NEWS b/NEWS index 88c92bcb..6cfeb0ec 100644 --- a/NEWS +++ b/NEWS @@ -3,6 +3,9 @@ testtools NEWS Changes and improvements to testtools_, grouped by release. +NEXT +~~~~ + 2.9.1 ~~~~~