Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

- **Declarative**: Define API methods using standard Python type hints.
- **Type-Safe**: Full support for static type checking.
- **Backend Agnostic**: Works with `httpx`, `aiohttp`, `requests`, `niquests` and `zapros`.
- **Backend Agnostic**: Works with `httpx`, `httpx2`, `aiohttp`, `requests`, `niquests` and `zapros`.
- **Extensible**: Powerful middleware and error handling systems.

## Installation
Expand All @@ -48,6 +48,8 @@ To include a specific HTTP backend (recommended):
```bash
pip install "unihttp[httpx]" # For HTTPX (Sync/Async) support
# OR
pip install "unihttp[httpx2]" # For HTTPX2 (Sync/Async) support
# OR
pip install "unihttp[niquests]" # For niquests (Sync/Async) support
# OR
pip install "unihttp[requests]" # For Requests (Sync) support
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Issues = "https://github.com/goduni/unihttp/issues"

[project.optional-dependencies]
httpx = ["httpx>=0.28.1"]
httpx2 = ["httpx2>=2.0.0"]
requests = ["requests>=2.32.0"]
aiohttp = ["aiohttp>=3.10.0"]
niquests = ["niquests>=3.17.0"]
Expand All @@ -53,6 +54,7 @@ msgspec = ["msgspec>=0.18.0"]
[dependency-groups]
optionals = [
"unihttp[httpx]",
"unihttp[httpx2]",
"unihttp[requests]",
"unihttp[aiohttp]",
"unihttp[niquests]",
Expand Down
196 changes: 196 additions & 0 deletions src/unihttp/clients/httpx2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import json
from collections.abc import Callable
from typing import Any
from urllib.parse import urljoin

import httpx2
from httpx2 import AsyncClient, Client

from unihttp.clients.base import BaseAsyncClient, BaseSyncClient
from unihttp.exceptions import NetworkError, RequestTimeoutError
from unihttp.http import UploadFile
from unihttp.http.request import HTTPRequest
from unihttp.http.response import HTTPResponse
from unihttp.middlewares.base import AsyncMiddleware, Middleware
from unihttp.serialize import RequestDumper, ResponseLoader


class HTTPX2SyncClient(BaseSyncClient):
"""Synchronous client implementation using the `httpx2` library."""

def __init__(
self,
base_url: str,
request_dumper: RequestDumper,
response_loader: ResponseLoader,
middleware: list[Middleware] | None = None,
session: Client | None = None,
json_dumps: Callable[[Any], str] = json.dumps,
json_loads: Callable[[str | bytes | bytearray], Any] = json.loads,
):
super().__init__(
base_url=base_url,
request_dumper=request_dumper,
response_loader=response_loader,
middleware=middleware,
json_dumps=json_dumps,
json_loads=json_loads,
)

if session is None:
session = Client()

self._session = session

def _convert_files(self, files: dict[str, Any]) -> list[tuple[str, Any]]:
"""Convert files to a list of tuples for httpx2."""
file_list = []
for key, value in files.items():
if isinstance(value, list):
for item in value:
if isinstance(item, UploadFile):
file_list.append((key, item.to_tuple()))
else:
file_list.append((key, item))
elif isinstance(value, UploadFile):
file_list.append((key, value.to_tuple()))
else:
file_list.append((key, value))
return file_list

def make_request(self, request: HTTPRequest) -> HTTPResponse:
content = 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."
)
content = self.json_dumps(request.body)
if "Content-Type" not in request.header:
request.header["Content-Type"] = "application/json"

try:
files = self._convert_files(request.file) if request.file else None
response = self._session.request(
method=request.method,
url=urljoin(self.base_url, request.url),
headers=request.header,
params=request.query,
files=files,
content=content,
data=request.form,
)
except httpx2.NetworkError as e:
raise NetworkError(str(e)) from e
except httpx2.TimeoutException as e:
raise RequestTimeoutError(str(e)) from e

response_data: Any = None
if response.content:
try:
response_data = self.json_loads(response.content)
except (ValueError, TypeError):
response_data = response.content

return HTTPResponse(
status_code=response.status_code,
headers=response.headers,
cookies=response.cookies,
data=response_data,
raw_response=response,
)

def close(self) -> None:
self._session.close()


class HTTPX2AsyncClient(BaseAsyncClient):
"""Asynchronous client implementation using the `httpx2` library."""

def __init__(
self,
base_url: str,
request_dumper: RequestDumper,
response_loader: ResponseLoader,
middleware: list[AsyncMiddleware] | None = None,
session: AsyncClient | None = None,
json_dumps: Callable[[Any], str] = json.dumps,
json_loads: Callable[[str | bytes | bytearray], Any] = json.loads,
):
super().__init__(
base_url=base_url,
request_dumper=request_dumper,
response_loader=response_loader,
middleware=middleware,
json_dumps=json_dumps,
json_loads=json_loads,
)

if session is None:
session = AsyncClient()

self._session = session

def _convert_files(self, files: dict[str, Any]) -> list[tuple[str, Any]]:
"""Convert files to a list of tuples for httpx2."""
file_list = []
for key, value in files.items():
if isinstance(value, list):
for item in value:
if isinstance(item, UploadFile):
file_list.append((key, item.to_tuple()))
else:
file_list.append((key, item))
elif isinstance(value, UploadFile):
file_list.append((key, value.to_tuple()))
else:
file_list.append((key, value))
return file_list

async def make_request(self, request: HTTPRequest) -> HTTPResponse:
content = 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."
)
content = self.json_dumps(request.body)
if "Content-Type" not in request.header:
request.header["Content-Type"] = "application/json"

try:
files = self._convert_files(request.file) if request.file else None
response = await self._session.request(
method=request.method,
url=urljoin(self.base_url, request.url),
headers=request.header,
params=request.query,
files=files,
content=content,
data=request.form,
)
except httpx2.NetworkError as e:
raise NetworkError(str(e)) from e
except httpx2.TimeoutException as e:
raise RequestTimeoutError(str(e)) from e

response_data: Any = None
if response.content:
try:
response_data = self.json_loads(response.content)
except (ValueError, TypeError):
response_data = response.content

return HTTPResponse(
status_code=response.status_code,
headers=response.headers,
cookies=response.cookies,
data=response_data,
raw_response=response,
)

async def close(self) -> None:
await self._session.aclose()
14 changes: 14 additions & 0 deletions tests/test_clients/test_defaults.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
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.requests import RequestsSyncClient


Expand All @@ -24,6 +25,19 @@ def test_httpx_sync_default_session(mock_request_dumper, mock_response_loader):
assert client._session is not None


@pytest.mark.asyncio
async def test_httpx2_async_default_session(mock_request_dumper, mock_response_loader):
client = HTTPX2AsyncClient("http://base", mock_request_dumper, mock_response_loader)
await client.close()
assert client._session is not None


def test_httpx2_sync_default_session(mock_request_dumper, mock_response_loader):
client = HTTPX2SyncClient("http://base", mock_request_dumper, mock_response_loader)
client.close()
assert client._session is not None


def test_requests_default_session(mock_request_dumper, mock_response_loader):
client = RequestsSyncClient("http://base", mock_request_dumper, mock_response_loader)
client.close()
Expand Down
Loading
Loading