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/utils.py ADDED
@@ -0,0 +1,525 @@
1
+ """
2
+ Utility functions for the DATAQUERY SDK.
3
+ """
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import structlog
10
+
11
+ from .models import ClientConfig
12
+
13
+ # Note: load_dotenv is imported where used to avoid unused import in environments
14
+
15
+
16
+ logger = structlog.get_logger(__name__)
17
+
18
+
19
+ def create_env_template(env_file: Optional[Path] = None) -> Path:
20
+ """
21
+ Create a .env template file with all available configuration options.
22
+
23
+ Args:
24
+ env_file: Path to the template file (default: .env.template)
25
+
26
+ Returns:
27
+ Path to the created template file
28
+ """
29
+ template_file = env_file or Path(".env.template")
30
+
31
+ # Validate the path
32
+ if not isinstance(template_file, Path):
33
+ template_file = Path(template_file)
34
+
35
+ try:
36
+ # Ensure parent directory exists
37
+ template_file.parent.mkdir(parents=True, exist_ok=True)
38
+
39
+ template_content = """# DATAQUERY SDK Configuration Template
40
+ # Copy this file to .env and update the values according to your setup
41
+ # cp .env.template .env
42
+
43
+ # =============================================================================
44
+ # REQUIRED: API Configuration
45
+ # =============================================================================
46
+
47
+ # Base URL of the DATAQUERY API (REQUIRED)
48
+ # Example: https://api-developer.jpmorgan.com (Production)
49
+ # Example: https://api-staging.dataquery.com
50
+ DATAQUERY_BASE_URL=https://api-developer.jpmorgan.com
51
+
52
+ # Optional: Separate host for file endpoints
53
+ # If set, file availability/list/download will use this host instead of DATAQUERY_BASE_URL
54
+ # DATAQUERY_FILES_BASE_URL=https://files-api.example.com
55
+ # DATAQUERY_FILES_CONTEXT_PATH=/research/dataquery-authe/api/v2
56
+
57
+ # =============================================================================
58
+ # AUTHENTICATION: OAuth 2.0 Configuration (Recommended)
59
+ # =============================================================================
60
+
61
+ # Enable OAuth authentication (true/false)
62
+ # Set to false if using Bearer token authentication
63
+ DATAQUERY_OAUTH_ENABLED=true
64
+
65
+ # OAuth token endpoint URL
66
+ # Usually: {BASE_URL}/oauth/token
67
+ DATAQUERY_OAUTH_TOKEN_URL=https://api-developer.jpmorgan.com/oauth/token
68
+
69
+ # OAuth client credentials (REQUIRED if OAuth enabled)
70
+ # Get these from your DATAQUERY account dashboard
71
+ DATAQUERY_CLIENT_ID=your_client_id_here
72
+ DATAQUERY_CLIENT_SECRET=your_client_secret_here
73
+
74
+ # OAuth scope removed
75
+
76
+ # OAuth audience (optional)
77
+ # Example: api://default or a full audience URI
78
+ DATAQUERY_OAUTH_AUD=
79
+
80
+ # OAuth grant type (usually client_credentials)
81
+ DATAQUERY_GRANT_TYPE=client_credentials
82
+
83
+ # =============================================================================
84
+ # AUTHENTICATION: Bearer Token Configuration (Alternative)
85
+ # =============================================================================
86
+
87
+ # Bearer token for direct API access (alternative to OAuth)
88
+ # Use this if you already have a Bearer token
89
+ # Leave empty if using OAuth authentication
90
+ DATAQUERY_BEARER_TOKEN=
91
+
92
+ # Token refresh threshold in seconds (default: 300 = 5 minutes)
93
+ # Refresh token when it expires within this many seconds
94
+ DATAQUERY_TOKEN_REFRESH_THRESHOLD=300
95
+
96
+ # =============================================================================
97
+ # HTTP Configuration
98
+ # =============================================================================
99
+
100
+ # Request timeout in seconds (default: 600.0)
101
+ DATAQUERY_TIMEOUT=600.0
102
+
103
+ # Maximum retry attempts for failed requests (default: 3)
104
+ DATAQUERY_MAX_RETRIES=3
105
+
106
+ # Delay between retries in seconds (default: 1.0)
107
+ DATAQUERY_RETRY_DELAY=1.0
108
+
109
+ # Number of connection pools (default: 10)
110
+ DATAQUERY_POOL_CONNECTIONS=10
111
+
112
+ # Maximum connections per pool (default: 20)
113
+ DATAQUERY_POOL_MAXSIZE=20
114
+
115
+ # =============================================================================
116
+ # Rate Limiting Configuration
117
+ # =============================================================================
118
+
119
+ # Requests per minute limit (default: 100)
120
+ DATAQUERY_REQUESTS_PER_MINUTE=100
121
+
122
+ # Burst capacity for rate limiting (default: 20)
123
+ DATAQUERY_BURST_CAPACITY=20
124
+
125
+ # =============================================================================
126
+ # Logging Configuration
127
+ # =============================================================================
128
+
129
+ # Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
130
+ # Default: INFO
131
+ DATAQUERY_LOG_LEVEL=INFO
132
+
133
+ # Enable debug logging (true/false)
134
+ # Default: false
135
+ DATAQUERY_ENABLE_DEBUG_LOGGING=false
136
+
137
+ # =============================================================================
138
+ # Download Configuration
139
+ # =============================================================================
140
+
141
+ # Base download directory (default: ./downloads)
142
+ # All downloaded files will be saved to this directory
143
+ DATAQUERY_DOWNLOAD_DIR=./downloads
144
+
145
+ # Create parent directories if they don't exist (true/false)
146
+ # Default: true
147
+ DATAQUERY_CREATE_DIRECTORIES=true
148
+
149
+ # Overwrite existing files (true/false)
150
+ # Default: false
151
+ DATAQUERY_OVERWRITE_EXISTING=false
152
+
153
+ # =============================================================================
154
+ # Download Subdirectories (Optional)
155
+ # =============================================================================
156
+
157
+ # Workflow downloads subdirectory (default: workflow)
158
+ DATAQUERY_WORKFLOW_DIR=workflow
159
+
160
+ # Groups downloads subdirectory (default: groups)
161
+ DATAQUERY_GROUPS_DIR=groups
162
+
163
+ # Availability downloads subdirectory (default: availability)
164
+ DATAQUERY_AVAILABILITY_DIR=availability
165
+
166
+ # Default downloads subdirectory (default: files)
167
+ DATAQUERY_DEFAULT_DIR=files
168
+
169
+ # =============================================================================
170
+ # Advanced Configuration (Optional)
171
+ # =============================================================================
172
+
173
+ # User agent string for HTTP requests
174
+ # Default: DATAQUERY-SDK/1.0.0
175
+ DATAQUERY_USER_AGENT=DATAQUERY-SDK/1.0.0
176
+
177
+ # Enable HTTP/2 support (true/false)
178
+ # Default: true
179
+ DATAQUERY_ENABLE_HTTP2=true
180
+
181
+ # Connection keepalive timeout in seconds
182
+ # Default: 30
183
+ DATAQUERY_KEEPALIVE_TIMEOUT=30
184
+
185
+ # Enable connection pooling (true/false)
186
+ # Default: true
187
+ DATAQUERY_ENABLE_CONNECTION_POOLING=true
188
+
189
+ # =============================================================================
190
+ # Development Configuration (Optional)
191
+ # =============================================================================
192
+
193
+ # Enable development mode (true/false)
194
+ # Default: false
195
+ DATAQUERY_DEVELOPMENT_MODE=false
196
+
197
+ # Development API base URL (used when development mode is enabled)
198
+ DATAQUERY_DEV_BASE_URL=https://api-dev.dataquery.com
199
+
200
+ # Enable request/response logging (true/false)
201
+ # Default: false
202
+ DATAQUERY_LOG_REQUESTS=false
203
+
204
+ # =============================================================================
205
+ # Example Configurations
206
+ # =============================================================================
207
+
208
+ # Bearer Token Configuration Example:
209
+ # DATAQUERY_BASE_URL=https://api.dataquery.com
210
+ # DATAQUERY_OAUTH_ENABLED=false
211
+ # DATAQUERY_BEARER_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
212
+ """
213
+
214
+ template_file.write_text(template_content)
215
+
216
+ logger.info("Created .env template", file=str(template_file))
217
+ return template_file
218
+
219
+ except (OSError, IOError) as e:
220
+ logger.error(
221
+ "Failed to create .env template", file=str(template_file), error=str(e)
222
+ )
223
+ raise
224
+ except Exception as e:
225
+ logger.error("Unexpected error creating .env template", error=str(e))
226
+ raise
227
+
228
+
229
+ def save_config_to_env(config: ClientConfig, env_file: Optional[Path] = None) -> Path:
230
+ """
231
+ Save configuration to .env file.
232
+
233
+ Args:
234
+ config: Client configuration to save
235
+ env_file: Path to the .env file (default: .env)
236
+
237
+ Returns:
238
+ Path to the saved .env file
239
+ """
240
+ env_file = env_file or Path(".env")
241
+
242
+ # Ensure env_file is a Path object
243
+ if not isinstance(env_file, Path):
244
+ env_file = Path(env_file)
245
+
246
+ env_content = f"""# DATAQUERY SDK Configuration
247
+
248
+ # API Configuration
249
+ DATAQUERY_BASE_URL={config.base_url}
250
+
251
+ # OAuth Configuration
252
+ DATAQUERY_OAUTH_ENABLED={str(config.oauth_enabled).lower()}
253
+ DATAQUERY_OAUTH_TOKEN_URL={config.oauth_token_url or ''}
254
+ DATAQUERY_CLIENT_ID={config.client_id or ''}
255
+ DATAQUERY_CLIENT_SECRET={config.client_secret or ''}
256
+ ## scope removed
257
+ DATAQUERY_OAUTH_AUD={getattr(config, 'aud', '') or ''}
258
+ DATAQUERY_GRANT_TYPE={config.grant_type}
259
+
260
+ # Bearer Token Configuration
261
+ DATAQUERY_BEARER_TOKEN={config.bearer_token or ''}
262
+ DATAQUERY_TOKEN_REFRESH_THRESHOLD={config.token_refresh_threshold}
263
+
264
+ # HTTP Configuration
265
+ DATAQUERY_TIMEOUT={config.timeout}
266
+ DATAQUERY_MAX_RETRIES={config.max_retries}
267
+ DATAQUERY_RETRY_DELAY={config.retry_delay}
268
+ DATAQUERY_POOL_CONNECTIONS={config.pool_connections}
269
+ DATAQUERY_POOL_MAXSIZE={config.pool_maxsize}
270
+
271
+ # Rate Limiting
272
+ DATAQUERY_REQUESTS_PER_MINUTE={config.requests_per_minute}
273
+ DATAQUERY_BURST_CAPACITY={config.burst_capacity}
274
+
275
+ # Logging
276
+ DATAQUERY_LOG_LEVEL={config.log_level}
277
+ DATAQUERY_ENABLE_DEBUG_LOGGING={str(config.enable_debug_logging).lower()}
278
+
279
+ # Download Configuration
280
+ DATAQUERY_DOWNLOAD_DIR={config.download_dir}
281
+ DATAQUERY_CREATE_DIRECTORIES={str(config.create_directories).lower()}
282
+ DATAQUERY_OVERWRITE_EXISTING={str(config.overwrite_existing).lower()}
283
+
284
+ # Download Subdirectories
285
+ DATAQUERY_WORKFLOW_DIR=workflow
286
+ DATAQUERY_GROUPS_DIR=groups
287
+ DATAQUERY_AVAILABILITY_DIR=availability
288
+ DATAQUERY_DEFAULT_DIR=files
289
+ """
290
+
291
+ env_file.write_text(env_content)
292
+
293
+ logger.info("Saved configuration to .env file", file=str(env_file))
294
+ return env_file
295
+
296
+
297
+ def load_env_file(env_file: Optional[Path] = None) -> None:
298
+ """
299
+ Load environment variables from .env file.
300
+
301
+ Args:
302
+ env_file: Path to the .env file (default: .env)
303
+ """
304
+ try:
305
+ from dotenv import load_dotenv # pylint: disable=import-outside-toplevel
306
+ except ImportError:
307
+ logger.warning("python-dotenv not installed, skipping .env file loading")
308
+ return
309
+
310
+ env_file = env_file or Path(".env")
311
+
312
+ # Ensure env_file is a Path object
313
+ if not isinstance(env_file, Path):
314
+ env_file = Path(env_file)
315
+
316
+ # For compatibility with tests: only call loader if file exists
317
+ # Use Path.exists as an unbound method to cooperate with tests that patch pathlib.Path.exists
318
+ if Path.exists(env_file):
319
+ try:
320
+ _ = load_dotenv(env_file)
321
+ except Exception:
322
+ logger.warning("Failed to load .env file", file=str(env_file))
323
+ return
324
+ logger.info("Loaded environment variables from .env file", file=str(env_file))
325
+ else:
326
+ logger.warning("No .env file found", file=str(env_file))
327
+
328
+
329
+ def get_env_value(key: str, default: Optional[str] = None) -> Optional[str]:
330
+ """
331
+ Get environment variable value with optional default.
332
+
333
+ Args:
334
+ key: Environment variable name
335
+ default: Default value if not found
336
+
337
+ Returns:
338
+ Environment variable value or default
339
+ """
340
+ return os.getenv(key, default)
341
+
342
+
343
+ def set_env_value(key: str, value: str) -> None:
344
+ """
345
+ Set environment variable value.
346
+
347
+ Args:
348
+ key: Environment variable name
349
+ value: Value to set
350
+ """
351
+ os.environ[key] = value
352
+ logger.debug("Set environment variable", key=key, value=value)
353
+
354
+
355
+ def validate_env_config() -> None:
356
+ """
357
+ Validate that required environment variables are set.
358
+
359
+ Raises:
360
+ ValueError: If required variables are missing or invalid
361
+ """
362
+ # Validate numeric values if present
363
+ timeout = get_env_value("DATAQUERY_TIMEOUT")
364
+ if timeout:
365
+ try:
366
+ float(timeout)
367
+ except ValueError:
368
+ raise ValueError(f"Invalid timeout value: {timeout}")
369
+
370
+ max_retries = get_env_value("DATAQUERY_MAX_RETRIES")
371
+ if max_retries:
372
+ try:
373
+ int(max_retries)
374
+ except ValueError:
375
+ raise ValueError(f"Invalid max retries value: {max_retries}")
376
+
377
+ # Validate boolean values if present
378
+ oauth_enabled_val = get_env_value("DATAQUERY_OAUTH_ENABLED")
379
+ if oauth_enabled_val and oauth_enabled_val.lower() not in ("true", "false"):
380
+ raise ValueError(f"Invalid OAuth enabled value: {oauth_enabled_val}")
381
+
382
+ # Check required variables - BASE_URL is always required
383
+ base_url = get_env_value("DATAQUERY_BASE_URL")
384
+ if not base_url or not base_url.startswith(("http://", "https://")):
385
+ raise ValueError("DATAQUERY_BASE_URL is required")
386
+
387
+ # Validate OAuth configuration
388
+ oauth_enabled_val = get_env_value("DATAQUERY_OAUTH_ENABLED", "false")
389
+ oauth_enabled = (oauth_enabled_val or "false").lower() == "true"
390
+ if oauth_enabled:
391
+ client_id = get_env_value("DATAQUERY_CLIENT_ID")
392
+ client_secret = get_env_value("DATAQUERY_CLIENT_SECRET")
393
+ if (
394
+ not client_id
395
+ or not client_secret
396
+ or client_id.strip() == ""
397
+ or client_secret.strip() == ""
398
+ ):
399
+ raise ValueError("OAuth credentials are required")
400
+
401
+ # Check if either OAuth or Bearer token is configured
402
+ if not oauth_enabled:
403
+ bearer_token = get_env_value("DATAQUERY_BEARER_TOKEN")
404
+ if not bearer_token or bearer_token.strip() == "":
405
+ # Only require authentication if OAuth is explicitly enabled
406
+ # If OAuth is disabled and no bearer token, that's okay for testing
407
+ pass
408
+
409
+ logger.info("Environment configuration validation passed")
410
+
411
+
412
+ def format_file_size(size_bytes: int) -> str:
413
+ """Format file size in human-readable format (bytes no decimals, KB+ one decimal)."""
414
+ if size_bytes == 0:
415
+ return "0 B"
416
+
417
+ # Handle negative values
418
+ if size_bytes < 0:
419
+ abs_size = abs(size_bytes)
420
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]
421
+ i = 0
422
+ size_float = float(abs_size)
423
+ while size_float >= 1024 and i < len(size_names) - 1:
424
+ size_float /= 1024.0
425
+ i += 1
426
+
427
+ if i == 0: # Bytes
428
+ return f"-{size_float:.0f} {size_names[i]}"
429
+ else:
430
+ return f"-{size_float:.1f} {size_names[i]}"
431
+
432
+ size_names = ["B", "KB", "MB", "GB", "TB", "PB", "EB"]
433
+ i = 0
434
+ size_float = float(size_bytes)
435
+ while size_float >= 1024 and i < len(size_names) - 1:
436
+ size_float /= 1024.0
437
+ i += 1
438
+
439
+ if i == 0: # Bytes
440
+ return f"{size_float:.0f} {size_names[i]}"
441
+ else:
442
+ return f"{size_float:.1f} {size_names[i]}"
443
+
444
+
445
+ def format_duration(seconds: float) -> str:
446
+ """Format duration with verbose style: Xm Ys or Xh Ym Zs where applicable."""
447
+ if seconds == 0:
448
+ return "0s"
449
+
450
+ # Handle negative values
451
+ if seconds < 0:
452
+ abs_seconds = abs(seconds)
453
+ if abs_seconds < 60:
454
+ return f"-{abs_seconds:.1f}s"
455
+ elif abs_seconds < 3600:
456
+ minutes = int(abs_seconds // 60)
457
+ remaining_seconds = int(abs_seconds % 60)
458
+ if remaining_seconds == 0:
459
+ return f"-{minutes}m"
460
+ else:
461
+ return f"-{minutes}m {remaining_seconds}s"
462
+ else:
463
+ hours = int(abs_seconds // 3600)
464
+ remaining_minutes = int((abs_seconds % 3600) // 60)
465
+ remaining_seconds = int(abs_seconds % 60)
466
+ if remaining_minutes == 0 and remaining_seconds == 0:
467
+ return f"-{hours}h"
468
+ elif remaining_seconds == 0:
469
+ return f"-{hours}h {remaining_minutes}m"
470
+ else:
471
+ return f"-{hours}h {remaining_minutes}m {remaining_seconds}s"
472
+
473
+ if seconds < 60:
474
+ return f"{seconds:.1f}s"
475
+ elif seconds < 3600:
476
+ minutes = int(seconds // 60)
477
+ remaining_seconds = int(seconds % 60)
478
+ if remaining_seconds == 0:
479
+ return f"{minutes}m"
480
+ else:
481
+ return f"{minutes}m {remaining_seconds}s"
482
+ else:
483
+ hours = int(seconds // 3600)
484
+ remaining_minutes = int((seconds % 3600) // 60)
485
+ remaining_seconds = int(seconds % 60)
486
+ if remaining_minutes == 0 and remaining_seconds == 0:
487
+ return f"{hours}h"
488
+ elif remaining_seconds == 0:
489
+ return f"{hours}h {remaining_minutes}m"
490
+ else:
491
+ return f"{hours}h {remaining_minutes}m {remaining_seconds}s"
492
+
493
+
494
+ def ensure_directory(path) -> Path:
495
+ """Ensure directory exists and return the path."""
496
+ # Convert string to Path if needed
497
+ if not isinstance(path, Path):
498
+ path = Path(path)
499
+
500
+ path.mkdir(parents=True, exist_ok=True)
501
+ return path
502
+
503
+
504
+ def get_download_paths(base_dir: Optional[Path] = None) -> dict:
505
+ """Get download paths from environment variables with defaults."""
506
+ import os
507
+ from pathlib import Path
508
+
509
+ if base_dir is None:
510
+ base_download_dir = Path(os.getenv("DATAQUERY_DOWNLOAD_DIR", "./downloads"))
511
+ else:
512
+ # Convert string to Path if needed
513
+ if not isinstance(base_dir, Path):
514
+ base_download_dir = Path(base_dir)
515
+ else:
516
+ base_download_dir = base_dir
517
+
518
+ return {
519
+ "base": base_download_dir,
520
+ "workflow": base_download_dir / os.getenv("DATAQUERY_WORKFLOW_DIR", "workflow"),
521
+ "groups": base_download_dir / os.getenv("DATAQUERY_GROUPS_DIR", "groups"),
522
+ "availability": base_download_dir
523
+ / os.getenv("DATAQUERY_AVAILABILITY_DIR", "availability"),
524
+ "default": base_download_dir / os.getenv("DATAQUERY_DEFAULT_DIR", "files"),
525
+ }