From 24b49dcb14065f2f41d007fcb2a893230b710f19 Mon Sep 17 00:00:00 2001 From: Chris Withers Date: Tue, 9 Jun 2026 08:54:49 +0100 Subject: [PATCH 01/11] Allow StringComparison to be built from a compiled re.Pattern Saves recompiling a pattern you already have, and lets you reuse a pattern compiled with flags that aren't expressible by name. --- docs/comparison.rst | 11 +++++++++++ src/testfixtures/comparison.py | 21 +++++++++++++++++++-- tests/test_stringcomparison.py | 6 ++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/comparison.rst b/docs/comparison.rst index 27afc45b..b006520a 100644 --- a/docs/comparison.rst +++ b/docs/comparison.rst @@ -670,3 +670,14 @@ If you need to specify flags, this can be done in one of three ways: expected=StringComparison("(?s:.*bar)"), actual="foo\nbar", ) + +You can also construct a :class:`StringComparison` from an already compiled +:class:`re.Pattern`: + +.. code-block:: python + + import re + compare( + expected=StringComparison(re.compile(".*BaR", re.DOTALL|re.IGNORECASE)), + actual="foo\nbar", + ) diff --git a/src/testfixtures/comparison.py b/src/testfixtures/comparison.py index 0ca076fc..bea56598 100644 --- a/src/testfixtures/comparison.py +++ b/src/testfixtures/comparison.py @@ -419,13 +419,30 @@ class StringComparison: :param regex_source: A string containing the source for a regular expression that will be used whenever this :class:`StringComparison` is compared with - any :class:`str` instance. + any :class:`str` instance, or an already compiled + :class:`re.Pattern`. :param flags: Flags passed to :func:`re.compile`. :param flag_names: See the :ref:`examples `. """ - def __init__(self, regex_source: str, flags: int | None = None, **flag_names: str): + + @overload + def __init__(self, regex_source: re.Pattern[str]): ... + + @overload + def __init__(self, regex_source: str, flags: int | None = None, **flag_names: bool): ... + + def __init__( + self, + regex_source: str | re.Pattern[str], + flags: int | None = None, + **flag_names: bool, + ): + if isinstance(regex_source, re.Pattern): + self.re = regex_source + return + args: list[Any] = [regex_source] flags_ = [] diff --git a/tests/test_stringcomparison.py b/tests/test_stringcomparison.py index dfbc624a..9644634f 100644 --- a/tests/test_stringcomparison.py +++ b/tests/test_stringcomparison.py @@ -59,3 +59,9 @@ def test_flags_parameter(self): def test_flags_names(self): compare(S(".*BaR", dotall=True, ignorecase=True), actual="foo\nbar") + + def test_compiled_pattern(self): + compare(S(re.compile(r'on \d+')), actual='on 40220') + + def test_compiled_pattern_with_flags(self): + compare(S(re.compile(".*BaR", re.DOTALL | re.IGNORECASE)), actual="foo\nbar") From f19ef36b3cf865352e7a2962392fbf96c55eed41 Mon Sep 17 00:00:00 2001 From: Chris Withers Date: Tue, 9 Jun 2026 08:58:00 +0100 Subject: [PATCH 02/11] Let like() build a StringComparison from a pattern, typed as str So strictly-typed code can express a regex match inline without an explicit StringComparison and the type-ignore that needs. --- docs/comparison.rst | 15 +++++++++++---- src/testfixtures/comparison.py | 20 +++++++++++++++++--- tests/test_comparison_typed.py | 11 +++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/comparison.rst b/docs/comparison.rst index b006520a..397f958d 100644 --- a/docs/comparison.rst +++ b/docs/comparison.rst @@ -66,10 +66,17 @@ including in assertions: >>> assert expected == SampleClass(1, '2') >>> assert expected == SampleClass(3, '4') -``like()`` always builds a partial :ref:`Comparison `. Reach for a -:ref:`Comparison ` directly when you need to match by type alone, by -dotted import path, against an existing instance, or matching exactly rather than -partially. +When passed a type and, optionally, attributeds, ``like()`` builds a partial +:ref:`Comparison `. Reach for a :ref:`Comparison ` directly when you need to +match by type alone, by dotted import path, against an existing instance, or matching exactly rather +than partially. + +When passed a regular expression pattern, either as a string or an already +compiled :class:`re.Pattern`, :func:`~testfixtures.like` returns a +:class:`StringComparison` typed as a :class:`str`: + +>>> expected_str: str = like(r'Starting thread \d+') +>>> compare(expected_str, actual='Starting thread 132356') Sequence helpers ---------------- diff --git a/src/testfixtures/comparison.py b/src/testfixtures/comparison.py index bea56598..e2452db1 100644 --- a/src/testfixtures/comparison.py +++ b/src/testfixtures/comparison.py @@ -520,7 +520,15 @@ def __repr__(self) -> str: T = TypeVar('T') -def like(t: type[T], **attributes: Any) -> T: +@overload +def like(t: str | re.Pattern[str]) -> str: ... + + +@overload +def like(t: type[T], **attributes: Any) -> T: ... + + +def like(t: type[T] | str | re.Pattern[str], **attributes: Any) -> T | str: """ Create a type-safe partial comparison for use in strictly typed code. @@ -528,10 +536,16 @@ def like(t: type[T], **attributes: Any) -> T: ``partial=True`` but is typed to return the type being compared, making it compatible with strict type checkers like mypy. - :param t: The type to compare against. + If passed a :class:`str` pattern or a compiled :class:`re.Pattern`, a + :class:`StringComparison` typed as a :class:`str` is returned instead. + + :param t: The type to compare against, or a regular expression pattern. :param attributes: Keyword arguments specifying the attributes to check. - :return: A :class:`Comparison` object typed as the input type. + :return: A :class:`Comparison` typed as the input type, or a + :class:`StringComparison` typed as a :class:`str`. """ + if isinstance(t, (str, re.Pattern)): + return cast(str, StringComparison(t)) return Comparison(t, attribute_dict=attributes, partial=True) # type: ignore[return-value] diff --git a/tests/test_comparison_typed.py b/tests/test_comparison_typed.py index f12efb7f..a7dc6894 100644 --- a/tests/test_comparison_typed.py +++ b/tests/test_comparison_typed.py @@ -1,4 +1,5 @@ # Tests that ensure compare and Comparison work correctly in strictly type checked environments +import re from collections import OrderedDict from dataclasses import dataclass from uuid import UUID, uuid4 @@ -67,6 +68,16 @@ def test_uuid_already_seen_like(): compare(uuid, expected=like(UUID)) +def test_like_str_pattern() -> None: + expected: str = like(r'on \d+') + compare(expected, actual='on 40220') + + +def test_like_compiled_pattern() -> None: + expected: str = like(re.compile(r'on \d+')) + compare(expected, actual='on 40220') + + class TestSequence: def test_minimal(self) -> None: actual = ListCollection([SampleClass(1, '2')]) From 4fc9d08fe4bcb9bfdbf3130d86a9f7876c6f2a20 Mon Sep 17 00:00:00 2001 From: Chris Withers Date: Tue, 9 Jun 2026 11:28:35 +0100 Subject: [PATCH 03/11] Add ReprComparison for type + repr assertions Useful for objects whose type doesn't support meaningful equality but whose repr is stable and worth pinning. --- docs/api.rst | 3 + docs/comparison.rst | 19 ++++++ src/testfixtures/__init__.py | 5 +- src/testfixtures/comparison.py | 51 +++++++++++++++++ tests/test_reprcomparison.py | 102 +++++++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 tests/test_reprcomparison.py diff --git a/docs/api.rst b/docs/api.rst index d0837b2f..33a35022 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -25,6 +25,9 @@ Comparisons .. autoclass:: RangeComparison :members: +.. autoclass:: ReprComparison + :members: + .. autoclass:: SequenceComparison :members: diff --git a/docs/comparison.rst b/docs/comparison.rst index 397f958d..be6c9bd3 100644 --- a/docs/comparison.rst +++ b/docs/comparison.rst @@ -622,6 +622,25 @@ Here's an example with dates: :class:`RangeComparison` is inclusive of both the lower and upper bound. +.. _reprcomparison: + +``ReprComparison`` +~~~~~~~~~~~~~~~~~~ + +Sometimes the easiest way to assert that an object is correct is to check that +it is of the expected type and has the expected :func:`repr`, particularly when +the object's type doesn't support meaningful equality. + +For these situations, you can use :class:`ReprComparison` objects, which compare +equal to any object that is an instance of the supplied type and whose +:func:`repr` matches the supplied string: + +.. code-block:: python + + from testfixtures import compare, ReprComparison + + compare(expected=ReprComparison(KeyError, "KeyError('foo')"), actual=KeyError('foo')) + .. _stringcomparison: ``StringComparison`` diff --git a/src/testfixtures/__init__.py b/src/testfixtures/__init__.py index ecf1fbed..db3d86c7 100644 --- a/src/testfixtures/__init__.py +++ b/src/testfixtures/__init__.py @@ -16,8 +16,8 @@ def __repr__(self) -> str: from testfixtures.comparing import compare, register from testfixtures.comparison import ( Comparison, StringComparison, RoundComparison, RangeComparison, - SequenceComparison, Subset, Permutation, MappingComparison, like, sequence, - contains, unordered, mapping + ReprComparison, SequenceComparison, Subset, Permutation, MappingComparison, + like, sequence, contains, unordered, mapping ) from testfixtures.command import Command, Run from testfixtures.datetime import mock_datetime, mock_date, mock_time @@ -55,6 +55,7 @@ def __repr__(self) -> str: 'OutputCapture', 'Permutation', 'RangeComparison', + 'ReprComparison', 'Replace', 'Replacer', 'Run', diff --git a/src/testfixtures/comparison.py b/src/testfixtures/comparison.py index e2452db1..ca8551f2 100644 --- a/src/testfixtures/comparison.py +++ b/src/testfixtures/comparison.py @@ -518,6 +518,57 @@ def __ne__(self, other: Any) -> bool: def __repr__(self) -> str: return '' % (self.lower_bound, self.upper_bound) + +class ReprComparison: + """ + An object that can be used in comparisons to check that an object is both + of an expected type and has an expected :func:`repr`. + + :param type_: the type the compared object must be an instance of. + + :param repr_: the :func:`repr` the compared object must have exactly. + + :param match: a regular expression, as either a :class:`str` or a compiled + :class:`re.Pattern`, that the compared object's :func:`repr` + must match. Mutually exclusive with ``repr_``. + """ + @overload + def __init__(self, type_: type, repr_: str): ... + @overload + def __init__(self, type_: type, *, match: str | re.Pattern[str]): ... + + def __init__( + self, + type_: type, + repr_: str | None = None, + *, + match: str | re.Pattern[str] | None = None, + ): + if (repr_ is None) == (match is None): + raise TypeError('provide either repr_ or match') + self.type = type_ + self.repr = repr_ + self.match = match + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, self.type): + return False + if self.match is not None: + return re.search(self.match, repr(other)) is not None + return repr(other) == self.repr + + def __ne__(self, other: Any) -> bool: + return not self == other + + def __repr__(self) -> str: + module = getattr(self.type, '__module__', None) + name = (module + '.' if module else '') + ( + getattr(self.type, '__name__', None) or repr(self.type) + ) + detail = self.repr if self.repr is not None else f'match={self.match!r}' + return f'' + + T = TypeVar('T') @overload diff --git a/tests/test_reprcomparison.py b/tests/test_reprcomparison.py new file mode 100644 index 00000000..0e30e92a --- /dev/null +++ b/tests/test_reprcomparison.py @@ -0,0 +1,102 @@ +import re + +from testfixtures import ReprComparison, ShouldRaise, compare + + +class AClass: + def __repr__(self): + return '' + + +class BClass: + def __repr__(self): + return '' + + +def test_equal_yes_rhs(): + assert AClass() == ReprComparison(AClass, '') + + +def test_equal_yes_lhs(): + assert ReprComparison(AClass, '') == AClass() + + +def test_equal_no_repr_rhs(): + assert not (AClass() == ReprComparison(AClass, '')) + + +def test_equal_no_repr_lhs(): + assert not (ReprComparison(AClass, '') == AClass()) + + +def test_equal_no_type_rhs(): + # BClass deliberately has the same repr as AClass, so only the type differs. + assert repr(BClass()) == '' + assert not (BClass() == ReprComparison(AClass, '')) + + +def test_equal_no_type_lhs(): + assert not (ReprComparison(AClass, '') == BClass()) + + +def test_not_equal_yes(): + assert BClass() != ReprComparison(AClass, '') + + +def test_not_equal_no(): + assert not (AClass() != ReprComparison(AClass, '')) + + +def test_subclass_is_equal(): + class SubClass(AClass): + pass + assert SubClass() == ReprComparison(AClass, '') + + +def test_in_sequence(): + compare((1, 2, AClass()), expected=(1, 2, ReprComparison(AClass, ''))) + + +def test_compare(): + compare(AClass(), expected=ReprComparison(AClass, '')) + + +def test_repr(): + compare('>', + actual=repr(ReprComparison(AClass, ''))) + + +def test_str(): + compare('>', + actual=str(ReprComparison(AClass, ''))) + + +def test_match_str(): + compare(AClass(), expected=ReprComparison(AClass, match=r'')) + + +def test_match_str_no_match(): + assert AClass() != ReprComparison(AClass, match=r'') + + +def test_match_str_wrong_type(): + assert BClass() != ReprComparison(AClass, match=r'') + + +def test_match_compiled_pattern(): + compare(AClass(), expected=ReprComparison(AClass, match=re.compile(r''))) + + +def test_match_repr(): + compare("", + actual=repr(ReprComparison(AClass, match=r''))) + + +def test_neither_repr_nor_match() -> None: + with ShouldRaise(TypeError('provide either expected or match')): + ReprComparison(AClass) # type: ignore[call-overload] + + +def test_both_repr_and_match() -> None: + with ShouldRaise(TypeError('provide either expected or match')): + ReprComparison(AClass, '', match=r'') # type: ignore[call-overload] From 976f21045a2bc85404522157b4a9151df946bd0c Mon Sep 17 00:00:00 2001 From: Chris Withers Date: Mon, 15 Jun 2026 18:57:55 +0100 Subject: [PATCH 04/11] Rename StringComparison to TextComparison, keep old name as alias Frees up the Str* naming space for a StrComparison sibling to ReprComparison, and names the class for what it stands in for (text) rather than how it matches (regex). --- AGENTS.md | 2 +- README.rst | 2 +- docs/api.rst | 7 ++++- docs/command.rst | 4 +-- docs/comparing.rst | 2 +- docs/comparison.rst | 26 ++++++++--------- src/testfixtures/__init__.py | 7 +++-- src/testfixtures/comparison.py | 17 +++++++---- src/testfixtures/outputcapture.py | 8 +++--- tests/test_loguru.py | 4 +-- tests/test_outputcapture.py | 10 +++---- tests/test_stringcomparison.py | 47 ++++++++++++++++--------------- tests/test_twisted.py | 2 +- 13 files changed, 76 insertions(+), 62 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f3d9f309..09f5fa9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ uv build # build sdist + wheel `src/testfixtures/`: all source. Key modules: -- `comparison.py`: `compare()`, `diff()`, `Comparison`, `StringComparison`, `RoundComparison`, etc. +- `comparison.py`: `compare()`, `diff()`, `Comparison`, `TextComparison`, `RoundComparison`, etc. - `replace.py`: `Replacer`, `replace()` decorators - `logcapture.py`: `LogCapture` - `datetime.py`: `mock_datetime`, `mock_date`, `mock_time` diff --git a/README.rst b/README.rst index c26a308f..b2d25cfa 100644 --- a/README.rst +++ b/README.rst @@ -24,7 +24,7 @@ with clear diffs for deeply nested data structures and for objects that don't normally support comparison. This includes first-class support for pandas and polars dataframes and numpy arrays. Flexible placeholder objects and helpers such as ``like``, ``sequence``, ``generator``, ``Comparison``, ``RoundComparison``, -``RangeComparison``, ``StringComparison`` and ``SequenceComparison`` let you assert that only part +``RangeComparison``, ``TextComparison`` and ``SequenceComparison`` let you assert that only part of a value matters, that a number is within a range or rounded to a precision, or that a string matches a pattern. diff --git a/docs/api.rst b/docs/api.rst index 33a35022..ef049953 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -42,9 +42,14 @@ Comparisons .. autoclass:: Subset :members: -.. autoclass:: StringComparison +.. autoclass:: TextComparison :members: +.. py:class:: StringComparison + + Deprecated alias for :class:`TextComparison`. Note this is *not* the same as + ``StrComparison``. + testfixtures.comparison ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/command.rst b/docs/command.rst index dfebfdf7..6263b5d9 100644 --- a/docs/command.rst +++ b/docs/command.rst @@ -42,9 +42,9 @@ This can be tested as follows: We can also test what happens if the required argument is not provided. The program name :mod:`argparse` puts in its messages varies with how the tests are run, so we use a -:class:`~testfixtures.StringComparison` to match it: +:class:`~testfixtures.TextComparison` to match it: ->>> from testfixtures import StringComparison as S +>>> from testfixtures import TextComparison as S >>> Command(main).run().check( ... output=S( ... r'usage: .+ \[-h\] message\n' diff --git a/docs/comparing.rst b/docs/comparing.rst index 584968de..0c1421f2 100644 --- a/docs/comparing.rst +++ b/docs/comparing.rst @@ -624,7 +624,7 @@ following: trailing_whitespace=False ) -See :ref:`StringComparison ` to assert that a string matches a +See :ref:`TextComparison ` to assert that a string matches a regular expression instead of comparing it exactly. .. _compare-datetime: diff --git a/docs/comparison.rst b/docs/comparison.rst index be6c9bd3..c3a09aef 100644 --- a/docs/comparison.rst +++ b/docs/comparison.rst @@ -27,7 +27,7 @@ __ https://mypy-lang.org/ Some expectations have no helper and are used as objects directly: :ref:`RangeComparison ` and :ref:`RoundComparison ` -for numbers, and :ref:`StringComparison ` for matching against a +for numbers, and :ref:`TextComparison ` for matching against a regular expression. The examples below use these dataclasses: @@ -73,7 +73,7 @@ than partially. When passed a regular expression pattern, either as a string or an already compiled :class:`re.Pattern`, :func:`~testfixtures.like` returns a -:class:`StringComparison` typed as a :class:`str`: +:class:`TextComparison` typed as a :class:`str`: >>> expected_str: str = like(r'Starting thread \d+') >>> compare(expected_str, actual='Starting thread 132356') @@ -641,17 +641,17 @@ equal to any object that is an instance of the supplied type and whose compare(expected=ReprComparison(KeyError, "KeyError('foo')"), actual=KeyError('foo')) -.. _stringcomparison: +.. _textcomparison: -``StringComparison`` -~~~~~~~~~~~~~~~~~~~~ +``TextComparison`` +~~~~~~~~~~~~~~~~~~ When comparing sequences of strings, particularly those coming from things like the python logging package, you often end up wanting to express a requirement that one string should be almost like another, or maybe fit a particular pattern expressed as a regular expression. -For these situations, you can use :class:`StringComparison` objects +For these situations, you can use :class:`TextComparison` objects wherever you would use normal strings, and they will compare equal to any string that matches the regular expression they are created with. @@ -659,10 +659,10 @@ Here's an example: .. code-block:: python - from testfixtures import compare, StringComparison + from testfixtures import compare, TextComparison compare( - expected=StringComparison(r'Starting thread \d+'), + expected=TextComparison(r'Starting thread \d+'), actual='Starting thread 132356', ) @@ -673,7 +673,7 @@ If you need to specify flags, this can be done in one of three ways: .. code-block:: python compare( - expected=StringComparison(".*BaR", dotall=True, ignorecase=True), + expected=TextComparison(".*BaR", dotall=True, ignorecase=True), actual="foo\nbar", ) @@ -684,7 +684,7 @@ If you need to specify flags, this can be done in one of three ways: import re compare( - expected=StringComparison(".*BaR", re.DOTALL|re.IGNORECASE), + expected=TextComparison(".*BaR", re.DOTALL|re.IGNORECASE), actual="foo\nbar", ) @@ -693,17 +693,17 @@ If you need to specify flags, this can be done in one of three ways: .. code-block:: python compare( - expected=StringComparison("(?s:.*bar)"), + expected=TextComparison("(?s:.*bar)"), actual="foo\nbar", ) -You can also construct a :class:`StringComparison` from an already compiled +You can also construct a :class:`TextComparison` from an already compiled :class:`re.Pattern`: .. code-block:: python import re compare( - expected=StringComparison(re.compile(".*BaR", re.DOTALL|re.IGNORECASE)), + expected=TextComparison(re.compile(".*BaR", re.DOTALL|re.IGNORECASE)), actual="foo\nbar", ) diff --git a/src/testfixtures/__init__.py b/src/testfixtures/__init__.py index db3d86c7..622bff79 100644 --- a/src/testfixtures/__init__.py +++ b/src/testfixtures/__init__.py @@ -15,9 +15,9 @@ def __repr__(self) -> str: from testfixtures.comparers import diff, safe_pformat, safe_repr from testfixtures.comparing import compare, register from testfixtures.comparison import ( - Comparison, StringComparison, RoundComparison, RangeComparison, - ReprComparison, SequenceComparison, Subset, Permutation, MappingComparison, - like, sequence, contains, unordered, mapping + Comparison, TextComparison, StringComparison, RoundComparison, + RangeComparison, ReprComparison, SequenceComparison, Subset, Permutation, + MappingComparison, like, sequence, contains, unordered, mapping ) from testfixtures.command import Command, Run from testfixtures.datetime import mock_datetime, mock_date, mock_time @@ -67,6 +67,7 @@ def __repr__(self) -> str: 'ShouldWarn', 'Subset', 'StringComparison', + 'TextComparison', 'TempDirectory', 'TempDir', 'compare', diff --git a/src/testfixtures/comparison.py b/src/testfixtures/comparison.py index ca8551f2..83de043a 100644 --- a/src/testfixtures/comparison.py +++ b/src/testfixtures/comparison.py @@ -410,7 +410,7 @@ def __ne__(self, other: Any) -> bool: return False -class StringComparison: +class TextComparison: """ An object that can be used in comparisons of expected and actual strings where the string expected matches a pattern rather than a @@ -418,13 +418,13 @@ class StringComparison: :param regex_source: A string containing the source for a regular expression that will be used whenever this - :class:`StringComparison` is compared with + :class:`TextComparison` is compared with any :class:`str` instance, or an already compiled :class:`re.Pattern`. :param flags: Flags passed to :func:`re.compile`. - :param flag_names: See the :ref:`examples `. + :param flag_names: See the :ref:`examples `. """ @overload @@ -469,6 +469,11 @@ def __gt__(self, other: Any) -> bool: return self.re.pattern > other +# Deprecated alias for TextComparison. Note this is NOT the same as +# StrComparison, which checks an object's type and its str(). +StringComparison = TextComparison + + class RoundComparison: """ An object that can be used in comparisons of expected and actual @@ -588,15 +593,15 @@ def like(t: type[T] | str | re.Pattern[str], **attributes: Any) -> T | str: compatible with strict type checkers like mypy. If passed a :class:`str` pattern or a compiled :class:`re.Pattern`, a - :class:`StringComparison` typed as a :class:`str` is returned instead. + :class:`TextComparison` typed as a :class:`str` is returned instead. :param t: The type to compare against, or a regular expression pattern. :param attributes: Keyword arguments specifying the attributes to check. :return: A :class:`Comparison` typed as the input type, or a - :class:`StringComparison` typed as a :class:`str`. + :class:`TextComparison` typed as a :class:`str`. """ if isinstance(t, (str, re.Pattern)): - return cast(str, StringComparison(t)) + return cast(str, TextComparison(t)) return Comparison(t, attribute_dict=attributes, partial=True) # type: ignore[return-value] diff --git a/src/testfixtures/outputcapture.py b/src/testfixtures/outputcapture.py index 6e3db618..e54658b6 100644 --- a/src/testfixtures/outputcapture.py +++ b/src/testfixtures/outputcapture.py @@ -5,7 +5,7 @@ from typing import Self, Any, IO from .comparing import compare -from .comparison import StringComparison +from .comparison import TextComparison class OutputCapture: @@ -109,9 +109,9 @@ def captured(self) -> str: def compare( self, - expected: str | StringComparison = '', - stdout: str | StringComparison = '', - stderr: str | StringComparison = '', + expected: str | TextComparison = '', + stdout: str | TextComparison = '', + stderr: str | TextComparison = '', raises: bool = True, ) -> str | None: """ diff --git a/tests/test_loguru.py b/tests/test_loguru.py index 77cc45b4..41918c43 100644 --- a/tests/test_loguru.py +++ b/tests/test_loguru.py @@ -10,7 +10,7 @@ ShouldNotWarn, ShouldRaise, ShouldWarn, - StringComparison, + TextComparison, compare, ) @@ -169,7 +169,7 @@ def test_serialize(self): def write_call(message: str) -> Call: return call.write( - StringComparison( + TextComparison( ( r'{"text": ".+ \| INFO +\| tests.test_loguru:test_serialize:\d+' r' - MESSAGE\\n", "record": {"elapsed": {"repr": "0.+", "seconds": .+}, ' diff --git a/tests/test_outputcapture.py b/tests/test_outputcapture.py index 0712f2f4..8c72b7b3 100644 --- a/tests/test_outputcapture.py +++ b/tests/test_outputcapture.py @@ -3,7 +3,7 @@ from unittest import TestCase from _pytest.capture import CaptureFixture -from testfixtures import OutputCapture, compare, StringComparison +from testfixtures import OutputCapture, compare, TextComparison from .test_compare import CompareHelper @@ -103,16 +103,16 @@ def test_double_enable(self) -> None: self.assertTrue(sys.stdout is o_out) self.assertTrue(sys.stderr is o_err) - def test_compare_expected_is_stringcomparison(self) -> None: + def test_compare_expected_is_textcomparison(self) -> None: with OutputCapture() as output: print('foo') - output.compare(StringComparison(r'^foo\Z')) + output.compare(TextComparison(r'^foo\Z')) - def test_compare_stdout_and_stdderr_are_stringcomparisons(self) -> None: + def test_compare_stdout_and_stdderr_are_textcomparisons(self) -> None: with OutputCapture(separate=True) as output: print('hello', file=sys.stdout) print('world', file=sys.stderr) - output.compare(stdout=StringComparison(r'^hello\Z'), stderr=StringComparison(r'^world\Z')) + output.compare(stdout=TextComparison(r'^hello\Z'), stderr=TextComparison(r'^world\Z')) def test_compare_raises_false_no_difference(self) -> None: with OutputCapture() as output: diff --git a/tests/test_stringcomparison.py b/tests/test_stringcomparison.py index 9644634f..d5bd61fb 100644 --- a/tests/test_stringcomparison.py +++ b/tests/test_stringcomparison.py @@ -1,31 +1,34 @@ import re -from testfixtures import StringComparison as S, compare +from testfixtures import StringComparison, TextComparison, compare from unittest import TestCase class Tests(TestCase): + def test_old_name_is_alias(self): + assert StringComparison is TextComparison + def test_equal_yes(self): - self.assertTrue('on 40220' == S(r'on \d+')) + self.assertTrue('on 40220' == StringComparison(r'on \d+')) def test_equal_no(self): - self.assertFalse('on xxx' == S(r'on \d+')) + self.assertFalse('on xxx' == StringComparison(r'on \d+')) def test_not_equal_yes(self): - self.assertFalse('on 40220' != S(r'on \d+')) + self.assertFalse('on 40220' != StringComparison(r'on \d+')) def test_not_equal_no(self): - self.assertTrue('on xxx' != S(r'on \d+')) + self.assertTrue('on xxx' != StringComparison(r'on \d+')) def test_comp_in_sequence(self): - self.assertTrue((1, 2, 'on 40220') == (1, 2, S(r'on \d+'))) + self.assertTrue((1, 2, 'on 40220') == (1, 2, StringComparison(r'on \d+'))) def test_not_string(self): - self.assertFalse(40220 == S(r'on \d+')) + self.assertFalse(40220 == StringComparison(r'on \d+')) def test_not_string_returns_not_implemented(self): - assert S(r'x').__eq__(40220) is NotImplemented + assert StringComparison(r'x').__eq__(40220) is NotImplemented def test_not_string_reflected_equality(self): class MatchesAnything: @@ -34,34 +37,34 @@ def __eq__(self, other): def __ne__(self, other): return False other = MatchesAnything() - assert S(r'x') == other - assert other == S(r'x') - assert not (S(r'x') != other) - assert not (other != S(r'x')) + assert StringComparison(r'x') == other + assert other == StringComparison(r'x') + assert not (StringComparison(r'x') != other) + assert not (other != StringComparison(r'x')) def test_repr(self): - compare('', repr(S(r'on \d+'))) + compare('', repr(StringComparison(r'on \d+'))) def test_str(self): - compare('', str(S(r'on \d+'))) + compare('', str(StringComparison(r'on \d+'))) def test_sort(self): - a = S('a') - b = S('b') - c = S('c') + a = StringComparison('a') + b = StringComparison('b') + c = StringComparison('c') compare(sorted(('d', c, 'e', a, 'a1', b)), expected=[a, 'a1', b, c, 'd', 'e']) def test_flags_argument(self): - compare(S(".*bar", re.DOTALL), actual="foo\nbar") + compare(StringComparison(".*bar", re.DOTALL), actual="foo\nbar") def test_flags_parameter(self): - compare(S(".*bar", flags=re.DOTALL), actual="foo\nbar") + compare(StringComparison(".*bar", flags=re.DOTALL), actual="foo\nbar") def test_flags_names(self): - compare(S(".*BaR", dotall=True, ignorecase=True), actual="foo\nbar") + compare(StringComparison(".*BaR", dotall=True, ignorecase=True), actual="foo\nbar") def test_compiled_pattern(self): - compare(S(re.compile(r'on \d+')), actual='on 40220') + compare(StringComparison(re.compile(r'on \d+')), actual='on 40220') def test_compiled_pattern_with_flags(self): - compare(S(re.compile(".*BaR", re.DOTALL | re.IGNORECASE)), actual="foo\nbar") + compare(StringComparison(re.compile(".*BaR", re.DOTALL | re.IGNORECASE)), actual="foo\nbar") diff --git a/tests/test_twisted.py b/tests/test_twisted.py index 27241e79..f25bc3a1 100644 --- a/tests/test_twisted.py +++ b/tests/test_twisted.py @@ -6,7 +6,7 @@ from twisted.python.failure import Failure from twisted.trial.unittest import TestCase -from testfixtures import compare, ShouldRaise, StringComparison as S, ShouldAssert +from testfixtures import compare, ShouldRaise, TextComparison as S, ShouldAssert from testfixtures import LogCapture from testfixtures.twisted import LogCapture as TwistedLogCapture, INFO, WARN, TwistedSource From 9b52bb3dc046e31ce6cab126f4922b4da0c52c6b Mon Sep 17 00:00:00 2001 From: Chris Withers Date: Mon, 15 Jun 2026 19:01:12 +0100 Subject: [PATCH 05/11] Add StrComparison, mirroring ReprComparison but on str() The str()/repr() pair: same type-plus-rendering check, for objects whose str() is the stable thing worth asserting. --- docs/api.rst | 5 +- docs/comparison.rst | 23 ++++++++ src/testfixtures/__init__.py | 5 +- src/testfixtures/comparison.py | 50 ++++++++++++++++ tests/test_strcomparison.py | 102 +++++++++++++++++++++++++++++++++ 5 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 tests/test_strcomparison.py diff --git a/docs/api.rst b/docs/api.rst index ef049953..fb8ab2d8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -28,6 +28,9 @@ Comparisons .. autoclass:: ReprComparison :members: +.. autoclass:: StrComparison + :members: + .. autoclass:: SequenceComparison :members: @@ -48,7 +51,7 @@ Comparisons .. py:class:: StringComparison Deprecated alias for :class:`TextComparison`. Note this is *not* the same as - ``StrComparison``. + :class:`StrComparison`. testfixtures.comparison diff --git a/docs/comparison.rst b/docs/comparison.rst index c3a09aef..6f56cdb4 100644 --- a/docs/comparison.rst +++ b/docs/comparison.rst @@ -641,6 +641,29 @@ equal to any object that is an instance of the supplied type and whose compare(expected=ReprComparison(KeyError, "KeyError('foo')"), actual=KeyError('foo')) +.. _strcomparison: + +``StrComparison`` +~~~~~~~~~~~~~~~~~ + +:class:`StrComparison` works just like :class:`ReprComparison`, but checks the +object's :class:`str` instead of its :func:`repr`: + +.. code-block:: python + + from testfixtures import compare, StrComparison + + compare(expected=StrComparison(KeyError, "'foo'"), actual=KeyError('foo')) + +Both :class:`ReprComparison` and :class:`StrComparison` can also match against a +regular expression instead of an exact string by passing ``match``: + +.. code-block:: python + + from testfixtures import compare, StrComparison + + compare(expected=StrComparison(KeyError, match=r"'\w+'"), actual=KeyError('foo')) + .. _textcomparison: ``TextComparison`` diff --git a/src/testfixtures/__init__.py b/src/testfixtures/__init__.py index 622bff79..1516e224 100644 --- a/src/testfixtures/__init__.py +++ b/src/testfixtures/__init__.py @@ -16,8 +16,8 @@ def __repr__(self) -> str: from testfixtures.comparing import compare, register from testfixtures.comparison import ( Comparison, TextComparison, StringComparison, RoundComparison, - RangeComparison, ReprComparison, SequenceComparison, Subset, Permutation, - MappingComparison, like, sequence, contains, unordered, mapping + RangeComparison, ReprComparison, StrComparison, SequenceComparison, Subset, + Permutation, MappingComparison, like, sequence, contains, unordered, mapping ) from testfixtures.command import Command, Run from testfixtures.datetime import mock_datetime, mock_date, mock_time @@ -66,6 +66,7 @@ def __repr__(self) -> str: 'ShouldNotWarn', 'ShouldWarn', 'Subset', + 'StrComparison', 'StringComparison', 'TextComparison', 'TempDirectory', diff --git a/src/testfixtures/comparison.py b/src/testfixtures/comparison.py index 83de043a..2fc1f958 100644 --- a/src/testfixtures/comparison.py +++ b/src/testfixtures/comparison.py @@ -574,6 +574,56 @@ def __repr__(self) -> str: return f'' +class StrComparison: + """ + An object that can be used in comparisons to check that an object is both + of an expected type and has an expected :class:`str`. + + :param type_: the type the compared object must be an instance of. + + :param str_: the :class:`str` the compared object must have exactly. + + :param match: a regular expression, as either a :class:`str` or a compiled + :class:`re.Pattern`, that the compared object's :class:`str` + must match. Mutually exclusive with ``str_``. + """ + @overload + def __init__(self, type_: type, str_: str): ... + @overload + def __init__(self, type_: type, *, match: str | re.Pattern[str]): ... + + def __init__( + self, + type_: type, + str_: str | None = None, + *, + match: str | re.Pattern[str] | None = None, + ): + if (str_ is None) == (match is None): + raise TypeError('provide either str_ or match') + self.type = type_ + self.str = str_ + self.match = match + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, self.type): + return False + if self.match is not None: + return re.search(self.match, str(other)) is not None + return str(other) == self.str + + def __ne__(self, other: Any) -> bool: + return not self == other + + def __repr__(self) -> str: + module = getattr(self.type, '__module__', None) + name = (module + '.' if module else '') + ( + getattr(self.type, '__name__', None) or repr(self.type) + ) + detail = self.str if self.str is not None else f'match={self.match!r}' + return f'' + + T = TypeVar('T') @overload diff --git a/tests/test_strcomparison.py b/tests/test_strcomparison.py new file mode 100644 index 00000000..db1630a6 --- /dev/null +++ b/tests/test_strcomparison.py @@ -0,0 +1,102 @@ +import re + +from testfixtures import StrComparison, ShouldRaise, compare + + +class AClass: + def __str__(self): + return 'an A' + + +class BClass: + def __str__(self): + return 'an A' + + +def test_equal_yes_rhs(): + assert AClass() == StrComparison(AClass, 'an A') + + +def test_equal_yes_lhs(): + assert StrComparison(AClass, 'an A') == AClass() + + +def test_equal_no_str_rhs(): + assert not (AClass() == StrComparison(AClass, 'other')) + + +def test_equal_no_str_lhs(): + assert not (StrComparison(AClass, 'other') == AClass()) + + +def test_equal_no_type_rhs(): + # BClass deliberately has the same str as AClass, so only the type differs. + assert str(BClass()) == 'an A' + assert not (BClass() == StrComparison(AClass, 'an A')) + + +def test_equal_no_type_lhs(): + assert not (StrComparison(AClass, 'an A') == BClass()) + + +def test_not_equal_yes(): + assert BClass() != StrComparison(AClass, 'an A') + + +def test_not_equal_no(): + assert not (AClass() != StrComparison(AClass, 'an A')) + + +def test_subclass_is_equal(): + class SubClass(AClass): + pass + assert SubClass() == StrComparison(AClass, 'an A') + + +def test_in_sequence(): + compare((1, 2, AClass()), expected=(1, 2, StrComparison(AClass, 'an A'))) + + +def test_compare(): + compare(AClass(), expected=StrComparison(AClass, 'an A')) + + +def test_repr(): + compare('', + actual=repr(StrComparison(AClass, 'an A'))) + + +def test_str(): + compare('', + actual=str(StrComparison(AClass, 'an A'))) + + +def test_match_str(): + compare(AClass(), expected=StrComparison(AClass, match=r'an \w')) + + +def test_match_str_no_match(): + assert AClass() != StrComparison(AClass, match=r'a B') + + +def test_match_str_wrong_type(): + assert BClass() != StrComparison(AClass, match=r'an \w') + + +def test_match_compiled_pattern(): + compare(AClass(), expected=StrComparison(AClass, match=re.compile(r'an \w'))) + + +def test_match_repr(): + compare("", + actual=repr(StrComparison(AClass, match=r'an .'))) + + +def test_neither_str_nor_match() -> None: + with ShouldRaise(TypeError('provide either expected or match')): + StrComparison(AClass) # type: ignore[call-overload] + + +def test_both_str_and_match() -> None: + with ShouldRaise(TypeError('provide either expected or match')): + StrComparison(AClass, 'an A', match=r'an \w') # type: ignore[call-overload] From b4620b9b450a351b70e49ff6cf78aea0dfa208be Mon Sep 17 00:00:00 2001 From: Chris Withers Date: Mon, 15 Jun 2026 19:27:23 +0100 Subject: [PATCH 06/11] Factor out shared comparison machinery --- src/testfixtures/comparers.py | 11 ++-- src/testfixtures/comparison.py | 107 ++++++++++++--------------------- src/testfixtures/resolve.py | 7 +++ tests/test_comparison.py | 27 ++++++--- 4 files changed, 72 insertions(+), 80 deletions(-) diff --git a/src/testfixtures/comparers.py b/src/testfixtures/comparers.py index 4fdce6aa..37db418b 100644 --- a/src/testfixtures/comparers.py +++ b/src/testfixtures/comparers.py @@ -18,6 +18,7 @@ from testfixtures import not_there from testfixtures.mock import parent_name, _Call +from testfixtures.resolve import type_name if TYPE_CHECKING: from .comparing import CompareContext @@ -62,7 +63,7 @@ def safe_repr(obj: Any) -> str: return repr(obj) except Exception as e: type_ = type(obj) - type_name = type_.__name__ + cls_name = type_.__name__ match obj: case list(): return '[' + ', '.join(safe_repr(e) for e in obj) + ']' @@ -75,18 +76,18 @@ def safe_repr(obj: Any) -> str: return '{' + body + '}' case frozenset(): if not obj: - return type_name - return type_name + '({' + ', '.join(safe_repr(e) for e in obj) + '})' + return cls_name + return cls_name + '({' + ', '.join(safe_repr(e) for e in obj) + '})' case set(): if not obj: - return type_name + '()' + return cls_name + '()' return '{' + ', '.join(safe_repr(e) for e in obj) + '}' case _: try: detail = f'{type(e).__name__}: {e}' except: detail = type(e).__name__ - return f'' + return f'' def safe_pformat(obj: Any) -> str: diff --git a/src/testfixtures/comparison.py b/src/testfixtures/comparison.py index 2fc1f958..614095b3 100644 --- a/src/testfixtures/comparison.py +++ b/src/testfixtures/comparison.py @@ -1,4 +1,5 @@ import re +from abc import ABC, abstractmethod from collections import OrderedDict from functools import reduce from operator import __or__ @@ -20,7 +21,7 @@ _extract_attrs, AlreadySeen, _compare_mapping, safe_repr, compare_simple ) from testfixtures.comparing import CompareContext, compare, register -from testfixtures.resolve import resolve +from testfixtures.resolve import resolve, type_name from testfixtures.utils import indent @@ -109,7 +110,7 @@ def __ne__(self, other: Any) -> bool: other_type = type(other) if self.expected_type is not other_type: - self.failed = f'wrong type: {other_type.__module__}.{other_type.__qualname__}' + self.failed = 'wrong type: ' + type_name(other_type) return True if self.expected_attributes is None: @@ -140,12 +141,7 @@ def __ne__(self, other: Any) -> bool: return bool(self.failed) def name(self) -> str: - name = 'C:' - module = getattr(self.expected_type, '__module__', None) - if module: - name = name + module + '.' - name += (getattr(self.expected_type, '__name__', None) or repr(self.expected_type)) - return name + return 'C:' + type_name(self.expected_type) def body(self) -> str: if self.expected_attributes: @@ -524,104 +520,81 @@ def __repr__(self) -> str: return '' % (self.lower_bound, self.upper_bound) -class ReprComparison: +class RenderingComparison(ABC): + """ + A base for comparisons that check an object's type and a string rendering + of it produced by :attr:`render`. """ - An object that can be used in comparisons to check that an object is both - of an expected type and has an expected :func:`repr`. - - :param type_: the type the compared object must be an instance of. - :param repr_: the :func:`repr` the compared object must have exactly. + @staticmethod + @abstractmethod + def render(other: object, /) -> str: + """The callable used to render the compared object as a string.""" - :param match: a regular expression, as either a :class:`str` or a compiled - :class:`re.Pattern`, that the compared object's :func:`repr` - must match. Mutually exclusive with ``repr_``. - """ @overload - def __init__(self, type_: type, repr_: str): ... + def __init__(self, type_: type, expected: str): ... @overload def __init__(self, type_: type, *, match: str | re.Pattern[str]): ... def __init__( self, type_: type, - repr_: str | None = None, + expected: str | None = None, *, match: str | re.Pattern[str] | None = None, ): - if (repr_ is None) == (match is None): - raise TypeError('provide either repr_ or match') + if (expected is None) == (match is None): + raise TypeError('provide either expected or match') self.type = type_ - self.repr = repr_ + self.expected = expected self.match = match def __eq__(self, other: Any) -> bool: if not isinstance(other, self.type): return False + rendered = self.render(other) if self.match is not None: - return re.search(self.match, repr(other)) is not None - return repr(other) == self.repr + return re.search(self.match, rendered) is not None + return rendered == self.expected def __ne__(self, other: Any) -> bool: return not self == other def __repr__(self) -> str: - module = getattr(self.type, '__module__', None) - name = (module + '.' if module else '') + ( - getattr(self.type, '__name__', None) or repr(self.type) - ) - detail = self.repr if self.repr is not None else f'match={self.match!r}' - return f'' + detail = self.expected if self.expected is not None else f'match={self.match!r}' + return f'<{type(self).__name__}: {type_name(self.type)}: {detail}>' -class StrComparison: +class ReprComparison(RenderingComparison): """ An object that can be used in comparisons to check that an object is both - of an expected type and has an expected :class:`str`. + of an expected type and has an expected :func:`repr`. :param type_: the type the compared object must be an instance of. - :param str_: the :class:`str` the compared object must have exactly. + :param expected: the :func:`repr` the compared object must have exactly. :param match: a regular expression, as either a :class:`str` or a compiled - :class:`re.Pattern`, that the compared object's :class:`str` - must match. Mutually exclusive with ``str_``. + :class:`re.Pattern`, that the compared object's :func:`repr` + must match. Mutually exclusive with ``expected``. """ - @overload - def __init__(self, type_: type, str_: str): ... - @overload - def __init__(self, type_: type, *, match: str | re.Pattern[str]): ... + render = staticmethod(repr) - def __init__( - self, - type_: type, - str_: str | None = None, - *, - match: str | re.Pattern[str] | None = None, - ): - if (str_ is None) == (match is None): - raise TypeError('provide either str_ or match') - self.type = type_ - self.str = str_ - self.match = match - def __eq__(self, other: Any) -> bool: - if not isinstance(other, self.type): - return False - if self.match is not None: - return re.search(self.match, str(other)) is not None - return str(other) == self.str +class StrComparison(RenderingComparison): + """ + An object that can be used in comparisons to check that an object is both + of an expected type and has an expected :class:`str`. - def __ne__(self, other: Any) -> bool: - return not self == other + :param type_: the type the compared object must be an instance of. - def __repr__(self) -> str: - module = getattr(self.type, '__module__', None) - name = (module + '.' if module else '') + ( - getattr(self.type, '__name__', None) or repr(self.type) - ) - detail = self.str if self.str is not None else f'match={self.match!r}' - return f'' + :param expected: the :class:`str` the compared object must have exactly. + + :param match: a regular expression, as either a :class:`str` or a compiled + :class:`re.Pattern`, that the compared object's :class:`str` + must match. Mutually exclusive with ``expected``. + """ + render = staticmethod(str) T = TypeVar('T') diff --git a/src/testfixtures/resolve.py b/src/testfixtures/resolve.py index 7c65b457..c3c58856 100644 --- a/src/testfixtures/resolve.py +++ b/src/testfixtures/resolve.py @@ -65,6 +65,13 @@ def resolve(dotted_name: str, container: Any | None = None, sep: str = '.') -> R return Resolved(container, setter, name, found) +def type_name(type_: Any) -> str: + module = getattr(type_, '__module__', None) + prefix = module + '.' if module else '' + name = getattr(type_, '__qualname__', None) or getattr(type_, '__name__', None) + return prefix + (name or repr(type_)) + + class _Reference: @classmethod diff --git a/tests/test_comparison.py b/tests/test_comparison.py index 00f317eb..3984d2e5 100644 --- a/tests/test_comparison.py +++ b/tests/test_comparison.py @@ -461,10 +461,12 @@ def prop(self): compare_repr( c, "\n" - "\n" + ".SomeClass(failed)>\n" "attributes differ:\n" "'prop': 2 (Comparison) != 1 (actual)\n" - "") + ".SomeClass>") def test_property_not_equal(self): self.run_property_not_equal_test(partial=False) @@ -499,11 +501,13 @@ class SomeClass: pass compare_repr( c, "\n" - "\n" + ".SomeClass(failed)>\n" "attributes differ:\n" "'method': (Comparison)" " != (actual)\n" - "" + ".SomeClass>" ) def test_method_not_equal(self): @@ -650,10 +654,12 @@ def __eq__(self, other): c == Annoying() compare_repr( c, - '\n\n' + '\n.Annoying(failed)>\n' 'attributes differ:\n' "'eq_called': 1 (Comparison) != 0 (actual)\n" - '' + '.Annoying>' ) def test_importerror(self): @@ -682,9 +688,10 @@ def test_no_name(self): class NoName: pass NoName.__name__ = '' + NoName.__qualname__ = '' NoName.__module__ = '' c = C(NoName) - self.assertEqual(repr(c), ".NoName'>>") + self.assertEqual(repr(c), ">") def test_missing_expected_attribute_not_partial(self): @@ -739,7 +746,11 @@ class Holder: compare( repr(C(Holder, attr=Broken())), - expected=f'attr: {Broken.marker}', + expected=( + '.Holder>' + f'attr: {Broken.marker}' + ), ) def test_mapping_comparison_body_broken_value(self): From c8357b3f1f41ebb755a183776c325e38506ef6d2 Mon Sep 17 00:00:00 2001 From: Chris Withers Date: Tue, 30 Jun 2026 08:52:30 +0100 Subject: [PATCH 07/11] Match the exact type in ReprComparison and StrComparison Subclasses render differently and usually mean a different object, so an exact type check is the safer default; reach for a plain Comparison when subclass matching is wanted. Co-Authored-By: Claude Opus 4.8 --- docs/comparison.rst | 2 +- src/testfixtures/comparison.py | 8 +++++--- tests/test_reprcomparison.py | 5 +++-- tests/test_strcomparison.py | 5 +++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/comparison.rst b/docs/comparison.rst index 6f56cdb4..b408abd5 100644 --- a/docs/comparison.rst +++ b/docs/comparison.rst @@ -632,7 +632,7 @@ it is of the expected type and has the expected :func:`repr`, particularly when the object's type doesn't support meaningful equality. For these situations, you can use :class:`ReprComparison` objects, which compare -equal to any object that is an instance of the supplied type and whose +equal to any object that is exactly of the supplied type and whose :func:`repr` matches the supplied string: .. code-block:: python diff --git a/src/testfixtures/comparison.py b/src/testfixtures/comparison.py index 614095b3..3b803be9 100644 --- a/src/testfixtures/comparison.py +++ b/src/testfixtures/comparison.py @@ -550,7 +550,7 @@ def __init__( self.match = match def __eq__(self, other: Any) -> bool: - if not isinstance(other, self.type): + if type(other) is not self.type: return False rendered = self.render(other) if self.match is not None: @@ -570,7 +570,8 @@ class ReprComparison(RenderingComparison): An object that can be used in comparisons to check that an object is both of an expected type and has an expected :func:`repr`. - :param type_: the type the compared object must be an instance of. + :param type_: the type the compared object must be exactly; subclasses do + not match. :param expected: the :func:`repr` the compared object must have exactly. @@ -586,7 +587,8 @@ class StrComparison(RenderingComparison): An object that can be used in comparisons to check that an object is both of an expected type and has an expected :class:`str`. - :param type_: the type the compared object must be an instance of. + :param type_: the type the compared object must be exactly; subclasses do + not match. :param expected: the :class:`str` the compared object must have exactly. diff --git a/tests/test_reprcomparison.py b/tests/test_reprcomparison.py index 0e30e92a..ec45cbef 100644 --- a/tests/test_reprcomparison.py +++ b/tests/test_reprcomparison.py @@ -47,10 +47,11 @@ def test_not_equal_no(): assert not (AClass() != ReprComparison(AClass, '')) -def test_subclass_is_equal(): +def test_subclass_not_equal(): + # The type must match exactly; a subclass is not enough. class SubClass(AClass): pass - assert SubClass() == ReprComparison(AClass, '') + assert not (SubClass() == ReprComparison(AClass, '')) def test_in_sequence(): diff --git a/tests/test_strcomparison.py b/tests/test_strcomparison.py index db1630a6..9a789e66 100644 --- a/tests/test_strcomparison.py +++ b/tests/test_strcomparison.py @@ -47,10 +47,11 @@ def test_not_equal_no(): assert not (AClass() != StrComparison(AClass, 'an A')) -def test_subclass_is_equal(): +def test_subclass_not_equal(): + # The type must match exactly; a subclass is not enough. class SubClass(AClass): pass - assert SubClass() == StrComparison(AClass, 'an A') + assert not (SubClass() == StrComparison(AClass, 'an A')) def test_in_sequence(): From a98cfd28a6023d48934386486d2782c22abd355c Mon Sep 17 00:00:00 2001 From: Chris Withers Date: Tue, 30 Jun 2026 08:58:57 +0100 Subject: [PATCH 08/11] Add repr_like and str_like, the typed wrappers for Repr/StrComparison These mirror like(): they build a ReprComparison or StrComparison but are typed to return the compared type, so they slot into strictly typed code. Co-Authored-By: Claude Opus 4.8 --- docs/api.rst | 4 +++ docs/comparison.rst | 11 ++++++ src/testfixtures/__init__.py | 5 ++- src/testfixtures/comparison.py | 64 ++++++++++++++++++++++++++++++++++ tests/test_comparison_typed.py | 24 ++++++++++++- 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index fb8ab2d8..07761336 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -28,9 +28,13 @@ Comparisons .. autoclass:: ReprComparison :members: +.. autofunction:: testfixtures.repr_like + .. autoclass:: StrComparison :members: +.. autofunction:: testfixtures.str_like + .. autoclass:: SequenceComparison :members: diff --git a/docs/comparison.rst b/docs/comparison.rst index b408abd5..2d0027ff 100644 --- a/docs/comparison.rst +++ b/docs/comparison.rst @@ -78,6 +78,17 @@ compiled :class:`re.Pattern`, :func:`~testfixtures.like` returns a >>> expected_str: str = like(r'Starting thread \d+') >>> compare(expected_str, actual='Starting thread 132356') +The :func:`~testfixtures.repr_like` and :func:`~testfixtures.str_like` functions +do the same for :class:`ReprComparison` and :class:`StrComparison`, returning a +value typed as the compared type while checking its :func:`repr` or +:class:`str`: + +>>> from testfixtures import repr_like, str_like +>>> by_repr: KeyError = repr_like(KeyError, "KeyError('foo')") +>>> compare(by_repr, actual=KeyError('foo')) +>>> by_str: KeyError = str_like(KeyError, "'foo'") +>>> compare(by_str, actual=KeyError('foo')) + Sequence helpers ---------------- diff --git a/src/testfixtures/__init__.py b/src/testfixtures/__init__.py index 1516e224..bb9339de 100644 --- a/src/testfixtures/__init__.py +++ b/src/testfixtures/__init__.py @@ -17,7 +17,8 @@ def __repr__(self) -> str: from testfixtures.comparison import ( Comparison, TextComparison, StringComparison, RoundComparison, RangeComparison, ReprComparison, StrComparison, SequenceComparison, Subset, - Permutation, MappingComparison, like, sequence, contains, unordered, mapping + Permutation, MappingComparison, like, repr_like, str_like, sequence, + contains, unordered, mapping ) from testfixtures.command import Command, Run from testfixtures.datetime import mock_datetime, mock_date, mock_time @@ -87,12 +88,14 @@ def __repr__(self) -> str: 'replace_in_environ', 'replace_on_class', 'replace_in_module', + 'repr_like', 'resolve', 'safe_pformat', 'safe_repr', 'sequence', 'should_raise', 'singleton', + 'str_like', 'tempdir', 'test_date', 'test_datetime', diff --git a/src/testfixtures/comparison.py b/src/testfixtures/comparison.py index 3b803be9..fa54b9bc 100644 --- a/src/testfixtures/comparison.py +++ b/src/testfixtures/comparison.py @@ -630,6 +630,70 @@ def like(t: type[T] | str | re.Pattern[str], **attributes: Any) -> T | str: return Comparison(t, attribute_dict=attributes, partial=True) # type: ignore[return-value] +@overload +def repr_like(type_: type[T], expected: str) -> T: ... + + +@overload +def repr_like(type_: type[T], *, match: str | re.Pattern[str]) -> T: ... + + +def repr_like( + type_: type[T], + expected: str | None = None, + *, + match: str | re.Pattern[str] | None = None, +) -> T: + """ + Create a type-safe :class:`ReprComparison` for use in strictly typed code. + + This is a convenience function that creates a :class:`ReprComparison` but is + typed to return the type being compared, making it compatible with strict + type checkers like mypy. + + :param type_: the type the compared object must be exactly; subclasses do + not match. + :param expected: the :func:`repr` the compared object must have exactly. + :param match: a regular expression, as either a :class:`str` or a compiled + :class:`re.Pattern`, that the compared object's :func:`repr` + must match. Mutually exclusive with ``expected``. + :return: A :class:`ReprComparison` typed as the input type. + """ + return cast(T, ReprComparison(type_, expected, match=match)) # type: ignore[call-overload] + + +@overload +def str_like(type_: type[T], expected: str) -> T: ... + + +@overload +def str_like(type_: type[T], *, match: str | re.Pattern[str]) -> T: ... + + +def str_like( + type_: type[T], + expected: str | None = None, + *, + match: str | re.Pattern[str] | None = None, +) -> T: + """ + Create a type-safe :class:`StrComparison` for use in strictly typed code. + + This is a convenience function that creates a :class:`StrComparison` but is + typed to return the type being compared, making it compatible with strict + type checkers like mypy. + + :param type_: the type the compared object must be exactly; subclasses do + not match. + :param expected: the :class:`str` the compared object must have exactly. + :param match: a regular expression, as either a :class:`str` or a compiled + :class:`re.Pattern`, that the compared object's :class:`str` + must match. Mutually exclusive with ``expected``. + :return: A :class:`StrComparison` typed as the input type. + """ + return cast(T, StrComparison(type_, expected, match=match)) # type: ignore[call-overload] + + S = TypeVar("S", bound=Sequence[Any]) S_ = TypeVar("S_", bound=Sequence[Any]) diff --git a/tests/test_comparison_typed.py b/tests/test_comparison_typed.py index a7dc6894..535da426 100644 --- a/tests/test_comparison_typed.py +++ b/tests/test_comparison_typed.py @@ -5,7 +5,9 @@ from uuid import UUID, uuid4 from testfixtures import compare, Comparison -from testfixtures.comparison import like, sequence, contains, unordered, mapping +from testfixtures.comparison import ( + like, repr_like, str_like, sequence, contains, unordered, mapping +) @dataclass @@ -78,6 +80,26 @@ def test_like_compiled_pattern() -> None: compare(expected, actual='on 40220') +def test_repr_like() -> None: + expected: SampleClass = repr_like(SampleClass, "SampleClass(x=1, y='2')") + compare(expected, actual=SampleClass(1, '2')) + + +def test_repr_like_match() -> None: + expected: SampleClass = repr_like(SampleClass, match=re.compile(r'SampleClass\(')) + compare(expected, actual=SampleClass(1, '2')) + + +def test_str_like() -> None: + expected: SampleClass = str_like(SampleClass, "SampleClass(x=1, y='2')") + compare(expected, actual=SampleClass(1, '2')) + + +def test_str_like_match() -> None: + expected: SampleClass = str_like(SampleClass, match=r'SampleClass\(') + compare(expected, actual=SampleClass(1, '2')) + + class TestSequence: def test_minimal(self) -> None: actual = ListCollection([SampleClass(1, '2')]) From 9449490eca432c186840e4bf4eb441343ba00e08 Mon Sep 17 00:00:00 2001 From: Chris Withers Date: Tue, 30 Jun 2026 09:59:13 +0100 Subject: [PATCH 09/11] Settle on "matchers" terminology everywhere --- README.rst | 2 +- docs/comparing.rst | 5 +++-- docs/comparison.rst | 35 +++++++++++++++++++++++------------ 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/README.rst b/README.rst index b2d25cfa..19f4914a 100644 --- a/README.rst +++ b/README.rst @@ -22,7 +22,7 @@ The areas of testing this package can help with are listed below: The ``compare`` function gives readable feedback when results aren't as expected, with clear diffs for deeply nested data structures and for objects that don't normally support comparison. This includes first-class support for pandas and -polars dataframes and numpy arrays. Flexible placeholder objects and helpers +polars dataframes and numpy arrays. Flexible placeholder objects and matchers such as ``like``, ``sequence``, ``generator``, ``Comparison``, ``RoundComparison``, ``RangeComparison``, ``TextComparison`` and ``SequenceComparison`` let you assert that only part of a value matters, that a number is within a range or rounded to a precision, or diff --git a/docs/comparing.rst b/docs/comparing.rst index 0c1421f2..d1660b1f 100644 --- a/docs/comparing.rst +++ b/docs/comparing.rst @@ -24,7 +24,7 @@ text instead of raising it. If, instead of checking for *exact* equality, you want to assert that a value merely *matches* a specification, such as a partial object, a number within a range, a string matching a pattern, or a sequence in any order, use the flexible -:ref:`comparison objects and helpers `. They slot into the +:ref:`comparison objects and matchers `. They slot into the expected side of :func:`compare`, and into plain ``assert`` statements. Comparing expected and actual @@ -720,7 +720,8 @@ While comparing .name: 'bar' (expected) != 'foo' (actual) This type of comparison is also used on objects that make use of ``__slots__``. -To compare only some of an object's attributes, see :ref:`ignore-attributes`, or use the partial :func:`like` helper described in :ref:`comparison-objects`. +To compare only some of an object's attributes, see :ref:`ignore-attributes`, or use the partial +:func:`like` matcher described in :ref:`comparison-objects`. .. _comparer-register: diff --git a/docs/comparison.rst b/docs/comparison.rst index 2d0027ff..a4d05ec6 100644 --- a/docs/comparison.rst +++ b/docs/comparison.rst @@ -17,15 +17,15 @@ string matches a pattern. Functions and objects are provided that express these expectations and slot into the expected side of :func:`compare`, or into a plain ``assert``. -The typed helpers :func:`like`, :func:`sequence`, :func:`contains`, +The typed matchers :func:`like`, :func:`sequence`, :func:`contains`, :func:`unordered` and :func:`mapping` are the place to start. They are typed to match the values you compare against, so they keep type checkers such as `mypy`__ happy. Under the hood they build the comparison objects described below, which you can also -construct directly when you need one that has no helper. +construct directly when you need one that has no matcher. __ https://mypy-lang.org/ -Some expectations have no helper and are used as objects directly: +Some expectations have no matcher and are used as objects directly: :ref:`RangeComparison ` and :ref:`RoundComparison ` for numbers, and :ref:`TextComparison ` for matching against a regular expression. @@ -49,8 +49,16 @@ The examples below use these dataclasses: class TupleContainer: items: tuple[SampleClass, ...] -Partial object comparisons with ``like()`` ------------------------------------------- +Instance matchers +----------------- + +The :func:`~testfixtures.like`, :func:`~testfixtures.repr_like` and +:func:`~testfixtures.str_like` functions check an object's type along with its +attributes, :func:`repr` or :class:`str`, and are typed to match the value being +compared so they slot into strictly typed code. + +Partial comparisons with ``like()`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The :func:`~testfixtures.like` function creates partial object comparisons that are typed to match the class being compared: @@ -66,7 +74,7 @@ including in assertions: >>> assert expected == SampleClass(1, '2') >>> assert expected == SampleClass(3, '4') -When passed a type and, optionally, attributeds, ``like()`` builds a partial +When passed a type and, optionally, attributes, ``like()`` builds a partial :ref:`Comparison `. Reach for a :ref:`Comparison ` directly when you need to match by type alone, by dotted import path, against an existing instance, or matching exactly rather than partially. @@ -78,6 +86,9 @@ compiled :class:`re.Pattern`, :func:`~testfixtures.like` returns a >>> expected_str: str = like(r'Starting thread \d+') >>> compare(expected_str, actual='Starting thread 132356') +Matching ``repr()`` or ``str()`` with ``repr_like()`` and ``str_like()`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + The :func:`~testfixtures.repr_like` and :func:`~testfixtures.str_like` functions do the same for :class:`ReprComparison` and :class:`StrComparison`, returning a value typed as the compared type while checking its :func:`repr` or @@ -89,8 +100,8 @@ value typed as the compared type while checking its :func:`repr` or >>> by_str: KeyError = str_like(KeyError, "'foo'") >>> compare(by_str, actual=KeyError('foo')) -Sequence helpers ----------------- +Sequence matchers +----------------- :func:`sequence`, :func:`contains` and :func:`unordered` compare sequences flexibly and all return :ref:`SequenceComparison ` objects. @@ -210,8 +221,8 @@ Use the ``returns`` parameter for type compatibility: ... ), ... ) -Mapping helpers ---------------- +Mapping matchers +---------------- The :func:`~testfixtures.mapping` function is the mapping equivalent of :func:`sequence`. It builds a typed :ref:`MappingComparison `, @@ -257,12 +268,12 @@ behaviour. Comparison objects ------------------ -The helpers above build these objects, and you can construct them directly. A +The matchers above build these objects, and you can construct them directly. A :ref:`Comparison `, returned by :func:`like`, a :ref:`SequenceComparison `, returned by :func:`sequence`, :func:`contains` and :func:`unordered`, and a :ref:`MappingComparison `, returned by :func:`mapping`, are -usually an implementation detail. The rest have no helper and are meant to be +usually an implementation detail. The rest have no matcher and are meant to be used directly. .. _comparison: From 9505a7594eb7f83a05ba610423fae598ed409cf4 Mon Sep 17 00:00:00 2001 From: Chris Withers Date: Tue, 30 Jun 2026 20:38:06 +0100 Subject: [PATCH 10/11] Regroup the API reference around Comparing, Matchers and Comparison objects The flat Comparisons dump didn't reflect the matchers/objects split, so the on-this-page index read as noise. Split it into three sections, surface the comparers under Comparing, drop the render member from the Repr/Str classes, and document the five compare_* comparers that had been missed. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 1 + docs/api.rst | 126 ++++++++++++++++++++++++++++----------------------- docs/conf.py | 1 + 3 files changed, 72 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 09f5fa9e..c94a6a43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ - **Done means green**: a change is only complete when `./happy.sh` exits 0; do not commit until it does. - **No unrelated failures**: if `./happy.sh` fails on something unrelated to your changes, do NOT assume it is a pre-existing problem and proceed anyway. Stop immediately and ask the user how to proceed. - **Docs for everything public**: new functionality or public API changes must have accompanying docs in `docs/*.rst` +- **Keep the api.rst comparers list current**: the `Comparing` > `Comparers` list in `docs/api.rst` is hand-curated (not an `automodule`). When you add a `compare_*` comparer to `comparers.py`, add a matching `.. autofunction::` entry there, otherwise it silently goes undocumented. - **No em-dashes or parenthetical asides in prose**: in `docs/*.rst` prose and Python docstrings, never use em-dashes, and never tuck a clause inside parentheses; rephrase with commas or separate sentences. This does not apply to code comments or agent-facing notes such as this file, where both are fine. - **No stacked headings in docs**: a heading in `docs/*.rst` must be followed by prose, never immediately by a sub-heading. Add a short lead-in or merge the levels. - **Type-annotate public APIs**: all public functions and classes need type annotations; mypy is the gate diff --git a/docs/api.rst b/docs/api.rst index 07761336..e3a17bef 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,107 +3,121 @@ API Reference .. currentmodule:: testfixtures +.. Register module targets that docstrings and the changelog cross-reference, + without rendering their (empty) module docstrings. +.. module:: testfixtures.comparison -Comparisons ------------ +.. module:: testfixtures.comparers -.. autofunction:: compare +.. currentmodule:: testfixtures -.. autoclass:: Comparison -.. autofunction:: testfixtures.like +Comparing +--------- -.. autoclass:: MappingComparison - :members: +.. autofunction:: compare -.. autoclass:: Permutation - :members: +.. autofunction:: testfixtures.comparison.register -.. autoclass:: RoundComparison +.. autoclass:: testfixtures.comparison.CompareContext :members: -.. autoclass:: RangeComparison - :members: +Comparers +~~~~~~~~~ -.. autoclass:: ReprComparison - :members: +.. autofunction:: testfixtures.comparers.compare_simple -.. autofunction:: testfixtures.repr_like +.. autofunction:: testfixtures.comparers.compare_object -.. autoclass:: StrComparison - :members: +.. autofunction:: testfixtures.comparers.merge_ignored_attributes -.. autofunction:: testfixtures.str_like +.. autofunction:: testfixtures.comparers.compare_exception -.. autoclass:: SequenceComparison - :members: +.. autofunction:: testfixtures.comparers.compare_exception_group -.. autofunction:: testfixtures.sequence +.. autofunction:: testfixtures.comparers.compare_with_type -.. autofunction:: testfixtures.contains +.. autofunction:: testfixtures.comparers.compare_sequence -.. autofunction:: testfixtures.unordered +.. autofunction:: testfixtures.comparers.compare_generator -.. autofunction:: testfixtures.mapping +.. autofunction:: testfixtures.comparers.compare_tuple -.. autoclass:: Subset - :members: +.. autofunction:: testfixtures.comparers.compare_dict -.. autoclass:: TextComparison - :members: +.. autofunction:: testfixtures.comparers.compare_set -.. py:class:: StringComparison +.. autofunction:: testfixtures.comparers.compare_text - Deprecated alias for :class:`TextComparison`. Note this is *not* the same as - :class:`StrComparison`. +.. autofunction:: testfixtures.comparers.compare_bytes +.. autofunction:: testfixtures.comparers.compare_call -testfixtures.comparison -~~~~~~~~~~~~~~~~~~~~~~~ +.. autofunction:: testfixtures.comparers.compare_partial -.. automodule:: testfixtures.comparison +.. autofunction:: testfixtures.comparers.compare_path -.. autofunction:: testfixtures.comparison.register +.. autofunction:: testfixtures.comparers.compare_with_fold -.. autoclass:: testfixtures.comparison.CompareContext - :members: +.. autofunction:: testfixtures.comparers.safe_repr -testfixtures.comparers -~~~~~~~~~~~~~~~~~~~~~~ +.. autofunction:: testfixtures.comparers.safe_pformat -.. automodule:: testfixtures.comparers +.. autoclass:: testfixtures.comparers.AlreadySeen -.. autofunction:: testfixtures.comparers.compare_simple +.. currentmodule:: testfixtures -.. autofunction:: testfixtures.comparers.compare_object +Matchers +-------- -.. autofunction:: testfixtures.comparers.merge_ignored_attributes +.. autofunction:: testfixtures.like -.. autofunction:: testfixtures.comparers.compare_exception +.. autofunction:: testfixtures.repr_like -.. autofunction:: testfixtures.comparers.compare_exception_group +.. autofunction:: testfixtures.str_like -.. autofunction:: testfixtures.comparers.compare_with_type +.. autofunction:: testfixtures.sequence -.. autofunction:: testfixtures.comparers.compare_sequence +.. autofunction:: testfixtures.contains -.. autofunction:: testfixtures.comparers.compare_generator +.. autofunction:: testfixtures.unordered -.. autofunction:: testfixtures.comparers.compare_tuple +.. autofunction:: testfixtures.mapping -.. autofunction:: testfixtures.comparers.compare_dict +Comparison objects +------------------ -.. autofunction:: testfixtures.comparers.compare_set +.. autoclass:: Comparison -.. autofunction:: testfixtures.comparers.compare_text +.. autoclass:: ReprComparison -.. autofunction:: testfixtures.comparers.safe_repr +.. autoclass:: StrComparison -.. autofunction:: testfixtures.comparers.safe_pformat +.. autoclass:: TextComparison + :members: -.. autoclass:: testfixtures.comparers.AlreadySeen +.. autoclass:: SequenceComparison + :members: -.. currentmodule:: testfixtures +.. autoclass:: Subset + :members: + +.. autoclass:: Permutation + :members: + +.. autoclass:: MappingComparison + :members: + +.. autoclass:: RangeComparison + :members: + +.. autoclass:: RoundComparison + :members: + +.. py:class:: StringComparison + + Deprecated alias for :class:`TextComparison`. Note this is *not* the same as + :class:`StrComparison`. Capturing --------- diff --git a/docs/conf.py b/docs/conf.py index 68962103..d3043931 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -47,6 +47,7 @@ ('py:class', '~P'), # param spec ('py:class', 'constantly._constants.NamedConstant'), # twisted logging constants ('py:class', 'django.db.models.base.Model'), # not documented upstream + ('py:class', 'functools.partial'), # rendered from a partial_type annotation, no class target ('py:class', 'polars.DataFrame'), # polars inventory has no top-level class entry ('py:class', 'polars.dataframe.frame.DataFrame'), # same ('py:class', 'module'), # ModuleType not documented. From cc7307a0f38e1758a71f265fdca5e3e81ff370a1 Mon Sep 17 00:00:00 2001 From: Chris Withers Date: Wed, 1 Jul 2026 09:24:36 +0100 Subject: [PATCH 11/11] Let ShouldRaise accept ReprComparison and StrComparison matchers So you can assert a raised exception's type and repr or str with repr_like and str_like without building the exception instance by hand. A wrong type still propagates the original exception, matching how ShouldRaise treats a mismatched type elsewhere. Co-Authored-By: Claude Opus 4.8 --- docs/exceptions.rst | 36 +++++++++++++++++++++++ src/testfixtures/shouldraise.py | 10 +++++++ tests/test_should_raise.py | 52 ++++++++++++++++++++++++++++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/docs/exceptions.rst b/docs/exceptions.rst index 135185cb..64ac86ec 100644 --- a/docs/exceptions.rst +++ b/docs/exceptions.rst @@ -168,6 +168,42 @@ something was raised and its message matches: ``match`` is not permitted when passing an exception instance or ``None``, indicating exception is expected; both will raise a :class:`TypeError` at construction time. +Matching the type and ``repr()`` or ``str()`` +--------------------------------------------- + +Sometimes you want to assert both the type of the exception and its +:func:`repr` or :class:`str` without constructing the exception instance +yourself. The :func:`repr_like` and :func:`str_like` matchers do this and are +typed to stand in for the exception: + +>>> from testfixtures import repr_like +>>> with ShouldRaise(repr_like(ValueError, "ValueError('Not good!')")): +... the_thrower() + +>>> from testfixtures import str_like +>>> with ShouldRaise(str_like(ValueError, 'Not good!')): +... the_thrower() + +Both can take a ``match`` regular expression instead of an exact string: + +>>> with ShouldRaise(repr_like(ValueError, match=r'good')): +... the_thrower() + +If the type matches but the rendering does not, an :class:`AssertionError` +explains the difference: + +>>> with ShouldRaise(str_like(ValueError, 'All good!')): +... the_thrower() +Traceback (most recent call last): +... +AssertionError: not equal: + (expected) +ValueError('Not good!') (raised) + +The type must match exactly, so a subclass of the expected exception will not +match. See :ref:`comparison-objects` for more about :func:`repr_like` and +:func:`str_like`. + Exceptions that are conditionally raised ---------------------------------------- diff --git a/src/testfixtures/shouldraise.py b/src/testfixtures/shouldraise.py index 64c548f6..8a7c8b06 100644 --- a/src/testfixtures/shouldraise.py +++ b/src/testfixtures/shouldraise.py @@ -6,6 +6,7 @@ from testfixtures import diff, compare, not_there, singleton from .comparers import split_repr +from .comparison import RenderingComparison param_docs = """ @@ -27,6 +28,12 @@ exception exactly matching the one supplied should be raised. + * A :class:`ReprComparison` or :class:`StrComparison`, + such as those returned by :func:`repr_like` and + :func:`str_like`, indicating that a raised exception + must be of the expected type and have the expected + :func:`repr` or :class:`str`. + :param unless: Can be passed a boolean that, when ``True`` indicates that no exception is expected. This is useful when checking that exceptions are only raised on certain versions of @@ -111,6 +118,9 @@ def __exit__( if self.exception is not actual_: return False self._check_match(actual) + elif isinstance(self.exception, RenderingComparison): + if self.exception.type is not type(actual): + return False else: if type(self.exception) is not type(actual): return False diff --git a/tests/test_should_raise.py b/tests/test_should_raise.py index 6b44080f..ba03ed67 100644 --- a/tests/test_should_raise.py +++ b/tests/test_should_raise.py @@ -2,7 +2,9 @@ from textwrap import dedent -from testfixtures import Comparison as C, ShouldRaise, should_raise, compare +from testfixtures import ( + Comparison as C, ShouldRaise, should_raise, compare, repr_like, str_like +) from unittest import TestCase from testfixtures.shouldraise import ShouldAssert, NoException @@ -414,3 +416,51 @@ def test_none_raises(self) -> None: def test_pattern_object(self) -> None: with ShouldRaise(ValueError, match=re.compile(r'bad')): raise ValueError('bad value') + + +class TestRenderingComparison: + + def test_repr_like_passes(self) -> None: + with ShouldRaise(repr_like(KeyError, "KeyError('foo')")): + raise KeyError('foo') + + def test_str_like_passes(self) -> None: + with ShouldRaise(str_like(ValueError, 'boom')): + raise ValueError('boom') + + def test_repr_like_match_regex(self) -> None: + with ShouldRaise(repr_like(KeyError, match=r"KeyError\('\w+'\)")): + raise KeyError('foo') + + def test_str_like_match_regex(self) -> None: + with ShouldRaise(str_like(ValueError, match=r'^bo')): + raise ValueError('boom') + + def test_repr_like_wrong_value_fails(self) -> None: + with ShouldAssert( + "not equal:\n" + " (expected)\n" + "KeyError('foo') (raised)" + ): + with ShouldRaise(repr_like(KeyError, "KeyError('other')")): + raise KeyError('foo') + + def test_str_like_wrong_value_fails(self) -> None: + with ShouldAssert( + "not equal:\n" + " (expected)\n" + "ValueError('boom') (raised)" + ): + with ShouldRaise(str_like(ValueError, 'nope')): + raise ValueError('boom') + + def test_wrong_type_propagates(self) -> None: + with ShouldRaise(ValueError('boom')): + with ShouldRaise(repr_like(KeyError, "KeyError('foo')")): + raise ValueError('boom') + + def test_via_decorator(self) -> None: + @should_raise(repr_like(KeyError, "KeyError('foo')")) + def raiser() -> None: + raise KeyError('foo') + raiser()