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
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Andrzej Ostrowski
Andy Freeland
Anita Hammer
Anna Tasiopoulou
Anneheartrecord
Anthon van der Neut
Anthony Shaw
Anthony Sottile
Expand Down
7 changes: 7 additions & 0 deletions changelog/14580.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Added an ``ordered`` parameter to :class:`pytest.RaisesGroup`.

By default :class:`~pytest.RaisesGroup` ignores the order of the exceptions in the
group. Passing ``ordered=True`` makes it match the expected exceptions against the
raised exceptions positionally, so the order is asserted. This also sidesteps the
greedy matching algorithm, which can otherwise fail to find a valid pairing even when
one exists.
10 changes: 10 additions & 0 deletions doc/en/how-to/assert.rst
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,16 @@ It is strict about structure and unwrapped exceptions, unlike :ref:`except* <exc
with pytest.RaisesGroup(ValueError, allow_unwrapped=True):
raise ValueError

By default the order of the contained exceptions does not matter. If you need to assert
that the exceptions are raised in a particular order you can pass ``ordered=True``, which
matches the expected exceptions against the raised exceptions positionally.

.. code-block:: python

def test_ordered():
with pytest.RaisesGroup(ValueError, TypeError, ordered=True):
raise ExceptionGroup("msg", [ValueError("foo"), TypeError("bar")])

To specify more details about the contained exception you can use :class:`pytest.RaisesExc`

.. code-block:: python
Expand Down
61 changes: 60 additions & 1 deletion src/_pytest/raises.py
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,15 @@ class RaisesGroup(AbstractRaises[BaseExceptionGroup[BaseExcT_co]]):
inside any nested groups, before matching. Without this it expects you to
fully specify the nesting structure by passing :class:`RaisesGroup` as expected
parameter.
:kwparam bool ordered:
.. versionadded:: 8.4

By default the order of the exceptions does not matter, and the matching
algorithm is greedy (see the note below). When ``ordered=True`` the expected
exceptions must match the raised exceptions *in order*: the first expected
exception is matched against the first raised exception, and so on. This
disables the reordering done by the greedy algorithm, so it both asserts the
order and avoids the greedy-algorithm pitfall described below.

Examples::

Expand Down Expand Up @@ -812,6 +821,10 @@ class RaisesGroup(AbstractRaises[BaseExceptionGroup[BaseExcT_co]]):
with RaisesGroup(ValueError, allow_unwrapped=True):
raise ValueError

# ordered
with RaisesGroup(ValueError, TypeError, ordered=True):
raise ExceptionGroup("", (ValueError(), TypeError()))


:meth:`RaisesGroup.matches` can also be used directly to check a standalone exception group.

Expand All @@ -822,7 +835,8 @@ class RaisesGroup(AbstractRaises[BaseExceptionGroup[BaseExcT_co]]):
raise ExceptionGroup("", (ValueError("hello"), ValueError("goodbye")))

even though it generally does not care about the order of the exceptions in the group.
To avoid the above you should specify the first :exc:`ValueError` with a :class:`RaisesExc` as well.
To avoid the above you should specify the first :exc:`ValueError` with a :class:`RaisesExc` as well,
or pass ``ordered=True`` to disable the greedy reordering and match positionally.

.. note::
When raised exceptions don't match the expected ones, you'll get a detailed error
Expand Down Expand Up @@ -855,6 +869,7 @@ def __init__(
flatten_subgroups: Literal[True],
match: str | Pattern[str] | None = None,
check: Callable[[BaseExceptionGroup[BaseExcT_co]], bool] | None = None,
ordered: bool = False,
) -> None: ...

# simplify the typevars if possible (the following 3 are equivalent but go simpler->complicated)
Expand All @@ -870,6 +885,7 @@ def __init__(
*other_exceptions: type[ExcT_1] | RaisesExc[ExcT_1],
match: str | Pattern[str] | None = None,
check: Callable[[ExceptionGroup[ExcT_1]], bool] | None = None,
ordered: bool = False,
) -> None: ...

@overload
Expand All @@ -880,6 +896,7 @@ def __init__(
*other_exceptions: RaisesGroup[ExcT_2],
match: str | Pattern[str] | None = None,
check: Callable[[ExceptionGroup[ExceptionGroup[ExcT_2]]], bool] | None = None,
ordered: bool = False,
) -> None: ...

@overload
Expand All @@ -892,6 +909,7 @@ def __init__(
check: (
Callable[[ExceptionGroup[ExcT_1 | ExceptionGroup[ExcT_2]]], bool] | None
) = None,
ordered: bool = False,
) -> None: ...

# same as the above 3 but handling BaseException
Expand All @@ -903,6 +921,7 @@ def __init__(
*other_exceptions: type[BaseExcT_1] | RaisesExc[BaseExcT_1],
match: str | Pattern[str] | None = None,
check: Callable[[BaseExceptionGroup[BaseExcT_1]], bool] | None = None,
ordered: bool = False,
) -> None: ...

@overload
Expand All @@ -915,6 +934,7 @@ def __init__(
check: (
Callable[[BaseExceptionGroup[BaseExceptionGroup[BaseExcT_2]]], bool] | None
) = None,
ordered: bool = False,
) -> None: ...

@overload
Expand All @@ -935,6 +955,7 @@ def __init__(
]
| None
) = None,
ordered: bool = False,
) -> None: ...

def __init__(
Expand All @@ -954,6 +975,7 @@ def __init__(
| Callable[[ExceptionGroup[ExcT_1]], bool]
| None
) = None,
ordered: bool = False,
):
# The type hint on the `self` and `check` parameters uses different formats
# that are *very* hard to reconcile while adhering to the overloads, so we cast
Expand All @@ -965,6 +987,7 @@ def __init__(
super().__init__(match=match, check=check)
self.allow_unwrapped = allow_unwrapped
self.flatten_subgroups: bool = flatten_subgroups
self.ordered = ordered
self.is_baseexception = False

if allow_unwrapped and other_exceptions:
Expand Down Expand Up @@ -1057,6 +1080,8 @@ def __repr__(self) -> str:
reqs.append(f"allow_unwrapped={self.allow_unwrapped}")
if self.flatten_subgroups:
reqs.append(f"flatten_subgroups={self.flatten_subgroups}")
if self.ordered:
reqs.append(f"ordered={self.ordered}")
if self.match is not None:
# If no flags were specified, discard the redundant re.compile() here.
reqs.append(f"match={_match_pattern(self.match)!r}")
Expand Down Expand Up @@ -1260,6 +1285,9 @@ def _check_exceptions(
"""Helper method for RaisesGroup.matches that attempts to pair up expected and actual exceptions"""
# The _exception parameter is not used, but necessary for the TypeGuard

if self.ordered:
return self._check_exceptions_ordered(actual_exceptions)

# full table with all results
results = ResultHolder(self.expected_exceptions, actual_exceptions)

Expand Down Expand Up @@ -1395,6 +1423,37 @@ def _check_exceptions(
self._fail_reason = s
return False

def _check_exceptions_ordered(
self,
actual_exceptions: Sequence[BaseException],
) -> bool:
"""Helper method for ``RaisesGroup._check_exceptions`` used when
``ordered=True``. Each expected exception is matched against the actual
exception at the same position, without any reordering. Sets
``self._fail_reason`` and returns ``False`` on the first mismatch."""
if len(actual_exceptions) != len(self.expected_exceptions):
self._fail_reason = (
f"Expected {len(self.expected_exceptions)} exceptions in ordered group, "
f"but got {len(actual_exceptions)}: {list(actual_exceptions)!r}"
)
return False

for i, (expected, actual) in enumerate(
zip(self.expected_exceptions, actual_exceptions, strict=True)
):
res = self._check_expected(expected, actual)
if res is not None:
# only prefix with the position when there's more than one
# exception, to keep single-exception messages identical to the
# unordered case.
if len(self.expected_exceptions) == 1:
self._fail_reason = res
else:
prefix = "\n" if res.startswith("\n") else " "
self._fail_reason = f"At index {i}:{prefix}{res}"
return False
return True

def __exit__(
self,
exc_type: type[BaseException] | None,
Expand Down
56 changes: 56 additions & 0 deletions testing/python/raises_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -963,6 +963,62 @@ def test_misordering_example() -> None:
)


def test_ordered() -> None:
# by default the order of the exceptions does not matter
with RaisesGroup(ValueError, TypeError):
raise ExceptionGroup("", [TypeError(), ValueError()])

# with ordered=True the exceptions must be in the specified order
with RaisesGroup(ValueError, TypeError, ordered=True):
raise ExceptionGroup("", [ValueError(), TypeError()])

# a group in the wrong order fails to match when ordered=True, and the failure
# message points at the offending position.
with (
fails_raises_group(
"At index 0: `TypeError()` is not an instance of `ValueError`",
),
RaisesGroup(ValueError, TypeError, ordered=True),
):
raise ExceptionGroup("", [TypeError(), ValueError()])

# a wrong number of exceptions also fails with ordered=True
with (
fails_raises_group(
"Expected 2 exceptions in ordered group, but got 1: [ValueError()]",
),
RaisesGroup(ValueError, TypeError, ordered=True),
):
raise ExceptionGroup("", [ValueError()])

# ordered=True does positional matching so it disables the greedy-algorithm
# reordering that test_misordering_example relies on. Here the first expected
# matches the first raised positionally, so it succeeds where the unordered
# greedy algorithm would have failed.
with RaisesGroup(RaisesExc(ValueError, match="foo"), ValueError, ordered=True):
raise ExceptionGroup("", [ValueError("foo"), ValueError("bar")])

# ordered=True also works with nested groups and RaisesExc
with RaisesGroup(
RaisesExc(ValueError, match="a"),
RaisesGroup(TypeError),
ordered=True,
):
raise ExceptionGroup(
"",
[ValueError("a"), ExceptionGroup("", [TypeError()])],
)


def test_ordered_repr() -> None:
assert (
repr(RaisesGroup(ValueError, ordered=True))
== "RaisesGroup(ValueError, ordered=True)"
)
# ordered=False is the default and should not show up in the repr
assert repr(RaisesGroup(ValueError, ordered=False)) == "RaisesGroup(ValueError)"


def test_brief_error_on_one_fail() -> None:
"""If only one raised and one expected fail to match up, we print a full table iff
the raised exception would match one of the expected that previously got matched"""
Expand Down
8 changes: 8 additions & 0 deletions testing/typing_raises_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@ def check_raisesgroup_overloads() -> None:
RaisesGroup(ValueError, flatten_subgroups=True)
RaisesGroup(RaisesExc(ValueError), flatten_subgroups=True)

# ordered is accepted alongside the other parameters
RaisesGroup(ValueError, TypeError, ordered=True)
RaisesGroup(ValueError, match="foo", ordered=True)
RaisesGroup(ValueError, check=bool, ordered=True)
RaisesGroup(RaisesExc(ValueError), ordered=True)
RaisesGroup(RaisesGroup(ValueError), ordered=True)
RaisesGroup(ValueError, flatten_subgroups=True, ordered=True)

# if they're both false we can of course specify nested raisesgroup
RaisesGroup(RaisesGroup(ValueError))

Expand Down
Loading