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/__init__.py ADDED
@@ -0,0 +1,81 @@
1
+ """
2
+ DeFiStream Python Client
3
+
4
+ Official Python client for the DeFiStream API - access historical
5
+ DeFi events from 45+ EVM networks.
6
+
7
+ Example:
8
+ >>> from defistream import DeFiStream
9
+ >>> client = DeFiStream(api_key="dsk_...")
10
+ >>>
11
+ >>> # Builder pattern
12
+ >>> query = client.erc20.transfers("USDT").network("ETH").start_block(24000000).end_block(24100000)
13
+ >>> transfers = query.as_dict()
14
+ >>>
15
+ >>> # Save to file
16
+ >>> query.to_csv_file("transfers.csv")
17
+ """
18
+
19
+ from .client import AsyncDeFiStream, DeFiStream
20
+ from .exceptions import (
21
+ AuthenticationError,
22
+ DeFiStreamError,
23
+ NotFoundError,
24
+ QuotaExceededError,
25
+ RateLimitError,
26
+ ServerError,
27
+ ValidationError,
28
+ )
29
+ from .models import (
30
+ AAVEBorrowEvent,
31
+ AAVEDepositEvent,
32
+ AAVELiquidationEvent,
33
+ AAVERepayEvent,
34
+ AAVEWithdrawEvent,
35
+ ERC20ApprovalEvent,
36
+ ERC20TransferEvent,
37
+ EventBase,
38
+ LidoDepositEvent,
39
+ LidoWithdrawEvent,
40
+ NativeTransferEvent,
41
+ ResponseMetadata,
42
+ UniswapBurnEvent,
43
+ UniswapMintEvent,
44
+ UniswapSwapEvent,
45
+ )
46
+ from .query import AsyncQueryBuilder, QueryBuilder
47
+
48
+ __version__ = "0.1.0"
49
+
50
+ __all__ = [
51
+ # Clients
52
+ "DeFiStream",
53
+ "AsyncDeFiStream",
54
+ # Query builders
55
+ "QueryBuilder",
56
+ "AsyncQueryBuilder",
57
+ # Exceptions
58
+ "DeFiStreamError",
59
+ "AuthenticationError",
60
+ "QuotaExceededError",
61
+ "RateLimitError",
62
+ "ValidationError",
63
+ "NotFoundError",
64
+ "ServerError",
65
+ # Models
66
+ "EventBase",
67
+ "ResponseMetadata",
68
+ "ERC20TransferEvent",
69
+ "ERC20ApprovalEvent",
70
+ "NativeTransferEvent",
71
+ "AAVEDepositEvent",
72
+ "AAVEWithdrawEvent",
73
+ "AAVEBorrowEvent",
74
+ "AAVERepayEvent",
75
+ "AAVELiquidationEvent",
76
+ "UniswapSwapEvent",
77
+ "UniswapMintEvent",
78
+ "UniswapBurnEvent",
79
+ "LidoDepositEvent",
80
+ "LidoWithdrawEvent",
81
+ ]
defistream/client.py ADDED
@@ -0,0 +1,380 @@
1
+ """DeFiStream API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any, Literal
7
+
8
+ import httpx
9
+
10
+ from .exceptions import (
11
+ AuthenticationError,
12
+ DeFiStreamError,
13
+ NotFoundError,
14
+ QuotaExceededError,
15
+ RateLimitError,
16
+ ServerError,
17
+ ValidationError,
18
+ )
19
+ from .models import ResponseMetadata
20
+ from .protocols import (
21
+ AAVEProtocol,
22
+ AsyncAAVEProtocol,
23
+ AsyncERC20Protocol,
24
+ AsyncLidoProtocol,
25
+ AsyncNativeTokenProtocol,
26
+ AsyncStaderProtocol,
27
+ AsyncThresholdProtocol,
28
+ AsyncUniswapProtocol,
29
+ ERC20Protocol,
30
+ LidoProtocol,
31
+ NativeTokenProtocol,
32
+ StaderProtocol,
33
+ ThresholdProtocol,
34
+ UniswapProtocol,
35
+ )
36
+
37
+ DEFAULT_BASE_URL = "https://api.defistream.dev/v1"
38
+ DEFAULT_TIMEOUT = 60.0
39
+
40
+
41
+ class BaseClient:
42
+ """Base client with shared functionality."""
43
+
44
+ def __init__(
45
+ self,
46
+ api_key: str | None = None,
47
+ base_url: str | None = None,
48
+ timeout: float = DEFAULT_TIMEOUT,
49
+ max_retries: int = 3,
50
+ ):
51
+ # Read API key from argument or environment
52
+ self.api_key = api_key or os.environ.get("DEFISTREAM_API_KEY")
53
+ if not self.api_key:
54
+ raise ValueError(
55
+ "API key required. Pass api_key or set DEFISTREAM_API_KEY environment variable."
56
+ )
57
+
58
+ self.base_url = (base_url or os.environ.get("DEFISTREAM_BASE_URL", DEFAULT_BASE_URL)).rstrip("/")
59
+ self.timeout = timeout
60
+ self.max_retries = max_retries
61
+ self.last_response: ResponseMetadata = ResponseMetadata()
62
+
63
+ def _get_headers(self) -> dict[str, str]:
64
+ """Get request headers."""
65
+ return {
66
+ "X-API-Key": self.api_key, # type: ignore
67
+ "Accept": "application/json",
68
+ }
69
+
70
+ def _parse_response_metadata(self, headers: httpx.Headers) -> ResponseMetadata:
71
+ """Parse rate limit and quota info from response headers."""
72
+ return ResponseMetadata(
73
+ rate_limit=int(headers.get("X-RateLimit-Limit", 0)) or None,
74
+ quota_remaining=int(headers.get("X-RateLimit-Remaining", 0)) or None,
75
+ request_cost=int(headers.get("X-Request-Cost", 0)) or None,
76
+ )
77
+
78
+ def _handle_error_response(self, response: httpx.Response) -> None:
79
+ """Handle error responses."""
80
+ status_code = response.status_code
81
+
82
+ try:
83
+ data = response.json()
84
+ message = data.get("error", data.get("message", response.text))
85
+ error_code = data.get("code", "")
86
+ except Exception:
87
+ message = response.text
88
+ error_code = ""
89
+
90
+ if status_code == 401:
91
+ raise AuthenticationError(message, status_code, response)
92
+
93
+ if status_code == 403:
94
+ if error_code == "quota_exceeded":
95
+ # Try to extract remaining quota from response
96
+ remaining = 0
97
+ try:
98
+ remaining = int(data.get("remaining", 0))
99
+ except (ValueError, TypeError):
100
+ pass
101
+ raise QuotaExceededError(message, remaining=remaining, status_code=status_code, response=response)
102
+ raise AuthenticationError(message, status_code, response)
103
+
104
+ if status_code == 404:
105
+ raise NotFoundError(message, status_code, response)
106
+
107
+ if status_code == 429:
108
+ retry_after = response.headers.get("Retry-After")
109
+ raise RateLimitError(
110
+ message,
111
+ retry_after=float(retry_after) if retry_after else None,
112
+ status_code=status_code,
113
+ response=response,
114
+ )
115
+
116
+ if status_code == 400:
117
+ raise ValidationError(message, status_code, response)
118
+
119
+ if status_code >= 500:
120
+ raise ServerError(message, status_code, response)
121
+
122
+ raise DeFiStreamError(message, status_code, response)
123
+
124
+ def _process_response(
125
+ self,
126
+ response: httpx.Response,
127
+ as_dataframe: Literal["pandas", "polars"] | None = None,
128
+ output_file: str | None = None,
129
+ ) -> list[dict[str, Any]] | Any:
130
+ """Process response and optionally convert to DataFrame or save to file."""
131
+ self.last_response = self._parse_response_metadata(response.headers)
132
+
133
+ if response.status_code >= 400:
134
+ self._handle_error_response(response)
135
+
136
+ content_type = response.headers.get("Content-Type", "")
137
+
138
+ # Handle file output
139
+ if output_file:
140
+ if output_file.endswith(".parquet"):
141
+ with open(output_file, "wb") as f:
142
+ f.write(response.content)
143
+ else:
144
+ with open(output_file, "w") as f:
145
+ f.write(response.text)
146
+ return None
147
+
148
+ # Handle CSV response
149
+ if "text/csv" in content_type:
150
+ csv_text = response.text
151
+ if as_dataframe == "pandas":
152
+ import io
153
+ import pandas as pd
154
+ return pd.read_csv(io.StringIO(csv_text))
155
+ elif as_dataframe == "polars":
156
+ import io
157
+ import polars as pl
158
+ return pl.read_csv(io.StringIO(csv_text))
159
+ return csv_text
160
+
161
+ # Handle Parquet response
162
+ if "application/octet-stream" in content_type or "application/parquet" in content_type:
163
+ if as_dataframe == "pandas":
164
+ import io
165
+ import pandas as pd
166
+ df = pd.read_parquet(io.BytesIO(response.content))
167
+ if "time" in df.columns:
168
+ df["time"] = pd.to_datetime(df["time"], unit="s", utc=True)
169
+ return df
170
+ elif as_dataframe == "polars":
171
+ import io
172
+ import polars as pl
173
+ df = pl.read_parquet(io.BytesIO(response.content))
174
+ if "time" in df.columns:
175
+ df = df.with_columns(
176
+ pl.from_epoch("time", time_unit="s").dt.replace_time_zone("UTC")
177
+ )
178
+ return df
179
+ return response.content
180
+
181
+ # Handle JSON response
182
+ data = response.json()
183
+
184
+ if data.get("status") == "error":
185
+ raise DeFiStreamError(data.get("error", "Unknown error"))
186
+
187
+ events = data.get("events", [])
188
+
189
+ if as_dataframe == "pandas":
190
+ import pandas as pd
191
+ df = pd.DataFrame(events)
192
+ if "time" in df.columns:
193
+ df["time"] = pd.to_datetime(df["time"], unit="s", utc=True)
194
+ return df
195
+ elif as_dataframe == "polars":
196
+ import polars as pl
197
+ df = pl.DataFrame(events)
198
+ if "time" in df.columns:
199
+ df = df.with_columns(
200
+ pl.from_epoch("time", time_unit="s").dt.replace_time_zone("UTC")
201
+ )
202
+ return df
203
+
204
+ return events
205
+
206
+
207
+ class DeFiStream(BaseClient):
208
+ """
209
+ Synchronous DeFiStream API client with builder pattern.
210
+
211
+ Example:
212
+ >>> from defistream import DeFiStream
213
+ >>> client = DeFiStream(api_key="dsk_...")
214
+ >>>
215
+ >>> # Builder pattern
216
+ >>> query = client.erc20.transfers("USDT").network("ETH").start_block(24000000).end_block(24100000)
217
+ >>> df = query.as_pandas()
218
+ >>>
219
+ >>> # Or chain everything
220
+ >>> transfers = client.erc20.transfers("USDT").network("ETH").start_block(24000000).end_block(24100000).as_dict()
221
+ """
222
+
223
+ def __init__(
224
+ self,
225
+ api_key: str | None = None,
226
+ base_url: str | None = None,
227
+ timeout: float = DEFAULT_TIMEOUT,
228
+ max_retries: int = 3,
229
+ ):
230
+ super().__init__(api_key, base_url, timeout, max_retries)
231
+ self._http_client: httpx.Client | None = None
232
+
233
+ # Protocol clients
234
+ self.erc20 = ERC20Protocol(self)
235
+ self.native_token = NativeTokenProtocol(self)
236
+ self.aave = AAVEProtocol(self)
237
+ self.uniswap = UniswapProtocol(self)
238
+ self.lido = LidoProtocol(self)
239
+ self.stader = StaderProtocol(self)
240
+ self.threshold = ThresholdProtocol(self)
241
+
242
+ @property
243
+ def _client(self) -> httpx.Client:
244
+ """Lazy-initialize HTTP client."""
245
+ if self._http_client is None:
246
+ self._http_client = httpx.Client(
247
+ base_url=self.base_url,
248
+ headers=self._get_headers(),
249
+ timeout=self.timeout,
250
+ )
251
+ return self._http_client
252
+
253
+ def close(self) -> None:
254
+ """Close the HTTP client."""
255
+ if self._http_client is not None:
256
+ self._http_client.close()
257
+ self._http_client = None
258
+
259
+ def __enter__(self) -> "DeFiStream":
260
+ return self
261
+
262
+ def __exit__(self, *args: Any) -> None:
263
+ self.close()
264
+
265
+ def _request(
266
+ self,
267
+ method: str,
268
+ path: str,
269
+ params: dict[str, Any] | None = None,
270
+ as_dataframe: Literal["pandas", "polars"] | None = None,
271
+ output_file: str | None = None,
272
+ ) -> list[dict[str, Any]] | Any:
273
+ """Make HTTP request."""
274
+ if params is None:
275
+ params = {}
276
+
277
+ if output_file:
278
+ if output_file.endswith(".parquet"):
279
+ params["format"] = "parquet"
280
+ elif output_file.endswith(".csv"):
281
+ params["format"] = "csv"
282
+
283
+ response = self._client.request(method, path, params=params)
284
+ return self._process_response(response, as_dataframe, output_file)
285
+
286
+ def decoders(self) -> list[str]:
287
+ """Get list of available decoders."""
288
+ response = self._client.get("/decoders")
289
+ if response.status_code >= 400:
290
+ self._handle_error_response(response)
291
+ data = response.json()
292
+ return data.get("decoders", [])
293
+
294
+
295
+ class AsyncDeFiStream(BaseClient):
296
+ """
297
+ Asynchronous DeFiStream API client with builder pattern.
298
+
299
+ Example:
300
+ >>> import asyncio
301
+ >>> from defistream import AsyncDeFiStream
302
+ >>>
303
+ >>> async def main():
304
+ ... async with AsyncDeFiStream(api_key="dsk_...") as client:
305
+ ... query = client.erc20.transfers("USDT").network("ETH").start_block(24000000).end_block(24100000)
306
+ ... df = await query.as_pandas()
307
+ ...
308
+ >>> asyncio.run(main())
309
+ """
310
+
311
+ def __init__(
312
+ self,
313
+ api_key: str | None = None,
314
+ base_url: str | None = None,
315
+ timeout: float = DEFAULT_TIMEOUT,
316
+ max_retries: int = 3,
317
+ ):
318
+ super().__init__(api_key, base_url, timeout, max_retries)
319
+ self._http_client: httpx.AsyncClient | None = None
320
+
321
+ # Protocol clients (async versions)
322
+ self.erc20 = AsyncERC20Protocol(self)
323
+ self.native_token = AsyncNativeTokenProtocol(self)
324
+ self.aave = AsyncAAVEProtocol(self)
325
+ self.uniswap = AsyncUniswapProtocol(self)
326
+ self.lido = AsyncLidoProtocol(self)
327
+ self.stader = AsyncStaderProtocol(self)
328
+ self.threshold = AsyncThresholdProtocol(self)
329
+
330
+ @property
331
+ def _client(self) -> httpx.AsyncClient:
332
+ """Lazy-initialize async HTTP client."""
333
+ if self._http_client is None:
334
+ self._http_client = httpx.AsyncClient(
335
+ base_url=self.base_url,
336
+ headers=self._get_headers(),
337
+ timeout=self.timeout,
338
+ )
339
+ return self._http_client
340
+
341
+ async def close(self) -> None:
342
+ """Close the async HTTP client."""
343
+ if self._http_client is not None:
344
+ await self._http_client.aclose()
345
+ self._http_client = None
346
+
347
+ async def __aenter__(self) -> "AsyncDeFiStream":
348
+ return self
349
+
350
+ async def __aexit__(self, *args: Any) -> None:
351
+ await self.close()
352
+
353
+ async def _request(
354
+ self,
355
+ method: str,
356
+ path: str,
357
+ params: dict[str, Any] | None = None,
358
+ as_dataframe: Literal["pandas", "polars"] | None = None,
359
+ output_file: str | None = None,
360
+ ) -> list[dict[str, Any]] | Any:
361
+ """Make async HTTP request."""
362
+ if params is None:
363
+ params = {}
364
+
365
+ if output_file:
366
+ if output_file.endswith(".parquet"):
367
+ params["format"] = "parquet"
368
+ elif output_file.endswith(".csv"):
369
+ params["format"] = "csv"
370
+
371
+ response = await self._client.request(method, path, params=params)
372
+ return self._process_response(response, as_dataframe, output_file)
373
+
374
+ async def decoders(self) -> list[str]:
375
+ """Get list of available decoders."""
376
+ response = await self._client.get("/decoders")
377
+ if response.status_code >= 400:
378
+ self._handle_error_response(response)
379
+ data = response.json()
380
+ return data.get("decoders", [])
@@ -0,0 +1,65 @@
1
+ """DeFiStream API exceptions."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ class DeFiStreamError(Exception):
7
+ """Base exception for DeFiStream API errors."""
8
+
9
+ def __init__(self, message: str, status_code: int | None = None, response: Any = None):
10
+ super().__init__(message)
11
+ self.message = message
12
+ self.status_code = status_code
13
+ self.response = response
14
+
15
+
16
+ class AuthenticationError(DeFiStreamError):
17
+ """Raised when API key is invalid or missing."""
18
+
19
+ pass
20
+
21
+
22
+ class QuotaExceededError(DeFiStreamError):
23
+ """Raised when account quota is exceeded."""
24
+
25
+ def __init__(
26
+ self,
27
+ message: str,
28
+ remaining: int = 0,
29
+ status_code: int | None = None,
30
+ response: Any = None,
31
+ ):
32
+ super().__init__(message, status_code, response)
33
+ self.remaining = remaining
34
+
35
+
36
+ class RateLimitError(DeFiStreamError):
37
+ """Raised when rate limit is exceeded."""
38
+
39
+ def __init__(
40
+ self,
41
+ message: str,
42
+ retry_after: float | None = None,
43
+ status_code: int | None = None,
44
+ response: Any = None,
45
+ ):
46
+ super().__init__(message, status_code, response)
47
+ self.retry_after = retry_after
48
+
49
+
50
+ class ValidationError(DeFiStreamError):
51
+ """Raised when request parameters are invalid."""
52
+
53
+ pass
54
+
55
+
56
+ class NotFoundError(DeFiStreamError):
57
+ """Raised when a resource is not found."""
58
+
59
+ pass
60
+
61
+
62
+ class ServerError(DeFiStreamError):
63
+ """Raised when the server returns a 5xx error."""
64
+
65
+ pass
defistream/models.py ADDED
@@ -0,0 +1,174 @@
1
+ """Pydantic models for DeFiStream API responses."""
2
+
3
+ from typing import Any, Literal
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class ResponseMetadata(BaseModel):
8
+ """Metadata from API response headers."""
9
+
10
+ rate_limit: int | None = None
11
+ quota_remaining: int | None = None
12
+ request_cost: int | None = None
13
+
14
+
15
+ class EventBase(BaseModel):
16
+ """Base model for all events."""
17
+
18
+ block_number: int
19
+ time: str | None = None
20
+ # Verbose fields (only present when verbose=true)
21
+ name: str | None = None
22
+ network: str | None = None
23
+ tx_id: str | None = None
24
+ tx_hash: str | None = None
25
+ log_index: int | None = None
26
+
27
+
28
+ class ERC20TransferEvent(EventBase):
29
+ """ERC20 Transfer event."""
30
+
31
+ sender: str = Field(alias="from_address", default="")
32
+ receiver: str = Field(alias="to_address", default="")
33
+ amount: float = 0.0
34
+ token_address: str | None = None
35
+ token_symbol: str | None = None
36
+
37
+ model_config = {"populate_by_name": True}
38
+
39
+
40
+ class ERC20ApprovalEvent(EventBase):
41
+ """ERC20 Approval event."""
42
+
43
+ owner: str = ""
44
+ spender: str = ""
45
+ amount: float = 0.0
46
+ token_address: str | None = None
47
+ token_symbol: str | None = None
48
+
49
+
50
+ class NativeTransferEvent(EventBase):
51
+ """Native token (ETH/MATIC/etc) transfer event."""
52
+
53
+ sender: str = ""
54
+ receiver: str = ""
55
+ amount: float = 0.0
56
+
57
+
58
+ class AAVEDepositEvent(EventBase):
59
+ """AAVE V3 Supply/Deposit event."""
60
+
61
+ user: str = ""
62
+ reserve: str = ""
63
+ amount: float = 0.0
64
+ on_behalf_of: str | None = None
65
+
66
+
67
+ class AAVEWithdrawEvent(EventBase):
68
+ """AAVE V3 Withdraw event."""
69
+
70
+ user: str = ""
71
+ reserve: str = ""
72
+ amount: float = 0.0
73
+ to: str | None = None
74
+
75
+
76
+ class AAVEBorrowEvent(EventBase):
77
+ """AAVE V3 Borrow event."""
78
+
79
+ user: str = ""
80
+ reserve: str = ""
81
+ amount: float = 0.0
82
+ interest_rate_mode: int | None = None
83
+ borrow_rate: float | None = None
84
+ on_behalf_of: str | None = None
85
+
86
+
87
+ class AAVERepayEvent(EventBase):
88
+ """AAVE V3 Repay event."""
89
+
90
+ user: str = ""
91
+ reserve: str = ""
92
+ amount: float = 0.0
93
+ repayer: str | None = None
94
+ use_a_tokens: bool | None = None
95
+
96
+
97
+ class AAVELiquidationEvent(EventBase):
98
+ """AAVE V3 Liquidation event."""
99
+
100
+ liquidator: str = ""
101
+ user: str = ""
102
+ collateral_asset: str = ""
103
+ debt_asset: str = ""
104
+ debt_to_cover: float = 0.0
105
+ liquidated_collateral_amount: float = 0.0
106
+ receive_a_token: bool | None = None
107
+
108
+
109
+ class UniswapSwapEvent(EventBase):
110
+ """Uniswap V3 Swap event."""
111
+
112
+ pool: str = ""
113
+ sender: str = ""
114
+ recipient: str = ""
115
+ amount0: float = 0.0
116
+ amount1: float = 0.0
117
+ sqrt_price_x96: int | None = None
118
+ liquidity: int | None = None
119
+ tick: int | None = None
120
+
121
+
122
+ class UniswapMintEvent(EventBase):
123
+ """Uniswap V3 Mint (add liquidity) event."""
124
+
125
+ pool: str = ""
126
+ owner: str = ""
127
+ tick_lower: int = 0
128
+ tick_upper: int = 0
129
+ amount: int = 0
130
+ amount0: float = 0.0
131
+ amount1: float = 0.0
132
+
133
+
134
+ class UniswapBurnEvent(EventBase):
135
+ """Uniswap V3 Burn (remove liquidity) event."""
136
+
137
+ pool: str = ""
138
+ owner: str = ""
139
+ tick_lower: int = 0
140
+ tick_upper: int = 0
141
+ amount: int = 0
142
+ amount0: float = 0.0
143
+ amount1: float = 0.0
144
+
145
+
146
+ class LidoDepositEvent(EventBase):
147
+ """Lido stETH deposit event."""
148
+
149
+ sender: str = ""
150
+ amount: float = 0.0
151
+ shares: float = 0.0
152
+
153
+
154
+ class LidoWithdrawEvent(EventBase):
155
+ """Lido stETH withdrawal event."""
156
+
157
+ owner: str = ""
158
+ request_id: int = 0
159
+ amount: float = 0.0
160
+
161
+
162
+ class EventsResponse(BaseModel):
163
+ """Standard events API response."""
164
+
165
+ status: Literal["success", "error"]
166
+ events: list[dict[str, Any]] = []
167
+ count: int = 0
168
+ error: str | None = None
169
+
170
+
171
+ class DecodersResponse(BaseModel):
172
+ """Response from /decoders endpoint."""
173
+
174
+ decoders: list[str] = []