From d416c8fe27aa72dc1f231544637f3ce482c42f87 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Thu, 18 Jun 2020 21:53:22 +0200 Subject: [PATCH 01/17] moved ExceptionGroup to separate module --- exceptiongroup/__init__.py | 60 +----------------------------- exceptiongroup/_exception_group.py | 56 ++++++++++++++++++++++++++++ exceptiongroup/_monkeypatch.py | 2 +- exceptiongroup/_tools.py | 2 +- 4 files changed, 59 insertions(+), 61 deletions(-) create mode 100644 exceptiongroup/_exception_group.py diff --git a/exceptiongroup/__init__.py b/exceptiongroup/__init__.py index a48c235..a60e6f2 100644 --- a/exceptiongroup/__init__.py +++ b/exceptiongroup/__init__.py @@ -4,64 +4,6 @@ __all__ = ["ExceptionGroup", "split", "catch"] - -class ExceptionGroup(BaseException): - """An exception that contains other exceptions. - - Its main use is to represent the situation when multiple child tasks all - raise errors "in parallel". - - Args: - message (str): A description of the overall exception. - exceptions (list): The exceptions. - sources (list): For each exception, a string describing where it came - from. - - Raises: - TypeError: if any of the passed in objects are not instances of - :exc:`BaseException`. - ValueError: if the exceptions and sources lists don't have the same - length. - - """ - - def __init__(self, message, exceptions, sources): - super().__init__(message, exceptions, sources) - self.exceptions = list(exceptions) - for exc in self.exceptions: - if not isinstance(exc, BaseException): - raise TypeError( - "Expected an exception object, not {!r}".format(exc) - ) - self.message = message - self.sources = list(sources) - if len(self.sources) != len(self.exceptions): - raise ValueError( - "different number of sources ({}) and exceptions ({})".format( - len(self.sources), len(self.exceptions) - ) - ) - - # copy.copy doesn't work for ExceptionGroup, because BaseException have - # rewrite __reduce_ex__ method. We need to add __copy__ method to - # make it can be copied. - def __copy__(self): - new_group = self.__class__(self.message, self.exceptions, self.sources) - new_group.__traceback__ = self.__traceback__ - new_group.__context__ = self.__context__ - new_group.__cause__ = self.__cause__ - # Setting __cause__ also implicitly sets the __suppress_context__ - # attribute to True. So we should copy __suppress_context__ attribute - # last, after copying __cause__. - new_group.__suppress_context__ = self.__suppress_context__ - return new_group - - def __str__(self): - return ", ".join(repr(exc) for exc in self.exceptions) - - def __repr__(self): - return "".format(self) - - +from ._exception_group import ExceptionGroup from . import _monkeypatch from ._tools import split, catch diff --git a/exceptiongroup/_exception_group.py b/exceptiongroup/_exception_group.py new file mode 100644 index 0000000..38d55c6 --- /dev/null +++ b/exceptiongroup/_exception_group.py @@ -0,0 +1,56 @@ +class ExceptionGroup(BaseException): + """An exception that contains other exceptions. + + Its main use is to represent the situation when multiple child tasks all + raise errors "in parallel". + + Args: + message (str): A description of the overall exception. + exceptions (list): The exceptions. + sources (list): For each exception, a string describing where it came + from. + + Raises: + TypeError: if any of the passed in objects are not instances of + :exc:`BaseException`. + ValueError: if the exceptions and sources lists don't have the same + length. + + """ + + def __init__(self, message, exceptions, sources): + super().__init__(message, exceptions, sources) + self.exceptions = list(exceptions) + for exc in self.exceptions: + if not isinstance(exc, BaseException): + raise TypeError( + "Expected an exception object, not {!r}".format(exc) + ) + self.message = message + self.sources = list(sources) + if len(self.sources) != len(self.exceptions): + raise ValueError( + "different number of sources ({}) and exceptions ({})".format( + len(self.sources), len(self.exceptions) + ) + ) + + # copy.copy doesn't work for ExceptionGroup, because BaseException have + # rewrite __reduce_ex__ method. We need to add __copy__ method to + # make it can be copied. + def __copy__(self): + new_group = self.__class__(self.message, self.exceptions, self.sources) + new_group.__traceback__ = self.__traceback__ + new_group.__context__ = self.__context__ + new_group.__cause__ = self.__cause__ + # Setting __cause__ also implicitly sets the __suppress_context__ + # attribute to True. So we should copy __suppress_context__ attribute + # last, after copying __cause__. + new_group.__suppress_context__ = self.__suppress_context__ + return new_group + + def __str__(self): + return ", ".join(repr(exc) for exc in self.exceptions) + + def __repr__(self): + return "".format(self) diff --git a/exceptiongroup/_monkeypatch.py b/exceptiongroup/_monkeypatch.py index b8af5c6..01d89a7 100644 --- a/exceptiongroup/_monkeypatch.py +++ b/exceptiongroup/_monkeypatch.py @@ -11,7 +11,7 @@ import traceback import warnings -from . import ExceptionGroup +from ._exception_group import ExceptionGroup traceback_exception_original_init = traceback.TracebackException.__init__ diff --git a/exceptiongroup/_tools.py b/exceptiongroup/_tools.py index a380334..77baf23 100644 --- a/exceptiongroup/_tools.py +++ b/exceptiongroup/_tools.py @@ -3,7 +3,7 @@ ################################################################ import copy -from . import ExceptionGroup +from ._exception_group import ExceptionGroup def split(exc_type, exc, *, match=None): From 07cde39608f4bb4f53453141ba0625eccb2d37e6 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 08:08:53 +0200 Subject: [PATCH 02/17] first draft based on usim --- exceptiongroup/_exception_group.py | 246 ++++++++++++++++++++++++++++- 1 file changed, 238 insertions(+), 8 deletions(-) diff --git a/exceptiongroup/_exception_group.py b/exceptiongroup/_exception_group.py index 38d55c6..50af396 100644 --- a/exceptiongroup/_exception_group.py +++ b/exceptiongroup/_exception_group.py @@ -1,13 +1,209 @@ -class ExceptionGroup(BaseException): +from typing import Optional, Tuple, Union, Type, Dict, Any, ClassVar, Sequence +from weakref import WeakValueDictionary + + +class ExceptionGroupMeta(type): + """ + Metaclass to specialize :py:exc:`ExceptionGroup` for specific child types + + Provides specialization via subscription and corresponding type checks: + ``Class[spec]`` and ``issubclass(Class[spec], Class[spec, spec2])``. Accepts + the specialization ``...`` (a :py:const:`Ellipsis`) to mark the specialization + as inclusive, meaning a subtype may have additional specializations. + """ + + # metaclass instance fields - i.e. class fields + #: the base case, i.e. Class + base_case: "ExceptionGroupMeta" + #: whether additional child exceptions are allowed in issubclass checking + inclusive: bool + #: the specialization of some class - e.g. (TypeError,) for Class[TypeError] + #: or None for the base case + specializations: "Optional[Tuple[Type[Union[ExceptionGroup, Exception]], ...]]" + #: internal cache for currently used specializations, i.e. mapping spec: Class[spec] + _specs_cache: WeakValueDictionary + + def __new__( + mcs, + name: str, + bases: Tuple[Type, ...], + namespace: Dict[str, Any], + specializations: "Optional[Tuple[Type[Union[ExceptionGroup, Exception]], ...]]" = None, + inclusive: bool = True, + **kwargs, + ): + cls = super().__new__( + mcs, name, bases, namespace, **kwargs + ) # type: ExceptionGroupMeta + if specializations is not None: + base_case = bases[0] + else: + inclusive = True + base_case = cls + cls.inclusive = inclusive + cls.specializations = specializations + cls.base_case = base_case + return cls + + # Implementation Note: + # The Python language translates the except clause of + # try: raise a + # except b as err: + # to ``if issubclass(type(a), b): ``. + # + # Which means we need just ``__subclasscheck__`` for error handling. + # We implement ``__instancecheck__`` for consistency only. + def __instancecheck__(cls, instance): + """``isinstance(instance, cls)``""" + return cls.__subclasscheck__(type(instance)) + + def __subclasscheck__(cls, subclass): + """``issubclass(subclass, cls)``""" + # issubclass(EG, EG) + if cls is subclass: + return True + try: + base_case = subclass.base_case + except AttributeError: + return False + else: + # check that the specialization matches + if base_case is not cls.base_case: + return False + # except EG: + # issubclass(EG[???], EG) + # the base class is the superclass of all its specializations + if cls.specializations is None: + return True + # except EG[XXX]: + # issubclass(EG[???], EG[XXX]) + # the superclass specialization must be at least + # as general as the subclass specialization + else: + return cls._subclasscheck_specialization(subclass) + + def _subclasscheck_specialization(cls, subclass: "ExceptionGroupMeta"): + """``issubclass(:Type[subclass.specialization], Type[:cls.specialization])``""" + # specializations are covariant - if A <: B, then Class[A] <: Class[B] + # + # This means that we must handle cases where specializations + # match multiple times - for example, when matching + # Class[B] against Class[A, B], then B matches both A and B, + # + # Make sure that every specialization of ``cls`` matches something + matched_specializations = all( + any( + issubclass(child, specialization) + for child in subclass.specializations + ) + for specialization in cls.specializations + ) + # issubclass(EG[A, B], EG[A, C]) + if not matched_specializations: + return False + # issubclass(EG[A, B], EG[A, ...]) + elif cls.inclusive: + # We do not care if ``subclass`` has unmatched specializations + return True + # issubclass(EG[A, B], EG[A, B]) vs issubclass(EG[A, B, C], EG[A, B]) + else: + # Make sure that ``subclass`` has no unmatched specializations + # + # We need to check every child of subclass instead of comparing counts. + # This is needed in case that we have duplicate matches. Consider: + # EG[KeyError, LookupError], EG[KeyError, RuntimeError] + return not any( + not issubclass(child, cls.specializations) + for child in subclass.specializations + ) + + # specialization Interface + # Allows to do ``Cls[A, B, C]`` to specialize ``Cls`` with ``A, B, C``. + # This part is the only one that actually understands ``...``. + # + # Expect this to be called by user-facing code, either directly or as a result + # of ``Cls(A(), B(), C())``. Errors should be reported appropriately. + def __getitem__( + cls, + item: Union[ # [Exception] or [...] or [Exception, ...] + Type[Exception], + "Type[ExceptionGroup]", + "ellipsis", + 'Tuple[Union[Type[ExceptionGroup], Type[Exception], "ellipsis"], ...]', + ], + ): + """``cls[item]`` - specialize ``cls`` with ``item``""" + # validate/normalize parameters + # + # Cls[A, B][C] + if cls.specializations is not None: + raise TypeError( + f"Cannot specialize already specialized {cls.__name__!r}" + ) + # Cls[...] + if item is ...: + return cls + # Cls[item] + elif type(item) is not tuple: + if not issubclass(item, (Exception, cls)): + raise TypeError( + f"expected an Exception subclass, not {item!r}" + ) + item = (item,) + # Cls[item1, item2] + else: + if not all( + (child is ...) or issubclass(child, (Exception, cls)) + for child in item + ): + raise TypeError( + f"expected a tuple of Exception subclasses, not {item!r}" + ) + return cls._get_specialization(item) + + def _get_specialization(cls, item): + # provide specialized class + # + # If a type already exists for the given specialization, we return that + # same type. This avoids class creation and allows fast `A is B` checks. + # TODO: can this be moved before the expensive validation? + unique_spec = frozenset(item) + try: + specialized_cls = cls._specs_cache[unique_spec] + except KeyError: + inclusive = ... in unique_spec + specializations = tuple( + child for child in unique_spec if child is not ... + ) + # the specialization string "KeyError, IndexError, ..." + spec = ", ".join(child.__name__ for child in specializations) + ( + ", ..." if inclusive else "" + ) + # Note: type(name, bases, namespace) parameters cannot be passed by keyword + specialized_cls = ExceptionGroupMeta( + f"{cls.__name__}[{spec}]", + (cls,), + {}, + specializations=specializations, + inclusive=inclusive, + ) + cls._specs_cache[unique_spec] = specialized_cls + return specialized_cls + + def __repr__(cls): + return f"" + + +class ExceptionGroup(BaseException, metaclass=ExceptionGroupMeta): """An exception that contains other exceptions. Its main use is to represent the situation when multiple child tasks all raise errors "in parallel". Args: - message (str): A description of the overall exception. - exceptions (list): The exceptions. - sources (list): For each exception, a string describing where it came + message: A description of the overall exception. + exceptions: The exceptions. + sources: For each exception, a string describing where it came from. Raises: @@ -18,16 +214,50 @@ class ExceptionGroup(BaseException): """ - def __init__(self, message, exceptions, sources): + # metaclass instance fields - keep in sync with ExceptionGroupMeta + #: the base case, i.e. this class + base_case: ClassVar[ExceptionGroupMeta] + #: whether additional child exceptions are allowed in issubclass checking + inclusive: ClassVar[bool] + #: the specialization of some class - e.g. (TypeError,) for Class[TypeError] + #: or None for the base case + specializations: "ClassVar[Optional[Tuple[Type[Union[ExceptionGroup, Exception]], ...]]]" + #: internal cache for currently used specializations, i.e. mapping spec: Class[spec] + _specs_cache = WeakValueDictionary() + # instance fields + message: str + exceptions: "Tuple[Union[ExceptionGroup, Exception]]" + sources: Tuple + + # __new__ automatically specialises Concurrent to match its children. + # Concurrent(A(), B()) => Concurrent[A, B](A(), B()) + def __new__( + cls: "Type[ExceptionGroup]", + message: str, + exceptions: "Sequence[Union[ExceptionGroup, Exception]]", + sources, + ): + if not exceptions: + # forbid EG[A, B, C]() + if cls.specializations is not None: + raise TypeError( + f"specialisation of {cls.specializations} does not match" + f" {exceptions!r}; Note: Do not 'raise {cls.__name__}'" + ) + return super().__new__(cls) + special_cls = cls[tuple(type(child) for child in exceptions)] + return super().__new__(special_cls) + + def __init__(self, message: str, exceptions, sources): super().__init__(message, exceptions, sources) - self.exceptions = list(exceptions) + self.exceptions = tuple(exceptions) for exc in self.exceptions: - if not isinstance(exc, BaseException): + if not isinstance(exc, Exception): raise TypeError( "Expected an exception object, not {!r}".format(exc) ) self.message = message - self.sources = list(sources) + self.sources = tuple(sources) if len(self.sources) != len(self.exceptions): raise ValueError( "different number of sources ({}) and exceptions ({})".format( From 10c14f911a673ea0b92229732d7f6711483df504 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 08:49:16 +0200 Subject: [PATCH 03/17] updated leftover comments --- exceptiongroup/_exception_group.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/exceptiongroup/_exception_group.py b/exceptiongroup/_exception_group.py index 50af396..5a7cb80 100644 --- a/exceptiongroup/_exception_group.py +++ b/exceptiongroup/_exception_group.py @@ -229,14 +229,15 @@ class ExceptionGroup(BaseException, metaclass=ExceptionGroupMeta): exceptions: "Tuple[Union[ExceptionGroup, Exception]]" sources: Tuple - # __new__ automatically specialises Concurrent to match its children. - # Concurrent(A(), B()) => Concurrent[A, B](A(), B()) + # __new__ automatically specialises ExceptionGroup to match its children. + # ExceptionGroup(A(), B()) => ExceptionGroup[A, B](A(), B()) def __new__( cls: "Type[ExceptionGroup]", message: str, exceptions: "Sequence[Union[ExceptionGroup, Exception]]", sources, ): + # TODO: this check should likely be inverted if not exceptions: # forbid EG[A, B, C]() if cls.specializations is not None: From ba5b7289cb60306c009c44735ec1a9052a9a0e20 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 09:43:29 +0200 Subject: [PATCH 04/17] changed expected attribute types in tests --- exceptiongroup/_tests/test_exceptiongroup.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/exceptiongroup/_tests/test_exceptiongroup.py b/exceptiongroup/_tests/test_exceptiongroup.py index ce17e25..446942e 100644 --- a/exceptiongroup/_tests/test_exceptiongroup.py +++ b/exceptiongroup/_tests/test_exceptiongroup.py @@ -17,9 +17,10 @@ def test_exception_group_init(): group = ExceptionGroup( "many error.", [memberA, memberB], [str(memberA), str(memberB)] ) - assert group.exceptions == [memberA, memberB] + assert group.exceptions == (memberA, memberB) assert group.message == "many error." - assert group.sources == [str(memberA), str(memberB)] + assert group.sources == (str(memberA), str(memberB)) + # `.args` contains the unmodified arguments assert group.args == ( "many error.", [memberA, memberB], From aee0327be114d7014f79db63655bc2f323d5285f Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 09:51:23 +0200 Subject: [PATCH 05/17] inverted consistency check in __new__ to simplify paths --- exceptiongroup/_exception_group.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/exceptiongroup/_exception_group.py b/exceptiongroup/_exception_group.py index 5a7cb80..5ab008a 100644 --- a/exceptiongroup/_exception_group.py +++ b/exceptiongroup/_exception_group.py @@ -237,14 +237,14 @@ def __new__( exceptions: "Sequence[Union[ExceptionGroup, Exception]]", sources, ): - # TODO: this check should likely be inverted - if not exceptions: + if cls.specializations is not None: # forbid EG[A, B, C]() - if cls.specializations is not None: + if not exceptions: raise TypeError( f"specialisation of {cls.specializations} does not match" - f" {exceptions!r}; Note: Do not 'raise {cls.__name__}'" + f" empty exceptions; Note: Do not 'raise {cls.__name__}'" ) + # TODO: forbid EG[A, B, C](d, e, f, g) return super().__new__(cls) special_cls = cls[tuple(type(child) for child in exceptions)] return super().__new__(special_cls) From 06bf80f29c302dc62989169b1697a50a0bd5e4c5 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 10:05:24 +0200 Subject: [PATCH 06/17] updated travis to resemble trio (remove py3.5, add 3.9) --- .travis.yml | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/.travis.yml b/.travis.yml index 76179f0..2aab40b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ +os: linux language: python -sudo: false -dist: trusty +dist: focal matrix: include: @@ -9,23 +9,17 @@ matrix: env: CHECK_DOCS=1 - python: 3.6 env: CHECK_FORMATTING=1 - # The pypy tests are slow, so list them early - - python: pypy3.5 + # The pypy tests are slow, so we list them first + - python: pypy3.6-7.2.0 + dist: bionic # Uncomment if you want to test on pypy nightly: # - language: generic - # env: USE_PYPY_NIGHTLY=1 - - python: 3.5.0 - - python: 3.5.2 + # env: PYPY_NIGHTLY_BRANCH=py3.6 - python: 3.6 - # As of 2018-07-05, Travis's 3.7 and 3.8 builds only work if you - # use dist: xenial AND sudo: required - # See: https://github.com/python-trio/trio/pull/556#issuecomment-402879391 - - python: 3.7 - dist: xenial - sudo: required + - python: 3.6-dev + - python: 3.7-dev - python: 3.8-dev - dist: xenial - sudo: required + - python: 3.9-dev - os: osx language: generic env: MACPYTHON=3.5.4 From f54106d96ea40621d6816e402142ae3f4c5c4347 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 10:22:32 +0200 Subject: [PATCH 07/17] removed MacOS Py3.5 --- .travis.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2aab40b..5b16167 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,9 +20,6 @@ matrix: - python: 3.7-dev - python: 3.8-dev - python: 3.9-dev - - os: osx - language: generic - env: MACPYTHON=3.5.4 - os: osx language: generic env: MACPYTHON=3.6.6 From e50e3680d6ca8ec2cf320a8eccac4e772d01dcee Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 10:39:32 +0200 Subject: [PATCH 08/17] added tests for matching groups via subscription --- exceptiongroup/_tests/test_exceptiongroup.py | 21 ++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/exceptiongroup/_tests/test_exceptiongroup.py b/exceptiongroup/_tests/test_exceptiongroup.py index 446942e..ebc944d 100644 --- a/exceptiongroup/_tests/test_exceptiongroup.py +++ b/exceptiongroup/_tests/test_exceptiongroup.py @@ -44,6 +44,27 @@ def test_exception_group_init_when_exceptions_messages_not_equal(): ) +def test_exception_group_catch_exact(): + with pytest.raises(ExceptionGroup[ZeroDivisionError]): + try: + raise_group() + except ExceptionGroup[KeyError]: + pytest.fail("Group may not match unrelated Exception types") + + +def test_exception_group_covariant(): + with pytest.raises(ExceptionGroup[LookupError]): + raise ExceptionGroup("one", [KeyError()], ["explicit test"]) + with pytest.raises(ExceptionGroup[LookupError]): + raise ExceptionGroup("one", [IndexError()], ["explicit test"]) + with pytest.raises(ExceptionGroup[LookupError]): + raise ExceptionGroup( + "several subtypes", + [KeyError(), IndexError()], + ["initial match", "trailing match to same base case"] + ) + + def test_exception_group_str(): memberA = ValueError("memberA") memberB = ValueError("memberB") From 4b38ea69c20d3297997c3bec089f1ef943c47d2e Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 10:41:41 +0200 Subject: [PATCH 09/17] black --- exceptiongroup/_tests/test_exceptiongroup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exceptiongroup/_tests/test_exceptiongroup.py b/exceptiongroup/_tests/test_exceptiongroup.py index ebc944d..895c28f 100644 --- a/exceptiongroup/_tests/test_exceptiongroup.py +++ b/exceptiongroup/_tests/test_exceptiongroup.py @@ -61,7 +61,7 @@ def test_exception_group_covariant(): raise ExceptionGroup( "several subtypes", [KeyError(), IndexError()], - ["initial match", "trailing match to same base case"] + ["initial match", "trailing match to same base case"], ) From 886303318078c35a59fccf111490a25f45d6a700 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 11:01:32 +0200 Subject: [PATCH 10/17] all BaseExceptions are valid children of ExceptionGroup --- exceptiongroup/_exception_group.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/exceptiongroup/_exception_group.py b/exceptiongroup/_exception_group.py index 5ab008a..8949037 100644 --- a/exceptiongroup/_exception_group.py +++ b/exceptiongroup/_exception_group.py @@ -19,7 +19,7 @@ class ExceptionGroupMeta(type): inclusive: bool #: the specialization of some class - e.g. (TypeError,) for Class[TypeError] #: or None for the base case - specializations: "Optional[Tuple[Type[Union[ExceptionGroup, Exception]], ...]]" + specializations: Optional[Tuple[Type[BaseException], ...]] #: internal cache for currently used specializations, i.e. mapping spec: Class[spec] _specs_cache: WeakValueDictionary @@ -28,7 +28,7 @@ def __new__( name: str, bases: Tuple[Type, ...], namespace: Dict[str, Any], - specializations: "Optional[Tuple[Type[Union[ExceptionGroup, Exception]], ...]]" = None, + specializations: Optional[Tuple[Type[BaseException], ...]] = None, inclusive: bool = True, **kwargs, ): @@ -126,10 +126,9 @@ def _subclasscheck_specialization(cls, subclass: "ExceptionGroupMeta"): def __getitem__( cls, item: Union[ # [Exception] or [...] or [Exception, ...] - Type[Exception], - "Type[ExceptionGroup]", + Type[BaseException], "ellipsis", - 'Tuple[Union[Type[ExceptionGroup], Type[Exception], "ellipsis"], ...]', + Tuple[Union[Type[BaseException], "ellipsis"], ...], ], ): """``cls[item]`` - specialize ``cls`` with ``item``""" @@ -145,19 +144,19 @@ def __getitem__( return cls # Cls[item] elif type(item) is not tuple: - if not issubclass(item, (Exception, cls)): + if not issubclass(item, BaseException): raise TypeError( - f"expected an Exception subclass, not {item!r}" + f"expected a BaseException subclass, not {item!r}" ) item = (item,) # Cls[item1, item2] else: if not all( - (child is ...) or issubclass(child, (Exception, cls)) + (child is ...) or issubclass(child, BaseException) for child in item ): raise TypeError( - f"expected a tuple of Exception subclasses, not {item!r}" + f"expected a tuple of BaseException subclasses, not {item!r}" ) return cls._get_specialization(item) @@ -221,12 +220,12 @@ class ExceptionGroup(BaseException, metaclass=ExceptionGroupMeta): inclusive: ClassVar[bool] #: the specialization of some class - e.g. (TypeError,) for Class[TypeError] #: or None for the base case - specializations: "ClassVar[Optional[Tuple[Type[Union[ExceptionGroup, Exception]], ...]]]" + specializations: ClassVar[Optional[Tuple[Type[BaseException], ...]]] #: internal cache for currently used specializations, i.e. mapping spec: Class[spec] _specs_cache = WeakValueDictionary() # instance fields message: str - exceptions: "Tuple[Union[ExceptionGroup, Exception]]" + exceptions: Tuple[BaseException] sources: Tuple # __new__ automatically specialises ExceptionGroup to match its children. @@ -234,7 +233,7 @@ class ExceptionGroup(BaseException, metaclass=ExceptionGroupMeta): def __new__( cls: "Type[ExceptionGroup]", message: str, - exceptions: "Sequence[Union[ExceptionGroup, Exception]]", + exceptions: Sequence[BaseException], sources, ): if cls.specializations is not None: From 29054e215464013b59835608a463907e9ea69ef7 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 11:10:11 +0200 Subject: [PATCH 11/17] added test cases for inclusive matching --- exceptiongroup/_tests/test_exceptiongroup.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/exceptiongroup/_tests/test_exceptiongroup.py b/exceptiongroup/_tests/test_exceptiongroup.py index 895c28f..0a0c39b 100644 --- a/exceptiongroup/_tests/test_exceptiongroup.py +++ b/exceptiongroup/_tests/test_exceptiongroup.py @@ -65,6 +65,15 @@ def test_exception_group_covariant(): ) +def test_exception_group_catch_inclusive(): + with pytest.raises(ExceptionGroup[ZeroDivisionError, ...]): + raise_group() + # inclusive catch-all still requires specific types to match + with pytest.raises(ExceptionGroup[ZeroDivisionError]): + with pytest.raises(ExceptionGroup[KeyError, ...]): + raise_group() + + def test_exception_group_str(): memberA = ValueError("memberA") memberB = ValueError("memberB") From 10939e07334a4bc298ca39894c728b27f3ed4c3a Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 11:23:08 +0200 Subject: [PATCH 12/17] explicitly testing behaviour in except clause --- exceptiongroup/_tests/test_exceptiongroup.py | 25 ++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/exceptiongroup/_tests/test_exceptiongroup.py b/exceptiongroup/_tests/test_exceptiongroup.py index 0a0c39b..e39c641 100644 --- a/exceptiongroup/_tests/test_exceptiongroup.py +++ b/exceptiongroup/_tests/test_exceptiongroup.py @@ -44,6 +44,26 @@ def test_exception_group_init_when_exceptions_messages_not_equal(): ) +def test_exception_group_in_except(): + """Verify that the hooks of ExceptionGroup work with `except` syntax""" + try: + raise_group() + except ExceptionGroup[ZeroDivisionError]: + pass + except BaseException: + pytest.fail("ExceptionGroup did not trigger except clause") + try: + raise ExceptionGroup( + "message", [KeyError(), RuntimeError()], ["first", "second"] + ) + except (ExceptionGroup[KeyError], ExceptionGroup[RuntimeError]): + pytest.fail("ExceptionGroup triggered too specific except clause") + except ExceptionGroup[KeyError, RuntimeError]: + pass + except BaseException: + pytest.fail("ExceptionGroup did not trigger except clause") + + def test_exception_group_catch_exact(): with pytest.raises(ExceptionGroup[ZeroDivisionError]): try: @@ -68,10 +88,11 @@ def test_exception_group_covariant(): def test_exception_group_catch_inclusive(): with pytest.raises(ExceptionGroup[ZeroDivisionError, ...]): raise_group() - # inclusive catch-all still requires specific types to match with pytest.raises(ExceptionGroup[ZeroDivisionError]): - with pytest.raises(ExceptionGroup[KeyError, ...]): + try: raise_group() + except ExceptionGroup[KeyError, ...]: + pytest.fail("inclusive catch-all still requires all specific types to match") def test_exception_group_str(): From 81477ad0efa221e9bc039922ec3bb4f9aa9b7728 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 11:25:09 +0200 Subject: [PATCH 13/17] black --- exceptiongroup/_tests/test_exceptiongroup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/exceptiongroup/_tests/test_exceptiongroup.py b/exceptiongroup/_tests/test_exceptiongroup.py index e39c641..a391e7b 100644 --- a/exceptiongroup/_tests/test_exceptiongroup.py +++ b/exceptiongroup/_tests/test_exceptiongroup.py @@ -92,7 +92,9 @@ def test_exception_group_catch_inclusive(): try: raise_group() except ExceptionGroup[KeyError, ...]: - pytest.fail("inclusive catch-all still requires all specific types to match") + pytest.fail( + "inclusive catch-all still requires all specific types to match" + ) def test_exception_group_str(): From f858de642b0bf9f914b60644cf6dbe378b016951 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 11:49:40 +0200 Subject: [PATCH 14/17] removed Py3.5 from appveyor targets --- .appveyor.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.appveyor.yml b/.appveyor.yml index b6fd0d6..5b2c851 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -4,8 +4,6 @@ os: Visual Studio 2015 environment: matrix: - - PYTHON: "C:\\Python35" - - PYTHON: "C:\\Python35-x64" - PYTHON: "C:\\Python36" - PYTHON: "C:\\Python36-x64" - PYTHON: "C:\\Python37" From 4535cce813cf0db027ff4a5bd2c7fa6cc343bab6 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 22:06:46 +0200 Subject: [PATCH 15/17] docstrings cover core functionality --- exceptiongroup/_exception_group.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/exceptiongroup/_exception_group.py b/exceptiongroup/_exception_group.py index 8949037..e72d31b 100644 --- a/exceptiongroup/_exception_group.py +++ b/exceptiongroup/_exception_group.py @@ -4,12 +4,13 @@ class ExceptionGroupMeta(type): """ - Metaclass to specialize :py:exc:`ExceptionGroup` for specific child types + Metaclass to specialize :py:exc:`ExceptionGroup` for specific child exception types Provides specialization via subscription and corresponding type checks: ``Class[spec]`` and ``issubclass(Class[spec], Class[spec, spec2])``. Accepts - the specialization ``...`` (a :py:const:`Ellipsis`) to mark the specialization - as inclusive, meaning a subtype may have additional specializations. + the specialization ``...`` (an :py:const:`Ellipsis`) to mark the specialization + as inclusive, meaning a subtype may have additional specializations. Specialisation + is covariant, i.e. ``issubclass(A, B)`` implies ``issubclass(Class[A], Class[B])`` """ # metaclass instance fields - i.e. class fields @@ -125,7 +126,7 @@ def _subclasscheck_specialization(cls, subclass: "ExceptionGroupMeta"): # of ``Cls(A(), B(), C())``. Errors should be reported appropriately. def __getitem__( cls, - item: Union[ # [Exception] or [...] or [Exception, ...] + item: Union[ Type[BaseException], "ellipsis", Tuple[Union[Type[BaseException], "ellipsis"], ...], @@ -211,6 +212,13 @@ class ExceptionGroup(BaseException, metaclass=ExceptionGroupMeta): ValueError: if the exceptions and sources lists don't have the same length. + The class can be subscribed with exception types, such as + ``ExceptionGroup[KeyError, RuntimeError]``. When used in an ``except`` clause, + this matches only the specified combination of sub-exceptions. + As fpr other exceptions, subclasses are respected, so + ``ExceptionGroup[LookupError]`` matches an ``ExceptionGroup`` of ``KeyError``, + ``IndexError`` or both. Use a literal ``...`` (an :py:const:`Ellipsis`) to + allow for additional, unspecified matches. """ # metaclass instance fields - keep in sync with ExceptionGroupMeta From 53e65911dc17ff8d108a520c4e029075d0e46696 Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 23:32:59 +0200 Subject: [PATCH 16/17] uses typing/private field names --- exceptiongroup/_exception_group.py | 48 +++++++++++++++--------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/exceptiongroup/_exception_group.py b/exceptiongroup/_exception_group.py index e72d31b..56103ec 100644 --- a/exceptiongroup/_exception_group.py +++ b/exceptiongroup/_exception_group.py @@ -14,13 +14,13 @@ class ExceptionGroupMeta(type): """ # metaclass instance fields - i.e. class fields - #: the base case, i.e. Class - base_case: "ExceptionGroupMeta" + #: the base case, i.e. Class of Class[spec, spec2] + __origin__: "ExceptionGroupMeta" #: whether additional child exceptions are allowed in issubclass checking - inclusive: bool - #: the specialization of some class - e.g. (TypeError,) for Class[TypeError] + _inclusive: bool + #: the specialization of some class - e.g. (spec, spec2) for Class[spec, spec2] #: or None for the base case - specializations: Optional[Tuple[Type[BaseException], ...]] + __args__: Optional[Tuple[Type[BaseException], ...]] #: internal cache for currently used specializations, i.e. mapping spec: Class[spec] _specs_cache: WeakValueDictionary @@ -37,13 +37,13 @@ def __new__( mcs, name, bases, namespace, **kwargs ) # type: ExceptionGroupMeta if specializations is not None: - base_case = bases[0] + origin = bases[0] else: inclusive = True - base_case = cls - cls.inclusive = inclusive - cls.specializations = specializations - cls.base_case = base_case + origin = cls + cls._inclusive = inclusive + cls.__args__ = specializations + cls.__origin__ = origin return cls # Implementation Note: @@ -64,17 +64,17 @@ def __subclasscheck__(cls, subclass): if cls is subclass: return True try: - base_case = subclass.base_case + origin = subclass.__origin__ except AttributeError: return False else: # check that the specialization matches - if base_case is not cls.base_case: + if origin is not cls.__origin__: return False # except EG: # issubclass(EG[???], EG) # the base class is the superclass of all its specializations - if cls.specializations is None: + if cls.__args__ is None: return True # except EG[XXX]: # issubclass(EG[???], EG[XXX]) @@ -95,15 +95,15 @@ def _subclasscheck_specialization(cls, subclass: "ExceptionGroupMeta"): matched_specializations = all( any( issubclass(child, specialization) - for child in subclass.specializations + for child in subclass.__args__ ) - for specialization in cls.specializations + for specialization in cls.__args__ ) # issubclass(EG[A, B], EG[A, C]) if not matched_specializations: return False # issubclass(EG[A, B], EG[A, ...]) - elif cls.inclusive: + elif cls._inclusive: # We do not care if ``subclass`` has unmatched specializations return True # issubclass(EG[A, B], EG[A, B]) vs issubclass(EG[A, B, C], EG[A, B]) @@ -114,8 +114,8 @@ def _subclasscheck_specialization(cls, subclass: "ExceptionGroupMeta"): # This is needed in case that we have duplicate matches. Consider: # EG[KeyError, LookupError], EG[KeyError, RuntimeError] return not any( - not issubclass(child, cls.specializations) - for child in subclass.specializations + not issubclass(child, cls.__args__) + for child in subclass.__args__ ) # specialization Interface @@ -136,7 +136,7 @@ def __getitem__( # validate/normalize parameters # # Cls[A, B][C] - if cls.specializations is not None: + if cls.__args__ is not None: raise TypeError( f"Cannot specialize already specialized {cls.__name__!r}" ) @@ -223,12 +223,12 @@ class ExceptionGroup(BaseException, metaclass=ExceptionGroupMeta): # metaclass instance fields - keep in sync with ExceptionGroupMeta #: the base case, i.e. this class - base_case: ClassVar[ExceptionGroupMeta] + __origin__: ClassVar[ExceptionGroupMeta] #: whether additional child exceptions are allowed in issubclass checking - inclusive: ClassVar[bool] + _inclusive: ClassVar[bool] #: the specialization of some class - e.g. (TypeError,) for Class[TypeError] #: or None for the base case - specializations: ClassVar[Optional[Tuple[Type[BaseException], ...]]] + __args__: ClassVar[Optional[Tuple[Type[BaseException], ...]]] #: internal cache for currently used specializations, i.e. mapping spec: Class[spec] _specs_cache = WeakValueDictionary() # instance fields @@ -244,11 +244,11 @@ def __new__( exceptions: Sequence[BaseException], sources, ): - if cls.specializations is not None: + if cls.__args__ is not None: # forbid EG[A, B, C]() if not exceptions: raise TypeError( - f"specialisation of {cls.specializations} does not match" + f"specialisation of {cls.__args__} does not match" f" empty exceptions; Note: Do not 'raise {cls.__name__}'" ) # TODO: forbid EG[A, B, C](d, e, f, g) From a64db853ac3c1605fc0a92106a678b87048cc9ba Mon Sep 17 00:00:00 2001 From: Max Fischer Date: Fri, 19 Jun 2020 23:58:34 +0200 Subject: [PATCH 17/17] adjusted allowed types in init to new --- exceptiongroup/_exception_group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/exceptiongroup/_exception_group.py b/exceptiongroup/_exception_group.py index 56103ec..44035c8 100644 --- a/exceptiongroup/_exception_group.py +++ b/exceptiongroup/_exception_group.py @@ -260,7 +260,7 @@ def __init__(self, message: str, exceptions, sources): super().__init__(message, exceptions, sources) self.exceptions = tuple(exceptions) for exc in self.exceptions: - if not isinstance(exc, Exception): + if not isinstance(exc, BaseException): raise TypeError( "Expected an exception object, not {!r}".format(exc) )