Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ jobs:
- python-version: "3.14"
uv-resolution: "highest"
extra: "--extra structlog"
- python-version: "3.14"
uv-resolution: "highest"
extra: "--extra pydantic"
- python-version: "3.14"
uv-resolution: "highest"
extra: "--extra twisted --group twisted-dev"
Expand Down Expand Up @@ -110,6 +113,7 @@ jobs:
- "sybil"
- "mock"
- "structlog"
- "pydantic"
- "twisted"
steps:
- uses: cjw296/python-action/check-distributions@v3
Expand Down
6 changes: 6 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,12 @@ testfixtures.polars
.. automodule:: testfixtures.polars
:members:

testfixtures.pydantic
~~~~~~~~~~~~~~~~~~~~~

.. automodule:: testfixtures.pydantic
:members:

testfixtures.structlog
~~~~~~~~~~~~~~~~~~~~~~

Expand Down
85 changes: 55 additions & 30 deletions docs/comparing.rst
Original file line number Diff line number Diff line change
Expand Up @@ -260,50 +260,75 @@ This is handled for standard container types and their subclasses, specifically
:class:`tuple`, :class:`dict`, :class:`set`, and :class:`frozenset`.

For custom container or wrapper types that implement ``__eq__`` and don't subclass one
of these standard container types, *and* which can contain instance of a type for which you'd like
to ``ignore_eq``, you will find that ``ignore_eq`` for that type alone is not sufficient.
of these standard container types, *and* which can contain instances of a type for which you'd like
to ``ignore_eq``, you will find that ``ignore_eq`` for the inner type alone is not sufficient.

For example, consider this container type:
A real-world example is a :class:`pydantic.BaseModel <pydantic:pydantic.BaseModel>` with a
:class:`pandas.DataFrame` attribute. :class:`~pandas.DataFrame` implements ``__eq__`` in a way
that raises :exc:`ValueError` when used as a boolean, and pydantic's ``__eq__`` calls ``==`` on
each field value directly, so if :class:`~pandas.DataFrame` is the only type passed to
``ignore_eq``, pydantic's ``__eq__`` still fires first and the comparison still raises:

.. code-block:: python

class OrmObjBox:
def __init__(self, *items: OrmObj):
self.items = list(items)
def __eq__(self, other: "OrmObjBox") -> bool:
return self.items == other.items
import pandas as pd
from pydantic import BaseModel, ConfigDict

If we only pass :class:`!OrmObj` to ``ignore_eq``, the :class:`!OrmObjBox` instances will still
erroneously appear to be equal:
class Report(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
name: str
data: pd.DataFrame

>>> compare(OrmObjBox(OrmObj(1)), OrmObjBox(OrmObj(2)), ignore_eq=OrmObj)
.. invisible-code-block: python

To successfully ignore the ``__eq__`` of :class:`!OrmObj`, we need to pass both the type and
any custom container or wrapper types to ``ignore_eq``:
from testfixtures.comparing import Registry
registry = Registry.initial().install()

>>> compare(OrmObjBox(OrmObj(1)), OrmObjBox(OrmObj(2)), ignore_eq=[OrmObj, OrmObjBox])
>>> compare(
... Report(name='sales', data=pd.DataFrame({'x': [1, 2]})),
... expected=Report(name='sales', data=pd.DataFrame({'x': [1, 3]})),
... ignore_eq=pd.DataFrame,
... )
Traceback (most recent call last):
...
AssertionError: OrmObjBox not as expected:
<BLANKLINE>
attributes differ:
'items': [OrmObj: 1] != [OrmObj: 2]
<BLANKLINE>
While comparing .items: sequence not as expected:
<BLANKLINE>
same:
[]
ValueError: The truth value of a DataFrame is ambiguous. Use a.empty, a.bool(), a.item(), a.any() or a.all().

.. invisible-code-block: python

registry.uninstall()

Both types must be known to :func:`compare`. For broadly applicable types like
:class:`~pydantic.BaseModel` and :class:`~pandas.DataFrame`, the right solution is to
:ref:`register <comparer-register>` a comparer for each. This happens automatically
when both pydantic and pandas are installed, so :func:`compare` handles
:class:`~pydantic.BaseModel` instances containing :class:`~pandas.DataFrame`
attributes without any extra arguments:

>>> compare(
... Report(name='sales', data=pd.DataFrame({'x': [1, 2]})),
... expected=Report(name='sales', data=pd.DataFrame({'x': [1, 3]})),
... )
Traceback (most recent call last):
...
AssertionError: Report not as expected:
<BLANKLINE>
first:
[OrmObj: 1]
attributes same:
['name']
<BLANKLINE>
second:
[OrmObj: 2]
attributes differ:
'data': x
0 1
1 3 (expected) != x
0 1
1 2 (actual)
<BLANKLINE>
While comparing .items[0]: OrmObj not as expected:
While comparing .data: DataFrame.iloc[:, 0] (column name="x") are different
<BLANKLINE>
attributes differ:
'a': 1 != 2
DataFrame.iloc[:, 0] (column name="x") values are different (50.0 %)
[index]: [0, 1]
[left]: [1, 3]
[right]: [1, 2]
At positional index 1, first diff: 3 != 2

.. _recursion:

Expand Down
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
'numpy': ('https://numpy.org/doc/stable/', None),
'pandas': ('https://pandas.pydata.org/docs/', None),
'polars': ('https://docs.pola.rs/api/python/stable/', None),
'pydantic': ('https://docs.pydantic.dev/latest/', None),
}

# General
Expand Down
60 changes: 46 additions & 14 deletions docs/exceptions.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ Testing exceptions

.. currentmodule:: testfixtures

.. invisible-code-block: python

try:
import pydantic
except ImportError:
pydantic = None

Testfixtures has tools to help when making assertions about exceptions that should be raised by
a piece of code.

Expand Down Expand Up @@ -174,31 +181,56 @@ 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:
typed to stand in for the exception.

This is particularly useful for exceptions such as
:class:`pydantic.ValidationError <pydantic:pydantic_core.ValidationError>`, which has no public
constructor that accepts a plain message:

.. skip: start if(pydantic is None, reason="No pydantic installed")

.. code-block:: python

from pydantic import BaseModel, ValidationError

class Point(BaseModel):
x: int
y: int

>>> from testfixtures import repr_like
>>> with ShouldRaise(repr_like(ValueError, "ValueError('Not good!')")):
... the_thrower()
>>> with ShouldRaise(repr_like(ValidationError, match='validation error for Point')):
... Point(x='not-an-int', y=2)

>>> from testfixtures import str_like
>>> with ShouldRaise(str_like(ValueError, 'Not good!')):
... the_thrower()
>>> with ShouldRaise(str_like(ValidationError, match='validation error for Point')):
... Point(x='not-an-int', y=2)

Both can take a ``match`` regular expression instead of an exact string:
Both matchers can also compare the whole rendering exactly, rather than
matching a pattern within it. If the type matches but the rendering does not,
an :class:`AssertionError` explains the difference:

>>> with ShouldRaise(repr_like(ValueError, match=r'good')):
... the_thrower()
.. invisible-code-block: python

If the type matches but the rendering does not, an :class:`AssertionError`
explains the difference:
try:
Point(x='not-an-int', y=2)
except ValidationError as e:
wrong_text = str(e).replace('\nx\n', '\ny\n', 1)

>>> with ShouldRaise(str_like(ValueError, 'All good!')):
... the_thrower()
>>> with ShouldRaise(str_like(ValidationError, wrong_text)):
... Point(x='not-an-int', y=2)
Traceback (most recent call last):
...
AssertionError: not equal:
<StrComparison: builtins.ValueError: All good!> (expected)
ValueError('Not good!') (raised)
<StrComparison: pydantic_core._pydantic_core.ValidationError: 1 validation error for Point
y
Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='not-an-int', input_type=str]
For further information visit https://errors.pydantic.dev/.../v/int_parsing> (expected)
1 validation error for Point
x
Input should be a valid integer, unable to parse string as an integer [type=int_parsing, input_value='not-an-int', input_type=str]
For further information visit https://errors.pydantic.dev/.../v/int_parsing (raised)

.. skip: end

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
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ The sections below describe the use of the various tools included:
pandas.rst
numpy.rst
structlog.rst
pydantic.rst
twisted.rst
utilities.rst

Expand Down
2 changes: 2 additions & 0 deletions docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ with the version of these packages that your project is using:

- ``[structlog]``: Structlog logging framework support. See :doc:`structlog`.

- ``[pydantic]``: Pydantic model helpers. See :doc:`pydantic`.

- ``[yaml]``: YAML format support for :class:`~testfixtures.TempDir`. See :ref:`files`.

- ``[toml]``: TOML format writing support for :class:`~testfixtures.TempDir`. See :ref:`files`.
Expand Down
Loading
Loading