Skip to content

Commit

Permalink
Add support to testing.RaisesGroup for catching unwrapped exceptions (p…
Browse files Browse the repository at this point in the history
…ython-trio#2989)

* Add support to testing.RaisesGroup for catching unwrapped exceptions with strict=False

* fix type error by adding covariance to typevar

* rewrite RaisesGroup docstring

* Work around +E typevar issue in docs for _raises_group

* Fix docs issue with type property in _ExceptionInfo

* split 'strict' into 'flatten_subgroups' and 'allow_unwrapped', fix bug where length check would fail incorrectly sometimes if using flatten_subgroups

* add deprecation of strict

* bump exceptiongroup to 1.2.1

* fix ^$ matching on exceptiongroups

* add test case for nested exceptiongroup + allow_unwrapped

* add signature overloads for RaisesGroup to raise type errors when doing incorrect incantations

* add pytest.deprecated_call() test

* add type tests for narrowing of check argument

---------

Co-authored-by: Spencer Brown <[email protected]>
Co-authored-by: Zac Hatfield-Dodds <[email protected]>
  • Loading branch information
3 people authored May 17, 2024
1 parent ccd40e1 commit 6f62575
Show file tree
Hide file tree
Showing 14 changed files with 445 additions and 56 deletions.
18 changes: 17 additions & 1 deletion docs/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import collections.abc
import os
import sys
import types
from typing import TYPE_CHECKING, cast

if TYPE_CHECKING:
Expand Down Expand Up @@ -98,14 +99,22 @@
# https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html#event-autodoc-process-signature
def autodoc_process_signature(
app: Sphinx,
what: object,
what: str,
name: str,
obj: object,
options: object,
signature: str,
return_annotation: str,
) -> tuple[str, str]:
"""Modify found signatures to fix various issues."""
if name == "trio.testing._raises_group._ExceptionInfo.type":
# This has the type "type[E]", which gets resolved into the property itself.
# That means Sphinx can't resolve it. Fix the issue by overwriting with a fully-qualified
# name.
assert isinstance(obj, property), obj
assert isinstance(obj.fget, types.FunctionType), obj.fget
assert obj.fget.__annotations__["return"] == "type[E]", obj.fget.__annotations__
obj.fget.__annotations__["return"] = "type[~trio.testing._raises_group.E]"
if signature is not None:
signature = signature.replace("~_contextvars.Context", "~contextvars.Context")
if name == "trio.lowlevel.RunVar": # Typevar is not useful here.
Expand All @@ -114,6 +123,13 @@ def autodoc_process_signature(
# Strip the type from the union, make it look like = ...
signature = signature.replace(" | type[trio._core._local._NoValue]", "")
signature = signature.replace("<class 'trio._core._local._NoValue'>", "...")
if (
name in ("trio.testing.RaisesGroup", "trio.testing.Matcher")
and "+E" in signature
):
# This typevar being covariant isn't handled correctly in some cases, strip the +
# and insert the fully-qualified name.
signature = signature.replace("+E", "~trio.testing._raises_group.E")
if "DTLS" in name:
signature = signature.replace("SSL.Context", "OpenSSL.SSL.Context")
# Don't specify PathLike[str] | PathLike[bytes], this is just for humans.
Expand Down
2 changes: 2 additions & 0 deletions newsfragments/2989.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fixed a bug where :class:`trio.testing.RaisesGroup(..., strict=False) <trio.testing.RaisesGroup>` would check the number of exceptions in the raised `ExceptionGroup` before flattening subgroups, leading to incorrectly failed matches.
It now properly supports end (``$``) regex markers in the ``match`` message, by no longer including " (x sub-exceptions)" in the string it matches against.
1 change: 1 addition & 0 deletions newsfragments/2989.deprecated.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Deprecated ``strict`` parameter from :class:`trio.testing.RaisesGroup`, previous functionality of ``strict=False`` is now in ``flatten_subgroups=True``.
1 change: 1 addition & 0 deletions newsfragments/2989.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
:class:`trio.testing.RaisesGroup` can now catch an unwrapped exception with ``unwrapped=True``. This means that the behaviour of :ref:`except* <except_star>` can be fully replicated in combination with ``flatten_subgroups=True`` (formerly ``strict=False``).
3 changes: 3 additions & 0 deletions src/trio/_core/_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,7 @@ def open_nursery(
"the default value of True and rewrite exception handlers to handle ExceptionGroups. "
"See https://trio.readthedocs.io/en/stable/reference-core.html#designing-for-multiple-errors"
),
use_triodeprecationwarning=True,
)

if strict_exception_groups is None:
Expand Down Expand Up @@ -2271,6 +2272,7 @@ def run(
"the default value of True and rewrite exception handlers to handle ExceptionGroups. "
"See https://trio.readthedocs.io/en/stable/reference-core.html#designing-for-multiple-errors"
),
use_triodeprecationwarning=True,
)

__tracebackhide__ = True
Expand Down Expand Up @@ -2387,6 +2389,7 @@ def my_done_callback(run_outcome):
"the default value of True and rewrite exception handlers to handle ExceptionGroups. "
"See https://trio.readthedocs.io/en/stable/reference-core.html#designing-for-multiple-errors"
),
use_triodeprecationwarning=True,
)

runner = setup_runner(
Expand Down
1 change: 1 addition & 0 deletions src/trio/_core/_unbounded_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ class UnboundedQueue(Generic[T]):
issue=497,
thing="trio.lowlevel.UnboundedQueue",
instead="trio.open_memory_channel(math.inf)",
use_triodeprecationwarning=True,
)
def __init__(self) -> None:
self._lot = _core.ParkingLot()
Expand Down
22 changes: 19 additions & 3 deletions src/trio/_deprecate.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ def warn_deprecated(
issue: int | None,
instead: object,
stacklevel: int = 2,
use_triodeprecationwarning: bool = False,
) -> None:
stacklevel += 1
msg = f"{_stringify(thing)} is deprecated since Trio {version}"
Expand All @@ -67,20 +68,35 @@ def warn_deprecated(
msg += f"; use {_stringify(instead)} instead"
if issue is not None:
msg += f" ({_url_for_issue(issue)})"
warnings.warn(TrioDeprecationWarning(msg), stacklevel=stacklevel)
if use_triodeprecationwarning:
warning_class: type[Warning] = TrioDeprecationWarning
else:
warning_class = DeprecationWarning
warnings.warn(warning_class(msg), stacklevel=stacklevel)


# @deprecated("0.2.0", issue=..., instead=...)
# def ...
def deprecated(
version: str, *, thing: object = None, issue: int | None, instead: object
version: str,
*,
thing: object = None,
issue: int | None,
instead: object,
use_triodeprecationwarning: bool = False,
) -> Callable[[Callable[ArgsT, RetT]], Callable[ArgsT, RetT]]:
def do_wrap(fn: Callable[ArgsT, RetT]) -> Callable[ArgsT, RetT]:
nonlocal thing

@wraps(fn)
def wrapper(*args: ArgsT.args, **kwargs: ArgsT.kwargs) -> RetT:
warn_deprecated(thing, version, instead=instead, issue=issue)
warn_deprecated(
thing,
version,
instead=instead,
issue=issue,
use_triodeprecationwarning=use_triodeprecationwarning,
)
return fn(*args, **kwargs)

# If our __module__ or __qualname__ get modified, we want to pick up
Expand Down
1 change: 1 addition & 0 deletions src/trio/_highlevel_open_tcp_listeners.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def _compute_backlog(backlog: int | None) -> int:
version="0.23.0",
instead="None",
issue=2842,
use_triodeprecationwarning=True,
)
if not isinstance(backlog, int) and backlog is not None:
raise TypeError(f"backlog must be an int or None, not {backlog!r}")
Expand Down
36 changes: 25 additions & 11 deletions src/trio/_tests/test_deprecate.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def deprecated_thing() -> None:
deprecated_thing()
filename, lineno = _here()
assert len(recwarn_always) == 1
got = recwarn_always.pop(TrioDeprecationWarning)
got = recwarn_always.pop(DeprecationWarning)
assert isinstance(got.message, Warning)
assert "ice is deprecated" in got.message.args[0]
assert "Trio 1.2" in got.message.args[0]
Expand All @@ -54,7 +54,7 @@ def test_warn_deprecated_no_instead_or_issue(
# Explicitly no instead or issue
warn_deprecated("water", "1.3", issue=None, instead=None)
assert len(recwarn_always) == 1
got = recwarn_always.pop(TrioDeprecationWarning)
got = recwarn_always.pop(DeprecationWarning)
assert isinstance(got.message, Warning)
assert "water is deprecated" in got.message.args[0]
assert "no replacement" in got.message.args[0]
Expand All @@ -70,7 +70,7 @@ def nested2() -> None:

filename, lineno = _here()
nested1()
got = recwarn_always.pop(TrioDeprecationWarning)
got = recwarn_always.pop(DeprecationWarning)
assert got.filename == filename
assert got.lineno == lineno + 1

Expand All @@ -85,7 +85,7 @@ def new() -> None: # pragma: no cover

def test_warn_deprecated_formatting(recwarn_always: pytest.WarningsRecorder) -> None:
warn_deprecated(old, "1.0", issue=1, instead=new)
got = recwarn_always.pop(TrioDeprecationWarning)
got = recwarn_always.pop(DeprecationWarning)
assert isinstance(got.message, Warning)
assert "test_deprecate.old is deprecated" in got.message.args[0]
assert "test_deprecate.new instead" in got.message.args[0]
Expand All @@ -98,7 +98,7 @@ def deprecated_old() -> int:

def test_deprecated_decorator(recwarn_always: pytest.WarningsRecorder) -> None:
assert deprecated_old() == 3
got = recwarn_always.pop(TrioDeprecationWarning)
got = recwarn_always.pop(DeprecationWarning)
assert isinstance(got.message, Warning)
assert "test_deprecate.deprecated_old is deprecated" in got.message.args[0]
assert "1.5" in got.message.args[0]
Expand All @@ -115,7 +115,7 @@ def method(self) -> int:
def test_deprecated_decorator_method(recwarn_always: pytest.WarningsRecorder) -> None:
f = Foo()
assert f.method() == 7
got = recwarn_always.pop(TrioDeprecationWarning)
got = recwarn_always.pop(DeprecationWarning)
assert isinstance(got.message, Warning)
assert "test_deprecate.Foo.method is deprecated" in got.message.args[0]

Expand All @@ -129,7 +129,7 @@ def test_deprecated_decorator_with_explicit_thing(
recwarn_always: pytest.WarningsRecorder,
) -> None:
assert deprecated_with_thing() == 72
got = recwarn_always.pop(TrioDeprecationWarning)
got = recwarn_always.pop(DeprecationWarning)
assert isinstance(got.message, Warning)
assert "the thing is deprecated" in got.message.args[0]

Expand All @@ -143,7 +143,7 @@ def new_hotness() -> str:

def test_deprecated_alias(recwarn_always: pytest.WarningsRecorder) -> None:
assert old_hotness() == "new hotness"
got = recwarn_always.pop(TrioDeprecationWarning)
got = recwarn_always.pop(DeprecationWarning)
assert isinstance(got.message, Warning)
assert "test_deprecate.old_hotness is deprecated" in got.message.args[0]
assert "1.23" in got.message.args[0]
Expand All @@ -168,7 +168,7 @@ def new_hotness_method(self) -> str:
def test_deprecated_alias_method(recwarn_always: pytest.WarningsRecorder) -> None:
obj = Alias()
assert obj.old_hotness_method() == "new hotness method"
got = recwarn_always.pop(TrioDeprecationWarning)
got = recwarn_always.pop(DeprecationWarning)
assert isinstance(got.message, Warning)
msg = got.message.args[0]
assert "test_deprecate.Alias.old_hotness_method is deprecated" in msg
Expand Down Expand Up @@ -243,7 +243,7 @@ def test_module_with_deprecations(recwarn_always: pytest.WarningsRecorder) -> No

filename, lineno = _here()
assert module_with_deprecations.dep1 == "value1" # type: ignore[attr-defined]
got = recwarn_always.pop(TrioDeprecationWarning)
got = recwarn_always.pop(DeprecationWarning)
assert isinstance(got.message, Warning)
assert got.filename == filename
assert got.lineno == lineno + 1
Expand All @@ -254,9 +254,23 @@ def test_module_with_deprecations(recwarn_always: pytest.WarningsRecorder) -> No
assert "value1 instead" in got.message.args[0]

assert module_with_deprecations.dep2 == "value2" # type: ignore[attr-defined]
got = recwarn_always.pop(TrioDeprecationWarning)
got = recwarn_always.pop(DeprecationWarning)
assert isinstance(got.message, Warning)
assert "instead-string instead" in got.message.args[0]

with pytest.raises(AttributeError):
module_with_deprecations.asdf # type: ignore[attr-defined] # noqa: B018 # "useless expression"


def test_warning_class() -> None:
with pytest.deprecated_call():
warn_deprecated("foo", "bar", issue=None, instead=None)

# essentially the same as the above check
with pytest.warns(DeprecationWarning):
warn_deprecated("foo", "bar", issue=None, instead=None)

with pytest.warns(TrioDeprecationWarning):
warn_deprecated(
"foo", "bar", issue=None, instead=None, use_triodeprecationwarning=True
)
Loading

0 comments on commit 6f62575

Please sign in to comment.