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/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)