Remove filters factory, introduce docs translation (#978)

* Rewrite filters

* Update README.rst

* Fixed tests

* Small optimization of the Text filter (TY to @bomzheg)

* Remove dataclass slots argument in due to the only Python 3.10 has an slots argument

* Fixed mypy

* Update tests

* Disable Python 3.11

* Fixed #1013: Empty mention should be None instead of empty string.

* Added #990 to the changelog

* Added #942 to the changelog

* Fixed coverage

* Update poetry and dependencies

* Fixed mypy

* Remove deprecated code

* Added more tests, update pyproject.toml

* Partial update docs

* Added initial Docs translation files

* Added more changes

* Added log message when connection is established in polling process

* Fixed action

* Disable lint for PyPy

* Added changelog for docs translation
This commit is contained in:
Alex Root Junior 2022-10-02 00:04:31 +03:00 committed by GitHub
parent 94030903ec
commit f4251382e8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
610 changed files with 61738 additions and 1687 deletions

View file

@ -35,5 +35,5 @@ __all__ = (
"flags",
)
__version__ = "3.0.0b4"
__version__ = "3.0.0b5"
__api_version__ = "6.2"

View file

@ -33,10 +33,12 @@ class Dispatcher(Router):
def __init__(
self,
*, # * - Preventing to pass instance of Bot to the FSM storage
storage: Optional[BaseStorage] = None,
fsm_strategy: FSMStrategy = FSMStrategy.USER_IN_CHAT,
events_isolation: Optional[BaseEventIsolation] = None,
disable_fsm: bool = False,
name: Optional[str] = None,
**kwargs: Any,
) -> None:
"""
@ -49,11 +51,16 @@ class Dispatcher(Router):
then you should not use storage and events isolation
:param kwargs: Other arguments, will be passed as keyword arguments to handlers
"""
super(Dispatcher, self).__init__(**kwargs)
super(Dispatcher, self).__init__(name=name)
if storage and not isinstance(storage, BaseStorage):
raise TypeError(
f"FSM storage should be instance of 'BaseStorage' not {type(storage).__name__}"
)
# Telegram API provides originally only one event type - Update
# For making easily interactions with events here is registered handler which helps
# to separate Update to different event types like Message, CallbackQuery and etc.
# to separate Update to different event types like Message, CallbackQuery etc.
self.update = self.observers["update"] = TelegramEventObserver(
router=self, event_name="update"
)
@ -80,7 +87,7 @@ class Dispatcher(Router):
self.update.outer_middleware(self.fsm)
self.shutdown.register(self.fsm.close)
self.workflow_data: Dict[str, Any] = {}
self.workflow_data: Dict[str, Any] = kwargs
self._running_lock = Lock()
def __getitem__(self, item: str) -> Any:
@ -186,10 +193,12 @@ class Dispatcher(Router):
# Request timeout can be lower than session timeout and that's OK.
# To prevent false-positive TimeoutError we should wait longer than polling timeout
kwargs["request_timeout"] = int(bot.session.timeout + polling_timeout)
failed = False
while True:
try:
updates = await bot(get_updates, **kwargs)
except Exception as e:
failed = True
# In cases when Telegram Bot API was inaccessible don't need to stop polling
# process because some developers can't make auto-restarting of the script
loggers.dispatcher.error("Failed to fetch updates - %s: %s", type(e).__name__, e)
@ -205,7 +214,14 @@ class Dispatcher(Router):
# In case when network connection was fixed let's reset the backoff
# to initial value and then process updates
backoff.reset()
if failed:
loggers.dispatcher.info(
"Connection established (tryings = %d, bot id = %d)",
backoff.counter,
bot.id,
)
backoff.reset()
failed = False
for update in updates:
yield update

View file

@ -1,14 +1,18 @@
import asyncio
import contextvars
import inspect
import warnings
from dataclasses import dataclass, field
from functools import partial
from typing import Any, Callable, Dict, List, Optional, Tuple
from magic_filter import MagicFilter
from magic_filter.magic import MagicFilter as OriginalMagicFilter
from aiogram.dispatcher.flags import extract_flags_from_object
from aiogram.filters.base import Filter
from aiogram.handlers import BaseHandler
from aiogram.utils.magic_filter import MagicFilter
from aiogram.utils.warnings import Recommendation
CallbackType = Callable[..., Any]
@ -45,20 +49,33 @@ class CallableMixin:
@dataclass
class FilterObject(CallableMixin):
callback: CallbackType
magic: Optional[MagicFilter] = None
def __post_init__(self) -> None:
# TODO: Make possibility to extract and explain magic from filter object.
# Current solution is hard for debugging because the MagicFilter instance can't be extracted
if isinstance(self.callback, MagicFilter):
# MagicFilter instance is callable but generates only "CallOperation" instead of applying the filter
if isinstance(self.callback, OriginalMagicFilter):
# MagicFilter instance is callable but generates
# only "CallOperation" instead of applying the filter
self.magic = self.callback
self.callback = self.callback.resolve
super().__post_init__()
if not isinstance(self.magic, MagicFilter):
# Issue: https://github.com/aiogram/aiogram/issues/990
warnings.warn(
category=Recommendation,
message="You are using F provided by magic_filter package directly, "
"but it lacks `.as_()` extension."
"\n Please change the import statement: from `from magic_filter import F` "
"to `from aiogram import F` to silence this warning.",
stacklevel=6,
)
super(FilterObject, self).__post_init__()
if isinstance(self.callback, Filter):
self.awaitable = True
@dataclass
class HandlerObject(CallableMixin):
callback: CallbackType
filters: Optional[List[FilterObject]] = None
flags: Dict[str, Any] = field(default_factory=dict)

View file

@ -1,17 +1,10 @@
from __future__ import annotations
import warnings
from inspect import isclass
from itertools import chain
from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, List, Optional, Tuple, Type
from pydantic import ValidationError
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
from aiogram.dispatcher.middlewares.manager import MiddlewareManager
from aiogram.filters.base import BaseFilter
from ...exceptions import FiltersResolveError
from ...filters import BUILTIN_FILTERS_SET
from ...filters.base import Filter
from ...types import TelegramObject
from .bases import REJECTED, UNHANDLED, MiddlewareType, SkipHandler
from .handler import CallbackType, FilterObject, HandlerObject
@ -33,7 +26,6 @@ class TelegramEventObserver:
self.event_name: str = event_name
self.handlers: List[HandlerObject] = []
self.filters: List[Type[BaseFilter]] = []
self.middleware = MiddlewareManager()
self.outer_middleware = MiddlewareManager()
@ -42,63 +34,16 @@ class TelegramEventObserver:
# with dummy callback which never will be used
self._handler = HandlerObject(callback=lambda: True, filters=[])
def filter(self, *filters: CallbackType, _stacklevel: int = 2, **bound_filters: Any) -> None:
def filter(self, *filters: CallbackType) -> None:
"""
Register filter for all handlers of this event observer
:param filters: positional filters
:param bound_filters: keyword filters
"""
resolved_filters = self.resolve_filters(
filters, bound_filters, _stacklevel=_stacklevel + 1
)
if self._handler.filters is None:
self._handler.filters = []
self._handler.filters.extend(
[
FilterObject(filter_) # type: ignore
for filter_ in chain(
resolved_filters,
filters,
)
]
)
def bind_filter(self, bound_filter: Type[BaseFilter]) -> None:
"""
Register filter class in factory
:param bound_filter:
"""
if not isclass(bound_filter) or not issubclass(bound_filter, BaseFilter):
raise TypeError(
"bound_filter() argument 'bound_filter' must be subclass of BaseFilter"
)
if bound_filter not in BUILTIN_FILTERS_SET:
warnings.warn(
category=DeprecationWarning,
message="filters factory deprecated and will be removed in 3.0b5,"
" use filters directly instead (Example: "
f"`{bound_filter.__name__}(<argument>=<value>)` instead of `<argument>=<value>`)",
stacklevel=2,
)
self.filters.append(bound_filter)
def _resolve_filters_chain(self) -> Generator[Type[BaseFilter], None, None]:
"""
Get all bounded filters from current observer and from the parents
with the same event type without duplicates
"""
registry: List[Type[BaseFilter]] = []
for router in reversed(tuple(self.router.chain_head)):
observer = router.observers[self.event_name]
for filter_ in observer.filters:
if filter_ in registry:
continue
yield filter_
registry.append(filter_)
self._handler.filters.extend([FilterObject(filter_) for filter_ in filters])
def _resolve_middlewares(self) -> List[MiddlewareType[TelegramObject]]:
middlewares: List[MiddlewareType[TelegramObject]] = []
@ -108,112 +53,30 @@ class TelegramEventObserver:
return middlewares
def resolve_filters(
self,
filters: Tuple[CallbackType, ...],
full_config: Dict[str, Any],
ignore_default: bool = True,
_stacklevel: int = 2,
) -> List[BaseFilter]:
"""
Resolve keyword filters via filters factory
:param filters: positional filters
:param full_config: keyword arguments to initialize bounded filters for router/handler
:param ignore_default: ignore to resolving filters with only default arguments that are not in full_config
"""
bound_filters: List[BaseFilter] = []
if ignore_default and not full_config:
return bound_filters
filter_types = set(type(f) for f in filters)
validation_errors = []
for bound_filter in self._resolve_filters_chain():
# skip filter if filter was used as positional filter:
if bound_filter in filter_types:
continue
# skip filter with no fields in full_config
if ignore_default:
full_config_keys = set(full_config.keys())
filter_fields = set(bound_filter.__fields__.keys())
if not full_config_keys.intersection(filter_fields):
continue
# Try to initialize filter.
try:
f = bound_filter(**full_config)
except ValidationError as e:
validation_errors.append(e)
continue
# Clean full config to prevent to re-initialize another filter
# with the same configuration
for key in f.__fields__:
full_config.pop(key, None)
bound_filters.append(f)
if full_config:
possible_cases = []
for error in validation_errors:
for sum_error in error.errors():
if sum_error["loc"][0] in full_config:
possible_cases.append(error)
break
raise FiltersResolveError(
unresolved_fields=set(full_config.keys()), possible_cases=possible_cases
)
if bound_filters:
warnings.warn(
category=DeprecationWarning,
message="Filters factory deprecated and will be removed in 3.0b5.\n"
"Use filters directly, for example instead of "
"`@router.message(commands=['help']')` "
"use `@router.message(Command(commands=['help'])`",
stacklevel=_stacklevel,
)
return bound_filters
def register(
self,
callback: CallbackType,
*filters: CallbackType,
flags: Optional[Dict[str, Any]] = None,
_stacklevel: int = 2,
**bound_filters: Any,
) -> CallbackType:
"""
Register event handler
"""
if flags is None:
flags = {}
resolved_filters = self.resolve_filters(
filters,
bound_filters,
ignore_default=False,
_stacklevel=_stacklevel + 1,
)
for resolved_filter in resolved_filters:
resolved_filter.update_handler_flags(flags=flags)
for item in filters:
if isinstance(item, Filter):
item.update_handler_flags(flags=flags)
self.handlers.append(
HandlerObject(
callback=callback,
filters=[
FilterObject(filter_) # type: ignore
for filter_ in chain(
resolved_filters,
filters,
)
],
filters=[FilterObject(filter_) for filter_ in filters],
flags=flags,
)
)
return callback
def wrap_outer_middleware(
@ -253,19 +116,15 @@ class TelegramEventObserver:
def __call__(
self,
*args: CallbackType,
*filters: CallbackType,
flags: Optional[Dict[str, Any]] = None,
_stacklevel: int = 2,
**bound_filters: Any,
) -> Callable[[CallbackType], CallbackType]:
"""
Decorator for registering event handlers
"""
def wrapper(callback: CallbackType) -> CallbackType:
self.register(
callback, *args, flags=flags, **bound_filters, _stacklevel=_stacklevel + 1
)
self.register(callback, *filters, flags=flags)
return callback
return wrapper

View file

@ -1,8 +1,9 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, cast
from ...types import TelegramObject
from ...types import TelegramObject, Update
from ...types.error_event import ErrorEvent
from ..event.bases import UNHANDLED, CancelHandler, SkipHandler
from .base import BaseMiddleware
@ -26,7 +27,9 @@ class ErrorsMiddleware(BaseMiddleware):
raise
except Exception as e:
response = await self.router.propagate_event(
update_type="error", event=event, **data, exception=e
update_type="error",
event=ErrorEvent(update=cast(Update, event), exception=e),
**data,
)
if response is not UNHANDLED:
return response

View file

@ -1,12 +1,8 @@
from __future__ import annotations
import warnings
from typing import Any, Dict, Final, Generator, List, Optional, Set, Union
from aiogram.filters import BUILTIN_FILTERS
from ..types import TelegramObject
from ..utils.warnings import CodeHasNoEffect
from .event.bases import REJECTED, UNHANDLED
from .event.event import EventObserver
from .event.telegram import TelegramEventObserver
@ -25,14 +21,11 @@ class Router:
- By decorator - :obj:`@router.<event_type>(<filters, ...>)`
"""
def __init__(self, use_builtin_filters: bool = True, name: Optional[str] = None) -> None:
def __init__(self, *, name: Optional[str] = None) -> None:
"""
:param use_builtin_filters: `aiogram` has many builtin filters and you can controll automatic registration of this filters in factory
:param name: Optional router name, can be useful for debugging
"""
self.use_builtin_filters = use_builtin_filters
self.name = name or hex(id(self))
self._parent_router: Optional[Router] = None
@ -83,12 +76,6 @@ class Router:
"error": self.errors,
}
# Builtin filters
if use_builtin_filters:
for name, observer in self.observers.items():
for builtin_filter in BUILTIN_FILTERS.get(name, ()):
observer.bind_filter(builtin_filter)
def __str__(self) -> str:
return f"{type(self).__name__} {self.name!r}"
@ -187,15 +174,6 @@ class Router:
if parent == self:
raise RuntimeError("Circular referencing of Router is not allowed")
if not self.use_builtin_filters and parent.use_builtin_filters:
warnings.warn(
f"{type(self).__name__}(use_builtin_filters=False) has no effect"
f" for router {self} in due to builtin filters is already registered"
f" in parent router",
CodeHasNoEffect,
stacklevel=3,
)
parent = parent.parent_router
self._parent_router = router

View file

@ -1,7 +1,4 @@
from textwrap import indent
from typing import List, Optional, Set
from pydantic import ValidationError
from typing import Optional
from aiogram.methods import TelegramMethod
from aiogram.methods.base import TelegramType
@ -107,17 +104,3 @@ class RestartingTelegram(TelegramServerError):
class TelegramEntityTooLarge(TelegramNetworkError):
url = "https://core.telegram.org/bots/api#sending-files"
class FiltersResolveError(DetailedAiogramError):
def __init__(self, unresolved_fields: Set[str], possible_cases: List[ValidationError]) -> None:
possible_cases_str = "\n".join(
" - " + indent(str(e), " " * 4).lstrip() for e in possible_cases
)
message = f"Unknown keyword filters: {unresolved_fields}"
if possible_cases_str:
message += f"\n Possible cases:\n{possible_cases_str}"
super().__init__(message=message)
self.unresolved_fields = unresolved_fields
self.possible_cases = possible_cases

View file

@ -1,7 +1,6 @@
from itertools import chain
from typing import Dict, Tuple, Type
from .base import BaseFilter
from .base import Filter
from .chat_member_updated import (
ADMINISTRATOR,
CREATOR,
@ -18,21 +17,21 @@ from .chat_member_updated import (
ChatMemberUpdatedFilter,
)
from .command import Command, CommandObject, CommandStart
from .content_types import ContentTypesFilter
from .exception import ExceptionMessageFilter, ExceptionTypeFilter
from .logic import and_f, invert_f, or_f
from .magic_data import MagicData
from .state import StateFilter
from .text import Text
BaseFilter = Filter
__all__ = (
"BUILTIN_FILTERS",
"Filter",
"BaseFilter",
"Text",
"Command",
"CommandObject",
"CommandStart",
"ContentTypesFilter",
"ExceptionMessageFilter",
"ExceptionTypeFilter",
"StateFilter",
@ -50,90 +49,6 @@ __all__ = (
"IS_NOT_MEMBER",
"JOIN_TRANSITION",
"LEAVE_TRANSITION",
"and_f",
"or_f",
"invert_f",
)
_ALL_EVENTS_FILTERS: Tuple[Type[BaseFilter], ...] = (MagicData,)
_TELEGRAM_EVENTS_FILTERS: Tuple[Type[BaseFilter], ...] = (StateFilter,)
BUILTIN_FILTERS: Dict[str, Tuple[Type[BaseFilter], ...]] = {
"message": (
Text,
Command,
ContentTypesFilter,
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
),
"edited_message": (
Text,
Command,
ContentTypesFilter,
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
),
"channel_post": (
Text,
ContentTypesFilter,
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
),
"edited_channel_post": (
Text,
ContentTypesFilter,
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
),
"inline_query": (
Text,
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
),
"chosen_inline_result": (
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
),
"callback_query": (
Text,
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
),
"shipping_query": (
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
),
"pre_checkout_query": (
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
),
"poll": (
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
),
"poll_answer": (
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
),
"my_chat_member": (
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
ChatMemberUpdatedFilter,
),
"chat_member": (
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
ChatMemberUpdatedFilter,
),
"chat_join_request": (
*_ALL_EVENTS_FILTERS,
*_TELEGRAM_EVENTS_FILTERS,
),
"error": (
ExceptionMessageFilter,
ExceptionTypeFilter,
*_ALL_EVENTS_FILTERS,
),
}
BUILTIN_FILTERS_SET = set(chain.from_iterable(BUILTIN_FILTERS.values()))
BUILTIN_FILTERS: Dict[str, Tuple[Type[Filter], ...]] = {}

View file

@ -1,19 +1,15 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Union
from pydantic import BaseModel
from aiogram.filters.logic import _LogicFilter
if TYPE_CHECKING:
from aiogram.dispatcher.event.handler import CallbackType, FilterObject
class BaseFilter(BaseModel, ABC, _LogicFilter):
class Filter(ABC):
"""
If you want to register own filters like builtin filters you will need to write subclass
of this class with overriding the :code:`__call__`
method and adding filter attributes.
BaseFilter is subclass of :class:`pydantic.BaseModel` that's mean all subclasses of BaseFilter has
the validators based on class attributes and custom validator.
"""
if TYPE_CHECKING:
@ -34,9 +30,42 @@ class BaseFilter(BaseModel, ABC, _LogicFilter):
"""
pass
def __invert__(self) -> "_InvertFilter":
return invert_f(self)
def update_handler_flags(self, flags: Dict[str, Any]) -> None:
"""
Also if you want to extend handler flags with using this filter you should implement this method
:param flags: existing flags, can be updated directly
"""
pass
def _signature_to_string(self, *args: Any, **kwargs: Any) -> str:
items = [repr(arg) for arg in args]
items.extend([f"{k}={v!r}" for k, v in kwargs.items() if v is not None])
return f"{type(self).__name__}({', '.join(items)})"
def __await__(self): # type: ignore # pragma: no cover
# Is needed only for inspection and this method is never be called
return self.__call__
class _InvertFilter(Filter):
__slots__ = ("target",)
def __init__(self, target: "FilterObject") -> None:
self.target = target
async def __call__(self, *args: Any, **kwargs: Any) -> Union[bool, Dict[str, Any]]:
return not bool(await self.target.call(*args, **kwargs))
def __str__(self) -> str:
return f"~{self.target.callback}"
def invert_f(target: "CallbackType") -> _InvertFilter:
from aiogram.dispatcher.event.handler import FilterObject
return _InvertFilter(target=FilterObject(target))

View file

@ -9,7 +9,7 @@ from uuid import UUID
from magic_filter import MagicFilter
from pydantic import BaseModel
from aiogram.filters import BaseFilter
from aiogram.filters.base import Filter
from aiogram.types import CallbackQuery
T = TypeVar("T", bound="CallbackData")
@ -122,11 +122,8 @@ class CallbackData(BaseModel):
"""
return CallbackQueryFilter(callback_data=cls, rule=rule)
# class Config:
# use_enum_values = True
class CallbackQueryFilter(BaseFilter):
class CallbackQueryFilter(Filter):
"""
This filter helps to handle callback query.
@ -134,10 +131,24 @@ class CallbackQueryFilter(BaseFilter):
via callback data instance
"""
callback_data: Type[CallbackData]
"""Expected type of callback data"""
rule: Optional[MagicFilter] = None
"""Magic rule"""
def __init__(
self,
*,
callback_data: Type[CallbackData],
rule: Optional[MagicFilter] = None,
):
"""
:param callback_data: Expected type of callback data
:param rule: Magic rule
"""
self.callback_data = callback_data
self.rule = rule
def __str__(self) -> str:
return self._signature_to_string(
callback_data=self.callback_data,
rule=self.rule,
)
async def __call__(self, query: CallbackQuery) -> Union[Literal[False], Dict[str, Any]]:
if not isinstance(query, CallbackQuery) or not query.data:
@ -150,7 +161,3 @@ class CallbackQueryFilter(BaseFilter):
if self.rule is None or self.rule.resolve(callback_data):
return {"callback_data": callback_data}
return False
class Config:
arbitrary_types_allowed = True
use_enum_values = True

View file

@ -1,6 +1,6 @@
from typing import Any, Dict, Optional, TypeVar, Union
from aiogram.filters import BaseFilter
from aiogram.filters.base import Filter
from aiogram.types import ChatMember, ChatMemberUpdated
MarkerT = TypeVar("MarkerT", bound="_MemberStatusMarker")
@ -154,16 +154,21 @@ LEAVE_TRANSITION = ~JOIN_TRANSITION
PROMOTED_TRANSITION = (MEMBER | RESTRICTED | LEFT | KICKED) >> ADMINISTRATOR
class ChatMemberUpdatedFilter(BaseFilter):
member_status_changed: Union[
_MemberStatusMarker,
_MemberStatusGroupMarker,
_MemberStatusTransition,
]
"""Accepts the status transition or new status of the member (see usage in docs)"""
class ChatMemberUpdatedFilter(Filter):
def __init__(
self,
member_status_changed: Union[
_MemberStatusMarker,
_MemberStatusGroupMarker,
_MemberStatusTransition,
],
):
self.member_status_changed = member_status_changed
class Config:
arbitrary_types_allowed = True
def __str__(self) -> str:
return self._signature_to_string(
member_status_changed=self.member_status_changed,
)
async def __call__(self, member_updated: ChatMemberUpdated) -> Union[bool, Dict[str, Any]]:
old = member_updated.old_chat_member

View file

@ -2,57 +2,111 @@ from __future__ import annotations
import re
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Any, Dict, Match, Optional, Pattern, Sequence, Tuple, Union, cast
from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterable,
Match,
Optional,
Pattern,
Sequence,
Union,
cast,
)
from magic_filter import MagicFilter
from pydantic import Field, validator
from aiogram.filters import BaseFilter
from aiogram.types import Message
from aiogram.filters.base import Filter
from aiogram.types import BotCommand, Message
from aiogram.utils.deep_linking import decode_payload
if TYPE_CHECKING:
from aiogram import Bot
CommandPatternType = Union[str, re.Pattern]
CommandPatternType = Union[str, re.Pattern, BotCommand]
class CommandException(Exception):
pass
class Command(BaseFilter):
class Command(Filter):
"""
This filter can be helpful for handling commands from the text messages.
Works only with :class:`aiogram.types.message.Message` events which have the :code:`text`.
"""
commands: Union[Sequence[CommandPatternType], CommandPatternType]
"""List of commands (string or compiled regexp patterns)"""
commands_prefix: str = "/"
"""Prefix for command. Prefix is always is single char but here you can pass all of allowed prefixes,
for example: :code:`"/!"` will work with commands prefixed by :code:`"/"` or :code:`"!"`."""
commands_ignore_case: bool = False
"""Ignore case (Does not work with regexp, use flags instead)"""
commands_ignore_mention: bool = False
"""Ignore bot mention. By default bot can not handle commands intended for other bots"""
command_magic: Optional[MagicFilter] = None
"""Validate command object via Magic filter after all checks done"""
def __init__(
self,
*values: CommandPatternType,
commands: Optional[Union[Sequence[CommandPatternType], CommandPatternType]] = None,
prefix: str = "/",
ignore_case: bool = False,
ignore_mention: bool = False,
magic: Optional[MagicFilter] = None,
):
"""
List of commands (string or compiled regexp patterns)
:param prefix: Prefix for command.
Prefix is always a single char but here you can pass all of allowed prefixes,
for example: :code:`"/!"` will work with commands prefixed
by :code:`"/"` or :code:`"!"`.
:param ignore_case: Ignore case (Does not work with regexp, use flags instead)
:param ignore_mention: Ignore bot mention. By default,
bot can not handle commands intended for other bots
:param magic: Validate command object via Magic filter after all checks done
"""
if commands is None:
commands = []
if isinstance(commands, (str, re.Pattern, BotCommand)):
commands = [commands]
if not isinstance(commands, Iterable):
raise ValueError(
"Command filter only supports str, re.Pattern, BotCommand object"
" or their Iterable"
)
items = []
for command in (*values, *commands):
if isinstance(command, BotCommand):
command = command.command
if not isinstance(command, (str, re.Pattern)):
raise ValueError(
"Command filter only supports str, re.Pattern, BotCommand object"
" or their Iterable"
)
items.append(command)
if not items:
raise ValueError("At least one command should be specified")
self.commands = tuple(items)
self.prefix = prefix
self.ignore_case = ignore_case
self.ignore_mention = ignore_mention
self.magic = magic
def __str__(self) -> str:
return self._signature_to_string(
*self.commands,
prefix=self.prefix,
ignore_case=self.ignore_case,
ignore_mention=self.ignore_mention,
magic=self.magic,
)
def update_handler_flags(self, flags: Dict[str, Any]) -> None:
commands = flags.setdefault("commands", [])
commands.append(self)
@validator("commands", always=True)
def _validate_commands(
cls, value: Union[Sequence[CommandPatternType], CommandPatternType]
) -> Sequence[CommandPatternType]:
if isinstance(value, (str, re.Pattern)):
value = [value]
return value
async def __call__(self, message: Message, bot: Bot) -> Union[bool, Dict[str, Any]]:
if not isinstance(message, Message):
return False
text = message.text or message.caption
if not text:
return False
@ -78,15 +132,18 @@ class Command(BaseFilter):
# "/command@mention" -> "/", ("command", "@", "mention")
prefix, (command, _, mention) = full_command[0], full_command[1:].partition("@")
return CommandObject(
prefix=prefix, command=command, mention=mention, args=args[0] if args else None
prefix=prefix,
command=command,
mention=mention or None,
args=args[0] if args else None,
)
def validate_prefix(self, command: CommandObject) -> None:
if command.prefix not in self.commands_prefix:
if command.prefix not in self.prefix:
raise CommandException("Invalid command prefix")
async def validate_mention(self, bot: Bot, command: CommandObject) -> None:
if command.mention and not self.commands_ignore_mention:
if command.mention and not self.ignore_mention:
me = await bot.me()
if me.username and command.mention.lower() != me.username.lower():
raise CommandException("Mention did not match")
@ -119,16 +176,13 @@ class Command(BaseFilter):
return command
def do_magic(self, command: CommandObject) -> Any:
if not self.command_magic:
if not self.magic:
return command
result = self.command_magic.resolve(command)
result = self.magic.resolve(command)
if not result:
raise CommandException("Rejected via magic filter")
return replace(command, magic_result=result)
class Config:
arbitrary_types_allowed = True
@dataclass(frozen=True)
class CommandObject:
@ -170,10 +224,32 @@ class CommandObject:
class CommandStart(Command):
commands: Tuple[str] = Field(("start",), const=True)
commands_prefix: str = Field("/", const=True)
deep_link: bool = False
deep_link_encoded: bool = False
def __init__(
self,
deep_link: bool = False,
deep_link_encoded: bool = False,
ignore_case: bool = False,
ignore_mention: bool = False,
magic: Optional[MagicFilter] = None,
):
super().__init__(
"start",
prefix="/",
ignore_case=ignore_case,
ignore_mention=ignore_mention,
magic=magic,
)
self.deep_link = deep_link
self.deep_link_encoded = deep_link_encoded
def __str__(self) -> str:
return self._signature_to_string(
ignore_case=self.ignore_case,
ignore_mention=self.ignore_mention,
magic=self.magic,
deep_link=self.deep_link,
deep_link_encoded=self.deep_link_encoded,
)
async def parse_command(self, text: str, bot: Bot) -> CommandObject:
"""

View file

@ -1,34 +0,0 @@
from typing import Any, Dict, Optional, Sequence, Union
from pydantic import validator
from aiogram.types import Message
from aiogram.types.message import ContentType
from .base import BaseFilter
class ContentTypesFilter(BaseFilter):
"""
Is useful for handling specific types of messages (For example separate text and stickers handlers).
"""
content_types: Union[Sequence[str], str]
"""Sequence of allowed content types"""
@validator("content_types")
def _validate_content_types(
cls, value: Optional[Union[Sequence[str], str]]
) -> Optional[Sequence[str]]:
if not value:
return value
if isinstance(value, str):
value = [value]
allowed_content_types = set(ContentType.all())
bad_content_types = set(value) - allowed_content_types
if bad_content_types:
raise ValueError(f"Invalid content types {bad_content_types} is not allowed here")
return value
async def __call__(self, message: Message) -> Union[bool, Dict[str, Any]]:
return ContentType.ANY in self.content_types or message.content_type in self.content_types

View file

@ -1,51 +1,51 @@
import re
from typing import Any, Dict, Pattern, Tuple, Type, Union, cast
from typing import Any, Dict, Pattern, Type, Union, cast
from pydantic import validator
from aiogram.filters import BaseFilter
from aiogram.filters.base import Filter
from aiogram.types import TelegramObject
from aiogram.types.error_event import ErrorEvent
class ExceptionTypeFilter(BaseFilter):
class ExceptionTypeFilter(Filter):
"""
Allows to match exception by type
"""
exception: Union[Type[Exception], Tuple[Type[Exception]]]
"""Exception type(s)"""
def __init__(self, *exceptions: Type[Exception]):
"""
:param exceptions: Exception type(s)
"""
if not exceptions:
raise ValueError("At least one exception type is required")
self.exceptions = exceptions
class Config:
arbitrary_types_allowed = True
async def __call__(
self, obj: TelegramObject, exception: Exception
) -> Union[bool, Dict[str, Any]]:
return isinstance(exception, self.exception)
async def __call__(self, obj: TelegramObject) -> Union[bool, Dict[str, Any]]:
return isinstance(cast(ErrorEvent, obj).exception, self.exceptions)
class ExceptionMessageFilter(BaseFilter):
class ExceptionMessageFilter(Filter):
"""
Allow to match exception by message
"""
pattern: Union[str, Pattern[str]]
"""Regexp pattern"""
def __init__(self, pattern: Union[str, Pattern[str]]):
"""
:param pattern: Regexp pattern
"""
if isinstance(pattern, str):
pattern = re.compile(pattern)
self.pattern = pattern
class Config:
arbitrary_types_allowed = True
@validator("pattern")
def _validate_match(cls, value: Union[str, Pattern[str]]) -> Union[str, Pattern[str]]:
if isinstance(value, str):
return re.compile(value)
return value
def __str__(self) -> str:
return self._signature_to_string(
pattern=self.pattern,
)
async def __call__(
self, obj: TelegramObject, exception: Exception
self,
obj: TelegramObject,
) -> Union[bool, Dict[str, Any]]:
pattern = cast(Pattern[str], self.pattern)
result = pattern.match(str(exception))
result = self.pattern.match(str(cast(ErrorEvent, obj).exception))
if not result:
return False
return {"match_exception": result}

View file

@ -1,87 +0,0 @@
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict, Union
if TYPE_CHECKING:
from aiogram.dispatcher.event.handler import CallbackType, FilterObject
class _LogicFilter:
__call__: Callable[..., Awaitable[Union[bool, Dict[str, Any]]]]
def __and__(self, other: "CallbackType") -> "_AndFilter":
return and_f(self, other)
def __or__(self, other: "CallbackType") -> "_OrFilter":
return or_f(self, other)
def __invert__(self) -> "_InvertFilter":
return invert_f(self)
def __await__(self): # type: ignore # pragma: no cover
# Is needed only for inspection and this method is never be called
return self.__call__
class _InvertFilter(_LogicFilter):
__slots__ = ("target",)
def __init__(self, target: "FilterObject") -> None:
self.target = target
async def __call__(self, *args: Any, **kwargs: Any) -> Union[bool, Dict[str, Any]]:
return not bool(await self.target.call(*args, **kwargs))
class _AndFilter(_LogicFilter):
__slots__ = ("targets",)
def __init__(self, *targets: "FilterObject") -> None:
self.targets = targets
async def __call__(self, *args: Any, **kwargs: Any) -> Union[bool, Dict[str, Any]]:
final_result = {}
for target in self.targets:
result = await target.call(*args, **kwargs)
if not result:
return False
if isinstance(result, dict):
final_result.update(result)
if final_result:
return final_result
return True
class _OrFilter(_LogicFilter):
__slots__ = ("targets",)
def __init__(self, *targets: "FilterObject") -> None:
self.targets = targets
async def __call__(self, *args: Any, **kwargs: Any) -> Union[bool, Dict[str, Any]]:
for target in self.targets:
result = await target.call(*args, **kwargs)
if not result:
continue
if isinstance(result, dict):
return result
return bool(result)
return False
def and_f(target1: "CallbackType", target2: "CallbackType") -> _AndFilter:
from aiogram.dispatcher.event.handler import FilterObject
return _AndFilter(FilterObject(target1), FilterObject(target2))
def or_f(target1: "CallbackType", target2: "CallbackType") -> _OrFilter:
from aiogram.dispatcher.event.handler import FilterObject
return _OrFilter(FilterObject(target1), FilterObject(target2))
def invert_f(target: "CallbackType") -> _InvertFilter:
from aiogram.dispatcher.event.handler import FilterObject
return _InvertFilter(FilterObject(target))

View file

@ -2,17 +2,20 @@ from typing import Any
from magic_filter import AttrDict, MagicFilter
from aiogram.filters import BaseFilter
from aiogram.filters.base import Filter
from aiogram.types import TelegramObject
class MagicData(BaseFilter):
magic_data: MagicFilter
class Config:
arbitrary_types_allowed = True
class MagicData(Filter):
def __init__(self, magic_data: MagicFilter) -> None:
self.magic_data = magic_data
async def __call__(self, event: TelegramObject, *args: Any, **kwargs: Any) -> Any:
return self.magic_data.resolve(
AttrDict({"event": event, **{k: v for k, v in enumerate(args)}, **kwargs})
)
def __str__(self) -> str:
return self._signature_to_string(
magic_data=self.magic_data,
)

View file

@ -1,40 +1,33 @@
from inspect import isclass
from typing import Any, Dict, Optional, Sequence, Type, Union, cast, no_type_check
from typing import Any, Dict, Optional, Sequence, Type, Union, cast
from pydantic import Field, validator
from aiogram.filters import BaseFilter
from aiogram.filters.base import Filter
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import TelegramObject
StateType = Union[str, None, State, StatesGroup, Type[StatesGroup]]
class StateFilter(BaseFilter):
class StateFilter(Filter):
"""
State filter
"""
state: Union[StateType, Sequence[StateType]] = Field(...)
def __init__(self, *states: StateType) -> None:
if not states:
raise ValueError("At least one state is required")
class Config:
arbitrary_types_allowed = True
self.states = states
@validator("state")
@no_type_check # issubclass breaks things
def _validate_state(cls, v: Union[StateType, Sequence[StateType]]) -> Sequence[StateType]:
if (
isinstance(v, (str, State, StatesGroup))
or (isclass(v) and issubclass(v, StatesGroup))
or v is None
):
return [v]
return v
def __str__(self) -> str:
return self._signature_to_string(
*self.states,
)
async def __call__(
self, obj: Union[TelegramObject], raw_state: Optional[str] = None
) -> Union[bool, Dict[str, Any]]:
allowed_states = cast(Sequence[StateType], self.state)
allowed_states = cast(Sequence[StateType], self.states)
for allowed_state in allowed_states:
if isinstance(allowed_state, str) or allowed_state is None:
if allowed_state == "*" or raw_state == allowed_state:

View file

@ -1,8 +1,6 @@
from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence, Union
from pydantic import root_validator
from aiogram.filters import BaseFilter
from aiogram.filters.base import Filter
from aiogram.types import CallbackQuery, InlineQuery, Message, Poll
if TYPE_CHECKING:
@ -11,7 +9,7 @@ if TYPE_CHECKING:
TextType = Union[str, "LazyProxy"]
class Text(BaseFilter):
class Text(Filter):
"""
Is useful for filtering text :class:`aiogram.types.message.Message`,
any :class:`aiogram.types.callback_query.CallbackQuery` with `data`,
@ -19,7 +17,7 @@ class Text(BaseFilter):
.. warning::
Only one of `text`, `text_contains`, `text_startswith` or `text_endswith` argument can be used at once.
Only one of `text`, `contains`, `startswith` or `endswith` argument can be used at once.
Any of that arguments can be string, list, set or tuple of strings.
.. deprecated:: 3.0
@ -27,40 +25,63 @@ class Text(BaseFilter):
use :ref:`magic-filter <magic-filters>`. For example do :pycode:`F.text == "text"` instead
"""
text: Optional[Union[Sequence[TextType], TextType]] = None
"""Text equals value or one of values"""
text_contains: Optional[Union[Sequence[TextType], TextType]] = None
"""Text contains value or one of values"""
text_startswith: Optional[Union[Sequence[TextType], TextType]] = None
"""Text starts with value or one of values"""
text_endswith: Optional[Union[Sequence[TextType], TextType]] = None
"""Text ends with value or one of values"""
text_ignore_case: bool = False
"""Ignore case when checks"""
def __init__(
self,
text: Optional[Union[Sequence[TextType], TextType]] = None,
*,
contains: Optional[Union[Sequence[TextType], TextType]] = None,
startswith: Optional[Union[Sequence[TextType], TextType]] = None,
endswith: Optional[Union[Sequence[TextType], TextType]] = None,
ignore_case: bool = False,
):
"""
class Config:
arbitrary_types_allowed = True
@root_validator
def _validate_constraints(cls, values: Dict[str, Any]) -> Dict[str, Any]:
# Validate that only one text filter type is presented
used_args = set(
key for key, value in values.items() if key != "text_ignore_case" and value is not None
:param text: Text equals value or one of values
:param contains: Text contains value or one of values
:param startswith: Text starts with value or one of values
:param endswith: Text ends with value or one of values
:param ignore_case: Ignore case when checks
"""
self._validate_constraints(
text=text,
contains=contains,
startswith=startswith,
endswith=endswith,
)
self.text = self._prepare_argument(text)
self.contains = self._prepare_argument(contains)
self.startswith = self._prepare_argument(startswith)
self.endswith = self._prepare_argument(endswith)
self.ignore_case = ignore_case
def __str__(self) -> str:
return self._signature_to_string(
text=self.text,
contains=self.contains,
startswith=self.startswith,
endswith=self.endswith,
ignore_case=self.ignore_case,
)
@classmethod
def _prepare_argument(
cls, value: Optional[Union[Sequence[TextType], TextType]]
) -> Optional[Sequence[TextType]]:
from aiogram.utils.i18n.lazy_proxy import LazyProxy
if isinstance(value, (str, LazyProxy)):
return [value]
return value
@classmethod
def _validate_constraints(cls, **values: Any) -> None:
# Validate that only one text filter type is presented
used_args = set(key for key, value in values.items() if value is not None)
if len(used_args) < 1:
raise ValueError(
"Filter should contain one of arguments: {'text', 'text_contains', 'text_startswith', 'text_endswith'}"
)
raise ValueError(f"Filter should contain one of arguments: {set(values.keys())}")
if len(used_args) > 1:
raise ValueError(f"Arguments {used_args} cannot be used together")
# Convert single value to list
for arg in used_args:
if isinstance(values[arg], str):
values[arg] = [values[arg]]
return values
async def __call__(
self, obj: Union[Message, CallbackQuery, InlineQuery, Poll]
) -> Union[bool, Dict[str, Any]]:
@ -79,30 +100,30 @@ class Text(BaseFilter):
if not text:
return False
if self.text_ignore_case:
if self.ignore_case:
text = text.lower()
if self.text is not None:
equals = list(map(self.prepare_text, self.text))
equals = map(self.prepare_text, self.text)
return text in equals
if self.text_contains is not None:
contains = list(map(self.prepare_text, self.text_contains))
if self.contains is not None:
contains = map(self.prepare_text, self.contains)
return all(map(text.__contains__, contains))
if self.text_startswith is not None:
startswith = list(map(self.prepare_text, self.text_startswith))
if self.startswith is not None:
startswith = map(self.prepare_text, self.startswith)
return any(map(text.startswith, startswith))
if self.text_endswith is not None:
endswith = list(map(self.prepare_text, self.text_endswith))
if self.endswith is not None:
endswith = map(self.prepare_text, self.endswith)
return any(map(text.endswith, endswith))
# Impossible because the validator prevents this situation
return False # pragma: no cover
def prepare_text(self, text: str) -> str:
if self.text_ignore_case:
if self.ignore_case:
return str(text).lower()
else:
return str(text)

View file

@ -0,0 +1,16 @@
from aiogram.types import Update
from aiogram.types.base import MutableTelegramObject
class ErrorEvent(MutableTelegramObject):
"""
Internal event, should be used to receive errors while processing Updates from Telegram
"""
update: Update
"""Received update"""
exception: Exception
"""Exception"""
class Config:
arbitrary_types_allowed = True

View file

@ -67,7 +67,7 @@ class BufferedInputFile(InputFile):
:param path: Path to file
:param filename: Filename to be propagated to telegram.
By default will be parsed from path
By default, will be parsed from path
:param chunk_size: Uploading chunk size
:return: instance of :obj:`BufferedInputFile`
"""
@ -95,7 +95,7 @@ class FSInputFile(InputFile):
:param path: Path to file
:param filename: Filename to be propagated to telegram.
By default will be parsed from path
By default, will be parsed from path
:param chunk_size: Uploading chunk size
"""
if filename is None:
@ -106,10 +106,8 @@ class FSInputFile(InputFile):
async def read(self, chunk_size: int) -> AsyncGenerator[bytes, None]:
async with aiofiles.open(self.path, "rb") as f:
chunk = await f.read(chunk_size)
while chunk:
while chunk := await f.read(chunk_size):
yield chunk
chunk = await f.read(chunk_size)
class URLInputFile(InputFile):

View file

@ -1,6 +1,5 @@
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Optional
from ..utils.text_decorations import add_surrogates, remove_surrogates
@ -36,11 +35,3 @@ class MessageEntity(MutableTelegramObject):
return remove_surrogates(
add_surrogates(text)[self.offset * 2 : (self.offset + self.length) * 2]
)
def extract(self, text: str) -> str:
warnings.warn(
"Method `MessageEntity.extract(...)` deprecated and will be removed in 3.0b5.\n"
" Use `MessageEntity.extract_from(...)` instead.",
DeprecationWarning,
)
return self.extract_from(text=text)

View file

@ -61,7 +61,7 @@ class Update(TelegramObject):
def __hash__(self) -> int:
return hash((type(self), self.update_id))
@property # type: ignore
@property
@lru_cache()
def event_type(self) -> str:
"""

View file

@ -9,7 +9,7 @@ account to their account on some external service.
You can read detailed description in the source:
https://core.telegram.org/bots#deep-linking
We have add some utils to get deep links more handy.
We have added some utils to get deep links more handy.
Basic link example:

View file

@ -200,7 +200,7 @@ class ItemsList(List[str]):
self.extend(other)
return self
__iadd__ = __add__ = __rand__ = __and__ = __ror__ = __or__ = add
__iadd__ = __add__ = __rand__ = __and__ = __ror__ = __or__ = add # type: ignore
class OrderedHelperMeta(type):

View file

@ -2,5 +2,5 @@ class AiogramWarning(Warning):
pass
class CodeHasNoEffect(AiogramWarning):
class Recommendation(AiogramWarning):
pass