From 7e2cef07304698f7815a50e0d117d5f737c401f5 Mon Sep 17 00:00:00 2001 From: Renat Akzholov <31467120+rrrrs09@users.noreply.github.com> Date: Wed, 25 Aug 2021 15:00:40 +0300 Subject: [PATCH] Feature/files methods (#160) * feat: add download and upload files methods * test: add and refactor tests * docs: add release changes * chore: fix pyproject.toml --- botx/bots/mixins/requests/files.py | 79 ++++++++ botx/bots/mixins/requests/mixin.py | 7 +- botx/clients/clients/async_client.py | 47 +++-- botx/clients/clients/processing.py | 27 ++- botx/clients/clients/sync_client.py | 47 +++-- botx/clients/methods/base.py | 19 +- botx/clients/methods/errors/files/__init__.py | 1 + .../methods/errors/files/chat_not_found.py | 53 ++++++ .../methods/errors/files/file_deleted.py | 51 ++++++ .../errors/files/metadata_not_found.py | 62 +++++++ .../methods/errors/files/without_preview.py | 53 ++++++ botx/clients/methods/v3/files/__init__.py | 1 + botx/clients/methods/v3/files/download.py | 57 ++++++ botx/clients/methods/v3/files/upload.py | 60 ++++++ botx/clients/types/http.py | 46 ++++- botx/clients/types/upload_file.py | 14 ++ botx/models/files.py | 67 +++++++ botx/testing/botx_mock/asgi/application.py | 4 + botx/testing/botx_mock/asgi/responses.py | 10 +- botx/testing/botx_mock/asgi/routes/files.py | 60 ++++++ botx/testing/botx_mock/entities.py | 29 +++ botx/testing/botx_mock/wsgi/application.py | 4 + botx/testing/botx_mock/wsgi/responses.py | 10 +- botx/testing/botx_mock/wsgi/routes/files.py | 70 +++++++ botx/testing/typing.py | 4 + docs/changelog.md | 17 ++ poetry.lock | 171 +++++++++++------- pyproject.toml | 7 +- .../test_mixins/test_requests/test_files.py | 27 +++ .../test_errors/test_files/__init__.py | 0 .../test_files/test_chat_not_found.py | 44 +++++ .../test_files/test_file_deleted.py | 41 +++++ .../test_files/test_metadata_not_found.py | 45 +++++ .../test_files/test_without_preview.py | 45 +++++ .../test_v3/test_files/__init__.py | 0 .../test_v3/test_files/test_download.py | 32 ++++ .../test_v3/test_files/test_upload.py | 35 ++++ tests/test_clients/test_types/__init__.py | 0 tests/test_clients/test_types/test_http.py | 14 ++ .../test_models/test_files/test_attributes.py | 5 + 40 files changed, 1250 insertions(+), 115 deletions(-) create mode 100644 botx/bots/mixins/requests/files.py create mode 100644 botx/clients/methods/errors/files/__init__.py create mode 100644 botx/clients/methods/errors/files/chat_not_found.py create mode 100644 botx/clients/methods/errors/files/file_deleted.py create mode 100644 botx/clients/methods/errors/files/metadata_not_found.py create mode 100644 botx/clients/methods/errors/files/without_preview.py create mode 100644 botx/clients/methods/v3/files/__init__.py create mode 100644 botx/clients/methods/v3/files/download.py create mode 100644 botx/clients/methods/v3/files/upload.py create mode 100644 botx/clients/types/upload_file.py create mode 100644 botx/testing/botx_mock/asgi/routes/files.py create mode 100644 botx/testing/botx_mock/wsgi/routes/files.py create mode 100644 tests/test_bots/test_mixins/test_requests/test_files.py create mode 100644 tests/test_clients/test_methods/test_errors/test_files/__init__.py create mode 100644 tests/test_clients/test_methods/test_errors/test_files/test_chat_not_found.py create mode 100644 tests/test_clients/test_methods/test_errors/test_files/test_file_deleted.py create mode 100644 tests/test_clients/test_methods/test_errors/test_files/test_metadata_not_found.py create mode 100644 tests/test_clients/test_methods/test_errors/test_files/test_without_preview.py create mode 100644 tests/test_clients/test_methods/test_v3/test_files/__init__.py create mode 100644 tests/test_clients/test_methods/test_v3/test_files/test_download.py create mode 100644 tests/test_clients/test_methods/test_v3/test_files/test_upload.py create mode 100644 tests/test_clients/test_types/__init__.py create mode 100644 tests/test_clients/test_types/test_http.py diff --git a/botx/bots/mixins/requests/files.py b/botx/bots/mixins/requests/files.py new file mode 100644 index 00000000..45b38a24 --- /dev/null +++ b/botx/bots/mixins/requests/files.py @@ -0,0 +1,79 @@ +"""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( + self: BotXMethodCallProtocol, + credentials: SendingCredentials, + file_id: UUID, + group_chat_id: UUID, + *, + 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. + is_preview: get preview or file. + + Returns: + Downloaded file. + """ + return await self.call_method( + DownloadFile( + file_id=file_id, + group_chat_id=group_chat_id, + is_preview=is_preview, + ), + credentials=credentials, + ) diff --git a/botx/bots/mixins/requests/mixin.py b/botx/bots/mixins/requests/mixin.py index e893c4ec..a8a58bed 100644 --- a/botx/bots/mixins/requests/mixin.py +++ b/botx/bots/mixins/requests/mixin.py @@ -9,6 +9,7 @@ chats, command, events, + files, internal_bot_notification, notification, users, @@ -45,6 +46,7 @@ class BotXRequestsMixin( # noqa: WPS215 notification.NotificationRequestsMixin, users.UsersRequestsMixin, internal_bot_notification.InternalBotNotificationRequestsMixin, + files.FilesRequestsMixin, ): """Mixin that defines methods for communicating with BotX API.""" @@ -86,7 +88,10 @@ async def call_method( # noqa: WPS211 request = self.client.build_request(method) - logger.bind(botx_client=True, payload=request.dict()).debug( + 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, diff --git a/botx/clients/clients/async_client.py b/botx/clients/clients/async_client.py index 611d680b..3d6bce71 100644 --- a/botx/clients/clients/async_client.py +++ b/botx/clients/clients/async_client.py @@ -9,7 +9,7 @@ from botx.clients.clients.processing import extract_result, handle_error from botx.clients.methods.base import BotXMethod -from botx.clients.types.http import HTTPRequest, HTTPResponse +from botx.clients.types.http import ExpectedType, HTTPRequest, HTTPResponse from botx.converters import optional_sequence_to_list from botx.exceptions import ( BotXAPIError, @@ -65,20 +65,20 @@ async def process_response( BotXAPIError: raised if handler for error status code was not found. BotXAPIRouteDeprecated: raised if API route was deprecated. """ - if response.is_error or response.is_redirect: - 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, - ) + 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, @@ -108,6 +108,8 @@ async def execute(self, request: HTTPRequest) -> HTTPResponse: 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( @@ -115,12 +117,29 @@ async def execute(self, request: HTTPRequest) -> HTTPResponse: 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 index 6752d45f..82f2ed31 100644 --- a/botx/clients/clients/processing.py +++ b/botx/clients/clients/processing.py @@ -1,18 +1,38 @@ """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 HTTPResponse +from botx.clients.types.http import ExpectedType, HTTPResponse +from botx.models.files import File ResponseT = TypeVar("ResponseT") -def extract_result(method: BotXMethod[ResponseT], response: HTTPResponse) -> 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) + 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: @@ -22,6 +42,9 @@ def extract_result(method: BotXMethod[ResponseT], response: HTTPResponse) -> Res 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, diff --git a/botx/clients/clients/sync_client.py b/botx/clients/clients/sync_client.py index 02601aab..a4ad37ed 100644 --- a/botx/clients/clients/sync_client.py +++ b/botx/clients/clients/sync_client.py @@ -10,7 +10,7 @@ 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 HTTPRequest, HTTPResponse +from botx.clients.types.http import ExpectedType, HTTPRequest, HTTPResponse from botx.converters import optional_sequence_to_list from botx.exceptions import ( BotXAPIError, @@ -65,20 +65,20 @@ def process_response( BotXAPIError: raised if handler for error status code was not found. BotXAPIRouteDeprecated: raised if API route was deprecated. """ - if response.is_error or response.is_redirect: - 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, - ) + 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, @@ -108,6 +108,8 @@ def execute(self, request: HTTPRequest) -> HTTPResponse: 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( @@ -115,12 +117,29 @@ def execute(self, request: HTTPRequest) -> HTTPResponse: 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/methods/base.py b/botx/clients/methods/base.py index 7d1b699b..74b35fef 100644 --- a/botx/clients/methods/base.py +++ b/botx/clients/methods/base.py @@ -10,7 +10,12 @@ from pydantic import BaseConfig, BaseModel, Extra from pydantic.generics import GenericModel -from botx.clients.types.http import HTTPRequest, HTTPResponse, PrimitiveDataType +from botx.clients.types.http import ( + ExpectedType, + HTTPRequest, + HTTPResponse, + PrimitiveDataType, +) from botx.models.enums import Statuses try: @@ -84,11 +89,16 @@ def __result_extractor__( """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): +class BaseBotXMethod(AbstractBotXMethod[ResponseT], ABC): # noqa: WPS214 """Base logic that is responsible for configuration and shortcuts for fields.""" #: host where request should be sent. @@ -138,6 +148,11 @@ def result_extractor( """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.""" diff --git a/botx/clients/methods/errors/files/__init__.py b/botx/clients/methods/errors/files/__init__.py new file mode 100644 index 00000000..86529f8e --- /dev/null +++ b/botx/clients/methods/errors/files/__init__.py @@ -0,0 +1 @@ +"""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 new file mode 100644 index 00000000..1858b48f --- /dev/null +++ b/botx/clients/methods/errors/files/chat_not_found.py @@ -0,0 +1,53 @@ +"""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 new file mode 100644 index 00000000..8f3216db --- /dev/null +++ b/botx/clients/methods/errors/files/file_deleted.py @@ -0,0 +1,51 @@ +"""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 new file mode 100644 index 00000000..f1239583 --- /dev/null +++ b/botx/clients/methods/errors/files/metadata_not_found.py @@ -0,0 +1,62 @@ +"""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 new file mode 100644 index 00000000..e78c478e --- /dev/null +++ b/botx/clients/methods/errors/files/without_preview.py @@ -0,0 +1,53 @@ +"""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/v3/files/__init__.py b/botx/clients/methods/v3/files/__init__.py new file mode 100644 index 00000000..2ad05f3b --- /dev/null +++ b/botx/clients/methods/v3/files/__init__.py @@ -0,0 +1 @@ +"""Definition for methods for files resource.""" diff --git a/botx/clients/methods/v3/files/download.py b/botx/clients/methods/v3/files/download.py new file mode 100644 index 00000000..a9a8a0c0 --- /dev/null +++ b/botx/clients/methods/v3/files/download.py @@ -0,0 +1,57 @@ +"""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 new file mode 100644 index 00000000..7f5e7a41 --- /dev/null +++ b/botx/clients/methods/v3/files/upload.py @@ -0,0 +1,60 @@ +"""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/types/http.py b/botx/clients/types/http.py index 6a6d9098..aae73d5d 100644 --- a/botx/clients/types/http.py +++ b/botx/clients/types/http.py @@ -1,11 +1,20 @@ """Custom wrapper for HTTP request for BotX API.""" -from typing import Any, Dict, Optional, Union +from enum import Enum +from io import BytesIO +from typing import Any, Dict, List, Optional, Tuple, Union -from pydantic import BaseModel +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.""" @@ -24,15 +33,37 @@ class HTTPRequest(BaseModel): #: 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: Dict[str, Any] + json_body: Optional[Dict[str, Any]] = None + + #: response raw data. + raw_data: Optional[bytes] = None @property def is_redirect(self) -> bool: @@ -51,3 +82,12 @@ def is_error(self) -> bool: 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/upload_file.py b/botx/clients/types/upload_file.py new file mode 100644 index 00000000..1847c422 --- /dev/null +++ b/botx/clients/types/upload_file.py @@ -0,0 +1,14 @@ +"""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/models/files.py b/botx/models/files.py index 533440b1..1d1694fb 100644 --- a/botx/models/files.py +++ b/botx/models/files.py @@ -6,11 +6,13 @@ 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 pydantic import validator from botx.models.base import BotXBaseModel +from botx.models.enums import AttachmentsTypes EXTENSIONS_TO_MIMETYPES = MappingProxyType( { @@ -239,6 +241,25 @@ def has_supported_extension(cls, filename: str) -> bool: file_extension = Path(filename).suffix.lower() return file_extension in BOTX_API_ACCEPTED_EXTENSIONS + @classmethod + def get_ext_by_mimetype(cls, mimetype: str) -> str: + """Get extension by mimetype. + + Arguments: + mimetype: mimetype of file. + + Returns: + file extension. + + Raises: + ValueError: when mimetype is unsupported. + """ + for ext, m_type in EXTENSIONS_TO_MIMETYPES.items(): + if m_type == mimetype: + return ext + + raise ValueError("`{0}` is unsupported mimetype.".format(mimetype)) + @classmethod def _to_rfc2397(cls, media_type: str, encoded_data: str) -> str: """Apply RFC 2397 format to encoded file contents. @@ -264,3 +285,49 @@ def _get_mimetype(cls, filename: str) -> str: """ file_extension = Path(filename).suffix.lower() return EXTENSIONS_TO_MIMETYPES[file_extension] + + +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/testing/botx_mock/asgi/application.py b/botx/testing/botx_mock/asgi/application.py index 7d332131..9b376470 100644 --- a/botx/testing/botx_mock/asgi/application.py +++ b/botx/testing/botx_mock/asgi/application.py @@ -13,6 +13,7 @@ chats, command, events, + files, notification, notifications, users, @@ -47,6 +48,9 @@ users.get_by_login, # notifications notifications.post_internal_bot_notification, + # files + files.upload_file, + files.download_file, ) diff --git a/botx/testing/botx_mock/asgi/responses.py b/botx/testing/botx_mock/asgi/responses.py index e9f23f56..11a84d35 100644 --- a/botx/testing/botx_mock/asgi/responses.py +++ b/botx/testing/botx_mock/asgi/responses.py @@ -1,7 +1,7 @@ """Common responses for mocks.""" import uuid -from typing import Any, Union +from typing import Any, Optional, Union from pydantic import BaseModel from starlette.responses import Response @@ -21,9 +21,10 @@ class PydanticResponse(Response): """Custom response to encode pydantic model from route.""" - def __init__( + def __init__( # noqa: WPS211 self, - model: BaseModel, + model: Optional[BaseModel], + raw_data: Optional[bytes] = None, status_code: int = 200, media_type: str = "application/json", **kwargs: Any, @@ -32,12 +33,13 @@ def __init__( 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__( - model.json(by_alias=True), + raw_data or model.json(by_alias=True), # type: ignore status_code, media_type=media_type, **kwargs, diff --git a/botx/testing/botx_mock/asgi/routes/files.py b/botx/testing/botx_mock/asgi/routes/files.py new file mode 100644 index 00000000..37c4f56e --- /dev/null +++ b/botx/testing/botx_mock/asgi/routes/files.py @@ -0,0 +1,60 @@ +"""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/entities.py b/botx/testing/botx_mock/entities.py index 050b6580..2ea60768 100644 --- a/botx/testing/botx_mock/entities.py +++ b/botx/testing/botx_mock/entities.py @@ -2,6 +2,8 @@ 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 @@ -31,3 +33,30 @@ def create_test_user( 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/application.py b/botx/testing/botx_mock/wsgi/application.py index f9b034d0..edd3c4e3 100644 --- a/botx/testing/botx_mock/wsgi/application.py +++ b/botx/testing/botx_mock/wsgi/application.py @@ -11,6 +11,7 @@ chats, command, events, + files, notification, notifications, users, @@ -44,6 +45,9 @@ users.get_by_login, # notifications notifications.post_internal_bot_notification, + # files + files.upload_file, + files.download_file, ) diff --git a/botx/testing/botx_mock/wsgi/responses.py b/botx/testing/botx_mock/wsgi/responses.py index a771265a..30688a58 100644 --- a/botx/testing/botx_mock/wsgi/responses.py +++ b/botx/testing/botx_mock/wsgi/responses.py @@ -23,7 +23,8 @@ class PydanticResponse(Response): def __init__( self, - model: BaseModel, + model: Optional[BaseModel], + raw_data: Optional[str] = None, status_code: str = HTTP_200, headers: Optional[Dict[Any, Any]] = None, ) -> None: @@ -31,15 +32,16 @@ def __init__( 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 {} - headers["Content-Type"] = "application/json" + headers = headers or {"Content-Type": "application/json"} + super().__init__( status_code, headers, - model.json(by_alias=True), + raw_data or model.json(by_alias=True), # type: ignore ) diff --git a/botx/testing/botx_mock/wsgi/routes/files.py b/botx/testing/botx_mock/wsgi/routes/files.py new file mode 100644 index 00000000..12be8c3c --- /dev/null +++ b/botx/testing/botx_mock/wsgi/routes/files.py @@ -0,0 +1,70 @@ +"""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/typing.py b/botx/testing/typing.py index f4d1c7e3..3062b688 100644 --- a/botx/testing/typing.py +++ b/botx/testing/typing.py @@ -15,6 +15,7 @@ ) 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.users import by_email, by_huid, by_login @@ -51,4 +52,7 @@ by_huid.ByHUID, by_email.ByEmail, by_login.ByLogin, + # files + upload.UploadFile, + download.DownloadFile, ] diff --git a/docs/changelog.md b/docs/changelog.md index df5f23c8..7e564c1d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,3 +1,20 @@ +## 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 diff --git a/poetry.lock b/poetry.lock index a7e2aad9..aa68eb7d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -437,7 +437,7 @@ flake8-plugin-utils = ">=1.3.2,<2.0.0" [[package]] name = "flake8-quotes" -version = "3.2.0" +version = "3.3.0" description = "Flake8 lint for quotes." category = "dev" optional = false @@ -605,7 +605,7 @@ i18n = ["Babel (>=2.7)"] name = "livereload" version = "2.6.3" description = "Python LiveReload is an awesome tool for web developers" -category = "main" +category = "dev" optional = false python-versions = "*" @@ -1026,6 +1026,17 @@ python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" [package.dependencies] 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" @@ -1055,7 +1066,7 @@ pyyaml = "*" [[package]] name = "regex" -version = "2021.8.3" +version = "2021.8.21" description = "Alternative regular expression module, to replace re." category = "dev" optional = false @@ -1139,7 +1150,7 @@ full = ["aiofiles", "graphene", "itsdangerous", "jinja2", "python-multipart", "p [[package]] name = "stevedore" -version = "3.3.0" +version = "3.4.0" description = "Manage dynamic plugins for Python applications" category = "dev" optional = false @@ -1159,7 +1170,7 @@ python-versions = "*" [[package]] name = "testfixtures" -version = "6.18.0" +version = "6.18.1" description = "A collection of helpers and mock objects for unit tests and doc tests." category = "dev" optional = false @@ -1190,7 +1201,7 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" name = "tornado" version = "6.1" description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed." -category = "main" +category = "dev" optional = false python-versions = ">= 3.5" @@ -1244,7 +1255,7 @@ testing = ["coverage (>=4)", "coverage-enable-subprocess (>=1)", "flaky (>=3)", [[package]] name = "watchdog" -version = "2.1.3" +version = "2.1.5" description = "Filesystem events monitoring" category = "dev" optional = false @@ -1315,12 +1326,12 @@ 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", "starlette"] +tests = ["aiofiles", "molten", "starlette", "python-multipart"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "bea4ac014b35b96514cb94413d5d448b45a9c608693da91f9fe7395f5fc37831" +content-hash = "58e3a2d90e514779d7175d8790f5444e55ca5e14724a6ffedf73ed1fb7e6bd4c" [metadata.files] add-trailing-comma = [ @@ -1402,6 +1413,9 @@ coverage = [ {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"}, @@ -1520,7 +1534,7 @@ flake8-pytest-style = [ {file = "flake8_pytest_style-1.5.0-py3-none-any.whl", hash = "sha256:ec287a7dc4fe95082af5e408c8b2f8f4b6bcb366d5a17ff6c34112eb03446580"}, ] flake8-quotes = [ - {file = "flake8-quotes-3.2.0.tar.gz", hash = "sha256:3f1116e985ef437c130431ac92f9b3155f8f652fda7405ac22ffdfd7a9d1055e"}, + {file = "flake8-quotes-3.3.0.tar.gz", hash = "sha256:f1dd87830ed77ff2ce47fc0ee0fd87ae20e8f045355354ffbf4dcaa18d528217"}, ] flake8-rst-docstrings = [ {file = "flake8-rst-docstrings-0.2.3.tar.gz", hash = "sha256:3045794e1c8467fba33aaea5c246b8369efc9c44ef8b0b20199bb6df7a4bd47b"}, @@ -1777,6 +1791,9 @@ 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"}, @@ -1788,18 +1805,26 @@ pyyaml = [ {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"}, @@ -1809,39 +1834,47 @@ pyyaml-env-tag = [ {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, ] regex = [ - {file = "regex-2021.8.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:8764a78c5464ac6bde91a8c87dd718c27c1cabb7ed2b4beaf36d3e8e390567f9"}, - {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4551728b767f35f86b8e5ec19a363df87450c7376d7419c3cac5b9ceb4bce576"}, - {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:577737ec3d4c195c4aef01b757905779a9e9aee608fa1cf0aec16b5576c893d3"}, - {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c856ec9b42e5af4fe2d8e75970fcc3a2c15925cbcc6e7a9bcb44583b10b95e80"}, - {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3835de96524a7b6869a6c710b26c90e94558c31006e96ca3cf6af6751b27dca1"}, - {file = "regex-2021.8.3-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:cea56288eeda8b7511d507bbe7790d89ae7049daa5f51ae31a35ae3c05408531"}, - {file = "regex-2021.8.3-cp36-cp36m-win32.whl", hash = "sha256:a4eddbe2a715b2dd3849afbdeacf1cc283160b24e09baf64fa5675f51940419d"}, - {file = "regex-2021.8.3-cp36-cp36m-win_amd64.whl", hash = "sha256:57fece29f7cc55d882fe282d9de52f2f522bb85290555b49394102f3621751ee"}, - {file = "regex-2021.8.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a5c6dbe09aff091adfa8c7cfc1a0e83fdb8021ddb2c183512775a14f1435fe16"}, - {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff4a8ad9638b7ca52313d8732f37ecd5fd3c8e3aff10a8ccb93176fd5b3812f6"}, - {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b63e3571b24a7959017573b6455e05b675050bbbea69408f35f3cb984ec54363"}, - {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:fbc20975eee093efa2071de80df7f972b7b35e560b213aafabcec7c0bd00bd8c"}, - {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:14caacd1853e40103f59571f169704367e79fb78fac3d6d09ac84d9197cadd16"}, - {file = "regex-2021.8.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bb350eb1060591d8e89d6bac4713d41006cd4d479f5e11db334a48ff8999512f"}, - {file = "regex-2021.8.3-cp37-cp37m-win32.whl", hash = "sha256:18fdc51458abc0a974822333bd3a932d4e06ba2a3243e9a1da305668bd62ec6d"}, - {file = "regex-2021.8.3-cp37-cp37m-win_amd64.whl", hash = "sha256:026beb631097a4a3def7299aa5825e05e057de3c6d72b139c37813bfa351274b"}, - {file = "regex-2021.8.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:16d9eaa8c7e91537516c20da37db975f09ac2e7772a0694b245076c6d68f85da"}, - {file = "regex-2021.8.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3905c86cc4ab6d71635d6419a6f8d972cab7c634539bba6053c47354fd04452c"}, - {file = "regex-2021.8.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937b20955806381e08e54bd9d71f83276d1f883264808521b70b33d98e4dec5d"}, - {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:28e8af338240b6f39713a34e337c3813047896ace09d51593d6907c66c0708ba"}, - {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c09d88a07483231119f5017904db8f60ad67906efac3f1baa31b9b7f7cca281"}, - {file = "regex-2021.8.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:85f568892422a0e96235eb8ea6c5a41c8ccbf55576a2260c0160800dbd7c4f20"}, - {file = "regex-2021.8.3-cp38-cp38-win32.whl", hash = "sha256:bf6d987edd4a44dd2fa2723fca2790f9442ae4de2c8438e53fcb1befdf5d823a"}, - {file = "regex-2021.8.3-cp38-cp38-win_amd64.whl", hash = "sha256:8fe58d9f6e3d1abf690174fd75800fda9bdc23d2a287e77758dc0e8567e38ce6"}, - {file = "regex-2021.8.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7976d410e42be9ae7458c1816a416218364e06e162b82e42f7060737e711d9ce"}, - {file = "regex-2021.8.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9569da9e78f0947b249370cb8fadf1015a193c359e7e442ac9ecc585d937f08d"}, - {file = "regex-2021.8.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459bbe342c5b2dec5c5223e7c363f291558bc27982ef39ffd6569e8c082bdc83"}, - {file = "regex-2021.8.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4f421e3cdd3a273bace013751c345f4ebeef08f05e8c10757533ada360b51a39"}, - {file = "regex-2021.8.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea212df6e5d3f60341aef46401d32fcfded85593af1d82b8b4a7a68cd67fdd6b"}, - {file = "regex-2021.8.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a3b73390511edd2db2d34ff09aa0b2c08be974c71b4c0505b4a048d5dc128c2b"}, - {file = "regex-2021.8.3-cp39-cp39-win32.whl", hash = "sha256:f35567470ee6dbfb946f069ed5f5615b40edcbb5f1e6e1d3d2b114468d505fc6"}, - {file = "regex-2021.8.3-cp39-cp39-win_amd64.whl", hash = "sha256:bfa6a679410b394600eafd16336b2ce8de43e9b13f7fb9247d84ef5ad2b45e91"}, - {file = "regex-2021.8.3.tar.gz", hash = "sha256:8935937dad2c9b369c3d932b0edbc52a62647c2afb2fafc0c280f14a8bf56a6a"}, + {file = "regex-2021.8.21-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b0c211c55d4aac4309c3209833c803fada3fc21cdf7b74abedda42a0c9dc3ce"}, + {file = "regex-2021.8.21-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d5209c3ba25864b1a57461526ebde31483db295fc6195fdfc4f8355e10f7376"}, + {file = "regex-2021.8.21-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c835c30f3af5c63a80917b72115e1defb83de99c73bc727bddd979a3b449e183"}, + {file = "regex-2021.8.21-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:615fb5a524cffc91ab4490b69e10ae76c1ccbfa3383ea2fad72e54a85c7d47dd"}, + {file = "regex-2021.8.21-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9966337353e436e6ba652814b0a957a517feb492a98b8f9d3b6ba76d22301dcc"}, + {file = "regex-2021.8.21-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a49f85f0a099a5755d0a2cc6fc337e3cb945ad6390ec892332c691ab0a045882"}, + {file = "regex-2021.8.21-cp310-cp310-win32.whl", hash = "sha256:f93a9d8804f4cec9da6c26c8cfae2c777028b4fdd9f49de0302e26e00bb86504"}, + {file = "regex-2021.8.21-cp310-cp310-win_amd64.whl", hash = "sha256:a795829dc522227265d72b25d6ee6f6d41eb2105c15912c230097c8f5bfdbcdc"}, + {file = "regex-2021.8.21-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:bca14dfcfd9aae06d7d8d7e105539bd77d39d06caaae57a1ce945670bae744e0"}, + {file = "regex-2021.8.21-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41acdd6d64cd56f857e271009966c2ffcbd07ec9149ca91f71088574eaa4278a"}, + {file = "regex-2021.8.21-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:96f0c79a70642dfdf7e6a018ebcbea7ea5205e27d8e019cad442d2acfc9af267"}, + {file = "regex-2021.8.21-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:45f97ade892ace20252e5ccecdd7515c7df5feeb42c3d2a8b8c55920c3551c30"}, + {file = "regex-2021.8.21-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f9974826aeeda32a76648fc677e3125ade379869a84aa964b683984a2dea9f1"}, + {file = "regex-2021.8.21-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ea9753d64cba6f226947c318a923dadaf1e21cd8db02f71652405263daa1f033"}, + {file = "regex-2021.8.21-cp36-cp36m-win32.whl", hash = "sha256:ef9326c64349e2d718373415814e754183057ebc092261387a2c2f732d9172b2"}, + {file = "regex-2021.8.21-cp36-cp36m-win_amd64.whl", hash = "sha256:6dbd51c3db300ce9d3171f4106da18fe49e7045232630fe3d4c6e37cb2b39ab9"}, + {file = "regex-2021.8.21-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a89ca4105f8099de349d139d1090bad387fe2b208b717b288699ca26f179acbe"}, + {file = "regex-2021.8.21-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6c2b1d78ceceb6741d703508cd0e9197b34f6bf6864dab30f940f8886e04ade"}, + {file = "regex-2021.8.21-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a34ba9e39f8269fd66ab4f7a802794ffea6d6ac500568ec05b327a862c21ce23"}, + {file = "regex-2021.8.21-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ecb6e7c45f9cd199c10ec35262b53b2247fb9a408803ed00ee5bb2b54aa626f5"}, + {file = "regex-2021.8.21-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:330836ad89ff0be756b58758878409f591d4737b6a8cef26a162e2a4961c3321"}, + {file = "regex-2021.8.21-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:71a904da8c9c02aee581f4452a5a988c3003207cb8033db426f29e5b2c0b7aea"}, + {file = "regex-2021.8.21-cp37-cp37m-win32.whl", hash = "sha256:b511c6009d50d5c0dd0bab85ed25bc8ad6b6f5611de3a63a59786207e82824bb"}, + {file = "regex-2021.8.21-cp37-cp37m-win_amd64.whl", hash = "sha256:93f9f720081d97acee38a411e861d4ce84cbc8ea5319bc1f8e38c972c47af49f"}, + {file = "regex-2021.8.21-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3a195e26df1fbb40ebee75865f9b64ba692a5824ecb91c078cc665b01f7a9a36"}, + {file = "regex-2021.8.21-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06ba444bbf7ede3890a912bd4904bb65bf0da8f0d8808b90545481362c978642"}, + {file = "regex-2021.8.21-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b8d551f1bd60b3e1c59ff55b9e8d74607a5308f66e2916948cafd13480b44a3"}, + {file = "regex-2021.8.21-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ebbceefbffae118ab954d3cd6bf718f5790db66152f95202ebc231d58ad4e2c2"}, + {file = "regex-2021.8.21-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccd721f1d4fc42b541b633d6e339018a08dd0290dc67269df79552843a06ca92"}, + {file = "regex-2021.8.21-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ae87ab669431f611c56e581679db33b9a467f87d7bf197ac384e71e4956b4456"}, + {file = "regex-2021.8.21-cp38-cp38-win32.whl", hash = "sha256:38600fd58c2996829480de7d034fb2d3a0307110e44dae80b6b4f9b3d2eea529"}, + {file = "regex-2021.8.21-cp38-cp38-win_amd64.whl", hash = "sha256:61e734c2bcb3742c3f454dfa930ea60ea08f56fd1a0eb52d8cb189a2f6be9586"}, + {file = "regex-2021.8.21-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b091dcfee169ad8de21b61eb2c3a75f9f0f859f851f64fdaf9320759a3244239"}, + {file = "regex-2021.8.21-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:640ccca4d0a6fcc6590f005ecd7b16c3d8f5d52174e4854f96b16f34c39d6cb7"}, + {file = "regex-2021.8.21-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac95101736239260189f426b1e361dc1b704513963357dc474beb0f39f5b7759"}, + {file = "regex-2021.8.21-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:b79dc2b2e313565416c1e62807c7c25c67a6ff0a0f8d83a318df464555b65948"}, + {file = "regex-2021.8.21-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8b623fc429a38a881ab2d9a56ef30e8ea20c72a891c193f5ebbddc016e083ee"}, + {file = "regex-2021.8.21-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8021dee64899f993f4b5cca323aae65aabc01a546ed44356a0965e29d7893c94"}, + {file = "regex-2021.8.21-cp39-cp39-win32.whl", hash = "sha256:d6ec4ae13760ceda023b2e5ef1f9bc0b21e4b0830458db143794a117fdbdc044"}, + {file = "regex-2021.8.21-cp39-cp39-win_amd64.whl", hash = "sha256:03840a07a402576b8e3a6261f17eb88abd653ad4e18ec46ef10c9a63f8c99ebd"}, + {file = "regex-2021.8.21.tar.gz", hash = "sha256:faf08b0341828f6a29b8f7dd94d5cf8cc7c39bfc3e67b78514c54b494b66915a"}, ] restructuredtext-lint = [ {file = "restructuredtext_lint-1.3.2.tar.gz", hash = "sha256:d3b10a1fe2ecac537e51ae6d151b223b78de9fafdd50e5eb6b08c243df173c80"}, @@ -1875,15 +1908,15 @@ starlette = [ {file = "starlette-0.13.2.tar.gz", hash = "sha256:a9bb130fa7aa736eda8a814b6ceb85ccf7a209ed53843d0d61e246b380afa10f"}, ] stevedore = [ - {file = "stevedore-3.3.0-py3-none-any.whl", hash = "sha256:50d7b78fbaf0d04cd62411188fa7eedcb03eb7f4c4b37005615ceebe582aa82a"}, - {file = "stevedore-3.3.0.tar.gz", hash = "sha256:3a5bbd0652bf552748871eaa73a4a8dc2899786bc497a2aa1fcb4dcdb0debeee"}, + {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"}, ] testfixtures = [ - {file = "testfixtures-6.18.0-py2.py3-none-any.whl", hash = "sha256:9bddf79b2dddb36420a20c25a65c827a8e7398c6ed4e2c75c2697857cb006be9"}, - {file = "testfixtures-6.18.0.tar.gz", hash = "sha256:d4bd1c4f90eac90a73e1bdc59c31d03943f218d687f3c5a09e48478841a8af5f"}, + {file = "testfixtures-6.18.1-py2.py3-none-any.whl", hash = "sha256:486be7b01eb71326029811878a3317b7e7994324621c0ec633c8e24499d8d5b3"}, + {file = "testfixtures-6.18.1.tar.gz", hash = "sha256:0a6422737f6d89b45cdef1e2df5576f52ad0f507956002ce1020daa9f44211d6"}, ] tokenize-rt = [ {file = "tokenize_rt-4.1.0-py2.py3-none-any.whl", hash = "sha256:b37251fa28c21e8cce2e42f7769a35fba2dd2ecafb297208f9a9a8add3ca7793"}, @@ -1983,27 +2016,29 @@ virtualenv = [ {file = "virtualenv-20.7.2.tar.gz", hash = "sha256:9ef4e8ee4710826e98ff3075c9a4739e2cb1040de6a2a8d35db0055840dc96a0"}, ] watchdog = [ - {file = "watchdog-2.1.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9628f3f85375a17614a2ab5eac7665f7f7be8b6b0a2a228e6f6a2e91dd4bfe26"}, - {file = "watchdog-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:acc4e2d5be6f140f02ee8590e51c002829e2c33ee199036fcd61311d558d89f4"}, - {file = "watchdog-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:85b851237cf3533fabbc034ffcd84d0fa52014b3121454e5f8b86974b531560c"}, - {file = "watchdog-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a12539ecf2478a94e4ba4d13476bb2c7a2e0a2080af2bb37df84d88b1b01358a"}, - {file = "watchdog-2.1.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6fe9c8533e955c6589cfea6f3f0a1a95fb16867a211125236c82e1815932b5d7"}, - {file = "watchdog-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:d9456f0433845e7153b102fffeb767bde2406b76042f2216838af3b21707894e"}, - {file = "watchdog-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fd8c595d5a93abd441ee7c5bb3ff0d7170e79031520d113d6f401d0cf49d7c8f"}, - {file = "watchdog-2.1.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0bcfe904c7d404eb6905f7106c54873503b442e8e918cc226e1828f498bdc0ca"}, - {file = "watchdog-2.1.3-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bf84bd94cbaad8f6b9cbaeef43080920f4cb0e61ad90af7106b3de402f5fe127"}, - {file = "watchdog-2.1.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b8ddb2c9f92e0c686ea77341dcb58216fa5ff7d5f992c7278ee8a392a06e86bb"}, - {file = "watchdog-2.1.3-py3-none-manylinux2014_aarch64.whl", hash = "sha256:8805a5f468862daf1e4f4447b0ccf3acaff626eaa57fbb46d7960d1cf09f2e6d"}, - {file = "watchdog-2.1.3-py3-none-manylinux2014_armv7l.whl", hash = "sha256:3e305ea2757f81d8ebd8559d1a944ed83e3ab1bdf68bcf16ec851b97c08dc035"}, - {file = "watchdog-2.1.3-py3-none-manylinux2014_i686.whl", hash = "sha256:431a3ea70b20962e6dee65f0eeecd768cd3085ea613ccb9b53c8969de9f6ebd2"}, - {file = "watchdog-2.1.3-py3-none-manylinux2014_ppc64.whl", hash = "sha256:e4929ac2aaa2e4f1a30a36751160be391911da463a8799460340901517298b13"}, - {file = "watchdog-2.1.3-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:201cadf0b8c11922f54ec97482f95b2aafca429c4c3a4bb869a14f3c20c32686"}, - {file = "watchdog-2.1.3-py3-none-manylinux2014_s390x.whl", hash = "sha256:3a7d242a7963174684206093846537220ee37ba9986b824a326a8bb4ef329a33"}, - {file = "watchdog-2.1.3-py3-none-manylinux2014_x86_64.whl", hash = "sha256:54e057727dd18bd01a3060dbf5104eb5a495ca26316487e0f32a394fd5fe725a"}, - {file = "watchdog-2.1.3-py3-none-win32.whl", hash = "sha256:b5fc5c127bad6983eecf1ad117ab3418949f18af9c8758bd10158be3647298a9"}, - {file = "watchdog-2.1.3-py3-none-win_amd64.whl", hash = "sha256:44acad6f642996a2b50bb9ce4fb3730dde08f23e79e20cd3d8e2a2076b730381"}, - {file = "watchdog-2.1.3-py3-none-win_ia64.whl", hash = "sha256:0bcdf7b99b56a3ae069866c33d247c9994ffde91b620eaf0306b27e099bd1ae0"}, - {file = "watchdog-2.1.3.tar.gz", hash = "sha256:e5236a8e8602ab6db4b873664c2d356c365ab3cac96fbdec4970ad616415dd45"}, + {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"}, ] wcwidth = [ {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, diff --git a/pyproject.toml b/pyproject.toml index d55677cd..94d93b66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "botx" -version = "0.22.1" +version = "0.23.0" description = "A little python framework for building bots for eXpress" license = "MIT" authors = [ @@ -25,7 +25,7 @@ typing-extensions = { version = "^3.7.4", python = "<3.8" } aiofiles = { version = "^0.6.0", optional = true } molten = { version = "^1.0.1", optional = true } starlette = { version = "^0.13.2", optional = true } -livereload = "^2.6.3" +python-multipart = { version = "^0.0.5", optional = true } [tool.poetry.dev-dependencies] # tasks @@ -51,9 +51,10 @@ mkdocs-material = "^5.2.1" markdown-include = "^0.5.1" mkdocstrings = "^0.12.2" fastapi = "^0.55.1" +livereload = "^2.6.3" [tool.poetry.extras] -tests = ["aiofiles", "molten", "starlette"] +tests = ["aiofiles", "molten", "starlette", "python-multipart"] [tool.black] target_version = ['py37', 'py38'] diff --git a/tests/test_bots/test_mixins/test_requests/test_files.py b/tests/test_bots/test_mixins/test_requests/test_files.py new file mode 100644 index 00000000..09c0cd39 --- /dev/null +++ b/tests/test_bots/test_mixins/test_requests/test_files.py @@ -0,0 +1,27 @@ +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) 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 new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..3f691fb0 --- /dev/null +++ b/tests/test_clients/test_methods/test_errors/test_files/test_chat_not_found.py @@ -0,0 +1,44 @@ +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 new file mode 100644 index 00000000..4cdeeb10 --- /dev/null +++ b/tests/test_clients/test_methods/test_errors/test_files/test_file_deleted.py @@ -0,0 +1,41 @@ +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 new file mode 100644 index 00000000..6899c51b --- /dev/null +++ b/tests/test_clients/test_methods/test_errors/test_files/test_metadata_not_found.py @@ -0,0 +1,45 @@ +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 new file mode 100644 index 00000000..5d583c12 --- /dev/null +++ b/tests/test_clients/test_methods/test_errors/test_files/test_without_preview.py @@ -0,0 +1,45 @@ +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_v3/test_files/__init__.py b/tests/test_clients/test_methods/test_v3/test_files/__init__.py new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..2eedce1a --- /dev/null +++ b/tests/test_clients/test_methods/test_v3/test_files/test_download.py @@ -0,0 +1,32 @@ +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 new file mode 100644 index 00000000..d1c1da45 --- /dev/null +++ b/tests/test_clients/test_methods/test_v3/test_files/test_upload.py @@ -0,0 +1,35 @@ +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_types/__init__.py b/tests/test_clients/test_types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_clients/test_types/test_http.py b/tests/test_clients/test_types/test_http.py new file mode 100644 index 00000000..d3f1fd69 --- /dev/null +++ b/tests/test_clients/test_types/test_http.py @@ -0,0 +1,14 @@ +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_models/test_files/test_attributes.py b/tests/test_models/test_files/test_attributes.py index 3ea8341f..5d27f7e6 100644 --- a/tests/test_models/test_files/test_attributes.py +++ b/tests/test_models/test_files/test_attributes.py @@ -34,3 +34,8 @@ def test_decline_has_supported_extension(): bad_filename = "test.bad" assert not File.has_supported_extension(bad_filename) + + +def test_get_ext_by_unsupported_mimetype(): + with pytest.raises(ValueError): + File.get_ext_by_mimetype("application/javascript")