Skip to content

Commit aadd1ea

Browse files
jakkdlCoolCat467TeamSpen210A5rocks
authored
Add RaisesGroup, a helper for catching ExceptionGroups in tests (#2898)
* Add RaisesGroup, a helper for catching ExceptionGroups in tests * Added helpers: Matcher and _ExceptionInfo * Tests and type tests for all of the above * Rewrite several existing tests to use this helper --------- Co-authored-by: CoolCat467 <52022020+CoolCat467@users.noreply.github.com> Co-authored-by: Spencer Brown <spencerb21@live.com> Co-authored-by: EXPLOSION <git@helvetica.moe>
1 parent ec011f4 commit aadd1ea

File tree

9 files changed

+962
-101
lines changed

9 files changed

+962
-101
lines changed

docs/source/reference-testing.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,3 +219,16 @@ Testing checkpoints
219219

220220
.. autofunction:: assert_no_checkpoints
221221
:with:
222+
223+
224+
ExceptionGroup helpers
225+
----------------------
226+
227+
.. autoclass:: RaisesGroup
228+
:members:
229+
230+
.. autoclass:: Matcher
231+
:members:
232+
233+
.. autoclass:: trio.testing._raises_group._ExceptionInfo
234+
:members:

newsfragments/2785.feature.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
New helper classes: :class:`~.testing.RaisesGroup` and :class:`~.testing.Matcher`.
2+
3+
In preparation for changing the default of ``strict_exception_groups`` to `True`, we're introducing a set of helper classes that can be used in place of `pytest.raises <https://docs.pytest.org/en/stable/reference/reference.html#pytest.raises>`_ in tests, to check for an expected `ExceptionGroup`.
4+
These are provisional, and only planned to be supplied until there's a good solution in ``pytest``. See https://github.com/pytest-dev/pytest/issues/11538

src/trio/_core/_tests/test_run.py

Lines changed: 67 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@
1919
from ... import _core
2020
from ..._threads import to_thread_run_sync
2121
from ..._timeouts import fail_after, sleep
22-
from ...testing import Sequencer, assert_checkpoints, wait_all_tasks_blocked
22+
from ...testing import (
23+
Matcher,
24+
RaisesGroup,
25+
Sequencer,
26+
assert_checkpoints,
27+
wait_all_tasks_blocked,
28+
)
2329
from .._run import DEADLINE_HEAP_MIN_PRUNE_THRESHOLD
2430
from .tutil import (
2531
check_sequence_matches,
@@ -192,13 +198,8 @@ async def main() -> NoReturn:
192198
nursery.start_soon(crasher)
193199
raise KeyError
194200

195-
with pytest.raises(ExceptionGroup) as excinfo:
201+
with RaisesGroup(ValueError, KeyError):
196202
_core.run(main)
197-
print(excinfo.value)
198-
assert {type(exc) for exc in excinfo.value.exceptions} == {
199-
ValueError,
200-
KeyError,
201-
}
202203

203204

204205
def test_two_child_crashes() -> None:
@@ -210,12 +211,8 @@ async def main() -> None:
210211
nursery.start_soon(crasher, KeyError)
211212
nursery.start_soon(crasher, ValueError)
212213

213-
with pytest.raises(ExceptionGroup) as excinfo:
214+
with RaisesGroup(ValueError, KeyError):
214215
_core.run(main)
215-
assert {type(exc) for exc in excinfo.value.exceptions} == {
216-
ValueError,
217-
KeyError,
218-
}
219216

220217

221218
async def test_child_crash_wakes_parent() -> None:
@@ -429,16 +426,18 @@ async def test_cancel_scope_exceptiongroup_filtering() -> None:
429426
async def crasher() -> NoReturn:
430427
raise KeyError
431428

432-
# check that the inner except is properly executed.
433-
# alternative would be to have a `except BaseException` and an `else`
434-
exception_group_caught_inner = False
435-
436429
# This is outside the outer scope, so all the Cancelled
437430
# exceptions should have been absorbed, leaving just a regular
438431
# KeyError from crasher()
439432
with pytest.raises(KeyError): # noqa: PT012
440433
with _core.CancelScope() as outer:
441-
try:
434+
# Since the outer scope became cancelled before the
435+
# nursery block exited, all cancellations inside the
436+
# nursery block continue propagating to reach the
437+
# outer scope.
438+
with RaisesGroup(
439+
_core.Cancelled, _core.Cancelled, _core.Cancelled, KeyError
440+
) as excinfo:
442441
async with _core.open_nursery() as nursery:
443442
# Two children that get cancelled by the nursery scope
444443
nursery.start_soon(sleep_forever) # t1
@@ -452,22 +451,9 @@ async def crasher() -> NoReturn:
452451
# And one that raises a different error
453452
nursery.start_soon(crasher) # t4
454453
# and then our __aexit__ also receives an outer Cancelled
455-
except BaseExceptionGroup as multi_exc:
456-
exception_group_caught_inner = True
457-
# Since the outer scope became cancelled before the
458-
# nursery block exited, all cancellations inside the
459-
# nursery block continue propagating to reach the
460-
# outer scope.
461-
# the noqa is for "Found assertion on exception `multi_exc` in `except` block"
462-
assert len(multi_exc.exceptions) == 4 # noqa: PT017
463-
summary: dict[type, int] = {}
464-
for exc in multi_exc.exceptions:
465-
summary.setdefault(type(exc), 0)
466-
summary[type(exc)] += 1
467-
assert summary == {_core.Cancelled: 3, KeyError: 1}
468-
raise
469-
470-
assert exception_group_caught_inner
454+
# reraise the exception caught by RaisesGroup for the
455+
# CancelScope to handle
456+
raise excinfo.value
471457

472458

473459
async def test_precancelled_task() -> None:
@@ -788,14 +774,22 @@ async def task2() -> None:
788774
RuntimeError, match="which had already been exited"
789775
) as exc_info:
790776
await nursery_mgr.__aexit__(*sys.exc_info())
791-
assert type(exc_info.value.__context__) is ExceptionGroup
792-
assert len(exc_info.value.__context__.exceptions) == 3
793-
cancelled_in_context = False
794-
for exc in exc_info.value.__context__.exceptions:
795-
assert isinstance(exc, RuntimeError)
796-
assert "closed before the task exited" in str(exc)
797-
cancelled_in_context |= isinstance(exc.__context__, _core.Cancelled)
798-
assert cancelled_in_context # for the sleep_forever
777+
778+
def no_context(exc: RuntimeError) -> bool:
779+
return exc.__context__ is None
780+
781+
msg = "closed before the task exited"
782+
group = RaisesGroup(
783+
Matcher(RuntimeError, match=msg, check=no_context),
784+
Matcher(RuntimeError, match=msg, check=no_context),
785+
# sleep_forever
786+
Matcher(
787+
RuntimeError,
788+
match=msg,
789+
check=lambda x: isinstance(x.__context__, _core.Cancelled),
790+
),
791+
)
792+
assert group.matches(exc_info.value.__context__)
799793

800794
# Trying to exit a cancel scope from an unrelated task raises an error
801795
# without affecting any state
@@ -949,11 +943,7 @@ async def main() -> None:
949943
with pytest.raises(_core.TrioInternalError) as excinfo:
950944
_core.run(main)
951945

952-
me = excinfo.value.__cause__
953-
assert isinstance(me, ExceptionGroup)
954-
assert len(me.exceptions) == 2
955-
for exc in me.exceptions:
956-
assert isinstance(exc, (KeyError, ValueError))
946+
assert RaisesGroup(KeyError, ValueError).matches(excinfo.value.__cause__)
957947

958948

959949
def test_system_task_crash_plus_Cancelled() -> None:
@@ -1210,12 +1200,11 @@ async def test_nursery_exception_chaining_doesnt_make_context_loops() -> None:
12101200
async def crasher() -> NoReturn:
12111201
raise KeyError
12121202

1213-
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
1203+
# the ExceptionGroup should not have the KeyError or ValueError as context
1204+
with RaisesGroup(ValueError, KeyError, check=lambda x: x.__context__ is None):
12141205
async with _core.open_nursery() as nursery:
12151206
nursery.start_soon(crasher)
12161207
raise ValueError
1217-
# the ExceptionGroup should not have the KeyError or ValueError as context
1218-
assert excinfo.value.__context__ is None
12191208

12201209

12211210
def test_TrioToken_identity() -> None:
@@ -1980,11 +1969,10 @@ async def test_nursery_stop_iteration() -> None:
19801969
async def fail() -> NoReturn:
19811970
raise ValueError
19821971

1983-
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
1972+
with RaisesGroup(StopIteration, ValueError):
19841973
async with _core.open_nursery() as nursery:
19851974
nursery.start_soon(fail)
19861975
raise StopIteration
1987-
assert tuple(map(type, excinfo.value.exceptions)) == (StopIteration, ValueError)
19881976

19891977

19901978
async def test_nursery_stop_async_iteration() -> None:
@@ -2033,7 +2021,18 @@ async def test_traceback_frame_removal() -> None:
20332021
async def my_child_task() -> NoReturn:
20342022
raise KeyError()
20352023

2036-
with pytest.raises(ExceptionGroup) as excinfo: # noqa: PT012
2024+
def check_traceback(exc: KeyError) -> bool:
2025+
# The top frame in the exception traceback should be inside the child
2026+
# task, not trio/contextvars internals. And there's only one frame
2027+
# inside the child task, so this will also detect if our frame-removal
2028+
# is too eager.
2029+
tb = exc.__traceback__
2030+
assert tb is not None
2031+
return tb.tb_frame.f_code is my_child_task.__code__
2032+
2033+
expected_exception = Matcher(KeyError, check=check_traceback)
2034+
2035+
with RaisesGroup(expected_exception, expected_exception):
20372036
# Trick: For now cancel/nursery scopes still leave a bunch of tb gunk
20382037
# behind. But if there's an ExceptionGroup, they leave it on the group,
20392038
# which lets us get a clean look at the KeyError itself. Someday I
@@ -2042,15 +2041,6 @@ async def my_child_task() -> NoReturn:
20422041
async with _core.open_nursery() as nursery:
20432042
nursery.start_soon(my_child_task)
20442043
nursery.start_soon(my_child_task)
2045-
first_exc = excinfo.value.exceptions[0]
2046-
assert isinstance(first_exc, KeyError)
2047-
# The top frame in the exception traceback should be inside the child
2048-
# task, not trio/contextvars internals. And there's only one frame
2049-
# inside the child task, so this will also detect if our frame-removal
2050-
# is too eager.
2051-
tb = first_exc.__traceback__
2052-
assert tb is not None
2053-
assert tb.tb_frame.f_code is my_child_task.__code__
20542044

20552045

20562046
def test_contextvar_support() -> None:
@@ -2529,15 +2519,12 @@ async def main() -> NoReturn:
25292519
async with _core.open_nursery():
25302520
raise Exception("foo")
25312521

2532-
with pytest.raises(
2533-
ExceptionGroup, match="^Exceptions from Trio nursery \\(1 sub-exception\\)$"
2534-
) as exc:
2522+
with RaisesGroup(
2523+
Matcher(Exception, match="^foo$"),
2524+
match="^Exceptions from Trio nursery \\(1 sub-exception\\)$",
2525+
):
25352526
_core.run(main, strict_exception_groups=True)
25362527

2537-
assert len(exc.value.exceptions) == 1
2538-
assert type(exc.value.exceptions[0]) is Exception
2539-
assert exc.value.exceptions[0].args == ("foo",)
2540-
25412528

25422529
def test_run_strict_exception_groups_nursery_override() -> None:
25432530
"""
@@ -2555,14 +2542,10 @@ async def main() -> NoReturn:
25552542

25562543
async def test_nursery_strict_exception_groups() -> None:
25572544
"""Test that strict exception groups can be enabled on a per-nursery basis."""
2558-
with pytest.raises(ExceptionGroup) as exc:
2545+
with RaisesGroup(Matcher(Exception, match="^foo$")):
25592546
async with _core.open_nursery(strict_exception_groups=True):
25602547
raise Exception("foo")
25612548

2562-
assert len(exc.value.exceptions) == 1
2563-
assert type(exc.value.exceptions[0]) is Exception
2564-
assert exc.value.exceptions[0].args == ("foo",)
2565-
25662549

25672550
async def test_nursery_loose_exception_groups() -> None:
25682551
"""Test that loose exception groups can be enabled on a per-nursery basis."""
@@ -2573,20 +2556,18 @@ async def raise_error() -> NoReturn:
25732556
with pytest.raises(RuntimeError, match="^test error$"):
25742557
async with _core.open_nursery(strict_exception_groups=False) as nursery:
25752558
nursery.start_soon(raise_error)
2576-
2577-
with pytest.raises( # noqa: PT012 # multiple statements
2578-
ExceptionGroup, match="^Exceptions from Trio nursery \\(2 sub-exceptions\\)$"
2579-
) as exc:
2559+
m = Matcher(RuntimeError, match="^test error$")
2560+
2561+
with RaisesGroup(
2562+
m,
2563+
m,
2564+
match="Exceptions from Trio nursery \\(2 sub-exceptions\\)",
2565+
check=lambda x: x.__notes__ == [_core._run.NONSTRICT_EXCEPTIONGROUP_NOTE],
2566+
):
25802567
async with _core.open_nursery(strict_exception_groups=False) as nursery:
25812568
nursery.start_soon(raise_error)
25822569
nursery.start_soon(raise_error)
25832570

2584-
assert exc.value.__notes__ == [_core._run.NONSTRICT_EXCEPTIONGROUP_NOTE]
2585-
assert len(exc.value.exceptions) == 2
2586-
for subexc in exc.value.exceptions:
2587-
assert type(subexc) is RuntimeError
2588-
assert subexc.args == ("test error",)
2589-
25902571

25912572
async def test_nursery_collapse_strict() -> None:
25922573
"""
@@ -2597,7 +2578,7 @@ async def test_nursery_collapse_strict() -> None:
25972578
async def raise_error() -> NoReturn:
25982579
raise RuntimeError("test error")
25992580

2600-
with pytest.raises(ExceptionGroup) as exc: # noqa: PT012
2581+
with RaisesGroup(RuntimeError, RaisesGroup(RuntimeError)):
26012582
async with _core.open_nursery() as nursery:
26022583
nursery.start_soon(sleep_forever)
26032584
nursery.start_soon(raise_error)
@@ -2606,13 +2587,6 @@ async def raise_error() -> NoReturn:
26062587
nursery2.start_soon(raise_error)
26072588
nursery.cancel_scope.cancel()
26082589

2609-
exceptions = exc.value.exceptions
2610-
assert len(exceptions) == 2
2611-
assert isinstance(exceptions[0], RuntimeError)
2612-
assert isinstance(exceptions[1], ExceptionGroup)
2613-
assert len(exceptions[1].exceptions) == 1
2614-
assert isinstance(exceptions[1].exceptions[0], RuntimeError)
2615-
26162590

26172591
async def test_nursery_collapse_loose() -> None:
26182592
"""
@@ -2623,7 +2597,7 @@ async def test_nursery_collapse_loose() -> None:
26232597
async def raise_error() -> NoReturn:
26242598
raise RuntimeError("test error")
26252599

2626-
with pytest.raises(ExceptionGroup) as exc: # noqa: PT012
2600+
with RaisesGroup(RuntimeError, RuntimeError):
26272601
async with _core.open_nursery() as nursery:
26282602
nursery.start_soon(sleep_forever)
26292603
nursery.start_soon(raise_error)
@@ -2632,19 +2606,14 @@ async def raise_error() -> NoReturn:
26322606
nursery2.start_soon(raise_error)
26332607
nursery.cancel_scope.cancel()
26342608

2635-
exceptions = exc.value.exceptions
2636-
assert len(exceptions) == 2
2637-
assert isinstance(exceptions[0], RuntimeError)
2638-
assert isinstance(exceptions[1], RuntimeError)
2639-
26402609

26412610
async def test_cancel_scope_no_cancellederror() -> None:
26422611
"""
26432612
Test that when a cancel scope encounters an exception group that does NOT contain
26442613
a Cancelled exception, it will NOT set the ``cancelled_caught`` flag.
26452614
"""
26462615

2647-
with pytest.raises(ExceptionGroup): # noqa: PT012
2616+
with RaisesGroup(RuntimeError, RuntimeError, match="test"):
26482617
with _core.CancelScope() as scope:
26492618
scope.cancel()
26502619
raise ExceptionGroup("test", [RuntimeError(), RuntimeError()])

src/trio/_tests/test_exports.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,10 @@ def lookup_symbol(symbol: str) -> dict[str, str]:
317317
if module_name == "trio.socket" and class_name in dir(stdlib_socket):
318318
continue
319319

320+
# ignore class that does dirty tricks
321+
if class_ is trio.testing.RaisesGroup:
322+
continue
323+
320324
# dir() and inspect.getmembers doesn't display properties from the metaclass
321325
# also ignore some dunder methods that tend to differ but are of no consequence
322326
ignore_names = set(dir(type(class_))) | {
@@ -429,7 +433,9 @@ def lookup_symbol(symbol: str) -> dict[str, str]:
429433
if tool == "mypy" and class_ == trio.Nursery:
430434
extra.remove("cancel_scope")
431435

432-
# TODO: I'm not so sure about these, but should still be looked at.
436+
# These are (mostly? solely?) *runtime* attributes, often set in
437+
# __init__, which doesn't show up with dir() or inspect.getmembers,
438+
# but we get them in the way we query mypy & jedi
433439
EXTRAS = {
434440
trio.DTLSChannel: {"peer_address", "endpoint"},
435441
trio.DTLSEndpoint: {"socket", "incoming_packets_buffer"},
@@ -444,6 +450,11 @@ def lookup_symbol(symbol: str) -> dict[str, str]:
444450
"send_all_hook",
445451
"wait_send_all_might_not_block_hook",
446452
},
453+
trio.testing.Matcher: {
454+
"exception_type",
455+
"match",
456+
"check",
457+
},
447458
}
448459
if tool == "mypy" and class_ in EXTRAS:
449460
before = len(extra)

src/trio/_tests/test_highlevel_open_tcp_stream.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
reorder_for_rfc_6555_section_5_4,
1717
)
1818
from trio.socket import AF_INET, AF_INET6, IPPROTO_TCP, SOCK_STREAM, SocketType
19+
from trio.testing import Matcher, RaisesGroup
1920

2021
if TYPE_CHECKING:
2122
from trio.testing import MockClock
@@ -530,8 +531,12 @@ async def test_all_fail(autojump_clock: MockClock) -> None:
530531
expect_error=OSError,
531532
)
532533
assert isinstance(exc, OSError)
533-
assert isinstance(exc.__cause__, BaseExceptionGroup)
534-
assert len(exc.__cause__.exceptions) == 4
534+
535+
subexceptions = (Matcher(OSError, match="^sorry$"),) * 4
536+
assert RaisesGroup(
537+
*subexceptions, match="all attempts to connect to test.example.com:80 failed"
538+
).matches(exc.__cause__)
539+
535540
assert trio.current_time() == (0.1 + 0.2 + 10)
536541
assert scenario.connect_times == {
537542
"1.1.1.1": 0,

0 commit comments

Comments
 (0)