Skip to content

Commit

Permalink
Add UpdateError, UpdateHookError, SingleUpdateHookError, UpdateHookEr…
Browse files Browse the repository at this point in the history
…rorGroup.

For #218.
  • Loading branch information
lemon24 committed Aug 12, 2023
1 parent d72fd4a commit 1e52e3c
Show file tree
Hide file tree
Showing 6 changed files with 260 additions and 13 deletions.
10 changes: 10 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,17 @@ Unreleased
when :ref:`updating feeds <update>` 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`.


Expand Down
24 changes: 20 additions & 4 deletions docs/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,6 @@ Exceptions
:show-inheritance:
:members:

.. autoexception:: ParseError
:show-inheritance:
:members:

.. autoexception:: InvalidFeedURLError
:show-inheritance:

Expand All @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion src/reader/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/reader/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
195 changes: 187 additions & 8 deletions src/reader/exceptions.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down Expand Up @@ -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 "<stdin>", line 1, in <module>
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."""

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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\* <exceptstar_>`_ 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.
Expand Down
30 changes: 30 additions & 0 deletions tests/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"

0 comments on commit 1e52e3c

Please sign in to comment.