diff --git a/aiogram/dispatcher/filters/__init__.py b/aiogram/dispatcher/filters/__init__.py index b9612ad4..e59259be 100644 --- a/aiogram/dispatcher/filters/__init__.py +++ b/aiogram/dispatcher/filters/__init__.py @@ -3,6 +3,7 @@ from typing import Dict, Tuple, Type from .base import BaseFilter from .command import Command, CommandObject from .content_types import ContentTypesFilter +from .exception import ExceptionMessageFilter, ExceptionTypeFilter from .text import Text __all__ = ( @@ -12,6 +13,8 @@ __all__ = ( "Command", "CommandObject", "ContentTypesFilter", + "ExceptionMessageFilter", + "ExceptionTypeFilter", ) BUILTIN_FILTERS: Dict[str, Tuple[Type[BaseFilter], ...]] = { @@ -27,5 +30,5 @@ BUILTIN_FILTERS: Dict[str, Tuple[Type[BaseFilter], ...]] = { "pre_checkout_query": (), "poll": (), "poll_answer": (), - "errors": (), + "error": (ExceptionMessageFilter, ExceptionTypeFilter), } diff --git a/aiogram/dispatcher/filters/exception.py b/aiogram/dispatcher/filters/exception.py new file mode 100644 index 00000000..8291291a --- /dev/null +++ b/aiogram/dispatcher/filters/exception.py @@ -0,0 +1,36 @@ +import re +from typing import Any, Dict, Pattern, Tuple, Type, Union, cast + +from pydantic import validator + +from aiogram.dispatcher.filters import BaseFilter + + +class ExceptionTypeFilter(BaseFilter): + exception: Union[Type[Exception], Tuple[Type[Exception]]] + + class Config: + arbitrary_types_allowed = True + + async def __call__(self, exception: Exception) -> Union[bool, Dict[str, Any]]: + return isinstance(exception, self.exception) + + +class ExceptionMessageFilter(BaseFilter): + match: Union[str, Pattern[str]] + + class Config: + arbitrary_types_allowed = True + + @validator("match") + def _validate_match(cls, value: Union[str, Pattern[str]]) -> Union[str, Pattern[str]]: + if isinstance(value, str): + return re.compile(value) + return value + + async def __call__(self, exception: Exception) -> Union[bool, Dict[str, Any]]: + pattern = cast(Pattern[str], self.match) + result = pattern.match(str(exception)) + if not result: + return False + return {"match_exception": result} diff --git a/aiogram/dispatcher/handler/__init__.py b/aiogram/dispatcher/handler/__init__.py index 49ae18d2..b2c5c9ef 100644 --- a/aiogram/dispatcher/handler/__init__.py +++ b/aiogram/dispatcher/handler/__init__.py @@ -1,6 +1,7 @@ from .base import BaseHandler, BaseHandlerMixin from .callback_query import CallbackQueryHandler from .chosen_inline_result import ChosenInlineResultHandler +from .error import ErrorHandler from .inline_query import InlineQueryHandler from .message import MessageHandler, MessageHandlerCommandMixin from .poll import PollHandler @@ -12,6 +13,7 @@ __all__ = ( "BaseHandlerMixin", "CallbackQueryHandler", "ChosenInlineResultHandler", + "ErrorHandler", "InlineQueryHandler", "MessageHandler", "MessageHandlerCommandMixin", diff --git a/aiogram/dispatcher/handler/error.py b/aiogram/dispatcher/handler/error.py new file mode 100644 index 00000000..dc7953bd --- /dev/null +++ b/aiogram/dispatcher/handler/error.py @@ -0,0 +1,9 @@ +from abc import ABC + +from aiogram.dispatcher.handler.base import BaseHandler + + +class ErrorHandler(BaseHandler[Exception], ABC): + """ + Base class for errors handlers + """ diff --git a/docs/dispatcher/class_based_handlers/error.md b/docs/dispatcher/class_based_handlers/error.md new file mode 100644 index 00000000..94efdb93 --- /dev/null +++ b/docs/dispatcher/class_based_handlers/error.md @@ -0,0 +1,29 @@ +# ErrorHandler + +There is base class for error handlers. + +## Simple usage: +```pyhton3 +from aiogram.handlers import ErrorHandler + +... + +@router.errors_handler() +class MyHandler(ErrorHandler): + async def handle(self) -> Any: + log.exception( + "Cause unexpected exception %s: %s", + self.event.__class__.__name__, + self.event + ) +``` + +## Extension + +This base handler is subclass of [BaseHandler](basics.md#basehandler) + +## Related pages + +- [BaseHandler](basics.md#basehandler) +- [Router.errors_handler](../router.md#errors) +- [Filters](../filters/exception.md) diff --git a/docs/dispatcher/filters/exception.md b/docs/dispatcher/filters/exception.md new file mode 100644 index 00000000..c24e47b3 --- /dev/null +++ b/docs/dispatcher/filters/exception.md @@ -0,0 +1,27 @@ +# Exceptions +This filters can be helpful for handling errors from the text messages. + +## ExceptionTypeFilter + +Allow to match exception by type + +### Specification +| Argument | Type | Description | +| --- | --- | --- | +| `exception` | `#!python3 Union[Type[Exception], Tuple[Type[Exception]]]` | Exception type(s) | + + +## ExceptionMessageFilter + +Allow to match exception by message + +### Specification +| Argument | Type | Description | +| --- | --- | --- | +| `match` | `#!python3 Union[str, Pattern[str]]` | Regexp pattern | + +## Allowed handlers + +Allowed update types for this filters: + +- `error` diff --git a/docs/dispatcher/router.md b/docs/dispatcher/router.md index 30625281..84d18ed9 100644 --- a/docs/dispatcher/router.md +++ b/docs/dispatcher/router.md @@ -116,6 +116,13 @@ async def poll_answer_handler(poll_answer: types.PollAnswer) -> Any: pass ``` Is useful for handling [polls answers](../api/types/poll_answer.md) +### Errors +```python3 +@router.errors_handler() +async def error_handler(exception: Exception) -> Any: pass +``` +Is useful for handling errors from other handlers + ## Nested routers diff --git a/mkdocs.yml b/mkdocs.yml index 47631d6a..747b5f4e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -240,6 +240,7 @@ nav: - dispatcher/filters/text.md - dispatcher/filters/command.md - dispatcher/filters/content_types.md + - dispatcher/filters/exception.md - Class based handlers: - dispatcher/class_based_handlers/basics.md - dispatcher/class_based_handlers/message.md @@ -249,6 +250,7 @@ nav: - dispatcher/class_based_handlers/poll.md - dispatcher/class_based_handlers/pre_checkout_query.md - dispatcher/class_based_handlers/shipping_query.md + - dispatcher/class_based_handlers/error.md - Middlewares: - dispatcher/middlewares/index.md - dispatcher/middlewares/basics.md diff --git a/tests/test_dispatcher/test_filters/test_exception.py b/tests/test_dispatcher/test_filters/test_exception.py new file mode 100644 index 00000000..4dd6d5d9 --- /dev/null +++ b/tests/test_dispatcher/test_filters/test_exception.py @@ -0,0 +1,51 @@ +import re + +import pytest + +from aiogram.dispatcher.filters import ExceptionMessageFilter, ExceptionTypeFilter + + +class TestExceptionMessageFilter: + @pytest.mark.parametrize("value", ["value", re.compile("value")]) + def test_converter(self, value): + obj = ExceptionMessageFilter(match=value) + assert isinstance(obj.match, re.Pattern) + + @pytest.mark.asyncio + async def test_match(self): + obj = ExceptionMessageFilter(match="KABOOM") + + result = await obj(Exception()) + assert not result + + result = await obj(Exception("KABOOM")) + assert isinstance(result, dict) + assert "match_exception" in result + + +class MyException(Exception): + pass + + +class MyAnotherException(MyException): + pass + + +class TestExceptionTypeFilter: + @pytest.mark.asyncio + @pytest.mark.parametrize( + "exception,value", + [ + [Exception(), False], + [ValueError(), False], + [TypeError(), False], + [MyException(), True], + [MyAnotherException(), True], + ], + ) + async def test_check(self, exception: Exception, value: bool): + obj = ExceptionTypeFilter(exception=MyException) + + result = await obj(exception) + + assert result == value