defistream 1.0.6__tar.gz → 1.1.1__tar.gz
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-1.1.1/.claude/settings.local.json +21 -0
- {defistream-1.0.6 → defistream-1.1.1}/PKG-INFO +87 -3
- {defistream-1.0.6 → defistream-1.1.1}/README.md +86 -2
- {defistream-1.0.6 → defistream-1.1.1}/pyproject.toml +1 -1
- {defistream-1.0.6 → defistream-1.1.1}/src/defistream/__init__.py +1 -1
- {defistream-1.0.6 → defistream-1.1.1}/src/defistream/client.py +4 -0
- {defistream-1.0.6 → defistream-1.1.1}/src/defistream/query.py +158 -24
- defistream-1.1.1/tests/conftest.py +117 -0
- {defistream-1.0.6 → defistream-1.1.1}/tests/test_client.py +332 -0
- defistream-1.1.1/tests/test_integration.py +1484 -0
- {defistream-1.0.6 → defistream-1.1.1}/.gitignore +0 -0
- {defistream-1.0.6 → defistream-1.1.1}/LICENSE +0 -0
- {defistream-1.0.6 → defistream-1.1.1}/src/defistream/exceptions.py +0 -0
- {defistream-1.0.6 → defistream-1.1.1}/src/defistream/models.py +0 -0
- {defistream-1.0.6 → defistream-1.1.1}/src/defistream/protocols.py +0 -0
- {defistream-1.0.6 → defistream-1.1.1}/src/defistream/py.typed +0 -0
- {defistream-1.0.6 → defistream-1.1.1}/tests/__init__.py +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(git log:*)",
|
|
5
|
+
"Bash(python -m pytest:*)",
|
|
6
|
+
"Bash(python3 -m pytest:*)",
|
|
7
|
+
"Bash(git add:*)",
|
|
8
|
+
"Bash(git commit -m \"$\\(cat <<''EOF''\nAdd label/category filters and multi-value support to query builders\n\nAdd involving, involving_label, involving_category, sender_label,\nsender_category, receiver_label, receiver_category methods to both\nQueryBuilder and AsyncQueryBuilder. Update sender/receiver/from_address/\nto_address to accept varargs for multi-value filtering. Add SQL safety\nvalidation for label/category params and mutual exclusivity enforcement\nbetween involving* and sender*/receiver* filter slots.\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
|
9
|
+
"Bash(python3 -m build:*)",
|
|
10
|
+
"Bash(python3:*)",
|
|
11
|
+
"Bash(pip3 install:*)",
|
|
12
|
+
"Bash(/home/mvp/Running/Horatio_Chain_Scan/DeFiStream/python-client/.venv/bin/pip install:*)",
|
|
13
|
+
"Bash(/home/mvp/Running/Horatio_Chain_Scan/DeFiStream/python-client/.venv/bin/python:*)",
|
|
14
|
+
"Bash(source:*)",
|
|
15
|
+
"Bash(TWINE_USERNAME=__token__ TWINE_PASSWORD=\"$PYPI_PASSWORD\" /home/mvp/Running/Horatio_Chain_Scan/DeFiStream/python-client/.venv/bin/python:*)",
|
|
16
|
+
"Bash(git commit -m \"$\\(cat <<''EOF''\nBump python-client version to 1.1.0\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")",
|
|
17
|
+
"Bash(git push:*)",
|
|
18
|
+
"Bash(git subtree push:*)"
|
|
19
|
+
]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: defistream
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.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
|
|
@@ -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 |
|
|
@@ -170,6 +170,47 @@ df = (
|
|
|
170
170
|
)
|
|
171
171
|
```
|
|
172
172
|
|
|
173
|
+
### Label & Category Filters
|
|
174
|
+
|
|
175
|
+
```python
|
|
176
|
+
# Get USDT transfers involving Binance wallets
|
|
177
|
+
df = (
|
|
178
|
+
client.erc20.transfers("USDT")
|
|
179
|
+
.network("ETH")
|
|
180
|
+
.block_range(21000000, 21010000)
|
|
181
|
+
.involving_label("Binance")
|
|
182
|
+
.as_df()
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Get USDT transfers FROM exchanges TO DeFi protocols
|
|
186
|
+
df = (
|
|
187
|
+
client.erc20.transfers("USDT")
|
|
188
|
+
.network("ETH")
|
|
189
|
+
.block_range(21000000, 21010000)
|
|
190
|
+
.sender_category("exchange")
|
|
191
|
+
.receiver_category("defi")
|
|
192
|
+
.as_df()
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Get AAVE deposits involving exchange addresses
|
|
196
|
+
df = (
|
|
197
|
+
client.aave.deposits()
|
|
198
|
+
.network("ETH")
|
|
199
|
+
.block_range(21000000, 21010000)
|
|
200
|
+
.involving_category("exchange")
|
|
201
|
+
.as_df()
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# Get native ETH transfers FROM Binance or Coinbase (multi-value)
|
|
205
|
+
df = (
|
|
206
|
+
client.native_token.transfers()
|
|
207
|
+
.network("ETH")
|
|
208
|
+
.block_range(21000000, 21010000)
|
|
209
|
+
.sender_label("Binance,Coinbase")
|
|
210
|
+
.as_df()
|
|
211
|
+
)
|
|
212
|
+
```
|
|
213
|
+
|
|
173
214
|
### Verbose Mode
|
|
174
215
|
|
|
175
216
|
By default, responses omit metadata fields to reduce payload size. Use `.verbose()` to include all fields:
|
|
@@ -261,6 +302,21 @@ for transfer in transfers:
|
|
|
261
302
|
|
|
262
303
|
> **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.
|
|
263
304
|
|
|
305
|
+
### Context Manager
|
|
306
|
+
|
|
307
|
+
Both sync and async clients support context managers to automatically close connections:
|
|
308
|
+
|
|
309
|
+
```python
|
|
310
|
+
# Sync
|
|
311
|
+
with DeFiStream() as client:
|
|
312
|
+
df = (
|
|
313
|
+
client.erc20.transfers("USDT")
|
|
314
|
+
.network("ETH")
|
|
315
|
+
.block_range(21000000, 21010000)
|
|
316
|
+
.as_df()
|
|
317
|
+
)
|
|
318
|
+
```
|
|
319
|
+
|
|
264
320
|
### Async Usage
|
|
265
321
|
|
|
266
322
|
```python
|
|
@@ -280,6 +336,14 @@ async def main():
|
|
|
280
336
|
asyncio.run(main())
|
|
281
337
|
```
|
|
282
338
|
|
|
339
|
+
### List Available Decoders
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
client = DeFiStream()
|
|
343
|
+
decoders = client.decoders()
|
|
344
|
+
print(decoders) # ['native_token', 'erc20', 'aave', 'uniswap', 'lido', 'stader', 'threshold']
|
|
345
|
+
```
|
|
346
|
+
|
|
283
347
|
## Configuration
|
|
284
348
|
|
|
285
349
|
### Environment Variables
|
|
@@ -380,8 +444,11 @@ print(f"Request cost: {client.last_response.request_cost}")
|
|
|
380
444
|
| Method | Protocols | Description |
|
|
381
445
|
|--------|-----------|-------------|
|
|
382
446
|
| `.token(symbol)` | ERC20 | Token symbol (USDT, USDC) or contract address (required) |
|
|
383
|
-
| `.sender(
|
|
384
|
-
| `.receiver(
|
|
447
|
+
| `.sender(*addrs)` | ERC20, Native | Filter by sender address (multi-value) |
|
|
448
|
+
| `.receiver(*addrs)` | ERC20, Native | Filter by receiver address (multi-value) |
|
|
449
|
+
| `.involving(*addrs)` | All | Filter by any involved address (multi-value) |
|
|
450
|
+
| `.from_address(*addrs)` | ERC20, Native | Alias for `.sender()` |
|
|
451
|
+
| `.to_address(*addrs)` | ERC20, Native | Alias for `.receiver()` |
|
|
385
452
|
| `.min_amount(amt)` | ERC20, Native | Minimum transfer amount |
|
|
386
453
|
| `.max_amount(amt)` | ERC20, Native | Maximum transfer amount |
|
|
387
454
|
| `.eth_market_type(type)` | AAVE | Market type for ETH: 'Core', 'Prime', 'EtherFi' |
|
|
@@ -389,6 +456,23 @@ print(f"Request cost: {client.last_response.request_cost}")
|
|
|
389
456
|
| `.symbol1(sym)` | Uniswap | Second token symbol (required) |
|
|
390
457
|
| `.fee(tier)` | Uniswap | Fee tier: 100, 500, 3000, 10000 (required) |
|
|
391
458
|
|
|
459
|
+
### Address Label & Category Filters
|
|
460
|
+
|
|
461
|
+
Filter events by entity names or categories using the labels database. Available on all protocols.
|
|
462
|
+
|
|
463
|
+
| Method | Protocols | Description |
|
|
464
|
+
|--------|-----------|-------------|
|
|
465
|
+
| `.involving_label(label)` | All | Filter where any involved address matches a label substring (e.g., "Binance") |
|
|
466
|
+
| `.involving_category(cat)` | All | Filter where any involved address matches a category (e.g., "exchange") |
|
|
467
|
+
| `.sender_label(label)` | ERC20, Native | Filter sender by label substring |
|
|
468
|
+
| `.sender_category(cat)` | ERC20, Native | Filter sender by category |
|
|
469
|
+
| `.receiver_label(label)` | ERC20, Native | Filter receiver by label substring |
|
|
470
|
+
| `.receiver_category(cat)` | ERC20, Native | Filter receiver by category |
|
|
471
|
+
|
|
472
|
+
**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.
|
|
473
|
+
|
|
474
|
+
**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.
|
|
475
|
+
|
|
392
476
|
### Terminal Methods
|
|
393
477
|
|
|
394
478
|
| Method | Description |
|
|
@@ -289,6 +289,8 @@ class DeFiStream(BaseClient):
|
|
|
289
289
|
if response.status_code >= 400:
|
|
290
290
|
self._handle_error_response(response)
|
|
291
291
|
data = response.json()
|
|
292
|
+
if isinstance(data, list):
|
|
293
|
+
return data
|
|
292
294
|
return data.get("decoders", [])
|
|
293
295
|
|
|
294
296
|
|
|
@@ -377,4 +379,6 @@ class AsyncDeFiStream(BaseClient):
|
|
|
377
379
|
if response.status_code >= 400:
|
|
378
380
|
self._handle_error_response(response)
|
|
379
381
|
data = response.json()
|
|
382
|
+
if isinstance(data, list):
|
|
383
|
+
return data
|
|
380
384
|
return data.get("decoders", [])
|
|
@@ -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))
|
|
158
|
+
|
|
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))
|
|
162
|
+
|
|
163
|
+
def from_address(self, *addresses: str) -> "QueryBuilder":
|
|
164
|
+
"""Filter by sender address (alias for sender). Accepts multiple addresses."""
|
|
165
|
+
return self.sender(*addresses)
|
|
166
|
+
|
|
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))
|
|
84
182
|
|
|
85
|
-
def
|
|
86
|
-
"""Filter by
|
|
87
|
-
return self._copy_with(
|
|
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))
|
|
88
186
|
|
|
89
|
-
def
|
|
90
|
-
"""Filter by
|
|
91
|
-
return self.
|
|
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))
|
|
92
190
|
|
|
93
|
-
def
|
|
94
|
-
"""Filter by
|
|
95
|
-
return self.
|
|
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
|
|
@@ -294,21 +398,49 @@ class AsyncQueryBuilder:
|
|
|
294
398
|
return self._copy_with(since=start, until=end)
|
|
295
399
|
|
|
296
400
|
# ERC20 and Native Token filters
|
|
297
|
-
def sender(self,
|
|
298
|
-
"""Filter by sender address (ERC20, Native Token)."""
|
|
299
|
-
return self._copy_with(sender=
|
|
401
|
+
def sender(self, *addresses: str) -> "AsyncQueryBuilder":
|
|
402
|
+
"""Filter by sender address (ERC20, Native Token). Accepts multiple addresses."""
|
|
403
|
+
return self._copy_with(sender=_normalize_multi(*addresses))
|
|
404
|
+
|
|
405
|
+
def receiver(self, *addresses: str) -> "AsyncQueryBuilder":
|
|
406
|
+
"""Filter by receiver address (ERC20, Native Token). Accepts multiple addresses."""
|
|
407
|
+
return self._copy_with(receiver=_normalize_multi(*addresses))
|
|
408
|
+
|
|
409
|
+
def from_address(self, *addresses: str) -> "AsyncQueryBuilder":
|
|
410
|
+
"""Filter by sender address (alias for sender). Accepts multiple addresses."""
|
|
411
|
+
return self.sender(*addresses)
|
|
412
|
+
|
|
413
|
+
def to_address(self, *addresses: str) -> "AsyncQueryBuilder":
|
|
414
|
+
"""Filter by receiver address (alias for receiver). Accepts multiple addresses."""
|
|
415
|
+
return self.receiver(*addresses)
|
|
416
|
+
|
|
417
|
+
def involving(self, *addresses: str) -> "AsyncQueryBuilder":
|
|
418
|
+
"""Filter by any involved address (all protocols). Accepts multiple addresses."""
|
|
419
|
+
return self._copy_with(involving=_normalize_multi(*addresses))
|
|
420
|
+
|
|
421
|
+
def involving_label(self, *labels: str) -> "AsyncQueryBuilder":
|
|
422
|
+
"""Filter by label on any involved address (all protocols)."""
|
|
423
|
+
return self._copy_with(involving_label=_normalize_multi(*labels))
|
|
424
|
+
|
|
425
|
+
def involving_category(self, *categories: str) -> "AsyncQueryBuilder":
|
|
426
|
+
"""Filter by category on any involved address (all protocols)."""
|
|
427
|
+
return self._copy_with(involving_category=_normalize_multi(*categories))
|
|
428
|
+
|
|
429
|
+
def sender_label(self, *labels: str) -> "AsyncQueryBuilder":
|
|
430
|
+
"""Filter sender by label (ERC20, Native Token)."""
|
|
431
|
+
return self._copy_with(sender_label=_normalize_multi(*labels))
|
|
300
432
|
|
|
301
|
-
def
|
|
302
|
-
"""Filter by
|
|
303
|
-
return self._copy_with(
|
|
433
|
+
def sender_category(self, *categories: str) -> "AsyncQueryBuilder":
|
|
434
|
+
"""Filter sender by category (ERC20, Native Token)."""
|
|
435
|
+
return self._copy_with(sender_category=_normalize_multi(*categories))
|
|
304
436
|
|
|
305
|
-
def
|
|
306
|
-
"""Filter by
|
|
307
|
-
return self.
|
|
437
|
+
def receiver_label(self, *labels: str) -> "AsyncQueryBuilder":
|
|
438
|
+
"""Filter receiver by label (ERC20, Native Token)."""
|
|
439
|
+
return self._copy_with(receiver_label=_normalize_multi(*labels))
|
|
308
440
|
|
|
309
|
-
def
|
|
310
|
-
"""Filter by
|
|
311
|
-
return self.
|
|
441
|
+
def receiver_category(self, *categories: str) -> "AsyncQueryBuilder":
|
|
442
|
+
"""Filter receiver by category (ERC20, Native Token)."""
|
|
443
|
+
return self._copy_with(receiver_category=_normalize_multi(*categories))
|
|
312
444
|
|
|
313
445
|
def min_amount(self, amount: float) -> "AsyncQueryBuilder":
|
|
314
446
|
"""Filter by minimum amount (ERC20, Native Token)."""
|
|
@@ -350,6 +482,8 @@ class AsyncQueryBuilder:
|
|
|
350
482
|
def _build_params(self) -> dict[str, Any]:
|
|
351
483
|
"""Build the final query parameters."""
|
|
352
484
|
params = self._params.copy()
|
|
485
|
+
_validate_sql_safety(params)
|
|
486
|
+
_validate_mutual_exclusivity(params)
|
|
353
487
|
if self._verbose:
|
|
354
488
|
params["verbose"] = "true"
|
|
355
489
|
return params
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared fixtures for DeFiStream python-client integration tests.
|
|
3
|
+
|
|
4
|
+
Mirrors the API test suite conftest at api/tests/api-gateway/decoders/conftest.py,
|
|
5
|
+
adapted for the python-client's builder-pattern interface.
|
|
6
|
+
|
|
7
|
+
Run with:
|
|
8
|
+
python -m pytest tests/test_integration.py -v
|
|
9
|
+
python -m pytest tests/test_integration.py -v --local # local dev gateway
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from defistream import AsyncDeFiStream, DeFiStream
|
|
19
|
+
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
# --local flag support
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
_local_mode = "--local" in sys.argv
|
|
25
|
+
|
|
26
|
+
_PRODUCTION_URL = "https://api.defistream.dev/v1"
|
|
27
|
+
_LOCAL_URL = "http://localhost:8081/v1"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def pytest_addoption(parser):
|
|
31
|
+
parser.addoption(
|
|
32
|
+
"--local",
|
|
33
|
+
action="store_true",
|
|
34
|
+
default=False,
|
|
35
|
+
help="Test against local dev gateway (http://localhost:8081/v1) instead of production",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# .env loading
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def _load_env_file(path: Path) -> dict[str, str]:
|
|
44
|
+
if not path.exists():
|
|
45
|
+
return {}
|
|
46
|
+
env: dict[str, str] = {}
|
|
47
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
48
|
+
line = line.strip()
|
|
49
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
50
|
+
continue
|
|
51
|
+
key, value = line.split("=", 1)
|
|
52
|
+
key = key.strip()
|
|
53
|
+
value = value.strip()
|
|
54
|
+
if (value.startswith('"') and value.endswith('"')) or (
|
|
55
|
+
value.startswith("'") and value.endswith("'")
|
|
56
|
+
):
|
|
57
|
+
value = value[1:-1]
|
|
58
|
+
env[key] = value
|
|
59
|
+
return env
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_env() -> dict[str, str]:
|
|
63
|
+
env = dict(os.environ)
|
|
64
|
+
project_root = Path(__file__).resolve().parents[1] # python-client/
|
|
65
|
+
repo_root = project_root.parent # DeFiStream/
|
|
66
|
+
for candidate in [project_root / ".env", repo_root / ".env"]:
|
|
67
|
+
for key, value in _load_env_file(candidate).items():
|
|
68
|
+
if key not in env:
|
|
69
|
+
env[key] = value
|
|
70
|
+
return env
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_env = _get_env()
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Resolved configuration
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
API_BASE_URL = _LOCAL_URL if _local_mode else _env.get("API_BASE_URL", _PRODUCTION_URL).rstrip("/")
|
|
80
|
+
TEST_API_KEY = _env.get("TEST_API_KEY", "")
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Auto-skip integration tests when API key is missing
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
_skip_no_key = pytest.mark.skipif(
|
|
87
|
+
not TEST_API_KEY,
|
|
88
|
+
reason="TEST_API_KEY not set in environment or .env",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def pytest_collection_modifyitems(config, items):
|
|
93
|
+
"""Skip integration tests automatically when TEST_API_KEY is absent."""
|
|
94
|
+
for item in items:
|
|
95
|
+
if "test_integration" in str(item.fspath):
|
|
96
|
+
item.add_marker(_skip_no_key)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Fixtures
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.fixture(scope="module")
|
|
105
|
+
def client():
|
|
106
|
+
"""Sync DeFiStream client shared across a test module."""
|
|
107
|
+
c = DeFiStream(api_key=TEST_API_KEY, base_url=API_BASE_URL)
|
|
108
|
+
yield c
|
|
109
|
+
c.close()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.fixture
|
|
113
|
+
async def async_client():
|
|
114
|
+
"""Async DeFiStream client — function-scoped for a clean event loop."""
|
|
115
|
+
c = AsyncDeFiStream(api_key=TEST_API_KEY, base_url=API_BASE_URL)
|
|
116
|
+
yield c
|
|
117
|
+
await c.close()
|