Skip to content

Commit 1313f4f

Browse files
feat: async client pilot (httpx) for cex.candle + cex.ticker (#142) (#149)
Closes #142 Add datamaxi.aio.AsyncDatamaxi (optional 'async' extra, httpx). Pilot scope: cex.candle + cex.ticker, async context manager, bounded retry of transient 5xx, ClientError/ServerError parity, and last_response. To avoid sync/async drift, extract endpoint resolution + error handling into datamaxi._dispatch (resolve_endpoint, raise_for_error, extract_limit_usage) and have the sync API delegate to them (behavior-preserving; full sync suite still green). httpx is an optional dependency (datamaxi[async]); importing the sync client never loads it, and using the async client without it raises a clear install hint. Query params are str()-encoded so bools match the sync urlencode output ('True', not httpx's 'true').
1 parent 9c5ae43 commit 1313f4f

6 files changed

Lines changed: 526 additions & 59 deletions

File tree

datamaxi/_dispatch.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Transport-agnostic request helpers shared by the sync and async clients.
2+
3+
Keeping the endpoint resolution and error handling here (rather than in
4+
``API``) lets the ``httpx``-based async client reuse exactly the same
5+
param-splitting and error semantics as the sync ``requests`` client, so the
6+
two can't drift.
7+
"""
8+
9+
import json
10+
from json import JSONDecodeError
11+
12+
from datamaxi.error import ClientError, ServerError
13+
from datamaxi.lib.utils import check_required_parameter
14+
from datamaxi._endpoints import ENDPOINTS
15+
16+
17+
def resolve_endpoint(op_id, **params):
18+
"""Resolve ``op_id`` + caller params into ``(method, url_path, query)``.
19+
20+
Uses ``datamaxi._endpoints.ENDPOINTS`` (generated from the backend
21+
OpenAPI spec) as the single source of truth for path, method, the
22+
path/query split, required params, and defaults.
23+
"""
24+
ep = ENDPOINTS.get(op_id)
25+
if ep is None:
26+
raise ValueError(f"unknown endpoint operation_id: {op_id!r}")
27+
28+
spec_params = ep.get("params", {})
29+
30+
unknown = set(params) - set(spec_params)
31+
if unknown:
32+
raise ValueError(
33+
f"{op_id}: unknown parameter(s) {sorted(unknown)}; "
34+
f"expected one of {sorted(spec_params)}"
35+
)
36+
37+
# Resolve each value: caller-supplied, else the registry default.
38+
values = {}
39+
for name, meta in spec_params.items():
40+
val = params.get(name)
41+
if val is None and "default" in meta:
42+
val = meta["default"]
43+
values[name] = val
44+
45+
# Enforce params the spec marks required.
46+
for name, meta in spec_params.items():
47+
if meta.get("required"):
48+
check_required_parameter(values.get(name), name)
49+
50+
# Split path vs query params; interpolate path params into the URL.
51+
url_path = ep["path"]
52+
query_params = {}
53+
for name, meta in spec_params.items():
54+
if meta.get("in") == "path":
55+
url_path = url_path.replace("{" + name + "}", str(values[name]))
56+
else:
57+
query_params[name] = values[name]
58+
59+
return ep["method"], url_path, query_params
60+
61+
62+
def raise_for_error(status_code, text, headers):
63+
"""Raise ``ClientError`` / ``ServerError`` for a 4xx / 5xx response.
64+
65+
Works on any response given its ``status_code`` / ``text`` / ``headers``,
66+
so it applies identically to ``requests`` and ``httpx`` responses.
67+
"""
68+
if status_code < 400:
69+
return
70+
if 400 <= status_code < 500:
71+
try:
72+
err = json.loads(text)
73+
except JSONDecodeError:
74+
raise ClientError(status_code, text, None, headers)
75+
error_data = None
76+
if "data" in err:
77+
error_data = err["data"]
78+
raise ClientError(status_code, err["error"], headers, error_data)
79+
raise ServerError(status_code, text)
80+
81+
82+
def extract_limit_usage(headers):
83+
"""Pull the ``x-ratelimit-*`` triplet out of the response headers."""
84+
usage = {}
85+
for key in headers.keys():
86+
k = key.lower()
87+
if (
88+
k.startswith("x-ratelimit-limit")
89+
or k.startswith("x-ratelimit-remaining")
90+
or k.startswith("x-ratelimit-reset")
91+
):
92+
usage[k] = headers[key]
93+
return usage

datamaxi/aio/__init__.py

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
"""Async client (pilot) — ``httpx``-based, mirrors a slice of the sync surface.
2+
3+
Requires the ``async`` extra::
4+
5+
pip install "datamaxi[async]"
6+
7+
Usage::
8+
9+
from datamaxi.aio import AsyncDatamaxi
10+
11+
async with AsyncDatamaxi(api_key="...") as client:
12+
df = await client.cex.candle(exchange="binance", market="spot",
13+
symbol="BTC-USDT")
14+
ticker = await client.cex.ticker.get(exchange="binance", market="spot",
15+
symbol="BTC-USDT")
16+
17+
This is a deliberately small pilot (candle + ticker). It reuses the sync
18+
client's endpoint resolution and error handling (``datamaxi._dispatch``) and
19+
the shared DataFrame / ResponseMeta helpers, so the two clients can't drift on
20+
request building or error semantics.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import asyncio
26+
import os
27+
from typing import Any, Union, TYPE_CHECKING
28+
29+
from datamaxi.__version__ import __version__
30+
from datamaxi.api import ResponseMeta
31+
from datamaxi._dispatch import resolve_endpoint, raise_for_error, extract_limit_usage
32+
from datamaxi.lib.constants import (
33+
BASE_URL,
34+
SPOT,
35+
FUTURES,
36+
USD,
37+
INTERVAL_1D,
38+
Market,
39+
Interval,
40+
)
41+
from datamaxi.lib.utils import check_required_parameters
42+
from datamaxi.resources.responses import CandleResponse, TickerResponse
43+
44+
if TYPE_CHECKING:
45+
import pandas as pd
46+
47+
48+
def _import_httpx():
49+
try:
50+
import httpx
51+
except ImportError as exc: # pragma: no cover - exercised via extra
52+
raise ImportError(
53+
"The async client requires httpx. Install it with: "
54+
"pip install 'datamaxi[async]'"
55+
) from exc
56+
return httpx
57+
58+
59+
class AsyncAPI:
60+
"""Async transport built on ``httpx.AsyncClient``.
61+
62+
Mirrors the sync ``API``: shared endpoint resolution, bounded retry of
63+
transient gateway 5xx, the same ``ClientError`` / ``ServerError`` contract,
64+
and ``last_response`` metadata.
65+
"""
66+
67+
def __init__(
68+
self,
69+
api_key=None,
70+
base_url=None,
71+
timeout=10,
72+
max_retries=3,
73+
retry_backoff=0.5,
74+
retry_statuses=(502, 503, 504),
75+
transport=None,
76+
):
77+
httpx = _import_httpx()
78+
self.api_key = api_key or os.environ.get("DATAMAXI_API_KEY")
79+
self.base_url = base_url
80+
self.timeout = timeout
81+
self.max_retries = max_retries
82+
self.retry_backoff = retry_backoff
83+
self.retry_statuses = tuple(retry_statuses)
84+
self.last_response = None
85+
self._client = httpx.AsyncClient(
86+
base_url=base_url or "",
87+
timeout=timeout,
88+
transport=transport,
89+
headers={
90+
"Content-Type": "application/json;charset=utf-8",
91+
"User-Agent": "datamaxi/" + __version__,
92+
"X-DTMX-APIKEY": str(self.api_key),
93+
},
94+
)
95+
96+
async def request_endpoint(self, op_id, **params):
97+
method, url_path, query_params = resolve_endpoint(op_id, **params)
98+
return await self.send_request(method, url_path, payload=query_params)
99+
100+
async def send_request(self, method, url_path, payload=None):
101+
# str()-encode scalars so bools match the sync client's urlencode
102+
# output (e.g. include_source -> "True", not httpx's "true").
103+
params = {k: str(v) for k, v in (payload or {}).items() if v is not None}
104+
for attempt in range(self.max_retries + 1):
105+
response = await self._client.request(method, url_path, params=params)
106+
if (
107+
response.status_code in self.retry_statuses
108+
and attempt < self.max_retries
109+
):
110+
await asyncio.sleep(self.retry_backoff * (attempt + 1))
111+
continue
112+
break
113+
114+
raise_for_error(response.status_code, response.text, response.headers)
115+
116+
try:
117+
data = response.json()
118+
except ValueError:
119+
data = response.text
120+
121+
self.last_response = ResponseMeta(
122+
status_code=response.status_code,
123+
headers=response.headers,
124+
limit_usage=extract_limit_usage(response.headers),
125+
data=data,
126+
)
127+
return data
128+
129+
async def aclose(self):
130+
await self._client.aclose()
131+
132+
async def __aenter__(self):
133+
return self
134+
135+
async def __aexit__(self, *exc):
136+
await self.aclose()
137+
138+
139+
class AsyncResource:
140+
"""Base for async resources — composes a shared ``AsyncAPI``."""
141+
142+
def __init__(self, api: "AsyncAPI"):
143+
self._api = api
144+
145+
async def request_endpoint(self, op_id, **params):
146+
return await self._api.request_endpoint(op_id, **params)
147+
148+
@property
149+
def last_response(self):
150+
return self._api.last_response
151+
152+
153+
class AsyncCexCandle(AsyncResource):
154+
async def __call__(
155+
self,
156+
exchange: str,
157+
market: Market,
158+
symbol: str,
159+
currency: str = USD,
160+
interval: Interval = INTERVAL_1D,
161+
from_unix: str = None,
162+
to_unix: str = None,
163+
pandas: bool = True,
164+
) -> Union[pd.DataFrame, CandleResponse]:
165+
"""Fetch candle data (async). See ``datamaxi.Datamaxi.cex.candle``."""
166+
check_required_parameters(
167+
[
168+
[exchange, "exchange"],
169+
[symbol, "symbol"],
170+
[interval, "interval"],
171+
[market, "market"],
172+
[currency, "currency"],
173+
]
174+
)
175+
if market not in [SPOT, FUTURES]:
176+
raise ValueError("market must be either spot or futures")
177+
178+
res = await self.request_endpoint(
179+
"cex_candle",
180+
exchange=exchange,
181+
market=market,
182+
symbol=symbol,
183+
interval=interval,
184+
currency=currency,
185+
**{"from": from_unix, "to": to_unix},
186+
)
187+
if res["data"] is None or len(res["data"]) == 0:
188+
raise ValueError("no data found")
189+
190+
if pandas:
191+
from datamaxi.resources.utils import convert_data_to_data_frame
192+
193+
return convert_data_to_data_frame(res["data"])
194+
return res
195+
196+
197+
class AsyncCexTicker(AsyncResource):
198+
async def get(
199+
self,
200+
exchange: str,
201+
symbol: str,
202+
market: Market,
203+
currency: str = None,
204+
conversion_base: str = None,
205+
include_source: bool = False,
206+
pandas: bool = True,
207+
) -> Union[pd.DataFrame, TickerResponse]:
208+
"""Fetch ticker data (async). See ``datamaxi.Datamaxi.cex.ticker``."""
209+
check_required_parameters(
210+
[
211+
[exchange, "exchange"],
212+
[symbol, "symbol"],
213+
[market, "market"],
214+
]
215+
)
216+
if market not in [SPOT, FUTURES]:
217+
raise ValueError("market must be either spot or futures")
218+
219+
res = await self.request_endpoint(
220+
"ticker",
221+
exchange=exchange,
222+
symbol=symbol,
223+
market=market,
224+
currency=currency,
225+
conversion_base=conversion_base,
226+
include_source=include_source,
227+
)
228+
229+
if pandas:
230+
import pandas as pd
231+
232+
df = pd.DataFrame([res["data"]])
233+
df = df.set_index("d")
234+
return df
235+
return res
236+
237+
238+
class AsyncCex(AsyncResource):
239+
def __init__(self, api: "AsyncAPI"):
240+
super().__init__(api)
241+
self.candle = AsyncCexCandle(api)
242+
self.ticker = AsyncCexTicker(api)
243+
244+
245+
class AsyncDatamaxi:
246+
"""Async entrypoint (pilot). Exposes ``cex.candle`` and ``cex.ticker``.
247+
248+
Use as an async context manager so the underlying ``httpx`` client is
249+
closed, or call :meth:`aclose` explicitly.
250+
"""
251+
252+
def __init__(self, api_key=None, **kwargs: Any):
253+
if "base_url" not in kwargs:
254+
kwargs["base_url"] = BASE_URL
255+
self._api = AsyncAPI(api_key, **kwargs)
256+
self.cex = AsyncCex(self._api)
257+
258+
async def aclose(self):
259+
await self._api.aclose()
260+
261+
async def __aenter__(self):
262+
return self
263+
264+
async def __aexit__(self, *exc):
265+
await self.aclose()
266+
267+
def __repr__(self):
268+
return "AsyncDatamaxi(base_url={!r}, has_key={})".format(
269+
self._api.base_url, bool(self._api.api_key)
270+
)
271+
272+
273+
__all__ = [
274+
"AsyncDatamaxi",
275+
"AsyncAPI",
276+
"AsyncResource",
277+
"AsyncCex",
278+
"AsyncCexCandle",
279+
"AsyncCexTicker",
280+
]

0 commit comments

Comments
 (0)