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 ADDED
@@ -0,0 +1,215 @@
1
+ """
2
+ DATAQUERY SDK - Python SDK for DATAQUERY Data API
3
+
4
+ A high-performance Python SDK for the DATAQUERY Data API, providing seamless access
5
+ to economic data files with advanced features like querying, downloading, availability
6
+ checking, rate limiting, retry logic, connection pool monitoring, and comprehensive logging.
7
+
8
+ Quick Start:
9
+ >>> from dataquery import DataQuery
10
+ >>> async with DataQuery() as dq:
11
+ ... groups = await dq.list_groups_async()
12
+ ... print(f"Found {len(groups)} groups")
13
+
14
+ For more information, visit: https://github.com/dataquery/dataquery-sdk
15
+ """
16
+
17
+ __version__ = "0.0.7"
18
+ __author__ = "DATAQUERY SDK Team"
19
+ __email__ = "support@dataquery.com"
20
+ __license__ = "MIT"
21
+ __url__ = "https://github.com/dataquery/dataquery-sdk-python"
22
+
23
+ # Authentication imports
24
+ from .auth import (
25
+ OAuthManager,
26
+ TokenManager,
27
+ )
28
+
29
+ # Auto-download imports
30
+ from .auto_download import AutoDownloadManager
31
+
32
+ # Client imports
33
+ from .client import DataQueryClient
34
+
35
+ # Configuration imports
36
+ from .config import EnvConfig
37
+
38
+ # Connection pool imports
39
+ from .connection_pool import (
40
+ ConnectionPoolConfig,
41
+ ConnectionPoolMonitor,
42
+ ConnectionPoolStats,
43
+ )
44
+
45
+ # Core imports
46
+ from .dataquery import DataQuery, setup_logging
47
+ from .exceptions import (
48
+ AuthenticationError,
49
+ AvailabilityError,
50
+ ConfigurationError,
51
+ DataQueryError,
52
+ DateRangeError,
53
+ DownloadError,
54
+ FileNotFoundError,
55
+ FileTypeError,
56
+ GroupNotFoundError,
57
+ NetworkError,
58
+ NotFoundError,
59
+ RateLimitError,
60
+ ValidationError,
61
+ WorkflowError,
62
+ )
63
+
64
+ # Logging imports
65
+ from .logging_config import (
66
+ LogFormat,
67
+ LoggingConfig,
68
+ LoggingManager,
69
+ LogLevel,
70
+ )
71
+ from .models import (
72
+ AvailabilityInfo,
73
+ AvailableFilesResponse,
74
+ ClientConfig,
75
+ DateRange,
76
+ DownloadOptions,
77
+ DownloadProgress,
78
+ DownloadResult,
79
+ DownloadStatus,
80
+ FileInfo,
81
+ FileList,
82
+ Group,
83
+ GroupList,
84
+ OAuthToken,
85
+ TokenRequest,
86
+ TokenResponse,
87
+ )
88
+
89
+ # Rate limiting imports
90
+ from .rate_limiter import (
91
+ EnhancedTokenBucketRateLimiter,
92
+ QueuePriority,
93
+ RateLimitConfig,
94
+ RateLimitContext,
95
+ TokenBucketRateLimiter,
96
+ create_rate_limiter,
97
+ )
98
+
99
+ # Retry imports
100
+ from .retry import (
101
+ CircuitBreaker,
102
+ CircuitState,
103
+ RetryConfig,
104
+ RetryManager,
105
+ RetryStrategy,
106
+ )
107
+
108
+ # Common utility helpers
109
+ # Utility imports
110
+ from .utils import (
111
+ create_env_template,
112
+ ensure_directory,
113
+ format_duration,
114
+ format_file_size,
115
+ get_download_paths,
116
+ get_env_value,
117
+ load_env_file,
118
+ save_config_to_env,
119
+ set_env_value,
120
+ validate_env_config,
121
+ )
122
+
123
+ # Type aliases for convenience
124
+ __all__ = [
125
+ # Core
126
+ "DataQuery",
127
+ "setup_logging",
128
+ # Models
129
+ "ClientConfig",
130
+ "Group",
131
+ "FileInfo",
132
+ "FileList",
133
+ "AvailabilityInfo",
134
+ "DownloadResult",
135
+ "DownloadStatus",
136
+ "DownloadOptions",
137
+ "DownloadProgress",
138
+ "OAuthToken",
139
+ "TokenRequest",
140
+ "TokenResponse",
141
+ "DateRange",
142
+ "GroupList",
143
+ "AvailableFilesResponse",
144
+ # Exceptions
145
+ "DataQueryError",
146
+ "AuthenticationError",
147
+ "ValidationError",
148
+ "NotFoundError",
149
+ "RateLimitError",
150
+ "NetworkError",
151
+ "ConfigurationError",
152
+ "DownloadError",
153
+ "AvailabilityError",
154
+ "GroupNotFoundError",
155
+ "FileNotFoundError",
156
+ "DateRangeError",
157
+ "FileTypeError",
158
+ "WorkflowError",
159
+ # Client
160
+ "DataQueryClient",
161
+ "format_file_size",
162
+ "format_duration",
163
+ "AutoDownloadManager",
164
+ # Configuration
165
+ "EnvConfig",
166
+ # Utilities
167
+ "create_env_template",
168
+ "save_config_to_env",
169
+ "load_env_file",
170
+ "get_env_value",
171
+ "set_env_value",
172
+ "validate_env_config",
173
+ "ensure_directory",
174
+ "get_download_paths",
175
+ # Rate Limiting
176
+ "TokenBucketRateLimiter",
177
+ "EnhancedTokenBucketRateLimiter",
178
+ "RateLimitConfig",
179
+ "RateLimitContext",
180
+ "QueuePriority",
181
+ "create_rate_limiter",
182
+ # Retry
183
+ "RetryManager",
184
+ "RetryConfig",
185
+ "RetryStrategy",
186
+ "CircuitBreaker",
187
+ "CircuitState",
188
+ # Connection Pool
189
+ "ConnectionPoolMonitor",
190
+ "ConnectionPoolConfig",
191
+ "ConnectionPoolStats",
192
+ # Logging
193
+ "LoggingManager",
194
+ "LoggingConfig",
195
+ "LogLevel",
196
+ "LogFormat",
197
+ # Authentication
198
+ "OAuthManager",
199
+ "TokenManager",
200
+ ]
201
+
202
+ # Version info
203
+ __version_info__ = tuple(int(x) for x in __version__.split("."))
204
+
205
+ # Package metadata
206
+ __package_info__ = {
207
+ "name": "dataquery-sdk",
208
+ "version": __version__,
209
+ "author": __author__,
210
+ "email": __email__,
211
+ "license": __license__,
212
+ "url": __url__,
213
+ "description": "Python SDK for DATAQUERY Data API - Query, download, and check availability of economic data files",
214
+ "python_requires": ">=3.11",
215
+ }
dataquery/auth.py ADDED
@@ -0,0 +1,380 @@
1
+ """
2
+ Authentication module for the DATAQUERY SDK.
3
+
4
+ Provides OAuth token management and Bearer token authentication.
5
+ """
6
+
7
+ import json
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional
11
+
12
+ import aiohttp
13
+ import structlog
14
+
15
+ from .exceptions import AuthenticationError, ConfigurationError
16
+ from .models import ClientConfig, OAuthToken, TokenRequest, TokenResponse
17
+
18
+ logger = structlog.get_logger(__name__)
19
+
20
+
21
+ class TokenManager:
22
+ """Manages OAuth tokens and Bearer token authentication."""
23
+
24
+ def __init__(self, config: ClientConfig):
25
+ self.config = config
26
+ self.current_token: Optional[OAuthToken] = None
27
+ self.token_file: Optional[Path] = None
28
+ self._setup_token_storage()
29
+
30
+ def _setup_token_storage(self):
31
+ """Setup token storage file."""
32
+ # Prefer explicit token_storage_dir only when token storage is enabled,
33
+ # else fallback to download_dir/.tokens if download_dir is provided.
34
+ base_dir: Optional[Path] = None
35
+ token_storage_enabled = bool(
36
+ getattr(self.config, "token_storage_enabled", False)
37
+ )
38
+ token_storage_dir = getattr(self.config, "token_storage_dir", None)
39
+ if token_storage_enabled and token_storage_dir:
40
+ base_dir = Path(token_storage_dir)
41
+ elif getattr(self.config, "download_dir", None):
42
+ # Use hidden .tokens folder under download_dir only if download_dir is configured
43
+ try:
44
+ if str(self.config.download_dir).strip():
45
+ base_dir = Path(self.config.download_dir) / ".tokens"
46
+ except Exception:
47
+ base_dir = None
48
+
49
+ if base_dir:
50
+ base_dir.mkdir(parents=True, exist_ok=True)
51
+ self.token_file = base_dir / "oauth_token.json"
52
+ else:
53
+ self.token_file = None
54
+
55
+ async def get_valid_token(self) -> Optional[str]:
56
+ """
57
+ Get a valid access token for API requests.
58
+
59
+ Returns:
60
+ Bearer token string or None if no valid token available
61
+ """
62
+ # Check if we have a static bearer token
63
+ if self.config.has_bearer_token:
64
+ return f"Bearer {self.config.bearer_token}"
65
+
66
+ # Check if OAuth is enabled and credentials are available
67
+ if not self.config.has_oauth_credentials:
68
+ logger.warning("No OAuth credentials or bearer token configured")
69
+ return None
70
+
71
+ # Load existing token
72
+ if not self.current_token:
73
+ await self._load_token()
74
+
75
+ # Check if current token is valid
76
+ if self.current_token and not self.current_token.is_expired:
77
+ # Check if token is expiring soon
78
+ if self.current_token.is_expiring_soon(self.config.token_refresh_threshold):
79
+ logger.info("Token expiring soon, refreshing...")
80
+ await self._refresh_token()
81
+
82
+ return self.current_token.to_authorization_header()
83
+
84
+ # Token is expired or invalid, get new token
85
+ logger.info("Getting new OAuth token...")
86
+ await self._get_new_token()
87
+
88
+ if self.current_token:
89
+ return self.current_token.to_authorization_header()
90
+
91
+ return None
92
+
93
+ async def _get_new_token(self) -> Optional[OAuthToken]:
94
+ """Get a new OAuth token from the server."""
95
+ if not self.config.oauth_token_url:
96
+ raise ConfigurationError("OAuth token URL not configured")
97
+
98
+ # Validate required fields
99
+ if not self.config.client_id or not self.config.client_secret:
100
+ raise ConfigurationError(
101
+ "client_id and client_secret are required for OAuth"
102
+ )
103
+
104
+ # Create token request
105
+ token_request = TokenRequest(
106
+ grant_type=self.config.grant_type,
107
+ client_id=self.config.client_id,
108
+ client_secret=self.config.client_secret,
109
+ # scope removed
110
+ aud=getattr(self.config, "aud", None),
111
+ )
112
+
113
+ try:
114
+ # Make token request
115
+ async with aiohttp.ClientSession() as session:
116
+ async with session.post(
117
+ self.config.oauth_token_url,
118
+ data=token_request.to_dict(),
119
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
120
+ timeout=aiohttp.ClientTimeout(
121
+ total=self.config.timeout,
122
+ connect=min(300.0, self.config.timeout * 0.5),
123
+ sock_read=min(300.0, self.config.timeout * 0.5),
124
+ ),
125
+ ) as response:
126
+ if response.status == 200:
127
+ data = await response.json()
128
+ token_response = TokenResponse(**data)
129
+ self.current_token = token_response.to_oauth_token()
130
+
131
+ # Save token
132
+ await self._save_token()
133
+
134
+ logger.info(
135
+ "OAuth token obtained successfully",
136
+ expires_in=self.current_token.expires_in,
137
+ )
138
+ return self.current_token
139
+ else:
140
+ error_data = await response.text()
141
+ logger.error(
142
+ "Failed to get OAuth token",
143
+ status=response.status,
144
+ error=error_data,
145
+ )
146
+ raise AuthenticationError(
147
+ f"OAuth token request failed: {response.status}"
148
+ )
149
+
150
+ except Exception as e:
151
+ logger.error("Error getting OAuth token", error=str(e))
152
+ raise AuthenticationError(f"Failed to get OAuth token: {e}")
153
+
154
+ async def _refresh_token(self) -> Optional[OAuthToken]:
155
+ """Refresh the current OAuth token."""
156
+ if not self.current_token or not self.current_token.refresh_token:
157
+ # No refresh token available, get new token
158
+ return await self._get_new_token()
159
+
160
+ try:
161
+ # Create refresh request
162
+ refresh_data = {
163
+ "grant_type": "refresh_token",
164
+ "refresh_token": self.current_token.refresh_token,
165
+ "client_id": self.config.client_id,
166
+ "client_secret": self.config.client_secret,
167
+ }
168
+
169
+ # Validate OAuth token URL
170
+ if not self.config.oauth_token_url:
171
+ raise ConfigurationError("OAuth token URL not configured")
172
+
173
+ # Make refresh request
174
+ async with aiohttp.ClientSession() as session:
175
+ async with session.post(
176
+ self.config.oauth_token_url,
177
+ data=refresh_data,
178
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
179
+ timeout=aiohttp.ClientTimeout(
180
+ total=self.config.timeout,
181
+ connect=min(300.0, self.config.timeout * 0.5),
182
+ sock_read=min(300.0, self.config.timeout * 0.5),
183
+ ),
184
+ ) as response:
185
+ if response.status == 200:
186
+ data = await response.json()
187
+ token_response = TokenResponse(**data)
188
+ self.current_token = token_response.to_oauth_token()
189
+
190
+ # Save token
191
+ await self._save_token()
192
+
193
+ logger.info(
194
+ "OAuth token refreshed successfully",
195
+ expires_in=self.current_token.expires_in,
196
+ )
197
+ return self.current_token
198
+ else:
199
+ error_data = await response.text()
200
+ logger.error(
201
+ "Failed to refresh OAuth token",
202
+ status=response.status,
203
+ error=error_data,
204
+ )
205
+ # Fall back to getting new token
206
+ return await self._get_new_token()
207
+
208
+ except Exception as e:
209
+ logger.error("Error refreshing OAuth token", error=str(e))
210
+ # Fall back to getting new token
211
+ return await self._get_new_token()
212
+
213
+ async def _load_token(self) -> Optional[OAuthToken]:
214
+ """Load token from storage."""
215
+ if not self.token_file or not self.token_file.exists():
216
+ return None
217
+
218
+ try:
219
+ with open(self.token_file, "r") as f:
220
+ token_data = json.load(f)
221
+
222
+ # Convert timestamp back to datetime
223
+ if "issued_at" in token_data:
224
+ token_data["issued_at"] = datetime.fromisoformat(
225
+ token_data["issued_at"]
226
+ )
227
+
228
+ self.current_token = OAuthToken(**token_data)
229
+
230
+ # Check if token is still valid
231
+ if self.current_token.is_expired:
232
+ logger.info("Stored token is expired")
233
+ self.current_token = None
234
+ return None
235
+
236
+ logger.info(
237
+ "Token loaded from storage", expires_at=self.current_token.expires_at
238
+ )
239
+ return self.current_token
240
+
241
+ except Exception as e:
242
+ logger.warning("Failed to load token from storage", error=str(e))
243
+ return None
244
+
245
+ async def _save_token(self) -> None:
246
+ """Save token to storage."""
247
+ if not self.token_file or not self.current_token:
248
+ return
249
+
250
+ try:
251
+ token_data = self.current_token.model_dump()
252
+ # Convert datetime to ISO string for JSON serialization
253
+ if "issued_at" in token_data and token_data["issued_at"] is not None:
254
+ token_data["issued_at"] = token_data["issued_at"].isoformat()
255
+
256
+ # Ensure directory exists
257
+ self.token_file.parent.mkdir(parents=True, exist_ok=True)
258
+
259
+ # Write to temporary file first, then rename for atomic operation
260
+ temp_file = self.token_file.with_suffix(".tmp")
261
+ with open(temp_file, "w") as f:
262
+ # Set restrictive permissions on the temp file (owner read/write only)
263
+ import os
264
+
265
+ os.chmod(temp_file, 0o600)
266
+ json.dump(token_data, f, indent=2)
267
+
268
+ # Atomic rename
269
+ temp_file.replace(self.token_file)
270
+
271
+ logger.debug("Token saved to storage with secure permissions")
272
+
273
+ except Exception as e:
274
+ logger.warning("Failed to save token to storage", error=str(e))
275
+ # Clean up temp file if it exists
276
+ temp_file = self.token_file.with_suffix(".tmp")
277
+ if temp_file.exists():
278
+ try:
279
+ temp_file.unlink()
280
+ except Exception:
281
+ pass
282
+
283
+ def clear_token(self) -> None:
284
+ """Clear the current token."""
285
+ self.current_token = None
286
+ if self.token_file and self.token_file.exists():
287
+ try:
288
+ self.token_file.unlink()
289
+ logger.info("Token file removed")
290
+ except Exception as e:
291
+ logger.warning("Failed to remove token file", error=str(e))
292
+
293
+ def get_token_info(self) -> Dict[str, Any]:
294
+ """Get information about the current token."""
295
+ if not self.current_token:
296
+ return {"status": "no_token"}
297
+
298
+ return {
299
+ "status": self.current_token.status.value,
300
+ "token_type": self.current_token.token_type,
301
+ "issued_at": (
302
+ self.current_token.issued_at.isoformat()
303
+ if self.current_token.issued_at
304
+ else None
305
+ ),
306
+ "expires_at": (
307
+ self.current_token.expires_at.isoformat()
308
+ if self.current_token.expires_at
309
+ else None
310
+ ),
311
+ "is_expired": self.current_token.is_expired,
312
+ "has_refresh_token": self.current_token.refresh_token is not None,
313
+ }
314
+
315
+
316
+ class OAuthManager:
317
+ """High-level OAuth management for the DATAQUERY SDK."""
318
+
319
+ def __init__(self, config: ClientConfig):
320
+ self.config = config
321
+ self.token_manager = TokenManager(config)
322
+
323
+ async def authenticate(self) -> str:
324
+ """
325
+ Authenticate and get a valid Bearer token.
326
+
327
+ Returns:
328
+ Bearer token string
329
+
330
+ Raises:
331
+ AuthenticationError: If authentication fails
332
+ """
333
+ token = await self.token_manager.get_valid_token()
334
+ if not token:
335
+ raise AuthenticationError("Failed to obtain valid authentication token")
336
+ return token
337
+
338
+ async def get_headers(self) -> Dict[str, str]:
339
+ """
340
+ Get headers with authentication token.
341
+
342
+ Returns:
343
+ Dictionary with Authorization header
344
+ """
345
+ token = await self.authenticate()
346
+ return {"Authorization": token}
347
+
348
+ def is_authenticated(self) -> bool:
349
+ """Check if authentication is configured."""
350
+ return self.config.has_bearer_token or self.config.has_oauth_credentials
351
+
352
+ def get_auth_info(self) -> Dict[str, Any]:
353
+ """Get authentication configuration information."""
354
+ return {
355
+ "oauth_enabled": self.config.oauth_enabled,
356
+ "has_oauth_credentials": self.config.has_oauth_credentials,
357
+ "has_bearer_token": self.config.has_bearer_token,
358
+ "oauth_token_url": self.config.oauth_token_url,
359
+ "grant_type": self.config.grant_type,
360
+ "token_info": self.token_manager.get_token_info(),
361
+ }
362
+
363
+ def clear_authentication(self) -> None:
364
+ """Clear all authentication data."""
365
+ self.token_manager.clear_token()
366
+ logger.info("Authentication data cleared")
367
+
368
+ async def test_authentication(self) -> bool:
369
+ """
370
+ Test if authentication is working.
371
+
372
+ Returns:
373
+ True if authentication is successful
374
+ """
375
+ try:
376
+ await self.authenticate()
377
+ return True
378
+ except Exception as e:
379
+ logger.error("Authentication test failed", error=str(e))
380
+ return False