defistream 1.0.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/query.py ADDED
@@ -0,0 +1,477 @@
1
+ """Query builder for DeFiStream API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
6
+
7
+ if TYPE_CHECKING:
8
+ from .client import BaseClient
9
+
10
+ T = TypeVar("T")
11
+
12
+
13
+ class QueryBuilder:
14
+ """
15
+ Builder for constructing and executing DeFiStream API queries.
16
+
17
+ Query is only executed when a terminal method is called:
18
+ - as_dict() - returns list of dictionaries
19
+ - as_df() - returns pandas DataFrame (default) or polars DataFrame
20
+ - as_file() - saves to CSV, Parquet, or JSON file
21
+
22
+ Example:
23
+ query = client.erc20.transfers("USDT").network("ETH").start_block(24000000).end_block(24100000)
24
+ query = query.min_amount(1000).sender("0x...")
25
+ df = query.as_df() # pandas DataFrame
26
+ df = query.as_df("polars") # polars DataFrame
27
+ query.as_file("transfers.csv") # save to CSV
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ client: "BaseClient",
33
+ endpoint: str,
34
+ initial_params: dict[str, Any] | None = None,
35
+ ):
36
+ self._client = client
37
+ self._endpoint = endpoint
38
+ self._params: dict[str, Any] = initial_params or {}
39
+ self._verbose = False
40
+
41
+ def _copy_with(self, **updates: Any) -> "QueryBuilder":
42
+ """Create a copy with updated parameters."""
43
+ new_builder = QueryBuilder(self._client, self._endpoint, self._params.copy())
44
+ new_builder._verbose = self._verbose
45
+ for key, value in updates.items():
46
+ if key == "verbose":
47
+ new_builder._verbose = value
48
+ elif value is not None:
49
+ new_builder._params[key] = value
50
+ return new_builder
51
+
52
+ # Network and block range
53
+ def network(self, network: str) -> "QueryBuilder":
54
+ """Set the network (ETH, ARB, BASE, OP, etc.)."""
55
+ return self._copy_with(network=network)
56
+
57
+ def start_block(self, block: int) -> "QueryBuilder":
58
+ """Set the starting block number."""
59
+ return self._copy_with(block_start=block)
60
+
61
+ def end_block(self, block: int) -> "QueryBuilder":
62
+ """Set the ending block number."""
63
+ return self._copy_with(block_end=block)
64
+
65
+ def block_range(self, start: int, end: int) -> "QueryBuilder":
66
+ """Set both start and end block numbers."""
67
+ return self._copy_with(block_start=start, block_end=end)
68
+
69
+ # Time range
70
+ def start_time(self, timestamp: str) -> "QueryBuilder":
71
+ """Set the starting time (ISO format or Unix timestamp)."""
72
+ return self._copy_with(since=timestamp)
73
+
74
+ def end_time(self, timestamp: str) -> "QueryBuilder":
75
+ """Set the ending time (ISO format or Unix timestamp)."""
76
+ return self._copy_with(until=timestamp)
77
+
78
+ def time_range(self, start: str, end: str) -> "QueryBuilder":
79
+ """Set both start and end times."""
80
+ return self._copy_with(since=start, until=end)
81
+
82
+ # Common filters
83
+ def sender(self, address: str) -> "QueryBuilder":
84
+ """Filter by sender address."""
85
+ return self._copy_with(sender=address)
86
+
87
+ def receiver(self, address: str) -> "QueryBuilder":
88
+ """Filter by receiver address."""
89
+ return self._copy_with(receiver=address)
90
+
91
+ def from_address(self, address: str) -> "QueryBuilder":
92
+ """Filter by sender address (alias for sender)."""
93
+ return self.sender(address)
94
+
95
+ def to_address(self, address: str) -> "QueryBuilder":
96
+ """Filter by receiver address (alias for receiver)."""
97
+ return self.receiver(address)
98
+
99
+ def min_amount(self, amount: float) -> "QueryBuilder":
100
+ """Filter by minimum amount."""
101
+ return self._copy_with(min_amount=amount)
102
+
103
+ # ERC20 specific
104
+ def token(self, symbol: str) -> "QueryBuilder":
105
+ """Set token symbol (for ERC20)."""
106
+ return self._copy_with(token=symbol)
107
+
108
+ def owner(self, address: str) -> "QueryBuilder":
109
+ """Filter by owner address (for approvals)."""
110
+ return self._copy_with(owner=address)
111
+
112
+ def spender(self, address: str) -> "QueryBuilder":
113
+ """Filter by spender address (for approvals)."""
114
+ return self._copy_with(spender=address)
115
+
116
+ # AAVE specific
117
+ def user(self, address: str) -> "QueryBuilder":
118
+ """Filter by user address (for AAVE)."""
119
+ return self._copy_with(user=address)
120
+
121
+ def reserve(self, address: str) -> "QueryBuilder":
122
+ """Filter by reserve address (for AAVE)."""
123
+ return self._copy_with(reserve=address)
124
+
125
+ def liquidator(self, address: str) -> "QueryBuilder":
126
+ """Filter by liquidator address (for AAVE liquidations)."""
127
+ return self._copy_with(liquidator=address)
128
+
129
+ # Uniswap specific
130
+ def symbol0(self, symbol: str) -> "QueryBuilder":
131
+ """Set first token symbol (for Uniswap)."""
132
+ return self._copy_with(symbol0=symbol)
133
+
134
+ def symbol1(self, symbol: str) -> "QueryBuilder":
135
+ """Set second token symbol (for Uniswap)."""
136
+ return self._copy_with(symbol1=symbol)
137
+
138
+ def fee(self, fee_tier: int) -> "QueryBuilder":
139
+ """Set fee tier (for Uniswap): 100, 500, 3000, 10000."""
140
+ return self._copy_with(fee=fee_tier)
141
+
142
+ def pool(self, address: str) -> "QueryBuilder":
143
+ """Set pool address (for Uniswap)."""
144
+ return self._copy_with(pool=address)
145
+
146
+ # Verbose mode
147
+ def verbose(self, enabled: bool = True) -> "QueryBuilder":
148
+ """Include all metadata fields (tx_hash, tx_id, log_index, network, name)."""
149
+ return self._copy_with(verbose=enabled)
150
+
151
+ # Build final params
152
+ def _build_params(self) -> dict[str, Any]:
153
+ """Build the final query parameters."""
154
+ params = self._params.copy()
155
+ if self._verbose:
156
+ params["verbose"] = "true"
157
+ return params
158
+
159
+ # Terminal methods - execute the query
160
+ def as_dict(self) -> list[dict[str, Any]]:
161
+ """
162
+ Execute query and return results as list of dictionaries.
163
+
164
+ Uses JSON format from API.
165
+
166
+ Returns:
167
+ List of event dictionaries
168
+ """
169
+ params = self._build_params()
170
+ return self._client._request("GET", self._endpoint, params=params)
171
+
172
+ def as_df(self, library: str = "pandas") -> Any:
173
+ """
174
+ Execute query and return results as DataFrame.
175
+
176
+ Args:
177
+ library: DataFrame library to use - "pandas" (default) or "polars"
178
+
179
+ Returns:
180
+ pandas.DataFrame or polars.DataFrame
181
+
182
+ Example:
183
+ df = query.as_df() # pandas DataFrame
184
+ df = query.as_df("polars") # polars DataFrame
185
+ """
186
+ if library not in ("pandas", "polars"):
187
+ raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
188
+ params = self._build_params()
189
+ params["format"] = "parquet"
190
+ return self._client._request(
191
+ "GET", self._endpoint, params=params, as_dataframe=library
192
+ )
193
+
194
+ def as_file(self, path: str, format: str | None = None) -> None:
195
+ """
196
+ Execute query and save results to file.
197
+
198
+ Format is automatically determined by file extension, or can be
199
+ explicitly specified.
200
+
201
+ Args:
202
+ path: File path to save to
203
+ format: File format - "csv", "parquet", or "json".
204
+ If None, determined from file extension.
205
+
206
+ Example:
207
+ query.as_file("transfers.csv") # CSV format
208
+ query.as_file("transfers.parquet") # Parquet format
209
+ query.as_file("transfers.json") # JSON format
210
+ query.as_file("transfers", format="csv") # Explicit format
211
+ """
212
+ # Determine format from extension or explicit parameter
213
+ if format is None:
214
+ if path.endswith(".csv"):
215
+ format = "csv"
216
+ elif path.endswith(".parquet"):
217
+ format = "parquet"
218
+ elif path.endswith(".json"):
219
+ format = "json"
220
+ else:
221
+ raise ValueError(
222
+ f"Cannot determine format from path '{path}'. "
223
+ "Use a file extension (.csv, .parquet, .json) or specify format explicitly."
224
+ )
225
+
226
+ if format not in ("csv", "parquet", "json"):
227
+ raise ValueError(f"format must be 'csv', 'parquet', or 'json', got '{format}'")
228
+
229
+ params = self._build_params()
230
+
231
+ if format == "json":
232
+ # For JSON, fetch as dict and write manually
233
+ import json
234
+ results = self.as_dict()
235
+ with open(path, "w") as f:
236
+ json.dump(results, f, indent=2)
237
+ else:
238
+ # For CSV and Parquet, use API format parameter
239
+ params["format"] = format
240
+ self._client._request("GET", self._endpoint, params=params, output_file=path)
241
+
242
+ def __repr__(self) -> str:
243
+ return f"QueryBuilder(endpoint={self._endpoint!r}, params={self._params!r}, verbose={self._verbose})"
244
+
245
+
246
+ class AsyncQueryBuilder:
247
+ """
248
+ Async builder for constructing and executing DeFiStream API queries.
249
+
250
+ Query is only executed when a terminal method is called:
251
+ - as_dict() - returns list of dictionaries
252
+ - as_df() - returns pandas DataFrame (default) or polars DataFrame
253
+ - as_file() - saves to CSV, Parquet, or JSON file
254
+
255
+ Example:
256
+ query = client.erc20.transfers("USDT").network("ETH").start_block(24000000).end_block(24100000)
257
+ df = await query.as_df() # pandas DataFrame
258
+ df = await query.as_df("polars") # polars DataFrame
259
+ await query.as_file("transfers.csv") # save to CSV
260
+ """
261
+
262
+ def __init__(
263
+ self,
264
+ client: "BaseClient",
265
+ endpoint: str,
266
+ initial_params: dict[str, Any] | None = None,
267
+ ):
268
+ self._client = client
269
+ self._endpoint = endpoint
270
+ self._params: dict[str, Any] = initial_params or {}
271
+ self._verbose = False
272
+
273
+ def _copy_with(self, **updates: Any) -> "AsyncQueryBuilder":
274
+ """Create a copy with updated parameters."""
275
+ new_builder = AsyncQueryBuilder(self._client, self._endpoint, self._params.copy())
276
+ new_builder._verbose = self._verbose
277
+ for key, value in updates.items():
278
+ if key == "verbose":
279
+ new_builder._verbose = value
280
+ elif value is not None:
281
+ new_builder._params[key] = value
282
+ return new_builder
283
+
284
+ # Network and block range
285
+ def network(self, network: str) -> "AsyncQueryBuilder":
286
+ """Set the network (ETH, ARB, BASE, OP, etc.)."""
287
+ return self._copy_with(network=network)
288
+
289
+ def start_block(self, block: int) -> "AsyncQueryBuilder":
290
+ """Set the starting block number."""
291
+ return self._copy_with(block_start=block)
292
+
293
+ def end_block(self, block: int) -> "AsyncQueryBuilder":
294
+ """Set the ending block number."""
295
+ return self._copy_with(block_end=block)
296
+
297
+ def block_range(self, start: int, end: int) -> "AsyncQueryBuilder":
298
+ """Set both start and end block numbers."""
299
+ return self._copy_with(block_start=start, block_end=end)
300
+
301
+ # Time range
302
+ def start_time(self, timestamp: str) -> "AsyncQueryBuilder":
303
+ """Set the starting time (ISO format or Unix timestamp)."""
304
+ return self._copy_with(since=timestamp)
305
+
306
+ def end_time(self, timestamp: str) -> "AsyncQueryBuilder":
307
+ """Set the ending time (ISO format or Unix timestamp)."""
308
+ return self._copy_with(until=timestamp)
309
+
310
+ def time_range(self, start: str, end: str) -> "AsyncQueryBuilder":
311
+ """Set both start and end times."""
312
+ return self._copy_with(since=start, until=end)
313
+
314
+ # Common filters
315
+ def sender(self, address: str) -> "AsyncQueryBuilder":
316
+ """Filter by sender address."""
317
+ return self._copy_with(sender=address)
318
+
319
+ def receiver(self, address: str) -> "AsyncQueryBuilder":
320
+ """Filter by receiver address."""
321
+ return self._copy_with(receiver=address)
322
+
323
+ def from_address(self, address: str) -> "AsyncQueryBuilder":
324
+ """Filter by sender address (alias for sender)."""
325
+ return self.sender(address)
326
+
327
+ def to_address(self, address: str) -> "AsyncQueryBuilder":
328
+ """Filter by receiver address (alias for receiver)."""
329
+ return self.receiver(address)
330
+
331
+ def min_amount(self, amount: float) -> "AsyncQueryBuilder":
332
+ """Filter by minimum amount."""
333
+ return self._copy_with(min_amount=amount)
334
+
335
+ # ERC20 specific
336
+ def token(self, symbol: str) -> "AsyncQueryBuilder":
337
+ """Set token symbol (for ERC20)."""
338
+ return self._copy_with(token=symbol)
339
+
340
+ def owner(self, address: str) -> "AsyncQueryBuilder":
341
+ """Filter by owner address (for approvals)."""
342
+ return self._copy_with(owner=address)
343
+
344
+ def spender(self, address: str) -> "AsyncQueryBuilder":
345
+ """Filter by spender address (for approvals)."""
346
+ return self._copy_with(spender=address)
347
+
348
+ # AAVE specific
349
+ def user(self, address: str) -> "AsyncQueryBuilder":
350
+ """Filter by user address (for AAVE)."""
351
+ return self._copy_with(user=address)
352
+
353
+ def reserve(self, address: str) -> "AsyncQueryBuilder":
354
+ """Filter by reserve address (for AAVE)."""
355
+ return self._copy_with(reserve=address)
356
+
357
+ def liquidator(self, address: str) -> "AsyncQueryBuilder":
358
+ """Filter by liquidator address (for AAVE liquidations)."""
359
+ return self._copy_with(liquidator=address)
360
+
361
+ # Uniswap specific
362
+ def symbol0(self, symbol: str) -> "AsyncQueryBuilder":
363
+ """Set first token symbol (for Uniswap)."""
364
+ return self._copy_with(symbol0=symbol)
365
+
366
+ def symbol1(self, symbol: str) -> "AsyncQueryBuilder":
367
+ """Set second token symbol (for Uniswap)."""
368
+ return self._copy_with(symbol1=symbol)
369
+
370
+ def fee(self, fee_tier: int) -> "AsyncQueryBuilder":
371
+ """Set fee tier (for Uniswap): 100, 500, 3000, 10000."""
372
+ return self._copy_with(fee=fee_tier)
373
+
374
+ def pool(self, address: str) -> "AsyncQueryBuilder":
375
+ """Set pool address (for Uniswap)."""
376
+ return self._copy_with(pool=address)
377
+
378
+ # Verbose mode
379
+ def verbose(self, enabled: bool = True) -> "AsyncQueryBuilder":
380
+ """Include all metadata fields (tx_hash, tx_id, log_index, network, name)."""
381
+ return self._copy_with(verbose=enabled)
382
+
383
+ # Build final params
384
+ def _build_params(self) -> dict[str, Any]:
385
+ """Build the final query parameters."""
386
+ params = self._params.copy()
387
+ if self._verbose:
388
+ params["verbose"] = "true"
389
+ return params
390
+
391
+ # Terminal methods - execute the query (async)
392
+ async def as_dict(self) -> list[dict[str, Any]]:
393
+ """
394
+ Execute query and return results as list of dictionaries.
395
+
396
+ Uses JSON format from API.
397
+
398
+ Returns:
399
+ List of event dictionaries
400
+ """
401
+ params = self._build_params()
402
+ return await self._client._request("GET", self._endpoint, params=params)
403
+
404
+ async def as_df(self, library: str = "pandas") -> Any:
405
+ """
406
+ Execute query and return results as DataFrame.
407
+
408
+ Uses Parquet format from API for efficiency, then converts to DataFrame.
409
+
410
+ Args:
411
+ library: DataFrame library to use - "pandas" (default) or "polars"
412
+
413
+ Returns:
414
+ pandas.DataFrame or polars.DataFrame
415
+
416
+ Example:
417
+ df = await query.as_df() # pandas DataFrame
418
+ df = await query.as_df("polars") # polars DataFrame
419
+ """
420
+ if library not in ("pandas", "polars"):
421
+ raise ValueError(f"library must be 'pandas' or 'polars', got '{library}'")
422
+ params = self._build_params()
423
+ params["format"] = "parquet"
424
+ return await self._client._request(
425
+ "GET", self._endpoint, params=params, as_dataframe=library
426
+ )
427
+
428
+ async def as_file(self, path: str, format: str | None = None) -> None:
429
+ """
430
+ Execute query and save results to file.
431
+
432
+ Format is automatically determined by file extension, or can be
433
+ explicitly specified.
434
+
435
+ Args:
436
+ path: File path to save to
437
+ format: File format - "csv", "parquet", or "json".
438
+ If None, determined from file extension.
439
+
440
+ Example:
441
+ await query.as_file("transfers.csv") # CSV format
442
+ await query.as_file("transfers.parquet") # Parquet format
443
+ await query.as_file("transfers.json") # JSON format
444
+ await query.as_file("transfers", format="csv") # Explicit format
445
+ """
446
+ # Determine format from extension or explicit parameter
447
+ if format is None:
448
+ if path.endswith(".csv"):
449
+ format = "csv"
450
+ elif path.endswith(".parquet"):
451
+ format = "parquet"
452
+ elif path.endswith(".json"):
453
+ format = "json"
454
+ else:
455
+ raise ValueError(
456
+ f"Cannot determine format from path '{path}'. "
457
+ "Use a file extension (.csv, .parquet, .json) or specify format explicitly."
458
+ )
459
+
460
+ if format not in ("csv", "parquet", "json"):
461
+ raise ValueError(f"format must be 'csv', 'parquet', or 'json', got '{format}'")
462
+
463
+ params = self._build_params()
464
+
465
+ if format == "json":
466
+ # For JSON, fetch as dict and write manually
467
+ import json
468
+ results = await self.as_dict()
469
+ with open(path, "w") as f:
470
+ json.dump(results, f, indent=2)
471
+ else:
472
+ # For CSV and Parquet, use API format parameter
473
+ params["format"] = format
474
+ await self._client._request("GET", self._endpoint, params=params, output_file=path)
475
+
476
+ def __repr__(self) -> str:
477
+ return f"AsyncQueryBuilder(endpoint={self._endpoint!r}, params={self._params!r}, verbose={self._verbose})"