diff --git a/AGENTS.md b/AGENTS.md index f3d9f309..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 @@ -39,7 +40,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..19f4914a 100644 --- a/README.rst +++ b/README.rst @@ -22,9 +22,9 @@ 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``, ``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 d0837b2f..e3a17bef 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -3,60 +3,27 @@ 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 - -.. autoclass:: Comparison - -.. autofunction:: testfixtures.like - -.. autoclass:: MappingComparison - :members: - -.. autoclass:: Permutation - :members: - -.. autoclass:: RoundComparison - :members: - -.. autoclass:: RangeComparison - :members: - -.. autoclass:: SequenceComparison - :members: - -.. autofunction:: testfixtures.sequence - -.. autofunction:: testfixtures.contains - -.. autofunction:: testfixtures.unordered - -.. autofunction:: testfixtures.mapping - -.. autoclass:: Subset - :members: - -.. autoclass:: StringComparison - :members: +.. currentmodule:: testfixtures -testfixtures.comparison -~~~~~~~~~~~~~~~~~~~~~~~ +Comparing +--------- -.. automodule:: testfixtures.comparison +.. autofunction:: compare .. autofunction:: testfixtures.comparison.register .. autoclass:: testfixtures.comparison.CompareContext :members: -testfixtures.comparers -~~~~~~~~~~~~~~~~~~~~~~ - -.. automodule:: testfixtures.comparers +Comparers +~~~~~~~~~ .. autofunction:: testfixtures.comparers.compare_simple @@ -82,6 +49,16 @@ testfixtures.comparers .. autofunction:: testfixtures.comparers.compare_text +.. autofunction:: testfixtures.comparers.compare_bytes + +.. autofunction:: testfixtures.comparers.compare_call + +.. autofunction:: testfixtures.comparers.compare_partial + +.. autofunction:: testfixtures.comparers.compare_path + +.. autofunction:: testfixtures.comparers.compare_with_fold + .. autofunction:: testfixtures.comparers.safe_repr .. autofunction:: testfixtures.comparers.safe_pformat @@ -90,6 +67,58 @@ testfixtures.comparers .. currentmodule:: testfixtures +Matchers +-------- + +.. autofunction:: testfixtures.like + +.. autofunction:: testfixtures.repr_like + +.. autofunction:: testfixtures.str_like + +.. autofunction:: testfixtures.sequence + +.. autofunction:: testfixtures.contains + +.. autofunction:: testfixtures.unordered + +.. autofunction:: testfixtures.mapping + +Comparison objects +------------------ + +.. autoclass:: Comparison + +.. autoclass:: ReprComparison + +.. autoclass:: StrComparison + +.. autoclass:: TextComparison + :members: + +.. autoclass:: SequenceComparison + :members: + +.. 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/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..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 @@ -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: @@ -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 27afc45b..a4d05ec6 100644 --- a/docs/comparison.rst +++ b/docs/comparison.rst @@ -17,17 +17,17 @@ 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:`StringComparison ` for matching against a +for numbers, and :ref:`TextComparison ` for matching against a regular expression. The examples below use these dataclasses: @@ -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,13 +74,34 @@ 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, 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. -Sequence helpers ----------------- +When passed a regular expression pattern, either as a string or an already +compiled :class:`re.Pattern`, :func:`~testfixtures.like` returns a +:class:`TextComparison` typed as a :class:`str`: + +>>> 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 +: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 matchers +----------------- :func:`sequence`, :func:`contains` and :func:`unordered` compare sequences flexibly and all return :ref:`SequenceComparison ` objects. @@ -192,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 `, @@ -239,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: @@ -615,17 +644,59 @@ Here's an example with dates: :class:`RangeComparison` is inclusive of both the lower and upper bound. -.. _stringcomparison: +.. _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 exactly 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')) + +.. _strcomparison: + +``StrComparison`` +~~~~~~~~~~~~~~~~~ -``StringComparison`` -~~~~~~~~~~~~~~~~~~~~ +: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`` +~~~~~~~~~~~~~~~~~~ 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. @@ -633,10 +704,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', ) @@ -647,7 +718,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", ) @@ -658,7 +729,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", ) @@ -667,6 +738,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:`TextComparison` from an already compiled +:class:`re.Pattern`: + +.. code-block:: python + + import re + compare( + expected=TextComparison(re.compile(".*BaR", re.DOTALL|re.IGNORECASE)), + actual="foo\nbar", + ) 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. 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/__init__.py b/src/testfixtures/__init__.py index ecf1fbed..bb9339de 100644 --- a/src/testfixtures/__init__.py +++ b/src/testfixtures/__init__.py @@ -15,8 +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, - SequenceComparison, Subset, Permutation, MappingComparison, like, sequence, + Comparison, TextComparison, StringComparison, RoundComparison, + RangeComparison, ReprComparison, StrComparison, SequenceComparison, Subset, + Permutation, MappingComparison, like, repr_like, str_like, sequence, contains, unordered, mapping ) from testfixtures.command import Command, Run @@ -55,6 +56,7 @@ def __repr__(self) -> str: 'OutputCapture', 'Permutation', 'RangeComparison', + 'ReprComparison', 'Replace', 'Replacer', 'Run', @@ -65,7 +67,9 @@ def __repr__(self) -> str: 'ShouldNotWarn', 'ShouldWarn', 'Subset', + 'StrComparison', 'StringComparison', + 'TextComparison', 'TempDirectory', 'TempDir', 'compare', @@ -84,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/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 0ca076fc..fa54b9bc 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: @@ -410,7 +406,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,14 +414,31 @@ 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. + :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 `. """ - 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_ = [] @@ -452,6 +465,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 @@ -501,9 +519,97 @@ def __ne__(self, other: Any) -> bool: def __repr__(self) -> str: return '' % (self.lower_bound, self.upper_bound) + +class RenderingComparison(ABC): + """ + A base for comparisons that check an object's type and a string rendering + of it produced by :attr:`render`. + """ + + @staticmethod + @abstractmethod + def render(other: object, /) -> str: + """The callable used to render the compared object as a string.""" + + @overload + def __init__(self, type_: type, expected: str): ... + @overload + def __init__(self, type_: type, *, match: str | re.Pattern[str]): ... + + def __init__( + self, + type_: type, + expected: str | None = None, + *, + match: str | re.Pattern[str] | None = None, + ): + if (expected is None) == (match is None): + raise TypeError('provide either expected or match') + self.type = type_ + self.expected = expected + self.match = match + + def __eq__(self, other: Any) -> bool: + if type(other) is not self.type: + return False + rendered = self.render(other) + if self.match is not None: + 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: + 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 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 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``. + """ + render = staticmethod(repr) + + +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 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``. + """ + render = staticmethod(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. @@ -511,13 +617,83 @@ 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:`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` object typed as the input type. + :return: A :class:`Comparison` typed as the input type, or a + :class:`TextComparison` typed as a :class:`str`. """ + if isinstance(t, (str, re.Pattern)): + return cast(str, TextComparison(t)) 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/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/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/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_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): diff --git a/tests/test_comparison_typed.py b/tests/test_comparison_typed.py index f12efb7f..535da426 100644 --- a/tests/test_comparison_typed.py +++ b/tests/test_comparison_typed.py @@ -1,10 +1,13 @@ # 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 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 @@ -67,6 +70,36 @@ 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') + + +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')]) 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_reprcomparison.py b/tests/test_reprcomparison.py new file mode 100644 index 00000000..ec45cbef --- /dev/null +++ b/tests/test_reprcomparison.py @@ -0,0 +1,103 @@ +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_not_equal(): + # The type must match exactly; a subclass is not enough. + class SubClass(AClass): + pass + assert not (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] 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() diff --git a/tests/test_strcomparison.py b/tests/test_strcomparison.py new file mode 100644 index 00000000..9a789e66 --- /dev/null +++ b/tests/test_strcomparison.py @@ -0,0 +1,103 @@ +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_not_equal(): + # The type must match exactly; a subclass is not enough. + class SubClass(AClass): + pass + assert not (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] diff --git a/tests/test_stringcomparison.py b/tests/test_stringcomparison.py index dfbc624a..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,28 +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(StringComparison(re.compile(r'on \d+')), actual='on 40220') + + def test_compiled_pattern_with_flags(self): + 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