diff --git a/README.md b/README.md index 5ad658a..ea5ccf2 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ - **Declarative**: Define API methods using standard Python type hints. - **Type-Safe**: Full support for static type checking. -- **Backend Agnostic**: Works with `httpx`, `aiohttp`, `requests` and `niquests`. +- **Backend Agnostic**: Works with `httpx`, `aiohttp`, `requests`, `niquests` and `zapros`. - **Extensible**: Powerful middleware and error handling systems. ## Installation @@ -52,6 +52,8 @@ pip install "unihttp[niquests]" # For niquests (Sync/Async) support pip install "unihttp[requests]" # For Requests (Sync) support # OR pip install "unihttp[aiohttp]" # For Aiohttp (Async) support +# OR +pip install "unihttp[zapros]" # For Zapros (Sync/Async) support ``` ## Serialization Backends diff --git a/pyproject.toml b/pyproject.toml index f808fd5..f3be687 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ httpx = ["httpx>=0.28.1"] requests = ["requests>=2.32.0"] aiohttp = ["aiohttp>=3.10.0"] niquests = ["niquests>=3.17.0"] +zapros = ["zapros>=0.11.0"] pydantic = ["pydantic>=2.0.0"] adaptix = ["adaptix>=3.0.0b12"] @@ -54,6 +55,7 @@ optionals = [ "unihttp[requests]", "unihttp[aiohttp]", "unihttp[niquests]", + "unihttp[zapros]", "unihttp[pydantic]", "unihttp[adaptix]" ] diff --git a/src/unihttp/clients/zapros.py b/src/unihttp/clients/zapros.py new file mode 100644 index 0000000..7f5308e --- /dev/null +++ b/src/unihttp/clients/zapros.py @@ -0,0 +1,266 @@ +import json +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Any +from urllib.parse import urljoin + +import zapros +from zapros import AsyncClient, Client, Multipart, Part + +from unihttp.clients.base import BaseAsyncClient, BaseSyncClient +from unihttp.exceptions import NetworkError, RequestTimeoutError +from unihttp.http import UploadFile +from unihttp.http.request import HTTPRequest +from unihttp.http.response import HTTPResponse +from unihttp.middlewares.base import AsyncMiddleware, Middleware +from unihttp.serialize import RequestDumper, ResponseLoader + + +def _stringify_pairs(mapping: Mapping[str, Any]) -> list[tuple[str, str]]: + """Flatten a mapping into ``[(key, str_value), ...]`` pairs. + + Both ``params=`` and ``form=`` in `zapros` are parsed by + `pywhatwgurl.URLSearchParams`, which follows the WHATWG URL spec strictly + and accepts only strings. httpx/requests/niquests auto-coerce + `int`/`bool`/`None`/sequences for query and form alike — we replicate + that contract so that markers like `Query[bool]`, `Form[int]` and + `Query[list[int]]` work uniformly across backends. + """ + def _value(item: Any) -> str: + if item is None: + return "" + if isinstance(item, bool): + return "true" if item else "false" + return str(item) + + return [ + (key, _value(item)) + for key, value in mapping.items() + for item in (value if isinstance(value, (list, tuple)) else [value]) + ] + + +def _to_bytes(content: Any) -> bytes: + """Normalize a file-content value into bytes for `zapros.Part`. + + Accepts `bytes`/`bytearray`/`memoryview`, `pathlib.Path`, and any + file-like object exposing `.read()` returning bytes. Anything else + raises `TypeError`. + """ + if isinstance(content, bytes): + return content + if isinstance(content, (bytearray, memoryview)): + return bytes(content) + if isinstance(content, Path): + return content.read_bytes() + if hasattr(content, "read"): + data = content.read() + if not isinstance(data, (bytes, bytearray, memoryview)): + raise TypeError( + f"File-like object {type(content).__name__} returned " + f"{type(data).__name__}, expected bytes.", + ) + return data if isinstance(data, bytes) else bytes(data) + raise TypeError( + f"Unsupported file content type: {type(content).__name__}", + ) + + +def _add_file_part(multipart: Multipart, key: str, value: Any) -> None: + if isinstance(value, UploadFile): + filename, content, content_type = value.to_tuple() + elif isinstance(value, tuple): + if len(value) == 2: + filename, content = value + content_type = "application/octet-stream" + else: + filename, content, content_type = value + else: + filename, content, content_type = None, value, "application/octet-stream" + + part = Part(_to_bytes(content)).mime_type(content_type) + if filename: + part = part.file_name(filename) + multipart.part(key, part) + + +def _build_multipart( + form: dict[str, Any] | None, files: dict[str, Any] | None +) -> Multipart: + """Build a `zapros.Multipart` from form fields and file uploads.""" + multipart = Multipart() + if form: + for key, value in _stringify_pairs(form): + multipart.text(key, value) + if files: + for key, value in files.items(): + if isinstance(value, list): + for item in value: + _add_file_part(multipart, key, item) + else: + _add_file_part(multipart, key, value) + return multipart + + +class ZaprosSyncClient(BaseSyncClient): + """Synchronous client implementation using the `zapros` library.""" + + def __init__( + self, + base_url: str, + request_dumper: RequestDumper, + response_loader: ResponseLoader, + middleware: list[Middleware] | None = None, + session: Client | None = None, + json_dumps: Callable[[Any], str] = json.dumps, + json_loads: Callable[[str | bytes | bytearray], Any] = json.loads, + ): + super().__init__( + base_url=base_url, + request_dumper=request_dumper, + response_loader=response_loader, + middleware=middleware, + json_dumps=json_dumps, + json_loads=json_loads, + ) + + if session is None: + session = Client() + + self._session = session + + def make_request(self, request: HTTPRequest) -> HTTPResponse: + body: bytes | None = None + form: Any = None + multipart: Multipart | None = None + + if request.body: + if request.form or request.file: + raise ValueError( + "Cannot use Body with Form or File. " + "Use Form for fields in multipart requests." + ) + body = self.json_dumps(request.body).encode("utf-8") + if "Content-Type" not in request.header: + request.header["Content-Type"] = "application/json" + elif request.file: + multipart = _build_multipart(request.form, request.file) + elif request.form: + form = _stringify_pairs(request.form) + + try: + response = self._session.request( # type: ignore[call-overload] + method=request.method, + url=urljoin(self.base_url, request.url), + headers=request.header, + params=_stringify_pairs(request.query), + form=form, + body=body, + multipart=multipart, + ) + except zapros.TimeoutError as e: + raise RequestTimeoutError(str(e)) from e + except zapros.ConnectionError as e: + raise NetworkError(str(e)) from e + + content = response.read() + + response_data: Any = None + if content: + try: + response_data = self.json_loads(content) + except (ValueError, TypeError): + response_data = content + + return HTTPResponse( + status_code=response.status, + headers=response.headers, + cookies={}, + data=response_data, + raw_response=response, + ) + + def close(self) -> None: + self._session.close() + + +class ZaprosAsyncClient(BaseAsyncClient): + """Asynchronous client implementation using the `zapros` library.""" + + def __init__( + self, + base_url: str, + request_dumper: RequestDumper, + response_loader: ResponseLoader, + middleware: list[AsyncMiddleware] | None = None, + session: AsyncClient | None = None, + json_dumps: Callable[[Any], str] = json.dumps, + json_loads: Callable[[str | bytes | bytearray], Any] = json.loads, + ): + super().__init__( + base_url=base_url, + request_dumper=request_dumper, + response_loader=response_loader, + middleware=middleware, + json_dumps=json_dumps, + json_loads=json_loads, + ) + + if session is None: + session = AsyncClient() + + self._session = session + + async def make_request(self, request: HTTPRequest) -> HTTPResponse: + body: bytes | None = None + form: Any = None + multipart: Multipart | None = None + + if request.body: + if request.form or request.file: + raise ValueError( + "Cannot use Body with Form or File. " + "Use Form for fields in multipart requests." + ) + body = self.json_dumps(request.body).encode("utf-8") + if "Content-Type" not in request.header: + request.header["Content-Type"] = "application/json" + elif request.file: + multipart = _build_multipart(request.form, request.file) + elif request.form: + form = _stringify_pairs(request.form) + + try: + response = await self._session.request( # type: ignore[call-overload] + method=request.method, + url=urljoin(self.base_url, request.url), + headers=request.header, + params=_stringify_pairs(request.query), + form=form, + body=body, + multipart=multipart, + ) + except zapros.TimeoutError as e: + raise RequestTimeoutError(str(e)) from e + except zapros.ConnectionError as e: + raise NetworkError(str(e)) from e + + content = await response.aread() + + response_data: Any = None + if content: + try: + response_data = self.json_loads(content) + except (ValueError, TypeError): + response_data = content + + return HTTPResponse( + status_code=response.status, + headers=response.headers, + cookies={}, + data=response_data, + raw_response=response, + ) + + async def close(self) -> None: + await self._session.aclose() diff --git a/tests/test_clients/test_zapros.py b/tests/test_clients/test_zapros.py new file mode 100644 index 0000000..647784f --- /dev/null +++ b/tests/test_clients/test_zapros.py @@ -0,0 +1,526 @@ +import io +from collections.abc import AsyncGenerator, Generator +from pathlib import Path +from typing import cast +from unittest.mock import AsyncMock, Mock + +import pytest +import zapros + +from unihttp.clients.base import BaseAsyncClient, BaseSyncClient +from unihttp.clients.zapros import ( + ZaprosAsyncClient, + ZaprosSyncClient, + _stringify_pairs, + _to_bytes, +) +from unihttp.exceptions import NetworkError, RequestTimeoutError +from unihttp.http import HTTPRequest, UploadFile + + +class TestToBytes: + def test_bytes_pass_through(self): + data = b"abc" + assert _to_bytes(data) is data + + def test_bytearray(self): + result = _to_bytes(bytearray(b"abc")) + assert isinstance(result, bytes) + assert result == b"abc" + + def test_memoryview(self): + result = _to_bytes(memoryview(b"abc")) + assert isinstance(result, bytes) + assert result == b"abc" + + def test_path(self, tmp_path: Path): + f = tmp_path / "x.bin" + f.write_bytes(b"contents") + assert _to_bytes(f) == b"contents" + + def test_file_like_returns_bytes(self): + assert _to_bytes(io.BytesIO(b"streamed")) == b"streamed" + + def test_file_like_returning_str_raises(self): + text_handle = io.StringIO("text-mode") + with pytest.raises(TypeError, match="expected bytes"): + _to_bytes(text_handle) + + def test_unsupported_type_raises(self): + with pytest.raises(TypeError, match="Unsupported file content type"): + _to_bytes(12345) + with pytest.raises(TypeError, match="Unsupported file content type"): + _to_bytes("string-not-bytes") + + +class TestStringifyPairs: + def test_strings_pass_through(self): + assert _stringify_pairs({"q": "hello"}) == [("q", "hello")] + + def test_int_and_float(self): + assert _stringify_pairs({"page": 1, "ratio": 0.5}) == [ + ("page", "1"), + ("ratio", "0.5"), + ] + + def test_bool_lowercased(self): + # bool is a subclass of int — must be detected first + assert _stringify_pairs({"flag": True, "off": False}) == [ + ("flag", "true"), + ("off", "false"), + ] + + def test_none_becomes_empty(self): + assert _stringify_pairs({"x": None}) == [("x", "")] + + def test_list_value_expands_to_repeated_keys(self): + assert _stringify_pairs({"id": [1, 2, 3]}) == [ + ("id", "1"), + ("id", "2"), + ("id", "3"), + ] + + def test_tuple_value_also_expands(self): + assert _stringify_pairs({"tag": ("a", "b")}) == [("tag", "a"), ("tag", "b")] + + def test_empty_mapping(self): + assert _stringify_pairs({}) == [] + + +def _mock_response(*, status: int = 200, content: bytes = b"{}") -> Mock: + response = Mock(spec=zapros.Response) + response.status = status + response.headers = {} + response.read = Mock(return_value=content) + response.aread = AsyncMock(return_value=content) + return response + + +@pytest.fixture +def sync_client(mock_request_dumper, mock_response_loader) -> Generator[BaseSyncClient, None, None]: + client = ZaprosSyncClient( + base_url="http://test.com", + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + ) + yield client + client.close() + + +@pytest.fixture +async def async_client(mock_request_dumper, mock_response_loader) -> AsyncGenerator[BaseAsyncClient, None]: + client = ZaprosAsyncClient( + base_url="http://test.com", + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + ) + yield client + await client.close() + + +class TestZaprosSyncClient: + def test_make_request(self, sync_client: BaseSyncClient, mocker): + mock_response = _mock_response(content=b'{"key": "value"}') + mock_request = mocker.patch("zapros.Client.request", return_value=mock_response) + + client = cast(ZaprosSyncClient, sync_client) + request = HTTPRequest( + url="/path", + method="GET", + header={"User-Agent": "test"}, + path={}, + query={"q": "search"}, + body=None, + form=None, + file={}, + ) + + response = client.make_request(request) + + assert response.status_code == 200 + assert response.data == {"key": "value"} + mock_request.assert_called_once_with( + method="GET", + url="http://test.com/path", + headers={"User-Agent": "test"}, + params=[("q", "search")], + form=None, + body=None, + multipart=None, + ) + + def test_request_with_body(self, sync_client: BaseSyncClient, mocker): + mock_request = mocker.patch("zapros.Client.request", return_value=_mock_response()) + + client = cast(ZaprosSyncClient, sync_client) + request = HTTPRequest( + url="/path", method="POST", header={}, path={}, query={}, + body={"key": "val"}, file={}, form=None, + ) + + client.make_request(request) + kwargs = mock_request.call_args[1] + assert kwargs["body"] == b'{"key": "val"}' + assert kwargs["form"] is None + assert kwargs["multipart"] is None + assert request.header["Content-Type"] == "application/json" + + def test_request_with_form(self, sync_client: BaseSyncClient, mocker): + mock_request = mocker.patch("zapros.Client.request", return_value=_mock_response()) + + client = cast(ZaprosSyncClient, sync_client) + request = HTTPRequest( + url="/path", method="POST", header={}, path={}, query={}, + body=None, file={}, form={"f": "v"}, + ) + + client.make_request(request) + kwargs = mock_request.call_args[1] + assert kwargs["form"] == [("f", "v")] + assert kwargs["body"] is None + assert kwargs["multipart"] is None + + def test_form_coerces_non_string_values(self, sync_client: BaseSyncClient, mocker): + """Form values get the same coercion as query (bool/int/None/list).""" + mock_request = mocker.patch("zapros.Client.request", return_value=_mock_response()) + client = cast(ZaprosSyncClient, sync_client) + + request = HTTPRequest( + url="/path", method="POST", header={}, path={}, query={}, + body=None, file={}, + form={"flag": True, "page": 1, "opt": None, "tags": ["a", "b"]}, + ) + + client.make_request(request) + assert mock_request.call_args[1]["form"] == [ + ("flag", "true"), + ("page", "1"), + ("opt", ""), + ("tags", "a"), + ("tags", "b"), + ] + + def test_query_coerces_non_string_values(self, sync_client: BaseSyncClient, mocker): + mock_request = mocker.patch("zapros.Client.request", return_value=_mock_response()) + client = cast(ZaprosSyncClient, sync_client) + + request = HTTPRequest( + url="/path", method="GET", header={}, + path={}, + query={"page": 1, "flag": False, "opt": None, "ids": [1, 2]}, + body=None, file={}, form=None, + ) + + client.make_request(request) + assert mock_request.call_args[1]["params"] == [ + ("page", "1"), + ("flag", "false"), + ("opt", ""), + ("ids", "1"), + ("ids", "2"), + ] + + def test_request_with_files_builds_multipart(self, sync_client: BaseSyncClient, mocker): + mock_request = mocker.patch("zapros.Client.request", return_value=_mock_response()) + client = cast(ZaprosSyncClient, sync_client) + + request = HTTPRequest( + url="/upload", + method="POST", + header={}, + path={}, + query={}, + body=None, + file={ + "files": [ + UploadFile(b"content1", filename="f1.txt"), + ("f2.txt", b"content2"), + ], + "single_upload_file": UploadFile(b"content3", filename="f3.txt"), + "single_tuple": ("f4.txt", b"content4", "image/png"), + }, + form={"caption": "hello", "count": 42, "active": True}, + ) + + client.make_request(request) + + kwargs = mock_request.call_args[1] + assert kwargs["body"] is None + assert kwargs["form"] is None + multipart = kwargs["multipart"] + assert isinstance(multipart, zapros.Multipart) + + # Render to bytes to verify all parts are present. + rendered = multipart.to_body() + assert isinstance(rendered, bytes) + assert b'name="caption"' in rendered + assert b"hello" in rendered + # int and bool form values must be coerced to strings + assert b'name="count"' in rendered + assert b"42" in rendered + assert b'name="active"' in rendered + assert b"true" in rendered + assert b'filename="f1.txt"' in rendered + assert b"content1" in rendered + assert b'filename="f2.txt"' in rendered + assert b"content2" in rendered + assert b'filename="f3.txt"' in rendered + assert b"content3" in rendered + assert b'filename="f4.txt"' in rendered + assert b"content4" in rendered + assert b"image/png" in rendered + + def test_file_part_variants(self, sync_client: BaseSyncClient, mocker): + """Cover BinaryIO read-path and raw-bytes value path in `_add_file_part`.""" + mock_request = mocker.patch("zapros.Client.request", return_value=_mock_response()) + client = cast(ZaprosSyncClient, sync_client) + + request = HTTPRequest( + url="/upload", method="POST", header={}, path={}, query={}, + body=None, form={}, + file={ + "stream": UploadFile(io.BytesIO(b"streamed"), filename="s.bin"), + "raw": b"naked-bytes", + }, + ) + + client.make_request(request) + rendered = mock_request.call_args[1]["multipart"].to_body() + assert b"streamed" in rendered + assert b'filename="s.bin"' in rendered + assert b"naked-bytes" in rendered + assert b'name="raw"' in rendered + + def test_non_json_response_kept_as_bytes(self, sync_client: BaseSyncClient, mocker): + mocker.patch( + "zapros.Client.request", + return_value=_mock_response(content=b"not json"), + ) + client = cast(ZaprosSyncClient, sync_client) + + request = HTTPRequest( + url="/path", method="GET", header={}, path={}, query={}, + body=None, file={}, form=None, + ) + + response = client.make_request(request) + assert response.data == b"not json" + + def test_empty_body_with_file_does_not_error(self, sync_client: BaseSyncClient, mocker): + """Dumper defaults `body` to `{}` — that must not preempt file uploads.""" + mock_request = mocker.patch("zapros.Client.request", return_value=_mock_response()) + client = cast(ZaprosSyncClient, sync_client) + + request = HTTPRequest( + url="/upload", method="POST", header={}, path={}, query={}, + body={}, form={}, file={"doc": UploadFile(b"x", filename="x.txt")}, + ) + + client.make_request(request) + kwargs = mock_request.call_args[1] + assert kwargs["body"] is None + assert isinstance(kwargs["multipart"], zapros.Multipart) + + def test_body_and_form_error(self, sync_client: BaseSyncClient): + client = cast(ZaprosSyncClient, sync_client) + request = HTTPRequest( + url="/path", method="POST", header={}, path={}, query={}, + body={"b": "v"}, file={}, form={"f": "v"}, + ) + with pytest.raises(ValueError, match="Cannot use Body with Form or File"): + client.make_request(request) + + def test_timeout_error(self, sync_client: BaseSyncClient, mocker): + mocker.patch( + "zapros.Client.request", + side_effect=zapros.ReadTimeoutError("Timeout Check"), + ) + client = cast(ZaprosSyncClient, sync_client) + request = HTTPRequest( + url="/path", method="GET", header={}, path={}, query={}, + body=None, file={}, form=None, + ) + with pytest.raises(RequestTimeoutError, match="Timeout Check"): + client.make_request(request) + + def test_network_error(self, sync_client: BaseSyncClient, mocker): + mocker.patch( + "zapros.Client.request", + side_effect=zapros.ConnectionError("Connection Check"), + ) + client = cast(ZaprosSyncClient, sync_client) + request = HTTPRequest( + url="/path", method="GET", header={}, path={}, query={}, + body=None, file={}, form=None, + ) + with pytest.raises(NetworkError, match="Connection Check"): + client.make_request(request) + + def test_close(self, sync_client: BaseSyncClient, mocker): + mock_close = mocker.patch("zapros.Client.close") + sync_client.close() + mock_close.assert_called_once() + + def test_init_with_session(self, mock_request_dumper, mock_response_loader): + session = Mock(spec=zapros.Client) + client = ZaprosSyncClient( + base_url="http://base", + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + session=session, + ) + assert client._session is session + client.close() + + +class TestZaprosAsyncClient: + @pytest.mark.asyncio + async def test_make_request(self, async_client: BaseAsyncClient, mocker): + mock_response = _mock_response(content=b'{"key": "value"}') + mock_request = mocker.patch( + "zapros.AsyncClient.request", + new_callable=AsyncMock, + return_value=mock_response, + ) + client = cast(ZaprosAsyncClient, async_client) + + request = HTTPRequest( + url="/path", + method="POST", + header={"User-Agent": "test"}, + path={}, + query={}, + body={"some": "data"}, + form=None, + file={}, + ) + + response = await client.make_request(request) + + assert response.status_code == 200 + assert response.data == {"key": "value"} + kwargs = mock_request.call_args[1] + assert kwargs["url"] == "http://test.com/path" + assert kwargs["body"] == b'{"some": "data"}' + assert kwargs["headers"] == {"User-Agent": "test", "Content-Type": "application/json"} + + @pytest.mark.asyncio + async def test_request_with_form(self, async_client: BaseAsyncClient, mocker): + mock_request = mocker.patch( + "zapros.AsyncClient.request", + new_callable=AsyncMock, + return_value=_mock_response(), + ) + client = cast(ZaprosAsyncClient, async_client) + + request = HTTPRequest( + url="/path", method="POST", header={}, path={}, query={}, + body=None, file={}, form={"f": "v"}, + ) + + await client.make_request(request) + assert mock_request.call_args[1]["form"] == [("f", "v")] + + @pytest.mark.asyncio + async def test_request_with_files_builds_multipart(self, async_client: BaseAsyncClient, mocker): + mock_request = mocker.patch( + "zapros.AsyncClient.request", + new_callable=AsyncMock, + return_value=_mock_response(), + ) + client = cast(ZaprosAsyncClient, async_client) + + request = HTTPRequest( + url="/upload", + method="POST", + header={}, + path={}, + query={}, + body=None, + file={"doc": UploadFile(b"content", filename="test.txt")}, + form={}, + ) + + await client.make_request(request) + + multipart = mock_request.call_args[1]["multipart"] + assert isinstance(multipart, zapros.Multipart) + rendered = multipart.to_body() + assert isinstance(rendered, bytes) + assert b'filename="test.txt"' in rendered + assert b"content" in rendered + + @pytest.mark.asyncio + async def test_non_json_response_kept_as_bytes(self, async_client: BaseAsyncClient, mocker): + mocker.patch( + "zapros.AsyncClient.request", + new_callable=AsyncMock, + return_value=_mock_response(content=b"not json"), + ) + client = cast(ZaprosAsyncClient, async_client) + + request = HTTPRequest( + url="/path", method="GET", header={}, path={}, query={}, + body=None, file={}, form=None, + ) + + response = await client.make_request(request) + assert response.data == b"not json" + + @pytest.mark.asyncio + async def test_body_and_form_error(self, async_client: BaseAsyncClient): + client = cast(ZaprosAsyncClient, async_client) + request = HTTPRequest( + url="/path", method="POST", header={}, path={}, query={}, + body={"b": "v"}, file={}, form={"f": "v"}, + ) + with pytest.raises(ValueError, match="Cannot use Body with Form or File"): + await client.make_request(request) + + @pytest.mark.asyncio + async def test_timeout_error(self, async_client: BaseAsyncClient, mocker): + mocker.patch( + "zapros.AsyncClient.request", + new_callable=AsyncMock, + side_effect=zapros.ConnectTimeoutError("Timeout Check"), + ) + client = cast(ZaprosAsyncClient, async_client) + request = HTTPRequest( + url="/path", method="GET", header={}, path={}, query={}, + body=None, file={}, form=None, + ) + with pytest.raises(RequestTimeoutError, match="Timeout Check"): + await client.make_request(request) + + @pytest.mark.asyncio + async def test_network_error(self, async_client: BaseAsyncClient, mocker): + mocker.patch( + "zapros.AsyncClient.request", + new_callable=AsyncMock, + side_effect=zapros.ConnectionError("Connection Check"), + ) + client = cast(ZaprosAsyncClient, async_client) + request = HTTPRequest( + url="/path", method="GET", header={}, path={}, query={}, + body=None, file={}, form=None, + ) + with pytest.raises(NetworkError, match="Connection Check"): + await client.make_request(request) + + @pytest.mark.asyncio + async def test_close(self, async_client: BaseAsyncClient, mocker): + mock_close = mocker.patch("zapros.AsyncClient.aclose", new_callable=AsyncMock) + await async_client.close() + mock_close.assert_awaited_once() + + @pytest.mark.asyncio + async def test_init_with_session(self, mock_request_dumper, mock_response_loader): + session = AsyncMock(spec=zapros.AsyncClient) + client = ZaprosAsyncClient( + base_url="http://base", + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + session=session, + ) + assert client._session is session + await client.close() diff --git a/tests/test_integration/test_real_clients.py b/tests/test_integration/test_real_clients.py index 812a1ee..989ff90 100644 --- a/tests/test_integration/test_real_clients.py +++ b/tests/test_integration/test_real_clients.py @@ -8,6 +8,7 @@ from unihttp.clients.aiohttp import AiohttpAsyncClient from unihttp.clients.httpx import HTTPXAsyncClient from unihttp.clients.requests import RequestsSyncClient +from unihttp.clients.zapros import ZaprosAsyncClient from unihttp.method import BaseMethod from unihttp.serialize import RequestDumper, ResponseLoader @@ -81,6 +82,18 @@ async def test_httpx_async_real_echo(integration_server, real_dumper, real_loade assert result["headers"]["X-Test"] == "httpx" +@pytest.mark.asyncio +async def test_zapros_async_real_echo(integration_server, real_dumper, real_loader): + base_url = str(integration_server.make_url("/")) + + async with ZaprosAsyncClient(base_url, real_dumper, real_loader) as client: + method = EchoMethod(body={"baz": "qux"}, headers={"X-Test": "zapros"}) + result = await client.call_method(method) + + assert result["body"] == {"baz": "qux"} + assert result["headers"]["X-Test"] == "zapros" + + @pytest.mark.skip(reason="Sync client blocks the event loop of the async server fixture") def test_requests_real_echo(integration_server, real_dumper, real_loader): base_url = str(integration_server.make_url("/"))