From 086806e202e4c63ce87443f17b8a9af072c5a6f6 Mon Sep 17 00:00:00 2001 From: Boger Date: Thu, 12 Mar 2020 20:35:46 +0300 Subject: [PATCH] :sparkles: Add httpx client and set it as default Closes #279 --- aiogram/api/client/base.py | 4 +- aiogram/api/client/session/httpx.py | 82 +++++++++ poetry.lock | 166 +++++++++++++++++- pyproject.toml | 2 + tests/test_api/test_client/test_base_bot.py | 16 +- .../test_session/test_httpx_session.py | 156 ++++++++++++++++ 6 files changed, 413 insertions(+), 13 deletions(-) create mode 100644 aiogram/api/client/session/httpx.py create mode 100644 tests/test_api/test_client/test_session/test_httpx_session.py diff --git a/aiogram/api/client/base.py b/aiogram/api/client/base.py index 4d8b7453..a4ffee79 100644 --- a/aiogram/api/client/base.py +++ b/aiogram/api/client/base.py @@ -6,8 +6,8 @@ from typing import Any, Optional, TypeVar from ...utils.mixins import ContextInstanceMixin, DataMixin from ...utils.token import extract_bot_id, validate_token from ..methods import TelegramMethod -from .session.aiohttp import AiohttpSession from .session.base import BaseSession +from .session.httpx import HttpxSession T = TypeVar("T") @@ -23,7 +23,7 @@ class BaseBot(ContextInstanceMixin, DataMixin): validate_token(token) if session is None: - session = AiohttpSession() + session = HttpxSession() self.session = session self.parse_mode = parse_mode diff --git a/aiogram/api/client/session/httpx.py b/aiogram/api/client/session/httpx.py new file mode 100644 index 00000000..5da1f720 --- /dev/null +++ b/aiogram/api/client/session/httpx.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import warnings +from typing import Any, AsyncGenerator, Callable, Dict, Optional, Tuple, TypeVar, Union, cast + +from httpx import AsyncClient + +from aiogram.api.client.session.base import BaseSession +from aiogram.api.client.telegram import PRODUCTION, TelegramAPIServer +from aiogram.api.methods import Request, TelegramMethod +from aiogram.api.types import InputFile +from aiogram.utils.warnings import CodeHasNoEffect + +T = TypeVar("T") + + +class HttpxSession(BaseSession): + def __init__( + self, + api: TelegramAPIServer = PRODUCTION, + json_loads: Optional[Callable] = None, + json_dumps: Optional[Callable] = None, + ): + super(HttpxSession, self).__init__( + api=api, json_loads=json_loads, json_dumps=json_dumps, + ) + self._client: Optional[AsyncClient] = None + + async def create_session(self) -> AsyncClient: + if self._client is None: + self._client = AsyncClient() + + return self._client + + async def close(self): + if self._client is not None: + await self._client.aclose() + + def build_form_data(self, request: Request): + form_data: Dict[str, Union[str, int, bool]] = {} + files: Dict[str, Tuple[InputFile, str]] = {} + + for key, value in request.data.items(): + if value is None: + continue + form_data[key] = self.prepare_value(value) + + if request.files: + for key, input_file in request.files.items(): + filename = input_file.filename or key + files[key] = (input_file, filename) + + return form_data, files + + async def make_request(self, token: str, call: TelegramMethod[T]) -> T: + session = await self.create_session() + + request = call.build_request() + url = self.api.api_url(token=token, method=request.method) + form_data, files = self.build_form_data(request) + resp = await session.post(url=url, data=form_data, files=files) + raw_result = resp.json() + # we need cast because JSON can return list, but not in our Telegram API case + raw_result = cast(Dict[str, Any], raw_result) + + response = call.build_response(raw_result) + self.raise_for_status(response) + return cast(T, response.result) + + async def stream_content( + self, url: str, timeout: int, chunk_size: int + ) -> AsyncGenerator[bytes, None]: + warnings.warn("httpx doesn't support `chunk_size` yet", CodeHasNoEffect) + session = await self.create_session() + + async with session.stream(method="GET", url=url, timeout=timeout) as resp: + async for chunk in resp.aiter_bytes(): + yield chunk + + async def __aenter__(self) -> HttpxSession: + await self.create_session() + return self diff --git a/poetry.lock b/poetry.lock index f1088435..f95d7884 100644 --- a/poetry.lock +++ b/poetry.lock @@ -72,7 +72,6 @@ version = "3.0.1" [[package]] category = "dev" description = "Enhance the standard unittest package with features for testing asyncio libraries" -marker = "python_version < \"3.8\"" name = "asynctest" optional = false python-versions = ">=3.5" @@ -140,6 +139,14 @@ typed-ast = ">=1.4.0" [package.extras] d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] +[[package]] +category = "main" +description = "Python package for providing Mozilla's CA Bundle." +name = "certifi" +optional = false +python-versions = "*" +version = "2019.11.28" + [[package]] category = "main" description = "Universal encoding detector for Python 2 and 3" @@ -219,6 +226,69 @@ flake8 = ">=3.3.0" jinja2 = ">=2.9.0" pygments = ">=2.2.0" +[[package]] +category = "main" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +name = "h11" +optional = false +python-versions = "*" +version = "0.9.0" + +[[package]] +category = "main" +description = "HTTP/2 State-Machine based protocol implementation" +name = "h2" +optional = false +python-versions = "*" +version = "3.2.0" + +[package.dependencies] +hpack = ">=3.0,<4" +hyperframe = ">=5.2.0,<6" + +[[package]] +category = "main" +description = "Pure-Python HPACK header compression" +name = "hpack" +optional = false +python-versions = "*" +version = "3.0.0" + +[[package]] +category = "main" +description = "Chromium HSTS Preload list as a Python package and updated daily" +name = "hstspreload" +optional = false +python-versions = ">=3.6" +version = "2020.3.12" + +[[package]] +category = "main" +description = "The next generation HTTP client." +name = "httpx" +optional = false +python-versions = ">=3.6" +version = "0.12.0" + +[package.dependencies] +certifi = "*" +chardet = ">=3.0.0,<4.0.0" +h11 = ">=0.8,<0.10" +h2 = ">=3.0.0,<4.0.0" +hstspreload = "*" +idna = ">=2.0.0,<3.0.0" +rfc3986 = ">=1.3,<2" +sniffio = ">=1.0.0,<2.0.0" +urllib3 = ">=1.0.0,<2.0.0" + +[[package]] +category = "main" +description = "HTTP/2 framing layer for Python" +name = "hyperframe" +optional = false +python-versions = "*" +version = "5.2.0" + [[package]] category = "main" description = "Internationalized Domain Names in Applications (IDNA)" @@ -759,6 +829,29 @@ optional = false python-versions = "*" version = "2020.2.20" +[[package]] +category = "dev" +description = "A utility for mocking out the Python HTTPX library." +name = "respx" +optional = false +python-versions = ">=3.6" +version = "0.10.1" + +[package.dependencies] +asynctest = "*" +httpx = ">=0.12,<0.13" + +[[package]] +category = "main" +description = "Validating URI References per RFC 3986" +name = "rfc3986" +optional = false +python-versions = "*" +version = "1.3.2" + +[package.extras] +idna2008 = ["idna"] + [[package]] category = "dev" description = "Python 2 and 3 compatibility utilities" @@ -767,6 +860,14 @@ optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" version = "1.14.0" +[[package]] +category = "main" +description = "Sniff out which async library your code is running under" +name = "sniffio" +optional = false +python-versions = ">=3.5" +version = "1.1.0" + [[package]] category = "dev" description = "Python Library for Tom's Obvious, Minimal Language" @@ -815,6 +916,19 @@ optional = false python-versions = "*" version = "3.7.4.1" +[[package]] +category = "main" +description = "HTTP library with thread-safe connection pooling, file post, and more." +name = "urllib3" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" +version = "1.25.8" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] + [[package]] category = "main" description = "Fast implementation of asyncio event loop on top of libuv" @@ -861,7 +975,7 @@ testing = ["jaraco.itertools", "func-timeout"] fast = ["uvloop"] [metadata] -content-hash = "2eb50b5b57d0fac4780f1eb3f92ff129d891fd346e0c00856c1a56c58feffb03" +content-hash = "d71d0606da0aeda76ffb026cc7bab6445522cef5e5b60207005e3447d6297684" python-versions = "^3.7" [metadata.files] @@ -926,6 +1040,10 @@ black = [ {file = "black-19.10b0-py36-none-any.whl", hash = "sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b"}, {file = "black-19.10b0.tar.gz", hash = "sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"}, ] +certifi = [ + {file = "certifi-2019.11.28-py2.py3-none-any.whl", hash = "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3"}, + {file = "certifi-2019.11.28.tar.gz", hash = "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"}, +] chardet = [ {file = "chardet-3.0.4-py2.py3-none-any.whl", hash = "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"}, {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, @@ -987,6 +1105,29 @@ flake8-html = [ {file = "flake8-html-0.4.0.tar.gz", hash = "sha256:44bec37f142e97c4a5b2cf10efe24ed253617a9736878851a594d4763011e742"}, {file = "flake8_html-0.4.0-py2.py3-none-any.whl", hash = "sha256:f372cd599ba9a374943eaa75a9cce30408cf4c0cc2251bc5194e6b0d3fc2bc3a"}, ] +h11 = [ + {file = "h11-0.9.0-py2.py3-none-any.whl", hash = "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"}, + {file = "h11-0.9.0.tar.gz", hash = "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1"}, +] +h2 = [ + {file = "h2-3.2.0-py2.py3-none-any.whl", hash = "sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5"}, + {file = "h2-3.2.0.tar.gz", hash = "sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14"}, +] +hpack = [ + {file = "hpack-3.0.0-py2.py3-none-any.whl", hash = "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89"}, + {file = "hpack-3.0.0.tar.gz", hash = "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"}, +] +hstspreload = [ + {file = "hstspreload-2020.3.12.tar.gz", hash = "sha256:0f02fd8f4fd40eb8dd406742c2a2531685dbdb6f7e96f3eb88c80b856de658fe"}, +] +httpx = [ + {file = "httpx-0.12.0-py3-none-any.whl", hash = "sha256:add141cad7602f58289287fd8e8b7adb610550e2c183712b31860ac7e113c150"}, + {file = "httpx-0.12.0.tar.gz", hash = "sha256:a3e82b1fec1e672e500c650b5d54a7353f7d20497f1fbfc6faae5f66aecd91d1"}, +] +hyperframe = [ + {file = "hyperframe-5.2.0-py2.py3-none-any.whl", hash = "sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40"}, + {file = "hyperframe-5.2.0.tar.gz", hash = "sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"}, +] idna = [ {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, @@ -1083,6 +1224,11 @@ markupsafe = [ {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-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"}, ] mccabe = [ @@ -1283,10 +1429,22 @@ regex = [ {file = "regex-2020.2.20-cp38-cp38-win_amd64.whl", hash = "sha256:7f78f963e62a61e294adb6ff5db901b629ef78cb2a1cfce3cf4eeba80c1c67aa"}, {file = "regex-2020.2.20.tar.gz", hash = "sha256:9e9624440d754733eddbcd4614378c18713d2d9d0dc647cf9c72f64e39671be5"}, ] +respx = [ + {file = "respx-0.10.1-py2.py3-none-any.whl", hash = "sha256:43aca802e0fd0c964865b07f101943e7b5902ea070ec94cf8e84a39db8729b06"}, + {file = "respx-0.10.1.tar.gz", hash = "sha256:190d1fb5bddaf6fcc1319a3cdfbd682c77d7167017b3283cbe79b8fb74927135"}, +] +rfc3986 = [ + {file = "rfc3986-1.3.2-py2.py3-none-any.whl", hash = "sha256:df4eba676077cefb86450c8f60121b9ae04b94f65f85b69f3f731af0516b7b18"}, + {file = "rfc3986-1.3.2.tar.gz", hash = "sha256:0344d0bd428126ce554e7ca2b61787b6a28d2bbd19fc70ed2dd85efe31176405"}, +] six = [ {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, ] +sniffio = [ + {file = "sniffio-1.1.0-py3-none-any.whl", hash = "sha256:20ed6d5b46f8ae136d00b9dcb807615d83ed82ceea6b2058cecb696765246da5"}, + {file = "sniffio-1.1.0.tar.gz", hash = "sha256:8e3810100f69fe0edd463d02ad407112542a11ffdc29f67db2bf3771afb87a21"}, +] toml = [ {file = "toml-0.10.0-py2.7.egg", hash = "sha256:f1db651f9657708513243e61e6cc67d101a39bad662eaa9b5546f789338e07a3"}, {file = "toml-0.10.0-py2.py3-none-any.whl", hash = "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"}, @@ -1333,6 +1491,10 @@ typing-extensions = [ {file = "typing_extensions-3.7.4.1-py3-none-any.whl", hash = "sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575"}, {file = "typing_extensions-3.7.4.1.tar.gz", hash = "sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2"}, ] +urllib3 = [ + {file = "urllib3-1.25.8-py2.py3-none-any.whl", hash = "sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc"}, + {file = "urllib3-1.25.8.tar.gz", hash = "sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"}, +] uvloop = [ {file = "uvloop-0.14.0-cp35-cp35m-macosx_10_11_x86_64.whl", hash = "sha256:08b109f0213af392150e2fe6f81d33261bb5ce968a288eb698aad4f46eb711bd"}, {file = "uvloop-0.14.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:4544dcf77d74f3a84f03dd6278174575c44c67d7165d4c42c71db3fdc3860726"}, diff --git a/pyproject.toml b/pyproject.toml index 119fe577..63d535cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ Babel = "^2.7" aiofiles = "^0.4.0" uvloop = {version = "^0.14.0", markers = "sys_platform == 'darwin' or sys_platform == 'linux'", optional = true} async_lru = "^1.0" +httpx = "^0.12.0" [tool.poetry.dev-dependencies] uvloop = {version = "^0.14.0", markers = "sys_platform == 'darwin' or sys_platform == 'linux'"} @@ -62,6 +63,7 @@ pymdown-extensions = "^6.1" lxml = "^4.4" ipython = "^7.10" markdown-include = "^0.5.1" +RESPX = "^0.10.1" [tool.poetry.extras] fast = ["uvloop"] diff --git a/tests/test_api/test_client/test_base_bot.py b/tests/test_api/test_client/test_base_bot.py index 652f0918..10a80891 100644 --- a/tests/test_api/test_client/test_base_bot.py +++ b/tests/test_api/test_client/test_base_bot.py @@ -1,7 +1,7 @@ import pytest from aiogram.api.client.base import BaseBot -from aiogram.api.client.session.aiohttp import AiohttpSession +from aiogram.api.client.session.httpx import HttpxSession from aiogram.api.methods import GetMe try: @@ -13,7 +13,7 @@ except ImportError: class TestBaseBot: def test_init(self): base_bot = BaseBot("42:TEST") - assert isinstance(base_bot.session, AiohttpSession) + assert isinstance(base_bot.session, HttpxSession) assert base_bot.id == 42 def test_hashable(self): @@ -32,7 +32,7 @@ class TestBaseBot: method = GetMe() with patch( - "aiogram.api.client.session.aiohttp.AiohttpSession.make_request", + "aiogram.api.client.session.httpx.HttpxSession.make_request", new_callable=CoroutineMock, ) as mocked_make_request: await base_bot(method) @@ -40,11 +40,11 @@ class TestBaseBot: @pytest.mark.asyncio async def test_close(self): - base_bot = BaseBot("42:TEST", session=AiohttpSession()) + base_bot = BaseBot("42:TEST", session=HttpxSession()) await base_bot.session.create_session() with patch( - "aiogram.api.client.session.aiohttp.AiohttpSession.close", new_callable=CoroutineMock + "aiogram.api.client.session.httpx.HttpxSession.close", new_callable=CoroutineMock ) as mocked_close: await base_bot.close() mocked_close.assert_awaited() @@ -53,11 +53,9 @@ class TestBaseBot: @pytest.mark.parametrize("close", [True, False]) async def test_context_manager(self, close: bool): with patch( - "aiogram.api.client.session.aiohttp.AiohttpSession.close", new_callable=CoroutineMock + "aiogram.api.client.session.httpx.HttpxSession.close", new_callable=CoroutineMock ) as mocked_close: - async with BaseBot("42:TEST", session=AiohttpSession()).context( - auto_close=close - ) as bot: + async with BaseBot("42:TEST", session=HttpxSession()).context(auto_close=close) as bot: assert isinstance(bot, BaseBot) if close: mocked_close.assert_awaited() diff --git a/tests/test_api/test_client/test_session/test_httpx_session.py b/tests/test_api/test_client/test_session/test_httpx_session.py new file mode 100644 index 00000000..df4bba79 --- /dev/null +++ b/tests/test_api/test_client/test_session/test_httpx_session.py @@ -0,0 +1,156 @@ +import re +from typing import AsyncContextManager, AsyncGenerator + +import aiohttp +import httpx +import pytest +import respx +from aresponses import ResponsesMockServer +from pip._vendor.packaging.version import Version +from respx import HTTPXMock + +from aiogram.api.client.session.httpx import HttpxSession +from aiogram.api.client.telegram import PRODUCTION +from aiogram.api.methods import Request, TelegramMethod +from aiogram.api.types import InputFile + +try: + from asynctest import CoroutineMock, patch +except ImportError: + from unittest.mock import AsyncMock as CoroutineMock, patch # type: ignore + + +@pytest.fixture +def httpx_mock(): + with respx.mock() as httpx_mock: + yield httpx_mock + + +class BareInputFile(InputFile): + async def read(self, chunk_size: int): + yield b"" + + +class TestHttpxSession: + @pytest.mark.asyncio + async def test_create_session(self): + session = HttpxSession() + + assert session._client is None + httpx_session = await session.create_session() + assert session._client is not None + assert isinstance(httpx_session, httpx.AsyncClient) + + @pytest.mark.asyncio + async def test_close_session(self): + session = HttpxSession() + await session.create_session() + + with patch("httpx.AsyncClient.aclose", new=CoroutineMock()) as mocked_close: + await session.close() + mocked_close.assert_called_once() + + def test_build_form_data_with_data_only(self): + request = Request( + method="method", + data={ + "str": "value", + "int": 42, + "bool": True, + "null": None, + "list": ["foo"], + "dict": {"bar": "baz"}, + }, + ) + + session = HttpxSession() + form, files = session.build_form_data(request) + + fields = list(form.keys()) + list(files.keys()) + assert len(fields) == 5 + assert all(isinstance(field, str) for field in fields) + assert "null" not in fields + + def test_build_form_data_with_files(self): + request = Request( + method="method", + data={"key": "value"}, + files={"document": BareInputFile(filename="file.txt")}, + ) + + session = HttpxSession() + form, files = session.build_form_data(request) + + assert len(form) + len(files) == 2 + assert "document" in files + assert files["document"][1] == "file.txt" + assert isinstance(files["document"][0], BareInputFile) + + @pytest.mark.asyncio + async def test_make_request(self, httpx_mock: HTTPXMock): + httpx_mock.post( + url=re.compile(r".*/bot42:TEST/method"), + status_code=200, + content='{"ok": true, "result": 42}', + content_type="application/json", + ) + + session = HttpxSession() + + class TestMethod(TelegramMethod[int]): + __returning__ = int + + def build_request(self) -> Request: + return Request(method="method", data={}) + + call = TestMethod() + with patch( + "aiogram.api.client.session.base.BaseSession.raise_for_status" + ) as patched_raise_for_status: + result = await session.make_request("42:TEST", call) + assert isinstance(result, int) + assert result == 42 + + assert patched_raise_for_status.called_once() + + # Update right Version if httpx still didn't implement it + @pytest.mark.skipif( + Version(httpx.__version__) <= Version("0.12.0"), + reason="old httpx doesn't support chunk_size", + ) + @pytest.mark.asyncio + async def test_stream_content(self, httpx_mock: HTTPXMock): + + httpx_mock.get( + url=re.compile(".*"), status_code=200, content=b"\f" * 10, + ) + + session = HttpxSession() + stream = session.stream_content( + "https://www.python.org/static/img/python-logo.png", timeout=5, chunk_size=1 + ) + assert isinstance(stream, AsyncGenerator) + + size = 0 + async for chunk in stream: + assert isinstance(chunk, bytes) + chunk_size = len(chunk) + assert chunk_size == 1 + size += chunk_size + assert size == 10 + + @pytest.mark.asyncio + async def test_context_manager(self): + session = HttpxSession() + assert isinstance(session, AsyncContextManager) + + with patch( + "aiogram.api.client.session.httpx.HttpxSession.create_session", + new_callable=CoroutineMock, + ) as mocked_create_session, patch( + "aiogram.api.client.session.httpx.HttpxSession.close", new_callable=CoroutineMock + ) as mocked_close: + async with session as ctx: + assert session == ctx + mocked_close.awaited_once() + mocked_create_session.awaited_once()