feat(proxy): proxy for aiohttp,base sessions (#284)

* feat(proxy): proxy for aiohttp,base sessions

Add support for proxies in aiohttp session with aiohttp_socks library,
edit BaseSession class to support proxies for other sessions in future.

* fix(annotation): missing underscore before "private" typevar

* chore: remove redundant of proxy_url schema for socks version

* test: add missing test

Add missing test, remove BaseSession.cfg and switch to
implementing class' "private" traits, add aiohttp_socks in dependency list as
optional and extra.

* feat(session):

Implement asyncio session for requests [wip]

* feat(proxy chain): Chained proxy support in aiohttp session

Add ChainProxyConnector support, !pin pydantic to "1.4", add
documentation on aiohttp connector.

* style(mypy): apply linter changes

* tests(mock): remove await for magic mock

* fix dangling dependency

* refactor(generic):

get rid of generic behaviour for base session
This commit is contained in:
Martin Winks 2020-05-02 17:12:53 +04:00 committed by GitHub
parent 2553f5f19e
commit 15bcc0ba9f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 245 additions and 8 deletions

View file

@ -121,7 +121,7 @@ class Bot(ContextInstanceMixin["Bot"]):
""" """
def __init__( def __init__(
self, token: str, session: Optional[BaseSession] = None, parse_mode: Optional[str] = None self, token: str, session: Optional[BaseSession] = None, parse_mode: Optional[str] = None,
) -> None: ) -> None:
validate_token(token) validate_token(token)

View file

@ -1,29 +1,105 @@
from __future__ import annotations from __future__ import annotations
from typing import AsyncGenerator, Callable, Optional, TypeVar, cast from typing import (
Any,
AsyncGenerator,
Callable,
Dict,
Iterable,
List,
Optional,
Tuple,
Type,
TypeVar,
Union,
cast,
)
from aiohttp import ClientSession, ClientTimeout, FormData from aiohttp import BasicAuth, ClientSession, ClientTimeout, FormData, TCPConnector
from aiogram.api.methods import Request, TelegramMethod from aiogram.api.methods import Request, TelegramMethod
from .base import PRODUCTION, BaseSession, TelegramAPIServer from .base import PRODUCTION, BaseSession, TelegramAPIServer
T = TypeVar("T") T = TypeVar("T")
_ProxyBasic = Union[str, Tuple[str, BasicAuth]]
_ProxyChain = Iterable[_ProxyBasic]
_ProxyType = Union[_ProxyChain, _ProxyBasic]
def _retrieve_basic(basic: _ProxyBasic) -> Dict[str, Any]:
from aiohttp_socks.utils import parse_proxy_url # type: ignore
proxy_auth: Optional[BasicAuth] = None
if isinstance(basic, str):
proxy_url = basic
else:
proxy_url, proxy_auth = basic
proxy_type, host, port, username, password = parse_proxy_url(proxy_url)
if isinstance(proxy_auth, BasicAuth):
username = proxy_auth.login
password = proxy_auth.password
return dict(
proxy_type=proxy_type,
host=host,
port=port,
username=username,
password=password,
rdns=True,
)
def _prepare_connector(chain_or_plain: _ProxyType) -> Tuple[Type["TCPConnector"], Dict[str, Any]]:
from aiohttp_socks import ProxyInfo, ProxyConnector, ChainProxyConnector # type: ignore
# since tuple is Iterable(compatible with _ProxyChain) object, we assume that
# user wants chained proxies if tuple is a pair of string(url) and BasicAuth
if isinstance(chain_or_plain, str) or (
isinstance(chain_or_plain, tuple) and len(chain_or_plain) == 2
):
chain_or_plain = cast(_ProxyBasic, chain_or_plain)
return ProxyConnector, _retrieve_basic(chain_or_plain)
chain_or_plain = cast(_ProxyChain, chain_or_plain)
infos: List[ProxyInfo] = []
for basic in chain_or_plain:
infos.append(ProxyInfo(**_retrieve_basic(basic)))
return ChainProxyConnector, dict(proxy_infos=infos)
class AiohttpSession(BaseSession): class AiohttpSession(BaseSession):
def __init__( def __init__(
self, self,
api: TelegramAPIServer = PRODUCTION, api: TelegramAPIServer = PRODUCTION,
json_loads: Optional[Callable[..., str]] = None, json_loads: Optional[Callable[..., Any]] = None,
json_dumps: Optional[Callable[..., str]] = None, json_dumps: Optional[Callable[..., str]] = None,
proxy: Optional[_ProxyType] = None,
): ):
super(AiohttpSession, self).__init__(api=api, json_loads=json_loads, json_dumps=json_dumps) super(AiohttpSession, self).__init__(
api=api, json_loads=json_loads, json_dumps=json_dumps, proxy=proxy
)
self._session: Optional[ClientSession] = None self._session: Optional[ClientSession] = None
self._connector_type: Type[TCPConnector] = TCPConnector
self._connector_init: Dict[str, Any] = {}
if self.proxy:
try:
self._connector_type, self._connector_init = _prepare_connector(
cast(_ProxyType, self.proxy)
)
except ImportError as exc: # pragma: no cover
raise UserWarning(
"In order to use aiohttp client for proxy requests, install "
"https://pypi.org/project/aiohttp-socks/"
) from exc
async def create_session(self) -> ClientSession: async def create_session(self) -> ClientSession:
if self._session is None or self._session.closed: if self._session is None or self._session.closed:
self._session = ClientSession() self._session = ClientSession(connector=self._connector_type(**self._connector_init))
return self._session return self._session

View file

@ -12,14 +12,16 @@ from ...methods import Response, TelegramMethod
from ..telegram import PRODUCTION, TelegramAPIServer from ..telegram import PRODUCTION, TelegramAPIServer
T = TypeVar("T") T = TypeVar("T")
PT = TypeVar("PT")
class BaseSession(abc.ABC): class BaseSession(abc.ABC):
def __init__( def __init__(
self, self,
api: Optional[TelegramAPIServer] = None, api: Optional[TelegramAPIServer] = None,
json_loads: Optional[Callable[..., str]] = None, json_loads: Optional[Callable[..., Any]] = None,
json_dumps: Optional[Callable[..., str]] = None, json_dumps: Optional[Callable[..., str]] = None,
proxy: Optional[PT] = None,
) -> None: ) -> None:
if api is None: if api is None:
api = PRODUCTION api = PRODUCTION
@ -31,6 +33,7 @@ class BaseSession(abc.ABC):
self.api = api self.api = api
self.json_loads = json_loads self.json_loads = json_loads
self.json_dumps = json_dumps self.json_dumps = json_dumps
self.proxy = proxy
def raise_for_status(self, response: Response[T]) -> None: def raise_for_status(self, response: Response[T]) -> None:
if response.ok: if response.ok:

View file

@ -0,0 +1,76 @@
# Aiohttp session
AiohttpSession represents a wrapper-class around `ClientSession` from [aiohttp]('https://pypi.org/project/aiohttp/')
Currently `AiohttpSession` is a default session used in `aiogram.Bot`
## Usage example
```python
from aiogram import Bot
from aiogram.api.client.session.aiohttp import AiohttpSession
session = AiohttpSession()
Bot('token', session=session)
```
## Proxy requests in AiohttpSession
In order to use AiohttpSession with proxy connector you have to install [aiohttp-socks]('https://pypi.org/project/aiohttp-socks/')
Binding session to bot:
```python
from aiogram import Bot
from aiogram.api.client.session.aiohttp import AiohttpSession
session = AiohttpSession(proxy="protocol://host:port/")
Bot(token="bot token", session=session)
```
!!! note "Protocols"
Only following protocols are supported: http(tunneling), socks4(a), socks5 as aiohttp_socks documentation claims.
### Authorization
Proxy authorization credentials can be specified in proxy URL or come as an instance of `aiohttp.BasicAuth` containing
login and password.
Consider examples:
```python
from aiohttp import BasicAuth
from aiogram.api.client.session.aiohttp import AiohttpSession
auth = BasicAuth(login="user", password="password")
session = AiohttpSession(proxy=("protocol://host:port", auth))
# or simply include your basic auth credential in URL
session = AiohttpSession(proxy="protocol://user:password@host:port")
```
!!! note "Credential priorities"
Aiogram prefers `BasicAuth` over username and password in URL, so
if proxy URL contains login and password and `BasicAuth` object is passed at the same time
aiogram will use login and password from `BasicAuth` instance.
### Proxy chains
Since [aiohttp-socks]('https://pypi.org/project/aiohttp-socks/') supports proxy chains, you're able to use them in aiogram
Example of chain proxies:
```python
from aiohttp import BasicAuth
from aiogram.api.client.session.aiohttp import AiohttpSession
auth = BasicAuth(login="user", password="password")
session = AiohttpSession(
proxy={"protocol0://host0:port0",
"protocol1://user:password@host1:port1",
("protocol2://host2:port2", auth),} # can be any iterable if not set
)
```
## Location
- `from aiogram.api.client.session.aiohttp import AiohttpSession`

View file

@ -41,6 +41,9 @@ nav:
- install.md - install.md
- Bot API: - Bot API:
- api/index.md - api/index.md
- Client:
- Session:
- aiohttp: api/client/session/aiohttp.md
- Methods: - Methods:
- Available methods: api/methods/index.md - Available methods: api/methods/index.md
- Getting updates: - Getting updates:

24
poetry.lock generated
View file

@ -24,6 +24,18 @@ yarl = ">=1.0,<2.0"
[package.extras] [package.extras]
speedups = ["aiodns", "brotlipy", "cchardet"] speedups = ["aiodns", "brotlipy", "cchardet"]
[[package]]
category = "main"
description = "Proxy connector for aiohttp"
name = "aiohttp-socks"
optional = false
python-versions = "*"
version = "0.3.9"
[package.dependencies]
aiohttp = ">=2.3.2"
attrs = ">=19.2.0"
[[package]] [[package]]
category = "dev" category = "dev"
description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
@ -931,9 +943,10 @@ testing = ["jaraco.itertools", "func-timeout"]
[extras] [extras]
fast = ["uvloop"] fast = ["uvloop"]
proxy = ["aiohttp-socks"]
[metadata] [metadata]
content-hash = "6e9fa892cd316d3e39bebc5ad146175716a8da246e7def800d66cd60f63cd630" content-hash = "8c2dc4c18c8de6ffe48c634e96e9a057d4a2ef211d21459fa4c8e23b87dd8456"
python-versions = "^3.7" python-versions = "^3.7"
[metadata.files] [metadata.files]
@ -955,6 +968,10 @@ aiohttp = [
{file = "aiohttp-3.6.2-py3-none-any.whl", hash = "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4"}, {file = "aiohttp-3.6.2-py3-none-any.whl", hash = "sha256:460bd4237d2dbecc3b5ed57e122992f60188afe46e7319116da5eb8a9dfedba4"},
{file = "aiohttp-3.6.2.tar.gz", hash = "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326"}, {file = "aiohttp-3.6.2.tar.gz", hash = "sha256:259ab809ff0727d0e834ac5e8a283dc5e3e0ecc30c4d80b3cd17a4139ce1f326"},
] ]
aiohttp-socks = [
{file = "aiohttp_socks-0.3.9-py3-none-any.whl", hash = "sha256:ccd483d7677d7ba80b7ccb738a9be27a3ad6dce4b2756509bc71c9d679d96105"},
{file = "aiohttp_socks-0.3.9.tar.gz", hash = "sha256:5e5638d0e472baa441eab7990cf19e034960cc803f259748cc359464ccb3c2d6"},
]
appdirs = [ appdirs = [
{file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"}, {file = "appdirs-1.4.3-py2.py3-none-any.whl", hash = "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"},
{file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"}, {file = "appdirs-1.4.3.tar.gz", hash = "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92"},
@ -1166,6 +1183,11 @@ markupsafe = [
{file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"},
{file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"},
{file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"},
{file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"},
{file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"},
{file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"},
{file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"},
{file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"},
{file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"},
] ]
mccabe = [ mccabe = [

View file

@ -39,6 +39,7 @@ Babel = "^2.7"
aiofiles = "^0.4.0" aiofiles = "^0.4.0"
uvloop = {version = "^0.14.0", markers = "sys_platform == 'darwin' or sys_platform == 'linux'", optional = true} uvloop = {version = "^0.14.0", markers = "sys_platform == 'darwin' or sys_platform == 'linux'", optional = true}
async_lru = "^1.0" async_lru = "^1.0"
aiohttp-socks = {version = "^0.3.8", optional = true}
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
uvloop = {version = "^0.14.0", markers = "sys_platform == 'darwin' or sys_platform == 'linux'"} uvloop = {version = "^0.14.0", markers = "sys_platform == 'darwin' or sys_platform == 'linux'"}
@ -63,9 +64,11 @@ pymdown-extensions = "^6.1"
lxml = "^4.4" lxml = "^4.4"
ipython = "^7.10" ipython = "^7.10"
markdown-include = "^0.5.1" markdown-include = "^0.5.1"
aiohttp-socks = "^0.3.4"
[tool.poetry.extras] [tool.poetry.extras]
fast = ["uvloop"] fast = ["uvloop"]
proxy = ["aiohttp-socks"]
[tool.black] [tool.black]
line-length = 99 line-length = 99

View file

@ -1,6 +1,7 @@
from typing import AsyncContextManager, AsyncGenerator from typing import AsyncContextManager, AsyncGenerator
import aiohttp import aiohttp
import aiohttp_socks
import pytest import pytest
from aresponses import ResponsesMockServer from aresponses import ResponsesMockServer
@ -29,6 +30,59 @@ class TestAiohttpSession:
assert session._session is not None assert session._session is not None
assert isinstance(aiohttp_session, aiohttp.ClientSession) assert isinstance(aiohttp_session, aiohttp.ClientSession)
@pytest.mark.asyncio
async def test_create_proxy_session(self):
session = AiohttpSession(
proxy=("socks5://proxy.url/", aiohttp.BasicAuth("login", "password", "encoding"))
)
assert session._connector_type == aiohttp_socks.ProxyConnector
assert isinstance(session._connector_init, dict)
assert session._connector_init["proxy_type"] is aiohttp_socks.ProxyType.SOCKS5
aiohttp_session = await session.create_session()
assert isinstance(aiohttp_session.connector, aiohttp_socks.ProxyConnector)
@pytest.mark.asyncio
async def test_create_proxy_session_proxy_url(self):
session = AiohttpSession(proxy="socks4://proxy.url/")
assert isinstance(session.proxy, str)
assert isinstance(session._connector_init, dict)
assert session._connector_init["proxy_type"] is aiohttp_socks.ProxyType.SOCKS4
aiohttp_session = await session.create_session()
assert isinstance(aiohttp_session.connector, aiohttp_socks.ProxyConnector)
@pytest.mark.asyncio
async def test_create_proxy_session_chained_proxies(self):
session = AiohttpSession(
proxy=[
"socks4://proxy.url/",
"socks5://proxy.url/",
"http://user:password@127.0.0.1:3128",
]
)
assert isinstance(session.proxy, list)
assert isinstance(session._connector_init, dict)
assert isinstance(session._connector_init["proxy_infos"], list)
assert isinstance(session._connector_init["proxy_infos"][0], aiohttp_socks.ProxyInfo)
assert (
session._connector_init["proxy_infos"][0].proxy_type is aiohttp_socks.ProxyType.SOCKS4
)
assert (
session._connector_init["proxy_infos"][1].proxy_type is aiohttp_socks.ProxyType.SOCKS5
)
assert session._connector_init["proxy_infos"][2].proxy_type is aiohttp_socks.ProxyType.HTTP
aiohttp_session = await session.create_session()
assert isinstance(aiohttp_session.connector, aiohttp_socks.ChainProxyConnector)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_close_session(self): async def test_close_session(self):
session = AiohttpSession() session = AiohttpSession()