diff --git a/CHANGES.rst b/CHANGES.rst index 1b1f0878..e49bed89 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -16,7 +16,17 @@ Unreleased when :ref:`updating feeds ` in parallel; :mod:`multiprocessing.dummy` does not work on some environments (e.g. AWS Lambda). + +* Add :exc:`UpdateError` as parent of all update-related exceptions. (:issue:`218`) + + * Make :exc:`ParseError` inherit from :exc:`UpdateError`. + * Make :exc:`ReaderWarning` inherit from :exc:`ReaderError`. + +* Add :exc:`UpdateHookError` and subclasses + :exc:`SingleUpdateHookError` and :exc:`UpdateHookErrorGroup`. + (:issue:`218`) + * Include a diagram of the :ref:`exctree` in the :doc:`api`. diff --git a/docs/api.rst b/docs/api.rst index 7dd975d4..61c115a7 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -83,10 +83,6 @@ Exceptions :show-inheritance: :members: -.. autoexception:: ParseError - :show-inheritance: - :members: - .. autoexception:: InvalidFeedURLError :show-inheritance: @@ -102,6 +98,26 @@ Exceptions :show-inheritance: :members: +.. autoexception:: UpdateError + :show-inheritance: + :members: + +.. autoexception:: ParseError + :show-inheritance: + :members: + +.. autoexception:: UpdateHookError + :show-inheritance: + :members: + +.. autoexception:: SingleUpdateHookError + :show-inheritance: + :members: + +.. autoexception:: UpdateHookErrorGroup + :show-inheritance: + :members: + .. autoexception:: StorageError :show-inheritance: :members: diff --git a/src/reader/__init__.py b/src/reader/__init__.py index 4b21c3dc..563a2c00 100644 --- a/src/reader/__init__.py +++ b/src/reader/__init__.py @@ -65,11 +65,15 @@ FeedError as FeedError, FeedExistsError as FeedExistsError, FeedNotFoundError as FeedNotFoundError, - ParseError as ParseError, InvalidFeedURLError as InvalidFeedURLError, EntryError as EntryError, EntryExistsError as EntryExistsError, EntryNotFoundError as EntryNotFoundError, + UpdateError as UpdateError, + ParseError as ParseError, + UpdateHookError as UpdateHookError, + SingleUpdateHookError as SingleUpdateHookError, + UpdateHookErrorGroup as UpdateHookErrorGroup, StorageError as StorageError, SearchError as SearchError, SearchNotEnabledError as SearchNotEnabledError, diff --git a/src/reader/core.py b/src/reader/core.py index b49a9c2a..20ed879a 100644 --- a/src/reader/core.py +++ b/src/reader/core.py @@ -80,9 +80,17 @@ # mypy doesn't seem to support Self yet. _TReader = TypeVar('_TReader', bound='Reader') +UpdateHook = Callable[..., None] AfterEntryUpdateHook = Callable[['Reader', EntryData, EntryUpdateStatus], None] FeedUpdateHook = Callable[['Reader', str], None] FeedsUpdateHook = Callable[['Reader'], None] +UpdateHookType = Literal[ + 'before_feeds_update', + 'before_feed_update', + 'after_entry_update', + 'after_feed_update', + 'after_feeds_update', +] def make_reader( diff --git a/src/reader/exceptions.py b/src/reader/exceptions.py index 9a80ff42..3d5c684b 100644 --- a/src/reader/exceptions.py +++ b/src/reader/exceptions.py @@ -1,6 +1,13 @@ from __future__ import annotations +from collections.abc import Iterable +from collections.abc import Sequence from functools import cached_property +from traceback import format_exception +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from .core import UpdateHook, UpdateHookType class _FancyExceptionBase(Exception): @@ -50,6 +57,80 @@ def __str__(self) -> str: return ': '.join(filter(None, parts)) +class _ExceptionGroup(Exception): # pragma: no cover + """ExceptionGroup shim for Python 3.10. + + Always includes a traceback in str(exc), + so no traceback.* monkeypatching is needed, + at the expense of the traceback not being in the right place, + and a slightly non-standard traceback. + + Avoids dependency on https://pypi.org/project/exceptiongroup/ + + >>> raise _ExceptionGroup('message', [ + ... NameError('one'), + ... _ExceptionGroup('another', [ + ... AttributeError('two'), + ... ]), + ... ]) + Traceback (most recent call last): + File "", line 1, in + reader.exceptions._ExceptionGroup: message (2 sub-exceptions) + +---------------- 1 ----------------- + | NameError: one + +---------------- 2 ----------------- + | reader.exceptions._ExceptionGroup: another (1 sub-exception) + | +---------------- 1 ----------------- + | | AttributeError: two + | +------------------------------------ + +------------------------------------ + + TODO: Remove this once we drop Python 3.10. + + """ + + def __init__(self, msg: str, excs: Sequence[Exception], /): + if not isinstance(msg, str): # pragma: no cover + raise TypeError(f"first argument must be str, not {type(msg).__name__}") + excs = tuple(excs) + if not excs: # pragma: no cover + raise ValueError("second argument must be a non-empty sequence") + for i, e in enumerate(excs): # pragma: no cover + if not isinstance(e, Exception): + raise ValueError(f"item {i} of second argument is not an exception") + super().__init__(msg, tuple(excs)) + self._message = msg + self._exceptions = excs + + @property + def message(self) -> str: + return self._message + + @property + def exceptions(self) -> tuple[Exception, ...]: + return self._exceptions + + def _format_lines(self) -> Iterable[str]: + count = len(self.exceptions) + s = 's' if count != 1 else '' + yield f"{self.message} ({count} sub-exception{s})\n" + for i, exc in enumerate(self.exceptions, 1): + yield f"+{f' {i} '.center(36, '-')}\n" + for line in format_exception(exc): + for subline in line.rstrip().splitlines(): + yield f"| {subline}\n" + yield f"+{'-' * 36}\n" + + def __str__(self) -> str: + return ''.join(self._format_lines()).rstrip() + + +try: + ExceptionGroup +except NameError: # pragma: no cover + ExceptionGroup = _ExceptionGroup + + class ReaderError(_FancyExceptionBase): """Base for all public exceptions.""" @@ -129,14 +210,6 @@ class InvalidFeedURLError(FeedError, ValueError): message = "invalid feed URL" -class ParseError(FeedError, ReaderWarning): - """An error occurred while getting/parsing feed. - - The original exception should be chained to this one (e.__cause__). - - """ - - class EntryError(ReaderError): """An entry error occurred. @@ -187,6 +260,112 @@ class EntryNotFoundError(EntryError, ResourceNotFoundError): message = "no such entry" +class UpdateError(ReaderError): + """An error occurred while updating the feed. + + Parent of all update-related exceptions. + + .. versionadded:: 3.8 + + """ + + +class ParseError(UpdateError, FeedError, ReaderWarning): + """An error occurred while retrieving/parsing the feed. + + The original exception should be chained to this one (e.__cause__). + + .. versionchanged:: 3.8 + Inherit from :exc:`UpdateError`. + + """ + + +class UpdateHookError(UpdateError): + r"""One or more update hooks (unexpectedly) failed. + + Not raised directly; + allows catching any hook errors with a single except clause. + + To inspect individual hook failures, + use `except\* `_ with :exc:`SingleUpdateHookError` + (or, on Python earlier than 3.11, + check if the exception :func:`isinstance` :exc:`UpdateHookErrorGroup` + and examine its :attr:`~BaseExceptionGroup.exceptions`). + + .. _exceptstar: https://docs.python.org/3/tutorial/errors.html#raising-and-handling-multiple-unrelated-exceptions + + .. versionadded:: 3.8 + + """ + + +class SingleUpdateHookError(UpdateHookError): + """An update hook (unexpectedly) failed. + + The original exception should be chained to this one (e.__cause__). + + .. versionadded:: 3.8 + + """ + + message = "unexpected hook error" + + def __init__( + self, + when: UpdateHookType, + hook: UpdateHook, + resource_id: tuple[str, ...] | None = None, + ) -> None: + super().__init__() + + #: The update phase (the hook type). One of: + #: + #: * ``'before_feeds_update'`` + #: * ``'before_feed_update'`` + #: * ``'after_entry_update'`` + #: * ``'after_feed_update'`` + #: * ``'after_feeds_update'`` + #: + self.when = when + + #: The hook. + self.hook = hook + + #: The `resource_id` of the resource, if any. + self.resource_id = resource_id + + @property + def _str(self) -> str: + parts = [self.when, repr(self.hook)] + if self.resource_id is not None: + if len(self.resource_id) == 1: + parts.append(repr(self.resource_id[0])) + else: + parts.append(repr(self.resource_id)) + return ': '.join(parts) + + +class UpdateHookErrorGroup(ExceptionGroup, UpdateHookError): # type: ignore[misc] + r"""A (possibly nested) :exc:`ExceptionGroup` of :exc:`SingleUpdateHookError`\s. + + .. versionadded:: 3.8 + + """ + + # FIXME: the type: ignore[misc] cause by .message overlap + # FIXME: the pragma: no cover + + def __init__(self, msg: str, excs: Sequence[Exception], /): # pragma: no cover + super().__init__(msg, excs) + assert all(isinstance(e, UpdateHookError) for e in self.exceptions) + + def derive( + self, excs: Sequence[Exception] + ) -> UpdateHookErrorGroup: # pragma: no cover + return UpdateHookErrorGroup(self.message, excs) + + class StorageError(ReaderError): """An exception was raised by the underlying storage. diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index b661a656..4361e6d5 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -4,6 +4,7 @@ from reader import EntryError from reader import FeedError +from reader import SingleUpdateHookError from reader import TagError from reader.exceptions import _FancyExceptionBase @@ -68,3 +69,32 @@ def test_entry_error_str(exc_type): def test_tag_error_str(exc_type): exc = exc_type(('object',), 'key') assert "'object': 'key'" in str(exc) + + +@pytest.mark.parametrize( + 'args, expected', + [ + ( + ('before_feeds_update', 'myhook'), + "unexpected hook error: before_feeds_update: 'myhook'", + ), + ( + ('before_feeds_update', 'myhook', ()), + "unexpected hook error: before_feeds_update: 'myhook': ()", + ), + ( + ('before_feed_update', 'myhook', ('feed',)), + "unexpected hook error: before_feed_update: 'myhook': 'feed'", + ), + ( + ('after_entry_update', 'myhook', ('feed', 'entry')), + "unexpected hook error: after_entry_update: 'myhook': ('feed', 'entry')", + ), + ], +) +def test_single_update_hook_error_str(args, expected): + exc = SingleUpdateHookError(*args) + assert str(exc) == expected + exc = SingleUpdateHookError(*args) + exc.__cause__ = Exception('cause') + assert str(exc) == expected + ": builtins.Exception: cause"