From 2c5de4e6bee9c486116d2789c0cc06ec38c33ad0 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Tue, 17 Feb 2026 16:21:31 +0000 Subject: [PATCH 1/3] Re-add removed compat helpers These were dropped in 7d62575bc30c81f99dee6f168d4569e292386cbe but that should have resulted in a major release since it was an API break. Re-add them temporarily until we release a v3.0.0. Signed-off-by: Stephen Finucane --- tests/test_compat.py | 39 +++++++++++++++++++++++++++++++++++++++ testtools/compat.py | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/tests/test_compat.py b/tests/test_compat.py index 957fa095..44201e8a 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -5,9 +5,11 @@ import ast import io import sys +import traceback import testtools from testtools.compat import ( + reraise, text_repr, unicode_output_stream, ) @@ -216,6 +218,43 @@ def test_unicode_examples_multiline(self): self.assertEqual(ast.literal_eval(actual), u) +class TestReraise(testtools.TestCase): + """Tests for trivial reraise wrapper needed for Python 2/3 changes""" + + def test_exc_info(self): + """After reraise exc_info matches plus some extra traceback""" + try: + raise ValueError("Bad value") + except ValueError: + _exc_info = sys.exc_info() + try: + reraise(*_exc_info) + except ValueError: + _new_exc_info = sys.exc_info() + self.assertIs(_exc_info[0], _new_exc_info[0]) + self.assertIs(_exc_info[1], _new_exc_info[1]) + expected_tb = traceback.extract_tb(_exc_info[2]) + self.assertEqual( + expected_tb, traceback.extract_tb(_new_exc_info[2])[-len(expected_tb) :] + ) + + def test_custom_exception_no_args(self): + """Reraising does not require args attribute to contain params""" + + class CustomException(Exception): + """Exception that expects and sets attrs but not args""" + + def __init__(self, value): + Exception.__init__(self) + self.value = value + + try: + raise CustomException("Some value") + except CustomException: + _exc_info = sys.exc_info() + self.assertRaises(CustomException, reraise, *_exc_info) + + def test_suite(): from unittest import TestLoader diff --git a/testtools/compat.py b/testtools/compat.py index 8964d316..8b57f429 100644 --- a/testtools/compat.py +++ b/testtools/compat.py @@ -12,9 +12,45 @@ import codecs import io import sys +import types import unicodedata +import warnings from io import BytesIO, StringIO # for backwards-compat -from typing import IO +from typing import IO, Any, NoReturn + + +def reraise( + exc_class: type[BaseException], + exc_obj: BaseException, + exc_tb: types.TracebackType, + _marker: Any = object(), +) -> NoReturn: + """Re-raise an exception received from sys.exc_info() or similar.""" + warnings.warn( + "This is not necessary in Python 3.", + DeprecationWarning, + stacklevel=2, + ) + raise exc_obj.with_traceback(exc_tb) + + +def _u(s: str) -> str: + warnings.warn( + "This is not necessary in Python 3.", + DeprecationWarning, + stacklevel=2, + ) + return s + + +def _b(s: str) -> bytes: + """A byte literal.""" + warnings.warn( + "This is not necessary in Python 3.", + DeprecationWarning, + stacklevel=2, + ) + return s.encode("latin-1") def _slow_escape(text: str) -> str: From ec03064b74e82409f07114083b9eb60a6aa57df9 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Tue, 17 Feb 2026 17:04:29 +0000 Subject: [PATCH 2/3] Re-add helpers modules These are part of our public API and should not be removed in a patch release. We do however deprecate them and skip type checks. Signed-off-by: Stephen Finucane --- pyproject.toml | 2 + testtools/tests/__init__.py | 0 testtools/tests/helpers.py | 166 +++++++++++++++++++++++++++ testtools/tests/matchers/__init__.py | 0 testtools/tests/matchers/helpers.py | 66 +++++++++++ 5 files changed, 234 insertions(+) create mode 100644 testtools/tests/__init__.py create mode 100644 testtools/tests/helpers.py create mode 100644 testtools/tests/matchers/__init__.py create mode 100644 testtools/tests/matchers/helpers.py diff --git a/pyproject.toml b/pyproject.toml index c936082a..97d84164 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,8 @@ module = [ # except tests (we're not sadists) "testtools.twistedsupport.*", "tests.*", + # NOTE(stephenfin): These are deprecated so we're not going to type them + "testtools.tests.helpers", ] disallow_untyped_calls = false disallow_untyped_defs = false diff --git a/testtools/tests/__init__.py b/testtools/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testtools/tests/helpers.py b/testtools/tests/helpers.py new file mode 100644 index 00000000..e82745cb --- /dev/null +++ b/testtools/tests/helpers.py @@ -0,0 +1,166 @@ +# Copyright (c) 2008-2016 testtools developers. See LICENSE for details. + +"""Helpers for tests.""" + +__all__ = [ + "LoggingResult", +] + +import sys +import warnings + +from testtools import TestResult, runtest +from testtools.content import StackLinesContent +from testtools.matchers import ( + AfterPreprocessing, + Equals, + MatchesDict, + MatchesListwise, +) + +warnings.warn( + "This module is deprecated for removal", + DeprecationWarning, + stacklevel=2, +) + + +# GZ 2010-08-12: Don't do this, pointlessly creates an exc_info cycle +try: + raise Exception +except Exception: + an_exc_info = sys.exc_info() + + +# Deprecated: This classes attributes are somewhat non deterministic which +# leads to hard to predict tests (because Python upstream are changing things. +class LoggingResult(TestResult): + """TestResult that logs its event to a list.""" + + def __init__(self, log): + self._events = log + super().__init__() + + def startTest(self, test): + self._events.append(("startTest", test)) + super().startTest(test) + + def stop(self): + self._events.append("stop") + super().stop() + + def stopTest(self, test): + self._events.append(("stopTest", test)) + super().stopTest(test) + + def addFailure(self, test, err=None, details=None): + self._events.append(("addFailure", test, err)) + super().addFailure(test, err, details) + + def addError(self, test, err=None, details=None): + self._events.append(("addError", test, err)) + super().addError(test, err, details) + + def addSkip(self, test, reason=None, details=None): + # Extract reason from details if not provided directly + if reason is None and details and "reason" in details: + reason = details["reason"].as_text() + self._events.append(("addSkip", test, reason)) + super().addSkip(test, reason, details) + + def addSuccess(self, test, details=None): + self._events.append(("addSuccess", test)) + super().addSuccess(test, details) + + def startTestRun(self): + self._events.append("startTestRun") + super().startTestRun() + + def stopTestRun(self): + self._events.append("stopTestRun") + super().stopTestRun() + + def done(self): + self._events.append("done") + super().done() + + def tags(self, new_tags, gone_tags): + self._events.append(("tags", new_tags, gone_tags)) + super().tags(new_tags, gone_tags) + + def time(self, a_datetime): + self._events.append(("time", a_datetime)) + super().time(a_datetime) + + +def is_stack_hidden(): + return StackLinesContent.HIDE_INTERNAL_STACK + + +def hide_testtools_stack(should_hide=True): + result = StackLinesContent.HIDE_INTERNAL_STACK + StackLinesContent.HIDE_INTERNAL_STACK = should_hide + return result + + +def run_with_stack_hidden(should_hide, f, *args, **kwargs): + old_should_hide = hide_testtools_stack(should_hide) + try: + return f(*args, **kwargs) + finally: + hide_testtools_stack(old_should_hide) + + +class FullStackRunTest(runtest.RunTest): + def _run_user(self, fn, *args, **kwargs): + return run_with_stack_hidden(False, super()._run_user, fn, *args, **kwargs) + + +class MatchesEvents: + """Match a list of test result events. + + Specify events as a data structure. Ordinary Python objects within this + structure will be compared exactly, but you can also use matchers at any + point. + """ + + def __init__(self, *expected): + self._expected = expected + + def _make_matcher(self, obj): + # This isn't very safe for general use, but is good enough to make + # some tests in this module more readable. + if hasattr(obj, "match"): + return obj + elif isinstance(obj, tuple) or isinstance(obj, list): + return MatchesListwise([self._make_matcher(item) for item in obj]) + elif isinstance(obj, dict): + return MatchesDict( + {key: self._make_matcher(value) for key, value in obj.items()} + ) + else: + return Equals(obj) + + def match(self, observed): + matcher = self._make_matcher(self._expected) + return matcher.match(observed) + + +class AsText(AfterPreprocessing): + """Match the text of a Content instance.""" + + def __init__(self, matcher, annotate=True): + super().__init__(lambda log: log.as_text(), matcher, annotate=annotate) + + +def raise_(exception): + """Raise ``exception``. + + Useful for raising exceptions when it is inconvenient to use a statement + (e.g. in a lambda). + + :param Exception exception: An exception to raise. + :raises: Whatever exception is + + """ + raise exception diff --git a/testtools/tests/matchers/__init__.py b/testtools/tests/matchers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testtools/tests/matchers/helpers.py b/testtools/tests/matchers/helpers.py new file mode 100644 index 00000000..fe26e6b1 --- /dev/null +++ b/testtools/tests/matchers/helpers.py @@ -0,0 +1,66 @@ +# Copyright (c) 2008-2012 testtools developers. See LICENSE for details. + +import warnings +from collections.abc import Callable +from typing import Any, ClassVar, Protocol, runtime_checkable + +warnings.warn( + "This module is deprecated for removal", + DeprecationWarning, + stacklevel=2, +) + + +@runtime_checkable +class MatcherTestProtocol(Protocol): + """Protocol for test classes that test matchers.""" + + matches_matcher: ClassVar[Any] + matches_matches: ClassVar[Any] + matches_mismatches: ClassVar[Any] + str_examples: ClassVar[Any] + describe_examples: ClassVar[Any] + assertEqual: Callable[..., Any] + assertNotEqual: Callable[..., Any] + assertThat: Callable[..., Any] + + +class TestMatchersInterface: + """Mixin class that provides test methods for matcher interfaces.""" + + __test__ = False # Tell pytest not to collect this as a test class + + def test_matches_match(self: MatcherTestProtocol) -> None: + matcher = self.matches_matcher + matches = self.matches_matches + mismatches = self.matches_mismatches + for candidate in matches: + self.assertEqual(None, matcher.match(candidate)) + for candidate in mismatches: + mismatch = matcher.match(candidate) + self.assertNotEqual(None, mismatch) + self.assertNotEqual(None, getattr(mismatch, "describe", None)) + + def test__str__(self: MatcherTestProtocol) -> None: + # [(expected, object to __str__)]. + from testtools.matchers._doctest import DocTestMatches + + examples = self.str_examples + for expected, matcher in examples: + self.assertThat(matcher, DocTestMatches(expected)) + + def test_describe_difference(self: MatcherTestProtocol) -> None: + # [(expected, matchee, matcher), ...] + examples = self.describe_examples + for difference, matchee, matcher in examples: + mismatch = matcher.match(matchee) + self.assertEqual(difference, mismatch.describe()) + + def test_mismatch_details(self: MatcherTestProtocol) -> None: + # The mismatch object must provide get_details, which must return a + # dictionary mapping names to Content objects. + examples = self.describe_examples + for difference, matchee, matcher in examples: + mismatch = matcher.match(matchee) + details = mismatch.get_details() + self.assertEqual(dict(details), details) From 3665841b06599bfe8738ff556e517755bff573a1 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Tue, 17 Feb 2026 17:17:03 +0000 Subject: [PATCH 3/3] Add testtools.matchers.test module End users can provide their own matchers. It is helpful to provide a test harness for them to use to test this. Signed-off-by: Stephen Finucane --- tests/matchers/test_basic.py | 2 +- tests/matchers/test_const.py | 3 +- tests/matchers/test_datastructures.py | 2 +- tests/matchers/test_dict.py | 3 +- tests/matchers/test_doctest.py | 2 +- tests/matchers/test_exception.py | 2 +- tests/matchers/test_higherorder.py | 2 +- tests/matchers/test_warnings.py | 2 +- .../helpers.py => testtools/matchers/test.py | 0 testtools/tests/matchers/helpers.py | 61 ++----------------- 10 files changed, 13 insertions(+), 66 deletions(-) rename tests/matchers/helpers.py => testtools/matchers/test.py (100%) diff --git a/tests/matchers/test_basic.py b/tests/matchers/test_basic.py index 8d5c3ad7..6ce8ce7c 100644 --- a/tests/matchers/test_basic.py +++ b/tests/matchers/test_basic.py @@ -24,9 +24,9 @@ _BinaryMismatch, _NotNearlyEqual, ) +from testtools.matchers.test import TestMatchersInterface from ..helpers import FullStackRunTest -from ..matchers.helpers import TestMatchersInterface class Test_BinaryMismatch(TestCase): diff --git a/tests/matchers/test_const.py b/tests/matchers/test_const.py index 2f1eee46..384b55bb 100644 --- a/tests/matchers/test_const.py +++ b/tests/matchers/test_const.py @@ -4,8 +4,7 @@ from testtools import TestCase from testtools.matchers import Always, Never - -from ..matchers.helpers import TestMatchersInterface +from testtools.matchers.test import TestMatchersInterface class TestAlwaysInterface(TestMatchersInterface, TestCase): diff --git a/tests/matchers/test_datastructures.py b/tests/matchers/test_datastructures.py index 02c1c8f2..2d6db363 100644 --- a/tests/matchers/test_datastructures.py +++ b/tests/matchers/test_datastructures.py @@ -20,9 +20,9 @@ MatchesSetwise, MatchesStructure, ) +from testtools.matchers.test import TestMatchersInterface from ..helpers import FullStackRunTest -from ..matchers.helpers import TestMatchersInterface def run_doctest(obj, name): diff --git a/tests/matchers/test_dict.py b/tests/matchers/test_dict.py index 43dc843c..60ee54b0 100644 --- a/tests/matchers/test_dict.py +++ b/tests/matchers/test_dict.py @@ -14,8 +14,7 @@ MatchesDict, _SubDictOf, ) - -from ..matchers.helpers import TestMatchersInterface +from testtools.matchers.test import TestMatchersInterface class TestMatchesAllDictInterface(TestCase, TestMatchersInterface): diff --git a/tests/matchers/test_doctest.py b/tests/matchers/test_doctest.py index 6e16a41a..749e023d 100644 --- a/tests/matchers/test_doctest.py +++ b/tests/matchers/test_doctest.py @@ -5,9 +5,9 @@ from testtools import TestCase from testtools.matchers._doctest import DocTestMatches +from testtools.matchers.test import TestMatchersInterface from ..helpers import FullStackRunTest -from ..matchers.helpers import TestMatchersInterface class TestDocTestMatchesInterface(TestCase, TestMatchersInterface): diff --git a/tests/matchers/test_exception.py b/tests/matchers/test_exception.py index 43472b51..aa9ab6b1 100644 --- a/tests/matchers/test_exception.py +++ b/tests/matchers/test_exception.py @@ -13,9 +13,9 @@ Raises, raises, ) +from testtools.matchers.test import TestMatchersInterface from ..helpers import FullStackRunTest -from ..matchers.helpers import TestMatchersInterface def make_error(type, *args, **kwargs): diff --git a/tests/matchers/test_higherorder.py b/tests/matchers/test_higherorder.py index 8d35c82f..620d4410 100644 --- a/tests/matchers/test_higherorder.py +++ b/tests/matchers/test_higherorder.py @@ -23,9 +23,9 @@ MatchesPredicateWithParams, Not, ) +from testtools.matchers.test import TestMatchersInterface from ..helpers import FullStackRunTest -from ..matchers.helpers import TestMatchersInterface class TestAllMatch(TestCase, TestMatchersInterface): diff --git a/tests/matchers/test_warnings.py b/tests/matchers/test_warnings.py index 56616318..6cd68a34 100644 --- a/tests/matchers/test_warnings.py +++ b/tests/matchers/test_warnings.py @@ -13,9 +13,9 @@ MatchesStructure, ) from testtools.matchers._warnings import IsDeprecated, WarningMessage, Warnings +from testtools.matchers.test import TestMatchersInterface from ..helpers import FullStackRunTest -from ..matchers.helpers import TestMatchersInterface def make_warning(warning_type, message): diff --git a/tests/matchers/helpers.py b/testtools/matchers/test.py similarity index 100% rename from tests/matchers/helpers.py rename to testtools/matchers/test.py diff --git a/testtools/tests/matchers/helpers.py b/testtools/tests/matchers/helpers.py index fe26e6b1..2f1c8ca2 100644 --- a/testtools/tests/matchers/helpers.py +++ b/testtools/tests/matchers/helpers.py @@ -1,8 +1,8 @@ # Copyright (c) 2008-2012 testtools developers. See LICENSE for details. import warnings -from collections.abc import Callable -from typing import Any, ClassVar, Protocol, runtime_checkable + +from testtools.matchers.test import TestMatchersInterface warnings.warn( "This module is deprecated for removal", @@ -10,57 +10,6 @@ stacklevel=2, ) - -@runtime_checkable -class MatcherTestProtocol(Protocol): - """Protocol for test classes that test matchers.""" - - matches_matcher: ClassVar[Any] - matches_matches: ClassVar[Any] - matches_mismatches: ClassVar[Any] - str_examples: ClassVar[Any] - describe_examples: ClassVar[Any] - assertEqual: Callable[..., Any] - assertNotEqual: Callable[..., Any] - assertThat: Callable[..., Any] - - -class TestMatchersInterface: - """Mixin class that provides test methods for matcher interfaces.""" - - __test__ = False # Tell pytest not to collect this as a test class - - def test_matches_match(self: MatcherTestProtocol) -> None: - matcher = self.matches_matcher - matches = self.matches_matches - mismatches = self.matches_mismatches - for candidate in matches: - self.assertEqual(None, matcher.match(candidate)) - for candidate in mismatches: - mismatch = matcher.match(candidate) - self.assertNotEqual(None, mismatch) - self.assertNotEqual(None, getattr(mismatch, "describe", None)) - - def test__str__(self: MatcherTestProtocol) -> None: - # [(expected, object to __str__)]. - from testtools.matchers._doctest import DocTestMatches - - examples = self.str_examples - for expected, matcher in examples: - self.assertThat(matcher, DocTestMatches(expected)) - - def test_describe_difference(self: MatcherTestProtocol) -> None: - # [(expected, matchee, matcher), ...] - examples = self.describe_examples - for difference, matchee, matcher in examples: - mismatch = matcher.match(matchee) - self.assertEqual(difference, mismatch.describe()) - - def test_mismatch_details(self: MatcherTestProtocol) -> None: - # The mismatch object must provide get_details, which must return a - # dictionary mapping names to Content objects. - examples = self.describe_examples - for difference, matchee, matcher in examples: - mismatch = matcher.match(matchee) - details = mismatch.get_details() - self.assertEqual(dict(details), details) +__all__ = [ + "TestMatchersInterface", +]