From 03ac1bf8aba761eb4365fd78d1337d32d1607b06 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:51:07 +0300 Subject: [PATCH 01/12] feat: add `raise_for_status` in `HTTPResponse` --- src/unihttp/http/response.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/unihttp/http/response.py b/src/unihttp/http/response.py index 4e95fa3..546a730 100644 --- a/src/unihttp/http/response.py +++ b/src/unihttp/http/response.py @@ -2,6 +2,8 @@ from dataclasses import dataclass from typing import Any +from unihttp.exceptions import ClientError, ServerError + @dataclass class HTTPResponse: @@ -38,3 +40,10 @@ def is_client_error(self) -> bool: def is_server_error(self) -> bool: """Check if response status code is 5xx.""" return 500 <= self.status_code < 600 + + def raise_for_status(self) -> None: + """Raise ClientError/ServerError if status code is 4xx/5xx.""" + if self.is_client_error: + raise ClientError(f"HTTP {self.status_code}", self) + if self.is_server_error: + raise ServerError(f"HTTP {self.status_code}", self) From dfddb871a6717ae8ae4dfcf6c3aaa80c5aef37a0 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:11:02 +0300 Subject: [PATCH 02/12] feat: add support raw in HTTPRequest --- src/unihttp/http/request.py | 2 ++ src/unihttp/markers.py | 5 +++++ src/unihttp/method.py | 2 ++ src/unihttp/serializers/adaptix/marker_tools.py | 4 +++- src/unihttp/serializers/adaptix/serialize.py | 4 ++++ 5 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/unihttp/http/request.py b/src/unihttp/http/request.py index 5cb07d2..5a5b9b6 100644 --- a/src/unihttp/http/request.py +++ b/src/unihttp/http/request.py @@ -15,6 +15,7 @@ class HTTPRequest: body: Dictionary of body parameters (JSON/Form). file: Dictionary of files to upload. form: Dictionary of form_data parameters. + raw: Raw request body as bytes or string. """ url: str @@ -26,3 +27,4 @@ class HTTPRequest: body: Any file: dict[str, Any] form: Any + raw: bytes | str | None = None diff --git a/src/unihttp/markers.py b/src/unihttp/markers.py index 323e2ee..3ead050 100644 --- a/src/unihttp/markers.py +++ b/src/unihttp/markers.py @@ -34,6 +34,10 @@ class FormMarker(Marker): name = "form" +class RawMarker(Marker): + name = "raw" + + _MarkerValueT = TypeVar("_MarkerValueT") Path = Annotated[_MarkerValueT, PathMarker()] @@ -42,3 +46,4 @@ class FormMarker(Marker): Header = Annotated[_MarkerValueT, HeaderMarker()] File = Annotated[_MarkerValueT, FileMarker()] Form = Annotated[_MarkerValueT, FormMarker()] +Raw = Annotated[_MarkerValueT, RawMarker()] diff --git a/src/unihttp/method.py b/src/unihttp/method.py index ac245b2..8e27a54 100644 --- a/src/unihttp/method.py +++ b/src/unihttp/method.py @@ -56,6 +56,7 @@ def build_http_request(self, request_dumper: RequestDumper) -> HTTPRequest: body_data = data.get("body", {}) file_data = data.get("file", {}) form_data = data.get("form", {}) + raw_data = data.get("raw", None) url = self.__url__.format(**path_data) @@ -68,6 +69,7 @@ def build_http_request(self, request_dumper: RequestDumper) -> HTTPRequest: body=body_data, file=file_data, form=form_data, + raw=raw_data, ) def make_response( diff --git a/src/unihttp/serializers/adaptix/marker_tools.py b/src/unihttp/serializers/adaptix/marker_tools.py index d694161..0fa98c3 100644 --- a/src/unihttp/serializers/adaptix/marker_tools.py +++ b/src/unihttp/serializers/adaptix/marker_tools.py @@ -2,7 +2,7 @@ from collections.abc import Iterable from typing import Annotated, Any, get_args, get_origin, override -from unihttp.markers import Marker +from unihttp.markers import Marker, RawMarker from adaptix import Mediator, P, TypeHint, create_loc_stack_checker from adaptix._internal.model_tools.definitions import BaseField @@ -94,6 +94,8 @@ def make( ) -> KeyPath: # if marker is Path, then ("user_id",) -> ("path", "user_id") # if marker is Body, then ("username",) -> ("body", "username") + if isinstance(marker, RawMarker): + return (marker.name,) return marker.name, *key_path diff --git a/src/unihttp/serializers/adaptix/serialize.py b/src/unihttp/serializers/adaptix/serialize.py index 308b830..cbd4c1c 100644 --- a/src/unihttp/serializers/adaptix/serialize.py +++ b/src/unihttp/serializers/adaptix/serialize.py @@ -1,8 +1,10 @@ from typing import Any, TypeVar from unihttp.http import UploadFile +from unihttp.markers import RawMarker from unihttp.omitted import Omitted from unihttp.serialize import RequestDumper, ResponseLoader +from unihttp.serializers.adaptix.marker_tools import for_marker from unihttp.serializers.adaptix.provider import method_provider from adaptix import Retort, as_sentinel, dumper @@ -16,6 +18,8 @@ TypeHintTagsUnwrappingProvider(), method_provider(), dumper(UploadFile, lambda x: x.to_tuple()), + # bytes dumps to base64 by default, but here it must stay raw. + dumper(for_marker(RawMarker), lambda x: x), ] ) From cf88121f9c8d590e2464247078ce84ac0376653b Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:44:16 +0300 Subject: [PATCH 03/12] feat: add support stream method --- src/unihttp/clients/aiohttp.py | 88 +++++++++---- src/unihttp/clients/base.py | 139 ++++++++++++++++++-- src/unihttp/clients/httpx.py | 81 +++++++++--- src/unihttp/clients/httpx2.py | 81 +++++++++--- src/unihttp/clients/niquests.py | 124 +++++++++++++---- src/unihttp/clients/requests.py | 45 +++++-- src/unihttp/clients/urllib.py | 48 +++++-- src/unihttp/clients/zapros.py | 117 ++++++++++++++-- src/unihttp/http/response.py | 7 +- src/unihttp/http/stream.py | 124 +++++++++++++++++ src/unihttp/method.py | 93 +++++++++---- src/unihttp/serializers/adaptix/provider.py | 8 +- 12 files changed, 777 insertions(+), 178 deletions(-) create mode 100644 src/unihttp/http/stream.py diff --git a/src/unihttp/clients/aiohttp.py b/src/unihttp/clients/aiohttp.py index e0a4e05..2f4b312 100644 --- a/src/unihttp/clients/aiohttp.py +++ b/src/unihttp/clients/aiohttp.py @@ -4,17 +4,31 @@ from urllib.parse import urljoin import aiohttp -from aiohttp import ClientSession, FormData +from aiohttp import ClientResponse, ClientSession, FormData from unihttp.clients.base import BaseAsyncClient 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.http.stream import AsyncChunkStream from unihttp.middlewares.base import AsyncMiddleware from unihttp.serialize import RequestDumper, ResponseLoader +class _AiohttpChunkStream(AsyncChunkStream): + def __init__(self, response: ClientResponse, chunk_size: int) -> None: + super().__init__() + self._response = response + self._iter = response.content.iter_chunked(chunk_size) + + async def _fetch_chunk(self) -> bytes: + return await anext(self._iter) + + async def _close_response(self) -> None: + self._response.close() + + class AiohttpAsyncClient(BaseAsyncClient): def __init__( self, @@ -70,50 +84,66 @@ def _build_form_data(self, request: HTTPRequest) -> FormData: return form_data - async def make_request(self, request: HTTPRequest) -> HTTPResponse: - data: FormData | str | None = None - + def _build_data(self, request: HTTPRequest) -> FormData | str | bytes | None: + """Resolve the request payload: raw, then multipart/form, then JSON body.""" + if request.raw is not None: + return request.raw if request.form or request.file: - data = self._build_form_data(request) - + return self._build_form_data(request) 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." - ) - data = self.json_dumps(request.body) if "Content-Type" not in request.header: request.header["Content-Type"] = "application/json" + return data + return None + + async def _do_request(self, request: HTTPRequest) -> ClientResponse: + data = self._build_data(request) try: - async with self._session.request( + return await self._session.request( method=request.method, url=urljoin(self.base_url, request.url), headers=request.header, params=request.query, data=data, - ) as response: - response_data: Any = None - content = await response.read() - 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=response.cookies, - data=response_data, - raw_response=response, - ) + ) except aiohttp.ClientConnectionError as e: raise NetworkError(str(e)) from e except TimeoutError as e: raise RequestTimeoutError(str(e)) from e + async def make_request(self, request: HTTPRequest) -> HTTPResponse: + response = await self._do_request(request) + + response_data: Any = None + content = await response.read() + 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=response.cookies, + data=response_data, + raw_response=response, + ) + + async def stream_make_request( + self, request: HTTPRequest, chunk_size: int = 65536 + ) -> HTTPResponse[AsyncChunkStream]: + response = await self._do_request(request) + + return HTTPResponse( + status_code=response.status, + headers=response.headers, + cookies=response.cookies, + data=_AiohttpChunkStream(response, chunk_size), + raw_response=response, + ) + async def close(self) -> None: await self._session.close() diff --git a/src/unihttp/clients/base.py b/src/unihttp/clients/base.py index 76ad2e0..6d826a4 100644 --- a/src/unihttp/clients/base.py +++ b/src/unihttp/clients/base.py @@ -5,8 +5,9 @@ from unihttp.http.request import HTTPRequest from unihttp.http.response import HTTPResponse -from unihttp.method import BaseMethod, ResponseType -from unihttp.middlewares.base import AsyncMiddleware, Middleware +from unihttp.http.stream import AsyncChunkStream, ChunkStream +from unihttp.method import BaseMethod, ResponseType, StreamMethod +from unihttp.middlewares.base import AsyncHandler, AsyncMiddleware, Handler, Middleware from unihttp.serialize import RequestDumper, ResponseLoader @@ -49,7 +50,9 @@ def validate_response(self, response: HTTPResponse, method: BaseMethod) -> None: Exception: if response body indicates an error. """ - def handle_error(self, response: HTTPResponse, method: BaseMethod) -> None: + def handle_error( + self, response: HTTPResponse, method: BaseMethod | StreamMethod + ) -> None: """Handle HTTP status errors for all methods. Override to provide shared error handling for all API methods. @@ -57,7 +60,9 @@ def handle_error(self, response: HTTPResponse, method: BaseMethod) -> None: Args: response: The HTTP response with error status. - method: The method instance that triggered the request. + method: The method instance that triggered the request. Either a + `BaseMethod` (from `call_method`) or a `StreamMethod` (from + `call_method_stream`). Raises: Exception: if response indicates an error that should stop processing. @@ -85,6 +90,11 @@ def __init__( ) self.middleware = middleware or [] + def _chain_middleware(self, handler: Handler) -> Handler: + for middleware in reversed(self.middleware): + handler = functools.partial(middleware.handle, next_handler=handler) + return handler + def call_method(self, method: BaseMethod[ResponseType]) -> ResponseType: """Execute an API method synchronously. @@ -116,11 +126,7 @@ def _send(request: HTTPRequest) -> HTTPResponse: return response - handler = _send - for middleware in reversed(self.middleware): - handler = functools.partial(middleware.handle, next_handler=handler) - - http_response = handler(http_request) + http_response = self._chain_middleware(_send)(http_request) return method.make_response(http_response, response_loader=self.response_loader) @@ -137,6 +143,57 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: """ raise NotImplementedError + def stream_make_request( + self, request: HTTPRequest, chunk_size: int = 65536 + ) -> HTTPResponse[ChunkStream]: + """Perform the actual streaming HTTP request. + + Must be implemented by concrete client subclasses. + + Args: + request: The unified HTTP request object. + chunk_size: Number of bytes to read per chunk. + + Returns: + HTTPResponse: status/headers available immediately, `.data` is + an unconsumed `ChunkStream`. + """ + raise NotImplementedError + + def call_method_stream( + self, method: StreamMethod, chunk_size: int = 65536 + ) -> ChunkStream: + """Execute a streaming API method synchronously. + + Pipeline mirrors `call_method`, but the terminal handler streams the + body instead of buffering it, and there is no response_loader step. + + Args: + method: The stream method instance to execute. + chunk_size: Number of bytes to read per chunk. + + Returns: + A `ChunkStream` of `bytes` chunks. Use as a context manager + (`with client.call_method_stream(method) as stream:`) — it + closes the underlying connection on exit, even if the caller + stops iterating early. + """ + request = method.build_http_request(request_dumper=self.request_dumper) + + def _send(request_: HTTPRequest) -> HTTPResponse[ChunkStream]: + response_ = self.stream_make_request(request_, chunk_size=chunk_size) + + if not response_.ok: + # ChunkStream.close() is a direct call, not tied to whether + # iteration ever started (unlike closing a bare generator). + response_.data.close() + method.on_error(response_) + self.handle_error(response_, method) + + return response_ + + return self._chain_middleware(_send)(request).data + def close(self) -> None: """Close the client and release resources.""" @@ -168,6 +225,11 @@ def __init__( ) self.middleware = middleware or [] + def _chain_middleware(self, handler: AsyncHandler) -> AsyncHandler: + for middleware in reversed(self.middleware): + handler = functools.partial(middleware.handle, next_handler=handler) + return handler + async def call_method(self, method: BaseMethod[ResponseType]) -> ResponseType: """Execute an API method asynchronously. @@ -199,11 +261,7 @@ async def _send(request: HTTPRequest) -> HTTPResponse: return response - handler = _send - for middleware in reversed(self.middleware): - handler = functools.partial(middleware.handle, next_handler=handler) - - http_response = await handler(http_request) + http_response = await self._chain_middleware(_send)(http_request) return method.make_response(http_response, response_loader=self.response_loader) @@ -220,6 +278,59 @@ async def make_request(self, request: HTTPRequest) -> HTTPResponse: """ raise NotImplementedError + async def stream_make_request( + self, request: HTTPRequest, chunk_size: int = 65536 + ) -> HTTPResponse[AsyncChunkStream]: + """Perform the actual streaming HTTP request asynchronously. + + Must be implemented by concrete client subclasses. + + Args: + request: The unified HTTP request object. + chunk_size: Number of bytes to read per chunk. + + Returns: + HTTPResponse: status/headers available immediately, `.data` is + an unconsumed `AsyncChunkStream`. + """ + raise NotImplementedError + + async def call_method_stream( + self, method: StreamMethod, chunk_size: int = 65536 + ) -> AsyncChunkStream: + """Execute a streaming API method asynchronously. + + Pipeline mirrors `call_method`, but the terminal handler streams the + body instead of buffering it, and there is no response_loader step. + + Args: + method: The stream method instance to execute. + chunk_size: Number of bytes to read per chunk. + + Returns: + An `AsyncChunkStream` of `bytes` chunks. Use as a context + manager (`async with await client.call_method_stream(method) as + stream:`) — it closes the underlying connection on exit, even + if the caller stops iterating early. + """ + request = method.build_http_request(request_dumper=self.request_dumper) + + async def _send(request_: HTTPRequest) -> HTTPResponse[AsyncChunkStream]: + response_ = await self.stream_make_request(request_, chunk_size=chunk_size) + + if not response_.ok: + # AsyncChunkStream.aclose() is a direct call, not tied to + # whether iteration ever started (unlike aclosing a bare + # async generator). + await response_.data.aclose() + method.on_error(response_) + self.handle_error(response_, method) + + return response_ + + response = await self._chain_middleware(_send)(request) + return response.data + async def close(self) -> None: """Close the client and release resources asynchronously.""" diff --git a/src/unihttp/clients/httpx.py b/src/unihttp/clients/httpx.py index 8774f4d..c01fd4b 100644 --- a/src/unihttp/clients/httpx.py +++ b/src/unihttp/clients/httpx.py @@ -4,13 +4,19 @@ from urllib.parse import urljoin import httpx -from httpx import AsyncClient, Client +from httpx import AsyncClient, Client, Response 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.http.stream import ( + AsyncChunkStream, + AsyncIteratorChunkStream, + ChunkStream, + IteratorChunkStream, +) from unihttp.middlewares.base import AsyncMiddleware, Middleware from unihttp.serialize import RequestDumper, ResponseLoader @@ -58,22 +64,23 @@ def _convert_files(self, files: dict[str, Any]) -> list[tuple[str, Any]]: file_list.append((key, value)) return file_list - def make_request(self, request: HTTPRequest) -> HTTPResponse: - content = None - + def _build_content(self, request: HTTPRequest) -> Any: + """Resolve the request body: raw bytes/str take priority over JSON body.""" + if request.raw is not None: + return request.raw 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." - ) content = self.json_dumps(request.body) if "Content-Type" not in request.header: request.header["Content-Type"] = "application/json" + return content + return None + + def _do_request(self, request: HTTPRequest, *, stream: bool) -> Response: + content = self._build_content(request) try: files = self._convert_files(request.file) if request.file else None - response = self._session.request( + built_request = self._session.build_request( method=request.method, url=urljoin(self.base_url, request.url), headers=request.header, @@ -82,11 +89,15 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: content=content, data=request.form, ) + return self._session.send(built_request, stream=stream) except httpx.NetworkError as e: raise NetworkError(str(e)) from e except httpx.TimeoutException as e: raise RequestTimeoutError(str(e)) from e + def make_request(self, request: HTTPRequest) -> HTTPResponse: + response = self._do_request(request, stream=False) + response_data: Any = None if response.content: try: @@ -102,6 +113,19 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: raw_response=response, ) + def stream_make_request( + self, request: HTTPRequest, chunk_size: int = 65536 + ) -> HTTPResponse[ChunkStream]: + response = self._do_request(request, stream=True) + + return HTTPResponse( + status_code=response.status_code, + headers=response.headers, + cookies=response.cookies, + data=IteratorChunkStream(response.iter_bytes(chunk_size), response.close), + raw_response=response, + ) + def close(self) -> None: self._session.close() @@ -149,21 +173,23 @@ def _convert_files(self, files: dict[str, Any]) -> list[tuple[str, Any]]: file_list.append((key, value)) return file_list - async def make_request(self, request: HTTPRequest) -> HTTPResponse: - content = None + def _build_content(self, request: HTTPRequest) -> Any: + """Resolve the request body: raw bytes/str take priority over JSON body.""" + if request.raw is not None: + return request.raw 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." - ) content = self.json_dumps(request.body) if "Content-Type" not in request.header: request.header["Content-Type"] = "application/json" + return content + return None + + async def _do_request(self, request: HTTPRequest, *, stream: bool) -> Response: + content = self._build_content(request) try: files = self._convert_files(request.file) if request.file else None - response = await self._session.request( + built_request = self._session.build_request( method=request.method, url=urljoin(self.base_url, request.url), headers=request.header, @@ -172,11 +198,15 @@ async def make_request(self, request: HTTPRequest) -> HTTPResponse: content=content, data=request.form, ) + return await self._session.send(built_request, stream=stream) except httpx.NetworkError as e: raise NetworkError(str(e)) from e except httpx.TimeoutException as e: raise RequestTimeoutError(str(e)) from e + async def make_request(self, request: HTTPRequest) -> HTTPResponse: + response = await self._do_request(request, stream=False) + response_data: Any = None if response.content: try: @@ -192,5 +222,20 @@ async def make_request(self, request: HTTPRequest) -> HTTPResponse: raw_response=response, ) + async def stream_make_request( + self, request: HTTPRequest, chunk_size: int = 65536 + ) -> HTTPResponse[AsyncChunkStream]: + response = await self._do_request(request, stream=True) + + return HTTPResponse( + status_code=response.status_code, + headers=response.headers, + cookies=response.cookies, + data=AsyncIteratorChunkStream( + response.aiter_bytes(chunk_size), response.aclose + ), + raw_response=response, + ) + async def close(self) -> None: await self._session.aclose() diff --git a/src/unihttp/clients/httpx2.py b/src/unihttp/clients/httpx2.py index fb880dc..251e6f1 100644 --- a/src/unihttp/clients/httpx2.py +++ b/src/unihttp/clients/httpx2.py @@ -4,13 +4,19 @@ from urllib.parse import urljoin import httpx2 -from httpx2 import AsyncClient, Client +from httpx2 import AsyncClient, Client, Response 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.http.stream import ( + AsyncChunkStream, + AsyncIteratorChunkStream, + ChunkStream, + IteratorChunkStream, +) from unihttp.middlewares.base import AsyncMiddleware, Middleware from unihttp.serialize import RequestDumper, ResponseLoader @@ -58,22 +64,23 @@ def _convert_files(self, files: dict[str, Any]) -> list[tuple[str, Any]]: file_list.append((key, value)) return file_list - def make_request(self, request: HTTPRequest) -> HTTPResponse: - content = None - + def _build_content(self, request: HTTPRequest) -> Any: + """Resolve the request body: raw bytes/str take priority over JSON body.""" + if request.raw is not None: + return request.raw 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." - ) content = self.json_dumps(request.body) if "Content-Type" not in request.header: request.header["Content-Type"] = "application/json" + return content + return None + + def _do_request(self, request: HTTPRequest, *, stream: bool) -> Response: + content = self._build_content(request) try: files = self._convert_files(request.file) if request.file else None - response = self._session.request( + built_request = self._session.build_request( method=request.method, url=urljoin(self.base_url, request.url), headers=request.header, @@ -82,11 +89,15 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: content=content, data=request.form, ) + return self._session.send(built_request, stream=stream) except httpx2.NetworkError as e: raise NetworkError(str(e)) from e except httpx2.TimeoutException as e: raise RequestTimeoutError(str(e)) from e + def make_request(self, request: HTTPRequest) -> HTTPResponse: + response = self._do_request(request, stream=False) + response_data: Any = None if response.content: try: @@ -102,6 +113,19 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: raw_response=response, ) + def stream_make_request( + self, request: HTTPRequest, chunk_size: int = 65536 + ) -> HTTPResponse[ChunkStream]: + response = self._do_request(request, stream=True) + + return HTTPResponse( + status_code=response.status_code, + headers=response.headers, + cookies=response.cookies, + data=IteratorChunkStream(response.iter_bytes(chunk_size), response.close), + raw_response=response, + ) + def close(self) -> None: self._session.close() @@ -149,21 +173,23 @@ def _convert_files(self, files: dict[str, Any]) -> list[tuple[str, Any]]: file_list.append((key, value)) return file_list - async def make_request(self, request: HTTPRequest) -> HTTPResponse: - content = None + def _build_content(self, request: HTTPRequest) -> Any: + """Resolve the request body: raw bytes/str take priority over JSON body.""" + if request.raw is not None: + return request.raw 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." - ) content = self.json_dumps(request.body) if "Content-Type" not in request.header: request.header["Content-Type"] = "application/json" + return content + return None + + async def _do_request(self, request: HTTPRequest, *, stream: bool) -> Response: + content = self._build_content(request) try: files = self._convert_files(request.file) if request.file else None - response = await self._session.request( + built_request = self._session.build_request( method=request.method, url=urljoin(self.base_url, request.url), headers=request.header, @@ -172,11 +198,15 @@ async def make_request(self, request: HTTPRequest) -> HTTPResponse: content=content, data=request.form, ) + return await self._session.send(built_request, stream=stream) except httpx2.NetworkError as e: raise NetworkError(str(e)) from e except httpx2.TimeoutException as e: raise RequestTimeoutError(str(e)) from e + async def make_request(self, request: HTTPRequest) -> HTTPResponse: + response = await self._do_request(request, stream=False) + response_data: Any = None if response.content: try: @@ -192,5 +222,20 @@ async def make_request(self, request: HTTPRequest) -> HTTPResponse: raw_response=response, ) + async def stream_make_request( + self, request: HTTPRequest, chunk_size: int = 65536 + ) -> HTTPResponse[AsyncChunkStream]: + response = await self._do_request(request, stream=True) + + return HTTPResponse( + status_code=response.status_code, + headers=response.headers, + cookies=response.cookies, + data=AsyncIteratorChunkStream( + response.aiter_bytes(chunk_size), response.aclose + ), + raw_response=response, + ) + async def close(self) -> None: await self._session.aclose() diff --git a/src/unihttp/clients/niquests.py b/src/unihttp/clients/niquests.py index 3c9b5cd..52a155a 100644 --- a/src/unihttp/clients/niquests.py +++ b/src/unihttp/clients/niquests.py @@ -1,16 +1,22 @@ import json from collections.abc import Callable, Mapping -from typing import Any, cast +from typing import Any, Literal, cast, overload from urllib.parse import urljoin import niquests -from niquests import AsyncSession, Session +from niquests import AsyncResponse, AsyncSession, Response, Session 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.http.stream import ( + AsyncChunkStream, + AsyncIteratorChunkStream, + ChunkStream, + IteratorChunkStream, +) from unihttp.middlewares.base import AsyncMiddleware, Middleware from unihttp.serialize import RequestDumper, ResponseLoader @@ -67,32 +73,32 @@ def _convert_files(self, files: dict[str, Any]) -> list[tuple[str, Any]]: file_list.append((key, value)) return file_list - def make_request(self, request: HTTPRequest) -> HTTPResponse: + def _build_content(self, request: HTTPRequest) -> Any: + """Resolve the request body: raw takes priority, then JSON body, then form.""" content = None - - if request.form: + if request.raw is not None: + content = request.raw + elif request.form: content = request.form - - 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." - ) - + if request.body and request.raw is None: content = self.json_dumps(request.body) if "Content-Type" not in request.header: request.header["Content-Type"] = "application/json" + return content + + def _do_request(self, request: HTTPRequest, *, stream: bool) -> Response: + content = self._build_content(request) try: files = self._convert_files(request.file) if request.file else None - response = self._session.request( + return self._session.request( method=request.method, url=urljoin(self.base_url, request.url), headers=request.header, params=request.query, files=files, data=content, + stream=stream, ) except niquests.exceptions.ConnectionError as e: raise NetworkError(str(e)) from e @@ -101,6 +107,9 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: except niquests.exceptions.RequestException as e: raise NetworkError(str(e)) from e + def make_request(self, request: HTTPRequest) -> HTTPResponse: + response = self._do_request(request, stream=False) + response_data: Any = None if response.content: try: @@ -116,6 +125,21 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: raw_response=response, ) + def stream_make_request( + self, request: HTTPRequest, chunk_size: int = 65536 + ) -> HTTPResponse[ChunkStream]: + response = self._do_request(request, stream=True) + + return HTTPResponse( + status_code=response.status_code or 0, + headers=dict(response.headers), + cookies=cast(Mapping[str, Any], response.cookies), + data=IteratorChunkStream( + response.iter_content(chunk_size=chunk_size), response.close + ), + raw_response=response, + ) + def close(self) -> None: self._session.close() @@ -163,32 +187,57 @@ def _convert_files(self, files: dict[str, Any]) -> list[tuple[str, Any]]: file_list.append((key, value)) return file_list - async def make_request(self, request: HTTPRequest) -> HTTPResponse: + def _build_content(self, request: HTTPRequest) -> Any: + """Resolve the request body: raw takes priority, then JSON body, then form.""" content = None - - if request.form: + if request.raw is not None: + content = request.raw + elif request.form: content = request.form - - 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." - ) - + if request.body and request.raw is None: content = self.json_dumps(request.body) if "Content-Type" not in request.header: request.header["Content-Type"] = "application/json" + return content + + @overload + async def _do_request( + self, request: HTTPRequest, *, stream: Literal[False] + ) -> Response: ... + @overload + async def _do_request( + self, request: HTTPRequest, *, stream: Literal[True] + ) -> AsyncResponse: ... + + async def _do_request( + self, request: HTTPRequest, *, stream: bool + ) -> Response | AsyncResponse: + # `stream` must stay a *literal* at each `self._session.request(...)` + # call site: niquests overloads that call on it (Response vs + # AsyncResponse, whose `.content` is sync vs a coroutine), and a + # plain `bool` can't select between them. + content = self._build_content(request) + files = self._convert_files(request.file) if request.file else None try: - files = self._convert_files(request.file) if request.file else None - response = await self._session.request( + if stream: + return await self._session.request( + method=request.method, + url=urljoin(self.base_url, request.url), + headers=request.header, + params=request.query, + files=files, + data=content, + stream=True, + ) + return await self._session.request( method=request.method, url=urljoin(self.base_url, request.url), headers=request.header, params=request.query, files=files, data=content, + stream=False, ) except niquests.exceptions.ConnectionError as e: raise NetworkError(str(e)) from e @@ -197,6 +246,9 @@ async def make_request(self, request: HTTPRequest) -> HTTPResponse: except niquests.exceptions.RequestException as e: raise NetworkError(str(e)) from e + async def make_request(self, request: HTTPRequest) -> HTTPResponse: + response = await self._do_request(request, stream=False) + response_data: Any = None if response.content: try: @@ -212,5 +264,23 @@ async def make_request(self, request: HTTPRequest) -> HTTPResponse: raw_response=response, ) + async def stream_make_request( + self, request: HTTPRequest, chunk_size: int = 65536 + ) -> HTTPResponse[AsyncChunkStream]: + response = await self._do_request(request, stream=True) + + # niquests builds the chunk iterator asynchronously (unlike the + # other backends' iter_bytes/iter_content, which are plain sync + # calls even on an async response) — await it once, up front. + chunk_iter = await response.iter_content(chunk_size=chunk_size) + + return HTTPResponse( + status_code=response.status_code or 0, + headers=dict(response.headers), + cookies=cast(Mapping[str, Any], response.cookies), + data=AsyncIteratorChunkStream(chunk_iter, response.close), + raw_response=response, + ) + async def close(self) -> None: await self._session.close() diff --git a/src/unihttp/clients/requests.py b/src/unihttp/clients/requests.py index 52c3125..8512ced 100644 --- a/src/unihttp/clients/requests.py +++ b/src/unihttp/clients/requests.py @@ -2,12 +2,13 @@ from urllib.parse import urljoin import requests -from requests import Session +from requests import Response, Session from unihttp.clients.base import BaseSyncClient from unihttp.exceptions import NetworkError, RequestTimeoutError from unihttp.http.request import HTTPRequest from unihttp.http.response import HTTPResponse +from unihttp.http.stream import ChunkStream, IteratorChunkStream from unihttp.middlewares.base import Middleware from unihttp.serialize import RequestDumper, ResponseLoader @@ -33,37 +34,40 @@ def __init__( else: self._session = session - def make_request(self, request: HTTPRequest) -> HTTPResponse: + def _build_content(self, request: HTTPRequest) -> Any: + """Resolve the request body: raw takes priority, then JSON body, then form.""" content = None - - if request.form: + if request.raw is not None: + content = request.raw + elif request.form: content = request.form - - 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." - ) - + if request.body and request.raw is None: content = self.json_dumps(request.body) if "Content-Type" not in request.header: request.header["Content-Type"] = "application/json" + return content + + def _do_request(self, request: HTTPRequest, *, stream: bool) -> Response: + content = self._build_content(request) try: - response = self._session.request( + return self._session.request( method=request.method, url=urljoin(self.base_url, request.url), headers=request.header, params=request.query, files=request.file, data=content, + stream=stream, ) except requests.exceptions.ConnectionError as e: raise NetworkError(str(e)) from e except requests.exceptions.Timeout as e: raise RequestTimeoutError(str(e)) from e + def make_request(self, request: HTTPRequest) -> HTTPResponse: + response = self._do_request(request, stream=False) + response_data: Any = None if response.content: try: @@ -79,5 +83,20 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: raw_response=response, ) + def stream_make_request( + self, request: HTTPRequest, chunk_size: int = 65536 + ) -> HTTPResponse[ChunkStream]: + response = self._do_request(request, stream=True) + + return HTTPResponse( + status_code=response.status_code, + headers=response.headers, + cookies=response.cookies, + data=IteratorChunkStream( + response.iter_content(chunk_size=chunk_size), response.close + ), + raw_response=response, + ) + def close(self) -> None: self._session.close() diff --git a/src/unihttp/clients/urllib.py b/src/unihttp/clients/urllib.py index 92f3844..5abeb82 100644 --- a/src/unihttp/clients/urllib.py +++ b/src/unihttp/clients/urllib.py @@ -1,3 +1,4 @@ +import http.client import json import urllib.error import urllib.request @@ -12,9 +13,28 @@ from unihttp.http import UploadFile from unihttp.http.request import HTTPRequest from unihttp.http.response import HTTPResponse +from unihttp.http.stream import ChunkStream from unihttp.middlewares.base import Middleware from unihttp.serialize import RequestDumper, ResponseLoader +_UrllibResponse = http.client.HTTPResponse | urllib.error.HTTPError + + +class _UrllibChunkStream(ChunkStream): + def __init__(self, raw: _UrllibResponse, chunk_size: int) -> None: + super().__init__() + self._raw = raw + self._chunk_size = chunk_size + + def _fetch_chunk(self) -> bytes: + chunk = self._raw.read(self._chunk_size) + if not chunk: + raise StopIteration + return chunk + + def _close_response(self) -> None: + self._raw.close() + class UrllibSyncClient(BaseSyncClient): """Synchronous client implementation using the standard library `urllib`. @@ -126,11 +146,8 @@ def _prepare_body( self, request: HTTPRequest, headers: dict[str, str] ) -> bytes | None: """Encode the request payload and set the matching Content-Type header.""" - if request.body and (request.form or request.file): - raise ValueError( - "Cannot use Body with Form or File. " - "Use Form for fields in multipart requests." - ) + if request.raw is not None: + return request.raw.encode() if isinstance(request.raw, str) else request.raw if request.file: body, content_type = self._encode_multipart(request.form, request.file) @@ -144,7 +161,7 @@ def _prepare_body( return self.json_dumps(request.body).encode() return None - def make_request(self, request: HTTPRequest) -> HTTPResponse: + def _do_request(self, request: HTTPRequest) -> _UrllibResponse: headers = dict(request.header) body = self._prepare_body(request, headers) @@ -156,10 +173,10 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: ) try: - raw: Any = self._opener.open(req, timeout=self._timeout) + return self._opener.open(req, timeout=self._timeout) except urllib.error.HTTPError as e: # HTTPError is itself a valid response object for non-2xx statuses. - raw = e + return e except urllib.error.URLError as e: if isinstance(e.reason, TimeoutError): raise RequestTimeoutError(str(e)) from e @@ -167,6 +184,8 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: except TimeoutError as e: raise RequestTimeoutError(str(e)) from e + def make_request(self, request: HTTPRequest) -> HTTPResponse: + raw = self._do_request(request) content = raw.read() response_data: Any = None @@ -183,3 +202,16 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: data=response_data, raw_response=raw, ) + + def stream_make_request( + self, request: HTTPRequest, chunk_size: int = 65536 + ) -> HTTPResponse[ChunkStream]: + raw = self._do_request(request) + + return HTTPResponse( + status_code=raw.getcode(), + headers=dict(raw.headers.items()), + cookies=self._extract_cookies(raw.headers), + data=_UrllibChunkStream(raw, chunk_size), + raw_response=raw, + ) diff --git a/src/unihttp/clients/zapros.py b/src/unihttp/clients/zapros.py index 809d311..bdbc3cd 100644 --- a/src/unihttp/clients/zapros.py +++ b/src/unihttp/clients/zapros.py @@ -1,5 +1,6 @@ import json from collections.abc import Callable, Mapping +from contextlib import AsyncExitStack, ExitStack from pathlib import Path from typing import Any from urllib.parse import urljoin @@ -12,6 +13,12 @@ from unihttp.http import UploadFile from unihttp.http.request import HTTPRequest from unihttp.http.response import HTTPResponse +from unihttp.http.stream import ( + AsyncChunkStream, + AsyncIteratorChunkStream, + ChunkStream, + IteratorChunkStream, +) from unihttp.middlewares.base import AsyncMiddleware, Middleware from unihttp.serialize import RequestDumper, ResponseLoader @@ -130,17 +137,21 @@ def __init__( self._session = session - def make_request(self, request: HTTPRequest) -> HTTPResponse: + def _build_payload( + self, request: HTTPRequest + ) -> tuple[bytes | None, Any, Multipart | None]: + """Resolve (body, form, multipart): raw, then JSON body, then file/form.""" 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." - ) + if request.raw is not None: + body = ( + request.raw.encode("utf-8") + if isinstance(request.raw, str) + else request.raw + ) + elif request.body: body = self.json_dumps(request.body).encode("utf-8") if "Content-Type" not in request.header: request.header["Content-Type"] = "application/json" @@ -149,6 +160,11 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: elif request.form: form = _stringify_pairs(request.form) + return body, form, multipart + + def make_request(self, request: HTTPRequest) -> HTTPResponse: + body, form, multipart = self._build_payload(request) + try: response = self._session.request( # type: ignore[call-overload] method=request.method, @@ -181,6 +197,37 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: raw_response=response, ) + def stream_make_request( + self, request: HTTPRequest, chunk_size: int = 65536 + ) -> HTTPResponse[ChunkStream]: + body, form, multipart = self._build_payload(request) + + stream_cm = self._session.stream( # 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, + ) + + stack = ExitStack() + try: + response = stack.enter_context(stream_cm) + except zapros.TimeoutError as e: + raise RequestTimeoutError(str(e)) from e + except zapros.ConnectionError as e: + raise NetworkError(str(e)) from e + + return HTTPResponse( + status_code=response.status, + headers=response.headers, + cookies={}, + data=IteratorChunkStream(response.iter_bytes(chunk_size), stack.close), + raw_response=response, + ) + def close(self) -> None: self._session.close() @@ -212,17 +259,21 @@ def __init__( self._session = session - async def make_request(self, request: HTTPRequest) -> HTTPResponse: + def _build_payload( + self, request: HTTPRequest + ) -> tuple[bytes | None, Any, Multipart | None]: + """Resolve (body, form, multipart): raw, then JSON body, then file/form.""" 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." - ) + if request.raw is not None: + body = ( + request.raw.encode("utf-8") + if isinstance(request.raw, str) + else request.raw + ) + elif request.body: body = self.json_dumps(request.body).encode("utf-8") if "Content-Type" not in request.header: request.header["Content-Type"] = "application/json" @@ -231,6 +282,11 @@ async def make_request(self, request: HTTPRequest) -> HTTPResponse: elif request.form: form = _stringify_pairs(request.form) + return body, form, multipart + + async def make_request(self, request: HTTPRequest) -> HTTPResponse: + body, form, multipart = self._build_payload(request) + try: response = await self._session.request( # type: ignore[call-overload] method=request.method, @@ -263,5 +319,38 @@ async def make_request(self, request: HTTPRequest) -> HTTPResponse: raw_response=response, ) + async def stream_make_request( + self, request: HTTPRequest, chunk_size: int = 65536 + ) -> HTTPResponse[AsyncChunkStream]: + body, form, multipart = self._build_payload(request) + + stream_cm = self._session.stream( # 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, + ) + + stack = AsyncExitStack() + try: + response = await stack.enter_async_context(stream_cm) + except zapros.TimeoutError as e: + raise RequestTimeoutError(str(e)) from e + except zapros.ConnectionError as e: + raise NetworkError(str(e)) from e + + return HTTPResponse( + status_code=response.status, + headers=response.headers, + cookies={}, + data=AsyncIteratorChunkStream( + response.async_iter_bytes(chunk_size), stack.aclose + ), + raw_response=response, + ) + async def close(self) -> None: await self._session.aclose() diff --git a/src/unihttp/http/response.py b/src/unihttp/http/response.py index 546a730..6eb5c94 100644 --- a/src/unihttp/http/response.py +++ b/src/unihttp/http/response.py @@ -6,13 +6,14 @@ @dataclass -class HTTPResponse: +class HTTPResponse[DataT]: """Unified HTTP response structure. Attributes: status_code: The HTTP status code of the response. headers: Dictionary of response headers. - data: The parsed response data (usually JSON). + data: The parsed response data (usually JSON), + or the `ChunkStream`/`AsyncChunkStream` for a streamed response. cookies: Dictionary of response cookies. raw_response: The original response object from the underlying client (e.g., httpx.Response). @@ -21,7 +22,7 @@ class HTTPResponse: status_code: int headers: Mapping[str, Any] - data: Any + data: DataT cookies: Mapping[str, Any] raw_response: Any diff --git a/src/unihttp/http/stream.py b/src/unihttp/http/stream.py new file mode 100644 index 0000000..20462cb --- /dev/null +++ b/src/unihttp/http/stream.py @@ -0,0 +1,124 @@ +from abc import ABC, abstractmethod +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator + + +class ChunkStream(ABC): + """Lazily-fetched, closeable stream of response body chunks (sync). + + Subclasses (one per backend) implement only `_fetch_chunk`/ + `_close_response`; this class owns the shared iteration, idempotent + close and context-manager protocol. Closing does not depend on whether + iteration ever started — unlike a bare generator's `finally`, `close()` + always releases the underlying connection. + """ + + def __init__(self) -> None: + self._closed = False + + @abstractmethod + def _fetch_chunk(self) -> bytes: + """Return the next chunk. Raise StopIteration when exhausted.""" + + @abstractmethod + def _close_response(self) -> None: + """Release the underlying connection. Called at most once.""" + + def __iter__(self) -> Iterator[bytes]: + return self + + def __next__(self) -> bytes: + if self._closed: + raise StopIteration + try: + return self._fetch_chunk() + except StopIteration: + self.close() + raise + + def close(self) -> None: + if not self._closed: + self._closed = True + self._close_response() + + def __enter__(self) -> "ChunkStream": + return self + + def __exit__(self, *exc_info: object) -> None: + self.close() + + +class AsyncChunkStream(ABC): + """Async counterpart of `ChunkStream`.""" + + def __init__(self) -> None: + self._closed = False + + @abstractmethod + async def _fetch_chunk(self) -> bytes: + """Return the next chunk. Raise StopAsyncIteration when exhausted.""" + + @abstractmethod + async def _close_response(self) -> None: + """Release the underlying connection. Called at most once.""" + + def __aiter__(self) -> AsyncIterator[bytes]: + return self + + async def __anext__(self) -> bytes: + if self._closed: + raise StopAsyncIteration + try: + return await self._fetch_chunk() + except StopAsyncIteration: + await self.aclose() + raise + + async def aclose(self) -> None: + if not self._closed: + self._closed = True + await self._close_response() + + async def __aenter__(self) -> "AsyncChunkStream": + return self + + async def __aexit__(self, *exc_info: object) -> None: + await self.aclose() + + +class IteratorChunkStream(ChunkStream): + """`ChunkStream` backed by a plain sync byte iterator plus a close callback. + + Fits backends where iteration and cleanup are just `next(iterator)` and + a zero-arg call — `response.close`, or an `ExitStack`'s `.close` when + the response lives behind a manually-driven context manager. Backends + with a mismatched close signature (e.g. sync `close()` on an async + response) use a dedicated `ChunkStream` subclass instead. + """ + + def __init__(self, iterator: Iterator[bytes], close: Callable[[], None]) -> None: + super().__init__() + self._iter = iterator + self._close = close + + def _fetch_chunk(self) -> bytes: + return next(self._iter) + + def _close_response(self) -> None: + self._close() + + +class AsyncIteratorChunkStream(AsyncChunkStream): + """Async counterpart of `IteratorChunkStream`.""" + + def __init__( + self, iterator: AsyncIterator[bytes], close: Callable[[], Awaitable[None]] + ) -> None: + super().__init__() + self._iter = iterator + self._close = close + + async def _fetch_chunk(self) -> bytes: + return await anext(self._iter) + + async def _close_response(self) -> None: + await self._close() diff --git a/src/unihttp/method.py b/src/unihttp/method.py index 8e27a54..510843d 100644 --- a/src/unihttp/method.py +++ b/src/unihttp/method.py @@ -10,35 +10,17 @@ @dataclass -class BaseMethod[ResponseType]: +class RequestMethod: """Base class for defining API methods. - Subclasses represent specific API endpoints. - Type parameter `ResponseType` specifies the expected return type. - Attributes: __url__: The URL path pattern (e.g., "/users/{id}"). __method__: The HTTP method (e.g., "GET"). - __returning__: The type class of the response (automatically extracted - from generic type). """ __url__: ClassVar[str] __method__: ClassVar[str] - __returning__: ClassVar[type] - - def __init_subclass__(cls, **kwargs): - super().__init_subclass__(**kwargs) - - for base in get_original_bases(cls): - origin = getattr(base, "__origin__", None) - - if origin is not None and issubclass(origin, BaseMethod): - if args := get_args(base): - cls.__returning__ = args[0] - break - def build_http_request(self, request_dumper: RequestDumper) -> HTTPRequest: """Convert this method instance into an HTTPRequest. @@ -47,6 +29,9 @@ def build_http_request(self, request_dumper: RequestDumper) -> HTTPRequest: Returns: HTTPRequest: The constructed HTTP request object. + + Raises: + ValueError: if more than one of `raw`/`body`/`(form or file)` is set. """ data = request_dumper.dump(self) @@ -58,6 +43,17 @@ def build_http_request(self, request_dumper: RequestDumper) -> HTTPRequest: form_data = data.get("form", {}) raw_data = data.get("raw", None) + if raw_data is not None and (body_data or form_data or file_data): + raise ValueError( + "Cannot use Raw with Body, Form or File. " + "Raw is a standalone raw request body." + ) + if body_data and (form_data or file_data): + raise ValueError( + "Cannot use Body with Form or File. " + "Use Form for fields in multipart requests." + ) + url = self.__url__.format(**path_data) return HTTPRequest( @@ -72,6 +68,47 @@ def build_http_request(self, request_dumper: RequestDumper) -> HTTPRequest: raw=raw_data, ) + def on_error(self, response: HTTPResponse) -> None: + """Handle HTTP status errors for this specific method. + + Override to provide custom error handling for this endpoint. + Called when response.ok is False. + + Args: + response: The HTTP response with error status. For `StreamMethod`, + only `status_code`/`headers` are available — the body is + never buffered for a streamed response. + + Raises: + Exception: propagate immediately + """ + + +@dataclass +class BaseMethod[ResponseType](RequestMethod): + """Base class for API methods with a buffered, deserialized response. + + Subclasses represent specific API endpoints. + Type parameter `ResponseType` specifies the expected return type. + + Attributes: + __returning__: The type class of the response (automatically extracted + from generic type). + """ + + __returning__: ClassVar[type] + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + + for base in get_original_bases(cls): + origin = getattr(base, "__origin__", None) + + if origin is not None and issubclass(origin, BaseMethod): + if args := get_args(base): + cls.__returning__ = args[0] + break + def make_response( self, response: HTTPResponse, @@ -101,15 +138,13 @@ def validate_response(self, response: HTTPResponse) -> None: Exception: if response body indicates an error """ - def on_error(self, response: HTTPResponse) -> None: - """Handle HTTP status errors for this specific method. - Override to provide custom error handling for this endpoint. - Called when response.ok is False. - - Args: - response: The HTTP response with error status. +@dataclass +class StreamMethod(RequestMethod): + """Base class for defining streamed-response API methods. - Raises: - Exception: propagate immediately - """ + Subclasses represent endpoints whose response body is read incrementally + (e.g. file downloads) rather than buffered and parsed. There is no + `response_loader`/`make_response` step here: the body is never fully + read, so there is nothing to deserialize. + """ diff --git a/src/unihttp/serializers/adaptix/provider.py b/src/unihttp/serializers/adaptix/provider.py index 79dab98..d18cf32 100644 --- a/src/unihttp/serializers/adaptix/provider.py +++ b/src/unihttp/serializers/adaptix/provider.py @@ -1,6 +1,4 @@ -from typing import Any - -from unihttp.method import BaseMethod +from unihttp.method import RequestMethod from adaptix import Provider, bound from adaptix._internal.morphing.name_layout.component import ( @@ -28,11 +26,11 @@ def __init__( def method_provider( - method_tp: type[BaseMethod[Any]] | None = None, + method_tp: type[RequestMethod] | None = None, marker_path_maker: MarkerFieldPathMaker | None = None, ) -> Provider: if method_tp is None: - method_tp = BaseMethod + method_tp = RequestMethod if marker_path_maker is None: marker_path_maker = DefaultMarkerFieldPathMaker() From 8e335ed4b7996ee20058d0c754929b458ecfbcfb Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:57:05 +0300 Subject: [PATCH 04/12] fix: use raw.status instead of deprecated getcode() in urllib client --- src/unihttp/clients/urllib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/unihttp/clients/urllib.py b/src/unihttp/clients/urllib.py index 5abeb82..a254d87 100644 --- a/src/unihttp/clients/urllib.py +++ b/src/unihttp/clients/urllib.py @@ -196,7 +196,7 @@ def make_request(self, request: HTTPRequest) -> HTTPResponse: response_data = content return HTTPResponse( - status_code=raw.getcode(), + status_code=raw.status or 0, headers=dict(raw.headers.items()), cookies=self._extract_cookies(raw.headers), data=response_data, @@ -209,7 +209,7 @@ def stream_make_request( raw = self._do_request(request) return HTTPResponse( - status_code=raw.getcode(), + status_code=raw.status or 0, headers=dict(raw.headers.items()), cookies=self._extract_cookies(raw.headers), data=_UrllibChunkStream(raw, chunk_size), From 8d2f08e4a523a79c745cb74a090a2ed3b6d51783 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:30:10 +0300 Subject: [PATCH 05/12] fix: translate mid-stream connection errors in all stream clients --- src/unihttp/clients/aiohttp.py | 7 +++- src/unihttp/clients/httpx.py | 51 ++++++++++++++++++++++------ src/unihttp/clients/httpx2.py | 51 ++++++++++++++++++++++------ src/unihttp/clients/niquests.py | 60 +++++++++++++++++++++++++++------ src/unihttp/clients/requests.py | 24 ++++++++++--- src/unihttp/clients/urllib.py | 7 +++- src/unihttp/clients/zapros.py | 51 ++++++++++++++++++++++------ 7 files changed, 205 insertions(+), 46 deletions(-) diff --git a/src/unihttp/clients/aiohttp.py b/src/unihttp/clients/aiohttp.py index 2f4b312..23e5f37 100644 --- a/src/unihttp/clients/aiohttp.py +++ b/src/unihttp/clients/aiohttp.py @@ -23,7 +23,12 @@ def __init__(self, response: ClientResponse, chunk_size: int) -> None: self._iter = response.content.iter_chunked(chunk_size) async def _fetch_chunk(self) -> bytes: - return await anext(self._iter) + try: + return await anext(self._iter) + except aiohttp.ClientConnectionError as e: + raise NetworkError(str(e)) from e + except TimeoutError as e: + raise RequestTimeoutError(str(e)) from e async def _close_response(self) -> None: self._response.close() diff --git a/src/unihttp/clients/httpx.py b/src/unihttp/clients/httpx.py index c01fd4b..21836c3 100644 --- a/src/unihttp/clients/httpx.py +++ b/src/unihttp/clients/httpx.py @@ -11,16 +11,49 @@ from unihttp.http import UploadFile from unihttp.http.request import HTTPRequest from unihttp.http.response import HTTPResponse -from unihttp.http.stream import ( - AsyncChunkStream, - AsyncIteratorChunkStream, - ChunkStream, - IteratorChunkStream, -) +from unihttp.http.stream import AsyncChunkStream, ChunkStream from unihttp.middlewares.base import AsyncMiddleware, Middleware from unihttp.serialize import RequestDumper, ResponseLoader +class _HTTPXChunkStream(ChunkStream): + def __init__(self, response: Response, chunk_size: int) -> None: + super().__init__() + self._response = response + self._iter = response.iter_bytes(chunk_size) + + def _fetch_chunk(self) -> bytes: + try: + return next(self._iter) + except httpx.NetworkError as e: + raise NetworkError(str(e)) from e + except httpx.TimeoutException as e: + raise RequestTimeoutError(str(e)) from e + + def _close_response(self) -> None: + self._response.close() + + +class _HTTPXAsyncChunkStream(AsyncChunkStream): + """Async counterpart of `_HTTPXChunkStream`.""" + + def __init__(self, response: Response, chunk_size: int) -> None: + super().__init__() + self._response = response + self._iter = response.aiter_bytes(chunk_size) + + async def _fetch_chunk(self) -> bytes: + try: + return await anext(self._iter) + except httpx.NetworkError as e: + raise NetworkError(str(e)) from e + except httpx.TimeoutException as e: + raise RequestTimeoutError(str(e)) from e + + async def _close_response(self) -> None: + await self._response.aclose() + + class HTTPXSyncClient(BaseSyncClient): """Synchronous client implementation using the `httpx` library.""" @@ -122,7 +155,7 @@ def stream_make_request( status_code=response.status_code, headers=response.headers, cookies=response.cookies, - data=IteratorChunkStream(response.iter_bytes(chunk_size), response.close), + data=_HTTPXChunkStream(response, chunk_size), raw_response=response, ) @@ -231,9 +264,7 @@ async def stream_make_request( status_code=response.status_code, headers=response.headers, cookies=response.cookies, - data=AsyncIteratorChunkStream( - response.aiter_bytes(chunk_size), response.aclose - ), + data=_HTTPXAsyncChunkStream(response, chunk_size), raw_response=response, ) diff --git a/src/unihttp/clients/httpx2.py b/src/unihttp/clients/httpx2.py index 251e6f1..14a2f5b 100644 --- a/src/unihttp/clients/httpx2.py +++ b/src/unihttp/clients/httpx2.py @@ -11,16 +11,49 @@ from unihttp.http import UploadFile from unihttp.http.request import HTTPRequest from unihttp.http.response import HTTPResponse -from unihttp.http.stream import ( - AsyncChunkStream, - AsyncIteratorChunkStream, - ChunkStream, - IteratorChunkStream, -) +from unihttp.http.stream import AsyncChunkStream, ChunkStream from unihttp.middlewares.base import AsyncMiddleware, Middleware from unihttp.serialize import RequestDumper, ResponseLoader +class _HTTPX2ChunkStream(ChunkStream): + def __init__(self, response: Response, chunk_size: int) -> None: + super().__init__() + self._response = response + self._iter = response.iter_bytes(chunk_size) + + def _fetch_chunk(self) -> bytes: + try: + return next(self._iter) + except httpx2.NetworkError as e: + raise NetworkError(str(e)) from e + except httpx2.TimeoutException as e: + raise RequestTimeoutError(str(e)) from e + + def _close_response(self) -> None: + self._response.close() + + +class _HTTPX2AsyncChunkStream(AsyncChunkStream): + """Async counterpart of `_HTTPX2ChunkStream`.""" + + def __init__(self, response: Response, chunk_size: int) -> None: + super().__init__() + self._response = response + self._iter = response.aiter_bytes(chunk_size) + + async def _fetch_chunk(self) -> bytes: + try: + return await anext(self._iter) + except httpx2.NetworkError as e: + raise NetworkError(str(e)) from e + except httpx2.TimeoutException as e: + raise RequestTimeoutError(str(e)) from e + + async def _close_response(self) -> None: + await self._response.aclose() + + class HTTPX2SyncClient(BaseSyncClient): """Synchronous client implementation using the `httpx2` library.""" @@ -122,7 +155,7 @@ def stream_make_request( status_code=response.status_code, headers=response.headers, cookies=response.cookies, - data=IteratorChunkStream(response.iter_bytes(chunk_size), response.close), + data=_HTTPX2ChunkStream(response, chunk_size), raw_response=response, ) @@ -231,9 +264,7 @@ async def stream_make_request( status_code=response.status_code, headers=response.headers, cookies=response.cookies, - data=AsyncIteratorChunkStream( - response.aiter_bytes(chunk_size), response.aclose - ), + data=_HTTPX2AsyncChunkStream(response, chunk_size), raw_response=response, ) diff --git a/src/unihttp/clients/niquests.py b/src/unihttp/clients/niquests.py index 52a155a..315b308 100644 --- a/src/unihttp/clients/niquests.py +++ b/src/unihttp/clients/niquests.py @@ -11,16 +11,58 @@ from unihttp.http import UploadFile from unihttp.http.request import HTTPRequest from unihttp.http.response import HTTPResponse -from unihttp.http.stream import ( - AsyncChunkStream, - AsyncIteratorChunkStream, - ChunkStream, - IteratorChunkStream, -) +from unihttp.http.stream import AsyncChunkStream, ChunkStream from unihttp.middlewares.base import AsyncMiddleware, Middleware from unihttp.serialize import RequestDumper, ResponseLoader +class _NiquestsChunkStream(ChunkStream): + def __init__(self, response: Response, chunk_size: int) -> None: + super().__init__() + self._response = response + self._iter = response.iter_content(chunk_size=chunk_size) + + def _fetch_chunk(self) -> bytes: + try: + return next(self._iter) + except niquests.exceptions.ConnectionError as e: + raise NetworkError(str(e)) from e + except niquests.exceptions.Timeout as e: + raise RequestTimeoutError(str(e)) from e + except niquests.exceptions.RequestException as e: + raise NetworkError(str(e)) from e + + def _close_response(self) -> None: + self._response.close() + + +class _NiquestsAsyncChunkStream(AsyncChunkStream): + """Async counterpart of `_NiquestsChunkStream`. + + Unlike the other backends, the chunk iterator itself must be awaited to + obtain (see `NiquestsAsyncClient.stream_make_request`), so it's passed + in already built rather than constructed here. + """ + + def __init__(self, response: AsyncResponse, chunk_iter: Any) -> None: + super().__init__() + self._response = response + self._iter = chunk_iter + + async def _fetch_chunk(self) -> bytes: + try: + return await anext(self._iter) + except niquests.exceptions.ConnectionError as e: + raise NetworkError(str(e)) from e + except niquests.exceptions.Timeout as e: + raise RequestTimeoutError(str(e)) from e + except niquests.exceptions.RequestException as e: + raise NetworkError(str(e)) from e + + async def _close_response(self) -> None: + await self._response.close() + + class NiquestsSyncClient(BaseSyncClient): """Synchronous client implementation using the `niquests` library.""" @@ -134,9 +176,7 @@ def stream_make_request( status_code=response.status_code or 0, headers=dict(response.headers), cookies=cast(Mapping[str, Any], response.cookies), - data=IteratorChunkStream( - response.iter_content(chunk_size=chunk_size), response.close - ), + data=_NiquestsChunkStream(response, chunk_size), raw_response=response, ) @@ -278,7 +318,7 @@ async def stream_make_request( status_code=response.status_code or 0, headers=dict(response.headers), cookies=cast(Mapping[str, Any], response.cookies), - data=AsyncIteratorChunkStream(chunk_iter, response.close), + data=_NiquestsAsyncChunkStream(response, chunk_iter), raw_response=response, ) diff --git a/src/unihttp/clients/requests.py b/src/unihttp/clients/requests.py index 8512ced..af2d318 100644 --- a/src/unihttp/clients/requests.py +++ b/src/unihttp/clients/requests.py @@ -8,11 +8,29 @@ from unihttp.exceptions import NetworkError, RequestTimeoutError from unihttp.http.request import HTTPRequest from unihttp.http.response import HTTPResponse -from unihttp.http.stream import ChunkStream, IteratorChunkStream +from unihttp.http.stream import ChunkStream from unihttp.middlewares.base import Middleware from unihttp.serialize import RequestDumper, ResponseLoader +class _RequestsChunkStream(ChunkStream): + def __init__(self, response: Response, chunk_size: int) -> None: + super().__init__() + self._response = response + self._iter = response.iter_content(chunk_size=chunk_size) + + def _fetch_chunk(self) -> bytes: + try: + return next(self._iter) + except requests.exceptions.ConnectionError as e: + raise NetworkError(str(e)) from e + except requests.exceptions.Timeout as e: + raise RequestTimeoutError(str(e)) from e + + def _close_response(self) -> None: + self._response.close() + + class RequestsSyncClient(BaseSyncClient): def __init__( self, @@ -92,9 +110,7 @@ def stream_make_request( status_code=response.status_code, headers=response.headers, cookies=response.cookies, - data=IteratorChunkStream( - response.iter_content(chunk_size=chunk_size), response.close - ), + data=_RequestsChunkStream(response, chunk_size), raw_response=response, ) diff --git a/src/unihttp/clients/urllib.py b/src/unihttp/clients/urllib.py index a254d87..dd3a46c 100644 --- a/src/unihttp/clients/urllib.py +++ b/src/unihttp/clients/urllib.py @@ -27,7 +27,12 @@ def __init__(self, raw: _UrllibResponse, chunk_size: int) -> None: self._chunk_size = chunk_size def _fetch_chunk(self) -> bytes: - chunk = self._raw.read(self._chunk_size) + try: + chunk = self._raw.read(self._chunk_size) + except TimeoutError as e: + raise RequestTimeoutError(str(e)) from e + except (OSError, http.client.HTTPException) as e: + raise NetworkError(str(e)) from e if not chunk: raise StopIteration return chunk diff --git a/src/unihttp/clients/zapros.py b/src/unihttp/clients/zapros.py index bdbc3cd..2d15111 100644 --- a/src/unihttp/clients/zapros.py +++ b/src/unihttp/clients/zapros.py @@ -13,16 +13,49 @@ from unihttp.http import UploadFile from unihttp.http.request import HTTPRequest from unihttp.http.response import HTTPResponse -from unihttp.http.stream import ( - AsyncChunkStream, - AsyncIteratorChunkStream, - ChunkStream, - IteratorChunkStream, -) +from unihttp.http.stream import AsyncChunkStream, ChunkStream from unihttp.middlewares.base import AsyncMiddleware, Middleware from unihttp.serialize import RequestDumper, ResponseLoader +class _ZaprosChunkStream(ChunkStream): + def __init__(self, response: Any, chunk_size: int, stack: ExitStack) -> None: + super().__init__() + self._iter = response.iter_bytes(chunk_size) + self._stack = stack + + def _fetch_chunk(self) -> bytes: + try: + return next(self._iter) + except zapros.TimeoutError as e: + raise RequestTimeoutError(str(e)) from e + except zapros.ConnectionError as e: + raise NetworkError(str(e)) from e + + def _close_response(self) -> None: + self._stack.close() + + +class _ZaprosAsyncChunkStream(AsyncChunkStream): + """Async counterpart of `_ZaprosChunkStream`.""" + + def __init__(self, response: Any, chunk_size: int, stack: AsyncExitStack) -> None: + super().__init__() + self._iter = response.async_iter_bytes(chunk_size) + self._stack = stack + + async def _fetch_chunk(self) -> bytes: + try: + return await anext(self._iter) + except zapros.TimeoutError as e: + raise RequestTimeoutError(str(e)) from e + except zapros.ConnectionError as e: + raise NetworkError(str(e)) from e + + async def _close_response(self) -> None: + await self._stack.aclose() + + def _stringify_pairs(mapping: Mapping[str, Any]) -> list[tuple[str, str]]: """Flatten a mapping into ``[(key, str_value), ...]`` pairs. @@ -224,7 +257,7 @@ def stream_make_request( status_code=response.status, headers=response.headers, cookies={}, - data=IteratorChunkStream(response.iter_bytes(chunk_size), stack.close), + data=_ZaprosChunkStream(response, chunk_size, stack), raw_response=response, ) @@ -346,9 +379,7 @@ async def stream_make_request( status_code=response.status, headers=response.headers, cookies={}, - data=AsyncIteratorChunkStream( - response.async_iter_bytes(chunk_size), stack.aclose - ), + data=_ZaprosAsyncChunkStream(response, chunk_size, stack), raw_response=response, ) From c93f4166a9128e76397652a37ca801eea1453e75 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:01:58 +0300 Subject: [PATCH 06/12] feat: move chunk_size onto StreamMethod and route bind_method through call_method_stream --- src/unihttp/bind_method.py | 60 ++++++++++++++++++++++--------------- src/unihttp/clients/base.py | 22 +++++++------- src/unihttp/method.py | 7 ++++- 3 files changed, 54 insertions(+), 35 deletions(-) diff --git a/src/unihttp/bind_method.py b/src/unihttp/bind_method.py index 04e8f4f..e16a343 100644 --- a/src/unihttp/bind_method.py +++ b/src/unihttp/bind_method.py @@ -1,9 +1,10 @@ import functools import inspect from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any, Generic, ParamSpec, TypeVar, cast, overload +from typing import TYPE_CHECKING, Any, Generic, ParamSpec, TypeVar, overload -from unihttp.method import BaseMethod +from unihttp.http.stream import AsyncChunkStream, ChunkStream +from unihttp.method import BaseMethod, StreamMethod if TYPE_CHECKING: from unihttp.clients.base import BaseAsyncClient, BaseSyncClient @@ -11,37 +12,41 @@ MethodParamSpec = ParamSpec("MethodParamSpec") MethodResultT = TypeVar("MethodResultT") +SyncResultT = TypeVar("SyncResultT") +AsyncResultT = TypeVar("AsyncResultT") -class MethodBinder(Generic[MethodParamSpec, MethodResultT]): # noqa: UP046 - __slots__ = ("_method_tp",) +class MethodBinder(Generic[MethodParamSpec, SyncResultT, AsyncResultT]): # noqa: UP046 + __slots__ = ("_call_name", "_method_tp") def __init__( self, - method_tp: Callable[MethodParamSpec, BaseMethod[MethodResultT]], + method_tp: Callable[MethodParamSpec, Any], + call_name: str, ) -> None: self._method_tp = method_tp + self._call_name = call_name @overload def __get__( self, instance: None, owner: type, - ) -> "MethodBinder[MethodParamSpec, MethodResultT]": ... + ) -> "MethodBinder[MethodParamSpec, SyncResultT, AsyncResultT]": ... @overload def __get__( self, instance: "BaseSyncClient", owner: type, - ) -> Callable[MethodParamSpec, MethodResultT]: ... + ) -> Callable[MethodParamSpec, SyncResultT]: ... @overload def __get__( self, instance: "BaseAsyncClient", owner: type, - ) -> Callable[MethodParamSpec, Awaitable[MethodResultT]]: ... + ) -> Callable[MethodParamSpec, Awaitable[AsyncResultT]]: ... def __get__( self, @@ -51,25 +56,23 @@ def __get__( if instance is None: return self - if not hasattr(instance, "call_method"): + call_name = self._call_name + if not hasattr(instance, call_name): raise RuntimeError( - "`bind_method` is available only for classes with `call_method`", + f"`bind_method` is available only for classes with `{call_name}`", ) - call_method = instance.call_method + call = getattr(instance, call_name) method_tp = self._method_tp - if inspect.iscoroutinefunction(call_method): + if inspect.iscoroutinefunction(call): @functools.wraps(method_tp) async def async_wrapper( *args: MethodParamSpec.args, **kwargs: MethodParamSpec.kwargs, - ) -> MethodResultT: - return cast( - MethodResultT, - await call_method(method_tp(*args, **kwargs)), - ) + ) -> Any: + return await call(method_tp(*args, **kwargs)) return async_wrapper @@ -77,16 +80,25 @@ async def async_wrapper( def sync_wrapper( *args: MethodParamSpec.args, **kwargs: MethodParamSpec.kwargs, - ) -> MethodResultT: - return cast( - MethodResultT, - call_method(method_tp(*args, **kwargs)), - ) + ) -> Any: + return call(method_tp(*args, **kwargs)) return sync_wrapper +@overload def bind_method( # noqa: UP047 method_tp: Callable[MethodParamSpec, BaseMethod[MethodResultT]], -) -> MethodBinder[MethodParamSpec, MethodResultT]: - return MethodBinder(method_tp) +) -> MethodBinder[MethodParamSpec, MethodResultT, MethodResultT]: ... + + +@overload +def bind_method( # noqa: UP047 + method_tp: Callable[MethodParamSpec, StreamMethod], +) -> MethodBinder[MethodParamSpec, ChunkStream, AsyncChunkStream]: ... + + +def bind_method(method_tp: Callable[..., Any]) -> Any: + if isinstance(method_tp, type) and issubclass(method_tp, StreamMethod): + return MethodBinder(method_tp, "call_method_stream") + return MethodBinder(method_tp, "call_method") diff --git a/src/unihttp/clients/base.py b/src/unihttp/clients/base.py index 6d826a4..75ba6a5 100644 --- a/src/unihttp/clients/base.py +++ b/src/unihttp/clients/base.py @@ -160,9 +160,7 @@ def stream_make_request( """ raise NotImplementedError - def call_method_stream( - self, method: StreamMethod, chunk_size: int = 65536 - ) -> ChunkStream: + def call_method_stream(self, method: StreamMethod) -> ChunkStream: """Execute a streaming API method synchronously. Pipeline mirrors `call_method`, but the terminal handler streams the @@ -170,7 +168,8 @@ def call_method_stream( Args: method: The stream method instance to execute. - chunk_size: Number of bytes to read per chunk. + `method.__chunk_size__` controls how many bytes are read per + chunk. Returns: A `ChunkStream` of `bytes` chunks. Use as a context manager @@ -181,7 +180,9 @@ def call_method_stream( request = method.build_http_request(request_dumper=self.request_dumper) def _send(request_: HTTPRequest) -> HTTPResponse[ChunkStream]: - response_ = self.stream_make_request(request_, chunk_size=chunk_size) + response_ = self.stream_make_request( + request_, chunk_size=method.__chunk_size__ + ) if not response_.ok: # ChunkStream.close() is a direct call, not tied to whether @@ -295,9 +296,7 @@ async def stream_make_request( """ raise NotImplementedError - async def call_method_stream( - self, method: StreamMethod, chunk_size: int = 65536 - ) -> AsyncChunkStream: + async def call_method_stream(self, method: StreamMethod) -> AsyncChunkStream: """Execute a streaming API method asynchronously. Pipeline mirrors `call_method`, but the terminal handler streams the @@ -305,7 +304,8 @@ async def call_method_stream( Args: method: The stream method instance to execute. - chunk_size: Number of bytes to read per chunk. + `method.__chunk_size__` controls how many bytes are read per + chunk. Returns: An `AsyncChunkStream` of `bytes` chunks. Use as a context @@ -316,7 +316,9 @@ async def call_method_stream( request = method.build_http_request(request_dumper=self.request_dumper) async def _send(request_: HTTPRequest) -> HTTPResponse[AsyncChunkStream]: - response_ = await self.stream_make_request(request_, chunk_size=chunk_size) + response_ = await self.stream_make_request( + request_, chunk_size=method.__chunk_size__ + ) if not response_.ok: # AsyncChunkStream.aclose() is a direct call, not tied to diff --git a/src/unihttp/method.py b/src/unihttp/method.py index 510843d..e081893 100644 --- a/src/unihttp/method.py +++ b/src/unihttp/method.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from types import get_original_bases from typing import Any, ClassVar, TypeVar, get_args @@ -147,4 +147,9 @@ class StreamMethod(RequestMethod): (e.g. file downloads) rather than buffered and parsed. There is no `response_loader`/`make_response` step here: the body is never fully read, so there is nothing to deserialize. + + Attributes: + __chunk_size__: Number of bytes to read per chunk. """ + + __chunk_size__: int = field(default=65536, kw_only=True) From 0ef5b478a0126d84dcb2134f9b2dc2081174694d Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:12:42 +0300 Subject: [PATCH 07/12] feat: add tests --- tests/server.py | 22 ++ tests/test_clients/test_aiohttp.py | 179 +++++++++++++---- tests/test_clients/test_base.py | 188 +++++++++++++++++- tests/test_clients/test_httpx.py | 136 ++++++++++--- tests/test_clients/test_httpx2.py | 137 +++++++++---- tests/test_clients/test_httpx2_sync.py | 121 ++++++++--- tests/test_clients/test_httpx_sync.py | 123 +++++++++--- tests/test_clients/test_json_load_errors.py | 18 +- tests/test_clients/test_niquests.py | 130 ++++++++++-- tests/test_clients/test_requests.py | 102 +++++++--- tests/test_clients/test_urllib.py | 125 +++++++++--- tests/test_clients/test_zapros.py | 180 +++++++++++++++-- tests/test_core/test_bind.py | 39 +++- tests/test_core/test_markers.py | 8 + tests/test_core/test_method.py | 85 +++++++- tests/test_features/test_forms_and_json.py | 30 +-- .../test_adaptix_for_marker.py | 19 +- tests/test_integration/test_streaming.py | 114 +++++++++++ 18 files changed, 1463 insertions(+), 293 deletions(-) create mode 100644 tests/test_integration/test_streaming.py diff --git a/tests/server.py b/tests/server.py index 8a22da7..5a439ad 100644 --- a/tests/server.py +++ b/tests/server.py @@ -41,6 +41,28 @@ async def sleep_handler(request): return web.json_response({"slept": seconds}) +async def _write_stream(request, total_bytes, status): + response = web.StreamResponse(status=status) + await response.prepare(request) + written = 0 + while written < total_bytes: + piece = min(4096, total_bytes - written) + await response.write(b"x" * piece) + written += piece + await response.write_eof() + return response + + +@routes.get("/stream/{total_bytes}") +async def stream_handler(request): + return await _write_stream(request, int(request.match_info["total_bytes"]), 200) + + +@routes.get("/stream-error/{total_bytes}") +async def stream_error_handler(request): + return await _write_stream(request, int(request.match_info["total_bytes"]), 500) + + async def make_app(): app = web.Application() app.add_routes(routes) diff --git a/tests/test_clients/test_aiohttp.py b/tests/test_clients/test_aiohttp.py index 8666549..10a61c1 100644 --- a/tests/test_clients/test_aiohttp.py +++ b/tests/test_clients/test_aiohttp.py @@ -2,7 +2,7 @@ import aiohttp import pytest -from unihttp.clients.aiohttp import AiohttpAsyncClient +from unihttp.clients.aiohttp import AiohttpAsyncClient, _AiohttpChunkStream from unihttp.exceptions import NetworkError, RequestTimeoutError from unihttp.http.request import HTTPRequest @@ -28,10 +28,7 @@ async def test_aiohttp_make_request(mock_request_dumper, mock_response_loader, m mock_response.cookies = {} mock_response.json.return_value = {"key": "value"} mock_response.read.return_value = b'{"key": "value"}' - mock_response.__aenter__.return_value = mock_response - mock_response.__aexit__.return_value = None - - mock_session.request.return_value = mock_response + mock_session.request = AsyncMock(return_value=mock_response) request = HTTPRequest( url="/test", @@ -113,9 +110,7 @@ async def test_aiohttp_upload_file(mock_request_dumper, mock_response_loader, mo mock_response.cookies = {} mock_response.json.return_value = {} mock_response.read.return_value = b"{}" - mock_response.__aenter__.return_value = mock_response - mock_response.__aexit__.return_value = None - mock_session.request.return_value = mock_response + mock_session.request = AsyncMock(return_value=mock_response) request = HTTPRequest( url="/upload", @@ -160,9 +155,7 @@ async def test_aiohttp_upload_complex(mock_request_dumper, mock_response_loader, mock_response.cookies = {} mock_response.json.return_value = {} mock_response.read.return_value = b"{}" - mock_response.__aenter__.return_value = mock_response - mock_response.__aexit__.return_value = None - mock_session.request.return_value = mock_response + mock_session.request = AsyncMock(return_value=mock_response) request = HTTPRequest("/upload", "POST", {}, {}, {}, {}, file={"f1": ("f.txt", b"data", "text/plain")}, @@ -174,29 +167,6 @@ async def test_aiohttp_upload_complex(mock_request_dumper, mock_response_loader, assert any(c[0][0] == "f1" and c[0][1] == b"data" and c[1].get("content_type") == "text/plain" for c in calls) -@pytest.mark.asyncio -async def test_aiohttp_body_and_form_error(mock_request_dumper, mock_response_loader, mock_session): - client = AiohttpAsyncClient( - base_url="http://base", - request_dumper=mock_request_dumper, - response_loader=mock_response_loader, - session=mock_session - ) - - request = HTTPRequest( - url="/test", - method="POST", - header={}, - path={}, - query={}, - body={"some": "body"}, - file={}, - form={"some": "form"} - ) - - with pytest.raises(ValueError, match="Cannot use Body with Form or File"): - await client.make_request(request) - @pytest.mark.asyncio async def test_aiohttp_upload_file_object(mock_request_dumper, mock_response_loader, mock_session): @@ -214,9 +184,7 @@ async def test_aiohttp_upload_file_object(mock_request_dumper, mock_response_loa mock_response.cookies = {} mock_response.json.return_value = {} mock_response.read.return_value = b"{}" - mock_response.__aenter__.return_value = mock_response - mock_response.__aexit__.return_value = None - mock_session.request.return_value = mock_response + mock_session.request = AsyncMock(return_value=mock_response) request = HTTPRequest( url="/upload", @@ -240,3 +208,140 @@ async def test_aiohttp_upload_file_object(mock_request_dumper, mock_response_loa c[1].get("content_type") == "text/plain" for c in calls ) + + +@pytest.mark.asyncio +async def test_aiohttp_raw_body(mock_request_dumper, mock_response_loader, mock_session): + client = AiohttpAsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_session) + + mock_response = AsyncMock() + mock_response.status = 200 + mock_response.headers = {} + mock_response.cookies = {} + mock_response.read.return_value = b"{}" + mock_session.request = AsyncMock(return_value=mock_response) + + request = HTTPRequest( + url="/raw", method="POST", header={}, path={}, query={}, + body={}, file={}, form={}, raw=b"raw-payload" + ) + + await client.make_request(request) + + call_kwargs = mock_session.request.call_args.kwargs + assert call_kwargs["data"] == b"raw-payload" + + +@pytest.mark.asyncio +async def test_aiohttp_stream_make_request(mock_request_dumper, mock_response_loader, mock_session): + class _FakeContent: + def iter_chunked(self, chunk_size): + async def gen(): + yield b"a" + yield b"b" + return gen() + + response = MagicMock() + response.status = 200 + response.headers = {} + response.cookies = {} + response.content = _FakeContent() + response.close = MagicMock() + mock_session.request = AsyncMock(return_value=response) + + client = AiohttpAsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_session) + + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + + result = await client.stream_make_request(request, chunk_size=999) + + assert result.status_code == 200 + chunks = [chunk async for chunk in result.data] + assert chunks == [b"a", b"b"] + response.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_aiohttp_chunk_stream_closes_without_ever_reading(): + class _FakeContent: + def iter_chunked(self, chunk_size): + async def gen(): + yield b"a" + yield b"b" + return gen() + + response = MagicMock() + response.content = _FakeContent() + response.close = MagicMock() + + stream = _AiohttpChunkStream(response, chunk_size=999) + await stream.aclose() + + response.close.assert_called_once() + + # Idempotent: closing again, or iterating after close, must not reopen + # or re-close the connection. + await stream.aclose() + assert [chunk async for chunk in stream] == [] + response.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_aiohttp_chunk_stream_early_break_closes_once(): + class _FakeContent: + def iter_chunked(self, chunk_size): + async def gen(): + yield b"a" + yield b"b" + return gen() + + response = MagicMock() + response.content = _FakeContent() + response.close = MagicMock() + + stream = _AiohttpChunkStream(response, chunk_size=999) + async for chunk in stream: + assert chunk == b"a" + break + await stream.aclose() + + response.close.assert_called_once() + + +@pytest.mark.asyncio +async def test_aiohttp_chunk_stream_mid_stream_connection_error_translated(): + class _FakeContent: + def iter_chunked(self, chunk_size): + async def gen(): + yield b"a" + raise aiohttp.ClientConnectionError("connection lost") + return gen() + + response = MagicMock() + response.content = _FakeContent() + + stream = _AiohttpChunkStream(response, chunk_size=999) + assert await anext(stream) == b"a" + with pytest.raises(NetworkError): + await anext(stream) + + +@pytest.mark.asyncio +async def test_aiohttp_chunk_stream_mid_stream_timeout_translated(): + class _FakeContent: + def iter_chunked(self, chunk_size): + async def gen(): + yield b"a" + raise TimeoutError("timed out") + return gen() + + response = MagicMock() + response.content = _FakeContent() + + stream = _AiohttpChunkStream(response, chunk_size=999) + assert await anext(stream) == b"a" + with pytest.raises(RequestTimeoutError): + await anext(stream) diff --git a/tests/test_clients/test_base.py b/tests/test_clients/test_base.py index c44213f..39b2db5 100644 --- a/tests/test_clients/test_base.py +++ b/tests/test_clients/test_base.py @@ -4,7 +4,8 @@ from unihttp.clients.base import BaseAsyncClient, BaseSyncClient from unihttp.http.request import HTTPRequest from unihttp.http.response import HTTPResponse -from unihttp.method import BaseMethod +from unihttp.http.stream import AsyncChunkStream, ChunkStream +from unihttp.method import BaseMethod, StreamMethod from unihttp.middlewares.base import AsyncMiddleware, Middleware @@ -13,6 +14,39 @@ class SimpleMethod(BaseMethod[str]): __method__ = "GET" +class _FakeStreamMethod(StreamMethod): + __url__ = "/files/{id}" + __method__ = "GET" + + +class _GenChunkStream(ChunkStream): + """Test double wiring a plain generator into the ChunkStream contract.""" + + def __init__(self, gen, on_close): + super().__init__() + self._gen = gen + self._on_close = on_close + + def _fetch_chunk(self): + return next(self._gen) + + def _close_response(self): + self._on_close() + + +class StreamClient(BaseSyncClient): + def make_request(self, request): + raise NotImplementedError + + def stream_make_request(self, request, chunk_size=65536): + def gen(): + yield b"chunk1" + yield b"chunk2" + + self.closed = False + self.last_request = request + return HTTPResponse(200, {}, _GenChunkStream(gen(), lambda: setattr(self, "closed", True)), {}, None) + class TestSyncClient: class MockClient(BaseSyncClient): def make_request(self, request: HTTPRequest) -> HTTPResponse: @@ -84,6 +118,64 @@ def handle_error(self, response, method): # Verify it still proceeded to load response since no exception was raised assert result == "proceeded" + def test_call_method_stream_full_consumption(self, mock_request_dumper, mock_response_loader): + client = StreamClient("http://base", mock_request_dumper, mock_response_loader) + mock_request_dumper.dump.return_value = {"path": {"id": "1"}} + + chunks = [] + with client.call_method_stream(_FakeStreamMethod()) as stream: + for chunk in stream: + chunks.append(chunk) + + assert chunks == [b"chunk1", b"chunk2"] + assert client.closed is True + + def test_call_method_stream_early_break_still_closes(self, mock_request_dumper, mock_response_loader): + client = StreamClient("http://base", mock_request_dumper, mock_response_loader) + mock_request_dumper.dump.return_value = {"path": {"id": "1"}} + + with client.call_method_stream(_FakeStreamMethod()) as stream: + for _chunk in stream: + break + + assert client.closed is True + + def test_call_method_stream_status_not_ok_calls_on_error(self, mock_request_dumper, mock_response_loader): + calls = [] + + class _StreamMethodWithOnError(StreamMethod): + __url__ = "/files/{id}" + __method__ = "GET" + + def on_error(self, response): + calls.append(response.status_code) + + class _ErrorClient(StreamClient): + def stream_make_request(self, request, chunk_size=65536): + self.closed = False + return HTTPResponse( + 404, {}, _GenChunkStream(iter(()), lambda: setattr(self, "closed", True)), {}, None + ) + + def handle_error(self, response, method): + # Never-iterated regression: the caller's `with`/`for` never + # runs at all because `call_method_stream` raises before + # returning, so closing cannot rely on the iterator having + # started. + raise RuntimeError(f"HTTP {response.status_code}") + + client = _ErrorClient("http://base", mock_request_dumper, mock_response_loader) + mock_request_dumper.dump.return_value = {"path": {"id": "1"}} + + with pytest.raises(RuntimeError): + with client.call_method_stream(_StreamMethodWithOnError()) as stream: + list(stream) + + assert calls == [404] + # The connection must be released as soon as `call_method_stream` sees + # the error status, before the caller ever gets a chance to iterate. + assert client.closed is True + @pytest.mark.asyncio class TestAsyncClient: @@ -130,3 +222,97 @@ async def handle(self, request, next_handler): await client.call_method(method) assert order == ["mw1_req", "mw2_req", "mw2_resp", "mw1_resp"] + + class _AsyncGenChunkStream(AsyncChunkStream): + """Test double wiring a plain async generator into the AsyncChunkStream contract.""" + + def __init__(self, gen, on_close): + super().__init__() + self._gen = gen + self._on_close = on_close + + async def _fetch_chunk(self): + return await self._gen.__anext__() + + async def _close_response(self): + self._on_close() + + class StreamClient(BaseAsyncClient): + async def make_request(self, request): + raise NotImplementedError + + async def stream_make_request(self, request, chunk_size=65536): + async def gen(): + yield b"chunk1" + yield b"chunk2" + + self.closed = False + self.last_request = request + return HTTPResponse( + 200, {}, TestAsyncClient._AsyncGenChunkStream(gen(), lambda: setattr(self, "closed", True)), {}, None + ) + + async def test_call_method_stream_full_consumption(self, mock_request_dumper, mock_response_loader): + client = self.StreamClient("http://base", mock_request_dumper, mock_response_loader) + mock_request_dumper.dump.return_value = {"path": {"id": "1"}} + + chunks = [] + async with await client.call_method_stream(_FakeStreamMethod()) as stream: + async for chunk in stream: + chunks.append(chunk) + + assert chunks == [b"chunk1", b"chunk2"] + assert client.closed is True + + async def test_call_method_stream_early_break_still_closes(self, mock_request_dumper, mock_response_loader): + client = self.StreamClient("http://base", mock_request_dumper, mock_response_loader) + mock_request_dumper.dump.return_value = {"path": {"id": "1"}} + + async with await client.call_method_stream(_FakeStreamMethod()) as stream: + async for _chunk in stream: + break + + assert client.closed is True + + async def test_call_method_stream_status_not_ok_calls_on_error(self, mock_request_dumper, mock_response_loader): + calls = [] + + class _StreamMethodWithOnError(StreamMethod): + __url__ = "/files/{id}" + __method__ = "GET" + + def on_error(self, response): + calls.append(response.status_code) + + async def empty_gen(): + return + yield # pragma: no cover - makes this an async generator + + class _ErrorClient(self.StreamClient): + async def stream_make_request(self, request, chunk_size=65536): + self.closed = False + return HTTPResponse( + 404, {}, + TestAsyncClient._AsyncGenChunkStream(empty_gen(), lambda: setattr(self, "closed", True)), + {}, None, + ) + + def handle_error(self, response, method): + # Never-iterated regression: the caller's `async with`/`for` + # never runs at all because `call_method_stream` raises + # before returning, so closing cannot rely on the async + # iterator having started. + raise RuntimeError(f"HTTP {response.status_code}") + + client = _ErrorClient("http://base", mock_request_dumper, mock_response_loader) + mock_request_dumper.dump.return_value = {"path": {"id": "1"}} + + with pytest.raises(RuntimeError): + async with await client.call_method_stream(_StreamMethodWithOnError()) as stream: + async for _chunk in stream: + pass + + assert calls == [404] + # The connection must be released as soon as `call_method_stream` sees + # the error status, before the caller ever gets a chance to iterate. + assert client.closed is True diff --git a/tests/test_clients/test_httpx.py b/tests/test_clients/test_httpx.py index 3ea8406..835f07f 100644 --- a/tests/test_clients/test_httpx.py +++ b/tests/test_clients/test_httpx.py @@ -5,6 +5,7 @@ from unihttp.clients.httpx import HTTPXAsyncClient from unihttp.exceptions import NetworkError, RequestTimeoutError from unihttp.http.request import HTTPRequest +from unihttp.clients.httpx import _HTTPXAsyncChunkStream @pytest.fixture @@ -30,7 +31,9 @@ async def test_httpx_make_request(mock_request_dumper, mock_response_loader, moc mock_response.content = b'{"key": "value"}' mock_response.text = '{"key": "value"}' - mock_client.request.return_value = mock_response + built_request = Mock() + mock_client.build_request.return_value = built_request + mock_client.send.return_value = mock_response request = HTTPRequest( url="/test", @@ -46,7 +49,7 @@ async def test_httpx_make_request(mock_request_dumper, mock_response_loader, moc response = await client.make_request(request) # Verify call arguments - mock_client.request.assert_called_once_with( + mock_client.build_request.assert_called_once_with( method="POST", url="http://base/test", headers={"Auth": "123", "Content-Type": "application/json"}, @@ -55,6 +58,7 @@ async def test_httpx_make_request(mock_request_dumper, mock_response_loader, moc files=None, content='{"data": "abc"}' ) + mock_client.send.assert_called_once_with(built_request, stream=False) # Verify response mapping assert response.status_code == 200 @@ -79,7 +83,7 @@ async def test_httpx_upload_file(mock_request_dumper, mock_response_loader, mock mock_response.status_code = 200 mock_response.content = b'{"status": "ok"}' mock_response.text = '{"status": "ok"}' - mock_client.request.return_value = mock_response + mock_client.send.return_value = mock_response request = HTTPRequest( url="/upload", @@ -94,7 +98,7 @@ async def test_httpx_upload_file(mock_request_dumper, mock_response_loader, mock await client.make_request(request) - mock_client.request.assert_called_once_with( + mock_client.build_request.assert_called_once_with( method="POST", url="http://base/upload", headers={}, @@ -118,7 +122,7 @@ async def test_httpx_close(mock_request_dumper, mock_response_loader, mock_clien @pytest.mark.asyncio async def test_httpx_network_error(mock_request_dumper, mock_response_loader, mock_client): client = HTTPXAsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_client) - mock_client.request.side_effect = httpx.NetworkError("Network error") + mock_client.send.side_effect = httpx.NetworkError("Network error") request = HTTPRequest("GET", "url", {}, {}, {}, {}, {}, {}) @@ -129,7 +133,7 @@ async def test_httpx_network_error(mock_request_dumper, mock_response_loader, mo @pytest.mark.asyncio async def test_httpx_timeout_error(mock_request_dumper, mock_response_loader, mock_client): client = HTTPXAsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_client) - mock_client.request.side_effect = httpx.TimeoutException("Timed out") + mock_client.send.side_effect = httpx.TimeoutException("Timed out") request = HTTPRequest("url", "GET", {}, {}, {}, {}, {}, {}) @@ -137,30 +141,6 @@ async def test_httpx_timeout_error(mock_request_dumper, mock_response_loader, mo await client.make_request(request) -@pytest.mark.asyncio -async def test_httpx_body_and_form_error(mock_request_dumper, mock_response_loader, mock_client): - client = HTTPXAsyncClient( - base_url="http://base", - request_dumper=mock_request_dumper, - response_loader=mock_response_loader, - session=mock_client - ) - - request = HTTPRequest( - url="/test", - method="POST", - header={}, - path={}, - query={}, - body={"some": "body"}, - file=None, - form={"some": "form"} - ) - - with pytest.raises(ValueError, match="Cannot use Body with Form or File"): - await client.make_request(request) - - @pytest.mark.asyncio async def test_httpx_file_list_conversion(mock_request_dumper, mock_response_loader, mock_client): from unihttp.http import UploadFile @@ -177,7 +157,7 @@ async def test_httpx_file_list_conversion(mock_request_dumper, mock_response_loa mock_response.status_code = 200 mock_response.content = b'{}' mock_response.text = '{}' - mock_client.request.return_value = mock_response + mock_client.send.return_value = mock_response request = HTTPRequest( url="/upload", @@ -199,8 +179,8 @@ async def test_httpx_file_list_conversion(mock_request_dumper, mock_response_loa await client.make_request(request) - mock_client.request.assert_called_once() - call_kwargs = mock_client.request.call_args[1] + mock_client.build_request.assert_called_once() + call_kwargs = mock_client.build_request.call_args[1] files = call_kwargs["files"] # Verify order and content @@ -208,3 +188,93 @@ async def test_httpx_file_list_conversion(mock_request_dumper, mock_response_loa assert files[1] == ("files", ("f2.txt", b"content2")) assert files[2] == ("single_upload_file", ("f3.txt", b"content3", "application/octet-stream")) assert files[3] == ("single_tuple", ("f4.txt", b"content4")) + + +@pytest.mark.asyncio +async def test_httpx_async_raw_body_bytes(mock_request_dumper, mock_response_loader, mock_client): + client = HTTPXAsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_client) + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.cookies = {} + mock_response.content = b"" + mock_client.send.return_value = mock_response + + request = HTTPRequest( + url="/raw", method="POST", header={}, path={}, query={}, + body={}, file={}, form={}, raw="raw-string" + ) + + await client.make_request(request) + + call_kwargs = mock_client.build_request.call_args.kwargs + assert call_kwargs["content"] == "raw-string" + + +@pytest.mark.asyncio +async def test_httpx_async_stream_make_request(mock_request_dumper, mock_response_loader): + from unittest.mock import AsyncMock, MagicMock + + session = MagicMock(spec=httpx.AsyncClient) + built_request = MagicMock() + session.build_request.return_value = built_request + + async def aiter_bytes(chunk_size): + for chunk in (b"a", b"b"): + yield chunk + + response = MagicMock() + response.status_code = 200 + response.headers = {} + response.cookies = {} + response.aiter_bytes = aiter_bytes + response.aclose = AsyncMock() + session.send = AsyncMock(return_value=response) + + client = HTTPXAsyncClient("http://base", mock_request_dumper, mock_response_loader, session=session) + + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + + result = await client.stream_make_request(request, chunk_size=1234) + + assert result.status_code == 200 + session.send.assert_called_once_with(built_request, stream=True) + + chunks = [chunk async for chunk in result.data] + assert chunks == [b"a", b"b"] + response.aclose.assert_called_once() + + +@pytest.mark.asyncio +async def test_httpx_async_chunk_stream_mid_stream_error_translated(): + async def aiter_bytes(chunk_size): + yield b"a" + raise httpx.ConnectError("connection lost") + + response = Mock() + response.aiter_bytes = aiter_bytes + + stream = _HTTPXAsyncChunkStream(response, chunk_size=999) + assert await anext(stream) == b"a" + with pytest.raises(NetworkError): + await anext(stream) + + +@pytest.mark.asyncio +async def test_httpx_async_chunk_stream_mid_stream_timeout_translated(): + + async def aiter_bytes(chunk_size): + yield b"a" + raise httpx.ReadTimeout("timed out") + + response = Mock() + response.aiter_bytes = aiter_bytes + + stream = _HTTPXAsyncChunkStream(response, chunk_size=999) + assert await anext(stream) == b"a" + with pytest.raises(RequestTimeoutError): + await anext(stream) diff --git a/tests/test_clients/test_httpx2.py b/tests/test_clients/test_httpx2.py index 1818db5..3ea9714 100644 --- a/tests/test_clients/test_httpx2.py +++ b/tests/test_clients/test_httpx2.py @@ -2,9 +2,10 @@ import httpx2 import pytest -from unihttp.clients.httpx2 import HTTPX2AsyncClient +from unihttp.clients.httpx2 import HTTPX2AsyncClient, _HTTPX2AsyncChunkStream from unihttp.exceptions import NetworkError, RequestTimeoutError from unihttp.http.request import HTTPRequest +from unittest.mock import AsyncMock, MagicMock @pytest.fixture @@ -30,7 +31,9 @@ async def test_httpx2_make_request(mock_request_dumper, mock_response_loader, mo mock_response.content = b'{"key": "value"}' mock_response.text = '{"key": "value"}' - mock_client.request.return_value = mock_response + built_request = Mock() + mock_client.build_request.return_value = built_request + mock_client.send.return_value = mock_response request = HTTPRequest( url="/test", @@ -46,7 +49,7 @@ async def test_httpx2_make_request(mock_request_dumper, mock_response_loader, mo response = await client.make_request(request) # Verify call arguments - mock_client.request.assert_called_once_with( + mock_client.build_request.assert_called_once_with( method="POST", url="http://base/test", headers={"Auth": "123", "Content-Type": "application/json"}, @@ -55,6 +58,7 @@ async def test_httpx2_make_request(mock_request_dumper, mock_response_loader, mo files=None, content='{"data": "abc"}' ) + mock_client.send.assert_called_once_with(built_request, stream=False) # Verify response mapping assert response.status_code == 200 @@ -79,7 +83,7 @@ async def test_httpx2_upload_file(mock_request_dumper, mock_response_loader, moc mock_response.status_code = 200 mock_response.content = b'{"status": "ok"}' mock_response.text = '{"status": "ok"}' - mock_client.request.return_value = mock_response + mock_client.send.return_value = mock_response request = HTTPRequest( url="/upload", @@ -94,7 +98,7 @@ async def test_httpx2_upload_file(mock_request_dumper, mock_response_loader, moc await client.make_request(request) - mock_client.request.assert_called_once_with( + mock_client.build_request.assert_called_once_with( method="POST", url="http://base/upload", headers={}, @@ -120,7 +124,7 @@ async def test_httpx2_close(mock_request_dumper, mock_response_loader, mock_clie @pytest.mark.asyncio async def test_httpx2_network_error(mock_request_dumper, mock_response_loader, mock_client): client = HTTPX2AsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_client) - mock_client.request.side_effect = httpx2.NetworkError("Network error") + mock_client.send.side_effect = httpx2.NetworkError("Network error") request = HTTPRequest("GET", "url", {}, {}, {}, {}, {}, {}) @@ -131,7 +135,7 @@ async def test_httpx2_network_error(mock_request_dumper, mock_response_loader, m @pytest.mark.asyncio async def test_httpx2_timeout_error(mock_request_dumper, mock_response_loader, mock_client): client = HTTPX2AsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_client) - mock_client.request.side_effect = httpx2.TimeoutException("Timed out") + mock_client.send.side_effect = httpx2.TimeoutException("Timed out") request = HTTPRequest("url", "GET", {}, {}, {}, {}, {}, {}) @@ -139,30 +143,6 @@ async def test_httpx2_timeout_error(mock_request_dumper, mock_response_loader, m await client.make_request(request) -@pytest.mark.asyncio -async def test_httpx2_body_and_form_error(mock_request_dumper, mock_response_loader, mock_client): - client = HTTPX2AsyncClient( - base_url="http://base", - request_dumper=mock_request_dumper, - response_loader=mock_response_loader, - session=mock_client - ) - - request = HTTPRequest( - url="/test", - method="POST", - header={}, - path={}, - query={}, - body={"some": "body"}, - file=None, - form={"some": "form"} - ) - - with pytest.raises(ValueError, match="Cannot use Body with Form or File"): - await client.make_request(request) - - @pytest.mark.asyncio async def test_httpx2_file_list_conversion(mock_request_dumper, mock_response_loader, mock_client): from unihttp.http import UploadFile @@ -179,7 +159,7 @@ async def test_httpx2_file_list_conversion(mock_request_dumper, mock_response_lo mock_response.status_code = 200 mock_response.content = b'{}' mock_response.text = '{}' - mock_client.request.return_value = mock_response + mock_client.send.return_value = mock_response request = HTTPRequest( url="/upload", @@ -201,8 +181,8 @@ async def test_httpx2_file_list_conversion(mock_request_dumper, mock_response_lo await client.make_request(request) - mock_client.request.assert_called_once() - call_kwargs = mock_client.request.call_args[1] + mock_client.build_request.assert_called_once() + call_kwargs = mock_client.build_request.call_args[1] files = call_kwargs["files"] # Verify order and content @@ -210,3 +190,92 @@ async def test_httpx2_file_list_conversion(mock_request_dumper, mock_response_lo assert files[1] == ("files", ("f2.txt", b"content2")) assert files[2] == ("single_upload_file", ("f3.txt", b"content3", "application/octet-stream")) assert files[3] == ("single_tuple", ("f4.txt", b"content4")) + + +@pytest.mark.asyncio +async def test_httpx2_async_raw_body_bytes(mock_request_dumper, mock_response_loader, mock_client): + client = HTTPX2AsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_client) + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.cookies = {} + mock_response.content = b"" + mock_client.send.return_value = mock_response + + request = HTTPRequest( + url="/raw", method="POST", header={}, path={}, query={}, + body={}, file={}, form={}, raw="raw-string" + ) + + await client.make_request(request) + + call_kwargs = mock_client.build_request.call_args.kwargs + assert call_kwargs["content"] == "raw-string" + + +@pytest.mark.asyncio +async def test_httpx2_async_stream_make_request(mock_request_dumper, mock_response_loader): + + session = MagicMock(spec=httpx2.AsyncClient) + built_request = MagicMock() + session.build_request.return_value = built_request + + async def aiter_bytes(chunk_size): + for chunk in (b"a", b"b"): + yield chunk + + response = MagicMock() + response.status_code = 200 + response.headers = {} + response.cookies = {} + response.aiter_bytes = aiter_bytes + response.aclose = AsyncMock() + session.send = AsyncMock(return_value=response) + + client = HTTPX2AsyncClient("http://base", mock_request_dumper, mock_response_loader, session=session) + + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + + result = await client.stream_make_request(request, chunk_size=1234) + + assert result.status_code == 200 + session.send.assert_called_once_with(built_request, stream=True) + + chunks = [chunk async for chunk in result.data] + assert chunks == [b"a", b"b"] + response.aclose.assert_called_once() + + +@pytest.mark.asyncio +async def test_httpx2_async_chunk_stream_mid_stream_error_translated(): + async def aiter_bytes(chunk_size): + yield b"a" + raise httpx2.ConnectError("connection lost") + + response = Mock() + response.aiter_bytes = aiter_bytes + + stream = _HTTPX2AsyncChunkStream(response, chunk_size=999) + assert await anext(stream) == b"a" + with pytest.raises(NetworkError): + await anext(stream) + + +@pytest.mark.asyncio +async def test_httpx2_async_chunk_stream_mid_stream_timeout_translated(): + + async def aiter_bytes(chunk_size): + yield b"a" + raise httpx2.ReadTimeout("timed out") + + response = Mock() + response.aiter_bytes = aiter_bytes + + stream = _HTTPX2AsyncChunkStream(response, chunk_size=999) + assert await anext(stream) == b"a" + with pytest.raises(RequestTimeoutError): + await anext(stream) diff --git a/tests/test_clients/test_httpx2_sync.py b/tests/test_clients/test_httpx2_sync.py index 6357dbd..527e46f 100644 --- a/tests/test_clients/test_httpx2_sync.py +++ b/tests/test_clients/test_httpx2_sync.py @@ -2,7 +2,7 @@ import httpx2 import pytest -from unihttp.clients.httpx2 import HTTPX2SyncClient +from unihttp.clients.httpx2 import HTTPX2SyncClient, _HTTPX2ChunkStream from unihttp.exceptions import NetworkError, RequestTimeoutError from unihttp.http.request import HTTPRequest @@ -28,7 +28,9 @@ def test_httpx2_sync_make_request(mock_request_dumper, mock_response_loader, moc mock_response.content = b'{"key": "value"}' mock_response.text = '{"key": "value"}' - mock_httpx2_client.request.return_value = mock_response + built_request = Mock() + mock_httpx2_client.build_request.return_value = built_request + mock_httpx2_client.send.return_value = mock_response request = HTTPRequest( url="/test", @@ -43,7 +45,7 @@ def test_httpx2_sync_make_request(mock_request_dumper, mock_response_loader, moc response = client.make_request(request) - mock_httpx2_client.request.assert_called_once_with( + mock_httpx2_client.build_request.assert_called_once_with( method="POST", url="http://base/test", headers={"Auth": "123", "Content-Type": "application/json"}, @@ -52,6 +54,7 @@ def test_httpx2_sync_make_request(mock_request_dumper, mock_response_loader, moc files=None, content='{"data": "abc"}' ) + mock_httpx2_client.send.assert_called_once_with(built_request, stream=False) assert response.status_code == 200 assert response.data == {"key": "value"} @@ -70,7 +73,7 @@ def test_httpx2_sync_close(mock_request_dumper, mock_response_loader, mock_httpx def test_httpx2_sync_network_error(mock_request_dumper, mock_response_loader, mock_httpx2_client): client = HTTPX2SyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_httpx2_client) - mock_httpx2_client.request.side_effect = httpx2.NetworkError("failures") + mock_httpx2_client.send.side_effect = httpx2.NetworkError("failures") request = HTTPRequest( url="/test", @@ -89,7 +92,7 @@ def test_httpx2_sync_network_error(mock_request_dumper, mock_response_loader, mo def test_httpx2_sync_timeout_error(mock_request_dumper, mock_response_loader, mock_httpx2_client): client = HTTPX2SyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_httpx2_client) - mock_httpx2_client.request.side_effect = httpx2.TimeoutException("timed out") + mock_httpx2_client.send.side_effect = httpx2.TimeoutException("timed out") request = HTTPRequest( url="/test", @@ -106,27 +109,6 @@ def test_httpx2_sync_timeout_error(mock_request_dumper, mock_response_loader, mo client.make_request(request) -def test_httpx2_sync_body_and_form_error(mock_request_dumper, mock_response_loader, mock_httpx2_client): - client = HTTPX2SyncClient( - base_url="http://base", - request_dumper=mock_request_dumper, - response_loader=mock_response_loader, - session=mock_httpx2_client - ) - - request = HTTPRequest( - url="/test", - method="POST", - header={}, - path={}, - query={}, - body={"some": "body"}, - file=None, - form={"some": "form"} - ) - - with pytest.raises(ValueError, match="Cannot use Body with Form or File"): - client.make_request(request) def test_httpx2_sync_file_list_conversion(mock_request_dumper, mock_response_loader, mock_httpx2_client): @@ -144,7 +126,7 @@ def test_httpx2_sync_file_list_conversion(mock_request_dumper, mock_response_loa mock_response.status_code = 200 mock_response.content = b'{}' mock_response.text = '{}' - mock_httpx2_client.request.return_value = mock_response + mock_httpx2_client.send.return_value = mock_response request = HTTPRequest( url="/upload", @@ -166,8 +148,8 @@ def test_httpx2_sync_file_list_conversion(mock_request_dumper, mock_response_loa client.make_request(request) - mock_httpx2_client.request.assert_called_once() - call_kwargs = mock_httpx2_client.request.call_args[1] + mock_httpx2_client.build_request.assert_called_once() + call_kwargs = mock_httpx2_client.build_request.call_args[1] files = call_kwargs["files"] # Verify order and content @@ -175,3 +157,84 @@ def test_httpx2_sync_file_list_conversion(mock_request_dumper, mock_response_loa assert files[1] == ("files", ("f2.txt", b"content2")) assert files[2] == ("single_upload_file", ("f3.txt", b"content3", "application/octet-stream")) assert files[3] == ("single_tuple", ("f4.txt", b"content4")) + + +def test_httpx2_sync_raw_body_bytes(mock_request_dumper, mock_response_loader, mock_httpx2_client): + client = HTTPX2SyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_httpx2_client) + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.cookies = {} + mock_response.content = b"" + mock_httpx2_client.send.return_value = mock_response + + request = HTTPRequest( + url="/raw", method="POST", header={}, path={}, query={}, + body={}, file={}, form={}, raw=b"raw-bytes" + ) + + client.make_request(request) + + call_kwargs = mock_httpx2_client.build_request.call_args.kwargs + assert call_kwargs["content"] == b"raw-bytes" + + +def test_httpx2_sync_stream_make_request(mock_request_dumper, mock_response_loader): + session = MagicMock(spec=httpx2.Client) + built_request = MagicMock() + session.build_request.return_value = built_request + + response = MagicMock() + response.status_code = 200 + response.headers = {} + response.cookies = {} + response.iter_bytes.return_value = iter([b"a", b"b"]) + session.send.return_value = response + + client = HTTPX2SyncClient("http://base", mock_request_dumper, mock_response_loader, session=session) + + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + + result = client.stream_make_request(request, chunk_size=1234) + + assert result.status_code == 200 + session.send.assert_called_once_with(built_request, stream=True) + + chunks = list(result.data) + assert chunks == [b"a", b"b"] + response.iter_bytes.assert_called_once_with(1234) + response.close.assert_called_once() + + +def test_httpx2_sync_chunk_stream_mid_stream_error_translated(): + + def gen(): + yield b"a" + raise httpx2.ConnectError("connection lost") + + response = Mock() + response.iter_bytes.return_value = gen() + + stream = _HTTPX2ChunkStream(response, chunk_size=999) + assert next(stream) == b"a" + with pytest.raises(NetworkError): + next(stream) + + +def test_httpx2_sync_chunk_stream_mid_stream_timeout_translated(): + + def gen(): + yield b"a" + raise httpx2.ReadTimeout("timed out") + + response = Mock() + response.iter_bytes.return_value = gen() + + stream = _HTTPX2ChunkStream(response, chunk_size=999) + assert next(stream) == b"a" + with pytest.raises(RequestTimeoutError): + next(stream) diff --git a/tests/test_clients/test_httpx_sync.py b/tests/test_clients/test_httpx_sync.py index 3d44de5..c1dab1c 100644 --- a/tests/test_clients/test_httpx_sync.py +++ b/tests/test_clients/test_httpx_sync.py @@ -2,7 +2,7 @@ import httpx import pytest -from unihttp.clients.httpx import HTTPXSyncClient +from unihttp.clients.httpx import HTTPXSyncClient, _HTTPXChunkStream from unihttp.exceptions import NetworkError, RequestTimeoutError from unihttp.http.request import HTTPRequest @@ -28,7 +28,9 @@ def test_httpx_sync_make_request(mock_request_dumper, mock_response_loader, mock mock_response.content = b'{"key": "value"}' mock_response.text = '{"key": "value"}' - mock_httpx_client.request.return_value = mock_response + built_request = Mock() + mock_httpx_client.build_request.return_value = built_request + mock_httpx_client.send.return_value = mock_response request = HTTPRequest( url="/test", @@ -43,7 +45,7 @@ def test_httpx_sync_make_request(mock_request_dumper, mock_response_loader, mock response = client.make_request(request) - mock_httpx_client.request.assert_called_once_with( + mock_httpx_client.build_request.assert_called_once_with( method="POST", url="http://base/test", headers={"Auth": "123", "Content-Type": "application/json"}, @@ -52,6 +54,7 @@ def test_httpx_sync_make_request(mock_request_dumper, mock_response_loader, mock files=None, content='{"data": "abc"}' ) + mock_httpx_client.send.assert_called_once_with(built_request, stream=False) assert response.status_code == 200 assert response.data == {"key": "value"} @@ -70,7 +73,7 @@ def test_httpx_sync_close(mock_request_dumper, mock_response_loader, mock_httpx_ def test_httpx_sync_network_error(mock_request_dumper, mock_response_loader, mock_httpx_client): client = HTTPXSyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_httpx_client) - mock_httpx_client.request.side_effect = httpx.NetworkError("failures") + mock_httpx_client.send.side_effect = httpx.NetworkError("failures") request = HTTPRequest( url="/test", @@ -89,7 +92,7 @@ def test_httpx_sync_network_error(mock_request_dumper, mock_response_loader, moc def test_httpx_sync_timeout_error(mock_request_dumper, mock_response_loader, mock_httpx_client): client = HTTPXSyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_httpx_client) - mock_httpx_client.request.side_effect = httpx.TimeoutException("timed out") + mock_httpx_client.send.side_effect = httpx.TimeoutException("timed out") request = HTTPRequest( url="/test", @@ -106,29 +109,6 @@ def test_httpx_sync_timeout_error(mock_request_dumper, mock_response_loader, moc client.make_request(request) -def test_httpx_sync_body_and_form_error(mock_request_dumper, mock_response_loader, mock_httpx_client): - client = HTTPXSyncClient( - base_url="http://base", - request_dumper=mock_request_dumper, - response_loader=mock_response_loader, - session=mock_httpx_client - ) - - request = HTTPRequest( - url="/test", - method="POST", - header={}, - path={}, - query={}, - body={"some": "body"}, - file=None, - form={"some": "form"} - ) - - with pytest.raises(ValueError, match="Cannot use Body with Form or File"): - client.make_request(request) - - def test_httpx_sync_file_list_conversion(mock_request_dumper, mock_response_loader, mock_httpx_client): from unihttp.http import UploadFile @@ -144,7 +124,7 @@ def test_httpx_sync_file_list_conversion(mock_request_dumper, mock_response_load mock_response.status_code = 200 mock_response.content = b'{}' mock_response.text = '{}' - mock_httpx_client.request.return_value = mock_response + mock_httpx_client.send.return_value = mock_response request = HTTPRequest( url="/upload", @@ -166,8 +146,8 @@ def test_httpx_sync_file_list_conversion(mock_request_dumper, mock_response_load client.make_request(request) - mock_httpx_client.request.assert_called_once() - call_kwargs = mock_httpx_client.request.call_args[1] + mock_httpx_client.build_request.assert_called_once() + call_kwargs = mock_httpx_client.build_request.call_args[1] files = call_kwargs["files"] # Verify order and content @@ -175,3 +155,84 @@ def test_httpx_sync_file_list_conversion(mock_request_dumper, mock_response_load assert files[1] == ("files", ("f2.txt", b"content2")) assert files[2] == ("single_upload_file", ("f3.txt", b"content3", "application/octet-stream")) assert files[3] == ("single_tuple", ("f4.txt", b"content4")) + + +def test_httpx_sync_raw_body_bytes(mock_request_dumper, mock_response_loader, mock_httpx_client): + client = HTTPXSyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_httpx_client) + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.cookies = {} + mock_response.content = b"" + mock_httpx_client.send.return_value = mock_response + + request = HTTPRequest( + url="/raw", method="POST", header={}, path={}, query={}, + body={}, file={}, form={}, raw=b"raw-bytes" + ) + + client.make_request(request) + + call_kwargs = mock_httpx_client.build_request.call_args.kwargs + assert call_kwargs["content"] == b"raw-bytes" + + +def test_httpx_sync_stream_make_request(mock_request_dumper, mock_response_loader): + from unittest.mock import MagicMock + + session = MagicMock(spec=httpx.Client) + built_request = MagicMock() + session.build_request.return_value = built_request + + response = MagicMock() + response.status_code = 200 + response.headers = {} + response.cookies = {} + response.iter_bytes.return_value = iter([b"a", b"b"]) + session.send.return_value = response + + client = HTTPXSyncClient("http://base", mock_request_dumper, mock_response_loader, session=session) + + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + + result = client.stream_make_request(request, chunk_size=1234) + + assert result.status_code == 200 + session.send.assert_called_once_with(built_request, stream=True) + + chunks = list(result.data) + assert chunks == [b"a", b"b"] + response.iter_bytes.assert_called_once_with(1234) + response.close.assert_called_once() + + +def test_httpx_sync_chunk_stream_mid_stream_error_translated(): + def gen(): + yield b"a" + raise httpx.ConnectError("connection lost") + + response = Mock() + response.iter_bytes.return_value = gen() + + stream = _HTTPXChunkStream(response, chunk_size=999) + assert next(stream) == b"a" + with pytest.raises(NetworkError): + next(stream) + + +def test_httpx_sync_chunk_stream_mid_stream_timeout_translated(): + def gen(): + yield b"a" + raise httpx.ReadTimeout("timed out") + + response = Mock() + response.iter_bytes.return_value = gen() + + stream = _HTTPXChunkStream(response, chunk_size=999) + assert next(stream) == b"a" + with pytest.raises(RequestTimeoutError): + next(stream) diff --git a/tests/test_clients/test_json_load_errors.py b/tests/test_clients/test_json_load_errors.py index 931395c..557ea64 100644 --- a/tests/test_clients/test_json_load_errors.py +++ b/tests/test_clients/test_json_load_errors.py @@ -34,8 +34,8 @@ def test_httpx_sync_json_error(mock_request, mock_request_dumper, mock_response_ mock_response.status_code = 200 mock_response.headers = {} mock_response.cookies = {} - mock_session.request.return_value = mock_response - + mock_session.send.return_value = mock_response + client = HTTPXSyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_session) response = client.make_request(mock_request) assert response.data == b"not json" @@ -49,8 +49,8 @@ async def test_httpx_async_json_error(mock_request, mock_request_dumper, mock_re mock_response.status_code = 200 mock_response.headers = {} mock_response.cookies = {} - mock_session.request.return_value = mock_response - + mock_session.send.return_value = mock_response + client = HTTPXAsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_session) response = await client.make_request(mock_request) assert response.data == b"not json" @@ -63,7 +63,7 @@ def test_httpx2_sync_json_error(mock_request, mock_request_dumper, mock_response mock_response.status_code = 200 mock_response.headers = {} mock_response.cookies = {} - mock_session.request.return_value = mock_response + mock_session.send.return_value = mock_response client = HTTPX2SyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_session) response = client.make_request(mock_request) @@ -78,7 +78,7 @@ async def test_httpx2_async_json_error(mock_request, mock_request_dumper, mock_r mock_response.status_code = 200 mock_response.headers = {} mock_response.cookies = {} - mock_session.request.return_value = mock_response + mock_session.send.return_value = mock_response client = HTTPX2AsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_session) response = await client.make_request(mock_request) @@ -92,10 +92,8 @@ async def test_aiohttp_json_error(mock_request, mock_request_dumper, mock_respon mock_response.headers = {} mock_response.cookies = {} - # aiohttp uses context manager - mock_session = MagicMock() # Use MagicMock for context manager - mock_session.request.return_value.__aenter__ = AsyncMock(return_value=mock_response) - mock_session.request.return_value.__aexit__ = AsyncMock() + mock_session = MagicMock() + mock_session.request = AsyncMock(return_value=mock_response) client = AiohttpAsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_session) response = await client.make_request(mock_request) diff --git a/tests/test_clients/test_niquests.py b/tests/test_clients/test_niquests.py index 7ef4b36..25115b5 100644 --- a/tests/test_clients/test_niquests.py +++ b/tests/test_clients/test_niquests.py @@ -2,10 +2,11 @@ import niquests from collections.abc import AsyncGenerator, Generator from typing import cast -from unittest.mock import AsyncMock, MagicMock, Mock +from unittest.mock import AsyncMock, Mock from unihttp.clients.base import BaseSyncClient, BaseAsyncClient -from unihttp.clients.niquests import NiquestsSyncClient, NiquestsAsyncClient +from unihttp.clients.niquests import NiquestsSyncClient, NiquestsAsyncClient, _NiquestsChunkStream, \ + _NiquestsAsyncChunkStream from unihttp.exceptions import NetworkError, RequestTimeoutError from unihttp.http import HTTPRequest from unihttp.serialize import RequestDumper, ResponseLoader @@ -68,6 +69,7 @@ def test_make_request(self, sync_client: BaseSyncClient, mocker): params={"q": "search"}, files=None, data=None, + stream=False, ) def test_network_error(self, sync_client: BaseSyncClient, mocker): @@ -172,14 +174,6 @@ def test_request_with_form(self, sync_client: BaseSyncClient, mocker): mock_session_request.assert_called_once() assert mock_session_request.call_args[1]["data"] == {"f": "v"} - def test_body_and_form_error(self, sync_client: BaseSyncClient): - client = cast(NiquestsSyncClient, 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_generic_request_exception(self, sync_client: BaseSyncClient, mocker): mocker.patch("niquests.Session.request", side_effect=niquests.exceptions.RequestException("Generic Error")) client = cast(NiquestsSyncClient, sync_client) @@ -189,6 +183,65 @@ def test_generic_request_exception(self, sync_client: BaseSyncClient, mocker): with pytest.raises(NetworkError, match="Generic Error"): client.make_request(request) + def test_raw_body(self, sync_client: BaseSyncClient, mocker): + mock_response = Mock(status_code=200, headers={}, cookies={}, content=b"{}") + mock_session_request = mocker.patch("niquests.Session.request", return_value=mock_response) + + client = cast(NiquestsSyncClient, sync_client) + request = HTTPRequest( + url="/raw", method="POST", header={}, path={}, query={}, + body={}, file={}, form={}, raw=b"raw-payload" + ) + client.make_request(request) + + assert mock_session_request.call_args[1]["data"] == b"raw-payload" + + def test_stream_make_request(self, sync_client: BaseSyncClient, mocker): + mock_response = Mock(status_code=200, headers={}, cookies={}) + mock_response.iter_content = Mock(return_value=iter([b"a", b"b"])) + mock_session_request = mocker.patch("niquests.Session.request", return_value=mock_response) + + client = cast(NiquestsSyncClient, sync_client) + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + result = client.stream_make_request(request, chunk_size=1234) + + assert result.status_code == 200 + assert mock_session_request.call_args[1]["stream"] is True + + chunks = list(result.data) + assert chunks == [b"a", b"b"] + mock_response.iter_content.assert_called_once_with(chunk_size=1234) + mock_response.close.assert_called_once() + + def test_chunk_stream_mid_stream_error_translated(self): + def gen(): + yield b"a" + raise niquests.exceptions.ConnectionError("connection lost") + + response = Mock() + response.iter_content.return_value = gen() + + stream = _NiquestsChunkStream(response, chunk_size=999) + assert next(stream) == b"a" + with pytest.raises(NetworkError): + next(stream) + + def test_chunk_stream_mid_stream_timeout_translated(self): + def gen(): + yield b"a" + raise niquests.exceptions.Timeout("timed out") + + response = Mock() + response.iter_content.return_value = gen() + + stream = _NiquestsChunkStream(response, chunk_size=999) + assert next(stream) == b"a" + with pytest.raises(RequestTimeoutError): + next(stream) + class TestNiquestsAsyncClient: @pytest.mark.asyncio @@ -228,6 +281,7 @@ async def test_make_request(self, async_client: BaseAsyncClient, mocker): params={}, files=None, data='{"some": "data"}', + stream=False, ) @pytest.mark.asyncio @@ -328,20 +382,60 @@ async def test_request_with_form(self, async_client: BaseAsyncClient, mocker): assert mock_session_request.call_args[1]["data"] == {"f": "v"} @pytest.mark.asyncio - async def test_body_and_form_error(self, async_client: BaseAsyncClient): + async def test_generic_request_exception(self, async_client: BaseAsyncClient, mocker): + mocker.patch("niquests.AsyncSession.request", side_effect=niquests.exceptions.RequestException("Generic Error")) client = cast(NiquestsAsyncClient, async_client) request = HTTPRequest( - url="/path", method="POST", header={}, path={}, query={}, body={"b": "v"}, file={}, form={"f": "v"} + url="/path", method="GET", header={}, path={}, query={}, body=None, file={}, form=None ) - with pytest.raises(ValueError, match="Cannot use Body with Form or File"): + with pytest.raises(NetworkError, match="Generic Error"): await client.make_request(request) @pytest.mark.asyncio - async def test_generic_request_exception(self, async_client: BaseAsyncClient, mocker): - mocker.patch("niquests.AsyncSession.request", side_effect=niquests.exceptions.RequestException("Generic Error")) + async def test_stream_make_request(self, async_client: BaseAsyncClient, mocker): + mock_response = Mock(status_code=200, headers={}, cookies={}) + + async def gen(): + yield b"a" + yield b"b" + + mock_response.iter_content = AsyncMock(return_value=gen()) + mock_response.close = AsyncMock() + mocker.patch("niquests.AsyncSession.request", new_callable=AsyncMock, return_value=mock_response) + client = cast(NiquestsAsyncClient, async_client) request = HTTPRequest( - url="/path", method="GET", header={}, path={}, query={}, body=None, file={}, form=None + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} ) - with pytest.raises(NetworkError, match="Generic Error"): - await client.make_request(request) + result = await client.stream_make_request(request, chunk_size=1234) + + assert result.status_code == 200 + chunks = [chunk async for chunk in result.data] + assert chunks == [b"a", b"b"] + mock_response.iter_content.assert_awaited_once_with(chunk_size=1234) + mock_response.close.assert_awaited_once() + + @pytest.mark.asyncio + async def test_chunk_stream_mid_stream_error_translated(self): + async def gen(): + yield b"a" + raise niquests.exceptions.ConnectionError("connection lost") + + response = Mock() + stream = _NiquestsAsyncChunkStream(response, gen()) + assert await anext(stream) == b"a" + with pytest.raises(NetworkError): + await anext(stream) + + @pytest.mark.asyncio + async def test_chunk_stream_mid_stream_timeout_translated(self): + async def gen(): + yield b"a" + raise niquests.exceptions.Timeout("timed out") + + response = Mock() + stream = _NiquestsAsyncChunkStream(response, gen()) + assert await anext(stream) == b"a" + with pytest.raises(RequestTimeoutError): + await anext(stream) diff --git a/tests/test_clients/test_requests.py b/tests/test_clients/test_requests.py index 5b0e40c..b73da23 100644 --- a/tests/test_clients/test_requests.py +++ b/tests/test_clients/test_requests.py @@ -2,7 +2,7 @@ import pytest import requests -from unihttp.clients.requests import RequestsSyncClient +from unihttp.clients.requests import RequestsSyncClient, _RequestsChunkStream from unihttp.exceptions import NetworkError, RequestTimeoutError from unihttp.http.request import HTTPRequest @@ -49,7 +49,8 @@ def test_requests_make_request(mock_request_dumper, mock_response_loader, mock_s headers={"Auth": "123", "Content-Type": "application/json"}, params={"q": "1"}, data='{"data": "abc"}', - files={} + files={}, + stream=False ) # Verify response mapping @@ -100,29 +101,6 @@ def test_requests_timeout_error(mock_request_dumper, mock_response_loader, mock_ client.make_request(request) -def test_requests_body_and_form_error(mock_request_dumper, mock_response_loader, mock_session): - client = RequestsSyncClient( - base_url="http://base", - request_dumper=mock_request_dumper, - response_loader=mock_response_loader, - session=mock_session - ) - - request = HTTPRequest( - url="/test", - method="POST", - header={}, - path={}, - query={}, - body={"some": "body"}, - file=None, - form={"some": "form"} - ) - - with pytest.raises(ValueError, match="Cannot use Body with Form or File"): - client.make_request(request) - - def test_requests_form_only(mock_request_dumper, mock_response_loader, mock_session): client = RequestsSyncClient( base_url="http://base", @@ -153,3 +131,77 @@ def test_requests_form_only(mock_request_dumper, mock_response_loader, mock_sess mock_session.request.assert_called_once() call_kwargs = mock_session.request.call_args[1] assert call_kwargs["data"] == {"key": "val"} + + +def test_requests_raw_body(mock_request_dumper, mock_response_loader, mock_session): + response = Mock() + response.status_code = 200 + response.headers = {} + response.cookies = {} + response.content = b"{}" + mock_session.request.return_value = response + + client = RequestsSyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_session) + + request = HTTPRequest( + url="/raw", method="POST", header={}, path={}, query={}, + body={}, file={}, form={}, raw=b"raw-payload" + ) + client.make_request(request) + + call_kwargs = mock_session.request.call_args.kwargs + assert call_kwargs["data"] == b"raw-payload" + + +def test_requests_stream_make_request(mock_request_dumper, mock_response_loader, mock_session): + response = Mock() + response.status_code = 200 + response.headers = {} + response.cookies = {} + response.iter_content.return_value = iter([b"a", b"b"]) + mock_session.request.return_value = response + + client = RequestsSyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_session) + + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + result = client.stream_make_request(request, chunk_size=1234) + + assert result.status_code == 200 + call_kwargs = mock_session.request.call_args.kwargs + assert call_kwargs["stream"] is True + + chunks = list(result.data) + assert chunks == [b"a", b"b"] + response.iter_content.assert_called_once_with(chunk_size=1234) + response.close.assert_called_once() + + +def test_requests_chunk_stream_mid_stream_error_translated(): + def gen(): + yield b"a" + raise requests.exceptions.ConnectionError("connection lost") + + response = Mock() + response.iter_content.return_value = gen() + + stream = _RequestsChunkStream(response, chunk_size=999) + assert next(stream) == b"a" + with pytest.raises(NetworkError): + next(stream) + + +def test_requests_chunk_stream_mid_stream_timeout_translated(): + def gen(): + yield b"a" + raise requests.exceptions.Timeout("timed out") + + response = Mock() + response.iter_content.return_value = gen() + + stream = _RequestsChunkStream(response, chunk_size=999) + assert next(stream) == b"a" + with pytest.raises(RequestTimeoutError): + next(stream) diff --git a/tests/test_clients/test_urllib.py b/tests/test_clients/test_urllib.py index 30546b0..b7e5a49 100644 --- a/tests/test_clients/test_urllib.py +++ b/tests/test_clients/test_urllib.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock import pytest -from unihttp.clients.urllib import UrllibSyncClient +from unihttp.clients.urllib import UrllibSyncClient, _UrllibChunkStream from unihttp.exceptions import NetworkError, RequestTimeoutError from unihttp.http import UploadFile from unihttp.http.request import HTTPRequest @@ -25,13 +25,10 @@ def __init__(self, headers=None, cookies=None): class FakeResponse: def __init__(self, status=200, headers=None, body=b"", cookies=None): - self._status = status + self.status = status self.headers = FakeHeaders(headers, cookies) self._body = body - def getcode(self): - return self._status - def read(self): return self._body @@ -149,26 +146,6 @@ def test_urllib_http_error_is_response( assert response.is_client_error -def test_urllib_body_and_form_error( - mock_request_dumper, mock_response_loader, mock_opener -): - client = make_client(mock_request_dumper, mock_response_loader, mock_opener) - - request = HTTPRequest( - url="/test", - method="POST", - header={}, - path={}, - query={}, - body={"some": "body"}, - file=None, - form={"some": "form"}, - ) - - with pytest.raises(ValueError, match="Cannot use Body with Form or File"): - client.make_request(request) - - def test_urllib_form_only(mock_request_dumper, mock_response_loader, mock_opener): client = make_client(mock_request_dumper, mock_response_loader, mock_opener) mock_opener.open.return_value = FakeResponse(status=200, body=b"{}") @@ -281,3 +258,101 @@ def test_urllib_no_body_get(mock_request_dumper, mock_response_loader, mock_open assert sent_req.get_method() == "GET" assert response.status_code == 204 assert response.data is None + + +def test_urllib_raw_body(mock_request_dumper, mock_response_loader): + fake_response = MagicMock() + fake_response.status = 200 + fake_response.headers.items.return_value = [] + fake_response.headers.get_all.return_value = [] + fake_response.read.return_value = b"{}" + + opener = MagicMock() + opener.open.return_value = fake_response + + client = UrllibSyncClient("http://base", mock_request_dumper, mock_response_loader, opener=opener) + + request = HTTPRequest( + url="/raw", method="POST", header={}, path={}, query={}, + body={}, file={}, form={}, raw=b"raw-payload" + ) + client.make_request(request) + + sent_request = opener.open.call_args.args[0] + assert sent_request.data == b"raw-payload" + + +def test_urllib_stream_make_request(mock_request_dumper, mock_response_loader): + fake_response = MagicMock() + fake_response.status = 200 + fake_response.headers.items.return_value = [] + fake_response.headers.get_all.return_value = [] + fake_response.read.side_effect = [b"a", b"b", b""] + + opener = MagicMock() + opener.open.return_value = fake_response + + client = UrllibSyncClient("http://base", mock_request_dumper, mock_response_loader, opener=opener) + + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + result = client.stream_make_request(request, chunk_size=5) + + assert result.status_code == 200 + chunks = list(result.data) + assert chunks == [b"a", b"b"] + fake_response.read.assert_any_call(5) + fake_response.close.assert_called_once() + + +def test_urllib_chunk_stream_closes_without_ever_reading(): + raw = MagicMock() + raw.read.side_effect = [b"a", b"b", b""] + + stream = _UrllibChunkStream(raw, chunk_size=5) + stream.close() + + raw.read.assert_not_called() + raw.close.assert_called_once() + + # Idempotent: closing again, or iterating after close, must not reopen + # or re-close the connection. + stream.close() + assert list(stream) == [] + raw.close.assert_called_once() + + +def test_urllib_chunk_stream_early_break_closes_once(): + raw = MagicMock() + raw.read.side_effect = [b"a", b"b", b""] + + stream = _UrllibChunkStream(raw, chunk_size=5) + for chunk in stream: + assert chunk == b"a" + break + stream.close() + + raw.read.assert_called_once_with(5) + raw.close.assert_called_once() + + +def test_urllib_chunk_stream_mid_stream_connection_error_translated(): + raw = MagicMock() + raw.read.side_effect = [b"a", ConnectionResetError("connection reset")] + + stream = _UrllibChunkStream(raw, chunk_size=5) + assert next(stream) == b"a" + with pytest.raises(NetworkError): + next(stream) + + +def test_urllib_chunk_stream_mid_stream_timeout_translated(): + raw = MagicMock() + raw.read.side_effect = [b"a", TimeoutError("timed out")] + + stream = _UrllibChunkStream(raw, chunk_size=5) + assert next(stream) == b"a" + with pytest.raises(RequestTimeoutError): + next(stream) diff --git a/tests/test_clients/test_zapros.py b/tests/test_clients/test_zapros.py index 647784f..d684eee 100644 --- a/tests/test_clients/test_zapros.py +++ b/tests/test_clients/test_zapros.py @@ -1,5 +1,6 @@ import io from collections.abc import AsyncGenerator, Generator +from contextlib import ExitStack, AsyncExitStack from pathlib import Path from typing import cast from unittest.mock import AsyncMock, Mock @@ -12,12 +13,47 @@ ZaprosAsyncClient, ZaprosSyncClient, _stringify_pairs, - _to_bytes, + _to_bytes, _ZaprosChunkStream, _ZaprosAsyncChunkStream, ) from unihttp.exceptions import NetworkError, RequestTimeoutError from unihttp.http import HTTPRequest, UploadFile +class _FakeStreamCM: + """Real `__enter__`/`__exit__` methods, not `Mock`-assigned ones. + + `ExitStack.enter_context` looks up `type(cm).__exit__` (to match `with` + semantics) and calls it unbound as `_exit(cm, *exc)`. A real function + handles that as an ordinary bound-method call; a `Mock` object assigned + to `cm.__exit__` does not, and ends up receiving `cm` as an extra + positional argument. Only a real class avoids that mismatch. + """ + + def __init__(self, response): + self._response = response + self.exit_calls: list[tuple] = [] + + def __enter__(self): + return self._response + + def __exit__(self, *exc_info): + self.exit_calls.append(exc_info) + + +class _FakeAsyncStreamCM: + """Async counterpart of `_FakeStreamCM` — see its docstring.""" + + def __init__(self, response): + self._response = response + self.exit_calls: list[tuple] = [] + + async def __aenter__(self): + return self._response + + async def __aexit__(self, *exc_info): + self.exit_calls.append(exc_info) + + class TestToBytes: def test_bytes_pass_through(self): data = b"abc" @@ -321,15 +357,6 @@ def test_empty_body_with_file_does_not_error(self, sync_client: BaseSyncClient, 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", @@ -372,6 +399,73 @@ def test_init_with_session(self, mock_request_dumper, mock_response_loader): assert client._session is session client.close() + def test_raw_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="/raw", method="POST", header={}, path={}, query={}, + body={}, file={}, form={}, raw=b"raw-payload" + ) + client.make_request(request) + + assert mock_request.call_args[1]["body"] == b"raw-payload" + + def test_stream_make_request(self, sync_client: BaseSyncClient, mocker): + response = Mock() + response.status = 200 + response.headers = {} + response.iter_bytes.return_value = iter([b"a", b"b"]) + + stream_cm = _FakeStreamCM(response) + mocker.patch("zapros.Client.stream", return_value=stream_cm) + + client = cast(ZaprosSyncClient, sync_client) + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + result = client.stream_make_request(request, chunk_size=1234) + + assert result.status_code == 200 + chunks = list(result.data) + assert chunks == [b"a", b"b"] + response.iter_bytes.assert_called_once_with(1234) + assert stream_cm.exit_calls == [(None, None, None)] + + def test_chunk_stream_mid_stream_error_translated(self): + """A connection drop after some chunks were already yielded must be + reported as `NetworkError`/`RequestTimeoutError`, not the raw + `zapros` exception.""" + def gen(): + yield b"a" + raise zapros.ConnectionError("connection lost") + + response = Mock() + response.iter_bytes.return_value = gen() + + stream = _ZaprosChunkStream(response, chunk_size=999, stack=ExitStack()) + assert next(stream) == b"a" + with pytest.raises(NetworkError): + next(stream) + + def test_chunk_stream_mid_stream_timeout_translated(self): + from contextlib import ExitStack + + from unihttp.clients.zapros import _ZaprosChunkStream + + def gen(): + yield b"a" + raise zapros.TimeoutError("timed out") + + response = Mock() + response.iter_bytes.return_value = gen() + + stream = _ZaprosChunkStream(response, chunk_size=999, stack=ExitStack()) + assert next(stream) == b"a" + with pytest.raises(RequestTimeoutError): + next(stream) + class TestZaprosAsyncClient: @pytest.mark.asyncio @@ -467,16 +561,6 @@ async def test_non_json_response_kept_as_bytes(self, async_client: BaseAsyncClie 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( @@ -524,3 +608,59 @@ async def test_init_with_session(self, mock_request_dumper, mock_response_loader ) assert client._session is session await client.close() + + @pytest.mark.asyncio + async def test_stream_make_request(self, async_client: BaseAsyncClient, mocker): + async def gen(): + yield b"a" + yield b"b" + + response = Mock() + response.status = 200 + response.headers = {} + response.async_iter_bytes.return_value = gen() + + stream_cm = _FakeAsyncStreamCM(response) + mocker.patch("zapros.AsyncClient.stream", return_value=stream_cm) + + client = cast(ZaprosAsyncClient, async_client) + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + result = await client.stream_make_request(request, chunk_size=1234) + + assert result.status_code == 200 + chunks = [chunk async for chunk in result.data] + assert chunks == [b"a", b"b"] + response.async_iter_bytes.assert_called_once_with(1234) + assert stream_cm.exit_calls == [(None, None, None)] + + @pytest.mark.asyncio + async def test_chunk_stream_mid_stream_error_translated(self): + + async def gen(): + yield b"a" + raise zapros.ConnectionError("connection lost") + + response = Mock() + response.async_iter_bytes.return_value = gen() + + stream = _ZaprosAsyncChunkStream(response, chunk_size=999, stack=AsyncExitStack()) + assert await anext(stream) == b"a" + with pytest.raises(NetworkError): + await anext(stream) + + @pytest.mark.asyncio + async def test_chunk_stream_mid_stream_timeout_translated(self): + async def gen(): + yield b"a" + raise zapros.TimeoutError("timed out") + + response = Mock() + response.async_iter_bytes.return_value = gen() + + stream = _ZaprosAsyncChunkStream(response, chunk_size=999, stack=AsyncExitStack()) + assert await anext(stream) == b"a" + with pytest.raises(RequestTimeoutError): + await anext(stream) diff --git a/tests/test_core/test_bind.py b/tests/test_core/test_bind.py index 799523d..592d88a 100644 --- a/tests/test_core/test_bind.py +++ b/tests/test_core/test_bind.py @@ -2,7 +2,7 @@ import pytest from unihttp.bind_method import bind_method -from unihttp.method import BaseMethod +from unihttp.method import BaseMethod, StreamMethod class MockMethod(BaseMethod[str]): @@ -10,22 +10,32 @@ class MockMethod(BaseMethod[str]): __method__ = "GET" +class MockStreamMethod(StreamMethod): + __url__ = "/test/stream" + __method__ = "GET" + + class SyncClient: def __init__(self): self.call_method = MagicMock(return_value="sync_result") + self.call_method_stream = MagicMock(return_value="sync_stream_result") method = bind_method(MockMethod) + stream = bind_method(MockStreamMethod) class AsyncClient: def __init__(self): self.call_method = AsyncMock(return_value="async_result") + self.call_method_stream = AsyncMock(return_value="async_stream_result") method = bind_method(MockMethod) + stream = bind_method(MockStreamMethod) class InvalidClient: method = bind_method(MockMethod) + stream = bind_method(MockStreamMethod) def test_bind_sync(): @@ -56,3 +66,30 @@ def test_bind_invalid_client(): client = InvalidClient() with pytest.raises(RuntimeError, match="available only for classes with `call_method`"): _ = client.method + + +def test_bind_stream_sync(): + client = SyncClient() + result = client.stream() + + assert result == "sync_stream_result" + client.call_method_stream.assert_called_once() + assert isinstance(client.call_method_stream.call_args[0][0], MockStreamMethod) + + +@pytest.mark.asyncio +async def test_bind_stream_async(): + client = AsyncClient() + result = await client.stream() + + assert result == "async_stream_result" + client.call_method_stream.assert_called_once() + assert isinstance(client.call_method_stream.call_args[0][0], MockStreamMethod) + + +def test_bind_stream_invalid_client(): + client = InvalidClient() + with pytest.raises( + RuntimeError, match="available only for classes with `call_method_stream`" + ): + _ = client.stream diff --git a/tests/test_core/test_markers.py b/tests/test_core/test_markers.py index f412bdc..b0adf47 100644 --- a/tests/test_core/test_markers.py +++ b/tests/test_core/test_markers.py @@ -11,6 +11,8 @@ PathMarker, Query, QueryMarker, + Raw, + RawMarker, ) @@ -28,6 +30,7 @@ def test_marker_names(): assert BodyMarker.name == "body" assert HeaderMarker.name == "header" assert FileMarker.name == "file" + assert RawMarker.name == "raw" def test_annotated_markers(): @@ -58,6 +61,11 @@ def test_annotated_markers(): assert type_args[0] == bytes assert isinstance(type_args[1], FileMarker) + # Raw + type_args = get_args(Raw[bytes]) + assert type_args[0] == bytes + assert isinstance(type_args[1], RawMarker) + def test_marker_instance_caching(): # Annotated helpers reuse instances diff --git a/tests/test_core/test_method.py b/tests/test_core/test_method.py index 062b35f..f068028 100644 --- a/tests/test_core/test_method.py +++ b/tests/test_core/test_method.py @@ -1,6 +1,8 @@ +import pytest + from unihttp.http.request import HTTPRequest from unihttp.http.response import HTTPResponse -from unihttp.method import BaseMethod +from unihttp.method import BaseMethod, StreamMethod class SimpleMethod(BaseMethod[str]): @@ -17,7 +19,7 @@ def test_build_http_request(mock_request_dumper): "path": {"id": 123}, "query": {"active": "true"}, "body": {"name": "test"}, - "file": {} + "file": {}, } request = method.build_http_request(mock_request_dumper) @@ -35,11 +37,7 @@ def test_build_http_request(mock_request_dumper): def test_make_response(mock_response_loader): method = SimpleMethod() response = HTTPResponse( - status_code=200, - headers={}, - cookies={}, - data={"key": "value"}, - raw_response=None + status_code=200, headers={}, cookies={}, data={"key": "value"}, raw_response=None, ) mock_response_loader.load.return_value = "loaded_data" @@ -62,3 +60,76 @@ def test_on_error_default(): response = HTTPResponse(404, {}, {}, {}, None) # Default implementation does nothing method.on_error(response) + + +class SimpleStreamMethod(StreamMethod): + __url__ = "/files/{id}" + __method__ = "GET" + + +def test_stream_method_build_http_request(mock_request_dumper): + method = SimpleStreamMethod() + mock_request_dumper.dump.return_value = { + "header": {"Authorization": "Bearer token"}, + "path": {"id": 42}, + "query": {}, + "raw": None, + } + + request = method.build_http_request(mock_request_dumper) + + assert isinstance(request, HTTPRequest) + assert request.url == "/files/42" + assert request.method == "GET" + assert request.header == {"Authorization": "Bearer token"} + + +def test_stream_method_on_error_default(): + method = SimpleStreamMethod() + response = HTTPResponse(404, {}, {}, {}, None) + method.on_error(response) + + +def test_build_http_request_raw_and_body_conflict(mock_request_dumper): + method = SimpleMethod() + mock_request_dumper.dump.return_value = { + "header": {}, + "path": {}, + "query": {}, + "body": {"a": 1}, + "file": {}, + "form": {}, + "raw": b"bytes", + } + with pytest.raises(ValueError, match="Raw"): + method.build_http_request(mock_request_dumper) + + +def test_build_http_request_body_and_form_conflict(mock_request_dumper): + method = SimpleMethod() + mock_request_dumper.dump.return_value = { + "header": {}, + "path": {}, + "query": {}, + "body": {"a": 1}, + "file": {}, + "form": {"b": 2}, + "raw": None, + } + with pytest.raises(ValueError, match="Cannot use Body with Form or File"): + method.build_http_request(mock_request_dumper) + + +def test_build_http_request_raw_only_ok(mock_request_dumper): + method = SimpleMethod() + mock_request_dumper.dump.return_value = { + "header": {}, + "path": {"id": 1}, + "query": {}, + "body": {}, + "file": {}, + "form": {}, + "raw": b"payload", + } + request = method.build_http_request(mock_request_dumper) + assert request.raw == b"payload" diff --git a/tests/test_features/test_forms_and_json.py b/tests/test_features/test_forms_and_json.py index c0197c7..24933b4 100644 --- a/tests/test_features/test_forms_and_json.py +++ b/tests/test_features/test_forms_and_json.py @@ -33,7 +33,7 @@ async def test_httpx_json_body_custom_dumps(mock_request_dumper, mock_response_l json_dumps=custom_dumps, json_loads=custom_loads ) - mock_client.request.return_value = mock_response + mock_client.send.return_value = mock_response request = HTTPRequest( url="/json", method="POST", header={}, path={}, query={}, @@ -46,8 +46,8 @@ async def test_httpx_json_body_custom_dumps(mock_request_dumper, mock_response_l custom_dumps.assert_called_once_with({"foo": "bar"}) # Verify httpx called with content string and content-type header - mock_client.request.assert_called_once() - call_kwargs = mock_client.request.call_args.kwargs + mock_client.build_request.assert_called_once() + call_kwargs = mock_client.build_request.call_args.kwargs assert call_kwargs["content"] == '{"custom": "json"}' assert call_kwargs["headers"]["Content-Type"] == "application/json" assert call_kwargs["data"] == {} @@ -58,7 +58,7 @@ async def test_httpx_json_body_custom_dumps(mock_request_dumper, mock_response_l @pytest.mark.asyncio async def test_httpx_form_data(mock_request_dumper, mock_response_loader, mock_client, mock_response): client = HTTPXAsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_client) - mock_client.request.return_value = mock_response + mock_client.send.return_value = mock_response request = HTTPRequest( url="/form", method="POST", header={}, path={}, query={}, @@ -67,29 +67,17 @@ async def test_httpx_form_data(mock_request_dumper, mock_response_loader, mock_c await client.make_request(request) - mock_client.request.assert_called_once() - call_kwargs = mock_client.request.call_args.kwargs + mock_client.build_request.assert_called_once() + call_kwargs = mock_client.build_request.call_args.kwargs assert call_kwargs["data"] == {"field": "value"} assert call_kwargs["content"] is None # No forced content-type for form (httpx handles it) # But if data is dict, httpx sets application/x-www-form-urlencoded -@pytest.mark.asyncio -async def test_httpx_mixed_body_form_error(mock_request_dumper, mock_response_loader, mock_client): - client = HTTPXAsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_client) - - request = HTTPRequest( - url="/mixed", method="POST", header={}, path={}, query={}, - body={"json": "part"}, file={}, form={"form": "part"} - ) - - with pytest.raises(ValueError, match="Cannot use Body with Form or File"): - await client.make_request(request) - @pytest.mark.asyncio async def test_httpx_multipart(mock_request_dumper, mock_response_loader, mock_client, mock_response): client = HTTPXAsyncClient("http://base", mock_request_dumper, mock_response_loader, session=mock_client) - mock_client.request.return_value = mock_response + mock_client.send.return_value = mock_response request = HTTPRequest( url="/files", method="POST", header={}, path={}, query={}, @@ -98,8 +86,8 @@ async def test_httpx_multipart(mock_request_dumper, mock_response_loader, mock_c await client.make_request(request) - mock_client.request.assert_called_once() - call_kwargs = mock_client.request.call_args.kwargs + mock_client.build_request.assert_called_once() + call_kwargs = mock_client.build_request.call_args.kwargs assert call_kwargs["files"] == [("file", b"bits")] assert call_kwargs["data"] == {"meta": "data"} assert call_kwargs["content"] is None diff --git a/tests/test_integration/test_adaptix_for_marker.py b/tests/test_integration/test_adaptix_for_marker.py index 24dbc46..27fa4b1 100644 --- a/tests/test_integration/test_adaptix_for_marker.py +++ b/tests/test_integration/test_adaptix_for_marker.py @@ -14,8 +14,9 @@ PathMarker, Query, QueryMarker, + Raw, ) -from unihttp.method import BaseMethod +from unihttp.method import BaseMethod, StreamMethod from unihttp.serializers.adaptix import DEFAULT_RETORT from unihttp.serializers.adaptix.marker_tools import for_marker @@ -111,3 +112,19 @@ class MyData(BaseMethod[Any]): "random_field": "random", } assert retort.dump(data) == excepted + + +def test_raw_marker_dumps_flat_and_verbatim() -> None: + @dataclass + class SendRaw(BaseMethod[Any]): + payload: Raw[bytes] + + assert DEFAULT_RETORT.dump(SendRaw(payload=b"hello")) == {"raw": b"hello"} + + +def test_stream_method_gets_marker_based_dumping() -> None: + @dataclass + class DownloadFile(StreamMethod): + file_id: Path[int] + + assert DEFAULT_RETORT.dump(DownloadFile(file_id=42)) == {"path": {"file_id": 42}} diff --git a/tests/test_integration/test_streaming.py b/tests/test_integration/test_streaming.py new file mode 100644 index 0000000..a7ea802 --- /dev/null +++ b/tests/test_integration/test_streaming.py @@ -0,0 +1,114 @@ +import pytest +from unihttp.clients.aiohttp import AiohttpAsyncClient +from unihttp.method import StreamMethod + + +class DownloadStream(StreamMethod): + __url__ = "/stream/{total_bytes}" + __method__ = "GET" + + def __init__(self, total_bytes: int, chunk_size: int = 65536): + self.total_bytes = total_bytes + self.__chunk_size__ = chunk_size + + +class DownloadStreamError(StreamMethod): + __url__ = "/stream-error/{total_bytes}" + __method__ = "GET" + + def __init__(self, total_bytes: int): + self.total_bytes = total_bytes + + +@pytest.mark.asyncio +async def test_call_method_stream_reassembles_full_body(integration_server, mock_request_dumper, mock_response_loader): + mock_request_dumper.dump.side_effect = lambda method: { + "path": {"total_bytes": str(method.total_bytes)}, + } + + client = AiohttpAsyncClient( + base_url=str(integration_server.make_url("/")), + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + ) + + total = 0 + received_chunks = [] + # chunk_size=7000 is misaligned with the server's 4096-byte write size, + # and total_bytes is large enough relative to chunk_size to guarantee + # several reassembled chunks over loopback (not one single read). + async with await client.call_method_stream( + DownloadStream(total_bytes=50_000, chunk_size=7000) + ) as chunks: + async for chunk in chunks: + total += len(chunk) + received_chunks.append(chunk) + + assert total == 50_000 + assert len(received_chunks) > 1 + + await client.close() + + +@pytest.mark.asyncio +async def test_call_method_stream_early_break_closes_connection( + integration_server, mock_request_dumper, mock_response_loader +): + mock_request_dumper.dump.side_effect = lambda method: { + "path": {"total_bytes": str(method.total_bytes)}, + } + + client = AiohttpAsyncClient( + base_url=str(integration_server.make_url("/")), + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + ) + + async with await client.call_method_stream(DownloadStream(total_bytes=1_000_000)) as chunks: + async for chunk in chunks: + break # stop after the first chunk, well before the body ends + + # Prove the early `break` + `async with` exit actually released the + # connection back to the pool immediately, rather than merely relying on + # `client.close()` (below) to tear down everything regardless. aiohttp's + # BaseConnector tracks in-flight connections in `_acquired`; it must be + # empty once the streaming context manager has exited. + assert len(client._session.connector._acquired) == 0 + + await client.close() + + +@pytest.mark.asyncio +async def test_call_method_stream_error_status_releases_connection( + integration_server, mock_request_dumper, mock_response_loader +): + """`handle_error` raising on a non-ok status must not leak the connection. + + Regression test: closing the chunk stream used to rely on the backend's + generator having been iterated at least once (a generator's own + `finally` never runs if `.close()`/`.aclose()` is called before the + first `next()`). `ChunkStream.close()` must release the connection + directly, regardless of whether the caller ever got to iterate. + """ + mock_request_dumper.dump.side_effect = lambda method: { + "path": {"total_bytes": str(method.total_bytes)}, + } + + class _RaisingClient(AiohttpAsyncClient): + def handle_error(self, response, method): + raise RuntimeError(f"HTTP {response.status_code}") + + client = _RaisingClient( + base_url=str(integration_server.make_url("/")), + request_dumper=mock_request_dumper, + response_loader=mock_response_loader, + ) + + with pytest.raises(RuntimeError): + async with await client.call_method_stream(DownloadStreamError(total_bytes=1000)) as chunks: + async for _chunk in chunks: + pass + + assert len(client._session.connector._acquired) == 0 + + await client.close() From 9222d0426364c00fdeee7cd928b8ae5d229df942 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:40:50 +0300 Subject: [PATCH 08/12] feat: add tests for raise_for_status --- tests/test_core/test_infra.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_core/test_infra.py b/tests/test_core/test_infra.py index 1ade27f..a213987 100644 --- a/tests/test_core/test_infra.py +++ b/tests/test_core/test_infra.py @@ -1,6 +1,7 @@ import tempfile from pathlib import Path +import pytest from unihttp.exceptions import ClientError, HTTPStatusError, ServerError from unihttp.http.files import UploadFile from unihttp.http.response import HTTPResponse @@ -68,3 +69,21 @@ def test_response_properties(): assert not r.ok assert not r.is_client_error assert r.is_server_error + + +def test_raise_for_status_ok_is_noop(): + HTTPResponse(200, {}, {}, {}, None).raise_for_status() + + +def test_raise_for_status_client_error(): + response = HTTPResponse(404, {}, {}, {}, None) + with pytest.raises(ClientError) as exc: + response.raise_for_status() + assert exc.value.response is response + + +def test_raise_for_status_server_error(): + response = HTTPResponse(500, {}, {}, {}, None) + with pytest.raises(ServerError) as exc: + response.raise_for_status() + assert exc.value.response is response From ca811490e671f52f83f7873333074131e967db7a Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:23:56 +0300 Subject: [PATCH 09/12] feat: add tests for streaming --- tests/server.py | 22 --- tests/test_integration/test_streaming.py | 162 ++++++++++++----------- 2 files changed, 85 insertions(+), 99 deletions(-) diff --git a/tests/server.py b/tests/server.py index 5a439ad..8a22da7 100644 --- a/tests/server.py +++ b/tests/server.py @@ -41,28 +41,6 @@ async def sleep_handler(request): return web.json_response({"slept": seconds}) -async def _write_stream(request, total_bytes, status): - response = web.StreamResponse(status=status) - await response.prepare(request) - written = 0 - while written < total_bytes: - piece = min(4096, total_bytes - written) - await response.write(b"x" * piece) - written += piece - await response.write_eof() - return response - - -@routes.get("/stream/{total_bytes}") -async def stream_handler(request): - return await _write_stream(request, int(request.match_info["total_bytes"]), 200) - - -@routes.get("/stream-error/{total_bytes}") -async def stream_error_handler(request): - return await _write_stream(request, int(request.match_info["total_bytes"]), 500) - - async def make_app(): app = web.Application() app.add_routes(routes) diff --git a/tests/test_integration/test_streaming.py b/tests/test_integration/test_streaming.py index a7ea802..ab39641 100644 --- a/tests/test_integration/test_streaming.py +++ b/tests/test_integration/test_streaming.py @@ -1,7 +1,20 @@ +import re +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + import pytest from unihttp.clients.aiohttp import AiohttpAsyncClient +from unihttp.clients.httpx import HTTPXAsyncClient, HTTPXSyncClient +from unihttp.clients.httpx2 import HTTPX2AsyncClient, HTTPX2SyncClient +from unihttp.clients.niquests import NiquestsAsyncClient, NiquestsSyncClient +from unihttp.clients.requests import RequestsSyncClient +from unihttp.clients.urllib import UrllibSyncClient +from unihttp.clients.zapros import ZaprosAsyncClient, ZaprosSyncClient from unihttp.method import StreamMethod +TOTAL_BYTES = 50_000 +CHUNK_SIZE = 7000 + class DownloadStream(StreamMethod): __url__ = "/stream/{total_bytes}" @@ -12,103 +25,98 @@ def __init__(self, total_bytes: int, chunk_size: int = 65536): self.__chunk_size__ = chunk_size -class DownloadStreamError(StreamMethod): - __url__ = "/stream-error/{total_bytes}" - __method__ = "GET" +ASYNC_CLIENTS = [ + ("aiohttp", AiohttpAsyncClient), + ("httpx", HTTPXAsyncClient), + ("httpx2", HTTPX2AsyncClient), + ("niquests", NiquestsAsyncClient), + ("zapros", ZaprosAsyncClient), +] - def __init__(self, total_bytes: int): - self.total_bytes = total_bytes +SYNC_CLIENTS = [ + ("httpx", HTTPXSyncClient), + ("httpx2", HTTPX2SyncClient), + ("requests", RequestsSyncClient), + ("niquests", NiquestsSyncClient), + ("urllib", UrllibSyncClient), + ("zapros", ZaprosSyncClient), +] -@pytest.mark.asyncio -async def test_call_method_stream_reassembles_full_body(integration_server, mock_request_dumper, mock_response_loader): - mock_request_dumper.dump.side_effect = lambda method: { +def make_client(client_cls, base_url, dumper, loader): + dumper.dump.side_effect = lambda method: { "path": {"total_bytes": str(method.total_bytes)}, } + return client_cls(base_url=base_url, request_dumper=dumper, response_loader=loader) - client = AiohttpAsyncClient( - base_url=str(integration_server.make_url("/")), - request_dumper=mock_request_dumper, - response_loader=mock_response_loader, - ) - total = 0 - received_chunks = [] - # chunk_size=7000 is misaligned with the server's 4096-byte write size, - # and total_bytes is large enough relative to chunk_size to guarantee - # several reassembled chunks over loopback (not one single read). - async with await client.call_method_stream( - DownloadStream(total_bytes=50_000, chunk_size=7000) - ) as chunks: - async for chunk in chunks: - total += len(chunk) - received_chunks.append(chunk) +class _StreamHandler(BaseHTTPRequestHandler): + """Mirrors tests/server.py's /stream/{total_bytes}: writes `total_bytes` + in 4096-byte pieces, misaligned with CHUNK_SIZE on purpose.""" - assert total == 50_000 - assert len(received_chunks) > 1 + def do_GET(self): + match = re.fullmatch(r"/stream/(\d+)", self.path) + if not match: + self.send_error(404) + return - await client.close() + self.send_response(200) + self.end_headers() + total = int(match.group(1)) + written = 0 + while written < total: + piece = min(4096, total - written) + self.wfile.write(b"x" * piece) + written += piece -@pytest.mark.asyncio -async def test_call_method_stream_early_break_closes_connection( - integration_server, mock_request_dumper, mock_response_loader -): - mock_request_dumper.dump.side_effect = lambda method: { - "path": {"total_bytes": str(method.total_bytes)}, - } + def log_message(self, format, *args): + pass - client = AiohttpAsyncClient( - base_url=str(integration_server.make_url("/")), - request_dumper=mock_request_dumper, - response_loader=mock_response_loader, - ) - async with await client.call_method_stream(DownloadStream(total_bytes=1_000_000)) as chunks: - async for chunk in chunks: - break # stop after the first chunk, well before the body ends +@pytest.fixture +def threaded_server(): + server = ThreadingHTTPServer(("127.0.0.1", 0), _StreamHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() - # Prove the early `break` + `async with` exit actually released the - # connection back to the pool immediately, rather than merely relying on - # `client.close()` (below) to tear down everything regardless. aiohttp's - # BaseConnector tracks in-flight connections in `_acquired`; it must be - # empty once the streaming context manager has exited. - assert len(client._session.connector._acquired) == 0 + yield f"http://127.0.0.1:{server.server_port}/" - await client.close() + server.shutdown() + thread.join(timeout=5) + server.server_close() @pytest.mark.asyncio -async def test_call_method_stream_error_status_releases_connection( - integration_server, mock_request_dumper, mock_response_loader -): - """`handle_error` raising on a non-ok status must not leak the connection. - - Regression test: closing the chunk stream used to rely on the backend's - generator having been iterated at least once (a generator's own - `finally` never runs if `.close()`/`.aclose()` is called before the - first `next()`). `ChunkStream.close()` must release the connection - directly, regardless of whether the caller ever got to iterate. - """ - mock_request_dumper.dump.side_effect = lambda method: { - "path": {"total_bytes": str(method.total_bytes)}, - } +@pytest.mark.parametrize("name,client_cls", ASYNC_CLIENTS, ids=[name for name, _ in ASYNC_CLIENTS]) +async def test_async_stream_reassembles_full_body(name, client_cls, threaded_server, mock_request_dumper, mock_response_loader): + client = make_client(client_cls, threaded_server, mock_request_dumper, mock_response_loader) - class _RaisingClient(AiohttpAsyncClient): - def handle_error(self, response, method): - raise RuntimeError(f"HTTP {response.status_code}") + total = 0 + received_chunks = [] + async with await client.call_method_stream(DownloadStream(TOTAL_BYTES, CHUNK_SIZE)) as chunks: + async for chunk in chunks: + total += len(chunk) + received_chunks.append(chunk) - client = _RaisingClient( - base_url=str(integration_server.make_url("/")), - request_dumper=mock_request_dumper, - response_loader=mock_response_loader, - ) + assert total == TOTAL_BYTES + assert len(received_chunks) > 1 - with pytest.raises(RuntimeError): - async with await client.call_method_stream(DownloadStreamError(total_bytes=1000)) as chunks: - async for _chunk in chunks: - pass + await client.close() - assert len(client._session.connector._acquired) == 0 - await client.close() +@pytest.mark.parametrize("name,client_cls", SYNC_CLIENTS, ids=[name for name, _ in SYNC_CLIENTS]) +def test_sync_stream_reassembles_full_body(name, client_cls, threaded_server, mock_request_dumper, mock_response_loader): + client = make_client(client_cls, threaded_server, mock_request_dumper, mock_response_loader) + + total = 0 + received_chunks = [] + with client.call_method_stream(DownloadStream(TOTAL_BYTES, CHUNK_SIZE)) as chunks: + for chunk in chunks: + total += len(chunk) + received_chunks.append(chunk) + + assert total == TOTAL_BYTES + assert len(received_chunks) > 1 + + client.close() From efcab44ff53e231e584abbbd98d4d1b3402fc789 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:00:15 +0300 Subject: [PATCH 10/12] fix: remove IteratorChunkStream, IteratorChunkStream --- src/unihttp/http/stream.py | 41 +------------------------------------- 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/src/unihttp/http/stream.py b/src/unihttp/http/stream.py index 20462cb..cc29da1 100644 --- a/src/unihttp/http/stream.py +++ b/src/unihttp/http/stream.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from collections.abc import AsyncIterator, Awaitable, Callable, Iterator +from collections.abc import AsyncIterator, Iterator class ChunkStream(ABC): @@ -83,42 +83,3 @@ async def __aenter__(self) -> "AsyncChunkStream": async def __aexit__(self, *exc_info: object) -> None: await self.aclose() - - -class IteratorChunkStream(ChunkStream): - """`ChunkStream` backed by a plain sync byte iterator plus a close callback. - - Fits backends where iteration and cleanup are just `next(iterator)` and - a zero-arg call — `response.close`, or an `ExitStack`'s `.close` when - the response lives behind a manually-driven context manager. Backends - with a mismatched close signature (e.g. sync `close()` on an async - response) use a dedicated `ChunkStream` subclass instead. - """ - - def __init__(self, iterator: Iterator[bytes], close: Callable[[], None]) -> None: - super().__init__() - self._iter = iterator - self._close = close - - def _fetch_chunk(self) -> bytes: - return next(self._iter) - - def _close_response(self) -> None: - self._close() - - -class AsyncIteratorChunkStream(AsyncChunkStream): - """Async counterpart of `IteratorChunkStream`.""" - - def __init__( - self, iterator: AsyncIterator[bytes], close: Callable[[], Awaitable[None]] - ) -> None: - super().__init__() - self._iter = iterator - self._close = close - - async def _fetch_chunk(self) -> bytes: - return await anext(self._iter) - - async def _close_response(self) -> None: - await self._close() From 8be6b652ced3791d5c62f43f178cfb2427ff7872 Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:17:36 +0300 Subject: [PATCH 11/12] feat: add tests for stream_make_request error paths in zapros/niquests clients --- tests/test_clients/test_niquests.py | 14 ++++++ tests/test_clients/test_zapros.py | 71 +++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/tests/test_clients/test_niquests.py b/tests/test_clients/test_niquests.py index 25115b5..6e15f47 100644 --- a/tests/test_clients/test_niquests.py +++ b/tests/test_clients/test_niquests.py @@ -284,6 +284,20 @@ async def test_make_request(self, async_client: BaseAsyncClient, mocker): stream=False, ) + @pytest.mark.asyncio + async def test_raw_body(self, async_client: BaseAsyncClient, mocker): + mock_response = Mock(status_code=200, headers={}, cookies={}, content=b"{}") + mock_session_request = mocker.patch("niquests.AsyncSession.request", new_callable=AsyncMock) + mock_session_request.return_value = mock_response + + request = HTTPRequest( + url="/raw", method="POST", header={}, path={}, query={}, + body={}, file={}, form={}, raw=b"raw-payload" + ) + await async_client.make_request(request) + + assert mock_session_request.call_args[1]["data"] == b"raw-payload" + @pytest.mark.asyncio async def test_network_error(self, async_client: BaseAsyncClient, mocker): mocker.patch("niquests.AsyncSession.request", side_effect=niquests.exceptions.ConnectionError("Connection Check")) diff --git a/tests/test_clients/test_zapros.py b/tests/test_clients/test_zapros.py index d684eee..e79ecad 100644 --- a/tests/test_clients/test_zapros.py +++ b/tests/test_clients/test_zapros.py @@ -433,6 +433,30 @@ def test_stream_make_request(self, sync_client: BaseSyncClient, mocker): response.iter_bytes.assert_called_once_with(1234) assert stream_cm.exit_calls == [(None, None, None)] + def test_stream_make_request_timeout_error(self, sync_client: BaseSyncClient, mocker): + mock_cm = Mock(__enter__=Mock(side_effect=zapros.ReadTimeoutError("Timeout Check")), __exit__=Mock()) + mocker.patch("zapros.Client.stream", return_value=mock_cm) + + client = cast(ZaprosSyncClient, sync_client) + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + with pytest.raises(RequestTimeoutError, match="Timeout Check"): + client.stream_make_request(request) + + def test_stream_make_request_network_error(self, sync_client: BaseSyncClient, mocker): + mock_cm = Mock(__enter__=Mock(side_effect=zapros.ConnectionError("Connection Check")), __exit__=Mock()) + mocker.patch("zapros.Client.stream", return_value=mock_cm) + + client = cast(ZaprosSyncClient, sync_client) + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + with pytest.raises(NetworkError, match="Connection Check"): + client.stream_make_request(request) + def test_chunk_stream_mid_stream_error_translated(self): """A connection drop after some chunks were already yielded must be reported as `NetworkError`/`RequestTimeoutError`, not the raw @@ -591,6 +615,21 @@ async def test_network_error(self, async_client: BaseAsyncClient, mocker): with pytest.raises(NetworkError, match="Connection Check"): await client.make_request(request) + @pytest.mark.asyncio + async def test_raw_body(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="/raw", method="POST", header={}, path={}, query={}, + body={}, file={}, form={}, raw=b"raw-payload" + ) + await client.make_request(request) + + assert mock_request.call_args[1]["body"] == b"raw-payload" + @pytest.mark.asyncio async def test_close(self, async_client: BaseAsyncClient, mocker): mock_close = mocker.patch("zapros.AsyncClient.aclose", new_callable=AsyncMock) @@ -636,6 +675,38 @@ async def gen(): response.async_iter_bytes.assert_called_once_with(1234) assert stream_cm.exit_calls == [(None, None, None)] + @pytest.mark.asyncio + async def test_stream_make_request_timeout_error(self, async_client: BaseAsyncClient, mocker): + mock_cm = Mock( + __aenter__=AsyncMock(side_effect=zapros.ConnectTimeoutError("Timeout Check")), + __aexit__=AsyncMock(), + ) + mocker.patch("zapros.AsyncClient.stream", return_value=mock_cm) + + client = cast(ZaprosAsyncClient, async_client) + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + with pytest.raises(RequestTimeoutError, match="Timeout Check"): + await client.stream_make_request(request) + + @pytest.mark.asyncio + async def test_stream_make_request_network_error(self, async_client: BaseAsyncClient, mocker): + mock_cm = Mock( + __aenter__=AsyncMock(side_effect=zapros.ConnectionError("Connection Check")), + __aexit__=AsyncMock(), + ) + mocker.patch("zapros.AsyncClient.stream", return_value=mock_cm) + + client = cast(ZaprosAsyncClient, async_client) + request = HTTPRequest( + url="/download", method="GET", header={}, path={}, query={}, + body={}, file={}, form={} + ) + with pytest.raises(NetworkError, match="Connection Check"): + await client.stream_make_request(request) + @pytest.mark.asyncio async def test_chunk_stream_mid_stream_error_translated(self): From 7dd34131dca06962a9a622015695e532e049425d Mon Sep 17 00:00:00 2001 From: m-xim <170838360+m-xim@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:41:56 +0300 Subject: [PATCH 12/12] fix: unspecced AsyncMock leaking coroutine warning in httpx/httpx2 json-error tests --- tests/test_clients/test_json_load_errors.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_clients/test_json_load_errors.py b/tests/test_clients/test_json_load_errors.py index 557ea64..b2651a3 100644 --- a/tests/test_clients/test_json_load_errors.py +++ b/tests/test_clients/test_json_load_errors.py @@ -1,3 +1,5 @@ +import httpx +import httpx2 import pytest from unittest.mock import Mock, AsyncMock, MagicMock from unihttp.http.request import HTTPRequest @@ -42,7 +44,7 @@ def test_httpx_sync_json_error(mock_request, mock_request_dumper, mock_response_ @pytest.mark.asyncio async def test_httpx_async_json_error(mock_request, mock_request_dumper, mock_response_loader): - mock_session = AsyncMock() + mock_session = AsyncMock(spec=httpx.AsyncClient) mock_response = Mock() mock_response.content = b"not json" mock_response.text = "not json" @@ -71,7 +73,7 @@ def test_httpx2_sync_json_error(mock_request, mock_request_dumper, mock_response @pytest.mark.asyncio async def test_httpx2_async_json_error(mock_request, mock_request_dumper, mock_response_loader): - mock_session = AsyncMock() + mock_session = AsyncMock(spec=httpx2.AsyncClient) mock_response = Mock() mock_response.content = b"not json" mock_response.text = "not json"