dataquery-sdk 0.0.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dataquery/__init__.py +215 -0
- dataquery/auth.py +380 -0
- dataquery/auto_download.py +448 -0
- dataquery/cli.py +244 -0
- dataquery/client.py +2825 -0
- dataquery/config.py +571 -0
- dataquery/connection_pool.py +538 -0
- dataquery/dataquery.py +2467 -0
- dataquery/exceptions.py +169 -0
- dataquery/logging_config.py +415 -0
- dataquery/models.py +1303 -0
- dataquery/py.typed +2 -0
- dataquery/rate_limiter.py +520 -0
- dataquery/retry.py +406 -0
- dataquery/utils.py +525 -0
- dataquery_sdk-0.0.7.dist-info/METADATA +996 -0
- dataquery_sdk-0.0.7.dist-info/RECORD +21 -0
- dataquery_sdk-0.0.7.dist-info/WHEEL +5 -0
- dataquery_sdk-0.0.7.dist-info/entry_points.txt +2 -0
- dataquery_sdk-0.0.7.dist-info/licenses/LICENSE +201 -0
- dataquery_sdk-0.0.7.dist-info/top_level.txt +1 -0
dataquery/models.py
ADDED
|
@@ -0,0 +1,1303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Data models for the DATAQUERY SDK based on the OpenAPI specification.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, timedelta
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union, cast
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DownloadStatus(str, Enum):
|
|
14
|
+
"""Status of a download operation."""
|
|
15
|
+
|
|
16
|
+
PENDING = "pending"
|
|
17
|
+
DOWNLOADING = "downloading"
|
|
18
|
+
COMPLETED = "completed"
|
|
19
|
+
FAILED = "failed"
|
|
20
|
+
CANCELLED = "cancelled"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TokenStatus(str, Enum):
|
|
24
|
+
"""Status of an OAuth token."""
|
|
25
|
+
|
|
26
|
+
VALID = "valid"
|
|
27
|
+
EXPIRED = "expired"
|
|
28
|
+
REFRESHING = "refreshing"
|
|
29
|
+
INVALID = "invalid"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ClientConfig(BaseModel):
|
|
33
|
+
"""Configuration for the DATAQUERY client."""
|
|
34
|
+
|
|
35
|
+
# API configuration
|
|
36
|
+
base_url: str = Field(
|
|
37
|
+
default="https://api-developer.jpmorgan.com",
|
|
38
|
+
description="Base URL of the DATAQUERY API",
|
|
39
|
+
)
|
|
40
|
+
context_path: Optional[str] = Field(
|
|
41
|
+
default="/research/dataquery-authe/api/v2", description="API context path"
|
|
42
|
+
)
|
|
43
|
+
api_version: str = Field(default="2.0.0", description="API version")
|
|
44
|
+
# Optional separate host for file endpoints
|
|
45
|
+
files_base_url: Optional[str] = Field(
|
|
46
|
+
default="https://api-strm-gw01.jpmchase.com",
|
|
47
|
+
description="Separate base URL for file endpoints",
|
|
48
|
+
)
|
|
49
|
+
files_context_path: Optional[str] = Field(
|
|
50
|
+
default="/research/dataquery-authe/api/v2",
|
|
51
|
+
description="Context path for the files host",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# OAuth configuration
|
|
55
|
+
oauth_enabled: bool = Field(default=True, description="Enable OAuth authentication")
|
|
56
|
+
oauth_token_url: Optional[str] = Field(
|
|
57
|
+
default="https://authe.jpmorgan.com/as/token.oauth2",
|
|
58
|
+
description="OAuth token endpoint URL",
|
|
59
|
+
)
|
|
60
|
+
client_id: Optional[str] = Field(default=None, description="OAuth client ID")
|
|
61
|
+
client_secret: Optional[str] = Field(
|
|
62
|
+
default=None, description="OAuth client secret"
|
|
63
|
+
)
|
|
64
|
+
# scope removed
|
|
65
|
+
aud: Optional[str] = Field(
|
|
66
|
+
default="JPMC:URI:RS-06785-DataQueryExternalApi-PROD",
|
|
67
|
+
description="OAuth audience (aud)",
|
|
68
|
+
)
|
|
69
|
+
grant_type: str = Field(
|
|
70
|
+
default="client_credentials", description="OAuth grant type"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Bearer token configuration
|
|
74
|
+
bearer_token: Optional[str] = Field(
|
|
75
|
+
default=None, description="Bearer token for API access"
|
|
76
|
+
)
|
|
77
|
+
token_refresh_threshold: int = Field(
|
|
78
|
+
default=300, description="Seconds before expiry to refresh token"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# HTTP configuration
|
|
82
|
+
timeout: float = Field(
|
|
83
|
+
default=600.0, description="Default request timeout in seconds"
|
|
84
|
+
)
|
|
85
|
+
max_retries: int = Field(default=3, description="Maximum retry attempts")
|
|
86
|
+
retry_delay: float = Field(
|
|
87
|
+
default=1.0, description="Delay between retries in seconds"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Connection pooling
|
|
91
|
+
pool_connections: int = Field(default=10, description="Number of connection pools")
|
|
92
|
+
pool_maxsize: int = Field(default=20, description="Maximum connections per pool")
|
|
93
|
+
|
|
94
|
+
# Rate limiting
|
|
95
|
+
requests_per_minute: int = Field(
|
|
96
|
+
default=100, description="Requests per minute limit"
|
|
97
|
+
)
|
|
98
|
+
burst_capacity: int = Field(
|
|
99
|
+
default=20, description="Burst capacity for rate limiting"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Proxy configuration
|
|
103
|
+
proxy_enabled: bool = Field(default=False, description="Enable proxy support")
|
|
104
|
+
proxy_url: Optional[str] = Field(
|
|
105
|
+
default="",
|
|
106
|
+
description="Proxy URL (e.g., http://proxy:8080, socks5://proxy:1080)",
|
|
107
|
+
)
|
|
108
|
+
proxy_username: Optional[str] = Field(
|
|
109
|
+
default="", description="Proxy username for authentication"
|
|
110
|
+
)
|
|
111
|
+
proxy_password: Optional[str] = Field(
|
|
112
|
+
default="", description="Proxy password for authentication"
|
|
113
|
+
)
|
|
114
|
+
proxy_verify_ssl: bool = Field(
|
|
115
|
+
default=True, description="Verify SSL certificates for proxy connections"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Logging
|
|
119
|
+
log_level: str = Field(default="INFO", description="Logging level")
|
|
120
|
+
enable_debug_logging: bool = Field(
|
|
121
|
+
default=False, description="Enable debug logging"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Download configuration
|
|
125
|
+
download_dir: str = Field(
|
|
126
|
+
default="./downloads", description="Base download directory"
|
|
127
|
+
)
|
|
128
|
+
create_directories: bool = Field(
|
|
129
|
+
default=True, description="Create parent directories if they don't exist"
|
|
130
|
+
)
|
|
131
|
+
overwrite_existing: bool = Field(
|
|
132
|
+
default=False, description="Overwrite existing files"
|
|
133
|
+
)
|
|
134
|
+
token_storage_dir: Optional[str] = Field(
|
|
135
|
+
default=None,
|
|
136
|
+
description="Optional directory to store OAuth tokens; defaults to '<download_dir>/.tokens'",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Batch Download Configuration
|
|
140
|
+
max_concurrent_downloads: int = Field(
|
|
141
|
+
default=5, description="Maximum concurrent downloads"
|
|
142
|
+
)
|
|
143
|
+
batch_size: int = Field(default=10, description="Batch size for operations")
|
|
144
|
+
retry_failed: bool = Field(default=True, description="Retry failed downloads")
|
|
145
|
+
max_retry_attempts: int = Field(
|
|
146
|
+
default=2, description="Maximum retry attempts for failed downloads"
|
|
147
|
+
)
|
|
148
|
+
create_date_folders: bool = Field(
|
|
149
|
+
default=True, description="Create date-based folders"
|
|
150
|
+
)
|
|
151
|
+
preserve_path_structure: bool = Field(
|
|
152
|
+
default=True, description="Preserve original path structure"
|
|
153
|
+
)
|
|
154
|
+
flatten_structure: bool = Field(
|
|
155
|
+
default=False, description="Flatten directory structure"
|
|
156
|
+
)
|
|
157
|
+
show_batch_progress: bool = Field(default=True, description="Show batch progress")
|
|
158
|
+
show_individual_progress: bool = Field(
|
|
159
|
+
default=True, description="Show individual file progress"
|
|
160
|
+
)
|
|
161
|
+
continue_on_error: bool = Field(
|
|
162
|
+
default=True, description="Continue processing on errors"
|
|
163
|
+
)
|
|
164
|
+
log_errors: bool = Field(default=True, description="Log errors")
|
|
165
|
+
save_error_log: bool = Field(default=True, description="Save error log to file")
|
|
166
|
+
use_async_downloads: bool = Field(default=True, description="Use async downloads")
|
|
167
|
+
chunk_size: int = Field(default=8192, description="Download chunk size in bytes")
|
|
168
|
+
|
|
169
|
+
# Download Options
|
|
170
|
+
enable_range_requests: bool = Field(
|
|
171
|
+
default=True, description="Enable HTTP range requests"
|
|
172
|
+
)
|
|
173
|
+
show_progress: bool = Field(default=True, description="Show download progress")
|
|
174
|
+
|
|
175
|
+
# Workflow Configuration
|
|
176
|
+
workflow_dir: str = Field(
|
|
177
|
+
default="workflow", description="Workflow files subdirectory"
|
|
178
|
+
)
|
|
179
|
+
groups_dir: str = Field(default="groups", description="Groups files subdirectory")
|
|
180
|
+
availability_dir: str = Field(
|
|
181
|
+
default="availability", description="Availability files subdirectory"
|
|
182
|
+
)
|
|
183
|
+
default_dir: str = Field(default="files", description="Default files subdirectory")
|
|
184
|
+
|
|
185
|
+
# Security
|
|
186
|
+
mask_secrets: bool = Field(default=True, description="Mask secrets in logs")
|
|
187
|
+
token_storage_enabled: bool = Field(
|
|
188
|
+
default=False, description="Enable token storage"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
model_config = ConfigDict(
|
|
192
|
+
extra="allow", populate_by_name=True
|
|
193
|
+
) # Allow extra fields from API and populate by both alias and name
|
|
194
|
+
|
|
195
|
+
@field_validator("base_url")
|
|
196
|
+
@classmethod
|
|
197
|
+
def validate_base_url(cls, v):
|
|
198
|
+
# Allow invalid URLs for testing - validation happens in client
|
|
199
|
+
if v and v.startswith(("http://", "https://")):
|
|
200
|
+
return v.rstrip("/")
|
|
201
|
+
return v # Allow invalid URLs to be created
|
|
202
|
+
|
|
203
|
+
@field_validator("context_path")
|
|
204
|
+
@classmethod
|
|
205
|
+
def validate_context_path(cls, v):
|
|
206
|
+
if v is not None:
|
|
207
|
+
# Ensure context path starts with / and doesn't end with /
|
|
208
|
+
if not v.startswith("/"):
|
|
209
|
+
v = "/" + v
|
|
210
|
+
return v.rstrip("/")
|
|
211
|
+
return v
|
|
212
|
+
|
|
213
|
+
@field_validator("files_base_url")
|
|
214
|
+
@classmethod
|
|
215
|
+
def validate_files_base_url(cls, v):
|
|
216
|
+
if v:
|
|
217
|
+
if v.startswith(("http://", "https://")):
|
|
218
|
+
return v.rstrip("/")
|
|
219
|
+
return v
|
|
220
|
+
|
|
221
|
+
@field_validator("files_context_path")
|
|
222
|
+
@classmethod
|
|
223
|
+
def validate_files_context_path(cls, v):
|
|
224
|
+
if v is not None and v != "":
|
|
225
|
+
if not v.startswith("/"):
|
|
226
|
+
v = "/" + v
|
|
227
|
+
return v.rstrip("/")
|
|
228
|
+
return v
|
|
229
|
+
|
|
230
|
+
@field_validator("proxy_url")
|
|
231
|
+
@classmethod
|
|
232
|
+
def validate_proxy_url(cls, v):
|
|
233
|
+
# Allow None or empty string without validation
|
|
234
|
+
if v is None or v == "":
|
|
235
|
+
return v
|
|
236
|
+
if v is not None:
|
|
237
|
+
# Validate proxy URL format
|
|
238
|
+
if not v.startswith(("http://", "https://", "socks4://", "socks5://")):
|
|
239
|
+
raise ValueError(
|
|
240
|
+
"Proxy URL must start with http://, https://, socks4://, or socks5://"
|
|
241
|
+
)
|
|
242
|
+
return v
|
|
243
|
+
|
|
244
|
+
@field_validator("log_level")
|
|
245
|
+
@classmethod
|
|
246
|
+
def validate_log_level(cls, v):
|
|
247
|
+
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
248
|
+
if v.upper() not in valid_levels:
|
|
249
|
+
raise ValueError(f"Log level must be one of: {valid_levels}")
|
|
250
|
+
return v.upper()
|
|
251
|
+
|
|
252
|
+
@field_validator("oauth_token_url")
|
|
253
|
+
@classmethod
|
|
254
|
+
def validate_oauth_token_url(cls, v, info):
|
|
255
|
+
if info.data.get("oauth_enabled", True) and not v:
|
|
256
|
+
# Auto-generate token URL from base URL
|
|
257
|
+
base_url = info.data.get("base_url", "")
|
|
258
|
+
if base_url:
|
|
259
|
+
return f"{base_url}/oauth/token"
|
|
260
|
+
return v
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def has_oauth_credentials(self) -> bool:
|
|
264
|
+
"""Check if OAuth credentials are configured."""
|
|
265
|
+
return (
|
|
266
|
+
self.oauth_enabled
|
|
267
|
+
and self.client_id is not None
|
|
268
|
+
and self.client_secret is not None
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def has_bearer_token(self) -> bool:
|
|
273
|
+
"""Check if bearer token is configured."""
|
|
274
|
+
return self.bearer_token is not None and self.bearer_token.strip() != ""
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def has_proxy_credentials(self) -> bool:
|
|
278
|
+
"""Check if proxy credentials are configured."""
|
|
279
|
+
return self.proxy_username is not None and self.proxy_password is not None
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def api_base_url(self) -> str:
|
|
283
|
+
"""Get the complete API base URL including context path."""
|
|
284
|
+
if self.context_path:
|
|
285
|
+
return f"{self.base_url}{self.context_path}"
|
|
286
|
+
return self.base_url
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def files_api_base_url(self) -> Optional[str]:
|
|
290
|
+
"""Get the complete files API base URL if configured, including context path."""
|
|
291
|
+
if not self.files_base_url:
|
|
292
|
+
return None
|
|
293
|
+
if self.files_context_path:
|
|
294
|
+
return f"{self.files_base_url}{self.files_context_path}"
|
|
295
|
+
return self.files_base_url
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class OAuthToken(BaseModel):
|
|
299
|
+
"""OAuth token information."""
|
|
300
|
+
|
|
301
|
+
access_token: str = Field(..., description="Access token")
|
|
302
|
+
token_type: str = Field(default="Bearer", description="Token type")
|
|
303
|
+
expires_in: Optional[int] = Field(
|
|
304
|
+
default=None, description="Token expiry time in seconds"
|
|
305
|
+
)
|
|
306
|
+
# scope removed
|
|
307
|
+
refresh_token: Optional[str] = Field(default=None, description="Refresh token")
|
|
308
|
+
|
|
309
|
+
# Internal tracking
|
|
310
|
+
issued_at: Optional[datetime] = Field(default=None, description="Token issue time")
|
|
311
|
+
|
|
312
|
+
model_config = ConfigDict(
|
|
313
|
+
extra="allow", populate_by_name=True
|
|
314
|
+
) # Allow extra fields from API and populate by both alias and name
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def expires_at(self) -> Optional[datetime]:
|
|
318
|
+
"""Get token expiry time."""
|
|
319
|
+
if self.expires_in and self.issued_at:
|
|
320
|
+
return self.issued_at + timedelta(seconds=self.expires_in)
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def is_expired(self) -> bool:
|
|
325
|
+
"""Check if token is expired."""
|
|
326
|
+
if not self.expires_at:
|
|
327
|
+
return False
|
|
328
|
+
return datetime.now() >= self.expires_at
|
|
329
|
+
|
|
330
|
+
def is_expiring_soon(self, threshold: int = 300) -> bool:
|
|
331
|
+
"""Check if token is expiring soon."""
|
|
332
|
+
if not self.expires_at:
|
|
333
|
+
return False
|
|
334
|
+
remaining_time = (self.expires_at - datetime.now()).total_seconds()
|
|
335
|
+
# If threshold is larger than the original token lifetime, not expiring soon
|
|
336
|
+
if self.expires_in and threshold > self.expires_in:
|
|
337
|
+
return False
|
|
338
|
+
return remaining_time < threshold
|
|
339
|
+
|
|
340
|
+
def to_authorization_header(self) -> str:
|
|
341
|
+
"""Get authorization header value."""
|
|
342
|
+
return f"{self.token_type} {self.access_token}"
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def status(self) -> TokenStatus:
|
|
346
|
+
"""Get the current status, checking if expired."""
|
|
347
|
+
if self.is_expired:
|
|
348
|
+
return TokenStatus.EXPIRED
|
|
349
|
+
return TokenStatus.VALID
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class TokenRequest(BaseModel):
|
|
353
|
+
"""OAuth token request parameters."""
|
|
354
|
+
|
|
355
|
+
grant_type: str = Field("client_credentials", description="OAuth grant type")
|
|
356
|
+
client_id: Optional[str] = Field(None, description="OAuth client ID")
|
|
357
|
+
client_secret: Optional[str] = Field(None, description="OAuth client secret")
|
|
358
|
+
# scope removed
|
|
359
|
+
aud: Optional[str] = Field(None, description="OAuth audience (aud)")
|
|
360
|
+
|
|
361
|
+
model_config = ConfigDict(
|
|
362
|
+
extra="allow", populate_by_name=True
|
|
363
|
+
) # Allow extra fields from API and populate by both alias and name
|
|
364
|
+
|
|
365
|
+
def to_dict(self) -> Dict[str, str]:
|
|
366
|
+
"""Convert to dictionary for request."""
|
|
367
|
+
data = {
|
|
368
|
+
"grant_type": self.grant_type,
|
|
369
|
+
}
|
|
370
|
+
if self.client_id:
|
|
371
|
+
data["client_id"] = cast(str, self.client_id)
|
|
372
|
+
if self.client_secret:
|
|
373
|
+
data["client_secret"] = cast(str, self.client_secret)
|
|
374
|
+
# scope removed
|
|
375
|
+
if getattr(self, "aud", None):
|
|
376
|
+
# Send audience as 'aud' per provider requirement
|
|
377
|
+
data["aud"] = cast(str, self.aud)
|
|
378
|
+
return data
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class TokenResponse(BaseModel):
|
|
382
|
+
"""OAuth token response."""
|
|
383
|
+
|
|
384
|
+
access_token: str = Field(..., description="Access token")
|
|
385
|
+
token_type: Optional[str] = Field(default=None, description="Token type")
|
|
386
|
+
expires_in: Optional[int] = Field(
|
|
387
|
+
default=None, description="Token expiry time in seconds"
|
|
388
|
+
)
|
|
389
|
+
# scope removed
|
|
390
|
+
refresh_token: Optional[str] = Field(default=None, description="Refresh token")
|
|
391
|
+
|
|
392
|
+
model_config = ConfigDict(
|
|
393
|
+
extra="allow", populate_by_name=True
|
|
394
|
+
) # Allow extra fields from API and populate by both alias and name
|
|
395
|
+
|
|
396
|
+
def to_oauth_token(self) -> "OAuthToken":
|
|
397
|
+
"""Convert to OAuthToken."""
|
|
398
|
+
return OAuthToken(
|
|
399
|
+
access_token=self.access_token,
|
|
400
|
+
token_type=(self.token_type or "Bearer"),
|
|
401
|
+
expires_in=self.expires_in,
|
|
402
|
+
# scope removed
|
|
403
|
+
refresh_token=self.refresh_token,
|
|
404
|
+
issued_at=datetime.now(), # Set issued_at when converting
|
|
405
|
+
# status is computed property
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class FileMetadata(BaseModel):
|
|
410
|
+
"""File metadata information."""
|
|
411
|
+
|
|
412
|
+
frequency: Optional[str] = Field(None, description="Data frequency")
|
|
413
|
+
regions: Optional[List[str]] = Field(
|
|
414
|
+
None, description="List of regions covered by the data"
|
|
415
|
+
)
|
|
416
|
+
history_start_date: Optional[str] = Field(
|
|
417
|
+
None, alias="history-start-date", description="History start date"
|
|
418
|
+
)
|
|
419
|
+
median_file_size_mb: Optional[str] = Field(
|
|
420
|
+
None, alias="median-file-size-mb", description="Median file size in MB"
|
|
421
|
+
)
|
|
422
|
+
publication_time: Optional[str] = Field(
|
|
423
|
+
None, alias="publication-time", description="Publication time"
|
|
424
|
+
)
|
|
425
|
+
data_lag: Optional[str] = Field(
|
|
426
|
+
None, alias="data-lag", description="Data lag information"
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
model_config = ConfigDict(
|
|
430
|
+
extra="allow", populate_by_name=True
|
|
431
|
+
) # Allow extra fields from API and populate by both alias and name
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class SchemaColumn(BaseModel):
|
|
435
|
+
"""Schema column information."""
|
|
436
|
+
|
|
437
|
+
column_id: str = Field(..., alias="columnId", description="Column identifier")
|
|
438
|
+
column_name: str = Field(..., alias="columnName", description="Column name")
|
|
439
|
+
column_description: Optional[str] = Field(
|
|
440
|
+
None, alias="columnDescription", description="Column description"
|
|
441
|
+
)
|
|
442
|
+
data_type: str = Field(..., alias="dataType", description="Data type")
|
|
443
|
+
|
|
444
|
+
model_config = ConfigDict(
|
|
445
|
+
extra="allow", populate_by_name=True
|
|
446
|
+
) # Allow extra fields from API and populate by both alias and name
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
class DateRange(BaseModel):
|
|
450
|
+
"""Date range information."""
|
|
451
|
+
|
|
452
|
+
earliest: str = Field(..., description="Earliest date in YYYYMMDD format")
|
|
453
|
+
latest: str = Field(..., description="Latest date in YYYYMMDD format")
|
|
454
|
+
|
|
455
|
+
model_config = ConfigDict(
|
|
456
|
+
extra="allow", populate_by_name=True
|
|
457
|
+
) # Allow extra fields from API and populate by both alias and name
|
|
458
|
+
|
|
459
|
+
def get_earliest_date(self) -> Optional[date]:
|
|
460
|
+
"""Get earliest date as date object."""
|
|
461
|
+
try:
|
|
462
|
+
return datetime.strptime(self.earliest, "%Y%m%d").date()
|
|
463
|
+
except ValueError:
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
def get_latest_date(self) -> Optional[date]:
|
|
467
|
+
"""Get latest date as date object."""
|
|
468
|
+
try:
|
|
469
|
+
return datetime.strptime(self.latest, "%Y%m%d").date()
|
|
470
|
+
except ValueError:
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class Group(BaseModel):
|
|
475
|
+
"""Model representing a data group based on the new OpenAPI spec."""
|
|
476
|
+
|
|
477
|
+
item: Optional[int] = Field(None, description="Item number")
|
|
478
|
+
group_id: Optional[str] = Field(
|
|
479
|
+
None, alias="group-id", description="Unique group identifier"
|
|
480
|
+
)
|
|
481
|
+
group_name: Optional[str] = Field(
|
|
482
|
+
None, alias="group-name", description="Display name of the group"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
description: Optional[str] = Field(None, description="Group description")
|
|
486
|
+
provider: Optional[str] = Field(None, description="Data provider")
|
|
487
|
+
premium: Optional[bool] = Field(None, description="Whether this is a premium group")
|
|
488
|
+
population: Optional[Dict[str, Any]] = Field(
|
|
489
|
+
None, description="Population information"
|
|
490
|
+
)
|
|
491
|
+
attributes: Optional[List[Dict[str, Any]]] = Field(
|
|
492
|
+
None, description="Group attributes"
|
|
493
|
+
)
|
|
494
|
+
file_groups: Optional[int] = Field(
|
|
495
|
+
None, alias="file-groups", description="Number of file groups"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
model_config = ConfigDict(
|
|
499
|
+
extra="allow", populate_by_name=True
|
|
500
|
+
) # Allow extra fields from API and populate by both alias and name
|
|
501
|
+
|
|
502
|
+
# associated_file_count removed; use file_groups instead
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class Link(BaseModel):
|
|
506
|
+
"""Pagination link model."""
|
|
507
|
+
|
|
508
|
+
self: Optional[str] = Field(None, description="Current page URL")
|
|
509
|
+
next: Optional[str] = Field(None, description="Next page URL")
|
|
510
|
+
|
|
511
|
+
model_config = ConfigDict(
|
|
512
|
+
extra="allow", populate_by_name=True
|
|
513
|
+
) # Allow extra fields from API and populate by both alias and name
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class GroupList(BaseModel):
|
|
517
|
+
"""Response model for listing groups with pagination support."""
|
|
518
|
+
|
|
519
|
+
groups: List[Group] = Field(..., description="List of groups")
|
|
520
|
+
links: Optional[List[Link]] = Field(None, description="Pagination links")
|
|
521
|
+
|
|
522
|
+
model_config = ConfigDict(
|
|
523
|
+
extra="allow", populate_by_name=True
|
|
524
|
+
) # Allow extra fields from API and populate by both alias and name
|
|
525
|
+
|
|
526
|
+
def get_next_link(self) -> Optional[str]:
|
|
527
|
+
"""Get the next page link URL."""
|
|
528
|
+
if not self.links:
|
|
529
|
+
return None
|
|
530
|
+
for link in self.links:
|
|
531
|
+
if link.next:
|
|
532
|
+
return link.next
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
def has_next_page(self) -> bool:
|
|
536
|
+
"""Check if there's a next page available."""
|
|
537
|
+
return self.get_next_link() is not None
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
class FileInfo(BaseModel):
|
|
541
|
+
"""Model representing file information based on the DataQuery spec.
|
|
542
|
+
|
|
543
|
+
Only `file_group_id` is supported as the identifier.
|
|
544
|
+
"""
|
|
545
|
+
|
|
546
|
+
# Primary identifier
|
|
547
|
+
file_group_id: Optional[str] = Field(
|
|
548
|
+
None, alias="file-group-id", description="Unique file group identifier"
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# Additional info
|
|
552
|
+
description: Optional[str] = Field(None, description="File description")
|
|
553
|
+
file_type: Optional[List[str]] = Field(
|
|
554
|
+
None, alias="file-type", description="Type(s) of the file"
|
|
555
|
+
)
|
|
556
|
+
# legacy fields removed
|
|
557
|
+
metadata: Optional[FileMetadata] = Field(None, description="File metadata")
|
|
558
|
+
file_schema: Optional[List[SchemaColumn]] = Field(
|
|
559
|
+
None, description="File schema", alias="schema"
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
563
|
+
|
|
564
|
+
@field_validator("file_type", mode="before")
|
|
565
|
+
@classmethod
|
|
566
|
+
def normalize_file_type(cls, v):
|
|
567
|
+
"""Accept string or list; normalize to list[str]."""
|
|
568
|
+
if v is None:
|
|
569
|
+
return None
|
|
570
|
+
if isinstance(v, str):
|
|
571
|
+
return [v]
|
|
572
|
+
if isinstance(v, list):
|
|
573
|
+
return [str(x) for x in v if x is not None]
|
|
574
|
+
# Fallback: coerce to string and wrap
|
|
575
|
+
return [str(v)]
|
|
576
|
+
|
|
577
|
+
# inputs must use 'file-group-id'
|
|
578
|
+
|
|
579
|
+
def get_file_extension(self) -> str:
|
|
580
|
+
"""Get file extension based on file type."""
|
|
581
|
+
if not self.file_type:
|
|
582
|
+
return ".bin"
|
|
583
|
+
types_lower = [t.lower() for t in (self.file_type or [])]
|
|
584
|
+
if "parquet" in types_lower:
|
|
585
|
+
return ".parquet"
|
|
586
|
+
elif "csv" in types_lower:
|
|
587
|
+
return ".csv"
|
|
588
|
+
elif "json" in types_lower:
|
|
589
|
+
return ".json"
|
|
590
|
+
else:
|
|
591
|
+
return ".bin"
|
|
592
|
+
|
|
593
|
+
def is_parquet(self) -> bool:
|
|
594
|
+
"""Check if file is Parquet format."""
|
|
595
|
+
return bool(self.file_type) and any(
|
|
596
|
+
(t or "").lower() == "parquet" for t in (self.file_type or [])
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
def is_csv(self) -> bool:
|
|
600
|
+
"""Check if file is CSV format."""
|
|
601
|
+
return bool(self.file_type) and any(
|
|
602
|
+
(t or "").lower() == "csv" for t in (self.file_type or [])
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
def is_json(self) -> bool:
|
|
606
|
+
"""Check if file is JSON format."""
|
|
607
|
+
return bool(self.file_type) and any(
|
|
608
|
+
(t or "").lower() == "json" for t in (self.file_type or [])
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
class FileList(BaseModel):
|
|
613
|
+
"""Response model for listing files."""
|
|
614
|
+
|
|
615
|
+
group_id: str = Field(..., alias="group-id", description="Group identifier")
|
|
616
|
+
file_group_ids: List[FileInfo] = Field(
|
|
617
|
+
..., alias="file-group-ids", description="List of file information"
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
model_config = ConfigDict(
|
|
621
|
+
extra="allow", populate_by_name=True
|
|
622
|
+
) # Allow extra fields from API and populate by both alias and name
|
|
623
|
+
|
|
624
|
+
@property
|
|
625
|
+
def file_count(self) -> int:
|
|
626
|
+
"""Get total number of files."""
|
|
627
|
+
return len(self.file_group_ids)
|
|
628
|
+
|
|
629
|
+
@property
|
|
630
|
+
def file_types(self) -> List[str]:
|
|
631
|
+
"""Get list of file types."""
|
|
632
|
+
types: List[str] = []
|
|
633
|
+
for f in self.file_group_ids:
|
|
634
|
+
ft = getattr(f, "file_type", None)
|
|
635
|
+
if ft:
|
|
636
|
+
types.extend([t for t in ft if isinstance(t, str)])
|
|
637
|
+
return list(set(types))
|
|
638
|
+
|
|
639
|
+
def get_files_by_type(self, file_type: str) -> List[FileInfo]:
|
|
640
|
+
"""Get files by type."""
|
|
641
|
+
target = (file_type or "").lower()
|
|
642
|
+
result: List[FileInfo] = []
|
|
643
|
+
for f in self.file_group_ids:
|
|
644
|
+
ft = getattr(f, "file_type", None)
|
|
645
|
+
if ft and any((t or "").lower() == target for t in ft):
|
|
646
|
+
result.append(f)
|
|
647
|
+
return result
|
|
648
|
+
|
|
649
|
+
def get_date_range(self) -> Optional[DateRange]:
|
|
650
|
+
"""Get overall date range from metadata if available."""
|
|
651
|
+
# This would need to be implemented based on actual metadata structure
|
|
652
|
+
# For now, return None as the new spec doesn't show date range in file list
|
|
653
|
+
return None
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
class AvailabilityInfo(BaseModel):
|
|
657
|
+
"""Model representing file availability information."""
|
|
658
|
+
|
|
659
|
+
group_id: Optional[str] = Field(
|
|
660
|
+
None, alias="group-id", description="Group identifier"
|
|
661
|
+
)
|
|
662
|
+
file_group_id: Optional[str] = Field(
|
|
663
|
+
None, alias="file-group-id", description="File group identifier"
|
|
664
|
+
)
|
|
665
|
+
file_date: str = Field(
|
|
666
|
+
..., alias="file-datetime", description="File date in YYYYMMDD format"
|
|
667
|
+
)
|
|
668
|
+
is_available: bool = Field(
|
|
669
|
+
..., alias="is-available", description="Whether the file is available"
|
|
670
|
+
)
|
|
671
|
+
file_name: Optional[str] = Field(
|
|
672
|
+
None, alias="file-name", description="Name of the file"
|
|
673
|
+
)
|
|
674
|
+
first_created_on: Optional[str] = Field(
|
|
675
|
+
None, alias="first-created-on", description="First creation timestamp"
|
|
676
|
+
)
|
|
677
|
+
last_modified: Optional[str] = Field(
|
|
678
|
+
None, alias="last-modified", description="Last modification timestamp"
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
model_config = ConfigDict(
|
|
682
|
+
extra="allow", populate_by_name=True
|
|
683
|
+
) # Allow extra fields from API and populate by both alias and name
|
|
684
|
+
|
|
685
|
+
def get_file_date(self) -> Optional[date]:
|
|
686
|
+
"""Get file date as date object."""
|
|
687
|
+
try:
|
|
688
|
+
return datetime.strptime(self.file_date, "%Y%m%d").date()
|
|
689
|
+
except ValueError:
|
|
690
|
+
return None
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
class AvailabilityResponse(BaseModel):
|
|
694
|
+
"""Deprecated: Use AvailabilityInfo directly."""
|
|
695
|
+
|
|
696
|
+
group_id: Optional[str] = Field(None, alias="group-id")
|
|
697
|
+
file_group_id: Optional[str] = Field(None, alias="file-group-id")
|
|
698
|
+
date_range: Optional[DateRange] = Field(None, alias="date-range")
|
|
699
|
+
availability: List[AvailabilityInfo] = Field(default_factory=list)
|
|
700
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
701
|
+
|
|
702
|
+
@property
|
|
703
|
+
def available_files(self) -> List[AvailabilityInfo]:
|
|
704
|
+
return [f for f in self.availability if f.is_available]
|
|
705
|
+
|
|
706
|
+
@property
|
|
707
|
+
def unavailable_files(self) -> List[AvailabilityInfo]:
|
|
708
|
+
return [f for f in self.availability if not f.is_available]
|
|
709
|
+
|
|
710
|
+
@property
|
|
711
|
+
def availability_rate(self) -> float:
|
|
712
|
+
if not self.availability:
|
|
713
|
+
return 0.0
|
|
714
|
+
return (len(self.available_files) / len(self.availability)) * 100
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
class AvailableFilesResponse(BaseModel):
|
|
718
|
+
"""Response model for listing available files by date range."""
|
|
719
|
+
|
|
720
|
+
group_id: str = Field(..., alias="group-id", description="Group identifier")
|
|
721
|
+
file_group_id: Optional[str] = Field(
|
|
722
|
+
None, alias="file-group-id", description="File identifier"
|
|
723
|
+
)
|
|
724
|
+
start_date: Optional[str] = Field(
|
|
725
|
+
None, alias="start-date", description="Start date"
|
|
726
|
+
)
|
|
727
|
+
end_date: Optional[str] = Field(None, alias="end-date", description="End date")
|
|
728
|
+
available_files: List[Dict[str, Any]] = Field(
|
|
729
|
+
..., alias="available-files", description="List of available files"
|
|
730
|
+
)
|
|
731
|
+
summary: Optional[Dict[str, Any]] = Field(
|
|
732
|
+
None, alias="summary", description="Summary of available files"
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
model_config = ConfigDict(
|
|
736
|
+
extra="allow", populate_by_name=True
|
|
737
|
+
) # Allow extra fields from API and populate by both alias and name
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
class DownloadProgress(BaseModel):
|
|
741
|
+
"""Model representing download progress."""
|
|
742
|
+
|
|
743
|
+
file_group_id: str = Field(..., description="File identifier")
|
|
744
|
+
bytes_downloaded: int = Field(default=0, description="Number of bytes downloaded")
|
|
745
|
+
total_bytes: int = Field(default=0, description="Total number of bytes to download")
|
|
746
|
+
percentage: float = Field(default=0.0, description="Download percentage (0-100)")
|
|
747
|
+
speed_bps: float = Field(
|
|
748
|
+
default=0.0, description="Download speed in bytes per second"
|
|
749
|
+
)
|
|
750
|
+
eta_seconds: Optional[float] = Field(
|
|
751
|
+
default=None, description="Estimated time to completion in seconds"
|
|
752
|
+
)
|
|
753
|
+
start_time: datetime = Field(
|
|
754
|
+
default_factory=datetime.now, description="Download start time"
|
|
755
|
+
)
|
|
756
|
+
last_update: datetime = Field(
|
|
757
|
+
default_factory=datetime.now, description="Last progress update time"
|
|
758
|
+
)
|
|
759
|
+
status: DownloadStatus = Field(
|
|
760
|
+
default=DownloadStatus.PENDING, description="Download status"
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
model_config = ConfigDict(extra="allow") # Allow extra fields from API
|
|
764
|
+
|
|
765
|
+
@property
|
|
766
|
+
def is_complete(self) -> bool:
|
|
767
|
+
"""Check if download is complete."""
|
|
768
|
+
return self.percentage >= 100.0
|
|
769
|
+
|
|
770
|
+
@property
|
|
771
|
+
def remaining_bytes(self) -> int:
|
|
772
|
+
"""Get remaining bytes to download."""
|
|
773
|
+
return max(0, self.total_bytes - self.bytes_downloaded)
|
|
774
|
+
|
|
775
|
+
def update_progress(self, bytes_downloaded: int, speed_bps: Optional[float] = None):
|
|
776
|
+
"""Update download progress."""
|
|
777
|
+
self.bytes_downloaded = bytes_downloaded
|
|
778
|
+
self.percentage = (
|
|
779
|
+
(bytes_downloaded / self.total_bytes) * 100 if self.total_bytes > 0 else 0
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
if speed_bps:
|
|
783
|
+
self.speed_bps = speed_bps
|
|
784
|
+
|
|
785
|
+
self.last_update = datetime.now()
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
class DownloadOptions(BaseModel):
|
|
789
|
+
"""Options for file downloads."""
|
|
790
|
+
|
|
791
|
+
# File options
|
|
792
|
+
destination_path: Optional[Path] = Field(
|
|
793
|
+
default=None, description="Local path to save the file"
|
|
794
|
+
)
|
|
795
|
+
create_directories: bool = Field(
|
|
796
|
+
default=True, description="Create parent directories if they don't exist"
|
|
797
|
+
)
|
|
798
|
+
overwrite_existing: bool = Field(
|
|
799
|
+
default=False, description="Overwrite existing files"
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
# Download options
|
|
803
|
+
chunk_size_setting: int = Field(
|
|
804
|
+
default=8192,
|
|
805
|
+
description="Chunk size for streaming downloads",
|
|
806
|
+
alias="chunk_size",
|
|
807
|
+
)
|
|
808
|
+
max_retries: int = Field(default=3, description="Maximum number of retry attempts")
|
|
809
|
+
retry_delay: float = Field(
|
|
810
|
+
default=1.0, description="Delay between retries in seconds"
|
|
811
|
+
)
|
|
812
|
+
timeout: float = Field(default=600.0, description="Request timeout in seconds")
|
|
813
|
+
|
|
814
|
+
# Range requests
|
|
815
|
+
enable_range_requests: bool = Field(
|
|
816
|
+
default=True, description="Enable HTTP range requests for resumable downloads"
|
|
817
|
+
)
|
|
818
|
+
range_start: Optional[int] = Field(
|
|
819
|
+
default=None, description="Start byte position for range download (0-based)"
|
|
820
|
+
)
|
|
821
|
+
range_end: Optional[int] = Field(
|
|
822
|
+
default=None, description="End byte position for range download (inclusive)"
|
|
823
|
+
)
|
|
824
|
+
range_header: Optional[str] = Field(
|
|
825
|
+
default=None, description="Custom range header (e.g., 'bytes=0-1023')"
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
# Progress tracking
|
|
829
|
+
show_progress: bool = Field(default=True, description="Show download progress")
|
|
830
|
+
progress_callback: Optional[Any] = Field(
|
|
831
|
+
default=None, description="Custom progress callback function"
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
model_config = ConfigDict(extra="allow") # Allow extra fields from API
|
|
835
|
+
|
|
836
|
+
@property
|
|
837
|
+
def overwrite(self) -> bool:
|
|
838
|
+
"""Backward compatibility property."""
|
|
839
|
+
# Check if overwrite was passed in extra fields
|
|
840
|
+
extra_overwrite = getattr(self, "__pydantic_extra__", {}).get("overwrite")
|
|
841
|
+
if extra_overwrite is not None:
|
|
842
|
+
return extra_overwrite
|
|
843
|
+
return self.overwrite_existing
|
|
844
|
+
|
|
845
|
+
@property
|
|
846
|
+
def verify_checksum(self) -> bool:
|
|
847
|
+
"""Backward compatibility property."""
|
|
848
|
+
# Check if verify_checksum was passed in extra fields
|
|
849
|
+
extra_verify_checksum = getattr(self, "__pydantic_extra__", {}).get(
|
|
850
|
+
"verify_checksum"
|
|
851
|
+
)
|
|
852
|
+
if extra_verify_checksum is not None:
|
|
853
|
+
return extra_verify_checksum
|
|
854
|
+
return False # Default to False for backward compatibility
|
|
855
|
+
|
|
856
|
+
@property
|
|
857
|
+
def chunk_size(self) -> int:
|
|
858
|
+
"""Public chunk size value always returning a positive integer."""
|
|
859
|
+
extra_chunk_size = getattr(self, "__pydantic_extra__", {}).get("chunk_size")
|
|
860
|
+
value = (
|
|
861
|
+
extra_chunk_size
|
|
862
|
+
if extra_chunk_size is not None
|
|
863
|
+
else self.chunk_size_setting
|
|
864
|
+
)
|
|
865
|
+
# Safety clamp
|
|
866
|
+
try:
|
|
867
|
+
value_int = int(value)
|
|
868
|
+
except Exception:
|
|
869
|
+
value_int = 8192
|
|
870
|
+
if value_int <= 0:
|
|
871
|
+
value_int = 8192
|
|
872
|
+
if value_int > 1024 * 1024:
|
|
873
|
+
value_int = 1024 * 1024
|
|
874
|
+
return value_int
|
|
875
|
+
|
|
876
|
+
@field_validator("chunk_size_setting")
|
|
877
|
+
@classmethod
|
|
878
|
+
def validate_chunk_size(cls, v):
|
|
879
|
+
if v <= 0:
|
|
880
|
+
raise ValueError("Chunk size must be positive")
|
|
881
|
+
if v > 1024 * 1024: # 1MB
|
|
882
|
+
raise ValueError("Chunk size cannot exceed 1MB")
|
|
883
|
+
return v
|
|
884
|
+
|
|
885
|
+
@field_validator("range_start", "range_end")
|
|
886
|
+
@classmethod
|
|
887
|
+
def validate_range_values(cls, v):
|
|
888
|
+
if v is not None and v < 0:
|
|
889
|
+
raise ValueError("Range values must be non-negative")
|
|
890
|
+
return v
|
|
891
|
+
|
|
892
|
+
@field_validator("range_end")
|
|
893
|
+
@classmethod
|
|
894
|
+
def validate_range_end(cls, v, info):
|
|
895
|
+
if v is not None and "range_start" in info.data:
|
|
896
|
+
range_start = info.data["range_start"]
|
|
897
|
+
if range_start is not None and v <= range_start:
|
|
898
|
+
raise ValueError("Range end must be greater than range start")
|
|
899
|
+
return v
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
class DownloadResult(BaseModel):
|
|
903
|
+
"""Result of a download operation."""
|
|
904
|
+
|
|
905
|
+
file_group_id: str = Field(..., description="File identifier")
|
|
906
|
+
group_id: Optional[str] = Field(None, description="Group identifier")
|
|
907
|
+
local_path: Optional[Path] = Field(
|
|
908
|
+
None, description="Local path where file was saved"
|
|
909
|
+
)
|
|
910
|
+
file_size: Optional[int] = Field(None, description="Size of the downloaded file")
|
|
911
|
+
download_time: Optional[float] = Field(
|
|
912
|
+
None, description="Time taken for download in seconds"
|
|
913
|
+
)
|
|
914
|
+
bytes_downloaded: Optional[int] = Field(None, description="Total bytes downloaded")
|
|
915
|
+
status: DownloadStatus = Field(DownloadStatus.FAILED, description="Download status")
|
|
916
|
+
error_message: Optional[str] = Field(
|
|
917
|
+
None, description="Error message if download failed"
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
# legacy download result fields removed
|
|
921
|
+
|
|
922
|
+
model_config = {"extra": "allow"} # Allow extra fields from API
|
|
923
|
+
|
|
924
|
+
@property
|
|
925
|
+
def speed_mbps(self) -> float:
|
|
926
|
+
"""Calculate download speed in MB/s."""
|
|
927
|
+
if not self.download_time or self.download_time <= 0 or not self.file_size:
|
|
928
|
+
return 0.0
|
|
929
|
+
return (self.file_size / (1024 * 1024)) / self.download_time
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
class Instrument(BaseModel):
|
|
933
|
+
"""Model representing an instrument from the DataQuery catalog."""
|
|
934
|
+
|
|
935
|
+
item: int = Field(..., description="Item number")
|
|
936
|
+
instrument_id: str = Field(
|
|
937
|
+
..., alias="instrument-id", description="Unique instrument identifier"
|
|
938
|
+
)
|
|
939
|
+
instrument_name: str = Field(
|
|
940
|
+
..., alias="instrument-name", description="Instrument short name"
|
|
941
|
+
)
|
|
942
|
+
country: Optional[str] = Field(None, description="ISO-3166-1 alpha-3 country code")
|
|
943
|
+
currency: Optional[str] = Field(None, description="ISO-4217 currency code")
|
|
944
|
+
instrument_cusip: Optional[str] = Field(
|
|
945
|
+
None, alias="instrument-cusip", description="Instrument CUSIP"
|
|
946
|
+
)
|
|
947
|
+
instrument_isin: Optional[str] = Field(
|
|
948
|
+
None, alias="instrument-isin", description="Instrument ISIN"
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
class Attribute(BaseModel):
|
|
955
|
+
"""Model representing an attribute measure for an instrument."""
|
|
956
|
+
|
|
957
|
+
attribute_id: str = Field(
|
|
958
|
+
..., alias="attribute-id", description="Unique attribute measure identifier"
|
|
959
|
+
)
|
|
960
|
+
attribute_name: str = Field(
|
|
961
|
+
..., alias="attribute-name", description="Attribute short name"
|
|
962
|
+
)
|
|
963
|
+
expression: str = Field(
|
|
964
|
+
..., description="Traditional DataQuery time-series identifier"
|
|
965
|
+
)
|
|
966
|
+
label: str = Field(..., description="Name of a time-series data set")
|
|
967
|
+
last_published: Optional[str] = Field(
|
|
968
|
+
None, alias="last-published", description="Date/Time data was last published"
|
|
969
|
+
)
|
|
970
|
+
message: Optional[str] = Field(None, description="Attribute level user message")
|
|
971
|
+
time_series: Optional[List[List[Union[str, float]]]] = Field(
|
|
972
|
+
None, alias="time-series", description="Time series data"
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
class Filter(BaseModel):
|
|
979
|
+
"""Model representing a filter dimension."""
|
|
980
|
+
|
|
981
|
+
filter_name: str = Field(
|
|
982
|
+
..., alias="filter-name", description="Name of a filter dimension"
|
|
983
|
+
)
|
|
984
|
+
description: Optional[str] = Field(
|
|
985
|
+
None, alias="filter-description", description="Description of a filter"
|
|
986
|
+
)
|
|
987
|
+
values: Optional[List[str]] = Field(
|
|
988
|
+
None, description="Valid filter value enumerator"
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
class TimeSeriesDataPoint(BaseModel):
|
|
995
|
+
"""Model representing a single time series data point."""
|
|
996
|
+
|
|
997
|
+
date: str = Field(..., description="Date in YYYYMMDD format")
|
|
998
|
+
value: float = Field(..., description="Measurable value")
|
|
999
|
+
|
|
1000
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
class InstrumentWithAttributes(BaseModel):
|
|
1004
|
+
"""Model representing an instrument with its attributes."""
|
|
1005
|
+
|
|
1006
|
+
item: int = Field(..., description="Item number")
|
|
1007
|
+
instrument_id: str = Field(
|
|
1008
|
+
..., alias="instrument-id", description="Unique instrument identifier"
|
|
1009
|
+
)
|
|
1010
|
+
instrument_name: str = Field(
|
|
1011
|
+
..., alias="instrument-name", description="Instrument short name"
|
|
1012
|
+
)
|
|
1013
|
+
instrument_cusip: Optional[str] = Field(
|
|
1014
|
+
None, alias="instrument-cusip", description="Instrument CUSIP"
|
|
1015
|
+
)
|
|
1016
|
+
instrument_isin: Optional[str] = Field(
|
|
1017
|
+
None, alias="instrument-isin", description="Instrument ISIN"
|
|
1018
|
+
)
|
|
1019
|
+
group: Optional[Dict[str, str]] = Field(None, description="Group information")
|
|
1020
|
+
attributes: List[Attribute] = Field(..., description="List of attributes")
|
|
1021
|
+
|
|
1022
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
class InstrumentResponse(BaseModel):
|
|
1026
|
+
"""Response model for a single instrument."""
|
|
1027
|
+
|
|
1028
|
+
instrument: Instrument = Field(..., description="Single instrument")
|
|
1029
|
+
|
|
1030
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
class InstrumentsResponse(BaseModel):
|
|
1034
|
+
"""Response model for listing instruments."""
|
|
1035
|
+
|
|
1036
|
+
links: Optional[List[Link]] = Field(None, description="Pagination links")
|
|
1037
|
+
items: int = Field(..., description="Total number of items")
|
|
1038
|
+
page_size: int = Field(
|
|
1039
|
+
..., alias="page-size", description="Number of items per page"
|
|
1040
|
+
)
|
|
1041
|
+
instruments: List[Instrument] = Field(..., description="List of instruments")
|
|
1042
|
+
|
|
1043
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
class AttributesResponse(BaseModel):
|
|
1047
|
+
"""Response model for listing attributes."""
|
|
1048
|
+
|
|
1049
|
+
links: Optional[List[Link]] = Field(None, description="Pagination links")
|
|
1050
|
+
items: int = Field(..., description="Total number of items")
|
|
1051
|
+
page_size: int = Field(
|
|
1052
|
+
..., alias="page-size", description="Number of items per page"
|
|
1053
|
+
)
|
|
1054
|
+
instruments: List[InstrumentWithAttributes] = Field(
|
|
1055
|
+
..., description="List of instruments with attributes"
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
class FiltersResponse(BaseModel):
|
|
1062
|
+
"""Response model for listing filters."""
|
|
1063
|
+
|
|
1064
|
+
links: Optional[List[Link]] = Field(None, description="Pagination links")
|
|
1065
|
+
items: int = Field(..., description="Total number of items")
|
|
1066
|
+
page_size: int = Field(
|
|
1067
|
+
..., alias="page-size", description="Number of items per page"
|
|
1068
|
+
)
|
|
1069
|
+
filters: List[Filter] = Field(..., description="List of filters")
|
|
1070
|
+
|
|
1071
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
class TimeSeriesResponse(BaseModel):
|
|
1075
|
+
"""Response model for time series data."""
|
|
1076
|
+
|
|
1077
|
+
links: Optional[List[Link]] = Field(None, description="Pagination links")
|
|
1078
|
+
items: int = Field(..., description="Total number of items")
|
|
1079
|
+
page_size: int = Field(
|
|
1080
|
+
..., alias="page-size", description="Number of items per page"
|
|
1081
|
+
)
|
|
1082
|
+
instruments: List[InstrumentWithAttributes] = Field(
|
|
1083
|
+
..., description="List of instruments with time series data"
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
class GridDataSeries(BaseModel):
|
|
1090
|
+
"""Model representing a grid data series."""
|
|
1091
|
+
|
|
1092
|
+
expr: str = Field(..., description="The expression for the grid data")
|
|
1093
|
+
error_code: Optional[str] = Field(
|
|
1094
|
+
None, alias="errorCode", description="Error code for this specific expression"
|
|
1095
|
+
)
|
|
1096
|
+
error_message: Optional[str] = Field(
|
|
1097
|
+
None,
|
|
1098
|
+
alias="errorMessage",
|
|
1099
|
+
description="Error message for this specific expression",
|
|
1100
|
+
)
|
|
1101
|
+
records: Optional[List[Dict[str, Any]]] = Field(
|
|
1102
|
+
None, description="The grid data records"
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
class GridDataResponse(BaseModel):
|
|
1109
|
+
"""Response model for grid data."""
|
|
1110
|
+
|
|
1111
|
+
error_code: Optional[str] = Field(
|
|
1112
|
+
None, alias="errorCode", description="Error code for the entire API request"
|
|
1113
|
+
)
|
|
1114
|
+
error_message: Optional[str] = Field(
|
|
1115
|
+
None,
|
|
1116
|
+
alias="errorMessage",
|
|
1117
|
+
description="Error message for the entire API request",
|
|
1118
|
+
)
|
|
1119
|
+
series: List[GridDataSeries] = Field(..., description="List of grid data series")
|
|
1120
|
+
|
|
1121
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
class ServiceStatus(BaseModel):
|
|
1125
|
+
"""Model representing service status."""
|
|
1126
|
+
|
|
1127
|
+
code: int = Field(..., description="Status code")
|
|
1128
|
+
description: str = Field(..., description="Status description")
|
|
1129
|
+
|
|
1130
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
class ErrorResponse(BaseModel):
|
|
1134
|
+
"""Error response from the API matching DataQuery specification."""
|
|
1135
|
+
|
|
1136
|
+
code: Union[int, str] = Field(..., description="DataQuery error code")
|
|
1137
|
+
description: str = Field(
|
|
1138
|
+
..., description="Description of the error that has occurred"
|
|
1139
|
+
)
|
|
1140
|
+
interaction_id: Optional[str] = Field(
|
|
1141
|
+
None,
|
|
1142
|
+
alias="x-dataquery-interaction-id",
|
|
1143
|
+
description="Interaction ID for traceability",
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
class AuthenticationErrorResponse(ErrorResponse):
|
|
1150
|
+
"""Authentication or Authorization information missing or invalid."""
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
class BadRequestResponse(ErrorResponse):
|
|
1154
|
+
"""The request received was malformed or invalid."""
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
class NotFoundResponse(ErrorResponse):
|
|
1158
|
+
"""The requested resource was not found."""
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
class ForbiddenPremiumResponse(ErrorResponse):
|
|
1162
|
+
"""Premium data access required."""
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
class InternalServerErrorResponse(ErrorResponse):
|
|
1166
|
+
"""Internal Server Error."""
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
class Information(BaseModel):
|
|
1170
|
+
"""A DataQuery information message."""
|
|
1171
|
+
|
|
1172
|
+
code: Union[int, str] = Field(
|
|
1173
|
+
..., description="DataQuery code for information message"
|
|
1174
|
+
)
|
|
1175
|
+
description: str = Field(..., description="Description of information provided")
|
|
1176
|
+
|
|
1177
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
class NoContentResponse(Information):
|
|
1181
|
+
"""Request successfully processed but no content available."""
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
class Available(Information):
|
|
1185
|
+
"""DataQuery Services are functioning as expected."""
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
class Unavailable(ErrorResponse):
|
|
1189
|
+
"""DataQuery Services are currently unavailable."""
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
# Time series parameters from OpenAPI specification
|
|
1193
|
+
|
|
1194
|
+
|
|
1195
|
+
class DataType(str, Enum):
|
|
1196
|
+
"""Data type for time series requests."""
|
|
1197
|
+
|
|
1198
|
+
REFERENCE_DATA = "REFERENCE_DATA"
|
|
1199
|
+
NO_REFERENCE_DATA = "NO_REFERENCE_DATA"
|
|
1200
|
+
ALL = "ALL"
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
class Calendar(str, Enum):
|
|
1204
|
+
"""Calendar convention for time-series."""
|
|
1205
|
+
|
|
1206
|
+
CAL_USBANK = "CAL_USBANK" # Default
|
|
1207
|
+
CAL_ALLDAYS = "CAL_ALLDAYS"
|
|
1208
|
+
CAL_WEEKDAYS = "CAL_WEEKDAYS"
|
|
1209
|
+
CAL_AUSTRALIA = "CAL_AUSTRALIA"
|
|
1210
|
+
CAL_BELGIUM = "CAL_BELGIUM"
|
|
1211
|
+
CAL_CANADA = "CAL_CANADA"
|
|
1212
|
+
CAL_DENMARK = "CAL_DENMARK"
|
|
1213
|
+
CAL_EURO = "CAL_EURO"
|
|
1214
|
+
CAL_FINLAND = "CAL_FINLAND"
|
|
1215
|
+
CAL_FRANCE = "CAL_FRANCE"
|
|
1216
|
+
CAL_GERMANY = "CAL_GERMANY"
|
|
1217
|
+
CAL_HONGKONG = "CAL_HONGKONG"
|
|
1218
|
+
CAL_IRELAND = "CAL_IRELAND"
|
|
1219
|
+
CAL_ITALY = "CAL_ITALY"
|
|
1220
|
+
CAL_JAPAN = "CAL_JAPAN"
|
|
1221
|
+
CAL_MALAYSIA = "CAL_MALAYSIA"
|
|
1222
|
+
CAL_NETHERLANDS = "CAL_NETHERLANDS"
|
|
1223
|
+
CAL_NEWZEALAND = "CAL_NEWZEALAND"
|
|
1224
|
+
CAL_NYSE = "CAL_NYSE"
|
|
1225
|
+
CAL_PORTUGAL = "CAL_PORTUGAL"
|
|
1226
|
+
CAL_SAFRICA = "CAL_SAFRICA"
|
|
1227
|
+
CAL_SINGAPORE = "CAL_SINGAPORE"
|
|
1228
|
+
CAL_SPAIN = "CAL_SPAIN"
|
|
1229
|
+
CAL_SWEDEN = "CAL_SWEDEN"
|
|
1230
|
+
CAL_SWITZERLAND = "CAL_SWITZERLAND"
|
|
1231
|
+
CAL_USEXCH = "CAL_USEXCH"
|
|
1232
|
+
CAL_UK = "CAL_UK"
|
|
1233
|
+
CALSOF = "CALSOF"
|
|
1234
|
+
CALTGT = "CALTGT"
|
|
1235
|
+
CALLIF = "CALLIF"
|
|
1236
|
+
CAL_EUR_UKFIN = "CAL_EUR_UKFIN"
|
|
1237
|
+
CAL_UK_TGT = "CAL_UK_TGT"
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
class Frequency(str, Enum):
|
|
1241
|
+
"""Frequency convention for time-series."""
|
|
1242
|
+
|
|
1243
|
+
FREQ_INTRA = "FREQ_INTRA"
|
|
1244
|
+
FREQ_DAY = "FREQ_DAY" # Default
|
|
1245
|
+
FREQ_WEEK = "FREQ_WEEK"
|
|
1246
|
+
FREQ_MONTH = "FREQ_MONTH"
|
|
1247
|
+
FREQ_QUARTER = "FREQ_QUARTER"
|
|
1248
|
+
FREQ_ANN = "FREQ_ANN"
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
class Conversion(str, Enum):
|
|
1252
|
+
"""Conversion convention for time-series."""
|
|
1253
|
+
|
|
1254
|
+
CONV_LASTBUS_ABS = "CONV_LASTBUS_ABS" # Default
|
|
1255
|
+
CONV_FIRSTBUS_ABS = "CONV_FIRSTBUS_ABS"
|
|
1256
|
+
CONV_LASTBUS_REL = "CONV_LASTBUS_REL"
|
|
1257
|
+
CONV_FIRSTBUS_REL = "CONV_FIRSTBUS_REL"
|
|
1258
|
+
CONV_SUM_ABS_SDT = "CONV_SUM_ABS_SDT"
|
|
1259
|
+
CONV_SUM_ABS_EDT = "CONV_SUM_ABS_EDT"
|
|
1260
|
+
CONV_SUM_REL_SDT = "CONV_SUM_REL_SDT"
|
|
1261
|
+
CONV_SUM_REL_EDT = "CONV_SUM_REL_EDT"
|
|
1262
|
+
CONV_AVG_ABS_SDT = "CONV_AVG_ABS_SDT"
|
|
1263
|
+
CONV_AVG_ABS_EDT = "CONV_AVG_ABS_EDT"
|
|
1264
|
+
CONV_AVG_REL_SDT = "CONV_AVG_REL_SDT"
|
|
1265
|
+
CONV_AVG_REL_EDT = "CONV_AVG_REL_EDT"
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
class NanTreatment(str, Enum):
|
|
1269
|
+
"""Missing data point treatment for time-series."""
|
|
1270
|
+
|
|
1271
|
+
NA_NOTHING = "NA_NOTHING" # Default
|
|
1272
|
+
NA_LAST = "NA_LAST"
|
|
1273
|
+
NA_NEXT = "NA_NEXT"
|
|
1274
|
+
NA_INTERP = "NA_INTERP"
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
class Format(str, Enum):
|
|
1278
|
+
"""Response format."""
|
|
1279
|
+
|
|
1280
|
+
JSON = "JSON"
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
class TimeSeriesParameters(BaseModel):
|
|
1284
|
+
"""Parameters for time series requests."""
|
|
1285
|
+
|
|
1286
|
+
data: DataType = Field(DataType.REFERENCE_DATA, description="Data type to retrieve")
|
|
1287
|
+
format: Format = Field(Format.JSON, description="Response format")
|
|
1288
|
+
start_date: Optional[str] = Field(
|
|
1289
|
+
"TODAY-1D", alias="start-date", description="Start date"
|
|
1290
|
+
)
|
|
1291
|
+
end_date: Optional[str] = Field("TODAY", alias="end-date", description="End date")
|
|
1292
|
+
calendar: Calendar = Field(Calendar.CAL_USBANK, description="Calendar convention")
|
|
1293
|
+
frequency: Frequency = Field(Frequency.FREQ_DAY, description="Frequency convention")
|
|
1294
|
+
conversion: Conversion = Field(
|
|
1295
|
+
Conversion.CONV_LASTBUS_ABS, description="Conversion convention"
|
|
1296
|
+
)
|
|
1297
|
+
nan_treatment: NanTreatment = Field(
|
|
1298
|
+
NanTreatment.NA_NOTHING,
|
|
1299
|
+
alias="nan-treatment",
|
|
1300
|
+
description="Missing data treatment",
|
|
1301
|
+
)
|
|
1302
|
+
|
|
1303
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|