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" diff --git a/.travis.yml b/.travis.yml index 76179f0..5b16167 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ +os: linux language: python -sudo: false -dist: trusty +dist: focal matrix: include: @@ -9,26 +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 - - os: osx - language: generic - env: MACPYTHON=3.5.4 + - python: 3.9-dev - os: osx language: generic env: MACPYTHON=3.6.6 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..44035c8 --- /dev/null +++ b/exceptiongroup/_exception_group.py @@ -0,0 +1,294 @@ +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 exception types + + Provides specialization via subscription and corresponding type checks: + ``Class[spec]`` and ``issubclass(Class[spec], Class[spec, spec2])``. Accepts + 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 + #: 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. (spec, spec2) for Class[spec, spec2] + #: or None for the base case + __args__: Optional[Tuple[Type[BaseException], ...]] + #: 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[BaseException], ...]] = None, + inclusive: bool = True, + **kwargs, + ): + cls = super().__new__( + mcs, name, bases, namespace, **kwargs + ) # type: ExceptionGroupMeta + if specializations is not None: + origin = bases[0] + else: + inclusive = True + origin = cls + cls._inclusive = inclusive + cls.__args__ = specializations + cls.__origin__ = origin + 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: + origin = subclass.__origin__ + except AttributeError: + return False + else: + # check that the specialization matches + 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.__args__ 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.__args__ + ) + 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: + # 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.__args__) + for child in subclass.__args__ + ) + + # 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[ + Type[BaseException], + "ellipsis", + Tuple[Union[Type[BaseException], "ellipsis"], ...], + ], + ): + """``cls[item]`` - specialize ``cls`` with ``item``""" + # validate/normalize parameters + # + # Cls[A, B][C] + if cls.__args__ 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, BaseException): + raise TypeError( + f"expected a BaseException subclass, not {item!r}" + ) + item = (item,) + # Cls[item1, item2] + else: + if not all( + (child is ...) or issubclass(child, BaseException) + for child in item + ): + raise TypeError( + f"expected a tuple of BaseException 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: A description of the overall exception. + exceptions: The exceptions. + sources: 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. + + 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 + #: the base case, i.e. this class + __origin__: 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 + __args__: 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[BaseException] + sources: Tuple + + # __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[BaseException], + sources, + ): + if cls.__args__ is not None: + # forbid EG[A, B, C]() + if not exceptions: + raise TypeError( + 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) + 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 = tuple(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 = tuple(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/_tests/test_exceptiongroup.py b/exceptiongroup/_tests/test_exceptiongroup.py index ce17e25..a391e7b 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], @@ -43,6 +44,59 @@ 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: + 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_catch_inclusive(): + with pytest.raises(ExceptionGroup[ZeroDivisionError, ...]): + raise_group() + with pytest.raises(ExceptionGroup[ZeroDivisionError]): + try: + raise_group() + except ExceptionGroup[KeyError, ...]: + pytest.fail( + "inclusive catch-all still requires all specific types to match" + ) + + def test_exception_group_str(): memberA = ValueError("memberA") memberB = ValueError("memberB") 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):