From 45851e1d57662b12d3e6ea0d9c1039127ebb4970 Mon Sep 17 00:00:00 2001 From: Martin Kersner Date: Fri, 3 Jul 2026 15:33:38 +0900 Subject: [PATCH 1/2] feat: expose response metadata via last_response; consistent return shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #140 send_request wrapped rate-limit/header info into the return only when show_limit_usage/show_header were set, so the return type branched on a constructor flag. Worse, the wrapper keyed metadata under 'data' — colliding with the backend's own {"data": ...} envelope — so the wrapped path corrupted every DataFrame-returning method (res['data'] became the whole backend dict). Now send_request always returns the payload and records a ResponseMeta (status_code, headers, limit_usage, data) on api.last_response, forwarded as a property on Resource (client..last_response). Non-flag callers see no change; the flags are kept for back-compat but marked deprecated and no longer alter the return shape. --- datamaxi/api.py | 83 +++++++++++++++++++++++++++---------- tests/test_last_response.py | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 22 deletions(-) create mode 100644 tests/test_last_response.py diff --git a/datamaxi/api.py b/datamaxi/api.py index 6bf9a92..48df756 100644 --- a/datamaxi/api.py +++ b/datamaxi/api.py @@ -38,8 +38,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); @@ -53,6 +56,9 @@ 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) + # 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( @@ -164,29 +170,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)) @@ -219,6 +230,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..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. @@ -240,3 +274,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 diff --git a/tests/test_last_response.py b/tests/test_last_response.py new file mode 100644 index 0000000..9a9fd9d --- /dev/null +++ b/tests/test_last_response.py @@ -0,0 +1,83 @@ +"""Local tests for last_response metadata + consistent (unwrapped) returns. + +Covers #140: response metadata (rate-limit usage, headers, status) is exposed +via `client..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 responses +import pandas as pd + +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() + 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" + + +@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 From db70c8a251ebf6adfd010be79787ea31e9a9bf0e Mon Sep 17 00:00:00 2001 From: Martin Kersner Date: Fri, 3 Jul 2026 15:38:36 +0900 Subject: [PATCH 2/2] feat: emit DeprecationWarning when show_limit_usage/show_header passed Proper option-2 deprecation: the flags still work as before (no-op on return shape) but now warn on use, so callers get a runtime nudge before a future major removes them. Assert the warning in tests (and no warning when the flags are absent/False). --- datamaxi/api.py | 14 ++++++++++++++ tests/test_api.py | 27 +++++++++++++++------------ tests/test_last_response.py | 30 +++++++++++++++++++++++++++--- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/datamaxi/api.py b/datamaxi/api.py index 48df756..f321a71 100644 --- a/datamaxi/api.py +++ b/datamaxi/api.py @@ -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 @@ -56,6 +57,19 @@ 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..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 diff --git a/tests/test_api.py b/tests/test_api.py index c0ceea8..db64ac4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 @@ -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 diff --git a/tests/test_last_response.py b/tests/test_last_response.py index 9a9fd9d..1f4b8b8 100644 --- a/tests/test_last_response.py +++ b/tests/test_last_response.py @@ -6,8 +6,10 @@ """ 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 @@ -61,9 +63,10 @@ def test_last_response_populated_after_call(): @responses.activate def test_flags_do_not_change_return_shape(): _add_ticker() - c = CexTicker( - api_key="k", base_url=BASE_URL, show_limit_usage=True, show_header=True - ) + 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. @@ -71,6 +74,27 @@ def test_flags_do_not_change_return_shape(): 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