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
97 changes: 75 additions & 22 deletions datamaxi/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import json
from json import JSONDecodeError
import logging
import warnings
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
Expand Down Expand Up @@ -38,8 +39,11 @@ def __init__(
base_url (str): The base URL for the DataMaxi+ API.
timeout (int): The timeout for the requests.
proxies (dict): The proxies for the requests.
show_limit_usage (bool): Show the limit usage.
show_header (bool): Show the header.
show_limit_usage (bool): Deprecated. Metadata is now always
available via ``last_response``; this flag no longer changes
the return shape. Kept for backward compatibility.
show_header (bool): Deprecated. See ``show_limit_usage`` /
``last_response``.
max_retries (int): Retry attempts for transient gateway 5xx
and connection/read errors. Set to 0 to disable.
retry_backoff (float): Backoff factor between retries (seconds);
Expand All @@ -53,6 +57,22 @@ def __init__(
self.proxies = proxies if type(proxies) is dict else None
self.show_limit_usage = bool(show_limit_usage)
self.show_header = bool(show_header)
for _flag, _name in (
(show_limit_usage, "show_limit_usage"),
(show_header, "show_header"),
):
if _flag:
warnings.warn(
"'{}' is deprecated and no longer changes the return "
"shape; read response metadata from "
"`client.<resource>.last_response` instead. It will be "
"removed in a future major release.".format(_name),
DeprecationWarning,
stacklevel=2,
)
# Metadata for the most recent successful response (see #140).
# Populated on every call; None until the first request.
self.last_response = None

self.session = requests.Session()
self.session.headers.update(
Expand Down Expand Up @@ -164,29 +184,34 @@ def send_request(self, http_method, url_path, payload=None):
data = response.json()
except ValueError:
data = response.text
result = {}

if self.show_limit_usage:
limit_usage = {}
for key in response.headers.keys():
key = key.lower()
if (
key.startswith("x-ratelimit-limit")
or key.startswith("x-ratelimit-remaining")
or key.startswith("x-ratelimit-reset")
):
limit_usage[key] = response.headers[key]
result["limit_usage"] = limit_usage

if self.show_header:
result["header"] = response.headers

if len(result) != 0:
result["data"] = data
return result

# Always expose response metadata via last_response instead of
# wrapping it into the return value. The old wrapper keyed rate-limit
# info under "data" too, which collided with the backend's own
# ``{"data": ...}`` envelope and corrupted the DataFrame code paths.
self.last_response = ResponseMeta(
status_code=response.status_code,
headers=response.headers,
limit_usage=self._extract_limit_usage(response.headers),
data=data,
)

return data

@staticmethod
def _extract_limit_usage(headers):
"""Pull the ``x-ratelimit-*`` triplet out of the response headers."""
usage = {}
for key in headers.keys():
k = key.lower()
if (
k.startswith("x-ratelimit-limit")
or k.startswith("x-ratelimit-remaining")
or k.startswith("x-ratelimit-reset")
):
usage[k] = headers[key]
return usage

def _prepare_params(self, params):
return encoded_string(cleanNoneValue(params))

Expand Down Expand Up @@ -219,6 +244,29 @@ def _handle_exception(self, response):
raise ServerError(status_code, response.text)


class ResponseMeta(object):
"""Metadata for the most recent successful response.

Exposed via ``client.<resource>.last_response`` so per-call info
(rate-limit usage, headers, status) no longer has to be wrapped into —
and change the shape of — the returned payload. The client tree shares
one transport, so this reflects the *last* call made through it.
"""

__slots__ = ("status_code", "headers", "limit_usage", "data")

def __init__(self, status_code, headers, limit_usage, data):
self.status_code = status_code
self.headers = headers
self.limit_usage = limit_usage
self.data = data

def __repr__(self):
return "ResponseMeta(status_code={}, limit_usage={})".format(
self.status_code, self.limit_usage
)


class Resource(object):
"""Base for endpoint/resource clients — *composes* an `API` transport
rather than subclassing it.
Expand All @@ -240,3 +288,8 @@ def request_endpoint(self, op_id, **params):

def query(self, url_path, payload=None):
return self._api.query(url_path, payload=payload)

@property
def last_response(self):
"""`ResponseMeta` for the most recent call through the shared transport."""
return self._api.last_response
27 changes: 15 additions & 12 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,15 @@ def test_API_with_extra_parameters():
base_url = random_str()
proxies = {"https": "https://1.2.3.4:8080"}

client = API(
api_key,
base_url=base_url,
show_limit_usage=True,
show_header=True,
timeout=0.1,
proxies=proxies,
)
with pytest.warns(DeprecationWarning):
client = API(
api_key,
base_url=base_url,
show_limit_usage=True,
show_header=True,
timeout=0.1,
proxies=proxies,
)

assert isinstance(client, API)
assert client.api_key == api_key
Expand All @@ -106,12 +107,14 @@ def test_API_with_custom_timeout():


def test_API_with_show_limit_usage():
"""Tests the API initialization with show_limit_usage enabled."""
client = API(show_limit_usage=True)
"""Tests the API initialization with show_limit_usage enabled (deprecated)."""
with pytest.warns(DeprecationWarning, match="show_limit_usage"):
client = API(show_limit_usage=True)
assert client.show_limit_usage is True


def test_API_with_show_header():
"""Tests the API initialization with show_header enabled."""
client = API(show_header=True)
"""Tests the API initialization with show_header enabled (deprecated)."""
with pytest.warns(DeprecationWarning, match="show_header"):
client = API(show_header=True)
assert client.show_header is True
107 changes: 107 additions & 0 deletions tests/test_last_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Local tests for last_response metadata + consistent (unwrapped) returns.

Covers #140: response metadata (rate-limit usage, headers, status) is exposed
via `client.<resource>.last_response` instead of being wrapped into the return
value, and the show_limit_usage/show_header flags no longer change the shape.
"""

import re
import warnings
import responses
import pandas as pd
import pytest

from datamaxi.resources.cex_ticker import CexTicker
from datamaxi.api import API, ResponseMeta

BASE_URL = "https://api.datamaxiplus.com"
_TICKER = {"data": {"d": "1700000000", "p": "105.5"}}
_RL_HEADERS = {
"x-ratelimit-limit": "100",
"x-ratelimit-remaining": "99",
"x-ratelimit-reset": "60",
}


def _add_ticker(**extra):
responses.add(
responses.GET,
re.compile(".*/api/v1/ticker.*"),
json=_TICKER,
status=200,
headers=_RL_HEADERS,
**extra,
)


def test_last_response_none_before_any_call():
assert API(api_key="k", base_url=BASE_URL).last_response is None
assert CexTicker(api_key="k", base_url=BASE_URL).last_response is None


@responses.activate
def test_last_response_populated_after_call():
_add_ticker()
c = CexTicker(api_key="k", base_url=BASE_URL)
df = c.get(exchange="binance", market="spot", symbol="BTC-USDT")

# return value is the payload (DataFrame), not a metadata wrapper
assert isinstance(df, pd.DataFrame)

lr = c.last_response
assert isinstance(lr, ResponseMeta)
assert lr.status_code == 200
assert lr.data == _TICKER
assert lr.limit_usage == {
"x-ratelimit-limit": "100",
"x-ratelimit-remaining": "99",
"x-ratelimit-reset": "60",
}
assert lr.headers["x-ratelimit-remaining"] == "99"


@responses.activate
def test_flags_do_not_change_return_shape():
_add_ticker()
with pytest.warns(DeprecationWarning):
c = CexTicker(
api_key="k", base_url=BASE_URL, show_limit_usage=True, show_header=True
)
df = c.get(exchange="binance", market="spot", symbol="BTC-USDT")
# Previously these flags wrapped the return in a dict; now the shape is
# identical to a plain client and metadata comes from last_response.
assert isinstance(df, pd.DataFrame)
assert c.last_response.limit_usage["x-ratelimit-limit"] == "100"


def test_deprecated_flags_emit_warning():
with pytest.warns(DeprecationWarning, match="show_limit_usage"):
API(api_key="k", base_url=BASE_URL, show_limit_usage=True)
with pytest.warns(DeprecationWarning, match="show_header"):
API(api_key="k", base_url=BASE_URL, show_header=True)


def test_no_warning_without_flags():
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
API(api_key="k", base_url=BASE_URL)
API(api_key="k", base_url=BASE_URL, show_limit_usage=False, show_header=False)
flag_warnings = [
w
for w in caught
if issubclass(w.category, DeprecationWarning)
and ("show_limit_usage" in str(w.message) or "show_header" in str(w.message))
]
assert flag_warnings == []


@responses.activate
def test_last_response_shared_across_client_tree():
from datamaxi import Datamaxi

_add_ticker()
client = Datamaxi(api_key="k", base_url=BASE_URL)
client.cex.ticker.get(exchange="binance", market="spot", symbol="BTC-USDT")
# One shared transport (#137) -> last_response visible from any node
assert client.cex.ticker.last_response.status_code == 200
assert client.premium.last_response is client.cex.ticker.last_response
Loading