defistream 1.0.6__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- defistream/__init__.py +9 -2
- defistream/client.py +38 -3
- defistream/query.py +346 -24
- {defistream-1.0.6.dist-info → defistream-1.2.0.dist-info}/METADATA +87 -3
- defistream-1.2.0.dist-info/RECORD +11 -0
- defistream-1.0.6.dist-info/RECORD +0 -11
- {defistream-1.0.6.dist-info → defistream-1.2.0.dist-info}/WHEEL +0 -0
- {defistream-1.0.6.dist-info → defistream-1.2.0.dist-info}/licenses/LICENSE +0 -0
defistream/__init__.py
CHANGED
|
@@ -29,9 +29,14 @@ from .exceptions import (
|
|
|
29
29
|
ServerError,
|
|
30
30
|
ValidationError,
|
|
31
31
|
)
|
|
32
|
-
from .query import
|
|
32
|
+
from .query import (
|
|
33
|
+
AggregateQueryBuilder,
|
|
34
|
+
AsyncAggregateQueryBuilder,
|
|
35
|
+
AsyncQueryBuilder,
|
|
36
|
+
QueryBuilder,
|
|
37
|
+
)
|
|
33
38
|
|
|
34
|
-
__version__ = "1.0
|
|
39
|
+
__version__ = "1.2.0"
|
|
35
40
|
|
|
36
41
|
__all__ = [
|
|
37
42
|
# Clients
|
|
@@ -40,6 +45,8 @@ __all__ = [
|
|
|
40
45
|
# Query builders
|
|
41
46
|
"QueryBuilder",
|
|
42
47
|
"AsyncQueryBuilder",
|
|
48
|
+
"AggregateQueryBuilder",
|
|
49
|
+
"AsyncAggregateQueryBuilder",
|
|
43
50
|
# Exceptions
|
|
44
51
|
"DeFiStreamError",
|
|
45
52
|
"AuthenticationError",
|
defistream/client.py
CHANGED
|
@@ -126,6 +126,7 @@ class BaseClient:
|
|
|
126
126
|
response: httpx.Response,
|
|
127
127
|
as_dataframe: Literal["pandas", "polars"] | None = None,
|
|
128
128
|
output_file: str | None = None,
|
|
129
|
+
response_key: str = "events",
|
|
129
130
|
) -> list[dict[str, Any]] | Any:
|
|
130
131
|
"""Process response and optionally convert to DataFrame or save to file."""
|
|
131
132
|
self.last_response = self._parse_response_metadata(response.headers)
|
|
@@ -184,7 +185,7 @@ class BaseClient:
|
|
|
184
185
|
if data.get("status") == "error":
|
|
185
186
|
raise DeFiStreamError(data.get("error", "Unknown error"))
|
|
186
187
|
|
|
187
|
-
events = data.get(
|
|
188
|
+
events = data.get(response_key, [])
|
|
188
189
|
|
|
189
190
|
if as_dataframe == "pandas":
|
|
190
191
|
import pandas as pd
|
|
@@ -269,6 +270,7 @@ class DeFiStream(BaseClient):
|
|
|
269
270
|
params: dict[str, Any] | None = None,
|
|
270
271
|
as_dataframe: Literal["pandas", "polars"] | None = None,
|
|
271
272
|
output_file: str | None = None,
|
|
273
|
+
response_key: str = "events",
|
|
272
274
|
) -> list[dict[str, Any]] | Any:
|
|
273
275
|
"""Make HTTP request."""
|
|
274
276
|
if params is None:
|
|
@@ -281,7 +283,7 @@ class DeFiStream(BaseClient):
|
|
|
281
283
|
params["format"] = "csv"
|
|
282
284
|
|
|
283
285
|
response = self._client.request(method, path, params=params)
|
|
284
|
-
return self._process_response(response, as_dataframe, output_file)
|
|
286
|
+
return self._process_response(response, as_dataframe, output_file, response_key)
|
|
285
287
|
|
|
286
288
|
def decoders(self) -> list[str]:
|
|
287
289
|
"""Get list of available decoders."""
|
|
@@ -289,8 +291,24 @@ class DeFiStream(BaseClient):
|
|
|
289
291
|
if response.status_code >= 400:
|
|
290
292
|
self._handle_error_response(response)
|
|
291
293
|
data = response.json()
|
|
294
|
+
if isinstance(data, list):
|
|
295
|
+
return data
|
|
292
296
|
return data.get("decoders", [])
|
|
293
297
|
|
|
298
|
+
def aggregate_schema(self, protocol: str) -> dict[str, Any]:
|
|
299
|
+
"""Get the aggregate schema for a protocol.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
protocol: Protocol name (e.g. ``"erc20"``, ``"aave"``).
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Schema dictionary describing available aggregate fields.
|
|
306
|
+
"""
|
|
307
|
+
response = self._client.get(f"/{protocol}/aggregate_schema")
|
|
308
|
+
if response.status_code >= 400:
|
|
309
|
+
self._handle_error_response(response)
|
|
310
|
+
return response.json()
|
|
311
|
+
|
|
294
312
|
|
|
295
313
|
class AsyncDeFiStream(BaseClient):
|
|
296
314
|
"""
|
|
@@ -357,6 +375,7 @@ class AsyncDeFiStream(BaseClient):
|
|
|
357
375
|
params: dict[str, Any] | None = None,
|
|
358
376
|
as_dataframe: Literal["pandas", "polars"] | None = None,
|
|
359
377
|
output_file: str | None = None,
|
|
378
|
+
response_key: str = "events",
|
|
360
379
|
) -> list[dict[str, Any]] | Any:
|
|
361
380
|
"""Make async HTTP request."""
|
|
362
381
|
if params is None:
|
|
@@ -369,7 +388,7 @@ class AsyncDeFiStream(BaseClient):
|
|
|
369
388
|
params["format"] = "csv"
|
|
370
389
|
|
|
371
390
|
response = await self._client.request(method, path, params=params)
|
|
372
|
-
return self._process_response(response, as_dataframe, output_file)
|
|
391
|
+
return self._process_response(response, as_dataframe, output_file, response_key)
|
|
373
392
|
|
|
374
393
|
async def decoders(self) -> list[str]:
|
|
375
394
|
"""Get list of available decoders."""
|
|
@@ -377,4 +396,20 @@ class AsyncDeFiStream(BaseClient):
|
|
|
377
396
|
if response.status_code >= 400:
|
|
378
397
|
self._handle_error_response(response)
|
|
379
398
|
data = response.json()
|
|
399
|
+
if isinstance(data, list):
|
|
400
|
+
return data
|
|
380
401
|
return data.get("decoders", [])
|
|
402
|
+
|
|
403
|
+
async def aggregate_schema(self, protocol: str) -> dict[str, Any]:
|
|
404
|
+
"""Get the aggregate schema for a protocol.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
protocol: Protocol name (e.g. ``"erc20"``, ``"aave"``).
|
|
408
|
+
|
|
409
|
+
Returns:
|
|
410
|
+
Schema dictionary describing available aggregate fields.
|
|
411
|
+
"""
|
|
412
|
+
response = await self._client.get(f"/{protocol}/aggregate_schema")
|
|
413
|
+
if response.status_code >= 400:
|
|
414
|
+
self._handle_error_response(response)
|
|
415
|
+
return response.json()
|
defistream/query.py
CHANGED
|
@@ -4,10 +4,84 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING, Any
|
|
6
6
|
|
|
7
|
+
from .exceptions import ValidationError
|
|
8
|
+
|
|
7
9
|
if TYPE_CHECKING:
|
|
8
10
|
from .client import BaseClient
|
|
9
11
|
|
|
10
12
|
|
|
13
|
+
# Label/category parameter names that need SQL safety checks
|
|
14
|
+
_LABEL_CATEGORY_PARAMS = frozenset({
|
|
15
|
+
"involving_label", "involving_category",
|
|
16
|
+
"sender_label", "sender_category",
|
|
17
|
+
"receiver_label", "receiver_category",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
# Mutual exclusivity groups: within each slot, only one key is allowed
|
|
21
|
+
_INVOLVING_SLOT = ("involving", "involving_label", "involving_category")
|
|
22
|
+
_SENDER_SLOT = ("sender", "sender_label", "sender_category")
|
|
23
|
+
_RECEIVER_SLOT = ("receiver", "receiver_label", "receiver_category")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _normalize_multi(*args: str) -> str:
|
|
27
|
+
"""Join varargs into a comma-separated string.
|
|
28
|
+
|
|
29
|
+
Accepts one or more strings. If a single pre-joined string is passed
|
|
30
|
+
(e.g. "a,b") it passes through unchanged.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
ValueError: If no arguments are provided.
|
|
34
|
+
"""
|
|
35
|
+
if not args:
|
|
36
|
+
raise ValueError("At least one value is required")
|
|
37
|
+
return ",".join(args)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _validate_sql_safety(params: dict[str, Any]) -> None:
|
|
41
|
+
"""Reject label/category values containing quotes or backslashes.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValidationError: If any label/category value contains ' or \\.
|
|
45
|
+
"""
|
|
46
|
+
for key in _LABEL_CATEGORY_PARAMS:
|
|
47
|
+
value = params.get(key)
|
|
48
|
+
if value is not None and isinstance(value, str):
|
|
49
|
+
if "'" in value or "\\" in value:
|
|
50
|
+
raise ValidationError(
|
|
51
|
+
f"Parameter '{key}' contains unsafe characters (single quote or backslash)"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate_mutual_exclusivity(params: dict[str, Any]) -> None:
|
|
56
|
+
"""Enforce mutual exclusivity between filter slots.
|
|
57
|
+
|
|
58
|
+
Within each slot (involving, sender, receiver) only one key is allowed.
|
|
59
|
+
Cross-slot: involving* cannot combine with sender* or receiver*.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValidationError: If conflicting parameters are present.
|
|
63
|
+
"""
|
|
64
|
+
def _check_slot(slot: tuple[str, ...], slot_name: str) -> str | None:
|
|
65
|
+
present = [k for k in slot if k in params]
|
|
66
|
+
if len(present) > 1:
|
|
67
|
+
raise ValidationError(
|
|
68
|
+
f"Cannot combine {present[0]} with {present[1]} — "
|
|
69
|
+
f"pick one {slot_name} filter"
|
|
70
|
+
)
|
|
71
|
+
return present[0] if present else None
|
|
72
|
+
|
|
73
|
+
involving_key = _check_slot(_INVOLVING_SLOT, "involving")
|
|
74
|
+
sender_key = _check_slot(_SENDER_SLOT, "sender")
|
|
75
|
+
receiver_key = _check_slot(_RECEIVER_SLOT, "receiver")
|
|
76
|
+
|
|
77
|
+
if involving_key and (sender_key or receiver_key):
|
|
78
|
+
other = sender_key or receiver_key
|
|
79
|
+
raise ValidationError(
|
|
80
|
+
f"Cannot combine {involving_key} with {other} — "
|
|
81
|
+
f"use involving* or sender*/receiver*, not both"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
11
85
|
class QueryBuilder:
|
|
12
86
|
"""
|
|
13
87
|
Builder for constructing and executing DeFiStream API queries.
|
|
@@ -78,21 +152,49 @@ class QueryBuilder:
|
|
|
78
152
|
return self._copy_with(since=start, until=end)
|
|
79
153
|
|
|
80
154
|
# ERC20 and Native Token filters
|
|
81
|
-
def sender(self,
|
|
82
|
-
"""Filter by sender address (ERC20, Native Token)."""
|
|
83
|
-
return self._copy_with(sender=
|
|
155
|
+
def sender(self, *addresses: str) -> "QueryBuilder":
|
|
156
|
+
"""Filter by sender address (ERC20, Native Token). Accepts multiple addresses."""
|
|
157
|
+
return self._copy_with(sender=_normalize_multi(*addresses))
|
|
84
158
|
|
|
85
|
-
def receiver(self,
|
|
86
|
-
"""Filter by receiver address (ERC20, Native Token)."""
|
|
87
|
-
return self._copy_with(receiver=
|
|
159
|
+
def receiver(self, *addresses: str) -> "QueryBuilder":
|
|
160
|
+
"""Filter by receiver address (ERC20, Native Token). Accepts multiple addresses."""
|
|
161
|
+
return self._copy_with(receiver=_normalize_multi(*addresses))
|
|
88
162
|
|
|
89
|
-
def from_address(self,
|
|
90
|
-
"""Filter by sender address (alias for sender)."""
|
|
91
|
-
return self.sender(
|
|
163
|
+
def from_address(self, *addresses: str) -> "QueryBuilder":
|
|
164
|
+
"""Filter by sender address (alias for sender). Accepts multiple addresses."""
|
|
165
|
+
return self.sender(*addresses)
|
|
92
166
|
|
|
93
|
-
def to_address(self,
|
|
94
|
-
"""Filter by receiver address (alias for receiver)."""
|
|
95
|
-
return self.receiver(
|
|
167
|
+
def to_address(self, *addresses: str) -> "QueryBuilder":
|
|
168
|
+
"""Filter by receiver address (alias for receiver). Accepts multiple addresses."""
|
|
169
|
+
return self.receiver(*addresses)
|
|
170
|
+
|
|
171
|
+
def involving(self, *addresses: str) -> "QueryBuilder":
|
|
172
|
+
"""Filter by any involved address (all protocols). Accepts multiple addresses."""
|
|
173
|
+
return self._copy_with(involving=_normalize_multi(*addresses))
|
|
174
|
+
|
|
175
|
+
def involving_label(self, *labels: str) -> "QueryBuilder":
|
|
176
|
+
"""Filter by label on any involved address (all protocols)."""
|
|
177
|
+
return self._copy_with(involving_label=_normalize_multi(*labels))
|
|
178
|
+
|
|
179
|
+
def involving_category(self, *categories: str) -> "QueryBuilder":
|
|
180
|
+
"""Filter by category on any involved address (all protocols)."""
|
|
181
|
+
return self._copy_with(involving_category=_normalize_multi(*categories))
|
|
182
|
+
|
|
183
|
+
def sender_label(self, *labels: str) -> "QueryBuilder":
|
|
184
|
+
"""Filter sender by label (ERC20, Native Token)."""
|
|
185
|
+
return self._copy_with(sender_label=_normalize_multi(*labels))
|
|
186
|
+
|
|
187
|
+
def sender_category(self, *categories: str) -> "QueryBuilder":
|
|
188
|
+
"""Filter sender by category (ERC20, Native Token)."""
|
|
189
|
+
return self._copy_with(sender_category=_normalize_multi(*categories))
|
|
190
|
+
|
|
191
|
+
def receiver_label(self, *labels: str) -> "QueryBuilder":
|
|
192
|
+
"""Filter receiver by label (ERC20, Native Token)."""
|
|
193
|
+
return self._copy_with(receiver_label=_normalize_multi(*labels))
|
|
194
|
+
|
|
195
|
+
def receiver_category(self, *categories: str) -> "QueryBuilder":
|
|
196
|
+
"""Filter receiver by category (ERC20, Native Token)."""
|
|
197
|
+
return self._copy_with(receiver_category=_normalize_multi(*categories))
|
|
96
198
|
|
|
97
199
|
def min_amount(self, amount: float) -> "QueryBuilder":
|
|
98
200
|
"""Filter by minimum amount (ERC20, Native Token)."""
|
|
@@ -134,6 +236,8 @@ class QueryBuilder:
|
|
|
134
236
|
def _build_params(self) -> dict[str, Any]:
|
|
135
237
|
"""Build the final query parameters."""
|
|
136
238
|
params = self._params.copy()
|
|
239
|
+
_validate_sql_safety(params)
|
|
240
|
+
_validate_mutual_exclusivity(params)
|
|
137
241
|
if self._verbose:
|
|
138
242
|
params["verbose"] = "true"
|
|
139
243
|
return params
|
|
@@ -221,10 +325,104 @@ class QueryBuilder:
|
|
|
221
325
|
params["format"] = format
|
|
222
326
|
self._client._request("GET", self._endpoint, params=params, output_file=path)
|
|
223
327
|
|
|
328
|
+
# Aggregate transition
|
|
329
|
+
def aggregate(self, group_by: str = "time", period: str = "1h") -> "AggregateQueryBuilder":
|
|
330
|
+
"""Transition to an aggregate query.
|
|
331
|
+
|
|
332
|
+
Appends ``/aggregate`` to the endpoint and includes ``group_by`` and
|
|
333
|
+
``period`` as query parameters.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
group_by: Aggregation axis — ``"time"`` or ``"block"``.
|
|
337
|
+
period: Bucket size, e.g. ``"1h"``, ``"100b"``.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
An :class:`AggregateQueryBuilder` that inherits all current filters.
|
|
341
|
+
"""
|
|
342
|
+
builder = AggregateQueryBuilder(
|
|
343
|
+
self._client,
|
|
344
|
+
self._endpoint.rstrip("/") + "/aggregate",
|
|
345
|
+
self._params.copy(),
|
|
346
|
+
)
|
|
347
|
+
builder._verbose = self._verbose
|
|
348
|
+
builder._params["group_by"] = group_by
|
|
349
|
+
builder._params["period"] = period
|
|
350
|
+
return builder
|
|
351
|
+
|
|
224
352
|
def __repr__(self) -> str:
|
|
225
353
|
return f"QueryBuilder(endpoint={self._endpoint!r}, params={self._params!r}, verbose={self._verbose})"
|
|
226
354
|
|
|
227
355
|
|
|
356
|
+
class AggregateQueryBuilder(QueryBuilder):
|
|
357
|
+
"""Builder for aggregate (bucketed) queries.
|
|
358
|
+
|
|
359
|
+
Returned by :pymeth:`QueryBuilder.aggregate`. Overrides terminal methods
|
|
360
|
+
so that JSON responses extract from the ``"data"`` key instead of
|
|
361
|
+
``"events"``.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
def _copy_with(self, **updates: Any) -> "AggregateQueryBuilder":
|
|
365
|
+
"""Create a copy preserving aggregate builder type."""
|
|
366
|
+
new_builder = AggregateQueryBuilder(self._client, self._endpoint, self._params.copy())
|
|
367
|
+
new_builder._verbose = self._verbose
|
|
368
|
+
for key, value in updates.items():
|
|
369
|
+
if key == "verbose":
|
|
370
|
+
new_builder._verbose = value
|
|
371
|
+
elif value is not None:
|
|
372
|
+
new_builder._params[key] = value
|
|
373
|
+
return new_builder
|
|
374
|
+
|
|
375
|
+
# Terminal methods — extract from "data" key
|
|
376
|
+
def as_dict(self) -> list[dict[str, Any]]:
|
|
377
|
+
"""Execute aggregate query and return results as list of dictionaries."""
|
|
378
|
+
params = self._build_params()
|
|
379
|
+
return self._client._request(
|
|
380
|
+
"GET", self._endpoint, params=params, response_key="data",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def as_df(self, library: str = "pandas") -> Any:
|
|
384
|
+
"""Execute aggregate query and return results as DataFrame."""
|
|
385
|
+
if library not in ("pandas", "polars"):
|
|
386
|
+
raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
|
|
387
|
+
params = self._build_params()
|
|
388
|
+
params["format"] = "parquet"
|
|
389
|
+
return self._client._request(
|
|
390
|
+
"GET", self._endpoint, params=params, as_dataframe=library,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def as_file(self, path: str, format: str | None = None) -> None:
|
|
394
|
+
"""Execute aggregate query and save results to file."""
|
|
395
|
+
if format is None:
|
|
396
|
+
if path.endswith(".csv"):
|
|
397
|
+
format = "csv"
|
|
398
|
+
elif path.endswith(".parquet"):
|
|
399
|
+
format = "parquet"
|
|
400
|
+
elif path.endswith(".json"):
|
|
401
|
+
format = "json"
|
|
402
|
+
else:
|
|
403
|
+
raise ValueError(
|
|
404
|
+
f"Cannot determine format from path '{path}'. "
|
|
405
|
+
"Use a file extension (.csv, .parquet, .json) or specify format explicitly."
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if format not in ("csv", "parquet", "json"):
|
|
409
|
+
raise ValueError(f"format must be 'csv', 'parquet', or 'json', got '{format}'")
|
|
410
|
+
|
|
411
|
+
params = self._build_params()
|
|
412
|
+
|
|
413
|
+
if format == "json":
|
|
414
|
+
import json as _json
|
|
415
|
+
results = self.as_dict()
|
|
416
|
+
with open(path, "w") as f:
|
|
417
|
+
_json.dump(results, f, indent=2)
|
|
418
|
+
else:
|
|
419
|
+
params["format"] = format
|
|
420
|
+
self._client._request("GET", self._endpoint, params=params, output_file=path)
|
|
421
|
+
|
|
422
|
+
def __repr__(self) -> str:
|
|
423
|
+
return f"AggregateQueryBuilder(endpoint={self._endpoint!r}, params={self._params!r}, verbose={self._verbose})"
|
|
424
|
+
|
|
425
|
+
|
|
228
426
|
class AsyncQueryBuilder:
|
|
229
427
|
"""
|
|
230
428
|
Async builder for constructing and executing DeFiStream API queries.
|
|
@@ -294,21 +492,49 @@ class AsyncQueryBuilder:
|
|
|
294
492
|
return self._copy_with(since=start, until=end)
|
|
295
493
|
|
|
296
494
|
# ERC20 and Native Token filters
|
|
297
|
-
def sender(self,
|
|
298
|
-
"""Filter by sender address (ERC20, Native Token)."""
|
|
299
|
-
return self._copy_with(sender=
|
|
495
|
+
def sender(self, *addresses: str) -> "AsyncQueryBuilder":
|
|
496
|
+
"""Filter by sender address (ERC20, Native Token). Accepts multiple addresses."""
|
|
497
|
+
return self._copy_with(sender=_normalize_multi(*addresses))
|
|
300
498
|
|
|
301
|
-
def receiver(self,
|
|
302
|
-
"""Filter by receiver address (ERC20, Native Token)."""
|
|
303
|
-
return self._copy_with(receiver=
|
|
499
|
+
def receiver(self, *addresses: str) -> "AsyncQueryBuilder":
|
|
500
|
+
"""Filter by receiver address (ERC20, Native Token). Accepts multiple addresses."""
|
|
501
|
+
return self._copy_with(receiver=_normalize_multi(*addresses))
|
|
304
502
|
|
|
305
|
-
def from_address(self,
|
|
306
|
-
"""Filter by sender address (alias for sender)."""
|
|
307
|
-
return self.sender(
|
|
503
|
+
def from_address(self, *addresses: str) -> "AsyncQueryBuilder":
|
|
504
|
+
"""Filter by sender address (alias for sender). Accepts multiple addresses."""
|
|
505
|
+
return self.sender(*addresses)
|
|
308
506
|
|
|
309
|
-
def to_address(self,
|
|
310
|
-
"""Filter by receiver address (alias for receiver)."""
|
|
311
|
-
return self.receiver(
|
|
507
|
+
def to_address(self, *addresses: str) -> "AsyncQueryBuilder":
|
|
508
|
+
"""Filter by receiver address (alias for receiver). Accepts multiple addresses."""
|
|
509
|
+
return self.receiver(*addresses)
|
|
510
|
+
|
|
511
|
+
def involving(self, *addresses: str) -> "AsyncQueryBuilder":
|
|
512
|
+
"""Filter by any involved address (all protocols). Accepts multiple addresses."""
|
|
513
|
+
return self._copy_with(involving=_normalize_multi(*addresses))
|
|
514
|
+
|
|
515
|
+
def involving_label(self, *labels: str) -> "AsyncQueryBuilder":
|
|
516
|
+
"""Filter by label on any involved address (all protocols)."""
|
|
517
|
+
return self._copy_with(involving_label=_normalize_multi(*labels))
|
|
518
|
+
|
|
519
|
+
def involving_category(self, *categories: str) -> "AsyncQueryBuilder":
|
|
520
|
+
"""Filter by category on any involved address (all protocols)."""
|
|
521
|
+
return self._copy_with(involving_category=_normalize_multi(*categories))
|
|
522
|
+
|
|
523
|
+
def sender_label(self, *labels: str) -> "AsyncQueryBuilder":
|
|
524
|
+
"""Filter sender by label (ERC20, Native Token)."""
|
|
525
|
+
return self._copy_with(sender_label=_normalize_multi(*labels))
|
|
526
|
+
|
|
527
|
+
def sender_category(self, *categories: str) -> "AsyncQueryBuilder":
|
|
528
|
+
"""Filter sender by category (ERC20, Native Token)."""
|
|
529
|
+
return self._copy_with(sender_category=_normalize_multi(*categories))
|
|
530
|
+
|
|
531
|
+
def receiver_label(self, *labels: str) -> "AsyncQueryBuilder":
|
|
532
|
+
"""Filter receiver by label (ERC20, Native Token)."""
|
|
533
|
+
return self._copy_with(receiver_label=_normalize_multi(*labels))
|
|
534
|
+
|
|
535
|
+
def receiver_category(self, *categories: str) -> "AsyncQueryBuilder":
|
|
536
|
+
"""Filter receiver by category (ERC20, Native Token)."""
|
|
537
|
+
return self._copy_with(receiver_category=_normalize_multi(*categories))
|
|
312
538
|
|
|
313
539
|
def min_amount(self, amount: float) -> "AsyncQueryBuilder":
|
|
314
540
|
"""Filter by minimum amount (ERC20, Native Token)."""
|
|
@@ -350,6 +576,8 @@ class AsyncQueryBuilder:
|
|
|
350
576
|
def _build_params(self) -> dict[str, Any]:
|
|
351
577
|
"""Build the final query parameters."""
|
|
352
578
|
params = self._params.copy()
|
|
579
|
+
_validate_sql_safety(params)
|
|
580
|
+
_validate_mutual_exclusivity(params)
|
|
353
581
|
if self._verbose:
|
|
354
582
|
params["verbose"] = "true"
|
|
355
583
|
return params
|
|
@@ -437,5 +665,99 @@ class AsyncQueryBuilder:
|
|
|
437
665
|
params["format"] = format
|
|
438
666
|
await self._client._request("GET", self._endpoint, params=params, output_file=path)
|
|
439
667
|
|
|
668
|
+
# Aggregate transition
|
|
669
|
+
def aggregate(self, group_by: str = "time", period: str = "1h") -> "AsyncAggregateQueryBuilder":
|
|
670
|
+
"""Transition to an async aggregate query.
|
|
671
|
+
|
|
672
|
+
Appends ``/aggregate`` to the endpoint and includes ``group_by`` and
|
|
673
|
+
``period`` as query parameters.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
group_by: Aggregation axis — ``"time"`` or ``"block"``.
|
|
677
|
+
period: Bucket size, e.g. ``"1h"``, ``"100b"``.
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
An :class:`AsyncAggregateQueryBuilder` that inherits all current filters.
|
|
681
|
+
"""
|
|
682
|
+
builder = AsyncAggregateQueryBuilder(
|
|
683
|
+
self._client,
|
|
684
|
+
self._endpoint.rstrip("/") + "/aggregate",
|
|
685
|
+
self._params.copy(),
|
|
686
|
+
)
|
|
687
|
+
builder._verbose = self._verbose
|
|
688
|
+
builder._params["group_by"] = group_by
|
|
689
|
+
builder._params["period"] = period
|
|
690
|
+
return builder
|
|
691
|
+
|
|
440
692
|
def __repr__(self) -> str:
|
|
441
693
|
return f"AsyncQueryBuilder(endpoint={self._endpoint!r}, params={self._params!r}, verbose={self._verbose})"
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
class AsyncAggregateQueryBuilder(AsyncQueryBuilder):
|
|
697
|
+
"""Async builder for aggregate (bucketed) queries.
|
|
698
|
+
|
|
699
|
+
Returned by :pymeth:`AsyncQueryBuilder.aggregate`. Overrides terminal
|
|
700
|
+
methods so that JSON responses extract from the ``"data"`` key instead of
|
|
701
|
+
``"events"``.
|
|
702
|
+
"""
|
|
703
|
+
|
|
704
|
+
def _copy_with(self, **updates: Any) -> "AsyncAggregateQueryBuilder":
|
|
705
|
+
"""Create a copy preserving async aggregate builder type."""
|
|
706
|
+
new_builder = AsyncAggregateQueryBuilder(self._client, self._endpoint, self._params.copy())
|
|
707
|
+
new_builder._verbose = self._verbose
|
|
708
|
+
for key, value in updates.items():
|
|
709
|
+
if key == "verbose":
|
|
710
|
+
new_builder._verbose = value
|
|
711
|
+
elif value is not None:
|
|
712
|
+
new_builder._params[key] = value
|
|
713
|
+
return new_builder
|
|
714
|
+
|
|
715
|
+
# Terminal methods — extract from "data" key
|
|
716
|
+
async def as_dict(self) -> list[dict[str, Any]]:
|
|
717
|
+
"""Execute aggregate query and return results as list of dictionaries."""
|
|
718
|
+
params = self._build_params()
|
|
719
|
+
return await self._client._request(
|
|
720
|
+
"GET", self._endpoint, params=params, response_key="data",
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
async def as_df(self, library: str = "pandas") -> Any:
|
|
724
|
+
"""Execute aggregate query and return results as DataFrame."""
|
|
725
|
+
if library not in ("pandas", "polars"):
|
|
726
|
+
raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
|
|
727
|
+
params = self._build_params()
|
|
728
|
+
params["format"] = "parquet"
|
|
729
|
+
return await self._client._request(
|
|
730
|
+
"GET", self._endpoint, params=params, as_dataframe=library,
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
async def as_file(self, path: str, format: str | None = None) -> None:
|
|
734
|
+
"""Execute aggregate query and save results to file."""
|
|
735
|
+
if format is None:
|
|
736
|
+
if path.endswith(".csv"):
|
|
737
|
+
format = "csv"
|
|
738
|
+
elif path.endswith(".parquet"):
|
|
739
|
+
format = "parquet"
|
|
740
|
+
elif path.endswith(".json"):
|
|
741
|
+
format = "json"
|
|
742
|
+
else:
|
|
743
|
+
raise ValueError(
|
|
744
|
+
f"Cannot determine format from path '{path}'. "
|
|
745
|
+
"Use a file extension (.csv, .parquet, .json) or specify format explicitly."
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
if format not in ("csv", "parquet", "json"):
|
|
749
|
+
raise ValueError(f"format must be 'csv', 'parquet', or 'json', got '{format}'")
|
|
750
|
+
|
|
751
|
+
params = self._build_params()
|
|
752
|
+
|
|
753
|
+
if format == "json":
|
|
754
|
+
import json as _json
|
|
755
|
+
results = await self.as_dict()
|
|
756
|
+
with open(path, "w") as f:
|
|
757
|
+
_json.dump(results, f, indent=2)
|
|
758
|
+
else:
|
|
759
|
+
params["format"] = format
|
|
760
|
+
await self._client._request("GET", self._endpoint, params=params, output_file=path)
|
|
761
|
+
|
|
762
|
+
def __repr__(self) -> str:
|
|
763
|
+
return f"AsyncAggregateQueryBuilder(endpoint={self._endpoint!r}, params={self._params!r}, verbose={self._verbose})"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: defistream
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: Python client for the DeFiStream API
|
|
5
5
|
Project-URL: Homepage, https://defistream.dev
|
|
6
6
|
Project-URL: Documentation, https://docs.defistream.dev
|
|
@@ -204,6 +204,47 @@ df = (
|
|
|
204
204
|
)
|
|
205
205
|
```
|
|
206
206
|
|
|
207
|
+
### Label & Category Filters
|
|
208
|
+
|
|
209
|
+
```python
|
|
210
|
+
# Get USDT transfers involving Binance wallets
|
|
211
|
+
df = (
|
|
212
|
+
client.erc20.transfers("USDT")
|
|
213
|
+
.network("ETH")
|
|
214
|
+
.block_range(21000000, 21010000)
|
|
215
|
+
.involving_label("Binance")
|
|
216
|
+
.as_df()
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
# Get USDT transfers FROM exchanges TO DeFi protocols
|
|
220
|
+
df = (
|
|
221
|
+
client.erc20.transfers("USDT")
|
|
222
|
+
.network("ETH")
|
|
223
|
+
.block_range(21000000, 21010000)
|
|
224
|
+
.sender_category("exchange")
|
|
225
|
+
.receiver_category("defi")
|
|
226
|
+
.as_df()
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Get AAVE deposits involving exchange addresses
|
|
230
|
+
df = (
|
|
231
|
+
client.aave.deposits()
|
|
232
|
+
.network("ETH")
|
|
233
|
+
.block_range(21000000, 21010000)
|
|
234
|
+
.involving_category("exchange")
|
|
235
|
+
.as_df()
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Get native ETH transfers FROM Binance or Coinbase (multi-value)
|
|
239
|
+
df = (
|
|
240
|
+
client.native_token.transfers()
|
|
241
|
+
.network("ETH")
|
|
242
|
+
.block_range(21000000, 21010000)
|
|
243
|
+
.sender_label("Binance,Coinbase")
|
|
244
|
+
.as_df()
|
|
245
|
+
)
|
|
246
|
+
```
|
|
247
|
+
|
|
207
248
|
### Verbose Mode
|
|
208
249
|
|
|
209
250
|
By default, responses omit metadata fields to reduce payload size. Use `.verbose()` to include all fields:
|
|
@@ -295,6 +336,21 @@ for transfer in transfers:
|
|
|
295
336
|
|
|
296
337
|
> **Note:** `as_dict()` and `as_file("*.json")` use JSON format which has a **10,000 block limit**. For larger block ranges, use `as_df()` or `as_file()` with `.parquet` or `.csv` extensions, which support up to 1,000,000 blocks.
|
|
297
338
|
|
|
339
|
+
### Context Manager
|
|
340
|
+
|
|
341
|
+
Both sync and async clients support context managers to automatically close connections:
|
|
342
|
+
|
|
343
|
+
```python
|
|
344
|
+
# Sync
|
|
345
|
+
with DeFiStream() as client:
|
|
346
|
+
df = (
|
|
347
|
+
client.erc20.transfers("USDT")
|
|
348
|
+
.network("ETH")
|
|
349
|
+
.block_range(21000000, 21010000)
|
|
350
|
+
.as_df()
|
|
351
|
+
)
|
|
352
|
+
```
|
|
353
|
+
|
|
298
354
|
### Async Usage
|
|
299
355
|
|
|
300
356
|
```python
|
|
@@ -314,6 +370,14 @@ async def main():
|
|
|
314
370
|
asyncio.run(main())
|
|
315
371
|
```
|
|
316
372
|
|
|
373
|
+
### List Available Decoders
|
|
374
|
+
|
|
375
|
+
```python
|
|
376
|
+
client = DeFiStream()
|
|
377
|
+
decoders = client.decoders()
|
|
378
|
+
print(decoders) # ['native_token', 'erc20', 'aave', 'uniswap', 'lido', 'stader', 'threshold']
|
|
379
|
+
```
|
|
380
|
+
|
|
317
381
|
## Configuration
|
|
318
382
|
|
|
319
383
|
### Environment Variables
|
|
@@ -414,8 +478,11 @@ print(f"Request cost: {client.last_response.request_cost}")
|
|
|
414
478
|
| Method | Protocols | Description |
|
|
415
479
|
|--------|-----------|-------------|
|
|
416
480
|
| `.token(symbol)` | ERC20 | Token symbol (USDT, USDC) or contract address (required) |
|
|
417
|
-
| `.sender(
|
|
418
|
-
| `.receiver(
|
|
481
|
+
| `.sender(*addrs)` | ERC20, Native | Filter by sender address (multi-value) |
|
|
482
|
+
| `.receiver(*addrs)` | ERC20, Native | Filter by receiver address (multi-value) |
|
|
483
|
+
| `.involving(*addrs)` | All | Filter by any involved address (multi-value) |
|
|
484
|
+
| `.from_address(*addrs)` | ERC20, Native | Alias for `.sender()` |
|
|
485
|
+
| `.to_address(*addrs)` | ERC20, Native | Alias for `.receiver()` |
|
|
419
486
|
| `.min_amount(amt)` | ERC20, Native | Minimum transfer amount |
|
|
420
487
|
| `.max_amount(amt)` | ERC20, Native | Maximum transfer amount |
|
|
421
488
|
| `.eth_market_type(type)` | AAVE | Market type for ETH: 'Core', 'Prime', 'EtherFi' |
|
|
@@ -423,6 +490,23 @@ print(f"Request cost: {client.last_response.request_cost}")
|
|
|
423
490
|
| `.symbol1(sym)` | Uniswap | Second token symbol (required) |
|
|
424
491
|
| `.fee(tier)` | Uniswap | Fee tier: 100, 500, 3000, 10000 (required) |
|
|
425
492
|
|
|
493
|
+
### Address Label & Category Filters
|
|
494
|
+
|
|
495
|
+
Filter events by entity names or categories using the labels database. Available on all protocols.
|
|
496
|
+
|
|
497
|
+
| Method | Protocols | Description |
|
|
498
|
+
|--------|-----------|-------------|
|
|
499
|
+
| `.involving_label(label)` | All | Filter where any involved address matches a label substring (e.g., "Binance") |
|
|
500
|
+
| `.involving_category(cat)` | All | Filter where any involved address matches a category (e.g., "exchange") |
|
|
501
|
+
| `.sender_label(label)` | ERC20, Native | Filter sender by label substring |
|
|
502
|
+
| `.sender_category(cat)` | ERC20, Native | Filter sender by category |
|
|
503
|
+
| `.receiver_label(label)` | ERC20, Native | Filter receiver by label substring |
|
|
504
|
+
| `.receiver_category(cat)` | ERC20, Native | Filter receiver by category |
|
|
505
|
+
|
|
506
|
+
**Multi-value support:** Pass multiple values as separate arguments (e.g., `.sender_label("Binance", "Coinbase")`) or as a comma-separated string (e.g., `.sender_label("Binance,Coinbase")`). Both forms are equivalent.
|
|
507
|
+
|
|
508
|
+
**Mutual exclusivity:** Within each slot (involving/sender/receiver), only one of address/label/category can be set. `involving*` filters cannot be combined with `sender*`/`receiver*` filters.
|
|
509
|
+
|
|
426
510
|
### Terminal Methods
|
|
427
511
|
|
|
428
512
|
| Method | Description |
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
defistream/__init__.py,sha256=j9vKDoqInjyvxau2lDb1eBfr0ZWKVouhBNBbgzXbgpg,1224
|
|
2
|
+
defistream/client.py,sha256=OZsaa7kgDxWCS37geoTGlp-IFaO3Kn8bm1ghNE9tqB4,13973
|
|
3
|
+
defistream/exceptions.py,sha256=_GxZQ18_YvXFtmNHeddWV8fHPIllHgFeP7fP0CmHF1k,1492
|
|
4
|
+
defistream/models.py,sha256=Zw3DHAISxB6pivKybNzyHXR5IcRwvTZl23DHbjfyKwM,622
|
|
5
|
+
defistream/protocols.py,sha256=5_bYd46lDy-mK6LZ8sTGW0Z2IVH4g0cQ5rBbbOXsw0U,15368
|
|
6
|
+
defistream/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
defistream/query.py,sha256=Pr-zwUCBcjBUkqa1CFzVJmsnZ4DpV29c0_9p1VtfGak,30433
|
|
8
|
+
defistream-1.2.0.dist-info/METADATA,sha256=7rNfboEYqF87-3RKkUlOzMRsKRKEns0H1lmeesEJwsM,13657
|
|
9
|
+
defistream-1.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
10
|
+
defistream-1.2.0.dist-info/licenses/LICENSE,sha256=72DWAof8dMePfFQmfaswClW5d-sE6k7p-7VpuSKLmU4,1067
|
|
11
|
+
defistream-1.2.0.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
defistream/__init__.py,sha256=duUlI1d-H7mP16D6dd0zbCslu45ARItE_pwQ5N7o05M,1089
|
|
2
|
-
defistream/client.py,sha256=Ku8ouDbM6Mx4lVmqvBwNvNt-h2FkvqauPMSjKyPkjU4,12717
|
|
3
|
-
defistream/exceptions.py,sha256=_GxZQ18_YvXFtmNHeddWV8fHPIllHgFeP7fP0CmHF1k,1492
|
|
4
|
-
defistream/models.py,sha256=Zw3DHAISxB6pivKybNzyHXR5IcRwvTZl23DHbjfyKwM,622
|
|
5
|
-
defistream/protocols.py,sha256=5_bYd46lDy-mK6LZ8sTGW0Z2IVH4g0cQ5rBbbOXsw0U,15368
|
|
6
|
-
defistream/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
-
defistream/query.py,sha256=sNn_0OzNDlsp4N74Grumsb08R5OFPPoppPok3vh8Y6Q,16775
|
|
8
|
-
defistream-1.0.6.dist-info/METADATA,sha256=2VBBk9BJNGQiGOyya3YCTB23rFTbes0UjNhma-4R4ig,10877
|
|
9
|
-
defistream-1.0.6.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
10
|
-
defistream-1.0.6.dist-info/licenses/LICENSE,sha256=72DWAof8dMePfFQmfaswClW5d-sE6k7p-7VpuSKLmU4,1067
|
|
11
|
-
defistream-1.0.6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|