defistream 1.0.6__py3-none-any.whl → 1.2.1__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 CHANGED
@@ -29,9 +29,14 @@ from .exceptions import (
29
29
  ServerError,
30
30
  ValidationError,
31
31
  )
32
- from .query import AsyncQueryBuilder, QueryBuilder
32
+ from .query import (
33
+ AggregateQueryBuilder,
34
+ AsyncAggregateQueryBuilder,
35
+ AsyncQueryBuilder,
36
+ QueryBuilder,
37
+ )
33
38
 
34
- __version__ = "1.0.6"
39
+ __version__ = "1.2.1"
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("events", [])
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, address: str) -> "QueryBuilder":
82
- """Filter by sender address (ERC20, Native Token)."""
83
- return self._copy_with(sender=address)
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, address: str) -> "QueryBuilder":
86
- """Filter by receiver address (ERC20, Native Token)."""
87
- return self._copy_with(receiver=address)
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, address: str) -> "QueryBuilder":
90
- """Filter by sender address (alias for sender)."""
91
- return self.sender(address)
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, address: str) -> "QueryBuilder":
94
- """Filter by receiver address (alias for receiver)."""
95
- return self.receiver(address)
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, address: str) -> "AsyncQueryBuilder":
298
- """Filter by sender address (ERC20, Native Token)."""
299
- return self._copy_with(sender=address)
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, address: str) -> "AsyncQueryBuilder":
302
- """Filter by receiver address (ERC20, Native Token)."""
303
- return self._copy_with(receiver=address)
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, address: str) -> "AsyncQueryBuilder":
306
- """Filter by sender address (alias for sender)."""
307
- return self.sender(address)
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, address: str) -> "AsyncQueryBuilder":
310
- """Filter by receiver address (alias for receiver)."""
311
- return self.receiver(address)
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.6
3
+ Version: 1.2.1
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
@@ -78,6 +78,7 @@ print(df.head())
78
78
  ## Features
79
79
 
80
80
  - **Builder pattern**: Fluent query API with chainable methods
81
+ - **Aggregate queries**: Bucket events into time or block intervals with summary statistics
81
82
  - **Type-safe**: Full type hints and Pydantic models
82
83
  - **Multiple formats**: DataFrame (pandas/polars), CSV, Parquet, JSON
83
84
  - **Async support**: Native async/await with `AsyncDeFiStream`
@@ -204,6 +205,98 @@ df = (
204
205
  )
205
206
  ```
206
207
 
208
+ ### Label & Category Filters
209
+
210
+ ```python
211
+ # Get USDT transfers involving Binance wallets
212
+ df = (
213
+ client.erc20.transfers("USDT")
214
+ .network("ETH")
215
+ .block_range(21000000, 21010000)
216
+ .involving_label("Binance")
217
+ .as_df()
218
+ )
219
+
220
+ # Get USDT transfers FROM exchanges TO DeFi protocols
221
+ df = (
222
+ client.erc20.transfers("USDT")
223
+ .network("ETH")
224
+ .block_range(21000000, 21010000)
225
+ .sender_category("exchange")
226
+ .receiver_category("defi")
227
+ .as_df()
228
+ )
229
+
230
+ # Get AAVE deposits involving exchange addresses
231
+ df = (
232
+ client.aave.deposits()
233
+ .network("ETH")
234
+ .block_range(21000000, 21010000)
235
+ .involving_category("exchange")
236
+ .as_df()
237
+ )
238
+
239
+ # Get native ETH transfers FROM Binance or Coinbase (multi-value)
240
+ df = (
241
+ client.native_token.transfers()
242
+ .network("ETH")
243
+ .block_range(21000000, 21010000)
244
+ .sender_label("Binance,Coinbase")
245
+ .as_df()
246
+ )
247
+ ```
248
+
249
+ ### Aggregate Queries
250
+
251
+ Use `.aggregate()` to bucket raw events into time or block intervals with summary statistics. All existing filters work before `.aggregate()` is called.
252
+
253
+ ```python
254
+ # Aggregate USDT transfers into 2-hour buckets
255
+ df = (
256
+ client.erc20.transfers("USDT")
257
+ .network("ETH")
258
+ .block_range(21000000, 21100000)
259
+ .aggregate(group_by="time", period="2h")
260
+ .as_df()
261
+ )
262
+
263
+ # Aggregate by block intervals
264
+ df = (
265
+ client.erc20.transfers("USDT")
266
+ .network("ETH")
267
+ .block_range(21000000, 21100000)
268
+ .aggregate(group_by="block", period="100b")
269
+ .as_df()
270
+ )
271
+
272
+ # Combine with filters — large transfers from exchanges, bucketed hourly
273
+ df = (
274
+ client.erc20.transfers("USDT")
275
+ .network("ETH")
276
+ .block_range(21000000, 21100000)
277
+ .sender_category("exchange")
278
+ .min_amount(10000)
279
+ .aggregate(group_by="time", period="1h")
280
+ .as_df()
281
+ )
282
+
283
+ # Aggregate Uniswap swaps
284
+ df = (
285
+ client.uniswap.swaps("WETH", "USDC", 500)
286
+ .network("ETH")
287
+ .block_range(21000000, 21100000)
288
+ .aggregate(group_by="time", period="1h")
289
+ .as_df()
290
+ )
291
+ ```
292
+
293
+ You can also discover what aggregate fields are available for a protocol:
294
+
295
+ ```python
296
+ schema = client.aggregate_schema("erc20")
297
+ print(schema)
298
+ ```
299
+
207
300
  ### Verbose Mode
208
301
 
209
302
  By default, responses omit metadata fields to reduce payload size. Use `.verbose()` to include all fields:
@@ -295,6 +388,21 @@ for transfer in transfers:
295
388
 
296
389
  > **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
390
 
391
+ ### Context Manager
392
+
393
+ Both sync and async clients support context managers to automatically close connections:
394
+
395
+ ```python
396
+ # Sync
397
+ with DeFiStream() as client:
398
+ df = (
399
+ client.erc20.transfers("USDT")
400
+ .network("ETH")
401
+ .block_range(21000000, 21010000)
402
+ .as_df()
403
+ )
404
+ ```
405
+
298
406
  ### Async Usage
299
407
 
300
408
  ```python
@@ -314,6 +422,14 @@ async def main():
314
422
  asyncio.run(main())
315
423
  ```
316
424
 
425
+ ### List Available Decoders
426
+
427
+ ```python
428
+ client = DeFiStream()
429
+ decoders = client.decoders()
430
+ print(decoders) # ['native_token', 'erc20', 'aave', 'uniswap', 'lido', 'stader', 'threshold']
431
+ ```
432
+
317
433
  ## Configuration
318
434
 
319
435
  ### Environment Variables
@@ -414,8 +530,11 @@ print(f"Request cost: {client.last_response.request_cost}")
414
530
  | Method | Protocols | Description |
415
531
  |--------|-----------|-------------|
416
532
  | `.token(symbol)` | ERC20 | Token symbol (USDT, USDC) or contract address (required) |
417
- | `.sender(addr)` | ERC20, Native | Filter by sender address |
418
- | `.receiver(addr)` | ERC20, Native | Filter by receiver address |
533
+ | `.sender(*addrs)` | ERC20, Native | Filter by sender address (multi-value) |
534
+ | `.receiver(*addrs)` | ERC20, Native | Filter by receiver address (multi-value) |
535
+ | `.involving(*addrs)` | All | Filter by any involved address (multi-value) |
536
+ | `.from_address(*addrs)` | ERC20, Native | Alias for `.sender()` |
537
+ | `.to_address(*addrs)` | ERC20, Native | Alias for `.receiver()` |
419
538
  | `.min_amount(amt)` | ERC20, Native | Minimum transfer amount |
420
539
  | `.max_amount(amt)` | ERC20, Native | Maximum transfer amount |
421
540
  | `.eth_market_type(type)` | AAVE | Market type for ETH: 'Core', 'Prime', 'EtherFi' |
@@ -423,6 +542,30 @@ print(f"Request cost: {client.last_response.request_cost}")
423
542
  | `.symbol1(sym)` | Uniswap | Second token symbol (required) |
424
543
  | `.fee(tier)` | Uniswap | Fee tier: 100, 500, 3000, 10000 (required) |
425
544
 
545
+ ### Address Label & Category Filters
546
+
547
+ Filter events by entity names or categories using the labels database. Available on all protocols.
548
+
549
+ | Method | Protocols | Description |
550
+ |--------|-----------|-------------|
551
+ | `.involving_label(label)` | All | Filter where any involved address matches a label substring (e.g., "Binance") |
552
+ | `.involving_category(cat)` | All | Filter where any involved address matches a category (e.g., "exchange") |
553
+ | `.sender_label(label)` | ERC20, Native | Filter sender by label substring |
554
+ | `.sender_category(cat)` | ERC20, Native | Filter sender by category |
555
+ | `.receiver_label(label)` | ERC20, Native | Filter receiver by label substring |
556
+ | `.receiver_category(cat)` | ERC20, Native | Filter receiver by category |
557
+
558
+ **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.
559
+
560
+ **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.
561
+
562
+ ### Aggregate Methods
563
+
564
+ | Method | Description |
565
+ |--------|-------------|
566
+ | `.aggregate(group_by, period)` | Transition to aggregate query. `group_by`: `"time"` or `"block"`. `period`: bucket size (e.g. `"1h"`, `"100b"`). Returns an `AggregateQueryBuilder` that supports all the same terminal and filter methods. |
567
+ | `client.aggregate_schema(protocol)` | Get available aggregate fields for a protocol (e.g. `"erc20"`, `"aave"`). |
568
+
426
569
  ### Terminal Methods
427
570
 
428
571
  | Method | Description |
@@ -0,0 +1,11 @@
1
+ defistream/__init__.py,sha256=jnSTX8AxaD5A8NIp059veFWnMkV9BOSWxgKfI-aau6k,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.1.dist-info/METADATA,sha256=S-XgsiQCjkgOLElZxcoFWfDSetIFxD17KmWOIwJQ1oM,15406
9
+ defistream-1.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
+ defistream-1.2.1.dist-info/licenses/LICENSE,sha256=72DWAof8dMePfFQmfaswClW5d-sE6k7p-7VpuSKLmU4,1067
11
+ defistream-1.2.1.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,,