Skip to content

Commit

Permalink
feat: Support async httpx client (#113)
Browse files Browse the repository at this point in the history
  • Loading branch information
pmateusz authored Jan 12, 2025
1 parent e992dc5 commit e7a3240
Show file tree
Hide file tree
Showing 12 changed files with 236 additions and 24 deletions.
2 changes: 1 addition & 1 deletion src/meatie/aio/descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def __init__(
) -> None:
self.template = template
self.response_decoder = response_decoder
self.get_json: Optional[Callable[[Any], Awaitable[dict[str, Any]]]] = None
self.get_json: Optional[Callable[[Any], Awaitable[Any]]] = None
self.get_text: Optional[Callable[[Any], Awaitable[str]]] = None
self.get_error: Optional[Callable[[AsyncResponse], Awaitable[Optional[Exception]]]] = None
self.__operator_by_priority: dict[int, AsyncOperator[ResponseBodyType]] = {}
Expand Down
2 changes: 1 addition & 1 deletion src/meatie/descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def __init__(
operators: Iterable[Operator[ResponseBodyType]],
template: RequestTemplate[Any],
response_decoder: TypeAdapter[ResponseBodyType],
get_json: Optional[Callable[[Any], dict[str, Any]]],
get_json: Optional[Callable[[Any], Any]],
get_text: Optional[Callable[[Any], str]],
get_error: Optional[Callable[[Response], Optional[Exception]]],
) -> None:
Expand Down
4 changes: 2 additions & 2 deletions src/meatie/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ async def read(self) -> bytes:
async def text(self) -> str:
...

async def json(self) -> dict[str, Any]:
async def json(self) -> Any:
...


Expand All @@ -55,5 +55,5 @@ def read(self) -> bytes:
def text(self) -> str:
...

def json(self) -> dict[str, Any]:
def json(self) -> Any:
...
4 changes: 3 additions & 1 deletion src/meatie_httpx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@
# isort:skip_file
from .response import Response
from .client import Client
from .async_response import AsyncResponse
from .async_client import AsyncClient

__all__ = ["Response", "Client"]
__all__ = ["Response", "Client", "AsyncResponse", "AsyncClient"]
73 changes: 73 additions & 0 deletions src/meatie_httpx/async_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Copyright 2025 The Meatie Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be found in the LICENSE file.
from typing import Any, Optional

import httpx
from meatie import (
BaseAsyncClient,
Cache,
MeatieError,
ProxyError,
RequestError,
ServerError,
Timeout,
TransportError,
)
from meatie.types import Request
from typing_extensions import Self

from .async_response import AsyncResponse
from .client import build_kwargs


class AsyncClient(BaseAsyncClient):
def __init__(
self,
client: httpx.AsyncClient,
client_params: Optional[dict[str, Any]] = None,
local_cache: Optional[Cache] = None,
limiter: Optional[Any] = None,
prefix: Optional[str] = None,
) -> None:
super().__init__(local_cache, limiter)

self.client = client
self.client_params = client_params if client_params else {}
self.prefix = prefix

async def send(self, request: Request) -> AsyncResponse:
kwargs = build_kwargs(request, self.client_params)

path = request.path
if self.prefix is not None:
path = self.prefix + path

try:
response = await self.client.request(request.method, path, **kwargs)
except (httpx.InvalidURL, httpx.UnsupportedProtocol) as exc:
raise RequestError(exc) from exc
except httpx.ProxyError as exc:
raise ProxyError(exc) from exc
except httpx.TimeoutException as exc:
raise Timeout(exc) from exc
except (httpx.NetworkError, httpx.RemoteProtocolError) as exc:
raise ServerError(exc) from exc
except (httpx.TooManyRedirects, httpx.ProtocolError) as exc:
raise TransportError(exc) from exc
except httpx.HTTPError as exc:
raise MeatieError(exc) from exc
return AsyncResponse(response)

async def __aenter__(self) -> Self:
return self

async def __aexit__(
self,
exc_type: Optional[type[BaseException]],
exc_val: Optional[BaseException],
exc_tb: Any,
) -> None:
await self.close()

async def close(self) -> None:
await self.client.aclose()
54 changes: 54 additions & 0 deletions src/meatie_httpx/async_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Copyright 2025 The Meatie Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be found in the LICENSE file.
from json.decoder import JSONDecodeError
from typing import Any, Awaitable, Callable, Optional

import httpx
from meatie.error import ParseResponseError, ResponseError


class AsyncResponse:
def __init__(
self,
response: httpx.Response,
get_json: Optional[Callable[[httpx.Response], Awaitable[Any]]] = None,
get_text: Optional[Callable[[httpx.Response], Awaitable[str]]] = None,
) -> None:
self.response = response
if get_json is not None:
self.get_json = get_json # type: ignore[assignment]
if get_text is not None:
self.get_text = get_text # type: ignore[assignment]

@property
def status(self) -> int:
return self.response.status_code

async def read(self) -> bytes:
try:
return self.response.content
except Exception as exc:
raise ResponseError(self, exc) from exc

async def text(self) -> str:
try:
return await self.get_text(self.response)
except Exception as exc:
raise ResponseError(self, exc) from exc

async def json(self) -> dict[str, Any]:
try:
return await self.get_json(self.response)
except JSONDecodeError as exc:
text = await self.text()
raise ParseResponseError(text, self, exc) from exc
except Exception as exc:
raise ResponseError(self, exc) from exc

@classmethod
async def get_json(cls, response: httpx.Response) -> Any:
return response.json()

@classmethod
async def get_text(cls, response: httpx.Response) -> str:
return response.text
32 changes: 19 additions & 13 deletions src/meatie_httpx/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,24 +34,12 @@ def __init__(
self.prefix = prefix

def send(self, request: Request) -> Response:
kwargs: dict[str, Any] = self.client_params.copy()
kwargs = build_kwargs(request, self.client_params)

path = request.path
if self.prefix is not None:
path = self.prefix + path

if request.data is not None:
kwargs["content"] = request.data

if request.json is not None:
kwargs["json"] = request.json

if request.headers:
kwargs["headers"] = request.headers

if request.params:
kwargs["params"] = request.params

try:
response = self.client.request(request.method, path, **kwargs)
except (httpx.InvalidURL, httpx.UnsupportedProtocol) as exc:
Expand Down Expand Up @@ -81,3 +69,21 @@ def __exit__(

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


def build_kwargs(request: Request, client_params: dict[str, Any]) -> dict[str, Any]:
kwargs = client_params.copy()

if request.data is not None:
kwargs["content"] = request.data

if request.json is not None:
kwargs["json"] = request.json

if request.headers:
kwargs["headers"] = request.headers

if request.params:
kwargs["params"] = request.params

return kwargs
6 changes: 3 additions & 3 deletions src/meatie_httpx/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class Response:
def __init__(
self,
response: httpx.Response,
get_json: Optional[Callable[[httpx.Response], dict[str, Any]]] = None,
get_json: Optional[Callable[[httpx.Response], Any]] = None,
get_text: Optional[Callable[[httpx.Response], str]] = None,
) -> None:
self.response = response
Expand All @@ -36,7 +36,7 @@ def text(self) -> str:
except Exception as exc:
raise ResponseError(self, exc) from exc

def json(self) -> dict[str, Any]:
def json(self) -> Any:
try:
return self.get_json(self.response)
except JSONDecodeError as exc:
Expand All @@ -46,7 +46,7 @@ def json(self) -> dict[str, Any]:
raise ResponseError(self, exc) from exc

@classmethod
def get_json(cls, response: httpx.Response) -> dict[str, Any]:
def get_json(cls, response: httpx.Response) -> Any:
return response.json()

@classmethod
Expand Down
6 changes: 3 additions & 3 deletions src/meatie_requests/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class Response:
def __init__(
self,
response: requests.Response,
get_json: Optional[Callable[[requests.Response], dict[str, Any]]] = None,
get_json: Optional[Callable[[requests.Response], Any]] = None,
get_text: Optional[Callable[[requests.Response], str]] = None,
) -> None:
self.response = response
Expand All @@ -35,7 +35,7 @@ def text(self) -> str:
except Exception as exc:
raise ResponseError(self, exc) from exc

def json(self) -> dict[str, Any]:
def json(self) -> Any:
try:
return self.get_json(self.response)
except requests.JSONDecodeError as exc:
Expand All @@ -45,7 +45,7 @@ def json(self) -> dict[str, Any]:
raise ResponseError(self, exc) from exc

@classmethod
def get_json(cls, response: requests.Response) -> dict[str, Any]:
def get_json(cls, response: requests.Response) -> Any:
return response.json()

@classmethod
Expand Down
20 changes: 20 additions & 0 deletions tests/client/httpx/async/test_default.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright 2024 The Meatie Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be found in the LICENSE file.
import asyncio
from typing import Generator

import httpx
import pytest
from http_test import ClientAdapter
from meatie_httpx import AsyncClient
from suite.client import DefaultSuite


class TestAsyncHttpxDefaultSuite(DefaultSuite):
@pytest.fixture(name="client")
def client_fixture(
self,
event_loop: asyncio.AbstractEventLoop,
) -> Generator[ClientAdapter, None, None]:
with ClientAdapter(event_loop, AsyncClient(httpx.AsyncClient())) as client:
yield client
35 changes: 35 additions & 0 deletions tests/client/httpx/async/test_proxy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright 2024 The Meatie Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be found in the LICENSE file.

import asyncio
from typing import Generator

import httpx
import pytest
from http_test import ClientAdapter, HTTPTestServer
from meatie import Request, ServerError
from meatie_httpx import AsyncClient, Client


class TestAsyncHttpxProxyErrorSuite:
@pytest.fixture(name="client")
def client_fixture(
self,
event_loop: asyncio.AbstractEventLoop,
) -> Generator[ClientAdapter, None, None]:
with ClientAdapter(
event_loop, AsyncClient(httpx.AsyncClient(proxy="http://localhost:3128"))
) as client:
yield client

@staticmethod
def test_can_handle_proxy_error(client: Client, http_server: HTTPTestServer) -> None:
# GIVEN
request = Request("GET", http_server.base_url, params={}, headers={})

# WHEN
with pytest.raises(ServerError) as exc_info:
client.send(request)

# THEN
assert exc_info.value.__cause__ is not None
22 changes: 22 additions & 0 deletions tests/client/httpx/async/test_timeout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Copyright 2024 The Meatie Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be found in the LICENSE file.
import asyncio
from typing import Generator

import httpx
import pytest
from http_test import ClientAdapter
from meatie_httpx import AsyncClient
from suite.client import TimeoutSuite


class TestHttpxTimeoutSuite(TimeoutSuite):
@pytest.fixture(name="client")
def client_fixture(
self,
event_loop: asyncio.AbstractEventLoop,
) -> Generator[ClientAdapter, None, None]:
with ClientAdapter(
event_loop, AsyncClient(httpx.AsyncClient(), client_params={"timeout": 0.005})
) as client:
yield client

0 comments on commit e7a3240

Please sign in to comment.