diff --git a/.travis.yml b/.travis.yml index 69bedeb8..7df195b2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,11 +7,12 @@ cache: pip python: - "3.6" - "3.7" + - "3.8" install: - pip install poetry - - poetry config settings.virtualenvs.create false - - poetry install + - poetry config virtualenvs.create false + - poetry install --extras tests script: - - bash scripts/test.sh + - bash scripts/test \ No newline at end of file diff --git a/README.md b/README.md index 515fbb5d..ef7f60db 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@
- A little python library for building bots for Express + A little python framework for building bots for eXpress messenger.
@@ -22,7 +22,8 @@ # Introduction -`pybotx` is a framework for building bots for Express providing a mechanism for simple integration with your favourite web frameworks. +`pybotx` is a framework for building bots for eXpress providing a mechanism for simple +integration with your favourite web frameworks. Main features: @@ -30,14 +31,15 @@ Main features: * Asynchronous API with synchronous as a fallback option. * 100% test coverage. * 100% type annotated codebase. + + +**NOTE**: *This library is under active development and its API may be unstable. Please lock the version you are using at the minor update level. For example, like this in `poetry`.* -**Note**: *This library is under active development and its API may be unstable. Please lock the version you are using at the minor update level. For example, like this in `poetry`.* -``` +```toml [tool.poetry.dependencies] -... -botx = "^0.12.0" -... +botx = "^0.13.0" ``` + --- ## Requirements @@ -49,13 +51,20 @@ Python 3.6+ * pydantic for the data parts. * httpx for making HTTP calls to BotX API. * loguru for beautiful and powerful logs. +* **Optional**. Starlette for tests. ## Installation ```bash $ pip install botx ``` -You will also need a web framework to create bots as the current BotX API only works with webhooks. +Or if you are going to write tests: + +```bash +$ pip install botx[tests] +``` + +You will also need a web framework to create bots as the current BotX API only works with webhooks. This documentation will use FastAPI for the examples bellow. ```bash $ pip install fastapi uvicorn @@ -66,38 +75,30 @@ $ pip install fastapi uvicorn Let's create a simple echo bot. * Create a file `main.py` with following content: -```Python3 -from botx import Bot, CTS, Message, Status +```python3 +from botx import Bot, ExpressServer, IncomingMessage, Message, Status from fastapi import FastAPI -from starlette.middleware.cors import CORSMiddleware from starlette.status import HTTP_202_ACCEPTED -bot = Bot() -bot.add_cts(CTS(host="cts.example.com", secret_key="secret")) +bot = Bot(known_hosts=[ExpressServer(host="cts.example.com", secret_key="secret")]) -@bot.default_handler -async def echo_handler(message: Message, bot: Bot): +@bot.default(include_in_status=False) +async def echo_handler(message: Message) -> None: await bot.answer_message(message.body, message) app = FastAPI() -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) +app.add_event_handler("shutdown", bot.shutdown) @app.get("/status", response_model=Status) -async def bot_status(): +async def bot_status() -> Status: return await bot.status() @app.post("/command", status_code=HTTP_202_ACCEPTED) -async def bot_command(message: Message): +async def bot_command(message: IncomingMessage) -> None: await bot.execute_command(message.dict()) ``` diff --git a/botx/__init__.py b/botx/__init__.py index f198f6fb..9c870495 100644 --- a/botx/__init__.py +++ b/botx/__init__.py @@ -1,89 +1,71 @@ -from loguru import logger -from pydantic import ValidationError +"""A little python framework for building bots for Express.""" -from .bots import AsyncBot as Bot -from .collectors import HandlersCollector -from .dependencies import Depends -from .exceptions import ( - BotXAPIException, - BotXDependencyFailure, - BotXException, - BotXValidationError, +from botx.bots import Bot +from botx.clients import AsyncClient, Client +from botx.collecting import Collector +from botx.exceptions import BotXAPIError, DependencyFailure, ServerUnknownError +from botx.models.buttons import BubbleElement, KeyboardElement +from botx.models.credentials import ExpressServer, ServerCredentials +from botx.models.enums import ( + ChatTypes, + CommandTypes, + Recipients, + Statuses, + SystemEvents, + UserKinds, ) -from .models import ( - CTS, - BotCredentials, - BubbleElement, - ChatCreatedData, - ChatTypeEnum, - CommandCallback, - CommandHandler, - CommandTypeEnum, - CommandUIElement, - CTSCredentials, - File, - KeyboardElement, - Mention, - MentionTypeEnum, - MentionUser, - MenuCommand, - Message, - MessageCommand, +from botx.models.errors import BotDisabledErrorData, BotDisabledResponse +from botx.models.events import ChatCreatedEvent +from botx.models.files import File +from botx.models.mentions import ChatMention, Mention, MentionTypes, UserMention +from botx.models.menu import Status +from botx.models.messages import Message, SendingMessage +from botx.models.receiving import IncomingMessage +from botx.models.sending import ( MessageMarkup, MessageOptions, - MessageUser, - NotificationOpts, - ReplyMessage, - ResponseRecipientsEnum, + MessagePayload, + NotificationOptions, SendingCredentials, - Status, - StatusEnum, - StatusResult, - SystemEventsEnum, - UserInChatCreated, - UserKindEnum, + UpdatePayload, ) - -logger.disable("botx") +from botx.params import Depends __all__ = ( - "BotXDependencyFailure", - "Depends", "Bot", - "HandlersCollector", - "BotXException", - "ValidationError", - "CTS", - "SystemEventsEnum", - "BotCredentials", - "ChatTypeEnum", - "CommandUIElement", - "CTSCredentials", + "AsyncClient", + "Client", + "Collector", + "BotXAPIError", + "ServerUnknownError", + "DependencyFailure", + "Depends", + "BubbleElement", + "KeyboardElement", + "ExpressServer", + "ServerCredentials", + "Statuses", + "Recipients", + "UserKinds", + "ChatTypes", + "CommandTypes", + "SystemEvents", + "BotDisabledErrorData", + "BotDisabledResponse", + "ChatCreatedEvent", "File", - "CommandTypeEnum", "Mention", - "MentionTypeEnum", - "MentionUser", - "MenuCommand", - "Message", - "MessageCommand", - "MessageUser", + "ChatMention", + "UserMention", + "MentionTypes", "Status", - "StatusEnum", - "StatusResult", - "CommandHandler", - "ReplyMessage", - "BubbleElement", - "KeyboardElement", - "NotificationOpts", - "ResponseRecipientsEnum", - "CommandCallback", - "ChatCreatedData", - "UserInChatCreated", + "Message", + "SendingMessage", + "IncomingMessage", "MessageMarkup", "MessageOptions", + "MessagePayload", + "NotificationOptions", + "UpdatePayload", "SendingCredentials", - "BotXAPIException", - "BotXValidationError", - "UserKindEnum", ) diff --git a/botx/api_helpers.py b/botx/api_helpers.py new file mode 100644 index 00000000..d11191cc --- /dev/null +++ b/botx/api_helpers.py @@ -0,0 +1,238 @@ +"""Definition of useful functions for BotX API related stuff.""" + +from typing import Any, cast +from uuid import UUID + +from httpx import StatusCode + +from botx.models.requests import ( + CommandResult, + EventEdition, + Notification, + ResultOptions, + ResultPayload, + UpdatePayload, +) +from botx.models.sending import ( + MessagePayload, + SendingCredentials, + UpdatePayload as SendingUpdatePayload, +) + + +class BotXEndpoint: + """Definition of BotX API endpoint.""" + + def __init__(self, method: str, endpoint: str) -> None: + """Init endpoint with required params. + + Arguments: + method: HTTP method that is used for endpoint. + endpoint: relative oath to endpoint from host. + """ + self.method = method + self.endpoint = endpoint + + +_URL_TEMPLATE = "{scheme}://{host}{endpoint}" +HTTPS_SCHEME = "https" + + +class BotXAPI: + """Builder for different BotX API endpoints.""" + + token_endpoint = BotXEndpoint( + method="GET", endpoint="/api/v2/botx/bots/{bot_id}/token" + ) + command_endpoint = BotXEndpoint( + method="POST", endpoint="/api/v3/botx/command/callback" + ) + notification_endpoint = BotXEndpoint( + method="POST", endpoint="/api/v3/botx/notification/callback" + ) + edit_event_endpoint = BotXEndpoint( + method="POST", endpoint="/api/v3/botx/events/edit_event" + ) + + @classmethod + def token( + cls, host: str, scheme: str = HTTPS_SCHEME, **endpoint_params: Any + ) -> str: + """Build token URL. + + Arguments: + host: host for URL. + scheme: HTTP URL schema. + endpoint_params: additional params for URL. + + Returns: + URL for token endpoint for BotX API. + """ + return _URL_TEMPLATE.format( + scheme=scheme, + host=host, + endpoint=cls.token_endpoint.endpoint.format(**endpoint_params), + ) + + @classmethod + def command(cls, host: str, scheme: str = HTTPS_SCHEME) -> str: + """Build command result URL. + + Arguments: + host: host for URL. + scheme: HTTP URL schema. + + Returns: + URL for command result endpoint for BotX API. + """ + return _URL_TEMPLATE.format( + scheme=scheme, host=host, endpoint=cls.command_endpoint.endpoint + ) + + @classmethod + def notification(cls, host: str, scheme: str = HTTPS_SCHEME) -> str: + """Build notification URL. + + Arguments: + host: host for URL. + scheme: HTTP URL schema. + + Returns: + URL for notification endpoint for BotX API. + """ + return _URL_TEMPLATE.format( + scheme=scheme, host=host, endpoint=cls.notification_endpoint.endpoint + ) + + @classmethod + def edit_event(cls, host: str, scheme: str = HTTPS_SCHEME) -> str: + """Build edit event URL. + + Arguments: + host: host for URL. + scheme: HTTP URL schema. + + Returns: + URL for edit event endpoint for BotX API. + """ + return _URL_TEMPLATE.format( + scheme=scheme, host=host, endpoint=cls.edit_event_endpoint.endpoint + ) + + +def is_api_error_code(code: int) -> bool: + """Check that status code returned from BotX API is a HTTP error code. + + Arguments: + code: HTTP status code returned from BotX API. + + Returns: + A result of check. + """ + return StatusCode.is_client_error(code) or StatusCode.is_server_error(code) + + +class RequestPayloadBuilder: + """Builder for requests payload.""" + + @classmethod + def build_token_query_params(cls, signature: str) -> dict: + """Create query params for token request. + + Arguments: + signature: calculated signature for obtaining token for bot. + + Returns: + A dictionary that will be used in token URL. + """ + return {"signature": signature} + + @classmethod + def build_command_result( + cls, credentials: SendingCredentials, payload: MessagePayload + ) -> CommandResult: + """Build command result entity. + + Arguments: + credentials: message credentials for generated command result. + payload: message payload that is used for generation entity payload. + + Returns: + Command result payload for API. + """ + credentials.bot_id = cast(UUID, credentials.bot_id) + + return CommandResult( + bot_id=credentials.bot_id, + sync_id=cast(UUID, credentials.sync_id), + command_result=cls._build_result_payload(payload), + recipients=payload.options.recipients, + file=payload.file, + opts=ResultOptions(notification_opts=payload.options.notifications), + ) + + @classmethod + def build_notification( + cls, credentials: SendingCredentials, payload: MessagePayload + ) -> Notification: + """Build notification entity. + + Arguments: + credentials: message credentials for generated notification. + payload: message payload that is used for generation entity payload. + + Returns: + A notification payload for API. + """ + credentials.bot_id = cast(UUID, credentials.bot_id) + + return Notification( + bot_id=credentials.bot_id, + group_chat_ids=credentials.chat_ids, + notification=cls._build_result_payload(payload), + recipients=payload.options.recipients, + file=payload.file, + opts=ResultOptions(notification_opts=payload.options.notifications), + ) + + @classmethod + def build_event_edition( + cls, credentials: SendingCredentials, payload: SendingUpdatePayload + ) -> EventEdition: + """Build event edition entity. + + Arguments: + credentials: credentials for message that will be send. + payload: new message payload. + + Returns: + A edit event payload for API. + """ + credentials.sync_id = cast(UUID, credentials.sync_id) + + return EventEdition( + sync_id=credentials.sync_id, + payload=UpdatePayload( + body=payload.text, + keyboard=payload.keyboard, + bubble=payload.bubbles, + mentions=payload.mentions, + ), + ) + + @classmethod + def _build_result_payload(cls, payload: MessagePayload) -> ResultPayload: + """Build payload for command result or notification. + + Arguments: + payload: message payload that is used for generation entity payload. + + Returns: + A common payload command result or notification for API. + """ + return ResultPayload( + body=payload.text, + bubble=payload.markup.bubbles, + keyboard=payload.markup.keyboard, + mentions=payload.options.mentions, + ) diff --git a/botx/bots.py b/botx/bots.py index 3f9dce4a..882fd3a4 100644 --- a/botx/bots.py +++ b/botx/bots.py @@ -1,303 +1,662 @@ -import abc +"""Implementation for bot classes.""" + +import asyncio from typing import ( Any, - Awaitable, BinaryIO, Callable, Dict, List, Optional, + Sequence, + Set, TextIO, Type, Union, + cast, ) +from uuid import UUID -from .clients import AsyncBotXClient -from .collectors import HandlersCollector -from .core import TEXT_MAX_LENGTH -from .dispatchers import AsyncDispatcher, BaseDispatcher -from .exceptions import BotXException -from .helpers import call_coroutine_as_function -from .models import ( - CTS, - BotCredentials, - BotXTokenResponse, - CommandCallback, - CommandHandler, - CTSCredentials, - File, - Message, - MessageMarkup, - MessageOptions, - ReplyMessage, - SendingCredentials, - SendingPayload, - Status, -) +from loguru import logger +from botx import clients, concurrency, exception_handlers, exceptions, typing, utils +from botx.collecting import Collector, Handler +from botx.dependencies import models as deps +from botx.exceptions import ServerUnknownError +from botx.middlewares.base import BaseMiddleware +from botx.middlewares.exceptions import ExceptionMiddleware +from botx.models import datastructures, enums, files, menu, messages, sending +from botx.models.credentials import ExpressServer, ServerCredentials -class BaseBot(abc.ABC, HandlersCollector): - _dispatcher: BaseDispatcher - _credentials: BotCredentials + +class Bot: # noqa: WPS214, WPS230 + """Class that implements bot behaviour.""" def __init__( self, *, - credentials: Optional[BotCredentials] = None, - dependencies: Optional[List[Callable]] = None, + handlers: Optional[List[Handler]] = None, + known_hosts: Optional[Sequence[ExpressServer]] = None, + dependencies: Optional[Sequence[deps.Depends]] = None, ) -> None: - super().__init__(dependencies=dependencies) + """Init bot with required params. + + Arguments: + handlers: list of handlers that will be stored in this bot after init. + known_hosts: list of servers that will be used for handling message. + dependencies: background dependencies for all handlers of bot. + """ + + self.collector: Collector = Collector( + handlers=handlers, + dependencies=dependencies, + dependency_overrides_provider=self, + ) + """collector for all handlers registered on bot.""" - self._credentials = credentials if credentials else BotCredentials() + self.sync_client = clients.Client() + self.exception_middleware = ExceptionMiddleware(self.collector) - @property - def credentials(self) -> BotCredentials: - return self._credentials + self.client: clients.AsyncClient = clients.AsyncClient() + """BotX API async client.""" - def add_credentials(self, credentials: BotCredentials) -> None: - self._credentials.known_cts = [ - cts - for host, cts in { - cts.host: cts - for cts in self._credentials.known_cts + credentials.known_cts - }.items() - ] + self.dependency_overrides: Dict[Callable, Callable] = {} + """overrider for dependencies that can be used in tests.""" - def add_cts(self, cts: CTS) -> None: - self._credentials.known_cts.append(cts) + self.known_hosts: List[ExpressServer] = utils.optional_sequence_to_list( + known_hosts + ) + """list of servers that will be used for handling message.""" - def add_handler(self, handler: CommandHandler, force_replace: bool = False) -> None: - handler.callback.args = (self,) + handler.callback.args - super().add_handler(handler, force_replace) - self._dispatcher.add_handler(handler) + self.state: datastructures.State = datastructures.State() + """state that can be used in bot for storing something.""" - def get_cts_by_host(self, host: str) -> Optional[CTS]: - return {cts.host: cts for cts in self.credentials.known_cts}.get(host) + self._tasks: Set[asyncio.Future] = set() - def start(self) -> Optional[Awaitable[None]]: - """Run some outer dependencies that can not be started in init""" + self.add_exception_handler( + exceptions.DependencyFailure, + exception_handlers.dependency_failure_exception_handler, + ) + self.add_exception_handler( + exceptions.NoMatchFound, exception_handlers.no_match_found_exception_handler + ) - def stop(self) -> Optional[Awaitable[None]]: - """Stop special objects and dispatcher for bot""" + @property + def handlers(self) -> List[Handler]: + """Get handlers registered on this bot. - def exception_catcher( - self, exceptions: List[Type[Exception]], force_replace: bool = False - ) -> Callable: - def _register_exception(func: Callable) -> Callable: - for exc in exceptions: - self._dispatcher.register_exception_catcher( - exc, func, force_replace=force_replace - ) + Returns: + Registered handlers of bot. + """ + return self.collector.handlers - return func + def include_collector( + self, + collector: "Collector", + *, + dependencies: Optional[Sequence[deps.Depends]] = None, + ) -> None: + """Include handlers from collector into bot. - return _register_exception + Arguments: + collector: collector from which handlers should be copied. + dependencies: optional sequence of dependencies for handlers for this + collector. - def register_next_step_handler( - self, message: Message, callback: Callable, *args: Any, **kwargs: Any - ) -> None: - if message.user_huid: - self._dispatcher.register_next_step_handler( - message, - CommandCallback(callback=callback, args=(self, *args), kwargs=kwargs), - ) - else: - raise BotXException( - "next step handlers registration is available " - "only for messages from real users" - ) + Raises: + AssertionError: raised if both of collectors has registered default handler. + """ + self.collector.include_collector(collector, dependencies=dependencies) - @abc.abstractmethod - def status(self) -> Union[Status, Awaitable[Status]]: - """Get Status to bot commands menu""" + def command_for(self, *args: Any) -> str: + """Find handler and build a command string using passed body params. - @abc.abstractmethod - def execute_command(self, data: Dict[str, Any]) -> Optional[Awaitable[None]]: - """Execute handler from request""" + Arguments: + args: sequence of elements where first element should be name of handler. - @abc.abstractmethod - def send_message( - self, - text: str, - credentials: SendingCredentials, - *, - file: Optional[Union[BinaryIO, TextIO]] = None, - markup: Optional[MessageMarkup] = None, - options: Optional[MessageOptions] = None, - ) -> Optional[Awaitable[None]]: - """Create answer for notification or for handler and send it to BotX API""" + Returns: + Command string. - @abc.abstractmethod - def reply(self, message: ReplyMessage) -> Optional[Awaitable[None]]: - """Reply for handler in shorter form using ReplyMessage""" + Raises: + NoMatchFound: raised if handler was no found. + """ + return self.collector.command_for(*args) - @abc.abstractmethod - def answer_message( - self, - text: str, - message: Message, - *, - file: Optional[Union[BinaryIO, TextIO]] = None, - markup: Optional[MessageMarkup] = None, - options: Optional[MessageOptions] = None, - ) -> Optional[Awaitable[None]]: - """Send message with credentials from incoming message""" + def handler_for(self, name: str) -> Handler: + """Find handler in handlers of this bot. - @abc.abstractmethod - def send_file( - self, file: Union[TextIO, BinaryIO], credentials: SendingCredentials - ) -> Optional[Awaitable[None]]: - """Send separate file to BotX API""" + Find registered handler using using [botx.collector.Collector.handler_for] of + inner collector. - @abc.abstractmethod - def obtain_token( - self, credentials: SendingCredentials - ) -> Optional[Awaitable[None]]: - """Obtain token from BotX for making requests""" + Arguments: + name: name of handler that should be found. + Returns: + Handler that was found by name. -class AsyncBot(BaseBot): - _dispatcher: AsyncDispatcher - client: AsyncBotXClient + Raises: + NoMatchFound: raise if handler was not found. + """ + return self.collector.handler_for(name) - def __init__( + def add_handler( # noqa: WPS211 self, + handler: Callable, *, - credentials: Optional[BotCredentials] = None, - dependencies: Optional[List[Callable]] = None, + body: Optional[str] = None, + name: Optional[str] = None, + description: Optional[str] = None, + full_description: Optional[str] = None, + include_in_status: Union[bool, Callable] = True, + dependencies: Optional[Sequence[deps.Depends]] = None, ) -> None: - super().__init__(credentials=credentials, dependencies=dependencies) - - self._dispatcher = AsyncDispatcher() - self.client = AsyncBotXClient() + """Create new handler from passed arguments and store it inside. + + !!! info + If `include_in_status` is a function, then `body` argument will be checked + for matching public commands style, like `/command`. + + Arguments: + handler: callable that will be used for executing handler. + body: body template that will trigger this handler. + name: optional name for handler that will be used in generating body. + description: description for command that will be shown in bot's menu. + full_description: full description that can be used for example in `/help` + command. + include_in_status: should this handler be shown in bot's menu, can be + callable function with no arguments *(for now)*. + dependencies: sequence of dependencies that should be executed before + handler. + """ + self.collector.add_handler( + body=body, + handler=handler, + name=name, + description=description, + full_description=full_description, + include_in_status=include_in_status, + dependencies=dependencies, + ) - async def start(self) -> None: - await self._dispatcher.start() + def handler( # noqa: WPS211 + self, + handler: Optional[Callable] = None, + *, + command: Optional[str] = None, + commands: Optional[Sequence[str]] = None, + name: Optional[str] = None, + description: Optional[str] = None, + full_description: Optional[str] = None, + include_in_status: Union[bool, Callable] = True, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> Callable: + """Add new handler to bot. + + !!! info + If `include_in_status` is a function, then `body` argument will be checked + for matching public commands style, like `/command`. + + Arguments: + handler: callable that will be used for executing handler. + command: body template that will trigger this handler. + commands: list of body templates that will trigger this handler. + name: optional name for handler that will be used in generating body. + description: description for command that will be shown in bot's menu. + full_description: full description that can be used for example in `/help` + command. + include_in_status: should this handler be shown in bot's menu, can be + callable function with no arguments *(for now)*. + dependencies: sequence of dependencies that should be executed before + handler. + dependency_overrides_provider: mock of callable for handler. + + Returns: + Passed in `handler` callable. + """ + return self.collector.handler( + handler=handler, + command=command, + commands=commands, + name=name, + description=description, + full_description=full_description, + include_in_status=include_in_status, + dependencies=dependencies, + dependency_overrides_provider=dependency_overrides_provider, + ) - async def stop(self) -> None: - await self._dispatcher.shutdown() + def default( # noqa: WPS211 + self, + handler: Optional[Callable] = None, + *, + command: Optional[str] = None, + commands: Optional[Sequence[str]] = None, + name: Optional[str] = None, + description: Optional[str] = None, + full_description: Optional[str] = None, + include_in_status: Union[bool, Callable] = True, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> Callable: + """Add new handler to bot and register it as default handler. + + !!! info + If `include_in_status` is a function, then `body` argument will be checked + for matching public commands style, like `/command`. + + Arguments: + handler: callable that will be used for executing handler. + command: body template that will trigger this handler. + commands: list of body templates that will trigger this handler. + name: optional name for handler that will be used in generating body. + description: description for command that will be shown in bot's menu. + full_description: full description that can be used for example in `/help` + command. + include_in_status: should this handler be shown in bot's menu, can be + callable function with no arguments *(for now)*. + dependencies: sequence of dependencies that should be executed before + handler. + dependency_overrides_provider: mock of callable for handler. + + Returns: + Passed in `handler` callable. + """ + return self.collector.default( + handler=handler, + command=command, + commands=commands, + name=name, + description=description, + full_description=full_description, + include_in_status=include_in_status, + dependencies=dependencies, + dependency_overrides_provider=dependency_overrides_provider, + ) - async def status(self) -> Status: - return await self._dispatcher.status() + def hidden( # noqa: WPS211 + self, + handler: Optional[Callable] = None, + *, + command: Optional[str] = None, + commands: Optional[Sequence[str]] = None, + name: Optional[str] = None, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> Callable: + """Register hidden handler that won't be showed in menu. + + Arguments: + handler: callable that will be used for executing handler. + command: body template that will trigger this handler. + commands: list of body templates that will trigger this handler. + name: optional name for handler that will be used in generating body. + dependencies: sequence of dependencies that should be executed before + handler. + dependency_overrides_provider: mock of callable for handler. + + Returns: + Passed in `handler` callable. + """ + return self.collector.hidden( + handler=handler, + command=command, + commands=commands, + name=name, + dependencies=dependencies, + dependency_overrides_provider=dependency_overrides_provider, + ) - async def execute_command(self, data: Dict[str, Any]) -> None: - await self._dispatcher.execute_command(data) + def system_event( # noqa: WPS211 + self, + handler: Optional[Callable] = None, + *, + event: Optional[enums.SystemEvents] = None, + events: Optional[Sequence[enums.SystemEvents]] = None, + name: Optional[str] = None, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> Callable: + """Register handler for system event. + + Arguments: + handler: callable that will be used for executing handler. + event: event for triggering this handler. + events: a sequence of events that will trigger handler. + name: optional name for handler that will be used in generating body. + dependencies: sequence of dependencies that should be executed before + handler. + dependency_overrides_provider: mock of callable for handler. + + Returns: + Passed in `handler` callable. + """ + return self.collector.system_event( + handler=handler, + event=event, + events=events, + name=name, + dependencies=dependencies, + dependency_overrides_provider=dependency_overrides_provider, + ) - def send_message( + def chat_created( self, - text: str, - credentials: SendingCredentials, + handler: Optional[Callable] = None, *, - file: Optional[Union[BinaryIO, TextIO]] = None, - markup: Optional[MessageMarkup] = None, - options: Optional[MessageOptions] = None, - ) -> Optional[Awaitable[None]]: - return call_coroutine_as_function( - self._send_message, - text, - credentials, - file=file, - markup=markup, - options=options, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> Callable: + """Register handler for `system:chat_created` event. + + Arguments: + handler: callable that will be used for executing handler. + dependencies: sequence of dependencies that should be executed before + handler. + dependency_overrides_provider: mock of callable for handler. + + Returns: + Passed in `handler` callable. + """ + return self.collector.chat_created( + handler=handler, + dependencies=dependencies, + dependency_overrides_provider=dependency_overrides_provider, ) - def answer_message( + def file_transfer( self, - text: str, - message: Message, + handler: Optional[Callable] = None, *, - file: Optional[Union[BinaryIO, TextIO]] = None, - markup: Optional[MessageMarkup] = None, - options: Optional[MessageOptions] = None, - ) -> Optional[Awaitable[None]]: - return self.send_message( - text, - SendingCredentials( - sync_id=message.sync_id, bot_id=message.bot_id, host=message.host - ), - file=file, - markup=markup, - options=options, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> Callable: + """Register handler for `file_transfer` event. + + Arguments: + handler: callable that will be used for executing handler. + dependencies: sequence of dependencies that should be executed before + handler. + dependency_overrides_provider: mock of callable for handler. + + Returns: + Passed in `handler` callable. + """ + return self.collector.file_transfer( + handler=handler, + dependencies=dependencies, + dependency_overrides_provider=dependency_overrides_provider, ) - def reply(self, message: ReplyMessage) -> Optional[Awaitable[None]]: - return self.send_message( - message.text, - SendingCredentials( - bot_id=message.bot_id, - host=message.host, - sync_id=message.sync_id, - chat_ids=message.chat_ids, - ), - file=message.file.file if message.file else None, - markup=MessageMarkup(bubbles=message.bubble, keyboard=message.keyboard), - options=MessageOptions( - recipients=message.recipients, - mentions=message.mentions, - notifications=message.opts, - ), + async def status(self) -> menu.Status: + """Generate status object that could be return to BotX API on `/status`.""" + status = menu.Status() + for handler in self.handlers: + if callable(handler.include_in_status): + include_in_status = await concurrency.callable_to_coroutine( + handler.include_in_status + ) + else: + include_in_status = handler.include_in_status + + if include_in_status: + status.result.commands.append( + menu.MenuCommand( + description=handler.description or "", + body=handler.body, + name=handler.name, + ) + ) + + return status + + async def execute_command(self, message: dict) -> None: + """Process data with incoming message and handle command inside. + + Arguments: + message: incoming message to bot. + + Raises: + ServerUnknownError: raised if message was received from unregistered host. + """ + logger.bind(botx_bot=True, payload=message).debug("process incoming message") + msg = messages.Message.from_dict(message, self) + for server in self.known_hosts: + if server.host == msg.host: + await self(msg) + break + else: + raise ServerUnknownError(f"unknown server {msg.host}") + + def add_middleware( + self, middleware_class: Type[BaseMiddleware], **kwargs: Any + ) -> None: + """Register new middleware for execution before handler. + + Arguments: + middleware_class: middleware that should be registered. + kwargs: arguments that are required for middleware initialization. + """ + self.exception_middleware.executor = middleware_class( + self.exception_middleware.executor, **kwargs ) - def send_file( - self, file: Union[TextIO, BinaryIO], credentials: SendingCredentials - ) -> Optional[Awaitable[None]]: - return call_coroutine_as_function(self._send_file, file, credentials) + def middleware(self, handler: typing.Executor) -> Callable: # noqa: D202 + """Register callable as middleware for request. + + Arguments: + handler: handler for middleware logic. - async def _send_message( + Returns: + Passed `handler` callable. + """ + + self.add_middleware(BaseMiddleware, dispatch=handler) + return handler + + def add_exception_handler( + self, exc_class: Type[Exception], handler: typing.ExceptionHandler + ) -> None: + """Register new handler for exception. + + Arguments: + exc_class: exception type that should be handled. + handler: handler for exception. + """ + self.exception_middleware.add_exception_handler(exc_class, handler) + + def exception_handler(self, exc_class: Type[Exception]) -> Callable: # noqa: D202 + """Register callable as handler for exception. + + Arguments: + exc_class: exception type that should be handled. + + Returns: + Decorator that will register exception and return passed function. + """ + + def decorator(handler: typing.ExceptionHandler) -> Callable: + self.add_exception_handler(exc_class, handler) + return handler + + return decorator + + async def send_message( self, text: str, - credentials: SendingCredentials, + credentials: sending.SendingCredentials, *, file: Optional[Union[BinaryIO, TextIO]] = None, - markup: Optional[MessageMarkup] = None, - options: Optional[MessageOptions] = None, - ) -> None: - markup = markup or MessageMarkup() - options = options or MessageOptions() + markup: Optional[sending.MessageMarkup] = None, + options: Optional[sending.MessageOptions] = None, + ) -> Optional[UUID]: + """Send message as answer to command or notification to chat and get it id. + + Arguments: + text: text that should be sent to client. + credentials: credentials that are used for sending message. + file: file that should be attached to message. + markup: message markup that should be attached to message. + options: extra options for message. + + Returns: + `UUID` if message was send as command result or `None` if message was send + as notification. + """ + await self._obtain_token(credentials) + + payload = sending.MessagePayload( + text=text, + file=files.File.from_file(file) if file else None, + markup=markup or sending.MessageMarkup(), + options=options or sending.MessageOptions(), + ) + + if credentials.sync_id: + return await self.client.send_command_result(credentials, payload) + + return await self.client.send_notification(credentials, payload) + + async def send(self, message: messages.SendingMessage) -> Optional[UUID]: + """Send message as answer to command or notification to chat and get it id. + + Arguments: + message: message that should be sent to chat. - if len(text) > TEXT_MAX_LENGTH: - raise BotXException( - f"message text must be shorter {TEXT_MAX_LENGTH} symbols" + Returns: + `UUID` of sent event if message was send as command result or `None` if + message was send as notification. + """ + await self._obtain_token(message.credentials) + + if message.sync_id: + return await self.client.send_command_result( + message.credentials, message.payload ) - await self.obtain_token(credentials) + return await self.client.send_notification(message.credentials, message.payload) - payload = SendingPayload( - text=text, - file=File.from_file(file) if file else None, - markup=markup, - options=options, + async def answer_message( + self, + text: str, + message: messages.Message, + *, + file: Optional[Union[BinaryIO, TextIO, files.File]] = None, + markup: Optional[sending.MessageMarkup] = None, + options: Optional[sending.MessageOptions] = None, + ) -> UUID: + """Answer on incoming message and return id of new message.. + + !!! warning + This method should be used only in handlers. + + Arguments: + text: text that should be sent in message. + message: incoming message. + file: file that can be attached to the message. + markup: bubbles and keyboard that can be attached to the message. + options: additional message options, like mentions or notifications + configuration. + + Returns: + `UUID` of sent event. + """ + sending_message = messages.SendingMessage( + text=text, credentials=message.credentials, markup=markup, options=options ) - if credentials.sync_id: - await self.client.send_command_result(credentials, payload) - elif credentials.chat_ids: - await self.client.send_notification(credentials, payload) - else: - raise BotXException("both sync_id and chat_ids in credentials are missed") + if file: + sending_message.add_file(file) + + return cast(UUID, await self.send(sending_message)) - async def _send_file( - self, file: Union[TextIO, BinaryIO], credentials: SendingCredentials + async def update_message( + self, credentials: sending.SendingCredentials, update: sending.UpdatePayload ) -> None: - await self.obtain_token(credentials) - await self.client.send_file( - credentials, SendingPayload(file=File.from_file(file)) - ) + """Change message by it's event id. - async def obtain_token(self, credentials: SendingCredentials) -> None: - cts = self.get_cts_by_host(credentials.host) - if not cts: - raise BotXException(f"unregistered cts with host {repr(credentials.host)}") + Arguments: + credentials: credentials that are used for sending message. *sync_id* is + required for credentials. + update: update of message content. + """ + await self._obtain_token(credentials) + await self.client.edit_event(credentials, update) - if cts.credentials and cts.credentials.token: - credentials.token = cts.credentials.token + async def send_file( + self, + file: Union[TextIO, BinaryIO, files.File], + credentials: sending.SendingCredentials, + filename: Optional[str] = None, + ) -> Optional[UUID]: + """Send file in chat and return id of message. + + Arguments: + file: file-like object that will be sent to chat. + credentials: credentials of chat where file should be sent. + filename: name for file that will be used if it can not be accessed from + `file` argument. + + Returns: + `UUID` of sent event if message was send as command result or `None` if + message was send as notification. + """ + message = messages.SendingMessage(credentials=credentials) + message.add_file(file, filename) + return await self.send(message) + + async def shutdown(self) -> None: + """Wait for all running handlers shutdown.""" + await asyncio.wait(self._tasks, return_when=asyncio.ALL_COMPLETED) + self._tasks = set() + + async def __call__(self, message: messages.Message) -> None: + """Iterate through collector, find handler and execute it, running middlewares. + + Arguments: + message: message that will be proceed by handler. + """ + self._tasks.add(asyncio.ensure_future(self.exception_middleware(message))) + + async def _obtain_token(self, credentials: sending.SendingCredentials) -> None: + """Get token for bot and fill credentials. + + Arguments: + credentials: credentials that should be filled with token. + """ + assert credentials.host, "host is required in credentials for obtaining token" + assert ( + credentials.bot_id + ), "bot_id is required in credentials for obtaining token" + + cts = self._get_cts_by_host(credentials.host) + + if cts.server_credentials and cts.server_credentials.token: + credentials.token = cts.server_credentials.token return signature = cts.calculate_signature(credentials.bot_id) - - token_data = await self.client.obtain_token( + token = await self.client.obtain_token( credentials.host, credentials.bot_id, signature ) - token = BotXTokenResponse(**token_data).result - - cts.credentials = CTSCredentials(bot_id=credentials.bot_id, token=token) + cts.server_credentials = ServerCredentials( + bot_id=credentials.bot_id, token=token + ) credentials.token = token + + def _get_cts_by_host(self, host: str) -> ExpressServer: + """Find CTS in bot registered servers. + + Arguments: + host: host of server that should be found. + + Returns: + Found instance of registered server. + + Raises: + ServerUnknownError: raised if server was not found. + """ + for cts in self.known_hosts: + if cts.host == host: + return cts + + raise ServerUnknownError(f"unknown server {host}") diff --git a/botx/clients.py b/botx/clients.py index 7ed79498..e1b38a44 100644 --- a/botx/clients.py +++ b/botx/clients.py @@ -1,181 +1,224 @@ -import abc -import json -from typing import Any, Awaitable, Callable, Dict, Optional +"""Implementation for BotX API clients.""" + from uuid import UUID -from httpx import AsyncClient -from httpx.models import BaseResponse -from httpx.status_codes import StatusCode +import httpx +from httpx import Response from loguru import logger -from .core import BotXAPI -from .exceptions import BotXAPIException -from .helpers import get_data_for_api_error -from .models import ( - BotXCommandResultPayload, - BotXFilePayload, - BotXNotificationPayload, - BotXResultPayload, - BotXTokenRequestParams, - SendingCredentials, - SendingPayload, -) -from .models.botx_api import BotXPayloadOptions +from botx.api_helpers import BotXAPI, RequestPayloadBuilder, is_api_error_code +from botx.exceptions import BotXAPIError +from botx.models.responses import PushResponse, TokenResponse +from botx.models.sending import MessagePayload, SendingCredentials, UpdatePayload +from botx.utils import LogsShapeBuilder -logger_ctx = logger.bind(botx_client=True) +_HOST_SHOULD_BE_FILLED_ERROR = "Host should be filled in credentials" +_BOT_ID_SHOULD_BE_FILLED_ERROR = "Bot ID should be filled in credentials" +_TOKEN_SHOULD_BE_FILLED_ERROR = "Token should be filled in credentials" # noqa: S105 +_TEXT_OR_FILE_MISSED_ERROR = "text or file should present in payload" +_REQUEST_SCHEMAS = ("http", "https") +SECURE_SCHEME = "https" -def get_headers(token: str) -> Dict[str, str]: - return {"authorization": f"Bearer {token}"} +class BaseClient: + """Base class for implementing client for making requests to BotX API.""" + default_headers = {"content-type": "application/json"} -def check_api_error(resp: BaseResponse) -> bool: - return StatusCode.is_client_error(resp.status_code) or StatusCode.is_server_error( - resp.status_code - ) + def __init__(self, scheme: str = SECURE_SCHEME) -> None: + """Init client with required params. + Arguments: + scheme: HTTP request scheme. + """ + self.scheme = scheme -class BaseBotXClient(abc.ABC): - _token_url: str = BotXAPI.V2.token.url - _command_url: str = BotXAPI.V3.command.url - _notification_url: str = BotXAPI.V3.notification.url - _file_url: str = BotXAPI.V1.file.url + @property + def scheme(self) -> str: + """HTTP request scheme for BotX API.""" + return self._scheme - @abc.abstractmethod - def send_file( - self, address: SendingCredentials, payload: SendingPayload - ) -> Optional[Awaitable[None]]: - """Send separate file to BotX API""" + @scheme.setter # noqa: WPS440 + def scheme(self, scheme: str) -> None: + """HTTP request scheme for BotX API.""" + if scheme not in _REQUEST_SCHEMAS: + raise ValueError("request scheme can be only http or https") + self._scheme = scheme - @abc.abstractmethod - def obtain_token(self, host: str, bot_id: UUID, signature: str) -> Any: - """Obtain token from BotX for making requests""" + def _get_bearer_headers(self, token: str) -> dict: + """Create authorization headers for BotX API v3 requests. - @abc.abstractmethod - def send_command_result( - self, credentials: SendingCredentials, payload: SendingPayload - ) -> Optional[Awaitable[None]]: - """Send handler result answer""" + Arguments: + token: obtained token for bot. - @abc.abstractmethod - def send_notification( - self, credentials: SendingCredentials, payload: SendingPayload - ) -> Optional[Awaitable[None]]: - """Send notification result answer""" + Return: + Dict that will be used as headers. + """ + return {"Authorization": f"Bearer {token}"} + def _check_api_response(self, response: Response, error_message: str) -> None: + """Check if response is errored, log it and raise exception. -class AsyncBotXClient(BaseBotXClient): - asgi_app: Optional[Callable] = None + Arguments: + response: response from BotX API. + error_message: message that will be logged. + """ + if is_api_error_code(response.status_code): + logger.bind( + botx_http_client=True, + payload=LogsShapeBuilder.get_response_shape(response), + ).error(error_message) + raise BotXAPIError(error_message) - async def send_file( - self, credentials: SendingCredentials, payload: SendingPayload - ) -> None: - assert payload.file, "payload should include File object" - - async with AsyncClient(app=self.asgi_app) as client: - logger_ctx.bind( - credentials=json.loads(credentials.json(exclude={"token", "chat_ids"})), - payload={"filename": payload.file.file_name}, - ).debug("send file") - - resp = await client.post( - self._file_url.format(host=credentials.host), - data=BotXFilePayload.from_orm(credentials).dict(), - files={"file": payload.file.file}, - ) - if check_api_error(resp): - raise BotXAPIException( - "unable to send file to BotX API", - data=get_data_for_api_error(credentials, resp), - ) - - async def obtain_token(self, host: str, bot_id: UUID, signature: str) -> Any: - async with AsyncClient(app=self.asgi_app) as client: - logger_ctx.bind( - credentials={"host": host, "bot_id": str(bot_id)}, - payload={"signature": signature}, - ).debug("obtain token") - - resp = await client.get( - self._token_url.format(host=host, bot_id=bot_id), - params=BotXTokenRequestParams(signature=signature).dict(), - ) - if check_api_error(resp): - raise BotXAPIException( - "unable to obtain token from BotX API", - data=get_data_for_api_error( - SendingCredentials(host=host, bot_id=bot_id, token=""), resp - ), - ) - return resp.json() + +class AsyncClient(BaseClient): + """Async client for making calls to BotX API.""" + + def __init__(self, scheme: str = SECURE_SCHEME) -> None: + """Init client to BotX API. + + Arguments: + scheme: HTTP scheme. + """ + super().__init__(scheme) + + self.http_client: httpx.AsyncClient = httpx.AsyncClient( + headers=self.default_headers, http2=True + ) + """HTTP client for requests.""" + + async def obtain_token(self, host: str, bot_id: UUID, signature: str) -> str: + """Send request to BotX API to obtain token for bot. + + Arguments: + host: host for URL. + bot_id: bot id which token should be obtained. + signature: calculated signature for bot. + + Returns: + Obtained token. + + Raises: + BotXAPIError: raised if there was an error in calling BotX API. + """ + logger.bind( + botx_http_client=True, + payload=LogsShapeBuilder.get_token_request_shape(host, bot_id, signature), + ).debug("obtain token for requests") + token_response = await self.http_client.get( + BotXAPI.token(host=host, bot_id=bot_id, scheme=self.scheme), + params=RequestPayloadBuilder.build_token_query_params(signature=signature), + ) + self._check_api_response(token_response, "unable to obtain token from BotX API") + + parsed_response = TokenResponse.parse_obj(token_response.json()) + return parsed_response.result async def send_command_result( - self, credentials: SendingCredentials, payload: SendingPayload - ) -> None: - assert credentials.token, "credentials should include access token" - - command_result = BotXCommandResultPayload( - bot_id=credentials.bot_id, - sync_id=credentials.sync_id, - command_result=BotXResultPayload( - body=payload.text, - bubble=payload.markup.bubbles, - keyboard=payload.markup.keyboard, - mentions=payload.options.mentions, - ), - recipients=payload.options.recipients, - file=payload.file, - opts=BotXPayloadOptions(notification_opts=payload.options.notifications), + self, credentials: SendingCredentials, payload: MessagePayload + ) -> UUID: + """Send command result to BotX API using `sync_id` from credentials. + + Arguments: + credentials: credentials that are used for sending result. + payload: command result that should be sent to BotX API. + + Returns: + `UUID` of sent event if message was send as command result or `None` if + message was sent as notification. + + Raises: + BotXAPIError: raised if there was an error in calling BotX API. + AssertionError: raised if there was an error in credentials configuration. + RuntimeError: raise if there was an error in payload configuration. + """ + assert credentials.host, _HOST_SHOULD_BE_FILLED_ERROR + assert credentials.token, _TOKEN_SHOULD_BE_FILLED_ERROR + if not (payload.text or payload.file): + raise RuntimeError(_TEXT_OR_FILE_MISSED_ERROR) + + command_result = RequestPayloadBuilder.build_command_result( + credentials, payload + ) + logger.bind( + botx_http_client=True, + payload=LogsShapeBuilder.get_command_result_shape(credentials, payload), + ).debug("send command result to BotX API") + command_response = await self.http_client.post( + BotXAPI.command(host=credentials.host, scheme=self.scheme), + data=command_result.json(by_alias=True), + headers=self._get_bearer_headers(token=credentials.token), + ) + self._check_api_response( + command_response, "unable to send command result to BotX API" ) - async with AsyncClient(app=self.asgi_app) as client: - logger_ctx.bind( - credentials=json.loads(credentials.json(exclude={"token", "chat_ids"})), - payload=json.loads(command_result.json()), - ).debug("send command result") - - resp = await client.post( - self._command_url.format(host=credentials.host), - json=command_result.dict(), - headers=get_headers(credentials.token), - ) - if check_api_error(resp): - raise BotXAPIException( - "unable to send command result to BotX API", - data=get_data_for_api_error(credentials, resp), - ) + parsed_response = PushResponse.parse_obj(command_response.json()) + return parsed_response.result.sync_id async def send_notification( - self, credentials: SendingCredentials, payload: SendingPayload + self, credentials: SendingCredentials, payload: MessagePayload ) -> None: - assert credentials.token, "credentials should include access token" - - notification = BotXNotificationPayload( - bot_id=credentials.bot_id, - group_chat_ids=credentials.chat_ids, - notification=BotXResultPayload( - body=payload.text, - bubble=payload.markup.bubbles, - keyboard=payload.markup.keyboard, - mentions=payload.options.mentions, - ), - recipients=payload.options.recipients, - file=payload.file, - opts=BotXPayloadOptions(notification_opts=payload.options.notifications), + """Send notification into chat or chats. + + Arguments: + credentials: credentials that are used for sending result. + payload: command result that should be sent to BotX API. + + Raises: + BotXAPIError: raised if there was an error in calling BotX API. + AssertionError: raised if there was an error in credentials configuration. + RuntimeError: raise if there was an error in payload configuration. + """ + assert credentials.host, _HOST_SHOULD_BE_FILLED_ERROR + assert credentials.token, _TOKEN_SHOULD_BE_FILLED_ERROR + if not (payload.text or payload.file): + raise RuntimeError(_TEXT_OR_FILE_MISSED_ERROR) + + notification = RequestPayloadBuilder.build_notification(credentials, payload) + logger.bind( + botx_http_client=True, + payload=LogsShapeBuilder.get_notification_shape(credentials, payload), + ).debug("send notification to BotX API") + notification_response = await self.http_client.post( + BotXAPI.notification(host=credentials.host, scheme=self.scheme), + data=notification.json(by_alias=True), + headers=self._get_bearer_headers(token=credentials.token), ) - async with AsyncClient(app=self.asgi_app) as client: - logger.bind( - credentials=json.loads(credentials.json(exclude={"token", "sync_id"})), - payload=json.loads(notification.json()), - ).debug("send notification") - - resp = await client.post( - self._notification_url.format(host=credentials.host), - json=notification.dict(), - headers=get_headers(credentials.token), - ) - if check_api_error(resp): - raise BotXAPIException( - "unable to send notification to BotX API", - data=get_data_for_api_error(credentials, resp), - ) + self._check_api_response( + notification_response, "unable to send notification to BotX API" + ) + + async def edit_event( + self, credentials: SendingCredentials, update_payload: UpdatePayload + ) -> None: + """Edit event sent from bot. + + Arguments: + credentials: credentials that are used for sending result. + update_payload: update payload for message. + + Raises: + BotXAPIError: raised if there was an error in calling BotX API. + AssertionError: raised if there was an error in credentials configuration. + """ + assert credentials.host, _HOST_SHOULD_BE_FILLED_ERROR + assert credentials.token, _TOKEN_SHOULD_BE_FILLED_ERROR + + edition = RequestPayloadBuilder.build_event_edition(credentials, update_payload) + logger.bind( + botx_http_client=True, + payload=LogsShapeBuilder.get_edition_shape(credentials, update_payload), + ).debug("update event in BotX API") + update_event_response = await self.http_client.post( + BotXAPI.edit_event(host=credentials.host, scheme=self.scheme), + data=edition.json(exclude_none=True), + headers=self._get_bearer_headers(token=credentials.token), + ) + self._check_api_response( + update_event_response, "unable to update event in BotX API" + ) + + +class Client(BaseClient): + """Synchronous client for making calls to BotX API.""" diff --git a/botx/collecting.py b/botx/collecting.py new file mode 100644 index 00000000..16348885 --- /dev/null +++ b/botx/collecting.py @@ -0,0 +1,654 @@ +"""Definition of command handlers and routing mechanism.""" + +import inspect +import itertools +import re +from functools import partial +from typing import Any, Awaitable, Callable, Iterator, List, Optional, Sequence, Union + +from loguru import logger + +from botx import concurrency, utils +from botx.dependencies import models as deps +from botx.dependencies.solving import solve_dependencies +from botx.exceptions import NoMatchFound +from botx.models import enums, messages + +SLASH = "/" + + +def get_body_from_name(name: str) -> str: + """Get auto body from given handler name in format `/word-word`. + + Examples: + ``` + >>> get_body_from_name("HandlerFunction") + "handler-function" + >>> get_body_from_name("handlerFunction") + "handler-function" + ``` + Arguments: + name: name of handler for which body should be generated. + """ + splited_words = re.findall(r"^[a-z\d_\-]+|[A-Z\d_\-][^A-Z\d_\-]*", name) + joined_body = "-".join(splited_words) + dashed_body = joined_body.replace("_", "-") + return "/{0}".format(re.sub(r"-+", "-", dashed_body).lower()) + + +def get_executor( + dependant: deps.Dependant, dependency_overrides_provider: Any = None +) -> Callable[[messages.Message], Awaitable[None]]: + """Get an execution callable for passed dependency. + + Arguments: + dependant: passed dependency for which execution callable should be generated. + dependency_overrides_provider: dependency overrider that will be passed to the + execution. + + Raises: + AssertationError: raise if there is no callable in `dependant.call`. + """ + assert dependant.call is not None, "dependant.call must be a function" + + async def factory(message: messages.Message) -> None: + values, _ = await solve_dependencies( + message=message, + dependant=dependant, + dependency_overrides_provider=dependency_overrides_provider, + ) + assert dependant.call is not None, "dependant.call must be a function" + await concurrency.callable_to_coroutine(dependant.call, **values) + + return factory + + +class Handler: # noqa: WPS230 + """Handler that will store body and callable.""" + + def __init__( # noqa: WPS211, WPS213 + self, + body: str, + handler: Callable, + *, + name: Optional[str] = None, + description: Optional[str] = None, + full_description: Optional[str] = None, + include_in_status: Union[bool, Callable] = True, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> None: + """Init handler that will be used for executing registered logic. + + Arguments: + handler: callable that will be used for executing handler. + body: body template that will trigger this handler. + name: optional name for handler that will be used in generating body. + description: description for command that will be shown in bot's menu. + full_description: full description that can be used for example in `/help` + command. + include_in_status: should this handler be shown in bot's menu, can be + callable function with no arguments *(for now)*. + dependencies: sequence of dependencies that should be executed before + handler. + dependency_overrides_provider: mock of callable for handler. + """ + if include_in_status: + assert body.startswith( + SLASH + ), "Public commands should start with leading slash" + assert ( + body[: -len(body.strip(SLASH))].count(SLASH) == 1 + ), "Command body can contain only single leading slash" + assert ( + len(body.split()) == 1 + ), "Public commands should contain only one word" + + self.body: str = body + """Command body.""" + self.handler: Callable = handler + """Callable for executing registered logic.""" + self.name: str = utils.get_name_from_callable(handler) if name is None else name + """Name of handler.""" + + self.dependencies: List[deps.Depends] = utils.optional_sequence_to_list( + dependencies + ) + """Additional dependencies of handler.""" + self.description: Optional[str] = description + """Description that will be used in bot's menu.""" + self.full_description: Optional[str] = full_description + """Extra description.""" + self.include_in_status: Union[bool, Callable] = include_in_status + """Flag or function that will check if command should be showed in menu.""" + + assert inspect.isfunction(handler) or inspect.ismethod( + handler + ), f"Handler must be a function or method" + self.dependant: deps.Dependant = deps.get_dependant(call=self.handler) + """Dependency for passed handler.""" + for depends in self.dependencies: + assert callable( + depends.dependency + ), "A parameter-less dependency must have a callable dependency" + self.dependant.dependencies.append( + deps.get_dependant(call=depends.dependency, use_cache=depends.use_cache) + ) + self.dependency_overrides_provider: Any = dependency_overrides_provider + """Overrider for passed dependencies.""" + self.executor: Callable = get_executor( + dependant=self.dependant, + dependency_overrides_provider=self.dependency_overrides_provider, + ) + """Main logic executor for passed handler.""" + + def matches(self, message: messages.Message) -> bool: + """Check if message body matched to handler's body. + + Arguments: + message: incoming message which body will be used to check route. + """ + return bool(re.compile(self.body).match(message.body)) + + def command_for(self, *args: Any) -> str: + """Build a command string using passed body params. + + Arguments: + args: sequence of elements that are arguments for command. + """ + args_str = " ".join((str(arg) for arg in args[1:])) + return "{0} {1}".format(self.body, args_str) + + async def __call__(self, message: messages.Message) -> None: + """Execute handler using incoming message. + + Arguments: + message: message that will be handled by handler. + """ + await self.executor(message) + + +class Collector: # noqa: WPS214 + """Collector for different handlers.""" + + def __init__( + self, + handlers: Optional[List[Handler]] = None, + default: Optional[Handler] = None, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> None: + """Init collector with required params. + + Arguments: + handlers: list of handlers that will be stored in this collector after init. + default: default handler that will be used if no handler found. + dependencies: background dependencies for all handlers applied to this + collector. + dependency_overrides_provider: object that will override dependencies for + this handler. + """ + self.handlers: List[Handler] = [] + """List of registered on this collector handlers.""" + self.dependencies: Optional[Sequence[deps.Depends]] = dependencies + """Background dependencies that will be executed for handlers.""" + self.dependency_overrides_provider: Any = dependency_overrides_provider + """Overrider for dependencies.""" + self.default_message_handler: Optional[Handler] = None + """Handler that will be used for handling non matched message.""" + + handlers = utils.optional_sequence_to_list(handlers) + + self._add_handlers(handlers) + + if default: + default_dependencies = list(dependencies or []) + list( + default.dependencies or [] + ) + self.default_message_handler = Handler( + body=default.body, + handler=default.handler, + name=default.name, + description=default.description, + full_description=default.full_description, + include_in_status=default.include_in_status, + dependencies=default_dependencies, + dependency_overrides_provider=self.dependency_overrides_provider, + ) + + def include_collector( + self, + collector: "Collector", + *, + dependencies: Optional[Sequence[deps.Depends]] = None, + ) -> None: + """Include handlers from another collector into this one. + + Arguments: + collector: collector from which handlers should be copied. + dependencies: optional sequence of dependencies for handlers for this + collector. + """ + assert not ( + self.default_message_handler and collector.default_message_handler + ), "Only one default handler can be applied" + + self._add_handlers(collector.handlers, dependencies) + + def command_for(self, *args: Any) -> str: + """Find handler and build a command string using passed body params. + + Arguments: + args: sequence of elements where first element should be name of handler. + + Returns: + Command string. + + Raises: + NoMatchFound: raised if handler was no found. + """ + if not len(args): + raise TypeError("missing handler name as the first argument") + + for handler in self.handlers: + if handler.name == args[0]: + return handler.command_for(*args) + + raise NoMatchFound + + def handler_for(self, name: str) -> Handler: + """Find handler in handlers of this bot. + + Arguments: + name: name of handler that should be found. + + Returns: + Handler that was found by name. + + Raises: + NoMatchFound: raise if handler was not found. + """ + + for handler in self.handlers: + if handler.name == name: + return handler + + raise NoMatchFound + + def add_handler( # noqa: WPS211 + self, + handler: Callable, + *, + body: Optional[str] = None, + name: Optional[str] = None, + description: Optional[str] = None, + full_description: Optional[str] = None, + include_in_status: Union[bool, Callable] = True, + dependencies: Optional[Sequence[deps.Depends]] = None, + ) -> None: + """Create new handler from passed arguments and store it inside. + + !!! info + If `include_in_status` is a function, then `body` argument will be checked + for matching public commands style, like `/command`. + + Arguments: + handler: callable that will be used for executing handler. + body: body template that will trigger this handler. + name: optional name for handler that will be used in generating body. + description: description for command that will be shown in bot's menu. + full_description: full description that can be used for example in `/help` + command. + include_in_status: should this handler be shown in bot's menu, can be + callable function with no arguments *(for now)*. + dependencies: sequence of dependencies that should be executed before + handler. + """ + if body is None: + name = name or utils.get_name_from_callable(handler) + body = get_body_from_name(name) + + for registered_handler in self.handlers: + assert ( + body.strip(SLASH) != "" + ), "Handler should not consist only from slashes" + assert ( + body != registered_handler.body + ), f"Handler with body {registered_handler.body} already registered" + assert ( + name != registered_handler.name + ), f"Handler with name {registered_handler.name} already registered" + + command_handler = Handler( + body=body, + handler=handler, + name=name, + description=description, + full_description=full_description, + include_in_status=include_in_status, + dependencies=dependencies, + dependency_overrides_provider=self.dependency_overrides_provider, + ) + self.handlers.append(command_handler) + self.handlers.sort(key=lambda handler: len(handler.body), reverse=True) + + def handler( # noqa: WPS211 + self, + handler: Optional[Callable] = None, + *, + command: Optional[str] = None, + commands: Optional[Sequence[str]] = None, + name: Optional[str] = None, + description: Optional[str] = None, + full_description: Optional[str] = None, + include_in_status: Union[bool, Callable] = True, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> Callable: + """Add new handler to collector. + + !!! info + If `include_in_status` is a function, then `body` argument will be checked + for matching public commands style, like `/command`. + + Arguments: + handler: callable that will be used for executing handler. + command: body template that will trigger this handler. + commands: list of body templates that will trigger this handler. + name: optional name for handler that will be used in generating body. + description: description for command that will be shown in bot's menu. + full_description: full description that can be used for example in `/help` + command. + include_in_status: should this handler be shown in bot's menu, can be + callable function with no arguments *(for now)*. + dependencies: sequence of dependencies that should be executed before + handler. + dependency_overrides_provider: mock of callable for handler. + + Returns: + Passed in `handler` callable. + """ + if handler: + handler_commands: List[Optional[str]] = utils.optional_sequence_to_list( + commands + ) + + if command and commands: + handler_commands += [command] + elif not commands: + handler_commands = [command] + + for command_body in handler_commands: + self.add_handler( + body=command_body, + handler=handler, + name=name, + description=description, + full_description=full_description, + include_in_status=include_in_status, + dependencies=dependencies, + ) + + return handler + + return partial( + self.handler, + command=command, + commands=commands, + name=name, + description=description, + full_description=full_description, + include_in_status=include_in_status, + dependencies=dependencies, + dependency_overrides_provider=dependency_overrides_provider, + ) + + def default( # noqa: WPS211 + self, + handler: Optional[Callable] = None, + *, + command: Optional[str] = None, + commands: Optional[Sequence[str]] = None, + name: Optional[str] = None, + description: Optional[str] = None, + full_description: Optional[str] = None, + include_in_status: Union[bool, Callable] = True, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> Callable: + """Add new handler to bot and register it as default handler. + + !!! info + If `include_in_status` is a function, then `body` argument will be checked + for matching public commands style, like `/command`. + + Arguments: + handler: callable that will be used for executing handler. + command: body template that will trigger this handler. + commands: list of body templates that will trigger this handler. + name: optional name for handler that will be used in generating body. + description: description for command that will be shown in bot's menu. + full_description: full description that can be used for example in `/help` + command. + include_in_status: should this handler be shown in bot's menu, can be + callable function with no arguments *(for now)*. + dependencies: sequence of dependencies that should be executed before + handler. + dependency_overrides_provider: mock of callable for handler. + + Returns: + Passed in `handler` callable. + """ + assert ( + not self.default_message_handler + ), "Default handler is already registered on this collector" + + if handler: + registered_handler = self.handler( + handler=handler, + command=command, + commands=commands, + name=name, + description=description, + full_description=full_description, + include_in_status=include_in_status, + dependencies=dependencies, + dependency_overrides_provider=dependency_overrides_provider, + ) + name = name or utils.get_name_from_callable(registered_handler) + self.default_message_handler = self.handler_for(name) + + return handler + + return partial( + self.default, + command=command, + commands=commands, + name=name, + description=description, + full_description=full_description, + include_in_status=include_in_status, + dependencies=dependencies, + dependency_overrides_provider=dependency_overrides_provider, + ) + + def hidden( # noqa: WPS211 + self, + handler: Optional[Callable] = None, + *, + command: Optional[str] = None, + commands: Optional[Sequence[str]] = None, + name: Optional[str] = None, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> Callable: + """Register hidden handler that won't be showed in menu. + + Arguments: + handler: callable that will be used for executing handler. + command: body template that will trigger this handler. + commands: list of body templates that will trigger this handler. + name: optional name for handler that will be used in generating body. + dependencies: sequence of dependencies that should be executed before + handler. + dependency_overrides_provider: mock of callable for handler. + + Returns: + Passed in `handler` callable. + """ + return self.handler( + handler=handler, + command=command, + commands=commands, + name=name, + include_in_status=False, + dependencies=dependencies, + dependency_overrides_provider=dependency_overrides_provider, + ) + + def system_event( # noqa: WPS211 + self, + handler: Optional[Callable] = None, + *, + event: Optional[enums.SystemEvents] = None, + events: Optional[Sequence[enums.SystemEvents]] = None, + name: Optional[str] = None, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> Callable: + """Register handler for system event. + + Arguments: + handler: callable that will be used for executing handler. + event: event for triggering this handler. + events: a sequence of events that will trigger handler. + name: optional name for handler that will be used in generating body. + dependencies: sequence of dependencies that should be executed before + handler. + dependency_overrides_provider: mock of callable for handler. + + Returns: + Passed in `handler` callable. + """ + assert event or events, "At least one event should be passed" + + return self.handler( + handler=handler, + command=event.value if event else None, + commands=[event.value for event in events] if events else None, + name=name, + include_in_status=False, + dependencies=dependencies, + dependency_overrides_provider=dependency_overrides_provider, + ) + + def chat_created( + self, + handler: Optional[Callable] = None, + *, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> Callable: + """Register handler for `system:chat_created` event. + + Arguments: + handler: callable that will be used for executing handler. + dependencies: sequence of dependencies that should be executed before + handler. + dependency_overrides_provider: mock of callable for handler. + + Returns: + Passed in `handler` callable. + """ + return self.system_event( + handler=handler, + event=enums.SystemEvents.chat_created, + name=enums.SystemEvents.chat_created.value, + dependencies=dependencies, + dependency_overrides_provider=dependency_overrides_provider, + ) + + def file_transfer( + self, + handler: Optional[Callable] = None, + *, + dependencies: Optional[Sequence[deps.Depends]] = None, + dependency_overrides_provider: Any = None, + ) -> Callable: + """Register handler for `file_transfer` event. + + Arguments: + handler: callable that will be used for executing handler. + dependencies: sequence of dependencies that should be executed before + handler. + dependency_overrides_provider: mock of callable for handler. + + Returns: + Passed in `handler` callable. + """ + return self.system_event( + handler=handler, + event=enums.SystemEvents.file_transfer, + name=enums.SystemEvents.file_transfer.value, + dependencies=dependencies, + dependency_overrides_provider=dependency_overrides_provider, + ) + + async def handle_message(self, message: messages.Message) -> None: + """Find handler and execute it. + + Arguments: + message: incoming message that will be passed to handler. + """ + for handler in self.handlers: + if handler.matches(message): + logger.bind(botx_collector=True).info( + f"botx => {handler.name}: {message.command.command}" + ) + await handler(message) + return + + if self.default_message_handler: + await self.default_message_handler(message) + else: + raise NoMatchFound + + async def __call__(self, message: messages.Message) -> None: + """Find handler and execute it. + + Arguments: + message: incoming message that will be passed to handler. + """ + await self.handle_message(message) + + def _add_handlers( + self, + handlers: List[Handler], + dependencies: Optional[Sequence[deps.Depends]] = None, + ) -> None: + """Add list of handlers with dependencies to collector. + + Arguments: + handlers: list of handlers that should be added to this collector. + dependencies: additional dependencies that will be applied for all handlers. + """ + for handler in handlers: + # mypy has problems with generic functions passed to map() + # see mypy#6697 + checked_sequences_dependencies: Iterator[List[deps.Depends]] = map( + utils.optional_sequence_to_list, + (self.dependencies, dependencies, handler.dependencies), + ) + updated_dependencies: List[deps.Depends] = list( + itertools.chain(*list(checked_sequences_dependencies)) + ) + + self.add_handler( + body=handler.body, + handler=handler.handler, + name=handler.name, + description=handler.description, + full_description=handler.full_description, + include_in_status=handler.include_in_status, + dependencies=updated_dependencies, + ) diff --git a/botx/collectors.py b/botx/collectors.py deleted file mode 100644 index b0ee23f6..00000000 --- a/botx/collectors.py +++ /dev/null @@ -1,262 +0,0 @@ -import inspect -import re -from functools import partial -from typing import Any, Callable, Dict, List, Mapping, Optional, Union - -from .core import ( - DEFAULT_HANDLER_BODY, - FILE_HANDLER_NAME, - PRIMITIVE_TYPES, - SYSTEM_FILE_TRANSFER, -) -from .exceptions import BotXException, BotXValidationError -from .models import CommandCallback, CommandHandler, Dependency, SystemEventsEnum - -PARAM_REGEX = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)}") - - -def get_name(handler: Callable) -> str: - if inspect.isfunction(handler) or inspect.isclass(handler): - return handler.__name__ - - return handler.__class__.__name__ - - -def get_regex_for_command( - command: str, params: Dict[str, Union[str, bool, float, int]] -) -> str: - command_regex = "^" - - idx = 0 - for match in PARAM_REGEX.finditer(command): - param_name = match.groups()[0] - - assert param_name in params, f"undefined parameter name {param_name !r}" - - command_regex += command[idx : match.start()] - command_regex += f"(?P<{param_name}>\\w+)" - - idx = match.end() - - command_regex += command[idx:] - - return command_regex - - -def replace_params( - command: str, passed_params: Dict[str, Any], required_params: Dict[str, Any] -) -> str: - filled_params = set() - for key, value in list(passed_params.items()): - if "{" + key + "}" in command: - filled_params.add(key) - command = command.replace("{" + key + "}", str(value)) - passed_params.pop(key) - continue - - raise BotXValidationError(f"unknown passed parameter {key}") - - missed_params = set(required_params).difference(filled_params) - if missed_params: - raise BotXValidationError(f"missed required command params: {missed_params}") - - return command - - -class HandlersCollector: - _handlers: Dict[str, CommandHandler] - dependencies: List[Dependency] = [] - - def __init__(self, dependencies: Optional[List[Callable]] = None) -> None: - self._handlers = {} - - if dependencies: - self.dependencies = [Dependency(call=call) for call in dependencies] - - def command_for(self, command_name: str, **params: Any) -> str: - for handler in self._handlers.values(): - if handler.name == command_name: - return replace_params( - handler.command, params, handler.callback.command_params - ) - else: - raise BotXException(f"handler with name {command_name} does not exist") - - @property - def handlers(self) -> Dict[str, CommandHandler]: - return self._handlers - - def add_handler(self, handler: CommandHandler, force_replace: bool = False) -> None: - if handler.menu_command in self._handlers and not force_replace: - raise BotXException( - f"can not add 2 handlers for {handler.menu_command !r} command" - ) - - self._handlers[handler.menu_command] = handler - - def include_handlers( - self, collector: "HandlersCollector", force_replace: bool = False - ) -> None: - for handler in collector.handlers.values(): - handler.callback.background_dependencies = ( - self.dependencies + handler.callback.background_dependencies - ) - self.add_handler(handler, force_replace=force_replace) - - def handler( - self, - callback: Optional[Callable] = None, - *, - name: Optional[str] = None, - description: Optional[str] = None, - full_description: Optional[str] = None, - command: Optional[str] = None, - commands: Optional[List[str]] = None, - use_as_default_handler: Optional[bool] = False, - exclude_from_status: Optional[bool] = False, - dependencies: Optional[List[Callable]] = None, - ) -> Callable: - if callback: - assert inspect.isfunction(callback) or inspect.ismethod( - callback - ), "A callback must be function or method" - - transformed_dependencies = ( - [Dependency(call=dep) for dep in dependencies] if dependencies else [] - ) - - if not commands: - commands = [command or ""] - elif command: - commands.append(command) - - for command in commands: - command_name = name or get_name(callback) - - if not command: - command_body = (name or get_name(callback)).replace("_", "-") - command = command_body - - if not (exclude_from_status or use_as_default_handler): - command = "/" + command.strip("/") - - description = description or inspect.cleandoc(callback.__doc__ or "") - full_description = full_description or description - - signature: inspect.Signature = inspect.signature(callback) - params: Mapping[str, inspect.Parameter] = signature.parameters - handler_params = {} - for param_name, param in params.items(): - if param.annotation in PRIMITIVE_TYPES: - handler_params[param_name] = param.annotation - - regex = get_regex_for_command(command, handler_params) - menu_command = command.split(" ", 1)[0] - - handler = CommandHandler( - regex_command=regex, - command=command, - menu_command=menu_command, - callback=CommandCallback( - callback=callback, - background_dependencies=( - self.dependencies + transformed_dependencies - ), - command_params=handler_params, - ), - name=command_name, - description=description, - full_description=full_description, - exclude_from_status=exclude_from_status, - use_as_default_handler=use_as_default_handler, - ) - - self.add_handler(handler) - - return callback - - return partial( - self.handler, - name=name, - description=description, - full_description=full_description, - command=command, - commands=commands, - exclude_from_status=exclude_from_status, - use_as_default_handler=use_as_default_handler, - dependencies=dependencies, - ) - - def hidden_command_handler( - self, - callback: Optional[Callable] = None, - *, - name: Optional[str] = None, - command: Optional[str] = None, - commands: Optional[List[str]] = None, - dependencies: Optional[List[Callable]] = None, - ) -> Callable: - return self.handler( - callback=callback, - name=name, - command=command, - commands=commands, - exclude_from_status=True, - dependencies=dependencies, - ) - - def file_handler( - self, - callback: Optional[Callable] = None, - *, - dependencies: Optional[List[Callable]] = None, - ) -> Callable: - return self.handler( - callback=callback, - name=FILE_HANDLER_NAME, - command=SYSTEM_FILE_TRANSFER, - exclude_from_status=True, - dependencies=dependencies, - ) - - def default_handler( - self, - callback: Optional[Callable] = None, - *, - dependencies: Optional[List[Callable]] = None, - ) -> Callable: - return self.handler( - callback=callback, - command=DEFAULT_HANDLER_BODY, - use_as_default_handler=True, - dependencies=dependencies, - ) - - def system_event_handler( - self, - callback: Optional[Callable] = None, - *, - event: Union[str, SystemEventsEnum], - name: Optional[str] = None, - dependencies: Optional[List[Callable]] = None, - ) -> Callable: - if isinstance(event, SystemEventsEnum): - command = event.value - else: - command = event - - return self.hidden_command_handler( - callback=callback, name=name, command=command, dependencies=dependencies - ) - - def chat_created_handler( - self, - callback: Optional[Callable] = None, - *, - dependencies: Optional[List[Callable]] = None, - ) -> Callable: - return self.system_event_handler( - callback=callback, - event=SystemEventsEnum.chat_created, - dependencies=dependencies, - ) diff --git a/botx/concurrency.py b/botx/concurrency.py new file mode 100644 index 00000000..55954c1f --- /dev/null +++ b/botx/concurrency.py @@ -0,0 +1,68 @@ +"""Helpers for execution functions as coroutines.""" + +import asyncio +import functools +import inspect +from typing import Any, Callable, Coroutine + +try: + import contextvars # Python 3.7+ only. # noqa: WPS440, WPS433 +except ImportError: # pragma: no cover + contextvars = None # type: ignore # noqa: WPS440 + + +async def run_in_threadpool(call: Callable, *args: Any, **kwargs: Any) -> Any: + """Run regular function (not a coroutine) as awaitable coroutine. + + Arguments: + call: function that should be called as coroutine. + args: positional arguments for the function. + kwargs: keyword arguments for the function. + + Returns: + Result of function call. + """ + loop = asyncio.get_event_loop() + if contextvars is not None: # pragma: no cover + # Ensure we run in the same context + child = functools.partial(call, *args, **kwargs) + context = contextvars.copy_context() + call = context.run + args = (child,) + elif kwargs: # pragma: no cover + # loop.run_in_executor doesn't accept 'kwargs', so bind them in here + call = functools.partial(call, **kwargs) + return await loop.run_in_executor(None, call, *args) + + +def is_coroutine_callable(call: Callable) -> bool: + """Check if object is a coroutine or an object which __call__ method is coroutine. + + Arguments: + call: callable for checking. + + Returns: + Result of check. + """ + if inspect.isfunction(call) or inspect.ismethod(call): + return asyncio.iscoroutinefunction(call) + call = getattr(call, "__call__", None) # noqa: B004 + return asyncio.iscoroutinefunction(call) + + +def callable_to_coroutine(func: Callable, *args: Any, **kwargs: Any) -> Coroutine: + """Transform callable to coroutine. + + Arguments: + func: function that can be sync or async and should be transformed into + corouine. + args: positional arguments for this function. + kwargs: key arguments for this function. + + Returns: + Coroutine object from passed callable. + """ + if is_coroutine_callable(func): + return func(*args, **kwargs) + + return run_in_threadpool(func, *args, **kwargs) diff --git a/botx/core.py b/botx/core.py deleted file mode 100644 index 9c985b51..00000000 --- a/botx/core.py +++ /dev/null @@ -1,35 +0,0 @@ -from dataclasses import dataclass - -SYSTEM_FILE_TRANSFER = "file_transfer" -FILE_HANDLER_NAME = "file_receiver" - -DEFAULT_HANDLER_BODY = "DEFAULT_HANDLER" - -PRIMITIVE_TYPES = {bool, int, float, str} -TEXT_MAX_LENGTH = 4096 - - -@dataclass -class BotXEndpoint: - method: str - url: str - - -class BotXAPI: - class V1: - file: BotXEndpoint = BotXEndpoint( - method="POST", url="https://{host}/api/v1/botx/file/callback" - ) - - class V2: - token: BotXEndpoint = BotXEndpoint( - method="GET", url="https://{host}/api/v2/botx/bots/{bot_id}/token" - ) - - class V3: - command: BotXEndpoint = BotXEndpoint( - method="POST", url="https://{host}/api/v3/botx/command/callback" - ) - notification: BotXEndpoint = BotXEndpoint( - method="POST", url="https://{host}/api/v3/botx/notification/callback" - ) diff --git a/botx/dependencies.py b/botx/dependencies.py deleted file mode 100644 index 3b97ef2f..00000000 --- a/botx/dependencies.py +++ /dev/null @@ -1,34 +0,0 @@ -import inspect -from typing import TYPE_CHECKING, Any, Callable, Dict - -from .helpers import call_function_as_coroutine -from .models import Dependency, Message - -if TYPE_CHECKING: # pragma: no cover - from .bots import BaseBot - - -def Depends(dependency: Callable) -> Any: # noqa: N802 - return Dependency(call=dependency) - - -async def solve_dependencies( - message: Message, bot: "BaseBot", dependency: Dependency -) -> Dict[str, Any]: - from .bots import BaseBot - - sig = inspect.signature(dependency.call) - dep_params: Dict[str, Any] = {} - for param in sig.parameters.values(): - if issubclass(param.annotation, Message): - dep_params[param.name] = message - elif issubclass(param.annotation, BaseBot): - dep_params[param.name] = bot - elif isinstance(param.default, Dependency): - sub_dep_call = param.default.call - sub_dep_dependencies = await solve_dependencies(message, bot, param.default) - dep_params[param.name] = await call_function_as_coroutine( - sub_dep_call, **sub_dep_dependencies - ) - - return dep_params diff --git a/botx/dependencies/__init__.py b/botx/dependencies/__init__.py new file mode 100644 index 00000000..5578cd4a --- /dev/null +++ b/botx/dependencies/__init__.py @@ -0,0 +1 @@ +"""Dependency injection implementation.""" diff --git a/botx/dependencies/inspecting.py b/botx/dependencies/inspecting.py new file mode 100644 index 00000000..c152cc81 --- /dev/null +++ b/botx/dependencies/inspecting.py @@ -0,0 +1,50 @@ +"""Functions for inspecting signatures and parameters.""" + +import inspect +from typing import Any, Callable, Dict + +from pydantic.typing import ForwardRef, evaluate_forwardref + + +def get_typed_signature(call: Callable) -> inspect.Signature: + """Get signature for callable function with solving possible annotations. + + Arguments: + call: callable object that will be used to get signature with annotations. + + Returns: + Callable signature obtained. + """ + signature = inspect.signature(call) + global_namespace = getattr(call, "__globals__", {}) + typed_params = [ + inspect.Parameter( + name=param.name, + kind=param.kind, + default=param.default, + annotation=get_typed_annotation(param, global_namespace), + ) + for param in signature.parameters.values() + ] + return inspect.Signature(typed_params) + + +def get_typed_annotation( + param: inspect.Parameter, global_namespace: Dict[str, Any] +) -> Any: + """Solve forward reference annotation for instance of `inspect.Parameter`. + + Arguments: + param: instance of `inspect.Parameter` for which possible forward annotation + will be evaluated. + global_namespace: dictionary of entities that can be used for evaluating + forward references. + + Returns: + Parameter annotation. + """ + annotation = param.annotation + if isinstance(annotation, str): + annotation = ForwardRef(annotation) + annotation = evaluate_forwardref(annotation, global_namespace, global_namespace) + return annotation diff --git a/botx/dependencies/models.py b/botx/dependencies/models.py new file mode 100644 index 00000000..35fb451e --- /dev/null +++ b/botx/dependencies/models.py @@ -0,0 +1,145 @@ +"""Dependant model and transforming functions.""" + +import inspect +from typing import Callable, List, Optional, Tuple + +from pydantic import BaseModel, validator +from pydantic.utils import lenient_issubclass + +from botx import bots +from botx.clients import AsyncClient, Client +from botx.dependencies import inspecting +from botx.models.messages import Message + + +class Depends: + """Stores dependency callable.""" + + def __init__(self, dependency: Callable, *, use_cache: bool = True) -> None: + """Store callable for dependency, if None then will be retrieved from signature. + + Arguments: + dependency: callable object that will be used in handlers or other + dependencies instances. + use_cache: use cache for dependency. + """ + self.dependency = dependency + self.use_cache = use_cache + + +DependantCache = Tuple[Optional[Callable], Tuple[str, ...]] + + +class Dependant(BaseModel): # noqa: WPS230 + """Main model that contains all necessary data for solving dependencies.""" + + dependencies: List["Dependant"] = [] + """list of sub-dependencies for this dependency.""" + name: Optional[str] = None + """name of dependency.""" + call: Optional[Callable] = None + """callable object that will solve dependency.""" + message_param_name: Optional[str] = None + """param name for passing incoming [message][botx.models.messages.Message].""" + bot_param_name: Optional[str] = None + """param name for passing [bot][botx.bots.Bot] that handles command.""" + async_client_param_name: Optional[str] = None + """param name for passing [client][botx.clients.AsyncClient] for sending requests + manually from async handlers.""" + sync_client_param_name: Optional[str] = None + use_cache: bool = True + """use cache for optimize solving performance.""" + + # Save the cache key at creation to optimize performance + cache_key: DependantCache = (None, ()) + """Storage for cache.""" + + @validator("cache_key", always=True) + def init_cache( + cls, _: DependantCache, values: dict # noqa: N805 + ) -> DependantCache: + """Init cache for dependency with passed call and empty tuple. + + Arguments: + _: init value for cache. Mainly won't be used in initialization. + values: already validated values. + + Returns: + Cache for callable. + """ + return values["call"], tuple((set())) + + +Dependant.update_forward_refs() + + +def get_param_sub_dependant(*, param: inspect.Parameter) -> Dependant: + """Parse instance of parameter to get it as dependency. + + Arguments: + param: param for which sub dependency should be retrieved. + + Returns: + Object that will be used in solving dependency. + """ + depends: Depends = param.default + dependency = depends.dependency + + return get_dependant(call=dependency, name=param.name, use_cache=depends.use_cache) + + +def get_dependant( + *, call: Callable, name: Optional[str] = None, use_cache: bool = True +) -> Dependant: + """Get dependant instance from passed callable object. + + Arguments: + call: callable object that will be parsed to get required parameters and + sub dependencies. + name: name for dependency. + use_cache: use cache for optimize solving performance. + + Returns: + Object that will be used in solving dependency. + """ + dependant = Dependant(call=call, name=name, use_cache=use_cache) + for param in inspecting.get_typed_signature(call).parameters.values(): + if isinstance(param.default, Depends): + dependant.dependencies.append(get_param_sub_dependant(param=param)) + continue + if add_special_param_to_dependency(param=param, dependant=dependant): + continue + + raise ValueError( + f"Param {param.name} can only be a dependency, message, bot or client" + ) + + return dependant + + +def add_special_param_to_dependency( + *, param: inspect.Parameter, dependant: Dependant +) -> bool: + """Check if param is non field object that should be passed into callable. + + Arguments: + param: param that should be checked. + dependant: dependency which field would be filled with required param name. + + Returns: + Result of check. + """ + if lenient_issubclass(param.annotation, bots.Bot): + dependant.bot_param_name = param.name + return True + elif lenient_issubclass(param.annotation, Message): + dependant.message_param_name = param.name + return True + elif lenient_issubclass(param.annotation, AsyncClient): + dependant.async_client_param_name = param.name + return True + elif lenient_issubclass(param.annotation, Client): + dependant.sync_client_param_name = param.name + return True + + return False diff --git a/botx/dependencies/solving.py b/botx/dependencies/solving.py new file mode 100644 index 00000000..8692b1ad --- /dev/null +++ b/botx/dependencies/solving.py @@ -0,0 +1,99 @@ +"""Functions for solving dependencies.""" + +from collections import Callable +from typing import Any, Dict, Optional, Tuple, cast + +from botx import concurrency +from botx.dependencies.models import Dependant, get_dependant +from botx.models.messages import Message + +CacheKey = Tuple[Callable, Tuple[str, ...]] +DependenciesCache = Dict[CacheKey, Any] + + +async def solve_sub_dependency( + message: Message, + dependant: Dependant, + values: Dict[str, Any], + dependency_overrides_provider: Any, + dependency_cache: Dict[CacheKey, Any], +) -> None: + """Solve single sub dependency. + + Arguments: + message: incoming message that is used for solving this sub dependency. + dependant: dependency that is solving while calling this function. + values: already filled values that are required for this dependency. + dependency_overrides_provider: an object with `dependency_overrides` attribute + that contains overrides for dependencies. + dependency_cache: cache that contains already solved dependency and result for + it. + """ + call = cast(Callable, dependant.call) + use_sub_dependant = dependant + + overrides = getattr(dependency_overrides_provider, "dependency_overrides", {}) + if overrides: + call = overrides.get(dependant.call, dependant.call) + use_sub_dependant = get_dependant(call=call, name=dependant.name) + + sub_values, sub_dependency_cache = await solve_dependencies( + message=message, + dependant=use_sub_dependant, + dependency_overrides_provider=dependency_overrides_provider, + dependency_cache=dependency_cache, + ) + dependency_cache.update(sub_dependency_cache) + + dependant.cache_key = cast(CacheKey, dependant.cache_key) + if dependant.use_cache and dependant.cache_key in dependency_cache: + solved = dependency_cache[dependant.cache_key] + else: + solved = await concurrency.callable_to_coroutine(call, **sub_values) + + if dependant.name is not None: + values[dependant.name] = solved + if dependant.cache_key not in dependency_cache: + dependency_cache[dependant.cache_key] = solved + + +async def solve_dependencies( + *, + message: Message, + dependant: Dependant, + dependency_overrides_provider: Any = None, + dependency_cache: Optional[Dict[CacheKey, Any]] = None, +) -> Tuple[Dict[str, Any], DependenciesCache]: + """Resolve all required dependencies for Dependant using incoming message. + + Arguments: + message: incoming Message with all necessary data. + dependant: Dependant object for which all sub dependencies should be solved. + dependency_overrides_provider: an object with `dependency_overrides` attribute + that contains overrides for dependencies. + dependency_cache: cache that contains already solved dependency and result for + it. + + Returns: + Keyword arguments with their vales and cache. + """ + values: Dict[str, Any] = {} + dependency_cache = dependency_cache or {} + for sub_dependant in dependant.dependencies: + await solve_sub_dependency( + message=message, + dependant=sub_dependant, + values=values, + dependency_overrides_provider=dependency_overrides_provider, + dependency_cache=dependency_cache, + ) + + if dependant.message_param_name: + values[dependant.message_param_name] = message + if dependant.bot_param_name: + values[dependant.bot_param_name] = message.bot + if dependant.async_client_param_name: + values[dependant.async_client_param_name] = message.bot.client + if dependant.sync_client_param_name: + values[dependant.sync_client_param_name] = message.bot.sync_client + return values, dependency_cache diff --git a/botx/dispatchers.py b/botx/dispatchers.py deleted file mode 100644 index 2234e8e4..00000000 --- a/botx/dispatchers.py +++ /dev/null @@ -1,190 +0,0 @@ -import abc -import asyncio -from collections import OrderedDict -from typing import ( - Any, - Awaitable, - Callable, - Dict, - List, - Optional, - Set, - Tuple, - Type, - Union, -) -from uuid import UUID - -from loguru import logger - -from .exceptions import BotXException -from .execution import execute_callback_with_exception_catching -from .helpers import create_message -from .models import CommandCallback, CommandHandler, Message, Status, StatusResult - -logger_ctx = logger.bind(botx_dispatcher=True) - - -class BaseDispatcher(abc.ABC): - _handlers: Dict[str, CommandHandler] - _next_step_handlers: Dict[ - Tuple[str, UUID, UUID, Optional[UUID]], List[CommandCallback] - ] - _default_handler: Optional[CommandHandler] = None - _exceptions_map: Dict[Type[Exception], Callable] - - def __init__(self) -> None: - self._handlers = OrderedDict() - self._next_step_handlers = {} - self._exceptions_map = {} - - def start(self) -> Optional[Awaitable[None]]: - """Start dispatcher-related things""" - - def shutdown(self) -> Optional[Awaitable[None]]: - """Stop dispatcher-related things like thread or coroutine joining""" - - @property - def exception_catchers(self) -> Dict[Type[Exception], Callable]: - return self._exceptions_map - - @abc.abstractmethod - def status(self) -> Union[Status, Awaitable[Status]]: - """Return Status object to be displayed in status menu""" - - @abc.abstractmethod - def execute_command(self, data: Dict[str, Any]) -> Optional[Awaitable[None]]: - """Parse request and call status creation or executing handler for handler""" - - def add_handler(self, handler: CommandHandler) -> None: - if handler.use_as_default_handler: - logger.debug("registered default handler") - - self._default_handler = handler - else: - logger.debug(f"registered handler => {handler.command !r}") - - self._handlers[handler.command] = handler - - def register_next_step_handler( - self, message: Message, callback: CommandCallback - ) -> None: - self._add_next_step_handler(message, callback) - - def register_exception_catcher( - self, exc: Type[Exception], callback: Callable, force_replace: bool = False - ) -> None: - if exc in self._exceptions_map and not force_replace: - raise BotXException(f"catcher for {exc} was already registered") - - self._exceptions_map[exc] = callback - logger_ctx.bind(exc_type=exc).debug("registered new exception catcher") - - def _add_next_step_handler( - self, message: Message, callback: CommandCallback - ) -> None: - key = (message.host, message.bot_id, message.group_chat_id, message.user_huid) - logger_ctx.bind(message=message).debug( - "registered next step handler => " - "host: {0}; bot_id: {1}, group_chat_id: {2}, user_huid: {3}", - *key, - ) - if key in self._next_step_handlers: - self._next_step_handlers[key].append(callback) - else: - self._next_step_handlers[key] = [callback] - - def _get_callback_for_message(self, message: Message) -> CommandCallback: - try: - callback = self._get_next_step_handler_from_message(message) - logger.bind(message=message).info( - "next step handler => " - "host: {0}; bot_id: {1}, group_chat_id: {2}, user_huid: {3}", - message.host, - message.bot_id, - message.group_chat_id, - message.user_huid, - ) - except (IndexError, KeyError): - handler = self._get_command_handler_from_message(message) - callback = handler.callback - logger.bind(message=message).info(f"handler => {handler.command !r}") - - return callback - - def _get_next_step_handler_from_message(self, message: Message) -> CommandCallback: - return self._next_step_handlers[ - (message.host, message.bot_id, message.group_chat_id, message.user_huid) - ].pop() - - def _get_command_handler_from_message(self, message: Message) -> CommandHandler: - body = message.command.body - - for handler in self._handlers.values(): - match = handler.regex_command.match(body) - if match: - matched_params = match.groupdict() - for key, value in list(matched_params.items()): - matched_params[key] = handler.callback.command_params[key](value) - - handler_copy = handler.copy() - handler_copy.callback = handler.callback.copy( - update={ - "kwargs": {**matched_params, **handler_copy.callback.kwargs} - } - ) - - return handler - else: - if self._default_handler: - return self._default_handler - - raise BotXException( - "unhandled command with missing handler", data={"handler": message.body} - ) - - def _get_callback_copy_for_message_data( - self, message_data: Dict[str, Any] - ) -> CommandCallback: - message = create_message(message_data) - - callback = self._get_callback_for_message(message) - callback_copy = callback.copy(update={"args": (message,) + callback.args}) - - return callback_copy - - -class AsyncDispatcher(BaseDispatcher): - _tasks: Set[asyncio.Future] - - def __init__(self) -> None: - super().__init__() - self._tasks = set() - - async def start(self) -> None: - pass - - async def shutdown(self) -> None: - if self._tasks: - await asyncio.wait(self._tasks, return_when=asyncio.ALL_COMPLETED) - - self._tasks = set() - - async def status(self) -> Status: - commands = [] - for _, handler in self._handlers.items(): - menu_command = handler.to_status_command() - if menu_command: - commands.append(menu_command) - - return Status(result=StatusResult(commands=commands)) - - async def execute_command(self, data: Dict[str, Any]) -> None: - self._tasks.add( - asyncio.ensure_future( - execute_callback_with_exception_catching( - self.exception_catchers, - self._get_callback_copy_for_message_data(data), - ) - ) - ) diff --git a/botx/exception_handlers.py b/botx/exception_handlers.py new file mode 100644 index 00000000..479ff55a --- /dev/null +++ b/botx/exception_handlers.py @@ -0,0 +1,23 @@ +"""Define several handlers for builtin exceptions from this library.""" + +from typing import Any + +from loguru import logger + +from botx.models import messages + + +async def dependency_failure_exception_handler(*_: Any) -> None: + """Just do nothing if there is this error, since it's just a signal for stop.""" + + +async def no_match_found_exception_handler( + _: Exception, message: messages.Message +) -> None: + """Log that handler was not found. + + Arguments: + _: raised exception, that is useless, since it is global handler. + message: message on which processing error was raised. + """ + logger.info("handler for {0} was not found", message.body) diff --git a/botx/exceptions.py b/botx/exceptions.py index 77309f98..b9069600 100644 --- a/botx/exceptions.py +++ b/botx/exceptions.py @@ -1,29 +1,36 @@ -import json -from typing import Any, Dict, Optional +"""Exceptions that are used in this library.""" +from typing import Any +# All inits here are required for auto docs -class BotXException(Exception): - def __init__(self, message: str = "", data: Optional[Dict[str, Any]] = None): - self.message = message - self.data = data - msg = "" +class NoMatchFound(Exception): + """Raised by collector if no matching handler exists.""" - if message: - msg = f"\n[msg] -> {message}" - if data: - msg += f"\n[data] -> {json.dumps(data, indent=4)}" + def __init__(self, *args: Any) -> None: + """Init NoMatchFound exception.""" + super().__init__(*args) - super().__init__(msg) +class DependencyFailure(Exception): + """Raised when there is error in dependency and flow should be stopped.""" -class BotXAPIException(BotXException): - pass + def __init__(self, *args: Any) -> None: + """Init DependencyFailure exception.""" + super().__init__(*args) -class BotXDependencyFailure(BotXException): - pass +class BotXAPIError(Exception): + """Raised if there is an error in requests to BotX API.""" + def __init__(self, *args: Any) -> None: + """Init BotXAPIError exception.""" + super().__init__(*args) -class BotXValidationError(BotXException): - pass + +class ServerUnknownError(Exception): + """Raised if bot does not know host.""" + + def __init__(self, *args: Any) -> None: + """Init ServerUnknownError exception.""" + super().__init__(*args) diff --git a/botx/execution.py b/botx/execution.py deleted file mode 100644 index b6b68706..00000000 --- a/botx/execution.py +++ /dev/null @@ -1,71 +0,0 @@ -from typing import TYPE_CHECKING, Callable, Dict, List, Type, cast - -from loguru import logger - -from .dependencies import solve_dependencies -from .exceptions import BotXDependencyFailure -from .helpers import call_function_as_coroutine -from .models import CommandCallback, Dependency, Message - -if TYPE_CHECKING: # pragma: no cover - from .bots import BaseBot - - -async def _handle_exception( - exceptions_map: Dict[Type[Exception], Callable], - exc: Exception, - message: Message, - bot: "BaseBot", -) -> bool: - for cls in cast(List[Type[Exception]], type(exc).mro()): - if cls in exceptions_map: - exc_catcher = exceptions_map[cls] - - try: - await call_function_as_coroutine(exc_catcher, exc, message, bot) - return True - except Exception as catcher_exc: - exceptions_map = exceptions_map.copy() - exceptions_map.pop(cls) - catching_basic_exc_res = await _handle_exception( - exceptions_map, exc, message, bot - ) - catching_catcher_exc_res = await _handle_exception( - exceptions_map, catcher_exc, message, bot - ) - if catching_basic_exc_res and catching_catcher_exc_res: - return True - logger.exception( - f"uncaught exception {catcher_exc !r} during catching {exc !r}" - ) - - return False - - -async def execute_callback_with_exception_catching( - exceptions_map: Dict[Type[Exception], Callable], callback: CommandCallback -) -> None: - message, bot = callback.args[:2] - callback.args = callback.args[2:] - - try: - for dep in callback.background_dependencies: - dep_deps = await solve_dependencies(message, bot, dep) - await call_function_as_coroutine(dep.call, **dep_deps) - - callback_deps = await solve_dependencies( - message, bot, Dependency(call=callback.callback) - ) - await call_function_as_coroutine( - callback.callback, *callback.args, **callback.kwargs, **callback_deps - ) - except Exception as exc: - is_dependency_failure = isinstance(exc, BotXDependencyFailure) - is_dep_failure_handled = BotXDependencyFailure not in exceptions_map - - if is_dependency_failure and is_dep_failure_handled: - return - - catcher_res = await _handle_exception(exceptions_map, exc, message, bot) - if not catcher_res: - logger.exception(f"uncaught exception {exc !r}") diff --git a/botx/helpers.py b/botx/helpers.py deleted file mode 100644 index dab1fb56..00000000 --- a/botx/helpers.py +++ /dev/null @@ -1,95 +0,0 @@ -import asyncio -import functools -import inspect -import json -import typing -from typing import Any, Callable, Dict, cast - -from httpx.models import BaseResponse -from pydantic import ValidationError - -from .exceptions import BotXValidationError -from .models import ( - BotXAPIErrorData, - ChatCreatedData, - CommandTypeEnum, - ErrorResponseData, - Message, - SendingCredentials, - SystemEventsEnum, -) - -try: - import contextvars # Python 3.7+ only. -except ImportError: # pragma: no cover - contextvars = None # type: ignore - - -def create_message(data: Dict[str, Any]) -> Message: - try: - message = Message(**data) - - if message.command.command_type == CommandTypeEnum.system: - if message.body == SystemEventsEnum.chat_created.value: - message.command.data = ChatCreatedData( - **cast(Dict[str, Any], message.command.data) - ) - - return message - except ValidationError as exc: - raise BotXValidationError from exc - - -def get_data_for_api_error( - address: SendingCredentials, response: BaseResponse -) -> Dict[str, Any]: - error_data = BotXAPIErrorData( - address=address, - response=ErrorResponseData( - status_code=response.status_code, body=response.text - ), - ) - - return json.loads(error_data.json()) - - -async def run_in_threadpool(func: Callable, *args: Any, **kwargs: Any) -> typing.Any: - loop = asyncio.get_event_loop() - if contextvars is not None: # pragma: no cover - child = functools.partial(func, *args, **kwargs) - context = contextvars.copy_context() - func = context.run - args = (child,) - elif kwargs: # pragma: no cover - func = functools.partial(func, **kwargs) - return await loop.run_in_executor(None, func, *args) - - -def is_coroutine_callable(call: Callable) -> bool: - if inspect.isfunction(call): - return asyncio.iscoroutinefunction(call) - if inspect.isclass(call): - return False - call = getattr(call, "__call__", None) # noqa: B004 - return asyncio.iscoroutinefunction(call) - - -async def call_function_as_coroutine(func: Callable, *args: Any, **kwargs: Any) -> Any: - if is_coroutine_callable(func): - return await func(*args, **kwargs) - else: - return await run_in_threadpool(func, *args, **kwargs) - - -def call_coroutine_as_function(func: Callable, *args: Any, **kwargs: Any) -> Any: - coro = func(*args, **kwargs) - - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - - if loop.is_running(): - return coro - - return loop.run_until_complete(coro) diff --git a/botx/middlewares/__init__.py b/botx/middlewares/__init__.py new file mode 100644 index 00000000..42632b0a --- /dev/null +++ b/botx/middlewares/__init__.py @@ -0,0 +1 @@ +"""Definition ob built-in middlewares for botx.""" diff --git a/botx/middlewares/base.py b/botx/middlewares/base.py new file mode 100644 index 00000000..05d4c01b --- /dev/null +++ b/botx/middlewares/base.py @@ -0,0 +1,43 @@ +"""Definition of base for custom middlewares.""" + +from typing import Optional + +from botx import concurrency +from botx.models import messages +from botx.typing import Executor, MiddlewareDispatcher + + +class BaseMiddleware: + """Base middleware entity.""" + + def __init__( + self, executor: Executor, dispatch: Optional[MiddlewareDispatcher] = None + ) -> None: + """Init middleware with required params. + + Arguments: + executor: callable object that accept message and will be executed after + middlewares. + dispatch: middleware logic executor. + """ + self.executor = executor + self.dispatch_func = dispatch or self.dispatch + + async def __call__(self, message: messages.Message) -> None: + """Call middleware dispatcher as normal handler executor. + + Arguments: + message: incoming message. + """ + await concurrency.callable_to_coroutine( + self.dispatch_func, message, self.executor + ) + + async def dispatch(self, message: messages.Message, call_next: Executor) -> None: + """Execute middleware logic. + + Arguments: + message: incoming message. + call_next: next executor in middleware chain. + """ + raise NotImplementedError() diff --git a/botx/middlewares/exceptions.py b/botx/middlewares/exceptions.py new file mode 100644 index 00000000..8b145f8f --- /dev/null +++ b/botx/middlewares/exceptions.py @@ -0,0 +1,82 @@ +"""Definition of base middleware class and some default middlewares.""" + +from typing import Callable, Dict, Optional, Type + +from loguru import logger + +from botx import concurrency +from botx.models import messages +from botx.typing import Executor +from botx.utils import LogsShapeBuilder + + +class ExceptionMiddleware: + """Custom middleware that is default and used to handle registered errors.""" + + def __init__(self, executor: Executor) -> None: + """Init middleware with required params. + + Arguments: + executor: callable object that accept message and will be executed after + middleware. + """ + self.executor = executor + self._exception_handlers: Dict[Type[Exception], Callable] = {} + + async def __call__(self, message: messages.Message) -> None: + """Wrap executor for catching exception or log them. + + Arguments: + message: incoming message that will be passed to executor. + """ + try: + await concurrency.callable_to_coroutine(self.executor, message) + except Exception as exc: + await self._handle_error_in_handler(exc, message) + else: + return + + def add_exception_handler( + self, exc_class: Type[Exception], handler: Callable + ) -> None: + """Register handler for specific exception in middleware. + + Arguments: + exc_class: exception class that should be handled by middleware. + handler: handler for exception. + """ + self._exception_handlers[exc_class] = handler + + def _lookup_handler_for_exception(self, exc: Exception) -> Optional[Callable]: + """Find handler for exception. + + Arguments: + exc: catched exception for which handler should be found. + + Returns: + Found handler or None. + """ + for exc_cls in type(exc).mro(): + handler = self._exception_handlers.get(exc_cls) + if handler: + return handler + return None + + async def _handle_error_in_handler( + self, exc: Exception, message: messages.Message + ) -> None: + """Pass error back to handler if there is one or log error. + + Arguments: + exc: exception that occured. + message: message on which exception occurred. + """ + handler = self._lookup_handler_for_exception(exc) + + if handler is None: + logger.bind( + botx_error=True, payload=LogsShapeBuilder.get_message_shape(message) + ).error("uncaught {0} exception {1}", type(exc).__name__, exc) + return + + await concurrency.callable_to_coroutine(handler, exc, message) diff --git a/botx/middlewares/ns.py b/botx/middlewares/ns.py new file mode 100644 index 00000000..77dcb4c4 --- /dev/null +++ b/botx/middlewares/ns.py @@ -0,0 +1,208 @@ +"""Definition for middleware that precess next step handlers logic.""" + +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union +from uuid import UUID + +from loguru import logger + +from botx import bots +from botx.collecting import Handler +from botx.concurrency import callable_to_coroutine +from botx.exceptions import NoMatchFound +from botx.middlewares.base import BaseMiddleware +from botx.models import messages +from botx.typing import Executor +from botx.utils import get_name_from_callable + + +class NextStepMiddleware(BaseMiddleware): + """ + Naive next step handlers middleware. May be useful in simple apps or as base. + + Important: + This middleware should be the last included into bot, since it will break + execution if right handler will be found. + + """ + + def __init__( + self, + executor: Executor, + bot: bots.Bot, + functions: Union[Dict[str, Callable], Sequence[Callable]], + break_handler: Optional[Union[Handler, str]] = None, + ) -> None: + """Init middleware with required params. + + Arguments: + executor: next callable that should be executed. + bot: bot that will store ns state. + functions: dict of functions and their names that will be used as next step + handlers or set of sequence of functions that will be registered by + their names. + break_handler: handler instance or name of handler that will break next step + handlers chain. + """ + super().__init__(executor) + self.break_handler: Optional[Handler] = None + """Handler that will be used if there is a break chain message.""" + if break_handler: + self.break_handler = ( + break_handler + if isinstance(break_handler, Handler) + else bot.handler_for(break_handler) + ) + bot.state.ns_storage = {} + bot.state.ns_handlers = {} + bot.state.ns_arguments = {} + if isinstance(functions, dict): + functions_dict = functions + else: + functions_dict = {get_name_from_callable(func): func for func in functions} + + for name, function in functions_dict.items(): + register_function_as_ns_handler(bot, function, name) + + async def dispatch(self, message: messages.Message, call_next: Executor) -> None: + """Execute middleware logic. + + Arguments: + message: incoming message. + call_next: next executor in middleware chain. + """ + if self.break_handler and self.break_handler.matches(message): + await self.drop_next_step_handlers_chain(message) + await self.break_handler(message) + return + + try: + next_handler = await self.lookup_next_handler_for_message(message) + except (NoMatchFound, IndexError, KeyError, RuntimeError): + await callable_to_coroutine(call_next, message) + return + + key = get_chain_key_by_message(message) + logger.bind(botx_ns_middleware=True, payload={"next_step_key": key}).info( + "botx: found next step handler" + ) + + arguments_lists = message.bot.state.ns_arguments[key] + arguments = arguments_lists.pop() + for argument, argument_value in arguments.items(): + setattr(message.state, argument, argument_value) + await next_handler(message) + + async def lookup_next_handler_for_message( + self, message: messages.Message + ) -> Handler: + """Find handler in bot storage or in handlers. + + Arguments: + message: message for which next step handler should be found. + + Returns: + Found handler. + """ + handlers: List[str] = message.bot.state.ns_storage[ + get_chain_key_by_message(message) + ] + handler_name = handlers.pop() + try: + return message.bot.state.ns_handlers[handler_name] + except KeyError: + return message.bot.handler_for(handler_name) + + async def drop_next_step_handlers_chain(self, message: messages.Message) -> None: + """Drop registered chain for message. + + Arguments: + message: message for which chain should be dropped. + """ + message.bot.state.ns_storage.pop(get_chain_key_by_message(message)) + + +def get_chain_key_by_message(message: messages.Message) -> Tuple[str, UUID, UUID, UUID]: + """Generate key for next step handlers chain from message. + + Arguments: + message: message from which key should be generated. + + Returns: + Key using which handler should be found. + """ + # key is a tuple of (host, bot_id, chat_id, user_huid) + if message.user_huid is None: + raise RuntimeError("Key for chain can be obtained only for messages from users") + return message.host, message.bot_id, message.group_chat_id, message.user_huid + + +def register_function_as_ns_handler( + bot: bots.Bot, func: Callable, name: Optional[str] = None +) -> None: + """Register new function that can be called as next step handler. + + !!! warning + This functions should not be called to dynamically register new functions in + handlers or elsewhere, since state on different time can be changed somehow. + + Arguments: + bot: bot that stores ns state. + func: functions that will be called as ns handler. Will be transformed to + coroutine if it is not already. + name: name for new function. Will be generated from `func` if not passed. + """ + name = name or get_name_from_callable(func) + handlers = bot.state.ns_handlers + if name in handlers: + raise ValueError(f"bot ns functions already include function with {name}") + handlers[name] = Handler( + body="", + handler=func, + include_in_status=False, + dependencies=bot.collector.dependencies, + dependency_overrides_provider=bot.dependency_overrides, + ) + + +def register_next_step_handler( + message: messages.Message, func: Union[str, Callable], **ns_arguments: Any +) -> None: + """Register new next step handler for next message from user. + + !!! info + While registration handler for next message this function fill first try to find + handlers that were registered using `register_function_as_ns_handler`, then + handlers that are registered in bot itself and then if no one was found an + exception will be raised. + + Arguments: + message: incoming message. + func: function name of function which name will be retrieved to register next + handler. + ns_arguments: arguments that will be stored in message state while executing + handler with next message. + """ + if message.user_huid is None: + raise ValueError( + "message for which ns handler is registered should include user_huid" + ) + + bot = message.bot + handlers = bot.state.ns_handlers + name = get_name_from_callable(func) if callable(func) else func + + try: + bot.handler_for(name) + except NoMatchFound: + if name not in handlers: + raise ValueError( + f"bot does not have registered function or handler with name {name}" + ) + + key = get_chain_key_by_message(message) + + store: List[str] = bot.state.ns_storage.setdefault(key, []) + args: List[dict] = bot.state.ns_arguments.setdefault(key, []) + + store.append(name) + args.append(ns_arguments) diff --git a/botx/models/__init__.py b/botx/models/__init__.py index 5fcdcd2b..1e8935c6 100644 --- a/botx/models/__init__.py +++ b/botx/models/__init__.py @@ -1,76 +1 @@ -from .botx_api import ( - BotXAPIErrorData, - BotXCommandResultPayload, - BotXFilePayload, - BotXNotificationPayload, - BotXResultPayload, - BotXTokenRequestParams, - BotXTokenResponse, - ErrorResponseData, - MessageMarkup, - MessageOptions, - SendingCredentials, - SendingPayload, -) -from .command_handler import CommandCallback, CommandHandler, Dependency -from .common import CommandUIElement, MenuCommand, NotificationOpts -from .cts import CTS, BotCredentials, CTSCredentials -from .enums import ( - ChatTypeEnum, - CommandTypeEnum, - MentionTypeEnum, - ResponseRecipientsEnum, - StatusEnum, - SystemEventsEnum, - UserKindEnum, -) -from .events import ChatCreatedData, UserInChatCreated -from .file import File -from .mention import Mention, MentionUser -from .message import Message, MessageCommand, MessageUser, ReplyMessage -from .status import Status, StatusResult -from .ui import BubbleElement, KeyboardElement - -__all__ = ( - "File", - "Dependency", - "CTS", - "BotCredentials", - "ChatCreatedData", - "UserInChatCreated", - "CTSCredentials", - "ChatTypeEnum", - "CommandUIElement", - "MentionTypeEnum", - "MenuCommand", - "ResponseRecipientsEnum", - "StatusEnum", - "BotXCommandResultPayload", - "BotXFilePayload", - "BotXNotificationPayload", - "BotXResultPayload", - "BotXTokenResponse", - "Mention", - "MentionUser", - "Message", - "MessageCommand", - "MessageUser", - "Status", - "StatusResult", - "KeyboardElement", - "BubbleElement", - "CommandHandler", - "CommandCallback", - "ReplyMessage", - "NotificationOpts", - "CommandTypeEnum", - "SystemEventsEnum", - "SendingCredentials", - "MessageMarkup", - "SendingPayload", - "MessageOptions", - "BotXTokenRequestParams", - "BotXAPIErrorData", - "ErrorResponseData", - "UserKindEnum", -) +"""Pydantic models, data classes or other entities.""" diff --git a/botx/models/base.py b/botx/models/base.py deleted file mode 100644 index ee520dea..00000000 --- a/botx/models/base.py +++ /dev/null @@ -1,53 +0,0 @@ -import json -from typing import TYPE_CHECKING, Any, Callable, Optional - -from pydantic import BaseConfig, BaseModel - -if TYPE_CHECKING: # pragma: no cover - from pydantic.main import SetStr, DictStrAny - - -class BotXType(BaseModel): - class Config(BaseConfig): - arbitrary_types_allowed = True - allow_population_by_alias = True - orm_mode = True - - def json( - self, - *, - include: "SetStr" = None, - exclude: "SetStr" = None, - by_alias: bool = True, - skip_defaults: bool = False, - encoder: Optional[Callable[[Any], Any]] = None, - **dumps_kwargs: Any, - ) -> str: - return super().json( - include=include, - exclude=exclude, - by_alias=by_alias, - skip_defaults=skip_defaults, - encoder=encoder, - **dumps_kwargs, - ) - - def dict( # noqa: A003 - self, - *, - include: "SetStr" = None, - exclude: "SetStr" = None, - by_alias: bool = True, - skip_defaults: bool = False, - ) -> "DictStrAny": - return json.loads( - json.dumps( - super().dict( - include=include, - exclude=exclude, - by_alias=by_alias, - skip_defaults=skip_defaults, - ), - default=str, - ) - ) diff --git a/botx/models/botx_api.py b/botx/models/botx_api.py deleted file mode 100644 index 1c036938..00000000 --- a/botx/models/botx_api.py +++ /dev/null @@ -1,116 +0,0 @@ -from typing import List, Optional, Union -from uuid import UUID - -from pydantic import Schema - -from botx.core import TEXT_MAX_LENGTH - -from .base import BotXType -from .common import MenuCommand, NotificationOpts -from .enums import ResponseRecipientsEnum, StatusEnum -from .file import File -from .mention import Mention -from .ui import BubbleElement, KeyboardElement, add_ui_element - - -class BotXTokenResponse(BotXType): - result: str - - -class BotXTokenRequestParams(BotXType): - signature: str - - -class BotXResultPayload(BotXType): - status: StatusEnum = StatusEnum.ok - body: str - commands: List[MenuCommand] = [] - keyboard: List[List[KeyboardElement]] = [] - bubble: List[List[BubbleElement]] = [] - mentions: List[Mention] = [] - - -class BotXPayloadOptions(BotXType): - notification_opts: NotificationOpts = NotificationOpts() - - -class BotXBasePayload(BotXType): - bot_id: UUID - recipients: Union[List[UUID], ResponseRecipientsEnum] = ResponseRecipientsEnum.all - file: Optional[File] = None - opts: BotXPayloadOptions = BotXPayloadOptions() - - -class BotXCommandResultPayload(BotXBasePayload): - sync_id: UUID - command_result: BotXResultPayload - - -class BotXNotificationPayload(BotXBasePayload): - group_chat_ids: List[UUID] = [] - notification: BotXResultPayload - - -class BotXFilePayload(BotXType): - bot_id: UUID - sync_id: UUID - - -class SendingCredentials(BotXType): - sync_id: Optional[UUID] = None - chat_ids: List[UUID] = [] - bot_id: UUID - host: str - token: Optional[str] = None - - -class MessageMarkup(BotXType): - bubbles: List[List[BubbleElement]] = [] - keyboard: List[List[KeyboardElement]] = [] - - def add_bubble( - self, command: str, label: Optional[str] = None, *, new_row: bool = True - ) -> None: - add_ui_element( - ui_cls=BubbleElement, - ui_array=self.bubbles, - command=command, - label=label, - new_row=new_row, - ) - - def add_keyboard_button( - self, command: str, label: Optional[str] = None, *, new_row: bool = True - ) -> None: - add_ui_element( - ui_cls=KeyboardElement, - ui_array=self.keyboard, - command=command, - label=label, - new_row=new_row, - ) - - -class MessageOptions(BotXType): - recipients: Union[ - List[UUID], str, ResponseRecipientsEnum - ] = ResponseRecipientsEnum.all - mentions: List[Mention] = [] - notifications: NotificationOpts = NotificationOpts() - - -class SendingPayload(BotXType): - text: Optional[str] = Schema(None, max_length=TEXT_MAX_LENGTH) - file: Optional[File] = None - markup: MessageMarkup = MessageMarkup() - options: MessageOptions = MessageOptions() - - -class ErrorResponseData(BotXType): - status_code: int - body: str - - -class BotXAPIErrorData(BotXType): - address: SendingCredentials - response: ErrorResponseData diff --git a/botx/models/buttons.py b/botx/models/buttons.py new file mode 100644 index 00000000..425f4025 --- /dev/null +++ b/botx/models/buttons.py @@ -0,0 +1,48 @@ +"""Pydantic models for bubbles and keyboard buttons.""" + +from typing import Optional + +from pydantic import BaseModel, validator + + +class ButtonOptions(BaseModel): + """Extra options for buttons, like disabling output by tap.""" + + silent: bool = True + """if True then text won't shown for user in messenger.""" + + +class Button(BaseModel): + """Base class for ui element like bubble or keyboard button.""" + + command: str + """command that will be triggered by click on the element.""" + label: Optional[str] = None + """text that will be shown on the element.""" + data: dict = {} + """extra payload that will be stored in button and then received in new message.""" + opts: ButtonOptions = ButtonOptions() + """options for button.""" + + @validator("label", always=True) + def label_as_command_if_none( + cls, value: Optional[str], values: dict # noqa: N805 + ) -> str: + """Return command as label if it is `None`. + + Arguments: + value: value that should be checked. + values: all other values checked before. + + Returns: + Label for button. + """ + return value or values["command"] + + +class BubbleElement(Button): + """Bubble buttons that is shown under messages.""" + + +class KeyboardElement(Button): + """Keyboard buttons that are placed instead of real keyboard.""" diff --git a/botx/models/command_handler.py b/botx/models/command_handler.py deleted file mode 100644 index e871aa1e..00000000 --- a/botx/models/command_handler.py +++ /dev/null @@ -1,62 +0,0 @@ -from typing import Any, Callable, Dict, List, Optional, Pattern, Tuple, Type, Union - -from pydantic import validator - -from botx.core import PRIMITIVE_TYPES - -from .base import BotXType -from .common import CommandUIElement -from .status import MenuCommand - -PRIMITIVE_TYPES_ALIAS = Union[Type[int], Type[float], Type[bool], Type[str]] - - -class Dependency(BotXType): - call: Callable - - -class CommandCallback(BotXType): - callback: Callable - args: Tuple[Any, ...] = () - kwargs: Dict[str, Any] = {} - background_dependencies: List[Dependency] = [] - command_params: Dict[str, type] = {} - - @validator("command_params") - def check_params_for_acceptable_types( - cls, param_type: PRIMITIVE_TYPES_ALIAS - ) -> PRIMITIVE_TYPES_ALIAS: - if param_type not in PRIMITIVE_TYPES: - raise ValueError( - f"command_params can be {PRIMITIVE_TYPES}, not {param_type}" - ) - - return param_type - - -class CommandHandler(BotXType): - name: str - command: str - menu_command: str - regex_command: Pattern - description: str - callback: CommandCallback - - full_description: str = "" - command_params: List[str] = [] - exclude_from_status: bool = False - use_as_default_handler: bool = False - options: Dict[str, Any] = {} - elements: List[CommandUIElement] = [] - - def to_status_command(self) -> Optional[MenuCommand]: - if not (self.exclude_from_status or self.use_as_default_handler): - return MenuCommand( - body=self.menu_command, - name=self.name, - description=self.description, - options=self.options, - elements=self.elements, - ) - - return None diff --git a/botx/models/common.py b/botx/models/common.py deleted file mode 100644 index 659d3892..00000000 --- a/botx/models/common.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Any, Dict, List, Optional - -from .base import BotXType - - -class CommandUIElement(BotXType): - type: str - label: str - order: Optional[int] = None - value: Optional[Any] = None - name: Optional[str] = None - disabled: Optional[bool] = None - - -class MenuCommand(BotXType): - description: str - body: str - name: str - options: Dict[str, Any] = {} - elements: List[CommandUIElement] = [] - - -class NotificationOpts(BotXType): - send: bool = True - force_dnd: bool = False diff --git a/botx/models/constants.py b/botx/models/constants.py new file mode 100644 index 00000000..01451fcd --- /dev/null +++ b/botx/models/constants.py @@ -0,0 +1,3 @@ +"""Definition of different constants that are used in models.""" + +MAXIMUM_TEXT_LENGTH = 4096 diff --git a/botx/models/credentials.py b/botx/models/credentials.py new file mode 100644 index 00000000..d70bc2af --- /dev/null +++ b/botx/models/credentials.py @@ -0,0 +1,46 @@ +"""Definition of credentials that are used for access to BotX API.""" + +import base64 +import hashlib +import hmac +from typing import Optional +from uuid import UUID + +from pydantic import BaseModel + + +class ServerCredentials(BaseModel): + """Container for credentials for bot.""" + + bot_id: UUID + """bot that retrieved token from API.""" + token: str + """token generated for bot.""" + + +class ExpressServer(BaseModel): + """Server on which bot can answer.""" + + host: str + """host name of server.""" + secret_key: str + """secret that will be used for generating signature for bot.""" + server_credentials: Optional[ServerCredentials] = None + """obtained credentials for bot.""" + + def calculate_signature(self, bot_id: UUID) -> str: + """Calculate signature for obtaining token for bot from BotX API. + + Arguments: + bot_id: bot for which token should be generated. + + Returns: + Calculated signature. + """ + return base64.b16encode( + hmac.new( + key=self.secret_key.encode(), + msg=str(bot_id).encode(), + digestmod=hashlib.sha256, + ).digest() + ).decode() diff --git a/botx/models/cts.py b/botx/models/cts.py deleted file mode 100644 index 59f6805e..00000000 --- a/botx/models/cts.py +++ /dev/null @@ -1,31 +0,0 @@ -import base64 -import hashlib -import hmac -from typing import List, Optional -from uuid import UUID - -from .base import BotXType - - -class CTSCredentials(BotXType): - bot_id: UUID - token: str - - -class CTS(BotXType): - host: str - secret_key: str - credentials: Optional[CTSCredentials] = None - - def calculate_signature(self, bot_id: UUID) -> str: - return base64.b16encode( - hmac.new( - key=self.secret_key.encode(), - msg=str(bot_id).encode(), - digestmod=hashlib.sha256, - ).digest() - ).decode() - - -class BotCredentials(BotXType): - known_cts: List[CTS] = [] diff --git a/botx/models/datastructures.py b/botx/models/datastructures.py new file mode 100644 index 00000000..ca5521a0 --- /dev/null +++ b/botx/models/datastructures.py @@ -0,0 +1,42 @@ +"""Entities that represent some structs that are used in this library.""" + +from typing import Any, Optional + + +class State: + """An object that can be used to store arbitrary state.""" + + _state: dict + + def __init__(self, state: Optional[dict] = None): + """Init state with required params. + + Arguments: + state: initial state. + """ + state = state or {} + super().__setattr__("_state", state) # noqa: WPS613 + + def __setattr__(self, key: Any, value: Any) -> None: + """Set state attribute. + + Arguments: + key: key to set attribute. + value: value of attribute. + """ + self._state[key] = value + + # this is not module __getattr__ + def __getattr__(self, key: Any) -> Any: # noqa: WPS413 + """Get state attribute. + + Arguments: + key: key of retrieved attribute. + + Returns: + Stored value. + """ + try: + return self._state[key] + except KeyError: + raise AttributeError(f"State has no attribute '{key}'") diff --git a/botx/models/enums.py b/botx/models/enums.py index d572f59f..a5f576ce 100644 --- a/botx/models/enums.py +++ b/botx/models/enums.py @@ -1,38 +1,66 @@ +"""Definition of enums that are used across different components of this library.""" + from enum import Enum -class StatusEnum(str, Enum): - ok: str = "ok" - error: str = "error" +class SystemEvents(Enum): + """System enums that bot can retrieve from BotX API in message. + + !!! info + NOTE: `file_transfer` is not a system event, but it is logical to place it in + this enum. + """ + + chat_created = "system:chat_created" + """`system:chat_created` event.""" + file_transfer = "file_transfer" + """`file_transfer` message.""" + + +class CommandTypes(str, Enum): # noqa: WPS600 + """Enum that specify from whom command was received.""" + + user = "user" + """command received from user.""" + system = "system" + """command received from system.""" + +class ChatTypes(str, Enum): # noqa: WPS600 + """Enum for type of chat.""" -class ResponseRecipientsEnum(str, Enum): - all: str = "all" + chat = "chat" + """private chat for user with bot.""" + group_chat = "group_chat" + """chat with several users.""" + channel = "channel" + """channel chat.""" -class ChatTypeEnum(str, Enum): - chat: str = "chat" - group_chat: str = "group_chat" - channel: str = "channel" +class UserKinds(str, Enum): # noqa: WPS600 + """Enum for type of user.""" + user = "user" + """normal user.""" + cts_user = "cts_user" + """normal user, but will present if all users in chat are from the same CTS.""" + bot = "botx" + """bot user.""" -class MentionTypeEnum(str, Enum): - user: str = "user" - all: str = "all" - cts: str = "cts" - channel: str = "channel" +class Recipients(str, Enum): # noqa: WPS600 + """Enum for default recipients value. -class SystemEventsEnum(str, Enum): - chat_created: str = "system:chat_created" + - *all*: show message to all users in chat. + """ + all: str = "all" # noqa: A003 -class UserKindEnum(str, Enum): - bot: str = "botx" - user: str = "user" - cts_user: str = "cts_user" +class Statuses(str, Enum): # noqa: WPS600 + """Enum for status of operation in BotX API.""" -class CommandTypeEnum(str, Enum): - user: str = "user" - system: str = "system" + ok = "ok" + """operation was successfully proceed.""" + error = "error" + """there was an error while processing operation.""" diff --git a/botx/models/errors.py b/botx/models/errors.py new file mode 100644 index 00000000..0be73c3b --- /dev/null +++ b/botx/models/errors.py @@ -0,0 +1,42 @@ +"""Definition of errors in processing request from BotX API.""" + +from typing import Any, Dict, Union + +from pydantic import BaseModel, validator + + +class BotDisabledErrorData(BaseModel): + """Data about occurred error.""" + + status_message: str + """message that will be shown to user.""" + + +class BotDisabledResponse(BaseModel): + """Response to BotX API if there was an error in handling incoming request.""" + + reason: str = "bot_disabled" + """error reason. *This should always be `bot_disabled` string.*""" + error_data: Union[Dict[str, Any], BotDisabledErrorData] + """data about occurred error that should include `status_message` + field in json.""" + + @validator("error_data", always=True, whole=True) + def status_message_in_error_data( + cls, value: Dict[str, Any] # noqa: N805 + ) -> Union[BotDisabledErrorData, Dict[str, Any]]: + """Check that value contains `status_message` key or field. + + Arguments: + value: value that should be checked. + + Returns: + Built payload for response. + """ + if set(value.keys()) == {"status_message"}: + return BotDisabledErrorData(status_message=value["status_message"]) + + if "status_message" not in value: + raise ValueError("status_message key required in error_data") + + return value diff --git a/botx/models/events.py b/botx/models/events.py index f09ef539..720a3787 100644 --- a/botx/models/events.py +++ b/botx/models/events.py @@ -1,20 +1,41 @@ +"""Definition of different schemas for system events.""" + +from types import MappingProxyType from typing import List from uuid import UUID -from .base import BotXType -from .enums import ChatTypeEnum, UserKindEnum +from pydantic import BaseModel + +from botx.models.enums import ChatTypes, SystemEvents, UserKinds + +class UserInChatCreated(BaseModel): + """User that can be included in data in `system:chat_created` event.""" -class UserInChatCreated(BotXType): huid: UUID - user_kind: UserKindEnum + """user huid.""" + user_kind: UserKinds + """type of user.""" name: str + """user username.""" admin: bool + """is user administrator in chat.""" -class ChatCreatedData(BotXType): +class ChatCreatedEvent(BaseModel): + """Shape for `system:chat_created` event data.""" + group_chat_id: UUID - chat_type: ChatTypeEnum + """chat id from which event received.""" + chat_type: ChatTypes + """type of chat.""" name: str + """chat name.""" creator: UUID + """`huid` of user that created chat.""" members: List[UserInChatCreated] + """list of users that are members of chat.""" + + +# dict for validating shape for different events +EVENTS_SHAPE_MAP = MappingProxyType({SystemEvents.chat_created: ChatCreatedEvent}) diff --git a/botx/models/file.py b/botx/models/file.py deleted file mode 100644 index 18e9ac1e..00000000 --- a/botx/models/file.py +++ /dev/null @@ -1,44 +0,0 @@ -import base64 -import mimetypes -import pathlib -from io import BytesIO -from typing import BinaryIO, TextIO, Union - -from .base import BotXType - - -class File(BotXType): - data: str - file_name: str - - @classmethod - def from_file(cls, file: Union[TextIO, BinaryIO]) -> "File": - file_name = pathlib.Path(file.name).name - file_data = file.read() - - if isinstance(file_data, str): - file_data = file_data.encode() - - data = base64.b64encode(file_data).decode() - media_type = mimetypes.guess_type(file_name)[0] or "text/plain" - return File(file_name=file_name, data=f"data:{media_type};base64,{data}") - - @classmethod - def from_bytes(cls, filename: str, data: bytes) -> "File": - file = BytesIO(data) - file.name = filename - return cls.from_file(file) - - @property - def file(self) -> BinaryIO: - bytes_data = BytesIO(self.raw_data) - bytes_data.name = self.file_name - return bytes_data - - @property - def raw_data(self) -> bytes: - return base64.b64decode(self.data.split(",", 1)[1]) - - @property - def media_type(self) -> str: - return self.data.split("data:", 1)[1].split(";", 1)[0] diff --git a/botx/models/files.py b/botx/models/files.py new file mode 100644 index 00000000..1d1e7839 --- /dev/null +++ b/botx/models/files.py @@ -0,0 +1,134 @@ +"""Definition of file that can be included in incoming message or in sending result.""" + +import base64 +import mimetypes +import pathlib +from io import BytesIO +from typing import AnyStr, BinaryIO, Optional, TextIO, Union + +from pydantic import BaseModel, validator + +"""File extensions that can be proceed by BotX API.""" +BOTX_API_ACCEPTED_EXTENSIONS = ( + ".doc", + ".docx", + ".xls", + ".xlsx", + ".ppt", + ".pptx", + ".json", + ".txt", + ".pdf", + ".html", + ".jpg", + ".jpeg", + ".gif", + ".png", + ".mp3", + ".mp4", + ".rar", + ".zip", + ".7z", + ".tar.gz", + ".tar.bz2", + ".gz", + ".tgz", +) + + +class File(BaseModel): + """Object that represents file in RFC 2397 format.""" + + file_name: str + """name of file.""" + data: str + """file content in RFC 2397 format.""" + caption: Optional[str] = None + """text under file.""" + + @validator("file_name", always=True) + def check_file_extension(cls, value: str) -> str: # noqa: N805 + """Check that file extension can be handled by BotX API. + + Arguments: + value: file name which will be checked for matching extensions. + + Returns: + Passed name if matching was successful. + """ + extensions_check = ( + value.lower().endswith(extension) + for extension in BOTX_API_ACCEPTED_EXTENSIONS + ) + + if not any(extensions_check): + raise ValueError( + f"file {value} has an extensions that is not supported by BotX API" + ) + + return value + + @classmethod + def from_file( + cls, file: Union[TextIO, BinaryIO], filename: Optional[str] = None + ) -> "File": + """Convert file-like object into BotX API compatible file. + + Arguments: + file: file-like object that will be used for creating file. + filename: name that will be used for file, if was not passed, then will be + retrieved from `file` `.name` property. + + Returns: + Built file object. + """ + filename = filename or pathlib.Path(file.name).name + file_data = file.read() + + if isinstance(file_data, str): + file_data = file_data.encode() + + data = base64.b64encode(file_data).decode() + media_type = mimetypes.guess_type(filename)[0] or "text/plain" + return cls(file_name=filename, data=f"data:{media_type};base64,{data}") + + @classmethod + def from_string(cls, data: AnyStr, filename: str) -> "File": + """Build file from bytes or string passed to method in `data` with `filename` as name. + + Arguments: + data: string or bytes that will be used for creating file. + filename: name for new file. + + Returns: + Built file object. + """ + if isinstance(data, str): + file_data = data.encode() + else: + file_data = data + file = BytesIO(file_data) + file.name = filename + return cls.from_file(file) + + @property + def file(self) -> BinaryIO: + """Return file data in file-like object that will return bytes.""" + bytes_file = BytesIO(self.data_in_bytes) + bytes_file.name = self.file_name + return bytes_file + + @property + def data_in_bytes(self) -> bytes: + """Return decoded file data in bytes.""" + return base64.b64decode(self.data_in_base64) + + @property + def data_in_base64(self) -> str: + """Return file data in base64 encoded string.""" + return self.data.split(",", 1)[1] + + @property + def media_type(self) -> str: + """Return media type of file.""" + return mimetypes.guess_type(self.data)[0] or "text/plain" diff --git a/botx/models/mention.py b/botx/models/mention.py deleted file mode 100644 index fe8ea5a2..00000000 --- a/botx/models/mention.py +++ /dev/null @@ -1,15 +0,0 @@ -from typing import Optional -from uuid import UUID - -from .base import BotXType -from .enums import MentionTypeEnum - - -class MentionUser(BotXType): - user_huid: UUID - name: Optional[str] = None - - -class Mention(BotXType): - mention_type: MentionTypeEnum = MentionTypeEnum.user - mention_data: MentionUser diff --git a/botx/models/mentions.py b/botx/models/mentions.py new file mode 100644 index 00000000..f4091a43 --- /dev/null +++ b/botx/models/mentions.py @@ -0,0 +1,91 @@ +"""Pydantic models for mentions.""" + +from enum import Enum +from typing import Optional, Union +from uuid import UUID, uuid4 + +from pydantic import BaseModel, validator + + +class MentionTypes(str, Enum): # noqa: WPS600 + """Enum for available values in mentions.""" + + user = "user" + """mention single user from chat in message.""" + contact = "contact" + """mention user by user_huid.""" + chat = "chat" + """mention chat in message.""" + + +class UserMention(BaseModel): + """Mention for single user in chat or by `user_huid`.""" + + user_huid: UUID + """huid of user that will be mentioned.""" + name: Optional[str] = None + """name that will be used instead of default user name.""" + + +class ChatMention(BaseModel): + """Mention chat in message by `group_chat_id`.""" + + group_chat_id: UUID + """id of chat that will be mentioned.""" + name: Optional[str] = None + """name that will be used instead of default chat name.""" + + +class Mention(BaseModel): + """Mention that is used in bot in messages.""" + + mention_id: Optional[UUID] = None + """unique id of mention.""" + mention_data: Union[ChatMention, UserMention] + """mention type.""" + mention_type: MentionTypes = MentionTypes.user + """payload with data about mention.""" + + @validator("mention_id", pre=True, always=True) + def generate_mention_id(cls, mention_id: Optional[UUID]) -> UUID: # noqa: N805 + """Verify that `mention_id` will be in mention. + + Arguments: + mention_id: id that should present or new UUID4 will be generated. + + Returns: + Mention ID. + """ + return mention_id or uuid4() + + @validator("mention_type", pre=True, always=True) + def check_that_type_matches_data( + cls, mention_type: MentionTypes, values: dict # noqa: N805 + ) -> MentionTypes: + """Verify that `mention_type` matches provided `mention_data`. + + Arguments: + mention_type: mention type that should be consistent with data. + values: verified data. + + Returns: + Checked mention type. + """ + mention_data = values["mention_data"] + user_mention_types = {MentionTypes.user, MentionTypes.contact} + chat_mention_types = {MentionTypes.chat} + + if isinstance(mention_data, UserMention): + if mention_type in user_mention_types: + return mention_type + raise ValueError( + "mention_type for provided mention_data is wrong, accepted: {0}", + user_mention_types, + ) + + if mention_type in chat_mention_types: + return mention_type + raise ValueError( + "mention_type for provided mention_data is wrong, accepted: {0}", + chat_mention_types, + ) diff --git a/botx/models/menu.py b/botx/models/menu.py new file mode 100644 index 00000000..9ceb377c --- /dev/null +++ b/botx/models/menu.py @@ -0,0 +1,59 @@ +"""Pydantic models for bot menu.""" + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel + +from botx.models.enums import Statuses + + +class CommandUIElement(BaseModel): + """UI elements for commands in menu. *Not used for now*.""" + + type: str + """element type.""" + label: str + """element title.""" + order: Optional[int] = None + """order of element on client.""" + value: Optional[Any] = None + """value of element.""" + name: Optional[str] = None + """name of element as key, that will be stored in command.""" + disabled: Optional[bool] = None + """possibility to change element value.""" + + +class MenuCommand(BaseModel): + """Command that is shown in bot menu.""" + + description: str + """command description that will be shown in menu.""" + body: str + """command body that will trigger command execution.""" + name: str + """command name.""" + options: Dict[str, Any] = {} + """dictionary with command options. *Not used for now*.""" + elements: List[CommandUIElement] = [] + """list of UI elements for command. *Not used for now*.""" + + +class StatusResult(BaseModel): + """Bot menu commands collection.""" + + enabled: bool = True + """is bot enabled.""" + status_message: str = "Bot is working" + """status of bot.""" + commands: List[MenuCommand] = [] + """list of bot commands that will be shown in menu.""" + + +class Status(BaseModel): + """Object that should be returned on `/status` request from BotX API.""" + + status: Statuses = Statuses.ok + """operation status.""" + result: StatusResult = StatusResult() + """bot status.""" diff --git a/botx/models/message.py b/botx/models/message.py deleted file mode 100644 index 3f35e224..00000000 --- a/botx/models/message.py +++ /dev/null @@ -1,182 +0,0 @@ -from typing import ( - TYPE_CHECKING, - Any, - BinaryIO, - Dict, - List, - Optional, - TextIO, - TypeVar, - Union, - cast, -) -from uuid import UUID - -from pydantic import Schema - -from botx.core import TEXT_MAX_LENGTH - -from .base import BotXType -from .common import NotificationOpts -from .enums import ChatTypeEnum, CommandTypeEnum, ResponseRecipientsEnum -from .events import ChatCreatedData -from .file import File -from .mention import Mention, MentionUser -from .ui import BubbleElement, KeyboardElement, add_ui_element - -if TYPE_CHECKING: # pragma: no cover - from .ui import UIElement - - TUIElement = TypeVar("TUIElement", bound=UIElement) - -CommandDataType = Union[Dict[str, Any], ChatCreatedData] - - -class MessageUser(BotXType): - user_huid: Optional[UUID] - group_chat_id: UUID - chat_type: ChatTypeEnum - ad_login: Optional[str] - ad_domain: Optional[str] - username: Optional[str] - is_admin: bool - is_creator: bool - host: str - - @property - def email(self) -> Optional[str]: - if self.ad_login and self.ad_domain: - return f"{self.ad_login}@{self.ad_domain}" - - return None - - -class MessageCommand(BotXType): - body: str - command_type: CommandTypeEnum - data: CommandDataType = {} - - @property - def command(self) -> str: - return self.body.split(" ", 1)[0] - - @property - def arguments(self) -> List[str]: - return [arg for arg in self.body.split(" ")[1:] if arg and not arg.isspace()] - - @property - def single_argument(self) -> str: - return self.body[len(self.command) :].strip() - - -class Message(BotXType): - sync_id: UUID - command: MessageCommand - file: Optional[File] = None - user: MessageUser = Schema(..., alias="from") # type: ignore - bot_id: UUID - - @property - def body(self) -> str: - return self.command.body - - @property - def data(self) -> CommandDataType: - return self.command.data - - @property - def user_huid(self) -> Optional[UUID]: - return self.user.user_huid - - @property - def ad_login(self) -> Optional[str]: - return self.user.ad_login - - @property - def group_chat_id(self) -> UUID: - return self.user.group_chat_id - - @property - def chat_type(self) -> str: - return self.user.chat_type.name - - @property - def host(self) -> str: - return self.user.host - - -class ReplyMessage(BotXType): - text: str = Schema(..., max_length=TEXT_MAX_LENGTH) # type: ignore - sync_id: Optional[UUID] = None - chat_ids: List[UUID] = [] - bot_id: UUID - host: str - recipients: Union[List[UUID], str] = ResponseRecipientsEnum.all - mentions: List[Mention] = [] - bubble: List[List[BubbleElement]] = [] - keyboard: List[List[KeyboardElement]] = [] - opts: NotificationOpts = NotificationOpts() - file: Optional[File] = None - - @classmethod - def from_message(cls, text: str, message: Message) -> "ReplyMessage": - reply_msg = cls( - text=text, - sync_id=message.sync_id, - bot_id=message.bot_id, - host=message.host, - chat_ids=[message.group_chat_id], - ) - return reply_msg - - @property - def chat_id(self) -> Optional[UUID]: - return self.chat_ids[0] if self.chat_ids else None - - def add_file(self, file: Union[TextIO, BinaryIO]) -> None: - self.file = File.from_file(file) - - def mention_user(self, user_huid: UUID, name: Optional[str] = None) -> None: - self.mentions.append( - Mention(mention_data=MentionUser(user_huid=user_huid, name=name)) - ) - - def add_recipient(self, recipient: UUID) -> None: - if self.recipients == ResponseRecipientsEnum.all: - self.recipients = [] - - cast(List[UUID], self.recipients).append(recipient) - - def add_recipients(self, recipients: List[UUID]) -> None: - if self.recipients == ResponseRecipientsEnum.all: - self.recipients = [] - - cast(List[UUID], self.recipients).extend(recipients) - - def add_bubble( - self, command: str, label: Optional[str] = None, *, new_row: bool = True - ) -> None: - add_ui_element( - ui_cls=BubbleElement, - ui_array=self.bubble, - command=command, - label=label, - new_row=new_row, - ) - - def add_keyboard_button( - self, command: str, label: Optional[str] = None, *, new_row: bool = True - ) -> None: - add_ui_element( - ui_cls=KeyboardElement, - ui_array=self.keyboard, - command=command, - label=label, - new_row=new_row, - ) - - def show_notification(self, show: bool) -> None: - self.opts.send = show - - def force_notification(self, force: bool) -> None: - self.opts.force_dnd = force diff --git a/botx/models/messages.py b/botx/models/messages.py new file mode 100644 index 00000000..4059b7fb --- /dev/null +++ b/botx/models/messages.py @@ -0,0 +1,531 @@ +"""Definition of message object that is used in all bot handlers.""" + +from typing import BinaryIO, List, Optional, TextIO, Union, cast +from uuid import UUID + +from botx import bots +from botx.models.datastructures import State +from botx.models.enums import Recipients +from botx.models.files import File +from botx.models.mentions import ChatMention, Mention, MentionTypes, UserMention +from botx.models.receiving import Command, IncomingMessage, User +from botx.models.sending import ( + MessageMarkup, + MessageOptions, + MessagePayload, + NotificationOptions, + SendingCredentials, +) +from botx.models.typing import AvailableRecipients, BubbleMarkup, KeyboardMarkup + + +class Message: # noqa: WPS214 + """Message that is used in handlers.""" + + def __init__(self, message: IncomingMessage, bot: "bots.Bot") -> None: + """Init message with required params. + + Arguments: + message: incoming message. + bot: bot that handles message. + """ + self.bot: bots.Bot = bot + """bot that is used for handling message.""" + self.state: State = State() + """message state.""" + self._message = message + + @property + def sync_id(self) -> UUID: + """Event id of message.""" + return self._message.sync_id + + @property + def command(self) -> Command: + """Command for bot.""" + return self._message.command + + @property + def file(self) -> Optional[File]: + """File attached to message.""" + return self._message.file + + @property + def user(self) -> User: + """Information about user that sent message.""" + return self._message.user + + @property + def bot_id(self) -> UUID: + """Id of bot that should handle message.""" + return self._message.bot_id + + @property + def body(self) -> str: + """Command body.""" + return self.command.body + + @property + def data(self) -> dict: + """Command payload.""" + return self.command.data_dict + + @property + def user_huid(self) -> Optional[UUID]: + """User huid.""" + return self.user.user_huid + + @property + def ad_login(self) -> Optional[str]: + """User AD login.""" + return self.user.ad_login + + @property + def group_chat_id(self) -> UUID: + """Chat from which message was received.""" + return self.user.group_chat_id + + @property + def chat_type(self) -> str: + """Type of chat.""" + return self.user.chat_type.value + + @property + def host(self) -> str: + """Host from which message was received.""" + return self.user.host + + @property + def credentials(self) -> SendingCredentials: + """Reply credentials for this message.""" + return SendingCredentials( + sync_id=self.sync_id, bot_id=self.bot_id, host=self.host + ) + + @property + def incoming_message(self) -> IncomingMessage: + """Incoming message from which this was generated.""" + return self._message.copy(deep=True) + + @classmethod + def from_dict(cls, message: dict, bot: "bots.Bot") -> "Message": + """Parse incoming dict into message. + + Arguments: + message: incoming message to bot as dictionary. + bot: bot that handles message. + + Returns: + Parsed message. + """ + incoming_msg = IncomingMessage(**message) + return cls(incoming_msg, bot) + + +class SendingMessage: # noqa: WPS214, WPS230 + """Message that will be sent by bot.""" + + def __init__( # noqa: WPS211 + self, + *, + text: str = "", + bot_id: Optional[UUID] = None, + host: Optional[str] = None, + sync_id: Optional[UUID] = None, + chat_id: Optional[UUID] = None, + chat_ids: Optional[List[UUID]] = None, + recipients: Optional[Union[List[UUID], Recipients]] = None, + mentions: Optional[List[Mention]] = None, + bubbles: Optional[BubbleMarkup] = None, + keyboard: Optional[KeyboardMarkup] = None, + notification_options: Optional[NotificationOptions] = None, + file: Optional[File] = None, + credentials: Optional[SendingCredentials] = None, + options: Optional[MessageOptions] = None, + markup: Optional[MessageMarkup] = None, + ) -> None: + """Init message with required attributes. + + !!! info + You should pass at least already built credentials or bot_id, host and + one of sync_id, chat_id or chat_ids for message. + !!! info + You can not pass markup along with bubbles or keyboards. You can merge them + manual before or after building message. + !!! info + You can not pass options along with any of recipients, mentions or + notification_options. You can merge them manual before or after building + message. + + Arguments: + text: text for message. + file: file that will be attached to message. + bot_id: bot id. + host: host for message. + sync_id: message event id. + chat_id: chat id. + chat_ids: sequence of chat ids. + credentials: message credentials. + bubbles: bubbles that will be attached to message. + keyboard: keyboard elements that will be attached to message. + markup: message markup. + recipients: recipients for message. + mentions: mentions that will be attached to message. + notification_options: configuration for notifications for message. + options: message options. + """ + self.credentials: SendingCredentials = self._built_credentials( + bot_id=bot_id, + host=host, + sync_id=sync_id, + chat_id=chat_id, + chat_ids=chat_ids, + credentials=credentials, + ) + + self.payload: MessagePayload = MessagePayload( + text=text, + file=file, + markup=self._built_markup( + bubbles=bubbles, keyboard=keyboard, markup=markup + ), + options=self._build_options( + recipients=recipients, + mentions=mentions, + notification_options=notification_options, + options=options, + ), + ) + + @classmethod + def from_message( + cls, *, text: str = "", file: Optional[File] = None, message: Message + ) -> "SendingMessage": + """Build message for sending from incoming message. + + Arguments: + text: text for message. + file: file attached to message. + message: incoming message. + + Returns: + Built message. + """ + return cls( + text=text, + file=file, + sync_id=message.sync_id, + bot_id=message.bot_id, + host=message.host, + ) + + @property + def text(self) -> str: + """Text in message.""" + return self.payload.text + + @text.setter # noqa: WPS440 + def text(self, text: str) -> None: + """Text in message.""" + self.payload.text = text + + @property + def file(self) -> Optional[File]: + """File attached to message.""" + return self.payload.file + + @file.setter # noqa: WPS440 + def file(self, file: File) -> None: + """File attached to message.""" + self.payload.file = file + + @property + def markup(self) -> MessageMarkup: + """Message markup.""" + return self.payload.markup + + @markup.setter # noqa: WPS440 + def markup(self, markup: MessageMarkup) -> None: + """Message markup.""" + self.payload.markup = markup + + @property + def options(self) -> MessageOptions: + """Message options.""" + return self.payload.options + + @options.setter # noqa: WPS440 + def options(self, options: MessageOptions) -> None: + """Message options.""" + self.payload.options = options + + @property + def sync_id(self) -> Optional[UUID]: + """Event id on which message should answer.""" + return self.credentials.sync_id + + @sync_id.setter # noqa: WPS440 + def sync_id(self, sync_id: UUID) -> None: + """Event id on which message should answer.""" + self.credentials.sync_id = sync_id + + @property + def chat_id(self) -> Optional[UUID]: + """Chat id in which message should be sent.""" + return self.credentials.chat_ids[0] + + @chat_id.setter # noqa: WPS440 + def chat_id(self, chat_id: UUID) -> None: + """Chat id in which message should be sent.""" + self.credentials.chat_ids.append(chat_id) + + @property + def chat_ids(self) -> List[UUID]: + """Chat ids in which message should be sent.""" + return self.credentials.chat_ids + + @chat_ids.setter # noqa: WPS440 + def chat_ids(self, chat_ids: List[UUID]) -> None: + """Chat ids in which message should be sent.""" + self.credentials.chat_ids = chat_ids + + @property + def bot_id(self) -> UUID: + """Bot id that handles message.""" + return cast(UUID, self.credentials.bot_id) + + @bot_id.setter # noqa: WPS440 + def bot_id(self, bot_id: UUID) -> None: + """Bot id that handles message.""" + self.credentials.bot_id = bot_id + + @property + def host(self) -> str: + """Host where BotX API places.""" + return cast(str, self.credentials.host) + + @host.setter # noqa: WPS440 + def host(self, host: str) -> None: + """Host where BotX API places.""" + self.credentials.host = host + + def add_file( + self, file: Union[TextIO, BinaryIO, File], filename: Optional[str] = None + ) -> None: + """Attach file to message. + + Arguments: + file: file that should be attached to the message. + filename: name for file that will be used if if can not be retrieved from + file. + """ + if isinstance(file, File): + file.file_name = filename or file.file_name + self.payload.file = file + else: + self.payload.file = File.from_file(file, filename=filename) + + def mention_user(self, user_huid: UUID, name: Optional[str] = None) -> None: + """Mention user in message. + + Arguments: + user_huid: id of user that should be mentioned. + name: name that will be shown. + """ + self.payload.options.mentions.append( + Mention(mention_data=UserMention(user_huid=user_huid, name=name)) + ) + + def mention_contact(self, user_huid: UUID, name: Optional[str] = None) -> None: + """Mention contact in message. + + Arguments: + user_huid: id of user that should be mentioned. + name: name that will be shown. + """ + self.payload.options.mentions.append( + Mention( + mention_data=UserMention(user_huid=user_huid, name=name), + mention_type=MentionTypes.contact, + ) + ) + + def mention_chat(self, group_chat_id: UUID, name: Optional[str] = None) -> None: + """Mention chat in message. + + Arguments: + group_chat_id: id of chat that should be mentioned. + name: name that will be shown. + """ + self.payload.options.mentions.append( + Mention( + mention_data=ChatMention(group_chat_id=group_chat_id, name=name), + mention_type=MentionTypes.chat, + ) + ) + + def add_recipient(self, recipient: UUID) -> None: + """Add new user that will receive message. + + Arguments: + recipient: recipient for message. + """ + if self.payload.options.recipients == Recipients.all: + self.payload.options.recipients = [] + + cast(List[UUID], self.payload.options.recipients).append(recipient) + + def add_recipients(self, recipients: List[UUID]) -> None: + """Add list of recipients that should receive message. + + Arguments: + recipients: recipients for message. + """ + if self.payload.options.recipients == Recipients.all: + self.payload.options.recipients = [] + + cast(List[UUID], self.payload.options.recipients).extend(recipients) + + def add_bubble( + self, + command: str, + label: Optional[str] = None, + data: Optional[dict] = None, + *, + new_row: bool = True, + ) -> None: + """Add new bubble button to message markup. + + Arguments: + command: command that will be triggered on bubble click. + label: label that will be shown on bubble. + data: payload that will be attached to bubble. + new_row: place bubble on new row or on current. + """ + self.payload.markup.add_bubble(command, label, data, new_row=new_row) + + def add_keyboard_button( + self, + command: str, + label: Optional[str] = None, + data: Optional[dict] = None, + *, + new_row: bool = True, + ) -> None: + """Add new keyboard button to message markup. + + Arguments: + command: command that will be triggered on keyboard click. + label: label that will be shown on keyboard button. + data: payload that will be attached to keyboard. + new_row: place keyboard on new row or on current. + """ + self.payload.markup.add_keyboard_button(command, label, data, new_row=new_row) + + def show_notification(self, show: bool) -> None: + """Show notification about message. + + Arguments: + show: show notification about message. + """ + self.payload.options.notifications.send = show + + def force_notification(self, force: bool) -> None: + """Break mute on bot messages. + + Arguments: + force: break mute on bot messages. + """ + self.payload.options.notifications.force_dnd = force + + def _built_credentials( + self, + bot_id: Optional[UUID] = None, + host: Optional[str] = None, + sync_id: Optional[UUID] = None, + chat_id: Optional[UUID] = None, + chat_ids: Optional[List[UUID]] = None, + credentials: Optional[SendingCredentials] = None, + ) -> SendingCredentials: + """Build credentials for message. + + Arguments: + bot_id: bot id. + host: host for message. + sync_id: message event id. + chat_id: chat id. + chat_ids: sequence of chat ids. + credentials: message credentials. + + Returns: + Credentials for message. + """ + if bot_id and host: + assert ( + not credentials + ), "MessageCredentials can not be passed along with manual values for it" + + return SendingCredentials( + bot_id=bot_id, + host=host, + sync_id=sync_id, + chat_ids=chat_ids or [], + chat_id=chat_id, + ) + + assert credentials, "MessageCredentials or manual values should be passed" + return credentials + + def _built_markup( + self, + bubbles: Optional[BubbleMarkup] = None, + keyboard: Optional[KeyboardMarkup] = None, + markup: Optional[MessageMarkup] = None, + ) -> MessageMarkup: + """Build markup for message. + + Arguments: + bubbles: bubbles that will be attached to message. + keyboard: keyboard elements that will be attached to message. + markup: message markup. + + Returns: + Markup for message. + """ + if bubbles is not None or keyboard is not None: + assert ( + not markup + ), "Markup can not be passed along with bubbles or keyboard elements" + return MessageMarkup(bubbles=bubbles or [], keyboard=keyboard or []) + + return markup or MessageMarkup() + + def _build_options( + self, + recipients: Optional[AvailableRecipients] = None, + mentions: Optional[List[Mention]] = None, + notification_options: Optional[NotificationOptions] = None, + options: Optional[MessageOptions] = None, + ) -> MessageOptions: + """Built options for message. + + Arguments: + recipients: recipients for message. + mentions: mentions that will be attached to message. + notification_options: configuration for notifications for message. + options: message options. + + Returns: + Options for message. + """ + if mentions or recipients or notification_options: + assert ( + not options + ), "MessageOptions can not be passed along with manual values for it" + return MessageOptions( + recipients=recipients or Recipients.all, + mentions=mentions or [], + notifications=notification_options or NotificationOptions(), + ) + + return options or MessageOptions() diff --git a/botx/models/receiving.py b/botx/models/receiving.py new file mode 100644 index 00000000..762a63a7 --- /dev/null +++ b/botx/models/receiving.py @@ -0,0 +1,97 @@ +"""Definition of messages received by bot or sent by it.""" + +from typing import Any, Dict, Optional, Tuple, Union +from uuid import UUID + +from pydantic import BaseConfig, BaseModel, Field + +from botx.models.enums import ChatTypes, CommandTypes +from botx.models.events import ChatCreatedEvent +from botx.models.files import File + +CommandDataType = Union[ChatCreatedEvent, Dict[str, Any]] + + +class Command(BaseModel): + """Command that should be proceed by bot.""" + + body: str + """incoming text message.""" + command_type: CommandTypes + """was command received from user or this is system event.""" + data: CommandDataType = {} + """command payload.""" + + @property + def command(self) -> str: + """First word of body that was sent to bot.""" + return self.body.split(" ", 1)[0] + + @property + def arguments(self) -> Tuple[str, ...]: + """Words that are passed after command.""" + words = (word for word in self.body.split(" ")[1:]) + arguments = (arg for arg in words if arg and not arg.isspace()) + + return tuple(arguments) + + @property + def single_argument(self) -> str: + """Line that passed after command.""" + return self.body[len(self.command) :].strip() + + @property + def data_dict(self) -> dict: + """Command data as dictionary.""" + if isinstance(self.data, dict): + return self.data + return self.data.dict() + + +class User(BaseModel): + """User that sent message to bot.""" + + user_huid: Optional[UUID] + """user id.""" + group_chat_id: UUID + """chat id.""" + chat_type: ChatTypes + """type of chat.""" + ad_login: Optional[str] + """AD login of user.""" + ad_domain: Optional[str] + """AD domain of user.""" + username: Optional[str] + """username of user.""" + is_admin: bool + """is user admin of chat.""" + is_creator: bool + """is user creator of chat.""" + host: str + """host from which user sent message.""" + + @property + def email(self) -> Optional[str]: + """User email.""" + if self.ad_login and self.ad_domain: + return f"{self.ad_login}@{self.ad_domain}" + + return None + + +class IncomingMessage(BaseModel): + """Message that was received by bot and should be handled.""" + + sync_id: UUID + """message event id on which bot should answer.""" + command: Command + """command for bot.""" + file: Optional[File] = None + """file attached to message.""" + user: User = Field(..., alias="from") + """information about user from which message was received.""" + bot_id: UUID + """id of bot that should handle message.""" + + class Config(BaseConfig): # noqa: WPS431, D106 + allow_population_by_field_name = True diff --git a/botx/models/requests.py b/botx/models/requests.py new file mode 100644 index 00000000..84e44278 --- /dev/null +++ b/botx/models/requests.py @@ -0,0 +1,97 @@ +"""Pydantic models for requests to BotX API.""" + +from typing import List, Optional, Union +from uuid import UUID + +from pydantic import BaseModel, Field + +from botx.models.constants import MAXIMUM_TEXT_LENGTH +from botx.models.enums import Recipients, Statuses +from botx.models.files import File +from botx.models.mentions import Mention +from botx.models.menu import MenuCommand +from botx.models.sending import NotificationOptions +from botx.models.typing import BubbleMarkup, KeyboardMarkup + + +class ResultPayload(BaseModel): + """Data that is sent when bot answers on command or send notification.""" + + status: Statuses = Statuses.ok + """status of operation. *Not used for now*.""" + body: str = Field("", max_length=MAXIMUM_TEXT_LENGTH) + """body for new message from bot.""" + commands: List[MenuCommand] = [] + """list of bot commands. *Not used for now*.""" + keyboard: KeyboardMarkup = [] + """keyboard that will be used for new message.""" + bubble: BubbleMarkup = [] + """bubble elements that will be showed under new message.""" + mentions: List[Mention] = [] + """mentions that BotX API will append before new message text.""" + + +class UpdatePayload(BaseModel): + """Data that is sent when bot updates message.""" + + status: Statuses = Statuses.ok + """status of operation. *Not used for now*.""" + body: Optional[str] = Field(None, max_length=MAXIMUM_TEXT_LENGTH) + """new body in message.""" + commands: Optional[List[MenuCommand]] = None + """new list of bot commands. *Not used for now*.""" + keyboard: Optional[KeyboardMarkup] = None + """new keyboard that will be used for new message.""" + bubble: Optional[BubbleMarkup] = None + """new bubble elements that will be showed under new message.""" + mentions: Optional[List[Mention]] = None + """new mentions that BotX API will append before new message text.""" + + +class ResultOptions(BaseModel): + """Configuration for command result or notification that is send to BotX API.""" + + notification_opts: NotificationOptions = NotificationOptions() + """message options for configuring notifications.""" + + +class BaseResult(BaseModel): + """Shared attributes for command result and notification.""" + + bot_id: UUID + """`UUID` of bot that handled operation.""" + recipients: Union[List[UUID], Recipients] = Recipients.all + """users that will receive recipients or `all` users in chat.""" + file: Optional[File] = None + """file that will be attached to new message.""" + opts: ResultOptions = ResultOptions() + """options that control message behaviour.""" + + +class CommandResult(BaseResult): + """Entity that will be sent to BotX API on command result.""" + + sync_id: UUID + """event id for message that is handled by command.""" + result: ResultPayload = Field(..., alias="command_result") + """result of operation.""" + + +class Notification(BaseResult): + """Entity that will be sent to BotX API on notification.""" + + group_chat_ids: List[UUID] + """chat ids that will receive message.""" + result: ResultPayload = Field(..., alias="notification") + """result of operation.""" + + +class EventEdition(BaseModel): + """Entity that will be sent to BotX API on event edition.""" + + sync_id: UUID + """id of event that should be edited.""" + result: Union[UpdatePayload] = Field(UpdatePayload(), alias="payload") + """update for message content.""" + opts: Optional[ResultOptions] = None + """update for options update. *Not used for now*.""" diff --git a/botx/models/responses.py b/botx/models/responses.py new file mode 100644 index 00000000..cf6e0d01 --- /dev/null +++ b/botx/models/responses.py @@ -0,0 +1,30 @@ +"""pydantic models for responses from BotX API.""" + +from uuid import UUID + +from pydantic import BaseModel, Field + +from botx.models.enums import Statuses + + +class TokenResponse(BaseModel): + """Response form for BotX API token request.""" + + result: str + """obtained token from request to BotX API.""" + + +class PushResult(BaseModel): + """Entity that contains result from notification or command result push.""" + + sync_id: UUID + """event id of pushed message.""" + + +class PushResponse(BaseModel): + """Entity that will be returned from BotX API command result push.""" + + status: Statuses = Field(Statuses.ok, const=True) + """operation status of push.""" + result: PushResult + """operation result.""" diff --git a/botx/models/sending.py b/botx/models/sending.py new file mode 100644 index 00000000..2e7d775c --- /dev/null +++ b/botx/models/sending.py @@ -0,0 +1,239 @@ +"""Entities that are used in sending operations.""" + +from typing import List, Optional, Type, TypeVar, Union +from uuid import UUID + +from pydantic import BaseModel, Field, validator + +from botx.models.buttons import BubbleElement, Button, KeyboardElement +from botx.models.constants import MAXIMUM_TEXT_LENGTH +from botx.models.enums import Recipients +from botx.models.files import File +from botx.models.mentions import Mention +from botx.models.typing import BubbleMarkup, KeyboardMarkup + + +class SendingCredentials(BaseModel): + """Credentials that are required to send command or notification result.""" + + sync_id: Optional[UUID] = None + """message event id.""" + chat_id: Optional[UUID] = None + """chat id in which bot should send message.""" + chat_ids: List[UUID] = [] + """list of chats that should receive message.""" + bot_id: Optional[UUID] = None + """bot that handles message.""" + host: Optional[str] = None + """host on which bot answers.""" + token: Optional[str] = None + """token that is used for bot authorization on requests to BotX API.""" + + @validator("chat_ids", always=True, whole=True) + def receiver_id_should_be_passed( + cls, value: List[UUID], values: dict # noqa: N805 + ) -> List[UUID]: + """Add `chat_id` in `chat_ids` if was passed. + + Arguments: + value: value that should be checked. + values: all other values checked before. + """ + if values["chat_id"]: + value.append(values["chat_id"]) + elif not (value or values["sync_id"]): + raise ValueError( + "sync_id, chat_id or chat_ids should be passed to initialization" + ) + + return value + + +TUIElement = TypeVar("TUIElement", bound=Button) + + +class MessageMarkup(BaseModel): + """Collection for bubbles and keyboard with some helper methods.""" + + bubbles: List[List[BubbleElement]] = [] + """bubbles that will be attached to message.""" + keyboard: List[List[KeyboardElement]] = [] + """keyboard elements that will be attached to message.""" + + def add_bubble( + self, + command: str, + label: Optional[str] = None, + data: Optional[dict] = None, + *, + new_row: bool = True, + ) -> None: + """Add new bubble button to markup. + + Arguments: + command: command that will be triggered on bubble click. + label: label that will be shown on bubble. + data: payload that will be attached to bubble. + new_row: place bubble on new row or on current. + """ + self._add_ui_element( + ui_cls=BubbleElement, + ui_array=self.bubbles, + command=command, + label=label, + data=data, + new_row=new_row, + ) + + def add_bubble_element( + self, element: BubbleElement, *, new_row: bool = True + ) -> None: + """Add new button to markup from existing element. + + Arguments: + element: existed bubble element. + new_row: place bubble on new row or on current. + """ + self._add_ui_element( + ui_cls=BubbleElement, + ui_array=self.bubbles, + command=element.command, + label=element.label, + data=element.data, + new_row=new_row, + ) + + def add_keyboard_button( + self, + command: str, + label: Optional[str] = None, + data: Optional[dict] = None, + *, + new_row: bool = True, + ) -> None: + """Add new keyboard button to markup. + + Arguments: + command: command that will be triggered on keyboard click. + label: label that will be shown on keyboard button. + data: payload that will be attached to keyboard. + new_row: place keyboard on new row or on current. + """ + self._add_ui_element( + ui_cls=KeyboardElement, + ui_array=self.keyboard, + command=command, + label=label, + data=data, + new_row=new_row, + ) + + def add_keyboard_button_element( + self, element: KeyboardElement, *, new_row: bool = True + ) -> None: + """Add new keyboard button to markup from existing element. + + Arguments: + element: existed keyboard button element. + new_row: place keyboard button on new row or on current. + """ + self._add_ui_element( + ui_cls=KeyboardElement, + ui_array=self.keyboard, + command=element.command, + label=element.label, + data=element.data, + new_row=new_row, + ) + + def _add_ui_element( # noqa: WPS211 + self, + ui_cls: Type[TUIElement], + ui_array: List[List[TUIElement]], + command: str, + label: Optional[str] = None, + data: Optional[dict] = None, + new_row: bool = True, + ) -> None: + """Add new button to bubble or keyboard arrays. + + Arguments: + ui_cls: UIElement instance that should be added to array. + ui_array: storage for ui elements. + command: command that will be triggered on ui element click. + label: label that will be shown on ui element. + data: payload that will be attached to ui element. + new_row: place ui element on new row or on current. + """ + element = ui_cls(command=command, label=label, data=data or {}) + + if new_row: + ui_array.append([element]) + return + + if not ui_array: + ui_array.append([]) + + ui_array[-1].append(element) + + +class NotificationOptions(BaseModel): + """Configurations for message notifications.""" + + send: bool = True + """show notification about message.""" + force_dnd: bool = False + """break mute on bot messages.""" + + +class MessageOptions(BaseModel): + """Message options configuration.""" + + recipients: Union[List[UUID], Recipients] = Recipients.all + """users that should receive message.""" + mentions: List[Mention] = [] + """attached to message mentions.""" + notifications: NotificationOptions = NotificationOptions() + """notification configuration.""" + + +class MessagePayload(BaseModel): + """Message payload configuration.""" + + text: str = Field("", max_length=MAXIMUM_TEXT_LENGTH) + """message text.""" + file: Optional[File] = None + """attached to message file.""" + markup: MessageMarkup = MessageMarkup() + """message markup.""" + options: MessageOptions = MessageOptions() + """message configuration.""" + + +class UpdatePayload(BaseModel): + """Payload for message edition.""" + + text: Optional[str] = Field(None, max_length=MAXIMUM_TEXT_LENGTH) + """new message text.""" + keyboard: Optional[KeyboardMarkup] = None + """new message bubbles.""" + bubbles: Optional[BubbleMarkup] = None + """new message keyboard.""" + mentions: Optional[List[Mention]] = None + """new message mentions.""" + opts: Optional[NotificationOptions] = None + """new message options.""" + + @property + def markup(self) -> MessageMarkup: + """Markup for edited message.""" + return MessageMarkup(bubbles=self.bubbles or [], keyboard=self.keyboard or []) + + def set_markup(self, markup: MessageMarkup) -> None: + """Markup for edited message. + + Arguments: + markup: markup that should be applied to payload. + """ + self.bubbles = markup.bubbles + self.keyboard = markup.keyboard diff --git a/botx/models/status.py b/botx/models/status.py deleted file mode 100644 index b1ece6ad..00000000 --- a/botx/models/status.py +++ /dev/null @@ -1,16 +0,0 @@ -from typing import List - -from .base import BotXType -from .common import MenuCommand -from .enums import StatusEnum - - -class StatusResult(BotXType): - enabled: bool = True - status_message: str = "Bot is working" - commands: List[MenuCommand] = [] - - -class Status(BotXType): - status: StatusEnum = StatusEnum.ok - result: StatusResult = StatusResult() diff --git a/botx/models/typing.py b/botx/models/typing.py new file mode 100644 index 00000000..1fbaeea9 --- /dev/null +++ b/botx/models/typing.py @@ -0,0 +1,15 @@ +"""Aliases for complex types from `typing` for models.""" + +from typing import List, Union +from uuid import UUID + +from botx.models.buttons import BubbleElement, KeyboardElement +from botx.models.enums import Recipients + +BubblesRow = List[BubbleElement] +BubbleMarkup = List[BubblesRow] + +KeyboardRow = List[KeyboardElement] +KeyboardMarkup = List[KeyboardRow] + +AvailableRecipients = Union[List[UUID], Recipients] diff --git a/botx/models/ui.py b/botx/models/ui.py deleted file mode 100644 index 07709ab6..00000000 --- a/botx/models/ui.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import Any, List, Optional, Type, TypeVar - -from .base import BotXType - - -class UIElement(BotXType): - command: str - label: Optional[str] = None - - def __init__(self, **data: Any) -> None: - super().__init__(**data) - self.label = self.label or self.command - - -TUIElement = TypeVar("TUIElement", bound=UIElement) - - -class BubbleElement(UIElement): - pass - - -class KeyboardElement(UIElement): - pass - - -def add_ui_element( - ui_cls: Type["TUIElement"], - ui_array: List[List["TUIElement"]], - command: str, - label: Optional[str] = None, - *, - new_row: bool = True, -) -> None: - element = ui_cls(command=command, label=label) - - if new_row: - ui_array.append([element]) - else: - ui_array[-1].append(element) diff --git a/botx/params.py b/botx/params.py new file mode 100644 index 00000000..5f7f0a47 --- /dev/null +++ b/botx/params.py @@ -0,0 +1,19 @@ +"""Wrappers around param classes that are used in handlers or dependencies.""" + +from typing import Any, Callable + +from botx.dependencies import models + + +def Depends(dependency: Callable, *, use_cache: bool = True) -> Any: # noqa: N802 + """Wrap Depends param for using in handlers. + + Arguments: + dependency: callable object that will be used in handlers or other dependencies + instances. + use_cache: use cache for dependency. + + Returns: + [Depends][botx.dependencies.models.Depends] that wraps passed callable. + """ + return models.Depends(dependency=dependency, use_cache=use_cache) diff --git a/botx/sync.py b/botx/sync.py deleted file mode 100644 index 5896c09d..00000000 --- a/botx/sync.py +++ /dev/null @@ -1,66 +0,0 @@ -import multiprocessing -from concurrent.futures.thread import ThreadPoolExecutor -from typing import Any, Callable, Dict, List, Optional - -from .bots import AsyncBot -from .dispatchers import AsyncDispatcher -from .execution import execute_callback_with_exception_catching -from .helpers import call_coroutine_as_function -from .models import BotCredentials, Status - -WORKERS_COUNT = multiprocessing.cpu_count() * 4 - - -class SyncDispatcher(AsyncDispatcher): - _pool: ThreadPoolExecutor - - def __init__(self, tasks_limit: int) -> None: - super().__init__() - self._pool = ThreadPoolExecutor(max_workers=tasks_limit) - - def start(self) -> None: # type: ignore - pass - - def shutdown(self) -> None: # type: ignore - self._pool.shutdown() - - def status(self) -> Status: - return call_coroutine_as_function(super().status) - - def execute_command(self, data: Dict[str, Any]) -> None: # type: ignore - self._pool.submit( - call_coroutine_as_function, - execute_callback_with_exception_catching, - self.exception_catchers, - self._get_callback_copy_for_message_data(data), - ) - - -class SyncBot(AsyncBot): - _dispatcher: SyncDispatcher - - def __init__( - self, - *, - concurrent_tasks: int = WORKERS_COUNT, - credentials: Optional[BotCredentials] = None, - dependencies: Optional[List[Callable]] = None, - ) -> None: - super().__init__(credentials=credentials, dependencies=dependencies) - - self._dispatcher = SyncDispatcher(concurrent_tasks) - - def start(self) -> None: # type: ignore - self._dispatcher.start() - - def stop(self) -> None: # type: ignore - self._dispatcher.shutdown() - - def status(self) -> Status: - return self._dispatcher.status() - - def execute_command(self, data: Dict[str, Any]) -> None: # type: ignore - self._dispatcher.execute_command(data) - - -Bot = SyncBot diff --git a/botx/testing.py b/botx/testing.py new file mode 100644 index 00000000..94ecd6b3 --- /dev/null +++ b/botx/testing.py @@ -0,0 +1,421 @@ +"""Definition for test client for bots.""" + +import uuid +from typing import Any, BinaryIO, Callable, List, Optional, TextIO, Tuple, Union + +import httpx +from starlette import status +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from botx.api_helpers import BotXAPI +from botx.bots import Bot +from botx.models import enums, events, files, receiving, requests, responses +from botx.models.enums import ChatTypes +from botx.models.requests import UpdatePayload + +APIMessage = Union[ + requests.CommandResult, requests.Notification, requests.UpdatePayload +] + + +class _BotXAPICallbacksFactory: + """Factory for generating mocks for BotX API.""" + + _error_response = JSONResponse( + {"result": "API error"}, status_code=status.HTTP_400_BAD_REQUEST + ) + + def __init__( + self, messages: List[APIMessage], generate_errored: bool = False + ) -> None: + """Init factory with required params. + + Arguments: + messages: list of entities that will be sent from bot. + generate_errored: generate endpoints that will return errored views. + """ + self.messages = messages + self.generate_errored = generate_errored + + def get_command_result_callback(self) -> Callable: + """Generate callback for command result endpoint.""" # noqa: D202 + + async def factory(request: Request) -> Response: + command_result = requests.CommandResult.parse_obj(await request.json()) + self.messages.append(command_result) + + if self.generate_errored: + return self._error_response + + return Response( + responses.PushResponse( + result=responses.PushResult(sync_id=uuid.uuid4()) + ).json(), + media_type="application/json", + ) + + return factory + + def get_notification_callback(self) -> Callable: + """Generate callback for notification endpoint.""" # noqa: D202 + + async def factory(request: Request) -> Response: + notification = requests.Notification.parse_obj(await request.json()) + self.messages.append(notification) + + if self.generate_errored: + return self._error_response + + return Response( + responses.PushResponse( + result=responses.PushResult(sync_id=uuid.uuid4()) + ).json(), + media_type="application/json", + ) + + return factory + + def get_token_callback(self) -> Callable: + """Generate callback for token endpoint.""" # noqa: D202 + + async def factory(_: Request) -> JSONResponse: + if self.generate_errored: + return self._error_response + + return JSONResponse(responses.TokenResponse(result="real token").dict()) + + return factory + + def get_update_callback(self) -> Callable: + """Generate callback for message update endpoint.""" # noqa: D202 + + async def factory(request: Request) -> JSONResponse: + update = UpdatePayload.parse_obj((await request.json())["result"]) + self.messages.append(update) + if self.generate_errored: + return self._error_response + + return JSONResponse({}) + + return factory + + +def _botx_api_mock( + messages: List[APIMessage], generate_errored: bool = False +) -> Starlette: + """Get ASGI application for mock HTTP client for BotX API. + + Arguments: + messages*: messages that stores entities sent to BotX API. + generate_errored*: generate endpoints that will return errored views. + + Returns: + Starlette mock application. + """ + factory = _BotXAPICallbacksFactory(messages, generate_errored) + app = Starlette() + app.add_route( + BotXAPI.command_endpoint.endpoint, + factory.get_command_result_callback(), + [BotXAPI.command_endpoint.method], + ) + app.add_route( + BotXAPI.notification_endpoint.endpoint, + factory.get_notification_callback(), + [BotXAPI.notification_endpoint.method], + ) + app.add_route( + BotXAPI.token_endpoint.endpoint, + factory.get_token_callback(), + [BotXAPI.token_endpoint.method], + ) + app.add_route( + BotXAPI.edit_event_endpoint.endpoint, + factory.get_update_callback(), + [BotXAPI.edit_event_endpoint.method], + ) + return app + + +# There are a lot of properties and functions here, since message is a complex type +# that should be carefully validated. +# Properties are count as normal functions, so disable +# 1) too many methods (a lot of properties) +# 2) block variables overlap (setters are counted as replacement for functions) +class MessageBuilder: # noqa: WPS214 + """Builder for command message for bot.""" + + def __init__( + self, + body: str = "", + bot_id: Optional[uuid.UUID] = None, + user: Optional[receiving.User] = None, + ) -> None: + """Init builder with required params. + + Arguments: + body: command body. + bot_id: id of bot that will be autogenerated if not passed. + user: user from which command was received. + """ + self._user: receiving.User = user or self._default_user + self._bot_id = bot_id or uuid.uuid4() + self._body: str = "" + self._is_system_command: bool = False + self._command_data: dict = {} + self._file: Optional[files.File] = None + + # checks for special invariants for events + self._event_checkers = { + events.SystemEvents.chat_created: self._check_chat_created_event, + events.SystemEvents.file_transfer: self._check_file_transfer_event, + } + + self.body = body + + @property + def bot_id(self) -> uuid.UUID: + """Id of bot.""" + return self._bot_id + + @bot_id.setter # noqa: WPS440 + def bot_id(self, bot_id: uuid.UUID) -> None: + """Id of bot.""" + self._bot_id = bot_id + + @property + def body(self) -> str: + """Message body.""" + return self._body + + @body.setter # noqa: WPS440 + def body(self, body: str) -> None: + """Message body.""" + self._check_system_command_properties( + body, self._is_system_command, self._command_data + ) + self._body = body + + @property + def command_data(self) -> dict: + """Additional command data.""" + return self._command_data + + @command_data.setter # noqa: WPS440 + def command_data(self, command_data: dict) -> None: + """Additional command data.""" + self._command_data = command_data + + @property + def system_command(self) -> bool: + """Is command a system event.""" + return self._is_system_command + + @system_command.setter # noqa: WPS440 + def system_command(self, is_system_command: bool) -> None: + """Is command a system event.""" + self._check_system_command_properties( + self._body, is_system_command, self._command_data + ) + self._is_system_command = is_system_command + + @property + def file(self) -> Optional[files.File]: + """File attached to message.""" + return self._file + + @file.setter # noqa: WPS440 + def file(self, file: Optional[Union[files.File, BinaryIO, TextIO]]) -> None: + """File that will be attached to message.""" + if isinstance(file, files.File) or file is None: + self._file = file + else: + self._file = files.File.from_file(file) + + @property + def user(self) -> receiving.User: + """User from which message will be received.""" + return self._user + + @user.setter # noqa: WPS440 + def user(self, user: receiving.User) -> None: + """User from which message will be received.""" + self._user = user + + @property + def message(self) -> receiving.IncomingMessage: + """Message that was built by builder.""" + command_type = ( + enums.CommandTypes.system + if self.system_command + else enums.CommandTypes.user + ) + command = receiving.Command( + body=self.body, command_type=command_type, data=self.command_data + ) + return receiving.IncomingMessage( + sync_id=uuid.uuid4(), + command=command, + file=self.file, + bot_id=self.bot_id, + user=self.user, + ) + + @property + def _default_user(self) -> receiving.User: + """User that will be used in __init__ as fallback.""" + return receiving.User( + user_huid=uuid.uuid4(), + group_chat_id=uuid.uuid4(), + chat_type=ChatTypes.chat, + ad_login="test_user", + ad_domain="example.com", + username="Test User", + is_admin=True, + is_creator=True, + host="cts.example.com", + ) + + def _check_system_command_properties( + self, body: str, is_system_command: bool, command_data: dict + ) -> None: + """Check that system event message is valid. + + Arguments: + body: message body. + is_system_command: flag that command is system event. + command_data: additional data that will be included into message and should + be validated. + """ + if is_system_command: + event = events.SystemEvents(body) # check that is real system event + event_shape = events.EVENTS_SHAPE_MAP.get(event) + if event_shape: + event_shape.parse_obj(command_data) # check event data + self._event_checkers[event]() + + def _check_chat_created_event(self) -> None: + """Check invariants for `system:chat_created` event.""" + assert ( + not self.user.user_huid + ), "A user in system:chat_created can not have user_huid" + assert ( + not self.user.ad_login + ), "A user in system:chat_created can not have ad_login" + assert ( + not self.user.ad_domain + ), "A user in system:chat_created can not have ad_domain" + assert ( + not self.user.username + ), "A user in system:chat_created can not have username" + + def _check_file_transfer_event(self) -> None: + """Check invariants for `file_transfer` event.""" + assert self.file, "file_transfer event should have attached file" + + +class TestClient: # noqa: WPS214 + """Test client for testing bots.""" + + def __init__(self, bot: Bot, generate_error_api: bool = False) -> None: + """Init client with required params. + + Arguments: + bot: bot that should be tested. + generate_error_api: mocked BotX API will return errored responses. + """ + self.bot: Bot = bot + """Bot that will be patched for tests.""" + self._original_http_client = bot.client.http_client + self._messages: List[APIMessage] = [] + self._generate_error_api = generate_error_api + + @property + def generate_error_api(self) -> bool: + """Regenerate BotX API mock.""" + return self._generate_error_api + + @generate_error_api.setter + def generate_error_api(self, generate_errored: bool) -> None: + """Regenerate BotX API mock.""" + self._generate_error_api = generate_errored + self.bot.client.http_client = httpx.AsyncClient( + app=_botx_api_mock(self._messages, self.generate_error_api) + ) + + def __enter__(self) -> "TestClient": + """Mock original HTTP client.""" + self.bot.client.http_client = httpx.AsyncClient( + app=_botx_api_mock(self._messages, self.generate_error_api) + ) + + return self + + def __exit__(self, *_: Any) -> None: + """Restore original HTTP client and clear storage.""" + self.bot.client.http_client = self._original_http_client + self._messages = [] + + async def send_command( + self, message: receiving.IncomingMessage, sync: bool = True + ) -> None: + """Send command message to bot. + + Arguments: + message: message with command for bot. + sync: if is `True` then wait while command is full executed. + """ + await self.bot.execute_command(message.dict()) + + if sync: + await self.bot.shutdown() + + @property + def messages(self) -> Tuple[APIMessage, ...]: + """Return all entities that were sent by bot. + + Returns: + Sequence of messages that were sent from bot. + """ + return tuple(message.copy(deep=True) for message in self._messages) + + @property + def command_results(self) -> Tuple[requests.CommandResult, ...]: + """Return all command results that were sent by bot. + + Returns: + Sequence of command results that were sent from bot. + """ + return tuple( + message + for message in self.messages + if isinstance(message, requests.CommandResult) + ) + + @property + def notifications(self) -> Tuple[requests.Notification, ...]: + """Return all notifications that were sent by bot. + + Returns: + Sequence of notifications that were sent by bot. + """ + return tuple( + message + for message in self.messages + if isinstance(message, requests.Notification) + ) + + @property + def message_updates(self) -> Tuple[requests.UpdatePayload, ...]: + """Return all updates that were sent by bot. + + Returns: + Sequence of updates that were sent by bot. + """ + return tuple( + message + for message in self.messages + if isinstance(message, requests.UpdatePayload) + ) diff --git a/botx/typing.py b/botx/typing.py new file mode 100644 index 00000000..eec93024 --- /dev/null +++ b/botx/typing.py @@ -0,0 +1,17 @@ +"""Aliases for complex types from `typing`.""" + +from typing import Any, Awaitable, Callable, Coroutine, TypeVar, Union + +from botx.models import messages + +ExceptionT = TypeVar("ExceptionT", bound=Exception) + +AsyncExecutor = Callable[[messages.Message], Coroutine[Any, Any, None]] +SyncExecutor = Callable[[messages.Message], None] +Executor = Union[AsyncExecutor, SyncExecutor] +MiddlewareDispatcher = Callable[[messages.Message, Executor], Awaitable[None]] +AsyncExceptionHandler = Callable[ + [ExceptionT, messages.Message], Coroutine[Any, Any, None] +] +SyncExceptionHandler = Callable[[ExceptionT, messages.Message], None] +ExceptionHandler = Union[AsyncExceptionHandler, SyncExceptionHandler] diff --git a/botx/utils.py b/botx/utils.py new file mode 100644 index 00000000..c3074e20 --- /dev/null +++ b/botx/utils.py @@ -0,0 +1,212 @@ +"""Some helper functions that are used in library.""" + +import inspect +from typing import Callable, List, Optional, Sequence, TypeVar +from uuid import UUID + +from httpx import Response + +from botx.models import messages +from botx.models.files import File +from botx.models.sending import MessagePayload, SendingCredentials, UpdatePayload + +TSequenceElement = TypeVar("TSequenceElement") + + +def optional_sequence_to_list( + seq: Optional[Sequence[TSequenceElement]] = None, +) -> List[TSequenceElement]: + """Convert optional sequence of elements to list. + + Arguments: + seq: sequence that should be converted to list. + + Returns: + List of passed elements. + """ + return list(seq or []) + + +def get_name_from_callable(handler: Callable) -> str: + """Get auto name from given callable object. + + Arguments: + handler: callable object that will be used to retrieve auto name for handler. + + Returns: + Name obtained from callable. + """ + is_function = inspect.isfunction(handler) + is_method = inspect.ismethod(handler) + is_class = inspect.isclass(handler) + if is_function or is_method or is_class: + return handler.__name__ + return handler.__class__.__name__ + + +class LogsShapeBuilder: # noqa: WPS214 + """Helper for obtaining dictionaries for loguru payload.""" + + @classmethod + def get_token_request_shape(cls, host: str, bot_id: UUID, signature: str) -> dict: + """Get shape for obtaining token request. + + Arguments: + host: host for sending request. + bot_id: bot_id for token. + signature: query param with bot signature. + + Returns: + Shape for logging in loguru. + """ + return {"host": host, "bot_id": bot_id, "signature": signature} + + @classmethod + def get_response_shape(cls, response: Response) -> dict: + """Get shape for response from BotX API. + + Arguments: + response: response from BotX API. + + Returns: + Shape for logging in loguru. + """ + response_content = response.json() + + return { + "status_code": response.status_code, + "request_url": response.request.url if response.request else None, + "response_content": response_content, + } + + @classmethod + def get_notification_shape( + cls, credentials: SendingCredentials, payload: MessagePayload + ) -> dict: + """Get shape for notification that will be sent to BotX API. + + Arguments: + credentials: credentials for notification. + payload: notification payload. + + Returns: + Shape for logging in loguru. + """ + return { + "credentials": credentials.dict(exclude={"token", "sync_id", "chat_id"}), + "payload": cls.get_payload_shape(payload), + } + + @classmethod + def get_command_result_shape( + cls, credentials: SendingCredentials, payload: MessagePayload + ) -> dict: + """Get shape for command result that will be sent to BotX API. + + Arguments: + credentials: credentials for command result. + payload: command result payload. + + Returns: + Shape for logging in loguru. + """ + return { + "credentials": credentials.dict(exclude={"token", "chat_ids", "chat_id"}), + "payload": cls.get_payload_shape(payload), + } + + @classmethod + def get_edition_shape( + cls, credentials: SendingCredentials, payload: UpdatePayload + ) -> dict: + """Get shape for event edition that will be send to BotX API. + + Arguments: + credentials: credentials for event edition. + payload: event edition payload. + + Returns: + Shape for logging in loguru. + """ + return { + "credentials": credentials.dict( + exclude={"token", "bot_id", "chat_ids", "chat_id"} + ), + "payload": payload.copy( + update={ + "text": cls._convert_text_to_logs_format(payload.text) + if payload.text + else None + } + ).dict(exclude_none=True), + } + + @classmethod + def get_payload_shape(cls, payload: MessagePayload) -> dict: + """Get shape for payload that will be sent to BotX API. + + Arguments: + payload: payload. + + Returns: + Shape for logging in loguru. + """ + return payload.copy( + update={ + "text": cls._convert_text_to_logs_format(payload.text), + "file": cls._convert_file_to_logs_format(payload.file), + } + ).dict() + + @classmethod + def get_message_shape(cls, message: messages.Message) -> dict: + """Get shape for incoming message from BotX API. + + Arguments: + message: incoming message. + + Returns: + Shape for logging in loguru. + """ + return message.incoming_message.copy( + update={ + "body": cls._convert_text_to_logs_format(message.body), + "file": cls._convert_file_to_logs_format(message.file), + } + ).dict() + + @classmethod + def _convert_text_to_logs_format(cls, text: str) -> str: + """Convert text into format that is suitable for logs. + + Arguments: + text: text that should be formatted. + + Returns: + Shape for logging in loguru. + """ + max_log_text_length = 50 + start_text_index = 15 + end_text_index = 5 + + return ( + "...".join((text[:start_text_index], text[:-end_text_index])) + if len(text) > max_log_text_length + else text + ) + + @classmethod + def _convert_file_to_logs_format(cls, file: Optional[File]) -> Optional[File]: + """Convert file to a new file that will be showed in logs. + + Arguments: + file: file that should be converted. + + Returns: + New file or nothing. + """ + return ( + File.from_string("[file content]", filename=file.file_name) + if file + else None + ) diff --git a/docs/api-reference/bots.md b/docs/api-reference/bots.md deleted file mode 100644 index 6af5aca8..00000000 --- a/docs/api-reference/bots.md +++ /dev/null @@ -1,65 +0,0 @@ -`pybotx` provides `Bot` class writing bots that inherit handlers registration behaviour from `HandlersCollector`: - -* `Bot(*, concurrent_tasks, credentials)` - bot that provides an asynchronous sdk. - * `concurrent_tasks: int = 1500` - the number of coroutines that can be executed by the bot at the same time, the rest will be in the queue. - * `credentials: Optional[BotCredentials] = None` -* `sync.Bot(*, tasks_limit, credentials)` - bot that provides a synchronous sdk. - * `tasks_limit: int` - the number of threads that can be used by the bot to execute handlers at the same time, by default - numbers of CPU cores X 4 - * `credentials: Optional[BotCredentials] = None` - -### Lifespan methods -* `.start()` - runs the necessary parts that can not be run in the initializer. -* `.stop()` - stops what was started at the `.start`. - -### Commands execution -* `.execute_command(data)` - start the handler associated with the command from the date. - * `data: Dict[str, Any]` - message payload, should be serializable into `Message`. - -### Sending messages to `BotX API` -* `.reply(message)` - send a message using the `ReplyMessage` created earlier in the code manually. - * `message: ReplyMessage` - previously created and configured message object. -* `.send_message(text, credentials, *, file, markup, options)` - send message to chat. - * `text: str` - the text that will be send to chat. - * `credentials: SendingCredentials` - an object to specify data for sending message. - * `file: Optional[Union[TextIO, BinaryIO] = None` - file-like object that will be attached to the message (unsupported on clients for now). - * `markup: Optional[Union[BinaryIO, TextIO]] = None` - an object to add specials UI elements to the message, like bubbles and keyboard buttons. - * `options: Optional[NotifyOptions] = None` - an object for specifying additional configuration for messages, for example, -for displaying notifications, users who will receive messages and mentions. -* `.answer_message(text, message, *, file, recipients, mentions, bubble, keyboard, opts)` - send chat message using `message` passed to handler. - * `text: str` - the text that will be send to chat. - * `message: Message` - the message object passed to your handler - * `file: Optional[Union[TextIO, BinaryIO]] = None` - * `markup: Optional[Union[BinaryIO, TextIO]] = None` - * `options: Optional[NotifyOptions] = None` -* `.send_file(file, sync_id, bot_id, host)` - send file to chat using message id. - * `file: Union[TextIO, BinaryIO]` - file-like object that will be sent to user. - * `credentials: SendingCredentials` - -### Properties - -* `.credentials: BotCredentials` - collection of registered `CTS` with secretes and tokens in it. - -### Handlers Registrations -* `.register_next_step_handler(message, callback, *args, **kwargs)` - register the handler that will be used to execute the next command from the user. - * `message: Message` - message whose properties will be used to register the handler. - * `callback: Callable` - callable object or coroutine to use as handler. - * `*args: Any` - additional positional arguments that will be passed to the handler. - * `**kwargs: Any` - additional key arguments to be passed to the handler. - -### Errors Handlers Registrations -* `.exception_catcher(exceptions, force_replace)` - decorator to register a handler that will be used when an exception occurs during -the execution of a handler for a command. - * `exceptions: List[Type[Exception]]` - list of exception types that will be handled by exception handler - * `force_replace: bool = False` - replace the existing handler for exception with a new one if the new one, -otherwise `.exception_catcher` will raise a `BotXException` exception if there are duplicates. - -### External setters/getters -* `.add_credentials(credentials)` - add credentials of known CTS with hosts and their secret keys. - * `credentials: BotCredentials` - an instance of `BotCredentials` with a list of registered `CTS` and obtained tokens. -* `.add_cts(cts)` - register one instance of `CTS`. - * `cts: CTS` - instance of the registered `CTS`. -* `.get_cts_by_host(host) -> str` - helper to get `CTS` by host name. - * `host: str` - `CTS` host name. -* `.get_token_from_cts(host) -> str` - helper to get token from registered `CTS` instance if there was one get or raise` BotXException`. - * `host: str` - `CTS` host name. -* `.status() -> Status` - the status that should be returned to the BotX API when calling `/status`, and can also be useful in the create `/help` command. diff --git a/docs/api-reference/botx-types.md b/docs/api-reference/botx-types.md deleted file mode 100644 index afeadc82..00000000 --- a/docs/api-reference/botx-types.md +++ /dev/null @@ -1,297 +0,0 @@ -All these classes are `pydantic` models except `BotXException` and enums. - -### CommandHandler - -* `CommandHandler` - * `.name: str` - * `.command: Pattern` - * `.description: str` - * `.callback: CommandCallback` - * `.exclude_from_status: bool = False` - * `.use_as_default_handler: bool = False` - * `.options: Dict[str, Any] = {}` - * `.elements: List[CommandUIElement] = []` - * `.to_status_command() -> Optional[MenuCommand]` - -#### CommandCallback -* `CommandCallback` - * `.callback: Callable` - * `.args: Tuple[Any, ...] = ()` - * `.kwargs: Dict[str, Any] = {}` - * `.background_dependencies: List[Dependency] = []` - -### Dependencies - -* `Dependency` - * `.call: Callable` - -* `Depends(dependency: Callable) -> Any` - -### Status - -* `Status` - * `.status: StatusEnum = StatusEnum.ok` - * `.result: StatusResult = StatusResult()` - -#### StatusResult - -* `StatusResult` - * `.enabled: bool = True` - * `.status_message: str = "Bot is working"` - * `.commands: List[MenuCommand] = []` - -#### MenuCommand - -* `MenuCommand` - * `.description: str` - * `.body: str` - * `.name: str` - * `.options: Dict[str, Any] = {}` - * `.elements: List[CommandUIElement] = []` - -#### CommandUIElement - -* `CommandUIElement` - * `.type: str` - * `.label: str` - * `.order: Optional[int] = None` - * `.value: Optional[Any] = None` - * `.name: Optional[str] = None` - * `.disabled: Optional[bool] = None` - -### SendingCredentials - -* `SendingCredentials` - * `.sync_id: Optional[UUID] = None` - * `.chat_ids: List[UUID] = []` - * `.bot_id: UUID` - * `.host: str` - * `.token: Optional[str] = None` - - -### Message - -* `Message` - * `.sync_id: UUID` - * `.command: MessageCommand` - * `.file: Optional[File] = None` - * `.user: MessageUser` - * `.bot_id: UUID` - * `.body: str` - * `.data: CommandDataType` - * `.user_huid: Optional[UUID]` - * `.ad_login: Optional[str]` - * `.group_chat_id: UUID` - * `.chat_type: str` - * `.host: str` - -#### MessageUser - -* `MessageUser` - * `.user_huid: Optional[UUID]` - * `.group_chat_id: UUID` - * `.chat_type: ChatTypeEnum` - * `.ad_login: Optional[str]` - * `.ad_domain: Optional[str]` - * `.username: Optional[str]` - * `.is_admin: bool` - * `.is_creator: bool` - * `.host: str` - * `.email: Optional[str]` - -#### MessageCommand - -* `MessageCommand` - * `.body: str` - * `.command_type: CommandTypeEnum` - * `.data: CommandDataType = {}` - * `.command: str` - * `.arguments: List[str]` - * `.single_argument: str` - -### ReplyMessage - -* `ReplyMessage` - * `.text: str` - * `.sync_id: UUID` - * `.chat_ids: List[UUID]` - * `.bot_id: UUID` - * `.host: str` - * `.recipients: Union[List[UUID], str] = ResponseRecipientsEnum.all` - * `.mentions: List[Mention] = []` - * `.bubble: List[List[BubbleElement]] = []` - * `.keyboard: List[List[KeyboardElement]] = []` - * `.opts: NotificationOpts = NotificationOpts()` - * `.file: Optional[File] = None` - --- - - * `.chat_id: UUID readonly` - - --- - * `.add_file(file)` - * `.mention_user(user_huid, name)` - * `.add_recipient(recipient)` - * `.add_recipients(recipients)` - * `.add_bubble(command, label, *, data, new_row)` - * `.add_keyboard_button(command, label, *, data, new_row)` - * `.show_notification(show)` - * `.force_notification(force)` - --- - * `ReplyMessage.from_message(text, message) -> ReplyMessage` - - -### File - -* `File` - * `.data: str` - * `.file_name: str` - * `.file: BinaryIO` - * `.raw_data: bytes` - * `.media_type: str` - --- - * `File.from_file(file) -> File` - - -### Markup - -* `MessageMarkup` - * `bubbles: List[List[BubbleElement]] = []` - * `keyboard: List[List[KeyboardElement]] = []` - - -#### Bubbles - -* `BubbleElement` - * `.command: str` - * `.label: Optional[str] = None` - * `.data: Dict[str, Any] = {}` - -#### Keyboards - -* `KeyboardElement` - * `.command: str` - * `.label: Optional[str] = None` - * `.data: Dict[str, Any] = {}` - -### Options - -* `MessageOptions` - * `.recipients: Union[List[UUID], ResponseRecipientsEnum, str] = ResponseRecipientsEnum.all` - * `.mentions: List[Mention] = []` - * `.notifications: NotificationOpts = NotificationOpts()` - - -### Mention - -* `Mention` - * `.mention_type: MentionTypeEnum = MentionTypeEnum.user` - * `.mention_data: MentionUser` - -#### MentionUser - -* `MentionUser` - * `.user_huid: UUID` - * `.name: Optional[str] = None` - -### NotificationOpts - -* `NotificationOpts` - * `.send: bool = True` - * `.force_dnd: bool = False` - -### BotCredentials - -* `BotCredentials` - * `.known_cts: List[CTS] = []` - -#### CTS - -* `CTSCredentials` - * `.bot_id: UUID` - * `.token: str` - -#### CTSCredentials - -* `CTS`: - * `.host: str` - * `.secret_key: str` - * `.credentials: Optional[CTSCredentials] = None` - --- - - * `.calculate_signature(bot_id: UUID) -> str` - -### System Events Data - -#### `system:chat_created` - -* `UserInChatCreated` - * `.huid: UUID` - * `.user_kind: UserKindEnum` - * `.name: str` - * `.admin: bool` - - -* `ChatCreatedData` - * `.group_chat_id: UUID` - * `.chat_type: ChatTypeEnum` - * `.name: str` - * `.creator: UUID` - * `.members: List[UserInChatCreated]` - - -### Enums - -#### StatusEnum - -* `StatusEnum` - * `.ok: str = "ok"` - * `.error: str = "error"` - - -#### ResponseRecipientsEnum - -* `ResponseRecipientsEnum` - * `.all: str = "all"` - - -#### ChatTypeEnum - -* `ChatTypeEnum` - * `.chat: str = "chat"` - * `.group_chat: str = "group_chat"` - -#### MentionTypeEnum - -* `MentionTypeEnum` - * `.user: str = "user"` - * `.all: str = "all"` - * `.cts: str = "cts"` - * `.channel: str = "channel"` - - -#### SystemEventsEnum - -* `SystemEventsEnum` - * `.chat_created: str = "system:chat_created"` - -#### UserKindEnum - -* `UserKindEnum` - * `.bot: str = "botx"` - * `.user: str = "user"` - -#### CommandTypeEnum - -* `CommandTypeEnum` - * `.user: str = "user"` - * `.system: str = "system"` - - -#### BotXException - -* `BotXException(message, data)` - * `.message: str = ""` - * `.data: Optional[Dict[str, Any]] = None` - - -* `BotXDependencyFailure` diff --git a/docs/api-reference/handlers-collector.md b/docs/api-reference/handlers-collector.md deleted file mode 100644 index 86f339d6..00000000 --- a/docs/api-reference/handlers-collector.md +++ /dev/null @@ -1,66 +0,0 @@ -* `HandlersCollector()` - base class for collecting handlers. - -### Properties - -* `.handlers: Dict[Pattern, CommandHandler]` - dictionary of registered handlers with their command patterns. - -### Handlers Registration - -#### Common registration - -* `.add_handler(handler, force_replace)` - low-level version of handlers registration. - * `handler: CommandHandler` - an object to store information associated with the handler. - * `force_replace: bool = False` - replace the existing `CommandHandler` with a new one if the new handler has a -matching body with any of the existing ones, otherwise `.add_handler` will raise a `BotXException` exception if there are duplicates. - -* `.include_handlers(collector, force_replace)` - copy all handlers registered in the `collector` argument to this instance of the `collector`. - * `collector: HandleresCollector` - an instance of the `HandlersCollector` from which handlers should be copied. - * `force_replace: bool = False` - replace all handlers with the same body with new instances. - -#### Decorators - -All registration decorators return original `callback` after registration. - -* `.handler(callback, *, name, description, command, commands, use_as_default_handler, exclude_from_status)` - -register a handler for a `command` or `commands`. Only a `callback` argument is required, others can be generated automatically. - * `callback: Callable` - handler function that will be run to process the command. - * `name: Optional[str] = None` - command name (useless field, maybe later will be use inside `pybotx`), -by default the name of the function in lower case. - * `description: Optional[str] = None` - description of the command that will be displayed in the status, -by default built by the rule "`name.capitalize()` description" or by the `__doc__` attribute. - * `command: Optional[Union[str, Pattern]] = None` - body of the command for which the instance of `Bot` will -launch the associated handler, by default function name with the underscore replaced with a dash and a single forward slash added -if not `Pattern` passed as an argument. If the `Pattern` is passed as an argument, then only the unchanged pattern is used. - * `commands: Optional[List[str, Pattern]] = None` - list of command aliases that also start the execution of the handler. -Uses the same rules as the `command` argument. - * `use_as_default_handler: bool = False` - indicates that the handler will be used in the absence of other handlers for the command. - * `exclude_from_status: bool = False` - indicates that handler will not appear in the list of public commands. - * `callback: Callable` - * `name: Optional[str] = None` - * `command: Optional[Pattern] = None` - regular expression to handle the command. - * `commands: Optional[List[Pattern]] = None` - list of regular expressions. -* `.hidden_command_handler(callback, *, name, command, commands)` - register handler that does not appear in the commands menu. - * `callback: Callable` - * `name: Optional[str] = None` - * `command: Optional[Union[str, Pattern]] = None` - * `commands: Optional[List[Union[str, Pattern]]] = None` -* `.file_handler(callback)` - handler for transferring one file to bot. - * `callback: Callable` -* `.default_handler(callback)` - handler for any message that does not have an associated handler. - * `callback: Callable` -* `.system_event_handler(callback, *, event, name)` - handler for system events, such handlers will not appear in the command menu. - * `callback: Callable` - * `event: Union[str, SystemEventsEnum]` - enum of system events that can be sent to the bot through the BotX API. - * `name: Optional[str] = None` -* `.chat_created_handler(callback)` - handler for the `system:chat_created` system event. - -#### Handlers callable signature - -The signature of the callback should take 2 required arguments: - - * `message: Message` - the message that started the execution of the handler. - * `bot: Union[Bot, AsyncBot]` - the bot that handlers the message. - -It may also take additional arguments that will be passed to it during execution, -but these arguments must have default values ​​or be asterisks arguments, -or you must be sure that these arguments will be passed to the handler. diff --git a/docs/changelog.md b/docs/changelog.md index e6bde9a1..859f1acc 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,104 +1,206 @@ -## 0.12.4 +## 0.13.0 (Jan 20, 2020) + +!!! warning + A lot of breaking changes. See API reference for more information. + +### Added +* Added `Silent buttons`. +* Added `botx.TestClient` for writing tests for bots. +* Added `botx.MessageBuilder` for building message for tests. +* `botx.Bot` can now accept sequence of `collecting.Handler`. +* `botx.Message` is now not a pydantic model. Use `botx.IncomingMessage` in webhooks. + +### Changed + +* `AsyncBot` renamed to `Bot` in `botx.bots`. +* `.stop` method was renamed to `.shutdown` in `botx.Bot`. +* `UserKindEnum` renamed to `UserKinds`. +* `ChatTypeEnum` renamed to `ChatTypes`. + +### Removed + +* Removed `botx.bots.BaseBot`, only `botx.bots.Bot` is now available. +* Removed `botx.BotCredentials`. Credentials for bot should be registered via +sequence of `botx.ExpressServer` instances. +* `.credentials` property, `.add_credentials`, `.add_cts` methods were removed in `botx.Bot`. +Known hosts can be obtained via `.known_hosts` field. +* `.start` method in `botx.Bot` was removed. + +## 0.12.4 (Oct 12, 2019) + +### Added * Add `cts_user` value to `UserKindEnum`. -## 0.12.3 +## 0.12.3 (Oct 12, 2019) + +### Changed -* Fix docs about removed `HandlesCollector.regex_handler`. * Update `httpx` to `0.7.5`. -* Start using `https` for connection to `BotX API`. +* Use `https` for connecting to BotX API. + +### Fixed + +* Remove reference about `HandlersCollector.regex_handler` from docs. + +## 0.12.2 (Sep 8, 2019) -## 0.12.2 +### Fixed * Clear `AsyncBot` tasks on shutdown. -## 0.12.1 +## 0.12.1 (Sep 2, 2019) + +### Changed -* Export `UserKindEnum` from `botx`. * Upgrade `pydantic` to `0.32.2`. + +### Added + * Added `channel` type to `ChatTypeEnum`. -## 0.12.0 +### Fixed + +* Export `UserKindEnum` from `botx`. + + +## 0.12.0 (Aug 30, 2019) + +### Changed -* `system_command_handler` argument has been removed from the `HandlersCollector.handler` method. * `HandlersCollector.system_command_handler` now takes an `event` argument of type `SystemEventsEnum` instead of the deleted argument `comamnd`. * `MessageCommand.data` field will now automatically converted to events data types corresponding to special events, such as creating a new chat with a bot. -* Added logging via `loguru`. -* Dropped `aiojobs`. * Replaced `requests` and `aiohttp` with `httpx`. -* `Bot` can now accept both coroutines and normal functions. * Moved synchronous `Bot` to `botx.sync` module. The current `Bot` is an alias to the `AsyncBot`. * `Bot.status` again became a coroutine to add the ability to receive different commands for different users depending on different conditions defined in the handlers (to be added to future releases, when BotX API support comes up). +* Changed methods signatures. See `api-reference` for details. + +### Added + +* Added logging via `loguru`. +* `Bot` can now accept both coroutines and normal functions. * Added mechanism for catching exceptions. * Add ability to use sync and async functions to send data from `Bot`. -* Changed methods signatures. See `api-reference` for details. -* Fixed `opts` shape. * Added dependency injection system * Added parsing command params into handler arguments. -## 0.11.3 +### Removed + +* `system_command_handler` argument has been removed from the `HandlersCollector.handler` method. +* Dropped `aiojobs`. -* Fixed `IndexError` when trying to get next step handler for the message. +### Fixed + +* Fixed `opts` shape. -## 0.11.2 +## 0.11.3 (Jul 24, 2019) -* Removed the `data` field in bubbles and keyboards to fix display problem on some clients. +### Fixed -## 0.11.1 +* Catch `IndexError` when trying to get next step handler for the message and there isn't available. -* Fixed raising exception on successful status codes from the BotX API. +## 0.11.2 (Jul 17, 2019) -## 0.11.0 +### Removed +* `.data` field in `BubbleElement` and `KeyboardElement` was removed to fix problem in displaying markup on some clients. + +## 0.11.1 (Jun 28, 2019) + +### Fixed + +* Exception won't be raised on successful status codes from the BotX API. + +## 0.11.0 (Jun 27, 2019) + +### Changed + +* `MkDocs` documentation and move to `github`. * `BotXException` will be raised if there is an error in sending message, obtaining tokens, parsing incoming message data and some other cases. -* Renamed `CommandRouter` to `HandlersCollector`, changed methods, added some new decorators for specific commands. -* Added new `ReplyMessage` class and `.reply` method to bots for building answers in more comfortable way. -* Added notification options. -* Removed `parse_status` method and replace it with the `status` property for bots. -* Added the ability to register bot's handlers as next step handlers and pass extra arguments for them. -* Added `email` property to `MessageUser`. +* Rename `CommandRouter` to `HandlersCollector`, changed methods, added some new decorators for specific commands. +* Replaced `Bot.parse_status` method with the `Bot.status` property. +* Added generating message for `BotXException` error. + +### Added -## 0.10.3 +* `ReplyMessage` class and `.reply` method to bots were added for building answers in command in more comfortable way. +* Options for message notifications. +* Bot's handlers can be registered as next step handlers. +* `MessageUser` has now `email`. + +## 0.10.3 (May 31, 2019) + +### Fixed * Fixed passing positional and key arguments into logging wrapper for next step handlers. -## 0.10.2 +## 0.10.2 (May 31, 2019) + +### Added + +* Next step handlers can now receive positional and key arguments that are passed through their registration. + +## 0.10.1 (May 31, 2019) + +### Fixed + +* Return handler function from `CommandRouter.command` decorator instead of `CommandHandler` instance. -* The next step handlers can now receive positional and key arguments that are passed through their registration. +## 0.10.0 (May 28, 2019) -## 0.10.1 +### Changed -* Fixed returning `CommandHandler` instance from `CommandRouter.command` decorator instead of handler's function. +* Move `requests`, `aiohttp` and `aiojobs` to optional dependencies. +* All handlers now receive a bot instance that processes current command execution as second argument for handler. +* Files renamed using snake case. +* Returned response text and status from methods for sending messages. -## 0.10.0 +### Added + +* Export `pydantic`'s `ValidationError` directly from `botx`. +* Add Readme.md for library. +* Add support for BotX API tokens for bots. +* Add `py.typed` file for `mypy`. +* Add `CommandRouter` for gathering command handlers together and some methods for handling specific commands. +* Add ability to change handlers processing behaviour by using next step handlers. +* Add `botx.bots.Bot.answer_message` method to bots for easier generating answers in commands. +* Add mentions for users in chats. +* Add abstract methods to `BaseBot` and `BaseDispatcher`. + +### Fixed -* Moved `requests`, `aiohttp` and `aiojobs` to optional dependencies. -* Added `py.typed` file. * Fixed some mypy types issues. -* Added `CommandRouter` for gathering command handlers together and some methods for handling specific commands. -* Added ability to change handlers processing behaviour by using next step handlers. -* All handlers receive a bot instance that processes current command execution as second argument. -* Added `.answer_message` method to bots for easier interaction. -* Added mentions. +* Removed print. + +## 0.9.4 (Apr 23, 2019) + +### Changed + +* Change generation of command bodies for bot status by not forcing leading slash. + +## 0.9.3 (Apr 4, 2019) + +### Fixed -## 0.9.4 +* Close `aiohttp.client.ClientSession` when calling `AsyncBot.stop()`. -* Temporary change in the generation of bot command bodies. +## 0.9.2 (Mar 27, 2019) -## 0.9.3 +### Removed -* Fixed closing `aiohttp.client.ClientSession` when calling `AsyncBot.stop()`. +* Delete unused for now argument `bot` from thread wrapper. -## 0.9.2 +## 0.9.1 (Mar 27, 2019) -* Removed unused for now argument `bot` from thread wrapper. +### Fixed -## 0.9.1 +* Log unhandled exception from synchronous handlers. -* Wrapping synchronous functions for logging unhandled exceptions. +## 0.9.0 (Mar 18, 2019) -## 0.9.0 +### Added * First public release in PyPI. +* Synchronous and asynchronous API for building bots. diff --git a/docs/development/bots-deployment.md b/docs/development/bots-deployment.md deleted file mode 100644 index 01cbfca3..00000000 --- a/docs/development/bots-deployment.md +++ /dev/null @@ -1,21 +0,0 @@ -### Single process - -The main note about deploying bots from `pybotx` is that if you are using `next step handlers`, you must run your web application -in a single process mode. - -### Using `gunicorn` - -If you are using `gunicorn`, then you must use the `gthread worker` class to simultaneously serve several requests: - -```bash -gunicorn --workers=1 --threads=4 application:app -``` - -### Using `uvicorn` - -If you have an `ASGI` application, then most likely it is asynchronous, -so you can safely load it using a single thread without significant loss in performance. - -```bash -uvicorn app:app -``` \ No newline at end of file diff --git a/docs/development/collector.md b/docs/development/collector.md new file mode 100644 index 00000000..3e4d8e3f --- /dev/null +++ b/docs/development/collector.md @@ -0,0 +1,55 @@ +At some point you may decide that it is time to split your handlers into several files. +In order to make it as convenient as possible, `pybotx` provides a special mechanism that is similar to the mechanism +of routers from traditional web frameworks like `Blueprint`s in `Flask`. + +Let's say you have a bot in the `bot.py` file that has many commands (public, hidden, next step) which can be divided into 3 groups: + + * commands to access `A` service. + * commands to access `B` service. + * general commands for handling files, saving user settings, etc. + +Let's divide these commands in a following way: + + 1. Leave the general commands in the `bot.py` file. + 2. Move the commands related to `A` service to the `a_commands.py` file. + 3. Move commands related to `B` service to the `b_commands.py` file. + +### Collector + +[Collector][botx.collecting.Collector] is a class that can collect registered handlers +inside itself and then transfer them to bot. + +Using [Collector][botx.collecting.Collector] is quite simple: + + 1. Create an instance of the collector. + 2. Register your handlers, just like you do it for your bot. + 3. Include registered handlers in your [Bot][botx.bots.Bot] instance using the [`.include_collector`][botx.bots.Bot.include_collector] method. + +Here is an example. + +If we have already divided our handlers into files, it will look something like this for the `a_commands.py` file: + +```Python3 +{!./src/development/collector/collector0/a_commands.py!} +``` + +And here is the `bot.py` file: + +```Python3 +{!./src/development/collector/collector0/bot.py!} +``` + +!!! warning + + If you try to add 2 handlers for the same command, `pybotx` will raise an exception indicating about merge error. + +### Advanced handlers registration + +There are different methods for handlers registration available on [Collector][botx.collecting.Collector] and [Bot][botx.bots.Bot] instances. +You can register: + +* regular handlers using [`.handler`][botx.collecting.Collector.handler] decorator. +* default handlers, that will be used if matching handler was not found using [`.default`][botx.collecting.Collector.default] decorator. +* hidden handlers, that won't be showed in bot's menu using [`.hidden`][botx.collecting.Collector.hidden] decorator. +* system event handlers, that will be used for handling special events from BotX API using [`.system_event`][botx.collecting.Collector.system_event] decorator. +* and some other type of handlers. See API reference for bot or collector for more information. diff --git a/docs/development/cts-tokens.md b/docs/development/cts-tokens.md deleted file mode 100644 index 8da175f6..00000000 --- a/docs/development/cts-tokens.md +++ /dev/null @@ -1,17 +0,0 @@ -To use the BotX API, you must prove that your bot is a real bot registered on the Express server. -You can do this by registering an instance of the `CTS` class with the `secret_key` for this bot in `Bot` or `AsyncBot` instances. - -Here is a short example: - -```Python3 -from botx import Bot, CTS - -host = 'cts.example.com' -secret_key = 'secret' - -bot = Bot() -bot.add_cts(CTS(host=host, secret_key=secret_key)) -``` - -It's all. From this point on, the `Bot` instance will use the BotX API routes with confirmation of the bot's credentials. -That also means that for all hosts to which you will send messages, an instance of `CTS` class must be registered `CTS`. \ No newline at end of file diff --git a/docs/development/dependencies-injection.md b/docs/development/dependencies-injection.md index 137449ce..3f8d6071 100644 --- a/docs/development/dependencies-injection.md +++ b/docs/development/dependencies-injection.md @@ -1,66 +1,35 @@ -`pybotx` has an dependency injection mechanism heavily inspired by [`FastAPI`](https://fastapi.tiangolo.com/tutorial/dependencies/first-steps/). +`pybotx` has a dependency injection mechanism heavily inspired by [`FastAPI`](https://fastapi.tiangolo.com/tutorial/dependencies/). ## Usage First, create a function that will execute some logic. It can be a coroutine or a simple function. Then write a handler for bot that will use this dependency: -```python3 hl_lines="7" -from botx import Bot, Message, Depends -... -def get_user_huid(message: Message) -> UUID: - return message.user_huid - -@bot.handler -async def handler(user_huid: UUID = Depends(get_user_huid)): - print(f"Message from {user_huid}") +```python3 +{!./src/development/dependencies_injection/dependencies_injection0.py!} ``` ## Dependencies with dependencies Each of your dependencies function can contain parameters with other dependencies. And all this will be solved at the runtime: -```python3 hl_lines="6" -from botx import Bot, Message, Depends -... -def get_user_huid(message: Message) -> UUID: - return message.user_huid - -async def get_user(user_huid: UUID = Depends(get_user_huid)) -> User: - return await get_user_by_huid(user_huid) - -@bot.handler -def handler(user: User = Depends(get_user)): - print(f"Message from {user.username}") -... +```python3 +{!./src/development/dependencies_injection/dependencies_injection1.py!} ``` -## Optional dependencies for bot and message +## Special dependencies: Bot and Message -`Bot` and `Message` objects and special case of dependencies. If you put an annotation for them into your function then -this objects will be passed inside. It can be useful if you write something like authentication dependency: +[Bot][botx.bots.Bot] and [Message][botx.models.messages.Message] objects and special case of dependencies. +If you put an annotation for them into your function then this objects will be passed inside. +It can be useful if you write something like authentication dependency: -```python3 hl_lines="3 7 9x" -from botx import Bot, Message, BotXDependecyFailure -... -def authenticate_user(message: Message, bot: Bot) -> None: - ... - if not user.authenticated: - bot.answer_message("You should login first", message) - raise BotXDependencyFailure - -@bot.handler(dependecies=[authenticate_user]) -async def handler(): - pass -... +```python3 +{!./src/development/dependencies_injection/dependencies_injection2.py!} ``` -## Background dependencies - +[DependencyFailure][botx.exceptions.DependencyFailure] exception is used for preventing execution +of dependencies after one that failed. -If you define a list of callable objects in the initialization of `HandlersColletor` or in `HandlersCollector.handler`, +Also, if you define a list of [dependencies][botx.params.Depends] objects in the initialization of [collector][botx.collecting.Collector] or [bot][botx.bots.Bot] or in [`.handler`][botx.collecting.Collector.handler] decorator or others, then these dependencies will be processed as background dependencies. -They will be executed before the handler and the dependencies of this handler in the following order: - - * Dependencies defined in the `HandlersCollector` init. - * Dependencies defined in the handler decorator. \ No newline at end of file +They will be executed before the handler and its' dependencies: \ No newline at end of file diff --git a/docs/development/first-steps.md b/docs/development/first-steps.md index ca026ba5..a33ae825 100644 --- a/docs/development/first-steps.md +++ b/docs/development/first-steps.md @@ -6,66 +6,31 @@ Take echo-bot, from the [Introduction](/), and gradually improve it step by step Right now we have the following code: ```Python3 -from botx import Bot, Message, Status -from fastapi import FastAPI -from starlette.middleware.cors import CORSMiddleware -from starlette.status import HTTP_202_ACCEPTED - -bot = Bot() -bot.add_cts(CTS(host="cts.example.com", secret_key="secret")) - - -@bot.default_handler -async def echo_handler(message: Message, bot: Bot): - await bot.answer_message(message.body, message) - - -app = FastAPI() -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.get("/status", response_model=Status) -async def bot_status(): - return await bot.status() - - -@app.post("/command", status_code=HTTP_202_ACCEPTED) -async def bot_command(message: Message): - await bot.execute_command(message.dict()) +{!./src/development/first_steps/first_steps0.py!} ``` ## First, let's see how this code works We will explain only those parts that relate to `pybotx`, and not to the frameworks used in this documentation. -### Step 1: import `Bot`, `Message` and `Status` classes +### Step 1: import `Bot`, `Message`, `Status` and other classes ```Python3 hl_lines="1" -from botx import Bot, Message, Status -from fastapi import FastAPI -from starlette.middleware.cors import CORSMiddleware -from starlette.status import HTTP_202_ACCEPTED -... +{!./src/development/first_steps/first_steps0.py!} ``` -* `Bot` is a Python class that provides all the functions to your bots. - -* `Message` is a class that provides data to your handlers for commands. - -* `Status` is a class that is used here only to document the `FastAPI` route. +* [Bot][botx.bots.Bot] is a class that provides all the core functionality to your bots. +* [Message][botx.models.messages.Message] provides data to your handlers for commands. +* [Status][botx.models.menu.Status] is used here only to document the `FastAPI` route, +but in fact it stores information about public commands that user of your bot should see in menu. +* [ExpressServer][botx.models.credentials.ExpressServer] is used for storing information +about servers with which your bot is able to communicate. +* [IncomingMessage][botx.models.receiving.IncomingMessage] is a pydantic model that is used +for base validating of data, that was received on your bot's webhook. ### Step 2: initialize your `Bot` -```Python3 hl_lines="2 3" -... -bot = Bot() -bot.add_cts(CTS(host="cts.example.com", secret_key="secret")) -... +```Python3 hl_lines="5" +{!./src/development/first_steps/first_steps0.py!} ``` The `bot` variable will be an "instance" of the class `Bot`. @@ -73,55 +38,40 @@ We also register an instance of the cts server to get tokens and the ability to ### Step 3: define default handler -```Python3 hl_lines="2 3" -... -@bot.default_handler -async def echo_handler(message: Message, bot: Bot): - async bot.answer_message(message.body, message) -... +```Python3 hl_lines="8" +{!./src/development/first_steps/first_steps0.py!} ``` This handler will be called for all commands that have not appropriate handlers. +We also set `include_in_status=False` so that handler won't be visible in menu and it won't +complain about "wrong" body generated for it automatically. ### Step 4: send text to user -```Python3 hl_lines="4" -... -@bot.default_handler -async def echo_handler(message: Message, bot: Bot): - async bot.answer_message(message.body, message) -... +```Python3 hl_lines="10" +{!./src/development/first_steps/first_steps0.py!} ``` -`Bot.answer_message` will send some text to the user by using `sync_id`, `bot_id` and `host` data from the `Message` instance. -This is a simple wrapper for the `Bot.send_message` method, which is used to gain more control over sending messages process, -allowing you to specify a different host, bot_id, sync_id, group_chat_id or a list of them. +[`.answer_message`][botx.bots.Bot.answer_message] will send text to the user by using +[sync_id][botx.models.messages.Message.sync_id], [bot_id][botx.models.messages.Message.bot_id] +and [host][botx.models.messages.Message.host] data from the [Message][botx.models.messages.Message] instance. +This is a simple wrapper for the [`.send`][botx.bots.Bot.send] method, which is used to +gain more control over sending messages process, allowing you to specify a different +host, bot_id, sync_id, group_chat_id or a list of them. -### Step 5: register lifespan events for bot proper initialization +### Step 5: register handler for bot proper shutdown. -```Python3 hl_lines="2 3" -... -app.add_event_handler("startup", bot.start) -app.add_event_handler("shutdown", bot.stop) -... +```Python3 hl_lines="14" +{!./src/development/first_steps/first_steps0.py!} ``` -The `Bot.start` and `Bot.stop` methods are used to initialize some data that cannot be initialized when creating a `Bot` instance. +The [`.shutdown`][botx.bots.Bot.shutdown] method is used to stop pending handler. You must call them to be sure that the bot will work properly. ### Step 6: define webhooks for bot -```Python3 hl_lines="4 9" -... -@app.get("/status", response_model=Status) -await def bot_status(): - return await bot.status() - - -@app.post("/command", status_code=HTTP_202_ACCEPTED) -async def bot_command(message: Message): - await bot.execute_command(message.dict()) -... +```Python3 hl_lines="17 22" +{!./src/development/first_steps/first_steps0.py!} ``` Here we define 2 `FastAPI` routes: @@ -129,10 +79,22 @@ Here we define 2 `FastAPI` routes: * `GET` on `/status` will tell BotX API which commands are available for your bot. * `POST` on `/command` will receive data for incoming messages for your bot and execute handlers for commands. -!!! warning +!!! info + + If [`.execute_command`][botx.bots.Bot.execute_command] did not find a handler for + the command in the message, it will raise an `NoMatch` error in background, + which you probably want to [handle](/development/handling-errors). You can register default handler to process all commands that do not have their own handler. + +### Step 7 (Improvement): Reply to user if message was received from host, which is not registered - If `Bot.execute_command` did not find a handler for the command in the message, it will raise an `BotXException`, - which you probably want to handle. You can register default handler to process all commands that do not have their own handler. +We can send to BotX API a special response, that will say to user that bot can not communicate with +user properly, since message was received from unknown host. We do it by handling +[ServerUnknownError][botx.exceptions.ServerUnknownError] and returning to BotX API information +about error. + +```Python3 hl_lines="35" +{!./src/development/first_steps/first_steps1.py!} +``` ## Define new handlers @@ -140,32 +102,13 @@ Let's define a new handler that will trigger a chain of questions for the user t We'll use the `/fill-info` command to start the chain: -```Python3 -... -users_data = {} - -bot = Bot() -bot.add_cts(CTS(host="cts.example.com", secret_key="secret")) - -@bot.handler -async def fill_info(message: Message, bot: Bot): - if message.user_huid not in users_data: - text = ( - "Hi! I'm a bot that will ask some questions about you.\n" - "First of all: what is your name?" - ) - await bot.answer_message(text, message) - else: - text = ( - "You've already filled out infomation about yourself.\n" - "You can view it by typing `/my-info` command.\n" - "You can also view the processed information by typing `/info` command." - ) - await bot.answer_message(text, message) -... +```Python3 hl_lines="14" +{!./src/development/first_steps/first_steps2.py!} ``` -Here is nothing new for now. Everything was explained in previous sections. +Here we define a new handler for `/fill-info` command using [`.handler`][botx.bots.Bot.handler] decorator. +This decorator will generate for us body for our command and register it doing it available to handle. +We also defined a `users_data` dictionary to store information from our users. Now let's define another 2 handlers for the commands that were mentioned in the message text that we send to the user: @@ -173,255 +116,62 @@ Now let's define another 2 handlers for the commands that were mentioned in the * `/info` will send back the number of users who filled in information about themselves, their average age and number of male and female users. * `/infomation` is an alias to `/info` command. -```Python3 hl_lines="2 19" -... -@bot.handler(command='my-info') -async def get_info_for_user(message: Message, bot: Bot): - if message.user_huid not in users_data: - text = ( - "I have no infomation about you :(\n" - "Type `/fill-info` so I can collect it, please." - ) - await bot.answer_message(text, message) - else: - text = ( - f"Your name: {users_data[message.user_huid]['name']}\n" - f"Your age: {users_data[message.user_huid]['age']}\n" - f"Your gender: {users_data[message.user_huid]['gender']}\n" - "This is all that I have now." - ) - await bot.answer_message(text, message) - -@bot.handler(commands=['info', '/infomation']) -async def get_processed_infomation(message: Message, bot: Bot): - users_count = len(users_data) - average_age = sum(user['age'] for user in users_data) / users_count - gender_array = [1 if user['gender'] == 'male' else 2 for user in users_data] - text = ( - f"Count of users: {users_count}\n" - f"Average age: {average_age}\n" - f"Male users count: {gender_array.count(1)}\n" - f"Female users count: {gender_array.count(2)}" - ) - - await bot.answer_message(text, message) -... +```Python3 hl_lines="32 50" +{!./src/development/first_steps/first_steps3.py!} ``` -Take a look at highlighted lines. `Bot.handler` method takes a different number of arguments. -The most commonly used arguments are `command` and `commands`. +Take a look at highlighted lines. [`.handler`][botx.bots.Bot.handler] method takes a +different number of arguments. The most commonly used arguments are `command` and `commands`. `command` is a single string that defines a command for a handler. `commands` is a list of strings that can be used to define a variety of aliases for a handler. You can use them together. In this case, they simply merge into one array as if you specified only `commands` argument. See also at how the commands themselves are declared: - * for the `fill_info` function we have not defined any `command` but it will be implicitly converted to the `fill-info` command. - * for the `get_info_for_user` function we had explicitly specified `my-info` string, but it will be converted to `/my-info` inside the `Bot.handler` decorator. - * for the `get_processed_information` we specified a `commands` argument to define many aliases for the handler. All commands strings will also be converted to have only one leading slash. + * for the `fill_info` function we have not defined any `command` but it will be implicitly converted to the `/fill-info` command. + * for the `get_info_for_user` function we had explicitly specified `/my-info` string. + * for the `get_processed_information` we specified a `commands` argument to define many aliases for the handler. ## Register next step handlers -`pybotx` provide you the ability to change mechanism of handlers processing. -To use it, you must define a function that accepts 2 required positional arguments, as in usual handlers for commands: -first for the message and then for the bot. -You can also add additional positional and key arguments to the handler that will be passed when it is called. +`pybotx` provide you the ability to change mechanism of handlers processing by mechanism of +middlewares. It also provides a middleware for handling chains of messages by [`Next Step Middleware`][botx.middlewares.ns.NextStepMiddleware]. -Lets' define these handlers and, finally, create a chain of questions from the bot to the user: +To use it you should define functions that will be used when messages that start chain will be handled. +All functions should be defined before bot starts to handler messages, since dynamic registration +can cause different hard to find problems. -```Python3 hl_lines="10" -... -@bot.handler -async def fill_info(message: Message, bot: Bot): - if message.user_huid not in users_data: - text = ( - "Hi! I'm a bot that will ask some questions about you.\n" - "First of all: what is your name?" - ) - await bot.answer_message(text, message) - bot.register_next_step_handler(message, get_name) - else: - text = ( - "You've already filled out infomation about yourself.\n" - "You can view it by typing `/my-info` command.\n" - "You can also view the processed information by typing `/info` command." - ) - await bot.answer_message(text, message) -... -async def get_name(message: Message, bot: Bot): - users_data[message.user_huid]["name"] = message.body - await bot.answer_message("Good! Move next: how old are you?", message) - bot.register_next_step_handler(message, get_age) - - -async def get_age(message: Message, bot: Bot): - try: - age = int(message.body) - if age <= 5: - await bot.answer_message( - "Sorry, but it's not true. Say your real age, please!", message - ) - bot.register_next_step_handler(message, get_age) - else: - users_data[message.user_huid]["age"] = age - await bot.answer_message("Got it! Final question: your gender?", message) - bot.register_next_step_handler(message, get_gender) - except ValueError: - await bot.answer_message("No, no, no. Pleas tell me your age in numbers!", message) - bot.register_next_step_handler(message, get_age) - - -async def get_gender(message: Message, bot: Bot): - gender = message.body - if gender in ["male", "female"]: - users_data[message.user_huid]["gender"] = gender - await bot.answer_message("Ok! Thanks for taking the time to answer my questions.", message) - else: - await bot.answer_message( - "Sorry, but I can not recognize your answer! Type 'male' or 'female', please!", - message, - ) - bot.register_next_step_handler(message, get_gender) -... +Lets' define these handlers and, finally, create a chain of questions from the bot to the user. + +First we should import our middleware and functions that will register function fo next +message from user. + +```Python3 hl_lines="2" +{!./src/development/first_steps/first_steps4.py!} ``` -What's going on here? We added one line to our `/fill-info` command to start a chain of questions for our user. -We also defined 3 functions, whose signature is similar to the usual handler signature, but instead of registration them using the `Bot.handler` decorator, -we do this using the `Bot.register_next_step_handler` method. We pass into method our message as the first argument -and the handler that will be executed for the next user message as the second. We also can pass positional and key -arguments if we need them, but this not our case now. +Next we should define our functions and register it in our middleware. + +```Python3 hl_lines="11 17 36 51 52 53" +{!./src/development/first_steps/first_steps4.py!} +``` -## Complete example +And the last part of this step is use +[register_next_step_handler][botx.middlewares.ns.register_next_step_handler] function to +register handler for next message from user. -That is all! Here is full listing: +```Python3 hl_lines="14 24 28 33 48 68" +{!./src/development/first_steps/first_steps4.py!} +``` -```Python3 -from botx import Bot, Message, Status -from fastapi import FastAPI -from starlette.middleware.cors import CORSMiddleware -from starlette.status import HTTP_202_ACCEPTED - -users_data = {} - -bot = Bot() -bot.add_cts(CTS(host="cts.example.com", secret_key="secret")) - - -@bot.default_handler -async def echo_handler(message: Message, bot: Bot): - await bot.answer_message(message.body, message) - - -@bot.handler -async def fill_info(message: Message, bot: Bot): - if message.user_huid not in users_data: - users_data[message.user_huid] = {} - text = ( - "Hi! I'm a bot that will ask some questions about you.\n" - "First of all: what is your name?" - ) - await bot.answer_message(text, message) - bot.register_next_step_handler(message, get_name) - else: - text = ( - "You've already filled out infomation about yourself.\n" - "You can view it by typing `/my-info` command.\n" - "You can also view the processed information by typing `/info` command." - ) - await bot.answer_message(text, message) - - -@bot.handler(command="my-info") -async def get_info_for_user(message: Message, bot: Bot): - if message.user_huid not in users_data: - text = ( - "I have no infomation about you :(\n" - "Type `/fill-info` so I can collect it, please." - ) - await bot.answer_message(text, message) - else: - text = ( - f"Your name: {users_data[message.user_huid]['name']}\n" - f"Your age: {users_data[message.user_huid]['age']}\n" - f"Your gender: {users_data[message.user_huid]['gender']}\n" - "This is all that I have now." - ) - await bot.answer_message(text, message) - - -@bot.handler(commands=["info", "/infomation"]) -async def get_processed_infomation(message: Message, bot: Bot): - users_count = len(users_data) - average_age = sum(user["age"] for user in users_data.values()) / users_count - gender_array = [ - 1 if user["gender"] == "male" else 2 for user in users_data.values() - ] - text = ( - f"Count of users: {users_count}\n" - f"Average age: {average_age}\n" - f"Male users count: {gender_array.count(1)}\n" - f"Female users count: {gender_array.count(2)}" - ) - - await bot.answer_message(text, message) - - -async def get_name(message: Message, bot: Bot): - users_data[message.user_huid]["name"] = message.body - await bot.answer_message("Good! Move next: how old are you?", message) - bot.register_next_step_handler(message, get_age) - - -async def get_age(message: Message, bot: Bot): - try: - age = int(message.body) - if age <= 5: - bot.answer_message( - "Sorry, but it's not true. Say your real age, please!", message - ) - bot.register_next_step_handler(message, get_age) - else: - users_data[message.user_huid]["age"] = age - await bot.answer_message("Got it! Final question: your gender?", message) - bot.register_next_step_handler(message, get_gender) - except ValueError: - await bot.answer_message( - "No, no, no. Pleas tell me your age in numbers!", message - ) - bot.register_next_step_handler(message, get_age) - - -async def get_gender(message: Message, bot: Bot): - gender = message.body - if gender in ["male", "female"]: - users_data[message.user_huid]["gender"] = gender - await bot.answer_message( - "Ok! Thanks for taking the time to answer my questions.", message - ) - else: - await bot.answer_message( - "Sorry, but I can not recognize your answer! Type 'male' or 'female', please!", - message, - ) - bot.register_next_step_handler(message, get_gender) - - -app = FastAPI() -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.get("/status", response_model=Status) -async def bot_status(): - return await bot.status() - - -@app.post("/command", status_code=HTTP_202_ACCEPTED) -async def bot_command(message: Message): - await bot.execute_command(message.dict()) -``` \ No newline at end of file +### Recap + +What's going on here? We added one line to our `/fill-info` command to start a chain of +questions for our user. We also defined 3 functions, whose signature is similar to the +usual handler signature, but instead of registration them using the +[`.handler`][botx.bots.Bot.handler] decorator, we do this while registering out +[`Next Step Middleware`][botx.middlewares.ns.NextStepMiddleware] for bot. We change message +handling flow using the [register_next_step_handler][botx.middlewares.ns.register_next_step_handler] function. +We pass into function our message as the first argument and the handler that will be +executed for the next user message as the second. We also can pass key arguments if we need them +and get them in our handler using [`Message state`][botx.models.datastructures.State] then, but this not our case now. diff --git a/docs/development/handlers-collector.md b/docs/development/handlers-collector.md deleted file mode 100644 index 95c64579..00000000 --- a/docs/development/handlers-collector.md +++ /dev/null @@ -1,105 +0,0 @@ -At some point you may decide that it is time to split your handlers into several files. -In order to make it as convenient as possible, `pybotx` provides a special mechanism that is similar to the mechanism -of routers from traditional web frameworks like `Blueprint`s in `Flask`. - -Let's say you have a bot in the `bot.py` file that has many commands (public, hidden, next step) which can be divided into 3 groups: - - * commands to access `A` service. - * commands to access `B` service. - * general commands for handling files, saving user settings, etc. - -Let's divide these commands in a following way: - - 1. Leave the general commands in the `bot.py` file. - 2. Move the commands related to `A` service to the `a_commands.py` file. - 3. Move commands related to `B` service to the `b_commands.py` file. - -### `HandlersCollector` - -`HandlersCollector` is a class that can collect registered handlers in itself and then transfer them to bot. -In fact `Bot` is subclass of `HandlersCollector` so all methods available to `HandlersCollector` are also available to bots. - - -Using `HandlersCollector` is quite simple: - - 1. Create an instance of the class. - 2. Register your handlers, just like you do it for your bot. - 3. Include registered handlers in your `Bot` instance using the `.include_handlers` method. - -Here is an example. - -If we have already divided our handlers into files, it will look something like this for the `a_commands.py` file: - -```Python3 -from botx import HandlersCollector, Message, Bot - -collector = HandlersCollector() - -@collector.handler -def my_handler_for_a(message: Message, bot: Bot): - ... -``` - -And here is the `bot.py` file: - -```Python3 -from botx import Message, Bot - -from a_commands import collector - -bot = Bot(disable_credentials) -bot.include_handlers(collector) - - -@bot.default_handler -def default(message: Message, bot: Bot): - ... -``` - -!!! warning - - If you try to add 2 handlers for the same command, `pybotx` will raise an exception indicating about merge error. - If you still want to do this, you can set the `force_replace` argument to `True` in the `.include_handlers` method. - This will replace the existing handler for the command with a new one without raising any error. - -### Advanced handlers registration - - -In addition to the standard `.handler` method, `HandlersCollector` provides several other methods that may be useful to you. -These methods are just wrappers over the `.handler` method, but can reduce the amount of copy-paste when declaring handlers. -All these methods are used as decorators and all of them accept the callable object as their first (and some of them as the only) argument: - - * `HandlersCollector.handler` - is a most commonly used method to declare public handlers. -But it also provides some additional key arguments that can be used to change the behaviour of your handler: - - * `name: str = None` - the command name (useless field, maybe later will be use inside `pybotx`). - * `description: str = None` - the description of the command that will be displayed in the status, default built by rule "`name` description". - * `command: Union[str, Pattern] = None` - the body for the command for which the `Bot` instance will run the associated handler. - * `commands: List[str, Pattern] = None` - list of command aliases that will also run handler execution. - * `use_as_default_handler: bool = False` - indicates that the handler will be used in the absence of other handlers for the command. - * `exclude_from_status: bool = False` - indicates that handler will not appear in the list of public commands. - * `dependencies: List[Callable] = None` - list of background dependencies that will be executed before handler. - * `HandlersCollector.hidden_command_handler` - is a `HandlersCollector.handler` with `exclude_from_status` set to `True` and removed -`description`, `use_as_default_handler` and `system_event_handler` arguments. - * `HandlerCollector.file_handler` - is a handler for receiving files sent to the bot and it takes no additional arguments. - The file will be placed in the `Message.file` property as the `File` class instance. - * `HandlersCollector.default_handler` - is a handler for handling any commands that do not have associated handlers and also takes no additional arguments. - * `HandlersCollector.system_event_handler` - is a handler for specific BotX API system commands with the reserved `system:` namespace. - It requires a `event` key argument, which is the body of the command. See the example bellow. - - Predefined handlers for system events: - - * `HandlersCollector.chat_created_handler` - handles `system:chat_created` system event. - - If you want to register a handler for the `system:chat_created` system event using `HandlersCollector.system_event_handler`, - you should register it as follows: - -```Python3 -from botx import SystemEventsEnum - -... - -@bot.system_event_handler(event=SystemEventsEnum.chat_created) -def handler(message: Message, bot: Bot): - ... -``` diff --git a/docs/development/handling-errors.md b/docs/development/handling-errors.md index 64eb29a0..464bd21e 100644 --- a/docs/development/handling-errors.md +++ b/docs/development/handling-errors.md @@ -3,27 +3,8 @@ By default, these errors are simply logged to the console, but you can register For example, you can handle database disconnection or another runtime errors. You can also use this mechanism to register the handler for an `Excpetion` error and send info about it to the Sentry with additional information. -!!! info - Exceptions from your error handlers will also be caught and propagated above. If there is no exception handler for error, - it will simply logged, otherwise the registered handler will try to handle it. - ## Usage Example ```python3 -... - -@bot.exception_catcher([RuntimeError]) -async def error_handler(exc: Exception, msg: Message, bot: Bot): - if message.body == "some action": - await bot.send_message("this action will be fixed soon") - ... - -@bot.handler(command="cmd") -async def handler(message: Message, bot: Bot): - ... - if not cond: - raise RuntimeError("error message") - ... - -... +{!./src/development/handling_errors/handling_errors0.py!} ``` \ No newline at end of file diff --git a/docs/development/logging.md b/docs/development/logging.md index aab70f1b..450ca097 100644 --- a/docs/development/logging.md +++ b/docs/development/logging.md @@ -1 +1,7 @@ -`pybotx` uses `loguru` internally to log things. To enable it, just import `logger` from `loguru` and call `logger.enable("botx")` \ No newline at end of file +`pybotx` uses `loguru` internally to log things. + +To enable it, just import `logger` from `loguru` and call `logger.enable("botx")`: + +```Python3 +{!./src/development/logging/logging0.py!} +``` \ No newline at end of file diff --git a/docs/development/sending-data.md b/docs/development/sending-data.md index d6173609..169b357c 100644 --- a/docs/development/sending-data.md +++ b/docs/development/sending-data.md @@ -1,181 +1,115 @@ -`Bot` class from `pybotx` provide you 3 methods for sending message to the user (with some additional data) and 1 for sending the file: +[Bot][botx.bots.Bot] from `pybotx` provide you 3 methods for sending message to the user (with some additional data) and 1 for sending the file: -* `.reply()` - send a message by passing a `ReplyMessage` instance. -* `.answer_message()` - send a message by passing text and the original message that was passed to the command handler. -* `.send_message()` - send message by passing text, `sync_id`, `group_chat_id` or list of them, `bot_id` and `host`. -* `.send_file()` - send file using file-like object. +* [`.send`][botx.bots.Bot.send] - send a message by passing a [SendingMessage][botx.models.messages.SendingMessage]. +* [`.answer_message`][botx.bots.Bot.answer_message] - send a message by passing text and the original [message][botx.models.messages.Message] that was passed to the command handler. +* [`.send_message`][botx.bots.Bot.send_message] - send message by passing text, `sync_id`, `group_chat_id` or list of them, `bot_id` and `host`. +At most cases you'll prefer [`.send`][botx.bots.Bot.send] method over this one. +* [`.send_file`][botx.bots.Bot.send_file] - send file using file-like object. !!! info Note about using different values to send messages - * `sync_id` is the `UUID` accosiated with the message in Express. You should use it only in command handlers. + * `sync_id` is the `UUID` accosiated with the message in Express. + You should use it only in command handlers as answer on command or when changing already sent message. * `group_chat_id` - is the `UUID` accosiated with one of the chats in Express. In most cases, you should use it to send messages, outside of handlers. -### Using `reply` +### Using `.send` -`Bot.reply` uses another method to send a message. Under the hood, he still uses `Bot.send_message` to perform send operations, -but provides a way for more easily create massive responses. It takes only 1 argument: - -* `message: ReplyMessage` - is the answer message from bot +[`.send`][botx.bots.Bot.send] is used to send a message. Here is an example of using this method outside from handler: ```Python3 -async def some_function(): - reply = ReplyMessage( - text="You were chosen by random.", - bot_id=BOT_ID, - host=CTS_HOST, - ) - reply.chat_id = get_random_chat_id() - await bot.reply(reply) +{!./src/development/sending_data/sending_data0.py!} ``` or inside command handler: ```Python3 -@bot.handler -async def my_handler(message: Message, bot: Bot): - reply = ReplyMessage.from_message("Answer from `.reply` method.", message) - await bot.reply(reply) +{!./src/development/sending_data/sending_data1.py!} ``` -### Send file +### Using `.answer_message` -```python3 -from botx import SendingCredentials -... -@bot.handler -async def my_handler(message: Message, bot: Bot): - with open("file.txt") as f: - file = File.from_file(f) - - await bot.send_file( - file.file, - SendingCredentials( - sync_id=message.sync_id, bot_id=message.bot_id, host=message.host - ), - ) -... -``` +[`.answer_message`][botx.bots.Bot.answer_message] is very useful for replying to command. -### Send file with message +```Python3 +{!./src/development/sending_data/sending_data2.py!} +``` -!!! warning - This feature is not supported yet on clients. +### Send file -To attach a file to your message, simply pass it to the `file` argument for tje `Bot.send_message` or `Bot.answer_message` methods -or use `ReplyMessage.add_file` if you use `Bot.reply`. This will create an instance of the `File` class that will be used to send result. -If you want to use the same file several times, you can create a `File` object manually and use the `File.file` property to reuse the data. +There are several ways to send a file from bot: -```python3 -@bot.handler -async def my_handler(message: Message, bot: Bot): - with open("file.txt") as f: - file = File.from_file(f) +* [Attach file][botx.models.messages.SendingMessage.add_file] to an instance of [SendingMessage][botx.models.messages.SendingMessage]. +* Pass file to `file` argument into [`.answer_message`][botx.bots.Bot.answer_message] or [`.send_message`][botx.bots.Bot.send_message] methods. +* Use [`.send_file`][botx.bots.Bot.send_file]. - await bot.answer_message("Your file", message, file=file.file) +#### Attach file to already built message or during initialization - reply = ReplyMessage.from_message("Your file (again)", message) - reply.add_file(file) - await bot.reply(reply) -... +```Python3 +{!./src/development/sending_data/sending_data3.py!} ``` -### Add `Bubble` and `Keyboard` - -You can attach bubbles or keyboard buttons to your message. -A `Bubble` is a button that is stuck to your message. -`Keyboard` is a panel that will be displayed when you click on the messege with the icon. - -Adding these elements to your message is pretty easy. -If you use the `.send_message` or `.answer_message` methods, you must pass a matrix of elements -(`BubbleElement` or `KeyboardElement`) to corresponding arguments. Each array in this matrix will be a new row. - -For example, if you want to add 3 buttons to a message (1 in the first line and 2 in the second): +#### Pass file as argument ```Python3 -@bot.handler -async def my_handler(message: Message, bot: Bot): - await bot.answer_message( - "Bubble", - message, - bubble=[ - [BubbleElement(label="buble 1", command="")], - [ - BubbleElement(label="buble 2", command=""), - BubbleElement(label="buble 3", command=""), - ], - ], - ) +{!./src/development/sending_data/sending_data4.py!} ``` -or add keyboard buttons using `.reply`: +#### Using `.send_file` ```Python3 -@bot.handler -async def my_handler(message: Message, bot: Bot): - reply = ReplyMessage.from_message(message) - reply.add_keyboard_button(command="", label="key 1") - - reply.add_keyboard_button(command="", label="key 1") - reply.add_keyboard_button(command="", label="key 1", new_row=False) - - await bot.reply(reply) +{!./src/development/sending_data/sending_data5.py!} ``` -### Mention users in message +### Attach interactive buttons to your message -You can mention users in your messages and they will receive notification from the chat, even if this chat was muted. +You can attach bubbles or keyboard buttons to your message. This can be done using +[MessageMarkup][botx.models.sending.MessageMarkup] class. +A [Bubble][botx.models.buttons.ButtonElement] is a button that is stuck to your message. +A [Keyboard][botx.models.buttons.KeyboardElement] is a panel that will be displayed when +you click on the messege with the icon. -Using `.reply`: +An attached collection of bubbles or keyboard buttons is a matrix of buttons. + +Adding these elements to your message is pretty easy. +For example, if you want to add 3 buttons to a message (1 in the first line and 2 in the second) +you can do something like this: ```Python3 -@bot.handler -async def my_handler(message: Message, bot: Bot): - user_huid = get_random_user() +{!./src/development/sending_data/sending_data6.py!} +``` - reply = ReplyMessage.from_message(message) - reply.mention_user(user_huid) - - await bot.reply(reply) +Or like this: + +```Python3 +{!./src/development/sending_data/sending_data7.py!} ``` -### Select Message Recipients -Similar to the mentions, you can specify the users who will receive your message by filling in `recipients` argument with list of users identifiers. +Also you can attach buttons to [SendingMessage][botx.models.message.SendingMessage] passing it +into `__init__` or after: -### Change Notification Options +```Python3 +{!./src/development/sending_data/sending_data8.py!} +``` -`Bot.send_message` and `Bot.answer_message` also take and additional argument `opts`, which is an instance of the `NotificationOpts` class -and can control message delivery notifications. +### Mention users or another chats in message -`NotificationOpts` take the following arguments: +You can mention users or another chats in your messages and they will receive notification +from the chat, even if this chat was muted. - * `send: bool` - send a push notification to the user, by default `True`. - * `force_dnd` - ignore mute mode in chat with user, by default `False`. - -Using `.reply`, it will look like this to disable the message delivery notification: +There are 2 types of mentions for users: -```Python3 -@bot.handler -async def my_handler(message: Message, bot: Bot): - reply = ReplyMessage.from_message(message) - reply.show_notification(False) - - await bot.reply(reply) -``` +* Mention user in chat where message will be sent +* Mention just user account -or like this to ignore user's mute mode for the chat with the bot: +Here is an example ```Python3 -@bot.handler -async def my_handler(message: Message, bot: Bot): - reply = ReplyMessage.from_message(message) - reply.force_notification(True) - - await bot.reply(reply) -``` \ No newline at end of file +{!./src/development/sending_data/sending_data9.py!} +``` diff --git a/docs/development/sync.md b/docs/development/sync.md deleted file mode 100644 index 370166d0..00000000 --- a/docs/development/sync.md +++ /dev/null @@ -1,22 +0,0 @@ -By default, `pybotx` provides asynchronous methods through the `Bot` class, since this is more efficient. -But as an alternative, you can import the `Bot` from the `botx.sync` module and use all the methods as normal blockable -functions. Handlers will then be dispatched using the `concurrent.futures.threads.ThreadPoolExecutor` object. - - -If you write synchronous handlers using the asynchronous `Bot` from the `botx` package, then you can call the client -function to send data in the same way as if it were synchronous:: - -```python3 -from botx import Bot, Message - -bot = Bot() - -@bot.handler -async def async_handler(message: Message, bot: Bot): - await bot.answer_message(message.body, message) - -@bot.handler -def sync_handler(message: Message, bot: Bot): - bot.answer_message(message.body, message) - -``` \ No newline at end of file diff --git a/docs/development/tests.md b/docs/development/tests.md index 2a9c4fa4..cf0a9ab7 100644 --- a/docs/development/tests.md +++ b/docs/development/tests.md @@ -1,7 +1,6 @@ You can test the behaviour of your bot by writing unit tests. Since the main goal of the bot is to process commands and send -results to the BotX API, you should be able to intercept the result between sending data to the API. You can do this by setting -`.asgi_app` attribute for `Bot.client` to an `ASGI` application instance. -Then you write some mocks and test your logic inside tests. In this example we will use [`Starlette`](https://www.starlette.io/) to write mocks and `pytest` for unit tests. +results to the BotX API, you should be able to intercept the result between sending data to the API. You can do this by using [TestClient][botx.testing.TestClient]. +Then you write some mocks and test your logic inside tests. In this example we will `pytest` for unit tests. ## Example @@ -10,53 +9,8 @@ Then you write some mocks and test your logic inside tests. In this example we w Suppose we have a bot that returns a message in the format `"Hello, {username}"` with the command `/hello`: `bot.py`: -```python -from botx import Bot, Message - -bot = Bot() - - -@bot.handler -async def hello(message: Message) -> None: - await bot.answer_message(f"Hello, {message.username}", message) -``` - -### Helping utils - -Let's write some utils for tests: - -`utils.py` - -```python -from typing import List, Any, Callable - -from botx.models import ( - BotXFilePayload, - BotXCommandResultPayload, - BotXNotificationPayload, -) -from starlette.requests import Request -from starlette.responses import JSONResponse - - -def get_route(url: str) -> str: - return url.split("{host}", 1)[1] - - -def get_test_route(array: List[Any]) -> Callable: - async def testing_route(request: Request) -> JSONResponse: - if request.headers["Content-Type"] != "application/json": - form = await request.form() - array.append((BotXFilePayload(**form), form["file"])) - else: - resp = await request.json() - if "command" in request.url.path: - array.append(BotXCommandResultPayload(**resp)) - else: - array.append(BotXNotificationPayload(**resp)) - return JSONResponse() - - return testing_route +```python3 +{!./src/development/tests/tests0/bot.py!} ``` ### Fixtures @@ -64,117 +18,8 @@ def get_test_route(array: List[Any]) -> Callable: Now let's write some fixtures to use them in our tests: `conftest.py`: -```python -from typing import Callable, Optional -from uuid import UUID, uuid4 - -import pytest -from botx import Bot, CTS, CTSCredentials, Message -from botx.core import BotXAPI -from starlette.applications import Starlette -from starlette.responses import JSONResponse - -from .bot import bot -from .utils import get_route - - -@pytest.fixture -def bot_id() -> UUID: - return uuid4() - - -@pytest.fixture -def host() -> str: - return "cts.example.com" - - -@pytest.fixture -def get_botx_api_app() -> Callable: - async def default_route(*_) -> JSONResponse: - return JSONResponse({"status": "ok", "result": "result"}) - - def _get_asgi_app( - command_route: Optional[Callable] = None, - notification_route: Optional[Callable] = None, - file_route: Optional[Callable] = None, - ) -> Starlette: - app = Starlette() - app.add_route( - get_route(BotXAPI.V3.command.url), - command_route or default_route, - methods=[BotXAPI.V3.command.method], - ) - app.add_route( - get_route(BotXAPI.V3.notification.url), - notification_route or default_route, - methods=[BotXAPI.V3.notification.method], - ) - app.add_route( - get_route(BotXAPI.V1.file.url), - file_route or default_route, - methods=[BotXAPI.V1.file.method], - ) - app.add_route( - get_route(BotXAPI.V2.token.url), - default_route, - methods=[BotXAPI.V2.token.method], - ) - - return app - - return _get_asgi_app - - -@pytest.fixture -def get_bot(bot_id: UUID, host: str, get_botx_api_app: Callable) -> Callable: - def _get_bot( - *, - command_route: Optional[Callable] = None, - notification_route: Optional[Callable] = None, - file_route: Optional[Callable] = None, - ) -> Bot: - bot.client.asgi_app = get_botx_api_app( - command_route, notification_route, file_route - ) - bot.add_cts( - CTS( - host=host, - secret_key="secret", - credentials=CTSCredentials(bot_id=bot_id, token="token"), - ) - ) - - return bot - - return _get_bot - - -@pytest.fixture -def message_data(bot_id: UUID, host: str) -> Callable: - def _create_message_data(command: str) -> Message: - command_body = {"body": command, "command_type": "user", "data": {}} - - data = { - "bot_id": str(bot_id), - "command": command_body, - "file": None, - "from": { - "ad_login": "User Name", - "ad_domain": "example.com", - "chat_type": "chat", - "group_chat_id": uuid4(), - "host": host, - "is_creator": True, - "is_admin": True, - "user_huid": uuid4(), - "username": "testuser", - }, - "sync_id": str(uuid4()), - } - - return Message(**data) - - return _create_message_data +```python3 +{!./src/development/tests/tests0/conftest.py!} ``` ### Tests @@ -182,23 +27,6 @@ def message_data(bot_id: UUID, host: str) -> Callable: Now we have fixtures for writing tests. Let's write a test to verify that the message body is in the required format: `test_format_command.py` -```python -import pytest - -from .utils import get_test_route - -@pytest.mark.asyncio -async def test_hello_format(get_bot, create_message): - result = [] - - bot = get_bot(command_route=get_test_route(result)) - message = create_message('/hello') - - await bot.start() - - await bot.execute_command(message.dict()) - - await bot.stop() - - assert result[0].command_result.body == 'Hello, testuser' +```python3 +{!./src/development/tests/tests0/test_format_command.py!} ``` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 9262791c..4feda6a9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,20 +1,20 @@- A little python library for building bots for Express + A little python framework for building bots for eXpress messenger.
@@ -22,7 +22,8 @@ # Introduction -`pybotx` is a framework for building bots for Express providing a mechanism for simple integration with your favourite web frameworks. +`pybotx` is a framework for building bots for eXpress providing a mechanism for simple +integration with your favourite asynchronous web frameworks. Main features: @@ -31,14 +32,16 @@ Main features: * 100% test coverage. * 100% type annotated codebase. + !!! warning This library is under active development and its API may be unstable. Please lock the version you are using at the minor update level. For example, like this in `poetry`. [tool.poetry.dependencies] ... - botx = "^0.12.0" + botx = "^0.13.0" ... + --- ## Requirements @@ -50,13 +53,20 @@ Python 3.6+ * pydantic for the data parts. * httpx for making HTTP calls to BotX API. * loguru for beautiful and powerful logs. +* **Optional**. Starlette for tests. ## Installation ```bash $ pip install botx ``` -You will also need a web framework to create bots as the current BotX API only works with webhooks. +Or if you are going to write tests: + +```bash +$ pip install botx[tests] +``` + +You will also need a web framework to create bots as the current BotX API only works with webhooks. This documentation will use FastAPI for the examples bellow. ```bash $ pip install fastapi uvicorn @@ -68,38 +78,7 @@ Let's create a simple echo bot. * Create a file `main.py` with following content: ```Python3 -from botx import Bot, CTS, Message, Status -from fastapi import FastAPI -from starlette.middleware.cors import CORSMiddleware -from starlette.status import HTTP_202_ACCEPTED - -bot = Bot() -bot.add_cts(CTS(host="cts.example.com", secret_key="secret")) - - -@bot.default_handler -async def echo_handler(message: Message, bot: Bot): - await bot.answer_message(message.body, message) - - -app = FastAPI() -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - - -@app.get("/status", response_model=Status) -async def bot_status(): - return await bot.status() - - -@app.post("/command", status_code=HTTP_202_ACCEPTED) -async def bot_command(message: Message): - await bot.execute_command(message.dict()) +{!./src/index/index0.py!} ``` * Deploy a bot on your server using uvicorn and set the url for the webhook in Express. diff --git a/docs/reference/bots.md b/docs/reference/bots.md new file mode 100644 index 00000000..e07c67d4 --- /dev/null +++ b/docs/reference/bots.md @@ -0,0 +1 @@ +::: botx.bots \ No newline at end of file diff --git a/docs/reference/clients.md b/docs/reference/clients.md new file mode 100644 index 00000000..09963d8c --- /dev/null +++ b/docs/reference/clients.md @@ -0,0 +1 @@ +::: botx.clients \ No newline at end of file diff --git a/docs/reference/collecting.md b/docs/reference/collecting.md new file mode 100644 index 00000000..0de03a03 --- /dev/null +++ b/docs/reference/collecting.md @@ -0,0 +1 @@ +::: botx.collecting \ No newline at end of file diff --git a/docs/reference/dependencies.md b/docs/reference/dependencies.md new file mode 100644 index 00000000..dca0394e --- /dev/null +++ b/docs/reference/dependencies.md @@ -0,0 +1,7 @@ +::: botx.dependencies.inspecting + +::: botx.dependencies.models + +::: botx.dependencies.solving + +::: botx.params \ No newline at end of file diff --git a/docs/reference/exceptions.md b/docs/reference/exceptions.md new file mode 100644 index 00000000..05a55380 --- /dev/null +++ b/docs/reference/exceptions.md @@ -0,0 +1 @@ +::: botx.exceptions \ No newline at end of file diff --git a/docs/reference/middlewares.md b/docs/reference/middlewares.md new file mode 100644 index 00000000..10cfffb8 --- /dev/null +++ b/docs/reference/middlewares.md @@ -0,0 +1,3 @@ +::: botx.middlewares.base + +::: botx.middlewares.ns diff --git a/docs/reference/models.md b/docs/reference/models.md new file mode 100644 index 00000000..d2b08165 --- /dev/null +++ b/docs/reference/models.md @@ -0,0 +1,27 @@ +::: botx.models.buttons + +::: botx.models.credentials + +::: botx.models.menu + +::: botx.models.datastructures + +::: botx.models.enums + +::: botx.models.errors + +::: botx.models.events + +::: botx.models.files + +::: botx.models.mentions + +::: botx.models.messages + +::: botx.models.receiving + +::: botx.models.requests + +::: botx.models.responses + +::: botx.models.sending diff --git a/docs/reference/testing.md b/docs/reference/testing.md new file mode 100644 index 00000000..24c1a2a2 --- /dev/null +++ b/docs/reference/testing.md @@ -0,0 +1 @@ +::: botx.testing \ No newline at end of file diff --git a/docs/src/development/collector/collector0/a_commands.py b/docs/src/development/collector/collector0/a_commands.py new file mode 100644 index 00000000..2af9f321 --- /dev/null +++ b/docs/src/development/collector/collector0/a_commands.py @@ -0,0 +1,9 @@ +from botx import Collector, Message + +collector = Collector() + + +@collector.handler +async def my_handler_for_a_service(message: Message) -> None: + # do something here + print(f"Message from {message.group_chat_id} chat") diff --git a/docs/src/development/collector/collector0/bot.py b/docs/src/development/collector/collector0/bot.py new file mode 100644 index 00000000..7ba050f1 --- /dev/null +++ b/docs/src/development/collector/collector0/bot.py @@ -0,0 +1,11 @@ +from botx import Bot + +from .a_commands import collector + +bot = Bot() +bot.include_collector(collector) + + +@bot.default(include_in_status=False) +async def default_handler() -> None: + print("default handler") diff --git a/docs/src/development/dependencies_injection/dependencies_injection0.py b/docs/src/development/dependencies_injection/dependencies_injection0.py new file mode 100644 index 00000000..42509691 --- /dev/null +++ b/docs/src/development/dependencies_injection/dependencies_injection0.py @@ -0,0 +1,14 @@ +from uuid import UUID + +from botx import Bot, Depends, Message + +bot = Bot() + + +def get_user_huid(message: Message) -> UUID: + return message.user_huid + + +@bot.handler +async def my_handler(user_huid: UUID = Depends(get_user_huid)) -> None: + print(f"Message from {user_huid}") diff --git a/docs/src/development/dependencies_injection/dependencies_injection1.py b/docs/src/development/dependencies_injection/dependencies_injection1.py new file mode 100644 index 00000000..382a7df3 --- /dev/null +++ b/docs/src/development/dependencies_injection/dependencies_injection1.py @@ -0,0 +1,31 @@ +import asyncio +from dataclasses import dataclass +from uuid import UUID + +from botx import Bot, Depends, Message + + +@dataclass +class User: + user_huid: UUID + username: str + + +bot = Bot() + + +def get_user_huid_from_message(message: Message) -> UUID: + return message.user_huid + + +async def fetch_user_by_huid( + user_huid: UUID = Depends(get_user_huid_from_message), +) -> User: + # some operations with db for example + await asyncio.sleep(0.5) + return User(user_huid=user_huid, username="Requested User") + + +@bot.handler +def my_handler(user: User = Depends(fetch_user_by_huid)) -> None: + print(f"Message from {user.username}") diff --git a/docs/src/development/dependencies_injection/dependencies_injection2.py b/docs/src/development/dependencies_injection/dependencies_injection2.py new file mode 100644 index 00000000..a6823be1 --- /dev/null +++ b/docs/src/development/dependencies_injection/dependencies_injection2.py @@ -0,0 +1,40 @@ +import asyncio +from dataclasses import dataclass +from uuid import UUID + +from botx import Bot, Collector, DependencyFailure, Depends, Message + + +@dataclass +class User: + user_huid: UUID + username: str + is_authenticated: bool + + +collector = Collector() + + +def get_user_huid_from_message(message: Message) -> UUID: + return message.user_huid + + +async def fetch_user_by_huid( + user_huid: UUID = Depends(get_user_huid_from_message), +) -> User: + # some operations with db for example + await asyncio.sleep(0.5) + return User(user_huid=user_huid, username="Requested User", is_authenticated=False) + + +async def authenticate_user( + bot: Bot, message: Message, user: User = Depends(fetch_user_by_huid) +) -> None: + if not user.is_authenticated: + await bot.answer_message("You should login first", message) + raise DependencyFailure + + +@collector.handler(dependencies=[Depends(authenticate_user)]) +def my_handler(user: User = Depends(fetch_user_by_huid)) -> None: + print(f"Message from {user.username}") diff --git a/docs/src/development/first_steps/first_steps0.py b/docs/src/development/first_steps/first_steps0.py new file mode 100644 index 00000000..6db97d7b --- /dev/null +++ b/docs/src/development/first_steps/first_steps0.py @@ -0,0 +1,24 @@ +from botx import Bot, ExpressServer, IncomingMessage, Message, Status +from fastapi import FastAPI +from starlette.status import HTTP_202_ACCEPTED + +bot = Bot(known_hosts=[ExpressServer(host="cts.example.com", secret_key="secret")]) + + +@bot.default(include_in_status=False) +async def echo_handler(message: Message) -> None: + await bot.answer_message(message.body, message) + + +app = FastAPI() +app.add_event_handler("shutdown", bot.shutdown) + + +@app.get("/status", response_model=Status) +async def bot_status() -> Status: + return await bot.status() + + +@app.post("/command", status_code=HTTP_202_ACCEPTED) +async def bot_command(message: IncomingMessage) -> None: + await bot.execute_command(message.dict()) diff --git a/docs/src/development/first_steps/first_steps1.py b/docs/src/development/first_steps/first_steps1.py new file mode 100644 index 00000000..ba0a88b0 --- /dev/null +++ b/docs/src/development/first_steps/first_steps1.py @@ -0,0 +1,46 @@ +from botx import ( + Bot, + BotDisabledErrorData, + BotDisabledResponse, + ExpressServer, + IncomingMessage, + Message, + ServerUnknownError, + Status, +) +from fastapi import FastAPI, HTTPException +from starlette.status import HTTP_202_ACCEPTED, HTTP_406_NOT_ACCEPTABLE + +bot = Bot(known_hosts=[ExpressServer(host="cts.example.com", secret_key="secret")]) + + +@bot.default(include_in_status=False) +async def echo_handler(message: Message) -> None: + await bot.answer_message(message.body, message) + + +app = FastAPI() +app.add_event_handler("shutdown", bot.shutdown) + + +@app.get("/status", response_model=Status) +async def bot_status() -> Status: + return await bot.status() + + +@app.post("/command", status_code=HTTP_202_ACCEPTED) +async def bot_command(message: IncomingMessage) -> None: + try: + await bot.execute_command(message.dict()) + except ServerUnknownError: + raise HTTPException( + status_code=HTTP_406_NOT_ACCEPTABLE, + detail=BotDisabledResponse( + error_data=BotDisabledErrorData( + status_message=( + "Sorry, bot can not communicate with user " + f"from {message.user.host} CTS" + ) + ) + ), + ) diff --git a/docs/src/development/first_steps/first_steps2.py b/docs/src/development/first_steps/first_steps2.py new file mode 100644 index 00000000..518008fe --- /dev/null +++ b/docs/src/development/first_steps/first_steps2.py @@ -0,0 +1,42 @@ +from botx import Bot, ExpressServer, IncomingMessage, Message, Status +from fastapi import FastAPI +from starlette.status import HTTP_202_ACCEPTED + +users_data = {} +bot = Bot(known_hosts=[ExpressServer(host="cts.example.com", secret_key="secret")]) + + +@bot.default(include_in_status=False) +async def echo_handler(message: Message) -> None: + await bot.answer_message(message.body, message) + + +@bot.handler +async def fill_info(message: Message) -> None: + if message.user_huid not in users_data: + text = ( + "Hi! I'm a bot that will ask some questions about you.\n" + "First of all: what is your name?" + ) + else: + text = ( + "You've already filled out information about yourself.\n" + "You can view it by typing `/my-info` command.\n" + "You can also view the processed information by typing `/info` command." + ) + + await bot.answer_message(text, message) + + +app = FastAPI() +app.add_event_handler("shutdown", bot.shutdown) + + +@app.get("/status", response_model=Status) +async def bot_status() -> Status: + return await bot.status() + + +@app.post("/command", status_code=HTTP_202_ACCEPTED) +async def bot_command(message: IncomingMessage) -> None: + await bot.execute_command(message.dict()) diff --git a/docs/src/development/first_steps/first_steps3.py b/docs/src/development/first_steps/first_steps3.py new file mode 100644 index 00000000..3b9d9208 --- /dev/null +++ b/docs/src/development/first_steps/first_steps3.py @@ -0,0 +1,75 @@ +from botx import Bot, ExpressServer, IncomingMessage, Message, Status +from fastapi import FastAPI +from starlette.status import HTTP_202_ACCEPTED + +users_data = {} +bot = Bot(known_hosts=[ExpressServer(host="cts.example.com", secret_key="secret")]) + + +@bot.default(include_in_status=False) +async def echo_handler(message: Message) -> None: + await bot.answer_message(message.body, message) + + +@bot.handler +async def fill_info(message: Message) -> None: + if message.user_huid not in users_data: + text = ( + "Hi! I'm a bot that will ask some questions about you.\n" + "First of all: what is your name?" + ) + else: + text = ( + "You've already filled out information about yourself.\n" + "You can view it by typing `/my-info` command.\n" + "You can also view the processed information by typing `/info` command." + ) + + await bot.answer_message(text, message) + + +@bot.handler(command="/my-info") +async def get_info_for_user(message: Message) -> None: + if message.user_huid not in users_data: + text = ( + "I have no information about you :(\n" + "Type `/fill-info` so I can collect it, please." + ) + await bot.answer_message(text, message) + else: + text = ( + f"Your name: {users_data[message.user_huid]['name']}\n" + f"Your age: {users_data[message.user_huid]['age']}\n" + f"Your gender: {users_data[message.user_huid]['gender']}\n" + "This is all that I have now." + ) + await bot.answer_message(text, message) + + +@bot.handler(commands=["/info", "/information"]) +async def get_processed_information(message: Message) -> None: + users_count = len(users_data) + average_age = sum(user["age"] for user in users_data) / users_count + gender_array = [1 if user["gender"] == "male" else 2 for user in users_data] + text = ( + f"Count of users: {users_count}\n" + f"Average age: {average_age}\n" + f"Male users count: {gender_array.count(1)}\n" + f"Female users count: {gender_array.count(2)}" + ) + + await bot.answer_message(text, message) + + +app = FastAPI() +app.add_event_handler("shutdown", bot.shutdown) + + +@app.get("/status", response_model=Status) +async def bot_status() -> Status: + return await bot.status() + + +@app.post("/command", status_code=HTTP_202_ACCEPTED) +async def bot_command(message: IncomingMessage) -> None: + await bot.execute_command(message.dict()) diff --git a/docs/src/development/first_steps/first_steps4.py b/docs/src/development/first_steps/first_steps4.py new file mode 100644 index 00000000..67d61771 --- /dev/null +++ b/docs/src/development/first_steps/first_steps4.py @@ -0,0 +1,123 @@ +from botx import Bot, ExpressServer, IncomingMessage, Message, Status +from botx.middlewares.ns import NextStepMiddleware, register_next_step_handler +from fastapi import FastAPI +from starlette.status import HTTP_202_ACCEPTED + +bot = Bot(known_hosts=[ExpressServer(host="cts.example.com", secret_key="secret")]) + +users_data = {} + + +async def get_name(message: Message) -> None: + users_data[message.user_huid]["name"] = message.body + await bot.answer_message("Good! Move next: how old are you?", message) + register_next_step_handler(message, get_age) + + +async def get_age(message: Message) -> None: + try: + age = int(message.body) + if age <= 2: + await bot.answer_message( + "Sorry, but it's not true. Say your real age, please!", message + ) + register_next_step_handler(message, get_age) + else: + users_data[message.user_huid]["age"] = age + await bot.answer_message("Got it! Final question: your gender?", message) + register_next_step_handler(message, get_gender) + except ValueError: + await bot.answer_message( + "No, no, no. Pleas tell me your age in numbers!", message + ) + register_next_step_handler(message, get_age) + + +async def get_gender(message: Message) -> None: + gender = message.body + if gender in ["male", "female"]: + users_data[message.user_huid]["gender"] = gender + await bot.answer_message( + "Ok! Thanks for taking the time to answer my questions.", message + ) + else: + await bot.answer_message( + "Sorry, but I can not recognize your answer! Type 'male' or 'female', please!", + message, + ) + register_next_step_handler(message, get_gender) + + +bot.add_middleware( + NextStepMiddleware, bot=bot, functions={get_age, get_name, get_gender} +) + + +@bot.default(include_in_status=False) +async def echo_handler(message: Message) -> None: + await bot.answer_message(message.body, message) + + +@bot.handler +async def fill_info(message: Message) -> None: + if message.user_huid not in users_data: + text = ( + "Hi! I'm a bot that will ask some questions about you.\n" + "First of all: what is your name?" + ) + register_next_step_handler(message, get_name) + else: + text = ( + "You've already filled out information about yourself.\n" + "You can view it by typing `/my-info` command.\n" + "You can also view the processed information by typing `/info` command." + ) + + await bot.answer_message(text, message) + + +@bot.handler(command="/my-info") +async def get_info_for_user(message: Message) -> None: + if message.user_huid not in users_data: + text = ( + "I have no information about you :(\n" + "Type `/fill-info` so I can collect it, please." + ) + await bot.answer_message(text, message) + else: + text = ( + f"Your name: {users_data[message.user_huid]['name']}\n" + f"Your age: {users_data[message.user_huid]['age']}\n" + f"Your gender: {users_data[message.user_huid]['gender']}\n" + "This is all that I have now." + ) + await bot.answer_message(text, message) + + +@bot.handler(commands=["/info", "/information"]) +async def get_processed_information(message: Message) -> None: + users_count = len(users_data) + average_age = sum(user["age"] for user in users_data) / users_count + gender_array = [1 if user["gender"] == "male" else 2 for user in users_data] + text = ( + f"Count of users: {users_count}\n" + f"Average age: {average_age}\n" + f"Male users count: {gender_array.count(1)}\n" + f"Female users count: {gender_array.count(2)}" + ) + + await bot.answer_message(text, message) + + +app = FastAPI() +app.add_event_handler("shutdown", bot.shutdown) + + +@app.get("/status", response_model=Status) +async def bot_status() -> Status: + return await bot.status() + + +@app.post("/command", status_code=HTTP_202_ACCEPTED) +async def bot_command(message: IncomingMessage) -> None: + await bot.execute_command(message.dict()) diff --git a/docs/src/development/handling_errors/handling_errors0.py b/docs/src/development/handling_errors/handling_errors0.py new file mode 100644 index 00000000..41cbd170 --- /dev/null +++ b/docs/src/development/handling_errors/handling_errors0.py @@ -0,0 +1,13 @@ +from botx import Bot, Message + +bot = Bot() + + +@bot.exception_handler(RuntimeError) +async def error_handler(exc: Exception, msg: Message) -> None: + await msg.bot.answer_message(f"Error occurred during handling command: {exc}", msg) + + +@bot.handler +async def handler_with_bug(message: Message) -> None: + raise RuntimeError(message.body) diff --git a/docs/src/development/logging/logging0.py b/docs/src/development/logging/logging0.py new file mode 100644 index 00000000..abd7b24d --- /dev/null +++ b/docs/src/development/logging/logging0.py @@ -0,0 +1,5 @@ +from botx import Bot +from loguru import logger + +bot = Bot() +logger.enable("botx") diff --git a/docs/src/development/sending_data/sending_data0.py b/docs/src/development/sending_data/sending_data0.py new file mode 100644 index 00000000..a1d3b6da --- /dev/null +++ b/docs/src/development/sending_data/sending_data0.py @@ -0,0 +1,15 @@ +from uuid import UUID + +from botx import Bot, SendingMessage + +bot = Bot() +CHAT_ID = UUID("1f972f5e-6d17-4f39-be5b-f7e20f1b4d13") +BOT_ID = UUID("cc257e1c-c028-4181-a055-01e14ba881b0") +CTS_HOST = "my-cts.example.com" + + +async def some_function() -> None: + message = SendingMessage( + text="You were chosen by random.", bot_id=BOT_ID, host=CTS_HOST, chat_id=CHAT_ID + ) + await bot.send(message) diff --git a/docs/src/development/sending_data/sending_data1.py b/docs/src/development/sending_data/sending_data1.py new file mode 100644 index 00000000..de20bd8b --- /dev/null +++ b/docs/src/development/sending_data/sending_data1.py @@ -0,0 +1,11 @@ +from botx import Bot, Message, SendingMessage + +bot = Bot() + + +@bot.handler(command="/my-handler") +async def some_handler(message: Message) -> None: + message = SendingMessage.from_message( + text="You were chosen by random.", message=message + ) + await bot.send(message) diff --git a/docs/src/development/sending_data/sending_data2.py b/docs/src/development/sending_data/sending_data2.py new file mode 100644 index 00000000..0532089f --- /dev/null +++ b/docs/src/development/sending_data/sending_data2.py @@ -0,0 +1,8 @@ +from botx import Bot, Message + +bot = Bot() + + +@bot.handler(command="/my-handler") +async def some_handler(message: Message) -> None: + await bot.answer_message(text="VERY IMPORTANT NOTIFICATION!!!", message=message) diff --git a/docs/src/development/sending_data/sending_data3.py b/docs/src/development/sending_data/sending_data3.py new file mode 100644 index 00000000..5ea9cb81 --- /dev/null +++ b/docs/src/development/sending_data/sending_data3.py @@ -0,0 +1,23 @@ +from botx import Bot, File, Message, SendingMessage + +bot = Bot() + + +@bot.handler +async def my_handler(message: Message) -> None: + with open("my_file.txt") as f: + notification = SendingMessage( + file=File.from_file(f), credentials=message.credentials + ) + + await bot.send(notification) + + +@bot.handler +async def another_handler(message: Message) -> None: + notification = SendingMessage.from_message(message=message) + + with open("my_file.txt") as f: + notification.add_file(f) + + await bot.send(notification) diff --git a/docs/src/development/sending_data/sending_data4.py b/docs/src/development/sending_data/sending_data4.py new file mode 100644 index 00000000..576425ec --- /dev/null +++ b/docs/src/development/sending_data/sending_data4.py @@ -0,0 +1,9 @@ +from botx import Bot, Message + +bot = Bot() + + +@bot.handler +async def my_handler(message: Message) -> None: + with open("my_file.txt") as f: + await bot.answer_message("Text that will be sent with file", message, file=f) diff --git a/docs/src/development/sending_data/sending_data5.py b/docs/src/development/sending_data/sending_data5.py new file mode 100644 index 00000000..8c3dc128 --- /dev/null +++ b/docs/src/development/sending_data/sending_data5.py @@ -0,0 +1,9 @@ +from botx import Bot, Message + +bot = Bot() + + +@bot.handler +async def my_handler(message: Message) -> None: + with open("my_file.txt") as f: + await bot.send_file(f, message.credentials) diff --git a/docs/src/development/sending_data/sending_data6.py b/docs/src/development/sending_data/sending_data6.py new file mode 100644 index 00000000..07e192ac --- /dev/null +++ b/docs/src/development/sending_data/sending_data6.py @@ -0,0 +1,20 @@ +from botx import Bot, BubbleElement, Message, MessageMarkup + +bot = Bot() + + +@bot.handler +async def my_handler_with_direct_bubbles_definition(message: Message) -> None: + await bot.answer_message( + "Bubbles!!", + message, + markup=MessageMarkup( + bubbles=[ + [BubbleElement(label="bubble 1", command="")], + [ + BubbleElement(label="bubble 2", command=""), + BubbleElement(label="bubble 3", command=""), + ], + ] + ), + ) diff --git a/docs/src/development/sending_data/sending_data7.py b/docs/src/development/sending_data/sending_data7.py new file mode 100644 index 00000000..55be109d --- /dev/null +++ b/docs/src/development/sending_data/sending_data7.py @@ -0,0 +1,13 @@ +from botx import Bot, Message, MessageMarkup + +bot = Bot() + + +@bot.handler +async def my_handler_with_passing_predefined_markup(message: Message) -> None: + markup = MessageMarkup() + markup.add_bubble(command="", label="bubble 1") + markup.add_bubble(command="", label="bubble 2", new_row=False) + markup.add_bubble(command="", label="bubble 3") + + await bot.answer_message("Bubbles!!", message, markup=markup) diff --git a/docs/src/development/sending_data/sending_data8.py b/docs/src/development/sending_data/sending_data8.py new file mode 100644 index 00000000..540bda91 --- /dev/null +++ b/docs/src/development/sending_data/sending_data8.py @@ -0,0 +1,13 @@ +from botx import Bot, Message, SendingMessage + +bot = Bot() + + +@bot.handler +async def my_handler_with_markup_in_sending_message(message: Message) -> None: + reply = SendingMessage.from_message(text="More buttons!!!", message=message) + reply.add_bubble(command="", label="bubble 1") + reply.add_keyboard_button(command="", label="keyboard button 1", new_row=False) + reply.add_keyboard_button(command="", label="keyboard button 2") + + await bot.send(reply) diff --git a/docs/src/development/sending_data/sending_data9.py b/docs/src/development/sending_data/sending_data9.py new file mode 100644 index 00000000..d788ffb5 --- /dev/null +++ b/docs/src/development/sending_data/sending_data9.py @@ -0,0 +1,33 @@ +from uuid import UUID + +from botx import Bot, Message, SendingMessage + +bot = Bot() +CHAT_FOR_MENTION = UUID("369b49fd-b5eb-4d5b-8e4d-83b020ff2b14") +USER_FOR_MENTION = UUID("cbf4b952-77d5-4484-aea0-f05fb622e089") + + +@bot.handler +async def my_handler_with_user_mention(message: Message) -> None: + reply = SendingMessage.from_message( + text="Hi! There is a notification with mention for you", message=message + ) + reply.mention_user(message.user_huid) + + await bot.send(reply) + + +@bot.handler +async def my_handler_with_chat_mention(message: Message) -> None: + reply = SendingMessage.from_message(text="Check this chat", message=message) + reply.mention_chat(CHAT_FOR_MENTION, name="Interesting chat") + await bot.send(reply) + + +@bot.handler +async def my_handler_with_contact_mention(message: Message) -> None: + reply = SendingMessage.from_message( + text="You should request access!", message=message + ) + reply.mention_chat(USER_FOR_MENTION, name="Administrator") + await bot.send(reply) diff --git a/docs/src/development/tests/tests0/bot.py b/docs/src/development/tests/tests0/bot.py new file mode 100644 index 00000000..03aa1ae1 --- /dev/null +++ b/docs/src/development/tests/tests0/bot.py @@ -0,0 +1,8 @@ +from botx import Bot, Message + +bot = Bot() + + +@bot.handler +async def hello(message: Message) -> None: + await bot.answer_message(f"Hello, {message.user.username}", message) diff --git a/docs/src/development/tests/tests0/conftest.py b/docs/src/development/tests/tests0/conftest.py new file mode 100644 index 00000000..02f52334 --- /dev/null +++ b/docs/src/development/tests/tests0/conftest.py @@ -0,0 +1,18 @@ +import pytest +from botx import Bot, ExpressServer +from botx.testing import MessageBuilder + +from .bot import bot + + +@pytest.fixture +def builder() -> MessageBuilder: + builder = MessageBuilder() + builder.user.host = "example.com" + return builder + + +@pytest.fixture +def bot(builder: MessageBuilder) -> Bot: + bot.known_hosts.append(ExpressServer(host=builder.user.host, secret_key="secret")) + return bot diff --git a/docs/src/development/tests/tests0/test_format_command.py b/docs/src/development/tests/tests0/test_format_command.py new file mode 100644 index 00000000..e5de2d02 --- /dev/null +++ b/docs/src/development/tests/tests0/test_format_command.py @@ -0,0 +1,13 @@ +import pytest +from botx import Bot, testing + + +@pytest.mark.asyncio +async def test_hello_format(bot: Bot, builder: testing.MessageBuilder) -> None: + builder.body = "/hello" + + with testing.TestClient(bot) as client: + await client.send_command(builder.message) + + command_result = client.command_results[0] + assert command_result.result.body == f"Hello, {builder.user.username}" diff --git a/docs/src/index/index0.py b/docs/src/index/index0.py new file mode 100644 index 00000000..6db97d7b --- /dev/null +++ b/docs/src/index/index0.py @@ -0,0 +1,24 @@ +from botx import Bot, ExpressServer, IncomingMessage, Message, Status +from fastapi import FastAPI +from starlette.status import HTTP_202_ACCEPTED + +bot = Bot(known_hosts=[ExpressServer(host="cts.example.com", secret_key="secret")]) + + +@bot.default(include_in_status=False) +async def echo_handler(message: Message) -> None: + await bot.answer_message(message.body, message) + + +app = FastAPI() +app.add_event_handler("shutdown", bot.shutdown) + + +@app.get("/status", response_model=Status) +async def bot_status() -> Status: + return await bot.status() + + +@app.post("/command", status_code=HTTP_202_ACCEPTED) +async def bot_command(message: IncomingMessage) -> None: + await bot.execute_command(message.dict()) diff --git a/mkdocs.yml b/mkdocs.yml index e9482485..ef01f155 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,21 +13,30 @@ nav: - Tutorial - User Guide: - First Steps: 'development/first-steps.md' - Sending Messages And Files: 'development/sending-data.md' - - HandlersCollector: 'development/handlers-collector.md' + - Handlers Collecting: 'development/collector.md' - Handling Errors: 'development/handling-errors.md' - Dependencies Injection: 'development/dependencies-injection.md' - Logging: 'development/logging.md' - - Use Synchronous SDK: 'development/sync.md' - - Developing Using Tokens: 'development/cts-tokens.md' - Testing: 'development/tests.md' - - Deployment: 'development/bots-deployment.md' - API Reference: - - Bots: 'api-reference/bots.md' - - HandlersCollector: 'api-reference/handlers-collector.md' - - BotX Types: 'api-reference/botx-types.md' + - Bots: 'reference/bots.md' + - Clients: 'reference/clients.md' + - Collecting: 'reference/collecting.md' + - Exceptions: 'reference/exceptions.md' + - Testing: 'reference/testing.md' + - Models: 'reference/models.md' + - Dependencies: 'reference/dependencies.md' + - Middlewares: 'reference/middlewares.md' - Changelog: 'changelog.md' markdown_extensions: - - markdown.extensions.codehilite: - guess_lang: false - - admonition \ No newline at end of file + - markdown.extensions.codehilite: + guess_lang: false + - markdown_include.include: + base_path: docs + - admonition + - codehilite + +plugins: + - search + - mkdocstrings \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 306f60fe..ff75cc21 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,17 @@ +[[package]] +category = "main" +description = "Asyncio support for PEP-567 contextvars backport." +marker = "python_version < \"3.7\"" +name = "aiocontextvars" +optional = false +python-versions = ">=3.5" +version = "0.2.2" + +[package.dependencies] +[package.dependencies.contextvars] +python = "<3.7" +version = "2.4" + [[package]] category = "dev" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." @@ -6,9 +20,18 @@ optional = false python-versions = "*" version = "1.4.3" +[[package]] +category = "dev" +description = "Read/rewrite/write Python ASTs" +name = "astor" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +version = "0.8.1" + [[package]] category = "dev" description = "Atomic file writes." +marker = "sys_platform == \"win32\"" name = "atomicwrites" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" @@ -22,6 +45,12 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "19.3.0" +[package.extras] +azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] +dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] +docs = ["sphinx", "zope.interface"] +tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] + [[package]] category = "dev" description = "Removes unused imports and unused variables" @@ -33,27 +62,40 @@ version = "1.3.1" [package.dependencies] pyflakes = ">=1.1.0" +[[package]] +category = "dev" +description = "Security oriented static analyser for python code." +name = "bandit" +optional = false +python-versions = "*" +version = "1.6.2" + +[package.dependencies] +GitPython = ">=1.0.1" +PyYAML = ">=3.13" +colorama = ">=0.3.9" +six = ">=1.10.0" +stevedore = ">=1.20.0" + [[package]] category = "dev" description = "The uncompromising code formatter." name = "black" optional = false python-versions = ">=3.6" -version = "18.9b0" +version = "19.10b0" [package.dependencies] appdirs = "*" -attrs = ">=17.4.0" +attrs = ">=18.1.0" click = ">=6.5" +pathspec = ">=0.6,<1" +regex = "*" toml = ">=0.9.4" +typed-ast = ">=1.4.0" -[[package]] -category = "dev" -description = "A decorator for caching properties in classes." -name = "cached-property" -optional = false -python-versions = "*" -version = "1.5.1" +[package.extras] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] [[package]] category = "main" @@ -61,7 +103,7 @@ description = "Python package for providing Mozilla's CA Bundle." name = "certifi" optional = false python-versions = "*" -version = "2019.9.11" +version = "2019.11.28" [[package]] category = "main" @@ -79,22 +121,55 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "7.0" +[[package]] +category = "dev" +description = "Library to calculate Python functions cognitive complexity via code" +name = "cognitive-complexity" +optional = false +python-versions = ">=3.6" +version = "0.0.4" + +[package.dependencies] +setuptools = "*" + [[package]] category = "main" description = "Cross-platform colored terminal text." -marker = "sys_platform == \"win32\"" name = "colorama" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.4.1" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.4.3" + +[[package]] +category = "main" +description = "PEP 567 Backport" +marker = "python_version < \"3.7\"" +name = "contextvars" +optional = false +python-versions = "*" +version = "2.4" + +[package.dependencies] +immutables = ">=0.9" [[package]] category = "dev" description = "Code coverage measurement for Python" name = "coverage" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*, <4" -version = "4.5.4" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +version = "5.0.3" + +[package.extras] +toml = ["toml"] + +[[package]] +category = "dev" +description = "A utility for ensuring Google-style docstrings stay up to date with the source code." +name = "darglint" +optional = false +python-versions = ">=3.5" +version = "1.1.0" [[package]] category = "main" @@ -105,6 +180,14 @@ optional = false python-versions = "*" version = "0.6" +[[package]] +category = "dev" +description = "Docutils -- Python Documentation Utilities" +name = "docutils" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.16" + [[package]] category = "dev" description = "Discover and load entry points from installed packages." @@ -121,6 +204,24 @@ optional = false python-versions = "*" version = "1.0" +[[package]] +category = "main" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +name = "fastapi" +optional = true +python-versions = ">=3.6" +version = "0.47.1" + +[package.dependencies] +pydantic = ">=0.32.2,<2.0.0" +starlette = "0.12.9" + +[package.extras] +all = ["requests", "aiofiles", "jinja2", "python-multipart", "itsdangerous", "pyyaml", "graphene", "ujson", "email-validator", "uvicorn", "async-exit-stack", "async-generator"] +dev = ["pyjwt", "passlib", "autoflake", "flake8", "uvicorn", "graphene"] +doc = ["mkdocs", "mkdocs-material", "markdown-include"] +test = ["pytest (>=4.0.0)", "pytest-cov", "mypy", "black", "isort", "requests", "email-validator", "sqlalchemy", "peewee", "databases", "orjson", "async-exit-stack", "async-generator"] + [[package]] category = "dev" description = "the modular source code checker: pep8, pyflakes and co" @@ -137,14 +238,28 @@ pyflakes = ">=2.1.0,<2.2.0" [[package]] category = "dev" -description = "Flake8 plugin warning for unsafe functions" -name = "flake8-alfred" +description = "A flake8 extension that checks for type annotations complexity" +name = "flake8-annotations-complexity" optional = false -python-versions = ">=3.6" -version = "1.1.1" +python-versions = "*" +version = "0.0.2" [package.dependencies] +setuptools = "*" + +[[package]] +category = "dev" +description = "Automated security testing with bandit and flake8." +name = "flake8-bandit" +optional = false +python-versions = "*" +version = "2.1.2" + +[package.dependencies] +bandit = "*" flake8 = "*" +flake8-polyfill = "*" +pycodestyle = "*" [[package]] category = "dev" @@ -175,40 +290,99 @@ description = "Check for python builtins being used as variables or parameters." name = "flake8-builtins" optional = false python-versions = "*" -version = "1.4.1" +version = "1.4.2" [package.dependencies] flake8 = "*" +[package.extras] +test = ["coverage", "coveralls", "mock", "pytest", "pytest-cov"] + +[[package]] +category = "dev" +description = "Adds coding magic comment checks to flake8" +name = "flake8-coding" +optional = false +python-versions = "*" +version = "1.3.2" + +[package.dependencies] +flake8 = "*" + +[[package]] +category = "dev" +description = "Flake8 lint for trailing commas." +name = "flake8-commas" +optional = false +python-versions = "*" +version = "2.0.0" + +[package.dependencies] +flake8 = ">=2,<4.0.0" + [[package]] category = "dev" description = "A flake8 plugin to help you write better list/set/dict comprehensions." name = "flake8-comprehensions" optional = false python-versions = ">=3.5" -version = "2.3.0" +version = "3.2.1" [package.dependencies] -cached-property = ">=1.0.0,<2.0.0" -flake8 = "!=3.2.0" +flake8 = ">=3.0,<3.2.0 || >3.2.0,<4" [package.dependencies.importlib-metadata] python = "<3.8" version = "*" +[[package]] +category = "dev" +description = "ipdb/pdb statement checker plugin for flake8" +name = "flake8-debugger" +optional = false +python-versions = "*" +version = "3.2.1" + +[package.dependencies] +flake8 = ">=1.5" +pycodestyle = "*" + +[[package]] +category = "dev" +description = "Extension for flake8 which uses pydocstyle to check docstrings" +name = "flake8-docstrings" +optional = false +python-versions = "*" +version = "1.5.0" + +[package.dependencies] +flake8 = ">=3" +pydocstyle = ">=2.1" + [[package]] category = "dev" description = "Flake8 plugin to find commented out code" name = "flake8-eradicate" optional = false python-versions = ">=3.6,<4.0" -version = "0.2.3" +version = "0.2.4" [package.dependencies] attrs = ">=18.2,<20.0" eradicate = ">=0.2.1,<1.1.0" flake8 = ">=3.5,<4.0" +[[package]] +category = "dev" +description = "A Flake8 plugin for checking executable permissions and shebangs." +name = "flake8-executable" +optional = false +python-versions = ">=3.6" +version = "2.0.3" + +[package.dependencies] +flake8 = ">=3.0.0" + [[package]] category = "dev" description = "Check for FIXME, TODO and other temporary developer notes. Plugin for flake8." @@ -217,6 +391,25 @@ optional = false python-versions = "*" version = "1.1.1" +[[package]] +category = "dev" +description = "flake8 plugin that integrates isort ." +name = "flake8-isort" +optional = false +python-versions = "*" +version = "2.8.0" + +[package.dependencies] +flake8 = ">=3.2.1" +testfixtures = "*" + +[package.dependencies.isort] +extras = ["pyproject"] +version = ">=4.3.0" + +[package.extras] +test = ["pytest"] + [[package]] category = "dev" description = "Flake8 extension to validate (lack of) logging format strings" @@ -227,39 +420,65 @@ version = "0.6.0" [[package]] category = "dev" -description = "mutable defaults flake8 extension" -name = "flake8-mutable" +description = "Checks for old string formatting." +name = "flake8-pep3101" optional = false python-versions = "*" -version = "1.2.0" +version = "1.3.0" [package.dependencies] -flake8 = "*" +flake8 = ">=3.0" + +[package.extras] +test = ["pytest", "testfixtures"] [[package]] category = "dev" -description = "Checks for old string formatting." -name = "flake8-pep3101" +description = "Polyfill package for Flake8 plugins" +name = "flake8-polyfill" optional = false python-versions = "*" -version = "1.2.1" +version = "1.0.2" [package.dependencies] -flake8 = ">=3.0" +flake8 = "*" [[package]] category = "dev" description = "print statement checker plugin for flake8" name = "flake8-print" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" -version = "3.1.1" +python-versions = "*" +version = "3.1.4" [package.dependencies] -flake8 = ">=2.1" +flake8 = ">=1.5" pycodestyle = "*" six = "*" +[[package]] +category = "dev" +description = "Flake8 lint for quotes." +name = "flake8-quotes" +optional = false +python-versions = "*" +version = "2.1.1" + +[package.dependencies] +flake8 = "*" + +[[package]] +category = "dev" +description = "Python docstring reStructuredText (RST) validator" +name = "flake8-rst-docstrings" +optional = false +python-versions = "*" +version = "0.0.12" + +[package.dependencies] +flake8 = ">=3.0.0" +restructuredtext_lint = "*" + [[package]] category = "dev" description = "string format checker, plugin for flake8" @@ -271,13 +490,43 @@ version = "0.2.3" [package.dependencies] flake8 = "*" +[[package]] +category = "dev" +description = "Clean single-source support for Python 3 and 2" +name = "future" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "0.18.2" + +[[package]] +category = "dev" +description = "Git Object Database" +name = "gitdb2" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.0.6" + +[package.dependencies] +smmap2 = ">=2.0.0" + +[[package]] +category = "dev" +description = "Python Git Library" +name = "gitpython" +optional = false +python-versions = ">=3.0, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.0.5" + +[package.dependencies] +gitdb2 = ">=2.0.0" + [[package]] category = "main" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" name = "h11" optional = false python-versions = "*" -version = "0.8.1" +version = "0.9.0" [[package]] category = "main" @@ -305,7 +554,7 @@ description = "Chromium HSTS Preload list as a Python package and updated daily" name = "hstspreload" optional = false python-versions = ">=3.6" -version = "2019.10.25" +version = "2020.1.17" [[package]] category = "main" @@ -321,16 +570,18 @@ description = "The next generation HTTP client." name = "httpx" optional = false python-versions = ">=3.6" -version = "0.7.5" +version = "0.11.1" [package.dependencies] certifi = "*" chardet = ">=3.0.0,<4.0.0" -h11 = ">=0.8.0,<0.9.0" +h11 = ">=0.8,<0.10" h2 = ">=3.0.0,<4.0.0" -hstspreload = ">=2019.8.27" +hstspreload = "*" idna = ">=2.0.0,<3.0.0" -rfc3986 = ">=1.0.0,<2.0.0" +rfc3986 = ">=1.3,<2" +sniffio = ">=1.0.0,<2.0.0" +urllib3 = ">=1.0.0,<2.0.0" [[package]] category = "main" @@ -348,18 +599,31 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "2.8" +[[package]] +category = "main" +description = "Immutable Collections" +marker = "python_version < \"3.7\"" +name = "immutables" +optional = false +python-versions = "*" +version = "0.11" + [[package]] category = "dev" description = "Read metadata from Python packages" marker = "python_version < \"3.8\"" name = "importlib-metadata" optional = false -python-versions = ">=2.7,!=3.0,!=3.1,!=3.2,!=3.3" -version = "0.23" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "1.4.0" [package.dependencies] zipp = ">=0.5" +[package.extras] +docs = ["sphinx", "rst.linker"] +testing = ["packaging", "importlib-resources"] + [[package]] category = "dev" description = "A Python utility / library to sort Python imports." @@ -368,6 +632,12 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "4.3.21" +[package.extras] +pipfile = ["pipreqs", "requirementslib"] +pyproject = ["toml"] +requirements = ["pipreqs", "pip-api"] +xdg_home = ["appdirs (>=1.4.0)"] + [[package]] category = "main" description = "A very fast and expressive template engine." @@ -379,6 +649,9 @@ version = "2.10.3" [package.dependencies] MarkupSafe = ">=0.23" +[package.extras] +i18n = ["Babel (>=0.8)"] + [[package]] category = "main" description = "JavaScript minifier." @@ -405,12 +678,33 @@ description = "Python logging made (stupidly) simple" name = "loguru" optional = false python-versions = ">=3.5" -version = "0.3.2" +version = "0.4.1" [package.dependencies] colorama = ">=0.3.4" win32-setctime = ">=1.0.0" +[package.dependencies.aiocontextvars] +python = "<3.7" +version = ">=0.2.0" + +[package.extras] +dev = ["codecov (>=2.0.15)", "colorama (>=0.3.4)", "flake8 (>=3.7.7)", "isort (>=4.3.20)", "tox (>=3.9.0)", "tox-travis (>=0.12)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "Sphinx (>=2.2.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)", "black (>=19.3b0)"] + +[[package]] +category = "dev" +description = "Create Python CLI apps with little to no effort at all!" +name = "mando" +optional = false +python-versions = "*" +version = "0.6.4" + +[package.dependencies] +six = "*" + +[package.extras] +restructuredText = ["rst2ansi"] + [[package]] category = "main" description = "Python implementation of Markdown." @@ -422,6 +716,20 @@ version = "3.1.1" [package.dependencies] setuptools = ">=36" +[package.extras] +testing = ["coverage", "pyyaml"] + +[[package]] +category = "main" +description = "This is an extension to Python-Markdown which provides an \"include\" function, similar to that found in LaTeX (and also the C pre-processor and Fortran). I originally wrote it for my FORD Fortran auto-documentation generator." +name = "markdown-include" +optional = true +python-versions = "*" +version = "0.5.1" + +[package.dependencies] +markdown = "*" + [[package]] category = "main" description = "Safely add untrusted strings to HTML/XML markup." @@ -460,13 +768,14 @@ description = "A Material Design theme for MkDocs" name = "mkdocs-material" optional = true python-versions = "*" -version = "4.4.3" +version = "4.6.0" [package.dependencies] Pygments = ">=2.2" +markdown = "<3.2" mkdocs = ">=1" mkdocs-minify-plugin = ">=0.2" -pymdown-extensions = ">=4.11" +pymdown-extensions = ">=6.2,<6.3" [[package]] category = "main" @@ -481,26 +790,37 @@ htmlmin = ">=0.1.4" jsmin = ">=2.2.2" mkdocs = ">=1.0.4" +[[package]] +category = "main" +description = "Automatic documentation from docstrings, for mkdocs." +name = "mkdocstrings" +optional = true +python-versions = ">=3.6,<4.0" +version = "0.7.0" + [[package]] category = "dev" description = "More routines for operating on iterables, beyond itertools" -marker = "python_version < \"3.8\" or python_version > \"2.7\"" name = "more-itertools" optional = false -python-versions = ">=3.4" -version = "7.2.0" +python-versions = ">=3.5" +version = "8.1.0" [[package]] category = "dev" -description = "Optional static typing for Python (mypyc-compiled version)" +description = "Optional static typing for Python" name = "mypy" optional = false -python-versions = "*" -version = "0.701" +python-versions = ">=3.5" +version = "0.740" [package.dependencies] mypy-extensions = ">=0.4.0,<0.5.0" -typed-ast = ">=1.3.1,<1.4.0" +typed-ast = ">=1.4.0,<1.5.0" +typing-extensions = ">=3.7.4" + +[package.extras] +dmypy = ["psutil (>=4.0)"] [[package]] category = "dev" @@ -516,12 +836,28 @@ description = "Core utilities for Python packages" name = "packaging" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "19.2" +version = "20.0" [package.dependencies] pyparsing = ">=2.0.2" six = "*" +[[package]] +category = "dev" +description = "Utility library for gitignore style pattern matching of file paths." +name = "pathspec" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.7.0" + +[[package]] +category = "dev" +description = "Python Build Reasonableness" +name = "pbr" +optional = false +python-versions = "*" +version = "5.4.4" + [[package]] category = "main" description = "Backport of PEP 562." @@ -530,26 +866,40 @@ optional = true python-versions = "*" version = "1.0" +[[package]] +category = "dev" +description = "Check PEP-8 naming conventions, plugin for flake8" +name = "pep8-naming" +optional = false +python-versions = "*" +version = "0.9.1" + +[package.dependencies] +flake8-polyfill = ">=1.0.2,<2" + [[package]] category = "dev" description = "plugin and hook calling mechanisms for python" name = "pluggy" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "0.13.0" +version = "0.13.1" [package.dependencies] [package.dependencies.importlib-metadata] python = "<3.8" version = ">=0.12" +[package.extras] +dev = ["pre-commit", "tox"] + [[package]] category = "dev" description = "library with cross-python path, ini-parsing, io, code, log facilities" name = "py" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.8.0" +version = "1.8.1" [[package]] category = "dev" @@ -565,13 +915,28 @@ description = "Data validation and settings management using python 3.6 type hin name = "pydantic" optional = false python-versions = ">=3.6" -version = "0.32.2" +version = "1.3" [package.dependencies] [package.dependencies.dataclasses] python = "<3.7" version = ">=0.6" +[package.extras] +email = ["email-validator (>=1.0.3)"] +typing_extensions = ["typing-extensions (>=3.7.2)"] + +[[package]] +category = "dev" +description = "Python docstring style checker" +name = "pydocstyle" +optional = false +python-versions = ">=3.5" +version = "5.0.2" + +[package.dependencies] +snowballstemmer = "*" + [[package]] category = "dev" description = "passive checker of Python programs" @@ -584,9 +949,9 @@ version = "2.1.1" category = "main" description = "Pygments is a syntax highlighting package written in Python." name = "pygments" -optional = true +optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "2.4.2" +version = "2.5.2" [[package]] category = "main" @@ -594,7 +959,7 @@ description = "Extension pack for Python Markdown." name = "pymdown-extensions" optional = true python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" -version = "6.1" +version = "6.2.1" [package.dependencies] Markdown = ">=3.0.1" @@ -606,33 +971,33 @@ description = "Python parsing module" name = "pyparsing" optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" -version = "2.4.2" +version = "2.4.6" [[package]] category = "dev" description = "pytest: simple powerful testing with Python" name = "pytest" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -version = "4.6.6" +python-versions = ">=3.5" +version = "5.3.3" [package.dependencies] atomicwrites = ">=1.0" attrs = ">=17.4.0" colorama = "*" +more-itertools = ">=4.0.0" packaging = "*" pluggy = ">=0.12,<1.0" py = ">=1.5.0" -six = ">=1.10.0" wcwidth = "*" [package.dependencies.importlib-metadata] python = "<3.8" version = ">=0.12" -[package.dependencies.more-itertools] -python = ">=2.8" -version = ">=4.0.0" +[package.extras] +checkqa-mypy = ["mypy (v0.761)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] [[package]] category = "dev" @@ -645,6 +1010,9 @@ version = "0.10.0" [package.dependencies] pytest = ">=3.0.6" +[package.extras] +testing = ["async-generator (>=1.3)", "coverage", "hypothesis (>=3.64)"] + [[package]] category = "dev" description = "Pytest plugin for measuring coverage." @@ -657,24 +1025,49 @@ version = "2.8.1" coverage = ">=4.4" pytest = ">=3.6" +[package.extras] +testing = ["fields", "hunter", "process-tests (2.0.2)", "six", "virtualenv"] + +[[package]] +category = "main" +description = "YAML parser and emitter for Python" +name = "pyyaml" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "5.3" + [[package]] category = "dev" -description = "A streaming multipart parser for Python" -name = "python-multipart" +description = "Code Metrics in Python" +name = "radon" optional = false python-versions = "*" -version = "0.0.5" +version = "4.0.0" [package.dependencies] -six = ">=1.4.0" +colorama = ">=0.4,<0.5" +flake8-polyfill = "*" +future = "*" +mando = ">=0.6,<0.7" [[package]] -category = "main" -description = "YAML parser and emitter for Python" -name = "pyyaml" -optional = true -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "5.1.2" +category = "dev" +description = "Alternative regular expression module, to replace re." +name = "regex" +optional = false +python-versions = "*" +version = "2020.1.8" + +[[package]] +category = "dev" +description = "reStructuredText linter" +name = "restructuredtext-lint" +optional = false +python-versions = "*" +version = "1.3.0" + +[package.dependencies] +docutils = ">=0.11,<1.0" [[package]] category = "main" @@ -684,21 +1077,81 @@ optional = false python-versions = "*" version = "1.3.2" +[package.extras] +idna2008 = ["idna"] + [[package]] category = "main" description = "Python 2 and 3 compatibility utilities" name = "six" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*" -version = "1.12.0" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +version = "1.14.0" [[package]] category = "dev" +description = "A pure Python implementation of a sliding window memory map manager" +name = "smmap2" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.0.5" + +[[package]] +category = "main" +description = "Sniff out which async library your code is running under" +name = "sniffio" +optional = false +python-versions = ">=3.5" +version = "1.1.0" + +[package.dependencies] +[package.dependencies.contextvars] +python = "<3.7" +version = ">=2.1" + +[[package]] +category = "dev" +description = "This package provides 26 stemmers for 25 languages generated from Snowball algorithms." +name = "snowballstemmer" +optional = false +python-versions = "*" +version = "2.0.0" + +[[package]] +category = "main" description = "The little ASGI library that shines." name = "starlette" -optional = false +optional = true python-versions = ">=3.6" -version = "0.12.10" +version = "0.12.9" + +[package.extras] +full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "ujson"] + +[[package]] +category = "dev" +description = "Manage dynamic plugins for Python applications" +name = "stevedore" +optional = false +python-versions = "*" +version = "1.31.0" + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" +six = ">=1.10.0" + +[[package]] +category = "dev" +description = "A collection of helpers and mock objects for unit tests and doc tests." +name = "testfixtures" +optional = false +python-versions = "*" +version = "6.10.3" + +[package.extras] +build = ["setuptools-git", "wheel", "twine"] +docs = ["sphinx"] +test = ["pytest (>=3.6)", "pytest-cov", "pytest-django", "sybil", "zope.component", "twisted", "mock", "django (<2)", "django"] [[package]] category = "dev" @@ -722,7 +1175,28 @@ description = "a fork of Python 2 and 3 ast modules with type comment support" name = "typed-ast" optional = false python-versions = "*" -version = "1.3.5" +version = "1.4.1" + +[[package]] +category = "dev" +description = "Backported and Experimental Type Hints for Python 3.5+" +name = "typing-extensions" +optional = false +python-versions = "*" +version = "3.7.4.1" + +[[package]] +category = "main" +description = "HTTP library with thread-safe connection pooling, file post, and more." +name = "urllib3" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" +version = "1.25.7" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] [[package]] category = "dev" @@ -730,7 +1204,45 @@ description = "Measures number of Terminal column cells of wide-character codes" name = "wcwidth" optional = false python-versions = "*" -version = "0.1.7" +version = "0.1.8" + +[[package]] +category = "dev" +description = "The strictest and most opinionated python linter ever" +name = "wemake-python-styleguide" +optional = false +python-versions = ">=3.6,<4.0" +version = "0.13.4" + +[package.dependencies] +astor = ">=0.8,<0.9" +attrs = "*" +cognitive_complexity = ">=0.0.4,<0.0.5" +darglint = ">=1.1,<2.0" +flake8 = ">=3.7,<4.0" +flake8-annotations-complexity = ">=0.0.2,<0.0.3" +flake8-bandit = ">=2.1,<3.0" +flake8-broken-line = ">=0.1,<0.2" +flake8-bugbear = ">=19.3,<20.0" +flake8-builtins = ">=1.4.2,<2.0.0" +flake8-coding = ">=1.3,<2.0" +flake8-commas = ">=2.0,<3.0" +flake8-comprehensions = ">=3.1.0,<4.0.0" +flake8-debugger = ">=3.1,<4.0" +flake8-docstrings = ">=1.3.1,<2.0.0" +flake8-eradicate = ">=0.2,<0.3" +flake8-executable = ">=2.0,<3.0" +flake8-isort = ">=2.6,<3.0" +flake8-logging-format = ">=0.6,<0.7" +flake8-pep3101 = ">=1.2,<2.0" +flake8-print = ">=3.1.4,<4.0.0" +flake8-quotes = ">=2.0.1,<3.0.0" +flake8-rst-docstrings = ">=0.0.12,<0.0.13" +flake8-string-format = ">=0.2,<0.3" +pep8-naming = ">=0.9.1,<0.10.0" +pygments = ">=2.4,<3.0" +radon = ">=4.0,<5.0" +typing_extensions = ">=3.6,<4.0" [[package]] category = "main" @@ -741,97 +1253,604 @@ optional = false python-versions = ">=3.5" version = "1.0.1" +[package.extras] +dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"] + [[package]] category = "dev" description = "Backport of pathlib-compatible object wrapper for zip files" marker = "python_version < \"3.8\"" name = "zipp" optional = false -python-versions = ">=2.7" -version = "0.6.0" +python-versions = ">=3.6" +version = "2.0.0" [package.dependencies] more-itertools = "*" +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["pathlib2", "contextlib2", "unittest2"] + [extras] -doc = ["mkdocs", "mkdocs-material"] +docs = ["mkdocs", "mkdocs-material", "mkdocstrings", "markdown-include", "fastapi"] +tests = ["starlette"] [metadata] -content-hash = "799a0ca21de32826a876c56c11469a8bbdf2735c7bea216f644dbc8f70a2e8e9" +content-hash = "4e45bfe6317b1fe96ad973bc21a5fd25034ba0c100cc958e39781a342be10aeb" python-versions = "^3.6" -[metadata.hashes] -appdirs = ["9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", "d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"] -atomicwrites = ["03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", "75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"] -attrs = ["08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"] -autoflake = ["680cb9dade101ed647488238ccb8b8bfb4369b53d58ba2c8cdf7d5d54e01f95b"] -black = ["817243426042db1d36617910df579a54f1afd659adb96fc5032fcf4b36209739", "e030a9a28f542debc08acceb273f228ac422798e5215ba2a791a6ddeaaca22a5"] -cached-property = ["3a026f1a54135677e7da5ce819b0c690f156f37976f3e30c5430740725203d7f", "9217a59f14a5682da7c4b8829deadbfc194ac22e9908ccf7c8820234e80a1504"] -certifi = ["e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", "fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef"] -chardet = ["84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", "fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"] -click = ["2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13", "5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"] -colorama = ["05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", "f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48"] -coverage = ["08907593569fe59baca0bf152c43f3863201efb6113ecb38ce7e97ce339805a6", "0be0f1ed45fc0c185cfd4ecc19a1d6532d72f86a2bac9de7e24541febad72650", "141f08ed3c4b1847015e2cd62ec06d35e67a3ac185c26f7635f4406b90afa9c5", "19e4df788a0581238e9390c85a7a09af39c7b539b29f25c89209e6c3e371270d", "23cc09ed395b03424d1ae30dcc292615c1372bfba7141eb85e11e50efaa6b351", "245388cda02af78276b479f299bbf3783ef0a6a6273037d7c60dc73b8d8d7755", "331cb5115673a20fb131dadd22f5bcaf7677ef758741312bee4937d71a14b2ef", "386e2e4090f0bc5df274e720105c342263423e77ee8826002dcffe0c9533dbca", "3a794ce50daee01c74a494919d5ebdc23d58873747fa0e288318728533a3e1ca", "60851187677b24c6085248f0a0b9b98d49cba7ecc7ec60ba6b9d2e5574ac1ee9", "63a9a5fc43b58735f65ed63d2cf43508f462dc49857da70b8980ad78d41d52fc", "6b62544bb68106e3f00b21c8930e83e584fdca005d4fffd29bb39fb3ffa03cb5", "6ba744056423ef8d450cf627289166da65903885272055fb4b5e113137cfa14f", "7494b0b0274c5072bddbfd5b4a6c6f18fbbe1ab1d22a41e99cd2d00c8f96ecfe", "826f32b9547c8091679ff292a82aca9c7b9650f9fda3e2ca6bf2ac905b7ce888", "93715dffbcd0678057f947f496484e906bf9509f5c1c38fc9ba3922893cda5f5", "9a334d6c83dfeadae576b4d633a71620d40d1c379129d587faa42ee3e2a85cce", "af7ed8a8aa6957aac47b4268631fa1df984643f07ef00acd374e456364b373f5", "bf0a7aed7f5521c7ca67febd57db473af4762b9622254291fbcbb8cd0ba5e33e", "bf1ef9eb901113a9805287e090452c05547578eaab1b62e4ad456fcc049a9b7e", "c0afd27bc0e307a1ffc04ca5ec010a290e49e3afbe841c5cafc5c5a80ecd81c9", "dd579709a87092c6dbee09d1b7cfa81831040705ffa12a1b248935274aee0437", "df6712284b2e44a065097846488f66840445eb987eb81b3cc6e4149e7b6982e1", "e07d9f1a23e9e93ab5c62902833bf3e4b1f65502927379148b6622686223125c", "e2ede7c1d45e65e209d6093b762e98e8318ddeff95317d07a27a2140b80cfd24", "e4ef9c164eb55123c62411f5936b5c2e521b12356037b6e1c2617cef45523d47", "eca2b7343524e7ba246cab8ff00cab47a2d6d54ada3b02772e908a45675722e2", "eee64c616adeff7db37cc37da4180a3a5b6177f5c46b187894e633f088fb5b28", "ef824cad1f980d27f26166f86856efe11eff9912c4fed97d3804820d43fa550c", "efc89291bd5a08855829a3c522df16d856455297cf35ae827a37edac45f466a7", "fa964bae817babece5aa2e8c1af841bebb6d0b9add8e637548809d040443fee0", "ff37757e068ae606659c28c3bd0d923f9d29a85de79bf25b2b34b148473b5025"] -dataclasses = ["454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f", "6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"] -entrypoints = ["589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", "c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"] -eradicate = ["4ffda82aae6fd49dfffa777a857cb758d77502a1f2e0f54c9ac5155a39d2d01a"] -flake8 = ["45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", "49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"] -flake8-alfred = ["3cce4a91fe07b50b5efcb650f5f13901052b64c3850f9cf10cce330efab6b34d", "c68b40165b00d3c9f0da6e64f08e95da919fc358dc8b8596374f54fa63cd6be5"] -flake8-broken-line = ["30378a3749911e453d0a9e03204156cbbd35bcc03fb89f12e6a5206e5baf3537", "7721725dce3aeee1df371a252822f1fcecfaf2766dcf5bac54ee1b3f779ee9d1"] -flake8-bugbear = ["d8c466ea79d5020cb20bf9f11cf349026e09517a42264f313d3f6fddb83e0571", "ded4d282778969b5ab5530ceba7aa1a9f1b86fa7618fc96a19a1d512331640f8"] -flake8-builtins = ["8d806360767947c0035feada4ddef3ede32f0a586ef457e62d811b8456ad9a51", "cd7b1b7fec4905386a3643b59f9ca8e305768da14a49a7efb31fe9364f33cd04"] -flake8-comprehensions = ["1d731dcccbef1f9a68b54d6846cf4e45cd2fea397cb0b71c463aab965118f594", "5ead5962307187a13359a1f108ab7c371c8a63343fb0dfa9fc97c30612a99561"] -flake8-eradicate = ["a42c501d40b2beb6bcbbcb961169b16a7794b179dcc990cb317c2e655c19e379", "dd9baf6428319b946b85964c5ad0fb41d68c8998d40ac4ce73f37b9f41c535be"] -flake8-fixme = ["226a6f2ef916730899f29ac140bed5d4a17e5aba79f00a0e3ae1eff1997cb1ac", "50cade07d27a4c30d4f12351478df87339e67640c83041b664724bda6d16f33a"] -flake8-logging-format = ["ca5f2b7fc31c3474a0aa77d227e022890f641a025f0ba664418797d979a779f8"] -flake8-mutable = ["38fd9dadcbcda6550a916197bc40ed76908119dabb37fbcca30873666c31d2d5", "ee9b77111b867d845177bbc289d87d541445ffcc6029a0c5c65865b42b18c6a6"] -flake8-pep3101 = ["493821d6bdd083794eb0691ebe5b68e5c520b622b269d60e54308fb97440e21a", "b661ab718df42b87743dde266ef5de4f9e900b56c67dbccd45d24cf527545553"] -flake8-print = ["3456804209b0420d443cfd6cfe115dad2b13ec72c9ef1171857b26b7412cc932", "b46a78fd9ef80f85bf421923b6a4ca22f82285f461b8649e196aeca89ed4ff49"] -flake8-string-format = ["68ea72a1a5b75e7018cae44d14f32473c798cf73d75cbaed86c6a9a907b770b2", "774d56103d9242ed968897455ef49b7d6de272000cfa83de5814273a868832f1"] -h11 = ["acca6a44cb52a32ab442b1779adf0875c443c689e9e028f8d831a3769f9c5208", "f2b1ca39bfed357d1f19ac732913d5f9faa54a5062eca7d2ec3a916cfb7ae4c7"] -h2 = ["ac377fcf586314ef3177bfd90c12c7826ab0840edeb03f0f24f511858326049e", "b8a32bd282594424c0ac55845377eea13fa54fe4a8db012f3a198ed923dc3ab4"] -hpack = ["0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89", "8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"] -hstspreload = ["4a2e3f5397d5ba51a21ec5786a35d095f508442d07d050b1172f4df5af9a8fed"] -htmlmin = ["50c1ef4630374a5d723900096a961cff426dff46b48f34d194a81bbe14eca178"] -httpx = ["93df0398c61607020b042b4914f0e9d75d69ccdc172d8c545da7c56b116e49d0", "f542c906e0fc604b9d03e0f498d478636ed51443e970b48e62883640e6d0e89b"] -hyperframe = ["5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40", "a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"] -idna = ["c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", "ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"] -importlib-metadata = ["aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", "d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af"] -isort = ["54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", "6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"] -jinja2 = ["74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f", "9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"] -jsmin = ["b6df99b2cd1c75d9d342e4335b535789b8da9107ec748212706ef7bbe5c2553b"] -livereload = ["78d55f2c268a8823ba499305dcac64e28ddeb9a92571e12d543cd304faf5817b", "89254f78d7529d7ea0a3417d224c34287ebfe266b05e67e51facaf82c27f0f66"] -loguru = ["b6fad0d7aed357b5c147edcc6982606b933754338950b72d8123f48c150c5a4f", "e3138bfdee5f57481a2a6e078714be20f8c71ab1ff3f07f8fb1cfa25191fed2a"] -markdown = ["2e50876bcdd74517e7b71f3e7a76102050edec255b3983403f1a63e7c8a41e7a", "56a46ac655704b91e5b7e6326ce43d5ef72411376588afa1dd90e881b83c7e8c"] -markupsafe = ["00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", "09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", "09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", "1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", "24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", "29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", "43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", "46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", "500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", "535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", "62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", "6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", "717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", "79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", "7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", "88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", "8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", "98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", "9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", "9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", "ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", "b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", "b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", "b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", "ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", "c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", "cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", "e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"] -mccabe = ["ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", "dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"] -mkdocs = ["17d34329aad75d5de604b9ed4e31df3a4d235afefdc46ce7b1964fddb2e1e939", "8cc8b38325456b9e942c981a209eaeb1e9f3f77b493ad755bfef889b9c8d356a"] -mkdocs-material = ["a5246b550299d00a135a3f739e70ac6db73d7127480f0fecbda113d0095a674a", "e4a9ac73db7c65fdae1dbd248091e4b0a3f5db3e6bf87a46bb457db013a045e4"] -mkdocs-minify-plugin = ["3000a5069dd0f42f56a8aaf7fd5ea1222c67487949617e39585d6b6434b074b6", "d54fdd5be6843dd29fd7af2f7fdd20a9eb4db46f1f6bed914e03b2f58d2d488e"] -more-itertools = ["409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", "92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4"] -mypy = ["2afe51527b1f6cdc4a5f34fc90473109b22bf7f21086ba3e9451857cf11489e6", "56a16df3e0abb145d8accd5dbb70eba6c4bd26e2f89042b491faa78c9635d1e2", "5764f10d27b2e93c84f70af5778941b8f4aa1379b2430f85c827e0f5464e8714", "5bbc86374f04a3aa817622f98e40375ccb28c4836f36b66706cf3c6ccce86eda", "6a9343089f6377e71e20ca734cd8e7ac25d36478a9df580efabfe9059819bf82", "6c9851bc4a23dc1d854d3f5dfd5f20a016f8da86bcdbb42687879bb5f86434b0", "b8e85956af3fcf043d6f87c91cbe8705073fc67029ba6e22d3468bfee42c4823", "b9a0af8fae490306bc112229000aa0c2ccc837b49d29a5c42e088c132a2334dd", "bbf643528e2a55df2c1587008d6e3bda5c0445f1240dfa85129af22ae16d7a9a", "c46ab3438bd21511db0f2c612d89d8344154c0c9494afc7fbc932de514cf8d15", "f7a83d6bd805855ef83ec605eb01ab4fa42bcef254b13631e451cbb44914a9b0"] -mypy-extensions = ["090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", "2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"] -packaging = ["28b924174df7a2fa32c1953825ff29c61e2f5e082343165438812f00d3a7fc47", "d9551545c6d761f3def1677baf08ab2a3ca17c56879e70fecba2fc4dde4ed108"] -pep562 = ["58cb1cc9ee63d93e62b4905a50357618d526d289919814bea1f0da8f53b79395", "d2a48b178ebf5f8dd31709cc26a19808ef794561fa2fe50ea01ea2bad4d667ef"] -pluggy = ["0db4b7601aae1d35b4a033282da476845aa19185c1e6964b25cf324b5e4ec3e6", "fa5fa1622fa6dd5c030e9cad086fa19ef6a0cf6d7a2d12318e10cb49d6d68f34"] -py = ["64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", "dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"] -pycodestyle = ["95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", "e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"] -pydantic = ["18598557f0d9ab46173045910ed50458c4fb4d16153c23346b504d7a5b679f77", "6a9335c968e13295430a208487e74d69fef40168b72dea8d975765d14e2da660", "6f5eb88fe4c21380aa064b7d249763fc6306f0b001d7e7d52d80866d1afc9ed3", "bc6c6a78647d7a65a493e1107572d993f26a652c49183201e3c7d23924bf7311", "e1a63b4e6bf8820833cb6fa239ffbe8eec57ccdd7d66359eff20e68a83c1deeb", "ede2d65ae33788d4e26e12b330b4a32c53cb14131c65bca3a59f037c73f6ee7a"] -pyflakes = ["17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", "d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"] -pygments = ["71e430bc85c88a430f000ac1d9b331d2407f681d6f6aec95e8bcfbc3df5b0127", "881c4c157e45f30af185c1ffe8d549d48ac9127433f2c380c24b84572ad66297"] -pymdown-extensions = ["24c1a0afbae101c4e2b2675ff4dd936470a90133f93398b9cbe0c855e2d2ec10", "960486dea995f1759dfd517aa140b3d851cd7b44d4c48d276fd2c74fc4e1bce9"] -pyparsing = ["6f98a7b9397e206d78cc01df10131398f1c8b8510a2f4d97d9abd82e1aacdd80", "d9338df12903bbf5d65a0e4e87c2161968b10d2e489652bb47001d82a9b028b4"] -pytest = ["5d0d20a9a66e39b5845ab14f8989f3463a7aa973700e6cdf02db69da9821e738", "692d9351353ef709c1126266579edd4fd469dcf6b5f4f583050f72161d6f3592"] -pytest-asyncio = ["9fac5100fd716cbecf6ef89233e8590a4ad61d729d1732e0a96b84182df1daaf", "d734718e25cfc32d2bf78d346e99d33724deeba774cc4afdf491530c6184b63b"] -pytest-cov = ["cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b", "cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"] -python-multipart = ["f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"] -pyyaml = ["0113bc0ec2ad727182326b61326afa3d1d8280ae1122493553fd6f4397f33df9", "01adf0b6c6f61bd11af6e10ca52b7d4057dd0be0343eb9283c878cf3af56aee4", "5124373960b0b3f4aa7df1707e63e9f109b5263eca5976c66e08b1c552d4eaf8", "5ca4f10adbddae56d824b2c09668e91219bb178a1eee1faa56af6f99f11bf696", "7907be34ffa3c5a32b60b95f4d95ea25361c951383a894fec31be7252b2b6f34", "7ec9b2a4ed5cad025c2278a1e6a19c011c80a3caaac804fd2d329e9cc2c287c9", "87ae4c829bb25b9fe99cf71fbb2140c448f534e24c998cc60f39ae4f94396a73", "9de9919becc9cc2ff03637872a440195ac4241c80536632fffeb6a1e25a74299", "a5a85b10e450c66b49f98846937e8cfca1db3127a9d5d1e31ca45c3d0bef4c5b", "b0997827b4f6a7c286c01c5f60384d218dca4ed7d9efa945c3e1aa623d5709ae", "b631ef96d3222e62861443cc89d6563ba3eeb816eeb96b2629345ab795e53681", "bf47c0607522fdbca6c9e817a6e81b08491de50f3766a7a0e6a5be7905961b41", "f81025eddd0327c7d4cfe9b62cf33190e1e736cc6e97502b3ec425f574b3e7a8"] -rfc3986 = ["0344d0bd428126ce554e7ca2b61787b6a28d2bbd19fc70ed2dd85efe31176405", "df4eba676077cefb86450c8f60121b9ae04b94f65f85b69f3f731af0516b7b18"] -six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] -starlette = ["e41ef52e711a82ef95c195674e5d8d41c75c6b1d6f5a275637eedd4cc2150a7f"] -toml = ["229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", "235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e", "f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"] -tornado = ["349884248c36801afa19e342a77cc4458caca694b0eda633f5878e458a44cb2c", "398e0d35e086ba38a0427c3b37f4337327231942e731edaa6e9fd1865bbd6f60", "4e73ef678b1a859f0cb29e1d895526a20ea64b5ffd510a2307b5998c7df24281", "559bce3d31484b665259f50cd94c5c28b961b09315ccd838f284687245f416e5", "abbe53a39734ef4aba061fca54e30c6b4639d3e1f59653f0da37a0003de148c7", "c845db36ba616912074c5b1ee897f8e0124df269468f25e4fe21fe72f6edd7a9", "c9399267c926a4e7c418baa5cbe91c7d1cf362d505a1ef898fde44a07c9dd8a5"] -typed-ast = ["132eae51d6ef3ff4a8c47c393a4ef5ebf0d1aecc96880eb5d6c8ceab7017cc9b", "18141c1484ab8784006c839be8b985cfc82a2e9725837b0ecfa0203f71c4e39d", "2baf617f5bbbfe73fd8846463f5aeafc912b5ee247f410700245d68525ec584a", "3d90063f2cbbe39177e9b4d888e45777012652d6110156845b828908c51ae462", "4304b2218b842d610aa1a1d87e1dc9559597969acc62ce717ee4dfeaa44d7eee", "4983ede548ffc3541bae49a82675996497348e55bafd1554dc4e4a5d6eda541a", "5315f4509c1476718a4825f45a203b82d7fdf2a6f5f0c8f166435975b1c9f7d4", "6cdfb1b49d5345f7c2b90d638822d16ba62dc82f7616e9b4caa10b72f3f16649", "7b325f12635598c604690efd7a0197d0b94b7d7778498e76e0710cd582fd1c7a", "8d3b0e3b8626615826f9a626548057c5275a9733512b137984a68ba1598d3d2f", "8f8631160c79f53081bd23446525db0bc4c5616f78d04021e6e434b286493fd7", "912de10965f3dc89da23936f1cc4ed60764f712e5fa603a09dd904f88c996760", "b010c07b975fe853c65d7bbe9d4ac62f1c69086750a574f6292597763781ba18", "c908c10505904c48081a5415a1e295d8403e353e0c14c42b6d67f8f97fae6616", "c94dd3807c0c0610f7c76f078119f4ea48235a953512752b9175f9f98f5ae2bd", "ce65dee7594a84c466e79d7fb7d3303e7295d16a83c22c7c4037071b059e2c21", "eaa9cfcb221a8a4c2889be6f93da141ac777eb8819f077e1d09fb12d00a09a93", "f3376bc31bad66d46d44b4e6522c5c21976bf9bca4ef5987bb2bf727f4506cbb", "f9202fa138544e13a4ec1a6792c35834250a85958fde1251b6a22e07d1260ae7"] -wcwidth = ["3df37372226d6e63e1b1e1eda15c594bca98a22d33a23832a90998faa96bc65e", "f4ebe71925af7b40a864553f761ed559b43544f8f71746c2d756c7fe788ade7c"] -win32-setctime = ["568fd636c68350bcc54755213fe01966fe0a6c90b386c0776425944a0382abef", "b47e5023ec7f0b4962950902b15bc56464a380d869f59d27dbf9ab423b23e8f9"] -zipp = ["3718b1cbcd963c7d4c5511a8240812904164b7f381b647143a89d3b98f9bcd8e", "f06903e9f1f43b12d371004b4ac7b06ab39a44adc747266928ae6debfa7b3335"] +[metadata.files] +aiocontextvars = [ + {file = "aiocontextvars-0.2.2-py2.py3-none-any.whl", hash = "sha256:885daf8261818767d8f7cbd79f9d4482d118f024b6586ef6e67980236a27bfa3"}, + {file = "aiocontextvars-0.2.2.tar.gz", hash = "sha256:f027372dc48641f683c559f247bd84962becaacdc9ba711d583c3871fb5652aa"}, +] +appdirs = [ + {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"}, + {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"}, +] +astor = [ + {file = "astor-0.8.1-py2.py3-none-any.whl", hash = "sha256:070a54e890cefb5b3739d19f30f5a5ec840ffc9c50ffa7d23cc9fc1a38ebbfc5"}, + {file = "astor-0.8.1.tar.gz", hash = "sha256:6a6effda93f4e1ce9f618779b2dd1d9d84f1e32812c23a29b3fff6fd7f63fa5e"}, +] +atomicwrites = [ + {file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"}, + {file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"}, +] +attrs = [ + {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, + {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, +] +autoflake = [ + {file = "autoflake-1.3.1.tar.gz", hash = "sha256:680cb9dade101ed647488238ccb8b8bfb4369b53d58ba2c8cdf7d5d54e01f95b"}, +] +bandit = [ + {file = "bandit-1.6.2-py2.py3-none-any.whl", hash = "sha256:336620e220cf2d3115877685e264477ff9d9abaeb0afe3dc7264f55fa17a3952"}, + {file = "bandit-1.6.2.tar.gz", hash = "sha256:41e75315853507aa145d62a78a2a6c5e3240fe14ee7c601459d0df9418196065"}, +] +black = [ + {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, + {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, +] +certifi = [ + {file = "certifi-2019.11.28-py2.py3-none-any.whl", hash = "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3"}, + {file = "certifi-2019.11.28.tar.gz", hash = "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"}, +] +chardet = [ + {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, + {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, +] +click = [ + {file = "Click-7.0-py2.py3-none-any.whl", hash = "sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13"}, + {file = "Click-7.0.tar.gz", hash = "sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"}, +] +cognitive-complexity = [ + {file = "cognitive_complexity-0.0.4.tar.gz", hash = "sha256:8a456bf2871a40c73f33c937ec0b42bc2daaefafa850b3158b0f7a2a91af2b64"}, +] +colorama = [ + {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, + {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, +] +contextvars = [ + {file = "contextvars-2.4.tar.gz", hash = "sha256:f38c908aaa59c14335eeea12abea5f443646216c4e29380d7bf34d2018e2c39e"}, +] +coverage = [ + {file = "coverage-5.0.3-cp27-cp27m-macosx_10_12_x86_64.whl", hash = "sha256:cc1109f54a14d940b8512ee9f1c3975c181bbb200306c6d8b87d93376538782f"}, + {file = "coverage-5.0.3-cp27-cp27m-macosx_10_13_intel.whl", hash = "sha256:be18f4ae5a9e46edae3f329de2191747966a34a3d93046dbdf897319923923bc"}, + {file = "coverage-5.0.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:3230d1003eec018ad4a472d254991e34241e0bbd513e97a29727c7c2f637bd2a"}, + {file = "coverage-5.0.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:e69215621707119c6baf99bda014a45b999d37602cb7043d943c76a59b05bf52"}, + {file = "coverage-5.0.3-cp27-cp27m-win32.whl", hash = "sha256:1daa3eceed220f9fdb80d5ff950dd95112cd27f70d004c7918ca6dfc6c47054c"}, + {file = "coverage-5.0.3-cp27-cp27m-win_amd64.whl", hash = "sha256:51bc7710b13a2ae0c726f69756cf7ffd4362f4ac36546e243136187cfcc8aa73"}, + {file = "coverage-5.0.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:9bea19ac2f08672636350f203db89382121c9c2ade85d945953ef3c8cf9d2a68"}, + {file = "coverage-5.0.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:5012d3b8d5a500834783689a5d2292fe06ec75dc86ee1ccdad04b6f5bf231691"}, + {file = "coverage-5.0.3-cp35-cp35m-macosx_10_12_x86_64.whl", hash = "sha256:d513cc3db248e566e07a0da99c230aca3556d9b09ed02f420664e2da97eac301"}, + {file = "coverage-5.0.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:3dbb72eaeea5763676a1a1efd9b427a048c97c39ed92e13336e726117d0b72bf"}, + {file = "coverage-5.0.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:15cf13a6896048d6d947bf7d222f36e4809ab926894beb748fc9caa14605d9c3"}, + {file = "coverage-5.0.3-cp35-cp35m-win32.whl", hash = "sha256:fca1669d464f0c9831fd10be2eef6b86f5ebd76c724d1e0706ebdff86bb4adf0"}, + {file = "coverage-5.0.3-cp35-cp35m-win_amd64.whl", hash = "sha256:1e44a022500d944d42f94df76727ba3fc0a5c0b672c358b61067abb88caee7a0"}, + {file = "coverage-5.0.3-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:b26aaf69713e5674efbde4d728fb7124e429c9466aeaf5f4a7e9e699b12c9fe2"}, + {file = "coverage-5.0.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:722e4557c8039aad9592c6a4213db75da08c2cd9945320220634f637251c3894"}, + {file = "coverage-5.0.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:7afad9835e7a651d3551eab18cbc0fdb888f0a6136169fbef0662d9cdc9987cf"}, + {file = "coverage-5.0.3-cp36-cp36m-win32.whl", hash = "sha256:25dbf1110d70bab68a74b4b9d74f30e99b177cde3388e07cc7272f2168bd1477"}, + {file = "coverage-5.0.3-cp36-cp36m-win_amd64.whl", hash = "sha256:c312e57847db2526bc92b9bfa78266bfbaabac3fdcd751df4d062cd4c23e46dc"}, + {file = "coverage-5.0.3-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:a8b8ac7876bc3598e43e2603f772d2353d9931709345ad6c1149009fd1bc81b8"}, + {file = "coverage-5.0.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:527b4f316e6bf7755082a783726da20671a0cc388b786a64417780b90565b987"}, + {file = "coverage-5.0.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d649dc0bcace6fcdb446ae02b98798a856593b19b637c1b9af8edadf2b150bea"}, + {file = "coverage-5.0.3-cp37-cp37m-win32.whl", hash = "sha256:cd60f507c125ac0ad83f05803063bed27e50fa903b9c2cfee3f8a6867ca600fc"}, + {file = "coverage-5.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:c60097190fe9dc2b329a0eb03393e2e0829156a589bd732e70794c0dd804258e"}, + {file = "coverage-5.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:d7008a6796095a79544f4da1ee49418901961c97ca9e9d44904205ff7d6aa8cb"}, + {file = "coverage-5.0.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ea9525e0fef2de9208250d6c5aeeee0138921057cd67fcef90fbed49c4d62d37"}, + {file = "coverage-5.0.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:c62a2143e1313944bf4a5ab34fd3b4be15367a02e9478b0ce800cb510e3bbb9d"}, + {file = "coverage-5.0.3-cp38-cp38m-win32.whl", hash = "sha256:b0840b45187699affd4c6588286d429cd79a99d509fe3de0f209594669bb0954"}, + {file = "coverage-5.0.3-cp38-cp38m-win_amd64.whl", hash = "sha256:76e2057e8ffba5472fd28a3a010431fd9e928885ff480cb278877c6e9943cc2e"}, + {file = "coverage-5.0.3-cp39-cp39m-win32.whl", hash = "sha256:b63dd43f455ba878e5e9f80ba4f748c0a2156dde6e0e6e690310e24d6e8caf40"}, + {file = "coverage-5.0.3-cp39-cp39m-win_amd64.whl", hash = "sha256:da93027835164b8223e8e5af2cf902a4c80ed93cb0909417234f4a9df3bcd9af"}, + {file = "coverage-5.0.3.tar.gz", hash = "sha256:77afca04240c40450c331fa796b3eab6f1e15c5ecf8bf2b8bee9706cd5452fef"}, +] +darglint = [ + {file = "darglint-1.1.0-py3-none-any.whl", hash = "sha256:2fe546c4b5d60f523dae4e9f975542c5b1b42838b22e8be64254684e361b0a5f"}, + {file = "darglint-1.1.0.tar.gz", hash = "sha256:8511f7e403e4512e6901d067b2a82b66c09f9497651b728023eeb4564bf8c0c1"}, +] +dataclasses = [ + {file = "dataclasses-0.6-py3-none-any.whl", hash = "sha256:454a69d788c7fda44efd71e259be79577822f5e3f53f029a22d08004e951dc9f"}, + {file = "dataclasses-0.6.tar.gz", hash = "sha256:6988bd2b895eef432d562370bb707d540f32f7360ab13da45340101bc2307d84"}, +] +docutils = [ + {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, + {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, +] +entrypoints = [ + {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, + {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, +] +eradicate = [ + {file = "eradicate-1.0.tar.gz", hash = "sha256:4ffda82aae6fd49dfffa777a857cb758d77502a1f2e0f54c9ac5155a39d2d01a"}, +] +fastapi = [ + {file = "fastapi-0.47.1-py3-none-any.whl", hash = "sha256:3130313f23935d99150953422dfe5f6b43f043b6fe3aac22cc4c8d537a4464d9"}, + {file = "fastapi-0.47.1.tar.gz", hash = "sha256:be62491f536dc50041913a37bdcd6b5e05c84e756ff331506b5afeddec859013"}, +] +flake8 = [ + {file = "flake8-3.7.9-py2.py3-none-any.whl", hash = "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"}, + {file = "flake8-3.7.9.tar.gz", hash = "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb"}, +] +flake8-annotations-complexity = [ + {file = "flake8_annotations_complexity-0.0.2.tar.gz", hash = "sha256:e499d2186efcc5f6f2f1c7eb18568d08f4d9313fad15f614645f3f445f70b45c"}, +] +flake8-bandit = [ + {file = "flake8_bandit-2.1.2.tar.gz", hash = "sha256:687fc8da2e4a239b206af2e54a90093572a60d0954f3054e23690739b0b0de3b"}, +] +flake8-broken-line = [ + {file = "flake8-broken-line-0.1.1.tar.gz", hash = "sha256:30378a3749911e453d0a9e03204156cbbd35bcc03fb89f12e6a5206e5baf3537"}, + {file = "flake8_broken_line-0.1.1-py3-none-any.whl", hash = "sha256:7721725dce3aeee1df371a252822f1fcecfaf2766dcf5bac54ee1b3f779ee9d1"}, +] +flake8-bugbear = [ + {file = "flake8-bugbear-19.8.0.tar.gz", hash = "sha256:d8c466ea79d5020cb20bf9f11cf349026e09517a42264f313d3f6fddb83e0571"}, + {file = "flake8_bugbear-19.8.0-py35.py36.py37-none-any.whl", hash = "sha256:ded4d282778969b5ab5530ceba7aa1a9f1b86fa7618fc96a19a1d512331640f8"}, +] +flake8-builtins = [ + {file = "flake8-builtins-1.4.2.tar.gz", hash = "sha256:c44415fb19162ef3737056e700d5b99d48c3612a533943b4e16419a5d3de3a64"}, + {file = "flake8_builtins-1.4.2-py2.py3-none-any.whl", hash = "sha256:29bc0f7e68af481d088f5c96f8aeb02520abdfc900500484e3af969f42a38a5f"}, +] +flake8-coding = [ + {file = "flake8-coding-1.3.2.tar.gz", hash = "sha256:b8f4d5157a8f74670e6cfea732c3d9f4291a4e994c8701d2c55f787c6e6cb741"}, + {file = "flake8_coding-1.3.2-py2.py3-none-any.whl", hash = "sha256:79704112c44d09d4ab6c8965e76a20c3f7073d52146db60303bce777d9612260"}, +] +flake8-commas = [ + {file = "flake8-commas-2.0.0.tar.gz", hash = "sha256:d3005899466f51380387df7151fb59afec666a0f4f4a2c6a8995b975de0f44b7"}, + {file = "flake8_commas-2.0.0-py2.py3-none-any.whl", hash = "sha256:ee2141a3495ef9789a3894ed8802d03eff1eaaf98ce6d8653a7c573ef101935e"}, +] +flake8-comprehensions = [ + {file = "flake8-comprehensions-3.2.1.tar.gz", hash = "sha256:f8ae0b3ccc01aaee8739871b94744910347cc72831d88c217f8b35ef1f07cef5"}, + {file = "flake8_comprehensions-3.2.1-py3-none-any.whl", hash = "sha256:b75db60680becfbb82797ec516ab3dd408019267fe9343864cd7f9b26a6c31c7"}, +] +flake8-debugger = [ + {file = "flake8-debugger-3.2.1.tar.gz", hash = "sha256:712d7c1ff69ddf3f0130e94cc88c2519e720760bce45e8c330bfdcb61ab4090d"}, +] +flake8-docstrings = [ + {file = "flake8-docstrings-1.5.0.tar.gz", hash = "sha256:3d5a31c7ec6b7367ea6506a87ec293b94a0a46c0bce2bb4975b7f1d09b6f3717"}, + {file = "flake8_docstrings-1.5.0-py2.py3-none-any.whl", hash = "sha256:a256ba91bc52307bef1de59e2a009c3cf61c3d0952dbe035d6ff7208940c2edc"}, +] +flake8-eradicate = [ + {file = "flake8-eradicate-0.2.4.tar.gz", hash = "sha256:b693e9dfe6da42dbc7fb75af8486495b9414d1ab0372d15efcf85a2ac85fd368"}, + {file = "flake8_eradicate-0.2.4-py3-none-any.whl", hash = "sha256:b0bcdbb70a489fb799f9ee11fefc57bd0d3251e1ea9bdc5bf454443cccfd620c"}, +] +flake8-executable = [ + {file = "flake8-executable-2.0.3.tar.gz", hash = "sha256:a636ff78b14b63b1245d1c4d509db2f6ea0f2e27a86ee7eb848f3827bef7e16d"}, + {file = "flake8_executable-2.0.3-py3-none-any.whl", hash = "sha256:968618c475a23a538ced9b957a741b818d37610838f99f6abcea249e4de7c9ec"}, +] +flake8-fixme = [ + {file = "flake8-fixme-1.1.1.tar.gz", hash = "sha256:50cade07d27a4c30d4f12351478df87339e67640c83041b664724bda6d16f33a"}, + {file = "flake8_fixme-1.1.1-py2.py3-none-any.whl", hash = "sha256:226a6f2ef916730899f29ac140bed5d4a17e5aba79f00a0e3ae1eff1997cb1ac"}, +] +flake8-isort = [ + {file = "flake8-isort-2.8.0.tar.gz", hash = "sha256:64454d1f154a303cfe23ee715aca37271d4f1d299b2f2663f45b73bff14e36a9"}, + {file = "flake8_isort-2.8.0-py2.py3-none-any.whl", hash = "sha256:aa0c4d004e6be47e74f122f5b7f36554d0d78ad8bf99b497a460dedccaa7cce9"}, +] +flake8-logging-format = [ + {file = "flake8-logging-format-0.6.0.tar.gz", hash = "sha256:ca5f2b7fc31c3474a0aa77d227e022890f641a025f0ba664418797d979a779f8"}, +] +flake8-pep3101 = [ + {file = "flake8-pep3101-1.3.0.tar.gz", hash = "sha256:86e3eb4e42de8326dcd98ebdeaf9a3c6854203a48f34aeb3e7e8ed948107f512"}, + {file = "flake8_pep3101-1.3.0-py2.py3-none-any.whl", hash = "sha256:a5dae1caca1243b2b40108dce926d97cf5a9f52515c4a4cbb1ffe1ca0c54e343"}, +] +flake8-polyfill = [ + {file = "flake8-polyfill-1.0.2.tar.gz", hash = "sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"}, + {file = "flake8_polyfill-1.0.2-py2.py3-none-any.whl", hash = "sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9"}, +] +flake8-print = [ + {file = "flake8-print-3.1.4.tar.gz", hash = "sha256:324f9e59a522518daa2461bacd7f82da3c34eb26a4314c2a54bd493f8b394a68"}, +] +flake8-quotes = [ + {file = "flake8-quotes-2.1.1.tar.gz", hash = "sha256:11a15d30c92ca5f04c2791bd7019cf62b6f9d3053eb050d02a135557eb118bfc"}, +] +flake8-rst-docstrings = [ + {file = "flake8-rst-docstrings-0.0.12.tar.gz", hash = "sha256:01d38327801781b26c3dfeb71ae37e5a02c5ca1b774a686f63feab8824ca6f9c"}, +] +flake8-string-format = [ + {file = "flake8-string-format-0.2.3.tar.gz", hash = "sha256:774d56103d9242ed968897455ef49b7d6de272000cfa83de5814273a868832f1"}, + {file = "flake8_string_format-0.2.3-py2.py3-none-any.whl", hash = "sha256:68ea72a1a5b75e7018cae44d14f32473c798cf73d75cbaed86c6a9a907b770b2"}, +] +future = [ + {file = "future-0.18.2.tar.gz", hash = "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d"}, +] +gitdb2 = [ + {file = "gitdb2-2.0.6-py2.py3-none-any.whl", hash = "sha256:96bbb507d765a7f51eb802554a9cfe194a174582f772e0d89f4e87288c288b7b"}, + {file = "gitdb2-2.0.6.tar.gz", hash = "sha256:1b6df1433567a51a4a9c1a5a0de977aa351a405cc56d7d35f3388bad1f630350"}, +] +gitpython = [ + {file = "GitPython-3.0.5-py3-none-any.whl", hash = "sha256:c155c6a2653593ccb300462f6ef533583a913e17857cfef8fc617c246b6dc245"}, + {file = "GitPython-3.0.5.tar.gz", hash = "sha256:9c2398ffc3dcb3c40b27324b316f08a4f93ad646d5a6328cafbb871aa79f5e42"}, +] +h11 = [ + {file = "h11-0.9.0-py2.py3-none-any.whl", hash = "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"}, + {file = "h11-0.9.0.tar.gz", hash = "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1"}, +] +h2 = [ + {file = "h2-3.1.1-py2.py3-none-any.whl", hash = "sha256:ac377fcf586314ef3177bfd90c12c7826ab0840edeb03f0f24f511858326049e"}, + {file = "h2-3.1.1.tar.gz", hash = "sha256:b8a32bd282594424c0ac55845377eea13fa54fe4a8db012f3a198ed923dc3ab4"}, +] +hpack = [ + {file = "hpack-3.0.0-py2.py3-none-any.whl", hash = "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89"}, + {file = "hpack-3.0.0.tar.gz", hash = "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"}, +] +hstspreload = [ + {file = "hstspreload-2020.1.17.tar.gz", hash = "sha256:1cde56803877ad3aa0a4aa91d0c9c52203f4b7ceb93280c87ad399d3fd79679c"}, +] +htmlmin = [ + {file = "htmlmin-0.1.12.tar.gz", hash = "sha256:50c1ef4630374a5d723900096a961cff426dff46b48f34d194a81bbe14eca178"}, +] +httpx = [ + {file = "httpx-0.11.1-py2.py3-none-any.whl", hash = "sha256:1d3893d3e4244c569764a6bae5c5a9fbbc4a6ec3825450b5696602af7a275576"}, + {file = "httpx-0.11.1.tar.gz", hash = "sha256:7d2bfb726eeed717953d15dddb22da9c2fcf48a4d70ba1456aa0a7faeda33cf7"}, +] +hyperframe = [ + {file = "hyperframe-5.2.0-py2.py3-none-any.whl", hash = "sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40"}, + {file = "hyperframe-5.2.0.tar.gz", hash = "sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"}, +] +idna = [ + {file = "idna-2.8-py2.py3-none-any.whl", hash = "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c"}, + {file = "idna-2.8.tar.gz", hash = "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407"}, +] +immutables = [ + {file = "immutables-0.11-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:bce27277a2fe91509cca69181971ab509c2ee862e8b37b09f26b64f90e8fe8fb"}, + {file = "immutables-0.11-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c7eb2d15c35c73bb168c002c6ea145b65f40131e10dede54b39db0b72849b280"}, + {file = "immutables-0.11-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:2de2ec8dde1ca154f811776a8cbbeaea515c3b226c26036eab6484530eea28e0"}, + {file = "immutables-0.11-cp35-cp35m-win32.whl", hash = "sha256:e87bd941cb4dfa35f16e1ff4b2d99a2931452dcc9cfd788dc8fe513f3d38551e"}, + {file = "immutables-0.11-cp35-cp35m-win_amd64.whl", hash = "sha256:0aa055c745510238cbad2f1f709a37a1c9e30a38594de3b385e9876c48a25633"}, + {file = "immutables-0.11-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:422c7d4c75c88057c625e32992248329507bca180b48cfb702b4ef608f581b50"}, + {file = "immutables-0.11-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:f5b93248552c9e7198558776da21c9157d3f70649905d7fdc083c2ab2fbc6088"}, + {file = "immutables-0.11-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:b268422a5802fbf934152b835329ac0d23b80b558eaee68034d45718edab4a11"}, + {file = "immutables-0.11-cp36-cp36m-win32.whl", hash = "sha256:0f07c58122e1ce70a7165e68e18e795ac5fe94d7fee3e045ffcf6432602026df"}, + {file = "immutables-0.11-cp36-cp36m-win_amd64.whl", hash = "sha256:b8fed714f1c84a3242c7184838f5e9889139a22bbdd701a182b7fdc237ca3cbb"}, + {file = "immutables-0.11-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:518f20945c1f600b618fb691922c2ab43b193f04dd2d4d2823220d0202014670"}, + {file = "immutables-0.11-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2c536ff2bafeeff9a7865ea10a17a50f90b80b585e31396c349e8f57b0075bd4"}, + {file = "immutables-0.11-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1c2e729aab250be0de0c13fa833241a778b51390ee2650e0457d1e45b318c441"}, + {file = "immutables-0.11-cp37-cp37m-win32.whl", hash = "sha256:545186faab9237c102b8bcffd36d71f0b382174c93c501e061de239753cff694"}, + {file = "immutables-0.11-cp37-cp37m-win_amd64.whl", hash = "sha256:6b6d8d035e5888baad3db61dfb167476838a63afccecd927c365f228bb55754c"}, + {file = "immutables-0.11.tar.gz", hash = "sha256:d6850578a0dc6530ac19113cfe4ddc13903df635212d498f176fe601a8a5a4a3"}, +] +importlib-metadata = [ + {file = "importlib_metadata-1.4.0-py2.py3-none-any.whl", hash = "sha256:bdd9b7c397c273bcc9a11d6629a38487cd07154fa255a467bf704cd2c258e359"}, + {file = "importlib_metadata-1.4.0.tar.gz", hash = "sha256:f17c015735e1a88296994c0697ecea7e11db24290941983b08c9feb30921e6d8"}, +] +isort = [ + {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, + {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, +] +jinja2 = [ + {file = "Jinja2-2.10.3-py2.py3-none-any.whl", hash = "sha256:74320bb91f31270f9551d46522e33af46a80c3d619f4a4bf42b3164d30b5911f"}, + {file = "Jinja2-2.10.3.tar.gz", hash = "sha256:9fe95f19286cfefaa917656583d020be14e7859c6b0252588391e47db34527de"}, +] +jsmin = [ + {file = "jsmin-2.2.2.tar.gz", hash = "sha256:b6df99b2cd1c75d9d342e4335b535789b8da9107ec748212706ef7bbe5c2553b"}, +] +livereload = [ + {file = "livereload-2.6.1-py2.py3-none-any.whl", hash = "sha256:78d55f2c268a8823ba499305dcac64e28ddeb9a92571e12d543cd304faf5817b"}, + {file = "livereload-2.6.1.tar.gz", hash = "sha256:89254f78d7529d7ea0a3417d224c34287ebfe266b05e67e51facaf82c27f0f66"}, +] +loguru = [ + {file = "loguru-0.4.1-py3-none-any.whl", hash = "sha256:074b3caa6748452c1e4f2b302093c94b65d5a4c5a4d7743636b4121e06437b0e"}, + {file = "loguru-0.4.1.tar.gz", hash = "sha256:a6101fd435ac89ba5205a105a26a6ede9e4ddbb4408a6e167852efca47806d11"}, +] +mando = [ + {file = "mando-0.6.4-py2.py3-none-any.whl", hash = "sha256:4ce09faec7e5192ffc3c57830e26acba0fd6cd11e1ee81af0d4df0657463bd1c"}, + {file = "mando-0.6.4.tar.gz", hash = "sha256:79feb19dc0f097daa64a1243db578e7674909b75f88ac2220f1c065c10a0d960"}, +] +markdown = [ + {file = "Markdown-3.1.1-py2.py3-none-any.whl", hash = "sha256:56a46ac655704b91e5b7e6326ce43d5ef72411376588afa1dd90e881b83c7e8c"}, + {file = "Markdown-3.1.1.tar.gz", hash = "sha256:2e50876bcdd74517e7b71f3e7a76102050edec255b3983403f1a63e7c8a41e7a"}, +] +markdown-include = [ + {file = "markdown-include-0.5.1.tar.gz", hash = "sha256:72a45461b589489a088753893bc95c5fa5909936186485f4ed55caa57d10250f"}, +] +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-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-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-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-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, + {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +mkdocs = [ + {file = "mkdocs-1.0.4-py2.py3-none-any.whl", hash = "sha256:8cc8b38325456b9e942c981a209eaeb1e9f3f77b493ad755bfef889b9c8d356a"}, + {file = "mkdocs-1.0.4.tar.gz", hash = "sha256:17d34329aad75d5de604b9ed4e31df3a4d235afefdc46ce7b1964fddb2e1e939"}, +] +mkdocs-material = [ + {file = "mkdocs-material-4.6.0.tar.gz", hash = "sha256:b21aa2645ccb11442ea381c92d187bbc94127f50702c0d28c3fc0152fa7b29da"}, + {file = "mkdocs_material-4.6.0-py2.py3-none-any.whl", hash = "sha256:89a8e2527ca8426c40f2213ce53513f73f54d0a32b36aef33fde6849d294e9ec"}, +] +mkdocs-minify-plugin = [ + {file = "mkdocs-minify-plugin-0.2.1.tar.gz", hash = "sha256:3000a5069dd0f42f56a8aaf7fd5ea1222c67487949617e39585d6b6434b074b6"}, + {file = "mkdocs_minify_plugin-0.2.1-py2-none-any.whl", hash = "sha256:d54fdd5be6843dd29fd7af2f7fdd20a9eb4db46f1f6bed914e03b2f58d2d488e"}, +] +mkdocstrings = [ + {file = "mkdocstrings-0.7.0-py3-none-any.whl", hash = "sha256:60e6d2620341e625e31e466af173acdde00bda6dac2d8f51e8717bbcf7329e2d"}, + {file = "mkdocstrings-0.7.0.tar.gz", hash = "sha256:76c255c59514d8f447e98d7bd8ee464d2a2bc5add3a638a8dda79efe8c942038"}, +] +more-itertools = [ + {file = "more-itertools-8.1.0.tar.gz", hash = "sha256:c468adec578380b6281a114cb8a5db34eb1116277da92d7c46f904f0b52d3288"}, + {file = "more_itertools-8.1.0-py3-none-any.whl", hash = "sha256:1a2a32c72400d365000412fe08eb4a24ebee89997c18d3d147544f70f5403b39"}, +] +mypy = [ + {file = "mypy-0.740-cp35-cp35m-macosx_10_6_x86_64.whl", hash = "sha256:9371290aa2cad5ad133e4cdc43892778efd13293406f7340b9ffe99d5ec7c1d9"}, + {file = "mypy-0.740-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b428f883d2b3fe1d052c630642cc6afddd07d5cd7873da948644508be3b9d4a7"}, + {file = "mypy-0.740-cp35-cp35m-win_amd64.whl", hash = "sha256:ace6ac1d0f87d4072f05b5468a084a45b4eda970e4d26704f201e06d47ab2990"}, + {file = "mypy-0.740-cp36-cp36m-macosx_10_6_x86_64.whl", hash = "sha256:d7574e283f83c08501607586b3167728c58e8442947e027d2d4c7dcd6d82f453"}, + {file = "mypy-0.740-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d5bf0e6ec8ba346a2cf35cb55bf4adfddbc6b6576fcc9e10863daa523e418dbb"}, + {file = "mypy-0.740-cp36-cp36m-win_amd64.whl", hash = "sha256:1521c186a3d200c399bd5573c828ea2db1362af7209b2adb1bb8532cea2fb36f"}, + {file = "mypy-0.740-cp37-cp37m-macosx_10_6_x86_64.whl", hash = "sha256:dc889c84241a857c263a2b1cd1121507db7d5b5f5e87e77147097230f374d10b"}, + {file = "mypy-0.740-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6ed3b9b3fdc7193ea7aca6f3c20549b377a56f28769783a8f27191903a54170f"}, + {file = "mypy-0.740-cp37-cp37m-win_amd64.whl", hash = "sha256:31a046ab040a84a0fc38bc93694876398e62bc9f35eca8ccbf6418b7297f4c00"}, + {file = "mypy-0.740-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:672e418425d957e276c291930a3921b4a6413204f53fe7c37cad7bc57b9a3391"}, + {file = "mypy-0.740-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3b1a411909c84b2ae9b8283b58b48541654b918e8513c20a400bb946aa9111ae"}, + {file = "mypy-0.740-cp38-cp38-win_amd64.whl", hash = "sha256:540c9caa57a22d0d5d3c69047cc9dd0094d49782603eb03069821b41f9e970e9"}, + {file = "mypy-0.740-py3-none-any.whl", hash = "sha256:f4748697b349f373002656bf32fede706a0e713d67bfdcf04edf39b1f61d46eb"}, + {file = "mypy-0.740.tar.gz", hash = "sha256:48c8bc99380575deb39f5d3400ebb6a8a1cb5cc669bbba4d3bb30f904e0a0e7d"}, +] +mypy-extensions = [ + {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, + {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, +] +packaging = [ + {file = "packaging-20.0-py2.py3-none-any.whl", hash = "sha256:aec3fdbb8bc9e4bb65f0634b9f551ced63983a529d6a8931817d52fdd0816ddb"}, + {file = "packaging-20.0.tar.gz", hash = "sha256:fe1d8331dfa7cc0a883b49d75fc76380b2ab2734b220fbb87d774e4fd4b851f8"}, +] +pathspec = [ + {file = "pathspec-0.7.0-py2.py3-none-any.whl", hash = "sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424"}, + {file = "pathspec-0.7.0.tar.gz", hash = "sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"}, +] +pbr = [ + {file = "pbr-5.4.4-py2.py3-none-any.whl", hash = "sha256:61aa52a0f18b71c5cc58232d2cf8f8d09cd67fcad60b742a60124cb8d6951488"}, + {file = "pbr-5.4.4.tar.gz", hash = "sha256:139d2625547dbfa5fb0b81daebb39601c478c21956dc57e2e07b74450a8c506b"}, +] +pep562 = [ + {file = "pep562-1.0-py2.py3-none-any.whl", hash = "sha256:d2a48b178ebf5f8dd31709cc26a19808ef794561fa2fe50ea01ea2bad4d667ef"}, + {file = "pep562-1.0.tar.gz", hash = "sha256:58cb1cc9ee63d93e62b4905a50357618d526d289919814bea1f0da8f53b79395"}, +] +pep8-naming = [ + {file = "pep8-naming-0.9.1.tar.gz", hash = "sha256:a33d38177056321a167decd6ba70b890856ba5025f0a8eca6a3eda607da93caf"}, + {file = "pep8_naming-0.9.1-py2.py3-none-any.whl", hash = "sha256:45f330db8fcfb0fba57458c77385e288e7a3be1d01e8ea4268263ef677ceea5f"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +py = [ + {file = "py-1.8.1-py2.py3-none-any.whl", hash = "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"}, + {file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"}, +] +pycodestyle = [ + {file = "pycodestyle-2.5.0-py2.py3-none-any.whl", hash = "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56"}, + {file = "pycodestyle-2.5.0.tar.gz", hash = "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"}, +] +pydantic = [ + {file = "pydantic-1.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:dd9359db7644317898816f6142f378aa48848dcc5cf14a481236235fde11a148"}, + {file = "pydantic-1.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:cbe284bd5ad67333d49ecc0dc27fa52c25b4c2fe72802a5c060b5f922db58bef"}, + {file = "pydantic-1.3-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:2b32a5f14558c36e39aeefda0c550bfc0f47fc32b4ce16d80dc4df2b33838ed8"}, + {file = "pydantic-1.3-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:59235324dd7dc5363a654cd14271ea8631f1a43de5d4fc29c782318fcc498002"}, + {file = "pydantic-1.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:176885123dfdd8f7ab6e7ba1b66d4197de75ba830bb44d921af88b3d977b8aa5"}, + {file = "pydantic-1.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:d4bb6a75abc2f04f6993124f1ed4221724c9dc3bd9df5cb54132e0b68775d375"}, + {file = "pydantic-1.3-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:8a8e089aec18c26561e09ee6daf15a3cc06df05bdc67de60a8684535ef54562f"}, + {file = "pydantic-1.3-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:479ca8dc7cc41418751bf10302ee0a1b1f8eedb2de6c4f4c0f3cf8372b204f9a"}, + {file = "pydantic-1.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:87673d1de790c8d5282153cab0b09271be77c49aabcedf3ac5ab1a1fd4dcbac0"}, + {file = "pydantic-1.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:dacb79144bb3fdb57cf9435e1bd16c35586bc44256215cfaa33bf21565d926ae"}, + {file = "pydantic-1.3-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:c0da48978382c83f9488c6bbe4350e065ea5c83e85ca5cfb8fa14ac11de3c296"}, + {file = "pydantic-1.3-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:b60f2b3b0e0dd74f1800a57d1bbd597839d16faf267e45fa4a5407b15d311085"}, + {file = "pydantic-1.3-py36.py37.py38-none-any.whl", hash = "sha256:d03df07b7611004140b0fef91548878c2b5f48c520a8cb76d11d20e9887a495e"}, + {file = "pydantic-1.3.tar.gz", hash = "sha256:2eab7d548b0e530bf65bee7855ad8164c2f6a889975d5e9c4eefd1e7c98245dc"}, +] +pydocstyle = [ + {file = "pydocstyle-5.0.2-py3-none-any.whl", hash = "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586"}, + {file = "pydocstyle-5.0.2.tar.gz", hash = "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5"}, +] +pyflakes = [ + {file = "pyflakes-2.1.1-py2.py3-none-any.whl", hash = "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0"}, + {file = "pyflakes-2.1.1.tar.gz", hash = "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"}, +] +pygments = [ + {file = "Pygments-2.5.2-py2.py3-none-any.whl", hash = "sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b"}, + {file = "Pygments-2.5.2.tar.gz", hash = "sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"}, +] +pymdown-extensions = [ + {file = "pymdown-extensions-6.2.1.tar.gz", hash = "sha256:3bbe6048275f8a0d13a0fe44e0ea201e67268aa7bb40c2544eef16abbf168f7b"}, + {file = "pymdown_extensions-6.2.1-py2.py3-none-any.whl", hash = "sha256:dce5e17b93be0572322b7d06c9a13c13a9d98694d6468277911d50ca87d26f29"}, +] +pyparsing = [ + {file = "pyparsing-2.4.6-py2.py3-none-any.whl", hash = "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"}, + {file = "pyparsing-2.4.6.tar.gz", hash = "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f"}, +] +pytest = [ + {file = "pytest-5.3.3-py3-none-any.whl", hash = "sha256:9f8d44f4722b3d06b41afaeb8d177cfbe0700f8351b1fc755dd27eedaa3eb9e0"}, + {file = "pytest-5.3.3.tar.gz", hash = "sha256:f5d3d0e07333119fe7d4af4ce122362dc4053cdd34a71d2766290cf5369c64ad"}, +] +pytest-asyncio = [ + {file = "pytest-asyncio-0.10.0.tar.gz", hash = "sha256:9fac5100fd716cbecf6ef89233e8590a4ad61d729d1732e0a96b84182df1daaf"}, + {file = "pytest_asyncio-0.10.0-py3-none-any.whl", hash = "sha256:d734718e25cfc32d2bf78d346e99d33724deeba774cc4afdf491530c6184b63b"}, +] +pytest-cov = [ + {file = "pytest-cov-2.8.1.tar.gz", hash = "sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b"}, + {file = "pytest_cov-2.8.1-py2.py3-none-any.whl", hash = "sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"}, +] +pyyaml = [ + {file = "PyYAML-5.3-cp27-cp27m-win32.whl", hash = "sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d"}, + {file = "PyYAML-5.3-cp27-cp27m-win_amd64.whl", hash = "sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6"}, + {file = "PyYAML-5.3-cp35-cp35m-win32.whl", hash = "sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e"}, + {file = "PyYAML-5.3-cp35-cp35m-win_amd64.whl", hash = "sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689"}, + {file = "PyYAML-5.3-cp36-cp36m-win32.whl", hash = "sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994"}, + {file = "PyYAML-5.3-cp36-cp36m-win_amd64.whl", hash = "sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e"}, + {file = "PyYAML-5.3-cp37-cp37m-win32.whl", hash = "sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5"}, + {file = "PyYAML-5.3-cp37-cp37m-win_amd64.whl", hash = "sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf"}, + {file = "PyYAML-5.3-cp38-cp38-win32.whl", hash = "sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811"}, + {file = "PyYAML-5.3-cp38-cp38-win_amd64.whl", hash = "sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20"}, + {file = "PyYAML-5.3.tar.gz", hash = "sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615"}, +] +radon = [ + {file = "radon-4.0.0-py2.py3-none-any.whl", hash = "sha256:32ac2f86bfacbddade5c79f0e927e97f90a5cda5b86f880511dd849c4a0096e3"}, + {file = "radon-4.0.0.tar.gz", hash = "sha256:20f799949e42e6899bc9304539de222d3bdaeec276f38fbd4034859ccd548b46"}, +] +regex = [ + {file = "regex-2020.1.8-cp27-cp27m-win32.whl", hash = "sha256:4e8f02d3d72ca94efc8396f8036c0d3bcc812aefc28ec70f35bb888c74a25161"}, + {file = "regex-2020.1.8-cp27-cp27m-win_amd64.whl", hash = "sha256:e6c02171d62ed6972ca8631f6f34fa3281d51db8b326ee397b9c83093a6b7242"}, + {file = "regex-2020.1.8-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:4eae742636aec40cf7ab98171ab9400393360b97e8f9da67b1867a9ee0889b26"}, + {file = "regex-2020.1.8-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bd25bb7980917e4e70ccccd7e3b5740614f1c408a642c245019cff9d7d1b6149"}, + {file = "regex-2020.1.8-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:3e77409b678b21a056415da3a56abfd7c3ad03da71f3051bbcdb68cf44d3c34d"}, + {file = "regex-2020.1.8-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:07b39bf943d3d2fe63d46281d8504f8df0ff3fe4c57e13d1656737950e53e525"}, + {file = "regex-2020.1.8-cp36-cp36m-win32.whl", hash = "sha256:23e2c2c0ff50f44877f64780b815b8fd2e003cda9ce817a7fd00dea5600c84a0"}, + {file = "regex-2020.1.8-cp36-cp36m-win_amd64.whl", hash = "sha256:27429b8d74ba683484a06b260b7bb00f312e7c757792628ea251afdbf1434003"}, + {file = "regex-2020.1.8-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0e182d2f097ea8549a249040922fa2b92ae28be4be4895933e369a525ba36576"}, + {file = "regex-2020.1.8-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e3cd21cc2840ca67de0bbe4071f79f031c81418deb544ceda93ad75ca1ee9f7b"}, + {file = "regex-2020.1.8-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:ecc6de77df3ef68fee966bb8cb4e067e84d4d1f397d0ef6fce46913663540d77"}, + {file = "regex-2020.1.8-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:26ff99c980f53b3191d8931b199b29d6787c059f2e029b2b0c694343b1708c35"}, + {file = "regex-2020.1.8-cp37-cp37m-win32.whl", hash = "sha256:7bcd322935377abcc79bfe5b63c44abd0b29387f267791d566bbb566edfdd146"}, + {file = "regex-2020.1.8-cp37-cp37m-win_amd64.whl", hash = "sha256:10671601ee06cf4dc1bc0b4805309040bb34c9af423c12c379c83d7895622bb5"}, + {file = "regex-2020.1.8-cp38-cp38-manylinux1_i686.whl", hash = "sha256:98b8ed7bb2155e2cbb8b76f627b2fd12cf4b22ab6e14873e8641f266e0fb6d8f"}, + {file = "regex-2020.1.8-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6a6ba91b94427cd49cd27764679024b14a96874e0dc638ae6bdd4b1a3ce97be1"}, + {file = "regex-2020.1.8-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:6a6ae17bf8f2d82d1e8858a47757ce389b880083c4ff2498dba17c56e6c103b9"}, + {file = "regex-2020.1.8-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:0932941cdfb3afcbc26cc3bcf7c3f3d73d5a9b9c56955d432dbf8bbc147d4c5b"}, + {file = "regex-2020.1.8-cp38-cp38-win32.whl", hash = "sha256:d58e4606da2a41659c84baeb3cfa2e4c87a74cec89a1e7c56bee4b956f9d7461"}, + {file = "regex-2020.1.8-cp38-cp38-win_amd64.whl", hash = "sha256:e7c7661f7276507bce416eaae22040fd91ca471b5b33c13f8ff21137ed6f248c"}, + {file = "regex-2020.1.8.tar.gz", hash = "sha256:d0f424328f9822b0323b3b6f2e4b9c90960b24743d220763c7f07071e0778351"}, +] +restructuredtext-lint = [ + {file = "restructuredtext_lint-1.3.0.tar.gz", hash = "sha256:97b3da356d5b3a8514d8f1f9098febd8b41463bed6a1d9f126cf0a048b6fd908"}, +] +rfc3986 = [ + {file = "rfc3986-1.3.2-py2.py3-none-any.whl", hash = "sha256:df4eba676077cefb86450c8f60121b9ae04b94f65f85b69f3f731af0516b7b18"}, + {file = "rfc3986-1.3.2.tar.gz", hash = "sha256:0344d0bd428126ce554e7ca2b61787b6a28d2bbd19fc70ed2dd85efe31176405"}, +] +six = [ + {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, + {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, +] +smmap2 = [ + {file = "smmap2-2.0.5-py2.py3-none-any.whl", hash = "sha256:0555a7bf4df71d1ef4218e4807bbf9b201f910174e6e08af2e138d4e517b4dde"}, + {file = "smmap2-2.0.5.tar.gz", hash = "sha256:29a9ffa0497e7f2be94ca0ed1ca1aa3cd4cf25a1f6b4f5f87f74b46ed91d609a"}, +] +sniffio = [ + {file = "sniffio-1.1.0-py3-none-any.whl", hash = "sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5"}, + {file = "sniffio-1.1.0.tar.gz", hash = "sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"}, +] +snowballstemmer = [ + {file = "snowballstemmer-2.0.0-py2.py3-none-any.whl", hash = "sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0"}, + {file = "snowballstemmer-2.0.0.tar.gz", hash = "sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"}, +] +starlette = [ + {file = "starlette-0.12.9.tar.gz", hash = "sha256:c2ac9a42e0e0328ad20fe444115ac5e3760c1ee2ac1ff8cdb5ec915c4a453411"}, +] +stevedore = [ + {file = "stevedore-1.31.0-py2.py3-none-any.whl", hash = "sha256:01d9f4beecf0fbd070ddb18e5efb10567801ba7ef3ddab0074f54e3cd4e91730"}, + {file = "stevedore-1.31.0.tar.gz", hash = "sha256:e0739f9739a681c7a1fda76a102b65295e96a144ccdb552f2ae03c5f0abe8a14"}, +] +testfixtures = [ + {file = "testfixtures-6.10.3-py2.py3-none-any.whl", hash = "sha256:9334f64d4210b734d04abff516d6ddaab7328306a0c4c1268ce4624df51c4f6d"}, + {file = "testfixtures-6.10.3.tar.gz", hash = "sha256:8f22100d4fb841b958f64e71c8820a32dc46f57d4d7e077777b932acd87b7327"}, +] +toml = [ + {file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"}, + {file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"}, + {file = "toml-0.10.0.tar.gz", hash = "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c"}, +] +tornado = [ + {file = "tornado-6.0.3-cp35-cp35m-win32.whl", hash = "sha256:c9399267c926a4e7c418baa5cbe91c7d1cf362d505a1ef898fde44a07c9dd8a5"}, + {file = "tornado-6.0.3-cp35-cp35m-win_amd64.whl", hash = "sha256:398e0d35e086ba38a0427c3b37f4337327231942e731edaa6e9fd1865bbd6f60"}, + {file = "tornado-6.0.3-cp36-cp36m-win32.whl", hash = "sha256:4e73ef678b1a859f0cb29e1d895526a20ea64b5ffd510a2307b5998c7df24281"}, + {file = "tornado-6.0.3-cp36-cp36m-win_amd64.whl", hash = "sha256:349884248c36801afa19e342a77cc4458caca694b0eda633f5878e458a44cb2c"}, + {file = "tornado-6.0.3-cp37-cp37m-win32.whl", hash = "sha256:559bce3d31484b665259f50cd94c5c28b961b09315ccd838f284687245f416e5"}, + {file = "tornado-6.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:abbe53a39734ef4aba061fca54e30c6b4639d3e1f59653f0da37a0003de148c7"}, + {file = "tornado-6.0.3.tar.gz", hash = "sha256:c845db36ba616912074c5b1ee897f8e0124df269468f25e4fe21fe72f6edd7a9"}, +] +typed-ast = [ + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3"}, + {file = "typed_ast-1.4.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win32.whl", hash = "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919"}, + {file = "typed_ast-1.4.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01"}, + {file = "typed_ast-1.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652"}, + {file = "typed_ast-1.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win32.whl", hash = "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1"}, + {file = "typed_ast-1.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa"}, + {file = "typed_ast-1.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41"}, + {file = "typed_ast-1.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win32.whl", hash = "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe"}, + {file = "typed_ast-1.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355"}, + {file = "typed_ast-1.4.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907"}, + {file = "typed_ast-1.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d"}, + {file = "typed_ast-1.4.1-cp38-cp38-win32.whl", hash = "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c"}, + {file = "typed_ast-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4"}, + {file = "typed_ast-1.4.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34"}, + {file = "typed_ast-1.4.1.tar.gz", hash = "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b"}, +] +typing-extensions = [ + {file = "typing_extensions-3.7.4.1-py2-none-any.whl", hash = "sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d"}, + {file = "typing_extensions-3.7.4.1-py3-none-any.whl", hash = "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575"}, + {file = "typing_extensions-3.7.4.1.tar.gz", hash = "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2"}, +] +urllib3 = [ + {file = "urllib3-1.25.7-py2.py3-none-any.whl", hash = "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293"}, + {file = "urllib3-1.25.7.tar.gz", hash = "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"}, +] +wcwidth = [ + {file = "wcwidth-0.1.8-py2.py3-none-any.whl", hash = "sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603"}, + {file = "wcwidth-0.1.8.tar.gz", hash = "sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8"}, +] +wemake-python-styleguide = [ + {file = "wemake-python-styleguide-0.13.4.tar.gz", hash = "sha256:927236eef999ef4a67ec6e120586da7d9e33f99568775d6ed28c3c6670872264"}, + {file = "wemake_python_styleguide-0.13.4-py3-none-any.whl", hash = "sha256:b005b29ba07eba59c5d8d781df35975adcf1febcad7f1306abe9bf07fa11f1d0"}, +] +win32-setctime = [ + {file = "win32_setctime-1.0.1-py3-none-any.whl", hash = "sha256:568fd636c68350bcc54755213fe01966fe0a6c90b386c0776425944a0382abef"}, + {file = "win32_setctime-1.0.1.tar.gz", hash = "sha256:b47e5023ec7f0b4962950902b15bc56464a380d869f59d27dbf9ab423b23e8f9"}, +] +zipp = [ + {file = "zipp-2.0.0-py3-none-any.whl", hash = "sha256:57147f6b0403b59f33fd357f169f860e031303415aeb7d04ede4839d23905ab8"}, + {file = "zipp-2.0.0.tar.gz", hash = "sha256:7ae5ccaca427bafa9760ac3cd8f8c244bfc259794b5b6bb9db4dda2241575d09"}, +] diff --git a/pyproject.toml b/pyproject.toml index 1cc2c31e..67d1a3d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,59 +1,51 @@ [tool.poetry] name = "botx" -version = "0.12.4" -description = "A little python library for building bots for Express" +version = "0.13.0" +description = "A little python framework for building bots for eXpress" +license = "MIT" authors = ["Sidnev Nikolay