first commit

This commit is contained in:
2025-10-26 13:38:37 +03:00
commit 15990c82df
11 changed files with 492 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# Ruff
.ruff_cache
# uv
uv.lock

34
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,34 @@
repos:
- repo: https://github.com/crate-ci/typos
rev: v1.36.3
hooks:
- id: typos
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.2
hooks:
- id: ruff
args: [ --fix ]
- id: ruff-format
- repo: https://github.com/RobertCraigie/pyright-python
rev: v1.1.405
hooks:
- id: pyright
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: check-docstring-first
- id: check-added-large-files
- id: check-yaml
- id: debug-statements
- id: check-merge-conflict
- id: double-quote-string-fixer
- id: end-of-file-fixer
- repo: meta
hooks:
- id: check-hooks-apply
- id: check-useless-excludes

0
README.md Normal file
View File

93
pyproject.toml Normal file
View File

@ -0,0 +1,93 @@
[project]
name = "aiohttpx"
version = "0.1.0"
description = "Custom HTTPX client with aiohttp transport, rate limiter and caching"
readme = "README.md"
authors = [
{ name = "Miwory", email = "miwory.uwu@gmail.com" }
]
requires-python = ">=3.13"
dependencies = [
"aiohttp==3.13.1",
"httpx==0.28.1",
"orjson==3.11.4",
"redis[hiredis]==7.0.0",
]
[project.scripts]
aiohttpx = "aiohttpx:main"
[project.optional-dependencies]
dev = [
"ruff==0.14.2",
"pyright==1.1.406",
"poethepoet==0.37.0",
"pre-commit==4.3.0",
"types-redis==4.6.0.20241004",
]
[tool.poe.tasks]
_git = "git add ."
_lint = "pre-commit run --all-files"
lint = ["_git", "_lint"]
check = "uv pip ls --outdated"
[build-system]
requires = ["uv_build>=0.9.2,<0.10.0"]
build-backend = "uv_build"
[tool.pyright]
venvPath = "."
venv = ".venv"
strictListInference = true
strictDictionaryInference = true
strictSetInference = true
deprecateTypingAliases = true
typeCheckingMode = "strict"
pythonPlatform = "All"
[tool.ruff]
target-version = "py313"
line-length = 79
fix = true
[tool.ruff.lint]
preview = true
select = [
"E",
"W",
"F",
"UP",
"A",
"B",
"C4",
"SIM",
"I",
"S",
"G",
"FAST",
"ASYNC",
"BLE",
"INT",
"ISC",
"ICN",
"PYI",
"INP",
"RSE",
"PIE",
"SLOT",
"TID",
"LOG",
"FBT",
"DTZ",
"EM",
"PERF",
"RUF",
]
ignore = ["RUF029", "S101", "S104"]
[tool.ruff.format]
quote-style = "single"
indent-style = "space"
docstring-code-format = true

1
src/aiohttpx/__init__.py Normal file
View File

@ -0,0 +1 @@
__version__: str = '0.1.0'

62
src/aiohttpx/client.py Normal file
View File

@ -0,0 +1,62 @@
from collections.abc import Callable, Mapping
from logging import getLogger
from ssl import SSLContext
from typing import Any
from httpx import URL, Limits
from httpx import AsyncClient as AsyncHTTPXClient
from httpx import _config as c # type: ignore
from httpx import _types as t # type: ignore
from aiohttpx.transports.cache import AsyncCacheTransport
class AioHTTPXClient(AsyncHTTPXClient):
def __init__(
self,
*,
auth: t.AuthTypes | None = None,
params: t.QueryParamTypes | None = None,
headers: t.HeaderTypes | None = None,
cookies: t.CookieTypes | None = None,
verify: SSLContext | str | bool = True,
cert: t.CertTypes | None = None,
proxy: t.ProxyTypes | None = None,
timeout: t.TimeoutTypes = c.DEFAULT_TIMEOUT_CONFIG,
follow_redirects: bool = False,
limits: Limits = c.DEFAULT_LIMITS,
max_redirects: int = c.DEFAULT_MAX_REDIRECTS,
event_hooks: Mapping[str, list[Callable[..., Any]]] | None = None,
base_url: URL | str = '',
trust_env: bool = True,
default_encoding: str | Callable[[bytes], str] = 'utf-8',
redis_url: str | None = None,
key: str | None = None,
limit: int | None = None,
):
super().__init__(
auth=auth,
params=params,
headers=headers,
cookies=cookies,
verify=verify,
cert=cert,
http1=True,
http2=False,
proxy=proxy,
mounts=None,
timeout=timeout,
follow_redirects=follow_redirects,
limits=limits,
max_redirects=max_redirects,
event_hooks=event_hooks,
base_url=base_url,
transport=AsyncCacheTransport(redis_url, key, limit),
trust_env=trust_env,
default_encoding=default_encoding,
)
self.logger = getLogger(__name__)
__all__ = ['AioHTTPXClient']

View File

@ -0,0 +1,9 @@
from typing import Any
from httpx import Response as HTTPXResponse
from orjson import loads
class Response(HTTPXResponse):
def json(self, **kwargs: Any):
return loads(self.content, **kwargs)

View File

View File

@ -0,0 +1,129 @@
import asyncio
from logging import getLogger
from types import TracebackType
from typing import Any
import aiohttp
import httpx
from aiohttpx.responses import Response
EXCEPTIONS = {
aiohttp.ClientError: httpx.RequestError,
aiohttp.ClientConnectionError: httpx.NetworkError,
aiohttp.ClientConnectorError: httpx.ConnectError,
aiohttp.ClientOSError: httpx.ConnectError,
aiohttp.ClientConnectionResetError: httpx.ConnectError,
aiohttp.ClientConnectorDNSError: httpx.ConnectError,
aiohttp.ClientSSLError: httpx.ProtocolError,
aiohttp.ClientConnectorCertificateError: httpx.ProtocolError,
aiohttp.ServerFingerprintMismatch: httpx.ProtocolError,
aiohttp.ClientProxyConnectionError: httpx.ProxyError,
aiohttp.ClientResponseError: httpx.HTTPStatusError,
aiohttp.ContentTypeError: httpx.DecodingError,
aiohttp.ClientPayloadError: httpx.ReadError,
aiohttp.ServerDisconnectedError: httpx.ReadError,
aiohttp.InvalidURL: httpx.InvalidURL,
aiohttp.TooManyRedirects: httpx.TooManyRedirects,
}
class AiohttpTransport(httpx.AsyncBaseTransport):
def __init__(
self,
session: aiohttp.ClientSession | None = None,
):
self.logger = getLogger(__name__)
self._session = session or aiohttp.ClientSession()
self._closed = False
def map_aiohttp_exception(self, exc: Exception):
for aiohttp_exc, httpx_exc in EXCEPTIONS.items():
if isinstance(exc, aiohttp_exc):
return httpx_exc(message=str(exc)) # type: ignore
if isinstance(exc, asyncio.TimeoutError):
return httpx.TimeoutException(str(exc))
return httpx.HTTPError(f'Unknown error: {exc!s}')
async def __aenter__(self):
await self._session.__aenter__()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None = None,
exc_value: BaseException | None = None,
traceback: TracebackType | None = None,
):
await self._session.__aexit__(exc_type, exc_value, traceback)
self._closed = True
async def aclose(self):
if not self._closed:
self._closed = True
await self._session.close()
async def handle_async_request(self, request: httpx.Request):
headers = dict(request.headers)
method = request.method
url = str(request.url)
content = request.content
for i in range(5):
try:
return await self.make_request(method, url, headers, content)
except aiohttp.ClientError as e:
exception = self.map_aiohttp_exception(e)
if not isinstance(
exception,
(
httpx.NetworkError,
httpx.ConnectError,
httpx.ConnectTimeout,
),
):
self.logger.error(
exception, extra={'reason': 'Request error'}
)
raise exception from e
if i == 4:
self.logger.error(
exception, extra={'reason': 'Too many retries'}
)
raise exception from e
msg = 'Unknown error'
raise RuntimeError(msg)
async def make_request(
self, method: str, url: str, headers: dict[str, Any], data: bytes
):
if self._closed:
msg = 'Cannot make request: Transport session is closed'
raise RuntimeError(msg)
async with self._session.request(
method=method,
url=url,
headers=headers,
data=data,
allow_redirects=True,
) as aiohttp_response:
content = await aiohttp_response.read()
httpx_headers = [
(k.lower(), v)
for k, v in aiohttp_response.headers.items()
if k.lower() != 'content-encoding'
]
return Response(
status_code=aiohttp_response.status,
headers=httpx_headers,
content=content,
)

View File

@ -0,0 +1,93 @@
from httpx import Request
from httpx import Response as HTTPXResponse
from httpx import _models as m # type: ignore
from orjson import dumps, loads
from aiohttpx.responses import Response
from aiohttpx.transports.rate_limiter import AsyncRateLimit, Redis
def generate_cache_key(request: Request):
request_data = {
'method': request.method,
'url': str(request.url),
}
return f'cache:{hash(str(dumps(request_data)))}'
def cache_response(
client: Redis[bytes],
cache_key: str,
request: Request,
response: Response | HTTPXResponse,
) -> None:
serialized_response = serialize_response(response)
ttl = get_ttl_from_headers(request.headers)
if ttl:
client.set(cache_key, serialized_response, ex=ttl)
def get_ttl_from_headers(headers: m.Headers):
if 'X-Cache-TTL' in headers:
try:
return int(headers['X-Cache-TTL'])
except (ValueError, TypeError):
return None
def get_cached_response(client: Redis[bytes], cache_key: str):
cached_data = client.get(cache_key)
if cached_data:
return deserialize_response(cached_data)
return None
def serialize_response(response: Response | HTTPXResponse):
response_data = {
'status_code': response.status_code,
'headers': dict(response.headers),
'content': response.content.decode(),
}
return dumps(response_data)
def deserialize_response(serialized_response: bytes):
response_data = loads(serialized_response)
return Response(
status_code=response_data['status_code'],
headers=response_data['headers'],
content=response_data['content'].encode(),
)
class AsyncCacheTransport(AsyncRateLimit):
def __init__(
self, redis_url: str | None, key: str | None, limit: int | None
):
if redis_url:
self.client = Redis.from_url(redis_url)
else:
self.client = None
self.transport = AsyncRateLimit(self.client, key, limit)
async def handle_async_request(self, request: Request):
if not self.client:
return await self.transport.handle_async_request(request)
cache_key = generate_cache_key(request)
cached_response = get_cached_response(self.client, cache_key)
if cached_response:
return cached_response
response = await self.transport.handle_async_request(request)
cache_response(self.client, cache_key, request, response)
return response

View File

@ -0,0 +1,55 @@
from asyncio import sleep as async_sleep
from httpx import Request
from redis import Redis
from aiohttpx.responses import Response
from aiohttpx.transports.aio import AiohttpTransport
class AsyncRateLimit(AiohttpTransport):
def __init__(
self, redis: Redis[bytes] | None, key: str | None, limit: int | None
):
self.transport = AiohttpTransport()
self.client = redis
self.key = key
self.limit = limit
if (
(key and not redis)
or (key and not limit)
or (limit and not redis)
or (limit and not key)
):
msg = 'Incorrectly configured for rate limiting'
raise Exception(msg)
async def request_is_limited(self):
if self.client and self.key and self.limit:
t: int = int(self.client.time()[0]) # type: ignore
separation = round(60 / self.limit)
value = self.client.get(self.key) or t
self.client.setnx(self.key, value)
tat = max(int(value), t)
if tat - t <= 60 - separation:
new_tat = max(tat, t) + separation
self.client.set(self.key, new_tat)
return False
return True
return False
async def handle_async_request(self, request: Request) -> Response:
if not self.client:
return await self.transport.handle_async_request(request)
if await self.request_is_limited():
await async_sleep(0.25)
return await self.handle_async_request(request)
return await self.transport.handle_async_request(request)