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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/):
Expand Down
3 changes: 3 additions & 0 deletions san/api_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions san/async_batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions san/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
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:
def __init__(self):
self.queries = []

def get(self, dataset, **kwargs):
validate_kwargs("Batch.get", kwargs)
Comment thread
IvanIvanoff marked this conversation as resolved.
self.queries.append([dataset, kwargs])

def execute(self):
Expand Down
2 changes: 2 additions & 0 deletions san/get.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions san/get_many.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)

Expand Down
48 changes: 48 additions & 0 deletions san/param_validation.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
IvanIvanoff marked this conversation as resolved.
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)),
)
)
22 changes: 21 additions & 1 deletion san/sanbase_graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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}
)
}}
"""
Expand All @@ -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,
)

Expand All @@ -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):
Expand Down
85 changes: 85 additions & 0 deletions san/tests/test_get.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")

Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

setuptools.setup(
name="sanpy",
version="0.13.0",
version="0.14.0",
Comment thread
IvanIvanoff marked this conversation as resolved.
author="Santiment",
author_email="admin@santiment.net",
description="Package for Santiment API access with python",
Expand Down