Implement connectors mechanism.

This commit is contained in:
Alex Root Junior 2018-07-08 18:40:54 +03:00
parent afa7ef9ebe
commit cd4fee5eaa
3 changed files with 144 additions and 107 deletions

View file

@ -1,8 +1,14 @@
import abc
import asyncio
import logging import logging
import os import os
import ssl
from asyncio import AbstractEventLoop
from http import HTTPStatus from http import HTTPStatus
from typing import Optional, Tuple
import aiohttp import aiohttp
import certifi
from .. import types from .. import types
from ..utils import exceptions from ..utils import exceptions
@ -34,7 +40,29 @@ def check_token(token: str) -> bool:
return True return True
async def _check_result(method_name, response): class AbstractConnector(abc.ABC):
"""
Abstract connector class
"""
def __init__(self, loop: Optional[AbstractEventLoop] = None, *args, **kwargs):
if loop is None:
loop = asyncio.get_event_loop()
self.loop = loop
self._args = args
self._kwargs = kwargs
async def make_request(self, token, method, data=None, files=None, **kwargs):
log.debug(f"Make request: '{method}' with data: {data} and files {files}")
url = Methods.api_url(token=token, method=method)
content_type, status, data = await self.request(url, data, files, **kwargs)
return await self.check_result(method, content_type, status, data)
@abc.abstractmethod
async def request(self, url, data=None, files=None, **kwargs) -> Tuple[str, int, str]:
pass
async def check_result(self, method_name: str, content_type: str, status_code: int, body: str):
""" """
Checks whether `result` is a valid API response. Checks whether `result` is a valid API response.
A result is considered invalid if: A result is considered invalid if:
@ -44,48 +72,106 @@ async def _check_result(method_name, response):
:raises ApiException: if one of the above listed cases is applicable :raises ApiException: if one of the above listed cases is applicable
:param method_name: The name of the method called :param method_name: The name of the method called
:param response: The returned response of the method request
:return: The result parsed to a JSON dictionary. :return: The result parsed to a JSON dictionary.
""" """
body = await response.text() log.debug(f"Response for {method_name}: [{status_code}] {body}")
log.debug(f"Response for {method_name}: [{response.status}] {body}")
if response.content_type != 'application/json': if content_type != 'application/json':
raise exceptions.NetworkError(f"Invalid response with content type {response.content_type}: \"{body}\"") raise exceptions.NetworkError(f"Invalid response with content type {content_type}: \"{body}\"")
try: try:
result_json = await response.json(loads=json.loads) result_json = json.loads(body)
except ValueError: except ValueError:
result_json = {} result_json = {}
description = result_json.get('description') or body description = result_json.get('description') or body
parameters = types.ResponseParameters(**result_json.get('parameters', {}) or {}) parameters = types.ResponseParameters(**result_json.get('parameters', {}) or {})
if HTTPStatus.OK <= response.status <= HTTPStatus.IM_USED: if HTTPStatus.OK <= status_code <= HTTPStatus.IM_USED:
return result_json.get('result') return result_json.get('result')
elif parameters.retry_after: elif parameters.retry_after:
raise exceptions.RetryAfter(parameters.retry_after) raise exceptions.RetryAfter(parameters.retry_after)
elif parameters.migrate_to_chat_id: elif parameters.migrate_to_chat_id:
raise exceptions.MigrateToChat(parameters.migrate_to_chat_id) raise exceptions.MigrateToChat(parameters.migrate_to_chat_id)
elif response.status == HTTPStatus.BAD_REQUEST: elif status_code == HTTPStatus.BAD_REQUEST:
exceptions.BadRequest.detect(description) exceptions.BadRequest.detect(description)
elif response.status == HTTPStatus.NOT_FOUND: elif status_code == HTTPStatus.NOT_FOUND:
exceptions.NotFound.detect(description) exceptions.NotFound.detect(description)
elif response.status == HTTPStatus.CONFLICT: elif status_code == HTTPStatus.CONFLICT:
exceptions.ConflictError.detect(description) exceptions.ConflictError.detect(description)
elif response.status in [HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN]: elif status_code in [HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN]:
exceptions.Unauthorized.detect(description) exceptions.Unauthorized.detect(description)
elif response.status == HTTPStatus.REQUEST_ENTITY_TOO_LARGE: elif status_code == HTTPStatus.REQUEST_ENTITY_TOO_LARGE:
raise exceptions.NetworkError('File too large for uploading. ' raise exceptions.NetworkError('File too large for uploading. '
'Check telegram api limits https://core.telegram.org/bots/api#senddocument') 'Check telegram api limits https://core.telegram.org/bots/api#senddocument')
elif response.status >= HTTPStatus.INTERNAL_SERVER_ERROR: elif status_code >= HTTPStatus.INTERNAL_SERVER_ERROR:
if 'restart' in description: if 'restart' in description:
raise exceptions.RestartingTelegram() raise exceptions.RestartingTelegram()
raise exceptions.TelegramAPIError(description) raise exceptions.TelegramAPIError(description)
raise exceptions.TelegramAPIError(f"{description} [{response.status}]") raise exceptions.TelegramAPIError(f"{description} [{status_code}]")
@abc.abstractmethod
async def close(self):
pass
def _guess_filename(obj): class AiohttpConnector(AbstractConnector):
def __init__(self, loop: Optional[AbstractEventLoop] = None,
proxy: Optional[str] = None, proxy_auth: Optional[aiohttp.BasicAuth] = None,
connections_limit: Optional[int] = None, *args, **kwargs):
super(AiohttpConnector, self).__init__(loop, *args, **kwargs)
self.proxy = proxy
self.proxy_auth = proxy_auth
# aiohttp main session
ssl_context = ssl.create_default_context(cafile=certifi.where())
if isinstance(proxy, str) and proxy.startswith('socks5://'):
from aiosocksy.connector import ProxyClientRequest, ProxyConnector
connector = ProxyConnector(limit=connections_limit, ssl_context=ssl_context,
loop=self.loop)
request_class = ProxyClientRequest
else:
connector = aiohttp.TCPConnector(limit=connections_limit, ssl_context=ssl_context,
loop=self.loop)
request_class = aiohttp.ClientRequest
self.session = aiohttp.ClientSession(connector=connector, request_class=request_class,
loop=self.loop, json_serialize=json.dumps)
async def request(self, url, data=None, files=None, **kwargs):
"""
Make request to API
That make request with Content-Type:
application/x-www-form-urlencoded - For simple request
and multipart/form-data - for files uploading
https://core.telegram.org/bots/api#making-requests
:param url: requested URL
:type url: :obj:`str`
:param data: request payload
:type data: :obj:`dict`
:param files: files
:type files: :obj:`dict`
:return: result
:rtype :obj:`bool` or :obj:`dict`
"""
req = compose_data(data, files)
try:
async with self.session.post(url, data=req, **kwargs) as response:
return response.content_type, response.status, await response.text()
except aiohttp.ClientError as e:
raise exceptions.NetworkError(f"aiohttp client throws an error: {e.__class__.__name__}: {e}")
async def close(self):
if self.session and not self.session.closed:
await self.session.close()
def guess_filename(obj):
""" """
Get file name from object Get file name from object
@ -97,7 +183,7 @@ def _guess_filename(obj):
return os.path.basename(name) return os.path.basename(name)
def _compose_data(params=None, files=None): def compose_data(params=None, files=None):
""" """
Prepare request data Prepare request data
@ -121,47 +207,13 @@ def _compose_data(params=None, files=None):
elif isinstance(f, types.InputFile): elif isinstance(f, types.InputFile):
filename, fileobj = f.filename, f.file filename, fileobj = f.filename, f.file
else: else:
filename, fileobj = _guess_filename(f) or key, f filename, fileobj = guess_filename(f) or key, f
data.add_field(key, fileobj, filename=filename) data.add_field(key, fileobj, filename=filename)
return data return data
async def request(session, token, method, data=None, files=None, **kwargs) -> bool or dict:
"""
Make request to API
That make request with Content-Type:
application/x-www-form-urlencoded - For simple request
and multipart/form-data - for files uploading
https://core.telegram.org/bots/api#making-requests
:param session: HTTP Client session
:type session: :obj:`aiohttp.ClientSession`
:param token: BOT token
:type token: :obj:`str`
:param method: API method
:type method: :obj:`str`
:param data: request payload
:type data: :obj:`dict`
:param files: files
:type files: :obj:`dict`
:return: result
:rtype :obj:`bool` or :obj:`dict`
"""
log.debug("Make request: '{0}' with data: {1} and files {2}".format(
method, data or {}, files or {}))
data = _compose_data(data, files)
url = Methods.api_url(token=token, method=method)
try:
async with session.post(url, data=data, **kwargs) as response:
return await _check_result(method, response)
except aiohttp.ClientError as e:
raise exceptions.NetworkError(f"aiohttp client throws an error: {e.__class__.__name__}: {e}")
class Methods(Helper): class Methods(Helper):
""" """
Helper for Telegram API Methods listed on https://core.telegram.org/bots/api Helper for Telegram API Methods listed on https://core.telegram.org/bots/api

View file

@ -19,6 +19,7 @@ class BaseBot:
def __init__(self, token: base.String, def __init__(self, token: base.String,
loop: Optional[Union[asyncio.BaseEventLoop, asyncio.AbstractEventLoop]] = None, loop: Optional[Union[asyncio.BaseEventLoop, asyncio.AbstractEventLoop]] = None,
connector: Optional[api.AbstractConnector] = None,
connections_limit: Optional[base.Integer] = None, connections_limit: Optional[base.Integer] = None,
proxy: Optional[base.String] = None, proxy_auth: Optional[aiohttp.BasicAuth] = None, proxy: Optional[base.String] = None, proxy_auth: Optional[aiohttp.BasicAuth] = None,
validate_token: Optional[base.Boolean] = True, validate_token: Optional[base.Boolean] = True,
@ -47,45 +48,30 @@ class BaseBot:
api.check_token(token) api.check_token(token)
self.__token = token self.__token = token
# Proxy settings if connector and any((connections_limit, proxy, proxy_auth)):
self.proxy = proxy raise ValueError('Connector instance can\'t be passed with connection settings in one time.')
self.proxy_auth = proxy_auth elif connector:
self.connector = connector
else:
connector = api.AiohttpConnector(loop=loop, proxy=proxy, proxy_auth=proxy_auth,
connections_limit=connections_limit)
self.connector = connector
# Asyncio loop instance # Asyncio loop instance
if loop is None: if loop is None:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
self.loop = loop self.loop = loop
# aiohttp main session
ssl_context = ssl.create_default_context(cafile=certifi.where())
if isinstance(proxy, str) and proxy.startswith('socks5://'):
from aiosocksy.connector import ProxyClientRequest, ProxyConnector
connector = ProxyConnector(limit=connections_limit, ssl_context=ssl_context, loop=self.loop)
request_class = ProxyClientRequest
else:
connector = aiohttp.TCPConnector(limit=connections_limit, ssl_context=ssl_context,
loop=self.loop)
request_class = aiohttp.ClientRequest
self.session = aiohttp.ClientSession(connector=connector, request_class=request_class,
loop=self.loop, json_serialize=json.dumps)
# Data stored in bot instance # Data stored in bot instance
self._data = {} self._data = {}
self.parse_mode = parse_mode self.parse_mode = parse_mode
def __del__(self):
# asyncio.ensure_future(self.close())
pass
async def close(self): async def close(self):
""" """
Close all client sessions Close all client sessions
""" """
if self.session and not self.session.closed: await self.connector.close()
await self.session.close()
async def request(self, method: base.String, async def request(self, method: base.String,
data: Optional[Dict] = None, data: Optional[Dict] = None,
@ -105,8 +91,7 @@ class BaseBot:
:rtype: Union[List, Dict] :rtype: Union[List, Dict]
:raise: :obj:`aiogram.exceptions.TelegramApiError` :raise: :obj:`aiogram.exceptions.TelegramApiError`
""" """
return await api.request(self.session, self.__token, method, data, files, return await self.connector.make_request(self.__token, method, data, files)
proxy=self.proxy, proxy_auth=self.proxy_auth)
async def download_file(self, file_path: base.String, async def download_file(self, file_path: base.String,
destination: Optional[base.InputFile] = None, destination: Optional[base.InputFile] = None,

View file

@ -54,7 +54,7 @@ class InputFile(base.TelegramObject):
@property @property
def filename(self): def filename(self):
if self._filename is None: if self._filename is None:
self._filename = api._guess_filename(self._file) self._filename = api.guess_filename(self._file)
return self._filename return self._filename
@filename.setter @filename.setter