diff --git a/README.md b/README.md index aa5df8e..60ec73f 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,10 @@ The returned result is a `pandas.DataFrame` indexed by `datetime`. For `get`, the value column is named `value`. For `get_many`, each column is named after the asset slug. +> **Note (v0.13.0):** `san.get`, `san.get_many`, and `AsyncBatch.get`/`.get_many` +> now raise `SanError` when passed unknown keyword arguments instead of silently ignoring them. +> If a parameter you expect is rejected, upgrade sanpy with `pip install --upgrade sanpy`. + ### Single asset ```python @@ -652,6 +656,21 @@ san.get( ) ``` +## Only finalized data + +For metrics whose values can be recomputed after publication (see `canMutate` in metric metadata), pass `only_finalized_data=True` to restrict the response to data points that have passed their stabilization period: + +```python +san.get( + "price_usd", + slug="bitcoin", + from_date="utc_now-30d", + to_date="utc_now", + interval="1d", + only_finalized_data=True +) +``` + ## Rate limit tools Four utility functions help you handle [API rate limits](https://academy.santiment.net/sanapi/rate-limits/): diff --git a/san/api_config.py b/san/api_config.py index 541c0ea..a8317cb 100644 --- a/san/api_config.py +++ b/san/api_config.py @@ -9,3 +9,6 @@ class ApiConfig: pool_connections = 10 # Maximum number of reusable connections kept per pool. pool_maxsize = 10 + # When True, passing unknown keyword arguments to san.get / san.get_many / + # AsyncBatch raises SanError instead of being silently ignored. + strict_kwargs = True diff --git a/san/async_batch.py b/san/async_batch.py index df17345..0c44ec6 100644 --- a/san/async_batch.py +++ b/san/async_batch.py @@ -3,6 +3,7 @@ from san.sanbase_graphql_helper import QUERY_MAPPING from san.error import SanError +from san.param_validation import validate_kwargs def task(request): @@ -27,9 +28,11 @@ def __init__(self): self.queries = [] def get(self, dataset, **kwargs): + validate_kwargs("AsyncBatch.get", kwargs) self.queries.append(["get", dataset, kwargs]) def get_many(self, dataset, **kwargs): + validate_kwargs("AsyncBatch.get_many", kwargs) self.queries.append(["get_many", dataset, kwargs]) def execute(self, max_workers=10): diff --git a/san/batch.py b/san/batch.py index c533b7e..52d8916 100644 --- a/san/batch.py +++ b/san/batch.py @@ -4,6 +4,7 @@ from san.graphql import execute_gql from san.transform import transform_timeseries_data_query_result from san.error import SanError +from san.param_validation import validate_kwargs class Batch: @@ -11,6 +12,7 @@ def __init__(self): self.queries = [] def get(self, dataset, **kwargs): + validate_kwargs("Batch.get", kwargs) self.queries.append([dataset, kwargs]) def execute(self): diff --git a/san/get.py b/san/get.py index 0477c7b..a290ed3 100644 --- a/san/get.py +++ b/san/get.py @@ -5,6 +5,7 @@ from san.query import get_gql_query, parse_dataset from san.transform import transform_timeseries_data_query_result from san.error import SanError +from san.param_validation import validate_kwargs def get(dataset, **kwargs): @@ -36,6 +37,7 @@ def get(dataset, **kwargs): from_date="utc_now-60d", to_date="utc_now-40d") """ + validate_kwargs("san.get", kwargs) query, slug = parse_dataset(dataset) if slug or query in NO_SLUG_QUERIES: return __get_metric_slug_string_selector(query, slug, dataset, **kwargs) diff --git a/san/get_many.py b/san/get_many.py index bc38a26..ce2bf5b 100644 --- a/san/get_many.py +++ b/san/get_many.py @@ -3,6 +3,7 @@ from san.query import parse_dataset from san.transform import transform_timeseries_data_per_slug_query_result from san.error import SanError +from san.param_validation import validate_kwargs def get_many(dataset, **kwargs): @@ -13,6 +14,7 @@ def get_many(dataset, **kwargs): from_date="2020-01-01" to_date="2020-01-10") """ + validate_kwargs("san.get_many", kwargs) query, slug = parse_dataset(dataset) return __get_many(query, **kwargs) diff --git a/san/param_validation.py b/san/param_validation.py new file mode 100644 index 0000000..14cc238 --- /dev/null +++ b/san/param_validation.py @@ -0,0 +1,48 @@ +from san.api_config import ApiConfig +from san.error import SanError + +_SUPPORTED_KWARGS = frozenset( + { + "slug", + "slugs", + "selector", + "from_date", + "to_date", + "interval", + "aggregation", + "include_incomplete_data", + "only_finalized_data", + "transform", + "version", + "return_fields", + "address", + "transaction_type", + "number_of_holders", + "limit", + "size", + "social_volume_type", + "source", + "search_text", + "idx", + } +) + +_PUBLIC_KWARGS = _SUPPORTED_KWARGS - {"idx"} + + +def validate_kwargs(func_name, kwargs): + if not ApiConfig.strict_kwargs: + return + unsupported = sorted(set(kwargs) - _SUPPORTED_KWARGS) + if not unsupported: + return + raise SanError( + "{func}() received unsupported parameter(s): {bad}. " + "Supported parameters: {ok}. " + "If you expect a parameter to be supported, upgrade sanpy " + "with `pip install --upgrade sanpy`.".format( + func=func_name, + bad=", ".join(unsupported), + ok=", ".join(sorted(_PUBLIC_KWARGS)), + ) + ) diff --git a/san/sanbase_graphql.py b/san/sanbase_graphql.py index 7155143..295c85a 100644 --- a/san/sanbase_graphql.py +++ b/san/sanbase_graphql.py @@ -211,6 +211,7 @@ def __choose_selector_or_slug(slug, **kwargs): def get_metric_timeseries_data(idx, metric, slug=None, **kwargs): + only_finalized_data_arg = _only_finalized_data_arg_helper(kwargs) kwargs = sgh.transform_query_args("get_metric", **kwargs) selector_or_slug = __choose_selector_or_slug(slug, **kwargs) version_arg = _version_arg_helper(kwargs) @@ -227,17 +228,25 @@ def get_metric_timeseries_data(idx, metric, slug=None, **kwargs): interval: \"{interval}\" aggregation: {aggregation} includeIncompleteData: {include_incomplete_data} + {only_finalized_data_arg} ) }} """ ).format( - idx=idx, metric=metric, version_arg=version_arg, selector_or_slug=selector_or_slug, transform_arg=transform_arg, **kwargs + idx=idx, + metric=metric, + version_arg=version_arg, + selector_or_slug=selector_or_slug, + transform_arg=transform_arg, + only_finalized_data_arg=only_finalized_data_arg, + **kwargs, ) return query_str def get_metric_timeseries_data_per_slug(idx, metric, slugs=None, **kwargs): + only_finalized_data_arg = _only_finalized_data_arg_helper(kwargs) kwargs = sgh.transform_query_args("get_metric", **kwargs) selector_or_slugs = __choose_selector_or_slugs(slugs, **kwargs) version_arg = _version_arg_helper(kwargs) @@ -254,6 +263,7 @@ def get_metric_timeseries_data_per_slug(idx, metric, slugs=None, **kwargs): interval: \"{interval}\" aggregation: {aggregation} includeIncompleteData: {include_incomplete_data} + {only_finalized_data_arg} ) }} """ @@ -263,6 +273,7 @@ def get_metric_timeseries_data_per_slug(idx, metric, slugs=None, **kwargs): version_arg=version_arg, selector_or_slugs=selector_or_slugs, transform_arg=transform_arg, + only_finalized_data_arg=only_finalized_data_arg, **kwargs, ) @@ -276,6 +287,15 @@ def _version_arg_helper(kwargs): return "" +def _only_finalized_data_arg_helper(kwargs): + value = kwargs.pop("only_finalized_data", None) + if value is None: + return "" + if not isinstance(value, bool): + raise SanError(f'"only_finalized_data" must be a bool, got: {value!r}') + return f"onlyFinalizedData: {'true' if value else 'false'}" + + def _transform_arg_helper(kwargs): transform_arg_str = "" if "transform" in kwargs and isinstance(kwargs["transform"], dict): diff --git a/san/tests/test_get.py b/san/tests/test_get.py index 72f934b..8897312 100644 --- a/san/tests/test_get.py +++ b/san/tests/test_get.py @@ -417,6 +417,91 @@ def test_invalid_method(): san.get("invalid_method/slug") +def test_get_raises_on_unsupported_kwarg(): + with pytest.raises(SanError, match="unsupported parameter"): + san.get("price_usd", slug="bitcoin", vlkajsdsakd=123) + + +def test_get_many_raises_on_unsupported_kwarg(): + with pytest.raises(SanError, match="unsupported parameter"): + san.get_many("price_usd", slugs=["bitcoin"], foo_bar=1) + + +def test_batch_get_raises_on_unsupported_kwarg(): + batch = Batch() + with pytest.raises(SanError, match="unsupported parameter"): + batch.get("price_usd/bitcoin", nonsense=True) + + +def test_async_batch_raises_on_unsupported_kwarg(): + from san import AsyncBatch + + ab = AsyncBatch() + with pytest.raises(SanError, match="unsupported parameter"): + ab.get("price_usd", slug="bitcoin", typo_param=1) + with pytest.raises(SanError, match="unsupported parameter"): + ab.get_many("price_usd", slugs=["bitcoin"], typo_param=1) + + +@patch("san.transport.requests.Session.post") +def test_strict_kwargs_disabled_allows_unknown(mock, test_response): + api_call_result = {"query_0": {"timeseriesDataJson": []}} + mock.return_value = test_response(status_code=200, data=deepcopy(api_call_result)) + + san.ApiConfig.strict_kwargs = False + try: + san.get("price_usd", slug="bitcoin", from_date="2026-01-01", to_date="2026-01-02", vlkajsdsakd=123) + finally: + san.ApiConfig.strict_kwargs = True + + +@patch("san.transport.requests.Session.post") +def test_get_with_only_finalized_data(mock, test_response): + api_call_result = {"query_0": {"timeseriesDataJson": [{"datetime": "2026-01-01T00:00:00Z", "value": 1.0}]}} + mock.return_value = test_response(status_code=200, data=deepcopy(api_call_result)) + + san.get( + "price_usd", + slug="bitcoin", + from_date="2026-01-01", + to_date="2026-01-02", + interval="1d", + only_finalized_data=True, + ) + + query = mock.call_args.kwargs["json"]["query"] + assert "onlyFinalizedData: true" in query + + +@patch("san.transport.requests.Session.post") +def test_get_many_with_only_finalized_data(mock, test_response): + api_call_result = {"query_0": {"timeseriesDataPerSlugJson": []}} + mock.return_value = test_response(status_code=200, data=deepcopy(api_call_result)) + + san.get_many( + "price_usd", + slugs=["bitcoin", "ethereum"], + from_date="2026-01-01", + to_date="2026-01-02", + interval="1d", + only_finalized_data=False, + ) + + query = mock.call_args.kwargs["json"]["query"] + assert "onlyFinalizedData: false" in query + + +@patch("san.transport.requests.Session.post") +def test_get_omits_only_finalized_data_when_absent(mock, test_response): + api_call_result = {"query_0": {"timeseriesDataJson": []}} + mock.return_value = test_response(status_code=200, data=deepcopy(api_call_result)) + + san.get("price_usd", slug="bitcoin", from_date="2026-01-01", to_date="2026-01-02", interval="1d") + + query = mock.call_args.kwargs["json"]["query"] + assert "onlyFinalizedData" not in query + + def test_rate_limits(): exception = SanError("API Rate Limit Reached. Try again in 366 seconds(7 minutes)") diff --git a/setup.py b/setup.py index 8d0eb4e..9ccab3d 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setuptools.setup( name="sanpy", - version="0.13.0", + version="0.14.0", author="Santiment", author_email="admin@santiment.net", description="Package for Santiment API access with python",