Skip to content

Commit

Permalink
Feature/files methods (#160)
Browse files Browse the repository at this point in the history
* feat: add download and upload files methods

* test: add and refactor tests

* docs: add release changes

* chore: fix pyproject.toml
  • Loading branch information
rrrrs09 committed Aug 25, 2021
1 parent cdf4632 commit 7e2cef0
Show file tree
Hide file tree
Showing 40 changed files with 1,250 additions and 115 deletions.
79 changes: 79 additions & 0 deletions botx/bots/mixins/requests/files.py
Original file line number Diff line number Diff line change
@@ -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,
)
7 changes: 6 additions & 1 deletion botx/bots/mixins/requests/mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
chats,
command,
events,
files,
internal_bot_notification,
notification,
users,
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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,
Expand Down
47 changes: 33 additions & 14 deletions botx/clients/clients/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -108,19 +108,38 @@ 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(
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,
)
27 changes: 25 additions & 2 deletions botx/clients/clients/processing.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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,
Expand Down
47 changes: 33 additions & 14 deletions botx/clients/clients/sync_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -108,19 +108,38 @@ 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(
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,
)
Expand Down
19 changes: 17 additions & 2 deletions botx/clients/methods/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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."""
Expand Down
1 change: 1 addition & 0 deletions botx/clients/methods/errors/files/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Definition for built-in error handlers for responses from BotX API."""
Loading

1 comment on commit 7e2cef0

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉 Published on https://pybotx.netlify.app as production
🚀 Deployed on https://6126317e6745b9c0d0fac6a4--pybotx.netlify.app

Please sign in to comment.