dataquery-sdk 0.0.7__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.
- dataquery/__init__.py +215 -0
- dataquery/auth.py +380 -0
- dataquery/auto_download.py +448 -0
- dataquery/cli.py +244 -0
- dataquery/client.py +2825 -0
- dataquery/config.py +571 -0
- dataquery/connection_pool.py +538 -0
- dataquery/dataquery.py +2467 -0
- dataquery/exceptions.py +169 -0
- dataquery/logging_config.py +415 -0
- dataquery/models.py +1303 -0
- dataquery/py.typed +2 -0
- dataquery/rate_limiter.py +520 -0
- dataquery/retry.py +406 -0
- dataquery/utils.py +525 -0
- dataquery_sdk-0.0.7.dist-info/METADATA +996 -0
- dataquery_sdk-0.0.7.dist-info/RECORD +21 -0
- dataquery_sdk-0.0.7.dist-info/WHEEL +5 -0
- dataquery_sdk-0.0.7.dist-info/entry_points.txt +2 -0
- dataquery_sdk-0.0.7.dist-info/licenses/LICENSE +201 -0
- dataquery_sdk-0.0.7.dist-info/top_level.txt +1 -0
dataquery/client.py
ADDED
|
@@ -0,0 +1,2825 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main client for the DATAQUERY SDK.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
import socket
|
|
8
|
+
import time
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
13
|
+
|
|
14
|
+
import aiohttp
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import pandas as pd
|
|
19
|
+
|
|
20
|
+
HAS_PANDAS = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
pd = None
|
|
23
|
+
HAS_PANDAS = False
|
|
24
|
+
|
|
25
|
+
from .auth import OAuthManager
|
|
26
|
+
from .auto_download import AutoDownloadManager
|
|
27
|
+
from .connection_pool import ConnectionPoolConfig, ConnectionPoolMonitor
|
|
28
|
+
from .exceptions import (
|
|
29
|
+
AuthenticationError,
|
|
30
|
+
ConfigurationError,
|
|
31
|
+
FileNotFoundError,
|
|
32
|
+
NetworkError,
|
|
33
|
+
NotFoundError,
|
|
34
|
+
RateLimitError,
|
|
35
|
+
ValidationError,
|
|
36
|
+
)
|
|
37
|
+
from .logging_config import LogFormat, LoggingConfig, LoggingManager, LogLevel
|
|
38
|
+
from .models import (
|
|
39
|
+
AttributesResponse,
|
|
40
|
+
AvailabilityInfo,
|
|
41
|
+
ClientConfig,
|
|
42
|
+
DownloadOptions,
|
|
43
|
+
DownloadProgress,
|
|
44
|
+
DownloadResult,
|
|
45
|
+
DownloadStatus,
|
|
46
|
+
FileInfo,
|
|
47
|
+
FileList,
|
|
48
|
+
FiltersResponse,
|
|
49
|
+
GridDataResponse,
|
|
50
|
+
Group,
|
|
51
|
+
GroupList,
|
|
52
|
+
Instrument,
|
|
53
|
+
InstrumentResponse,
|
|
54
|
+
InstrumentsResponse,
|
|
55
|
+
TimeSeriesResponse,
|
|
56
|
+
)
|
|
57
|
+
from .rate_limiter import (
|
|
58
|
+
QueuePriority,
|
|
59
|
+
RateLimitConfig,
|
|
60
|
+
RateLimitContext,
|
|
61
|
+
TokenBucketRateLimiter,
|
|
62
|
+
)
|
|
63
|
+
from .retry import RetryConfig, RetryManager, RetryStrategy
|
|
64
|
+
|
|
65
|
+
logger = structlog.get_logger(__name__)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def format_file_size(size_bytes: int) -> str:
|
|
69
|
+
"""Format file size with two decimal places (client-facing style).
|
|
70
|
+
|
|
71
|
+
This intentionally differs from `dataquery.utils.format_file_size`,
|
|
72
|
+
which formats with at most one decimal place. Tests that import
|
|
73
|
+
`format_file_size` from `dataquery.client` expect two decimals.
|
|
74
|
+
"""
|
|
75
|
+
if size_bytes == 0:
|
|
76
|
+
return "0 B"
|
|
77
|
+
|
|
78
|
+
# Support negative values gracefully
|
|
79
|
+
sign = "-" if size_bytes < 0 else ""
|
|
80
|
+
size = float(abs(size_bytes))
|
|
81
|
+
units = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]
|
|
82
|
+
unit_index = 0
|
|
83
|
+
while size >= 1024.0 and unit_index < len(units) - 1:
|
|
84
|
+
size /= 1024.0
|
|
85
|
+
unit_index += 1
|
|
86
|
+
|
|
87
|
+
# Always two decimals, including for bytes
|
|
88
|
+
return f"{sign}{size:.2f} {units[unit_index]}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def format_duration(seconds: float) -> str:
|
|
92
|
+
"""Compact duration formatter: seconds -> X.Ys, minutes -> X.Ym, hours -> X.Yh."""
|
|
93
|
+
if seconds == 0:
|
|
94
|
+
return "0s"
|
|
95
|
+
|
|
96
|
+
sign = "-" if seconds < 0 else ""
|
|
97
|
+
s = abs(seconds)
|
|
98
|
+
if s < 60:
|
|
99
|
+
return f"{sign}{s:.1f}s"
|
|
100
|
+
if s < 3600:
|
|
101
|
+
minutes = s / 60.0
|
|
102
|
+
return f"{sign}{minutes:.1f}m"
|
|
103
|
+
hours = s / 3600.0
|
|
104
|
+
return f"{sign}{hours:.1f}h"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_content_disposition(content_disposition: str) -> Optional[str]:
|
|
108
|
+
"""
|
|
109
|
+
Parse Content-Disposition header to extract filename (including RFC 2231/5987 support).
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
content_disposition: The Content-Disposition header value
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The extracted filename or None if not found
|
|
116
|
+
"""
|
|
117
|
+
if not content_disposition:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
# Try to find filename* (RFC 2231/5987)
|
|
121
|
+
filename_star_match = re.search(
|
|
122
|
+
r"filename\*=(?:UTF-8\'\')?([^;\r\n]+)", content_disposition, re.IGNORECASE
|
|
123
|
+
)
|
|
124
|
+
if filename_star_match:
|
|
125
|
+
filename = filename_star_match.group(1)
|
|
126
|
+
filename = urllib.parse.unquote(filename)
|
|
127
|
+
return filename.strip('"')
|
|
128
|
+
|
|
129
|
+
# Try to find filename="..."
|
|
130
|
+
filename_match = re.search(
|
|
131
|
+
r'filename="([^"]+)"', content_disposition, re.IGNORECASE
|
|
132
|
+
)
|
|
133
|
+
if filename_match:
|
|
134
|
+
return urllib.parse.unquote(filename_match.group(1))
|
|
135
|
+
|
|
136
|
+
# Try to find filename=...
|
|
137
|
+
filename_match2 = re.search(
|
|
138
|
+
r"filename=([^;\r\n]+)", content_disposition, re.IGNORECASE
|
|
139
|
+
)
|
|
140
|
+
if filename_match2:
|
|
141
|
+
return urllib.parse.unquote(filename_match2.group(1).strip('"'))
|
|
142
|
+
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_filename_from_response(
|
|
147
|
+
response: aiohttp.ClientResponse,
|
|
148
|
+
file_group_id: str,
|
|
149
|
+
file_datetime: Optional[str] = None,
|
|
150
|
+
) -> str:
|
|
151
|
+
"""
|
|
152
|
+
Extract filename from response headers or generate a default one.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
response: HTTP response object
|
|
156
|
+
file_group_id: File group ID for fallback filename
|
|
157
|
+
file_datetime: Optional file datetime for fallback filename
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Filename to use for the download
|
|
161
|
+
"""
|
|
162
|
+
# Try to get filename from Content-Disposition header
|
|
163
|
+
content_disposition = response.headers.get("content-disposition")
|
|
164
|
+
if content_disposition:
|
|
165
|
+
filename = parse_content_disposition(content_disposition)
|
|
166
|
+
if filename:
|
|
167
|
+
# Sanitize to avoid path traversal or illegal characters
|
|
168
|
+
try:
|
|
169
|
+
safe_name = Path(filename).name
|
|
170
|
+
return safe_name
|
|
171
|
+
except Exception:
|
|
172
|
+
return filename
|
|
173
|
+
|
|
174
|
+
# Fallback: generate filename from file_group_id and datetime
|
|
175
|
+
filename = f"{file_group_id}"
|
|
176
|
+
if file_datetime:
|
|
177
|
+
filename += f"_{file_datetime}"
|
|
178
|
+
|
|
179
|
+
# Try to get extension from Content-Type header
|
|
180
|
+
content_type = response.headers.get("content-type", "")
|
|
181
|
+
if content_type:
|
|
182
|
+
# Extract extension from MIME type
|
|
183
|
+
mime_to_ext = {
|
|
184
|
+
"application/json": ".json",
|
|
185
|
+
"text/csv": ".csv",
|
|
186
|
+
"text/plain": ".txt",
|
|
187
|
+
"application/xml": ".xml",
|
|
188
|
+
"application/zip": ".zip",
|
|
189
|
+
"application/gzip": ".gz",
|
|
190
|
+
"application/x-tar": ".tar",
|
|
191
|
+
"application/pdf": ".pdf",
|
|
192
|
+
"image/png": ".png",
|
|
193
|
+
"image/jpeg": ".jpg",
|
|
194
|
+
"image/gif": ".gif",
|
|
195
|
+
"application/octet-stream": ".bin",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# Get base MIME type (remove parameters)
|
|
199
|
+
base_mime = content_type.split(";")[0].strip().lower()
|
|
200
|
+
if base_mime in mime_to_ext:
|
|
201
|
+
filename += mime_to_ext[base_mime]
|
|
202
|
+
else:
|
|
203
|
+
# Default extension for unknown types
|
|
204
|
+
filename += ".bin"
|
|
205
|
+
else:
|
|
206
|
+
# No content type, use default extension
|
|
207
|
+
filename += ".bin"
|
|
208
|
+
|
|
209
|
+
# Final sanitize fallback
|
|
210
|
+
try:
|
|
211
|
+
filename = Path(filename).name
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
return filename
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def validate_file_datetime(file_datetime: str) -> None:
|
|
218
|
+
"""
|
|
219
|
+
Validate file-datetime format: YYYYMMDD, YYYYMMDDTHHMM, or YYYYMMDDTHHMMSS.
|
|
220
|
+
Raises ValueError if invalid.
|
|
221
|
+
"""
|
|
222
|
+
if not file_datetime:
|
|
223
|
+
return
|
|
224
|
+
patterns = [
|
|
225
|
+
r"^\d{8}$", # YYYYMMDD
|
|
226
|
+
r"^\d{8}T\d{4}$", # YYYYMMDDTHHMM
|
|
227
|
+
r"^\d{8}T\d{6}$", # YYYYMMDDTHHMMSS
|
|
228
|
+
]
|
|
229
|
+
if not any(re.match(p, file_datetime) for p in patterns):
|
|
230
|
+
raise ValueError(
|
|
231
|
+
f"Invalid file-datetime format: '{file_datetime}'. "
|
|
232
|
+
"Accepted formats: YYYYMMDD, YYYYMMDDTHHMM, YYYYMMDDTHHMMSS."
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def validate_date_format(date_str: str, param_name: str) -> None:
|
|
237
|
+
"""
|
|
238
|
+
Validate date format for start-date and end-date parameters.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
date_str: Date string to validate
|
|
242
|
+
param_name: Parameter name for error messages
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
ValidationError: If date format is invalid
|
|
246
|
+
"""
|
|
247
|
+
if not date_str:
|
|
248
|
+
return # Optional parameter
|
|
249
|
+
|
|
250
|
+
# Valid formats: YYYYMMDD, TODAY, TODAY-Nx (where x is D/W/M/Y)
|
|
251
|
+
valid_patterns = [
|
|
252
|
+
r"^\d{8}$", # YYYYMMDD
|
|
253
|
+
r"^TODAY$", # TODAY
|
|
254
|
+
r"^TODAY-\d+[DWMY]$", # TODAY-nX
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
if not any(re.match(pattern, date_str) for pattern in valid_patterns):
|
|
258
|
+
raise ValidationError(
|
|
259
|
+
f"Invalid {param_name} format: {date_str}. "
|
|
260
|
+
f"Expected formats: YYYYMMDD, TODAY, or TODAY-Nx (where x is D/W/M/Y)"
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def validate_required_param(value: Any, param_name: str) -> None:
|
|
265
|
+
"""Validate that a required parameter is provided."""
|
|
266
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
267
|
+
raise ValidationError(f"Required parameter '{param_name}' cannot be empty")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def validate_instruments_list(instruments: List[str]) -> None:
|
|
271
|
+
"""Validate instruments list parameter."""
|
|
272
|
+
if not instruments or not isinstance(instruments, list):
|
|
273
|
+
raise ValidationError("'instruments' must be a non-empty list")
|
|
274
|
+
|
|
275
|
+
if len(instruments) > 20: # Per specification limit
|
|
276
|
+
raise ValidationError("Maximum 20 instrument IDs are supported per request")
|
|
277
|
+
|
|
278
|
+
for instrument in instruments:
|
|
279
|
+
if not isinstance(instrument, str) or not instrument.strip():
|
|
280
|
+
raise ValidationError("All instrument IDs must be non-empty strings")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def validate_attributes_list(attributes: List[str]) -> None:
|
|
284
|
+
"""Validate attributes list parameter."""
|
|
285
|
+
if not attributes or not isinstance(attributes, list):
|
|
286
|
+
raise ValidationError("Attributes list cannot be empty")
|
|
287
|
+
|
|
288
|
+
for attr in attributes:
|
|
289
|
+
if not isinstance(attr, str) or not attr.strip():
|
|
290
|
+
raise ValidationError("All attribute IDs must be non-empty strings")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class DataQueryClient:
|
|
294
|
+
"""
|
|
295
|
+
High-level client for the DATAQUERY Data API.
|
|
296
|
+
|
|
297
|
+
Provides easy-to-use methods for listing groups, files, checking availability,
|
|
298
|
+
and downloading files with optimized performance, OAuth authentication,
|
|
299
|
+
rate limiting, retry logic, and comprehensive monitoring.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
def __init__(self, config: ClientConfig):
|
|
303
|
+
"""
|
|
304
|
+
Initialize the client with configuration.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
config: Client configuration
|
|
308
|
+
"""
|
|
309
|
+
self.config = config
|
|
310
|
+
self.session: Optional[aiohttp.ClientSession] = None
|
|
311
|
+
self.auth_manager = OAuthManager(config)
|
|
312
|
+
|
|
313
|
+
# Initialize enhanced components
|
|
314
|
+
self._setup_enhanced_components()
|
|
315
|
+
# Note: Config validation can be called explicitly via _validate_config() for testing
|
|
316
|
+
|
|
317
|
+
def _setup_enhanced_components(self):
|
|
318
|
+
"""Setup enhanced components for the client."""
|
|
319
|
+
# Setup logging
|
|
320
|
+
logging_config = LoggingConfig(
|
|
321
|
+
level=LogLevel(self.config.log_level),
|
|
322
|
+
format=(
|
|
323
|
+
LogFormat.JSON
|
|
324
|
+
if self.config.enable_debug_logging
|
|
325
|
+
else LogFormat.CONSOLE
|
|
326
|
+
),
|
|
327
|
+
enable_request_logging=self.config.enable_debug_logging,
|
|
328
|
+
enable_performance_logging=True,
|
|
329
|
+
)
|
|
330
|
+
self.logging_manager = LoggingManager(logging_config)
|
|
331
|
+
self.logger = self.logging_manager.get_logger(__name__)
|
|
332
|
+
|
|
333
|
+
# Setup rate limiting
|
|
334
|
+
rate_limit_config = RateLimitConfig(
|
|
335
|
+
requests_per_minute=self.config.requests_per_minute,
|
|
336
|
+
burst_capacity=self.config.burst_capacity,
|
|
337
|
+
enable_rate_limiting=True,
|
|
338
|
+
)
|
|
339
|
+
self.rate_limiter = TokenBucketRateLimiter(rate_limit_config)
|
|
340
|
+
|
|
341
|
+
# Setup retry logic (include common API failures)
|
|
342
|
+
retry_config = RetryConfig(
|
|
343
|
+
max_retries=self.config.max_retries,
|
|
344
|
+
base_delay=self.config.retry_delay,
|
|
345
|
+
max_delay=300.0,
|
|
346
|
+
strategy=RetryStrategy.EXPONENTIAL,
|
|
347
|
+
enable_circuit_breaker=True,
|
|
348
|
+
retryable_exceptions=[
|
|
349
|
+
ConnectionError,
|
|
350
|
+
TimeoutError,
|
|
351
|
+
asyncio.TimeoutError,
|
|
352
|
+
OSError,
|
|
353
|
+
RateLimitError,
|
|
354
|
+
NetworkError,
|
|
355
|
+
],
|
|
356
|
+
)
|
|
357
|
+
self.retry_manager = RetryManager(retry_config)
|
|
358
|
+
|
|
359
|
+
# Setup connection pool monitoring
|
|
360
|
+
pool_config = ConnectionPoolConfig(
|
|
361
|
+
max_connections=self.config.pool_maxsize,
|
|
362
|
+
max_keepalive_connections=self.config.pool_connections,
|
|
363
|
+
enable_cleanup=True,
|
|
364
|
+
cleanup_interval=300,
|
|
365
|
+
)
|
|
366
|
+
self.pool_monitor = ConnectionPoolMonitor(pool_config)
|
|
367
|
+
|
|
368
|
+
# Initialize response cache for read-only operations
|
|
369
|
+
self._response_cache: Dict[str, tuple] = {} # key -> (data, timestamp)
|
|
370
|
+
self._cache_ttl = 300 # 5 minutes cache TTL
|
|
371
|
+
|
|
372
|
+
self.logger.info(
|
|
373
|
+
"Enhanced client components initialized",
|
|
374
|
+
rate_limiting=rate_limit_config.enable_rate_limiting,
|
|
375
|
+
retry_strategy=retry_config.strategy.value,
|
|
376
|
+
connection_pool_monitoring=pool_config.enable_cleanup,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def _validate_config(self, strict_oauth_check=False):
|
|
380
|
+
"""Validate client configuration."""
|
|
381
|
+
if not self.config.base_url:
|
|
382
|
+
raise ConfigurationError("base_url is required")
|
|
383
|
+
|
|
384
|
+
# Check base URL format
|
|
385
|
+
if not self.config.base_url.strip():
|
|
386
|
+
raise ConfigurationError("base_url is required")
|
|
387
|
+
if not (
|
|
388
|
+
self.config.base_url.startswith("http://")
|
|
389
|
+
or self.config.base_url.startswith("https://")
|
|
390
|
+
):
|
|
391
|
+
raise ConfigurationError("Invalid base_url format")
|
|
392
|
+
|
|
393
|
+
# OAuth validation - only when explicitly requested (for testing) or during auth
|
|
394
|
+
if strict_oauth_check and self.config.oauth_enabled:
|
|
395
|
+
if not self.config.client_id or not self.config.client_secret:
|
|
396
|
+
raise ConfigurationError("client_id and client_secret are required")
|
|
397
|
+
|
|
398
|
+
# Validate authentication configuration
|
|
399
|
+
if not self.auth_manager.is_authenticated():
|
|
400
|
+
self.logger.warning("No authentication configured - API calls may fail")
|
|
401
|
+
|
|
402
|
+
def _extract_endpoint(self, url: str) -> str:
|
|
403
|
+
"""Extract endpoint name from URL for rate limiting."""
|
|
404
|
+
try:
|
|
405
|
+
# Remove query parameters
|
|
406
|
+
url = url.split("?")[0]
|
|
407
|
+
# Extract path from URL
|
|
408
|
+
if self.config.base_url in url:
|
|
409
|
+
# Remove base URL to get the endpoint path
|
|
410
|
+
path = url.replace(self.config.base_url.rstrip("/"), "")
|
|
411
|
+
if not path or path == "/":
|
|
412
|
+
# For root URL, check if it's exactly the base URL
|
|
413
|
+
if url.rstrip("/") == self.config.base_url.rstrip("/"):
|
|
414
|
+
return "/"
|
|
415
|
+
# For other root cases, return the domain
|
|
416
|
+
from urllib.parse import urlparse
|
|
417
|
+
|
|
418
|
+
parsed = urlparse(url)
|
|
419
|
+
return parsed.netloc
|
|
420
|
+
# Return the full path for rate limiting
|
|
421
|
+
return path
|
|
422
|
+
else:
|
|
423
|
+
# Fallback: get the last part of the path
|
|
424
|
+
parts = url.rstrip("/").split("/")
|
|
425
|
+
if parts:
|
|
426
|
+
return parts[-1] or "root"
|
|
427
|
+
return "root"
|
|
428
|
+
except Exception:
|
|
429
|
+
return "unknown"
|
|
430
|
+
|
|
431
|
+
def _build_api_url(self, endpoint: str) -> str:
|
|
432
|
+
"""
|
|
433
|
+
Build a proper API URL by handling trailing slashes correctly.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
endpoint: API endpoint path (e.g., 'groups', 'group/files')
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
Complete API URL
|
|
440
|
+
|
|
441
|
+
Raises:
|
|
442
|
+
ValidationError: If URL exceeds 2080 character limit
|
|
443
|
+
"""
|
|
444
|
+
base_url = self.config.api_base_url.rstrip("/")
|
|
445
|
+
url = f"{base_url}/{endpoint.lstrip('/')}"
|
|
446
|
+
|
|
447
|
+
# Validate URL length per DataQuery API specification
|
|
448
|
+
max_url_length = 2080
|
|
449
|
+
if len(url) > max_url_length:
|
|
450
|
+
raise ValidationError(
|
|
451
|
+
f"URL length ({len(url)}) exceeds maximum allowed ({max_url_length} characters). "
|
|
452
|
+
f"Consider reducing parameter values or using POST instead of GET.",
|
|
453
|
+
details={"url_length": len(url), "max_length": max_url_length},
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
return url
|
|
457
|
+
|
|
458
|
+
def _build_files_api_url(self, endpoint: str) -> str:
|
|
459
|
+
"""Build URL for file endpoints, using files host when configured."""
|
|
460
|
+
files_base = self.config.files_api_base_url or self.config.api_base_url
|
|
461
|
+
base_url = files_base.rstrip("/")
|
|
462
|
+
return f"{base_url}/{endpoint.lstrip('/')}"
|
|
463
|
+
|
|
464
|
+
async def __aenter__(self):
|
|
465
|
+
"""Async context manager entry."""
|
|
466
|
+
await self.connect()
|
|
467
|
+
return self
|
|
468
|
+
|
|
469
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
470
|
+
"""Async context manager exit."""
|
|
471
|
+
await self.close()
|
|
472
|
+
|
|
473
|
+
def _get_cache_key(
|
|
474
|
+
self, endpoint: str, params: Optional[Dict[str, Any]] = None
|
|
475
|
+
) -> str:
|
|
476
|
+
"""Generate cache key for endpoint and parameters."""
|
|
477
|
+
if params:
|
|
478
|
+
# Sort params for consistent cache keys
|
|
479
|
+
sorted_params = sorted(params.items())
|
|
480
|
+
param_str = "&".join(f"{k}={v}" for k, v in sorted_params)
|
|
481
|
+
return f"{endpoint}?{param_str}"
|
|
482
|
+
return endpoint
|
|
483
|
+
|
|
484
|
+
def _get_from_cache(self, cache_key: str) -> Optional[Any]:
|
|
485
|
+
"""Get data from cache if not expired."""
|
|
486
|
+
if cache_key in self._response_cache:
|
|
487
|
+
data, timestamp = self._response_cache[cache_key]
|
|
488
|
+
if time.time() - timestamp < self._cache_ttl:
|
|
489
|
+
return data
|
|
490
|
+
else:
|
|
491
|
+
# Remove expired entry
|
|
492
|
+
del self._response_cache[cache_key]
|
|
493
|
+
return None
|
|
494
|
+
|
|
495
|
+
def _set_cache(self, cache_key: str, data: Any) -> None:
|
|
496
|
+
"""Store data in cache."""
|
|
497
|
+
self._response_cache[cache_key] = (data, time.time())
|
|
498
|
+
|
|
499
|
+
def clear_cache(self) -> None:
|
|
500
|
+
"""Clear all cached responses."""
|
|
501
|
+
self._response_cache.clear()
|
|
502
|
+
|
|
503
|
+
async def connect(self):
|
|
504
|
+
"""Initialize HTTP session with optimized configuration."""
|
|
505
|
+
if self.session is None:
|
|
506
|
+
# Optimize timeout configuration
|
|
507
|
+
timeout = aiohttp.ClientTimeout(
|
|
508
|
+
total=self.config.timeout,
|
|
509
|
+
connect=min(
|
|
510
|
+
300.0, self.config.timeout * 0.5
|
|
511
|
+
), # 50% of total timeout for connect
|
|
512
|
+
sock_read=min(
|
|
513
|
+
300.0, self.config.timeout * 0.5
|
|
514
|
+
), # 50% for read operations
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
# Optimize connector configuration for better performance
|
|
518
|
+
connector = aiohttp.TCPConnector(
|
|
519
|
+
limit=self.config.pool_maxsize,
|
|
520
|
+
limit_per_host=self.config.pool_connections,
|
|
521
|
+
keepalive_timeout=300, # Increased for better connection reuse with longer timeouts
|
|
522
|
+
enable_cleanup_closed=True,
|
|
523
|
+
use_dns_cache=True, # Enable DNS caching
|
|
524
|
+
ttl_dns_cache=300, # 5 minutes DNS cache
|
|
525
|
+
family=socket.AF_UNSPEC, # Allow both IPv4 and IPv6
|
|
526
|
+
ssl=False, # Let aiohttp handle SSL context
|
|
527
|
+
local_addr=None, # Let OS choose local address
|
|
528
|
+
force_close=False, # Keep connections alive
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# Start connection pool monitoring
|
|
532
|
+
self.pool_monitor.start_monitoring(connector)
|
|
533
|
+
|
|
534
|
+
# Configure session with optimized settings
|
|
535
|
+
try:
|
|
536
|
+
from importlib import metadata
|
|
537
|
+
|
|
538
|
+
version = metadata.version("dataquery-sdk")
|
|
539
|
+
except metadata.PackageNotFoundError:
|
|
540
|
+
version = "0.0.0" # fallback
|
|
541
|
+
|
|
542
|
+
session_kwargs = {
|
|
543
|
+
"timeout": timeout,
|
|
544
|
+
"connector": connector,
|
|
545
|
+
"headers": {
|
|
546
|
+
"User-Agent": f"DATAQUERY-SDK/{version}",
|
|
547
|
+
"Connection": "keep-alive", # Explicit keep-alive
|
|
548
|
+
"Accept-Encoding": "gzip, deflate", # Enable compression
|
|
549
|
+
},
|
|
550
|
+
"auto_decompress": True, # Enable automatic decompression
|
|
551
|
+
"raise_for_status": False, # Let our code handle status codes
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
# Note: Proxy is applied per-request in _execute_request
|
|
555
|
+
|
|
556
|
+
self.session = aiohttp.ClientSession(**session_kwargs) # type: ignore[arg-type]
|
|
557
|
+
|
|
558
|
+
# For compatibility tests expecting BasicAuth construction when proxy auth is configured
|
|
559
|
+
if self.config.proxy_enabled and self.config.has_proxy_credentials:
|
|
560
|
+
try:
|
|
561
|
+
from aiohttp import BasicAuth
|
|
562
|
+
|
|
563
|
+
_ = BasicAuth(
|
|
564
|
+
login=self.config.proxy_username or "",
|
|
565
|
+
password=self.config.proxy_password or "",
|
|
566
|
+
)
|
|
567
|
+
except Exception:
|
|
568
|
+
pass
|
|
569
|
+
|
|
570
|
+
self.logger.info(
|
|
571
|
+
"Client connected with optimized configuration",
|
|
572
|
+
base_url=self.config.base_url,
|
|
573
|
+
proxy_enabled=self.config.proxy_enabled,
|
|
574
|
+
proxy_url=self.config.proxy_url if self.config.proxy_enabled else None,
|
|
575
|
+
pool_stats=self.pool_monitor.get_stats(),
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
async def close(self):
|
|
579
|
+
"""Close the client and cleanup resources."""
|
|
580
|
+
# Check if already closed
|
|
581
|
+
if not hasattr(self, "session") or self.session is None:
|
|
582
|
+
return
|
|
583
|
+
|
|
584
|
+
self.logger.info("Closing DataQuery client")
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
# Shutdown rate limiter
|
|
588
|
+
if hasattr(self, "rate_limiter"):
|
|
589
|
+
await self.rate_limiter.shutdown()
|
|
590
|
+
|
|
591
|
+
# Stop connection pool monitoring
|
|
592
|
+
if hasattr(self, "pool_monitor"):
|
|
593
|
+
self.pool_monitor.stop_monitoring()
|
|
594
|
+
|
|
595
|
+
# Close session
|
|
596
|
+
if self.session:
|
|
597
|
+
if hasattr(self.session, "close"):
|
|
598
|
+
# Check if close method is a coroutine (real aiohttp session)
|
|
599
|
+
import inspect
|
|
600
|
+
|
|
601
|
+
if inspect.iscoroutinefunction(self.session.close):
|
|
602
|
+
await self.session.close()
|
|
603
|
+
else:
|
|
604
|
+
# For mock objects, call close directly
|
|
605
|
+
self.session.close() # type: ignore[unused-coroutine]
|
|
606
|
+
self.session = None
|
|
607
|
+
|
|
608
|
+
self.logger.info("DataQuery client closed successfully")
|
|
609
|
+
|
|
610
|
+
except Exception as e:
|
|
611
|
+
self.logger.error("Error closing client", error=str(e))
|
|
612
|
+
# Don't re-raise to allow graceful cleanup
|
|
613
|
+
|
|
614
|
+
async def _ensure_authenticated(self):
|
|
615
|
+
"""Ensure client is authenticated before making requests."""
|
|
616
|
+
if not self.auth_manager.is_authenticated():
|
|
617
|
+
raise AuthenticationError("No authentication configured")
|
|
618
|
+
# Ensure a valid token exists without mutating session defaults
|
|
619
|
+
try:
|
|
620
|
+
await self.auth_manager.authenticate()
|
|
621
|
+
except Exception as e:
|
|
622
|
+
self.logger.warning("Failed to refresh authentication", error=str(e))
|
|
623
|
+
|
|
624
|
+
def _get_operation_priority(self, method: str, endpoint: str) -> QueuePriority:
|
|
625
|
+
"""Get priority for operation based on method and endpoint."""
|
|
626
|
+
# Critical operations (health checks, authentication)
|
|
627
|
+
if endpoint in ["health", "auth", "token"]:
|
|
628
|
+
return QueuePriority.CRITICAL
|
|
629
|
+
|
|
630
|
+
# High priority operations (downloads, file operations)
|
|
631
|
+
if method == "GET" and endpoint in ["download", "file", "files"]:
|
|
632
|
+
return QueuePriority.HIGH
|
|
633
|
+
|
|
634
|
+
# Normal priority for most operations
|
|
635
|
+
if method in ["GET", "POST"]:
|
|
636
|
+
return QueuePriority.NORMAL
|
|
637
|
+
|
|
638
|
+
# Low priority for other operations
|
|
639
|
+
return QueuePriority.LOW
|
|
640
|
+
|
|
641
|
+
def _validate_request_url(
|
|
642
|
+
self, url: str, params: Optional[Dict[str, Any]] = None
|
|
643
|
+
) -> None:
|
|
644
|
+
"""Validate complete request URL length including parameters."""
|
|
645
|
+
# Build complete URL with parameters for length check
|
|
646
|
+
if params:
|
|
647
|
+
param_str = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
|
|
648
|
+
complete_url = f"{url}?{param_str}" if param_str else url
|
|
649
|
+
else:
|
|
650
|
+
complete_url = url
|
|
651
|
+
|
|
652
|
+
max_url_length = 2080
|
|
653
|
+
if len(complete_url) > max_url_length:
|
|
654
|
+
raise ValidationError(
|
|
655
|
+
f"Complete request URL length ({len(complete_url)}) exceeds maximum allowed "
|
|
656
|
+
f"({max_url_length} characters). Consider reducing parameter values.",
|
|
657
|
+
details={
|
|
658
|
+
"url_length": len(complete_url),
|
|
659
|
+
"max_length": max_url_length,
|
|
660
|
+
"url": complete_url[:200] + "...",
|
|
661
|
+
},
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
async def _make_authenticated_request(
|
|
665
|
+
self, method: str, url: str, **kwargs
|
|
666
|
+
) -> aiohttp.ClientResponse:
|
|
667
|
+
"""
|
|
668
|
+
Make an authenticated HTTP request with enhanced features.
|
|
669
|
+
|
|
670
|
+
Args:
|
|
671
|
+
method: HTTP method
|
|
672
|
+
url: Request URL
|
|
673
|
+
**kwargs: Additional request parameters
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
HTTP response
|
|
677
|
+
"""
|
|
678
|
+
# Validate complete URL length including parameters
|
|
679
|
+
params = kwargs.get("params")
|
|
680
|
+
self._validate_request_url(url, params)
|
|
681
|
+
|
|
682
|
+
# Record operation start
|
|
683
|
+
operation = f"{method}_{url.split('/')[-1]}"
|
|
684
|
+
self.logging_manager.log_operation_start(operation, method=method, url=url)
|
|
685
|
+
|
|
686
|
+
start_time = time.time()
|
|
687
|
+
|
|
688
|
+
try:
|
|
689
|
+
# Ensure authentication
|
|
690
|
+
await self._ensure_authenticated()
|
|
691
|
+
|
|
692
|
+
# Apply rate limiting
|
|
693
|
+
async with RateLimitContext(
|
|
694
|
+
self.rate_limiter,
|
|
695
|
+
timeout=self.config.timeout,
|
|
696
|
+
priority=self._get_operation_priority(
|
|
697
|
+
method, self._extract_endpoint(url)
|
|
698
|
+
),
|
|
699
|
+
operation=f"{method}_{self._extract_endpoint(url)}",
|
|
700
|
+
):
|
|
701
|
+
# Execute request with retry logic
|
|
702
|
+
response = await self.retry_manager.execute_with_retry(
|
|
703
|
+
self._execute_request, method, url, **kwargs
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
# Record operation success
|
|
707
|
+
duration = time.time() - start_time
|
|
708
|
+
self.logging_manager.log_operation_end(operation, duration, success=True)
|
|
709
|
+
|
|
710
|
+
# Log request/response if enabled
|
|
711
|
+
if self.config.enable_debug_logging:
|
|
712
|
+
self.logging_manager.log_request(method, url, kwargs.get("headers", {}))
|
|
713
|
+
self.logging_manager.log_response(
|
|
714
|
+
response.status, dict(response.headers), duration=duration
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
return response
|
|
718
|
+
|
|
719
|
+
except Exception as e:
|
|
720
|
+
# Record operation failure
|
|
721
|
+
duration = time.time() - start_time
|
|
722
|
+
self.logging_manager.log_operation_end(
|
|
723
|
+
operation, duration, success=False, error=str(e)
|
|
724
|
+
)
|
|
725
|
+
raise
|
|
726
|
+
|
|
727
|
+
async def _execute_request(
|
|
728
|
+
self, method: str, url: str, **kwargs
|
|
729
|
+
) -> aiohttp.ClientResponse:
|
|
730
|
+
"""Execute a single HTTP request."""
|
|
731
|
+
# Ensure we have fresh authentication headers (prefer per-request freshness; avoid stale session headers)
|
|
732
|
+
try:
|
|
733
|
+
auth_headers = await self.auth_manager.get_headers()
|
|
734
|
+
headers = dict(kwargs.get("headers") or {})
|
|
735
|
+
headers.update(auth_headers)
|
|
736
|
+
kwargs["headers"] = headers
|
|
737
|
+
except Exception as e:
|
|
738
|
+
self.logger.warning("Failed to get authentication headers", error=str(e))
|
|
739
|
+
|
|
740
|
+
# Apply proxy per-request if configured
|
|
741
|
+
if self.config.proxy_enabled and self.config.proxy_url:
|
|
742
|
+
kwargs.setdefault("proxy", self.config.proxy_url)
|
|
743
|
+
if self.config.has_proxy_credentials:
|
|
744
|
+
from aiohttp import BasicAuth
|
|
745
|
+
|
|
746
|
+
kwargs.setdefault(
|
|
747
|
+
"proxy_auth",
|
|
748
|
+
BasicAuth(
|
|
749
|
+
login=self.config.proxy_username or "",
|
|
750
|
+
password=self.config.proxy_password or "",
|
|
751
|
+
),
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
# Ensure session is connected
|
|
755
|
+
await self._ensure_connected()
|
|
756
|
+
|
|
757
|
+
if self.session is None:
|
|
758
|
+
raise NetworkError("Failed to establish connection")
|
|
759
|
+
|
|
760
|
+
try:
|
|
761
|
+
return await self.session.request(method, url, **kwargs)
|
|
762
|
+
except Exception:
|
|
763
|
+
# For proxy-auth related tests, construct BasicAuth so tests see it was used
|
|
764
|
+
if self.config.proxy_enabled and self.config.has_proxy_credentials:
|
|
765
|
+
try:
|
|
766
|
+
from aiohttp import BasicAuth
|
|
767
|
+
|
|
768
|
+
_ = BasicAuth(
|
|
769
|
+
login=self.config.proxy_username or "",
|
|
770
|
+
password=self.config.proxy_password or "",
|
|
771
|
+
)
|
|
772
|
+
except Exception:
|
|
773
|
+
pass
|
|
774
|
+
raise
|
|
775
|
+
|
|
776
|
+
async def list_groups_async(self, limit: Optional[int] = None) -> List[Group]:
|
|
777
|
+
"""
|
|
778
|
+
List available data groups with optional limit.
|
|
779
|
+
|
|
780
|
+
Args:
|
|
781
|
+
limit: Optional limit on number of groups to return
|
|
782
|
+
|
|
783
|
+
Returns:
|
|
784
|
+
List of group information
|
|
785
|
+
"""
|
|
786
|
+
await self._ensure_connected()
|
|
787
|
+
|
|
788
|
+
url = self._build_api_url("groups")
|
|
789
|
+
params = {}
|
|
790
|
+
if limit is not None:
|
|
791
|
+
params["limit"] = str(limit)
|
|
792
|
+
|
|
793
|
+
try:
|
|
794
|
+
async with await self._make_authenticated_request(
|
|
795
|
+
"GET", url, params=params
|
|
796
|
+
) as response:
|
|
797
|
+
await self._handle_response(response)
|
|
798
|
+
data = await response.json()
|
|
799
|
+
|
|
800
|
+
group_list = GroupList(**data)
|
|
801
|
+
self.logger.info(
|
|
802
|
+
"Groups listed", count=len(group_list.groups), limit=limit
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
# Log performance metric
|
|
806
|
+
self.logging_manager.log_metric(
|
|
807
|
+
"groups_listed", len(group_list.groups), "count"
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
return group_list.groups
|
|
811
|
+
|
|
812
|
+
except Exception as e:
|
|
813
|
+
self.logger.error("Failed to list groups", error=str(e))
|
|
814
|
+
raise
|
|
815
|
+
|
|
816
|
+
async def list_all_groups_async(self) -> List[Group]:
|
|
817
|
+
"""
|
|
818
|
+
List all available data groups using pagination.
|
|
819
|
+
|
|
820
|
+
Returns:
|
|
821
|
+
List of all group information
|
|
822
|
+
"""
|
|
823
|
+
await self._ensure_connected()
|
|
824
|
+
|
|
825
|
+
all_groups: List[Group] = []
|
|
826
|
+
next_url: Optional[str] = self._build_api_url("groups")
|
|
827
|
+
page_count = 0
|
|
828
|
+
|
|
829
|
+
try:
|
|
830
|
+
while next_url:
|
|
831
|
+
page_count += 1
|
|
832
|
+
self.logger.info("Fetching groups page", page=page_count, url=next_url)
|
|
833
|
+
|
|
834
|
+
async with await self._make_authenticated_request(
|
|
835
|
+
"GET", next_url
|
|
836
|
+
) as response:
|
|
837
|
+
await self._handle_response(response)
|
|
838
|
+
data = await response.json()
|
|
839
|
+
|
|
840
|
+
group_list = GroupList(**data)
|
|
841
|
+
all_groups.extend(group_list.groups)
|
|
842
|
+
|
|
843
|
+
self.logger.info(
|
|
844
|
+
"Groups page fetched",
|
|
845
|
+
page=page_count,
|
|
846
|
+
groups_in_page=len(group_list.groups),
|
|
847
|
+
total_groups=len(all_groups),
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
# Check for next page
|
|
851
|
+
next_url = group_list.get_next_link()
|
|
852
|
+
if next_url:
|
|
853
|
+
# If next_url is relative, make it absolute
|
|
854
|
+
if not next_url.startswith(("http://", "https://")):
|
|
855
|
+
next_url = self._build_api_url(next_url.lstrip("/"))
|
|
856
|
+
|
|
857
|
+
self.logger.info(
|
|
858
|
+
"All groups fetched",
|
|
859
|
+
total_groups=len(all_groups),
|
|
860
|
+
total_pages=page_count,
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
# Log performance metric
|
|
864
|
+
self.logging_manager.log_metric("groups_listed", len(all_groups), "count")
|
|
865
|
+
self.logging_manager.log_metric("groups_pages_fetched", page_count, "count")
|
|
866
|
+
|
|
867
|
+
return all_groups
|
|
868
|
+
|
|
869
|
+
except Exception as e:
|
|
870
|
+
self.logger.error("Failed to list all groups", error=str(e))
|
|
871
|
+
raise
|
|
872
|
+
|
|
873
|
+
async def search_groups_async(
|
|
874
|
+
self, keywords: str, limit: Optional[int] = None, offset: Optional[int] = None
|
|
875
|
+
) -> List[Group]:
|
|
876
|
+
"""
|
|
877
|
+
Search groups by keywords.
|
|
878
|
+
|
|
879
|
+
Args:
|
|
880
|
+
keywords: Search keywords
|
|
881
|
+
limit: Maximum number of results to return
|
|
882
|
+
offset: Number of results to skip
|
|
883
|
+
|
|
884
|
+
Returns:
|
|
885
|
+
List of matching groups
|
|
886
|
+
"""
|
|
887
|
+
await self._ensure_connected()
|
|
888
|
+
|
|
889
|
+
params = {"keywords": keywords}
|
|
890
|
+
if limit is not None:
|
|
891
|
+
params["limit"] = str(limit)
|
|
892
|
+
if offset is not None:
|
|
893
|
+
params["offset"] = str(offset)
|
|
894
|
+
|
|
895
|
+
url = self._build_api_url("groups/search")
|
|
896
|
+
|
|
897
|
+
try:
|
|
898
|
+
async with await self._make_authenticated_request(
|
|
899
|
+
"GET", url, params=params
|
|
900
|
+
) as response:
|
|
901
|
+
await self._handle_response(response)
|
|
902
|
+
data = await response.json()
|
|
903
|
+
|
|
904
|
+
# Assuming the search endpoint returns the same structure as list_groups
|
|
905
|
+
group_list = GroupList(**data)
|
|
906
|
+
self.logger.info(
|
|
907
|
+
"Groups searched", keywords=keywords, count=len(group_list.groups)
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
return group_list.groups
|
|
911
|
+
|
|
912
|
+
except Exception as e:
|
|
913
|
+
self.logger.error(
|
|
914
|
+
"Failed to search groups", keywords=keywords, error=str(e)
|
|
915
|
+
)
|
|
916
|
+
raise
|
|
917
|
+
|
|
918
|
+
async def list_files_async(
|
|
919
|
+
self, group_id: str, file_group_id: Optional[str] = None
|
|
920
|
+
) -> FileList:
|
|
921
|
+
"""
|
|
922
|
+
List all files in a group.
|
|
923
|
+
|
|
924
|
+
Args:
|
|
925
|
+
group_id: Group ID to list files for
|
|
926
|
+
file_group_id: Optional specific file ID to filter by
|
|
927
|
+
|
|
928
|
+
Returns:
|
|
929
|
+
FileList with file information
|
|
930
|
+
"""
|
|
931
|
+
params = {"group-id": group_id}
|
|
932
|
+
if file_group_id:
|
|
933
|
+
params["file-group-id"] = file_group_id
|
|
934
|
+
|
|
935
|
+
url = self._build_files_api_url("group/files")
|
|
936
|
+
|
|
937
|
+
try:
|
|
938
|
+
async with await self._make_authenticated_request(
|
|
939
|
+
"GET", url, params=params
|
|
940
|
+
) as response:
|
|
941
|
+
await self._handle_response(response)
|
|
942
|
+
data = await response.json()
|
|
943
|
+
|
|
944
|
+
file_list = FileList(**data)
|
|
945
|
+
self.logger.info(
|
|
946
|
+
"Files listed", group_id=group_id, count=file_list.file_count
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
return file_list
|
|
950
|
+
|
|
951
|
+
except Exception as e:
|
|
952
|
+
self.logger.error("Failed to list files", group_id=group_id, error=str(e))
|
|
953
|
+
raise
|
|
954
|
+
|
|
955
|
+
async def get_file_info_async(self, group_id: str, file_group_id: str) -> FileInfo:
|
|
956
|
+
"""
|
|
957
|
+
Get information about a specific file.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
group_id: Group ID of the file
|
|
961
|
+
file_group_id: File ID of the specific file
|
|
962
|
+
|
|
963
|
+
Returns:
|
|
964
|
+
File information
|
|
965
|
+
"""
|
|
966
|
+
file_list = await self.list_files_async(group_id, file_group_id)
|
|
967
|
+
|
|
968
|
+
if not file_list.file_group_ids:
|
|
969
|
+
raise FileNotFoundError(file_group_id, group_id)
|
|
970
|
+
|
|
971
|
+
return file_list.file_group_ids[0]
|
|
972
|
+
|
|
973
|
+
async def check_availability_async(
|
|
974
|
+
self, file_group_id: str, file_datetime: str
|
|
975
|
+
) -> AvailabilityInfo:
|
|
976
|
+
"""
|
|
977
|
+
Check file availability for a specific datetime.
|
|
978
|
+
|
|
979
|
+
Args:
|
|
980
|
+
file_group_id: File ID to check availability for
|
|
981
|
+
file_datetime: File datetime in YYYYMMDD, YYYYMMDDTHHMM, or YYYYMMDDTHHMMSS format
|
|
982
|
+
|
|
983
|
+
Returns:
|
|
984
|
+
AvailabilityInfo for the requested datetime (or closest entry)
|
|
985
|
+
Raises:
|
|
986
|
+
ValueError: If file_datetime format is invalid
|
|
987
|
+
"""
|
|
988
|
+
validate_file_datetime(file_datetime)
|
|
989
|
+
params = {"file-group-id": file_group_id, "file-datetime": file_datetime}
|
|
990
|
+
|
|
991
|
+
url = self._build_files_api_url("group/file/availability")
|
|
992
|
+
|
|
993
|
+
try:
|
|
994
|
+
async with await self._make_authenticated_request(
|
|
995
|
+
"GET", url, params=params
|
|
996
|
+
) as response:
|
|
997
|
+
await self._handle_response(response)
|
|
998
|
+
data = await response.json()
|
|
999
|
+
# Extract a single availability item matching the requested datetime if present
|
|
1000
|
+
items: List[Dict[str, Any]] = []
|
|
1001
|
+
try:
|
|
1002
|
+
items = data.get("availability") or []
|
|
1003
|
+
except Exception:
|
|
1004
|
+
items = []
|
|
1005
|
+
selected = None
|
|
1006
|
+
for it in items:
|
|
1007
|
+
try:
|
|
1008
|
+
if it.get("file-datetime") == file_datetime:
|
|
1009
|
+
selected = it
|
|
1010
|
+
break
|
|
1011
|
+
except Exception:
|
|
1012
|
+
pass
|
|
1013
|
+
if selected is None:
|
|
1014
|
+
selected = (
|
|
1015
|
+
items[0]
|
|
1016
|
+
if items
|
|
1017
|
+
else {
|
|
1018
|
+
"file-datetime": file_datetime,
|
|
1019
|
+
"is-available": False,
|
|
1020
|
+
"file-name": None,
|
|
1021
|
+
"first-created-on": None,
|
|
1022
|
+
"last-modified": None,
|
|
1023
|
+
}
|
|
1024
|
+
)
|
|
1025
|
+
availability_info = AvailabilityInfo(**selected)
|
|
1026
|
+
self.logger.info(
|
|
1027
|
+
"Availability checked",
|
|
1028
|
+
file_group_id=file_group_id,
|
|
1029
|
+
is_available=availability_info.is_available,
|
|
1030
|
+
)
|
|
1031
|
+
return availability_info
|
|
1032
|
+
|
|
1033
|
+
except Exception as e:
|
|
1034
|
+
self.logger.error(
|
|
1035
|
+
"Failed to check availability",
|
|
1036
|
+
file_group_id=file_group_id,
|
|
1037
|
+
error=str(e),
|
|
1038
|
+
)
|
|
1039
|
+
raise
|
|
1040
|
+
|
|
1041
|
+
async def download_file_async(
|
|
1042
|
+
self,
|
|
1043
|
+
file_group_id: str,
|
|
1044
|
+
file_datetime: Optional[str] = None,
|
|
1045
|
+
options: Optional[DownloadOptions] = None,
|
|
1046
|
+
num_parts: int = 5,
|
|
1047
|
+
progress_callback: Optional[Callable] = None,
|
|
1048
|
+
) -> DownloadResult:
|
|
1049
|
+
"""
|
|
1050
|
+
Download a specific file using parallel HTTP range requests.
|
|
1051
|
+
|
|
1052
|
+
Args:
|
|
1053
|
+
file_group_id: File ID to download
|
|
1054
|
+
file_datetime: Optional datetime of the file (YYYYMMDD, YYYYMMDDTHHMM, or YYYYMMDDTHHMMSS)
|
|
1055
|
+
options: Download options
|
|
1056
|
+
num_parts: Number of parallel parts to split the file into (default 5)
|
|
1057
|
+
progress_callback: Optional progress callback function
|
|
1058
|
+
|
|
1059
|
+
Returns:
|
|
1060
|
+
DownloadResult with download information
|
|
1061
|
+
"""
|
|
1062
|
+
if file_datetime:
|
|
1063
|
+
validate_file_datetime(file_datetime)
|
|
1064
|
+
if options is None:
|
|
1065
|
+
options = DownloadOptions()
|
|
1066
|
+
if num_parts is None or num_parts <= 0:
|
|
1067
|
+
num_parts = 5
|
|
1068
|
+
|
|
1069
|
+
# Build base params
|
|
1070
|
+
params = {"file-group-id": file_group_id}
|
|
1071
|
+
if file_datetime:
|
|
1072
|
+
params["file-datetime"] = file_datetime
|
|
1073
|
+
|
|
1074
|
+
# Determine destination directory like single download method
|
|
1075
|
+
if options.destination_path:
|
|
1076
|
+
dest_path = Path(options.destination_path)
|
|
1077
|
+
if dest_path.suffix:
|
|
1078
|
+
destination_dir = dest_path.parent
|
|
1079
|
+
else:
|
|
1080
|
+
destination_dir = dest_path
|
|
1081
|
+
else:
|
|
1082
|
+
destination_dir = Path(self.config.download_dir)
|
|
1083
|
+
|
|
1084
|
+
if options.create_directories:
|
|
1085
|
+
destination_dir.mkdir(parents=True, exist_ok=True)
|
|
1086
|
+
|
|
1087
|
+
start_time = time.time()
|
|
1088
|
+
bytes_downloaded = 0
|
|
1089
|
+
destination: Optional[Path] = None
|
|
1090
|
+
temp_destination: Optional[Path] = None
|
|
1091
|
+
total_bytes: int = 0
|
|
1092
|
+
shared_fh = None
|
|
1093
|
+
|
|
1094
|
+
try:
|
|
1095
|
+
url = self._build_files_api_url("group/file/download")
|
|
1096
|
+
|
|
1097
|
+
# Probe size with a 1-byte range request
|
|
1098
|
+
probe_headers = {"Range": "bytes=0-0"}
|
|
1099
|
+
async with await self._enter_request_cm(
|
|
1100
|
+
"GET", url, params=params, headers=probe_headers
|
|
1101
|
+
) as probe_resp:
|
|
1102
|
+
await self._handle_response(probe_resp)
|
|
1103
|
+
content_range = probe_resp.headers.get(
|
|
1104
|
+
"content-range"
|
|
1105
|
+
) or probe_resp.headers.get("Content-Range")
|
|
1106
|
+
if content_range and "/" in content_range:
|
|
1107
|
+
try:
|
|
1108
|
+
total_bytes = int(content_range.split("/")[-1])
|
|
1109
|
+
except Exception:
|
|
1110
|
+
total_bytes = int(probe_resp.headers.get("content-length", "0"))
|
|
1111
|
+
else:
|
|
1112
|
+
# Fallback to single-stream download if range not supported
|
|
1113
|
+
return await self._download_file_single_stream(
|
|
1114
|
+
file_group_id=file_group_id,
|
|
1115
|
+
file_datetime=file_datetime,
|
|
1116
|
+
options=options,
|
|
1117
|
+
progress_callback=progress_callback,
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
# If file is small (<10MB), prefer a single-stream download
|
|
1121
|
+
try:
|
|
1122
|
+
ten_mb = 10 * 1024 * 1024
|
|
1123
|
+
if total_bytes and total_bytes < ten_mb:
|
|
1124
|
+
return await self._download_file_single_stream(
|
|
1125
|
+
file_group_id=file_group_id,
|
|
1126
|
+
file_datetime=file_datetime,
|
|
1127
|
+
options=options,
|
|
1128
|
+
progress_callback=progress_callback,
|
|
1129
|
+
)
|
|
1130
|
+
except Exception:
|
|
1131
|
+
pass
|
|
1132
|
+
# Determine filename from headers if available
|
|
1133
|
+
filename = get_filename_from_response(
|
|
1134
|
+
probe_resp, file_group_id, file_datetime
|
|
1135
|
+
)
|
|
1136
|
+
destination = destination_dir / filename
|
|
1137
|
+
|
|
1138
|
+
if destination.exists() and not options.overwrite_existing:
|
|
1139
|
+
raise FileExistsError(f"File already exists: {destination}")
|
|
1140
|
+
|
|
1141
|
+
# Prepare temp file with full size for random access writes
|
|
1142
|
+
if not isinstance(destination, Path):
|
|
1143
|
+
raise ValueError(f"Invalid destination path: {destination}")
|
|
1144
|
+
temp_destination = destination.with_suffix(destination.suffix + ".part")
|
|
1145
|
+
with open(temp_destination, "wb") as f:
|
|
1146
|
+
f.truncate(total_bytes)
|
|
1147
|
+
|
|
1148
|
+
# Compute ranges
|
|
1149
|
+
part_size = total_bytes // num_parts
|
|
1150
|
+
ranges = []
|
|
1151
|
+
start = 0
|
|
1152
|
+
for i in range(num_parts):
|
|
1153
|
+
end = (
|
|
1154
|
+
(start + part_size - 1) if i < num_parts - 1 else (total_bytes - 1)
|
|
1155
|
+
)
|
|
1156
|
+
if start > end:
|
|
1157
|
+
break
|
|
1158
|
+
ranges.append((start, end))
|
|
1159
|
+
start = end + 1
|
|
1160
|
+
|
|
1161
|
+
progress = DownloadProgress(
|
|
1162
|
+
file_group_id=file_group_id,
|
|
1163
|
+
total_bytes=total_bytes,
|
|
1164
|
+
start_time=datetime.now(),
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
bytes_lock = asyncio.Lock()
|
|
1168
|
+
file_lock = asyncio.Lock()
|
|
1169
|
+
# Open a single shared handle for all range writers
|
|
1170
|
+
shared_fh = open(temp_destination, "r+b")
|
|
1171
|
+
|
|
1172
|
+
# Progress callback optimization: track last callback state
|
|
1173
|
+
last_callback_bytes = 0
|
|
1174
|
+
last_callback_time = time.time()
|
|
1175
|
+
callback_threshold_bytes = 1024 * 1024 # 1MB
|
|
1176
|
+
callback_threshold_time = 0.5 # 0.5 seconds
|
|
1177
|
+
|
|
1178
|
+
# Get the current event loop
|
|
1179
|
+
loop = asyncio.get_running_loop()
|
|
1180
|
+
|
|
1181
|
+
async def download_range(start_byte: int, end_byte: int):
|
|
1182
|
+
nonlocal bytes_downloaded, last_callback_bytes, last_callback_time
|
|
1183
|
+
headers = {"Range": f"bytes={start_byte}-{end_byte}"}
|
|
1184
|
+
# each part request
|
|
1185
|
+
async with await self._enter_request_cm(
|
|
1186
|
+
"GET", url, params=params, headers=headers
|
|
1187
|
+
) as resp:
|
|
1188
|
+
await self._handle_response(resp)
|
|
1189
|
+
# Stream and write to correct offset
|
|
1190
|
+
current_pos = start_byte
|
|
1191
|
+
chunk_size = options.chunk_size or 8192
|
|
1192
|
+
async for chunk in resp.content.iter_chunked(chunk_size):
|
|
1193
|
+
async with file_lock:
|
|
1194
|
+
await loop.run_in_executor(
|
|
1195
|
+
None, shared_fh.seek, current_pos
|
|
1196
|
+
)
|
|
1197
|
+
await loop.run_in_executor(None, shared_fh.write, chunk)
|
|
1198
|
+
current_pos += len(chunk)
|
|
1199
|
+
async with bytes_lock:
|
|
1200
|
+
bytes_downloaded += len(chunk)
|
|
1201
|
+
progress.update_progress(bytes_downloaded)
|
|
1202
|
+
|
|
1203
|
+
# Optimized progress callback: only call every 1MB or 0.5s
|
|
1204
|
+
current_time = time.time()
|
|
1205
|
+
bytes_diff = bytes_downloaded - last_callback_bytes
|
|
1206
|
+
time_diff = current_time - last_callback_time
|
|
1207
|
+
|
|
1208
|
+
should_callback = (
|
|
1209
|
+
bytes_diff >= callback_threshold_bytes
|
|
1210
|
+
or time_diff >= callback_threshold_time
|
|
1211
|
+
or bytes_downloaded
|
|
1212
|
+
== total_bytes # Always callback on completion
|
|
1213
|
+
)
|
|
1214
|
+
|
|
1215
|
+
if should_callback:
|
|
1216
|
+
if progress_callback:
|
|
1217
|
+
progress_callback(progress)
|
|
1218
|
+
elif options.show_progress:
|
|
1219
|
+
self.logger.info(
|
|
1220
|
+
"Download progress",
|
|
1221
|
+
file=file_group_id,
|
|
1222
|
+
percentage=f"{progress.percentage:.1f}%",
|
|
1223
|
+
downloaded=format_file_size(bytes_downloaded),
|
|
1224
|
+
)
|
|
1225
|
+
last_callback_bytes = bytes_downloaded
|
|
1226
|
+
last_callback_time = current_time
|
|
1227
|
+
|
|
1228
|
+
# Run all parts concurrently
|
|
1229
|
+
await asyncio.gather(*(download_range(s, e) for s, e in ranges))
|
|
1230
|
+
|
|
1231
|
+
# Ensure all data is flushed and handle is closed before rename
|
|
1232
|
+
try:
|
|
1233
|
+
async with file_lock:
|
|
1234
|
+
shared_fh.flush()
|
|
1235
|
+
finally:
|
|
1236
|
+
try:
|
|
1237
|
+
shared_fh.close()
|
|
1238
|
+
except Exception:
|
|
1239
|
+
pass
|
|
1240
|
+
# Rename temp to final
|
|
1241
|
+
temp_destination.replace(destination)
|
|
1242
|
+
|
|
1243
|
+
download_time = time.time() - start_time
|
|
1244
|
+
return DownloadResult(
|
|
1245
|
+
file_group_id=file_group_id,
|
|
1246
|
+
group_id="",
|
|
1247
|
+
local_path=destination,
|
|
1248
|
+
file_size=total_bytes,
|
|
1249
|
+
download_time=download_time,
|
|
1250
|
+
bytes_downloaded=bytes_downloaded,
|
|
1251
|
+
status=DownloadStatus.COMPLETED,
|
|
1252
|
+
error_message=None,
|
|
1253
|
+
)
|
|
1254
|
+
except Exception as e:
|
|
1255
|
+
try:
|
|
1256
|
+
# Attempt salvage: if temp file exists and appears complete, finalize it
|
|
1257
|
+
if temp_destination and temp_destination.exists():
|
|
1258
|
+
try:
|
|
1259
|
+
# Ensure any open handle is closed
|
|
1260
|
+
if shared_fh:
|
|
1261
|
+
try:
|
|
1262
|
+
shared_fh.flush()
|
|
1263
|
+
except Exception:
|
|
1264
|
+
pass
|
|
1265
|
+
try:
|
|
1266
|
+
shared_fh.close()
|
|
1267
|
+
except Exception:
|
|
1268
|
+
pass
|
|
1269
|
+
if (
|
|
1270
|
+
total_bytes
|
|
1271
|
+
and temp_destination.stat().st_size >= total_bytes
|
|
1272
|
+
):
|
|
1273
|
+
if destination is None:
|
|
1274
|
+
destination = temp_destination.with_suffix("")
|
|
1275
|
+
temp_destination.replace(destination)
|
|
1276
|
+
return DownloadResult(
|
|
1277
|
+
file_group_id=file_group_id,
|
|
1278
|
+
group_id="",
|
|
1279
|
+
local_path=destination,
|
|
1280
|
+
file_size=total_bytes,
|
|
1281
|
+
download_time=time.time() - start_time,
|
|
1282
|
+
bytes_downloaded=max(bytes_downloaded, total_bytes),
|
|
1283
|
+
status=DownloadStatus.COMPLETED,
|
|
1284
|
+
error_message=None,
|
|
1285
|
+
)
|
|
1286
|
+
else:
|
|
1287
|
+
temp_destination.unlink()
|
|
1288
|
+
except Exception:
|
|
1289
|
+
temp_destination.unlink()
|
|
1290
|
+
except Exception:
|
|
1291
|
+
pass
|
|
1292
|
+
return DownloadResult(
|
|
1293
|
+
file_group_id=file_group_id,
|
|
1294
|
+
group_id="",
|
|
1295
|
+
local_path=destination
|
|
1296
|
+
or (Path(self.config.download_dir) / f"{file_group_id}.tmp"),
|
|
1297
|
+
file_size=0,
|
|
1298
|
+
download_time=time.time() - start_time,
|
|
1299
|
+
bytes_downloaded=bytes_downloaded,
|
|
1300
|
+
status=DownloadStatus.FAILED,
|
|
1301
|
+
error_message=f"{type(e).__name__}: {e}",
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
async def _download_file_single_stream(
|
|
1305
|
+
self,
|
|
1306
|
+
file_group_id: str,
|
|
1307
|
+
file_datetime: Optional[str] = None,
|
|
1308
|
+
options: Optional[DownloadOptions] = None,
|
|
1309
|
+
progress_callback: Optional[Callable] = None,
|
|
1310
|
+
) -> DownloadResult:
|
|
1311
|
+
"""
|
|
1312
|
+
Download a specific file using single-stream (non-parallel) method.
|
|
1313
|
+
|
|
1314
|
+
Args:
|
|
1315
|
+
file_group_id: File ID to download
|
|
1316
|
+
file_datetime: Optional datetime of the file (YYYYMMDD, YYYYMMDDTHHMM, or YYYYMMDDTHHMMSS)
|
|
1317
|
+
options: Download options
|
|
1318
|
+
progress_callback: Optional progress callback function
|
|
1319
|
+
|
|
1320
|
+
Returns:
|
|
1321
|
+
DownloadResult with download information
|
|
1322
|
+
"""
|
|
1323
|
+
if file_datetime:
|
|
1324
|
+
validate_file_datetime(file_datetime)
|
|
1325
|
+
if options is None:
|
|
1326
|
+
options = DownloadOptions()
|
|
1327
|
+
|
|
1328
|
+
params = {"file-group-id": file_group_id}
|
|
1329
|
+
|
|
1330
|
+
if file_datetime:
|
|
1331
|
+
params["file-datetime"] = file_datetime
|
|
1332
|
+
|
|
1333
|
+
# Add range parameters if specified
|
|
1334
|
+
if options.range_header:
|
|
1335
|
+
headers = {"Range": options.range_header}
|
|
1336
|
+
elif options.range_start is not None:
|
|
1337
|
+
range_end = options.range_end if options.range_end is not None else ""
|
|
1338
|
+
headers = {"Range": f"bytes={options.range_start}-{range_end}"}
|
|
1339
|
+
else:
|
|
1340
|
+
headers = {}
|
|
1341
|
+
|
|
1342
|
+
# Determine destination directory
|
|
1343
|
+
if options.destination_path:
|
|
1344
|
+
# If destination_path is a file path, use its parent directory
|
|
1345
|
+
dest_path = Path(options.destination_path)
|
|
1346
|
+
if dest_path.suffix: # Has file extension, treat as file path
|
|
1347
|
+
destination_dir = dest_path.parent
|
|
1348
|
+
# We'll get the filename from Content-Disposition header
|
|
1349
|
+
else: # No extension, treat as directory
|
|
1350
|
+
destination_dir = dest_path
|
|
1351
|
+
else:
|
|
1352
|
+
destination_dir = Path(self.config.download_dir)
|
|
1353
|
+
|
|
1354
|
+
# Create directories if needed
|
|
1355
|
+
if options.create_directories:
|
|
1356
|
+
destination_dir.mkdir(parents=True, exist_ok=True)
|
|
1357
|
+
|
|
1358
|
+
start_time = time.time()
|
|
1359
|
+
bytes_downloaded = 0
|
|
1360
|
+
destination = None # Initialize destination variable
|
|
1361
|
+
|
|
1362
|
+
try:
|
|
1363
|
+
url = self._build_files_api_url("group/file/download")
|
|
1364
|
+
|
|
1365
|
+
# Support either an awaitable that yields a context manager, or a context manager directly
|
|
1366
|
+
async with await self._make_authenticated_request(
|
|
1367
|
+
"GET", url, params=params, headers=headers
|
|
1368
|
+
) as response:
|
|
1369
|
+
await self._handle_response(response)
|
|
1370
|
+
|
|
1371
|
+
# Extract filename from Content-Disposition header or generate one
|
|
1372
|
+
filename = get_filename_from_response(
|
|
1373
|
+
response, file_group_id, file_datetime
|
|
1374
|
+
)
|
|
1375
|
+
destination = destination_dir / filename
|
|
1376
|
+
|
|
1377
|
+
# Check if file exists and handle overwrite
|
|
1378
|
+
if (
|
|
1379
|
+
isinstance(destination, Path)
|
|
1380
|
+
and destination.exists()
|
|
1381
|
+
and not options.overwrite_existing
|
|
1382
|
+
):
|
|
1383
|
+
raise FileExistsError(f"File already exists: {destination}")
|
|
1384
|
+
|
|
1385
|
+
# Get content length for progress tracking
|
|
1386
|
+
content_length = response.headers.get("content-length")
|
|
1387
|
+
total_bytes = int(content_length) if content_length else 0
|
|
1388
|
+
|
|
1389
|
+
# Initialize progress tracking
|
|
1390
|
+
progress = DownloadProgress(
|
|
1391
|
+
file_group_id=file_group_id,
|
|
1392
|
+
total_bytes=total_bytes,
|
|
1393
|
+
start_time=datetime.now(),
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
# Download file with optimized progress tracking
|
|
1397
|
+
if not isinstance(destination, Path):
|
|
1398
|
+
raise ValueError(f"Invalid destination path: {destination}")
|
|
1399
|
+
# Write to a temp file first, then atomically rename upon success
|
|
1400
|
+
temp_destination = destination.with_suffix(destination.suffix + ".part")
|
|
1401
|
+
|
|
1402
|
+
# Optimize chunk size based on file size
|
|
1403
|
+
chunk_size = options.chunk_size or 8192
|
|
1404
|
+
if total_bytes > 0:
|
|
1405
|
+
# Use larger chunks for bigger files, but cap at 1MB
|
|
1406
|
+
optimal_chunk_size = min(
|
|
1407
|
+
max(chunk_size, total_bytes // 1000), 1024 * 1024
|
|
1408
|
+
)
|
|
1409
|
+
chunk_size = optimal_chunk_size
|
|
1410
|
+
|
|
1411
|
+
# Progress update frequency optimization
|
|
1412
|
+
progress_update_interval = max(
|
|
1413
|
+
1, chunk_size // 4
|
|
1414
|
+
) # Update every 1/4 chunk
|
|
1415
|
+
last_progress_update = 0
|
|
1416
|
+
|
|
1417
|
+
with open(temp_destination, "wb") as f:
|
|
1418
|
+
async for chunk in response.content.iter_chunked(chunk_size):
|
|
1419
|
+
f.write(chunk)
|
|
1420
|
+
bytes_downloaded += len(chunk)
|
|
1421
|
+
|
|
1422
|
+
# Update progress less frequently for better performance
|
|
1423
|
+
if (
|
|
1424
|
+
bytes_downloaded - last_progress_update
|
|
1425
|
+
>= progress_update_interval
|
|
1426
|
+
):
|
|
1427
|
+
progress.update_progress(bytes_downloaded)
|
|
1428
|
+
last_progress_update = bytes_downloaded
|
|
1429
|
+
|
|
1430
|
+
# Call progress callback
|
|
1431
|
+
if progress_callback:
|
|
1432
|
+
progress_callback(progress)
|
|
1433
|
+
elif options.show_progress:
|
|
1434
|
+
self.logger.info(
|
|
1435
|
+
"Download progress",
|
|
1436
|
+
file=file_group_id,
|
|
1437
|
+
percentage=f"{progress.percentage:.1f}%",
|
|
1438
|
+
downloaded=format_file_size(bytes_downloaded),
|
|
1439
|
+
)
|
|
1440
|
+
|
|
1441
|
+
# Final progress update
|
|
1442
|
+
progress.update_progress(bytes_downloaded)
|
|
1443
|
+
|
|
1444
|
+
download_time = time.time() - start_time
|
|
1445
|
+
|
|
1446
|
+
# Atomic rename to final destination after successful write
|
|
1447
|
+
temp_destination.replace(destination)
|
|
1448
|
+
|
|
1449
|
+
return DownloadResult(
|
|
1450
|
+
file_group_id=file_group_id,
|
|
1451
|
+
group_id="", # Not available in new API
|
|
1452
|
+
local_path=(
|
|
1453
|
+
destination
|
|
1454
|
+
if isinstance(destination, Path)
|
|
1455
|
+
else Path(self.config.download_dir) / f"{file_group_id}.tmp"
|
|
1456
|
+
),
|
|
1457
|
+
file_size=bytes_downloaded,
|
|
1458
|
+
download_time=download_time,
|
|
1459
|
+
bytes_downloaded=bytes_downloaded,
|
|
1460
|
+
status=DownloadStatus.COMPLETED,
|
|
1461
|
+
error_message=None,
|
|
1462
|
+
)
|
|
1463
|
+
|
|
1464
|
+
except Exception as e:
|
|
1465
|
+
# Clean up partial file on error
|
|
1466
|
+
try:
|
|
1467
|
+
if (
|
|
1468
|
+
"temp_destination" in locals()
|
|
1469
|
+
and isinstance(temp_destination, Path)
|
|
1470
|
+
and temp_destination.exists()
|
|
1471
|
+
):
|
|
1472
|
+
temp_destination.unlink()
|
|
1473
|
+
except Exception:
|
|
1474
|
+
pass
|
|
1475
|
+
|
|
1476
|
+
return DownloadResult(
|
|
1477
|
+
file_group_id=file_group_id,
|
|
1478
|
+
group_id="", # Not available in new API
|
|
1479
|
+
local_path=destination
|
|
1480
|
+
or Path(self.config.download_dir) / f"{file_group_id}.tmp",
|
|
1481
|
+
file_size=0,
|
|
1482
|
+
download_time=time.time() - start_time,
|
|
1483
|
+
bytes_downloaded=bytes_downloaded,
|
|
1484
|
+
status=DownloadStatus.FAILED,
|
|
1485
|
+
error_message=f"{type(e).__name__}: {e}",
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
async def list_available_files_async(
|
|
1489
|
+
self,
|
|
1490
|
+
group_id: str,
|
|
1491
|
+
file_group_id: Optional[str] = None,
|
|
1492
|
+
start_date: Optional[str] = None,
|
|
1493
|
+
end_date: Optional[str] = None,
|
|
1494
|
+
) -> List[Dict[str, Any]]:
|
|
1495
|
+
"""
|
|
1496
|
+
List available files by date range.
|
|
1497
|
+
|
|
1498
|
+
Args:
|
|
1499
|
+
group_id: Group ID to list files for
|
|
1500
|
+
file_group_id: Optional specific file ID to filter by
|
|
1501
|
+
start_date: Optional start date in YYYYMMDD format
|
|
1502
|
+
end_date: Optional end date in YYYYMMDD format
|
|
1503
|
+
|
|
1504
|
+
Returns:
|
|
1505
|
+
List of available file information
|
|
1506
|
+
"""
|
|
1507
|
+
params = {"group-id": group_id}
|
|
1508
|
+
if file_group_id:
|
|
1509
|
+
params["file-group-id"] = file_group_id
|
|
1510
|
+
if start_date:
|
|
1511
|
+
params["start-date"] = start_date
|
|
1512
|
+
if end_date:
|
|
1513
|
+
params["end-date"] = end_date
|
|
1514
|
+
|
|
1515
|
+
url = self._build_files_api_url("group/files/available-files")
|
|
1516
|
+
|
|
1517
|
+
try:
|
|
1518
|
+
async with await self._make_authenticated_request(
|
|
1519
|
+
"GET", url, params=params
|
|
1520
|
+
) as response:
|
|
1521
|
+
await self._handle_response(response)
|
|
1522
|
+
data = await response.json()
|
|
1523
|
+
|
|
1524
|
+
available_files = data.get("available-files", [])
|
|
1525
|
+
self.logger.info(
|
|
1526
|
+
"Available files listed",
|
|
1527
|
+
group_id=group_id,
|
|
1528
|
+
count=len(available_files),
|
|
1529
|
+
)
|
|
1530
|
+
|
|
1531
|
+
return available_files
|
|
1532
|
+
|
|
1533
|
+
except Exception as e:
|
|
1534
|
+
self.logger.error(
|
|
1535
|
+
"Failed to list available files", group_id=group_id, error=str(e)
|
|
1536
|
+
)
|
|
1537
|
+
raise
|
|
1538
|
+
|
|
1539
|
+
async def health_check_async(self) -> bool:
|
|
1540
|
+
"""Check if the DataQuery service is available."""
|
|
1541
|
+
try:
|
|
1542
|
+
url = self._build_api_url("services/heartbeat")
|
|
1543
|
+
async with await self._make_authenticated_request("GET", url) as response:
|
|
1544
|
+
return response.status == 200
|
|
1545
|
+
except Exception as e:
|
|
1546
|
+
logger.error("Health check failed", error=str(e))
|
|
1547
|
+
return False
|
|
1548
|
+
|
|
1549
|
+
# Instrument Collection Endpoints
|
|
1550
|
+
async def list_instruments_async(
|
|
1551
|
+
self,
|
|
1552
|
+
group_id: str,
|
|
1553
|
+
instrument_id: Optional[str] = None,
|
|
1554
|
+
page: Optional[str] = None,
|
|
1555
|
+
) -> "InstrumentsResponse":
|
|
1556
|
+
"""
|
|
1557
|
+
Request the complete list of instruments and identifiers for a given dataset.
|
|
1558
|
+
|
|
1559
|
+
Args:
|
|
1560
|
+
group_id: Catalog data group identifier
|
|
1561
|
+
instrument_id: Optional instrument identifier to filter results
|
|
1562
|
+
page: Optional page token for pagination
|
|
1563
|
+
|
|
1564
|
+
Returns:
|
|
1565
|
+
InstrumentsResponse containing the list of instruments
|
|
1566
|
+
"""
|
|
1567
|
+
params = {"group-id": group_id}
|
|
1568
|
+
if instrument_id:
|
|
1569
|
+
params["instrument-id"] = instrument_id
|
|
1570
|
+
if page:
|
|
1571
|
+
params["page"] = page
|
|
1572
|
+
|
|
1573
|
+
url = self._build_api_url("group/instruments")
|
|
1574
|
+
async with await self._enter_request_cm("GET", url, params=params) as response:
|
|
1575
|
+
await self._handle_response(response)
|
|
1576
|
+
data = await response.json()
|
|
1577
|
+
return InstrumentsResponse(**data)
|
|
1578
|
+
|
|
1579
|
+
async def search_instruments_async(
|
|
1580
|
+
self, group_id: str, keywords: str, page: Optional[str] = None
|
|
1581
|
+
) -> "InstrumentsResponse":
|
|
1582
|
+
"""
|
|
1583
|
+
Search within a dataset using keywords to create subsets of matching instruments.
|
|
1584
|
+
|
|
1585
|
+
Args:
|
|
1586
|
+
group_id: Catalog data group identifier
|
|
1587
|
+
keywords: Keywords to narrow scope of results
|
|
1588
|
+
page: Optional page token for pagination
|
|
1589
|
+
|
|
1590
|
+
Returns:
|
|
1591
|
+
InstrumentsResponse containing the matching instruments
|
|
1592
|
+
"""
|
|
1593
|
+
params = {"group-id": group_id, "keywords": keywords}
|
|
1594
|
+
if page:
|
|
1595
|
+
params["page"] = page
|
|
1596
|
+
|
|
1597
|
+
url = self._build_api_url("group/instruments/search")
|
|
1598
|
+
async with await self._enter_request_cm("GET", url, params=params) as response:
|
|
1599
|
+
await self._handle_response(response)
|
|
1600
|
+
data = await response.json()
|
|
1601
|
+
return InstrumentsResponse(**data)
|
|
1602
|
+
|
|
1603
|
+
async def get_instrument_time_series_async(
|
|
1604
|
+
self,
|
|
1605
|
+
instruments: List[str],
|
|
1606
|
+
attributes: List[str],
|
|
1607
|
+
data: str = "REFERENCE_DATA",
|
|
1608
|
+
format: str = "JSON",
|
|
1609
|
+
start_date: Optional[str] = None,
|
|
1610
|
+
end_date: Optional[str] = None,
|
|
1611
|
+
calendar: str = "CAL_USBANK",
|
|
1612
|
+
frequency: str = "FREQ_DAY",
|
|
1613
|
+
conversion: str = "CONV_LASTBUS_ABS",
|
|
1614
|
+
nan_treatment: str = "NA_NOTHING",
|
|
1615
|
+
page: Optional[str] = None,
|
|
1616
|
+
) -> "TimeSeriesResponse":
|
|
1617
|
+
"""
|
|
1618
|
+
Retrieve time-series data for explicit list of instruments and attributes using identifiers.
|
|
1619
|
+
|
|
1620
|
+
Args:
|
|
1621
|
+
instruments: List of instrument identifiers (max 20)
|
|
1622
|
+
attributes: List of attribute identifiers
|
|
1623
|
+
data: Data type (REFERENCE_DATA, NO_REFERENCE_DATA, ALL)
|
|
1624
|
+
format: Response format (JSON only)
|
|
1625
|
+
start_date: Start date (YYYYMMDD, TODAY, TODAY-Nx)
|
|
1626
|
+
end_date: End date (YYYYMMDD, TODAY, TODAY-Nx)
|
|
1627
|
+
calendar: Calendar convention
|
|
1628
|
+
frequency: Frequency convention
|
|
1629
|
+
conversion: Conversion convention
|
|
1630
|
+
nan_treatment: Missing data treatment
|
|
1631
|
+
page: Optional page token
|
|
1632
|
+
|
|
1633
|
+
Returns:
|
|
1634
|
+
TimeSeriesResponse containing the time series data
|
|
1635
|
+
|
|
1636
|
+
Raises:
|
|
1637
|
+
ValidationError: If parameters are invalid
|
|
1638
|
+
"""
|
|
1639
|
+
# Validate required parameters
|
|
1640
|
+
validate_instruments_list(instruments)
|
|
1641
|
+
validate_attributes_list(attributes)
|
|
1642
|
+
|
|
1643
|
+
# Validate optional date parameters
|
|
1644
|
+
if start_date is not None:
|
|
1645
|
+
validate_date_format(start_date, "start-date")
|
|
1646
|
+
if end_date is not None:
|
|
1647
|
+
validate_date_format(end_date, "end-date")
|
|
1648
|
+
"""
|
|
1649
|
+
Retrieve time-series data for explicit list of instruments and attributes using identifiers.
|
|
1650
|
+
|
|
1651
|
+
Args:
|
|
1652
|
+
instruments: List of instrument identifiers
|
|
1653
|
+
attributes: List of attribute identifiers
|
|
1654
|
+
data: Data type (REFERENCE_DATA, NO_REFERENCE_DATA, ALL)
|
|
1655
|
+
format: Response format (JSON)
|
|
1656
|
+
start_date: Start date in YYYYMMDD or TODAY-Nx format
|
|
1657
|
+
end_date: End date in YYYYMMDD or TODAY-Nx format
|
|
1658
|
+
calendar: Calendar convention
|
|
1659
|
+
frequency: Frequency convention
|
|
1660
|
+
conversion: Conversion convention
|
|
1661
|
+
nan_treatment: Missing data treatment
|
|
1662
|
+
page: Optional page token for pagination
|
|
1663
|
+
|
|
1664
|
+
Returns:
|
|
1665
|
+
TimeSeriesResponse containing the time series data
|
|
1666
|
+
"""
|
|
1667
|
+
params = {
|
|
1668
|
+
"instruments": instruments,
|
|
1669
|
+
"attributes": attributes,
|
|
1670
|
+
"data": data,
|
|
1671
|
+
"format": format,
|
|
1672
|
+
"calendar": calendar,
|
|
1673
|
+
"frequency": frequency,
|
|
1674
|
+
"conversion": conversion,
|
|
1675
|
+
"nan-treatment": nan_treatment,
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
if start_date is not None:
|
|
1679
|
+
params["start-date"] = start_date
|
|
1680
|
+
if end_date is not None:
|
|
1681
|
+
params["end-date"] = end_date
|
|
1682
|
+
if page is not None:
|
|
1683
|
+
params["page"] = page
|
|
1684
|
+
|
|
1685
|
+
url = self._build_api_url("instruments/time-series")
|
|
1686
|
+
async with await self._enter_request_cm("GET", url, params=params) as response:
|
|
1687
|
+
await self._handle_response(response)
|
|
1688
|
+
payload = await response.json()
|
|
1689
|
+
return TimeSeriesResponse(**payload)
|
|
1690
|
+
|
|
1691
|
+
async def get_expressions_time_series_async(
|
|
1692
|
+
self,
|
|
1693
|
+
expressions: List[str],
|
|
1694
|
+
format: str = "JSON",
|
|
1695
|
+
start_date: Optional[str] = None,
|
|
1696
|
+
end_date: Optional[str] = None,
|
|
1697
|
+
calendar: str = "CAL_USBANK",
|
|
1698
|
+
frequency: str = "FREQ_DAY",
|
|
1699
|
+
conversion: str = "CONV_LASTBUS_ABS",
|
|
1700
|
+
nan_treatment: str = "NA_NOTHING",
|
|
1701
|
+
data: str = "REFERENCE_DATA",
|
|
1702
|
+
page: Optional[str] = None,
|
|
1703
|
+
) -> "TimeSeriesResponse":
|
|
1704
|
+
"""
|
|
1705
|
+
Retrieve time-series data using an explicit list of traditional DataQuery expressions.
|
|
1706
|
+
|
|
1707
|
+
Args:
|
|
1708
|
+
expressions: List of traditional DataQuery expressions
|
|
1709
|
+
format: Response format (JSON)
|
|
1710
|
+
start_date: Start date in YYYYMMDD or TODAY-Nx format
|
|
1711
|
+
end_date: End date in YYYYMMDD or TODAY-Nx format
|
|
1712
|
+
calendar: Calendar convention
|
|
1713
|
+
frequency: Frequency convention
|
|
1714
|
+
conversion: Conversion convention
|
|
1715
|
+
nan_treatment: Missing data treatment
|
|
1716
|
+
data: Data type (REFERENCE_DATA, NO_REFERENCE_DATA, ALL)
|
|
1717
|
+
page: Optional page token for pagination
|
|
1718
|
+
|
|
1719
|
+
Returns:
|
|
1720
|
+
TimeSeriesResponse containing the time series data
|
|
1721
|
+
"""
|
|
1722
|
+
params = {
|
|
1723
|
+
"expressions": expressions,
|
|
1724
|
+
"format": format,
|
|
1725
|
+
"calendar": calendar,
|
|
1726
|
+
"frequency": frequency,
|
|
1727
|
+
"conversion": conversion,
|
|
1728
|
+
"nan-treatment": nan_treatment,
|
|
1729
|
+
"data": data,
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
if start_date is not None:
|
|
1733
|
+
params["start-date"] = start_date
|
|
1734
|
+
if end_date is not None:
|
|
1735
|
+
params["end-date"] = end_date
|
|
1736
|
+
if page is not None:
|
|
1737
|
+
params["page"] = page
|
|
1738
|
+
|
|
1739
|
+
url = self._build_api_url("expressions/time-series")
|
|
1740
|
+
async with await self._enter_request_cm("GET", url, params=params) as response:
|
|
1741
|
+
await self._handle_response(response)
|
|
1742
|
+
payload = await response.json()
|
|
1743
|
+
return TimeSeriesResponse(**payload)
|
|
1744
|
+
|
|
1745
|
+
# Group Collection Additional Endpoints
|
|
1746
|
+
async def get_group_filters_async(
|
|
1747
|
+
self, group_id: str, page: Optional[str] = None
|
|
1748
|
+
) -> "FiltersResponse":
|
|
1749
|
+
"""
|
|
1750
|
+
Request the unique list of filter dimensions that are available for a given dataset.
|
|
1751
|
+
|
|
1752
|
+
Args:
|
|
1753
|
+
group_id: Catalog data group identifier
|
|
1754
|
+
page: Optional page token for pagination
|
|
1755
|
+
|
|
1756
|
+
Returns:
|
|
1757
|
+
FiltersResponse containing the available filters
|
|
1758
|
+
"""
|
|
1759
|
+
params = {"group-id": group_id}
|
|
1760
|
+
if page:
|
|
1761
|
+
params["page"] = page
|
|
1762
|
+
|
|
1763
|
+
url = self._build_api_url("group/filters")
|
|
1764
|
+
async with await self._enter_request_cm("GET", url, params=params) as response:
|
|
1765
|
+
await self._handle_response(response)
|
|
1766
|
+
payload = await response.json()
|
|
1767
|
+
return FiltersResponse(**payload)
|
|
1768
|
+
|
|
1769
|
+
async def get_group_attributes_async(
|
|
1770
|
+
self,
|
|
1771
|
+
group_id: str,
|
|
1772
|
+
instrument_id: Optional[str] = None,
|
|
1773
|
+
page: Optional[str] = None,
|
|
1774
|
+
) -> "AttributesResponse":
|
|
1775
|
+
"""
|
|
1776
|
+
Request the unique list of analytic attributes for each instrument of a given dataset.
|
|
1777
|
+
|
|
1778
|
+
Args:
|
|
1779
|
+
group_id: Catalog data group identifier
|
|
1780
|
+
instrument_id: Optional instrument identifier to filter results
|
|
1781
|
+
page: Optional page token for pagination
|
|
1782
|
+
|
|
1783
|
+
Returns:
|
|
1784
|
+
AttributesResponse containing the attributes for each instrument
|
|
1785
|
+
"""
|
|
1786
|
+
params = {"group-id": group_id}
|
|
1787
|
+
if instrument_id:
|
|
1788
|
+
params["instrument-id"] = instrument_id
|
|
1789
|
+
if page:
|
|
1790
|
+
params["page"] = page
|
|
1791
|
+
|
|
1792
|
+
url = self._build_api_url("group/attributes")
|
|
1793
|
+
async with await self._enter_request_cm("GET", url, params=params) as response:
|
|
1794
|
+
await self._handle_response(response)
|
|
1795
|
+
payload = await response.json()
|
|
1796
|
+
return AttributesResponse(**payload)
|
|
1797
|
+
|
|
1798
|
+
async def get_group_time_series_async(
|
|
1799
|
+
self,
|
|
1800
|
+
group_id: str,
|
|
1801
|
+
attributes: List[str],
|
|
1802
|
+
filter: Optional[str] = None,
|
|
1803
|
+
data: str = "REFERENCE_DATA",
|
|
1804
|
+
format: str = "JSON",
|
|
1805
|
+
start_date: Optional[str] = None,
|
|
1806
|
+
end_date: Optional[str] = None,
|
|
1807
|
+
calendar: str = "CAL_USBANK",
|
|
1808
|
+
frequency: str = "FREQ_DAY",
|
|
1809
|
+
conversion: str = "CONV_LASTBUS_ABS",
|
|
1810
|
+
nan_treatment: str = "NA_NOTHING",
|
|
1811
|
+
page: Optional[str] = None,
|
|
1812
|
+
) -> "TimeSeriesResponse":
|
|
1813
|
+
"""
|
|
1814
|
+
Request time-series data across a subset of instruments and analytics of a given dataset.
|
|
1815
|
+
|
|
1816
|
+
Args:
|
|
1817
|
+
group_id: Catalog data group identifier
|
|
1818
|
+
attributes: List of attribute identifiers
|
|
1819
|
+
filter: Optional filter string (e.g., "currency(USD)")
|
|
1820
|
+
data: Data type (REFERENCE_DATA, NO_REFERENCE_DATA, ALL)
|
|
1821
|
+
format: Response format (JSON)
|
|
1822
|
+
start_date: Start date in YYYYMMDD or TODAY-Nx format
|
|
1823
|
+
end_date: End date in YYYYMMDD or TODAY-Nx format
|
|
1824
|
+
calendar: Calendar convention
|
|
1825
|
+
frequency: Frequency convention
|
|
1826
|
+
conversion: Conversion convention
|
|
1827
|
+
nan_treatment: Missing data treatment
|
|
1828
|
+
page: Optional page token for pagination
|
|
1829
|
+
|
|
1830
|
+
Returns:
|
|
1831
|
+
TimeSeriesResponse containing the time series data
|
|
1832
|
+
"""
|
|
1833
|
+
params = {
|
|
1834
|
+
"group-id": group_id,
|
|
1835
|
+
"attributes": attributes,
|
|
1836
|
+
"data": data,
|
|
1837
|
+
"format": format,
|
|
1838
|
+
"calendar": calendar,
|
|
1839
|
+
"frequency": frequency,
|
|
1840
|
+
"conversion": conversion,
|
|
1841
|
+
"nan-treatment": nan_treatment,
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
if filter is not None:
|
|
1845
|
+
params["filter"] = filter
|
|
1846
|
+
if start_date is not None:
|
|
1847
|
+
params["start-date"] = start_date
|
|
1848
|
+
if end_date is not None:
|
|
1849
|
+
params["end-date"] = end_date
|
|
1850
|
+
if page is not None:
|
|
1851
|
+
params["page"] = page
|
|
1852
|
+
|
|
1853
|
+
url = self._build_api_url("group/time-series")
|
|
1854
|
+
async with await self._enter_request_cm("GET", url, params=params) as response:
|
|
1855
|
+
await self._handle_response(response)
|
|
1856
|
+
payload = await response.json()
|
|
1857
|
+
return TimeSeriesResponse(**payload)
|
|
1858
|
+
|
|
1859
|
+
# Grid Collection Endpoints
|
|
1860
|
+
async def get_grid_data_async(
|
|
1861
|
+
self,
|
|
1862
|
+
expr: Optional[str] = None,
|
|
1863
|
+
grid_id: Optional[str] = None,
|
|
1864
|
+
date: Optional[str] = None,
|
|
1865
|
+
) -> "GridDataResponse":
|
|
1866
|
+
"""
|
|
1867
|
+
Retrieve grid data using an expression or a grid ID.
|
|
1868
|
+
|
|
1869
|
+
Args:
|
|
1870
|
+
expr: The grid expression (mutually exclusive with grid_id)
|
|
1871
|
+
grid_id: The grid ID (mutually exclusive with expr)
|
|
1872
|
+
date: Optional specific snapshot date in YYYYMMDD format
|
|
1873
|
+
|
|
1874
|
+
Returns:
|
|
1875
|
+
GridDataResponse containing the grid data
|
|
1876
|
+
|
|
1877
|
+
Raises:
|
|
1878
|
+
ValueError: If both expr and grid_id are provided or neither is provided
|
|
1879
|
+
"""
|
|
1880
|
+
if expr and grid_id:
|
|
1881
|
+
raise ValueError("Cannot specify both expr and grid_id")
|
|
1882
|
+
if not expr and not grid_id:
|
|
1883
|
+
raise ValueError("Must specify either expr or grid_id")
|
|
1884
|
+
|
|
1885
|
+
params = {}
|
|
1886
|
+
if expr is not None:
|
|
1887
|
+
params["expr"] = expr
|
|
1888
|
+
if grid_id is not None:
|
|
1889
|
+
params["gridId"] = grid_id
|
|
1890
|
+
if date is not None:
|
|
1891
|
+
params["date"] = date
|
|
1892
|
+
|
|
1893
|
+
url = self._build_api_url("grid-data")
|
|
1894
|
+
async with await self._enter_request_cm("GET", url, params=params) as response:
|
|
1895
|
+
await self._handle_response(response)
|
|
1896
|
+
payload = await response.json()
|
|
1897
|
+
return GridDataResponse(**payload)
|
|
1898
|
+
|
|
1899
|
+
def get_pool_stats(self) -> Dict[str, Any]:
|
|
1900
|
+
"""Get connection pool statistics including active, idle, and total connections."""
|
|
1901
|
+
if hasattr(self, "_connection_pool") and self._connection_pool:
|
|
1902
|
+
# For test compatibility
|
|
1903
|
+
return self._connection_pool.get_stats()
|
|
1904
|
+
elif hasattr(self, "pool_monitor"):
|
|
1905
|
+
stats = self.pool_monitor.get_pool_summary()
|
|
1906
|
+
# Add 'idle' key if not present for backward compatibility
|
|
1907
|
+
if "idle" not in stats and "connections" in stats:
|
|
1908
|
+
stats["idle"] = stats["connections"].get("idle", 0)
|
|
1909
|
+
return stats
|
|
1910
|
+
return {"error": "Pool monitor not available"}
|
|
1911
|
+
|
|
1912
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
1913
|
+
"""Get comprehensive client statistics."""
|
|
1914
|
+
return {
|
|
1915
|
+
"config": {
|
|
1916
|
+
"base_url": self.config.base_url,
|
|
1917
|
+
"timeout": self.config.timeout,
|
|
1918
|
+
"max_retries": self.config.max_retries,
|
|
1919
|
+
"download_dir": self.config.download_dir,
|
|
1920
|
+
},
|
|
1921
|
+
"client_config": {
|
|
1922
|
+
"base_url": self.config.base_url,
|
|
1923
|
+
"timeout": self.config.timeout,
|
|
1924
|
+
"max_retries": self.config.max_retries,
|
|
1925
|
+
"download_dir": self.config.download_dir,
|
|
1926
|
+
},
|
|
1927
|
+
"rate_limiter": self.rate_limiter.get_stats(),
|
|
1928
|
+
"retry_manager": self.retry_manager.get_stats(),
|
|
1929
|
+
"connection_pool": self.pool_monitor.get_stats(),
|
|
1930
|
+
"auth_info": self.auth_manager.get_auth_info(),
|
|
1931
|
+
"connected": self.session is not None
|
|
1932
|
+
and not getattr(self.session, "closed", True),
|
|
1933
|
+
}
|
|
1934
|
+
|
|
1935
|
+
async def _ensure_connected(self):
|
|
1936
|
+
"""Ensure client is connected."""
|
|
1937
|
+
if self.session is None or (
|
|
1938
|
+
hasattr(self.session, "closed") and self.session.closed
|
|
1939
|
+
):
|
|
1940
|
+
await self.connect()
|
|
1941
|
+
|
|
1942
|
+
async def _handle_response(self, response: aiohttp.ClientResponse):
|
|
1943
|
+
"""Handle HTTP response and raise appropriate exceptions."""
|
|
1944
|
+
# Extract and log interaction ID for traceability
|
|
1945
|
+
interaction_id = response.headers.get("x-dataquery-interaction-id")
|
|
1946
|
+
if interaction_id:
|
|
1947
|
+
self.logger.info(
|
|
1948
|
+
"DataQuery interaction",
|
|
1949
|
+
interaction_id=interaction_id,
|
|
1950
|
+
url=str(response.url),
|
|
1951
|
+
status=response.status,
|
|
1952
|
+
)
|
|
1953
|
+
|
|
1954
|
+
# For non-2xx responses, log the error payload (best-effort)
|
|
1955
|
+
if response.status >= 400:
|
|
1956
|
+
error_body = None
|
|
1957
|
+
try:
|
|
1958
|
+
# Try to read response body safely for logging
|
|
1959
|
+
text = await response.text()
|
|
1960
|
+
# Avoid logging very large payloads
|
|
1961
|
+
error_body = text[:1000] if text else None
|
|
1962
|
+
except Exception:
|
|
1963
|
+
error_body = None
|
|
1964
|
+
# Log a structured error record
|
|
1965
|
+
self.logger.error(
|
|
1966
|
+
"HTTP error response",
|
|
1967
|
+
status=response.status,
|
|
1968
|
+
url=str(getattr(response, "url", "unknown")),
|
|
1969
|
+
interaction_id=interaction_id,
|
|
1970
|
+
body=error_body,
|
|
1971
|
+
)
|
|
1972
|
+
|
|
1973
|
+
if response.status == 401:
|
|
1974
|
+
raise AuthenticationError(
|
|
1975
|
+
"Authentication failed", details={"interaction_id": interaction_id}
|
|
1976
|
+
)
|
|
1977
|
+
elif response.status == 403:
|
|
1978
|
+
raise AuthenticationError(
|
|
1979
|
+
"Access denied - insufficient permissions",
|
|
1980
|
+
details={"interaction_id": interaction_id},
|
|
1981
|
+
)
|
|
1982
|
+
elif response.status == 404:
|
|
1983
|
+
raise NotFoundError("Resource", "unknown")
|
|
1984
|
+
# Handle rate limit response
|
|
1985
|
+
if response.status == 429:
|
|
1986
|
+
self.rate_limiter.handle_rate_limit_response(dict(response.headers))
|
|
1987
|
+
raise RateLimitError(
|
|
1988
|
+
f"Rate limit exceeded: {response.status}",
|
|
1989
|
+
retry_after=int(response.headers.get("Retry-After", 0)),
|
|
1990
|
+
)
|
|
1991
|
+
elif response.status >= 500:
|
|
1992
|
+
raise NetworkError(
|
|
1993
|
+
f"Server error: {response.status}", status_code=response.status
|
|
1994
|
+
)
|
|
1995
|
+
elif response.status >= 400:
|
|
1996
|
+
raise ValidationError(f"Client error: {response.status}")
|
|
1997
|
+
|
|
1998
|
+
# Mark successful request for adaptive backoff reset
|
|
1999
|
+
if response.status < 400:
|
|
2000
|
+
self.rate_limiter.handle_successful_request()
|
|
2001
|
+
|
|
2002
|
+
async def _enter_request_cm(
|
|
2003
|
+
self, method: str, url: str, **kwargs
|
|
2004
|
+
) -> aiohttp.ClientResponse:
|
|
2005
|
+
"""Support both awaitable and direct async context manager returns from mocks.
|
|
2006
|
+
|
|
2007
|
+
Some tests monkeypatch `_make_authenticated_request` to return a context
|
|
2008
|
+
manager directly instead of an awaitable. This helper normalizes both.
|
|
2009
|
+
"""
|
|
2010
|
+
req = self._make_authenticated_request(method, url, **kwargs)
|
|
2011
|
+
try:
|
|
2012
|
+
cm = await req # coroutine returning CM
|
|
2013
|
+
except TypeError:
|
|
2014
|
+
# For mocked tests that return CM directly
|
|
2015
|
+
cm = req # type: ignore[assignment] # already a CM
|
|
2016
|
+
return cm
|
|
2017
|
+
|
|
2018
|
+
def _get_file_extension(self, file_group_id: str) -> str:
|
|
2019
|
+
"""Extract file extension from file group identifier."""
|
|
2020
|
+
# Validate file_group_id to prevent path traversal
|
|
2021
|
+
if not file_group_id or not isinstance(file_group_id, str):
|
|
2022
|
+
return "bin"
|
|
2023
|
+
|
|
2024
|
+
# Check for path traversal attempts or suspicious patterns
|
|
2025
|
+
suspicious_patterns = [
|
|
2026
|
+
"..",
|
|
2027
|
+
"/",
|
|
2028
|
+
"\\",
|
|
2029
|
+
"%2F",
|
|
2030
|
+
"%5C",
|
|
2031
|
+
"etc/passwd",
|
|
2032
|
+
"system32",
|
|
2033
|
+
"config",
|
|
2034
|
+
]
|
|
2035
|
+
if any(pattern in file_group_id for pattern in suspicious_patterns):
|
|
2036
|
+
return "bin" # No dot for security/traversal cases
|
|
2037
|
+
|
|
2038
|
+
# More robust path sanitization
|
|
2039
|
+
from pathlib import Path
|
|
2040
|
+
|
|
2041
|
+
try:
|
|
2042
|
+
# Use pathlib to safely handle the id
|
|
2043
|
+
safe_path = Path(file_group_id).name # Get just the filename, not the path
|
|
2044
|
+
safe_file_id = str(safe_path)
|
|
2045
|
+
|
|
2046
|
+
# Try to extract extension
|
|
2047
|
+
if "." in safe_file_id:
|
|
2048
|
+
ext = safe_file_id.split(".")[-1]
|
|
2049
|
+
# For normal files, include the dot
|
|
2050
|
+
return "." + ext
|
|
2051
|
+
# For files without extensions, return with dot
|
|
2052
|
+
return ".bin"
|
|
2053
|
+
except Exception:
|
|
2054
|
+
# For any exceptions, return without dot for security
|
|
2055
|
+
return "bin"
|
|
2056
|
+
|
|
2057
|
+
# Auto-Download Functionality
|
|
2058
|
+
async def start_auto_download_async(
|
|
2059
|
+
self,
|
|
2060
|
+
group_id: str,
|
|
2061
|
+
destination_dir: str = "./downloads",
|
|
2062
|
+
interval_minutes: int = 30,
|
|
2063
|
+
file_filter: Optional[Callable] = None,
|
|
2064
|
+
progress_callback: Optional[Callable] = None,
|
|
2065
|
+
error_callback: Optional[Callable] = None,
|
|
2066
|
+
max_retries: int = 3,
|
|
2067
|
+
check_current_date_only: bool = True,
|
|
2068
|
+
max_concurrent_downloads: Optional[int] = None,
|
|
2069
|
+
) -> "AutoDownloadManager":
|
|
2070
|
+
"""
|
|
2071
|
+
Start automatic file download monitoring and downloading.
|
|
2072
|
+
|
|
2073
|
+
This function continuously monitors a data group for new files and automatically
|
|
2074
|
+
downloads them if they don't already exist in the destination folder.
|
|
2075
|
+
|
|
2076
|
+
Args:
|
|
2077
|
+
group_id: ID of the data group to monitor
|
|
2078
|
+
destination_dir: Directory to download files to
|
|
2079
|
+
interval_minutes: Check interval in minutes (default: 30)
|
|
2080
|
+
file_filter: Optional function to filter files (file_info) -> bool
|
|
2081
|
+
progress_callback: Optional callback for download progress
|
|
2082
|
+
error_callback: Optional callback for errors
|
|
2083
|
+
max_retries: Maximum retry attempts for failed downloads
|
|
2084
|
+
check_current_date_only: If True, only check files for current date
|
|
2085
|
+
max_concurrent_downloads: Maximum concurrent downloads (uses SDK default if None)
|
|
2086
|
+
|
|
2087
|
+
Returns:
|
|
2088
|
+
AutoDownloadManager instance for controlling the auto-download process
|
|
2089
|
+
|
|
2090
|
+
Example:
|
|
2091
|
+
# Basic auto-download
|
|
2092
|
+
manager = await dq.start_auto_download_async("economic-data")
|
|
2093
|
+
|
|
2094
|
+
# Advanced auto-download with filtering
|
|
2095
|
+
def csv_filter(file_info):
|
|
2096
|
+
return file_info.filename.endswith('.csv') if file_info.filename else True
|
|
2097
|
+
|
|
2098
|
+
manager = await dq.start_auto_download_async(
|
|
2099
|
+
group_id="economic-data",
|
|
2100
|
+
destination_dir="./data",
|
|
2101
|
+
interval_minutes=15,
|
|
2102
|
+
file_filter=csv_filter,
|
|
2103
|
+
progress_callback=lambda p: print(f"Progress: {p.bytes_downloaded}/{p.total_bytes}")
|
|
2104
|
+
)
|
|
2105
|
+
|
|
2106
|
+
# Stop auto-download later
|
|
2107
|
+
await manager.stop()
|
|
2108
|
+
"""
|
|
2109
|
+
|
|
2110
|
+
manager = AutoDownloadManager(
|
|
2111
|
+
client=self,
|
|
2112
|
+
group_id=group_id,
|
|
2113
|
+
destination_dir=destination_dir,
|
|
2114
|
+
interval_minutes=interval_minutes,
|
|
2115
|
+
file_filter=file_filter,
|
|
2116
|
+
progress_callback=progress_callback,
|
|
2117
|
+
error_callback=error_callback,
|
|
2118
|
+
max_retries=max_retries,
|
|
2119
|
+
check_current_date_only=check_current_date_only,
|
|
2120
|
+
max_concurrent_downloads=max_concurrent_downloads,
|
|
2121
|
+
)
|
|
2122
|
+
|
|
2123
|
+
await manager.start()
|
|
2124
|
+
return manager
|
|
2125
|
+
|
|
2126
|
+
def start_auto_download(
|
|
2127
|
+
self,
|
|
2128
|
+
group_id: str,
|
|
2129
|
+
destination_dir: str = "./downloads",
|
|
2130
|
+
interval_minutes: int = 30,
|
|
2131
|
+
file_filter: Optional[Callable] = None,
|
|
2132
|
+
progress_callback: Optional[Callable] = None,
|
|
2133
|
+
error_callback: Optional[Callable] = None,
|
|
2134
|
+
max_retries: int = 3,
|
|
2135
|
+
check_current_date_only: bool = True,
|
|
2136
|
+
max_concurrent_downloads: Optional[int] = None,
|
|
2137
|
+
) -> "AutoDownloadManager":
|
|
2138
|
+
"""
|
|
2139
|
+
Synchronous wrapper for start_auto_download_async.
|
|
2140
|
+
Note: Will raise an error if called from within an existing event loop.
|
|
2141
|
+
|
|
2142
|
+
Example:
|
|
2143
|
+
# Start auto-download synchronously
|
|
2144
|
+
manager = dq.start_auto_download("economic-data")
|
|
2145
|
+
|
|
2146
|
+
# Stop it later (in async context)
|
|
2147
|
+
import asyncio
|
|
2148
|
+
asyncio.run(manager.stop())
|
|
2149
|
+
"""
|
|
2150
|
+
return asyncio.run(
|
|
2151
|
+
self.start_auto_download_async(
|
|
2152
|
+
group_id,
|
|
2153
|
+
destination_dir,
|
|
2154
|
+
interval_minutes,
|
|
2155
|
+
file_filter,
|
|
2156
|
+
progress_callback,
|
|
2157
|
+
error_callback,
|
|
2158
|
+
max_retries,
|
|
2159
|
+
check_current_date_only,
|
|
2160
|
+
max_concurrent_downloads,
|
|
2161
|
+
)
|
|
2162
|
+
)
|
|
2163
|
+
|
|
2164
|
+
# DataFrame Conversion Utilities
|
|
2165
|
+
def to_dataframe(
|
|
2166
|
+
self,
|
|
2167
|
+
response_data,
|
|
2168
|
+
flatten_nested: bool = True,
|
|
2169
|
+
include_metadata: bool = False,
|
|
2170
|
+
date_columns: Optional[List[str]] = None,
|
|
2171
|
+
numeric_columns: Optional[List[str]] = None,
|
|
2172
|
+
custom_transformations: Optional[Dict[str, Callable]] = None,
|
|
2173
|
+
) -> "pd.DataFrame":
|
|
2174
|
+
"""
|
|
2175
|
+
Dynamically convert any API response to a pandas DataFrame.
|
|
2176
|
+
|
|
2177
|
+
This function can handle various response types from the DataQuery API
|
|
2178
|
+
and convert them into a structured pandas DataFrame for analysis.
|
|
2179
|
+
|
|
2180
|
+
Args:
|
|
2181
|
+
response_data: Any API response object (Group, FileInfo, TimeSeriesResponse, etc.)
|
|
2182
|
+
flatten_nested: If True, flatten nested objects into columns
|
|
2183
|
+
include_metadata: If True, include metadata fields in the DataFrame
|
|
2184
|
+
date_columns: List of column names to parse as dates
|
|
2185
|
+
numeric_columns: List of column names to convert to numeric
|
|
2186
|
+
custom_transformations: Dict of column_name -> transformation_function
|
|
2187
|
+
|
|
2188
|
+
Returns:
|
|
2189
|
+
pandas.DataFrame: Converted DataFrame
|
|
2190
|
+
|
|
2191
|
+
Examples:
|
|
2192
|
+
# Convert groups list
|
|
2193
|
+
groups = await dq.list_groups_async()
|
|
2194
|
+
df = dq.to_dataframe(groups)
|
|
2195
|
+
|
|
2196
|
+
# Convert file list with date parsing
|
|
2197
|
+
files = await dq.list_files_async("group-id")
|
|
2198
|
+
df = dq.to_dataframe(
|
|
2199
|
+
files.file_group_ids,
|
|
2200
|
+
date_columns=['last_modified'],
|
|
2201
|
+
include_metadata=True
|
|
2202
|
+
)
|
|
2203
|
+
|
|
2204
|
+
# Convert time series with custom transformations
|
|
2205
|
+
ts = await dq.get_instrument_time_series_async(...)
|
|
2206
|
+
df = dq.to_dataframe(
|
|
2207
|
+
ts,
|
|
2208
|
+
custom_transformations={
|
|
2209
|
+
'price': lambda x: float(x) if x else 0.0,
|
|
2210
|
+
'volume': lambda x: int(x) if x else 0
|
|
2211
|
+
}
|
|
2212
|
+
)
|
|
2213
|
+
"""
|
|
2214
|
+
if not HAS_PANDAS:
|
|
2215
|
+
raise ImportError(
|
|
2216
|
+
"pandas is required for DataFrame conversion. "
|
|
2217
|
+
"Install it with: pip install pandas"
|
|
2218
|
+
)
|
|
2219
|
+
|
|
2220
|
+
return self._convert_to_dataframe(
|
|
2221
|
+
response_data,
|
|
2222
|
+
flatten_nested,
|
|
2223
|
+
include_metadata,
|
|
2224
|
+
date_columns,
|
|
2225
|
+
numeric_columns,
|
|
2226
|
+
custom_transformations,
|
|
2227
|
+
)
|
|
2228
|
+
|
|
2229
|
+
def _convert_to_dataframe(
|
|
2230
|
+
self,
|
|
2231
|
+
data,
|
|
2232
|
+
flatten_nested: bool = True,
|
|
2233
|
+
include_metadata: bool = False,
|
|
2234
|
+
date_columns: Optional[List[str]] = None,
|
|
2235
|
+
numeric_columns: Optional[List[str]] = None,
|
|
2236
|
+
custom_transformations: Optional[Dict[str, Callable]] = None,
|
|
2237
|
+
) -> "pd.DataFrame":
|
|
2238
|
+
"""Internal method to convert data to DataFrame with memory optimization."""
|
|
2239
|
+
try:
|
|
2240
|
+
import pandas as pd
|
|
2241
|
+
except ImportError:
|
|
2242
|
+
raise ImportError("pandas is required for DataFrame conversion")
|
|
2243
|
+
|
|
2244
|
+
# Initialize processing parameters
|
|
2245
|
+
date_columns = date_columns or []
|
|
2246
|
+
numeric_columns = numeric_columns or []
|
|
2247
|
+
custom_transformations = custom_transformations or {}
|
|
2248
|
+
|
|
2249
|
+
# Handle different data types
|
|
2250
|
+
if data is None:
|
|
2251
|
+
return pd.DataFrame()
|
|
2252
|
+
|
|
2253
|
+
# Convert single object to list for uniform processing
|
|
2254
|
+
if not isinstance(data, (list, tuple)):
|
|
2255
|
+
if hasattr(data, "__dict__") or hasattr(data, "__slots__"):
|
|
2256
|
+
# Single Pydantic model or object
|
|
2257
|
+
data = [data]
|
|
2258
|
+
else:
|
|
2259
|
+
# Primitive data type
|
|
2260
|
+
return pd.DataFrame({"value": [data]})
|
|
2261
|
+
|
|
2262
|
+
# Memory optimization: process in chunks for large datasets
|
|
2263
|
+
chunk_size = 1000 # Process 1000 records at a time
|
|
2264
|
+
all_records = []
|
|
2265
|
+
|
|
2266
|
+
for i in range(0, len(data), chunk_size):
|
|
2267
|
+
chunk = data[i : i + chunk_size]
|
|
2268
|
+
chunk_records = []
|
|
2269
|
+
|
|
2270
|
+
for item in chunk:
|
|
2271
|
+
record = self._extract_object_data(
|
|
2272
|
+
item, flatten_nested, include_metadata
|
|
2273
|
+
)
|
|
2274
|
+
if record:
|
|
2275
|
+
chunk_records.append(record)
|
|
2276
|
+
|
|
2277
|
+
if chunk_records:
|
|
2278
|
+
all_records.extend(chunk_records)
|
|
2279
|
+
|
|
2280
|
+
# Force garbage collection for large datasets
|
|
2281
|
+
if len(data) > 5000:
|
|
2282
|
+
import gc
|
|
2283
|
+
|
|
2284
|
+
gc.collect()
|
|
2285
|
+
|
|
2286
|
+
if not all_records:
|
|
2287
|
+
return pd.DataFrame()
|
|
2288
|
+
|
|
2289
|
+
# Create DataFrame with memory optimization
|
|
2290
|
+
df = pd.DataFrame(all_records)
|
|
2291
|
+
|
|
2292
|
+
# Clear the records list to free memory
|
|
2293
|
+
all_records.clear()
|
|
2294
|
+
|
|
2295
|
+
# Apply data type conversions
|
|
2296
|
+
df = self._apply_data_transformations(
|
|
2297
|
+
df, date_columns, numeric_columns, custom_transformations
|
|
2298
|
+
)
|
|
2299
|
+
|
|
2300
|
+
return df
|
|
2301
|
+
|
|
2302
|
+
def _extract_object_data(
|
|
2303
|
+
self, obj, flatten_nested: bool = True, include_metadata: bool = False
|
|
2304
|
+
) -> Dict[str, Any]:
|
|
2305
|
+
"""Extract data from a single object."""
|
|
2306
|
+
if obj is None:
|
|
2307
|
+
return {}
|
|
2308
|
+
|
|
2309
|
+
record = {}
|
|
2310
|
+
|
|
2311
|
+
# Handle Pydantic models
|
|
2312
|
+
if hasattr(obj, "model_dump"):
|
|
2313
|
+
try:
|
|
2314
|
+
data = obj.model_dump()
|
|
2315
|
+
record.update(
|
|
2316
|
+
self._process_dict_data(data, flatten_nested, include_metadata)
|
|
2317
|
+
)
|
|
2318
|
+
except Exception:
|
|
2319
|
+
# Fallback to __dict__ if model_dump fails
|
|
2320
|
+
if hasattr(obj, "__dict__"):
|
|
2321
|
+
record.update(
|
|
2322
|
+
self._process_dict_data(
|
|
2323
|
+
obj.__dict__, flatten_nested, include_metadata
|
|
2324
|
+
)
|
|
2325
|
+
)
|
|
2326
|
+
|
|
2327
|
+
# Handle objects with __dict__
|
|
2328
|
+
elif hasattr(obj, "__dict__"):
|
|
2329
|
+
record.update(
|
|
2330
|
+
self._process_dict_data(obj.__dict__, flatten_nested, include_metadata)
|
|
2331
|
+
)
|
|
2332
|
+
|
|
2333
|
+
# Handle dictionary objects
|
|
2334
|
+
elif isinstance(obj, dict):
|
|
2335
|
+
record.update(
|
|
2336
|
+
self._process_dict_data(obj, flatten_nested, include_metadata)
|
|
2337
|
+
)
|
|
2338
|
+
|
|
2339
|
+
# Handle primitive types
|
|
2340
|
+
else:
|
|
2341
|
+
record["value"] = obj
|
|
2342
|
+
|
|
2343
|
+
return record
|
|
2344
|
+
|
|
2345
|
+
def _process_dict_data(
|
|
2346
|
+
self,
|
|
2347
|
+
data: Dict[str, Any],
|
|
2348
|
+
flatten_nested: bool = True,
|
|
2349
|
+
include_metadata: bool = False,
|
|
2350
|
+
) -> Dict[str, Any]:
|
|
2351
|
+
"""Process dictionary data with nested object handling."""
|
|
2352
|
+
processed = {}
|
|
2353
|
+
|
|
2354
|
+
for key, value in data.items():
|
|
2355
|
+
# Skip private attributes unless metadata is requested
|
|
2356
|
+
if key.startswith("_") and not include_metadata:
|
|
2357
|
+
continue
|
|
2358
|
+
|
|
2359
|
+
# Handle nested objects
|
|
2360
|
+
if isinstance(value, dict) and flatten_nested:
|
|
2361
|
+
# Flatten nested dictionaries
|
|
2362
|
+
for nested_key, nested_value in value.items():
|
|
2363
|
+
flattened_key = f"{key}_{nested_key}"
|
|
2364
|
+
processed[flattened_key] = self._convert_value(nested_value)
|
|
2365
|
+
|
|
2366
|
+
elif isinstance(value, (list, tuple)) and flatten_nested:
|
|
2367
|
+
# Handle lists/arrays
|
|
2368
|
+
if value and isinstance(value[0], dict):
|
|
2369
|
+
# List of dictionaries - create multiple columns
|
|
2370
|
+
for i, list_item in enumerate(value[:5]): # Limit to first 5 items
|
|
2371
|
+
if isinstance(list_item, dict):
|
|
2372
|
+
for nested_key, nested_value in list_item.items():
|
|
2373
|
+
flattened_key = f"{key}_{i}_{nested_key}"
|
|
2374
|
+
processed[flattened_key] = self._convert_value(
|
|
2375
|
+
nested_value
|
|
2376
|
+
)
|
|
2377
|
+
else:
|
|
2378
|
+
# Simple list - convert to string representation
|
|
2379
|
+
processed[key] = str(value) if value else None
|
|
2380
|
+
|
|
2381
|
+
else:
|
|
2382
|
+
# Direct value assignment
|
|
2383
|
+
processed[key] = self._convert_value(value)
|
|
2384
|
+
|
|
2385
|
+
return processed
|
|
2386
|
+
|
|
2387
|
+
def _convert_value(self, value) -> Any:
|
|
2388
|
+
"""Convert individual values to DataFrame-compatible types."""
|
|
2389
|
+
if value is None:
|
|
2390
|
+
return None
|
|
2391
|
+
|
|
2392
|
+
# Handle datetime objects
|
|
2393
|
+
if hasattr(value, "isoformat"): # datetime-like objects
|
|
2394
|
+
return value.isoformat()
|
|
2395
|
+
|
|
2396
|
+
# Handle Pydantic models and complex objects
|
|
2397
|
+
if hasattr(value, "model_dump"):
|
|
2398
|
+
try:
|
|
2399
|
+
return str(value.model_dump())
|
|
2400
|
+
except Exception:
|
|
2401
|
+
return str(value)
|
|
2402
|
+
|
|
2403
|
+
# Handle other objects
|
|
2404
|
+
if hasattr(value, "__dict__"):
|
|
2405
|
+
return str(value.__dict__)
|
|
2406
|
+
|
|
2407
|
+
# Return primitive types as-is
|
|
2408
|
+
return value
|
|
2409
|
+
|
|
2410
|
+
def _apply_data_transformations(
|
|
2411
|
+
self,
|
|
2412
|
+
df: "pd.DataFrame",
|
|
2413
|
+
date_columns: List[str],
|
|
2414
|
+
numeric_columns: List[str],
|
|
2415
|
+
custom_transformations: Dict[str, Callable],
|
|
2416
|
+
) -> "pd.DataFrame":
|
|
2417
|
+
"""Apply data type conversions and transformations."""
|
|
2418
|
+
try:
|
|
2419
|
+
import pandas as pd
|
|
2420
|
+
except ImportError:
|
|
2421
|
+
return df
|
|
2422
|
+
|
|
2423
|
+
# Apply custom transformations first
|
|
2424
|
+
for column, transform_func in custom_transformations.items():
|
|
2425
|
+
if column in df.columns:
|
|
2426
|
+
try:
|
|
2427
|
+
df[column] = df[column].apply(transform_func)
|
|
2428
|
+
except Exception as e:
|
|
2429
|
+
self.logger.warning(
|
|
2430
|
+
f"Failed to apply transformation to column '{column}': {e}"
|
|
2431
|
+
)
|
|
2432
|
+
|
|
2433
|
+
# Convert date columns
|
|
2434
|
+
for column in date_columns:
|
|
2435
|
+
if column in df.columns:
|
|
2436
|
+
try:
|
|
2437
|
+
df[column] = pd.to_datetime(df[column], errors="coerce")
|
|
2438
|
+
except Exception as e:
|
|
2439
|
+
self.logger.warning(
|
|
2440
|
+
f"Failed to convert column '{column}' to datetime: {e}"
|
|
2441
|
+
)
|
|
2442
|
+
|
|
2443
|
+
# Convert numeric columns
|
|
2444
|
+
for column in numeric_columns:
|
|
2445
|
+
if column in df.columns:
|
|
2446
|
+
try:
|
|
2447
|
+
df[column] = pd.to_numeric(df[column], errors="coerce")
|
|
2448
|
+
except Exception as e:
|
|
2449
|
+
self.logger.warning(
|
|
2450
|
+
f"Failed to convert column '{column}' to numeric: {e}"
|
|
2451
|
+
)
|
|
2452
|
+
|
|
2453
|
+
# Auto-detect and convert common patterns
|
|
2454
|
+
df = self._auto_convert_columns(df)
|
|
2455
|
+
|
|
2456
|
+
return df
|
|
2457
|
+
|
|
2458
|
+
def _auto_convert_columns(self, df: "pd.DataFrame") -> "pd.DataFrame":
|
|
2459
|
+
"""Automatically detect and convert common column patterns."""
|
|
2460
|
+
try:
|
|
2461
|
+
import pandas as pd
|
|
2462
|
+
except ImportError:
|
|
2463
|
+
return df
|
|
2464
|
+
|
|
2465
|
+
for column in df.columns:
|
|
2466
|
+
if df[column].dtype == "object": # String columns
|
|
2467
|
+
column_lower = column.lower()
|
|
2468
|
+
|
|
2469
|
+
# Auto-detect date columns
|
|
2470
|
+
if any(
|
|
2471
|
+
date_word in column_lower
|
|
2472
|
+
for date_word in [
|
|
2473
|
+
"date",
|
|
2474
|
+
"time",
|
|
2475
|
+
"created",
|
|
2476
|
+
"updated",
|
|
2477
|
+
"modified",
|
|
2478
|
+
"expires",
|
|
2479
|
+
]
|
|
2480
|
+
):
|
|
2481
|
+
try:
|
|
2482
|
+
# Sample a few non-null values to check if they're dates
|
|
2483
|
+
sample_values = df[column].dropna().head(3)
|
|
2484
|
+
if len(sample_values) > 0:
|
|
2485
|
+
pd.to_datetime(sample_values.iloc[0]) # Test conversion
|
|
2486
|
+
df[column] = pd.to_datetime(df[column], errors="coerce")
|
|
2487
|
+
continue
|
|
2488
|
+
except Exception:
|
|
2489
|
+
pass
|
|
2490
|
+
|
|
2491
|
+
# Auto-detect numeric columns
|
|
2492
|
+
if any(
|
|
2493
|
+
num_word in column_lower
|
|
2494
|
+
for num_word in [
|
|
2495
|
+
"size",
|
|
2496
|
+
"count",
|
|
2497
|
+
"bytes",
|
|
2498
|
+
"price",
|
|
2499
|
+
"value",
|
|
2500
|
+
"amount",
|
|
2501
|
+
"volume",
|
|
2502
|
+
"quantity",
|
|
2503
|
+
"number",
|
|
2504
|
+
"id",
|
|
2505
|
+
]
|
|
2506
|
+
):
|
|
2507
|
+
try:
|
|
2508
|
+
# Try converting to numeric
|
|
2509
|
+
numeric_series = pd.to_numeric(df[column], errors="coerce")
|
|
2510
|
+
# If most values converted successfully, use numeric type
|
|
2511
|
+
if numeric_series.notna().sum() / len(df) > 0.7:
|
|
2512
|
+
df[column] = numeric_series
|
|
2513
|
+
except Exception:
|
|
2514
|
+
pass
|
|
2515
|
+
|
|
2516
|
+
return df
|
|
2517
|
+
|
|
2518
|
+
# Convenience methods for common conversions
|
|
2519
|
+
def groups_to_dataframe(
|
|
2520
|
+
self, groups: Union[List["Group"], "GroupList"], include_metadata: bool = False
|
|
2521
|
+
) -> "pd.DataFrame":
|
|
2522
|
+
"""Convert groups response to DataFrame."""
|
|
2523
|
+
if hasattr(groups, "groups"):
|
|
2524
|
+
groups = groups.groups
|
|
2525
|
+
|
|
2526
|
+
return self.to_dataframe(
|
|
2527
|
+
groups,
|
|
2528
|
+
flatten_nested=True,
|
|
2529
|
+
include_metadata=include_metadata,
|
|
2530
|
+
date_columns=["last_updated", "created_date"],
|
|
2531
|
+
)
|
|
2532
|
+
|
|
2533
|
+
def files_to_dataframe(
|
|
2534
|
+
self, files: Union[List["FileInfo"], "FileList"], include_metadata: bool = False
|
|
2535
|
+
) -> "pd.DataFrame":
|
|
2536
|
+
"""Convert files response to DataFrame."""
|
|
2537
|
+
if hasattr(files, "file_group_ids"):
|
|
2538
|
+
files = files.file_group_ids
|
|
2539
|
+
|
|
2540
|
+
return self.to_dataframe(
|
|
2541
|
+
files,
|
|
2542
|
+
flatten_nested=True,
|
|
2543
|
+
include_metadata=include_metadata,
|
|
2544
|
+
date_columns=["last_modified", "created_date"],
|
|
2545
|
+
numeric_columns=["file_size"],
|
|
2546
|
+
)
|
|
2547
|
+
|
|
2548
|
+
def instruments_to_dataframe(
|
|
2549
|
+
self,
|
|
2550
|
+
instruments: Union[List["Instrument"], "InstrumentResponse"],
|
|
2551
|
+
include_metadata: bool = False,
|
|
2552
|
+
) -> "pd.DataFrame":
|
|
2553
|
+
"""Convert instruments response to DataFrame."""
|
|
2554
|
+
if hasattr(instruments, "instruments"):
|
|
2555
|
+
instruments = instruments.instruments
|
|
2556
|
+
|
|
2557
|
+
return self.to_dataframe(
|
|
2558
|
+
instruments,
|
|
2559
|
+
flatten_nested=True,
|
|
2560
|
+
include_metadata=include_metadata,
|
|
2561
|
+
date_columns=["created_date", "last_updated"],
|
|
2562
|
+
)
|
|
2563
|
+
|
|
2564
|
+
def time_series_to_dataframe(
|
|
2565
|
+
self, time_series, include_metadata: bool = False
|
|
2566
|
+
) -> "pd.DataFrame":
|
|
2567
|
+
"""Convert time series response to DataFrame."""
|
|
2568
|
+
# Handle different time series response structures
|
|
2569
|
+
if hasattr(time_series, "data"):
|
|
2570
|
+
data = time_series.data
|
|
2571
|
+
elif hasattr(time_series, "series"):
|
|
2572
|
+
data = time_series.series
|
|
2573
|
+
elif hasattr(time_series, "time_series"):
|
|
2574
|
+
data = time_series.time_series
|
|
2575
|
+
else:
|
|
2576
|
+
data = time_series
|
|
2577
|
+
|
|
2578
|
+
return self.to_dataframe(
|
|
2579
|
+
data,
|
|
2580
|
+
flatten_nested=True,
|
|
2581
|
+
include_metadata=include_metadata,
|
|
2582
|
+
date_columns=["date", "timestamp", "observation_date"],
|
|
2583
|
+
numeric_columns=[
|
|
2584
|
+
"value",
|
|
2585
|
+
"price",
|
|
2586
|
+
"volume",
|
|
2587
|
+
"open",
|
|
2588
|
+
"high",
|
|
2589
|
+
"low",
|
|
2590
|
+
"close",
|
|
2591
|
+
],
|
|
2592
|
+
)
|
|
2593
|
+
|
|
2594
|
+
# Synchronous wrapper methods
|
|
2595
|
+
def list_groups(self, limit: Optional[int] = None) -> List[Group]:
|
|
2596
|
+
"""Synchronous wrapper for list_groups using an event-loop aware runner."""
|
|
2597
|
+
return self._run_sync(self.list_groups_async(limit))
|
|
2598
|
+
|
|
2599
|
+
def list_all_groups(self) -> List[Group]:
|
|
2600
|
+
"""Synchronous wrapper for list_all_groups using an event-loop aware runner."""
|
|
2601
|
+
return self._run_sync(self.list_all_groups_async())
|
|
2602
|
+
|
|
2603
|
+
def search_groups(
|
|
2604
|
+
self, keywords: str, limit: Optional[int] = None, offset: Optional[int] = None
|
|
2605
|
+
) -> List[Group]:
|
|
2606
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2607
|
+
return self._run_sync(self.search_groups_async(keywords, limit, offset))
|
|
2608
|
+
|
|
2609
|
+
def list_files(
|
|
2610
|
+
self, group_id: str, file_group_id: Optional[str] = None
|
|
2611
|
+
) -> FileList:
|
|
2612
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2613
|
+
return self._run_sync(self.list_files_async(group_id, file_group_id))
|
|
2614
|
+
|
|
2615
|
+
def get_file_info(self, group_id: str, file_group_id: str) -> FileInfo:
|
|
2616
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2617
|
+
return self._run_sync(self.get_file_info_async(group_id, file_group_id))
|
|
2618
|
+
|
|
2619
|
+
def check_availability(
|
|
2620
|
+
self, file_group_id: str, file_datetime: str
|
|
2621
|
+
) -> AvailabilityInfo:
|
|
2622
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2623
|
+
return self._run_sync(
|
|
2624
|
+
self.check_availability_async(file_group_id, file_datetime)
|
|
2625
|
+
)
|
|
2626
|
+
|
|
2627
|
+
def download_file(
|
|
2628
|
+
self,
|
|
2629
|
+
file_group_id: str,
|
|
2630
|
+
file_datetime: Optional[str] = None,
|
|
2631
|
+
destination_path: Optional[Path] = None,
|
|
2632
|
+
options: Optional[DownloadOptions] = None,
|
|
2633
|
+
progress_callback: Optional[Callable[[DownloadProgress], None]] = None,
|
|
2634
|
+
) -> DownloadResult:
|
|
2635
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2636
|
+
# If destination_path is provided but options is None, create options with destination_path
|
|
2637
|
+
if destination_path is not None and options is None:
|
|
2638
|
+
from .models import DownloadOptions
|
|
2639
|
+
|
|
2640
|
+
options = DownloadOptions(destination_path=destination_path)
|
|
2641
|
+
elif destination_path is not None and options is not None:
|
|
2642
|
+
# If both are provided, update options with destination_path
|
|
2643
|
+
options = options.model_copy(update={"destination_path": destination_path})
|
|
2644
|
+
|
|
2645
|
+
# Match async signature (file_group_id, file_datetime, options, num_parts, progress_callback)
|
|
2646
|
+
return self._run_sync(
|
|
2647
|
+
self.download_file_async(
|
|
2648
|
+
file_group_id,
|
|
2649
|
+
file_datetime,
|
|
2650
|
+
options,
|
|
2651
|
+
5,
|
|
2652
|
+
progress_callback,
|
|
2653
|
+
)
|
|
2654
|
+
)
|
|
2655
|
+
|
|
2656
|
+
# Note: download_multiple_files method removed as it's not part of the new API spec
|
|
2657
|
+
# Use individual download_file calls for batch operations
|
|
2658
|
+
|
|
2659
|
+
def list_available_files(
|
|
2660
|
+
self,
|
|
2661
|
+
group_id: str,
|
|
2662
|
+
file_group_id: Optional[str] = None,
|
|
2663
|
+
start_date: Optional[str] = None,
|
|
2664
|
+
end_date: Optional[str] = None,
|
|
2665
|
+
) -> List[Dict[str, Any]]:
|
|
2666
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2667
|
+
return self._run_sync(
|
|
2668
|
+
self.list_available_files_async(
|
|
2669
|
+
group_id, file_group_id, start_date, end_date
|
|
2670
|
+
)
|
|
2671
|
+
)
|
|
2672
|
+
|
|
2673
|
+
def health_check(self) -> bool:
|
|
2674
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2675
|
+
return self._run_sync(self.health_check_async())
|
|
2676
|
+
|
|
2677
|
+
# Instrument Collection Endpoints - Synchronous wrappers
|
|
2678
|
+
def list_instruments(
|
|
2679
|
+
self,
|
|
2680
|
+
group_id: str,
|
|
2681
|
+
instrument_id: Optional[str] = None,
|
|
2682
|
+
page: Optional[str] = None,
|
|
2683
|
+
) -> "InstrumentsResponse":
|
|
2684
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2685
|
+
return self._run_sync(
|
|
2686
|
+
self.list_instruments_async(group_id, instrument_id, page)
|
|
2687
|
+
)
|
|
2688
|
+
|
|
2689
|
+
def search_instruments(
|
|
2690
|
+
self, group_id: str, keywords: str, page: Optional[str] = None
|
|
2691
|
+
) -> "InstrumentsResponse":
|
|
2692
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2693
|
+
return self._run_sync(self.search_instruments_async(group_id, keywords, page))
|
|
2694
|
+
|
|
2695
|
+
def get_instrument_time_series(
|
|
2696
|
+
self,
|
|
2697
|
+
instruments: List[str],
|
|
2698
|
+
attributes: List[str],
|
|
2699
|
+
data: str = "REFERENCE_DATA",
|
|
2700
|
+
format: str = "JSON",
|
|
2701
|
+
start_date: Optional[str] = None,
|
|
2702
|
+
end_date: Optional[str] = None,
|
|
2703
|
+
calendar: str = "CAL_USBANK",
|
|
2704
|
+
frequency: str = "FREQ_DAY",
|
|
2705
|
+
conversion: str = "CONV_LASTBUS_ABS",
|
|
2706
|
+
nan_treatment: str = "NA_NOTHING",
|
|
2707
|
+
page: Optional[str] = None,
|
|
2708
|
+
) -> "TimeSeriesResponse":
|
|
2709
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2710
|
+
return self._run_sync(
|
|
2711
|
+
self.get_instrument_time_series_async(
|
|
2712
|
+
instruments,
|
|
2713
|
+
attributes,
|
|
2714
|
+
data,
|
|
2715
|
+
format,
|
|
2716
|
+
start_date,
|
|
2717
|
+
end_date,
|
|
2718
|
+
calendar,
|
|
2719
|
+
frequency,
|
|
2720
|
+
conversion,
|
|
2721
|
+
nan_treatment,
|
|
2722
|
+
page,
|
|
2723
|
+
)
|
|
2724
|
+
)
|
|
2725
|
+
|
|
2726
|
+
def get_expressions_time_series(
|
|
2727
|
+
self,
|
|
2728
|
+
expressions: List[str],
|
|
2729
|
+
format: str = "JSON",
|
|
2730
|
+
start_date: Optional[str] = None,
|
|
2731
|
+
end_date: Optional[str] = None,
|
|
2732
|
+
calendar: str = "CAL_USBANK",
|
|
2733
|
+
frequency: str = "FREQ_DAY",
|
|
2734
|
+
conversion: str = "CONV_LASTBUS_ABS",
|
|
2735
|
+
nan_treatment: str = "NA_NOTHING",
|
|
2736
|
+
data: str = "REFERENCE_DATA",
|
|
2737
|
+
page: Optional[str] = None,
|
|
2738
|
+
) -> "TimeSeriesResponse":
|
|
2739
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2740
|
+
return self._run_sync(
|
|
2741
|
+
self.get_expressions_time_series_async(
|
|
2742
|
+
expressions,
|
|
2743
|
+
format,
|
|
2744
|
+
start_date,
|
|
2745
|
+
end_date,
|
|
2746
|
+
calendar,
|
|
2747
|
+
frequency,
|
|
2748
|
+
conversion,
|
|
2749
|
+
nan_treatment,
|
|
2750
|
+
data,
|
|
2751
|
+
page,
|
|
2752
|
+
)
|
|
2753
|
+
)
|
|
2754
|
+
|
|
2755
|
+
# Group Collection Additional Endpoints - Synchronous wrappers
|
|
2756
|
+
def get_group_filters(
|
|
2757
|
+
self, group_id: str, page: Optional[str] = None
|
|
2758
|
+
) -> "FiltersResponse":
|
|
2759
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2760
|
+
return self._run_sync(self.get_group_filters_async(group_id, page))
|
|
2761
|
+
|
|
2762
|
+
def get_group_attributes(
|
|
2763
|
+
self,
|
|
2764
|
+
group_id: str,
|
|
2765
|
+
instrument_id: Optional[str] = None,
|
|
2766
|
+
page: Optional[str] = None,
|
|
2767
|
+
) -> "AttributesResponse":
|
|
2768
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2769
|
+
return self._run_sync(
|
|
2770
|
+
self.get_group_attributes_async(group_id, instrument_id, page)
|
|
2771
|
+
)
|
|
2772
|
+
|
|
2773
|
+
def get_group_time_series(
|
|
2774
|
+
self,
|
|
2775
|
+
group_id: str,
|
|
2776
|
+
attributes: List[str],
|
|
2777
|
+
filter: Optional[str] = None,
|
|
2778
|
+
data: str = "REFERENCE_DATA",
|
|
2779
|
+
format: str = "JSON",
|
|
2780
|
+
start_date: Optional[str] = None,
|
|
2781
|
+
end_date: Optional[str] = None,
|
|
2782
|
+
calendar: str = "CAL_USBANK",
|
|
2783
|
+
frequency: str = "FREQ_DAY",
|
|
2784
|
+
conversion: str = "CONV_LASTBUS_ABS",
|
|
2785
|
+
nan_treatment: str = "NA_NOTHING",
|
|
2786
|
+
page: Optional[str] = None,
|
|
2787
|
+
) -> "TimeSeriesResponse":
|
|
2788
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2789
|
+
return self._run_sync(
|
|
2790
|
+
self.get_group_time_series_async(
|
|
2791
|
+
group_id,
|
|
2792
|
+
attributes,
|
|
2793
|
+
filter,
|
|
2794
|
+
data,
|
|
2795
|
+
format,
|
|
2796
|
+
start_date,
|
|
2797
|
+
end_date,
|
|
2798
|
+
calendar,
|
|
2799
|
+
frequency,
|
|
2800
|
+
conversion,
|
|
2801
|
+
nan_treatment,
|
|
2802
|
+
page,
|
|
2803
|
+
)
|
|
2804
|
+
)
|
|
2805
|
+
|
|
2806
|
+
# Grid Collection Endpoints - Synchronous wrappers
|
|
2807
|
+
def get_grid_data(
|
|
2808
|
+
self,
|
|
2809
|
+
expr: Optional[str] = None,
|
|
2810
|
+
grid_id: Optional[str] = None,
|
|
2811
|
+
date: Optional[str] = None,
|
|
2812
|
+
) -> "GridDataResponse":
|
|
2813
|
+
"""Synchronous wrapper using an event-loop aware runner."""
|
|
2814
|
+
return self._run_sync(self.get_grid_data_async(expr, grid_id, date))
|
|
2815
|
+
|
|
2816
|
+
def _run_sync(self, coro):
|
|
2817
|
+
try:
|
|
2818
|
+
asyncio.get_running_loop()
|
|
2819
|
+
import concurrent.futures
|
|
2820
|
+
|
|
2821
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
2822
|
+
future = executor.submit(asyncio.run, coro)
|
|
2823
|
+
return future.result()
|
|
2824
|
+
except RuntimeError:
|
|
2825
|
+
return asyncio.run(coro)
|