From 9a48173a6a4f0c6e046e54da902622ef3b987acd Mon Sep 17 00:00:00 2001 From: jakkdl Date: Fri, 1 Dec 2023 17:45:50 +0100 Subject: [PATCH 1/4] draft implementation of ExpectedExceptionGroup --- src/_pytest/python_api.py | 235 +++++++++++++++++- testing/python/expected_exception_group.py | 262 +++++++++++++++++++++ testing/python/raises.py | 15 +- 3 files changed, 496 insertions(+), 16 deletions(-) create mode 100644 testing/python/expected_exception_group.py diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 07db0f234d4..4646540507f 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -1,5 +1,6 @@ import math import pprint +import re from collections.abc import Collection from collections.abc import Sized from decimal import Decimal @@ -10,6 +11,8 @@ from typing import cast from typing import ContextManager from typing import final +from typing import Generic +from typing import Iterable from typing import List from typing import Mapping from typing import Optional @@ -22,6 +25,10 @@ from typing import TypeVar from typing import Union +if TYPE_CHECKING: + from typing_extensions import TypeAlias, TypeGuard + + import _pytest._code from _pytest.compat import STRING_TYPES from _pytest.outcomes import fail @@ -780,6 +787,149 @@ def _as_numpy_array(obj: object) -> Optional["ndarray"]: # builtin pytest.raises helper E = TypeVar("E", bound=BaseException) +E2 = TypeVar("E2", bound=BaseException) + + +class Matcher(Generic[E]): + def __init__( + self, + exception_type: Optional[type[E]] = None, + match: Optional[Union[str, Pattern[str]]] = None, + check: Optional[Callable[[E], bool]] = None, + ): + if exception_type is None and match is None and check is None: + raise ValueError("You must specify at least one parameter to match on.") + self.exception_type = exception_type + self.match = match + self.check = check + + def matches(self, exception: E) -> "TypeGuard[E]": + if self.exception_type is not None and not isinstance( + exception, self.exception_type + ): + return False + if self.match is not None and not re.search(self.match, str(exception)): + return False + if self.check is not None and not self.check(exception): + return False + return True + + +# TODO: rename if kept, EEE[E] looks like gibberish +EEE: "TypeAlias" = Union[Matcher[E], Type[E], "ExpectedExceptionGroup[E]"] + +if TYPE_CHECKING: + SuperClass = BaseExceptionGroup +else: + SuperClass = Generic + + +# it's unclear if +# `ExpectedExceptionGroup(ValueError, strict=False).matches(ValueError())` +# should return True. It matches the behaviour of expect*, but is maybe better handled +# by the end user doing pytest.raises((ValueError, ExpectedExceptionGroup(ValueError))) +@final +class ExpectedExceptionGroup(SuperClass[E]): + # TODO: overload to disallow nested exceptiongroup with strict=False + # @overload + # def __init__(self, exceptions: Union[Matcher[E], Type[E]], *args: Union[Matcher[E], + # Type[E]], strict: Literal[False]): ... + + # @overload + # def __init__(self, exceptions: EEE[E], *args: EEE[E], strict: bool = True): ... + + def __init__( + self, + exceptions: Union[Type[E], E, Matcher[E]], + *args: Union[Type[E], E, Matcher[E]], + strict: bool = True, + ): + # could add parameter `notes: Optional[Tuple[str, Pattern[str]]] = None` + self.expected_exceptions = (exceptions, *args) + self.strict = strict + + for exc in self.expected_exceptions: + if not isinstance(exc, (Matcher, ExpectedExceptionGroup)) and not ( + isinstance(exc, type) and issubclass(exc, BaseException) + ): + raise ValueError( + "Invalid argument {exc} must be exception type, Matcher, or ExpectedExceptionGroup." + ) + if isinstance(exc, ExpectedExceptionGroup) and not strict: + raise ValueError( + "You cannot specify a nested structure inside an ExpectedExceptionGroup with strict=False" + ) + + def _unroll_exceptions( + self, exceptions: Iterable[BaseException] + ) -> Iterable[BaseException]: + res: list[BaseException] = [] + for exc in exceptions: + if isinstance(exc, BaseExceptionGroup): + res.extend(self._unroll_exceptions(exc.exceptions)) + + else: + res.append(exc) + return res + + def matches( + self, + exc_val: Optional[BaseException], + ) -> "TypeGuard[BaseExceptionGroup[E]]": + if exc_val is None: + return False + if not isinstance(exc_val, BaseExceptionGroup): + return False + if not len(exc_val.exceptions) == len(self.expected_exceptions): + return False + remaining_exceptions = list(self.expected_exceptions) + actual_exceptions: Iterable[BaseException] = exc_val.exceptions + if not self.strict: + actual_exceptions = self._unroll_exceptions(actual_exceptions) + + # it should be possible to get ExpectedExceptionGroup.matches typed so as not to + # need these type: ignores, but I'm not sure that's possible while also having it + # transparent for the end user. + for e in actual_exceptions: + for rem_e in remaining_exceptions: + # TODO: how to print string diff on mismatch? + # Probably accumulate them, and then if fail, print them + # Further QoL would be to print how the exception structure differs on non-match + if ( + (isinstance(rem_e, type) and isinstance(e, rem_e)) + or ( + isinstance(e, BaseExceptionGroup) + and isinstance(rem_e, ExpectedExceptionGroup) + and rem_e.matches(e) + ) + or ( + isinstance(rem_e, Matcher) + and rem_e.matches(e) # type: ignore[arg-type] + ) + ): + remaining_exceptions.remove(rem_e) # type: ignore[arg-type] + break + else: + return False + return True + + # def __str__(self) -> str: + # return f"ExceptionGroup{self.expected_exceptions}" + # str(tuple(...)) seems to call repr + def __repr__(self) -> str: + # TODO: [Base]ExceptionGroup + return f"ExceptionGroup{self.expected_exceptions}" + + +@overload +def raises( + expected_exception: Union[ + ExpectedExceptionGroup[E], Tuple[ExpectedExceptionGroup[E], ...] + ], + *, + match: Optional[Union[str, Pattern[str]]] = ..., +) -> "RaisesContext[ExpectedExceptionGroup[E]]": + ... @overload @@ -791,6 +941,17 @@ def raises( ... +# +# +# @overload +# def raises( +# expected_exception: Tuple[Union[Type[E], ExpectedExceptionGroup[E2]], ...], +# *, +# match: Optional[Union[str, Pattern[str]]] = ..., +# ) -> "RaisesContext[Union[E, BaseExceptionGroup[E2]]]": +# ... + + @overload def raises( # noqa: F811 expected_exception: Union[Type[E], Tuple[Type[E], ...]], @@ -801,9 +962,20 @@ def raises( # noqa: F811 ... -def raises( # noqa: F811 - expected_exception: Union[Type[E], Tuple[Type[E], ...]], *args: Any, **kwargs: Any -) -> Union["RaisesContext[E]", _pytest._code.ExceptionInfo[E]]: +def raises( + expected_exception: Union[ + Type[E], + ExpectedExceptionGroup[E2], + Tuple[Union[Type[E], ExpectedExceptionGroup[E2]], ...], + ], + *args: Any, + **kwargs: Any, +) -> Union[ + "RaisesContext[E]", + "RaisesContext[BaseExceptionGroup[E2]]", + "RaisesContext[Union[E, BaseExceptionGroup[E2]]]", + _pytest._code.ExceptionInfo[E], +]: r"""Assert that a code block/function call raises an exception type, or one of its subclasses. :param typing.Type[E] | typing.Tuple[typing.Type[E], ...] expected_exception: @@ -952,13 +1124,20 @@ def raises( # noqa: F811 f"Raising exceptions is already understood as failing the test, so you don't need " f"any special code to say 'this should never raise an exception'." ) - if isinstance(expected_exception, type): - expected_exceptions: Tuple[Type[E], ...] = (expected_exception,) + if isinstance(expected_exception, (type, ExpectedExceptionGroup)): + expected_exception_tuple: Tuple[ + Union[Type[E], ExpectedExceptionGroup[E2]], ... + ] = (expected_exception,) else: - expected_exceptions = expected_exception - for exc in expected_exceptions: - if not isinstance(exc, type) or not issubclass(exc, BaseException): - msg = "expected exception must be a BaseException type, not {}" # type: ignore[unreachable] + expected_exception_tuple = expected_exception + for exc in expected_exception_tuple: + if ( + not isinstance(exc, type) or not issubclass(exc, BaseException) + ) and not isinstance(exc, ExpectedExceptionGroup): + msg = ( # type: ignore[unreachable] + "expected exception must be a BaseException " + "type or ExpectedExceptionGroup instance, not {}" + ) not_a = exc.__name__ if isinstance(exc, type) else type(exc).__name__ raise TypeError(msg.format(not_a)) @@ -971,14 +1150,23 @@ def raises( # noqa: F811 msg += ", ".join(sorted(kwargs)) msg += "\nUse context-manager form instead?" raise TypeError(msg) - return RaisesContext(expected_exception, message, match) + # the ExpectedExceptionGroup -> BaseExceptionGroup swap necessitates an ignore + return RaisesContext(expected_exception, message, match) # type: ignore[misc] else: func = args[0] + + for exc in expected_exception_tuple: + if isinstance(exc, ExpectedExceptionGroup): + raise TypeError( + "Only contextmanager form is supported for ExpectedExceptionGroup" + ) + if not callable(func): raise TypeError(f"{func!r} object (type: {type(func)}) must be callable") try: func(*args[1:], **kwargs) - except expected_exception as e: + except expected_exception as e: # type: ignore[misc] # TypeError raised for any ExpectedExceptionGroup + return _pytest._code.ExceptionInfo.from_exception(e) fail(message) @@ -987,11 +1175,14 @@ def raises( # noqa: F811 raises.Exception = fail.Exception # type: ignore +EE: "TypeAlias" = Union[Type[E], "ExpectedExceptionGroup[E]"] + + @final class RaisesContext(ContextManager[_pytest._code.ExceptionInfo[E]]): def __init__( self, - expected_exception: Union[Type[E], Tuple[Type[E], ...]], + expected_exception: Union[EE[E], Tuple[EE[E], ...]], message: str, match_expr: Optional[Union[str, Pattern[str]]] = None, ) -> None: @@ -1014,8 +1205,26 @@ def __exit__( if exc_type is None: fail(self.message) assert self.excinfo is not None - if not issubclass(exc_type, self.expected_exception): + + if isinstance(self.expected_exception, ExpectedExceptionGroup): + if not self.expected_exception.matches(exc_val): + return False + elif isinstance(self.expected_exception, tuple): + for expected_exc in self.expected_exception: + if ( + isinstance(expected_exc, ExpectedExceptionGroup) + and expected_exc.matches(exc_val) + ) or ( + isinstance(expected_exc, type) + and issubclass(exc_type, expected_exc) + ): + break + else: # pragma: no cover + # this would've been caught on initialization of pytest.raises() + return False + elif not issubclass(exc_type, self.expected_exception): return False + # Cast to narrow the exception type now that it's verified. exc_info = cast(Tuple[Type[E], E, TracebackType], (exc_type, exc_val, exc_tb)) self.excinfo.fill_unfilled(exc_info) diff --git a/testing/python/expected_exception_group.py b/testing/python/expected_exception_group.py new file mode 100644 index 00000000000..a3d9641235f --- /dev/null +++ b/testing/python/expected_exception_group.py @@ -0,0 +1,262 @@ +from typing import TYPE_CHECKING + +import pytest +from _pytest.python_api import ExpectedExceptionGroup +from _pytest.python_api import Matcher + +# TODO: make a public export + +if TYPE_CHECKING: + from typing_extensions import assert_type + + +class TestExpectedExceptionGroup: + def test_expected_exception_group(self) -> None: + with pytest.raises( + ValueError, + match="^Invalid argument {exc} must be exception type, Matcher, or ExpectedExceptionGroup.$", + ): + ExpectedExceptionGroup(ValueError()) + + with pytest.raises(ExpectedExceptionGroup(ValueError)): + raise ExceptionGroup("foo", (ValueError(),)) + + with pytest.raises(ExpectedExceptionGroup(SyntaxError)): + with pytest.raises(ExpectedExceptionGroup(ValueError)): + raise ExceptionGroup("foo", (SyntaxError(),)) + + # multiple exceptions + with pytest.raises(ExpectedExceptionGroup(ValueError, SyntaxError)): + raise ExceptionGroup("foo", (ValueError(), SyntaxError())) + + # order doesn't matter + with pytest.raises(ExpectedExceptionGroup(SyntaxError, ValueError)): + raise ExceptionGroup("foo", (ValueError(), SyntaxError())) + + # nested exceptions + with pytest.raises(ExpectedExceptionGroup(ExpectedExceptionGroup(ValueError))): + raise ExceptionGroup("foo", (ExceptionGroup("bar", (ValueError(),)),)) + + with pytest.raises( + ExpectedExceptionGroup( + SyntaxError, + ExpectedExceptionGroup(ValueError), + ExpectedExceptionGroup(RuntimeError), + ) + ): + raise ExceptionGroup( + "foo", + ( + SyntaxError(), + ExceptionGroup("bar", (ValueError(),)), + ExceptionGroup("", (RuntimeError(),)), + ), + ) + + # will error if there's excess exceptions + with pytest.raises(ExceptionGroup): + with pytest.raises(ExpectedExceptionGroup(ValueError)): + raise ExceptionGroup("", (ValueError(), ValueError())) + + with pytest.raises(ExceptionGroup): + with pytest.raises(ExpectedExceptionGroup(ValueError)): + raise ExceptionGroup("", (RuntimeError(), ValueError())) + + # will error if there's missing exceptions + with pytest.raises(ExceptionGroup): + with pytest.raises(ExpectedExceptionGroup(ValueError, ValueError)): + raise ExceptionGroup("", (ValueError(),)) + + with pytest.raises(ExceptionGroup): + with pytest.raises(ExpectedExceptionGroup(ValueError, SyntaxError)): + raise ExceptionGroup("", (ValueError(),)) + + # loose semantics, as with expect* + with pytest.raises(ExpectedExceptionGroup(ValueError, strict=False)): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + + # mixed loose is possible if you want it to be at least N deep + with pytest.raises( + ExpectedExceptionGroup(ExpectedExceptionGroup(ValueError, strict=True)) + ): + raise ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)) + with pytest.raises( + ExpectedExceptionGroup(ExpectedExceptionGroup(ValueError, strict=False)) + ): + raise ExceptionGroup( + "", (ExceptionGroup("", (ExceptionGroup("", (ValueError(),)),)),) + ) + + # but not the other way around + with pytest.raises( + ValueError, + match="^You cannot specify a nested structure inside an ExpectedExceptionGroup with strict=False$", + ): + ExpectedExceptionGroup(ExpectedExceptionGroup(ValueError), strict=False) + + # currently not fully identical in behaviour to expect*, which would also catch an unwrapped exception + with pytest.raises(ValueError): + with pytest.raises(ExpectedExceptionGroup(ValueError, strict=False)): + raise ValueError + + def test_match(self) -> None: + # supports match string + with pytest.raises(ExpectedExceptionGroup(ValueError), match="bar"): + raise ExceptionGroup("bar", (ValueError(),)) + + try: + with pytest.raises(ExpectedExceptionGroup(ValueError), match="foo"): + raise ExceptionGroup("bar", (ValueError(),)) + except AssertionError as e: + assert str(e).startswith("Regex pattern did not match.") + else: + raise AssertionError("Expected pytest.raises.Exception") + + def test_ExpectedExceptionGroup_matches(self) -> None: + eeg = ExpectedExceptionGroup(ValueError) + assert not eeg.matches(None) + assert not eeg.matches(ValueError()) + assert eeg.matches(ExceptionGroup("", (ValueError(),))) + + def test_message(self) -> None: + + try: + with pytest.raises(ExpectedExceptionGroup(ValueError)): + ... + except pytest.fail.Exception as e: + assert e.msg == f"DID NOT RAISE ExceptionGroup({repr(ValueError)},)" + else: + assert False, "Expected pytest.raises.Exception" + try: + with pytest.raises( + ExpectedExceptionGroup(ExpectedExceptionGroup(ValueError)) + ): + ... + except pytest.fail.Exception as e: + assert ( + e.msg + == f"DID NOT RAISE ExceptionGroup(ExceptionGroup({repr(ValueError)},),)" + ) + else: + assert False, "Expected pytest.raises.Exception" + + def test_matcher(self) -> None: + with pytest.raises( + ValueError, match="^You must specify at least one parameter to match on.$" + ): + Matcher() + + with pytest.raises(ExpectedExceptionGroup(Matcher(ValueError))): + raise ExceptionGroup("", (ValueError(),)) + try: + with pytest.raises(ExpectedExceptionGroup(Matcher(TypeError))): + raise ExceptionGroup("", (ValueError(),)) + except ExceptionGroup: + pass + else: + assert False, "Expected pytest.raises.Exception" + + def test_matcher_match(self) -> None: + with pytest.raises(ExpectedExceptionGroup(Matcher(ValueError, "foo"))): + raise ExceptionGroup("", (ValueError("foo"),)) + try: + with pytest.raises(ExpectedExceptionGroup(Matcher(ValueError, "foo"))): + raise ExceptionGroup("", (ValueError("bar"),)) + except ExceptionGroup: + pass + else: + assert False, "Expected pytest.raises.Exception" + + # Can be used without specifying the type + with pytest.raises(ExpectedExceptionGroup(Matcher(match="foo"))): + raise ExceptionGroup("", (ValueError("foo"),)) + try: + with pytest.raises(ExpectedExceptionGroup(Matcher(match="foo"))): + raise ExceptionGroup("", (ValueError("bar"),)) + except ExceptionGroup: + pass + else: + assert False, "Expected pytest.raises.Exception" + + def test_Matcher_check(self) -> None: + def check_oserror_and_errno_is_5(e: BaseException) -> bool: + return isinstance(e, OSError) and e.errno == 5 + + with pytest.raises( + ExpectedExceptionGroup(Matcher(check=check_oserror_and_errno_is_5)) + ): + raise ExceptionGroup("", (OSError(5, ""),)) + + # specifying exception_type narrows the parameter type to the callable + def check_errno_is_5(e: OSError) -> bool: + return e.errno == 5 + + with pytest.raises( + ExpectedExceptionGroup(Matcher(OSError, check=check_errno_is_5)) + ): + raise ExceptionGroup("", (OSError(5, ""),)) + + try: + with pytest.raises( + ExpectedExceptionGroup(Matcher(OSError, check=check_errno_is_5)) + ): + raise ExceptionGroup("", (OSError(6, ""),)) + except ExceptionGroup: + pass + else: + assert False, "Expected pytest.raises.Exception" + + if TYPE_CHECKING: + # getting the typing working satisfactory is very tricky + # but with ExpectedExceptionGroup being seen as a subclass of BaseExceptionGroup + # most end-user cases of checking excinfo.value.foobar should work fine now. + def test_types_0(self) -> None: + _: BaseExceptionGroup[ValueError] = ExpectedExceptionGroup(ValueError) + _ = ExpectedExceptionGroup(ExpectedExceptionGroup(ValueError)) # type: ignore[arg-type] + a: BaseExceptionGroup[BaseExceptionGroup[ValueError]] + a = ExpectedExceptionGroup(ExpectedExceptionGroup(ValueError)) + a = BaseExceptionGroup("", (BaseExceptionGroup("", (ValueError(),)),)) + assert a + + def test_types_1(self) -> None: + with pytest.raises(ExpectedExceptionGroup(ValueError)) as e: + raise ExceptionGroup("foo", (ValueError(),)) + assert_type(e.value, ExpectedExceptionGroup[ValueError]) + + def test_types_2(self) -> None: + exc: ExceptionGroup[ValueError] | ValueError = ExceptionGroup( + "", (ValueError(),) + ) + if ExpectedExceptionGroup(ValueError).matches(exc): + assert_type(exc, BaseExceptionGroup[ValueError]) + + def test_types_3(self) -> None: + e: BaseExceptionGroup[KeyboardInterrupt] = BaseExceptionGroup( + "", (KeyboardInterrupt(),) + ) + if ExpectedExceptionGroup(ValueError).matches(e): + assert_type(e, BaseExceptionGroup[ValueError]) + + def test_types_4(self) -> None: + with pytest.raises(ExpectedExceptionGroup(Matcher(ValueError))) as e: + ... + _: BaseExceptionGroup[ValueError] = e.value + assert_type(e.value, ExpectedExceptionGroup[ValueError]) + + def test_types_5(self) -> None: + with pytest.raises( + ExpectedExceptionGroup(ExpectedExceptionGroup(ValueError)) + ) as excinfo: + raise ExceptionGroup("foo", (ValueError(),)) + _: BaseExceptionGroup[BaseExceptionGroup[ValueError]] = excinfo.value + assert_type( + excinfo.value, + ExpectedExceptionGroup[ExpectedExceptionGroup[ValueError]], + ) + print(excinfo.value.exceptions[0].exceptions[0]) + + def test_types_6(self) -> None: + exc: ExceptionGroup[ExceptionGroup[ValueError]] = ... # type: ignore[assignment] + if ExpectedExceptionGroup(ExpectedExceptionGroup(ValueError)).matches(exc): # type: ignore[arg-type] + # ugly + assert_type(exc, BaseExceptionGroup[ExpectedExceptionGroup[ValueError]]) diff --git a/testing/python/raises.py b/testing/python/raises.py index 3dcec31eb1f..9e5ce69c47a 100644 --- a/testing/python/raises.py +++ b/testing/python/raises.py @@ -287,7 +287,10 @@ def test_expected_exception_is_not_a_baseexception(self) -> None: with pytest.raises(TypeError) as excinfo: with pytest.raises("hello"): # type: ignore[call-overload] pass # pragma: no cover - assert "must be a BaseException type, not str" in str(excinfo.value) + assert ( + "must be a BaseException type or ExpectedExceptionGroup instance, not str" + in str(excinfo.value) + ) class NotAnException: pass @@ -295,9 +298,15 @@ class NotAnException: with pytest.raises(TypeError) as excinfo: with pytest.raises(NotAnException): # type: ignore[type-var] pass # pragma: no cover - assert "must be a BaseException type, not NotAnException" in str(excinfo.value) + assert ( + "must be a BaseException type or ExpectedExceptionGroup instance, not NotAnException" + in str(excinfo.value) + ) with pytest.raises(TypeError) as excinfo: with pytest.raises(("hello", NotAnException)): # type: ignore[arg-type] pass # pragma: no cover - assert "must be a BaseException type, not str" in str(excinfo.value) + assert ( + "must be a BaseException type or ExpectedExceptionGroup instance, not str" + in str(excinfo.value) + ) From 91084644a0531d1aabd9556318cc0ea56bcf0f3d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 1 Dec 2023 17:07:38 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/_pytest/python_api.py | 1 - testing/python/expected_exception_group.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 4646540507f..72973e2cf5d 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -1166,7 +1166,6 @@ def raises( try: func(*args[1:], **kwargs) except expected_exception as e: # type: ignore[misc] # TypeError raised for any ExpectedExceptionGroup - return _pytest._code.ExceptionInfo.from_exception(e) fail(message) diff --git a/testing/python/expected_exception_group.py b/testing/python/expected_exception_group.py index a3d9641235f..5d74b73619e 100644 --- a/testing/python/expected_exception_group.py +++ b/testing/python/expected_exception_group.py @@ -119,7 +119,6 @@ def test_ExpectedExceptionGroup_matches(self) -> None: assert eeg.matches(ExceptionGroup("", (ValueError(),))) def test_message(self) -> None: - try: with pytest.raises(ExpectedExceptionGroup(ValueError)): ... From e577541b4bdcab19f68418c8a98c1a6d2e33999f Mon Sep 17 00:00:00 2001 From: jakkdl Date: Fri, 1 Dec 2023 18:19:15 +0100 Subject: [PATCH 3/4] import exceptiongroup on <3.11 --- src/_pytest/python_api.py | 9 +++++---- testing/python/expected_exception_group.py | 4 ++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 72973e2cf5d..82fde59dee1 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -1,6 +1,7 @@ import math import pprint import re +import sys from collections.abc import Collection from collections.abc import Sized from decimal import Decimal @@ -25,16 +26,16 @@ from typing import TypeVar from typing import Union -if TYPE_CHECKING: - from typing_extensions import TypeAlias, TypeGuard - - import _pytest._code from _pytest.compat import STRING_TYPES from _pytest.outcomes import fail if TYPE_CHECKING: from numpy import ndarray + from typing_extensions import TypeAlias, TypeGuard + +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup def _non_numeric_type_error(value, at: Optional[str]) -> TypeError: diff --git a/testing/python/expected_exception_group.py b/testing/python/expected_exception_group.py index 5d74b73619e..c33e9d048c4 100644 --- a/testing/python/expected_exception_group.py +++ b/testing/python/expected_exception_group.py @@ -1,3 +1,4 @@ +import sys from typing import TYPE_CHECKING import pytest @@ -9,6 +10,9 @@ if TYPE_CHECKING: from typing_extensions import assert_type +if sys.version_info < (3, 11): + from exceptiongroup import ExceptionGroup + class TestExpectedExceptionGroup: def test_expected_exception_group(self) -> None: From b2f659780ab753516e7b502a57eb4f8cb080321b Mon Sep 17 00:00:00 2001 From: jakkdl Date: Fri, 1 Dec 2023 18:28:12 +0100 Subject: [PATCH 4/4] type->Type --- src/_pytest/python_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/python_api.py b/src/_pytest/python_api.py index 82fde59dee1..608b0c0b2d0 100644 --- a/src/_pytest/python_api.py +++ b/src/_pytest/python_api.py @@ -794,7 +794,7 @@ def _as_numpy_array(obj: object) -> Optional["ndarray"]: class Matcher(Generic[E]): def __init__( self, - exception_type: Optional[type[E]] = None, + exception_type: Optional[Type[E]] = None, match: Optional[Union[str, Pattern[str]]] = None, check: Optional[Callable[[E], bool]] = None, ):