diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 3802c718..00000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -* @Nimond @Opti213 @YunusovSamat @lxmnk @vonabarak diff --git a/.github/workflows/deploy-docs-gh-pages.yml b/.github/workflows/deploy-docs-gh-pages.yml deleted file mode 100644 index 7a6b1e07..00000000 --- a/.github/workflows/deploy-docs-gh-pages.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Build and Deploy to GitHub Pages - -on: - push: - branches: - - master - -jobs: - build: - runs-on: ubuntu-18.04 - steps: - - uses: actions/checkout@v2 - - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: 3.8 - - - name: Install poetry - run: | - pip install poetry==1.0 - poetry config virtualenvs.in-project true - - - name: Set up cache - uses: actions/cache@v1 - id: cache - with: - path: .venv - key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }} - - - name: Ensure cache is healthy - if: steps.cache.outputs.cache-hit == 'true' - shell: bash - run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv - - - name: Install dependencies - shell: bash - run: poetry install --extras tests - - - name: Build MkDocs for GitHub Pages - run: | - poetry run nox -s build-docs - - - name: Deploy to GitHub Pages - uses: JamesIves/github-pages-deploy-action@releases/v3 - with: - ACCESS_TOKEN: ${{ secrets.GH_PAGES_TOKEN }} - BRANCH: gh-pages - FOLDER: site \ No newline at end of file diff --git a/.github/workflows/deploy-docs-netlify.yml b/.github/workflows/deploy-docs-netlify.yml index 613ee0ba..4db3f0ab 100644 --- a/.github/workflows/deploy-docs-netlify.yml +++ b/.github/workflows/deploy-docs-netlify.yml @@ -1,13 +1,7 @@ name: Build and Deploy to Netlify on: - push: - branches: - - master - pull_request: - types: - - opened - - synchronize + push env: SITE_URL: https://pybotx.netlify.com @@ -23,31 +17,13 @@ jobs: with: python-version: 3.8 - - name: Install poetry - run: | - pip install poetry==1.0 - poetry config virtualenvs.in-project true - - - name: Set up cache - uses: actions/cache@v1 - id: cache - with: - path: .venv - key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }} - - - name: Ensure cache is healthy - if: steps.cache.outputs.cache-hit == 'true' - shell: bash - run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv - - - name: Install dependencies - shell: bash - run: poetry install --extras tests + - name: Setup dependencies + uses: ExpressApp/github-actions-poetry@v0.1 - name: Build MkDocs for Netlify run: | - poetry run nox -s build-docs - + source .venv/bin/activate + mkdocs build - name: Deploy to Netlify uses: nwtgck/actions-netlify@v1 with: @@ -56,4 +32,4 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} env: NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} \ No newline at end of file + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} diff --git a/.github/workflows/python_app.yml b/.github/workflows/python_app.yml new file mode 100644 index 00000000..3a61c2a7 --- /dev/null +++ b/.github/workflows/python_app.yml @@ -0,0 +1,54 @@ +name: Python application +on: push +jobs: + + test: + name: Test + runs-on: ubuntu-20.04 + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10"] + + steps: + - name: Setup dependencies + uses: ExpressApp/github-actions-poetry@v0.2 + with: + python-version: ${{ matrix.python-version }} + poetry-version: "1.1.12" + + - name: Run tests + env: + BOT_CREDENTIALS: ${{ secrets.END_TO_END_TESTS_BOT_CREDENTIALS }} + run: | + poetry run ./scripts/test --cov-report=xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v2 + with: + fail_ci_if_error: true + files: ./coverage.xml + flags: unittests + + lint: + name: Lint + runs-on: ubuntu-20.04 + + steps: + - name: Setup dependencies + uses: ExpressApp/github-actions-poetry@v0.2 + + - name: Run linters + run: | + poetry run ./scripts/lint + + docs-lint: + name: Docs lint + runs-on: ubuntu-20.04 + + steps: + - name: Setup dependencies + uses: ExpressApp/github-actions-poetry@v0.2 + + - name: Run linters + run: | + poetry run ./scripts/docs-lint diff --git a/.github/workflows/styles.yml b/.github/workflows/styles.yml deleted file mode 100644 index ac0807cf..00000000 --- a/.github/workflows/styles.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Styles - -on: push - -jobs: - styles: - name: Styles - runs-on: ubuntu-18.04 - strategy: - matrix: - python-version: [3.8] - steps: - - uses: actions/checkout@v1 - - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - - name: Install poetry - run: | - pip install poetry==1.0 - poetry config virtualenvs.in-project true - - - name: Set up cache - uses: actions/cache@v1 - id: cache - with: - path: .venv - key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }} - - - name: Ensure cache is healthy - if: steps.cache.outputs.cache-hit == 'true' - shell: bash - run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv - - - name: Install dependencies - shell: bash - run: poetry install --extras tests - - - name: Run linters - run: | - poetry run nox -s lint diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index 577c321e..00000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: Tests - -on: push - -jobs: - tests: - name: Tests - runs-on: ubuntu-18.04 - strategy: - matrix: - python-version: [3.7, 3.8, 3.9] - steps: - - uses: actions/checkout@master - - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: ${{ matrix.python-version }} - - - name: Install poetry - run: | - pip install poetry==1.0 - poetry config virtualenvs.in-project true - - - name: Set up cache - uses: actions/cache@v1 - id: cache - with: - path: .venv - key: venv-${{ runner.os }}-py-${{ matrix.python-version }}-poetry-${{ hashFiles('poetry.lock') }} - - - name: Ensure cache is healthy - if: steps.cache.outputs.cache-hit == 'true' - shell: bash - run: poetry run pip --version >/dev/null 2>&1 || rm -rf .venv - - - name: Install dependencies - shell: bash - run: poetry install --extras tests - - - name: Run tests - run: | - poetry run nox -s test - - - uses: codecov/codecov-action@v1 - with: - file: ./coverage.xml - fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index 1a1155c1..d91383cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,111 +1,8 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ .coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# Environments .env -.venv -env/ +.venv/ venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ - +__pycache__ +htmlcov +site .idea/ -.vscode/ - -static/ - -**/.DS_Store diff --git a/README.md b/README.md index 4cac9024..ac2a8dd7 100644 --- a/README.md +++ b/README.md @@ -1,126 +1,37 @@ -

pybotx

-

- A little python framework for building bots for eXpress messenger. -

-

- - Tests - - - Styles - - - Coverage - - - Code Style - - - Package version - - - License - -

+# pybotx +*A python library for building bots and smartapps for eXpress messenger.* ---- +[![PyPI version](https://badge.fury.io/py/botx.svg)](https://badge.fury.io/py/botx) +![PyPI - Python Version](https://img.shields.io/pypi/pyversions/botx) +[![Coverage](https://codecov.io/gh/ExpressApp/pybotx/branch/master/graph/badge.svg)](https://codecov.io/gh/ExpressApp/pybotx/branch/master) +[![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) -# Introduction -`pybotx` is a framework for building bots for eXpress providing a mechanism for simple -integration with your favourite web frameworks. +## Features -Main features: +* Designed to be easy to use +* Simple integration with async web-frameworks +* Support middlewares for command, command-collector and bot +* 100% test coverage +* 100% type annotated codebase - * Simple integration with your web apps. - * Asynchronous API with synchronous as a fallback option. - * 100% test coverage. - * 100% type annotated codebase. +## Documentation -**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`.* +Documentation will be here: +For now, pls contact eXpress team, we'll help you. -```toml -[tool.poetry.dependencies] -botx = "^0.15.0" -``` - ---- - -## Requirements - -Python 3.7+ +**Note:** Available only in Russian language -`pybotx` use the following libraries: - -* 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 -``` - -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 -``` - -## Example -Let's create a simple echo bot. +Install pybotx using `pip`: -* Create a file `main.py` with following content: - -```python3 -from botx import Bot, BotXCredentials, IncomingMessage, Message, Status -from fastapi import FastAPI -from starlette.status import HTTP_202_ACCEPTED -from uuid import UUID - - -bot_accounts=[ - BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id")) -] -bot = Bot(bot_accounts=bot_accounts) - - -@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()) -``` - -* Deploy a bot on your server using uvicorn and set the url for the webhook in Express. ```bash -$ uvicorn main:app --host=0.0.0.0 +pip install git+https://github.com/ExpressApp/pybotx.git ``` -This bot will send back every your message. - -## License - -This project is licensed under the terms of the MIT license. +**Note:** This project is under active development (`0.y.z`) and its API may be +unstable. diff --git a/botx/__init__.py b/botx/__init__.py index 0bdf1196..ecd2d420 100644 --- a/botx/__init__.py +++ b/botx/__init__.py @@ -1,157 +1,173 @@ -"""A little python framework for building bots for Express.""" - -from loguru import logger - -from botx.bots.bots import Bot -from botx.clients.clients.async_client import AsyncClient -from botx.clients.clients.sync_client import Client -from botx.clients.types.message_payload import InternalBotNotificationPayload -from botx.collecting.collectors.collector import Collector -from botx.dependencies.injection_params import Depends -from botx.exceptions import BotXAPIError, DependencyFailure, TokenError, UnknownBotError -from botx.models.attachments import ( - AttachList, - Attachment, - Contact, - Document, - Image, - Link, - Location, - Video, - Voice, +from botx.bot.api.exceptions import UnsupportedBotAPIVersionError +from botx.bot.api.responses.bot_disabled import ( + BotAPIBotDisabledResponse, + build_bot_disabled_response, +) +from botx.bot.api.responses.command_accepted import build_command_accepted_response +from botx.bot.bot import Bot +from botx.bot.exceptions import ( + AnswerDestinationLookupError, + BotShuttingDownError, + BotXMethodCallbackNotFoundError, + UnknownBotAccountError, +) +from botx.bot.handler import IncomingMessageHandlerFunc, Middleware +from botx.bot.handler_collector import HandlerCollector +from botx.bot.testing import lifespan_wrapper +from botx.client.exceptions.callbacks import ( + BotXMethodFailedCallbackReceivedError, + CallbackNotReceivedError, +) +from botx.client.exceptions.chats import ( + CantUpdatePersonalChatError, + ChatCreationError, + ChatCreationProhibitedError, + InvalidUsersListError, +) +from botx.client.exceptions.common import ( + ChatNotFoundError, + InvalidBotAccountError, + PermissionDeniedError, + RateLimitReachedError, +) +from botx.client.exceptions.event import EventNotFoundError +from botx.client.exceptions.files import FileDeletedError, FileMetadataNotFound +from botx.client.exceptions.http import ( + InvalidBotXResponsePayloadError, + InvalidBotXStatusCodeError, +) +from botx.client.exceptions.notifications import ( + BotIsNotChatMemberError, + FinalRecipientsListEmptyError, + StealthModeDisabledError, ) -from botx.models.buttons import BubbleElement, KeyboardElement -from botx.models.credentials import BotXCredentials -from botx.models.entities import ( - ChatMention, - Entity, - EntityList, - Forward, - Mention, - Reply, - UserMention, +from botx.client.exceptions.users import UserNotFoundError +from botx.client.stickers_api.exceptions import ( + InvalidEmojiError, + InvalidImageError, + StickerPackOrStickerNotFoundError, ) +from botx.logger import logger +from botx.models.async_files import Document, File, Image, Video, Voice +from botx.models.attachments import OutgoingAttachment +from botx.models.bot_account import BotAccount, BotAccountWithSecret +from botx.models.bot_sender import BotSender +from botx.models.chats import Chat, ChatInfo, ChatInfoMember, ChatListItem from botx.models.enums import ( + AttachmentTypes, ChatTypes, - CommandTypes, - EntityTypes, + ClientPlatforms, MentionTypes, - Statuses, - SystemEvents, UserKinds, ) -from botx.models.errors import BotDisabledErrorData, BotDisabledResponse -from botx.models.events import ChatCreatedEvent, InternalBotNotificationEvent -from botx.models.files import File -from botx.models.menu import Status -from botx.models.messages.incoming_message import IncomingMessage -from botx.models.messages.message import Message -from botx.models.messages.sending.credentials import SendingCredentials -from botx.models.messages.sending.markup import MessageMarkup -from botx.models.messages.sending.message import SendingMessage -from botx.models.messages.sending.options import MessageOptions, NotificationOptions -from botx.models.messages.sending.payload import MessagePayload, UpdatePayload -from botx.models.smartapps import SendingSmartAppEvent, SendingSmartAppNotification -from botx.models.status import StatusRecipient -from botx.models.stickers import ( - Pagination, - Sticker, - StickerFromPack, - StickerPack, - StickerPackList, - StickerPackPreview, +from botx.models.message.edit_message import EditMessage +from botx.models.message.forward import Forward +from botx.models.message.incoming_message import IncomingMessage, UserDevice, UserSender +from botx.models.message.markup import BubbleMarkup, Button, KeyboardMarkup +from botx.models.message.mentions import Mention, MentionList +from botx.models.message.message_status import MessageStatus +from botx.models.message.outgoing_message import OutgoingMessage +from botx.models.message.reply import Reply +from botx.models.message.reply_message import ReplyMessage +from botx.models.method_callbacks import BotAPIMethodFailedCallback +from botx.models.status import BotMenu, StatusRecipient +from botx.models.stickers import Sticker, StickerPack +from botx.models.system_events.added_to_chat import AddedToChatEvent +from botx.models.system_events.chat_created import ChatCreatedEvent, ChatCreatedMember +from botx.models.system_events.cts_login import CTSLoginEvent +from botx.models.system_events.cts_logout import CTSLogoutEvent +from botx.models.system_events.deleted_from_chat import DeletedFromChatEvent +from botx.models.system_events.internal_bot_notification import ( + InternalBotNotificationEvent, ) -from botx.testing.building.builder import MessageBuilder - -try: - from botx.testing.testing_client.client import TestClient # noqa: WPS433 -except ImportError: - TestClient = None # type: ignore # noqa: WPS440 +from botx.models.system_events.left_from_chat import LeftFromChatEvent +from botx.models.system_events.smartapp_event import SmartAppEvent +from botx.models.users import UserFromSearch __all__ = ( - # bots + "AddedToChatEvent", + "AnswerDestinationLookupError", + "AttachmentTypes", "Bot", - # collecting - "Collector", - # clients - "AsyncClient", - "Client", - # exceptions - "BotXAPIError", - "DependencyFailure", - "UnknownBotError", - "TokenError", - # DI - "Depends", - # models - # markup - "BubbleElement", - "KeyboardElement", - # credentials - "BotXCredentials", - # enums - "Statuses", - "UserKinds", - "ChatTypes", - "CommandTypes", - "SystemEvents", - "EntityTypes", - "MentionTypes", - # errors - "BotDisabledErrorData", - "BotDisabledResponse", - # events + "BotAccountWithSecret", + "BotAPIBotDisabledResponse", + "BotAPIMethodFailedCallback", + "BotIsNotChatMemberError", + "BotMenu", + "BotAccount", + "BotSender", + "BotShuttingDownError", + "BotXMethodCallbackNotFoundError", + "BotXMethodFailedCallbackReceivedError", + "BubbleMarkup", + "Button", + "CallbackNotReceivedError", + "CantUpdatePersonalChatError", + "Chat", "ChatCreatedEvent", - "InternalBotNotificationEvent", - # files + "ChatCreatedMember", + "ChatCreationError", + "ChatCreationProhibitedError", + "ChatInfo", + "ChatInfoMember", + "ChatListItem", + "ChatNotFoundError", + "ChatTypes", + "ClientPlatforms", + "CTSLoginEvent", + "CTSLogoutEvent", + "DeletedFromChatEvent", + "Document", + "EditMessage", + "MessageStatus", "File", - # attachments + "FileDeletedError", + "FileMetadataNotFound", + "FinalRecipientsListEmptyError", + "Forward", + "HandlerCollector", "Image", - "Video", - "Document", - "Voice", - "Location", - "Contact", - "Link", - "Attachment", - "AttachList", - # mentions - "Mention", - "ChatMention", - "UserMention", - # status - "Status", - "StatusRecipient", - # messages - # handler message - "Message", - # incoming "IncomingMessage", - "Entity", - "EntityList", - "Forward", + "IncomingMessageHandlerFunc", + "InternalBotNotificationEvent", + "InvalidBotAccountError", + "InvalidBotXResponsePayloadError", + "InvalidBotXStatusCodeError", + "InvalidEmojiError", + "InvalidImageError", + "InvalidUsersListError", + "EventNotFoundError", + "KeyboardMarkup", + "LeftFromChatEvent", + "Mention", + "MentionList", + "MentionTypes", + "Middleware", + "OutgoingAttachment", + "OutgoingMessage", + "PermissionDeniedError", + "RateLimitReachedError", "Reply", - # sending - "SendingCredentials", - "SendingMessage", - "MessageMarkup", - "MessageOptions", - "NotificationOptions", - "MessagePayload", - "UpdatePayload", - "InternalBotNotificationPayload", - # Stickers - "Pagination", + "ReplyMessage", + "SmartAppEvent", + "SmartAppEvent", + "StatusRecipient", + "StealthModeDisabledError", "Sticker", "StickerPack", - "StickerPackList", - "StickerFromPack", - "StickerPackPreview", - "SendingSmartAppEvent", - "SendingSmartAppNotification", - # testing - "TestClient", - "MessageBuilder", + "StickerPackOrStickerNotFoundError", + "UnknownBotAccountError", + "UnsupportedBotAPIVersionError", + "UserDevice", + "UserFromSearch", + "UserKinds", + "UserNotFoundError", + "UserSender", + "Video", + "Voice", + "build_bot_disabled_response", + "build_command_accepted_response", + "lifespan_wrapper", ) logger.disable("botx") diff --git a/botx/async_buffer.py b/botx/async_buffer.py new file mode 100644 index 00000000..d5e8a8a5 --- /dev/null +++ b/botx/async_buffer.py @@ -0,0 +1,33 @@ +import os +from typing import Optional + +try: + from typing import Protocol +except ImportError: + from typing_extensions import Protocol # type: ignore # noqa: WPS440 + + +class AsyncBufferBase(Protocol): + async def seek(self, cursor: int, whence: int = os.SEEK_SET) -> int: + ... # noqa: WPS428 + + async def tell(self) -> int: + ... # noqa: WPS428 + + +class AsyncBufferWritable(AsyncBufferBase): + async def write(self, content: bytes) -> int: + ... # noqa: WPS428 + + +class AsyncBufferReadable(AsyncBufferBase): + async def read(self, bytes_to_read: Optional[int] = None) -> bytes: + ... # noqa: WPS428 + + +async def get_file_size(async_buffer: AsyncBufferReadable) -> int: + await async_buffer.seek(0, os.SEEK_END) + file_size = await async_buffer.tell() + await async_buffer.seek(0) + + return file_size diff --git a/docs/src/__init__.py b/botx/bot/__init__.py similarity index 100% rename from docs/src/__init__.py rename to botx/bot/__init__.py diff --git a/docs/src/development/__init__.py b/botx/bot/api/__init__.py similarity index 100% rename from docs/src/development/__init__.py rename to botx/bot/api/__init__.py diff --git a/botx/bot/api/exceptions.py b/botx/bot/api/exceptions.py new file mode 100644 index 00000000..47cd0a5d --- /dev/null +++ b/botx/bot/api/exceptions.py @@ -0,0 +1,10 @@ +from botx.constants import BOT_API_VERSION + + +class UnsupportedBotAPIVersionError(Exception): + def __init__(self, version: int) -> None: + self.version = version + self.message = ( + f"Unsupported Bot API version: `{version}`, expected `{BOT_API_VERSION}`" + ) + super().__init__(self.message) diff --git a/docs/src/development/collector/__init__.py b/botx/bot/api/responses/__init__.py similarity index 100% rename from docs/src/development/collector/__init__.py rename to botx/bot/api/responses/__init__.py diff --git a/botx/bot/api/responses/bot_disabled.py b/botx/bot/api/responses/bot_disabled.py new file mode 100644 index 00000000..c8a2f3af --- /dev/null +++ b/botx/bot/api/responses/bot_disabled.py @@ -0,0 +1,41 @@ +from dataclasses import asdict, dataclass, field +from typing import Any, Dict, List, Literal + + +@dataclass +class BotAPIBotDisabledErrorData: + status_message: str + + +@dataclass +class BotAPIBotDisabledResponse: + """Disabled bot response model. + + Only `.error_data.status_message` attribute will be displayed to + user. Other attributes will be visible only in BotX logs. + """ + + error_data: BotAPIBotDisabledErrorData + errors: List[str] = field(default_factory=list) + reason: Literal["bot_disabled"] = "bot_disabled" + + +def build_bot_disabled_response(status_message: str) -> Dict[str, Any]: + """Build bot disabled response for BotX. + + It should be send if the bot can't process the command. + + If you would like to build complex response, see + [BotAPIBotDisabledResponse] + [botx.bot.api.commands.bot_disabled_response.BotAPIBotDisabledResponse]. + + :param status_message: Status message. + + :return: Built bot disabled response. + """ + + response = BotAPIBotDisabledResponse( + error_data=BotAPIBotDisabledErrorData(status_message=status_message), + ) + + return asdict(response) diff --git a/botx/bot/api/responses/command_accepted.py b/botx/bot/api/responses/command_accepted.py new file mode 100644 index 00000000..a0713734 --- /dev/null +++ b/botx/bot/api/responses/command_accepted.py @@ -0,0 +1,12 @@ +from typing import Any, Dict + + +def build_command_accepted_response() -> Dict[str, Any]: + """Build accepted response for BotX. + + It should be sent if the bot started processing a command. + + :return: Built accepted response. + """ + + return {"result": "accepted"} diff --git a/botx/bot/bot.py b/botx/bot/bot.py new file mode 100644 index 00000000..9d3d8c56 --- /dev/null +++ b/botx/bot/bot.py @@ -0,0 +1,1466 @@ +from types import SimpleNamespace +from typing import Any, AsyncIterable, Dict, Iterator, List, Optional, Sequence, Union +from uuid import UUID + +import httpx +from pydantic import ValidationError, parse_obj_as + +from botx.async_buffer import AsyncBufferReadable, AsyncBufferWritable +from botx.bot.bot_accounts_storage import BotAccountsStorage +from botx.bot.callbacks_manager import CallbacksManager +from botx.bot.contextvars import bot_id_var, chat_id_var +from botx.bot.exceptions import AnswerDestinationLookupError +from botx.bot.handler import Middleware +from botx.bot.handler_collector import HandlerCollector +from botx.bot.middlewares.exception_middleware import ExceptionHandlersDict +from botx.client.chats_api.add_admin import ( + AddAdminMethod, + BotXAPIAddAdminRequestPayload, +) +from botx.client.chats_api.add_user import AddUserMethod, BotXAPIAddUserRequestPayload +from botx.client.chats_api.chat_info import ( + BotXAPIChatInfoRequestPayload, + ChatInfoMethod, +) +from botx.client.chats_api.create_chat import ( + BotXAPICreateChatRequestPayload, + CreateChatMethod, +) +from botx.client.chats_api.disable_stealth import ( + BotXAPIDisableStealthRequestPayload, + DisableStealthMethod, +) +from botx.client.chats_api.list_chats import ListChatsMethod +from botx.client.chats_api.pin_message import ( + BotXAPIPinMessageRequestPayload, + PinMessageMethod, +) +from botx.client.chats_api.remove_user import ( + BotXAPIRemoveUserRequestPayload, + RemoveUserMethod, +) +from botx.client.chats_api.set_stealth import ( + BotXAPISetStealthRequestPayload, + SetStealthMethod, +) +from botx.client.chats_api.unpin_message import ( + BotXAPIUnpinMessageRequestPayload, + UnpinMessageMethod, +) +from botx.client.events_api.edit_event import ( + BotXAPIEditEventRequestPayload, + EditEventMethod, +) +from botx.client.events_api.message_status_event import ( + BotXAPIMessageStatusRequestPayload, + MessageStatusMethod, +) +from botx.client.events_api.reply_event import ( + BotXAPIReplyEventRequestPayload, + ReplyEventMethod, +) +from botx.client.events_api.stop_typing_event import ( + BotXAPIStopTypingEventRequestPayload, + StopTypingEventMethod, +) +from botx.client.events_api.typing_event import ( + BotXAPITypingEventRequestPayload, + TypingEventMethod, +) +from botx.client.exceptions.common import InvalidBotAccountError +from botx.client.files_api.download_file import ( + BotXAPIDownloadFileRequestPayload, + DownloadFileMethod, +) +from botx.client.files_api.upload_file import ( + BotXAPIUploadFileRequestPayload, + UploadFileMethod, +) +from botx.client.get_token import get_token +from botx.client.notifications_api.direct_notification import ( + BotXAPIDirectNotificationRequestPayload, + DirectNotificationMethod, +) +from botx.client.notifications_api.internal_bot_notification import ( + BotXAPIInternalBotNotificationRequestPayload, + InternalBotNotificationMethod, +) +from botx.client.smartapps_api.smartapp_event import ( + BotXAPISmartAppEventRequestPayload, + SmartAppEventMethod, +) +from botx.client.smartapps_api.smartapp_notification import ( + BotXAPISmartAppNotificationRequestPayload, + SmartAppNotificationMethod, +) +from botx.client.stickers_api.add_sticker import ( + AddStickerMethod, + BotXAPIAddStickerRequestPayload, +) +from botx.client.stickers_api.create_sticker_pack import ( + BotXAPICreateStickerPackRequestPayload, + CreateStickerPackMethod, +) +from botx.client.stickers_api.delete_sticker import ( + BotXAPIDeleteStickerRequestPayload, + DeleteStickerMethod, +) +from botx.client.stickers_api.delete_sticker_pack import ( + BotXAPIDeleteStickerPackRequestPayload, + DeleteStickerPackMethod, +) +from botx.client.stickers_api.edit_sticker_pack import ( + BotXAPIEditStickerPackRequestPayload, + EditStickerPackMethod, +) +from botx.client.stickers_api.get_sticker import ( + BotXAPIGetStickerRequestPayload, + GetStickerMethod, +) +from botx.client.stickers_api.get_sticker_pack import ( + BotXAPIGetStickerPackRequestPayload, + GetStickerPackMethod, +) +from botx.client.stickers_api.get_sticker_packs import ( + BotXAPIGetStickerPacksRequestPayload, + GetStickerPacksMethod, +) +from botx.client.users_api.search_user_by_email import ( + BotXAPISearchUserByEmailRequestPayload, + SearchUserByEmailMethod, +) +from botx.client.users_api.search_user_by_huid import ( + BotXAPISearchUserByHUIDRequestPayload, + SearchUserByHUIDMethod, +) +from botx.client.users_api.search_user_by_login import ( + BotXAPISearchUserByLoginRequestPayload, + SearchUserByLoginMethod, +) +from botx.constants import STICKER_PACKS_PER_PAGE +from botx.converters import optional_sequence_to_list +from botx.image_validators import ( + ensure_file_content_is_png, + ensure_sticker_image_size_valid, +) +from botx.logger import logger, pformat_jsonable_obj, trim_file_data_in_incoming_json +from botx.missing import Missing, MissingOptional, Undefined, not_undefined +from botx.models.async_files import File +from botx.models.attachments import IncomingFileAttachment, OutgoingAttachment +from botx.models.bot_account import BotAccount, BotAccountWithSecret +from botx.models.chats import ChatInfo, ChatListItem +from botx.models.commands import BotAPICommand, BotCommand +from botx.models.enums import ChatTypes +from botx.models.message.edit_message import EditMessage +from botx.models.message.markup import BubbleMarkup, KeyboardMarkup +from botx.models.message.message_status import MessageStatus +from botx.models.message.outgoing_message import OutgoingMessage +from botx.models.message.reply_message import ReplyMessage +from botx.models.method_callbacks import BotXMethodCallback +from botx.models.status import ( + BotAPIStatusRecipient, + BotMenu, + StatusRecipient, + build_bot_status_response, +) +from botx.models.stickers import Sticker, StickerPack, StickerPackFromList +from botx.models.users import UserFromSearch + +MissingOptionalAttachment = MissingOptional[ + Union[IncomingFileAttachment, OutgoingAttachment] +] + + +class Bot: + def __init__( + self, + *, + collectors: Sequence[HandlerCollector], + bot_accounts: Sequence[BotAccountWithSecret], + middlewares: Optional[Sequence[Middleware]] = None, + httpx_client: Optional[httpx.AsyncClient] = None, + exception_handlers: Optional[ExceptionHandlersDict] = None, + default_callback_timeout: Optional[int] = None, + ) -> None: + if not collectors: + logger.warning("Bot has no connected collectors") + if not bot_accounts: + logger.warning("Bot has no bot accounts") + + self.state: SimpleNamespace = SimpleNamespace() + + self.default_callback_timeout = default_callback_timeout + + middlewares = optional_sequence_to_list(middlewares) + + self._handler_collector = self._build_main_collector( + collectors, + middlewares, + exception_handlers, + ) + + self._bot_accounts_storage = BotAccountsStorage(list(bot_accounts)) + self._httpx_client = httpx_client or httpx.AsyncClient() + + self._callback_manager = CallbacksManager() + + def async_execute_raw_bot_command(self, raw_bot_command: Dict[str, Any]) -> None: + try: + bot_api_command: BotAPICommand = parse_obj_as( + # Same ignore as in pydantic + BotAPICommand, # type: ignore[arg-type] + raw_bot_command, + ) + except ValidationError as validation_exc: + raise ValueError("Bot command validation error") from validation_exc + + logger.opt(lazy=True).debug( + "Got command: {command}", + command=lambda: pformat_jsonable_obj( + trim_file_data_in_incoming_json(raw_bot_command), + ), + ) + + bot_command = bot_api_command.to_domain(raw_bot_command) + self.async_execute_bot_command(bot_command) + + def async_execute_bot_command(self, bot_command: BotCommand) -> None: + # raise UnknownBotAccountError if no bot account with this bot_id. + self._bot_accounts_storage.ensure_bot_id_exists(bot_command.bot.id) + + self._handler_collector.async_handle_bot_command(self, bot_command) + + async def raw_get_status(self, query_params: Dict[str, str]) -> Dict[str, Any]: + logger.opt(lazy=True).debug( + "Got status: {status}", + status=lambda: pformat_jsonable_obj(query_params), + ) + + try: + bot_api_status_recipient = BotAPIStatusRecipient.parse_obj(query_params) + except ValidationError as exc: + raise ValueError("Status request validation error") from exc + + status_recipient = bot_api_status_recipient.to_domain() + + bot_menu = await self.get_status(status_recipient) + return build_bot_status_response(bot_menu) + + async def get_status(self, status_recipient: StatusRecipient) -> BotMenu: + # raise UnknownBotAccountError if no bot account with this bot_id. + self._bot_accounts_storage.ensure_bot_id_exists(status_recipient.bot_id) + + return await self._handler_collector.get_bot_menu(status_recipient, self) + + def set_raw_botx_method_result( + self, + raw_botx_method_result: Dict[str, Any], + ) -> None: + logger.debug("Got callback: {callback}", callback=raw_botx_method_result) + + callback: BotXMethodCallback = parse_obj_as( + # Same ignore as in pydantic + BotXMethodCallback, # type: ignore[arg-type] + raw_botx_method_result, + ) + + self._callback_manager.set_botx_method_callback_result(callback) + + async def wait_botx_method_callback( + self, + sync_id: UUID, + timeout: Optional[int], + ) -> BotXMethodCallback: + return await self._callback_manager.wait_botx_method_callback(sync_id, timeout) + + @property + def bot_accounts(self) -> Iterator[BotAccount]: + yield from self._bot_accounts_storage.iter_bot_accounts() + + async def startup(self) -> None: + for bot_account in self.bot_accounts: + try: + token = await self.get_token(bot_id=bot_account.id) + except (InvalidBotAccountError, httpx.HTTPError): + logger.warning( + "Can't get token for bot account: " + f"host - {bot_account.host}, bot_id - {bot_account.id}", + ) + continue + + self._bot_accounts_storage.set_token(bot_account.id, token) + + async def shutdown(self) -> None: + self._callback_manager.stop_callbacks_waiting() + await self._handler_collector.wait_active_tasks() + await self._httpx_client.aclose() + + # - Bots API - + async def get_token( + self, + *, + bot_id: UUID, + ) -> str: + """Get bot auth token. + + :param bot_id: Bot which should perform the request. + + :return: Auth token. + """ + + return await get_token(bot_id, self._httpx_client, self._bot_accounts_storage) + + # - Notifications API - + async def answer_message( + self, + body: str, + *, + metadata: Missing[Dict[str, Any]] = Undefined, + bubbles: Missing[BubbleMarkup] = Undefined, + keyboard: Missing[KeyboardMarkup] = Undefined, + file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined, + recipients: Missing[List[UUID]] = Undefined, + silent_response: Missing[bool] = Undefined, + markup_auto_adjust: Missing[bool] = Undefined, + stealth_mode: Missing[bool] = Undefined, + send_push: Missing[bool] = Undefined, + ignore_mute: Missing[bool] = Undefined, + wait_callback: bool = True, + callback_timeout: MissingOptional[int] = Undefined, + ) -> UUID: + """Answer to incoming message. + + Works just like `Bot.send`, but `bot_id` and `chat_id` are + taken from the incoming message. + + :param body: Message body. + :param metadata: Notification options. + :param bubbles: Bubbles (buttons attached to message) markup. + :param keyboard: Keyboard (buttons below message input) markup. + :param file: Attachment. + :param recipients: List of recipients, empty for all in chat. + :param silent_response: (BotX default: False) Exclude next user + messages from history. + :param markup_auto_adjust: (BotX default: False) Move button to next + row, if its text doesn't fit. + :param stealth_mode: (BotX default: False) Enable stealth mode. + :param send_push: (BotX default: True) Send push notification on + devices. + :param ignore_mute: (BotX default: False) Ignore mute or dnd (do not + disturb). + :param wait_callback: Block method call until callback received. + :param callback_timeout: Callback timeout in seconds (or `None` for + endless waiting). + + :raises AnswerDestinationLookupError: If you try to answer without + receiving incoming message. + + :return: Notification sync_id. + """ + + try: # noqa: WPS229 + bot_id = bot_id_var.get() + chat_id = chat_id_var.get() + except LookupError as exc: + raise AnswerDestinationLookupError from exc + + return await self.send_message( + bot_id=bot_id, + chat_id=chat_id, + body=body, + metadata=metadata, + bubbles=bubbles, + keyboard=keyboard, + file=file, + recipients=recipients, + silent_response=silent_response, + markup_auto_adjust=markup_auto_adjust, + stealth_mode=stealth_mode, + send_push=send_push, + ignore_mute=ignore_mute, + wait_callback=wait_callback, + callback_timeout=callback_timeout, + ) + + async def send( + self, + *, + message: OutgoingMessage, + wait_callback: bool = True, + callback_timeout: MissingOptional[int] = Undefined, + ) -> UUID: + """Send internal notification. + + :param message: Built outgoing message. + :param wait_callback: Wait for callback. + :param callback_timeout: Timeout for waiting for callback. + + :return: Notification sync_id. + """ + + return await self.send_message( + bot_id=message.bot_id, + chat_id=message.chat_id, + body=message.body, + metadata=message.metadata, + bubbles=message.bubbles, + keyboard=message.keyboard, + file=message.file, + recipients=message.recipients, + silent_response=message.silent_response, + markup_auto_adjust=message.markup_auto_adjust, + stealth_mode=message.stealth_mode, + send_push=message.send_push, + ignore_mute=message.ignore_mute, + wait_callback=wait_callback, + callback_timeout=callback_timeout, + ) + + async def send_message( + self, + *, + bot_id: UUID, + chat_id: UUID, + body: str, + metadata: Missing[Dict[str, Any]] = Undefined, + bubbles: Missing[BubbleMarkup] = Undefined, + keyboard: Missing[KeyboardMarkup] = Undefined, + file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined, + silent_response: Missing[bool] = Undefined, + markup_auto_adjust: Missing[bool] = Undefined, + recipients: Missing[List[UUID]] = Undefined, + stealth_mode: Missing[bool] = Undefined, + send_push: Missing[bool] = Undefined, + ignore_mute: Missing[bool] = Undefined, + wait_callback: bool = True, + callback_timeout: MissingOptional[int] = Undefined, + ) -> UUID: + """Send message to chat. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + :param body: Message body. + :param metadata: Notification options. + :param bubbles: Bubbles (buttons attached to message) markup. + :param keyboard: Keyboard (buttons below message input) markup. + :param file: Attachment. + :param recipients: List of recipients, empty for all in chat. + :param silent_response: (BotX default: False) Exclude next user + messages from history. + :param markup_auto_adjust: (BotX default: False) Move button to next + row, if its text doesn't fit. + :param stealth_mode: (BotX default: False) Enable stealth mode. + :param send_push: (BotX default: True) Send push notification on + devices. + :param ignore_mute: (BotX default: False) Ignore mute or dnd (do not + disturb). + :param wait_callback: Block method call until callback received. + :param callback_timeout: Callback timeout in seconds (or `None` for + endless waiting). + + :return: Notification sync_id. + """ + + method = DirectNotificationMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + self._callback_manager, + ) + + payload = BotXAPIDirectNotificationRequestPayload.from_domain( + chat_id=chat_id, + body=body, + metadata=metadata, + bubbles=bubbles, + keyboard=keyboard, + file=file, + recipients=recipients, + silent_response=silent_response, + markup_auto_adjust=markup_auto_adjust, + stealth_mode=stealth_mode, + send_push=send_push, + ignore_mute=ignore_mute, + ) + botx_api_sync_id = await method.execute( + payload, + wait_callback, + not_undefined(callback_timeout, self.default_callback_timeout), + ) + + return botx_api_sync_id.to_domain() + + async def send_internal_bot_notification( + self, + *, + bot_id: UUID, + chat_id: UUID, + data: Dict[str, Any], + opts: Missing[Dict[str, Any]] = Undefined, + recipients: Missing[List[UUID]] = Undefined, + wait_callback: bool = True, + callback_timeout: MissingOptional[int] = Undefined, + ) -> UUID: + """Send internal notification. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + :param data: Notification payload. + :param opts: Notification options. + :param recipients: List of bot uuids, empty for all in chat. + :param wait_callback: Wait for callback. + :param callback_timeout: Timeout for waiting for callback. + + :return: Notification sync_id. + """ + + method = InternalBotNotificationMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + self._callback_manager, + ) + + payload = BotXAPIInternalBotNotificationRequestPayload.from_domain( + chat_id=chat_id, + data=data, + opts=opts, + recipients=recipients, + ) + botx_api_sync_id = await method.execute( + payload, + wait_callback, + not_undefined(callback_timeout, self.default_callback_timeout), + ) + + return botx_api_sync_id.to_domain() + + # - Events API - + async def edit( + self, + *, + message: EditMessage, + ) -> None: + """Edit message. + + :param message: Built outgoing edit message. + """ + + await self.edit_message( + bot_id=message.bot_id, + sync_id=message.sync_id, + body=message.body, + metadata=message.metadata, + bubbles=message.bubbles, + keyboard=message.keyboard, + file=message.file, + markup_auto_adjust=message.markup_auto_adjust, + ) + + async def edit_message( + self, + *, + bot_id: UUID, + sync_id: UUID, + body: Missing[str] = Undefined, + metadata: Missing[Dict[str, Any]] = Undefined, + bubbles: Missing[BubbleMarkup] = Undefined, + keyboard: Missing[KeyboardMarkup] = Undefined, + file: MissingOptionalAttachment = Undefined, + markup_auto_adjust: Missing[bool] = Undefined, + ) -> None: + """Edit message. + + :param bot_id: Bot which should perform the request. + :param sync_id: `sync_id` of message to update. + :param body: New message body. Skip to leave previous body or pass + empty string to clean it. + :param metadata: Notification options. Skip to leave previous metadata. + :param bubbles: Bubbles (buttons attached to message) markup. Skip to + leave previous bubbles. + :param keyboard: Keyboard (buttons below message input) markup. Skip + to leave previous keyboard. + :param file: Attachment. Skip to leave previous file or pass `None` + to clean it. + :param markup_auto_adjust: (BotX default: False) Move button to next + row, if its text doesn't fit. + """ + + method = EditEventMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPIEditEventRequestPayload.from_domain( + sync_id=sync_id, + body=body, + metadata=metadata, + bubbles=bubbles, + keyboard=keyboard, + file=file, + markup_auto_adjust=markup_auto_adjust, + ) + + await method.execute(payload) + + async def reply( + self, + *, + message: ReplyMessage, + ) -> None: + """Reply message. + + :param message: Built outgoing reply message. + """ + + await self.reply_message( + bot_id=message.bot_id, + sync_id=message.sync_id, + body=message.body, + metadata=message.metadata, + bubbles=message.bubbles, + keyboard=message.keyboard, + file=message.file, + silent_response=message.silent_response, + markup_auto_adjust=message.markup_auto_adjust, + stealth_mode=message.stealth_mode, + send_push=message.send_push, + ignore_mute=message.ignore_mute, + ) + + async def reply_message( + self, + *, + bot_id: UUID, + sync_id: UUID, + body: str, + metadata: Missing[Dict[str, Any]] = Undefined, + bubbles: Missing[BubbleMarkup] = Undefined, + keyboard: Missing[KeyboardMarkup] = Undefined, + file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined, + silent_response: Missing[bool] = Undefined, + markup_auto_adjust: Missing[bool] = Undefined, + stealth_mode: Missing[bool] = Undefined, + send_push: Missing[bool] = Undefined, + ignore_mute: Missing[bool] = Undefined, + ) -> None: + """Reply on message by `sync_id`. + + :param bot_id: Bot which should perform the request. + :param sync_id: `sync_id` of message to reply on. + :param body: Reply body. + :param metadata: Notification options. + :param bubbles: Bubbles (buttons attached to message) markup. + :param keyboard: Keyboard (buttons below message input) markup. + :param file: Attachment. + :param silent_response: (BotX default: False) Exclude next user + messages from history. + :param markup_auto_adjust: (BotX default: False) Move button to next + row, if its text doesn't fit. + :param stealth_mode: (BotX default: False) Enable stealth mode. + :param send_push: (BotX default: True) Send push notification on + devices. + :param ignore_mute: (BotX default: False) Ignore mute or dnd (do not + disturb). + """ + + payload = BotXAPIReplyEventRequestPayload.from_domain( + sync_id=sync_id, + body=body, + metadata=metadata, + bubbles=bubbles, + keyboard=keyboard, + file=file, + silent_response=silent_response, + markup_auto_adjust=markup_auto_adjust, + stealth_mode=stealth_mode, + send_push=send_push, + ignore_mute=ignore_mute, + ) + method = ReplyEventMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + await method.execute(payload) + + async def get_message_status(self, *, bot_id: UUID, sync_id: UUID) -> MessageStatus: + """ + Get status of message by `sync_id`. + + :param bot_id: Bot which should perform the request. + :param sync_id: `sync_id` of message to get its status. + + :returns: Message status object. + """ + payload = BotXAPIMessageStatusRequestPayload.from_domain(sync_id=sync_id) + method = MessageStatusMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + + botx_api_message_status = await method.execute(payload) + return botx_api_message_status.to_domain() + + async def start_typing( + self, + *, + bot_id: UUID, + chat_id: UUID, + ) -> None: + """Send `typing` event. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + """ + + payload = BotXAPITypingEventRequestPayload.from_domain( + chat_id=chat_id, + ) + method = TypingEventMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + await method.execute(payload) + + async def stop_typing( + self, + *, + bot_id: UUID, + chat_id: UUID, + ) -> None: + """Send `stop_typing` event. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + """ + + payload = BotXAPIStopTypingEventRequestPayload.from_domain( + chat_id=chat_id, + ) + method = StopTypingEventMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + await method.execute(payload) + + # - Chats API - + async def list_chats( + self, + *, + bot_id: UUID, + ) -> List[ChatListItem]: + """Get all bot chats. + + :param bot_id: Bot which should perform the request. + + :returns: List of chats info. + """ + + method = ListChatsMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + + botx_api_list_chat = await method.execute() + + return botx_api_list_chat.to_domain() + + async def chat_info( + self, + *, + bot_id: UUID, + chat_id: UUID, + ) -> ChatInfo: + """Get chat information. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + + :return: Chat information. + """ + + method = ChatInfoMethod(bot_id, self._httpx_client, self._bot_accounts_storage) + + payload = BotXAPIChatInfoRequestPayload.from_domain(chat_id=chat_id) + botx_api_chat_info = await method.execute(payload) + + return botx_api_chat_info.to_domain() + + async def add_users_to_chat( + self, + *, + bot_id: UUID, + chat_id: UUID, + huids: List[UUID], + ) -> None: + """Add user to chat. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + :param huids: List of eXpress account ids. + """ + + method = AddUserMethod(bot_id, self._httpx_client, self._bot_accounts_storage) + + payload = BotXAPIAddUserRequestPayload.from_domain(chat_id=chat_id, huids=huids) + await method.execute(payload) + + async def remove_users_from_chat( + self, + *, + bot_id: UUID, + chat_id: UUID, + huids: List[UUID], + ) -> None: + """Remove eXpress accounts from chat. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + :param huids: List of eXpress account ids. + """ + + method = RemoveUserMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + + payload = BotXAPIRemoveUserRequestPayload.from_domain( + chat_id=chat_id, + huids=huids, + ) + await method.execute(payload) + + async def promote_to_chat_admins( + self, + *, + bot_id: UUID, + chat_id: UUID, + huids: List[UUID], + ) -> None: + """Promote users in chat to admins. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + :param huids: List of eXpress account ids. + """ + + method = AddAdminMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + + payload = BotXAPIAddAdminRequestPayload.from_domain( + chat_id=chat_id, + huids=huids, + ) + await method.execute(payload) + + async def enable_stealth( + self, + *, + bot_id: UUID, + chat_id: UUID, + disable_web_client: Missing[bool] = Undefined, + ttl_after_read: Missing[int] = Undefined, + total_ttl: Missing[int] = Undefined, + ) -> None: + """Enable stealth mode. + + After the expiration of the time all messages will be hidden. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + :param disable_web_client: (BotX default: False) Should messages + be shown in web. + :param ttl_after_read: (BotX default: OFF) Time of messages burning + after read. + :param total_ttl: (BotX default: OFF) Time of messages burning after + send. + """ + + method = SetStealthMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPISetStealthRequestPayload.from_domain( + chat_id=chat_id, + disable_web_client=disable_web_client, + ttl_after_read=ttl_after_read, + total_ttl=total_ttl, + ) + + await method.execute(payload) + + async def disable_stealth( + self, + *, + bot_id: UUID, + chat_id: UUID, + ) -> None: + """Disable stealth model. Hides all messages that were in stealth. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + """ + + method = DisableStealthMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPIDisableStealthRequestPayload.from_domain(chat_id=chat_id) + + await method.execute(payload) + + async def create_chat( + self, + *, + bot_id: UUID, + name: str, + chat_type: ChatTypes, + huids: List[UUID], + description: Optional[str] = None, + shared_history: Missing[bool] = Undefined, + ) -> UUID: + """Create chat. + + :param bot_id: Bot which should perform the request. + :param name: Chat visible name. + :param chat_type: Chat type. + :param huids: List of eXpress account ids. + :param description: Chat description. + :param shared_history: (BotX default: False) Open old chat history for + new added users. + + :return: Created chat uuid. + """ + + method = CreateChatMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + + payload = BotXAPICreateChatRequestPayload.from_domain( + name=name, + chat_type=chat_type, + huids=huids, + shared_history=shared_history, + description=description, + ) + botx_api_chat_id = await method.execute(payload) + + return botx_api_chat_id.to_domain() + + async def pin_message( + self, + *, + bot_id: UUID, + chat_id: UUID, + sync_id: UUID, + ) -> None: + """Pin message in chat. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + :param sync_id: Target sync id. + """ + + method = PinMessageMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPIPinMessageRequestPayload.from_domain( + chat_id=chat_id, + sync_id=sync_id, + ) + + await method.execute(payload) + + async def unpin_message( + self, + *, + bot_id: UUID, + chat_id: UUID, + ) -> None: + """Unpin message in chat. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + """ + + method = UnpinMessageMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPIUnpinMessageRequestPayload.from_domain(chat_id=chat_id) + + await method.execute(payload) + + # - Users API - + async def search_user_by_email( + self, + *, + bot_id: UUID, + email: str, + ) -> UserFromSearch: + """Search user by email for search. + + :param bot_id: Bot which should perform the request. + :param email: User email. + + :return: User information. + """ + + method = SearchUserByEmailMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPISearchUserByEmailRequestPayload.from_domain(email=email) + + botx_api_user_from_search = await method.execute(payload) + + return botx_api_user_from_search.to_domain() + + async def search_user_by_huid( + self, + *, + bot_id: UUID, + huid: UUID, + ) -> UserFromSearch: + """Search user by huid for search. + + :param bot_id: Bot which should perform the request. + :param huid: User huid. + + :return: User information. + """ + + method = SearchUserByHUIDMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPISearchUserByHUIDRequestPayload.from_domain(huid=huid) + + botx_api_user_from_search = await method.execute(payload) + + return botx_api_user_from_search.to_domain() + + async def search_user_by_ad( + self, + *, + bot_id: UUID, + ad_login: str, + ad_domain: str, + ) -> UserFromSearch: + """Search user by AD login and AD domain for search. + + :param bot_id: Bot which should perform the request. + :param ad_login: User AD login. + :param ad_domain: User AD domain. + + :return: User information. + """ + + method = SearchUserByLoginMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPISearchUserByLoginRequestPayload.from_domain( + ad_login=ad_login, + ad_domain=ad_domain, + ) + + botx_api_user_from_search = await method.execute(payload) + + return botx_api_user_from_search.to_domain() + + # - SmartApps API - + async def send_smartapp_event( + self, + *, + bot_id: UUID, + chat_id: UUID, + data: Dict[str, Any], + ref: MissingOptional[UUID] = Undefined, + opts: Missing[Dict[str, Any]] = Undefined, + files: Missing[List[File]] = Undefined, + ) -> None: + """Send SmartApp event. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + :param data: Event payload. + :param ref: Request identifier. + :param opts: Event options. + :param files: Files. + """ + + method = SmartAppEventMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPISmartAppEventRequestPayload.from_domain( + ref=ref, + smartapp_id=bot_id, + chat_id=chat_id, + data=data, + opts=opts, + files=files, + ) + + await method.execute(payload) + + async def send_smartapp_notification( + self, + bot_id: UUID, + chat_id: UUID, + smartapp_counter: int, + opts: Missing[Dict[str, Any]] = Undefined, + ) -> None: + """Send SmartApp notification. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + :param smartapp_counter: Value app's counter. + :param opts: Vvent options. + """ + + method = SmartAppNotificationMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPISmartAppNotificationRequestPayload.from_domain( + chat_id=chat_id, + smartapp_counter=smartapp_counter, + opts=opts, + ) + + await method.execute(payload) + + # - Stickers API - + async def create_sticker_pack(self, *, bot_id: UUID, name: str) -> StickerPack: + """Create empty sticker pack. + + :param bot_id: Bot which should perform the request. + :param name: Sticker pack name. + + :return: Created sticker pack. + """ + + method = CreateStickerPackMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPICreateStickerPackRequestPayload.from_domain(name=name) + + botx_api_sticker_pack = await method.execute(payload) + + return botx_api_sticker_pack.to_domain() + + async def add_sticker( + self, + *, + bot_id: UUID, + sticker_pack_id: UUID, + emoji: str, + async_buffer: AsyncBufferReadable, + ) -> Sticker: + """Add sticker in sticker pack. + + :param bot_id: Bot which should perform the request. + :param sticker_pack_id: Sticker pack id to indicate where to add. + :param emoji: Sticker emoji. + :param async_buffer: Sticker image file. Only PNG. + + :return: Added sticker. + """ + + await ensure_file_content_is_png(async_buffer) + await ensure_sticker_image_size_valid(async_buffer) + + method = AddStickerMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = await BotXAPIAddStickerRequestPayload.from_domain( + sticker_pack_id=sticker_pack_id, + emoji=emoji, + async_buffer=async_buffer, + ) + + botx_api_sticker = await method.execute(payload) + + return botx_api_sticker.to_domain() + + async def delete_sticker( + self, + *, + bot_id: UUID, + sticker_pack_id: UUID, + sticker_id: UUID, + ) -> None: + """Delete sticker from sticker pack. + + :param bot_id: Bot which should perform the request. + :param sticker_pack_id: Target sticker pack id. + :param sticker_id: Sticker id which should be deleted. + """ + + method = DeleteStickerMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = await BotXAPIDeleteStickerRequestPayload.from_domain( + sticker_id=sticker_id, + sticker_pack_id=sticker_pack_id, + ) + + await method.execute(payload) + + async def iterate_by_sticker_packs( + self, + *, + bot_id: UUID, + user_huid: UUID, + ) -> AsyncIterable[StickerPackFromList]: + """Iterate by user sticker packs. + + :param bot_id: Bot which should perform the request. + :param user_huid: User huid. + + :yield: Sticker pack. + """ + + after = None + + method = GetStickerPacksMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + + while True: + payload = BotXAPIGetStickerPacksRequestPayload.from_domain( + huid=user_huid, + limit=STICKER_PACKS_PER_PAGE, + after=after, + ) + botx_api_sticker_pack_list = await method.execute(payload) + + sticker_pack_page = botx_api_sticker_pack_list.to_domain() + after = sticker_pack_page.after + + for sticker_pack in sticker_pack_page.sticker_packs: + yield sticker_pack + + if not after: + break + + async def get_sticker_pack( + self, + *, + bot_id: UUID, + sticker_pack_id: UUID, + ) -> StickerPack: + """Get sticker pack. + + :param bot_id: Bot which should perform the request. + :param sticker_pack_id: Sticker pack id. + + :return: Sticker pack. + """ + + method = GetStickerPackMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPIGetStickerPackRequestPayload.from_domain( + sticker_pack_id=sticker_pack_id, + ) + + botx_api_sticker_pack = await method.execute(payload) + + return botx_api_sticker_pack.to_domain() + + async def delete_sticker_pack(self, *, bot_id: UUID, sticker_pack_id: UUID) -> None: + """Delete existing sticker pack. + + :param bot_id: Bot which should perform the request. + :param sticker_pack_id: Target sticker pack. + """ + + method = DeleteStickerPackMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + + payload = BotXAPIDeleteStickerPackRequestPayload.from_domain( + sticker_pack_id=sticker_pack_id, + ) + + await method.execute(payload) + + async def get_sticker( + self, + *, + bot_id: UUID, + sticker_pack_id: UUID, + sticker_id: UUID, + ) -> Sticker: + """Get sticker. + + :param bot_id: Bot which should perform the request. + :param sticker_pack_id: Sticker pack id. + :param sticker_id: Sticker id. + + :return: Sticker. + """ + + method = GetStickerMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPIGetStickerRequestPayload.from_domain( + sticker_pack_id=sticker_pack_id, + sticker_id=sticker_id, + ) + + botx_api_sticker = await method.execute(payload) + + return botx_api_sticker.to_domain() + + async def edit_sticker_pack( + self, + *, + bot_id: UUID, + sticker_pack_id: UUID, + name: str, + preview: UUID, + stickers_order: List[UUID], + ) -> StickerPack: + """Edit Sticker pack. + + :param bot_id: Bot which should perform the request. + :param sticker_pack_id: Sticker pack id. + :param name: Sticker pack name. + :param preview: Sticker from the set selected as a preview. + :param stickers_order: Sticker IDs in order they are displayed. + + :return: Edited sticker pack. + """ + + method = EditStickerPackMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPIEditStickerPackRequestPayload.from_domain( + sticker_pack_id=sticker_pack_id, + name=name, + preview=preview, + stickers_order=stickers_order, + ) + + botx_api_sticker_pack = await method.execute(payload) + + return botx_api_sticker_pack.to_domain() + + # - Files API - + async def download_file( + self, + *, + bot_id: UUID, + chat_id: UUID, + file_id: UUID, + async_buffer: AsyncBufferWritable, + ) -> None: + """Download file form file service. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + :param file_id: Async file id. + :param async_buffer: Buffer to write downloaded file. + """ + + method = DownloadFileMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPIDownloadFileRequestPayload.from_domain( + chat_id=chat_id, + file_id=file_id, + ) + + await method.execute(payload, async_buffer) + + async def upload_file( + self, + *, + bot_id: UUID, + chat_id: UUID, + async_buffer: AsyncBufferReadable, + filename: str, + duration: Missing[int] = Undefined, + caption: Missing[str] = Undefined, + ) -> File: + """Upload file to file service. + + :param bot_id: Bot which should perform the request. + :param chat_id: Target chat id. + :param async_buffer: Buffer to write downloaded file. + :param filename: File name. + :param duration: Video duration. + :param caption: Text under file. + + :return: Meta info of uploaded file. + """ + + method = UploadFileMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + payload = BotXAPIUploadFileRequestPayload.from_domain( + chat_id=chat_id, + duration=duration, + caption=caption, + ) + + botx_api_async_file = await method.execute(payload, async_buffer, filename) + + return botx_api_async_file.to_domain() + + @staticmethod + def _build_main_collector( + collectors: Sequence[HandlerCollector], + middlewares: List[Middleware], + exception_handlers: Optional[ExceptionHandlersDict] = None, + ) -> HandlerCollector: + main_collector = HandlerCollector(middlewares=middlewares) + main_collector.insert_exception_middleware(exception_handlers) + main_collector.include(*collectors) + + return main_collector diff --git a/botx/bot/bot_accounts_storage.py b/botx/bot/bot_accounts_storage.py new file mode 100644 index 00000000..d97cbcdd --- /dev/null +++ b/botx/bot/bot_accounts_storage.py @@ -0,0 +1,48 @@ +import base64 +import hashlib +import hmac +from typing import Dict, Iterator, List, Optional +from uuid import UUID + +from botx.bot.exceptions import UnknownBotAccountError +from botx.models.bot_account import BotAccount, BotAccountWithSecret + + +class BotAccountsStorage: + def __init__(self, bot_accounts: List[BotAccountWithSecret]) -> None: + self._bot_accounts = bot_accounts + self._auth_tokens: Dict[UUID, str] = {} + + def iter_bot_accounts(self) -> Iterator[BotAccount]: + yield from self._bot_accounts + + def get_host(self, bot_id: UUID) -> str: + bot_account = self._get_bot_account(bot_id) + return bot_account.host + + def set_token(self, bot_id: UUID, token: str) -> None: + self._auth_tokens[bot_id] = token + + def get_token_or_none(self, bot_id: UUID) -> Optional[str]: + return self._auth_tokens.get(bot_id) + + def build_signature(self, bot_id: UUID) -> str: + bot_account = self._get_bot_account(bot_id) + + signed_bot_id = hmac.new( + key=bot_account.secret_key.encode(), + msg=str(bot_account.id).encode(), + digestmod=hashlib.sha256, + ).digest() + + return base64.b16encode(signed_bot_id).decode() + + def ensure_bot_id_exists(self, bot_id: UUID) -> None: + self._get_bot_account(bot_id) + + def _get_bot_account(self, bot_id: UUID) -> BotAccountWithSecret: + for bot_account in self._bot_accounts: + if bot_account.id == bot_id: + return bot_account + + raise UnknownBotAccountError(bot_id) diff --git a/botx/bot/callbacks_manager.py b/botx/bot/callbacks_manager.py new file mode 100644 index 00000000..c4a3f9fc --- /dev/null +++ b/botx/bot/callbacks_manager.py @@ -0,0 +1,63 @@ +import asyncio +from typing import TYPE_CHECKING, Dict, Optional +from uuid import UUID + +from botx.bot.exceptions import BotShuttingDownError, BotXMethodCallbackNotFoundError +from botx.client.exceptions.callbacks import CallbackNotReceivedError +from botx.logger import logger +from botx.models.method_callbacks import BotXMethodCallback + +if TYPE_CHECKING: + from asyncio import Future # noqa: WPS458 + + +class CallbacksManager: + def __init__(self) -> None: + self._callback_futures: Dict[UUID, "Future[BotXMethodCallback]"] = {} + + def create_botx_method_callback(self, sync_id: UUID) -> None: + self._callback_futures[sync_id] = asyncio.Future() + + def set_botx_method_callback_result( + self, + callback: BotXMethodCallback, + ) -> None: + sync_id = callback.sync_id + future = self._pop_future(sync_id) + + if future.cancelled(): + logger.warning( + f"BotX method with sync_id `{sync_id!s}` don't wait callback", + ) + return + + future.set_result(callback) + + async def wait_botx_method_callback( + self, + sync_id: UUID, + timeout: Optional[int], + ) -> BotXMethodCallback: + future = self._callback_futures[sync_id] + + try: + return await asyncio.wait_for(future, timeout=timeout) + except asyncio.TimeoutError as exc: + raise CallbackNotReceivedError(sync_id) from exc + + def stop_callbacks_waiting(self) -> None: + for sync_id, future in self._callback_futures.items(): + if not future.done(): + future.set_exception( + BotShuttingDownError( + f"Callback with sync_id `{sync_id!s}` can't be received", + ), + ) + + def _pop_future(self, sync_id: UUID) -> "Future[BotXMethodCallback]": + try: + future = self._callback_futures.pop(sync_id) + except KeyError: + raise BotXMethodCallbackNotFoundError(sync_id) from None + + return future diff --git a/botx/bot/contextvars.py b/botx/bot/contextvars.py new file mode 100644 index 00000000..030a50c7 --- /dev/null +++ b/botx/bot/contextvars.py @@ -0,0 +1,10 @@ +from contextvars import ContextVar +from typing import TYPE_CHECKING +from uuid import UUID + +if TYPE_CHECKING: # To avoid circular import + from botx.bot.bot import Bot + +bot_var: ContextVar["Bot"] = ContextVar("bot_var") +bot_id_var: ContextVar[UUID] = ContextVar("bot_id") +chat_id_var: ContextVar[UUID] = ContextVar("chat_id") diff --git a/botx/bot/exceptions.py b/botx/bot/exceptions.py new file mode 100644 index 00000000..b6313189 --- /dev/null +++ b/botx/bot/exceptions.py @@ -0,0 +1,29 @@ +from typing import Any +from uuid import UUID + + +class UnknownBotAccountError(Exception): + def __init__(self, bot_id: UUID) -> None: + self.bot_id = bot_id + self.message = f"No bot account with bot_id: `{bot_id!s}`" + super().__init__(self.message) + + +class BotXMethodCallbackNotFoundError(Exception): + def __init__(self, sync_id: UUID) -> None: + self.sync_id = sync_id + self.message = f"No callback found with sync_id: `{sync_id!s}`" + super().__init__(self.message) + + +class BotShuttingDownError(Exception): + def __init__(self, context: Any) -> None: + self.context = context + self.message = f"Bot is shutting down: {context}" + super().__init__(self.message) + + +class AnswerDestinationLookupError(Exception): + def __init__(self) -> None: + self.message = "No IncomingMessage received. Use `Bot.send` instead" + super().__init__(self.message) diff --git a/botx/bot/handler.py b/botx/bot/handler.py new file mode 100644 index 00000000..b5ac9ffc --- /dev/null +++ b/botx/bot/handler.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +from functools import partial +from typing import TYPE_CHECKING, Awaitable, Callable, List, Literal, TypeVar, Union + +from botx.models.commands import BotCommand +from botx.models.message.incoming_message import IncomingMessage +from botx.models.status import StatusRecipient +from botx.models.system_events.added_to_chat import AddedToChatEvent +from botx.models.system_events.chat_created import ChatCreatedEvent +from botx.models.system_events.cts_login import CTSLoginEvent +from botx.models.system_events.cts_logout import CTSLogoutEvent +from botx.models.system_events.deleted_from_chat import DeletedFromChatEvent +from botx.models.system_events.internal_bot_notification import ( + InternalBotNotificationEvent, +) +from botx.models.system_events.left_from_chat import LeftFromChatEvent +from botx.models.system_events.smartapp_event import SmartAppEvent + +if TYPE_CHECKING: # To avoid circular import + from botx.bot.bot import Bot + +TBotCommand = TypeVar("TBotCommand", bound=BotCommand) +HandlerFunc = Callable[[TBotCommand, "Bot"], Awaitable[None]] + +IncomingMessageHandlerFunc = HandlerFunc[IncomingMessage] +SystemEventHandlerFunc = Union[ + HandlerFunc[AddedToChatEvent], + HandlerFunc[ChatCreatedEvent], + HandlerFunc[DeletedFromChatEvent], + HandlerFunc[LeftFromChatEvent], + HandlerFunc[CTSLoginEvent], + HandlerFunc[CTSLogoutEvent], + HandlerFunc[InternalBotNotificationEvent], + HandlerFunc[SmartAppEvent], +] + +VisibleFunc = Callable[[StatusRecipient, "Bot"], Awaitable[bool]] + +Middleware = Callable[ + [IncomingMessage, "Bot", IncomingMessageHandlerFunc], + Awaitable[None], +] + + +@dataclass +class BaseIncomingMessageHandler: + handler_func: IncomingMessageHandlerFunc + middlewares: List[Middleware] + + async def __call__(self, message: IncomingMessage, bot: "Bot") -> None: + handler_func = self.handler_func + + for middleware in self.middlewares[::-1]: + handler_func = partial(middleware, call_next=handler_func) + + await handler_func(message, bot) + + def add_middlewares(self, middlewares: List[Middleware]) -> None: + self.middlewares = middlewares + self.middlewares + + +@dataclass +class HiddenCommandHandler(BaseIncomingMessageHandler): + # Default should be here, see: https://github.com/python/mypy/issues/6113 + visible: Literal[False] = False + + +@dataclass +class VisibleCommandHandler(BaseIncomingMessageHandler): + description: str + visible: Union[Literal[True], VisibleFunc] = True + + +@dataclass +class DefaultMessageHandler(BaseIncomingMessageHandler): + """Just for separate type.""" + + +CommandHandler = Union[HiddenCommandHandler, VisibleCommandHandler] diff --git a/botx/bot/handler_collector.py b/botx/bot/handler_collector.py new file mode 100644 index 00000000..438c4fb3 --- /dev/null +++ b/botx/bot/handler_collector.py @@ -0,0 +1,438 @@ +import asyncio +import re +from typing import ( + TYPE_CHECKING, + Callable, + Dict, + List, + Optional, + Sequence, + Type, + Union, + overload, +) +from weakref import WeakSet + +from botx.bot.contextvars import bot_id_var, bot_var, chat_id_var +from botx.bot.handler import ( + CommandHandler, + DefaultMessageHandler, + HandlerFunc, + HiddenCommandHandler, + IncomingMessageHandlerFunc, + Middleware, + SystemEventHandlerFunc, + VisibleCommandHandler, + VisibleFunc, +) +from botx.bot.middlewares.exception_middleware import ( + ExceptionHandlersDict, + ExceptionMiddleware, +) +from botx.converters import optional_sequence_to_list +from botx.logger import logger +from botx.models.commands import BotCommand, SystemEvent +from botx.models.message.incoming_message import IncomingMessage +from botx.models.status import BotMenu, StatusRecipient +from botx.models.system_events.added_to_chat import AddedToChatEvent +from botx.models.system_events.chat_created import ChatCreatedEvent +from botx.models.system_events.cts_login import CTSLoginEvent +from botx.models.system_events.cts_logout import CTSLogoutEvent +from botx.models.system_events.deleted_from_chat import DeletedFromChatEvent +from botx.models.system_events.internal_bot_notification import ( + InternalBotNotificationEvent, +) +from botx.models.system_events.left_from_chat import LeftFromChatEvent +from botx.models.system_events.smartapp_event import SmartAppEvent + +if TYPE_CHECKING: # To avoid circular import + from botx.bot.bot import Bot + + +class HandlerCollector: + VALID_COMMAND_NAME_RE = re.compile(r"^\/[^\s\/]+$", flags=re.UNICODE) + + def __init__(self, middlewares: Optional[Sequence[Middleware]] = None) -> None: + self._user_commands_handlers: Dict[str, CommandHandler] = {} + self._default_message_handler: Optional[DefaultMessageHandler] = None + self._system_events_handlers: Dict[ + Type[BotCommand], + SystemEventHandlerFunc, + ] = {} + self._middlewares = optional_sequence_to_list(middlewares) + self._tasks: "WeakSet[asyncio.Task[None]]" = WeakSet() + + def include(self, *others: "HandlerCollector") -> None: + """Include other `HandlerCollector`.""" + + for collector in others: + self._include_collector(collector) + + def async_handle_bot_command( + self, + bot: "Bot", + bot_command: BotCommand, + ) -> None: + task = asyncio.create_task( + self.handle_bot_command(bot_command, bot), + ) + self._tasks.add(task) + + async def handle_incoming_message_by_command( + self, + message: IncomingMessage, + bot: "Bot", + command: str, + ) -> None: + message_handler = self._get_command_handler(command) + if message_handler: + self._fill_contextvars(message, bot) + await message_handler(message, bot) + + async def handle_bot_command(self, bot_command: BotCommand, bot: "Bot") -> None: + if isinstance(bot_command, IncomingMessage): + message_handler = self._get_incoming_message_handler(bot_command) + if message_handler: + self._fill_contextvars(bot_command, bot) + await message_handler(bot_command, bot) + + elif isinstance( + bot_command, + # TODO: Replace `__args__` with `typing.get_origin` on python 3.7 drop. + SystemEvent.__args__, # type: ignore [attr-defined] # noqa: WPS609 + ): + event_handler = self._get_system_event_handler_or_none(bot_command) + if event_handler: + self._fill_contextvars(bot_command, bot) + await event_handler(bot_command, bot) + + else: + raise NotImplementedError(f"Unsupported event type: `{bot_command}`") + + async def get_bot_menu( + self, + status_recipient: StatusRecipient, + bot: "Bot", + ) -> BotMenu: + bot_menu = {} + + for command_name, handler in self._user_commands_handlers.items(): + if handler.visible is True or ( + callable(handler.visible) + and await handler.visible(status_recipient, bot) + ): + bot_menu[command_name] = handler.description + + return BotMenu(bot_menu) + + def command( + self, + command_name: str, + visible: Union[bool, VisibleFunc] = True, + description: Optional[str] = None, + middlewares: Optional[Sequence[Middleware]] = None, + ) -> Callable[[IncomingMessageHandlerFunc], IncomingMessageHandlerFunc]: + """Decorate command handler.""" + + if not self.VALID_COMMAND_NAME_RE.match(command_name): + raise ValueError("Command should start with '/' and doesn't include spaces") + + def decorator( + handler_func: IncomingMessageHandlerFunc, + ) -> IncomingMessageHandlerFunc: + if command_name in self._user_commands_handlers: + raise ValueError( + f"Handler for command `{command_name}` already registered", + ) + + self._user_commands_handlers[command_name] = self._build_command_handler( + handler_func, + visible, + description, + self._middlewares + optional_sequence_to_list(middlewares), + ) + + return handler_func + + return decorator + + @overload + def default_message_handler( + self, + handler_func: IncomingMessageHandlerFunc, + ) -> IncomingMessageHandlerFunc: + ... # noqa: WPS428 + + @overload + def default_message_handler( + self, + *, + middlewares: Optional[Sequence[Middleware]] = None, + ) -> Callable[[IncomingMessageHandlerFunc], IncomingMessageHandlerFunc]: + ... # noqa: WPS428 + + def default_message_handler( # noqa: WPS320 + self, + handler_func: Optional[IncomingMessageHandlerFunc] = None, + *, + middlewares: Optional[Sequence[Middleware]] = None, + ) -> Union[ + IncomingMessageHandlerFunc, + Callable[[IncomingMessageHandlerFunc], IncomingMessageHandlerFunc], + ]: + """Decorate fallback messages handler.""" + if self._default_message_handler: + raise ValueError("Default command handler already registered") + + def decorator( + handler_func: IncomingMessageHandlerFunc, # noqa: WPS442 + ) -> IncomingMessageHandlerFunc: + self._default_message_handler = DefaultMessageHandler( + handler_func=handler_func, + middlewares=self._middlewares + optional_sequence_to_list(middlewares), + ) + + return handler_func + + if callable(handler_func) and not middlewares: + return decorator(handler_func) + + return decorator + + def chat_created( + self, + handler_func: HandlerFunc[ChatCreatedEvent], + ) -> HandlerFunc[ChatCreatedEvent]: + """Decorate `chat_created` event handler.""" + + self._system_event(ChatCreatedEvent, handler_func) + + return handler_func + + def added_to_chat( + self, + handler_func: HandlerFunc[AddedToChatEvent], + ) -> HandlerFunc[AddedToChatEvent]: + """Decorate `added_to_chat` event handler.""" + + self._system_event(AddedToChatEvent, handler_func) + + return handler_func + + def deleted_from_chat( + self, + handler_func: HandlerFunc[DeletedFromChatEvent], + ) -> HandlerFunc[DeletedFromChatEvent]: + """Decorate `deleted_from_chat` event handler.""" + + self._system_event(DeletedFromChatEvent, handler_func) + + return handler_func + + def left_from_chat( + self, + handler_func: HandlerFunc[LeftFromChatEvent], + ) -> HandlerFunc[LeftFromChatEvent]: + """Decorate `left_from_chat` event handler.""" + + self._system_event(LeftFromChatEvent, handler_func) + + return handler_func + + def internal_bot_notification( + self, + handler_func: HandlerFunc[InternalBotNotificationEvent], + ) -> HandlerFunc[InternalBotNotificationEvent]: + """Decorate `internal_bot_notification` event handler.""" + + self._system_event(InternalBotNotificationEvent, handler_func) + + return handler_func + + def cts_login( + self, + handler_func: HandlerFunc[CTSLoginEvent], + ) -> HandlerFunc[CTSLoginEvent]: + """Decorate `cts_login` event handler.""" + + self._system_event(CTSLoginEvent, handler_func) + + return handler_func + + def cts_logout( + self, + handler_func: HandlerFunc[CTSLogoutEvent], + ) -> HandlerFunc[CTSLogoutEvent]: + """Decorate `cts_logout` event handler.""" + + self._system_event(CTSLogoutEvent, handler_func) + + return handler_func + + def smartapp_event( + self, + handler_func: HandlerFunc[SmartAppEvent], + ) -> HandlerFunc[SmartAppEvent]: + """Decorate `smartapp` event handler.""" + + self._system_event(SmartAppEvent, handler_func) + + return handler_func + + def insert_exception_middleware( + self, + exception_handlers: Optional[ExceptionHandlersDict] = None, + ) -> None: + exception_middleware = ExceptionMiddleware(exception_handlers or {}) + self._middlewares.insert(0, exception_middleware.dispatch) + + async def wait_active_tasks(self) -> None: + if self._tasks: + await asyncio.wait( + self._tasks, + return_when=asyncio.ALL_COMPLETED, + ) + + def _include_collector(self, other: "HandlerCollector") -> None: + # - Message handlers - + command_duplicates = set(self._user_commands_handlers) & set( + other._user_commands_handlers, + ) + if command_duplicates: + raise ValueError( + f"Handlers for {command_duplicates} commands already registered", + ) + + other_handlers = other._user_commands_handlers + for handler in other_handlers.values(): + handler.add_middlewares(self._middlewares) + + self._user_commands_handlers.update(other_handlers) + + # - Default message handler - + if self._default_message_handler and other._default_message_handler: + raise ValueError("Default message handler already registered") + + if not self._default_message_handler and other._default_message_handler: + other._default_message_handler.add_middlewares(self._middlewares) + self._default_message_handler = other._default_message_handler + + # - System events - + events_duplicates = set(self._system_events_handlers) & set( + other._system_events_handlers, + ) + if events_duplicates: + raise ValueError( + f"Handlers for {events_duplicates} events already registered", + ) + + self._system_events_handlers.update(other._system_events_handlers) + + def _get_incoming_message_handler( + self, + message: IncomingMessage, + ) -> Union[CommandHandler, DefaultMessageHandler, None]: + return self._get_command_handler(message.body) + + def _get_command_handler( + self, + command: str, + ) -> Union[CommandHandler, DefaultMessageHandler, None]: + handler: Optional[Union[CommandHandler, DefaultMessageHandler]] = None + + command_name = self._get_command_name(command) + if command_name: + handler = self._user_commands_handlers.get(command_name) + if handler: + logger.info(f"Found handler for command `{command_name}`") + return handler + + if self._default_message_handler: + self._log_default_handler_call(command_name) + return self._default_message_handler + + logger.warning(f"Handler for message text `{command}` not found") + return None + + def _get_system_event_handler_or_none( + self, + event: SystemEvent, + ) -> Optional[SystemEventHandlerFunc]: + event_cls = event.__class__ + + handler = self._system_events_handlers.get(event_cls) + self._log_system_event_handler_call(event_cls.__name__, handler) + + return handler + + def _get_command_name(self, body: str) -> Optional[str]: + if not body: + return None + + command_name = body.split(maxsplit=1)[0] + if self.VALID_COMMAND_NAME_RE.match(command_name): + return command_name + + return None + + def _build_command_handler( + self, + handler_func: IncomingMessageHandlerFunc, + visible: Union[bool, VisibleFunc], + description: Optional[str], + middlewares: List[Middleware], + ) -> CommandHandler: + if visible is True or callable(visible): + if not description: + raise ValueError("Description is required for visible command") + + return VisibleCommandHandler( + handler_func=handler_func, + visible=visible, + description=description, + middlewares=middlewares, + ) + + return HiddenCommandHandler( + handler_func=handler_func, + middlewares=middlewares, + ) + + def _system_event( + self, + event_cls_name: Type[BotCommand], + handler_func: SystemEventHandlerFunc, + ) -> SystemEventHandlerFunc: + if event_cls_name in self._system_events_handlers: + raise ValueError(f"Handler for {event_cls_name} already registered") + + self._system_events_handlers[event_cls_name] = handler_func + + return handler_func + + def _fill_contextvars(self, bot_command: BotCommand, bot: "Bot") -> None: + bot_var.set(bot) + bot_id_var.set(bot_command.bot.id) + + chat = getattr(bot_command, "chat", None) + if chat: + chat_id_var.set(chat.id) + + def _log_system_event_handler_call( + self, + event_cls_name: str, + handler: Optional[SystemEventHandlerFunc], + ) -> None: + if handler: + logger.info(f"Found handler for `{event_cls_name}`") + else: + logger.info(f"Handler for `{event_cls_name}` not found") + + def _log_default_handler_call(self, command_name: Optional[str]) -> None: + if command_name: + logger.info( + f"Handler for command `{command_name}` not found, " + "using default handler", + ) + else: + logger.info("No command found, using default handler") diff --git a/docs/src/development/collector/collector0/__init__.py b/botx/bot/middlewares/__init__.py similarity index 100% rename from docs/src/development/collector/collector0/__init__.py rename to botx/bot/middlewares/__init__.py diff --git a/botx/bot/middlewares/exception_middleware.py b/botx/bot/middlewares/exception_middleware.py new file mode 100644 index 00000000..ae1a0055 --- /dev/null +++ b/botx/bot/middlewares/exception_middleware.py @@ -0,0 +1,56 @@ +from typing import TYPE_CHECKING, Awaitable, Callable, Dict, Optional, Type + +from botx.bot.handler import IncomingMessageHandlerFunc +from botx.logger import logger +from botx.models.message.incoming_message import IncomingMessage + +if TYPE_CHECKING: # To avoid circular import + from botx.bot.bot import Bot + +ExceptionHandler = Callable[ + [IncomingMessage, "Bot", Exception], + Awaitable[None], +] +ExceptionHandlersDict = Dict[Type[Exception], ExceptionHandler] + + +class ExceptionMiddleware: + """Exception handling middleware.""" + + def __init__(self, exception_handlers: ExceptionHandlersDict) -> None: + self._exception_handlers = exception_handlers + + async def dispatch( + self, + message: IncomingMessage, + bot: "Bot", + call_next: IncomingMessageHandlerFunc, + ) -> None: + try: + await call_next(message, bot) + except Exception as message_handler_exc: + exception_handler = self._get_exception_handler(message_handler_exc) + if exception_handler is None: + exc_name = type(message_handler_exc).__name__ + logger.exception( + f"Uncaught exception {exc_name}:", + message_handler_exc, + ) + return + + try: # noqa: WPS505 + await exception_handler(message, bot, message_handler_exc) + except Exception as error_handler_exc: + exc_name = type(message_handler_exc).__name__ + logger.exception( + f"Uncaught exception {exc_name} in exception handler:", + error_handler_exc, + ) + + def _get_exception_handler(self, exc: Exception) -> Optional[ExceptionHandler]: + for exc_cls in type(exc).mro(): + handler = self._exception_handlers.get(exc_cls) + if handler: + return handler + + return None diff --git a/botx/bot/testing.py b/botx/bot/testing.py new file mode 100644 index 00000000..59427a27 --- /dev/null +++ b/botx/bot/testing.py @@ -0,0 +1,14 @@ +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from botx.bot.bot import Bot + + +@asynccontextmanager +async def lifespan_wrapper(bot: Bot) -> AsyncGenerator[Bot, None]: + await bot.startup() + + try: + yield bot + finally: + await bot.shutdown() diff --git a/botx/bots/__init__.py b/botx/bots/__init__.py deleted file mode 100644 index 39810ec5..00000000 --- a/botx/bots/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for Bot and it's components.""" diff --git a/botx/bots/bots.py b/botx/bots/bots.py deleted file mode 100644 index 35c4595b..00000000 --- a/botx/bots/bots.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Implementation for bot classes.""" - -import asyncio -from dataclasses import InitVar, field -from typing import Any, Callable, Dict, List -from weakref import WeakSet - -from loguru import logger -from pydantic.dataclasses import dataclass - -from botx import concurrency, exception_handlers, exceptions, shared, typing -from botx.bots.mixins import ( - clients, - collectors, - exceptions as exception_mixin, - lifespan, - middlewares, -) -from botx.clients.clients import async_client, sync_client as synchronous_client -from botx.collecting.collectors.collector import Collector -from botx.dependencies.models import Depends -from botx.middlewares.authorization import AuthorizationMiddleware -from botx.middlewares.exceptions import ExceptionMiddleware -from botx.models import credentials, datastructures, menu -from botx.models.messages.message import Message - - -@dataclass(config=shared.BotXDataclassConfig) -class Bot( # noqa: WPS215 - collectors.BotCollectingMixin, - clients.ClientsMixin, - lifespan.LifespanMixin, - middlewares.MiddlewareMixin, - exception_mixin.ExceptionHandlersMixin, -): - """Class that implements bot behaviour.""" - - dependencies: InitVar[List[Depends]] = field(default=None) - bot_accounts: List[credentials.BotXCredentials] = field(default_factory=list) - startup_events: List[typing.BotLifespanEvent] = field(default_factory=list) - shutdown_events: List[typing.BotLifespanEvent] = field(default_factory=list) - - client: async_client.AsyncClient = field(init=False) - sync_client: synchronous_client.Client = field(init=False) - collector: Collector = field(init=False) - exception_middleware: ExceptionMiddleware = field(init=False) - state: datastructures.State = field(init=False) - dependency_overrides: Dict[Callable, Callable] = field( - init=False, - default_factory=dict, - ) - - tasks: WeakSet = field(init=False, default_factory=WeakSet) - - async def __call__(self, message: 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))) - - def __post_init__(self, dependencies: List[Depends]) -> None: - """Initialize special fields. - - Arguments: - dependencies: initial background dependencies for inner collector. - """ - self.state = datastructures.State() - self.client = async_client.AsyncClient() - self.sync_client = synchronous_client.Client() - self.collector = Collector( - dependencies=dependencies, - dependency_overrides_provider=self, - ) - self.exception_middleware = ExceptionMiddleware(self.collector) - - 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, - ) - self.add_middleware(AuthorizationMiddleware) - - async def status(self, *args: Any, **kwargs: Any) -> menu.Status: - """Generate status object that could be return to BotX API on `/status`. - - Arguments: - args: additional positional arguments that will be passed to callable - status function. - kwargs: additional key arguments that will be passed to callable - status function. - - Returns: - Built status for returning to BotX API. - """ - 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, - *args, - **kwargs, - ) - 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. - """ - logger.bind(botx_bot=True, payload=message).debug("process incoming message") - msg = Message.from_dict(message, self) - - # raise UnknownBotError if not registered. - self.get_account_by_bot_id(msg.bot_id) - - await self(msg) - - async def authorize(self, *args: Any) -> None: - """Process auth for each bot account.""" - for account in self.bot_accounts: - try: - token = await self.get_token( - account.host, - account.bot_id, - account.signature, - ) - except (exceptions.BotXAPIError, exceptions.BotXConnectError) as exc: - logger.bind(botx_bot=True).warning( - f"Credentials `host - {account.host}, " # noqa: WPS305 - f"bot_id - {account.bot_id}` are invalid. " - f"Reason - {exc.message_template}", - ) - continue - - account.token = token diff --git a/botx/bots/mixins/__init__.py b/botx/bots/mixins/__init__.py deleted file mode 100644 index bffd8c59..00000000 --- a/botx/bots/mixins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for mixins for bot.""" diff --git a/botx/bots/mixins/clients.py b/botx/bots/mixins/clients.py deleted file mode 100644 index 506694a1..00000000 --- a/botx/bots/mixins/clients.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Define Mixin that combines API and sending mixins and will be used in Bot.""" -from typing import List -from uuid import UUID - -from botx.bots.mixins.requests.mixin import BotXRequestsMixin -from botx.bots.mixins.sending import SendingMixin -from botx.exceptions import TokenError, UnknownBotError -from botx.models.credentials import BotXCredentials - - -class ClientsMixin(SendingMixin, BotXRequestsMixin): - """Mixin that defines methods that are used for communicating with BotX API.""" - - bot_accounts: List[BotXCredentials] - - def get_account_by_bot_id(self, bot_id: UUID) -> BotXCredentials: - """Find BotCredentials in bot registered bot. - - Arguments: - bot_id: UUID of bot for which server should be searched. - - Returns: - Found instance of registered server. - - Raises: - UnknownBotError: raised if account was not found. - """ - for bot in self.bot_accounts: - if bot.bot_id == bot_id: - return bot - - raise UnknownBotError(bot_id=bot_id) - - def get_token_for_bot(self, bot_id: UUID) -> str: - """Search token in bot saved tokens by bot_id. - - Arguments: - bot_id: UUID of bot for which token should be searched. - - Returns: - Found bot's token. - - Raises: - TokenError: raised of there is not token for bot. - """ - account = self.get_account_by_bot_id(bot_id) - if account.token is not None: - return account.token - - raise TokenError(message_template="Token is empty") diff --git a/botx/bots/mixins/collecting/__init__.py b/botx/bots/mixins/collecting/__init__.py deleted file mode 100644 index a309329c..00000000 --- a/botx/bots/mixins/collecting/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for collecting behaviour mixins.""" diff --git a/botx/bots/mixins/collecting/add_handler.py b/botx/bots/mixins/collecting/add_handler.py deleted file mode 100644 index 4e61e862..00000000 --- a/botx/bots/mixins/collecting/add_handler.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Mixin that defines handler decorator.""" - -from typing import Callable, Optional, Sequence, Union - -from botx.collecting.collectors.collector import Collector -from botx.dependencies.models import Depends - - -class AddHandlerMixin: - """Mixin that defines handler decorator.""" - - collector: Collector - - 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[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. - """ - self.collector.add_handler( - body=body, - handler=handler, - name=name, - description=description, - full_description=full_description, - include_in_status=include_in_status, - dependencies=dependencies, - ) diff --git a/botx/bots/mixins/collecting/default.py b/botx/bots/mixins/collecting/default.py deleted file mode 100644 index 60899dac..00000000 --- a/botx/bots/mixins/collecting/default.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Mixin that defines handler decorator.""" - -from typing import Any, Callable, Optional, Sequence, Union - -from botx.collecting.collectors.collector import Collector -from botx.dependencies.models import Depends - - -class DefaultHandlerMixin: - """Mixin that defines handler decorator.""" - - collector: Collector - - 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] = False, - dependencies: Optional[Sequence[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, - ) diff --git a/botx/bots/mixins/collecting/handler.py b/botx/bots/mixins/collecting/handler.py deleted file mode 100644 index c793646d..00000000 --- a/botx/bots/mixins/collecting/handler.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Mixin that defines handler decorator.""" - -from typing import Any, Callable, Optional, Sequence, Union - -from botx.collecting.collectors.collector import Collector -from botx.dependencies.models import Depends - - -class HandlerMixin: - """Mixin that defines handler decorator.""" - - collector: Collector - - 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[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, - ) diff --git a/botx/bots/mixins/collecting/hidden.py b/botx/bots/mixins/collecting/hidden.py deleted file mode 100644 index bc38b489..00000000 --- a/botx/bots/mixins/collecting/hidden.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Mixin that defines handler decorator.""" - -from typing import Any, Callable, Optional, Sequence - -from botx.collecting.collectors.collector import Collector -from botx.dependencies.models import Depends - - -class HiddenHandlerMixin: - """Mixin that defines handler decorator.""" - - collector: Collector - - 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[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, - ) diff --git a/botx/bots/mixins/collecting/system_events.py b/botx/bots/mixins/collecting/system_events.py deleted file mode 100644 index 59c8b3af..00000000 --- a/botx/bots/mixins/collecting/system_events.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Mixin that defines handler decorator.""" - -from typing import Any, Callable, Optional, Sequence - -from botx.collecting.collectors.collector import Collector -from botx.dependencies.models import Depends -from botx.models.enums import SystemEvents - - -class SystemEventsHandlerMixin: # noqa: WPS214 - """Mixin that defines handler decorator.""" - - collector: Collector - - def system_event( # noqa: WPS211 - self, - handler: Optional[Callable] = None, - *, - event: Optional[SystemEvents] = None, - events: Optional[Sequence[SystemEvents]] = None, - name: Optional[str] = None, - dependencies: Optional[Sequence[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 chat_created( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[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 file_transfer( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[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 added_to_chat( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Register handler for `added_to_chat` 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.added_to_chat( - handler=handler, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) - - def deleted_from_chat( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Register handler for `deleted_from_chat` 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.deleted_from_chat( - handler=handler, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) - - def left_from_chat( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Register handler for `left_from_chat` 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.left_from_chat( - handler=handler, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) - - def cts_login( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Register handler for `cts_login` 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.cts_login( - handler=handler, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) - - def cts_logout( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Register handler for `cts_logout` 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.cts_logout( - handler=handler, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) - - def smartapp_event( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Register handler for `smartapp_event` 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.smartapp_event( - handler=handler, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) diff --git a/botx/bots/mixins/collectors.py b/botx/bots/mixins/collectors.py deleted file mode 100644 index 9cd14774..00000000 --- a/botx/bots/mixins/collectors.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Definition for bot's collecting component. - -All of this methods are just wrappers around inner collector. -""" - -from typing import Any, List, Optional, Sequence - -from botx.bots.mixins.collecting.add_handler import AddHandlerMixin -from botx.bots.mixins.collecting.default import DefaultHandlerMixin -from botx.bots.mixins.collecting.handler import HandlerMixin -from botx.bots.mixins.collecting.hidden import HiddenHandlerMixin -from botx.bots.mixins.collecting.system_events import SystemEventsHandlerMixin -from botx.collecting.collectors.collector import Collector -from botx.collecting.handlers.handler import Handler -from botx.dependencies import models as deps - - -class BotCollectingMixin( # noqa: WPS215 - AddHandlerMixin, - HandlerMixin, - DefaultHandlerMixin, - HiddenHandlerMixin, - SystemEventsHandlerMixin, -): - """Mixin that defines collector-like behaviour.""" - - collector: Collector - - @property - def handlers(self) -> List[Handler]: - """Get handlers registered on this bot. - - Returns: - Registered handlers of bot. - """ - return self.collector.handlers - - def include_collector( - self, - collector: Collector, - *, - dependencies: Optional[Sequence[deps.Depends]] = None, - ) -> None: - """Include handlers from collector into bot. - - Arguments: - collector: collector from which handlers should be copied. - dependencies: optional sequence of dependencies for handlers for this - collector. - """ - self.collector.include_collector(collector, dependencies=dependencies) - - def command_for(self, *args: Any) -> str: - """Find handler and build a command string using passed body query_params. - - Arguments: - args: sequence of elements where first element should be name of handler. - - Returns: - Command string. - """ - return self.collector.command_for(*args) - - def handler_for(self, name: str) -> Handler: - """Find handler in handlers of this bot. - - Find registered handler using using [botx.collector.Collector.handler_for] of - inner collector. - - Arguments: - name: name of handler that should be found. - - Returns: - Handler that was found by name. - """ - return self.collector.handler_for(name) diff --git a/botx/bots/mixins/exceptions.py b/botx/bots/mixins/exceptions.py deleted file mode 100644 index 9be3f9d1..00000000 --- a/botx/bots/mixins/exceptions.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Exception mixin for bot.""" - -from typing import Callable, Type - -from botx import typing -from botx.middlewares.exceptions import ExceptionMiddleware - -try: - from typing import Protocol # noqa: WPS433 -except ImportError: - from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401 - - -class ExceptionHandlersMixin: - """Mixin that defines functions for exception handlers registration.""" - - exception_middleware: ExceptionMiddleware - - 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: - """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 diff --git a/botx/bots/mixins/lifespan.py b/botx/bots/mixins/lifespan.py deleted file mode 100644 index 66db16c5..00000000 --- a/botx/bots/mixins/lifespan.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Lifespan mixin for bot.""" - -import asyncio -from typing import List -from weakref import WeakSet - -from botx.concurrency import callable_to_coroutine -from botx.typing import BotLifespanEvent - -try: - from typing import Protocol # noqa: WPS433 -except ImportError: - from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401 - - -class LifespanMixin: - """Lifespan events mixin for bot.""" - - #: currently running tasks. - tasks: WeakSet - - #: startup events. - startup_events: List[BotLifespanEvent] - - #: shutdown events. - shutdown_events: List[BotLifespanEvent] - - async def start(self) -> None: - """Run all startup events and other initialization stuff.""" - for event in self.startup_events: - await callable_to_coroutine(event, self) - - async def shutdown(self) -> None: - """Wait for all running handlers shutdown.""" - await self.wait_current_handlers() - - for event in self.shutdown_events: - await callable_to_coroutine(event, self) - - async def wait_current_handlers(self) -> None: - """Wait until all current tasks are done.""" - if self.tasks: - tasks, _ = await asyncio.wait( - self.tasks, - return_when=asyncio.ALL_COMPLETED, - ) - for task in tasks: - task.result() - - self.tasks.clear() diff --git a/botx/bots/mixins/middlewares.py b/botx/bots/mixins/middlewares.py deleted file mode 100644 index a8cb317d..00000000 --- a/botx/bots/mixins/middlewares.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Implementation for bot classes.""" - -from typing import Any, Callable, Type - -from botx import typing -from botx.middlewares.base import BaseMiddleware -from botx.middlewares.exceptions import ExceptionMiddleware - - -class MiddlewareMixin: - """Middleware mixin for bot.""" - - exception_middleware: ExceptionMiddleware - - 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 middleware(self, handler: typing.Executor) -> Callable: - """Register callable as middleware for request. - - Arguments: - handler: handler for middleware logic. - - Returns: - Passed `handler` callable. - """ - self.add_middleware(BaseMiddleware, dispatch=handler) - return handler diff --git a/botx/bots/mixins/requests/__init__.py b/botx/bots/mixins/requests/__init__.py deleted file mode 100644 index bf1d0b23..00000000 --- a/botx/bots/mixins/requests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Mixins for shortcuts for requests to BotX API.""" diff --git a/botx/bots/mixins/requests/bots.py b/botx/bots/mixins/requests/bots.py deleted file mode 100644 index 2bfc5987..00000000 --- a/botx/bots/mixins/requests/bots.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Mixin for shortcut for bots resource requests.""" - -from uuid import UUID - -from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol -from botx.clients.methods.v2.bots.token import Token - - -class BotsRequestsMixin: - """Mixin for shortcut for bots resource requests.""" - - async def get_token( - self: BotXMethodCallProtocol, - host: str, - bot_id: UUID, - signature: str, - ) -> str: - """Obtain token for bot. - - Arguments: - host: host on which request should be made. - bot_id: ID of bot for which token should be obtained. - signature: calculated signature of bot. - - Returns: - Obtained token. - """ - return await self.call_method( - Token(bot_id=bot_id, signature=signature), - host=host, - bot_id=bot_id, - ) diff --git a/botx/bots/mixins/requests/call_protocol.py b/botx/bots/mixins/requests/call_protocol.py deleted file mode 100644 index 54d7a296..00000000 --- a/botx/bots/mixins/requests/call_protocol.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Protocol for using in mixins to make mypy and typing happy.""" -from typing import Any, Optional -from uuid import UUID - -from botx.clients.methods.base import BotXMethod -from botx.models.messages.sending.credentials import SendingCredentials - -try: - from typing import Protocol # noqa: WPS433 -except ImportError: - from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401 - - -class BotXMethodCallProtocol(Protocol): - """Protocol for using in mixins to make mypy and typing happy.""" - - async def call_method( # noqa: WPS211 - self, - method: BotXMethod[Any], - *, - host: Optional[str] = None, - token: Optional[str] = None, - bot_id: Optional[UUID] = None, - credentials: Optional[SendingCredentials] = None, - ) -> Any: - """Send request to BotX API through bot's async client.""" diff --git a/botx/bots/mixins/requests/chats.py b/botx/bots/mixins/requests/chats.py deleted file mode 100644 index 4fa3bf0f..00000000 --- a/botx/bots/mixins/requests/chats.py +++ /dev/null @@ -1,226 +0,0 @@ -"""Mixin for shortcut for chats resource requests.""" - -from typing import List, Optional -from uuid import UUID - -from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol -from botx.clients.methods.v3.chats.add_admin_role import AddAdminRole -from botx.clients.methods.v3.chats.add_user import AddUser -from botx.clients.methods.v3.chats.chat_list import ChatList -from botx.clients.methods.v3.chats.create import Create -from botx.clients.methods.v3.chats.info import Info -from botx.clients.methods.v3.chats.pin_message import PinMessage -from botx.clients.methods.v3.chats.remove_user import RemoveUser -from botx.clients.methods.v3.chats.stealth_disable import StealthDisable -from botx.clients.methods.v3.chats.stealth_set import StealthSet -from botx.clients.methods.v3.chats.unpin_message import UnpinMessage -from botx.models.chats import BotChatList, ChatFromSearch -from botx.models.enums import ChatTypes -from botx.models.messages.sending.credentials import SendingCredentials - - -class ChatsRequestsMixin: - """Mixin for shortcut for chats resource requests.""" - - async def create_chat( # noqa: WPS211 - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - name: str, - members: List[UUID], - chat_type: ChatTypes, - shared_history: bool = False, - description: Optional[str] = None, - avatar: Optional[str] = None, - ) -> UUID: - """Create new chat. - - Arguments: - credentials: credentials for making request. - name: name of chat that should be created. - members: HUIDs of users that should be added into chat. - chat_type: chat type. - description: description of new chat. - avatar: logo image of chat. - shared_history: chat history is available to newcomers. - - Returns: - ID of created chat. - """ - return await self.call_method( - Create( - name=name, - description=description, - members=members, - avatar=avatar, - chat_type=chat_type, - shared_history=shared_history, - ), - credentials=credentials, - ) - - async def get_chat_info( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - chat_id: UUID, - ) -> ChatFromSearch: - """Return chat's info. - - Arguments: - credentials: credentials for making request. - chat_id: ID of chat for about which information should be retrieving. - - Returns: - Information about chat. - """ - return await self.call_method( - Info(group_chat_id=chat_id), - credentials=credentials, - ) - - async def get_bot_chats( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - ) -> BotChatList: - """Return list of bot's chats. - - Arguments: - credentials: credentials for making request. - - Returns: - List of bot's chats. - """ - return await self.call_method( - ChatList(), - credentials=credentials, - ) - - async def enable_stealth_mode( # noqa: WPS211 - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - chat_id: UUID, - disable_web: bool = False, - burn_in: Optional[int] = None, - expire_in: Optional[int] = None, - ) -> None: - """Enable stealth mode. - - Arguments: - credentials: credentials for making request. - chat_id: ID of chat where stealth should be enabled. - disable_web: should messages be shown in web. - burn_in: time of messages burning after read. - expire_in: time of messages burning after send. - """ - await self.call_method( - StealthSet( - group_chat_id=chat_id, - disable_web=disable_web, - burn_in=burn_in, - expire_in=expire_in, - ), - credentials=credentials, - ) - - async def disable_stealth_mode( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - chat_id: UUID, - ) -> None: - """Disable stealth mode. - - Arguments: - credentials: credentials for making request. - chat_id: ID of chat where stealth should be disabled. - """ - await self.call_method( - StealthDisable(group_chat_id=chat_id), - credentials=credentials, - ) - - async def add_users( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - chat_id: UUID, - user_huids: List[UUID], - ) -> None: - """Add users to chat. - - Arguments: - credentials: credentials for making request. - chat_id: ID of chat into which users should be added. - user_huids: IDs of users that should be added into chat. - """ - await self.call_method( - AddUser(group_chat_id=chat_id, user_huids=user_huids), - credentials=credentials, - ) - - async def remove_users( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - chat_id: UUID, - user_huids: List[UUID], - ) -> None: - """Remove users from chat. - - Arguments: - credentials: credentials for making request. - chat_id: ID of chat from which users should be removed. - user_huids: HUID of users that should be removed. - """ - await self.call_method( - RemoveUser(group_chat_id=chat_id, user_huids=user_huids), - credentials=credentials, - ) - - async def add_admin_roles( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - chat_id: UUID, - user_huids: List[UUID], - ) -> None: - """Promote users in chat to admins. - - Arguments: - credentials: credentials for making request. - chat_id: UUID of chat where action should be performed. - user_huids: HUIDs of users that should be promoted to admins. - """ - await self.call_method( - AddAdminRole(group_chat_id=chat_id, user_huids=user_huids), - credentials=credentials, - ) - - async def pin_message( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - chat_id: UUID, - sync_id: UUID, - ) -> None: - """Pin message in chat. - - Arguments: - credentials: credentials for making request. - chat_id: ID of chat where message should be pinned. - sync_id: ID of message that should be pinned. - """ - await self.call_method( - PinMessage(chat_id=chat_id, sync_id=sync_id), - credentials=credentials, - ) - - async def unpin_message( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - chat_id: UUID, - ) -> None: - """Unpin message in chat. - - Arguments: - credentials: credentials for making request. - chat_id: ID of chat where message should be unpinned. - """ - await self.call_method( - UnpinMessage(chat_id=chat_id), - credentials=credentials, - ) diff --git a/botx/bots/mixins/requests/command.py b/botx/bots/mixins/requests/command.py deleted file mode 100644 index 3f28cc12..00000000 --- a/botx/bots/mixins/requests/command.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Mixin for shortcut for command resource requests.""" - -from typing import cast -from uuid import UUID - -from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol -from botx.clients.methods.v3.command.command_result import CommandResult -from botx.clients.types.message_payload import ResultPayload -from botx.clients.types.options import ResultOptions -from botx.models.messages.sending.credentials import SendingCredentials -from botx.models.messages.sending.options import ResultPayloadOptions -from botx.models.messages.sending.payload import MessagePayload - - -class CommandRequestsMixin: - """Mixin for shortcut for command resource requests.""" - - async def send_command_result( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - payload: MessagePayload, - ) -> UUID: - """Send command result into chat. - - Arguments: - credentials: credentials for making request. - payload: payload for command result. - - Returns: - ID sent message. - """ - return await self.call_method( - CommandResult( - sync_id=cast(UUID, credentials.sync_id), - event_sync_id=credentials.message_id, - result=ResultPayload( - body=payload.text, - metadata=payload.metadata, - bubble=payload.markup.bubbles, - keyboard=payload.markup.keyboard, - mentions=payload.options.mentions, - opts=ResultPayloadOptions( - silent_response=payload.options.silent_response, - ), - ), - recipients=payload.options.recipients, - file=payload.file, - opts=ResultOptions( - stealth_mode=payload.options.stealth_mode, - notification_opts=payload.options.notifications, - ), - ), - credentials=credentials, - ) diff --git a/botx/bots/mixins/requests/events.py b/botx/bots/mixins/requests/events.py deleted file mode 100644 index a56ff789..00000000 --- a/botx/bots/mixins/requests/events.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Mixin for shortcut for events resource requests.""" -from typing import List, Optional -from uuid import UUID - -from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol -from botx.clients.methods.v3.events.edit_event import EditEvent -from botx.clients.methods.v3.events.reply_event import ReplyEvent -from botx.clients.types.message_payload import ResultPayload, UpdatePayload -from botx.clients.types.options import ResultOptions -from botx.models.entities import Mention -from botx.models.messages.message import File -from botx.models.messages.sending.credentials import SendingCredentials -from botx.models.messages.sending.markup import MessageMarkup -from botx.models.messages.sending.payload import UpdatePayload as SendingUpdatePayload - - -class EventsRequestsMixin: - """Mixin that defines methods for communicating with BotX API.""" - - async def update_message( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - update: SendingUpdatePayload, - ) -> None: - """Change message by it's event id. - - Arguments: - credentials: credentials that are used for sending message. *sync_id* is - required for credentials. - update: update of message content. - - Raises: - ValueError: raised if sync_id wasn't provided - """ - if not credentials.sync_id: - raise ValueError("sync_id is required for message update") - - await self.call_method( - EditEvent( - sync_id=credentials.sync_id, - result=UpdatePayload( - body=update.text, - metadata=update.metadata, - keyboard=update.keyboard, - bubble=update.bubbles, - mentions=update.mentions, - ), - file=update.file, - ), - credentials=credentials, - ) - - async def reply( # noqa: WPS211 - self: BotXMethodCallProtocol, - source_sync_id: UUID, - credentials: SendingCredentials, - text: str = "", - *, - file: Optional[File] = None, - markup: Optional[MessageMarkup] = None, - mentions: Optional[List[Mention]] = None, - opts: Optional[ResultOptions] = None, - ) -> None: - """Reply on message by source_sync_id. - - Arguments: - text: text of message. - source_sync_id: source message uuid that will replied. - file: attachment file. - markup: markup of sending message. - opts: options of sending message. - credentials: credentials for making request. - mentions: mentions in message. - - Raises: - ValueError: empty text. - """ - if not (text or file or mentions): - raise ValueError("text or file or mention required") - - await self.call_method( - ReplyEvent( - source_sync_id=source_sync_id, - result=ResultPayload( - body=text, - keyboard=markup.keyboard if markup else [], - bubble=markup.bubbles if markup else [], - mentions=mentions or [], - ), - file=file, - opts=opts or ResultOptions(), - ), - credentials=credentials, - ) diff --git a/botx/bots/mixins/requests/files.py b/botx/bots/mixins/requests/files.py deleted file mode 100644 index 50c1678e..00000000 --- a/botx/bots/mixins/requests/files.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Mixin for shortcut for files resource requests.""" - -from typing import Optional -from uuid import UUID - -from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol -from botx.clients.clients.async_client import AsyncClient -from botx.clients.methods.v3.files.download import DownloadFile -from botx.clients.methods.v3.files.upload import UploadFile -from botx.clients.types.upload_file import UploadingFileMeta -from botx.models.files import File, MetaFile -from botx.models.messages.sending.credentials import SendingCredentials - - -class FilesRequestsMixin: - """Mixin for shortcut for files resource requests.""" - - client: AsyncClient - - async def upload_file( # noqa: WPS211 - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - sending_file: File, - group_chat_id: UUID, - *, - duration: Optional[int] = None, - caption: Optional[str] = None, - ) -> MetaFile: - """Upload file to the chat. - - Arguments: - credentials: credentials for making request. - sending_file: file to upload. - group_chat_id: ID of the chat that accepts the file. - duration: duration of the voice or the video. - caption: file caption. - - Returns: - File metadata. - """ - return await self.call_method( - UploadFile( - group_chat_id=group_chat_id, - file=sending_file, - meta=UploadingFileMeta( - duration=duration, - caption=caption, - ), - ), - credentials=credentials, - ) - - async def download_file( # noqa: WPS211 - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - file_id: UUID, - group_chat_id: UUID, - *, - file_name: Optional[str] = None, - is_preview: bool = False, - ) -> File: - """Download file from the chat. - - Arguments: - credentials: credentials for making request. - file_id: ID of the file. - group_chat_id: ID of the chat that accepts the file. - file_name: file name to be assigned instead of default name. - is_preview: get preview or file. - - Returns: - Downloaded file. - """ - file = await self.call_method( - DownloadFile( - file_id=file_id, - group_chat_id=group_chat_id, - is_preview=is_preview, - ), - credentials=credentials, - ) - - if file_name: - ext = file.file_name.split(".", maxsplit=1)[1] - file.file_name = "{name}.{ext}".format(name=file_name, ext=ext) - - return file diff --git a/botx/bots/mixins/requests/internal_bot_notification.py b/botx/bots/mixins/requests/internal_bot_notification.py deleted file mode 100644 index 9a78f112..00000000 --- a/botx/bots/mixins/requests/internal_bot_notification.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Mixin for shortcut for internal bot notification resource requests.""" - -from typing import Any, Dict, List, Optional -from uuid import UUID - -from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol -from botx.clients.methods.v4.notifications.internal_bot_notification import ( - InternalBotNotification, -) -from botx.clients.types.message_payload import InternalBotNotificationPayload -from botx.models.messages.sending.credentials import SendingCredentials - - -class InternalBotNotificationRequestsMixin: - """Mixin for shortcut for internal bot notification resource requests.""" - - async def internal_bot_notification( # noqa: WPS211 - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - group_chat_id: UUID, - text: str, - sender: Optional[str] = None, - recipients: Optional[List[UUID]] = None, - opts: Optional[Dict[str, Any]] = None, - ) -> UUID: - """Send internal bot notifications into chat. - - Arguments: - credentials: credentials for making request. - group_chat_id: ID of chats into which message should be sent. - text: notification text. - sender: information about notification sender. - recipients: List of recipients' UUIDs (send to all if None) - opts: additional user-defined data to send - - Returns: - Sync ID of sent notification. - """ - return await self.call_method( - InternalBotNotification( - group_chat_id=group_chat_id, - recipients=recipients, - data=InternalBotNotificationPayload(message=text, sender=sender), - opts=opts or {}, - ), - credentials=credentials, - ) diff --git a/botx/bots/mixins/requests/mixin.py b/botx/bots/mixins/requests/mixin.py deleted file mode 100644 index 95d2015e..00000000 --- a/botx/bots/mixins/requests/mixin.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Definition for mixin that defines BotX API methods.""" -from typing import Optional, TypeVar, cast -from uuid import UUID - -from loguru import logger - -from botx.bots.mixins.requests import bots # noqa: WPS235 -from botx.bots.mixins.requests import ( - chats, - command, - events, - files, - internal_bot_notification, - notification, - smartapps, - stickers, - users, -) -from botx.clients.clients.async_client import AsyncClient -from botx.clients.methods.base import BotXMethod -from botx.models.credentials import BotXCredentials -from botx.models.messages.sending.credentials import SendingCredentials - -ResponseT = TypeVar("ResponseT") - -try: - from typing import Protocol # noqa: WPS433 -except ImportError: - from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401 - - -class CredentialsSearchProtocol(Protocol): - """Protocol for search token in local credentials.""" - - def get_token_for_bot(self, bot_id: UUID) -> str: - """Search token in local credentials.""" - - def get_account_by_bot_id(self, bot_id: UUID) -> BotXCredentials: - """Get bot credentials by bot id.""" - - -# A lot of base classes since it's mixin for all shorthands for BotX API requests -class BotXRequestsMixin( # noqa: WPS215 - bots.BotsRequestsMixin, - chats.ChatsRequestsMixin, - command.CommandRequestsMixin, - events.EventsRequestsMixin, - notification.NotificationRequestsMixin, - users.UsersRequestsMixin, - internal_bot_notification.InternalBotNotificationRequestsMixin, - files.FilesRequestsMixin, - smartapps.SmartAppMixin, - stickers.StickersMixin, -): - """Mixin that defines methods for communicating with BotX API.""" - - client: AsyncClient - - # TODO: remove SendingCredential from client module. - async def call_method( # noqa: WPS211 - self, - method: BotXMethod[ResponseT], - *, - host: Optional[str] = None, - token: Optional[str] = None, - bot_id: Optional[UUID] = None, - credentials: Optional[SendingCredentials] = None, - ) -> ResponseT: - """Call method with async client. - - Arguments: - method: method that should be user for request. - host: host where request should be sent. - token: token for method. - bot_id: ID of bot that send request. - credentials: credentials for making request. - - Returns: - Response for method. - """ - if credentials is not None: - debug_bot_id = credentials.bot_id - host = cast(str, credentials.host) - bot_id = cast(UUID, credentials.bot_id) - method.configure( - host=host, - token=cast(CredentialsSearchProtocol, self).get_token_for_bot(bot_id), - ) - else: - debug_bot_id = bot_id - method.configure(host=host or method.host, token=token or method.token) - - request = self.client.build_request(method) - - logger.bind( - botx_client=True, - payload=request.dict(exclude={"expected_type"}), - ).debug( - "send {0} request to bot {1}", - method.__repr_name__(), # noqa: WPS609 - debug_bot_id, - ) - - response = await self.client.execute(request) - return await self.client.process_response(method, response) diff --git a/botx/bots/mixins/requests/notification.py b/botx/bots/mixins/requests/notification.py deleted file mode 100644 index a4dfe1e9..00000000 --- a/botx/bots/mixins/requests/notification.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Mixin for shortcut for notification resource requests.""" - -from typing import Optional, Sequence -from uuid import UUID - -from botx import converters -from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol -from botx.clients.methods.v3.notification.direct_notification import NotificationDirect -from botx.clients.methods.v3.notification.notification import Notification -from botx.clients.types.message_payload import ResultPayload -from botx.clients.types.options import ResultOptions -from botx.models.messages.sending.credentials import SendingCredentials -from botx.models.messages.sending.options import ResultPayloadOptions -from botx.models.messages.sending.payload import MessagePayload - - -class NotificationRequestsMixin: - """Mixin for shortcut for notification resource requests.""" - - async def send_notification( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - payload: MessagePayload, - group_chat_ids: Optional[Sequence[UUID]] = None, - ) -> None: - """Send notifications into chat. - - Arguments: - credentials: credentials for making request. - payload: payload for notification. - group_chat_ids: IDS of chats into which message should be sent. - """ - if group_chat_ids is not None: - chat_ids = converters.optional_sequence_to_list(group_chat_ids) - elif credentials.chat_id: - chat_ids = [credentials.chat_id] - else: - chat_ids = [] - - await self.call_method( - Notification( - group_chat_ids=chat_ids, - result=ResultPayload( - body=payload.text, - metadata=payload.metadata, - bubble=payload.markup.bubbles, - keyboard=payload.markup.keyboard, - mentions=payload.options.mentions, - opts=ResultPayloadOptions( - silent_response=payload.options.silent_response, - ), - ), - recipients=payload.options.recipients, - file=payload.file, - opts=ResultOptions( - stealth_mode=payload.options.stealth_mode, - raw_mentions=payload.options.raw_mentions, - notification_opts=payload.options.notifications, - ), - ), - credentials=credentials, - ) - - async def send_direct_notification( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - payload: MessagePayload, - ) -> UUID: - """Send notification into chat. - - Arguments: - credentials: credentials for making request. - payload: payload for notification. - - Returns: - ID sent message. - - Raises: - ValueError: raised if chat_id wasn't provided - """ - if not credentials.chat_id: - raise ValueError("chat_id is required to send direct notification") - - return await self.call_method( - NotificationDirect( - group_chat_id=credentials.chat_id, - event_sync_id=credentials.message_id, - result=ResultPayload( - body=payload.text, - metadata=payload.metadata, - bubble=payload.markup.bubbles, - keyboard=payload.markup.keyboard, - mentions=payload.options.mentions, - opts=ResultPayloadOptions( - silent_response=payload.options.silent_response, - ), - ), - recipients=payload.options.recipients, - file=payload.file, - opts=ResultOptions( - stealth_mode=payload.options.stealth_mode, - raw_mentions=payload.options.raw_mentions, - notification_opts=payload.options.notifications, - ), - ), - credentials=credentials, - ) diff --git a/botx/bots/mixins/requests/smartapps.py b/botx/bots/mixins/requests/smartapps.py deleted file mode 100644 index 69e2ac27..00000000 --- a/botx/bots/mixins/requests/smartapps.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Mixin for shortcut for smartapp.""" - -from uuid import UUID - -from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol -from botx.clients.methods.v3.smartapps.smartapp_event import SmartAppEvent -from botx.clients.methods.v3.smartapps.smartapp_notification import SmartAppNotification -from botx.models.messages.sending.credentials import SendingCredentials -from botx.models.smartapps import SendingSmartAppEvent, SendingSmartAppNotification - - -class SmartAppMixin: - """Mixin for shortcut for smartapp methods.""" - - async def send_smartapp_event( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - smartapp_event: SendingSmartAppEvent, - ) -> UUID: - """Send smartapp event into chat. - - Arguments: - credentials: credentials for making request. - smartapp_event: SmartpApp event. - - Returns: - ID sent message. - """ - return await self.call_method( - SmartAppEvent( - ref=smartapp_event.ref, - smartapp_id=smartapp_event.smartapp_id, - data=smartapp_event.data, - opts=smartapp_event.opts, - smartapp_api_version=smartapp_event.smartapp_api_version, - group_chat_id=smartapp_event.group_chat_id, - files=smartapp_event.files, - async_files=smartapp_event.async_files, - ), - credentials=credentials, - ) - - async def send_smartapp_notification( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - smartapp_notification: SendingSmartAppNotification, - ) -> None: - """Send smartapp notification into chat. - - Arguments: - credentials: credentials for making request. - smartapp_notification: Smartapp notification. - """ - await self.call_method( - SmartAppNotification( - group_chat_id=smartapp_notification.group_chat_id, - smartapp_counter=smartapp_notification.smartapp_counter, - opts=smartapp_notification.opts, - smartapp_api_version=smartapp_notification.smartapp_api_version, - ), - credentials=credentials, - ) diff --git a/botx/bots/mixins/requests/stickers.py b/botx/bots/mixins/requests/stickers.py deleted file mode 100644 index 8ca02581..00000000 --- a/botx/bots/mixins/requests/stickers.py +++ /dev/null @@ -1,199 +0,0 @@ -"""Mixin for shortcut for users resource requests.""" - -from typing import List, Optional, Tuple -from uuid import UUID - -from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol -from botx.clients.methods.v3.stickers.add_sticker import AddSticker -from botx.clients.methods.v3.stickers.create_sticker_pack import CreateStickerPack -from botx.clients.methods.v3.stickers.delete_sticker import DeleteSticker -from botx.clients.methods.v3.stickers.delete_sticker_pack import DeleteStickerPack -from botx.clients.methods.v3.stickers.edit_sticker_pack import EditStickerPack -from botx.clients.methods.v3.stickers.sticker import GetSticker -from botx.clients.methods.v3.stickers.sticker_pack import GetStickerPack -from botx.clients.methods.v3.stickers.sticker_pack_list import GetStickerPackList -from botx.models.messages.sending.credentials import SendingCredentials -from botx.models.stickers import ( - Sticker, - StickerFromPack, - StickerPack, - StickerPackList, - StickerPackPreview, -) - - -class StickersMixin: # noqa: WPS214 - """Mixin for shortcut for users resource requests.""" - - async def get_sticker_pack_list( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - user_huid: Optional[UUID] = None, - limit: int = 1, - after: Optional[str] = None, - ) -> Tuple[List[StickerPackPreview], Optional[str]]: - """Get sticker pack list. - - Arguments: - credentials: credentials for making request. - user_huid: author HUID. - limit: returning value count. - after: cursor hash for pagination. - - Returns: - Sticker packs list and cursor. - """ - response: StickerPackList = await self.call_method( - GetStickerPackList(user_huid=user_huid, limit=limit, after=after), - credentials=credentials, - ) - - return response.packs, response.pagination.after - - async def get_sticker_pack( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - pack_id: UUID, - ) -> StickerPack: - """Get sticker pack. - - Arguments: - credentials: credentials for making request. - pack_id: sticker pack ID. - - Returns: - StickerPack entity. - """ - return await self.call_method( - GetStickerPack(pack_id=pack_id), - credentials=credentials, - ) - - async def get_sticker_from_pack( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - pack_id: UUID, - sticker_id: UUID, - ) -> StickerFromPack: - """Get sticker from pack. - - Arguments: - credentials: credentials for making request. - pack_id: sticker pack ID. - sticker_id: sticker ID. - - Returns: - StickerFromPack entity. - """ - return await self.call_method( - GetSticker(pack_id=pack_id, sticker_id=sticker_id), - credentials=credentials, - ) - - async def create_sticker_pack( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - name: str, - user_huid: UUID, - ) -> StickerPack: - """Create sticker pack. - - Arguments: - credentials: credentials for making request. - name: sticker pack name. - user_huid: author HUID. - - Returns: - StickerPackPreview entity. - """ - return await self.call_method( - CreateStickerPack(name=name, user_huid=user_huid), - credentials=credentials, - ) - - async def add_sticker( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - pack_id: UUID, - emoji: str, - image: str, - ) -> Sticker: - """Add sticker. - - Arguments: - credentials: credentials for making request. - pack_id: sticker pack ID. - emoji: emoji that the sticker will be associated with. - image: sticker image. - - Returns: - Sticker entity. - """ - return await self.call_method( - AddSticker(pack_id=pack_id, emoji=emoji, image=image), - credentials=credentials, - ) - - async def edit_sticker_pack( # noqa: WPS211 - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - pack_id: UUID, - name: str, - preview: Optional[UUID] = None, - stickers_order: Optional[List[UUID]] = None, - ) -> StickerPack: - """Edit sticker pack. - - Arguments: - credentials: credentials for making request. - pack_id: sticker pack ID. - name: sticker pack name. - preview: sticker pack preview. - stickers_order: stickers order in sticker pack. - - Returns: - StickerPack entity. - """ - return await self.call_method( - EditStickerPack( - pack_id=pack_id, - name=name, - preview=preview, - stickers_order=stickers_order, - ), - credentials=credentials, - ) - - async def delete_sticker_pack( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - pack_id: UUID, - ) -> None: - """Delete sticker pack. - - Arguments: - credentials: credentials for making request. - pack_id: sticker pack ID. - """ - await self.call_method( - DeleteStickerPack(pack_id=pack_id), - credentials=credentials, - ) - - async def delete_sticker( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - pack_id: UUID, - sticker_id: UUID, - ) -> None: - """Delete sticker. - - Arguments: - credentials: credentials for making request. - pack_id: sticker pack ID. - sticker_id: sticker ID. - """ - await self.call_method( - DeleteSticker(pack_id=pack_id, sticker_id=sticker_id), - credentials=credentials, - ) diff --git a/botx/bots/mixins/requests/users.py b/botx/bots/mixins/requests/users.py deleted file mode 100644 index 8cc24569..00000000 --- a/botx/bots/mixins/requests/users.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Mixin for shortcut for users resource requests.""" - -from typing import Optional, Tuple -from uuid import UUID - -from botx.bots.mixins.requests.call_protocol import BotXMethodCallProtocol -from botx.clients.methods.v3.users.by_email import ByEmail -from botx.clients.methods.v3.users.by_huid import ByHUID -from botx.clients.methods.v3.users.by_login import ByLogin -from botx.models.messages.sending.credentials import SendingCredentials -from botx.models.users import UserFromSearch - - -class UsersRequestsMixin: - """Mixin for shortcut for users resource requests.""" - - async def search_user( - self: BotXMethodCallProtocol, - credentials: SendingCredentials, - *, - user_huid: Optional[UUID] = None, - email: Optional[str] = None, - ad: Optional[Tuple[str, str]] = None, - ) -> UserFromSearch: - """Search user by one of provided params for search. - - Arguments: - credentials: credentials for making request. - user_huid: HUID of user. - email: email of user. - ad: AD login and domain of user. - - Returns: - Information about user. - - Raises: - ValueError: raised if none of provided params were filled. - """ - if user_huid is not None: - return await self.call_method( - ByHUID(user_huid=user_huid), - credentials=credentials, - ) - elif email is not None: - return await self.call_method(ByEmail(email=email), credentials=credentials) - elif ad is not None: - return await self.call_method( - ByLogin(ad_login=ad[0], ad_domain=ad[1]), - credentials=credentials, - ) - - raise ValueError("one of user_huid, email or ad query_params should be filled") diff --git a/botx/bots/mixins/sending.py b/botx/bots/mixins/sending.py deleted file mode 100644 index da2d93ff..00000000 --- a/botx/bots/mixins/sending.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Definition for mixin that defines helpers for sending message.""" - -from typing import Any, BinaryIO, Dict, Optional, TextIO, Union -from uuid import UUID - -from botx.models.files import File -from botx.models.messages.message import Message -from botx.models.messages.sending.credentials import SendingCredentials -from botx.models.messages.sending.markup import MessageMarkup -from botx.models.messages.sending.message import SendingMessage -from botx.models.messages.sending.options import MessageOptions -from botx.models.messages.sending.payload import MessagePayload, UpdatePayload - -try: - from typing import Protocol # noqa: WPS433 -except ImportError: - from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401 - - -class ResultSendProtocol(Protocol): - """Protocol for object that can create new or update message.""" - - async def send_command_result( - self, - credentials: SendingCredentials, - payload: MessagePayload, - ) -> UUID: - """Send command result.""" - - async def send_direct_notification( - self, - credentials: SendingCredentials, - payload: MessagePayload, - ) -> UUID: - """Send notification.""" - - async def update_message( - self, - credentials: SendingCredentials, - update: UpdatePayload, - ) -> None: - """Update existing message.""" - - -class MessageSendProtocol(Protocol): - """Protocol for object that can send complex message.""" - - async def send(self, message: SendingMessage) -> UUID: - """Send message.""" - - -class SendingMixin: - """Mixin that defines helpers for sending messages.""" - - async def send_message( # noqa: WPS211 - self: MessageSendProtocol, - text: str, - credentials: SendingCredentials, - *, - file: Optional[Union[BinaryIO, TextIO]] = None, - markup: Optional[MessageMarkup] = None, - options: Optional[MessageOptions] = None, - ) -> 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` of sent event. - """ - message = SendingMessage( - text=text, - markup=markup, - options=options, - credentials=credentials, - ) - if file: - message.add_file(file) - - return await self.send(message) - - async def send( - self: ResultSendProtocol, - message: SendingMessage, - *, - update: bool = False, - ) -> UUID: - """Send message as direct notification to chat and get it id. - - Arguments: - message: message that should be sent to chat. - update: if True then check, that `message_id` was set in credentials and - update existing message with this ID. - - Returns: - `UUID` of sent event. - """ - if message.credentials.message_id is not None and update: - await self.update_message( - message.credentials.copy( - update={"sync_id": message.credentials.message_id}, - ), - UpdatePayload.from_sending_payload(message.payload), - ) - return message.credentials.message_id - - return await self.send_direct_notification(message.credentials, message.payload) - - async def answer_message( # noqa: WPS211 - self: MessageSendProtocol, - text: str, - message: Message, - *, - metadata: Optional[Dict[str, Any]] = None, - file: Optional[Union[BinaryIO, TextIO, File]] = None, - markup: Optional[MessageMarkup] = None, - options: Optional[MessageOptions] = None, - embed_mentions: bool = False, - ) -> 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. - metadata: dict of message metadata. - embed_mentions: get mentions from text. - - Returns: - `UUID` of sent event. - """ - sending_message = SendingMessage( - text=text, - credentials=message.credentials, - markup=markup, - options=options, - metadata=metadata, - embed_mentions=embed_mentions, - ) - if file: - sending_message.add_file(file) - - return await self.send(sending_message) - - async def send_file( - self: MessageSendProtocol, - file: Union[TextIO, BinaryIO, File], - credentials: SendingCredentials, - filename: Optional[str] = None, - ) -> 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. - """ - message = SendingMessage(credentials=credentials) - message.add_file(file, filename) - return await self.send(message) diff --git a/docs/src/development/dependencies_injection/__init__.py b/botx/client/__init__.py similarity index 100% rename from docs/src/development/dependencies_injection/__init__.py rename to botx/client/__init__.py diff --git a/botx/client/authorized_botx_method.py b/botx/client/authorized_botx_method.py new file mode 100644 index 00000000..e574617e --- /dev/null +++ b/botx/client/authorized_botx_method.py @@ -0,0 +1,50 @@ +from contextlib import asynccontextmanager +from typing import Any, AsyncGenerator, Dict + +import httpx + +from botx.client.botx_method import BotXMethod, response_exception_thrower +from botx.client.exceptions.common import InvalidBotAccountError +from botx.client.get_token import get_token + + +class AuthorizedBotXMethod(BotXMethod): + status_handlers = {401: response_exception_thrower(InvalidBotAccountError)} + + async def _botx_method_call( + self, + *args: Any, + **kwargs: Any, + ) -> httpx.Response: + headers = kwargs.pop("headers", {}) + await self._add_authorization_headers(headers) + + return await super()._botx_method_call(*args, headers=headers, **kwargs) + + @asynccontextmanager + async def _botx_method_stream( + self, + *args: Any, + **kwargs: Any, + ) -> AsyncGenerator[httpx.Response, None]: + headers = kwargs.pop("headers", {}) + await self._add_authorization_headers(headers) + + async with super()._botx_method_stream( + *args, + headers=headers, + **kwargs, + ) as response: + yield response + + async def _add_authorization_headers(self, headers: Dict[str, Any]) -> None: + token = self._bot_accounts_storage.get_token_or_none(self._bot_id) + if not token: + token = await get_token( + self._bot_id, + self._httpx_client, + self._bot_accounts_storage, + ) + self._bot_accounts_storage.set_token(self._bot_id, token) + + headers.update({"Authorization": f"Bearer {token}"}) diff --git a/docs/src/development/first_steps/__init__.py b/botx/client/bots_api/__init__.py similarity index 100% rename from docs/src/development/first_steps/__init__.py rename to botx/client/bots_api/__init__.py diff --git a/botx/client/bots_api/get_token.py b/botx/client/bots_api/get_token.py new file mode 100644 index 00000000..2c4c2e5a --- /dev/null +++ b/botx/client/bots_api/get_token.py @@ -0,0 +1,42 @@ +from typing import Literal + +from botx.client.botx_method import BotXMethod, response_exception_thrower +from botx.client.exceptions.common import InvalidBotAccountError +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPIGetTokenRequestPayload(UnverifiedPayloadBaseModel): + signature: str + + @classmethod + def from_domain(cls, signature: str) -> "BotXAPIGetTokenRequestPayload": + return cls(signature=signature) + + +class BotXAPIGetTokenResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: str + + def to_domain(self) -> str: + return self.result + + +class GetTokenMethod(BotXMethod): + status_handlers = {401: response_exception_thrower(InvalidBotAccountError)} + + async def execute( + self, + payload: BotXAPIGetTokenRequestPayload, + ) -> BotXAPIGetTokenResponsePayload: + path = f"/api/v2/botx/bots/{self._bot_id}/token" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + params=payload.jsonable_dict(), + ) + + return self._verify_and_extract_api_model( + BotXAPIGetTokenResponsePayload, + response, + ) diff --git a/botx/client/botx_method.py b/botx/client/botx_method.py new file mode 100644 index 00000000..53a5e173 --- /dev/null +++ b/botx/client/botx_method.py @@ -0,0 +1,201 @@ +import json +from contextlib import asynccontextmanager +from json.decoder import JSONDecodeError +from typing import ( + Any, + AsyncGenerator, + Awaitable, + Callable, + Mapping, + NoReturn, + Optional, + Type, + TypeVar, +) +from urllib.parse import urljoin +from uuid import UUID + +import httpx +from mypy_extensions import Arg +from pydantic import ValidationError, parse_obj_as + +from botx.bot.bot_accounts_storage import BotAccountsStorage +from botx.bot.callbacks_manager import CallbacksManager +from botx.client.exceptions.base import BaseClientError +from botx.client.exceptions.callbacks import BotXMethodFailedCallbackReceivedError +from botx.client.exceptions.http import ( + InvalidBotXResponsePayloadError, + InvalidBotXStatusCodeError, +) +from botx.logger import logger, pformat_jsonable_obj, trim_file_data_in_outgoing_json +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.method_callbacks import BotAPIMethodFailedCallback, BotXMethodCallback + +StatusHandler = Callable[[Arg(httpx.Response, "response")], NoReturn] # noqa: F821 +StatusHandlers = Mapping[int, StatusHandler] + +CallbackExceptionHandler = Callable[ + [Arg(BotAPIMethodFailedCallback, "callback")], # noqa: F821 + NoReturn, +] +ErrorCallbackHandlers = Mapping[str, CallbackExceptionHandler] +TBotXAPIModel = TypeVar("TBotXAPIModel", bound=VerifiedPayloadBaseModel) + + +def response_exception_thrower( + exc: Type[BaseClientError], + comment: Optional[str] = None, +) -> StatusHandler: + def factory(response: httpx.Response) -> NoReturn: + raise exc.from_response(response, comment) + + return factory + + +def callback_exception_thrower( + exc: Type[BaseClientError], + comment: Optional[str] = None, +) -> CallbackExceptionHandler: # noqa: F821 + def factory(callback: BotAPIMethodFailedCallback) -> NoReturn: + raise exc.from_callback(callback, comment) + + return factory + + +class BotXMethod: + status_handlers: StatusHandlers = {} + error_callback_handlers: ErrorCallbackHandlers = {} + + def __init__( + self, + sender_bot_id: UUID, + httpx_client: httpx.AsyncClient, + bot_accounts_storage: BotAccountsStorage, + callbacks_manager: Optional[CallbacksManager] = None, + ) -> None: + self._bot_id = sender_bot_id + self._httpx_client = httpx_client + self._bot_accounts_storage = bot_accounts_storage + self._callbacks_manager = callbacks_manager + + # For MyPy checks + execute: Callable[..., Awaitable[Any]] + + async def execute(self, *args: Any, **kwargs: Any) -> Any: # type: ignore + raise NotImplementedError("You should define `execute` method") + + def _build_url(self, path: str) -> str: + host = self._bot_accounts_storage.get_host(self._bot_id) + return urljoin(f"https://{host}", path) + + def _verify_and_extract_api_model( + self, + model_cls: Type[TBotXAPIModel], + response: httpx.Response, + ) -> TBotXAPIModel: + try: + raw_model = json.loads(response.content) + except JSONDecodeError as decoding_exc: + raise InvalidBotXResponsePayloadError(response) from decoding_exc + + logger.opt(lazy=True).debug( + "Got response from BotX: {json}", + json=lambda: pformat_jsonable_obj(raw_model), + ) + + try: + api_model = parse_obj_as(model_cls, raw_model) + except ValidationError as validation_exc: + raise InvalidBotXResponsePayloadError(response) from validation_exc + + return api_model + + async def _botx_method_call(self, *args: Any, **kwargs: Any) -> httpx.Response: + self._log_outgoing_request(*args, **kwargs) + + response = await self._httpx_client.request(*args, **kwargs) + await self._raise_for_status(response) + + return response + + @asynccontextmanager + async def _botx_method_stream( + self, + *args: Any, + **kwargs: Any, + ) -> AsyncGenerator[httpx.Response, None]: + self._log_outgoing_request(*args, **kwargs) + + async with self._httpx_client.stream(*args, **kwargs) as response: + await self._raise_for_status(response) + yield response + + async def _raise_for_status(self, response: httpx.Response) -> None: + handler = self.status_handlers.get(response.status_code) + if handler: + if not response.is_closed: + await response.aread() + + handler(response) # Handler should raise an exception + + try: + response.raise_for_status() + except httpx.HTTPStatusError as exc: + if not response.is_closed: + await response.aread() + + raise InvalidBotXStatusCodeError(exc.response) + + async def _process_callback( + self, + sync_id: UUID, + wait_callback: bool, + callback_timeout: Optional[int], + ) -> Optional[BotXMethodCallback]: + assert ( + self._callbacks_manager is not None + ), "CallbackManager hasn't been passed to this method" + + self._callbacks_manager.create_botx_method_callback(sync_id) + + if not wait_callback: + return None + + callback = await self._callbacks_manager.wait_botx_method_callback( + sync_id, + callback_timeout, + ) + + if callback.status == "error": + error_handler = self.error_callback_handlers.get(callback.reason) + if not error_handler: + raise BotXMethodFailedCallbackReceivedError(callback) + + error_handler(callback) # Handler should raise an exception + + return callback + + def _log_outgoing_request( + self, + *args: Any, + **kwargs: Any, + ) -> None: + method, url = args + query_params = kwargs.get("params") + json_body = kwargs.get("json") + + log_template = "Performing request to BotX:\n{method} {url}" + if query_params: + log_template += "\nquery: {params}" + if json_body is not None: + log_template += "\njson: {json}" + + logger.opt(lazy=True).debug( + log_template, + method=lambda: method, # If `lazy` enabled, all kwargs should be callable + url=lambda: url, # If `lazy` enabled, all kwargs should be callable + params=lambda: pformat_jsonable_obj(query_params), + json=lambda: pformat_jsonable_obj( + trim_file_data_in_outgoing_json(json_body), + ), + ) diff --git a/docs/src/development/handling_errors/__init__.py b/botx/client/chats_api/__init__.py similarity index 100% rename from docs/src/development/handling_errors/__init__.py rename to botx/client/chats_api/__init__.py diff --git a/botx/client/chats_api/add_admin.py b/botx/client/chats_api/add_admin.py new file mode 100644 index 00000000..a582598b --- /dev/null +++ b/botx/client/chats_api/add_admin.py @@ -0,0 +1,71 @@ +from typing import List, Literal, NoReturn +from uuid import UUID + +import httpx + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.chats import ( + CantUpdatePersonalChatError, + InvalidUsersListError, +) +from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError +from botx.client.exceptions.http import InvalidBotXStatusCodeError +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPIAddAdminRequestPayload(UnverifiedPayloadBaseModel): + group_chat_id: UUID + user_huids: List[UUID] + + @classmethod + def from_domain( + cls, + chat_id: UUID, + huids: List[UUID], + ) -> "BotXAPIAddAdminRequestPayload": + return cls(group_chat_id=chat_id, user_huids=huids) + + +class BotXAPIAddAdminResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +def bad_request_error_handler(response: httpx.Response) -> NoReturn: + reason = response.json().get("reason") + + if reason == "chat_members_not_modifiable": + raise CantUpdatePersonalChatError.from_response( + response, + "Personal chat couldn't have admins", + ) + elif reason == "admins_not_changed": + raise InvalidUsersListError.from_response( + response, + "Specified users are already admins or missing from chat", + ) + + raise InvalidBotXStatusCodeError(response) + + +class AddAdminMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 400: bad_request_error_handler, + 403: response_exception_thrower(PermissionDeniedError), + 404: response_exception_thrower(ChatNotFoundError), + } + + async def execute( + self, + payload: BotXAPIAddAdminRequestPayload, + ) -> None: + path = "/api/v3/botx/chats/add_admin" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + self._verify_and_extract_api_model(BotXAPIAddAdminResponsePayload, response) diff --git a/botx/client/chats_api/add_user.py b/botx/client/chats_api/add_user.py new file mode 100644 index 00000000..bd39bcf8 --- /dev/null +++ b/botx/client/chats_api/add_user.py @@ -0,0 +1,48 @@ +from typing import List, Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPIAddUserRequestPayload(UnverifiedPayloadBaseModel): + group_chat_id: UUID + user_huids: List[UUID] + + @classmethod + def from_domain( + cls, + chat_id: UUID, + huids: List[UUID], + ) -> "BotXAPIAddUserRequestPayload": + return cls(group_chat_id=chat_id, user_huids=huids) + + +class BotXAPIAddUserResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class AddUserMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 403: response_exception_thrower(PermissionDeniedError), + 404: response_exception_thrower(ChatNotFoundError), + } + + async def execute( + self, + payload: BotXAPIAddUserRequestPayload, + ) -> None: + path = "/api/v3/botx/chats/add_user" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + self._verify_and_extract_api_model( + BotXAPIAddUserResponsePayload, + response, + ) diff --git a/botx/client/chats_api/chat_info.py b/botx/client/chats_api/chat_info.py new file mode 100644 index 00000000..46135d51 --- /dev/null +++ b/botx/client/chats_api/chat_info.py @@ -0,0 +1,88 @@ +from datetime import datetime as dt +from typing import List, Literal, Optional +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.common import ChatNotFoundError +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.chats import ChatInfo, ChatInfoMember +from botx.models.enums import ( + APIChatTypes, + APIUserKinds, + convert_chat_type_to_domain, + convert_user_kind_to_domain, +) + + +class BotXAPIChatInfoRequestPayload(UnverifiedPayloadBaseModel): + group_chat_id: UUID + + @classmethod + def from_domain(cls, chat_id: UUID) -> "BotXAPIChatInfoRequestPayload": + return cls(group_chat_id=chat_id) + + +class BotXAPIChatInfoMember(VerifiedPayloadBaseModel): + admin: bool + user_huid: UUID + user_kind: APIUserKinds + + +class BotXAPIChatInfoResult(VerifiedPayloadBaseModel): + chat_type: APIChatTypes + creator: UUID + description: Optional[str] = None + group_chat_id: UUID + inserted_at: dt + members: List[BotXAPIChatInfoMember] + name: str + + +class BotXAPIChatInfoResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIChatInfoResult + + def to_domain(self) -> ChatInfo: + members = [ + ChatInfoMember( + is_admin=member.admin, + huid=member.user_huid, + kind=convert_user_kind_to_domain(member.user_kind), + ) + for member in self.result.members + ] + + return ChatInfo( + chat_type=convert_chat_type_to_domain(self.result.chat_type), + creator_id=self.result.creator, + description=self.result.description, + chat_id=self.result.group_chat_id, + created_at=self.result.inserted_at, + members=members, + name=self.result.name, + ) + + +class ChatInfoMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(ChatNotFoundError), + } + + async def execute( + self, + payload: BotXAPIChatInfoRequestPayload, + ) -> BotXAPIChatInfoResponsePayload: + path = "/api/v3/botx/chats/info" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + params=payload.jsonable_dict(), + ) + + return self._verify_and_extract_api_model( + BotXAPIChatInfoResponsePayload, + response, + ) diff --git a/botx/client/chats_api/create_chat.py b/botx/client/chats_api/create_chat.py new file mode 100644 index 00000000..57af0f5f --- /dev/null +++ b/botx/client/chats_api/create_chat.py @@ -0,0 +1,71 @@ +from typing import List, Literal, Optional +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.chats import ChatCreationError, ChatCreationProhibitedError +from botx.missing import Missing +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.enums import APIChatTypes, ChatTypes, convert_chat_type_from_domain + + +class BotXAPICreateChatRequestPayload(UnverifiedPayloadBaseModel): + name: str + description: Optional[str] + chat_type: APIChatTypes + members: List[UUID] + shared_history: Missing[bool] + + @classmethod + def from_domain( + cls, + name: str, + chat_type: ChatTypes, + huids: List[UUID], + shared_history: Missing[bool], + description: Optional[str] = None, + ) -> "BotXAPICreateChatRequestPayload": + return cls( + name=name, + chat_type=convert_chat_type_from_domain(chat_type), + members=huids, + description=description, + shared_history=shared_history, + ) + + +class BotXAPIChatIdResult(VerifiedPayloadBaseModel): + chat_id: UUID + + +class BotXAPICreateChatResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIChatIdResult + + def to_domain(self) -> UUID: + return self.result.chat_id + + +class CreateChatMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 403: response_exception_thrower(ChatCreationProhibitedError), + 422: response_exception_thrower(ChatCreationError), + } + + async def execute( + self, + payload: BotXAPICreateChatRequestPayload, + ) -> BotXAPICreateChatResponsePayload: + path = "/api/v3/botx/chats/create" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + return self._verify_and_extract_api_model( + BotXAPICreateChatResponsePayload, + response, + ) diff --git a/botx/client/chats_api/disable_stealth.py b/botx/client/chats_api/disable_stealth.py new file mode 100644 index 00000000..5a2a1eea --- /dev/null +++ b/botx/client/chats_api/disable_stealth.py @@ -0,0 +1,46 @@ +from typing import Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPIDisableStealthRequestPayload(UnverifiedPayloadBaseModel): + group_chat_id: UUID + + @classmethod + def from_domain( + cls, + chat_id: UUID, + ) -> "BotXAPIDisableStealthRequestPayload": + return cls( + group_chat_id=chat_id, + ) + + +class BotXAPIDisableStealthResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class DisableStealthMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 403: response_exception_thrower(PermissionDeniedError), + 404: response_exception_thrower(ChatNotFoundError), + } + + async def execute(self, payload: BotXAPIDisableStealthRequestPayload) -> None: + path = "/api/v3/botx/chats/stealth_disable" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + self._verify_and_extract_api_model( + BotXAPIDisableStealthResponsePayload, + response, + ) diff --git a/botx/client/chats_api/list_chats.py b/botx/client/chats_api/list_chats.py new file mode 100644 index 00000000..05db5e0d --- /dev/null +++ b/botx/client/chats_api/list_chats.py @@ -0,0 +1,52 @@ +from datetime import datetime +from typing import List, Literal, Optional +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.chats import ChatListItem +from botx.models.enums import APIChatTypes, convert_chat_type_to_domain + + +class BotXAPIListChatResult(VerifiedPayloadBaseModel): + group_chat_id: UUID + chat_type: APIChatTypes + name: str + description: Optional[str] = None + members: List[UUID] + inserted_at: datetime + updated_at: datetime + + +class BotXAPIListChatResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: List[BotXAPIListChatResult] + + def to_domain(self) -> List[ChatListItem]: + return [ + ChatListItem( + chat_id=chat_item.group_chat_id, + chat_type=convert_chat_type_to_domain(chat_item.chat_type), + name=chat_item.name, + description=chat_item.description, + members=chat_item.members, + created_at=chat_item.inserted_at, + updated_at=chat_item.updated_at, + ) + for chat_item in self.result + ] + + +class ListChatsMethod(AuthorizedBotXMethod): + async def execute(self) -> BotXAPIListChatResponsePayload: + path = "/api/v3/botx/chats/list" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + ) + + return self._verify_and_extract_api_model( + BotXAPIListChatResponsePayload, + response, + ) diff --git a/botx/client/chats_api/pin_message.py b/botx/client/chats_api/pin_message.py new file mode 100644 index 00000000..062dc76f --- /dev/null +++ b/botx/client/chats_api/pin_message.py @@ -0,0 +1,46 @@ +from typing import Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPIPinMessageRequestPayload(UnverifiedPayloadBaseModel): + chat_id: UUID + sync_id: UUID + + @classmethod + def from_domain( + cls, + chat_id: UUID, + sync_id: UUID, + ) -> "BotXAPIPinMessageRequestPayload": + return cls(chat_id=chat_id, sync_id=sync_id) + + +class BotXAPIPinMessageResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class PinMessageMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 403: response_exception_thrower(PermissionDeniedError), + 404: response_exception_thrower(ChatNotFoundError), + } + + async def execute( + self, + payload: BotXAPIPinMessageRequestPayload, + ) -> None: + path = "/api/v3/botx/chats/pin_message" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + self._verify_and_extract_api_model(BotXAPIPinMessageResponsePayload, response) diff --git a/botx/client/chats_api/remove_user.py b/botx/client/chats_api/remove_user.py new file mode 100644 index 00000000..8b0f2627 --- /dev/null +++ b/botx/client/chats_api/remove_user.py @@ -0,0 +1,46 @@ +from typing import List, Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPIRemoveUserRequestPayload(UnverifiedPayloadBaseModel): + group_chat_id: UUID + user_huids: List[UUID] + + @classmethod + def from_domain( + cls, + chat_id: UUID, + huids: List[UUID], + ) -> "BotXAPIRemoveUserRequestPayload": + return cls(group_chat_id=chat_id, user_huids=huids) + + +class BotXAPIRemoveUserResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class RemoveUserMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 403: response_exception_thrower(PermissionDeniedError), + 404: response_exception_thrower(ChatNotFoundError), + } + + async def execute( + self, + payload: BotXAPIRemoveUserRequestPayload, + ) -> None: + path = "/api/v3/botx/chats/remove_user" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + self._verify_and_extract_api_model(BotXAPIRemoveUserResponsePayload, response) diff --git a/botx/client/chats_api/set_stealth.py b/botx/client/chats_api/set_stealth.py new file mode 100644 index 00000000..b0e0d012 --- /dev/null +++ b/botx/client/chats_api/set_stealth.py @@ -0,0 +1,53 @@ +from typing import Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError +from botx.missing import Missing +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPISetStealthRequestPayload(UnverifiedPayloadBaseModel): + group_chat_id: UUID + disable_web: Missing[bool] + burn_in: Missing[int] + expire_in: Missing[int] + + @classmethod + def from_domain( + cls, + chat_id: UUID, + disable_web_client: Missing[bool], + ttl_after_read: Missing[int], + total_ttl: Missing[int], + ) -> "BotXAPISetStealthRequestPayload": + return cls( + group_chat_id=chat_id, + disable_web=disable_web_client, + burn_in=ttl_after_read, + expire_in=total_ttl, + ) + + +class BotXAPISetStealthResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class SetStealthMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 403: response_exception_thrower(PermissionDeniedError), + 404: response_exception_thrower(ChatNotFoundError), + } + + async def execute(self, payload: BotXAPISetStealthRequestPayload) -> None: + path = "/api/v3/botx/chats/stealth_set" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + self._verify_and_extract_api_model(BotXAPISetStealthResponsePayload, response) diff --git a/botx/client/chats_api/unpin_message.py b/botx/client/chats_api/unpin_message.py new file mode 100644 index 00000000..d1260f02 --- /dev/null +++ b/botx/client/chats_api/unpin_message.py @@ -0,0 +1,44 @@ +from typing import Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.common import ChatNotFoundError, PermissionDeniedError +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPIUnpinMessageRequestPayload(UnverifiedPayloadBaseModel): + chat_id: UUID + + @classmethod + def from_domain( + cls, + chat_id: UUID, + ) -> "BotXAPIUnpinMessageRequestPayload": + return cls(chat_id=chat_id) + + +class BotXAPIUnpinMessageResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class UnpinMessageMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 403: response_exception_thrower(PermissionDeniedError), + 404: response_exception_thrower(ChatNotFoundError), + } + + async def execute( + self, + payload: BotXAPIUnpinMessageRequestPayload, + ) -> None: + path = "/api/v3/botx/chats/unpin_message" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + self._verify_and_extract_api_model(BotXAPIUnpinMessageResponsePayload, response) diff --git a/docs/src/development/logging/__init__.py b/botx/client/events_api/__init__.py similarity index 100% rename from docs/src/development/logging/__init__.py rename to botx/client/events_api/__init__.py diff --git a/botx/client/events_api/edit_event.py b/botx/client/events_api/edit_event.py new file mode 100644 index 00000000..8bfeb855 --- /dev/null +++ b/botx/client/events_api/edit_event.py @@ -0,0 +1,98 @@ +from typing import Any, Dict, List, Literal, Union +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.missing import Missing, MissingOptional, Undefined +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.attachments import ( + BotXAPIAttachment, + IncomingFileAttachment, + OutgoingAttachment, +) +from botx.models.message.markup import ( + BotXAPIMarkup, + BubbleMarkup, + KeyboardMarkup, + api_markup_from_domain, +) +from botx.models.message.mentions import BotXAPIMention, find_and_replace_embed_mentions + + +class BotXAPIEditEventOpts(UnverifiedPayloadBaseModel): + buttons_auto_adjust: Missing[bool] + + +class BotXAPIEditEvent(UnverifiedPayloadBaseModel): + body: Missing[str] + metadata: Missing[Dict[str, Any]] + opts: Missing[BotXAPIEditEventOpts] + bubble: Missing[BotXAPIMarkup] + keyboard: Missing[BotXAPIMarkup] + mentions: Missing[List[BotXAPIMention]] + + +class BotXAPIEditEventRequestPayload(UnverifiedPayloadBaseModel): + sync_id: UUID + payload: BotXAPIEditEvent + file: Missing[BotXAPIAttachment] + + @classmethod + def from_domain( + cls, + sync_id: UUID, + body: Missing[str], + metadata: Missing[Dict[str, Any]], + bubbles: Missing[BubbleMarkup], + keyboard: Missing[KeyboardMarkup], + file: Missing[Union[IncomingFileAttachment, OutgoingAttachment, None]], + markup_auto_adjust: Missing[bool], + ) -> "BotXAPIEditEventRequestPayload": + api_file: MissingOptional[BotXAPIAttachment] = Undefined + if file: + assert not file.is_async_file, "async_files not supported" + api_file = BotXAPIAttachment.from_file_attachment(file) + elif file is None: + api_file = None + + mentions: Missing[List[BotXAPIMention]] = Undefined + if isinstance(body, str): + body, mentions = find_and_replace_embed_mentions(body) + + return cls( + sync_id=sync_id, + payload=BotXAPIEditEvent( + body=body, + # TODO: Metadata can be cleaned with `{}` + metadata=metadata, + opts=BotXAPIEditEventOpts( + buttons_auto_adjust=markup_auto_adjust, + ), + bubble=api_markup_from_domain(bubbles) if bubbles else bubbles, + keyboard=api_markup_from_domain(keyboard) if keyboard else keyboard, + mentions=mentions, + ), + file=api_file, + ) + + +class BotXAPIEditEventResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class EditEventMethod(AuthorizedBotXMethod): + async def execute( + self, + payload: BotXAPIEditEventRequestPayload, + ) -> None: + path = "/api/v3/botx/events/edit_event" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + self._verify_and_extract_api_model( + BotXAPIEditEventResponsePayload, + response, + ) diff --git a/botx/client/events_api/message_status_event.py b/botx/client/events_api/message_status_event.py new file mode 100644 index 00000000..89a3071c --- /dev/null +++ b/botx/client/events_api/message_status_event.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import List, Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.event import EventNotFoundError +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.message.message_status import MessageStatus + + +class BotXAPIMessageStatusRequestPayload(UnverifiedPayloadBaseModel): + sync_id: UUID + + @classmethod + def from_domain(cls, sync_id: UUID) -> "BotXAPIMessageStatusRequestPayload": + return cls(sync_id=sync_id) + + +@dataclass +class BotXAPIMessageStatusReadUser: + user_huid: UUID + read_at: datetime + + +@dataclass +class BotXAPIMessageStatusReceivedUser: + user_huid: UUID + received_at: datetime + + +class BotXAPIMessageStatusResult(VerifiedPayloadBaseModel): + group_chat_id: UUID + sent_to: List[UUID] + read_by: List[BotXAPIMessageStatusReadUser] + received_by: List[BotXAPIMessageStatusReceivedUser] + + +class BotXAPIMessageStatusResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIMessageStatusResult + + def to_domain(self) -> MessageStatus: + + return MessageStatus( + group_chat_id=self.result.group_chat_id, + sent_to=self.result.sent_to, + read_by={ + reader.user_huid: reader.read_at for reader in self.result.read_by + }, + received_by={ + receiver.user_huid: receiver.received_at + for receiver in self.result.received_by + }, + ) + + +class MessageStatusMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(EventNotFoundError), + } + + async def execute( + self, + payload: BotXAPIMessageStatusRequestPayload, + ) -> "BotXAPIMessageStatusResponsePayload": + path = f"/api/v3/botx/events/{payload.sync_id}/status" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + ) + + return self._verify_and_extract_api_model( + BotXAPIMessageStatusResponsePayload, + response, + ) diff --git a/botx/client/events_api/reply_event.py b/botx/client/events_api/reply_event.py new file mode 100644 index 00000000..3275db25 --- /dev/null +++ b/botx/client/events_api/reply_event.py @@ -0,0 +1,118 @@ +from typing import Any, Dict, List, Literal, Union +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.missing import Missing, Undefined +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.attachments import ( + BotXAPIAttachment, + IncomingFileAttachment, + OutgoingAttachment, +) +from botx.models.message.markup import ( + BotXAPIMarkup, + BubbleMarkup, + KeyboardMarkup, + api_markup_from_domain, +) +from botx.models.message.mentions import BotXAPIMention, find_and_replace_embed_mentions + + +class BotXAPIReplyEventMessageOpts(UnverifiedPayloadBaseModel): + silent_response: Missing[bool] + buttons_auto_adjust: Missing[bool] + + +class BotXAPIReplyEvent(UnverifiedPayloadBaseModel): + status: Literal["ok"] + body: str + metadata: Missing[Dict[str, Any]] + opts: Missing[BotXAPIReplyEventMessageOpts] + bubble: Missing[BotXAPIMarkup] + keyboard: Missing[BotXAPIMarkup] + mentions: Missing[List[BotXAPIMention]] + + +class BotXAPIReplyEventNestedOpts(UnverifiedPayloadBaseModel): + send: Missing[bool] + force_dnd: Missing[bool] + + +class BotXAPIReplyEventOpts(UnverifiedPayloadBaseModel): + raw_mentions: Literal[True] + stealth_mode: Missing[bool] + notification_opts: Missing[BotXAPIReplyEventNestedOpts] + + +class BotXAPIReplyEventRequestPayload(UnverifiedPayloadBaseModel): + source_sync_id: UUID + reply: BotXAPIReplyEvent + file: Missing[BotXAPIAttachment] + opts: BotXAPIReplyEventOpts + + @classmethod + def from_domain( + cls, + sync_id: UUID, + body: str, + metadata: Missing[Dict[str, Any]], + bubbles: Missing[BubbleMarkup], + keyboard: Missing[KeyboardMarkup], + file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]], + silent_response: Missing[bool], + markup_auto_adjust: Missing[bool], + stealth_mode: Missing[bool], + send_push: Missing[bool], + ignore_mute: Missing[bool], + ) -> "BotXAPIReplyEventRequestPayload": + api_file: Missing[BotXAPIAttachment] = Undefined + if file: + assert not file.is_async_file, "async_files not supported" + api_file = BotXAPIAttachment.from_file_attachment(file) + + body, mentions = find_and_replace_embed_mentions(body) + + return cls( + source_sync_id=sync_id, + reply=BotXAPIReplyEvent( + status="ok", + body=body, + metadata=metadata, + opts=BotXAPIReplyEventMessageOpts( + buttons_auto_adjust=markup_auto_adjust, + silent_response=silent_response, + ), + bubble=api_markup_from_domain(bubbles) if bubbles else bubbles, + keyboard=api_markup_from_domain(keyboard) if keyboard else keyboard, + mentions=mentions or Undefined, + ), + file=api_file, + opts=BotXAPIReplyEventOpts( + raw_mentions=True, + stealth_mode=stealth_mode, + notification_opts=BotXAPIReplyEventNestedOpts( + send=send_push, + force_dnd=ignore_mute, + ), + ), + ) + + +class BotXAPIReplyEventResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class ReplyEventMethod(AuthorizedBotXMethod): + async def execute(self, payload: BotXAPIReplyEventRequestPayload) -> None: + path = "/api/v3/botx/events/reply_event" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + self._verify_and_extract_api_model( + BotXAPIReplyEventResponsePayload, + response, + ) diff --git a/botx/client/events_api/stop_typing_event.py b/botx/client/events_api/stop_typing_event.py new file mode 100644 index 00000000..e9d27298 --- /dev/null +++ b/botx/client/events_api/stop_typing_event.py @@ -0,0 +1,33 @@ +from typing import Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPIStopTypingEventRequestPayload(UnverifiedPayloadBaseModel): + group_chat_id: UUID + + @classmethod + def from_domain(cls, chat_id: UUID) -> "BotXAPIStopTypingEventRequestPayload": + return cls(group_chat_id=chat_id) + + +class BotXAPIStopTypingEventResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class StopTypingEventMethod(AuthorizedBotXMethod): + async def execute(self, payload: BotXAPIStopTypingEventRequestPayload) -> None: + path = "/api/v3/botx/events/stop_typing" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + self._verify_and_extract_api_model( + BotXAPIStopTypingEventResponsePayload, + response, + ) diff --git a/botx/client/events_api/typing_event.py b/botx/client/events_api/typing_event.py new file mode 100644 index 00000000..e7f76e5d --- /dev/null +++ b/botx/client/events_api/typing_event.py @@ -0,0 +1,30 @@ +from typing import Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPITypingEventRequestPayload(UnverifiedPayloadBaseModel): + group_chat_id: UUID + + @classmethod + def from_domain(cls, chat_id: UUID) -> "BotXAPITypingEventRequestPayload": + return cls(group_chat_id=chat_id) + + +class BotXAPITypingEventResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class TypingEventMethod(AuthorizedBotXMethod): + async def execute(self, payload: BotXAPITypingEventRequestPayload) -> None: + path = "/api/v3/botx/events/typing" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + self._verify_and_extract_api_model(BotXAPITypingEventResponsePayload, response) diff --git a/docs/src/development/sending_data/__init__.py b/botx/client/exceptions/__init__.py similarity index 100% rename from docs/src/development/sending_data/__init__.py rename to botx/client/exceptions/__init__.py diff --git a/botx/client/exceptions/base.py b/botx/client/exceptions/base.py new file mode 100644 index 00000000..68ddf2bc --- /dev/null +++ b/botx/client/exceptions/base.py @@ -0,0 +1,49 @@ +from typing import Optional + +import httpx + +from botx.models.method_callbacks import BotAPIMethodFailedCallback + + +class BaseClientError(Exception): + def __init__(self, message: str) -> None: + self.message = message + super().__init__(message) + + @classmethod + def from_response( + cls, + response: httpx.Response, + comment: Optional[str] = None, + ) -> "BaseClientError": + method = response.request.method + url = response.request.url + status_code = response.status_code + content = response.content + + message = ( + f"{method} {url}\n" # noqa: WPS221 (Strange error on CI) + f"failed with code {status_code} and payload:\n" + f"{content!r}" + ) + + if comment is not None: + message = f"{message}\n\nComment: {comment}" + + return cls(message) + + @classmethod + def from_callback( + cls, + callback: BotAPIMethodFailedCallback, + comment: Optional[str] = None, + ) -> "BaseClientError": + message = ( + f"BotX method call with sync_id `{callback.sync_id!s}` " + f"failed with: {callback}" + ) + + if comment is not None: + message = f"{message}\n\nComment: {comment}" + + return cls(message) diff --git a/botx/client/exceptions/callbacks.py b/botx/client/exceptions/callbacks.py new file mode 100644 index 00000000..5a28325d --- /dev/null +++ b/botx/client/exceptions/callbacks.py @@ -0,0 +1,19 @@ +from uuid import UUID + +from botx.client.exceptions.base import BaseClientError +from botx.models.method_callbacks import BotAPIMethodFailedCallback + + +class BotXMethodFailedCallbackReceivedError(BaseClientError): + """Callback with error received.""" + + def __init__(self, callback: BotAPIMethodFailedCallback) -> None: + exc = BaseClientError.from_callback(callback) + self.args = exc.args + + +class CallbackNotReceivedError(Exception): + def __init__(self, sync_id: UUID) -> None: + self.sync_id = sync_id + self.message = f"Callback for sync_id `{sync_id}` hasn't been received" + super().__init__(self.message) diff --git a/botx/client/exceptions/chats.py b/botx/client/exceptions/chats.py new file mode 100644 index 00000000..cd821bd5 --- /dev/null +++ b/botx/client/exceptions/chats.py @@ -0,0 +1,17 @@ +from botx.client.exceptions.base import BaseClientError + + +class CantUpdatePersonalChatError(BaseClientError): + """Can't edit a personal chat.""" + + +class InvalidUsersListError(BaseClientError): + """Users list isn't correct.""" + + +class ChatCreationProhibitedError(BaseClientError): + """Bot doesn't have permissions to create chat.""" + + +class ChatCreationError(BaseClientError): + """Error while chat creation.""" diff --git a/botx/client/exceptions/common.py b/botx/client/exceptions/common.py new file mode 100644 index 00000000..02496b14 --- /dev/null +++ b/botx/client/exceptions/common.py @@ -0,0 +1,17 @@ +from botx.client.exceptions.base import BaseClientError + + +class InvalidBotAccountError(BaseClientError): + """Can't get token with given bot account.""" + + +class RateLimitReachedError(BaseClientError): + """Too many method requests.""" + + +class PermissionDeniedError(BaseClientError): + """Bot can't perform this action.""" + + +class ChatNotFoundError(BaseClientError): + """Chat with specified group_chat_id not found.""" diff --git a/botx/client/exceptions/event.py b/botx/client/exceptions/event.py new file mode 100644 index 00000000..9ffd7d4b --- /dev/null +++ b/botx/client/exceptions/event.py @@ -0,0 +1,5 @@ +from botx.client.exceptions.base import BaseClientError + + +class EventNotFoundError(BaseClientError): + """Event not found.""" diff --git a/botx/client/exceptions/files.py b/botx/client/exceptions/files.py new file mode 100644 index 00000000..6002e7f5 --- /dev/null +++ b/botx/client/exceptions/files.py @@ -0,0 +1,9 @@ +from botx.client.exceptions.base import BaseClientError + + +class FileDeletedError(BaseClientError): + """File deleted.""" + + +class FileMetadataNotFound(BaseClientError): + """Can't find file metadata.""" diff --git a/botx/client/exceptions/http.py b/botx/client/exceptions/http.py new file mode 100644 index 00000000..e7bdbb41 --- /dev/null +++ b/botx/client/exceptions/http.py @@ -0,0 +1,19 @@ +import httpx + +from botx.client.exceptions.base import BaseClientError + + +class InvalidBotXResponseError(BaseClientError): + """Received invalid response.""" + + def __init__(self, response: httpx.Response) -> None: + exc = BaseClientError.from_response(response) + self.args = exc.args + + +class InvalidBotXStatusCodeError(InvalidBotXResponseError): + """Received invalid status code.""" + + +class InvalidBotXResponsePayloadError(InvalidBotXResponseError): + """Received invalid status code.""" diff --git a/botx/client/exceptions/notifications.py b/botx/client/exceptions/notifications.py new file mode 100644 index 00000000..6795327e --- /dev/null +++ b/botx/client/exceptions/notifications.py @@ -0,0 +1,13 @@ +from botx.client.exceptions.base import BaseClientError + + +class BotIsNotChatMemberError(BaseClientError): + """Bot is not in the list of chat members.""" + + +class FinalRecipientsListEmptyError(BaseClientError): + """Resulting event recipients list is empty.""" + + +class StealthModeDisabledError(BaseClientError): + """Requested stealth mode disabled in specified chat.""" diff --git a/botx/client/exceptions/users.py b/botx/client/exceptions/users.py new file mode 100644 index 00000000..3263bf15 --- /dev/null +++ b/botx/client/exceptions/users.py @@ -0,0 +1,5 @@ +from botx.client.exceptions.base import BaseClientError + + +class UserNotFoundError(BaseClientError): + """User not found.""" diff --git a/docs/src/development/tests/__init__.py b/botx/client/files_api/__init__.py similarity index 100% rename from docs/src/development/tests/__init__.py rename to botx/client/files_api/__init__.py diff --git a/botx/client/files_api/download_file.py b/botx/client/files_api/download_file.py new file mode 100644 index 00000000..8beaf4a4 --- /dev/null +++ b/botx/client/files_api/download_file.py @@ -0,0 +1,67 @@ +from typing import NoReturn +from uuid import UUID + +import httpx + +from botx.async_buffer import AsyncBufferWritable +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.common import ChatNotFoundError +from botx.client.exceptions.files import FileDeletedError, FileMetadataNotFound +from botx.client.exceptions.http import InvalidBotXStatusCodeError +from botx.models.api_base import UnverifiedPayloadBaseModel + + +class BotXAPIDownloadFileRequestPayload(UnverifiedPayloadBaseModel): + group_chat_id: UUID + file_id: UUID + is_preview: bool + + @classmethod + def from_domain( + cls, + chat_id: UUID, + file_id: UUID, + ) -> "BotXAPIDownloadFileRequestPayload": + return cls( + group_chat_id=chat_id, + file_id=file_id, + is_preview=False, + ) + + +def not_found_error_handler(response: httpx.Response) -> NoReturn: + reason = response.json().get("reason") + + if reason == "file_metadata_not_found": + raise FileMetadataNotFound.from_response(response) + elif reason == "chat_not_found": + raise ChatNotFoundError.from_response(response) + + raise InvalidBotXStatusCodeError(response) + + +class DownloadFileMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 204: response_exception_thrower(FileDeletedError), + 404: not_found_error_handler, + } + + async def execute( + self, + payload: BotXAPIDownloadFileRequestPayload, + async_buffer: AsyncBufferWritable, + ) -> None: + path = "/api/v3/botx/files/download" + + async with self._botx_method_stream( + "GET", + self._build_url(path), + params=payload.jsonable_dict(), + ) as response: + # https://github.com/nedbat/coveragepy/issues/1223 + async for chunk in response.aiter_bytes(): # pragma: no cover + await async_buffer.write(chunk) + + await async_buffer.seek(0) diff --git a/botx/client/files_api/upload_file.py b/botx/client/files_api/upload_file.py new file mode 100644 index 00000000..2c8e7408 --- /dev/null +++ b/botx/client/files_api/upload_file.py @@ -0,0 +1,80 @@ +import tempfile +from typing import Literal +from uuid import UUID + +from botx.async_buffer import AsyncBufferReadable +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.common import ChatNotFoundError +from botx.constants import CHUNK_SIZE +from botx.missing import Missing +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.async_files import APIAsyncFile, File, convert_async_file_to_domain + + +class BotXAPIUploadFileMeta(UnverifiedPayloadBaseModel): + duration: Missing[int] + caption: Missing[str] + + +class BotXAPIUploadFileRequestPayload(UnverifiedPayloadBaseModel): + group_chat_id: UUID + meta: str + + @classmethod + def from_domain( + cls, + chat_id: UUID, + duration: Missing[int], + caption: Missing[str], + ) -> "BotXAPIUploadFileRequestPayload": + return cls( + group_chat_id=chat_id, + meta=BotXAPIUploadFileMeta( + duration=duration, + caption=caption, + ).json(), + ) + + +class BotXAPIUploadFileResponsePayload(VerifiedPayloadBaseModel): + result: APIAsyncFile + status: Literal["ok"] + + def to_domain(self) -> File: + return convert_async_file_to_domain(self.result) + + +class UploadFileMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(ChatNotFoundError), + } + + async def execute( + self, + payload: BotXAPIUploadFileRequestPayload, + async_buffer: AsyncBufferReadable, + filename: str, + ) -> BotXAPIUploadFileResponsePayload: + path = "/api/v3/botx/files/upload" + + with tempfile.SpooledTemporaryFile(max_size=CHUNK_SIZE) as tmp_file: + chunk = await async_buffer.read(CHUNK_SIZE) + while chunk: + tmp_file.write(chunk) + chunk = await async_buffer.read(CHUNK_SIZE) + + tmp_file.seek(0) + + response = await self._botx_method_call( + "POST", + self._build_url(path), + data=payload.jsonable_dict(), + files={"content": (filename, tmp_file)}, + ) + + return self._verify_and_extract_api_model( + BotXAPIUploadFileResponsePayload, + response, + ) diff --git a/botx/client/get_token.py b/botx/client/get_token.py new file mode 100644 index 00000000..e353f2f2 --- /dev/null +++ b/botx/client/get_token.py @@ -0,0 +1,30 @@ +from uuid import UUID + +import httpx + +from botx.bot.bot_accounts_storage import BotAccountsStorage +from botx.client.bots_api.get_token import BotXAPIGetTokenRequestPayload, GetTokenMethod + + +async def get_token( + bot_id: UUID, + httpx_client: httpx.AsyncClient, + bot_accounts_storage: BotAccountsStorage, +) -> str: # noqa: DAR101, DAR201 + """Request token for bot. + + Moved to separate file because used in `AuthorizedBotXMethod` and `Bot.get_token`. + """ + + method = GetTokenMethod( + bot_id, + httpx_client, + bot_accounts_storage, + ) + + signature = bot_accounts_storage.build_signature(bot_id) + payload = BotXAPIGetTokenRequestPayload.from_domain(signature) + + botx_api_token = await method.execute(payload) + + return botx_api_token.to_domain() diff --git a/docs/src/development/tests/tests0/__init__.py b/botx/client/notifications_api/__init__.py similarity index 100% rename from docs/src/development/tests/tests0/__init__.py rename to botx/client/notifications_api/__init__.py diff --git a/botx/client/notifications_api/direct_notification.py b/botx/client/notifications_api/direct_notification.py new file mode 100644 index 00000000..939eb32f --- /dev/null +++ b/botx/client/notifications_api/direct_notification.py @@ -0,0 +1,164 @@ +from typing import Any, Dict, List, Literal, Optional, Union +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import callback_exception_thrower +from botx.client.exceptions.common import ChatNotFoundError +from botx.client.exceptions.notifications import ( + BotIsNotChatMemberError, + FinalRecipientsListEmptyError, + StealthModeDisabledError, +) +from botx.constants import MAX_NOTIFICATION_BODY_LENGTH +from botx.missing import Missing, Undefined +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.attachments import ( + BotXAPIAttachment, + IncomingFileAttachment, + OutgoingAttachment, +) +from botx.models.message.markup import ( + BotXAPIMarkup, + BubbleMarkup, + KeyboardMarkup, + api_markup_from_domain, +) +from botx.models.message.mentions import BotXAPIMention, find_and_replace_embed_mentions + + +class BotXAPIDirectNotificationMessageOpts(UnverifiedPayloadBaseModel): + silent_response: Missing[bool] + buttons_auto_adjust: Missing[bool] + + +class BotXAPIDirectNotificationNestedOpts(UnverifiedPayloadBaseModel): + send: Missing[bool] + force_dnd: Missing[bool] + + +class BotXAPIDirectNotificationOpts(UnverifiedPayloadBaseModel): + stealth_mode: Missing[bool] + notification_opts: Missing[BotXAPIDirectNotificationNestedOpts] + + +class BotXAPIDirectNotification(UnverifiedPayloadBaseModel): + status: Literal["ok"] + body: str + metadata: Missing[Dict[str, Any]] + opts: Missing[BotXAPIDirectNotificationMessageOpts] + bubble: Missing[BotXAPIMarkup] + keyboard: Missing[BotXAPIMarkup] + mentions: Missing[List[BotXAPIMention]] + + +class BotXAPIDirectNotificationRequestPayload(UnverifiedPayloadBaseModel): + group_chat_id: UUID + notification: BotXAPIDirectNotification + file: Missing[BotXAPIAttachment] + recipients: Missing[List[UUID]] + opts: Missing[BotXAPIDirectNotificationOpts] + + @classmethod + def from_domain( + cls, + chat_id: UUID, + body: str, + metadata: Missing[Dict[str, Any]], + bubbles: Missing[BubbleMarkup], + keyboard: Missing[KeyboardMarkup], + file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]], + recipients: Missing[List[UUID]], + silent_response: Missing[bool], + markup_auto_adjust: Missing[bool], + stealth_mode: Missing[bool], + send_push: Missing[bool], + ignore_mute: Missing[bool], + ) -> "BotXAPIDirectNotificationRequestPayload": + api_file: Missing[BotXAPIAttachment] = Undefined + if file: + assert not file.is_async_file, "async_files not supported" + api_file = BotXAPIAttachment.from_file_attachment(file) + + if len(body) > MAX_NOTIFICATION_BODY_LENGTH: + raise ValueError( + f"Message body length exceeds {MAX_NOTIFICATION_BODY_LENGTH} symbols", + ) + + body, mentions = find_and_replace_embed_mentions(body) + + return cls( + group_chat_id=chat_id, + notification=BotXAPIDirectNotification( + status="ok", + body=body, + metadata=metadata, + opts=BotXAPIDirectNotificationMessageOpts( + silent_response=silent_response, + buttons_auto_adjust=markup_auto_adjust, + ), + bubble=api_markup_from_domain(bubbles) if bubbles else bubbles, + keyboard=api_markup_from_domain(keyboard) if keyboard else keyboard, + mentions=mentions or Undefined, + ), + file=api_file, + recipients=recipients, + opts=BotXAPIDirectNotificationOpts( + stealth_mode=stealth_mode, + notification_opts=BotXAPIDirectNotificationNestedOpts( + send=send_push, + force_dnd=ignore_mute, + ), + ), + ) + + +class BotXAPISyncIdResult(VerifiedPayloadBaseModel): + sync_id: UUID + + +class BotXAPIDirectNotificationResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPISyncIdResult + + def to_domain(self) -> UUID: + return self.result.sync_id + + +class DirectNotificationMethod(AuthorizedBotXMethod): + error_callback_handlers = { + **AuthorizedBotXMethod.error_callback_handlers, + "chat_not_found": callback_exception_thrower(ChatNotFoundError), + "bot_is_not_a_chat_member": callback_exception_thrower( + BotIsNotChatMemberError, + ), + "event_recipients_list_is_empty": callback_exception_thrower( + FinalRecipientsListEmptyError, + ), + "stealth_mode_disabled": callback_exception_thrower(StealthModeDisabledError), + } + + async def execute( + self, + payload: BotXAPIDirectNotificationRequestPayload, + wait_callback: bool, + callback_timeout: Optional[int], + ) -> BotXAPIDirectNotificationResponsePayload: + path = "/api/v4/botx/notifications/direct" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + api_model = self._verify_and_extract_api_model( + BotXAPIDirectNotificationResponsePayload, + response, + ) + + await self._process_callback( + api_model.result.sync_id, + wait_callback, + callback_timeout, + ) + return api_model diff --git a/botx/client/notifications_api/internal_bot_notification.py b/botx/client/notifications_api/internal_bot_notification.py new file mode 100644 index 00000000..97722418 --- /dev/null +++ b/botx/client/notifications_api/internal_bot_notification.py @@ -0,0 +1,93 @@ +from typing import Any, Dict, List, Literal, Optional +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import ( + callback_exception_thrower, + response_exception_thrower, +) +from botx.client.exceptions.common import ChatNotFoundError, RateLimitReachedError +from botx.client.exceptions.notifications import ( + BotIsNotChatMemberError, + FinalRecipientsListEmptyError, +) +from botx.missing import Missing, MissingOptional +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPIInternalBotNotificationRequestPayload(UnverifiedPayloadBaseModel): + group_chat_id: UUID + data: Dict[str, Any] + opts: Missing[Dict[str, Any]] + recipients: MissingOptional[List[UUID]] + + @classmethod + def from_domain( + cls, + chat_id: UUID, + data: Dict[str, Any], + opts: Missing[Dict[str, Any]], + recipients: MissingOptional[List[UUID]], + ) -> "BotXAPIInternalBotNotificationRequestPayload": + return cls( + group_chat_id=chat_id, + data=data, + opts=opts, + recipients=recipients, + ) + + +class BotXAPISyncIdResult(VerifiedPayloadBaseModel): + sync_id: UUID + + +class BotXAPIInternalBotNotificationResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPISyncIdResult + + def to_domain(self) -> UUID: + return self.result.sync_id + + +class InternalBotNotificationMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 429: response_exception_thrower(RateLimitReachedError), + } + + error_callback_handlers = { + **AuthorizedBotXMethod.error_callback_handlers, + "chat_not_found": callback_exception_thrower(ChatNotFoundError), + "bot_is_not_a_chat_member": callback_exception_thrower( + BotIsNotChatMemberError, + ), + "event_recipients_list_is_empty": callback_exception_thrower( + FinalRecipientsListEmptyError, + ), + } + + async def execute( + self, + payload: BotXAPIInternalBotNotificationRequestPayload, + wait_callback: bool, + callback_timeout: Optional[int], + ) -> BotXAPIInternalBotNotificationResponsePayload: + path = "/api/v4/botx/notifications/internal" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + api_model = self._verify_and_extract_api_model( + BotXAPIInternalBotNotificationResponsePayload, + response, + ) + + await self._process_callback( + api_model.result.sync_id, + wait_callback, + callback_timeout, + ) + + return api_model diff --git a/docs/src/index/__init__.py b/botx/client/smartapps_api/__init__.py similarity index 100% rename from docs/src/index/__init__.py rename to botx/client/smartapps_api/__init__.py diff --git a/botx/client/smartapps_api/smartapp_event.py b/botx/client/smartapps_api/smartapp_event.py new file mode 100644 index 00000000..e9f39b37 --- /dev/null +++ b/botx/client/smartapps_api/smartapp_event.py @@ -0,0 +1,70 @@ +from typing import Any, Dict, List, Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.constants import SMARTAPP_API_VERSION +from botx.missing import Missing, MissingOptional, Undefined +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.async_files import APIAsyncFile, File, convert_async_file_from_domain + + +class BotXAPISmartAppEventRequestPayload(UnverifiedPayloadBaseModel): + ref: MissingOptional[UUID] + smartapp_id: UUID + group_chat_id: UUID + data: Dict[str, Any] + opts: Missing[Dict[str, Any]] + smartapp_api_version: int + async_files: Missing[List[APIAsyncFile]] + + @classmethod + def from_domain( + cls, + ref: MissingOptional[UUID], + smartapp_id: UUID, + chat_id: UUID, + data: Dict[str, Any], + opts: Missing[Dict[str, Any]], + files: Missing[List[File]], + ) -> "BotXAPISmartAppEventRequestPayload": + api_async_files: Missing[List[APIAsyncFile]] = Undefined + if files: + api_async_files = [convert_async_file_from_domain(file) for file in files] + + return cls( + ref=ref, + smartapp_id=smartapp_id, + group_chat_id=chat_id, + data=data, + opts=opts, + smartapp_api_version=SMARTAPP_API_VERSION, + async_files=api_async_files, + ) + + +class BotXAPISmartAppEventResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class SmartAppEventMethod(AuthorizedBotXMethod): + async def execute( + self, + payload: BotXAPISmartAppEventRequestPayload, + ) -> None: + path = "/api/v3/botx/smartapps/event" + + # TODO: Remove opts + # UnverifiedPayloadBaseModel.jsonable_dict remove empty dicts + json = payload.jsonable_dict() + json["opts"] = json.get("opts", {}) + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=json, + ) + + self._verify_and_extract_api_model( + BotXAPISmartAppEventResponsePayload, + response, + ) diff --git a/botx/client/smartapps_api/smartapp_notification.py b/botx/client/smartapps_api/smartapp_notification.py new file mode 100644 index 00000000..cf69f9b1 --- /dev/null +++ b/botx/client/smartapps_api/smartapp_notification.py @@ -0,0 +1,56 @@ +from typing import Any, Dict, Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.constants import SMARTAPP_API_VERSION +from botx.missing import Missing +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPISmartAppNotificationRequestPayload(UnverifiedPayloadBaseModel): + group_chat_id: UUID + smartapp_counter: int + opts: Missing[Dict[str, Any]] + smartapp_api_version: int + + @classmethod + def from_domain( + cls, + chat_id: UUID, + smartapp_counter: int, + opts: Missing[Dict[str, Any]], + ) -> "BotXAPISmartAppNotificationRequestPayload": + return cls( + group_chat_id=chat_id, + smartapp_counter=smartapp_counter, + opts=opts, + smartapp_api_version=SMARTAPP_API_VERSION, + ) + + +class BotXAPISmartAppNotificationResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class SmartAppNotificationMethod(AuthorizedBotXMethod): + async def execute( + self, + payload: BotXAPISmartAppNotificationRequestPayload, + ) -> None: + path = "/api/v3/botx/smartapps/notification" + + # TODO: Remove opts + # UnverifiedPayloadBaseModel.jsonable_dict remove empty dicts + json = payload.jsonable_dict() + json["opts"] = json.get("opts", {}) + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=json, + ) + + self._verify_and_extract_api_model( + BotXAPISmartAppNotificationResponsePayload, + response, + ) diff --git a/examples/fsm/bot/__init__.py b/botx/client/stickers_api/__init__.py similarity index 100% rename from examples/fsm/bot/__init__.py rename to botx/client/stickers_api/__init__.py diff --git a/botx/client/stickers_api/add_sticker.py b/botx/client/stickers_api/add_sticker.py new file mode 100644 index 00000000..6a2c6f26 --- /dev/null +++ b/botx/client/stickers_api/add_sticker.py @@ -0,0 +1,97 @@ +from typing import Literal, NoReturn +from uuid import UUID + +import httpx + +from botx.async_buffer import AsyncBufferReadable +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.exceptions.http import InvalidBotXStatusCodeError +from botx.client.stickers_api.exceptions import ( + InvalidEmojiError, + InvalidImageError, + StickerPackOrStickerNotFoundError, +) +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.attachments import encode_rfc2397 +from botx.models.stickers import Sticker + + +class BotXAPIAddStickerRequestPayload(UnverifiedPayloadBaseModel): + sticker_pack_id: UUID + emoji: str + image: str + + @classmethod + async def from_domain( + cls, + sticker_pack_id: UUID, + emoji: str, + async_buffer: AsyncBufferReadable, + ) -> "BotXAPIAddStickerRequestPayload": + mimetype = "image/png" + + content = await async_buffer.read() + b64_content = encode_rfc2397(content, mimetype) + + return cls(sticker_pack_id=sticker_pack_id, emoji=emoji, image=b64_content) + + +class BotXAPIAddStickerResult(VerifiedPayloadBaseModel): + id: UUID + emoji: str + link: str + + +class BotXAPIAddStickerResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIAddStickerResult + + def to_domain(self) -> Sticker: + return Sticker( + id=self.result.id, + emoji=self.result.emoji, + image_link=self.result.link, + ) + + +def bad_request_error_handler(response: httpx.Response) -> NoReturn: # noqa: WPS238 + reason = response.json().get("reason") + + if reason == "pack_not_found": + raise StickerPackOrStickerNotFoundError.from_response(response) + + error_data = response.json().get("error_data") + + if error_data.get("emoji") == "invalid": + raise InvalidEmojiError.from_response(response) + elif error_data.get("image") == "invalid": + raise InvalidImageError.from_response(response) + + raise InvalidBotXStatusCodeError(response) + + +class AddStickerMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 400: bad_request_error_handler, + } + + async def execute( + self, + payload: BotXAPIAddStickerRequestPayload, + ) -> BotXAPIAddStickerResponsePayload: + jsonable_dict = payload.jsonable_dict() + sticker_pack_id = jsonable_dict.pop("sticker_pack_id") + + path = f"/api/v3/botx/stickers/packs/{sticker_pack_id}/stickers" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=jsonable_dict, + ) + + return self._verify_and_extract_api_model( + BotXAPIAddStickerResponsePayload, + response, + ) diff --git a/botx/client/stickers_api/create_sticker_pack.py b/botx/client/stickers_api/create_sticker_pack.py new file mode 100644 index 00000000..b0e04b33 --- /dev/null +++ b/botx/client/stickers_api/create_sticker_pack.py @@ -0,0 +1,56 @@ +from typing import Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.stickers import StickerPack + + +class BotXAPICreateStickerPackRequestPayload(UnverifiedPayloadBaseModel): + name: str + + @classmethod + def from_domain(cls, name: str) -> "BotXAPICreateStickerPackRequestPayload": + return cls(name=name) + + +class BotXAPICreateStickerPackResult(VerifiedPayloadBaseModel): + id: UUID + name: str + public: bool + + +class BotXAPICreateStickerPackResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPICreateStickerPackResult + + def to_domain(self) -> StickerPack: + return StickerPack( + id=self.result.id, + name=self.result.name, + is_public=self.result.public, + stickers=[], + ) + + +class CreateStickerPackMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + } + + async def execute( + self, + payload: BotXAPICreateStickerPackRequestPayload, + ) -> BotXAPICreateStickerPackResponsePayload: + path = "/api/v3/botx/stickers/packs" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + return self._verify_and_extract_api_model( + BotXAPICreateStickerPackResponsePayload, + response, + ) diff --git a/botx/client/stickers_api/delete_sticker.py b/botx/client/stickers_api/delete_sticker.py new file mode 100644 index 00000000..3bcefa4f --- /dev/null +++ b/botx/client/stickers_api/delete_sticker.py @@ -0,0 +1,50 @@ +from typing import Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.stickers_api.exceptions import StickerPackOrStickerNotFoundError +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPIDeleteStickerRequestPayload(UnverifiedPayloadBaseModel): + sticker_pack_id: UUID + sticker_id: UUID + + @classmethod + async def from_domain( + cls, + sticker_pack_id: UUID, + sticker_id: UUID, + ) -> "BotXAPIDeleteStickerRequestPayload": + return cls(sticker_pack_id=sticker_pack_id, sticker_id=sticker_id) + + +class BotXAPIDeleteStickerResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class DeleteStickerMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(StickerPackOrStickerNotFoundError), + } + + async def execute( + self, + payload: BotXAPIDeleteStickerRequestPayload, + ) -> None: + path = ( + f"/api/v3/botx/stickers/packs/{payload.sticker_pack_id}" + f"/stickers/{payload.sticker_id}" + ) + + response = await self._botx_method_call( + "DELETE", + self._build_url(path), + ) + + self._verify_and_extract_api_model( + BotXAPIDeleteStickerResponsePayload, + response, + ) diff --git a/botx/client/stickers_api/delete_sticker_pack.py b/botx/client/stickers_api/delete_sticker_pack.py new file mode 100644 index 00000000..1bcef0ed --- /dev/null +++ b/botx/client/stickers_api/delete_sticker_pack.py @@ -0,0 +1,45 @@ +from typing import Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.stickers_api.exceptions import StickerPackOrStickerNotFoundError +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPIDeleteStickerPackRequestPayload(UnverifiedPayloadBaseModel): + sticker_pack_id: UUID + + @classmethod + def from_domain( + cls, + sticker_pack_id: UUID, + ) -> "BotXAPIDeleteStickerPackRequestPayload": + return cls(sticker_pack_id=sticker_pack_id) + + +class BotXAPIDeleteStickerPackResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class DeleteStickerPackMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(StickerPackOrStickerNotFoundError), + } + + async def execute( + self, + payload: BotXAPIDeleteStickerPackRequestPayload, + ) -> BotXAPIDeleteStickerPackResponsePayload: + path = f"/api/v3/botx/stickers/packs/{payload.sticker_pack_id}" + + response = await self._botx_method_call( + "DELETE", + self._build_url(path), + ) + + return self._verify_and_extract_api_model( + BotXAPIDeleteStickerPackResponsePayload, + response, + ) diff --git a/botx/client/stickers_api/edit_sticker_pack.py b/botx/client/stickers_api/edit_sticker_pack.py new file mode 100644 index 00000000..191e51f6 --- /dev/null +++ b/botx/client/stickers_api/edit_sticker_pack.py @@ -0,0 +1,57 @@ +from typing import List, Optional +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.stickers_api.exceptions import StickerPackOrStickerNotFoundError +from botx.client.stickers_api.sticker_pack import BotXAPIGetStickerPackResponsePayload +from botx.models.api_base import UnverifiedPayloadBaseModel + + +class BotXAPIEditStickerPackRequestPayload(UnverifiedPayloadBaseModel): + sticker_pack_id: UUID + name: str + preview: UUID + stickers_order: Optional[List[UUID]] + + @classmethod + def from_domain( + cls, + sticker_pack_id: UUID, + name: str, + preview: UUID, + stickers_order: Optional[List[UUID]], + ) -> "BotXAPIEditStickerPackRequestPayload": + return cls( + sticker_pack_id=sticker_pack_id, + name=name, + preview=preview, + stickers_order=stickers_order, + ) + + +class EditStickerPackMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(StickerPackOrStickerNotFoundError), + } + + async def execute( + self, + payload: BotXAPIEditStickerPackRequestPayload, + ) -> BotXAPIGetStickerPackResponsePayload: + jsonable_dict = payload.jsonable_dict() + sticker_pack_id = jsonable_dict.pop("sticker_pack_id") + + path = f"/api/v3/botx/stickers/packs/{sticker_pack_id}" + + response = await self._botx_method_call( + "PUT", + self._build_url(path), + json=jsonable_dict, + ) + + return self._verify_and_extract_api_model( + BotXAPIGetStickerPackResponsePayload, + response, + ) diff --git a/botx/client/stickers_api/exceptions.py b/botx/client/stickers_api/exceptions.py new file mode 100644 index 00000000..4e6af776 --- /dev/null +++ b/botx/client/stickers_api/exceptions.py @@ -0,0 +1,13 @@ +from botx.client.exceptions.base import BaseClientError + + +class StickerPackOrStickerNotFoundError(BaseClientError): + """Sticker pack or sticker with specified id not found.""" + + +class InvalidEmojiError(BaseClientError): + """Bad emoji.""" + + +class InvalidImageError(BaseClientError): + """Bad image.""" diff --git a/botx/client/stickers_api/get_sticker.py b/botx/client/stickers_api/get_sticker.py new file mode 100644 index 00000000..81a37c08 --- /dev/null +++ b/botx/client/stickers_api/get_sticker.py @@ -0,0 +1,66 @@ +from typing import Literal +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.stickers_api.exceptions import StickerPackOrStickerNotFoundError +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.stickers import Sticker + + +class BotXAPIGetStickerRequestPayload(UnverifiedPayloadBaseModel): + sticker_pack_id: UUID + sticker_id: UUID + + @classmethod + def from_domain( + cls, + sticker_pack_id: UUID, + sticker_id: UUID, + ) -> "BotXAPIGetStickerRequestPayload": + return cls(sticker_pack_id=sticker_pack_id, sticker_id=sticker_id) + + +class BotXAPIGetStickerResult(VerifiedPayloadBaseModel): + id: UUID + emoji: str + link: str + + +class BotXAPIGetStickerResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIGetStickerResult + + def to_domain(self) -> Sticker: + return Sticker( + id=self.result.id, + emoji=self.result.emoji, + image_link=self.result.link, + ) + + +class GetStickerMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(StickerPackOrStickerNotFoundError), + } + + async def execute( + self, + payload: BotXAPIGetStickerRequestPayload, + ) -> BotXAPIGetStickerResponsePayload: + jsonable_dict = payload.jsonable_dict() + path = ( + f"/api/v3/botx/stickers/packs/{jsonable_dict['sticker_pack_id']}/" + f"stickers/{jsonable_dict['sticker_id']}" + ) + + response = await self._botx_method_call( + "GET", + self._build_url(path), + ) + + return self._verify_and_extract_api_model( + BotXAPIGetStickerResponsePayload, + response, + ) diff --git a/botx/client/stickers_api/get_sticker_pack.py b/botx/client/stickers_api/get_sticker_pack.py new file mode 100644 index 00000000..443df163 --- /dev/null +++ b/botx/client/stickers_api/get_sticker_pack.py @@ -0,0 +1,42 @@ +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.stickers_api.exceptions import StickerPackOrStickerNotFoundError +from botx.client.stickers_api.sticker_pack import BotXAPIGetStickerPackResponsePayload +from botx.models.api_base import UnverifiedPayloadBaseModel + + +class BotXAPIGetStickerPackRequestPayload(UnverifiedPayloadBaseModel): + sticker_pack_id: UUID + + @classmethod + def from_domain( + cls, + sticker_pack_id: UUID, + ) -> "BotXAPIGetStickerPackRequestPayload": + return cls(sticker_pack_id=sticker_pack_id) + + +class GetStickerPackMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(StickerPackOrStickerNotFoundError), + } + + async def execute( + self, + payload: BotXAPIGetStickerPackRequestPayload, + ) -> BotXAPIGetStickerPackResponsePayload: + jsonable_dict = payload.jsonable_dict() + path = f"/api/v3/botx/stickers/packs/{jsonable_dict['sticker_pack_id']}" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + ) + + return self._verify_and_extract_api_model( + BotXAPIGetStickerPackResponsePayload, + response, + ) diff --git a/botx/client/stickers_api/get_sticker_packs.py b/botx/client/stickers_api/get_sticker_packs.py new file mode 100644 index 00000000..6800d429 --- /dev/null +++ b/botx/client/stickers_api/get_sticker_packs.py @@ -0,0 +1,77 @@ +from typing import List, Literal, Optional +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.stickers import StickerPackFromList, StickerPackPage + + +class BotXAPIGetStickerPacksRequestPayload(UnverifiedPayloadBaseModel): + user_huid: UUID + limit: int + after: Optional[str] + + @classmethod + def from_domain( + cls, + huid: UUID, + limit: int, + after: Optional[str], + ) -> "BotXAPIGetStickerPacksRequestPayload": + return cls(user_huid=huid, limit=limit, after=after) + + +class BotXAPIGetPaginationResult(VerifiedPayloadBaseModel): + after: Optional[str] + + +class BotXAPIGetStickerPackResult(VerifiedPayloadBaseModel): + id: UUID + name: str + public: bool + stickers_count: int + stickers_order: Optional[List[UUID]] + + +class BotXAPIGetStickerPacksResult(VerifiedPayloadBaseModel): + packs: List[BotXAPIGetStickerPackResult] + pagination: BotXAPIGetPaginationResult + + +class BotXAPIGetStickerPacksResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIGetStickerPacksResult + + def to_domain(self) -> StickerPackPage: + return StickerPackPage( + sticker_packs=[ + StickerPackFromList( + id=sticker_pack.id, + name=sticker_pack.name, + is_public=sticker_pack.public, + stickers_count=sticker_pack.stickers_count, + sticker_ids=sticker_pack.stickers_order, + ) + for sticker_pack in self.result.packs + ], + after=self.result.pagination.after, + ) + + +class GetStickerPacksMethod(AuthorizedBotXMethod): + async def execute( + self, + payload: BotXAPIGetStickerPacksRequestPayload, + ) -> BotXAPIGetStickerPacksResponsePayload: + path = "/api/v3/botx/stickers/packs" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + params=payload.jsonable_dict(), + ) + + return self._verify_and_extract_api_model( + BotXAPIGetStickerPacksResponsePayload, + response, + ) diff --git a/botx/client/stickers_api/sticker_pack.py b/botx/client/stickers_api/sticker_pack.py new file mode 100644 index 00000000..a03b3e45 --- /dev/null +++ b/botx/client/stickers_api/sticker_pack.py @@ -0,0 +1,42 @@ +from typing import List, Literal, Optional +from uuid import UUID + +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.stickers import Sticker, StickerPack + + +class BotXAPIGetStickerResult(VerifiedPayloadBaseModel): + id: UUID + emoji: str + link: str + + +class BotXAPIGetStickerPackResult(VerifiedPayloadBaseModel): + id: UUID + name: str + public: bool + stickers_order: Optional[List[UUID]] + stickers: List[BotXAPIGetStickerResult] + + +class BotXAPIGetStickerPackResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPIGetStickerPackResult + + def to_domain(self) -> StickerPack: + if self.result.stickers_order: + self.result.stickers.sort( + key=lambda pack: self.result.stickers_order.index( # type:ignore + pack.id, + ), + ) + + return StickerPack( + id=self.result.id, + name=self.result.name, + is_public=self.result.public, + stickers=[ + Sticker(id=sticker.id, emoji=sticker.emoji, image_link=sticker.link) + for sticker in self.result.stickers + ], + ) diff --git a/tests/fixtures/__init__.py b/botx/client/users_api/__init__.py similarity index 100% rename from tests/fixtures/__init__.py rename to botx/client/users_api/__init__.py diff --git a/botx/client/users_api/search_user_by_email.py b/botx/client/users_api/search_user_by_email.py new file mode 100644 index 00000000..0a820e79 --- /dev/null +++ b/botx/client/users_api/search_user_by_email.py @@ -0,0 +1,37 @@ +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.users import UserNotFoundError +from botx.client.users_api.user_from_search import BotXAPISearchUserResponsePayload +from botx.models.api_base import UnverifiedPayloadBaseModel + + +class BotXAPISearchUserByEmailRequestPayload(UnverifiedPayloadBaseModel): + email: str + + @classmethod + def from_domain(cls, email: str) -> "BotXAPISearchUserByEmailRequestPayload": + return cls(email=email) + + +class SearchUserByEmailMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(UserNotFoundError), + } + + async def execute( + self, + payload: BotXAPISearchUserByEmailRequestPayload, + ) -> BotXAPISearchUserResponsePayload: + path = "/api/v3/botx/users/by_email" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + params=payload.jsonable_dict(), + ) + + return self._verify_and_extract_api_model( + BotXAPISearchUserResponsePayload, + response, + ) diff --git a/botx/client/users_api/search_user_by_huid.py b/botx/client/users_api/search_user_by_huid.py new file mode 100644 index 00000000..5e806fbe --- /dev/null +++ b/botx/client/users_api/search_user_by_huid.py @@ -0,0 +1,39 @@ +from uuid import UUID + +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.users import UserNotFoundError +from botx.client.users_api.user_from_search import BotXAPISearchUserResponsePayload +from botx.models.api_base import UnverifiedPayloadBaseModel + + +class BotXAPISearchUserByHUIDRequestPayload(UnverifiedPayloadBaseModel): + user_huid: UUID + + @classmethod + def from_domain(cls, huid: UUID) -> "BotXAPISearchUserByHUIDRequestPayload": + return cls(user_huid=huid) + + +class SearchUserByHUIDMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(UserNotFoundError), + } + + async def execute( + self, + payload: BotXAPISearchUserByHUIDRequestPayload, + ) -> BotXAPISearchUserResponsePayload: + path = "/api/v3/botx/users/by_huid" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + params=payload.jsonable_dict(), + ) + + return self._verify_and_extract_api_model( + BotXAPISearchUserResponsePayload, + response, + ) diff --git a/botx/client/users_api/search_user_by_login.py b/botx/client/users_api/search_user_by_login.py new file mode 100644 index 00000000..34175e64 --- /dev/null +++ b/botx/client/users_api/search_user_by_login.py @@ -0,0 +1,42 @@ +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from botx.client.botx_method import response_exception_thrower +from botx.client.exceptions.users import UserNotFoundError +from botx.client.users_api.user_from_search import BotXAPISearchUserResponsePayload +from botx.models.api_base import UnverifiedPayloadBaseModel + + +class BotXAPISearchUserByLoginRequestPayload(UnverifiedPayloadBaseModel): + ad_login: str + ad_domain: str + + @classmethod + def from_domain( + cls, + ad_login: str, + ad_domain: str, + ) -> "BotXAPISearchUserByLoginRequestPayload": + return cls(ad_login=ad_login, ad_domain=ad_domain) + + +class SearchUserByLoginMethod(AuthorizedBotXMethod): + status_handlers = { + **AuthorizedBotXMethod.status_handlers, + 404: response_exception_thrower(UserNotFoundError), + } + + async def execute( + self, + payload: BotXAPISearchUserByLoginRequestPayload, + ) -> BotXAPISearchUserResponsePayload: + path = "/api/v3/botx/users/by_login" + + response = await self._botx_method_call( + "GET", + self._build_url(path), + params=payload.jsonable_dict(), + ) + + return self._verify_and_extract_api_model( + BotXAPISearchUserResponsePayload, + response, + ) diff --git a/botx/client/users_api/user_from_search.py b/botx/client/users_api/user_from_search.py new file mode 100644 index 00000000..448df6d4 --- /dev/null +++ b/botx/client/users_api/user_from_search.py @@ -0,0 +1,35 @@ +from typing import List, Literal, Optional +from uuid import UUID + +from pydantic import Field + +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.users import UserFromSearch + + +class BotXAPISearchUserResult(VerifiedPayloadBaseModel): + user_huid: UUID + ad_login: Optional[str] = None + ad_domain: Optional[str] = None + name: str + company: Optional[str] = None + company_position: Optional[str] = None + department: Optional[str] = None + emails: List[str] = Field(default_factory=list) + + +class BotXAPISearchUserResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPISearchUserResult + + def to_domain(self) -> UserFromSearch: + return UserFromSearch( + huid=self.result.user_huid, + ad_login=self.result.ad_login, + ad_domain=self.result.ad_domain, + username=self.result.name, + company=self.result.company, + company_position=self.result.company_position, + department=self.result.department, + emails=self.result.emails, + ) diff --git a/botx/clients/__init__.py b/botx/clients/__init__.py deleted file mode 100644 index 2434158d..00000000 --- a/botx/clients/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for BotX API related parts: requests, clients, types.""" diff --git a/botx/clients/clients/__init__.py b/botx/clients/clients/__init__.py deleted file mode 100644 index 7199c19d..00000000 --- a/botx/clients/clients/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for clients that make requests to BotX API.""" diff --git a/botx/clients/clients/async_client.py b/botx/clients/clients/async_client.py deleted file mode 100644 index 3d6bce71..00000000 --- a/botx/clients/clients/async_client.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Definition for async client for BotX API.""" -from dataclasses import field -from http import HTTPStatus -from json import JSONDecodeError -from typing import Any, List, TypeVar - -import httpx -from pydantic.dataclasses import dataclass - -from botx.clients.clients.processing import extract_result, handle_error -from botx.clients.methods.base import BotXMethod -from botx.clients.types.http import ExpectedType, HTTPRequest, HTTPResponse -from botx.converters import optional_sequence_to_list -from botx.exceptions import ( - BotXAPIError, - BotXAPIRouteDeprecated, - BotXConnectError, - BotXJSONDecodeError, -) -from botx.shared import BotXDataclassConfig - -ResponseT = TypeVar("ResponseT") - - -@dataclass(config=BotXDataclassConfig) -class AsyncClient: - """Async client for BotX API.""" - - http_client: httpx.AsyncClient = field(init=False) - interceptors: List[Any] = field(default_factory=list) - - def __post_init__(self) -> None: - """Init or update special fields.""" - self.http_client = httpx.AsyncClient() - self.interceptors = optional_sequence_to_list(self.interceptors) - - # TODO: Use host as argument here. - @classmethod - def build_request(cls, method: BotXMethod[ResponseT]) -> HTTPRequest: - """Build HTTPRequest from passed BotX method. - - Arguments: - method: BotX method. - - Returns: - Built request. - """ - return method.build_http_request() - - async def process_response( - self, - method: BotXMethod[ResponseT], - response: HTTPResponse, - ) -> ResponseT: - """Handle errors and extract data from BotX API response. - - Arguments: - method: BotX API method. - response: HTTPResponse that is result of method executing. - - Returns: - Shape specified for method response. - - Raises: - BotXAPIError: raised if handler for error status code was not found. - BotXAPIRouteDeprecated: raised if API route was deprecated. - """ - handlers_dict = method.error_handlers - error_handlers = handlers_dict.get(response.status_code) - if error_handlers is not None: - await handle_error(method, error_handlers, response) - - if response.status_code == HTTPStatus.GONE: - raise BotXAPIRouteDeprecated( - url=method.url, - method=method.http_method, - status=response.status_code, - response_content=response.json_body, - ) - - if response.is_error or response.is_redirect: - raise BotXAPIError( - url=method.url, - method=method.http_method, - status=response.status_code, - response_content=response.json_body, - ) - - return extract_result(method, response) - - async def execute(self, request: HTTPRequest) -> HTTPResponse: - """Make request to BotX API. - - Arguments: - request: HTTPRequest that was built from method. - - Returns: - HTTP response from API. - - Raises: - BotXConnectError: raised if unable to connect to service. - BotXJSONDecodeError: raised if service returned invalid body. - """ - try: - response = await self.http_client.request( - request.method, - request.url, - headers=request.headers, - params=request.query_params, - json=request.json_body, - data=request.data, - files=request.files, - ) - except httpx.HTTPError as httpx_exc: - raise BotXConnectError( - url=request.url, - method=request.method, - ) from httpx_exc - - headers = dict(response.headers) - - should_process_as_error = ( - response.status_code in request.should_process_as_error - ) - if ( # noqa: WPS337 - not response.is_error - and not should_process_as_error # noqa: W503 - and request.expected_type == ExpectedType.BINARY # noqa: W503 - ): - return HTTPResponse( - headers=headers, - status_code=response.status_code, - raw_data=response.read(), - ) - - try: - json_body = response.json() - except JSONDecodeError as exc: - raise BotXJSONDecodeError(url=request.url, method=request.method) from exc - - return HTTPResponse( - headers=headers, - status_code=response.status_code, - json_body=json_body, - ) diff --git a/botx/clients/clients/processing.py b/botx/clients/clients/processing.py deleted file mode 100644 index 7c98b50d..00000000 --- a/botx/clients/clients/processing.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Logic for handling response from BotX API for real HTTP responses.""" -import collections -import contextlib -from io import BytesIO -from typing import TypeVar - -from pydantic import ValidationError - -from botx import concurrency -from botx.clients.methods.base import APIResponse, BotXMethod, ErrorHandlersInMethod -from botx.clients.types.http import ExpectedType, HTTPResponse -from botx.models.files import File - -ResponseT = TypeVar("ResponseT") - - -def build_file(response: HTTPResponse) -> File: - """Build file from response raw data. - - Arguments: - response: HTTP response from BotX API. - - Returns: - Built file from response. - """ - mimetype = response.headers["content-type"].split(";", 1)[0] - ext = File.get_ext_by_mimetype(mimetype) or "" - file_name = "document{0}".format(ext) - return File.from_file(BytesIO(response.raw_data), file_name) # type: ignore - - -def extract_result( # noqa: WPS210 - method: BotXMethod[ResponseT], - response: HTTPResponse, -) -> ResponseT: - """Extract result from successful response and convert it to right shape. - - Arguments: - method: method to BotX API that was called. - response: HTTP response from BotX API. - - Returns: - Converted shape from BotX API. - """ - if method.expected_type == ExpectedType.BINARY: - return build_file(response) # type: ignore - - return_shape = method.returning - api_response = APIResponse[return_shape].parse_obj( # type: ignore - response.json_body, - ) - response_result = api_response.result - extractor = method.result_extractor - if extractor is not None: - # mypy does not understand that self passed here - return extractor(response_result) # type: ignore - - return response_result - - -async def handle_error( - method: BotXMethod, - error_handlers: ErrorHandlersInMethod, - response: HTTPResponse, -) -> None: - """Handle error status code from BotX API. - - Arguments: - method: method to BotX API that was called. - error_handlers: registered on method handlers for different responses. - response: HTTP response from BotX API. - """ - if not isinstance(error_handlers, collections.Sequence): - error_handlers = [error_handlers] - - for error_handler in error_handlers: - with contextlib.suppress(ValidationError): - await concurrency.callable_to_coroutine(error_handler, method, response) diff --git a/botx/clients/clients/sync_client.py b/botx/clients/clients/sync_client.py deleted file mode 100644 index a4ad37ed..00000000 --- a/botx/clients/clients/sync_client.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Definition for sync client for BotX API.""" -from dataclasses import field -from http import HTTPStatus -from json import JSONDecodeError -from typing import Any, List, TypeVar - -import httpx -from pydantic.dataclasses import dataclass - -from botx import concurrency -from botx.clients.clients.processing import extract_result, handle_error -from botx.clients.methods.base import BotXMethod, ErrorHandlersInMethod -from botx.clients.types.http import ExpectedType, HTTPRequest, HTTPResponse -from botx.converters import optional_sequence_to_list -from botx.exceptions import ( - BotXAPIError, - BotXAPIRouteDeprecated, - BotXConnectError, - BotXJSONDecodeError, -) -from botx.shared import BotXDataclassConfig - -ResponseT = TypeVar("ResponseT") - - -@dataclass(config=BotXDataclassConfig) -class Client: - """Sync client for BotX API.""" - - http_client: httpx.Client = field(init=False) - interceptors: List[Any] = field(default_factory=list) - - def __post_init__(self) -> None: - """Init or update special fields.""" - self.http_client = httpx.Client() - self.interceptors = optional_sequence_to_list(self.interceptors) - - @classmethod - def build_request(cls, method: BotXMethod[ResponseT]) -> HTTPRequest: - """Build HTTPRequest from passed BotX method. - - Arguments: - method: BotX method. - - Returns: - Built request. - """ - return method.build_http_request() - - def process_response( - self, - method: BotXMethod[ResponseT], - response: HTTPResponse, - ) -> ResponseT: - """Handle errors and extract data from BotX API response. - - Arguments: - method: BotX API method. - response: HTTPResponse that is result of method executing. - - Returns: - Shape specified for method response. - - Raises: - BotXAPIError: raised if handler for error status code was not found. - BotXAPIRouteDeprecated: raised if API route was deprecated. - """ - handlers_dict = method.error_handlers - error_handlers = handlers_dict.get(response.status_code) - if error_handlers is not None: - _handle_error(method, error_handlers, response) - - if response.status_code == HTTPStatus.GONE: - raise BotXAPIRouteDeprecated( - url=method.url, - method=method.http_method, - status=response.status_code, - response_content=response.json_body, - ) - - if response.is_error or response.is_redirect: - raise BotXAPIError( - url=method.url, - method=method.http_method, - status=response.status_code, - response_content=response.json_body, - ) - - return extract_result(method, response) - - def execute(self, request: HTTPRequest) -> HTTPResponse: - """Make request to BotX API. - - Arguments: - request: HTTPRequest that was built from method. - - Returns: - HTTP response from API. - - Raises: - BotXConnectError: raised if unable to connect to service. - BotXJSONDecodeError: raised if service returned invalid body. - """ - try: - response = self.http_client.request( - request.method, - request.url, - headers=request.headers, - params=request.query_params, - json=request.json_body, - data=request.data, - files=request.files, - ) - except httpx.HTTPError as httpx_exc: - raise BotXConnectError( - url=request.url, - method=request.method, - ) from httpx_exc - - headers = dict(response.headers) - - should_process_as_error = ( - response.status_code in request.should_process_as_error - ) - if ( # noqa: WPS337 - not response.is_error - and not should_process_as_error # noqa: W503 - and request.expected_type == ExpectedType.BINARY # noqa: W503 - ): - return HTTPResponse( - headers=headers, - status_code=response.status_code, - raw_data=response.read(), - ) - - try: - json_body = response.json() - except JSONDecodeError as exc: - raise BotXJSONDecodeError(url=request.url, method=request.method) from exc - - return HTTPResponse( - headers=headers, - status_code=response.status_code, - json_body=json_body, - ) - - -def _handle_error( - method: BotXMethod, - error_handlers: ErrorHandlersInMethod, - response: HTTPResponse, -) -> None: - concurrency.async_to_sync(handle_error)(method, error_handlers, response) diff --git a/botx/clients/interceptors/__init__.py b/botx/clients/interceptors/__init__.py deleted file mode 100644 index 92a142d2..00000000 --- a/botx/clients/interceptors/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for requests interceptors for clietns.""" diff --git a/botx/clients/methods/__init__.py b/botx/clients/methods/__init__.py deleted file mode 100644 index 848781a1..00000000 --- a/botx/clients/methods/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for logic of requests to BotX API.""" diff --git a/botx/clients/methods/base.py b/botx/clients/methods/base.py deleted file mode 100644 index 74b35fef..00000000 --- a/botx/clients/methods/base.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Definition for base class that is responsible for request to BotX API.""" -from __future__ import annotations - -import json -import typing -from abc import ABC, abstractmethod -from urllib.parse import urljoin - -from httpx import Response -from pydantic import BaseConfig, BaseModel, Extra -from pydantic.generics import GenericModel - -from botx.clients.types.http import ( - ExpectedType, - HTTPRequest, - HTTPResponse, - PrimitiveDataType, -) -from botx.models.enums import Statuses - -try: - from typing import Literal # noqa: WPS433, WPS458 -except ImportError: - from typing_extensions import Literal # type: ignore # noqa: WPS433, WPS440, F401 - -ResponseT = typing.TypeVar("ResponseT") -SyncErrorHandler = typing.Callable[["BotXMethod", HTTPResponse], typing.NoReturn] -AsyncErrorHandler = typing.Callable[ - ["BotXMethod", Response], - typing.Awaitable[typing.NoReturn], -] -ErrorHandler = typing.Union[SyncErrorHandler, AsyncErrorHandler] -ErrorHandlersInMethod = typing.Union[typing.Sequence[ErrorHandler], ErrorHandler] - - -class APIResponse(GenericModel, typing.Generic[ResponseT]): - """Model for successful response from BotX API.""" - - #: status of requested operation response. - status: Literal[Statuses.ok] = Statuses.ok - - #: generic response shape. - result: ResponseT - - -class APIErrorResponse(GenericModel, typing.Generic[ResponseT]): - """Model for error response from BotX API.""" - - #: status of requested operation response. - status: Literal[Statuses.error] = Statuses.error - - #: reason why operation failed - reason: str - - #: errors from API. - errors: typing.List[str] - - #: additional payload with more data about error. - error_data: ResponseT - - -class AbstractBotXMethod(ABC, typing.Generic[ResponseT]): - """Abstract base class for BotX request.""" - - @property - @abstractmethod - def __url__(self) -> str: - """Path for method in BotX API.""" - - @property - @abstractmethod - def __method__(self) -> str: - """HTTP method used for method.""" - - @property - @abstractmethod - def __returning__(self) -> typing.Type[typing.Any]: - """Shape returned from method that can be parsed by pydantic.""" - - @property - def __errors_handlers__(self) -> typing.Mapping[int, ErrorHandlersInMethod]: - """Error handlers for responses from BotX API by status code and handler.""" - return typing.cast(typing.Mapping[int, ErrorHandlersInMethod], {}) - - @property - def __result_extractor__( - self, - ) -> typing.Optional[typing.Callable[[BotXMethod, typing.Any], ResponseT]]: - """Extractor for response shape from BotX API.""" - return None # noqa: WPS324 - - @property - def __expected_type__(self) -> ExpectedType: - """Extractor of expected type of response body.""" - return ExpectedType.JSON - - -CREDENTIALS_FIELDS = frozenset(("token", "host", "scheme")) - - -class BaseBotXMethod(AbstractBotXMethod[ResponseT], ABC): # noqa: WPS214 - """Base logic that is responsible for configuration and shortcuts for fields.""" - - #: host where request should be sent. - host: str = "" - - #: token for request. - token: str = "" - - #: HTTP scheme for request. - scheme: str = "https" - - @property - def url(self) -> str: - """Full URL for request.""" - base_url = "{scheme}://{host}".format(scheme=self.scheme, host=self.host) - return urljoin(base_url, self.__url__) - - @property - def http_method(self) -> str: - """HTTP method for request.""" - return self.__method__ - - @property - def headers(self) -> typing.Dict[str, str]: - """Headers that should be used in request.""" - return {"Content-Type": "application/json"} - - @property - def query_params(self) -> typing.Dict[str, PrimitiveDataType]: - """Query string query_params for request.""" - return {} - - @property - def returning(self) -> typing.Type[typing.Any]: - """Shape returned from method that can be parsed by pydantic.""" - return self.__returning__ - - @property - def error_handlers(self) -> typing.Mapping[int, ErrorHandlersInMethod]: - """Error handlers for responses from BotX API by status code and handler.""" - return self.__errors_handlers__ - - @property - def result_extractor( - self, - ) -> typing.Optional[typing.Callable[[BotXMethod, typing.Any], ResponseT]]: - """Extractor for response shape from BotX API.""" - return self.__result_extractor__ - - @property - def expected_type(self) -> ExpectedType: - """Extractor of expected type of response body.""" - return self.__expected_type__ - - -class BotXMethod(BaseBotXMethod[ResponseT], BaseModel, ABC): - """Method for BotX API that should be extended by actual implementation.""" - - class Config(BaseConfig): - extra = Extra.allow - allow_population_by_field_name = True - arbitrary_types_allowed = True - orm_mode = True - - def configure(self, *, host: str, token: str, scheme: str = "https") -> None: - """Configure request with credentials and transport related stuff. - - Arguments: - host: host where request should be sent. - token: token for request. - scheme: HTTP scheme for request. - """ - self.token = token - self.host = host - self.scheme = scheme - - def build_serialized_dict( - self, - ) -> typing.Optional[typing.Dict[str, PrimitiveDataType]]: - """Build serialized dict (with only primitive types) for request. - - Returns: - Serialized dict. - """ - # TODO: Waiting for - serialized_dict = json.loads( - self.json( - by_alias=True, - exclude=CREDENTIALS_FIELDS, - exclude_none=True, - ), - ) - - # Because exclude_none removes empty file key on message update - if hasattr(self, "file") and self.file is None: # type: ignore # noqa: WPS421 - serialized_dict["file"] = None - - return serialized_dict - - def build_http_request(self) -> HTTPRequest: - """Build HTTP request that can be used by clients for making real requests. - - Returns: - Built HTTP request. - """ - request_params = self.query_params - request_data = self.build_serialized_dict() - - if self.__method__ == "GET" and request_data: - request_params = request_data - request_data = None - - return HTTPRequest( - method=self.__method__, - url=self.url, - headers=self.headers, - query_params=dict(request_params), - json_body=request_data, - ) - - -class AuthorizedBotXMethod(BotXMethod[ResponseT], ABC): - """Method for BotX API that adds authorization token.""" - - @property - def headers(self) -> typing.Dict[str, str]: - """Headers that should be used in request.""" - headers = super().headers - headers["Authorization"] = "Bearer {token}".format(token=self.token) - return headers diff --git a/botx/clients/methods/errors/__init__.py b/botx/clients/methods/errors/__init__.py deleted file mode 100644 index 86529f8e..00000000 --- a/botx/clients/methods/errors/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for built-in error handlers for responses from BotX API.""" diff --git a/botx/clients/methods/errors/bot_is_not_admin.py b/botx/clients/methods/errors/bot_is_not_admin.py deleted file mode 100644 index 1cde3a08..00000000 --- a/botx/clients/methods/errors/bot_is_not_admin.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Definition for "bot is not admin" error.""" -from typing import NoReturn -from uuid import UUID - -from pydantic import BaseModel - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class BotIsNotAdminError(BotXAPIError): - """Error for raising when bot is not admin.""" - - message_template = "bot {bot_id} is not admin of chat {group_chat_id}" - - #: ID of bot that sent request. - bot_id: UUID - - #: ID of chat into which request was sent. - group_chat_id: UUID - - -class BotIsNotAdminData(BaseModel): - """Data for error when bot is not admin.""" - - #: ID of sender (bot) - sender: UUID - - #: ID of chat into which request was sent. - group_chat_id: UUID - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "bot is not admin" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - BotIsNotAdminError: raised always. - """ - error_data = ( - APIErrorResponse[BotIsNotAdminData].parse_obj(response.json_body).error_data - ) - raise BotIsNotAdminError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - bot_id=error_data.sender, - group_chat_id=error_data.group_chat_id, - ) diff --git a/botx/clients/methods/errors/bot_not_found.py b/botx/clients/methods/errors/bot_not_found.py deleted file mode 100644 index ab08af3b..00000000 --- a/botx/clients/methods/errors/bot_not_found.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Definition for "bot not found" error.""" -from typing import NoReturn - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class BotNotFoundError(BotXAPIError): - """Error for raising when bot not found.""" - - message_template = "bot with id `{bot_id}` not found. " - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "bot not found" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - BotNotFoundError: raised always. - """ - APIErrorResponse[dict].parse_obj(response.json_body) - raise BotNotFoundError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - bot_id=method.bot_id, # type: ignore - ) diff --git a/botx/clients/methods/errors/chat_creation_disallowed.py b/botx/clients/methods/errors/chat_creation_disallowed.py deleted file mode 100644 index 080febc7..00000000 --- a/botx/clients/methods/errors/chat_creation_disallowed.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Definition for "chat creating disallowed" error.""" -from typing import NoReturn -from uuid import UUID - -from pydantic import BaseModel - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class ChatCreationDisallowedError(BotXAPIError): - """Error for raising when bot is not allowed to create chats.""" - - message_template = "bot {bot_id} is not allowed to create chats" - - #: ID of bot that sent request. - bot_id: UUID - - -class ChatCreationDisallowedData(BaseModel): - """Data for error when bot is not allowed to create chats.""" - - #: ID of bot that sent request. - bot_id: UUID - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "chat creating disallowed" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - ChatCreationDisallowedError: raised always. - """ - parsed_response = APIErrorResponse[ChatCreationDisallowedData].parse_obj( - response.json_body, - ) - error_data = parsed_response.error_data - raise ChatCreationDisallowedError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - bot_id=error_data.bot_id, - ) diff --git a/botx/clients/methods/errors/chat_creation_error.py b/botx/clients/methods/errors/chat_creation_error.py deleted file mode 100644 index 789f72eb..00000000 --- a/botx/clients/methods/errors/chat_creation_error.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Definition for "chat creation error".""" -from typing import NoReturn - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class ChatCreationError(BotXAPIError): - """Error for raising when there is error for chat creation.""" - - message_template = "error while creating chat" - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "chat creation error" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - ChatCreationError: raised always. - """ - APIErrorResponse[dict].parse_obj(response.json_body) - raise ChatCreationError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - ) diff --git a/botx/clients/methods/errors/chat_is_not_modifiable.py b/botx/clients/methods/errors/chat_is_not_modifiable.py deleted file mode 100644 index 8b123378..00000000 --- a/botx/clients/methods/errors/chat_is_not_modifiable.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Definition for "chat is not modifiable" error.""" -from typing import NoReturn -from uuid import UUID - -from pydantic import BaseModel - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class PersonalChatIsNotModifiableError(BotXAPIError): - """Error for raising when chat is not modifiable.""" - - message_template = "personal chat is not modifiable" - - -class PersonalChatIsNotModifiableData(BaseModel): - """Data for error when chat is not modifiable.""" - - #: ID of chat that can not be modified. - group_chat_id: UUID - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "chat creation error" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - PersonalChatIsNotModifiableError: raised always. - """ - parsed_response = APIErrorResponse[PersonalChatIsNotModifiableData].parse_obj( - response.json_body, - ) - error_data = parsed_response.error_data - raise PersonalChatIsNotModifiableError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - group_chat_id=error_data.group_chat_id, - ) diff --git a/botx/clients/methods/errors/chat_not_found.py b/botx/clients/methods/errors/chat_not_found.py deleted file mode 100644 index 78544d69..00000000 --- a/botx/clients/methods/errors/chat_not_found.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Definition for "chat not found" error.""" -from typing import NoReturn -from uuid import UUID - -from pydantic import BaseModel - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class ChatNotFoundError(BotXAPIError): - """Error for raising when chat not found.""" - - message_template = "chat {group_chat_id} not found" - - #: ID of chat that was requested. - group_chat_id: UUID - - -class ChatNotFoundData(BaseModel): - """Data for error when chat not found.""" - - #: ID of chat that was requested. - group_chat_id: UUID - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "chat creation error" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - ChatNotFoundError: raised always. - """ - parsed_response = APIErrorResponse[ChatNotFoundData].parse_obj(response.json_body) - - error_data = parsed_response.error_data - raise ChatNotFoundError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - group_chat_id=error_data.group_chat_id, - ) diff --git a/botx/clients/methods/errors/files/__init__.py b/botx/clients/methods/errors/files/__init__.py deleted file mode 100644 index 86529f8e..00000000 --- a/botx/clients/methods/errors/files/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for built-in error handlers for responses from BotX API.""" diff --git a/botx/clients/methods/errors/files/chat_not_found.py b/botx/clients/methods/errors/files/chat_not_found.py deleted file mode 100644 index 1858b48f..00000000 --- a/botx/clients/methods/errors/files/chat_not_found.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Definition for "chat not found" error.""" -from typing import NoReturn -from uuid import UUID - -from pydantic import BaseModel - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class ChatNotFoundError(BotXAPIError): - """Error for raising when chat not found.""" - - message_template = "{error_description}" - - #: description of error. - error_description: str - - -class ChatNotFoundData(BaseModel): - """Data for error when chat not found.""" - - #: ID of chat where file is from. - group_chat_id: UUID - - #: description of error. - error_description: str - - class Config: - extra = "forbid" - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "chat not found" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - ChatNotFoundError: raised always. - """ - parsed_response = APIErrorResponse[ChatNotFoundData].parse_obj(response.json_body) - - error_data = parsed_response.error_data - raise ChatNotFoundError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - error_description=error_data.error_description, - ) diff --git a/botx/clients/methods/errors/files/file_deleted.py b/botx/clients/methods/errors/files/file_deleted.py deleted file mode 100644 index 8f3216db..00000000 --- a/botx/clients/methods/errors/files/file_deleted.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Definition for "file deleted" error.""" -from typing import NoReturn - -from pydantic import BaseModel - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class FileDeletedError(BotXAPIError): - """Error for raising when file deleted.""" - - message_template = "{error_description}" - - #: description of error. - error_description: str - - -class FileDeletedErrorData(BaseModel): - """Data for error when file deleted.""" - - #: link of deleted file. - link: str - - #: description of error. - error_description: str - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "file deleted" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - FileDeletedError: raised always. - """ - parsed_response = APIErrorResponse[FileDeletedErrorData].parse_obj( - response.json_body, - ) - - error_data = parsed_response.error_data - raise FileDeletedError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - error_description=error_data.error_description, - ) diff --git a/botx/clients/methods/errors/files/metadata_not_found.py b/botx/clients/methods/errors/files/metadata_not_found.py deleted file mode 100644 index f1239583..00000000 --- a/botx/clients/methods/errors/files/metadata_not_found.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Definition for "file metadata not found" error.""" -from typing import NoReturn -from uuid import UUID - -from pydantic import BaseModel - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class MetadataNotFoundError(BotXAPIError): - """Error for raising when file metadata not found.""" - - message_template = ( - "File with specified file_id `{file_id}` and " - "group_chat_id `{group_chat_id}` not found in file service." - ) - - #: ID of file which metadata was requested. - file_id: UUID - - #: ID of chat where file is from. - group_chat_id: UUID - - -class MetadataNotFoundData(BaseModel): - """Data for error when file metadata not found.""" - - #: ID of file which metadata was requested. - file_id: UUID - - #: ID of chat where file is from. - group_chat_id: UUID - - #: description of error. - error_description: str - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "file metadata not found" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - MetadataNotFoundError: raised always. - """ - parsed_response = APIErrorResponse[MetadataNotFoundData].parse_obj( - response.json_body, - ) - - error_data = parsed_response.error_data - raise MetadataNotFoundError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - file_id=error_data.file_id, - group_chat_id=error_data.group_chat_id, - ) diff --git a/botx/clients/methods/errors/files/without_preview.py b/botx/clients/methods/errors/files/without_preview.py deleted file mode 100644 index e78c478e..00000000 --- a/botx/clients/methods/errors/files/without_preview.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Definition for "without preview" error.""" -from typing import NoReturn -from uuid import UUID - -from pydantic import BaseModel - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class WithoutPreviewError(BotXAPIError): - """Error for raising when there is no file preview.""" - - message_template = "{error_description}" - - #: description of error. - error_description: str - - -class WithoutPreviewData(BaseModel): - """Data for error when there is no file preview.""" - - #: ID of file which preview was requested. - file_id: UUID - - #: ID of chat where file is from. - group_chat_id: UUID - - #: description of error. - error_description: str - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "without preview" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - WithoutPreviewError: raised always. - """ - parsed_response = APIErrorResponse[WithoutPreviewData].parse_obj(response.json_body) - - error_data = parsed_response.error_data - raise WithoutPreviewError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - error_description=error_data.error_description, - ) diff --git a/botx/clients/methods/errors/messaging.py b/botx/clients/methods/errors/messaging.py deleted file mode 100644 index 03d806fb..00000000 --- a/botx/clients/methods/errors/messaging.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Definition for "messaging" error.""" -from typing import NoReturn - -from botx.clients.methods.base import BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class MessagingError(BotXAPIError): - """Error for raising when there is messaging error.""" - - message_template = "error from messaging service" - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle messaging error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - MessagingError: raised always. - """ - raise MessagingError( - url=method.url, - method=method.http_method, - response=response.json_body, - status=response.status_code, - ) diff --git a/botx/clients/methods/errors/permissions.py b/botx/clients/methods/errors/permissions.py deleted file mode 100644 index 0a37b99a..00000000 --- a/botx/clients/methods/errors/permissions.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Definition for "no permission" error.""" -from typing import NoReturn -from uuid import UUID - -from pydantic import BaseModel - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class NoPermissionError(BotXAPIError): - """Error for raising when there is no permission for operation.""" - - message_template = ( - "Bot doesn't have permission for this operation in chat {group_chat_id}" - ) - - #: ID of chat that was requested. - group_chat_id: UUID - - -class NoPermissionErrorData(BaseModel): - """Data for error when there is no permission for operation.""" - - #: ID of chat that was requested. - group_chat_id: UUID - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "no permission" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - NoPermissionError: raised always. - """ - parsed_response = APIErrorResponse[NoPermissionErrorData].parse_obj( - response.json_body, - ) - - error_data = parsed_response.error_data - raise NoPermissionError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - group_chat_id=error_data.group_chat_id, - ) diff --git a/botx/clients/methods/errors/stickers/__init__.py b/botx/clients/methods/errors/stickers/__init__.py deleted file mode 100644 index 86529f8e..00000000 --- a/botx/clients/methods/errors/stickers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for built-in error handlers for responses from BotX API.""" diff --git a/botx/clients/methods/errors/stickers/image_not_valid.py b/botx/clients/methods/errors/stickers/image_not_valid.py deleted file mode 100644 index ecf6c191..00000000 --- a/botx/clients/methods/errors/stickers/image_not_valid.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Definition for "image is not valid" error.""" -from typing import NoReturn - -from botx.clients.methods.base import BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class ImageNotValidError(BotXAPIError): - """Error for raising when image is not valid.""" - - message_template = "image is not valid" - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "image is not valid" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - ImageNotValidError: raised always. - """ - raise ImageNotValidError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - ) diff --git a/botx/clients/methods/errors/stickers/sticker_pack_not_found.py b/botx/clients/methods/errors/stickers/sticker_pack_not_found.py deleted file mode 100644 index 3e593eca..00000000 --- a/botx/clients/methods/errors/stickers/sticker_pack_not_found.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Definition for "sticker pack was not found" error.""" -from typing import NoReturn -from uuid import UUID - -from pydantic import BaseModel - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class StickerPackNotFoundError(BotXAPIError): - """Error for raising when sticker pack was not found.""" - - message_template = "sticker pack {pack_id} not found" - - #: sticker pack ID. - pack_id: UUID - - -class StickerPackNotFoundData(BaseModel): - """Data for error when sticker pack was not found.""" - - #: sticker pack ID. - pack_id: UUID - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "sticker pack getting error" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - StickerPackNotFoundError: raised always. - """ - parsed_response = APIErrorResponse[StickerPackNotFoundData].parse_obj( - response.json_body, - ) - - error_data = parsed_response.error_data - raise StickerPackNotFoundError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - pack_id=error_data.pack_id, - ) diff --git a/botx/clients/methods/errors/stickers/sticker_pack_or_sticker_not_found.py b/botx/clients/methods/errors/stickers/sticker_pack_or_sticker_not_found.py deleted file mode 100644 index b6e354da..00000000 --- a/botx/clients/methods/errors/stickers/sticker_pack_or_sticker_not_found.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Definition for "sticker pack or sticker was not found" error.""" -from typing import NoReturn -from uuid import UUID - -from pydantic import BaseModel - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class StickerPackOrStickerNotFoundError(BotXAPIError): - """Error for raising when sticker pack or sticker was not found.""" - - message_template = "sticker pack {pack_id} or sticker {sticker_id} was not found" - - #: sticker pack ID. - pack_id: UUID - - #: sticker ID. - sticker_id: UUID - - -class StickerPackOrStickerNotFoundData(BaseModel): - """Data for error when sticker pack or sticker was not found.""" - - #: sticker pack ID. - pack_id: UUID - - #: sticker ID. - sticker_id: UUID - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "sticker getting error" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - StickerPackOrStickerNotFoundError: raised always. - """ - parsed_response = APIErrorResponse[StickerPackOrStickerNotFoundData].parse_obj( - response.json_body, - ) - - error_data = parsed_response.error_data - raise StickerPackOrStickerNotFoundError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - pack_id=error_data.pack_id, - sticker_id=error_data.sticker_id, - ) diff --git a/botx/clients/methods/errors/unauthorized_bot.py b/botx/clients/methods/errors/unauthorized_bot.py deleted file mode 100644 index a956dee6..00000000 --- a/botx/clients/methods/errors/unauthorized_bot.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Definition for "invalid bot credentials" error.""" -from typing import NoReturn - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class InvalidBotCredentials(BotXAPIError): - """Error for raising when got invalid bot credentials.""" - - message_template = ( - "Can't get token for bot {bot_id}. Make sure bot credentials is correct" - ) - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "invalid bot credentials" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - InvalidBotCredentials: raised always. - """ - APIErrorResponse[dict].parse_obj(response.json_body) - raise InvalidBotCredentials( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - bot_id=method.bot_id, # type: ignore - ) diff --git a/botx/clients/methods/errors/user_not_found.py b/botx/clients/methods/errors/user_not_found.py deleted file mode 100644 index e146bae8..00000000 --- a/botx/clients/methods/errors/user_not_found.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Definition for "user not found" error.""" -from typing import NoReturn - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.clients.types.http import HTTPResponse -from botx.exceptions import BotXAPIError - - -class UserNotFoundError(BotXAPIError): - """Error for raising when user not found.""" - - message_template = "user not found" - - -def handle_error(method: BotXMethod, response: HTTPResponse) -> NoReturn: - """Handle "user not found" error response. - - Arguments: - method: method which was made before error. - response: HTTP response from BotX API. - - Raises: - UserNotFoundError: raised always. - """ - APIErrorResponse[dict].parse_obj(response.json_body) - raise UserNotFoundError( - url=method.url, - method=method.http_method, - response_content=response.json_body, - status_content=response.status_code, - ) diff --git a/botx/clients/methods/extractors.py b/botx/clients/methods/extractors.py deleted file mode 100644 index a7408d5a..00000000 --- a/botx/clients/methods/extractors.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Custom extractors for responses from BotX API.""" -from uuid import UUID - -from botx.clients.methods.base import BotXMethod -from botx.clients.types.response_results import ChatCreatedResult, PushResult - - -def extract_generated_sync_id(_method: BotXMethod, push: PushResult) -> UUID: - """Extract generated sync ID from response. - - Arguments: - _method: method that was used for making request. - push: push response from BotX API for generated message. - - Returns: - Extracted sync ID. - """ - return push.sync_id - - -def extract_generated_chat_id(_method: BotXMethod, response: ChatCreatedResult) -> UUID: - """Extract generated sync ID from response. - - Arguments: - _method: method that was used for making request. - response: response for created chat. - - Returns: - Extracted chat ID. - """ - return response.chat_id diff --git a/botx/clients/methods/v2/__init__.py b/botx/clients/methods/v2/__init__.py deleted file mode 100644 index ecf8b531..00000000 --- a/botx/clients/methods/v2/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for V3 API methods for BotX API.""" diff --git a/botx/clients/methods/v2/bots/__init__.py b/botx/clients/methods/v2/bots/__init__.py deleted file mode 100644 index 4d34b3c7..00000000 --- a/botx/clients/methods/v2/bots/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for methods for bots resource.""" diff --git a/botx/clients/methods/v2/bots/token.py b/botx/clients/methods/v2/bots/token.py deleted file mode 100644 index 624411e8..00000000 --- a/botx/clients/methods/v2/bots/token.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Method for retrieving token for bot.""" -from http import HTTPStatus -from typing import Dict -from urllib.parse import urljoin -from uuid import UUID - -from botx.clients.methods.base import BotXMethod, PrimitiveDataType -from botx.clients.methods.errors import bot_not_found, unauthorized_bot - - -class Token(BotXMethod[str]): - """Method for retrieving token for bot.""" - - __url__ = "/api/v2/botx/bots/{bot_id}/token" - __method__ = "GET" - __returning__ = str - __errors_handlers__ = { - HTTPStatus.NOT_FOUND: bot_not_found.handle_error, - HTTPStatus.UNAUTHORIZED: unauthorized_bot.handle_error, - } - - #: ID of bot which access for token. - bot_id: UUID - - #: calculated signature from secret_key for bot. - signature: str - - @property - def url(self) -> str: - """Full URL for request with filling bot_id.""" - api_url = self.__url__.format(bot_id=self.bot_id) - return urljoin(super().url, api_url) - - @property - def query_params(self) -> Dict[str, PrimitiveDataType]: - """Query string query_params for request with signature key.""" - return {"signature": self.signature} - - def build_serialized_dict(self) -> None: - """Return nothing to override dict body. - - Returns: - Nothing. - """ - return None # noqa: WPS324 diff --git a/botx/clients/methods/v3/__init__.py b/botx/clients/methods/v3/__init__.py deleted file mode 100644 index ecf8b531..00000000 --- a/botx/clients/methods/v3/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for V3 API methods for BotX API.""" diff --git a/botx/clients/methods/v3/chats/__init__.py b/botx/clients/methods/v3/chats/__init__.py deleted file mode 100644 index 9acbfb6f..00000000 --- a/botx/clients/methods/v3/chats/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for methods for chat resource.""" diff --git a/botx/clients/methods/v3/chats/add_admin_role.py b/botx/clients/methods/v3/chats/add_admin_role.py deleted file mode 100644 index 1d587c42..00000000 --- a/botx/clients/methods/v3/chats/add_admin_role.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Method for promoting users to admins.""" -from http import HTTPStatus -from typing import List -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors import ( - bot_is_not_admin, - chat_is_not_modifiable, - chat_not_found, -) - - -class AddAdminRole(AuthorizedBotXMethod[bool]): - """Method for promoting users to chat admins.""" - - __url__ = "/api/v3/botx/chats/add_admin" - __method__ = "POST" - __returning__ = bool - __errors_handlers__ = { - HTTPStatus.FORBIDDEN: ( - bot_is_not_admin.handle_error, - chat_is_not_modifiable.handle_error, - ), - HTTPStatus.NOT_FOUND: (chat_not_found.handle_error,), - } - - #: ID of chat where action should be performed. - group_chat_id: UUID - - #: IDs of users that should be promoted to admins. - user_huids: List[UUID] diff --git a/botx/clients/methods/v3/chats/add_user.py b/botx/clients/methods/v3/chats/add_user.py deleted file mode 100644 index 5b60c521..00000000 --- a/botx/clients/methods/v3/chats/add_user.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Method for adding new users into chat.""" -from http import HTTPStatus -from typing import List -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors import ( - bot_is_not_admin, - chat_is_not_modifiable, - chat_not_found, -) - - -class AddUser(AuthorizedBotXMethod[bool]): - """Method for adding new users into chat.""" - - __url__ = "/api/v3/botx/chats/add_user" - __method__ = "POST" - __returning__ = bool - __errors_handlers__ = { - HTTPStatus.FORBIDDEN: ( - bot_is_not_admin.handle_error, - chat_is_not_modifiable.handle_error, - ), - HTTPStatus.NOT_FOUND: (chat_not_found.handle_error,), - } - - #: ID of chat into which users should be added. - group_chat_id: UUID - - #: IDs of users that should be added into chat. - user_huids: List[UUID] diff --git a/botx/clients/methods/v3/chats/chat_list.py b/botx/clients/methods/v3/chats/chat_list.py deleted file mode 100644 index 319ca358..00000000 --- a/botx/clients/methods/v3/chats/chat_list.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Method for retrieving information about chat.""" - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.models.chats import BotChatList - - -class ChatList(AuthorizedBotXMethod[BotChatList]): # noqa: WPS110 - """Method for retrieving list of bot's chats.""" - - __url__ = "/api/v3/botx/chats/list" - __method__ = "GET" - __returning__ = BotChatList diff --git a/botx/clients/methods/v3/chats/create.py b/botx/clients/methods/v3/chats/create.py deleted file mode 100644 index f1c562aa..00000000 --- a/botx/clients/methods/v3/chats/create.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Method for creating new chat.""" - -from http import HTTPStatus -from typing import List, Optional -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors import chat_creation_disallowed, chat_creation_error -from botx.clients.methods.extractors import extract_generated_chat_id -from botx.clients.types.response_results import ChatCreatedResult -from botx.models.enums import ChatTypes - - -class Create(AuthorizedBotXMethod[UUID]): - """Method for creating new chat.""" - - __url__ = "/api/v3/botx/chats/create" - __method__ = "POST" - __returning__ = ChatCreatedResult - __result_extractor__ = extract_generated_chat_id - __errors_handlers__ = { - HTTPStatus.FORBIDDEN: chat_creation_disallowed.handle_error, - HTTPStatus.UNPROCESSABLE_ENTITY: chat_creation_error.handle_error, - } - - #: name of chat that should be created. - name: str - - #: description of new chat. - description: Optional[str] = None - - #: HUIDs of users that should be added into chat. - members: List[UUID] - - #: logo image of chat. - avatar: Optional[str] = None - - #: chat type. - chat_type: ChatTypes - - #: chat history is available to newcomers. - shared_history: bool diff --git a/botx/clients/methods/v3/chats/info.py b/botx/clients/methods/v3/chats/info.py deleted file mode 100644 index dd96e8f5..00000000 --- a/botx/clients/methods/v3/chats/info.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Method for retrieving information about chat.""" -from http import HTTPStatus -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors import messaging -from botx.models.chats import ChatFromSearch - - -class Info(AuthorizedBotXMethod[ChatFromSearch]): # noqa: WPS110 - """Method for retrieving information about chat.""" - - __url__ = "/api/v3/botx/chats/info" - __method__ = "GET" - __returning__ = ChatFromSearch - __errors_handlers__ = {HTTPStatus.BAD_REQUEST: messaging.handle_error} - - #: ID of chat for about which information should be retrieving. - group_chat_id: UUID diff --git a/botx/clients/methods/v3/chats/pin_message.py b/botx/clients/methods/v3/chats/pin_message.py deleted file mode 100644 index 43ac73eb..00000000 --- a/botx/clients/methods/v3/chats/pin_message.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Method for pinning message in chat.""" -from http import HTTPStatus -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors import chat_not_found, permissions - - -class PinMessage(AuthorizedBotXMethod[str]): - """Method for pinning message in chat.""" - - __url__ = "/api/v3/botx/chats/pin_message" - __method__ = "POST" - __returning__ = str - __errors_handlers__ = { - HTTPStatus.NOT_FOUND: chat_not_found.handle_error, - HTTPStatus.FORBIDDEN: permissions.handle_error, - } - - #: ID of chat where message should be pinned. - chat_id: UUID - - #: ID of message that should be pinned. - sync_id: UUID diff --git a/botx/clients/methods/v3/chats/remove_user.py b/botx/clients/methods/v3/chats/remove_user.py deleted file mode 100644 index 641055ef..00000000 --- a/botx/clients/methods/v3/chats/remove_user.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Method for removing users from chat.""" -from http import HTTPStatus -from typing import List -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors import ( - bot_is_not_admin, - chat_is_not_modifiable, - chat_not_found, -) - - -class RemoveUser(AuthorizedBotXMethod[bool]): - """Method for removing users from chat.""" - - __url__ = "/api/v3/botx/chats/remove_user" - __method__ = "POST" - __returning__ = bool - __errors_handlers__ = { - HTTPStatus.FORBIDDEN: ( - bot_is_not_admin.handle_error, - chat_is_not_modifiable.handle_error, - ), - HTTPStatus.NOT_FOUND: (chat_not_found.handle_error,), - } - - #: ID of chat from which users should be removed. - group_chat_id: UUID - - #: HUID of users that should be removed. - user_huids: List[UUID] diff --git a/botx/clients/methods/v3/chats/stealth_disable.py b/botx/clients/methods/v3/chats/stealth_disable.py deleted file mode 100644 index 321eeacd..00000000 --- a/botx/clients/methods/v3/chats/stealth_disable.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Method for disabling stealth in chat.""" -from http import HTTPStatus -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors import bot_is_not_admin, chat_not_found - - -class StealthDisable(AuthorizedBotXMethod[bool]): - """Method for disabling stealth in chat.""" - - __url__ = "/api/v3/botx/chats/stealth_disable" - __method__ = "POST" - __returning__ = bool - __errors_handlers__ = { - HTTPStatus.FORBIDDEN: bot_is_not_admin.handle_error, - HTTPStatus.NOT_FOUND: chat_not_found.handle_error, - } - - #: ID of chat where stealth should be disabled. - group_chat_id: UUID diff --git a/botx/clients/methods/v3/chats/stealth_set.py b/botx/clients/methods/v3/chats/stealth_set.py deleted file mode 100644 index 93b26841..00000000 --- a/botx/clients/methods/v3/chats/stealth_set.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Method for enabling stealth in chat.""" -from http import HTTPStatus -from typing import Optional -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors import bot_is_not_admin, chat_not_found - - -class StealthSet(AuthorizedBotXMethod[bool]): - """Method for enabling stealth in chat.""" - - __url__ = "/api/v3/botx/chats/stealth_set" - __method__ = "POST" - __returning__ = bool - __errors_handlers__ = { - HTTPStatus.FORBIDDEN: bot_is_not_admin.handle_error, - HTTPStatus.NOT_FOUND: chat_not_found.handle_error, - } - - #: ID of chat where stealth should be enabled. - group_chat_id: UUID - - #: should messages be shown in web. - disable_web: bool = False - - #: time of messages burning after read. - burn_in: Optional[int] = None - - #: time of messages burning after send. - expire_in: Optional[int] = None diff --git a/botx/clients/methods/v3/chats/unpin_message.py b/botx/clients/methods/v3/chats/unpin_message.py deleted file mode 100644 index 09ca4796..00000000 --- a/botx/clients/methods/v3/chats/unpin_message.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Method for unpinning message in chat.""" -from http import HTTPStatus -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors import chat_not_found, permissions - - -class UnpinMessage(AuthorizedBotXMethod[str]): - """Method for unpinning message in chat.""" - - __url__ = "/api/v3/botx/chats/unpin_message" - __method__ = "POST" - __returning__ = str - __errors_handlers__ = { - HTTPStatus.NOT_FOUND: chat_not_found.handle_error, - HTTPStatus.FORBIDDEN: permissions.handle_error, - } - - #: ID of chat where message should be unpinned. - chat_id: UUID diff --git a/botx/clients/methods/v3/command/__init__.py b/botx/clients/methods/v3/command/__init__.py deleted file mode 100644 index 1c917907..00000000 --- a/botx/clients/methods/v3/command/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for methods for command resource.""" diff --git a/botx/clients/methods/v3/command/command_result.py b/botx/clients/methods/v3/command/command_result.py deleted file mode 100644 index 5132db72..00000000 --- a/botx/clients/methods/v3/command/command_result.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Method for sending command result into chat.""" - -from typing import Optional -from uuid import UUID - -from pydantic import Field - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.extractors import extract_generated_sync_id -from botx.clients.types.message_payload import ResultPayload -from botx.clients.types.options import ResultOptions -from botx.clients.types.response_results import PushResult -from botx.models.files import File -from botx.models.typing import AvailableRecipients - - -class CommandResult(AuthorizedBotXMethod[UUID]): - """Method for sending notification into many chats.""" - - __url__ = "/api/v3/botx/command/callback" - __method__ = "POST" - __returning__ = PushResult - __result_extractor__ = extract_generated_sync_id - - #: ID of event on which this answer will be sent. - sync_id: UUID - - #: custom ID of new message. - event_sync_id: Optional[UUID] = None - - #: users that will receive message. - recipients: AvailableRecipients = "all" - - #: message payload. - result: ResultPayload = Field(..., alias="command_result") - - #: attached file. - file: Optional[File] = None - - #: extra options for new message. - opts: ResultOptions = ResultOptions() diff --git a/botx/clients/methods/v3/events/__init__.py b/botx/clients/methods/v3/events/__init__.py deleted file mode 100644 index f7f18591..00000000 --- a/botx/clients/methods/v3/events/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for methods for events resource.""" diff --git a/botx/clients/methods/v3/events/edit_event.py b/botx/clients/methods/v3/events/edit_event.py deleted file mode 100644 index 25ce6a3f..00000000 --- a/botx/clients/methods/v3/events/edit_event.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Method for editing sent event.""" -from typing import Optional -from uuid import UUID - -from pydantic import Field - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.types.message_payload import UpdatePayload -from botx.clients.types.options import ResultOptions -from botx.models.files import File - - -class EditEvent(AuthorizedBotXMethod[str]): - """Method for editing sent event.""" - - __url__ = "/api/v3/botx/events/edit_event" - __method__ = "POST" - __returning__ = str - - #: ID of event that should be edited. - sync_id: UUID - - #: data for editing. - result: UpdatePayload = Field(..., alias="payload") - - #: file attached to message. - file: Optional[File] = None - - #: extra options for message. - opts: ResultOptions = ResultOptions() diff --git a/botx/clients/methods/v3/events/reply_event.py b/botx/clients/methods/v3/events/reply_event.py deleted file mode 100644 index b5717a99..00000000 --- a/botx/clients/methods/v3/events/reply_event.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Method for sending command result into chat.""" - -from typing import Optional -from uuid import UUID - -from pydantic import Field - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.types.message_payload import ResultPayload -from botx.clients.types.options import ResultOptions -from botx.models.files import File - - -class ReplyEvent(AuthorizedBotXMethod[UUID]): - """Method for sending reply message.""" - - __url__ = "/api/v3/botx/events/reply_event" - __method__ = "POST" - __returning__ = str - - #: ID of message for reply. - source_sync_id: Optional[UUID] = None - - #: message payload. - result: ResultPayload = Field(..., alias="reply") - - #: attached file. - file: Optional[File] = None - - #: extra options for new message. - opts: ResultOptions = ResultOptions() diff --git a/botx/clients/methods/v3/files/__init__.py b/botx/clients/methods/v3/files/__init__.py deleted file mode 100644 index 2ad05f3b..00000000 --- a/botx/clients/methods/v3/files/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for methods for files resource.""" diff --git a/botx/clients/methods/v3/files/download.py b/botx/clients/methods/v3/files/download.py deleted file mode 100644 index a9a8a0c0..00000000 --- a/botx/clients/methods/v3/files/download.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Method for downloading file from chat.""" -from http import HTTPStatus -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors.files import ( - chat_not_found, - file_deleted, - metadata_not_found, - without_preview, -) -from botx.clients.types.http import ExpectedType, HTTPRequest -from botx.models.files import File - - -class DownloadFile(AuthorizedBotXMethod[File]): - """Method for downloading a file from chat.""" - - __url__ = "/api/v3/botx/files/download" - __method__ = "GET" - __returning__ = File - __expected_type__ = ExpectedType.BINARY - __errors_handlers__ = { - HTTPStatus.NO_CONTENT: (file_deleted.handle_error,), - HTTPStatus.BAD_REQUEST: (without_preview.handle_error,), - HTTPStatus.NOT_FOUND: ( - chat_not_found.handle_error, - metadata_not_found.handle_error, - ), - } - - #: ID of the chat with file. - group_chat_id: UUID - - #: ID of the file for downloading. - file_id: UUID - - #: preview or file. - is_preview: bool - - def build_http_request(self) -> HTTPRequest: - """Build HTTP request that can be used by clients for making real requests. - - Returns: - Built HTTP request. - """ - request_params = self.build_serialized_dict() - - return HTTPRequest.construct( - method=self.http_method, - url=self.url, - headers=self.headers, - query_params=dict(request_params), # type: ignore - json_body={}, - expected_type=self.expected_type, - should_process_as_error=[HTTPStatus.NO_CONTENT], - ) diff --git a/botx/clients/methods/v3/files/upload.py b/botx/clients/methods/v3/files/upload.py deleted file mode 100644 index 7f5e7a41..00000000 --- a/botx/clients/methods/v3/files/upload.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Method for uploading file to chat.""" -from http import HTTPStatus -from typing import Dict -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors.files import chat_not_found -from botx.clients.types.http import HTTPRequest -from botx.clients.types.upload_file import UploadingFileMeta -from botx.models.files import File, MetaFile - - -class UploadFile(AuthorizedBotXMethod[MetaFile]): - """Method for uploading file to a chat.""" - - __url__ = "/api/v3/botx/files/upload" - __method__ = "POST" - __returning__ = MetaFile - __errors_handlers__ = { - HTTPStatus.NOT_FOUND: (chat_not_found.handle_error,), - } - - #: ID of the chat. - group_chat_id: UUID - - #: file for uploading. - file: File - - #: file metadata. - meta: UploadingFileMeta - - @property - def headers(self) -> Dict[str, str]: - """Headers that should be used in request.""" - headers = super().headers - # used to enable the client to attach a Content-Type with a boundary - headers.pop("Content-Type") - return headers - - def build_http_request(self) -> HTTPRequest: - """Build HTTP request that can be used by clients for making real requests. - - Returns: - Built HTTP request. - """ - files = {"content": (self.file.file_name, self.file.file)} - request_data = { - "group_chat_id": str(self.group_chat_id), - "meta": self.meta.json(), - } - - return HTTPRequest( - method=self.http_method, - url=self.url, - headers=self.headers, - query_params=self.query_params, - json_body={}, - data=request_data, - files=files, # type: ignore - ) diff --git a/botx/clients/methods/v3/notification/__init__.py b/botx/clients/methods/v3/notification/__init__.py deleted file mode 100644 index dabef1cb..00000000 --- a/botx/clients/methods/v3/notification/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for methods for notification resource.""" diff --git a/botx/clients/methods/v3/notification/direct_notification.py b/botx/clients/methods/v3/notification/direct_notification.py deleted file mode 100644 index 6e3af91f..00000000 --- a/botx/clients/methods/v3/notification/direct_notification.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Method for sending notification into single chat.""" -from typing import Optional -from uuid import UUID - -from pydantic import Field - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.extractors import extract_generated_sync_id -from botx.clients.types.message_payload import ResultPayload -from botx.clients.types.options import ResultOptions -from botx.clients.types.response_results import PushResult -from botx.models.files import File -from botx.models.typing import AvailableRecipients - - -class NotificationDirect(AuthorizedBotXMethod[UUID]): - """Method for sending notification into single chat.""" - - __url__ = "/api/v3/botx/notification/callback/direct" - __method__ = "POST" - __returning__ = PushResult - __result_extractor__ = extract_generated_sync_id - - #: ID of chat for new notification. - group_chat_id: UUID - - #: custom ID for message. - event_sync_id: Optional[UUID] = None - - #: HUIDs of users that should receive notifications. - recipients: AvailableRecipients = "all" - - #: data for build message: body, markup, mentions. - result: ResultPayload = Field(..., alias="notification") - - #: attached file for message. - file: Optional[File] = None - - #: extra options for message. - opts: ResultOptions = ResultOptions() diff --git a/botx/clients/methods/v3/notification/notification.py b/botx/clients/methods/v3/notification/notification.py deleted file mode 100644 index 1076d683..00000000 --- a/botx/clients/methods/v3/notification/notification.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Method for sending notification into many chats.""" -from typing import List, Optional -from uuid import UUID - -from pydantic import Field - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.types.message_payload import ResultPayload -from botx.clients.types.options import ResultOptions -from botx.models.files import File -from botx.models.typing import AvailableRecipients - - -class Notification(AuthorizedBotXMethod[str]): - """Method for sending notification into many chats.""" - - __url__ = "/api/v3/botx/notification/callback" - __method__ = "POST" - __returning__ = str - - #: IDs of chats for new notification. - group_chat_ids: List[UUID] = [] - - #: HUIDs of users that should receive notifications. - recipients: AvailableRecipients = "all" - - #: data for build message: body, markup, mentions. - result: ResultPayload = Field(..., alias="notification") - - #: attached file for message. - file: Optional[File] = None - - #: extra options for message. - opts: ResultOptions = ResultOptions() diff --git a/botx/clients/methods/v3/smartapps/__init__.py b/botx/clients/methods/v3/smartapps/__init__.py deleted file mode 100644 index 1e1bdd78..00000000 --- a/botx/clients/methods/v3/smartapps/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for smartapp methods.""" diff --git a/botx/clients/methods/v3/smartapps/smartapp_event.py b/botx/clients/methods/v3/smartapps/smartapp_event.py deleted file mode 100644 index c4a0e1c0..00000000 --- a/botx/clients/methods/v3/smartapps/smartapp_event.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Method for sending smartapp event.""" -from typing import Any, Dict, List, Optional -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.models.files import File, MetaFile - - -class SmartAppEvent(AuthorizedBotXMethod[str]): - """Method for sending smartapp events.""" - - __url__ = "/api/v3/botx/smartapps/event" - __method__ = "POST" - __returning__ = str - - #: unique request id - ref: Optional[UUID] = None - - #: smartapp id - smartapp_id: UUID - - #: event data - data: Dict[str, Any] # noqa: WPS110 - - #: event options - opts: Dict[str, Any] = {} - - #: version of protocol smartapp <-> bot - smartapp_api_version: int - - #: smartapp chat - group_chat_id: Optional[UUID] - - #: files - files: List[File] = [] - - #: file's meta to upload - async_files: List[MetaFile] = [] diff --git a/botx/clients/methods/v3/smartapps/smartapp_notification.py b/botx/clients/methods/v3/smartapps/smartapp_notification.py deleted file mode 100644 index 574beddb..00000000 --- a/botx/clients/methods/v3/smartapps/smartapp_notification.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Method for sending smartapp event.""" -from typing import Any, Dict, Optional -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod - - -class SmartAppNotification(AuthorizedBotXMethod[str]): - """Method for sending smartapp notifications.""" - - __url__ = "/api/v3/botx/smartapps/notification" - __method__ = "POST" - __returning__ = str - - #: smartapp chat - group_chat_id: Optional[UUID] - - #: unread notifications count - smartapp_counter: int - - #: event options - opts: Dict[str, Any] = {} - - #: version of protocol smartapp <-> bot - smartapp_api_version: int diff --git a/botx/clients/methods/v3/stickers/__init__.py b/botx/clients/methods/v3/stickers/__init__.py deleted file mode 100644 index 1ec2ca1d..00000000 --- a/botx/clients/methods/v3/stickers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for methods for sticker resource.""" diff --git a/botx/clients/methods/v3/stickers/add_sticker.py b/botx/clients/methods/v3/stickers/add_sticker.py deleted file mode 100644 index 4891e3a6..00000000 --- a/botx/clients/methods/v3/stickers/add_sticker.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Method for adding stickers into sticker pack.""" -from http import HTTPStatus -from urllib.parse import urljoin -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors.stickers import image_not_valid, sticker_pack_not_found -from botx.clients.types.http import HTTPRequest -from botx.models.stickers import Sticker - - -class AddSticker(AuthorizedBotXMethod[Sticker]): - """Method for adding stickers into sticker pack.""" - - __url__ = "/api/v3/botx/stickers/packs/{pack_id}/stickers/" - __method__ = "POST" - __returning__ = Sticker - __errors_handlers__ = { - HTTPStatus.BAD_REQUEST: ( - sticker_pack_not_found.handle_error, - image_not_valid.handle_error, - ), - } - - #: sticker pack ID. - pack_id: UUID - - #: emoji that the sticker will be associated with. - emoji: str - - #: sticker image. - image: str - - @property - def url(self) -> str: - """Full URL for request with filling pack_id.""" - api_url = self.__url__.format(pack_id=self.pack_id) - return urljoin(super().url, api_url) - - def build_http_request(self) -> HTTPRequest: - """Build HTTP request that can be used by clients for making real requests. - - Returns: - Built HTTP request. - """ - request_params = self.build_serialized_dict() - - return HTTPRequest.construct( - method=self.http_method, - url=self.url, - headers=self.headers, - query_params={}, - json_body=dict(request_params), # type: ignore - expected_type=self.expected_type, - ) diff --git a/botx/clients/methods/v3/stickers/create_sticker_pack.py b/botx/clients/methods/v3/stickers/create_sticker_pack.py deleted file mode 100644 index 78a20f5b..00000000 --- a/botx/clients/methods/v3/stickers/create_sticker_pack.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Method for creating new sticker pack.""" -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.types.http import HTTPRequest -from botx.models.stickers import StickerPack - - -class CreateStickerPack(AuthorizedBotXMethod[StickerPack]): - """Method for creating sticker pack.""" - - __url__ = "/api/v3/botx/stickers/packs" - __method__ = "POST" - __returning__ = StickerPack - - #: sticker pack name. - name: str - - #: author HUID. - user_huid: UUID - - def build_http_request(self) -> HTTPRequest: - """Build HTTP request that can be used by clients for making real requests. - - Returns: - Built HTTP request. - """ - request_params = self.build_serialized_dict() - - return HTTPRequest.construct( - method=self.http_method, - url=self.url, - headers=self.headers, - query_params=dict(request_params), # type: ignore - json_body={}, - expected_type=self.expected_type, - ) diff --git a/botx/clients/methods/v3/stickers/delete_sticker.py b/botx/clients/methods/v3/stickers/delete_sticker.py deleted file mode 100644 index b6e89664..00000000 --- a/botx/clients/methods/v3/stickers/delete_sticker.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Method for deleting sticker from sticker pack.""" -from http import HTTPStatus -from urllib.parse import urljoin -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors.stickers import sticker_pack_or_sticker_not_found -from botx.clients.types.http import HTTPRequest - - -class DeleteSticker(AuthorizedBotXMethod[str]): - """Method for deleting sticker from sticker pack.""" - - __url__ = "/api/v3/botx/stickers/packs/{pack_id}/stickers/{sticker_id}" - __method__ = "DELETE" - __returning__ = str - __errors_handlers__ = { - HTTPStatus.NOT_FOUND: (sticker_pack_or_sticker_not_found.handle_error,), - } - - # : sticker pack ID. - pack_id: UUID - - # : sticker ID. - sticker_id: UUID - - @property - def url(self) -> str: - """Full URL for request with filling pack_id.""" - api_url = self.__url__.format(pack_id=self.pack_id, sticker_id=self.sticker_id) - return urljoin(super().url, api_url) - - def build_http_request(self) -> HTTPRequest: - """Build HTTP request that can be used by clients for making real requests. - - Returns: - Built HTTP request. - """ - request_params = self.build_serialized_dict() - - return HTTPRequest.construct( - method=self.http_method, - url=self.url, - headers=self.headers, - query_params=request_params, # type: ignore - json_body={}, - expected_type=self.expected_type, - ) diff --git a/botx/clients/methods/v3/stickers/delete_sticker_pack.py b/botx/clients/methods/v3/stickers/delete_sticker_pack.py deleted file mode 100644 index 33f9a8b6..00000000 --- a/botx/clients/methods/v3/stickers/delete_sticker_pack.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Method for deleting sticker pack.""" -from http import HTTPStatus -from urllib.parse import urljoin -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors.stickers import sticker_pack_not_found -from botx.clients.types.http import HTTPRequest - - -class DeleteStickerPack(AuthorizedBotXMethod[str]): - """Method for deleting sticker pack.""" - - __url__ = "/api/v3/botx/stickers/packs/{pack_id}" - __method__ = "DELETE" - __returning__ = str - __errors_handlers__ = { - HTTPStatus.NOT_FOUND: (sticker_pack_not_found.handle_error,), - } - - # : sticker pack ID. - pack_id: UUID - - @property - def url(self) -> str: - """Full URL for request with filling pack_id.""" - api_url = self.__url__.format(pack_id=self.pack_id) - return urljoin(super().url, api_url) - - def build_http_request(self) -> HTTPRequest: - """Build HTTP request that can be used by clients for making real requests. - - Returns: - Built HTTP request. - """ - request_params = self.build_serialized_dict() - - return HTTPRequest.construct( - method=self.http_method, - url=self.url, - headers=self.headers, - query_params=request_params, # type: ignore - json_body={}, - expected_type=self.expected_type, - ) diff --git a/botx/clients/methods/v3/stickers/edit_sticker_pack.py b/botx/clients/methods/v3/stickers/edit_sticker_pack.py deleted file mode 100644 index c679750f..00000000 --- a/botx/clients/methods/v3/stickers/edit_sticker_pack.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Method for editing sticker pack.""" -from http import HTTPStatus -from typing import List, Optional -from urllib.parse import urljoin -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors.stickers import sticker_pack_not_found -from botx.clients.types.http import HTTPRequest -from botx.models.stickers import StickerPack - - -class EditStickerPack(AuthorizedBotXMethod[StickerPack]): - """Method for editing sticker pack.""" - - __url__ = "/api/v3/botx/stickers/packs/{pack_id}" - __method__ = "PUT" - __returning__ = StickerPack - __errors_handlers__ = { - HTTPStatus.NOT_FOUND: (sticker_pack_not_found.handle_error,), - } - - # : sticker pack ID. - pack_id: UUID - - # : sticker pack name. - name: str - - #: sticker pack preview. - preview: Optional[UUID] - - #: stickers order in sticker pack. - stickers_order: Optional[List[UUID]] - - @property - def url(self) -> str: - """Full URL for request with filling pack_id.""" - api_url = self.__url__.format(pack_id=self.pack_id) - return urljoin(super().url, api_url) - - def build_http_request(self) -> HTTPRequest: - """Build HTTP request that can be used by clients for making real requests. - - Returns: - Built HTTP request. - """ - request_params = self.build_serialized_dict() - - return HTTPRequest.construct( - method=self.http_method, - url=self.url, - headers=self.headers, - query_params={}, - json_body=request_params, - expected_type=self.expected_type, - ) diff --git a/botx/clients/methods/v3/stickers/sticker.py b/botx/clients/methods/v3/stickers/sticker.py deleted file mode 100644 index d50efe7d..00000000 --- a/botx/clients/methods/v3/stickers/sticker.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Method for getting sticker from sticker pack.""" -from http import HTTPStatus -from urllib.parse import urljoin -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors.stickers import sticker_pack_or_sticker_not_found -from botx.clients.types.http import HTTPRequest -from botx.models.stickers import StickerFromPack - - -class GetSticker(AuthorizedBotXMethod[StickerFromPack]): - """Method for getting sticker from sticker pack.""" - - __url__ = "/api/v3/botx/stickers/packs/{pack_id}/stickers/{sticker_id}" - __method__ = "GET" - __returning__ = StickerFromPack - __errors_handlers__ = { - HTTPStatus.NOT_FOUND: (sticker_pack_or_sticker_not_found.handle_error,), - } - - #: sticker pack ID. - pack_id: UUID - - #: sticker ID. - sticker_id: UUID - - @property - def url(self) -> str: - """Full URL for request with filling pack_id.""" - api_url = self.__url__.format(pack_id=self.pack_id, sticker_id=self.sticker_id) - return urljoin(super().url, api_url) - - def build_http_request(self) -> HTTPRequest: - """Build HTTP request that can be used by clients for making real requests. - - Returns: - Built HTTP request. - """ - request_params = self.build_serialized_dict() - - return HTTPRequest.construct( - method=self.http_method, - url=self.url, - headers=self.headers, - query_params=request_params, # type: ignore - json_body={}, - expected_type=self.expected_type, - ) diff --git a/botx/clients/methods/v3/stickers/sticker_pack.py b/botx/clients/methods/v3/stickers/sticker_pack.py deleted file mode 100644 index 03ee1c5f..00000000 --- a/botx/clients/methods/v3/stickers/sticker_pack.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Method for getting sticker pack.""" -from http import HTTPStatus -from urllib.parse import urljoin -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors.stickers import sticker_pack_not_found -from botx.clients.types.http import HTTPRequest -from botx.models.stickers import StickerPack - - -class GetStickerPack(AuthorizedBotXMethod[StickerPack]): - """Method for getting sticker pack.""" - - __url__ = "/api/v3/botx/stickers/packs/{pack_id}" - __method__ = "GET" - __returning__ = StickerPack - __errors_handlers__ = { - HTTPStatus.NOT_FOUND: (sticker_pack_not_found.handle_error,), - } - - #: sticker pack ID. - pack_id: UUID - - @property - def url(self) -> str: - """Full URL for request with filling pack_id.""" - api_url = self.__url__.format(pack_id=self.pack_id) - return urljoin(super().url, api_url) - - def build_http_request(self) -> HTTPRequest: - """Build HTTP request that can be used by clients for making real requests. - - Returns: - Built HTTP request. - """ - request_params = self.build_serialized_dict() - - return HTTPRequest.construct( - method=self.http_method, - url=self.url, - headers=self.headers, - query_params=request_params, # type: ignore - json_body={}, - expected_type=self.expected_type, - ) diff --git a/botx/clients/methods/v3/stickers/sticker_pack_list.py b/botx/clients/methods/v3/stickers/sticker_pack_list.py deleted file mode 100644 index 56c44999..00000000 --- a/botx/clients/methods/v3/stickers/sticker_pack_list.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Method for getting sticker pack list.""" -from typing import Optional -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.types.http import HTTPRequest -from botx.models.stickers import StickerPackList - - -class GetStickerPackList(AuthorizedBotXMethod[StickerPackList]): - """Method for getting sticker pack list.""" - - __url__ = "/api/v3/botx/stickers/packs" - __method__ = "GET" - __returning__ = StickerPackList - - #: author HUID. - user_huid: Optional[UUID] - - #: returning value count. - limit: int - - #: cursor hash for pagination. - after: Optional[str] = None - - def build_http_request(self) -> HTTPRequest: - """Build HTTP request that can be used by clients for making real requests. - - Returns: - Built HTTP request. - """ - request_params = self.build_serialized_dict() - - return HTTPRequest.construct( - method=self.http_method, - url=self.url, - headers=self.headers, - query_params=dict(request_params), # type: ignore - json_body={}, - expected_type=self.expected_type, - ) diff --git a/botx/clients/methods/v3/users/__init__.py b/botx/clients/methods/v3/users/__init__.py deleted file mode 100644 index db4e74a6..00000000 --- a/botx/clients/methods/v3/users/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for methods for users resource.""" diff --git a/botx/clients/methods/v3/users/by_email.py b/botx/clients/methods/v3/users/by_email.py deleted file mode 100644 index fed0c91b..00000000 --- a/botx/clients/methods/v3/users/by_email.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Method for searching user by his email.""" -from http import HTTPStatus - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors import user_not_found -from botx.models.users import UserFromSearch - - -class ByEmail(AuthorizedBotXMethod[UserFromSearch]): - """Method for searching user by his email.""" - - __url__ = "/api/v3/botx/users/by_email" - __method__ = "GET" - __returning__ = UserFromSearch - __errors_handlers__ = {HTTPStatus.NOT_FOUND: user_not_found.handle_error} - - #: email to search - email: str diff --git a/botx/clients/methods/v3/users/by_huid.py b/botx/clients/methods/v3/users/by_huid.py deleted file mode 100644 index 4cf3e404..00000000 --- a/botx/clients/methods/v3/users/by_huid.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Method for searching user by his HUID.""" -from http import HTTPStatus -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors import user_not_found -from botx.models.users import UserFromSearch - - -class ByHUID(AuthorizedBotXMethod[UserFromSearch]): - """Method for searching user by his HUID.""" - - __url__ = "/api/v3/botx/users/by_huid" - __method__ = "GET" - __returning__ = UserFromSearch - __errors_handlers__ = {HTTPStatus.NOT_FOUND: user_not_found.handle_error} - - #: HUID to search - user_huid: UUID diff --git a/botx/clients/methods/v3/users/by_login.py b/botx/clients/methods/v3/users/by_login.py deleted file mode 100644 index df05b731..00000000 --- a/botx/clients/methods/v3/users/by_login.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Method for searching user by his AD credentials.""" -from http import HTTPStatus - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.errors import user_not_found -from botx.models.users import UserFromSearch - - -class ByLogin(AuthorizedBotXMethod[UserFromSearch]): - """Method for searching user by his AD credentials.""" - - __url__ = "/api/v3/botx/users/by_login" - __method__ = "GET" - __returning__ = UserFromSearch - __errors_handlers__ = {HTTPStatus.NOT_FOUND: user_not_found.handle_error} - - #: AD login to search - ad_login: str - - #: AD domain to search - ad_domain: str diff --git a/botx/clients/methods/v4/__init__.py b/botx/clients/methods/v4/__init__.py deleted file mode 100644 index 99f9d1af..00000000 --- a/botx/clients/methods/v4/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for V4 API methods for BotX API.""" diff --git a/botx/clients/methods/v4/notifications/__init__.py b/botx/clients/methods/v4/notifications/__init__.py deleted file mode 100644 index 1506ec03..00000000 --- a/botx/clients/methods/v4/notifications/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for methods for notifications resource.""" diff --git a/botx/clients/methods/v4/notifications/internal_bot_notification.py b/botx/clients/methods/v4/notifications/internal_bot_notification.py deleted file mode 100644 index 1b0d5144..00000000 --- a/botx/clients/methods/v4/notifications/internal_bot_notification.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Method for sending internal bot notification.""" -from typing import Any, Dict, List, Optional -from uuid import UUID - -from botx.clients.methods.base import AuthorizedBotXMethod -from botx.clients.methods.extractors import extract_generated_sync_id -from botx.clients.types.message_payload import InternalBotNotificationPayload -from botx.clients.types.response_results import InternalBotNotificationResult - - -class InternalBotNotification(AuthorizedBotXMethod[UUID]): - """Method for sending internal bot notification.""" - - __url__ = "/api/v4/botx/notifications/internal" - __method__ = "POST" - __returning__ = InternalBotNotificationResult - __result_extractor__ = extract_generated_sync_id - - #: IDs of chats for new notification. - group_chat_id: UUID - - #: HUIDs of bots that should receive notifications (None for all bots in chat). - recipients: Optional[List[UUID]] = None - - # notification payload - data: InternalBotNotificationPayload # noqa: WPS110 - - #: extra options for message. - opts: Dict[str, Any] = {} diff --git a/botx/clients/types/__init__.py b/botx/clients/types/__init__.py deleted file mode 100644 index bdf2c69a..00000000 --- a/botx/clients/types/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Types that are used only for communicating with BotX API.""" diff --git a/botx/clients/types/http.py b/botx/clients/types/http.py deleted file mode 100644 index aae73d5d..00000000 --- a/botx/clients/types/http.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Custom wrapper for HTTP request for BotX API.""" -from enum import Enum -from io import BytesIO -from typing import Any, Dict, List, Optional, Tuple, Union - -from pydantic import BaseModel, root_validator - -PrimitiveDataType = Union[None, str, int, float, bool] - - -class ExpectedType(Enum): - """Expected types of response body.""" - - JSON = "JSON" # noqa: WPS115 - BINARY = "BINARY" # noqa: WPS115 - - -class HTTPRequest(BaseModel): - """Wrapper for HTTP request.""" - - #: HTTP method. - method: str - - #: URL for request. - url: str - - #: headers for request. - headers: Dict[str, str] - - #: query params for request. - query_params: Dict[str, PrimitiveDataType] - - #: request body. - json_body: Optional[Dict[str, Any]] - - #: form data. - data: Optional[Dict[str, Any]] = None # noqa: WPS110 - - #: file for httpx in {field_name: (file_name, file_content)}. - files: Optional[Dict[str, Tuple[str, BytesIO]]] = None # noqa: WPS234 - - #: expected type of response body. - expected_type: ExpectedType = ExpectedType.JSON - - # This field is used to provide handlers that are not in the range of 400 to 599. - #: extra error codes. - should_process_as_error: List[int] = [] - - class Config: - arbitrary_types_allowed = True - - -class HTTPResponse(BaseModel): - """Wrapper for HTTP response.""" - - #: response headers - headers: Dict[str, str] - - #: response status code. - status_code: int - - #: response body. - json_body: Optional[Dict[str, Any]] = None - - #: response raw data. - raw_data: Optional[bytes] = None - - @property - def is_redirect(self) -> bool: - """Is redirect status code. - - Returns: - Check result. - """ - return 300 <= self.status_code < 399 # noqa: WPS432 - - @property - def is_error(self) -> bool: - """Is error status code. - - Returns: - Check result. - """ - return 400 <= self.status_code < 599 # noqa: WPS432 - - @root_validator(pre=True) - def check_fields(cls, values: Any) -> Any: # noqa: N805, WPS110 - """Check if passed both `json_body` and `raw_data`.""" - json_body, raw_data = values.get("json_body"), values.get("raw_data") - if (json_body is not None) and (raw_data is not None): - raise ValueError("you cannot pass both `json_body` and `raw_data`.") - - return values diff --git a/botx/clients/types/message_payload.py b/botx/clients/types/message_payload.py deleted file mode 100644 index dd4c8c25..00000000 --- a/botx/clients/types/message_payload.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Shape that is used for messages from bot.""" -from typing import Any, Dict, List, Optional - -from pydantic import BaseModel, Field - -from botx.models.constants import MAXIMUM_TEXT_LENGTH -from botx.models.entities import Mention -from botx.models.enums import Statuses -from botx.models.messages.sending.options import ResultPayloadOptions -from botx.models.typing import BubbleMarkup, KeyboardMarkup - -try: - from typing import Literal # noqa: WPS433 -except ImportError: - from typing_extensions import Literal # type: ignore # noqa: WPS433, WPS440, F401 - - -class ResultPayload(BaseModel): - """Data that is sent when bot answers on command or send notification.""" - - #: status of operation. - status: Literal[Statuses.ok] = Statuses.ok - - #: body for new message from bot. - body: str = Field("", max_length=MAXIMUM_TEXT_LENGTH) - - #: message metadata. - metadata: Dict[str, Any] = {} - - #: options for `notification` and `command_result` API entities. - opts: ResultPayloadOptions = ResultPayloadOptions() - - #: keyboard that will be used for new message. - keyboard: KeyboardMarkup = [] - - #: bubble elements that will be showed under new message. - bubble: BubbleMarkup = [] - - #: mentions that BotX API will append before new message text. - mentions: List[Mention] = [] - - -class UpdatePayload(BaseModel): - """Data that is sent when bot updates message.""" - - #: status of operation. - status: Literal[Statuses.ok] = Statuses.ok - - #: new body in message. - body: Optional[str] = Field(None, max_length=MAXIMUM_TEXT_LENGTH) - - #: message metadata. - metadata: Optional[Dict[str, Any]] = None - - #: new keyboard that will be used for new message. - keyboard: Optional[KeyboardMarkup] = None - - #: new bubble elements that will be showed under new message. - bubble: Optional[BubbleMarkup] = None - - #: new mentions that BotX API will append before new message text. - mentions: Optional[List[Mention]] = None - - -class InternalBotNotificationPayload(BaseModel): - """Data that is sent in internal bot notification.""" - - #: message data - message: str - - #: extra information about notification sender - sender: Optional[str] diff --git a/botx/clients/types/options.py b/botx/clients/types/options.py deleted file mode 100644 index 5059b12e..00000000 --- a/botx/clients/types/options.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Special options for messages from bot.""" -from pydantic import BaseModel - -from botx.models.messages.sending.options import NotificationOptions - - -class ResultOptions(BaseModel): - """Configuration for command result or notification that is send to BotX API.""" - - #: send message only when stealth mode is enabled. - stealth_mode: bool = False - - #: use in-text mentions - raw_mentions: bool = False - - #: message options for configuring notifications. - notification_opts: NotificationOptions = NotificationOptions() diff --git a/botx/clients/types/response_results.py b/botx/clients/types/response_results.py deleted file mode 100644 index 506e830b..00000000 --- a/botx/clients/types/response_results.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Responses from BotX API.""" -from uuid import UUID - -from pydantic import BaseModel - - -class PushResult(BaseModel): - """Entity that contains result from notification or command result push.""" - - #: event id of pushed message. - sync_id: UUID - - -class ChatCreatedResult(BaseModel): - """Entity that contains result from chat creation.""" - - #: id of created chat. - chat_id: UUID - - -class InternalBotNotificationResult(BaseModel): - """Entity that contains result from internal bot notification.""" - - #: event id of pushed message. - sync_id: UUID diff --git a/botx/clients/types/upload_file.py b/botx/clients/types/upload_file.py deleted file mode 100644 index 1847c422..00000000 --- a/botx/clients/types/upload_file.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Uploading file metadata.""" -from typing import Optional - -from pydantic import BaseModel - - -class UploadingFileMeta(BaseModel): - """Uploading file metadata.""" - - #: duration of media file - duration: Optional[int] = None - - #: caption of media file - caption: Optional[str] = None diff --git a/botx/collecting/__init__.py b/botx/collecting/__init__.py deleted file mode 100644 index b20abee9..00000000 --- a/botx/collecting/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition of command handlers and routing mechanism.""" diff --git a/botx/collecting/collectors/__init__.py b/botx/collecting/collectors/__init__.py deleted file mode 100644 index fc68d408..00000000 --- a/botx/collecting/collectors/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for collecting logic.""" diff --git a/botx/collecting/collectors/base.py b/botx/collecting/collectors/base.py deleted file mode 100644 index 24784382..00000000 --- a/botx/collecting/collectors/base.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Definition for base collector.""" - -from dataclasses import InitVar, field -from typing import Any, Callable, List, Optional, Sequence, Union - -from pydantic.dataclasses import dataclass - -from botx import converters -from botx.collecting.handlers.handler import Handler -from botx.collecting.handlers.name_generators import ( - get_body_from_name, - get_name_from_callable, -) -from botx.dependencies import models as deps -from botx.dependencies.models import Depends -from botx.exceptions import NoMatchFound - - -def _get_sorted_handlers(handlers: List[Handler]) -> List[Handler]: - return sorted(handlers, key=lambda handler: len(handler.body), reverse=True) - - -def _combine_dependencies( - *dependencies: Optional[Sequence[deps.Depends]], -) -> List[deps.Depends]: - result_dependencies = [] - for deps_sequence in dependencies: - result_dependencies.extend(converters.optional_sequence_to_list(deps_sequence)) - - return result_dependencies - - -def _check_new_handler_restrictions( - body: str, - name: Optional[str], - handler: Callable, - existed_handler: Handler, -) -> None: - handler_executor = existed_handler.handler - handler_name = existed_handler.name - - if body == existed_handler.body: - raise AssertionError("handler with body {0} already registered".format(body)) - - handler_registered = handler == handler_executor and name == handler_name - if name == existed_handler.name and not handler_registered: - raise AssertionError("handler with name {0} already registered".format(name)) - - -@dataclass -class BaseCollector: - """Base collector.""" - - default: InitVar[Handler] = None - - #: registered handlers on this collector handlers in order of adding. - handlers: List[Handler] = field(default_factory=list) - - #: handler that will be used for handling non matched message. - default_message_handler: Optional[Handler] = None - - #: background dependencies that will be executed for handlers. - dependencies: Optional[Sequence[Depends]] = None - - #: overrider for dependencies. - dependency_overrides_provider: Optional[Any] = None - - @property - def sorted_handlers(self) -> List[Handler]: - """Get added handlers sorted by bodies length. - - Returns: - Sorted handlers. - """ - return _get_sorted_handlers(self.handlers) - - def __post_init__(self, default: Optional[Handler]) -> None: - """Initialize or update special fields. - - Arguments: - default: callable that should be used as default handler. - """ - handlers = self.handlers - self.handlers = [] - self._add_handlers(converters.optional_sequence_to_list(handlers)) - - if default is not None: - self._add_default_handler(default) - - 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(search_param=name) - - 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, - dependency_overrides_provider: Any = 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. - dependency_overrides_provider: mock of callable for handler. - """ - if body is None: - name = name or get_name_from_callable(handler) - body = get_body_from_name(name) - - for registered_handler in self.handlers: - _check_new_handler_restrictions(body, name, handler, registered_handler) - - dep_override = ( - dependency_overrides_provider - if dependency_overrides_provider is not None - else self.dependency_overrides_provider - ) - command_handler = Handler( - body=body, - handler=handler, - name=name, # type: ignore - description=description, - full_description=full_description, # type: ignore - include_in_status=include_in_status, - dependencies=_combine_dependencies(self.dependencies, dependencies), - dependency_overrides_provider=dep_override, - ) - self.handlers.append(command_handler) - - def _add_handlers( - self, - handlers: List[Handler], - dependencies: Optional[Sequence[Depends]] = None, - ) -> None: - for handler in handlers: - combined_dependencies = _combine_dependencies( - dependencies, - handler.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=combined_dependencies, - ) - - def _add_default_handler( - self, - default: Handler, - dependencies: Optional[Sequence[Depends]] = None, - ) -> None: - default_dependencies = _combine_dependencies( - self.dependencies, - dependencies, - default.dependencies, - ) - 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, - ) diff --git a/botx/collecting/collectors/collector.py b/botx/collecting/collectors/collector.py deleted file mode 100644 index 07ab55db..00000000 --- a/botx/collecting/collectors/collector.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Definition for collector.""" -from typing import Any, Optional, Sequence - -from loguru import logger -from pydantic.dataclasses import dataclass - -from botx.collecting.collectors.base import BaseCollector -from botx.collecting.collectors.mixins.default import DefaultHandlerMixin -from botx.collecting.collectors.mixins.handler import HandlerMixin -from botx.collecting.collectors.mixins.hidden import HiddenHandlerMixin -from botx.collecting.collectors.mixins.system_events import SystemEventsHandlerMixin -from botx.dependencies.models import Depends -from botx.exceptions import NoMatchFound -from botx.models.messages.message import Message - - -@dataclass -class Collector( # noqa: WPS215 - HandlerMixin, - DefaultHandlerMixin, - HiddenHandlerMixin, - SystemEventsHandlerMixin, - BaseCollector, -): - """Collector for different handlers.""" - - async def __call__(self, message: Message) -> None: - """Find handler and execute it. - - Arguments: - message: incoming message that will be passed to handler. - """ - await self.handle_message(message) - - def include_collector( - self, - collector: "Collector", - *, - dependencies: Optional[Sequence[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. - - Raises: - AssertionError: raised if both collectors defines default handlers. - """ - if self.default_message_handler and collector.default_message_handler: - raise AssertionError("only one default handler can be applied") - - if collector.default_message_handler: - self._add_default_handler(collector.default_message_handler, dependencies) - - self._add_handlers(collector.handlers, dependencies) - - def command_for(self, *args: Any) -> str: - """Find handler and build a command string using passed body query_params. - - Arguments: - args: sequence of elements where first element should be name of handler. - - Returns: - Command string. - - Raises: - TypeError: raised no arguments passed. - """ - if not len(args): - raise TypeError("missing handler name as the first argument") - - return self.handler_for(args[0]).command_for(*args) - - async def handle_message(self, message: Message) -> None: - """Find handler and execute it. - - Arguments: - message: incoming message that will be passed to handler. - - Raises: - NoMatchFound: raised if no handler for message found. - """ - for handler in self.sorted_handlers: - if handler.matches(message): - logger.bind(botx_collector=True).info( - "botx => {0}: {1}".format(handler.name, message.command.command), - ) - await handler(message) - return - - if self.default_message_handler and not message.is_system_event: - await self.default_message_handler(message) - else: - raise NoMatchFound(search_param=message.body) diff --git a/botx/collecting/collectors/mixins/__init__.py b/botx/collecting/collectors/mixins/__init__.py deleted file mode 100644 index f175eefa..00000000 --- a/botx/collecting/collectors/mixins/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for mixin with decorators for collector.""" diff --git a/botx/collecting/collectors/mixins/default.py b/botx/collecting/collectors/mixins/default.py deleted file mode 100644 index cc37c988..00000000 --- a/botx/collecting/collectors/mixins/default.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Definition for mixin with default decorator.""" -from functools import partial -from typing import Any, Callable, Optional, Sequence, Union, cast - -from botx.collecting.collectors.mixins.handler import HandlerDecoratorProtocol -from botx.collecting.handlers.handler import Handler -from botx.collecting.handlers.name_generators import get_name_from_callable -from botx.dependencies.models import Depends - -try: - from typing import Protocol # noqa: WPS433 -except ImportError: - from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401 - - -class HandlerSearchProtocol(Protocol): - """Protocol for searching handler.""" - - def handler_for(self, name: str) -> Handler: - """Find handler in handlers of this bot.""" - - -class DefaultHandlerMixin: - """Mixin that defines default handler decorator.""" - - default_message_handler: Optional[Handler] - - 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] = False, - dependencies: Optional[Sequence[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. - - Raises: - AssertionError: raised if default handler already defined on collector. - """ - if self.default_message_handler is not None: - raise AssertionError( - "default handler is already registered on this collector", - ) - - if handler: - registered_handler = cast(HandlerDecoratorProtocol, 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 get_name_from_callable(registered_handler) - self.default_message_handler = cast( - HandlerSearchProtocol, - 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, - ) diff --git a/botx/collecting/collectors/mixins/handler.py b/botx/collecting/collectors/mixins/handler.py deleted file mode 100644 index 15eadcf4..00000000 --- a/botx/collecting/collectors/mixins/handler.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Definition for mixin with handler decorator.""" -from functools import partial -from typing import Any, Callable, List, Optional, Sequence, Union, cast - -from botx import converters -from botx.dependencies.models import Depends - -try: - from typing import Protocol # noqa: WPS433 -except ImportError: - from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401 - - -class AddHandlerProtocol(Protocol): - """Protocol for definition add_handler method.""" - - 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[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> None: - """Create new handler from passed arguments and store it inside.""" - - -class HandlerDecoratorProtocol(Protocol): - """Protocol for definition handler decorator.""" - - 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[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Add new handler to collector.""" - - -class HandlerMixin: - """Mixin that defines handler decorator.""" - - def handler( # noqa: WPS211 - self: AddHandlerProtocol, - 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[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] - ] = converters.optional_sequence_to_list(commands) - - if command and commands: - handler_commands.insert(0, 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, - dependency_overrides_provider=dependency_overrides_provider, - ) - - return handler - - return partial( - cast(HandlerDecoratorProtocol, 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, - ) diff --git a/botx/collecting/collectors/mixins/hidden.py b/botx/collecting/collectors/mixins/hidden.py deleted file mode 100644 index 892ebacf..00000000 --- a/botx/collecting/collectors/mixins/hidden.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Definition for mixin with hidden decorator.""" -from typing import Any, Callable, Optional, Sequence - -from botx.collecting.collectors.mixins.handler import HandlerDecoratorProtocol -from botx.dependencies.models import Depends - - -class HiddenHandlerMixin: - """Mixin that defines hidden handler decorator.""" - - def hidden( # noqa: WPS211 - self: HandlerDecoratorProtocol, - handler: Optional[Callable] = None, - *, - command: Optional[str] = None, - commands: Optional[Sequence[str]] = None, - name: Optional[str] = None, - dependencies: Optional[Sequence[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, - ) diff --git a/botx/collecting/collectors/mixins/system_events.py b/botx/collecting/collectors/mixins/system_events.py deleted file mode 100644 index d8c79e33..00000000 --- a/botx/collecting/collectors/mixins/system_events.py +++ /dev/null @@ -1,289 +0,0 @@ -"""Definition for mixin with system events decorator.""" -from typing import Any, Callable, Optional, Sequence, cast - -from botx.collecting.collectors.mixins.handler import HandlerDecoratorProtocol -from botx.dependencies.models import Depends -from botx.models.enums import SystemEvents - -try: - from typing import Protocol # noqa: WPS433 -except ImportError: - from typing_extensions import Protocol # type: ignore # noqa: WPS433, WPS440, F401 - - -class SystemEventsHandlerMixin: # noqa: WPS214 - """Mixin that defines system events handler decorator.""" - - def system_event( # noqa: WPS211 - self, - handler: Optional[Callable] = None, - *, - event: Optional[SystemEvents] = None, - events: Optional[Sequence[SystemEvents]] = None, - name: Optional[str] = None, - dependencies: Optional[Sequence[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. - - Raises: - AssertionError: raised if nor event or events passed. - """ - if not (event or events): - raise AssertionError("at least one event should be passed") - - return cast(HandlerDecoratorProtocol, 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[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=SystemEvents.chat_created, - name=SystemEvents.chat_created.value, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) - - def file_transfer( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[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=SystemEvents.file_transfer, - name=SystemEvents.file_transfer.value, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) - - def added_to_chat( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Register handler for `added_to_chat` 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=SystemEvents.added_to_chat, - name=SystemEvents.added_to_chat.value, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) - - def deleted_from_chat( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Register handler for `deleted_from_chat` 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=SystemEvents.deleted_from_chat, - name=SystemEvents.deleted_from_chat.value, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) - - def left_from_chat( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Register handler for `left_from_chat` 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=SystemEvents.left_from_chat, - name=SystemEvents.left_from_chat.value, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) - - def internal_bot_notification( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Register handler for `internal_bot_notification` 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=SystemEvents.internal_bot_notification, - name=SystemEvents.internal_bot_notification.value, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) - - def cts_login( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Register handler for `cts_login` 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=SystemEvents.cts_login, - name=SystemEvents.cts_login.value, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) - - def cts_logout( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Register handler for `cts_logout` 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=SystemEvents.cts_logout, - name=SystemEvents.cts_logout.value, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) - - def smartapp_event( - self, - handler: Optional[Callable] = None, - *, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> Callable: - """Register handler for `smartapp_event` 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=SystemEvents.smartapp_event, - name=SystemEvents.smartapp_event.value, - dependencies=dependencies, - dependency_overrides_provider=dependency_overrides_provider, - ) diff --git a/botx/collecting/handlers/__init__.py b/botx/collecting/handlers/__init__.py deleted file mode 100644 index 8b3be030..00000000 --- a/botx/collecting/handlers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for handler related stuff.""" diff --git a/botx/collecting/handlers/handler.py b/botx/collecting/handlers/handler.py deleted file mode 100644 index db5ee1a0..00000000 --- a/botx/collecting/handlers/handler.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Definition for command handler.""" - -from __future__ import annotations - -import re -from dataclasses import field -from typing import Any, Callable, List, Optional, Union - -from pydantic import validator -from pydantic.dataclasses import dataclass - -from botx.collecting.handlers.validators import ( - check_handler_is_function, - retrieve_dependant, - retrieve_executor, - retrieve_full_description_for_handler, - retrieve_name_for_handler, - validate_body_for_status, -) -from botx.dependencies import models as deps -from botx.models.messages.message import Message - - -@dataclass -class Handler: - """Handler that will store body and callable.""" - - #: callable for executing registered logic. - handler: Callable - - #: command body. - body: str - - #: name of handler. - name: str = "" - - #: description that will be shown in bot's menu. - description: Optional[str] = None - - #: custom description that can be used for another purposes. - full_description: str = "" - - #: should handler be included into status. - include_in_status: Union[bool, Callable] = True - - #: wrapper around handler that will be executed. - dependant: deps.Dependant = field(init=False) - - #: background dependencies for handler. - dependencies: List[deps.Depends] = field(default_factory=list) - - #: custom object that will override dependencies for handler. - dependency_overrides_provider: Optional[Any] = None - - #: function that will be used for handling incoming message - executor: Callable = field(init=False) - - _body_validator = validator("executor", always=True)(validate_body_for_status) - _handler_is_function_validator = validator("handler", pre=True, always=True)( - check_handler_is_function, - ) - - async def __call__(self, message: Message) -> None: - """Execute handler using incoming message. - - Arguments: - message: message that will be handled by handler. - """ - await self.executor(message) - - def __post_init__(self) -> None: - """Initialize or update special fields.""" - self.name = retrieve_name_for_handler(self.name, self.handler) - self.full_description = retrieve_full_description_for_handler( - self.full_description, - self.handler, - ) - self.dependant = retrieve_dependant(self.handler, self.dependencies) - self.executor = retrieve_executor( # type: ignore - self.dependant, - self.dependency_overrides_provider, - ) - - def matches(self, message: Message) -> bool: - """Check if message body matched to handler's body. - - Arguments: - message: incoming message which body will be used to check route. - - Returns: - Result of check. - """ - return bool(re.compile(self.body).match(message.body)) - - def command_for(self, *args: Any) -> str: - """Build a command string using passed body query_params. - - Arguments: - args: sequence of elements that are arguments for command. - - Returns: - Built command. - """ - args_str = " ".join((str(arg) for arg in args[1:])) - return "{0} {1}".format(self.body, args_str).strip() - - def __eq__(self, other: object) -> bool: - """Compare 2 handlers for equality. - - Arguments: - other: handler to compare with. - - Returns: - Result of comparing. - """ - if not isinstance(other, Handler): - return False - - callable_comp = self.handler == other.handler - callable_comp = callable_comp and self.dependencies == other.dependencies - - export_comp = self.name == other.name - export_comp = export_comp and self.body == other.body - export_comp = export_comp and self.description == other.description - export_comp = export_comp and self.full_description == other.full_description - export_comp = export_comp and self.include_in_status == other.include_in_status - - return callable_comp and export_comp diff --git a/botx/collecting/handlers/name_generators.py b/botx/collecting/handlers/name_generators.py deleted file mode 100644 index e2210ea9..00000000 --- a/botx/collecting/handlers/name_generators.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Generators for names for handler.""" - -import inspect -import re -from typing import Callable - - -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("-+", "-", dashed_body).lower()) - - -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__ diff --git a/botx/collecting/handlers/validators.py b/botx/collecting/handlers/validators.py deleted file mode 100644 index 52cbc405..00000000 --- a/botx/collecting/handlers/validators.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Validators and extractors for Handler fields.""" -import inspect -from typing import Any, Callable, List, Optional - -from botx.collecting.handlers.name_generators import get_name_from_callable -from botx.dependencies.models import Dependant, Depends, get_dependant -from botx.dependencies.solving import get_executor - - -def validate_body_for_status(executor: Callable, values: dict) -> Callable: - """Validate that body is acceptable for status. - - Arguments: - executor: executor that will be just returned from validator. - values: already checked validated_values. - - Returns: - Passed executor. - - Raises: - ValueError: raised if body is not acceptable for status. - """ - include_in_status = values["include_in_status"] - if not include_in_status: - return executor - - body = values["body"] - - if not body.startswith("/"): - raise ValueError("public commands should start with leading slash") - - slash_part_of_body = body[: -len(body.strip("/"))] - if slash_part_of_body.count("/") != 1: - raise ValueError("command body can contain only single leading slash") - - if len(body.split()) != 1: - raise ValueError("public commands should contain only one word") - - return executor - - -def retrieve_name_for_handler(name: Optional[str], handler: Callable) -> str: - """Retrieve name for handler. - - Arguments: - name: passed name for handler. - handler: handler that will be used to generate name. - - Returns: - Name for handler. - """ - return name or get_name_from_callable(handler) - - -def retrieve_full_description_for_handler( - full_description: Optional[str], - handler: Callable, -) -> str: - """Retrieve full description for handler. - - Arguments: - full_description: passed name for handler. - handler: handler from which docstring will be used. - - Returns: - Full description for handler. - """ - return full_description or inspect.cleandoc(handler.__doc__ or "") - - -def check_handler_is_function(handler: Callable) -> Callable: - """Check handler can be proceed by library. - - Library can handle functions and methods as handlers. - - Arguments: - handler: passed handler callable. - - Returns: - Passed handler. - - Raises: - ValueError: raised if handler is not acceptable. - """ - if not (inspect.isfunction(handler) or inspect.ismethod(handler)): - raise ValueError("handler must be a function or method") - - return handler - - -def retrieve_dependant(handler: Callable, dependencies: List[Depends]) -> Dependant: - """Retrieve dependant for handler. - - Arguments: - handler: handler for which dependant should be created. - dependencies: passed background dependencies. - - Returns: - Generated dependant object. - """ - dependant = get_dependant(call=handler) - for index, depends in enumerate(dependencies): - dependant.dependencies.insert( - index, - get_dependant(call=depends.dependency, use_cache=depends.use_cache), - ) - - return dependant - - -def retrieve_executor( - dependant: Dependant, - dependency_overrides_provider: Any, -) -> Callable: - """Retrieve executor for handler. - - Arguments: - dependant: dependant that will be used to generate executor. - dependency_overrides_provider: overrider for dependencies. - - Returns: - Generated executor. - """ - return get_executor( - dependant=dependant, - dependency_overrides_provider=dependency_overrides_provider, - ) diff --git a/botx/concurrency.py b/botx/concurrency.py deleted file mode 100644 index 9e15ab3b..00000000 --- a/botx/concurrency.py +++ /dev/null @@ -1,104 +0,0 @@ -"""Helpers for execution functions as coroutines.""" - -import asyncio -import contextvars -import functools -import inspect -from typing import Any, Callable, Coroutine - - -def is_awaitable_object(call: Callable) -> bool: - """Check if object is an awaitable or an object which __call__ method is awaitable. - - Arguments: - call: callable for checking. - - Returns: - Result of check. - """ - if is_awaitable(call): - return True - call = getattr(call, "__call__", None) # noqa: B004 - return asyncio.iscoroutinefunction(call) - - -def is_awaitable(call: Callable) -> bool: - """Check if function returns awaitable object. - - Arguments: - call: function that should be checked. - - Returns: - Result of check. - """ - if inspect.isfunction(call) or inspect.ismethod(call): - return asyncio.iscoroutinefunction(call) - return False - - -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() - child = functools.partial(call, *args, **kwargs) - context = contextvars.copy_context() - call = context.run - args = (child,) - return await loop.run_in_executor(None, call, *args) - - -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 - coroutine. - args: positional arguments for this function. - kwargs: key arguments for this function. - - Returns: - Coroutine object from passed callable. - """ - if is_awaitable_object(func): - return func(*args, **kwargs) - - return run_in_threadpool(func, *args, **kwargs) - - -def run_in_blocking_loop(call: Callable, *args: Any, **kwargs: Any) -> Any: - """Run coroutine with loop blocking. - - Arguments: - call: function that should be called in loop until complete. - args: function arguments. - kwargs: function key arguments. - - Returns: - Result of function call. - """ - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - - return loop.run_until_complete(callable_to_coroutine(call, *args, **kwargs)) - - -def async_to_sync(func: Callable) -> Callable: - """Convert asynchronous function to blocking. - - Arguments: - func: function that should be converted. - - Returns: - Converted function. - """ - return functools.partial(run_in_blocking_loop, func) diff --git a/botx/constants.py b/botx/constants.py new file mode 100644 index 00000000..085fb2d0 --- /dev/null +++ b/botx/constants.py @@ -0,0 +1,12 @@ +try: + from typing import Final +except ImportError: + from typing_extensions import Final # type: ignore # noqa: WPS440 + +CHUNK_SIZE: Final = 1024 * 1024 # 1Mb +BOT_API_VERSION: Final = 4 +SMARTAPP_API_VERSION: Final = 1 +STICKER_IMAGE_MAX_SIZE: Final = 512 * 1024 # 512Kb +STICKER_PACKS_PER_PAGE: Final = 10 +MAX_NOTIFICATION_BODY_LENGTH: Final = 4096 +MAX_FILE_LEN_IN_LOGS: Final = 64 diff --git a/botx/converters.py b/botx/converters.py index bb41049d..3a9147a9 100644 --- a/botx/converters.py +++ b/botx/converters.py @@ -1,19 +1,9 @@ -"""Converters for common operations.""" - from typing import List, Optional, Sequence, TypeVar -TSequenceElement = TypeVar("TSequenceElement") +TItem = TypeVar("TItem") 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 []) + optional_sequence: Optional[Sequence[TItem]], +) -> List[TItem]: + return list(optional_sequence or []) diff --git a/botx/dependencies/__init__.py b/botx/dependencies/__init__.py deleted file mode 100644 index 5578cd4a..00000000 --- a/botx/dependencies/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Dependency injection implementation.""" diff --git a/botx/dependencies/injection_params.py b/botx/dependencies/injection_params.py deleted file mode 100644 index 5f7f0a47..00000000 --- a/botx/dependencies/injection_params.py +++ /dev/null @@ -1,19 +0,0 @@ -"""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/dependencies/inspecting.py b/botx/dependencies/inspecting.py deleted file mode 100644 index d14fc426..00000000 --- a/botx/dependencies/inspecting.py +++ /dev/null @@ -1,51 +0,0 @@ -"""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=dependency_param.name, - kind=dependency_param.kind, - default=dependency_param.default, - annotation=get_typed_annotation(dependency_param, global_namespace), - ) - for dependency_param in signature.parameters.values() - ] - return inspect.Signature(typed_params) - - -def get_typed_annotation( - dependency_param: inspect.Parameter, - global_namespace: Dict[str, Any], -) -> Any: - """Solve forward reference annotation for instance of `inspect.Parameter`. - - Arguments: - dependency_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 = dependency_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 deleted file mode 100644 index 36f61b1e..00000000 --- a/botx/dependencies/models.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Dependant model and transforming functions.""" -from __future__ import annotations - -import inspect -from dataclasses import field -from typing import Any, Callable, Dict, List, Optional - -from pydantic.dataclasses import dataclass -from pydantic.utils import lenient_issubclass - -from botx.bots import bots -from botx.clients.clients import async_client, sync_client -from botx.dependencies import inspecting -from botx.models.messages.message import Message - -WRONG_PARAM_TYPE_ERROR_TEXT = ( - "Param {0} of {1} can only be a dependency, message, bot or client, got: {2}" -) - -CacheKey = Optional[Callable] -DependenciesCache = Dict[CacheKey, Any] - - -@dataclass -class Depends: - """Stores dependency callable.""" - - #: callable object that will be used in handlers or other dependencies instances. - dependency: Callable[..., Any] - - #: use cache for dependency. - use_cache: bool = True - - -@dataclass -class Dependant: - """Main model that contains all necessary data for solving dependencies.""" - - #: list of sub-dependencies for this dependency. - dependencies: List[Dependant] = field(default_factory=list) - - #: name of dependency. - name: Optional[str] = None - - #: callable object that will solve dependency. - call: Optional[Callable] = None - - #: param name for passing incoming [message][botx.models.messages.Message] - message_param_name: Optional[str] = None - - #: param name for passing [bot][botx.bots.Bot] that handles command. - bot_param_name: Optional[str] = None - - #: param name for passing [client][botx.clients.clients.async_client.AsyncClient]. - async_client_param_name: Optional[str] = None - - #: param name for passing [client][botx.clients.clients.sync_client.Client]. - sync_client_param_name: Optional[str] = None - - #: use cache for optimize solving performance. - use_cache: bool = True - - # Save the cache key at creation to optimize performance - #: storage for cache. - cache_key: CacheKey = field(init=False) - - def __post_init__(self) -> None: - """Init special fields.""" - self.cache_key = self.call - - -Dependant.__pydantic_model__.update_forward_refs() # type: ignore # noqa: WPS609 - - -def get_param_sub_dependant(*, dependency_param: inspect.Parameter) -> Dependant: - """Parse instance of parameter to get it as dependency. - - Arguments: - dependency_param: param for which sub dependency should be retrieved. - - Returns: - Object that will be used in solving dependency. - """ - depends: Depends = dependency_param.default - dependency = depends.dependency - - return get_dependant( - call=dependency, - name=dependency_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. - - Raises: - ValueError: raised if param is not Dependant or special type. - """ - dependant = Dependant(call=call, name=name, use_cache=use_cache) - for dependency_param in inspecting.get_typed_signature(call).parameters.values(): - if isinstance(dependency_param.default, Depends): - dependant.dependencies.append( - get_param_sub_dependant(dependency_param=dependency_param), - ) - continue - - is_special_param = add_special_param_to_dependency( - dependency_param=dependency_param, - dependant=dependant, - ) - if is_special_param: - continue - - raise ValueError( - WRONG_PARAM_TYPE_ERROR_TEXT.format( - dependency_param.name, - call, - dependency_param.annotation, - ), - ) - - return dependant - - -def add_special_param_to_dependency( - *, - dependency_param: inspect.Parameter, - dependant: Dependant, -) -> bool: - """Check if param is non field object that should be passed into callable. - - Arguments: - dependency_param: param that should be checked. - dependant: dependency which field would be filled with required param name. - - Returns: - Result of check. - """ - if lenient_issubclass(dependency_param.annotation, bots.Bot): - dependant.bot_param_name = dependency_param.name - return True - elif lenient_issubclass(dependency_param.annotation, Message): - dependant.message_param_name = dependency_param.name - return True - elif lenient_issubclass(dependency_param.annotation, async_client.AsyncClient): - dependant.async_client_param_name = dependency_param.name - return True - elif lenient_issubclass(dependency_param.annotation, sync_client.Client): - dependant.sync_client_param_name = dependency_param.name - return True - - return False diff --git a/botx/dependencies/solving.py b/botx/dependencies/solving.py deleted file mode 100644 index 2cda9de1..00000000 --- a/botx/dependencies/solving.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Functions for solving dependencies.""" - -from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, cast - -from botx import concurrency -from botx.dependencies.models import ( - CacheKey, - Dependant, - DependenciesCache, - get_dependant, -) -from botx.models.messages.message import Message - - -async def solve_sub_dependency( - message: Message, - dependant: Dependant, - solved_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. - solved_values: already filled validated_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 - if dependency_overrides_provider is not None - else message.bot, - "dependency_overrides", - {}, - ) - if overrides: - call = overrides.get(dependant.call, dependant.call) - use_sub_dependant = get_dependant(call=call, name=dependant.name) - - solving_result = await solve_dependencies( - message=message, - dependant=use_sub_dependant, - dependency_overrides_provider=dependency_overrides_provider, - dependency_cache=dependency_cache, - ) - dependency_cache.update(solving_result[1]) - - dependant.cache_key = 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, **solving_result[0]) - - if dependant.name is not None: - solved_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. - - """ - solved_values: Dict[str, Any] = {} - dependency_cache = dependency_cache or {} - for sub_dependant in dependant.dependencies: - await solve_sub_dependency( - message=message, - dependant=sub_dependant, - solved_values=solved_values, - dependency_overrides_provider=dependency_overrides_provider, - dependency_cache=dependency_cache, - ) - - if dependant.message_param_name: - solved_values[dependant.message_param_name] = message - if dependant.bot_param_name: - solved_values[dependant.bot_param_name] = message.bot - if dependant.async_client_param_name: - solved_values[dependant.async_client_param_name] = message.bot.client - if dependant.sync_client_param_name: - solved_values[dependant.sync_client_param_name] = message.bot.sync_client - return solved_values, dependency_cache - - -def get_executor( - dependant: Dependant, - dependency_overrides_provider: Any = None, -) -> Callable[[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. - - Returns: - Asynchronous executor for handling message. - - Raises: - AssertionError: raised if there is no callable in `dependant.call`. - """ - if dependant.call is None: - raise AssertionError("dependant.call must be present") - - async def factory(message: Message) -> None: - solved_values, _ = await solve_dependencies( - message=message, - dependant=dependant, - dependency_overrides_provider=dependency_overrides_provider, - ) - await concurrency.callable_to_coroutine( - cast(Callable, dependant.call), - **solved_values, - ) - - return factory diff --git a/botx/exception_handlers.py b/botx/exception_handlers.py deleted file mode 100644 index 9262bc35..00000000 --- a/botx/exception_handlers.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Define several handlers for builtin exceptions from this library.""" - -from typing import Any - -from loguru import logger - -from botx.exceptions import NoMatchFound -from botx.models.messages import message as 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. - - Arguments: - _: default arguments passed to exception handler. - """ - - -async def no_match_found_exception_handler( - exception: NoMatchFound, - message: messages.Message, -) -> None: - """Log that handler was not found. - - Arguments: - exception: raised NoMatchFound exception. - message: message on which processing error was raised. - """ - logger.info("handler for {0!r} was not found", exception.search_param) diff --git a/botx/exceptions.py b/botx/exceptions.py deleted file mode 100644 index de36642b..00000000 --- a/botx/exceptions.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Exceptions that are used in this library.""" -from typing import Any, Dict -from uuid import UUID - - -class BotXException(Exception): - """Base error for exception in this library.""" - - #: template that should be rendered on __str__ call. - message_template: str = "" - - def __init__(self, **kwargs: Any) -> None: - """Init exception with passed query_params. - - Arguments: - kwargs: key-arguments that will be stored in instance. - """ - self.__dict__ = kwargs - - def __str__(self) -> str: - """Render string representation. - - Returns: - String representation of error. - """ - return self.message_template.format(**self.__dict__) - - -class NoMatchFound(BotXException): - """Raised by collector if no matching handler exists.""" - - message_template = "handler for {search_param} not found" - - #: body for which handler was not found. - search_param: str - - -class DependencyFailure(BotXException): - """Raised when there is error in dependency and flow should be stopped.""" - - -class BotXAPIError(BotXException): - """Raised if there is an error in requests to BotX API.""" - - message_template = ( - "unable to send {method} {url} to BotX API ({status}): {response_content}" - ) - - #: URL from request. - url: str - - #: HTTP method. - method: str - - #: response from API. - response_content: Dict[str, Any] - - # HTTP status code. - status: int - - -class UnknownBotError(BotXException): - """Raised if bot does not know bot.""" - - message_template = "unknown bot {bot_id}" - - #: bot id that is unregistered. - bot_id: UUID - - -class BotXAPIRouteDeprecated(BotXAPIError): - """Raised if API route was deprecated.""" - - message_template = "route {method} {url} is deprecated" - - #: bot id that is unregistered. - bot_id: UUID - - -class TokenError(BotXException): - """Raised if token is invalid.""" - - message_template = "invalid token for bot {bot_id}" - - bot_id: UUID - - -class BotXJSONDecodeError(BotXException): - """Raised if response body cannot be processed.""" - - message_template = "unable to process response body from {method} {url}" - - #: URL from request. - url: str - - #: HTTP method. - method: str - - -class BotXConnectError(BotXException): - """Raised if unable to connect to service.""" - - message_template = ( - "unable to connect to service {method} {url}. " - "Make sure you specified the correct host in bot credentials." - ) - - #: URL from request. - url: str - - #: HTTP method. - method: str diff --git a/botx/image_validators.py b/botx/image_validators.py new file mode 100644 index 00000000..d66e30c3 --- /dev/null +++ b/botx/image_validators.py @@ -0,0 +1,23 @@ +from botx.async_buffer import AsyncBufferReadable, get_file_size +from botx.constants import STICKER_IMAGE_MAX_SIZE + +PNG_MAGIC_BYTES: bytes = b"\x89\x50\x4E\x47\x0D\x0A\x1A\x0A" + + +async def ensure_file_content_is_png(async_buffer: AsyncBufferReadable) -> None: + magic_bytes = await async_buffer.read(8) + + await async_buffer.seek(0) + + if magic_bytes != PNG_MAGIC_BYTES: + raise ValueError("Passed file is not PNG") + + +async def ensure_sticker_image_size_valid(async_buffer: AsyncBufferReadable) -> None: + file_size = await get_file_size(async_buffer) + + if file_size > STICKER_IMAGE_MAX_SIZE: + max_file_size_mb = STICKER_IMAGE_MAX_SIZE / 1024 / 1024 + raise ValueError( + f"Passed file size is greater than {max_file_size_mb:.1f} Mb", + ) diff --git a/botx/logger.py b/botx/logger.py new file mode 100644 index 00000000..ef97cab6 --- /dev/null +++ b/botx/logger.py @@ -0,0 +1,48 @@ +import json +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Dict + +from loguru import logger as _logger + +from botx.constants import MAX_FILE_LEN_IN_LOGS + +if TYPE_CHECKING: # To avoid circular import + from loguru import Logger + + +def pformat_jsonable_obj(jsonable_obj: Any) -> str: + return json.dumps(jsonable_obj, sort_keys=True, indent=4, ensure_ascii=False) + + +def trim_file_data_in_outgoing_json(json_body: Any) -> Any: + if not isinstance(json_body, dict): + return json_body + + if json_body.get("file"): + json_body = deepcopy(json_body) + json_body["file"]["data"] = ( + json_body["file"]["data"][:MAX_FILE_LEN_IN_LOGS] + "..." + ) + + return json_body + + +def trim_file_data_in_incoming_json(json_body: Dict[str, Any]) -> Dict[str, Any]: + if json_body.get("attachments"): + # Max one attach per-message + # Link and Location doesn't have content + if json_body["attachments"][0]["data"].get("content"): + json_body = deepcopy(json_body) + json_body["attachments"][0]["data"]["content"] = ( + json_body["attachments"][0]["data"]["content"][:MAX_FILE_LEN_IN_LOGS] + + "..." + ) + + return json_body + + +def setup_logger() -> "Logger": + return _logger + + +logger = setup_logger() diff --git a/botx/middlewares/__init__.py b/botx/middlewares/__init__.py deleted file mode 100644 index 42632b0a..00000000 --- a/botx/middlewares/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition ob built-in middlewares for botx.""" diff --git a/botx/middlewares/authorization.py b/botx/middlewares/authorization.py deleted file mode 100644 index e0abc69b..00000000 --- a/botx/middlewares/authorization.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Middleware for retrieving tokens from BotX API before processing message.""" - -from botx.middlewares.base import BaseMiddleware -from botx.models.messages.message import Message -from botx.typing import AsyncExecutor - - -class AuthorizationMiddleware(BaseMiddleware): - """Middleware for retrieving tokens from BotX API before processing message.""" - - async def dispatch(self, message: Message, call_next: AsyncExecutor) -> None: - """Obtain token for bot for handling answers to message. - - Arguments: - message: incoming message. - call_next: next executor in chain. - """ - bot = message.bot - bot_account = bot.get_account_by_bot_id(message.bot_id) - if bot_account.token is None: - token = await bot.get_token( - message.host, - message.bot_id, - bot_account.signature, - ) - bot_account.token = token - await call_next(message) diff --git a/botx/middlewares/base.py b/botx/middlewares/base.py deleted file mode 100644 index 312e6edf..00000000 --- a/botx/middlewares/base.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Definition of base for custom middlewares. - -Important: - Middleware should implement `dispatch` method that can be a common function or - an asynchronous function. - -```python -class MyAsyncBotXMiddleware(BaseMiddleware): - async def dispatch( - self, message: Message, call_next: AsyncExecutor, - ) -> None: - await call_next(message) - -class MySyncBotXMiddleware(BaseMiddleware): - def dispatch(self, message: Message, call_next: SyncExecutor) -> None: - call_next(message) -``` -""" - -from typing import Callable, Optional - -from botx import concurrency -from botx.models.messages.message import Message -from botx.typing import Executor, MiddlewareDispatcher, SyncExecutor - - -def _default_dispatch( - _middleware: "BaseMiddleware", - _message: Message, - _call_next: SyncExecutor, -) -> None: - raise NotImplementedError - - -class BaseMiddleware: - """Base middleware entity.""" - - dispatch: Callable = _default_dispatch - - def __init__( - self, - executor: Executor, - dispatch: Optional[MiddlewareDispatcher] = None, - ) -> None: - """Init middleware with required query_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: Message) -> None: - """Call middleware dispatcher as normal handler executor. - - Arguments: - message: incoming message. - """ - executor = self.executor - if not concurrency.is_awaitable(self.dispatch_func): - executor = concurrency.async_to_sync(self.executor) - - await concurrency.callable_to_coroutine(self.dispatch_func, message, executor) diff --git a/botx/middlewares/exceptions.py b/botx/middlewares/exceptions.py deleted file mode 100644 index 54f2187c..00000000 --- a/botx/middlewares/exceptions.py +++ /dev/null @@ -1,133 +0,0 @@ -"""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.middlewares.base import BaseMiddleware -from botx.models import files -from botx.models.messages.message import Message -from botx.typing import AsyncExecutor, Executor - - -class ExceptionMiddleware(BaseMiddleware): - """Custom middleware that is default and used to handle registered errors.""" - - def __init__(self, executor: Executor) -> None: - """Init middleware with required query_params. - - Arguments: - executor: callable object that accept message and will be executed after - middleware. - """ - super().__init__(executor) - self._exception_handlers: Dict[Type[Exception], Callable] = {} - - async def dispatch(self, message: Message, call_next: AsyncExecutor) -> None: - """Wrap executor for catching exception or log them. - - Arguments: - message: incoming message that will be passed to executor. - call_next: next executor that should be called after this. - """ - try: - await call_next(message) - except Exception as exc: - await self._handle_error_in_handler(exc, message) - - 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: Message) -> None: - """Pass error back to handler if there is one or log error. - - Arguments: - exc: exception that occurred. - message: message on which exception occurred. - """ - exception_logger = logger.bind( - botx_error=True, - payload=message.incoming_message.copy( - update={ - "body": _convert_text_to_logs_format(message.body), - "file": _convert_file_to_logs_format(message.file), - }, - ).dict(), - ) - handler = self._lookup_handler_for_exception(exc) - - if handler is None: - exception_logger.exception( - "uncaught {0} exception in handler: {1}", - type(exc).__name__, - exc, - ) - return - - try: - await concurrency.callable_to_coroutine(handler, exc, message) - except Exception as error_handler_exc: - exception_logger.exception( - "uncaught {0} exception in error handler: {1}", - type(error_handler_exc).__name__, - error_handler_exc, - ) - - -def _convert_text_to_logs_format(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 - ) - - -def _convert_file_to_logs_format(file: Optional[files.File]) -> Optional[dict]: - """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.copy(update={"data": "[file content]"}).dict() if file else None diff --git a/botx/middlewares/ns.py b/botx/middlewares/ns.py deleted file mode 100644 index c5005826..00000000 --- a/botx/middlewares/ns.py +++ /dev/null @@ -1,261 +0,0 @@ -"""Definition for middleware that precess next step handlers logic.""" - -import contextlib -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union -from uuid import UUID - -from loguru import logger -from pydantic import BaseConfig, BaseModel - -from botx import Bot, Collector, concurrency, converters, exceptions -from botx.collecting.handlers.handler import Handler -from botx.collecting.handlers.name_generators import get_name_from_callable -from botx.dependencies.models import Depends -from botx.middlewares.base import BaseMiddleware -from botx.models.messages.message import Message -from botx.typing import Executor - - -class NextStepHandlerState(BaseModel): - """Information about next step handler.""" - - class Config(BaseConfig): - arbitrary_types_allowed = True - - #: name of handler that should be called. - name: str - - #: arguments that should be set on message for handler. - arguments: Dict[str, Any] - - -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__( # noqa: WPS211 - self, - executor: Executor, - bot: Bot, - functions: Union[Dict[str, Callable], Sequence[Callable]], - break_handler: Optional[Union[Handler, str, Callable]] = None, - dependencies: Optional[Sequence[Depends]] = None, - dependency_overrides_provider: Any = None, - ) -> None: - """Init middleware with required query_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. - dependencies: background dependencies that should be applied to handlers. - dependency_overrides_provider: object that will override dependencies for - this handler. - """ - super().__init__(executor) - - dependencies = converters.optional_sequence_to_list( - bot.collector.dependencies, - ) + converters.optional_sequence_to_list( - dependencies, - ) # noqa: W503 - dep_override = ( - dependency_overrides_provider or bot.collector.dependency_overrides_provider - ) - bot.state.ns_collector = Collector( - dependencies=dependencies, - dependency_overrides_provider=dep_override, - ) - bot.state.ns_store = {} - - bot.state.ns_break_handler = None - if break_handler: - if isinstance(break_handler, Handler): - bot.state.ns_collector.handlers.append(break_handler) - bot.state.ns_break_handler = break_handler.name - elif callable(break_handler): - register_function_as_ns_handler(bot, break_handler) - bot.state.ns_break_handler = get_name_from_callable(break_handler) - else: - bot.state.ns_collector.handlers.append( - bot.collector.handler_for(break_handler), - ) - bot.state.ns_break_handler = break_handler - - 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: Message, call_next: Executor) -> None: - """Execute middleware logic. - - Arguments: - message: incoming message. - call_next: next executor in middleware chain. - """ - if message.bot.state.ns_break_handler: - break_handler = message.bot.state.ns_collector.handler_for( - message.bot.state.ns_break_handler, - ) - if break_handler.matches(message): - await self.drop_next_step_handlers_chain(message) - await break_handler(message) - return - - try: - next_handler, state = await self.lookup_next_handler_for_message(message) - except (exceptions.NoMatchFound, IndexError, KeyError, RuntimeError): - await concurrency.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", - ) - - for state_argument in state.arguments.items(): - setattr(message.state, state_argument[0], state_argument[1]) - await next_handler(message) - - async def lookup_next_handler_for_message( - self, - message: Message, - ) -> Tuple[Handler, NextStepHandlerState]: - """Find handler in bot storage or in handlers. - - Arguments: - message: message for which next step handler should be found. - - Returns: - Found handler and state with arguments for message. - """ - handlers: List[NextStepHandlerState] = message.bot.state.ns_store[ - get_chain_key_by_message(message) - ] - handler_state = handlers.pop() - return ( - message.bot.state.ns_collector.handler_for(handler_state.name), - handler_state, - ) - - async def drop_next_step_handlers_chain(self, message: Message) -> None: - """Drop registered chain for message. - - Arguments: - message: message for which chain should be dropped. - """ - with contextlib.suppress(KeyError): - message.bot.state.ns_store.pop(get_chain_key_by_message(message)) - - -def get_chain_key_by_message(message: 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. - - Raises: - RuntimeError: raised if key for chain can not be built. - """ - # key is a tuple of (host, bot_id, chat_id, user_huid) - if not (message.user_huid and message.group_chat_id): - 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: 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. - - Raises: - ValueError: raised if there is error to register handler. - """ - name = name or get_name_from_callable(func) - collector: Collector = bot.state.ns_collector - try: - collector.add_handler( - body=name, - name=name, - handler=func, - include_in_status=False, - dependencies=collector.dependencies, - dependency_overrides_provider=collector.dependency_overrides_provider, - ) - except AssertionError as exc: - raise ValueError(exc.args) - - -def register_next_step_handler( - message: 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. - - Raises: - ValueError: raised if passed message does not include user_huid or if handler - that should be registered as next step does not exists. - """ - if message.user_huid is None: - raise ValueError( - "message for which ns handler is registered should include user_huid", - ) - - bot = message.bot - collector: Collector = bot.state.ns_collector - name = get_name_from_callable(func) if callable(func) else func - - try: - collector.handler_for(name) - except exceptions.NoMatchFound: - raise ValueError( - "bot does not have registered next step handler with name {0}".format(name), - ) - - key = get_chain_key_by_message(message) - - store: List[NextStepHandlerState] = bot.state.ns_store.setdefault(key, []) - store.append(NextStepHandlerState(name=name, arguments=ns_arguments)) diff --git a/botx/missing.py b/botx/missing.py new file mode 100644 index 00000000..193aacd7 --- /dev/null +++ b/botx/missing.py @@ -0,0 +1,26 @@ +from typing import Any, Literal, TypeVar, Union + + +class _UndefinedType: + """For fields that can be skipped.""" + + def __bool__(self) -> Literal[False]: + return False + + def __repr__(self) -> str: + return "Undefined" + + +RequiredType = TypeVar("RequiredType") +Undefined = _UndefinedType() + +Missing = Union[RequiredType, _UndefinedType] +MissingOptional = Union[RequiredType, None, _UndefinedType] + + +def not_undefined(*args: Any) -> Any: + for arg in args: + if arg is not Undefined: + return arg + + raise ValueError("All arguments have `Undefined` type") diff --git a/botx/models/__init__.py b/botx/models/__init__.py index 1e8935c6..e69de29b 100644 --- a/botx/models/__init__.py +++ b/botx/models/__init__.py @@ -1 +0,0 @@ -"""Pydantic models, data classes or other entities.""" diff --git a/botx/models/api_base.py b/botx/models/api_base.py new file mode 100644 index 00000000..6d219c5c --- /dev/null +++ b/botx/models/api_base.py @@ -0,0 +1,82 @@ +import json +from enum import Enum +from typing import Any, Dict, List, Optional, Set, Union, cast + +from pydantic import BaseModel +from pydantic.json import pydantic_encoder + +from botx.missing import Undefined + + +def _remove_undefined( + origin_obj: Union[Dict[str, Any], List[Any]], +) -> Union[Dict[str, Any], List[Any]]: + if isinstance(origin_obj, dict): + new_dict = {} + + for key, value in origin_obj.items(): + if value is Undefined: + continue + + if isinstance(value, (list, dict)): + new_value = _remove_undefined(value) + if new_value or len(new_value) == len(value): + new_dict[key] = new_value + else: + new_dict[key] = value + + return new_dict + + elif isinstance(origin_obj, list): + new_list = [] + + for value in origin_obj: + if value is Undefined: + continue + + if isinstance(value, (list, dict)): + new_value = _remove_undefined(value) + if new_value or len(new_value) == len(value): + new_list.append(new_value) + else: + new_list.append(value) + + return new_list + + raise NotImplementedError + + +class PayloadBaseModel(BaseModel): + def json(self) -> str: # type: ignore [override] + clean_dict = _remove_undefined(self.dict()) + return json.dumps(clean_dict, default=pydantic_encoder, ensure_ascii=False) + + def jsonable_dict(self) -> Dict[str, Any]: + return cast( + Dict[str, Any], + json.loads(self.json()), + ) + + +class VerifiedPayloadBaseModel(PayloadBaseModel): + """Pydantic base model for API models.""" + + class Config: + use_enum_values = True + + +class UnverifiedPayloadBaseModel(PayloadBaseModel): + def __init__( + self, + _fields_set: Optional[Set[str]] = None, + **kwargs: Any, + ) -> None: + model = BaseModel.construct(_fields_set, **kwargs) + self.__dict__.update(model.__dict__) # noqa: WPS609 (Replace self attrs) + + class Config: + arbitrary_types_allowed = True + + +class StrEnum(str, Enum): # noqa: WPS600 (pydantic needs this inheritance) + """Enum base for API models.""" diff --git a/botx/models/async_files.py b/botx/models/async_files.py new file mode 100644 index 00000000..f7f2fcc1 --- /dev/null +++ b/botx/models/async_files.py @@ -0,0 +1,249 @@ +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import AsyncGenerator, Literal, Union, cast +from uuid import UUID + +from aiofiles.tempfile import SpooledTemporaryFile + +from botx.bot.contextvars import bot_id_var, bot_var, chat_id_var +from botx.constants import CHUNK_SIZE +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.enums import ( + APIAttachmentTypes, + AttachmentTypes, + convert_attachment_type_from_domain, + convert_attachment_type_to_domain, +) + + +@dataclass +class AsyncFileBase: + type: AttachmentTypes + filename: str + size: int + + is_async_file: Literal[True] + + _file_id: UUID + _file_url: str + _file_mimetype: str + _file_hash: str + + @asynccontextmanager + async def open(self) -> AsyncGenerator[SpooledTemporaryFile, None]: + bot = bot_var.get() + + async with SpooledTemporaryFile(max_size=CHUNK_SIZE) as tmp_file: + await bot.download_file( + bot_id=bot_id_var.get(), + chat_id=chat_id_var.get(), + file_id=self._file_id, + async_buffer=tmp_file, + ) + + yield tmp_file + + +@dataclass +class Image(AsyncFileBase): + type: Literal[AttachmentTypes.IMAGE] + + +@dataclass +class Video(AsyncFileBase): + type: Literal[AttachmentTypes.VIDEO] + + duration: int + + +@dataclass +class Document(AsyncFileBase): + type: Literal[AttachmentTypes.DOCUMENT] + + +@dataclass +class Voice(AsyncFileBase): + type: Literal[AttachmentTypes.VOICE] + + duration: int + + +class APIAsyncFileBase(VerifiedPayloadBaseModel): + type: APIAttachmentTypes + file: str + file_mime_type: str + file_id: UUID + file_name: str + file_size: int + file_hash: str + + class Config: + """BotX sends extra fields which are used by client only. + + We skip their validation, but extra fields will be saved during + serialization/deserialization. + """ + + extra = "allow" + + +class ApiAsyncFileImage(APIAsyncFileBase): + type: Literal[APIAttachmentTypes.IMAGE] + + +class ApiAsyncFileVideo(APIAsyncFileBase): + type: Literal[APIAttachmentTypes.VIDEO] + + duration: int + + +class ApiAsyncFileDocument(APIAsyncFileBase): + type: Literal[APIAttachmentTypes.DOCUMENT] + + +class ApiAsyncFileVoice(APIAsyncFileBase): + type: Literal[APIAttachmentTypes.VOICE] + + duration: int + + +APIAsyncFile = Union[ + ApiAsyncFileImage, + ApiAsyncFileVideo, + ApiAsyncFileDocument, + ApiAsyncFileVoice, +] + +File = Union[Image, Video, Document, Voice] + + +def convert_async_file_from_domain(file: File) -> APIAsyncFile: + attachment_type = convert_attachment_type_from_domain(file.type) + + if attachment_type == APIAttachmentTypes.IMAGE: + attachment_type = cast(Literal[APIAttachmentTypes.IMAGE], attachment_type) + file = cast(Image, file) + + return ApiAsyncFileImage( + type=attachment_type, + file_name=file.filename, + file_size=file.size, + file_id=file._file_id, + file=file._file_url, + file_mime_type=file._file_mimetype, + file_hash=file._file_hash, + ) + + if attachment_type == APIAttachmentTypes.VIDEO: + attachment_type = cast(Literal[APIAttachmentTypes.VIDEO], attachment_type) + file = cast(Video, file) + + return ApiAsyncFileVideo( + type=attachment_type, + file_name=file.filename, + file_size=file.size, + duration=file.duration, + file_id=file._file_id, + file=file._file_url, + file_mime_type=file._file_mimetype, + file_hash=file._file_hash, + ) + + if attachment_type == APIAttachmentTypes.DOCUMENT: + attachment_type = cast(Literal[APIAttachmentTypes.DOCUMENT], attachment_type) + file = cast(Document, file) + + return ApiAsyncFileDocument( + type=attachment_type, + file_name=file.filename, + file_size=file.size, + file_id=file._file_id, + file=file._file_url, + file_mime_type=file._file_mimetype, + file_hash=file._file_hash, + ) + + if attachment_type == APIAttachmentTypes.VOICE: + attachment_type = cast(Literal[APIAttachmentTypes.VOICE], attachment_type) + file = cast(Voice, file) + + return ApiAsyncFileVoice( + type=attachment_type, + file_name=file.filename, + file_size=file.size, + duration=file.duration, + file_id=file._file_id, + file=file._file_url, + file_mime_type=file._file_mimetype, + file_hash=file._file_hash, + ) + + raise NotImplementedError(f"Unsupported attachment type: {attachment_type}") + + +def convert_async_file_to_domain(async_file: APIAsyncFile) -> File: + attachment_type = convert_attachment_type_to_domain(async_file.type) + + if attachment_type == AttachmentTypes.IMAGE: + attachment_type = cast(Literal[AttachmentTypes.IMAGE], attachment_type) + async_file = cast(ApiAsyncFileImage, async_file) + + return Image( + type=attachment_type, + filename=async_file.file_name, + size=async_file.file_size, + is_async_file=True, + _file_id=async_file.file_id, + _file_mimetype=async_file.file_mime_type, + _file_url=async_file.file, + _file_hash=async_file.file_hash, + ) + + if attachment_type == AttachmentTypes.VIDEO: + attachment_type = cast(Literal[AttachmentTypes.VIDEO], attachment_type) + async_file = cast(ApiAsyncFileVideo, async_file) + + return Video( + type=attachment_type, + filename=async_file.file_name, + size=async_file.file_size, + duration=async_file.duration, + is_async_file=True, + _file_id=async_file.file_id, + _file_mimetype=async_file.file_mime_type, + _file_url=async_file.file, + _file_hash=async_file.file_hash, + ) + + if attachment_type == AttachmentTypes.DOCUMENT: + attachment_type = cast(Literal[AttachmentTypes.DOCUMENT], attachment_type) + async_file = cast(ApiAsyncFileDocument, async_file) + + return Document( + type=attachment_type, + filename=async_file.file_name, + size=async_file.file_size, + is_async_file=True, + _file_id=async_file.file_id, + _file_mimetype=async_file.file_mime_type, + _file_url=async_file.file, + _file_hash=async_file.file_hash, + ) + + if attachment_type == AttachmentTypes.VOICE: + attachment_type = cast(Literal[AttachmentTypes.VOICE], attachment_type) + async_file = cast(ApiAsyncFileVoice, async_file) + + return Voice( + type=attachment_type, + filename=async_file.file_name, + size=async_file.file_size, + duration=async_file.duration, + is_async_file=True, + _file_id=async_file.file_id, + _file_mimetype=async_file.file_mime_type, + _file_url=async_file.file, + _file_hash=async_file.file_hash, + ) + + raise NotImplementedError(f"Unsupported attachment type: {attachment_type}") diff --git a/botx/models/attachments.py b/botx/models/attachments.py index 1a942f6a..3a3d3ab8 100644 --- a/botx/models/attachments.py +++ b/botx/models/attachments.py @@ -1,370 +1,438 @@ -"""Module with attachments for botx.""" +import base64 +from contextlib import asynccontextmanager +from dataclasses import dataclass +from types import MappingProxyType +from typing import AsyncGenerator, Literal, Union, cast -from typing import List, Optional, Union, cast +from aiofiles.tempfile import SpooledTemporaryFile -from pydantic import Field +from botx.async_buffer import AsyncBufferReadable +from botx.constants import CHUNK_SIZE +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.enums import ( + APIAttachmentTypes, + AttachmentTypes, + convert_attachment_type_to_domain, +) -from botx.models.base import BotXBaseModel -from botx.models.enums import AttachmentsTypes, LinkProtos -from botx.models.files import File -try: - from typing import Literal # noqa: WPS433 -except ImportError: - from typing_extensions import Literal # type: ignore # noqa: WPS433, WPS440, F401 +@dataclass +class FileAttachmentBase: + type: AttachmentTypes + filename: str + size: int + is_async_file: Literal[False] -class FileAttachment(BotXBaseModel): - """Class that represents file in RFC 2397 format.""" + content: bytes - #: name of file. - file_name: Optional[str] - - #: file content in RFC 2397 format. - content: str + @asynccontextmanager + async def open(self) -> AsyncGenerator[SpooledTemporaryFile, None]: + async with SpooledTemporaryFile(max_size=CHUNK_SIZE) as tmp_file: + await tmp_file.write(self.content) + await tmp_file.seek(0) + yield tmp_file -class Image(FileAttachment): - """Image model from botx.""" - file_name: str = "image.jpg" +@dataclass +class AttachmentImage(FileAttachmentBase): + type: Literal[AttachmentTypes.IMAGE] -class Video(FileAttachment): - """Video model from botx.""" +@dataclass +class AttachmentVideo(FileAttachmentBase): + type: Literal[AttachmentTypes.VIDEO] - file_name: str = "video.mp4" - - #: video duration duration: int -class Document(FileAttachment): - """Document model from botx.""" - - file_name: str = "document.docx" +@dataclass +class AttachmentDocument(FileAttachmentBase): + type: Literal[AttachmentTypes.DOCUMENT] -class Voice(BotXBaseModel): - """Voice model from botx.""" +@dataclass +class AttachmentVoice(FileAttachmentBase): + type: Literal[AttachmentTypes.VOICE] - #: file content in RFC 2397 format. - content: str - - #: voice duration duration: int -class Location(BotXBaseModel): - """Location model from botx.""" - - #: name of location - location_name: str - - #: address of location - location_address: str +@dataclass +class AttachmentLocation: + type: Literal[AttachmentTypes.LOCATION] - #: latitude of location - location_lat: float + name: str + address: str + latitude: str + longitude: str - #: longitude of location - location_lng: float +@dataclass +class AttachmentContact: + type: Literal[AttachmentTypes.CONTACT] -class Contact(BotXBaseModel): - """Contact model from botx.""" + name: str - #: name of contact - contact_name: str +@dataclass +class AttachmentLink: + type: Literal[AttachmentTypes.LINK] -class Link(BotXBaseModel): - """Class that marked as Link from botx.""" - - #: url of link url: str + title: str + preview: str + text: str - #: title of url - url_title: Optional[str] = None - - #: link on preview of this link - url_preview: Optional[str] = None - - #: text on preview - url_text: Optional[str] = None - def is_mail(self) -> bool: - """Confirm that is email link.""" - return self.url.startswith(LinkProtos.email) - - def is_telephone(self) -> bool: - """Confirm that is telephone link.""" - return self.url.startswith(LinkProtos.telephone) - - def is_link(self) -> bool: - """Confirm that is link on resource.""" - return not (self.is_mail() or self.is_telephone()) +IncomingFileAttachment = Union[ + AttachmentImage, + AttachmentVideo, + AttachmentDocument, + AttachmentVoice, +] - @property - def mailto(self) -> str: - """Property that retuning email address without protocol.""" - if not self.is_mail(): - raise AttributeError("mailto") - return self.url[len(LinkProtos.email) :] # noqa: E203 - @property - def tel(self) -> str: - """Property that retuning telephone number without protocol.""" - if not self.is_telephone(): - raise AttributeError("telephone number") - return self.url[len(LinkProtos.telephone) :] # noqa: E203 +@dataclass +class OutgoingAttachment: + content: bytes + filename: str + is_async_file: Literal[False] = False + @classmethod + async def from_async_buffer( + cls, + async_buffer: AsyncBufferReadable, + filename: str, + ) -> "OutgoingAttachment": + return cls( + content=await async_buffer.read(), + filename=filename, + ) -Attachments = Union[Image, Video, Document, Voice, Location, Contact, Link] +class BotAPIAttachmentImageData(VerifiedPayloadBaseModel): + content: str + file_name: str -class ImageAttachment(BotXBaseModel): - """BotX API image attachment container.""" - #: type of attachment - type: Literal[AttachmentsTypes.image] = Field(default=AttachmentsTypes.image) +class BotAPIAttachmentImage(VerifiedPayloadBaseModel): + type: Literal[APIAttachmentTypes.IMAGE] + data: BotAPIAttachmentImageData - #: content of attachment - data: Image +class BotAPIAttachmentVideoData(VerifiedPayloadBaseModel): + content: str + file_name: str + duration: int -class VideoAttachment(BotXBaseModel): - """BotX API video attachment container.""" - #: type of attachment - type: Literal[AttachmentsTypes.video] = Field(default=AttachmentsTypes.video) +class BotAPIAttachmentVideo(VerifiedPayloadBaseModel): + type: Literal[APIAttachmentTypes.VIDEO] + data: BotAPIAttachmentVideoData - #: content of attachment - data: Video +class BotAPIAttachmentDocumentData(VerifiedPayloadBaseModel): + content: str + file_name: str -class DocumentAttachment(BotXBaseModel): - """BotX API document attachment container.""" - #: type of attachment - type: Literal[AttachmentsTypes.document] = Field(default=AttachmentsTypes.document) +class BotAPIAttachmentDocument(VerifiedPayloadBaseModel): + type: Literal[APIAttachmentTypes.DOCUMENT] + data: BotAPIAttachmentDocumentData - #: content of attachment - data: Document +class BotAPIAttachmentVoiceData(VerifiedPayloadBaseModel): + content: str + duration: int -class VoiceAttachment(BotXBaseModel): - """BotX API voice attachment container.""" - #: type of attachment - type: Literal[AttachmentsTypes.voice] = Field(default=AttachmentsTypes.voice) +class BotAPIAttachmentVoice(VerifiedPayloadBaseModel): + type: Literal[APIAttachmentTypes.VOICE] + data: BotAPIAttachmentVoiceData - #: content of attachment - data: Voice +class BotAPIAttachmentLocationData(VerifiedPayloadBaseModel): + location_name: str + location_address: str + location_lat: str + location_lng: str -class ContactAttachment(BotXBaseModel): - """BotX API contact attachment container.""" - #: type of attachment - type: Literal[AttachmentsTypes.contact] = Field(default=AttachmentsTypes.contact) +class BotAPIAttachmentLocation(VerifiedPayloadBaseModel): + type: Literal[APIAttachmentTypes.LOCATION] + data: BotAPIAttachmentLocationData - #: content of attachment - data: Contact +class BotAPIAttachmentContactData(VerifiedPayloadBaseModel): + contact_name: str -class LocationAttachment(BotXBaseModel): - """BotX API location attachment container.""" - #: type of attachment - type: Literal[AttachmentsTypes.location] = Field(default=AttachmentsTypes.location) +class BotAPIAttachmentContact(VerifiedPayloadBaseModel): + type: Literal[APIAttachmentTypes.CONTACT] + data: BotAPIAttachmentContactData - #: content of attachment - data: Location +class BotAPIAttachmentLinkData(VerifiedPayloadBaseModel): + url: str + url_title: str + url_preview: str + url_text: str -class LinkAttachment(BotXBaseModel): - """BotX API link attachment container.""" - #: type of attachment - type: Literal[AttachmentsTypes.link] = Field(default=AttachmentsTypes.link) +class BotAPIAttachmentLink(VerifiedPayloadBaseModel): + type: Literal[APIAttachmentTypes.LINK] + data: BotAPIAttachmentLinkData - #: content of attachment - data: Link +BotAPIAttachment = Union[ + BotAPIAttachmentVideo, + BotAPIAttachmentImage, + BotAPIAttachmentDocument, + BotAPIAttachmentVoice, + BotAPIAttachmentLocation, + BotAPIAttachmentContact, + BotAPIAttachmentLink, +] -Attachment = Union[ - ImageAttachment, - VideoAttachment, - DocumentAttachment, - VoiceAttachment, - ContactAttachment, - LocationAttachment, - LinkAttachment, +IncomingAttachment = Union[ + IncomingFileAttachment, + AttachmentLocation, + AttachmentContact, + AttachmentLink, ] -class AttachList(BotXBaseModel): # noqa: WPS214, WPS338 - """Additional wrapped class for use property.""" - - __root__: List[Attachment] - - def _get_attach_by_type(self, attach_type: AttachmentsTypes) -> Attachments: - for attach in self.all_attachments: - if attach.type == attach_type: - return attach.data - raise AttributeError(attach_type) - - @property - def image(self) -> Image: - """Parse attachments. - - Returns: - Image: image from attachments. - Raises: - AttributeError: message has no image. - """ - attach = self._get_attach_by_type(AttachmentsTypes.image) - return cast(Image, attach) - - @property - def document(self) -> Document: - """Parse attachments. - - Returns: - Document: document from attachments. - Raises: - AttributeError: message has no document. - """ - attach = self._get_attach_by_type(AttachmentsTypes.document) - return cast(Document, attach) - - @property - def location(self) -> Location: - """Parse attachments. - - Returns: - Location: location from attachments. - Raises: - AttributeError: message has no location. - """ - attach = self._get_attach_by_type(AttachmentsTypes.location) - return cast(Location, attach) - - @property - def contact(self) -> Contact: - """Parse attachments. - - Returns: - Contact: contact from attachments. - Raises: - AttributeError: message has no contact. - """ - attach = self._get_attach_by_type(AttachmentsTypes.contact) - return cast(Contact, attach) - - @property - def voice(self) -> Voice: - """Parse attachments. - - Returns: - Voice: voice from attachments - Raises: - AttributeError: message has no voice. - """ - attach = self._get_attach_by_type(AttachmentsTypes.voice) - return cast(Voice, attach) - - @property - def video(self) -> Video: - """Parse attachments. - - Returns: - Video: video from attachments. - Raises: - AttributeError: message has no video. - """ - attach = self._get_attach_by_type(AttachmentsTypes.video) - return cast(Video, attach) - - @property - def link(self) -> Link: - """Parse attachments. - - Returns: - Link: lint to resource from attachments. - Raises: - AttributeError: message has no link. - """ - attach = self._get_attach_by_type(AttachmentsTypes.link) - if attach.is_link(): # type: ignore - return cast(Link, attach) - raise AttributeError(AttachmentsTypes.link) - - @property - def email(self) -> str: - """Parse attachments. - - Returns: - str: email from attachments. - Raises: - AttributeError: message has no email. - """ - attach = self._get_attach_by_type(AttachmentsTypes.link) - if attach.is_mail(): # type: ignore - return attach.mailto # type: ignore - raise AttributeError(AttachmentsTypes.link) - - @property - def telephone(self) -> str: - """Parse attachments. - - Returns: - str: telephone number from attachments. - Raises: - AttributeError: message has no telephone. - """ - attach = self._get_attach_by_type(AttachmentsTypes.link) - if attach.is_telephone(): # type: ignore - return attach.tel # type: ignore - raise AttributeError(AttachmentsTypes.link) - - @property - def all_attachments(self) -> List[Attachment]: - """Search attachments in message. - - Returns: - List of attachments. - """ - return self.__root__ - - @property - def file(self) -> File: - """Search file in message's attachments. - - Returns: - Botx file from video, image or document. - Raises: - AttributeError: message has no file. - """ - for attachment in self.all_attachments: - if isinstance(attachment.data, FileAttachment): - return File.construct( - file_name=attachment.data.file_name, - data=attachment.data.content, - ) - raise AttributeError - - @property - def attach_type(self) -> AttachmentsTypes: - """Get attachment type. - - Returns: - AttachmentsTypes: Attachment type. - Raises: - AttributeError: message has no attachment. - """ - if self.all_attachments: - return self.all_attachments[0].type - - raise AttributeError +def convert_api_attachment_to_domain( # noqa: WPS212 + api_attachment: BotAPIAttachment, +) -> IncomingAttachment: + attachment_type = convert_attachment_type_to_domain(api_attachment.type) + + if attachment_type == AttachmentTypes.IMAGE: + attachment_type = cast(Literal[AttachmentTypes.IMAGE], attachment_type) + api_attachment = cast(BotAPIAttachmentImage, api_attachment) + content = decode_rfc2397(api_attachment.data.content) + + return AttachmentImage( + type=attachment_type, + filename=api_attachment.data.file_name, + size=len(content), + is_async_file=False, + content=content, + ) + + if attachment_type == AttachmentTypes.VIDEO: + attachment_type = cast(Literal[AttachmentTypes.VIDEO], attachment_type) + api_attachment = cast(BotAPIAttachmentVideo, api_attachment) + content = decode_rfc2397(api_attachment.data.content) + + return AttachmentVideo( + type=attachment_type, + filename=api_attachment.data.file_name, + size=len(content), + is_async_file=False, + content=content, + duration=api_attachment.data.duration, + ) + + if attachment_type == AttachmentTypes.DOCUMENT: + attachment_type = cast(Literal[AttachmentTypes.DOCUMENT], attachment_type) + api_attachment = cast(BotAPIAttachmentDocument, api_attachment) + content = decode_rfc2397(api_attachment.data.content) + + return AttachmentDocument( + type=attachment_type, + filename=api_attachment.data.file_name, + size=len(content), + is_async_file=False, + content=content, + ) + + if attachment_type == AttachmentTypes.VOICE: + attachment_type = cast(Literal[AttachmentTypes.VOICE], attachment_type) + api_attachment = cast(BotAPIAttachmentVoice, api_attachment) + content = decode_rfc2397(api_attachment.data.content) + + return AttachmentVoice( + type=attachment_type, + filename="record.mp3", + size=len(content), + is_async_file=False, + content=content, + duration=api_attachment.data.duration, + ) + + if attachment_type == AttachmentTypes.LOCATION: + attachment_type = cast(Literal[AttachmentTypes.LOCATION], attachment_type) + api_attachment = cast(BotAPIAttachmentLocation, api_attachment) + + return AttachmentLocation( + type=attachment_type, + name=api_attachment.data.location_name, + address=api_attachment.data.location_address, + latitude=api_attachment.data.location_lat, + longitude=api_attachment.data.location_lng, + ) + + if attachment_type == AttachmentTypes.CONTACT: + attachment_type = cast(Literal[AttachmentTypes.CONTACT], attachment_type) + api_attachment = cast(BotAPIAttachmentContact, api_attachment) + + return AttachmentContact( + type=attachment_type, + name=api_attachment.data.contact_name, + ) + + if attachment_type == AttachmentTypes.LINK: + attachment_type = cast(Literal[AttachmentTypes.LINK], attachment_type) + api_attachment = cast(BotAPIAttachmentLink, api_attachment) + + return AttachmentLink( + type=attachment_type, + url=api_attachment.data.url, + title=api_attachment.data.url_title, + preview=api_attachment.data.url_preview, + text=api_attachment.data.url_text, + ) + + raise NotImplementedError(f"Unsupported attachment type: {attachment_type}") + + +def decode_rfc2397(encoded_content: str) -> bytes: + # "data:image/gif;base64,aGVsbG8=" -> b"hello" + return base64.b64decode(encoded_content.split(",", 1)[1].encode()) + + +EXTENSIONS_TO_MIMETYPES = MappingProxyType( + { + # application + "7z": "application/x-7z-compressed", + "abw": "application/x-abiword", + "ai": "application/postscript", + "arc": "application/x-freearc", + "azw": "application/vnd.amazon.ebook", + "bin": "application/octet-stream", + "bz": "application/x-bzip", + "bz2": "application/x-bzip2", + "cda": "application/x-cdf", + "csh": "application/x-csh", + "doc": "application/msword", + "docx": ( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ), + "eot": "application/vnd.ms-fontobject", + "eps": "application/postscript", + "epub": "application/epub+zip", + "gz": "application/gzip", + "jar": "application/java-archive", + "json-api": "application/vnd.api+json", + "json-patch": "application/json-patch+json", + "json": "application/json", + "jsonld": "application/ld+json", + "mdb": "application/x-msaccess", + "mpkg": "application/vnd.apple.installer+xml", + "odp": "application/vnd.oasis.opendocument.presentation", + "ods": "application/vnd.oasis.opendocument.spreadsheet", + "odt": "application/vnd.oasis.opendocument.text", + "ogx": "application/ogg", + "pdf": "application/pdf", + "php": "application/x-httpd-php", + "ppt": "application/vnd.ms-powerpoint", + "pptx": ( + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ), + "ps": "application/postscript", + "rar": "application/vnd.rar", + "rtf": "application/rtf", + "sh": "application/x-sh", + "swf": "application/x-shockwave-flash", + "tar": "application/x-tar", + "vsd": "application/vnd.visio", + "wasm": "application/wasm", + "webmanifest": "application/manifest+json", + "xhtml": "application/xhtml+xml", + "xls": "application/vnd.ms-excel", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "xul": "application/vnd.mozilla.xul+xml", + "zip": "application/zip", + # audio + "aac": "audio/aac", + "mid": "audio/midi", + "midi": "audio/midi", + "mp3": "audio/mpeg", + "oga": "audio/ogg", + "opus": "audio/opus", + "wav": "audio/wav", + "weba": "audio/webm", + # font + "otf": "font/otf", + "ttf": "font/ttf", + "woff": "font/woff", + "woff2": "font/woff2", + # image + "avif": "image/avif", + "bmp": "image/bmp", + "gif": "image/gif", + "ico": "image/vnd.microsoft.icon", + "jpeg": "image/jpeg", + "jpg": "image/jpeg", + "png": "image/png", + "svg": "image/svg+xml", + "svgz": "image/svg+xml", + "tif": "image/tiff", + "tiff": "image/tiff", + "webp": "image/webp", + # text + "css": "text/css", + "csv": "text/csv", + "htm": "text/html", + "html": "text/html", + "ics": "text/calendar", + "js": "text/javascript", + "mjs": "text/javascript", + "txt": "text/plain", + "text": "text/plain", + "xml": "text/xml", + # video + "3g2": "video/3gpp2", + "3gp": "video/3gpp", + "avi": "video/x-msvideo", + "mov": "video/quicktime", + "mp4": "video/mp4", + "mpeg": "video/mpeg", + "mpg": "video/mpeg", + "ogv": "video/ogg", + "ts": "video/mp2t", + "webm": "video/webm", + "wmv": "video/x-ms-wmv", + }, +) +DEFAULT_MIMETYPE = "application/octet-stream" + + +def encode_rfc2397(content: bytes, mimetype: str) -> str: + b64_content = base64.b64encode(content).decode() + return f"data:{mimetype};base64,{b64_content}" + + +class BotXAPIAttachment(UnverifiedPayloadBaseModel): + file_name: str + data: str + + @classmethod + def from_file_attachment( + cls, + attachment: Union[IncomingFileAttachment, OutgoingAttachment], + ) -> "BotXAPIAttachment": + assert attachment.content is not None + + mimetype = EXTENSIONS_TO_MIMETYPES.get( + attachment.filename.split(".")[-1], + DEFAULT_MIMETYPE, + ) + + return cls( + file_name=attachment.filename, + data=encode_rfc2397(attachment.content, mimetype), + ) diff --git a/botx/models/attachments_meta.py b/botx/models/attachments_meta.py deleted file mode 100644 index 7c2469b9..00000000 --- a/botx/models/attachments_meta.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Module with attachments meta for botx.""" - -from typing import Optional, Union - -from pydantic import Field - -from botx.models.base import BotXBaseModel -from botx.models.enums import AttachmentsTypes - -try: - from typing import Literal # noqa: WPS433 -except ImportError: - from typing_extensions import Literal # type: ignore # noqa: WPS433, WPS440, F401 - - -class FileAttachmentMeta(BotXBaseModel): - """Common metadata of file.""" - - #: type of attachment - type: str - - #: name of file. - file_name: str - - #: mime type of file - file_mime_type: Optional[str] - - #: file preview in RFC 2397 format. - file_preview_base64: Optional[str] - - -class ImageAttachmentMeta(FileAttachmentMeta): - """BotX API image attachment meta container.""" - - #: type of attachment - type: Literal[AttachmentsTypes.image] = Field(default=AttachmentsTypes.image) - - -class VideoAttachmentMeta(FileAttachmentMeta): - """BotX API video attachment meta container.""" - - #: type of attachment - type: Literal[AttachmentsTypes.video] = Field(default=AttachmentsTypes.video) - - -class DocumentAttachmentMeta(FileAttachmentMeta): - """BotX API document attachment meta container.""" - - #: type of attachment - type: Literal[AttachmentsTypes.document] = Field(default=AttachmentsTypes.document) - - -class VoiceAttachmentMeta(FileAttachmentMeta): - """BotX API voice attachment meta container.""" - - #: type of attachment - type: Literal[AttachmentsTypes.voice] = Field(default=AttachmentsTypes.voice) - - -class ContactAttachmentMeta(BotXBaseModel): - """BotX API contact attachment meta container.""" - - #: type of attachment - type: Literal[AttachmentsTypes.contact] = Field(default=AttachmentsTypes.contact) - - #: name of contact - contact_name: str - - -class LocationAttachmentMeta(BotXBaseModel): - """BotX API location attachment meta container.""" - - #: type of attachment - type: Literal[AttachmentsTypes.location] = Field(default=AttachmentsTypes.location) - - #: address of location - location_address: str - - -AttachmentMeta = Union[ - ImageAttachmentMeta, - VideoAttachmentMeta, - DocumentAttachmentMeta, - VoiceAttachmentMeta, - ContactAttachmentMeta, - LocationAttachmentMeta, -] diff --git a/botx/models/base.py b/botx/models/base.py deleted file mode 100644 index 95c23d9a..00000000 --- a/botx/models/base.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Module with base model classes.""" -from pydantic import BaseModel - - -class BotXBaseModel(BaseModel): - """Base class for configure all models.""" - - class Config: - use_enum_values = True diff --git a/botx/models/base_command.py b/botx/models/base_command.py new file mode 100644 index 00000000..8e7963ce --- /dev/null +++ b/botx/models/base_command.py @@ -0,0 +1,73 @@ +from dataclasses import dataclass +from typing import Any, Dict, Literal, Optional +from uuid import UUID + +from pydantic import validator + +from botx.bot.api.exceptions import UnsupportedBotAPIVersionError +from botx.constants import BOT_API_VERSION +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.bot_account import BotAccount +from botx.models.enums import APIChatTypes, BotAPIClientPlatforms, BotAPICommandTypes + + +class BotAPICommandPayload(VerifiedPayloadBaseModel): + body: str + command_type: Literal[BotAPICommandTypes.USER] + data: Dict[str, Any] + metadata: Dict[str, Any] + + +class BotAPIDeviceMeta(VerifiedPayloadBaseModel): + pushes: Optional[bool] + timezone: Optional[str] + permissions: Optional[Dict[str, Any]] + + +class BaseBotAPIContext(VerifiedPayloadBaseModel): + host: str + + +class BotAPIUserContext(BaseBotAPIContext): + user_huid: UUID + ad_domain: Optional[str] + ad_login: Optional[str] + username: Optional[str] + is_admin: Optional[bool] + is_creator: Optional[bool] + + +class BotAPIChatContext(BaseBotAPIContext): + group_chat_id: UUID + chat_type: APIChatTypes + + +class BotAPIDeviceContext(BaseBotAPIContext): + app_version: Optional[str] + platform: Optional[BotAPIClientPlatforms] + platform_package_id: Optional[str] + device: Optional[str] + device_meta: Optional[BotAPIDeviceMeta] + device_software: Optional[str] + manufacturer: Optional[str] + locale: Optional[str] + + +class BotAPIBaseCommand(VerifiedPayloadBaseModel): + bot_id: UUID + sync_id: UUID + proto_version: int + + @validator("proto_version", pre=True) + @classmethod + def validate_proto_version(cls, version: Any) -> int: + if isinstance(version, int) and version == BOT_API_VERSION: + return version + + raise UnsupportedBotAPIVersionError(version) + + +@dataclass +class BotCommandBase: + bot: BotAccount + raw_command: Optional[Dict[str, Any]] diff --git a/botx/models/bot_account.py b/botx/models/bot_account.py new file mode 100644 index 00000000..5c201ea7 --- /dev/null +++ b/botx/models/bot_account.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from uuid import UUID + + +@dataclass +class BotAccount: + id: UUID + host: str + + +@dataclass +class BotAccountWithSecret(BotAccount): + secret_key: str diff --git a/botx/models/bot_sender.py b/botx/models/bot_sender.py new file mode 100644 index 00000000..5709c7ad --- /dev/null +++ b/botx/models/bot_sender.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from typing import Optional +from uuid import UUID + + +@dataclass +class BotSender: + huid: UUID + is_chat_admin: Optional[bool] + is_chat_creator: Optional[bool] diff --git a/botx/models/buttons.py b/botx/models/buttons.py deleted file mode 100644 index 8ba2137a..00000000 --- a/botx/models/buttons.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Pydantic models for bubbles and keyboard buttons.""" - -from typing import Optional - -from pydantic import validator - -from botx.models.base import BotXBaseModel -from botx.models.enums import ButtonHandlerTypes - - -class ButtonOptions(BotXBaseModel): - """Extra options for buttons, like disabling output by tap.""" - - #: if True then text won't shown for user in messenger. - silent: bool = True - - #: button width weight (the more weight, the more occupied space). - h_size: int = 1 - - #: show toast with `alert_text` when user press the button - show_alert: bool = False - - #: text to be shown in toast (show command body if `alert_text` is `None`). - alert_text: Optional[str] = None - - #: platform, that handle command from markup. If `bot` - command should be send - # to bot, else(`client`) should be executed by client. - handler: ButtonHandlerTypes = ButtonHandlerTypes.bot - - @validator("h_size") - def h_size_should_be_positive(cls, h_size: int) -> int: # noqa: N805 - """Validate that `h_size` is positive integer. - - Arguments: - h_size: width weight for validation. - - Returns: - Validated `h_size`. - - Raises: - ValueError: if `h_size` is not valid. - """ - if h_size < 1: - raise ValueError("h_size should be positive integer") - - return h_size - - -class Button(BotXBaseModel): - """Base class for ui element like bubble or keyboard button.""" - - #: command that will be triggered by click on the element. - command: str - - #: text that will be shown on the element. - label: Optional[str] = None - - #: extra payload that will be stored in button and then received in new message. - data: dict = {} # noqa: WPS110 - - #: options for button. - opts: ButtonOptions = ButtonOptions() - - @validator("label", always=True) - def label_as_command_if_none( - cls, - label: Optional[str], - values: dict, # noqa: N805, WPS110 - ) -> str: - """Return command as label if it is `None`. - - Arguments: - label: value that should be checked. - values: all other validated_values checked before. - - Returns: - Label for button. - """ - if label is None: - return values["command"] - - return label - - -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/chats.py b/botx/models/chats.py index 7586808d..9c12c0eb 100644 --- a/botx/models/chats.py +++ b/botx/models/chats.py @@ -1,75 +1,74 @@ -"""Entities for chats.""" - +from dataclasses import dataclass from datetime import datetime -from typing import Iterator, List, Optional +from datetime import datetime as dt +from typing import List, Optional from uuid import UUID -from botx.models.base import BotXBaseModel -from botx.models.enums import ChatTypes -from botx.models.users import UserFromChatSearch - - -class ChatFromSearch(BotXBaseModel): - """Chat from search request.""" +from botx.models.enums import ChatTypes, UserKinds - #: name of chat. - name: str - #: description of chat - description: Optional[str] +@dataclass +class Chat: + id: UUID + type: ChatTypes - #: type of chat. - chat_type: ChatTypes - #: HUID of chat creator. - creator: UUID +@dataclass +class ChatListItem: + """Chat from list. - #: ID of chat. - group_chat_id: UUID + Attributes: + chat_id: Chat id. + chat_type: Chat Type. + name: Chat name. + description: Chat description. + members: Chat members. + created_at: Chat creation datetime. + updated_at: Last chat update datetime. + """ - #: users in chat. - members: List[UserFromChatSearch] - - #: creation datetime of chat. - inserted_at: datetime - - -class BotChatFromList(BotXBaseModel): - """Chat from list.""" - - #: name of chat. + chat_id: UUID + chat_type: ChatTypes name: str - - #: description of chat. description: Optional[str] - - #: type of chat. - chat_type: ChatTypes - - #: ID of chat. - group_chat_id: UUID - - #: users in chat. members: List[UUID] + created_at: datetime + updated_at: datetime - #: datetime bot joined in chat. - inserted_at: datetime - #: update datetime of chat. - updated_at: datetime +@dataclass +class ChatInfoMember: + """Chat member. + Attributes: + is_admin: Is user admin. + huid: User huid. + kind: User type. + """ -class BotChatList(BotXBaseModel): - """Bot's chat list response model.""" + is_admin: bool + huid: UUID + kind: UserKinds - __root__: List[BotChatFromList] - def __iter__(self) -> Iterator[BotChatFromList]: # type: ignore - """Override iterator for pydantic model.""" - return iter(self.__root__) +@dataclass +class ChatInfo: + """Chat information. - def __len__(self) -> int: # noqa: D105 - return len(self.__root__) + Attributes: + chat_type: Chat type. + creator_id: Chat creator id. + description: Chat description. + chat_id: Chat id. + created_at: Chat creation datetime. + members: Chat members. + name: Chat name. + """ - def __getitem__(self, key: int) -> BotChatFromList: # noqa: D105 - return self.__root__[key] + chat_type: ChatTypes + creator_id: UUID + description: Optional[str] + chat_id: UUID + created_at: dt + members: List[ChatInfoMember] + name: str diff --git a/botx/models/commands.py b/botx/models/commands.py new file mode 100644 index 00000000..acd6e64d --- /dev/null +++ b/botx/models/commands.py @@ -0,0 +1,44 @@ +from typing import Union + +from botx.models.message.incoming_message import BotAPIIncomingMessage, IncomingMessage +from botx.models.system_events.added_to_chat import AddedToChatEvent, BotAPIAddedToChat +from botx.models.system_events.chat_created import BotAPIChatCreated, ChatCreatedEvent +from botx.models.system_events.cts_login import BotAPICTSLogin, CTSLoginEvent +from botx.models.system_events.cts_logout import BotAPICTSLogout, CTSLogoutEvent +from botx.models.system_events.deleted_from_chat import ( + BotAPIDeletedFromChat, + DeletedFromChatEvent, +) +from botx.models.system_events.internal_bot_notification import ( + BotAPIInternalBotNotification, + InternalBotNotificationEvent, +) +from botx.models.system_events.left_from_chat import ( + BotAPILeftFromChat, + LeftFromChatEvent, +) +from botx.models.system_events.smartapp_event import BotAPISmartAppEvent, SmartAppEvent + +BotAPISystemEvent = Union[ + BotAPIChatCreated, + BotAPIAddedToChat, + BotAPIDeletedFromChat, + BotAPILeftFromChat, + BotAPICTSLogin, + BotAPICTSLogout, + BotAPIInternalBotNotification, + BotAPISmartAppEvent, +] +BotAPICommand = Union[BotAPIIncomingMessage, BotAPISystemEvent] + +SystemEvent = Union[ + ChatCreatedEvent, + AddedToChatEvent, + DeletedFromChatEvent, + LeftFromChatEvent, + CTSLoginEvent, + CTSLogoutEvent, + InternalBotNotificationEvent, + SmartAppEvent, +] +BotCommand = Union[IncomingMessage, SystemEvent] diff --git a/botx/models/constants.py b/botx/models/constants.py deleted file mode 100644 index 01451fcd..00000000 --- a/botx/models/constants.py +++ /dev/null @@ -1,3 +0,0 @@ -"""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 deleted file mode 100644 index 35ea7edd..00000000 --- a/botx/models/credentials.py +++ /dev/null @@ -1,40 +0,0 @@ -"""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 botx.models.base import BotXBaseModel - - -class BotXCredentials(BotXBaseModel): - """Credentials to bot account in express.""" - - #: host name of server. - host: str - - #: secret that will be used for generating signature for bot. - secret_key: str - - #: bot that retrieved token from API. - bot_id: UUID - - #: token generated for bot. - token: Optional[str] = None - - @property - def signature(self) -> str: - """Calculate signature for obtaining token for bot from BotX API. - - Returns: - Calculated signature. - """ - return base64.b16encode( - hmac.new( - key=self.secret_key.encode(), - msg=str(self.bot_id).encode(), - digestmod=hashlib.sha256, - ).digest(), - ).decode() diff --git a/botx/models/datastructures.py b/botx/models/datastructures.py deleted file mode 100644 index de69c87b..00000000 --- a/botx/models/datastructures.py +++ /dev/null @@ -1,44 +0,0 @@ -"""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 query_params. - - Arguments: - state: initial state. - """ - state = state or {} - super().__setattr__("_state", state) # noqa: WPS613 - - def __setattr__(self, key: Any, new_value: Any) -> None: - """Set state attribute. - - Arguments: - key: key to set attribute. - new_value: value of attribute. - """ - self._state[key] = new_value - - def __getattr__(self, key: Any) -> Any: - """Get state attribute. - - Arguments: - key: key of retrieved attribute. - - Returns: - Stored value. - - Raises: - AttributeError: raised if attribute was not found in state. - """ - try: - return self._state[key] - except KeyError: - raise AttributeError("state has no attribute '{0}'".format(key)) diff --git a/botx/models/entities.py b/botx/models/entities.py deleted file mode 100644 index 232ae68b..00000000 --- a/botx/models/entities.py +++ /dev/null @@ -1,294 +0,0 @@ -"""Entities that can be received in message.""" - -from datetime import datetime -from typing import Dict, List, Optional, Union, cast -from uuid import UUID, uuid4 - -from pydantic import Field, validator - -from botx.models.attachments_meta import AttachmentMeta -from botx.models.base import BotXBaseModel -from botx.models.enums import ChatTypes, EntityTypes, MentionTypes - - -class Forward(BotXBaseModel): - """Forward in message.""" - - #: ID of chat from which forward received. - group_chat_id: UUID - - #: ID of user that is author of message. - sender_huid: UUID - - #: type of forward. - forward_type: ChatTypes - - #: name of original chat. - source_chat_name: Optional[str] = None - - #: id of original message event. - source_sync_id: UUID - - #: id of event creation. - source_inserted_at: datetime - - -class UserMention(BotXBaseModel): - """Mention for single user in chat or by `user_huid`.""" - - #: huid of user that will be mentioned. - user_huid: UUID - - #: name that will be used instead of default user name. - name: Optional[str] = None - - #: connection type via that entity was mention - conn_type: Optional[str] = None - - -class ChatMention(BotXBaseModel): - """Mention chat in message by `group_chat_id`.""" - - #: id of chat that will be mentioned. - group_chat_id: UUID - - #: name that will be used instead of default chat name. - name: Optional[str] = None - - -class Mention(BotXBaseModel): - """Mention that is used in bot in messages.""" - - #: unique id of mention. - mention_id: Optional[UUID] = None - - #: information about mention object - mention_data: Optional[Union[ChatMention, UserMention, Dict]] - - #: payload with data about mention. - mention_type: MentionTypes = MentionTypes.user - - @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_data", pre=True, always=True) - def ignore_empty_data( - cls, - mention_data: Union[ChatMention, UserMention, Dict], # noqa: N805 - ) -> Optional[Union[ChatMention, UserMention, Dict]]: - """Pass empty dict into mention_data as None. - - Arguments: - mention_data: dict of mention's data. - - Returns: - Mention's data if is not empty or None. - """ - if mention_data == {}: # noqa: WPS520 - return None - - return mention_data - - @validator("mention_type", pre=True, always=True) - def check_that_type_matches_data( # noqa: WPS231, WPS210 - cls, - mention_type: MentionTypes, - values: dict, # noqa: N805, WPS110 - ) -> 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. - - Raises: - ValueError: raised if mention_type does not corresponds with data. - """ - mention_data = values.get("mention_data") - if (mention_type != MentionTypes.all_members) and (mention_data is None): - raise ValueError("no `mention_data`, perhaps this entity isn't a mention") - - user_mention_types = {MentionTypes.user, MentionTypes.contact} - chat_mention_types = {MentionTypes.chat, MentionTypes.channel} - - is_user_mention_signature = isinstance(mention_data, UserMention) and ( - mention_type in user_mention_types - ) - is_chat_mention_signature = isinstance(mention_data, ChatMention) and ( - mention_type in chat_mention_types - ) - is_mention_all_signature = mention_type == MentionTypes.all_members - - if not any( # noqa: WPS337 - { - is_chat_mention_signature, - is_mention_all_signature, - is_user_mention_signature, - }, - ): - raise ValueError("No one suitable type for this mention_data signature") - - return mention_type - - @classmethod - def build_from_values( - cls, - mention_type: MentionTypes, - mentioned_entity_id: UUID, - name: Optional[str] = None, - mention_id: Optional[UUID] = None, - ) -> "Mention": - """Build mention. - - Simpler to use than constructor 'cause of flat values. - - Arguments: - mention_type: mention type. - mentioned_entity_id: id of mentioned entity (user, chat, etc.). - name: for overriding mention name. - mention_id: mention id (if not passed, will be generated). - - Raises: - NotImplementedError: If unsupported mention type was passed. - - Returns: - Built mention. - """ - mention_data: Union[UserMention, ChatMention] - - if mention_type in {MentionTypes.user, MentionTypes.contact}: - mention_data = UserMention(user_huid=mentioned_entity_id, name=name) - elif mention_type in {MentionTypes.chat, MentionTypes.channel}: - mention_data = ChatMention(group_chat_id=mentioned_entity_id, name=name) - else: - raise NotImplementedError("Unsupported mention type") - - return cls( - mention_id=mention_id, - mention_data=mention_data, - mention_type=mention_type, - ) - - def to_botx_format(self) -> str: - """Format mention to format, which can be parse by botx. - - Raises: - NotImplementedError: If unsupported mention type was passed. - - Returns: - Formatted mention. - """ - formatted_mention_data = "{{mention:{0}}}".format(self.mention_id) - - if self.mention_type == MentionTypes.user: - prefix = "@" - elif self.mention_type == MentionTypes.contact: - prefix = "@@" - elif self.mention_type in {MentionTypes.chat, MentionTypes.channel}: - prefix = "##" - else: - raise NotImplementedError("Unsupported mention type") - - return "{0}{1}".format(prefix, formatted_mention_data) - - -class Reply(BotXBaseModel): - """Message that was replied.""" - - #: attachment metadata. - attachment_meta: Optional[AttachmentMeta] = Field(alias="attachment") - - #: text of source message. - body: Optional[str] - - #: mentions of source message. - mentions: List[Mention] = [] - - #: type of source message's chat. - reply_type: ChatTypes - - #: uuid of sender. - sender: UUID - - #: chat name of source message. - source_chat_name: Optional[str] - - #: chat uuid of source message. - source_group_chat_id: Optional[UUID] - - #: uuid of source message. - source_sync_id: UUID - - class Config: - allow_population_by_field_name = True - - -class Entity(BotXBaseModel): - """Additional entity that can be received by bot.""" - - #: entity type. - type: EntityTypes # noqa: WPS125 - - #: entity data. - data: Union[Forward, Mention, Reply] # noqa: WPS110 - - -class EntityList(BotXBaseModel): - """Additional wrapped class for use property.""" - - __root__: List[Entity] - - @property - def mentions(self) -> List[Mention]: - """Search mentions in message's entity. - - Returns: - List of mentions. - """ - return [ - cast(Mention, entity.data) - for entity in self.__root__ - if entity.type == EntityTypes.mention - ] - - @property - def forward(self) -> Forward: - """Search forward in message's entity. - - Returns: - Information about forward. - - Raises: - AttributeError: raised if message has no forward. - """ - for entity in self.__root__: - if entity.type == EntityTypes.forward: # pragma: no branch - return cast(Forward, entity.data) - raise AttributeError("forward") - - @property - def reply(self) -> Reply: - """Search reply in message's entity. - - Returns: - Reply. - - Raises: - AttributeError: raised if message has no reply. - """ - for entity in self.__root__: - if entity.type == EntityTypes.reply: # pragma: no branch - return cast(Reply, entity.data) - raise AttributeError("reply") diff --git a/botx/models/enums.py b/botx/models/enums.py index f395b451..5e430071 100644 --- a/botx/models/enums.py +++ b/botx/models/enums.py @@ -1,168 +1,239 @@ -"""Definition of enums that are used across different components of this library.""" +from enum import Enum, auto -from enum import Enum +from botx.models.api_base import StrEnum -class SystemEvents(Enum): - """System enums that bot can retrieve from BotX API in message. +class AutoName(Enum): + def _generate_next_value_( # type: ignore # noqa: WPS120 + name, # noqa: N805 (copied from official python docs) + start, + count, + last_values, + ): + return name - !!! info - NOTE: `file_transfer` is not a system event, but it is logical to place it in - this enum. - """ - - #: `system:chat_created` event. - chat_created = "system:chat_created" - - #: `system:added_to_chat` event. - added_to_chat = "system:added_to_chat" - - #: `system:deleted_from_chat` event. - deleted_from_chat = "system:deleted_from_chat" - - #: `system:left_from_chat` event. - left_from_chat = "system:left_from_chat" - - #: `system:internal_bot_notification` event - internal_bot_notification = "system:internal_bot_notification" - - #: `system:cts_login` event. - cts_login = "system:cts_login" - - #: `system:cts_logout` event. - cts_logout = "system:cts_logout" - #: `system:smartapp_event` event. - smartapp_event = "system:smartapp_event" +class UserKinds(AutoName): + RTS_USER = auto() + CTS_USER = auto() + BOT = auto() - #: `file_transfer` message. - file_transfer = "file_transfer" +class AttachmentTypes(AutoName): + IMAGE = auto() + VIDEO = auto() + DOCUMENT = auto() + VOICE = auto() + LOCATION = auto() + CONTACT = auto() + LINK = auto() -class CommandTypes(str, Enum): - """Enum that specify from whom command was received.""" - #: command received from user. - user = "user" +class ClientPlatforms(AutoName): + WEB = auto() + ANDROID = auto() + IOS = auto() + DESKTOP = auto() - #: command received from system. - system = "system" +class MentionTypes(AutoName): + CONTACT = auto() + CHAT = auto() + CHANNEL = auto() + USER = auto() + ALL = auto() -class ChatTypes(str, Enum): - """Enum for type of chat.""" - #: private chat for user with bot. - chat = "chat" +class ChatTypes(AutoName): + """BotX chat types. - #: chat with several users. - group_chat = "group_chat" - - #: channel chat. - channel = "channel" - - # botx - botx = "botx" # todo replies incoming with whith type - - -class UserKinds(str, Enum): - """Enum for type of user.""" - - #: normal user. - user = "user" - - #: normal user, but will present if all users in chat are from the same CTS. - cts_user = "cts_user" - - #: bot user. - bot = "botx" + Attributes: + PERSONAL_CHAT: Personal chat with user. + GROUP_CHAT: Group chat. + CHANNEL: Public channel. + """ + PERSONAL_CHAT = auto() + GROUP_CHAT = auto() + CHANNEL = auto() -class Statuses(str, Enum): - """Enum for status of operation in BotX API.""" - #: operation was successfully proceed. - ok = "ok" +class APIChatTypes(StrEnum): + CHAT = "chat" + GROUP_CHAT = "group_chat" + CHANNEL = "channel" - #: there was an error while processing operation. - error = "error" +class BotAPICommandTypes(StrEnum): + USER = "user" + SYSTEM = "system" -class EntityTypes(str, Enum): - """Types for entities that could be received by bot.""" - #: mention entity. - mention = "mention" +class BotAPIClientPlatforms(StrEnum): + WEB = "web" + ANDROID = "android" + IOS = "ios" + DESKTOP = "desktop" - #: forward entity. - forward = "forward" - #: reply entity. - reply = "reply" +class BotAPIEntityTypes(StrEnum): + MENTION = "mention" + FORWARD = "forward" + REPLY = "reply" -class AttachmentsTypes(str, Enum): - """Types for attachments that could be received by bot.""" +class BotAPIMentionTypes(StrEnum): + CONTACT = "contact" + CHAT = "chat" + CHANNEL = "channel" + USER = "user" + ALL = "all" - image = "image" - video = "video" - document = "document" - voice = "voice" - contact = "contact" - location = "location" - link = "link" +class APIUserKinds(StrEnum): + USER = "user" + CTS_USER = "cts_user" + BOTX = "botx" -class MentionTypes(str, Enum): - """Enum for available validated_values in mentions.""" - #: mention single user from chat in message. - user = "user" +class APIAttachmentTypes(StrEnum): + IMAGE = "image" + VIDEO = "video" + DOCUMENT = "document" + VOICE = "voice" + LOCATION = "location" + CONTACT = "contact" + LINK = "link" - #: mention user by user_huid. - contact = "contact" - #: mention chat in message. - chat = "chat" +def convert_client_platform_to_domain( + client_platform: BotAPIClientPlatforms, +) -> ClientPlatforms: + client_platforms_mapping = { + BotAPIClientPlatforms.WEB: ClientPlatforms.WEB, + BotAPIClientPlatforms.ANDROID: ClientPlatforms.ANDROID, + BotAPIClientPlatforms.IOS: ClientPlatforms.IOS, + BotAPIClientPlatforms.DESKTOP: ClientPlatforms.DESKTOP, + } - #: mention channel in message. - channel = "channel" + converted_type = client_platforms_mapping.get(client_platform) + if converted_type is None: + raise NotImplementedError(f"Unsupported client platform: {client_platform}") - #: mention all users in chat - all_members = "all" + return converted_type -class LinkProtos(str, Enum): - """Enum for protos of links in attachments.""" +def convert_mention_type_to_domain(mention_type: BotAPIMentionTypes) -> MentionTypes: + mention_types_mapping = { + BotAPIMentionTypes.CONTACT: MentionTypes.CONTACT, + BotAPIMentionTypes.CHAT: MentionTypes.CHAT, + BotAPIMentionTypes.CHANNEL: MentionTypes.CHANNEL, + BotAPIMentionTypes.USER: MentionTypes.USER, + BotAPIMentionTypes.ALL: MentionTypes.ALL, + } - #: proto for attach with email. - email = "mailto:" + converted_type = mention_types_mapping.get(mention_type) + if converted_type is None: + raise NotImplementedError(f"Unsupported mention type: {mention_type}") - #: proto for attach with telephone number. - telephone = "tel://" + return converted_type -class ClientPlatformEnum(str, Enum): - """Enum for distinguishing client platforms.""" +def convert_mention_type_from_domain( + mention_type: MentionTypes, +) -> BotAPIMentionTypes: + embed_mention_types_mapping = { + MentionTypes.USER: BotAPIMentionTypes.USER, + MentionTypes.CONTACT: BotAPIMentionTypes.CONTACT, + MentionTypes.CHAT: BotAPIMentionTypes.CHAT, + MentionTypes.CHANNEL: BotAPIMentionTypes.CHANNEL, + MentionTypes.ALL: BotAPIMentionTypes.ALL, + } - #: Web platform. - web = "web" + converted_type = embed_mention_types_mapping.get(mention_type) + if converted_type is None: + raise NotImplementedError(f"Unsupported mention type: {mention_type}") - #: Android platform. - android = "android" + return converted_type - #: iOS platform. - ios = "ios" - #: Desktop platform. - desktop = "desktop" +def convert_user_kind_to_domain(user_kind: APIUserKinds) -> UserKinds: + user_kinds_mapping = { + APIUserKinds.USER: UserKinds.RTS_USER, + APIUserKinds.CTS_USER: UserKinds.CTS_USER, + APIUserKinds.BOTX: UserKinds.BOT, + } + converted_type = user_kinds_mapping.get(user_kind) + if converted_type is None: + raise NotImplementedError(f"Unsupported user kind: {user_kind}") -class ButtonHandlerTypes(str, Enum): - """Enum for markup's `handler` field.""" + return converted_type + + +def convert_attachment_type_to_domain( + attachment_type: APIAttachmentTypes, +) -> AttachmentTypes: + attachment_types_mapping = { + APIAttachmentTypes.IMAGE: AttachmentTypes.IMAGE, + APIAttachmentTypes.VIDEO: AttachmentTypes.VIDEO, + APIAttachmentTypes.DOCUMENT: AttachmentTypes.DOCUMENT, + APIAttachmentTypes.VOICE: AttachmentTypes.VOICE, + APIAttachmentTypes.LOCATION: AttachmentTypes.LOCATION, + APIAttachmentTypes.CONTACT: AttachmentTypes.CONTACT, + APIAttachmentTypes.LINK: AttachmentTypes.LINK, + } + + converted_type = attachment_types_mapping.get(attachment_type) + if converted_type is None: + raise NotImplementedError(f"Unsupported attachment type: {attachment_type}") + + return converted_type + + +def convert_attachment_type_from_domain( + attachment_type: AttachmentTypes, +) -> APIAttachmentTypes: + attachment_types_mapping = { + AttachmentTypes.IMAGE: APIAttachmentTypes.IMAGE, + AttachmentTypes.VIDEO: APIAttachmentTypes.VIDEO, + AttachmentTypes.DOCUMENT: APIAttachmentTypes.DOCUMENT, + AttachmentTypes.VOICE: APIAttachmentTypes.VOICE, + AttachmentTypes.LOCATION: APIAttachmentTypes.LOCATION, + AttachmentTypes.CONTACT: APIAttachmentTypes.CONTACT, + AttachmentTypes.LINK: APIAttachmentTypes.LINK, + } + + converted_type = attachment_types_mapping.get(attachment_type) + if converted_type is None: + raise NotImplementedError(f"Unsupported attachment type: {attachment_type}") + + return converted_type + + +def convert_chat_type_from_domain(chat_type: ChatTypes) -> APIChatTypes: + chat_types_mapping = { + ChatTypes.PERSONAL_CHAT: APIChatTypes.CHAT, + ChatTypes.GROUP_CHAT: APIChatTypes.GROUP_CHAT, + ChatTypes.CHANNEL: APIChatTypes.CHANNEL, + } + + converted_type = chat_types_mapping.get(chat_type) + if converted_type is None: + raise NotImplementedError(f"Unsupported chat type: {chat_type}") + + return converted_type + + +def convert_chat_type_to_domain(chat_type: APIChatTypes) -> ChatTypes: + chat_types_mapping = { + APIChatTypes.CHAT: ChatTypes.PERSONAL_CHAT, + APIChatTypes.GROUP_CHAT: ChatTypes.GROUP_CHAT, + APIChatTypes.CHANNEL: ChatTypes.CHANNEL, + } - #: bot side process. - bot = "bot" + converted_type = chat_types_mapping.get(chat_type) + if converted_type is None: + raise NotImplementedError(f"Unsupported chat type: {chat_type}") - #: client side process. - client = "client" + return converted_type diff --git a/botx/models/errors.py b/botx/models/errors.py deleted file mode 100644 index d9b259b1..00000000 --- a/botx/models/errors.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Definition of errors in processing request from BotX API.""" - -from typing import List - -from botx.models.base import BotXBaseModel - - -class BotDisabledErrorData(BotXBaseModel): - """Data about occurred error.""" - - #: message that will be shown to user. - status_message: str - - -class BotDisabledResponse(BotXBaseModel): - """Response to BotX API if there was an error in handling incoming request.""" - - #: error reason. *This should always be `bot_disabled` string.* - reason: str = "bot_disabled" - - #: data about occurred error that should include `status_message` field in json. - error_data: BotDisabledErrorData - - errors: List[str] = [] diff --git a/botx/models/events.py b/botx/models/events.py deleted file mode 100644 index 4234508a..00000000 --- a/botx/models/events.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Definition of different schemas for system events.""" - -from types import MappingProxyType -from typing import Any, Dict, List, Mapping, Optional, Type -from uuid import UUID - -from botx.clients.types.message_payload import InternalBotNotificationPayload -from botx.models.base import BotXBaseModel -from botx.models.enums import ChatTypes, SystemEvents -from botx.models.users import UserInChatCreated - - -class ChatCreatedEvent(BotXBaseModel): - """Shape for `system:chat_created` event data.""" - - #: chat id from which event received. - group_chat_id: UUID - - #: type of chat. - chat_type: ChatTypes - - #: chat name. - name: str - - #: HUID of user that created chat. - creator: UUID - - #: list of users that are members of chat. - members: List[UserInChatCreated] - - -class AddedToChatEvent(BotXBaseModel): - """Shape for `system:added_to_chat` event data.""" - - #: members added to chat. - added_members: List[UUID] - - -class DeletedFromChatEvent(BotXBaseModel): - """Shape for `system:deleted_from_chat` event data.""" - - #: members deleted from chat - deleted_members: List[UUID] - - -class LeftFromChatEvent(BotXBaseModel): - """Shape for `system:left_from_chat` event data.""" - - #: left chat members - left_members: List[UUID] - - -class InternalBotNotificationEvent(BotXBaseModel): - """Shape for `system:internal_bot_notification` event data.""" - - #: notification data - data: InternalBotNotificationPayload # noqa: WPS110 - - #: user-defined extra options - opts: Dict[str, Any] - - -class CTSLoginEvent(BotXBaseModel): - """Shape for `system:cts_login` event data.""" - - #: huid of user which logged into CTS. - user_huid: UUID - - #: CTS id. - cts_id: UUID - - -class CTSLogoutEvent(BotXBaseModel): - """Shape for `system:cts_logout` event data.""" - - #: huid of user which logged out from CTS. - user_huid: UUID - - #: CTS id. - cts_id: UUID - - -class SmartAppEvent(BotXBaseModel): - """Shape for `system:smartapp_event` event data.""" - - #: unique request id - ref: Optional[UUID] = None - - #: smartapp id - smartapp_id: UUID - - #: event data - data: Dict[str, Any] # noqa: WPS110 - - #: event options - opts: Dict[str, Any] = {} - - #: version of protocol smartapp <-> bot - smartapp_api_version: int - - -# dict for validating shape for different events -EVENTS_SHAPE_MAP: Mapping[SystemEvents, Type[BotXBaseModel]] = MappingProxyType( - { - SystemEvents.chat_created: ChatCreatedEvent, - SystemEvents.added_to_chat: AddedToChatEvent, - SystemEvents.deleted_from_chat: DeletedFromChatEvent, - SystemEvents.left_from_chat: LeftFromChatEvent, - SystemEvents.internal_bot_notification: InternalBotNotificationEvent, - SystemEvents.cts_login: CTSLoginEvent, - SystemEvents.cts_logout: CTSLogoutEvent, - SystemEvents.smartapp_event: SmartAppEvent, - }, -) diff --git a/botx/models/files.py b/botx/models/files.py deleted file mode 100644 index 60912bd3..00000000 --- a/botx/models/files.py +++ /dev/null @@ -1,352 +0,0 @@ -"""Definition of file that can be included in incoming message or in sending result.""" - -import base64 -from contextlib import contextmanager -from io import BytesIO -from pathlib import Path -from types import MappingProxyType -from typing import AnyStr, AsyncIterable, BinaryIO, Generator, Optional, TextIO, Union -from uuid import UUID - -from base64io import Base64IO - -from botx.models.base import BotXBaseModel -from botx.models.enums import AttachmentsTypes - -EXTENSIONS_TO_MIMETYPES = MappingProxyType( - { - # application - ".7z": "application/x-7z-compressed", - ".abw": "application/x-abiword", - ".ai": "application/postscript", - ".arc": "application/x-freearc", - ".azw": "application/vnd.amazon.ebook", - ".bin": "application/octet-stream", - ".bz": "application/x-bzip", - ".bz2": "application/x-bzip2", - ".cda": "application/x-cdf", - ".csh": "application/x-csh", - ".doc": "application/msword", - ".docx": ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - ), - ".eot": "application/vnd.ms-fontobject", - ".eps": "application/postscript", - ".epub": "application/epub+zip", - ".gz": "application/gzip", - ".jar": "application/java-archive", - ".json-api": "application/vnd.api+json", - ".json-patch": "application/json-patch+json", - ".json": "application/json", - ".jsonld": "application/ld+json", - ".mdb": "application/x-msaccess", - ".mpkg": "application/vnd.apple.installer+xml", - ".odp": "application/vnd.oasis.opendocument.presentation", - ".ods": "application/vnd.oasis.opendocument.spreadsheet", - ".odt": "application/vnd.oasis.opendocument.text", - ".ogx": "application/ogg", - ".pdf": "application/pdf", - ".php": "application/x-httpd-php", - ".ppt": "application/vnd.ms-powerpoint", - ".pptx": ( - "application/vnd.openxmlformats-officedocument.presentationml.presentation" - ), - ".ps": "application/postscript", - ".rar": "application/vnd.rar", - ".rtf": "application/rtf", - ".sh": "application/x-sh", - ".swf": "application/x-shockwave-flash", - ".tar": "application/x-tar", - ".vsd": "application/vnd.visio", - ".wasm": "application/wasm", - ".webmanifest": "application/manifest+json", - ".xhtml": "application/xhtml+xml", - ".xls": "application/vnd.ms-excel", - ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".xul": "application/vnd.mozilla.xul+xml", - ".zip": "application/zip", - # audio - ".aac": "audio/aac", - ".mid": "audio/midi", - ".midi": "audio/midi", - ".mp3": "audio/mpeg", - ".oga": "audio/ogg", - ".opus": "audio/opus", - ".wav": "audio/wav", - ".weba": "audio/webm", - # font - ".otf": "font/otf", - ".ttf": "font/ttf", - ".woff": "font/woff", - ".woff2": "font/woff2", - # image - ".avif": "image/avif", - ".bmp": "image/bmp", - ".gif": "image/gif", - ".ico": "image/vnd.microsoft.icon", - ".jpeg": "image/jpeg", - ".jpg": "image/jpeg", - ".png": "image/png", - ".svg": "image/svg+xml", - ".svgz": "image/svg+xml", - ".tif": "image/tiff", - ".tiff": "image/tiff", - ".webp": "image/webp", - # text - ".css": "text/css", - ".csv": "text/csv", - ".htm": "text/html", - ".html": "text/html", - ".ics": "text/calendar", - ".js": "text/javascript", - ".mjs": "text/javascript", - ".txt": "text/plain", - ".text": "text/plain", - ".xml": "text/xml", - # video - ".3g2": "video/3gpp2", - ".3gp": "video/3gpp", - ".avi": "video/x-msvideo", - ".mov": "video/quicktime", - ".mp4": "video/mp4", - ".mpeg": "video/mpeg", - ".mpg": "video/mpeg", - ".ogv": "video/ogg", - ".ts": "video/mp2t", - ".webm": "video/webm", - ".wmv": "video/x-ms-wmv", - }, -) -DEFAULT_MIMETYPE = "application/octet-stream" - - -class NamedAsyncIterable(AsyncIterable): - """AsyncIterable with `name` protocol.""" - - name: str - - -class File(BotXBaseModel): # noqa: WPS214 - """Object that represents file in RFC 2397 format.""" - - #: name of file. - file_name: str - - #: file content in RFC 2397 format. - data: str # noqa: WPS110 - - #: text under file. - caption: Optional[str] = None - - @classmethod - def from_file( # noqa: WPS210 - 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 Path(file.name).name - encoded_file = BytesIO() - - text_mode = file.read(0) == "" # b"" if bytes mode - - with Base64IO(encoded_file) as b64_stream: - if text_mode: - for text_line in file: # TODO: Deprecate text mode in 0.17 - b64_stream.write(text_line.encode()) # type: ignore - else: - for line in file: - b64_stream.write(line) - - encoded_file.seek(0) - encoded_data = encoded_file.read().decode() - - media_type = cls._get_mimetype(filename) - return cls(file_name=filename, data=cls._to_rfc2397(media_type, encoded_data)) - - @classmethod - async def async_from_file( # noqa: WPS210 - cls, - file: NamedAsyncIterable, - filename: Optional[str] = None, - ) -> "File": - """Convert async file-like object into BotX API compatible file. - - Arguments: - file: async 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. - """ - assert hasattr( # noqa: WPS421 - file, - "__aiter__", - ), "file should support async iteration" - - filename = filename or Path(file.name).name - media_type = cls._get_mimetype(filename) - - encoded_file = BytesIO() - - with Base64IO(encoded_file) as b64_stream: - async for line in file: # pragma: no branch - b64_stream.write(line) - - encoded_file.seek(0) - encoded_data = encoded_file.read().decode() - - return cls(file_name=filename, data=cls._to_rfc2397(media_type, encoded_data)) - - @contextmanager - def file_chunks(self) -> Generator[bytes, None, None]: - """Return file data in iterator that will return bytes.""" - encoded_file = BytesIO(self.data_in_base64.encode()) - - with Base64IO(encoded_file) as decoded_file: - yield decoded_file - - @classmethod - def from_string(cls, data_of_file: AnyStr, filename: str) -> "File": - """Build file from bytes or string passed to method in `data` with `filename` as name. - - Arguments: - data_of_file: string or bytes that will be used for creating file. - filename: name for new file. - - Returns: - Built file object. - """ - if isinstance(data_of_file, str): - file_data = data_of_file.encode() - else: - file_data = data_of_file - 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 size_in_bytes(self) -> int: - """Return file size in bytes.""" - with self.file_chunks() as chunks: - return sum(len(chunk) for chunk in chunks) # type: ignore - - @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 self._get_mimetype(self.file_name) - - @classmethod - def get_ext_by_mimetype(cls, mimetype: str) -> Optional[str]: - """Get extension by mimetype. - - Arguments: - mimetype: mimetype of file. - - Returns: - file extension or none if mimetype not found. - """ - for ext, m_type in EXTENSIONS_TO_MIMETYPES.items(): - if m_type == mimetype: - return ext - - return None - - @classmethod - def _to_rfc2397(cls, media_type: str, encoded_data: str) -> str: - """Apply RFC 2397 format to encoded file contents. - - Arguments: - media_type: file media type. - encoded_data: base64 encoded file contents. - - Returns: - File contents converted to RFC 2397. - """ - return "data:{0};base64,{1}".format(media_type, encoded_data) - - @classmethod - def _get_mimetype(cls, filename: str) -> str: - """Get mimetype by filename. - - Arguments: - filename: file name to inspect. - - Returns: - File mimetype. - """ - file_extension = Path(filename).suffix.lower() - return EXTENSIONS_TO_MIMETYPES.get(file_extension, DEFAULT_MIMETYPE) - - -class MetaFile(BotXBaseModel): - """File info from file service.""" - - #: type of file - type: AttachmentsTypes - - #: file url. - file: str - - #: mime type of file. - file_mime_type: str - - #: name of file. - file_name: str - - #: file preview. - file_preview: Optional[str] - - #: height of file (px). - file_preview_height: Optional[int] - - #: width of file (px). - file_preview_width: Optional[int] - - #: size of file. - file_size: int - - #: hash of file. - file_hash: str - - #: encryption algorithm of file. - file_encryption_algo: str - - #: chunks size. - chunk_size: int - - #: ID of file. - file_id: UUID - - #: file caption. - caption: Optional[str] - - #: media file duration. - duration: Optional[int] diff --git a/botx/models/menu.py b/botx/models/menu.py deleted file mode 100644 index 931ee6ea..00000000 --- a/botx/models/menu.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Pydantic models for bot menu.""" - -from typing import List - -from botx.models.base import BotXBaseModel -from botx.models.enums import Statuses - - -class MenuCommand(BotXBaseModel): - """Command that is shown in bot menu.""" - - #: command description that will be shown in menu. - description: str - - #: command body that will trigger command execution. - body: str - - #: command name. - name: str - - -class StatusResult(BotXBaseModel): - """Bot menu commands collection.""" - - #: is bot enabled. - enabled: bool = True - - #: status of bot. - status_message: str = "Bot is working" - - #: list of bot commands that will be shown in menu. - commands: List[MenuCommand] = [] - - -class Status(BotXBaseModel): - """Object that should be returned on `/status` request from BotX API.""" - - #: operation status. - status: Statuses = Statuses.ok - - #: bot status. - result: StatusResult = StatusResult() diff --git a/tests/test_bots/__init__.py b/botx/models/message/__init__.py similarity index 100% rename from tests/test_bots/__init__.py rename to botx/models/message/__init__.py diff --git a/botx/models/message/edit_message.py b/botx/models/message/edit_message.py new file mode 100644 index 00000000..11b0e0eb --- /dev/null +++ b/botx/models/message/edit_message.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Any, Dict, Union +from uuid import UUID + +from botx.missing import Missing, Undefined +from botx.models.attachments import IncomingFileAttachment, OutgoingAttachment +from botx.models.message.markup import BubbleMarkup, KeyboardMarkup + + +@dataclass +class EditMessage: + bot_id: UUID + sync_id: UUID + body: Missing[str] = Undefined + metadata: Missing[Dict[str, Any]] = Undefined + bubbles: Missing[BubbleMarkup] = Undefined + keyboard: Missing[KeyboardMarkup] = Undefined + file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined + markup_auto_adjust: Missing[bool] = Undefined diff --git a/botx/models/message/forward.py b/botx/models/message/forward.py new file mode 100644 index 00000000..a8d02c32 --- /dev/null +++ b/botx/models/message/forward.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass +from typing import Literal +from uuid import UUID + +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.enums import BotAPIEntityTypes + + +@dataclass +class Forward: + chat_id: UUID + author_id: UUID + sync_id: UUID + + +class BotAPIForwardData(VerifiedPayloadBaseModel): + group_chat_id: UUID + sender_huid: UUID + source_sync_id: UUID + + +class BotAPIForward(VerifiedPayloadBaseModel): + type: Literal[BotAPIEntityTypes.FORWARD] + data: BotAPIForwardData diff --git a/botx/models/message/incoming_message.py b/botx/models/message/incoming_message.py new file mode 100644 index 00000000..a771458c --- /dev/null +++ b/botx/models/message/incoming_message.py @@ -0,0 +1,281 @@ +from dataclasses import dataclass, field +from types import SimpleNamespace +from typing import Any, Dict, List, Optional, Tuple, Union, cast +from uuid import UUID + +from pydantic import Field + +from botx.models.async_files import APIAsyncFile, File, convert_async_file_to_domain +from botx.models.attachments import ( + AttachmentContact, + AttachmentLink, + AttachmentLocation, + BotAPIAttachment, + FileAttachmentBase, + IncomingFileAttachment, + convert_api_attachment_to_domain, +) +from botx.models.base_command import ( + BotAPIBaseCommand, + BotAPIChatContext, + BotAPICommandPayload, + BotAPIDeviceContext, + BotAPIUserContext, + BotCommandBase, +) +from botx.models.bot_account import BotAccount +from botx.models.chats import Chat +from botx.models.enums import ( + AttachmentTypes, + BotAPIEntityTypes, + BotAPIMentionTypes, + ClientPlatforms, + convert_chat_type_to_domain, + convert_client_platform_to_domain, + convert_mention_type_to_domain, +) +from botx.models.message.forward import BotAPIForward, Forward +from botx.models.message.mentions import ( + BotAPIMention, + BotAPIMentionData, + BotAPINestedMentionData, + Mention, + MentionList, +) +from botx.models.message.reply import BotAPIReply, Reply + + +@dataclass +class UserDevice: + manufacturer: Optional[str] + device_name: Optional[str] + os: Optional[str] + pushes: Optional[bool] + timezone: Optional[str] + permissions: Optional[Dict[str, Any]] + platform: Optional[ClientPlatforms] + platform_package_id: Optional[str] + app_version: Optional[str] + locale: Optional[str] + + +@dataclass +class UserSender: + huid: UUID + ad_login: Optional[str] + ad_domain: Optional[str] + username: Optional[str] + is_chat_admin: Optional[bool] + is_chat_creator: Optional[bool] + device: UserDevice + + @property + def upn(self) -> Optional[str]: + # https://docs.microsoft.com/en-us/windows/win32/secauthn/user-name-formats + if not (self.ad_login and self.ad_domain): + return None + + return f"{self.ad_login}@{self.ad_domain}" + + +@dataclass +class IncomingMessage(BotCommandBase): + sync_id: UUID + source_sync_id: Optional[UUID] + body: str + data: Dict[str, Any] + metadata: Dict[str, Any] + sender: UserSender + chat: Chat + mentions: MentionList = field(default_factory=MentionList) + forward: Optional[Forward] = None + reply: Optional[Reply] = None + file: Optional[Union[File, IncomingFileAttachment]] = None + location: Optional[AttachmentLocation] = None + contact: Optional[AttachmentContact] = None + link: Optional[AttachmentLink] = None + + state: SimpleNamespace = field(default_factory=SimpleNamespace) + + @property + def argument(self) -> str: + split_body = self.body.split() + if not split_body: + return "" + + command_len = len(split_body[0]) + return self.body[command_len:].strip() + + @property + def arguments(self) -> Tuple[str, ...]: + return tuple(arg.strip() for arg in self.argument.split()) + + +BotAPIEntity = Union[BotAPIMention, BotAPIForward, BotAPIReply] +Entity = Union[Mention, Forward, Reply] + + +def _convert_bot_api_mention_to_domain(api_mention_data: BotAPIMentionData) -> Mention: + entity_id: Optional[UUID] = None + name: Optional[str] = None + + if api_mention_data.mention_type != BotAPIMentionTypes.ALL: + mention_data = cast(BotAPINestedMentionData, api_mention_data.mention_data) + entity_id = mention_data.entity_id + name = mention_data.name + + return Mention( + type=convert_mention_type_to_domain(api_mention_data.mention_type), + entity_id=entity_id, + name=name, + ) + + +def convert_bot_api_entity_to_domain(api_entity: BotAPIEntity) -> Entity: + if api_entity.type == BotAPIEntityTypes.MENTION: + api_entity = cast(BotAPIMention, api_entity) + return _convert_bot_api_mention_to_domain(api_entity.data) + + if api_entity.type == BotAPIEntityTypes.FORWARD: + api_entity = cast(BotAPIForward, api_entity) + + return Forward( + chat_id=api_entity.data.group_chat_id, + author_id=api_entity.data.sender_huid, + sync_id=api_entity.data.source_sync_id, + ) + + if api_entity.type == BotAPIEntityTypes.REPLY: + api_entity = cast(BotAPIReply, api_entity) + + mentions = MentionList() + for api_mention_data in api_entity.data.mentions: + mentions.append(_convert_bot_api_mention_to_domain(api_mention_data)) + + return Reply( + author_id=api_entity.data.sender, + sync_id=api_entity.data.source_sync_id, + body=api_entity.data.body, + mentions=mentions, + ) + + raise NotImplementedError(f"Unsupported entity type: {api_entity.type}") + + +class BotAPIIncomingMessageContext( + BotAPIUserContext, + BotAPIChatContext, + BotAPIDeviceContext, +): + """Class for merging contexts.""" + + +class BotAPIIncomingMessage(BotAPIBaseCommand): + payload: BotAPICommandPayload = Field(..., alias="command") + sender: BotAPIIncomingMessageContext = Field(..., alias="from") + + source_sync_id: Optional[UUID] + attachments: List[BotAPIAttachment] + async_files: List[APIAsyncFile] + entities: List[BotAPIEntity] + + def to_domain(self, raw_command: Dict[str, Any]) -> IncomingMessage: # noqa: WPS231 + if self.sender.device_meta: + pushes = self.sender.device_meta.pushes + timezone = self.sender.device_meta.timezone + permissions = self.sender.device_meta.permissions + else: + pushes, timezone, permissions = None, None, None + + device = UserDevice( + manufacturer=self.sender.manufacturer, + device_name=self.sender.device, + os=self.sender.device_software, + pushes=pushes, + timezone=timezone, + permissions=permissions, + platform=( + convert_client_platform_to_domain(self.sender.platform) + if self.sender.platform + else None + ), + platform_package_id=self.sender.platform_package_id, + app_version=self.sender.app_version, + locale=self.sender.locale, + ) + + sender = UserSender( + huid=self.sender.user_huid, + ad_login=self.sender.ad_login, + ad_domain=self.sender.ad_domain, + username=self.sender.username, + is_chat_admin=self.sender.is_admin, + is_chat_creator=self.sender.is_creator, + device=device, + ) + + chat = Chat( + id=self.sender.group_chat_id, + type=convert_chat_type_to_domain(self.sender.chat_type), + ) + + file: Optional[Union[File, IncomingFileAttachment]] = None + location: Optional[AttachmentLocation] = None + contact: Optional[AttachmentContact] = None + link: Optional[AttachmentLink] = None + if self.async_files: + # Always one async file per-message + file = convert_async_file_to_domain(self.async_files[0]) + elif self.attachments: + # Always one attachment per-message + attachment_domain = convert_api_attachment_to_domain(self.attachments[0]) + if isinstance(attachment_domain, FileAttachmentBase): + file = attachment_domain + elif attachment_domain.type == AttachmentTypes.LOCATION: + location = attachment_domain + elif attachment_domain.type == AttachmentTypes.CONTACT: + contact = attachment_domain + elif attachment_domain.type == AttachmentTypes.LINK: + link = attachment_domain + else: + raise NotImplementedError + + mentions: MentionList = MentionList() + forward: Optional[Forward] = None + reply: Optional[Reply] = None + for entity in self.entities: + entity_domain = convert_bot_api_entity_to_domain(entity) + if isinstance(entity_domain, Mention): + mentions.append(entity_domain) + elif isinstance(entity_domain, Forward): + # Max one forward per message + forward = entity_domain + elif isinstance(entity_domain, Reply): + # Max one reply per message + reply = entity_domain + else: + raise NotImplementedError + + bot = BotAccount( + id=self.bot_id, + host=self.sender.host, + ) + + return IncomingMessage( + bot=bot, + sync_id=self.sync_id, + source_sync_id=self.source_sync_id, + body=self.payload.body, + data=self.payload.data, + metadata=self.payload.metadata, + sender=sender, + chat=chat, + raw_command=raw_command, + file=file, + location=location, + contact=contact, + link=link, + mentions=mentions, + forward=forward, + reply=reply, + ) diff --git a/botx/models/message/markup.py b/botx/models/message/markup.py new file mode 100644 index 00000000..0f26b0ab --- /dev/null +++ b/botx/models/message/markup.py @@ -0,0 +1,131 @@ +from dataclasses import dataclass, field +from typing import Any, Dict, Iterator, List, Literal, Optional, Union + +from botx.missing import Missing, Undefined +from botx.models.api_base import UnverifiedPayloadBaseModel + + +@dataclass +class Button: + command: str + label: str + data: Dict[str, Any] = field(default_factory=dict) + + silent: bool = True # BotX has `False` as default, so Missing type can't be used + width_ratio: Missing[int] = Undefined + alert: Missing[str] = Undefined + process_on_client: Missing[bool] = Undefined + + +ButtonRow = List[Button] + + +class BaseMarkup: + def __init__(self, buttons: Optional[List[ButtonRow]] = None) -> None: + self._buttons = buttons or [] + + def __iter__(self) -> Iterator[ButtonRow]: + return iter(self._buttons) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BaseMarkup): + raise NotImplementedError + + # https://github.com/wemake-services/wemake-python-styleguide/issues/2172 + return self._buttons == other._buttons # noqa: WPS437 + + def add_built_button(self, button: Button, new_row: bool = True) -> None: + if new_row: + self._buttons.append([button]) + return + + if not self._buttons: + self._buttons.append([]) + + self._buttons[-1].append(button) + + def add_button( + self, + command: str, + label: str, + data: Optional[Dict[str, Any]] = None, + silent: bool = True, + width_ratio: Missing[int] = Undefined, + alert: Missing[str] = Undefined, + process_on_client: Missing[bool] = Undefined, + new_row: bool = True, + ) -> None: + button = Button( + command=command, + label=label, + data=data or {}, + silent=silent, + width_ratio=width_ratio, + alert=alert, + process_on_client=process_on_client, + ) + self.add_built_button(button, new_row=new_row) + + def add_row(self, button_row: ButtonRow) -> None: + self._buttons.append(button_row) + + +class BubbleMarkup(BaseMarkup): + """Class for managing inline message buttons.""" + + +class KeyboardMarkup(BaseMarkup): + """Class for managing keyboard message buttons.""" + + +Markup = Union[BubbleMarkup, KeyboardMarkup] + + +class BotXAPIButtonOptions(UnverifiedPayloadBaseModel): + silent: Missing[bool] + h_size: Missing[int] + show_alert: Missing[Literal[True]] + alert_text: Missing[str] + handler: Missing[Literal["client"]] + + +class BotXAPIButton(UnverifiedPayloadBaseModel): + command: str + label: str + data: Dict[str, Any] + opts: BotXAPIButtonOptions + + +class BotXAPIMarkup(UnverifiedPayloadBaseModel): + __root__: List[List[BotXAPIButton]] + + +def api_button_from_domain(button: Button) -> BotXAPIButton: + show_alert: Missing[Literal[True]] = Undefined + if button.alert is not Undefined: + show_alert = True + + handler: Missing[Literal["client"]] = Undefined + if button.process_on_client: + handler = "client" + + return BotXAPIButton( + command=button.command, + label=button.label, + data=button.data, + opts=BotXAPIButtonOptions( + silent=button.silent, + h_size=button.width_ratio, + alert_text=button.alert, + show_alert=show_alert, + handler=handler, + ), + ) + + +def api_markup_from_domain(markup: Markup) -> BotXAPIMarkup: + return BotXAPIMarkup( + __root__=[ + [api_button_from_domain(button) for button in buttons] for buttons in markup + ], + ) diff --git a/botx/models/message/mentions.py b/botx/models/message/mentions.py new file mode 100644 index 00000000..a254c8a4 --- /dev/null +++ b/botx/models/message/mentions.py @@ -0,0 +1,276 @@ +import re +from dataclasses import dataclass +from typing import Dict, List, Literal, Optional, Tuple, Union +from uuid import UUID, uuid4 + +from pydantic import Field, validator + +from botx.missing import Missing, Undefined +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel +from botx.models.enums import ( + BotAPIEntityTypes, + BotAPIMentionTypes, + MentionTypes, + convert_mention_type_from_domain, +) + + +@dataclass +class Mention: + type: MentionTypes + entity_id: Optional[UUID] = None + name: Optional[str] = None + + def __str__(self) -> str: + name = self.name or "" + entity_id = self.entity_id or "" + mention_type = self.type.value + return f"{mention_type}:{entity_id}:{name}" + + @classmethod + def user(cls, huid: UUID, name: Optional[str] = None) -> "Mention": + return cls( + type=MentionTypes.USER, + entity_id=huid, + name=name, + ) + + @classmethod + def contact(cls, huid: UUID, name: Optional[str] = None) -> "Mention": + return cls( + type=MentionTypes.CONTACT, + entity_id=huid, + name=name, + ) + + @classmethod + def chat(cls, chat_id: UUID, name: Optional[str] = None) -> "Mention": + return cls( + type=MentionTypes.CHAT, + entity_id=chat_id, + name=name, + ) + + @classmethod + def channel(cls, chat_id: UUID, name: Optional[str] = None) -> "Mention": + return cls( + type=MentionTypes.CHANNEL, + entity_id=chat_id, + name=name, + ) + + @classmethod + def all(cls) -> "Mention": + return cls(type=MentionTypes.ALL) + + +class MentionList(List[Mention]): + @property + def contacts(self) -> List[Mention]: + return [mention for mention in self if mention.type == MentionTypes.CONTACT] + + @property + def chats(self) -> List[Mention]: + return [mention for mention in self if mention.type == MentionTypes.CHAT] + + @property + def channels(self) -> List[Mention]: + return [mention for mention in self if mention.type == MentionTypes.CHANNEL] + + @property + def users(self) -> List[Mention]: + return [mention for mention in self if mention.type == MentionTypes.USER] + + @property + def all_users_mentioned(self) -> bool: + for mention in self: + if mention.type == MentionTypes.ALL: + return True + + return False + + +class BotAPINestedPersonalMentionData(VerifiedPayloadBaseModel): + entity_id: UUID = Field(alias="user_huid") + name: str + conn_type: str + + +class BotAPINestedGroupMentionData(VerifiedPayloadBaseModel): + entity_id: UUID = Field(alias="group_chat_id") + name: str + + +BotAPINestedMentionData = Union[ + BotAPINestedPersonalMentionData, + BotAPINestedGroupMentionData, +] + + +class BotAPIMentionData(VerifiedPayloadBaseModel): + mention_type: BotAPIMentionTypes + mention_id: UUID + mention_data: Optional[BotAPINestedMentionData] + + @validator("mention_data", pre=True) + @classmethod + def validate_mention_data( + cls, + mention_data: Dict[str, str], + ) -> Optional[Dict[str, str]]: + # Mention data can be an empty dict + if not mention_data: + return None + + return mention_data + + +class BotAPIMention(VerifiedPayloadBaseModel): + type: Literal[BotAPIEntityTypes.MENTION] + data: BotAPIMentionData + + +class BotXAPIPersonalMentionData(UnverifiedPayloadBaseModel): + user_huid: UUID + name: Missing[str] + + +class BotXAPIUserMention(UnverifiedPayloadBaseModel): + mention_type: Literal[BotAPIMentionTypes.USER] + mention_id: UUID + mention_data: BotXAPIPersonalMentionData + + def to_botx_embed_mention_format(self) -> str: + return f"@{{mention:{self.mention_id}}}" + + +class BotXAPIContactMention(UnverifiedPayloadBaseModel): + mention_type: Literal[BotAPIMentionTypes.CONTACT] + mention_id: UUID + mention_data: BotXAPIPersonalMentionData + + def to_botx_embed_mention_format(self) -> str: + return f"@@{{mention:{self.mention_id}}}" + + +class BotXAPIGroupMentionData(UnverifiedPayloadBaseModel): + group_chat_id: UUID + name: Missing[str] + + +class BotXAPIChatMention(UnverifiedPayloadBaseModel): + mention_type: Literal[BotAPIMentionTypes.CHAT] + mention_id: UUID + mention_data: BotXAPIGroupMentionData + + def to_botx_embed_mention_format(self) -> str: + return f"##{{mention:{self.mention_id}}}" + + +class BotXAPIChannelMention(UnverifiedPayloadBaseModel): + mention_type: Literal[BotAPIMentionTypes.CHANNEL] + mention_id: UUID + mention_data: BotXAPIGroupMentionData + + def to_botx_embed_mention_format(self) -> str: + return f"##{{mention:{self.mention_id}}}" + + +class BotXAPIAllMention(UnverifiedPayloadBaseModel): + mention_type: Literal[BotAPIMentionTypes.ALL] + mention_id: UUID + + def to_botx_embed_mention_format(self) -> str: + return f"@{{mention:{self.mention_id}}}" + + +BotXAPIMention = Union[ + BotXAPIUserMention, + BotXAPIContactMention, + BotXAPIChatMention, + BotXAPIChannelMention, + BotXAPIAllMention, +] + + +def build_botx_api_embed_mention( + mention_dict: Dict[str, str], +) -> BotXAPIMention: + mention_type = MentionTypes(mention_dict["mention_type"]) + mentioned_entity_id = mention_dict["mentioned_entity_id"] + # re match will have "" if mention_name not passed + mention_name = mention_dict["mention_name"] or Undefined + + if mention_type == MentionTypes.USER: + return BotXAPIUserMention( + mention_type=convert_mention_type_from_domain(mention_type), + mention_id=uuid4(), + mention_data=BotXAPIPersonalMentionData( + user_huid=UUID(mentioned_entity_id), + name=mention_name, + ), + ) + + if mention_type == MentionTypes.CONTACT: + return BotXAPIContactMention( + mention_type=convert_mention_type_from_domain(mention_type), + mention_id=uuid4(), + mention_data=BotXAPIPersonalMentionData( + user_huid=UUID(mentioned_entity_id), + name=mention_name, + ), + ) + + if mention_type == MentionTypes.CHAT: + return BotXAPIChatMention( + mention_type=convert_mention_type_from_domain(mention_type), + mention_id=uuid4(), + mention_data=BotXAPIGroupMentionData( + group_chat_id=UUID(mentioned_entity_id), + name=mention_name, + ), + ) + + if mention_type == MentionTypes.CHANNEL: + return BotXAPIChannelMention( + mention_type=convert_mention_type_from_domain(mention_type), + mention_id=uuid4(), + mention_data=BotXAPIGroupMentionData( + group_chat_id=UUID(mentioned_entity_id), + name=mention_name, + ), + ) + + if mention_type == MentionTypes.ALL: + return BotXAPIAllMention( + mention_type=convert_mention_type_from_domain(mention_type), + mention_id=uuid4(), + ) + + raise NotImplementedError + + +EMBED_MENTION_RE = re.compile( + ( + "" + "(?P.+?):" + r"(?P[0-9a-f\-]*?):" + "(?P.*?)" + r"<\/embed_mention>" + ), +) + + +def find_and_replace_embed_mentions(body: str) -> Tuple[str, List[BotXAPIMention]]: + mentions = [] + + for match in EMBED_MENTION_RE.finditer(body): + mention_dict = match.groupdict() + embed_mention = match.group(0) + + mention = build_botx_api_embed_mention(mention_dict) + body = body.replace(embed_mention, mention.to_botx_embed_mention_format(), 1) + + mentions.append(mention) + + return body, mentions diff --git a/botx/models/message/message_status.py b/botx/models/message/message_status.py new file mode 100644 index 00000000..def98483 --- /dev/null +++ b/botx/models/message/message_status.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Dict, List +from uuid import UUID + + +@dataclass +class MessageStatus: + group_chat_id: UUID + sent_to: List[UUID] + read_by: Dict[UUID, datetime] + received_by: Dict[UUID, datetime] diff --git a/botx/models/message/outgoing_message.py b/botx/models/message/outgoing_message.py new file mode 100644 index 00000000..d3aa51ec --- /dev/null +++ b/botx/models/message/outgoing_message.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Union +from uuid import UUID + +from botx.missing import Missing, Undefined +from botx.models.attachments import IncomingFileAttachment, OutgoingAttachment +from botx.models.message.markup import BubbleMarkup, KeyboardMarkup + + +@dataclass +class OutgoingMessage: + bot_id: UUID + chat_id: UUID + body: str + metadata: Missing[Dict[str, Any]] = Undefined + bubbles: Missing[BubbleMarkup] = Undefined + keyboard: Missing[KeyboardMarkup] = Undefined + file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined + silent_response: Missing[bool] = Undefined + markup_auto_adjust: Missing[bool] = Undefined + recipients: Missing[List[UUID]] = Undefined + stealth_mode: Missing[bool] = Undefined + send_push: Missing[bool] = Undefined + ignore_mute: Missing[bool] = Undefined diff --git a/botx/models/message/reply.py b/botx/models/message/reply.py new file mode 100644 index 00000000..a18b3c7f --- /dev/null +++ b/botx/models/message/reply.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from typing import List, Literal +from uuid import UUID + +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.enums import BotAPIEntityTypes +from botx.models.message.mentions import BotAPIMentionData, MentionList + + +@dataclass +class Reply: + author_id: UUID + sync_id: UUID + body: str + mentions: MentionList + + +class BotAPIReplyData(VerifiedPayloadBaseModel): + source_sync_id: UUID + sender: UUID + body: str + mentions: List[BotAPIMentionData] + # Ignoring attachments cause they don't have content + + +class BotAPIReply(VerifiedPayloadBaseModel): + type: Literal[BotAPIEntityTypes.REPLY] + data: BotAPIReplyData diff --git a/botx/models/message/reply_message.py b/botx/models/message/reply_message.py new file mode 100644 index 00000000..b9034eaa --- /dev/null +++ b/botx/models/message/reply_message.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from typing import Any, Dict, Union +from uuid import UUID + +from botx.missing import Missing, Undefined +from botx.models.attachments import IncomingFileAttachment, OutgoingAttachment +from botx.models.message.markup import BubbleMarkup, KeyboardMarkup + + +@dataclass +class ReplyMessage: + bot_id: UUID + sync_id: UUID + body: str + metadata: Missing[Dict[str, Any]] = Undefined + bubbles: Missing[BubbleMarkup] = Undefined + keyboard: Missing[KeyboardMarkup] = Undefined + file: Missing[Union[IncomingFileAttachment, OutgoingAttachment]] = Undefined + silent_response: Missing[bool] = Undefined + markup_auto_adjust: Missing[bool] = Undefined + stealth_mode: Missing[bool] = Undefined + send_push: Missing[bool] = Undefined + ignore_mute: Missing[bool] = Undefined diff --git a/botx/models/messages/__init__.py b/botx/models/messages/__init__.py deleted file mode 100644 index 5a28247a..00000000 --- a/botx/models/messages/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Entities for messages: incoming or outgoing.""" diff --git a/botx/models/messages/incoming_message.py b/botx/models/messages/incoming_message.py deleted file mode 100644 index 3b8dbefb..00000000 --- a/botx/models/messages/incoming_message.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Definition of messages received by bot or sent by it.""" - -from typing import Any, Dict, List, Optional, Tuple, Union -from uuid import UUID - -from pydantic import BaseConfig, BaseModel, Field, validator - -from botx.models import events -from botx.models.attachments import AttachList -from botx.models.entities import EntityList -from botx.models.enums import ChatTypes, ClientPlatformEnum, CommandTypes -from botx.models.files import File, MetaFile - -CommandDataType = Union[ - events.ChatCreatedEvent, - events.AddedToChatEvent, - events.DeletedFromChatEvent, - events.LeftFromChatEvent, - events.InternalBotNotificationEvent, - events.CTSLoginEvent, - events.CTSLogoutEvent, - events.SmartAppEvent, - Dict[str, Any], -] - - -class Command(BaseModel): - """Command that should be proceed by bot.""" - - #: incoming text message. - body: str - - #: was command received from user or this is system event. - command_type: CommandTypes - - #: command payload. - data: CommandDataType = {} # noqa: WPS110 - - #: command metadata. - metadata: Dict[str, Any] = {} - - @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.""" - body_len = len(self.command) - return self.body[body_len:].strip() - - @property - def data_dict(self) -> dict: - """Command data as dictionary.""" - if isinstance(self.data, dict): - return self.data - return self.data.dict() - - -class DeviceMeta(BaseModel): - """User device metadata.""" - - #: could send pushes. - pushes: Optional[bool] - - #: user timezone. - timezone: Optional[str] - - #: app permissions (microphone, camera, etc.) - permissions: Optional[Dict[str, Any]] - - -class Sender(BaseModel): - """User that sent message to bot.""" - - #: user id. - user_huid: Optional[UUID] - - #: chat id. - group_chat_id: Optional[UUID] - - #: type of chat. - chat_type: Optional[ChatTypes] - - #: AD login of user. - ad_login: Optional[str] - - #: AD domain of user. - ad_domain: Optional[str] - - #: username of user. - username: Optional[str] - - #: is user admin of chat. - is_admin: Optional[bool] - - #: is user creator of chat. - is_creator: Optional[bool] - - #: device brand. - manufacturer: Optional[str] - - #: device name. - device: Optional[str] - - #: device Operating System. - device_software: Optional[str] - - #: device metadata. - device_meta: Optional[DeviceMeta] - - #: client platform name. - platform: Optional[ClientPlatformEnum] - - #: platform package ID with app data and device. - platform_package_id: Optional[str] - - #: Express app version. - app_version: Optional[str] - - #: session locale. - locale: Optional[str] - - #: host from which user sent message. - host: str - - @property - def upn(self) -> Optional[str]: - """User principal name. - - https://docs.microsoft.com/en-us/windows/win32/adschema/a-userprincipalname - """ - if self.ad_login and self.ad_domain: - return "{0}@{1}".format(self.ad_login, self.ad_domain) - - return None - - -class IncomingMessage(BaseModel): - """ - Message that was received by bot and should be handled. - - Warning: - `file` is deprecated field for botx api v4+. - """ - - #: message event id on which bot should answer. - sync_id: UUID - - #: command for bot. - command: Command - - #: file attached to message. - file: Optional[File] = None - - #: meta info for downloading files - async_files: List[MetaFile] = Field(default_factory=list) - - #: information about user from which message was received. - user: Sender = Field(..., alias="from") - - #: ID of message whose ui element was triggered to send this message. - source_sync_id: Optional[UUID] = None - - #: id of bot that should handle message. - bot_id: UUID - - #: additional entities that can be received by bot. - entities: EntityList = Field([]) - - #: attached documents and files to message. - attachments: AttachList = Field([]) - - class Config(BaseConfig): - allow_population_by_field_name = True - - @validator("file", always=True, pre=True) - def skip_file_validation( - cls, - file: Optional[Union[dict, File]], # noqa: N805 - ) -> Optional[File]: - """Skip validation for incoming file since users have not such limits as bot. - - Arguments: - file: file data that should be used for building file instance. - - Returns: - Constructed file. - """ - if isinstance(file, File): - return file - elif file is not None: - return File.construct(**file) - - return None diff --git a/botx/models/messages/message.py b/botx/models/messages/message.py deleted file mode 100644 index 88e98aec..00000000 --- a/botx/models/messages/message.py +++ /dev/null @@ -1,174 +0,0 @@ -"""Definition of message object that is used in all bot handlers.""" -from __future__ import annotations - -from functools import partial -from typing import Any, Dict, List, Optional, Type -from uuid import UUID - -from botx.bots import bots -from botx.models.attachments import AttachList -from botx.models.chats import ChatTypes -from botx.models.datastructures import State -from botx.models.entities import EntityList -from botx.models.enums import CommandTypes -from botx.models.files import File, MetaFile -from botx.models.messages.incoming_message import Command, IncomingMessage, Sender -from botx.models.messages.sending.credentials import SendingCredentials - - -class _ProxyProperty: - def __init__(self, proxy_attribute_name: str, *nested_fields: str) -> None: - self.proxy_attribute_name = proxy_attribute_name - self.nested_fields = list(nested_fields) - - def __get__(self, instance: Message, _owner: Type[Message]) -> Any: - proxy_object = getattr(instance, self.proxy_attribute_name) - accessed_result = proxy_object - for nested_field in self.nested_fields: - accessed_result = getattr(accessed_result, nested_field) - return accessed_result - - def __set_name__(self, _owner: Type[Message], name: str) -> None: - self.nested_fields.append(name) - - -def _proxy_property(proxy_attribute_name: str, *nested_fields: str) -> Any: - return _ProxyProperty(proxy_attribute_name, *nested_fields) - - -_message_proxy_property = partial(_proxy_property, "incoming_message") -_user_proxy_property = partial(_message_proxy_property, "user") - - -class Message: - """Message that is used in handlers.""" - - #: incoming message from BotX. - incoming_message: IncomingMessage - - #: bot that handles this message processing. - bot: "bots.Bot" - - #: state of message during processing. - state: State - - #: ID of message event. - sync_id: UUID = _message_proxy_property() - - #: ID of message whose ui element was triggered to send this message. - source_sync_id: Optional[UUID] = _message_proxy_property() - - #: ID of bot that handles message in Express. - bot_id: UUID = _message_proxy_property() - - #: access to command information. - command: Command = _message_proxy_property() - - #: command body. - body: str = _message_proxy_property("command") - - #: command metadata. - metadata: dict = _message_proxy_property("command") - - #: file from message. - file: Optional[File] = _message_proxy_property() - - #: Meta for download files. - async_files: List[MetaFile] = _message_proxy_property() - - #: attachment from message v4+ - attachments: AttachList = _message_proxy_property() - - #: information about user that sent message. - user: Sender = _message_proxy_property() - - #: HUID of user. - user_huid: Optional[UUID] = _user_proxy_property() - - #: AD login of user. - ad_login: Optional[str] = _user_proxy_property() - - #: AD domain of user. - ad_domain: Optional[str] = _user_proxy_property() - - #: ID of chat from which message was received. - group_chat_id: Optional[UUID] = _user_proxy_property() - - #: type of chat. - chat_type: Optional[ChatTypes] = _user_proxy_property() - - #: host of CTS from which message was received. - host: str = _user_proxy_property() - - #: external entities in message (mentions, forwards, etc) - entities: EntityList = _message_proxy_property() - - #: credentials from message for using in requests. - credentials: SendingCredentials - - #: flag for marking that message was received from button. - sent_from_button: bool - - def __init__(self, message: IncomingMessage, bot: "bots.Bot") -> None: - """Initialize and update fields. - - Arguments: - message: incoming message. - bot: bot that handles message processing. - """ - self.incoming_message = message - self.bot = bot - - self.state = State() - - self.credentials = SendingCredentials( - sync_id=self.sync_id, - bot_id=self.bot_id, - host=self.host, - chat_id=self.group_chat_id, - ) - - @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. - """ - return cls(IncomingMessage(**message), bot) - - @property - def is_forward(self) -> bool: - """Check this message on forwarding. - - Returns: - bool: True if message is forward else False - """ - try: # noqa: WPS503 - self.entities.forward # noqa: WPS428 - except AttributeError: - return False - else: - return True - - @property - def is_system_event(self) -> bool: - """Check if message is a system event. - - Returns: - bool: Result of check. - """ - return self.command.command_type == CommandTypes.system - - @property - def data(self) -> Dict[Any, Any]: # noqa: WPS110 - """Concatenated metadata and data from UI element. - - Returns: - dict: Concatenated metadata and data. - """ - return {**self.metadata, **self.incoming_message.command.data_dict} diff --git a/botx/models/messages/sending/__init__.py b/botx/models/messages/sending/__init__.py deleted file mode 100644 index c62ba5ce..00000000 --- a/botx/models/messages/sending/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition for entities that are used in sending messages.""" diff --git a/botx/models/messages/sending/credentials.py b/botx/models/messages/sending/credentials.py deleted file mode 100644 index 86540b58..00000000 --- a/botx/models/messages/sending/credentials.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Definition for sending credentials.""" - -from typing import Optional -from uuid import UUID - -from botx.models.base import BotXBaseModel - - -class SendingCredentials(BotXBaseModel): - """Credentials that are required to send command or notification result.""" - - #: message event id. - sync_id: Optional[UUID] = None - - #: id of message that will be sent. - message_id: Optional[UUID] = None - - #: chat id in which bot should send message. - chat_id: Optional[UUID] = None - - #: bot that handles message. - bot_id: Optional[UUID] = None - - #: host on which bot answers. - host: Optional[str] = None - - #: token that is used for bot authorization on requests to BotX API. - token: Optional[str] = None diff --git a/botx/models/messages/sending/markup.py b/botx/models/messages/sending/markup.py deleted file mode 100644 index 8b5ba892..00000000 --- a/botx/models/messages/sending/markup.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Definition for markup attached to sent message.""" - -from typing import List, Optional, Type, TypeVar - -from botx.models.base import BotXBaseModel -from botx.models.buttons import BubbleElement, Button, ButtonOptions, KeyboardElement - -TUIElement = TypeVar("TUIElement", bound=Button) - - -class MessageMarkup(BotXBaseModel): - """Collection for bubbles and keyboard with some helper methods.""" - - #: bubbles that will be attached to message. - bubbles: List[List[BubbleElement]] = [] - - #: keyboard elements that will be attached to message. - keyboard: List[List[KeyboardElement]] = [] - - def add_bubble( # noqa: WPS211 - self, - command: str, - label: Optional[str] = None, - data: Optional[dict] = None, # noqa: WPS110 - options: Optional[ButtonOptions] = 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. - options: add special effects 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, - opts=options, - 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, - opts=element.opts, - new_row=new_row, - ) - - def add_keyboard_button( # noqa: WPS211 - self, - command: str, - label: Optional[str] = None, - data: Optional[dict] = None, # noqa: WPS110 - options: Optional[ButtonOptions] = 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. - options: add special effects to keyboard button. - 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, - opts=options, - 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, - opts=element.opts, - 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, # noqa: WPS110 - opts: Optional[ButtonOptions] = 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. - opts: add special effects ui element. - new_row: place ui element on new row or on current. - """ - element = ui_cls( - command=command, - label=label, - data=(data or {}), - opts=(opts or ButtonOptions()), - ) - - if new_row: - ui_array.append([element]) - return - - if not ui_array: - ui_array.append([]) - - ui_array[-1].append(element) diff --git a/botx/models/messages/sending/message.py b/botx/models/messages/sending/message.py deleted file mode 100644 index 6b8aa470..00000000 --- a/botx/models/messages/sending/message.py +++ /dev/null @@ -1,651 +0,0 @@ -"""Message that is sent from bot.""" -import re -from typing import Any # noqa: WPS235 -from typing import BinaryIO, Dict, List, Optional, TextIO, Tuple, Union, cast -from uuid import UUID - -from botx.models.buttons import ButtonOptions -from botx.models.entities import ChatMention, Mention, UserMention -from botx.models.enums import MentionTypes -from botx.models.files import File -from botx.models.messages.message import Message -from botx.models.messages.sending.credentials import SendingCredentials -from botx.models.messages.sending.markup import MessageMarkup -from botx.models.messages.sending.options import MessageOptions, NotificationOptions -from botx.models.messages.sending.payload import MessagePayload -from botx.models.typing import AvailableRecipients, BubbleMarkup, KeyboardMarkup - -try: - from typing import Final # noqa: WPS433 -except ImportError: - from typing_extensions import Final # type: ignore # noqa: WPS433, WPS440, F401 - -ARGUMENTS_DUPLICATION_ERROR = ( - "{0} can not be passed along with manual validated_values for it" -) - -EMBED_MENTION_TEMPLATE = ( - "" # noqa: WPS326 -) -EMBED_MENTION_RE: Final = re.compile( - r".+?):(?P[0-9a-f\-]+?):" - r"(?P[0-9a-f\-]+?):(?P.+?)??>", # noqa: WPS326 C812 -) - - -# currently I have no idea how to clean this. -class SendingMessage: # noqa: WPS214 - """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, - message_id: Optional[UUID] = None, - recipients: Optional[AvailableRecipients] = 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, - metadata: Optional[Dict[str, Any]] = None, - embed_mentions: bool = False, - ) -> 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. - message_id: custom id of new message. - 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. - metadata: message metadata. - embed_mentions: get mentions from text. - """ - self.credentials: SendingCredentials = _build_credentials( - bot_id=bot_id, - host=host, - sync_id=sync_id, - message_id=message_id, - chat_id=chat_id, - credentials=(credentials.copy() if credentials else credentials), - ) - - options = _build_options( - recipients=recipients, - mentions=mentions, - notification_options=notification_options, - options=options, - ) - if embed_mentions: - updated_text, found_mentions = self._find_and_replace_embed_mentions(text) - - text = updated_text - options.mentions = found_mentions - options.raw_mentions = True - - self.payload: MessagePayload = MessagePayload( - text=text, - metadata=metadata or {}, - file=file, - markup=_build_markup(bubbles=bubbles, keyboard=keyboard, markup=markup), - options=options, - ) - - @classmethod - def from_message( - cls, - *, - text: str = "", - file: Optional[File] = None, - message: Message, - embed_mentions: bool = False, - ) -> "SendingMessage": - """Build message for sending from incoming message. - - Arguments: - text: text for message. - file: file attached to message. - message: incoming message. - embed_mentions: get mentions from text. - - Returns: - Built message. - """ - return cls( - text=text, - file=file, - sync_id=message.sync_id, - chat_id=message.group_chat_id, - bot_id=message.bot_id, - host=message.host, - embed_mentions=embed_mentions, - ) - - @classmethod - def make_mention_embeddable(cls, mention: Mention) -> str: - """Get mention as string, which can be embed in text. - - Arguments: - mention: mention for embedding. - - Raises: - NotImplementedError: If unsupported mention type was passed. - - Returns: - Formatted mention. - """ - if mention.mention_type in {MentionTypes.user, MentionTypes.contact}: - assert isinstance(mention.mention_data, UserMention) # for mypy - mentioned_entity_id = mention.mention_data.user_huid - elif mention.mention_type in {MentionTypes.chat, MentionTypes.channel}: - assert isinstance(mention.mention_data, ChatMention) # for mypy - mentioned_entity_id = mention.mention_data.group_chat_id - else: - raise NotImplementedError("Unsupported mention type") - - mention_name = mention.mention_data.name or "" - - return EMBED_MENTION_TEMPLATE.format( - mention_type=mention.mention_type, - mentioned_entity_id=mentioned_entity_id, - mention_id=mention.mention_id, - mention_name=mention_name, - ) - - @classmethod - def build_embeddable_user_mention( - cls, - user_huid: UUID, - name: Optional[str] = None, - mention_id: Optional[UUID] = None, - ) -> str: - """Get user mention as string, which can be embed in text. - - Arguments: - user_huid: user id to mention. - name: for overriding mention name. - mention_id: mention id (if not passed, will be generated). - - Returns: - Formatted mention. - """ - mention = Mention.build_from_values( - MentionTypes.user, - user_huid, - name, - mention_id, - ) - - return cls.make_mention_embeddable(mention) - - @classmethod - def build_embeddable_contact_mention( - cls, - user_huid: UUID, - name: Optional[str] = None, - mention_id: Optional[UUID] = None, - ) -> str: - """Get contact mention as string, which can be embed in text. - - Arguments: - user_huid: user id to mention. - name: for overriding mention name. - mention_id: mention id (if not passed, will be generated). - - Returns: - Formatted mention. - """ - mention = Mention.build_from_values( - MentionTypes.contact, - user_huid, - name, - mention_id, - ) - - return cls.make_mention_embeddable(mention) - - @classmethod - def build_embeddable_chat_mention( - cls, - group_chat_id: UUID, - name: Optional[str] = None, - mention_id: Optional[UUID] = None, - ) -> str: - """Get chat mention as string, which can be embed in text. - - Arguments: - group_chat_id: chat id to mention. - name: for overriding mention name. - mention_id: mention id (if not passed, will be generated). - - Returns: - Formatted mention. - """ - mention = Mention.build_from_values( - MentionTypes.chat, - group_chat_id, - name, - mention_id, - ) - - return cls.make_mention_embeddable(mention) - - @classmethod - def build_embeddable_channel_mention( - cls, - group_chat_id: UUID, - name: Optional[str] = None, - mention_id: Optional[UUID] = None, - ) -> str: - """Get channel mention as string, which can be embed in text. - - Arguments: - group_chat_id: channel id to mention. - name: for overriding mention name. - mention_id: mention id (if not passed, will be generated). - - Returns: - Formatted mention. - """ - mention = Mention.build_from_values( - MentionTypes.channel, - group_chat_id, - name, - mention_id, - ) - - return cls.make_mention_embeddable(mention) - - @property - def text(self) -> str: - """Text in message.""" - return self.payload.text - - @text.setter - def text(self, text: str) -> None: - """Text in message.""" - self.payload.text = text - - @property - def metadata(self) -> Dict[str, Any]: - """Metadata in message.""" - return self.payload.metadata - - @metadata.setter - def metadata(self, metadata: Dict[str, Any]) -> None: - self.payload.metadata = metadata - - @property - def file(self) -> Optional[File]: - """File attached to message.""" - return self.payload.file - - @file.setter - 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 - 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 - 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 - 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_id - - @chat_id.setter - def chat_id(self, chat_id: UUID) -> None: - """Chat id in which message should be sent.""" - self.credentials.chat_id = chat_id - - @property - def bot_id(self) -> UUID: - """Bot id that handles message.""" - return cast(UUID, self.credentials.bot_id) - - @bot_id.setter - 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 - 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 == "all": - self.payload.options.recipients = [] - - 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 == "all": - self.payload.options.recipients = [] - - self.payload.options.recipients.extend(recipients) - - def add_bubble( # noqa: WPS211 - self, - command: str, - label: Optional[str] = None, - data: Optional[dict] = None, # noqa: WPS110 - options: Optional[ButtonOptions] = 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. - options: add special effects to bubble. - new_row: place bubble on new row or on current. - """ - self.payload.markup.add_bubble(command, label, data, options, new_row=new_row) - - def add_keyboard_button( # noqa: WPS211 - self, - command: str, - label: Optional[str] = None, - data: Optional[dict] = None, # noqa: WPS110 - options: Optional[ButtonOptions] = 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. - options: add special effects to keyboard button. - new_row: place keyboard on new row or on current. - """ - self.payload.markup.add_keyboard_button( - command, - label, - data, - options, - 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 _find_and_replace_embed_mentions( # noqa: WPS210 - self, - text: str, - ) -> Tuple[str, List[Mention]]: - mentions = [] - - match = EMBED_MENTION_RE.search(text) - while match: - mention_dict = match.groupdict() - embed_mention = match.group(0) - - mention = Mention.build_from_values( - MentionTypes(mention_dict["mention_type"]), - UUID(mention_dict["mentioned_entity_id"]), - mention_dict["name"], - UUID(mention_dict["mention_id"]), - ) - - text = text.replace(embed_mention, mention.to_botx_format()) - mentions.append(mention) - - match = EMBED_MENTION_RE.search(text) - - return text, mentions - - -def _build_credentials( # noqa: WPS211 - bot_id: Optional[UUID] = None, - host: Optional[str] = None, - sync_id: Optional[UUID] = None, - message_id: Optional[UUID] = None, - chat_id: Optional[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. - message_id: id of new message. - chat_id: chat id. - credentials: message credentials. - - Returns: - Credentials for message. - - Raises: - AssertionError: raised if credentials were passed with separate parameters. - """ - if bot_id and host: - if credentials is not None: - raise AssertionError( - ARGUMENTS_DUPLICATION_ERROR.format("MessageCredentials"), - ) - - return SendingCredentials( - bot_id=bot_id, - host=host, - sync_id=sync_id, - chat_id=chat_id, - message_id=message_id, - ) - - if credentials is None: - raise AssertionError( - "MessageCredentials or manual validated_values should be passed", - ) - - if credentials.message_id is None: - credentials.message_id = message_id - - return credentials - - -def _build_markup( - 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. - - Raises: - AssertionError: raised if markup were passed with separate parameters. - """ - if bubbles is not None or keyboard is not None: - if markup is not None: - raise AssertionError( - "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( - recipients: Optional[AvailableRecipients] = None, - mentions: Optional[List[Mention]] = None, - notification_options: Optional[NotificationOptions] = None, - options: Optional[MessageOptions] = None, -) -> MessageOptions: - """Build 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. - - Raises: - AssertionError: raised if options were passed with separate parameters. - """ - if mentions or recipients or notification_options: - if options is not None: - raise AssertionError(ARGUMENTS_DUPLICATION_ERROR.format("MessageOptions")) - return MessageOptions( - recipients=recipients or "all", - mentions=mentions or [], - notifications=notification_options or NotificationOptions(), - ) - - return options or MessageOptions() diff --git a/botx/models/messages/sending/options.py b/botx/models/messages/sending/options.py deleted file mode 100644 index 962d7e62..00000000 --- a/botx/models/messages/sending/options.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Special options for message.""" - -from typing import List - -from botx.models.base import BotXBaseModel -from botx.models.entities import Mention -from botx.models.typing import AvailableRecipients - - -class ResultPayloadOptions(BotXBaseModel): - """Options for `notification` and `command_result` API entities.""" - - #: don't show next user's input in chat - silent_response: bool = False - - -class NotificationOptions(BotXBaseModel): - """Configurations for message notifications.""" - - #: show notification about message. - send: bool = True - - #: break mute on bot messages. - force_dnd: bool = False - - -class MessageOptions(BotXBaseModel): - """Message options configuration.""" - - #: users that should receive message. - recipients: AvailableRecipients = "all" - - #: attached to message mentions. - mentions: List[Mention] = [] - - #: don't show next user's input in chat - silent_response: bool = False - - #: deliver message only if stealth mode enabled - stealth_mode: bool = False - - #: use in-text mentions - raw_mentions: bool = False - - #: notification configuration. - notifications: NotificationOptions = NotificationOptions() diff --git a/botx/models/messages/sending/payload.py b/botx/models/messages/sending/payload.py deleted file mode 100644 index b61b30c4..00000000 --- a/botx/models/messages/sending/payload.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Payload for messages.""" -from __future__ import annotations - -from typing import Any, Dict, List, Optional - -from pydantic import Field - -from botx.models.base import BotXBaseModel -from botx.models.constants import MAXIMUM_TEXT_LENGTH -from botx.models.entities import Mention -from botx.models.files import File -from botx.models.messages.sending.markup import MessageMarkup -from botx.models.messages.sending.options import MessageOptions, NotificationOptions -from botx.models.typing import BubbleMarkup, KeyboardMarkup - - -class MessagePayload(BotXBaseModel): - """Message payload configuration.""" - - #: message text. - text: str = Field("", max_length=MAXIMUM_TEXT_LENGTH) - - #: message metadata. - metadata: Dict[str, Any] = {} - - #: file attached to message. - file: Optional[File] = None - - #: message markup. - markup: MessageMarkup = MessageMarkup() - - #: message configuration. - options: MessageOptions = MessageOptions() - - -class UpdatePayload(BotXBaseModel): - """Payload for message edition.""" - - #: new message text. - text: Optional[str] = Field(None, max_length=MAXIMUM_TEXT_LENGTH) - - #: file attached to message. - file: Optional[File] = None - - #: new message bubbles. - keyboard: Optional[KeyboardMarkup] = None - - #: new message keyboard. - bubbles: Optional[BubbleMarkup] = None - - #: new message mentions. - mentions: Optional[List[Mention]] = None - - #: new message options. - opts: Optional[NotificationOptions] = None - - #: message metadata. - metadata: Optional[Dict[str, Any]] = None - - @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 - - @classmethod - def from_sending_payload(cls, payload: MessagePayload) -> UpdatePayload: - """Create new update payload from existing payload for new message. - - Arguments: - payload: payload that can be used for sending new message. - - Returns: - Created payload for update. - """ - update = cls() - update.text = payload.text or None - update.set_markup(payload.markup) - update.mentions = payload.options.mentions - update.file = payload.file - update.metadata = payload.metadata - return update diff --git a/botx/models/method_callbacks.py b/botx/models/method_callbacks.py new file mode 100644 index 00000000..dc248da9 --- /dev/null +++ b/botx/models/method_callbacks.py @@ -0,0 +1,21 @@ +from typing import Any, Dict, List, Literal, Union +from uuid import UUID + +from botx.models.api_base import VerifiedPayloadBaseModel + + +class BotAPIMethodSuccessfulCallback(VerifiedPayloadBaseModel): + sync_id: UUID + status: Literal["ok"] + result: Dict[str, Any] + + +class BotAPIMethodFailedCallback(VerifiedPayloadBaseModel): + sync_id: UUID + status: Literal["error"] + reason: str + errors: List[str] + error_data: Dict[str, Any] + + +BotXMethodCallback = Union[BotAPIMethodSuccessfulCallback, BotAPIMethodFailedCallback] diff --git a/botx/models/smartapps.py b/botx/models/smartapps.py deleted file mode 100644 index 45169b4e..00000000 --- a/botx/models/smartapps.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Definition of smartapp object.""" - -from typing import Any, BinaryIO, Dict, List, Optional, TextIO, Union -from uuid import UUID - -from botx.models.base import BotXBaseModel -from botx.models.files import File, MetaFile -from botx.models.messages.message import Message - - -class SendingSmartAppEvent(BotXBaseModel): - """SmartApp event with data.""" - - #: unique request id - ref: Optional[UUID] = None - - #: smartapp id - smartapp_id: UUID - - #: event data - data: Dict[str, Any] # noqa: WPS110 - - #: event options - opts: Dict[str, Any] = {} - - #: version of protocol smartapp <-> bot - smartapp_api_version: int - - #: smartapp chat - group_chat_id: Optional[UUID] - - #: files - files: List[File] = [] - - #: file's meta to upload - async_files: List[MetaFile] = [] - - @classmethod - def from_message( - cls, - data: Dict[str, Any], # noqa: WPS110 - message: Message, - ) -> "SendingSmartAppEvent": - """Build smartapp event from message. - - Arguments: - data: smartapp's data. - message: incoming message. - - Returns: - Built smartapp event. - """ - return cls( - ref=message.data["ref"], - smartapp_id=message.data["smartapp_id"], - data=data, - opts=message.data["opts"], - smartapp_api_version=message.data["smartapp_api_version"], - group_chat_id=message.group_chat_id, - ) - - def add_file( - self, - file: Union[TextIO, BinaryIO, File], - filename: Optional[str] = None, - ) -> None: - """Attach file to smartapp. - - 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.files.append(file) - else: - self.files.append(File.from_file(file, filename=filename)) - - -class SendingSmartAppNotification(BotXBaseModel): - """SmartApp notification with counter.""" - - #: smartapp chat - group_chat_id: Optional[UUID] - - #: unread notifications count - smartapp_counter: int - - #: event options - opts: Dict[str, Any] = {} - - #: version of protocol smartapp <-> bot - smartapp_api_version: int - - @classmethod - def from_message( - cls, - smartapp_counter: int, - message: Message, - ) -> "SendingSmartAppNotification": - """Build smartapp notification from message. - - Arguments: - smartapp_counter: smartapp notification counter. - message: incoming message. - - Returns: - Built smartapp notification. - """ - return cls( - smartapp_counter=smartapp_counter, - opts=message.data["opts"], - smartapp_api_version=message.data["smartapp_api_version"], - group_chat_id=message.group_chat_id, - ) diff --git a/botx/models/status.py b/botx/models/status.py index 7433a49e..f7f0b930 100644 --- a/botx/models/status.py +++ b/botx/models/status.py @@ -1,28 +1,100 @@ -"""Module of model for status recipient.""" -from typing import Optional +from dataclasses import asdict, dataclass +from typing import Any, Dict, List, Literal, NewType, Optional, Union from uuid import UUID -from botx import ChatTypes -from botx.models.base import BotXBaseModel +from pydantic import validator +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.enums import APIChatTypes, ChatTypes, convert_chat_type_to_domain +from botx.models.message.incoming_message import IncomingMessage -class StatusRecipient(BotXBaseModel): - """Model of recipients in status request.""" +BotMenu = NewType("BotMenu", Dict[str, str]) - #: bot that request status + +@dataclass +class StatusRecipient: bot_id: UUID + huid: UUID + ad_login: Optional[str] + ad_domain: Optional[str] + is_admin: Optional[bool] + chat_type: ChatTypes - #: user that request status - user_huid: UUID + @classmethod + def from_incoming_message( + cls, + incoming_message: IncomingMessage, + ) -> "StatusRecipient": + return StatusRecipient( + bot_id=incoming_message.bot.id, + huid=incoming_message.sender.huid, + ad_login=incoming_message.sender.ad_login, + ad_domain=incoming_message.sender.ad_domain, + is_admin=incoming_message.sender.is_chat_admin, + chat_type=incoming_message.chat.type, + ) - #: user's ad_login - ad_login: Optional[str] - #: user's ad_domain +class BotAPIStatusRecipient(VerifiedPayloadBaseModel): + bot_id: UUID + user_huid: UUID + ad_login: Optional[str] ad_domain: Optional[str] - - #: user has admin role is_admin: Optional[bool] + chat_type: APIChatTypes - #: chat type - chat_type: ChatTypes + @validator("ad_login", "ad_domain", "is_admin", pre=True) + @classmethod + def replace_empty_string( + cls, + field_value: Union[str, bool], + ) -> Union[str, bool, None]: + if field_value == "": + return None + + return field_value + + def to_domain(self) -> StatusRecipient: + return StatusRecipient( + bot_id=self.bot_id, + huid=self.user_huid, + ad_login=self.ad_login, + ad_domain=self.ad_domain, + is_admin=self.is_admin, + chat_type=convert_chat_type_to_domain(self.chat_type), + ) + + +@dataclass +class BotAPIBotMenuItem: + description: str + body: str + name: str + + +BotAPIBotMenu = List[BotAPIBotMenuItem] + + +@dataclass +class BotAPIStatusResult: + commands: BotAPIBotMenu + enabled: Literal[True] = True + status_message: Optional[str] = None + + +@dataclass +class BotAPIStatus: + result: BotAPIStatusResult + status: Literal["ok"] = "ok" + + +def build_bot_status_response(bot_menu: BotMenu) -> Dict[str, Any]: + commands = [ + BotAPIBotMenuItem(body=command, name=command, description=description) + for command, description in bot_menu.items() + ] + + status = BotAPIStatus( + result=BotAPIStatusResult(status_message="Bot is working", commands=commands), + ) + return asdict(status) diff --git a/botx/models/stickers.py b/botx/models/stickers.py index c35f1686..05610b6a 100644 --- a/botx/models/stickers.py +++ b/botx/models/stickers.py @@ -1,71 +1,87 @@ -"""Models for stickers.""" -from datetime import datetime +from dataclasses import dataclass from typing import List, Optional from uuid import UUID -from botx.models.base import BotXBaseModel +from botx.async_buffer import AsyncBufferWritable +from botx.bot.contextvars import bot_var -class Pagination(BotXBaseModel): - """Model of pagination.""" - - #: cursor hash - after: Optional[str] +@dataclass +class Sticker: + """Sticker from sticker pack. + Attributes: + id: Sticker id. + emoji: Sticker emoji. + link: Sticker image link. -class Sticker(BotXBaseModel): - """Model of sticker from request by id.""" + """ id: UUID emoji: str - link: str - inserted_at: datetime - updated_at: datetime - deleted_at: Optional[datetime] + image_link: str + async def download( + self, + async_buffer: AsyncBufferWritable, + ) -> None: + bot = bot_var.get() -class StickerFromPack(BotXBaseModel): - """Model of sticker from sticker pack.""" + response = await bot._httpx_client.get(self.image_link) # noqa: WPS437 + response.raise_for_status() - id: UUID - emoji: str - link: str - preview: str + await async_buffer.write(response.content) + await async_buffer.seek(0) -class StickerPackPreview(BotXBaseModel): - """Model of sticker pack from pack list.""" - id: UUID - name: str - preview: Optional[str] - public: Optional[bool] - stickers_count: int - stickers_order: Optional[List[UUID]] - inserted_at: datetime - updated_at: Optional[datetime] - deleted_at: Optional[datetime] +@dataclass +class StickerPack: + """Sticker pack. + Attributes: + id: Sticker pack id. + name: Sticker pack name. + is_public: Is public pack. + stickers: Stickers data. -class StickerPackList(BotXBaseModel): - """Full model of sticker pack list response.""" + """ - #: list of sticker packs - packs: List[StickerPackPreview] + id: UUID + name: str + is_public: bool + stickers: List[Sticker] - #: cursor - pagination: Pagination +@dataclass +class StickerPackFromList: + """Sticker pack from list. -class StickerPack(BotXBaseModel): - """Model of sticker pack from request by id.""" + Attributes: + id: Sticker pack id. + name: Sticker pack name. + is_public: Is public pack + stickers_count: Stickers count in pack + sticker_ids: Stickers ids in pack + + """ id: UUID name: str - public: bool - preview: Optional[str] - stickers_order: Optional[List[UUID]] - stickers: List[Sticker] - inserted_at: datetime - updated_at: datetime - deleted_at: Optional[datetime] + is_public: bool + stickers_count: int + sticker_ids: Optional[List[UUID]] # Can be omitted in result + + +@dataclass +class StickerPackPage: + """Sticker pack page. + + Attributes: + sticker_packs: Sticker pack list. + after: Base64 string for pagination. + + """ + + sticker_packs: List[StickerPackFromList] + after: Optional[str] diff --git a/tests/test_bots/test_bots/__init__.py b/botx/models/system_events/__init__.py similarity index 100% rename from tests/test_bots/test_bots/__init__.py rename to botx/models/system_events/__init__.py diff --git a/botx/models/system_events/added_to_chat.py b/botx/models/system_events/added_to_chat.py new file mode 100644 index 00000000..08654cbc --- /dev/null +++ b/botx/models/system_events/added_to_chat.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Literal +from uuid import UUID + +from pydantic import Field + +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.base_command import ( + BotAPIBaseCommand, + BotAPIChatContext, + BotCommandBase, +) +from botx.models.bot_account import BotAccount +from botx.models.chats import Chat +from botx.models.enums import BotAPICommandTypes, convert_chat_type_to_domain + + +@dataclass +class AddedToChatEvent(BotCommandBase): + """Event `system:added_to_chat`. + + Attributes: + huids: List of added to chat user huids. + """ + + huids: List[UUID] + chat: Chat + + +class BotAPIAddedToChatData(VerifiedPayloadBaseModel): + added_members: List[UUID] + + +class BotAPIAddedToChatPayload(VerifiedPayloadBaseModel): + body: Literal["system:added_to_chat"] = "system:added_to_chat" + command_type: Literal[BotAPICommandTypes.SYSTEM] + data: BotAPIAddedToChatData + + +class BotAPIAddedToChat(BotAPIBaseCommand): + payload: BotAPIAddedToChatPayload = Field(..., alias="command") + sender: BotAPIChatContext = Field(..., alias="from") + + def to_domain(self, raw_command: Dict[str, Any]) -> AddedToChatEvent: + return AddedToChatEvent( + bot=BotAccount( + id=self.bot_id, + host=self.sender.host, + ), + raw_command=raw_command, + huids=self.payload.data.added_members, + chat=Chat( + id=self.sender.group_chat_id, + type=convert_chat_type_to_domain(self.sender.chat_type), + ), + ) diff --git a/botx/models/system_events/chat_created.py b/botx/models/system_events/chat_created.py new file mode 100644 index 00000000..3e0c08d1 --- /dev/null +++ b/botx/models/system_events/chat_created.py @@ -0,0 +1,115 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Literal, Optional +from uuid import UUID + +from pydantic import Field + +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.base_command import ( + BotAPIBaseCommand, + BotAPIChatContext, + BotCommandBase, +) +from botx.models.bot_account import BotAccount +from botx.models.chats import Chat +from botx.models.enums import ( + APIChatTypes, + APIUserKinds, + BotAPICommandTypes, + UserKinds, + convert_chat_type_to_domain, + convert_user_kind_to_domain, +) + + +@dataclass +class ChatCreatedMember: + """ChatCreatedEvent member. + + Attributes: + is_admin: Is user admin. + huid: User huid. + username: Username. + kind: User type. + """ + + is_admin: bool + huid: UUID + username: Optional[str] + kind: UserKinds + + +@dataclass +class ChatCreatedEvent(BotCommandBase): + """Event `system:chat_created`. + + Attributes: + sync_id: Event sync id. + chat_id: Created chat id. + chat_name: Created chat name. + chat_type: Created chat type. + host: Created chat cts host. + creator_id: Creator huid. + members: List of created chat members. + """ + + chat: Chat + sync_id: UUID + chat_name: str + creator_id: UUID + members: List[ChatCreatedMember] + + +class BotAPIChatMember(VerifiedPayloadBaseModel): + is_admin: bool = Field(..., alias="admin") + huid: UUID + name: Optional[str] + user_kind: APIUserKinds + + +class BotAPIChatCreatedData(VerifiedPayloadBaseModel): + chat_type: APIChatTypes + creator: UUID + group_chat_id: UUID + members: List[BotAPIChatMember] + name: str + + +class BotAPIChatCreatedPayload(VerifiedPayloadBaseModel): + body: Literal["system:chat_created"] = "system:chat_created" + command_type: Literal[BotAPICommandTypes.SYSTEM] + data: BotAPIChatCreatedData + + +class BotAPIChatCreated(BotAPIBaseCommand): + payload: BotAPIChatCreatedPayload = Field(..., alias="command") + sender: BotAPIChatContext = Field(..., alias="from") + + def to_domain(self, raw_command: Dict[str, Any]) -> ChatCreatedEvent: + members = [ + ChatCreatedMember( + is_admin=member.is_admin, + huid=member.huid, + username=member.name, + kind=convert_user_kind_to_domain(member.user_kind), + ) + for member in self.payload.data.members + ] + + chat = Chat( + id=self.payload.data.group_chat_id, + type=convert_chat_type_to_domain(self.payload.data.chat_type), + ) + + return ChatCreatedEvent( + sync_id=self.sync_id, + bot=BotAccount( + id=self.bot_id, + host=self.sender.host, + ), + chat=chat, + chat_name=self.payload.data.name, + creator_id=self.payload.data.creator, + members=members, + raw_command=raw_command, + ) diff --git a/botx/models/system_events/cts_login.py b/botx/models/system_events/cts_login.py new file mode 100644 index 00000000..9cda8b9d --- /dev/null +++ b/botx/models/system_events/cts_login.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from typing import Any, Dict, Literal +from uuid import UUID + +from pydantic import Field + +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.base_command import ( + BaseBotAPIContext, + BotAPIBaseCommand, + BotCommandBase, +) +from botx.models.bot_account import BotAccount +from botx.models.enums import BotAPICommandTypes + + +@dataclass +class CTSLoginEvent(BotCommandBase): + """Event `system:cts_login`. + + Attributes: + huid: user ID. + """ + + huid: UUID + + +class BotAPICTSLoginData(VerifiedPayloadBaseModel): + user_huid: UUID + + +class BotAPICTSLoginPayload(VerifiedPayloadBaseModel): + body: Literal["system:cts_login"] = "system:cts_login" + command_type: Literal[BotAPICommandTypes.SYSTEM] + data: BotAPICTSLoginData + + +class BotAPICTSLogin(BotAPIBaseCommand): + payload: BotAPICTSLoginPayload = Field(..., alias="command") + sender: BaseBotAPIContext = Field(..., alias="from") + + def to_domain(self, raw_command: Dict[str, Any]) -> CTSLoginEvent: + return CTSLoginEvent( + bot=BotAccount( + id=self.bot_id, + host=self.sender.host, + ), + raw_command=raw_command, + huid=self.payload.data.user_huid, + ) diff --git a/botx/models/system_events/cts_logout.py b/botx/models/system_events/cts_logout.py new file mode 100644 index 00000000..df1f760e --- /dev/null +++ b/botx/models/system_events/cts_logout.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from typing import Any, Dict, Literal +from uuid import UUID + +from pydantic import Field + +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.base_command import ( + BaseBotAPIContext, + BotAPIBaseCommand, + BotCommandBase, +) +from botx.models.bot_account import BotAccount +from botx.models.enums import BotAPICommandTypes + + +@dataclass +class CTSLogoutEvent(BotCommandBase): + """Event `system:cts_logout`. + + Attributes: + huid: user ID. + """ + + huid: UUID + + +class BotAPICTSLogoutData(VerifiedPayloadBaseModel): + user_huid: UUID + + +class BotAPICTSLogoutPayload(VerifiedPayloadBaseModel): + body: Literal["system:cts_logout"] = "system:cts_logout" + command_type: Literal[BotAPICommandTypes.SYSTEM] + data: BotAPICTSLogoutData + + +class BotAPICTSLogout(BotAPIBaseCommand): + payload: BotAPICTSLogoutPayload = Field(..., alias="command") + sender: BaseBotAPIContext = Field(..., alias="from") + + def to_domain(self, raw_command: Dict[str, Any]) -> CTSLogoutEvent: + return CTSLogoutEvent( + bot=BotAccount( + id=self.bot_id, + host=self.sender.host, + ), + raw_command=raw_command, + huid=self.payload.data.user_huid, + ) diff --git a/botx/models/system_events/deleted_from_chat.py b/botx/models/system_events/deleted_from_chat.py new file mode 100644 index 00000000..c5c1c808 --- /dev/null +++ b/botx/models/system_events/deleted_from_chat.py @@ -0,0 +1,57 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Literal +from uuid import UUID + +from pydantic import Field + +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.base_command import ( + BotAPIBaseCommand, + BotAPIChatContext, + BotCommandBase, +) +from botx.models.bot_account import BotAccount +from botx.models.chats import Chat +from botx.models.enums import BotAPICommandTypes, convert_chat_type_to_domain + + +@dataclass +class DeletedFromChatEvent(BotCommandBase): + """Event `system:deleted_from_chat`. + + Attributes: + huids: List of deleted from chat user huids. + chat_id: Chat where the user was deleted from. + """ + + huids: List[UUID] + chat: Chat + + +class BotAPIDeletedFromChatData(VerifiedPayloadBaseModel): + deleted_members: List[UUID] + + +class BotAPIDeletedFromChatPayload(VerifiedPayloadBaseModel): + body: Literal["system:deleted_from_chat"] = "system:deleted_from_chat" + command_type: Literal[BotAPICommandTypes.SYSTEM] + data: BotAPIDeletedFromChatData + + +class BotAPIDeletedFromChat(BotAPIBaseCommand): + payload: BotAPIDeletedFromChatPayload = Field(..., alias="command") + sender: BotAPIChatContext = Field(..., alias="from") + + def to_domain(self, raw_command: Dict[str, Any]) -> DeletedFromChatEvent: + return DeletedFromChatEvent( + bot=BotAccount( + id=self.bot_id, + host=self.sender.host, + ), + raw_command=raw_command, + huids=self.payload.data.deleted_members, + chat=Chat( + id=self.sender.group_chat_id, + type=convert_chat_type_to_domain(self.sender.chat_type), + ), + ) diff --git a/botx/models/system_events/internal_bot_notification.py b/botx/models/system_events/internal_bot_notification.py new file mode 100644 index 00000000..17d139aa --- /dev/null +++ b/botx/models/system_events/internal_bot_notification.py @@ -0,0 +1,73 @@ +from dataclasses import dataclass +from typing import Any, Dict, Literal + +from pydantic import Field + +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.base_command import ( + BotAPIBaseCommand, + BotAPIChatContext, + BotAPIUserContext, + BotCommandBase, +) +from botx.models.bot_account import BotAccount +from botx.models.bot_sender import BotSender +from botx.models.chats import Chat +from botx.models.enums import BotAPICommandTypes, convert_chat_type_to_domain + + +@dataclass +class InternalBotNotificationEvent(BotCommandBase): + """Event `system:internal_bot_notification`. + + Attributes: + data: user data. + opts: request options. + """ + + data: Dict[str, Any] + opts: Dict[str, Any] + chat: Chat + sender: BotSender + + +class BotAPIInternalBotNotificationData(VerifiedPayloadBaseModel): + data: Dict[str, Any] + opts: Dict[str, Any] + + +class BotAPIInternalBotNotificationPayload(VerifiedPayloadBaseModel): + body: Literal[ + "system:internal_bot_notification" + ] = "system:internal_bot_notification" + command_type: Literal[BotAPICommandTypes.SYSTEM] + data: BotAPIInternalBotNotificationData + + +class BotAPIBotContext(BotAPIChatContext, BotAPIUserContext): + """Bot context.""" + + +class BotAPIInternalBotNotification(BotAPIBaseCommand): + payload: BotAPIInternalBotNotificationPayload = Field(..., alias="command") + sender: BotAPIBotContext = Field(..., alias="from") + + def to_domain(self, raw_command: Dict[str, Any]) -> InternalBotNotificationEvent: + return InternalBotNotificationEvent( + bot=BotAccount( + id=self.bot_id, + host=self.sender.host, + ), + raw_command=raw_command, + data=self.payload.data.data, + opts=self.payload.data.opts, + chat=Chat( + id=self.sender.group_chat_id, + type=convert_chat_type_to_domain(self.sender.chat_type), + ), + sender=BotSender( + huid=self.sender.user_huid, + is_chat_admin=self.sender.is_admin, + is_chat_creator=self.sender.is_creator, + ), + ) diff --git a/botx/models/system_events/left_from_chat.py b/botx/models/system_events/left_from_chat.py new file mode 100644 index 00000000..0be7cf8e --- /dev/null +++ b/botx/models/system_events/left_from_chat.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Literal +from uuid import UUID + +from pydantic import Field + +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.base_command import ( + BotAPIBaseCommand, + BotAPIChatContext, + BotCommandBase, +) +from botx.models.bot_account import BotAccount +from botx.models.chats import Chat +from botx.models.enums import BotAPICommandTypes, convert_chat_type_to_domain + + +@dataclass +class LeftFromChatEvent(BotCommandBase): + """Event `system:left_from_chat`. + + Attributes: + huids: List of left from chat user huids. + """ + + huids: List[UUID] + chat: Chat + + +class BotAPILeftFromChatData(VerifiedPayloadBaseModel): + left_members: List[UUID] + + +class BotAPILeftFromChatPayload(VerifiedPayloadBaseModel): + body: Literal["system:left_from_chat"] = "system:left_from_chat" + command_type: Literal[BotAPICommandTypes.SYSTEM] + data: BotAPILeftFromChatData + + +class BotAPILeftFromChat(BotAPIBaseCommand): + payload: BotAPILeftFromChatPayload = Field(..., alias="command") + sender: BotAPIChatContext = Field(..., alias="from") + + def to_domain(self, raw_command: Dict[str, Any]) -> LeftFromChatEvent: + return LeftFromChatEvent( + bot=BotAccount( + id=self.bot_id, + host=self.sender.host, + ), + raw_command=raw_command, + huids=self.payload.data.left_members, + chat=Chat( + id=self.sender.group_chat_id, + type=convert_chat_type_to_domain(self.sender.chat_type), + ), + ) diff --git a/botx/models/system_events/smartapp_event.py b/botx/models/system_events/smartapp_event.py new file mode 100644 index 00000000..a4c8846c --- /dev/null +++ b/botx/models/system_events/smartapp_event.py @@ -0,0 +1,119 @@ +from dataclasses import dataclass +from typing import Any, Dict, List, Literal +from uuid import UUID + +from pydantic import Field + +from botx.models.api_base import VerifiedPayloadBaseModel +from botx.models.async_files import APIAsyncFile, File, convert_async_file_to_domain +from botx.models.base_command import ( + BotAPIBaseCommand, + BotAPIChatContext, + BotAPIDeviceContext, + BotAPIUserContext, + BotCommandBase, +) +from botx.models.bot_account import BotAccount +from botx.models.chats import Chat +from botx.models.enums import ( + BotAPICommandTypes, + convert_chat_type_to_domain, + convert_client_platform_to_domain, +) +from botx.models.message.incoming_message import UserDevice, UserSender + + +@dataclass +class SmartAppEvent(BotCommandBase): + """Event `system:smartapp_event`. + + Attributes: + ref: Unique request id. + smartapp_id: also personnel chat_id. + data: Payload. + opts: Request options. + smartapp_api_version: Protocol version. + sender: Event sender. + """ + + ref: UUID + smartapp_id: UUID + data: Dict[str, Any] # noqa: WPS110 + opts: Dict[str, Any] + smartapp_api_version: int + files: List[File] + chat: Chat + sender: UserSender + + +class BotAPISmartAppData(VerifiedPayloadBaseModel): + ref: UUID + smartapp_id: UUID + data: Dict[str, Any] # noqa: WPS110 + opts: Dict[str, Any] + smartapp_api_version: int + + +class BotAPISmartAppPayload(VerifiedPayloadBaseModel): + body: Literal["system:smartapp_event"] = "system:smartapp_event" + command_type: Literal[BotAPICommandTypes.SYSTEM] + data: BotAPISmartAppData + metadata: Dict[str, Any] + + +class BotAPISmartAppEventContext( + BotAPIUserContext, + BotAPIChatContext, + BotAPIDeviceContext, +): + """Class for merging contexts.""" + + +class BotAPISmartAppEvent(BotAPIBaseCommand): + payload: BotAPISmartAppPayload = Field(..., alias="command") + sender: BotAPISmartAppEventContext = Field(..., alias="from") + async_files: List[APIAsyncFile] + + def to_domain(self, raw_command: Dict[str, Any]) -> SmartAppEvent: + device = UserDevice( + manufacturer=self.sender.manufacturer, + device_name=self.sender.device, + os=self.sender.device_software, + pushes=None, + timezone=None, + permissions=None, + platform=( + convert_client_platform_to_domain(self.sender.platform) + if self.sender.platform + else None + ), + platform_package_id=self.sender.platform_package_id, + app_version=self.sender.app_version, + locale=self.sender.locale, + ) + + sender = UserSender( + huid=self.sender.user_huid, + ad_login=self.sender.ad_login, + ad_domain=self.sender.ad_domain, + username=self.sender.username, + is_chat_admin=self.sender.is_admin, + is_chat_creator=self.sender.is_creator, + device=device, + ) + + return SmartAppEvent( + bot=BotAccount(id=self.bot_id, host=self.sender.host), + raw_command=raw_command, + ref=self.payload.data.ref, + smartapp_id=self.payload.data.smartapp_id, + data=self.payload.data.data, + opts=self.payload.data.opts, + smartapp_api_version=self.payload.data.smartapp_api_version, + files=[convert_async_file_to_domain(file) for file in self.async_files], + chat=Chat( + id=self.sender.group_chat_id, + type=convert_chat_type_to_domain(self.sender.chat_type), + ), + sender=sender, + ) diff --git a/botx/models/typing.py b/botx/models/typing.py deleted file mode 100644 index ea1f213a..00000000 --- a/botx/models/typing.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Aliases for complex types from `typing` for models.""" - -from typing import List, Union -from uuid import UUID - -from botx.models.buttons import BubbleElement, KeyboardElement - -try: - from typing import Literal # noqa: WPS433 -except ImportError: - from typing_extensions import Literal # type: ignore # noqa: WPS433, WPS440, F401 - -BubblesRow = List[BubbleElement] -BubbleMarkup = List[BubblesRow] - -KeyboardRow = List[KeyboardElement] -KeyboardMarkup = List[KeyboardRow] - -AvailableRecipients = Union[List[UUID], Literal["all"]] diff --git a/botx/models/users.py b/botx/models/users.py index bfc1c1c5..fa43e7da 100644 --- a/botx/models/users.py +++ b/botx/models/users.py @@ -1,64 +1,28 @@ -"""Entities for users.""" - +from dataclasses import dataclass from typing import List, Optional from uuid import UUID -from botx.models.base import BotXBaseModel -from botx.models.enums import UserKinds +@dataclass +class UserFromSearch: + """User from search. -class UserInChatCreated(BotXBaseModel): - """User that can be included in data in `system:chat_created` event.""" + Attributes: + huid: User huid. + ad_login: User AD login. + ad_domain: User AD domain. + username: User name. + company: User company. + company_position: User company position. + department: User department. + emails: User emails. + """ - #: user HUID. huid: UUID - - #: type of user. - user_kind: UserKinds - - #: user username. - name: Optional[str] - - #: is user administrator in chat. - admin: bool - - -class UserFromSearch(BotXBaseModel): - """User from search request.""" - - #: HUID of user from search. - user_huid: UUID - - #: AD login of user. ad_login: Optional[str] - - # AD domain of user. ad_domain: Optional[str] - - #: visible username. - name: str - - #: user's company. + username: str company: Optional[str] - - #: user's position. company_position: Optional[str] - - #: user's department. department: Optional[str] - - #: user's emails. emails: List[str] - - -class UserFromChatSearch(BotXBaseModel): - """User from chat search request.""" - - #: is user admin of chat. - admin: bool - - #: HUID of user. - user_huid: UUID - - #: type of user. - user_kind: UserKinds diff --git a/botx/shared.py b/botx/shared.py deleted file mode 100644 index 1a07cda1..00000000 --- a/botx/shared.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Shared config for pydantic dataclasses.""" - -from pydantic import BaseConfig - - -class BotXDataclassConfig(BaseConfig): - """Config for pydantic dataclasses that allows custom types.""" - - arbitrary_types_allowed = True diff --git a/botx/testing/__init__.py b/botx/testing/__init__.py deleted file mode 100644 index 47482079..00000000 --- a/botx/testing/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Definition of entities for using in tests.""" - -from botx.testing.building.builder import MessageBuilder - -try: - from botx.testing.testing_client.client import TestClient # noqa: WPS433 -except ImportError: - TestClient = None # type: ignore # noqa: WPS440 - -__all__ = ("TestClient", "MessageBuilder") # noqa: WPS410 diff --git a/botx/testing/botx_mock/__init__.py b/botx/testing/botx_mock/__init__.py deleted file mode 100644 index 69127fa2..00000000 --- a/botx/testing/botx_mock/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Mock for BotX API for httpx client using Starlette.""" diff --git a/botx/testing/botx_mock/asgi/__init__.py b/botx/testing/botx_mock/asgi/__init__.py deleted file mode 100644 index 01768527..00000000 --- a/botx/testing/botx_mock/asgi/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""ASGI mock for BotX API for using with httpx.""" diff --git a/botx/testing/botx_mock/asgi/application.py b/botx/testing/botx_mock/asgi/application.py deleted file mode 100644 index 836d730d..00000000 --- a/botx/testing/botx_mock/asgi/application.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Definition of Starlette application that is mock for BotX API.""" - -from typing import Any, Dict, List, Sequence, Tuple, Type - -from starlette.applications import Starlette -from starlette.middleware.base import RequestResponseEndpoint -from starlette.routing import Route - -from botx.clients.methods.base import BotXMethod -from botx.testing.botx_mock.asgi.errors import ErrorMiddleware -from botx.testing.botx_mock.asgi.routes import bots # noqa: WPS235 -from botx.testing.botx_mock.asgi.routes import ( - chats, - command, - events, - files, - notification, - notifications, - smartapps, - stickers, - users, -) -from botx.testing.typing import APIMessage, APIRequest - -_ENDPOINTS: Tuple[RequestResponseEndpoint, ...] = ( - # V2 - # bots - bots.get_token, - # V3 - # chats - chats.get_info, - chats.get_bot_chats, - chats.post_add_admin_role, - chats.post_add_user, - chats.post_remove_user, - chats.post_stealth_set, - chats.post_stealth_disable, - chats.post_create, - chats.post_pin_message, - chats.post_unpin_message, - # command - command.post_command_result, - # events - events.post_edit_event, - events.post_reply_event, - # notification - notification.post_notification, - notification.post_notification_direct, - # users - users.get_by_huid, - users.get_by_email, - users.get_by_login, - # notifications - notifications.post_internal_bot_notification, - # files - files.upload_file, - files.download_file, - # stickers - stickers.get_sticker_pack_list, - stickers.get_sticker_pack, - stickers.get_sticker_from_sticker_pack, - stickers.post_add_sticker_into_sticker_pack, - stickers.post_delete_sticker_pack, - stickers.post_delete_sticker_from_sticker_pack, - stickers.post_create_sticker_pack, - stickers.post_edit_sticker_pack, - # smartapps - smartapps.post_smartapp_event, - smartapps.post_smartapp_notification, -) - - -def _create_starlette_routes() -> Sequence[Route]: - routes = [] - - for endpoint in _ENDPOINTS: - url = endpoint.method.__url__ # type: ignore # noqa: WPS609 - method = endpoint.method.__method__ # type: ignore # noqa: WPS609 - routes.append(Route(url, endpoint, methods=[method])) - - return routes - - -def get_botx_asgi_api( - messages: List[APIMessage], - requests: List[APIRequest], - errors: Dict[Type[BotXMethod], Tuple[int, Any]], -) -> Starlette: - """Generate BotX API mock. - - Arguments: - messages: list of message that were sent from bot and should be extended. - requests: all requests that were sent from bot. - errors: errors to be generated by mocked API. - - Returns: - Generated BotX API mock for using with httpx. - """ - botx_app = Starlette(routes=list(_create_starlette_routes())) - botx_app.add_middleware(ErrorMiddleware) - botx_app.state.messages = messages - botx_app.state.requests = requests - botx_app.state.errors = errors - - return botx_app diff --git a/botx/testing/botx_mock/asgi/errors.py b/botx/testing/botx_mock/asgi/errors.py deleted file mode 100644 index d0a13dd3..00000000 --- a/botx/testing/botx_mock/asgi/errors.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Definition of middleware that will generate BotX API errors depending from flag.""" -from typing import Tuple, Type - -from pydantic import BaseModel -from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint -from starlette.requests import Request -from starlette.responses import Response -from starlette.routing import Match - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.testing.botx_mock.asgi.responses import PydanticResponse - - -def _fill_request_scope(request: Request) -> None: - routes = request.app.router.routes - for route in routes: - match, scope = route.matches(request) - if match == Match.FULL: - request.scope = {**request.scope, **scope} - - -def _get_error_from_request( - request: Request, -) -> Tuple[Type[BotXMethod], Tuple[int, BaseModel]]: - _fill_request_scope(request) - endpoint = request.scope["endpoint"] - method = endpoint.method - return method, request.app.state.errors.get(method) - - -def should_generate_error_response(request: Request) -> bool: - """Check if mocked API should generate error response. - - Arguments: - request: request from Starlette route that contains application with required - state. - - Returns: - Result of check. - """ - _, status_and_error_to_raise = _get_error_from_request(request) - return bool(status_and_error_to_raise) - - -def generate_error_response(request: Request) -> Response: - """Generate error response for mocked BotX API. - - Arguments: - request: request from Starlette route that contains application with required - state. - - Returns: - Generated response. - """ - method, response_info = _get_error_from_request(request) - status_code, error_data = response_info - - return PydanticResponse( - APIErrorResponse[BaseModel]( - errors=["error from mock"], - reason="asked_for_error", - error_data=error_data, - ), - status_code=status_code, - ) - - -class ErrorMiddleware(BaseHTTPMiddleware): - """Middleware that will generate error response.""" - - async def dispatch( - self, - request: Request, - call_next: RequestResponseEndpoint, - ) -> Response: - """Generate error response for API call or pass request to mocked endpoint. - - Arguments: - request: request that should be handled. - call_next: next executor for mock. - - Returns: - Mocked response. - """ - if should_generate_error_response(request): - return generate_error_response(request) - - return await call_next(request) diff --git a/botx/testing/botx_mock/asgi/messages.py b/botx/testing/botx_mock/asgi/messages.py deleted file mode 100644 index 7ad26966..00000000 --- a/botx/testing/botx_mock/asgi/messages.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Logic for extending messages and requests collections from test client.""" - -import contextlib - -from starlette.requests import Request - -from botx.clients.methods.base import BotXMethod - - -def add_message_to_collection(request: Request, message: BotXMethod) -> None: - """Add new message to messages collection. - - Arguments: - request: request from Starlette endpoint. - message: message that should be added. - """ - app = request.app - with contextlib.suppress(AttributeError): - app.state.messages.append(message) - - add_request_to_collection(request, message) - - -def add_request_to_collection(http_request: Request, api_request: BotXMethod) -> None: - """Add new API request to requests collection. - - Arguments: - http_request: request from Starlette endpoint. - api_request: API request that should be added. - """ - app = http_request.app - with contextlib.suppress(AttributeError): - app.state.requests.append(api_request) diff --git a/botx/testing/botx_mock/asgi/responses.py b/botx/testing/botx_mock/asgi/responses.py deleted file mode 100644 index 11a84d35..00000000 --- a/botx/testing/botx_mock/asgi/responses.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Common responses for mocks.""" - -import uuid -from typing import Any, Optional, Union - -from pydantic import BaseModel -from starlette.responses import Response - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.command.command_result import CommandResult -from botx.clients.methods.v3.notification.direct_notification import NotificationDirect -from botx.clients.methods.v4.notifications.internal_bot_notification import ( - InternalBotNotification, -) -from botx.clients.types.response_results import ( - InternalBotNotificationResult, - PushResult, -) - - -class PydanticResponse(Response): - """Custom response to encode pydantic model from route.""" - - def __init__( # noqa: WPS211 - self, - model: Optional[BaseModel], - raw_data: Optional[bytes] = None, - status_code: int = 200, - media_type: str = "application/json", - **kwargs: Any, - ) -> None: - """Init custom response. - - Arguments: - model: pydantic model that should be encoded. - raw_data: binary data. - status_code: response HTTP status code. - media_type: content type of response. - kwargs: other arguments to response constructor from starlette. - """ - super().__init__( - raw_data or model.json(by_alias=True), # type: ignore - status_code, - media_type=media_type, - **kwargs, - ) - - -def generate_push_response( - payload: Union[CommandResult, NotificationDirect], -) -> Response: - """Generate response as like new message from bot was pushed. - - Arguments: - payload: pushed message. - - Returns: - Response with sync_id for new message. - """ - sync_id = payload.event_sync_id or uuid.uuid4() - return PydanticResponse( - APIResponse[PushResult](result=PushResult(sync_id=sync_id)), - ) - - -def generate_internal_bot_notification_response( - payload: InternalBotNotification, -) -> Response: - """Generate response as like internal bot notification was sent. - - Arguments: - payload: sent notification. - - Returns: - Response with sync_id for new message. - """ - sync_id = uuid.uuid4() - return PydanticResponse( - APIResponse[InternalBotNotificationResult]( - result=InternalBotNotificationResult(sync_id=sync_id), - ), - ) diff --git a/botx/testing/botx_mock/asgi/routes/__init__.py b/botx/testing/botx_mock/asgi/routes/__init__.py deleted file mode 100644 index c259761e..00000000 --- a/botx/testing/botx_mock/asgi/routes/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition of routes for BotX API mock.""" diff --git a/botx/testing/botx_mock/asgi/routes/bots.py b/botx/testing/botx_mock/asgi/routes/bots.py deleted file mode 100644 index 959ef5b0..00000000 --- a/botx/testing/botx_mock/asgi/routes/bots.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Endpoints for bots resource.""" - -from starlette.requests import Request -from starlette.responses import Response - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v2.bots.token import Token -from botx.testing.botx_mock.asgi.messages import add_request_to_collection -from botx.testing.botx_mock.asgi.responses import PydanticResponse -from botx.testing.botx_mock.binders import bind_implementation_to_method - - -@bind_implementation_to_method(Token) -async def get_token(request: Request) -> Response: - """Handle retrieving token from BotX API request. - - Arguments: - request: starlette request for route. - - Returns: - Return response with new token. - """ - request_data = {**request.path_params, **request.query_params} - payload = Token(**request_data) - add_request_to_collection(request, payload) - return PydanticResponse(APIResponse[str](result="real token")) diff --git a/botx/testing/botx_mock/asgi/routes/chats.py b/botx/testing/botx_mock/asgi/routes/chats.py deleted file mode 100644 index 05fd03a5..00000000 --- a/botx/testing/botx_mock/asgi/routes/chats.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Endpoints for chats resource.""" -import uuid -from datetime import datetime as dt - -from starlette import requests, responses - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.chats import add_admin_role # noqa: WPS235 -from botx.clients.methods.v3.chats import ( - add_user, - chat_list, - create, - info, - pin_message, - remove_user, - stealth_disable, - stealth_set, - unpin_message, -) -from botx.clients.types.response_results import ChatCreatedResult -from botx.models import chats, enums, users -from botx.testing.botx_mock.asgi.messages import add_request_to_collection -from botx.testing.botx_mock.asgi.responses import PydanticResponse -from botx.testing.botx_mock.binders import bind_implementation_to_method - - -@bind_implementation_to_method(info.Info) -async def get_info(request: requests.Request) -> responses.Response: - """Handle retrieving information of chat request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with information of chat. - """ - payload = info.Info.parse_obj(request.query_params) - add_request_to_collection(request, payload) - - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - return PydanticResponse( - APIResponse[chats.ChatFromSearch]( - result=chats.ChatFromSearch( - name="chat name", - chat_type=enums.ChatTypes.group_chat, - creator=uuid.uuid4(), - group_chat_id=payload.group_chat_id, - members=[ - users.UserFromChatSearch( - user_huid=uuid.uuid4(), - user_kind=enums.UserKinds.user, - admin=True, - ), - ], - inserted_at=inserted_at, - ), - ), - ) - - -@bind_implementation_to_method(chat_list.ChatList) -async def get_bot_chats(request: requests.Request) -> responses.Response: - """Return list of bot chats. - - Arguments: - request: HTTP request from Starlette. - - Returns: - List of bot chats. - """ - payload = chat_list.ChatList.parse_obj(request.query_params) - add_request_to_collection(request, payload) - - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - updated_at = dt.fromisoformat("2019-09-29T10:30:48.358586+00:00") - return PydanticResponse( - APIResponse[chats.BotChatList]( - result=chats.BotChatList( - __root__=[ - chats.BotChatFromList( - name="chat name", - description="test", - chat_type=enums.ChatTypes.group_chat, - group_chat_id=uuid.uuid4(), - members=[uuid.uuid4()], - inserted_at=inserted_at, - updated_at=updated_at, - ), - ], - ), - ), - ) - - -@bind_implementation_to_method(add_user.AddUser) -async def post_add_user(request: requests.Request) -> responses.Response: - """Handle adding of user to chat request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with result of adding. - """ - payload = add_user.AddUser.parse_obj(await request.json()) - add_request_to_collection(request, payload) - return PydanticResponse(APIResponse[bool](result=True)) - - -@bind_implementation_to_method(remove_user.RemoveUser) -async def post_remove_user(request: requests.Request) -> responses.Response: - """Handle removing of user to chat request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with result of removing. - """ - payload = remove_user.RemoveUser.parse_obj(await request.json()) - add_request_to_collection(request, payload) - return PydanticResponse(APIResponse[bool](result=True)) - - -@bind_implementation_to_method(stealth_set.StealthSet) -async def post_stealth_set(request: requests.Request) -> responses.Response: - """Handle stealth enabling in chat request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with result of enabling stealth. - """ - payload = stealth_set.StealthSet.parse_obj(await request.json()) - add_request_to_collection(request, payload) - return PydanticResponse(APIResponse[bool](result=True)) - - -@bind_implementation_to_method(stealth_disable.StealthDisable) -async def post_stealth_disable(request: requests.Request) -> responses.Response: - """Handle stealth disabling in chat request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with result of disabling stealth. - """ - payload = stealth_disable.StealthDisable.parse_obj(await request.json()) - add_request_to_collection(request, payload) - return PydanticResponse(APIResponse[bool](result=True)) - - -@bind_implementation_to_method(create.Create) -async def post_create(request: requests.Request) -> responses.Response: - """Handle creation of new chat request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with result of creation. - """ - payload = create.Create.parse_obj(await request.json()) - add_request_to_collection(request, payload) - return PydanticResponse( - APIResponse[ChatCreatedResult](result=ChatCreatedResult(chat_id=uuid.uuid4())), - ) - - -@bind_implementation_to_method(add_admin_role.AddAdminRole) -async def post_add_admin_role(request: requests.Request) -> responses.Response: - """Handle promoting users to admins request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with result of adding. - """ - payload = add_admin_role.AddAdminRole.parse_obj(await request.json()) - add_request_to_collection(request, payload) - return PydanticResponse(APIResponse[bool](result=True)) - - -@bind_implementation_to_method(pin_message.PinMessage) -async def post_pin_message(request: requests.Request) -> responses.Response: - """Handle pinning message in chat request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with result of pinning. - """ - payload = pin_message.PinMessage.parse_obj(await request.json()) - add_request_to_collection(request, payload) - return PydanticResponse(APIResponse[str](result="pinned")) - - -@bind_implementation_to_method(unpin_message.UnpinMessage) -async def post_unpin_message(request: requests.Request) -> responses.Response: - """Handle unpinning message in chat request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with result of unpinning. - """ - payload = unpin_message.UnpinMessage.parse_obj(await request.json()) - add_request_to_collection(request, payload) - return PydanticResponse(APIResponse[str](result="unpinned")) diff --git a/botx/testing/botx_mock/asgi/routes/command.py b/botx/testing/botx_mock/asgi/routes/command.py deleted file mode 100644 index bde1af0c..00000000 --- a/botx/testing/botx_mock/asgi/routes/command.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Endpoints for command resource.""" - -from starlette.requests import Request -from starlette.responses import Response - -from botx.clients.methods.v3.command.command_result import CommandResult -from botx.testing.botx_mock.asgi.messages import add_message_to_collection -from botx.testing.botx_mock.asgi.responses import generate_push_response -from botx.testing.botx_mock.binders import bind_implementation_to_method - - -@bind_implementation_to_method(CommandResult) -async def post_command_result(request: Request) -> Response: - """Handle command result request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with sync_id of pushed message. - """ - payload = CommandResult.parse_obj(await request.json()) - add_message_to_collection(request, payload) - return generate_push_response(payload) diff --git a/botx/testing/botx_mock/asgi/routes/events.py b/botx/testing/botx_mock/asgi/routes/events.py deleted file mode 100644 index bbe32640..00000000 --- a/botx/testing/botx_mock/asgi/routes/events.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Endpoints for events resource.""" - -from starlette.requests import Request -from starlette.responses import Response - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.events.edit_event import EditEvent -from botx.clients.methods.v3.events.reply_event import ReplyEvent -from botx.testing.botx_mock.asgi.messages import add_message_to_collection -from botx.testing.botx_mock.asgi.responses import PydanticResponse -from botx.testing.botx_mock.binders import bind_implementation_to_method - - -@bind_implementation_to_method(EditEvent) -async def post_edit_event(request: Request) -> Response: - """Handle edition of event request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Empty json response. - """ - payload = EditEvent.parse_obj(await request.json()) - add_message_to_collection(request, payload) - return PydanticResponse(APIResponse[str](result="update_pushed")) - - -@bind_implementation_to_method(ReplyEvent) -async def post_reply_event(request: Request) -> Response: - """Handle reply event request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Empty json response. - """ - payload = ReplyEvent.parse_obj(await request.json()) - add_message_to_collection(request, payload) - return PydanticResponse(APIResponse[str](result="reply_pushed")) diff --git a/botx/testing/botx_mock/asgi/routes/files.py b/botx/testing/botx_mock/asgi/routes/files.py deleted file mode 100644 index 37c4f56e..00000000 --- a/botx/testing/botx_mock/asgi/routes/files.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Endpoints for chats resource.""" - -import json - -from starlette.requests import Request -from starlette.responses import Response - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.files.download import DownloadFile -from botx.clients.methods.v3.files.upload import UploadFile -from botx.models.files import File, MetaFile -from botx.testing.botx_mock.asgi.messages import add_request_to_collection -from botx.testing.botx_mock.asgi.responses import PydanticResponse -from botx.testing.botx_mock.binders import bind_implementation_to_method -from botx.testing.botx_mock.entities import create_test_metafile - - -@bind_implementation_to_method(UploadFile) -async def upload_file(request: Request) -> Response: - """Handle retrieving information about user request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with metadata of file. - """ - form = dict(await request.form()) - meta = json.loads(form["meta"]) - filename = form["content"].filename # type: ignore - file = File.from_file( - filename=filename, - file=form["content"].file, # type: ignore - ) - payload = UploadFile( - group_chat_id=form["group_chat_id"], # type: ignore - file=file, - meta=meta, - ) - add_request_to_collection(request, payload) - return PydanticResponse( - APIResponse[MetaFile]( - result=create_test_metafile(filename), - ), - ) - - -@bind_implementation_to_method(DownloadFile) -async def download_file(request: Request) -> Response: - """Handle retrieving information about user request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with file content. - """ - payload = DownloadFile.parse_obj(request.query_params) - add_request_to_collection(request, payload) - return PydanticResponse(model=None, raw_data=b"content", media_type="text/plain") diff --git a/botx/testing/botx_mock/asgi/routes/notification.py b/botx/testing/botx_mock/asgi/routes/notification.py deleted file mode 100644 index 6b55e604..00000000 --- a/botx/testing/botx_mock/asgi/routes/notification.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Endpoints for notification resource.""" - -from starlette.requests import Request -from starlette.responses import Response - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.notification.direct_notification import NotificationDirect -from botx.clients.methods.v3.notification.notification import Notification -from botx.testing.botx_mock.asgi.messages import add_message_to_collection -from botx.testing.botx_mock.asgi.responses import ( - PydanticResponse, - generate_push_response, -) -from botx.testing.botx_mock.binders import bind_implementation_to_method - - -@bind_implementation_to_method(Notification) -async def post_notification(request: Request) -> Response: - """Handle pushed notification request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with sync_id of pushed message. - """ - payload = Notification.parse_obj(await request.json()) - add_message_to_collection(request, payload) - return PydanticResponse(APIResponse[str](result="notification_pushed")) - - -@bind_implementation_to_method(NotificationDirect) -async def post_notification_direct(request: Request) -> Response: - """Handle pushed notification request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with sync_id of pushed message. - """ - payload = NotificationDirect.parse_obj(await request.json()) - add_message_to_collection(request, payload) - return generate_push_response(payload) diff --git a/botx/testing/botx_mock/asgi/routes/notifications.py b/botx/testing/botx_mock/asgi/routes/notifications.py deleted file mode 100644 index 0e71827c..00000000 --- a/botx/testing/botx_mock/asgi/routes/notifications.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Endpoints for notification resource.""" - -from starlette.requests import Request -from starlette.responses import Response - -from botx.clients.methods.v4.notifications.internal_bot_notification import ( - InternalBotNotification, -) -from botx.testing.botx_mock.asgi.messages import add_message_to_collection -from botx.testing.botx_mock.asgi.responses import ( - generate_internal_bot_notification_response, -) -from botx.testing.botx_mock.binders import bind_implementation_to_method - - -@bind_implementation_to_method(InternalBotNotification) -async def post_internal_bot_notification(request: Request) -> Response: - """Handle pushed notification request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with sync_id of pushed message. - """ - payload = InternalBotNotification.parse_obj(await request.json()) - add_message_to_collection(request, payload) - return generate_internal_bot_notification_response(payload) diff --git a/botx/testing/botx_mock/asgi/routes/smartapps.py b/botx/testing/botx_mock/asgi/routes/smartapps.py deleted file mode 100644 index 94201969..00000000 --- a/botx/testing/botx_mock/asgi/routes/smartapps.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Endpoints for smartapps.""" - -from starlette.requests import Request -from starlette.responses import Response - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.smartapps.smartapp_event import SmartAppEvent -from botx.clients.methods.v3.smartapps.smartapp_notification import SmartAppNotification -from botx.testing.botx_mock.asgi.messages import add_message_to_collection -from botx.testing.botx_mock.asgi.responses import PydanticResponse -from botx.testing.botx_mock.binders import bind_implementation_to_method - - -@bind_implementation_to_method(SmartAppEvent) -async def post_smartapp_event(request: Request) -> Response: - """Handle pushed smartapp event request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with sync_id of pushed message. - """ - payload = SmartAppEvent.parse_obj(await request.json()) - add_message_to_collection(request, payload) - return PydanticResponse(APIResponse[str](result="smartapp_event_pushed")) - - -@bind_implementation_to_method(SmartAppNotification) -async def post_smartapp_notification(request: Request) -> Response: - """Handle pushed smartapp notification request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with sync_id of pushed message. - """ - payload = SmartAppNotification.parse_obj(await request.json()) - add_message_to_collection(request, payload) - return PydanticResponse(APIResponse[str](result="smartapp_notification_pushed")) diff --git a/botx/testing/botx_mock/asgi/routes/stickers.py b/botx/testing/botx_mock/asgi/routes/stickers.py deleted file mode 100644 index 75ec4033..00000000 --- a/botx/testing/botx_mock/asgi/routes/stickers.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Endpoints for stickers.""" -import uuid -from datetime import datetime as dt - -from starlette import requests, responses - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.stickers import ( - add_sticker, - create_sticker_pack, - delete_sticker, - delete_sticker_pack, - edit_sticker_pack, - sticker, - sticker_pack, - sticker_pack_list, -) -from botx.models import stickers -from botx.testing.botx_mock.asgi.messages import add_request_to_collection -from botx.testing.botx_mock.asgi.responses import PydanticResponse -from botx.testing.botx_mock.binders import bind_implementation_to_method -from botx.testing.content import PNG_DATA - - -@bind_implementation_to_method(sticker_pack_list.GetStickerPackList) -async def get_sticker_pack_list(request: requests.Request) -> responses.Response: - """Handle retrieving information of sticker pack list request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with list of sticker packs. - """ - payload = sticker_pack_list.GetStickerPackList.parse_obj(request.query_params) - add_request_to_collection(request, payload) - - pagination = stickers.Pagination(after=PNG_DATA) - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - sticker_packs = [ - stickers.StickerPackPreview( - id=uuid.uuid4(), - name="Test sticker pack", - public=False, - stickers_count=1, - inserted_at=inserted_at, - ), - ] - sticker_pack_list_response = stickers.StickerPackList( - packs=sticker_packs, - pagination=pagination, - ) - - return PydanticResponse( - APIResponse[stickers.StickerPackList]( - result=sticker_pack_list_response, - ), - ) - - -@bind_implementation_to_method(sticker_pack.GetStickerPack) -async def get_sticker_pack(request: requests.Request) -> responses.Response: - """Handle retrieving information of sticker pack request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with information of sticker pack. - """ - payload = sticker_pack.GetStickerPack.parse_obj(request.path_params) - add_request_to_collection(request, payload) - - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - pack_stickers = [ - stickers.Sticker( - id=uuid.uuid4(), - emoji="🐢", - link="http://some_link.com", - inserted_at=inserted_at, - updated_at=inserted_at, - ), - ] - sticker_pack_preview = stickers.StickerPack( - id=uuid.uuid4(), - name="Test sticker pack", - preview=None, - public=False, - stickers_order=None, - stickers=pack_stickers, - inserted_at=inserted_at, - updated_at=inserted_at, - deleted_at=None, - ) - - return PydanticResponse( - APIResponse[stickers.StickerPack]( - result=sticker_pack_preview, - ), - ) - - -@bind_implementation_to_method(sticker.GetSticker) -async def get_sticker_from_sticker_pack( - request: requests.Request, -) -> responses.Response: - """Handle retrieving information of sticker from sticker pack request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with information of sticker from sticker pack. - """ - payload = sticker.GetSticker.parse_obj(request.path_params) - add_request_to_collection(request, payload) - - sticker_from_sticker_pack = stickers.StickerFromPack( - id=uuid.uuid4(), - emoji="🐢", - link="http://some_link.com", - preview="http://preview_link.com", - ) - - return PydanticResponse( - APIResponse[stickers.StickerFromPack]( - result=sticker_from_sticker_pack, - ), - ) - - -@bind_implementation_to_method(add_sticker.AddSticker) -async def post_add_sticker_into_sticker_pack( - request: requests.Request, -) -> responses.Response: - """Handle adding of sticker to sticker pack request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with result of adding. - """ - payload = add_sticker.AddSticker.parse_obj(await request.json()) - add_request_to_collection(request, payload) - - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - sticker_from_sticker_pack = stickers.Sticker( - id=uuid.uuid4(), - emoji="🐢", - link="http://some_link.com", - inserted_at=inserted_at, - updated_at=inserted_at, - ) - - return PydanticResponse( - APIResponse[stickers.Sticker]( - result=sticker_from_sticker_pack, - ), - ) - - -@bind_implementation_to_method(create_sticker_pack.CreateStickerPack) -async def post_create_sticker_pack(request: requests.Request) -> responses.Response: - """Handle creating of sticker pack request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with result of creating. - """ - payload = create_sticker_pack.CreateStickerPack.parse_obj(request.query_params) - add_request_to_collection(request, payload) - - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - pack_stickers = [ - stickers.Sticker( - id=uuid.uuid4(), - emoji="🐢", - link="http://some_link.com", - inserted_at=inserted_at, - updated_at=inserted_at, - ), - ] - sticker_from_sticker_pack = stickers.StickerPack( - id=uuid.uuid4(), - name="Test sticker pack", - preview=None, - public=False, - stickers_order=None, - stickers=pack_stickers, - inserted_at=inserted_at, - updated_at=inserted_at, - deleted_at=None, - ) - - return PydanticResponse( - APIResponse[stickers.StickerPack]( - result=sticker_from_sticker_pack, - ), - ) - - -@bind_implementation_to_method(edit_sticker_pack.EditStickerPack) -async def post_edit_sticker_pack(request: requests.Request) -> responses.Response: - """Handle editing of sticker pack request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with result of editing. - """ - payload = edit_sticker_pack.EditStickerPack.parse_obj(await request.json()) - add_request_to_collection(request, payload) - - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - pack_stickers = [ - stickers.Sticker( - id=uuid.uuid4(), - emoji="🐢", - link="http://some_link.com", - inserted_at=inserted_at, - updated_at=inserted_at, - ), - ] - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - sticker_from_sticker_pack = stickers.StickerPack( - id=uuid.uuid4(), - name="Test sticker pack", - preview=None, - public=False, - stickers_order=None, - stickers=pack_stickers, - inserted_at=inserted_at, - updated_at=inserted_at, - deleted_at=None, - ) - - return PydanticResponse( - APIResponse[stickers.StickerPack]( - result=sticker_from_sticker_pack, - ), - ) - - -@bind_implementation_to_method(delete_sticker_pack.DeleteStickerPack) -async def post_delete_sticker_pack(request: requests.Request) -> responses.Response: - """Handle deleting of sticker pack request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with result of deleting. - """ - payload = delete_sticker_pack.DeleteStickerPack.parse_obj(request.path_params) - add_request_to_collection(request, payload) - - return PydanticResponse( - APIResponse[str]( - result="sticker_pack_deleted", - ), - ) - - -@bind_implementation_to_method(delete_sticker.DeleteSticker) -async def post_delete_sticker_from_sticker_pack( - request: requests.Request, -) -> responses.Response: - """Handle deleting of sticker from sticker pack pack request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with result of deleting. - """ - payload = delete_sticker.DeleteSticker.parse_obj(request.path_params) - add_request_to_collection(request, payload) - - return PydanticResponse( - APIResponse[str]( - result="sticker_deleted", - ), - ) diff --git a/botx/testing/botx_mock/asgi/routes/users.py b/botx/testing/botx_mock/asgi/routes/users.py deleted file mode 100644 index fc54fc23..00000000 --- a/botx/testing/botx_mock/asgi/routes/users.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Endpoints for chats resource.""" - -from starlette.requests import Request -from starlette.responses import Response - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.users.by_email import ByEmail -from botx.clients.methods.v3.users.by_huid import ByHUID -from botx.clients.methods.v3.users.by_login import ByLogin -from botx.models.users import UserFromSearch -from botx.testing.botx_mock.asgi.messages import add_request_to_collection -from botx.testing.botx_mock.asgi.responses import PydanticResponse -from botx.testing.botx_mock.binders import bind_implementation_to_method -from botx.testing.botx_mock.entities import create_test_user - - -@bind_implementation_to_method(ByHUID) -async def get_by_huid(request: Request) -> Response: - """Handle retrieving information about user request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with information about user. - """ - payload = ByHUID.parse_obj(request.query_params) - add_request_to_collection(request, payload) - return PydanticResponse( - APIResponse[UserFromSearch]( - result=create_test_user(user_huid=payload.user_huid), - ), - ) - - -@bind_implementation_to_method(ByEmail) -async def get_by_email(request: Request) -> Response: - """Handle retrieving information about user request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with information about user. - """ - payload = ByEmail.parse_obj(request.query_params) - add_request_to_collection(request, payload) - return PydanticResponse( - APIResponse[UserFromSearch](result=create_test_user(email=payload.email)), - ) - - -@bind_implementation_to_method(ByLogin) -async def get_by_login(request: Request) -> Response: - """Handle retrieving information about user request. - - Arguments: - request: HTTP request from Starlette. - - Returns: - Response with information about user. - """ - payload = ByLogin.parse_obj(request.query_params) - add_request_to_collection(request, payload) - return PydanticResponse( - APIResponse[UserFromSearch]( - result=create_test_user(ad=(payload.ad_login, payload.ad_domain)), - ), - ) diff --git a/botx/testing/botx_mock/binders.py b/botx/testing/botx_mock/binders.py deleted file mode 100644 index f20c9a1a..00000000 --- a/botx/testing/botx_mock/binders.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Decorator for binding custom BotXMethod with implementation.""" -from typing import Any, Callable, Type - -from botx.clients.methods.base import BotXMethod - - -def bind_implementation_to_method(method: Type[BotXMethod]) -> Callable[..., Any]: - """Bind implementation of async route to method. - - Arguments: - method: method class to bind. - - Returns: - Decorator that binds method and returns it. - """ - - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - func.method = method # type: ignore - return func - - return decorator diff --git a/botx/testing/botx_mock/entities.py b/botx/testing/botx_mock/entities.py deleted file mode 100644 index 2ea60768..00000000 --- a/botx/testing/botx_mock/entities.py +++ /dev/null @@ -1,62 +0,0 @@ -"""Predefined mocked entities builder for routes.""" -import uuid -from typing import Optional, Tuple - -from botx.models.enums import AttachmentsTypes -from botx.models.files import MetaFile -from botx.models.users import UserFromSearch - - -def create_test_user( - *, - user_huid: Optional[uuid.UUID] = None, - email: Optional[str] = None, - ad: Optional[Tuple[str, str]] = None, -) -> UserFromSearch: - """Build test user for using in search. - - Arguments: - user_huid: HUID of user for search. - email: email of user for search. - ad: AD credentials of user for search. - - Returns: - "Found" user. - """ - return UserFromSearch( - user_huid=user_huid or uuid.uuid4(), - ad_login=ad[0] if ad else "ad_login", - ad_domain=ad[1] if ad else "ad_domain", - name="test user", - company="test company", - company_position="test position", - department="test department", - emails=[email or "test@example.com"], - ) - - -def create_test_metafile(filename: str = None) -> MetaFile: - """Build test metafile for using in uploading. - - Arguments: - filename: name of uploaded file. - - Returns: - Metadata of uploaded file. - """ - return MetaFile( - type=AttachmentsTypes.image, - file="https://service.to./image", - file_mime_type="image/png", - file_name=filename or "image.png", - file_preview=None, - file_preview_height=None, - file_preview_width=None, - file_size=100, - file_hash="W1Sn1AkotkOpH0", - file_encryption_algo="stream", - chunk_size=10, - file_id=uuid.UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), - caption=None, - duration=None, - ) diff --git a/botx/testing/botx_mock/wsgi/__init__.py b/botx/testing/botx_mock/wsgi/__init__.py deleted file mode 100644 index 7d2452e7..00000000 --- a/botx/testing/botx_mock/wsgi/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""WSGI mock for BotX API for using with httpx.""" diff --git a/botx/testing/botx_mock/wsgi/application.py b/botx/testing/botx_mock/wsgi/application.py deleted file mode 100644 index 91fe6b36..00000000 --- a/botx/testing/botx_mock/wsgi/application.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Definition of Starlette application that is mock for BotX API.""" - -from typing import Any, Callable, Dict, List, Sequence, Tuple, Type - -from molten import App, JSONParser, Route, Settings, SettingsComponent - -from botx.clients.methods.base import BotXMethod -from botx.testing.botx_mock.wsgi.errors import error_middleware -from botx.testing.botx_mock.wsgi.routes import bots # noqa: WPS235 -from botx.testing.botx_mock.wsgi.routes import ( - chats, - command, - events, - files, - notification, - notifications, - smartapps, - stickers, - users, -) -from botx.testing.typing import APIMessage, APIRequest - -_ENDPOINTS: Tuple[Callable[..., Any], ...] = ( - # V2 - # bots - bots.get_token, - # V3 - # chats - chats.get_info, - chats.get_bot_chats, - chats.post_add_admin_role, - chats.post_add_user, - chats.post_remove_user, - chats.post_stealth_set, - chats.post_stealth_disable, - chats.post_create, - chats.post_pin_message, - chats.post_unpin_message, - # command - command.post_command_result, - # events - events.post_edit_event, - # notification - notification.post_notification, - notification.post_notification_direct, - # users - users.get_by_huid, - users.get_by_email, - users.get_by_login, - # notifications - notifications.post_internal_bot_notification, - # files - files.upload_file, - files.download_file, - # stickers - stickers.get_sticker_pack_list, - stickers.get_sticker_pack, - stickers.get_sticker_from_sticker_pack, - stickers.post_add_sticker_into_sticker_pack, - stickers.post_delete_sticker_pack, - stickers.delete_sticker_from_sticker_pack, - stickers.post_create_sticker_pack, - stickers.post_edit_sticker_pack, - # smartapps - smartapps.post_smartapp_event, - smartapps.post_smartapp_notification, -) - - -def _create_molten_routes() -> Sequence[Route]: - routes = [] - - for endpoint in _ENDPOINTS: - url = endpoint.method.__url__ # type: ignore # noqa: WPS609 - method = endpoint.method.__method__ # type: ignore # noqa: WPS609 - routes.append(Route(url, endpoint, method=method)) - - return routes - - -def get_botx_wsgi_api( - messages: List[APIMessage], - requests: List[APIRequest], - errors: Dict[Type[BotXMethod], Tuple[int, Any]], -) -> App: - """Generate BotX API mock. - - Arguments: - messages: list of message that were sent from bot and should be extended. - requests: all requests that were sent from bot. - errors: errors to be generated by mocked API. - - Returns: - Generated BotX API mock for using with httpx. - """ - return App( - components=[ - SettingsComponent( - Settings(messages=messages, requests=requests, errors=errors), - ), - ], - routes=list(_create_molten_routes()), - middleware=[error_middleware], - parsers=[JSONParser()], - ) diff --git a/botx/testing/botx_mock/wsgi/errors.py b/botx/testing/botx_mock/wsgi/errors.py deleted file mode 100644 index 56705771..00000000 --- a/botx/testing/botx_mock/wsgi/errors.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Definition of middleware that will generate BotX API errors depending from flag.""" -from typing import Any, Callable, Tuple, Type, cast - -from molten import BaseApp, Request, Response, Route, Settings -from pydantic import BaseModel - -from botx.clients.methods.base import APIErrorResponse, BotXMethod -from botx.testing.botx_mock.wsgi.responses import PydanticResponse - - -def _get_error_from_request( - request: Request, - app: BaseApp, - settings: Settings, -) -> Tuple[Type[BotXMethod], Tuple[int, BaseModel]]: - match = app.router.match(request.method, request.path) - route, _ = cast(Tuple[Route, Any], match) - method = route.handler.method # type: ignore - return method, settings["errors"].get(method) - - -def should_generate_error_response( - request: Request, - app: BaseApp, - settings: Settings, -) -> bool: - """Check if mocked API should generate error response. - - Arguments: - request: request from molten route. - app: molten app that serves request. - settings: application settings with storage. - - Returns: - Result of check. - """ - _, status_and_error_to_raise = _get_error_from_request(request, app, settings) - return bool(status_and_error_to_raise) - - -def generate_error_response( - request: Request, - app: BaseApp, - settings: Settings, -) -> Response: - """Generate error response for mocked BotX API. - - Arguments: - request: request from molten route. - app: molten app that serves request. - settings: application settings with storage. - - Returns: - Generated response. - """ - method, response_info = _get_error_from_request(request, app, settings) - status_code, error_data = response_info - - return PydanticResponse( - APIErrorResponse[BaseModel]( - errors=["error from mock"], - reason="asked_for_error", - error_data=error_data, - ), - # TODO: Drop unnecessary description. - status_code="{0} ".format(status_code), - ) - - -def error_middleware(handler: Callable[..., Any]) -> Callable[..., Any]: - """Middleware that will generate error response. - - Arguments: - handler: next handler for request for molten. - - Returns: - Created handler for request. - """ - - def decorator(request: Request, app: BaseApp, settings: Settings) -> Any: - if should_generate_error_response(request, app, settings): - return generate_error_response(request, app, settings) - - return handler() - - return decorator diff --git a/botx/testing/botx_mock/wsgi/messages.py b/botx/testing/botx_mock/wsgi/messages.py deleted file mode 100644 index 4b0403d2..00000000 --- a/botx/testing/botx_mock/wsgi/messages.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Logic for extending messages and requests collections from test client.""" - -from molten import Settings - -from botx.clients.methods.base import BotXMethod - - -def add_message_to_collection(settings: Settings, message: BotXMethod) -> None: - """Add new message to messages collection. - - Arguments: - settings: application settings with storage. - message: message that should be added. - """ - messages = settings["messages"] - messages.append(message) - add_request_to_collection(settings, message) - - -def add_request_to_collection(settings: Settings, api_request: BotXMethod) -> None: - """Add new API request to requests collection. - - Arguments: - settings: application settings with storage. - api_request: API request that should be added. - """ - requests = settings["requests"] - requests.append(api_request) diff --git a/botx/testing/botx_mock/wsgi/responses.py b/botx/testing/botx_mock/wsgi/responses.py deleted file mode 100644 index 30688a58..00000000 --- a/botx/testing/botx_mock/wsgi/responses.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Common responses for mocks.""" - -import uuid -from typing import Any, Dict, Optional, Union - -from molten import HTTP_200, Response -from pydantic import BaseModel - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.command.command_result import CommandResult -from botx.clients.methods.v3.notification.direct_notification import NotificationDirect -from botx.clients.methods.v4.notifications.internal_bot_notification import ( - InternalBotNotification, -) -from botx.clients.types.response_results import ( - InternalBotNotificationResult, - PushResult, -) - - -class PydanticResponse(Response): - """Custom response to encode pydantic model from route.""" - - def __init__( - self, - model: Optional[BaseModel], - raw_data: Optional[str] = None, - status_code: str = HTTP_200, - headers: Optional[Dict[Any, Any]] = None, - ) -> None: - """Init custom response. - - Arguments: - model: pydantic model that should be encoded. - raw_data: raw data that should be encoded. - status_code: response HTTP status code. - headers: headers for response. - """ - headers = headers or {"Content-Type": "application/json"} - - super().__init__( - status_code, - headers, - raw_data or model.json(by_alias=True), # type: ignore - ) - - -def generate_push_response( - payload: Union[CommandResult, NotificationDirect], -) -> Response: - """Generate response as like new message from bot was pushed. - - Arguments: - payload: pushed message. - - Returns: - Response with sync_id for new message. - """ - sync_id = payload.event_sync_id or uuid.uuid4() - return PydanticResponse( - APIResponse[PushResult](result=PushResult(sync_id=sync_id)), - ) - - -def generate_internal_bot_notification_response( - payload: InternalBotNotification, -) -> Response: - """Generate response as like internal bot notification was sent. - - Arguments: - payload: sent notification. - - Returns: - Response with sync_id for new message. - """ - sync_id = uuid.uuid4() - return PydanticResponse( - APIResponse[InternalBotNotificationResult]( - result=InternalBotNotificationResult(sync_id=sync_id), - ), - ) diff --git a/botx/testing/botx_mock/wsgi/routes/__init__.py b/botx/testing/botx_mock/wsgi/routes/__init__.py deleted file mode 100644 index c259761e..00000000 --- a/botx/testing/botx_mock/wsgi/routes/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition of routes for BotX API mock.""" diff --git a/botx/testing/botx_mock/wsgi/routes/bots.py b/botx/testing/botx_mock/wsgi/routes/bots.py deleted file mode 100644 index 87dcc39c..00000000 --- a/botx/testing/botx_mock/wsgi/routes/bots.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Endpoints for bots resource.""" -from uuid import UUID - -from molten import Request, Response, Settings - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v2.bots.token import Token -from botx.testing.botx_mock.binders import bind_implementation_to_method -from botx.testing.botx_mock.wsgi.messages import add_request_to_collection -from botx.testing.botx_mock.wsgi.responses import PydanticResponse - - -@bind_implementation_to_method(Token) -def get_token(bot_id: str, request: Request, settings: Settings) -> Response: - """Handle retrieving token from BotX API request. - - Arguments: - bot_id: ID of bot from query params. - request: modten request for route. - settings: application settings with storage. - - Returns: - Return response with new token. - """ - signature = request.params["signature"] - payload = Token(bot_id=UUID(bot_id), signature=signature) - add_request_to_collection(settings, payload) - return PydanticResponse(APIResponse[str](result="real token")) diff --git a/botx/testing/botx_mock/wsgi/routes/chats.py b/botx/testing/botx_mock/wsgi/routes/chats.py deleted file mode 100644 index f0935f28..00000000 --- a/botx/testing/botx_mock/wsgi/routes/chats.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Endpoints for chats resource.""" -import uuid -from datetime import datetime as dt - -from molten import Request, RequestData, Response, Settings - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.chats import add_admin_role # noqa: WPS235 -from botx.clients.methods.v3.chats import ( - add_user, - chat_list, - create, - info, - pin_message, - remove_user, - stealth_disable, - stealth_set, - unpin_message, -) -from botx.clients.types.response_results import ChatCreatedResult -from botx.models import chats, enums, users -from botx.testing.botx_mock.binders import bind_implementation_to_method -from botx.testing.botx_mock.wsgi.messages import add_request_to_collection -from botx.testing.botx_mock.wsgi.responses import PydanticResponse - - -@bind_implementation_to_method(info.Info) -def get_info(request: Request, settings: Settings) -> Response: - """Handle retrieving information of chat request. - - Arguments: - request: HTTP request from Molten. - settings: application settings with storage. - - Returns: - Response with information of chat. - """ - payload = info.Info.parse_obj(request.params) - add_request_to_collection(settings, payload) - - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - return PydanticResponse( - APIResponse[chats.ChatFromSearch]( - result=chats.ChatFromSearch( - name="chat name", - chat_type=enums.ChatTypes.group_chat, - creator=uuid.uuid4(), - group_chat_id=payload.group_chat_id, - members=[ - users.UserFromChatSearch( - user_huid=uuid.uuid4(), - user_kind=enums.UserKinds.user, - admin=True, - ), - ], - inserted_at=inserted_at, - ), - ), - ) - - -@bind_implementation_to_method(chat_list.ChatList) -def get_bot_chats(request: Request, settings: Settings) -> Response: - """Return list of bot chats. - - Arguments: - request: HTTP request from Molten. - settings: application settings with storage. - - Returns: - List of bot chats. - """ - payload = chat_list.ChatList.parse_obj(request.params) - add_request_to_collection(settings, payload) - - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - updated_at = dt.fromisoformat("2019-09-29T10:30:48.358586+00:00") - return PydanticResponse( - APIResponse[chats.BotChatList]( - result=chats.BotChatList( - __root__=[ - chats.BotChatFromList( - name="chat name", - description="test", - chat_type=enums.ChatTypes.group_chat, - group_chat_id=uuid.uuid4(), - members=[uuid.uuid4()], - inserted_at=inserted_at, - updated_at=updated_at, - ), - ], - ), - ), - ) - - -@bind_implementation_to_method(add_user.AddUser) -def post_add_user(request_data: RequestData, settings: Settings) -> Response: - """Handle adding of user to chat request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with result of adding. - """ - payload = add_user.AddUser.parse_obj(request_data) - add_request_to_collection(settings, payload) - return PydanticResponse(APIResponse[bool](result=True)) - - -@bind_implementation_to_method(remove_user.RemoveUser) -def post_remove_user(request_data: RequestData, settings: Settings) -> Response: - """Handle removing of user to chat request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with result of removing. - """ - payload = remove_user.RemoveUser.parse_obj(request_data) - add_request_to_collection(settings, payload) - return PydanticResponse(APIResponse[bool](result=True)) - - -@bind_implementation_to_method(stealth_set.StealthSet) -def post_stealth_set(request_data: RequestData, settings: Settings) -> Response: - """Handle stealth enabling in chat request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with result of enabling stealth. - """ - payload = stealth_set.StealthSet.parse_obj(request_data) - add_request_to_collection(settings, payload) - return PydanticResponse(APIResponse[bool](result=True)) - - -@bind_implementation_to_method(stealth_disable.StealthDisable) -def post_stealth_disable(request_data: RequestData, settings: Settings) -> Response: - """Handle stealth disabling in chat request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with result of disabling stealth. - """ - payload = stealth_disable.StealthDisable.parse_obj(request_data) - add_request_to_collection(settings, payload) - return PydanticResponse(APIResponse[bool](result=True)) - - -@bind_implementation_to_method(create.Create) -def post_create(request_data: RequestData, settings: Settings) -> Response: - """Handle creation of new chat request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with result of creation. - """ - payload = create.Create.parse_obj(request_data) - add_request_to_collection(settings, payload) - return PydanticResponse( - APIResponse[ChatCreatedResult](result=ChatCreatedResult(chat_id=uuid.uuid4())), - ) - - -@bind_implementation_to_method(add_admin_role.AddAdminRole) -def post_add_admin_role(request_data: RequestData, settings: Settings) -> Response: - """Handle promoting users to admins request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with result of adding. - """ - payload = add_admin_role.AddAdminRole.parse_obj(request_data) - add_request_to_collection(settings, payload) - return PydanticResponse(APIResponse[bool](result=True)) - - -@bind_implementation_to_method(pin_message.PinMessage) -def post_pin_message(request_data: RequestData, settings: Settings) -> Response: - """Handle pinning message in chat request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with result of pinning. - """ - payload = pin_message.PinMessage.parse_obj(request_data) - add_request_to_collection(settings, payload) - return PydanticResponse(APIResponse[str](result="pinned")) - - -@bind_implementation_to_method(unpin_message.UnpinMessage) -def post_unpin_message(request_data: RequestData, settings: Settings) -> Response: - """Handle unpinning message in chat request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with result of unpinning. - """ - payload = unpin_message.UnpinMessage.parse_obj(request_data) - add_request_to_collection(settings, payload) - return PydanticResponse(APIResponse[str](result="pinned")) diff --git a/botx/testing/botx_mock/wsgi/routes/command.py b/botx/testing/botx_mock/wsgi/routes/command.py deleted file mode 100644 index 0f018cc3..00000000 --- a/botx/testing/botx_mock/wsgi/routes/command.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Endpoints for command resource.""" -from molten import RequestData, Response, Settings - -from botx.clients.methods.v3.command.command_result import CommandResult -from botx.testing.botx_mock.binders import bind_implementation_to_method -from botx.testing.botx_mock.wsgi.messages import add_message_to_collection -from botx.testing.botx_mock.wsgi.responses import generate_push_response - - -@bind_implementation_to_method(CommandResult) -def post_command_result(request_data: RequestData, settings: Settings) -> Response: - """Handle command result request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with sync_id of pushed message. - """ - payload = CommandResult.parse_obj(request_data) - add_message_to_collection(settings, payload) - return generate_push_response(payload) diff --git a/botx/testing/botx_mock/wsgi/routes/events.py b/botx/testing/botx_mock/wsgi/routes/events.py deleted file mode 100644 index 5a199027..00000000 --- a/botx/testing/botx_mock/wsgi/routes/events.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Endpoints for events resource.""" -from molten import RequestData, Response, Settings - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.events.edit_event import EditEvent -from botx.testing.botx_mock.binders import bind_implementation_to_method -from botx.testing.botx_mock.wsgi.messages import add_message_to_collection -from botx.testing.botx_mock.wsgi.responses import PydanticResponse - - -@bind_implementation_to_method(EditEvent) -def post_edit_event(request_data: RequestData, settings: Settings) -> Response: - """Handle edition of event request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Empty json response. - """ - payload = EditEvent.parse_obj(request_data) - add_message_to_collection(settings, payload) - return PydanticResponse(APIResponse[str](result="update_pushed")) diff --git a/botx/testing/botx_mock/wsgi/routes/files.py b/botx/testing/botx_mock/wsgi/routes/files.py deleted file mode 100644 index 12be8c3c..00000000 --- a/botx/testing/botx_mock/wsgi/routes/files.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Endpoints for chats resource.""" - -import json - -from molten import Header, MultiPartParser, Request, RequestInput, Response, Settings - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.files.download import DownloadFile -from botx.clients.methods.v3.files.upload import UploadFile -from botx.models.files import File, MetaFile -from botx.testing.botx_mock.binders import bind_implementation_to_method -from botx.testing.botx_mock.entities import create_test_metafile -from botx.testing.botx_mock.wsgi.messages import add_request_to_collection -from botx.testing.botx_mock.wsgi.responses import PydanticResponse - - -@bind_implementation_to_method(UploadFile) -def upload_file(request: Request, settings: Settings) -> Response: - """Handle retrieving information about user request. - - Arguments: - request: HTTP request from Molten. - settings: application settings with storage. - - Returns: - Response with metadata of file. - """ - headers = dict(request.headers) - form = MultiPartParser().parse( - Header(headers["content-type"]), - Header(headers["content-length"]), - RequestInput(request.body_file), - ) - - meta = json.loads(form["meta"]) # type: ignore - file = File.from_file( - filename=form["content"].filename, # type: ignore - file=form["content"].stream, # type: ignore - ) - payload = UploadFile( - group_chat_id=form["group_chat_id"], # type: ignore - file=file, - meta=meta, - ) - add_request_to_collection(settings, payload) - return PydanticResponse( - APIResponse[MetaFile]( - result=create_test_metafile(), - ), - ) - - -@bind_implementation_to_method(DownloadFile) -def download_file(request: Request, settings: Settings) -> Response: - """Handle retrieving information about user request. - - Arguments: - request: HTTP request from Molten. - settings: application settings with storage. - - Returns: - Response with file content. - """ - payload = DownloadFile.parse_obj(request.params) - add_request_to_collection(settings, payload) - return PydanticResponse( - model=None, - raw_data="content", - headers={"Content-Type": "text/plain"}, - ) diff --git a/botx/testing/botx_mock/wsgi/routes/notification.py b/botx/testing/botx_mock/wsgi/routes/notification.py deleted file mode 100644 index 46dad979..00000000 --- a/botx/testing/botx_mock/wsgi/routes/notification.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Endpoints for notification resource.""" -from molten import RequestData, Response, Settings - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.notification.direct_notification import NotificationDirect -from botx.clients.methods.v3.notification.notification import Notification -from botx.testing.botx_mock.binders import bind_implementation_to_method -from botx.testing.botx_mock.wsgi.messages import add_message_to_collection -from botx.testing.botx_mock.wsgi.responses import ( - PydanticResponse, - generate_push_response, -) - - -@bind_implementation_to_method(Notification) -def post_notification(request_data: RequestData, settings: Settings) -> Response: - """Handle pushed notification request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with sync_id of pushed message. - """ - payload = Notification.parse_obj(request_data) - add_message_to_collection(settings, payload) - return PydanticResponse(APIResponse[str](result="notification_pushed")) - - -@bind_implementation_to_method(NotificationDirect) -def post_notification_direct(request_data: RequestData, settings: Settings) -> Response: - """Handle pushed notification request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with sync_id of pushed message. - """ - payload = NotificationDirect.parse_obj(request_data) - add_message_to_collection(settings, payload) - return generate_push_response(payload) diff --git a/botx/testing/botx_mock/wsgi/routes/notifications.py b/botx/testing/botx_mock/wsgi/routes/notifications.py deleted file mode 100644 index cf8bda5c..00000000 --- a/botx/testing/botx_mock/wsgi/routes/notifications.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Endpoints for notifications resource.""" -from molten import RequestData, Response, Settings - -from botx.clients.methods.v4.notifications.internal_bot_notification import ( - InternalBotNotification, -) -from botx.testing.botx_mock.binders import bind_implementation_to_method -from botx.testing.botx_mock.wsgi.messages import add_message_to_collection -from botx.testing.botx_mock.wsgi.responses import ( - generate_internal_bot_notification_response, -) - - -@bind_implementation_to_method(InternalBotNotification) -def post_internal_bot_notification( - request_data: RequestData, - settings: Settings, -) -> Response: - """Handle pushed notification request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with sync_id of pushed message. - """ - payload = InternalBotNotification.parse_obj(request_data) - add_message_to_collection(settings, payload) - return generate_internal_bot_notification_response(payload) diff --git a/botx/testing/botx_mock/wsgi/routes/smartapps.py b/botx/testing/botx_mock/wsgi/routes/smartapps.py deleted file mode 100644 index 81827dc7..00000000 --- a/botx/testing/botx_mock/wsgi/routes/smartapps.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Endpoints for smartapps.""" - -from molten import RequestData, Response, Settings - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.smartapps.smartapp_event import SmartAppEvent -from botx.clients.methods.v3.smartapps.smartapp_notification import SmartAppNotification -from botx.testing.botx_mock.binders import bind_implementation_to_method -from botx.testing.botx_mock.wsgi.messages import add_message_to_collection -from botx.testing.botx_mock.wsgi.responses import PydanticResponse - - -@bind_implementation_to_method(SmartAppEvent) -def post_smartapp_event(request_data: RequestData, settings: Settings) -> Response: - """Handle pushed smartapp event request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with sync_id of pushed message. - """ - payload = SmartAppEvent.parse_obj(request_data) - add_message_to_collection(settings, payload) - return PydanticResponse(APIResponse[str](result="smartapp_event_pushed")) - - -@bind_implementation_to_method(SmartAppNotification) -def post_smartapp_notification( - request_data: RequestData, - settings: Settings, -) -> Response: - """Handle pushed smartapp notification request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with sync_id of pushed message. - """ - payload = SmartAppNotification.parse_obj(request_data) - add_message_to_collection(settings, payload) - return PydanticResponse(APIResponse[str](result="smartapp_notification_pushed")) diff --git a/botx/testing/botx_mock/wsgi/routes/stickers.py b/botx/testing/botx_mock/wsgi/routes/stickers.py deleted file mode 100644 index 85f984df..00000000 --- a/botx/testing/botx_mock/wsgi/routes/stickers.py +++ /dev/null @@ -1,292 +0,0 @@ -"""Endpoints for stickers.""" -import uuid -from datetime import datetime as dt - -from molten import Request, RequestData, Response, Settings - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.stickers import ( - add_sticker, - create_sticker_pack, - delete_sticker, - delete_sticker_pack, - edit_sticker_pack, - sticker, - sticker_pack, - sticker_pack_list, -) -from botx.models import stickers -from botx.testing.botx_mock.binders import bind_implementation_to_method -from botx.testing.botx_mock.wsgi.messages import add_request_to_collection -from botx.testing.botx_mock.wsgi.responses import PydanticResponse - - -@bind_implementation_to_method(sticker_pack_list.GetStickerPackList) -def get_sticker_pack_list(request: Request, settings: Settings) -> Response: - """Handle retrieving information of sticker pack list request. - - Arguments: - request: HTTP request from Molten. - settings: application settings with storage. - - Returns: - Response with list of sticker packs. - """ - payload = sticker_pack_list.GetStickerPackList.parse_obj(request.params) - add_request_to_collection(settings, payload) - - pagination = stickers.Pagination( - after="ABAmCAFTpX1ajK8O_ezuPEQ1AA0ACnVwZGF0ZWRfYXQAf____gAAAAA=", - ) - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - sticker_packs = [ - stickers.StickerPackPreview( - id=uuid.uuid4(), - name="Test sticker pack", - public=False, - stickers_count=1, - inserted_at=inserted_at, - ), - ] - pack_list = stickers.StickerPackList( - packs=sticker_packs, - pagination=pagination, - ) - - return PydanticResponse( - APIResponse[stickers.StickerPackList]( - result=pack_list, - ), - ) - - -@bind_implementation_to_method(sticker_pack.GetStickerPack) -def get_sticker_pack(request: Request, settings: Settings) -> Response: - """Handle retrieving information of sticker pack request. - - Arguments: - request: HTTP request from Molten. - settings: application settings with storage. - - Returns: - Response with information of sticker pack. - """ - payload = sticker_pack.GetStickerPack.parse_obj(request.params) - add_request_to_collection(settings, payload) - - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - pack_stickers = [ - stickers.Sticker( - id=uuid.uuid4(), - emoji="🐢", - link="http://some_link.com", - inserted_at=inserted_at, - updated_at=inserted_at, - ), - ] - sticker_pack_preview = stickers.StickerPack( - id=uuid.uuid4(), - name="Test sticker pack", - preview=None, - public=False, - stickers_order=None, - stickers=pack_stickers, - inserted_at=inserted_at, - updated_at=inserted_at, - deleted_at=None, - ) - - return PydanticResponse( - APIResponse[stickers.StickerPack]( - result=sticker_pack_preview, - ), - ) - - -@bind_implementation_to_method(sticker.GetSticker) -def get_sticker_from_sticker_pack(request: Request, settings: Settings) -> Response: - """Handle retrieving information of sticker from sticker pack request. - - Arguments: - request: HTTP request from Molten. - settings: application settings with storage. - - Returns: - Response with information of sticker from sticker pack. - """ - payload = sticker.GetSticker.parse_obj(request.params) - - add_request_to_collection(settings, payload) - sticker_from_sticker_pack = stickers.StickerFromPack( - id=uuid.uuid4(), - emoji="🐢", - link="http://some_link.com", - preview="http://preview_link.com", - ) - - return PydanticResponse( - APIResponse[stickers.StickerFromPack]( - result=sticker_from_sticker_pack, - ), - ) - - -@bind_implementation_to_method(add_sticker.AddSticker) -def post_add_sticker_into_sticker_pack( - request_data: RequestData, - settings: Settings, -) -> Response: - """Handle adding of sticker to sticker pack request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with result of adding. - """ - payload = add_sticker.AddSticker.parse_obj(request_data) - add_request_to_collection(settings, payload) - - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - sticker_from_sticker_pack = stickers.Sticker( - id=uuid.uuid4(), - emoji="🐢", - link="http://some_link.com", - inserted_at=inserted_at, - updated_at=inserted_at, - ) - - return PydanticResponse( - APIResponse[stickers.Sticker]( - result=sticker_from_sticker_pack, - ), - ) - - -@bind_implementation_to_method(create_sticker_pack.CreateStickerPack) -def post_create_sticker_pack(request: Request, settings: Settings) -> Response: - """Handle creating of sticker pack request. - - Arguments: - request: HTTP request from Molten. - settings: application settings with storage. - - Returns: - Response with result of creating. - """ - payload = create_sticker_pack.CreateStickerPack.parse_obj(request.params) - add_request_to_collection(settings, payload) - - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - pack_stickers = [ - stickers.Sticker( - id=uuid.uuid4(), - emoji="🐢", - link="http://some_link.com", - inserted_at=inserted_at, - updated_at=inserted_at, - ), - ] - sticker_from_sticker_pack = stickers.StickerPack( - id=uuid.uuid4(), - name="Test sticker pack", - preview=None, - public=False, - stickers_order=None, - stickers=pack_stickers, - inserted_at=inserted_at, - updated_at=inserted_at, - deleted_at=None, - ) - - return PydanticResponse( - APIResponse[stickers.StickerPack]( - result=sticker_from_sticker_pack, - ), - ) - - -@bind_implementation_to_method(edit_sticker_pack.EditStickerPack) -def post_edit_sticker_pack(request_data: RequestData, settings: Settings) -> Response: - """Handle editing of sticker pack request. - - Arguments: - request_data: parsed json data from request. - settings: application settings with storage. - - Returns: - Response with result of editing. - """ - payload = edit_sticker_pack.EditStickerPack.parse_obj(request_data) - add_request_to_collection(settings, payload) - - inserted_at = dt.fromisoformat("2019-08-29T11:22:48.358586+00:00") - pack_stickers = [ - stickers.Sticker( - id=uuid.uuid4(), - emoji="🐢", - link="http://some_link.com", - inserted_at=inserted_at, - updated_at=inserted_at, - ), - ] - sticker_from_sticker_pack = stickers.StickerPack( - id=uuid.uuid4(), - name="Test sticker pack", - preview=None, - public=False, - stickers_order=None, - stickers=pack_stickers, - inserted_at=inserted_at, - updated_at=inserted_at, - deleted_at=None, - ) - - return PydanticResponse( - APIResponse[stickers.StickerPack]( - result=sticker_from_sticker_pack, - ), - ) - - -@bind_implementation_to_method(delete_sticker_pack.DeleteStickerPack) -def post_delete_sticker_pack(request: Request, settings: Settings) -> Response: - """Handle deleting of sticker pack request. - - Arguments: - request: HTTP request from Molten. - settings: application settings with storage. - - Returns: - Response with result of deleting. - """ - payload = delete_sticker_pack.DeleteStickerPack.parse_obj(request.params) - add_request_to_collection(settings, payload) - - return PydanticResponse( - APIResponse[str]( - result="sticker_pack_deleted", - ), - ) - - -@bind_implementation_to_method(delete_sticker.DeleteSticker) -def delete_sticker_from_sticker_pack(request: Request, settings: Settings) -> Response: - """Handle deleting of sticker from sticker pack request. - - Arguments: - request: HTTP request from Molten. - settings: application settings with storage. - - Returns: - Response with result of deleting. - """ - payload = delete_sticker.DeleteSticker.parse_obj(request.params) - add_request_to_collection(settings, payload) - - return PydanticResponse( - APIResponse[str]( - result="sticker_deleted", - ), - ) diff --git a/botx/testing/botx_mock/wsgi/routes/users.py b/botx/testing/botx_mock/wsgi/routes/users.py deleted file mode 100644 index 1aef2425..00000000 --- a/botx/testing/botx_mock/wsgi/routes/users.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Endpoints for chats resource.""" - -from molten import Request, Response, Settings - -from botx.clients.methods.base import APIResponse -from botx.clients.methods.v3.users.by_email import ByEmail -from botx.clients.methods.v3.users.by_huid import ByHUID -from botx.clients.methods.v3.users.by_login import ByLogin -from botx.models.users import UserFromSearch -from botx.testing.botx_mock.binders import bind_implementation_to_method -from botx.testing.botx_mock.entities import create_test_user -from botx.testing.botx_mock.wsgi.messages import add_request_to_collection -from botx.testing.botx_mock.wsgi.responses import PydanticResponse - - -@bind_implementation_to_method(ByHUID) -def get_by_huid(request: Request, settings: Settings) -> Response: - """Handle retrieving information about user request. - - Arguments: - request: HTTP request from Molten. - settings: application settings with storage. - - Returns: - Response with information about user. - """ - payload = ByHUID.parse_obj(dict(request.params)) - add_request_to_collection(settings, payload) - return PydanticResponse( - APIResponse[UserFromSearch]( - result=create_test_user(user_huid=payload.user_huid), - ), - ) - - -@bind_implementation_to_method(ByEmail) -def get_by_email(request: Request, settings: Settings) -> Response: - """Handle retrieving information about user request. - - Arguments: - request: HTTP request from Molten. - settings: application settings with storage. - - Returns: - Response with information about user. - """ - payload = ByEmail.parse_obj(dict(request.params)) - add_request_to_collection(settings, payload) - return PydanticResponse( - APIResponse[UserFromSearch](result=create_test_user(email=payload.email)), - ) - - -@bind_implementation_to_method(ByLogin) -def get_by_login(request: Request, settings: Settings) -> Response: - """Handle retrieving information about user request. - - Arguments: - request: HTTP request from Molten. - settings: application settings with storage. - - Returns: - Response with information about user. - """ - payload = ByLogin.parse_obj(dict(request.params)) - add_request_to_collection(settings, payload) - return PydanticResponse( - APIResponse[UserFromSearch]( - result=create_test_user(ad=(payload.ad_login, payload.ad_domain)), - ), - ) diff --git a/botx/testing/building/__init__.py b/botx/testing/building/__init__.py deleted file mode 100644 index c32f8003..00000000 --- a/botx/testing/building/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Entities responsible for MessageBuilder.""" diff --git a/botx/testing/building/attachments.py b/botx/testing/building/attachments.py deleted file mode 100644 index 8b574ccb..00000000 --- a/botx/testing/building/attachments.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Mixin for building attachments.""" -from dataclasses import field - -from botx.models import attachments as attach -from botx.testing import content as test_content - - -class BuildAttachmentsMixin: - """Mixin for building attachments in message.""" - - attachments: attach.AttachList = field(default_factory=list) # type: ignore - - def image( - self, - content: str = test_content.PNG_DATA, # noqa: WPS110 - file_name: str = "image.jpg", - ) -> None: - """Add image into incoming message. - - Arguments: - content: image content in RFC 2397 format. - file_name: name of file. - """ - self.attachments.__root__.append( - attach.ImageAttachment( - data=attach.Image(content=content, file_name=file_name), - ), - ) - - def document( - self, - content: str = test_content.DOCX_DATA, # noqa: WPS110 - file_name: str = "document.docx", - ) -> None: - """Add document into incoming message. - - Arguments: - content: document content in RFC 2397 format. - file_name: name of file. - """ - self.attachments.__root__.append( - attach.DocumentAttachment( - data=attach.Document(content=content, file_name=file_name), - ), - ) - - def location( - self, - location_name: str = "loc_name", - location_address: str = "loc_address", - location_lat: int = 0, - location_lng: int = 0, - ) -> None: - """Add location into incoming message. - - Arguments: - location_name: name of location. - location_lat: latitude. - location_lng: longitude. - location_address: address of location. - """ - self.attachments.__root__.append( - attach.LocationAttachment( - data=attach.Location( - location_name=location_name, - location_lat=location_lat, - location_lng=location_lng, - location_address=location_address, - ), - ), - ) - - def voice( - self, - content: str = test_content.MP3_DATA, # noqa: WPS110 - duration: int = 10, # noqa: WPS110 - ) -> None: - """Add voice into incoming message. - - Arguments: - content: voice content in RFC 2397 format. - duration: voice duration. - """ - self.attachments.__root__.append( - attach.VoiceAttachment( - data=attach.Voice(duration=duration, content=content), - ), - ) - - def video( - self, - content: str = test_content.MP4_DATA, # noqa: WPS110 - file_name: str = "video.mp4", - duration: int = 10, - ) -> None: - """Add video into incoming message. - - Arguments: - content: voice content in RFC 2397 format. - file_name: name of file. - duration: video duration. - """ - self.attachments.__root__.append( - attach.VideoAttachment( - data=attach.Video( - content=content, - file_name=file_name, - duration=duration, - ), - ), - ) - - def link( - self, - url: str = "https://google.com", - url_preview: str = "https://image.jpg", - url_text: str = "foo", - url_title: str = "bar", - ) -> None: - """Add link into incoming message. - - Arguments: - url: link on resource. - url_preview: link on url preview. - url_text: presented text on link. - url_title: title of link. - """ - self.attachments.__root__.append( - attach.LinkAttachment( - data=attach.Link( - url=url, - url_preview=url_preview, - url_text=url_text, - url_title=url_title, - ), - ), - ) - - def contact(self, contact_name: str = "Foo") -> None: - """Add link into incoming message. - - Arguments: - contact_name: name of contact - """ - self.attachments.__root__.append( - attach.ContactAttachment(data=attach.Contact(contact_name=contact_name)), - ) diff --git a/botx/testing/building/builder.py b/botx/testing/building/builder.py deleted file mode 100644 index 16300c28..00000000 --- a/botx/testing/building/builder.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Builder for messages in tests.""" - -import uuid -from dataclasses import field -from typing import Any, Optional - -from pydantic import BaseConfig, validator -from pydantic.dataclasses import dataclass - -from botx.models.attachments import AttachList -from botx.models.entities import EntityList -from botx.models.enums import ChatTypes, ClientPlatformEnum, CommandTypes -from botx.models.messages.incoming_message import ( - Command, - DeviceMeta, - IncomingMessage, - Sender, -) -from botx.testing.building.attachments import BuildAttachmentsMixin -from botx.testing.building.entites import BuildEntityMixin -from botx.testing.building.validators import ( - convert_to_acceptable_file, - validate_body_corresponds_command, - validate_command_type_corresponds_command, -) - - -def _build_default_user() -> Sender: - return Sender( - 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", - manufacturer="Google", - device="Chrome 87.0", - device_software="macOS 10.15.7", - device_meta=DeviceMeta( - pushes=False, - timezone="Asia/Novosibirsk", - permissions={"microphone": True, "notifications": False}, - ), - platform=ClientPlatformEnum.web, - platform_package_id=None, - app_version="1.15.52", - locale="en", - ) - - -class BuilderConfig(BaseConfig): - """Config for builder dataclass.""" - - validate_assignment = True - - -@dataclass(config=BuilderConfig) -class MessageBuilder(BuildAttachmentsMixin, BuildEntityMixin): # noqa: WPS214 - """Builder for command message for bot.""" - - bot_id: uuid.UUID = field(default_factory=uuid.uuid4) - - command_data: dict = field(default_factory=dict) - system_command: bool = field(default=False) - file: Optional[Any] = field(default=None) - attachments: AttachList = field(default_factory=list) # type: ignore - user: Sender = field(default_factory=_build_default_user) - entities: EntityList = field(default_factory=list) # type: ignore - body: str = field(default="") - - _body_and_command_validator = validator("body", always=True)( - validate_body_corresponds_command, - ) - _command_type_and_data_validator = validator("system_command", always=True)( - validate_command_type_corresponds_command, - ) - _file_converter = validator("file", always=True)(convert_to_acceptable_file) - - @property - def message(self) -> IncomingMessage: - """Message that was built by builder.""" - command_type = CommandTypes.system if self.system_command else CommandTypes.user - command = Command( - body=self.body, - command_type=command_type, - data=self.command_data, - ) - return IncomingMessage( - sync_id=uuid.uuid4(), - command=command, - attachments=self.attachments, - file=self.file, - bot_id=self.bot_id, - user=self.user, - entities=self.entities, - ) diff --git a/botx/testing/building/entites.py b/botx/testing/building/entites.py deleted file mode 100644 index bf75b669..00000000 --- a/botx/testing/building/entites.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Mixin for building entities.""" - -import uuid -from dataclasses import field -from datetime import datetime -from typing import Optional - -from botx.models.attachments_meta import DocumentAttachmentMeta -from botx.models.entities import ( - ChatMention, - Entity, - EntityList, - Forward, - Mention, - MentionTypes, - Reply, - UserMention, -) -from botx.models.enums import ChatTypes, EntityTypes -from botx.models.messages.message import Message - - -class BuildEntityMixin: - """Mixin for building entities in message.""" - - entities: EntityList = field(default_factory=list) # type: ignore - - def mention_contact(self, user_huid: uuid.UUID) -> None: - """Add contact mention to message for bot. - - Arguments: - user_huid: huid of user to mention. - """ - self.entities.__root__.append( - Entity( - type=EntityTypes.mention, - data=Mention( - mention_data=UserMention(user_huid=user_huid), - mention_type=MentionTypes.contact, - ), - ), - ) - - def mention_user(self, user_huid: uuid.UUID) -> None: - """Add user mention to message for bot. - - Arguments: - user_huid: huid of user to mention. - """ - self.entities.__root__.append( - Entity( - type=EntityTypes.mention, - data=Mention(mention_data=UserMention(user_huid=user_huid)), - ), - ) - - def mention_chat(self, chat_id: uuid.UUID) -> None: - """Add chat mention to message for bot. - - Arguments: - chat_id: id of chat to mention. - """ - self.entities.__root__.append( - Entity( - type=EntityTypes.mention, - data=Mention( - mention_data=ChatMention(group_chat_id=chat_id), - mention_type=MentionTypes.chat, - ), - ), - ) - - def mention(self, mention: Mention) -> None: - """Add mention by mention model. - - Arguments: - mention: mention model to build. - """ - self.entities.__root__.append(Entity(type=EntityTypes.mention, data=mention)) - - def reply( - self, - *, - message: Optional[Message] = None, - reply: Optional[Reply] = None, - source_chat_name: str = "chat", - ) -> None: - """Add reply to message for bot. - - Arguments: - message: replied message. - reply: reply model to build. - source_chat_name: name of chat where message was reply. - - Raises: - ValueError: raise if conflict of requirement arguments. - """ - if message and not reply: - mentions = message.entities.mentions - - reply = Reply( - attachment=DocumentAttachmentMeta(file_name="test.doc"), - body=message.body, - mentions=mentions, - reply_type=ChatTypes(message.chat_type), - sender=message.user_huid, # type: ignore - source_chat_name=source_chat_name, - source_sync_id=message.sync_id, - source_group_chat_id=message.group_chat_id, - ) - elif reply and not message: - pass # noqa: WPS420 - else: - raise ValueError("Must be replied message of reply model") - self.entities.__root__.append(Entity(type=EntityTypes.reply, data=reply)) - - def forward( - self, - *, - message: Optional[Message] = None, - forward: Optional[Forward] = None, - source_chat_name: str = "chat", - source_inserted_at: datetime = datetime( # noqa: B008, WPS404 - 1, - 1, - 1, - 1, - 1, - 1, - 1, - None, - ), - ) -> None: - """Add forward to message for bot. - - Arguments: - message: forwarded message. - forward: forward model to build. - source_chat_name: name of chat where message was forward. - source_inserted_at: ts of forwarded message. - - Raises: - ValueError: raise if conflict of requirement arguments. - """ - if message and not forward: - assert message.group_chat_id is not None - forward = Forward( - group_chat_id=message.group_chat_id, - sender_huid=message.user_huid or uuid.uuid4(), - forward_type=ChatTypes(message.chat_type), - source_chat_name=source_chat_name, - source_sync_id=message.sync_id, - source_inserted_at=source_inserted_at, - ) - elif forward and not message: - pass # noqa: WPS420 - else: - raise ValueError("Must be forwarding message or forward") - - self.entities.__root__.append(Entity(type=EntityTypes.forward, data=forward)) diff --git a/botx/testing/building/validators.py b/botx/testing/building/validators.py deleted file mode 100644 index 3d1ea8d7..00000000 --- a/botx/testing/building/validators.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Validators and converters for fields in builder.""" - -from typing import Any, BinaryIO, Dict, Optional, TextIO, Union - -from botx.models import enums, events, files -from botx.models.messages.incoming_message import Sender - - -def validate_body_corresponds_command(body: str, values: dict) -> str: # noqa: WPS110 - """Check that passed body can be proceed. - - Arguments: - body: passed body. - values: already validated validated_values. - - Returns: - Checked passed body. - """ - _check_system_command_properties( - body, - values.get("system_command", False), - values["command_data"], - values, - ) - return body - - -def validate_command_type_corresponds_command( - is_system_command: bool, - values: dict, # noqa: WPS110 -) -> bool: - """Check that command type corresponds body. - - Arguments: - is_system_command: is command marked as system command. - values: already validated validated_values. - - Returns: - Checked flag. - """ - if is_system_command: - _check_system_command_properties( - values["body"], - is_system_command, - values["command_data"], - values, - ) - - return is_system_command - - -def convert_to_acceptable_file( - file: Optional[Union[files.File, BinaryIO, TextIO]], -) -> Optional[files.File]: - """Convert file to File that can be passed into message. - - Arguments: - file: passed file. - - Returns: - Converted file. - """ - if isinstance(file, files.File) or file is None: - return file - - new_file = files.File.from_file(file, filename="temp.txt") - new_file.file_name = file.name - return new_file - - -def _check_system_command_properties( - body: str, - is_system_command: bool, - command_data: dict, - validated_values: dict, -) -> None: - if is_system_command: - event = enums.SystemEvents(body) # check that is real system event - event_shape = events.EVENTS_SHAPE_MAP.get(event) - if event_shape is not None: - event_shape.parse_obj(command_data) # check event data - _event_checkers[event](**validated_values) # type: ignore - - -def _check_common_system_event(user: Sender, **_kwargs: Any) -> None: - error_field = "" - if user.user_huid is not None: - error_field = "user_huid" - elif user.ad_login is not None: - error_field = "ad_login" - elif user.ad_domain is not None: - error_field = "ad_domain" - elif user.username is not None: - error_field = "username" - - if error_field: - raise ValueError( - "user in system:chat_created can not have {0}".format(error_field), - ) - - -def _check_file_transfer_event(file: Optional[files.File], **_kwargs: Any) -> None: - if file is None: - raise ValueError("file_transfer event should have attached file") - - -def _check_internal_notification_event( - command_data: Dict[str, Any], - **_kwargs: Any, -) -> None: - assert "data" in command_data - - -_event_checkers = { - enums.SystemEvents.chat_created: _check_common_system_event, - enums.SystemEvents.added_to_chat: _check_common_system_event, - enums.SystemEvents.file_transfer: _check_file_transfer_event, - enums.SystemEvents.internal_bot_notification: _check_internal_notification_event, -} diff --git a/botx/testing/content.py b/botx/testing/content.py deleted file mode 100644 index eae291a9..00000000 --- a/botx/testing/content.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Module with test data content for files.""" - -JPG_DATA = "data:image/jpeg;base64,Lg==" -JPEG_DATA = "data:image/jpeg;base64,Lg==" -GIF_DATA = "data:image/gif;base64,Lg==" -PNG_DATA = "data:image/png;base64,Lg==" -DOC_DATA = "data:application/msword;base64,Lg==" -DOCX_DATA = "data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,Lg==" -XLS_DATA = "data:application/vnd.ms-excel;base64,Lg==" -XLSX_DATA = ( - "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,Lg==" -) -TXT_DATA = "data:text/plain;base64,Lg==" -PDF_DATA = "data:application/pdf;base64,Lg==" -HTML_DATA = "data:text/html;base64,Lg==" -JSON_DATA = "data:application/json;base64,Lg==" -SIG_DATA = "data:application/pgp-signature;base64,Lg==" -PPT_DATA = "data:application/vnd.ms-powerpoint;base64,Lg==" -PPTX_DATA = "data:application/vnd.openxmlformats-officedocument.presentationml.presentation;base64,Lg==" -MP3_DATA = "data:audio/mpeg;base64,Lg==" -MP4_DATA = "data:video/mp4;base64,Lg==" -GZ_DATA = "data:text/plain;base64,Lg==" -TGZ_DATA = "data:application/x-tar;base64,Lg==" -ZIP_DATA = "data:application/zip;base64,Lg==" -RAR_DATA = "data:application/vnd.rar;base64,Lg==" diff --git a/botx/testing/testing_client/__init__.py b/botx/testing/testing_client/__init__.py deleted file mode 100644 index 089d6c46..00000000 --- a/botx/testing/testing_client/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Definition of client for testing.""" diff --git a/botx/testing/testing_client/base.py b/botx/testing/testing_client/base.py deleted file mode 100644 index 3b608876..00000000 --- a/botx/testing/testing_client/base.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Base for testing client for bots.""" -from __future__ import annotations - -import asyncio -from concurrent.futures import ThreadPoolExecutor -from contextlib import contextmanager -from typing import Any, Dict, Generator, List, Optional, Tuple, Type - -import httpx - -from botx.bots.bots import Bot -from botx.clients.methods.base import BotXMethod -from botx.middlewares.exceptions import ExceptionMiddleware -from botx.models.messages.incoming_message import IncomingMessage -from botx.models.messages.message import Message -from botx.testing.botx_mock.asgi.application import get_botx_asgi_api -from botx.testing.botx_mock.wsgi.application import get_botx_wsgi_api -from botx.testing.typing import APIMessage, APIRequest - - -class _ExceptionMiddleware(ExceptionMiddleware): - """Replacement of built-in ExceptionMiddleware that will raise errors.""" - - async def _handle_error_in_handler(self, exc: Exception, message: Message) -> None: - handler = self._lookup_handler_for_exception(exc) - - if handler is None: - raise exc - - await super()._handle_error_in_handler(exc, message) - - -ErrorsOverrides = Dict[Type[BotXMethod], Tuple[int, Any]] - - -class BaseTestClient: - """Base for testing client for bots.""" - - def __init__( - self, - bot: Bot, - errors: Optional[ErrorsOverrides] = None, - suppress_errors: bool = False, - ) -> None: - """Init client with required query_params. - - Arguments: - bot: bot that should be tested. - errors: errors that should be raised from methods calls. - suppress_errors: if True then don't raise raise errors from handlers. - """ - self.bot: Bot = bot - self._original_http_client = bot.client.http_client - self._original_sync_http_client = bot.sync_client.http_client - self._error_middleware: Optional[ExceptionMiddleware] = None - self._messages: List[APIMessage] = [] - self._requests: List[APIRequest] = [] - self._errors = errors or {} - self._suppress_errors = suppress_errors - - def __enter__(self) -> BaseTestClient: - """Mock original HTTP clients.""" - is_error_middleware = isinstance( - self.bot.exception_middleware, - ExceptionMiddleware, - ) - if not self._suppress_errors and is_error_middleware: - self._error_middleware = self.bot.exception_middleware - self.bot.exception_middleware = _ExceptionMiddleware( - self.bot.exception_middleware.executor, - ) - self.bot.exception_middleware._exception_handlers = ( # noqa: WPS437 - self._error_middleware._exception_handlers # noqa: WPS437 - ) - - self.bot.client.http_client = httpx.AsyncClient( - app=get_botx_asgi_api(self._messages, self._requests, self._errors), - ) - self.bot.sync_client.http_client = httpx.Client( - app=get_botx_wsgi_api(self._messages, self._requests, self._errors), - ) - - return self - - def __exit__(self, *_: Any) -> None: - """Restore original HTTP client and clear storage.""" - if self._error_middleware is not None: - self.bot.exception_middleware = self._error_middleware - - ThreadPoolExecutor().submit( - asyncio.run, - self.bot.client.http_client.aclose(), - ).result() - self.bot.client.http_client = self._original_http_client - self.bot.sync_client.http_client = self._original_sync_http_client - self._messages = [] - - @contextmanager - def error_client( - self, - errors: Dict[Type[BotXMethod], Tuple[int, Any]], - ) -> Generator[BaseTestClient, None, None]: - """Enter into new test client that adds error responses to mocks. - - Arguments: - errors: overrides for errors in context. - - Yields: - New client with overridden errors. - """ - override_errors = {**self._errors, **errors} - with self.__class__(self.bot, override_errors, self._suppress_errors) as client: - yield client - - async def send_command(self, message: 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.wait_current_handlers() diff --git a/botx/testing/testing_client/client.py b/botx/testing/testing_client/client.py deleted file mode 100644 index a1e18aa4..00000000 --- a/botx/testing/testing_client/client.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Definition of client for testing.""" -from typing import Tuple, Union - -from botx.clients.methods.v3.command.command_result import CommandResult -from botx.clients.methods.v3.events.edit_event import EditEvent -from botx.clients.methods.v3.events.reply_event import ReplyEvent -from botx.clients.methods.v3.notification.direct_notification import NotificationDirect -from botx.clients.methods.v3.notification.notification import Notification -from botx.testing.testing_client.base import BaseTestClient -from botx.testing.typing import APIMessage, APIRequest - - -class TestClient(BaseTestClient): - """Test client for testing bots.""" - - # https://docs.pytest.org/en/latest/changelog.html#changes - # Allow to skip test classes from being collected - __test__: bool = False - - @property - def requests(self) -> Tuple[APIRequest, ...]: - """Return all requests that were sent by bot. - - Returns: - Sequence of requests that were sent from bot. - """ - return tuple(request.copy(deep=True) for request in self._requests) - - @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[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, CommandResult) - ) - - @property - def notifications(self) -> Tuple[Union[Notification, NotificationDirect], ...]: - """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, (Notification, NotificationDirect)) - ) - - @property - def message_updates(self) -> Tuple[EditEvent, ...]: - """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, EditEvent) - ) - - @property - def replies(self) -> Tuple[ReplyEvent, ...]: - """Return all replies that were sent by bot. - - Returns: - Sequence of replies that were sent by bot. - """ - return tuple( - message for message in self.messages if isinstance(message, ReplyEvent) - ) diff --git a/botx/testing/typing.py b/botx/testing/typing.py deleted file mode 100644 index c7be802f..00000000 --- a/botx/testing/typing.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Typings for test client and mocks.""" - -from typing import Union - -from botx.clients.methods.v2.bots import token -from botx.clients.methods.v3.chats import ( - add_admin_role, - add_user, - chat_list, - create, - info, - remove_user, - stealth_disable, - stealth_set, -) -from botx.clients.methods.v3.command import command_result -from botx.clients.methods.v3.events import edit_event, reply_event -from botx.clients.methods.v3.files import download, upload -from botx.clients.methods.v3.notification import direct_notification, notification -from botx.clients.methods.v3.stickers import ( - add_sticker, - create_sticker_pack, - delete_sticker, - delete_sticker_pack, - edit_sticker_pack, - sticker, - sticker_pack, - sticker_pack_list, -) -from botx.clients.methods.v3.users import by_email, by_huid, by_login - -APIMessage = Union[ - command_result.CommandResult, - notification.Notification, - direct_notification.NotificationDirect, - edit_event.EditEvent, - reply_event.ReplyEvent, -] - -APIRequest = Union[ - # V2 - # bots - token.Token, - # V3 - # chats - add_admin_role.AddAdminRole, - add_user.AddUser, - chat_list.ChatList, - info.Info, - remove_user.RemoveUser, - stealth_disable.StealthDisable, - stealth_set.StealthSet, - create.Create, - # command - command_result.CommandResult, - # notification - notification.Notification, - direct_notification.NotificationDirect, - # events - edit_event.EditEvent, - # users - by_huid.ByHUID, - by_email.ByEmail, - by_login.ByLogin, - # files - upload.UploadFile, - download.DownloadFile, - # stickers - sticker_pack_list.GetStickerPackList, - sticker_pack.GetStickerPack, - sticker.GetSticker, - add_sticker.AddSticker, - delete_sticker_pack.DeleteStickerPack, - delete_sticker.DeleteSticker, - create_sticker_pack.CreateStickerPack, - edit_sticker_pack.EditStickerPack, -] diff --git a/botx/typing.py b/botx/typing.py deleted file mode 100644 index dea6e83f..00000000 --- a/botx/typing.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Aliases for complex types from `typing`.""" - -from typing import TYPE_CHECKING, Awaitable, Callable, TypeVar, Union - -from botx.models.messages import message - -if TYPE_CHECKING: - from botx.bots.bots import Bot # noqa: WPS433 - -try: - from typing import Literal # noqa: WPS433 -except ImportError: - from typing_extensions import Literal # type: ignore # noqa: WPS433, WPS440, F401 - -ExceptionT = TypeVar("ExceptionT", bound=Exception) - -# Something that can handle new message -AsyncExecutor = Callable[[message.Message], Awaitable[None]] -SyncExecutor = Callable[[message.Message], None] -Executor = Union[AsyncExecutor, SyncExecutor] - -# Middlware dispatchers -AsyncMiddlewareDispatcher = Callable[[message.Message, AsyncExecutor], Awaitable[None]] -SyncMiddlewareDispatcher = Callable[[message.Message, SyncExecutor], None] -MiddlewareDispatcher = Union[AsyncMiddlewareDispatcher, SyncMiddlewareDispatcher] - -# Exception handlers -AsyncExceptionHandler = Callable[[ExceptionT, message.Message], Awaitable[None]] -SyncExceptionHandler = Callable[[ExceptionT, message.Message], None] -ExceptionHandler = Union[AsyncExceptionHandler, SyncExceptionHandler] - -# Startup and shutdown events -AsyncLifespanEvent = Callable[["Bot"], Awaitable[None]] -SyncLifespanEvent = Callable[["Bot"], None] -BotLifespanEvent = Union[AsyncLifespanEvent, SyncLifespanEvent] diff --git a/docs/botx_api.md b/docs/botx_api.md new file mode 100644 index 00000000..29e40ec8 --- /dev/null +++ b/docs/botx_api.md @@ -0,0 +1,19 @@ +# BotX API + +--- + +## [Bots API](https://hackmd.ccsteam.ru/s/E9MPeOxjP#Bots-API) + +### [Получение токена](https://hackmd.ccsteam.ru/s/E9MPeOxjP#%D0%9F%D0%BE%D0%BB%D1%83%D1%87%D0%B5%D0%BD%D0%B8%D0%B5-%D1%82%D0%BE%D0%BA%D0%B5%D0%BD%D0%B0) + +Токен можно получить для каждого из добавленных аккаунтов бота. Для выбора аккаунта +используется его ID. + +!!! note + + Вряд ли вам когда-нибудь понадобится запрашивать токен вручную, `pybotx` + получает их автоматически. + +``` py +--8<-- "docs/snippets/client/bots_api/get_token.py" +``` diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index d207ca3f..00000000 --- a/docs/changelog.md +++ /dev/null @@ -1,864 +0,0 @@ -## 0.28.0 (Nov 11, 2021) - -### Added - -* SmartApps main functionality. - - -## 0.27.0 (Nov 8, 2021) - -### Added - -* `pin_message` and `unpin_message` methods. - - -## 0.26.0 (Nov 1, 2021) - -### Added - -* Added methods for interacting with sticker pack/stickers - `get_sticker_pack_list`, `get_sticker_pack`. `get_sticker_from_pack`, `create_sticker_pack`, `add_sticker`, `edit_sticker_pack`, `delete_sticker_pack`, `delete_sticker`. - - -## 0.25.1 (Oct 22, 2021) - -### Changed - -* Add `embed_mentions` argument in `answer_message` method. - - -## 0.25.0 (Sep 17, 2021) - -### Added - -* `cts_login` and `cts_logout` system events. - - -## 0.24.0 (Sep 14, 2021) - -### Removed - -* File extensions validation. -* `File.has_supported_extension` classmethod. - -### Added - -* Multiple mime-types. - -### Changed - -* `File.get_ext_by_mimetype` now don't raise `ValueError` and returns `None` if mimetype not found. - - -## 0.23.2 (Sep 09, 2021) - -### Added - -* Add `file_name` param to `download_file` method to provide ability to change the returned file name. - - -## 0.23.1 (Aug 30, 2021) - -### Fixed - -* Memory leak in bot.tasks collection. - - -## 0.23.0 (Aug 23, 2021) - -### Added - -* Add method for uploading files to chat. -* Add method for downloading files from chat. - -### Changed - -* Add `data` and `files` fields to `HTTPRequest` for sending multipart/form-data in request. -* Add `expected_type` field to `HTTPRequest` and `expected_type` property to `BaseBotXMethod` - to allow interacting with non JSON responses. -* Add `should_process_as_error` field to `HTTPRequest` so that errors that are not in - the range of 400 to 599 can be added. -* Add `raw_data` to `HTTPResponse`so that you can process raw content of response. - - -## 0.22.1 (Aug 23, 2021) - -### Fixed - -* Add `embed_mentions` argument in `SendingMessage.from_message` method. -* Fix `EMBED_MENTION_RE` expression. - - -## 0.22.0 (Aug 19, 2021) - -Tested on BotX 1.44.0-rc2 - -### Added - -* Sending and handling internal bot notifications. - - -## 0.21.3 (Aug 17, 2021) - -### Fixed - -* Bot's method `authorize()` now not fall if cant take some tokens. Just logging and skip invalid credentials. - - -## 0.21.2 (Aug 3, 2021) - -### Fixed - -* `File` is now serializing when sending message. - - -## 0.21.1 (Jul 28, 2021) - -### Fixed - -* Make the `body` attribute of `Reply` event optional. -* Add `AttachmentMeta` model to `Reply` event instead of `Attachments`. - - -## 0.21.0 (Jul 23, 2021) - -Tested on BotX 1.42.0-rc4 - -### Fixed - -* Remove `Dict[str, Any]` from type of `error_data` field of `BotDisabledResponse`, - now it can only be `BotDisabledErrorData`. - - -## 0.20.4 (Jul 23, 2021) - -Tested on BotX 1.42.0-rc4 - -### Added - -* Add possibility to send message that visible only in stealth mode. -* Add support for embed mentions (can be used anywhere in text). - -### Fixed - -* Fix silent response by changing option location in method call. - - -## 0.20.3 (Jul 22, 2021) - -Tested on BotX 1.42.0-rc4 - -### Add - -* Add possibility to create chats with enabled shared_history option (Bot.create_chat). - - -## 0.20.2 (Jul 22, 2021) - -Tested on BotX 1.42.0-rc4 - -### Changed - -* Exceptions thrown in `exception_handler` are now logged. - - -## 0.20.1 (Jul 19, 2021) - -Tested on BotX 1.42.0-rc4 - -### Added - -* Add `bot not found` error handler for `Token` method. -* Add `invalid bot credentials` error handler for `Token` method. -* Add `connection error` handler for all BotX methods. -* Add `JSON decoding error` handler for all BotX methods. - - -## 0.20.0 (Jul 08, 2021) - -Tested on BotX 1.42.0-rc4 - -### Added - -* Add method for retrieving list of bot's chats -* Add `inserted_at` field to `ChatFromSearch` model - -### Changed - -* `HTTPRequest` & `HTTPResponse` moved to `clients.types` -* `HTTPRequest` now work with JSON (dict) instead of bytes. It improves consistency with - `HTTPResponse` and will be useful in interceptors implementation. -* Reply event field `source_chat_name` is optional now -* Forward event field `source_sync_id` is required now - -### Removed - -* `HTTPResponse` bytes content property - -### Fixed - -* `File` is now deleted when the message is updated -* `Bot_id` is now displayed in the request -* Add description for `BotXAPIError` - - -## 0.19.1 (May 21, 2021) - -Tested on BotX 1.40.0-rc0 - -### Changed - -* `method.host` assignment moved to method constructor in unit-tests - - -## 0.19.0 (May 18, 2021) - -Tested on BotX 1.40.0-rc0 - -### Added - -* Bot now has method `authorize` for explicit authorization each account -* Add `source_sync_id` field to `Message` which contains id of the message if it was sent from button - -### Changed - -* `ExpressServer` renamed to `BotXCredentials` -* Bot id now required for `BotXCredentials` - -### Removed - -* `send_from_button` field from `Message` -* `ui` flag from button's data - -### Fixed - -* Fix pydantic deprecation warning: `whole` flag renamed to `each_item` - -## 0.18.4 (Apr 08, 2021) - -Tested on BotX 1.40.0-rc0 - -### Changed - -* Raise special exception (BotXAPIRouteDeprecated) on response 410 GONE - -## 0.18.3 (Apr 05, 2021) - -### Added - -* Edit events and answer message now support metadata - -## 0.18.2 (Apr 01, 2021) - -### Changed - -* Bot can recognize mention all (@all) now - -## 0.18.1 (Mar 22, 2021) - -### Changed - -* Fixed empty label bug, now you can use empty string as button label - -## 0.18.0 (Mar 13, 2021) - -### Changed - -* Now `message.data` returns concatenated metadata and data from UI element -* `message.user.email` renamed to `message.user.upn` - -## 0.17.1 (Feb 9, 2021) - -### Added - -* Add `handler` option for bubbles and keyboard buttons - - -## 0.17.0 (Jan 28, 2021) - -### Changed - -* Mimetypes are now taken from constant dict, not `mimetypes` library -* All models use enum's fields by value - -### Fixed - -* `File.media_type` property - -### Added - -* Now `File` has property `size_in_bytes` -* New fields in **message.user**: `manufacturer`, `device`, `device_software`, - `device_meta`, `platform`, `platform_package_id`, `app_version`, `locale`. - - -## 0.16.10 (Dec 29, 2020) - -### Added - -* Attachments now have property `attach_type` with type of attachment - - -## 0.16.9 (Dec 29, 2020) - -### Fixed - -* Dependencies added trought `include_collector` don't called - - -## 0.16.8 (Dec 24, 2020) - -### Added - -* `async_from_file`, `file_chunks` methods for `File` to async work with attachments -* new dependency "base64io" - -### Changed - -* `from_file` now uses stream base64 encoding - - -## 0.16.7 (Dec 23, 2020) - -### Fixed - -* has_supported_extension now supports uppercase extensions - - -## 0.16.6 (Dec 22, 2020) - -### Changed - -* Attachments now have default value for type - -### Fixed - -* Default handler don't process system events anymore - - -## 0.16.5 (Dec 22, 2020) - -### Added - -* New static method to check that file extension can be handled by BotX API - - -## 0.16.4 (Dec 16, 2020) - -### Fixed - -* Fixed casting Voice attachment to Video - - -## 0.16.3 (Dec 14, 2020) - -### Fixed - -* Now you can accept File with unsupported extension - - -## 0.16.2 (Dec 11, 2020) - -### Fixed - -* Now you can reply with mention (no text or file) - - -## 0.16.1 (Dec 09, 2020) - -### Added - -* StatusRecipient import from core module - - -## 0.16.0 (Dec 07, 2020) - -### Added - -* Support of attachments in messages for bot's api v4 -* Support of reply in messages for bot's api v4 -* Builder of attachments in MessageBuilder -* Test content in RFC 2397 format -* Entity building methods for `MessageBuilder` -* Flag `is_forward` for `Message` -* Bot's method `reply` for reply by message - -### Changed - -* Type of `message.entities` from `List[Attachment]` to is `EntityList` -* `send()` sending only direct notification, not command result - - -## 0.15.17 (Nov 20, 2020) - -### Changed - -* Now you can update attached file while updating message - - -## 0.15.16 (Nov 19, 2020) - -### Added - -* New event `left_from_chat`, which stores HUIDs of members who left the chat - - -## 0.15.15 (Nov 17, 2020) - -### Added - -* New event `deleted_from_chat`, which stores deleted members HUIDs - - -## 0.15.14 (Nov 13, 2020) - -### Fixed - -* `added_to_chat` event is created properly now - - -## 0.15.13 (Nov 6, 2020) - -## Added - -* Add `bot_id` to outgoing messages debug logs. Incoming messages already have it - - -## 0.15.12 (Oct 30, 2020) - -### Added - -* Added width weight for bubbles and keyboard buttons (`h_size`). The more width weight, - the more horizontal space is occupied by button. - - -## 0.15.11 (Oct 30, 2020) - -### Added - -* Added `.sig`, `.mp3` and `.mp4` to allowed file formats - - -## 0.15.10 (Oct 29, 2020) - -### Added - -* Added alerts (toast on button press) for bubbles and keyboard buttons - - -## 0.15.9 (Oct 28, 2020) - -### Added - -* New message option 'silent_response' to hide next user messages in chat - - -## 0.15.8 (Oct 23, 2020) - -### Changed - -* `httpx` dependency was updated to 0.16 -* `loguru` dependency was updated to 0.5 - - -## 0.15.7 (Oct 22, 2020) - -### Added - -* New bot method `add_admin_roles` for promoting users to admins (bot should have - admin role itself) - - -## 0.15.6 (Oct 12, 2020) - -### Fixed - -* Fix `system:chat_created` event (`UserInChatCreated.name` now optional) - - -## 0.15.5 (Sep 16, 2020) - -### Added - -* Added `.ppt` and `.pptx` to allowed file formats - - -## 0.15.4 (Sep 4, 2020) - -### Added - -* Update accepted extensions list (`.jpg`, `.jpeg`, `.gif`, `.png`, `.json`, `.zip`, `.rar`) - - -## 0.15.3 (Aug 26, 2020) - -### Added - -* Models were added to API Reference - -### Fixed - -* Fix "handler not found" error message - - -## 0.15.2 (Aug 7, 2020) - -### Fixed - -* Fix overwriting SendingMessage credentials - - -## 0.15.1 (Aug 4, 2020) - -### Fixed - -* Fix optional fields in UserFromSearch model - - -## 0.15.0 (Jul 23, 2020) - -### Added - -* Added startup and shutdown lifespan events. -* Added support for synchronous requests (now `molten` is required for tests). -* Added support for `system:added_to_chat` event. -* Added support for forwarded messages. -* Added support for passing additional arguments to `Bot.status()`. -* Added methods for: - * chat creation; - * information about chat retrieving; - * search user by email, user HUID or AD login/domain. -* Add client flag for logger. -* Allow to update message through `.send()` from bot. -* Add `metadata` property to message. - -### Fixed - -* Fix information about sender for `system:chat_created` event. -* Fix crash when forwarding message to bot. -* Fix error on creating empty credentials. - -### Changed - -* Simplify middlewares. Now sync middlewares will receive sync `call_next` and -asynchronous async `call_next`. -* `TestClient` will now propagate unhandled errors. -* Rewrite inner clients. They now work with `methods` classes. -* Update `httpx` to `^0.13.0` -* Use bot as default dependency overrides provider. -* Simplify cache key. - - -## 0.14.1 (Apr 29, 2020) - -### Added - -* Add flag for messages sent from buttons. - -### Fixed - -* Attach dependencies on default handler when collector is included by another. -* Suppress `KeyError` when dropping key from next steps storage. - -## 0.14.0 (Apr 3, 2020) - -### Added - -* Support for external entities in incoming message, like mentions. -* Add ability to specify custom message id for new message from bot. - -### Changed - -* Refactor exceptions to be more usefule. Now exceptions have additional properties with extra data. -* If there will be an error when convering function to handler or dependency, then exception will contain information about failed attributes. -* Collector will iterate through handlers in right order. -* Change deploing documentation to github pages from master branch. - -### Fixed - -* Fix shape for `bot_disabled` response. - -## 0.13.6 (Mar 20, 2020) - -### Added - -* Add Netlify for documention preview. -* Parse handler docstring as `full_description` for handler. -* Preserve order of added handlers. - -### Changed - -* Skip validation for incoming file, so files with unsupported extensions. - -### Fixed - -* Fix logging file. - -## 0.13.5 (Mar 6, 2020) - -### Added - -* Add channel type into `MentionTypes`. - -### Changed - -* Replace travis with github actions. - -### Fixed - -* Fix dependencies extending when use `Collector.include_collector` and dependencies are -defined in collector initialization. -* Fix default handler including into another collector. -* Fix message text logging. -* Fix internal links in docs. - -## 0.13.4 (Mar 3, 2020) - -### Added - -* Examples of bots that are built using `pybotx`: - * Bot that defines finite-state machine behaviour for handlers. - -### Changed - -* Log exception traceback with `logger.exception` instead of `logger.error` when error was -not caught. -* Default handler will be excluded from status by default (as it was in library versions before 0.13.0). - -## 0.13.3 (Feb 26, 2020) - -### Added - -* Add background dependencies to next step middleware. -* Next step break handler can be registered as function. -* Add methods to add/remove users to/from chat using `Bot.add_users_into_chat()` and `Bot.remove_users_from_chat()`. - -### Fixed - -* Add missing `dependency_overrides_provider` to `botx.collecting.Collector.add_handler`. -* Encode message update payload by alias. - -### Changed - -* Refactored next step middleware -* Next step middleware won't now lookup for handler in bot. -* Disable `loguru` logger by default. - -## 0.13.2 (Feb 14, 2020) - -### Fixed - -* Check that there are futures while stopping bot. -* Strip command in `Handler.command_for` so no space at the end. - -## 0.13.1 (Feb 6, 2020) - -### Added - -* Stealth mode enable/disable methods `Bot.enable_stealth_mode()` and `Bot.disable_stealth_mode()`. - -## 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 (Oct 12, 2019) - -### Changed - -* Update `httpx` to `0.7.5`. -* Use `https` for connecting to BotX API. - -### Fixed - -* Remove reference about `HandlersCollector.regex_handler` from docs. - -## 0.12.2 (Sep 8, 2019) - -### Fixed - -* Clear `AsyncBot` tasks on shutdown. - -## 0.12.1 (Sep 2, 2019) - -### Changed - -* Upgrade `pydantic` to `0.32.2`. - -### Added - -* Added `channel` type to `ChatTypeEnum`. - -### Fixed - -* Export `UserKindEnum` from `botx`. - - -## 0.12.0 (Aug 30, 2019) - -### Changed - -* `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. -* Replaced `requests` and `aiohttp` with `httpx`. -* 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`. -* Added dependency injection system -* Added parsing command query_params into handler arguments. - -### Removed - -* `system_command_handler` argument has been removed from the `HandlersCollector.handler` method. -* Dropped `aiojobs`. - -### Fixed - -* Fixed `opts` shape. - -## 0.11.3 (Jul 24, 2019) - -### Fixed - -* Catch `IndexError` when trying to get next step handler for the message and there isn't available. - -## 0.11.2 (Jul 17, 2019) - -### 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. -* 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 - -* `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 (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. - -## 0.10.0 (May 28, 2019) - -### Changed - -* 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. - -### 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 - -* Fixed some mypy types issues. -* 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 - -* Close `aiohttp.client.ClientSession` when calling `AsyncBot.stop()`. - -## 0.9.2 (Mar 27, 2019) - -### Removed - -* Delete unused for now argument `bot` from thread wrapper. - -## 0.9.1 (Mar 27, 2019) - -### Fixed - -* Log unhandled exception from synchronous handlers. - -## 0.9.0 (Mar 18, 2019) - -### Added - -* First public release in PyPI. -* Synchronous and asynchronous API for building bots. diff --git a/docs/css/custom.css b/docs/css/custom.css new file mode 100644 index 00000000..f0c708c1 --- /dev/null +++ b/docs/css/custom.css @@ -0,0 +1,10 @@ +div.autodoc-docstring { + padding-left: 20px; + margin-bottom: 30px; + border-left: 5px solid rgba(230, 230, 230); +} + +div.autodoc-members { + padding-left: 20px; + margin-bottom: 15px; +} diff --git a/docs/development/collector.md b/docs/development/collector.md deleted file mode 100644 index f984b9d4..00000000 --- a/docs/development/collector.md +++ /dev/null @@ -1,55 +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. - -### Collector - -[Collector][botx.collecting.collectors.collector.Collector] is a class that can collect registered handlers -inside itself and then transfer them to bot. - -Using [Collector][botx.collecting.collectors.collector.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.bots.Bot] instance using the [`.include_collector`][botx.bots.mixins.collectors.BotCollectingMixin.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.collectors.collector.Collector] and [Bot][botx.bots.bots.Bot] instances. -You can register: - -* regular handlers using [`.handler`][botx.collecting.collectors.mixins.handler.HandlerMixin.handler] decorator. -* default handlers, that will be used if matching handler was not found using [`.default`][botx.collecting.collectors.mixins.default.DefaultHandlerMixin.default] decorator. -* hidden handlers, that won't be showed in bot's menu using [`.hidden`][botx.collecting.collectors.mixins.hidden.HiddenHandlerMixin.hidden] decorator. -* system event handlers, that will be used for handling special events from BotX API using [`.system_event`][botx.collecting.collectors.mixins.system_events.SystemEventsHandlerMixin.system_event] decorator. -* and some other type of handlers. See API reference for bot or collector for more information. diff --git a/docs/development/dependencies-injection.md b/docs/development/dependencies-injection.md deleted file mode 100644 index a51592b4..00000000 --- a/docs/development/dependencies-injection.md +++ /dev/null @@ -1,35 +0,0 @@ -`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 -{!./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 -{!./src/development/dependencies_injection/dependencies_injection1.py!} -``` - -## Special dependencies: Bot and Message - -[Bot][botx.bots.bots.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: - -```python3 -{!./src/development/dependencies_injection/dependencies_injection2.py!} -``` - -[DependencyFailure][botx.exceptions.DependencyFailure] exception is used for preventing execution -of dependencies after one that failed. - -Also, if you define a list of dependencies objects in the initialization of [collector][botx.collecting.collectors.collector.Collector] or [bot][botx.bots.bots.Bot] or in `.handler` decorator or others, -then these dependencies will be processed as background dependencies. -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 deleted file mode 100644 index 41f520b6..00000000 --- a/docs/development/first-steps.md +++ /dev/null @@ -1,176 +0,0 @@ -Let's create a new bot, which will ask the user for his data, and then send some statistics from the collected information. -Take echo-bot, from the [Introduction](../index.md), and gradually improve it step by step. - -## Starting point - -Right now we have the following code: - -```Python3 -{!./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`, `Status` and other classes - -```Python3 hl_lines="1" -{!./src/development/first_steps/first_steps0.py!} -``` - -* `Bot` is a class that provides all the core functionality to your bots. -* `Message` provides data to your handlers for commands. -* `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` is used for storing information -about servers with which your bot is able to communicate. -* `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="5" -{!./src/development/first_steps/first_steps0.py!} -``` - -The `bot` variable will be an "instance" of the class `Bot`. -We also register an instance of the cts server to get tokens and the ability to send requests to the API. - -### Step 3: define default handler - -```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="10" -{!./src/development/first_steps/first_steps0.py!} -``` - -[`.answer_message`][botx.bots.mixins.sending.SendingMixin.answer_message] will send text to the user by using -`sync_id`, `bot_id` and `host` data from the `Message` instance. -This is a simple wrapper for the [`.send`][botx.bots.mixins.sending.SendingMixin.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 handler for bot proper shutdown. - -```Python3 hl_lines="14" -{!./src/development/first_steps/first_steps0.py!} -``` - -The `.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="17 22" -{!./src/development/first_steps/first_steps0.py!} -``` - -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. - -!!! info - - If `.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](./handling-errors.md). 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 - -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 - -Let's define a new handler that will trigger a chain of questions for the user to collect information. - -We'll use the `/fill-info` command to start the chain: - -```Python3 hl_lines="14" -{!./src/development/first_steps/first_steps2.py!} -``` - -Here we define a new handler for `/fill-info` command using `.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: - - * `/my-info` will just send the information that users have filled out about themselves. - * `/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="32 50" -{!./src/development/first_steps/first_steps3.py!} -``` - -Take a look at highlighted lines. `.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. - * 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 by mechanism of -middlewares. It also provides a middleware for handling chains of messages by [`Next Step Middleware`][botx.middlewares.ns.NextStepMiddleware]. - -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. - -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!} -``` - -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!} -``` - -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. - -```Python3 hl_lines="14 24 28 33 48 68" -{!./src/development/first_steps/first_steps4.py!} -``` - -### 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` 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 then, but this not our case now. diff --git a/docs/development/handling-errors.md b/docs/development/handling-errors.md deleted file mode 100644 index 464bd21e..00000000 --- a/docs/development/handling-errors.md +++ /dev/null @@ -1,10 +0,0 @@ -`pybotx` provides a mechanism for registering a handler for exceptions that may occur in your command handlers. -By default, these errors are simply logged to the console, but you can register different behavior and perform some actions. -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. - -## Usage Example - -```python3 -{!./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 deleted file mode 100644 index 450ca097..00000000 --- a/docs/development/logging.md +++ /dev/null @@ -1,7 +0,0 @@ -`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 deleted file mode 100644 index 764c6afe..00000000 --- a/docs/development/sending-data.md +++ /dev/null @@ -1,115 +0,0 @@ -`Bot` from `pybotx` provide you 3 methods for sending message to the user (with some additional data) and 1 for sending the file: - -* `.send` - send a message by passing a `SendingMessage`. -* `.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`. -At most cases you'll prefer `.send` method over this one. -* `.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 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 `.send` - -`.send` is used to send a message. - -Here is an example of using this method outside from handler: - -```Python3 -{!./src/development/sending_data/sending_data0.py!} -``` - -or inside command handler: - -```Python3 -{!./src/development/sending_data/sending_data1.py!} -``` - -### Using `.answer_message` - -`.answer_message` is very useful for replying to command. - -```Python3 -{!./src/development/sending_data/sending_data2.py!} -``` - -### Send file - -There are several ways to send a file from bot: - -* Attach file to an instance of `SendingMessage`. -* Pass file to `file` argument into `.answer_message` or `.send_message` methods. -* Use `.send_file`. - -#### Attach file to already built message or during initialization - -```Python3 -{!./src/development/sending_data/sending_data3.py!} -``` - -#### Pass file as argument - -```Python3 -{!./src/development/sending_data/sending_data4.py!} -``` - -#### Using `.send_file` - -```Python3 -{!./src/development/sending_data/sending_data5.py!} -``` - -### Attach interactive buttons to your message - -You can attach bubbles or keyboard buttons to your message. This can be done using -`MessageMarkup` class. -A `Bubble` is a button that is stuck to your message. -A `Keyboard` is a panel that will be displayed when -you click on the messege with the icon. - -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 -{!./src/development/sending_data/sending_data6.py!} -``` - -Or like this: - -```Python3 -{!./src/development/sending_data/sending_data7.py!} -``` - - -Also you can attach buttons to `SendingMessage` passing it -into `__init__` or after: - -```Python3 -{!./src/development/sending_data/sending_data8.py!} -``` - -### Mention users or another chats in message - -You can mention users or another chats in your messages and they will receive notification -from the chat, even if this chat was muted. - -There are 2 types of mentions for users: - -* Mention user in chat where message will be sent -* Mention just user account - -Here is an example - -```Python3 -{!./src/development/sending_data/sending_data9.py!} -``` diff --git a/docs/development/tests.md b/docs/development/tests.md deleted file mode 100644 index 09e8f2e5..00000000 --- a/docs/development/tests.md +++ /dev/null @@ -1,32 +0,0 @@ -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 using [TestClient][botx.testing.testing_client.client.TestClient]. -Then you write some mocks and test your logic inside tests. In this example we will `pytest` for unit tests. - -## Example - -### Bot - -Suppose we have a bot that returns a message in the format `"Hello, {username}"` with the command `/hello`: - -`bot.py`: -```python3 -{!./src/development/tests/tests0/bot.py!} -``` - -### Fixtures - -Now let's write some fixtures to use them in our tests: - -`conftest.py`: -```python3 -{!./src/development/tests/tests0/conftest.py!} -``` - -### Tests - -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` -```python3 -{!./src/development/tests/tests0/test_format_command.py!} -``` diff --git a/docs/index.md b/docs/index.md index 56dfe1ec..99408aba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,99 +1,5 @@ -

pybotx

-

- A little python framework for building bots for eXpress messenger. -

-

- - Tests - - - Styles - - - Coverage - - - Code Style - - - Package version - - - License - -

+# Минимальный пример бота (интеграция с FastAPI) - ---- - -# Introduction - -`pybotx` is a framework for building bots for eXpress providing a mechanism for simple -integration with your favourite asynchronous web frameworks. - -Main features: - - * Simple integration with your web apps. - * Asynchronous API with synchronous as a fallback option. - * 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.15.0" - ... - ---- - -## Requirements - -Python 3.7+ - -`pybotx` use the following libraries: - -* 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 +``` py +--8<-- "docs/snippets/minimal_example.py" ``` - -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 -``` - -## Example - -Let's create a simple echo bot. - -* Create a file `main.py` with following content: -```Python3 -{!./src/index/index0.py!} -``` - -* Deploy a bot on your server using uvicorn and set the url for the webhook in Express. -```bash -$ uvicorn main:app --host=0.0.0.0 -``` - -This bot will send back every your message. - -## License - -This project is licensed under the terms of the MIT license. diff --git a/docs/reference/bots.md b/docs/reference/bots.md deleted file mode 100644 index 4da587f1..00000000 --- a/docs/reference/bots.md +++ /dev/null @@ -1,37 +0,0 @@ -::: botx.bots.bots - -::: botx.bots.mixins.collectors - -::: botx.bots.mixins.collecting.handler - -::: botx.bots.mixins.collecting.add_handler - -::: botx.bots.mixins.collecting.default - -::: botx.bots.mixins.collecting.hidden - -::: botx.bots.mixins.collecting.system_events - -::: botx.bots.mixins.exceptions - -::: botx.bots.mixins.lifespan - -::: botx.bots.mixins.middlewares - -::: botx.bots.mixins.sending - -::: botx.bots.mixins.clients - -::: botx.bots.mixins.requests.mixin - -::: botx.bots.mixins.requests.bots - -::: botx.bots.mixins.requests.chats - -::: botx.bots.mixins.requests.command - -::: botx.bots.mixins.requests.events - -::: botx.bots.mixins.requests.notification - -::: botx.bots.mixins.requests.users diff --git a/docs/reference/clients/async-client.md b/docs/reference/clients/async-client.md deleted file mode 100644 index 427183f1..00000000 --- a/docs/reference/clients/async-client.md +++ /dev/null @@ -1 +0,0 @@ -::: botx.clients.clients.async_client diff --git a/docs/reference/clients/methods.md b/docs/reference/clients/methods.md deleted file mode 100644 index 5d4989bc..00000000 --- a/docs/reference/clients/methods.md +++ /dev/null @@ -1,38 +0,0 @@ -## Base Method - -::: botx.clients.methods.base - -## V3 - -## chats - -::: botx.clients.methods.v3.chats.add_admin_role - -::: botx.clients.methods.v3.chats.add_user - -::: botx.clients.methods.v3.chats.create - -::: botx.clients.methods.v3.chats.info - -::: botx.clients.methods.v3.chats.remove_user - -::: botx.clients.methods.v3.chats.stealth_disable - -::: botx.clients.methods.v3.chats.stealth_set - -## command - -::: botx.clients.methods.v3.command.command_result - - -## notification - -::: botx.clients.methods.v3.notification.direct_notification - -# users - -::: botx.clients.methods.v3.users.by_email - -::: botx.clients.methods.v3.users.by_huid - -::: botx.clients.methods.v3.users.by_login diff --git a/docs/reference/clients/sync-client.md b/docs/reference/clients/sync-client.md deleted file mode 100644 index 2f24bd95..00000000 --- a/docs/reference/clients/sync-client.md +++ /dev/null @@ -1 +0,0 @@ -::: botx.clients.clients.sync_client \ No newline at end of file diff --git a/docs/reference/collecting.md b/docs/reference/collecting.md deleted file mode 100644 index 8dcf2fbe..00000000 --- a/docs/reference/collecting.md +++ /dev/null @@ -1,11 +0,0 @@ -::: botx.collecting.collectors.base - -::: botx.collecting.collectors.collector - -::: botx.collecting.collectors.mixins.default - -::: botx.collecting.collectors.mixins.handler - -::: botx.collecting.collectors.mixins.hidden - -::: botx.collecting.collectors.mixins.system_events diff --git a/docs/reference/exceptions.md b/docs/reference/exceptions.md deleted file mode 100644 index 05a55380..00000000 --- a/docs/reference/exceptions.md +++ /dev/null @@ -1 +0,0 @@ -::: botx.exceptions \ No newline at end of file diff --git a/docs/reference/middlewares/authorization.md b/docs/reference/middlewares/authorization.md deleted file mode 100644 index 59fd3123..00000000 --- a/docs/reference/middlewares/authorization.md +++ /dev/null @@ -1 +0,0 @@ -::: botx.middlewares.authorization \ No newline at end of file diff --git a/docs/reference/middlewares/base.md b/docs/reference/middlewares/base.md deleted file mode 100644 index b453947a..00000000 --- a/docs/reference/middlewares/base.md +++ /dev/null @@ -1 +0,0 @@ -::: botx.middlewares.base \ No newline at end of file diff --git a/docs/reference/middlewares/ns.md b/docs/reference/middlewares/ns.md deleted file mode 100644 index d4d6af3e..00000000 --- a/docs/reference/middlewares/ns.md +++ /dev/null @@ -1 +0,0 @@ -::: botx.middlewares.ns \ No newline at end of file diff --git a/docs/reference/models.md b/docs/reference/models.md deleted file mode 100644 index 2ca0f8f2..00000000 --- a/docs/reference/models.md +++ /dev/null @@ -1,39 +0,0 @@ -::: botx.models.buttons - -::: botx.models.chats - -::: botx.models.constants - -::: botx.models.credentials - -::: botx.models.datastructures - -::: botx.models.enums - -::: botx.models.errors - -::: botx.models.events - -::: botx.models.files - -::: botx.models.entities - -::: botx.models.menu - -::: botx.models.messages.incoming_message - -::: botx.models.messages.message - -::: botx.models.messages.sending.credentials - -::: botx.models.messages.sending.markup - -::: botx.models.messages.sending.message - -::: botx.models.messages.sending.options - -::: botx.models.messages.sending.payload - -::: botx.models.typing - -::: botx.models.users diff --git a/docs/reference/testing/message-builder.md b/docs/reference/testing/message-builder.md deleted file mode 100644 index c67c7e13..00000000 --- a/docs/reference/testing/message-builder.md +++ /dev/null @@ -1 +0,0 @@ -::: botx.testing.building.builder \ No newline at end of file diff --git a/docs/reference/testing/test-client.md b/docs/reference/testing/test-client.md deleted file mode 100644 index c177fad8..00000000 --- a/docs/reference/testing/test-client.md +++ /dev/null @@ -1,3 +0,0 @@ -::: botx.testing.testing_client.base - -::: botx.testing.testing_client.client \ No newline at end of file diff --git a/docs/snippets/client/bots_api/get_token.py b/docs/snippets/client/bots_api/get_token.py new file mode 100644 index 00000000..0b4d312c --- /dev/null +++ b/docs/snippets/client/bots_api/get_token.py @@ -0,0 +1,17 @@ +import asyncio + +from botx import Bot, HandlerCollector, lifespan_wrapper + +# Не забудьте заполнить учётные данные бота +built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[]) + + +async def main() -> None: + async with lifespan_wrapper(built_bot) as bot: + for bot_account in bot.bot_accounts: + token = await built_bot.get_token(bot_id=bot_account.id) + print(token) # noqa: WPS421 + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs/snippets/minimal_example.py b/docs/snippets/minimal_example.py new file mode 100644 index 00000000..fba6a750 --- /dev/null +++ b/docs/snippets/minimal_example.py @@ -0,0 +1,72 @@ +from http import HTTPStatus +from uuid import UUID + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from botx import ( + Bot, + BotAccountWithSecret, + ChatCreatedEvent, + HandlerCollector, + IncomingMessage, + build_command_accepted_response, +) + +collector = HandlerCollector() + + +@collector.chat_created +async def chat_created_handler(event: ChatCreatedEvent, bot: Bot) -> None: + await bot.answer_message("Hello!") + + +@collector.command("/echo", description="Send back the received message body") +async def echo_handler(message: IncomingMessage, bot: Bot) -> None: + await bot.answer_message(message.body) + + +@collector.default_message_handler +async def default_message_handler(event: IncomingMessage, bot: Bot) -> None: + await bot.answer_message("Sorry, command not found.") + + +bot = Bot( + collectors=[collector], + bot_accounts=[ + BotAccountWithSecret( # noqa: S106 + # Replace fake account credentials with yours + id=UUID("123e4567-e89b-12d3-a456-426655440000"), + host="cts.example.com", + secret_key="e29b417773f2feab9dac143ee3da20c5", + ), + ], +) + +app = FastAPI() +app.add_event_handler("startup", bot.startup) +app.add_event_handler("shutdown", bot.shutdown) + + +@app.post("/command") +async def command_handler(request: Request) -> JSONResponse: + bot.async_execute_raw_bot_command(await request.json()) + return JSONResponse( + build_command_accepted_response(), + status_code=HTTPStatus.ACCEPTED, + ) + + +@app.get("/status") +async def status_handler(request: Request) -> JSONResponse: + status = await bot.raw_get_status(dict(request.query_params)) + return JSONResponse(status) + + +@app.post("/notification/callback") +async def callback_handler(request: Request) -> JSONResponse: + bot.set_raw_botx_method_result(await request.json()) + return JSONResponse( + build_command_accepted_response(), + status_code=HTTPStatus.ACCEPTED, + ) diff --git a/docs/src/development/collector/collector0/a_commands.py b/docs/src/development/collector/collector0/a_commands.py deleted file mode 100644 index 2af9f321..00000000 --- a/docs/src/development/collector/collector0/a_commands.py +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 7ba050f1..00000000 --- a/docs/src/development/collector/collector0/bot.py +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 42509691..00000000 --- a/docs/src/development/dependencies_injection/dependencies_injection0.py +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 382a7df3..00000000 --- a/docs/src/development/dependencies_injection/dependencies_injection1.py +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index a6823be1..00000000 --- a/docs/src/development/dependencies_injection/dependencies_injection2.py +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 57505d76..00000000 --- a/docs/src/development/first_steps/first_steps0.py +++ /dev/null @@ -1,27 +0,0 @@ -from uuid import UUID - -from fastapi import FastAPI -from starlette.status import HTTP_202_ACCEPTED - -from botx import Bot, BotXCredentials, IncomingMessage, Message, Status - -bot = Bot(bot_accounts=[BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id"))]) - - -@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 deleted file mode 100644 index b76b1e3e..00000000 --- a/docs/src/development/first_steps/first_steps1.py +++ /dev/null @@ -1,54 +0,0 @@ -from uuid import UUID - -from fastapi import FastAPI -from starlette.requests import Request -from starlette.responses import JSONResponse, Response -from starlette.status import HTTP_202_ACCEPTED, HTTP_503_SERVICE_UNAVAILABLE - -from botx import ( - Bot, - BotDisabledErrorData, - BotDisabledResponse, - BotXCredentials, - IncomingMessage, - Message, - ServerUnknownError, - Status, -) - -bot = Bot(bot_accounts=[BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id"))]) - - -@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()) - - -@app.exception_handler(ServerUnknownError) -async def message_from_unknown_server_hanlder( - _request: Request, exc: ServerUnknownError -) -> Response: - return JSONResponse( - status_code=HTTP_503_SERVICE_UNAVAILABLE, - content=BotDisabledResponse( - error_data=BotDisabledErrorData( - status_message=( - f"Sorry, bot can not communicate with user from {exc.host} CTS" - ), - ), - ).dict(), - ) diff --git a/docs/src/development/first_steps/first_steps2.py b/docs/src/development/first_steps/first_steps2.py deleted file mode 100644 index 09afa4ca..00000000 --- a/docs/src/development/first_steps/first_steps2.py +++ /dev/null @@ -1,45 +0,0 @@ -from uuid import UUID - -from fastapi import FastAPI -from starlette.status import HTTP_202_ACCEPTED - -from botx import Bot, BotXCredentials, IncomingMessage, Message, Status - -users_data = {} -bot = Bot(bot_accounts=[BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id"))]) - - -@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 deleted file mode 100644 index 2bdd0fff..00000000 --- a/docs/src/development/first_steps/first_steps3.py +++ /dev/null @@ -1,78 +0,0 @@ -from uuid import UUID - -from fastapi import FastAPI -from starlette.status import HTTP_202_ACCEPTED - -from botx import Bot, BotXCredentials, IncomingMessage, Message, Status - -users_data = {} -bot = Bot(bot_accounts=[BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id"))]) - - -@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 deleted file mode 100644 index 69bb994c..00000000 --- a/docs/src/development/first_steps/first_steps4.py +++ /dev/null @@ -1,126 +0,0 @@ -from uuid import UUID - -from fastapi import FastAPI -from starlette.status import HTTP_202_ACCEPTED - -from botx import Bot, BotXCredentials, IncomingMessage, Message, Status -from botx.middlewares.ns import NextStepMiddleware, register_next_step_handler - -bot = Bot(bot_accounts=[BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id"))]) - -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 deleted file mode 100644 index 41cbd170..00000000 --- a/docs/src/development/handling_errors/handling_errors0.py +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index ac71a13e..00000000 --- a/docs/src/development/logging/logging0.py +++ /dev/null @@ -1,6 +0,0 @@ -from loguru import logger - -from botx import Bot - -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 deleted file mode 100644 index b9b60d6a..00000000 --- a/docs/src/development/sending_data/sending_data0.py +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index dfe95fb3..00000000 --- a/docs/src/development/sending_data/sending_data1.py +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 0532089f..00000000 --- a/docs/src/development/sending_data/sending_data2.py +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 5d0c4ac8..00000000 --- a/docs/src/development/sending_data/sending_data3.py +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index 576425ec..00000000 --- a/docs/src/development/sending_data/sending_data4.py +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 8c3dc128..00000000 --- a/docs/src/development/sending_data/sending_data5.py +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 3c0c08b2..00000000 --- a/docs/src/development/sending_data/sending_data6.py +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 55be109d..00000000 --- a/docs/src/development/sending_data/sending_data7.py +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 540bda91..00000000 --- a/docs/src/development/sending_data/sending_data8.py +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 0d1a2e08..00000000 --- a/docs/src/development/sending_data/sending_data9.py +++ /dev/null @@ -1,33 +0,0 @@ -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 deleted file mode 100644 index 03aa1ae1..00000000 --- a/docs/src/development/tests/tests0/bot.py +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index 6bdd3f52..00000000 --- a/docs/src/development/tests/tests0/conftest.py +++ /dev/null @@ -1,26 +0,0 @@ -from uuid import UUID - -import pytest - -from botx import Bot, BotXCredentials, MessageBuilder, TestClient - -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.bot_accounts.append(BotXCredentials(host=builder.user.host, secret_key="secret", bot_id=UUID("bot_id"))) - return bot - - -@pytest.fixture -def client(bot: Bot) -> TestClient: - with TestClient(bot) as client: - yield client diff --git a/docs/src/development/tests/tests0/test_format_command.py b/docs/src/development/tests/tests0/test_format_command.py deleted file mode 100644 index cec31a30..00000000 --- a/docs/src/development/tests/tests0/test_format_command.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest - -from botx import Bot, MessageBuilder, TestClient - - -@pytest.mark.asyncio -async def test_hello_format( - bot: Bot, builder: MessageBuilder, client: TestClient -) -> None: - builder.body = "/hello" - - 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 deleted file mode 100644 index 57505d76..00000000 --- a/docs/src/index/index0.py +++ /dev/null @@ -1,27 +0,0 @@ -from uuid import UUID - -from fastapi import FastAPI -from starlette.status import HTTP_202_ACCEPTED - -from botx import Bot, BotXCredentials, IncomingMessage, Message, Status - -bot = Bot(bot_accounts=[BotXCredentials(host="cts.example.com", secret_key="secret", bot_id=UUID("bot_id"))]) - - -@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/examples/README.md b/examples/README.md deleted file mode 100644 index 6056ec49..00000000 --- a/examples/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# Examples - -In this folder are small examples to show different ways of usage `pybotx` library for -writing bots. - -### [Finite-state machine middleware](./fsm) - -An example of bot and middleware that defines finite-state machine behaviour. \ No newline at end of file diff --git a/examples/fsm/.env.example b/examples/fsm/.env.example deleted file mode 100644 index 704e6987..00000000 --- a/examples/fsm/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -CTS_HOST=cts.example.com -BOT_SECRET=secret key \ No newline at end of file diff --git a/examples/fsm/README.md b/examples/fsm/README.md deleted file mode 100644 index 6e368143..00000000 --- a/examples/fsm/README.md +++ /dev/null @@ -1,15 +0,0 @@ -## General - -A bot that defines middleware that process request in finite-state machine way. - -State is an enum (`enum.Enum`) with several values that are changed using -`bot.middleware.change_state` function. - -This example shows definition of custom middleware and handlers processing logic. - -## Run - -start `uvicorn` ASGI server with bot on 8000 port with following command: -```bash -$ uvicorn bot.web:app -``` \ No newline at end of file diff --git a/examples/fsm/bot/bot.py b/examples/fsm/bot/bot.py deleted file mode 100644 index 0777a586..00000000 --- a/examples/fsm/bot/bot.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import Any -from uuid import UUID - -from bot.config import BOT_SECRET, CTS_HOST -from bot.handlers import FSMStates, fsm -from bot.middleware import FlowError, FSMMiddleware, change_state -from botx import Bot, BotXCredentials, Message - -bot = Bot(bot_accounts=[BotXCredentials(host=CTS_HOST, secret_key=str(BOT_SECRET), bot_id=UUID("bot_id"))]) -bot.add_middleware(FSMMiddleware, bot=bot, fsm=fsm) - - -@bot.default(include_in_status=False) -async def default_handler(message: Message) -> None: - if message.body == "start": - change_state(message, FSMStates.get_first_name) - await message.bot.answer_message("enter first name", message) - return - - await message.bot.answer_message("default handler", message) - - -@bot.exception_handler(FlowError) -async def flow_error_handler(*_: Any) -> None: - pass diff --git a/examples/fsm/bot/config.py b/examples/fsm/bot/config.py deleted file mode 100644 index dfd190e5..00000000 --- a/examples/fsm/bot/config.py +++ /dev/null @@ -1,10 +0,0 @@ -from loguru import logger -from starlette.config import Config -from starlette.datastructures import Secret - -config = Config(".env") - -CTS_HOST: str = config("CTS_HOST") -BOT_SECRET: Secret = config("BOT_SECRET", cast=Secret) - -logger.enable("botx") diff --git a/examples/fsm/bot/handlers.py b/examples/fsm/bot/handlers.py deleted file mode 100644 index ab3217b5..00000000 --- a/examples/fsm/bot/handlers.py +++ /dev/null @@ -1,60 +0,0 @@ -import pprint -from collections import defaultdict -from enum import Enum, auto -from typing import DefaultDict, Dict -from uuid import UUID - -from bot.middleware import FSM, FlowError -from botx import Message - - -class FSMStates(Enum): - get_first_name = auto() - get_middle_name = auto() - get_last_name = auto() - get_age = auto() - get_gender = auto() - end = auto() - - -fsm = FSM(FSMStates) -user_info: DefaultDict[UUID, Dict[str, str]] = defaultdict(dict) - - -@fsm.handler(on_state=FSMStates.get_first_name, next_state=FSMStates.get_middle_name) -async def get_first_name(message: Message) -> None: - user_info[message.user_huid]["first_name"] = message.body - await message.bot.answer_message("enter middle name", message) - - -@fsm.handler(on_state=FSMStates.get_middle_name, next_state=FSMStates.get_last_name) -async def get_middle_name(message: Message) -> None: - user_info[message.user_huid]["middle_name"] = message.body - await message.bot.answer_message("enter last name", message) - - -@fsm.handler( - on_state=FSMStates.get_last_name, - next_state=FSMStates.get_age, - on_failure=FSMStates.get_first_name, -) -async def get_last_name(message: Message) -> None: - if message.body == "fail": - await message.bot.answer_message("failed to read last name", message) - raise FlowError - - await message.bot.answer_message("enter age", message) - user_info[message.user_huid]["last_name"] = message.body - - -@fsm.handler(on_state=FSMStates.get_age, next_state=FSMStates.get_gender) -async def get_age(message: Message) -> None: - await message.bot.answer_message("enter gender", message) - user_info[message.user_huid]["age"] = message.body - - -@fsm.handler(on_state=FSMStates.get_gender, next_state=None) -async def get_gender(message: Message) -> None: - user_info[message.user_huid]["gender"] = message.body - await message.bot.answer_message("thanks for sharing info:", message) - await message.bot.answer_message(pprint.pformat(user_info), message) diff --git a/examples/fsm/bot/middleware.py b/examples/fsm/bot/middleware.py deleted file mode 100644 index b485f685..00000000 --- a/examples/fsm/bot/middleware.py +++ /dev/null @@ -1,88 +0,0 @@ -from dataclasses import dataclass -from enum import Enum -from typing import Callable, Dict, Final, Optional, Type, Union - -from botx import Bot, Collector, Message -from botx.concurrency import callable_to_coroutine -from botx.middlewares.base import BaseMiddleware -from botx.typing import Executor - -_default_transition: Final = object() - - -@dataclass -class Transition: - on_failure: Optional[Union[Enum, object]] = _default_transition - on_success: Optional[Union[Enum, object]] = _default_transition - - -class FlowError(Exception): - pass - - -class FSM: - def __init__(self, states: Type[Enum]) -> None: - self.transitions: Dict[Enum, Transition] = {} - self.collector = Collector() - self.states = states - - def handler( - self, - on_state: Enum, - next_state: Optional[Union[Enum, object]] = _default_transition, - on_failure: Optional[Union[Enum, object]] = _default_transition, - ) -> Callable: - def decorator(handler: Callable) -> Callable: - self.collector.add_handler( - handler, - body=on_state.name, - name=on_state.name, - include_in_status=False, - ) - self.transitions[on_state] = Transition( - on_success=next_state, on_failure=on_failure, - ) - - return handler - - return decorator - - -def change_state(message: Message, new_state: Optional[Enum]) -> None: - message.bot.state.fsm_state[(message.user_huid, message.group_chat_id)] = new_state - - -class FSMMiddleware(BaseMiddleware): - def __init__( - self, - executor: Executor, - bot: Bot, - fsm: FSM, - initial_state: Optional[Enum] = None, - ) -> None: - super().__init__(executor) - bot.state.fsm_state = {} - self.fsm = fsm - self.initial_state = initial_state - for state in self.fsm.states: - # check that for each state there is registered handler - assert state in self.fsm.transitions - - async def dispatch(self, message: Message, call_next: Executor) -> None: - current_state: Enum = message.bot.state.fsm_state.setdefault( - (message.user_huid, message.group_chat_id), self.initial_state, - ) - if current_state is not None: - transition = self.fsm.transitions[current_state] - handler = self.fsm.collector.handler_for(current_state.name) - try: - await handler(message) - except Exception as exc: - if transition.on_failure is not _default_transition: - change_state(message, transition.on_failure) - raise exc - else: - if transition.on_success is not _default_transition: - change_state(message, transition.on_success) - else: - await callable_to_coroutine(call_next, message) diff --git a/examples/fsm/bot/web.py b/examples/fsm/bot/web.py deleted file mode 100644 index b1117420..00000000 --- a/examples/fsm/bot/web.py +++ /dev/null @@ -1,16 +0,0 @@ -from fastapi import FastAPI - -from bot.bot import bot -from botx import Status - -app = FastAPI() - - -@app.get("/status", response_model=Status) -async def bot_status() -> Status: - return await bot.status() - - -@app.post("/command") -async def bot_command(message: dict) -> None: - await bot.execute_command(message) diff --git a/examples/fsm/poetry.lock b/examples/fsm/poetry.lock deleted file mode 100644 index 7c3c5273..00000000 --- a/examples/fsm/poetry.lock +++ /dev/null @@ -1,599 +0,0 @@ -[[package]] -category = "dev" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -name = "appdirs" -optional = false -python-versions = "*" -version = "1.4.3" - -[[package]] -category = "dev" -description = "Classes Without Boilerplate" -name = "attrs" -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" -name = "autoflake" -optional = false -python-versions = "*" -version = "1.3.1" - -[package.dependencies] -pyflakes = ">=1.1.0" - -[[package]] -category = "dev" -description = "The uncompromising code formatter." -name = "black" -optional = false -python-versions = ">=3.6" -version = "19.10b0" - -[package.dependencies] -appdirs = "*" -attrs = ">=18.1.0" -click = ">=6.5" -pathspec = ">=0.6,<1" -regex = "*" -toml = ">=0.9.4" -typed-ast = ">=1.4.0" - -[package.extras] -d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] - -[[package]] -category = "main" -description = "A little python framework for building bots for eXpress" -name = "botx" -optional = false -python-versions = ">=3.6,<4.0" -version = "0.13.3" - -[package.dependencies] -httpx = ">=0.11.0,<0.12.0" -loguru = ">=0.4.0,<0.5.0" -pydantic = ">=1.0,<2.0" - -[package.extras] -docs = ["mkdocs (>=1.0,<2.0)", "mkdocs-material (>=4.4,<5.0)", "mkdocstrings (>=0.7,<0.8)", "markdown-include (>=0.5.1,<0.6.0)", "fastapi (>=0.47.0,<0.48.0)"] -tests = ["starlette (>=0.12.9,<0.13.0)"] - -[[package]] -category = "main" -description = "Python package for providing Mozilla's CA Bundle." -name = "certifi" -optional = false -python-versions = "*" -version = "2019.11.28" - -[[package]] -category = "main" -description = "Universal encoding detector for Python 2 and 3" -name = "chardet" -optional = false -python-versions = "*" -version = "3.0.4" - -[[package]] -category = "main" -description = "Composable command line interface toolkit" -name = "click" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "7.0" - -[[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.*, !=3.4.*" -version = "0.4.3" - -[[package]] -category = "main" -description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" -name = "fastapi" -optional = false -python-versions = ">=3.6" -version = "0.48.0" - -[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 = "main" -description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" -name = "h11" -optional = false -python-versions = "*" -version = "0.9.0" - -[[package]] -category = "main" -description = "HTTP/2 State-Machine based protocol implementation" -name = "h2" -optional = false -python-versions = "*" -version = "3.2.0" - -[package.dependencies] -hpack = ">=3.0,<4" -hyperframe = ">=5.2.0,<6" - -[[package]] -category = "main" -description = "Pure-Python HPACK header compression" -name = "hpack" -optional = false -python-versions = "*" -version = "3.0.0" - -[[package]] -category = "main" -description = "Chromium HSTS Preload list as a Python package and updated daily" -name = "hstspreload" -optional = false -python-versions = ">=3.6" -version = "2020.2.25" - -[[package]] -category = "main" -description = "A collection of framework independent HTTP protocol utils." -marker = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" -name = "httptools" -optional = false -python-versions = "*" -version = "0.1.1" - -[package.extras] -test = ["Cython (0.29.14)"] - -[[package]] -category = "main" -description = "The next generation HTTP client." -name = "httpx" -optional = false -python-versions = ">=3.6" -version = "0.11.1" - -[package.dependencies] -certifi = "*" -chardet = ">=3.0.0,<4.0.0" -h11 = ">=0.8,<0.10" -h2 = ">=3.0.0,<4.0.0" -hstspreload = "*" -idna = ">=2.0.0,<3.0.0" -rfc3986 = ">=1.3,<2" -sniffio = ">=1.0.0,<2.0.0" -urllib3 = ">=1.0.0,<2.0.0" - -[[package]] -category = "main" -description = "HTTP/2 framing layer for Python" -name = "hyperframe" -optional = false -python-versions = "*" -version = "5.2.0" - -[[package]] -category = "main" -description = "Internationalized Domain Names in Applications (IDNA)" -name = "idna" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.9" - -[[package]] -category = "dev" -description = "A Python utility / library to sort Python imports." -name = "isort" -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 = "Python logging made (stupidly) simple" -name = "loguru" -optional = false -python-versions = ">=3.5" -version = "0.4.1" - -[package.dependencies] -colorama = ">=0.3.4" -win32-setctime = ">=1.0.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 = "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 = "main" -description = "Data validation and settings management using python 3.6 type hinting" -name = "pydantic" -optional = false -python-versions = ">=3.6" -version = "1.4" - -[package.extras] -dotenv = ["python-dotenv (>=0.10.4)"] -email = ["email-validator (>=1.0.3)"] -typing_extensions = ["typing-extensions (>=3.7.2)"] - -[[package]] -category = "dev" -description = "passive checker of Python programs" -name = "pyflakes" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.1.1" - -[[package]] -category = "dev" -description = "Alternative regular expression module, to replace re." -name = "regex" -optional = false -python-versions = "*" -version = "2020.2.20" - -[[package]] -category = "main" -description = "Validating URI References per RFC 3986" -name = "rfc3986" -optional = false -python-versions = "*" -version = "1.3.2" - -[package.extras] -idna2008 = ["idna"] - -[[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]] -category = "main" -description = "The little ASGI library that shines." -name = "starlette" -optional = false -python-versions = ">=3.6" -version = "0.12.9" - -[package.extras] -full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "ujson"] - -[[package]] -category = "dev" -description = "Python Library for Tom's Obvious, Minimal Language" -name = "toml" -optional = false -python-versions = "*" -version = "0.10.0" - -[[package]] -category = "dev" -description = "a fork of Python 2 and 3 ast modules with type comment support" -name = "typed-ast" -optional = false -python-versions = "*" -version = "1.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.*, !=3.4.*, <4" -version = "1.25.8" - -[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 = "main" -description = "The lightning-fast ASGI server." -name = "uvicorn" -optional = false -python-versions = "*" -version = "0.11.3" - -[package.dependencies] -click = ">=7.0.0,<8.0.0" -h11 = ">=0.8,<0.10" -httptools = ">=0.1.0,<0.2.0" -uvloop = ">=0.14.0" -websockets = ">=8.0.0,<9.0.0" - -[[package]] -category = "main" -description = "Fast implementation of asyncio event loop on top of libuv" -marker = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" -name = "uvloop" -optional = false -python-versions = "*" -version = "0.14.0" - -[[package]] -category = "main" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -name = "websockets" -optional = false -python-versions = ">=3.6.1" -version = "8.1" - -[[package]] -category = "main" -description = "A small Python utility to set file creation time on Windows" -marker = "sys_platform == \"win32\"" -name = "win32-setctime" -optional = false -python-versions = ">=3.5" -version = "1.0.1" - -[package.extras] -dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"] - -[metadata] -content-hash = "e6eb201134d6b1d79b81049d96a3cf895462598d51cfb9438a423f5c4033600b" -python-versions = "^3.8" - -[metadata.files] -appdirs = [ - {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"}, - {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"}, -] -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"}, -] -black = [ - {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, - {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, -] -botx = [ - {file = "botx-0.13.3-py3-none-any.whl", hash = "sha256:a87768a47105c699ad26bae1a5b930e360ccc792940f3f22ead3e80da2c7a158"}, - {file = "botx-0.13.3.tar.gz", hash = "sha256:b7a68a359c793a2ebaa0c7b86aa7284c997d5cf8bb0c3c3b1987337b86bb9c0c"}, -] -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"}, -] -colorama = [ - {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, - {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, -] -fastapi = [ - {file = "fastapi-0.48.0-py3-none-any.whl", hash = "sha256:87d409d3ac3957713c016ba3b28fa5214e0903d4351cc3fe486b170d29e8aacd"}, - {file = "fastapi-0.48.0.tar.gz", hash = "sha256:2e00347f6a84291a5f04302733fbcf7c2ad9c674c0d0448cbee661db0e01ca16"}, -] -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.2.0-py2.py3-none-any.whl", hash = "sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5"}, - {file = "h2-3.2.0.tar.gz", hash = "sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14"}, -] -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.2.25.tar.gz", hash = "sha256:a1ba0c2730593a1922f93cd9c66ff620248090656102bf31e4559c01d7935e05"}, -] -httptools = [ - {file = "httptools-0.1.1-cp35-cp35m-macosx_10_13_x86_64.whl", hash = "sha256:a2719e1d7a84bb131c4f1e0cb79705034b48de6ae486eb5297a139d6a3296dce"}, - {file = "httptools-0.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:fa3cd71e31436911a44620473e873a256851e1f53dee56669dae403ba41756a4"}, - {file = "httptools-0.1.1-cp36-cp36m-macosx_10_13_x86_64.whl", hash = "sha256:86c6acd66765a934e8730bf0e9dfaac6fdcf2a4334212bd4a0a1c78f16475ca6"}, - {file = "httptools-0.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:bc3114b9edbca5a1eb7ae7db698c669eb53eb8afbbebdde116c174925260849c"}, - {file = "httptools-0.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:ac0aa11e99454b6a66989aa2d44bca41d4e0f968e395a0a8f164b401fefe359a"}, - {file = "httptools-0.1.1-cp37-cp37m-macosx_10_13_x86_64.whl", hash = "sha256:96da81e1992be8ac2fd5597bf0283d832287e20cb3cfde8996d2b00356d4e17f"}, - {file = "httptools-0.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:56b6393c6ac7abe632f2294da53f30d279130a92e8ae39d8d14ee2e1b05ad1f2"}, - {file = "httptools-0.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:96eb359252aeed57ea5c7b3d79839aaa0382c9d3149f7d24dd7172b1bcecb009"}, - {file = "httptools-0.1.1-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:fea04e126014169384dee76a153d4573d90d0cbd1d12185da089f73c78390437"}, - {file = "httptools-0.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3592e854424ec94bd17dc3e0c96a64e459ec4147e6d53c0a42d0ebcef9cb9c5d"}, - {file = "httptools-0.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:0a4b1b2012b28e68306575ad14ad5e9120b34fccd02a81eb08838d7e3bbb48be"}, - {file = "httptools-0.1.1.tar.gz", hash = "sha256:41b573cf33f64a8f8f3400d0a7faf48e1888582b6f6e02b82b9bd4f0bf7497ce"}, -] -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.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, - {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, -] -isort = [ - {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, - {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, -] -loguru = [ - {file = "loguru-0.4.1-py3-none-any.whl", hash = "sha256:074b3caa6748452c1e4f2b302093c94b65d5a4c5a4d7743636b4121e06437b0e"}, - {file = "loguru-0.4.1.tar.gz", hash = "sha256:a6101fd435ac89ba5205a105a26a6ede9e4ddbb4408a6e167852efca47806d11"}, -] -pathspec = [ - {file = "pathspec-0.7.0-py2.py3-none-any.whl", hash = "sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424"}, - {file = "pathspec-0.7.0.tar.gz", hash = "sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"}, -] -pydantic = [ - {file = "pydantic-1.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:07911aab70f3bc52bb845ce1748569c5e70478ac977e106a150dd9d0465ebf04"}, - {file = "pydantic-1.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:012c422859bac2e03ab3151ea6624fecf0e249486be7eb8c6ee69c91740c6752"}, - {file = "pydantic-1.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:61d22d36808087d3184ed6ac0d91dd71c533b66addb02e4a9930e1e30833202f"}, - {file = "pydantic-1.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f863456d3d4bf817f2e5248553dee3974c5dc796f48e6ddb599383570f4215ac"}, - {file = "pydantic-1.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bbbed364376f4a0aebb9ea452ff7968b306499a9e74f4db69b28ff2cd4043a11"}, - {file = "pydantic-1.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e27559cedbd7f59d2375bfd6eea29a330ea1a5b0589c34d6b4e0d7bec6027bbf"}, - {file = "pydantic-1.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:50e4e948892a6815649ad5a9a9379ad1e5f090f17842ac206535dfaed75c6f2f"}, - {file = "pydantic-1.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:8848b4eb458469739126e4c1a202d723dd092e087f8dbe3104371335f87ba5df"}, - {file = "pydantic-1.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:831a0265a9e3933b3d0f04d1a81bba543bafbe4119c183ff2771871db70524ab"}, - {file = "pydantic-1.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:47b8db7024ba3d46c3d4768535e1cf87b6c8cf92ccd81e76f4e1cb8ee47688b3"}, - {file = "pydantic-1.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:51f11c8bbf794a68086540da099aae4a9107447c7a9d63151edbb7d50110cf21"}, - {file = "pydantic-1.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:6100d7862371115c40be55cc4b8d766a74b1d0dbaf99dbfe72bb4bac0faf89ed"}, - {file = "pydantic-1.4-py36.py37.py38-none-any.whl", hash = "sha256:72184c1421103cca128300120f8f1185fb42a9ea73a1c9845b1c53db8c026a7d"}, - {file = "pydantic-1.4.tar.gz", hash = "sha256:f17ec336e64d4583311249fb179528e9a2c27c8a2eaf590ec6ec2c6dece7cb3f"}, -] -pyflakes = [ - {file = "pyflakes-2.1.1-py2.py3-none-any.whl", hash = "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0"}, - {file = "pyflakes-2.1.1.tar.gz", hash = "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"}, -] -regex = [ - {file = "regex-2020.2.20-cp27-cp27m-win32.whl", hash = "sha256:99272d6b6a68c7ae4391908fc15f6b8c9a6c345a46b632d7fdb7ef6c883a2bbb"}, - {file = "regex-2020.2.20-cp27-cp27m-win_amd64.whl", hash = "sha256:974535648f31c2b712a6b2595969f8ab370834080e00ab24e5dbb9d19b8bfb74"}, - {file = "regex-2020.2.20-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:5de40649d4f88a15c9489ed37f88f053c15400257eeb18425ac7ed0a4e119400"}, - {file = "regex-2020.2.20-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:82469a0c1330a4beb3d42568f82dffa32226ced006e0b063719468dcd40ffdf0"}, - {file = "regex-2020.2.20-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d58a4fa7910102500722defbde6e2816b0372a4fcc85c7e239323767c74f5cbc"}, - {file = "regex-2020.2.20-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f1ac2dc65105a53c1c2d72b1d3e98c2464a133b4067a51a3d2477b28449709a0"}, - {file = "regex-2020.2.20-cp36-cp36m-win32.whl", hash = "sha256:8c2b7fa4d72781577ac45ab658da44c7518e6d96e2a50d04ecb0fd8f28b21d69"}, - {file = "regex-2020.2.20-cp36-cp36m-win_amd64.whl", hash = "sha256:269f0c5ff23639316b29f31df199f401e4cb87529eafff0c76828071635d417b"}, - {file = "regex-2020.2.20-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:bed7986547ce54d230fd8721aba6fd19459cdc6d315497b98686d0416efaff4e"}, - {file = "regex-2020.2.20-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:046e83a8b160aff37e7034139a336b660b01dbfe58706f9d73f5cdc6b3460242"}, - {file = "regex-2020.2.20-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:b33ebcd0222c1d77e61dbcd04a9fd139359bded86803063d3d2d197b796c63ce"}, - {file = "regex-2020.2.20-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:bba52d72e16a554d1894a0cc74041da50eea99a8483e591a9edf1025a66843ab"}, - {file = "regex-2020.2.20-cp37-cp37m-win32.whl", hash = "sha256:01b2d70cbaed11f72e57c1cfbaca71b02e3b98f739ce33f5f26f71859ad90431"}, - {file = "regex-2020.2.20-cp37-cp37m-win_amd64.whl", hash = "sha256:113309e819634f499d0006f6200700c8209a2a8bf6bd1bdc863a4d9d6776a5d1"}, - {file = "regex-2020.2.20-cp38-cp38-manylinux1_i686.whl", hash = "sha256:25f4ce26b68425b80a233ce7b6218743c71cf7297dbe02feab1d711a2bf90045"}, - {file = "regex-2020.2.20-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9b64a4cc825ec4df262050c17e18f60252cdd94742b4ba1286bcfe481f1c0f26"}, - {file = "regex-2020.2.20-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:9ff16d994309b26a1cdf666a6309c1ef51ad4f72f99d3392bcd7b7139577a1f2"}, - {file = "regex-2020.2.20-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c7f58a0e0e13fb44623b65b01052dae8e820ed9b8b654bb6296bc9c41f571b70"}, - {file = "regex-2020.2.20-cp38-cp38-win32.whl", hash = "sha256:200539b5124bc4721247a823a47d116a7a23e62cc6695744e3eb5454a8888e6d"}, - {file = "regex-2020.2.20-cp38-cp38-win_amd64.whl", hash = "sha256:7f78f963e62a61e294adb6ff5db901b629ef78cb2a1cfce3cf4eeba80c1c67aa"}, - {file = "regex-2020.2.20.tar.gz", hash = "sha256:9e9624440d754733eddbcd4614378c18713d2d9d0dc647cf9c72f64e39671be5"}, -] -rfc3986 = [ - {file = "rfc3986-1.3.2-py2.py3-none-any.whl", hash = "sha256:df4eba676077cefb86450c8f60121b9ae04b94f65f85b69f3f731af0516b7b18"}, - {file = "rfc3986-1.3.2.tar.gz", hash = "sha256:0344d0bd428126ce554e7ca2b61787b6a28d2bbd19fc70ed2dd85efe31176405"}, -] -sniffio = [ - {file = "sniffio-1.1.0-py3-none-any.whl", hash = "sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5"}, - {file = "sniffio-1.1.0.tar.gz", hash = "sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"}, -] -starlette = [ - {file = "starlette-0.12.9.tar.gz", hash = "sha256:c2ac9a42e0e0328ad20fe444115ac5e3760c1ee2ac1ff8cdb5ec915c4a453411"}, -] -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"}, -] -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"}, -] -urllib3 = [ - {file = "urllib3-1.25.8-py2.py3-none-any.whl", hash = "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc"}, - {file = "urllib3-1.25.8.tar.gz", hash = "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"}, -] -uvicorn = [ - {file = "uvicorn-0.11.3-py3-none-any.whl", hash = "sha256:0f58170165c4495f563d8224b2f415a0829af0412baa034d6f777904613087fd"}, - {file = "uvicorn-0.11.3.tar.gz", hash = "sha256:6fdaf8e53bf1b2ddf0fe9ed06079b5348d7d1d87b3365fe2549e6de0d49e631c"}, -] -uvloop = [ - {file = "uvloop-0.14.0-cp35-cp35m-macosx_10_11_x86_64.whl", hash = "sha256:08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd"}, - {file = "uvloop-0.14.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726"}, - {file = "uvloop-0.14.0-cp36-cp36m-macosx_10_11_x86_64.whl", hash = "sha256:b4f591aa4b3fa7f32fb51e2ee9fea1b495eb75b0b3c8d0ca52514ad675ae63f7"}, - {file = "uvloop-0.14.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:f07909cd9fc08c52d294b1570bba92186181ca01fe3dc9ffba68955273dd7362"}, - {file = "uvloop-0.14.0-cp37-cp37m-macosx_10_11_x86_64.whl", hash = "sha256:afd5513c0ae414ec71d24f6f123614a80f3d27ca655a4fcf6cabe50994cc1891"}, - {file = "uvloop-0.14.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:e7514d7a48c063226b7d06617cbb12a14278d4323a065a8d46a7962686ce2e95"}, - {file = "uvloop-0.14.0-cp38-cp38-macosx_10_11_x86_64.whl", hash = "sha256:bcac356d62edd330080aed082e78d4b580ff260a677508718f88016333e2c9c5"}, - {file = "uvloop-0.14.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4315d2ec3ca393dd5bc0b0089d23101276778c304d42faff5dc4579cb6caef09"}, - {file = "uvloop-0.14.0.tar.gz", hash = "sha256:123ac9c0c7dd71464f58f1b4ee0bbd81285d96cdda8bc3519281b8973e3a461e"}, -] -websockets = [ - {file = "websockets-8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c"}, - {file = "websockets-8.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170"}, - {file = "websockets-8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8"}, - {file = "websockets-8.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb"}, - {file = "websockets-8.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5"}, - {file = "websockets-8.1-cp36-cp36m-win32.whl", hash = "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a"}, - {file = "websockets-8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5"}, - {file = "websockets-8.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989"}, - {file = "websockets-8.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d"}, - {file = "websockets-8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779"}, - {file = "websockets-8.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8"}, - {file = "websockets-8.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422"}, - {file = "websockets-8.1-cp37-cp37m-win32.whl", hash = "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc"}, - {file = "websockets-8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308"}, - {file = "websockets-8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092"}, - {file = "websockets-8.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485"}, - {file = "websockets-8.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1"}, - {file = "websockets-8.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55"}, - {file = "websockets-8.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824"}, - {file = "websockets-8.1-cp38-cp38-win32.whl", hash = "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36"}, - {file = "websockets-8.1-cp38-cp38-win_amd64.whl", hash = "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b"}, - {file = "websockets-8.1.tar.gz", hash = "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f"}, -] -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"}, -] diff --git a/examples/fsm/pyproject.toml b/examples/fsm/pyproject.toml deleted file mode 100644 index ee91f8b6..00000000 --- a/examples/fsm/pyproject.toml +++ /dev/null @@ -1,21 +0,0 @@ -[tool.poetry] -name = "pybotx-fsm-example" -version = "0.0.0" -description = "An example of bot with FSM behaviour" -authors = ["Nik Sidnev "] -license = "MIT" - -[tool.poetry.dependencies] -python = "^3.8" -botx = "^0.13.0" -fastapi = "^0.48.0" -uvicorn = "^0.11.3" - -[tool.poetry.dev-dependencies] -black = "^19.10b0" -isort = "^4.3" -autoflake = "^1.3" - -[build-system] -requires = ["poetry>=1.0"] -build-backend = "poetry.masonry.api" diff --git a/examples/fsm/scripts/format b/examples/fsm/scripts/format deleted file mode 100755 index 9fa80f09..00000000 --- a/examples/fsm/scripts/format +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -set -e - -# Sort imports one per line, so autoflake can remove unused imports -isort --recursive --force-single-line-imports bot - -autoflake --recursive --remove-all-unused-imports --remove-unused-variables --in-place bot -black bot -isort --recursive bot - -isort --recursive --thirdparty=botx bot diff --git a/mkdocs.yml b/mkdocs.yml index 10174435..b22de101 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,9 +1,9 @@ -site_name: pybotx -site_url: !!python/object/apply:os.getenv ["SITE_URL", "https://expressapp.github.io/pybotx"] +site_name: pybotx-next +site_url: !!python/object/apply:os.getenv ["SITE_URL", "https://expressapp.github.io/pybotx-next"] site_description: A little python library for building bots for Express theme: - name: 'material' + name: "material" repo_name: ExpressApp/pybotx repo_url: https://github.com/ExpressApp/pybotx @@ -11,41 +11,20 @@ edit_uri: '' nav: - Introduction: 'index.md' - - Tutorial - User Guide: - - First Steps: 'development/first-steps.md' - - Sending Messages And Files: 'development/sending-data.md' - - Handlers Collecting: 'development/collector.md' - - Handling Errors: 'development/handling-errors.md' - - Dependencies Injection: 'development/dependencies-injection.md' - - Logging: 'development/logging.md' - - Testing: 'development/tests.md' - - API Reference: - - Bots: 'reference/bots.md' - - Collecting: 'reference/collecting.md' - - Middlewares: - - Base: 'reference/middlewares/base.md' - - Next Step: 'reference/middlewares/ns.md' - - Authorization: 'reference/middlewares/authorization.md' - - Models: 'reference/models.md' - - Clients: - - Methods: 'reference/clients/methods.md' - - Async Client: 'reference/clients/async-client.md' - - Synchronous Client: 'reference/clients/sync-client.md' - - Testing: - Client: 'reference/testing/test-client.md' - Message Builder: 'reference/testing/message-builder.md' - - Exceptions: 'reference/exceptions.md' + - BotX API: 'botx_api.md' - - Changelog: 'changelog.md' +plugins: + - search markdown_extensions: - - markdown.extensions.codehilite: - guess_lang: false - - markdown_include.include: - base_path: docs - admonition - - codehilite + - pymdownx.snippets + - pymdownx.highlight: + anchor_linenums: true + linenums: true + - pymdownx.superfences + - codehilite: + css_class: highlight -plugins: - - search - - mkdocstrings +extra_css: + - css/custom.css diff --git a/noxfile.py b/noxfile.py deleted file mode 100644 index ddad127a..00000000 --- a/noxfile.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Run common tasks using nox.""" -import pathlib - -import nox -from nox.sessions import Session - -TARGETS = ("botx", "tests") - - -def _process_add_single_comma_path(session: Session, path: pathlib.Path) -> None: - if path.is_dir(): - for new_path in path.iterdir(): - _process_add_single_comma_path(session, new_path) - - return - - if path.suffix not in {".py", ".pyi"}: - return - - session.run( - "add-trailing-comma", "--py36-plus", "--exit-zero-even-if-changed", str(path), - ) - - -def _process_add_single_comma(session: Session, *paths: str) -> None: - for target in paths: - path = pathlib.Path(target) - _process_add_single_comma_path(session, path) - - -@nox.session(python=False, name="format") -def run_formatters(session: Session) -> None: - """Run all project formatters. - - Formatters to run: - 1. isort with autoflake to remove all unused imports. - 2. black for sinle style in all project. - 3. add-trailing-comma to adding or removing comma from line. - 4. isort for properly imports sorting. - """ - # we need to run isort here, since autoflake is unable to understand unused imports - # when they are multiline. - # see https://github.com/myint/autoflake/issues/8 - session.run("isort", "--recursive", "--force-single-line-imports", *TARGETS) - session.run( - "autoflake", - "--recursive", - "--remove-all-unused-imports", - "--remove-unused-variables", - "--in-place", - *TARGETS, - ) - session.run("black", *TARGETS) - _process_add_single_comma(session, *TARGETS) - session.run("isort", "--recursive", *TARGETS) - - -@nox.session(python=False) -def lint(session: Session) -> None: - """Run all project linters. - - Linters to run: - 1. black for code format style. - 2. mypy for type checking. - 3. flake8 for common python code style issues. - """ - session.run("black", "--check", "--diff", *TARGETS) - session.run("mypy", *TARGETS) - session.run("flake8", *TARGETS) - - -@nox.session(python=False) -def test(session: Session) -> None: - """Run pytest.""" - session.run("pytest", "--cov-config=setup.cfg") - - -@nox.session(python=False) -def publish(session: Session) -> None: - """Publish library on PyPI.""" - session.run("poetry", "publish", "--build") - - -@nox.session(python=False, name="build-docs") -def build_docs(session: Session) -> None: - """Build MkDocs pages.""" - session.run("mkdocs", "build") - - -@nox.session(python=False, name="serve-docs") -def serve_docs(session: Session) -> None: - """Serve MkDocs pages.""" - session.run("mkdocs", "serve", "--dev-addr", "0.0.0.0:8008") diff --git a/poetry.lock b/poetry.lock index 2e0c9328..3d8ec0d3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,6 +1,6 @@ [[package]] name = "add-trailing-comma" -version = "2.1.0" +version = "2.2.1" description = "Automatically add trailing commas to calls and literals" category = "dev" optional = false @@ -11,15 +11,15 @@ tokenize-rt = ">=3.0.1" [[package]] name = "aiofiles" -version = "0.7.0" +version = "0.8.0" description = "File support for asyncio." category = "main" -optional = true +optional = false python-versions = ">=3.6,<4.0" [[package]] name = "anyio" -version = "3.3.1" +version = "3.5.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" category = "main" optional = false @@ -28,34 +28,22 @@ python-versions = ">=3.6.2" [package.dependencies] idna = ">=2.8" sniffio = ">=1.1" -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [package.extras] -doc = ["sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] -test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] +doc = ["packaging", "sphinx-rtd-theme", "sphinx-autodoc-typehints (>=1.2.0)"] +test = ["coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "pytest (>=6.0)", "pytest-mock (>=3.6.1)", "trustme", "contextlib2", "uvloop (<0.15)", "mock (>=4)", "uvloop (>=0.15)"] trio = ["trio (>=0.16)"] [[package]] -name = "appdirs" -version = "1.4.4" -description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." -category = "dev" -optional = false -python-versions = "*" - -[[package]] -name = "argcomplete" -version = "1.12.3" -description = "Bash tab completion for argparse" +name = "asgiref" +version = "3.5.0" +description = "ASGI specs, helper code, and adapters" category = "dev" optional = false -python-versions = "*" - -[package.dependencies] -importlib-metadata = {version = ">=0.23,<5", markers = "python_version == \"3.7\""} +python-versions = ">=3.7" [package.extras] -test = ["coverage", "flake8", "pexpect", "wheel"] +tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"] [[package]] name = "astor" @@ -75,17 +63,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "21.2.0" +version = "21.4.0" description = "Classes Without Boilerplate" category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] [[package]] name = "autoflake" @@ -98,69 +86,54 @@ python-versions = "*" [package.dependencies] pyflakes = ">=1.1.0" -[[package]] -name = "backports.entry-points-selectable" -version = "1.1.0" -description = "Compatibility shim providing selectable entry points for older implementations" -category = "dev" -optional = false -python-versions = ">=2.7" - -[package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} - -[package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"] - [[package]] name = "bandit" -version = "1.7.0" +version = "1.7.2" description = "Security oriented static analyser for python code." category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.7" [package.dependencies] colorama = {version = ">=0.3.9", markers = "platform_system == \"Windows\""} GitPython = ">=1.0.1" PyYAML = ">=5.3.1" -six = ">=1.10.0" stevedore = ">=1.20.0" -[[package]] -name = "base64io" -version = "1.0.3" -description = "" -category = "main" -optional = false -python-versions = "*" +[package.extras] +test = ["beautifulsoup4 (>=4.8.0)", "coverage (>=4.5.4)", "fixtures (>=3.0.0)", "flake8 (>=4.0.0)", "pylint (==1.9.4)", "stestr (>=2.5.0)", "testscenarios (>=0.5.0)", "testtools (>=2.3.0)", "toml"] +toml = ["toml"] +yaml = ["pyyaml"] [[package]] name = "black" -version = "20.8b1" +version = "21.12b0" description = "The uncompromising code formatter." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.6.2" [package.dependencies] -appdirs = "*" click = ">=7.1.2" mypy-extensions = ">=0.4.3" -pathspec = ">=0.6,<1" -regex = ">=2020.1.8" -toml = ">=0.10.1" -typed-ast = ">=1.4.0" -typing-extensions = ">=3.7.4" +pathspec = ">=0.9.0,<1" +platformdirs = ">=2" +tomli = ">=0.2.6,<2.0.0" +typing-extensions = [ + {version = ">=3.10.0.0", markers = "python_version < \"3.10\""}, + {version = "!=3.10.0.1", markers = "python_version >= \"3.10\""}, +] [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +python2 = ["typed-ast (>=1.4.3)"] +uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2021.5.30" +version = "2021.10.8" description = "Python package for providing Mozilla's CA Bundle." category = "main" optional = false @@ -168,7 +141,7 @@ python-versions = "*" [[package]] name = "charset-normalizer" -version = "2.0.4" +version = "2.0.12" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." category = "main" optional = false @@ -179,7 +152,7 @@ unicode_backport = ["unicodedata2"] [[package]] name = "click" -version = "8.0.1" +version = "8.0.4" description = "Composable command line interface toolkit" category = "dev" optional = false @@ -187,7 +160,6 @@ python-versions = ">=3.6" [package.dependencies] colorama = {version = "*", markers = "platform_system == \"Windows\""} -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} [[package]] name = "colorama" @@ -197,59 +169,31 @@ category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -[[package]] -name = "colorlog" -version = "4.8.0" -description = "Log formatting with colors!" -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} - [[package]] name = "coverage" -version = "5.5" +version = "6.3.2" description = "Code coverage measurement for Python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" - -[package.extras] -toml = ["toml"] - -[[package]] -name = "coverage-conditional-plugin" -version = "0.4.0" -description = "Conditional coverage based on any rules you define!" -category = "dev" -optional = false -python-versions = ">=3.6,<4.0" +python-versions = ">=3.7" [package.dependencies] -coverage = ">=5.0,<6.0" -packaging = ">=20.4" +tomli = {version = "*", optional = true, markers = "extra == \"toml\""} + +[package.extras] +toml = ["tomli"] [[package]] name = "darglint" -version = "1.8.0" +version = "1.8.1" description = "A utility for ensuring Google-style docstrings stay up to date with the source code." category = "dev" optional = false python-versions = ">=3.6,<4.0" -[[package]] -name = "distlib" -version = "0.3.2" -description = "Distribution utilities" -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "docutils" -version = "0.17.1" +version = "0.18.1" description = "Docutils -- Python Documentation Utilities" category = "dev" optional = false @@ -264,26 +208,35 @@ optional = false python-versions = "*" [[package]] -name = "filelock" -version = "3.0.12" -description = "A platform independent file lock." +name = "fastapi" +version = "0.73.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6.1" + +[package.dependencies] +pydantic = ">=1.6.2,<1.7 || >1.7,<1.7.1 || >1.7.1,<1.7.2 || >1.7.2,<1.7.3 || >1.7.3,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0" +starlette = "0.17.1" + +[package.extras] +all = ["requests (>=2.24.0,<3.0.0)", "jinja2 (>=2.11.2,<4.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "itsdangerous (>=1.1.0,<3.0.0)", "pyyaml (>=5.3.1,<6.0.0)", "ujson (>=4.0.1,<5.0.0)", "orjson (>=3.2.1,<4.0.0)", "email_validator (>=1.1.1,<2.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] +dev = ["python-jose[cryptography] (>=3.3.0,<4.0.0)", "passlib[bcrypt] (>=1.7.2,<2.0.0)", "autoflake (>=1.4.0,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "uvicorn[standard] (>=0.12.0,<0.16.0)"] +doc = ["mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs-markdownextradata-plugin (>=0.1.7,<0.3.0)", "typer-cli (>=0.0.12,<0.0.13)", "pyyaml (>=5.3.1,<6.0.0)"] +test = ["pytest (>=6.2.4,<7.0.0)", "pytest-cov (>=2.12.0,<4.0.0)", "mypy (==0.910)", "flake8 (>=3.8.3,<4.0.0)", "black (==21.9b0)", "isort (>=5.0.6,<6.0.0)", "requests (>=2.24.0,<3.0.0)", "httpx (>=0.14.0,<0.19.0)", "email_validator (>=1.1.1,<2.0.0)", "sqlalchemy (>=1.3.18,<1.5.0)", "peewee (>=3.13.3,<4.0.0)", "databases[sqlite] (>=0.3.2,<0.6.0)", "orjson (>=3.2.1,<4.0.0)", "ujson (>=4.0.1,<5.0.0)", "python-multipart (>=0.0.5,<0.0.6)", "flask (>=1.1.2,<3.0.0)", "anyio[trio] (>=3.2.1,<4.0.0)", "types-ujson (==0.1.1)", "types-orjson (==3.6.0)", "types-dataclasses (==0.1.7)"] [[package]] name = "flake8" -version = "3.9.2" +version = "4.0.1" description = "the modular source code checker: pep8 pyflakes and co" category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = ">=3.6" [package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.7.0,<2.8.0" -pyflakes = ">=2.3.0,<2.4.0" +pycodestyle = ">=2.8.0,<2.9.0" +pyflakes = ">=2.4.0,<2.5.0" [[package]] name = "flake8-bandit" @@ -301,18 +254,18 @@ pycodestyle = "*" [[package]] name = "flake8-broken-line" -version = "0.3.0" +version = "0.4.0" description = "Flake8 plugin to forbid backslashes for line breaks" category = "dev" optional = false python-versions = ">=3.6,<4.0" [package.dependencies] -flake8 = ">=3.5,<4.0" +flake8 = ">=3.5,<5" [[package]] name = "flake8-bugbear" -version = "21.9.1" +version = "21.11.29" description = "A plugin for flake8 finding likely bugs and design problems in your program. Contains warnings that don't belong in pyflakes and pycodestyle." category = "dev" optional = false @@ -323,30 +276,29 @@ attrs = ">=19.2.0" flake8 = ">=3.0.0" [package.extras] -dev = ["coverage", "black", "hypothesis", "hypothesmith"] +dev = ["coverage", "hypothesis", "hypothesmith (>=0.2)", "pre-commit"] [[package]] name = "flake8-commas" -version = "2.0.0" +version = "2.1.0" description = "Flake8 lint for trailing commas." category = "dev" optional = false python-versions = "*" [package.dependencies] -flake8 = ">=2,<4.0.0" +flake8 = ">=2" [[package]] name = "flake8-comprehensions" -version = "3.6.1" +version = "3.8.0" description = "A flake8 plugin to help you write better list/set/dict comprehensions." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] -flake8 = ">=3.0,<3.2.0 || >3.2.0,<4" -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +flake8 = ">=3.0,<3.2.0 || >3.2.0" [[package]] name = "flake8-debugger" @@ -375,7 +327,7 @@ pydocstyle = ">=2.1" [[package]] name = "flake8-eradicate" -version = "1.1.0" +version = "1.2.0" description = "Flake8 plugin to find commented out code" category = "dev" optional = false @@ -384,31 +336,23 @@ python-versions = ">=3.6,<4.0" [package.dependencies] attrs = "*" eradicate = ">=2.0,<3.0" -flake8 = ">=3.5,<4.0" +flake8 = ">=3.5,<5" [[package]] name = "flake8-isort" -version = "4.0.0" +version = "4.1.1" description = "flake8 plugin that integrates isort ." category = "dev" optional = false python-versions = "*" [package.dependencies] -flake8 = ">=3.2.1,<4" +flake8 = ">=3.2.1,<5" isort = ">=4.3.5,<6" testfixtures = ">=6.8.0,<7" [package.extras] -test = ["pytest (>=4.0.2,<6)", "toml"] - -[[package]] -name = "flake8-plugin-utils" -version = "1.3.2" -description = "The package provides base classes and utils for flake8 plugin writing" -category = "dev" -optional = false -python-versions = ">=3.6,<4.0" +test = ["pytest-cov"] [[package]] name = "flake8-polyfill" @@ -421,20 +365,9 @@ python-versions = "*" [package.dependencies] flake8 = "*" -[[package]] -name = "flake8-pytest-style" -version = "1.5.0" -description = "A flake8 plugin checking common style issues or inconsistencies with pytest-based tests." -category = "dev" -optional = false -python-versions = ">=3.6,<4.0" - -[package.dependencies] -flake8-plugin-utils = ">=1.3.2,<2.0.0" - [[package]] name = "flake8-quotes" -version = "3.3.0" +version = "3.3.1" description = "Flake8 lint for quotes." category = "dev" optional = false @@ -445,11 +378,11 @@ flake8 = "*" [[package]] name = "flake8-rst-docstrings" -version = "0.2.3" +version = "0.2.5" description = "Python docstring reStructuredText (RST) validator" category = "dev" optional = false -python-versions = ">=3.3" +python-versions = ">=3.6" [package.dependencies] flake8 = ">=3.0.0" @@ -469,7 +402,7 @@ flake8 = "*" [[package]] name = "ghp-import" -version = "2.0.1" +version = "2.0.2" description = "Copy your docs directly to the gh-pages branch." category = "dev" optional = false @@ -479,22 +412,22 @@ python-versions = "*" python-dateutil = ">=2.8.1" [package.extras] -dev = ["twine", "markdown", "flake8"] +dev = ["twine", "markdown", "flake8", "wheel"] [[package]] name = "gitdb" -version = "4.0.7" +version = "4.0.9" description = "Git Object Database" category = "dev" optional = false -python-versions = ">=3.4" +python-versions = ">=3.6" [package.dependencies] -smmap = ">=3.0.1,<5" +smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.23" +version = "3.1.27" description = "GitPython is a python library used to interact with Git repositories" category = "dev" optional = false @@ -502,7 +435,6 @@ python-versions = ">=3.7" [package.dependencies] gitdb = ">=4.0.1,<5" -typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.10\""} [[package]] name = "h11" @@ -514,7 +446,7 @@ python-versions = ">=3.6" [[package]] name = "httpcore" -version = "0.13.7" +version = "0.14.7" description = "A minimal low-level HTTP client." category = "main" optional = false @@ -522,15 +454,17 @@ python-versions = ">=3.6" [package.dependencies] anyio = ">=3.0.0,<4.0.0" +certifi = "*" h11 = ">=0.11,<0.13" sniffio = ">=1.0.0,<2.0.0" [package.extras] http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] [[package]] name = "httpx" -version = "0.19.0" +version = "0.21.3" description = "The next generation HTTP client." category = "main" optional = false @@ -539,17 +473,18 @@ python-versions = ">=3.6" [package.dependencies] certifi = "*" charset-normalizer = "*" -httpcore = ">=0.13.3,<0.14.0" +httpcore = ">=0.14.0,<0.15.0" rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} sniffio = "*" [package.extras] brotli = ["brotlicffi", "brotli"] +cli = ["click (>=8.0.0,<9.0.0)", "rich (>=10.0.0,<11.0.0)", "pygments (>=2.0.0,<3.0.0)"] http2 = ["h2 (>=3,<5)"] [[package]] name = "idna" -version = "3.2" +version = "3.3" description = "Internationalized Domain Names in Applications (IDNA)" category = "main" optional = false @@ -557,20 +492,19 @@ python-versions = ">=3.5" [[package]] name = "importlib-metadata" -version = "4.8.1" +version = "4.11.3" description = "Read metadata from Python packages" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] -typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +docs = ["sphinx", "jaraco.packaging (>=9)", "rst.linker (>=1.9)"] perf = ["ipython"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "importlib-resources (>=1.3)"] [[package]] name = "iniconfig" @@ -582,7 +516,7 @@ python-versions = "*" [[package]] name = "isort" -version = "5.9.3" +version = "5.10.1" description = "A Python utility / library to sort Python imports." category = "dev" optional = false @@ -596,7 +530,7 @@ plugins = ["setuptools"] [[package]] name = "jinja2" -version = "3.0.1" +version = "3.0.3" description = "A very fast and expressive template engine." category = "dev" optional = false @@ -608,21 +542,9 @@ MarkupSafe = ">=2.0" [package.extras] i18n = ["Babel (>=2.7)"] -[[package]] -name = "livereload" -version = "2.6.3" -description = "Python LiveReload is an awesome tool for web developers" -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -six = "*" -tornado = {version = "*", markers = "python_version > \"2.7\""} - [[package]] name = "loguru" -version = "0.5.3" +version = "0.6.0" description = "Python logging made (stupidly) simple" category = "main" optional = false @@ -633,40 +555,29 @@ colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} [package.extras] -dev = ["codecov (>=2.0.15)", "colorama (>=0.3.4)", "flake8 (>=3.7.7)", "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.10b0)", "isort (>=5.1.1)"] +dev = ["colorama (>=0.3.4)", "docutils (==0.16)", "flake8 (>=3.7.7)", "tox (>=3.9.0)", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "black (>=19.10b0)", "isort (>=5.1.1)", "Sphinx (>=4.1.1)", "sphinx-autobuild (>=0.7.1)", "sphinx-rtd-theme (>=0.4.3)"] [[package]] name = "markdown" -version = "3.3.4" +version = "3.3.6" description = "Python implementation of Markdown." category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} +importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} [package.extras] testing = ["coverage", "pyyaml"] -[[package]] -name = "markdown-include" -version = "0.6.0" -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." -category = "dev" -optional = false -python-versions = "*" - -[package.dependencies] -markdown = "*" - [[package]] name = "markupsafe" -version = "2.0.1" +version = "2.1.1" description = "Safely add untrusted strings to HTML/XML markup." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "mccabe" @@ -686,7 +597,7 @@ python-versions = ">=3.6" [[package]] name = "mkdocs" -version = "1.2.2" +version = "1.2.3" description = "Project documentation with Markdown." category = "dev" optional = false @@ -707,32 +618,21 @@ watchdog = ">=2.0" [package.extras] i18n = ["babel (>=2.9.0)"] -[[package]] -name = "mkdocs-autorefs" -version = "0.2.1" -description = "Automatically link across pages in MkDocs." -category = "dev" -optional = false -python-versions = ">=3.6,<4.0" - -[package.dependencies] -Markdown = ">=3.3,<4.0" -mkdocs = ">=1.1,<2.0" - [[package]] name = "mkdocs-material" -version = "7.2.6" +version = "8.1.9" description = "A Material Design theme for MkDocs" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.6" [package.dependencies] +jinja2 = ">=2.11.1" markdown = ">=3.2" -mkdocs = ">=1.2.2" +mkdocs = ">=1.2.3" mkdocs-material-extensions = ">=1.0" -Pygments = ">=2.4" -pymdown-extensions = ">=7.0" +pygments = ">=2.10" +pymdown-extensions = ">=9.0" [[package]] name = "mkdocs-material-extensions" @@ -742,42 +642,9 @@ category = "dev" optional = false python-versions = ">=3.6" -[[package]] -name = "mkdocstrings" -version = "0.15.2" -description = "Automatic documentation from sources, for MkDocs." -category = "dev" -optional = false -python-versions = ">=3.6,<4.0" - -[package.dependencies] -Jinja2 = ">=2.11.1,<4.0" -Markdown = ">=3.3,<4.0" -MarkupSafe = ">=1.1,<3.0" -mkdocs = ">=1.1.1,<2.0.0" -mkdocs-autorefs = ">=0.1,<0.3" -pymdown-extensions = ">=6.3,<9.0" -pytkdocs = ">=0.2.0,<0.12.0" - -[[package]] -name = "molten" -version = "1.0.2" -description = "A minimal, extensible, fast and productive API framework." -category = "main" -optional = true -python-versions = ">=3.6" - -[package.dependencies] -typing-extensions = ">=3.6,<4.0" -typing-inspect = ">=0.3.1,<0.7" - -[package.extras] -all = ["typing-extensions (>=3.6,<4.0)", "typing-inspect (>=0.3.1,<0.7)"] -dev = ["typing-extensions (>=3.6,<4.0)", "typing-inspect (>=0.3.1,<0.7)", "alabaster (>0.7)", "sphinx (<1.8)", "sphinxcontrib-napoleon", "flake8", "flake8-bugbear", "flake8-quotes", "isort", "mypy", "bumpversion (>0.5,<0.6)", "dramatiq[rabbitmq] (>1.3,<2.0)", "gevent", "gunicorn (>19.8)", "jinja2 (>=2.10,<3.0)", "msgpack (>0.5,<0.6)", "prometheus-client (>=0.2,<0.3)", "sqlalchemy (>1.2,<2.0)", "toml (>0.9,<0.10)", "wsgicors (>=0.7,<0.8)", "pytest"] - [[package]] name = "mypy" -version = "0.812" +version = "0.910" description = "Optional static typing for Python" category = "dev" optional = false @@ -785,11 +652,12 @@ python-versions = ">=3.5" [package.dependencies] mypy-extensions = ">=0.4.3,<0.5.0" -typed-ast = ">=1.4.0,<1.5.0" +toml = "*" typing-extensions = ">=3.7.4" [package.extras] dmypy = ["psutil (>=4.0)"] +python2 = ["typed-ast (>=1.4.0,<1.5.0)"] [[package]] name = "mypy-extensions" @@ -799,35 +667,16 @@ category = "main" optional = false python-versions = "*" -[[package]] -name = "nox" -version = "2021.6.12" -description = "Flexible test automation." -category = "dev" -optional = false -python-versions = ">=3.6" - -[package.dependencies] -argcomplete = ">=1.9.4,<2.0" -colorlog = ">=2.6.1,<7.0.0" -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} -packaging = ">=20.9" -py = ">=1.4.0,<2.0.0" -virtualenv = ">=14.0.0" - -[package.extras] -tox_to_nox = ["jinja2", "tox"] - [[package]] name = "packaging" -version = "21.0" +version = "21.3" description = "Core utilities for Python packages" category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -pyparsing = ">=2.0.2" +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" [[package]] name = "pathspec" @@ -839,7 +688,7 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" [[package]] name = "pbr" -version = "5.6.0" +version = "5.8.1" description = "Python Build Reasonableness" category = "dev" optional = false @@ -847,22 +696,23 @@ python-versions = ">=2.6" [[package]] name = "pep8-naming" -version = "0.11.1" +version = "0.12.1" description = "Check PEP-8 naming conventions, plugin for flake8" category = "dev" optional = false python-versions = "*" [package.dependencies] +flake8 = ">=3.9.1" flake8-polyfill = ">=1.0.2,<2" [[package]] name = "platformdirs" -version = "2.3.0" +version = "2.5.1" description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] docs = ["Sphinx (>=4)", "furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)"] @@ -870,33 +720,31 @@ test = ["appdirs (==1.4.4)", "pytest (>=6)", "pytest-cov (>=2.7)", "pytest-mock [[package]] name = "pluggy" -version = "0.13.1" +version = "1.0.0" description = "plugin and hook calling mechanisms for python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.dependencies] -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} +python-versions = ">=3.6" [package.extras] dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] [[package]] name = "py" -version = "1.10.0" +version = "1.11.0" description = "library with cross-python path, ini-parsing, io, code, log facilities" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pycodestyle" -version = "2.7.0" +version = "2.8.0" description = "Python style guide checker" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [[package]] name = "pydantic" @@ -929,7 +777,7 @@ toml = ["toml"] [[package]] name = "pyflakes" -version = "2.3.1" +version = "2.4.0" description = "passive checker of Python programs" category = "dev" optional = false @@ -937,7 +785,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.10.0" +version = "2.11.2" description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false @@ -945,22 +793,25 @@ python-versions = ">=3.5" [[package]] name = "pymdown-extensions" -version = "8.2" +version = "9.3" description = "Extension pack for Python Markdown." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] Markdown = ">=3.2" [[package]] name = "pyparsing" -version = "2.4.7" +version = "3.0.7" description = "Python parsing module" category = "dev" optional = false -python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = ">=3.6" + +[package.extras] +diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" @@ -974,7 +825,6 @@ python-versions = ">=3.6" atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} attrs = ">=19.2.0" colorama = {version = "*", markers = "sys_platform == \"win32\""} -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" @@ -986,7 +836,7 @@ testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xm [[package]] name = "pytest-asyncio" -version = "0.15.1" +version = "0.16.0" description = "Pytest support for asyncio." category = "dev" optional = false @@ -998,30 +848,17 @@ pytest = ">=5.4.0" [package.extras] testing = ["coverage", "hypothesis (>=5.7.1)"] -[[package]] -name = "pytest-clarity" -version = "0.3.0a0" -description = "A plugin providing an alternative, colourful diff output for failing assertions." -category = "dev" -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" - -[package.dependencies] -pytest = ">=3.5.0" -termcolor = "1.1.0" - [[package]] name = "pytest-cov" -version = "2.12.1" +version = "3.0.0" description = "Pytest plugin for measuring coverage." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.6" [package.dependencies] -coverage = ">=5.2.1" +coverage = {version = ">=5.2.1", extras = ["toml"]} pytest = ">=4.6" -toml = "*" [package.extras] testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] @@ -1038,31 +875,23 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" six = ">=1.5" [[package]] -name = "python-multipart" -version = "0.0.5" -description = "A streaming multipart parser for Python" -category = "main" -optional = true -python-versions = "*" - -[package.dependencies] -six = ">=1.4.0" - -[[package]] -name = "pytkdocs" -version = "0.7.0" -description = "Load Python objects documentation." +name = "python-dotenv" +version = "0.19.2" +description = "Read key-value pairs from a .env file and set them as environment variables" category = "dev" optional = false -python-versions = ">=3.6,<4.0" +python-versions = ">=3.5" + +[package.extras] +cli = ["click (>=5.0)"] [[package]] name = "pyyaml" -version = "5.4.1" +version = "6.0" description = "YAML parser and emitter for Python" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.6" [[package]] name = "pyyaml-env-tag" @@ -1076,16 +905,37 @@ python-versions = ">=3.6" pyyaml = "*" [[package]] -name = "regex" -version = "2021.8.28" -description = "Alternative regular expression module, to replace re." +name = "requests" +version = "2.26.0" +description = "Python HTTP for Humans." category = "dev" optional = false -python-versions = "*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = {version = ">=2.0.0,<2.1.0", markers = "python_version >= \"3\""} +idna = {version = ">=2.5,<4", markers = "python_version >= \"3\""} +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)", "win-inet-pton"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<5)"] + +[[package]] +name = "respx" +version = "0.19.0" +description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +httpx = ">=0.21.0" [[package]] name = "restructuredtext-lint" -version = "1.3.2" +version = "1.4.0" description = "reStructuredText linter" category = "dev" optional = false @@ -1112,17 +962,17 @@ idna2008 = ["idna"] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "smmap" -version = "4.0.0" +version = "5.0.0" description = "A pure Python implementation of a sliding window memory map manager" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [[package]] name = "sniffio" @@ -1134,7 +984,7 @@ python-versions = ">=3.5" [[package]] name = "snowballstemmer" -version = "2.1.0" +version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." category = "dev" optional = false @@ -1142,7 +992,7 @@ python-versions = "*" [[package]] name = "starlette" -version = "0.16.0" +version = "0.17.1" description = "The little ASGI library that shines." category = "dev" optional = false @@ -1150,34 +1000,24 @@ python-versions = ">=3.6" [package.dependencies] anyio = ">=3.0.0,<4" -typing-extensions = {version = "*", markers = "python_version < \"3.8\""} [package.extras] -full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests", "graphene"] +full = ["itsdangerous", "jinja2", "python-multipart", "pyyaml", "requests"] [[package]] name = "stevedore" -version = "3.4.0" +version = "3.5.0" description = "Manage dynamic plugins for Python applications" category = "dev" optional = false python-versions = ">=3.6" [package.dependencies] -importlib-metadata = {version = ">=1.7.0", markers = "python_version < \"3.8\""} pbr = ">=2.0.0,<2.1.0 || >2.1.0" -[[package]] -name = "termcolor" -version = "1.1.0" -description = "ANSII Color formatting for output in terminal." -category = "dev" -optional = false -python-versions = "*" - [[package]] name = "testfixtures" -version = "6.18.1" +version = "6.18.5" description = "A collection of helpers and mock objects for unit tests and doc tests." category = "dev" optional = false @@ -1190,7 +1030,7 @@ test = ["pytest (>=3.6)", "pytest-cov", "pytest-django", "zope.component", "sybi [[package]] name = "tokenize-rt" -version = "4.1.0" +version = "4.2.1" description = "A wrapper around the stdlib `tokenize` which roundtrips." category = "dev" optional = false @@ -1205,75 +1045,64 @@ optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] -name = "tornado" -version = "6.1" -description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." +name = "tomli" +version = "1.2.3" +description = "A lil' TOML parser" category = "dev" optional = false -python-versions = ">= 3.5" - -[[package]] -name = "typed-ast" -version = "1.4.3" -description = "a fork of Python 2 and 3 ast modules with type comment support" -category = "dev" -optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] name = "typing-extensions" -version = "3.10.0.2" -description = "Backported and Experimental Type Hints for Python 3.5+" +version = "4.1.1" +description = "Backported and Experimental Type Hints for Python 3.6+" category = "main" optional = false -python-versions = "*" +python-versions = ">=3.6" [[package]] -name = "typing-inspect" -version = "0.6.0" -description = "Runtime inspection utilities for typing module." -category = "main" -optional = true -python-versions = "*" +name = "urllib3" +version = "1.26.9" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" -[package.dependencies] -mypy-extensions = ">=0.3.0" -typing-extensions = ">=3.7.4" +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "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,<2.0)"] [[package]] -name = "virtualenv" -version = "20.7.2" -description = "Virtual Python Environment builder" +name = "uvicorn" +version = "0.16.0" +description = "The lightning-fast ASGI server." category = "dev" optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +python-versions = "*" [package.dependencies] -"backports.entry-points-selectable" = ">=1.0.4" -distlib = ">=0.3.1,<1" -filelock = ">=3.0.0,<4" -importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} -platformdirs = ">=2,<3" -six = ">=1.9.0,<2" +asgiref = ">=3.4.0" +click = ">=7.0" +h11 = ">=0.8" [package.extras] -docs = ["proselint (>=0.10.2)", "sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)"] -testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", "pytest (>=4)", "pytest-env (>=0.6.2)", "pytest-freezegun (>=0.4.1)", "pytest-mock (>=2)", "pytest-randomly (>=1)", "pytest-timeout (>=1)", "packaging (>=20.0)"] +standard = ["httptools (>=0.2.0,<0.4.0)", "watchgod (>=0.6)", "python-dotenv (>=0.13)", "PyYAML (>=5.1)", "websockets (>=9.1)", "websockets (>=10.0)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "colorama (>=0.4)"] [[package]] name = "watchdog" -version = "2.1.5" +version = "2.1.6" description = "Filesystem events monitoring" category = "dev" optional = false python-versions = ">=3.6" [package.extras] -watchmedo = ["PyYAML (>=3.10)", "argh (>=0.24.1)"] +watchmedo = ["PyYAML (>=3.10)"] [[package]] name = "wemake-python-styleguide" -version = "0.15.3" +version = "0.16.0" description = "The strictest and most opinionated python linter ever" category = "dev" optional = false @@ -1283,9 +1112,9 @@ python-versions = ">=3.6,<4.0" astor = ">=0.8,<0.9" attrs = "*" darglint = ">=1.2,<2.0" -flake8 = ">=3.7,<4.0" +flake8 = ">=3.7,<5" flake8-bandit = ">=2.1,<3.0" -flake8-broken-line = ">=0.3,<0.4" +flake8-broken-line = ">=0.3,<0.5" flake8-bugbear = ">=20.1,<22.0" flake8-commas = ">=2.0,<3.0" flake8-comprehensions = ">=3.1,<4.0" @@ -1296,14 +1125,13 @@ flake8-isort = ">=4.0,<5.0" flake8-quotes = ">=3.0,<4.0" flake8-rst-docstrings = ">=0.2.3,<0.3.0" flake8-string-format = ">=0.3,<0.4" -importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} -pep8-naming = ">=0.11,<0.12" +pep8-naming = ">=0.11,<0.13" pygments = ">=2.4,<3.0" -typing_extensions = ">=3.6,<4.0" +typing_extensions = ">=3.6,<5.0" [[package]] name = "win32-setctime" -version = "1.0.3" +version = "1.1.0" description = "A small Python utility to set file creation time on Windows" category = "main" optional = false @@ -1314,44 +1142,37 @@ dev = ["pytest (>=4.6.2)", "black (>=19.3b0)"] [[package]] name = "zipp" -version = "3.5.0" +version = "3.7.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] - -[extras] -tests = ["aiofiles", "molten", "python-multipart"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [metadata] lock-version = "1.1" -python-versions = "^3.7" -content-hash = "4f4ed47faa04787bd89453652c1cf77f4e419feafba93388c15cab12c1b5d6b1" +python-versions = ">=3.8,<3.11" +content-hash = "29869ce33ae5ceefbd9ee52a0ad98196ea521d93607f83421d5494f88c63e2aa" [metadata.files] add-trailing-comma = [ - {file = "add_trailing_comma-2.1.0-py2.py3-none-any.whl", hash = "sha256:f462403aa2e997e20855708edb57536d1d3310d5c5fac7e80542578eb47fdb10"}, - {file = "add_trailing_comma-2.1.0.tar.gz", hash = "sha256:f9864ffbc12ea4e54916a356d57341ab58f612867c2ad453339c51004807e8ce"}, + {file = "add_trailing_comma-2.2.1-py2.py3-none-any.whl", hash = "sha256:981c18282b38ec5bceab80ef11485440334d2a274fcf3fce1f91692374b6d818"}, + {file = "add_trailing_comma-2.2.1.tar.gz", hash = "sha256:1640e97c4e85132633a6cb19b29e392dbaf9516292388afa685f7ef1012468e0"}, ] aiofiles = [ - {file = "aiofiles-0.7.0-py3-none-any.whl", hash = "sha256:c67a6823b5f23fcab0a2595a289cec7d8c863ffcb4322fb8cd6b90400aedfdbc"}, - {file = "aiofiles-0.7.0.tar.gz", hash = "sha256:a1c4fc9b2ff81568c83e21392a82f344ea9d23da906e4f6a52662764545e19d4"}, + {file = "aiofiles-0.8.0-py3-none-any.whl", hash = "sha256:7a973fc22b29e9962d0897805ace5856e6a566ab1f0c8e5c91ff6c866519c937"}, + {file = "aiofiles-0.8.0.tar.gz", hash = "sha256:8334f23235248a3b2e83b2c3a78a22674f39969b96397126cc93664d9a901e59"}, ] anyio = [ - {file = "anyio-3.3.1-py3-none-any.whl", hash = "sha256:d7c604dd491eca70e19c78664d685d5e4337612d574419d503e76f5d7d1590bd"}, - {file = "anyio-3.3.1.tar.gz", hash = "sha256:85913b4e2fec030e8c72a8f9f98092eeb9e25847a6e00d567751b77e34f856fe"}, -] -appdirs = [ - {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, - {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, + {file = "anyio-3.5.0-py3-none-any.whl", hash = "sha256:b5fa16c5ff93fa1046f2eeb5bbff2dad4d3514d6cda61d02816dba34fa8c3c2e"}, + {file = "anyio-3.5.0.tar.gz", hash = "sha256:a0aeffe2fb1fdf374a8e4b471444f0f3ac4fb9f5a5b542b48824475e0042a5a6"}, ] -argcomplete = [ - {file = "argcomplete-1.12.3-py2.py3-none-any.whl", hash = "sha256:291f0beca7fd49ce285d2f10e4c1c77e9460cf823eef2de54df0c0fec88b0d81"}, - {file = "argcomplete-1.12.3.tar.gz", hash = "sha256:2c7dbffd8c045ea534921e63b0be6fe65e88599990d8dc408ac8c542b72a5445"}, +asgiref = [ + {file = "asgiref-3.5.0-py3-none-any.whl", hash = "sha256:88d59c13d634dcffe0510be048210188edd79aeccb6a6c9028cdad6f31d730a9"}, + {file = "asgiref-3.5.0.tar.gz", hash = "sha256:2f8abc20f7248433085eda803936d98992f1343ddb022065779f37c5da0181d0"}, ] astor = [ {file = "astor-0.8.1-py2.py3-none-any.whl", hash = "sha256:070a54e890cefb5b3739d19f30f5a5ec840ffc9c50ffa7d23cc9fc1a38ebbfc5"}, @@ -1362,146 +1183,116 @@ atomicwrites = [ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, - {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, ] autoflake = [ {file = "autoflake-1.4.tar.gz", hash = "sha256:61a353012cff6ab94ca062823d1fb2f692c4acda51c76ff83a8d77915fba51ea"}, ] -"backports.entry-points-selectable" = [ - {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"}, - {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"}, -] bandit = [ - {file = "bandit-1.7.0-py3-none-any.whl", hash = "sha256:216be4d044209fa06cf2a3e51b319769a51be8318140659719aa7a115c35ed07"}, - {file = "bandit-1.7.0.tar.gz", hash = "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608"}, -] -base64io = [ - {file = "base64io-1.0.3-py2.py3-none-any.whl", hash = "sha256:e9a6c9f470e34f8debaad26134bcf3f0bcbf677dac73e32295cfb2915d30815b"}, - {file = "base64io-1.0.3.tar.gz", hash = "sha256:24f2d0fe765c35339e1b2d33aa95f9137b1b765b594164fad1016c15827a7073"}, + {file = "bandit-1.7.2-py3-none-any.whl", hash = "sha256:e20402cadfd126d85b68ed4c8862959663c8c372dbbb1fca8f8e2c9f55a067ec"}, + {file = "bandit-1.7.2.tar.gz", hash = "sha256:6d11adea0214a43813887bfe71a377b5a9955e4c826c8ffd341b494e3ab25260"}, ] black = [ - {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, + {file = "black-21.12b0-py3-none-any.whl", hash = "sha256:a615e69ae185e08fdd73e4715e260e2479c861b5740057fde6e8b4e3b7dd589f"}, + {file = "black-21.12b0.tar.gz", hash = "sha256:77b80f693a569e2e527958459634f18df9b0ba2625ba4e0c2d5da5be42e6f2b3"}, ] certifi = [ - {file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"}, - {file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"}, + {file = "certifi-2021.10.8-py2.py3-none-any.whl", hash = "sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"}, + {file = "certifi-2021.10.8.tar.gz", hash = "sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872"}, ] charset-normalizer = [ - {file = "charset-normalizer-2.0.4.tar.gz", hash = "sha256:f23667ebe1084be45f6ae0538e4a5a865206544097e4e8bbcacf42cd02a348f3"}, - {file = "charset_normalizer-2.0.4-py3-none-any.whl", hash = "sha256:0c8911edd15d19223366a194a513099a302055a962bca2cec0f54b8b63175d8b"}, + {file = "charset-normalizer-2.0.12.tar.gz", hash = "sha256:2857e29ff0d34db842cd7ca3230549d1a697f96ee6d3fb071cfa6c7393832597"}, + {file = "charset_normalizer-2.0.12-py3-none-any.whl", hash = "sha256:6881edbebdb17b39b4eaaa821b438bf6eddffb4468cf344f09f89def34a8b1df"}, ] click = [ - {file = "click-8.0.1-py3-none-any.whl", hash = "sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6"}, - {file = "click-8.0.1.tar.gz", hash = "sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a"}, + {file = "click-8.0.4-py3-none-any.whl", hash = "sha256:6a7a62563bbfabfda3a38f3023a1db4a35978c0abd76f6c9605ecd6554d6d9b1"}, + {file = "click-8.0.4.tar.gz", hash = "sha256:8458d7b1287c5fb128c90e23381cf99dcde74beaf6c7ff6384ce84d6fe090adb"}, ] colorama = [ {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, ] -colorlog = [ - {file = "colorlog-4.8.0-py2.py3-none-any.whl", hash = "sha256:3dd15cb27e8119a24c1a7b5c93f9f3b455855e0f73993b1c25921b2f646f1dcd"}, - {file = "colorlog-4.8.0.tar.gz", hash = "sha256:59b53160c60902c405cdec28d38356e09d40686659048893e026ecbd589516b1"}, -] coverage = [ - {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, - {file = "coverage-5.5-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:b7895207b4c843c76a25ab8c1e866261bcfe27bfaa20c192de5190121770672b"}, - {file = "coverage-5.5-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c2723d347ab06e7ddad1a58b2a821218239249a9e4365eaff6649d31180c1669"}, - {file = "coverage-5.5-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:900fbf7759501bc7807fd6638c947d7a831fc9fdf742dc10f02956ff7220fa90"}, - {file = "coverage-5.5-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:004d1880bed2d97151facef49f08e255a20ceb6f9432df75f4eef018fdd5a78c"}, - {file = "coverage-5.5-cp27-cp27m-win32.whl", hash = "sha256:06191eb60f8d8a5bc046f3799f8a07a2d7aefb9504b0209aff0b47298333302a"}, - {file = "coverage-5.5-cp27-cp27m-win_amd64.whl", hash = "sha256:7501140f755b725495941b43347ba8a2777407fc7f250d4f5a7d2a1050ba8e82"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:372da284cfd642d8e08ef606917846fa2ee350f64994bebfbd3afb0040436905"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:8963a499849a1fc54b35b1c9f162f4108017b2e6db2c46c1bed93a72262ed083"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:869a64f53488f40fa5b5b9dcb9e9b2962a66a87dab37790f3fcfb5144b996ef5"}, - {file = "coverage-5.5-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:4a7697d8cb0f27399b0e393c0b90f0f1e40c82023ea4d45d22bce7032a5d7b81"}, - {file = "coverage-5.5-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:8d0a0725ad7c1a0bcd8d1b437e191107d457e2ec1084b9f190630a4fb1af78e6"}, - {file = "coverage-5.5-cp310-cp310-manylinux1_x86_64.whl", hash = "sha256:51cb9476a3987c8967ebab3f0fe144819781fca264f57f89760037a2ea191cb0"}, - {file = "coverage-5.5-cp310-cp310-win_amd64.whl", hash = "sha256:c0891a6a97b09c1f3e073a890514d5012eb256845c451bd48f7968ef939bf4ae"}, - {file = "coverage-5.5-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:3487286bc29a5aa4b93a072e9592f22254291ce96a9fbc5251f566b6b7343cdb"}, - {file = "coverage-5.5-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:deee1077aae10d8fa88cb02c845cfba9b62c55e1183f52f6ae6a2df6a2187160"}, - {file = "coverage-5.5-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f11642dddbb0253cc8853254301b51390ba0081750a8ac03f20ea8103f0c56b6"}, - {file = "coverage-5.5-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:6c90e11318f0d3c436a42409f2749ee1a115cd8b067d7f14c148f1ce5574d701"}, - {file = "coverage-5.5-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:30c77c1dc9f253283e34c27935fded5015f7d1abe83bc7821680ac444eaf7793"}, - {file = "coverage-5.5-cp35-cp35m-win32.whl", hash = "sha256:9a1ef3b66e38ef8618ce5fdc7bea3d9f45f3624e2a66295eea5e57966c85909e"}, - {file = "coverage-5.5-cp35-cp35m-win_amd64.whl", hash = "sha256:972c85d205b51e30e59525694670de6a8a89691186012535f9d7dbaa230e42c3"}, - {file = "coverage-5.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:af0e781009aaf59e25c5a678122391cb0f345ac0ec272c7961dc5455e1c40066"}, - {file = "coverage-5.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:74d881fc777ebb11c63736622b60cb9e4aee5cace591ce274fb69e582a12a61a"}, - {file = "coverage-5.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:92b017ce34b68a7d67bd6d117e6d443a9bf63a2ecf8567bb3d8c6c7bc5014465"}, - {file = "coverage-5.5-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:d636598c8305e1f90b439dbf4f66437de4a5e3c31fdf47ad29542478c8508bbb"}, - {file = "coverage-5.5-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:41179b8a845742d1eb60449bdb2992196e211341818565abded11cfa90efb821"}, - {file = "coverage-5.5-cp36-cp36m-win32.whl", hash = "sha256:040af6c32813fa3eae5305d53f18875bedd079960822ef8ec067a66dd8afcd45"}, - {file = "coverage-5.5-cp36-cp36m-win_amd64.whl", hash = "sha256:5fec2d43a2cc6965edc0bb9e83e1e4b557f76f843a77a2496cbe719583ce8184"}, - {file = "coverage-5.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:18ba8bbede96a2c3dde7b868de9dcbd55670690af0988713f0603f037848418a"}, - {file = "coverage-5.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:2910f4d36a6a9b4214bb7038d537f015346f413a975d57ca6b43bf23d6563b53"}, - {file = "coverage-5.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:f0b278ce10936db1a37e6954e15a3730bea96a0997c26d7fee88e6c396c2086d"}, - {file = "coverage-5.5-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:796c9c3c79747146ebd278dbe1e5c5c05dd6b10cc3bcb8389dfdf844f3ead638"}, - {file = "coverage-5.5-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:53194af30d5bad77fcba80e23a1441c71abfb3e01192034f8246e0d8f99528f3"}, - {file = "coverage-5.5-cp37-cp37m-win32.whl", hash = "sha256:184a47bbe0aa6400ed2d41d8e9ed868b8205046518c52464fde713ea06e3a74a"}, - {file = "coverage-5.5-cp37-cp37m-win_amd64.whl", hash = "sha256:2949cad1c5208b8298d5686d5a85b66aae46d73eec2c3e08c817dd3513e5848a"}, - {file = "coverage-5.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:217658ec7187497e3f3ebd901afdca1af062b42cfe3e0dafea4cced3983739f6"}, - {file = "coverage-5.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1aa846f56c3d49205c952d8318e76ccc2ae23303351d9270ab220004c580cfe2"}, - {file = "coverage-5.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:24d4a7de75446be83244eabbff746d66b9240ae020ced65d060815fac3423759"}, - {file = "coverage-5.5-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:d1f8bf7b90ba55699b3a5e44930e93ff0189aa27186e96071fac7dd0d06a1873"}, - {file = "coverage-5.5-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:970284a88b99673ccb2e4e334cfb38a10aab7cd44f7457564d11898a74b62d0a"}, - {file = "coverage-5.5-cp38-cp38-win32.whl", hash = "sha256:01d84219b5cdbfc8122223b39a954820929497a1cb1422824bb86b07b74594b6"}, - {file = "coverage-5.5-cp38-cp38-win_amd64.whl", hash = "sha256:2e0d881ad471768bf6e6c2bf905d183543f10098e3b3640fc029509530091502"}, - {file = "coverage-5.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1f9ce122f83b2305592c11d64f181b87153fc2c2bbd3bb4a3dde8303cfb1a6b"}, - {file = "coverage-5.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:13c4ee887eca0f4c5a247b75398d4114c37882658300e153113dafb1d76de529"}, - {file = "coverage-5.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:52596d3d0e8bdf3af43db3e9ba8dcdaac724ba7b5ca3f6358529d56f7a166f8b"}, - {file = "coverage-5.5-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2cafbbb3af0733db200c9b5f798d18953b1a304d3f86a938367de1567f4b5bff"}, - {file = "coverage-5.5-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:44d654437b8ddd9eee7d1eaee28b7219bec228520ff809af170488fd2fed3e2b"}, - {file = "coverage-5.5-cp39-cp39-win32.whl", hash = "sha256:d314ed732c25d29775e84a960c3c60808b682c08d86602ec2c3008e1202e3bb6"}, - {file = "coverage-5.5-cp39-cp39-win_amd64.whl", hash = "sha256:13034c4409db851670bc9acd836243aeee299949bd5673e11844befcb0149f03"}, - {file = "coverage-5.5-pp36-none-any.whl", hash = "sha256:f030f8873312a16414c0d8e1a1ddff2d3235655a2174e3648b4fa66b3f2f1079"}, - {file = "coverage-5.5-pp37-none-any.whl", hash = "sha256:2a3859cb82dcbda1cfd3e6f71c27081d18aa251d20a17d87d26d4cd216fb0af4"}, - {file = "coverage-5.5.tar.gz", hash = "sha256:ebe78fe9a0e874362175b02371bdfbee64d8edc42a044253ddf4ee7d3c15212c"}, -] -coverage-conditional-plugin = [ - {file = "coverage-conditional-plugin-0.4.0.tar.gz", hash = "sha256:3ca50bb03b32f8ba8cf63b18d830f334cb75f401579040dd5c61df1c4687fef0"}, - {file = "coverage_conditional_plugin-0.4.0-py3-none-any.whl", hash = "sha256:3bf586f7b793a9e4e2950f7af32711012929b39d1a4b53a4829489e763fda2dc"}, + {file = "coverage-6.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b27d894748475fa858f9597c0ee1d4829f44683f3813633aaf94b19cb5453cf"}, + {file = "coverage-6.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37d1141ad6b2466a7b53a22e08fe76994c2d35a5b6b469590424a9953155afac"}, + {file = "coverage-6.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9987b0354b06d4df0f4d3e0ec1ae76d7ce7cbca9a2f98c25041eb79eec766f1"}, + {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:26e2deacd414fc2f97dd9f7676ee3eaecd299ca751412d89f40bc01557a6b1b4"}, + {file = "coverage-6.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4dd8bafa458b5c7d061540f1ee9f18025a68e2d8471b3e858a9dad47c8d41903"}, + {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:46191097ebc381fbf89bdce207a6c107ac4ec0890d8d20f3360345ff5976155c"}, + {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6f89d05e028d274ce4fa1a86887b071ae1755082ef94a6740238cd7a8178804f"}, + {file = "coverage-6.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58303469e9a272b4abdb9e302a780072c0633cdcc0165db7eec0f9e32f901e05"}, + {file = "coverage-6.3.2-cp310-cp310-win32.whl", hash = "sha256:2fea046bfb455510e05be95e879f0e768d45c10c11509e20e06d8fcaa31d9e39"}, + {file = "coverage-6.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:a2a8b8bcc399edb4347a5ca8b9b87e7524c0967b335fbb08a83c8421489ddee1"}, + {file = "coverage-6.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f1555ea6d6da108e1999b2463ea1003fe03f29213e459145e70edbaf3e004aaa"}, + {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5f4e1edcf57ce94e5475fe09e5afa3e3145081318e5fd1a43a6b4539a97e518"}, + {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a15dc0a14008f1da3d1ebd44bdda3e357dbabdf5a0b5034d38fcde0b5c234b7"}, + {file = "coverage-6.3.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21b7745788866028adeb1e0eca3bf1101109e2dc58456cb49d2d9b99a8c516e6"}, + {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:8ce257cac556cb03be4a248d92ed36904a59a4a5ff55a994e92214cde15c5bad"}, + {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b0be84e5a6209858a1d3e8d1806c46214e867ce1b0fd32e4ea03f4bd8b2e3359"}, + {file = "coverage-6.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:acf53bc2cf7282ab9b8ba346746afe703474004d9e566ad164c91a7a59f188a4"}, + {file = "coverage-6.3.2-cp37-cp37m-win32.whl", hash = "sha256:8bdde1177f2311ee552f47ae6e5aa7750c0e3291ca6b75f71f7ffe1f1dab3dca"}, + {file = "coverage-6.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b31651d018b23ec463e95cf10070d0b2c548aa950a03d0b559eaa11c7e5a6fa3"}, + {file = "coverage-6.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:07e6db90cd9686c767dcc593dff16c8c09f9814f5e9c51034066cad3373b914d"}, + {file = "coverage-6.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2c6dbb42f3ad25760010c45191e9757e7dce981cbfb90e42feef301d71540059"}, + {file = "coverage-6.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c76aeef1b95aff3905fb2ae2d96e319caca5b76fa41d3470b19d4e4a3a313512"}, + {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cf5cfcb1521dc3255d845d9dca3ff204b3229401994ef8d1984b32746bb45ca"}, + {file = "coverage-6.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fbbdc8d55990eac1b0919ca69eb5a988a802b854488c34b8f37f3e2025fa90d"}, + {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ec6bc7fe73a938933d4178c9b23c4e0568e43e220aef9472c4f6044bfc6dd0f0"}, + {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9baff2a45ae1f17c8078452e9e5962e518eab705e50a0aa8083733ea7d45f3a6"}, + {file = "coverage-6.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd9e830e9d8d89b20ab1e5af09b32d33e1a08ef4c4e14411e559556fd788e6b2"}, + {file = "coverage-6.3.2-cp38-cp38-win32.whl", hash = "sha256:f7331dbf301b7289013175087636bbaf5b2405e57259dd2c42fdcc9fcc47325e"}, + {file = "coverage-6.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:68353fe7cdf91f109fc7d474461b46e7f1f14e533e911a2a2cbb8b0fc8613cf1"}, + {file = "coverage-6.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b78e5afb39941572209f71866aa0b206c12f0109835aa0d601e41552f9b3e620"}, + {file = "coverage-6.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4e21876082ed887baed0146fe222f861b5815455ada3b33b890f4105d806128d"}, + {file = "coverage-6.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34626a7eee2a3da12af0507780bb51eb52dca0e1751fd1471d0810539cefb536"}, + {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ebf730d2381158ecf3dfd4453fbca0613e16eaa547b4170e2450c9707665ce7"}, + {file = "coverage-6.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd6fe30bd519694b356cbfcaca9bd5c1737cddd20778c6a581ae20dc8c04def2"}, + {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:96f8a1cb43ca1422f36492bebe63312d396491a9165ed3b9231e778d43a7fca4"}, + {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dd035edafefee4d573140a76fdc785dc38829fe5a455c4bb12bac8c20cfc3d69"}, + {file = "coverage-6.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5ca5aeb4344b30d0bec47481536b8ba1181d50dbe783b0e4ad03c95dc1296684"}, + {file = "coverage-6.3.2-cp39-cp39-win32.whl", hash = "sha256:f5fa5803f47e095d7ad8443d28b01d48c0359484fec1b9d8606d0e3282084bc4"}, + {file = "coverage-6.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:9548f10d8be799551eb3a9c74bbf2b4934ddb330e08a73320123c07f95cc2d92"}, + {file = "coverage-6.3.2-pp36.pp37.pp38-none-any.whl", hash = "sha256:18d520c6860515a771708937d2f78f63cc47ab3b80cb78e86573b0a760161faf"}, + {file = "coverage-6.3.2.tar.gz", hash = "sha256:03e2a7826086b91ef345ff18742ee9fc47a6839ccd517061ef8fa1976e652ce9"}, ] darglint = [ - {file = "darglint-1.8.0-py3-none-any.whl", hash = "sha256:ac6797bcc918cd8d8f14c168a4a364f54e1aeb4ced59db58e7e4c6dfec2fe15c"}, - {file = "darglint-1.8.0.tar.gz", hash = "sha256:aa605ef47817a6d14797d32b390466edab621768ea4ca5cc0f3c54f6d8dcaec8"}, -] -distlib = [ - {file = "distlib-0.3.2-py2.py3-none-any.whl", hash = "sha256:23e223426b28491b1ced97dc3bbe183027419dfc7982b4fa2f05d5f3ff10711c"}, - {file = "distlib-0.3.2.zip", hash = "sha256:106fef6dc37dd8c0e2c0a60d3fca3e77460a48907f335fa28420463a6f799736"}, + {file = "darglint-1.8.1-py3-none-any.whl", hash = "sha256:5ae11c259c17b0701618a20c3da343a3eb98b3bc4b5a83d31cdd94f5ebdced8d"}, + {file = "darglint-1.8.1.tar.gz", hash = "sha256:080d5106df149b199822e7ee7deb9c012b49891538f14a11be681044f0bb20da"}, ] docutils = [ - {file = "docutils-0.17.1-py2.py3-none-any.whl", hash = "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61"}, - {file = "docutils-0.17.1.tar.gz", hash = "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125"}, + {file = "docutils-0.18.1-py2.py3-none-any.whl", hash = "sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c"}, + {file = "docutils-0.18.1.tar.gz", hash = "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"}, ] eradicate = [ {file = "eradicate-2.0.0.tar.gz", hash = "sha256:27434596f2c5314cc9b31410c93d8f7e8885747399773cd088d3adea647a60c8"}, ] -filelock = [ - {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, - {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, +fastapi = [ + {file = "fastapi-0.73.0-py3-none-any.whl", hash = "sha256:f0a618aff5f6942862f2d3f20f39b1c037e33314d1b8207fd1c3a2cca76dfd8c"}, + {file = "fastapi-0.73.0.tar.gz", hash = "sha256:dcfee92a7f9a72b5d4b7ca364bd2b009f8fc10d95ed5769be20e94f39f7e5a15"}, ] flake8 = [ - {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, - {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, + {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, + {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, ] flake8-bandit = [ {file = "flake8_bandit-2.1.2.tar.gz", hash = "sha256:687fc8da2e4a239b206af2e54a90093572a60d0954f3054e23690739b0b0de3b"}, ] flake8-broken-line = [ - {file = "flake8-broken-line-0.3.0.tar.gz", hash = "sha256:f74e052833324a9e5f0055032f7ccc54b23faabafe5a26241c2f977e70b10b50"}, - {file = "flake8_broken_line-0.3.0-py3-none-any.whl", hash = "sha256:611f79c7f27118e7e5d3dc098ef7681c40aeadf23783700c5dbee840d2baf3af"}, + {file = "flake8-broken-line-0.4.0.tar.gz", hash = "sha256:771aab5aa0997666796fed249d0e48e6c01cdfeca8c95521eea28a38b7ced4c7"}, + {file = "flake8_broken_line-0.4.0-py3-none-any.whl", hash = "sha256:e9c522856862239a2c7ef2c1de0276fa598572aa864bd4e9c7efc2a827538515"}, ] flake8-bugbear = [ - {file = "flake8-bugbear-21.9.1.tar.gz", hash = "sha256:2f60c8ce0dc53d51da119faab2d67dea978227f0f92ed3c44eb7d65fb2e06a96"}, - {file = "flake8_bugbear-21.9.1-py36.py37.py38-none-any.whl", hash = "sha256:45bfdccfb9f2d8aa140e33cac8f46f1e38215c13d5aa8650e7e188d84e2f94c6"}, + {file = "flake8-bugbear-21.11.29.tar.gz", hash = "sha256:8b04cb2fafc6a78e1a9d873bd3988e4282f7959bb6b0d7c1ae648ec09b937a7b"}, + {file = "flake8_bugbear-21.11.29-py36.py37.py38-none-any.whl", hash = "sha256:179e41ddae5de5e3c20d1f61736feeb234e70958fbb56ab3c28a67739c8e9a82"}, ] 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"}, + {file = "flake8-commas-2.1.0.tar.gz", hash = "sha256:940441ab8ee544df564ae3b3f49f20462d75d5c7cac2463e0b27436e2050f263"}, + {file = "flake8_commas-2.1.0-py2.py3-none-any.whl", hash = "sha256:ebb96c31e01d0ef1d0685a21f3f0e2f8153a0381430e748bf0bbbb5d5b453d54"}, ] flake8-comprehensions = [ - {file = "flake8-comprehensions-3.6.1.tar.gz", hash = "sha256:4888de89248b7f7535159189ff693c77f8354f6d37a02619fa28c9921a913aa0"}, - {file = "flake8_comprehensions-3.6.1-py3-none-any.whl", hash = "sha256:e9a010b99aa90c05790d45281ad9953df44a4a08a1a8f6cd41f98b4fc6a268a0"}, + {file = "flake8-comprehensions-3.8.0.tar.gz", hash = "sha256:8e108707637b1d13734f38e03435984f6b7854fa6b5a4e34f93e69534be8e521"}, + {file = "flake8_comprehensions-3.8.0-py3-none-any.whl", hash = "sha256:9406314803abe1193c064544ab14fdc43c58424c0882f6ff8a581eb73fc9bb58"}, ] flake8-debugger = [ {file = "flake8-debugger-4.0.0.tar.gz", hash = "sha256:e43dc777f7db1481db473210101ec2df2bd39a45b149d7218a618e954177eda6"}, @@ -1512,148 +1303,121 @@ flake8-docstrings = [ {file = "flake8_docstrings-1.6.0-py2.py3-none-any.whl", hash = "sha256:99cac583d6c7e32dd28bbfbef120a7c0d1b6dde4adb5a9fd441c4227a6534bde"}, ] flake8-eradicate = [ - {file = "flake8-eradicate-1.1.0.tar.gz", hash = "sha256:f5917d6dbca352efcd10c15fdab9c55c48f0f26f6a8d47898b25d39101f170a8"}, - {file = "flake8_eradicate-1.1.0-py3-none-any.whl", hash = "sha256:d8e39b684a37c257a53cda817d86e2d96c9ba3450ddc292742623a5dfee04d9e"}, + {file = "flake8-eradicate-1.2.0.tar.gz", hash = "sha256:acaa1b6839ff00d284b805c432fdfa6047262bd15a5504ec945797e87b4de1fa"}, + {file = "flake8_eradicate-1.2.0-py3-none-any.whl", hash = "sha256:51dc660d0c1c1ed93af0f813540bbbf72ab2d3466c14e3f3bac371c618b6042f"}, ] flake8-isort = [ - {file = "flake8-isort-4.0.0.tar.gz", hash = "sha256:2b91300f4f1926b396c2c90185844eb1a3d5ec39ea6138832d119da0a208f4d9"}, - {file = "flake8_isort-4.0.0-py2.py3-none-any.whl", hash = "sha256:729cd6ef9ba3659512dee337687c05d79c78e1215fdf921ed67e5fe46cce2f3c"}, -] -flake8-plugin-utils = [ - {file = "flake8-plugin-utils-1.3.2.tar.gz", hash = "sha256:20fa2a8ca2decac50116edb42e6af0a1253ef639ad79941249b840531889c65a"}, - {file = "flake8_plugin_utils-1.3.2-py3-none-any.whl", hash = "sha256:1fe43e3e9acf3a7c0f6b88f5338cad37044d2f156c43cb6b080b5f9da8a76f06"}, + {file = "flake8-isort-4.1.1.tar.gz", hash = "sha256:d814304ab70e6e58859bc5c3e221e2e6e71c958e7005239202fee19c24f82717"}, + {file = "flake8_isort-4.1.1-py3-none-any.whl", hash = "sha256:c4e8b6dcb7be9b71a02e6e5d4196cefcef0f3447be51e82730fb336fff164949"}, ] 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-pytest-style = [ - {file = "flake8-pytest-style-1.5.0.tar.gz", hash = "sha256:668ce8f55edf7db4ac386d2735c3b354b5cb47aa341a4655d91a5788dd03124b"}, - {file = "flake8_pytest_style-1.5.0-py3-none-any.whl", hash = "sha256:ec287a7dc4fe95082af5e408c8b2f8f4b6bcb366d5a17ff6c34112eb03446580"}, -] flake8-quotes = [ - {file = "flake8-quotes-3.3.0.tar.gz", hash = "sha256:f1dd87830ed77ff2ce47fc0ee0fd87ae20e8f045355354ffbf4dcaa18d528217"}, + {file = "flake8-quotes-3.3.1.tar.gz", hash = "sha256:633adca6fb8a08131536af0d750b44d6985b9aba46f498871e21588c3e6f525a"}, ] flake8-rst-docstrings = [ - {file = "flake8-rst-docstrings-0.2.3.tar.gz", hash = "sha256:3045794e1c8467fba33aaea5c246b8369efc9c44ef8b0b20199bb6df7a4bd47b"}, - {file = "flake8_rst_docstrings-0.2.3-py3-none-any.whl", hash = "sha256:565bbb391d7e4d0042924102221e9857ad72929cdd305b26501736ec22c1451a"}, + {file = "flake8-rst-docstrings-0.2.5.tar.gz", hash = "sha256:4fe93f997dea45d9d3c8bd220f12f0b6c359948fb943b5b48021a3f927edd816"}, + {file = "flake8_rst_docstrings-0.2.5-py3-none-any.whl", hash = "sha256:b99d9041b769b857efe45a448dc8c71b1bb311f9cacbdac5de82f96498105082"}, ] flake8-string-format = [ {file = "flake8-string-format-0.3.0.tar.gz", hash = "sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2"}, {file = "flake8_string_format-0.3.0-py2.py3-none-any.whl", hash = "sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"}, ] ghp-import = [ - {file = "ghp-import-2.0.1.tar.gz", hash = "sha256:753de2eace6e0f7d4edfb3cce5e3c3b98cd52aadb80163303d1d036bda7b4483"}, + {file = "ghp-import-2.0.2.tar.gz", hash = "sha256:947b3771f11be850c852c64b561c600fdddf794bab363060854c1ee7ad05e071"}, + {file = "ghp_import-2.0.2-py3-none-any.whl", hash = "sha256:5f8962b30b20652cdffa9c5a9812f7de6bcb56ec475acac579807719bf242c46"}, ] gitdb = [ - {file = "gitdb-4.0.7-py3-none-any.whl", hash = "sha256:6c4cc71933456991da20917998acbe6cf4fb41eeaab7d6d67fbc05ecd4c865b0"}, - {file = "gitdb-4.0.7.tar.gz", hash = "sha256:96bf5c08b157a666fec41129e6d327235284cca4c81e92109260f353ba138005"}, + {file = "gitdb-4.0.9-py3-none-any.whl", hash = "sha256:8033ad4e853066ba6ca92050b9df2f89301b8fc8bf7e9324d412a63f8bf1a8fd"}, + {file = "gitdb-4.0.9.tar.gz", hash = "sha256:bac2fd45c0a1c9cf619e63a90d62bdc63892ef92387424b855792a6cabe789aa"}, ] gitpython = [ - {file = "GitPython-3.1.23-py3-none-any.whl", hash = "sha256:de2e2aff068097b23d6dca5daf588078fd8996a4218f6ffa704a662c2b54f9ac"}, - {file = "GitPython-3.1.23.tar.gz", hash = "sha256:aaae7a3bfdf0a6db30dc1f3aeae47b71cd326d86b936fe2e158aa925fdf1471c"}, + {file = "GitPython-3.1.27-py3-none-any.whl", hash = "sha256:5b68b000463593e05ff2b261acff0ff0972df8ab1b70d3cdbd41b546c8b8fc3d"}, + {file = "GitPython-3.1.27.tar.gz", hash = "sha256:1c885ce809e8ba2d88a29befeb385fcea06338d3640712b59ca623c220bb5704"}, ] h11 = [ {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, ] httpcore = [ - {file = "httpcore-0.13.7-py3-none-any.whl", hash = "sha256:369aa481b014cf046f7067fddd67d00560f2f00426e79569d99cb11245134af0"}, - {file = "httpcore-0.13.7.tar.gz", hash = "sha256:036f960468759e633574d7c121afba48af6419615d36ab8ede979f1ad6276fa3"}, + {file = "httpcore-0.14.7-py3-none-any.whl", hash = "sha256:47d772f754359e56dd9d892d9593b6f9870a37aeb8ba51e9a88b09b3d68cfade"}, + {file = "httpcore-0.14.7.tar.gz", hash = "sha256:7503ec1c0f559066e7e39bc4003fd2ce023d01cf51793e3c173b864eb456ead1"}, ] httpx = [ - {file = "httpx-0.19.0-py3-none-any.whl", hash = "sha256:9bd728a6c5ec0a9e243932a9983d57d3cc4a87bb4f554e1360fce407f78f9435"}, - {file = "httpx-0.19.0.tar.gz", hash = "sha256:92ecd2c00c688b529eda11cedb15161eaf02dee9116712f621c70d9a40b2cdd0"}, + {file = "httpx-0.21.3-py3-none-any.whl", hash = "sha256:df9a0fd43fa79dbab411d83eb1ea6f7a525c96ad92e60c2d7f40388971b25777"}, + {file = "httpx-0.21.3.tar.gz", hash = "sha256:7a3eb67ef0b8abbd6d9402248ef2f84a76080fa1c839f8662e6eb385640e445a"}, ] idna = [ - {file = "idna-3.2-py3-none-any.whl", hash = "sha256:14475042e284991034cb48e06f6851428fb14c4dc953acd9be9a5e95c7b6dd7a"}, - {file = "idna-3.2.tar.gz", hash = "sha256:467fbad99067910785144ce333826c71fb0e63a425657295239737f7ecd125f3"}, + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.8.1-py3-none-any.whl", hash = "sha256:b618b6d2d5ffa2f16add5697cf57a46c76a56229b0ed1c438322e4e95645bd15"}, - {file = "importlib_metadata-4.8.1.tar.gz", hash = "sha256:f284b3e11256ad1e5d03ab86bb2ccd6f5339688ff17a4d797a0fe7df326f23b1"}, + {file = "importlib_metadata-4.11.3-py3-none-any.whl", hash = "sha256:1208431ca90a8cca1a6b8af391bb53c1a2db74e5d1cef6ddced95d4b2062edc6"}, + {file = "importlib_metadata-4.11.3.tar.gz", hash = "sha256:ea4c597ebf37142f827b8f39299579e31685c31d3a438b59f469406afd0f2539"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, ] isort = [ - {file = "isort-5.9.3-py3-none-any.whl", hash = "sha256:e17d6e2b81095c9db0a03a8025a957f334d6ea30b26f9ec70805411e5c7c81f2"}, - {file = "isort-5.9.3.tar.gz", hash = "sha256:9c2ea1e62d871267b78307fe511c0838ba0da28698c5732d54e2790bf3ba9899"}, + {file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"}, + {file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"}, ] jinja2 = [ - {file = "Jinja2-3.0.1-py3-none-any.whl", hash = "sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4"}, - {file = "Jinja2-3.0.1.tar.gz", hash = "sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4"}, -] -livereload = [ - {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"}, + {file = "Jinja2-3.0.3-py3-none-any.whl", hash = "sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8"}, + {file = "Jinja2-3.0.3.tar.gz", hash = "sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"}, ] loguru = [ - {file = "loguru-0.5.3-py3-none-any.whl", hash = "sha256:f8087ac396b5ee5f67c963b495d615ebbceac2796379599820e324419d53667c"}, - {file = "loguru-0.5.3.tar.gz", hash = "sha256:b28e72ac7a98be3d28ad28570299a393dfcd32e5e3f6a353dec94675767b6319"}, + {file = "loguru-0.6.0-py3-none-any.whl", hash = "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3"}, + {file = "loguru-0.6.0.tar.gz", hash = "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c"}, ] markdown = [ - {file = "Markdown-3.3.4-py3-none-any.whl", hash = "sha256:96c3ba1261de2f7547b46a00ea8463832c921d3f9d6aba3f255a6f71386db20c"}, - {file = "Markdown-3.3.4.tar.gz", hash = "sha256:31b5b491868dcc87d6c24b7e3d19a0d730d59d3e46f4eea6430a321bed387a49"}, -] -markdown-include = [ - {file = "markdown-include-0.6.0.tar.gz", hash = "sha256:6f5d680e36f7780c7f0f61dca53ca581bd50d1b56137ddcd6353efafa0c3e4a2"}, + {file = "Markdown-3.3.6-py3-none-any.whl", hash = "sha256:9923332318f843411e9932237530df53162e29dc7a4e2b91e35764583c46c9a3"}, + {file = "Markdown-3.3.6.tar.gz", hash = "sha256:76df8ae32294ec39dcf89340382882dfa12975f87f45c3ed1ecdb1e8cefc7006"}, ] markupsafe = [ - {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"}, - {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"}, - {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"}, - {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"}, - {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"}, - {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"}, - {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, + {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, + {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, + {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, + {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, + {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, ] mccabe = [ {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, @@ -1664,92 +1428,77 @@ mergedeep = [ {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, ] mkdocs = [ - {file = "mkdocs-1.2.2-py3-none-any.whl", hash = "sha256:d019ff8e17ec746afeb54eb9eb4112b5e959597aebc971da46a5c9486137f0ff"}, - {file = "mkdocs-1.2.2.tar.gz", hash = "sha256:a334f5bd98ec960638511366eb8c5abc9c99b9083a0ed2401d8791b112d6b078"}, -] -mkdocs-autorefs = [ - {file = "mkdocs-autorefs-0.2.1.tar.gz", hash = "sha256:b8156d653ed91356e71675ce1fa1186d2b2c2085050012522895c9aa98fca3e5"}, - {file = "mkdocs_autorefs-0.2.1-py3-none-any.whl", hash = "sha256:f301b983a34259df90b3fcf7edc234b5e6c7065bd578781e66fd90b8cfbe76be"}, + {file = "mkdocs-1.2.3-py3-none-any.whl", hash = "sha256:a1fa8c2d0c1305d7fc2b9d9f607c71778572a8b110fb26642aa00296c9e6d072"}, + {file = "mkdocs-1.2.3.tar.gz", hash = "sha256:89f5a094764381cda656af4298727c9f53dc3e602983087e1fe96ea1df24f4c1"}, ] mkdocs-material = [ - {file = "mkdocs-material-7.2.6.tar.gz", hash = "sha256:4bdeff63904680865676ceb3193216934de0b33fa5b2446e0a84ade60929ee54"}, - {file = "mkdocs_material-7.2.6-py2.py3-none-any.whl", hash = "sha256:4c6939b9d7d5c6db948ab02df8525c64211828ddf33286acea8b9d2115cec369"}, + {file = "mkdocs-material-8.1.9.tar.gz", hash = "sha256:a15873a5e116bf4615af4fcedc85a0537492464365286cba50310d96fb066958"}, + {file = "mkdocs_material-8.1.9-py2.py3-none-any.whl", hash = "sha256:6feb433f29227b862418bd1009edeec2e52870770c476bf02840fc094b8823f2"}, ] mkdocs-material-extensions = [ {file = "mkdocs-material-extensions-1.0.3.tar.gz", hash = "sha256:bfd24dfdef7b41c312ede42648f9eb83476ea168ec163b613f9abd12bbfddba2"}, {file = "mkdocs_material_extensions-1.0.3-py3-none-any.whl", hash = "sha256:a82b70e533ce060b2a5d9eb2bc2e1be201cf61f901f93704b4acf6e3d5983a44"}, ] -mkdocstrings = [ - {file = "mkdocstrings-0.15.2-py3-none-any.whl", hash = "sha256:8d6cbe64c07ae66739010979ca01d49dd2f64d1a45009f089d217b9cd2a65e36"}, - {file = "mkdocstrings-0.15.2.tar.gz", hash = "sha256:c2fee9a3a644647c06eb2044fdfede1073adfd1a55bf6752005d3db10705fe73"}, -] -molten = [ - {file = "molten-1.0.2-py3-none-any.whl", hash = "sha256:e4316ecd97e721ac573ff744803542c0ebeee3f3d37575f42614db84f7f6b737"}, - {file = "molten-1.0.2.tar.gz", hash = "sha256:bc119f0f59f2ac1fab447ce23dc44d0d598784f27482c3be8c4fdf3ad722aae1"}, -] mypy = [ - {file = "mypy-0.812-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a26f8ec704e5a7423c8824d425086705e381b4f1dfdef6e3a1edab7ba174ec49"}, - {file = "mypy-0.812-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28fb5479c494b1bab244620685e2eb3c3f988d71fd5d64cc753195e8ed53df7c"}, - {file = "mypy-0.812-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:9743c91088d396c1a5a3c9978354b61b0382b4e3c440ce83cf77994a43e8c521"}, - {file = "mypy-0.812-cp35-cp35m-win_amd64.whl", hash = "sha256:d7da2e1d5f558c37d6e8c1246f1aec1e7349e4913d8fb3cb289a35de573fe2eb"}, - {file = "mypy-0.812-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:4eec37370483331d13514c3f55f446fc5248d6373e7029a29ecb7b7494851e7a"}, - {file = "mypy-0.812-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d65cc1df038ef55a99e617431f0553cd77763869eebdf9042403e16089fe746c"}, - {file = "mypy-0.812-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:61a3d5b97955422964be6b3baf05ff2ce7f26f52c85dd88db11d5e03e146a3a6"}, - {file = "mypy-0.812-cp36-cp36m-win_amd64.whl", hash = "sha256:25adde9b862f8f9aac9d2d11971f226bd4c8fbaa89fb76bdadb267ef22d10064"}, - {file = "mypy-0.812-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:552a815579aa1e995f39fd05dde6cd378e191b063f031f2acfe73ce9fb7f9e56"}, - {file = "mypy-0.812-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:499c798053cdebcaa916eef8cd733e5584b5909f789de856b482cd7d069bdad8"}, - {file = "mypy-0.812-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:5873888fff1c7cf5b71efbe80e0e73153fe9212fafdf8e44adfe4c20ec9f82d7"}, - {file = "mypy-0.812-cp37-cp37m-win_amd64.whl", hash = "sha256:9f94aac67a2045ec719ffe6111df543bac7874cee01f41928f6969756e030564"}, - {file = "mypy-0.812-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d23e0ea196702d918b60c8288561e722bf437d82cb7ef2edcd98cfa38905d506"}, - {file = "mypy-0.812-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:674e822aa665b9fd75130c6c5f5ed9564a38c6cea6a6432ce47eafb68ee578c5"}, - {file = "mypy-0.812-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:abf7e0c3cf117c44d9285cc6128856106183938c68fd4944763003decdcfeb66"}, - {file = "mypy-0.812-cp38-cp38-win_amd64.whl", hash = "sha256:0d0a87c0e7e3a9becdfbe936c981d32e5ee0ccda3e0f07e1ef2c3d1a817cf73e"}, - {file = "mypy-0.812-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7ce3175801d0ae5fdfa79b4f0cfed08807af4d075b402b7e294e6aa72af9aa2a"}, - {file = "mypy-0.812-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b09669bcda124e83708f34a94606e01b614fa71931d356c1f1a5297ba11f110a"}, - {file = "mypy-0.812-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:33f159443db0829d16f0a8d83d94df3109bb6dd801975fe86bacb9bf71628e97"}, - {file = "mypy-0.812-cp39-cp39-win_amd64.whl", hash = "sha256:3f2aca7f68580dc2508289c729bd49ee929a436208d2b2b6aab15745a70a57df"}, - {file = "mypy-0.812-py3-none-any.whl", hash = "sha256:2f9b3407c58347a452fc0736861593e105139b905cca7d097e413453a1d650b4"}, - {file = "mypy-0.812.tar.gz", hash = "sha256:cd07039aa5df222037005b08fbbfd69b3ab0b0bd7a07d7906de75ae52c4e3119"}, + {file = "mypy-0.910-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:a155d80ea6cee511a3694b108c4494a39f42de11ee4e61e72bc424c490e46457"}, + {file = "mypy-0.910-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b94e4b785e304a04ea0828759172a15add27088520dc7e49ceade7834275bedb"}, + {file = "mypy-0.910-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:088cd9c7904b4ad80bec811053272986611b84221835e079be5bcad029e79dd9"}, + {file = "mypy-0.910-cp35-cp35m-win_amd64.whl", hash = "sha256:adaeee09bfde366d2c13fe6093a7df5df83c9a2ba98638c7d76b010694db760e"}, + {file = "mypy-0.910-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ecd2c3fe726758037234c93df7e98deb257fd15c24c9180dacf1ef829da5f921"}, + {file = "mypy-0.910-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d9dd839eb0dc1bbe866a288ba3c1afc33a202015d2ad83b31e875b5905a079b6"}, + {file = "mypy-0.910-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:3e382b29f8e0ccf19a2df2b29a167591245df90c0b5a2542249873b5c1d78212"}, + {file = "mypy-0.910-cp36-cp36m-win_amd64.whl", hash = "sha256:53fd2eb27a8ee2892614370896956af2ff61254c275aaee4c230ae771cadd885"}, + {file = "mypy-0.910-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b6fb13123aeef4a3abbcfd7e71773ff3ff1526a7d3dc538f3929a49b42be03f0"}, + {file = "mypy-0.910-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e4dab234478e3bd3ce83bac4193b2ecd9cf94e720ddd95ce69840273bf44f6de"}, + {file = "mypy-0.910-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:7df1ead20c81371ccd6091fa3e2878559b5c4d4caadaf1a484cf88d93ca06703"}, + {file = "mypy-0.910-cp37-cp37m-win_amd64.whl", hash = "sha256:0aadfb2d3935988ec3815952e44058a3100499f5be5b28c34ac9d79f002a4a9a"}, + {file = "mypy-0.910-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ec4e0cd079db280b6bdabdc807047ff3e199f334050db5cbb91ba3e959a67504"}, + {file = "mypy-0.910-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:119bed3832d961f3a880787bf621634ba042cb8dc850a7429f643508eeac97b9"}, + {file = "mypy-0.910-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:866c41f28cee548475f146aa4d39a51cf3b6a84246969f3759cb3e9c742fc072"}, + {file = "mypy-0.910-cp38-cp38-win_amd64.whl", hash = "sha256:ceb6e0a6e27fb364fb3853389607cf7eb3a126ad335790fa1e14ed02fba50811"}, + {file = "mypy-0.910-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1a85e280d4d217150ce8cb1a6dddffd14e753a4e0c3cf90baabb32cefa41b59e"}, + {file = "mypy-0.910-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42c266ced41b65ed40a282c575705325fa7991af370036d3f134518336636f5b"}, + {file = "mypy-0.910-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:3c4b8ca36877fc75339253721f69603a9c7fdb5d4d5a95a1a1b899d8b86a4de2"}, + {file = "mypy-0.910-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:c0df2d30ed496a08de5daed2a9ea807d07c21ae0ab23acf541ab88c24b26ab97"}, + {file = "mypy-0.910-cp39-cp39-win_amd64.whl", hash = "sha256:c6c2602dffb74867498f86e6129fd52a2770c48b7cd3ece77ada4fa38f94eba8"}, + {file = "mypy-0.910-py3-none-any.whl", hash = "sha256:ef565033fa5a958e62796867b1df10c40263ea9ded87164d67572834e57a174d"}, + {file = "mypy-0.910.tar.gz", hash = "sha256:704098302473cb31a218f1775a873b376b30b4c18229421e9e9dc8916fd16150"}, ] 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"}, ] -nox = [ - {file = "nox-2021.6.12-py3-none-any.whl", hash = "sha256:1e90df301f6622efb1c29d1586e5a5755846b1eb99b2764230304f8fa31d1734"}, - {file = "nox-2021.6.12.tar.gz", hash = "sha256:955dbeb8e657a08226f8c1c8f8d1e2a40fe5438a792056314f351e504639a80f"}, -] packaging = [ - {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, - {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, ] pathspec = [ {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, ] pbr = [ - {file = "pbr-5.6.0-py2.py3-none-any.whl", hash = "sha256:c68c661ac5cc81058ac94247278eeda6d2e6aecb3e227b0387c30d277e7ef8d4"}, - {file = "pbr-5.6.0.tar.gz", hash = "sha256:42df03e7797b796625b1029c0400279c7c34fd7df24a7d7818a1abb5b38710dd"}, + {file = "pbr-5.8.1-py2.py3-none-any.whl", hash = "sha256:27108648368782d07bbf1cb468ad2e2eeef29086affd14087a6d04b7de8af4ec"}, + {file = "pbr-5.8.1.tar.gz", hash = "sha256:66bc5a34912f408bb3925bf21231cb6f59206267b7f63f3503ef865c1a292e25"}, ] pep8-naming = [ - {file = "pep8-naming-0.11.1.tar.gz", hash = "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724"}, - {file = "pep8_naming-0.11.1-py2.py3-none-any.whl", hash = "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738"}, + {file = "pep8-naming-0.12.1.tar.gz", hash = "sha256:bb2455947757d162aa4cad55dba4ce029005cd1692f2899a21d51d8630ca7841"}, + {file = "pep8_naming-0.12.1-py2.py3-none-any.whl", hash = "sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37"}, ] platformdirs = [ - {file = "platformdirs-2.3.0-py3-none-any.whl", hash = "sha256:8003ac87717ae2c7ee1ea5a84a1a61e87f3fbd16eb5aadba194ea30a9019f648"}, - {file = "platformdirs-2.3.0.tar.gz", hash = "sha256:15b056538719b1c94bdaccb29e5f81879c7f7f0f4a153f46086d155dffcd4f0f"}, + {file = "platformdirs-2.5.1-py3-none-any.whl", hash = "sha256:bcae7cab893c2d310a711b70b24efb93334febe65f8de776ee320b517471e227"}, + {file = "platformdirs-2.5.1.tar.gz", hash = "sha256:7535e70dfa32e84d4b34996ea99c5e432fa29a708d0f4e394bbcb2a8faa4f16d"}, ] pluggy = [ - {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, - {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, + {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, + {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, ] py = [ - {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, - {file = "py-1.10.0.tar.gz", hash = "sha256:21b81bda15b66ef5e1a777a21c4dcd9c20ad3efd0b3f817e7a809035269e1bd3"}, + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, ] pycodestyle = [ - {file = "pycodestyle-2.7.0-py2.py3-none-any.whl", hash = "sha256:514f76d918fcc0b55c6680472f0a37970994e07bbb80725808c17089be302068"}, - {file = "pycodestyle-2.7.0.tar.gz", hash = "sha256:c389c1d06bf7904078ca03399a4816f974a1d590090fecea0c63ec26ebaf1cef"}, + {file = "pycodestyle-2.8.0-py2.py3-none-any.whl", hash = "sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20"}, + {file = "pycodestyle-2.8.0.tar.gz", hash = "sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"}, ] pydantic = [ {file = "pydantic-1.8.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:05ddfd37c1720c392f4e0d43c484217b7521558302e7069ce8d318438d297739"}, @@ -1780,127 +1529,90 @@ pydocstyle = [ {file = "pydocstyle-6.1.1.tar.gz", hash = "sha256:1d41b7c459ba0ee6c345f2eb9ae827cab14a7533a88c5c6f7e94923f72df92dc"}, ] pyflakes = [ - {file = "pyflakes-2.3.1-py2.py3-none-any.whl", hash = "sha256:7893783d01b8a89811dd72d7dfd4d84ff098e5eed95cfa8905b22bbffe52efc3"}, - {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, + {file = "pyflakes-2.4.0-py2.py3-none-any.whl", hash = "sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"}, + {file = "pyflakes-2.4.0.tar.gz", hash = "sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c"}, ] pygments = [ - {file = "Pygments-2.10.0-py3-none-any.whl", hash = "sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380"}, - {file = "Pygments-2.10.0.tar.gz", hash = "sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6"}, + {file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"}, + {file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"}, ] pymdown-extensions = [ - {file = "pymdown-extensions-8.2.tar.gz", hash = "sha256:b6daa94aad9e1310f9c64c8b1f01e4ce82937ab7eb53bfc92876a97aca02a6f4"}, - {file = "pymdown_extensions-8.2-py3-none-any.whl", hash = "sha256:141452d8ed61165518f2c923454bf054866b85cf466feedb0eb68f04acdc2560"}, + {file = "pymdown-extensions-9.3.tar.gz", hash = "sha256:a80553b243d3ed2d6c27723bcd64ca9887e560e6f4808baa96f36e93061eaf90"}, + {file = "pymdown_extensions-9.3-py3-none-any.whl", hash = "sha256:b37461a181c1c8103cfe1660081726a0361a8294cbfda88e5b02cefe976f0546"}, ] pyparsing = [ - {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, - {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, + {file = "pyparsing-3.0.7-py3-none-any.whl", hash = "sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"}, + {file = "pyparsing-3.0.7.tar.gz", hash = "sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea"}, ] pytest = [ {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, {file = "pytest-6.2.5.tar.gz", hash = "sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89"}, ] pytest-asyncio = [ - {file = "pytest-asyncio-0.15.1.tar.gz", hash = "sha256:2564ceb9612bbd560d19ca4b41347b54e7835c2f792c504f698e05395ed63f6f"}, - {file = "pytest_asyncio-0.15.1-py3-none-any.whl", hash = "sha256:3042bcdf1c5d978f6b74d96a151c4cfb9dcece65006198389ccd7e6c60eb1eea"}, -] -pytest-clarity = [ - {file = "pytest-clarity-0.3.0a0.tar.gz", hash = "sha256:5cc99e3d9b7969dfe17e5f6072d45a917c59d363b679686d3c958a1ded2e4dcf"}, + {file = "pytest-asyncio-0.16.0.tar.gz", hash = "sha256:7496c5977ce88c34379df64a66459fe395cd05543f0a2f837016e7144391fcfb"}, + {file = "pytest_asyncio-0.16.0-py3-none-any.whl", hash = "sha256:5f2a21273c47b331ae6aa5b36087047b4899e40f03f18397c0e65fa5cca54e9b"}, ] pytest-cov = [ - {file = "pytest-cov-2.12.1.tar.gz", hash = "sha256:261ceeb8c227b726249b376b8526b600f38667ee314f910353fa318caa01f4d7"}, - {file = "pytest_cov-2.12.1-py2.py3-none-any.whl", hash = "sha256:261bb9e47e65bd099c89c3edf92972865210c36813f80ede5277dceb77a4a62a"}, + {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"}, + {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"}, ] python-dateutil = [ {file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"}, {file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"}, ] -python-multipart = [ - {file = "python-multipart-0.0.5.tar.gz", hash = "sha256:f7bb5f611fc600d15fa47b3974c8aa16e93724513b49b5f95c81e6624c83fa43"}, -] -pytkdocs = [ - {file = "pytkdocs-0.7.0-py3-none-any.whl", hash = "sha256:96c494143e70ccbb657bc4c0a93a97da0209f839f0236c08f227faedc51c1745"}, - {file = "pytkdocs-0.7.0.tar.gz", hash = "sha256:88c79290525f7658e8271ce19dd343c01c53bbe6c2801d1bfcc6792cad0636d5"}, +python-dotenv = [ + {file = "python-dotenv-0.19.2.tar.gz", hash = "sha256:a5de49a31e953b45ff2d2fd434bbc2670e8db5273606c1e737cc6b93eff3655f"}, + {file = "python_dotenv-0.19.2-py2.py3-none-any.whl", hash = "sha256:32b2bdc1873fd3a3c346da1c6db83d0053c3c62f28f1f38516070c4c8971b1d3"}, ] pyyaml = [ - {file = "PyYAML-5.4.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win32.whl", hash = "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393"}, - {file = "PyYAML-5.4.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8"}, - {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, - {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, - {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, - {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, - {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, - {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, - {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, - {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, - {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, - {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, - {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, - {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, - {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, - {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, - {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, - {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, + {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, + {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, + {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, + {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, + {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, + {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, + {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, + {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, + {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, + {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, + {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, + {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, + {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, + {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, + {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, + {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, + {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, + {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, + {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, + {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, + {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, ] pyyaml-env-tag = [ {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, ] -regex = [ - {file = "regex-2021.8.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9d05ad5367c90814099000442b2125535e9d77581855b9bee8780f1b41f2b1a2"}, - {file = "regex-2021.8.28-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3bf1bc02bc421047bfec3343729c4bbbea42605bcfd6d6bfe2c07ade8b12d2a"}, - {file = "regex-2021.8.28-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f6a808044faae658f546dd5f525e921de9fa409de7a5570865467f03a626fc0"}, - {file = "regex-2021.8.28-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a617593aeacc7a691cc4af4a4410031654f2909053bd8c8e7db837f179a630eb"}, - {file = "regex-2021.8.28-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79aef6b5cd41feff359acaf98e040844613ff5298d0d19c455b3d9ae0bc8c35a"}, - {file = "regex-2021.8.28-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:0fc1f8f06977c2d4f5e3d3f0d4a08089be783973fc6b6e278bde01f0544ff308"}, - {file = "regex-2021.8.28-cp310-cp310-win32.whl", hash = "sha256:6eebf512aa90751d5ef6a7c2ac9d60113f32e86e5687326a50d7686e309f66ed"}, - {file = "regex-2021.8.28-cp310-cp310-win_amd64.whl", hash = "sha256:ac88856a8cbccfc14f1b2d0b829af354cc1743cb375e7f04251ae73b2af6adf8"}, - {file = "regex-2021.8.28-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c206587c83e795d417ed3adc8453a791f6d36b67c81416676cad053b4104152c"}, - {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8690ed94481f219a7a967c118abaf71ccc440f69acd583cab721b90eeedb77c"}, - {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:328a1fad67445550b982caa2a2a850da5989fd6595e858f02d04636e7f8b0b13"}, - {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c7cb4c512d2d3b0870e00fbbac2f291d4b4bf2634d59a31176a87afe2777c6f0"}, - {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66256b6391c057305e5ae9209941ef63c33a476b73772ca967d4a2df70520ec1"}, - {file = "regex-2021.8.28-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8e44769068d33e0ea6ccdf4b84d80c5afffe5207aa4d1881a629cf0ef3ec398f"}, - {file = "regex-2021.8.28-cp36-cp36m-win32.whl", hash = "sha256:08d74bfaa4c7731b8dac0a992c63673a2782758f7cfad34cf9c1b9184f911354"}, - {file = "regex-2021.8.28-cp36-cp36m-win_amd64.whl", hash = "sha256:abb48494d88e8a82601af905143e0de838c776c1241d92021e9256d5515b3645"}, - {file = "regex-2021.8.28-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b4c220a1fe0d2c622493b0a1fd48f8f991998fb447d3cd368033a4b86cf1127a"}, - {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4a332404baa6665b54e5d283b4262f41f2103c255897084ec8f5487ce7b9e8e"}, - {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c61dcc1cf9fd165127a2853e2c31eb4fb961a4f26b394ac9fe5669c7a6592892"}, - {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ee329d0387b5b41a5dddbb6243a21cb7896587a651bebb957e2d2bb8b63c0791"}, - {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f60667673ff9c249709160529ab39667d1ae9fd38634e006bec95611f632e759"}, - {file = "regex-2021.8.28-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b844fb09bd9936ed158ff9df0ab601e2045b316b17aa8b931857365ea8586906"}, - {file = "regex-2021.8.28-cp37-cp37m-win32.whl", hash = "sha256:4cde065ab33bcaab774d84096fae266d9301d1a2f5519d7bd58fc55274afbf7a"}, - {file = "regex-2021.8.28-cp37-cp37m-win_amd64.whl", hash = "sha256:1413b5022ed6ac0d504ba425ef02549a57d0f4276de58e3ab7e82437892704fc"}, - {file = "regex-2021.8.28-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ed4b50355b066796dacdd1cf538f2ce57275d001838f9b132fab80b75e8c84dd"}, - {file = "regex-2021.8.28-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28fc475f560d8f67cc8767b94db4c9440210f6958495aeae70fac8faec631797"}, - {file = "regex-2021.8.28-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdc178caebd0f338d57ae445ef8e9b737ddf8fbc3ea187603f65aec5b041248f"}, - {file = "regex-2021.8.28-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:999ad08220467b6ad4bd3dd34e65329dd5d0df9b31e47106105e407954965256"}, - {file = "regex-2021.8.28-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:808ee5834e06f57978da3e003ad9d6292de69d2bf6263662a1a8ae30788e080b"}, - {file = "regex-2021.8.28-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d5111d4c843d80202e62b4fdbb4920db1dcee4f9366d6b03294f45ed7b18b42e"}, - {file = "regex-2021.8.28-cp38-cp38-win32.whl", hash = "sha256:473858730ef6d6ff7f7d5f19452184cd0caa062a20047f6d6f3e135a4648865d"}, - {file = "regex-2021.8.28-cp38-cp38-win_amd64.whl", hash = "sha256:31a99a4796bf5aefc8351e98507b09e1b09115574f7c9dbb9cf2111f7220d2e2"}, - {file = "regex-2021.8.28-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:04f6b9749e335bb0d2f68c707f23bb1773c3fb6ecd10edf0f04df12a8920d468"}, - {file = "regex-2021.8.28-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b006628fe43aa69259ec04ca258d88ed19b64791693df59c422b607b6ece8bb"}, - {file = "regex-2021.8.28-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:121f4b3185feaade3f85f70294aef3f777199e9b5c0c0245c774ae884b110a2d"}, - {file = "regex-2021.8.28-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:a577a21de2ef8059b58f79ff76a4da81c45a75fe0bfb09bc8b7bb4293fa18983"}, - {file = "regex-2021.8.28-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1743345e30917e8c574f273f51679c294effba6ad372db1967852f12c76759d8"}, - {file = "regex-2021.8.28-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:e1e8406b895aba6caa63d9fd1b6b1700d7e4825f78ccb1e5260551d168db38ed"}, - {file = "regex-2021.8.28-cp39-cp39-win32.whl", hash = "sha256:ed283ab3a01d8b53de3a05bfdf4473ae24e43caee7dcb5584e86f3f3e5ab4374"}, - {file = "regex-2021.8.28-cp39-cp39-win_amd64.whl", hash = "sha256:610b690b406653c84b7cb6091facb3033500ee81089867ee7d59e675f9ca2b73"}, - {file = "regex-2021.8.28.tar.gz", hash = "sha256:f585cbbeecb35f35609edccb95efd95a3e35824cd7752b586503f7e6087303f1"}, +requests = [ + {file = "requests-2.26.0-py2.py3-none-any.whl", hash = "sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24"}, + {file = "requests-2.26.0.tar.gz", hash = "sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7"}, +] +respx = [ + {file = "respx-0.19.0-py2.py3-none-any.whl", hash = "sha256:1ac1cc99bf892ffd3e33108ae43d71d8309a58ac226965f4bd81ec055600f265"}, + {file = "respx-0.19.0.tar.gz", hash = "sha256:4a09e15803c7450d45303520ec528794c9fd77b05984263bc83b78aabbb39413"}, ] restructuredtext-lint = [ - {file = "restructuredtext_lint-1.3.2.tar.gz", hash = "sha256:d3b10a1fe2ecac537e51ae6d151b223b78de9fafdd50e5eb6b08c243df173c80"}, + {file = "restructuredtext_lint-1.4.0.tar.gz", hash = "sha256:1b235c0c922341ab6c530390892eb9e92f90b9b75046063e047cacfb0f050c45"}, ] rfc3986 = [ {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, @@ -1911,163 +1623,87 @@ six = [ {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] smmap = [ - {file = "smmap-4.0.0-py2.py3-none-any.whl", hash = "sha256:a9a7479e4c572e2e775c404dcd3080c8dc49f39918c2cf74913d30c4c478e3c2"}, - {file = "smmap-4.0.0.tar.gz", hash = "sha256:7e65386bd122d45405ddf795637b7f7d2b532e7e401d46bbe3fb49b9986d5182"}, + {file = "smmap-5.0.0-py3-none-any.whl", hash = "sha256:2aba19d6a040e78d8b09de5c57e96207b09ed71d8e55ce0959eeee6c8e190d94"}, + {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, ] sniffio = [ {file = "sniffio-1.2.0-py3-none-any.whl", hash = "sha256:471b71698eac1c2112a40ce2752bb2f4a4814c22a54a3eed3676bc0f5ca9f663"}, {file = "sniffio-1.2.0.tar.gz", hash = "sha256:c4666eecec1d3f50960c6bdf61ab7bc350648da6c126e3cf6898d8cd4ddcd3de"}, ] snowballstemmer = [ - {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, - {file = "snowballstemmer-2.1.0.tar.gz", hash = "sha256:e997baa4f2e9139951b6f4c631bad912dfd3c792467e2f03d7239464af90e914"}, + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, ] starlette = [ - {file = "starlette-0.16.0-py3-none-any.whl", hash = "sha256:38eb24bf705a2c317e15868e384c1b8a12ca396e5a3c3a003db7e667c43f939f"}, - {file = "starlette-0.16.0.tar.gz", hash = "sha256:e1904b5d0007aee24bdd3c43994be9b3b729f4f58e740200de1d623f8c3a8870"}, + {file = "starlette-0.17.1-py3-none-any.whl", hash = "sha256:26a18cbda5e6b651c964c12c88b36d9898481cd428ed6e063f5f29c418f73050"}, + {file = "starlette-0.17.1.tar.gz", hash = "sha256:57eab3cc975a28af62f6faec94d355a410634940f10b30d68d31cb5ec1b44ae8"}, ] stevedore = [ - {file = "stevedore-3.4.0-py3-none-any.whl", hash = "sha256:920ce6259f0b2498aaa4545989536a27e4e4607b8318802d7ddc3a533d3d069e"}, - {file = "stevedore-3.4.0.tar.gz", hash = "sha256:59b58edb7f57b11897f150475e7bc0c39c5381f0b8e3fa9f5c20ce6c89ec4aa1"}, -] -termcolor = [ - {file = "termcolor-1.1.0.tar.gz", hash = "sha256:1d6d69ce66211143803fbc56652b41d73b4a400a2891d7bf7a1cdf4c02de613b"}, + {file = "stevedore-3.5.0-py3-none-any.whl", hash = "sha256:a547de73308fd7e90075bb4d301405bebf705292fa90a90fc3bcf9133f58616c"}, + {file = "stevedore-3.5.0.tar.gz", hash = "sha256:f40253887d8712eaa2bb0ea3830374416736dc8ec0e22f5a65092c1174c44335"}, ] testfixtures = [ - {file = "testfixtures-6.18.1-py2.py3-none-any.whl", hash = "sha256:486be7b01eb71326029811878a3317b7e7994324621c0ec633c8e24499d8d5b3"}, - {file = "testfixtures-6.18.1.tar.gz", hash = "sha256:0a6422737f6d89b45cdef1e2df5576f52ad0f507956002ce1020daa9f44211d6"}, + {file = "testfixtures-6.18.5-py2.py3-none-any.whl", hash = "sha256:7de200e24f50a4a5d6da7019fb1197aaf5abd475efb2ec2422fdcf2f2eb98c1d"}, + {file = "testfixtures-6.18.5.tar.gz", hash = "sha256:02dae883f567f5b70fd3ad3c9eefb95912e78ac90be6c7444b5e2f46bf572c84"}, ] tokenize-rt = [ - {file = "tokenize_rt-4.1.0-py2.py3-none-any.whl", hash = "sha256:b37251fa28c21e8cce2e42f7769a35fba2dd2ecafb297208f9a9a8add3ca7793"}, - {file = "tokenize_rt-4.1.0.tar.gz", hash = "sha256:ab339b5ff829eb5e198590477f9c03c84e762b3e455e74c018956e7e326cbc70"}, + {file = "tokenize_rt-4.2.1-py2.py3-none-any.whl", hash = "sha256:08a27fa032a81cf45e8858d0ac706004fcd523e8463415ddf1442be38e204ea8"}, + {file = "tokenize_rt-4.2.1.tar.gz", hash = "sha256:0d4f69026fed520f8a1e0103aa36c406ef4661417f20ca643f913e33531b3b94"}, ] toml = [ {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] -tornado = [ - {file = "tornado-6.1-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:d371e811d6b156d82aa5f9a4e08b58debf97c302a35714f6f45e35139c332e32"}, - {file = "tornado-6.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:0d321a39c36e5f2c4ff12b4ed58d41390460f798422c4504e09eb5678e09998c"}, - {file = "tornado-6.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:9de9e5188a782be6b1ce866e8a51bc76a0fbaa0e16613823fc38e4fc2556ad05"}, - {file = "tornado-6.1-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:61b32d06ae8a036a6607805e6720ef00a3c98207038444ba7fd3d169cd998910"}, - {file = "tornado-6.1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:3e63498f680547ed24d2c71e6497f24bca791aca2fe116dbc2bd0ac7f191691b"}, - {file = "tornado-6.1-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:6c77c9937962577a6a76917845d06af6ab9197702a42e1346d8ae2e76b5e3675"}, - {file = "tornado-6.1-cp35-cp35m-win32.whl", hash = "sha256:6286efab1ed6e74b7028327365cf7346b1d777d63ab30e21a0f4d5b275fc17d5"}, - {file = "tornado-6.1-cp35-cp35m-win_amd64.whl", hash = "sha256:fa2ba70284fa42c2a5ecb35e322e68823288a4251f9ba9cc77be04ae15eada68"}, - {file = "tornado-6.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:0a00ff4561e2929a2c37ce706cb8233b7907e0cdc22eab98888aca5dd3775feb"}, - {file = "tornado-6.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:748290bf9112b581c525e6e6d3820621ff020ed95af6f17fedef416b27ed564c"}, - {file = "tornado-6.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e385b637ac3acaae8022e7e47dfa7b83d3620e432e3ecb9a3f7f58f150e50921"}, - {file = "tornado-6.1-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:25ad220258349a12ae87ede08a7b04aca51237721f63b1808d39bdb4b2164558"}, - {file = "tornado-6.1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:65d98939f1a2e74b58839f8c4dab3b6b3c1ce84972ae712be02845e65391ac7c"}, - {file = "tornado-6.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:e519d64089b0876c7b467274468709dadf11e41d65f63bba207e04217f47c085"}, - {file = "tornado-6.1-cp36-cp36m-win32.whl", hash = "sha256:b87936fd2c317b6ee08a5741ea06b9d11a6074ef4cc42e031bc6403f82a32575"}, - {file = "tornado-6.1-cp36-cp36m-win_amd64.whl", hash = "sha256:cc0ee35043162abbf717b7df924597ade8e5395e7b66d18270116f8745ceb795"}, - {file = "tornado-6.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7250a3fa399f08ec9cb3f7b1b987955d17e044f1ade821b32e5f435130250d7f"}, - {file = "tornado-6.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ed3ad863b1b40cd1d4bd21e7498329ccaece75db5a5bf58cd3c9f130843e7102"}, - {file = "tornado-6.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:dcef026f608f678c118779cd6591c8af6e9b4155c44e0d1bc0c87c036fb8c8c4"}, - {file = "tornado-6.1-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:70dec29e8ac485dbf57481baee40781c63e381bebea080991893cd297742b8fd"}, - {file = "tornado-6.1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:d3f7594930c423fd9f5d1a76bee85a2c36fd8b4b16921cae7e965f22575e9c01"}, - {file = "tornado-6.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3447475585bae2e77ecb832fc0300c3695516a47d46cefa0528181a34c5b9d3d"}, - {file = "tornado-6.1-cp37-cp37m-win32.whl", hash = "sha256:e7229e60ac41a1202444497ddde70a48d33909e484f96eb0da9baf8dc68541df"}, - {file = "tornado-6.1-cp37-cp37m-win_amd64.whl", hash = "sha256:cb5ec8eead331e3bb4ce8066cf06d2dfef1bfb1b2a73082dfe8a161301b76e37"}, - {file = "tornado-6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:20241b3cb4f425e971cb0a8e4ffc9b0a861530ae3c52f2b0434e6c1b57e9fd95"}, - {file = "tornado-6.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:c77da1263aa361938476f04c4b6c8916001b90b2c2fdd92d8d535e1af48fba5a"}, - {file = "tornado-6.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fba85b6cd9c39be262fcd23865652920832b61583de2a2ca907dbd8e8a8c81e5"}, - {file = "tornado-6.1-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:1e8225a1070cd8eec59a996c43229fe8f95689cb16e552d130b9793cb570a288"}, - {file = "tornado-6.1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d14d30e7f46a0476efb0deb5b61343b1526f73ebb5ed84f23dc794bdb88f9d9f"}, - {file = "tornado-6.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8f959b26f2634a091bb42241c3ed8d3cedb506e7c27b8dd5c7b9f745318ddbb6"}, - {file = "tornado-6.1-cp38-cp38-win32.whl", hash = "sha256:34ca2dac9e4d7afb0bed4677512e36a52f09caa6fded70b4e3e1c89dbd92c326"}, - {file = "tornado-6.1-cp38-cp38-win_amd64.whl", hash = "sha256:6196a5c39286cc37c024cd78834fb9345e464525d8991c21e908cc046d1cc02c"}, - {file = "tornado-6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0ba29bafd8e7e22920567ce0d232c26d4d47c8b5cf4ed7b562b5db39fa199c5"}, - {file = "tornado-6.1-cp39-cp39-manylinux1_i686.whl", hash = "sha256:33892118b165401f291070100d6d09359ca74addda679b60390b09f8ef325ffe"}, - {file = "tornado-6.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7da13da6f985aab7f6f28debab00c67ff9cbacd588e8477034c0652ac141feea"}, - {file = "tornado-6.1-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:e0791ac58d91ac58f694d8d2957884df8e4e2f6687cdf367ef7eb7497f79eaa2"}, - {file = "tornado-6.1-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:66324e4e1beede9ac79e60f88de548da58b1f8ab4b2f1354d8375774f997e6c0"}, - {file = "tornado-6.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:a48900ecea1cbb71b8c71c620dee15b62f85f7c14189bdeee54966fbd9a0c5bd"}, - {file = "tornado-6.1-cp39-cp39-win32.whl", hash = "sha256:d3d20ea5782ba63ed13bc2b8c291a053c8d807a8fa927d941bd718468f7b950c"}, - {file = "tornado-6.1-cp39-cp39-win_amd64.whl", hash = "sha256:548430be2740e327b3fe0201abe471f314741efcb0067ec4f2d7dcfb4825f3e4"}, - {file = "tornado-6.1.tar.gz", hash = "sha256:33c6e81d7bd55b468d2e793517c909b139960b6c790a60b7991b9b6b76fb9791"}, -] -typed-ast = [ - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:2068531575a125b87a41802130fa7e29f26c09a2833fea68d9a40cf33902eba6"}, - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:c907f561b1e83e93fad565bac5ba9c22d96a54e7ea0267c708bffe863cbe4075"}, - {file = "typed_ast-1.4.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:1b3ead4a96c9101bef08f9f7d1217c096f31667617b58de957f690c92378b528"}, - {file = "typed_ast-1.4.3-cp35-cp35m-win32.whl", hash = "sha256:dde816ca9dac1d9c01dd504ea5967821606f02e510438120091b84e852367428"}, - {file = "typed_ast-1.4.3-cp35-cp35m-win_amd64.whl", hash = "sha256:777a26c84bea6cd934422ac2e3b78863a37017618b6e5c08f92ef69853e765d3"}, - {file = "typed_ast-1.4.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f8afcf15cc511ada719a88e013cec87c11aff7b91f019295eb4530f96fe5ef2f"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:52b1eb8c83f178ab787f3a4283f68258525f8d70f778a2f6dd54d3b5e5fb4341"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:01ae5f73431d21eead5015997ab41afa53aa1fbe252f9da060be5dad2c730ace"}, - {file = "typed_ast-1.4.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c190f0899e9f9f8b6b7863debfb739abcb21a5c054f911ca3596d12b8a4c4c7f"}, - {file = "typed_ast-1.4.3-cp36-cp36m-win32.whl", hash = "sha256:398e44cd480f4d2b7ee8d98385ca104e35c81525dd98c519acff1b79bdaac363"}, - {file = "typed_ast-1.4.3-cp36-cp36m-win_amd64.whl", hash = "sha256:bff6ad71c81b3bba8fa35f0f1921fb24ff4476235a6e94a26ada2e54370e6da7"}, - {file = "typed_ast-1.4.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0fb71b8c643187d7492c1f8352f2c15b4c4af3f6338f21681d3681b3dc31a266"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:760ad187b1041a154f0e4d0f6aae3e40fdb51d6de16e5c99aedadd9246450e9e"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5feca99c17af94057417d744607b82dd0a664fd5e4ca98061480fd8b14b18d04"}, - {file = "typed_ast-1.4.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:95431a26309a21874005845c21118c83991c63ea800dd44843e42a916aec5899"}, - {file = "typed_ast-1.4.3-cp37-cp37m-win32.whl", hash = "sha256:aee0c1256be6c07bd3e1263ff920c325b59849dc95392a05f258bb9b259cf39c"}, - {file = "typed_ast-1.4.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9ad2c92ec681e02baf81fdfa056fe0d818645efa9af1f1cd5fd6f1bd2bdfd805"}, - {file = "typed_ast-1.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b36b4f3920103a25e1d5d024d155c504080959582b928e91cb608a65c3a49e1a"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:067a74454df670dcaa4e59349a2e5c81e567d8d65458d480a5b3dfecec08c5ff"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7538e495704e2ccda9b234b82423a4038f324f3a10c43bc088a1636180f11a41"}, - {file = "typed_ast-1.4.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:af3d4a73793725138d6b334d9d247ce7e5f084d96284ed23f22ee626a7b88e39"}, - {file = "typed_ast-1.4.3-cp38-cp38-win32.whl", hash = "sha256:f2362f3cb0f3172c42938946dbc5b7843c2a28aec307c49100c8b38764eb6927"}, - {file = "typed_ast-1.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:dd4a21253f42b8d2b48410cb31fe501d32f8b9fbeb1f55063ad102fe9c425e40"}, - {file = "typed_ast-1.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f328adcfebed9f11301eaedfa48e15bdece9b519fb27e6a8c01aa52a17ec31b3"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:2c726c276d09fc5c414693a2de063f521052d9ea7c240ce553316f70656c84d4"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:cae53c389825d3b46fb37538441f75d6aecc4174f615d048321b716df2757fb0"}, - {file = "typed_ast-1.4.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9574c6f03f685070d859e75c7f9eeca02d6933273b5e69572e5ff9d5e3931c3"}, - {file = "typed_ast-1.4.3-cp39-cp39-win32.whl", hash = "sha256:209596a4ec71d990d71d5e0d312ac935d86930e6eecff6ccc7007fe54d703808"}, - {file = "typed_ast-1.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:9c6d1a54552b5330bc657b7ef0eae25d00ba7ffe85d9ea8ae6540d2197a3788c"}, - {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, +tomli = [ + {file = "tomli-1.2.3-py3-none-any.whl", hash = "sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c"}, + {file = "tomli-1.2.3.tar.gz", hash = "sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f"}, ] typing-extensions = [ - {file = "typing_extensions-3.10.0.2-py2-none-any.whl", hash = "sha256:d8226d10bc02a29bcc81df19a26e56a9647f8b0a6d4a83924139f4a8b01f17b7"}, - {file = "typing_extensions-3.10.0.2-py3-none-any.whl", hash = "sha256:f1d25edafde516b146ecd0613dabcc61409817af4766fbbcfb8d1ad4ec441a34"}, - {file = "typing_extensions-3.10.0.2.tar.gz", hash = "sha256:49f75d16ff11f1cd258e1b988ccff82a3ca5570217d7ad8c5f48205dd99a677e"}, + {file = "typing_extensions-4.1.1-py3-none-any.whl", hash = "sha256:21c85e0fe4b9a155d0799430b0ad741cdce7e359660ccbd8b530613e8df88ce2"}, + {file = "typing_extensions-4.1.1.tar.gz", hash = "sha256:1a9462dcc3347a79b1f1c0271fbe79e844580bb598bafa1ed208b94da3cdcd42"}, ] -typing-inspect = [ - {file = "typing_inspect-0.6.0-py2-none-any.whl", hash = "sha256:de08f50a22955ddec353876df7b2545994d6df08a2f45d54ac8c05e530372ca0"}, - {file = "typing_inspect-0.6.0-py3-none-any.whl", hash = "sha256:3b98390df4d999a28cf5b35d8b333425af5da2ece8a4ea9e98f71e7591347b4f"}, - {file = "typing_inspect-0.6.0.tar.gz", hash = "sha256:8f1b1dd25908dbfd81d3bebc218011531e7ab614ba6e5bf7826d887c834afab7"}, +urllib3 = [ + {file = "urllib3-1.26.9-py2.py3-none-any.whl", hash = "sha256:44ece4d53fb1706f667c9bd1c648f5469a2ec925fcf3a776667042d645472c14"}, + {file = "urllib3-1.26.9.tar.gz", hash = "sha256:aabaf16477806a5e1dd19aa41f8c2b7950dd3c746362d7e3223dbe6de6ac448e"}, ] -virtualenv = [ - {file = "virtualenv-20.7.2-py2.py3-none-any.whl", hash = "sha256:e4670891b3a03eb071748c569a87cceaefbf643c5bac46d996c5a45c34aa0f06"}, - {file = "virtualenv-20.7.2.tar.gz", hash = "sha256:9ef4e8ee4710826e98ff3075c9a4739e2cb1040de6a2a8d35db0055840dc96a0"}, +uvicorn = [ + {file = "uvicorn-0.16.0-py3-none-any.whl", hash = "sha256:d8c839231f270adaa6d338d525e2652a0b4a5f4c2430b5c4ef6ae4d11776b0d2"}, + {file = "uvicorn-0.16.0.tar.gz", hash = "sha256:eacb66afa65e0648fcbce5e746b135d09722231ffffc61883d4fac2b62fbea8d"}, ] watchdog = [ - {file = "watchdog-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5f57ce4f7e498278fb2a091f39359930144a0f2f90ea8cbf4523c4e25de34028"}, - {file = "watchdog-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8b74d0d92a69a7ab5f101f9fe74e44ba017be269efa824337366ccbb4effde85"}, - {file = "watchdog-2.1.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:59767f476cd1f48531bf378f0300565d879688c82da8369ca8c52f633299523c"}, - {file = "watchdog-2.1.5-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:814d396859c95598f7576d15bc257c3bd3ba61fa4bc1db7dfc18f09070ded7da"}, - {file = "watchdog-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:28777dbed3bbd95f9c70f461443990a36c07dbf49ae7cd69932cdd1b8fb2850c"}, - {file = "watchdog-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5cf78f794c9d7bc64a626ef4f71aff88f57a7ae288e0b359a9c6ea711a41395f"}, - {file = "watchdog-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:43bf728eb7830559f329864ab5da2302c15b2efbac24ad84ccc09949ba753c40"}, - {file = "watchdog-2.1.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a7053d4d22dc95c5e0c90aeeae1e4ed5269d2f04001798eec43a654a03008d22"}, - {file = "watchdog-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6f3ad1d973fe8fc8fe64ba38f6a934b74346342fa98ef08ad5da361a05d46044"}, - {file = "watchdog-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41d44ef21a77a32b55ce9bf59b75777063751f688de51098859b7c7f6466589a"}, - {file = "watchdog-2.1.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ed4ca4351cd2bb0d863ee737a2011ca44d8d8be19b43509bd4507f8a449b376b"}, - {file = "watchdog-2.1.5-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8874d5ad6b7f43b18935d9b0183e29727a623a216693d6938d07dfd411ba462f"}, - {file = "watchdog-2.1.5-py3-none-manylinux2014_aarch64.whl", hash = "sha256:50a7f81f99d238f72185f481b493f9de80096e046935b60ea78e1276f3d76960"}, - {file = "watchdog-2.1.5-py3-none-manylinux2014_armv7l.whl", hash = "sha256:e40e33a4889382824846b4baa05634e1365b47c6fa40071dc2d06b4d7c715fc1"}, - {file = "watchdog-2.1.5-py3-none-manylinux2014_i686.whl", hash = "sha256:78b1514067ff4089f4dac930b043a142997a5b98553120919005e97fbaba6546"}, - {file = "watchdog-2.1.5-py3-none-manylinux2014_ppc64.whl", hash = "sha256:58ae842300cbfe5e62fb068c83901abe76e4f413234b7bec5446e4275eb1f9cb"}, - {file = "watchdog-2.1.5-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:b0cc7d8b7d60da6c313779d85903ce39a63d89d866014b085f720a083d5f3e9a"}, - {file = "watchdog-2.1.5-py3-none-manylinux2014_s390x.whl", hash = "sha256:e60d3bb7166b7cb830b86938d1eb0e6cfe23dfd634cce05c128f8f9967895193"}, - {file = "watchdog-2.1.5-py3-none-manylinux2014_x86_64.whl", hash = "sha256:51af09ae937ada0e9a10cc16988ec03c649754a91526170b6839b89fc56d6acb"}, - {file = "watchdog-2.1.5-py3-none-win32.whl", hash = "sha256:9391003635aa783957b9b11175d9802d3272ed67e69ef2e3394c0b6d9d24fa9a"}, - {file = "watchdog-2.1.5-py3-none-win_amd64.whl", hash = "sha256:eab14adfc417c2c983fbcb2c73ef3f28ba6990d1fff45d1180bf7e38bda0d98d"}, - {file = "watchdog-2.1.5-py3-none-win_ia64.whl", hash = "sha256:a2888a788893c4ef7e562861ec5433875b7915f930a5a7ed3d32c048158f1be5"}, - {file = "watchdog-2.1.5.tar.gz", hash = "sha256:5563b005907613430ef3d4aaac9c78600dd5704e84764cb6deda4b3d72807f09"}, + {file = "watchdog-2.1.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9693f35162dc6208d10b10ddf0458cc09ad70c30ba689d9206e02cd836ce28a3"}, + {file = "watchdog-2.1.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:aba5c812f8ee8a3ff3be51887ca2d55fb8e268439ed44110d3846e4229eb0e8b"}, + {file = "watchdog-2.1.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4ae38bf8ba6f39d5b83f78661273216e7db5b00f08be7592062cb1fc8b8ba542"}, + {file = "watchdog-2.1.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:ad6f1796e37db2223d2a3f302f586f74c72c630b48a9872c1e7ae8e92e0ab669"}, + {file = "watchdog-2.1.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:922a69fa533cb0c793b483becaaa0845f655151e7256ec73630a1b2e9ebcb660"}, + {file = "watchdog-2.1.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:b2fcf9402fde2672545b139694284dc3b665fd1be660d73eca6805197ef776a3"}, + {file = "watchdog-2.1.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3386b367e950a11b0568062b70cc026c6f645428a698d33d39e013aaeda4cc04"}, + {file = "watchdog-2.1.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8f1c00aa35f504197561060ca4c21d3cc079ba29cf6dd2fe61024c70160c990b"}, + {file = "watchdog-2.1.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b52b88021b9541a60531142b0a451baca08d28b74a723d0c99b13c8c8d48d604"}, + {file = "watchdog-2.1.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8047da932432aa32c515ec1447ea79ce578d0559362ca3605f8e9568f844e3c6"}, + {file = "watchdog-2.1.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e92c2d33858c8f560671b448205a268096e17870dcf60a9bb3ac7bfbafb7f5f9"}, + {file = "watchdog-2.1.6-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b7d336912853d7b77f9b2c24eeed6a5065d0a0cc0d3b6a5a45ad6d1d05fb8cd8"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_aarch64.whl", hash = "sha256:cca7741c0fcc765568350cb139e92b7f9f3c9a08c4f32591d18ab0a6ac9e71b6"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_armv7l.whl", hash = "sha256:25fb5240b195d17de949588628fdf93032ebf163524ef08933db0ea1f99bd685"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_i686.whl", hash = "sha256:be9be735f827820a06340dff2ddea1fb7234561fa5e6300a62fe7f54d40546a0"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_ppc64.whl", hash = "sha256:d0d19fb2441947b58fbf91336638c2b9f4cc98e05e1045404d7a4cb7cddc7a65"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:3becdb380d8916c873ad512f1701f8a92ce79ec6978ffde92919fd18d41da7fb"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_s390x.whl", hash = "sha256:ae67501c95606072aafa865b6ed47343ac6484472a2f95490ba151f6347acfc2"}, + {file = "watchdog-2.1.6-py3-none-manylinux2014_x86_64.whl", hash = "sha256:e0f30db709c939cabf64a6dc5babb276e6d823fd84464ab916f9b9ba5623ca15"}, + {file = "watchdog-2.1.6-py3-none-win32.whl", hash = "sha256:e02794ac791662a5eafc6ffeaf9bcc149035a0e48eb0a9d40a8feb4622605a3d"}, + {file = "watchdog-2.1.6-py3-none-win_amd64.whl", hash = "sha256:bd9ba4f332cf57b2c1f698be0728c020399ef3040577cde2939f2e045b39c1e5"}, + {file = "watchdog-2.1.6-py3-none-win_ia64.whl", hash = "sha256:a0f1c7edf116a12f7245be06120b1852275f9506a7d90227648b250755a03923"}, + {file = "watchdog-2.1.6.tar.gz", hash = "sha256:a36e75df6c767cbf46f61a91c70b3ba71811dfa0aca4a324d9407a06a8b7a2e7"}, ] wemake-python-styleguide = [ - {file = "wemake-python-styleguide-0.15.3.tar.gz", hash = "sha256:8b89aedabae67b7b915908ed06c178b702068137c0d8afe1fb59cdc829cd2143"}, - {file = "wemake_python_styleguide-0.15.3-py3-none-any.whl", hash = "sha256:a382f6c9ec87d56daa08a11e47cab019c99b384f1393b32564ebc74c6da80441"}, + {file = "wemake-python-styleguide-0.16.0.tar.gz", hash = "sha256:3bf0a4962404e6fd6fa479e72e2ba3fb75d5920ea6c44b72b45240c9e519543c"}, + {file = "wemake_python_styleguide-0.16.0-py3-none-any.whl", hash = "sha256:8caa92b4aa77b08a505d718553238812d1b612b1036bc171ca3aa18345efe0b4"}, ] win32-setctime = [ - {file = "win32_setctime-1.0.3-py3-none-any.whl", hash = "sha256:dc925662de0a6eb987f0b01f599c01a8236cb8c62831c22d9cada09ad958243e"}, - {file = "win32_setctime-1.0.3.tar.gz", hash = "sha256:4e88556c32fdf47f64165a2180ba4552f8bb32c1103a2fafd05723a0bd42bd4b"}, + {file = "win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad"}, + {file = "win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2"}, ] zipp = [ - {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"}, - {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"}, + {file = "zipp-3.7.0-py3-none-any.whl", hash = "sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"}, + {file = "zipp-3.7.0.tar.gz", hash = "sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d"}, ] diff --git a/pyproject.toml b/pyproject.toml index b858a205..6bfb5769 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,86 +1,52 @@ [tool.poetry] name = "botx" -version = "0.28.0" -description = "A little python framework for building bots for eXpress" -license = "MIT" +version = "0.30.0" +description = "A python library for interacting with eXpress BotX API" authors = [ "Sidnev Nikolay ", "Maxim Gorbachev ", - "Alexander Samoylenko " + "Alexander Samoylenko ", + "Arseniy Zhiltsov " ] -readme = "README.md" documentation = "https://expressapp.github.io/pybotx" repository = "https://github.com/ExpressApp/pybotx" + [tool.poetry.dependencies] -python = "^3.7" +python = ">=3.8,<3.11" -base64io = "^1.0.3" -httpx = "^0.19.0" -loguru = "^0.5.0" -pydantic = "^1.0.0" -typing-extensions = { version = "^3.7.4", python = "<3.8" } +aiofiles = ">=0.7.0,<0.9.0" +httpx = ">=0.18.0,<0.22.0" +loguru = ">=0.6.0,<0.7.0" +mypy-extensions = ">=0.2.0,<0.5.0" +pydantic = ">=1.6.0,<1.9.0" +typing-extensions = ">=3.7.4,<5.0.0" -# for testing by users -aiofiles = { version = "^0.7.0", optional = true } -molten = { version = "^1.0.1", optional = true } -python-multipart = { version = "^0.0.5", optional = true } [tool.poetry.dev-dependencies] -# tasks -nox = "^2021.0.0" -# formatters -black = "^20.8b1" -isort = "^5.9" -autoflake = "^1.4" -add-trailing-comma = "^2.0.1" -# linters -mypy = "^0.812" -wemake-python-styleguide = "^0.15" -flake8-pytest-style = "^1.1.1" -# tests -pytest = "^6.0.0" -pytest-asyncio = "^0.15.0" -pytest-cov = "^2.8.1" -pytest-clarity = "^0.3.0-alpha.0" -coverage-conditional-plugin = "^0.4.0" -starlette = "^0.16.0" -# docs -mkdocs = "^1.1" -mkdocs-material = "^7.0.0" -markdown-include = "^0.6.0" -mkdocstrings = "^0.15.0" -livereload = "^2.6.3" +add-trailing-comma = "2.2.1" +autoflake = "1.4.0" +black = "21.12b0" +isort = "5.10.1" +mypy = "0.910.0" +wemake-python-styleguide = "0.16.0" +bandit = "1.7.2" # https://github.com/PyCQA/bandit/issues/837 + +pytest = "6.2.5" +pytest-asyncio = "0.16.0" +pytest-cov = "3.0.0" +python-dotenv = "0.19.2" +requests = "2.26.0" +respx = "0.19.0" -[tool.poetry.extras] -tests = ["aiofiles", "molten", "python-multipart"] +mkdocs = "1.2.3" +mkdocs-material = "8.1.9" +markdown = "3.3.6" # https://github.com/python-poetry/poetry/issues/4777 -[tool.black] -target_version = ['py37', 'py38'] -include = '\.pyi?$' -exclude = ''' -/(\.git/ - |\.eggs - |\.hg - |__pycache__ - |\.cache - |\.ipynb_checkpoints - |\.mypy_cache - |\.pytest_cache - |\.tox - |\.venv - |node_modules - |_build - |buck-out - |build - |dist - |media - |infrastructure - |templates - |locale -)/ -''' +fastapi = "0.73.0" +starlette = "0.17.1" # TODO: Drop dependency after updating end-to-end test +uvicorn = "0.16.0" [build-system] -requires = ["poetry>=0.1.0"] +requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" diff --git a/scripts/docs-format b/scripts/docs-format new file mode 100755 index 00000000..7acc651f --- /dev/null +++ b/scripts/docs-format @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -ex + +autoflake --recursive --in-place \ + --remove-all-unused-imports \ + --ignore-init-module-imports \ + docs/snippets +isort --profile black docs/snippets +black docs/snippets + +find docs/snippets -type f -name "*.py" | xargs add-trailing-comma --py36-plus --exit-zero-even-if-changed + +# This `black` is needed again in order to transfer parameters/arguments to new lines +# after inserting commas. +# The first `black` won't be able to transfer parameters/arguments to new lines because +# there is no comma at the end of the line. +# Inserting commas must be after the first `black`, so that there is one new line break, +# if the line is out of max-line-length. +black botx tests > /dev/null diff --git a/scripts/docs-lint b/scripts/docs-lint new file mode 100755 index 00000000..c73e32d6 --- /dev/null +++ b/scripts/docs-lint @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -ex + +black --check --diff docs/snippets +isort --profile black --check-only docs/snippets + +mypy docs/snippets +flake8 docs/snippets diff --git a/scripts/format b/scripts/format new file mode 100755 index 00000000..dd2b08e8 --- /dev/null +++ b/scripts/format @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -ex + +autoflake --recursive --in-place \ + --remove-all-unused-imports \ + --ignore-init-module-imports \ + botx tests +isort --profile black botx tests +black botx tests + +find botx -type f -name "*.py" | xargs add-trailing-comma --py36-plus --exit-zero-even-if-changed +find tests -type f -name "*.py" | xargs add-trailing-comma --py36-plus --exit-zero-even-if-changed + +# This `black` is needed again in order to transfer parameters/arguments to new lines +# after inserting commas. +# The first `black` won't be able to transfer parameters/arguments to new lines because +# there is no comma at the end of the line. +# Inserting commas must be after the first `black`, so that there is one new line break, +# if the line is out of max-line-length. +black botx tests > /dev/null diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 00000000..c2da4b1f --- /dev/null +++ b/scripts/lint @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -ex + +black --check --diff botx tests +isort --profile black --check-only botx tests + +mypy botx tests +flake8 botx tests + +./scripts/wip_marks diff --git a/scripts/test b/scripts/test new file mode 100755 index 00000000..743b0a6b --- /dev/null +++ b/scripts/test @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +set -ex + +pytest ${@} diff --git a/scripts/wip_marks b/scripts/wip_marks new file mode 100755 index 00000000..daddf39e --- /dev/null +++ b/scripts/wip_marks @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +wip_markers=$(find . -name "*.py" -print0 | xargs -0 grep -n "@pytest.mark.wip") + +if [[ "$wip_markers" ]] +then + printf "Some wip marks found:\n%s\n" "$wip_markers" >&2 + exit 1 +fi diff --git a/setup.cfg b/setup.cfg index 7639e7a4..eed2b5d5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,206 +1,153 @@ -# All configuration for plugins and other utils is defined here. -# Read more about `setup.cfg`: -# https://docs.python.org/3/distutils/configfile.html - - -[isort] -# isort configuration: -# https://github.com/timothycrosley/isort/wiki/isort-Settings -include_trailing_comma = true -# See https://github.com/timothycrosley/isort#multi-line-output-modes -multi_line_output = 3 -line_length = 88 -force_grid_wrap = 0 -combine_as_imports = True - - -[darglint] -# darglint configuration: -# https://github.com/terrencepreilly/darglint -strictness = long - - -[tool:pytest] -# Directories that are not visited by pytest collector: -norecursedirs = *.egg .eggs dist build docs .tox .git __pycache__ - -# You will need to measure your tests speed with `-n auto` and without it, -# so you can see whether it gives you any performance gain, or just gives -# you an overhead. See `docs/template/development-process.rst`. -addopts = - --strict-markers - --tb=short - --cov=botx - --cov=tests - --cov-branch - --cov-report=term-missing - --cov-report=html - --cov-report=xml - --no-cov-on-fail - --cov-fail-under=100 - - -[coverage:run] -# Here we specify plugins for coverage to be used: -plugins = - coverage_conditional_plugin - -omit = - - -[coverage:report] -precision = 2 -exclude_lines = - pragma: no cover - raise NotImplementedError - if TYPE_CHECKING: - except ImportError: - - -[coverage:coverage_conditional_plugin] -# Here we specify our pragma rules: -rules = - "sys_version_info < (3, 8)": py-lt-38 - - [mypy] -# Mypy configuration: -# https://mypy.readthedocs.io/en/latest/config_file.html plugins = pydantic.mypy +warn_unused_configs = True +disallow_any_generics = True +disallow_subclassing_any = True +disallow_untyped_calls = True disallow_untyped_defs = True -strict_optional = True +disallow_incomplete_defs = True +check_untyped_defs = True +disallow_untyped_decorators = True +no_implicit_optional = True warn_redundant_casts = True warn_unused_ignores = True +warn_return_any = True +no_implicit_reexport = True +strict_equality = True show_error_codes = True +[mypy-tests.*] +# https://github.com/python/mypy/issues/9689 +disallow_untyped_decorators = False -[pydantic-mypy] -init_forbid_extra = True -init_typed = True -warn_required_dynamic_aliases = True - +[mypy-aiofiles.*] +ignore_missing_imports = True -[mypy-noxfile] -# Nox decorators return untyped callables -disallow_untyped_decorators = false +[mypy-pytest.*] +ignore_missing_imports = True +[mypy-respx.*] +ignore_missing_imports = True -[mypy-tests.*] -# ignore mypy on tests package -ignore_errors = true +[isort] +profile = black +multi_line_output = 3 -[mypy-base64io.*] -ignore_missing_imports = True +[darglint] +# darglint configuration: +# https://github.com/terrencepreilly/darglint +strictness = short +docstring_style = sphinx [flake8] format = wemake -show-source = True -statistics = False +show-source = true -# Flake plugins: max-line-length = 88 inline-quotes = double -i-control-code = False -# currently 13 imports are used for Bot definition -max-imports = 13 -nested-classes-whitelist = Config -allowed-domain-names = - # handler is something similar to "views" from common framework, but for bot: - handler, - - # BotX API is built with similar to json-rpc approach and use "result" field for responses: - result, - - # file is field that is used in BotX API and it's an entity provided by library: - file, -pytest-raises-require-match-for = - -# Excluding some directories: -exclude = .git,__pycache__,.venv,.eggs,*.egg - -# Docs: https://github.com/snoack/flake8-per-file-ignores -# You can completely or partially disable our custom checks, -# to do so you have to ignore `WPS` letters for all python files: -per-file-ignores = - # WPS: - # "validated_values" is required name for pydantic validator in case it receives validated query_params: - botx/collecting/handlers/validators.py: WPS110, - - # re-exports from library using __all__: - botx/__init__.py: WPS201, WPS203, WPS235, WPS410 - - # allow inheritance from builtin, since there are enums for pydantic, module members, OverusedStringViolation: - botx/models/enums.py: WPS600, WPS202, WPS226 - - # TODO: simplify test utils - botx/testing/testing_client/base.py: WPS201 - botx/testing/botx_mock/asgi/routes/chats.py: WPS202, WPS204 - botx/testing/botx_mock/wsgi/routes/chats.py: WPS202, WPS204 - botx/testing/botx_mock/asgi/routes/stickers.py: WPS202, WPS204, WPS226 - botx/testing/botx_mock/wsgi/routes/stickers.py: WPS202, WPS204, WPS226 - - # magic method(__root__) - botx/testing/building/attachments.py: WPS609 - botx/testing/building/entites.py: WPS609 - - # E800 for disable formatter - botx/models/attachments.py: WPS110, WPS125, WPS202, E800 - - # allow noqa overuse and many imports (we should reconsider WPS ignores) - botx/models/messages/sending/message.py: WPS402, WPS201 +nested_classes_whitelist = Config +allowed_domain_names = data, handler, result, content, file - # cls to pydantic validators - botx/models/*.py: N805 +per-file-ignores = + botx/bot/bot.py:WPS203, + botx/constants.py:WPS432, + botx/__init__.py:WPS203,WPS410,WPS412,F401, + # https://github.com/wemake-services/wemake-python-styleguide/issues/2172 + botx/bot/handler_collector.py:WPS437, + botx/client/notifications_api/internal_bot_notification.py:WPS202, + # Complex model converting + botx/models/message/incoming_message.py:WPS232, + # WPS reacts at using `}` in f-strings + botx/models/message/mentions.py:WPS226, + # Protected attr usage is OK with async_files + botx/models/async_files.py:WPS437, + botx/models/api_base.py:WPS232,WPS231,WPS110,WPS440 + # This ignores make logger code cleaner + botx/logger.py:WPS219,WPS226 + + tests/*:DAR101,E501,WPS110,WPS114,WPS116,WPS118,WPS202,WPS221,WPS226,WPS237,WPS402,WPS420,WPS428,WPS430,WPS432,WPS441,WPS442,WPS520,PT011,S105,S106 - # disable most linting issues for tests: - # TODO: configure linting for tests more strictly - tests/*.py: D, S101, S106, WPS, B015 +ignore = + # This project uses google style docstring + RST, + # Upper-case constant in class + WPS115, + # Too many module members + WPS202, + # Too many arguments + WPS211, + # f-strings + WPS305, + # Class without base class + WPS306, + # Implicit string concatenation + WPS326, + # Explicit string concatenation + WPS336, + # Module docstring + D100, + # Class docstring + D101, + # Method docstring + D102, + # Function docstring + D103, + # Package docstring + D104, + # Magic method docstring + D105, + # Nested class docstring + D106, + # __init__ docstring + D107, + # Allow empty line after docstring + D202, + # Line break before binary operator + W503, + # Too many methods + WPS214, + # Too many imports + WPS201, + # Overused expression + WPS204, + # Too many local vars + WPS210, + # Too many imported names from module + WPS235, + # Multiple conditions + WPS337, + # Nested imports (often used with ImportError) + WPS433, + # Forbidden `@staticmethod` + WPS602, + # Forbidden `assert` + S101, - # module with content examplest - botx/testing/content.py: E501 - # imports into module with collector model is ok - botx/bots/bots.py: WPS201 +[tool:pytest] +testpaths = tests - # many chat methods - botx/bots/mixins/requests/chats.py: WPS201, WPS214 +addopts = + --strict-markers + --tb=short + --cov=botx + --cov-report=term-missing + --cov-branch + --no-cov-on-fail + --cov-fail-under=100 - botx/exceptions.py: WPS202 - botx/models/events.py: WPS202 +markers = + wip: "Work in progress" + mock_authorization: "Mock authorization" - # too many module members - # too many imported names from a module - # found overused expression - botx/bots/mixins/requests/mixin.py: WPS235 - botx/testing/botx_mock/asgi/application.py: WPS235 - botx/testing/botx_mock/asgi/routes/chats.py: WPS202,WPS204,WPS235 - botx/testing/botx_mock/wsgi/application.py: WPS235 - botx/testing/botx_mock/wsgi/routes/chats.py: WPS202,WPS204,WPS235 -# Disable some checks: -ignore = - # Docs: - # Disable nested classes documentation, since only Config for pydantic is allowed: - D106, - # This project uses google style and mkdocs for docs: - RST, - - # WPS: - # 3xx - # Disable required inheritance from object: - WPS306, - # Allow implicit string concatenation - WPS326, - - # 6xx - # A lot of functionality in this lib is build around async __call__: - WPS610, - - # TODO: - # WPS bugs: - WPS601, - - # Asserts in code is OK - S101, +[coverage:report] +exclude_lines = + pragma: no cover + if TYPE_CHECKING: + raise NotImplementedError + except ImportError: + ... # noqa: WPS428 + def __repr__ diff --git a/tests/test_bots/test_bots/test_decorators/__init__.py b/tests/client/__init__.py similarity index 100% rename from tests/test_bots/test_bots/test_decorators/__init__.py rename to tests/client/__init__.py diff --git a/tests/test_bots/test_mixins/__init__.py b/tests/client/bots_api/__init__.py similarity index 100% rename from tests/test_bots/test_mixins/__init__.py rename to tests/client/bots_api/__init__.py diff --git a/tests/client/bots_api/test_get_token.py b/tests/client/bots_api/test_get_token.py new file mode 100644 index 00000000..f371e84e --- /dev/null +++ b/tests/client/bots_api/test_get_token.py @@ -0,0 +1,78 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + InvalidBotAccountError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__get_token__invalid_bot_account_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_signature: str, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v2/botx/bots/{bot_id}/token", + params={"signature": bot_signature}, + ).mock( + return_value=httpx.Response(HTTPStatus.UNAUTHORIZED), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + with pytest.raises(InvalidBotAccountError) as exc: + async with lifespan_wrapper(built_bot) as bot: + await bot.get_token(bot_id=bot_id) + + # - Assert - + assert "failed with code 401" in str(exc.value) + assert endpoint.called + + +async def test__get_token__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_signature: str, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v2/botx/bots/{bot_id}/token", + params={"signature": bot_signature}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": "token", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + token = await bot.get_token(bot_id=bot_id) + + # - Assert - + assert token == "token" + assert endpoint.called diff --git a/tests/test_bots/test_mixins/test_requests/__init__.py b/tests/client/chats_api/__init__.py similarity index 100% rename from tests/test_bots/test_mixins/test_requests/__init__.py rename to tests/client/chats_api/__init__.py diff --git a/tests/client/chats_api/test_add_admin.py b/tests/client/chats_api/test_add_admin.py new file mode 100644 index 00000000..14fd58f9 --- /dev/null +++ b/tests/client/chats_api/test_add_admin.py @@ -0,0 +1,278 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + CantUpdatePersonalChatError, + ChatNotFoundError, + HandlerCollector, + InvalidBotXStatusCodeError, + InvalidUsersListError, + PermissionDeniedError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__promote_to_chat_admins__unexpected_bad_request_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/add_admin", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.BAD_REQUEST, + json={ + "status": "error", + "reason": "some_reason", + "errors": [], + "error_data": {}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(InvalidBotXStatusCodeError) as exc: + await bot.promote_to_chat_admins( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], + ) + + # - Assert - + assert "some_reason" in str(exc.value) + assert endpoint.called + + +async def test__promote_to_chat_admins__cant_update_personal_chat_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/add_admin", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.BAD_REQUEST, + json={ + "status": "error", + "reason": "chat_members_not_modifiable", + "errors": [], + "error_data": {}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(CantUpdatePersonalChatError) as exc: + await bot.promote_to_chat_admins( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], + ) + + # - Assert - + assert "chat_members_not_modifiable" in str(exc.value) + assert "Personal chat couldn't have admins" in str(exc.value) + assert endpoint.called + + +async def test__promote_to_chat_admins__invalid_users_list_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/add_admin", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.BAD_REQUEST, + json={ + "status": "error", + "reason": "admins_not_changed", + "errors": ["Admins have not changed"], + "error_data": { + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(InvalidUsersListError) as exc: + await bot.promote_to_chat_admins( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], + ) + + # - Assert - + assert "admins_not_changed" in str(exc.value) + assert "Specified users are already admins or missing from chat" in str(exc.value) + assert endpoint.called + + +async def test__promote_to_chat_admins__permission_denied_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/add_admin", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.FORBIDDEN, + json={ + "status": "error", + "reason": "no_permission_for_operation", + "errors": ["Sender is not chat admin"], + "error_data": { + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + "sender": "a465f0f3-1354-491c-8f11-f400164295cb", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(PermissionDeniedError) as exc: + await bot.promote_to_chat_admins( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], + ) + + # - Assert - + assert "no_permission_for_operation" in str(exc.value) + assert endpoint.called + + +async def test__promote_to_chat_admins__chat_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/add_admin", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "chat_not_found", + "errors": ["Chat not found"], + "error_data": { + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ChatNotFoundError) as exc: + await bot.promote_to_chat_admins( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], + ) + + # - Assert - + assert "chat_not_found" in str(exc.value) + assert endpoint.called + + +async def test__promote_to_chat_admins__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/add_admin", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={"status": "ok", "result": True}, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.promote_to_chat_admins( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], + ) + + # - Assert - + assert endpoint.called diff --git a/tests/client/chats_api/test_add_user.py b/tests/client/chats_api/test_add_user.py new file mode 100644 index 00000000..eaaed589 --- /dev/null +++ b/tests/client/chats_api/test_add_user.py @@ -0,0 +1,145 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + ChatNotFoundError, + HandlerCollector, + PermissionDeniedError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__add_users_to_chat__chat_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/add_user", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "chat_not_found", + "errors": ["Chat not found"], + "error_data": { + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ChatNotFoundError) as exc: + await bot.add_users_to_chat( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], + ) + + # - Assert - + assert "chat_not_found" in str(exc.value) + assert endpoint.called + + +async def test__add_users_to_chat__permission_denied_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/add_user", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.FORBIDDEN, + json={ + "status": "error", + "reason": "no_permission_for_operation", + "errors": ["Sender is not chat admin"], + "error_data": { + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + "sender": "a465f0f3-1354-491c-8f11-f400164295cb", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(PermissionDeniedError) as exc: + await bot.add_users_to_chat( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], + ) + + # - Assert - + assert "no_permission_for_operation" in str(exc.value) + assert endpoint.called + + +async def test__add_users_to_chat__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/add_user", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={"status": "ok", "result": True}, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.add_users_to_chat( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], + ) + + # - Assert - + assert endpoint.called diff --git a/tests/client/chats_api/test_chat_info.py b/tests/client/chats_api/test_chat_info.py new file mode 100644 index 00000000..be9979f1 --- /dev/null +++ b/tests/client/chats_api/test_chat_info.py @@ -0,0 +1,142 @@ +from datetime import datetime as dt +from http import HTTPStatus +from typing import Callable +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + ChatInfo, + ChatInfoMember, + ChatNotFoundError, + ChatTypes, + HandlerCollector, + UserKinds, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__chat_info__chat_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/chats/info", + headers={"Authorization": "Bearer token"}, + params={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "chat_not_found", + "errors": [], + "error_data": { + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + "error_description": "Chat with id dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4 not found", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ChatNotFoundError) as exc: + await bot.chat_info( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert "chat_not_found" in str(exc.value) + assert endpoint.called + + +async def test__chat_info__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + datetime_formatter: Callable[[str], dt], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/chats/info", + headers={"Authorization": "Bearer token"}, + params={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "chat_type": "group_chat", + "creator": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "description": None, + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "inserted_at": "2019-08-29T11:22:48.358586Z", + "members": [ + { + "admin": True, + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "user_kind": "user", + }, + { + "admin": False, + "user_huid": "705df263-6bfd-536a-9d51-13524afaab5c", + "user_kind": "botx", + }, + ], + "name": "Group Chat Example", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + chat_info = await bot.chat_info( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert chat_info == ChatInfo( + chat_type=ChatTypes.GROUP_CHAT, + creator_id=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + description=None, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), + members=[ + ChatInfoMember( + is_admin=True, + huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + kind=UserKinds.RTS_USER, + ), + ChatInfoMember( + is_admin=False, + huid=UUID("705df263-6bfd-536a-9d51-13524afaab5c"), + kind=UserKinds.BOT, + ), + ], + name="Group Chat Example", + ) + + assert endpoint.called diff --git a/tests/client/chats_api/test_create_chat.py b/tests/client/chats_api/test_create_chat.py new file mode 100644 index 00000000..63d242a1 --- /dev/null +++ b/tests/client/chats_api/test_create_chat.py @@ -0,0 +1,159 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + ChatCreationError, + ChatCreationProhibitedError, + ChatTypes, + HandlerCollector, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__create_chat__bot_have_no_permissions_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/create", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "name": "Test chat name", + "description": None, + "chat_type": "group_chat", + "members": [], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.FORBIDDEN, + json={ + "status": "error", + "reason": "chat_creation_is_prohibited", + "errors": ["This bot is not allowed to create chats"], + "error_data": { + "bot_id": "a465f0f3-1354-491c-8f11-f400164295cb", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ChatCreationProhibitedError) as exc: + await bot.create_chat( + bot_id=bot_id, + name="Test chat name", + chat_type=ChatTypes.GROUP_CHAT, + huids=[], + ) + + # - Assert - + assert endpoint.called + assert "chat_creation_is_prohibited" in str(exc.value) + + +async def test__create_chat__botx_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/create", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "name": "Test chat name", + "description": None, + "chat_type": "group_chat", + "members": [], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.UNPROCESSABLE_ENTITY, + json={ + "status": "error", + "reason": "|specified reason|", + "errors": ["|specified errors|"], + "error_data": {}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ChatCreationError) as exc: + await bot.create_chat( + bot_id=bot_id, + name="Test chat name", + chat_type=ChatTypes.GROUP_CHAT, + huids=[], + ) + + # - Assert - + assert endpoint.called + assert "specified reason" in str(exc.value) + + +async def test__create_chat__maximum_filled_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/create", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "name": "Test chat name", + "description": "Test description", + "chat_type": "group_chat", + "members": ["2fc83441-366a-49ba-81fc-6c39f065bb58"], + "shared_history": True, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": {"chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + created_chat_id = await bot.create_chat( + bot_id=bot_id, + name="Test chat name", + chat_type=ChatTypes.GROUP_CHAT, + huids=[UUID("2fc83441-366a-49ba-81fc-6c39f065bb58")], + description="Test description", + shared_history=True, + ) + + # - Assert - + assert created_chat_id == UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa") + assert endpoint.called diff --git a/tests/client/chats_api/test_disable_stealth.py b/tests/client/chats_api/test_disable_stealth.py new file mode 100644 index 00000000..d8993c9a --- /dev/null +++ b/tests/client/chats_api/test_disable_stealth.py @@ -0,0 +1,133 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + ChatNotFoundError, + HandlerCollector, + PermissionDeniedError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__disable_stealth__permission_denied_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/stealth_disable", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.FORBIDDEN, + json={ + "status": "error", + "reason": "no_permission_for_operation", + "errors": ["Sender is not chat admin"], + "error_data": { + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + "sender": "a465f0f3-1354-491c-8f11-f400164295cb", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(PermissionDeniedError) as exc: + await bot.disable_stealth( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert "no_permission_for_operation" in str(exc.value) + assert endpoint.called + + +async def test__disable_stealth__chat_not_found_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/stealth_disable", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "chat_not_found", + "errors": ["Chat not found"], + "error_data": { + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ChatNotFoundError) as exc: + await bot.disable_stealth( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert "chat_not_found" in str(exc.value) + assert endpoint.called + + +async def test__disable_stealth__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/stealth_disable", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={"group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={"status": "ok", "result": True}, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.disable_stealth( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert endpoint.called diff --git a/tests/client/chats_api/test_list_chats.py b/tests/client/chats_api/test_list_chats.py new file mode 100644 index 00000000..ad53b76c --- /dev/null +++ b/tests/client/chats_api/test_list_chats.py @@ -0,0 +1,81 @@ +from datetime import datetime +from http import HTTPStatus +from typing import Callable +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + ChatListItem, + ChatTypes, + HandlerCollector, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__list_chats__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + datetime_formatter: Callable[[str], datetime], +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/chats/list", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": [ + { + "group_chat_id": "740cf331-d833-5250-b5a5-5b5cbc697ff5", + "chat_type": "group_chat", + "name": "Chat Name", + "description": "Desc", + "members": [ + "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "705df263-6bfd-536a-9d51-13524afaab5c", + ], + "inserted_at": "2019-08-29T11:22:48.358586Z", + "updated_at": "2019-08-30T21:02:10.453786Z", + }, + ], + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + chats = await bot.list_chats(bot_id=bot_id) + + # - Assert - + assert chats == [ + ChatListItem( + chat_id=UUID("740cf331-d833-5250-b5a5-5b5cbc697ff5"), + chat_type=ChatTypes.GROUP_CHAT, + name="Chat Name", + description="Desc", + members=[ + UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + UUID("705df263-6bfd-536a-9d51-13524afaab5c"), + ], + created_at=datetime_formatter("2019-08-29T11:22:48.358586Z"), + updated_at=datetime_formatter("2019-08-30T21:02:10.453786Z"), + ), + ] + assert endpoint.called diff --git a/tests/client/chats_api/test_pin_message.py b/tests/client/chats_api/test_pin_message.py new file mode 100644 index 00000000..46cd40cc --- /dev/null +++ b/tests/client/chats_api/test_pin_message.py @@ -0,0 +1,147 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + ChatNotFoundError, + HandlerCollector, + PermissionDeniedError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__pin_message__permission_denied_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/pin_message", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.FORBIDDEN, + json={ + "error_data": { + "bot_id": "f9e1c958-bf81-564e-bff2-a2943869af15", + "error_description": "Bot doesn't have permission for this operation in current chat", + "group_chat_id": "5680c26a-07a5-5b40-a6ff-f5e7e68fed25", + }, + "errors": [], + "reason": "no_permission_for_operation", + "status": "error", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(PermissionDeniedError) as exc: + await bot.pin_message( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + sync_id=UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"), + ) + + # - Assert - + assert "no_permission_for_operation" in str(exc.value) + assert endpoint.called + + +async def test__pin_message__chat_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/pin_message", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "chat_not_found", + "errors": [], + "error_data": { + "error_description": "Chat with specified id not found", + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ChatNotFoundError) as exc: + await bot.pin_message( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + sync_id=UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"), + ) + + # - Assert - + assert "chat_not_found" in str(exc.value) + assert endpoint.called + + +async def test__pin_message__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/pin_message", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={"status": "ok", "result": "message_pinned"}, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.pin_message( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + sync_id=UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"), + ) + + # - Assert - + assert endpoint.called diff --git a/tests/client/chats_api/test_remove_user.py b/tests/client/chats_api/test_remove_user.py new file mode 100644 index 00000000..e39906cc --- /dev/null +++ b/tests/client/chats_api/test_remove_user.py @@ -0,0 +1,145 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + ChatNotFoundError, + HandlerCollector, + PermissionDeniedError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__remove_users_from_chat__permission_denied_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/remove_user", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.FORBIDDEN, + json={ + "status": "error", + "reason": "no_permission_for_operation", + "errors": ["Sender is not chat admin"], + "error_data": { + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + "sender": "a465f0f3-1354-491c-8f11-f400164295cb", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(PermissionDeniedError) as exc: + await bot.remove_users_from_chat( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], + ) + + # - Assert - + assert "no_permission_for_operation" in str(exc.value) + assert endpoint.called + + +async def test__remove_users_from_chat__chat_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/remove_user", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "chat_not_found", + "errors": ["Chat not found"], + "error_data": { + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ChatNotFoundError) as exc: + await bot.remove_users_from_chat( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], + ) + + # - Assert - + assert "chat_not_found" in str(exc.value) + assert endpoint.called + + +async def test__remove_users_from_chat__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/remove_user", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "user_huids": ["f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={"status": "ok", "result": True}, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.remove_users_from_chat( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + huids=[UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1")], + ) + + # - Assert - + assert endpoint.called diff --git a/tests/client/chats_api/test_set_stealth.py b/tests/client/chats_api/test_set_stealth.py new file mode 100644 index 00000000..22addd5f --- /dev/null +++ b/tests/client/chats_api/test_set_stealth.py @@ -0,0 +1,148 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + ChatNotFoundError, + HandlerCollector, + PermissionDeniedError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__enable_stealth__permission_denied_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/stealth_set", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.FORBIDDEN, + json={ + "status": "error", + "reason": "no_permission_for_operation", + "errors": ["Sender is not chat admin"], + "error_data": { + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + "sender": "a465f0f3-1354-491c-8f11-f400164295cb", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(PermissionDeniedError) as exc: + await bot.enable_stealth( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert "no_permission_for_operation" in str(exc.value) + assert endpoint.called + + +async def test__enable_stealth__chat_not_found_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/stealth_set", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "chat_not_found", + "errors": ["Chat not found"], + "error_data": { + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ChatNotFoundError) as exc: + await bot.enable_stealth( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert "chat_not_found" in str(exc.value) + assert endpoint.called + + +async def test__enable_stealth__maximum_filled_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/stealth_set", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "disable_web": True, + "burn_in": 100, + "expire_in": 1000, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": True, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.enable_stealth( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + disable_web_client=True, + ttl_after_read=100, + total_ttl=1000, + ) + + # - Assert - + assert endpoint.called diff --git a/tests/client/chats_api/test_unpin_message.py b/tests/client/chats_api/test_unpin_message.py new file mode 100644 index 00000000..30429f90 --- /dev/null +++ b/tests/client/chats_api/test_unpin_message.py @@ -0,0 +1,141 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + ChatNotFoundError, + HandlerCollector, + PermissionDeniedError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__unpin_message__permission_denied_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/unpin_message", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.FORBIDDEN, + json={ + "error_data": { + "bot_id": "f9e1c958-bf81-564e-bff2-a2943869af15", + "error_description": "Bot doesn't have permission for this operation in current chat", + "group_chat_id": "5680c26a-07a5-5b40-a6ff-f5e7e68fed25", + }, + "errors": [], + "reason": "no_permission_for_operation", + "status": "error", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(PermissionDeniedError) as exc: + await bot.unpin_message( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert "no_permission_for_operation" in str(exc.value) + assert endpoint.called + + +async def test__unpin_message__chat_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/unpin_message", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "chat_not_found", + "errors": [], + "error_data": { + "error_description": "Chat with specified id not found", + "group_chat_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ChatNotFoundError) as exc: + await bot.unpin_message( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert "chat_not_found" in str(exc.value) + assert endpoint.called + + +async def test__unpin_message__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/chats/unpin_message", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={"status": "ok", "result": "message_unpinned"}, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.unpin_message( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert endpoint.called diff --git a/tests/test_bots/test_mixins/test_sending/__init__.py b/tests/client/events_api/__init__.py similarity index 100% rename from tests/test_bots/test_mixins/test_sending/__init__.py rename to tests/client/events_api/__init__.py diff --git a/tests/client/events_api/test_edit_event.py b/tests/client/events_api/test_edit_event.py new file mode 100644 index 00000000..7f0e55a9 --- /dev/null +++ b/tests/client/events_api/test_edit_event.py @@ -0,0 +1,304 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from aiofiles.tempfile import NamedTemporaryFile +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + BubbleMarkup, + EditMessage, + HandlerCollector, + KeyboardMarkup, + Mention, + OutgoingAttachment, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__edit_message__minimal_edit_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/events/edit_event", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9", + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": "bot_command_result_pushed", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.edit_message( + bot_id=bot_id, + sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"), + ) + + # - Assert - + assert endpoint.called + + +async def test__edit_message__maximum_edit_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # - Arrange - + monkeypatch.setattr( + "botx.models.message.mentions.uuid4", + lambda: UUID("f3e176d5-ff46-4b18-b260-25008338c06e"), + ) + + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/events/edit_event", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9", + "payload": { + "body": "@{mention:f3e176d5-ff46-4b18-b260-25008338c06e}!", + "metadata": {"message": "metadata"}, + "bubble": [ + [ + { + "command": "/bubble-button", + "data": {}, + "label": "Bubble button", + "opts": {"silent": True}, + }, + ], + ], + "keyboard": [ + [ + { + "command": "/keyboard-button", + "data": {}, + "label": "Keyboard button", + "opts": {"silent": True}, + }, + ], + ], + "mentions": [ + { + "mention_type": "user", + "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e", + "mention_data": { + "user_huid": "8f3abcc8-ba00-4c89-88e0-b786beb8ec24", + }, + }, + ], + }, + "file": { + "file_name": "test.txt", + "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=", + }, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": "bot_command_result_pushed", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + bubbles = BubbleMarkup() + bubbles.add_button(command="/bubble-button", label="Bubble button") + + keyboard = KeyboardMarkup() + keyboard.add_button(command="/keyboard-button", label="Keyboard button") + + async with NamedTemporaryFile("wb+") as async_buffer: + await async_buffer.write(b"Hello, world!\n") + await async_buffer.seek(0) + + file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt") + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.edit_message( + bot_id=bot_id, + sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"), + body=f"{Mention.user(UUID('8f3abcc8-ba00-4c89-88e0-b786beb8ec24'))}!", + metadata={"message": "metadata"}, + bubbles=bubbles, + keyboard=keyboard, + file=file, + ) + + # - Assert - + assert endpoint.called + + +async def test__edit_message__clean_message_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/events/edit_event", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "payload": { + "body": "", + "metadata": {}, + "bubble": [], + "keyboard": [], + "mentions": [], + }, + "sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9", + "file": None, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": "bot_command_result_pushed", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.edit_message( + bot_id=bot_id, + sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"), + body="", + metadata={}, + bubbles=BubbleMarkup(), + keyboard=KeyboardMarkup(), + file=None, + ) + + # - Assert - + assert endpoint.called + + +async def test__edit__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # - Arrange - + monkeypatch.setattr( + "botx.models.message.mentions.uuid4", + lambda: UUID("f3e176d5-ff46-4b18-b260-25008338c06e"), + ) + + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/events/edit_event", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9", + "payload": { + "body": "@{mention:f3e176d5-ff46-4b18-b260-25008338c06e}!", + "metadata": {"message": "metadata"}, + "bubble": [ + [ + { + "command": "/bubble-button", + "data": {}, + "label": "Bubble button", + "opts": {"silent": True}, + }, + ], + ], + "keyboard": [ + [ + { + "command": "/keyboard-button", + "data": {}, + "label": "Keyboard button", + "opts": {"silent": True}, + }, + ], + ], + "mentions": [ + { + "mention_type": "user", + "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e", + "mention_data": { + "user_huid": "8f3abcc8-ba00-4c89-88e0-b786beb8ec24", + }, + }, + ], + }, + "file": { + "file_name": "test.txt", + "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=", + }, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": "bot_command_result_pushed", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + bubbles = BubbleMarkup() + bubbles.add_button(command="/bubble-button", label="Bubble button") + + keyboard = KeyboardMarkup() + keyboard.add_button(command="/keyboard-button", label="Keyboard button") + + async with NamedTemporaryFile("wb+") as async_buffer: + await async_buffer.write(b"Hello, world!\n") + await async_buffer.seek(0) + + file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt") + + message = EditMessage( + bot_id=bot_id, + sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"), + body=f"{Mention.user(UUID('8f3abcc8-ba00-4c89-88e0-b786beb8ec24'))}!", + metadata={"message": "metadata"}, + bubbles=bubbles, + keyboard=keyboard, + file=file, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.edit(message=message) + + # - Assert - + assert endpoint.called diff --git a/tests/client/events_api/test_message_status_event.py b/tests/client/events_api/test_message_status_event.py new file mode 100644 index 00000000..7bd5f2d5 --- /dev/null +++ b/tests/client/events_api/test_message_status_event.py @@ -0,0 +1,123 @@ +from datetime import datetime +from http import HTTPStatus +from typing import Callable +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + EventNotFoundError, + HandlerCollector, + MessageStatus, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__get_message_status__event_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/events/fe1f285c-073e-4231-b190-2959f28168cc/status", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "event_not_found", + "errors": [], + "error_data": {"sync_id": "fe1f285c-073e-4231-b190-2959f28168cc"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(EventNotFoundError) as exc: + await bot.get_message_status( + bot_id=bot_id, + sync_id=UUID("fe1f285c-073e-4231-b190-2959f28168cc"), + ) + + # - Assert - + assert "event_not_found" in str(exc.value) + assert endpoint.called + + +async def test__get_message_status__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + datetime_formatter: Callable[[str], datetime], +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/events/fe1f285c-073e-4231-b190-2959f28168cc/status", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": { + "group_chat_id": "740cf331-d833-5250-b5a5-5b5cbc697ff5", + "sent_to": ["32bb051e-cee9-5c5c-9c35-f213ec18d11e"], + "read_by": [ + { + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "read_at": "2019-08-29T11:22:48.358586Z", + }, + ], + "received_by": [ + { + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "received_at": "2019-08-29T11:22:48.358586Z", + }, + ], + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + message_status = await bot.get_message_status( + bot_id=bot_id, + sync_id=UUID("fe1f285c-073e-4231-b190-2959f28168cc"), + ) + + # - Assert - + assert message_status == MessageStatus( + group_chat_id=UUID("740cf331-d833-5250-b5a5-5b5cbc697ff5"), + sent_to=[UUID("32bb051e-cee9-5c5c-9c35-f213ec18d11e")], + read_by={ + UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"): datetime_formatter( + "2019-08-29T11:22:48.358586Z", + ), + }, + received_by={ + UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"): datetime_formatter( + "2019-08-29T11:22:48.358586Z", + ), + }, + ) + assert endpoint.called diff --git a/tests/client/events_api/test_reply_event.py b/tests/client/events_api/test_reply_event.py new file mode 100644 index 00000000..5d1cb8af --- /dev/null +++ b/tests/client/events_api/test_reply_event.py @@ -0,0 +1,289 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from aiofiles.tempfile import NamedTemporaryFile +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + BubbleMarkup, + HandlerCollector, + KeyboardMarkup, + Mention, + OutgoingAttachment, + ReplyMessage, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__reply_message__minimal_filled_reply_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/events/reply_event", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "source_sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9", + "reply": { + "status": "ok", + "body": "Replied", + }, + "opts": {"raw_mentions": True}, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": "bot_reply_pushed", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.reply_message( + bot_id=bot_id, + sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"), + body="Replied", + ) + + # - Assert - + assert endpoint.called + + +async def test__reply_message__maximum_filled_reply_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # - Arrange - + monkeypatch.setattr( + "botx.models.message.mentions.uuid4", + lambda: UUID("f3e176d5-ff46-4b18-b260-25008338c06e"), + ) + + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/events/reply_event", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "source_sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9", + "reply": { + "status": "ok", + "body": "@{mention:f3e176d5-ff46-4b18-b260-25008338c06e}!", + "metadata": {"message": "metadata"}, + "opts": { + "buttons_auto_adjust": True, + "silent_response": True, + }, + "bubble": [ + [ + { + "command": "/bubble-button", + "label": "Bubble button", + "data": {}, + "opts": {"silent": True}, + }, + ], + ], + "keyboard": [ + [ + { + "command": "/keyboard-button", + "label": "Keyboard button", + "data": {}, + "opts": {"silent": True}, + }, + ], + ], + "mentions": [ + { + "mention_type": "user", + "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e", + "mention_data": { + "user_huid": "8f3abcc8-ba00-4c89-88e0-b786beb8ec24", + }, + }, + ], + }, + "file": { + "file_name": "test.txt", + "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=", + }, + "opts": { + "raw_mentions": True, + "stealth_mode": True, + "notification_opts": {"send": True, "force_dnd": True}, + }, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": "bot_reply_pushed", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + bubbles = BubbleMarkup() + bubbles.add_button(command="/bubble-button", label="Bubble button") + + keyboard = KeyboardMarkup() + keyboard.add_button(command="/keyboard-button", label="Keyboard button") + + async with NamedTemporaryFile("wb+") as async_buffer: + await async_buffer.write(b"Hello, world!\n") + await async_buffer.seek(0) + + file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt") + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.reply_message( + bot_id=bot_id, + sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"), + body=f"{Mention.user(UUID('8f3abcc8-ba00-4c89-88e0-b786beb8ec24'))}!", + metadata={"message": "metadata"}, + bubbles=bubbles, + keyboard=keyboard, + file=file, + silent_response=True, + markup_auto_adjust=True, + stealth_mode=True, + send_push=True, + ignore_mute=True, + ) + + # - Assert - + assert endpoint.called + + +async def test__reply__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # - Arrange - + monkeypatch.setattr( + "botx.models.message.mentions.uuid4", + lambda: UUID("f3e176d5-ff46-4b18-b260-25008338c06e"), + ) + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/events/reply_event", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "source_sync_id": "8ba66c5b-40bf-5c77-911d-519cb4e382e9", + "reply": { + "status": "ok", + "body": "@{mention:f3e176d5-ff46-4b18-b260-25008338c06e}!", + "metadata": {"message": "metadata"}, + "opts": { + "buttons_auto_adjust": True, + "silent_response": True, + }, + "bubble": [ + [ + { + "command": "/bubble-button", + "label": "Bubble button", + "data": {}, + "opts": {"silent": True}, + }, + ], + ], + "keyboard": [ + [ + { + "command": "/keyboard-button", + "label": "Keyboard button", + "data": {}, + "opts": {"silent": True}, + }, + ], + ], + "mentions": [ + { + "mention_type": "user", + "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e", + "mention_data": { + "user_huid": "8f3abcc8-ba00-4c89-88e0-b786beb8ec24", + }, + }, + ], + }, + "file": { + "file_name": "test.txt", + "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=", + }, + "opts": { + "raw_mentions": True, + "stealth_mode": True, + "notification_opts": {"send": True, "force_dnd": True}, + }, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": "bot_reply_pushed", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + bubbles = BubbleMarkup() + bubbles.add_button(command="/bubble-button", label="Bubble button") + + keyboard = KeyboardMarkup() + keyboard.add_button(command="/keyboard-button", label="Keyboard button") + + async with NamedTemporaryFile("wb+") as async_buffer: + await async_buffer.write(b"Hello, world!\n") + await async_buffer.seek(0) + + file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt") + + message = ReplyMessage( + bot_id=bot_id, + sync_id=UUID("8ba66c5b-40bf-5c77-911d-519cb4e382e9"), + body=f"{Mention.user(UUID('8f3abcc8-ba00-4c89-88e0-b786beb8ec24'))}!", + metadata={"message": "metadata"}, + bubbles=bubbles, + keyboard=keyboard, + file=file, + silent_response=True, + markup_auto_adjust=True, + stealth_mode=True, + send_push=True, + ignore_mute=True, + ) + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.reply(message=message) + + # - Assert - + assert endpoint.called diff --git a/tests/client/events_api/test_stop_typing_event.py b/tests/client/events_api/test_stop_typing_event.py new file mode 100644 index 00000000..6568eec4 --- /dev/null +++ b/tests/client/events_api/test_stop_typing_event.py @@ -0,0 +1,47 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import Bot, BotAccountWithSecret, HandlerCollector, lifespan_wrapper + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__stop_typing__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/events/stop_typing", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={"status": "ok", "result": "stop_typing_event_pushed"}, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.stop_typing( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert endpoint.called diff --git a/tests/client/events_api/test_typing_event.py b/tests/client/events_api/test_typing_event.py new file mode 100644 index 00000000..31b6a00e --- /dev/null +++ b/tests/client/events_api/test_typing_event.py @@ -0,0 +1,47 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import Bot, BotAccountWithSecret, HandlerCollector, lifespan_wrapper + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__start_typing__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/events/typing", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={"status": "ok", "result": "typing_event_pushed"}, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.start_typing( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert endpoint.called diff --git a/tests/test_clients/__init__.py b/tests/client/files_api/__init__.py similarity index 100% rename from tests/test_clients/__init__.py rename to tests/client/files_api/__init__.py diff --git a/tests/client/files_api/test_download_file.py b/tests/client/files_api/test_download_file.py new file mode 100644 index 00000000..9a68aa65 --- /dev/null +++ b/tests/client/files_api/test_download_file.py @@ -0,0 +1,251 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from aiofiles.tempfile import NamedTemporaryFile +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + ChatNotFoundError, + FileDeletedError, + FileMetadataNotFound, + HandlerCollector, + InvalidBotXStatusCodeError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__download_file__unexpected_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/files/download", + params={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80", + }, + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "anything_not_found", + "errors": [], + "error_data": { + "group_chat_id": "84a12e71-3efc-5c34-87d5-84e3d9ad64fd", + "error_description": "42", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(InvalidBotXStatusCodeError) as exc: + await bot.download_file( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + file_id=UUID("c3b9def2-b2c8-4732-b61f-99b9b110fa80"), + async_buffer=async_buffer, + ) + + # - Assert - + assert "anything_not_found" in str(exc.value) + assert endpoint.called + + +async def test__download_file__file_metadata_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/files/download", + params={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80", + }, + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "file_metadata_not_found", + "errors": [], + "error_data": { + "file_id": "e48c5612-b94f-4264-adc2-1bc36445a226", + "group_chat_id": "84a12e71-3efc-5c34-87d5-84e3d9ad64fd", + "error_description": "File with specified file_id and group_chat_id not found in file service", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(FileMetadataNotFound) as exc: + await bot.download_file( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + file_id=UUID("c3b9def2-b2c8-4732-b61f-99b9b110fa80"), + async_buffer=async_buffer, + ) + + # - Assert - + assert "file_metadata_not_found" in str(exc.value) + assert endpoint.called + + +async def test__download_file__file_deleted_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/files/download", + params={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80", + }, + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NO_CONTENT, + json={ + "status": "error", + "reason": "file_deleted", + "errors": [], + "error_data": { + "link": "/example/file.jpeg", + "error_description": "File at the specified link has been deleted", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(FileDeletedError) as exc: + await bot.download_file( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + file_id=UUID("c3b9def2-b2c8-4732-b61f-99b9b110fa80"), + async_buffer=async_buffer, + ) + + # - Assert - + assert "file_deleted" in str(exc.value) + assert endpoint.called + + +async def test__download_file__chat_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/files/download", + params={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80", + }, + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "chat_not_found", + "errors": [], + "error_data": { + "group_chat_id": "84a12e71-3efc-5c34-87d5-84e3d9ad64fd", + "error_description": "Chat with id 84a12e71-3efc-5c34-87d5-84e3d9ad64fd not found", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ChatNotFoundError) as exc: + await bot.download_file( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + file_id=UUID("c3b9def2-b2c8-4732-b61f-99b9b110fa80"), + async_buffer=async_buffer, + ) + + # - Assert - + assert "chat_not_found" in str(exc.value) + assert endpoint.called + + +async def test__download_file__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/files/download", + params={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80", + }, + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + content=b"Hello, world!\n", + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.download_file( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + file_id=UUID("c3b9def2-b2c8-4732-b61f-99b9b110fa80"), + async_buffer=async_buffer, + ) + + # - Assert - + assert await async_buffer.read() == b"Hello, world!\n" + assert endpoint.called diff --git a/tests/client/files_api/test_upload_file.py b/tests/client/files_api/test_upload_file.py new file mode 100644 index 00000000..17df5dae --- /dev/null +++ b/tests/client/files_api/test_upload_file.py @@ -0,0 +1,125 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from aiofiles.tempfile import NamedTemporaryFile +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + ChatNotFoundError, + HandlerCollector, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__download_file__chat_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + await async_buffer.write(b"Hello, world!\n") + await async_buffer.seek(0) + + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/files/upload", + # TODO: check data too, when files pattern will be ready + # https://github.com/lundberg/respx/issues/115 + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "chat_not_found", + "errors": [], + "error_data": { + "group_chat_id": "84a12e71-3efc-5c34-87d5-84e3d9ad64fd", + "error_description": "Chat with id 84a12e71-3efc-5c34-87d5-84e3d9ad64fd not found", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ChatNotFoundError) as exc: + await bot.upload_file( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + async_buffer=async_buffer, + filename="test.txt", + ) + + # - Assert - + assert "chat_not_found" in str(exc.value) + assert endpoint.called + + +async def test__download_file__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + await async_buffer.write(b"Hello, world!\n") + await async_buffer.seek(0) + + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/files/upload", + # TODO: check data too, when files pattern will be ready + # https://github.com/lundberg/respx/issues/115 + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "type": "image", + "file": "https://link.to/file", + "file_mime_type": "image/png", + "file_name": "pass.png", + "file_preview": "https://link.to/preview", + "file_preview_height": 300, + "file_preview_width": 300, + "file_size": 1502345, + "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + "file_encryption_algo": "stream", + "chunk_size": 2097152, + "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + "caption": "текст", + "duration": None, + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.upload_file( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + async_buffer=async_buffer, + filename="test.txt", + ) + + # - Assert - + assert endpoint.called diff --git a/tests/test_clients/test_clients/__init__.py b/tests/client/notifications_api/__init__.py similarity index 100% rename from tests/test_clients/test_clients/__init__.py rename to tests/client/notifications_api/__init__.py diff --git a/tests/client/notifications_api/test_direct_notification.py b/tests/client/notifications_api/test_direct_notification.py new file mode 100644 index 00000000..4e90d9df --- /dev/null +++ b/tests/client/notifications_api/test_direct_notification.py @@ -0,0 +1,974 @@ +import asyncio +from http import HTTPStatus +from typing import Any, Callable, Dict +from uuid import UUID + +import httpx +import pytest +from aiofiles.tempfile import NamedTemporaryFile +from respx.router import MockRouter + +from botx import ( + AnswerDestinationLookupError, + Bot, + BotAccountWithSecret, + BotIsNotChatMemberError, + BubbleMarkup, + ChatNotFoundError, + FinalRecipientsListEmptyError, + HandlerCollector, + IncomingMessage, + KeyboardMarkup, + Mention, + OutgoingAttachment, + OutgoingMessage, + StealthModeDisabledError, + UnknownBotAccountError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__send_message__succeed( + respx_mock: MockRouter, + host: str, + bot_account: BotAccountWithSecret, + bot_id: UUID, + api_incoming_message_factory: Callable[..., Dict[str, Any]], +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "notification": { + "opts": { + "silent_response": True, + "buttons_auto_adjust": True, + }, + "status": "ok", + "body": "Hi!", + "metadata": {"foo": "bar"}, + "bubble": [ + [ + { + "command": "/bubble-button", + "data": {}, + "label": "Bubble button", + "opts": {"silent": True}, + }, + ], + ], + "keyboard": [ + [ + { + "command": "/keyboard-button", + "data": {}, + "label": "Keyboard button", + "opts": {"silent": True}, + }, + ], + ], + }, + "file": { + "file_name": "test.txt", + "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=", + }, + "recipients": ["0a462a79-d9a2-4fad-8a96-7074f59daba9"], + "opts": { + "stealth_mode": True, + "notification_opts": { + "send": True, + "force_dnd": True, + }, + }, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + payload = api_incoming_message_factory( + bot_id=bot_id, + host=host, + group_chat_id="054af49e-5e18-4dca-ad73-4f96b6de63fa", + ) + + bubbles = BubbleMarkup() + bubbles.add_button( + command="/bubble-button", + label="Bubble button", + ) + + keyboard = KeyboardMarkup() + keyboard.add_button( + command="/keyboard-button", + label="Keyboard button", + ) + + async with NamedTemporaryFile("wb+") as async_buffer: + await async_buffer.write(b"Hello, world!\n") + await async_buffer.seek(0) + + file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt") + + collector = HandlerCollector() + + outgoing_message = OutgoingMessage( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + body="Hi!", + metadata={"foo": "bar"}, + bubbles=bubbles, + keyboard=keyboard, + file=file, + silent_response=True, + markup_auto_adjust=True, + recipients=[UUID("0a462a79-d9a2-4fad-8a96-7074f59daba9")], + stealth_mode=True, + send_push=True, + ignore_mute=True, + ) + + @collector.command("/hello", description="Hello command") + async def hello_handler(message: IncomingMessage, bot: Bot) -> None: + await bot.send(message=outgoing_message) + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + }, + ) + + # - Assert - + assert endpoint.called + + +async def test__answer_message__no_incoming_message_error_raised( + host: str, + bot_account: BotAccountWithSecret, + bot_id: UUID, +) -> None: + # - Arrange - + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(AnswerDestinationLookupError) as exc: + await bot.answer_message("Hi!") + + # - Assert - + assert "No IncomingMessage received" in str(exc.value) + + +async def test__answer_message__succeed( + respx_mock: MockRouter, + host: str, + bot_account: BotAccountWithSecret, + bot_id: UUID, + api_incoming_message_factory: Callable[..., Dict[str, Any]], +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "notification": { + "status": "ok", + "body": "Hi!", + "metadata": {"foo": "bar"}, + "bubble": [ + [ + { + "command": "/bubble-button", + "data": {}, + "label": "Bubble button", + "opts": {"silent": True}, + }, + ], + ], + "keyboard": [ + [ + { + "command": "/keyboard-button", + "data": {}, + "label": "Keyboard button", + "opts": {"silent": True}, + }, + ], + ], + }, + "file": { + "file_name": "test.txt", + "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=", + }, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + payload = api_incoming_message_factory( + bot_id=bot_id, + host=host, + group_chat_id="054af49e-5e18-4dca-ad73-4f96b6de63fa", + ) + + bubbles = BubbleMarkup() + bubbles.add_button( + command="/bubble-button", + label="Bubble button", + ) + + keyboard = KeyboardMarkup() + keyboard.add_button( + command="/keyboard-button", + label="Keyboard button", + ) + + async with NamedTemporaryFile("wb+") as async_buffer: + await async_buffer.write(b"Hello, world!\n") + await async_buffer.seek(0) + + file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt") + + collector = HandlerCollector() + + @collector.command("/hello", description="Hello command") + async def hello_handler(message: IncomingMessage, bot: Bot) -> None: + await bot.answer_message( + "Hi!", + metadata={"foo": "bar"}, + bubbles=bubbles, + keyboard=keyboard, + file=file, + ) + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + }, + ) + + # - Assert - + assert endpoint.called + + +async def test__send_message__unknown_bot_account_error_raised( + respx_mock: MockRouter, + host: str, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + unknown_bot_id = UUID("51550ccc-dfd1-4d22-9b6f-a330145192b0") + direct_notification_endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnknownBotAccountError) as exc: + await bot.send_message( + body="Hi!", + bot_id=unknown_bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert not direct_notification_endpoint.called + assert str(unknown_bot_id) in str(exc.value) + + +async def test__send_message__chat_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "notification": {"status": "ok", "body": "Hi!"}, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_message( + body="Hi!", + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ), + ) + + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "error", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "reason": "chat_not_found", + "errors": [], + "error_data": { + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "error_description": "Chat with specified id not found", + }, + }, + ) + + # - Assert - + with pytest.raises(ChatNotFoundError) as exc: + await task + + assert "chat_not_found" in str(exc.value) + assert endpoint.called + + +async def test__send_message__bot_is_not_a_chat_member_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "notification": {"status": "ok", "body": "Hi!"}, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_message( + body="Hi!", + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ), + ) + + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "error", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "reason": "bot_is_not_a_chat_member", + "errors": [], + "error_data": { + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "bot_id": "b165f00f-3154-412c-7f11-c120164257da", + "error_description": "Bot is not a chat member", + }, + }, + ) + + # - Assert - + with pytest.raises(BotIsNotChatMemberError) as exc: + await task + + assert "bot_is_not_a_chat_member" in str(exc.value) + assert endpoint.called + + +async def test__send_message__event_recipients_list_is_empty_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "notification": {"status": "ok", "body": "Hi!"}, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_message( + body="Hi!", + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ), + ) + + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "error", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "reason": "event_recipients_list_is_empty", + "errors": [], + "error_data": { + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "bot_id": "b165f00f-3154-412c-7f11-c120164257da", + "recipients_param": ["b165f00f-3154-412c-7f11-c120164257da"], + "error_description": "Event recipients list is empty", + }, + }, + ) + + # - Assert - + with pytest.raises(FinalRecipientsListEmptyError) as exc: + await task + + assert "event_recipients_list_is_empty" in str(exc.value) + assert endpoint.called + + +async def test__send_message__stealth_mode_disabled_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "notification": {"status": "ok", "body": "Hi!"}, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_message( + body="Hi!", + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ), + ) + + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "error", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "reason": "stealth_mode_disabled", + "errors": [], + "error_data": { + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "bot_id": "b165f00f-3154-412c-7f11-c120164257da", + "error_description": "Stealth mode disabled in specified chat", + }, + }, + ) + + # - Assert - + with pytest.raises(StealthModeDisabledError) as exc: + await task + + assert "stealth_mode_disabled" in str(exc.value) + assert endpoint.called + + +async def test__send_message__miminally_filled_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "notification": {"status": "ok", "body": "Hi!"}, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_message( + body="Hi!", + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ), + ) + + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + }, + ) + + # - Assert - + assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.called + + +async def test__send_message__maximum_filled_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # - Arrange - + monkeypatch.setattr( + "botx.models.message.mentions.uuid4", + lambda: UUID("f3e176d5-ff46-4b18-b260-25008338c06e"), + ) + + body = f"Hi, {Mention.user(UUID('8f3abcc8-ba00-4c89-88e0-b786beb8ec24'))}!" + formatted_body = "Hi, @{mention:f3e176d5-ff46-4b18-b260-25008338c06e}!" + + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "notification": { + "opts": { + "silent_response": True, + "buttons_auto_adjust": True, + }, + "status": "ok", + "body": formatted_body, + "metadata": {"foo": "bar"}, + "bubble": [ + [ + { + "command": "/bubble-button", + "label": "Bubble button", + "data": {"foo": "bar"}, + "opts": { + "silent": False, + "h_size": 1, + "alert_text": "Alert text 1", + "show_alert": True, + "handler": "client", + }, + }, + ], + ], + "keyboard": [ + [ + { + "command": "/keyboard-button", + "label": "Keyboard button", + "data": {"baz": "quux"}, + "opts": { + "silent": True, + "h_size": 2, + "alert_text": "Alert text 2", + "show_alert": True, + }, + }, + ], + ], + "mentions": [ + { + "mention_type": "user", + "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e", + "mention_data": { + "user_huid": "8f3abcc8-ba00-4c89-88e0-b786beb8ec24", + }, + }, + ], + }, + "file": { + "file_name": "test.txt", + "data": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=", + }, + "recipients": ["41af5a7b-04c1-465e-8383-e3b1d9e76126"], + "opts": { + "stealth_mode": True, + "notification_opts": { + "send": True, + "force_dnd": True, + }, + }, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + async with NamedTemporaryFile("wb+") as async_buffer: + await async_buffer.write(b"Hello, world!\n") + await async_buffer.seek(0) + + file = await OutgoingAttachment.from_async_buffer(async_buffer, "test.txt") + + bubbles = BubbleMarkup() + bubbles.add_button( + command="/bubble-button", + label="Bubble button", + data={"foo": "bar"}, + silent=False, + width_ratio=1, + alert="Alert text 1", + process_on_client=True, + ) + + keyboard = KeyboardMarkup() + keyboard.add_button( + command="/keyboard-button", + label="Keyboard button", + data={"baz": "quux"}, + silent=True, + width_ratio=2, + alert="Alert text 2", + process_on_client=False, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_message( + body=body, + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + metadata={"foo": "bar"}, + bubbles=bubbles, + keyboard=keyboard, + file=file, + silent_response=True, + markup_auto_adjust=True, + recipients=[UUID("41af5a7b-04c1-465e-8383-e3b1d9e76126")], + stealth_mode=True, + send_push=True, + ignore_mute=True, + ), + ) + + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + }, + ) + + # - Assert - + assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.called + + +async def test__send_message__all_mentions_types_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # - Arrange - + monkeypatch.setattr( + "botx.models.message.mentions.uuid4", + lambda: UUID("f3e176d5-ff46-4b18-b260-25008338c06e"), + ) + + mentioned_user_huid = UUID("8f3abcc8-ba00-4c89-88e0-b786beb8ec24") + user_mention = Mention.user(mentioned_user_huid) + mentioned_contact_huid = UUID("1e0529fd-f091-4be9-93cc-6704a8957432") + contact_mention = Mention.contact(mentioned_contact_huid) + mentioned_chat_huid = UUID("454d73ad-1d32-4939-a708-e14b77414e86") + chat_mention = Mention.chat(mentioned_chat_huid, "Our chat") + mentioned_channel_huid = UUID("78198bec-3285-48d0-9fe2-c0eb3afaffd7") + channel_mention = Mention.channel(mentioned_channel_huid) + all_mention = Mention.all() + + body = ( + f"Hi, {user_mention}, want you to know, " + f"that I and {contact_mention} are getting married in a week. " + f"Here's a chat for all the invitees: {chat_mention}. " + f"And here is the news channel just in case: {channel_mention}. " + "In case of something incredible, " + f"I will notify you with {all_mention}, so you won't miss it." + ) + + formatted_body = ( + "Hi, @{mention:f3e176d5-ff46-4b18-b260-25008338c06e}, want you to know, " + "that I and @@{mention:f3e176d5-ff46-4b18-b260-25008338c06e} are getting married in a week. " + "Here's a chat for all the invitees: ##{mention:f3e176d5-ff46-4b18-b260-25008338c06e}. " + "And here is the news channel just in case: ##{mention:f3e176d5-ff46-4b18-b260-25008338c06e}. " + "In case of something incredible, " + "I will notify you with @{mention:f3e176d5-ff46-4b18-b260-25008338c06e}, so you won't miss it." + ) + + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "notification": { + "status": "ok", + "body": formatted_body, + "mentions": [ + { + "mention_type": "user", + "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e", + "mention_data": { + "user_huid": "8f3abcc8-ba00-4c89-88e0-b786beb8ec24", + }, + }, + { + "mention_type": "contact", + "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e", + "mention_data": { + "user_huid": "1e0529fd-f091-4be9-93cc-6704a8957432", + }, + }, + { + "mention_type": "chat", + "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e", + "mention_data": { + "group_chat_id": "454d73ad-1d32-4939-a708-e14b77414e86", + "name": "Our chat", + }, + }, + { + "mention_type": "channel", + "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e", + "mention_data": { + "group_chat_id": "78198bec-3285-48d0-9fe2-c0eb3afaffd7", + }, + }, + { + "mention_type": "all", + "mention_id": "f3e176d5-ff46-4b18-b260-25008338c06e", + }, + ], + }, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_message( + body=body, + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ), + ) + + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + }, + ) + + # - Assert - + assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.called + + +async def test__send_message__message_body_max_length_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + too_long_body = "1" * 4097 + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ValueError) as exc: + await bot.send_message( + body=too_long_body, + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ) + + # - Assert - + assert "Message body length exceeds 4096 symbols" in str(exc.value) + assert not endpoint.called + + +async def test__send_message__message_body_max_length_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + max_long_body = "1" * 4096 + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_message( + body=max_long_body, + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ), + ) + + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + }, + ) + + # - Assert - + assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.called diff --git a/tests/client/notifications_api/test_internal_bot_notification.py b/tests/client/notifications_api/test_internal_bot_notification.py new file mode 100644 index 00000000..cfb18357 --- /dev/null +++ b/tests/client/notifications_api/test_internal_bot_notification.py @@ -0,0 +1,297 @@ +import asyncio +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + BotIsNotChatMemberError, + ChatNotFoundError, + FinalRecipientsListEmptyError, + HandlerCollector, + RateLimitReachedError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__send_internal_bot_notification__rate_limit_reached_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/internal", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "data": {"foo": "bar"}, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.TOO_MANY_REQUESTS, + json={ + "status": "error", + "reason": "too_many_requests", + "errors": [], + "error_data": { + "bot_id": "b165f00f-3154-412c-7f11-c120164257da", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(RateLimitReachedError) as exc: + await bot.send_internal_bot_notification( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + data={"foo": "bar"}, + ) + + # - Assert - + assert "too_many_requests" in str(exc.value) + assert endpoint.called + + +async def test__send_internal_bot_notification__chat_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/internal", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "data": {"foo": "bar"}, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_internal_bot_notification( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + data={"foo": "bar"}, + ), + ) + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "error", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "reason": "chat_not_found", + "errors": [], + "error_data": { + "group_chat_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "error_description": ( + "Chat with id 21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3 not found" + ), + }, + }, + ) + + with pytest.raises(ChatNotFoundError) as exc: + await task + + # - Assert - + assert "chat_not_found" in str(exc.value) + assert endpoint.called + + +async def test__send_internal_bot_notification__bot_is_not_chat_member_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/internal", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "data": {"foo": "bar"}, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_internal_bot_notification( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + data={"foo": "bar"}, + ), + ) + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "error", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "reason": "bot_is_not_a_chat_member", + "errors": [], + "error_data": { + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "bot_id": str(bot_id), + "error_description": "Bot is not a chat member", + }, + }, + ) + + with pytest.raises(BotIsNotChatMemberError) as exc: + await task + + # - Assert - + assert "bot_is_not_a_chat_member" in str(exc.value) + assert endpoint.called + + +async def test__send_internal_bot_notification__final_recipients_list_empty_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/internal", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "data": {"foo": "bar"}, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_internal_bot_notification( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + data={"foo": "bar"}, + ), + ) + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "error", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "reason": "event_recipients_list_is_empty", + "errors": [], + "error_data": { + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "bot_id": str(bot_id), + "recipients_param": ["b165f00f-3154-412c-7f11-c120164257da"], + "error_description": "Event recipients list is empty", + }, + }, + ) + + with pytest.raises(FinalRecipientsListEmptyError) as exc: + await task + + # - Assert - + assert "event_recipients_list_is_empty" in str(exc.value) + assert endpoint.called + + +async def test__send_internal_bot_notification__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/internal", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "data": {"foo": "bar"}, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_internal_bot_notification( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + data={"foo": "bar"}, + ), + ) + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + }, + ) + + # - Assert - + assert await task == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.called diff --git a/tests/client/notifications_api/test_markup.py b/tests/client/notifications_api/test_markup.py new file mode 100644 index 00000000..3421381b --- /dev/null +++ b/tests/client/notifications_api/test_markup.py @@ -0,0 +1,239 @@ +import asyncio +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + BubbleMarkup, + Button, + HandlerCollector, + KeyboardMarkup, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__markup__defaults_filled( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "notification": { + "status": "ok", + "body": "Hi!", + "bubble": [ + [ + { + "command": "/bubble-button", + "data": {}, + "label": "Bubble button", + "opts": {"silent": True}, + }, + ], + ], + "keyboard": [ + [ + { + "command": "/keyboard-button", + "data": {}, + "label": "Keyboard button", + "opts": {"silent": True}, + }, + ], + ], + }, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + bubbles = BubbleMarkup() + bubbles.add_button( + command="/bubble-button", + label="Bubble button", + ) + + keyboard = KeyboardMarkup() + keyboard.add_button( + command="/keyboard-button", + label="Keyboard button", + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_message( + body="Hi!", + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + bubbles=bubbles, + keyboard=keyboard, + ), + ) + + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + }, + ) + + # - Assert - + assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.called + + +async def test__markup__correctly_built( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "notification": { + "status": "ok", + "body": "Hi!", + "bubble": [ + [ + { + "command": "/bubble-button-1", + "data": {}, + "label": "Bubble button 1", + "opts": {"silent": True}, + }, + { + "command": "/bubble-button-2", + "data": {}, + "label": "Bubble button 2", + "opts": {"silent": True}, + }, + ], + [ + { + "command": "/bubble-button-3", + "data": {}, + "label": "Bubble button 3", + "opts": {"silent": True}, + }, + ], + [ + { + "command": "/bubble-button-4", + "data": {}, + "label": "Bubble button 4", + "opts": {"silent": True}, + }, + { + "command": "/bubble-button-5", + "data": {}, + "label": "Bubble button 5", + "opts": {"silent": True}, + }, + ], + ], + }, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + bubbles = BubbleMarkup() + + bubbles.add_button( + command="/bubble-button-1", + label="Bubble button 1", + new_row=False, + ) + bubbles.add_button( + command="/bubble-button-2", + label="Bubble button 2", + new_row=False, + ) + bubbles.add_button( + command="/bubble-button-3", + label="Bubble button 3", + ) + + button_4 = Button( + command="/bubble-button-4", + label="Bubble button 4", + ) + button_5 = Button( + command="/bubble-button-5", + label="Bubble button 5", + ) + bubbles.add_row([button_4, button_5]) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.send_message( + body="Hi!", + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + bubbles=bubbles, + ), + ) + + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + }, + ) + + # - Assert - + assert (await task) == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.called + + +def test__markup__comparison() -> None: + # - Arrange - + button = Button("/test", "test") + + # - Assert - + assert BubbleMarkup([[button]]) == BubbleMarkup([[button]]) diff --git a/tests/test_clients/test_clients/test_async_client/__init__.py b/tests/client/smartapps_api/__init__.py similarity index 100% rename from tests/test_clients/test_clients/test_async_client/__init__.py rename to tests/client/smartapps_api/__init__.py diff --git a/tests/client/smartapps_api/test_smartapp_event.py b/tests/client/smartapps_api/test_smartapp_event.py new file mode 100644 index 00000000..67b4126c --- /dev/null +++ b/tests/client/smartapps_api/test_smartapp_event.py @@ -0,0 +1,187 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import Bot, BotAccountWithSecret, HandlerCollector, lifespan_wrapper +from botx.models.async_files import Document, Image, Video, Voice +from botx.models.enums import AttachmentTypes + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__send_smartapp_event__miminally_filled_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/smartapps/event", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "ref": "921763b3-77e8-4f37-b97e-20f4517949b8", + "smartapp_id": str(bot_id), + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "data": {"key": "value"}, + "opts": {}, + "smartapp_api_version": 1, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": "smartapp_event_pushed", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.send_smartapp_event( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + data={"key": "value"}, + ref=UUID("921763b3-77e8-4f37-b97e-20f4517949b8"), + ) + + # - Assert - + assert endpoint.called + + +async def test__send_smartapp_event__maximum_filled_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/smartapps/event", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "ref": "921763b3-77e8-4f37-b97e-20f4517949b8", + "smartapp_id": str(bot_id), + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "data": {"key": "value"}, + "opts": {"option": True}, + "smartapp_api_version": 1, + "async_files": [ + { + "type": "image", + "file": "https://link.to/file", + "file_mime_type": "image/png", + "file_name": "pass.png", + "file_size": 1502345, + "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + }, + { + "type": "video", + "file": "https://link.to/file", + "file_mime_type": "video/mp4", + "file_name": "pass.mp4", + "file_size": 1502345, + "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + "duration": 10, + }, + { + "type": "document", + "file": "https://link.to/file", + "file_mime_type": "plain/text", + "file_name": "pass.txt", + "file_size": 1502345, + "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + }, + { + "type": "voice", + "file": "https://link.to/file", + "file_mime_type": "audio/mp3", + "file_name": "pass.mp3", + "file_size": 1502345, + "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + "duration": 10, + }, + ], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": "smartapp_event_pushed", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.send_smartapp_event( + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + ref=UUID("921763b3-77e8-4f37-b97e-20f4517949b8"), + data={"key": "value"}, + opts={"option": True}, + files=[ + Image( + type=AttachmentTypes.IMAGE, + filename="pass.png", + size=1502345, + is_async_file=True, + _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), + _file_url="https://link.to/file", + _file_mimetype="image/png", + _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + ), + Video( + type=AttachmentTypes.VIDEO, + filename="pass.mp4", + size=1502345, + is_async_file=True, + duration=10, + _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), + _file_url="https://link.to/file", + _file_mimetype="video/mp4", + _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + ), + Document( + type=AttachmentTypes.DOCUMENT, + filename="pass.txt", + size=1502345, + is_async_file=True, + _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), + _file_url="https://link.to/file", + _file_mimetype="plain/text", + _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + ), + Voice( + type=AttachmentTypes.VOICE, + filename="pass.mp3", + size=1502345, + is_async_file=True, + duration=10, + _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), + _file_url="https://link.to/file", + _file_mimetype="audio/mp3", + _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + ), + ], + ) + + # - Assert - + assert endpoint.called diff --git a/tests/client/smartapps_api/test_smartapp_notification.py b/tests/client/smartapps_api/test_smartapp_notification.py new file mode 100644 index 00000000..aaf5ea32 --- /dev/null +++ b/tests/client/smartapps_api/test_smartapp_notification.py @@ -0,0 +1,55 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import Bot, BotAccountWithSecret, HandlerCollector, lifespan_wrapper + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__send_smartapp_notification__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/smartapps/notification", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c", + "smartapp_counter": 42, + "opts": {"message": "ping"}, + "smartapp_api_version": 1, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": "smartapp_notification_pushed", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.send_smartapp_notification( + bot_id=bot_id, + chat_id=UUID("705df263-6bfd-536a-9d51-13524afaab5c"), + smartapp_counter=42, + opts={"message": "ping"}, + ) + + # - Assert - + assert endpoint.called diff --git a/tests/test_clients/test_clients/test_sync_client/__init__.py b/tests/client/stickers_api/__init__.py similarity index 100% rename from tests/test_clients/test_clients/test_sync_client/__init__.py rename to tests/client/stickers_api/__init__.py diff --git a/tests/client/stickers_api/test_add_sticker.py b/tests/client/stickers_api/test_add_sticker.py new file mode 100644 index 00000000..c8ea1e52 --- /dev/null +++ b/tests/client/stickers_api/test_add_sticker.py @@ -0,0 +1,318 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from aiofiles.tempfile import NamedTemporaryFile +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + InvalidBotXStatusCodeError, + InvalidEmojiError, + InvalidImageError, + Sticker, + StickerPackOrStickerNotFoundError, + lifespan_wrapper, +) + +PNG_IMAGE = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x01\x03\x00" + b"\x00\x00%\xdbV\xca\x00\x00\x00\x03PLTE\x00\x00\x00\xa7z=\xda\x00" + b"\x00\x00\x01tRNS\x00@\xe6\xd8f\x00\x00\x00\nIDAT\x08\xd7c`\x00" + b"\x00\x00\x02\x00\x01\xe2!\xbc3\x00\x00\x00\x00IEND\xaeB`\x82" +) +PNG_IMAGE_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAAACnej3a" + "AAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=" +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__add_sticker__is_not_png_error_raised( + respx_mock: MockRouter, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + await async_buffer.write(b"Hello, world!\n") + await async_buffer.seek(0) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ValueError) as exc: + await bot.add_sticker( + bot_id=bot_id, + sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + emoji="🤔", + async_buffer=async_buffer, + ) + + # - Assert - + assert "Passed file is not PNG" in str(exc.value) + + +async def test__add_sticker__bad_file_size_error_raised( + respx_mock: MockRouter, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + await async_buffer.write(PNG_IMAGE + b"\x00" * (512 * 1024 + 1)) + await async_buffer.seek(0) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ValueError) as exc: + await bot.add_sticker( + bot_id=bot_id, + sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + emoji="🤔", + async_buffer=async_buffer, + ) + + # - Assert - + assert "Passed file size is greater than 0.5 Mb" in str(exc.value) + + +async def test__add_sticker__unexpected_bad_request_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + await async_buffer.write(PNG_IMAGE) + await async_buffer.seek(0) + + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/stickers", + headers={"Authorization": "Bearer token"}, + json={"emoji": "🤔", "image": f"data:image/png;base64,{PNG_IMAGE_B64}"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.BAD_REQUEST, + json={ + "status": "error", + "reason": "some_reason", + "errors": [], + "error_data": {}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(InvalidBotXStatusCodeError) as exc: + await bot.add_sticker( + bot_id=bot_id, + sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + emoji="🤔", + async_buffer=async_buffer, + ) + + # - Assert - + assert "some_reason" in str(exc.value) + assert endpoint.called + + +async def test__add_sticker__sticker_pack_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + await async_buffer.write(PNG_IMAGE) + await async_buffer.seek(0) + + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/stickers", + headers={"Authorization": "Bearer token"}, + json={"emoji": "🤔", "image": f"data:image/png;base64,{PNG_IMAGE_B64}"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.BAD_REQUEST, + json={ + "error_data": {"pack_id": "26080153-a57d-5a8c-af0e-fdecee3c4435"}, + "errors": ["Failed to add sticker because pack not found."], + "reason": "pack_not_found", + "status": "error", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(StickerPackOrStickerNotFoundError) as exc: + await bot.add_sticker( + bot_id=bot_id, + sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + emoji="🤔", + async_buffer=async_buffer, + ) + + # - Assert - + assert "pack_not_found" in str(exc.value) + assert endpoint.called + + +async def test__add_sticker__invalid_emoji_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + await async_buffer.write(PNG_IMAGE) + await async_buffer.seek(0) + + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/stickers", + headers={"Authorization": "Bearer token"}, + json={"emoji": "🤔", "image": f"data:image/png;base64,{PNG_IMAGE_B64}"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.BAD_REQUEST, + json={ + "error_data": {"emoji": "invalid"}, + "errors": [], + "reason": "malformed_request", + "status": "error", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(InvalidEmojiError) as exc: + await bot.add_sticker( + bot_id=bot_id, + sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + emoji="🤔", + async_buffer=async_buffer, + ) + + # - Assert - + assert "malformed_request" in str(exc.value) + assert endpoint.called + + +async def test__add_sticker__invalid_image_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + await async_buffer.write(PNG_IMAGE) + await async_buffer.seek(0) + + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/stickers", + headers={"Authorization": "Bearer token"}, + json={"emoji": "🤔", "image": f"data:image/png;base64,{PNG_IMAGE_B64}"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.BAD_REQUEST, + json={ + "error_data": {"image": "invalid"}, + "errors": [], + "reason": "malformed_request", + "status": "error", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(InvalidImageError) as exc: + await bot.add_sticker( + bot_id=bot_id, + sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + emoji="🤔", + async_buffer=async_buffer, + ) + + # - Assert - + assert "malformed_request" in str(exc.value) + assert endpoint.called + + +async def test__add_sticker__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + await async_buffer.write(PNG_IMAGE) + await async_buffer.seek(0) + + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/stickers", + headers={"Authorization": "Bearer token"}, + json={"emoji": "🤔", "image": f"data:image/png;base64,{PNG_IMAGE_B64}"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "id": "75bb24c9-7c08-5db0-ae3e-085929e80c54", + "emoji": "🤔", + "link": "http://cts-domain/uploads/sticker_pack/26080153-a57d-5a8c-af0e-fdecee3c4435/b4577728162f4d9ea2b35f25f9f0dcde.png?v=1626137130775", + "inserted_at": "2020-12-28T12:56:43.672163Z", + "updated_at": "2020-12-28T12:56:43.672163Z", + "deleted_at": None, + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + sticker = await bot.add_sticker( + bot_id=bot_id, + sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + emoji="🤔", + async_buffer=async_buffer, + ) + + # - Assert - + assert sticker == Sticker( + id=UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"), + emoji="🤔", + image_link="http://cts-domain/uploads/sticker_pack/26080153-a57d-5a8c-af0e-fdecee3c4435/b4577728162f4d9ea2b35f25f9f0dcde.png?v=1626137130775", + ) + + assert endpoint.called diff --git a/tests/client/stickers_api/test_create_sticker_pack.py b/tests/client/stickers_api/test_create_sticker_pack.py new file mode 100644 index 00000000..b6a72d37 --- /dev/null +++ b/tests/client/stickers_api/test_create_sticker_pack.py @@ -0,0 +1,68 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + StickerPack, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__create_sticker_pack__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v3/botx/stickers/packs", + headers={"Authorization": "Bearer token"}, + json={"name": "Sticker Pack"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "id": "26080153-a57d-5a8c-af0e-fdecee3c4435", + "name": "Sticker Pack", + "public": False, + "preview": None, + "stickers": [], + "stickers_order": [], + "inserted_at": "2021-07-10T00:27:55.616703Z", + "updated_at": "2021-07-10T00:27:55.616703Z", + "deleted_at": None, + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + sticker_pack = await bot.create_sticker_pack(bot_id=bot_id, name="Sticker Pack") + + # - Assert - + assert sticker_pack == StickerPack( + id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + name="Sticker Pack", + is_public=False, + stickers=[], + ) + + assert endpoint.called diff --git a/tests/client/stickers_api/test_delete_sticker.py b/tests/client/stickers_api/test_delete_sticker.py new file mode 100644 index 00000000..be3c7f13 --- /dev/null +++ b/tests/client/stickers_api/test_delete_sticker.py @@ -0,0 +1,99 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + StickerPackOrStickerNotFoundError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__delete_sticker__sticker_or_pack_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.delete( + f"https://{host}/api/v3/botx/stickers/" + f"packs/78f9743c-8b24-4e97-8059-70908604a252/" + f"stickers/6ead1e00-f788-4ce6-9e1a-95abe219414e", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "error_data": { + "pack_id": "78f9743c-8b24-4e97-8059-70908604a252", + "sticker_id": "6ead1e00-f788-4ce6-9e1a-95abe219414e", + }, + "errors": ["Sticker or sticker pack not found."], + "reason": "not_found", + "status": "error", + }, + ), + ) + + build_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(build_bot) as bot: + with pytest.raises(StickerPackOrStickerNotFoundError) as exc: + await bot.delete_sticker( + bot_id=bot_id, + sticker_id=UUID("6ead1e00-f788-4ce6-9e1a-95abe219414e"), + sticker_pack_id=UUID("78f9743c-8b24-4e97-8059-70908604a252"), + ) + + # - Assert - + assert "Sticker or sticker pack not found" in str(exc.value) + assert endpoint.called + + +async def test__delete_sticker__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.delete( + f"https://{host}/api/v3/botx/stickers/" + f"packs/78f9743c-8b24-4e97-8059-70908604a252/" + f"stickers/6ead1e00-f788-4ce6-9e1a-95abe219414e", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": "delete_sticker_from_pack_pushed", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.delete_sticker( + bot_id=bot_id, + sticker_id=UUID("6ead1e00-f788-4ce6-9e1a-95abe219414e"), + sticker_pack_id=UUID("78f9743c-8b24-4e97-8059-70908604a252"), + ) + + # - Assert - + assert endpoint.called diff --git a/tests/client/stickers_api/test_delete_sticker_pack.py b/tests/client/stickers_api/test_delete_sticker_pack.py new file mode 100644 index 00000000..998d6f18 --- /dev/null +++ b/tests/client/stickers_api/test_delete_sticker_pack.py @@ -0,0 +1,92 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + StickerPackOrStickerNotFoundError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__delete_sticker_pack__sticker_pack_not_found( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.delete( + f"https://{host}/api/v3/botx/stickers/" + f"packs/26080153-a57d-5a8c-af0e-fdecee3c4435", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "error_data": {"pack_id": "26080153-a57d-5a8c-af0e-fdecee3c4435"}, + "errors": ["Sticker pack not found."], + "reason": "pack_not_found", + "status": "error", + }, + ), + ) + + build_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(build_bot) as bot: + with pytest.raises(StickerPackOrStickerNotFoundError) as exc: + await bot.delete_sticker_pack( + bot_id=bot_id, + sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + ) + + # - Assert - + assert "pack_not_found" in str(exc) + assert endpoint.called + + +async def test__delete_sticker_pack__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.delete( + f"https://{host}/api/v3/botx/stickers/" + f"packs/26080153-a57d-5a8c-af0e-fdecee3c4435", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": "delete_sticker_pack_pushed", + }, + ), + ) + + build_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(build_bot) as bot: + await bot.delete_sticker_pack( + bot_id=bot_id, + sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + ) + + # - Assert - + assert endpoint.called diff --git a/tests/client/stickers_api/test_edit_sticker_pack.py b/tests/client/stickers_api/test_edit_sticker_pack.py new file mode 100644 index 00000000..67ffb807 --- /dev/null +++ b/tests/client/stickers_api/test_edit_sticker_pack.py @@ -0,0 +1,159 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + StickerPackOrStickerNotFoundError, + lifespan_wrapper, +) +from botx.models.stickers import Sticker, StickerPack + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__edit_sticker_pack__sticker_pack_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.put( + f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "error_data": {"pack_id": "26080153-a57d-5a8c-af0e-fdecee3c4435"}, + "errors": ["Sticker pack not found."], + "reason": "pack_not_found", + "status": "error", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(StickerPackOrStickerNotFoundError) as exc: + await bot.edit_sticker_pack( + bot_id=bot_id, + sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + name="Sticker Pack 2.0", + preview=UUID("528c3953-5842-5a30-b2cb-8a09218497bc"), + stickers_order=[ + UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"), + UUID("528c3953-5842-5a30-b2cb-8a09218497bc"), + ], + ) + + # - Assert - + assert "pack_not_found" in str(exc.value) + assert endpoint.called + + +async def test__edit_sticker__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.put( + f"https://{host}/api/v3/botx/stickers/packs/d881f83a-db30-4cff-b60e-f24ac53deecf", + headers={"Authorization": "Bearer token"}, + json={ + "name": "Sticker Pack 2.0", + "preview": "528c3953-5842-5a30-b2cb-8a09218497bc", + "stickers_order": [ + "75bb24c9-7c08-5db0-ae3e-085929e80c54", + "528c3953-5842-5a30-b2cb-8a09218497bc", + ], + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "id": "d881f83a-db30-4cff-b60e-f24ac53deecf", + "name": "Sticker Pack 2.0", + "public": True, + "preview": "528c3953-5842-5a30-b2cb-8a09218497bc", + "stickers_order": [ + "75bb24c9-7c08-5db0-ae3e-085929e80c54", + "528c3953-5842-5a30-b2cb-8a09218497bc", + ], + "stickers": [ + { + "id": "75bb24c9-7c08-5db0-ae3e-085929e80c54", + "emoji": "🤔", + "link": "https://cts-host/uploads/sticker_pack/image.png", + "inserted_at": "2020-12-28T12:56:43.672163Z", + "updated_at": "2020-12-28T12:56:43.672163Z", + "deleted_at": None, + }, + { + "id": "528c3953-5842-5a30-b2cb-8a09218497bc", + "emoji": "😀", + "link": "https://cts-host/uploads/sticker_pack/image.png", + "inserted_at": "2020-12-28T12:56:43.672163Z", + "updated_at": "2020-12-28T12:56:43.672163Z", + "deleted_at": None, + }, + ], + "inserted_at": "2020-12-28T12:56:43.672163Z", + "updated_at": "2021-07-22T13:26:41.562143Z", + "deleted_at": None, + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + sticker_pack = await bot.edit_sticker_pack( + bot_id=bot_id, + sticker_pack_id=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"), + name="Sticker Pack 2.0", + preview=UUID("528c3953-5842-5a30-b2cb-8a09218497bc"), + stickers_order=[ + UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"), + UUID("528c3953-5842-5a30-b2cb-8a09218497bc"), + ], + ) + + # - Assert - + assert sticker_pack == StickerPack( + id=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"), + name="Sticker Pack 2.0", + is_public=True, + stickers=[ + Sticker( + id=UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"), + emoji="🤔", + image_link="https://cts-host/uploads/sticker_pack/image.png", + ), + Sticker( + id=UUID("528c3953-5842-5a30-b2cb-8a09218497bc"), + emoji="😀", + image_link="https://cts-host/uploads/sticker_pack/image.png", + ), + ], + ) + + assert endpoint.called diff --git a/tests/client/stickers_api/test_get_sticker.py b/tests/client/stickers_api/test_get_sticker.py new file mode 100644 index 00000000..5092f35e --- /dev/null +++ b/tests/client/stickers_api/test_get_sticker.py @@ -0,0 +1,109 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + StickerPackOrStickerNotFoundError, + lifespan_wrapper, +) +from botx.models.stickers import Sticker + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__get_sticker__sticker_pack_or_sticker_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/" + f"stickers/75bb24c9-7c08-5db0-ae3e-085929e80c54", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "error_data": { + "pack_id": "26080153-a57d-5a8c-af0e-fdecee3c4435", + "sticker_id": "75bb24c9-7c08-5db0-ae3e-085929e80c54", + }, + "errors": ["Sticker or sticker pack not found."], + "reason": "not_found", + "status": "error", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(StickerPackOrStickerNotFoundError) as exc: + await bot.get_sticker( + bot_id=bot_id, + sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + sticker_id=UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"), + ) + + # - Assert - + assert "not_found" in str(exc.value) + assert endpoint.called + + +async def test__get_sticker__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435/" + f"stickers/75bb24c9-7c08-5db0-ae3e-085929e80c54", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "id": "75bb24c9-7c08-5db0-ae3e-085929e80c54", + "emoji": "🤔", + "link": "https://cts-host/uploads/sticker_pack/image.png", + "preview": "https://cts-host/uploads/sticker_pack/image.png", + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + sticker = await bot.get_sticker( + bot_id=bot_id, + sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + sticker_id=UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"), + ) + + # - Assert - + assert sticker == Sticker( + id=UUID("75bb24c9-7c08-5db0-ae3e-085929e80c54"), + emoji="🤔", + image_link="https://cts-host/uploads/sticker_pack/image.png", + ) + + assert endpoint.called diff --git a/tests/client/stickers_api/test_get_sticker_pack.py b/tests/client/stickers_api/test_get_sticker_pack.py new file mode 100644 index 00000000..d344b0c4 --- /dev/null +++ b/tests/client/stickers_api/test_get_sticker_pack.py @@ -0,0 +1,202 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + StickerPackOrStickerNotFoundError, + lifespan_wrapper, +) +from botx.models.stickers import Sticker, StickerPack + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__get_sticker__sticker_pack_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/stickers/packs/26080153-a57d-5a8c-af0e-fdecee3c4435", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "error_data": {"pack_id": "26080153-a57d-5a8c-af0e-fdecee3c4435"}, + "errors": ["Sticker pack not found."], + "reason": "pack_not_found", + "status": "error", + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(StickerPackOrStickerNotFoundError) as exc: + await bot.get_sticker_pack( + bot_id=bot_id, + sticker_pack_id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + ) + + # - Assert - + assert "pack_not_found" in str(exc.value) + assert endpoint.called + + +async def test__get_sticker_pack__stickers_in_right_order_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/stickers/packs/d881f83a-db30-4cff-b60e-f24ac53deecf", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "id": "d881f83a-db30-4cff-b60e-f24ac53deecf", + "name": "Sticker Pack", + "public": True, + "preview": "https://cts-host/uploads/sticker_pack/image.png", + "stickers_order": [ + "528c3953-5842-5a30-b2cb-8a09218497bc", + "750bb400-bb37-4ff9-aa92-cc293f09cafa", + ], + "stickers": [ + { + "id": "750bb400-bb37-4ff9-aa92-cc293f09cafa", + "emoji": "🤔", + "link": "https://cts-host/uploads/sticker_pack/image.png", + "inserted_at": "2020-12-28T12:56:43.672163Z", + "updated_at": "2020-12-28T12:56:43.672163Z", + "deleted_at": None, + }, + { + "id": "528c3953-5842-5a30-b2cb-8a09218497bc", + "emoji": "🤔", + "link": "https://cts-host/uploads/sticker_pack/image.png", + "inserted_at": "2020-12-28T12:56:43.672163Z", + "updated_at": "2020-12-28T12:56:43.672163Z", + "deleted_at": None, + }, + ], + "inserted_at": "2020-12-28T12:56:43.672163Z", + "updated_at": "2020-12-28T12:56:43.672163Z", + "deleted_at": None, + }, + }, + ), + ) + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + sticker_pack = await bot.get_sticker_pack( + bot_id=bot_id, + sticker_pack_id=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"), + ) + + # - Assert - + assert sticker_pack == StickerPack( + id=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"), + name="Sticker Pack", + is_public=True, + stickers=[ + Sticker( + id=UUID("528c3953-5842-5a30-b2cb-8a09218497bc"), + emoji="🤔", + image_link="https://cts-host/uploads/sticker_pack/image.png", + ), + Sticker( + id=UUID("750bb400-bb37-4ff9-aa92-cc293f09cafa"), + emoji="🤔", + image_link="https://cts-host/uploads/sticker_pack/image.png", + ), + ], + ) + + assert endpoint.called + + +async def test__get_sticker_pack__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/stickers/packs/d881f83a-db30-4cff-b60e-f24ac53deecf", + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "id": "d881f83a-db30-4cff-b60e-f24ac53deecf", + "name": "Sticker Pack", + "public": True, + "preview": "https://cts-host/uploads/sticker_pack/image.png", + "stickers_order": [], + "stickers": [ + { + "id": "528c3953-5842-5a30-b2cb-8a09218497bc", + "emoji": "🤔", + "link": "https://cts-host/uploads/sticker_pack/image.png", + "inserted_at": "2020-12-28T12:56:43.672163Z", + "updated_at": "2020-12-28T12:56:43.672163Z", + "deleted_at": None, + }, + ], + "inserted_at": "2020-12-28T12:56:43.672163Z", + "updated_at": "2020-12-28T12:56:43.672163Z", + "deleted_at": None, + }, + }, + ), + ) + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + sticker_pack = await bot.get_sticker_pack( + bot_id=bot_id, + sticker_pack_id=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"), + ) + + # - Assert - + assert sticker_pack == StickerPack( + id=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"), + name="Sticker Pack", + is_public=True, + stickers=[ + Sticker( + id=UUID("528c3953-5842-5a30-b2cb-8a09218497bc"), + emoji="🤔", + image_link="https://cts-host/uploads/sticker_pack/image.png", + ), + ], + ) + + assert endpoint.called diff --git a/tests/client/stickers_api/test_get_sticker_packs.py b/tests/client/stickers_api/test_get_sticker_packs.py new file mode 100644 index 00000000..297650c0 --- /dev/null +++ b/tests/client/stickers_api/test_get_sticker_packs.py @@ -0,0 +1,228 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import Bot, BotAccountWithSecret, HandlerCollector, lifespan_wrapper +from botx.models.stickers import StickerPackFromList + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__iterate_by_sticker_packs__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/stickers/packs", + headers={"Authorization": "Bearer token"}, + params={"user_huid": "d881f83a-db30-4cff-b60e-f24ac53deecf"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "packs": [ + { + "id": "26080153-a57d-5a8c-af0e-fdecee3c4435", + "name": "Sticker Pack", + "preview": "https://cts-host/uploads/sticker_pack/image.png", + "public": True, + "stickers_count": 2, + "stickers_order": [ + "a998f599-d7ac-5e04-9fdb-2d98224ce4ff", + "25054ac4-8be2-5a4b-ae00-9efd38c73fb7", + ], + "inserted_at": "2020-11-28T12:56:43.672163Z", + "updated_at": "2021-02-18T12:52:31.571133Z", + "deleted_at": None, + }, + ], + "pagination": { + "after": None, + }, + }, + }, + ), + ) + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + sticker_pack_list = [] + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + sticker_pack_pages = bot.iterate_by_sticker_packs( + bot_id=bot_id, + user_huid=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"), + ) + async for sticker_packs in sticker_pack_pages: + sticker_pack_list.append(sticker_packs) + + # - Assert - + assert sticker_pack_list == [ + StickerPackFromList( + id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + name="Sticker Pack", + is_public=True, + stickers_count=2, + sticker_ids=[ + UUID("a998f599-d7ac-5e04-9fdb-2d98224ce4ff"), + UUID("25054ac4-8be2-5a4b-ae00-9efd38c73fb7"), + ], + ), + ] + assert endpoint.called + + +async def test__iterate_by_sticker_packs__iterate_by_pages_succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # - Arrange - + monkeypatch.setattr("botx.bot.bot.STICKER_PACKS_PER_PAGE", 2) + + # Mock order matters + # https://lundberg.github.io/respx/guide/#routing-requests + second_sticker_endpoint_call = respx_mock.get( + f"https://{host}/api/v3/botx/stickers/packs", + headers={"Authorization": "Bearer token"}, + params={ + "user_huid": "d881f83a-db30-4cff-b60e-f24ac53deecf", + "limit": 2, + "after": "base64string", + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "packs": [ + { + "id": "750bb400-bb37-4ff9-aa92-cc293f09cafa", + "name": "Sticker Pack 3", + "preview": "https://cts-host/uploads/sticker_pack/image.png", + "public": True, + "stickers_count": 2, + "stickers_order": [ + "a998f599-d7ac-5e04-9fdb-2d98224ce4ff", + "25054ac4-8be2-5a4b-ae00-9efd38c73fb7", + ], + "inserted_at": "2020-11-28T12:56:43.672163Z", + "updated_at": "2021-02-18T12:52:31.571133Z", + "deleted_at": None, + }, + ], + "pagination": { + "after": None, + }, + }, + }, + ), + ) + first_sticker_endpoint_call = respx_mock.get( + f"https://{host}/api/v3/botx/stickers/packs", + headers={"Authorization": "Bearer token"}, + params={"user_huid": "d881f83a-db30-4cff-b60e-f24ac53deecf", "limit": 2}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "packs": [ + { + "id": "26080153-a57d-5a8c-af0e-fdecee3c4435", + "name": "Sticker Pack 1", + "preview": "https://cts-host/uploads/sticker_pack/image.png", + "public": True, + "stickers_count": 2, + "stickers_order": [ + "a998f599-d7ac-5e04-9fdb-2d98224ce4ff", + "25054ac4-8be2-5a4b-ae00-9efd38c73fb7", + ], + "inserted_at": "2020-11-28T12:56:43.672163Z", + "updated_at": "2021-02-18T12:52:31.571133Z", + "deleted_at": None, + }, + { + "id": "89152263-2484-4e00-bc6c-90003027e39e", + "name": "Sticker Pack 2", + "preview": "https://cts-host/uploads/sticker_pack/image.png", + "public": True, + "stickers_count": 1, + "stickers_order": [ + "a998f599-d7ac-5e04-9fdb-2d98224ce4ff", + ], + "inserted_at": "2020-11-28T12:56:43.672163Z", + "updated_at": "2021-02-18T12:52:31.571133Z", + "deleted_at": None, + }, + ], + "pagination": { + "after": "base64string", + }, + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + sticker_pack_list = [] + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + sticker_pack_pages = bot.iterate_by_sticker_packs( + bot_id=bot_id, + user_huid=UUID("d881f83a-db30-4cff-b60e-f24ac53deecf"), + ) + async for sticker_packs in sticker_pack_pages: + sticker_pack_list.append(sticker_packs) + + # - Assert - + assert sticker_pack_list == [ + StickerPackFromList( + id=UUID("26080153-a57d-5a8c-af0e-fdecee3c4435"), + name="Sticker Pack 1", + is_public=True, + stickers_count=2, + sticker_ids=[ + UUID("a998f599-d7ac-5e04-9fdb-2d98224ce4ff"), + UUID("25054ac4-8be2-5a4b-ae00-9efd38c73fb7"), + ], + ), + StickerPackFromList( + id=UUID("89152263-2484-4e00-bc6c-90003027e39e"), + name="Sticker Pack 2", + is_public=True, + stickers_count=1, + sticker_ids=[ + UUID("a998f599-d7ac-5e04-9fdb-2d98224ce4ff"), + ], + ), + StickerPackFromList( + id=UUID("750bb400-bb37-4ff9-aa92-cc293f09cafa"), + name="Sticker Pack 3", + is_public=True, + stickers_count=2, + sticker_ids=[ + UUID("a998f599-d7ac-5e04-9fdb-2d98224ce4ff"), + UUID("25054ac4-8be2-5a4b-ae00-9efd38c73fb7"), + ], + ), + ] + assert first_sticker_endpoint_call.called + assert second_sticker_endpoint_call.called diff --git a/tests/client/test_authorized_botx_method.py b/tests/client/test_authorized_botx_method.py new file mode 100644 index 00000000..9da35c8a --- /dev/null +++ b/tests/client/test_authorized_botx_method.py @@ -0,0 +1,176 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import BotAccountWithSecret, InvalidBotAccountError +from botx.bot.bot_accounts_storage import BotAccountsStorage +from botx.client.authorized_botx_method import AuthorizedBotXMethod +from tests.client.test_botx_method import ( + BotXAPIFooBarRequestPayload, + BotXAPIFooBarResponsePayload, +) + + +class FooBarMethod(AuthorizedBotXMethod): + async def execute( + self, + payload: BotXAPIFooBarRequestPayload, + ) -> BotXAPIFooBarResponsePayload: + path = "/foo/bar" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + return self._verify_and_extract_api_model( + BotXAPIFooBarResponsePayload, + response, + ) + + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__authorized_botx_method__unauthorized( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_signature: str, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + token_endpoint = respx_mock.get( + f"https://{host}/api/v2/botx/bots/{bot_id}/token", + params={"signature": bot_signature}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": "token", + }, + ), + ) + + foo_bar_endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response(HTTPStatus.UNAUTHORIZED), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account]), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + with pytest.raises(InvalidBotAccountError) as exc: + await method.execute(payload) + + # - Assert - + assert "failed with code 401" in str(exc.value) + assert token_endpoint.called + assert foo_bar_endpoint.called + + +async def test__authorized_botx_method__succeed( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_signature: str, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + token_endpoint = respx_mock.get( + f"https://{host}/api/v2/botx/bots/{bot_id}/token", + params={"signature": bot_signature}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": "token", + }, + ), + ) + + foo_bar_endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account]), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + botx_api_foo_bar = await method.execute(payload) + + # - Assert - + assert botx_api_foo_bar.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert token_endpoint.called + assert foo_bar_endpoint.called + + +async def test__authorized_botx_method__with_prepared_token( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + prepared_bot_accounts_storage: BotAccountsStorage, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + prepared_bot_accounts_storage, + ) + + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + botx_api_foo_bar = await method.execute(payload) + + # - Assert - + assert botx_api_foo_bar.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.called diff --git a/tests/client/test_botx_method.py b/tests/client/test_botx_method.py new file mode 100644 index 00000000..f87a48aa --- /dev/null +++ b/tests/client/test_botx_method.py @@ -0,0 +1,242 @@ +from http import HTTPStatus +from typing import Literal +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + BotAccountWithSecret, + InvalidBotXResponsePayloadError, + InvalidBotXStatusCodeError, +) +from botx.bot.bot_accounts_storage import BotAccountsStorage +from botx.client.botx_method import BotXMethod, response_exception_thrower +from botx.client.exceptions.base import BaseClientError +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class FooBarError(BaseClientError): + """Test exception.""" + + +class BotXAPIFooBarRequestPayload(UnverifiedPayloadBaseModel): + baz: int + + @classmethod + def from_domain(cls, baz: int) -> "BotXAPIFooBarRequestPayload": + return cls(baz=baz) + + +class BotXAPISyncIdResult(VerifiedPayloadBaseModel): + sync_id: UUID + + +class BotXAPIFooBarResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + result: BotXAPISyncIdResult + + def to_domain(self) -> UUID: + return self.result.sync_id + + +class FooBarMethod(BotXMethod): + status_handlers = { + 403: response_exception_thrower(FooBarError, "FooBar comment"), + } + + async def execute( + self, + payload: BotXAPIFooBarRequestPayload, + ) -> BotXAPIFooBarResponsePayload: + path = "/foo/bar" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + return self._verify_and_extract_api_model( + BotXAPIFooBarResponsePayload, + response, + ) + + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__botx_method__invalid_botx_status_code_error_raised( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response(HTTPStatus.METHOD_NOT_ALLOWED), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account]), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + with pytest.raises(InvalidBotXStatusCodeError) as exc: + await method.execute(payload) + + # - Assert - + assert "failed with code 405" in str(exc.value) + assert endpoint.called + + +async def test__botx_method__invalid_json_raises_invalid_botx_response_payload_error( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + content='{"invalid": "json', + ), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account]), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + with pytest.raises(InvalidBotXResponsePayloadError) as exc: + await method.execute(payload) + + # - Assert - + assert '{"invalid": "json' in str(exc.value) + assert endpoint.called + + +async def test__botx_method__invalid_schema_raises_invalid_botx_response_payload_error( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={"invalid": "schema"}, + ), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account]), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + with pytest.raises(InvalidBotXResponsePayloadError) as exc: + await method.execute(payload) + + # - Assert - + assert '{"invalid": "schema"}' in str(exc.value) + assert endpoint.called + + +async def test__botx_method__status_handler_called( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response(HTTPStatus.FORBIDDEN), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account]), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + with pytest.raises(FooBarError) as exc: + await method.execute(payload) + + # - Assert - + assert "403" in str(exc.value) + assert "FooBar comment" in str(exc.value) + assert endpoint.called + + +async def test__botx_method__succeed( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account]), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + botx_api_foo_bar = await method.execute(payload) + + # - Assert - + assert botx_api_foo_bar.to_domain() == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.called diff --git a/tests/client/test_botx_method_callback.py b/tests/client/test_botx_method_callback.py new file mode 100644 index 00000000..7cee4c52 --- /dev/null +++ b/tests/client/test_botx_method_callback.py @@ -0,0 +1,500 @@ +# type: ignore [attr-defined] + +import asyncio +import types +from http import HTTPStatus +from typing import Optional +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + BotShuttingDownError, + BotXMethodCallbackNotFoundError, + BotXMethodFailedCallbackReceivedError, + CallbackNotReceivedError, + HandlerCollector, + lifespan_wrapper, +) +from botx.client.botx_method import ( + BotXMethod, + ErrorCallbackHandlers, + callback_exception_thrower, +) +from botx.client.exceptions.base import BaseClientError +from botx.missing import MissingOptional, Undefined, not_undefined +from botx.models.method_callbacks import BotAPIMethodSuccessfulCallback +from tests.client.test_botx_method import ( + BotXAPIFooBarRequestPayload, + BotXAPIFooBarResponsePayload, +) + + +class FooBarError(BaseClientError): + """Test exception.""" + + +class FooBarCallbackMethod(BotXMethod): + error_callback_handlers: ErrorCallbackHandlers = { + "foo_bar_error": callback_exception_thrower( + FooBarError, + "FooBar comment", + ), + } + + async def execute( + self, + payload: BotXAPIFooBarRequestPayload, + wait_callback: bool, + callback_timeout: MissingOptional[int] = Undefined, + ) -> BotXAPIFooBarResponsePayload: + path = "/foo/bar" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + api_model = self._verify_and_extract_api_model( + BotXAPIFooBarResponsePayload, + response, + ) + + await self._process_callback( + api_model.result.sync_id, + wait_callback, + callback_timeout, + ) + + return api_model + + +async def call_foo_bar( + self: Bot, + bot_id: UUID, + baz: int, + wait_callback: bool = True, + callback_timeout: Optional[int] = None, +) -> UUID: + method = FooBarCallbackMethod( + bot_id, + self._httpx_client, + self._bot_accounts_storage, + self._callback_manager, + ) + + payload = BotXAPIFooBarRequestPayload.from_domain(baz=baz) + botx_api_foo_bar = await method.execute( + payload, + wait_callback, + not_undefined(callback_timeout, self.default_callback_timeout), + ) + + return botx_api_foo_bar.to_domain() + + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__botx_method_callback__callback_not_found( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(BotXMethodCallbackNotFoundError) as exc: + bot.set_raw_botx_method_result( + { + "status": "error", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "reason": "chat_not_found", + "errors": [], + "error_data": { + "group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c", + "error_description": ( + "Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found" + ), + }, + }, + ) + + # - Assert - + assert "No callback found" in str(exc.value) + assert "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" in str(exc.value) + + +async def test__botx_method_callback__error_callback_error_handler_called( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.call_foo_bar(bot_id, baz=1), + ) + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "error", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "reason": "foo_bar_error", + "errors": [], + "error_data": { + "group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c", + "error_description": ( + "Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found" + ), + }, + }, + ) + + with pytest.raises(FooBarError) as exc: + await task + + # - Assert - + assert "foo_bar_error" in str(exc.value) + assert "FooBar comment" in str(exc.value) + assert endpoint.called + + +async def test__botx_method_callback__error_callback_received( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.call_foo_bar(bot_id, baz=1), + ) + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "error", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "reason": "quux_error", + "errors": [], + "error_data": { + "group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c", + "error_description": ( + "Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found" + ), + }, + }, + ) + + with pytest.raises(BotXMethodFailedCallbackReceivedError) as exc: + await task + + # - Assert - + assert "failed with" in str(exc.value) + assert endpoint.called + + +async def test__botx_method_callback__cancelled_callback_future_during_shutdown( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(CallbackNotReceivedError) as exc: + await bot.call_foo_bar(bot_id, baz=1, callback_timeout=0) + + # - Assert - + assert "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" in str(exc.value) + assert endpoint.called + + +async def test__botx_method_callback__callback_received_after_timeout( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + loguru_caplog: pytest.LogCaptureFixture, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(CallbackNotReceivedError) as exc: + await bot.call_foo_bar(bot_id, baz=1, callback_timeout=0) + + bot.set_raw_botx_method_result( + { + "status": "error", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "reason": "quux_error", + "errors": [], + "error_data": { + "group_chat_id": "705df263-6bfd-536a-9d51-13524afaab5c", + "error_description": ( + "Chat with id 705df263-6bfd-536a-9d51-13524afaab5c not found" + ), + }, + }, + ) + + # - Assert - + assert "hasn't been received" in str(exc.value) + assert "don't wait callback" in loguru_caplog.text + assert "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" in loguru_caplog.text + assert endpoint.called + + +async def test__botx_method_callback__dont_wait_for_callback( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + foo_bar = await bot.call_foo_bar(bot_id, baz=1, wait_callback=False) + + # - Assert - + assert foo_bar == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.called + + +async def test__botx_method_callback__pending_callback_future_during_shutdown( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.call_foo_bar(bot_id, baz=1), + ) + await asyncio.sleep(0) # Return control to event loop + + with pytest.raises(BotShuttingDownError) as exc: + await task + + # - Assert - + assert "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3" in str(exc.value) + assert endpoint.called + + +async def test__botx_method_callback__callback_successful_received( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + task = asyncio.create_task( + bot.call_foo_bar(bot_id, baz=1), + ) + await asyncio.sleep(0) # Return control to event loop + + bot.set_raw_botx_method_result( + { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + }, + ) + + # - Assert - + assert await task == UUID("21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3") + assert endpoint.called + + +async def test__botx_method_callback__bot_wait_callback( + respx_mock: MockRouter, + httpx_client: httpx.AsyncClient, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={"baz": 1}, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + built_bot = Bot( + collectors=[HandlerCollector()], + bot_accounts=[bot_account], + httpx_client=httpx_client, + ) + + built_bot.call_foo_bar = types.MethodType(call_foo_bar, built_bot) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + foo_bar = await bot.call_foo_bar(bot_id, baz=1, wait_callback=False) + task = asyncio.create_task(bot.wait_botx_method_callback(foo_bar, None)) + + # Return control to event loop + await asyncio.sleep(0) + + bot.set_raw_botx_method_result( + { + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "status": "ok", + "result": {}, + }, + ) + + callback = await task + + # - Assert - + assert callback == BotAPIMethodSuccessfulCallback( + sync_id=foo_bar, + status="ok", + result={}, + ) + assert endpoint.called diff --git a/tests/client/test_botx_method_stream.py b/tests/client/test_botx_method_stream.py new file mode 100644 index 00000000..829939a2 --- /dev/null +++ b/tests/client/test_botx_method_stream.py @@ -0,0 +1,137 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from aiofiles.tempfile import NamedTemporaryFile +from respx.router import MockRouter + +from botx import BotAccountWithSecret, InvalidBotXStatusCodeError +from botx.async_buffer import AsyncBufferWritable +from botx.bot.bot_accounts_storage import BotAccountsStorage +from botx.client.botx_method import BotXMethod, response_exception_thrower +from botx.client.exceptions.base import BaseClientError +from tests.client.test_botx_method import BotXAPIFooBarRequestPayload + + +class FooBarError(BaseClientError): + """Test exception.""" + + +class FooBarStreamMethod(BotXMethod): + status_handlers = { + 403: response_exception_thrower(FooBarError), + } + + async def execute( + self, + payload: BotXAPIFooBarRequestPayload, + async_buffer: AsyncBufferWritable, + ) -> None: + path = "/foo/bar" + + async with self._botx_method_stream( + "GET", + self._build_url(path), + params=payload.jsonable_dict(), + ) as response: + async for chunk in response.aiter_bytes(): + await async_buffer.write(chunk) + + await async_buffer.seek(0) + + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__botx_method_stream__invalid_botx_status_code_error_raised( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + endpoint = respx_mock.get(f"https://{host}/foo/bar", params={"baz": 1}).mock( + return_value=httpx.Response(HTTPStatus.METHOD_NOT_ALLOWED), + ) + + method = FooBarStreamMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account]), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + with pytest.raises(InvalidBotXStatusCodeError) as exc: + await method.execute(payload, async_buffer) + + # - Assert - + assert "failed with code 405" in str(exc.value) + assert endpoint.called + + +async def test__botx_method_stream__status_handler_called( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + endpoint = respx_mock.get(f"https://{host}/foo/bar", params={"baz": 1}).mock( + return_value=httpx.Response(HTTPStatus.FORBIDDEN), + ) + + method = FooBarStreamMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account]), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + with pytest.raises(FooBarError) as exc: + await method.execute(payload, async_buffer) + + # - Assert - + assert "403" in str(exc.value) + assert endpoint.called + + +async def test__botx_method_stream__succeed( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, +) -> None: + # - Arrange - + endpoint = respx_mock.get(f"https://{host}/foo/bar", params={"baz": 1}).mock( + return_value=httpx.Response( + HTTPStatus.OK, + content=b"Hello, world!\n", + ), + ) + + method = FooBarStreamMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account]), + ) + payload = BotXAPIFooBarRequestPayload.from_domain(baz=1) + + # - Act - + await method.execute(payload, async_buffer) + + # - Assert - + assert await async_buffer.read() == b"Hello, world!\n" + assert endpoint.called diff --git a/tests/client/test_botx_method_undefined_cleaned.py b/tests/client/test_botx_method_undefined_cleaned.py new file mode 100644 index 00000000..ed4b418c --- /dev/null +++ b/tests/client/test_botx_method_undefined_cleaned.py @@ -0,0 +1,113 @@ +from http import HTTPStatus +from typing import Any, Dict, Literal +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import BotAccountWithSecret +from botx.bot.bot_accounts_storage import BotAccountsStorage +from botx.client.botx_method import BotXMethod +from botx.missing import Undefined +from botx.models.api_base import UnverifiedPayloadBaseModel, VerifiedPayloadBaseModel + + +class BotXAPIFooBarRequestPayload(UnverifiedPayloadBaseModel): + baz: Dict[str, Any] + + @classmethod + def from_domain(cls, baz: Dict[str, Any]) -> "BotXAPIFooBarRequestPayload": + return cls(baz=baz) + + +class BotXAPIFooBarResponsePayload(VerifiedPayloadBaseModel): + status: Literal["ok"] + + +class FooBarMethod(BotXMethod): + async def execute( + self, + payload: BotXAPIFooBarRequestPayload, + ) -> None: + path = "/foo/bar" + + response = await self._botx_method_call( + "POST", + self._build_url(path), + json=payload.jsonable_dict(), + ) + + self._verify_and_extract_api_model( + BotXAPIFooBarResponsePayload, + response, + ) + + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__botx_method__undefined_cleaned( + httpx_client: httpx.AsyncClient, + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/foo/bar", + json={ + "baz": { + "key": [ + { + "key1": "value", + "key3": [1, 2, 3], + "key4": {"key1": "value"}, + "key7": {}, + "key8": [], + }, + ], + }, + }, + headers={"Content-Type": "application/json"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={"status": "ok"}, + ), + ) + + method = FooBarMethod( + bot_id, + httpx_client, + BotAccountsStorage([bot_account]), + ) + payload = BotXAPIFooBarRequestPayload.from_domain( + baz={ + "key": [ + { + "key1": "value", + "key2": Undefined, + "key3": [Undefined, 1, 2, Undefined, 3], + "key4": {"key1": "value", "key2": Undefined}, + "key5": [Undefined, Undefined], + "key6": {"key1": Undefined, "key2": Undefined}, + "key7": {}, + "key8": [], + }, + { + "key": Undefined, + }, + ], + }, + ) + + # - Act - + await method.execute(payload) + + # - Assert - + assert endpoint.called diff --git a/tests/test_clients/test_methods/__init__.py b/tests/client/users_api/__init__.py similarity index 100% rename from tests/test_clients/test_methods/__init__.py rename to tests/client/users_api/__init__.py diff --git a/tests/client/users_api/test_search_user_by_email.py b/tests/client/users_api/test_search_user_by_email.py new file mode 100644 index 00000000..ccaf71ba --- /dev/null +++ b/tests/client/users_api/test_search_user_by_email.py @@ -0,0 +1,113 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + UserFromSearch, + UserNotFoundError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__search_user_by_email__user_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/users/by_email", + headers={"Authorization": "Bearer token"}, + params={"email": "ad_user@cts.com"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "user_not_found", + "errors": [], + "error_data": {}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UserNotFoundError) as exc: + await bot.search_user_by_email( + bot_id=bot_id, + email="ad_user@cts.com", + ) + + # - Assert - + assert "user_not_found" in str(exc.value) + assert endpoint.called + + +async def test__search_user_by_email__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/users/by_email", + headers={"Authorization": "Bearer token"}, + params={"email": "ad_user@cts.com"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "ad_login": "ad_user_login", + "ad_domain": "cts.com", + "name": "Bob", + "company": "Bobs Co", + "company_position": "Director", + "department": "Owners", + "emails": ["ad_user@cts.com"], + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + user = await bot.search_user_by_email( + bot_id=bot_id, + email="ad_user@cts.com", + ) + + # - Assert - + assert user == UserFromSearch( + huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + ad_login="ad_user_login", + ad_domain="cts.com", + username="Bob", + company="Bobs Co", + company_position="Director", + department="Owners", + emails=["ad_user@cts.com"], + ) + + assert endpoint.called diff --git a/tests/client/users_api/test_search_user_by_huid.py b/tests/client/users_api/test_search_user_by_huid.py new file mode 100644 index 00000000..aebcca8f --- /dev/null +++ b/tests/client/users_api/test_search_user_by_huid.py @@ -0,0 +1,113 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + UserFromSearch, + UserNotFoundError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__search_user_by_huid__user_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/users/by_huid", + headers={"Authorization": "Bearer token"}, + params={"user_huid": "f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "user_not_found", + "errors": [], + "error_data": {}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UserNotFoundError) as exc: + await bot.search_user_by_huid( + bot_id=bot_id, + huid=UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"), + ) + + # - Assert - + assert "user_not_found" in str(exc.value) + assert endpoint.called + + +async def test__search_user_by_huid__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/users/by_huid", + headers={"Authorization": "Bearer token"}, + params={"user_huid": "f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "user_huid": "f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1", + "ad_login": "ad_user_login", + "ad_domain": "cts.com", + "name": "Bob", + "company": "Bobs Co", + "company_position": "Director", + "department": "Owners", + "emails": ["ad_user@cts.com"], + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + user = await bot.search_user_by_huid( + bot_id=bot_id, + huid=UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"), + ) + + # - Assert - + assert user == UserFromSearch( + huid=UUID("f837dff4-d3ad-4b8d-a0a3-5c6ca9c747d1"), + ad_login="ad_user_login", + ad_domain="cts.com", + username="Bob", + company="Bobs Co", + company_position="Director", + department="Owners", + emails=["ad_user@cts.com"], + ) + + assert endpoint.called diff --git a/tests/client/users_api/test_search_user_by_login.py b/tests/client/users_api/test_search_user_by_login.py new file mode 100644 index 00000000..3a2ec0e2 --- /dev/null +++ b/tests/client/users_api/test_search_user_by_login.py @@ -0,0 +1,115 @@ +from http import HTTPStatus +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + UserFromSearch, + UserNotFoundError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__search_user_by_ad__user_not_found_error_raised( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/users/by_login", + headers={"Authorization": "Bearer token"}, + params={"ad_login": "ad_user_login", "ad_domain": "cts.com"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.NOT_FOUND, + json={ + "status": "error", + "reason": "user_not_found", + "errors": [], + "error_data": {}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UserNotFoundError) as exc: + await bot.search_user_by_ad( + bot_id=bot_id, + ad_login="ad_user_login", + ad_domain="cts.com", + ) + + # - Assert - + assert "user_not_found" in str(exc.value) + assert endpoint.called + + +async def test__search_user_by_ad__succeed( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/users/by_login", + headers={"Authorization": "Bearer token"}, + params={"ad_login": "ad_user_login", "ad_domain": "cts.com"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": { + "user_huid": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "ad_login": "ad_user_login", + "ad_domain": "cts.com", + "name": "Bob", + "company": "Bobs Co", + "company_position": "Director", + "department": "Owners", + "emails": ["ad_user@cts.com"], + }, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + user = await bot.search_user_by_ad( + bot_id=bot_id, + ad_login="ad_user_login", + ad_domain="cts.com", + ) + + # - Assert - + assert user == UserFromSearch( + huid=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + ad_login="ad_user_login", + ad_domain="cts.com", + username="Bob", + company="Bobs Co", + company_position="Director", + department="Owners", + emails=["ad_user@cts.com"], + ) + + assert endpoint.called diff --git a/tests/conftest.py b/tests/conftest.py index 71bbddc9..5ec584e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,252 @@ -pytest_plugins = ( - "tests.fixtures.bot", - "tests.fixtures.collector", - "tests.fixtures.handlers", - "tests.fixtures.storage", - "tests.fixtures.credentials", - "tests.fixtures.logging", - "tests.fixtures.messages", - "tests.fixtures.errors", +import logging +from datetime import datetime +from http import HTTPStatus +from typing import Any, AsyncGenerator, Callable, Dict, Generator, List, Optional +from unittest.mock import Mock +from uuid import UUID, uuid4 + +import httpx +import pytest +from aiofiles.tempfile import NamedTemporaryFile +from pydantic import BaseModel +from respx.router import MockRouter + +from botx import ( + BotAccount, + BotAccountWithSecret, + Chat, + ChatTypes, + IncomingMessage, + UserDevice, + UserSender, ) +from botx.bot.bot_accounts_storage import BotAccountsStorage +from botx.logger import logger + + +@pytest.fixture(autouse=True) +def enable_logger() -> None: + logger.enable("botx") + + +@pytest.fixture +def prepared_bot_accounts_storage( + bot_id: UUID, + bot_account: BotAccountWithSecret, +) -> BotAccountsStorage: + bot_accounts_storage = BotAccountsStorage([bot_account]) + bot_accounts_storage.set_token(bot_id, "token") + + return bot_accounts_storage + + +@pytest.fixture +def datetime_formatter() -> Callable[[str], datetime]: + class DateTimeFormatter(BaseModel): # noqa: WPS431 + value: datetime + + def factory(dt_str: str) -> datetime: + return DateTimeFormatter(value=dt_str).value + + return factory + + +@pytest.fixture +def host() -> str: + return "cts.example.com" + + +@pytest.fixture +def bot_id() -> UUID: + return UUID("24348246-6791-4ac0-9d86-b948cd6a0e46") + + +@pytest.fixture +def bot_account(host: str, bot_id: UUID) -> BotAccountWithSecret: + return BotAccountWithSecret( + id=bot_id, + host=host, + secret_key="bee001", + ) + + +@pytest.fixture +def bot_signature() -> str: + return "E050AEEA197E0EF0A6E1653E18B7D41C7FDEC0FCFBA44C44FCCD2A88CEABD130" + + +@pytest.fixture +def mock_authorization( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_signature: str, +) -> None: + """Fixture should be used as a marker.""" + respx_mock.get( + f"https://{host}/api/v2/botx/bots/{bot_id}/token", + params={"signature": bot_signature}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + json={ + "status": "ok", + "result": "token", + }, + ), + ) + + +@pytest.hookimpl(trylast=True) +def pytest_collection_modifyitems(items: List[pytest.Function]) -> None: + for item in items: + if item.get_closest_marker("mock_authorization"): + item.fixturenames.append("mock_authorization") + + +@pytest.fixture() +def loguru_caplog( + caplog: pytest.LogCaptureFixture, +) -> Generator[pytest.LogCaptureFixture, None, None]: + # https://github.com/Delgan/loguru/issues/59 + + class PropogateHandler(logging.Handler): # noqa: WPS431 + def emit(self, record: logging.LogRecord) -> None: + logging.getLogger(record.name).handle(record) + + handler_id = logger.add(PropogateHandler(), format="{message}") + yield caplog + logger.remove(handler_id) + + +@pytest.fixture +async def httpx_client() -> AsyncGenerator[httpx.AsyncClient, None]: + async with httpx.AsyncClient() as client: + yield client + + +@pytest.fixture +async def async_buffer() -> AsyncGenerator[NamedTemporaryFile, None]: + async with NamedTemporaryFile("wb+") as async_buffer: + yield async_buffer + + +@pytest.fixture +def api_incoming_message_factory() -> Callable[..., Dict[str, Any]]: + def decorator( + *, + bot_id: Optional[UUID] = None, + group_chat_id: Optional[UUID] = None, + user_huid: Optional[UUID] = None, + host: Optional[str] = None, + attachment: Optional[Dict[str, Any]] = None, + async_file: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + return { + "bot_id": str(bot_id) if bot_id else "24348246-6791-4ac0-9d86-b948cd6a0e46", + "command": { + "body": "/hello", + "command_type": "user", + "data": {}, + "metadata": {}, + }, + "attachments": [attachment] if attachment else [], + "async_files": [async_file] if async_file else [], + "source_sync_id": None, + "sync_id": "6f40a492-4b5f-54f3-87ee-77126d825b51", + "from": { + "ad_domain": None, + "ad_login": None, + "app_version": None, + "chat_type": "chat", + "device": None, + "device_meta": { + "permissions": None, + "pushes": False, + "timezone": "Europe/Moscow", + }, + "device_software": None, + "group_chat_id": ( + str(group_chat_id) + if group_chat_id + else "30dc1980-643a-00ad-37fc-7cc10d74e935" + ), + "host": host or "cts.example.com", + "is_admin": True, + "is_creator": True, + "locale": "en", + "manufacturer": None, + "platform": None, + "platform_package_id": None, + "user_huid": ( + str(user_huid) + if user_huid + else "f16cdc5f-6366-5552-9ecd-c36290ab3d11" + ), + "username": None, + }, + "proto_version": 4, + "entities": [], + } + + return decorator + + +@pytest.fixture +def incoming_message_factory( + bot_id: UUID, +) -> Callable[..., IncomingMessage]: + def decorator( + *, + body: str = "", + ad_login: Optional[str] = None, + ad_domain: Optional[str] = None, + ) -> IncomingMessage: + return IncomingMessage( + bot=BotAccount( + id=bot_id, + host="cts.example.com", + ), + sync_id=uuid4(), + source_sync_id=None, + body=body, + data={}, + metadata={}, + sender=UserSender( + huid=uuid4(), + ad_login=ad_login, + ad_domain=ad_domain, + username=None, + is_chat_admin=True, + is_chat_creator=True, + device=UserDevice( + manufacturer=None, + device_name=None, + os=None, + pushes=None, + timezone=None, + permissions=None, + platform=None, + platform_package_id=None, + app_version=None, + locale=None, + ), + ), + chat=Chat( + id=uuid4(), + type=ChatTypes.PERSONAL_CHAT, + ), + raw_command=None, + ) + + return decorator + + +@pytest.fixture +def correct_handler_trigger() -> Mock: + return Mock() + + +@pytest.fixture +def incorrect_handler_trigger() -> Mock: + return Mock() diff --git a/tests/fixtures/bot.py b/tests/fixtures/bot.py deleted file mode 100644 index e164dd8e..00000000 --- a/tests/fixtures/bot.py +++ /dev/null @@ -1,21 +0,0 @@ -import pytest - -from botx import Bot, BotXCredentials, TestClient - - -@pytest.fixture() -def bot(host, secret_key, bot_id, token): - accounts = BotXCredentials( - host=host, - secret_key=secret_key, - bot_id=bot_id, - token=token, - ) - - return Bot(bot_accounts=[accounts]) - - -@pytest.fixture() -def client(bot): - with TestClient(bot) as client: - yield client diff --git a/tests/fixtures/collector.py b/tests/fixtures/collector.py deleted file mode 100644 index 851be7a3..00000000 --- a/tests/fixtures/collector.py +++ /dev/null @@ -1,75 +0,0 @@ -from typing import Callable, Optional - -import pytest - -from botx import Collector, Depends, Message - - -def build_botx_handler(name: Optional[str] = None) -> Callable[[], None]: - def factory(_message: Message): - """Just do nothing.""" - - factory.__name__ = name or factory.__name__ - return factory - - -@pytest.fixture() -def build_handler_for_collector(): - return build_botx_handler - - -@pytest.fixture() -def collector_with_handlers(build_handler_for_collector): - collector = Collector() - collector.handler(build_handler_for_collector("regular_handler")) - collector.handler( - build_handler_for_collector("regular_handler_with_command"), - command="/handler-command", - ) - collector.handler( - build_handler_for_collector("regular_handler_with_command_aliases"), - commands=["/handler-command1", "/handler-command2"], - ) - collector.handler( - build_handler_for_collector("regular_handler_with_command_and_command_aliases"), - command="/handler-command3", - commands=["/handler-command4", "/handler-command5"], - ) - collector.handler( - build_handler_for_collector("regular_handler_with_custom_name"), - name="regular-handler-with-name", - ) - collector.handler( - build_handler_for_collector("regular_handler_with_background_dependencies"), - dependencies=[Depends(build_handler_for_collector("background_dependency"))], - ) - collector.handler( - build_handler_for_collector( - "regular_handler_that_excluded_from_status_and_auto_body", - ), - include_in_status=False, - ) - collector.handler( - build_handler_for_collector( - "regular_handler_that_excluded_from_status_and_passed_body", - ), - command="regular-handler-with-excluding-from-status", - include_in_status=False, - ) - collector.handler( - build_handler_for_collector( - "regular_handler_that_included_in_status_by_callable_function", - ), - include_in_status=lambda *_: True, - ) - collector.handler( - build_handler_for_collector( - "regular_handler_that_excluded_from_status_by_callable_function", - ), - include_in_status=lambda *_: False, - ) - collector.default(build_handler_for_collector("default_handler")) - collector.hidden(build_handler_for_collector("hidden_handler")) - collector.chat_created(build_handler_for_collector("chat_created_handler")) - collector.file_transfer(build_handler_for_collector("file_transfer_handler")) - return collector diff --git a/tests/fixtures/credentials.py b/tests/fixtures/credentials.py deleted file mode 100644 index 69e5bf08..00000000 --- a/tests/fixtures/credentials.py +++ /dev/null @@ -1,23 +0,0 @@ -import uuid - -import pytest - - -@pytest.fixture() -def host(): - return "cts.example.com" - - -@pytest.fixture() -def secret_key(): - return "secret-key-for-token" - - -@pytest.fixture() -def token(): - return "generated-token-for-bot" - - -@pytest.fixture() -def bot_id(): - return uuid.uuid4() diff --git a/tests/fixtures/errors.py b/tests/fixtures/errors.py deleted file mode 100644 index 840f6067..00000000 --- a/tests/fixtures/errors.py +++ /dev/null @@ -1,18 +0,0 @@ -import threading - -import pytest - -from botx import Message - - -@pytest.fixture() -def build_exception_catcher(storage): - def factory(event: threading.Event): - def decorator(exc: Exception, msg: Message): - event.set() - storage.exception = exc - storage.message = msg - - return decorator - - return factory diff --git a/tests/fixtures/handlers.py b/tests/fixtures/handlers.py deleted file mode 100644 index ec916dbc..00000000 --- a/tests/fixtures/handlers.py +++ /dev/null @@ -1,28 +0,0 @@ -import threading -from typing import Callable - -import pytest - - -def build_botx_handler(event: threading.Event) -> Callable[[], None]: - def factory(): - event.set() - - return factory - - -@pytest.fixture() -def build_handler(): - return build_botx_handler - - -@pytest.fixture() -def build_failed_handler(): - def factory(exception: Exception, event: threading.Event): - def decorator(): - event.set() - raise exception - - return decorator - - return factory diff --git a/tests/fixtures/logging.py b/tests/fixtures/logging.py deleted file mode 100644 index 03769385..00000000 --- a/tests/fixtures/logging.py +++ /dev/null @@ -1,20 +0,0 @@ -import logging - -import pytest -from loguru import logger - - -@pytest.fixture() -def _enable_logger(): - logger.enable("botx") - - -@pytest.fixture() -def loguru_caplog(caplog, _enable_logger): - class PropogateHandler(logging.Handler): - def emit(self, record: logging.LogRecord) -> None: - logging.getLogger(record.name).handle(record) - - handler_id = logger.add(PropogateHandler(), format="{message}") - yield caplog - logger.remove(handler_id) diff --git a/tests/fixtures/messages.py b/tests/fixtures/messages.py deleted file mode 100644 index 21152cfd..00000000 --- a/tests/fixtures/messages.py +++ /dev/null @@ -1,72 +0,0 @@ -import pytest - -from botx import ( - ChatCreatedEvent, - InternalBotNotificationEvent, - InternalBotNotificationPayload, - Message, - MessageBuilder, - UserKinds, -) -from botx.models.events import UserInChatCreated - - -@pytest.fixture() -def incoming_message(host, bot_id): - builder = MessageBuilder() - builder.bot_id = bot_id - builder.user.host = host - return builder.message - - -@pytest.fixture() -def message(incoming_message, bot): - return Message.from_dict(incoming_message.dict(), bot) - - -@pytest.fixture() -def chat_created_message(host, bot_id): - builder = MessageBuilder() - builder.bot_id = bot_id - builder.command_data = ChatCreatedEvent( - group_chat_id=builder.user.group_chat_id, - chat_type=builder.user.chat_type, - name="chat", - creator=builder.user.user_huid, - members=[ - UserInChatCreated( - huid=builder.user.user_huid, - user_kind=UserKinds.user, - name=builder.user.username, - admin=True, - ), - UserInChatCreated( - huid=builder.bot_id, - user_kind=UserKinds.bot, - name="bot", - admin=False, - ), - ], - ) - builder.user.user_huid = None - builder.user.ad_login = None - builder.user.ad_domain = None - builder.user.username = None - - builder.body = "system:chat_created" - builder.system_command = True - - return builder.message - - -@pytest.fixture() -def internal_bot_notification_message(host, bot_id, bot): - builder = MessageBuilder() - builder.bot_id = bot_id - builder.command_data = InternalBotNotificationEvent( - data=InternalBotNotificationPayload(message="ping"), # noqa: WPS110 - opts={}, - ) - builder.body = "system:internal_bot_notification" - builder.system_command = True - return Message.from_dict(builder.message.dict(), bot) diff --git a/tests/fixtures/smartapps.py b/tests/fixtures/smartapps.py deleted file mode 100644 index 00763dae..00000000 --- a/tests/fixtures/smartapps.py +++ /dev/null @@ -1,34 +0,0 @@ -from typing import Dict -from uuid import UUID, uuid4 - -import pytest - - -@pytest.fixture() -def smartapp_api_version() -> int: - return 1 - - -@pytest.fixture() -def smartapp_counter() -> int: - return 42 - - -@pytest.fixture() -def smartapp_id() -> UUID: - return uuid4() - - -@pytest.fixture() -def group_chat_id() -> UUID: - return uuid4() - - -@pytest.fixture() -def ref() -> UUID: - return uuid4() - - -@pytest.fixture() -def smartapp_data() -> Dict[str, str]: - return {"key": "value"} diff --git a/tests/fixtures/storage.py b/tests/fixtures/storage.py deleted file mode 100644 index bfb2ef67..00000000 --- a/tests/fixtures/storage.py +++ /dev/null @@ -1,8 +0,0 @@ -import pytest - -from botx.models.datastructures import State - - -@pytest.fixture() -def storage(): - return State() diff --git a/tests/test_clients/test_methods/test_base/__init__.py b/tests/models/__init__.py similarity index 100% rename from tests/test_clients/test_methods/test_base/__init__.py rename to tests/models/__init__.py diff --git a/tests/models/test_incoming_message.py b/tests/models/test_incoming_message.py new file mode 100644 index 00000000..081848aa --- /dev/null +++ b/tests/models/test_incoming_message.py @@ -0,0 +1,101 @@ +from typing import Callable, Tuple + +import pytest + +from botx import IncomingMessage + + +def test__upn_property__not_filled( + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + # - Arrange - + message = incoming_message_factory() + + # - Assert - + assert message.sender.upn is None + + +def test__upn_property__filled( + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + # - Arrange - + message = incoming_message_factory(ad_login="login", ad_domain="domain") + + # - Assert - + assert message.sender.upn == "login@domain" + + +@pytest.mark.parametrize( + "body,argument_answer", + [ + ("", ""), + ("/command", ""), + ], +) +def test__argument__not_filled( + incoming_message_factory: Callable[..., IncomingMessage], + body: str, + argument_answer: str, +) -> None: + # - Arrange - + message = incoming_message_factory(body=body) + + # - Assert - + assert message.argument == argument_answer + + +@pytest.mark.parametrize( + "body,argument_answer", + [ + ("/command arg1 ", "arg1"), + ("/command arg1 arg2", "arg1 arg2"), + ], +) +def test__argument__filled( + incoming_message_factory: Callable[..., IncomingMessage], + body: str, + argument_answer: str, +) -> None: + # - Arrange - + message = incoming_message_factory(body=body) + + # - Assert - + assert message.argument == argument_answer + + +@pytest.mark.parametrize( + "body,argument_answer", + [ + ("", ()), + ("/command", ()), + ], +) +def test__arguments__not_filled( + incoming_message_factory: Callable[..., IncomingMessage], + body: str, + argument_answer: Tuple[str, ...], +) -> None: + # - Arrange - + message = incoming_message_factory(body=body) + + # - Assert - + assert message.arguments == argument_answer + + +@pytest.mark.parametrize( + "body,argument_answer", + [ + ("/command arg1 ", ("arg1",)), + ("/command arg1 arg2", ("arg1", "arg2")), + ], +) +def test__arguments__filled( + incoming_message_factory: Callable[..., IncomingMessage], + body: str, + argument_answer: Tuple[str, ...], +) -> None: + # - Arrange - + message = incoming_message_factory(body=body) + + # - Assert - + assert message.arguments == argument_answer diff --git a/tests/models/test_mentions_list.py b/tests/models/test_mentions_list.py new file mode 100644 index 00000000..17cf5707 --- /dev/null +++ b/tests/models/test_mentions_list.py @@ -0,0 +1,88 @@ +from typing import Callable, Optional +from uuid import UUID, uuid4 + +import pytest + +from botx import Mention, MentionList, MentionTypes + + +@pytest.fixture +def mention_factory() -> Callable[..., Mention]: + def factory( + mention_type: MentionTypes, + huid: UUID, + name: Optional[str] = None, + ) -> Mention: + return Mention( + type=mention_type, + entity_id=huid, + name=name, + ) + + return factory + + +def test__mentions_list_properties__filled( + mention_factory: Callable[..., Mention], +) -> None: + # - Arrange - + contacts = [ + mention_factory( + mention_type=MentionTypes.CONTACT, + huid=uuid4(), + name=str(name), + ) + for name in range(2) + ] + chats = [ + mention_factory( + mention_type=MentionTypes.CHAT, + huid=uuid4(), + name=str(name), + ) + for name in range(2) + ] + channels = [ + mention_factory( + mention_type=MentionTypes.CHANNEL, + huid=uuid4(), + name=str(name), + ) + for name in range(2) + ] + users = [ + mention_factory( + mention_type=MentionTypes.USER, + huid=uuid4(), + name=str(name), + ) + for name in range(2) + ] + + mentions = MentionList([*contacts, *chats, *channels, *users]) + + # - Assert - + assert mentions.contacts == contacts + assert mentions.chats == chats + assert mentions.channels == channels + assert mentions.users == users + + +def test__mentions_list_all_users_mentioned__filled( + mention_factory: Callable[..., Mention], +) -> None: + # - Arrange - + user_mention = mention_factory( + mention_type=MentionTypes.CONTACT, + huid=uuid4(), + ) + all_mention = Mention(type=MentionTypes.ALL) + + one_all_mention = MentionList([user_mention, all_mention]) + two_all_mentions = MentionList([all_mention, all_mention]) + + # - Assert - + assert one_all_mention.all_users_mentioned + assert two_all_mentions.all_users_mentioned + + assert not MentionList([]).all_users_mentioned diff --git a/tests/models/test_status_recipient.py b/tests/models/test_status_recipient.py new file mode 100644 index 00000000..5d3f8c2a --- /dev/null +++ b/tests/models/test_status_recipient.py @@ -0,0 +1,24 @@ +from typing import Callable + +from botx import IncomingMessage, StatusRecipient + + +def test__status_recipient__from_message( + incoming_message_factory: Callable[..., IncomingMessage], +) -> None: + # - Arrange - + incoming_message = incoming_message_factory( + ad_login="test_login", + ad_domain="test_domain", + ) + status_recipient = StatusRecipient.from_incoming_message(incoming_message) + + # - Assert - + assert status_recipient == StatusRecipient( + bot_id=incoming_message.bot.id, + huid=incoming_message.sender.huid, + ad_login=incoming_message.sender.ad_login, + ad_domain=incoming_message.sender.ad_domain, + is_admin=incoming_message.sender.is_chat_admin, + chat_type=incoming_message.chat.type, + ) diff --git a/tests/system_events/test_added_to_chat.py b/tests/system_events/test_added_to_chat.py new file mode 100644 index 00000000..6788e60e --- /dev/null +++ b/tests/system_events/test_added_to_chat.py @@ -0,0 +1,100 @@ +from typing import Optional +from uuid import UUID + +import pytest + +from botx import ( + AddedToChatEvent, + Bot, + BotAccount, + BotAccountWithSecret, + Chat, + ChatTypes, + HandlerCollector, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__added_to_chat__succeed( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = { + "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "command": { + "body": "system:added_to_chat", + "command_type": "system", + "data": { + "added_members": [ + "ab103983-6001-44e9-889e-d55feb295494", + "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + ], + }, + "metadata": {}, + }, + "source_sync_id": None, + "sync_id": "2c1a31d6-f47f-5f54-aee2-d0c526bb1d54", + "from": { + "ad_domain": None, + "ad_login": None, + "app_version": None, + "chat_type": "group_chat", + "device": None, + "device_meta": { + "permissions": None, + "pushes": None, + "timezone": None, + }, + "device_software": None, + "group_chat_id": "dea55ee4-7a9f-5da0-8c73-079f400ee517", + "host": "cts.example.com", + "is_admin": None, + "is_creator": None, + "locale": "en", + "manufacturer": None, + "platform": None, + "platform_package_id": None, + "user_huid": None, + "username": None, + }, + "proto_version": 4, + } + + collector = HandlerCollector() + added_to_chat: Optional[AddedToChatEvent] = None + + @collector.added_to_chat + async def added_to_chat_handler(event: AddedToChatEvent, bot: Bot) -> None: + nonlocal added_to_chat + added_to_chat = event + # Drop `raw_command` from asserting + added_to_chat.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert added_to_chat == AddedToChatEvent( + bot=BotAccount( + id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"), + host="cts.example.com", + ), + raw_command=None, + huids=[ + UUID("ab103983-6001-44e9-889e-d55feb295494"), + UUID("dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4"), + ], + chat=Chat( + id=UUID("dea55ee4-7a9f-5da0-8c73-079f400ee517"), + type=ChatTypes.GROUP_CHAT, + ), + ) diff --git a/tests/system_events/test_chat_created.py b/tests/system_events/test_chat_created.py new file mode 100644 index 00000000..806d3338 --- /dev/null +++ b/tests/system_events/test_chat_created.py @@ -0,0 +1,129 @@ +from typing import Optional +from uuid import UUID + +import pytest + +from botx import ( + Bot, + BotAccount, + BotAccountWithSecret, + Chat, + ChatCreatedEvent, + ChatCreatedMember, + ChatTypes, + HandlerCollector, + UserKinds, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__chat_created__succeed( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = { + "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "command": { + "body": "system:chat_created", + "command_type": "system", + "data": { + "chat_type": "group_chat", + "creator": "83fbf1c7-f14b-5176-bd32-ca15cf00d4b7", + "group_chat_id": "dea55ee4-7a9f-5da0-8c73-079f400ee517", + "members": [ + { + "admin": True, + "huid": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "name": "Feature bot", + "user_kind": "botx", + }, + { + "admin": False, + "huid": "83fbf1c7-f14b-5176-bd32-ca15cf00d4b7", + "name": "Ivanov Ivan Ivanovich", + "user_kind": "cts_user", + }, + ], + "name": "Feature-party", + }, + "metadata": {}, + }, + "source_sync_id": None, + "sync_id": "2c1a31d6-f47f-5f54-aee2-d0c526bb1d54", + "from": { + "ad_domain": None, + "ad_login": None, + "app_version": None, + "chat_type": "group_chat", + "device": None, + "device_meta": { + "permissions": None, + "pushes": None, + "timezone": None, + }, + "device_software": None, + "group_chat_id": "dea55ee4-7a9f-5da0-8c73-079f400ee517", + "host": "cts.example.com", + "is_admin": None, + "is_creator": None, + "locale": "en", + "manufacturer": None, + "platform": None, + "platform_package_id": None, + "user_huid": None, + "username": None, + }, + "proto_version": 4, + } + + collector = HandlerCollector() + chat_created: Optional[ChatCreatedEvent] = None + + @collector.chat_created + async def chat_created_handler(event: ChatCreatedEvent, bot: Bot) -> None: + nonlocal chat_created + chat_created = event + # Drop `raw_command` from asserting + chat_created.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert chat_created == ChatCreatedEvent( + sync_id=UUID("2c1a31d6-f47f-5f54-aee2-d0c526bb1d54"), + bot=BotAccount( + id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"), + host="cts.example.com", + ), + chat_name="Feature-party", + chat=Chat( + id=UUID("dea55ee4-7a9f-5da0-8c73-079f400ee517"), + type=ChatTypes.GROUP_CHAT, + ), + creator_id=UUID("83fbf1c7-f14b-5176-bd32-ca15cf00d4b7"), + members=[ + ChatCreatedMember( + is_admin=True, + huid=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"), + username="Feature bot", + kind=UserKinds.BOT, + ), + ChatCreatedMember( + is_admin=False, + huid=UUID("83fbf1c7-f14b-5176-bd32-ca15cf00d4b7"), + username="Ivanov Ivan Ivanovich", + kind=UserKinds.CTS_USER, + ), + ], + raw_command=None, + ) diff --git a/tests/system_events/test_cts_login.py b/tests/system_events/test_cts_login.py new file mode 100644 index 00000000..5c3effef --- /dev/null +++ b/tests/system_events/test_cts_login.py @@ -0,0 +1,89 @@ +from typing import Optional +from uuid import UUID + +import pytest + +from botx import ( + Bot, + BotAccount, + BotAccountWithSecret, + CTSLoginEvent, + HandlerCollector, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__cts_login__succeed( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = { + "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "command": { + "body": "system:cts_login", + "data": { + "user_huid": "b9197d3a-d855-5d34-ba8a-eff3a975ab20", + "cts_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + }, + "command_type": "system", + "metadata": {}, + }, + "source_sync_id": None, + "sync_id": "2c1a31d6-f47f-5f54-aee2-d0c526bb1d54", + "from": { + "ad_domain": None, + "ad_login": None, + "app_version": None, + "chat_type": None, + "device": None, + "device_meta": { + "permissions": None, + "pushes": None, + "timezone": None, + }, + "device_software": None, + "group_chat_id": None, + "host": "cts.example.com", + "is_admin": None, + "is_creator": None, + "locale": "en", + "manufacturer": None, + "platform": None, + "platform_package_id": None, + "user_huid": None, + "username": None, + }, + "proto_version": 4, + } + + collector = HandlerCollector() + cts_login: Optional[CTSLoginEvent] = None + + @collector.cts_login + async def cts_login_handler(event: CTSLoginEvent, bot: Bot) -> None: + nonlocal cts_login + cts_login = event + # Drop `raw_command` from asserting + cts_login.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert cts_login == CTSLoginEvent( + bot=BotAccount( + id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"), + host="cts.example.com", + ), + raw_command=None, + huid=UUID("b9197d3a-d855-5d34-ba8a-eff3a975ab20"), + ) diff --git a/tests/system_events/test_cts_logout.py b/tests/system_events/test_cts_logout.py new file mode 100644 index 00000000..13363bdc --- /dev/null +++ b/tests/system_events/test_cts_logout.py @@ -0,0 +1,89 @@ +from typing import Optional +from uuid import UUID + +import pytest + +from botx import ( + Bot, + BotAccount, + BotAccountWithSecret, + CTSLogoutEvent, + HandlerCollector, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__cts_logout__succeed( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = { + "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "command": { + "body": "system:cts_logout", + "data": { + "user_huid": "b9197d3a-d855-5d34-ba8a-eff3a975ab20", + "cts_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + }, + "command_type": "system", + "metadata": {}, + }, + "source_sync_id": None, + "sync_id": "2c1a31d6-f47f-5f54-aee2-d0c526bb1d54", + "from": { + "ad_domain": None, + "ad_login": None, + "app_version": None, + "chat_type": None, + "device": None, + "device_meta": { + "permissions": None, + "pushes": None, + "timezone": None, + }, + "device_software": None, + "group_chat_id": None, + "host": "cts.example.com", + "is_admin": None, + "is_creator": None, + "locale": "en", + "manufacturer": None, + "platform": None, + "platform_package_id": None, + "user_huid": None, + "username": None, + }, + "proto_version": 4, + } + + collector = HandlerCollector() + cts_logout: Optional[CTSLogoutEvent] = None + + @collector.cts_logout + async def cts_logout_handler(event: CTSLogoutEvent, bot: Bot) -> None: + nonlocal cts_logout + cts_logout = event + # Drop `raw_command` from asserting + cts_logout.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert cts_logout == CTSLogoutEvent( + bot=BotAccount( + id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"), + host="cts.example.com", + ), + raw_command=None, + huid=UUID("b9197d3a-d855-5d34-ba8a-eff3a975ab20"), + ) diff --git a/tests/system_events/test_deleted_from_chat.py b/tests/system_events/test_deleted_from_chat.py new file mode 100644 index 00000000..4f93fa30 --- /dev/null +++ b/tests/system_events/test_deleted_from_chat.py @@ -0,0 +1,99 @@ +from typing import Optional +from uuid import UUID + +import pytest + +from botx import ( + Bot, + BotAccount, + BotAccountWithSecret, + Chat, + ChatTypes, + DeletedFromChatEvent, + HandlerCollector, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__deleted_from_chat__succeed( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = { + "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "command": { + "body": "system:deleted_from_chat", + "command_type": "system", + "data": { + "deleted_members": [ + "ab103983-6001-44e9-889e-d55feb295494", + "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + ], + }, + }, + "source_sync_id": None, + "sync_id": "2c1a31d6-f47f-5f54-aee2-d0c526bb1d54", + "from": { + "ad_domain": None, + "ad_login": None, + "app_version": None, + "chat_type": "group_chat", + "device": None, + "device_meta": { + "permissions": None, + "pushes": None, + "timezone": None, + }, + "device_software": None, + "group_chat_id": "dea55ee4-7a9f-5da0-8c73-079f400ee517", + "host": "cts.example.com", + "is_admin": None, + "is_creator": None, + "locale": "en", + "manufacturer": None, + "platform": None, + "platform_package_id": None, + "user_huid": None, + "username": None, + }, + "proto_version": 4, + } + + collector = HandlerCollector() + deleted_from_chat: Optional[DeletedFromChatEvent] = None + + @collector.deleted_from_chat + async def deleted_from_chat_handler(event: DeletedFromChatEvent, bot: Bot) -> None: + nonlocal deleted_from_chat + deleted_from_chat = event + # Drop `raw_command` from asserting + deleted_from_chat.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert deleted_from_chat == DeletedFromChatEvent( + bot=BotAccount( + id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"), + host="cts.example.com", + ), + raw_command=None, + huids=[ + UUID("ab103983-6001-44e9-889e-d55feb295494"), + UUID("dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4"), + ], + chat=Chat( + id=UUID("dea55ee4-7a9f-5da0-8c73-079f400ee517"), + type=ChatTypes.GROUP_CHAT, + ), + ) diff --git a/tests/system_events/test_internal_bot_notification.py b/tests/system_events/test_internal_bot_notification.py new file mode 100644 index 00000000..f39b636f --- /dev/null +++ b/tests/system_events/test_internal_bot_notification.py @@ -0,0 +1,108 @@ +from typing import Optional +from uuid import UUID + +import pytest + +from botx import ( + Bot, + BotAccount, + BotAccountWithSecret, + BotSender, + Chat, + ChatTypes, + HandlerCollector, + InternalBotNotificationEvent, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__internal_bot_notification__succeed( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = { + "sync_id": "a465f0f3-1354-491c-8f11-f400164295cb", + "command": { + "body": "system:internal_bot_notification", + "data": { + "data": { + "message": "ping", + }, + "opts": { + "internal_token": "KyKfLJD1zMjNSJ1cQ4+8Lz", + }, + }, + "command_type": "system", + "metadata": {}, + }, + "async_files": [], + "attachments": [], + "entities": [], + "from": { + "user_huid": "b9197d3a-d855-5d34-ba8a-eff3a975ab20", + "group_chat_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + "ad_login": None, + "ad_domain": None, + "username": None, + "chat_type": "group_chat", + "manufacturer": None, + "device": None, + "device_software": None, + "device_meta": {}, + "platform": None, + "platform_package_id": None, + "is_admin": False, + "is_creator": False, + "app_version": None, + "locale": "en", + "host": "cts.example.com", + }, + "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "proto_version": 4, + "source_sync_id": None, + } + + collector = HandlerCollector() + internal_bot_notification: Optional[InternalBotNotificationEvent] = None + + @collector.internal_bot_notification + async def internal_bot_notification_handler( + event: InternalBotNotificationEvent, + bot: Bot, + ) -> None: + nonlocal internal_bot_notification + internal_bot_notification = event + # Drop `raw_command` from asserting + internal_bot_notification.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert internal_bot_notification == InternalBotNotificationEvent( + bot=BotAccount( + id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"), + host="cts.example.com", + ), + raw_command=None, + data={"message": "ping"}, + opts={"internal_token": "KyKfLJD1zMjNSJ1cQ4+8Lz"}, + chat=Chat( + id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), + type=ChatTypes.GROUP_CHAT, + ), + sender=BotSender( + huid=UUID("b9197d3a-d855-5d34-ba8a-eff3a975ab20"), + is_chat_admin=False, + is_chat_creator=False, + ), + ) diff --git a/tests/system_events/test_left_from_chat.py b/tests/system_events/test_left_from_chat.py new file mode 100644 index 00000000..918ce34b --- /dev/null +++ b/tests/system_events/test_left_from_chat.py @@ -0,0 +1,99 @@ +from typing import Optional +from uuid import UUID + +import pytest + +from botx import ( + Bot, + BotAccount, + BotAccountWithSecret, + Chat, + ChatTypes, + HandlerCollector, + LeftFromChatEvent, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__left_from_chat__succeed( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = { + "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "command": { + "body": "system:left_from_chat", + "command_type": "system", + "data": { + "left_members": [ + "ab103983-6001-44e9-889e-d55feb295494", + "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", + ], + }, + }, + "source_sync_id": None, + "sync_id": "2c1a31d6-f47f-5f54-aee2-d0c526bb1d54", + "from": { + "ad_domain": None, + "ad_login": None, + "app_version": None, + "chat_type": "group_chat", + "device": None, + "device_meta": { + "permissions": None, + "pushes": None, + "timezone": None, + }, + "device_software": None, + "group_chat_id": "dea55ee4-7a9f-5da0-8c73-079f400ee517", + "host": "cts.example.com", + "is_admin": None, + "is_creator": None, + "locale": "en", + "manufacturer": None, + "platform": None, + "platform_package_id": None, + "user_huid": None, + "username": None, + }, + "proto_version": 4, + } + + collector = HandlerCollector() + left_from_chat: Optional[LeftFromChatEvent] = None + + @collector.left_from_chat + async def left_from_chat_handler(event: LeftFromChatEvent, bot: Bot) -> None: + nonlocal left_from_chat + left_from_chat = event + # Drop `raw_command` from asserting + left_from_chat.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert left_from_chat == LeftFromChatEvent( + bot=BotAccount( + id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"), + host="cts.example.com", + ), + raw_command=None, + huids=[ + UUID("ab103983-6001-44e9-889e-d55feb295494"), + UUID("dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4"), + ], + chat=Chat( + id=UUID("dea55ee4-7a9f-5da0-8c73-079f400ee517"), + type=ChatTypes.GROUP_CHAT, + ), + ) diff --git a/tests/system_events/test_smartapp_event.py b/tests/system_events/test_smartapp_event.py new file mode 100644 index 00000000..72dcabb4 --- /dev/null +++ b/tests/system_events/test_smartapp_event.py @@ -0,0 +1,163 @@ +from typing import Optional +from uuid import UUID + +import pytest + +from botx import ( + AttachmentTypes, + Bot, + BotAccount, + BotAccountWithSecret, + HandlerCollector, + Image, + SmartAppEvent, + lifespan_wrapper, +) +from botx.models.chats import Chat +from botx.models.enums import ChatTypes +from botx.models.message.incoming_message import UserDevice, UserSender + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__smartapp__succeed( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = { + "sync_id": "a465f0f3-1354-491c-8f11-f400164295cb", + "command": { + "body": "system:smartapp_event", + "data": { + "ref": "6fafda2c-6505-57a5-a088-25ea5d1d0364", + "smartapp_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + "data": { + "type": "smartapp_rpc", + "method": "folders.get", + "params": { + "q": 1, + }, + }, + "opts": {"option": "test_option"}, + "smartapp_api_version": 1, + }, + "command_type": "system", + "metadata": {}, + }, + "async_files": [ + { + "type": "image", + "file": "https://link.to/file", + "file_mime_type": "image/png", + "file_name": "pass.png", + "file_preview": "https://link.to/preview", + "file_preview_height": 300, + "file_preview_width": 300, + "file_size": 1502345, + "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + "file_encryption_algo": "stream", + "chunk_size": 2097152, + "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + }, + ], + "attachments": [], + "entities": [], + "from": { + "user_huid": "b9197d3a-d855-5d34-ba8a-eff3a975ab20", + "group_chat_id": "dea55ee4-7a9f-5da0-8c73-079f400ee517", + "host": "cts.example.com", + "ad_login": None, + "ad_domain": None, + "username": None, + "chat_type": "group_chat", + "manufacturer": None, + "device": None, + "device_software": None, + "device_meta": {}, + "platform": None, + "platform_package_id": None, + "is_admin": False, + "is_creator": False, + "app_version": None, + "locale": "en", + }, + "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "proto_version": 4, + "source_sync_id": None, + } + + collector = HandlerCollector() + smartapp: Optional[SmartAppEvent] = None + + @collector.smartapp_event + async def smartapp_handler(event: SmartAppEvent, bot: Bot) -> None: + nonlocal smartapp + smartapp = event + # Drop `raw_command` from asserting + smartapp.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert smartapp == SmartAppEvent( + ref=UUID("6fafda2c-6505-57a5-a088-25ea5d1d0364"), + smartapp_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), + bot=BotAccount( + id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"), + host="cts.example.com", + ), + data={ + "type": "smartapp_rpc", + "method": "folders.get", + "params": { + "q": 1, + }, + }, + opts={"option": "test_option"}, + smartapp_api_version=1, + files=[ + Image( + type=AttachmentTypes.IMAGE, + filename="pass.png", + size=1502345, + is_async_file=True, + _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), + _file_url="https://link.to/file", + _file_mimetype="image/png", + _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + ), + ], + chat=Chat( + id=UUID("dea55ee4-7a9f-5da0-8c73-079f400ee517"), + type=ChatTypes.GROUP_CHAT, + ), + sender=UserSender( + huid=UUID("b9197d3a-d855-5d34-ba8a-eff3a975ab20"), + ad_login=None, + ad_domain=None, + username=None, + is_chat_admin=False, + is_chat_creator=False, + device=UserDevice( + manufacturer=None, + device_name=None, + os=None, + pushes=None, + timezone=None, + permissions=None, + platform=None, + platform_package_id=None, + app_version=None, + locale="en", + ), + ), + raw_command=None, + ) diff --git a/tests/test_attachments.py b/tests/test_attachments.py new file mode 100644 index 00000000..add65590 --- /dev/null +++ b/tests/test_attachments.py @@ -0,0 +1,272 @@ +import asyncio +from typing import Any, Callable, Dict, Optional +from uuid import UUID + +import pytest + +from botx import ( + AttachmentTypes, + Bot, + BotAccountWithSecret, + HandlerCollector, + IncomingMessage, + lifespan_wrapper, +) +from botx.models.attachments import ( + AttachmentContact, + AttachmentDocument, + AttachmentImage, + AttachmentLink, + AttachmentLocation, + AttachmentVideo, + AttachmentVoice, + IncomingAttachment, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__attachment__open( + host: str, + bot_account: BotAccountWithSecret, + bot_id: UUID, + api_incoming_message_factory: Callable[..., Dict[str, Any]], +) -> None: + # - Arrange - + payload = api_incoming_message_factory( + bot_id=bot_id, + attachment={ + "data": { + "content": "data:image/jpg;base64,SGVsbG8sIHdvcmxkIQo=", + "file_name": "test_file.jpg", + }, + "type": "image", + }, + group_chat_id="054af49e-5e18-4dca-ad73-4f96b6de63fa", + host=host, + ) + collector = HandlerCollector() + incoming_message: Optional[IncomingMessage] = None + + @collector.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + nonlocal incoming_message + incoming_message = message + + # Drop `raw_command` from asserting + incoming_message.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + await asyncio.sleep(0) # Return control to event loop + + assert incoming_message and incoming_message.file + async with incoming_message.file.open() as fo: + read_content = await fo.read() + + # - Assert - + assert read_content == b"Hello, world!\n" + + +API_AND_DOMAIN_NON_FILE_ATTACHMENTS = ( + ( + { + "type": "location", + "data": { + "location_name": "Центр вселенной", + "location_address": "Россия, Тверская область", + "location_lat": 58.04861, + "location_lng": 34.28833, + }, + }, + AttachmentLocation( + type=AttachmentTypes.LOCATION, + name="Центр вселенной", + address="Россия, Тверская область", + latitude="58.04861", + longitude="34.28833", + ), + "location", + ), + ( + { + "type": "contact", + "data": { + "file_name": "Контакт", + "contact_name": "Иванов Иван", + "content": "data:text/vcard;base64,eDnXAc1FEUB0VFEFctII3lRlRBcetROeFfduPmXxE/8=", + }, + }, + AttachmentContact( + type=AttachmentTypes.CONTACT, + name="Иванов Иван", + ), + "contact", + ), + ( + { + "type": "link", + "data": { + "url": "http://ya.ru/xxx", + "url_title": "Header in link", + "url_preview": "http://ya.ru/xxx.jpg", + "url_text": "Some text in link", + }, + }, + AttachmentLink( + type=AttachmentTypes.LINK, + url="http://ya.ru/xxx", + title="Header in link", + preview="http://ya.ru/xxx.jpg", + text="Some text in link", + ), + "link", + ), +) + + +@pytest.mark.parametrize( + "api_attachment,domain_attachment,attr_name", + API_AND_DOMAIN_NON_FILE_ATTACHMENTS, +) +async def test__async_execute_raw_bot_command__non_file_attachments_types( + api_attachment: Dict[str, Any], + domain_attachment: IncomingAttachment, + attr_name: str, + api_incoming_message_factory: Callable[..., Dict[str, Any]], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = api_incoming_message_factory(attachment=api_attachment) + + collector = HandlerCollector() + incoming_message: Optional[IncomingMessage] = None + + @collector.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + nonlocal incoming_message + incoming_message = message + # Drop `raw_command` from asserting + incoming_message.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert getattr(incoming_message, attr_name) == domain_attachment + + +API_AND_DOMAIN_FILE_ATTACHMENTS = ( + ( + { + "data": { + "content": "data:image/jpg;base64,SGVsbG8sIHdvcmxkIQo=", + "file_name": "test_file.jpg", + }, + "type": "image", + }, + AttachmentImage( + type=AttachmentTypes.IMAGE, + filename="test_file.jpg", + size=len(b"Hello, world!\n"), + is_async_file=False, + content=b"Hello, world!\n", + ), + ), + ( + { + "data": { + "content": "data:video/mp4;base64,SGVsbG8sIHdvcmxkIQo=", + "file_name": "test_file.mp4", + "duration": 10, + }, + "type": "video", + }, + AttachmentVideo( + type=AttachmentTypes.VIDEO, + filename="test_file.mp4", + size=len(b"Hello, world!\n"), + is_async_file=False, + content=b"Hello, world!\n", + duration=10, + ), + ), + ( + { + "data": { + "content": "data:text/plain;base64,SGVsbG8sIHdvcmxkIQo=", + "file_name": "test_file.txt", + }, + "type": "document", + }, + AttachmentDocument( + type=AttachmentTypes.DOCUMENT, + filename="test_file.txt", + size=len(b"Hello, world!\n"), + is_async_file=False, + content=b"Hello, world!\n", + ), + ), + ( + { + "data": { + "content": "data:audio/mpeg3;base64,SGVsbG8sIHdvcmxkIQo=", + "duration": 10, + }, + "type": "voice", + }, + AttachmentVoice( + type=AttachmentTypes.VOICE, + filename="record.mp3", + size=len(b"Hello, world!\n"), + is_async_file=False, + content=b"Hello, world!\n", + duration=10, + ), + ), +) + + +@pytest.mark.parametrize( + "api_attachment,domain_attachment", + API_AND_DOMAIN_FILE_ATTACHMENTS, +) +async def test__async_execute_raw_bot_command__file_attachments_types( + api_attachment: Dict[str, Any], + domain_attachment: IncomingAttachment, + api_incoming_message_factory: Callable[..., Dict[str, Any]], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = api_incoming_message_factory(attachment=api_attachment) + + collector = HandlerCollector() + incoming_message: Optional[IncomingMessage] = None + + @collector.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + nonlocal incoming_message + incoming_message = message + # Drop `raw_command` from asserting + incoming_message.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert incoming_message + assert incoming_message.file == domain_attachment diff --git a/tests/test_base_command.py b/tests/test_base_command.py new file mode 100644 index 00000000..2e7086f6 --- /dev/null +++ b/tests/test_base_command.py @@ -0,0 +1,38 @@ +import pytest + +from botx import Bot, HandlerCollector, UnsupportedBotAPIVersionError, lifespan_wrapper + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__async_execute_raw_bot_command__invalid_payload_value_error_raised() -> None: + # - Arrange - + payload = {"invalid": "command"} + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(ValueError) as exc: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert "validation" in str(exc.value) + + +async def test__async_execute_raw_bot_command__unsupported_bot_api_version_error_raised() -> None: + # - Arrange - + payload = {"proto_version": "3"} + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnsupportedBotAPIVersionError) as exc: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert "Unsupported" in str(exc.value) + assert "expected `4`" in str(exc.value) diff --git a/tests/test_bot_constructing.py b/tests/test_bot_constructing.py new file mode 100644 index 00000000..41ebedd0 --- /dev/null +++ b/tests/test_bot_constructing.py @@ -0,0 +1,24 @@ +import pytest + +from botx import Bot, BotAccountWithSecret, HandlerCollector + + +def test__bot__empty_collectors_warning( + loguru_caplog: pytest.LogCaptureFixture, + bot_account: BotAccountWithSecret, +) -> None: + # - Act - + Bot(collectors=[], bot_accounts=[bot_account]) + + # - Assert - + assert "Bot has no connected collectors" in loguru_caplog.text + + +def test__bot__empty_bot_accounts_warning( + loguru_caplog: pytest.LogCaptureFixture, +) -> None: + # - Act - + Bot(collectors=[HandlerCollector()], bot_accounts=[]) + + # - Assert - + assert "Bot has no bot accounts" in loguru_caplog.text diff --git a/tests/test_bots/test_bots/test_decorators/test_exception_handler.py b/tests/test_bots/test_bots/test_decorators/test_exception_handler.py deleted file mode 100644 index ff8ecdad..00000000 --- a/tests/test_bots/test_bots/test_decorators/test_exception_handler.py +++ /dev/null @@ -1,21 +0,0 @@ -import threading - -import pytest - -pytestmark = pytest.mark.asyncio - - -async def test_register_middleware_through_decorator( - bot, - client, - incoming_message, - build_failed_handler, - build_exception_catcher, -): - exception_event = threading.Event() - bot.exception_handler(Exception)(build_exception_catcher(exception_event)) - bot.default(build_failed_handler(Exception(), threading.Event())) - - await client.send_command(incoming_message) - - assert exception_event.is_set() diff --git a/tests/test_bots/test_bots/test_decorators/test_middleware.py b/tests/test_bots/test_bots/test_decorators/test_middleware.py deleted file mode 100644 index 390fde05..00000000 --- a/tests/test_bots/test_bots/test_decorators/test_middleware.py +++ /dev/null @@ -1,17 +0,0 @@ -import pytest - -from botx import Message -from botx.typing import SyncExecutor - - -@pytest.fixture() -def middleware_function(): - def factory(_message: Message, _call_next: SyncExecutor): - """Do nothing.""" - - return factory - - -def test_register_middleware_through_decorator(bot, middleware_function): - bot.middleware(middleware_function) - assert bot.exception_middleware.executor.dispatch_func == middleware_function diff --git a/tests/test_bots/test_bots/test_execution.py b/tests/test_bots/test_bots/test_execution.py deleted file mode 100644 index db0f9f38..00000000 --- a/tests/test_bots/test_bots/test_execution.py +++ /dev/null @@ -1,24 +0,0 @@ -import threading - -import pytest - -pytestmark = pytest.mark.asyncio - - -async def test_bot_process_message_by_sorted_handlers( - bot, - client, - incoming_message, - build_handler, -): - event1 = threading.Event() - event2 = threading.Event() - - bot.handler(build_handler(event1), command="/body") - bot.handler(build_handler(event2), command="/body-v2") - - incoming_message.command.body = "/body-v2 args" - await client.send_command(incoming_message) - - assert not event1.is_set() - assert event2.is_set() diff --git a/tests/test_bots/test_bots/test_execution_errors.py b/tests/test_bots/test_bots/test_execution_errors.py deleted file mode 100644 index d93c6d35..00000000 --- a/tests/test_bots/test_bots/test_execution_errors.py +++ /dev/null @@ -1,29 +0,0 @@ -import uuid - -import pytest - -from botx import BotXCredentials, UnknownBotError - -pytestmark = pytest.mark.asyncio - - -async def test_error_when_execution_for_unknown_host( - bot, - client, - incoming_message, - bot_id, -): - bot.bot_accounts = [ - BotXCredentials( - host="cts.unknown1.com", - secret_key="secret", - bot_id=uuid.uuid4(), - ), - BotXCredentials( - host="cts.unknown2.com", - secret_key="secret", - bot_id=uuid.uuid4(), - ), - ] - with pytest.raises(UnknownBotError): - await client.send_command(incoming_message) diff --git a/tests/test_bots/test_bots/test_lifespan.py b/tests/test_bots/test_bots/test_lifespan.py deleted file mode 100644 index ee04fd83..00000000 --- a/tests/test_bots/test_bots/test_lifespan.py +++ /dev/null @@ -1,38 +0,0 @@ -import threading -from typing import Callable - -import pytest - -from botx import Bot - -pytestmark = pytest.mark.asyncio - - -def build_lifespan_event(event: threading.Event) -> Callable[[Bot], None]: - def factory(_bot): - event.set() - - return factory - - -@pytest.fixture() -def build_lifespan(): - return build_lifespan_event - - -async def test_lifespan_events(bot, build_lifespan): - startup_event = threading.Event() - shutdown_event = threading.Event() - - bot.startup_events = [build_lifespan(startup_event)] - bot.shutdown_events = [build_lifespan(shutdown_event)] - - await bot.start() - assert startup_event.is_set() - - await bot.shutdown() - assert shutdown_event.is_set() - - -async def test_no_error_when_stopping_bot_with_no_tasks(bot): - await bot.shutdown() diff --git a/tests/test_bots/test_bots/test_status.py b/tests/test_bots/test_bots/test_status.py deleted file mode 100644 index 2bbef91d..00000000 --- a/tests/test_bots/test_bots/test_status.py +++ /dev/null @@ -1,21 +0,0 @@ -import pytest - -pytestmark = pytest.mark.asyncio - - -async def test_returning_bot_commands_status(bot, collector_with_handlers): - bot.include_collector(collector_with_handlers) - status = await bot.status() - commands = [command.body for command in status.result.commands] - assert commands == [ - "/regular-handler", - "/handler-command", - "/handler-command1", - "/handler-command2", - "/handler-command3", - "/handler-command4", - "/handler-command5", - "/regular-handler-with-name", - "/regular-handler-with-background-dependencies", - "/regular-handler-that-included-in-status-by-callable-function", - ] diff --git a/tests/test_bots/test_mixins/test_clients.py b/tests/test_bots/test_mixins/test_clients.py deleted file mode 100644 index d918ad0f..00000000 --- a/tests/test_bots/test_mixins/test_clients.py +++ /dev/null @@ -1,38 +0,0 @@ -import uuid - -import pytest - -from botx import BotXCredentials -from botx.exceptions import TokenError, UnknownBotError - -pytestmark = pytest.mark.asyncio - - -def test_raising_error_if_token_was_not_found(client, incoming_message): - account = client.bot.get_account_by_bot_id(incoming_message.bot_id) - account.token = None - with pytest.raises(TokenError): - client.bot.get_token_for_bot(incoming_message.bot_id) - - -def test_get_token_to_bot(client, incoming_message): - account = client.bot.get_account_by_bot_id(incoming_message.bot_id) - account.token = "token" - assert client.bot.get_token_for_bot(incoming_message.bot_id) is not None - - -def test_raising_error_if_cts_not_found(bot, incoming_message): - bot.bot_accounts = [ - BotXCredentials( - host="cts.unknown1.com", - secret_key="secret", - bot_id=uuid.uuid4(), - ), - BotXCredentials( - host="cts.unknown2.com", - secret_key="secret", - bot_id=uuid.uuid4(), - ), - ] - with pytest.raises(UnknownBotError): - bot.get_account_by_bot_id(incoming_message.bot_id) diff --git a/tests/test_bots/test_mixins/test_requests/test_chats.py b/tests/test_bots/test_mixins/test_requests/test_chats.py deleted file mode 100644 index 71244fa7..00000000 --- a/tests/test_bots/test_mixins/test_requests/test_chats.py +++ /dev/null @@ -1,121 +0,0 @@ -import uuid - -import pytest - -from botx import ChatTypes -from botx.clients.methods.v3.chats.chat_list import ChatList - -pytestmark = pytest.mark.asyncio - - -async def test_creating_chat(client, message): - await client.bot.create_chat( - message.credentials, - name="test", - members=[message.user_huid], - chat_type=ChatTypes.group_chat, - ) - - assert client.requests[0].name == "test" - - -async def test_enable_stealth_mode(bot, client, message): - await bot.enable_stealth_mode( - message.credentials, - chat_id=message.group_chat_id, - burn_in=60, - ) - - assert client.requests[0].burn_in == 60 - - -async def test_disable_stealth_mode(bot, client, message): - await bot.disable_stealth_mode( - message.credentials, - chat_id=message.group_chat_id, - ) - - assert client.requests[0].group_chat_id == message.group_chat_id - - -async def test_adding_user_to_chat(bot, client, message): - users = [uuid.uuid4()] - await bot.add_users( - message.credentials, - chat_id=message.group_chat_id, - user_huids=users, - ) - request = client.requests[0] - - assert request.group_chat_id == message.group_chat_id - - assert request.user_huids == users - - -async def test_remove_user(bot, client, message): - users = [uuid.uuid4()] - await bot.remove_users( - message.credentials, - chat_id=message.group_chat_id, - user_huids=users, - ) - request = client.requests[0] - - assert request.group_chat_id == message.group_chat_id - - assert request.user_huids == users - - -async def test_retrieving_chat_info(bot, client, message): - chat_id = uuid.uuid4() - info = await bot.get_chat_info(message.credentials, chat_id=chat_id) - - assert info.group_chat_id == chat_id - - -async def test_retrieving_bot_chats(bot, client, message): - await bot.get_bot_chats(message.credentials) - request = client.requests[0] - - assert isinstance(request, ChatList) - - -async def test_promoting_users_to_admins(bot, client, message): - users = [uuid.uuid4()] - await bot.add_admin_roles( - message.credentials, - chat_id=message.group_chat_id, - user_huids=users, - ) - request = client.requests[0] - - assert request.group_chat_id == message.group_chat_id - - assert request.user_huids == users - - -async def test_pinning_message(bot, client, message): - chat_id = uuid.uuid4() - sync_id = uuid.uuid4() - - await bot.pin_message( - message.credentials, - chat_id=chat_id, - sync_id=sync_id, - ) - request = client.requests[0] - - assert request.chat_id == chat_id - assert request.sync_id == sync_id - - -async def test_unpinning_message(bot, client, message): - chat_id = uuid.uuid4() - - await bot.unpin_message( - message.credentials, - chat_id=chat_id, - ) - request = client.requests[0] - - assert request.chat_id == chat_id diff --git a/tests/test_bots/test_mixins/test_requests/test_command.py b/tests/test_bots/test_mixins/test_requests/test_command.py deleted file mode 100644 index ce94e37c..00000000 --- a/tests/test_bots/test_mixins/test_requests/test_command.py +++ /dev/null @@ -1,14 +0,0 @@ -import pytest - -from botx import MessagePayload - -pytestmark = pytest.mark.asyncio - - -async def test_command_result(client, message): - await client.bot.send_command_result( - credentials=message.credentials, - payload=MessagePayload(text="some text"), - ) - - assert client.command_results[0].result.body == "some text" diff --git a/tests/test_bots/test_mixins/test_requests/test_events.py b/tests/test_bots/test_mixins/test_requests/test_events.py deleted file mode 100644 index 7c70aea0..00000000 --- a/tests/test_bots/test_mixins/test_requests/test_events.py +++ /dev/null @@ -1,69 +0,0 @@ -import pytest - -from botx import SendingMessage, UpdatePayload - -pytestmark = pytest.mark.asyncio - - -async def test_updating_message_through_bot(bot, client, message): - sync_id = await bot.answer_message("some text", message) - - await bot.update_message( - message.credentials.copy(update={"sync_id": sync_id}), - UpdatePayload(text="new text"), - ) - - update = client.message_updates[0].result - assert update.body == "new text" - - -async def test_update_metadata(bot, client, message): - msg = SendingMessage.from_message(text="some text", message=message) - msg.metadata = {"hello": "world"} - await bot.send(msg) - - upd = UpdatePayload.from_sending_payload(msg.payload) - upd.metadata = {"foo": "bar"} - - await bot.update_message(message.credentials, upd) - - update = client.message_updates[0].result - assert update.metadata == {"foo": "bar"} - - -async def test_cant_update_without_sync_id(bot, client, message): - credentials = message.credentials.copy(update={"sync_id": None}) - - with pytest.raises(ValueError) as exc: - await bot.update_message(credentials, UpdatePayload(text="new text")) - - assert "sync_id is required" in str(exc.value) - - -async def test_reply(bot, client, message): - await bot.reply( - text="foo", - source_sync_id=message.sync_id, - credentials=message.credentials, - ) - - reply = client.replies[0] - assert reply.result.body == "foo" - assert reply.source_sync_id == message.sync_id - - -async def test_reply_on_message_empty_text_error(bot, message): - with pytest.raises(ValueError): - await bot.reply( - text="", - source_sync_id=message.sync_id, - credentials=message.credentials, - ) - - -async def test_reply_arguments_error(bot, message): - with pytest.raises(ValueError): - await bot.reply( - source_sync_id=message.sync_id, - credentials=message.credentials, - ) diff --git a/tests/test_bots/test_mixins/test_requests/test_files.py b/tests/test_bots/test_mixins/test_requests/test_files.py deleted file mode 100644 index cccdda76..00000000 --- a/tests/test_bots/test_mixins/test_requests/test_files.py +++ /dev/null @@ -1,38 +0,0 @@ -from uuid import uuid4 - -import pytest - -from botx.clients.methods.v3.files.download import DownloadFile -from botx.clients.methods.v3.files.upload import UploadFile -from botx.models.files import File -from botx.testing.content import PNG_DATA - -pytestmark = pytest.mark.asyncio - - -async def test_upload_file(client, message): - image = File(file_name="image.png", data=PNG_DATA) - await client.bot.upload_file(message.credentials, image, group_chat_id=uuid4()) - - assert isinstance(client.requests[0], UploadFile) - - -async def test_download_file(client, message): - await client.bot.download_file( - message.credentials, - file_id=uuid4(), - group_chat_id=uuid4(), - ) - - assert isinstance(client.requests[0], DownloadFile) - - -async def test_custom_filename(client, message): - file = await client.bot.download_file( - message.credentials, - file_id=uuid4(), - group_chat_id=uuid4(), - file_name="myname", - ) - - assert file.file_name == "myname.txt" diff --git a/tests/test_bots/test_mixins/test_requests/test_internal_bot_notification.py b/tests/test_bots/test_mixins/test_requests/test_internal_bot_notification.py deleted file mode 100644 index 92cab4cf..00000000 --- a/tests/test_bots/test_mixins/test_requests/test_internal_bot_notification.py +++ /dev/null @@ -1,18 +0,0 @@ -import uuid - -import pytest - -pytestmark = pytest.mark.asyncio - - -async def test_internal_bot_notification(client, message): - await client.bot.internal_bot_notification( - credentials=message.credentials, - group_chat_id=uuid.uuid4(), - text="ping", - sender=None, - recipients=None, - opts=None, - ) - - assert client.messages[0].data.message == "ping" diff --git a/tests/test_bots/test_mixins/test_requests/test_notification.py b/tests/test_bots/test_mixins/test_requests/test_notification.py deleted file mode 100644 index 78b1e444..00000000 --- a/tests/test_bots/test_mixins/test_requests/test_notification.py +++ /dev/null @@ -1,45 +0,0 @@ -import pytest - -from botx import MessagePayload - -pytestmark = pytest.mark.asyncio - - -async def test_filling_with_chat_id_from_credentials(client, message): - await client.bot.send_notification( - credentials=message.credentials, - payload=MessagePayload(text="some text"), - ) - - assert client.notifications[0].result.body == "some text" - - -async def test_filling_with_ids_if_passed(client, message): - await client.bot.send_notification( - message.credentials, - group_chat_ids=[message.user.group_chat_id], - payload=MessagePayload(text="some text"), - ) - - assert client.notifications[0].result.body == "some text" - - -async def test_send_to_all_if_ids_omitted(client, message): - text = "some text" - credentials = message.credentials.copy(update={"chat_id": None}) - - await client.bot.send_notification(credentials, MessagePayload(text=text)) - - assert client.notifications[0].result.body == text - - -async def test_direct_notification_chat_id_required(client, message): - credentials = message.credentials.copy(update={"chat_id": None}) - - with pytest.raises(ValueError) as exc: - await client.bot.send_direct_notification( - credentials, - payload=MessagePayload(text="some text"), - ) - - assert "chat_id is required" in str(exc.value) diff --git a/tests/test_bots/test_mixins/test_requests/test_smartapps.py b/tests/test_bots/test_mixins/test_requests/test_smartapps.py deleted file mode 100644 index 1cde5887..00000000 --- a/tests/test_bots/test_mixins/test_requests/test_smartapps.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Any, Dict -from uuid import UUID - -import pytest - -from botx import Message, TestClient -from botx.clients.methods.v3.smartapps.smartapp_event import SmartAppEvent -from botx.clients.methods.v3.smartapps.smartapp_notification import SmartAppNotification -from botx.models.smartapps import SendingSmartAppEvent - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures", "tests.fixtures.smartapps") - - -async def test_smartapp_event( - client: TestClient, - message: Message, - ref: UUID, - smartapp_id: UUID, - smartapp_api_version: int, - group_chat_id: UUID, - smartapp_data: Dict[str, Any], -): - await client.bot.send_smartapp_event( - credentials=message.credentials, - smartapp_event=SendingSmartAppEvent( - ref=ref, - smartapp_id=smartapp_id, - smartapp_api_version=smartapp_api_version, - group_chat_id=group_chat_id, - data=smartapp_data, - ), - ) - - assert client.requests[0] == SmartAppEvent( - ref=ref, - smartapp_id=smartapp_id, - smartapp_api_version=smartapp_api_version, - group_chat_id=group_chat_id, - data=smartapp_data, - ) - - -async def test_smartapp_notification( - client: TestClient, - message: Message, - smartapp_api_version: int, - group_chat_id: UUID, - smartapp_counter: int, -): - await client.bot.send_smartapp_notification( - credentials=message.credentials, - smartapp_notification=SmartAppNotification( - smartapp_api_version=smartapp_api_version, - group_chat_id=group_chat_id, - smartapp_counter=smartapp_counter, - ), - ) - - assert client.requests[0] == SmartAppNotification( - smartapp_api_version=smartapp_api_version, - group_chat_id=group_chat_id, - smartapp_counter=smartapp_counter, - ) diff --git a/tests/test_bots/test_mixins/test_requests/test_stickers.py b/tests/test_bots/test_mixins/test_requests/test_stickers.py deleted file mode 100644 index 0b6ba33c..00000000 --- a/tests/test_bots/test_mixins/test_requests/test_stickers.py +++ /dev/null @@ -1,76 +0,0 @@ -from uuid import uuid4 - -import pytest - -from botx.clients.methods.v3.stickers.add_sticker import AddSticker -from botx.clients.methods.v3.stickers.create_sticker_pack import CreateStickerPack -from botx.clients.methods.v3.stickers.delete_sticker import DeleteSticker -from botx.clients.methods.v3.stickers.delete_sticker_pack import DeleteStickerPack -from botx.clients.methods.v3.stickers.edit_sticker_pack import EditStickerPack -from botx.clients.methods.v3.stickers.sticker import GetSticker -from botx.clients.methods.v3.stickers.sticker_pack import GetStickerPack -from botx.clients.methods.v3.stickers.sticker_pack_list import GetStickerPackList -from botx.testing.content import PNG_DATA - -pytestmark = pytest.mark.asyncio - - -async def test_get_sticker_pack_list(client, message): - await client.bot.get_sticker_pack_list(message.credentials) - assert isinstance(client.requests[0], GetStickerPackList) - - -async def test_get_sticker_pack(client, message): - await client.bot.get_sticker_pack(message.credentials, pack_id=uuid4()) - assert isinstance(client.requests[0], GetStickerPack) - - -async def test_get_sticker_from_pack(client, message): - await client.bot.get_sticker_from_pack( - message.credentials, - pack_id=uuid4(), - sticker_id=uuid4(), - ) - assert isinstance(client.requests[0], GetSticker) - - -async def test_create_sticker_pack(client, message): - await client.bot.create_sticker_pack( - message.credentials, - name="Test sticker pack", - user_huid=uuid4(), - ) - assert isinstance(client.requests[0], CreateStickerPack) - - -async def test_add_sticker_into_pack(client, message): - await client.bot.add_sticker( - message.credentials, - pack_id=uuid4(), - emoji="🐢", - image=PNG_DATA, - ) - assert isinstance(client.requests[0], AddSticker) - - -async def test_edit_sticker_pack(client, message): - await client.bot.edit_sticker_pack( - message.credentials, - pack_id=uuid4(), - name="New test sticker pack", - ) - assert isinstance(client.requests[0], EditStickerPack) - - -async def test_delete_sticker_pack(client, message): - await client.bot.delete_sticker_pack(message.credentials, pack_id=uuid4()) - assert isinstance(client.requests[0], DeleteStickerPack) - - -async def test_delete_sticker_from_pack(client, message): - await client.bot.delete_sticker( - message.credentials, - pack_id=uuid4(), - sticker_id=uuid4(), - ) - assert isinstance(client.requests[0], DeleteSticker) diff --git a/tests/test_bots/test_mixins/test_requests/test_users.py b/tests/test_bots/test_mixins/test_requests/test_users.py deleted file mode 100644 index 15a9a58b..00000000 --- a/tests/test_bots/test_mixins/test_requests/test_users.py +++ /dev/null @@ -1,33 +0,0 @@ -import pytest - -from botx.clients.methods.v3.users.by_email import ByEmail -from botx.clients.methods.v3.users.by_huid import ByHUID -from botx.clients.methods.v3.users.by_login import ByLogin - -pytestmark = pytest.mark.asyncio - - -async def test_search_requires_one_of_params(client, message): - with pytest.raises(ValueError): - await client.bot.search_user(message.credentials) - - -async def test_search_using_huid_method(client, message): - await client.bot.search_user(message.credentials, user_huid=message.user_huid) - - assert isinstance(client.requests[0], ByHUID) - - -async def test_search_using_email_method(client, message): - await client.bot.search_user(message.credentials, email=message.user.upn) - - assert isinstance(client.requests[0], ByEmail) - - -async def test_search_using_ad_method(client, message): - await client.bot.search_user( - message.credentials, - ad=(message.user.ad_login, message.user.ad_domain), - ) - - assert isinstance(client.requests[0], ByLogin) diff --git a/tests/test_bots/test_mixins/test_sending/test_answer_message.py b/tests/test_bots/test_mixins/test_sending/test_answer_message.py deleted file mode 100644 index d22f39d7..00000000 --- a/tests/test_bots/test_mixins/test_sending/test_answer_message.py +++ /dev/null @@ -1,32 +0,0 @@ -import pytest - -from botx import File - -pytestmark = pytest.mark.asyncio - - -async def test_answer_message_is_notification(bot, client, message): - await bot.answer_message("some text", message) - - message = client.notifications[0] - assert message.result.body == "some text" - - -async def test_answer_message_with_file_is_notification(bot, client, message): - file = File.from_string("some content", "file.txt") - await bot.answer_message( - "some text", - message, - file=file, - ) - - message = client.notifications[0] - assert message.result.body == "some text" - assert message.file == file - - -async def test_answer_message_with_metadata(bot, client, message): - await bot.answer_message("some text", message, metadata={"foo": "bar"}) - - message = client.notifications[0] - assert message.result.metadata == {"foo": "bar"} diff --git a/tests/test_bots/test_mixins/test_sending/test_errors.py b/tests/test_bots/test_mixins/test_sending/test_errors.py deleted file mode 100644 index d8a0b863..00000000 --- a/tests/test_bots/test_mixins/test_sending/test_errors.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from botx.exceptions import UnknownBotError - -pytestmark = pytest.mark.asyncio - - -async def test_error_for_sending_to_unknown_host(bot, message): - bot.bot_accounts = [] - with pytest.raises(UnknownBotError): - await bot.answer_message("some text", message) diff --git a/tests/test_bots/test_mixins/test_sending/test_send.py b/tests/test_bots/test_mixins/test_sending/test_send.py deleted file mode 100644 index b49bc7eb..00000000 --- a/tests/test_bots/test_mixins/test_sending/test_send.py +++ /dev/null @@ -1,67 +0,0 @@ -import uuid - -import pytest - -from botx import File, SendingMessage - -pytestmark = pytest.mark.asyncio - - -@pytest.fixture() -def sending_file(): - return File.from_string("some content", "file.txt") - - -@pytest.fixture() -def metadata(): - return {"account_id": 94} - - -@pytest.fixture() -def sending_message(message, metadata, sending_file): - sending_message = SendingMessage.from_message( - text="some text", - file=sending_file, - message=message, - ) - sending_message.add_keyboard_button(command="/command", label="keyboard") - sending_message.add_bubble(command="/command", label="bubble") - sending_message.metadata = metadata - return sending_message - - -async def test_using_notification_route(bot, client, sending_message): - await bot.send(sending_message) - - assert client.notifications[0] - - -async def test_sending_notification_using_send(bot, client, sending_message, metadata): - sending_message.credentials.sync_id = None - - await bot.send(sending_message) - - assert len(client.notifications) - notification = client.notifications[0] - - assert notification.result.metadata == metadata - - -async def test_sending_update_using_send(bot, client, sending_message): - sending_message.credentials.message_id = uuid.uuid4() - - await bot.send(sending_message, update=True) - - assert client.message_updates[0].sync_id == sending_message.credentials.message_id - - -async def test_returning_event_id_from_notification(bot, client, sending_message): - sending_message.credentials.sync_id = None - assert await bot.send(sending_message) - - -async def test_setting_custom_id_for_notification(bot, client, sending_message): - message_id = uuid.uuid4() - sending_message.credentials.message_id = message_id - notification_id = await bot.send(sending_message) - assert notification_id == message_id diff --git a/tests/test_bots/test_mixins/test_sending/test_send_file.py b/tests/test_bots/test_mixins/test_sending/test_send_file.py deleted file mode 100644 index d4d22ac9..00000000 --- a/tests/test_bots/test_mixins/test_sending/test_send_file.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest - -from botx import File - -pytestmark = pytest.mark.asyncio - - -@pytest.fixture() -def sending_file(): - return File.from_string("some content", "file.txt") - - -async def test_send_file_is_notification(bot, client, message, sending_file): - await bot.send_file(sending_file, message.credentials) - - message = client.notifications[0] - assert message.file == sending_file - - -async def test_using_notification(bot, client, message, sending_file): - await bot.send_file( - sending_file, - message.credentials.copy(update={"sync_id": None}), - ) - - message = client.notifications[0] - assert message.file == sending_file diff --git a/tests/test_bots/test_mixins/test_sending/test_send_message.py b/tests/test_bots/test_mixins/test_sending/test_send_message.py deleted file mode 100644 index 34c51cf2..00000000 --- a/tests/test_bots/test_mixins/test_sending/test_send_message.py +++ /dev/null @@ -1,35 +0,0 @@ -import pytest - -from botx import File - -pytestmark = pytest.mark.asyncio - - -async def test_sending_command_result(bot, client, message): - await bot.send_message( - "some text", - message.credentials, - ) - - assert client.notifications[0] - - -async def test_sending_notification_using_send_message(bot, client, message): - await bot.send_message( - "some text", - message.credentials.copy(update={"sync_id": None}), - ) - - assert client.notifications[0] - - -async def test_adding_file(bot, client, message): - sending_file = File.from_string("some content", "file.txt") - await bot.send_message( - "some text", - message.credentials, - file=sending_file.file, - ) - - command_result = client.notifications[0] - assert command_result.file == sending_file diff --git a/tests/test_clients/fixtures.py b/tests/test_clients/fixtures.py deleted file mode 100644 index cd2837d8..00000000 --- a/tests/test_clients/fixtures.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from botx import AsyncClient, Client - - -@pytest.fixture(params=(AsyncClient, Client)) -def requests_client(request, client): - if issubclass(request.param, AsyncClient): - return client.bot.client - - return client.bot.sync_client diff --git a/tests/test_clients/test_clients/test_async_client/test_execute.py b/tests/test_clients/test_clients/test_async_client/test_execute.py deleted file mode 100644 index cd0a3281..00000000 --- a/tests/test_clients/test_clients/test_async_client/test_execute.py +++ /dev/null @@ -1,51 +0,0 @@ -import uuid - -import pytest -from httpx import ConnectError, Request, Response - -from botx.clients.methods.v2.bots.token import Token -from botx.exceptions import BotXConnectError, BotXJSONDecodeError - -try: - from unittest.mock import AsyncMock -except ImportError: - from unittest.mock import MagicMock - - # Used for compatibility with python 3.7 - class AsyncMock(MagicMock): - async def __call__(self, *args, **kwargs): - return super(AsyncMock, self).__call__(*args, **kwargs) - - -@pytest.fixture() -def token_method(): - return Token(host="example.cts", bot_id=uuid.uuid4(), signature="signature") - - -@pytest.fixture() -def mock_http_client(): - return AsyncMock() - - -@pytest.mark.asyncio() -async def test_raising_connection_error(client, token_method, mock_http_client): - request = Request(token_method.http_method, token_method.url) - mock_http_client.request.side_effect = ConnectError("Test error", request=request) - - client.bot.client.http_client = mock_http_client - botx_request = client.bot.client.build_request(token_method) - - with pytest.raises(BotXConnectError): - await client.bot.client.execute(botx_request) - - -@pytest.mark.asyncio() -async def test_raising_decode_error(client, token_method, mock_http_client): - response = Response(status_code=418, text="Wrong json") - mock_http_client.request.return_value = response - - client.bot.client.http_client = mock_http_client - botx_request = client.bot.client.build_request(token_method) - - with pytest.raises(BotXJSONDecodeError): - await client.bot.client.execute(botx_request) diff --git a/tests/test_clients/test_clients/test_sync_client/test_execute.py b/tests/test_clients/test_clients/test_sync_client/test_execute.py deleted file mode 100644 index 5e90e9ef..00000000 --- a/tests/test_clients/test_clients/test_sync_client/test_execute.py +++ /dev/null @@ -1,46 +0,0 @@ -import uuid -from unittest.mock import Mock - -import pytest -from httpx import ConnectError, Request, Response - -from botx.clients.methods.v2.bots.token import Token -from botx.exceptions import BotXConnectError, BotXJSONDecodeError - - -@pytest.fixture() -def token_method(): - return Token(host="example.cts", bot_id=uuid.uuid4(), signature="signature") - - -@pytest.fixture() -def mock_http_client(): - return Mock() - - -def test_execute_without_explicit_host(client, token_method): - request = client.bot.sync_client.build_request(token_method) - - assert client.bot.sync_client.execute(request) - - -def test_raising_connection_error(client, token_method, mock_http_client): - request = Request(token_method.http_method, token_method.url) - mock_http_client.request.side_effect = ConnectError("Test error", request=request) - - client.bot.sync_client.http_client = mock_http_client - botx_request = client.bot.sync_client.build_request(token_method) - - with pytest.raises(BotXConnectError): - client.bot.sync_client.execute(botx_request) - - -def test_raising_decode_error(client, token_method, mock_http_client): - response = Response(status_code=418, text="Wrong json") - mock_http_client.request.return_value = response - - client.bot.sync_client.http_client = mock_http_client - botx_request = client.bot.sync_client.build_request(token_method) - - with pytest.raises(BotXJSONDecodeError): - client.bot.sync_client.execute(botx_request) diff --git a/tests/test_clients/test_methods/test_base/test_empty_error_handlers.py b/tests/test_clients/test_methods/test_base/test_empty_error_handlers.py deleted file mode 100644 index 5106ff1d..00000000 --- a/tests/test_clients/test_methods/test_base/test_empty_error_handlers.py +++ /dev/null @@ -1,15 +0,0 @@ -from botx.clients.methods.base import BotXMethod - - -class TestMethod(BotXMethod): - __test__ = False - - __url__ = "/path/to/example" - __method__ = "GET" - __returning__ = str - - -def test_method_empty_error_handlers(): - test_method = TestMethod() - - assert test_method.__errors_handlers__ == {} diff --git a/tests/test_clients/test_methods/test_base/test_unhandled_error.py b/tests/test_clients/test_methods/test_base/test_unhandled_error.py deleted file mode 100644 index 8f22e8ee..00000000 --- a/tests/test_clients/test_methods/test_base/test_unhandled_error.py +++ /dev/null @@ -1,66 +0,0 @@ -import uuid - -import pytest - -from botx import BotXAPIError, ChatTypes -from botx.clients.methods.errors.bot_is_not_admin import BotIsNotAdminData -from botx.clients.methods.v2.bots.token import Token -from botx.clients.methods.v3.chats.create import Create -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - -IM_A_TEAPOT = 418 # This status code added in python 3.9 - - -async def test_raising_base_api_error_if_empty_handlers(client, requests_client): - method = Token(host="example.com", bot_id=uuid.uuid4(), signature="signature") - - errors_to_raise = { - Token: ( - IM_A_TEAPOT, - BotIsNotAdminData(sender=uuid.uuid4(), group_chat_id=uuid.uuid4()), - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(BotXAPIError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - -async def test_raising_base_api_error_if_unhandled(client, requests_client): - method = Create( - host="example.com", - name="test name", - members=[uuid.uuid4()], - chat_type=ChatTypes.group_chat, - shared_history=False, - ) - - method.__errors_handlers__[IM_A_TEAPOT] = [] - - errors_to_raise = { - Create: ( - IM_A_TEAPOT, - BotIsNotAdminData(sender=uuid.uuid4(), group_chat_id=uuid.uuid4()), - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(BotXAPIError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/__init__.py b/tests/test_clients/test_methods/test_errors/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_errors/test_bot_is_not_admin.py b/tests/test_clients/test_methods/test_errors/test_bot_is_not_admin.py deleted file mode 100644 index 569296e4..00000000 --- a/tests/test_clients/test_methods/test_errors/test_bot_is_not_admin.py +++ /dev/null @@ -1,39 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.bot_is_not_admin import ( - BotIsNotAdminData, - BotIsNotAdminError, -) -from botx.clients.methods.v3.chats.add_user import AddUser -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_bot_is_not_admin(client, requests_client): - method = AddUser( - host="example.com", - group_chat_id=uuid.uuid4(), - user_huids=[uuid.uuid4() for _ in range(10)], - ) - errors_to_raise = { - AddUser: ( - HTTPStatus.FORBIDDEN, - BotIsNotAdminData(sender=uuid.uuid4(), group_chat_id=method.group_chat_id), - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(BotIsNotAdminError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_bot_not_found.py b/tests/test_clients/test_methods/test_errors/test_bot_not_found.py deleted file mode 100644 index 1ae420f1..00000000 --- a/tests/test_clients/test_methods/test_errors/test_bot_not_found.py +++ /dev/null @@ -1,28 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.bot_not_found import BotNotFoundError -from botx.clients.methods.v2.bots.token import Token -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_bot_not_found_error(client, requests_client): - method = Token(host="example.com", bot_id=uuid.uuid4(), signature="signature") - - errors_to_raise = {Token: (HTTPStatus.NOT_FOUND, {})} - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(BotNotFoundError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_chat_creation_disallowed.py b/tests/test_clients/test_methods/test_errors/test_chat_creation_disallowed.py deleted file mode 100644 index 4ffe31cf..00000000 --- a/tests/test_clients/test_methods/test_errors/test_chat_creation_disallowed.py +++ /dev/null @@ -1,43 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx import ChatTypes -from botx.clients.methods.errors.chat_creation_disallowed import ( - ChatCreationDisallowedData, - ChatCreationDisallowedError, -) -from botx.clients.methods.v3.chats.create import Create -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_chat_creation_disallowed(client, requests_client): - method = Create( - host="example.com", - name="test name", - members=[uuid.uuid4()], - chat_type=ChatTypes.group_chat, - shared_history=False, - ) - - errors_to_raise = { - Create: ( - HTTPStatus.FORBIDDEN, - ChatCreationDisallowedData(bot_id=uuid.uuid4()), - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(ChatCreationDisallowedError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_chat_creation_error.py b/tests/test_clients/test_methods/test_errors/test_chat_creation_error.py deleted file mode 100644 index 1d238028..00000000 --- a/tests/test_clients/test_methods/test_errors/test_chat_creation_error.py +++ /dev/null @@ -1,35 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx import ChatTypes -from botx.clients.methods.errors.chat_creation_error import ChatCreationError -from botx.clients.methods.v3.chats.create import Create -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_chat_creation_error(client, requests_client): - method = Create( - host="example.com", - name="test name", - members=[uuid.uuid4()], - chat_type=ChatTypes.group_chat, - shared_history=False, - ) - - errors_to_raise = {Create: (HTTPStatus.UNPROCESSABLE_ENTITY, {})} - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(ChatCreationError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_chat_is_not_modifiable.py b/tests/test_clients/test_methods/test_errors/test_chat_is_not_modifiable.py deleted file mode 100644 index 3904fbda..00000000 --- a/tests/test_clients/test_methods/test_errors/test_chat_is_not_modifiable.py +++ /dev/null @@ -1,40 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.chat_is_not_modifiable import ( - PersonalChatIsNotModifiableData, - PersonalChatIsNotModifiableError, -) -from botx.clients.methods.v3.chats.add_user import AddUser -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_chat_is_not_modifiable(client, requests_client): - method = AddUser( - host="example.com", - group_chat_id=uuid.uuid4(), - user_huids=[uuid.uuid4()], - ) - - errors_to_raise = { - AddUser: ( - HTTPStatus.FORBIDDEN, - PersonalChatIsNotModifiableData(group_chat_id=method.group_chat_id), - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(PersonalChatIsNotModifiableError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_chat_not_found.py b/tests/test_clients/test_methods/test_errors/test_chat_not_found.py deleted file mode 100644 index 790d258c..00000000 --- a/tests/test_clients/test_methods/test_errors/test_chat_not_found.py +++ /dev/null @@ -1,40 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.chat_not_found import ( - ChatNotFoundData, - ChatNotFoundError, -) -from botx.clients.methods.v3.chats.add_user import AddUser -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_chat_not_found(client, requests_client): - method = AddUser( - host="example.com", - group_chat_id=uuid.uuid4(), - user_huids=[uuid.uuid4()], - ) - - errors_to_raise = { - AddUser: ( - HTTPStatus.NOT_FOUND, - ChatNotFoundData(group_chat_id=method.group_chat_id), - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(ChatNotFoundError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_files/__init__.py b/tests/test_clients/test_methods/test_errors/test_files/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_errors/test_files/test_chat_not_found.py b/tests/test_clients/test_methods/test_errors/test_files/test_chat_not_found.py deleted file mode 100644 index 3f691fb0..00000000 --- a/tests/test_clients/test_methods/test_errors/test_files/test_chat_not_found.py +++ /dev/null @@ -1,44 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.files.chat_not_found import ( - ChatNotFoundData, - ChatNotFoundError, -) -from botx.clients.methods.v3.files.download import DownloadFile -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_chat_not_found(client, requests_client): - method = DownloadFile( - host="example.com", - group_chat_id=uuid.uuid4(), - file_id=uuid.uuid4(), - is_preview=False, - ) - - errors_to_raise = { - DownloadFile: ( - HTTPStatus.NOT_FOUND, - ChatNotFoundData( - group_chat_id=method.group_chat_id, - error_description="test", - ), - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(ChatNotFoundError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_files/test_file_deleted.py b/tests/test_clients/test_methods/test_errors/test_files/test_file_deleted.py deleted file mode 100644 index 4cdeeb10..00000000 --- a/tests/test_clients/test_methods/test_errors/test_files/test_file_deleted.py +++ /dev/null @@ -1,41 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.files.file_deleted import ( - FileDeletedError, - FileDeletedErrorData, -) -from botx.clients.methods.v3.files.download import DownloadFile -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_file_deleted(client, requests_client): - method = DownloadFile( - host="example.com", - group_chat_id=uuid.uuid4(), - file_id=uuid.uuid4(), - is_preview=False, - ) - - errors_to_raise = { - DownloadFile: ( - HTTPStatus.NO_CONTENT, - FileDeletedErrorData(link="/path/to/file", error_description="test"), - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(FileDeletedError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_files/test_metadata_not_found.py b/tests/test_clients/test_methods/test_errors/test_files/test_metadata_not_found.py deleted file mode 100644 index 6899c51b..00000000 --- a/tests/test_clients/test_methods/test_errors/test_files/test_metadata_not_found.py +++ /dev/null @@ -1,45 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.files.metadata_not_found import ( - MetadataNotFoundData, - MetadataNotFoundError, -) -from botx.clients.methods.v3.files.download import DownloadFile -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_metadata_found(client, requests_client): - method = DownloadFile( - host="example.com", - group_chat_id=uuid.uuid4(), - file_id=uuid.uuid4(), - is_preview=False, - ) - - errors_to_raise = { - DownloadFile: ( - HTTPStatus.NOT_FOUND, - MetadataNotFoundData( - file_id=method.file_id, - group_chat_id=method.group_chat_id, - error_description="test", - ), - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(MetadataNotFoundError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_files/test_without_preview.py b/tests/test_clients/test_methods/test_errors/test_files/test_without_preview.py deleted file mode 100644 index 5d583c12..00000000 --- a/tests/test_clients/test_methods/test_errors/test_files/test_without_preview.py +++ /dev/null @@ -1,45 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.files.without_preview import ( - WithoutPreviewData, - WithoutPreviewError, -) -from botx.clients.methods.v3.files.download import DownloadFile -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_without_preview(client, requests_client): - method = DownloadFile( - host="example.com", - group_chat_id=uuid.uuid4(), - file_id=uuid.uuid4(), - is_preview=True, - ) - - errors_to_raise = { - DownloadFile: ( - HTTPStatus.BAD_REQUEST, - WithoutPreviewData( - file_id=method.file_id, - group_chat_id=method.group_chat_id, - error_description="test", - ), - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(WithoutPreviewError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_messaging.py b/tests/test_clients/test_methods/test_errors/test_messaging.py deleted file mode 100644 index bda8c716..00000000 --- a/tests/test_clients/test_methods/test_errors/test_messaging.py +++ /dev/null @@ -1,28 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.messaging import MessagingError -from botx.clients.methods.v3.chats.info import Info -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_messaging_error(client, requests_client): - method = Info(host="example.com", group_chat_id=uuid.uuid4()) - - errors_to_raise = {Info: (HTTPStatus.BAD_REQUEST, {})} - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(MessagingError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_permissions.py b/tests/test_clients/test_methods/test_errors/test_permissions.py deleted file mode 100644 index c2b77fcd..00000000 --- a/tests/test_clients/test_methods/test_errors/test_permissions.py +++ /dev/null @@ -1,40 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.permissions import ( - NoPermissionError, - NoPermissionErrorData, -) -from botx.clients.methods.v3.chats.pin_message import PinMessage -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_no_permission(client, requests_client): - method = PinMessage( - host="example.com", - chat_id=uuid.uuid4(), - sync_id=uuid.uuid4(), - ) - - errors_to_raise = { - PinMessage: ( - HTTPStatus.FORBIDDEN, - NoPermissionErrorData(group_chat_id=method.chat_id), - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(NoPermissionError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_stickers/__init__.py b/tests/test_clients/test_methods/test_errors/test_stickers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_errors/test_stickers/test_image_not_valid.py b/tests/test_clients/test_methods/test_errors/test_stickers/test_image_not_valid.py deleted file mode 100644 index 8b5da31f..00000000 --- a/tests/test_clients/test_methods/test_errors/test_stickers/test_image_not_valid.py +++ /dev/null @@ -1,39 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.stickers.image_not_valid import ImageNotValidError -from botx.clients.methods.v3.stickers.add_sticker import AddSticker -from botx.concurrency import callable_to_coroutine -from botx.testing.content import PNG_DATA - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_image_not_valid(client, requests_client): - method = AddSticker( - host="example.com", - pack_id=uuid.uuid4(), - emoji="🐢", - image=PNG_DATA, - ) - - errors_to_raise = { - AddSticker: ( - HTTPStatus.BAD_REQUEST, - {}, - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(ImageNotValidError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_stickers/test_sticker_not_found.py b/tests/test_clients/test_methods/test_errors/test_stickers/test_sticker_not_found.py deleted file mode 100644 index a781fd48..00000000 --- a/tests/test_clients/test_methods/test_errors/test_stickers/test_sticker_not_found.py +++ /dev/null @@ -1,43 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.stickers.sticker_pack_or_sticker_not_found import ( - StickerPackOrStickerNotFoundData, - StickerPackOrStickerNotFoundError, -) -from botx.clients.methods.v3.stickers.sticker import GetSticker -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_sticker_not_found(client, requests_client): - method = GetSticker( - host="example.com", - pack_id=uuid.uuid4(), - sticker_id=uuid.uuid4(), - ) - - errors_to_raise = { - GetSticker: ( - HTTPStatus.NOT_FOUND, - StickerPackOrStickerNotFoundData( - pack_id=method.pack_id, - sticker_id=method.sticker_id, - ), - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(StickerPackOrStickerNotFoundError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_stickers/test_sticker_pack_not_found.py b/tests/test_clients/test_methods/test_errors/test_stickers/test_sticker_pack_not_found.py deleted file mode 100644 index 942fb839..00000000 --- a/tests/test_clients/test_methods/test_errors/test_stickers/test_sticker_pack_not_found.py +++ /dev/null @@ -1,39 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.stickers.sticker_pack_not_found import ( - StickerPackNotFoundData, - StickerPackNotFoundError, -) -from botx.clients.methods.v3.stickers.sticker_pack import GetStickerPack -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_sticker_pack_not_found(client, requests_client): - method = GetStickerPack( - host="example.com", - pack_id=uuid.uuid4(), - ) - - errors_to_raise = { - GetStickerPack: ( - HTTPStatus.NOT_FOUND, - StickerPackNotFoundData(pack_id=method.pack_id), - ), - } - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(StickerPackNotFoundError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_unauthorized_bot.py b/tests/test_clients/test_methods/test_errors/test_unauthorized_bot.py deleted file mode 100644 index 27105221..00000000 --- a/tests/test_clients/test_methods/test_errors/test_unauthorized_bot.py +++ /dev/null @@ -1,28 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.unauthorized_bot import InvalidBotCredentials -from botx.clients.methods.v2.bots.token import Token -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_unauthorized_bot_error(client, requests_client): - method = Token(host="example.com", bot_id=uuid.uuid4(), signature="signature") - - errors_to_raise = {Token: (HTTPStatus.UNAUTHORIZED, {})} - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(InvalidBotCredentials): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_errors/test_user_not_found.py b/tests/test_clients/test_methods/test_errors/test_user_not_found.py deleted file mode 100644 index 41f36d00..00000000 --- a/tests/test_clients/test_methods/test_errors/test_user_not_found.py +++ /dev/null @@ -1,28 +0,0 @@ -import uuid -from http import HTTPStatus - -import pytest - -from botx.clients.methods.errors.user_not_found import UserNotFoundError -from botx.clients.methods.v3.users.by_huid import ByHUID -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_user_not_found(client, requests_client): - method = ByHUID(host="example.com", user_huid=uuid.uuid4()) - - errors_to_raise = {ByHUID: (HTTPStatus.NOT_FOUND, {})} - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(UserNotFoundError): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_clients/test_methods/test_v2/__init__.py b/tests/test_clients/test_methods/test_v2/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_v2/test_bots/__init__.py b/tests/test_clients/test_methods/test_v2/test_bots/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_v2/test_bots/test_token.py b/tests/test_clients/test_methods/test_v2/test_bots/test_token.py deleted file mode 100644 index e1bea697..00000000 --- a/tests/test_clients/test_methods/test_v2/test_bots/test_token.py +++ /dev/null @@ -1,19 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v2.bots.token import Token -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_obtaining_token(client, requests_client): - method = Token(host="example.com", bot_id=uuid.uuid4(), signature="signature") - - request = requests_client.build_request(method) - await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].bot_id == method.bot_id - assert client.requests[0].signature == method.signature diff --git a/tests/test_clients/test_methods/test_v3/__init__.py b/tests/test_clients/test_methods/test_v3/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_v3/test_chats/__init__.py b/tests/test_clients/test_methods/test_v3/test_chats/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_add_admin_role.py b/tests/test_clients/test_methods/test_v3/test_chats/test_add_admin_role.py deleted file mode 100644 index 6d39273b..00000000 --- a/tests/test_clients/test_methods/test_v3/test_chats/test_add_admin_role.py +++ /dev/null @@ -1,22 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.chats.add_admin_role import AddAdminRole -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio - - -async def test_adding_users(client, requests_client): - method = AddAdminRole( - host="example.com", - group_chat_id=uuid.uuid4(), - user_huids=[uuid.uuid4() for _ in range(10)], - ) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].group_chat_id == method.group_chat_id - assert client.requests[0].user_huids == method.user_huids diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_add_user.py b/tests/test_clients/test_methods/test_v3/test_chats/test_add_user.py deleted file mode 100644 index 102083b1..00000000 --- a/tests/test_clients/test_methods/test_v3/test_chats/test_add_user.py +++ /dev/null @@ -1,22 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.chats.add_user import AddUser -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio - - -async def test_adding_users(client, requests_client): - method = AddUser( - host="example.com", - group_chat_id=uuid.uuid4(), - user_huids=[uuid.uuid4() for _ in range(10)], - ) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].group_chat_id == method.group_chat_id - assert client.requests[0].user_huids == method.user_huids diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_chat_list.py b/tests/test_clients/test_methods/test_v3/test_chats/test_chat_list.py deleted file mode 100644 index 5e706bd4..00000000 --- a/tests/test_clients/test_methods/test_v3/test_chats/test_chat_list.py +++ /dev/null @@ -1,24 +0,0 @@ -import pytest - -from botx.clients.methods.v3.chats.chat_list import ChatList -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio - - -async def test_retrieving_bot_chats(client, requests_client): - method = ChatList(host="example.com") - - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - bot_chats = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert isinstance(list(bot_chats), list) - - assert len(bot_chats) == 1 - - assert len(bot_chats[0].members) == 1 diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_create.py b/tests/test_clients/test_methods/test_v3/test_chats/test_create.py deleted file mode 100644 index 0328cb1d..00000000 --- a/tests/test_clients/test_methods/test_v3/test_chats/test_create.py +++ /dev/null @@ -1,25 +0,0 @@ -import uuid - -import pytest - -from botx import ChatTypes -from botx.clients.methods.v3.chats.create import Create -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio - - -async def test_chat_creation(client, requests_client): - method = Create( - host="example.com", - name="test name", - members=[uuid.uuid4()], - chat_type=ChatTypes.group_chat, - shared_history=False, - ) - - request = requests_client.build_request(method) - await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].name == method.name - assert client.requests[0].members == method.members diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_info.py b/tests/test_clients/test_methods/test_v3/test_chats/test_info.py deleted file mode 100644 index 4c9a88d4..00000000 --- a/tests/test_clients/test_methods/test_v3/test_chats/test_info.py +++ /dev/null @@ -1,24 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.chats.info import Info -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio - - -async def test_retrieving_info(client, requests_client): - method = Info(host="example.com", group_chat_id=uuid.uuid4()) - - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - info = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert info.members - - assert client.requests[0].group_chat_id == method.group_chat_id diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_pin_message.py b/tests/test_clients/test_methods/test_v3/test_chats/test_pin_message.py deleted file mode 100644 index 8b634e59..00000000 --- a/tests/test_clients/test_methods/test_v3/test_chats/test_pin_message.py +++ /dev/null @@ -1,21 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.chats.pin_message import PinMessage -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_pinning_message(client, requests_client): - chat_id = uuid.uuid4() - sync_id = uuid.uuid4() - method = PinMessage(host="example.com", chat_id=chat_id, sync_id=sync_id) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].chat_id == method.chat_id - assert client.requests[0].sync_id == method.sync_id diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_remove_user.py b/tests/test_clients/test_methods/test_v3/test_chats/test_remove_user.py deleted file mode 100644 index f87ef12a..00000000 --- a/tests/test_clients/test_methods/test_v3/test_chats/test_remove_user.py +++ /dev/null @@ -1,21 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.chats.remove_user import RemoveUser -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio - - -async def test_removing_users(client, requests_client): - method = RemoveUser( - host="example.com", - group_chat_id=uuid.uuid4(), - user_huids=[uuid.uuid4() for _ in range(10)], - ) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].group_chat_id == method.group_chat_id diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_stealth_disable.py b/tests/test_clients/test_methods/test_v3/test_chats/test_stealth_disable.py deleted file mode 100644 index f50b0307..00000000 --- a/tests/test_clients/test_methods/test_v3/test_chats/test_stealth_disable.py +++ /dev/null @@ -1,18 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.chats.stealth_disable import StealthDisable -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_disabling_stealth(client, requests_client): - method = StealthDisable(host="example.com", group_chat_id=uuid.uuid4()) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].group_chat_id == method.group_chat_id diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_stealth_set.py b/tests/test_clients/test_methods/test_v3/test_chats/test_stealth_set.py deleted file mode 100644 index 980e5bf5..00000000 --- a/tests/test_clients/test_methods/test_v3/test_chats/test_stealth_set.py +++ /dev/null @@ -1,18 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.chats.stealth_set import StealthSet -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_enabling_stealth(client, requests_client): - method = StealthSet(host="example.com", group_chat_id=uuid.uuid4()) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].group_chat_id == method.group_chat_id diff --git a/tests/test_clients/test_methods/test_v3/test_chats/test_unpin_message.py b/tests/test_clients/test_methods/test_v3/test_chats/test_unpin_message.py deleted file mode 100644 index 8d192f29..00000000 --- a/tests/test_clients/test_methods/test_v3/test_chats/test_unpin_message.py +++ /dev/null @@ -1,19 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.chats.unpin_message import UnpinMessage -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_unpinning_message(client, requests_client): - chat_id = uuid.uuid4() - method = UnpinMessage(host="example.com", chat_id=chat_id) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].chat_id == method.chat_id diff --git a/tests/test_clients/test_methods/test_v3/test_command/__init__.py b/tests/test_clients/test_methods/test_v3/test_command/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_v3/test_command/test_command_result.py b/tests/test_clients/test_methods/test_v3/test_command/test_command_result.py deleted file mode 100644 index 8eb42a59..00000000 --- a/tests/test_clients/test_methods/test_v3/test_command/test_command_result.py +++ /dev/null @@ -1,24 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.command.command_result import CommandResult -from botx.clients.types.message_payload import ResultPayload -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_sending_command_result(client, requests_client): - method = CommandResult( - host="example.com", - sync_id=uuid.uuid4(), - bot_id=uuid.uuid4(), - result=ResultPayload(body="test"), - ) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].result.body == method.result.body diff --git a/tests/test_clients/test_methods/test_v3/test_events/__init__.py b/tests/test_clients/test_methods/test_v3/test_events/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_v3/test_events/test_edit_event.py b/tests/test_clients/test_methods/test_v3/test_events/test_edit_event.py deleted file mode 100644 index 41265103..00000000 --- a/tests/test_clients/test_methods/test_v3/test_events/test_edit_event.py +++ /dev/null @@ -1,23 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.events.edit_event import EditEvent -from botx.clients.types.message_payload import UpdatePayload -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_sending_edit_event(client, requests_client): - method = EditEvent( - host="example.com", - sync_id=uuid.uuid4(), - result=UpdatePayload(body="test"), - ) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].result.body == method.result.body diff --git a/tests/test_clients/test_methods/test_v3/test_files/__init__.py b/tests/test_clients/test_methods/test_v3/test_files/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_v3/test_files/test_download.py b/tests/test_clients/test_methods/test_v3/test_files/test_download.py deleted file mode 100644 index 2eedce1a..00000000 --- a/tests/test_clients/test_methods/test_v3/test_files/test_download.py +++ /dev/null @@ -1,32 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.files.download import DownloadFile -from botx.concurrency import callable_to_coroutine -from botx.models.files import File - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_download_file(client, requests_client): - file_id = uuid.uuid4() - method = DownloadFile( - host="example.com", - group_chat_id=uuid.uuid4(), - file_id=file_id, - is_preview=False, - ) - - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - file = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert isinstance(file, File) - - assert client.requests[0].file_id == file_id diff --git a/tests/test_clients/test_methods/test_v3/test_files/test_upload.py b/tests/test_clients/test_methods/test_v3/test_files/test_upload.py deleted file mode 100644 index d1c1da45..00000000 --- a/tests/test_clients/test_methods/test_v3/test_files/test_upload.py +++ /dev/null @@ -1,35 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.files.upload import UploadFile -from botx.concurrency import callable_to_coroutine -from botx.models.files import File -from botx.testing.content import PNG_DATA - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_upload_file(client, requests_client): - file_name = "image.png" - - image = File(file_name=file_name, data=PNG_DATA) - method = UploadFile( - host="example.com", - group_chat_id=uuid.uuid4(), - file=image, - meta={}, - ) - - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - meta_file = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert meta_file.file_name == file_name - - assert client.requests[0].file.file_name == file_name diff --git a/tests/test_clients/test_methods/test_v3/test_notification/__init__.py b/tests/test_clients/test_methods/test_v3/test_notification/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_v3/test_notification/test_notification.py b/tests/test_clients/test_methods/test_v3/test_notification/test_notification.py deleted file mode 100644 index f0791863..00000000 --- a/tests/test_clients/test_methods/test_v3/test_notification/test_notification.py +++ /dev/null @@ -1,24 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.notification.notification import Notification -from botx.clients.types.message_payload import ResultPayload -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_sending_notification(client, requests_client): - method = Notification( - host="example.com", - group_chat_ids=[uuid.uuid4()], - bot_id=uuid.uuid4(), - result=ResultPayload(body="test"), - ) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].result.body == method.result.body diff --git a/tests/test_clients/test_methods/test_v3/test_notification/test_notification_direct.py b/tests/test_clients/test_methods/test_v3/test_notification/test_notification_direct.py deleted file mode 100644 index b67a6fff..00000000 --- a/tests/test_clients/test_methods/test_v3/test_notification/test_notification_direct.py +++ /dev/null @@ -1,24 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.notification.direct_notification import NotificationDirect -from botx.clients.types.message_payload import ResultPayload -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_sending_direct_notification(client, requests_client): - method = NotificationDirect( - host="example.com", - group_chat_id=uuid.uuid4(), - bot_id=uuid.uuid4(), - result=ResultPayload(body="test"), - ) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].result.body == method.result.body diff --git a/tests/test_clients/test_methods/test_v3/test_smartapps/test_smartapp_event.py b/tests/test_clients/test_methods/test_v3/test_smartapps/test_smartapp_event.py deleted file mode 100644 index decc32ee..00000000 --- a/tests/test_clients/test_methods/test_v3/test_smartapps/test_smartapp_event.py +++ /dev/null @@ -1,42 +0,0 @@ -from typing import Any, Dict, Union -from uuid import UUID - -import pytest - -from botx import AsyncClient, Client, TestClient -from botx.clients.methods.v3.smartapps.smartapp_event import SmartAppEvent -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures", "tests.fixtures.smartapps") - - -async def test_smartapp_event( - client: TestClient, - requests_client: Union[AsyncClient, Client], - ref: UUID, - smartapp_id: UUID, - smartapp_api_version: int, - group_chat_id: UUID, - smartapp_data: Dict[str, Any], -) -> None: - method = SmartAppEvent( - host="example.com", # type: ignore [call-arg] - ref=ref, - smartapp_id=smartapp_id, - data=smartapp_data, - smartapp_api_version=smartapp_api_version, - group_chat_id=group_chat_id, - ) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert isinstance(client.requests[0], SmartAppEvent) - smartapp_event = client.requests[0] - - assert smartapp_event.ref == ref - assert smartapp_event.smartapp_id == smartapp_id - assert smartapp_event.smartapp_api_version == smartapp_api_version - assert smartapp_event.group_chat_id == group_chat_id - assert smartapp_event.data == smartapp_data diff --git a/tests/test_clients/test_methods/test_v3/test_smartapps/test_smartapp_notification.py b/tests/test_clients/test_methods/test_v3/test_smartapps/test_smartapp_notification.py deleted file mode 100644 index 33ec0d25..00000000 --- a/tests/test_clients/test_methods/test_v3/test_smartapps/test_smartapp_notification.py +++ /dev/null @@ -1,36 +0,0 @@ -from typing import Union -from uuid import UUID - -import pytest - -from botx import AsyncClient, Client, TestClient -from botx.clients.methods.v3.smartapps.smartapp_notification import SmartAppNotification -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures", "tests.fixtures.smartapps") - - -async def test_smartapp_event( - client: TestClient, - requests_client: Union[AsyncClient, Client], - smartapp_api_version: int, - group_chat_id: UUID, - smartapp_counter: int, -) -> None: - method = SmartAppNotification( - host="example.com", # type: ignore [call-arg] - group_chat_id=group_chat_id, - smartapp_counter=smartapp_counter, - smartapp_api_version=smartapp_api_version, - ) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert isinstance(client.requests[0], SmartAppNotification) - client.requests[0] - - assert client.requests[0].smartapp_counter == smartapp_counter - assert client.requests[0].smartapp_api_version == smartapp_api_version - assert client.requests[0].group_chat_id == group_chat_id diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/__init__.py b/tests/test_clients/test_methods/test_v3/test_stickers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_add_sticker.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_add_sticker.py deleted file mode 100644 index c485bcdf..00000000 --- a/tests/test_clients/test_methods/test_v3/test_stickers/test_add_sticker.py +++ /dev/null @@ -1,31 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.stickers.add_sticker import AddSticker -from botx.concurrency import callable_to_coroutine -from botx.testing.content import PNG_DATA - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_add_sticker_into_sticker_pack(client, requests_client): - emoji = "🐢" - - method = AddSticker( - pack_id=uuid.uuid4(), - emoji=emoji, - image=PNG_DATA, - host="example.com", - ) - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - sticker = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert sticker.emoji == emoji diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_create_sticker_pack.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_create_sticker_pack.py deleted file mode 100644 index ea8702b2..00000000 --- a/tests/test_clients/test_methods/test_v3/test_stickers/test_create_sticker_pack.py +++ /dev/null @@ -1,29 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.stickers.create_sticker_pack import CreateStickerPack -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_create_sticker_pack(client, requests_client): - sticker_pack_name = "Test sticker pack" - - method = CreateStickerPack( - name=sticker_pack_name, - host="example.com", - user_huid=uuid.uuid4(), - ) - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - sticker_pack = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert sticker_pack.name == sticker_pack_name diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_delete_sticker.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_delete_sticker.py deleted file mode 100644 index c80aa0bb..00000000 --- a/tests/test_clients/test_methods/test_v3/test_stickers/test_delete_sticker.py +++ /dev/null @@ -1,28 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.stickers.delete_sticker import DeleteSticker -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_delete_sticker(client, requests_client): - - method = DeleteSticker( - pack_id=uuid.uuid4(), - sticker_id=uuid.uuid4(), - host="example.com", - ) - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - result = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert result == "sticker_deleted" diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_delete_sticker_pack.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_delete_sticker_pack.py deleted file mode 100644 index 6cb89d10..00000000 --- a/tests/test_clients/test_methods/test_v3/test_stickers/test_delete_sticker_pack.py +++ /dev/null @@ -1,24 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.stickers.delete_sticker_pack import DeleteStickerPack -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_delete_sticker_pack(client, requests_client): - - method = DeleteStickerPack(pack_id=uuid.uuid4(), host="example.com") - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - result = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert result == "sticker_pack_deleted" diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_edit_sticker_pack.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_edit_sticker_pack.py deleted file mode 100644 index af5dc62d..00000000 --- a/tests/test_clients/test_methods/test_v3/test_stickers/test_edit_sticker_pack.py +++ /dev/null @@ -1,29 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.stickers.edit_sticker_pack import EditStickerPack -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_edit_sticker_pack(client, requests_client): - sticker_pack_name = "Test sticker pack" - - method = EditStickerPack( - pack_id=uuid.uuid4(), - name=sticker_pack_name, - host="example.com", - ) - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - sticker_pack = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert sticker_pack.name == sticker_pack_name diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_from_sticker_pack.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_from_sticker_pack.py deleted file mode 100644 index a21003a9..00000000 --- a/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_from_sticker_pack.py +++ /dev/null @@ -1,29 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.stickers.sticker import GetSticker -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_get_sticker_from_sticker_pack(client, requests_client): - emoji = "🐢" - - method = GetSticker( - pack_id=uuid.uuid4(), - sticker_id=uuid.uuid4(), - host="example.com", - ) - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - sticker = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert sticker.emoji == emoji diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_pack.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_pack.py deleted file mode 100644 index ef36c34c..00000000 --- a/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_pack.py +++ /dev/null @@ -1,25 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.stickers.sticker_pack import GetStickerPack -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_get_sticker_pack(client, requests_client): - sticker_pack_name = "Test sticker pack" - - method = GetStickerPack(pack_id=uuid.uuid4(), host="example.com") - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - sticker_pack = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert sticker_pack.name == sticker_pack_name diff --git a/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_pack_list.py b/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_pack_list.py deleted file mode 100644 index 1f6be276..00000000 --- a/tests/test_clients/test_methods/test_v3/test_stickers/test_get_sticker_pack_list.py +++ /dev/null @@ -1,23 +0,0 @@ -import pytest - -from botx.clients.methods.v3.stickers.sticker_pack_list import GetStickerPackList -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_get_sticker_pack_list(client, requests_client): - sticker_pack_name = "Test sticker pack" - - method = GetStickerPackList(host="example.com", limit=1) - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - sticker_pack_list = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert sticker_pack_list.packs[0].name == sticker_pack_name diff --git a/tests/test_clients/test_methods/test_v3/test_users/__init__.py b/tests/test_clients/test_methods/test_v3/test_users/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_v3/test_users/test_by_email.py b/tests/test_clients/test_methods/test_v3/test_users/test_by_email.py deleted file mode 100644 index 2504b01c..00000000 --- a/tests/test_clients/test_methods/test_v3/test_users/test_by_email.py +++ /dev/null @@ -1,23 +0,0 @@ -import pytest - -from botx.clients.methods.v3.users.by_email import ByEmail -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_search_by_huid(client, requests_client): - method = ByEmail(host="example.com", email="test@example.com") - - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - user = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert user.emails == [method.email] - - assert client.requests[0].email == method.email diff --git a/tests/test_clients/test_methods/test_v3/test_users/test_by_huid.py b/tests/test_clients/test_methods/test_v3/test_users/test_by_huid.py deleted file mode 100644 index 842cbc0d..00000000 --- a/tests/test_clients/test_methods/test_v3/test_users/test_by_huid.py +++ /dev/null @@ -1,25 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v3.users.by_huid import ByHUID -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_search_by_huid(client, requests_client): - method = ByHUID(host="example.com", user_huid=uuid.uuid4()) - - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - user = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert user.user_huid == method.user_huid - - assert client.requests[0].user_huid == method.user_huid diff --git a/tests/test_clients/test_methods/test_v3/test_users/test_by_login.py b/tests/test_clients/test_methods/test_v3/test_users/test_by_login.py deleted file mode 100644 index 096fadfc..00000000 --- a/tests/test_clients/test_methods/test_v3/test_users/test_by_login.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -from botx.clients.methods.v3.users.by_login import ByLogin -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_search_by_huid(client, requests_client): - method = ByLogin(host="example.com", ad_login="test", ad_domain="example.com") - - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - user = await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) - - assert user.ad_login == method.ad_login - assert user.ad_domain == method.ad_domain - - assert client.requests[0].ad_login == method.ad_login - assert client.requests[0].ad_domain == method.ad_domain diff --git a/tests/test_clients/test_methods/test_v4/__init__.py b/tests/test_clients/test_methods/test_v4/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_v4/test_notifications/__init__.py b/tests/test_clients/test_methods/test_v4/test_notifications/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_methods/test_v4/test_notifications/test_internal_bot_notification.py b/tests/test_clients/test_methods/test_v4/test_notifications/test_internal_bot_notification.py deleted file mode 100644 index be7b01ac..00000000 --- a/tests/test_clients/test_methods/test_v4/test_notifications/test_internal_bot_notification.py +++ /dev/null @@ -1,26 +0,0 @@ -import uuid - -import pytest - -from botx.clients.methods.v4.notifications.internal_bot_notification import ( - InternalBotNotification, -) -from botx.clients.types.message_payload import InternalBotNotificationPayload -from botx.concurrency import callable_to_coroutine - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_sending_internal_bot_notification(client, requests_client): - method = InternalBotNotification( - host="example.com", - group_chat_id=uuid.uuid4(), - bot_id=uuid.uuid4(), - data=InternalBotNotificationPayload(message="test"), - ) - - request = requests_client.build_request(method) - assert await callable_to_coroutine(requests_client.execute, request) - - assert client.requests[0].data.message == "test" diff --git a/tests/test_clients/test_types/__init__.py b/tests/test_clients/test_types/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_clients/test_types/test_http.py b/tests/test_clients/test_types/test_http.py deleted file mode 100644 index d3f1fd69..00000000 --- a/tests/test_clients/test_types/test_http.py +++ /dev/null @@ -1,14 +0,0 @@ -import pytest -from pydantic import ValidationError - -from botx.clients.types.http import HTTPResponse - - -def test_response_validation(): - with pytest.raises(ValidationError): - HTTPResponse( - headers={}, - status_code=200, - json_body={"status": "ok"}, - raw_data=b"content", - ) diff --git a/tests/test_collecting/__init__.py b/tests/test_collecting/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_collecting/fixtures.py b/tests/test_collecting/fixtures.py deleted file mode 100644 index ec71704c..00000000 --- a/tests/test_collecting/fixtures.py +++ /dev/null @@ -1,69 +0,0 @@ -import pytest - -from botx import Bot, Collector -from botx.collecting.handlers.handler import Handler - - -class HandlerClass: - def handler_method_snake_case(self) -> None: - """Handler with name in snake case.""" - - def handlerMethodCamelCase(self) -> None: # noqa: N802 - """Handler with name in camel case.""" - - def HandlerMethodPascalCase(self) -> None: # noqa: N802 - """Handler with name in pascal case.""" - - def __call__(self) -> None: - """Handler that is callable class.""" - - -@pytest.fixture() -def handler_as_function(build_handler_for_collector): - return build_handler_for_collector("handler_function") - - -@pytest.fixture() -def handler_as_class(): - return HandlerClass - - -@pytest.fixture() -def handler_as_callable_object(): - return HandlerClass() - - -@pytest.fixture() -def handler_as_normal_method(): - return HandlerClass().handler_method_snake_case - - -@pytest.fixture() -def handler_as_pascal_case_method(): - return HandlerClass().HandlerMethodPascalCase - - -@pytest.fixture() -def handler_as_camel_case_method(): - return HandlerClass().handlerMethodCamelCase - - -@pytest.fixture() -def default_handler(handler_as_function): - return Handler(handler=handler_as_function, body="/default-handler") - - -@pytest.fixture() -def extract_collector(): - def factory(collector_instance): - if isinstance(collector_instance, Bot): - return collector_instance.collector - - return collector_instance - - return factory - - -@pytest.fixture(params=(Collector, Bot)) -def collector_cls(request): - return request.param diff --git a/tests/test_collecting/test_collector/__init__.py b/tests/test_collecting/test_collector/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_collecting/test_collector/test_bodies_generating.py b/tests/test_collecting/test_collector/test_bodies_generating.py deleted file mode 100644 index f534f715..00000000 --- a/tests/test_collecting/test_collector/test_bodies_generating.py +++ /dev/null @@ -1,24 +0,0 @@ -from botx import Collector - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_generating_body_from_snake_case(handler_as_normal_method): - collector = Collector() - collector.add_handler(handler=handler_as_normal_method) - handler = collector.handler_for("handler_method_snake_case") - assert handler.body == "/handler-method-snake-case" - - -def test_generating_body_from_pascal_case(handler_as_pascal_case_method): - collector = Collector() - collector.add_handler(handler=handler_as_pascal_case_method) - handler = collector.handler_for("HandlerMethodPascalCase") - assert handler.body == "/handler-method-pascal-case" - - -def test_generating_body_from_camel_case(handler_as_camel_case_method): - collector = Collector() - collector.add_handler(handler=handler_as_camel_case_method) - handler = collector.handler_for("handlerMethodCamelCase") - assert handler.body == "/handler-method-camel-case" diff --git a/tests/test_collecting/test_collector/test_collectors_merging/__init__.py b/tests/test_collecting/test_collector/test_collectors_merging/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_collecting/test_collector/test_collectors_merging/test_default_handler_adding.py b/tests/test_collecting/test_collector/test_collectors_merging/test_default_handler_adding.py deleted file mode 100644 index 4874c57a..00000000 --- a/tests/test_collecting/test_collector/test_collectors_merging/test_default_handler_adding.py +++ /dev/null @@ -1,15 +0,0 @@ -from botx import Collector - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_default_handler_after_including_into_another_collector( - default_handler, - handler_as_function, -): - collector1 = Collector() - collector2 = Collector(default=default_handler) - - collector1.include_collector(collector2) - - assert collector1.default_message_handler == collector2.default_message_handler diff --git a/tests/test_collecting/test_collector/test_collectors_merging/test_dependencies_order.py b/tests/test_collecting/test_collector/test_collectors_merging/test_dependencies_order.py deleted file mode 100644 index 25c23cc3..00000000 --- a/tests/test_collecting/test_collector/test_collectors_merging/test_dependencies_order.py +++ /dev/null @@ -1,93 +0,0 @@ -from typing import Callable - -import pytest - -from botx import Collector, Depends - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def build_botx_dependency(number: int) -> Callable[[], None]: - def factory(): - """Just do nothing.""" - - factory.number = number - return factory - - -@pytest.fixture() -def build_dependency(): - return build_botx_dependency - - -def test_preserving_order_after_merging(message, handler_as_function, build_dependency): - message.command.body = "/command" - - collector1 = Collector(dependencies=[Depends(build_dependency(1))]) - collector2 = Collector(dependencies=[Depends(build_dependency(2))]) - - collector2.add_handler( - handler=handler_as_function, - body=message.command.command, - dependencies=[Depends(build_dependency(3))], - ) - - collector1.include_collector(collector2) - - handler = collector1.handler_for("handler_function") - - numbers = [dep.dependency.number for dep in handler.dependencies] - - assert numbers == [1, 2, 3] - - -def test_preserving_order_after_merging_for_default_handler( - message, - default_handler, - build_dependency, -): - message.command.body = "/command" - - default_handler.dependencies = [Depends(build_dependency(3))] - - collector1 = Collector(dependencies=[Depends(build_dependency(1))]) - collector2 = Collector( - dependencies=[Depends(build_dependency(2))], - default=default_handler, - ) - - collector1.include_collector(collector2) - - handler = collector1.default_message_handler - - numbers = [dep.dependency.number for dep in handler.dependencies] - - assert numbers == [1, 2, 3] - - -def test_dependencies_order_in_include_collector( - message, - handler_as_function, - build_dependency, -): - message.command.body = "/command" - - collector1 = Collector() - collector2 = Collector() - - collector2.add_handler( - handler=handler_as_function, - body=message.command.command, - dependencies=[Depends(build_dependency(2))], - ) - - collector1.include_collector( - collector2, - dependencies=[Depends(build_dependency(1))], - ) - - handler = collector1.handler_for("handler_function") - - numbers = [dep.dependency.number for dep in handler.dependencies] - - assert numbers == [1, 2] diff --git a/tests/test_collecting/test_collector/test_collectors_merging/test_errors.py b/tests/test_collecting/test_collector/test_collectors_merging/test_errors.py deleted file mode 100644 index 35bc1655..00000000 --- a/tests/test_collecting/test_collector/test_collectors_merging/test_errors.py +++ /dev/null @@ -1,51 +0,0 @@ -import pytest - -from botx import Collector - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_error_when_merging_handlers_with_equal_bodies(build_handler): - collector1 = Collector() - collector1.add_handler( - handler=build_handler("handler1"), - body="/body", - name="handler1", - ) - - collector2 = Collector() - collector2.add_handler( - handler=build_handler("handler2"), - body="/body", - name="handler2", - ) - - with pytest.raises(AssertionError): - collector1.include_collector(collector2) - - -def test_error_when_merging_handlers_with_equal_names(build_handler): - collector1 = Collector() - collector1.add_handler( - handler=build_handler("handler1"), - body="/body1", - name="handler", - ) - - collector2 = Collector() - collector2.add_handler( - handler=build_handler("handler2"), - body="/body2", - name="handler", - ) - - with pytest.raises(AssertionError): - collector1.include_collector(collector2) - - -def test_only_single_default_handler_can_defined_in_collector(default_handler): - collector1 = Collector(default=default_handler) - collector2 = Collector(default=default_handler) - - with pytest.raises(AssertionError): - collector1.include_collector(collector2) diff --git a/tests/test_collecting/test_collector/test_commands_generation.py b/tests/test_collecting/test_collector/test_commands_generation.py deleted file mode 100644 index 4e515739..00000000 --- a/tests/test_collecting/test_collector/test_commands_generation.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest - -from botx.exceptions import NoMatchFound - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_error_when_no_args_passed(collector_cls): - collector = collector_cls() - with pytest.raises(TypeError): - collector.command_for() - - -def test_building_command_with_arguments(handler_as_function, collector_cls): - collector = collector_cls() - collector.add_handler(handler=handler_as_function, name="handler", body="/handler") - built_command = collector.command_for("handler", "arg1", 1, True) - assert built_command == "/handler arg1 1 True" - - -def test_raising_exception_when_generating_command_and_not_found( - build_handler_for_collector, - collector_cls, -): - collector = collector_cls() - collector.handler(build_handler_for_collector("handler1")) - collector.handler(build_handler_for_collector("handler2")) - with pytest.raises(NoMatchFound): - collector.command_for("not-existing-handler") diff --git a/tests/test_collecting/test_collector/test_decorators/__init__.py b/tests/test_collecting/test_collector/test_decorators/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_collecting/test_collector/test_decorators/test_default.py b/tests/test_collecting/test_collector/test_decorators/test_default.py deleted file mode 100644 index 966510c7..00000000 --- a/tests/test_collecting/test_collector/test_decorators/test_default.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_defining_default_handler_in_collector_as_decorator( - handler_as_function, - extract_collector, - collector_cls, -): - collector = collector_cls() - collector.default()(handler_as_function) - assert extract_collector(collector).default_message_handler - - -def test_error_when_default_already_exists( - handler_as_function, - extract_collector, - collector_cls, -): - collector = collector_cls() - collector.default()(handler_as_function) - - with pytest.raises(AssertionError): - collector.default()(handler_as_function) diff --git a/tests/test_collecting/test_collector/test_decorators/test_handler.py b/tests/test_collecting/test_collector/test_decorators/test_handler.py deleted file mode 100644 index eba5d870..00000000 --- a/tests/test_collecting/test_collector/test_decorators/test_handler.py +++ /dev/null @@ -1,14 +0,0 @@ -from botx import Collector - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_defining_handler_in_collector_as_decorator( - handler_as_function, - extract_collector, - collector_cls, -): - collector = Collector() - collector.handler()(handler_as_function) - handlers = [collector.handler_for("handler_function")] - assert handlers diff --git a/tests/test_collecting/test_collector/test_decorators/test_hidden.py b/tests/test_collecting/test_collector/test_decorators/test_hidden.py deleted file mode 100644 index 613debb1..00000000 --- a/tests/test_collecting/test_collector/test_decorators/test_hidden.py +++ /dev/null @@ -1,11 +0,0 @@ -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_defining_hidden_handler_in_collector_as_decorator( - handler_as_function, - extract_collector, - collector_cls, -): - collector = collector_cls() - collector.hidden()(handler_as_function) - assert not collector.handlers[0].include_in_status diff --git a/tests/test_collecting/test_collector/test_decorators/test_system_event.py b/tests/test_collecting/test_collector/test_decorators/test_system_event.py deleted file mode 100644 index c72edcba..00000000 --- a/tests/test_collecting/test_collector/test_decorators/test_system_event.py +++ /dev/null @@ -1,64 +0,0 @@ -import pytest - -from botx import SystemEvents - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_registration_handler_for_several_system_events( - handler_as_function, - extract_collector, - collector_cls, -): - system_events = { - SystemEvents.chat_created, - SystemEvents.file_transfer, - SystemEvents.added_to_chat, - SystemEvents.deleted_from_chat, - SystemEvents.left_from_chat, - SystemEvents.internal_bot_notification, - SystemEvents.cts_login, - SystemEvents.cts_logout, - SystemEvents.smartapp_event, - } - collector = collector_cls() - collector.system_event( - handler=handler_as_function, - events=list(system_events), - ) - handlers = [SystemEvents(handler.body) for handler in collector.handlers] - assert handlers - - -@pytest.mark.parametrize( - "event", - [ - SystemEvents.added_to_chat, - SystemEvents.deleted_from_chat, - SystemEvents.chat_created, - SystemEvents.file_transfer, - SystemEvents.left_from_chat, - SystemEvents.cts_login, - SystemEvents.cts_logout, - SystemEvents.smartapp_event, - ], -) -def test_defining_system_handler_in_collector_as_decorator( - handler_as_function, - extract_collector, - collector_cls, - event, -): - collector = collector_cls() - getattr(collector, event.name)()(handler_as_function) - assert SystemEvents(collector.handlers[0].body) == event - - -def test_error_when_no_event_was_passed( - handler_as_function, - extract_collector, - collector_cls, -): - collector = collector_cls() - with pytest.raises(AssertionError): - collector.system_event(handler=handler_as_function) diff --git a/tests/test_collecting/test_collector/test_execution.py b/tests/test_collecting/test_collector/test_execution.py deleted file mode 100644 index 1f04359b..00000000 --- a/tests/test_collecting/test_collector/test_execution.py +++ /dev/null @@ -1,45 +0,0 @@ -import threading - -import pytest - -from botx import Collector - -pytest_plugins = ("tests.test_collecting.fixtures",) -pytestmark = pytest.mark.asyncio - - -async def test_execution_when_full_match(message, build_handler): - event = threading.Event() - message.command.body = "/command" - - collector = Collector() - collector.add_handler(build_handler(event), body=message.command.body) - - await collector.handle_message(message) - - assert event.is_set() - - -async def test_executing_handler_when_partial_match(message, build_handler): - event = threading.Event() - message.command.body = "/command with arguments" - - collector = Collector() - collector.add_handler(build_handler(event), body=message.command.command) - - await collector.handle_message(message) - - assert event.is_set() - - -async def test_execution_internal_bot_notification( - internal_bot_notification_message, - build_handler, -): - event = threading.Event() - collector = Collector() - collector.internal_bot_notification(build_handler(event)) - - await collector.handle_message(internal_bot_notification_message) - - assert event.is_set() diff --git a/tests/test_collecting/test_collector/test_handler_definition_errors.py b/tests/test_collecting/test_collector/test_handler_definition_errors.py deleted file mode 100644 index b07dc764..00000000 --- a/tests/test_collecting/test_collector/test_handler_definition_errors.py +++ /dev/null @@ -1,12 +0,0 @@ -import pytest -from pydantic import ValidationError - -from botx import Collector - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_handler_can_not_consist_from_slashes_only(handler_as_function): - collector = Collector() - with pytest.raises(ValidationError): - collector.add_handler(handler_as_function, body="////") diff --git a/tests/test_collecting/test_collector/test_handlers_definition.py b/tests/test_collecting/test_collector/test_handlers_definition.py deleted file mode 100644 index 896313bb..00000000 --- a/tests/test_collecting/test_collector/test_handlers_definition.py +++ /dev/null @@ -1,9 +0,0 @@ -from botx import Collector - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_collector_default_handler_generating(default_handler): - collector = Collector(default=default_handler) - - assert collector.default_message_handler == default_handler diff --git a/tests/test_collecting/test_collector/test_handlers_order.py b/tests/test_collecting/test_collector/test_handlers_order.py deleted file mode 100644 index 92f2c6c3..00000000 --- a/tests/test_collecting/test_collector/test_handlers_order.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest - -from botx import Collector - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -@pytest.fixture() -def collector_with_handlers(handler_as_function): - collector = Collector() - for index in range(1, 31): - body = "/{0}".format("a" * index) - collector.add_handler(handler=handler_as_function, body=body, name=str(index)) - - return collector - - -def test_sorting_handlers_in_collector_by_body_length(collector_with_handlers): - added_handlers = collector_with_handlers.sorted_handlers - assert added_handlers == sorted( - added_handlers, - key=lambda handler: len(handler.body), - reverse=True, - ) - - -def test_preserve_length_sort_when_merging_collectors( - collector_with_handlers, - handler_as_function, -): - collector = Collector() - collector.add_handler(handler=handler_as_function, body="/{0}".format("a" * 1000)) - - collector_with_handlers.include_collector(collector) - - added_handlers = collector_with_handlers.sorted_handlers - assert added_handlers[0] == collector.handlers[0] diff --git a/tests/test_collecting/test_collector/test_handlers_search.py b/tests/test_collecting/test_collector/test_handlers_search.py deleted file mode 100644 index cad298d6..00000000 --- a/tests/test_collecting/test_collector/test_handlers_search.py +++ /dev/null @@ -1,11 +0,0 @@ -import pytest - -from botx.exceptions import NoMatchFound - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_raising_exception_when_searching_for_handler_and_no_found(collector_cls): - collector = collector_cls() - with pytest.raises(NoMatchFound): - collector.handler_for("not-existing-handler") diff --git a/tests/test_collecting/test_handler/__init__.py b/tests/test_collecting/test_handler/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_collecting/test_handler/test_attributes.py b/tests/test_collecting/test_handler/test_attributes.py deleted file mode 100644 index a86267e2..00000000 --- a/tests/test_collecting/test_handler/test_attributes.py +++ /dev/null @@ -1,22 +0,0 @@ -from botx.collecting.handlers.handler import Handler - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_handler_docstring_stored_as_full_description(handler_as_function): - handler = Handler(body="/command", handler=handler_as_function) - assert handler.full_description == handler_as_function.__doc__ - - -def test_handler_from_function(handler_as_function): - handler = Handler(body="/command", handler=handler_as_function) - assert handler.name == "handler_function" - - -def test_name_when_name_was_passed_explicitly(handler_as_function): - handler = Handler(handler=handler_as_function, body="/command", name="my_handler") - assert handler.name == "my_handler" - - -def test_any_body_for_hidden_handler(handler_as_function): - Handler(handler=handler_as_function, body="any text!", include_in_status=False) diff --git a/tests/test_collecting/test_handler/test_commands_generation.py b/tests/test_collecting/test_handler/test_commands_generation.py deleted file mode 100644 index 1ee5ed8b..00000000 --- a/tests/test_collecting/test_handler/test_commands_generation.py +++ /dev/null @@ -1,13 +0,0 @@ -from botx.collecting.handlers.handler import Handler - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_no_extra_space_on_command_built_through_command_for(handler_as_function): - handler = Handler(body="/command", handler=handler_as_function) - - assert handler.command_for() == "/command" - - built_command_with_args = handler.command_for(None, 1, "some string", True) - - assert built_command_with_args == "/command 1 some string True" diff --git a/tests/test_collecting/test_handler/test_constructing_errors.py b/tests/test_collecting/test_handler/test_constructing_errors.py deleted file mode 100644 index 3dbfd50b..00000000 --- a/tests/test_collecting/test_handler/test_constructing_errors.py +++ /dev/null @@ -1,31 +0,0 @@ -import pytest -from pydantic import ValidationError - -from botx.collecting.handlers.handler import Handler - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_error_handler_from_class(handler_as_class): - with pytest.raises(ValidationError): - Handler(body="/command", handler=handler_as_class) - - -def test_error_from_callable(handler_as_callable_object): - with pytest.raises(ValidationError): - Handler(body="/command", handler=handler_as_callable_object) - - -def test_slash_in_command_for_public_command(handler_as_function): - with pytest.raises(ValidationError): - Handler(body="command", handler=handler_as_function) - - -def test_only_one_slash_in_public_command(handler_as_function): - with pytest.raises(ValidationError): - Handler(body="//command", handler=handler_as_function) - - -def test_that_menu_command_contain_only_single_word(handler_as_function): - with pytest.raises(ValidationError): - Handler(body="/many words handler", handler=handler_as_function) diff --git a/tests/test_collecting/test_handler/test_equeality.py b/tests/test_collecting/test_handler/test_equeality.py deleted file mode 100644 index e6893a1d..00000000 --- a/tests/test_collecting/test_handler/test_equeality.py +++ /dev/null @@ -1,20 +0,0 @@ -from botx.collecting.handlers.handler import Handler - -pytest_plugins = ("tests.test_collecting.fixtures",) - - -def test_equality_is_false_if_not_handler_passed(handler_as_function): - handler = Handler(body="/command", handler=handler_as_function) - assert handler != "" - - -def test_equality_is_false_if_handlers_are_different(handler_as_function): - handler1 = Handler(body="/command1", handler=handler_as_function) - handler2 = Handler(body="/command2", handler=handler_as_function) - assert handler1 != handler2 - - -def test_equality_if_handlers_are_similar(handler_as_function): - handler1 = Handler(body="/command", handler=handler_as_function) - handler2 = Handler(body="/command", handler=handler_as_function) - assert handler1 == handler2 diff --git a/tests/test_dependencies/__init__.py b/tests/test_dependencies/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_dependencies/test_default_handler_dependencies.py b/tests/test_dependencies/test_default_handler_dependencies.py deleted file mode 100644 index 3c5aa631..00000000 --- a/tests/test_dependencies/test_default_handler_dependencies.py +++ /dev/null @@ -1,51 +0,0 @@ -import threading - -import pytest - -from botx import Collector, Depends - -pytestmark = pytest.mark.asyncio - - -async def test_dependencies_from_bot_on_default_handler( - bot, - incoming_message, - client, - build_handler, -): - event1 = threading.Event() - event2 = threading.Event() - - bot.collector = bot.exception_middleware.executor = Collector( - dependencies=[Depends(build_handler(event1))], - ) - - bot.default(build_handler(event2)) - - await client.send_command(incoming_message) - - assert event1.is_set() - assert event2.is_set() - - -async def test_dependency_saved_after_include_collector( - bot, - incoming_message, - client, - build_handler, -): - event1 = threading.Event() - event2 = threading.Event() - - collector = Collector() - collector.default(build_handler(event1)) - - bot.collector = bot.exception_middleware.executor = Collector( - dependencies=[Depends(build_handler(event2))], - ) - bot.include_collector(collector) - - await client.send_command(incoming_message) - - assert event1.is_set() - assert event2.is_set() diff --git a/tests/test_dependencies/test_dependencies_cache.py b/tests/test_dependencies/test_dependencies_cache.py deleted file mode 100644 index 60895ac8..00000000 --- a/tests/test_dependencies/test_dependencies_cache.py +++ /dev/null @@ -1,42 +0,0 @@ -import threading -from typing import Callable - -import pytest - -from botx import Depends - -pytestmark = pytest.mark.asyncio - - -def build_botx_dependency(lock: threading.Lock) -> Callable[[], None]: - def factory(): - lock.acquire() - - return factory - - -@pytest.fixture() -def build_dependency(): - return build_botx_dependency - - -async def test_dependency_executed_only_once_per_message( - bot, - client, - incoming_message, - build_dependency, - build_handler, -): - event = threading.Event() - lock = threading.Lock() - - dependency_function = build_dependency(lock) - - bot.default( - build_handler(event), - dependencies=[Depends(dependency_function), Depends(dependency_function)], - ) - - await client.send_command(incoming_message) - - assert event.is_set() diff --git a/tests/test_dependencies/test_errors.py b/tests/test_dependencies/test_errors.py deleted file mode 100644 index 13a80dce..00000000 --- a/tests/test_dependencies/test_errors.py +++ /dev/null @@ -1,12 +0,0 @@ -import pytest - -from botx.collecting.handlers.handler import Handler -from botx.dependencies.solving import get_executor - - -def test_error_when_creating_executor_without_call(build_handler): - handler = Handler(build_handler(...), body="/body") - dependant = handler.dependant - dependant.call = None - with pytest.raises(AssertionError): - get_executor(dependant) diff --git a/tests/test_dependencies/test_flow_control.py b/tests/test_dependencies/test_flow_control.py deleted file mode 100644 index 3ddcf012..00000000 --- a/tests/test_dependencies/test_flow_control.py +++ /dev/null @@ -1,51 +0,0 @@ -import threading -from typing import Callable - -import pytest - -from botx import DependencyFailure, Depends - -pytestmark = pytest.mark.asyncio - - -def build_botx_fail_dependency(event: threading.Event) -> Callable[[], None]: - def factory(): - event.set() - raise DependencyFailure - - return factory - - -@pytest.fixture() -def build_fail_dependency(): - return build_botx_fail_dependency - - -async def test_flow_stop_if_error_raised( - bot, - client, - incoming_message, - build_handler, - build_fail_dependency, -): - handler_event = threading.Event() - - dependency_event1 = threading.Event() - dependency_event2 = threading.Event() - dependency_event3 = threading.Event() - - bot.default( - handler=build_handler(handler_event), - dependencies=[ - Depends(build_handler(dependency_event1)), - Depends(build_botx_fail_dependency(dependency_event2)), - Depends(build_handler(dependency_event3)), - ], - ) - - await client.send_command(incoming_message) - - assert dependency_event1.is_set() - assert dependency_event2.is_set() - assert not dependency_event3.is_set() - assert not handler_event.is_set() diff --git a/tests/test_dependencies/test_overrides.py b/tests/test_dependencies/test_overrides.py deleted file mode 100644 index ecb8fa38..00000000 --- a/tests/test_dependencies/test_overrides.py +++ /dev/null @@ -1,81 +0,0 @@ -import threading - -import pytest - -from botx import Depends - -pytestmark = pytest.mark.asyncio - - -async def test_that_dependency_can_be_overriden( - bot, - client, - incoming_message, - build_handler, -): - handler_event = threading.Event() - original_dependency_event = threading.Event() - fake_dependency_event = threading.Event() - - dependency = build_handler(original_dependency_event) - bot.default(build_handler(handler_event), dependencies=[Depends(dependency)]) - - bot.dependency_overrides[dependency] = build_handler(fake_dependency_event) - - await client.send_command(incoming_message) - - assert handler_event.is_set() - assert not original_dependency_event.is_set() - assert fake_dependency_event.is_set() - - -async def test_bot_is_used_as_default_provider( - bot, - client, - incoming_message, - build_handler, -): - handler_event = threading.Event() - original_dependency_event = threading.Event() - fake_dependency_event = threading.Event() - - dependency = build_handler(original_dependency_event) - bot.default( - build_handler(handler_event), - dependencies=[Depends(dependency)], - dependency_overrides_provider=None, - ) - - bot.dependency_overrides[dependency] = build_handler(fake_dependency_event) - - await client.send_command(incoming_message) - - assert handler_event.is_set() - assert not original_dependency_event.is_set() - assert fake_dependency_event.is_set() - - -async def test_overrider_is_used_if_not_none( - bot, - client, - incoming_message, - build_handler, -): - handler_event = threading.Event() - original_dependency_event = threading.Event() - fake_dependency_event = threading.Event() - - dependency = build_handler(original_dependency_event) - bot.default( - build_handler(handler_event), - dependencies=[Depends(dependency)], - dependency_overrides_provider={}, - ) - - bot.dependency_overrides[dependency] = build_handler(fake_dependency_event) - - await client.send_command(incoming_message) - - assert handler_event.is_set() - assert not fake_dependency_event.is_set() - assert original_dependency_event.is_set() diff --git a/tests/test_dependencies/test_special_params/__init__.py b/tests/test_dependencies/test_special_params/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_dependencies/test_special_params/test_definition/__init__.py b/tests/test_dependencies/test_special_params/test_definition/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_dependencies/test_special_params/test_definition/test_async_client_param.py b/tests/test_dependencies/test_special_params/test_definition/test_async_client_param.py deleted file mode 100644 index cc912690..00000000 --- a/tests/test_dependencies/test_special_params/test_definition/test_async_client_param.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -from botx import AsyncClient - -pytestmark = pytest.mark.asyncio - - -@pytest.fixture() -def handler_with_dependency(storage): - def factory(client: AsyncClient) -> None: - storage.client = client - - return factory - - -async def test_passing_async_client_as_dependency( - bot, - client, - incoming_message, - handler_with_dependency, - storage, -): - bot.default(handler_with_dependency) - await client.send_command(incoming_message) - assert storage.client == bot.client diff --git a/tests/test_dependencies/test_special_params/test_definition/test_bot_param.py b/tests/test_dependencies/test_special_params/test_definition/test_bot_param.py deleted file mode 100644 index eb18cd1b..00000000 --- a/tests/test_dependencies/test_special_params/test_definition/test_bot_param.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -from botx import Bot - -pytestmark = pytest.mark.asyncio - - -@pytest.fixture() -def handler_with_dependency(storage): - def factory(bot: Bot) -> None: - storage.bot = bot - - return factory - - -async def test_passing_async_client_as_dependency( - bot, - client, - incoming_message, - handler_with_dependency, - storage, -): - bot.default(handler_with_dependency) - await client.send_command(incoming_message) - assert storage.bot == bot diff --git a/tests/test_dependencies/test_special_params/test_definition/test_depends_param.py b/tests/test_dependencies/test_special_params/test_definition/test_depends_param.py deleted file mode 100644 index 1db4167c..00000000 --- a/tests/test_dependencies/test_special_params/test_definition/test_depends_param.py +++ /dev/null @@ -1,29 +0,0 @@ -import pytest - -from botx import Depends - -pytestmark = pytest.mark.asyncio - - -def dependency(): - return 42 - - -@pytest.fixture() -def handler_with_dependency(storage): - def factory(dep: int = Depends(dependency)) -> None: # noqa: B008 - storage.dep = dep - - return factory - - -async def test_passing_async_client_as_dependency( - bot, - client, - incoming_message, - handler_with_dependency, - storage, -): - bot.default(handler_with_dependency) - await client.send_command(incoming_message) - assert storage.dep == 42 diff --git a/tests/test_dependencies/test_special_params/test_definition/test_message_param.py b/tests/test_dependencies/test_special_params/test_definition/test_message_param.py deleted file mode 100644 index 83aaac69..00000000 --- a/tests/test_dependencies/test_special_params/test_definition/test_message_param.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -from botx import Message - -pytestmark = pytest.mark.asyncio - - -@pytest.fixture() -def handler_with_dependency(storage): - def factory(message: Message) -> None: - storage.message = message - - return factory - - -async def test_passing_async_client_as_dependency( - bot, - client, - incoming_message, - handler_with_dependency, - storage, -): - bot.default(handler_with_dependency) - await client.send_command(incoming_message) - assert storage.message.incoming_message == incoming_message diff --git a/tests/test_dependencies/test_special_params/test_definition/test_sync_client_param.py b/tests/test_dependencies/test_special_params/test_definition/test_sync_client_param.py deleted file mode 100644 index 7bb3cd15..00000000 --- a/tests/test_dependencies/test_special_params/test_definition/test_sync_client_param.py +++ /dev/null @@ -1,25 +0,0 @@ -import pytest - -from botx import Client - -pytestmark = pytest.mark.asyncio - - -@pytest.fixture() -def handler_with_dependency(storage): - def factory(client: Client) -> None: - storage.client = client - - return factory - - -async def test_passing_async_client_as_dependency( - bot, - client, - incoming_message, - handler_with_dependency, - storage, -): - bot.default(handler_with_dependency) - await client.send_command(incoming_message) - assert storage.client == bot.sync_client diff --git a/tests/test_dependencies/test_special_params/test_errors.py b/tests/test_dependencies/test_special_params/test_errors.py deleted file mode 100644 index 0c618520..00000000 --- a/tests/test_dependencies/test_special_params/test_errors.py +++ /dev/null @@ -1,14 +0,0 @@ -import pytest - - -@pytest.fixture() -def handler_with_dependency(storage): - def factory(_: int) -> None: - """Just do nothing""" - - return factory - - -def test_error_for_wrong_param(bot, handler_with_dependency) -> None: - with pytest.raises(ValueError): - bot.default(handler_with_dependency) diff --git a/tests/test_dependencies/test_special_params/test_forward_references_solving.py b/tests/test_dependencies/test_special_params/test_forward_references_solving.py deleted file mode 100644 index 3fbfd5bc..00000000 --- a/tests/test_dependencies/test_special_params/test_forward_references_solving.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest - -from botx import Message - -pytestmark = pytest.mark.asyncio - - -@pytest.fixture() -def handler_with_dependency(storage): - def factory(message: "Message") -> None: - storage.message = message - - return factory - - -async def test_solving_forward_references_for_special_dependencies( - bot, - client, - incoming_message, - handler_with_dependency, - storage, -): - bot.default(handler_with_dependency) - - await client.send_command(incoming_message) - - assert storage.message.incoming_message == incoming_message diff --git a/tests/test_docs/__init__.py b/tests/test_docs/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_end_to_end.py b/tests/test_end_to_end.py new file mode 100644 index 00000000..27abf5c5 --- /dev/null +++ b/tests/test_end_to_end.py @@ -0,0 +1,345 @@ +import os +from http import HTTPStatus +from typing import List +from uuid import UUID + +import httpx +import pytest +from dotenv import load_dotenv +from fastapi import APIRouter, Depends, FastAPI, Request +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient +from loguru import logger +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + IncomingMessage, + UnknownBotAccountError, + build_bot_disabled_response, + build_command_accepted_response, +) + +# - Test utils - +load_dotenv() + + +def get_bot_accounts() -> List[BotAccountWithSecret]: + raw_credentials_list = os.getenv("BOT_CREDENTIALS") + if not raw_credentials_list: + raise RuntimeError("BOT_CREDENTIALS env not set") + + bot_accounts = [] + for raw_credentials in raw_credentials_list.split(","): + host, secret_key, raw_bot_id = raw_credentials.replace("|", "@").split("@") + bot_accounts.append( + BotAccountWithSecret( + id=UUID(raw_bot_id), + host=host, + secret_key=secret_key, + ), + ) + + return bot_accounts + + +# - Bot setup - +collector = HandlerCollector() + + +@collector.command("/debug", description="Simple debug command") +async def debug_handler(message: IncomingMessage, bot: Bot) -> None: + await bot.answer_message("Hi!") + + +def bot_factory( + bot_accounts: List[BotAccountWithSecret], +) -> Bot: + return Bot(collectors=[collector], bot_accounts=bot_accounts) + + +# - FastAPI integration - +def get_bot(request: Request) -> Bot: + assert isinstance(request.app.state.bot, Bot) + + return request.app.state.bot + + +bot_dependency = Depends(get_bot) + +router = APIRouter() + + +@router.post("/command") +async def command_handler( + request: Request, + bot: Bot = bot_dependency, +) -> JSONResponse: + try: + bot.async_execute_raw_bot_command(await request.json()) + except ValueError: + error_label = "Bot command validation error" + logger.exception(error_label) + + return JSONResponse( + build_bot_disabled_response(error_label), + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + ) + except UnknownBotAccountError as exc: + error_label = f"No credentials for bot {exc.bot_id}" + logger.warning(error_label) + + return JSONResponse( + build_bot_disabled_response(error_label), + status_code=HTTPStatus.SERVICE_UNAVAILABLE, + ) + + return JSONResponse( + build_command_accepted_response(), + status_code=HTTPStatus.ACCEPTED, + ) + + +@router.get("/status") +async def status_handler(request: Request, bot: Bot = bot_dependency) -> JSONResponse: + status = await bot.raw_get_status(dict(request.query_params)) + return JSONResponse(status) + + +@router.post("/notification/callback") +async def callback_handler( + request: Request, + bot: Bot = bot_dependency, +) -> JSONResponse: + bot.set_raw_botx_method_result(await request.json()) + return JSONResponse( + build_command_accepted_response(), + status_code=HTTPStatus.ACCEPTED, + ) + + +def fastapi_factory(bot: Bot) -> FastAPI: + application = FastAPI() + application.state.bot = bot + + application.add_event_handler("startup", bot.startup) + application.add_event_handler("shutdown", bot.shutdown) + + application.include_router(router) + + return application + + +# https://www.uvicorn.org/#application-factories +def asgi_factory() -> FastAPI: + bot_accounts = get_bot_accounts() + bot = bot_factory(bot_accounts) + return fastapi_factory(bot) + + +# - Tests - +@pytest.fixture +def bot(bot_account: BotAccountWithSecret) -> Bot: + return bot_factory(bot_accounts=[bot_account]) + + +pytestmark = [ + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +def test__web_app__bot_status( + bot_id: UUID, + bot: Bot, +) -> None: + # - Arrange - + query_params = { + "bot_id": str(bot_id), + "chat_type": "chat", + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + } + + # - Act - + with TestClient(fastapi_factory(bot)) as test_client: + response = test_client.get( + "/status", + params=query_params, + ) + + # - Assert - + assert response.status_code == HTTPStatus.OK + assert response.json() == { + "result": { + "commands": [ + { + "body": "/debug", + "description": "Simple debug command", + "name": "/debug", + }, + ], + "enabled": True, + "status_message": "Bot is working", + }, + "status": "ok", + } + + +def test__web_app__bot_command( + respx_mock: MockRouter, + bot_id: UUID, + host: str, + bot: Bot, +) -> None: + # - Arrange - + direct_notification_endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + command_payload = { + "bot_id": str(bot_id), + "command": { + "body": "/debug", + "command_type": "user", + "data": {}, + "metadata": {}, + }, + "attachments": [], + "async_files": [], + "entities": [], + "source_sync_id": None, + "sync_id": "6f40a492-4b5f-54f3-87ee-77126d825b51", + "from": { + "ad_domain": None, + "ad_login": None, + "app_version": None, + "chat_type": "chat", + "device": None, + "device_meta": { + "permissions": None, + "pushes": False, + "timezone": "Europe/Moscow", + }, + "device_software": None, + "group_chat_id": "30dc1980-643a-00ad-37fc-7cc10d74e935", + "host": "cts.example.com", + "is_admin": True, + "is_creator": True, + "locale": "en", + "manufacturer": None, + "platform": None, + "platform_package_id": None, + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + "username": None, + }, + "proto_version": 4, + } + + callback_payload = { + "status": "ok", + "sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3", + "result": {}, + } + + # - Act - + with TestClient(fastapi_factory(bot)) as test_client: + command_response = test_client.post( + "/command", + json=command_payload, + ) + + callback_response = test_client.post( + "/notification/callback", + json=callback_payload, + ) + + # - Assert - + assert command_response.status_code == HTTPStatus.ACCEPTED + assert direct_notification_endpoint.called + assert callback_response.status_code == HTTPStatus.ACCEPTED + + +def test__web_app__unknown_bot_response( + bot: Bot, +) -> None: + # - Arrange - + payload = { + "bot_id": "c755e147-30a5-45df-b46a-c75aa6089c8f", + "command": { + "body": "/debug", + "command_type": "user", + "data": {}, + "metadata": {}, + }, + "attachments": [], + "async_files": [], + "entities": [], + "source_sync_id": None, + "sync_id": "6f40a492-4b5f-54f3-87ee-77126d825b51", + "from": { + "ad_domain": None, + "ad_login": None, + "app_version": None, + "chat_type": "chat", + "device": None, + "device_meta": { + "permissions": None, + "pushes": False, + "timezone": "Europe/Moscow", + }, + "device_software": None, + "group_chat_id": "30dc1980-643a-00ad-37fc-7cc10d74e935", + "host": "cts.example.com", + "is_admin": True, + "is_creator": True, + "locale": "en", + "manufacturer": None, + "platform": None, + "platform_package_id": None, + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + "username": None, + }, + "proto_version": 4, + } + + # - Act - + with TestClient(fastapi_factory(bot)) as test_client: + response = test_client.post( + "/command", + json=payload, + ) + + # - Assert - + assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE + + +def test__web_app__disabled_bot_response( + bot: Bot, +) -> None: + # - Arrange - + payload = {"incorrect": "request"} + + # - Act - + with TestClient(fastapi_factory(bot)) as test_client: + response = test_client.post( + "/command", + json=payload, + ) + + # - Assert - + assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE + assert response.json() == { + "error_data": {"status_message": "Bot command validation error"}, + "errors": [], + "reason": "bot_disabled", + } diff --git a/tests/test_exception_handlers/__init__.py b/tests/test_exception_handlers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_exception_handlers/test_logging.py b/tests/test_exception_handlers/test_logging.py deleted file mode 100644 index 7e2b9e88..00000000 --- a/tests/test_exception_handlers/test_logging.py +++ /dev/null @@ -1,18 +0,0 @@ -import pytest - -pytestmark = pytest.mark.asyncio - - -async def test_logging_that_handler_was_not_found( - bot, - client, - incoming_message, - loguru_caplog, -) -> None: - await client.send_command(incoming_message) - - error_message = "handler for {0!r} was not found".format( - incoming_message.command.body, - ) - - assert error_message in loguru_caplog.text diff --git a/tests/test_exception_middleware.py b/tests/test_exception_middleware.py new file mode 100644 index 00000000..ede3fe51 --- /dev/null +++ b/tests/test_exception_middleware.py @@ -0,0 +1,112 @@ +import asyncio +from typing import Callable +from unittest.mock import MagicMock, call + +import pytest + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + IncomingMessage, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__exception_middleware__handler_called( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + exc = ValueError("test_error") + value_error_handler = MagicMock(asyncio.Future()) + + user_command = incoming_message_factory(body="/command") + + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + raise exc + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + exception_handlers={ValueError: value_error_handler}, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + assert len(value_error_handler.mock_calls) == 1 + assert value_error_handler.mock_calls[0] == call(user_command, built_bot, exc) + + +async def test__exception_middleware__without_handler_logs( + incoming_message_factory: Callable[..., IncomingMessage], + loguru_caplog: pytest.LogCaptureFixture, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + user_command = incoming_message_factory(body="/command") + + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + raise ValueError("Testing exception middleware") + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + assert "Uncaught exception ValueError" in loguru_caplog.text + assert "Testing exception middleware" in loguru_caplog.text + + +async def test__exception_middleware__error_in_handler_logs( + incoming_message_factory: Callable[..., IncomingMessage], + loguru_caplog: pytest.LogCaptureFixture, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + user_command = incoming_message_factory(body="/command") + + async def exception_handler( + message: IncomingMessage, + bot: Bot, + exc: Exception, + ) -> None: + raise ValueError("Nested error") + + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + raise ValueError("Testing exception middleware") + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + exception_handlers={Exception: exception_handler}, + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + assert "Uncaught exception ValueError in exception handler" in loguru_caplog.text + assert "Testing exception middleware" in loguru_caplog.text + assert "Nested error" in loguru_caplog.text diff --git a/tests/test_exceptions/__init__.py b/tests/test_exceptions/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_exceptions/test_botx_exception.py b/tests/test_exceptions/test_botx_exception.py deleted file mode 100644 index e51034a0..00000000 --- a/tests/test_exceptions/test_botx_exception.py +++ /dev/null @@ -1,8 +0,0 @@ -from botx.exceptions import BotXException - - -def test_to_string_fills_template(): - exc = BotXException(arg=42) - exc.message_template = "template with {arg}" - - assert str(exc) == "template with 42" diff --git a/tests/test_exceptions/test_route_deprecated_error.py b/tests/test_exceptions/test_route_deprecated_error.py deleted file mode 100644 index 2b2bb6c9..00000000 --- a/tests/test_exceptions/test_route_deprecated_error.py +++ /dev/null @@ -1,27 +0,0 @@ -import uuid - -import httpx -import pytest - -from botx.clients.methods.v3.chats.info import Info -from botx.concurrency import callable_to_coroutine -from botx.exceptions import BotXAPIRouteDeprecated - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_clients.fixtures",) - - -async def test_raising_route_deprecated_error(client, requests_client): - method = Info(host="example.com", group_chat_id=uuid.uuid4()) - errors_to_raise = {Info: (httpx.codes.GONE, {})} - - with client.error_client(errors=errors_to_raise): - request = requests_client.build_request(method) - response = await callable_to_coroutine(requests_client.execute, request) - - with pytest.raises(BotXAPIRouteDeprecated): - await callable_to_coroutine( - requests_client.process_response, - method, - response, - ) diff --git a/tests/test_exceptions/test_routing_match_error.py b/tests/test_exceptions/test_routing_match_error.py deleted file mode 100644 index 5184653e..00000000 --- a/tests/test_exceptions/test_routing_match_error.py +++ /dev/null @@ -1,13 +0,0 @@ -import pytest - -from botx.exceptions import NoMatchFound - -pytestmark = pytest.mark.asyncio - - -async def test_search_param_in_matching_error(bot, message, client): - with pytest.raises(NoMatchFound) as err_info: - await bot.collector.handle_message(message) - - error = err_info.value - assert error.search_param == message.command.body diff --git a/tests/test_exceptions/test_unknown_server.py b/tests/test_exceptions/test_unknown_server.py deleted file mode 100644 index cff8d424..00000000 --- a/tests/test_exceptions/test_unknown_server.py +++ /dev/null @@ -1,15 +0,0 @@ -import pytest - -from botx import UnknownBotError - -pytestmark = pytest.mark.asyncio - - -async def test_host_in_server_error(bot, incoming_message, client): - bot.bot_accounts = [] - - with pytest.raises(UnknownBotError) as err_info: - await client.send_command(incoming_message) - - error = err_info.value - assert error.bot_id == incoming_message.bot_id diff --git a/tests/test_files.py b/tests/test_files.py new file mode 100644 index 00000000..1274e43d --- /dev/null +++ b/tests/test_files.py @@ -0,0 +1,237 @@ +from http import HTTPStatus +from typing import Any, Callable, Dict, Optional +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import ( + AttachmentTypes, + Bot, + BotAccountWithSecret, + Document, + File, + HandlerCollector, + Image, + IncomingMessage, + Video, + Voice, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__async_file__open( + respx_mock: MockRouter, + host: str, + bot_account: BotAccountWithSecret, + bot_id: UUID, + api_incoming_message_factory: Callable[..., Dict[str, Any]], +) -> None: + # - Arrange - + endpoint = respx_mock.get( + f"https://{host}/api/v3/botx/files/download", + params={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80", + }, + headers={"Authorization": "Bearer token"}, + ).mock( + return_value=httpx.Response( + HTTPStatus.OK, + content=b"Hello, world!\n", + ), + ) + + payload = api_incoming_message_factory( + bot_id=bot_id, + async_file={ + "type": "image", + "file": "https://link.to/file", + "file_mime_type": "image/png", + "file_name": "pass.png", + "file_preview": "https://link.to/preview", + "file_preview_height": 300, + "file_preview_width": 300, + "file_size": 1502345, + "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + "file_encryption_algo": "stream", + "chunk_size": 2097152, + "file_id": "c3b9def2-b2c8-4732-b61f-99b9b110fa80", + }, + group_chat_id="054af49e-5e18-4dca-ad73-4f96b6de63fa", + host=host, + ) + + collector = HandlerCollector() + read_content: Optional[bytes] = None + + @collector.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + nonlocal read_content + + assert message.file + async with message.file.open() as fo: + read_content = await fo.read() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert read_content == b"Hello, world!\n" + assert endpoint.called + + +API_AND_DOMAIN_FILES = ( + ( + { + "type": "image", + "file": "https://link.to/file", + "file_mime_type": "image/png", + "file_name": "pass.png", + "file_preview": "https://link.to/preview", + "file_preview_height": 300, + "file_preview_width": 300, + "file_size": 1502345, + "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + "file_encryption_algo": "stream", + "chunk_size": 2097152, + "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + }, + Image( + type=AttachmentTypes.IMAGE, + filename="pass.png", + size=1502345, + is_async_file=True, + _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), + _file_url="https://link.to/file", + _file_mimetype="image/png", + _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + ), + ), + ( + { + "type": "video", + "file": "https://link.to/file", + "file_mime_type": "video/mp4", + "file_name": "pass.mp4", + "file_preview": "https://link.to/preview", + "file_preview_height": 300, + "file_preview_width": 300, + "file_size": 1502345, + "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + "file_encryption_algo": "stream", + "chunk_size": 2097152, + "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + "duration": 10, + }, + Video( + type=AttachmentTypes.VIDEO, + filename="pass.mp4", + size=1502345, + is_async_file=True, + duration=10, + _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), + _file_url="https://link.to/file", + _file_mimetype="video/mp4", + _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + ), + ), + ( + { + "type": "document", + "file": "https://link.to/file", + "file_mime_type": "plain/text", + "file_name": "pass.txt", + "file_preview": "https://link.to/preview", + "file_preview_height": 300, + "file_preview_width": 300, + "file_size": 1502345, + "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + "file_encryption_algo": "stream", + "chunk_size": 2097152, + "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + }, + Document( + type=AttachmentTypes.DOCUMENT, + filename="pass.txt", + size=1502345, + is_async_file=True, + _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), + _file_url="https://link.to/file", + _file_mimetype="plain/text", + _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + ), + ), + ( + { + "type": "voice", + "file": "https://link.to/file", + "file_mime_type": "audio/mp3", + "file_name": "pass.mp3", + "file_preview": "https://link.to/preview", + "file_preview_height": 300, + "file_preview_width": 300, + "file_size": 1502345, + "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + "file_encryption_algo": "stream", + "chunk_size": 2097152, + "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + "duration": 10, + }, + Voice( + type=AttachmentTypes.VOICE, + filename="pass.mp3", + size=1502345, + is_async_file=True, + duration=10, + _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), + _file_url="https://link.to/file", + _file_mimetype="audio/mp3", + _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + ), + ), +) + + +@pytest.mark.parametrize( + "api_async_file,domain_async_file", + API_AND_DOMAIN_FILES, +) +async def test__async_execute_raw_bot_command__different_file_types( + api_async_file: Dict[str, Any], + domain_async_file: File, + api_incoming_message_factory: Callable[..., Dict[str, Any]], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = api_incoming_message_factory(async_file=api_async_file) + + collector = HandlerCollector() + incoming_message: Optional[IncomingMessage] = None + + @collector.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + nonlocal incoming_message + incoming_message = message + # Drop `raw_command` from asserting + incoming_message.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert incoming_message + assert incoming_message.file == domain_async_file diff --git a/tests/test_handler_collector.py b/tests/test_handler_collector.py new file mode 100644 index 00000000..3177318e --- /dev/null +++ b/tests/test_handler_collector.py @@ -0,0 +1,509 @@ +from typing import Callable +from unittest.mock import Mock + +import pytest + +from botx import ( + Bot, + BotAccountWithSecret, + ChatCreatedEvent, + HandlerCollector, + IncomingMessage, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +def test__handler_collector__command_with_space_error_raised() -> None: + # - Arrange - + collector = HandlerCollector() + + with pytest.raises(ValueError) as exc: + + @collector.command("/ command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + pass + + # - Assert - + assert "include space" in str(exc.value) + + +def test__handler_collector__command_without_leading_slash_error_raised() -> None: + # - Arrange - + collector = HandlerCollector() + + with pytest.raises(ValueError) as exc: + + @collector.command("command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + pass + + # - Assert - + assert "should start with '/'" in str(exc.value) + + +def test__handler_collector__visible_command_without_description_error_raised() -> None: + # - Act - + collector = HandlerCollector() + + with pytest.raises(ValueError) as exc: + + @collector.command("/command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + pass + + # - Assert - + assert "Description is required" in str(exc.value) + + +def test__handler_collector__two_same_commands_error_raised() -> None: + # - Arrange - + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler_1(message: IncomingMessage, bot: Bot) -> None: + pass + + # - Act - + with pytest.raises(ValueError) as exc: + + @collector.command("/command", description="My command") + async def handler_2(message: IncomingMessage, bot: Bot) -> None: + pass + + # - Assert - + assert "already registered" in str(exc.value) + assert "/command" in str(exc.value) + + +def test__handler_collector__two_default_handlers_error_raised() -> None: + # - Arrange - + collector = HandlerCollector() + + @collector.default_message_handler + async def handler_1(message: IncomingMessage, bot: Bot) -> None: + pass + + # - Act - + with pytest.raises(ValueError) as exc: + + @collector.default_message_handler + async def handler_2(message: IncomingMessage, bot: Bot) -> None: + pass + + # - Assert - + assert "already registered" in str(exc.value) + assert "Default" in str(exc.value) + + +def test__handler_collector__two_same_system_events_handlers_error_raised() -> None: + # - Arrange - + collector = HandlerCollector() + + @collector.chat_created + async def handler_1(message: ChatCreatedEvent, bot: Bot) -> None: + pass + + # - Act - + with pytest.raises(ValueError) as exc: + + @collector.chat_created + async def handler_2(message: ChatCreatedEvent, bot: Bot) -> None: + pass + + # - Assert - + assert "already registered" in str(exc.value) + assert "Event" in str(exc.value) + + +def test___handler_collector__merge_collectors_with_same_command_error_raised() -> None: + # - Arrange - + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler_1(message: IncomingMessage, bot: Bot) -> None: + pass + + other_collector = HandlerCollector() + + @other_collector.command("/command", description="My command") + async def handler_2(message: IncomingMessage, bot: Bot) -> None: + pass + + # - Act - + with pytest.raises(ValueError) as exc: + collector.include(other_collector) + + # - Assert - + assert "already registered" in str(exc.value) + assert "/command" in str(exc.value) + + +def test__handler_collector__merge_collectors_with_default_handlers_error_raised() -> None: + # - Arrange - + collector = HandlerCollector() + + @collector.default_message_handler + async def handler_1(message: IncomingMessage, bot: Bot) -> None: + pass + + other_collector = HandlerCollector() + + @other_collector.default_message_handler + async def handler_2(message: IncomingMessage, bot: Bot) -> None: + pass + + # - Act - + with pytest.raises(ValueError) as exc: + collector.include(other_collector) + + # - Assert - + assert "already registered" in str(exc.value) + assert "Default" in str(exc.value) + + +def test__handler_collector__merge_collectors_with_same_system_events_handlers_error_raised() -> None: + # - Arrange - + collector = HandlerCollector() + + @collector.chat_created + async def handler_1(message: ChatCreatedEvent, bot: Bot) -> None: + pass + + other_collector = HandlerCollector() + + @other_collector.chat_created + async def handler_2(message: ChatCreatedEvent, bot: Bot) -> None: + pass + + # - Act - + with pytest.raises(ValueError) as exc: + collector.include(other_collector) + + # - Assert - + assert "already registered" in str(exc.value) + assert "event" in str(exc.value) + + +@pytest.mark.asyncio +async def test__handler_collector__command_handler_called( + incoming_message_factory: Callable[..., IncomingMessage], + correct_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + user_command = incoming_message_factory(body="/command") + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + correct_handler_trigger() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + correct_handler_trigger.assert_called_once() + + +@pytest.mark.asyncio +async def test__handler_collector__unicode_command_error_raised( + incoming_message_factory: Callable[..., IncomingMessage], + correct_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + russian_command = incoming_message_factory(body="/команда") + collector = HandlerCollector() + + @collector.command("/команда", description="Моя команда") + async def handler(message: IncomingMessage, bot: Bot) -> None: + correct_handler_trigger() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(russian_command) + + # - Assert - + correct_handler_trigger.assert_called_once() + + +@pytest.mark.asyncio +async def test__handler_collector__correct_command_handler_called( + incoming_message_factory: Callable[..., IncomingMessage], + correct_handler_trigger: Mock, + incorrect_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + user_command = incoming_message_factory(body="/command") + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def correct_handler(message: IncomingMessage, bot: Bot) -> None: + correct_handler_trigger() + + @collector.command("/other", description="My command") + async def incorrect_handler(message: IncomingMessage, bot: Bot) -> None: + incorrect_handler_trigger() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + correct_handler_trigger.assert_called_once() + incorrect_handler_trigger.assert_not_called() + + +@pytest.mark.asyncio +async def test__handler_collector__correct_command_handler_called_in_merged_collectors( + incoming_message_factory: Callable[..., IncomingMessage], + correct_handler_trigger: Mock, + incorrect_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + user_command = incoming_message_factory(body="/command") + + collector_1 = HandlerCollector() + collector_2 = HandlerCollector() + + @collector_1.command("/command", description="My command") + async def correct_handler(message: IncomingMessage, bot: Bot) -> None: + correct_handler_trigger() + + @collector_2.command("/command-two", description="My command") + async def incorrect_handler(message: IncomingMessage, bot: Bot) -> None: + incorrect_handler_trigger() + + built_bot = Bot(collectors=[collector_1, collector_2], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + correct_handler_trigger.assert_called_once() + incorrect_handler_trigger.assert_not_called() + + +@pytest.mark.asyncio +async def test__handler_collector__default_handler_called( + incoming_message_factory: Callable[..., IncomingMessage], + correct_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + user_command = incoming_message_factory(body="/command") + collector = HandlerCollector() + + @collector.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + correct_handler_trigger() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + correct_handler_trigger.assert_called_once() + + +@pytest.mark.asyncio +async def test__handler_collector__empty_command_goes_to_default_handler( + incoming_message_factory: Callable[..., IncomingMessage], + correct_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + empty_command = incoming_message_factory(body="") + collector = HandlerCollector() + + @collector.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + correct_handler_trigger() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(empty_command) + + # - Assert - + correct_handler_trigger.assert_called_once() + + +@pytest.mark.asyncio +async def test__handler_collector__invalid_command_goes_to_default_handler( + incoming_message_factory: Callable[..., IncomingMessage], + correct_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + empty_command = incoming_message_factory(body="/") + collector = HandlerCollector() + + @collector.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + correct_handler_trigger() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(empty_command) + + # - Assert - + correct_handler_trigger.assert_called_once() + + +@pytest.mark.asyncio +async def test__handler_collector__handler_not_found_logged( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, + loguru_caplog: pytest.LogCaptureFixture, +) -> None: + # - Arrange - + user_command = incoming_message_factory(body="/command") + collector = HandlerCollector() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + assert "`/command` not found" in loguru_caplog.text + + +@pytest.mark.asyncio +async def test__handler_collector__default_handler_in_first_collector_called( + incoming_message_factory: Callable[..., IncomingMessage], + correct_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + user_command = incoming_message_factory(body="/command") + + collector_1 = HandlerCollector() + collector_2 = HandlerCollector() + + @collector_1.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + correct_handler_trigger() + + built_bot = Bot(collectors=[collector_1, collector_2], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + correct_handler_trigger.assert_called_once() + + +@pytest.mark.asyncio +async def test__handler_collector__default_handler_in_second_collector_called( + incoming_message_factory: Callable[..., IncomingMessage], + correct_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + user_command = incoming_message_factory(body="/command") + + collector_1 = HandlerCollector() + collector_2 = HandlerCollector() + + @collector_2.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + correct_handler_trigger() + + built_bot = Bot(collectors=[collector_1, collector_2], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + correct_handler_trigger.assert_called_once() + + +@pytest.mark.asyncio +async def test__handler_collector__handler_not_found_exception_logged( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, + loguru_caplog: pytest.LogCaptureFixture, +) -> None: + # - Arrange - + bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + bot.async_execute_bot_command(incoming_message_factory(body="/command")) + await bot.shutdown() + + # - Assert - + assert "`/command` not found" in loguru_caplog.text + + +@pytest.mark.asyncio +async def test__handler_collector__handle_incoming_message_by_command_handler_not_found_exception_logged( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, + loguru_caplog: pytest.LogCaptureFixture, +) -> None: + # - Arrange - + collector = HandlerCollector() + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await collector.handle_incoming_message_by_command( + incoming_message_factory(body="Text"), + bot, + command="/command", + ) + + # - Assert - + assert "`/command` not found" in loguru_caplog.text + + +@pytest.mark.asyncio +async def test__handler_collector__handle_incoming_message_by_command_succeed( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, + correct_handler_trigger: Mock, +) -> None: + # - Arrange - + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + correct_handler_trigger() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await collector.handle_incoming_message_by_command( + incoming_message_factory(body="Text"), + bot, + command="/command", + ) + + # - Assert - + correct_handler_trigger.assert_called_once() diff --git a/tests/test_incoming_message.py b/tests/test_incoming_message.py new file mode 100644 index 00000000..0284146a --- /dev/null +++ b/tests/test_incoming_message.py @@ -0,0 +1,475 @@ +from datetime import datetime +from typing import Callable, Optional +from uuid import UUID + +import pytest + +from botx import ( + AttachmentTypes, + Bot, + BotAccount, + BotAccountWithSecret, + Chat, + ChatTypes, + ClientPlatforms, + Forward, + HandlerCollector, + Image, + IncomingMessage, + Mention, + MentionList, + MentionTypes, + Reply, + UserDevice, + UserSender, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__async_execute_raw_bot_command__minimally_filled_incoming_message( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = { + "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "command": { + "body": "/hello", + "command_type": "user", + "data": {}, + "metadata": {}, + }, + "attachments": [], + "async_files": [], + "entities": [], + "source_sync_id": None, + "sync_id": "6f40a492-4b5f-54f3-87ee-77126d825b51", + "from": { + "ad_domain": None, + "ad_login": None, + "app_version": None, + "chat_type": "chat", + "device": None, + "device_meta": None, + "device_software": None, + "group_chat_id": "30dc1980-643a-00ad-37fc-7cc10d74e935", + "host": "cts.example.com", + "is_admin": True, + "is_creator": True, + "locale": "en", + "manufacturer": None, + "platform": None, + "platform_package_id": None, + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + "username": None, + }, + "proto_version": 4, + } + + collector = HandlerCollector() + incoming_message: Optional[IncomingMessage] = None + + @collector.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + nonlocal incoming_message + incoming_message = message + # Drop `raw_command` from asserting + incoming_message.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert incoming_message == IncomingMessage( + bot=BotAccount( + id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"), + host="cts.example.com", + ), + sync_id=UUID("6f40a492-4b5f-54f3-87ee-77126d825b51"), + source_sync_id=None, + body="/hello", + data={}, + metadata={}, + sender=UserSender( + huid=UUID("f16cdc5f-6366-5552-9ecd-c36290ab3d11"), + ad_login=None, + ad_domain=None, + username=None, + is_chat_admin=True, + is_chat_creator=True, + device=UserDevice( + manufacturer=None, + device_name=None, + os=None, + pushes=None, + timezone=None, + permissions=None, + platform=None, + platform_package_id=None, + app_version=None, + locale="en", + ), + ), + chat=Chat( + id=UUID("30dc1980-643a-00ad-37fc-7cc10d74e935"), + type=ChatTypes.PERSONAL_CHAT, + ), + raw_command=None, + ) + + +async def test__async_execute_raw_bot_command__maximum_filled_incoming_message( + datetime_formatter: Callable[[str], datetime], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = { + "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "command": { + "body": "/hello", + "command_type": "user", + "data": {"message": "data"}, + "metadata": {"message": "metadata"}, + }, + "attachments": [], + "async_files": [ + { + "type": "image", + "file": "https://link.to/file", + "file_mime_type": "image/png", + "file_name": "pass.png", + "file_preview": "https://link.to/preview", + "file_preview_height": 300, + "file_preview_width": 300, + "file_size": 1502345, + "file_hash": "Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + "file_encryption_algo": "stream", + "chunk_size": 2097152, + "file_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", + }, + ], + "entities": [ + { + "type": "reply", + "data": { + "source_sync_id": "a7ffba12-8d0a-534e-8896-a0aa2d93a434", + "sender": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f", + "body": "все равно документацию никто не читает...", + "mentions": [ + { + "mention_type": "contact", + "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f", + "mention_data": { + "user_huid": "ab103983-6001-44e9-889e-d55feb295494", + "name": "Вася Иванов", + "conn_type": "cts", + }, + }, + ], + "attachment": None, + "reply_type": "chat", + "source_group_chat_id": "918da23a-1c9a-506e-8a6f-1328f1499ee8", + "source_chat_name": "Serious Dev Chat", + }, + }, + { + "type": "forward", + "data": { + "group_chat_id": "918da23a-1c9a-506e-8a6f-1328f1499ee8", + "sender_huid": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f", + "forward_type": "chat", + "source_chat_name": "Simple Chat", + "source_sync_id": "a7ffba12-8d0a-534e-8896-a0aa2d93a434", + "source_inserted_at": "2020-04-21T22:09:32.178Z", + }, + }, + { + "type": "mention", + "data": { + "mention_type": "contact", + "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f", + "mention_data": { + "user_huid": "ab103983-6001-44e9-889e-d55feb295494", + "name": "Вася Иванов", + "conn_type": "cts", + }, + }, + }, + ], + "source_sync_id": "bc3d06ed-7b2e-41ad-99f9-ca28adc2c88d", + "sync_id": "6f40a492-4b5f-54f3-87ee-77126d825b51", + "from": { + "ad_domain": "domain", + "ad_login": "login", + "app_version": "1.21.9", + "chat_type": "chat", + "device": "Firefox 91.0", + "device_meta": { + "permissions": { + "microphone": True, + "notifications": False, + }, + "pushes": False, + "timezone": "Europe/Moscow", + }, + "device_software": "Linux", + "group_chat_id": "30dc1980-643a-00ad-37fc-7cc10d74e935", + "host": "cts.example.com", + "is_admin": True, + "is_creator": True, + "locale": "en", + "manufacturer": "Mozilla", + "platform": "web", + "platform_package_id": "ru.unlimitedtech.express", + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + "username": "Ivanov Ivan Ivanovich", + }, + "proto_version": 4, + } + + collector = HandlerCollector() + incoming_message: Optional[IncomingMessage] = None + + @collector.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + nonlocal incoming_message + incoming_message = message + # Drop `raw_command` from asserting + incoming_message.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert incoming_message == IncomingMessage( + bot=BotAccount( + id=UUID("24348246-6791-4ac0-9d86-b948cd6a0e46"), + host="cts.example.com", + ), + sync_id=UUID("6f40a492-4b5f-54f3-87ee-77126d825b51"), + source_sync_id=UUID("bc3d06ed-7b2e-41ad-99f9-ca28adc2c88d"), + body="/hello", + data={"message": "data"}, + metadata={"message": "metadata"}, + sender=UserSender( + huid=UUID("f16cdc5f-6366-5552-9ecd-c36290ab3d11"), + ad_login="login", + ad_domain="domain", + username="Ivanov Ivan Ivanovich", + is_chat_admin=True, + is_chat_creator=True, + device=UserDevice( + manufacturer="Mozilla", + device_name="Firefox 91.0", + os="Linux", + pushes=False, + timezone="Europe/Moscow", + permissions={"microphone": True, "notifications": False}, + platform=ClientPlatforms.WEB, + platform_package_id="ru.unlimitedtech.express", + app_version="1.21.9", + locale="en", + ), + ), + chat=Chat( + id=UUID("30dc1980-643a-00ad-37fc-7cc10d74e935"), + type=ChatTypes.PERSONAL_CHAT, + ), + raw_command=None, + file=Image( + type=AttachmentTypes.IMAGE, + filename="pass.png", + size=1502345, + is_async_file=True, + _file_id=UUID("8dada2c8-67a6-4434-9dec-570d244e78ee"), + _file_url="https://link.to/file", + _file_mimetype="image/png", + _file_hash="Jd9r+OKpw5y+FSCg1xNTSUkwEo4nCW1Sn1AkotkOpH0=", + ), + mentions=MentionList( + [ + Mention( + type=MentionTypes.CONTACT, + entity_id=UUID("ab103983-6001-44e9-889e-d55feb295494"), + name="Вася Иванов", + ), + ], + ), + forward=Forward( + chat_id=UUID("918da23a-1c9a-506e-8a6f-1328f1499ee8"), + author_id=UUID("c06a96fa-7881-0bb6-0e0b-0af72fe3683f"), + sync_id=UUID("a7ffba12-8d0a-534e-8896-a0aa2d93a434"), + ), + reply=Reply( + author_id=UUID("c06a96fa-7881-0bb6-0e0b-0af72fe3683f"), + sync_id=UUID("a7ffba12-8d0a-534e-8896-a0aa2d93a434"), + body="все равно документацию никто не читает...", + mentions=MentionList( + [ + Mention( + type=MentionTypes.CONTACT, + entity_id=UUID("ab103983-6001-44e9-889e-d55feb295494"), + name="Вася Иванов", + ), + ], + ), + ), + ) + + +async def test__async_execute_raw_bot_command__all_mention_types( + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + payload = { + "bot_id": "24348246-6791-4ac0-9d86-b948cd6a0e46", + "command": { + "body": "/hello", + "command_type": "user", + "data": {}, + "metadata": {}, + }, + "attachments": [], + "async_files": [], + "entities": [ + { + "type": "mention", + "data": { + "mention_type": "contact", + "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f", + "mention_data": { + "user_huid": "ab103983-6001-44e9-889e-d55feb295494", + "name": "Вася Иванов", + "conn_type": "cts", + }, + }, + }, + { + "type": "mention", + "data": { + "mention_type": "user", + "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f", + "mention_data": { + "user_huid": "ab103983-6001-44e9-889e-d55feb295494", + "name": "Вася Иванов", + "conn_type": "cts", + }, + }, + }, + { + "type": "mention", + "data": { + "mention_type": "channel", + "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f", + "mention_data": { + "group_chat_id": "ab103983-6001-44e9-889e-d55feb295494", + "name": "Вася Иванов", + }, + }, + }, + { + "type": "mention", + "data": { + "mention_type": "chat", + "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f", + "mention_data": { + "group_chat_id": "ab103983-6001-44e9-889e-d55feb295494", + "name": "Вася Иванов", + }, + }, + }, + { + "type": "mention", + "data": { + "mention_type": "all", + "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f", + "mention_data": {}, + }, + }, + ], + "source_sync_id": None, + "sync_id": "6f40a492-4b5f-54f3-87ee-77126d825b51", + "from": { + "ad_domain": None, + "ad_login": None, + "app_version": None, + "chat_type": "chat", + "device": None, + "device_meta": { + "permissions": None, + "pushes": False, + "timezone": "Europe/Moscow", + }, + "device_software": None, + "group_chat_id": "30dc1980-643a-00ad-37fc-7cc10d74e935", + "host": "cts.example.com", + "is_admin": True, + "is_creator": True, + "locale": "en", + "manufacturer": None, + "platform": None, + "platform_package_id": None, + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + "username": None, + }, + "proto_version": 4, + } + + collector = HandlerCollector() + incoming_message: Optional[IncomingMessage] = None + + @collector.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + nonlocal incoming_message + incoming_message = message + # Drop `raw_command` from asserting + incoming_message.raw_command = None + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert incoming_message + assert incoming_message.mentions == MentionList( + [ + Mention( + type=MentionTypes.CONTACT, + entity_id=UUID("ab103983-6001-44e9-889e-d55feb295494"), + name="Вася Иванов", + ), + Mention( + type=MentionTypes.USER, + entity_id=UUID("ab103983-6001-44e9-889e-d55feb295494"), + name="Вася Иванов", + ), + Mention( + type=MentionTypes.CHANNEL, + entity_id=UUID("ab103983-6001-44e9-889e-d55feb295494"), + name="Вася Иванов", + ), + Mention( + type=MentionTypes.CHAT, + entity_id=UUID("ab103983-6001-44e9-889e-d55feb295494"), + name="Вася Иванов", + ), + Mention( + type=MentionTypes.ALL, + entity_id=None, + name=None, + ), + ], + ) diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py new file mode 100644 index 00000000..13197747 --- /dev/null +++ b/tests/test_lifespan.py @@ -0,0 +1,76 @@ +from http import HTTPStatus +from typing import Callable +from unittest.mock import Mock +from uuid import UUID + +import httpx +import pytest +from respx.router import MockRouter + +from botx import Bot, BotAccountWithSecret, HandlerCollector, IncomingMessage + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__shutdown__wait_for_active_handlers( + incoming_message_factory: Callable[..., IncomingMessage], + correct_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + user_command = incoming_message_factory(body="/command") + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + correct_handler_trigger() + + bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + bot.async_execute_bot_command(user_command) + await bot.shutdown() + + # - Assert - + correct_handler_trigger.assert_called_once() + + +async def test__startup__authorize_cant_get_token( + respx_mock: MockRouter, + loguru_caplog: pytest.LogCaptureFixture, + bot_account: BotAccountWithSecret, + host: str, + bot_id: UUID, + bot_signature: str, +) -> None: + # - Arrange - + token_endpoint = respx_mock.get( + f"https://{host}/api/v2/botx/bots/{bot_id}/token", + params={"signature": bot_signature}, + ).mock( + return_value=httpx.Response( + HTTPStatus.UNAUTHORIZED, + json={ + "status": "error", + }, + ), + ) + + collector = HandlerCollector() + + bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + await bot.startup() + + # - Assert - + assert token_endpoint.called + + assert "Can't get token for bot account: " in loguru_caplog.text + assert f"host - {host}, bot_id - {bot_id}" in loguru_caplog.text + + await bot.shutdown() diff --git a/tests/test_logs.py b/tests/test_logs.py new file mode 100644 index 00000000..56093595 --- /dev/null +++ b/tests/test_logs.py @@ -0,0 +1,128 @@ +from http import HTTPStatus +from typing import Any, Callable, Dict, Optional, cast +from uuid import UUID + +import httpx +import pytest +from aiofiles.tempfile import NamedTemporaryFile +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + IncomingMessage, + lifespan_wrapper, +) +from botx.models.attachments import AttachmentDocument, OutgoingAttachment + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__attachment__trimmed_in_incoming_message( + bot_account: BotAccountWithSecret, + api_incoming_message_factory: Callable[..., Dict[str, Any]], + loguru_caplog: pytest.LogCaptureFixture, +) -> None: + payload = api_incoming_message_factory( + attachment={ + "data": { + "content": ( + "data:text/plain;base64," + "SGVsbG8sIGFtYXppbmcgd29ybGQhIFZlcnkgdmVyeSB2ZXJ5IHZlcnkgdm" + "VyeSB2ZXJ5IGxvbmcgdGV4dCB0byB0ZXN0IHRoYXQgdHJpbW1pbmcgY29u" + "dGVudCBkb2Vzbid0IGFmZmVjdCBmaWxlIGluIGluY29taW5nIG1lc3NhZ2U=" + ), + "file_name": "test_file.jpg", + }, + "type": "image", + }, + ) + collector = HandlerCollector() + file_data: Optional[bytes] = None + + @collector.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + nonlocal file_data + file = cast(AttachmentDocument, message.file) + file_data = file.content + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert "..." in loguru_caplog.text + assert file_data == ( + b"Hello, amazing world! Very very very very very very long text to" + b" test that trimming content doesn't affect file in incoming message" + ) + + +async def test__attachment__trimmed_in_outgoing_message( + respx_mock: MockRouter, + host: str, + bot_id: UUID, + bot_account: BotAccountWithSecret, + loguru_caplog: pytest.LogCaptureFixture, +) -> None: + # - Arrange - + endpoint = respx_mock.post( + f"https://{host}/api/v4/botx/notifications/direct", + headers={"Authorization": "Bearer token", "Content-Type": "application/json"}, + json={ + "group_chat_id": "054af49e-5e18-4dca-ad73-4f96b6de63fa", + "notification": {"status": "ok", "body": "Hi!"}, + "file": { + "file_name": "test.txt", + "data": ( + "data:text/plain;base64," + "SGVsbG8sIGFtYXppbmcgd29ybGQhIFZlcnkgdmVyeSB2ZXJ5IHZlcnkgdm" + "VyeSB2ZXJ5IGxvbmcgdGV4dCB0byB0ZXN0IHRoYXQgdHJpbW1pbmcgY29u" + "dGVudCBkb2Vzbid0IGFmZmVjdCBmaWxlIGluIGluY29taW5nIG1lc3NhZ2U=" + ), + }, + }, + ).mock( + return_value=httpx.Response( + HTTPStatus.ACCEPTED, + json={ + "status": "ok", + "result": {"sync_id": "21a9ec9e-f21f-4406-ac44-1a78d2ccf9e3"}, + }, + ), + ) + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + async with NamedTemporaryFile("wb+") as async_buffer: + await async_buffer.write( + b"Hello, amazing world! Very very very very very very long text to" + b" test that trimming content doesn't affect file in incoming message", + ) + await async_buffer.seek(0) + + file = await OutgoingAttachment.from_async_buffer( + async_buffer, + "test.txt", + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + await bot.send_message( + body="Hi!", + bot_id=bot_id, + chat_id=UUID("054af49e-5e18-4dca-ad73-4f96b6de63fa"), + file=file, + wait_callback=False, + ) + + # - Assert - + assert "..." in loguru_caplog.text + assert endpoint.called diff --git a/tests/test_middlewares.py b/tests/test_middlewares.py new file mode 100644 index 00000000..a7d29143 --- /dev/null +++ b/tests/test_middlewares.py @@ -0,0 +1,211 @@ +from typing import Callable +from unittest.mock import Mock + +import pytest + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + IncomingMessage, + IncomingMessageHandlerFunc, + Middleware, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__middlewares__correct_order( + incoming_message_factory: Callable[..., IncomingMessage], + correct_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + middlewares_called_order = [] + user_command = incoming_message_factory(body="/command") + + def middleware_factory(number: int) -> Middleware: + async def middleware( + message: IncomingMessage, + bot: Bot, + call_next: IncomingMessageHandlerFunc, + ) -> None: + nonlocal middlewares_called_order + middlewares_called_order.append(number) + + await call_next(message, bot) + + return middleware + + collector = HandlerCollector( + middlewares=[middleware_factory(3), middleware_factory(4)], + ) + + @collector.command( + "/command", + description="My command", + middlewares=[middleware_factory(5), middleware_factory(6)], + ) + async def handler(message: IncomingMessage, bot: Bot) -> None: + correct_handler_trigger() + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + middlewares=[middleware_factory(1), middleware_factory(2)], + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + correct_handler_trigger.assert_called_once() + + assert middlewares_called_order == [1, 2, 3, 4, 5, 6] + + +async def test__middlewares__called_in_default_handler( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + middlewares_called_order = [] + user_command = incoming_message_factory(body="/command") + + def middleware_factory(number: int) -> Middleware: + async def middleware( + message: IncomingMessage, + bot: Bot, + call_next: IncomingMessageHandlerFunc, + ) -> None: + nonlocal middlewares_called_order + middlewares_called_order.append(number) + + await call_next(message, bot) + + return middleware + + collector = HandlerCollector( + middlewares=[middleware_factory(3), middleware_factory(4)], + ) + + @collector.default_message_handler( + middlewares=[middleware_factory(5), middleware_factory(6)], + ) + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + pass + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + middlewares=[middleware_factory(1), middleware_factory(2)], + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + assert middlewares_called_order == [1, 2, 3, 4, 5, 6] + + +async def test__middlewares__correct_child_collector_middlewares( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + middlewares_called_order = [] + user_command = incoming_message_factory(body="/command") + + def middleware_factory(number: int) -> Middleware: + async def middleware( + message: IncomingMessage, + bot: Bot, + call_next: IncomingMessageHandlerFunc, + ) -> None: + nonlocal middlewares_called_order + middlewares_called_order.append(number) + + await call_next(message, bot) + + return middleware + + collector_1 = HandlerCollector( + middlewares=[middleware_factory(1), middleware_factory(2)], + ) + + @collector_1.command("/other-command", description="My command") + async def handler_1(message: IncomingMessage, bot: Bot) -> None: + pass + + collector_2 = HandlerCollector( + middlewares=[middleware_factory(3), middleware_factory(4)], + ) + + @collector_2.command("/command", description="My command") + async def handler_2(message: IncomingMessage, bot: Bot) -> None: + pass + + collector_1.include(collector_2) + built_bot = Bot(collectors=[collector_1], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + assert middlewares_called_order == [1, 2, 3, 4] + + +async def test__middlewares__correct_parent_collector_middlewares( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + middlewares_called_order = [] + user_command = incoming_message_factory(body="/command") + + def middleware_factory(number: int) -> Middleware: + async def middleware( + message: IncomingMessage, + bot: Bot, + call_next: IncomingMessageHandlerFunc, + ) -> None: + nonlocal middlewares_called_order + middlewares_called_order.append(number) + + await call_next(message, bot) + + return middleware + + collector_1 = HandlerCollector( + middlewares=[middleware_factory(1), middleware_factory(2)], + ) + + @collector_1.command("/command", description="My command") + async def handler_1(message: IncomingMessage, bot: Bot) -> None: + pass + + collector_2 = HandlerCollector( + middlewares=[middleware_factory(3), middleware_factory(4)], + ) + + @collector_2.command("/other-command", description="My command") + async def handler_2(message: IncomingMessage, bot: Bot) -> None: + pass + + collector_1.include(collector_2) + built_bot = Bot(collectors=[collector_1], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + assert middlewares_called_order == [1, 2] diff --git a/tests/test_middlewares/__init__.py b/tests/test_middlewares/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_middlewares/test_authorization_middleware.py b/tests/test_middlewares/test_authorization_middleware.py deleted file mode 100644 index 32abdb96..00000000 --- a/tests/test_middlewares/test_authorization_middleware.py +++ /dev/null @@ -1,26 +0,0 @@ -import pytest - -from botx.middlewares.authorization import AuthorizationMiddleware - -pytestmark = pytest.mark.asyncio - - -async def test_obtaining_token_if_not_set(client, incoming_message): - bot = client.bot - bot.add_middleware(AuthorizationMiddleware) - bot_account = bot.get_account_by_bot_id(incoming_message.bot_id) - bot_account.token = None - - await client.send_command(incoming_message) - - assert bot.get_token_for_bot(incoming_message.bot_id) - - -async def test_doing_nothing_if_token_present(client, incoming_message): - bot = client.bot - bot.add_middleware(AuthorizationMiddleware) - token = bot.get_token_for_bot(incoming_message.bot_id) - - await client.send_command(incoming_message) - - assert bot.get_token_for_bot(incoming_message.bot_id) == token diff --git a/tests/test_middlewares/test_concurrency/__init__.py b/tests/test_middlewares/test_concurrency/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_middlewares/test_concurrency/fixtures.py b/tests/test_middlewares/test_concurrency/fixtures.py deleted file mode 100644 index 187d1cb1..00000000 --- a/tests/test_middlewares/test_concurrency/fixtures.py +++ /dev/null @@ -1,37 +0,0 @@ -import threading - -import pytest - -from botx import Message -from botx.middlewares.base import BaseMiddleware -from botx.typing import AsyncExecutor, Executor, SyncExecutor - - -class SyncMiddleware(BaseMiddleware): - def __init__(self, executor: Executor) -> None: - super().__init__(executor) - self.event = threading.Event() - - def dispatch(self, message: Message, call_next: SyncExecutor) -> None: - self.event.set() - call_next(message) - - -class AsyncMiddleware(BaseMiddleware): - def __init__(self, executor: Executor) -> None: - super().__init__(executor) - self.event = threading.Event() - - async def dispatch(self, message: Message, call_next: AsyncExecutor) -> None: - self.event.set() - await call_next(message) - - -@pytest.fixture() -def sync_middleware_class(): - return SyncMiddleware - - -@pytest.fixture() -def async_middleware_class(): - return AsyncMiddleware diff --git a/tests/test_middlewares/test_concurrency/test_async_processing.py b/tests/test_middlewares/test_concurrency/test_async_processing.py deleted file mode 100644 index 760e70a0..00000000 --- a/tests/test_middlewares/test_concurrency/test_async_processing.py +++ /dev/null @@ -1,28 +0,0 @@ -import threading - -import pytest - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_middlewares.test_concurrency.fixtures",) - - -async def test_async_middleware_receives_async_executor( - bot, - client, - incoming_message, - build_handler, - async_middleware_class, -): - bot.add_middleware(async_middleware_class) - - event = threading.Event() - bot.default(build_handler(event)) - - await client.send_command(incoming_message) - - assert event.is_set() - - executor = bot.exception_middleware.executor - - assert isinstance(executor, async_middleware_class) - assert executor.event.is_set() diff --git a/tests/test_middlewares/test_concurrency/test_sync_async_combinaton.py b/tests/test_middlewares/test_concurrency/test_sync_async_combinaton.py deleted file mode 100644 index 254b871d..00000000 --- a/tests/test_middlewares/test_concurrency/test_sync_async_combinaton.py +++ /dev/null @@ -1,44 +0,0 @@ -import threading - -import pytest - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_middlewares.test_concurrency.fixtures",) - - -@pytest.mark.parametrize( - "order", - [("sync", "sync"), ("sync", "async"), ("async", "sync"), ("async", "async")], -) -async def test_that_both_async_and_sync_middlewares_will_work( - bot, - client, - incoming_message, - async_middleware_class, - sync_middleware_class, - build_handler, - order, -): - event = threading.Event() - - if order[0] == "sync": - bot.add_middleware(sync_middleware_class) - else: - bot.add_middleware(async_middleware_class) - - if order[1] == "sync": - bot.add_middleware(sync_middleware_class) - else: - bot.add_middleware(async_middleware_class) - - bot.default(build_handler(event)) - - await client.send_command(incoming_message) - - assert event.is_set() - - middleware1 = bot.exception_middleware.executor - assert middleware1.event.is_set() - - middleware2 = middleware1.executor - assert middleware2.event.is_set() diff --git a/tests/test_middlewares/test_concurrency/test_sync_processing.py b/tests/test_middlewares/test_concurrency/test_sync_processing.py deleted file mode 100644 index f2bd7fc2..00000000 --- a/tests/test_middlewares/test_concurrency/test_sync_processing.py +++ /dev/null @@ -1,28 +0,0 @@ -import threading - -import pytest - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_middlewares.test_concurrency.fixtures",) - - -async def test_sync_middleware_receives_sync_executor( - bot, - client, - incoming_message, - build_handler, - sync_middleware_class, -): - bot.add_middleware(sync_middleware_class) - - event = threading.Event() - bot.default(build_handler(event)) - - await client.send_command(incoming_message) - - assert event.is_set() - - executor = bot.exception_middleware.executor - - assert isinstance(executor, sync_middleware_class) - assert executor.event.is_set() diff --git a/tests/test_middlewares/test_exception_middleware.py b/tests/test_middlewares/test_exception_middleware.py deleted file mode 100644 index eeb92185..00000000 --- a/tests/test_middlewares/test_exception_middleware.py +++ /dev/null @@ -1,101 +0,0 @@ -import threading - -import pytest - -from botx import TestClient - -pytestmark = pytest.mark.asyncio - - -async def test_handling_exception_with_custom_catcher( - bot, - incoming_message, - client, - build_exception_catcher, - build_failed_handler, - storage, -): - exc_for_raising = Exception("exception from handler") - - cather_event = threading.Event() - handler_event = threading.Event() - - bot.add_exception_handler(Exception, build_exception_catcher(cather_event)) - bot.default(build_failed_handler(exc_for_raising, handler_event)) - - await client.send_command(incoming_message) - - assert cather_event.is_set() - assert handler_event.is_set() - assert storage.exception == exc_for_raising - assert storage.message.incoming_message == incoming_message - - -async def test_handling_from_nearest_mro_handler( - bot, - incoming_message, - client, - build_exception_catcher, - build_failed_handler, - storage, -): - exc_for_raising = UnicodeError("exception from handler") - - exception_catcher_event = threading.Event() - value_error_catcher_event = threading.Event() - handler_event = threading.Event() - - bot.add_exception_handler( - Exception, - build_exception_catcher(exception_catcher_event), - ) - bot.add_exception_handler( - Exception, - build_exception_catcher(value_error_catcher_event), - ) - bot.default(handler=build_failed_handler(exc_for_raising, handler_event)) - - await client.send_command(incoming_message) - - assert not exception_catcher_event.is_set() - assert value_error_catcher_event.is_set() - assert handler_event.is_set() - assert storage.exception == exc_for_raising - assert storage.message.incoming_message == incoming_message - - -async def test_logging_exception_if_was_not_found( - bot, - incoming_message, - loguru_caplog, - build_failed_handler, -) -> None: - event = threading.Event() - bot.default(build_failed_handler(ValueError, event)) - - with TestClient(bot, suppress_errors=True) as client: - await client.send_command(incoming_message) - - assert event.is_set() - assert "uncaught ValueError exception" in loguru_caplog.text - - -async def test_logging_from_failed_exception_handler( - bot, - incoming_message, - client, - loguru_caplog, - build_exception_catcher, - build_failed_handler, -) -> None: - exc_for_raising = ValueError("exception from handler") - - handler_event = threading.Event() - - bot.add_exception_handler(Exception, build_exception_catcher(None)) - bot.default(build_failed_handler(exc_for_raising, handler_event)) - - await client.send_command(incoming_message) - - assert "ValueError('exception from handler')" in loguru_caplog.text - assert "uncaught AttributeError exception in error handler:" in loguru_caplog.text diff --git a/tests/test_middlewares/test_ns_middleware/__init__.py b/tests/test_middlewares/test_ns_middleware/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_middlewares/test_ns_middleware/fixtures.py b/tests/test_middlewares/test_ns_middleware/fixtures.py deleted file mode 100644 index 796cf204..00000000 --- a/tests/test_middlewares/test_ns_middleware/fixtures.py +++ /dev/null @@ -1,20 +0,0 @@ -import threading -from typing import Optional - -import pytest - -from botx import Message -from botx.middlewares.ns import register_next_step_handler - - -@pytest.fixture() -def build_handler_to_start_chain(): - def factory(next_handler_name: Optional[str], event: threading.Event): - def decorator(message: Message): - event.set() - if next_handler_name is not None: - register_next_step_handler(message, next_handler_name) - - return decorator - - return factory diff --git a/tests/test_middlewares/test_ns_middleware/test_arguments.py b/tests/test_middlewares/test_ns_middleware/test_arguments.py deleted file mode 100644 index 12a36b73..00000000 --- a/tests/test_middlewares/test_ns_middleware/test_arguments.py +++ /dev/null @@ -1,73 +0,0 @@ -import threading -from typing import Any - -import pytest - -from botx import Message -from botx.middlewares.ns import NextStepMiddleware, register_next_step_handler - -pytestmark = pytest.mark.asyncio - - -@pytest.fixture() -def build_handler_to_store_arguments(): - def factory(next_handler_name: str, event: threading.Event, **ns_args: Any): - def decorator(message: Message): - event.set() - register_next_step_handler(message, next_handler_name, **ns_args) - - return decorator - - return factory - - -@pytest.fixture() -def build_handler_to_save_message_in_storage(storage): - def factory(event: threading.Event): - def decorator(message: Message): - event.set() - storage.state = message.state - - return decorator - - return factory - - -async def test_setting_args_into_message_state( - bot, - incoming_message, - client, - build_handler_to_store_arguments, - build_handler_to_save_message_in_storage, - storage, -): - event1 = threading.Event() - event2 = threading.Event() - - bot.default( - handler=build_handler_to_store_arguments( - "ns_handler", - event1, - arg1=1, - arg2="2", - arg3=True, - ), - ) - - ns_handler = build_handler_to_save_message_in_storage(event2) - - bot.add_middleware( - NextStepMiddleware, - bot=bot, - functions={"ns_handler": ns_handler}, - ) - - await client.send_command(incoming_message) - assert event1.is_set() - - await client.send_command(incoming_message) - assert event2.is_set() - - assert storage.state.arg1 == 1 - assert storage.state.arg2 == "2" - assert storage.state.arg3 diff --git a/tests/test_middlewares/test_ns_middleware/test_errors.py b/tests/test_middlewares/test_ns_middleware/test_errors.py deleted file mode 100644 index f876ebfb..00000000 --- a/tests/test_middlewares/test_ns_middleware/test_errors.py +++ /dev/null @@ -1,49 +0,0 @@ -import threading - -import pytest - -from botx.middlewares.ns import NextStepMiddleware - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_middlewares.test_ns_middleware.fixtures",) - - -async def test_error_for_ns_for_unregistered_handler( - bot, - client, - incoming_message, - build_handler_to_start_chain, -): - event = threading.Event() - - bot.default(handler=build_handler_to_start_chain("unknown_handler_name", event)) - - bot.add_middleware(NextStepMiddleware, bot=bot, functions={}) - - with pytest.raises(ValueError): - await client.send_command(incoming_message) - - assert event.is_set() - - -async def test_error_for_message_without_huid( - bot, - incoming_message, - client, - chat_created_message, - build_handler_to_start_chain, - build_handler_for_collector, -): - event = threading.Event() - bot.chat_created(build_handler_to_start_chain("ns_handler", event)) - - bot.add_middleware( - NextStepMiddleware, - bot=bot, - functions={build_handler_for_collector("ns_handler")}, - ) - - with pytest.raises(ValueError): - await client.send_command(chat_created_message) - - assert event.is_set() diff --git a/tests/test_middlewares/test_ns_middleware/test_execution.py b/tests/test_middlewares/test_ns_middleware/test_execution.py deleted file mode 100644 index 2ef5193c..00000000 --- a/tests/test_middlewares/test_ns_middleware/test_execution.py +++ /dev/null @@ -1,80 +0,0 @@ -import threading - -import pytest - -from botx.middlewares.ns import NextStepMiddleware - -pytestmark = pytest.mark.asyncio -pytest_plugins = ("tests.test_middlewares.test_ns_middleware.fixtures",) - - -async def test_executing_ns_handlers( - bot, - incoming_message, - client, - build_handler_to_start_chain, -) -> None: - chain_start_event = threading.Event() - ns_handler_event = threading.Event() - - bot.default(handler=build_handler_to_start_chain("ns_handler", chain_start_event)) - bot.add_middleware( - NextStepMiddleware, - bot=bot, - functions={"ns_handler": build_handler_to_start_chain(None, ns_handler_event)}, - ) - - incoming_message.command.body = "/start-ns" - - await client.send_command(incoming_message) - await client.send_command(incoming_message) - - assert chain_start_event.is_set() - assert ns_handler_event.is_set() - - -async def test_breaking_chain( - bot, - incoming_message, - client, - build_handler_to_start_chain, -): - break_handler_event = threading.Event() - chain_start_event = threading.Event() - ns_handler_event = threading.Event() - - chain_start_handler = build_handler_to_start_chain("ns_handler", chain_start_event) - - bot.handler( - handler=build_handler_to_start_chain(None, break_handler_event), - command="/break", - name="break_handler", - ) - bot.handler(handler=chain_start_handler, command="/ns-start", name="chain_start") - - bot.add_middleware( - NextStepMiddleware, - bot=bot, - functions={ - "ns_handler": build_handler_to_start_chain("chain_start", ns_handler_event), - "chain_start": chain_start_handler, - }, - break_handler="break_handler", - ) - - incoming_message.command.body = "/ns-start" - await client.send_command(incoming_message) - assert chain_start_event.is_set() - chain_start_event.clear() - - await client.send_command(incoming_message) - assert ns_handler_event.is_set() - ns_handler_event.clear() - - await client.send_command(incoming_message) - assert chain_start_event.is_set() - chain_start_event.clear() - - incoming_message.command.body = "/break-handler" - await client.send_command(incoming_message) - assert break_handler_event.is_set() diff --git a/tests/test_middlewares/test_ns_middleware/test_registration.py b/tests/test_middlewares/test_ns_middleware/test_registration.py deleted file mode 100644 index 54c4dc5e..00000000 --- a/tests/test_middlewares/test_ns_middleware/test_registration.py +++ /dev/null @@ -1,72 +0,0 @@ -import pytest - -from botx.middlewares.ns import NextStepMiddleware, register_function_as_ns_handler - - -def test_register_middleware_with_functions_dict(bot, build_handler_for_collector): - functions = {"ns_handler": build_handler_for_collector("ns_handler")} - - bot.add_middleware(NextStepMiddleware, bot=bot, functions=functions) - - assert [bot.state.ns_collector.handler_for(name) for name in functions] - - -def test_register_ns_middleware_using_functions_set(bot, build_handler_for_collector): - functions = {build_handler_for_collector("ns_handler")} - - bot.add_middleware(NextStepMiddleware, bot=bot, functions=functions) - - assert [bot.state.ns_collector.handler_for(name) for name in ["ns_handler"]] - - -def test_no_duplicate_handlers_registration(bot, build_handler_for_collector): - bot.add_middleware(NextStepMiddleware, bot=bot, functions={}) - - handler = build_handler_for_collector("ns_handler") - - register_function_as_ns_handler(bot, handler) - - with pytest.raises(ValueError): - register_function_as_ns_handler(bot, handler) - - -def test_register_break_handler_as_string(bot, build_handler_for_collector): - bot.handler(handler=build_handler_for_collector("break_handler"), command="/break") - - bot.add_middleware( - NextStepMiddleware, - bot=bot, - functions={}, - break_handler="break_handler", - ) - - assert bot.state.ns_collector.handler_for("break_handler") == bot.handler_for( - "break_handler", - ) - - -def test_register_break_handler_as_handler(bot, build_handler_for_collector): - bot.handler(build_handler_for_collector("break_handler"), command="/break") - - bot.add_middleware( - NextStepMiddleware, - bot=bot, - functions={}, - break_handler=bot.handler_for("break_handler"), - ) - - assert bot.state.ns_collector.handler_for("break_handler") == bot.handler_for( - "break_handler", - ) - - -def test_register_break_handler_as_function(bot, build_handler_for_collector): - handler = build_handler_for_collector("break_handler") - bot.add_middleware( - NextStepMiddleware, - bot=bot, - functions={}, - break_handler=handler, - ) - - assert bot.state.ns_collector.handler_for("break_handler").handler == handler diff --git a/tests/test_missing.py b/tests/test_missing.py new file mode 100644 index 00000000..25ed6b4b --- /dev/null +++ b/tests/test_missing.py @@ -0,0 +1,10 @@ +import pytest + +from botx.missing import Undefined, not_undefined + + +def test__not_undefined__all_args_undefined() -> None: + with pytest.raises(ValueError) as exc: + not_undefined(Undefined, Undefined) + + assert "All arguments" in str(exc.value) diff --git a/tests/test_models/__init__.py b/tests/test_models/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_models/test_attachments.py b/tests/test_models/test_attachments.py deleted file mode 100644 index 051d09a7..00000000 --- a/tests/test_models/test_attachments.py +++ /dev/null @@ -1,229 +0,0 @@ -import pytest - -from botx import MessageBuilder -from botx.models.attachments import Video, Voice - - -def test_is_link_in_attachment(): - builder = MessageBuilder() - builder.link() - assert builder.message.attachments.__root__[0].data.is_link() - - -def test_is_mail_in_attachment(): - builder = MessageBuilder() - mailto_url = "mailto:mail@mail.com" - builder.link(url=mailto_url) - assert builder.message.attachments.__root__[0].data.is_mail() - - -def test_is_telephone_number_in_attachment(): - builder = MessageBuilder() - tel_url = "tel://+77777777777" - builder.link(url=tel_url) - assert builder.message.attachments.__root__[0].data.is_telephone() - - -def test_mailto_property_in_attachment(): - builder = MessageBuilder() - mailto_url = "mailto:mail@mail.com" - builder.link(mailto_url) - assert builder.message.attachments.__root__[0].data.mailto == "mail@mail.com" - - -def test_raising_missing_mailto(): - builder = MessageBuilder() - builder.link() - with pytest.raises(AttributeError): - builder.message.attachments.__root__[0].data.mailto - - -def test_tel_property_in_attachment(): - builder = MessageBuilder() - tel_url = "tel://+77777777777" - builder.link(url=tel_url) - assert builder.message.attachments.__root__[0].data.tel == "+77777777777" - - -def test_raising_missing_tel(): - builder = MessageBuilder() - builder.link() - with pytest.raises(AttributeError): - builder.message.attachments.__root__[0].data.tel - - -def test_image_in_attachments(): - builder = MessageBuilder() - builder.document() - builder.image() - assert builder.message.attachments.image - - -def test_missing_image_in_attachments(): - builder = MessageBuilder() - with pytest.raises(AttributeError): - builder.message.attachments.image - - -def test_document_in_attachments(): - builder = MessageBuilder() - builder.image() - builder.document() - assert builder.message.attachments.document - - -def test_missing_document_in_attachments(): - builder = MessageBuilder() - with pytest.raises(AttributeError): - builder.message.attachments.document - - -def test_location_in_attachments(): - builder = MessageBuilder() - builder.image() - builder.location() - assert builder.message.attachments.location - - -def test_missing_location_in_attachments(): - builder = MessageBuilder() - with pytest.raises(AttributeError): - builder.message.attachments.location - - -def test_contact_in_attachments(): - builder = MessageBuilder() - builder.image() - builder.contact() - assert builder.message.attachments.contact - - -def test_missing_contact_in_attachments(): - builder = MessageBuilder() - with pytest.raises(AttributeError): - builder.message.attachments.contact - - -def test_voice_in_attachments(): - builder = MessageBuilder() - builder.image() - builder.voice() - assert builder.message.attachments.voice - - -def test_missing_voice_in_attachments(): - builder = MessageBuilder() - with pytest.raises(AttributeError): - builder.message.attachments.voice - - -def test_video_in_attachments(): - builder = MessageBuilder() - builder.image() - builder.video() - assert builder.message.attachments.video - - -def test_missing_video_in_attachments(): - builder = MessageBuilder() - with pytest.raises(AttributeError): - builder.message.attachments.video - - -def test_link_in_attachments(): - builder = MessageBuilder() - builder.image() - builder.link() - assert builder.message.attachments.link - - -def test_missing_link_in_attachments(): - builder = MessageBuilder() - builder.link(url="mailto:mail@mail.com") - with pytest.raises(AttributeError): - builder.message.attachments.link - - -def test_email_in_attachments(): - builder = MessageBuilder() - mailto_url = "mailto:mail@mail.com" - builder.image() - builder.link(url=mailto_url) - assert builder.message.attachments.email == "mail@mail.com" - - -def test_missing_email_in_attachments(): - builder = MessageBuilder() - builder.link(url="https://any.com") - with pytest.raises(AttributeError): - builder.message.attachments.email - - -def test_telephone_in_attachments(): - builder = MessageBuilder() - tel_url = "tel://+77777777777" - builder.image() - builder.link(url=tel_url) - assert builder.message.attachments.telephone == "+77777777777" - - -def test_missing_telephone_in_attachments(): - builder = MessageBuilder() - builder.link(url="mailto:mail@mail.com") - with pytest.raises(AttributeError): - builder.message.attachments.telephone - - -@pytest.mark.parametrize( - "attach", - [lambda x: x.document, lambda x: x.image, lambda x: x.video], -) -def test_file_in_attachments(attach): - builder = MessageBuilder() - attach(builder)() - assert builder.message.attachments.file - - -def test_no_file_in_message(): - builder = MessageBuilder() - builder.link() - with pytest.raises(AttributeError): - builder.message.attachments.file - - -def test_file_with_unsupported_extension(): - builder = MessageBuilder() - builder.document(file_name="test.py") - assert builder.message.attachments.file - - -@pytest.mark.parametrize("len_of_attachments", [1, 2, 3]) -def test_get_all_attachments(len_of_attachments): - builder = MessageBuilder() - for _ in range(len_of_attachments): - builder.document() - assert len(builder.message.attachments.all_attachments) == len_of_attachments - - -def test_video_attach_has_video_type(): - builder = MessageBuilder() - builder.video() - assert isinstance(builder.message.attachments.video, Video) - - -def test_voice_attach_has_voice_type(): - builder = MessageBuilder() - builder.voice() - assert isinstance(builder.message.attachments.voice, Voice) - - -def test_no_attach_type(): - builder = MessageBuilder() - with pytest.raises(AttributeError): - builder.message.attachments.attach_type - - -def test_attach_type(): - builder = MessageBuilder() - builder.link() - builder.message.attachments.attach_type == "link" diff --git a/tests/test_models/test_buttons.py b/tests/test_models/test_buttons.py deleted file mode 100644 index 0e8dc644..00000000 --- a/tests/test_models/test_buttons.py +++ /dev/null @@ -1,27 +0,0 @@ -import pytest -from pydantic import ValidationError - -from botx.models.buttons import Button, ButtonOptions - - -class CustomButton(Button): - """Button without custom behaviour.""" - - -def test_label_will_be_set_to_command_if_none(): - assert CustomButton(command="/cmd").label == "/cmd" - - -def test_label_can_be_set_if_passed_explicitly(): - assert CustomButton(command="/cmd", label="temp").label == "temp" - - -def test_empty_label(): - assert CustomButton(command="/cmd", label="").label == "" - - -def test_create_button_options_with_invalid_hsize(): - with pytest.raises(ValidationError) as exc_info: - ButtonOptions(h_size=0) - - assert "should be positive integer" in str(exc_info) diff --git a/tests/test_models/test_credentials.py b/tests/test_models/test_credentials.py deleted file mode 100644 index f9b02c41..00000000 --- a/tests/test_models/test_credentials.py +++ /dev/null @@ -1,48 +0,0 @@ -from uuid import UUID - -import pytest - -from botx import BotXCredentials -from botx.clients.methods.v2.bots.token import Token - - -def test_calculating_signature_for_token(host) -> None: - bot_id = UUID("8dada2c8-67a6-4434-9dec-570d244e78ee") - account = BotXCredentials( - host=host, - secret_key="secret", - bot_id=bot_id, - ) - signature = "904E39D3BC549C71F4A4BDA66AFCDA6FC90D471A64889B45CC8D2288E56526AD" - assert account.signature == signature - - -@pytest.mark.asyncio() -async def test_auth_to_each_known_account(bot, client) -> None: - accounts_len = len(bot.bot_accounts) - for account in bot.bot_accounts: - account.token = None - - await bot.authorize() - assert len(client.requests) == accounts_len - - for request in client.requests: - assert isinstance(request, Token) - - for account in bot.bot_accounts: - assert account.token is not None - - -@pytest.mark.asyncio() -async def test_auth_with_wrong_credentials(bot, host) -> None: - bot_id = UUID("8dada2c8-67a6-4434-9dec-570d244e78ee") - bot.bot_accounts = [ - BotXCredentials( - host=host, - secret_key="wrong_secret", - bot_id=bot_id, - ), - ] - await bot.authorize() - - assert bot.bot_accounts[0].token is None diff --git a/tests/test_models/test_datastructures.py b/tests/test_models/test_datastructures.py deleted file mode 100644 index aaa3807c..00000000 --- a/tests/test_models/test_datastructures.py +++ /dev/null @@ -1,20 +0,0 @@ -import pytest - -from botx.models.datastructures import State - - -def test_passed_state_applied(): - state = State({"arg": 1}) - assert state.arg == 1 - - -def test_state_can_be_set(): - state = State() - state.arg = 1 - assert state.arg == 1 - - -def test_state_will_raise_error_on_empty_attribute(): - state = State() - with pytest.raises(AttributeError): - _ = state.arg # noqa: WPS122 diff --git a/tests/test_models/test_entities.py b/tests/test_models/test_entities.py deleted file mode 100644 index 2867dd37..00000000 --- a/tests/test_models/test_entities.py +++ /dev/null @@ -1,150 +0,0 @@ -import uuid - -import pytest -from pydantic import ValidationError - -from botx import ( - ChatMention, - Mention, - MentionTypes, - Message, - MessageBuilder, - UserMention, -) - - -class TestMentions: - @pytest.mark.parametrize("mention_id", [None, uuid.uuid4()]) - def test_mention_id_will_be_generated_if_missed(self, mention_id): - mention = Mention( - mention_id=mention_id, - mention_data=UserMention(user_huid=uuid.uuid4()), - ) - assert mention.mention_id is not None - - def test_error_when_no_mention_data(self): - with pytest.raises(ValidationError): - Mention(mention_type=MentionTypes.user) - - @pytest.mark.parametrize( - ("mention_data", "mention_type"), - [ - (UserMention(user_huid=uuid.uuid4()), MentionTypes.user), - (UserMention(user_huid=uuid.uuid4()), MentionTypes.contact), - (ChatMention(group_chat_id=uuid.uuid4()), MentionTypes.chat), - (ChatMention(group_chat_id=uuid.uuid4()), MentionTypes.channel), - ], - ) - def test_mention_corresponds_data_by_type(self, mention_data, mention_type) -> None: - mention = Mention(mention_data=mention_data, mention_type=mention_type) - assert mention.mention_type == mention_type - - @pytest.mark.parametrize( - ("mention_data", "mention_type"), - [ - (UserMention(user_huid=uuid.uuid4()), MentionTypes.chat), - (UserMention(user_huid=uuid.uuid4()), MentionTypes.channel), - (ChatMention(group_chat_id=uuid.uuid4()), MentionTypes.user), - (ChatMention(group_chat_id=uuid.uuid4()), MentionTypes.contact), - ], - ) - def test_error_when_data_not_corresponds_type( - self, - mention_data, - mention_type, - ) -> None: - with pytest.raises(ValidationError): - assert Mention(mention_data=mention_data, mention_type=mention_type) - - @pytest.mark.parametrize( - ("mention_data", "mention_type"), - [ - (UserMention(user_huid=uuid.uuid4()), MentionTypes.user), - (UserMention(user_huid=uuid.uuid4()), MentionTypes.contact), - (ChatMention(group_chat_id=uuid.uuid4()), MentionTypes.chat), - (ChatMention(group_chat_id=uuid.uuid4()), MentionTypes.channel), - ], - ) - def test_mention_in_message(self, mention_data, mention_type) -> None: - builder = MessageBuilder() - builder.mention( - mention=Mention(mention_data=mention_data, mention_type=mention_type), - ) - assert builder.message.entities.mentions[0] - - def test_mention_not_in_message(self, bot) -> None: - builder = MessageBuilder() - message = Message.from_dict(builder.message.dict(), bot) - assert message.entities.mentions == [] - - def test_user_mention_botx_format(self) -> None: - mention_id = uuid.uuid4() - user_mention = Mention( - mention_id=mention_id, - mention_data=UserMention(user_huid=uuid.uuid4()), - mention_type=MentionTypes.user, - ) - - formatted_mention = user_mention.to_botx_format() - - assert formatted_mention == f"@{{mention:{mention_id}}}" - - def test_contact_mention_botx_format(self) -> None: - mention_id = uuid.uuid4() - contact_mention = Mention( - mention_id=mention_id, - mention_data=UserMention(user_huid=uuid.uuid4()), - mention_type=MentionTypes.contact, - ) - - formatted_mention = contact_mention.to_botx_format() - - assert formatted_mention == f"@@{{mention:{mention_id}}}" - - def test_chat_mention_botx_format(self) -> None: - mention_id = uuid.uuid4() - chat_mention = Mention( - mention_id=mention_id, - mention_data=ChatMention(group_chat_id=uuid.uuid4()), - mention_type=MentionTypes.chat, - ) - - formatted_mention = chat_mention.to_botx_format() - - assert formatted_mention == f"##{{mention:{mention_id}}}" - - def test_channel_mention_botx_format(self) -> None: - mention_id = uuid.uuid4() - channel_mention = Mention( - mention_id=mention_id, - mention_data=ChatMention(group_chat_id=uuid.uuid4()), - mention_type=MentionTypes.channel, - ) - - formatted_mention = channel_mention.to_botx_format() - - assert formatted_mention == f"##{{mention:{mention_id}}}" - - -class TestReply: - def test_reply_in_message(self, message) -> None: - builder = MessageBuilder() - builder.reply(message=message) - assert builder.message.entities.reply.source_sync_id == message.sync_id - - def test_reply_not_in_message(self) -> None: - builder = MessageBuilder() - with pytest.raises(AttributeError): - builder.message.entities.reply - - -class TestForward: - def test_forward_in_message(self, message) -> None: - builder = MessageBuilder() - builder.forward(message=message) - assert builder.message.entities.forward.source_sync_id == message.sync_id - - def test_forward_not_in_message(self) -> None: - builder = MessageBuilder() - with pytest.raises(AttributeError): - builder.message.entities.forward diff --git a/tests/test_models/test_errors.py b/tests/test_models/test_errors.py deleted file mode 100644 index cdadeaf7..00000000 --- a/tests/test_models/test_errors.py +++ /dev/null @@ -1,16 +0,0 @@ -import pytest -from pydantic import ValidationError - -from botx import BotDisabledErrorData, BotDisabledResponse - - -def test_error_for_missing_status_message_field(): - with pytest.raises(ValidationError): - BotDisabledResponse(error_data={}) - - -def test_doing_nothing_when_passed_error_data_model(): - response = BotDisabledResponse( - error_data=BotDisabledErrorData(status_message="test"), - ) - assert response.error_data.status_message == "test" diff --git a/tests/test_models/test_files/__init__.py b/tests/test_models/test_files/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_models/test_files/test_attributes.py b/tests/test_models/test_files/test_attributes.py deleted file mode 100644 index 43d8f984..00000000 --- a/tests/test_models/test_files/test_attributes.py +++ /dev/null @@ -1,25 +0,0 @@ -from botx import File - - -def test_retrieving_file_data_in_bytes(): - assert File.from_string(b"test", filename="test.txt").data_in_bytes == b"test" - - -def test_retrieving_file_data_in_base64(): - assert File.from_string(b"test", filename="test.txt").data_in_base64 == "dGVzdA==" - - -def test_retrieving_txt_media_type(): - assert File.from_string(b"test", filename="test.txt").media_type == "text/plain" - - -def test_retrieving_png_media_type(): - assert File.from_string(b"test", filename="test.png").media_type == "image/png" - - -def test_retrieving_file_size(): - assert File.from_string(b"file\ncontents", filename="test.txt").size_in_bytes == 13 - - -def test_get_ext_by_unsupported_mimetype(): - assert File.get_ext_by_mimetype("application/javascript") is None diff --git a/tests/test_models/test_files/test_constructing.py b/tests/test_models/test_files/test_constructing.py deleted file mode 100644 index 0b0a5908..00000000 --- a/tests/test_models/test_files/test_constructing.py +++ /dev/null @@ -1,85 +0,0 @@ -from io import BytesIO, StringIO - -import aiofiles -import pytest - -from botx import File - - -@pytest.mark.parametrize("extension", [".docx", ".txt", ".html", ".pdf"]) -def test_file_creation_with_right_extension(extension): - File(file_name=f"tmp{extension}", data="") - - -@pytest.mark.parametrize( - ("io_cls", "file_data", "file_name"), - [(StringIO, "test", "test.txt"), (BytesIO, b"test", "test.txt")], -) -@pytest.mark.parametrize("explicit_file_name", ["test2.txt", None]) -def test_creating_file_from_io_with_name( - io_cls, - file_data, - file_name, - explicit_file_name, -): - created_file = io_cls(file_data) - if not explicit_file_name: - created_file.name = file_name - - assert File.from_file(created_file, filename=explicit_file_name) == File( - file_name=explicit_file_name or file_name, - data="data:text/plain;base64,dGVzdA==", - ) - - -@pytest.mark.parametrize("file_data", ["test", b"test"]) -def test_creating_file_from_string(file_data): - assert File.from_string(file_data, filename="test.txt") == File( - file_name="test.txt", - data="data:text/plain;base64,dGVzdA==", - ) - - -@pytest.fixture() -def filename(): - return "test.txt" - - -@pytest.fixture() -def origin_data(): - return b"Hello,\nworld!" - - -@pytest.fixture() -def encoded_data(): - return "data:text/plain;base64,SGVsbG8sCndvcmxkIQ==" - - -@pytest.fixture() -def temp_file(tmp_path, filename, origin_data): - file_path = tmp_path / filename - file_path.write_bytes(origin_data) - - return file_path - - -@pytest.mark.asyncio() -async def test_async_from_file(temp_file, encoded_data): - async with aiofiles.open(temp_file, "rb") as fo: - file = await File.async_from_file(fo) - - assert file.file_name == temp_file.name - assert file.data == encoded_data - - -def test_file_chunks(filename, encoded_data, origin_data): - file = File.construct(file_name=filename, data=encoded_data) - temp_file = BytesIO() - - with file.file_chunks() as chunks: - for chunk in chunks: - temp_file.write(chunk) - - temp_file.seek(0) - - assert temp_file.read() == origin_data diff --git a/tests/test_models/test_messages/__init__.py b/tests/test_models/test_messages/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_models/test_messages/test_messages.py b/tests/test_models/test_messages/test_messages.py deleted file mode 100644 index 717e5928..00000000 --- a/tests/test_models/test_messages/test_messages.py +++ /dev/null @@ -1,663 +0,0 @@ -import uuid -from io import BytesIO, StringIO - -import pytest - -from botx import ( - Bot, - BubbleElement, - ChatMention, - File, - IncomingMessage, - KeyboardElement, - Mention, - MentionTypes, - Message, - MessageBuilder, - MessageMarkup, - MessageOptions, - NotificationOptions, - SendingCredentials, - SendingMessage, - UserMention, -) - - -@pytest.fixture() -def incoming_message() -> IncomingMessage: - return IncomingMessage.parse_obj( - { - "sync_id": "a465f0f3-1354-491c-8f11-f400164295cb", - "command": { - "body": "system:chat_created", - "command_type": "system", - "data": { - "group_chat_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", - "chat_type": "group_chat", - "name": "Meeting Room", - "creator": "ab103983-6001-44e9-889e-d55feb295494", - "members": [ - { - "huid": "ab103983-6001-44e9-889e-d55feb295494", - "name": "Bob", - "user_kind": "user", - "admin": True, - }, - { - "huid": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", - "name": "Funny Bot", - "user_kind": "botx", - "admin": False, - }, - ], - }, - "metadata": {"account_id": 94}, - }, - "file": None, - "from": { - "user_huid": None, - "group_chat_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", - "ad_login": None, - "ad_domain": None, - "username": None, - "chat_type": "group_chat", - "host": "cts.ccteam.ru", - "is_admin": False, - "is_creator": False, - }, - "bot_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", - "entities": [ - { - "type": "mention", - "data": { - "mention_type": "contact", - "mention_id": "c06a96fa-7881-0bb6-0e0b-0af72fe3683f", - "mention_data": { - "user_huid": "ab103983-6001-44e9-889e-d55feb295494", - "name": "User", - }, - }, - }, - ], - }, - ) - - -@pytest.fixture() -def embed_mention_with_name() -> str: - user_huid = uuid.uuid4() - return f"" - - -@pytest.fixture() -def embed_mention_without_name() -> str: - user_huid = uuid.uuid4() - return f"" - - -@pytest.fixture() -def embed_mention_with_wrong_type() -> str: - user_huid = uuid.uuid4() - return f"" - - -def test_setting_ui_flag_property_for_common_message() -> None: - msg = Message.from_dict( - { - "sync_id": "a465f0f3-1354-491c-8f11-f400164295cb", - "source_sync_id": "ff934be3-a03f-45d8-b315-738ba1ddec45", - "command": { - "body": "/cmd", - "command_type": "user", - "data": {"ui": True}, - "metadata": {"account_id": 94}, - }, - "file": None, - "from": { - "user_huid": None, - "group_chat_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", - "ad_login": None, - "ad_domain": None, - "username": None, - "chat_type": "group_chat", - "host": "cts.ccteam.ru", - "is_admin": False, - "is_creator": False, - }, - "bot_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", - }, - Bot(), - ) - - assert msg.source_sync_id == uuid.UUID("ff934be3-a03f-45d8-b315-738ba1ddec45") - - -def test_setting_ui_flag_property_for_system_message(incoming_message) -> None: - msg = Message.from_dict(incoming_message.dict(), Bot()) - assert not msg.source_sync_id - - -@pytest.fixture() -def sending_message() -> SendingMessage: - return SendingMessage( - text="text", - file=File.from_string(b"data", filename="file.txt"), - credentials=SendingCredentials( - sync_id=uuid.uuid4(), - bot_id=uuid.uuid4(), - host="host", - ), - markup=MessageMarkup( - bubbles=[[BubbleElement(command="")]], - keyboard=[[KeyboardElement(command="")]], - ), - options=MessageOptions( - recipients=[uuid.uuid4()], - mentions=[ - Mention(mention_data=UserMention(user_huid=uuid.uuid4())), - Mention( - mention_data=UserMention(user_huid=uuid.uuid4()), - mention_type=MentionTypes.contact, - ), - Mention( - mention_data=ChatMention(group_chat_id=uuid.uuid4()), - mention_type=MentionTypes.chat, - ), - ], - notifications=NotificationOptions(send=False, force_dnd=True), - ), - ) - - -def test_message_is_proxy_to_incoming_message(incoming_message) -> None: - msg = Message.from_dict(incoming_message.dict(), Bot()) - assert msg.sync_id == incoming_message.sync_id - assert msg.command == incoming_message.command - assert msg.file == incoming_message.file - assert msg.user == incoming_message.user - assert msg.bot_id == incoming_message.bot_id - assert msg.body == incoming_message.command.body - assert msg.data == {**msg.metadata, **incoming_message.command.data_dict} - assert msg.metadata == incoming_message.command.metadata - assert msg.user_huid == incoming_message.user.user_huid - assert msg.ad_login == incoming_message.user.ad_login - assert msg.group_chat_id == incoming_message.user.group_chat_id - assert msg.chat_type == incoming_message.user.chat_type - assert msg.host == incoming_message.user.host - assert msg.credentials.sync_id == incoming_message.sync_id - assert msg.credentials.bot_id == incoming_message.bot_id - assert msg.credentials.host == incoming_message.user.host - assert msg.entities == incoming_message.entities - assert msg.incoming_message == incoming_message - - -class TestBuildingSendingMessage: - def test_building_from_message(self, sending_message: SendingMessage) -> None: - builder = MessageBuilder() - msg = Message(message=builder.message, bot=Bot()) - sending_msg = SendingMessage.from_message( - text=sending_message.text, - message=msg, - ) - assert sending_msg.host == msg.host - assert sending_msg.sync_id == msg.sync_id - assert sending_msg.bot_id == msg.bot_id - - def test_building_with_embed_mentions( - self, - sending_message: SendingMessage, - ) -> None: - credentials = sending_message.credentials - user_huid = uuid.uuid4() - - mention_id = uuid.uuid4() - text = ( - f"Text with embed_mention: " - ) - - msg = SendingMessage(text=text, credentials=credentials, embed_mentions=True) - - assert msg.text == f"Text with embed_mention: @{{mention:{mention_id}}}" - assert msg.options.mentions - - class TestCredentialsBuilding: - def test_only_credentials_or_separate_credential_parts( - self, - sending_message: SendingMessage, - ) -> None: - with pytest.raises(AssertionError): - _ = SendingMessage( - sync_id=sending_message.sync_id, - bot_id=sending_message.bot_id, - host=sending_message.host, - credentials=sending_message.credentials, - ) - - def test_credentials_will_be_built_from_credential_parts( - self, - sending_message: SendingMessage, - ) -> None: - msg = SendingMessage( - text=sending_message.text, - sync_id=sending_message.sync_id, - bot_id=sending_message.bot_id, - host=sending_message.host, - ) - assert msg.credentials == sending_message.credentials - - def test_merging_message_id_into_credentials( - self, - sending_message: SendingMessage, - ) -> None: - message_id = uuid.uuid4() - msg = SendingMessage( - text=sending_message.text, - credentials=sending_message.credentials, - message_id=message_id, - ) - assert msg.credentials.message_id == message_id - - def test_leaving_credentials_message_id_into_credentials_if_was_set( - self, - sending_message: SendingMessage, - ) -> None: - message_id = uuid.uuid4() - sending_message.credentials.message_id = message_id - msg = SendingMessage( - text=sending_message.text, - credentials=sending_message.credentials, - message_id=uuid.uuid4(), - ) - assert msg.credentials.message_id == sending_message.credentials.message_id - - class TestMarkupBuilding: - def test_markup_creation_from_bubbles( - self, - sending_message: SendingMessage, - ) -> None: - msg = SendingMessage( - text=sending_message.text, - credentials=sending_message.credentials, - bubbles=sending_message.markup.bubbles, - ) - assert msg.markup.keyboard == [] - assert msg.markup.bubbles == sending_message.markup.bubbles - - def test_markup_creation_from_keyboard( - self, - sending_message: SendingMessage, - ) -> None: - msg = SendingMessage( - text=sending_message.text, - credentials=sending_message.credentials, - keyboard=sending_message.markup.keyboard, - ) - assert msg.markup.keyboard == sending_message.markup.keyboard - assert msg.markup.bubbles == [] - - def test_markup_creation_from_bubbles_and_keyboard( - self, - sending_message: SendingMessage, - ) -> None: - msg = SendingMessage( - text=sending_message.text, - credentials=sending_message.credentials, - bubbles=sending_message.markup.bubbles, - keyboard=sending_message.markup.keyboard, - ) - assert msg.markup == sending_message.markup - - def test_only_markup_or_separate_markup_parts( - self, - sending_message: SendingMessage, - ) -> None: - with pytest.raises(AssertionError): - _ = SendingMessage( - text=sending_message.text, - credentials=sending_message.credentials, - bubbles=sending_message.markup.bubbles, - keyboard=sending_message.markup.keyboard, - markup=sending_message.markup, - ) - - class TestOptionsBuilding: - def test_options_from_mentions(self, sending_message: SendingMessage) -> None: - msg = SendingMessage( - text=sending_message.text, - credentials=sending_message.credentials, - mentions=sending_message.options.mentions, - ) - assert msg.options.mentions == sending_message.options.mentions - - def test_options_from_recipients(self, sending_message: SendingMessage) -> None: - msg = SendingMessage( - text=sending_message.text, - credentials=sending_message.credentials, - recipients=sending_message.options.recipients, - ) - assert msg.options.recipients == sending_message.options.recipients - - def test_options_from_notification_options( - self, - sending_message: SendingMessage, - ) -> None: - msg = SendingMessage( - text=sending_message.text, - credentials=sending_message.credentials, - notification_options=sending_message.options.notifications, - ) - assert msg.options.notifications == sending_message.options.notifications - - def test_option_from_message_options( - self, - sending_message: SendingMessage, - ) -> None: - msg = SendingMessage( - text=sending_message.text, - credentials=sending_message.credentials, - options=sending_message.options, - ) - assert msg.options == sending_message.options - - def test_only_options_or_separate_options_parts( - self, - sending_message: SendingMessage, - ) -> None: - with pytest.raises(AssertionError): - _ = SendingMessage( - text=sending_message.text, - credentials=sending_message.credentials, - options=sending_message.options, - mentions=sending_message.options.mentions, - recipients=sending_message.options.recipients, - notification_options=sending_message.options.notifications, - ) - - -class TestSendingMessageProperties: - def test_message_text(self, sending_message: SendingMessage) -> None: - sending_message.text = "test" - assert sending_message.text == "test" - - def test_metadata(self, sending_message: SendingMessage) -> None: - value = {"account_id", 94} - - sending_message.metadata = value - assert sending_message.metadata == value - - class TestMessageFile: - def test_message_file(self, sending_message: SendingMessage) -> None: - file = sending_message.file - sending_message.file = file - assert sending_message.file == file - - def test_message_file_from_file(self, sending_message: SendingMessage) -> None: - original_file = sending_message.file - sending_message.add_file(File.from_file(original_file.file)) - assert sending_message.file == original_file - - def test_message_file_from_string_file( - self, - sending_message: SendingMessage, - ) -> None: - original_file = sending_message.file - sending_message.add_file( - StringIO(original_file.file.read().decode()), - filename=original_file.file_name, - ) - assert sending_message.file == original_file - - def test_message_file_from_bytes_file( - self, - sending_message: SendingMessage, - ) -> None: - original_file = sending_message.file - sending_message.add_file( - BytesIO(original_file.file.read()), - filename=original_file.file_name, - ) - assert sending_message.file == original_file - - def test_message_markup(self, sending_message: SendingMessage) -> None: - markup = MessageMarkup(bubbles=[[BubbleElement(command="/test")]]) - sending_message.markup = markup - assert sending_message.markup == markup - - def test_message_options(self, sending_message: SendingMessage) -> None: - options = MessageOptions() - sending_message.options = options - assert sending_message.options == options - - def test_message_sync_id(self, sending_message: SendingMessage) -> None: - sync_id = uuid.uuid4() - sending_message.sync_id = sync_id - assert sending_message.sync_id == sync_id - - def test_message_chat_id(self, sending_message: SendingMessage) -> None: - chat_id = uuid.uuid4() - sending_message.chat_id = chat_id - assert sending_message.chat_id == chat_id - - def test_message_bot_id(self, sending_message: SendingMessage) -> None: - bot_id = uuid.uuid4() - sending_message.bot_id = bot_id - assert sending_message.bot_id == bot_id - - def test_message_host(self, sending_message: SendingMessage) -> None: - host = "example.com" - sending_message.host = host - assert sending_message.host == host - - class TestMentioning: - def test_mentioning_user(self, sending_message: SendingMessage) -> None: - sending_message.payload.options.mentions = [] - user_huid = uuid.uuid4() - user_name = "test" - sending_message.mention_user(user_huid, user_name) - mention = sending_message.payload.options.mentions[0] - assert mention.mention_type == MentionTypes.user - - def test_mentioning_contact(self, sending_message: SendingMessage) -> None: - sending_message.payload.options.mentions = [] - user_huid = uuid.uuid4() - user_name = "test" - sending_message.mention_contact(user_huid, user_name) - mention = sending_message.payload.options.mentions[0] - assert mention.mention_type == MentionTypes.contact - - def test_mentioning_chat(self, sending_message: SendingMessage) -> None: - sending_message.payload.options.mentions = [] - chat_id = uuid.uuid4() - chat_name = "test" - sending_message.mention_chat(chat_id, chat_name) - mention = sending_message.payload.options.mentions[0] - assert mention.mention_type == MentionTypes.chat - - def test_wrong_mention_chat(self, sending_message: SendingMessage) -> None: - wrong_mention_chat = { - "mention_type": MentionTypes.chat, - "mention_id": uuid.uuid4(), - "mention_data": {"foo": "bar"}, - } - with pytest.raises(ValueError): - Mention.parse_obj(wrong_mention_chat) - - def test_mention_data_error(self): - mention_all = { - "mention_type": "all", - "mention_id": uuid.uuid4(), - "mention_data": {}, - } - mention = Mention.parse_obj(mention_all) - assert mention.mention_data is None - - class TestBuildingMentions: - def test_build_embeddable_user_mention(self) -> None: - user_huid = uuid.uuid4() - - embeddable_mention = SendingMessage.build_embeddable_user_mention(user_huid) - - assert embeddable_mention.startswith(f" None: - user_huid = uuid.uuid4() - - embeddable_mention = SendingMessage.build_embeddable_contact_mention( - user_huid, - ) - - assert embeddable_mention.startswith(f" None: - chat_id = uuid.uuid4() - - embeddable_mention = SendingMessage.build_embeddable_chat_mention(chat_id) - - assert embeddable_mention.startswith(f" None: - channel_id = uuid.uuid4() - - embeddable_mention = SendingMessage.build_embeddable_channel_mention( - channel_id, - ) - - assert embeddable_mention.startswith( - f" None: - _, found_mentions = sending_message._find_and_replace_embed_mentions( - embed_mention_with_name, - ) - - assert len(found_mentions) == 1 - - def test_replace_embed_mention_without_name( - self, - sending_message: SendingMessage, - embed_mention_without_name: str, - ) -> None: - _, found_mentions = sending_message._find_and_replace_embed_mentions( - embed_mention_without_name, - ) - - assert len(found_mentions) == 1 - - @pytest.mark.parametrize( - ("embed_mention_with_name", "embed_mention_without_name"), - [ - (embed_mention_with_name, embed_mention_without_name), - (embed_mention_without_name, embed_mention_with_name), - ], - indirect=True, - ) - def test_replace_group_embed_mentions( - self, - sending_message: SendingMessage, - embed_mention_with_name: str, - embed_mention_without_name: str, - ) -> None: - embed_mentions = ", ".join( - [embed_mention_with_name, embed_mention_without_name], - ) - _, found_mentions = sending_message._find_and_replace_embed_mentions( - embed_mentions, - ) - - assert len(found_mentions) == 2 - - def test_replace_embed_mention_with_wrong_type( - self, - sending_message: SendingMessage, - embed_mention_with_wrong_type: str, - ) -> None: - with pytest.raises(ValueError): - _, found_mentions = sending_message._find_and_replace_embed_mentions( - embed_mention_with_wrong_type, - ) - - class TestAddingRecipients: - def test_adding_recipients_separately( - self, - sending_message: SendingMessage, - ) -> None: - users = [uuid.uuid4(), uuid.uuid4()] - sending_message.payload.options.recipients = "all" - - sending_message.add_recipient(users[0]) - assert sending_message.options.recipients == [users[0]] - - sending_message.add_recipient(users[1]) - assert sending_message.options.recipients == users - - def test_adding_multiple_recipients( - self, - sending_message: SendingMessage, - ) -> None: - users = [uuid.uuid4(), uuid.uuid4()] - sending_message.payload.options.recipients = "all" - - sending_message.add_recipients(users[:1]) - assert sending_message.options.recipients == users[:1] - - sending_message.add_recipients(users[1:]) - assert sending_message.options.recipients == users - - class TestMarkupAdding: - def test_adding_bubbles(self, sending_message: SendingMessage) -> None: - bubble = BubbleElement(command="/test") - sending_message.markup = MessageMarkup() - sending_message.add_bubble("/test") - sending_message.add_bubble("/test", new_row=False) - sending_message.add_bubble("/test") - sending_message.add_bubble("/test") - sending_message.add_bubble("/test", new_row=False) - assert sending_message.markup == MessageMarkup( - bubbles=[[bubble, bubble], [bubble], [bubble, bubble]], - ) - - def test_adding_keyboard(self, sending_message: SendingMessage) -> None: - keyboard_button = KeyboardElement(command="/test") - sending_message.markup = MessageMarkup() - sending_message.add_keyboard_button("/test") - sending_message.add_keyboard_button("/test", new_row=False) - sending_message.add_keyboard_button("/test") - sending_message.add_keyboard_button("/test") - sending_message.add_keyboard_button("/test", new_row=False) - assert sending_message.markup == MessageMarkup( - keyboard=[ - [keyboard_button, keyboard_button], - [keyboard_button], - [keyboard_button, keyboard_button], - ], - ) - - def test_setting_notification_show(self, sending_message: SendingMessage) -> None: - sending_message.show_notification(True) - assert sending_message.options.notifications.send - - def test_setting_dnd(self, sending_message: SendingMessage) -> None: - sending_message.force_notification(True) - assert sending_message.options.notifications.force_dnd - - -def test_credentials_or_parameters_required_for_message_creation(): - with pytest.raises(AssertionError): - SendingMessage() - - -class TestIsForward: - def test_is_forward_message(self, message, bot) -> None: - builder = MessageBuilder() - builder.forward(message=message) - new_message = Message.from_dict(message=builder.message.dict(), bot=bot) - assert new_message.is_forward - - def test_is_forward_message_error(self, message, bot) -> None: - assert not message.is_forward diff --git a/tests/test_models/test_messages/test_receiving.py b/tests/test_models/test_messages/test_receiving.py deleted file mode 100644 index 30f1103a..00000000 --- a/tests/test_models/test_messages/test_receiving.py +++ /dev/null @@ -1,136 +0,0 @@ -import uuid -from datetime import datetime as dt, timezone as tz -from typing import List - -import pytest - -from botx import ChatTypes, CommandTypes, EntityTypes -from botx.models.messages.incoming_message import Command, IncomingMessage, Sender - - -@pytest.mark.parametrize( - ("body", "command", "arguments", "single_argument"), - [ - ("/command", "/command", (), ""), - ("/command ", "/command", (), ""), - ("/command arg", "/command", ("arg",), "arg"), - ("/command arg ", "/command", ("arg",), "arg"), - ("/command \t\t arg ", "/command", ("arg",), "arg"), - ("/command arg arg", "/command", ("arg", "arg"), "arg arg"), - ("/command arg arg ", "/command", ("arg", "arg"), "arg arg"), - ], -) -def test_command_splits_right( - body: str, - command: str, - arguments: List[str], - single_argument: str, -) -> None: - command = Command(body=body, command_type=CommandTypes.user) - assert command.body == body - assert command.command == command.command - assert command.arguments == arguments - assert command.single_argument == command.single_argument - - -def test_command_data_as_dict() -> None: - command = Command( - body="/test", - command_type=CommandTypes.user, - data={"some": "data"}, - ) - assert command.data_dict == command.data == {"some": "data"} - - -def test_user_email_when_credentials_passed() -> None: - sender = Sender( - user_huid=uuid.uuid4(), - group_chat_id=uuid.uuid4(), - chat_type=ChatTypes.chat, - ad_login="user", - ad_domain="example.com", - username="test user", - is_admin=False, - is_creator=True, - host="cts.example.com", - ) - assert sender.upn == "user@example.com" - - -def test_user_email_when_credentials_missed() -> None: - assert ( - Sender( - group_chat_id=uuid.uuid4(), - chat_type=ChatTypes.chat, - is_admin=False, - is_creator=True, - host="cts.example.com", - ).upn - is None - ) - - -def test_skip_validation_for_file() -> None: - file_data = {"file_name": "zen.py", "data": "data:text/plain;base64,"} - - IncomingMessage.parse_obj( - { - "sync_id": "a465f0f3-1354-491c-8f11-f400164295cb", - "command": {"body": "/cmd", "command_type": "user", "data": {}}, - "file": file_data, - "from": { - "user_huid": None, - "group_chat_id": "8dada2c8-67a6-4434-9dec-570d244e78ee", - "ad_login": None, - "ad_domain": None, - "username": None, - "chat_type": "group_chat", - "host": "cts.ccteam.ru", - "is_admin": False, - "is_creator": False, - }, - "bot_id": "dcfa5a7c-7cc4-4c89-b6c0-80325604f9f4", - }, - ) - - -def test_parse_message_forward() -> None: - inserted_at = dt(2020, 7, 10, 10, 12, 58, 420000, tzinfo=tz.utc) - - IncomingMessage.parse_obj( - { - "bot_id": "f6615a30-9d3d-5770-b453-749ea562a974", - "command": { - "body": "Message body", - "command_type": CommandTypes.user, - "data": {}, - "metadata": {}, - }, - "entities": [ - { - "data": { - "forward_type": ChatTypes.chat, - "group_chat_id": "b51df4c1-3834-0949-1066-614ec424d28a", - "sender_huid": "4471289e-5b52-5c1b-8eab-a22c548fef9b", - "source_chat_name": "MessageAuthor Name", - "source_inserted_at": inserted_at, - "source_sync_id": "80d2c3a9-0031-50a8-aeed-32bb5d285758", - }, - "type": EntityTypes.forward, - }, - ], - "file": None, - "sync_id": "eeb8eeca-3f31-5037-8b41-84de63909a31", - "user": { - "ad_domain": "ccsteam.ru", - "ad_login": "message.forwarder", - "chat_type": ChatTypes.chat, - "group_chat_id": "070d866f-fe5b-0222-2a9e-b7fc35c99465", - "host": "cts.ccsteam.ru", - "is_admin": True, - "is_creator": True, - "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", - "username": "MessageForwarder Name", - }, - }, - ) diff --git a/tests/test_models/test_messages/test_sending.py b/tests/test_models/test_messages/test_sending.py deleted file mode 100644 index 9ef90307..00000000 --- a/tests/test_models/test_messages/test_sending.py +++ /dev/null @@ -1,35 +0,0 @@ -from botx import BubbleElement, KeyboardElement, MessageMarkup, UpdatePayload - - -def test_message_markup_will_add_row_if_there_is_no_existed_and_not_new_row() -> None: - markup1 = MessageMarkup() - markup1.add_bubble("/command", new_row=False) - - markup2 = MessageMarkup() - markup2.add_bubble("/command") - - assert markup1 == markup2 - - -def test_update_markup_will_can_be_set_from_markup() -> None: - markup = MessageMarkup() - markup.add_bubble("/command") - markup.add_keyboard_button("/command") - - update = UpdatePayload() - update.set_markup(markup) - - assert update.markup == markup - assert update.bubbles == markup.bubbles - assert update.keyboard == markup.keyboard - - -def test_adding_markup_by_elements() -> None: - bubble = BubbleElement(command="/command") - keyboard = KeyboardElement(command="/command") - - markup = MessageMarkup() - markup.add_bubble_element(bubble) - markup.add_keyboard_button_element(keyboard) - - assert markup == MessageMarkup(bubbles=[[bubble]], keyboard=[[keyboard]]) diff --git a/tests/test_models/test_smartapps.py b/tests/test_models/test_smartapps.py deleted file mode 100644 index ff210fcc..00000000 --- a/tests/test_models/test_smartapps.py +++ /dev/null @@ -1,141 +0,0 @@ -from io import BytesIO -from typing import Any, Dict -from uuid import UUID - -from botx import File, Message -from botx.models.smartapps import SendingSmartAppEvent, SendingSmartAppNotification - -pytest_plugins = ("tests.test_clients.fixtures", "tests.fixtures.smartapps") - - -def test_sending_smartapp_event( - ref: UUID, - smartapp_id: UUID, - smartapp_api_version: int, - group_chat_id: UUID, - smartapp_data: Dict[str, Any], -): - sending_smartapp = SendingSmartAppEvent( - ref=ref, - smartapp_id=smartapp_id, - smartapp_api_version=smartapp_api_version, - group_chat_id=group_chat_id, - data=smartapp_data, - ) - - assert sending_smartapp.ref == ref - assert sending_smartapp.smartapp_id == smartapp_id - assert sending_smartapp.smartapp_api_version == smartapp_api_version - assert sending_smartapp.group_chat_id == group_chat_id - assert sending_smartapp.data == smartapp_data - - -def test_sending_smartapp_notification( - ref: UUID, - smartapp_api_version: int, - group_chat_id: UUID, - smartapp_counter: int, -): - sending_smartapp = SendingSmartAppNotification( - smartapp_api_version=smartapp_api_version, - group_chat_id=group_chat_id, - smartapp_counter=smartapp_counter, - ) - - assert sending_smartapp.smartapp_api_version == smartapp_api_version - assert sending_smartapp.group_chat_id == group_chat_id - assert sending_smartapp.smartapp_counter == smartapp_counter - - -def test_sending_smartapp_notification_from_message( - message: Message, - ref: UUID, - smartapp_id: UUID, - smartapp_api_version: int, - group_chat_id: UUID, - smartapp_counter: int, -): - message.incoming_message.command.data_dict[ - "smartapp_api_version" - ] = smartapp_api_version - message.incoming_message.command.data_dict["opts"] = {} - message.group_chat_id = group_chat_id - - sending_smartapp = SendingSmartAppNotification.from_message( - smartapp_counter=smartapp_counter, - message=message, - ) - - assert sending_smartapp.smartapp_api_version == smartapp_api_version - assert sending_smartapp.group_chat_id == group_chat_id - assert sending_smartapp.smartapp_counter == smartapp_counter - - -def test_sending_smartapp_event_from_message( - message: Message, - ref: UUID, - smartapp_id: UUID, - smartapp_api_version: int, - group_chat_id: UUID, - smartapp_data: Dict[str, Any], -): - message.incoming_message.command.data_dict[ - "smartapp_api_version" - ] = smartapp_api_version - message.incoming_message.command.data_dict["opts"] = {} - message.incoming_message.command.data_dict["smartapp_id"] = smartapp_id - message.incoming_message.command.data_dict["ref"] = ref - - message.group_chat_id = group_chat_id - - sending_smartapp = SendingSmartAppEvent.from_message( - data=smartapp_data, - message=message, - ) - - assert sending_smartapp.smartapp_api_version == smartapp_api_version - assert sending_smartapp.group_chat_id == group_chat_id - assert sending_smartapp.data == smartapp_data - - -def test_sending_smartapp_event_add_botx_file( - ref: UUID, - smartapp_id: UUID, - smartapp_api_version: int, - group_chat_id: UUID, - smartapp_data: Dict[str, Any], -): - sending_smartapp = SendingSmartAppEvent( - ref=ref, - smartapp_id=smartapp_id, - smartapp_api_version=smartapp_api_version, - group_chat_id=group_chat_id, - data=smartapp_data, - ) - - file = File.from_string(b"data", filename="file.txt") - sending_smartapp.add_file(file) - - assert sending_smartapp.files == [file] - - -def test_sending_smartapp_event_add_file( - ref: UUID, - smartapp_id: UUID, - smartapp_api_version: int, - group_chat_id: UUID, - smartapp_data: Dict[str, Any], -): - sending_smartapp = SendingSmartAppEvent( - ref=ref, - smartapp_id=smartapp_id, - smartapp_api_version=smartapp_api_version, - group_chat_id=group_chat_id, - data=smartapp_data, - ) - - file_data = b"data" - file = File.from_string(file_data, filename="file.txt") - sending_smartapp.add_file(BytesIO(file_data), file.file_name) - - assert sending_smartapp.files == [file] diff --git a/tests/test_models/test_status.py b/tests/test_models/test_status.py deleted file mode 100644 index 53df32c0..00000000 --- a/tests/test_models/test_status.py +++ /dev/null @@ -1,27 +0,0 @@ -import uuid - -from botx import ChatTypes -from botx.models.status import StatusRecipient - - -def test_status_recipient(): - bot_id = uuid.uuid4() - user_huid = uuid.uuid4() - ad_login = "login" - ad_domain = "domain" - is_admin = True - chat_type = ChatTypes.chat - recipient = StatusRecipient( - bot_id=bot_id, - user_huid=user_huid, - ad_login=ad_login, - ad_domain=ad_domain, - is_admin=is_admin, - chat_type=chat_type, - ) - assert recipient.bot_id == bot_id - assert recipient.user_huid == user_huid - assert recipient.ad_login == ad_login - assert recipient.ad_domain == ad_domain - assert recipient.is_admin == is_admin - assert recipient.chat_type == chat_type diff --git a/tests/test_state.py b/tests/test_state.py new file mode 100644 index 00000000..35ed2b3d --- /dev/null +++ b/tests/test_state.py @@ -0,0 +1,93 @@ +from typing import Callable, Optional + +import pytest + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + IncomingMessage, + IncomingMessageHandlerFunc, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__bot_state__save_changes_between_middleware_and_handler( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + user_command = incoming_message_factory(body="/command") + + async def middleware( + message: IncomingMessage, + bot: Bot, + call_next: IncomingMessageHandlerFunc, + ) -> None: + bot.state.api_token = "token" + + await call_next(message, bot) + + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + pass + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + middlewares=[middleware], + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + assert built_bot.state.api_token == "token" + + +async def test__message_state__save_changes_between_middleware_and_handler( + incoming_message_factory: Callable[..., IncomingMessage], + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + incoming_message: Optional[IncomingMessage] = None + user_command = incoming_message_factory(body="/command") + + async def middleware( + message: IncomingMessage, + bot: Bot, + call_next: IncomingMessageHandlerFunc, + ) -> None: + message.state.username = "ivanov_ivan_1990" + + await call_next(message, bot) + + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + nonlocal incoming_message + incoming_message = message + + built_bot = Bot( + collectors=[collector], + bot_accounts=[bot_account], + middlewares=[middleware], + ) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(user_command) + + # - Assert - + assert incoming_message is not None + assert incoming_message.state.username == "ivanov_ivan_1990" diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 00000000..760d8aea --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,319 @@ +from unittest.mock import Mock +from uuid import UUID, uuid4 + +import pytest + +from botx import ( + Bot, + BotAccountWithSecret, + BotMenu, + ChatTypes, + HandlerCollector, + IncomingMessage, + StatusRecipient, + UnknownBotAccountError, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +@pytest.fixture +def status_recipient(bot_id: UUID) -> StatusRecipient: + return StatusRecipient( + bot_id=bot_id, + huid=uuid4(), + ad_login=None, + ad_domain=None, + is_admin=True, + chat_type=ChatTypes.PERSONAL_CHAT, + ) + + +async def test__get_status__hidden_command_not_in_menu( + bot_account: BotAccountWithSecret, + status_recipient: StatusRecipient, + incorrect_handler_trigger: Mock, +) -> None: + # - Arrange - + collector = HandlerCollector() + + @collector.command("/_command", visible=False) + async def handler(message: IncomingMessage, bot: Bot) -> None: + incorrect_handler_trigger() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + status = await bot.get_status(status_recipient) + + # - Assert - + assert status == BotMenu({}) + + incorrect_handler_trigger.assert_not_called() + + +async def test__get_status__visible_command_in_menu( + bot_account: BotAccountWithSecret, + status_recipient: StatusRecipient, + incorrect_handler_trigger: Mock, +) -> None: + # - Arrange - + collector = HandlerCollector() + + @collector.command("/command", description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + incorrect_handler_trigger() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + status = await bot.get_status(status_recipient) + + # - Assert - + assert status == BotMenu({"/command": "My command"}) + + incorrect_handler_trigger.assert_not_called() + + +async def test__get_status__command_not_in_menu_if_visible_func_return_false( + bot_account: BotAccountWithSecret, + status_recipient: StatusRecipient, + incorrect_handler_trigger: Mock, +) -> None: + # - Arrange - + collector = HandlerCollector() + + async def visible_func(status_recipient: StatusRecipient, bot: Bot) -> bool: + return False + + @collector.command("/command", visible=visible_func, description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + incorrect_handler_trigger() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + status = await bot.get_status(status_recipient) + + # - Assert - + assert status == BotMenu({}) + + incorrect_handler_trigger.assert_not_called() + + +async def test__get_status__command_in_menu_if_visible_func_return_true( + bot_account: BotAccountWithSecret, + status_recipient: StatusRecipient, + incorrect_handler_trigger: Mock, +) -> None: + # - Arrange - + collector = HandlerCollector() + + async def visible_func(status_recipient: StatusRecipient, bot: Bot) -> bool: + return True + + @collector.command("/command", visible=visible_func, description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + incorrect_handler_trigger() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + status = await bot.get_status(status_recipient) + + # - Assert - + assert status == BotMenu({"/command": "My command"}) + + incorrect_handler_trigger.assert_not_called() + + +async def test__raw_get_status__invalid_query() -> None: + # - Arrange - + query = {"user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11"} + + collector = HandlerCollector() + + @collector.command("/_command", visible=False) + async def handler(message: IncomingMessage, bot: Bot) -> None: + pass + + built_bot = Bot(collectors=[collector], bot_accounts=[]) + + # - Act - + with pytest.raises(ValueError) as exc: + async with lifespan_wrapper(built_bot) as bot: + await bot.raw_get_status(query) + + # - Assert - + assert "validation error" in str(exc.value) + + +async def test__raw_get_status__unknown_bot_account_error_raised() -> None: + # - Arrange - + query = { + "bot_id": "123e4567-e89b-12d3-a456-426655440000", + "chat_type": "chat", + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + } + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + with pytest.raises(UnknownBotAccountError) as exc: + await bot.raw_get_status(query) + + # - Assert - + assert "123e4567-e89b-12d3-a456-426655440000" in str(exc.value) + + +async def test__raw_get_status__minimally_filled_succeed( + bot_account: BotAccountWithSecret, + bot_id: UUID, +) -> None: + # - Arrange - + query = { + "bot_id": str(bot_id), + "chat_type": "chat", + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + } + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + status = await bot.raw_get_status(query) + + # - Assert - + assert status + + +async def test__raw_get_status__minimum_filled_succeed( + bot_account: BotAccountWithSecret, + bot_id: UUID, +) -> None: + # - Arrange - + query = { + "ad_domain": "", + "ad_login": "", + "is_admin": "", + "bot_id": str(bot_id), + "chat_type": "group_chat", + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + } + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + status = await bot.raw_get_status(query) + + # - Assert - + assert status + + +async def test__raw_get_status__maximum_filled_succeed( + bot_account: BotAccountWithSecret, + bot_id: UUID, +) -> None: + # - Arrange - + query = { + "ad_domain": "domain", + "ad_login": "login", + "bot_id": str(bot_id), + "chat_type": "chat", + "is_admin": "true", + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + } + + built_bot = Bot(collectors=[HandlerCollector()], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + status = await bot.raw_get_status(query) + + # - Assert - + assert status + + +async def test__raw_get_status__hidden_command_not_in_status( + bot_account: BotAccountWithSecret, + bot_id: UUID, +) -> None: + # - Arrange - + query = { + "bot_id": str(bot_id), + "chat_type": "chat", + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + } + + collector = HandlerCollector() + + @collector.command("/_command", visible=False) + async def handler(message: IncomingMessage, bot: Bot) -> None: + pass + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + status = await bot.raw_get_status(query) + + # - Assert - + assert status == { + "result": { + "commands": [], + "enabled": True, + "status_message": "Bot is working", + }, + "status": "ok", + } + + +async def test__raw_get_status__visible_command_in_status( + bot_account: BotAccountWithSecret, + bot_id: UUID, +) -> None: + # - Arrange - + query = { + "bot_id": str(bot_id), + "chat_type": "chat", + "user_huid": "f16cdc5f-6366-5552-9ecd-c36290ab3d11", + } + + collector = HandlerCollector() + + @collector.command("/command", visible=True, description="My command") + async def handler(message: IncomingMessage, bot: Bot) -> None: + pass + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + status = await bot.raw_get_status(query) + + # - Assert - + assert status == { + "result": { + "commands": [ + { + "body": "/command", + "description": "My command", + "name": "/command", + }, + ], + "enabled": True, + "status_message": "Bot is working", + }, + "status": "ok", + } diff --git a/tests/test_stickers.py b/tests/test_stickers.py new file mode 100644 index 00000000..32de09e2 --- /dev/null +++ b/tests/test_stickers.py @@ -0,0 +1,77 @@ +from http import HTTPStatus +from typing import Any, Callable, Dict +from uuid import UUID + +import httpx +import pytest +from aiofiles.tempfile import NamedTemporaryFile +from respx.router import MockRouter + +from botx import ( + Bot, + BotAccountWithSecret, + HandlerCollector, + IncomingMessage, + Sticker, + lifespan_wrapper, +) + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + +PNG_IMAGE = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x01\x03\x00" + b"\x00\x00%\xdbV\xca\x00\x00\x00\x03PLTE\x00\x00\x00\xa7z=\xda\x00" + b"\x00\x00\x01tRNS\x00@\xe6\xd8f\x00\x00\x00\nIDAT\x08\xd7c`\x00" + b"\x00\x00\x02\x00\x01\xe2!\xbc3\x00\x00\x00\x00IEND\xaeB`\x82" +) + + +async def test__sticker__download( + respx_mock: MockRouter, + host: str, + bot_account: BotAccountWithSecret, + async_buffer: NamedTemporaryFile, + api_incoming_message_factory: Callable[..., Dict[str, Any]], +) -> None: + # - Arrange - + image_link = ( + f"https://{host}/uploads/sticker_pack/" + "4ff8113b-8460-5977-86b2-c1798eb4fbce/" + "14a762edf2e04c579de98098e22b01da.png" + ) + + endpoint = respx_mock.get(image_link).mock( + return_value=httpx.Response( + HTTPStatus.OK, + content=PNG_IMAGE, + ), + ) + + sticker = Sticker( + id=UUID("4ff8113b-8460-5977-86b2-c1798eb4fbce"), + emoji="🤔", + image_link=image_link, + ) + + payload = api_incoming_message_factory() + + collector = HandlerCollector() + + @collector.default_message_handler + async def default_handler(message: IncomingMessage, bot: Bot) -> None: + await sticker.download(async_buffer) + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_raw_bot_command(payload) + + # - Assert - + assert await async_buffer.read() == PNG_IMAGE + assert endpoint.called diff --git a/tests/test_system_events_routing.py b/tests/test_system_events_routing.py new file mode 100644 index 00000000..df41ce2a --- /dev/null +++ b/tests/test_system_events_routing.py @@ -0,0 +1,138 @@ +from unittest.mock import Mock +from uuid import UUID, uuid4 + +import pytest + +from botx import ( + Bot, + BotAccount, + BotAccountWithSecret, + Chat, + ChatCreatedEvent, + ChatCreatedMember, + ChatTypes, + HandlerCollector, + UserKinds, + lifespan_wrapper, +) + + +@pytest.fixture +def chat_created( + bot_id: UUID, +) -> ChatCreatedEvent: + return ChatCreatedEvent( + bot=BotAccount( + id=bot_id, + host="cts.example.com", + ), + sync_id=uuid4(), + chat_name="Test", + chat=Chat( + id=uuid4(), + type=ChatTypes.PERSONAL_CHAT, + ), + creator_id=uuid4(), + members=[ + ChatCreatedMember( + is_admin=False, + huid=uuid4(), + username="Ivanov Ivan Ivanovich", + kind=UserKinds.CTS_USER, + ), + ], + raw_command=None, + ) + + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.mock_authorization, + pytest.mark.usefixtures("respx_mock"), +] + + +async def test__system_event_handler__called( + chat_created: ChatCreatedEvent, + correct_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + collector = HandlerCollector() + + @collector.chat_created + async def chat_created_handler(event: ChatCreatedEvent, bot: Bot) -> None: + correct_handler_trigger() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(chat_created) + + # - Assert - + correct_handler_trigger.assert_called_once() + + +async def test__system_event_handler__no_handler_for_system_event( + chat_created: ChatCreatedEvent, + bot_account: BotAccountWithSecret, + loguru_caplog: pytest.LogCaptureFixture, +) -> None: + # - Arrange - + collector = HandlerCollector() + + built_bot = Bot(collectors=[collector], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(chat_created) + + # - Assert - + assert "Handler for `ChatCreatedEvent` not found" in loguru_caplog.text + + +async def test__system_event_handler__handler_in_first_collector( + chat_created: ChatCreatedEvent, + correct_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + collector_1 = HandlerCollector() + collector_2 = HandlerCollector() + + @collector_1.chat_created + async def chat_created_handler(event: ChatCreatedEvent, bot: Bot) -> None: + correct_handler_trigger() + + built_bot = Bot(collectors=[collector_1, collector_2], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(chat_created) + + # - Assert - + correct_handler_trigger.assert_called_once() + + +async def test__system_event_handler__handler_in_second_collector( + chat_created: ChatCreatedEvent, + correct_handler_trigger: Mock, + bot_account: BotAccountWithSecret, +) -> None: + # - Arrange - + collector_1 = HandlerCollector() + collector_2 = HandlerCollector() + + @collector_2.chat_created + async def chat_created_handler(event: ChatCreatedEvent, bot: Bot) -> None: + correct_handler_trigger() + + built_bot = Bot(collectors=[collector_1, collector_2], bot_accounts=[bot_account]) + + # - Act - + async with lifespan_wrapper(built_bot) as bot: + bot.async_execute_bot_command(chat_created) + + # - Assert - + correct_handler_trigger.assert_called_once() diff --git a/tests/test_testing/__init__.py b/tests/test_testing/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_testing/test_builder.py b/tests/test_testing/test_builder.py deleted file mode 100644 index f5383ea8..00000000 --- a/tests/test_testing/test_builder.py +++ /dev/null @@ -1,199 +0,0 @@ -import uuid -from datetime import datetime -from io import StringIO - -import pytest -from pydantic import ValidationError - -from botx import ( - ChatTypes, - Entity, - EntityTypes, - File, - Forward, - Mention, - MentionTypes, - MessageBuilder, - UserMention, -) -from botx.models.entities import Reply - - -def test_setting_new_file_from_file(): - file = File.from_string("some data", "name.txt") - builder = MessageBuilder() - builder.file = file - - assert builder.file == file - - -def test_setting_new_file_from_io(): - file = File.from_string("some data", "name.txt") - builder = MessageBuilder() - builder.file = file.file - - assert builder.file == file - - -def test_settings_new_user_for_message(incoming_message): - builder = MessageBuilder() - builder.user = incoming_message.user - - assert builder.user == incoming_message.user - - -def test_file_transfer_event(): - builder = MessageBuilder() - builder.file = File.from_string("some data", "name.txt") - - builder.body = "file_transfer" - builder.system_command = True - - -def test_setting_not_processable_file_for_incoming_message(): - file = StringIO("import this") - file.name = "zen.py" - - builder = MessageBuilder() - builder.file = file - - message = builder.message - - assert message.file.file_name == "zen.py" - - -def test_mention_user_in_message(): - user_huid = uuid.uuid4() - builder = MessageBuilder() - builder.mention_user(user_huid) - - assert builder.message.entities.__root__[0].data.mention_type == MentionTypes.user - assert builder.message.entities.__root__[0].data.mention_data.user_huid == user_huid - - -def test_mention_contact_in_message(): - user_huid = uuid.uuid4() - builder = MessageBuilder() - builder.mention_contact(user_huid) - - assert ( - builder.message.entities.__root__[0].data.mention_type == MentionTypes.contact - ) - assert builder.message.entities.__root__[0].data.mention_data.user_huid == user_huid - - -def test_mention_chat_in_message(): - chat_id = uuid.uuid4() - builder = MessageBuilder() - builder.mention_chat(chat_id) - - assert builder.message.entities.__root__[0].data.mention_type == MentionTypes.chat - assert ( - builder.message.entities.__root__[0].data.mention_data.group_chat_id == chat_id - ) - - -def test_setting_raw_entities(): - builder = MessageBuilder() - builder.entities = [ - Entity( - type=EntityTypes.mention, - data=Mention(mention_data=UserMention(user_huid=uuid.uuid4())), - ), - ] - - assert builder.message.entities.__root__[0].data.mention_type == MentionTypes.user - - -@pytest.mark.parametrize( - "include_param", - ["user_huid", "ad_login", "ad_domain", "username"], -) -def test_error_when_chat_validation_not_passed(include_param): - user_params = {"user_huid", "ad_login", "ad_domain", "username"} - builder = MessageBuilder() - - builder.body = "system:chat_created" - builder.user = builder.user.copy( - update={param: None for param in user_params - {include_param}}, - ) - builder.command_data = { - "group_chat_id": uuid.uuid4(), - "chat_type": "group_chat", - "name": "", - "creator": uuid.uuid4(), - "members": [], - } - with pytest.raises(ValidationError): - builder.system_command = True - - -def test_error_when_file_validation_not_passed(): - builder = MessageBuilder() - builder.body = "file_transfer" - with pytest.raises(ValidationError): - builder.system_command = True - - -class TestBuildForward: - def test_building_forward_via_models(self): - builder = MessageBuilder() - builder.forward( - forward=Forward( - group_chat_id=uuid.uuid4(), - sender_huid=uuid.uuid4(), - forward_type=ChatTypes.group_chat, # ignore: type - source_inserted_at=datetime.now(), - source_sync_id=uuid.uuid4(), - ), - ) - - def test_building_forward_via_message(self, message): - builder = MessageBuilder() - builder.forward(message=message) - - def test_building_forward_arguments_error(self, message): - builder = MessageBuilder() - with pytest.raises(ValueError): - builder.forward( - message=message, - forward=Forward( - group_chat_id=uuid.uuid4(), - sender_huid=uuid.uuid4(), - forward_type=ChatTypes.botx, # ignore: type - source_inserted_at=datetime.now(), - source_sync_id=uuid.uuid4(), - ), - ) - - -class TestBuildReply: - def test_building_forward_via_models(self): - builder = MessageBuilder() - builder.reply( - reply=Reply( - body="foo", - reply_type=ChatTypes.group_chat, - sender=uuid.uuid4(), - source_chat_name="bar", - source_sync_id=uuid.uuid4(), - ), - ) - - def test_building_forward_via_message(self, message): - builder = MessageBuilder() - builder.reply(message=message) - - def test_building_forward_arguments_error(self, message): - builder = MessageBuilder() - with pytest.raises(ValueError): - builder.reply( - message=message, - reply=Reply( - body="foo", - reply_type=ChatTypes.group_chat, - sender=uuid.uuid4(), - source_chat_name="bar", - source_sync_id=uuid.uuid4(), - ), - ) diff --git a/tests/test_testing/test_client.py b/tests/test_testing/test_client.py deleted file mode 100644 index 9eafbf8b..00000000 --- a/tests/test_testing/test_client.py +++ /dev/null @@ -1,20 +0,0 @@ -import threading - -import pytest - -from botx import TestClient - -pytestmark = pytest.mark.asyncio - - -async def test_disabling_sync_send_for_client(bot, incoming_message, build_handler): - bot.default(build_handler(threading.Event())) - - with TestClient(bot) as client: - await client.send_command(incoming_message, False) - - assert bot.tasks - - await bot.shutdown() - - assert not bot.tasks