dexscreen 0.0.2__py3-none-any.whl → 0.0.5__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.
- dexscreen/__init__.py +87 -0
- dexscreen/api/client.py +275 -42
- dexscreen/core/exceptions.py +1067 -0
- dexscreen/core/http.py +859 -117
- dexscreen/core/validators.py +542 -0
- dexscreen/stream/polling.py +288 -78
- dexscreen/utils/__init__.py +54 -1
- dexscreen/utils/filters.py +182 -12
- dexscreen/utils/logging_config.py +421 -0
- dexscreen/utils/middleware.py +363 -0
- dexscreen/utils/ratelimit.py +212 -8
- dexscreen/utils/retry.py +357 -0
- {dexscreen-0.0.2.dist-info → dexscreen-0.0.5.dist-info}/METADATA +52 -1
- dexscreen-0.0.5.dist-info/RECORD +22 -0
- dexscreen-0.0.2.dist-info/RECORD +0 -17
- {dexscreen-0.0.2.dist-info → dexscreen-0.0.5.dist-info}/WHEEL +0 -0
- {dexscreen-0.0.2.dist-info → dexscreen-0.0.5.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,542 @@
|
|
1
|
+
"""
|
2
|
+
Input validation utilities for Dexscreen API
|
3
|
+
"""
|
4
|
+
|
5
|
+
import re
|
6
|
+
from typing import Any, Callable, Optional, Union
|
7
|
+
from urllib.parse import urlparse
|
8
|
+
|
9
|
+
from .exceptions import (
|
10
|
+
EmptyListError,
|
11
|
+
InvalidAddressError,
|
12
|
+
InvalidCallbackError,
|
13
|
+
InvalidChainIdError,
|
14
|
+
InvalidFilterError,
|
15
|
+
InvalidIntervalError,
|
16
|
+
InvalidParameterError,
|
17
|
+
InvalidRangeError,
|
18
|
+
InvalidTypeError,
|
19
|
+
InvalidUrlError,
|
20
|
+
TooManyItemsError,
|
21
|
+
)
|
22
|
+
|
23
|
+
# Common blockchain networks supported by Dexscreener
|
24
|
+
VALID_CHAIN_IDS = {
|
25
|
+
"ethereum",
|
26
|
+
"bsc",
|
27
|
+
"polygon",
|
28
|
+
"avalanche",
|
29
|
+
"fantom",
|
30
|
+
"cronos",
|
31
|
+
"arbitrum",
|
32
|
+
"optimism",
|
33
|
+
"solana",
|
34
|
+
"base",
|
35
|
+
"linea",
|
36
|
+
"scroll",
|
37
|
+
"blast",
|
38
|
+
"manta",
|
39
|
+
"mantle",
|
40
|
+
"mode",
|
41
|
+
"sei",
|
42
|
+
"pulsechain",
|
43
|
+
"metis",
|
44
|
+
"moonbeam",
|
45
|
+
"moonriver",
|
46
|
+
"celo",
|
47
|
+
"fuse",
|
48
|
+
"harmony",
|
49
|
+
"kava",
|
50
|
+
"evmos",
|
51
|
+
"milkomeda",
|
52
|
+
"aurora",
|
53
|
+
"near",
|
54
|
+
"telos",
|
55
|
+
"wax",
|
56
|
+
"eos",
|
57
|
+
"tron",
|
58
|
+
"aptos",
|
59
|
+
"sui",
|
60
|
+
"starknet",
|
61
|
+
"zksync",
|
62
|
+
"polygonzkevm",
|
63
|
+
"immutablex",
|
64
|
+
"loopring",
|
65
|
+
"dydx",
|
66
|
+
"osmosis",
|
67
|
+
"cosmos",
|
68
|
+
"terra",
|
69
|
+
"thorchain",
|
70
|
+
"bitcoin",
|
71
|
+
"litecoin",
|
72
|
+
"dogecoin",
|
73
|
+
"cardano",
|
74
|
+
"polkadot",
|
75
|
+
"kusama",
|
76
|
+
"algorand",
|
77
|
+
"tezos",
|
78
|
+
"flow",
|
79
|
+
"hedera",
|
80
|
+
"icp",
|
81
|
+
"waves",
|
82
|
+
"stellar",
|
83
|
+
"xrp",
|
84
|
+
"chia",
|
85
|
+
"elrond",
|
86
|
+
"zilliqa",
|
87
|
+
"vechain",
|
88
|
+
"nuls",
|
89
|
+
"nem",
|
90
|
+
"symbol",
|
91
|
+
"iotex",
|
92
|
+
"ontology",
|
93
|
+
"qtum",
|
94
|
+
"conflux",
|
95
|
+
"nervos",
|
96
|
+
"syscoin",
|
97
|
+
"digibyte",
|
98
|
+
"ravencoin",
|
99
|
+
"zcash",
|
100
|
+
"dash",
|
101
|
+
"monero",
|
102
|
+
"decred",
|
103
|
+
"horizen",
|
104
|
+
"beam",
|
105
|
+
"grin",
|
106
|
+
}
|
107
|
+
|
108
|
+
# Address format patterns for different blockchains
|
109
|
+
ADDRESS_PATTERNS = {
|
110
|
+
# Ethereum-style (hex, 40 chars + 0x prefix)
|
111
|
+
"ethereum": re.compile(r"^0x[a-fA-F0-9]{40}$"),
|
112
|
+
"bsc": re.compile(r"^0x[a-fA-F0-9]{40}$"),
|
113
|
+
"polygon": re.compile(r"^0x[a-fA-F0-9]{40}$"),
|
114
|
+
"arbitrum": re.compile(r"^0x[a-fA-F0-9]{40}$"),
|
115
|
+
"optimism": re.compile(r"^0x[a-fA-F0-9]{40}$"),
|
116
|
+
"avalanche": re.compile(r"^0x[a-fA-F0-9]{40}$"),
|
117
|
+
"fantom": re.compile(r"^0x[a-fA-F0-9]{40}$"),
|
118
|
+
"base": re.compile(r"^0x[a-fA-F0-9]{40}$"),
|
119
|
+
# Solana (base58, 32-44 chars)
|
120
|
+
"solana": re.compile(r"^[1-9A-HJ-NP-Za-km-z]{32,44}$"),
|
121
|
+
# Bitcoin (base58, starts with 1, 3, or bc1)
|
122
|
+
"bitcoin": re.compile(r"^(1[1-9A-HJ-NP-Za-km-z]{25,34}|3[1-9A-HJ-NP-Za-km-z]{25,34}|bc1[a-z0-9]{39,59})$"),
|
123
|
+
# Generic fallback (alphanumeric, 20-65 chars)
|
124
|
+
"default": re.compile(r"^[a-zA-Z0-9]{20,65}$"),
|
125
|
+
}
|
126
|
+
|
127
|
+
|
128
|
+
def validate_string(
|
129
|
+
value: Any, parameter_name: str, min_length: int = 1, max_length: int = 1000, allow_empty: bool = False
|
130
|
+
) -> str:
|
131
|
+
"""
|
132
|
+
Validate string parameter.
|
133
|
+
|
134
|
+
Args:
|
135
|
+
value: Value to validate
|
136
|
+
parameter_name: Name of parameter for error messages
|
137
|
+
min_length: Minimum string length
|
138
|
+
max_length: Maximum string length
|
139
|
+
allow_empty: Whether to allow empty strings
|
140
|
+
|
141
|
+
Returns:
|
142
|
+
Validated string
|
143
|
+
|
144
|
+
Raises:
|
145
|
+
InvalidTypeError: If value is not a string
|
146
|
+
InvalidParameterError: If string is empty when not allowed or outside length bounds
|
147
|
+
"""
|
148
|
+
if not isinstance(value, str):
|
149
|
+
raise InvalidTypeError(parameter_name, value, "string")
|
150
|
+
|
151
|
+
if not allow_empty and len(value) == 0:
|
152
|
+
raise InvalidParameterError(parameter_name, value, "non-empty string")
|
153
|
+
|
154
|
+
if not (min_length <= len(value) <= max_length):
|
155
|
+
raise InvalidRangeError(parameter_name, len(value), min_length, max_length)
|
156
|
+
|
157
|
+
return value
|
158
|
+
|
159
|
+
|
160
|
+
def validate_chain_id(chain_id: Any) -> str:
|
161
|
+
"""
|
162
|
+
Validate blockchain chain ID.
|
163
|
+
|
164
|
+
Args:
|
165
|
+
chain_id: Chain ID to validate
|
166
|
+
|
167
|
+
Returns:
|
168
|
+
Validated chain ID (lowercase)
|
169
|
+
|
170
|
+
Raises:
|
171
|
+
InvalidChainIdError: If chain ID is invalid
|
172
|
+
"""
|
173
|
+
if not isinstance(chain_id, str):
|
174
|
+
raise InvalidTypeError("chain_id", chain_id, "string")
|
175
|
+
|
176
|
+
chain_id = chain_id.lower().strip()
|
177
|
+
|
178
|
+
if not chain_id:
|
179
|
+
raise InvalidParameterError("chain_id", chain_id, "non-empty string")
|
180
|
+
|
181
|
+
if chain_id not in VALID_CHAIN_IDS:
|
182
|
+
# Get similar chain IDs for better error message
|
183
|
+
similar = [c for c in VALID_CHAIN_IDS if chain_id in c or c in chain_id][:5]
|
184
|
+
raise InvalidChainIdError(chain_id, similar if similar else list(VALID_CHAIN_IDS)[:10])
|
185
|
+
|
186
|
+
return chain_id
|
187
|
+
|
188
|
+
|
189
|
+
def validate_address(address: Any, chain_id: Optional[str] = None) -> str:
|
190
|
+
"""
|
191
|
+
Validate blockchain address format.
|
192
|
+
|
193
|
+
Args:
|
194
|
+
address: Address to validate
|
195
|
+
chain_id: Optional chain ID for chain-specific validation
|
196
|
+
|
197
|
+
Returns:
|
198
|
+
Validated address
|
199
|
+
|
200
|
+
Raises:
|
201
|
+
InvalidAddressError: If address format is invalid
|
202
|
+
"""
|
203
|
+
if not isinstance(address, str):
|
204
|
+
raise InvalidTypeError("address", address, "string")
|
205
|
+
|
206
|
+
address = address.strip()
|
207
|
+
|
208
|
+
if not address:
|
209
|
+
raise InvalidAddressError(address, "Address cannot be empty")
|
210
|
+
|
211
|
+
# Basic length check
|
212
|
+
if len(address) < 20 or len(address) > 70:
|
213
|
+
raise InvalidAddressError(address, "Address length must be between 20 and 70 characters")
|
214
|
+
|
215
|
+
# Chain-specific validation
|
216
|
+
if chain_id:
|
217
|
+
pattern = ADDRESS_PATTERNS.get(chain_id.lower(), ADDRESS_PATTERNS["default"])
|
218
|
+
if not pattern.match(address):
|
219
|
+
raise InvalidAddressError(address, f"Invalid address format for {chain_id}")
|
220
|
+
|
221
|
+
return address
|
222
|
+
|
223
|
+
|
224
|
+
def validate_addresses_list(
|
225
|
+
addresses: Any,
|
226
|
+
parameter_name: str = "addresses",
|
227
|
+
min_count: int = 1,
|
228
|
+
max_count: int = 30,
|
229
|
+
chain_id: Optional[str] = None,
|
230
|
+
) -> list[str]:
|
231
|
+
"""
|
232
|
+
Validate list of addresses.
|
233
|
+
|
234
|
+
Args:
|
235
|
+
addresses: List of addresses to validate
|
236
|
+
parameter_name: Name of parameter for error messages
|
237
|
+
min_count: Minimum number of addresses
|
238
|
+
max_count: Maximum number of addresses
|
239
|
+
chain_id: Optional chain ID for address validation
|
240
|
+
|
241
|
+
Returns:
|
242
|
+
Validated list of addresses
|
243
|
+
|
244
|
+
Raises:
|
245
|
+
InvalidTypeError: If not a list
|
246
|
+
EmptyListError: If list is empty when not allowed
|
247
|
+
TooManyItemsError: If too many addresses
|
248
|
+
InvalidAddressError: If any address is invalid
|
249
|
+
"""
|
250
|
+
if not isinstance(addresses, (list, tuple)):
|
251
|
+
raise InvalidTypeError(parameter_name, addresses, "list")
|
252
|
+
|
253
|
+
addresses = list(addresses)
|
254
|
+
|
255
|
+
if len(addresses) < min_count:
|
256
|
+
if min_count == 1:
|
257
|
+
raise EmptyListError(parameter_name)
|
258
|
+
else:
|
259
|
+
raise InvalidParameterError(parameter_name, addresses, f"at least {min_count} items")
|
260
|
+
|
261
|
+
if len(addresses) > max_count:
|
262
|
+
raise TooManyItemsError(parameter_name, len(addresses), max_count)
|
263
|
+
|
264
|
+
# Validate each address
|
265
|
+
validated_addresses = []
|
266
|
+
for i, addr in enumerate(addresses):
|
267
|
+
try:
|
268
|
+
validated_addr = validate_address(addr, chain_id)
|
269
|
+
validated_addresses.append(validated_addr)
|
270
|
+
except InvalidAddressError as e:
|
271
|
+
raise InvalidAddressError(addr, f"Address at index {i}: {e.reason}") from e
|
272
|
+
|
273
|
+
# Check for duplicates
|
274
|
+
if len(set(validated_addresses)) != len(validated_addresses):
|
275
|
+
raise InvalidParameterError(parameter_name, addresses, "unique addresses (duplicates found)")
|
276
|
+
|
277
|
+
return validated_addresses
|
278
|
+
|
279
|
+
|
280
|
+
def validate_numeric(
|
281
|
+
value: Any,
|
282
|
+
parameter_name: str,
|
283
|
+
expected_type: type = float,
|
284
|
+
min_value: Optional[Union[int, float]] = None,
|
285
|
+
max_value: Optional[Union[int, float]] = None,
|
286
|
+
allow_none: bool = False,
|
287
|
+
) -> Union[int, float, None]:
|
288
|
+
"""
|
289
|
+
Validate numeric parameter.
|
290
|
+
|
291
|
+
Args:
|
292
|
+
value: Value to validate
|
293
|
+
parameter_name: Name of parameter for error messages
|
294
|
+
expected_type: Expected numeric type (int or float)
|
295
|
+
min_value: Minimum allowed value
|
296
|
+
max_value: Maximum allowed value
|
297
|
+
allow_none: Whether to allow None values
|
298
|
+
|
299
|
+
Returns:
|
300
|
+
Validated numeric value
|
301
|
+
|
302
|
+
Raises:
|
303
|
+
InvalidTypeError: If value is not numeric
|
304
|
+
InvalidRangeError: If value is outside valid range
|
305
|
+
"""
|
306
|
+
if value is None and allow_none:
|
307
|
+
return None
|
308
|
+
|
309
|
+
if not isinstance(value, (int, float)):
|
310
|
+
raise InvalidTypeError(parameter_name, value, expected_type.__name__)
|
311
|
+
|
312
|
+
# Convert to expected type
|
313
|
+
try:
|
314
|
+
if expected_type is int:
|
315
|
+
value = int(value)
|
316
|
+
elif expected_type is float:
|
317
|
+
value = float(value)
|
318
|
+
except (ValueError, OverflowError) as e:
|
319
|
+
raise InvalidTypeError(parameter_name, value, expected_type.__name__) from e
|
320
|
+
|
321
|
+
# Check for special float values
|
322
|
+
if expected_type is float and (value != value or value == float("inf") or value == float("-inf")):
|
323
|
+
raise InvalidParameterError(parameter_name, value, "finite number")
|
324
|
+
|
325
|
+
# Range validation
|
326
|
+
if min_value is not None and value < min_value:
|
327
|
+
raise InvalidRangeError(parameter_name, value, min_value, max_value)
|
328
|
+
|
329
|
+
if max_value is not None and value > max_value:
|
330
|
+
raise InvalidRangeError(parameter_name, value, min_value, max_value)
|
331
|
+
|
332
|
+
return value
|
333
|
+
|
334
|
+
|
335
|
+
def validate_interval(interval: Any, min_interval: float = 0.1, max_interval: float = 3600.0) -> float:
|
336
|
+
"""
|
337
|
+
Validate polling interval.
|
338
|
+
|
339
|
+
Args:
|
340
|
+
interval: Interval to validate
|
341
|
+
min_interval: Minimum allowed interval
|
342
|
+
max_interval: Maximum allowed interval
|
343
|
+
|
344
|
+
Returns:
|
345
|
+
Validated interval
|
346
|
+
|
347
|
+
Raises:
|
348
|
+
InvalidIntervalError: If interval is invalid
|
349
|
+
"""
|
350
|
+
try:
|
351
|
+
interval = validate_numeric(interval, "interval", float, min_interval, max_interval)
|
352
|
+
except (InvalidTypeError, InvalidRangeError) as e:
|
353
|
+
raise InvalidIntervalError(interval, min_interval, max_interval) from e
|
354
|
+
|
355
|
+
return interval
|
356
|
+
|
357
|
+
|
358
|
+
def validate_callback(callback: Any) -> Callable:
|
359
|
+
"""
|
360
|
+
Validate callback function.
|
361
|
+
|
362
|
+
Args:
|
363
|
+
callback: Callback to validate
|
364
|
+
|
365
|
+
Returns:
|
366
|
+
Validated callback
|
367
|
+
|
368
|
+
Raises:
|
369
|
+
InvalidCallbackError: If callback is invalid
|
370
|
+
"""
|
371
|
+
if not callable(callback):
|
372
|
+
raise InvalidCallbackError(callback, "Must be callable")
|
373
|
+
|
374
|
+
return callback
|
375
|
+
|
376
|
+
|
377
|
+
def validate_url(url: Any, require_https: bool = False) -> str:
|
378
|
+
"""
|
379
|
+
Validate URL format.
|
380
|
+
|
381
|
+
Args:
|
382
|
+
url: URL to validate
|
383
|
+
require_https: Whether to require HTTPS scheme
|
384
|
+
|
385
|
+
Returns:
|
386
|
+
Validated URL
|
387
|
+
|
388
|
+
Raises:
|
389
|
+
InvalidUrlError: If URL is invalid
|
390
|
+
"""
|
391
|
+
if not isinstance(url, str):
|
392
|
+
raise InvalidTypeError("url", url, "string")
|
393
|
+
|
394
|
+
url = url.strip()
|
395
|
+
|
396
|
+
if not url:
|
397
|
+
raise InvalidUrlError(url, "URL cannot be empty")
|
398
|
+
|
399
|
+
try:
|
400
|
+
parsed = urlparse(url)
|
401
|
+
|
402
|
+
if not parsed.scheme:
|
403
|
+
raise InvalidUrlError(url, "URL must include scheme (http/https)")
|
404
|
+
|
405
|
+
if require_https and parsed.scheme != "https":
|
406
|
+
raise InvalidUrlError(url, "URL must use HTTPS")
|
407
|
+
|
408
|
+
if not parsed.netloc:
|
409
|
+
raise InvalidUrlError(url, "URL must include domain")
|
410
|
+
|
411
|
+
except Exception as e:
|
412
|
+
raise InvalidUrlError(url, "Invalid URL format") from e
|
413
|
+
|
414
|
+
return url
|
415
|
+
|
416
|
+
|
417
|
+
def validate_filter_config(filter_config: Any) -> Any:
|
418
|
+
"""
|
419
|
+
Validate filter configuration.
|
420
|
+
|
421
|
+
Args:
|
422
|
+
filter_config: Filter config to validate
|
423
|
+
|
424
|
+
Returns:
|
425
|
+
Validated filter config
|
426
|
+
|
427
|
+
Raises:
|
428
|
+
InvalidFilterError: If filter config is invalid
|
429
|
+
"""
|
430
|
+
from ..utils.filters import FilterConfig
|
431
|
+
|
432
|
+
if filter_config is None:
|
433
|
+
return None
|
434
|
+
|
435
|
+
if isinstance(filter_config, bool):
|
436
|
+
return filter_config
|
437
|
+
|
438
|
+
if not isinstance(filter_config, FilterConfig):
|
439
|
+
raise InvalidFilterError(f"Must be bool or FilterConfig, got {type(filter_config).__name__}")
|
440
|
+
|
441
|
+
# Validate filter config fields
|
442
|
+
if hasattr(filter_config, "price_change_threshold") and filter_config.price_change_threshold is not None:
|
443
|
+
validate_numeric(filter_config.price_change_threshold, "price_change_threshold", float, 0.0, 1.0)
|
444
|
+
|
445
|
+
if hasattr(filter_config, "volume_change_threshold") and filter_config.volume_change_threshold is not None:
|
446
|
+
validate_numeric(filter_config.volume_change_threshold, "volume_change_threshold", float, 0.0, 10.0)
|
447
|
+
|
448
|
+
if hasattr(filter_config, "max_updates_per_second") and filter_config.max_updates_per_second is not None:
|
449
|
+
validate_numeric(filter_config.max_updates_per_second, "max_updates_per_second", float, 0.01, 100.0)
|
450
|
+
|
451
|
+
return filter_config
|
452
|
+
|
453
|
+
|
454
|
+
def validate_query_string(query: Any, max_length: int = 200) -> str:
|
455
|
+
"""
|
456
|
+
Validate search query string.
|
457
|
+
|
458
|
+
Args:
|
459
|
+
query: Query to validate
|
460
|
+
max_length: Maximum query length
|
461
|
+
|
462
|
+
Returns:
|
463
|
+
Validated query
|
464
|
+
|
465
|
+
Raises:
|
466
|
+
InvalidParameterError: If query is invalid
|
467
|
+
"""
|
468
|
+
query = validate_string(query, "query", 1, max_length)
|
469
|
+
|
470
|
+
# Remove potentially dangerous characters
|
471
|
+
if any(char in query for char in ["<", ">", '"', "'"]):
|
472
|
+
raise InvalidParameterError("query", query, "query without HTML/script characters")
|
473
|
+
|
474
|
+
return query.strip()
|
475
|
+
|
476
|
+
|
477
|
+
def validate_dict_config(config: Any, parameter_name: str = "config", allow_none: bool = True) -> Optional[dict]:
|
478
|
+
"""
|
479
|
+
Validate dictionary configuration.
|
480
|
+
|
481
|
+
Args:
|
482
|
+
config: Config dict to validate
|
483
|
+
parameter_name: Name of parameter for error messages
|
484
|
+
allow_none: Whether to allow None values
|
485
|
+
|
486
|
+
Returns:
|
487
|
+
Validated config dict
|
488
|
+
|
489
|
+
Raises:
|
490
|
+
InvalidTypeError: If config is not dict or None
|
491
|
+
"""
|
492
|
+
if config is None and allow_none:
|
493
|
+
return None
|
494
|
+
|
495
|
+
if not isinstance(config, dict):
|
496
|
+
raise InvalidTypeError(parameter_name, config, "dict")
|
497
|
+
|
498
|
+
return config
|
499
|
+
|
500
|
+
|
501
|
+
def validate_boolean(value: Any, parameter_name: str) -> bool:
|
502
|
+
"""
|
503
|
+
Validate boolean parameter.
|
504
|
+
|
505
|
+
Args:
|
506
|
+
value: Value to validate
|
507
|
+
parameter_name: Name of parameter for error messages
|
508
|
+
|
509
|
+
Returns:
|
510
|
+
Validated boolean
|
511
|
+
|
512
|
+
Raises:
|
513
|
+
InvalidTypeError: If value is not boolean
|
514
|
+
"""
|
515
|
+
if not isinstance(value, bool):
|
516
|
+
raise InvalidTypeError(parameter_name, value, "bool")
|
517
|
+
|
518
|
+
return value
|
519
|
+
|
520
|
+
|
521
|
+
def validate_list_not_empty(value: Any, parameter_name: str) -> list:
|
522
|
+
"""
|
523
|
+
Validate that list is not empty.
|
524
|
+
|
525
|
+
Args:
|
526
|
+
value: List to validate
|
527
|
+
parameter_name: Name of parameter for error messages
|
528
|
+
|
529
|
+
Returns:
|
530
|
+
Validated list
|
531
|
+
|
532
|
+
Raises:
|
533
|
+
InvalidTypeError: If value is not a list
|
534
|
+
EmptyListError: If list is empty
|
535
|
+
"""
|
536
|
+
if not isinstance(value, (list, tuple)):
|
537
|
+
raise InvalidTypeError(parameter_name, value, "list")
|
538
|
+
|
539
|
+
if len(value) == 0:
|
540
|
+
raise EmptyListError(parameter_name)
|
541
|
+
|
542
|
+
return list(value)
|