From ee89ee435e00f71f8927306da8709a40891ce673 Mon Sep 17 00:00:00 2001 From: Anneheartrecord Date: Sat, 13 Jun 2026 21:34:03 +0800 Subject: [PATCH] Add ordered parameter to RaisesGroup By default RaisesGroup ignores the order of the exceptions in the group and uses a greedy matching algorithm. The new ordered=True parameter matches the expected exceptions against the raised exceptions positionally, asserting the order and avoiding the greedy-algorithm pitfall where a valid pairing exists but is not found. --- AUTHORS | 1 + changelog/14580.feature.rst | 7 ++++ doc/en/how-to/assert.rst | 10 ++++++ src/_pytest/raises.py | 61 +++++++++++++++++++++++++++++++++- testing/python/raises_group.py | 56 +++++++++++++++++++++++++++++++ testing/typing_raises_group.py | 8 +++++ 6 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 changelog/14580.feature.rst diff --git a/AUTHORS b/AUTHORS index 972f39aa45e..e45b4ecc65e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -42,6 +42,7 @@ Andrzej Ostrowski Andy Freeland Anita Hammer Anna Tasiopoulou +Anneheartrecord Anthon van der Neut Anthony Shaw Anthony Sottile diff --git a/changelog/14580.feature.rst b/changelog/14580.feature.rst new file mode 100644 index 00000000000..490bf8835b2 --- /dev/null +++ b/changelog/14580.feature.rst @@ -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. diff --git a/doc/en/how-to/assert.rst b/doc/en/how-to/assert.rst index 43aeb66fdfc..6c3585de591 100644 --- a/doc/en/how-to/assert.rst +++ b/doc/en/how-to/assert.rst @@ -210,6 +210,16 @@ It is strict about structure and unwrapped exceptions, unlike :ref:`except* None: ... # simplify the typevars if possible (the following 3 are equivalent but go simpler->complicated) @@ -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 @@ -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 @@ -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 @@ -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 @@ -915,6 +934,7 @@ def __init__( check: ( Callable[[BaseExceptionGroup[BaseExceptionGroup[BaseExcT_2]]], bool] | None ) = None, + ordered: bool = False, ) -> None: ... @overload @@ -935,6 +955,7 @@ def __init__( ] | None ) = None, + ordered: bool = False, ) -> None: ... def __init__( @@ -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 @@ -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: @@ -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}") @@ -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) @@ -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, diff --git a/testing/python/raises_group.py b/testing/python/raises_group.py index 8b311bd0eed..c8dd12e4c13 100644 --- a/testing/python/raises_group.py +++ b/testing/python/raises_group.py @@ -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""" diff --git a/testing/typing_raises_group.py b/testing/typing_raises_group.py index 081ffd59bca..fe8534b559e 100644 --- a/testing/typing_raises_group.py +++ b/testing/typing_raises_group.py @@ -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))