Skip to content

Commit 8d37eb6

Browse files
A5rocksjakkdl
andauthored
Type RaisesGroup properly (#3141)
* Improve RaisesGroup typing now that mypy supports narrowing by self type * remove Sphinx workaround --------- Co-authored-by: John Litborn <[email protected]>
1 parent 7d9c4a6 commit 8d37eb6

File tree

6 files changed

+177
-152
lines changed

6 files changed

+177
-152
lines changed

docs/source/conf.py

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
import glob
2323
import os
2424
import sys
25-
import types
2625
from pathlib import Path
2726
from typing import TYPE_CHECKING, cast
2827

@@ -152,16 +151,6 @@ def autodoc_process_signature(
152151
return_annotation: str,
153152
) -> tuple[str, str]:
154153
"""Modify found signatures to fix various issues."""
155-
if name == "trio.testing._raises_group._ExceptionInfo.type":
156-
# This has the type "type[E]", which gets resolved into the property itself.
157-
# That means Sphinx can't resolve it. Fix the issue by overwriting with a fully-qualified
158-
# name.
159-
assert isinstance(obj, property), obj
160-
assert isinstance(obj.fget, types.FunctionType), obj.fget
161-
assert (
162-
obj.fget.__annotations__["return"] == "type[MatchE]"
163-
), obj.fget.__annotations__
164-
obj.fget.__annotations__["return"] = "type[~trio.testing._raises_group.MatchE]"
165154
if signature is not None:
166155
signature = signature.replace("~_contextvars.Context", "~contextvars.Context")
167156
if name == "trio.lowlevel.RunVar": # Typevar is not useful here.
@@ -170,16 +159,6 @@ def autodoc_process_signature(
170159
# Strip the type from the union, make it look like = ...
171160
signature = signature.replace(" | type[trio._core._local._NoValue]", "")
172161
signature = signature.replace("<class 'trio._core._local._NoValue'>", "...")
173-
if name in ("trio.testing.RaisesGroup", "trio.testing.Matcher") and (
174-
"+E" in signature or "+MatchE" in signature
175-
):
176-
# This typevar being covariant isn't handled correctly in some cases, strip the +
177-
# and insert the fully-qualified name.
178-
signature = signature.replace("+E", "~trio.testing._raises_group.E")
179-
signature = signature.replace(
180-
"+MatchE",
181-
"~trio.testing._raises_group.MatchE",
182-
)
183162
if "DTLS" in name:
184163
signature = signature.replace("SSL.Context", "OpenSSL.SSL.Context")
185164
# Don't specify PathLike[str] | PathLike[bytes], this is just for humans.

newsfragments/3141.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix `trio.testing.RaisesGroup`'s typing.

src/trio/_tests/test_testing_raisesgroup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def test_raises_group() -> None:
2424
f'Invalid argument "{TypeError()!r}" must be exception type, Matcher, or RaisesGroup.',
2525
),
2626
):
27-
RaisesGroup(TypeError())
27+
RaisesGroup(TypeError()) # type: ignore[call-overload]
2828

2929
with RaisesGroup(ValueError):
3030
raise ExceptionGroup("foo", (ValueError(),))

src/trio/_tests/type_tests/path.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def sync_attrs(path: trio.Path) -> None:
5454
assert_type(path.match("*.py"), bool)
5555
assert_type(path.relative_to("/usr"), trio.Path)
5656
if sys.version_info >= (3, 12):
57-
assert_type(path.relative_to("/", walk_up=True), bool)
57+
assert_type(path.relative_to("/", walk_up=True), trio.Path)
5858
assert_type(path.with_name("filename.txt"), trio.Path)
5959
assert_type(path.with_stem("readme"), trio.Path)
6060
assert_type(path.with_suffix(".log"), trio.Path)

src/trio/_tests/type_tests/raisesgroup.py

Lines changed: 47 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,7 @@
1-
"""The typing of RaisesGroup involves a lot of deception and lies, since AFAIK what we
2-
actually want to achieve is ~impossible. This is because we specify what we expect with
3-
instances of RaisesGroup and exception classes, but excinfo.value will be instances of
4-
[Base]ExceptionGroup and instances of exceptions. So we need to "translate" from
5-
RaisesGroup to ExceptionGroup.
6-
7-
The way it currently works is that RaisesGroup[E] corresponds to
8-
ExceptionInfo[BaseExceptionGroup[E]], so the top-level group will be correct. But
9-
RaisesGroup[RaisesGroup[ValueError]] will become
10-
ExceptionInfo[BaseExceptionGroup[RaisesGroup[ValueError]]]. To get around that we specify
11-
RaisesGroup as a subclass of BaseExceptionGroup during type checking - which should mean
12-
that most static type checking for end users should be mostly correct.
13-
"""
14-
151
from __future__ import annotations
162

173
import sys
18-
from typing import Union
4+
from typing import Callable, Union
195

206
from trio.testing import Matcher, RaisesGroup
217
from typing_extensions import assert_type
@@ -26,17 +12,6 @@
2612
# split into functions to isolate the different scopes
2713

2814

29-
def check_inheritance_and_assignments() -> None:
30-
# Check inheritance
31-
_: BaseExceptionGroup[ValueError] = RaisesGroup(ValueError)
32-
_ = RaisesGroup(RaisesGroup(ValueError)) # type: ignore
33-
34-
a: BaseExceptionGroup[BaseExceptionGroup[ValueError]]
35-
a = RaisesGroup(RaisesGroup(ValueError))
36-
a = BaseExceptionGroup("", (BaseExceptionGroup("", (ValueError(),)),))
37-
assert a
38-
39-
4015
def check_matcher_typevar_default(e: Matcher) -> object:
4116
assert e.exception_type is not None
4217
exc: type[BaseException] = e.exception_type
@@ -46,29 +21,32 @@ def check_matcher_typevar_default(e: Matcher) -> object:
4621

4722

4823
def check_basic_contextmanager() -> None:
49-
# One level of Group is correctly translated - except it's a BaseExceptionGroup
50-
# instead of an ExceptionGroup.
5124
with RaisesGroup(ValueError) as e:
5225
raise ExceptionGroup("foo", (ValueError(),))
53-
assert_type(e.value, BaseExceptionGroup[ValueError])
26+
assert_type(e.value, ExceptionGroup[ValueError])
5427

5528

5629
def check_basic_matches() -> None:
5730
# check that matches gets rid of the naked ValueError in the union
5831
exc: ExceptionGroup[ValueError] | ValueError = ExceptionGroup("", (ValueError(),))
5932
if RaisesGroup(ValueError).matches(exc):
60-
assert_type(exc, BaseExceptionGroup[ValueError])
33+
assert_type(exc, ExceptionGroup[ValueError])
34+
35+
# also check that BaseExceptionGroup shows up for BaseExceptions
36+
if RaisesGroup(KeyboardInterrupt).matches(exc):
37+
assert_type(exc, BaseExceptionGroup[KeyboardInterrupt])
6138

6239

6340
def check_matches_with_different_exception_type() -> None:
64-
# This should probably raise some type error somewhere, since
65-
# ValueError != KeyboardInterrupt
6641
e: BaseExceptionGroup[KeyboardInterrupt] = BaseExceptionGroup(
6742
"",
6843
(KeyboardInterrupt(),),
6944
)
45+
46+
# note: it might be tempting to have this warn.
47+
# however, that isn't possible with current typing
7048
if RaisesGroup(ValueError).matches(e):
71-
assert_type(e, BaseExceptionGroup[ValueError])
49+
assert_type(e, ExceptionGroup[ValueError])
7250

7351

7452
def check_matcher_init() -> None:
@@ -134,59 +112,44 @@ def handle_value(e: BaseExceptionGroup[ValueError]) -> bool:
134112

135113

136114
def raisesgroup_narrow_baseexceptiongroup() -> None:
137-
"""Check type narrowing specifically for the container exceptiongroup.
138-
This is not currently working, and after playing around with it for a bit
139-
I think the only way is to introduce a subclass `NonBaseRaisesGroup`, and overload
140-
`__new__` in Raisesgroup to return the subclass when exceptions are non-base.
141-
(or make current class BaseRaisesGroup and introduce RaisesGroup for non-base)
142-
I encountered problems trying to type this though, see
143-
https://github.com/python/mypy/issues/17251
144-
That is probably possible to work around by entirely using `__new__` instead of
145-
`__init__`, but........ ugh.
146-
"""
115+
"""Check type narrowing specifically for the container exceptiongroup."""
147116

148117
def handle_group(e: ExceptionGroup[Exception]) -> bool:
149118
return True
150119

151120
def handle_group_value(e: ExceptionGroup[ValueError]) -> bool:
152121
return True
153122

154-
# should work, but BaseExceptionGroup does not get narrowed to ExceptionGroup
155-
RaisesGroup(ValueError, check=handle_group_value) # type: ignore
123+
RaisesGroup(ValueError, check=handle_group_value)
156124

157-
# should work, but BaseExceptionGroup does not get narrowed to ExceptionGroup
158-
RaisesGroup(Exception, check=handle_group) # type: ignore
125+
RaisesGroup(Exception, check=handle_group)
159126

160127

161128
def check_matcher_transparent() -> None:
162129
with RaisesGroup(Matcher(ValueError)) as e:
163130
...
164131
_: BaseExceptionGroup[ValueError] = e.value
165-
assert_type(e.value, BaseExceptionGroup[ValueError])
132+
assert_type(e.value, ExceptionGroup[ValueError])
166133

167134

168135
def check_nested_raisesgroups_contextmanager() -> None:
169136
with RaisesGroup(RaisesGroup(ValueError)) as excinfo:
170137
raise ExceptionGroup("foo", (ValueError(),))
171138

172-
# thanks to inheritance this assignment works
173139
_: BaseExceptionGroup[BaseExceptionGroup[ValueError]] = excinfo.value
174-
# and it can mostly be treated like an exceptiongroup
175-
print(excinfo.value.exceptions[0].exceptions[0])
176140

177-
# but assert_type reveals the lies
178-
print(type(excinfo.value)) # would print "ExceptionGroup"
179-
# typing says it's a BaseExceptionGroup
180141
assert_type(
181142
excinfo.value,
182-
BaseExceptionGroup[RaisesGroup[ValueError]],
143+
ExceptionGroup[ExceptionGroup[ValueError]],
183144
)
184145

185-
print(type(excinfo.value.exceptions[0])) # would print "ExceptionGroup"
186-
# but type checkers are utterly confused
187146
assert_type(
188147
excinfo.value.exceptions[0],
189-
Union[RaisesGroup[ValueError], BaseExceptionGroup[RaisesGroup[ValueError]]],
148+
# this union is because of how typeshed defines .exceptions
149+
Union[
150+
ExceptionGroup[ValueError],
151+
ExceptionGroup[ExceptionGroup[ValueError]],
152+
],
190153
)
191154

192155

@@ -196,17 +159,17 @@ def check_nested_raisesgroups_matches() -> None:
196159
"",
197160
(ExceptionGroup("", (ValueError(),)),),
198161
)
199-
# has the same problems as check_nested_raisesgroups_contextmanager
162+
200163
if RaisesGroup(RaisesGroup(ValueError)).matches(exc):
201-
assert_type(exc, BaseExceptionGroup[RaisesGroup[ValueError]])
164+
assert_type(exc, ExceptionGroup[ExceptionGroup[ValueError]])
202165

203166

204167
def check_multiple_exceptions_1() -> None:
205168
a = RaisesGroup(ValueError, ValueError)
206169
b = RaisesGroup(Matcher(ValueError), Matcher(ValueError))
207170
c = RaisesGroup(ValueError, Matcher(ValueError))
208171

209-
d: BaseExceptionGroup[ValueError]
172+
d: RaisesGroup[ValueError]
210173
d = a
211174
d = b
212175
d = c
@@ -219,7 +182,7 @@ def check_multiple_exceptions_2() -> None:
219182
b = RaisesGroup(Matcher(ValueError), TypeError)
220183
c = RaisesGroup(ValueError, TypeError)
221184

222-
d: BaseExceptionGroup[Exception]
185+
d: RaisesGroup[Exception]
223186
d = a
224187
d = b
225188
d = c
@@ -252,3 +215,25 @@ def check_raisesgroup_overloads() -> None:
252215

253216
# if they're both false we can of course specify nested raisesgroup
254217
RaisesGroup(RaisesGroup(ValueError))
218+
219+
220+
def check_triple_nested_raisesgroup() -> None:
221+
with RaisesGroup(RaisesGroup(RaisesGroup(ValueError))) as e:
222+
assert_type(e.value, ExceptionGroup[ExceptionGroup[ExceptionGroup[ValueError]]])
223+
224+
225+
def check_check_typing() -> None:
226+
# mypy issue is https://github.com/python/mypy/issues/18185
227+
228+
# fmt: off
229+
# mypy raises an error on `assert_type`
230+
# pyright raises an error on `RaisesGroup(ValueError).check`
231+
# to satisfy both, need to disable formatting and put it on one line
232+
assert_type(RaisesGroup(ValueError).check, # type: ignore
233+
Union[
234+
Callable[[BaseExceptionGroup[ValueError]], None],
235+
Callable[[ExceptionGroup[ValueError]], None],
236+
None,
237+
],
238+
)
239+
# fmt: on

0 commit comments

Comments
 (0)