From f022b4441c4be5a57bab7533d7e9aa8fd650be6b Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 25 May 2021 00:56:44 +0300 Subject: [PATCH 01/38] Added more aliases, refactor CallbackData factory, added base exceptions classification mechanism --- aiogram/client/bot.py | 2 +- aiogram/client/session/aiohttp.py | 18 +- aiogram/client/session/base.py | 74 ++- aiogram/dispatcher/dispatcher.py | 39 +- aiogram/dispatcher/event/telegram.py | 2 +- aiogram/dispatcher/fsm/middleware.py | 6 +- aiogram/dispatcher/fsm/storage/base.py | 4 +- aiogram/dispatcher/fsm/storage/memory.py | 6 +- aiogram/methods/base.py | 15 +- aiogram/types/message.py | 46 ++ aiogram/utils/exceptions.py | 563 ------------------ aiogram/utils/exceptions/exceptions.py | 93 +++ aiogram/utils/{markup.py => keyboard.py} | 35 +- examples/echo_bot.py | 37 +- tests/mocked_bot.py | 30 +- .../test_session/test_aiohttp_session.py | 10 +- .../test_session/test_base_session.py | 20 +- tests/test_api/test_types/test_message.py | 28 + 18 files changed, 364 insertions(+), 664 deletions(-) delete mode 100644 aiogram/utils/exceptions.py create mode 100644 aiogram/utils/exceptions/exceptions.py rename aiogram/utils/{markup.py => keyboard.py} (83%) diff --git a/aiogram/client/bot.py b/aiogram/client/bot.py index 19fe7838..ea84baa7 100644 --- a/aiogram/client/bot.py +++ b/aiogram/client/bot.py @@ -302,7 +302,7 @@ class Bot(ContextInstanceMixin["Bot"]): :param method: :return: """ - return await self.session.make_request(self, method, timeout=request_timeout) + return await self.session(self, method, timeout=request_timeout) def __hash__(self) -> int: """ diff --git a/aiogram/client/session/aiohttp.py b/aiogram/client/session/aiohttp.py index 3373d800..dfb297ff 100644 --- a/aiogram/client/session/aiohttp.py +++ b/aiogram/client/session/aiohttp.py @@ -10,7 +10,6 @@ from typing import ( Optional, Tuple, Type, - TypeVar, Union, cast, ) @@ -19,12 +18,12 @@ from aiohttp import BasicAuth, ClientSession, FormData, TCPConnector from aiogram.methods import Request, TelegramMethod +from ...methods.base import TelegramType from .base import UNSET, BaseSession if TYPE_CHECKING: # pragma: no cover from ..bot import Bot -T = TypeVar("T") _ProxyBasic = Union[str, Tuple[str, BasicAuth]] _ProxyChain = Iterable[_ProxyBasic] _ProxyType = Union[_ProxyChain, _ProxyBasic] @@ -76,6 +75,8 @@ def _prepare_connector(chain_or_plain: _ProxyType) -> Tuple[Type["TCPConnector"] class AiohttpSession(BaseSession): def __init__(self, proxy: Optional[_ProxyType] = None): + super().__init__() + self._session: Optional[ClientSession] = None self._connector_type: Type[TCPConnector] = TCPConnector self._connector_init: Dict[str, Any] = {} @@ -86,7 +87,7 @@ class AiohttpSession(BaseSession): try: self._setup_proxy_connector(proxy) except ImportError as exc: # pragma: no cover - raise UserWarning( + raise RuntimeError( "In order to use aiohttp client for proxy requests, install " "https://pypi.org/project/aiohttp-socks/" ) from exc @@ -130,8 +131,8 @@ class AiohttpSession(BaseSession): return form async def make_request( - self, bot: Bot, call: TelegramMethod[T], timeout: Optional[int] = None - ) -> T: + self, bot: Bot, call: TelegramMethod[TelegramType], timeout: Optional[int] = None + ) -> TelegramType: session = await self.create_session() request = call.build_request(bot) @@ -141,11 +142,10 @@ class AiohttpSession(BaseSession): async with session.post( url, data=form, timeout=self.timeout if timeout is None else timeout ) as resp: - raw_result = await resp.json(loads=self.json_loads) + raw_result = await resp.text() - response = call.build_response(raw_result) - self.raise_for_status(response) - return cast(T, response.result) + response = self.check_response(method=call, status_code=resp.status, content=raw_result) + return cast(TelegramType, response.result) async def stream_content( self, url: str, timeout: int, chunk_size: int diff --git a/aiogram/client/session/base.py b/aiogram/client/session/base.py index 7a8edd4a..2e752e72 100644 --- a/aiogram/client/session/base.py +++ b/aiogram/client/session/base.py @@ -3,32 +3,44 @@ from __future__ import annotations import abc import datetime import json +from functools import partial from types import TracebackType from typing import ( TYPE_CHECKING, Any, AsyncGenerator, + Awaitable, Callable, ClassVar, + List, Optional, Type, - TypeVar, Union, + cast, ) -from aiogram.utils.exceptions import TelegramAPIError +from aiogram.utils.exceptions.base import TelegramAPIError from aiogram.utils.helper import Default from ...methods import Response, TelegramMethod -from ...types import UNSET +from ...methods.base import TelegramType +from ...types import UNSET, TelegramObject +from ...utils.exceptions.special import MigrateToChat, RetryAfter +from ..errors_middleware import RequestErrorMiddleware from ..telegram import PRODUCTION, TelegramAPIServer if TYPE_CHECKING: # pragma: no cover from ..bot import Bot -T = TypeVar("T") _JsonLoads = Callable[..., Any] _JsonDumps = Callable[..., str] +NextRequestMiddlewareType = Callable[ + ["Bot", TelegramMethod[TelegramObject]], Awaitable[Response[TelegramObject]] +] +RequestMiddlewareType = Callable[ + ["Bot", TelegramMethod[TelegramType], NextRequestMiddlewareType], + Awaitable[Response[TelegramType]], +] class BaseSession(abc.ABC): @@ -43,16 +55,40 @@ class BaseSession(abc.ABC): timeout: Default[float] = Default(fget=lambda self: float(self.__class__.default_timeout)) """Session scope request timeout""" - @classmethod - def raise_for_status(cls, response: Response[T]) -> None: + errors_middleware: ClassVar[RequestErrorMiddleware] = RequestErrorMiddleware() + + def __init__(self) -> None: + self.middlewares: List[RequestMiddlewareType[TelegramObject]] = [ + self.errors_middleware, + ] + + def check_response( + self, method: TelegramMethod[TelegramType], status_code: int, content: str + ) -> Response[TelegramType]: """ Check response status - - :param response: Response instance """ + json_data = self.json_loads(content) + response = method.build_response(json_data) if response.ok: - return - raise TelegramAPIError(response.description) + return response + + description = cast(str, response.description) + if parameters := response.parameters: + if parameters.retry_after: + raise RetryAfter( + method=method, message=description, retry_after=parameters.retry_after + ) + if parameters.migrate_to_chat_id: + raise MigrateToChat( + method=method, + message=description, + migrate_to_chat_id=parameters.migrate_to_chat_id, + ) + raise TelegramAPIError( + method=method, + message=description, + ) @abc.abstractmethod async def close(self) -> None: # pragma: no cover @@ -63,8 +99,8 @@ class BaseSession(abc.ABC): @abc.abstractmethod async def make_request( - self, bot: Bot, method: TelegramMethod[T], timeout: Optional[int] = UNSET - ) -> T: # pragma: no cover + self, bot: Bot, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET + ) -> TelegramType: # pragma: no cover """ Make request to Telegram Bot API @@ -111,6 +147,20 @@ class BaseSession(abc.ABC): return {k: self.clean_json(v) for k, v in value.items() if v is not None} return value + def middleware( + self, middleware: RequestMiddlewareType[TelegramObject] + ) -> RequestMiddlewareType[TelegramObject]: + self.middlewares.append(middleware) + return middleware + + async def __call__( + self, bot: Bot, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET + ) -> TelegramType: + middleware = partial(self.make_request, timeout=timeout) + for m in reversed(self.middlewares): + middleware = partial(m, make_request=middleware) # type: ignore + return await middleware(bot, method) + async def __aenter__(self) -> BaseSession: return self diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 2978d5bd..78ff5aaf 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -8,9 +8,9 @@ from typing import Any, AsyncGenerator, Dict, Optional, Union, cast from .. import loggers from ..client.bot import Bot -from ..methods import TelegramMethod +from ..methods import GetUpdates, TelegramMethod from ..types import TelegramObject, Update, User -from ..utils.exceptions import TelegramAPIError +from ..utils.exceptions.base import TelegramAPIError from .event.bases import UNHANDLED, SkipHandler from .event.telegram import TelegramEventObserver from .fsm.context import FSMContext @@ -119,16 +119,22 @@ class Dispatcher(Router): return await self.feed_update(bot=bot, update=parsed_update, **kwargs) @classmethod - async def _listen_updates(cls, bot: Bot) -> AsyncGenerator[Update, None]: + async def _listen_updates( + cls, bot: Bot, polling_timeout: int = 30 + ) -> AsyncGenerator[Update, None]: """ Infinity updates reader """ - update_id: Optional[int] = None + get_updates = GetUpdates(timeout=polling_timeout) + kwargs = {} + if bot.session.timeout: + kwargs["request_timeout"] = int(bot.session.timeout + polling_timeout) while True: # TODO: Skip restarting telegram error - for update in await bot.get_updates(offset=update_id): + updates = await bot(get_updates, **kwargs) + for update in updates: yield update - update_id = update.update_id + 1 + get_updates.offset = update.update_id + 1 async def _listen_update(self, update: Update, **kwargs: Any) -> Any: """ @@ -249,7 +255,7 @@ class Dispatcher(Router): ) return True # because update was processed but unsuccessful - async def _polling(self, bot: Bot, **kwargs: Any) -> None: + async def _polling(self, bot: Bot, polling_timeout: int = 30, **kwargs: Any) -> None: """ Internal polling process @@ -257,7 +263,7 @@ class Dispatcher(Router): :param kwargs: :return: """ - async for update in self._listen_updates(bot): + async for update in self._listen_updates(bot, polling_timeout=polling_timeout): await self._process_update(bot=bot, update=update, **kwargs) async def _feed_webhook_update(self, bot: Bot, update: Update, **kwargs: Any) -> Any: @@ -336,7 +342,7 @@ class Dispatcher(Router): return None - async def start_polling(self, *bots: Bot, **kwargs: Any) -> None: + async def start_polling(self, *bots: Bot, polling_timeout: int = 10, **kwargs: Any) -> None: """ Polling runner @@ -356,7 +362,9 @@ class Dispatcher(Router): loggers.dispatcher.info( "Run polling for bot @%s id=%d - %r", user.username, bot.id, user.full_name ) - coro_list.append(self._polling(bot=bot, **kwargs)) + coro_list.append( + self._polling(bot=bot, polling_timeout=polling_timeout, **kwargs) + ) await asyncio.gather(*coro_list) finally: for bot in bots: # Close sessions @@ -364,16 +372,19 @@ class Dispatcher(Router): loggers.dispatcher.info("Polling stopped") await self.emit_shutdown(**workflow_data) - def run_polling(self, *bots: Bot, **kwargs: Any) -> None: + def run_polling(self, *bots: Bot, polling_timeout: int = 30, **kwargs: Any) -> None: """ Run many bots with polling - :param bots: - :param kwargs: + :param bots: Bot instances + :param polling_timeout: Poling timeout + :param kwargs: contextual data :return: """ try: - return asyncio.run(self.start_polling(*bots, **kwargs)) + return asyncio.run( + self.start_polling(*bots, **kwargs, polling_timeout=polling_timeout) + ) except (KeyboardInterrupt, SystemExit): # pragma: no cover # Allow to graceful shutdown pass diff --git a/aiogram/dispatcher/event/telegram.py b/aiogram/dispatcher/event/telegram.py index ab185043..50e2412d 100644 --- a/aiogram/dispatcher/event/telegram.py +++ b/aiogram/dispatcher/event/telegram.py @@ -150,7 +150,7 @@ class TelegramEventObserver: return UNHANDLED def __call__( - self, *args: FilterType, **bound_filters: BaseFilter + self, *args: FilterType, **bound_filters: Any ) -> Callable[[CallbackType], CallbackType]: """ Decorator for registering event handlers diff --git a/aiogram/dispatcher/fsm/middleware.py b/aiogram/dispatcher/fsm/middleware.py index d3d5d8c2..1e3ba91c 100644 --- a/aiogram/dispatcher/fsm/middleware.py +++ b/aiogram/dispatcher/fsm/middleware.py @@ -28,9 +28,9 @@ class FSMContextMiddleware(BaseMiddleware[Update]): data["fsm_storage"] = self.storage if context: data.update({"state": context, "raw_state": await context.get_state()}) - if self.isolate_events: - async with self.storage.lock(): - return await handler(event, data) + if self.isolate_events: + async with self.storage.lock(chat_id=context.chat_id, user_id=context.user_id): + return await handler(event, data) return await handler(event, data) def resolve_event_context(self, data: Dict[str, Any]) -> Optional[FSMContext]: diff --git a/aiogram/dispatcher/fsm/storage/base.py b/aiogram/dispatcher/fsm/storage/base.py index 36cebb31..f394cd61 100644 --- a/aiogram/dispatcher/fsm/storage/base.py +++ b/aiogram/dispatcher/fsm/storage/base.py @@ -10,7 +10,9 @@ StateType = Optional[Union[str, State]] class BaseStorage(ABC): @abstractmethod @asynccontextmanager - async def lock(self) -> AsyncGenerator[None, None]: # pragma: no cover + async def lock( + self, chat_id: int, user_id: int + ) -> AsyncGenerator[None, None]: # pragma: no cover yield None @abstractmethod diff --git a/aiogram/dispatcher/fsm/storage/memory.py b/aiogram/dispatcher/fsm/storage/memory.py index 46b6d60b..933e225c 100644 --- a/aiogram/dispatcher/fsm/storage/memory.py +++ b/aiogram/dispatcher/fsm/storage/memory.py @@ -12,6 +12,7 @@ from aiogram.dispatcher.fsm.storage.base import BaseStorage, StateType class MemoryStorageRecord: data: Dict[str, Any] = field(default_factory=dict) state: Optional[str] = None + lock: Lock = field(default_factory=Lock) class MemoryStorage(BaseStorage): @@ -19,11 +20,10 @@ class MemoryStorage(BaseStorage): self.storage: DefaultDict[int, DefaultDict[int, MemoryStorageRecord]] = defaultdict( lambda: defaultdict(MemoryStorageRecord) ) - self._lock = Lock() @asynccontextmanager - async def lock(self) -> AsyncGenerator[None, None]: - async with self._lock: + async def lock(self, chat_id: int, user_id: int) -> AsyncGenerator[None, None]: + async with self.storage[chat_id][user_id].lock: yield None async def set_state(self, chat_id: int, user_id: int, state: StateType = None) -> None: diff --git a/aiogram/methods/base.py b/aiogram/methods/base.py index ce73a35b..334beac0 100644 --- a/aiogram/methods/base.py +++ b/aiogram/methods/base.py @@ -12,7 +12,7 @@ from ..types import UNSET, InputFile, ResponseParameters if TYPE_CHECKING: # pragma: no cover from ..client.bot import Bot -T = TypeVar("T") +TelegramType = TypeVar("TelegramType", bound=Any) class Request(BaseModel): @@ -31,14 +31,15 @@ class Request(BaseModel): } -class Response(ResponseParameters, GenericModel, Generic[T]): +class Response(GenericModel, Generic[TelegramType]): ok: bool - result: Optional[T] = None + result: Optional[TelegramType] = None description: Optional[str] = None error_code: Optional[int] = None + parameters: Optional[ResponseParameters] = None -class TelegramMethod(abc.ABC, BaseModel, Generic[T]): +class TelegramMethod(abc.ABC, BaseModel, Generic[TelegramType]): class Config(BaseConfig): # use_enum_values = True extra = Extra.allow @@ -76,14 +77,14 @@ class TelegramMethod(abc.ABC, BaseModel, Generic[T]): return super().dict(exclude=exclude, **kwargs) - def build_response(self, data: Dict[str, Any]) -> Response[T]: + def build_response(self, data: Dict[str, Any]) -> Response[TelegramType]: # noinspection PyTypeChecker return Response[self.__returning__](**data) # type: ignore - async def emit(self, bot: Bot) -> T: + async def emit(self, bot: Bot) -> TelegramType: return await bot(self) - def __await__(self) -> Generator[Any, None, T]: + def __await__(self) -> Generator[Any, None, TelegramType]: from aiogram.client.bot import Bot bot = Bot.get_current(no_error=False) diff --git a/aiogram/types/message.py b/aiogram/types/message.py index d476c099..67b3a594 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -12,6 +12,9 @@ from .base import UNSET, TelegramObject if TYPE_CHECKING: # pragma: no cover from ..methods import ( CopyMessage, + DeleteMessage, + EditMessageCaption, + EditMessageText, SendAnimation, SendAudio, SendContact, @@ -1714,6 +1717,49 @@ class Message(TelegramObject): reply_markup=reply_markup, ) + def edit_text( + self, + text: str, + parse_mode: Optional[str] = UNSET, + entities: Optional[List[MessageEntity]] = None, + disable_web_page_preview: Optional[bool] = None, + reply_markup: Optional[InlineKeyboardMarkup] = None, + ) -> EditMessageText: + from ..methods import EditMessageText + + return EditMessageText( + chat_id=self.chat.id, + message_id=self.message_id, + text=text, + parse_mode=parse_mode, + entities=entities, + disable_web_page_preview=disable_web_page_preview, + reply_markup=reply_markup, + ) + + def edit_caption( + self, + caption: str, + parse_mode: Optional[str] = UNSET, + caption_entities: Optional[List[MessageEntity]] = None, + reply_markup: Optional[InlineKeyboardMarkup] = None, + ) -> EditMessageCaption: + from ..methods import EditMessageCaption + + return EditMessageCaption( + chat_id=self.chat.id, + message_id=self.message_id, + caption=caption, + parse_mode=parse_mode, + caption_entities=caption_entities, + reply_markup=reply_markup, + ) + + def delete(self) -> DeleteMessage: + from ..methods import DeleteMessage + + return DeleteMessage(chat_id=self.chat.id, message_id=self.message_id) + class ContentType(helper.Helper): mode = helper.HelperMode.snake_case diff --git a/aiogram/utils/exceptions.py b/aiogram/utils/exceptions.py deleted file mode 100644 index 3efe7032..00000000 --- a/aiogram/utils/exceptions.py +++ /dev/null @@ -1,563 +0,0 @@ -""" -- TelegramAPIError - - ValidationError - - Throttled - - BadRequest - - MessageError - - MessageNotModified - - MessageToForwardNotFound - - MessageToDeleteNotFound - - MessageIdentifierNotSpecified - - MessageTextIsEmpty - - MessageCantBeEdited - - MessageCantBeDeleted - - MessageToEditNotFound - - MessageToReplyNotFound - - ToMuchMessages - - PollError - - PollCantBeStopped - - PollHasAlreadyClosed - - PollsCantBeSentToPrivateChats - - PollSizeError - - PollMustHaveMoreOptions - - PollCantHaveMoreOptions - - PollsOptionsLengthTooLong - - PollOptionsMustBeNonEmpty - - PollQuestionMustBeNonEmpty - - MessageWithPollNotFound (with MessageError) - - MessageIsNotAPoll (with MessageError) - - ObjectExpectedAsReplyMarkup - - InlineKeyboardExpected - - ChatNotFound - - ChatDescriptionIsNotModified - - InvalidQueryID - - InvalidPeerID - - InvalidHTTPUrlContent - - ButtonURLInvalid - - URLHostIsEmpty - - StartParamInvalid - - ButtonDataInvalid - - WrongFileIdentifier - - GroupDeactivated - - BadWebhook - - WebhookRequireHTTPS - - BadWebhookPort - - BadWebhookAddrInfo - - BadWebhookNoAddressAssociatedWithHostname - - NotFound - - MethodNotKnown - - PhotoAsInputFileRequired - - InvalidStickersSet - - NoStickerInRequest - - ChatAdminRequired - - NeedAdministratorRightsInTheChannel - - MethodNotAvailableInPrivateChats - - CantDemoteChatCreator - - CantRestrictSelf - - NotEnoughRightsToRestrict - - PhotoDimensions - - UnavailableMembers - - TypeOfFileMismatch - - WrongRemoteFileIdSpecified - - PaymentProviderInvalid - - CurrencyTotalAmountInvalid - - CantParseUrl - - UnsupportedUrlProtocol - - CantParseEntities - - ResultIdDuplicate - - ConflictError - - TerminatedByOtherGetUpdates - - CantGetUpdates - - Unauthorized - - BotKicked - - BotBlocked - - UserDeactivated - - CantInitiateConversation - - CantTalkWithBots - - NetworkError - - RetryAfter - - MigrateToChat - - RestartingTelegram - -- AIOGramWarning - - TimeoutWarning -""" - - -class TelegramAPIError(Exception): - pass - - -# _PREFIXES = ["error: ", "[error]: ", "bad request: ", "conflict: ", "not found: "] -# - -# def _clean_message(text): -# for prefix in _PREFIXES: -# if text.startswith(prefix): -# text = text[len(prefix) :] -# return (text[0].upper() + text[1:]).strip() -# - - -# -# -# class _MatchErrorMixin: -# match = "" -# text = None -# -# __subclasses = [] -# -# def __init_subclass__(cls, **kwargs): -# super(_MatchErrorMixin, cls).__init_subclass__(**kwargs) -# # cls.match = cls.match.lower() if cls.match else '' -# if not hasattr(cls, f"_{cls.__name__}__group"): -# cls.__subclasses.append(cls) -# -# @classmethod -# def check(cls, message) -> bool: -# """ -# Compare pattern with message -# -# :param message: always must be in lowercase -# :return: bool -# """ -# return cls.match.lower() in message -# -# @classmethod -# def detect(cls, description): -# description = description.lower() -# for err in cls.__subclasses: -# if err is cls: -# continue -# if err.check(description): -# raise err(cls.text or description) -# raise cls(description) -# -# -# class AIOGramWarning(Warning): -# pass -# -# -# class TimeoutWarning(AIOGramWarning): -# pass -# -# -# class FSMStorageWarning(AIOGramWarning): -# pass -# -# -# class ValidationError(TelegramAPIError): -# pass -# -# -# class BadRequest(TelegramAPIError, _MatchErrorMixin): -# __group = True -# -# -# class MessageError(BadRequest): -# __group = True -# -# -# class MessageNotModified(MessageError): -# """ -# Will be raised when you try to set new text is equals to current text. -# """ -# -# match = "message is not modified" -# -# -# class MessageToForwardNotFound(MessageError): -# """ -# Will be raised when you try to forward very old or deleted or unknown message. -# """ -# -# match = "message to forward not found" -# -# -# class MessageToDeleteNotFound(MessageError): -# """ -# Will be raised when you try to delete very old or deleted or unknown message. -# """ -# -# match = "message to delete not found" -# -# -# class MessageToReplyNotFound(MessageError): -# """ -# Will be raised when you try to reply to very old or deleted or unknown message. -# """ -# -# match = "message to reply not found" -# -# -# class MessageIdentifierNotSpecified(MessageError): -# match = "message identifier is not specified" -# -# -# class MessageTextIsEmpty(MessageError): -# match = "Message text is empty" -# -# -# class MessageCantBeEdited(MessageError): -# match = "message can't be edited" -# -# -# class MessageCantBeDeleted(MessageError): -# match = "message can't be deleted" -# -# -# class MessageToEditNotFound(MessageError): -# match = "message to edit not found" -# -# -# class MessageIsTooLong(MessageError): -# match = "message is too long" -# -# -# class ToMuchMessages(MessageError): -# """ -# Will be raised when you try to send media group with more than 10 items. -# """ -# -# match = "Too much messages to send as an album" -# -# -# class ObjectExpectedAsReplyMarkup(BadRequest): -# match = "object expected as reply markup" -# -# -# class InlineKeyboardExpected(BadRequest): -# match = "inline keyboard expected" -# -# -# class PollError(BadRequest): -# __group = True -# -# -# class PollCantBeStopped(PollError): -# match = "poll can't be stopped" -# -# -# class PollHasAlreadyBeenClosed(PollError): -# match = "poll has already been closed" -# -# -# class PollsCantBeSentToPrivateChats(PollError): -# match = "polls can't be sent to private chats" -# -# -# class PollSizeError(PollError): -# __group = True -# -# -# class PollMustHaveMoreOptions(PollSizeError): -# match = "poll must have at least 2 option" -# -# -# class PollCantHaveMoreOptions(PollSizeError): -# match = "poll can't have more than 10 options" -# -# -# class PollOptionsMustBeNonEmpty(PollSizeError): -# match = "poll options must be non-empty" -# -# -# class PollQuestionMustBeNonEmpty(PollSizeError): -# match = "poll question must be non-empty" -# -# -# class PollOptionsLengthTooLong(PollSizeError): -# match = "poll options length must not exceed 100" -# -# -# class PollQuestionLengthTooLong(PollSizeError): -# match = "poll question length must not exceed 255" -# -# -# class MessageWithPollNotFound(PollError, MessageError): -# """ -# Will be raised when you try to stop poll with message without poll -# """ -# -# match = "message with poll to stop not found" -# -# -# class MessageIsNotAPoll(PollError, MessageError): -# """ -# Will be raised when you try to stop poll with message without poll -# """ -# -# match = "message is not a poll" -# -# -# class ChatNotFound(BadRequest): -# match = "chat not found" -# -# -# class ChatIdIsEmpty(BadRequest): -# match = "chat_id is empty" -# -# -# class InvalidUserId(BadRequest): -# match = "user_id_invalid" -# text = "Invalid user id" -# -# -# class ChatDescriptionIsNotModified(BadRequest): -# match = "chat description is not modified" -# -# -# class InvalidQueryID(BadRequest): -# match = "query is too old and response timeout expired or query id is invalid" -# -# -# class InvalidPeerID(BadRequest): -# match = "PEER_ID_INVALID" -# text = "Invalid peer ID" -# -# -# class InvalidHTTPUrlContent(BadRequest): -# match = "Failed to get HTTP URL content" -# -# -# class ButtonURLInvalid(BadRequest): -# match = "BUTTON_URL_INVALID" -# text = "Button URL invalid" -# -# -# class URLHostIsEmpty(BadRequest): -# match = "URL host is empty" -# -# -# class StartParamInvalid(BadRequest): -# match = "START_PARAM_INVALID" -# text = "Start param invalid" -# -# -# class ButtonDataInvalid(BadRequest): -# match = "BUTTON_DATA_INVALID" -# text = "Button data invalid" -# -# -# class WrongFileIdentifier(BadRequest): -# match = "wrong file identifier/HTTP URL specified" -# -# -# class GroupDeactivated(BadRequest): -# match = "group is deactivated" -# -# -# class PhotoAsInputFileRequired(BadRequest): -# """ -# Will be raised when you try to set chat photo from file ID. -# """ -# -# match = "Photo should be uploaded as an InputFile" -# -# -# class InvalidStickersSet(BadRequest): -# match = "STICKERSET_INVALID" -# text = "Stickers set is invalid" -# -# -# class NoStickerInRequest(BadRequest): -# match = "there is no sticker in the request" -# -# -# class ChatAdminRequired(BadRequest): -# match = "CHAT_ADMIN_REQUIRED" -# text = "Admin permissions is required!" -# -# -# class NeedAdministratorRightsInTheChannel(BadRequest): -# match = "need administrator rights in the channel chat" -# text = "Admin permissions is required!" -# -# -# class NotEnoughRightsToPinMessage(BadRequest): -# match = "not enough rights to pin a message" -# -# -# class MethodNotAvailableInPrivateChats(BadRequest): -# match = "method is available only for supergroups and channel" -# -# -# class CantDemoteChatCreator(BadRequest): -# match = "can't demote chat creator" -# -# -# class CantRestrictSelf(BadRequest): -# match = "can't restrict self" -# text = "Admin can't restrict self." -# -# -# class NotEnoughRightsToRestrict(BadRequest): -# match = "not enough rights to restrict/unrestrict chat member" -# -# -# class PhotoDimensions(BadRequest): -# match = "PHOTO_INVALID_DIMENSIONS" -# text = "Invalid photo dimensions" -# -# -# class UnavailableMembers(BadRequest): -# match = "supergroup members are unavailable" -# -# -# class TypeOfFileMismatch(BadRequest): -# match = "type of file mismatch" -# -# -# class WrongRemoteFileIdSpecified(BadRequest): -# match = "wrong remote file id specified" -# -# -# class PaymentProviderInvalid(BadRequest): -# match = "PAYMENT_PROVIDER_INVALID" -# text = "payment provider invalid" -# -# -# class CurrencyTotalAmountInvalid(BadRequest): -# match = "currency_total_amount_invalid" -# text = "currency total amount invalid" -# -# -# class BadWebhook(BadRequest): -# __group = True -# -# -# class WebhookRequireHTTPS(BadWebhook): -# match = "HTTPS url must be provided for webhook" -# text = "bad webhook: " + match -# -# -# class BadWebhookPort(BadWebhook): -# match = "Webhook can be set up only on ports 80, 88, 443 or 8443" -# text = "bad webhook: " + match -# -# -# class BadWebhookAddrInfo(BadWebhook): -# match = "getaddrinfo: Temporary failure in name resolution" -# text = "bad webhook: " + match -# -# -# class BadWebhookNoAddressAssociatedWithHostname(BadWebhook): -# match = "failed to resolve host: no address associated with hostname" -# -# -# class CantParseUrl(BadRequest): -# match = "can't parse URL" -# -# -# class UnsupportedUrlProtocol(BadRequest): -# match = "unsupported URL protocol" -# -# -# class CantParseEntities(BadRequest): -# match = "can't parse entities" -# -# -# class ResultIdDuplicate(BadRequest): -# match = "result_id_duplicate" -# text = "Result ID duplicate" -# -# -# class BotDomainInvalid(BadRequest): -# match = "bot_domain_invalid" -# text = "Invalid bot domain" -# -# -# class NotFound(TelegramAPIError, _MatchErrorMixin): -# __group = True -# -# -# class MethodNotKnown(NotFound): -# match = "method not found" -# -# -# class ConflictError(TelegramAPIError, _MatchErrorMixin): -# __group = True -# -# -# class TerminatedByOtherGetUpdates(ConflictError): -# match = "terminated by other getUpdates request" -# text = ( -# "Terminated by other getUpdates request; " -# "Make sure that only one bot instance is running" -# ) -# -# -# class CantGetUpdates(ConflictError): -# match = "can't use getUpdates method while webhook is active" -# -# -# class Unauthorized(TelegramAPIError, _MatchErrorMixin): -# __group = True -# -# -# class BotKicked(Unauthorized): -# match = "bot was kicked from a chat" -# -# -# class BotBlocked(Unauthorized): -# match = "bot was blocked by the user" -# -# -# class UserDeactivated(Unauthorized): -# match = "user is deactivated" -# -# -# class CantInitiateConversation(Unauthorized): -# match = "bot can't initiate conversation with a user" -# -# -# class CantTalkWithBots(Unauthorized): -# match = "bot can't send messages to bots" -# -# -# class NetworkError(TelegramAPIError): -# pass -# -# -# class RestartingTelegram(TelegramAPIError): -# def __init__(self): -# super(RestartingTelegram, self).__init__( -# "The Telegram Bot API service is restarting. Wait few second." -# ) -# -# -# class RetryAfter(TelegramAPIError): -# def __init__(self, retry_after): -# super(RetryAfter, self).__init__( -# f"Flood control exceeded. Retry in {retry_after} seconds." -# ) -# self.timeout = retry_after -# -# -# class MigrateToChat(TelegramAPIError): -# def __init__(self, chat_id): -# super(MigrateToChat, self).__init__( -# f"The group has been migrated to a supergroup. New id: {chat_id}." -# ) -# self.migrate_to_chat_id = chat_id -# -# -# class Throttled(TelegramAPIError): -# def __init__(self, **kwargs): -# from ..dispatcher.storage import DELTA, EXCEEDED_COUNT, KEY, LAST_CALL, RATE_LIMIT, RESULT -# -# self.key = kwargs.pop(KEY, "") -# self.called_at = kwargs.pop(LAST_CALL, time.time()) -# self.rate = kwargs.pop(RATE_LIMIT, None) -# self.result = kwargs.pop(RESULT, False) -# self.exceeded_count = kwargs.pop(EXCEEDED_COUNT, 0) -# self.delta = kwargs.pop(DELTA, 0) -# self.user = kwargs.pop("user", None) -# self.chat = kwargs.pop("chat", None) -# -# def __str__(self): -# return ( -# f"Rate limit exceeded! (Limit: {self.rate} s, " -# f"exceeded: {self.exceeded_count}, " -# f"time delta: {round(self.delta, 3)} s)" -# ) diff --git a/aiogram/utils/exceptions/exceptions.py b/aiogram/utils/exceptions/exceptions.py new file mode 100644 index 00000000..4ce6f0c2 --- /dev/null +++ b/aiogram/utils/exceptions/exceptions.py @@ -0,0 +1,93 @@ +from textwrap import indent +from typing import Match + +from aiogram.methods.base import TelegramMethod, TelegramType +from aiogram.utils.exceptions.base import DetailedTelegramAPIError +from aiogram.utils.exceptions.util import mark_line + + +class BadRequest(DetailedTelegramAPIError): + pass + + +class CantParseEntities(BadRequest): + pass + + +class CantParseEntitiesStartTag(CantParseEntities): + patterns = [ + "Bad Request: can't parse entities: Can't find end tag corresponding to start tag (?P.+)" + ] + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + match: Match[str], + ) -> None: + super().__init__(method=method, message=message, match=match) + self.tag: str = match.group("tag") + + +class CantParseEntitiesUnmatchedTags(CantParseEntities): + patterns = [ + r'Bad Request: can\'t parse entities: Unmatched end tag at byte offset (?P\d), expected "\w+)>", found "\w+)>"' + ] + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + match: Match[str], + ) -> None: + super().__init__(method=method, message=message, match=match) + self.offset: int = int(match.group("offset")) + self.expected: str = match.group("expected") + self.found: str = match.group("found") + + +class CantParseEntitiesUnclosed(CantParseEntities): + patterns = [ + "Bad Request: can't parse entities: Unclosed start tag at byte offset (?P.+)" + ] + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + match: Match[str], + ) -> None: + super().__init__(method=method, message=message, match=match) + self.offset: int = int(match.group("offset")) + + def __str__(self) -> str: + message = [self.message] + text = getattr(self.method, "text", None) or getattr(self.method, "caption", None) + if text: + message.extend(["Example:", indent(mark_line(text, self.offset), prefix=" ")]) + return "\n".join(message) + + +class CantParseEntitiesUnsupportedTag(CantParseEntities): + patterns = [ + r'Bad Request: can\'t parse entities: Unsupported start tag "(?P.+)" at byte offset (?P\d+)' + ] + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + match: Match[str], + ) -> None: + super().__init__(method=method, message=message, match=match) + self.offset = int(match.group("offset")) + self.tag = match.group("tag") + + def __str__(self) -> str: + message = [self.message] + text = getattr(self.method, "text", None) or getattr(self.method, "caption", None) + if text: + message.extend( + ["Example:", indent(mark_line(text, self.offset, len(self.tag)), prefix=" ")] + ) + return "\n".join(message) diff --git a/aiogram/utils/markup.py b/aiogram/utils/keyboard.py similarity index 83% rename from aiogram/utils/markup.py rename to aiogram/utils/keyboard.py index 32169104..19409c94 100644 --- a/aiogram/utils/markup.py +++ b/aiogram/utils/keyboard.py @@ -1,8 +1,16 @@ +from __future__ import annotations + from itertools import chain from itertools import cycle as repeat_all -from typing import Any, Generator, Generic, Iterable, List, Optional, Type, TypeVar +from typing import Any, Generator, Generic, Iterable, List, Optional, Type, TypeVar, Union -from aiogram.types import InlineKeyboardButton, KeyboardButton +from aiogram.dispatcher.filters.callback_data import CallbackData +from aiogram.types import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + KeyboardButton, + ReplyKeyboardMarkup, +) ButtonType = TypeVar("ButtonType", InlineKeyboardButton, KeyboardButton) T = TypeVar("T") @@ -11,7 +19,7 @@ MIN_WIDTH = 1 MAX_BUTTONS = 100 -class MarkupConstructor(Generic[ButtonType]): +class KeyboardConstructor(Generic[ButtonType]): def __init__( self, button_type: Type[ButtonType], markup: Optional[List[List[ButtonType]]] = None ) -> None: @@ -106,7 +114,7 @@ class MarkupConstructor(Generic[ButtonType]): raise ValueError(f"Row size {size} are not allowed") return size - def copy(self: "MarkupConstructor[ButtonType]") -> "MarkupConstructor[ButtonType]": + def copy(self: "KeyboardConstructor[ButtonType]") -> "KeyboardConstructor[ButtonType]": """ Make full copy of current constructor with markup @@ -120,7 +128,7 @@ class MarkupConstructor(Generic[ButtonType]): .. code-block:: python - >>> constructor = MarkupConstructor(button_type=InlineKeyboardButton) + >>> constructor = KeyboardConstructor(button_type=InlineKeyboardButton) >>> ... # Add buttons to constructor >>> markup = InlineKeyboardMarkup(inline_keyboard=constructor.export()) @@ -128,7 +136,7 @@ class MarkupConstructor(Generic[ButtonType]): """ return self._markup.copy() - def add(self, *buttons: ButtonType) -> "MarkupConstructor[ButtonType]": + def add(self, *buttons: ButtonType) -> "KeyboardConstructor[ButtonType]": """ Add one or many buttons to markup. @@ -153,7 +161,9 @@ class MarkupConstructor(Generic[ButtonType]): self._markup = markup return self - def row(self, *buttons: ButtonType, width: int = MAX_WIDTH) -> "MarkupConstructor[ButtonType]": + def row( + self, *buttons: ButtonType, width: int = MAX_WIDTH + ) -> "KeyboardConstructor[ButtonType]": """ Add row to markup @@ -170,7 +180,7 @@ class MarkupConstructor(Generic[ButtonType]): ) return self - def adjust(self, *sizes: int, repeat: bool = False) -> "MarkupConstructor[ButtonType]": + def adjust(self, *sizes: int, repeat: bool = False) -> "KeyboardConstructor[ButtonType]": """ Adjust previously added buttons to specific row sizes. @@ -202,10 +212,17 @@ class MarkupConstructor(Generic[ButtonType]): self._markup = markup return self - def button(self, **kwargs: Any) -> "MarkupConstructor[ButtonType]": + def button(self, **kwargs: Any) -> "KeyboardConstructor[ButtonType]": + if isinstance(callback_data := kwargs.get("callback_data", None), CallbackData): + kwargs["callback_data"] = callback_data.pack() button = self._button_type(**kwargs) return self.add(button) + def as_markup(self, **kwargs: Any) -> Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]: + if self._button_type is ReplyKeyboardMarkup: + return ReplyKeyboardMarkup(keyboard=self.export(), **kwargs) + return InlineKeyboardMarkup(inline_keyboard=self.export()) + def repeat_last(items: Iterable[T]) -> Generator[T, None, None]: items_iter = iter(items) diff --git a/examples/echo_bot.py b/examples/echo_bot.py index 2ab06c78..f5689c3d 100644 --- a/examples/echo_bot.py +++ b/examples/echo_bot.py @@ -1,39 +1,46 @@ +import logging from typing import Any from aiogram import Bot, Dispatcher, types -from aiogram.dispatcher.handler import MessageHandler +from aiogram.types import Message TOKEN = "42:TOKEN" dp = Dispatcher() +logger = logging.getLogger(__name__) -@dp.message(commands=["start"]) -class MyHandler(MessageHandler): + +@dp.message(commands={"start"}) +async def command_start_handler(message: Message) -> None: """ This handler receive messages with `/start` command - - Usage of Class-based handlers """ - - async def handle(self) -> Any: - await self.event.answer(f"Hello, {self.from_user.full_name}!") + # Most of event objects has an aliases for API methods to be called in event context + # For example if you want to answer to incoming message you can use `message.answer(...)` alias + # and the target chat will be passed to :ref:`aiogram.methods.send_message.SendMessage` method automatically + # or call API method directly via Bot instance: `bot.send_message(chat_id=message.chat.id, ...)` + await message.answer(f"Hello, {message.from_user.full_name}!") -@dp.message(content_types=[types.ContentType.ANY]) -async def echo_handler(message: types.Message, bot: Bot) -> Any: +@dp.message() +async def echo_handler(message: types.Message) -> Any: """ Handler will forward received message back to the sender - Usage of Function-based handlers + By default message handler will handle all message types (like text, photo, sticker and etc.) """ - - await bot.forward_message( - from_chat_id=message.chat.id, chat_id=message.chat.id, message_id=message.message_id - ) + try: + # Send copy of the received message + await message.send_copy(chat_id=message.chat.id) + except TypeError: + # But not all the types is supported to be copied so need to handle it + await message.answer("Nice try!") def main() -> None: + # Initialize Bot instance with an default parse mode which will be passed to all API calls bot = Bot(TOKEN, parse_mode="HTML") + # And the run events dispatching dp.run_polling(bot) diff --git a/tests/mocked_bot.py b/tests/mocked_bot.py index aee31398..03e48e4f 100644 --- a/tests/mocked_bot.py +++ b/tests/mocked_bot.py @@ -4,17 +4,17 @@ from typing import TYPE_CHECKING, AsyncGenerator, Deque, Optional, Type from aiogram import Bot from aiogram.client.session.base import BaseSession from aiogram.methods import TelegramMethod -from aiogram.methods.base import Request, Response, T -from aiogram.types import UNSET +from aiogram.methods.base import Request, Response, TelegramType +from aiogram.types import UNSET, ResponseParameters class MockedSession(BaseSession): def __init__(self): super(MockedSession, self).__init__() - self.responses: Deque[Response[T]] = deque() + self.responses: Deque[Response[TelegramType]] = deque() self.requests: Deque[Request] = deque() - def add_result(self, response: Response[T]) -> Response[T]: + def add_result(self, response: Response[TelegramType]) -> Response[TelegramType]: self.responses.append(response) return response @@ -25,11 +25,13 @@ class MockedSession(BaseSession): pass async def make_request( - self, bot: Bot, method: TelegramMethod[T], timeout: Optional[int] = UNSET - ) -> T: + self, bot: Bot, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET + ) -> TelegramType: self.requests.append(method.build_request(bot)) - response: Response[T] = self.responses.pop() - self.raise_for_status(response) + response: Response[TelegramType] = self.responses.pop() + self.check_response( + method=method, status_code=response.error_code, content=response.json() + ) return response.result # type: ignore async def stream_content( @@ -47,21 +49,23 @@ class MockedBot(Bot): def add_result_for( self, - method: Type[TelegramMethod[T]], + method: Type[TelegramMethod[TelegramType]], ok: bool, - result: T = None, + result: TelegramType = None, description: Optional[str] = None, error_code: Optional[int] = None, migrate_to_chat_id: Optional[int] = None, retry_after: Optional[int] = None, - ) -> Response[T]: + ) -> Response[TelegramType]: response = Response[method.__returning__]( # type: ignore ok=ok, result=result, description=description, error_code=error_code, - migrate_to_chat_id=migrate_to_chat_id, - retry_after=retry_after, + parameters=ResponseParameters( + migrate_to_chat_id=migrate_to_chat_id, + retry_after=retry_after, + ), ) self.session.add_result(response) return response diff --git a/tests/test_api/test_client/test_session/test_aiohttp_session.py b/tests/test_api/test_client/test_session/test_aiohttp_session.py index 42ee7e3e..9624642e 100644 --- a/tests/test_api/test_client/test_session/test_aiohttp_session.py +++ b/tests/test_api/test_client/test_session/test_aiohttp_session.py @@ -172,14 +172,10 @@ class TestAiohttpSession: return Request(method="method", data={}) call = TestMethod() - with patch( - "aiogram.client.session.base.BaseSession.raise_for_status" - ) as patched_raise_for_status: - result = await session.make_request(bot, call) - assert isinstance(result, int) - assert result == 42 - assert patched_raise_for_status.called_once() + result = await session.make_request(bot, call) + assert isinstance(result, int) + assert result == 42 @pytest.mark.asyncio async def test_stream_content(self, aresponses: ResponsesMockServer): diff --git a/tests/test_api/test_client/test_session/test_base_session.py b/tests/test_api/test_client/test_session/test_base_session.py index 3ed8a126..448f663e 100644 --- a/tests/test_api/test_client/test_session/test_base_session.py +++ b/tests/test_api/test_client/test_session/test_base_session.py @@ -4,9 +4,9 @@ from typing import AsyncContextManager, AsyncGenerator, Optional import pytest -from aiogram.client.session.base import BaseSession, T +from aiogram.client.session.base import BaseSession, TelegramType from aiogram.client.telegram import PRODUCTION, TelegramAPIServer -from aiogram.methods import GetMe, Response, TelegramMethod +from aiogram.methods import DeleteMessage, GetMe, Response, TelegramMethod from aiogram.types import UNSET try: @@ -20,7 +20,7 @@ class CustomSession(BaseSession): async def close(self): pass - async def make_request(self, token: str, method: TelegramMethod[T], timeout: Optional[int] = UNSET) -> None: # type: ignore + async def make_request(self, token: str, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET) -> None: # type: ignore assert isinstance(token, str) assert isinstance(method, TelegramMethod) @@ -135,12 +135,20 @@ class TestBaseSession: assert session.clean_json(42) == 42 - def test_raise_for_status(self): + def check_response(self): session = CustomSession() - session.raise_for_status(Response[bool](ok=True, result=True)) + session.check_response( + method=DeleteMessage(chat_id=42, message_id=42), + status_code=200, + content='{"ok":true,"result":true}', + ) with pytest.raises(Exception): - session.raise_for_status(Response[bool](ok=False, description="Error", error_code=400)) + session.check_response( + method=DeleteMessage(chat_id=42, message_id=42), + status_code=400, + content='{"ok":false,"description":"test"}', + ) @pytest.mark.asyncio async def test_make_request(self): diff --git a/tests/test_api/test_types/test_message.py b/tests/test_api/test_types/test_message.py index 5e56ed8a..bea20f08 100644 --- a/tests/test_api/test_types/test_message.py +++ b/tests/test_api/test_types/test_message.py @@ -5,6 +5,9 @@ import pytest from aiogram.methods import ( CopyMessage, + DeleteMessage, + EditMessageCaption, + EditMessageText, SendAnimation, SendAudio, SendContact, @@ -549,3 +552,28 @@ class TestMessage: if method: assert isinstance(method, expected_method) # TODO: Check additional fields + + def test_edit_text(self): + message = Message( + message_id=42, chat=Chat(id=42, type="private"), date=datetime.datetime.now() + ) + method = message.edit_text(text="test") + assert isinstance(method, EditMessageText) + assert method.chat_id == message.chat.id + + def test_edit_caption(self): + message = Message( + message_id=42, chat=Chat(id=42, type="private"), date=datetime.datetime.now() + ) + method = message.edit_caption(caption="test") + assert isinstance(method, EditMessageCaption) + assert method.chat_id == message.chat.id + + def test_delete(self): + message = Message( + message_id=42, chat=Chat(id=42, type="private"), date=datetime.datetime.now() + ) + method = message.delete() + assert isinstance(method, DeleteMessage) + assert method.chat_id == message.chat.id + assert method.message_id == message.message_id From 6253b25158a31406befd1a925cc2c936aba4a74f Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 25 May 2021 01:00:36 +0300 Subject: [PATCH 02/38] Remove compatibility with Python 3.7 --- .github/workflows/tests.yml | 1 - codecov.yaml | 2 +- poetry.lock | 329 +++++++++++++++++------------------- pyproject.toml | 3 +- 4 files changed, 161 insertions(+), 174 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f5057032..f35271f0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,6 @@ jobs: - macos-latest - windows-latest python-version: - - 3.7 - 3.8 - 3.9 diff --git a/codecov.yaml b/codecov.yaml index cdf02d42..082f0672 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -21,4 +21,4 @@ comment: require_changes: no branches: - dev-3.x - after_n_builds: 8 + after_n_builds: 6 diff --git a/poetry.lock b/poetry.lock index 86a831d1..7c3acd24 100644 --- a/poetry.lock +++ b/poetry.lock @@ -108,17 +108,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "20.3.0" +version = "21.2.0" description = "Classes Without Boilerplate" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] -docs = ["furo", "sphinx", "zope.interface"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] [[package]] name = "babel" @@ -156,7 +156,7 @@ lxml = ["lxml"] [[package]] name = "black" -version = "21.4b2" +version = "21.5b1" description = "The uncompromising code formatter." category = "dev" optional = false @@ -169,17 +169,15 @@ mypy-extensions = ">=0.4.3" pathspec = ">=0.8.1,<1" regex = ">=2020.1.8" toml = ">=0.10.1" -typed-ast = {version = ">=1.4.2", markers = "python_version < \"3.8\""} -typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] +d = ["aiohttp (>=3.6.0)", "aiohttp-cors"] python2 = ["typed-ast (>=1.4.2)"] [[package]] name = "cfgv" -version = "3.2.0" +version = "3.3.0" description = "Validate configuration and produce human readable error messages." category = "dev" optional = false @@ -195,11 +193,14 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "click" -version = "7.1.2" +version = "8.0.1" description = "Composable command line interface toolkit" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} [[package]] name = "colorama" @@ -217,12 +218,15 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +[package.dependencies] +toml = {version = "*", optional = true, markers = "extra == \"toml\""} + [package.extras] toml = ["toml"] [[package]] name = "decorator" -version = "5.0.7" +version = "5.0.9" description = "Decorators for Humans" category = "dev" optional = false @@ -254,14 +258,13 @@ python-versions = "*" [[package]] name = "flake8" -version = "3.9.1" +version = "3.9.2" description = "the modular source code checker: pep8 pyflakes and co" category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} mccabe = ">=0.6.0,<0.7.0" pycodestyle = ">=2.7.0,<2.8.0" pyflakes = ">=2.3.0,<2.4.0" @@ -298,7 +301,7 @@ test = ["pytest", "pytest-cov", "pytest-xdist"] [[package]] name = "identify" -version = "2.2.3" +version = "2.2.5" description = "File identification library for Python" category = "dev" optional = false @@ -325,14 +328,13 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.0.0" +version = "4.0.1" description = "Read metadata from Python packages" category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] @@ -349,7 +351,7 @@ python-versions = "*" [[package]] name = "ipython" -version = "7.22.0" +version = "7.23.1" description = "IPython: Productive Interactive Computing" category = "dev" optional = false @@ -361,6 +363,7 @@ backcall = "*" colorama = {version = "*", markers = "sys_platform == \"win32\""} decorator = "*" jedi = ">=0.16" +matplotlib-inline = "*" pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} pickleshare = "*" prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0" @@ -416,17 +419,17 @@ testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<6.0.0)"] [[package]] name = "jinja2" -version = "2.11.3" +version = "3.0.1" description = "A very fast and expressive template engine." category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" [package.dependencies] -MarkupSafe = ">=0.23" +MarkupSafe = ">=2.0" [package.extras] -i18n = ["Babel (>=0.8)"] +i18n = ["Babel (>=2.7)"] [[package]] name = "livereload" @@ -456,9 +459,6 @@ category = "dev" optional = false python-versions = ">=3.6" -[package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} - [package.extras] testing = ["coverage", "pyyaml"] @@ -475,11 +475,22 @@ markdown = "*" [[package]] name = "markupsafe" -version = "1.1.1" +version = "2.0.1" description = "Safely add untrusted strings to HTML/XML markup." category = "main" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +python-versions = ">=3.6" + +[[package]] +name = "matplotlib-inline" +version = "0.1.2" +description = "Inline Matplotlib backend for Jupyter" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +traitlets = "*" [[package]] name = "mccabe" @@ -587,15 +598,12 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} - [package.extras] dev = ["pre-commit", "tox"] [[package]] name = "pre-commit" -version = "2.12.1" +version = "2.13.0" description = "A framework for managing and maintaining multi-language pre-commit hooks." category = "dev" optional = false @@ -604,7 +612,6 @@ python-versions = ">=3.6.1" [package.dependencies] cfgv = ">=2.0.0" identify = ">=1.0.0" -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} nodeenv = ">=0.11.1" pyyaml = ">=5.1" toml = "*" @@ -647,7 +654,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pydantic" -version = "1.8.1" +version = "1.8.2" description = "Data validation and settings management using python 3.6 type hinting" category = "main" optional = false @@ -670,7 +677,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.8.1" +version = "2.9.0" description = "Pygments is a syntax highlighting package written in Python." category = "main" optional = false @@ -678,7 +685,7 @@ python-versions = ">=3.5" [[package]] name = "pymdown-extensions" -version = "8.1.1" +version = "8.2" description = "Extension pack for Python Markdown." category = "dev" optional = false @@ -697,7 +704,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "pytest" -version = "6.2.3" +version = "6.2.4" description = "pytest: simple powerful testing with Python" category = "dev" optional = false @@ -707,7 +714,6 @@ python-versions = ">=3.6" atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<1.0.0a1" @@ -733,14 +739,14 @@ testing = ["coverage", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "2.11.1" +version = "2.12.0" description = "Pytest plugin for measuring coverage." category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.dependencies] -coverage = ">=5.2.1" +coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" [package.extras] @@ -771,7 +777,7 @@ pytest = ">=2.9.0" [[package]] name = "pytest-mock" -version = "3.6.0" +version = "3.6.1" description = "Thin-wrapper around the mock package for easier use with pytest" category = "dev" optional = false @@ -795,7 +801,6 @@ python-versions = ">=3.5" attrs = ">=19.0" filelock = ">=3.0" mypy = [ - {version = ">=0.500", markers = "python_version < \"3.8\""}, {version = ">=0.700", markers = "python_version >= \"3.8\" and python_version < \"3.9\""}, {version = ">=0.780", markers = "python_version >= \"3.9\""}, ] @@ -855,7 +860,7 @@ socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] [[package]] name = "six" -version = "1.15.0" +version = "1.16.0" description = "Python 2 and 3 compatibility utilities" category = "main" optional = false @@ -1008,11 +1013,11 @@ test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" -version = "1.0.3" +version = "2.0.0" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.extras] lint = ["flake8", "mypy", "docutils-stubs"] @@ -1043,7 +1048,7 @@ test = ["pytest"] [[package]] name = "sphinxcontrib-serializinghtml" -version = "1.1.4" +version = "1.1.5" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." category = "main" optional = false @@ -1093,7 +1098,7 @@ python-versions = "*" [[package]] name = "typing-extensions" -version = "3.7.4.3" +version = "3.10.0.0" description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" optional = false @@ -1114,7 +1119,7 @@ test = ["aiohttp", "flake8 (>=3.8.4,<3.9.0)", "psutil", "pycodestyle (>=2.6.0,<2 [[package]] name = "virtualenv" -version = "20.4.3" +version = "20.4.7" description = "Virtual Python Environment builder" category = "dev" optional = false @@ -1124,7 +1129,6 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" appdirs = ">=1.4.3,<2" distlib = ">=0.3.1,<1" filelock = ">=3.0.0,<4" -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} six = ">=1.9.0,<2" [package.extras] @@ -1150,7 +1154,6 @@ python-versions = ">=3.6" [package.dependencies] idna = ">=2.0" multidict = ">=4.0" -typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""} [[package]] name = "zipp" @@ -1171,8 +1174,8 @@ proxy = ["aiohttp-socks"] [metadata] lock-version = "1.1" -python-versions = "^3.7" -content-hash = "9a787135a6d8ed2a395c07246db424e9961281cf7e0dc00fc08507da3081143e" +python-versions = "^3.8" +content-hash = "2fcd44a8937b3ea48196c8eba8ceb0533281af34c884103bcc5b4f5f16b817d5" [metadata.files] aiofiles = [ @@ -1254,8 +1257,8 @@ atomicwrites = [ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, - {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, + {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, + {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] babel = [ {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, @@ -1271,20 +1274,20 @@ beautifulsoup4 = [ {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, ] black = [ - {file = "black-21.4b2-py3-none-any.whl", hash = "sha256:bff7067d8bc25eb21dcfdbc8c72f2baafd9ec6de4663241a52fb904b304d391f"}, - {file = "black-21.4b2.tar.gz", hash = "sha256:fc9bcf3b482b05c1f35f6a882c079dc01b9c7795827532f4cc43c0ec88067bbc"}, + {file = "black-21.5b1-py3-none-any.whl", hash = "sha256:8a60071a0043876a4ae96e6c69bd3a127dad2c1ca7c8083573eb82f92705d008"}, + {file = "black-21.5b1.tar.gz", hash = "sha256:23695358dbcb3deafe7f0a3ad89feee5999a46be5fec21f4f1d108be0bcdb3b1"}, ] cfgv = [ - {file = "cfgv-3.2.0-py2.py3-none-any.whl", hash = "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d"}, - {file = "cfgv-3.2.0.tar.gz", hash = "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1"}, + {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, + {file = "cfgv-3.3.0.tar.gz", hash = "sha256:9e600479b3b99e8af981ecdfc80a0296104ee610cab48a5ae4ffd0b668650eb1"}, ] chardet = [ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"}, {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"}, ] click = [ - {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, - {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, + {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"}, + {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, @@ -1345,8 +1348,8 @@ coverage = [ {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, ] decorator = [ - {file = "decorator-5.0.7-py3-none-any.whl", hash = "sha256:945d84890bb20cc4a2f4a31fc4311c0c473af65ea318617f13a7257c9a58bc98"}, - {file = "decorator-5.0.7.tar.gz", hash = "sha256:6f201a6c4dac3d187352661f508b9364ec8091217442c9478f1f83c003a0f060"}, + {file = "decorator-5.0.9-py3-none-any.whl", hash = "sha256:6e5c199c16f7a9f0e3a61a4a54b3d27e7dad0dbdde92b944426cb20914376323"}, + {file = "decorator-5.0.9.tar.gz", hash = "sha256:72ecfba4320a893c53f9706bebb2d55c270c1e51a28789361aa93e4a21319ed5"}, ] distlib = [ {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, @@ -1361,8 +1364,8 @@ filelock = [ {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, ] flake8 = [ - {file = "flake8-3.9.1-py2.py3-none-any.whl", hash = "sha256:3b9f848952dddccf635be78098ca75010f073bfe14d2c6bda867154bea728d2a"}, - {file = "flake8-3.9.1.tar.gz", hash = "sha256:1aa8990be1e689d96c745c5682b687ea49f2e05a443aff1f8251092b0014e378"}, + {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, + {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, ] flake8-html = [ {file = "flake8-html-0.4.1.tar.gz", hash = "sha256:2fb436cbfe1e109275bc8fb7fdd0cb00e67b3b48cfeb397309b6b2c61eeb4cb4"}, @@ -1373,8 +1376,8 @@ furo = [ {file = "furo-2020.12.30b24.tar.gz", hash = "sha256:30171899c9c06d692a778e6daf6cb2e5cbb05efc6006e1692e5e776007dc8a8c"}, ] identify = [ - {file = "identify-2.2.3-py2.py3-none-any.whl", hash = "sha256:398cb92a7599da0b433c65301a1b62b9b1f4bb8248719b84736af6c0b22289d6"}, - {file = "identify-2.2.3.tar.gz", hash = "sha256:4537474817e0bbb8cea3e5b7504b7de6d44e3f169a90846cbc6adb0fc8294502"}, + {file = "identify-2.2.5-py2.py3-none-any.whl", hash = "sha256:9c3ab58543c03bd794a1735e4552ef6dec49ec32053278130d525f0982447d47"}, + {file = "identify-2.2.5.tar.gz", hash = "sha256:bc1705694253763a3160b943316867792ec00ba7a0ee40b46e20aebaf4e0c46a"}, ] idna = [ {file = "idna-3.1-py3-none-any.whl", hash = "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16"}, @@ -1385,16 +1388,16 @@ imagesize = [ {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.0.0-py3-none-any.whl", hash = "sha256:19192b88d959336bfa6bdaaaef99aeafec179eca19c47c804e555703ee5f07ef"}, - {file = "importlib_metadata-4.0.0.tar.gz", hash = "sha256:2e881981c9748d7282b374b68e759c87745c25427b67ecf0cc67fb6637a1bff9"}, + {file = "importlib_metadata-4.0.1-py3-none-any.whl", hash = "sha256:d7eb1dea6d6a6086f8be21784cc9e3bcfa55872b52309bc5fad53a8ea444465d"}, + {file = "importlib_metadata-4.0.1.tar.gz", hash = "sha256:8c501196e49fb9df5df43833bdb1e4328f64847763ec8a50703148b73784d581"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] ipython = [ - {file = "ipython-7.22.0-py3-none-any.whl", hash = "sha256:c0ce02dfaa5f854809ab7413c601c4543846d9da81010258ecdab299b542d199"}, - {file = "ipython-7.22.0.tar.gz", hash = "sha256:9c900332d4c5a6de534b4befeeb7de44ad0cc42e8327fa41b7685abde58cec74"}, + {file = "ipython-7.23.1-py3-none-any.whl", hash = "sha256:f78c6a3972dde1cc9e4041cbf4de583546314ba52d3c97208e5b6b2221a9cb7d"}, + {file = "ipython-7.23.1.tar.gz", hash = "sha256:714810a5c74f512b69d5f3b944c86e592cee0a5fb9c728e582f074610f6cf038"}, ] ipython-genutils = [ {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, @@ -1409,8 +1412,8 @@ jedi = [ {file = "jedi-0.18.0.tar.gz", hash = "sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707"}, ] jinja2 = [ - {file = "Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419"}, - {file = "Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"}, + {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, + {file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"}, ] livereload = [ {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, @@ -1427,58 +1430,44 @@ markdown-include = [ {file = "markdown-include-0.6.0.tar.gz", hash = "sha256:6f5d680e36f7780c7f0f61dca53ca581bd50d1b56137ddcd6353efafa0c3e4a2"}, ] markupsafe = [ - {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, - {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, - {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, - {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, - {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d53bc011414228441014aa71dbec320c66468c1030aae3a6e29778a3382d96e5"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:3b8a6499709d29c2e2399569d96719a1b21dcd94410a586a18526b143ec8470f"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:84dee80c15f1b560d55bcfe6d47b27d070b4681c699c572af2e3c7cc90a3b8e0"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:b1dba4527182c95a0db8b6060cc98ac49b9e2f5e64320e2b56e47cb2831978c7"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, - {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:bf5aa3cbcfdf57fa2ee9cd1822c862ef23037f5c832ad09cfea57fa846dec193"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:6fffc775d90dcc9aed1b89219549b329a9250d918fd0b8fa8d93d154918422e1"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:a6a744282b7718a2a62d2ed9d993cad6f5f585605ad352c11de459f4108df0a1"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:195d7d2c4fbb0ee8139a6cf67194f3973a6b3042d742ebe0a9ed36d8b6f0c07f"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, - {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:acf08ac40292838b3cbbb06cfe9b2cb9ec78fce8baca31ddb87aaac2e2dc3bc2"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d9be0ba6c527163cbed5e0857c451fcd092ce83947944d6c14bc95441203f032"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:caabedc8323f1e93231b52fc32bdcde6db817623d33e100708d9a68e1f53b26b"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, - {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d73a845f227b0bfe8a7455ee623525ee656a9e2e749e4742706d80a6065d5e2c"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:98bae9582248d6cf62321dcb52aaf5d9adf0bad3b40582925ef7c7f0ed85fceb"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:2beec1e0de6924ea551859edb9e7679da6e4870d32cb766240ce17e0a0ba2014"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:7fed13866cf14bba33e7176717346713881f56d9d2bcebab207f7a036f41b850"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:6f1e273a344928347c1290119b493a1f0303c52f5a5eae5f16d74f48c15d4a85"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:feb7b34d6325451ef96bc0e36e1a6c0c1c64bc1fbec4b854f4529e51887b1621"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-win32.whl", hash = "sha256:22c178a091fc6630d0d045bdb5992d2dfe14e3259760e713c490da5323866c39"}, - {file = "MarkupSafe-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7d644ddb4dbd407d31ffb699f1d140bc35478da613b441c582aeb7c43838dd8"}, - {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, + {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, + {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, + {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, + {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, + {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, +] +matplotlib-inline = [ + {file = "matplotlib-inline-0.1.2.tar.gz", hash = "sha256:f41d5ff73c9f5385775d5c0bc13b424535c8402fe70ea8210f93e11f3683993e"}, + {file = "matplotlib_inline-0.1.2-py3-none-any.whl", hash = "sha256:5cf1176f554abb4fa98cb362aa2b55c500147e4bdbb07e3fda359143e1da0811"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, @@ -1580,8 +1569,8 @@ pluggy = [ {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] pre-commit = [ - {file = "pre_commit-2.12.1-py2.py3-none-any.whl", hash = "sha256:70c5ec1f30406250b706eda35e868b87e3e4ba099af8787e3e8b4b01e84f4712"}, - {file = "pre_commit-2.12.1.tar.gz", hash = "sha256:900d3c7e1bf4cf0374bb2893c24c23304952181405b4d88c9c40b72bda1bb8a9"}, + {file = "pre_commit-2.13.0-py2.py3-none-any.whl", hash = "sha256:b679d0fddd5b9d6d98783ae5f10fd0c4c59954f375b70a58cbe1ce9bcf9809a4"}, + {file = "pre_commit-2.13.0.tar.gz", hash = "sha256:764972c60693dc668ba8e86eb29654ec3144501310f7198742a767bec385a378"}, ] prompt-toolkit = [ {file = "prompt_toolkit-3.0.18-py3-none-any.whl", hash = "sha256:bf00f22079f5fadc949f42ae8ff7f05702826a97059ffcc6281036ad40ac6f04"}, @@ -1600,56 +1589,56 @@ pycodestyle = [ {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, ] pydantic = [ - {file = "pydantic-1.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0c40162796fc8d0aa744875b60e4dc36834db9f2a25dbf9ba9664b1915a23850"}, - {file = "pydantic-1.8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:fff29fe54ec419338c522b908154a2efabeee4f483e48990f87e189661f31ce3"}, - {file = "pydantic-1.8.1-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:fbfb608febde1afd4743c6822c19060a8dbdd3eb30f98e36061ba4973308059e"}, - {file = "pydantic-1.8.1-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:eb8ccf12295113ce0de38f80b25f736d62f0a8d87c6b88aca645f168f9c78771"}, - {file = "pydantic-1.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:20d42f1be7c7acc352b3d09b0cf505a9fab9deb93125061b376fbe1f06a5459f"}, - {file = "pydantic-1.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dde4ca368e82791de97c2ec019681ffb437728090c0ff0c3852708cf923e0c7d"}, - {file = "pydantic-1.8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:3bbd023c981cbe26e6e21c8d2ce78485f85c2e77f7bab5ec15b7d2a1f491918f"}, - {file = "pydantic-1.8.1-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:830ef1a148012b640186bf4d9789a206c56071ff38f2460a32ae67ca21880eb8"}, - {file = "pydantic-1.8.1-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:fb77f7a7e111db1832ae3f8f44203691e15b1fa7e5a1cb9691d4e2659aee41c4"}, - {file = "pydantic-1.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:3bcb9d7e1f9849a6bdbd027aabb3a06414abd6068cb3b21c49427956cce5038a"}, - {file = "pydantic-1.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2287ebff0018eec3cc69b1d09d4b7cebf277726fa1bd96b45806283c1d808683"}, - {file = "pydantic-1.8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:4bbc47cf7925c86a345d03b07086696ed916c7663cb76aa409edaa54546e53e2"}, - {file = "pydantic-1.8.1-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:6388ef4ef1435364c8cc9a8192238aed030595e873d8462447ccef2e17387125"}, - {file = "pydantic-1.8.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:dd4888b300769ecec194ca8f2699415f5f7760365ddbe243d4fd6581485fa5f0"}, - {file = "pydantic-1.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:8fbb677e4e89c8ab3d450df7b1d9caed23f254072e8597c33279460eeae59b99"}, - {file = "pydantic-1.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2f2736d9a996b976cfdfe52455ad27462308c9d3d0ae21a2aa8b4cd1a78f47b9"}, - {file = "pydantic-1.8.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:3114d74329873af0a0e8004627f5389f3bb27f956b965ddd3e355fe984a1789c"}, - {file = "pydantic-1.8.1-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:258576f2d997ee4573469633592e8b99aa13bda182fcc28e875f866016c8e07e"}, - {file = "pydantic-1.8.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:c17a0b35c854049e67c68b48d55e026c84f35593c66d69b278b8b49e2484346f"}, - {file = "pydantic-1.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:e8bc082afef97c5fd3903d05c6f7bb3a6af9fc18631b4cc9fedeb4720efb0c58"}, - {file = "pydantic-1.8.1-py3-none-any.whl", hash = "sha256:e3f8790c47ac42549dc8b045a67b0ca371c7f66e73040d0197ce6172b385e520"}, - {file = "pydantic-1.8.1.tar.gz", hash = "sha256:26cf3cb2e68ec6c0cfcb6293e69fb3450c5fd1ace87f46b64f678b0d29eac4c3"}, + {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, + {file = "pydantic-1.8.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a7c6002203fe2c5a1b5cbb141bb85060cbff88c2d78eccbc72d97eb7022c43e4"}, + {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:589eb6cd6361e8ac341db97602eb7f354551482368a37f4fd086c0733548308e"}, + {file = "pydantic-1.8.2-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:10e5622224245941efc193ad1d159887872776df7a8fd592ed746aa25d071840"}, + {file = "pydantic-1.8.2-cp36-cp36m-win_amd64.whl", hash = "sha256:99a9fc39470010c45c161a1dc584997f1feb13f689ecf645f59bb4ba623e586b"}, + {file = "pydantic-1.8.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a83db7205f60c6a86f2c44a61791d993dff4b73135df1973ecd9eed5ea0bda20"}, + {file = "pydantic-1.8.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:41b542c0b3c42dc17da70554bc6f38cbc30d7066d2c2815a94499b5684582ecb"}, + {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:ea5cb40a3b23b3265f6325727ddfc45141b08ed665458be8c6285e7b85bd73a1"}, + {file = "pydantic-1.8.2-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:18b5ea242dd3e62dbf89b2b0ec9ba6c7b5abaf6af85b95a97b00279f65845a23"}, + {file = "pydantic-1.8.2-cp37-cp37m-win_amd64.whl", hash = "sha256:234a6c19f1c14e25e362cb05c68afb7f183eb931dd3cd4605eafff055ebbf287"}, + {file = "pydantic-1.8.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:021ea0e4133e8c824775a0cfe098677acf6fa5a3cbf9206a376eed3fc09302cd"}, + {file = "pydantic-1.8.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e710876437bc07bd414ff453ac8ec63d219e7690128d925c6e82889d674bb505"}, + {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:ac8eed4ca3bd3aadc58a13c2aa93cd8a884bcf21cb019f8cfecaae3b6ce3746e"}, + {file = "pydantic-1.8.2-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:4a03cbbe743e9c7247ceae6f0d8898f7a64bb65800a45cbdc52d65e370570820"}, + {file = "pydantic-1.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:8621559dcf5afacf0069ed194278f35c255dc1a1385c28b32dd6c110fd6531b3"}, + {file = "pydantic-1.8.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8b223557f9510cf0bfd8b01316bf6dd281cf41826607eada99662f5e4963f316"}, + {file = "pydantic-1.8.2-cp39-cp39-manylinux1_i686.whl", hash = "sha256:244ad78eeb388a43b0c927e74d3af78008e944074b7d0f4f696ddd5b2af43c62"}, + {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:05ef5246a7ffd2ce12a619cbb29f3307b7c4509307b1b49f456657b43529dc6f"}, + {file = "pydantic-1.8.2-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:54cd5121383f4a461ff7644c7ca20c0419d58052db70d8791eacbbe31528916b"}, + {file = "pydantic-1.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:4be75bebf676a5f0f87937c6ddb061fa39cbea067240d98e298508c1bda6f3f3"}, + {file = "pydantic-1.8.2-py3-none-any.whl", hash = "sha256:fec866a0b59f372b7e776f2d7308511784dace622e0992a0b59ea3ccee0ae833"}, + {file = "pydantic-1.8.2.tar.gz", hash = "sha256:26464e57ccaafe72b7ad156fdaa4e9b9ef051f69e175dbbb463283000c05ab7b"}, ] pyflakes = [ {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pygments = [ - {file = "Pygments-2.8.1-py3-none-any.whl", hash = "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"}, - {file = "Pygments-2.8.1.tar.gz", hash = "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94"}, + {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, + {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, ] pymdown-extensions = [ - {file = "pymdown-extensions-8.1.1.tar.gz", hash = "sha256:632371fa3bf1b21a0e3f4063010da59b41db049f261f4c0b0872069a9b6d1735"}, - {file = "pymdown_extensions-8.1.1-py3-none-any.whl", hash = "sha256:478b2c04513fbb2db61688d5f6e9030a92fb9be14f1f383535c43f7be9dff95b"}, + {file = "pymdown-extensions-8.2.tar.gz", hash = "sha256:b6daa94aad9e1310f9c64c8b1f01e4ce82937ab7eb53bfc92876a97aca02a6f4"}, + {file = "pymdown_extensions-8.2-py3-none-any.whl", hash = "sha256:141452d8ed61165518f2c923454bf054866b85cf466feedb0eb68f04acdc2560"}, ] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] pytest = [ - {file = "pytest-6.2.3-py3-none-any.whl", hash = "sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"}, - {file = "pytest-6.2.3.tar.gz", hash = "sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634"}, + {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, + {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, ] pytest-asyncio = [ {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, ] pytest-cov = [ - {file = "pytest-cov-2.11.1.tar.gz", hash = "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7"}, - {file = "pytest_cov-2.11.1-py2.py3-none-any.whl", hash = "sha256:bdb9fdb0b85a7cc825269a4c56b48ccaa5c7e365054b6038772c32ddcdc969da"}, + {file = "pytest-cov-2.12.0.tar.gz", hash = "sha256:8535764137fecce504a49c2b742288e3d34bc09eed298ad65963616cc98fd45e"}, + {file = "pytest_cov-2.12.0-py2.py3-none-any.whl", hash = "sha256:95d4933dcbbacfa377bb60b29801daa30d90c33981ab2a79e9ab4452c165066e"}, ] pytest-html = [ {file = "pytest-html-3.1.1.tar.gz", hash = "sha256:3ee1cf319c913d19fe53aeb0bc400e7b0bc2dbeb477553733db1dad12eb75ee3"}, @@ -1660,8 +1649,8 @@ pytest-metadata = [ {file = "pytest_metadata-1.11.0-py2.py3-none-any.whl", hash = "sha256:576055b8336dd4a9006dd2a47615f76f2f8c30ab12b1b1c039d99e834583523f"}, ] pytest-mock = [ - {file = "pytest-mock-3.6.0.tar.gz", hash = "sha256:f7c3d42d6287f4e45846c8231c31902b6fa2bea98735af413a43da4cf5b727f1"}, - {file = "pytest_mock-3.6.0-py3-none-any.whl", hash = "sha256:952139a535b5b48ac0bb2f90b5dd36b67c7e1ba92601f3a8012678c4bd7f0bcc"}, + {file = "pytest-mock-3.6.1.tar.gz", hash = "sha256:40217a058c52a63f1042f0784f62009e976ba824c418cced42e88d5f40ab0e62"}, + {file = "pytest_mock-3.6.1-py3-none-any.whl", hash = "sha256:30c2f2cc9759e76eee674b81ea28c9f0b94f8f0445a1b87762cadf774f0df7e3"}, ] pytest-mypy = [ {file = "pytest-mypy-0.8.1.tar.gz", hash = "sha256:1fa55723a4bf1d054fcba1c3bd694215a2a65cc95ab10164f5808afd893f3b11"}, @@ -1754,8 +1743,8 @@ requests = [ {file = "requests-2.15.1.tar.gz", hash = "sha256:e5659b9315a0610505e050bb7190bf6fa2ccee1ac295f2b760ef9d8a03ebbb2e"}, ] six = [ - {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, - {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] snowballstemmer = [ {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, @@ -1798,8 +1787,8 @@ sphinxcontrib-devhelp = [ {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, ] sphinxcontrib-htmlhelp = [ - {file = "sphinxcontrib-htmlhelp-1.0.3.tar.gz", hash = "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b"}, - {file = "sphinxcontrib_htmlhelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f"}, + {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, + {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, ] sphinxcontrib-jsmath = [ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, @@ -1810,8 +1799,8 @@ sphinxcontrib-qthelp = [ {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, ] sphinxcontrib-serializinghtml = [ - {file = "sphinxcontrib-serializinghtml-1.1.4.tar.gz", hash = "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc"}, - {file = "sphinxcontrib_serializinghtml-1.1.4-py2.py3-none-any.whl", hash = "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a"}, + {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, + {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, ] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, @@ -1897,9 +1886,9 @@ typed-ast = [ {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, ] typing-extensions = [ - {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, - {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, - {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, + {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, + {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, + {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, ] uvloop = [ {file = "uvloop-0.15.2-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:19fa1d56c91341318ac5d417e7b61c56e9a41183946cc70c411341173de02c69"}, @@ -1914,8 +1903,8 @@ uvloop = [ {file = "uvloop-0.15.2.tar.gz", hash = "sha256:2bb0624a8a70834e54dde8feed62ed63b50bad7a1265c40d6403a2ac447bce01"}, ] virtualenv = [ - {file = "virtualenv-20.4.3-py2.py3-none-any.whl", hash = "sha256:83f95875d382c7abafe06bd2a4cdd1b363e1bb77e02f155ebe8ac082a916b37c"}, - {file = "virtualenv-20.4.3.tar.gz", hash = "sha256:49ec4eb4c224c6f7dd81bb6d0a28a09ecae5894f4e593c89b0db0885f565a107"}, + {file = "virtualenv-20.4.7-py2.py3-none-any.whl", hash = "sha256:2b0126166ea7c9c3661f5b8e06773d28f83322de7a3ff7d06f0aed18c9de6a76"}, + {file = "virtualenv-20.4.7.tar.gz", hash = "sha256:14fdf849f80dbb29a4eb6caa9875d476ee2a5cf76a5f5415fa2f1606010ab467"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, diff --git a/pyproject.toml b/pyproject.toml index a480cbb9..4dee13c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,6 @@ classifiers = [ "Intended Audience :: Developers", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development :: Libraries :: Application Frameworks", @@ -32,7 +31,7 @@ classifiers = [ ] [tool.poetry.dependencies] -python = "^3.7" +python = "^3.8" aiohttp = "^3.7.4" pydantic = "^1.8.1" Babel = "^2.9.1" From 79f21416c877b6561b6003b016004c0b8d03ec1f Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 25 May 2021 01:00:58 +0300 Subject: [PATCH 03/38] Added lost files --- aiogram/client/errors_middleware.py | 64 +++++++ aiogram/dispatcher/filters/callback_data.py | 113 +++++++++++ aiogram/utils/exceptions/__init__.py | 0 aiogram/utils/exceptions/bad_request.py | 5 + aiogram/utils/exceptions/base.py | 40 ++++ aiogram/utils/exceptions/conflict.py | 0 aiogram/utils/exceptions/network.py | 5 + aiogram/utils/exceptions/not_found.py | 5 + aiogram/utils/exceptions/server.py | 0 aiogram/utils/exceptions/special.py | 46 +++++ aiogram/utils/exceptions/unauthorized.py | 0 aiogram/utils/exceptions/util.py | 20 ++ examples/finite_state_machine.py | 111 +++++++++++ .../test_filters/test_callback_data.py | 177 ++++++++++++++++++ 14 files changed, 586 insertions(+) create mode 100644 aiogram/client/errors_middleware.py create mode 100644 aiogram/dispatcher/filters/callback_data.py create mode 100644 aiogram/utils/exceptions/__init__.py create mode 100644 aiogram/utils/exceptions/bad_request.py create mode 100644 aiogram/utils/exceptions/base.py create mode 100644 aiogram/utils/exceptions/conflict.py create mode 100644 aiogram/utils/exceptions/network.py create mode 100644 aiogram/utils/exceptions/not_found.py create mode 100644 aiogram/utils/exceptions/server.py create mode 100644 aiogram/utils/exceptions/special.py create mode 100644 aiogram/utils/exceptions/unauthorized.py create mode 100644 aiogram/utils/exceptions/util.py create mode 100644 examples/finite_state_machine.py create mode 100644 tests/test_dispatcher/test_filters/test_callback_data.py diff --git a/aiogram/client/errors_middleware.py b/aiogram/client/errors_middleware.py new file mode 100644 index 00000000..59d95f07 --- /dev/null +++ b/aiogram/client/errors_middleware.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, List, Type + +from aiogram.methods import Response, TelegramMethod +from aiogram.types import TelegramObject +from aiogram.utils.exceptions.base import TelegramAPIError +from aiogram.utils.exceptions.exceptions import ( + CantParseEntitiesStartTag, + CantParseEntitiesUnclosed, + CantParseEntitiesUnmatchedTags, + CantParseEntitiesUnsupportedTag, + DetailedTelegramAPIError, +) + +if TYPE_CHECKING: + from aiogram.client.bot import Bot + from aiogram.client.session.base import NextRequestMiddlewareType + + +class RequestErrorMiddleware: + def __init__(self) -> None: + self._registry: List[Type[DetailedTelegramAPIError]] = [ + CantParseEntitiesStartTag, + CantParseEntitiesUnmatchedTags, + CantParseEntitiesUnclosed, + CantParseEntitiesUnsupportedTag, + ] + + def mount(self, error: Type[DetailedTelegramAPIError]) -> Type[DetailedTelegramAPIError]: + if error in self: + raise ValueError(f"{error!r} is already registered") + if not hasattr(error, "patterns"): + raise ValueError(f"{error!r} has no attribute 'patterns'") + self._registry.append(error) + return error + + def detect_error(self, err: TelegramAPIError) -> TelegramAPIError: + message = err.message + for variant in self._registry: + for pattern in variant.patterns: + if match := re.match(pattern, message): + return variant( + method=err.method, + message=err.message, + match=match, + ) + return err + + def __contains__(self, item: Type[DetailedTelegramAPIError]) -> bool: + return item in self._registry + + async def __call__( + self, + bot: Bot, + method: TelegramMethod[TelegramObject], + make_request: NextRequestMiddlewareType, + ) -> Response[TelegramObject]: + try: + return await make_request(bot, method) + except TelegramAPIError as e: + detected_err = self.detect_error(err=e) + raise detected_err from e diff --git a/aiogram/dispatcher/filters/callback_data.py b/aiogram/dispatcher/filters/callback_data.py new file mode 100644 index 00000000..68f5b773 --- /dev/null +++ b/aiogram/dispatcher/filters/callback_data.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from decimal import Decimal +from enum import Enum +from fractions import Fraction +from typing import TYPE_CHECKING, Any, Dict, Optional, Type, TypeVar, Union +from uuid import UUID + +from magic_filter import MagicFilter +from pydantic import BaseModel + +from aiogram.dispatcher.filters import BaseFilter +from aiogram.types import CallbackQuery + +T = TypeVar("T", bound="CallbackData") + +MAX_CALLBACK_LENGTH: int = 64 + + +class CallbackDataException(Exception): + pass + + +class CallbackData(BaseModel): + if TYPE_CHECKING: # pragma: no cover + sep: str + prefix: str + + def __init_subclass__(cls, **kwargs: Any) -> None: + if "prefix" not in kwargs: + raise ValueError( + f"prefix required, usage example: " + f"`class {cls.__name__}(CallbackData, prefix='my_callback'): ...`" + ) + cls.sep = kwargs.pop("sep", ":") + cls.prefix = kwargs.pop("prefix") + if cls.sep in cls.prefix: + raise ValueError( + f"Separator symbol {cls.sep!r} can not be used inside prefix {cls.prefix!r}" + ) + + def _encode_value(self, key: str, value: Any) -> str: + if value is None: + return "" + if isinstance(value, Enum): + return str(value.value) + if isinstance(value, (int, str, float, Decimal, Fraction, UUID)): + return str(value) + raise ValueError( + f"Attribute {key}={value!r} of type {type(value).__name__!r}" + f" can not be packed to callback data" + ) + + def pack(self) -> str: + result = [self.prefix] + for key, value in self.dict().items(): + encoded = self._encode_value(key, value) + if self.sep in encoded: + raise ValueError( + f"Separator symbol {self.sep!r} can not be used in value {key}={encoded!r}" + ) + result.append(encoded) + callback_data = self.sep.join(result) + if len(callback_data.encode()) > MAX_CALLBACK_LENGTH: + raise ValueError( + f"Resulted callback data is too long! len({callback_data!r}.encode()) > {MAX_CALLBACK_LENGTH}" + ) + return callback_data + + @classmethod + def unpack(cls: Type[T], value: str) -> T: + prefix, *parts = value.split(cls.sep) + names = cls.__fields__.keys() + if len(parts) != len(names): + raise TypeError( + f"Callback data {cls.__name__!r} takes {len(names)} arguments but {len(parts)} were given" + ) + if prefix != cls.prefix: + raise ValueError(f"Bad prefix ({prefix!r} != {cls.prefix!r})") + payload = {} + for k, v in zip(names, parts): # type: str, Optional[str] + if field := cls.__fields__.get(k): + if v == "" and not field.required: + v = None + payload[k] = v + return cls(**payload) + + @classmethod + def filter(cls, rule: MagicFilter) -> CallbackQueryFilter: + return CallbackQueryFilter(callback_data=cls, rule=rule) + + class Config: + use_enum_values = True + + +class CallbackQueryFilter(BaseFilter): + callback_data: Type[CallbackData] + rule: MagicFilter + + async def __call__(self, query: CallbackQuery) -> Union[bool, Dict[str, Any]]: + if not isinstance(query, CallbackQuery) or not query.data: + return False + try: + callback_data = self.callback_data.unpack(query.data) + except (TypeError, ValueError): + return False + + if self.rule.resolve(callback_data): + return {"callback_data": callback_data} + return False + + class Config: + arbitrary_types_allowed = True diff --git a/aiogram/utils/exceptions/__init__.py b/aiogram/utils/exceptions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aiogram/utils/exceptions/bad_request.py b/aiogram/utils/exceptions/bad_request.py new file mode 100644 index 00000000..9b9d878a --- /dev/null +++ b/aiogram/utils/exceptions/bad_request.py @@ -0,0 +1,5 @@ +from aiogram.utils.exceptions.base import DetailedTelegramAPIError + + +class BadRequest(DetailedTelegramAPIError): + pass diff --git a/aiogram/utils/exceptions/base.py b/aiogram/utils/exceptions/base.py new file mode 100644 index 00000000..fdbc2514 --- /dev/null +++ b/aiogram/utils/exceptions/base.py @@ -0,0 +1,40 @@ +from typing import ClassVar, List, Match, Optional, TypeVar + +from aiogram.methods import TelegramMethod +from aiogram.methods.base import TelegramType + +ErrorType = TypeVar("ErrorType") + + +class TelegramAPIError(Exception): + url: Optional[str] = None + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + ) -> None: + self.method = method + self.message = message + + def render_description(self) -> str: + return self.message + + def __str__(self) -> str: + message = [self.render_description()] + if self.url: + message.append(f"(background on this error at: {self.url})") + return "\n".join(message) + + +class DetailedTelegramAPIError(TelegramAPIError): + patterns: ClassVar[List[str]] + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + match: Match[str], + ) -> None: + super().__init__(method=method, message=message) + self.match: Match[str] = match diff --git a/aiogram/utils/exceptions/conflict.py b/aiogram/utils/exceptions/conflict.py new file mode 100644 index 00000000..e69de29b diff --git a/aiogram/utils/exceptions/network.py b/aiogram/utils/exceptions/network.py new file mode 100644 index 00000000..067b1a80 --- /dev/null +++ b/aiogram/utils/exceptions/network.py @@ -0,0 +1,5 @@ +from aiogram.utils.exceptions.base import DetailedTelegramAPIError + + +class NetworkError(DetailedTelegramAPIError): + pass diff --git a/aiogram/utils/exceptions/not_found.py b/aiogram/utils/exceptions/not_found.py new file mode 100644 index 00000000..8dfb344b --- /dev/null +++ b/aiogram/utils/exceptions/not_found.py @@ -0,0 +1,5 @@ +from aiogram.utils.exceptions.base import DetailedTelegramAPIError + + +class NotFound(DetailedTelegramAPIError): + pass diff --git a/aiogram/utils/exceptions/server.py b/aiogram/utils/exceptions/server.py new file mode 100644 index 00000000..e69de29b diff --git a/aiogram/utils/exceptions/special.py b/aiogram/utils/exceptions/special.py new file mode 100644 index 00000000..0568f900 --- /dev/null +++ b/aiogram/utils/exceptions/special.py @@ -0,0 +1,46 @@ +from typing import Optional + +from aiogram.methods import TelegramMethod +from aiogram.methods.base import TelegramType +from aiogram.utils.exceptions.base import TelegramAPIError + + +class RetryAfter(TelegramAPIError): + url = "https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this" + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + retry_after: int, + ) -> None: + super().__init__(method=method, message=message) + self.retry_after = retry_after + + def render_description(self) -> str: + description = f"Flood control exceeded on method {type(self.method).__name__!r}" + if chat_id := getattr(self.method, "chat_id", None): + description += f" in chat {chat_id}" + description += f". Retry in {self.retry_after} seconds." + return description + + +class MigrateToChat(TelegramAPIError): + url = "https://core.telegram.org/bots/api#responseparameters" + + def __init__( + self, + method: TelegramMethod[TelegramType], + message: str, + migrate_to_chat_id: int, + ) -> None: + super().__init__(method=method, message=message) + self.migrate_to_chat_id = migrate_to_chat_id + + def render_message(self) -> Optional[str]: + description = ( + f"The group has been migrated to a supergroup with id {self.migrate_to_chat_id}" + ) + if chat_id := getattr(self.method, "chat_id", None): + description += f" from {chat_id}" + return description diff --git a/aiogram/utils/exceptions/unauthorized.py b/aiogram/utils/exceptions/unauthorized.py new file mode 100644 index 00000000..e69de29b diff --git a/aiogram/utils/exceptions/util.py b/aiogram/utils/exceptions/util.py new file mode 100644 index 00000000..a7cb191e --- /dev/null +++ b/aiogram/utils/exceptions/util.py @@ -0,0 +1,20 @@ +def mark_line(text: str, offset: int, length: int = 1) -> str: + try: + if offset > 0 and (new_line_pos := text[:offset].rindex("\n")): + text = "..." + text[:new_line_pos] + offset -= new_line_pos - 3 + except ValueError: + pass + + if offset > 10: + text = "..." + text[offset - 10 :] + offset = 13 + + mark = " " * offset + mark += "^" * length + try: + if new_line_pos := text[len(mark) :].index("\n"): + text = text[:new_line_pos].rstrip() + "..." + except ValueError: + pass + return text + "\n" + mark diff --git a/examples/finite_state_machine.py b/examples/finite_state_machine.py new file mode 100644 index 00000000..65266b64 --- /dev/null +++ b/examples/finite_state_machine.py @@ -0,0 +1,111 @@ +import asyncio +import logging +import sys +from os import getenv + +from aiogram import Bot, Dispatcher, F +from aiogram.dispatcher.filters import Command +from aiogram.dispatcher.fsm.context import FSMContext +from aiogram.dispatcher.fsm.state import State, StatesGroup +from aiogram.types import Message, ReplyKeyboardRemove, ReplyKeyboardMarkup, KeyboardButton +from aiogram.utils.markdown import hbold +from aiogram.utils.markup import KeyboardConstructor + +GENDERS = ["Male", "Female", "Helicopter", "Other"] + +dp = Dispatcher() + + +# States +class Form(StatesGroup): + name = State() # Will be represented in storage as 'Form:name' + age = State() # Will be represented in storage as 'Form:age' + gender = State() # Will be represented in storage as 'Form:gender' + + +@dp.message(Command(commands=["start"])) +async def cmd_start(message: Message, state: FSMContext): + """ + Conversation's entry point + """ + # Set state + await state.set_state(Form.name) + await message.answer("Hi there! What's your name?") + + +@dp.message(Command(commands=["cancel"])) +@dp.message(F.text.lower() == "cancel") +async def cancel_handler(message: Message, state: FSMContext): + """ + Allow user to cancel any action + """ + current_state = await state.get_state() + if current_state is None: + return + + logging.info("Cancelling state %r", current_state) + # Cancel state and inform user about it + await state.clear() + # And remove keyboard (just in case) + await message.answer("Cancelled.", reply_markup=ReplyKeyboardRemove()) + + +@dp.message(Form.name) +async def process_name(message: Message, state: FSMContext): + """ + Process user name + """ + await state.update_data(name=message.text) + await state.set_state(Form.age) + await message.answer("How old are you?") + + +# Check age. Age gotta be digit +@dp.message(Form.age, ~F.text.isdigit()) +async def process_age_invalid(message: Message): + """ + If age is invalid + """ + return await message.answer("Age gotta be a number.\nHow old are you? (digits only)") + + +@dp.message(Form.age) +async def process_age(message: Message, state: FSMContext): + # Update state and data + await state.set_state(Form.gender) + await state.update_data(age=int(message.text)) + + # Configure ReplyKeyboardMarkup + constructor = KeyboardConstructor(KeyboardButton) + constructor.add(*(KeyboardButton(text=text) for text in GENDERS)).adjust(2) + markup = ReplyKeyboardMarkup( + resize_keyboard=True, selective=True, keyboard=constructor.export() + ) + await message.reply("What is your gender?", reply_markup=markup) + + +@dp.message(Form.gender) +async def process_gender(message: Message, state: FSMContext): + data = await state.update_data(gender=message.text) + await state.clear() + + # And send message + await message.answer( + ( + f'Hi, nice to meet you, {hbold(data["name"])}\n' + f'Age: {hbold(data["age"])}\n' + f'Gender: {hbold(data["gender"])}\n' + ), + reply_markup=ReplyKeyboardRemove(), + ) + + +async def main(): + bot = Bot(token=getenv("TELEGRAM_TOKEN"), parse_mode="HTML") + + await dp.start_polling(bot) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, stream=sys.stdout) + asyncio.run(main()) diff --git a/tests/test_dispatcher/test_filters/test_callback_data.py b/tests/test_dispatcher/test_filters/test_callback_data.py new file mode 100644 index 00000000..f71ca706 --- /dev/null +++ b/tests/test_dispatcher/test_filters/test_callback_data.py @@ -0,0 +1,177 @@ +from decimal import Decimal +from enum import Enum, auto +from fractions import Fraction +from typing import Optional +from uuid import UUID + +import pytest +from magic_filter import MagicFilter +from pydantic import ValidationError + +from aiogram import F +from aiogram.dispatcher.filters.callback_data import CallbackData +from aiogram.types import CallbackQuery, User + + +class MyIntEnum(Enum): + FOO = auto() + + +class MyStringEnum(str, Enum): + FOO = "FOO" + + +class MyCallback(CallbackData, prefix="test"): + foo: str + bar: int + + +class TestCallbackData: + def test_init_subclass_prefix_required(self): + assert MyCallback.prefix == "test" + + with pytest.raises(ValueError, match="prefix required.+"): + + class MyInvalidCallback(CallbackData): + pass + + def test_init_subclass_sep_validation(self): + assert MyCallback.sep == ":" + + class MyCallback2(CallbackData, prefix="test2", sep="@"): + pass + + assert MyCallback2.sep == "@" + + with pytest.raises(ValueError, match="Separator symbol '@' .+ 'sp@m'"): + + class MyInvalidCallback(CallbackData, prefix="sp@m", sep="@"): + pass + + @pytest.mark.parametrize( + "value,success,expected", + [ + [None, True, ""], + [42, True, "42"], + ["test", True, "test"], + [9.99, True, "9.99"], + [Decimal("9.99"), True, "9.99"], + [Fraction("3/2"), True, "3/2"], + [ + UUID("123e4567-e89b-12d3-a456-426655440000"), + True, + "123e4567-e89b-12d3-a456-426655440000", + ], + [MyIntEnum.FOO, True, "1"], + [MyStringEnum.FOO, True, "FOO"], + [..., False, "..."], + [object, False, "..."], + [object(), False, "..."], + [User(id=42, is_bot=False, first_name="test"), False, "..."], + ], + ) + def test_encode_value(self, value, success, expected): + callback = MyCallback(foo="test", bar=42) + if success: + assert callback._encode_value("test", value) == expected + else: + with pytest.raises(ValueError): + assert callback._encode_value("test", value) == expected + + def test_pack(self): + with pytest.raises(ValueError, match="Separator symbol .+"): + assert MyCallback(foo="te:st", bar=42).pack() + + with pytest.raises(ValueError, match=".+is too long.+"): + assert MyCallback(foo="test" * 32, bar=42).pack() + + assert MyCallback(foo="test", bar=42).pack() == "test:test:42" + + def test_pack_optional(self): + class MyCallback1(CallbackData, prefix="test1"): + foo: str + bar: Optional[int] = None + + assert MyCallback1(foo="spam").pack() == "test1:spam:" + assert MyCallback1(foo="spam", bar=42).pack() == "test1:spam:42" + + class MyCallback2(CallbackData, prefix="test2"): + foo: Optional[str] = None + bar: int + + assert MyCallback2(bar=42).pack() == "test2::42" + assert MyCallback2(foo="spam", bar=42).pack() == "test2:spam:42" + + class MyCallback3(CallbackData, prefix="test3"): + foo: Optional[str] = "experiment" + bar: int + + assert MyCallback3(bar=42).pack() == "test3:experiment:42" + assert MyCallback3(foo="spam", bar=42).pack() == "test3:spam:42" + + def test_unpack(self): + with pytest.raises(TypeError, match=".+ takes 2 arguments but 3 were given"): + MyCallback.unpack("test:test:test:test") + + with pytest.raises(ValueError, match="Bad prefix .+"): + MyCallback.unpack("spam:test:test") + + assert MyCallback.unpack("test:test:42") == MyCallback(foo="test", bar=42) + + def test_unpack_optional(self): + with pytest.raises(ValidationError): + assert MyCallback.unpack("test:test:") + + class MyCallback1(CallbackData, prefix="test1"): + foo: str + bar: Optional[int] = None + + assert MyCallback1.unpack("test1:spam:") == MyCallback1(foo="spam") + assert MyCallback1.unpack("test1:spam:42") == MyCallback1(foo="spam", bar=42) + + class MyCallback2(CallbackData, prefix="test2"): + foo: Optional[str] = None + bar: int + + assert MyCallback2.unpack("test2::42") == MyCallback2(bar=42) + assert MyCallback2.unpack("test2:spam:42") == MyCallback2(foo="spam", bar=42) + + class MyCallback3(CallbackData, prefix="test3"): + foo: Optional[str] = "experiment" + bar: int + + assert MyCallback3.unpack("test3:experiment:42") == MyCallback3(bar=42) + assert MyCallback3.unpack("test3:spam:42") == MyCallback3(foo="spam", bar=42) + + def test_build_filter(self): + filter_object = MyCallback.filter(F.foo == "test") + assert isinstance(filter_object.rule, MagicFilter) + assert filter_object.callback_data is MyCallback + + +class TestCallbackDataFilter: + @pytest.mark.parametrize( + "query,rule,result", + [ + ["test", F.foo == "test", False], + ["test:spam:42", F.foo == "test", False], + ["test:test:42", F.foo == "test", {"callback_data": MyCallback(foo="test", bar=42)}], + ["test:test:", F.foo == "test", False], + ], + ) + @pytest.mark.asyncio + async def test_call(self, query, rule, result): + callback_query = CallbackQuery( + id="1", + from_user=User(id=42, is_bot=False, first_name="test"), + data=query, + chat_instance="test", + ) + + filter_object = MyCallback.filter(rule) + assert await filter_object(callback_query) == result + + @pytest.mark.asyncio + async def test_invalid_call(self): + filter_object = MyCallback.filter(F.test) + assert not await filter_object(User(id=42, is_bot=False, first_name="test")) From 53da50045e68fef925786373d988491c74ef1e4c Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 25 May 2021 01:07:22 +0300 Subject: [PATCH 04/38] Bump python version in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d3d5248b..76153bbf 100644 --- a/README.md +++ b/README.md @@ -9,4 +9,4 @@ [![Downloads](https://img.shields.io/pypi/dm/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Codecov](https://img.shields.io/codecov/c/github/aiogram/aiogram?style=flat-square)](https://app.codecov.io/gh/aiogram/aiogram) -**aiogram** modern and fully asynchronous framework for [Telegram Bot API](https://core.telegram.org/bots/api) written in Python 3.7 with [asyncio](https://docs.python.org/3/library/asyncio.html) and [aiohttp](https://github.com/aio-libs/aiohttp). It helps you to make your bots faster and simpler. +**aiogram** modern and fully asynchronous framework for [Telegram Bot API](https://core.telegram.org/bots/api) written in Python 3.8 with [asyncio](https://docs.python.org/3/library/asyncio.html) and [aiohttp](https://github.com/aio-libs/aiohttp). It helps you to make your bots faster and simpler. From 811ccd999a8261752c5b8f3329830d484c366a22 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 25 May 2021 01:11:26 +0300 Subject: [PATCH 05/38] Bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4dee13c6..aa283b07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aiogram" -version = "3.0.0-alpha.8" +version = "3.0.0-alpha.9" description = "Modern and fully asynchronous framework for Telegram Bot API" authors = ["Alex Root Junior "] license = "MIT" From 32bc05130f7043ff49ebaf6d90ca3387cc04a507 Mon Sep 17 00:00:00 2001 From: Aleksandr Date: Sat, 5 Jun 2021 13:37:01 +0300 Subject: [PATCH 06/38] Added get_url() method for Message object and shifted_id property for Chat object (#585) * Added get_url() method for Message object and shifted_id property for Chat object * Added missing closing bracket to shifted_id description * Added basic groups to skipped pattern, simplified code * Return None instead of raising TypeError, removed redundant f-string * Change get_url typing to Optional[str] * Better shifted_id method * get_url tests added * Added whitespace (E226) * Code format with black * Parametrized test --- aiogram/types/chat.py | 16 +++++++ aiogram/types/message.py | 19 ++++++++ tests/test_api/test_methods/test_get_url.py | 51 +++++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 tests/test_api/test_methods/test_get_url.py diff --git a/aiogram/types/chat.py b/aiogram/types/chat.py index 7d735ac3..4c0db8c9 100644 --- a/aiogram/types/chat.py +++ b/aiogram/types/chat.py @@ -54,3 +54,19 @@ class Chat(TelegramObject): """*Optional*. Unique identifier for the linked chat, i.e. the discussion group identifier for a channel and vice versa; for supergroups and channel chats. This identifier may be greater than 32 bits and some programming languages may have difficulty/silent defects in interpreting it. But it is smaller than 52 bits, so a signed 64 bit integer or double-precision float type are safe for storing this identifier. Returned only in :class:`aiogram.methods.get_chat.GetChat`.""" location: Optional[ChatLocation] = None """*Optional*. For supergroups, the location to which the supergroup is connected. Returned only in :class:`aiogram.methods.get_chat.GetChat`.""" + + @property + def shifted_id(self) -> int: + """ + Returns shifted chat ID (positive and without "-100" prefix). + Mostly used for private links like t.me/c/chat_id/message_id + + Currently supergroup/channel IDs have 10-digit ID after "-100" prefix removed. + However, these IDs might become 11-digit in future. So, first we remove "-100" + prefix and count remaining number length. Then we multiple + -1 * 10 ^ (number_length + 2) + Finally, self.id is substracted from that number + """ + short_id = str(self.id).replace("-100", "") + shift = int(-1 * pow(10, len(short_id) + 2)) + return shift - self.id diff --git a/aiogram/types/message.py b/aiogram/types/message.py index 67b3a594..8d72d0e5 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -1760,6 +1760,25 @@ class Message(TelegramObject): return DeleteMessage(chat_id=self.chat.id, message_id=self.message_id) + def get_url(self, force_private: bool = False) -> Optional[str]: + """ + Returns message URL. Cannot be used in private (one-to-one) chats. + If chat has a username, returns URL like https://t.me/username/message_id + Otherwise (or if {force_private} flag is set), returns https://t.me/c/shifted_chat_id/message_id + + :param force_private: if set, a private URL is returned even for a public chat + :return: string with full message URL + """ + if self.chat.type in ("private", "group"): + return None + + if not self.chat.username or force_private: + chat_value = f"c/{self.chat.shifted_id}" + else: + chat_value = self.chat.username + + return f"https://t.me/{chat_value}/{self.message_id}" + class ContentType(helper.Helper): mode = helper.HelperMode.snake_case diff --git a/tests/test_api/test_methods/test_get_url.py b/tests/test_api/test_methods/test_get_url.py new file mode 100644 index 00000000..3c769ca2 --- /dev/null +++ b/tests/test_api/test_methods/test_get_url.py @@ -0,0 +1,51 @@ +import datetime +from typing import Optional + +import pytest +from aiogram.types import Chat, Message + +from tests.mocked_bot import MockedBot + + +class TestGetMessageUrl: + @pytest.mark.parametrize( + "chat_type,chat_id,chat_username,force_private,expected_result", + [ + ["private", 123456, "username", False, None], + ["group", -123456, "username", False, None], + ["supergroup", -1001234567890, None, False, "https://t.me/c/1234567890/10"], + ["supergroup", -1001234567890, None, True, "https://t.me/c/1234567890/10"], + ["supergroup", -1001234567890, "username", False, "https://t.me/username/10"], + ["supergroup", -1001234567890, "username", True, "https://t.me/c/1234567890/10"], + ["channel", -1001234567890, None, False, "https://t.me/c/1234567890/10"], + ["channel", -1001234567890, None, True, "https://t.me/c/1234567890/10"], + ["channel", -1001234567890, "username", False, "https://t.me/username/10"], + ["channel", -1001234567890, "username", True, "https://t.me/c/1234567890/10"], + # 2 extra cases: 9-digit ID and 11-digit ID (without "-100") + ["supergroup", -100123456789, None, True, "https://t.me/c/123456789/10"], + ["supergroup", -10012345678901, None, True, "https://t.me/c/12345678901/10"], + ], + ) + def test_method( + self, + bot: MockedBot, + chat_type: str, + chat_id: int, + chat_username: Optional[str], + force_private: bool, + expected_result: Optional[str], + ): + + fake_chat = Chat(id=chat_id, username=chat_username, type=chat_type) + fake_message_id = 10 + fake_message = Message( + message_id=fake_message_id, + date=datetime.datetime.now(), + text="test", + chat=fake_chat, + ) + + if expected_result is None: + assert fake_message.get_url(force_private=force_private) is None + else: + assert fake_message.get_url(force_private=force_private) == expected_result From 83d6ab48c58c388bb5ab5001d23ba639476083eb Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 15 Jun 2021 01:45:31 +0300 Subject: [PATCH 07/38] Backport and improvements (#601) * Backport RedisStorage, deep-linking * Allow prereleases for aioredis * Bump dependencies * Correctly skip Redis tests on Windows * Reformat tests code and bump Makefile --- .github/workflows/tests.yml | 15 +- Makefile | 59 +++---- aiogram/__init__.py | 6 + aiogram/dispatcher/dispatcher.py | 43 +++-- aiogram/dispatcher/filters/command.py | 159 ++++++++++++------ aiogram/dispatcher/fsm/context.py | 22 ++- aiogram/dispatcher/fsm/middleware.py | 22 ++- aiogram/dispatcher/fsm/storage/base.py | 25 ++- aiogram/dispatcher/fsm/storage/memory.py | 34 ++-- aiogram/dispatcher/fsm/storage/redis.py | 101 +++++++++++ aiogram/utils/auth_widget.py | 34 ++++ aiogram/utils/deep_linking.py | 131 +++++++++++++++ aiogram/utils/keyboard.py | 41 ++++- aiogram/utils/link.py | 18 ++ aiogram/utils/text_decorations.py | 2 +- mypy.ini | 5 +- poetry.lock | 106 ++++++++---- pyproject.toml | 8 +- tests/conftest.py | 55 ++++++ tests/docker-compose.yml | 7 + .../test_session/test_base_session.py | 6 +- .../test_methods/test_edit_message_media.py | 2 +- tests/test_api/test_methods/test_get_url.py | 2 +- .../test_api/test_methods/test_send_audio.py | 2 +- ...est_set_chat_administrator_custom_title.py | 2 +- .../test_methods/test_set_chat_photo.py | 2 +- tests/test_dispatcher/test_dispatcher.py | 46 ++--- .../test_event/test_handler.py | 1 - .../test_filters/test_command.py | 72 ++++---- .../test_fsm/storage/test_memory.py | 45 ----- .../test_fsm/storage/test_redis.py | 21 +++ .../test_fsm/storage/test_storages.py | 44 +++++ .../test_dispatcher/test_fsm/test_context.py | 17 +- .../test_handler/test_chosen_inline_result.py | 2 +- .../test_handler/test_error.py | 11 +- .../test_handler/test_inline_query.py | 2 +- .../test_dispatcher/test_handler/test_poll.py | 10 +- .../test_handler/test_shipping_query.py | 2 +- tests/test_utils/test_auth_widget.py | 27 +++ tests/test_utils/test_deep_linking.py | 94 +++++++++++ tests/test_utils/test_link.py | 24 +++ tests/test_utils/test_markdown.py | 2 +- tests/test_utils/test_text_decorations.py | 2 +- 43 files changed, 1004 insertions(+), 327 deletions(-) create mode 100644 aiogram/dispatcher/fsm/storage/redis.py create mode 100644 aiogram/utils/auth_widget.py create mode 100644 aiogram/utils/deep_linking.py create mode 100644 aiogram/utils/link.py create mode 100644 tests/docker-compose.yml delete mode 100644 tests/test_dispatcher/test_fsm/storage/test_memory.py create mode 100644 tests/test_dispatcher/test_fsm/storage/test_redis.py create mode 100644 tests/test_dispatcher/test_fsm/storage/test_storages.py create mode 100644 tests/test_utils/test_auth_widget.py create mode 100644 tests/test_utils/test_deep_linking.py create mode 100644 tests/test_utils/test_link.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f35271f0..38066ebe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,6 +43,12 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true + - name: Setup redis + if: ${{ matrix.os != 'windows-latest' }} + uses: shogo82148/actions-setup-redis@v1 + with: + redis-version: 6 + - name: Load cached venv id: cached-poetry-dependencies uses: actions/cache@v2 @@ -64,7 +70,14 @@ jobs: run: | poetry run black --check --diff aiogram tests - - name: Run tests + - name: Run tests (with Redis) + if: ${{ matrix.os != 'windows-latest' }} + run: | + poetry run pytest --cov=aiogram --cov-config .coveragerc --cov-report=xml --redis redis://localhost:6379/0 + + - name: Run tests (without Redis) + # Redis can't be used on GitHub Windows Runners + if: ${{ matrix.os == 'windows-latest' }} run: | poetry run pytest --cov=aiogram --cov-config .coveragerc --cov-report=xml diff --git a/Makefile b/Makefile index c1fa9797..da73f8b7 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,14 @@ base_python := python3 py := poetry run python := $(py) python +package_dir := aiogram +tests_dir := tests +scripts_dir := scripts +code_dir := $(package_dir) $(tests_dir) $(scripts_dir) reports_dir := reports +redis_connection := redis://localhost:6379 + .PHONY: help help: @echo "=======================================================================================" @@ -17,13 +23,8 @@ help: @echo " clean: Delete temporary files" @echo "" @echo "Code quality:" - @echo " isort: Run isort tool" - @echo " black: Run black tool" - @echo " flake8: Run flake8 tool" - @echo " flake8-report: Run flake8 with HTML reporting" - @echo " mypy: Run mypy tool" - @echo " mypy-report: Run mypy tool with HTML reporting" - @echo " lint: Run isort, black, flake8 and mypy tools" + @echo " lint: Lint code by isort, black, flake8 and mypy tools" + @echo " reformat: Reformat code by isort and black tools" @echo "" @echo "Tests:" @echo " test: Run tests" @@ -31,8 +32,8 @@ help: @echo " test-coverage-report: Open coverage report in default system web browser" @echo "" @echo "Documentation:" - @echo " docs: Build docs" - @echo " docs-serve: Serve docs for local development" + @echo " docs: Build docs" + @echo " docs-serve: Serve docs for local development" @echo " docs-prepare-reports: Move all HTML reports to docs dir" @echo "" @echo "Project" @@ -65,33 +66,17 @@ clean: # Code quality # ================================================================================================= -.PHONY: isort -isort: - $(py) isort aiogram tests scripts - -.PHONY: black -black: - $(py) black aiogram tests scripts - -.PHONY: flake8 -flake8: - $(py) flake8 aiogram - -.PHONY: flake8-report -flake8-report: - mkdir -p $(reports_dir)/flake8 - $(py) flake8 --format=html --htmldir=$(reports_dir)/flake8 aiogram - -.PHONY: mypy -mypy: - $(py) mypy aiogram - -.PHONY: mypy-report -mypy-report: - $(py) mypy aiogram --html-report $(reports_dir)/typechecking - .PHONY: lint -lint: isort black flake8 mypy +lint: + $(py) isort --check-only $(code_dir) + $(py) black --check --diff $(code_dir) + $(py) flake8 $(code_dir) + $(py) mypy $(package_dir) + +.PHONY: reformat +reformat: + $(py) black $(code_dir) + $(py) isort $(code_dir) # ================================================================================================= # Tests @@ -99,12 +84,12 @@ lint: isort black flake8 mypy .PHONY: test test: - $(py) pytest --cov=aiogram --cov-config .coveragerc tests/ + $(py) pytest --cov=aiogram --cov-config .coveragerc tests/ --redis $(redis_connection) .PHONY: test-coverage test-coverage: mkdir -p $(reports_dir)/tests/ - $(py) pytest --cov=aiogram --cov-config .coveragerc --html=$(reports_dir)/tests/index.html tests/ + $(py) pytest --cov=aiogram --cov-config .coveragerc --html=$(reports_dir)/tests/index.html tests/ --redis $(redis_connection) .PHONY: test-coverage-report test-coverage-report: diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 31b52552..639e68c9 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -6,6 +6,8 @@ from .dispatcher import filters, handler from .dispatcher.dispatcher import Dispatcher from .dispatcher.middlewares.base import BaseMiddleware from .dispatcher.router import Router +from .utils.text_decorations import html_decoration as _html_decoration +from .utils.text_decorations import markdown_decoration as _markdown_decoration try: import uvloop as _uvloop @@ -15,6 +17,8 @@ except ImportError: # pragma: no cover pass F = MagicFilter() +html = _html_decoration +md = _markdown_decoration __all__ = ( "__api_version__", @@ -29,6 +33,8 @@ __all__ = ( "filters", "handler", "F", + "html", + "md", ) __version__ = "3.0.0-alpha.8" diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 78ff5aaf..95c721a1 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -4,7 +4,7 @@ import asyncio import contextvars import warnings from asyncio import CancelledError, Future, Lock -from typing import Any, AsyncGenerator, Dict, Optional, Union, cast +from typing import Any, AsyncGenerator, Dict, Optional, Union from .. import loggers from ..client.bot import Bot @@ -13,7 +13,6 @@ from ..types import TelegramObject, Update, User from ..utils.exceptions.base import TelegramAPIError from .event.bases import UNHANDLED, SkipHandler from .event.telegram import TelegramEventObserver -from .fsm.context import FSMContext from .fsm.middleware import FSMContextMiddleware from .fsm.storage.base import BaseStorage from .fsm.storage.memory import MemoryStorage @@ -32,7 +31,7 @@ class Dispatcher(Router): self, storage: Optional[BaseStorage] = None, fsm_strategy: FSMStrategy = FSMStrategy.USER_IN_CHAT, - isolate_events: bool = True, + isolate_events: bool = False, **kwargs: Any, ) -> None: super(Dispatcher, self).__init__(**kwargs) @@ -255,7 +254,9 @@ class Dispatcher(Router): ) return True # because update was processed but unsuccessful - async def _polling(self, bot: Bot, polling_timeout: int = 30, **kwargs: Any) -> None: + async def _polling( + self, bot: Bot, polling_timeout: int = 30, handle_as_tasks: bool = True, **kwargs: Any + ) -> None: """ Internal polling process @@ -264,7 +265,11 @@ class Dispatcher(Router): :return: """ async for update in self._listen_updates(bot, polling_timeout=polling_timeout): - await self._process_update(bot=bot, update=update, **kwargs) + handle_update = self._process_update(bot=bot, update=update, **kwargs) + if handle_as_tasks: + asyncio.create_task(handle_update) + else: + await handle_update async def _feed_webhook_update(self, bot: Bot, update: Update, **kwargs: Any) -> Any: """ @@ -342,11 +347,15 @@ class Dispatcher(Router): return None - async def start_polling(self, *bots: Bot, polling_timeout: int = 10, **kwargs: Any) -> None: + async def start_polling( + self, *bots: Bot, polling_timeout: int = 10, handle_as_tasks: bool = True, **kwargs: Any + ) -> None: """ Polling runner :param bots: + :param polling_timeout: + :param handle_as_tasks: :param kwargs: :return: """ @@ -363,7 +372,12 @@ class Dispatcher(Router): "Run polling for bot @%s id=%d - %r", user.username, bot.id, user.full_name ) coro_list.append( - self._polling(bot=bot, polling_timeout=polling_timeout, **kwargs) + self._polling( + bot=bot, + handle_as_tasks=handle_as_tasks, + polling_timeout=polling_timeout, + **kwargs, + ) ) await asyncio.gather(*coro_list) finally: @@ -372,22 +386,27 @@ class Dispatcher(Router): loggers.dispatcher.info("Polling stopped") await self.emit_shutdown(**workflow_data) - def run_polling(self, *bots: Bot, polling_timeout: int = 30, **kwargs: Any) -> None: + def run_polling( + self, *bots: Bot, polling_timeout: int = 30, handle_as_tasks: bool = True, **kwargs: Any + ) -> None: """ Run many bots with polling :param bots: Bot instances :param polling_timeout: Poling timeout + :param handle_as_tasks: Run task for each event and no wait result :param kwargs: contextual data :return: """ try: return asyncio.run( - self.start_polling(*bots, **kwargs, polling_timeout=polling_timeout) + self.start_polling( + *bots, + **kwargs, + polling_timeout=polling_timeout, + handle_as_tasks=handle_as_tasks, + ) ) except (KeyboardInterrupt, SystemExit): # pragma: no cover # Allow to graceful shutdown pass - - def current_state(self, chat_id: int, user_id: int) -> FSMContext: - return cast(FSMContext, self.fsm.resolve_context(chat_id=chat_id, user_id=user_id)) diff --git a/aiogram/dispatcher/filters/command.py b/aiogram/dispatcher/filters/command.py index 899b09be..0e584c99 100644 --- a/aiogram/dispatcher/filters/command.py +++ b/aiogram/dispatcher/filters/command.py @@ -1,18 +1,24 @@ from __future__ import annotations import re -from dataclasses import dataclass, field -from typing import Any, Dict, Match, Optional, Pattern, Sequence, Union, cast +from dataclasses import dataclass, field, replace +from typing import Any, Dict, Match, Optional, Pattern, Sequence, Tuple, Union, cast -from pydantic import validator +from magic_filter import MagicFilter +from pydantic import Field, validator from aiogram import Bot from aiogram.dispatcher.filters import BaseFilter from aiogram.types import Message +from aiogram.utils.deep_linking import decode_payload CommandPatterType = Union[str, re.Pattern] +class CommandException(Exception): + pass + + class Command(BaseFilter): """ This filter can be helpful for handling commands from the text messages. @@ -29,6 +35,8 @@ class Command(BaseFilter): """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""" @validator("commands", always=True) def _validate_commands( @@ -39,12 +47,54 @@ class Command(BaseFilter): return value async def __call__(self, message: Message, bot: Bot) -> Union[bool, Dict[str, Any]]: - if not message.text: + text = message.text or message.caption + if not text: return False - return await self.parse_command(text=message.text, bot=bot) + try: + command = await self.parse_command(text=cast(str, message.text), bot=bot) + except CommandException: + return False + return {"command": command} - async def parse_command(self, text: str, bot: Bot) -> Union[bool, Dict[str, CommandObject]]: + def extract_command(self, text: str) -> CommandObject: + # First step: separate command with arguments + # "/command@mention arg1 arg2" -> "/command@mention", ["arg1 arg2"] + try: + full_command, *args = text.split(maxsplit=1) + except ValueError: + raise CommandException("not enough values to unpack") + + # Separate command into valuable parts + # "/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 + ) + + def validate_prefix(self, command: CommandObject) -> None: + if command.prefix not in self.commands_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: + me = await bot.me() + if me.username and command.mention.lower() != me.username.lower(): + raise CommandException("Mention did not match") + + def validate_command(self, command: CommandObject) -> CommandObject: + for allowed_command in cast(Sequence[CommandPatterType], self.commands): + # Command can be presented as regexp pattern or raw string + # then need to validate that in different ways + if isinstance(allowed_command, Pattern): # Regexp + result = allowed_command.match(command.command) + if result: + return replace(command, match=result) + elif command.command == allowed_command: # String + return command + raise CommandException("Command did not match pattern") + + async def parse_command(self, text: str, bot: Bot) -> CommandObject: """ Extract command from the text and validate @@ -52,56 +102,18 @@ class Command(BaseFilter): :param bot: :return: """ - if not text.strip(): - return False + command = self.extract_command(text) + self.validate_prefix(command=command) + await self.validate_mention(bot=bot, command=command) + command = self.validate_command(command) + self.do_magic(command=command) + return command - # First step: separate command with arguments - # "/command@mention arg1 arg2" -> "/command@mention", ["arg1 arg2"] - full_command, *args = text.split(maxsplit=1) - - # Separate command into valuable parts - # "/command@mention" -> "/", ("command", "@", "mention") - prefix, (command, _, mention) = full_command[0], full_command[1:].partition("@") - - # Validate prefixes - if prefix not in self.commands_prefix: - return False - - # Validate mention - if mention and not self.commands_ignore_mention: - me = await bot.me() - if me.username and mention.lower() != me.username.lower(): - return False - - # Validate command - for allowed_command in cast(Sequence[CommandPatterType], self.commands): - # Command can be presented as regexp pattern or raw string - # then need to validate that in different ways - if isinstance(allowed_command, Pattern): # Regexp - result = allowed_command.match(command) - if result: - return { - "command": CommandObject( - prefix=prefix, - command=command, - mention=mention, - args=args[0] if args else None, - match=result, - ) - } - - elif command == allowed_command: # String - return { - "command": CommandObject( - prefix=prefix, - command=command, - mention=mention, - args=args[0] if args else None, - match=None, - ) - } - - return False + def do_magic(self, command: CommandObject) -> None: + if not self.command_magic: + return + if not self.command_magic.resolve(command): + raise CommandException("Rejected via magic filter") class Config: arbitrary_types_allowed = True @@ -143,3 +155,40 @@ class CommandObject: if self.args: line += " " + self.args return line + + +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 + + async def parse_command(self, text: str, bot: Bot) -> CommandObject: + """ + Extract command from the text and validate + + :param text: + :param bot: + :return: + """ + command = self.extract_command(text) + self.validate_prefix(command=command) + await self.validate_mention(bot=bot, command=command) + command = self.validate_command(command) + command = self.validate_deeplink(command=command) + self.do_magic(command=command) + return command + + def validate_deeplink(self, command: CommandObject) -> CommandObject: + if not self.deep_link: + return command + if not command.args: + raise CommandException("Deep-link was missing") + args = command.args + if self.deep_link_encoded: + try: + args = decode_payload(args) + except UnicodeDecodeError as e: + raise CommandException(f"Failed to decode Base64: {e}") + return replace(command, args=args) + return command diff --git a/aiogram/dispatcher/fsm/context.py b/aiogram/dispatcher/fsm/context.py index 78ed480b..dc4e4030 100644 --- a/aiogram/dispatcher/fsm/context.py +++ b/aiogram/dispatcher/fsm/context.py @@ -1,25 +1,35 @@ from typing import Any, Dict, Optional +from aiogram import Bot from aiogram.dispatcher.fsm.storage.base import BaseStorage, StateType class FSMContext: - def __init__(self, storage: BaseStorage, chat_id: int, user_id: int) -> None: + def __init__(self, bot: Bot, storage: BaseStorage, chat_id: int, user_id: int) -> None: + self.bot = bot self.storage = storage self.chat_id = chat_id self.user_id = user_id async def set_state(self, state: StateType = None) -> None: - await self.storage.set_state(chat_id=self.chat_id, user_id=self.user_id, state=state) + await self.storage.set_state( + bot=self.bot, chat_id=self.chat_id, user_id=self.user_id, state=state + ) async def get_state(self) -> Optional[str]: - return await self.storage.get_state(chat_id=self.chat_id, user_id=self.user_id) + return await self.storage.get_state( + bot=self.bot, chat_id=self.chat_id, user_id=self.user_id + ) async def set_data(self, data: Dict[str, Any]) -> None: - await self.storage.set_data(chat_id=self.chat_id, user_id=self.user_id, data=data) + await self.storage.set_data( + bot=self.bot, chat_id=self.chat_id, user_id=self.user_id, data=data + ) async def get_data(self) -> Dict[str, Any]: - return await self.storage.get_data(chat_id=self.chat_id, user_id=self.user_id) + return await self.storage.get_data( + bot=self.bot, chat_id=self.chat_id, user_id=self.user_id + ) async def update_data( self, data: Optional[Dict[str, Any]] = None, **kwargs: Any @@ -27,7 +37,7 @@ class FSMContext: if data: kwargs.update(data) return await self.storage.update_data( - chat_id=self.chat_id, user_id=self.user_id, data=kwargs + bot=self.bot, chat_id=self.chat_id, user_id=self.user_id, data=kwargs ) async def clear(self) -> None: diff --git a/aiogram/dispatcher/fsm/middleware.py b/aiogram/dispatcher/fsm/middleware.py index 1e3ba91c..734c5825 100644 --- a/aiogram/dispatcher/fsm/middleware.py +++ b/aiogram/dispatcher/fsm/middleware.py @@ -1,5 +1,6 @@ -from typing import Any, Awaitable, Callable, Dict, Optional +from typing import Any, Awaitable, Callable, Dict, Optional, cast +from aiogram import Bot from aiogram.dispatcher.fsm.context import FSMContext from aiogram.dispatcher.fsm.storage.base import BaseStorage from aiogram.dispatcher.fsm.strategy import FSMStrategy, apply_strategy @@ -24,24 +25,27 @@ class FSMContextMiddleware(BaseMiddleware[Update]): event: Update, data: Dict[str, Any], ) -> Any: - context = self.resolve_event_context(data) + bot: Bot = cast(Bot, data["bot"]) + context = self.resolve_event_context(bot, data) data["fsm_storage"] = self.storage if context: data.update({"state": context, "raw_state": await context.get_state()}) if self.isolate_events: - async with self.storage.lock(chat_id=context.chat_id, user_id=context.user_id): + async with self.storage.lock( + bot=bot, chat_id=context.chat_id, user_id=context.user_id + ): return await handler(event, data) return await handler(event, data) - def resolve_event_context(self, data: Dict[str, Any]) -> Optional[FSMContext]: + def resolve_event_context(self, bot: Bot, data: Dict[str, Any]) -> Optional[FSMContext]: user = data.get("event_from_user") chat = data.get("event_chat") chat_id = chat.id if chat else None user_id = user.id if user else None - return self.resolve_context(chat_id=chat_id, user_id=user_id) + return self.resolve_context(bot=bot, chat_id=chat_id, user_id=user_id) def resolve_context( - self, chat_id: Optional[int], user_id: Optional[int] + self, bot: Bot, chat_id: Optional[int], user_id: Optional[int] ) -> Optional[FSMContext]: if chat_id is None: chat_id = user_id @@ -50,8 +54,8 @@ class FSMContextMiddleware(BaseMiddleware[Update]): chat_id, user_id = apply_strategy( chat_id=chat_id, user_id=user_id, strategy=self.strategy ) - return self.get_context(chat_id=chat_id, user_id=user_id) + return self.get_context(bot=bot, chat_id=chat_id, user_id=user_id) return None - def get_context(self, chat_id: int, user_id: int) -> FSMContext: - return FSMContext(storage=self.storage, chat_id=chat_id, user_id=user_id) + def get_context(self, bot: Bot, chat_id: int, user_id: int) -> FSMContext: + return FSMContext(bot=bot, storage=self.storage, chat_id=chat_id, user_id=user_id) diff --git a/aiogram/dispatcher/fsm/storage/base.py b/aiogram/dispatcher/fsm/storage/base.py index f394cd61..42826915 100644 --- a/aiogram/dispatcher/fsm/storage/base.py +++ b/aiogram/dispatcher/fsm/storage/base.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from contextlib import asynccontextmanager from typing import Any, AsyncGenerator, Dict, Optional, Union +from aiogram import Bot from aiogram.dispatcher.fsm.state import State StateType = Optional[Union[str, State]] @@ -11,34 +12,42 @@ class BaseStorage(ABC): @abstractmethod @asynccontextmanager async def lock( - self, chat_id: int, user_id: int + self, bot: Bot, chat_id: int, user_id: int ) -> AsyncGenerator[None, None]: # pragma: no cover yield None @abstractmethod async def set_state( - self, chat_id: int, user_id: int, state: StateType = None + self, bot: Bot, chat_id: int, user_id: int, state: StateType = None ) -> None: # pragma: no cover pass @abstractmethod - async def get_state(self, chat_id: int, user_id: int) -> Optional[str]: # pragma: no cover + async def get_state( + self, bot: Bot, chat_id: int, user_id: int + ) -> Optional[str]: # pragma: no cover pass @abstractmethod async def set_data( - self, chat_id: int, user_id: int, data: Dict[str, Any] + self, bot: Bot, chat_id: int, user_id: int, data: Dict[str, Any] ) -> None: # pragma: no cover pass @abstractmethod - async def get_data(self, chat_id: int, user_id: int) -> Dict[str, Any]: # pragma: no cover + async def get_data( + self, bot: Bot, chat_id: int, user_id: int + ) -> Dict[str, Any]: # pragma: no cover pass async def update_data( - self, chat_id: int, user_id: int, data: Dict[str, Any] + self, bot: Bot, chat_id: int, user_id: int, data: Dict[str, Any] ) -> Dict[str, Any]: - current_data = await self.get_data(chat_id=chat_id, user_id=user_id) + current_data = await self.get_data(bot=bot, chat_id=chat_id, user_id=user_id) current_data.update(data) - await self.set_data(chat_id=chat_id, user_id=user_id, data=current_data) + await self.set_data(bot=bot, chat_id=chat_id, user_id=user_id, data=current_data) return current_data.copy() + + @abstractmethod + async def close(self) -> None: # pragma: no cover + pass diff --git a/aiogram/dispatcher/fsm/storage/memory.py b/aiogram/dispatcher/fsm/storage/memory.py index 933e225c..3e82d306 100644 --- a/aiogram/dispatcher/fsm/storage/memory.py +++ b/aiogram/dispatcher/fsm/storage/memory.py @@ -4,6 +4,7 @@ from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import Any, AsyncGenerator, DefaultDict, Dict, Optional +from aiogram import Bot from aiogram.dispatcher.fsm.state import State from aiogram.dispatcher.fsm.storage.base import BaseStorage, StateType @@ -17,23 +18,30 @@ class MemoryStorageRecord: class MemoryStorage(BaseStorage): def __init__(self) -> None: - self.storage: DefaultDict[int, DefaultDict[int, MemoryStorageRecord]] = defaultdict( - lambda: defaultdict(MemoryStorageRecord) - ) + self.storage: DefaultDict[ + Bot, DefaultDict[int, DefaultDict[int, MemoryStorageRecord]] + ] = defaultdict(lambda: defaultdict(lambda: defaultdict(MemoryStorageRecord))) + + async def close(self) -> None: + pass @asynccontextmanager - async def lock(self, chat_id: int, user_id: int) -> AsyncGenerator[None, None]: - async with self.storage[chat_id][user_id].lock: + async def lock(self, bot: Bot, chat_id: int, user_id: int) -> AsyncGenerator[None, None]: + async with self.storage[bot][chat_id][user_id].lock: yield None - async def set_state(self, chat_id: int, user_id: int, state: StateType = None) -> None: - self.storage[chat_id][user_id].state = state.state if isinstance(state, State) else state + async def set_state( + self, bot: Bot, chat_id: int, user_id: int, state: StateType = None + ) -> None: + self.storage[bot][chat_id][user_id].state = ( + state.state if isinstance(state, State) else state + ) - async def get_state(self, chat_id: int, user_id: int) -> Optional[str]: - return self.storage[chat_id][user_id].state + async def get_state(self, bot: Bot, chat_id: int, user_id: int) -> Optional[str]: + return self.storage[bot][chat_id][user_id].state - async def set_data(self, chat_id: int, user_id: int, data: Dict[str, Any]) -> None: - self.storage[chat_id][user_id].data = data.copy() + async def set_data(self, bot: Bot, chat_id: int, user_id: int, data: Dict[str, Any]) -> None: + self.storage[bot][chat_id][user_id].data = data.copy() - async def get_data(self, chat_id: int, user_id: int) -> Dict[str, Any]: - return self.storage[chat_id][user_id].data.copy() + async def get_data(self, bot: Bot, chat_id: int, user_id: int) -> Dict[str, Any]: + return self.storage[bot][chat_id][user_id].data.copy() diff --git a/aiogram/dispatcher/fsm/storage/redis.py b/aiogram/dispatcher/fsm/storage/redis.py new file mode 100644 index 00000000..64c832f9 --- /dev/null +++ b/aiogram/dispatcher/fsm/storage/redis.py @@ -0,0 +1,101 @@ +from contextlib import asynccontextmanager +from typing import Any, AsyncGenerator, Callable, Dict, Optional, Union, cast + +from aioredis import ConnectionPool, Redis + +from aiogram import Bot +from aiogram.dispatcher.fsm.state import State +from aiogram.dispatcher.fsm.storage.base import BaseStorage, StateType + +PrefixFactoryType = Callable[[Bot], str] +STATE_KEY = "state" +STATE_DATA_KEY = "data" +STATE_LOCK_KEY = "lock" + +DEFAULT_REDIS_LOCK_KWARGS = {"timeout": 60} + + +class RedisStorage(BaseStorage): + def __init__( + self, + redis: Redis, + prefix: str = "fsm", + prefix_bot: Union[bool, PrefixFactoryType, Dict[int, str]] = False, + state_ttl: Optional[int] = None, + data_ttl: Optional[int] = None, + lock_kwargs: Optional[Dict[str, Any]] = None, + ) -> None: + if lock_kwargs is None: + lock_kwargs = DEFAULT_REDIS_LOCK_KWARGS + self.redis = redis + self.prefix = prefix + self.prefix_bot = prefix_bot + self.state_ttl = state_ttl + self.data_ttl = data_ttl + self.lock_kwargs = lock_kwargs + + @classmethod + def from_url( + cls, url: str, connection_kwargs: Optional[Dict[str, Any]] = None, **kwargs: Any + ) -> "RedisStorage": + if connection_kwargs is None: + connection_kwargs = {} + pool = ConnectionPool.from_url(url, **connection_kwargs) + redis = Redis(connection_pool=pool) + return cls(redis=redis, **kwargs) + + async def close(self) -> None: + await self.redis.close() + + def generate_key(self, bot: Bot, *parts: Any) -> str: + prefix_parts = [self.prefix] + if self.prefix_bot: + if isinstance(self.prefix_bot, dict): + prefix_parts.append(self.prefix_bot[bot.id]) + elif callable(self.prefix_bot): + prefix_parts.append(self.prefix_bot(bot)) + else: + prefix_parts.append(str(bot.id)) + prefix_parts.extend(parts) + return ":".join(map(str, prefix_parts)) + + @asynccontextmanager + async def lock(self, bot: Bot, chat_id: int, user_id: int) -> AsyncGenerator[None, None]: + key = self.generate_key(bot, chat_id, user_id, STATE_LOCK_KEY) + async with self.redis.lock(name=key, **self.lock_kwargs): + yield None + + async def set_state( + self, bot: Bot, chat_id: int, user_id: int, state: StateType = None + ) -> None: + key = self.generate_key(bot, chat_id, user_id, STATE_KEY) + if state is None: + await self.redis.delete(key) + else: + await self.redis.set( + key, state.state if isinstance(state, State) else state, ex=self.state_ttl + ) + + async def get_state(self, bot: Bot, chat_id: int, user_id: int) -> Optional[str]: + key = self.generate_key(bot, chat_id, user_id, STATE_KEY) + value = await self.redis.get(key) + if isinstance(value, bytes): + return value.decode("utf-8") + return cast(Optional[str], value) + + async def set_data(self, bot: Bot, chat_id: int, user_id: int, data: Dict[str, Any]) -> None: + key = self.generate_key(bot, chat_id, user_id, STATE_DATA_KEY) + if not data: + await self.redis.delete(key) + return + json_data = bot.session.json_dumps(data) + await self.redis.set(key, json_data, ex=self.data_ttl) + + async def get_data(self, bot: Bot, chat_id: int, user_id: int) -> Dict[str, Any]: + key = self.generate_key(bot, chat_id, user_id, STATE_DATA_KEY) + value = await self.redis.get(key) + if value is None: + return {} + if isinstance(value, bytes): + value = value.decode("utf-8") + return cast(Dict[str, Any], bot.session.json_loads(value)) diff --git a/aiogram/utils/auth_widget.py b/aiogram/utils/auth_widget.py new file mode 100644 index 00000000..a67afe65 --- /dev/null +++ b/aiogram/utils/auth_widget.py @@ -0,0 +1,34 @@ +import hashlib +import hmac +from typing import Any, Dict + + +def check_signature(token: str, hash: str, **kwargs: Any) -> bool: + """ + Generate hexadecimal representation + of the HMAC-SHA-256 signature of the data-check-string + with the SHA256 hash of the bot's token used as a secret key + + :param token: + :param hash: + :param kwargs: all params received on auth + :return: + """ + secret = hashlib.sha256(token.encode("utf-8")) + check_string = "\n".join(map(lambda k: f"{k}={kwargs[k]}", sorted(kwargs))) + hmac_string = hmac.new( + secret.digest(), check_string.encode("utf-8"), digestmod=hashlib.sha256 + ).hexdigest() + return hmac_string == hash + + +def check_integrity(token: str, data: Dict[str, Any]) -> bool: + """ + Verify the authentication and the integrity + of the data received on user's auth + + :param token: Bot's token + :param data: all data that came on auth + :return: + """ + return check_signature(token, **data) diff --git a/aiogram/utils/deep_linking.py b/aiogram/utils/deep_linking.py new file mode 100644 index 00000000..caac2c26 --- /dev/null +++ b/aiogram/utils/deep_linking.py @@ -0,0 +1,131 @@ +""" +Deep linking + +Telegram bots have a deep linking mechanism, that allows for passing +additional parameters to the bot on startup. It could be a command that +launches the bot — or an auth token to connect the user's Telegram +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. + +Basic link example: + + .. code-block:: python + + from aiogram.utils.deep_linking import get_start_link + link = await get_start_link('foo') + + # result: 'https://t.me/MyBot?start=foo' + +Encoded link example: + + .. code-block:: python + + from aiogram.utils.deep_linking import get_start_link + + link = await get_start_link('foo', encode=True) + # result: 'https://t.me/MyBot?start=Zm9v' + +Decode it back example: + .. code-block:: python + + from aiogram.utils.deep_linking import decode_payload + from aiogram.types import Message + + @dp.message_handler(commands=["start"]) + async def handler(message: Message): + args = message.get_args() + payload = decode_payload(args) + await message.answer(f"Your payload: {payload}") + +""" +import re +from base64 import urlsafe_b64decode, urlsafe_b64encode +from typing import Literal, cast + +from aiogram import Bot +from aiogram.utils.link import create_telegram_link + +BAD_PATTERN = re.compile(r"[^_A-z0-9-]") + + +async def create_start_link(bot: Bot, payload: str, encode: bool = False) -> str: + """ + Create 'start' deep link with your payload. + + If you need to encode payload or pass special characters - + set encode as True + + :param bot: bot instance + :param payload: args passed with /start + :param encode: encode payload with base64url + :return: link + """ + username = (await bot.me()).username + return create_deep_link(username=username, link_type="start", payload=payload, encode=encode) + + +async def create_startgroup_link(bot: Bot, payload: str, encode: bool = False) -> str: + """ + Create 'startgroup' deep link with your payload. + + If you need to encode payload or pass special characters - + set encode as True + + :param bot: bot instance + :param payload: args passed with /start + :param encode: encode payload with base64url + :return: link + """ + username = (await bot.me()).username + return create_deep_link( + username=username, link_type="startgroup", payload=payload, encode=encode + ) + + +def create_deep_link( + username: str, link_type: Literal["start", "startgroup"], payload: str, encode: bool = False +) -> str: + """ + Create deep link. + + :param username: + :param link_type: `start` or `startgroup` + :param payload: any string-convertible data + :param encode: pass True to encode the payload + :return: deeplink + """ + if not isinstance(payload, str): + payload = str(payload) + + if encode: + payload = encode_payload(payload) + + if re.search(BAD_PATTERN, payload): + raise ValueError( + "Wrong payload! Only A-Z, a-z, 0-9, _ and - are allowed. " + "Pass `encode=True` or encode payload manually." + ) + + if len(payload) > 64: + raise ValueError("Payload must be up to 64 characters long.") + + return create_telegram_link(username, **{cast(str, link_type): payload}) + + +def encode_payload(payload: str) -> str: + """Encode payload with URL-safe base64url.""" + payload = str(payload) + bytes_payload: bytes = urlsafe_b64encode(payload.encode()) + str_payload = bytes_payload.decode() + return str_payload.replace("=", "") + + +def decode_payload(payload: str) -> str: + """Decode payload with URL-safe base64url.""" + payload += "=" * (4 - len(payload) % 4) + result: bytes = urlsafe_b64decode(payload) + return result.decode() diff --git a/aiogram/utils/keyboard.py b/aiogram/utils/keyboard.py index 19409c94..9cb10b02 100644 --- a/aiogram/utils/keyboard.py +++ b/aiogram/utils/keyboard.py @@ -2,13 +2,27 @@ from __future__ import annotations from itertools import chain from itertools import cycle as repeat_all -from typing import Any, Generator, Generic, Iterable, List, Optional, Type, TypeVar, Union +from typing import ( + TYPE_CHECKING, + Any, + Generator, + Generic, + Iterable, + List, + Optional, + Type, + TypeVar, + Union, + no_type_check, +) from aiogram.dispatcher.filters.callback_data import CallbackData from aiogram.types import ( + CallbackGame, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, + LoginUrl, ReplyKeyboardMarkup, ) @@ -239,3 +253,28 @@ def repeat_last(items: Iterable[T]) -> Generator[T, None, None]: except StopIteration: finished = True yield value + + +class InlineKeyboardConstructor(KeyboardConstructor[InlineKeyboardButton]): + if TYPE_CHECKING: # pragma: no cover + + @no_type_check + def button( + self, + text: str, + url: Optional[str] = None, + login_url: Optional[LoginUrl] = None, + callback_data: Optional[Union[str, CallbackData]] = None, + switch_inline_query: Optional[str] = None, + switch_inline_query_current_chat: Optional[str] = None, + callback_game: Optional[CallbackGame] = None, + pay: Optional[bool] = None, + **kwargs: Any, + ) -> "KeyboardConstructor[InlineKeyboardButton]": + ... + + def as_markup(self, **kwargs: Any) -> InlineKeyboardMarkup: + ... + + def __init__(self) -> None: + super().__init__(InlineKeyboardButton) diff --git a/aiogram/utils/link.py b/aiogram/utils/link.py new file mode 100644 index 00000000..87d402e2 --- /dev/null +++ b/aiogram/utils/link.py @@ -0,0 +1,18 @@ +from typing import Any +from urllib.parse import urlencode, urljoin + + +def create_tg_link(link: str, **kwargs: Any) -> str: + url = f"tg://{link}" + if kwargs: + query = urlencode(kwargs) + url += f"?{query}" + return url + + +def create_telegram_link(uri: str, **kwargs: Any) -> str: + url = urljoin("https://t.me", uri) + if kwargs: + query = urlencode(query=kwargs) + url += f"?{query}" + return url diff --git a/aiogram/utils/text_decorations.py b/aiogram/utils/text_decorations.py index a41e481f..23c9c2a7 100644 --- a/aiogram/utils/text_decorations.py +++ b/aiogram/utils/text_decorations.py @@ -183,7 +183,7 @@ class MarkdownDecoration(TextDecoration): return f"`{value}`" def pre(self, value: str) -> str: - return f"```{value}```" + return f"```\n{value}\n```" def pre_language(self, value: str, language: str) -> str: return f"```{language}\n{value}\n```" diff --git a/mypy.ini b/mypy.ini index afe61218..a75c96cb 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,6 @@ [mypy] ;plugins = pydantic.mypy -python_version = 3.7 +python_version = 3.8 show_error_codes = True show_error_context = True pretty = True @@ -29,3 +29,6 @@ ignore_missing_imports = True [mypy-uvloop] ignore_missing_imports = True + +[mypy-aioredis] +ignore_missing_imports = True diff --git a/poetry.lock b/poetry.lock index 7c3acd24..cb05c237 100644 --- a/poetry.lock +++ b/poetry.lock @@ -38,6 +38,21 @@ aiohttp = ">=2.3.2" attrs = ">=19.2.0" python-socks = {version = ">=1.0.1", extras = ["asyncio"]} +[[package]] +name = "aioredis" +version = "2.0.0a1" +description = "asyncio (PEP 3156) Redis support" +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +async-timeout = "*" +typing-extensions = "*" + +[package.extras] +hiredis = ["hiredis (>=1.0)"] + [[package]] name = "alabaster" version = "0.7.12" @@ -156,7 +171,7 @@ lxml = ["lxml"] [[package]] name = "black" -version = "21.5b1" +version = "21.6b0" description = "The uncompromising code formatter." category = "dev" optional = false @@ -172,8 +187,9 @@ toml = ">=0.10.1" [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.6.0)", "aiohttp-cors"] +d = ["aiohttp (>=3.6.0)", "aiohttp-cors (>=0.4.0)"] python2 = ["typed-ast (>=1.4.2)"] +uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "cfgv" @@ -218,9 +234,6 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -[package.dependencies] -toml = {version = "*", optional = true, markers = "extra == \"toml\""} - [package.extras] toml = ["toml"] @@ -234,7 +247,7 @@ python-versions = ">=3.5" [[package]] name = "distlib" -version = "0.3.1" +version = "0.3.2" description = "Distribution utilities" category = "dev" optional = false @@ -301,7 +314,7 @@ test = ["pytest", "pytest-cov", "pytest-xdist"] [[package]] name = "identify" -version = "2.2.5" +version = "2.2.10" description = "File identification library for Python" category = "dev" optional = false @@ -312,11 +325,11 @@ license = ["editdistance-s"] [[package]] name = "idna" -version = "3.1" +version = "3.2" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false -python-versions = ">=3.4" +python-versions = ">=3.5" [[package]] name = "imagesize" @@ -328,7 +341,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.0.1" +version = "4.5.0" description = "Read metadata from Python packages" category = "dev" optional = false @@ -351,7 +364,7 @@ python-versions = "*" [[package]] name = "ipython" -version = "7.23.1" +version = "7.24.1" description = "IPython: Productive Interactive Computing" category = "dev" optional = false @@ -371,7 +384,7 @@ pygments = "*" traitlets = ">=4.2" [package.extras] -all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.16)", "pygments", "qtconsole", "requests", "testpath"] +all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.17)", "pygments", "qtconsole", "requests", "testpath"] doc = ["Sphinx (>=1.3)"] kernel = ["ipykernel"] nbconvert = ["nbconvert"] @@ -379,7 +392,7 @@ nbformat = ["nbformat"] notebook = ["notebook", "ipywidgets"] parallel = ["ipyparallel"] qtconsole = ["qtconsole"] -test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.16)"] +test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.17)"] [[package]] name = "ipython-genutils" @@ -739,18 +752,19 @@ testing = ["coverage", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-cov" -version = "2.12.0" +version = "2.12.1" description = "Pytest plugin for measuring coverage." category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.dependencies] -coverage = {version = ">=5.2.1", extras = ["toml"]} +coverage = ">=5.2.1" pytest = ">=4.6" +toml = "*" [package.extras] -testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] [[package]] name = "pytest-html" @@ -764,6 +778,17 @@ python-versions = ">=3.6" pytest = ">=5.0,<6.0.0 || >6.0.0" pytest-metadata = "*" +[[package]] +name = "pytest-lazy-fixture" +version = "0.6.3" +description = "It helps to use fixtures in pytest.mark.parametrize" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +pytest = ">=3.2.5" + [[package]] name = "pytest-metadata" version = "1.11.0" @@ -930,17 +955,17 @@ test = ["pytest", "pytest-cov"] [[package]] name = "sphinx-copybutton" -version = "0.3.1" +version = "0.3.2" description = "Add a copy button to each of your code cells." category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" [package.dependencies] sphinx = ">=1.8" [package.extras] -code_style = ["flake8 (>=3.7.0,<3.8.0)", "black", "pre-commit (==1.17.0)"] +code_style = ["pre-commit (==2.12.1)"] [[package]] name = "sphinx-intl" @@ -1171,11 +1196,12 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt docs = ["sphinx", "sphinx-intl", "sphinx-autobuild", "sphinx-copybutton", "furo", "sphinx-prompt", "Sphinx-Substitution-Extensions"] fast = [] proxy = ["aiohttp-socks"] +redis = ["aioredis"] [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "2fcd44a8937b3ea48196c8eba8ceb0533281af34c884103bcc5b4f5f16b817d5" +content-hash = "362a6caf937b1c457599cbf2cd5d000eab4cac529bd7fe8c257ae713ebc63331" [metadata.files] aiofiles = [ @@ -1225,6 +1251,10 @@ aiohttp-socks = [ {file = "aiohttp_socks-0.5.5-py3-none-any.whl", hash = "sha256:faaa25ed4dc34440ca888d23e089420f3b1918dc4ecf062c3fd9474827ad6a39"}, {file = "aiohttp_socks-0.5.5.tar.gz", hash = "sha256:2eb2059756bde34c55bb429541cbf2eba3fd53e36ac80875b461221e2858b04a"}, ] +aioredis = [ + {file = "aioredis-2.0.0a1-py3-none-any.whl", hash = "sha256:32d7910724282a475c91b8b34403867069a4f07bf0c5ad5fe66cd797322f9a0d"}, + {file = "aioredis-2.0.0a1.tar.gz", hash = "sha256:5884f384b8ecb143bb73320a96e7c464fd38e117950a7d48340a35db8e35e7d2"}, +] alabaster = [ {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, {file = "alabaster-0.7.12.tar.gz", hash = "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"}, @@ -1274,8 +1304,8 @@ beautifulsoup4 = [ {file = "beautifulsoup4-4.9.3.tar.gz", hash = "sha256:84729e322ad1d5b4d25f805bfa05b902dd96450f43842c4e99067d5e1369eb25"}, ] black = [ - {file = "black-21.5b1-py3-none-any.whl", hash = "sha256:8a60071a0043876a4ae96e6c69bd3a127dad2c1ca7c8083573eb82f92705d008"}, - {file = "black-21.5b1.tar.gz", hash = "sha256:23695358dbcb3deafe7f0a3ad89feee5999a46be5fec21f4f1d108be0bcdb3b1"}, + {file = "black-21.6b0-py3-none-any.whl", hash = "sha256:dfb8c5a069012b2ab1e972e7b908f5fb42b6bbabcba0a788b86dc05067c7d9c7"}, + {file = "black-21.6b0.tar.gz", hash = "sha256:dc132348a88d103016726fe360cb9ede02cecf99b76e3660ce6c596be132ce04"}, ] cfgv = [ {file = "cfgv-3.3.0-py2.py3-none-any.whl", hash = "sha256:b449c9c6118fe8cca7fa5e00b9ec60ba08145d281d52164230a69211c5d597a1"}, @@ -1352,8 +1382,8 @@ decorator = [ {file = "decorator-5.0.9.tar.gz", hash = "sha256:72ecfba4320a893c53f9706bebb2d55c270c1e51a28789361aa93e4a21319ed5"}, ] distlib = [ - {file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"}, - {file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"}, + {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, + {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, ] docutils = [ {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, @@ -1376,28 +1406,28 @@ furo = [ {file = "furo-2020.12.30b24.tar.gz", hash = "sha256:30171899c9c06d692a778e6daf6cb2e5cbb05efc6006e1692e5e776007dc8a8c"}, ] identify = [ - {file = "identify-2.2.5-py2.py3-none-any.whl", hash = "sha256:9c3ab58543c03bd794a1735e4552ef6dec49ec32053278130d525f0982447d47"}, - {file = "identify-2.2.5.tar.gz", hash = "sha256:bc1705694253763a3160b943316867792ec00ba7a0ee40b46e20aebaf4e0c46a"}, + {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, + {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, ] idna = [ - {file = "idna-3.1-py3-none-any.whl", hash = "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16"}, - {file = "idna-3.1.tar.gz", hash = "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1"}, + {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, + {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, ] imagesize = [ {file = "imagesize-1.2.0-py2.py3-none-any.whl", hash = "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1"}, {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.0.1-py3-none-any.whl", hash = "sha256:d7eb1dea6d6a6086f8be21784cc9e3bcfa55872b52309bc5fad53a8ea444465d"}, - {file = "importlib_metadata-4.0.1.tar.gz", hash = "sha256:8c501196e49fb9df5df43833bdb1e4328f64847763ec8a50703148b73784d581"}, + {file = "importlib_metadata-4.5.0-py3-none-any.whl", hash = "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00"}, + {file = "importlib_metadata-4.5.0.tar.gz", hash = "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] ipython = [ - {file = "ipython-7.23.1-py3-none-any.whl", hash = "sha256:f78c6a3972dde1cc9e4041cbf4de583546314ba52d3c97208e5b6b2221a9cb7d"}, - {file = "ipython-7.23.1.tar.gz", hash = "sha256:714810a5c74f512b69d5f3b944c86e592cee0a5fb9c728e582f074610f6cf038"}, + {file = "ipython-7.24.1-py3-none-any.whl", hash = "sha256:d513e93327cf8657d6467c81f1f894adc125334ffe0e4ddd1abbb1c78d828703"}, + {file = "ipython-7.24.1.tar.gz", hash = "sha256:9bc24a99f5d19721fb8a2d1408908e9c0520a17fff2233ffe82620847f17f1b6"}, ] ipython-genutils = [ {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, @@ -1637,13 +1667,17 @@ pytest-asyncio = [ {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, ] pytest-cov = [ - {file = "pytest-cov-2.12.0.tar.gz", hash = "sha256:8535764137fecce504a49c2b742288e3d34bc09eed298ad65963616cc98fd45e"}, - {file = "pytest_cov-2.12.0-py2.py3-none-any.whl", hash = "sha256:95d4933dcbbacfa377bb60b29801daa30d90c33981ab2a79e9ab4452c165066e"}, + {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, + {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, ] pytest-html = [ {file = "pytest-html-3.1.1.tar.gz", hash = "sha256:3ee1cf319c913d19fe53aeb0bc400e7b0bc2dbeb477553733db1dad12eb75ee3"}, {file = "pytest_html-3.1.1-py3-none-any.whl", hash = "sha256:b7f82f123936a3f4d2950bc993c2c1ca09ce262c9ae12f9ac763a2401380b455"}, ] +pytest-lazy-fixture = [ + {file = "pytest-lazy-fixture-0.6.3.tar.gz", hash = "sha256:0e7d0c7f74ba33e6e80905e9bfd81f9d15ef9a790de97993e34213deb5ad10ac"}, + {file = "pytest_lazy_fixture-0.6.3-py3-none-any.whl", hash = "sha256:e0b379f38299ff27a653f03eaa69b08a6fd4484e46fd1c9907d984b9f9daeda6"}, +] pytest-metadata = [ {file = "pytest-metadata-1.11.0.tar.gz", hash = "sha256:71b506d49d34e539cc3cfdb7ce2c5f072bea5c953320002c95968e0238f8ecf1"}, {file = "pytest_metadata-1.11.0-py2.py3-none-any.whl", hash = "sha256:576055b8336dd4a9006dd2a47615f76f2f8c30ab12b1b1c039d99e834583523f"}, @@ -1763,8 +1797,8 @@ sphinx-autobuild = [ {file = "sphinx_autobuild-2020.9.1-py3-none-any.whl", hash = "sha256:df5c72cb8b8fc9b31279c4619780c4e95029be6de569ff60a8bb2e99d20f63dd"}, ] sphinx-copybutton = [ - {file = "sphinx-copybutton-0.3.1.tar.gz", hash = "sha256:0e0461df394515284e3907e3f418a0c60ef6ab6c9a27a800c8552772d0a402a2"}, - {file = "sphinx_copybutton-0.3.1-py3-none-any.whl", hash = "sha256:5125c718e763596e6e52d92e15ee0d6f4800ad3817939be6dee51218870b3e3d"}, + {file = "sphinx-copybutton-0.3.2.tar.gz", hash = "sha256:f901f17e7dadc063bcfca592c5160f9113ec17501a59e046af3edb82b7527656"}, + {file = "sphinx_copybutton-0.3.2-py3-none-any.whl", hash = "sha256:f16f8ed8dfc60f2b34a58cb69bfa04722e24be2f6d7e04db5554c32cde4df815"}, ] sphinx-intl = [ {file = "sphinx-intl-2.0.1.tar.gz", hash = "sha256:b25a6ec169347909e8d983eefe2d8adecb3edc2f27760db79b965c69950638b4"}, diff --git a/pyproject.toml b/pyproject.toml index 4dee13c6..4befda3e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aiogram" -version = "3.0.0-alpha.8" +version = "3.0.0-alpha.9" description = "Modern and fully asynchronous framework for Telegram Bot API" authors = ["Alex Root Junior "] license = "MIT" @@ -38,8 +38,9 @@ Babel = "^2.9.1" aiofiles = "^0.6.0" async_lru = "^1.0.2" aiohttp-socks = { version = "^0.5.5", optional = true } +aioredis = { version = "^2.0.0a1", allow-prereleases = true, optional = true } typing-extensions = { version = "^3.7.4", python = "<3.8" } -magic-filter = {version = "1.0.0a1", allow-prereleases = true} +magic-filter = { version = "1.0.0a1", allow-prereleases = true } sphinx = { version = "^3.1.0", optional = true } sphinx-intl = { version = "^2.0.1", optional = true } sphinx-autobuild = { version = "^2020.9.1", optional = true } @@ -50,6 +51,7 @@ Sphinx-Substitution-Extensions = { version = "^2020.9.30", optional = true } [tool.poetry.dev-dependencies] aiohttp-socks = "^0.5" +aioredis = { version = "^2.0.0a1", allow-prereleases = true } ipython = "^7.22.0" uvloop = { version = "^0.15.2", markers = "sys_platform == 'darwin' or sys_platform == 'linux'" } black = "^21.4b2" @@ -79,9 +81,11 @@ sphinx-copybutton = "^0.3.1" furo = "^2020.11.15-beta.17" sphinx-prompt = "^1.3.0" Sphinx-Substitution-Extensions = "^2020.9.30" +pytest-lazy-fixture = "^0.6.3" [tool.poetry.extras] fast = ["uvloop"] +redis = ["aioredis"] proxy = ["aiohttp-socks"] docs = [ "sphinx", diff --git a/tests/conftest.py b/tests/conftest.py index 60d9d0fe..92dd97fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,64 @@ import pytest +from _pytest.config import UsageError +from aioredis.connection import parse_url as parse_redis_url from aiogram import Bot +from aiogram.dispatcher.fsm.storage.memory import MemoryStorage +from aiogram.dispatcher.fsm.storage.redis import RedisStorage from tests.mocked_bot import MockedBot +def pytest_addoption(parser): + parser.addoption("--redis", default=None, help="run tests which require redis connection") + + +def pytest_configure(config): + config.addinivalue_line("markers", "redis: marked tests require redis connection to run") + + +def pytest_collection_modifyitems(config, items): + redis_uri = config.getoption("--redis") + if redis_uri is None: + skip_redis = pytest.mark.skip(reason="need --redis option with redis URI to run") + for item in items: + if "redis" in item.keywords: + item.add_marker(skip_redis) + return + try: + parse_redis_url(redis_uri) + except ValueError as e: + raise UsageError(f"Invalid redis URI {redis_uri!r}: {e}") + + +@pytest.fixture(scope="session") +def redis_server(request): + redis_uri = request.config.getoption("--redis") + return redis_uri + + +@pytest.fixture() +@pytest.mark.redis +async def redis_storage(redis_server): + if not redis_server: + pytest.skip("Redis is not available here") + storage = RedisStorage.from_url(redis_server) + try: + yield storage + finally: + conn = await storage.redis + await conn.flushdb() + await storage.close() + + +@pytest.fixture() +async def memory_storage(): + storage = MemoryStorage() + try: + yield storage + finally: + await storage.close() + + @pytest.fixture() def bot(): bot = MockedBot() diff --git a/tests/docker-compose.yml b/tests/docker-compose.yml new file mode 100644 index 00000000..453f5e5a --- /dev/null +++ b/tests/docker-compose.yml @@ -0,0 +1,7 @@ +version: "3.9" + +services: + redis: + image: redis:6-alpine + ports: + - "${REDIS_PORT-6379}:6379" diff --git a/tests/test_api/test_client/test_session/test_base_session.py b/tests/test_api/test_client/test_session/test_base_session.py index 448f663e..ef82c1d3 100644 --- a/tests/test_api/test_client/test_session/test_base_session.py +++ b/tests/test_api/test_client/test_session/test_base_session.py @@ -6,7 +6,7 @@ import pytest from aiogram.client.session.base import BaseSession, TelegramType from aiogram.client.telegram import PRODUCTION, TelegramAPIServer -from aiogram.methods import DeleteMessage, GetMe, Response, TelegramMethod +from aiogram.methods import DeleteMessage, GetMe, TelegramMethod from aiogram.types import UNSET try: @@ -20,7 +20,9 @@ class CustomSession(BaseSession): async def close(self): pass - async def make_request(self, token: str, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET) -> None: # type: ignore + async def make_request( + self, token: str, method: TelegramMethod[TelegramType], timeout: Optional[int] = UNSET + ) -> None: # type: ignore assert isinstance(token, str) assert isinstance(method, TelegramMethod) diff --git a/tests/test_api/test_methods/test_edit_message_media.py b/tests/test_api/test_methods/test_edit_message_media.py index c6715163..ee3003b9 100644 --- a/tests/test_api/test_methods/test_edit_message_media.py +++ b/tests/test_api/test_methods/test_edit_message_media.py @@ -3,7 +3,7 @@ from typing import Union import pytest from aiogram.methods import EditMessageMedia, Request -from aiogram.types import BufferedInputFile, InputMedia, InputMediaPhoto, Message +from aiogram.types import BufferedInputFile, InputMediaPhoto, Message from tests.mocked_bot import MockedBot diff --git a/tests/test_api/test_methods/test_get_url.py b/tests/test_api/test_methods/test_get_url.py index 3c769ca2..76b24200 100644 --- a/tests/test_api/test_methods/test_get_url.py +++ b/tests/test_api/test_methods/test_get_url.py @@ -2,8 +2,8 @@ import datetime from typing import Optional import pytest -from aiogram.types import Chat, Message +from aiogram.types import Chat, Message from tests.mocked_bot import MockedBot diff --git a/tests/test_api/test_methods/test_send_audio.py b/tests/test_api/test_methods/test_send_audio.py index 4a33bbdc..2a5e67fd 100644 --- a/tests/test_api/test_methods/test_send_audio.py +++ b/tests/test_api/test_methods/test_send_audio.py @@ -3,7 +3,7 @@ import datetime import pytest from aiogram.methods import Request, SendAudio -from aiogram.types import Audio, Chat, File, Message +from aiogram.types import Audio, Chat, Message from tests.mocked_bot import MockedBot diff --git a/tests/test_api/test_methods/test_set_chat_administrator_custom_title.py b/tests/test_api/test_methods/test_set_chat_administrator_custom_title.py index 2f4752c7..1395df0d 100644 --- a/tests/test_api/test_methods/test_set_chat_administrator_custom_title.py +++ b/tests/test_api/test_methods/test_set_chat_administrator_custom_title.py @@ -1,6 +1,6 @@ import pytest -from aiogram.methods import Request, SetChatAdministratorCustomTitle, SetChatTitle +from aiogram.methods import Request, SetChatAdministratorCustomTitle from tests.mocked_bot import MockedBot diff --git a/tests/test_api/test_methods/test_set_chat_photo.py b/tests/test_api/test_methods/test_set_chat_photo.py index 02e00670..f648ccdb 100644 --- a/tests/test_api/test_methods/test_set_chat_photo.py +++ b/tests/test_api/test_methods/test_set_chat_photo.py @@ -1,7 +1,7 @@ import pytest from aiogram.methods import Request, SetChatPhoto -from aiogram.types import BufferedInputFile, InputFile +from aiogram.types import BufferedInputFile from tests.mocked_bot import MockedBot diff --git a/tests/test_dispatcher/test_dispatcher.py b/tests/test_dispatcher/test_dispatcher.py index ecf44712..37bbf634 100644 --- a/tests/test_dispatcher/test_dispatcher.py +++ b/tests/test_dispatcher/test_dispatcher.py @@ -9,8 +9,6 @@ import pytest from aiogram import Bot from aiogram.dispatcher.dispatcher import Dispatcher from aiogram.dispatcher.event.bases import UNHANDLED, SkipHandler -from aiogram.dispatcher.fsm.strategy import FSMStrategy -from aiogram.dispatcher.middlewares.user_context import UserContextMiddleware from aiogram.dispatcher.router import Router from aiogram.methods import GetMe, GetUpdates, SendMessage from aiogram.types import ( @@ -423,7 +421,7 @@ class TestDispatcher: assert User.get_current(False) return kwargs - result = await router.update.trigger(update, test="PASS") + result = await router.update.trigger(update, test="PASS", bot=None) assert isinstance(result, dict) assert result["event_update"] == update assert result["event_router"] == router @@ -526,8 +524,9 @@ class TestDispatcher: assert len(log_records) == 1 assert "Cause exception while process update" in log_records[0] + @pytest.mark.parametrize("as_task", [True, False]) @pytest.mark.asyncio - async def test_polling(self, bot: MockedBot): + async def test_polling(self, bot: MockedBot, as_task: bool): dispatcher = Dispatcher() async def _mock_updates(*_): @@ -539,8 +538,11 @@ class TestDispatcher: "aiogram.dispatcher.dispatcher.Dispatcher._listen_updates" ) as patched_listen_updates: patched_listen_updates.return_value = _mock_updates() - await dispatcher._polling(bot=bot) - mocked_process_update.assert_awaited() + await dispatcher._polling(bot=bot, handle_as_tasks=as_task) + if as_task: + pass + else: + mocked_process_update.assert_awaited() @pytest.mark.asyncio async def test_exception_handler_catch_exceptions(self): @@ -548,9 +550,12 @@ class TestDispatcher: router = Router() dp.include_router(router) + class CustomException(Exception): + pass + @router.message() async def message_handler(message: Message): - raise Exception("KABOOM") + raise CustomException("KABOOM") update = Update( update_id=42, @@ -562,23 +567,23 @@ class TestDispatcher: from_user=User(id=42, is_bot=False, first_name="Test"), ), ) - with pytest.raises(Exception, match="KABOOM"): - await dp.update.trigger(update) + with pytest.raises(CustomException, match="KABOOM"): + await dp.update.trigger(update, bot=None) @router.errors() async def error_handler(event: Update, exception: Exception): return "KABOOM" - response = await dp.update.trigger(update) + response = await dp.update.trigger(update, bot=None) assert response == "KABOOM" @dp.errors() async def root_error_handler(event: Update, exception: Exception): return exception - response = await dp.update.trigger(update) + response = await dp.update.trigger(update, bot=None) - assert isinstance(response, Exception) + assert isinstance(response, CustomException) assert str(response) == "KABOOM" @pytest.mark.asyncio @@ -654,20 +659,3 @@ class TestDispatcher: log_records = [rec.message for rec in caplog.records] assert "Cause exception while process update" in log_records[0] - - @pytest.mark.parametrize( - "strategy,case,expected", - [ - [FSMStrategy.USER_IN_CHAT, (-42, 42), (-42, 42)], - [FSMStrategy.CHAT, (-42, 42), (-42, -42)], - [FSMStrategy.GLOBAL_USER, (-42, 42), (42, 42)], - [FSMStrategy.USER_IN_CHAT, (42, 42), (42, 42)], - [FSMStrategy.CHAT, (42, 42), (42, 42)], - [FSMStrategy.GLOBAL_USER, (42, 42), (42, 42)], - ], - ) - def test_get_current_state_context(self, strategy, case, expected): - dp = Dispatcher(fsm_strategy=strategy) - chat_id, user_id = case - state = dp.current_state(chat_id=chat_id, user_id=user_id) - assert (state.chat_id, state.user_id) == expected diff --git a/tests/test_dispatcher/test_event/test_handler.py b/tests/test_dispatcher/test_event/test_handler.py index d7e6a1da..168dac59 100644 --- a/tests/test_dispatcher/test_event/test_handler.py +++ b/tests/test_dispatcher/test_event/test_handler.py @@ -5,7 +5,6 @@ import pytest from aiogram import F from aiogram.dispatcher.event.handler import CallableMixin, FilterObject, HandlerObject -from aiogram.dispatcher.filters import Text from aiogram.dispatcher.filters.base import BaseFilter from aiogram.dispatcher.handler.base import BaseHandler from aiogram.types import Update diff --git a/tests/test_dispatcher/test_filters/test_command.py b/tests/test_dispatcher/test_filters/test_command.py index 6eb24097..a3ea4756 100644 --- a/tests/test_dispatcher/test_filters/test_command.py +++ b/tests/test_dispatcher/test_filters/test_command.py @@ -1,10 +1,11 @@ import datetime import re -from typing import Match import pytest +from aiogram import F from aiogram.dispatcher.filters import Command, CommandObject +from aiogram.dispatcher.filters.command import CommandStart from aiogram.methods import GetMe from aiogram.types import Chat, Message, User from tests.mocked_bot import MockedBot @@ -18,45 +19,54 @@ class TestCommandFilter: assert cmd.commands[0] == "start" assert cmd == Command(commands=["start"]) + @pytest.mark.parametrize( + "text,command,result", + [ + ["/test@tbot", Command(commands=["test"], commands_prefix="/"), True], + ["!test", Command(commands=["test"], commands_prefix="/"), False], + ["/test@mention", Command(commands=["test"], commands_prefix="/"), False], + ["/tests", Command(commands=["test"], commands_prefix="/"), False], + ["/", Command(commands=["test"], commands_prefix="/"), False], + ["/ test", Command(commands=["test"], commands_prefix="/"), False], + ["", Command(commands=["test"], commands_prefix="/"), False], + [" ", Command(commands=["test"], commands_prefix="/"), False], + ["test", Command(commands=["test"], commands_prefix="/"), False], + [" test", Command(commands=["test"], commands_prefix="/"), False], + ["a", Command(commands=["test"], commands_prefix="/"), False], + ["/test@tbot some args", Command(commands=["test"]), True], + ["/test42@tbot some args", Command(commands=[re.compile(r"test(\d+)")]), True], + [ + "/test42@tbot some args", + Command(commands=[re.compile(r"test(\d+)")], command_magic=F.args == "some args"), + True, + ], + [ + "/test42@tbot some args", + Command(commands=[re.compile(r"test(\d+)")], command_magic=F.args == "test"), + False, + ], + ["/start test", CommandStart(), True], + ["/start", CommandStart(deep_link=True), False], + ["/start test", CommandStart(deep_link=True), True], + ["/start test", CommandStart(deep_link=True, deep_link_encoded=True), False], + ["/start dGVzdA", CommandStart(deep_link=True, deep_link_encoded=True), True], + ], + ) @pytest.mark.asyncio - async def test_parse_command(self, bot: MockedBot): - # TODO: parametrize + async def test_parse_command(self, bot: MockedBot, text: str, result: bool, command: Command): # TODO: test ignore case # TODO: test ignore mention bot.add_result_for( GetMe, ok=True, result=User(id=42, is_bot=True, first_name="The bot", username="tbot") ) - command = Command(commands=["test", re.compile(r"test(\d+)")], commands_prefix="/") - assert await command.parse_command("/test@tbot", bot) - assert not await command.parse_command("!test", bot) - assert not await command.parse_command("/test@mention", bot) - assert not await command.parse_command("/tests", bot) - assert not await command.parse_command("/", bot) - assert not await command.parse_command("/ test", bot) - assert not await command.parse_command("", bot) - assert not await command.parse_command(" ", bot) - assert not await command.parse_command("test", bot) - assert not await command.parse_command(" test", bot) - assert not await command.parse_command("a", bot) + message = Message( + message_id=0, text=text, chat=Chat(id=42, type="private"), date=datetime.datetime.now() + ) - result = await command.parse_command("/test@tbot some args", bot) - assert isinstance(result, dict) - assert "command" in result - assert isinstance(result["command"], CommandObject) - assert result["command"].command == "test" - assert result["command"].mention == "tbot" - assert result["command"].args == "some args" - - result = await command.parse_command("/test42@tbot some args", bot) - assert isinstance(result, dict) - assert "command" in result - assert isinstance(result["command"], CommandObject) - assert result["command"].command == "test42" - assert result["command"].mention == "tbot" - assert result["command"].args == "some args" - assert isinstance(result["command"].match, Match) + response = await command(message, bot) + assert bool(response) is result @pytest.mark.asyncio @pytest.mark.parametrize( diff --git a/tests/test_dispatcher/test_fsm/storage/test_memory.py b/tests/test_dispatcher/test_fsm/storage/test_memory.py deleted file mode 100644 index 2f587075..00000000 --- a/tests/test_dispatcher/test_fsm/storage/test_memory.py +++ /dev/null @@ -1,45 +0,0 @@ -import pytest - -from aiogram.dispatcher.fsm.storage.memory import MemoryStorage, MemoryStorageRecord - - -@pytest.fixture() -def storage(): - return MemoryStorage() - - -class TestMemoryStorage: - @pytest.mark.asyncio - async def test_set_state(self, storage: MemoryStorage): - assert await storage.get_state(chat_id=-42, user_id=42) is None - - await storage.set_state(chat_id=-42, user_id=42, state="state") - assert await storage.get_state(chat_id=-42, user_id=42) == "state" - - assert -42 in storage.storage - assert 42 in storage.storage[-42] - assert isinstance(storage.storage[-42][42], MemoryStorageRecord) - assert storage.storage[-42][42].state == "state" - - @pytest.mark.asyncio - async def test_set_data(self, storage: MemoryStorage): - assert await storage.get_data(chat_id=-42, user_id=42) == {} - - await storage.set_data(chat_id=-42, user_id=42, data={"foo": "bar"}) - assert await storage.get_data(chat_id=-42, user_id=42) == {"foo": "bar"} - - assert -42 in storage.storage - assert 42 in storage.storage[-42] - assert isinstance(storage.storage[-42][42], MemoryStorageRecord) - assert storage.storage[-42][42].data == {"foo": "bar"} - - @pytest.mark.asyncio - async def test_update_data(self, storage: MemoryStorage): - assert await storage.get_data(chat_id=-42, user_id=42) == {} - assert await storage.update_data(chat_id=-42, user_id=42, data={"foo": "bar"}) == { - "foo": "bar" - } - assert await storage.update_data(chat_id=-42, user_id=42, data={"baz": "spam"}) == { - "foo": "bar", - "baz": "spam", - } diff --git a/tests/test_dispatcher/test_fsm/storage/test_redis.py b/tests/test_dispatcher/test_fsm/storage/test_redis.py new file mode 100644 index 00000000..7b914a33 --- /dev/null +++ b/tests/test_dispatcher/test_fsm/storage/test_redis.py @@ -0,0 +1,21 @@ +import pytest + +from aiogram.dispatcher.fsm.storage.redis import RedisStorage +from tests.mocked_bot import MockedBot + + +@pytest.mark.redis +class TestRedisStorage: + @pytest.mark.parametrize( + "prefix_bot,result", + [ + [False, "fsm:-1:2"], + [True, "fsm:42:-1:2"], + [{42: "kaboom"}, "fsm:kaboom:-1:2"], + [lambda bot: "kaboom", "fsm:kaboom:-1:2"], + ], + ) + @pytest.mark.asyncio + async def test_generate_key(self, bot: MockedBot, redis_server, prefix_bot, result): + storage = RedisStorage.from_url(redis_server, prefix_bot=prefix_bot) + assert storage.generate_key(bot, -1, 2) == result diff --git a/tests/test_dispatcher/test_fsm/storage/test_storages.py b/tests/test_dispatcher/test_fsm/storage/test_storages.py new file mode 100644 index 00000000..fcb2deae --- /dev/null +++ b/tests/test_dispatcher/test_fsm/storage/test_storages.py @@ -0,0 +1,44 @@ +import pytest + +from aiogram.dispatcher.fsm.storage.base import BaseStorage +from tests.mocked_bot import MockedBot + + +@pytest.mark.parametrize( + "storage", + [pytest.lazy_fixture("redis_storage"), pytest.lazy_fixture("memory_storage")], +) +class TestStorages: + @pytest.mark.asyncio + async def test_lock(self, bot: MockedBot, storage: BaseStorage): + # TODO: ?!? + async with storage.lock(bot=bot, chat_id=-42, user_id=42): + assert True, "You are kidding me?" + + @pytest.mark.asyncio + async def test_set_state(self, bot: MockedBot, storage: BaseStorage): + assert await storage.get_state(bot=bot, chat_id=-42, user_id=42) is None + + await storage.set_state(bot=bot, chat_id=-42, user_id=42, state="state") + assert await storage.get_state(bot=bot, chat_id=-42, user_id=42) == "state" + await storage.set_state(bot=bot, chat_id=-42, user_id=42, state=None) + assert await storage.get_state(bot=bot, chat_id=-42, user_id=42) is None + + @pytest.mark.asyncio + async def test_set_data(self, bot: MockedBot, storage: BaseStorage): + assert await storage.get_data(bot=bot, chat_id=-42, user_id=42) == {} + + await storage.set_data(bot=bot, chat_id=-42, user_id=42, data={"foo": "bar"}) + assert await storage.get_data(bot=bot, chat_id=-42, user_id=42) == {"foo": "bar"} + await storage.set_data(bot=bot, chat_id=-42, user_id=42, data={}) + assert await storage.get_data(bot=bot, chat_id=-42, user_id=42) == {} + + @pytest.mark.asyncio + async def test_update_data(self, bot: MockedBot, storage: BaseStorage): + assert await storage.get_data(bot=bot, chat_id=-42, user_id=42) == {} + assert await storage.update_data( + bot=bot, chat_id=-42, user_id=42, data={"foo": "bar"} + ) == {"foo": "bar"} + assert await storage.update_data( + bot=bot, chat_id=-42, user_id=42, data={"baz": "spam"} + ) == {"foo": "bar", "baz": "spam"} diff --git a/tests/test_dispatcher/test_fsm/test_context.py b/tests/test_dispatcher/test_fsm/test_context.py index 6c444c44..fb98c423 100644 --- a/tests/test_dispatcher/test_fsm/test_context.py +++ b/tests/test_dispatcher/test_fsm/test_context.py @@ -2,27 +2,28 @@ import pytest from aiogram.dispatcher.fsm.context import FSMContext from aiogram.dispatcher.fsm.storage.memory import MemoryStorage +from tests.mocked_bot import MockedBot @pytest.fixture() -def state(): +def state(bot: MockedBot): storage = MemoryStorage() - ctx = storage.storage[-42][42] + ctx = storage.storage[bot][-42][42] ctx.state = "test" ctx.data = {"foo": "bar"} - return FSMContext(storage=storage, user_id=-42, chat_id=42) + return FSMContext(bot=bot, storage=storage, user_id=-42, chat_id=42) class TestFSMContext: @pytest.mark.asyncio - async def test_address_mapping(self): + async def test_address_mapping(self, bot: MockedBot): storage = MemoryStorage() - ctx = storage.storage[-42][42] + ctx = storage.storage[bot][-42][42] ctx.state = "test" ctx.data = {"foo": "bar"} - state = FSMContext(storage=storage, chat_id=-42, user_id=42) - state2 = FSMContext(storage=storage, chat_id=42, user_id=42) - state3 = FSMContext(storage=storage, chat_id=69, user_id=69) + state = FSMContext(bot=bot, storage=storage, chat_id=-42, user_id=42) + state2 = FSMContext(bot=bot, storage=storage, chat_id=42, user_id=42) + state3 = FSMContext(bot=bot, storage=storage, chat_id=69, user_id=69) assert await state.get_state() == "test" assert await state2.get_state() is None diff --git a/tests/test_dispatcher/test_handler/test_chosen_inline_result.py b/tests/test_dispatcher/test_handler/test_chosen_inline_result.py index 2e1f4045..ecbb363d 100644 --- a/tests/test_dispatcher/test_handler/test_chosen_inline_result.py +++ b/tests/test_dispatcher/test_handler/test_chosen_inline_result.py @@ -3,7 +3,7 @@ from typing import Any import pytest from aiogram.dispatcher.handler import ChosenInlineResultHandler -from aiogram.types import CallbackQuery, ChosenInlineResult, User +from aiogram.types import ChosenInlineResult, User class TestChosenInlineResultHandler: diff --git a/tests/test_dispatcher/test_handler/test_error.py b/tests/test_dispatcher/test_handler/test_error.py index f6e6b090..a83d96a4 100644 --- a/tests/test_dispatcher/test_handler/test_error.py +++ b/tests/test_dispatcher/test_handler/test_error.py @@ -2,16 +2,7 @@ from typing import Any import pytest -from aiogram.dispatcher.handler import ErrorHandler, PollHandler -from aiogram.types import ( - CallbackQuery, - InlineQuery, - Poll, - PollOption, - ShippingAddress, - ShippingQuery, - User, -) +from aiogram.dispatcher.handler import ErrorHandler class TestErrorHandler: diff --git a/tests/test_dispatcher/test_handler/test_inline_query.py b/tests/test_dispatcher/test_handler/test_inline_query.py index 100fccdd..99ed913f 100644 --- a/tests/test_dispatcher/test_handler/test_inline_query.py +++ b/tests/test_dispatcher/test_handler/test_inline_query.py @@ -3,7 +3,7 @@ from typing import Any import pytest from aiogram.dispatcher.handler import InlineQueryHandler -from aiogram.types import CallbackQuery, InlineQuery, User +from aiogram.types import InlineQuery, User class TestCallbackQueryHandler: diff --git a/tests/test_dispatcher/test_handler/test_poll.py b/tests/test_dispatcher/test_handler/test_poll.py index 172012d6..6fc23e9e 100644 --- a/tests/test_dispatcher/test_handler/test_poll.py +++ b/tests/test_dispatcher/test_handler/test_poll.py @@ -3,15 +3,7 @@ from typing import Any import pytest from aiogram.dispatcher.handler import PollHandler -from aiogram.types import ( - CallbackQuery, - InlineQuery, - Poll, - PollOption, - ShippingAddress, - ShippingQuery, - User, -) +from aiogram.types import Poll, PollOption class TestShippingQueryHandler: diff --git a/tests/test_dispatcher/test_handler/test_shipping_query.py b/tests/test_dispatcher/test_handler/test_shipping_query.py index 0d5aa578..0e938571 100644 --- a/tests/test_dispatcher/test_handler/test_shipping_query.py +++ b/tests/test_dispatcher/test_handler/test_shipping_query.py @@ -3,7 +3,7 @@ from typing import Any import pytest from aiogram.dispatcher.handler import ShippingQueryHandler -from aiogram.types import CallbackQuery, InlineQuery, ShippingAddress, ShippingQuery, User +from aiogram.types import ShippingAddress, ShippingQuery, User class TestShippingQueryHandler: diff --git a/tests/test_utils/test_auth_widget.py b/tests/test_utils/test_auth_widget.py new file mode 100644 index 00000000..a6071760 --- /dev/null +++ b/tests/test_utils/test_auth_widget.py @@ -0,0 +1,27 @@ +import pytest + +from aiogram.utils.auth_widget import check_integrity + +TOKEN = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + + +@pytest.fixture +def data(): + return { + "id": "42", + "first_name": "John", + "last_name": "Smith", + "username": "username", + "photo_url": "https://t.me/i/userpic/320/picname.jpg", + "auth_date": "1565810688", + "hash": "c303db2b5a06fe41d23a9b14f7c545cfc11dcc7473c07c9c5034ae60062461ce", + } + + +class TestCheckIntegrity: + def test_ok(self, data): + assert check_integrity(TOKEN, data) is True + + def test_fail(self, data): + data.pop("username") + assert check_integrity(TOKEN, data) is False diff --git a/tests/test_utils/test_deep_linking.py b/tests/test_utils/test_deep_linking.py new file mode 100644 index 00000000..de44725c --- /dev/null +++ b/tests/test_utils/test_deep_linking.py @@ -0,0 +1,94 @@ +import pytest +from async_lru import alru_cache + +from aiogram.utils.deep_linking import ( + create_start_link, + create_startgroup_link, + decode_payload, + encode_payload, +) +from tests.mocked_bot import MockedBot + +PAYLOADS = [ + "foo", + "AAbbCCddEEff1122334455", + "aaBBccDDeeFF5544332211", + -12345678901234567890, + 12345678901234567890, +] +WRONG_PAYLOADS = [ + "@BotFather", + "Some:special$characters#=", + "spaces spaces spaces", + 1234567890123456789.0, +] + + +@pytest.fixture(params=PAYLOADS, name="payload") +def payload_fixture(request): + return request.param + + +@pytest.fixture(params=WRONG_PAYLOADS, name="wrong_payload") +def wrong_payload_fixture(request): + return request.param + + +@pytest.fixture(autouse=True) +def get_bot_user_fixture(monkeypatch): + """Monkey patching of bot.me calling.""" + + @alru_cache() + async def get_bot_user_mock(self): + from aiogram.types import User + + return User( + id=12345678, + is_bot=True, + first_name="FirstName", + last_name="LastName", + username="username", + language_code="uk-UA", + ) + + monkeypatch.setattr(MockedBot, "me", get_bot_user_mock) + + +@pytest.mark.asyncio +class TestDeepLinking: + async def test_get_start_link(self, bot, payload): + link = await create_start_link(bot=bot, payload=payload) + assert link == f"https://t.me/username?start={payload}" + + async def test_wrong_symbols(self, bot, wrong_payload): + with pytest.raises(ValueError): + await create_start_link(bot, wrong_payload) + + async def test_get_startgroup_link(self, bot, payload): + link = await create_startgroup_link(bot, payload) + assert link == f"https://t.me/username?startgroup={payload}" + + async def test_filter_encode_and_decode(self, payload): + encoded = encode_payload(payload) + decoded = decode_payload(encoded) + assert decoded == str(payload) + + async def test_get_start_link_with_encoding(self, bot, wrong_payload): + # define link + link = await create_start_link(bot, wrong_payload, encode=True) + + # define reference link + encoded_payload = encode_payload(wrong_payload) + + assert link == f"https://t.me/username?start={encoded_payload}" + + async def test_64_len_payload(self, bot): + payload = "p" * 64 + link = await create_start_link(bot, payload) + assert link + + async def test_too_long_payload(self, bot): + payload = "p" * 65 + print(payload, len(payload)) + with pytest.raises(ValueError): + await create_start_link(bot, payload) diff --git a/tests/test_utils/test_link.py b/tests/test_utils/test_link.py new file mode 100644 index 00000000..4dbfe8a2 --- /dev/null +++ b/tests/test_utils/test_link.py @@ -0,0 +1,24 @@ +from typing import Any, Dict + +import pytest + +from aiogram.utils.link import create_telegram_link, create_tg_link + + +class TestLink: + @pytest.mark.parametrize( + "base,params,result", + [["user", dict(id=42), "tg://user?id=42"]], + ) + def test_create_tg_link(self, base: str, params: Dict[str, Any], result: str): + assert create_tg_link(base, **params) == result + + @pytest.mark.parametrize( + "base,params,result", + [ + ["username", dict(), "https://t.me/username"], + ["username", dict(start="test"), "https://t.me/username?start=test"], + ], + ) + def test_create_telegram_link(self, base: str, params: Dict[str, Any], result: str): + assert create_telegram_link(base, **params) == result diff --git a/tests/test_utils/test_markdown.py b/tests/test_utils/test_markdown.py index 12e44ccf..815b1c5d 100644 --- a/tests/test_utils/test_markdown.py +++ b/tests/test_utils/test_markdown.py @@ -35,7 +35,7 @@ class TestMarkdown: [hitalic, ("test", "test"), " ", "test test"], [code, ("test", "test"), " ", "`test test`"], [hcode, ("test", "test"), " ", "test test"], - [pre, ("test", "test"), " ", "```test test```"], + [pre, ("test", "test"), " ", "```\ntest test\n```"], [hpre, ("test", "test"), " ", "
test test
"], [underline, ("test", "test"), " ", "__\rtest test__\r"], [hunderline, ("test", "test"), " ", "test test"], diff --git a/tests/test_utils/test_text_decorations.py b/tests/test_utils/test_text_decorations.py index 6cb5105d..da171575 100644 --- a/tests/test_utils/test_text_decorations.py +++ b/tests/test_utils/test_text_decorations.py @@ -55,7 +55,7 @@ class TestTextDecoration: [markdown_decoration, MessageEntity(type="bold", offset=0, length=5), "*test*"], [markdown_decoration, MessageEntity(type="italic", offset=0, length=5), "_\rtest_\r"], [markdown_decoration, MessageEntity(type="code", offset=0, length=5), "`test`"], - [markdown_decoration, MessageEntity(type="pre", offset=0, length=5), "```test```"], + [markdown_decoration, MessageEntity(type="pre", offset=0, length=5), "```\ntest\n```"], [ markdown_decoration, MessageEntity(type="pre", offset=0, length=5, language="python"), From 5296724a0f77d072634aaf8f8c3f124604b0f506 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 15 Jun 2021 02:01:57 +0300 Subject: [PATCH 08/38] Small changes in tests --- tests/conftest.py | 8 +++++--- tests/test_dispatcher/test_fsm/storage/test_storages.py | 4 ++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 92dd97fe..7586f389 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -63,6 +63,8 @@ async def memory_storage(): def bot(): bot = MockedBot() token = Bot.set_current(bot) - yield bot - Bot.reset_current(token) - bot.me.invalidate(bot) + try: + yield bot + finally: + Bot.reset_current(token) + bot.me.invalidate(bot) diff --git a/tests/test_dispatcher/test_fsm/storage/test_storages.py b/tests/test_dispatcher/test_fsm/storage/test_storages.py index fcb2deae..cae3cb2c 100644 --- a/tests/test_dispatcher/test_fsm/storage/test_storages.py +++ b/tests/test_dispatcher/test_fsm/storage/test_storages.py @@ -42,3 +42,7 @@ class TestStorages: assert await storage.update_data( bot=bot, chat_id=-42, user_id=42, data={"baz": "spam"} ) == {"foo": "bar", "baz": "spam"} + assert await storage.get_data(bot=bot, chat_id=-42, user_id=42) == { + "foo": "bar", + "baz": "spam", + } From ac1f0efde86377327ee1ec3f8a9c50f38d438638 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 19 Jun 2021 01:16:51 +0300 Subject: [PATCH 09/38] Make endless long-polling --- aiogram/client/session/aiohttp.py | 18 ++++--- aiogram/dispatcher/dispatcher.py | 78 ++++++++++++++++++++++++---- aiogram/dispatcher/router.py | 6 +-- aiogram/utils/backoff.py | 80 +++++++++++++++++++++++++++++ aiogram/utils/exceptions/network.py | 4 +- aiogram/utils/exceptions/server.py | 5 ++ tests/test_utils/test_backoff.py | 76 +++++++++++++++++++++++++++ 7 files changed, 245 insertions(+), 22 deletions(-) create mode 100644 aiogram/utils/backoff.py create mode 100644 tests/test_utils/test_backoff.py diff --git a/aiogram/client/session/aiohttp.py b/aiogram/client/session/aiohttp.py index dfb297ff..a25e705c 100644 --- a/aiogram/client/session/aiohttp.py +++ b/aiogram/client/session/aiohttp.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from typing import ( TYPE_CHECKING, Any, @@ -14,11 +15,12 @@ from typing import ( cast, ) -from aiohttp import BasicAuth, ClientSession, FormData, TCPConnector +from aiohttp import BasicAuth, ClientError, ClientSession, FormData, TCPConnector from aiogram.methods import Request, TelegramMethod from ...methods.base import TelegramType +from ...utils.exceptions.network import NetworkError from .base import UNSET, BaseSession if TYPE_CHECKING: # pragma: no cover @@ -139,11 +141,15 @@ class AiohttpSession(BaseSession): url = self.api.api_url(token=bot.token, method=request.method) form = self.build_form_data(request) - async with session.post( - url, data=form, timeout=self.timeout if timeout is None else timeout - ) as resp: - raw_result = await resp.text() - + try: + async with session.post( + url, data=form, timeout=self.timeout if timeout is None else timeout + ) as resp: + raw_result = await resp.text() + except asyncio.TimeoutError: + raise NetworkError(method=call, message="Request timeout error") + except ClientError as e: + raise NetworkError(method=call, message=f"{type(e).__name__}: {e}") response = self.check_response(method=call, status_code=resp.status, content=raw_result) return cast(TelegramType, response.result) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 95c721a1..47c67096 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -10,7 +10,10 @@ from .. import loggers from ..client.bot import Bot from ..methods import GetUpdates, TelegramMethod from ..types import TelegramObject, Update, User +from ..utils.backoff import Backoff, BackoffConfig from ..utils.exceptions.base import TelegramAPIError +from ..utils.exceptions.network import NetworkError +from ..utils.exceptions.server import ServerError from .event.bases import UNHANDLED, SkipHandler from .event.telegram import TelegramEventObserver from .fsm.middleware import FSMContextMiddleware @@ -21,6 +24,8 @@ from .middlewares.error import ErrorsMiddleware from .middlewares.user_context import UserContextMiddleware from .router import Router +DEFAULT_BACKOFF_CONFIG = BackoffConfig(min_delay=1.0, max_delay=5.0, factor=1.3, jitter=0.1) + class Dispatcher(Router): """ @@ -63,7 +68,7 @@ class Dispatcher(Router): @property def parent_router(self) -> None: """ - Dispatcher has no parent router + Dispatcher has no parent router and can't be included to any other routers or dispatchers :return: """ @@ -82,6 +87,7 @@ class Dispatcher(Router): async def feed_update(self, bot: Bot, update: Update, **kwargs: Any) -> Any: """ Main entry point for incoming updates + Response of this method can be used as Webhook response :param bot: :param update: @@ -90,7 +96,7 @@ class Dispatcher(Router): handled = False start_time = loop.time() - Bot.set_current(bot) + token = Bot.set_current(bot) try: response = await self.update.trigger(update, bot=bot, **kwargs) handled = response is not UNHANDLED @@ -105,6 +111,7 @@ class Dispatcher(Router): duration, bot.id, ) + Bot.reset_current(token) async def feed_raw_update(self, bot: Bot, update: Dict[str, Any], **kwargs: Any) -> Any: """ @@ -119,20 +126,50 @@ class Dispatcher(Router): @classmethod async def _listen_updates( - cls, bot: Bot, polling_timeout: int = 30 + cls, + bot: Bot, + polling_timeout: int = 30, + backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, ) -> AsyncGenerator[Update, None]: """ - Infinity updates reader + Endless updates reader with correctly handling any server-side or connection errors. + + So you may not worry that the polling will stop working. """ + backoff = Backoff(config=backoff_config) get_updates = GetUpdates(timeout=polling_timeout) kwargs = {} if bot.session.timeout: + # Request timeout can be lower than session timeout ant that's OK. + # To prevent false-positive TimeoutError we should wait longer than polling timeout kwargs["request_timeout"] = int(bot.session.timeout + polling_timeout) while True: - # TODO: Skip restarting telegram error - updates = await bot(get_updates, **kwargs) + try: + updates = await bot(get_updates, **kwargs) + except (NetworkError, ServerError) as e: + # In cases when Telegram Bot API was inaccessible don't need to stop polling process + # because some of developers can't make auto-restarting of the script + loggers.dispatcher.error("Failed to fetch updates - %s: %s", type(e).__name__, e) + # And also backoff timeout is best practice to retry any network activity + loggers.dispatcher.warning( + "Sleep for %f seconds and try again... (tryings = %d, bot id = %d)", + backoff.next_delay, + backoff.counter, + bot.id, + ) + await backoff.asleep() + continue + + # In case when network connection was fixed let's reset the backoff + # to initial value and then process updates + backoff.reset() + for update in updates: yield update + # The getUpdates method returns the earliest 100 unconfirmed updates. + # To confirm an update, use the offset parameter when calling getUpdates + # All updates with update_id less than or equal to offset will be marked as confirmed on the server + # and will no longer be returned. get_updates.offset = update.update_id + 1 async def _listen_update(self, update: Update, **kwargs: Any) -> Any: @@ -255,7 +292,12 @@ class Dispatcher(Router): return True # because update was processed but unsuccessful async def _polling( - self, bot: Bot, polling_timeout: int = 30, handle_as_tasks: bool = True, **kwargs: Any + self, + bot: Bot, + polling_timeout: int = 30, + handle_as_tasks: bool = True, + backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, + **kwargs: Any, ) -> None: """ Internal polling process @@ -264,7 +306,9 @@ class Dispatcher(Router): :param kwargs: :return: """ - async for update in self._listen_updates(bot, polling_timeout=polling_timeout): + async for update in self._listen_updates( + bot, polling_timeout=polling_timeout, backoff_config=backoff_config + ): handle_update = self._process_update(bot=bot, update=update, **kwargs) if handle_as_tasks: asyncio.create_task(handle_update) @@ -348,7 +392,12 @@ class Dispatcher(Router): return None async def start_polling( - self, *bots: Bot, polling_timeout: int = 10, handle_as_tasks: bool = True, **kwargs: Any + self, + *bots: Bot, + polling_timeout: int = 10, + handle_as_tasks: bool = True, + backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, + **kwargs: Any, ) -> None: """ Polling runner @@ -357,6 +406,7 @@ class Dispatcher(Router): :param polling_timeout: :param handle_as_tasks: :param kwargs: + :param backoff_config: :return: """ async with self._running_lock: # Prevent to run this method twice at a once @@ -376,6 +426,7 @@ class Dispatcher(Router): bot=bot, handle_as_tasks=handle_as_tasks, polling_timeout=polling_timeout, + backoff_config=backoff_config, **kwargs, ) ) @@ -387,13 +438,19 @@ class Dispatcher(Router): await self.emit_shutdown(**workflow_data) def run_polling( - self, *bots: Bot, polling_timeout: int = 30, handle_as_tasks: bool = True, **kwargs: Any + self, + *bots: Bot, + polling_timeout: int = 30, + handle_as_tasks: bool = True, + backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, + **kwargs: Any, ) -> None: """ Run many bots with polling :param bots: Bot instances :param polling_timeout: Poling timeout + :param backoff_config: :param handle_as_tasks: Run task for each event and no wait result :param kwargs: contextual data :return: @@ -405,6 +462,7 @@ class Dispatcher(Router): **kwargs, polling_timeout=polling_timeout, handle_as_tasks=handle_as_tasks, + backoff_config=backoff_config, ) ) except (KeyboardInterrupt, SystemExit): # pragma: no cover diff --git a/aiogram/dispatcher/router.py b/aiogram/dispatcher/router.py index 2e659c7e..b61afc68 100644 --- a/aiogram/dispatcher/router.py +++ b/aiogram/dispatcher/router.py @@ -118,9 +118,7 @@ class Router: :param router: """ if not isinstance(router, Router): - raise ValueError( - f"router should be instance of Router not {type(router).__class__.__name__}" - ) + raise ValueError(f"router should be instance of Router not {type(router).__name__!r}") if self._parent_router: raise RuntimeError(f"Router is already attached to {self._parent_router!r}") if self == router: @@ -133,7 +131,7 @@ class Router: if not self.use_builtin_filters and parent.use_builtin_filters: warnings.warn( - f"{self.__class__.__name__}(use_builtin_filters=False) has no effect" + 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, diff --git a/aiogram/utils/backoff.py b/aiogram/utils/backoff.py new file mode 100644 index 00000000..6cc3c5a1 --- /dev/null +++ b/aiogram/utils/backoff.py @@ -0,0 +1,80 @@ +import asyncio +import time +from dataclasses import dataclass +from random import normalvariate + + +@dataclass(frozen=True) +class BackoffConfig: + min_delay: float + max_delay: float + factor: float + jitter: float + + def __post_init__(self): + if self.max_delay <= self.min_delay: + raise ValueError("`max_delay` should be greater than `min_delay`") + if self.factor <= 1: + raise ValueError("`factor` should be greater than 1") + + +class Backoff: + def __init__(self, config: BackoffConfig) -> None: + self.config = config + self._next_delay = config.min_delay + self._current_delay = 0.0 + self._counter = 0 + + def __iter__(self): + return self + + @property + def min_delay(self) -> float: + return self.config.min_delay + + @property + def max_delay(self) -> float: + return self.config.max_delay + + @property + def factor(self) -> float: + return self.config.factor + + @property + def jitter(self) -> float: + return self.config.jitter + + @property + def next_delay(self) -> float: + return self._next_delay + + @property + def current_delay(self) -> float: + return self._current_delay + + @property + def counter(self) -> int: + return self._counter + + def sleep(self) -> None: + time.sleep(next(self)) + + async def asleep(self) -> None: + await asyncio.sleep(next(self)) + + def _calculate_next(self, value: float) -> float: + return normalvariate(min(value * self.factor, self.max_delay), self.jitter) + + def __next__(self) -> float: + self._current_delay = self._next_delay + self._next_delay = self._calculate_next(self._next_delay) + self._counter += 1 + return self._current_delay + + def reset(self) -> None: + self._current_delay = 0.0 + self._counter = 0 + self._next_delay = self.min_delay + + def __str__(self) -> str: + return f"Backoff(tryings={self._counter}, current_delay={self._current_delay}, next_delay={self._next_delay})" diff --git a/aiogram/utils/exceptions/network.py b/aiogram/utils/exceptions/network.py index 067b1a80..90a31041 100644 --- a/aiogram/utils/exceptions/network.py +++ b/aiogram/utils/exceptions/network.py @@ -1,5 +1,5 @@ -from aiogram.utils.exceptions.base import DetailedTelegramAPIError +from aiogram.utils.exceptions.base import TelegramAPIError -class NetworkError(DetailedTelegramAPIError): +class NetworkError(TelegramAPIError): pass diff --git a/aiogram/utils/exceptions/server.py b/aiogram/utils/exceptions/server.py index e69de29b..e9b5f365 100644 --- a/aiogram/utils/exceptions/server.py +++ b/aiogram/utils/exceptions/server.py @@ -0,0 +1,5 @@ +from aiogram.utils.exceptions.base import TelegramAPIError + + +class ServerError(TelegramAPIError): + pass diff --git a/tests/test_utils/test_backoff.py b/tests/test_utils/test_backoff.py new file mode 100644 index 00000000..5c9f6a20 --- /dev/null +++ b/tests/test_utils/test_backoff.py @@ -0,0 +1,76 @@ +import pytest + +from aiogram.utils.backoff import Backoff, BackoffConfig + +BACKOFF_CONFIG = BackoffConfig(min_delay=0.1, max_delay=1.0, factor=2.0, jitter=0.0) + + +class TestBackoffConfig: + @pytest.mark.parametrize( + "kwargs", + [ + dict(min_delay=1.0, max_delay=1.0, factor=2.0, jitter=0.1), # equals min and max + dict(min_delay=1.0, max_delay=1.0, factor=1.0, jitter=0.1), # factor == 1 + dict(min_delay=1.0, max_delay=2.0, factor=0.5, jitter=0.1), # factor < 1 + dict(min_delay=2.0, max_delay=1.0, factor=2.0, jitter=0.1), # min > max + ], + ) + def test_incorrect_post_init(self, kwargs): + with pytest.raises(ValueError): + BackoffConfig(**kwargs) + + @pytest.mark.parametrize( + "kwargs", + [dict(min_delay=1.0, max_delay=2.0, factor=1.2, jitter=0.1)], + ) + def test_correct_post_init(self, kwargs): + assert BackoffConfig(**kwargs) + + +class TestBackoff: + def test_aliases(self): + backoff = Backoff(config=BACKOFF_CONFIG) + assert backoff.min_delay == BACKOFF_CONFIG.min_delay + assert backoff.max_delay == BACKOFF_CONFIG.max_delay + assert backoff.factor == BACKOFF_CONFIG.factor + assert backoff.jitter == BACKOFF_CONFIG.jitter + + def test_calculation(self): + backoff = Backoff(config=BACKOFF_CONFIG) + index = 0 + + iterable = iter(backoff) + assert iterable == backoff + + assert backoff.current_delay == 0.0 + assert backoff.next_delay == 0.1 + + while (val := next(backoff)) < 1: + index += 1 + assert val in {0.1, 0.2, 0.4, 0.8} + + assert next(backoff) == 1 + assert next(backoff) == 1 + assert index == 4 + + assert backoff.current_delay == 1 + assert backoff.next_delay == 1 + assert backoff.counter == 7 # 4+1 in while loop + 2 after loop + + assert str(backoff) == "Backoff(tryings=7, current_delay=1.0, next_delay=1.0)" + + backoff.reset() + assert backoff.current_delay == 0.0 + assert backoff.next_delay == 0.1 + assert backoff.counter == 0 + + def test_sleep(self): + backoff = Backoff(config=BACKOFF_CONFIG) + backoff.sleep() + assert backoff.counter == 1 + + @pytest.mark.asyncio + async def test_asleep(self): + backoff = Backoff(config=BACKOFF_CONFIG) + await backoff.asleep() + assert backoff.counter == 1 From 5790c431d015bdb17f2d1f62db07aeb13a20ab15 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 19 Jun 2021 02:16:40 +0300 Subject: [PATCH 10/38] Fixed typing --- aiogram/utils/backoff.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/aiogram/utils/backoff.py b/aiogram/utils/backoff.py index 6cc3c5a1..f0b1b578 100644 --- a/aiogram/utils/backoff.py +++ b/aiogram/utils/backoff.py @@ -11,7 +11,7 @@ class BackoffConfig: factor: float jitter: float - def __post_init__(self): + def __post_init__(self) -> None: if self.max_delay <= self.min_delay: raise ValueError("`max_delay` should be greater than `min_delay`") if self.factor <= 1: @@ -25,7 +25,7 @@ class Backoff: self._current_delay = 0.0 self._counter = 0 - def __iter__(self): + def __iter__(self) -> "Backoff": return self @property From 5780d1840eb097ce36bddb8759cc711d43123d4e Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 19 Jun 2021 02:42:56 +0300 Subject: [PATCH 11/38] Bump version --- aiogram/__init__.py | 2 +- aiogram/types/downloadable.py | 5 +---- poetry.lock | 14 +++++++------- pyproject.toml | 24 ++++++++++++------------ scripts/bump_versions.py | 15 ++------------- 5 files changed, 23 insertions(+), 37 deletions(-) diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 639e68c9..ca17b233 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -37,5 +37,5 @@ __all__ = ( "md", ) -__version__ = "3.0.0-alpha.8" +__version__ = "3.0.0a10" __api_version__ = "5.2" diff --git a/aiogram/types/downloadable.py b/aiogram/types/downloadable.py index 48525f65..be808293 100644 --- a/aiogram/types/downloadable.py +++ b/aiogram/types/downloadable.py @@ -1,7 +1,4 @@ -try: - from typing import Protocol -except ImportError: # pragma: no cover - from typing_extensions import Protocol # type: ignore +from typing import Protocol class Downloadable(Protocol): diff --git a/poetry.lock b/poetry.lock index cb05c237..4e6bcb7d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -298,18 +298,18 @@ pygments = ">=2.2.0" [[package]] name = "furo" -version = "2020.12.30b24" +version = "2021.6.18b36" description = "A clean customisable Sphinx documentation theme." category = "main" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] beautifulsoup4 = "*" -sphinx = ">=3.0,<4.0" +sphinx = ">=3.0,<5.0" [package.extras] -doc = ["myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"] +doc = ["myst-parser", "sphinx-copybutton", "sphinx-inline-tabs", "docutils (!=0.17)"] test = ["pytest", "pytest-cov", "pytest-xdist"] [[package]] @@ -1201,7 +1201,7 @@ redis = ["aioredis"] [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "362a6caf937b1c457599cbf2cd5d000eab4cac529bd7fe8c257ae713ebc63331" +content-hash = "ef3571030ff35c2a05e01dca86e9347239e98ad0f45bed6f5d9a73121013f376" [metadata.files] aiofiles = [ @@ -1402,8 +1402,8 @@ flake8-html = [ {file = "flake8_html-0.4.1-py2.py3-none-any.whl", hash = "sha256:17324eb947e7006807e4184ee26953e67baf421b3cf9e646a38bfec34eec5a94"}, ] furo = [ - {file = "furo-2020.12.30b24-py3-none-any.whl", hash = "sha256:251dadee4dee96dddf2dc9b5243b88212e16923f53397bf12bc98574918bda41"}, - {file = "furo-2020.12.30b24.tar.gz", hash = "sha256:30171899c9c06d692a778e6daf6cb2e5cbb05efc6006e1692e5e776007dc8a8c"}, + {file = "furo-2021.6.18b36-py3-none-any.whl", hash = "sha256:a4c00634afeb5896a34d141a5dffb62f20c5eca7831b78269823a8cd8b09a5e4"}, + {file = "furo-2021.6.18b36.tar.gz", hash = "sha256:46a30bc597a9067088d39d730e7d9bf6c1a1d71967e4af062f796769f66b3bdb"}, ] identify = [ {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, diff --git a/pyproject.toml b/pyproject.toml index 4befda3e..cac43cf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aiogram" -version = "3.0.0-alpha.9" +version = "3.0.0-alpha.10" description = "Modern and fully asynchronous framework for Telegram Bot API" authors = ["Alex Root Junior "] license = "MIT" @@ -39,15 +39,14 @@ aiofiles = "^0.6.0" async_lru = "^1.0.2" aiohttp-socks = { version = "^0.5.5", optional = true } aioredis = { version = "^2.0.0a1", allow-prereleases = true, optional = true } -typing-extensions = { version = "^3.7.4", python = "<3.8" } magic-filter = { version = "1.0.0a1", allow-prereleases = true } -sphinx = { version = "^3.1.0", optional = true } -sphinx-intl = { version = "^2.0.1", optional = true } -sphinx-autobuild = { version = "^2020.9.1", optional = true } -sphinx-copybutton = { version = "^0.3.1", optional = true } -furo = { version = "^2020.11.15-beta.17", optional = true } -sphinx-prompt = { version = "^1.3.0", optional = true } -Sphinx-Substitution-Extensions = { version = "^2020.9.30", optional = true } +# sphinx = { version = "^3.1.0", optional = true } +# sphinx-intl = { version = "^2.0.1", optional = true } +# sphinx-autobuild = { version = "^2020.9.1", optional = true } +# sphinx-copybutton = { version = "^0.3.1", optional = true } +# furo = { version = "^2021.6.18-beta.36", optional = true } +# sphinx-prompt = { version = "^1.3.0", optional = true } +# Sphinx-Substitution-Extensions = { version = "^2020.9.30", optional = true } [tool.poetry.dev-dependencies] aiohttp-socks = "^0.5" @@ -62,8 +61,9 @@ mypy = "^0.812" pytest = "^6.2.3" pytest-html = "^3.1.1" pytest-asyncio = "^0.15.1" -pytest-mypy = "^0.8.1" +pytest-lazy-fixture = "^0.6.3" pytest-mock = "^3.6.0" +pytest-mypy = "^0.8.1" pytest-cov = "^2.11.1" aresponses = "^2.1.4" asynctest = "^0.13.0" @@ -78,10 +78,10 @@ sphinx = "^3.1.0" sphinx-intl = "^2.0.1" sphinx-autobuild = "^2020.9.1" sphinx-copybutton = "^0.3.1" -furo = "^2020.11.15-beta.17" +furo = "^2021.6.18-beta.36" sphinx-prompt = "^1.3.0" Sphinx-Substitution-Extensions = "^2020.9.30" -pytest-lazy-fixture = "^0.6.3" + [tool.poetry.extras] fast = ["uvloop"] diff --git a/scripts/bump_versions.py b/scripts/bump_versions.py index 28072c9c..68547d12 100644 --- a/scripts/bump_versions.py +++ b/scripts/bump_versions.py @@ -3,7 +3,7 @@ from pathlib import Path import toml -BASE_PATTERN = r'({variable} = ")[a-z0-9.+]+(")' +BASE_PATTERN = r'({variable} = ").+(")' PACKAGE_VERSION = re.compile(BASE_PATTERN.format(variable="__version__")) API_VERSION = re.compile(BASE_PATTERN.format(variable="__api_version__")) API_VERSION_BADGE = re.compile(r"(API-)[\d.]+(-blue\.svg)") @@ -62,23 +62,13 @@ def write_readme(package_version: str, api_version: str) -> None: def write_docs_index(package_version: str, api_version: str) -> None: - path = Path.cwd() / "docs2" / "index.rst" + path = Path.cwd() / "docs" / "index.rst" content = path.read_text() content = replace_line(content, API_VERSION_BADGE, api_version) print(f"Write {path}") path.write_text(content) -def write_docs_meta(package_version: str, api_version: str) -> None: - api_meta = Path.cwd() / "docs" / "_api_version.md" - package_meta = Path.cwd() / "docs" / "_package_version.md" - - print(f"Write {api_meta}") - api_meta.write_text(api_version + "\n") - print(f"Write {package_meta}") - package_meta.write_text(package_version + "\n") - - def main(): package_version = get_package_version() api_version = get_telegram_api_version() @@ -86,7 +76,6 @@ def main(): print(f"Package version: {package_version}") print(f"Telegram Bot API version: {api_version}") write_package_meta(package_version=package_version, api_version=api_version) - write_docs_meta(package_version=package_version, api_version=api_version) write_readme(package_version=package_version, api_version=api_version) write_docs_index(package_version=package_version, api_version=api_version) From 602fed00ff6bf7866000b116d32cef376dafef44 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 19 Jun 2021 02:43:55 +0300 Subject: [PATCH 12/38] Oops. Enable sphinx dependencies --- pyproject.toml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cac43cf8..c005ec52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,13 +40,13 @@ async_lru = "^1.0.2" aiohttp-socks = { version = "^0.5.5", optional = true } aioredis = { version = "^2.0.0a1", allow-prereleases = true, optional = true } magic-filter = { version = "1.0.0a1", allow-prereleases = true } -# sphinx = { version = "^3.1.0", optional = true } -# sphinx-intl = { version = "^2.0.1", optional = true } -# sphinx-autobuild = { version = "^2020.9.1", optional = true } -# sphinx-copybutton = { version = "^0.3.1", optional = true } -# furo = { version = "^2021.6.18-beta.36", optional = true } -# sphinx-prompt = { version = "^1.3.0", optional = true } -# Sphinx-Substitution-Extensions = { version = "^2020.9.30", optional = true } +sphinx = { version = "^3.1.0", optional = true } +sphinx-intl = { version = "^2.0.1", optional = true } +sphinx-autobuild = { version = "^2020.9.1", optional = true } +sphinx-copybutton = { version = "^0.3.1", optional = true } +furo = { version = "^2021.6.18-beta.36", optional = true } +sphinx-prompt = { version = "^1.3.0", optional = true } +Sphinx-Substitution-Extensions = { version = "^2020.9.30", optional = true } [tool.poetry.dev-dependencies] aiohttp-socks = "^0.5" From eee6589a2c4fc59dce51b9650e1a42f9c18a13db Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 19 Jun 2021 02:56:04 +0300 Subject: [PATCH 13/38] Change Python version in docs index --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index e6d2b918..1fa0678e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -42,7 +42,7 @@ aiogram :alt: Codecov **aiogram** modern and fully asynchronous framework for -`Telegram Bot API `_ written in Python 3.7 with +`Telegram Bot API `_ written in Python 3.8 with `asyncio `_ and `aiohttp `_. From 125fc22ff941e76101e90f834f55738cf2fb0d4b Mon Sep 17 00:00:00 2001 From: Forevka <32968153+Forevka@users.noreply.github.com> Date: Mon, 5 Jul 2021 00:41:27 +0200 Subject: [PATCH 14/38] Added ability to specify which update bot need to receive and process while using polling mode (#617) * provide allowed_updates in polling mode --- .gitignore | 3 +- aiogram/dispatcher/dispatcher.py | 16 ++++- aiogram/utils/handlers_in_use.py | 28 ++++++++ examples/specify_updates.py | 87 ++++++++++++++++++++++++ tests/test_dispatcher/test_dispatcher.py | 54 +++++++++++++++ 5 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 aiogram/utils/handlers_in_use.py create mode 100644 examples/specify_updates.py diff --git a/.gitignore b/.gitignore index 1f3e1971..4ffb8359 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ aiogram/_meta.py .coverage reports -dev/ \ No newline at end of file +dev/ +.venv/ diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 47c67096..109bb920 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -4,7 +4,7 @@ import asyncio import contextvars import warnings from asyncio import CancelledError, Future, Lock -from typing import Any, AsyncGenerator, Dict, Optional, Union +from typing import Any, AsyncGenerator, Dict, List, Optional, Union from .. import loggers from ..client.bot import Bot @@ -130,6 +130,7 @@ class Dispatcher(Router): bot: Bot, polling_timeout: int = 30, backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, + allowed_updates: Optional[List[str]] = None, ) -> AsyncGenerator[Update, None]: """ Endless updates reader with correctly handling any server-side or connection errors. @@ -137,7 +138,7 @@ class Dispatcher(Router): So you may not worry that the polling will stop working. """ backoff = Backoff(config=backoff_config) - get_updates = GetUpdates(timeout=polling_timeout) + get_updates = GetUpdates(timeout=polling_timeout, allowed_updates=allowed_updates) kwargs = {} if bot.session.timeout: # Request timeout can be lower than session timeout ant that's OK. @@ -297,6 +298,7 @@ class Dispatcher(Router): polling_timeout: int = 30, handle_as_tasks: bool = True, backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, + allowed_updates: Optional[List[str]] = None, **kwargs: Any, ) -> None: """ @@ -307,7 +309,10 @@ class Dispatcher(Router): :return: """ async for update in self._listen_updates( - bot, polling_timeout=polling_timeout, backoff_config=backoff_config + bot, + polling_timeout=polling_timeout, + backoff_config=backoff_config, + allowed_updates=allowed_updates, ): handle_update = self._process_update(bot=bot, update=update, **kwargs) if handle_as_tasks: @@ -397,6 +402,7 @@ class Dispatcher(Router): polling_timeout: int = 10, handle_as_tasks: bool = True, backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, + allowed_updates: Optional[List[str]] = None, **kwargs: Any, ) -> None: """ @@ -427,6 +433,7 @@ class Dispatcher(Router): handle_as_tasks=handle_as_tasks, polling_timeout=polling_timeout, backoff_config=backoff_config, + allowed_updates=allowed_updates, **kwargs, ) ) @@ -443,6 +450,7 @@ class Dispatcher(Router): polling_timeout: int = 30, handle_as_tasks: bool = True, backoff_config: BackoffConfig = DEFAULT_BACKOFF_CONFIG, + allowed_updates: Optional[List[str]] = None, **kwargs: Any, ) -> None: """ @@ -452,6 +460,7 @@ class Dispatcher(Router): :param polling_timeout: Poling timeout :param backoff_config: :param handle_as_tasks: Run task for each event and no wait result + :param allowed_updates: List of the update types you want your bot to receive :param kwargs: contextual data :return: """ @@ -463,6 +472,7 @@ class Dispatcher(Router): polling_timeout=polling_timeout, handle_as_tasks=handle_as_tasks, backoff_config=backoff_config, + allowed_updates=allowed_updates, ) ) except (KeyboardInterrupt, SystemExit): # pragma: no cover diff --git a/aiogram/utils/handlers_in_use.py b/aiogram/utils/handlers_in_use.py new file mode 100644 index 00000000..c1816476 --- /dev/null +++ b/aiogram/utils/handlers_in_use.py @@ -0,0 +1,28 @@ +from itertools import chain +from typing import List, cast + +from aiogram.dispatcher.dispatcher import Dispatcher +from aiogram.dispatcher.router import Router + +INTERNAL_HANDLERS = [ + "update", + "error", +] + + +def get_handlers_in_use( + dispatcher: Dispatcher, handlers_to_skip: List[str] = INTERNAL_HANDLERS +) -> List[str]: + handlers_in_use: List[str] = [] + + for router in [dispatcher.sub_routers, dispatcher]: + if isinstance(router, list): + if router: + handlers_in_use.extend(chain(*list(map(get_handlers_in_use, router)))) + else: + router = cast(Router, router) + for update_name, observer in router.observers.items(): + if observer.handlers and update_name not in [*handlers_to_skip, *handlers_in_use]: + handlers_in_use.append(update_name) + + return handlers_in_use diff --git a/examples/specify_updates.py b/examples/specify_updates.py new file mode 100644 index 00000000..33fdd093 --- /dev/null +++ b/examples/specify_updates.py @@ -0,0 +1,87 @@ +from aiogram.types.inline_keyboard_button import InlineKeyboardButton +from aiogram.types.inline_keyboard_markup import InlineKeyboardMarkup +from aiogram.dispatcher.router import Router +from aiogram.utils.handlers_in_use import get_handlers_in_use +import logging + +from aiogram import Bot, Dispatcher +from aiogram.types import Message, ChatMemberUpdated, CallbackQuery + +TOKEN = "6wo" +dp = Dispatcher() + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +@dp.message(commands={"start"}) +async def command_start_handler(message: Message) -> None: + """ + This handler receive messages with `/start` command + """ + + await message.answer( + f"Hello, {message.from_user.full_name}!", + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[[InlineKeyboardButton(text="Tap me, bro", callback_data="*")]] + ), + ) + + +@dp.chat_member() +async def chat_member_update(chat_member: ChatMemberUpdated, bot: Bot) -> None: + await bot.send_message( + chat_member.chat.id, + "Member {chat_member.from_user.id} was changed " + + f"from {chat_member.old_chat_member.is_chat_member} to {chat_member.new_chat_member.is_chat_member}", + ) + + +# this router will use only callback_query updates +sub_router = Router() + + +@sub_router.callback_query() +async def callback_tap_me(callback_query: CallbackQuery) -> None: + await callback_query.answer("Yeah good, now i'm fine") + + +# this router will use only edited_message updates +sub_sub_router = Router() + + +@sub_sub_router.edited_message() +async def callback_tap_me(edited_message: Message) -> None: + await edited_message.reply("Message was edited, big brother watch you") + + +# this router will use only my_chat_member updates +deep_dark_router = Router() + + +@deep_dark_router.my_chat_member() +async def my_chat_member_change(chat_member: ChatMemberUpdated, bot: Bot) -> None: + await bot.send_message( + chat_member.chat.id, + "Member was changed from " + + f"{chat_member.old_chat_member.is_chat_member} to {chat_member.new_chat_member.is_chat_member}", + ) + + +def main() -> None: + # Initialize Bot instance with an default parse mode which will be passed to all API calls + bot = Bot(TOKEN, parse_mode="HTML") + + sub_router.include_router(deep_dark_router) + + dp.include_router(sub_router) + dp.include_router(sub_sub_router) + + useful_updates = get_handlers_in_use(dp) + + # And the run events dispatching + dp.run_polling(bot, allowed_updates=useful_updates) + + +if __name__ == "__main__": + main() diff --git a/tests/test_dispatcher/test_dispatcher.py b/tests/test_dispatcher/test_dispatcher.py index 37bbf634..5f7a2f62 100644 --- a/tests/test_dispatcher/test_dispatcher.py +++ b/tests/test_dispatcher/test_dispatcher.py @@ -28,6 +28,7 @@ from aiogram.types import ( Update, User, ) +from aiogram.utils.handlers_in_use import get_handlers_in_use from tests.mocked_bot import MockedBot try: @@ -659,3 +660,56 @@ class TestDispatcher: log_records = [rec.message for rec in caplog.records] assert "Cause exception while process update" in log_records[0] + + def test_specify_updates_calculation(self): + def simple_msg_handler() -> None: + ... + + def simple_callback_query_handler() -> None: + ... + + def simple_poll_handler() -> None: + ... + + def simple_edited_msg_handler() -> None: + ... + + dispatcher = Dispatcher() + dispatcher.message.register(simple_msg_handler) + + router1 = Router() + router1.callback_query.register(simple_callback_query_handler) + + router2 = Router() + router2.poll.register(simple_poll_handler) + + router21 = Router() + router21.edited_message.register(simple_edited_msg_handler) + + useful_updates1 = get_handlers_in_use(dispatcher) + + assert sorted(useful_updates1) == sorted(["message"]) + + dispatcher.include_router(router1) + + useful_updates2 = get_handlers_in_use(dispatcher) + + assert sorted(useful_updates2) == sorted(["message", "callback_query"]) + + dispatcher.include_router(router2) + + useful_updates3 = get_handlers_in_use(dispatcher) + + assert sorted(useful_updates3) == sorted(["message", "callback_query", "poll"]) + + router2.include_router(router21) + + useful_updates4 = get_handlers_in_use(dispatcher) + + assert sorted(useful_updates4) == sorted( + ["message", "callback_query", "poll", "edited_message"] + ) + + useful_updates5 = get_handlers_in_use(router2) + + assert sorted(useful_updates5) == sorted(["poll", "edited_message"]) From 4599913e185efe55770926a7460c73738b8c3dae Mon Sep 17 00:00:00 2001 From: evgfilim1 Date: Sun, 18 Jul 2021 16:10:51 +0500 Subject: [PATCH 15/38] Fix not all args are passed to handler function invocation (#633) --- aiogram/dispatcher/event/handler.py | 4 +++- tests/test_dispatcher/test_event/test_handler.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/aiogram/dispatcher/event/handler.py b/aiogram/dispatcher/event/handler.py index 63f5130b..37a9ecb7 100644 --- a/aiogram/dispatcher/event/handler.py +++ b/aiogram/dispatcher/event/handler.py @@ -32,7 +32,9 @@ class CallableMixin: if self.spec.varkw: return kwargs - return {k: v for k, v in kwargs.items() if k in self.spec.args} + return { + k: v for k, v in kwargs.items() if k in self.spec.args or k in self.spec.kwonlyargs + } async def call(self, *args: Any, **kwargs: Any) -> Any: wrapped = partial(self.callback, *args, **self._prepare_kwargs(kwargs)) diff --git a/tests/test_dispatcher/test_event/test_handler.py b/tests/test_dispatcher/test_event/test_handler.py index 168dac59..6bce7d98 100644 --- a/tests/test_dispatcher/test_event/test_handler.py +++ b/tests/test_dispatcher/test_event/test_handler.py @@ -22,6 +22,10 @@ async def callback3(foo: int, **kwargs): return locals() +async def callback4(foo: int, *, bar: int, baz: int): + return locals() + + class Filter(BaseFilter): async def __call__(self, foo: int, bar: int, baz: int) -> Union[bool, Dict[str, Any]]: return locals() @@ -95,11 +99,21 @@ class TestCallableMixin: {"foo": 42, "spam": True, "baz": "fuz", "bar": "test"}, {"foo": 42, "baz": "fuz", "bar": "test"}, ), + pytest.param( + functools.partial(callback2, bar="test"), + {"foo": 42, "spam": True, "baz": "fuz"}, + {"foo": 42, "baz": "fuz"}, + ), pytest.param( callback3, {"foo": 42, "spam": True, "baz": "fuz", "bar": "test"}, {"foo": 42, "spam": True, "baz": "fuz", "bar": "test"}, ), + pytest.param( + callback4, + {"foo": 42, "spam": True, "baz": "fuz", "bar": "test"}, + {"foo": 42, "baz": "fuz", "bar": "test"}, + ), pytest.param( Filter(), {"foo": 42, "spam": True, "baz": "fuz"}, {"foo": 42, "baz": "fuz"} ), From ac2b0bb198d4e51593bf0c7d62bccedd7cc8280d Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Thu, 29 Jul 2021 00:40:50 +0300 Subject: [PATCH 16/38] [3.x] Bot API 5.3 + Improvements (#618) * Regenerate API * Update code * Fixed command filter for photos * Fix tests so they are able to run * Test new and renamed API methods * Reformat files * Fix outer_middleware resolution (#637) (#640) * Fix outer_middleware resolution (#637) * Reformat files * Reorder routers when resolve middlewares Co-authored-by: Alex Root Junior * Added possibility to use empty callback data factory filter * Rename KeyboardConstructor to KeyboardBuilder * Fixed type Co-authored-by: evgfilim1 --- aiogram/client/bot.py | 141 ++++++++++++++++-- aiogram/dispatcher/event/telegram.py | 16 +- aiogram/dispatcher/filters/callback_data.py | 10 +- aiogram/dispatcher/filters/command.py | 2 +- aiogram/methods/__init__.py | 6 + aiogram/methods/ban_chat_member.py | 33 ++++ aiogram/methods/delete_my_commands.py | 29 ++++ aiogram/methods/get_chat_administrators.py | 35 ++++- aiogram/methods/get_chat_member.py | 31 +++- aiogram/methods/get_chat_member_count.py | 26 ++++ aiogram/methods/get_chat_members_count.py | 6 +- aiogram/methods/get_my_commands.py | 11 +- aiogram/methods/kick_chat_member.py | 8 +- aiogram/methods/set_my_commands.py | 10 +- aiogram/methods/unban_chat_member.py | 2 +- aiogram/types/__init__.py | 28 ++++ aiogram/types/bot_command_scope.py | 19 +++ ...t_command_scope_all_chat_administrators.py | 16 ++ .../bot_command_scope_all_group_chats.py | 16 ++ .../bot_command_scope_all_private_chats.py | 16 ++ aiogram/types/bot_command_scope_chat.py | 20 +++ .../bot_command_scope_chat_administrators.py | 20 +++ .../types/bot_command_scope_chat_member.py | 22 +++ aiogram/types/bot_command_scope_default.py | 16 ++ aiogram/types/chat_member.py | 85 +---------- aiogram/types/chat_member_administrator.py | 49 ++++++ aiogram/types/chat_member_banned.py | 26 ++++ aiogram/types/chat_member_left.py | 23 +++ aiogram/types/chat_member_member.py | 23 +++ aiogram/types/chat_member_owner.py | 27 ++++ aiogram/types/chat_member_restricted.py | 44 ++++++ aiogram/types/chat_member_updated.py | 27 +++- aiogram/types/force_reply.py | 2 + .../message_auto_delete_timer_changed.py | 5 - aiogram/types/reply_keyboard_markup.py | 2 + aiogram/types/voice_chat_ended.py | 5 - aiogram/types/voice_chat_scheduled.py | 5 - aiogram/types/voice_chat_started.py | 5 - aiogram/utils/keyboard.py | 48 ++++-- docs/api/methods/ban_chat_member.rst | 51 +++++++ docs/api/methods/delete_my_commands.rst | 51 +++++++ docs/api/methods/get_chat_member_count.rst | 44 ++++++ docs/api/methods/index.rst | 3 + docs/api/types/bot_command_scope.rst | 9 ++ ..._command_scope_all_chat_administrators.rst | 9 ++ .../bot_command_scope_all_group_chats.rst | 9 ++ .../bot_command_scope_all_private_chats.rst | 9 ++ docs/api/types/bot_command_scope_chat.rst | 9 ++ .../bot_command_scope_chat_administrators.rst | 9 ++ .../types/bot_command_scope_chat_member.rst | 9 ++ docs/api/types/bot_command_scope_default.rst | 9 ++ docs/api/types/chat_member_administrator.rst | 9 ++ docs/api/types/chat_member_banned.rst | 9 ++ docs/api/types/chat_member_left.rst | 9 ++ docs/api/types/chat_member_member.rst | 9 ++ docs/api/types/chat_member_owner.rst | 9 ++ docs/api/types/chat_member_restricted.rst | 9 ++ docs/api/types/index.rst | 14 ++ .../finite_state_machine/storages.rst | 23 +++ tests/conftest.py | 4 + .../test_methods/test_ban_chat_member.py | 24 +++ .../test_methods/test_delete_my_commands.py | 24 +++ .../test_get_chat_administrators.py | 10 +- .../test_methods/test_get_chat_member.py | 10 +- .../test_get_chat_member_count.py | 24 +++ tests/test_api/test_types/test_chat_member.py | 29 ---- tests/test_dispatcher/test_dispatcher.py | 63 ++++++-- .../test_filters/test_callback_data.py | 4 + .../test_handler/test_chat_member.py | 10 +- 69 files changed, 1223 insertions(+), 206 deletions(-) create mode 100644 aiogram/methods/ban_chat_member.py create mode 100644 aiogram/methods/delete_my_commands.py create mode 100644 aiogram/methods/get_chat_member_count.py create mode 100644 aiogram/types/bot_command_scope.py create mode 100644 aiogram/types/bot_command_scope_all_chat_administrators.py create mode 100644 aiogram/types/bot_command_scope_all_group_chats.py create mode 100644 aiogram/types/bot_command_scope_all_private_chats.py create mode 100644 aiogram/types/bot_command_scope_chat.py create mode 100644 aiogram/types/bot_command_scope_chat_administrators.py create mode 100644 aiogram/types/bot_command_scope_chat_member.py create mode 100644 aiogram/types/bot_command_scope_default.py create mode 100644 aiogram/types/chat_member_administrator.py create mode 100644 aiogram/types/chat_member_banned.py create mode 100644 aiogram/types/chat_member_left.py create mode 100644 aiogram/types/chat_member_member.py create mode 100644 aiogram/types/chat_member_owner.py create mode 100644 aiogram/types/chat_member_restricted.py create mode 100644 docs/api/methods/ban_chat_member.rst create mode 100644 docs/api/methods/delete_my_commands.rst create mode 100644 docs/api/methods/get_chat_member_count.rst create mode 100644 docs/api/types/bot_command_scope.rst create mode 100644 docs/api/types/bot_command_scope_all_chat_administrators.rst create mode 100644 docs/api/types/bot_command_scope_all_group_chats.rst create mode 100644 docs/api/types/bot_command_scope_all_private_chats.rst create mode 100644 docs/api/types/bot_command_scope_chat.rst create mode 100644 docs/api/types/bot_command_scope_chat_administrators.rst create mode 100644 docs/api/types/bot_command_scope_chat_member.rst create mode 100644 docs/api/types/bot_command_scope_default.rst create mode 100644 docs/api/types/chat_member_administrator.rst create mode 100644 docs/api/types/chat_member_banned.rst create mode 100644 docs/api/types/chat_member_left.rst create mode 100644 docs/api/types/chat_member_member.rst create mode 100644 docs/api/types/chat_member_owner.rst create mode 100644 docs/api/types/chat_member_restricted.rst create mode 100644 docs/dispatcher/finite_state_machine/storages.rst create mode 100644 tests/test_api/test_methods/test_ban_chat_member.py create mode 100644 tests/test_api/test_methods/test_delete_my_commands.py create mode 100644 tests/test_api/test_methods/test_get_chat_member_count.py delete mode 100644 tests/test_api/test_types/test_chat_member.py diff --git a/aiogram/client/bot.py b/aiogram/client/bot.py index ea84baa7..592a83f8 100644 --- a/aiogram/client/bot.py +++ b/aiogram/client/bot.py @@ -28,6 +28,7 @@ from ..methods import ( AnswerInlineQuery, AnswerPreCheckoutQuery, AnswerShippingQuery, + BanChatMember, Close, CopyMessage, CreateChatInviteLink, @@ -35,6 +36,7 @@ from ..methods import ( DeleteChatPhoto, DeleteChatStickerSet, DeleteMessage, + DeleteMyCommands, DeleteStickerFromSet, DeleteWebhook, EditChatInviteLink, @@ -48,6 +50,7 @@ from ..methods import ( GetChat, GetChatAdministrators, GetChatMember, + GetChatMemberCount, GetChatMembersCount, GetFile, GetGameHighScores, @@ -105,9 +108,15 @@ from ..methods import ( from ..types import ( UNSET, BotCommand, + BotCommandScope, Chat, ChatInviteLink, - ChatMember, + ChatMemberAdministrator, + ChatMemberBanned, + ChatMemberLeft, + ChatMemberMember, + ChatMemberOwner, + ChatMemberRestricted, ChatPermissions, Downloadable, File, @@ -1410,6 +1419,35 @@ class Bot(ContextInstanceMixin["Bot"]): ) return await self(call, request_timeout=request_timeout) + async def ban_chat_member( + self, + chat_id: Union[int, str], + user_id: int, + until_date: Optional[Union[datetime.datetime, datetime.timedelta, int]] = None, + revoke_messages: Optional[bool] = None, + request_timeout: Optional[int] = None, + ) -> bool: + """ + Use this method to ban a user in a group, a supergroup or a channel. In the case of supergroups and channels, the user will not be able to return to the chat on their own using invite links, etc., unless `unbanned `_ first. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. Returns :code:`True` on success. + + Source: https://core.telegram.org/bots/api#banchatmember + + :param chat_id: Unique identifier for the target group or username of the target supergroup or channel (in the format :code:`@channelusername`) + :param user_id: Unique identifier of the target user + :param until_date: Date when the user will be unbanned, unix time. If user is banned for more than 366 days or less than 30 seconds from the current time they are considered to be banned forever. Applied for supergroups and channels only. + :param revoke_messages: Pass :code:`True` to delete all messages from the chat for the user that is being removed. If :code:`False`, the user will be able to see messages in the group that were sent before the user was removed. Always :code:`True` for supergroups and channels. + :param request_timeout: Request timeout + :return: In the case of supergroups and channels, the user will not be able to return to + the chat on their own using invite links, etc. Returns True on success. + """ + call = BanChatMember( + chat_id=chat_id, + user_id=user_id, + until_date=until_date, + revoke_messages=revoke_messages, + ) + return await self(call, request_timeout=request_timeout) + async def kick_chat_member( self, chat_id: Union[int, str], @@ -1419,9 +1457,13 @@ class Bot(ContextInstanceMixin["Bot"]): request_timeout: Optional[int] = None, ) -> bool: """ - Use this method to kick a user from a group, a supergroup or a channel. In the case of supergroups and channels, the user will not be able to return to the chat on their own using invite links, etc., unless `unbanned `_ first. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. Returns :code:`True` on success. + .. warning: - Source: https://core.telegram.org/bots/api#kickchatmember + Renamed from :code:`kickChatMember` in 5.3 bot API version and can be removed in near future + + Use this method to ban a user in a group, a supergroup or a channel. In the case of supergroups and channels, the user will not be able to return to the chat on their own using invite links, etc., unless `unbanned `_ first. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. Returns :code:`True` on success. + + Source: https://core.telegram.org/bots/api#banchatmember :param chat_id: Unique identifier for the target group or username of the target supergroup or channel (in the format :code:`@channelusername`) :param user_id: Unique identifier of the target user @@ -1447,7 +1489,7 @@ class Bot(ContextInstanceMixin["Bot"]): request_timeout: Optional[int] = None, ) -> bool: """ - Use this method to unban a previously kicked user in a supergroup or channel. The user will **not** return to the group or channel automatically, but will be able to join via link, etc. The bot must be an administrator for this to work. By default, this method guarantees that after the call the user is not a member of the chat, but will be able to join it. So if the user is a member of the chat they will also be **removed** from the chat. If you don't want this, use the parameter *only_if_banned*. Returns :code:`True` on success. + Use this method to unban a previously banned user in a supergroup or channel. The user will **not** return to the group or channel automatically, but will be able to join via link, etc. The bot must be an administrator for this to work. By default, this method guarantees that after the call the user is not a member of the chat, but will be able to join it. So if the user is a member of the chat they will also be **removed** from the chat. If you don't want this, use the parameter *only_if_banned*. Returns :code:`True` on success. Source: https://core.telegram.org/bots/api#unbanchatmember @@ -1884,7 +1926,16 @@ class Bot(ContextInstanceMixin["Bot"]): self, chat_id: Union[int, str], request_timeout: Optional[int] = None, - ) -> List[ChatMember]: + ) -> List[ + Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] + ]: """ Use this method to get a list of administrators in a chat. On success, returns an Array of :class:`aiogram.types.chat_member.ChatMember` objects that contains information about all chat administrators except other bots. If the chat is a group or a supergroup and no administrators were appointed, only the creator will be returned. @@ -1902,7 +1953,7 @@ class Bot(ContextInstanceMixin["Bot"]): ) return await self(call, request_timeout=request_timeout) - async def get_chat_members_count( + async def get_chat_member_count( self, chat_id: Union[int, str], request_timeout: Optional[int] = None, @@ -1910,7 +1961,30 @@ class Bot(ContextInstanceMixin["Bot"]): """ Use this method to get the number of members in a chat. Returns *Int* on success. - Source: https://core.telegram.org/bots/api#getchatmemberscount + Source: https://core.telegram.org/bots/api#getchatmembercount + + :param chat_id: Unique identifier for the target chat or username of the target supergroup or channel (in the format :code:`@channelusername`) + :param request_timeout: Request timeout + :return: Returns Int on success. + """ + call = GetChatMemberCount( + chat_id=chat_id, + ) + return await self(call, request_timeout=request_timeout) + + async def get_chat_members_count( + self, + chat_id: Union[int, str], + request_timeout: Optional[int] = None, + ) -> int: + """ + .. warning: + + Renamed from :code:`getChatMembersCount` in 5.3 bot API version and can be removed in near future + + Use this method to get the number of members in a chat. Returns *Int* on success. + + Source: https://core.telegram.org/bots/api#getchatmembercount :param chat_id: Unique identifier for the target chat or username of the target supergroup or channel (in the format :code:`@channelusername`) :param request_timeout: Request timeout @@ -1926,7 +2000,14 @@ class Bot(ContextInstanceMixin["Bot"]): chat_id: Union[int, str], user_id: int, request_timeout: Optional[int] = None, - ) -> ChatMember: + ) -> Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ]: """ Use this method to get information about a member of a chat. Returns a :class:`aiogram.types.chat_member.ChatMember` object on success. @@ -2022,35 +2103,71 @@ class Bot(ContextInstanceMixin["Bot"]): async def set_my_commands( self, commands: List[BotCommand], + scope: Optional[BotCommandScope] = None, + language_code: Optional[str] = None, request_timeout: Optional[int] = None, ) -> bool: """ - Use this method to change the list of the bot's commands. Returns :code:`True` on success. + Use this method to change the list of the bot's commands. See `https://core.telegram.org/bots#commands `_`https://core.telegram.org/bots#commands `_ for more details about bot commands. Returns :code:`True` on success. Source: https://core.telegram.org/bots/api#setmycommands :param commands: A JSON-serialized list of bot commands to be set as the list of the bot's commands. At most 100 commands can be specified. + :param scope: A JSON-serialized object, describing scope of users for which the commands are relevant. Defaults to :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault`. + :param language_code: A two-letter ISO 639-1 language code. If empty, commands will be applied to all users from the given scope, for whose language there are no dedicated commands :param request_timeout: Request timeout :return: Returns True on success. """ call = SetMyCommands( commands=commands, + scope=scope, + language_code=language_code, + ) + return await self(call, request_timeout=request_timeout) + + async def delete_my_commands( + self, + scope: Optional[BotCommandScope] = None, + language_code: Optional[str] = None, + request_timeout: Optional[int] = None, + ) -> bool: + """ + Use this method to delete the list of the bot's commands for the given scope and user language. After deletion, `higher level commands `_ will be shown to affected users. Returns :code:`True` on success. + + Source: https://core.telegram.org/bots/api#deletemycommands + + :param scope: A JSON-serialized object, describing scope of users for which the commands are relevant. Defaults to :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault`. + :param language_code: A two-letter ISO 639-1 language code. If empty, commands will be applied to all users from the given scope, for whose language there are no dedicated commands + :param request_timeout: Request timeout + :return: Returns True on success. + """ + call = DeleteMyCommands( + scope=scope, + language_code=language_code, ) return await self(call, request_timeout=request_timeout) async def get_my_commands( self, + scope: Optional[BotCommandScope] = None, + language_code: Optional[str] = None, request_timeout: Optional[int] = None, ) -> List[BotCommand]: """ - Use this method to get the current list of the bot's commands. Requires no parameters. Returns Array of :class:`aiogram.types.bot_command.BotCommand` on success. + Use this method to get the current list of the bot's commands for the given scope and user language. Returns Array of :class:`aiogram.types.bot_command.BotCommand` on success. If commands aren't set, an empty list is returned. Source: https://core.telegram.org/bots/api#getmycommands + :param scope: A JSON-serialized object, describing scope of users. Defaults to :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault`. + :param language_code: A two-letter ISO 639-1 language code or an empty string :param request_timeout: Request timeout - :return: Returns Array of BotCommand on success. + :return: Returns Array of BotCommand on success. If commands aren't set, an empty list is + returned. """ - call = GetMyCommands() + call = GetMyCommands( + scope=scope, + language_code=language_code, + ) return await self(call, request_timeout=request_timeout) # ============================================================================================= diff --git a/aiogram/dispatcher/event/telegram.py b/aiogram/dispatcher/event/telegram.py index 50e2412d..f91e8b77 100644 --- a/aiogram/dispatcher/event/telegram.py +++ b/aiogram/dispatcher/event/telegram.py @@ -60,15 +60,19 @@ class TelegramEventObserver: yield filter_ registry.append(filter_) - def _resolve_inner_middlewares(self) -> List[MiddlewareType]: + def _resolve_middlewares(self, *, outer: bool = False) -> List[MiddlewareType]: """ - Get all inner middlewares in an tree + Get all middlewares in a tree + :param *: """ middlewares = [] - for router in self.router.chain_head: + for router in reversed(list(self.router.chain_head)): observer = router.observers[self.event_name] - middlewares.extend(observer.middlewares) + if outer: + middlewares.extend(observer.outer_middlewares) + else: + middlewares.extend(observer.middlewares) return middlewares def resolve_filters(self, full_config: Dict[str, Any]) -> List[BaseFilter]: @@ -131,7 +135,7 @@ class TelegramEventObserver: Propagate event to handlers and stops propagation on first match. Handler will be called when all its filters is pass. """ - wrapped_outer = self._wrap_middleware(self.outer_middlewares, self._trigger) + wrapped_outer = self._wrap_middleware(self._resolve_middlewares(outer=True), self._trigger) return await wrapped_outer(event, kwargs) async def _trigger(self, event: TelegramObject, **kwargs: Any) -> Any: @@ -141,7 +145,7 @@ class TelegramEventObserver: kwargs.update(data) try: wrapped_inner = self._wrap_middleware( - self._resolve_inner_middlewares(), handler.call + self._resolve_middlewares(), handler.call ) return await wrapped_inner(event, kwargs) except SkipHandler: diff --git a/aiogram/dispatcher/filters/callback_data.py b/aiogram/dispatcher/filters/callback_data.py index 68f5b773..4a1cd392 100644 --- a/aiogram/dispatcher/filters/callback_data.py +++ b/aiogram/dispatcher/filters/callback_data.py @@ -3,7 +3,7 @@ from __future__ import annotations from decimal import Decimal from enum import Enum from fractions import Fraction -from typing import TYPE_CHECKING, Any, Dict, Optional, Type, TypeVar, Union +from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Type, TypeVar, Union from uuid import UUID from magic_filter import MagicFilter @@ -86,7 +86,7 @@ class CallbackData(BaseModel): return cls(**payload) @classmethod - def filter(cls, rule: MagicFilter) -> CallbackQueryFilter: + def filter(cls, rule: Optional[MagicFilter] = None) -> CallbackQueryFilter: return CallbackQueryFilter(callback_data=cls, rule=rule) class Config: @@ -95,9 +95,9 @@ class CallbackData(BaseModel): class CallbackQueryFilter(BaseFilter): callback_data: Type[CallbackData] - rule: MagicFilter + rule: Optional[MagicFilter] = None - async def __call__(self, query: CallbackQuery) -> Union[bool, Dict[str, Any]]: + async def __call__(self, query: CallbackQuery) -> Union[Literal[False], Dict[str, Any]]: if not isinstance(query, CallbackQuery) or not query.data: return False try: @@ -105,7 +105,7 @@ class CallbackQueryFilter(BaseFilter): except (TypeError, ValueError): return False - if self.rule.resolve(callback_data): + if self.rule is None or self.rule.resolve(callback_data): return {"callback_data": callback_data} return False diff --git a/aiogram/dispatcher/filters/command.py b/aiogram/dispatcher/filters/command.py index 0e584c99..ba760bef 100644 --- a/aiogram/dispatcher/filters/command.py +++ b/aiogram/dispatcher/filters/command.py @@ -52,7 +52,7 @@ class Command(BaseFilter): return False try: - command = await self.parse_command(text=cast(str, message.text), bot=bot) + command = await self.parse_command(text=text, bot=bot) except CommandException: return False return {"command": command} diff --git a/aiogram/methods/__init__.py b/aiogram/methods/__init__.py index df602bef..54b59e74 100644 --- a/aiogram/methods/__init__.py +++ b/aiogram/methods/__init__.py @@ -3,6 +3,7 @@ from .answer_callback_query import AnswerCallbackQuery from .answer_inline_query import AnswerInlineQuery from .answer_pre_checkout_query import AnswerPreCheckoutQuery from .answer_shipping_query import AnswerShippingQuery +from .ban_chat_member import BanChatMember from .base import Request, Response, TelegramMethod from .close import Close from .copy_message import CopyMessage @@ -11,6 +12,7 @@ from .create_new_sticker_set import CreateNewStickerSet from .delete_chat_photo import DeleteChatPhoto from .delete_chat_sticker_set import DeleteChatStickerSet from .delete_message import DeleteMessage +from .delete_my_commands import DeleteMyCommands from .delete_sticker_from_set import DeleteStickerFromSet from .delete_webhook import DeleteWebhook from .edit_chat_invite_link import EditChatInviteLink @@ -24,6 +26,7 @@ from .forward_message import ForwardMessage from .get_chat import GetChat from .get_chat_administrators import GetChatAdministrators from .get_chat_member import GetChatMember +from .get_chat_member_count import GetChatMemberCount from .get_chat_members_count import GetChatMembersCount from .get_file import GetFile from .get_game_high_scores import GetGameHighScores @@ -109,6 +112,7 @@ __all__ = ( "SendChatAction", "GetUserProfilePhotos", "GetFile", + "BanChatMember", "KickChatMember", "UnbanChatMember", "RestrictChatMember", @@ -129,12 +133,14 @@ __all__ = ( "LeaveChat", "GetChat", "GetChatAdministrators", + "GetChatMemberCount", "GetChatMembersCount", "GetChatMember", "SetChatStickerSet", "DeleteChatStickerSet", "AnswerCallbackQuery", "SetMyCommands", + "DeleteMyCommands", "GetMyCommands", "EditMessageText", "EditMessageCaption", diff --git a/aiogram/methods/ban_chat_member.py b/aiogram/methods/ban_chat_member.py new file mode 100644 index 00000000..457ca68b --- /dev/null +++ b/aiogram/methods/ban_chat_member.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, Any, Dict, Optional, Union + +from .base import Request, TelegramMethod + +if TYPE_CHECKING: # pragma: no cover + from ..client.bot import Bot + + +class BanChatMember(TelegramMethod[bool]): + """ + Use this method to ban a user in a group, a supergroup or a channel. In the case of supergroups and channels, the user will not be able to return to the chat on their own using invite links, etc., unless `unbanned `_ first. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. Returns :code:`True` on success. + + Source: https://core.telegram.org/bots/api#banchatmember + """ + + __returning__ = bool + + chat_id: Union[int, str] + """Unique identifier for the target group or username of the target supergroup or channel (in the format :code:`@channelusername`)""" + user_id: int + """Unique identifier of the target user""" + until_date: Optional[Union[datetime.datetime, datetime.timedelta, int]] = None + """Date when the user will be unbanned, unix time. If user is banned for more than 366 days or less than 30 seconds from the current time they are considered to be banned forever. Applied for supergroups and channels only.""" + revoke_messages: Optional[bool] = None + """Pass :code:`True` to delete all messages from the chat for the user that is being removed. If :code:`False`, the user will be able to see messages in the group that were sent before the user was removed. Always :code:`True` for supergroups and channels.""" + + def build_request(self, bot: Bot) -> Request: + data: Dict[str, Any] = self.dict() + + return Request(method="banChatMember", data=data) diff --git a/aiogram/methods/delete_my_commands.py b/aiogram/methods/delete_my_commands.py new file mode 100644 index 00000000..b0b9ebaa --- /dev/null +++ b/aiogram/methods/delete_my_commands.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Optional + +from ..types import BotCommandScope +from .base import Request, TelegramMethod + +if TYPE_CHECKING: # pragma: no cover + from ..client.bot import Bot + + +class DeleteMyCommands(TelegramMethod[bool]): + """ + Use this method to delete the list of the bot's commands for the given scope and user language. After deletion, `higher level commands `_ will be shown to affected users. Returns :code:`True` on success. + + Source: https://core.telegram.org/bots/api#deletemycommands + """ + + __returning__ = bool + + scope: Optional[BotCommandScope] = None + """A JSON-serialized object, describing scope of users for which the commands are relevant. Defaults to :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault`.""" + language_code: Optional[str] = None + """A two-letter ISO 639-1 language code. If empty, commands will be applied to all users from the given scope, for whose language there are no dedicated commands""" + + def build_request(self, bot: Bot) -> Request: + data: Dict[str, Any] = self.dict() + + return Request(method="deleteMyCommands", data=data) diff --git a/aiogram/methods/get_chat_administrators.py b/aiogram/methods/get_chat_administrators.py index 961fb959..9ca31884 100644 --- a/aiogram/methods/get_chat_administrators.py +++ b/aiogram/methods/get_chat_administrators.py @@ -2,21 +2,50 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Dict, List, Union -from ..types import ChatMember +from ..types import ( + ChatMemberAdministrator, + ChatMemberBanned, + ChatMemberLeft, + ChatMemberMember, + ChatMemberOwner, + ChatMemberRestricted, +) from .base import Request, TelegramMethod if TYPE_CHECKING: # pragma: no cover from ..client.bot import Bot -class GetChatAdministrators(TelegramMethod[List[ChatMember]]): +class GetChatAdministrators( + TelegramMethod[ + List[ + Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] + ] + ] +): """ Use this method to get a list of administrators in a chat. On success, returns an Array of :class:`aiogram.types.chat_member.ChatMember` objects that contains information about all chat administrators except other bots. If the chat is a group or a supergroup and no administrators were appointed, only the creator will be returned. Source: https://core.telegram.org/bots/api#getchatadministrators """ - __returning__ = List[ChatMember] + __returning__ = List[ + Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] + ] chat_id: Union[int, str] """Unique identifier for the target chat or username of the target supergroup or channel (in the format :code:`@channelusername`)""" diff --git a/aiogram/methods/get_chat_member.py b/aiogram/methods/get_chat_member.py index cb21e1ff..60d508bf 100644 --- a/aiogram/methods/get_chat_member.py +++ b/aiogram/methods/get_chat_member.py @@ -2,21 +2,46 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, Dict, Union -from ..types import ChatMember +from ..types import ( + ChatMemberAdministrator, + ChatMemberBanned, + ChatMemberLeft, + ChatMemberMember, + ChatMemberOwner, + ChatMemberRestricted, +) from .base import Request, TelegramMethod if TYPE_CHECKING: # pragma: no cover from ..client.bot import Bot -class GetChatMember(TelegramMethod[ChatMember]): +class GetChatMember( + TelegramMethod[ + Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] + ] +): """ Use this method to get information about a member of a chat. Returns a :class:`aiogram.types.chat_member.ChatMember` object on success. Source: https://core.telegram.org/bots/api#getchatmember """ - __returning__ = ChatMember + __returning__ = Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] chat_id: Union[int, str] """Unique identifier for the target chat or username of the target supergroup or channel (in the format :code:`@channelusername`)""" diff --git a/aiogram/methods/get_chat_member_count.py b/aiogram/methods/get_chat_member_count.py new file mode 100644 index 00000000..b6bd67a4 --- /dev/null +++ b/aiogram/methods/get_chat_member_count.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, Union + +from .base import Request, TelegramMethod + +if TYPE_CHECKING: # pragma: no cover + from ..client.bot import Bot + + +class GetChatMemberCount(TelegramMethod[int]): + """ + Use this method to get the number of members in a chat. Returns *Int* on success. + + Source: https://core.telegram.org/bots/api#getchatmembercount + """ + + __returning__ = int + + chat_id: Union[int, str] + """Unique identifier for the target chat or username of the target supergroup or channel (in the format :code:`@channelusername`)""" + + def build_request(self, bot: Bot) -> Request: + data: Dict[str, Any] = self.dict() + + return Request(method="getChatMemberCount", data=data) diff --git a/aiogram/methods/get_chat_members_count.py b/aiogram/methods/get_chat_members_count.py index 2ec638f4..cec4929d 100644 --- a/aiogram/methods/get_chat_members_count.py +++ b/aiogram/methods/get_chat_members_count.py @@ -10,9 +10,13 @@ if TYPE_CHECKING: # pragma: no cover class GetChatMembersCount(TelegramMethod[int]): """ + .. warning: + + Renamed from :code:`getChatMembersCount` in 5.3 bot API version and can be removed in near future + Use this method to get the number of members in a chat. Returns *Int* on success. - Source: https://core.telegram.org/bots/api#getchatmemberscount + Source: https://core.telegram.org/bots/api#getchatmembercount """ __returning__ = int diff --git a/aiogram/methods/get_my_commands.py b/aiogram/methods/get_my_commands.py index 19adb9bd..2e4e683d 100644 --- a/aiogram/methods/get_my_commands.py +++ b/aiogram/methods/get_my_commands.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List +from typing import TYPE_CHECKING, Any, Dict, List, Optional -from ..types import BotCommand +from ..types import BotCommand, BotCommandScope from .base import Request, TelegramMethod if TYPE_CHECKING: # pragma: no cover @@ -11,13 +11,18 @@ if TYPE_CHECKING: # pragma: no cover class GetMyCommands(TelegramMethod[List[BotCommand]]): """ - Use this method to get the current list of the bot's commands. Requires no parameters. Returns Array of :class:`aiogram.types.bot_command.BotCommand` on success. + Use this method to get the current list of the bot's commands for the given scope and user language. Returns Array of :class:`aiogram.types.bot_command.BotCommand` on success. If commands aren't set, an empty list is returned. Source: https://core.telegram.org/bots/api#getmycommands """ __returning__ = List[BotCommand] + scope: Optional[BotCommandScope] = None + """A JSON-serialized object, describing scope of users. Defaults to :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault`.""" + language_code: Optional[str] = None + """A two-letter ISO 639-1 language code or an empty string""" + def build_request(self, bot: Bot) -> Request: data: Dict[str, Any] = self.dict() diff --git a/aiogram/methods/kick_chat_member.py b/aiogram/methods/kick_chat_member.py index 11ed6bba..e854ff11 100644 --- a/aiogram/methods/kick_chat_member.py +++ b/aiogram/methods/kick_chat_member.py @@ -11,9 +11,13 @@ if TYPE_CHECKING: # pragma: no cover class KickChatMember(TelegramMethod[bool]): """ - Use this method to kick a user from a group, a supergroup or a channel. In the case of supergroups and channels, the user will not be able to return to the chat on their own using invite links, etc., unless `unbanned `_ first. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. Returns :code:`True` on success. + .. warning: - Source: https://core.telegram.org/bots/api#kickchatmember + Renamed from :code:`kickChatMember` in 5.3 bot API version and can be removed in near future + + Use this method to ban a user in a group, a supergroup or a channel. In the case of supergroups and channels, the user will not be able to return to the chat on their own using invite links, etc., unless `unbanned `_ first. The bot must be an administrator in the chat for this to work and must have the appropriate admin rights. Returns :code:`True` on success. + + Source: https://core.telegram.org/bots/api#banchatmember """ __returning__ = bool diff --git a/aiogram/methods/set_my_commands.py b/aiogram/methods/set_my_commands.py index a739b228..451ce509 100644 --- a/aiogram/methods/set_my_commands.py +++ b/aiogram/methods/set_my_commands.py @@ -1,8 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, List +from typing import TYPE_CHECKING, Any, Dict, List, Optional -from ..types import BotCommand +from ..types import BotCommand, BotCommandScope from .base import Request, TelegramMethod if TYPE_CHECKING: # pragma: no cover @@ -11,7 +11,7 @@ if TYPE_CHECKING: # pragma: no cover class SetMyCommands(TelegramMethod[bool]): """ - Use this method to change the list of the bot's commands. Returns :code:`True` on success. + Use this method to change the list of the bot's commands. See `https://core.telegram.org/bots#commands `_`https://core.telegram.org/bots#commands `_ for more details about bot commands. Returns :code:`True` on success. Source: https://core.telegram.org/bots/api#setmycommands """ @@ -20,6 +20,10 @@ class SetMyCommands(TelegramMethod[bool]): commands: List[BotCommand] """A JSON-serialized list of bot commands to be set as the list of the bot's commands. At most 100 commands can be specified.""" + scope: Optional[BotCommandScope] = None + """A JSON-serialized object, describing scope of users for which the commands are relevant. Defaults to :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault`.""" + language_code: Optional[str] = None + """A two-letter ISO 639-1 language code. If empty, commands will be applied to all users from the given scope, for whose language there are no dedicated commands""" def build_request(self, bot: Bot) -> Request: data: Dict[str, Any] = self.dict() diff --git a/aiogram/methods/unban_chat_member.py b/aiogram/methods/unban_chat_member.py index cae62f8e..993335cf 100644 --- a/aiogram/methods/unban_chat_member.py +++ b/aiogram/methods/unban_chat_member.py @@ -10,7 +10,7 @@ if TYPE_CHECKING: # pragma: no cover class UnbanChatMember(TelegramMethod[bool]): """ - Use this method to unban a previously kicked user in a supergroup or channel. The user will **not** return to the group or channel automatically, but will be able to join via link, etc. The bot must be an administrator for this to work. By default, this method guarantees that after the call the user is not a member of the chat, but will be able to join it. So if the user is a member of the chat they will also be **removed** from the chat. If you don't want this, use the parameter *only_if_banned*. Returns :code:`True` on success. + Use this method to unban a previously banned user in a supergroup or channel. The user will **not** return to the group or channel automatically, but will be able to join via link, etc. The bot must be an administrator for this to work. By default, this method guarantees that after the call the user is not a member of the chat, but will be able to join it. So if the user is a member of the chat they will also be **removed** from the chat. If you don't want this, use the parameter *only_if_banned*. Returns :code:`True` on success. Source: https://core.telegram.org/bots/api#unbanchatmember """ diff --git a/aiogram/types/__init__.py b/aiogram/types/__init__.py index eb4c2c55..bfa7d6f8 100644 --- a/aiogram/types/__init__.py +++ b/aiogram/types/__init__.py @@ -2,12 +2,26 @@ from .animation import Animation from .audio import Audio from .base import UNSET, TelegramObject from .bot_command import BotCommand +from .bot_command_scope import BotCommandScope +from .bot_command_scope_all_chat_administrators import BotCommandScopeAllChatAdministrators +from .bot_command_scope_all_group_chats import BotCommandScopeAllGroupChats +from .bot_command_scope_all_private_chats import BotCommandScopeAllPrivateChats +from .bot_command_scope_chat import BotCommandScopeChat +from .bot_command_scope_chat_administrators import BotCommandScopeChatAdministrators +from .bot_command_scope_chat_member import BotCommandScopeChatMember +from .bot_command_scope_default import BotCommandScopeDefault from .callback_game import CallbackGame from .callback_query import CallbackQuery from .chat import Chat from .chat_invite_link import ChatInviteLink from .chat_location import ChatLocation from .chat_member import ChatMember +from .chat_member_administrator import ChatMemberAdministrator +from .chat_member_banned import ChatMemberBanned +from .chat_member_left import ChatMemberLeft +from .chat_member_member import ChatMemberMember +from .chat_member_owner import ChatMemberOwner +from .chat_member_restricted import ChatMemberRestricted from .chat_member_updated import ChatMemberUpdated from .chat_permissions import ChatPermissions from .chat_photo import ChatPhoto @@ -160,10 +174,24 @@ __all__ = ( "ChatPhoto", "ChatInviteLink", "ChatMember", + "ChatMemberOwner", + "ChatMemberAdministrator", + "ChatMemberMember", + "ChatMemberRestricted", + "ChatMemberLeft", + "ChatMemberBanned", "ChatMemberUpdated", "ChatPermissions", "ChatLocation", "BotCommand", + "BotCommandScope", + "BotCommandScopeDefault", + "BotCommandScopeAllPrivateChats", + "BotCommandScopeAllGroupChats", + "BotCommandScopeAllChatAdministrators", + "BotCommandScopeChat", + "BotCommandScopeChatAdministrators", + "BotCommandScopeChatMember", "ResponseParameters", "InputMedia", "InputMediaPhoto", diff --git a/aiogram/types/bot_command_scope.py b/aiogram/types/bot_command_scope.py new file mode 100644 index 00000000..dfb1d528 --- /dev/null +++ b/aiogram/types/bot_command_scope.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from .base import TelegramObject + + +class BotCommandScope(TelegramObject): + """ + This object represents the scope to which bot commands are applied. Currently, the following 7 scopes are supported: + + - :class:`aiogram.types.bot_command_scope_default.BotCommandScopeDefault` + - :class:`aiogram.types.bot_command_scope_all_private_chats.BotCommandScopeAllPrivateChats` + - :class:`aiogram.types.bot_command_scope_all_group_chats.BotCommandScopeAllGroupChats` + - :class:`aiogram.types.bot_command_scope_all_chat_administrators.BotCommandScopeAllChatAdministrators` + - :class:`aiogram.types.bot_command_scope_chat.BotCommandScopeChat` + - :class:`aiogram.types.bot_command_scope_chat_administrators.BotCommandScopeChatAdministrators` + - :class:`aiogram.types.bot_command_scope_chat_member.BotCommandScopeChatMember` + + Source: https://core.telegram.org/bots/api#botcommandscope + """ diff --git a/aiogram/types/bot_command_scope_all_chat_administrators.py b/aiogram/types/bot_command_scope_all_chat_administrators.py new file mode 100644 index 00000000..25a35cbf --- /dev/null +++ b/aiogram/types/bot_command_scope_all_chat_administrators.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeAllChatAdministrators(BotCommandScope): + """ + Represents the `scope `_ of bot commands, covering all group and supergroup chat administrators. + + Source: https://core.telegram.org/bots/api#botcommandscopeallchatadministrators + """ + + type: str = Field("all_chat_administrators", const=True) + """Scope type, must be *all_chat_administrators*""" diff --git a/aiogram/types/bot_command_scope_all_group_chats.py b/aiogram/types/bot_command_scope_all_group_chats.py new file mode 100644 index 00000000..00e2984e --- /dev/null +++ b/aiogram/types/bot_command_scope_all_group_chats.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeAllGroupChats(BotCommandScope): + """ + Represents the `scope `_ of bot commands, covering all group and supergroup chats. + + Source: https://core.telegram.org/bots/api#botcommandscopeallgroupchats + """ + + type: str = Field("all_group_chats", const=True) + """Scope type, must be *all_group_chats*""" diff --git a/aiogram/types/bot_command_scope_all_private_chats.py b/aiogram/types/bot_command_scope_all_private_chats.py new file mode 100644 index 00000000..debc3baf --- /dev/null +++ b/aiogram/types/bot_command_scope_all_private_chats.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeAllPrivateChats(BotCommandScope): + """ + Represents the `scope `_ of bot commands, covering all private chats. + + Source: https://core.telegram.org/bots/api#botcommandscopeallprivatechats + """ + + type: str = Field("all_private_chats", const=True) + """Scope type, must be *all_private_chats*""" diff --git a/aiogram/types/bot_command_scope_chat.py b/aiogram/types/bot_command_scope_chat.py new file mode 100644 index 00000000..5d89c046 --- /dev/null +++ b/aiogram/types/bot_command_scope_chat.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Union + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeChat(BotCommandScope): + """ + Represents the `scope `_ of bot commands, covering a specific chat. + + Source: https://core.telegram.org/bots/api#botcommandscopechat + """ + + type: str = Field("chat", const=True) + """Scope type, must be *chat*""" + chat_id: Union[int, str] + """Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`)""" diff --git a/aiogram/types/bot_command_scope_chat_administrators.py b/aiogram/types/bot_command_scope_chat_administrators.py new file mode 100644 index 00000000..152eab13 --- /dev/null +++ b/aiogram/types/bot_command_scope_chat_administrators.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Union + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeChatAdministrators(BotCommandScope): + """ + Represents the `scope `_ of bot commands, covering all administrators of a specific group or supergroup chat. + + Source: https://core.telegram.org/bots/api#botcommandscopechatadministrators + """ + + type: str = Field("chat_administrators", const=True) + """Scope type, must be *chat_administrators*""" + chat_id: Union[int, str] + """Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`)""" diff --git a/aiogram/types/bot_command_scope_chat_member.py b/aiogram/types/bot_command_scope_chat_member.py new file mode 100644 index 00000000..e69ff642 --- /dev/null +++ b/aiogram/types/bot_command_scope_chat_member.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from typing import Union + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeChatMember(BotCommandScope): + """ + Represents the `scope `_ of bot commands, covering a specific member of a group or supergroup chat. + + Source: https://core.telegram.org/bots/api#botcommandscopechatmember + """ + + type: str = Field("chat_member", const=True) + """Scope type, must be *chat_member*""" + chat_id: Union[int, str] + """Unique identifier for the target chat or username of the target supergroup (in the format :code:`@supergroupusername`)""" + user_id: int + """Unique identifier of the target user""" diff --git a/aiogram/types/bot_command_scope_default.py b/aiogram/types/bot_command_scope_default.py new file mode 100644 index 00000000..8cf1a1d5 --- /dev/null +++ b/aiogram/types/bot_command_scope_default.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pydantic import Field + +from .bot_command_scope import BotCommandScope + + +class BotCommandScopeDefault(BotCommandScope): + """ + Represents the default `scope `_ of bot commands. Default commands are used if no commands with a `narrower scope `_ are specified for the user. + + Source: https://core.telegram.org/bots/api#botcommandscopedefault + """ + + type: str = Field("default", const=True) + """Scope type, must be *default*""" diff --git a/aiogram/types/chat_member.py b/aiogram/types/chat_member.py index 4a1abc8b..018bebda 100644 --- a/aiogram/types/chat_member.py +++ b/aiogram/types/chat_member.py @@ -1,87 +1,18 @@ from __future__ import annotations -import datetime -from typing import TYPE_CHECKING, Optional, Union - -from aiogram.utils import helper - from .base import TelegramObject -if TYPE_CHECKING: # pragma: no cover - from .user import User - class ChatMember(TelegramObject): """ - This object contains information about one member of a chat. + This object contains information about one member of a chat. Currently, the following 6 types of chat members are supported: + + - :class:`aiogram.types.chat_member_owner.ChatMemberOwner` + - :class:`aiogram.types.chat_member_administrator.ChatMemberAdministrator` + - :class:`aiogram.types.chat_member_member.ChatMemberMember` + - :class:`aiogram.types.chat_member_restricted.ChatMemberRestricted` + - :class:`aiogram.types.chat_member_left.ChatMemberLeft` + - :class:`aiogram.types.chat_member_banned.ChatMemberBanned` Source: https://core.telegram.org/bots/api#chatmember """ - - user: User - """Information about the user""" - status: str - """The member's status in the chat. Can be 'creator', 'administrator', 'member', 'restricted', 'left' or 'kicked'""" - custom_title: Optional[str] = None - """*Optional*. Owner and administrators only. Custom title for this user""" - is_anonymous: Optional[bool] = None - """*Optional*. Owner and administrators only. True, if the user's presence in the chat is hidden""" - can_be_edited: Optional[bool] = None - """*Optional*. Administrators only. True, if the bot is allowed to edit administrator privileges of that user""" - can_manage_chat: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can access the chat event log, chat statistics, message statistics in channels, see channel members, see anonymous administrators in supergroups and ignore slow mode. Implied by any other administrator privilege""" - can_post_messages: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can post in the channel; channels only""" - can_edit_messages: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can edit messages of other users and can pin messages; channels only""" - can_delete_messages: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can delete messages of other users""" - can_manage_voice_chats: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can manage voice chats""" - can_restrict_members: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can restrict, ban or unban chat members""" - can_promote_members: Optional[bool] = None - """*Optional*. Administrators only. True, if the administrator can add new administrators with a subset of their own privileges or demote administrators that he has promoted, directly or indirectly (promoted by administrators that were appointed by the user)""" - can_change_info: Optional[bool] = None - """*Optional*. Administrators and restricted only. True, if the user is allowed to change the chat title, photo and other settings""" - can_invite_users: Optional[bool] = None - """*Optional*. Administrators and restricted only. True, if the user is allowed to invite new users to the chat""" - can_pin_messages: Optional[bool] = None - """*Optional*. Administrators and restricted only. True, if the user is allowed to pin messages; groups and supergroups only""" - is_member: Optional[bool] = None - """*Optional*. Restricted only. True, if the user is a member of the chat at the moment of the request""" - can_send_messages: Optional[bool] = None - """*Optional*. Restricted only. True, if the user is allowed to send text messages, contacts, locations and venues""" - can_send_media_messages: Optional[bool] = None - """*Optional*. Restricted only. True, if the user is allowed to send audios, documents, photos, videos, video notes and voice notes""" - can_send_polls: Optional[bool] = None - """*Optional*. Restricted only. True, if the user is allowed to send polls""" - can_send_other_messages: Optional[bool] = None - """*Optional*. Restricted only. True, if the user is allowed to send animations, games, stickers and use inline bots""" - can_add_web_page_previews: Optional[bool] = None - """*Optional*. Restricted only. True, if the user is allowed to add web page previews to their messages""" - until_date: Optional[Union[datetime.datetime, datetime.timedelta, int]] = None - """*Optional*. Restricted and kicked only. Date when restrictions will be lifted for this user; unix time""" - - @property - def is_chat_admin(self) -> bool: - return self.status in {ChatMemberStatus.CREATOR, ChatMemberStatus.ADMINISTRATOR} - - @property - def is_chat_member(self) -> bool: - return self.status not in {ChatMemberStatus.LEFT, ChatMemberStatus.KICKED} - - -class ChatMemberStatus(helper.Helper): - """ - Chat member status - """ - - mode = helper.HelperMode.lowercase - - CREATOR = helper.Item() # creator - ADMINISTRATOR = helper.Item() # administrator - MEMBER = helper.Item() # member - RESTRICTED = helper.Item() # restricted - LEFT = helper.Item() # left - KICKED = helper.Item() # kicked diff --git a/aiogram/types/chat_member_administrator.py b/aiogram/types/chat_member_administrator.py new file mode 100644 index 00000000..f25818c2 --- /dev/null +++ b/aiogram/types/chat_member_administrator.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from pydantic import Field + +from .chat_member import ChatMember + +if TYPE_CHECKING: # pragma: no cover + from .user import User + + +class ChatMemberAdministrator(ChatMember): + """ + Represents a `chat member `_ that has some additional privileges. + + Source: https://core.telegram.org/bots/api#chatmemberadministrator + """ + + status: str = Field("administrator", const=True) + """The member's status in the chat, always 'administrator'""" + user: User + """Information about the user""" + can_be_edited: bool + """True, if the bot is allowed to edit administrator privileges of that user""" + is_anonymous: bool + """True, if the user's presence in the chat is hidden""" + can_manage_chat: bool + """True, if the administrator can access the chat event log, chat statistics, message statistics in channels, see channel members, see anonymous administrators in supergroups and ignore slow mode. Implied by any other administrator privilege""" + can_delete_messages: bool + """True, if the administrator can delete messages of other users""" + can_manage_voice_chats: bool + """True, if the administrator can manage voice chats""" + can_restrict_members: bool + """True, if the administrator can restrict, ban or unban chat members""" + can_promote_members: bool + """True, if the administrator can add new administrators with a subset of their own privileges or demote administrators that he has promoted, directly or indirectly (promoted by administrators that were appointed by the user)""" + can_change_info: bool + """True, if the user is allowed to change the chat title, photo and other settings""" + can_invite_users: bool + """True, if the user is allowed to invite new users to the chat""" + can_post_messages: Optional[bool] = None + """*Optional*. True, if the administrator can post in the channel; channels only""" + can_edit_messages: Optional[bool] = None + """*Optional*. True, if the administrator can edit messages of other users and can pin messages; channels only""" + can_pin_messages: Optional[bool] = None + """*Optional*. True, if the user is allowed to pin messages; groups and supergroups only""" + custom_title: Optional[str] = None + """*Optional*. Custom title for this user""" diff --git a/aiogram/types/chat_member_banned.py b/aiogram/types/chat_member_banned.py new file mode 100644 index 00000000..3b828247 --- /dev/null +++ b/aiogram/types/chat_member_banned.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, Union + +from pydantic import Field + +from .chat_member import ChatMember + +if TYPE_CHECKING: # pragma: no cover + from .user import User + + +class ChatMemberBanned(ChatMember): + """ + Represents a `chat member `_ that was banned in the chat and can't return to the chat or view chat messages. + + Source: https://core.telegram.org/bots/api#chatmemberbanned + """ + + status: str = Field("kicked", const=True) + """The member's status in the chat, always 'kicked'""" + user: User + """Information about the user""" + until_date: Union[datetime.datetime, datetime.timedelta, int] + """Date when restrictions will be lifted for this user; unix time""" diff --git a/aiogram/types/chat_member_left.py b/aiogram/types/chat_member_left.py new file mode 100644 index 00000000..d95cd320 --- /dev/null +++ b/aiogram/types/chat_member_left.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import Field + +from .chat_member import ChatMember + +if TYPE_CHECKING: # pragma: no cover + from .user import User + + +class ChatMemberLeft(ChatMember): + """ + Represents a `chat member `_ that isn't currently a member of the chat, but may join it themselves. + + Source: https://core.telegram.org/bots/api#chatmemberleft + """ + + status: str = Field("left", const=True) + """The member's status in the chat, always 'left'""" + user: User + """Information about the user""" diff --git a/aiogram/types/chat_member_member.py b/aiogram/types/chat_member_member.py new file mode 100644 index 00000000..2c55ea62 --- /dev/null +++ b/aiogram/types/chat_member_member.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import Field + +from .chat_member import ChatMember + +if TYPE_CHECKING: # pragma: no cover + from .user import User + + +class ChatMemberMember(ChatMember): + """ + Represents a `chat member `_ that has no additional privileges or restrictions. + + Source: https://core.telegram.org/bots/api#chatmembermember + """ + + status: str = Field("member", const=True) + """The member's status in the chat, always 'member'""" + user: User + """Information about the user""" diff --git a/aiogram/types/chat_member_owner.py b/aiogram/types/chat_member_owner.py new file mode 100644 index 00000000..dcc766a5 --- /dev/null +++ b/aiogram/types/chat_member_owner.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +from pydantic import Field + +from .chat_member import ChatMember + +if TYPE_CHECKING: # pragma: no cover + from .user import User + + +class ChatMemberOwner(ChatMember): + """ + Represents a `chat member `_ that owns the chat and has all administrator privileges. + + Source: https://core.telegram.org/bots/api#chatmemberowner + """ + + status: str = Field("creator", const=True) + """The member's status in the chat, always 'creator'""" + user: User + """Information about the user""" + is_anonymous: bool + """True, if the user's presence in the chat is hidden""" + custom_title: Optional[str] = None + """*Optional*. Custom title for this user""" diff --git a/aiogram/types/chat_member_restricted.py b/aiogram/types/chat_member_restricted.py new file mode 100644 index 00000000..6860b8d0 --- /dev/null +++ b/aiogram/types/chat_member_restricted.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import datetime +from typing import TYPE_CHECKING, Union + +from pydantic import Field + +from .chat_member import ChatMember + +if TYPE_CHECKING: # pragma: no cover + from .user import User + + +class ChatMemberRestricted(ChatMember): + """ + Represents a `chat member `_ that is under certain restrictions in the chat. Supergroups only. + + Source: https://core.telegram.org/bots/api#chatmemberrestricted + """ + + status: str = Field("restricted", const=True) + """The member's status in the chat, always 'restricted'""" + user: User + """Information about the user""" + is_member: bool + """True, if the user is a member of the chat at the moment of the request""" + can_change_info: bool + """True, if the user is allowed to change the chat title, photo and other settings""" + can_invite_users: bool + """True, if the user is allowed to invite new users to the chat""" + can_pin_messages: bool + """True, if the user is allowed to pin messages; groups and supergroups only""" + can_send_messages: bool + """True, if the user is allowed to send text messages, contacts, locations and venues""" + can_send_media_messages: bool + """True, if the user is allowed to send audios, documents, photos, videos, video notes and voice notes""" + can_send_polls: bool + """True, if the user is allowed to send polls""" + can_send_other_messages: bool + """True, if the user is allowed to send animations, games, stickers and use inline bots""" + can_add_web_page_previews: bool + """True, if the user is allowed to add web page previews to their messages""" + until_date: Union[datetime.datetime, datetime.timedelta, int] + """Date when restrictions will be lifted for this user; unix time""" diff --git a/aiogram/types/chat_member_updated.py b/aiogram/types/chat_member_updated.py index e14a9e88..e7a2ce92 100644 --- a/aiogram/types/chat_member_updated.py +++ b/aiogram/types/chat_member_updated.py @@ -1,7 +1,7 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Union from pydantic import Field @@ -10,7 +10,12 @@ from .base import TelegramObject if TYPE_CHECKING: # pragma: no cover from .chat import Chat from .chat_invite_link import ChatInviteLink - from .chat_member import ChatMember + from .chat_member_administrator import ChatMemberAdministrator + from .chat_member_banned import ChatMemberBanned + from .chat_member_left import ChatMemberLeft + from .chat_member_member import ChatMemberMember + from .chat_member_owner import ChatMemberOwner + from .chat_member_restricted import ChatMemberRestricted from .user import User @@ -27,9 +32,23 @@ class ChatMemberUpdated(TelegramObject): """Performer of the action, which resulted in the change""" date: datetime.datetime """Date the change was done in Unix time""" - old_chat_member: ChatMember + old_chat_member: Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] """Previous information about the chat member""" - new_chat_member: ChatMember + new_chat_member: Union[ + ChatMemberOwner, + ChatMemberAdministrator, + ChatMemberMember, + ChatMemberRestricted, + ChatMemberLeft, + ChatMemberBanned, + ] """New information about the chat member""" invite_link: Optional[ChatInviteLink] = None """*Optional*. Chat invite link, which was used by the user to join the chat; for joining by invite link events only.""" diff --git a/aiogram/types/force_reply.py b/aiogram/types/force_reply.py index 9c29d130..b69ad94c 100644 --- a/aiogram/types/force_reply.py +++ b/aiogram/types/force_reply.py @@ -21,5 +21,7 @@ class ForceReply(MutableTelegramObject): force_reply: bool """Shows reply interface to the user, as if they manually selected the bot's message and tapped 'Reply'""" + input_field_placeholder: Optional[str] = None + """*Optional*. The placeholder to be shown in the input field when the reply is active; 1-64 characters""" selective: Optional[bool] = None """*Optional*. Use this parameter if you want to force reply from specific users only. Targets: 1) users that are @mentioned in the *text* of the :class:`aiogram.types.message.Message` object; 2) if the bot's message is a reply (has *reply_to_message_id*), sender of the original message.""" diff --git a/aiogram/types/message_auto_delete_timer_changed.py b/aiogram/types/message_auto_delete_timer_changed.py index ab62baab..ed5a251f 100644 --- a/aiogram/types/message_auto_delete_timer_changed.py +++ b/aiogram/types/message_auto_delete_timer_changed.py @@ -1,12 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from .base import TelegramObject -if TYPE_CHECKING: # pragma: no cover - pass - class MessageAutoDeleteTimerChanged(TelegramObject): """ diff --git a/aiogram/types/reply_keyboard_markup.py b/aiogram/types/reply_keyboard_markup.py index 33c364d0..dfbd46ed 100644 --- a/aiogram/types/reply_keyboard_markup.py +++ b/aiogram/types/reply_keyboard_markup.py @@ -21,5 +21,7 @@ class ReplyKeyboardMarkup(MutableTelegramObject): """*Optional*. Requests clients to resize the keyboard vertically for optimal fit (e.g., make the keyboard smaller if there are just two rows of buttons). Defaults to *false*, in which case the custom keyboard is always of the same height as the app's standard keyboard.""" one_time_keyboard: Optional[bool] = None """*Optional*. Requests clients to hide the keyboard as soon as it's been used. The keyboard will still be available, but clients will automatically display the usual letter-keyboard in the chat – the user can press a special button in the input field to see the custom keyboard again. Defaults to *false*.""" + input_field_placeholder: Optional[str] = None + """*Optional*. The placeholder to be shown in the input field when the keyboard is active; 1-64 characters""" selective: Optional[bool] = None """*Optional*. Use this parameter if you want to show the keyboard to specific users only. Targets: 1) users that are @mentioned in the *text* of the :class:`aiogram.types.message.Message` object; 2) if the bot's message is a reply (has *reply_to_message_id*), sender of the original message.""" diff --git a/aiogram/types/voice_chat_ended.py b/aiogram/types/voice_chat_ended.py index cd8290d8..2b1ae161 100644 --- a/aiogram/types/voice_chat_ended.py +++ b/aiogram/types/voice_chat_ended.py @@ -1,12 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from .base import TelegramObject -if TYPE_CHECKING: # pragma: no cover - pass - class VoiceChatEnded(TelegramObject): """ diff --git a/aiogram/types/voice_chat_scheduled.py b/aiogram/types/voice_chat_scheduled.py index 8aa8eb97..37c6c7bd 100644 --- a/aiogram/types/voice_chat_scheduled.py +++ b/aiogram/types/voice_chat_scheduled.py @@ -1,12 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from .base import TelegramObject -if TYPE_CHECKING: # pragma: no cover - pass - class VoiceChatScheduled(TelegramObject): """ diff --git a/aiogram/types/voice_chat_started.py b/aiogram/types/voice_chat_started.py index 68b72cf3..6ad45263 100644 --- a/aiogram/types/voice_chat_started.py +++ b/aiogram/types/voice_chat_started.py @@ -1,12 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING - from .base import TelegramObject -if TYPE_CHECKING: # pragma: no cover - pass - class VoiceChatStarted(TelegramObject): """ diff --git a/aiogram/utils/keyboard.py b/aiogram/utils/keyboard.py index 9cb10b02..a8e31c17 100644 --- a/aiogram/utils/keyboard.py +++ b/aiogram/utils/keyboard.py @@ -22,6 +22,7 @@ from aiogram.types import ( InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, + KeyboardButtonPollType, LoginUrl, ReplyKeyboardMarkup, ) @@ -33,7 +34,7 @@ MIN_WIDTH = 1 MAX_BUTTONS = 100 -class KeyboardConstructor(Generic[ButtonType]): +class KeyboardBuilder(Generic[ButtonType]): def __init__( self, button_type: Type[ButtonType], markup: Optional[List[List[ButtonType]]] = None ) -> None: @@ -128,9 +129,9 @@ class KeyboardConstructor(Generic[ButtonType]): raise ValueError(f"Row size {size} are not allowed") return size - def copy(self: "KeyboardConstructor[ButtonType]") -> "KeyboardConstructor[ButtonType]": + def copy(self: "KeyboardBuilder[ButtonType]") -> "KeyboardBuilder[ButtonType]": """ - Make full copy of current constructor with markup + Make full copy of current builder with markup :return: """ @@ -142,15 +143,15 @@ class KeyboardConstructor(Generic[ButtonType]): .. code-block:: python - >>> constructor = KeyboardConstructor(button_type=InlineKeyboardButton) - >>> ... # Add buttons to constructor - >>> markup = InlineKeyboardMarkup(inline_keyboard=constructor.export()) + >>> builder = KeyboardBuilder(button_type=InlineKeyboardButton) + >>> ... # Add buttons to builder + >>> markup = InlineKeyboardMarkup(inline_keyboard=builder.export()) :return: """ return self._markup.copy() - def add(self, *buttons: ButtonType) -> "KeyboardConstructor[ButtonType]": + def add(self, *buttons: ButtonType) -> "KeyboardBuilder[ButtonType]": """ Add one or many buttons to markup. @@ -175,9 +176,7 @@ class KeyboardConstructor(Generic[ButtonType]): self._markup = markup return self - def row( - self, *buttons: ButtonType, width: int = MAX_WIDTH - ) -> "KeyboardConstructor[ButtonType]": + def row(self, *buttons: ButtonType, width: int = MAX_WIDTH) -> "KeyboardBuilder[ButtonType]": """ Add row to markup @@ -194,7 +193,7 @@ class KeyboardConstructor(Generic[ButtonType]): ) return self - def adjust(self, *sizes: int, repeat: bool = False) -> "KeyboardConstructor[ButtonType]": + def adjust(self, *sizes: int, repeat: bool = False) -> "KeyboardBuilder[ButtonType]": """ Adjust previously added buttons to specific row sizes. @@ -226,7 +225,7 @@ class KeyboardConstructor(Generic[ButtonType]): self._markup = markup return self - def button(self, **kwargs: Any) -> "KeyboardConstructor[ButtonType]": + def button(self, **kwargs: Any) -> "KeyboardBuilder[ButtonType]": if isinstance(callback_data := kwargs.get("callback_data", None), CallbackData): kwargs["callback_data"] = callback_data.pack() button = self._button_type(**kwargs) @@ -255,7 +254,7 @@ def repeat_last(items: Iterable[T]) -> Generator[T, None, None]: yield value -class InlineKeyboardConstructor(KeyboardConstructor[InlineKeyboardButton]): +class InlineKeyboardBuilder(KeyboardBuilder[InlineKeyboardButton]): if TYPE_CHECKING: # pragma: no cover @no_type_check @@ -270,7 +269,7 @@ class InlineKeyboardConstructor(KeyboardConstructor[InlineKeyboardButton]): callback_game: Optional[CallbackGame] = None, pay: Optional[bool] = None, **kwargs: Any, - ) -> "KeyboardConstructor[InlineKeyboardButton]": + ) -> "KeyboardBuilder[InlineKeyboardButton]": ... def as_markup(self, **kwargs: Any) -> InlineKeyboardMarkup: @@ -278,3 +277,24 @@ class InlineKeyboardConstructor(KeyboardConstructor[InlineKeyboardButton]): def __init__(self) -> None: super().__init__(InlineKeyboardButton) + + +class ReplyKeyboardBuilder(KeyboardBuilder[KeyboardButton]): + if TYPE_CHECKING: # pragma: no cover + + @no_type_check + def button( + self, + text: str, + request_contact: Optional[bool] = None, + request_location: Optional[bool] = None, + request_poll: Optional[KeyboardButtonPollType] = None, + **kwargs: Any, + ) -> "KeyboardBuilder[KeyboardButton]": + ... + + def as_markup(self, **kwargs: Any) -> ReplyKeyboardMarkup: + ... + + def __init__(self) -> None: + super().__init__(KeyboardButton) diff --git a/docs/api/methods/ban_chat_member.rst b/docs/api/methods/ban_chat_member.rst new file mode 100644 index 00000000..0f8edf8b --- /dev/null +++ b/docs/api/methods/ban_chat_member.rst @@ -0,0 +1,51 @@ +############# +banChatMember +############# + +Returns: :obj:`bool` + +.. automodule:: aiogram.methods.ban_chat_member + :members: + :member-order: bysource + :undoc-members: True + + +Usage +===== + +As bot method +------------- + +.. code-block:: + + result: bool = await bot.ban_chat_member(...) + + +Method as object +---------------- + +Imports: + +- :code:`from aiogram.methods.ban_chat_member import BanChatMember` +- alias: :code:`from aiogram.methods import BanChatMember` + +In handlers with current bot +---------------------------- + +.. code-block:: python + + result: bool = await BanChatMember(...) + +With specific bot +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + result: bool = await bot(BanChatMember(...)) + +As reply into Webhook in handler +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + return BanChatMember(...) diff --git a/docs/api/methods/delete_my_commands.rst b/docs/api/methods/delete_my_commands.rst new file mode 100644 index 00000000..5077f68c --- /dev/null +++ b/docs/api/methods/delete_my_commands.rst @@ -0,0 +1,51 @@ +################ +deleteMyCommands +################ + +Returns: :obj:`bool` + +.. automodule:: aiogram.methods.delete_my_commands + :members: + :member-order: bysource + :undoc-members: True + + +Usage +===== + +As bot method +------------- + +.. code-block:: + + result: bool = await bot.delete_my_commands(...) + + +Method as object +---------------- + +Imports: + +- :code:`from aiogram.methods.delete_my_commands import DeleteMyCommands` +- alias: :code:`from aiogram.methods import DeleteMyCommands` + +In handlers with current bot +---------------------------- + +.. code-block:: python + + result: bool = await DeleteMyCommands(...) + +With specific bot +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + result: bool = await bot(DeleteMyCommands(...)) + +As reply into Webhook in handler +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + return DeleteMyCommands(...) diff --git a/docs/api/methods/get_chat_member_count.rst b/docs/api/methods/get_chat_member_count.rst new file mode 100644 index 00000000..2bee4cd9 --- /dev/null +++ b/docs/api/methods/get_chat_member_count.rst @@ -0,0 +1,44 @@ +################## +getChatMemberCount +################## + +Returns: :obj:`int` + +.. automodule:: aiogram.methods.get_chat_member_count + :members: + :member-order: bysource + :undoc-members: True + + +Usage +===== + +As bot method +------------- + +.. code-block:: + + result: int = await bot.get_chat_member_count(...) + + +Method as object +---------------- + +Imports: + +- :code:`from aiogram.methods.get_chat_member_count import GetChatMemberCount` +- alias: :code:`from aiogram.methods import GetChatMemberCount` + +In handlers with current bot +---------------------------- + +.. code-block:: python + + result: int = await GetChatMemberCount(...) + +With specific bot +~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + result: int = await bot(GetChatMemberCount(...)) diff --git a/docs/api/methods/index.rst b/docs/api/methods/index.rst index 94c08366..22fbff3e 100644 --- a/docs/api/methods/index.rst +++ b/docs/api/methods/index.rst @@ -48,6 +48,7 @@ Available methods send_chat_action get_user_profile_photos get_file + ban_chat_member kick_chat_member unban_chat_member restrict_chat_member @@ -68,12 +69,14 @@ Available methods leave_chat get_chat get_chat_administrators + get_chat_member_count get_chat_members_count get_chat_member set_chat_sticker_set delete_chat_sticker_set answer_callback_query set_my_commands + delete_my_commands get_my_commands Updating messages diff --git a/docs/api/types/bot_command_scope.rst b/docs/api/types/bot_command_scope.rst new file mode 100644 index 00000000..fa89f3ab --- /dev/null +++ b/docs/api/types/bot_command_scope.rst @@ -0,0 +1,9 @@ +############### +BotCommandScope +############### + + +.. automodule:: aiogram.types.bot_command_scope + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_all_chat_administrators.rst b/docs/api/types/bot_command_scope_all_chat_administrators.rst new file mode 100644 index 00000000..cfde1f73 --- /dev/null +++ b/docs/api/types/bot_command_scope_all_chat_administrators.rst @@ -0,0 +1,9 @@ +#################################### +BotCommandScopeAllChatAdministrators +#################################### + + +.. automodule:: aiogram.types.bot_command_scope_all_chat_administrators + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_all_group_chats.rst b/docs/api/types/bot_command_scope_all_group_chats.rst new file mode 100644 index 00000000..2fe3ec7a --- /dev/null +++ b/docs/api/types/bot_command_scope_all_group_chats.rst @@ -0,0 +1,9 @@ +############################ +BotCommandScopeAllGroupChats +############################ + + +.. automodule:: aiogram.types.bot_command_scope_all_group_chats + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_all_private_chats.rst b/docs/api/types/bot_command_scope_all_private_chats.rst new file mode 100644 index 00000000..4c018322 --- /dev/null +++ b/docs/api/types/bot_command_scope_all_private_chats.rst @@ -0,0 +1,9 @@ +############################## +BotCommandScopeAllPrivateChats +############################## + + +.. automodule:: aiogram.types.bot_command_scope_all_private_chats + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_chat.rst b/docs/api/types/bot_command_scope_chat.rst new file mode 100644 index 00000000..ee7900fc --- /dev/null +++ b/docs/api/types/bot_command_scope_chat.rst @@ -0,0 +1,9 @@ +################### +BotCommandScopeChat +################### + + +.. automodule:: aiogram.types.bot_command_scope_chat + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_chat_administrators.rst b/docs/api/types/bot_command_scope_chat_administrators.rst new file mode 100644 index 00000000..76e72c45 --- /dev/null +++ b/docs/api/types/bot_command_scope_chat_administrators.rst @@ -0,0 +1,9 @@ +################################# +BotCommandScopeChatAdministrators +################################# + + +.. automodule:: aiogram.types.bot_command_scope_chat_administrators + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_chat_member.rst b/docs/api/types/bot_command_scope_chat_member.rst new file mode 100644 index 00000000..60a76fa1 --- /dev/null +++ b/docs/api/types/bot_command_scope_chat_member.rst @@ -0,0 +1,9 @@ +######################### +BotCommandScopeChatMember +######################### + + +.. automodule:: aiogram.types.bot_command_scope_chat_member + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/bot_command_scope_default.rst b/docs/api/types/bot_command_scope_default.rst new file mode 100644 index 00000000..fe97331b --- /dev/null +++ b/docs/api/types/bot_command_scope_default.rst @@ -0,0 +1,9 @@ +###################### +BotCommandScopeDefault +###################### + + +.. automodule:: aiogram.types.bot_command_scope_default + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/chat_member_administrator.rst b/docs/api/types/chat_member_administrator.rst new file mode 100644 index 00000000..55302054 --- /dev/null +++ b/docs/api/types/chat_member_administrator.rst @@ -0,0 +1,9 @@ +####################### +ChatMemberAdministrator +####################### + + +.. automodule:: aiogram.types.chat_member_administrator + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/chat_member_banned.rst b/docs/api/types/chat_member_banned.rst new file mode 100644 index 00000000..31570bec --- /dev/null +++ b/docs/api/types/chat_member_banned.rst @@ -0,0 +1,9 @@ +################ +ChatMemberBanned +################ + + +.. automodule:: aiogram.types.chat_member_banned + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/chat_member_left.rst b/docs/api/types/chat_member_left.rst new file mode 100644 index 00000000..52a4dd9d --- /dev/null +++ b/docs/api/types/chat_member_left.rst @@ -0,0 +1,9 @@ +############## +ChatMemberLeft +############## + + +.. automodule:: aiogram.types.chat_member_left + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/chat_member_member.rst b/docs/api/types/chat_member_member.rst new file mode 100644 index 00000000..8f884af9 --- /dev/null +++ b/docs/api/types/chat_member_member.rst @@ -0,0 +1,9 @@ +################ +ChatMemberMember +################ + + +.. automodule:: aiogram.types.chat_member_member + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/chat_member_owner.rst b/docs/api/types/chat_member_owner.rst new file mode 100644 index 00000000..09eee65c --- /dev/null +++ b/docs/api/types/chat_member_owner.rst @@ -0,0 +1,9 @@ +############### +ChatMemberOwner +############### + + +.. automodule:: aiogram.types.chat_member_owner + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/chat_member_restricted.rst b/docs/api/types/chat_member_restricted.rst new file mode 100644 index 00000000..dcc3db58 --- /dev/null +++ b/docs/api/types/chat_member_restricted.rst @@ -0,0 +1,9 @@ +#################### +ChatMemberRestricted +#################### + + +.. automodule:: aiogram.types.chat_member_restricted + :members: + :member-order: bysource + :undoc-members: True diff --git a/docs/api/types/index.rst b/docs/api/types/index.rst index 2e3b3812..2309261b 100644 --- a/docs/api/types/index.rst +++ b/docs/api/types/index.rst @@ -60,10 +60,24 @@ Available types chat_photo chat_invite_link chat_member + chat_member_owner + chat_member_administrator + chat_member_member + chat_member_restricted + chat_member_left + chat_member_banned chat_member_updated chat_permissions chat_location bot_command + bot_command_scope + bot_command_scope_default + bot_command_scope_all_private_chats + bot_command_scope_all_group_chats + bot_command_scope_all_chat_administrators + bot_command_scope_chat + bot_command_scope_chat_administrators + bot_command_scope_chat_member response_parameters input_media input_media_photo diff --git a/docs/dispatcher/finite_state_machine/storages.rst b/docs/dispatcher/finite_state_machine/storages.rst new file mode 100644 index 00000000..f2fefe41 --- /dev/null +++ b/docs/dispatcher/finite_state_machine/storages.rst @@ -0,0 +1,23 @@ +######## +Storages +######## + +Storages out of the box +======================= + +MemoryStorage +------------- + +.. autoclass:: aiogram.dispatcher.fsm.storage.memory.MemoryStorage + :members: __init__, from_url + :member-order: bysource + +RedisStorage +------------ + +.. autoclass:: aiogram.dispatcher.fsm.storage.redis.RedisStorage + :members: __init__, from_url + :member-order: bysource + +Writing own storages +==================== diff --git a/tests/conftest.py b/tests/conftest.py index 7586f389..f2d0cebc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,6 +42,10 @@ async def redis_storage(redis_server): if not redis_server: pytest.skip("Redis is not available here") storage = RedisStorage.from_url(redis_server) + try: + await storage.redis.info() + except ConnectionError as e: + pytest.skip(str(e)) try: yield storage finally: diff --git a/tests/test_api/test_methods/test_ban_chat_member.py b/tests/test_api/test_methods/test_ban_chat_member.py new file mode 100644 index 00000000..bdbf1c41 --- /dev/null +++ b/tests/test_api/test_methods/test_ban_chat_member.py @@ -0,0 +1,24 @@ +import pytest + +from aiogram.methods import BanChatMember, Request +from tests.mocked_bot import MockedBot + + +class TestKickChatMember: + @pytest.mark.asyncio + async def test_method(self, bot: MockedBot): + prepare_result = bot.add_result_for(BanChatMember, ok=True, result=True) + + response: bool = await BanChatMember(chat_id=-42, user_id=42) + request: Request = bot.get_request() + assert request.method == "banChatMember" + assert response == prepare_result.result + + @pytest.mark.asyncio + async def test_bot_method(self, bot: MockedBot): + prepare_result = bot.add_result_for(BanChatMember, ok=True, result=True) + + response: bool = await bot.ban_chat_member(chat_id=-42, user_id=42) + request: Request = bot.get_request() + assert request.method == "banChatMember" + assert response == prepare_result.result diff --git a/tests/test_api/test_methods/test_delete_my_commands.py b/tests/test_api/test_methods/test_delete_my_commands.py new file mode 100644 index 00000000..a39d080b --- /dev/null +++ b/tests/test_api/test_methods/test_delete_my_commands.py @@ -0,0 +1,24 @@ +import pytest + +from aiogram.methods import BanChatMember, DeleteMyCommands, Request +from tests.mocked_bot import MockedBot + + +class TestKickChatMember: + @pytest.mark.asyncio + async def test_method(self, bot: MockedBot): + prepare_result = bot.add_result_for(DeleteMyCommands, ok=True, result=True) + + response: bool = await DeleteMyCommands() + request: Request = bot.get_request() + assert request.method == "deleteMyCommands" + assert response == prepare_result.result + + @pytest.mark.asyncio + async def test_bot_method(self, bot: MockedBot): + prepare_result = bot.add_result_for(DeleteMyCommands, ok=True, result=True) + + response: bool = await bot.delete_my_commands() + request: Request = bot.get_request() + assert request.method == "deleteMyCommands" + assert response == prepare_result.result diff --git a/tests/test_api/test_methods/test_get_chat_administrators.py b/tests/test_api/test_methods/test_get_chat_administrators.py index 9309460b..f6de6db5 100644 --- a/tests/test_api/test_methods/test_get_chat_administrators.py +++ b/tests/test_api/test_methods/test_get_chat_administrators.py @@ -3,7 +3,7 @@ from typing import List import pytest from aiogram.methods import GetChatAdministrators, Request -from aiogram.types import ChatMember, User +from aiogram.types import ChatMember, ChatMemberOwner, User from tests.mocked_bot import MockedBot @@ -14,7 +14,9 @@ class TestGetChatAdministrators: GetChatAdministrators, ok=True, result=[ - ChatMember(user=User(id=42, is_bot=False, first_name="User"), status="creator") + ChatMemberOwner( + user=User(id=42, is_bot=False, first_name="User"), is_anonymous=False + ) ], ) @@ -29,7 +31,9 @@ class TestGetChatAdministrators: GetChatAdministrators, ok=True, result=[ - ChatMember(user=User(id=42, is_bot=False, first_name="User"), status="creator") + ChatMemberOwner( + user=User(id=42, is_bot=False, first_name="User"), is_anonymous=False + ) ], ) response: List[ChatMember] = await bot.get_chat_administrators(chat_id=-42) diff --git a/tests/test_api/test_methods/test_get_chat_member.py b/tests/test_api/test_methods/test_get_chat_member.py index 111b06cd..4018433e 100644 --- a/tests/test_api/test_methods/test_get_chat_member.py +++ b/tests/test_api/test_methods/test_get_chat_member.py @@ -1,7 +1,7 @@ import pytest from aiogram.methods import GetChatMember, Request -from aiogram.types import ChatMember, User +from aiogram.types import ChatMember, ChatMemberOwner, User from tests.mocked_bot import MockedBot @@ -11,7 +11,9 @@ class TestGetChatMember: prepare_result = bot.add_result_for( GetChatMember, ok=True, - result=ChatMember(user=User(id=42, is_bot=False, first_name="User"), status="creator"), + result=ChatMemberOwner( + user=User(id=42, is_bot=False, first_name="User"), is_anonymous=False + ), ) response: ChatMember = await GetChatMember(chat_id=-42, user_id=42) @@ -24,7 +26,9 @@ class TestGetChatMember: prepare_result = bot.add_result_for( GetChatMember, ok=True, - result=ChatMember(user=User(id=42, is_bot=False, first_name="User"), status="creator"), + result=ChatMemberOwner( + user=User(id=42, is_bot=False, first_name="User"), is_anonymous=False + ), ) response: ChatMember = await bot.get_chat_member(chat_id=-42, user_id=42) diff --git a/tests/test_api/test_methods/test_get_chat_member_count.py b/tests/test_api/test_methods/test_get_chat_member_count.py new file mode 100644 index 00000000..a3ecec72 --- /dev/null +++ b/tests/test_api/test_methods/test_get_chat_member_count.py @@ -0,0 +1,24 @@ +import pytest + +from aiogram.methods import GetChatMemberCount, Request +from tests.mocked_bot import MockedBot + + +class TestGetChatMembersCount: + @pytest.mark.asyncio + async def test_method(self, bot: MockedBot): + prepare_result = bot.add_result_for(GetChatMemberCount, ok=True, result=42) + + response: int = await GetChatMemberCount(chat_id=-42) + request: Request = bot.get_request() + assert request.method == "getChatMemberCount" + assert response == prepare_result.result + + @pytest.mark.asyncio + async def test_bot_method(self, bot: MockedBot): + prepare_result = bot.add_result_for(GetChatMemberCount, ok=True, result=42) + + response: int = await bot.get_chat_member_count(chat_id=-42) + request: Request = bot.get_request() + assert request.method == "getChatMemberCount" + assert response == prepare_result.result diff --git a/tests/test_api/test_types/test_chat_member.py b/tests/test_api/test_types/test_chat_member.py deleted file mode 100644 index e92c7203..00000000 --- a/tests/test_api/test_types/test_chat_member.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest - -from aiogram.types import ChatMember, User - -user = User(id=42, is_bot=False, first_name="User", last_name=None) - - -class TestChatMember: - @pytest.mark.parametrize( - "status,result", [["administrator", True], ["creator", True], ["member", False]] - ) - def test_is_chat_admin(self, status: str, result: bool): - chat_member = ChatMember(user=user, status=status) - assert chat_member.is_chat_admin == result - - @pytest.mark.parametrize( - "status,result", - [ - ["administrator", True], - ["creator", True], - ["member", True], - ["restricted", True], - ["kicked", False], - ["left", False], - ], - ) - def test_is_chat_member(self, status: str, result: bool): - chat_member = ChatMember(user=user, status=status) - assert chat_member.is_chat_member == result diff --git a/tests/test_dispatcher/test_dispatcher.py b/tests/test_dispatcher/test_dispatcher.py index 5f7a2f62..5bcbd9f6 100644 --- a/tests/test_dispatcher/test_dispatcher.py +++ b/tests/test_dispatcher/test_dispatcher.py @@ -2,6 +2,7 @@ import asyncio import datetime import time import warnings +from collections import Counter from typing import Any import pytest @@ -14,7 +15,7 @@ from aiogram.methods import GetMe, GetUpdates, SendMessage from aiogram.types import ( CallbackQuery, Chat, - ChatMember, + ChatMemberMember, ChatMemberUpdated, ChosenInlineResult, InlineQuery, @@ -375,11 +376,11 @@ class TestDispatcher: chat=Chat(id=42, type="private"), from_user=User(id=42, is_bot=False, first_name="Test"), date=datetime.datetime.now(), - old_chat_member=ChatMember( - user=User(id=42, is_bot=False, first_name="Test"), status="restricted" + old_chat_member=ChatMemberMember( + user=User(id=42, is_bot=False, first_name="Test") ), - new_chat_member=ChatMember( - user=User(id=42, is_bot=False, first_name="Test"), status="restricted" + new_chat_member=ChatMemberMember( + user=User(id=42, is_bot=False, first_name="Test") ), ), ), @@ -394,11 +395,11 @@ class TestDispatcher: chat=Chat(id=42, type="private"), from_user=User(id=42, is_bot=False, first_name="Test"), date=datetime.datetime.now(), - old_chat_member=ChatMember( - user=User(id=42, is_bot=False, first_name="Test"), status="restricted" + old_chat_member=ChatMemberMember( + user=User(id=42, is_bot=False, first_name="Test") ), - new_chat_member=ChatMember( - user=User(id=42, is_bot=False, first_name="Test"), status="restricted" + new_chat_member=ChatMemberMember( + user=User(id=42, is_bot=False, first_name="Test") ), ), ), @@ -494,6 +495,50 @@ class TestDispatcher: assert result["event_router"] == router1 assert result["test"] == "PASS" + @pytest.mark.asyncio + async def test_nested_router_middleware_resolution(self, bot: MockedBot): + counter = Counter() + + def mw(type_: str, inject_data: dict): + async def middleware(h, event, data): + counter[type_] += 1 + data.update(inject_data) + return await h(event, data) + + return middleware + + async def handler(event, foo, bar, baz, fizz, buzz): + counter["child.handler"] += 1 + + root = Dispatcher() + child = Router() + + root.message.outer_middleware(mw("root.outer_middleware", {"foo": True})) + root.message.middleware(mw("root.middleware", {"bar": None})) + child.message.outer_middleware(mw("child.outer_middleware", {"fizz": 42})) + child.message.middleware(mw("child.middleware", {"buzz": -42})) + child.message.register(handler) + + root.include_router(child) + await root.feed_update( + bot=bot, + update=Update( + update_id=42, + message=Message( + message_id=42, + date=datetime.datetime.fromtimestamp(0), + chat=Chat(id=-42, type="group"), + ), + ), + baz=..., + ) + + assert counter["root.outer_middleware"] == 2 + assert counter["root.middleware"] == 1 + assert counter["child.outer_middleware"] == 1 + assert counter["child.middleware"] == 1 + assert counter["child.handler"] == 1 + @pytest.mark.asyncio async def test_process_update_call_request(self, bot: MockedBot): dispatcher = Dispatcher() diff --git a/tests/test_dispatcher/test_filters/test_callback_data.py b/tests/test_dispatcher/test_filters/test_callback_data.py index f71ca706..20d10be3 100644 --- a/tests/test_dispatcher/test_filters/test_callback_data.py +++ b/tests/test_dispatcher/test_filters/test_callback_data.py @@ -156,7 +156,11 @@ class TestCallbackDataFilter: ["test", F.foo == "test", False], ["test:spam:42", F.foo == "test", False], ["test:test:42", F.foo == "test", {"callback_data": MyCallback(foo="test", bar=42)}], + ["test:test:42", None, {"callback_data": MyCallback(foo="test", bar=42)}], + ["test:test:777", None, {"callback_data": MyCallback(foo="test", bar=777)}], + ["spam:test:777", None, False], ["test:test:", F.foo == "test", False], + ["test:test:", None, False], ], ) @pytest.mark.asyncio diff --git a/tests/test_dispatcher/test_handler/test_chat_member.py b/tests/test_dispatcher/test_handler/test_chat_member.py index baf1ee85..8b4ffe2c 100644 --- a/tests/test_dispatcher/test_handler/test_chat_member.py +++ b/tests/test_dispatcher/test_handler/test_chat_member.py @@ -4,7 +4,7 @@ from typing import Any import pytest from aiogram.dispatcher.handler.chat_member import ChatMemberHandler -from aiogram.types import Chat, ChatMember, ChatMemberUpdated, User +from aiogram.types import Chat, ChatMemberMember, ChatMemberUpdated, User class TestChatMemberUpdated: @@ -14,12 +14,8 @@ class TestChatMemberUpdated: chat=Chat(id=42, type="private"), from_user=User(id=42, is_bot=False, first_name="Test"), date=datetime.datetime.now(), - old_chat_member=ChatMember( - user=User(id=42, is_bot=False, first_name="Test"), status="restricted" - ), - new_chat_member=ChatMember( - user=User(id=42, is_bot=False, first_name="Test"), status="restricted" - ), + old_chat_member=ChatMemberMember(user=User(id=42, is_bot=False, first_name="Test")), + new_chat_member=ChatMemberMember(user=User(id=42, is_bot=False, first_name="Test")), ) class MyHandler(ChatMemberHandler): From a70ecb767f282b030d4d73185f171a503b37092c Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Thu, 29 Jul 2021 00:42:53 +0300 Subject: [PATCH 17/38] Bump version --- aiogram/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aiogram/__init__.py b/aiogram/__init__.py index ca17b233..1db738b1 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -37,5 +37,5 @@ __all__ = ( "md", ) -__version__ = "3.0.0a10" +__version__ = "3.0.0a11" __api_version__ = "5.2" diff --git a/pyproject.toml b/pyproject.toml index c005ec52..b9e92418 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aiogram" -version = "3.0.0-alpha.10" +version = "3.0.0-alpha.11" description = "Modern and fully asynchronous framework for Telegram Bot API" authors = ["Alex Root Junior "] license = "MIT" From 4f2cc75951d18766be50bb9c3d14a3bc525a071c Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 31 Jul 2021 23:34:09 +0300 Subject: [PATCH 18/38] Global filters for router (#644) * Bump version * Added more comments * Cover registering global filters * Reformat code * Add more tests * Rework event propagation to routers mechanism. Fixed compatibility with Python 3.10 syntax (match keyword) * Fixed tests * Fixed coverage Co-authored-by: evgfilim1 --- aiogram/dispatcher/dispatcher.py | 13 +--- aiogram/dispatcher/event/bases.py | 1 + aiogram/dispatcher/event/telegram.py | 26 +++++++- aiogram/dispatcher/filters/command.py | 4 +- aiogram/dispatcher/filters/exception.py | 6 +- aiogram/dispatcher/fsm/storage/redis.py | 6 +- aiogram/dispatcher/router.py | 18 ++++++ poetry.lock | 59 +++++++++++++++++-- pyproject.toml | 3 +- .../test_methods/test_delete_my_commands.py | 2 +- .../test_event/test_telegram.py | 47 ++++++++++++++- .../test_filters/test_exception.py | 6 +- tests/test_dispatcher/test_router.py | 16 ++++- 13 files changed, 176 insertions(+), 31 deletions(-) diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index 109bb920..ee61d1d4 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -232,20 +232,11 @@ class Dispatcher(Router): "installed not latest version of aiogram framework", RuntimeWarning, ) - raise SkipHandler + raise SkipHandler() kwargs.update(event_update=update) - for router in self.chain: - kwargs.update(event_router=router) - observer = router.observers[update_type] - response = await observer.trigger(event, update=update, **kwargs) - if response is not UNHANDLED: - break - else: - response = UNHANDLED - - return response + return await self.propagate_event(update_type=update_type, event=event, **kwargs) @classmethod async def _silent_call_request(cls, bot: Bot, result: TelegramMethod[Any]) -> None: diff --git a/aiogram/dispatcher/event/bases.py b/aiogram/dispatcher/event/bases.py index cb5fb2cf..8e5937ec 100644 --- a/aiogram/dispatcher/event/bases.py +++ b/aiogram/dispatcher/event/bases.py @@ -12,6 +12,7 @@ MiddlewareType = Union[ ] UNHANDLED = sentinel.UNHANDLED +REJECTED = sentinel.REJECTED class SkipHandler(Exception): diff --git a/aiogram/dispatcher/event/telegram.py b/aiogram/dispatcher/event/telegram.py index f91e8b77..ad03c06c 100644 --- a/aiogram/dispatcher/event/telegram.py +++ b/aiogram/dispatcher/event/telegram.py @@ -8,7 +8,7 @@ from pydantic import ValidationError from ...types import TelegramObject from ..filters.base import BaseFilter -from .bases import UNHANDLED, MiddlewareType, NextMiddlewareType, SkipHandler +from .bases import REJECTED, UNHANDLED, MiddlewareType, NextMiddlewareType, SkipHandler from .handler import CallbackType, FilterObject, FilterType, HandlerObject, HandlerType if TYPE_CHECKING: # pragma: no cover @@ -32,6 +32,24 @@ class TelegramEventObserver: self.outer_middlewares: List[MiddlewareType] = [] self.middlewares: List[MiddlewareType] = [] + # Re-used filters check method from already implemented handler object + # with dummy callback which never will be used + self._handler = HandlerObject(callback=lambda: True, filters=[]) + + def filter(self, *filters: FilterType, **bound_filters: Any) -> None: + """ + Register filter for all handlers of this event observer + + :param filters: positional filters + :param bound_filters: keyword filters + """ + resolved_filters = self.resolve_filters(bound_filters) + if self._handler.filters is None: + self._handler.filters = [] + self._handler.filters.extend( + [FilterObject(filter_) for filter_ in chain(resolved_filters, filters)] + ) + def bind_filter(self, bound_filter: Type[BaseFilter]) -> None: """ Register filter class in factory @@ -139,6 +157,12 @@ class TelegramEventObserver: return await wrapped_outer(event, kwargs) async def _trigger(self, event: TelegramObject, **kwargs: Any) -> Any: + # Check globally defined filters before any other handler will be checked + result, data = await self._handler.check(event, **kwargs) + if not result: + return REJECTED + kwargs.update(data) + for handler in self.handlers: result, data = await handler.check(event, **kwargs) if result: diff --git a/aiogram/dispatcher/filters/command.py b/aiogram/dispatcher/filters/command.py index ba760bef..0e46c1ec 100644 --- a/aiogram/dispatcher/filters/command.py +++ b/aiogram/dispatcher/filters/command.py @@ -89,7 +89,7 @@ class Command(BaseFilter): if isinstance(allowed_command, Pattern): # Regexp result = allowed_command.match(command.command) if result: - return replace(command, match=result) + return replace(command, regexp_match=result) elif command.command == allowed_command: # String return command raise CommandException("Command did not match pattern") @@ -134,7 +134,7 @@ class CommandObject: """Mention (if available)""" args: Optional[str] = field(repr=False, default=None) """Command argument""" - match: Optional[Match[str]] = field(repr=False, default=None) + regexp_match: Optional[Match[str]] = field(repr=False, default=None) """Will be presented match result if the command is presented as regexp in filter""" @property diff --git a/aiogram/dispatcher/filters/exception.py b/aiogram/dispatcher/filters/exception.py index f46cd739..f4a077f8 100644 --- a/aiogram/dispatcher/filters/exception.py +++ b/aiogram/dispatcher/filters/exception.py @@ -26,20 +26,20 @@ class ExceptionMessageFilter(BaseFilter): Allow to match exception by message """ - match: Union[str, Pattern[str]] + pattern: Union[str, Pattern[str]] """Regexp pattern""" class Config: arbitrary_types_allowed = True - @validator("match") + @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 async def __call__(self, exception: Exception) -> Union[bool, Dict[str, Any]]: - pattern = cast(Pattern[str], self.match) + pattern = cast(Pattern[str], self.pattern) result = pattern.match(str(exception)) if not result: return False diff --git a/aiogram/dispatcher/fsm/storage/redis.py b/aiogram/dispatcher/fsm/storage/redis.py index 64c832f9..f176e209 100644 --- a/aiogram/dispatcher/fsm/storage/redis.py +++ b/aiogram/dispatcher/fsm/storage/redis.py @@ -45,7 +45,7 @@ class RedisStorage(BaseStorage): return cls(redis=redis, **kwargs) async def close(self) -> None: - await self.redis.close() + await self.redis.close() # type: ignore def generate_key(self, bot: Bot, *parts: Any) -> str: prefix_parts = [self.prefix] @@ -73,7 +73,7 @@ class RedisStorage(BaseStorage): await self.redis.delete(key) else: await self.redis.set( - key, state.state if isinstance(state, State) else state, ex=self.state_ttl + key, state.state if isinstance(state, State) else state, ex=self.state_ttl # type: ignore[arg-type] ) async def get_state(self, bot: Bot, chat_id: int, user_id: int) -> Optional[str]: @@ -89,7 +89,7 @@ class RedisStorage(BaseStorage): await self.redis.delete(key) return json_data = bot.session.json_dumps(data) - await self.redis.set(key, json_data, ex=self.data_ttl) + await self.redis.set(key, json_data, ex=self.data_ttl) # type: ignore[arg-type] async def get_data(self, bot: Bot, chat_id: int, user_id: int) -> Dict[str, Any]: key = self.generate_key(bot, chat_id, user_id, STATE_DATA_KEY) diff --git a/aiogram/dispatcher/router.py b/aiogram/dispatcher/router.py index b61afc68..bc66d0de 100644 --- a/aiogram/dispatcher/router.py +++ b/aiogram/dispatcher/router.py @@ -3,8 +3,10 @@ from __future__ import annotations import warnings from typing import Any, Dict, Generator, List, Optional, Union +from ..types import TelegramObject from ..utils.imports import import_module from ..utils.warnings import CodeHasNoEffect +from .event.bases import REJECTED, UNHANDLED from .event.event import EventObserver from .event.telegram import TelegramEventObserver from .filters import BUILTIN_FILTERS @@ -82,6 +84,22 @@ class Router: for builtin_filter in BUILTIN_FILTERS.get(name, ()): observer.bind_filter(builtin_filter) + async def propagate_event(self, update_type: str, event: TelegramObject, **kwargs: Any) -> Any: + kwargs.update(event_router=self) + observer = self.observers[update_type] + response = await observer.trigger(event, **kwargs) + if response is REJECTED: + return UNHANDLED + if response is not UNHANDLED: + return response + + for router in self.sub_routers: + response = await router.propagate_event(update_type=update_type, event=event, **kwargs) + if response is not UNHANDLED: + break + + return response + @property def chain_head(self) -> Generator[Router, None, None]: router: Optional[Router] = self diff --git a/poetry.lock b/poetry.lock index 4e6bcb7d..7f324bfc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -40,7 +40,7 @@ python-socks = {version = ">=1.0.1", extras = ["asyncio"]} [[package]] name = "aioredis" -version = "2.0.0a1" +version = "2.0.0" description = "asyncio (PEP 3156) Redis support" category = "main" optional = false @@ -296,6 +296,14 @@ importlib-metadata = "*" jinja2 = ">=2.9.0" pygments = ">=2.2.0" +[[package]] +name = "frozenlist" +version = "1.1.1" +description = "A list-like structure which implements collections.abc.MutableSequence" +category = "main" +optional = false +python-versions = ">=3.6" + [[package]] name = "furo" version = "2021.6.18b36" @@ -1201,7 +1209,7 @@ redis = ["aioredis"] [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "ef3571030ff35c2a05e01dca86e9347239e98ad0f45bed6f5d9a73121013f376" +content-hash = "c51e22cdb0e17fb996fda81c5484d34f3dff0e57511380b1103a1d53c9416440" [metadata.files] aiofiles = [ @@ -1252,8 +1260,8 @@ aiohttp-socks = [ {file = "aiohttp_socks-0.5.5.tar.gz", hash = "sha256:2eb2059756bde34c55bb429541cbf2eba3fd53e36ac80875b461221e2858b04a"}, ] aioredis = [ - {file = "aioredis-2.0.0a1-py3-none-any.whl", hash = "sha256:32d7910724282a475c91b8b34403867069a4f07bf0c5ad5fe66cd797322f9a0d"}, - {file = "aioredis-2.0.0a1.tar.gz", hash = "sha256:5884f384b8ecb143bb73320a96e7c464fd38e117950a7d48340a35db8e35e7d2"}, + {file = "aioredis-2.0.0-py3-none-any.whl", hash = "sha256:9921d68a3df5c5cdb0d5b49ad4fc88a4cfdd60c108325df4f0066e8410c55ffb"}, + {file = "aioredis-2.0.0.tar.gz", hash = "sha256:3a2de4b614e6a5f8e104238924294dc4e811aefbe17ddf52c04a93cbf06e67db"}, ] alabaster = [ {file = "alabaster-0.7.12-py2.py3-none-any.whl", hash = "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359"}, @@ -1401,6 +1409,49 @@ flake8-html = [ {file = "flake8-html-0.4.1.tar.gz", hash = "sha256:2fb436cbfe1e109275bc8fb7fdd0cb00e67b3b48cfeb397309b6b2c61eeb4cb4"}, {file = "flake8_html-0.4.1-py2.py3-none-any.whl", hash = "sha256:17324eb947e7006807e4184ee26953e67baf421b3cf9e646a38bfec34eec5a94"}, ] +frozenlist = [ + {file = "frozenlist-1.1.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:968b520ef969541b2c8f47d9a13c78e080806dc97862434d29163d44c2c1d709"}, + {file = "frozenlist-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:451b445120ea95d86af3817bbd4d67ab77269fe7f055dc67b8c70bf4633f4efe"}, + {file = "frozenlist-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:313384e54a7285a6f20ca6530b207a0a9cf6ebcda6c7b074ee802e4a82a0a6dc"}, + {file = "frozenlist-1.1.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:3c4f7399e7338a5788d32802017f94aaab3267afa8b1a663272b81eee7193e66"}, + {file = "frozenlist-1.1.1-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1e7b18fdf6682028f512d3e6142b79ca95b9b66f30c1bec2be237160d9eb6518"}, + {file = "frozenlist-1.1.1-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:7622f5c4c3dfaa09b9c6a62fb1af94da124626bb30f3ad9095f9cec6328074c1"}, + {file = "frozenlist-1.1.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:657341b9bc166d3f7418d37e1decf6d95485501e0d0e7da1a26a881e624216c6"}, + {file = "frozenlist-1.1.1-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:fc6d994de78b11e1f465f2224c56858eb52cb51c8f9faf0c33e5799184d414a7"}, + {file = "frozenlist-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:08428f9d0178b6fa0da95a42ab87a5b20ed2a707bacc97e3689e96ae6cab13fa"}, + {file = "frozenlist-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:5c4c42bdbf5754010e0cc5cc0f91019437839bc6b7e585262bcc126557a244bc"}, + {file = "frozenlist-1.1.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:32491ac26e72e5f35913887bc3ab7bcfe562b4fb65b0e58350fce6efa22fec75"}, + {file = "frozenlist-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:d160a73e4a034a857a98384b5e05204c375489d2bbb6ecf1ee8fc124735028fa"}, + {file = "frozenlist-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:b3cf6737afc4347092a0c8392b4c0e77acc5594e73f4aef355705117a945743a"}, + {file = "frozenlist-1.1.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:efb805e383836250bef3c99f1857c432a8941c802d0ed7767751315617a54794"}, + {file = "frozenlist-1.1.1-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:2f1f56a36962e28c304872797e226cd646395381de97517870fb819ff7b4f496"}, + {file = "frozenlist-1.1.1-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:7443d815fd9ff2de75b810e192cfa92854bada43aed47ec1598766c7bc9d4a40"}, + {file = "frozenlist-1.1.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe463e7b3cd089d221f33bd9c22cfad2726622b2a96c3af56a8eb5a71c0943bd"}, + {file = "frozenlist-1.1.1-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:c228886891dc0170d21acbfb62fea801856c3fa207619c973e17d96455ab83e3"}, + {file = "frozenlist-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:de170ea97e7b5051a13989ea457300b8159c00455d2207d22afb6b129a433152"}, + {file = "frozenlist-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:158c6258ab4ee8a01470d86e75a7514091391b27bb400ba28a7f6a30466cc8e0"}, + {file = "frozenlist-1.1.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:572a5a0977b1bd2f15183a352df907726b20da5f91cd1242343b0d72ac677be6"}, + {file = "frozenlist-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:748150da8bbd9cbe1b29f0965a675b5732337ae874eed47ccb48dfa75815d0d7"}, + {file = "frozenlist-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:caba5ae97c40020771502866dee5024b0031187293185ca5c7714ea52a824a92"}, + {file = "frozenlist-1.1.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:aea1b84bbebec7c46cd59da13aff90e23bece13bba91974a305bd555f66a72f3"}, + {file = "frozenlist-1.1.1-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:d07f08268b8d37c357f4d34272f1f7588a0618d3fa509a87ad614b5e1cf7109f"}, + {file = "frozenlist-1.1.1-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:66f7ba888fe51685502be51ad548b226eb4214fcab0ef48672a2a91a4de08417"}, + {file = "frozenlist-1.1.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:4282d897ea190b5e38a18fc3b70295e20e00af7734892250876e1e1b452a7dc8"}, + {file = "frozenlist-1.1.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:84ef9f6f6f8e2dd9cf828367c61715202a781ef6d32caa9a016d9055a7daef8b"}, + {file = "frozenlist-1.1.1-cp38-cp38-win32.whl", hash = "sha256:d5cba2a537bcf8b4abffc9e01b037eb4ca5c9d1cb29d575fd433f82919a04c68"}, + {file = "frozenlist-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:6c1cefdc3666507f7241b120b828e223c1dfb18e599f960ebbd0558de5010efe"}, + {file = "frozenlist-1.1.1-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:a6d2d80222eefe6e08b8167005e5a0c1a05ce784ce97de4d6d693be7e2a99862"}, + {file = "frozenlist-1.1.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:8fbdc86968f71d1d1e216f1f3467da96571b092378ad55b7eb6fc9f3ff877902"}, + {file = "frozenlist-1.1.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:1032a7eb76ca47cb94dcfd05a289dfb2f31b5e155c9cd845f97a56526eca9800"}, + {file = "frozenlist-1.1.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:4fdfb300d205f3d007462d66c9e8ffa89d7b1b3699e538ae7344845223291ff0"}, + {file = "frozenlist-1.1.1-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:e2d35804cf42b58e42e9b2cca6a2a5bb7155bb545808ff652503a8bacab2be5d"}, + {file = "frozenlist-1.1.1-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:a2ebc6dd4f73f39212073add6b3a629a4274ed0a5e43c2fa87bd91957f511450"}, + {file = "frozenlist-1.1.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:9ee0bca9801eea5431680bdf22817b1b07310474ac284a3aa7a3902d0dba2382"}, + {file = "frozenlist-1.1.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:08a4f1bd182659416c8ae518ef8a63c37953eb2d4bd77cf8b45941a90e87d27c"}, + {file = "frozenlist-1.1.1-cp39-cp39-win32.whl", hash = "sha256:803bc0fdb904a762b0a49572fe2f1cb2a03ade5514b265971da5c3e7a8b14798"}, + {file = "frozenlist-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:301220a5752fc2585ef97794b1dc88f87d77f5c1951782488896fcb6a732f883"}, + {file = "frozenlist-1.1.1.tar.gz", hash = "sha256:32fa8c86eee5c6f11b44863c0d3e945b2b1a03df3bf218617e832f1f04ba3146"}, +] furo = [ {file = "furo-2021.6.18b36-py3-none-any.whl", hash = "sha256:a4c00634afeb5896a34d141a5dffb62f20c5eca7831b78269823a8cd8b09a5e4"}, {file = "furo-2021.6.18b36.tar.gz", hash = "sha256:46a30bc597a9067088d39d730e7d9bf6c1a1d71967e4af062f796769f66b3bdb"}, diff --git a/pyproject.toml b/pyproject.toml index b9e92418..d9c29089 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,8 +37,9 @@ pydantic = "^1.8.1" Babel = "^2.9.1" aiofiles = "^0.6.0" async_lru = "^1.0.2" +frozenlist = "^1.1.1" aiohttp-socks = { version = "^0.5.5", optional = true } -aioredis = { version = "^2.0.0a1", allow-prereleases = true, optional = true } +aioredis = { version = "^2.0.0", allow-prereleases = true, optional = true } magic-filter = { version = "1.0.0a1", allow-prereleases = true } sphinx = { version = "^3.1.0", optional = true } sphinx-intl = { version = "^2.0.1", optional = true } diff --git a/tests/test_api/test_methods/test_delete_my_commands.py b/tests/test_api/test_methods/test_delete_my_commands.py index a39d080b..b5546b25 100644 --- a/tests/test_api/test_methods/test_delete_my_commands.py +++ b/tests/test_api/test_methods/test_delete_my_commands.py @@ -1,6 +1,6 @@ import pytest -from aiogram.methods import BanChatMember, DeleteMyCommands, Request +from aiogram.methods import DeleteMyCommands, Request from tests.mocked_bot import MockedBot diff --git a/tests/test_dispatcher/test_event/test_telegram.py b/tests/test_dispatcher/test_event/test_telegram.py index d4306b1a..13f70503 100644 --- a/tests/test_dispatcher/test_event/test_telegram.py +++ b/tests/test_dispatcher/test_event/test_telegram.py @@ -4,7 +4,7 @@ from typing import Any, Awaitable, Callable, Dict, NoReturn, Union import pytest -from aiogram.dispatcher.event.bases import SkipHandler +from aiogram.dispatcher.event.bases import REJECTED, SkipHandler from aiogram.dispatcher.event.handler import HandlerObject from aiogram.dispatcher.event.telegram import TelegramEventObserver from aiogram.dispatcher.filters.base import BaseFilter @@ -233,3 +233,48 @@ class TestTelegramEventObserver: assert my_middleware3 in middlewares assert middlewares == [my_middleware1, my_middleware2, my_middleware3] + + def test_register_global_filters(self): + router = Router(use_builtin_filters=False) + assert isinstance(router.message._handler.filters, list) + assert not router.message._handler.filters + + my_filter = MyFilter1(test="pass") + router.message.filter(my_filter) + + assert len(router.message._handler.filters) == 1 + assert router.message._handler.filters[0].callback is my_filter + + router.message._handler.filters = None + router.message.filter(my_filter) + assert len(router.message._handler.filters) == 1 + assert router.message._handler.filters[0].callback is my_filter + + @pytest.mark.asyncio + async def test_global_filter(self): + r1 = Router() + r2 = Router() + + async def handler(evt): + return evt + + r1.message.filter(lambda evt: False) + r1.message.register(handler) + r2.message.register(handler) + + assert await r1.message.trigger(None) is REJECTED + assert await r2.message.trigger(None) is None + + @pytest.mark.asyncio + async def test_global_filter_in_nested_router(self): + r1 = Router() + r2 = Router() + + async def handler(evt): + return evt + + r1.include_router(r2) + r1.message.filter(lambda evt: False) + r2.message.register(handler) + + assert await r1.message.trigger(None) is REJECTED diff --git a/tests/test_dispatcher/test_filters/test_exception.py b/tests/test_dispatcher/test_filters/test_exception.py index 4dd6d5d9..23d850da 100644 --- a/tests/test_dispatcher/test_filters/test_exception.py +++ b/tests/test_dispatcher/test_filters/test_exception.py @@ -8,12 +8,12 @@ from aiogram.dispatcher.filters import ExceptionMessageFilter, ExceptionTypeFilt 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) + obj = ExceptionMessageFilter(pattern=value) + assert isinstance(obj.pattern, re.Pattern) @pytest.mark.asyncio async def test_match(self): - obj = ExceptionMessageFilter(match="KABOOM") + obj = ExceptionMessageFilter(pattern="KABOOM") result = await obj(Exception()) assert not result diff --git a/tests/test_dispatcher/test_router.py b/tests/test_dispatcher/test_router.py index c84239b1..1516c33c 100644 --- a/tests/test_dispatcher/test_router.py +++ b/tests/test_dispatcher/test_router.py @@ -1,6 +1,6 @@ import pytest -from aiogram.dispatcher.event.bases import SkipHandler, skip +from aiogram.dispatcher.event.bases import SkipHandler, skip, UNHANDLED from aiogram.dispatcher.router import Router from aiogram.utils.warnings import CodeHasNoEffect @@ -122,3 +122,17 @@ class TestRouter: skip() with pytest.raises(SkipHandler, match="KABOOM"): skip("KABOOM") + + @pytest.mark.asyncio + async def test_global_filter_in_nested_router(self): + r1 = Router() + r2 = Router() + + async def handler(evt): + return evt + + r1.include_router(r2) + r1.message.filter(lambda evt: False) + r2.message.register(handler) + + assert await r1.propagate_event(update_type="message", event=None) is UNHANDLED From c3844bb18fc3372d63bccb552e7056476ab71ac3 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 1 Aug 2021 00:34:50 +0300 Subject: [PATCH 19/38] Added detection of API Errors and fixed coverage --- aiogram/client/errors_middleware.py | 64 ----------- aiogram/client/session/base.py | 32 ++++-- aiogram/utils/exceptions/bad_request.py | 4 +- aiogram/utils/exceptions/base.py | 15 +-- aiogram/utils/exceptions/conflict.py | 5 + aiogram/utils/exceptions/exceptions.py | 92 +--------------- aiogram/utils/exceptions/network.py | 4 + aiogram/utils/exceptions/not_found.py | 4 +- aiogram/utils/exceptions/server.py | 4 + aiogram/utils/exceptions/special.py | 4 +- aiogram/utils/exceptions/unauthorized.py | 5 + aiogram/utils/exceptions/util.py | 20 ---- tests/mocked_bot.py | 2 +- .../test_session/test_aiohttp_session.py | 19 ++++ .../test_session/test_base_session.py | 100 +++++++++++++++--- tests/test_dispatcher/test_dispatcher.py | 19 ++++ tests/test_dispatcher/test_router.py | 2 +- 17 files changed, 179 insertions(+), 216 deletions(-) delete mode 100644 aiogram/client/errors_middleware.py delete mode 100644 aiogram/utils/exceptions/util.py diff --git a/aiogram/client/errors_middleware.py b/aiogram/client/errors_middleware.py deleted file mode 100644 index 59d95f07..00000000 --- a/aiogram/client/errors_middleware.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -import re -from typing import TYPE_CHECKING, List, Type - -from aiogram.methods import Response, TelegramMethod -from aiogram.types import TelegramObject -from aiogram.utils.exceptions.base import TelegramAPIError -from aiogram.utils.exceptions.exceptions import ( - CantParseEntitiesStartTag, - CantParseEntitiesUnclosed, - CantParseEntitiesUnmatchedTags, - CantParseEntitiesUnsupportedTag, - DetailedTelegramAPIError, -) - -if TYPE_CHECKING: - from aiogram.client.bot import Bot - from aiogram.client.session.base import NextRequestMiddlewareType - - -class RequestErrorMiddleware: - def __init__(self) -> None: - self._registry: List[Type[DetailedTelegramAPIError]] = [ - CantParseEntitiesStartTag, - CantParseEntitiesUnmatchedTags, - CantParseEntitiesUnclosed, - CantParseEntitiesUnsupportedTag, - ] - - def mount(self, error: Type[DetailedTelegramAPIError]) -> Type[DetailedTelegramAPIError]: - if error in self: - raise ValueError(f"{error!r} is already registered") - if not hasattr(error, "patterns"): - raise ValueError(f"{error!r} has no attribute 'patterns'") - self._registry.append(error) - return error - - def detect_error(self, err: TelegramAPIError) -> TelegramAPIError: - message = err.message - for variant in self._registry: - for pattern in variant.patterns: - if match := re.match(pattern, message): - return variant( - method=err.method, - message=err.message, - match=match, - ) - return err - - def __contains__(self, item: Type[DetailedTelegramAPIError]) -> bool: - return item in self._registry - - async def __call__( - self, - bot: Bot, - method: TelegramMethod[TelegramObject], - make_request: NextRequestMiddlewareType, - ) -> Response[TelegramObject]: - try: - return await make_request(bot, method) - except TelegramAPIError as e: - detected_err = self.detect_error(err=e) - raise detected_err from e diff --git a/aiogram/client/session/base.py b/aiogram/client/session/base.py index 2e752e72..0a8c2973 100644 --- a/aiogram/client/session/base.py +++ b/aiogram/client/session/base.py @@ -4,6 +4,7 @@ import abc import datetime import json from functools import partial +from http import HTTPStatus from types import TracebackType from typing import ( TYPE_CHECKING, @@ -25,8 +26,13 @@ from aiogram.utils.helper import Default from ...methods import Response, TelegramMethod from ...methods.base import TelegramType from ...types import UNSET, TelegramObject +from ...utils.exceptions.bad_request import BadRequest +from ...utils.exceptions.conflict import ConflictError +from ...utils.exceptions.network import EntityTooLarge +from ...utils.exceptions.not_found import NotFound +from ...utils.exceptions.server import RestartingTelegram, ServerError from ...utils.exceptions.special import MigrateToChat, RetryAfter -from ..errors_middleware import RequestErrorMiddleware +from ...utils.exceptions.unauthorized import UnauthorizedError from ..telegram import PRODUCTION, TelegramAPIServer if TYPE_CHECKING: # pragma: no cover @@ -55,12 +61,8 @@ class BaseSession(abc.ABC): timeout: Default[float] = Default(fget=lambda self: float(self.__class__.default_timeout)) """Session scope request timeout""" - errors_middleware: ClassVar[RequestErrorMiddleware] = RequestErrorMiddleware() - def __init__(self) -> None: - self.middlewares: List[RequestMiddlewareType[TelegramObject]] = [ - self.errors_middleware, - ] + self.middlewares: List[RequestMiddlewareType[TelegramObject]] = [] def check_response( self, method: TelegramMethod[TelegramType], status_code: int, content: str @@ -70,10 +72,11 @@ class BaseSession(abc.ABC): """ json_data = self.json_loads(content) response = method.build_response(json_data) - if response.ok: + if HTTPStatus.OK <= status_code <= HTTPStatus.IM_USED and response.ok: return response description = cast(str, response.description) + if parameters := response.parameters: if parameters.retry_after: raise RetryAfter( @@ -85,6 +88,21 @@ class BaseSession(abc.ABC): message=description, migrate_to_chat_id=parameters.migrate_to_chat_id, ) + if status_code == HTTPStatus.BAD_REQUEST: + raise BadRequest(method=method, message=description) + if status_code == HTTPStatus.NOT_FOUND: + raise NotFound(method=method, message=description) + if status_code == HTTPStatus.CONFLICT: + raise ConflictError(method=method, message=description) + if status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): + raise UnauthorizedError(method=method, message=description) + if status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: + raise EntityTooLarge(method=method, message=description) + if status_code >= HTTPStatus.INTERNAL_SERVER_ERROR: + if "restart" in description: + raise RestartingTelegram(method=method, message=description) + raise ServerError(method=method, message=description) + raise TelegramAPIError( method=method, message=description, diff --git a/aiogram/utils/exceptions/bad_request.py b/aiogram/utils/exceptions/bad_request.py index 9b9d878a..de1a0e2d 100644 --- a/aiogram/utils/exceptions/bad_request.py +++ b/aiogram/utils/exceptions/bad_request.py @@ -1,5 +1,5 @@ -from aiogram.utils.exceptions.base import DetailedTelegramAPIError +from aiogram.utils.exceptions.base import TelegramAPIError -class BadRequest(DetailedTelegramAPIError): +class BadRequest(TelegramAPIError): pass diff --git a/aiogram/utils/exceptions/base.py b/aiogram/utils/exceptions/base.py index fdbc2514..fefd3db8 100644 --- a/aiogram/utils/exceptions/base.py +++ b/aiogram/utils/exceptions/base.py @@ -1,4 +1,4 @@ -from typing import ClassVar, List, Match, Optional, TypeVar +from typing import Optional, TypeVar from aiogram.methods import TelegramMethod from aiogram.methods.base import TelegramType @@ -25,16 +25,3 @@ class TelegramAPIError(Exception): if self.url: message.append(f"(background on this error at: {self.url})") return "\n".join(message) - - -class DetailedTelegramAPIError(TelegramAPIError): - patterns: ClassVar[List[str]] - - def __init__( - self, - method: TelegramMethod[TelegramType], - message: str, - match: Match[str], - ) -> None: - super().__init__(method=method, message=message) - self.match: Match[str] = match diff --git a/aiogram/utils/exceptions/conflict.py b/aiogram/utils/exceptions/conflict.py index e69de29b..965b328c 100644 --- a/aiogram/utils/exceptions/conflict.py +++ b/aiogram/utils/exceptions/conflict.py @@ -0,0 +1,5 @@ +from aiogram.utils.exceptions.base import TelegramAPIError + + +class ConflictError(TelegramAPIError): + pass diff --git a/aiogram/utils/exceptions/exceptions.py b/aiogram/utils/exceptions/exceptions.py index 4ce6f0c2..de1a0e2d 100644 --- a/aiogram/utils/exceptions/exceptions.py +++ b/aiogram/utils/exceptions/exceptions.py @@ -1,93 +1,5 @@ -from textwrap import indent -from typing import Match - -from aiogram.methods.base import TelegramMethod, TelegramType -from aiogram.utils.exceptions.base import DetailedTelegramAPIError -from aiogram.utils.exceptions.util import mark_line +from aiogram.utils.exceptions.base import TelegramAPIError -class BadRequest(DetailedTelegramAPIError): +class BadRequest(TelegramAPIError): pass - - -class CantParseEntities(BadRequest): - pass - - -class CantParseEntitiesStartTag(CantParseEntities): - patterns = [ - "Bad Request: can't parse entities: Can't find end tag corresponding to start tag (?P.+)" - ] - - def __init__( - self, - method: TelegramMethod[TelegramType], - message: str, - match: Match[str], - ) -> None: - super().__init__(method=method, message=message, match=match) - self.tag: str = match.group("tag") - - -class CantParseEntitiesUnmatchedTags(CantParseEntities): - patterns = [ - r'Bad Request: can\'t parse entities: Unmatched end tag at byte offset (?P\d), expected "\w+)>", found "\w+)>"' - ] - - def __init__( - self, - method: TelegramMethod[TelegramType], - message: str, - match: Match[str], - ) -> None: - super().__init__(method=method, message=message, match=match) - self.offset: int = int(match.group("offset")) - self.expected: str = match.group("expected") - self.found: str = match.group("found") - - -class CantParseEntitiesUnclosed(CantParseEntities): - patterns = [ - "Bad Request: can't parse entities: Unclosed start tag at byte offset (?P.+)" - ] - - def __init__( - self, - method: TelegramMethod[TelegramType], - message: str, - match: Match[str], - ) -> None: - super().__init__(method=method, message=message, match=match) - self.offset: int = int(match.group("offset")) - - def __str__(self) -> str: - message = [self.message] - text = getattr(self.method, "text", None) or getattr(self.method, "caption", None) - if text: - message.extend(["Example:", indent(mark_line(text, self.offset), prefix=" ")]) - return "\n".join(message) - - -class CantParseEntitiesUnsupportedTag(CantParseEntities): - patterns = [ - r'Bad Request: can\'t parse entities: Unsupported start tag "(?P.+)" at byte offset (?P\d+)' - ] - - def __init__( - self, - method: TelegramMethod[TelegramType], - message: str, - match: Match[str], - ) -> None: - super().__init__(method=method, message=message, match=match) - self.offset = int(match.group("offset")) - self.tag = match.group("tag") - - def __str__(self) -> str: - message = [self.message] - text = getattr(self.method, "text", None) or getattr(self.method, "caption", None) - if text: - message.extend( - ["Example:", indent(mark_line(text, self.offset, len(self.tag)), prefix=" ")] - ) - return "\n".join(message) diff --git a/aiogram/utils/exceptions/network.py b/aiogram/utils/exceptions/network.py index 90a31041..b802ce69 100644 --- a/aiogram/utils/exceptions/network.py +++ b/aiogram/utils/exceptions/network.py @@ -3,3 +3,7 @@ from aiogram.utils.exceptions.base import TelegramAPIError class NetworkError(TelegramAPIError): pass + + +class EntityTooLarge(NetworkError): + url = "https://core.telegram.org/bots/api#sending-files" diff --git a/aiogram/utils/exceptions/not_found.py b/aiogram/utils/exceptions/not_found.py index 8dfb344b..6fa87a06 100644 --- a/aiogram/utils/exceptions/not_found.py +++ b/aiogram/utils/exceptions/not_found.py @@ -1,5 +1,5 @@ -from aiogram.utils.exceptions.base import DetailedTelegramAPIError +from aiogram.utils.exceptions.base import TelegramAPIError -class NotFound(DetailedTelegramAPIError): +class NotFound(TelegramAPIError): pass diff --git a/aiogram/utils/exceptions/server.py b/aiogram/utils/exceptions/server.py index e9b5f365..c45c9f01 100644 --- a/aiogram/utils/exceptions/server.py +++ b/aiogram/utils/exceptions/server.py @@ -3,3 +3,7 @@ from aiogram.utils.exceptions.base import TelegramAPIError class ServerError(TelegramAPIError): pass + + +class RestartingTelegram(ServerError): + pass diff --git a/aiogram/utils/exceptions/special.py b/aiogram/utils/exceptions/special.py index 0568f900..d2044ec2 100644 --- a/aiogram/utils/exceptions/special.py +++ b/aiogram/utils/exceptions/special.py @@ -1,5 +1,3 @@ -from typing import Optional - from aiogram.methods import TelegramMethod from aiogram.methods.base import TelegramType from aiogram.utils.exceptions.base import TelegramAPIError @@ -37,7 +35,7 @@ class MigrateToChat(TelegramAPIError): super().__init__(method=method, message=message) self.migrate_to_chat_id = migrate_to_chat_id - def render_message(self) -> Optional[str]: + def render_description(self) -> str: description = ( f"The group has been migrated to a supergroup with id {self.migrate_to_chat_id}" ) diff --git a/aiogram/utils/exceptions/unauthorized.py b/aiogram/utils/exceptions/unauthorized.py index e69de29b..789574a5 100644 --- a/aiogram/utils/exceptions/unauthorized.py +++ b/aiogram/utils/exceptions/unauthorized.py @@ -0,0 +1,5 @@ +from aiogram.utils.exceptions.base import TelegramAPIError + + +class UnauthorizedError(TelegramAPIError): + pass diff --git a/aiogram/utils/exceptions/util.py b/aiogram/utils/exceptions/util.py deleted file mode 100644 index a7cb191e..00000000 --- a/aiogram/utils/exceptions/util.py +++ /dev/null @@ -1,20 +0,0 @@ -def mark_line(text: str, offset: int, length: int = 1) -> str: - try: - if offset > 0 and (new_line_pos := text[:offset].rindex("\n")): - text = "..." + text[:new_line_pos] - offset -= new_line_pos - 3 - except ValueError: - pass - - if offset > 10: - text = "..." + text[offset - 10 :] - offset = 13 - - mark = " " * offset - mark += "^" * length - try: - if new_line_pos := text[len(mark) :].index("\n"): - text = text[:new_line_pos].rstrip() + "..." - except ValueError: - pass - return text + "\n" + mark diff --git a/tests/mocked_bot.py b/tests/mocked_bot.py index 03e48e4f..76abc445 100644 --- a/tests/mocked_bot.py +++ b/tests/mocked_bot.py @@ -53,7 +53,7 @@ class MockedBot(Bot): ok: bool, result: TelegramType = None, description: Optional[str] = None, - error_code: Optional[int] = None, + error_code: int = 200, migrate_to_chat_id: Optional[int] = None, retry_after: Optional[int] = None, ) -> Response[TelegramType]: diff --git a/tests/test_api/test_client/test_session/test_aiohttp_session.py b/tests/test_api/test_client/test_session/test_aiohttp_session.py index 9624642e..e3149a29 100644 --- a/tests/test_api/test_client/test_session/test_aiohttp_session.py +++ b/tests/test_api/test_client/test_session/test_aiohttp_session.py @@ -1,7 +1,9 @@ +import asyncio from typing import AsyncContextManager, AsyncGenerator import aiohttp_socks import pytest +from aiohttp import ClientError from aresponses import ResponsesMockServer from aiogram import Bot @@ -9,6 +11,7 @@ from aiogram.client.session import aiohttp from aiogram.client.session.aiohttp import AiohttpSession from aiogram.methods import Request, TelegramMethod from aiogram.types import UNSET, InputFile +from aiogram.utils.exceptions.network import NetworkError from tests.mocked_bot import MockedBot try: @@ -177,6 +180,22 @@ class TestAiohttpSession: assert isinstance(result, int) assert result == 42 + @pytest.mark.parametrize("error", [ClientError("mocked"), asyncio.TimeoutError()]) + @pytest.mark.asyncio + async def test_make_request_network_error(self, error): + bot = Bot("42:TEST") + + async def side_effect(*args, **kwargs): + raise error + + with patch( + "aiohttp.client.ClientSession._request", + new_callable=CoroutineMock, + side_effect=side_effect, + ): + with pytest.raises(NetworkError): + await bot.get_me() + @pytest.mark.asyncio async def test_stream_content(self, aresponses: ResponsesMockServer): aresponses.add( diff --git a/tests/test_api/test_client/test_session/test_base_session.py b/tests/test_api/test_client/test_session/test_base_session.py index ef82c1d3..c30b63ea 100644 --- a/tests/test_api/test_client/test_session/test_base_session.py +++ b/tests/test_api/test_client/test_session/test_base_session.py @@ -4,10 +4,20 @@ from typing import AsyncContextManager, AsyncGenerator, Optional import pytest +from aiogram import Bot from aiogram.client.session.base import BaseSession, TelegramType from aiogram.client.telegram import PRODUCTION, TelegramAPIServer from aiogram.methods import DeleteMessage, GetMe, TelegramMethod -from aiogram.types import UNSET +from aiogram.types import UNSET, User +from aiogram.utils.exceptions.bad_request import BadRequest +from aiogram.utils.exceptions.base import TelegramAPIError +from aiogram.utils.exceptions.conflict import ConflictError +from aiogram.utils.exceptions.network import EntityTooLarge +from aiogram.utils.exceptions.not_found import NotFound +from aiogram.utils.exceptions.server import RestartingTelegram, ServerError +from aiogram.utils.exceptions.special import MigrateToChat, RetryAfter +from aiogram.utils.exceptions.unauthorized import UnauthorizedError +from tests.mocked_bot import MockedBot try: from asynctest import CoroutineMock, patch @@ -137,20 +147,53 @@ class TestBaseSession: assert session.clean_json(42) == 42 - def check_response(self): + @pytest.mark.parametrize( + "status_code,content,error", + [ + [200, '{"ok":true,"result":true}', None], + [400, '{"ok":false,"description":"test"}', BadRequest], + [ + 400, + '{"ok":false,"description":"test", "parameters": {"retry_after": 1}}', + RetryAfter, + ], + [ + 400, + '{"ok":false,"description":"test", "parameters": {"migrate_to_chat_id": -42}}', + MigrateToChat, + ], + [404, '{"ok":false,"description":"test"}', NotFound], + [401, '{"ok":false,"description":"test"}', UnauthorizedError], + [403, '{"ok":false,"description":"test"}', UnauthorizedError], + [409, '{"ok":false,"description":"test"}', ConflictError], + [413, '{"ok":false,"description":"test"}', EntityTooLarge], + [500, '{"ok":false,"description":"restarting"}', RestartingTelegram], + [500, '{"ok":false,"description":"test"}', ServerError], + [502, '{"ok":false,"description":"test"}', ServerError], + [499, '{"ok":false,"description":"test"}', TelegramAPIError], + [499, '{"ok":false,"description":"test"}', TelegramAPIError], + ], + ) + def test_check_response(self, status_code, content, error): session = CustomSession() - - session.check_response( - method=DeleteMessage(chat_id=42, message_id=42), - status_code=200, - content='{"ok":true,"result":true}', - ) - with pytest.raises(Exception): + method = DeleteMessage(chat_id=42, message_id=42) + if error is None: session.check_response( - method=DeleteMessage(chat_id=42, message_id=42), - status_code=400, - content='{"ok":false,"description":"test"}', + method=method, + status_code=status_code, + content=content, ) + else: + with pytest.raises(error) as exc_info: + session.check_response( + method=method, + status_code=status_code, + content=content, + ) + error: TelegramAPIError = exc_info.value + string = str(error) + if error.url: + assert error.url in string @pytest.mark.asyncio async def test_make_request(self): @@ -181,3 +224,36 @@ class TestBaseSession: async with session as ctx: assert session == ctx mocked_close.assert_awaited_once() + + def test_add_middleware(self): + async def my_middleware(bot, method, make_request): + return await make_request(bot, method) + + session = CustomSession() + assert not session.middlewares + + session.middleware(my_middleware) + assert my_middleware in session.middlewares + assert len(session.middlewares) == 1 + + @pytest.mark.asyncio + async def test_use_middleware(self, bot: MockedBot): + flag_before = False + flag_after = False + + @bot.session.middleware + async def my_middleware(b, method, make_request): + nonlocal flag_before, flag_after + flag_before = True + try: + assert isinstance(b, Bot) + assert isinstance(method, TelegramMethod) + + return await make_request(bot, method) + finally: + flag_after = True + + bot.add_result_for(GetMe, ok=True, result=User(id=42, is_bot=True, first_name="Test")) + assert await bot.get_me() + assert flag_before + assert flag_after diff --git a/tests/test_dispatcher/test_dispatcher.py b/tests/test_dispatcher/test_dispatcher.py index 5bcbd9f6..9b9dd8fe 100644 --- a/tests/test_dispatcher/test_dispatcher.py +++ b/tests/test_dispatcher/test_dispatcher.py @@ -49,6 +49,10 @@ async def invalid_message_handler(message: Message): raise Exception(42) +async def anext(ait): + return await ait.__anext__() + + RAW_UPDATE = { "update_id": 42, "message": { @@ -147,6 +151,21 @@ class TestDispatcher: break assert index == 42 + @pytest.mark.asyncio + async def test_listen_update_with_error(self, bot: MockedBot): + dispatcher = Dispatcher() + listen = dispatcher._listen_updates(bot=bot) + bot.add_result_for( + GetUpdates, ok=True, result=[Update(update_id=update_id) for update_id in range(42)] + ) + bot.add_result_for(GetUpdates, ok=False, error_code=500, description="restarting") + with patch( + "aiogram.utils.backoff.Backoff.asleep", + new_callable=CoroutineMock, + ) as mocked_asleep: + assert isinstance(await anext(listen), Update) + assert mocked_asleep.awaited + @pytest.mark.asyncio async def test_silent_call_request(self, bot: MockedBot, caplog): dispatcher = Dispatcher() diff --git a/tests/test_dispatcher/test_router.py b/tests/test_dispatcher/test_router.py index 1516c33c..cee9e1bf 100644 --- a/tests/test_dispatcher/test_router.py +++ b/tests/test_dispatcher/test_router.py @@ -1,6 +1,6 @@ import pytest -from aiogram.dispatcher.event.bases import SkipHandler, skip, UNHANDLED +from aiogram.dispatcher.event.bases import UNHANDLED, SkipHandler, skip from aiogram.dispatcher.router import Router from aiogram.utils.warnings import CodeHasNoEffect From 5c1ea85e9da04c94ff5153f02c7289d44d82b944 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 1 Aug 2021 00:35:50 +0300 Subject: [PATCH 20/38] Remove frozenlist --- poetry.lock | 53 +------------------------------------------------- pyproject.toml | 1 - 2 files changed, 1 insertion(+), 53 deletions(-) diff --git a/poetry.lock b/poetry.lock index 7f324bfc..6d99a4c9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -296,14 +296,6 @@ importlib-metadata = "*" jinja2 = ">=2.9.0" pygments = ">=2.2.0" -[[package]] -name = "frozenlist" -version = "1.1.1" -description = "A list-like structure which implements collections.abc.MutableSequence" -category = "main" -optional = false -python-versions = ">=3.6" - [[package]] name = "furo" version = "2021.6.18b36" @@ -1209,7 +1201,7 @@ redis = ["aioredis"] [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "c51e22cdb0e17fb996fda81c5484d34f3dff0e57511380b1103a1d53c9416440" +content-hash = "e85c7f8f5dbb7fb40641cf8c900cd393e6fe2f4ad68f94fcaceeb746afc5f68f" [metadata.files] aiofiles = [ @@ -1409,49 +1401,6 @@ flake8-html = [ {file = "flake8-html-0.4.1.tar.gz", hash = "sha256:2fb436cbfe1e109275bc8fb7fdd0cb00e67b3b48cfeb397309b6b2c61eeb4cb4"}, {file = "flake8_html-0.4.1-py2.py3-none-any.whl", hash = "sha256:17324eb947e7006807e4184ee26953e67baf421b3cf9e646a38bfec34eec5a94"}, ] -frozenlist = [ - {file = "frozenlist-1.1.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:968b520ef969541b2c8f47d9a13c78e080806dc97862434d29163d44c2c1d709"}, - {file = "frozenlist-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:451b445120ea95d86af3817bbd4d67ab77269fe7f055dc67b8c70bf4633f4efe"}, - {file = "frozenlist-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:313384e54a7285a6f20ca6530b207a0a9cf6ebcda6c7b074ee802e4a82a0a6dc"}, - {file = "frozenlist-1.1.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:3c4f7399e7338a5788d32802017f94aaab3267afa8b1a663272b81eee7193e66"}, - {file = "frozenlist-1.1.1-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:1e7b18fdf6682028f512d3e6142b79ca95b9b66f30c1bec2be237160d9eb6518"}, - {file = "frozenlist-1.1.1-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:7622f5c4c3dfaa09b9c6a62fb1af94da124626bb30f3ad9095f9cec6328074c1"}, - {file = "frozenlist-1.1.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:657341b9bc166d3f7418d37e1decf6d95485501e0d0e7da1a26a881e624216c6"}, - {file = "frozenlist-1.1.1-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:fc6d994de78b11e1f465f2224c56858eb52cb51c8f9faf0c33e5799184d414a7"}, - {file = "frozenlist-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:08428f9d0178b6fa0da95a42ab87a5b20ed2a707bacc97e3689e96ae6cab13fa"}, - {file = "frozenlist-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:5c4c42bdbf5754010e0cc5cc0f91019437839bc6b7e585262bcc126557a244bc"}, - {file = "frozenlist-1.1.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:32491ac26e72e5f35913887bc3ab7bcfe562b4fb65b0e58350fce6efa22fec75"}, - {file = "frozenlist-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:d160a73e4a034a857a98384b5e05204c375489d2bbb6ecf1ee8fc124735028fa"}, - {file = "frozenlist-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:b3cf6737afc4347092a0c8392b4c0e77acc5594e73f4aef355705117a945743a"}, - {file = "frozenlist-1.1.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:efb805e383836250bef3c99f1857c432a8941c802d0ed7767751315617a54794"}, - {file = "frozenlist-1.1.1-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:2f1f56a36962e28c304872797e226cd646395381de97517870fb819ff7b4f496"}, - {file = "frozenlist-1.1.1-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:7443d815fd9ff2de75b810e192cfa92854bada43aed47ec1598766c7bc9d4a40"}, - {file = "frozenlist-1.1.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe463e7b3cd089d221f33bd9c22cfad2726622b2a96c3af56a8eb5a71c0943bd"}, - {file = "frozenlist-1.1.1-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:c228886891dc0170d21acbfb62fea801856c3fa207619c973e17d96455ab83e3"}, - {file = "frozenlist-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:de170ea97e7b5051a13989ea457300b8159c00455d2207d22afb6b129a433152"}, - {file = "frozenlist-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:158c6258ab4ee8a01470d86e75a7514091391b27bb400ba28a7f6a30466cc8e0"}, - {file = "frozenlist-1.1.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:572a5a0977b1bd2f15183a352df907726b20da5f91cd1242343b0d72ac677be6"}, - {file = "frozenlist-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:748150da8bbd9cbe1b29f0965a675b5732337ae874eed47ccb48dfa75815d0d7"}, - {file = "frozenlist-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:caba5ae97c40020771502866dee5024b0031187293185ca5c7714ea52a824a92"}, - {file = "frozenlist-1.1.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:aea1b84bbebec7c46cd59da13aff90e23bece13bba91974a305bd555f66a72f3"}, - {file = "frozenlist-1.1.1-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:d07f08268b8d37c357f4d34272f1f7588a0618d3fa509a87ad614b5e1cf7109f"}, - {file = "frozenlist-1.1.1-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:66f7ba888fe51685502be51ad548b226eb4214fcab0ef48672a2a91a4de08417"}, - {file = "frozenlist-1.1.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:4282d897ea190b5e38a18fc3b70295e20e00af7734892250876e1e1b452a7dc8"}, - {file = "frozenlist-1.1.1-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:84ef9f6f6f8e2dd9cf828367c61715202a781ef6d32caa9a016d9055a7daef8b"}, - {file = "frozenlist-1.1.1-cp38-cp38-win32.whl", hash = "sha256:d5cba2a537bcf8b4abffc9e01b037eb4ca5c9d1cb29d575fd433f82919a04c68"}, - {file = "frozenlist-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:6c1cefdc3666507f7241b120b828e223c1dfb18e599f960ebbd0558de5010efe"}, - {file = "frozenlist-1.1.1-cp39-cp39-macosx_10_14_x86_64.whl", hash = "sha256:a6d2d80222eefe6e08b8167005e5a0c1a05ce784ce97de4d6d693be7e2a99862"}, - {file = "frozenlist-1.1.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:8fbdc86968f71d1d1e216f1f3467da96571b092378ad55b7eb6fc9f3ff877902"}, - {file = "frozenlist-1.1.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:1032a7eb76ca47cb94dcfd05a289dfb2f31b5e155c9cd845f97a56526eca9800"}, - {file = "frozenlist-1.1.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:4fdfb300d205f3d007462d66c9e8ffa89d7b1b3699e538ae7344845223291ff0"}, - {file = "frozenlist-1.1.1-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:e2d35804cf42b58e42e9b2cca6a2a5bb7155bb545808ff652503a8bacab2be5d"}, - {file = "frozenlist-1.1.1-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:a2ebc6dd4f73f39212073add6b3a629a4274ed0a5e43c2fa87bd91957f511450"}, - {file = "frozenlist-1.1.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:9ee0bca9801eea5431680bdf22817b1b07310474ac284a3aa7a3902d0dba2382"}, - {file = "frozenlist-1.1.1-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:08a4f1bd182659416c8ae518ef8a63c37953eb2d4bd77cf8b45941a90e87d27c"}, - {file = "frozenlist-1.1.1-cp39-cp39-win32.whl", hash = "sha256:803bc0fdb904a762b0a49572fe2f1cb2a03ade5514b265971da5c3e7a8b14798"}, - {file = "frozenlist-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:301220a5752fc2585ef97794b1dc88f87d77f5c1951782488896fcb6a732f883"}, - {file = "frozenlist-1.1.1.tar.gz", hash = "sha256:32fa8c86eee5c6f11b44863c0d3e945b2b1a03df3bf218617e832f1f04ba3146"}, -] furo = [ {file = "furo-2021.6.18b36-py3-none-any.whl", hash = "sha256:a4c00634afeb5896a34d141a5dffb62f20c5eca7831b78269823a8cd8b09a5e4"}, {file = "furo-2021.6.18b36.tar.gz", hash = "sha256:46a30bc597a9067088d39d730e7d9bf6c1a1d71967e4af062f796769f66b3bdb"}, diff --git a/pyproject.toml b/pyproject.toml index d9c29089..8184acef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ pydantic = "^1.8.1" Babel = "^2.9.1" aiofiles = "^0.6.0" async_lru = "^1.0.2" -frozenlist = "^1.1.1" aiohttp-socks = { version = "^0.5.5", optional = true } aioredis = { version = "^2.0.0", allow-prereleases = true, optional = true } magic-filter = { version = "1.0.0a1", allow-prereleases = true } From e5a7c69e176806aeb2fcbb93e2d9efdf01d59aec Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 1 Aug 2021 01:09:43 +0300 Subject: [PATCH 21/38] Bump version --- .apiversion | 2 +- .github/workflows/testpypi.yml | 10 +++++----- README.md | 2 +- aiogram/__init__.py | 2 +- docs/index.rst | 2 +- poetry.lock | 8 ++++---- pyproject.toml | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.apiversion b/.apiversion index 341d0b55..11aa1452 100644 --- a/.apiversion +++ b/.apiversion @@ -1 +1 @@ -5.2 \ No newline at end of file +5.3 \ No newline at end of file diff --git a/.github/workflows/testpypi.yml b/.github/workflows/testpypi.yml index a74db317..a453c750 100644 --- a/.github/workflows/testpypi.yml +++ b/.github/workflows/testpypi.yml @@ -57,9 +57,9 @@ jobs: password: ${{ secrets.PYPI_TEST_TOKEN }} repository_url: https://test.pypi.org/legacy/ -# - name: Publish a Python distribution to PyPI -# uses: pypa/gh-action-pypi-publish@master + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@master # if: github.event.action == 'published' -# with: -# user: __token__ -# password: ${{ secrets.PYPI_TOKEN }} + with: + user: __token__ + password: ${{ secrets.PYPI_TOKEN }} diff --git a/README.md b/README.md index 76153bbf..ee1c3f8f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![\[Telegram\] aiogram live](https://img.shields.io/badge/telegram-aiogram-blue.svg?style=flat-square)](https://t.me/aiogram_live) [![MIT License](https://img.shields.io/pypi/l/aiogram.svg?style=flat-square)](https://opensource.org/licenses/MIT) [![Supported python versions](https://img.shields.io/pypi/pyversions/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) -[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-5.2-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) +[![Telegram Bot API](https://img.shields.io/badge/Telegram%20Bot%20API-5.3-blue.svg?style=flat-square&logo=telegram)](https://core.telegram.org/bots/api) [![PyPi Package Version](https://img.shields.io/pypi/v/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![PyPi status](https://img.shields.io/pypi/status/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) [![Downloads](https://img.shields.io/pypi/dm/aiogram.svg?style=flat-square)](https://pypi.python.org/pypi/aiogram) diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 1db738b1..1358d675 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -38,4 +38,4 @@ __all__ = ( ) __version__ = "3.0.0a11" -__api_version__ = "5.2" +__api_version__ = "5.3" diff --git a/docs/index.rst b/docs/index.rst index 1fa0678e..12517c1e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -13,7 +13,7 @@ aiogram :target: https://pypi.python.org/pypi/aiogram :alt: Supported python versions -.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.2-blue.svg?logo=telegram +.. image:: https://img.shields.io/badge/Telegram%20Bot%20API-5.3-blue.svg?logo=telegram :target: https://core.telegram.org/bots/api :alt: Telegram Bot API diff --git a/poetry.lock b/poetry.lock index 6d99a4c9..df96b90c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -458,7 +458,7 @@ tornado = {version = "*", markers = "python_version > \"2.7\""} [[package]] name = "magic-filter" -version = "1.0.0a1" +version = "1.0.0" description = "This package provides magic filter based on dynamic attribute getter" category = "main" optional = false @@ -1201,7 +1201,7 @@ redis = ["aioredis"] [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "e85c7f8f5dbb7fb40641cf8c900cd393e6fe2f4ad68f94fcaceeb746afc5f68f" +content-hash = "50a7fd536687179aa4a7fc2dd97f346065b6559328ad5e1d12ec75d45390d3cd" [metadata.files] aiofiles = [ @@ -1449,8 +1449,8 @@ livereload = [ {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, ] magic-filter = [ - {file = "magic-filter-1.0.0a1.tar.gz", hash = "sha256:af77522f1ab2a7aac6a960fb731097ada793da18f7ad96b1e29c11bd9c2d09cd"}, - {file = "magic_filter-1.0.0a1-py3-none-any.whl", hash = "sha256:ae4268493a6955887b63d1deb6f9409c063c7518d5e4bc6feb1dc1ce7ac61a0d"}, + {file = "magic-filter-1.0.0.tar.gz", hash = "sha256:6c1e8d185cd540606555a07a7c78d9c36bf0c97b9cd6e0a00da65dd38d56026f"}, + {file = "magic_filter-1.0.0-py3-none-any.whl", hash = "sha256:37f6c67144cbd087dcc1879f684b3640e13d5c73196544a5a00a6180c5edaa2e"}, ] markdown = [ {file = "Markdown-3.3.4-py3-none-any.whl", hash = "sha256:96c3ba1261de2f7547b46a00ea8463832c921d3f9d6aba3f255a6f71386db20c"}, diff --git a/pyproject.toml b/pyproject.toml index 8184acef..f68c226a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ classifiers = [ [tool.poetry.dependencies] python = "^3.8" +magic-filter = "^1.0.0" aiohttp = "^3.7.4" pydantic = "^1.8.1" Babel = "^2.9.1" @@ -39,7 +40,6 @@ aiofiles = "^0.6.0" async_lru = "^1.0.2" aiohttp-socks = { version = "^0.5.5", optional = true } aioredis = { version = "^2.0.0", allow-prereleases = true, optional = true } -magic-filter = { version = "1.0.0a1", allow-prereleases = true } sphinx = { version = "^3.1.0", optional = true } sphinx-intl = { version = "^2.0.1", optional = true } sphinx-autobuild = { version = "^2020.9.1", optional = true } From a19b6bcf3aad69551a28c36dab6bc1443c0266aa Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 1 Aug 2021 01:11:46 +0300 Subject: [PATCH 22/38] Bump version // 2 --- aiogram/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 1358d675..2c2c9ff5 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -37,5 +37,5 @@ __all__ = ( "md", ) -__version__ = "3.0.0a11" +__version__ = "3.0.0a12" __api_version__ = "5.3" diff --git a/pyproject.toml b/pyproject.toml index f68c226a..32acbf98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aiogram" -version = "3.0.0-alpha.11" +version = "3.0.0-alpha.12" description = "Modern and fully asynchronous framework for Telegram Bot API" authors = ["Alex Root Junior "] license = "MIT" From 16cf6419d36a752dd4921b7911fa28c50db75301 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 1 Aug 2021 01:19:03 +0300 Subject: [PATCH 23/38] Rename deployment script --- .github/workflows/{testpypi.yml => deploy.yml} | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) rename .github/workflows/{testpypi.yml => deploy.yml} (81%) diff --git a/.github/workflows/testpypi.yml b/.github/workflows/deploy.yml similarity index 81% rename from .github/workflows/testpypi.yml rename to .github/workflows/deploy.yml index a453c750..f6c0d175 100644 --- a/.github/workflows/testpypi.yml +++ b/.github/workflows/deploy.yml @@ -49,13 +49,13 @@ jobs: name: dist path: dist - - name: Publish a Python distribution to Test PyPI - uses: pypa/gh-action-pypi-publish@master -# if: github.event.action != 'published' - with: - user: __token__ - password: ${{ secrets.PYPI_TEST_TOKEN }} - repository_url: https://test.pypi.org/legacy/ +# - name: Publish a Python distribution to Test PyPI +# uses: pypa/gh-action-pypi-publish@master +## if: github.event.action != 'published' +# with: +# user: __token__ +# password: ${{ secrets.PYPI_TEST_TOKEN }} +# repository_url: https://test.pypi.org/legacy/ - name: Publish a Python distribution to PyPI uses: pypa/gh-action-pypi-publish@master From 9114abe919f18f78d82e461545e28f361c2cea37 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 1 Aug 2021 19:08:03 +0300 Subject: [PATCH 24/38] `towncrier` integration (#647) * Added towncrier and clone changelog from GitHub Releases page * Added record to changelog * Extended makefile * Fix typo * Changed ref in changelog * Auto-confirm towncrier question on build Co-authored-by: evgfilim1 --- CHANGES.rst | 16 ++ CHANGES/.template.rst.jinja2 | 44 ++++ CHANGES/602.doc | 1 + HISTORY.rst | 497 +++++++++++++++++++++++++++++++++++ Makefile | 20 ++ docs/changelog.rst | 5 + docs/index.rst | 1 + poetry.lock | 53 +++- pyproject.toml | 8 + 9 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 CHANGES.rst create mode 100644 CHANGES/.template.rst.jinja2 create mode 100644 CHANGES/602.doc create mode 100644 HISTORY.rst create mode 100644 docs/changelog.rst diff --git a/CHANGES.rst b/CHANGES.rst new file mode 100644 index 00000000..62d17a3d --- /dev/null +++ b/CHANGES.rst @@ -0,0 +1,16 @@ +========= +Changelog +========= + +.. + You should *NOT* be adding new change log entries to this file, this + file is managed by towncrier. You *may* edit previous change logs to + fix problems like typo corrections or such. + To add a new change log entry, please see + https://pip.pypa.io/en/latest/development/#adding-a-news-entry + we named the news folder "CHANGES". + + WARNING: Don't drop the next directive! + +.. towncrier release notes start + diff --git a/CHANGES/.template.rst.jinja2 b/CHANGES/.template.rst.jinja2 new file mode 100644 index 00000000..9881cc42 --- /dev/null +++ b/CHANGES/.template.rst.jinja2 @@ -0,0 +1,44 @@ +{% if top_line %} +{{ top_line }} +{{ top_underline * ((top_line)|length)}} +{% elif versiondata.name %} +{{ versiondata.version }} ({{ versiondata.date }}) +{{ top_underline * ((versiondata.version + versiondata.date)|length + 4)}} +{% else %} +{{ versiondata.version }} ({{ versiondata.date }}) +{{ top_underline * ((versiondata.version + versiondata.date)|length + 3)}} +{% endif %} +{% for section, _ in sections.items() %} +{% set underline = underlines[0] %}{% if section %}{{section}} +{{ underline * section|length }}{% set underline = underlines[1] %} + +{% endif %} + +{% if sections[section] %} +{% for category, val in definitions.items() if category in sections[section]%} +{{ definitions[category]['name'] }} +{{ underline * definitions[category]['name']|length }} + +{% if definitions[category]['showcontent'] %} +{% for text, values in sections[section][category].items() %} +- {{ text }} + {{ values|join(', ') }} +{% endfor %} + +{% else %} +- {{ sections[section][category]['']|join(', ') }} + +{% endif %} +{% if sections[section][category]|length == 0 %} +No significant changes. + +{% else %} +{% endif %} + +{% endfor %} +{% else %} +No significant changes. + + +{% endif %} +{% endfor %} \ No newline at end of file diff --git a/CHANGES/602.doc b/CHANGES/602.doc new file mode 100644 index 00000000..1c23d1d9 --- /dev/null +++ b/CHANGES/602.doc @@ -0,0 +1 @@ +Added integration with :code:`towncrier` \ No newline at end of file diff --git a/HISTORY.rst b/HISTORY.rst new file mode 100644 index 00000000..c1dc6ad5 --- /dev/null +++ b/HISTORY.rst @@ -0,0 +1,497 @@ + +.. + Copy-pasted and reformatted from GitHub releases page + + +2.14.3 (2021-07-21) +=================== + +- Fixed :code:`ChatMember` type detection via adding customizable object serialization mechanism (`#624 `_, `#623 `_) + + +2.14.2 (2021-07-26) +=================== + +- Fixed :code:`MemoryStorage` cleaner (`#619 `_) +- Fixed unused default locale in :code:`I18nMiddleware` (`#562 `_, `#563 `_) + + +2.14 (2021-07-27) +================= + +- Full support of Bot API 5.3 (`#610 `_, `#614 `_) +- Fixed :code:`Message.send_copy` method for polls (`#603 `_) +- Updated pattern for :code:`GroupDeactivated` exception (`#549 `_ +- Added :code:`caption_entities` field in :code:`InputMedia` base class (`#583 `_) +- Fixed HTML text decorations for tag :code:`pre` (`#597 `_ fixes issues `#596 `_ and `#481 `_) +- Fixed :code:`Message.get_full_command` method for messages with caption (`#576 `_) +- Improved :code:`MongoStorage`: remove documents with empty data from :code:`aiogram_data` collection to save memory. (`#609 `_) + + +2.13 (2021-04-28) +================= + +- Added full support of Bot API 5.2 (`#572 `_) +- Fixed usage of :code:`provider_data` argument in :code:`sendInvoice` method call +- Fixed builtin command filter args (`#556 `_) (`#558 `_) +- Allowed to use State instances FSM storage directly (`#542 `_) +- Added possibility to get i18n locale without User instance (`#546 `_) +- Fixed returning type of :code:`Bot.*_chat_invite_link()` methods `#548 `_ (`#549 `_) +- Fixed deep-linking util (`#569 `_) +- Small changes in documentation - describe limits in docstrings corresponding to the current limit. (`#565 `_) +- Fixed internal call to deprecated 'is_private' method (`#553 `_) +- Added possibility to use :code:`allowed_updates` argument in Polling mode (`#564 `_) + + +2.12.1 (2021-03-22) +=================== + +- Fixed :code:`TypeError: Value should be instance of 'User' not 'NoneType'` (`#527 `_) +- Added missing :code:`Chat.message_auto_delete_time` field (`#535 `_) +- Added :code:`MediaGroup` filter (`#528 `_) +- Added :code:`Chat.delete_message` shortcut (`#526 `_) +- Added mime types parsing for :code:`aiogram.types.Document` (`#431 `_) +- Added warning in :code:`TelegramObject.__setitem__` when Telegram adds a new field (`#532 `_) +- Fixed :code:`examples/chat_type_filter.py` (`#533 `_) +- Removed redundant definitions in framework code (`#531 `_) + + +2.12 (2021-03-14) +================= + +- Full support for Telegram Bot API 5.1 (`#519 `_) +- Fixed sending playlist of audio files and documents (`#465 `_, `#468 `_) +- Fixed :code:`FSMContextProxy.setdefault` method (`#491 `_) +- Fixed :code:`Message.answer_location` and :code:`Message.reply_location` unable to send live location (`#497 `_) +- Fixed :code:`user_id` and :code:`chat_id` getters from the context at Dispatcher :code:`check_key`, :code:`release_key` and :code:`throttle` methods (`#520 `_) +- Fixed :code:`Chat.update_chat` method and all similar situations (`#516 `_) +- Fixed :code:`MediaGroup` attach methods (`#514 `_) +- Fixed state filter for inline keyboard query callback in groups (`#508 `_, `#510 `_) +- Added missing :code:`ContentTypes.DICE` (`#466 `_) +- Added missing vcard argument to :code:`InputContactMessageContent` constructor (`#473 `_) +- Add missing exceptions: :code:`MessageIdInvalid`, :code:`CantRestrictChatOwner` and :code:`UserIsAnAdministratorOfTheChat` (`#474 `_, `#512 `_) +- Added :code:`answer_chat_action` to the :code:`Message` object (`#501 `_) +- Added dice to :code:`message.send_copy` method (`#511 `_) +- Removed deprecation warning from :code:`Message.send_copy` +- Added an example of integration between externally created aiohttp Application and aiogram (`#433 `_) +- Added :code:`split_separator` argument to :code:`safe_split_text` (`#515 `_) +- Fixed some typos in docs and examples (`#489 `_, `#490 `_, `#498 `_, `#504 `_, `#514 `_) + + +2.11.2 (2021-11-10) +=================== + +- Fixed default parse mode +- Added missing "supports_streaming" argument to answer_video method `#462 `_ + + +2.11.1 (2021-11-10) +=================== + +- Fixed files URL template +- Fix MessageEntity serialization for API calls `#457 `_ +- When entities are set, default parse_mode become disabled (`#461 `_) +- Added parameter supports_streaming to reply_video, remove redundant docstrings (`#459 `_) +- Added missing parameter to promoteChatMember alias (`#458 `_) + + +2.11 (2021-11-08) +================= + +- Added full support of Telegram Bot API 5.0 (`#454 `_) +- Added possibility to more easy specify custom API Server (example) + - WARNING: API method :code:`close` was named in Bot class as close_bot in due to Bot instance already has method with the same name. It will be changed in :code:`aiogram 3.0` +- Added alias to Message object :code:`Message.copy_to` with deprecation of :code:`Message.send_copy` +- :code:`ChatType.SUPER_GROUP` renamed to :code:`ChatType.SUPERGROUP` (`#438 `_) + + +2.10.1 (2021-09-14) +=================== + +- Fixed critical bug with getting asyncio event loop in executor. (`#424 `_) :code:`AttributeError: 'NoneType' object has no attribute 'run_until_complete'` + + +2.10 (2021-09-13) +================== + +- Breaking change: Stop using _MainThread event loop in bot/dispatcher instances (`#397 `_) +- Breaking change: Replaced aiomongo with motor (`#368 `_, `#380 `_) +- Fixed: TelegramObject's aren't destroyed after update handling `#307 `_ (`#371 `_) +- Add setting current context of Telegram types (`#369 `_) +- Fixed markdown escaping issues (`#363 `_) +- Fixed HTML characters escaping (`#409 `_) +- Fixed italic and underline decorations when parse entities to Markdown +- Fixed `#413 `_: parse entities positioning (`#414 `_) +- Added missing thumb parameter (`#362 `_) +- Added public methods to register filters and middlewares (`#370 `_) +- Added ChatType builtin filter (`#356 `_) +- Fixed IDFilter checking message from channel (`#376 `_) +- Added missed answer_poll and reply_poll (`#384 `_) +- Added possibility to ignore message caption in commands filter (`#383 `_) +- Fixed addStickerToSet method +- Added preparing thumb in send_document method (`#391 `_) +- Added exception MessageToPinNotFound (`#404 `_) +- Fixed handlers parameter-spec solving (`#408 `_) +- Fixed CallbackQuery.answer() returns nothing (`#420 `_) +- CHOSEN_INLINE_RESULT is a correct API-term (`#415 `_) +- Fixed missing attributes for Animation class (`#422 `_) +- Added missed emoji argument to reply_dice (`#395 `_) +- Added is_chat_creator method to ChatMemberStatus (`#394 `_) +- Added missed ChatPermissions to __all__ (`#393 `_) +- Added is_forward method to Message (`#390 `_) +- Fixed usage of deprecated is_private function (`#421 `_) + +and many others documentation and examples changes: + +- Updated docstring of RedisStorage2 (`#423 `_) +- Updated I18n example (added docs and fixed typos) (`#419 `_) +- A little documentation revision (`#381 `_) +- Added comments about correct errors_handlers usage (`#398 `_) +- Fixed typo rexex -> regex (`#386 `_) +- Fixed docs Quick start page code blocks (`#417 `_) +- fixed type hints of callback_data (`#400 `_) +- Prettify readme, update downloads stats badge (`#406 `_) + + +2.9.2 (2021-06-13) +================== + +- Fixed :code:`Message.get_full_command()` `#352 `_ +- Fixed markdown util `#353 `_ + + +2.9 (2021-06-08) +================ + +- Added full support of Telegram Bot API 4.9 +- Fixed user context at poll_answer update (`#322 `_) +- Fix Chat.set_description (`#325 `_) +- Add lazy session generator (`#326 `_) +- Fix text decorations (`#315 `_, `#316 `_, `#328 `_) +- Fix missing :code:`InlineQueryResultPhoto` :code:`parse_mode` field (`#331 `_) +- Fix fields from parent object in :code:`KeyboardButton` (`#344 `_ fixes `#343 `_) +- Add possibility to get bot id without calling :code:`get_me` (`#296 `_) + + +2.8 (2021-04-26) +================ + +- Added full support of Bot API 4.8 +- Added :code:`Message.answer_dice` and :code:`Message.reply_dice` methods (`#306 `_) + + +2.7 (2021-04-07) +================ + +- Added full support of Bot API 4.7 (`#294 `_ `#289 `_) +- Added default parse mode for send_animation method (`#293 `_ `#292 `_) +- Added new API exception when poll requested in public chats (`#270 `_) +- Make correct User and Chat get_mention methods (`#277 `_) +- Small changes and other minor improvements + + +2.6.1 (2021-01-25) +================== + +- Fixed reply :code:`KeyboardButton` initializer with :code:`request_poll` argument (`#266 `_) +- Added helper for poll types (:code:`aiogram.types.PollType`) +- Changed behavior of Telegram_object :code:`.as_*` and :code:`.to_*` methods. It will no more mutate the object. (`#247 `_) + + +2.6 (2021-01-23) +================ + +- Full support of Telegram Bot API v4.6 (Polls 2.0) `#265 `_ +- Aded new filter - IsContactSender (commit) +- Fixed proxy extra dependencies version `#262 `_ + + +2.5.3 (2021-01-05) +================== + +- `#255 `_ Updated CallbackData factory validity check. More correct for non-latin symbols +- `#256 `_ Fixed :code:`renamed_argument` decorator error +- `#257 `_ One more fix of CommandStart filter + + +2.5.2 (2021-01-01) +================== + +- Get back :code:`quote_html` and :code:`escape_md` functions + + +2.5.1 (2021-01-01) +================== + +- Hot-fix of :code:`CommandStart` filter + + +2.5 (2021-01-01) +================ + +- Added full support of Telegram Bot API 4.5 (`#250 `_, `#251 `_) +- `#239 `_ Fixed :code:`check_token` method +- `#238 `_, `#241 `_: Added deep-linking utils +- `#248 `_ Fixed support of aiohttp-socks +- Updated setup.py. No more use of internal pip API +- Updated links to documentations (https://docs.aiogram.dev) +- Other small changes and minor improvements (`#223 `_ and others...) + + +2.4 (2021-10-29) +================ + +- Added Message.send_copy method (forward message without forwarding) +- Safe close of aiohttp client session (no more exception when application is shutdown) +- No more "adWanced" words in project `#209 `_ +- Arguments user and chat is renamed to user_id and chat_id in Dispatcher.throttle method `#196 `_ +- Fixed set_chat_permissions `#198 `_ +- Fixed Dispatcher polling task does not process cancellation `#199 `_, `#201 `_ +- Fixed compatibility with latest asyncio version `#200 `_ +- Disabled caching by default for lazy_gettext method of I18nMiddleware `#203 `_ +- Fixed HTML user mention parser `#205 `_ +- Added IsReplyFilter `#210 `_ +- Fixed send_poll method arguments `#211 `_ +- Added OrderedHelper `#215 `_ +- Fix incorrect completion order. `#217 `_ + + +2.3 (2021-08-16) +================ + +- Full support of Telegram Bot API 4.4 +- Fixed `#143 `_ +- Added new filters from issue `#151 `_: `#172 `_, `#176 `_, `#182 `_ +- Added expire argument to RedisStorage2 and other storage fixes `#145 `_ +- Fixed JSON and Pickle storages `#138 `_ +- Implemented MongoStorage `#153 `_ based on aiomongo (soon motor will be also added) +- Improved tests +- Updated examples +- Warning: Updated auth widget util. `#190 `_ +- Implemented throttle decorator `#181 `_ + + +2.2 (2021-06-09) +================ + +- Provides latest Telegram Bot API (4.3) +- Updated docs for filters +- Added opportunity to use different bot tokens from single bot instance (via context manager, `#100 `_) +- IMPORTANT: Fixed Typo: :code:`data` -> :code:`bucket` in :code:`update_bucket` for RedisStorage2 (`#132 `_) + + +2.1 (2021-04-18) +================ + +- Implemented all new features from Telegram Bot API 4.2 +- :code:`is_member` and :code:`is_admin` methods of :code:`ChatMember` and :code:`ChatMemberStatus` was renamed to :code:`is_chat_member` and :code:`is_chat_admin` +- Remover func filter +- Added some useful Message edit functions (:code:`Message.edit_caption`, :code:`Message.edit_media`, :code:`Message.edit_reply_markup`) (`#121 `_, `#103 `_, `#104 `_, `#112 `_) +- Added requests timeout for all methods (`#110 `_) +- Added :code:`answer*` methods to :code:`Message` object (`#112 `_) +- Maked some improvements of :code:`CallbackData` factory +- Added deep-linking parameter filter to :code:`CommandStart` filter +- Implemented opportunity to use DNS over socks (`#97 `_ -> `#98 `_) +- Implemented logging filter for extending LogRecord attributes (Will be usefull with external logs collector utils like GrayLog, Kibana and etc.) +- Updated :code:`requirements.txt` and :code:`dev_requirements.txt` files +- Other small changes and minor improvements + + +2.0.1 (2021-12-31) +================== + +- Implemented CallbackData factory (`example `_) +- Implemented methods for answering to inline query from context and reply with animation to the messages. `#85 `_ +- Fixed installation from tar.gz `#84 `_ +- More exceptions (:code:`ChatIdIsEmpty` and :code:`NotEnoughRightsToRestrict`) + + +2.0 (2021-10-28) +================ + +This update will break backward compability with Python 3.6 and works only with Python 3.7+: +- contextvars (PEP-567); +- New syntax for annotations (PEP-563). + +Changes: +- Used contextvars instead of :code:`aiogram.utils.context`; +- Implemented filters factory; +- Implemented new filters mechanism; +- Allowed to customize command prefix in CommandsFilter; +- Implemented mechanism of passing results from filters (as dicts) as kwargs in handlers (like fixtures in pytest); +- Implemented states group feature; +- Implemented FSM storage's proxy; +- Changed files uploading mechanism; +- Implemented pipe for uploading files from URL; +- Implemented I18n Middleware; +- Errors handlers now should accept only two arguments (current update and exception); +- Used :code:`aiohttp_socks` instead of :code:`aiosocksy` for Socks4/5 proxy; +- types.ContentType was divided to :code:`types.ContentType` and :code:`types.ContentTypes`; +- Allowed to use rapidjson instead of ujson/json; +- :code:`.current()` method in bot and dispatcher objects was renamed to :code:`get_current()`; + +Full changelog +- You can read more details about this release in migration FAQ: ``_ + + +1.4 (2021-08-03) +================ + +- Bot API 4.0 (`#57 `_) + + +1.3.3 (2021-07-16) +================== + +- Fixed markup-entities parsing; +- Added more API exceptions; +- Now InlineQueryResultLocation has live_period; +- Added more message content types; +- Other small changes and minor improvements. + + +1.3.2 (2021-05-27) +================== + +- Fixed crashing of polling process. (i think) +- Added parse_mode field into input query results according to Bot API Docs. +- Added new methods for Chat object. (`#42 `_, `#43 `_) +- **Warning**: disabled connections limit for bot aiohttp session. +- **Warning**: Destroyed "temp sessions" mechanism. +- Added new error types. +- Refactored detection of error type. +- Small fixes of executor util. +- Fixed RethinkDBStorage + +1.3.1 (2018-05-27) +================== + + +1.3 (2021-04-22) +================ + +- Allow to use Socks5 proxy (need manually install :code:`aiosocksy`). +- Refactored :code:`aiogram.utils.executor` module. +- **[Warning]** Updated requirements list. + + +1.2.3 (2018-04-14) +================== + +- Fixed API errors detection +- Fixed compability of :code:`setup.py` with pip 10.0.0 + + +1.2.2 (2018-04-08) +================== + +- Added more error types. +- Implemented method :code:`InputFile.from_url(url: str)` for downloading files. +- Implemented big part of API method tests. +- Other small changes and mminor improvements. + + +1.2.1 (2018-03-25) +================== + +- Fixed handling Venue's [`#27 `_, `#26 `_] +- Added parse_mode to all medias (Bot API 3.6 support) [`#23 `_] +- Now regexp filter can be used with callback query data [`#19 `_] +- Improvements in :code:`InlineKeyboardMarkup` & :code:`ReplyKeyboardMarkup` objects [`#21 `_] +- Other bug & typo fixes and minor improvements. + + +1.2 (2018-02-23) +================ + +- Full provide Telegram Bot API 3.6 +- Fixed critical error: :code:`Fatal Python error: PyImport_GetModuleDict: no module dictionary!` +- Implemented connection pool in RethinkDB driver +- Typo fixes of documentstion +- Other bug fixes and minor improvements. + + +1.1 (2018-01-27) +================ + +- Added more methods for data types (like :code:`message.reply_sticker(...)` or :code:`file.download(...)` +- Typo fixes of documentstion +- Allow to set default parse mode for messages (:code:`Bot( ... , parse_mode='HTML')`) +- Allowed to cancel event from the :code:`Middleware.on_pre_process_` +- Fixed sending files with correct names. +- Fixed MediaGroup +- Added RethinkDB storage for FSM (:code:`aiogram.contrib.fsm_storage.rethinkdb`) + + +1.0.4 (2018-01-10) +================== + + +1.0.3 (2018-01-07) +================== + +- Added middlewares mechanism. +- Added example for middlewares and throttling manager. +- Added logging middleware (:code:`aiogram.contrib.middlewares.logging.LoggingMiddleware`) +- Fixed handling errors in async tasks (marked as 'async_task') +- Small fixes and other minor improvements. + + +1.0.2 (2017-11-29) +================== + + +1.0.1 (2017-11-21) +================== + +- Implemented :code:`types.InputFile` for more easy sending local files +- **Danger!** Fixed typo in word pooling. Now whatever all methods with that word marked as deprecated and original methods is renamed to polling. Check it in you'r code before updating! +- Fixed helper for chat actions (:code:`types.ChatActions`) +- Added `example `_ for media group. + + +1.0 (2017-11-19) +================ + +- Remaked data types serialozation/deserialization mechanism (Speed up). +- Fully rewrited all Telegram data types. +- Bot object was fully rewritted (regenerated). +- Full provide Telegram Bot API 3.4+ (with sendMediaGroup) +- Warning: Now :code:`BaseStorage.close()` is awaitable! (FSM) +- Fixed compability with uvloop. +- More employments for :code:`aiogram.utils.context`. +- Allowed to disable :code:`ujson`. +- Other bug fixes and minor improvements. +- Migrated from Bitbucket to Github. + + +0.4.1 (2017-08-03) +================== + + +0.4 (2017-08-05) +================ + + +0.3.4 (2017-08-04) +================== + + +0.3.3 (2017-07-05) +================== + + +0.3.2 (2017-07-04) +================== + + +0.3.1 (2017-07-04) +================== + + +0.2b1 (2017-06-00) +================== + + +0.1 (2017-06-03) +================ diff --git a/Makefile b/Makefile index da73f8b7..bb66a7e0 100644 --- a/Makefile +++ b/Makefile @@ -123,3 +123,23 @@ build: clean flake8-report mypy-report test-coverage bump: poetry version $(args) $(python) scripts/bump_versions.py + +.PHONY: towncrier-build +towncrier-build: + towncrier build --yes + +.PHONY: towncrier-draft +towncrier-draft: + towncrier build --draft + +.PHONY: towncrier-draft-github +towncrier-draft-github: + mkdir -p dist + towncrier build --draft | pandoc - -o dist/release.md + +.PHONY: prepare-release +prepare-release: bump towncrier-draft-github towncrier-build + +.PHONY: tag-release +tag-release: + git tag v$(poetry version -s) diff --git a/docs/changelog.rst b/docs/changelog.rst new file mode 100644 index 00000000..ddb848d5 --- /dev/null +++ b/docs/changelog.rst @@ -0,0 +1,5 @@ +.. _aiogram_changes: + +.. include:: ../CHANGES.rst + +.. include:: ../HISTORY.rst diff --git a/docs/index.rst b/docs/index.rst index 12517c1e..0016a02e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -93,3 +93,4 @@ Contents install api/index dispatcher/index + changelog diff --git a/poetry.lock b/poetry.lock index df96b90c..9b60c280 100644 --- a/poetry.lock +++ b/poetry.lock @@ -218,6 +218,17 @@ python-versions = ">=3.6" [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} +[[package]] +name = "click-default-group" +version = "1.2.2" +description = "Extends click.Group to invoke a command without explicit subcommand name" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +click = "*" + [[package]] name = "colorama" version = "0.4.4" @@ -354,6 +365,17 @@ zipp = ">=0.5" docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +[[package]] +name = "incremental" +version = "21.3.0" +description = "A small library that versions your Python projects." +category = "dev" +optional = false +python-versions = "*" + +[package.extras] +scripts = ["click (>=6.0)", "twisted (>=16.4.0)"] + [[package]] name = "iniconfig" version = "1.1.1" @@ -1099,6 +1121,24 @@ category = "main" optional = false python-versions = ">= 3.5" +[[package]] +name = "towncrier" +version = "21.3.0" +description = "Building newsfiles for your project." +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +click = "*" +click-default-group = "*" +incremental = "*" +jinja2 = "*" +toml = "*" + +[package.extras] +dev = ["packaging"] + [[package]] name = "traitlets" version = "5.0.5" @@ -1201,7 +1241,7 @@ redis = ["aioredis"] [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "50a7fd536687179aa4a7fc2dd97f346065b6559328ad5e1d12ec75d45390d3cd" +content-hash = "f6ac17a44b1eec95b101daab369097785a093d9263d0c6cf6c9ef8d363d8962d" [metadata.files] aiofiles = [ @@ -1319,6 +1359,9 @@ click = [ {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"}, {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"}, ] +click-default-group = [ + {file = "click-default-group-1.2.2.tar.gz", hash = "sha256:d9560e8e8dfa44b3562fbc9425042a0fd6d21956fcc2db0077f63f34253ab904"}, +] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, @@ -1421,6 +1464,10 @@ importlib-metadata = [ {file = "importlib_metadata-4.5.0-py3-none-any.whl", hash = "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00"}, {file = "importlib_metadata-4.5.0.tar.gz", hash = "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139"}, ] +incremental = [ + {file = "incremental-21.3.0-py2.py3-none-any.whl", hash = "sha256:92014aebc6a20b78a8084cdd5645eeaa7f74b8933f70fa3ada2cfbd1e3b54321"}, + {file = "incremental-21.3.0.tar.gz", hash = "sha256:02f5de5aff48f6b9f665d99d48bfc7ec03b6e3943210de7cfc88856d755d6f57"}, +] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, @@ -1883,6 +1930,10 @@ tornado = [ {file = "tornado-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4"}, {file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"}, ] +towncrier = [ + {file = "towncrier-21.3.0-py2.py3-none-any.whl", hash = "sha256:e6ccec65418bbcb8de5c908003e130e37fe0e9d6396cb77c1338241071edc082"}, + {file = "towncrier-21.3.0.tar.gz", hash = "sha256:6eed0bc924d72c98c000cb8a64de3bd566e5cb0d11032b73fcccf8a8f956ddfe"}, +] traitlets = [ {file = "traitlets-5.0.5-py3-none-any.whl", hash = "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426"}, {file = "traitlets-5.0.5.tar.gz", hash = "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396"}, diff --git a/pyproject.toml b/pyproject.toml index 32acbf98..513b6a2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,7 @@ sphinx-copybutton = "^0.3.1" furo = "^2021.6.18-beta.36" sphinx-prompt = "^1.3.0" Sphinx-Substitution-Extensions = "^2020.9.30" +towncrier = "^21.3.0" [tool.poetry.extras] @@ -131,6 +132,13 @@ known_third_party = [ "pytest" ] +[tool.towncrier] +package = "aiogram" +filename = "CHANGES.rst" +directory = "CHANGES/" +template = "CHANGES/.template.rst.jinja2" +issue_format = "`#{issue} `_" + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" From f4ca858571c59fc02a7f3b8c83475b0bc864dd90 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sun, 1 Aug 2021 19:56:01 +0300 Subject: [PATCH 25/38] Added possibility to use notes without issue --- CHANGES/.template.rst.jinja2 | 3 ++- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGES/.template.rst.jinja2 b/CHANGES/.template.rst.jinja2 index 9881cc42..cd927e5a 100644 --- a/CHANGES/.template.rst.jinja2 +++ b/CHANGES/.template.rst.jinja2 @@ -22,7 +22,8 @@ {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} - {{ text }} - {{ values|join(', ') }} +{% for value in values %}{% if value.isdigit() %} `#{{ value }} `_ +{% endif %}{% endfor %} {% endfor %} {% else %} diff --git a/pyproject.toml b/pyproject.toml index 513b6a2c..d1713690 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,7 +137,7 @@ package = "aiogram" filename = "CHANGES.rst" directory = "CHANGES/" template = "CHANGES/.template.rst.jinja2" -issue_format = "`#{issue} `_" +issue_format = "{issue}" [build-system] requires = ["poetry-core>=1.0.0"] From 18b268f0b6b2832b4ffea265a104633c0262d58e Mon Sep 17 00:00:00 2001 From: evgfilim1 Date: Tue, 3 Aug 2021 02:06:52 +0300 Subject: [PATCH 26/38] Update installation instructions (#655) * Update installation instructions * Small fix for 2.x branch & make instructions brief * Style fix --- docs/install.rst | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index a5a1f8ee..7dbf3cf8 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -5,34 +5,29 @@ Installation Stable (2.x) ============ -Using PIP +From PyPI --------- .. code-block:: bash pip install -U aiogram -Using AUR ---------- - -*aiogram* 2.x is also available in Arch Linux Repository, so you can install this framework -on any Arch-based distribution like Arch Linux, Antergos, Manjaro, etc. - -To do this, just use pacman to install the *python-aiogram* package: +From Arch Linux Repository +-------------------------- .. code-block:: bash - $ pacman -S python-aiogram + pacman -S python-aiogram Development build (3.x) ======================= -From test PyPi index +From PyPI ----------------------- .. code-block:: bash - pip install -U --extra-index-url https://test.pypi.org/simple/ --pre aiogram + pip install -U --pre aiogram From GitHub ----------- @@ -40,3 +35,10 @@ From GitHub .. code-block:: bash pip install https://github.com/aiogram/aiogram/archive/refs/heads/dev-3.x.zip + +From AUR +-------- + +.. code-block:: bash + + yay -S python-aiogram3 From 89f7debc854147c81635e79e02a88cfc19c9b2c9 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Tue, 3 Aug 2021 23:10:18 +0300 Subject: [PATCH 27/38] Add `.editorconfig` support (#653) * feat: add .editorconfig support * Added record to changes list Co-authored-by: Alex Root Junior --- .editorconfig | 24 ++++++++++++++++++++++++ CHANGES/650.misc | 1 + 2 files changed, 25 insertions(+) create mode 100644 .editorconfig create mode 100644 CHANGES/650.misc diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..9a80d2d5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 99 + +[*.{yml, yaml}] +indent_style = space +indent_size = 2 + +[*.{md,txt}] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/CHANGES/650.misc b/CHANGES/650.misc new file mode 100644 index 00000000..513270ef --- /dev/null +++ b/CHANGES/650.misc @@ -0,0 +1 @@ +Added `.editorconfig` From fff33e4ac9a62993f5ce3c11a0c85a5dc4df7b94 Mon Sep 17 00:00:00 2001 From: Oleg A Date: Tue, 3 Aug 2021 23:39:04 +0300 Subject: [PATCH 28/38] Redis storage speedup globals (#652) * chore: redis storage speedup globals #651 * chore: temp ignore aioredis typing issue * Added patch-notes Co-authored-by: Alex Root Junior --- CHANGES/651.misc | 1 + aiogram/dispatcher/fsm/storage/redis.py | 50 +++++++++++++++++++------ 2 files changed, 40 insertions(+), 11 deletions(-) create mode 100644 CHANGES/651.misc diff --git a/CHANGES/651.misc b/CHANGES/651.misc new file mode 100644 index 00000000..4efdd5ea --- /dev/null +++ b/CHANGES/651.misc @@ -0,0 +1 @@ +Redis storage speedup globals diff --git a/aiogram/dispatcher/fsm/storage/redis.py b/aiogram/dispatcher/fsm/storage/redis.py index f176e209..145aae22 100644 --- a/aiogram/dispatcher/fsm/storage/redis.py +++ b/aiogram/dispatcher/fsm/storage/redis.py @@ -60,39 +60,67 @@ class RedisStorage(BaseStorage): return ":".join(map(str, prefix_parts)) @asynccontextmanager - async def lock(self, bot: Bot, chat_id: int, user_id: int) -> AsyncGenerator[None, None]: - key = self.generate_key(bot, chat_id, user_id, STATE_LOCK_KEY) + async def lock( + self, bot: Bot, chat_id: int, user_id: int, state_lock_key: str = STATE_LOCK_KEY + ) -> AsyncGenerator[None, None]: + key = self.generate_key(bot, chat_id, user_id, state_lock_key) async with self.redis.lock(name=key, **self.lock_kwargs): yield None async def set_state( - self, bot: Bot, chat_id: int, user_id: int, state: StateType = None + self, + bot: Bot, + chat_id: int, + user_id: int, + state: StateType = None, + state_key: str = STATE_KEY, ) -> None: - key = self.generate_key(bot, chat_id, user_id, STATE_KEY) + key = self.generate_key(bot, chat_id, user_id, state_key) if state is None: await self.redis.delete(key) else: await self.redis.set( - key, state.state if isinstance(state, State) else state, ex=self.state_ttl # type: ignore[arg-type] + key, + state.state if isinstance(state, State) else state, # type: ignore[arg-type] + ex=self.state_ttl, # type: ignore[arg-type] ) - async def get_state(self, bot: Bot, chat_id: int, user_id: int) -> Optional[str]: - key = self.generate_key(bot, chat_id, user_id, STATE_KEY) + async def get_state( + self, + bot: Bot, + chat_id: int, + user_id: int, + state_key: str = STATE_KEY, + ) -> Optional[str]: + key = self.generate_key(bot, chat_id, user_id, state_key) value = await self.redis.get(key) if isinstance(value, bytes): return value.decode("utf-8") return cast(Optional[str], value) - async def set_data(self, bot: Bot, chat_id: int, user_id: int, data: Dict[str, Any]) -> None: - key = self.generate_key(bot, chat_id, user_id, STATE_DATA_KEY) + async def set_data( + self, + bot: Bot, + chat_id: int, + user_id: int, + data: Dict[str, Any], + state_data_key: str = STATE_DATA_KEY, + ) -> None: + key = self.generate_key(bot, chat_id, user_id, state_data_key) if not data: await self.redis.delete(key) return json_data = bot.session.json_dumps(data) await self.redis.set(key, json_data, ex=self.data_ttl) # type: ignore[arg-type] - async def get_data(self, bot: Bot, chat_id: int, user_id: int) -> Dict[str, Any]: - key = self.generate_key(bot, chat_id, user_id, STATE_DATA_KEY) + async def get_data( + self, + bot: Bot, + chat_id: int, + user_id: int, + state_data_key: str = STATE_DATA_KEY, + ) -> Dict[str, Any]: + key = self.generate_key(bot, chat_id, user_id, state_data_key) value = await self.redis.get(key) if value is None: return {} From f2f276b8cf91795edc58a2e72a546a577f3670ee Mon Sep 17 00:00:00 2001 From: Oleg A Date: Tue, 3 Aug 2021 23:40:14 +0300 Subject: [PATCH 29/38] refactor: remove redundant pytest marks (#654) --- tests/test_api/test_client/test_bot.py | 9 ++------ .../test_session/test_aiohttp_session.py | 12 ++-------- .../test_session/test_base_session.py | 6 ++--- .../test_methods/test_add_sticker_to_set.py | 4 ++-- .../test_answer_callback_query.py | 4 ++-- .../test_methods/test_answer_inline_query.py | 4 ++-- .../test_answer_pre_checkout_query.py | 4 ++-- .../test_answer_shipping_query.py | 4 ++-- .../test_methods/test_ban_chat_member.py | 4 ++-- tests/test_api/test_methods/test_base.py | 4 ++-- tests/test_api/test_methods/test_close.py | 4 ++-- .../test_methods/test_copy_message.py | 4 ++-- .../test_create_chat_invite_link.py | 4 ++-- .../test_create_new_sticker_set.py | 4 ++-- .../test_methods/test_delete_chat_photo.py | 4 ++-- .../test_delete_chat_sticker_set.py | 4 ++-- .../test_methods/test_delete_message.py | 4 ++-- .../test_methods/test_delete_my_commands.py | 4 ++-- .../test_delete_sticker_from_set.py | 4 ++-- .../test_methods/test_delete_webhook.py | 4 ++-- .../test_edit_chat_invite_link.py | 4 ++-- .../test_methods/test_edit_message_caption.py | 4 ++-- .../test_edit_message_live_location.py | 4 ++-- .../test_methods/test_edit_message_media.py | 4 ++-- .../test_edit_message_reply_markup.py | 4 ++-- .../test_methods/test_edit_message_text.py | 4 ++-- .../test_export_chat_invite_link.py | 4 ++-- .../test_methods/test_forward_message.py | 4 ++-- tests/test_api/test_methods/test_get_chat.py | 4 ++-- .../test_get_chat_administrators.py | 4 ++-- .../test_methods/test_get_chat_member.py | 4 ++-- .../test_get_chat_member_count.py | 4 ++-- .../test_get_chat_members_count.py | 4 ++-- tests/test_api/test_methods/test_get_file.py | 4 ++-- .../test_methods/test_get_game_high_scores.py | 4 ++-- tests/test_api/test_methods/test_get_me.py | 5 ++--- .../test_methods/test_get_my_commands.py | 4 ++-- .../test_methods/test_get_sticker_set.py | 4 ++-- .../test_api/test_methods/test_get_updates.py | 4 ++-- .../test_get_user_profile_photos.py | 4 ++-- .../test_methods/test_get_webhook_info.py | 4 ++-- .../test_methods/test_kick_chat_member.py | 4 ++-- .../test_api/test_methods/test_leave_chat.py | 4 ++-- tests/test_api/test_methods/test_log_out.py | 4 ++-- .../test_methods/test_pin_chat_message.py | 4 ++-- .../test_methods/test_promote_chat_member.py | 4 ++-- .../test_methods/test_restrict_chat_member.py | 4 ++-- .../test_revoke_chat_invite_link.py | 4 ++-- .../test_methods/test_send_animation.py | 4 ++-- .../test_api/test_methods/test_send_audio.py | 4 ++-- .../test_methods/test_send_chat_action.py | 4 ++-- .../test_methods/test_send_contact.py | 4 ++-- tests/test_api/test_methods/test_send_dice.py | 4 ++-- .../test_methods/test_send_document.py | 4 ++-- tests/test_api/test_methods/test_send_game.py | 4 ++-- .../test_methods/test_send_invoice.py | 4 ++-- .../test_methods/test_send_location.py | 4 ++-- .../test_methods/test_send_media_group.py | 4 ++-- .../test_methods/test_send_message.py | 4 ++-- .../test_api/test_methods/test_send_photo.py | 4 ++-- tests/test_api/test_methods/test_send_poll.py | 4 ++-- .../test_methods/test_send_sticker.py | 4 ++-- .../test_api/test_methods/test_send_venue.py | 4 ++-- .../test_api/test_methods/test_send_video.py | 4 ++-- .../test_methods/test_send_video_note.py | 4 ++-- .../test_api/test_methods/test_send_voice.py | 4 ++-- ...est_set_chat_administrator_custom_title.py | 4 ++-- .../test_methods/test_set_chat_description.py | 4 ++-- .../test_methods/test_set_chat_permissions.py | 4 ++-- .../test_methods/test_set_chat_photo.py | 4 ++-- .../test_methods/test_set_chat_sticker_set.py | 4 ++-- .../test_methods/test_set_chat_title.py | 4 ++-- .../test_methods/test_set_game_score.py | 4 ++-- .../test_methods/test_set_my_commands.py | 4 ++-- .../test_set_passport_data_errors.py | 4 ++-- .../test_set_sticker_position_in_set.py | 4 ++-- .../test_set_sticker_set_thumb.py | 4 ++-- .../test_api/test_methods/test_set_webhook.py | 4 ++-- .../test_stop_message_live_location.py | 4 ++-- tests/test_api/test_methods/test_stop_poll.py | 4 ++-- .../test_methods/test_unban_chat_member.py | 4 ++-- .../test_unpin_all_chat_messages.py | 4 ++-- .../test_methods/test_unpin_chat_message.py | 4 ++-- .../test_methods/test_upload_sticker_file.py | 4 ++-- tests/test_api/test_types/test_input_file.py | 7 ++---- tests/test_dispatcher/test_dispatcher.py | 22 ++----------------- .../test_dispatcher/test_event/test_event.py | 3 ++- .../test_event/test_handler.py | 10 ++------- .../test_event/test_telegram.py | 7 +++--- .../test_dispatcher/test_filters/test_base.py | 3 ++- .../test_filters/test_callback_data.py | 4 ++-- .../test_filters/test_command.py | 4 ++-- .../test_filters/test_content_types.py | 4 ++-- .../test_filters/test_exception.py | 4 ++-- .../test_dispatcher/test_filters/test_text.py | 3 ++- .../test_fsm/storage/test_redis.py | 3 ++- .../test_fsm/storage/test_storages.py | 6 ++--- .../test_dispatcher/test_fsm/test_context.py | 3 ++- .../test_dispatcher/test_handler/test_base.py | 6 ++--- .../test_handler/test_callback_query.py | 3 ++- .../test_handler/test_chat_member.py | 3 ++- .../test_handler/test_chosen_inline_result.py | 3 ++- .../test_handler/test_error.py | 3 ++- .../test_handler/test_inline_query.py | 3 ++- .../test_handler/test_message.py | 3 ++- .../test_dispatcher/test_handler/test_poll.py | 3 ++- .../test_handler/test_pre_checkout_query.py | 3 ++- .../test_handler/test_shipping_query.py | 3 ++- tests/test_dispatcher/test_router.py | 4 +--- tests/test_utils/test_backoff.py | 2 +- tests/test_utils/test_deep_linking.py | 3 ++- 111 files changed, 221 insertions(+), 256 deletions(-) diff --git a/tests/test_api/test_client/test_bot.py b/tests/test_api/test_client/test_bot.py index a3c11bd9..b36006cc 100644 --- a/tests/test_api/test_client/test_bot.py +++ b/tests/test_api/test_client/test_bot.py @@ -16,6 +16,8 @@ except ImportError: from unittest.mock import AsyncMock as CoroutineMock # type: ignore from unittest.mock import patch +pytestmark = pytest.mark.asyncio + class TestBot: def test_init(self): @@ -32,7 +34,6 @@ class TestBot: assert bot == Bot("42:TEST") assert bot != "42:TEST" - @pytest.mark.asyncio async def test_emit(self): bot = Bot("42:TEST") @@ -45,7 +46,6 @@ class TestBot: await bot(method) mocked_make_request.assert_awaited_with(bot, method, timeout=None) - @pytest.mark.asyncio async def test_close(self): session = AiohttpSession() bot = Bot("42:TEST", session=session) @@ -57,7 +57,6 @@ class TestBot: await bot.session.close() mocked_close.assert_awaited() - @pytest.mark.asyncio @pytest.mark.parametrize("close", [True, False]) async def test_context_manager(self, close: bool): with patch( @@ -70,7 +69,6 @@ class TestBot: else: mocked_close.assert_not_awaited() - @pytest.mark.asyncio async def test_download_file(self, aresponses: ResponsesMockServer): aresponses.add( aresponses.ANY, aresponses.ANY, "get", aresponses.Response(status=200, body=b"\f" * 10) @@ -88,7 +86,6 @@ class TestBot: await bot.download_file("TEST", "file.png") mock_file.write.assert_called_once_with(b"\f" * 10) - @pytest.mark.asyncio async def test_download_file_default_destination(self, aresponses: ResponsesMockServer): bot = Bot("42:TEST") @@ -101,7 +98,6 @@ class TestBot: assert isinstance(result, io.BytesIO) assert result.read() == b"\f" * 10 - @pytest.mark.asyncio async def test_download_file_custom_destination(self, aresponses: ResponsesMockServer): bot = Bot("42:TEST") @@ -117,7 +113,6 @@ class TestBot: assert result is custom assert result.read() == b"\f" * 10 - @pytest.mark.asyncio async def test_download(self, bot: MockedBot, aresponses: ResponsesMockServer): bot.add_result_for( GetFile, ok=True, result=File(file_id="file id", file_unique_id="file id") diff --git a/tests/test_api/test_client/test_session/test_aiohttp_session.py b/tests/test_api/test_client/test_session/test_aiohttp_session.py index e3149a29..36c9e17a 100644 --- a/tests/test_api/test_client/test_session/test_aiohttp_session.py +++ b/tests/test_api/test_client/test_session/test_aiohttp_session.py @@ -20,6 +20,8 @@ except ImportError: from unittest.mock import AsyncMock as CoroutineMock # type: ignore from unittest.mock import patch +pytestmark = pytest.mark.asyncio + class BareInputFile(InputFile): async def read(self, chunk_size: int): @@ -27,7 +29,6 @@ class BareInputFile(InputFile): class TestAiohttpSession: - @pytest.mark.asyncio async def test_create_session(self): session = AiohttpSession() @@ -36,7 +37,6 @@ class TestAiohttpSession: assert session._session is not None assert isinstance(aiohttp_session, aiohttp.ClientSession) - @pytest.mark.asyncio async def test_create_proxy_session(self): session = AiohttpSession( proxy=("socks5://proxy.url/", aiohttp.BasicAuth("login", "password", "encoding")) @@ -50,7 +50,6 @@ class TestAiohttpSession: aiohttp_session = await session.create_session() assert isinstance(aiohttp_session.connector, aiohttp_socks.ProxyConnector) - @pytest.mark.asyncio async def test_create_proxy_session_proxy_url(self): session = AiohttpSession(proxy="socks4://proxy.url/") @@ -62,7 +61,6 @@ class TestAiohttpSession: aiohttp_session = await session.create_session() assert isinstance(aiohttp_session.connector, aiohttp_socks.ProxyConnector) - @pytest.mark.asyncio async def test_create_proxy_session_chained_proxies(self): session = AiohttpSession( proxy=[ @@ -89,7 +87,6 @@ class TestAiohttpSession: aiohttp_session = await session.create_session() assert isinstance(aiohttp_session.connector, aiohttp_socks.ChainProxyConnector) - @pytest.mark.asyncio async def test_reset_connector(self): session = AiohttpSession() assert session._should_reset_connector @@ -105,7 +102,6 @@ class TestAiohttpSession: assert session._should_reset_connector is False await session.close() - @pytest.mark.asyncio async def test_close_session(self): session = AiohttpSession() await session.create_session() @@ -153,7 +149,6 @@ class TestAiohttpSession: assert fields[1][0]["filename"] == "file.txt" assert isinstance(fields[1][2], BareInputFile) - @pytest.mark.asyncio async def test_make_request(self, bot: MockedBot, aresponses: ResponsesMockServer): aresponses.add( aresponses.ANY, @@ -181,7 +176,6 @@ class TestAiohttpSession: assert result == 42 @pytest.mark.parametrize("error", [ClientError("mocked"), asyncio.TimeoutError()]) - @pytest.mark.asyncio async def test_make_request_network_error(self, error): bot = Bot("42:TEST") @@ -196,7 +190,6 @@ class TestAiohttpSession: with pytest.raises(NetworkError): await bot.get_me() - @pytest.mark.asyncio async def test_stream_content(self, aresponses: ResponsesMockServer): aresponses.add( aresponses.ANY, aresponses.ANY, "get", aresponses.Response(status=200, body=b"\f" * 10) @@ -216,7 +209,6 @@ class TestAiohttpSession: size += chunk_size assert size == 10 - @pytest.mark.asyncio async def test_context_manager(self): session = AiohttpSession() assert isinstance(session, AsyncContextManager) diff --git a/tests/test_api/test_client/test_session/test_base_session.py b/tests/test_api/test_client/test_session/test_base_session.py index c30b63ea..d4e28293 100644 --- a/tests/test_api/test_client/test_session/test_base_session.py +++ b/tests/test_api/test_client/test_session/test_base_session.py @@ -25,6 +25,8 @@ except ImportError: from unittest.mock import AsyncMock as CoroutineMock # type: ignore from unittest.mock import patch +pytestmark = pytest.mark.asyncio + class CustomSession(BaseSession): async def close(self): @@ -195,13 +197,11 @@ class TestBaseSession: if error.url: assert error.url in string - @pytest.mark.asyncio async def test_make_request(self): session = CustomSession() assert await session.make_request("42:TEST", GetMe()) is None - @pytest.mark.asyncio async def test_stream_content(self): session = CustomSession() stream = session.stream_content( @@ -212,7 +212,6 @@ class TestBaseSession: async for chunk in stream: assert isinstance(chunk, bytes) - @pytest.mark.asyncio async def test_context_manager(self): session = CustomSession() assert isinstance(session, AsyncContextManager) @@ -236,7 +235,6 @@ class TestBaseSession: assert my_middleware in session.middlewares assert len(session.middlewares) == 1 - @pytest.mark.asyncio async def test_use_middleware(self, bot: MockedBot): flag_before = False flag_after = False diff --git a/tests/test_api/test_methods/test_add_sticker_to_set.py b/tests/test_api/test_methods/test_add_sticker_to_set.py index dae220cc..35a08f75 100644 --- a/tests/test_api/test_methods/test_add_sticker_to_set.py +++ b/tests/test_api/test_methods/test_add_sticker_to_set.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import AddStickerToSet, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestAddStickerToSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AddStickerToSet, ok=True, result=True) @@ -16,7 +17,6 @@ class TestAddStickerToSet: assert request.method == "addStickerToSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AddStickerToSet, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_answer_callback_query.py b/tests/test_api/test_methods/test_answer_callback_query.py index c3749455..f157e86f 100644 --- a/tests/test_api/test_methods/test_answer_callback_query.py +++ b/tests/test_api/test_methods/test_answer_callback_query.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import AnswerCallbackQuery, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestAnswerCallbackQuery: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerCallbackQuery, ok=True, result=True) @@ -14,7 +15,6 @@ class TestAnswerCallbackQuery: assert request.method == "answerCallbackQuery" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerCallbackQuery, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_answer_inline_query.py b/tests/test_api/test_methods/test_answer_inline_query.py index a843a59d..6b15cb8c 100644 --- a/tests/test_api/test_methods/test_answer_inline_query.py +++ b/tests/test_api/test_methods/test_answer_inline_query.py @@ -5,9 +5,10 @@ from aiogram.methods import AnswerInlineQuery, Request from aiogram.types import InlineQueryResult, InlineQueryResultPhoto from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestAnswerInlineQuery: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerInlineQuery, ok=True, result=True) @@ -18,7 +19,6 @@ class TestAnswerInlineQuery: assert request.method == "answerInlineQuery" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerInlineQuery, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_answer_pre_checkout_query.py b/tests/test_api/test_methods/test_answer_pre_checkout_query.py index b1afa384..7d0077c7 100644 --- a/tests/test_api/test_methods/test_answer_pre_checkout_query.py +++ b/tests/test_api/test_methods/test_answer_pre_checkout_query.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import AnswerPreCheckoutQuery, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestAnswerPreCheckoutQuery: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerPreCheckoutQuery, ok=True, result=True) @@ -14,7 +15,6 @@ class TestAnswerPreCheckoutQuery: assert request.method == "answerPreCheckoutQuery" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerPreCheckoutQuery, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_answer_shipping_query.py b/tests/test_api/test_methods/test_answer_shipping_query.py index bba639a3..1374543e 100644 --- a/tests/test_api/test_methods/test_answer_shipping_query.py +++ b/tests/test_api/test_methods/test_answer_shipping_query.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import AnswerShippingQuery, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestAnswerShippingQuery: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerShippingQuery, ok=True, result=True) @@ -14,7 +15,6 @@ class TestAnswerShippingQuery: assert request.method == "answerShippingQuery" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(AnswerShippingQuery, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_ban_chat_member.py b/tests/test_api/test_methods/test_ban_chat_member.py index bdbf1c41..73090f40 100644 --- a/tests/test_api/test_methods/test_ban_chat_member.py +++ b/tests/test_api/test_methods/test_ban_chat_member.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import BanChatMember, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestKickChatMember: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(BanChatMember, ok=True, result=True) @@ -14,7 +15,6 @@ class TestKickChatMember: assert request.method == "banChatMember" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(BanChatMember, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_base.py b/tests/test_api/test_methods/test_base.py index 079721a3..4dc39946 100644 --- a/tests/test_api/test_methods/test_base.py +++ b/tests/test_api/test_methods/test_base.py @@ -6,6 +6,8 @@ from aiogram import Bot from aiogram.methods.base import prepare_parse_mode from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestPrepareFile: # TODO: Add tests @@ -34,7 +36,6 @@ class TestPrepareParseMode: ["Markdown", {"parse_mode": "HTML"}, "HTML"], ], ) - @pytest.mark.asyncio async def test_default_parse_mode( self, bot: MockedBot, parse_mode: str, data: Dict[str, str], result: Optional[str] ): @@ -43,7 +44,6 @@ class TestPrepareParseMode: prepare_parse_mode(bot, data) assert data.get("parse_mode") == result - @pytest.mark.asyncio async def test_list(self): data = [{}] * 2 data.append({"parse_mode": "HTML"}) diff --git a/tests/test_api/test_methods/test_close.py b/tests/test_api/test_methods/test_close.py index c497520e..c6a84e31 100644 --- a/tests/test_api/test_methods/test_close.py +++ b/tests/test_api/test_methods/test_close.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Close, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestClose: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(Close, ok=True, result=True) @@ -15,7 +16,6 @@ class TestClose: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(Close, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_copy_message.py b/tests/test_api/test_methods/test_copy_message.py index d380f24a..e844cede 100644 --- a/tests/test_api/test_methods/test_copy_message.py +++ b/tests/test_api/test_methods/test_copy_message.py @@ -4,9 +4,10 @@ from aiogram.methods import CopyMessage, Request from aiogram.types import MessageId from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestCopyMessage: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(CopyMessage, ok=True, result=MessageId(message_id=42)) @@ -20,7 +21,6 @@ class TestCopyMessage: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(CopyMessage, ok=True, result=MessageId(message_id=42)) diff --git a/tests/test_api/test_methods/test_create_chat_invite_link.py b/tests/test_api/test_methods/test_create_chat_invite_link.py index d33d25f7..0241a60c 100644 --- a/tests/test_api/test_methods/test_create_chat_invite_link.py +++ b/tests/test_api/test_methods/test_create_chat_invite_link.py @@ -4,9 +4,10 @@ from aiogram.methods import CreateChatInviteLink, Request from aiogram.types import ChatInviteLink, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestCreateChatInviteLink: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( CreateChatInviteLink, @@ -27,7 +28,6 @@ class TestCreateChatInviteLink: # assert request.data == {"chat_id": -42} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( CreateChatInviteLink, diff --git a/tests/test_api/test_methods/test_create_new_sticker_set.py b/tests/test_api/test_methods/test_create_new_sticker_set.py index 218bff0d..4c927b77 100644 --- a/tests/test_api/test_methods/test_create_new_sticker_set.py +++ b/tests/test_api/test_methods/test_create_new_sticker_set.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import CreateNewStickerSet, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestCreateNewStickerSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(CreateNewStickerSet, ok=True, result=True) @@ -16,7 +17,6 @@ class TestCreateNewStickerSet: assert request.method == "createNewStickerSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(CreateNewStickerSet, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_delete_chat_photo.py b/tests/test_api/test_methods/test_delete_chat_photo.py index c807bd71..0f528775 100644 --- a/tests/test_api/test_methods/test_delete_chat_photo.py +++ b/tests/test_api/test_methods/test_delete_chat_photo.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import DeleteChatPhoto, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestDeleteChatPhoto: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteChatPhoto, ok=True, result=True) @@ -14,7 +15,6 @@ class TestDeleteChatPhoto: assert request.method == "deleteChatPhoto" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteChatPhoto, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_delete_chat_sticker_set.py b/tests/test_api/test_methods/test_delete_chat_sticker_set.py index 80d0967d..8fdb7b84 100644 --- a/tests/test_api/test_methods/test_delete_chat_sticker_set.py +++ b/tests/test_api/test_methods/test_delete_chat_sticker_set.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import DeleteChatStickerSet, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestDeleteChatStickerSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteChatStickerSet, ok=True, result=True) @@ -14,7 +15,6 @@ class TestDeleteChatStickerSet: assert request.method == "deleteChatStickerSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteChatStickerSet, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_delete_message.py b/tests/test_api/test_methods/test_delete_message.py index 87b8efad..ecceb6dd 100644 --- a/tests/test_api/test_methods/test_delete_message.py +++ b/tests/test_api/test_methods/test_delete_message.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import DeleteMessage, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestDeleteMessage: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteMessage, ok=True, result=True) @@ -14,7 +15,6 @@ class TestDeleteMessage: assert request.method == "deleteMessage" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteMessage, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_delete_my_commands.py b/tests/test_api/test_methods/test_delete_my_commands.py index b5546b25..14d36381 100644 --- a/tests/test_api/test_methods/test_delete_my_commands.py +++ b/tests/test_api/test_methods/test_delete_my_commands.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import DeleteMyCommands, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestKickChatMember: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteMyCommands, ok=True, result=True) @@ -14,7 +15,6 @@ class TestKickChatMember: assert request.method == "deleteMyCommands" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteMyCommands, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_delete_sticker_from_set.py b/tests/test_api/test_methods/test_delete_sticker_from_set.py index 350e0b3e..c17a5493 100644 --- a/tests/test_api/test_methods/test_delete_sticker_from_set.py +++ b/tests/test_api/test_methods/test_delete_sticker_from_set.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import DeleteStickerFromSet, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestDeleteStickerFromSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteStickerFromSet, ok=True, result=True) @@ -14,7 +15,6 @@ class TestDeleteStickerFromSet: assert request.method == "deleteStickerFromSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteStickerFromSet, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_delete_webhook.py b/tests/test_api/test_methods/test_delete_webhook.py index 91ecb809..21c0fcf7 100644 --- a/tests/test_api/test_methods/test_delete_webhook.py +++ b/tests/test_api/test_methods/test_delete_webhook.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import DeleteWebhook, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestDeleteWebhook: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteWebhook, ok=True, result=True) @@ -14,7 +15,6 @@ class TestDeleteWebhook: assert request.method == "deleteWebhook" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(DeleteWebhook, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_edit_chat_invite_link.py b/tests/test_api/test_methods/test_edit_chat_invite_link.py index 212d1e51..b0c055c7 100644 --- a/tests/test_api/test_methods/test_edit_chat_invite_link.py +++ b/tests/test_api/test_methods/test_edit_chat_invite_link.py @@ -4,9 +4,10 @@ from aiogram.methods import EditChatInviteLink, Request from aiogram.types import ChatInviteLink, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestEditChatInviteLink: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( EditChatInviteLink, @@ -27,7 +28,6 @@ class TestEditChatInviteLink: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( EditChatInviteLink, diff --git a/tests/test_api/test_methods/test_edit_message_caption.py b/tests/test_api/test_methods/test_edit_message_caption.py index 62803d43..a3afef46 100644 --- a/tests/test_api/test_methods/test_edit_message_caption.py +++ b/tests/test_api/test_methods/test_edit_message_caption.py @@ -7,9 +7,10 @@ from aiogram.methods import EditMessageCaption, Request from aiogram.types import Chat, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestEditMessageCaption: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( EditMessageCaption, @@ -27,7 +28,6 @@ class TestEditMessageCaption: assert request.method == "editMessageCaption" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( EditMessageCaption, diff --git a/tests/test_api/test_methods/test_edit_message_live_location.py b/tests/test_api/test_methods/test_edit_message_live_location.py index db04fb19..32f19b2f 100644 --- a/tests/test_api/test_methods/test_edit_message_live_location.py +++ b/tests/test_api/test_methods/test_edit_message_live_location.py @@ -6,9 +6,10 @@ from aiogram.methods import EditMessageLiveLocation, Request from aiogram.types import Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestEditMessageLiveLocation: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageLiveLocation, ok=True, result=True) @@ -19,7 +20,6 @@ class TestEditMessageLiveLocation: assert request.method == "editMessageLiveLocation" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageLiveLocation, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_edit_message_media.py b/tests/test_api/test_methods/test_edit_message_media.py index ee3003b9..bc60e4d5 100644 --- a/tests/test_api/test_methods/test_edit_message_media.py +++ b/tests/test_api/test_methods/test_edit_message_media.py @@ -6,9 +6,10 @@ from aiogram.methods import EditMessageMedia, Request from aiogram.types import BufferedInputFile, InputMediaPhoto, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestEditMessageMedia: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageMedia, ok=True, result=True) @@ -19,7 +20,6 @@ class TestEditMessageMedia: assert request.method == "editMessageMedia" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageMedia, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_edit_message_reply_markup.py b/tests/test_api/test_methods/test_edit_message_reply_markup.py index 474f052a..71a1f823 100644 --- a/tests/test_api/test_methods/test_edit_message_reply_markup.py +++ b/tests/test_api/test_methods/test_edit_message_reply_markup.py @@ -6,9 +6,10 @@ from aiogram.methods import EditMessageReplyMarkup, Request from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestEditMessageReplyMarkup: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageReplyMarkup, ok=True, result=True) @@ -25,7 +26,6 @@ class TestEditMessageReplyMarkup: assert request.method == "editMessageReplyMarkup" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageReplyMarkup, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_edit_message_text.py b/tests/test_api/test_methods/test_edit_message_text.py index d889e7ec..5543f62c 100644 --- a/tests/test_api/test_methods/test_edit_message_text.py +++ b/tests/test_api/test_methods/test_edit_message_text.py @@ -6,9 +6,10 @@ from aiogram.methods import EditMessageText, Request from aiogram.types import Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestEditMessageText: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageText, ok=True, result=True) @@ -19,7 +20,6 @@ class TestEditMessageText: assert request.method == "editMessageText" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(EditMessageText, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_export_chat_invite_link.py b/tests/test_api/test_methods/test_export_chat_invite_link.py index 2d5f6d05..fc8b4f13 100644 --- a/tests/test_api/test_methods/test_export_chat_invite_link.py +++ b/tests/test_api/test_methods/test_export_chat_invite_link.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import ExportChatInviteLink, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestExportChatInviteLink: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( ExportChatInviteLink, ok=True, result="http://example.com" @@ -16,7 +17,6 @@ class TestExportChatInviteLink: assert request.method == "exportChatInviteLink" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( ExportChatInviteLink, ok=True, result="http://example.com" diff --git a/tests/test_api/test_methods/test_forward_message.py b/tests/test_api/test_methods/test_forward_message.py index 77adb37d..94dfdf60 100644 --- a/tests/test_api/test_methods/test_forward_message.py +++ b/tests/test_api/test_methods/test_forward_message.py @@ -6,9 +6,10 @@ from aiogram.methods import ForwardMessage, Request from aiogram.types import Chat, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestForwardMessage: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( ForwardMessage, @@ -27,7 +28,6 @@ class TestForwardMessage: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( ForwardMessage, diff --git a/tests/test_api/test_methods/test_get_chat.py b/tests/test_api/test_methods/test_get_chat.py index 3d9510b9..f5117d11 100644 --- a/tests/test_api/test_methods/test_get_chat.py +++ b/tests/test_api/test_methods/test_get_chat.py @@ -4,9 +4,10 @@ from aiogram.methods import GetChat, Request from aiogram.types import Chat from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetChat: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetChat, ok=True, result=Chat(id=-42, type="channel", title="chat") @@ -17,7 +18,6 @@ class TestGetChat: assert request.method == "getChat" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetChat, ok=True, result=Chat(id=-42, type="channel", title="chat") diff --git a/tests/test_api/test_methods/test_get_chat_administrators.py b/tests/test_api/test_methods/test_get_chat_administrators.py index f6de6db5..e254d34e 100644 --- a/tests/test_api/test_methods/test_get_chat_administrators.py +++ b/tests/test_api/test_methods/test_get_chat_administrators.py @@ -6,9 +6,10 @@ from aiogram.methods import GetChatAdministrators, Request from aiogram.types import ChatMember, ChatMemberOwner, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetChatAdministrators: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetChatAdministrators, @@ -25,7 +26,6 @@ class TestGetChatAdministrators: assert request.method == "getChatAdministrators" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetChatAdministrators, diff --git a/tests/test_api/test_methods/test_get_chat_member.py b/tests/test_api/test_methods/test_get_chat_member.py index 4018433e..35dc98bd 100644 --- a/tests/test_api/test_methods/test_get_chat_member.py +++ b/tests/test_api/test_methods/test_get_chat_member.py @@ -4,9 +4,10 @@ from aiogram.methods import GetChatMember, Request from aiogram.types import ChatMember, ChatMemberOwner, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetChatMember: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetChatMember, @@ -21,7 +22,6 @@ class TestGetChatMember: assert request.method == "getChatMember" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetChatMember, diff --git a/tests/test_api/test_methods/test_get_chat_member_count.py b/tests/test_api/test_methods/test_get_chat_member_count.py index a3ecec72..e0fd1a4a 100644 --- a/tests/test_api/test_methods/test_get_chat_member_count.py +++ b/tests/test_api/test_methods/test_get_chat_member_count.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import GetChatMemberCount, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetChatMembersCount: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetChatMemberCount, ok=True, result=42) @@ -14,7 +15,6 @@ class TestGetChatMembersCount: assert request.method == "getChatMemberCount" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetChatMemberCount, ok=True, result=42) diff --git a/tests/test_api/test_methods/test_get_chat_members_count.py b/tests/test_api/test_methods/test_get_chat_members_count.py index fd88f925..cd44a13a 100644 --- a/tests/test_api/test_methods/test_get_chat_members_count.py +++ b/tests/test_api/test_methods/test_get_chat_members_count.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import GetChatMembersCount, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetChatMembersCount: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetChatMembersCount, ok=True, result=42) @@ -14,7 +15,6 @@ class TestGetChatMembersCount: assert request.method == "getChatMembersCount" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetChatMembersCount, ok=True, result=42) diff --git a/tests/test_api/test_methods/test_get_file.py b/tests/test_api/test_methods/test_get_file.py index f466ef04..38923a6e 100644 --- a/tests/test_api/test_methods/test_get_file.py +++ b/tests/test_api/test_methods/test_get_file.py @@ -4,9 +4,10 @@ from aiogram.methods import GetFile, Request from aiogram.types import File from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetFile: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetFile, ok=True, result=File(file_id="file id", file_unique_id="file id") @@ -17,7 +18,6 @@ class TestGetFile: assert request.method == "getFile" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetFile, ok=True, result=File(file_id="file id", file_unique_id="file id") diff --git a/tests/test_api/test_methods/test_get_game_high_scores.py b/tests/test_api/test_methods/test_get_game_high_scores.py index 397b450d..bc2c7116 100644 --- a/tests/test_api/test_methods/test_get_game_high_scores.py +++ b/tests/test_api/test_methods/test_get_game_high_scores.py @@ -6,9 +6,10 @@ from aiogram.methods import GetGameHighScores, Request from aiogram.types import GameHighScore, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetGameHighScores: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetGameHighScores, @@ -25,7 +26,6 @@ class TestGetGameHighScores: assert request.method == "getGameHighScores" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetGameHighScores, diff --git a/tests/test_api/test_methods/test_get_me.py b/tests/test_api/test_methods/test_get_me.py index 9e6454e3..7da53ea8 100644 --- a/tests/test_api/test_methods/test_get_me.py +++ b/tests/test_api/test_methods/test_get_me.py @@ -4,9 +4,10 @@ from aiogram.methods import GetMe, Request from aiogram.types import User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetMe: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetMe, ok=True, result=User(id=42, is_bot=False, first_name="User") @@ -18,7 +19,6 @@ class TestGetMe: assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetMe, ok=True, result=User(id=42, is_bot=False, first_name="User") @@ -29,7 +29,6 @@ class TestGetMe: assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_me_property(self, bot: MockedBot): prepare_result = bot.add_result_for( GetMe, ok=True, result=User(id=42, is_bot=False, first_name="User") diff --git a/tests/test_api/test_methods/test_get_my_commands.py b/tests/test_api/test_methods/test_get_my_commands.py index 569a13a3..a28ef026 100644 --- a/tests/test_api/test_methods/test_get_my_commands.py +++ b/tests/test_api/test_methods/test_get_my_commands.py @@ -6,9 +6,10 @@ from aiogram.methods import GetMyCommands, Request from aiogram.types import BotCommand from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetMyCommands: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetMyCommands, ok=True, result=None) @@ -17,7 +18,6 @@ class TestGetMyCommands: assert request.method == "getMyCommands" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetMyCommands, ok=True, result=None) diff --git a/tests/test_api/test_methods/test_get_sticker_set.py b/tests/test_api/test_methods/test_get_sticker_set.py index 8b331982..baed1d40 100644 --- a/tests/test_api/test_methods/test_get_sticker_set.py +++ b/tests/test_api/test_methods/test_get_sticker_set.py @@ -4,9 +4,10 @@ from aiogram.methods import GetStickerSet, Request from aiogram.types import Sticker, StickerSet from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetStickerSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetStickerSet, @@ -33,7 +34,6 @@ class TestGetStickerSet: assert request.method == "getStickerSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetStickerSet, diff --git a/tests/test_api/test_methods/test_get_updates.py b/tests/test_api/test_methods/test_get_updates.py index c400b1df..8f8dfa87 100644 --- a/tests/test_api/test_methods/test_get_updates.py +++ b/tests/test_api/test_methods/test_get_updates.py @@ -6,9 +6,10 @@ from aiogram.methods import GetUpdates, Request from aiogram.types import Update from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetUpdates: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetUpdates, ok=True, result=[Update(update_id=42)]) @@ -17,7 +18,6 @@ class TestGetUpdates: assert request.method == "getUpdates" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(GetUpdates, ok=True, result=[Update(update_id=42)]) diff --git a/tests/test_api/test_methods/test_get_user_profile_photos.py b/tests/test_api/test_methods/test_get_user_profile_photos.py index 3e24a115..d6094eb9 100644 --- a/tests/test_api/test_methods/test_get_user_profile_photos.py +++ b/tests/test_api/test_methods/test_get_user_profile_photos.py @@ -4,9 +4,10 @@ from aiogram.methods import GetUserProfilePhotos, Request from aiogram.types import PhotoSize, UserProfilePhotos from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetUserProfilePhotos: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetUserProfilePhotos, @@ -24,7 +25,6 @@ class TestGetUserProfilePhotos: assert request.method == "getUserProfilePhotos" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetUserProfilePhotos, diff --git a/tests/test_api/test_methods/test_get_webhook_info.py b/tests/test_api/test_methods/test_get_webhook_info.py index 6dc28928..f50213cb 100644 --- a/tests/test_api/test_methods/test_get_webhook_info.py +++ b/tests/test_api/test_methods/test_get_webhook_info.py @@ -4,9 +4,10 @@ from aiogram.methods import GetWebhookInfo, Request from aiogram.types import WebhookInfo from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestGetWebhookInfo: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetWebhookInfo, @@ -21,7 +22,6 @@ class TestGetWebhookInfo: assert request.method == "getWebhookInfo" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( GetWebhookInfo, diff --git a/tests/test_api/test_methods/test_kick_chat_member.py b/tests/test_api/test_methods/test_kick_chat_member.py index d60133c5..4aaa651b 100644 --- a/tests/test_api/test_methods/test_kick_chat_member.py +++ b/tests/test_api/test_methods/test_kick_chat_member.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import KickChatMember, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestKickChatMember: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(KickChatMember, ok=True, result=True) @@ -14,7 +15,6 @@ class TestKickChatMember: assert request.method == "kickChatMember" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(KickChatMember, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_leave_chat.py b/tests/test_api/test_methods/test_leave_chat.py index d4788002..77bf739f 100644 --- a/tests/test_api/test_methods/test_leave_chat.py +++ b/tests/test_api/test_methods/test_leave_chat.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import LeaveChat, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestLeaveChat: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(LeaveChat, ok=True, result=True) @@ -14,7 +15,6 @@ class TestLeaveChat: assert request.method == "leaveChat" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(LeaveChat, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_log_out.py b/tests/test_api/test_methods/test_log_out.py index e000540f..b472da50 100644 --- a/tests/test_api/test_methods/test_log_out.py +++ b/tests/test_api/test_methods/test_log_out.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import LogOut, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestLogOut: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(LogOut, ok=True, result=True) @@ -15,7 +16,6 @@ class TestLogOut: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(LogOut, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_pin_chat_message.py b/tests/test_api/test_methods/test_pin_chat_message.py index 190fefcc..59a2bf91 100644 --- a/tests/test_api/test_methods/test_pin_chat_message.py +++ b/tests/test_api/test_methods/test_pin_chat_message.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import PinChatMessage, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestPinChatMessage: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(PinChatMessage, ok=True, result=True) @@ -14,7 +15,6 @@ class TestPinChatMessage: assert request.method == "pinChatMessage" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(PinChatMessage, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_promote_chat_member.py b/tests/test_api/test_methods/test_promote_chat_member.py index 11528160..20c796bd 100644 --- a/tests/test_api/test_methods/test_promote_chat_member.py +++ b/tests/test_api/test_methods/test_promote_chat_member.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import PromoteChatMember, Request from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestPromoteChatMember: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(PromoteChatMember, ok=True, result=True) @@ -14,7 +15,6 @@ class TestPromoteChatMember: assert request.method == "promoteChatMember" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(PromoteChatMember, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_restrict_chat_member.py b/tests/test_api/test_methods/test_restrict_chat_member.py index fe3ce74d..715d0c28 100644 --- a/tests/test_api/test_methods/test_restrict_chat_member.py +++ b/tests/test_api/test_methods/test_restrict_chat_member.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, RestrictChatMember from aiogram.types import ChatPermissions from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestRestrictChatMember: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(RestrictChatMember, ok=True, result=True) @@ -17,7 +18,6 @@ class TestRestrictChatMember: assert request.method == "restrictChatMember" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(RestrictChatMember, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_revoke_chat_invite_link.py b/tests/test_api/test_methods/test_revoke_chat_invite_link.py index a791d53b..fc30ff69 100644 --- a/tests/test_api/test_methods/test_revoke_chat_invite_link.py +++ b/tests/test_api/test_methods/test_revoke_chat_invite_link.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, RevokeChatInviteLink from aiogram.types import ChatInviteLink, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestRevokeChatInviteLink: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( RevokeChatInviteLink, @@ -28,7 +29,6 @@ class TestRevokeChatInviteLink: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( RevokeChatInviteLink, diff --git a/tests/test_api/test_methods/test_send_animation.py b/tests/test_api/test_methods/test_send_animation.py index 95e85cc1..9a123f1a 100644 --- a/tests/test_api/test_methods/test_send_animation.py +++ b/tests/test_api/test_methods/test_send_animation.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendAnimation from aiogram.types import Animation, Chat, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendAnimation: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendAnimation, @@ -28,7 +29,6 @@ class TestSendAnimation: assert request.method == "sendAnimation" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendAnimation, diff --git a/tests/test_api/test_methods/test_send_audio.py b/tests/test_api/test_methods/test_send_audio.py index 2a5e67fd..77ad22f7 100644 --- a/tests/test_api/test_methods/test_send_audio.py +++ b/tests/test_api/test_methods/test_send_audio.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendAudio from aiogram.types import Audio, Chat, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendAudio: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendAudio, @@ -26,7 +27,6 @@ class TestSendAudio: assert request.method == "sendAudio" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendAudio, diff --git a/tests/test_api/test_methods/test_send_chat_action.py b/tests/test_api/test_methods/test_send_chat_action.py index 1478b160..6b6454ae 100644 --- a/tests/test_api/test_methods/test_send_chat_action.py +++ b/tests/test_api/test_methods/test_send_chat_action.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SendChatAction from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendChatAction: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SendChatAction, ok=True, result=True) @@ -14,7 +15,6 @@ class TestSendChatAction: assert request.method == "sendChatAction" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SendChatAction, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_send_contact.py b/tests/test_api/test_methods/test_send_contact.py index 801968ed..adfb697e 100644 --- a/tests/test_api/test_methods/test_send_contact.py +++ b/tests/test_api/test_methods/test_send_contact.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendContact from aiogram.types import Chat, Contact, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendContact: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendContact, @@ -26,7 +27,6 @@ class TestSendContact: assert request.method == "sendContact" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendContact, diff --git a/tests/test_api/test_methods/test_send_dice.py b/tests/test_api/test_methods/test_send_dice.py index 981c242b..80e618ac 100644 --- a/tests/test_api/test_methods/test_send_dice.py +++ b/tests/test_api/test_methods/test_send_dice.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, SendDice from aiogram.types import Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendDice: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SendDice, ok=True, result=None) @@ -15,7 +16,6 @@ class TestSendDice: assert request.method == "sendDice" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SendDice, ok=True, result=None) diff --git a/tests/test_api/test_methods/test_send_document.py b/tests/test_api/test_methods/test_send_document.py index d7d5b32d..106e1737 100644 --- a/tests/test_api/test_methods/test_send_document.py +++ b/tests/test_api/test_methods/test_send_document.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendDocument from aiogram.types import Chat, Document, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendDocument: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendDocument, @@ -26,7 +27,6 @@ class TestSendDocument: assert request.method == "sendDocument" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendDocument, diff --git a/tests/test_api/test_methods/test_send_game.py b/tests/test_api/test_methods/test_send_game.py index 35373f2e..fca6753c 100644 --- a/tests/test_api/test_methods/test_send_game.py +++ b/tests/test_api/test_methods/test_send_game.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendGame from aiogram.types import Chat, Game, Message, PhotoSize from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendGame: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendGame, @@ -32,7 +33,6 @@ class TestSendGame: assert request.method == "sendGame" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendGame, diff --git a/tests/test_api/test_methods/test_send_invoice.py b/tests/test_api/test_methods/test_send_invoice.py index d033e621..6915fcc5 100644 --- a/tests/test_api/test_methods/test_send_invoice.py +++ b/tests/test_api/test_methods/test_send_invoice.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendInvoice from aiogram.types import Chat, Invoice, LabeledPrice, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendInvoice: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendInvoice, @@ -41,7 +42,6 @@ class TestSendInvoice: assert request.method == "sendInvoice" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendInvoice, diff --git a/tests/test_api/test_methods/test_send_location.py b/tests/test_api/test_methods/test_send_location.py index cbadceaa..0f42cf74 100644 --- a/tests/test_api/test_methods/test_send_location.py +++ b/tests/test_api/test_methods/test_send_location.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendLocation from aiogram.types import Chat, Location, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendLocation: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendLocation, @@ -26,7 +27,6 @@ class TestSendLocation: assert request.method == "sendLocation" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendLocation, diff --git a/tests/test_api/test_methods/test_send_media_group.py b/tests/test_api/test_methods/test_send_media_group.py index 884d362c..5b6dc3e1 100644 --- a/tests/test_api/test_methods/test_send_media_group.py +++ b/tests/test_api/test_methods/test_send_media_group.py @@ -15,9 +15,10 @@ from aiogram.types import ( ) from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendMediaGroup: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendMediaGroup, @@ -59,7 +60,6 @@ class TestSendMediaGroup: assert request.method == "sendMediaGroup" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendMediaGroup, diff --git a/tests/test_api/test_methods/test_send_message.py b/tests/test_api/test_methods/test_send_message.py index 26d85613..c2da672e 100644 --- a/tests/test_api/test_methods/test_send_message.py +++ b/tests/test_api/test_methods/test_send_message.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendMessage from aiogram.types import Chat, Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendMessage: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendMessage, @@ -26,7 +27,6 @@ class TestSendMessage: assert request.method == "sendMessage" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendMessage, diff --git a/tests/test_api/test_methods/test_send_photo.py b/tests/test_api/test_methods/test_send_photo.py index e3c2065e..c55565f3 100644 --- a/tests/test_api/test_methods/test_send_photo.py +++ b/tests/test_api/test_methods/test_send_photo.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendPhoto from aiogram.types import Chat, Message, PhotoSize from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendPhoto: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendPhoto, @@ -28,7 +29,6 @@ class TestSendPhoto: assert request.method == "sendPhoto" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendPhoto, diff --git a/tests/test_api/test_methods/test_send_poll.py b/tests/test_api/test_methods/test_send_poll.py index 2f963506..5cbb3c75 100644 --- a/tests/test_api/test_methods/test_send_poll.py +++ b/tests/test_api/test_methods/test_send_poll.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendPoll from aiogram.types import Chat, Message, Poll, PollOption from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendPoll: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendPoll, @@ -41,7 +42,6 @@ class TestSendPoll: assert request.method == "sendPoll" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendPoll, diff --git a/tests/test_api/test_methods/test_send_sticker.py b/tests/test_api/test_methods/test_send_sticker.py index 12c3b28e..d356e8ae 100644 --- a/tests/test_api/test_methods/test_send_sticker.py +++ b/tests/test_api/test_methods/test_send_sticker.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendSticker from aiogram.types import Chat, Message, Sticker from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendSticker: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendSticker, @@ -32,7 +33,6 @@ class TestSendSticker: assert request.method == "sendSticker" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendSticker, diff --git a/tests/test_api/test_methods/test_send_venue.py b/tests/test_api/test_methods/test_send_venue.py index 9246dd90..2f046196 100644 --- a/tests/test_api/test_methods/test_send_venue.py +++ b/tests/test_api/test_methods/test_send_venue.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendVenue from aiogram.types import Chat, Location, Message, Venue from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendVenue: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVenue, @@ -38,7 +39,6 @@ class TestSendVenue: assert request.method == "sendVenue" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVenue, diff --git a/tests/test_api/test_methods/test_send_video.py b/tests/test_api/test_methods/test_send_video.py index 0729dda5..cb1c1222 100644 --- a/tests/test_api/test_methods/test_send_video.py +++ b/tests/test_api/test_methods/test_send_video.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendVideo from aiogram.types import Chat, Message, Video from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendVideo: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVideo, @@ -28,7 +29,6 @@ class TestSendVideo: assert request.method == "sendVideo" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVideo, diff --git a/tests/test_api/test_methods/test_send_video_note.py b/tests/test_api/test_methods/test_send_video_note.py index 8a31209e..63c5bd84 100644 --- a/tests/test_api/test_methods/test_send_video_note.py +++ b/tests/test_api/test_methods/test_send_video_note.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendVideoNote from aiogram.types import BufferedInputFile, Chat, Message, VideoNote from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendVideoNote: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVideoNote, @@ -30,7 +31,6 @@ class TestSendVideoNote: assert request.method == "sendVideoNote" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVideoNote, diff --git a/tests/test_api/test_methods/test_send_voice.py b/tests/test_api/test_methods/test_send_voice.py index bd19921a..ee3894cd 100644 --- a/tests/test_api/test_methods/test_send_voice.py +++ b/tests/test_api/test_methods/test_send_voice.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SendVoice from aiogram.types import Chat, Message, Voice from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSendVoice: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVoice, @@ -26,7 +27,6 @@ class TestSendVoice: assert request.method == "sendVoice" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( SendVoice, diff --git a/tests/test_api/test_methods/test_set_chat_administrator_custom_title.py b/tests/test_api/test_methods/test_set_chat_administrator_custom_title.py index 1395df0d..968c805b 100644 --- a/tests/test_api/test_methods/test_set_chat_administrator_custom_title.py +++ b/tests/test_api/test_methods/test_set_chat_administrator_custom_title.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SetChatAdministratorCustomTitle from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetChatTitle: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatAdministratorCustomTitle, ok=True, result=True) @@ -16,7 +17,6 @@ class TestSetChatTitle: assert request.method == "setChatAdministratorCustomTitle" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatAdministratorCustomTitle, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_chat_description.py b/tests/test_api/test_methods/test_set_chat_description.py index 3679d1c5..36d05cec 100644 --- a/tests/test_api/test_methods/test_set_chat_description.py +++ b/tests/test_api/test_methods/test_set_chat_description.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SetChatDescription from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetChatDescription: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatDescription, ok=True, result=True) @@ -14,7 +15,6 @@ class TestSetChatDescription: assert request.method == "setChatDescription" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatDescription, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_chat_permissions.py b/tests/test_api/test_methods/test_set_chat_permissions.py index 83c90883..a0278b80 100644 --- a/tests/test_api/test_methods/test_set_chat_permissions.py +++ b/tests/test_api/test_methods/test_set_chat_permissions.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, SetChatPermissions from aiogram.types import ChatPermissions from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetChatPermissions: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatPermissions, ok=True, result=True) @@ -17,7 +18,6 @@ class TestSetChatPermissions: assert request.method == "setChatPermissions" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatPermissions, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_chat_photo.py b/tests/test_api/test_methods/test_set_chat_photo.py index f648ccdb..268f668b 100644 --- a/tests/test_api/test_methods/test_set_chat_photo.py +++ b/tests/test_api/test_methods/test_set_chat_photo.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, SetChatPhoto from aiogram.types import BufferedInputFile from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetChatPhoto: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatPhoto, ok=True, result=True) @@ -17,7 +18,6 @@ class TestSetChatPhoto: assert request.method == "setChatPhoto" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatPhoto, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_chat_sticker_set.py b/tests/test_api/test_methods/test_set_chat_sticker_set.py index 50a87ca7..311b2dd8 100644 --- a/tests/test_api/test_methods/test_set_chat_sticker_set.py +++ b/tests/test_api/test_methods/test_set_chat_sticker_set.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SetChatStickerSet from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetChatStickerSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatStickerSet, ok=True, result=True) @@ -14,7 +15,6 @@ class TestSetChatStickerSet: assert request.method == "setChatStickerSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatStickerSet, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_chat_title.py b/tests/test_api/test_methods/test_set_chat_title.py index 40473bc1..01558d84 100644 --- a/tests/test_api/test_methods/test_set_chat_title.py +++ b/tests/test_api/test_methods/test_set_chat_title.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SetChatTitle from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetChatTitle: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatTitle, ok=True, result=True) @@ -14,7 +15,6 @@ class TestSetChatTitle: assert request.method == "setChatTitle" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetChatTitle, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_game_score.py b/tests/test_api/test_methods/test_set_game_score.py index 5b6cbb84..c8177625 100644 --- a/tests/test_api/test_methods/test_set_game_score.py +++ b/tests/test_api/test_methods/test_set_game_score.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, SetGameScore from aiogram.types import Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetGameScore: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetGameScore, ok=True, result=True) @@ -19,7 +20,6 @@ class TestSetGameScore: assert request.method == "setGameScore" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetGameScore, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_my_commands.py b/tests/test_api/test_methods/test_set_my_commands.py index 23b9476e..ec08bbc3 100644 --- a/tests/test_api/test_methods/test_set_my_commands.py +++ b/tests/test_api/test_methods/test_set_my_commands.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, SetMyCommands from aiogram.types import BotCommand from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetMyCommands: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetMyCommands, ok=True, result=None) @@ -17,7 +18,6 @@ class TestSetMyCommands: assert request.method == "setMyCommands" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetMyCommands, ok=True, result=None) diff --git a/tests/test_api/test_methods/test_set_passport_data_errors.py b/tests/test_api/test_methods/test_set_passport_data_errors.py index e5ad0a8d..fba464f7 100644 --- a/tests/test_api/test_methods/test_set_passport_data_errors.py +++ b/tests/test_api/test_methods/test_set_passport_data_errors.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, SetPassportDataErrors from aiogram.types import PassportElementError from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetPassportDataErrors: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetPassportDataErrors, ok=True, result=True) @@ -15,7 +16,6 @@ class TestSetPassportDataErrors: assert request.method == "setPassportDataErrors" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetPassportDataErrors, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_sticker_position_in_set.py b/tests/test_api/test_methods/test_set_sticker_position_in_set.py index b1e72507..4de83feb 100644 --- a/tests/test_api/test_methods/test_set_sticker_position_in_set.py +++ b/tests/test_api/test_methods/test_set_sticker_position_in_set.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SetStickerPositionInSet from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetStickerPositionInSet: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetStickerPositionInSet, ok=True, result=True) @@ -14,7 +15,6 @@ class TestSetStickerPositionInSet: assert request.method == "setStickerPositionInSet" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetStickerPositionInSet, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_set_sticker_set_thumb.py b/tests/test_api/test_methods/test_set_sticker_set_thumb.py index b31526d5..2ded22ee 100644 --- a/tests/test_api/test_methods/test_set_sticker_set_thumb.py +++ b/tests/test_api/test_methods/test_set_sticker_set_thumb.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SetStickerSetThumb from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetStickerSetThumb: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetStickerSetThumb, ok=True, result=None) @@ -15,7 +16,6 @@ class TestSetStickerSetThumb: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetStickerSetThumb, ok=True, result=None) diff --git a/tests/test_api/test_methods/test_set_webhook.py b/tests/test_api/test_methods/test_set_webhook.py index 08ddae7d..48a67c73 100644 --- a/tests/test_api/test_methods/test_set_webhook.py +++ b/tests/test_api/test_methods/test_set_webhook.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, SetWebhook from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestSetWebhook: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetWebhook, ok=True, result=True) @@ -14,7 +15,6 @@ class TestSetWebhook: assert request.method == "setWebhook" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(SetWebhook, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_stop_message_live_location.py b/tests/test_api/test_methods/test_stop_message_live_location.py index 8ea1ed62..4d34795b 100644 --- a/tests/test_api/test_methods/test_stop_message_live_location.py +++ b/tests/test_api/test_methods/test_stop_message_live_location.py @@ -6,9 +6,10 @@ from aiogram.methods import Request, StopMessageLiveLocation from aiogram.types import Message from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestStopMessageLiveLocation: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(StopMessageLiveLocation, ok=True, result=True) @@ -19,7 +20,6 @@ class TestStopMessageLiveLocation: assert request.method == "stopMessageLiveLocation" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(StopMessageLiveLocation, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_stop_poll.py b/tests/test_api/test_methods/test_stop_poll.py index 03ea9b75..e3b83bc1 100644 --- a/tests/test_api/test_methods/test_stop_poll.py +++ b/tests/test_api/test_methods/test_stop_poll.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, StopPoll from aiogram.types import Poll, PollOption from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestStopPoll: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( StopPoll, @@ -29,7 +30,6 @@ class TestStopPoll: assert request.method == "stopPoll" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( StopPoll, diff --git a/tests/test_api/test_methods/test_unban_chat_member.py b/tests/test_api/test_methods/test_unban_chat_member.py index 2d0fffbd..0139b9de 100644 --- a/tests/test_api/test_methods/test_unban_chat_member.py +++ b/tests/test_api/test_methods/test_unban_chat_member.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, UnbanChatMember from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestUnbanChatMember: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnbanChatMember, ok=True, result=True) @@ -14,7 +15,6 @@ class TestUnbanChatMember: assert request.method == "unbanChatMember" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnbanChatMember, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_unpin_all_chat_messages.py b/tests/test_api/test_methods/test_unpin_all_chat_messages.py index 48348dfd..24d90171 100644 --- a/tests/test_api/test_methods/test_unpin_all_chat_messages.py +++ b/tests/test_api/test_methods/test_unpin_all_chat_messages.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, UnpinAllChatMessages from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestUnpinAllChatMessages: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnpinAllChatMessages, ok=True, result=True) @@ -17,7 +18,6 @@ class TestUnpinAllChatMessages: # assert request.data == {} assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnpinAllChatMessages, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_unpin_chat_message.py b/tests/test_api/test_methods/test_unpin_chat_message.py index 1ebe5ccb..7a0bca41 100644 --- a/tests/test_api/test_methods/test_unpin_chat_message.py +++ b/tests/test_api/test_methods/test_unpin_chat_message.py @@ -3,9 +3,10 @@ import pytest from aiogram.methods import Request, UnpinChatMessage from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestUnpinChatMessage: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnpinChatMessage, ok=True, result=True) @@ -14,7 +15,6 @@ class TestUnpinChatMessage: assert request.method == "unpinChatMessage" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for(UnpinChatMessage, ok=True, result=True) diff --git a/tests/test_api/test_methods/test_upload_sticker_file.py b/tests/test_api/test_methods/test_upload_sticker_file.py index 22b85a33..2a7b14ea 100644 --- a/tests/test_api/test_methods/test_upload_sticker_file.py +++ b/tests/test_api/test_methods/test_upload_sticker_file.py @@ -4,9 +4,10 @@ from aiogram.methods import Request, UploadStickerFile from aiogram.types import BufferedInputFile, File from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestUploadStickerFile: - @pytest.mark.asyncio async def test_method(self, bot: MockedBot): prepare_result = bot.add_result_for( UploadStickerFile, ok=True, result=File(file_id="file id", file_unique_id="file id") @@ -19,7 +20,6 @@ class TestUploadStickerFile: assert request.method == "uploadStickerFile" assert response == prepare_result.result - @pytest.mark.asyncio async def test_bot_method(self, bot: MockedBot): prepare_result = bot.add_result_for( UploadStickerFile, ok=True, result=File(file_id="file id", file_unique_id="file id") diff --git a/tests/test_api/test_types/test_input_file.py b/tests/test_api/test_types/test_input_file.py index e59dc461..9317158e 100644 --- a/tests/test_api/test_types/test_input_file.py +++ b/tests/test_api/test_types/test_input_file.py @@ -6,6 +6,8 @@ from aresponses import ResponsesMockServer from aiogram import Bot from aiogram.types import BufferedInputFile, FSInputFile, InputFile, URLInputFile +pytestmark = pytest.mark.asyncio + class TestInputFile: def test_fs_input_file(self): @@ -18,7 +20,6 @@ class TestInputFile: assert file.filename.endswith(".py") assert file.chunk_size > 0 - @pytest.mark.asyncio async def test_fs_input_file_readable(self): file = FSInputFile(__file__, chunk_size=1) @@ -39,7 +40,6 @@ class TestInputFile: assert file.filename == "file.bin" assert isinstance(file.data, bytes) - @pytest.mark.asyncio async def test_buffered_input_file_readable(self): file = BufferedInputFile(b"\f" * 10, filename="file.bin", chunk_size=1) @@ -50,7 +50,6 @@ class TestInputFile: size += chunk_size assert size == 10 - @pytest.mark.asyncio async def test_buffered_input_file_from_file(self): file = BufferedInputFile.from_file(__file__, chunk_size=10) @@ -62,7 +61,6 @@ class TestInputFile: assert isinstance(file.data, bytes) assert file.chunk_size == 10 - @pytest.mark.asyncio async def test_buffered_input_file_from_file_readable(self): file = BufferedInputFile.from_file(__file__, chunk_size=1) @@ -73,7 +71,6 @@ class TestInputFile: size += chunk_size assert size > 0 - @pytest.mark.asyncio async def test_uri_input_file(self, aresponses: ResponsesMockServer): aresponses.add( aresponses.ANY, aresponses.ANY, "get", aresponses.Response(status=200, body=b"\f" * 10) diff --git a/tests/test_dispatcher/test_dispatcher.py b/tests/test_dispatcher/test_dispatcher.py index 9b9dd8fe..1c97cf3b 100644 --- a/tests/test_dispatcher/test_dispatcher.py +++ b/tests/test_dispatcher/test_dispatcher.py @@ -38,6 +38,8 @@ except ImportError: from unittest.mock import AsyncMock as CoroutineMock # type: ignore from unittest.mock import patch +pytestmark = pytest.mark.asyncio + async def simple_message_handler(message: Message): await asyncio.sleep(0.2) @@ -82,7 +84,6 @@ class TestDispatcher: dp._parent_router = Router() assert dp.parent_router is None - @pytest.mark.asyncio @pytest.mark.parametrize("isolate_events", (True, False)) async def test_feed_update(self, isolate_events): dp = Dispatcher(isolate_events=isolate_events) @@ -112,7 +113,6 @@ class TestDispatcher: results_count += 1 assert result == "test" - @pytest.mark.asyncio async def test_feed_raw_update(self): dp = Dispatcher() bot = Bot("42:TEST") @@ -137,7 +137,6 @@ class TestDispatcher: ) assert result == "test" - @pytest.mark.asyncio async def test_listen_updates(self, bot: MockedBot): dispatcher = Dispatcher() bot.add_result_for( @@ -151,7 +150,6 @@ class TestDispatcher: break assert index == 42 - @pytest.mark.asyncio async def test_listen_update_with_error(self, bot: MockedBot): dispatcher = Dispatcher() listen = dispatcher._listen_updates(bot=bot) @@ -166,7 +164,6 @@ class TestDispatcher: assert isinstance(await anext(listen), Update) assert mocked_asleep.awaited - @pytest.mark.asyncio async def test_silent_call_request(self, bot: MockedBot, caplog): dispatcher = Dispatcher() bot.add_result_for(SendMessage, ok=False, error_code=400, description="Kaboom") @@ -175,14 +172,12 @@ class TestDispatcher: assert len(log_records) == 1 assert "Failed to make answer" in log_records[0] - @pytest.mark.asyncio async def test_process_update_empty(self, bot: MockedBot): dispatcher = Dispatcher() result = await dispatcher._process_update(bot=bot, update=Update(update_id=42)) assert not result - @pytest.mark.asyncio async def test_process_update_handled(self, bot: MockedBot): dispatcher = Dispatcher() @@ -192,7 +187,6 @@ class TestDispatcher: assert await dispatcher._process_update(bot=bot, update=Update(update_id=42)) - @pytest.mark.asyncio @pytest.mark.parametrize( "event_type,update,has_chat,has_user", [ @@ -448,14 +442,12 @@ class TestDispatcher: assert result["event_router"] == router assert result["test"] == "PASS" - @pytest.mark.asyncio async def test_listen_unknown_update(self): dp = Dispatcher() with pytest.raises(SkipHandler): await dp._listen_update(Update(update_id=42)) - @pytest.mark.asyncio async def test_listen_unhandled_update(self): dp = Dispatcher() observer = dp.observers["message"] @@ -485,7 +477,6 @@ class TestDispatcher: ) assert response is UNHANDLED - @pytest.mark.asyncio async def test_nested_router_listen_update(self): dp = Dispatcher() router0 = Router() @@ -514,7 +505,6 @@ class TestDispatcher: assert result["event_router"] == router1 assert result["test"] == "PASS" - @pytest.mark.asyncio async def test_nested_router_middleware_resolution(self, bot: MockedBot): counter = Counter() @@ -558,7 +548,6 @@ class TestDispatcher: assert counter["child.middleware"] == 1 assert counter["child.handler"] == 1 - @pytest.mark.asyncio async def test_process_update_call_request(self, bot: MockedBot): dispatcher = Dispatcher() @@ -576,7 +565,6 @@ class TestDispatcher: print(result) mocked_silent_call_request.assert_awaited() - @pytest.mark.asyncio async def test_process_update_exception(self, bot: MockedBot, caplog): dispatcher = Dispatcher() @@ -590,7 +578,6 @@ class TestDispatcher: assert "Cause exception while process update" in log_records[0] @pytest.mark.parametrize("as_task", [True, False]) - @pytest.mark.asyncio async def test_polling(self, bot: MockedBot, as_task: bool): dispatcher = Dispatcher() @@ -609,7 +596,6 @@ class TestDispatcher: else: mocked_process_update.assert_awaited() - @pytest.mark.asyncio async def test_exception_handler_catch_exceptions(self): dp = Dispatcher() router = Router() @@ -651,7 +637,6 @@ class TestDispatcher: assert isinstance(response, CustomException) assert str(response) == "KABOOM" - @pytest.mark.asyncio async def test_start_polling(self, bot: MockedBot): dispatcher = Dispatcher() bot.add_result_for( @@ -685,7 +670,6 @@ class TestDispatcher: dispatcher.run_polling(bot) patched_start_polling.assert_awaited_once() - @pytest.mark.asyncio async def test_feed_webhook_update_fast_process(self, bot: MockedBot): dispatcher = Dispatcher() dispatcher.message.register(simple_message_handler) @@ -695,7 +679,6 @@ class TestDispatcher: assert response["method"] == "sendMessage" assert response["text"] == "ok" - @pytest.mark.asyncio async def test_feed_webhook_update_slow_process(self, bot: MockedBot, recwarn): warnings.simplefilter("always") @@ -711,7 +694,6 @@ class TestDispatcher: await asyncio.sleep(0.5) mocked_silent_call_request.assert_awaited() - @pytest.mark.asyncio async def test_feed_webhook_update_fast_process_error(self, bot: MockedBot, caplog): warnings.simplefilter("always") diff --git a/tests/test_dispatcher/test_event/test_event.py b/tests/test_dispatcher/test_event/test_event.py index be733ebb..735c498b 100644 --- a/tests/test_dispatcher/test_event/test_event.py +++ b/tests/test_dispatcher/test_event/test_event.py @@ -12,6 +12,8 @@ except ImportError: from unittest.mock import AsyncMock as CoroutineMock # type: ignore from unittest.mock import patch +pytestmark = pytest.mark.asyncio + async def my_handler(value: str, index: int = 0) -> Any: return value @@ -39,7 +41,6 @@ class TestEventObserver: assert registered_handler.callback == wrapped_handler assert not registered_handler.filters - @pytest.mark.asyncio async def test_trigger(self): observer = EventObserver() diff --git a/tests/test_dispatcher/test_event/test_handler.py b/tests/test_dispatcher/test_event/test_handler.py index 6bce7d98..7257b47b 100644 --- a/tests/test_dispatcher/test_event/test_handler.py +++ b/tests/test_dispatcher/test_event/test_handler.py @@ -9,6 +9,8 @@ from aiogram.dispatcher.filters.base import BaseFilter from aiogram.dispatcher.handler.base import BaseHandler from aiogram.types import Update +pytestmark = pytest.mark.asyncio + def callback1(foo: int, bar: int, baz: int): return locals() @@ -126,14 +128,12 @@ class TestCallableMixin: obj = CallableMixin(callback) assert obj._prepare_kwargs(kwargs) == result - @pytest.mark.asyncio async def test_sync_call(self): obj = CallableMixin(callback1) result = await obj.call(foo=42, bar="test", baz="fuz", spam=True) assert result == {"foo": 42, "bar": "test", "baz": "fuz"} - @pytest.mark.asyncio async def test_async_call(self): obj = CallableMixin(callback2) @@ -154,14 +154,12 @@ async def simple_handler(*args, **kwargs): class TestHandlerObject: - @pytest.mark.asyncio async def test_check_with_bool_result(self): handler = HandlerObject(simple_handler, [FilterObject(lambda value: True)] * 3) result, data = await handler.check(42, foo=True) assert result assert data == {"foo": True} - @pytest.mark.asyncio async def test_check_with_dict_result(self): handler = HandlerObject( simple_handler, @@ -176,7 +174,6 @@ class TestHandlerObject: assert result assert data == {"foo": True, "test0": "ok", "test1": "ok", "test2": "ok"} - @pytest.mark.asyncio async def test_check_with_combined_result(self): handler = HandlerObject( simple_handler, @@ -186,13 +183,11 @@ class TestHandlerObject: assert result assert data == {"foo": True, "test": 42} - @pytest.mark.asyncio async def test_check_rejected(self): handler = HandlerObject(simple_handler, [FilterObject(lambda value: False)]) result, data = await handler.check(42, foo=True) assert not result - @pytest.mark.asyncio async def test_check_partial_rejected(self): handler = HandlerObject( simple_handler, [FilterObject(lambda value: True), FilterObject(lambda value: False)] @@ -200,7 +195,6 @@ class TestHandlerObject: result, data = await handler.check(42, foo=True) assert not result - @pytest.mark.asyncio async def test_class_based_handler(self): class MyHandler(BaseHandler): event: Update diff --git a/tests/test_dispatcher/test_event/test_telegram.py b/tests/test_dispatcher/test_event/test_telegram.py index 13f70503..39535219 100644 --- a/tests/test_dispatcher/test_event/test_telegram.py +++ b/tests/test_dispatcher/test_event/test_telegram.py @@ -11,6 +11,9 @@ from aiogram.dispatcher.filters.base import BaseFilter from aiogram.dispatcher.router import Router from aiogram.types import Chat, Message, User +pytestmark = pytest.mark.asyncio + + # TODO: Test middlewares in routers tree @@ -138,7 +141,6 @@ class TestTelegramEventObserver: assert len(observer.handlers) == 1 assert observer.handlers[0].callback == my_handler - @pytest.mark.asyncio async def test_trigger(self): router = Router(use_builtin_filters=False) observer = router.message @@ -178,7 +180,6 @@ class TestTelegramEventObserver: assert registered_handler.callback == wrapped_handler assert len(registered_handler.filters) == len(filters) - @pytest.mark.asyncio async def test_trigger_right_context_in_handlers(self): router = Router(use_builtin_filters=False) observer = router.message @@ -250,7 +251,6 @@ class TestTelegramEventObserver: assert len(router.message._handler.filters) == 1 assert router.message._handler.filters[0].callback is my_filter - @pytest.mark.asyncio async def test_global_filter(self): r1 = Router() r2 = Router() @@ -265,7 +265,6 @@ class TestTelegramEventObserver: assert await r1.message.trigger(None) is REJECTED assert await r2.message.trigger(None) is None - @pytest.mark.asyncio async def test_global_filter_in_nested_router(self): r1 = Router() r2 = Router() diff --git a/tests/test_dispatcher/test_filters/test_base.py b/tests/test_dispatcher/test_filters/test_base.py index 27a1f349..3e1e9a3e 100644 --- a/tests/test_dispatcher/test_filters/test_base.py +++ b/tests/test_dispatcher/test_filters/test_base.py @@ -10,6 +10,8 @@ except ImportError: from unittest.mock import AsyncMock as CoroutineMock # type: ignore from unittest.mock import patch +pytestmark = pytest.mark.asyncio + class MyFilter(BaseFilter): foo: str @@ -19,7 +21,6 @@ class MyFilter(BaseFilter): class TestBaseFilter: - @pytest.mark.asyncio async def test_awaitable(self): my_filter = MyFilter(foo="bar") diff --git a/tests/test_dispatcher/test_filters/test_callback_data.py b/tests/test_dispatcher/test_filters/test_callback_data.py index 20d10be3..cd7bc53e 100644 --- a/tests/test_dispatcher/test_filters/test_callback_data.py +++ b/tests/test_dispatcher/test_filters/test_callback_data.py @@ -12,6 +12,8 @@ from aiogram import F from aiogram.dispatcher.filters.callback_data import CallbackData from aiogram.types import CallbackQuery, User +pytestmark = pytest.mark.asyncio + class MyIntEnum(Enum): FOO = auto() @@ -163,7 +165,6 @@ class TestCallbackDataFilter: ["test:test:", None, False], ], ) - @pytest.mark.asyncio async def test_call(self, query, rule, result): callback_query = CallbackQuery( id="1", @@ -175,7 +176,6 @@ class TestCallbackDataFilter: filter_object = MyCallback.filter(rule) assert await filter_object(callback_query) == result - @pytest.mark.asyncio async def test_invalid_call(self): filter_object = MyCallback.filter(F.test) assert not await filter_object(User(id=42, is_bot=False, first_name="test")) diff --git a/tests/test_dispatcher/test_filters/test_command.py b/tests/test_dispatcher/test_filters/test_command.py index a3ea4756..51aabade 100644 --- a/tests/test_dispatcher/test_filters/test_command.py +++ b/tests/test_dispatcher/test_filters/test_command.py @@ -10,6 +10,8 @@ from aiogram.methods import GetMe from aiogram.types import Chat, Message, User from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + class TestCommandFilter: def test_convert_to_list(self): @@ -52,7 +54,6 @@ class TestCommandFilter: ["/start dGVzdA", CommandStart(deep_link=True, deep_link_encoded=True), True], ], ) - @pytest.mark.asyncio async def test_parse_command(self, bot: MockedBot, text: str, result: bool, command: Command): # TODO: test ignore case # TODO: test ignore mention @@ -68,7 +69,6 @@ class TestCommandFilter: response = await command(message, bot) assert bool(response) is result - @pytest.mark.asyncio @pytest.mark.parametrize( "message,result", [ diff --git a/tests/test_dispatcher/test_filters/test_content_types.py b/tests/test_dispatcher/test_filters/test_content_types.py index 63eb207e..8d1706b2 100644 --- a/tests/test_dispatcher/test_filters/test_content_types.py +++ b/tests/test_dispatcher/test_filters/test_content_types.py @@ -7,6 +7,8 @@ from pydantic import ValidationError from aiogram.dispatcher.filters import ContentTypesFilter from aiogram.types import ContentType, Message +pytestmark = pytest.mark.asyncio + @dataclass class MinimalMessage: @@ -14,7 +16,6 @@ class MinimalMessage: class TestContentTypesFilter: - @pytest.mark.asyncio async def test_validator_empty(self): filter_ = ContentTypesFilter() assert not filter_.content_types @@ -53,7 +54,6 @@ class TestContentTypesFilter: [[ContentType.ANY, ContentType.PHOTO, ContentType.DOCUMENT], ContentType.TEXT, True], ], ) - @pytest.mark.asyncio async def test_call(self, values, content_type, result): filter_ = ContentTypesFilter(content_types=values) assert await filter_(cast(Message, MinimalMessage(content_type=content_type))) == result diff --git a/tests/test_dispatcher/test_filters/test_exception.py b/tests/test_dispatcher/test_filters/test_exception.py index 23d850da..ca37b9e9 100644 --- a/tests/test_dispatcher/test_filters/test_exception.py +++ b/tests/test_dispatcher/test_filters/test_exception.py @@ -4,6 +4,8 @@ import pytest from aiogram.dispatcher.filters import ExceptionMessageFilter, ExceptionTypeFilter +pytestmark = pytest.mark.asyncio + class TestExceptionMessageFilter: @pytest.mark.parametrize("value", ["value", re.compile("value")]) @@ -11,7 +13,6 @@ class TestExceptionMessageFilter: obj = ExceptionMessageFilter(pattern=value) assert isinstance(obj.pattern, re.Pattern) - @pytest.mark.asyncio async def test_match(self): obj = ExceptionMessageFilter(pattern="KABOOM") @@ -32,7 +33,6 @@ class MyAnotherException(MyException): class TestExceptionTypeFilter: - @pytest.mark.asyncio @pytest.mark.parametrize( "exception,value", [ diff --git a/tests/test_dispatcher/test_filters/test_text.py b/tests/test_dispatcher/test_filters/test_text.py index 72f95e9d..ac3178de 100644 --- a/tests/test_dispatcher/test_filters/test_text.py +++ b/tests/test_dispatcher/test_filters/test_text.py @@ -9,6 +9,8 @@ from aiogram.dispatcher.filters import BUILTIN_FILTERS from aiogram.dispatcher.filters.text import Text from aiogram.types import CallbackQuery, Chat, InlineQuery, Message, Poll, PollOption, User +pytestmark = pytest.mark.asyncio + class TestText: def test_default_for_observer(self): @@ -240,7 +242,6 @@ class TestText: ["text", True, ["question", "another question"], object(), False], ], ) - @pytest.mark.asyncio async def test_check_text(self, argument, ignore_case, input_value, result, update_type): text = Text(**{argument: input_value}, text_ignore_case=ignore_case) assert await text(obj=update_type) is result diff --git a/tests/test_dispatcher/test_fsm/storage/test_redis.py b/tests/test_dispatcher/test_fsm/storage/test_redis.py index 7b914a33..d3b4d090 100644 --- a/tests/test_dispatcher/test_fsm/storage/test_redis.py +++ b/tests/test_dispatcher/test_fsm/storage/test_redis.py @@ -3,6 +3,8 @@ import pytest from aiogram.dispatcher.fsm.storage.redis import RedisStorage from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + @pytest.mark.redis class TestRedisStorage: @@ -15,7 +17,6 @@ class TestRedisStorage: [lambda bot: "kaboom", "fsm:kaboom:-1:2"], ], ) - @pytest.mark.asyncio async def test_generate_key(self, bot: MockedBot, redis_server, prefix_bot, result): storage = RedisStorage.from_url(redis_server, prefix_bot=prefix_bot) assert storage.generate_key(bot, -1, 2) == result diff --git a/tests/test_dispatcher/test_fsm/storage/test_storages.py b/tests/test_dispatcher/test_fsm/storage/test_storages.py index cae3cb2c..f7bbf082 100644 --- a/tests/test_dispatcher/test_fsm/storage/test_storages.py +++ b/tests/test_dispatcher/test_fsm/storage/test_storages.py @@ -3,19 +3,19 @@ import pytest from aiogram.dispatcher.fsm.storage.base import BaseStorage from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + @pytest.mark.parametrize( "storage", [pytest.lazy_fixture("redis_storage"), pytest.lazy_fixture("memory_storage")], ) class TestStorages: - @pytest.mark.asyncio async def test_lock(self, bot: MockedBot, storage: BaseStorage): # TODO: ?!? async with storage.lock(bot=bot, chat_id=-42, user_id=42): assert True, "You are kidding me?" - @pytest.mark.asyncio async def test_set_state(self, bot: MockedBot, storage: BaseStorage): assert await storage.get_state(bot=bot, chat_id=-42, user_id=42) is None @@ -24,7 +24,6 @@ class TestStorages: await storage.set_state(bot=bot, chat_id=-42, user_id=42, state=None) assert await storage.get_state(bot=bot, chat_id=-42, user_id=42) is None - @pytest.mark.asyncio async def test_set_data(self, bot: MockedBot, storage: BaseStorage): assert await storage.get_data(bot=bot, chat_id=-42, user_id=42) == {} @@ -33,7 +32,6 @@ class TestStorages: await storage.set_data(bot=bot, chat_id=-42, user_id=42, data={}) assert await storage.get_data(bot=bot, chat_id=-42, user_id=42) == {} - @pytest.mark.asyncio async def test_update_data(self, bot: MockedBot, storage: BaseStorage): assert await storage.get_data(bot=bot, chat_id=-42, user_id=42) == {} assert await storage.update_data( diff --git a/tests/test_dispatcher/test_fsm/test_context.py b/tests/test_dispatcher/test_fsm/test_context.py index fb98c423..ad68bd84 100644 --- a/tests/test_dispatcher/test_fsm/test_context.py +++ b/tests/test_dispatcher/test_fsm/test_context.py @@ -4,6 +4,8 @@ from aiogram.dispatcher.fsm.context import FSMContext from aiogram.dispatcher.fsm.storage.memory import MemoryStorage from tests.mocked_bot import MockedBot +pytestmark = pytest.mark.asyncio + @pytest.fixture() def state(bot: MockedBot): @@ -15,7 +17,6 @@ def state(bot: MockedBot): class TestFSMContext: - @pytest.mark.asyncio async def test_address_mapping(self, bot: MockedBot): storage = MemoryStorage() ctx = storage.storage[bot][-42][42] diff --git a/tests/test_dispatcher/test_handler/test_base.py b/tests/test_dispatcher/test_handler/test_base.py index 88982907..09a8882d 100644 --- a/tests/test_dispatcher/test_handler/test_base.py +++ b/tests/test_dispatcher/test_handler/test_base.py @@ -10,6 +10,8 @@ from aiogram.dispatcher.event.handler import HandlerObject from aiogram.dispatcher.handler.base import BaseHandler from aiogram.types import Chat, Message, Update +pytestmark = pytest.mark.asyncio + class MyHandler(BaseHandler): async def handle(self) -> Any: @@ -18,7 +20,6 @@ class MyHandler(BaseHandler): class TestBaseClassBasedHandler: - @pytest.mark.asyncio async def test_base_handler(self): event = Update(update_id=42) handler = MyHandler(event=event, key=42) @@ -28,7 +29,6 @@ class TestBaseClassBasedHandler: assert not hasattr(handler, "filters") assert await handler == 42 - @pytest.mark.asyncio async def test_bot_from_context(self): event = Update(update_id=42) handler = MyHandler(event=event, key=42) @@ -40,7 +40,6 @@ class TestBaseClassBasedHandler: Bot.set_current(bot) assert handler.bot == bot - @pytest.mark.asyncio async def test_bot_from_data(self): event = Update(update_id=42) bot = Bot("42:TEST") @@ -59,7 +58,6 @@ class TestBaseClassBasedHandler: assert handler.event == event assert handler.update == update - @pytest.mark.asyncio async def test_wrapped_handler(self): # wrap the handler on dummy function handler = wraps(MyHandler)(lambda: None) diff --git a/tests/test_dispatcher/test_handler/test_callback_query.py b/tests/test_dispatcher/test_handler/test_callback_query.py index e47534f4..c33d9358 100644 --- a/tests/test_dispatcher/test_handler/test_callback_query.py +++ b/tests/test_dispatcher/test_handler/test_callback_query.py @@ -5,9 +5,10 @@ import pytest from aiogram.dispatcher.handler import CallbackQueryHandler from aiogram.types import CallbackQuery, User +pytestmark = pytest.mark.asyncio + class TestCallbackQueryHandler: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = CallbackQuery( id="chosen", diff --git a/tests/test_dispatcher/test_handler/test_chat_member.py b/tests/test_dispatcher/test_handler/test_chat_member.py index 8b4ffe2c..191b9002 100644 --- a/tests/test_dispatcher/test_handler/test_chat_member.py +++ b/tests/test_dispatcher/test_handler/test_chat_member.py @@ -6,9 +6,10 @@ import pytest from aiogram.dispatcher.handler.chat_member import ChatMemberHandler from aiogram.types import Chat, ChatMemberMember, ChatMemberUpdated, User +pytestmark = pytest.mark.asyncio + class TestChatMemberUpdated: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = ChatMemberUpdated( chat=Chat(id=42, type="private"), diff --git a/tests/test_dispatcher/test_handler/test_chosen_inline_result.py b/tests/test_dispatcher/test_handler/test_chosen_inline_result.py index ecbb363d..5c06aeec 100644 --- a/tests/test_dispatcher/test_handler/test_chosen_inline_result.py +++ b/tests/test_dispatcher/test_handler/test_chosen_inline_result.py @@ -5,9 +5,10 @@ import pytest from aiogram.dispatcher.handler import ChosenInlineResultHandler from aiogram.types import ChosenInlineResult, User +pytestmark = pytest.mark.asyncio + class TestChosenInlineResultHandler: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = ChosenInlineResult( result_id="chosen", diff --git a/tests/test_dispatcher/test_handler/test_error.py b/tests/test_dispatcher/test_handler/test_error.py index a83d96a4..58b4010a 100644 --- a/tests/test_dispatcher/test_handler/test_error.py +++ b/tests/test_dispatcher/test_handler/test_error.py @@ -4,9 +4,10 @@ import pytest from aiogram.dispatcher.handler import ErrorHandler +pytestmark = pytest.mark.asyncio + class TestErrorHandler: - @pytest.mark.asyncio async def test_extensions(self): event = KeyError("kaboom") diff --git a/tests/test_dispatcher/test_handler/test_inline_query.py b/tests/test_dispatcher/test_handler/test_inline_query.py index 99ed913f..99fe65e7 100644 --- a/tests/test_dispatcher/test_handler/test_inline_query.py +++ b/tests/test_dispatcher/test_handler/test_inline_query.py @@ -5,9 +5,10 @@ import pytest from aiogram.dispatcher.handler import InlineQueryHandler from aiogram.types import InlineQuery, User +pytestmark = pytest.mark.asyncio + class TestCallbackQueryHandler: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = InlineQuery( id="query", diff --git a/tests/test_dispatcher/test_handler/test_message.py b/tests/test_dispatcher/test_handler/test_message.py index 5f95d2bd..643a5f51 100644 --- a/tests/test_dispatcher/test_handler/test_message.py +++ b/tests/test_dispatcher/test_handler/test_message.py @@ -7,6 +7,8 @@ from aiogram.dispatcher.filters import CommandObject from aiogram.dispatcher.handler.message import MessageHandler, MessageHandlerCommandMixin from aiogram.types import Chat, Message, User +pytestmark = pytest.mark.asyncio + class MyHandler(MessageHandler): async def handle(self) -> Any: @@ -14,7 +16,6 @@ class MyHandler(MessageHandler): class TestClassBasedMessageHandler: - @pytest.mark.asyncio async def test_message_handler(self): event = Message( message_id=42, diff --git a/tests/test_dispatcher/test_handler/test_poll.py b/tests/test_dispatcher/test_handler/test_poll.py index 6fc23e9e..aa82bf78 100644 --- a/tests/test_dispatcher/test_handler/test_poll.py +++ b/tests/test_dispatcher/test_handler/test_poll.py @@ -5,9 +5,10 @@ import pytest from aiogram.dispatcher.handler import PollHandler from aiogram.types import Poll, PollOption +pytestmark = pytest.mark.asyncio + class TestShippingQueryHandler: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = Poll( id="query", diff --git a/tests/test_dispatcher/test_handler/test_pre_checkout_query.py b/tests/test_dispatcher/test_handler/test_pre_checkout_query.py index 828bd57d..76de7d55 100644 --- a/tests/test_dispatcher/test_handler/test_pre_checkout_query.py +++ b/tests/test_dispatcher/test_handler/test_pre_checkout_query.py @@ -5,9 +5,10 @@ import pytest from aiogram.dispatcher.handler import PreCheckoutQueryHandler from aiogram.types import PreCheckoutQuery, User +pytestmark = pytest.mark.asyncio + class TestPreCheckoutQueryHandler: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = PreCheckoutQuery( id="query", diff --git a/tests/test_dispatcher/test_handler/test_shipping_query.py b/tests/test_dispatcher/test_handler/test_shipping_query.py index 0e938571..831a773a 100644 --- a/tests/test_dispatcher/test_handler/test_shipping_query.py +++ b/tests/test_dispatcher/test_handler/test_shipping_query.py @@ -5,9 +5,10 @@ import pytest from aiogram.dispatcher.handler import ShippingQueryHandler from aiogram.types import ShippingAddress, ShippingQuery, User +pytestmark = pytest.mark.asyncio + class TestShippingQueryHandler: - @pytest.mark.asyncio async def test_attributes_aliases(self): event = ShippingQuery( id="query", diff --git a/tests/test_dispatcher/test_router.py b/tests/test_dispatcher/test_router.py index cee9e1bf..980e5f34 100644 --- a/tests/test_dispatcher/test_router.py +++ b/tests/test_dispatcher/test_router.py @@ -4,6 +4,7 @@ from aiogram.dispatcher.event.bases import UNHANDLED, SkipHandler, skip from aiogram.dispatcher.router import Router from aiogram.utils.warnings import CodeHasNoEffect +pytestmark = pytest.mark.asyncio importable_router = Router() @@ -73,7 +74,6 @@ class TestRouter: assert router.observers["pre_checkout_query"] == router.pre_checkout_query assert router.observers["poll"] == router.poll - @pytest.mark.asyncio async def test_emit_startup(self): router1 = Router() router2 = Router() @@ -95,7 +95,6 @@ class TestRouter: await router1.emit_startup() assert results == [2, 1, 2] - @pytest.mark.asyncio async def test_emit_shutdown(self): router1 = Router() router2 = Router() @@ -123,7 +122,6 @@ class TestRouter: with pytest.raises(SkipHandler, match="KABOOM"): skip("KABOOM") - @pytest.mark.asyncio async def test_global_filter_in_nested_router(self): r1 = Router() r2 = Router() diff --git a/tests/test_utils/test_backoff.py b/tests/test_utils/test_backoff.py index 5c9f6a20..b409e22a 100644 --- a/tests/test_utils/test_backoff.py +++ b/tests/test_utils/test_backoff.py @@ -3,6 +3,7 @@ import pytest from aiogram.utils.backoff import Backoff, BackoffConfig BACKOFF_CONFIG = BackoffConfig(min_delay=0.1, max_delay=1.0, factor=2.0, jitter=0.0) +pytestmark = pytest.mark.asyncio class TestBackoffConfig: @@ -69,7 +70,6 @@ class TestBackoff: backoff.sleep() assert backoff.counter == 1 - @pytest.mark.asyncio async def test_asleep(self): backoff = Backoff(config=BACKOFF_CONFIG) await backoff.asleep() diff --git a/tests/test_utils/test_deep_linking.py b/tests/test_utils/test_deep_linking.py index de44725c..830c8149 100644 --- a/tests/test_utils/test_deep_linking.py +++ b/tests/test_utils/test_deep_linking.py @@ -23,6 +23,8 @@ WRONG_PAYLOADS = [ 1234567890123456789.0, ] +pytestmark = pytest.mark.asyncio + @pytest.fixture(params=PAYLOADS, name="payload") def payload_fixture(request): @@ -54,7 +56,6 @@ def get_bot_user_fixture(monkeypatch): monkeypatch.setattr(MockedBot, "me", get_bot_user_mock) -@pytest.mark.asyncio class TestDeepLinking: async def test_get_start_link(self, bot, payload): link = await create_start_link(bot=bot, payload=payload) From 058d85860714e6d75ba8fa4dfbf989dedc7ff6af Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 3 Aug 2021 23:50:10 +0300 Subject: [PATCH 30/38] Update towncrier config --- CHANGES/.template.rst.jinja2 | 5 ++--- pyproject.toml | 30 +++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CHANGES/.template.rst.jinja2 b/CHANGES/.template.rst.jinja2 index cd927e5a..24c86a9c 100644 --- a/CHANGES/.template.rst.jinja2 +++ b/CHANGES/.template.rst.jinja2 @@ -22,8 +22,7 @@ {% if definitions[category]['showcontent'] %} {% for text, values in sections[section][category].items() %} - {{ text }} -{% for value in values %}{% if value.isdigit() %} `#{{ value }} `_ -{% endif %}{% endfor %} + {{ values|join(', ') }} {% endfor %} {% else %} @@ -42,4 +41,4 @@ No significant changes. {% endif %} -{% endfor %} \ No newline at end of file +{% endfor %} diff --git a/pyproject.toml b/pyproject.toml index d1713690..47fb8bf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,7 +137,35 @@ package = "aiogram" filename = "CHANGES.rst" directory = "CHANGES/" template = "CHANGES/.template.rst.jinja2" -issue_format = "{issue}" +issue_format = "`#{issue} `_" + +[[tool.towncrier.section]] +path = "" + +[[tool.towncrier.type]] +directory = "feature" +name = "Features" +showcontent = true + +[[tool.towncrier.type]] +directory = "bugfix" +name = "Bugfixes" +showcontent = true + +[[tool.towncrier.type]] +directory = "doc" +name = "Improved Documentation" +showcontent = true + +[[tool.towncrier.type]] +directory = "removal" +name = "Deprecations and Removals" +showcontent = true + +[[tool.towncrier.type]] +directory = "misc" +name = "Misc" +showcontent = true [build-system] requires = ["poetry-core>=1.0.0"] From 0ca9f98baf5820c2f6cd3d770d7e6cc61bdf8306 Mon Sep 17 00:00:00 2001 From: evgfilim1 Date: Thu, 5 Aug 2021 22:25:13 +0300 Subject: [PATCH 31/38] Improve .editorconfig (#658) * Improve .editorconfig This is a continuation of https://github.com/aiogram/aiogram/pull/653#pullrequestreview-721944497 * Fix wildcard * Fixes from review comments --- .editorconfig | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/.editorconfig b/.editorconfig index 9a80d2d5..f519610f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,23 +1,18 @@ root = true -[*] +[**] charset = utf-8 end_of_line = lf +indent_size = 4 +indent_style = space insert_final_newline = true trim_trailing_whitespace = true - -[*.py] -indent_style = space -indent_size = 4 max_line_length = 99 -[*.{yml, yaml}] -indent_style = space +[**.{yml,yaml}] indent_size = 2 -[*.{md,txt}] -indent_style = space -indent_size = 4 +[**.{md,txt,rst}] trim_trailing_whitespace = false [Makefile] From 7cb0ac1ee2201469f045d8f97522aabacddc5709 Mon Sep 17 00:00:00 2001 From: darksidecat <58224121+darksidecat@users.noreply.github.com> Date: Tue, 17 Aug 2021 00:07:38 +0300 Subject: [PATCH 32/38] add allow_sending_without_reply param to Message reply aliases (#663) --- CHANGES/663.misc | 1 + aiogram/types/message.py | 51 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 CHANGES/663.misc diff --git a/CHANGES/663.misc b/CHANGES/663.misc new file mode 100644 index 00000000..38bdfb5d --- /dev/null +++ b/CHANGES/663.misc @@ -0,0 +1 @@ +add allow_sending_without_reply param to Message reply aliases diff --git a/aiogram/types/message.py b/aiogram/types/message.py index 8d72d0e5..52acd9f6 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -268,6 +268,7 @@ class Message(TelegramObject): caption: Optional[str] = None, parse_mode: Optional[str] = UNSET, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -283,6 +284,7 @@ class Message(TelegramObject): :param caption: :param parse_mode: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -299,6 +301,7 @@ class Message(TelegramObject): parse_mode=parse_mode, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -356,6 +359,7 @@ class Message(TelegramObject): title: Optional[str] = None, thumb: Optional[Union[InputFile, str]] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -371,6 +375,7 @@ class Message(TelegramObject): :param title: :param thumb: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -387,6 +392,7 @@ class Message(TelegramObject): thumb=thumb, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -441,6 +447,7 @@ class Message(TelegramObject): last_name: Optional[str] = None, vcard: Optional[str] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -453,6 +460,7 @@ class Message(TelegramObject): :param last_name: :param vcard: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -466,6 +474,7 @@ class Message(TelegramObject): vcard=vcard, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -511,6 +520,7 @@ class Message(TelegramObject): caption: Optional[str] = None, parse_mode: Optional[str] = UNSET, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -523,6 +533,7 @@ class Message(TelegramObject): :param caption: :param parse_mode: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -536,6 +547,7 @@ class Message(TelegramObject): parse_mode=parse_mode, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -578,6 +590,7 @@ class Message(TelegramObject): self, game_short_name: str, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[InlineKeyboardMarkup] = None, ) -> SendGame: """ @@ -585,6 +598,7 @@ class Message(TelegramObject): :param game_short_name: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -595,6 +609,7 @@ class Message(TelegramObject): game_short_name=game_short_name, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -644,6 +659,7 @@ class Message(TelegramObject): send_email_to_provider: Optional[bool] = None, is_flexible: Optional[bool] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[InlineKeyboardMarkup] = None, ) -> SendInvoice: """ @@ -669,6 +685,7 @@ class Message(TelegramObject): :param send_email_to_provider: :param is_flexible: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -697,6 +714,7 @@ class Message(TelegramObject): is_flexible=is_flexible, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -784,6 +802,7 @@ class Message(TelegramObject): longitude: float, live_period: Optional[int] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -795,6 +814,7 @@ class Message(TelegramObject): :param longitude: :param live_period: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -807,6 +827,7 @@ class Message(TelegramObject): live_period=live_period, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -846,12 +867,14 @@ class Message(TelegramObject): self, media: List[Union[InputMediaPhoto, InputMediaVideo]], disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, ) -> SendMediaGroup: """ Reply with media group :param media: :param disable_notification: + :param allow_sending_without_reply: :return: """ from ..methods import SendMediaGroup @@ -861,6 +884,7 @@ class Message(TelegramObject): media=media, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, ) def answer_media_group( @@ -890,6 +914,7 @@ class Message(TelegramObject): parse_mode: Optional[str] = UNSET, disable_web_page_preview: Optional[bool] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -901,6 +926,7 @@ class Message(TelegramObject): :param parse_mode: :param disable_web_page_preview: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -913,6 +939,7 @@ class Message(TelegramObject): disable_web_page_preview=disable_web_page_preview, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -954,6 +981,7 @@ class Message(TelegramObject): caption: Optional[str] = None, parse_mode: Optional[str] = UNSET, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -965,6 +993,7 @@ class Message(TelegramObject): :param caption: :param parse_mode: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -977,6 +1006,7 @@ class Message(TelegramObject): parse_mode=parse_mode, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1026,6 +1056,7 @@ class Message(TelegramObject): close_date: Optional[Union[datetime.datetime, datetime.timedelta, int]] = None, is_closed: Optional[bool] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1045,6 +1076,7 @@ class Message(TelegramObject): :param close_date: :param is_closed: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1065,6 +1097,7 @@ class Message(TelegramObject): is_closed=is_closed, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1128,6 +1161,7 @@ class Message(TelegramObject): self, emoji: Optional[str] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1137,6 +1171,7 @@ class Message(TelegramObject): :param emoji: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1147,6 +1182,7 @@ class Message(TelegramObject): emoji=emoji, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1180,6 +1216,7 @@ class Message(TelegramObject): self, sticker: Union[InputFile, str], disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1189,6 +1226,7 @@ class Message(TelegramObject): :param sticker: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1199,6 +1237,7 @@ class Message(TelegramObject): sticker=sticker, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1237,6 +1276,7 @@ class Message(TelegramObject): foursquare_id: Optional[str] = None, foursquare_type: Optional[str] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1251,6 +1291,7 @@ class Message(TelegramObject): :param foursquare_id: :param foursquare_type: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1266,6 +1307,7 @@ class Message(TelegramObject): foursquare_type=foursquare_type, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1321,6 +1363,7 @@ class Message(TelegramObject): parse_mode: Optional[str] = UNSET, supports_streaming: Optional[bool] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1337,6 +1380,7 @@ class Message(TelegramObject): :param parse_mode: :param supports_streaming: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1354,6 +1398,7 @@ class Message(TelegramObject): supports_streaming=supports_streaming, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1411,6 +1456,7 @@ class Message(TelegramObject): length: Optional[int] = None, thumb: Optional[Union[InputFile, str]] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1423,6 +1469,7 @@ class Message(TelegramObject): :param length: :param thumb: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1436,6 +1483,7 @@ class Message(TelegramObject): thumb=thumb, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) @@ -1481,6 +1529,7 @@ class Message(TelegramObject): parse_mode: Optional[str] = UNSET, duration: Optional[int] = None, disable_notification: Optional[bool] = None, + allow_sending_without_reply: Optional[bool] = None, reply_markup: Optional[ Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply] ] = None, @@ -1493,6 +1542,7 @@ class Message(TelegramObject): :param parse_mode: :param duration: :param disable_notification: + :param allow_sending_without_reply: :param reply_markup: :return: """ @@ -1506,6 +1556,7 @@ class Message(TelegramObject): duration=duration, disable_notification=disable_notification, reply_to_message_id=self.message_id, + allow_sending_without_reply=allow_sending_without_reply, reply_markup=reply_markup, ) From 71eb5fc44ebde015cef6618d21daac7ce0d13204 Mon Sep 17 00:00:00 2001 From: darksidecat <58224121+darksidecat@users.noreply.github.com> Date: Tue, 17 Aug 2021 00:08:36 +0300 Subject: [PATCH 33/38] fix #660 prepare parse mode for input_message_content (#661) * fix #660 prepare parse mode for input_message_content of InlineQueryResult * remove redundant get * black lint * add towncrier patch note --- CHANGES/660.bugfix | 1 + aiogram/methods/answer_inline_query.py | 9 +++++++- .../test_methods/test_answer_inline_query.py | 21 ++++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 CHANGES/660.bugfix diff --git a/CHANGES/660.bugfix b/CHANGES/660.bugfix new file mode 100644 index 00000000..3da5568d --- /dev/null +++ b/CHANGES/660.bugfix @@ -0,0 +1 @@ +Prepare parse mode for InputMessageContent in AnswerInlineQuery method diff --git a/aiogram/methods/answer_inline_query.py b/aiogram/methods/answer_inline_query.py index 95c9b66d..78511403 100644 --- a/aiogram/methods/answer_inline_query.py +++ b/aiogram/methods/answer_inline_query.py @@ -37,5 +37,12 @@ class AnswerInlineQuery(TelegramMethod[bool]): def build_request(self, bot: Bot) -> Request: data: Dict[str, Any] = self.dict() - prepare_parse_mode(bot, data["results"]) + + input_message_contents = [] + for result in data["results"]: + input_message_content = result.get("input_message_content", None) + if input_message_content is not None: + input_message_contents.append(input_message_content) + + prepare_parse_mode(bot, data["results"] + input_message_contents) return Request(method="answerInlineQuery", data=data) diff --git a/tests/test_api/test_methods/test_answer_inline_query.py b/tests/test_api/test_methods/test_answer_inline_query.py index 6b15cb8c..98227b57 100644 --- a/tests/test_api/test_methods/test_answer_inline_query.py +++ b/tests/test_api/test_methods/test_answer_inline_query.py @@ -2,7 +2,7 @@ import pytest from aiogram import Bot from aiogram.methods import AnswerInlineQuery, Request -from aiogram.types import InlineQueryResult, InlineQueryResultPhoto +from aiogram.types import InlineQueryResult, InlineQueryResultPhoto, InputTextMessageContent from tests.mocked_bot import MockedBot pytestmark = pytest.mark.asyncio @@ -40,3 +40,22 @@ class TestAnswerInlineQuery: new_bot = Bot(token="42:TEST", parse_mode="HTML") request = query.build_request(new_bot) assert request.data["results"][0]["parse_mode"] == "HTML" + + def test_parse_mode_input_message_content(self, bot: MockedBot): + query = AnswerInlineQuery( + inline_query_id="query id", + results=[ + InlineQueryResultPhoto( + id="result id", + photo_url="photo", + thumb_url="thumb", + input_message_content=InputTextMessageContent(message_text="test"), + ) + ], + ) + request = query.build_request(bot) + assert request.data["results"][0]["input_message_content"]["parse_mode"] is None + + new_bot = Bot(token="42:TEST", parse_mode="HTML") + request = query.build_request(new_bot) + assert request.data["results"][0]["input_message_content"]["parse_mode"] == "HTML" From 04bbc8211cee7b165a877726743a993d68760dd7 Mon Sep 17 00:00:00 2001 From: darksidecat <58224121+darksidecat@users.noreply.github.com> Date: Tue, 17 Aug 2021 00:09:11 +0300 Subject: [PATCH 34/38] add stategroup filter (#659) --- aiogram/dispatcher/fsm/state.py | 11 +++--- tests/test_dispatcher/test_fsm/test_state.py | 36 ++++++++++++++++++-- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/aiogram/dispatcher/fsm/state.py b/aiogram/dispatcher/fsm/state.py index d4ea1974..ced9779a 100644 --- a/aiogram/dispatcher/fsm/state.py +++ b/aiogram/dispatcher/fsm/state.py @@ -111,8 +111,8 @@ class StatesGroupMeta(type): return item in cls.__all_states_names__ if isinstance(item, State): return item in cls.__all_states__ - # if isinstance(item, StatesGroup): - # return item in cls.__all_childs__ + if isinstance(item, StatesGroupMeta): + return item in cls.__all_childs__ return False def __str__(self) -> str: @@ -126,8 +126,11 @@ class StatesGroup(metaclass=StatesGroupMeta): return cls return cls.__parent__.get_root() - # def __call__(cls, event: TelegramObject, raw_state: Optional[str] = None) -> bool: - # return raw_state in cls.__all_states_names__ + def __call__(cls, event: TelegramObject, raw_state: Optional[str] = None) -> bool: + return raw_state in type(cls).__all_states_names__ + + def __str__(self) -> str: + return f"StatesGroup {type(self).__full_group_name__}" default_state = State() diff --git a/tests/test_dispatcher/test_fsm/test_state.py b/tests/test_dispatcher/test_fsm/test_state.py index 07037e86..04c8e448 100644 --- a/tests/test_dispatcher/test_fsm/test_state.py +++ b/tests/test_dispatcher/test_fsm/test_state.py @@ -139,8 +139,7 @@ class TestStatesGroup: assert MyGroup.state1 not in MyGroup.MyNestedGroup assert MyGroup.state1 in MyGroup - # Not working as well - # assert MyGroup.MyNestedGroup in MyGroup + assert MyGroup.MyNestedGroup in MyGroup assert "MyGroup.MyNestedGroup:state1" in MyGroup assert "MyGroup.MyNestedGroup:state1" in MyGroup.MyNestedGroup @@ -150,3 +149,36 @@ class TestStatesGroup: assert 42 not in MyGroup assert MyGroup.MyNestedGroup.get_root() is MyGroup + + def test_empty_filter(self): + class MyGroup(StatesGroup): + pass + + assert str(MyGroup()) == "StatesGroup MyGroup" + + def test_with_state_filter(self): + class MyGroup(StatesGroup): + state1 = State() + state2 = State() + + assert MyGroup()(None, "MyGroup:state1") + assert MyGroup()(None, "MyGroup:state2") + assert not MyGroup()(None, "MyGroup:state3") + + assert str(MyGroup()) == "StatesGroup MyGroup" + + def test_nested_group_filter(self): + class MyGroup(StatesGroup): + state1 = State() + + class MyNestedGroup(StatesGroup): + state1 = State() + + assert MyGroup()(None, "MyGroup:state1") + assert MyGroup()(None, "MyGroup.MyNestedGroup:state1") + assert not MyGroup()(None, "MyGroup:state2") + assert MyGroup.MyNestedGroup()(None, "MyGroup.MyNestedGroup:state1") + assert not MyGroup.MyNestedGroup()(None, "MyGroup:state1") + + assert str(MyGroup()) == "StatesGroup MyGroup" + assert str(MyGroup.MyNestedGroup()) == "StatesGroup MyGroup.MyNestedGroup" From c1f605c6f594857068facaf51ae298e7c893d8ff Mon Sep 17 00:00:00 2001 From: darksidecat <58224121+darksidecat@users.noreply.github.com> Date: Tue, 17 Aug 2021 00:11:47 +0300 Subject: [PATCH 35/38] add aliases for edit/delete reply markup to Message (#662) * add aliases for edit/delete reply markup to Message * add towncrier patch note * add missed towncrier patch note description --- CHANGES/662.feature | 1 + aiogram/types/message.py | 16 ++++++ tests/test_api/test_types/test_message.py | 61 +++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 CHANGES/662.feature diff --git a/CHANGES/662.feature b/CHANGES/662.feature new file mode 100644 index 00000000..3a92b141 --- /dev/null +++ b/CHANGES/662.feature @@ -0,0 +1 @@ +add aliases for edit/delete reply markup to Message diff --git a/aiogram/types/message.py b/aiogram/types/message.py index 52acd9f6..1f2fd996 100644 --- a/aiogram/types/message.py +++ b/aiogram/types/message.py @@ -14,6 +14,7 @@ if TYPE_CHECKING: # pragma: no cover CopyMessage, DeleteMessage, EditMessageCaption, + EditMessageReplyMarkup, EditMessageText, SendAnimation, SendAudio, @@ -1788,6 +1789,21 @@ class Message(TelegramObject): reply_markup=reply_markup, ) + def edit_reply_markup( + self, + reply_markup: Optional[InlineKeyboardMarkup] = None, + ) -> EditMessageReplyMarkup: + from ..methods import EditMessageReplyMarkup + + return EditMessageReplyMarkup( + chat_id=self.chat.id, + message_id=self.message_id, + reply_markup=reply_markup, + ) + + def delete_reply_markup(self) -> EditMessageReplyMarkup: + return self.edit_reply_markup(reply_markup=None) + def edit_caption( self, caption: str, diff --git a/tests/test_api/test_types/test_message.py b/tests/test_api/test_types/test_message.py index bea20f08..cb450731 100644 --- a/tests/test_api/test_types/test_message.py +++ b/tests/test_api/test_types/test_message.py @@ -7,6 +7,7 @@ from aiogram.methods import ( CopyMessage, DeleteMessage, EditMessageCaption, + EditMessageReplyMarkup, EditMessageText, SendAnimation, SendAudio, @@ -36,6 +37,8 @@ from aiogram.types import ( Document, EncryptedCredentials, Game, + InlineKeyboardButton, + InlineKeyboardMarkup, Invoice, Location, MessageAutoDeleteTimerChanged, @@ -561,6 +564,64 @@ class TestMessage: assert isinstance(method, EditMessageText) assert method.chat_id == message.chat.id + def test_edit_reply_markup(self): + reply_markup = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="test", + callback_data="test", + ), + ], + ] + ) + reply_markup_new = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="test2", + callback_data="test2", + ), + ], + ] + ) + + message = Message( + message_id=42, + chat=Chat(id=42, type="private"), + date=datetime.datetime.now(), + reply_markup=reply_markup, + ) + method = message.edit_reply_markup( + reply_markup=reply_markup_new, + ) + assert isinstance(method, EditMessageReplyMarkup) + assert method.reply_markup == reply_markup_new + assert method.chat_id == message.chat.id + + def test_delete_reply_markup(self): + reply_markup = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="test", + callback_data="test", + ), + ], + ] + ) + + message = Message( + message_id=42, + chat=Chat(id=42, type="private"), + date=datetime.datetime.now(), + reply_markup=reply_markup, + ) + method = message.delete_reply_markup() + assert isinstance(method, EditMessageReplyMarkup) + assert method.reply_markup is None + assert method.chat_id == message.chat.id + def test_edit_caption(self): message = Message( message_id=42, chat=Chat(id=42, type="private"), date=datetime.datetime.now() From 9238533e93404080db3b3d1dd019f51f5023317d Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 17 Aug 2021 00:43:27 +0300 Subject: [PATCH 36/38] Feature/rework middlewares chain (#664) * Reworked middlewares chain * Added description for router name * Added patch-notes * Fixed type hints --- CHANGES/664.feature | 1 + aiogram/dispatcher/dispatcher.py | 3 +- aiogram/dispatcher/event/telegram.py | 22 ++++++----- aiogram/dispatcher/router.py | 26 ++++++++++++- poetry.lock | 47 ++++++++++++++++++++---- pyproject.toml | 1 + tests/test_dispatcher/test_dispatcher.py | 23 +++++++----- 7 files changed, 94 insertions(+), 29 deletions(-) create mode 100644 CHANGES/664.feature diff --git a/CHANGES/664.feature b/CHANGES/664.feature new file mode 100644 index 00000000..2db72144 --- /dev/null +++ b/CHANGES/664.feature @@ -0,0 +1 @@ +Reworked outer middleware chain. Prevent to call many times the outer middleware for each nested router diff --git a/aiogram/dispatcher/dispatcher.py b/aiogram/dispatcher/dispatcher.py index ee61d1d4..2f4bb1ba 100644 --- a/aiogram/dispatcher/dispatcher.py +++ b/aiogram/dispatcher/dispatcher.py @@ -98,7 +98,8 @@ class Dispatcher(Router): token = Bot.set_current(bot) try: - response = await self.update.trigger(update, bot=bot, **kwargs) + kwargs.update(bot=bot) + response = await self.update.wrap_outer_middleware(self.update.trigger, update, kwargs) handled = response is not UNHANDLED return response finally: diff --git a/aiogram/dispatcher/event/telegram.py b/aiogram/dispatcher/event/telegram.py index ad03c06c..424ffeb3 100644 --- a/aiogram/dispatcher/event/telegram.py +++ b/aiogram/dispatcher/event/telegram.py @@ -84,13 +84,13 @@ class TelegramEventObserver: :param *: """ middlewares = [] - - for router in reversed(list(self.router.chain_head)): - observer = router.observers[self.event_name] - if outer: - middlewares.extend(observer.outer_middlewares) - else: + if outer: + middlewares.extend(self.outer_middlewares) + else: + for router in reversed(list(self.router.chain_head)): + observer = router.observers[self.event_name] middlewares.extend(observer.middlewares) + return middlewares def resolve_filters(self, full_config: Dict[str, Any]) -> List[BaseFilter]: @@ -148,15 +148,17 @@ class TelegramEventObserver: middleware = functools.partial(m, middleware) return middleware + def wrap_outer_middleware( + self, callback: Any, event: TelegramObject, data: Dict[str, Any] + ) -> Any: + wrapped_outer = self._wrap_middleware(self._resolve_middlewares(outer=True), callback) + return wrapped_outer(event, data) + async def trigger(self, event: TelegramObject, **kwargs: Any) -> Any: """ Propagate event to handlers and stops propagation on first match. Handler will be called when all its filters is pass. """ - wrapped_outer = self._wrap_middleware(self._resolve_middlewares(outer=True), self._trigger) - return await wrapped_outer(event, kwargs) - - async def _trigger(self, event: TelegramObject, **kwargs: Any) -> Any: # Check globally defined filters before any other handler will be checked result, data = await self._handler.check(event, **kwargs) if not result: diff --git a/aiogram/dispatcher/router.py b/aiogram/dispatcher/router.py index bc66d0de..b776bcdf 100644 --- a/aiogram/dispatcher/router.py +++ b/aiogram/dispatcher/router.py @@ -21,16 +21,17 @@ class Router: - By observer method - :obj:`router..register(handler, )` - By decorator - :obj:`@router.()` - """ - def __init__(self, use_builtin_filters: bool = True) -> None: + def __init__(self, use_builtin_filters: bool = True, 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 self.sub_routers: List[Router] = [] @@ -84,9 +85,30 @@ class Router: 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}" + + def __repr__(self) -> str: + return f"<{self}>" + async def propagate_event(self, update_type: str, event: TelegramObject, **kwargs: Any) -> Any: kwargs.update(event_router=self) observer = self.observers[update_type] + + async def _wrapped(telegram_event: TelegramObject, **data: Any) -> Any: + return await self._propagate_event( + observer=observer, update_type=update_type, event=telegram_event, **data + ) + + return await observer.wrap_outer_middleware(_wrapped, event=event, data=kwargs) + + async def _propagate_event( + self, + observer: TelegramEventObserver, + update_type: str, + event: TelegramObject, + **kwargs: Any, + ) -> Any: response = await observer.trigger(event, **kwargs) if response is REJECTED: return UNHANDLED diff --git a/poetry.lock b/poetry.lock index 9b60c280..7f29ff0d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -256,6 +256,18 @@ category = "dev" optional = false python-versions = ">=3.5" +[[package]] +name = "diagrams" +version = "0.20.0" +description = "Diagram as Code" +category = "dev" +optional = false +python-versions = ">=3.6,<4.0" + +[package.dependencies] +graphviz = ">=0.13.2,<0.17.0" +jinja2 = ">=2.10,<3.0" + [[package]] name = "distlib" version = "0.3.2" @@ -323,6 +335,19 @@ sphinx = ">=3.0,<5.0" doc = ["myst-parser", "sphinx-copybutton", "sphinx-inline-tabs", "docutils (!=0.17)"] test = ["pytest", "pytest-cov", "pytest-xdist"] +[[package]] +name = "graphviz" +version = "0.16" +description = "Simple Python interface for Graphviz" +category = "dev" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*" + +[package.extras] +dev = ["tox (>=3)", "flake8", "pep8-naming", "wheel", "twine"] +docs = ["sphinx (>=1.8)", "sphinx-rtd-theme"] +test = ["mock (>=3)", "pytest (>=4)", "pytest-mock (>=2)", "pytest-cov"] + [[package]] name = "identify" version = "2.2.10" @@ -454,17 +479,17 @@ testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<6.0.0)"] [[package]] name = "jinja2" -version = "3.0.1" +version = "2.11.3" description = "A very fast and expressive template engine." category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.dependencies] -MarkupSafe = ">=2.0" +MarkupSafe = ">=0.23" [package.extras] -i18n = ["Babel (>=2.7)"] +i18n = ["Babel (>=0.8)"] [[package]] name = "livereload" @@ -1241,7 +1266,7 @@ redis = ["aioredis"] [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "f6ac17a44b1eec95b101daab369097785a093d9263d0c6cf6c9ef8d363d8962d" +content-hash = "e8bc158e14347b3766672505f38ad9d76b1cbf6f9557565e4e56664b0f663717" [metadata.files] aiofiles = [ @@ -1424,6 +1449,10 @@ decorator = [ {file = "decorator-5.0.9-py3-none-any.whl", hash = "sha256:6e5c199c16f7a9f0e3a61a4a54b3d27e7dad0dbdde92b944426cb20914376323"}, {file = "decorator-5.0.9.tar.gz", hash = "sha256:72ecfba4320a893c53f9706bebb2d55c270c1e51a28789361aa93e4a21319ed5"}, ] +diagrams = [ + {file = "diagrams-0.20.0-py3-none-any.whl", hash = "sha256:395391663b4d3f2d3e3614797402ca99494e00baf3926f5c9e72856d34cafedd"}, + {file = "diagrams-0.20.0.tar.gz", hash = "sha256:a50743ed9274e194e7898820f69aa12868ae217003580ef9e7d0285132c9674a"}, +] distlib = [ {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, @@ -1448,6 +1477,10 @@ furo = [ {file = "furo-2021.6.18b36-py3-none-any.whl", hash = "sha256:a4c00634afeb5896a34d141a5dffb62f20c5eca7831b78269823a8cd8b09a5e4"}, {file = "furo-2021.6.18b36.tar.gz", hash = "sha256:46a30bc597a9067088d39d730e7d9bf6c1a1d71967e4af062f796769f66b3bdb"}, ] +graphviz = [ + {file = "graphviz-0.16-py2.py3-none-any.whl", hash = "sha256:3cad5517c961090dfc679df6402a57de62d97703e2880a1a46147bb0dc1639eb"}, + {file = "graphviz-0.16.zip", hash = "sha256:d2d25af1c199cad567ce4806f0449cb74eb30cf451fd7597251e1da099ac6e57"}, +] identify = [ {file = "identify-2.2.10-py2.py3-none-any.whl", hash = "sha256:18d0c531ee3dbc112fa6181f34faa179de3f57ea57ae2899754f16a7e0ff6421"}, {file = "identify-2.2.10.tar.gz", hash = "sha256:5b41f71471bc738e7b586308c3fca172f78940195cb3bf6734c1e66fdac49306"}, @@ -1489,8 +1522,8 @@ jedi = [ {file = "jedi-0.18.0.tar.gz", hash = "sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707"}, ] jinja2 = [ - {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, - {file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"}, + {file = "Jinja2-2.11.3-py2.py3-none-any.whl", hash = "sha256:03e47ad063331dd6a3f04a43eddca8a966a26ba0c5b7207a9a9e4e08f1b29419"}, + {file = "Jinja2-2.11.3.tar.gz", hash = "sha256:a6d58433de0ae800347cab1fa3043cebbabe8baa9d29e668f1c768cb87a333c6"}, ] livereload = [ {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, diff --git a/pyproject.toml b/pyproject.toml index 47fb8bf3..6b659613 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,7 @@ furo = "^2021.6.18-beta.36" sphinx-prompt = "^1.3.0" Sphinx-Substitution-Extensions = "^2020.9.30" towncrier = "^21.3.0" +diagrams = "^0.20.0" [tool.poetry.extras] diff --git a/tests/test_dispatcher/test_dispatcher.py b/tests/test_dispatcher/test_dispatcher.py index 1c97cf3b..520b190c 100644 --- a/tests/test_dispatcher/test_dispatcher.py +++ b/tests/test_dispatcher/test_dispatcher.py @@ -422,7 +422,12 @@ class TestDispatcher: ], ) async def test_listen_update( - self, event_type: str, update: Update, has_chat: bool, has_user: bool + self, + event_type: str, + update: Update, + has_chat: bool, + has_user: bool, + bot: MockedBot, ): router = Dispatcher() observer = router.observers[event_type] @@ -436,7 +441,7 @@ class TestDispatcher: assert User.get_current(False) return kwargs - result = await router.update.trigger(update, test="PASS", bot=None) + result = await router.feed_update(bot, update, test="PASS") assert isinstance(result, dict) assert result["event_update"] == update assert result["event_router"] == router @@ -477,7 +482,7 @@ class TestDispatcher: ) assert response is UNHANDLED - async def test_nested_router_listen_update(self): + async def test_nested_router_listen_update(self, bot: MockedBot): dp = Dispatcher() router0 = Router() router1 = Router() @@ -499,7 +504,7 @@ class TestDispatcher: from_user=User(id=42, is_bot=False, first_name="Test"), ), ) - result = await dp._listen_update(update, test="PASS") + result = await dp.feed_update(bot, update, test="PASS") assert isinstance(result, dict) assert result["event_update"] == update assert result["event_router"] == router1 @@ -542,7 +547,7 @@ class TestDispatcher: baz=..., ) - assert counter["root.outer_middleware"] == 2 + assert counter["root.outer_middleware"] == 1 assert counter["root.middleware"] == 1 assert counter["child.outer_middleware"] == 1 assert counter["child.middleware"] == 1 @@ -596,7 +601,7 @@ class TestDispatcher: else: mocked_process_update.assert_awaited() - async def test_exception_handler_catch_exceptions(self): + async def test_exception_handler_catch_exceptions(self, bot: MockedBot): dp = Dispatcher() router = Router() dp.include_router(router) @@ -619,20 +624,20 @@ class TestDispatcher: ), ) with pytest.raises(CustomException, match="KABOOM"): - await dp.update.trigger(update, bot=None) + await dp.feed_update(bot, update) @router.errors() async def error_handler(event: Update, exception: Exception): return "KABOOM" - response = await dp.update.trigger(update, bot=None) + response = await dp.feed_update(bot, update) assert response == "KABOOM" @dp.errors() async def root_error_handler(event: Update, exception: Exception): return exception - response = await dp.update.trigger(update, bot=None) + response = await dp.feed_update(bot, update) assert isinstance(response, CustomException) assert str(response) == "KABOOM" From 2aa8c38691440509056d4c50a556d818eec7b731 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 17 Aug 2021 01:14:27 +0300 Subject: [PATCH 37/38] Bump version --- CHANGES.rst | 37 +++++++++++++++++++++++++++++++++++++ CHANGES/602.doc | 1 - CHANGES/650.misc | 1 - CHANGES/651.misc | 1 - CHANGES/660.bugfix | 1 - CHANGES/662.feature | 1 - CHANGES/663.misc | 1 - CHANGES/664.feature | 1 - aiogram/__init__.py | 2 +- pyproject.toml | 2 +- 10 files changed, 39 insertions(+), 9 deletions(-) delete mode 100644 CHANGES/602.doc delete mode 100644 CHANGES/650.misc delete mode 100644 CHANGES/651.misc delete mode 100644 CHANGES/660.bugfix delete mode 100644 CHANGES/662.feature delete mode 100644 CHANGES/663.misc delete mode 100644 CHANGES/664.feature diff --git a/CHANGES.rst b/CHANGES.rst index 62d17a3d..5d0944dd 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,3 +14,40 @@ Changelog .. towncrier release notes start +3.0.0a13 (2021-08-17) +====================== + +Features +-------- + +- add aliases for edit/delete reply markup to Message + `#662 `_ +- Reworked outer middleware chain. Prevent to call many times the outer middleware for each nested router + `#664 `_ + + +Bugfixes +-------- + +- Prepare parse mode for InputMessageContent in AnswerInlineQuery method + `#660 `_ + + +Improved Documentation +---------------------- + +- Added integration with :code:`towncrier` + `#602 `_ + + +Misc +---- + +- Added `.editorconfig` + `#650 `_ +- Redis storage speedup globals + `#651 `_ +- add allow_sending_without_reply param to Message reply aliases + `#663 `_ + + diff --git a/CHANGES/602.doc b/CHANGES/602.doc deleted file mode 100644 index 1c23d1d9..00000000 --- a/CHANGES/602.doc +++ /dev/null @@ -1 +0,0 @@ -Added integration with :code:`towncrier` \ No newline at end of file diff --git a/CHANGES/650.misc b/CHANGES/650.misc deleted file mode 100644 index 513270ef..00000000 --- a/CHANGES/650.misc +++ /dev/null @@ -1 +0,0 @@ -Added `.editorconfig` diff --git a/CHANGES/651.misc b/CHANGES/651.misc deleted file mode 100644 index 4efdd5ea..00000000 --- a/CHANGES/651.misc +++ /dev/null @@ -1 +0,0 @@ -Redis storage speedup globals diff --git a/CHANGES/660.bugfix b/CHANGES/660.bugfix deleted file mode 100644 index 3da5568d..00000000 --- a/CHANGES/660.bugfix +++ /dev/null @@ -1 +0,0 @@ -Prepare parse mode for InputMessageContent in AnswerInlineQuery method diff --git a/CHANGES/662.feature b/CHANGES/662.feature deleted file mode 100644 index 3a92b141..00000000 --- a/CHANGES/662.feature +++ /dev/null @@ -1 +0,0 @@ -add aliases for edit/delete reply markup to Message diff --git a/CHANGES/663.misc b/CHANGES/663.misc deleted file mode 100644 index 38bdfb5d..00000000 --- a/CHANGES/663.misc +++ /dev/null @@ -1 +0,0 @@ -add allow_sending_without_reply param to Message reply aliases diff --git a/CHANGES/664.feature b/CHANGES/664.feature deleted file mode 100644 index 2db72144..00000000 --- a/CHANGES/664.feature +++ /dev/null @@ -1 +0,0 @@ -Reworked outer middleware chain. Prevent to call many times the outer middleware for each nested router diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 2c2c9ff5..502480c2 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -37,5 +37,5 @@ __all__ = ( "md", ) -__version__ = "3.0.0a12" +__version__ = "3.0.0a13" __api_version__ = "5.3" diff --git a/pyproject.toml b/pyproject.toml index 6b659613..ca114351 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aiogram" -version = "3.0.0-alpha.12" +version = "3.0.0-alpha.13" description = "Modern and fully asynchronous framework for Telegram Bot API" authors = ["Alex Root Junior "] license = "MIT" From 5851e32266f6232538f6212284363b7e5c83378d Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Tue, 17 Aug 2021 03:07:38 +0300 Subject: [PATCH 38/38] Bump version // 2 --- CHANGES.rst | 2 +- aiogram/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 5d0944dd..9028bbb8 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -14,7 +14,7 @@ Changelog .. towncrier release notes start -3.0.0a13 (2021-08-17) +3.0.0a14 (2021-08-17) ====================== Features diff --git a/aiogram/__init__.py b/aiogram/__init__.py index 502480c2..737e8185 100644 --- a/aiogram/__init__.py +++ b/aiogram/__init__.py @@ -37,5 +37,5 @@ __all__ = ( "md", ) -__version__ = "3.0.0a13" +__version__ = "3.0.0a14" __api_version__ = "5.3" diff --git a/pyproject.toml b/pyproject.toml index ca114351..8ddeeaaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "aiogram" -version = "3.0.0-alpha.13" +version = "3.0.0-alpha.14" description = "Modern and fully asynchronous framework for Telegram Bot API" authors = ["Alex Root Junior "] license = "MIT"