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
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-Download Manager for dataquery-sdk
|
|
3
|
+
|
|
4
|
+
This module provides automatic file download functionality that continuously
|
|
5
|
+
monitors data groups and downloads new files when they become available.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable, Dict, List, Optional, Set
|
|
13
|
+
|
|
14
|
+
from .config import EnvConfig
|
|
15
|
+
from .models import DownloadOptions, DownloadProgress
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AutoDownloadManager:
|
|
19
|
+
"""
|
|
20
|
+
Manages automatic file download monitoring and downloading.
|
|
21
|
+
|
|
22
|
+
This class continuously monitors a data group for new files and automatically
|
|
23
|
+
downloads them if they don't already exist in the destination folder.
|
|
24
|
+
|
|
25
|
+
Features:
|
|
26
|
+
- Continuous monitoring with configurable intervals
|
|
27
|
+
- File filtering capabilities
|
|
28
|
+
- Progress tracking and callbacks
|
|
29
|
+
- Error handling and retry logic
|
|
30
|
+
- Statistics and monitoring
|
|
31
|
+
- Graceful shutdown
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
client, # DataQueryClient instance
|
|
37
|
+
group_id: str,
|
|
38
|
+
destination_dir: str = "./downloads",
|
|
39
|
+
interval_minutes: int = 30,
|
|
40
|
+
file_filter: Optional[Callable] = None,
|
|
41
|
+
progress_callback: Optional[Callable] = None,
|
|
42
|
+
error_callback: Optional[Callable] = None,
|
|
43
|
+
max_retries: int = 3,
|
|
44
|
+
check_current_date_only: bool = True,
|
|
45
|
+
max_concurrent_downloads: Optional[int] = None,
|
|
46
|
+
):
|
|
47
|
+
"""
|
|
48
|
+
Initialize the AutoDownloadManager.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
client: DataQueryClient instance
|
|
52
|
+
group_id: ID of the data group to monitor
|
|
53
|
+
destination_dir: Directory to download files to
|
|
54
|
+
interval_minutes: Check interval in minutes
|
|
55
|
+
file_filter: Optional function to filter files
|
|
56
|
+
progress_callback: Optional callback for download progress
|
|
57
|
+
error_callback: Optional callback for errors
|
|
58
|
+
max_retries: Maximum retry attempts for failed downloads
|
|
59
|
+
check_current_date_only: If True, only check files for current date
|
|
60
|
+
max_concurrent_downloads: Maximum concurrent downloads (uses SDK default if None)
|
|
61
|
+
"""
|
|
62
|
+
self.client = client
|
|
63
|
+
self.group_id = group_id
|
|
64
|
+
self.destination_dir = Path(destination_dir)
|
|
65
|
+
self.interval_minutes = interval_minutes
|
|
66
|
+
self.file_filter = file_filter
|
|
67
|
+
self.progress_callback = progress_callback
|
|
68
|
+
self.error_callback = error_callback
|
|
69
|
+
self.max_retries = max_retries
|
|
70
|
+
self.check_current_date_only = check_current_date_only
|
|
71
|
+
# Use SDK default if not specified
|
|
72
|
+
self.max_concurrent_downloads = (
|
|
73
|
+
max_concurrent_downloads
|
|
74
|
+
if max_concurrent_downloads is not None
|
|
75
|
+
else EnvConfig.get_int("MAX_CONCURRENT_DOWNLOADS", "5")
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Create destination directory if it doesn't exist
|
|
79
|
+
self.destination_dir.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
# Internal state
|
|
82
|
+
self._running = False
|
|
83
|
+
self._task: Optional[asyncio.Task] = None
|
|
84
|
+
self._stop_event = asyncio.Event()
|
|
85
|
+
self._downloaded_files: Set[str] = set()
|
|
86
|
+
self._failed_files: Dict[str, int] = {} # file_group_id -> retry_count
|
|
87
|
+
|
|
88
|
+
# Statistics
|
|
89
|
+
self.stats: Dict[str, Any] = {
|
|
90
|
+
"start_time": None,
|
|
91
|
+
"last_check_time": None,
|
|
92
|
+
"total_checks": 0,
|
|
93
|
+
"files_discovered": 0,
|
|
94
|
+
"files_downloaded": 0,
|
|
95
|
+
"files_skipped": 0,
|
|
96
|
+
"download_failures": 0,
|
|
97
|
+
"total_bytes_downloaded": 0,
|
|
98
|
+
"errors": [],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Setup logging
|
|
102
|
+
self.logger = logging.getLogger(f"AutoDownloadManager.{group_id}")
|
|
103
|
+
|
|
104
|
+
# Download options
|
|
105
|
+
# Note: destination_path is used by the client to treat this as a directory target
|
|
106
|
+
self.download_options = DownloadOptions(
|
|
107
|
+
destination_path=self.destination_dir,
|
|
108
|
+
overwrite_existing=False, # Don't overwrite existing files
|
|
109
|
+
chunk_size=8192,
|
|
110
|
+
)
|
|
111
|
+
# Backward-compat extra attribute used by tests; harmless extra field
|
|
112
|
+
setattr(self.download_options, "output_dir", str(self.destination_dir))
|
|
113
|
+
|
|
114
|
+
async def start(self):
|
|
115
|
+
"""Start the auto-download monitoring."""
|
|
116
|
+
if self._running:
|
|
117
|
+
raise RuntimeError("AutoDownloadManager is already running")
|
|
118
|
+
|
|
119
|
+
self._running = True
|
|
120
|
+
self.stats["start_time"] = datetime.now()
|
|
121
|
+
self._stop_event.clear()
|
|
122
|
+
|
|
123
|
+
self.logger.info(
|
|
124
|
+
f"Starting auto-download for group '{self.group_id}' "
|
|
125
|
+
f"(interval: {self.interval_minutes} minutes)"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Start the monitoring task
|
|
129
|
+
self._task = asyncio.create_task(self._monitoring_loop())
|
|
130
|
+
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
async def stop(self):
|
|
134
|
+
"""Stop the auto-download monitoring."""
|
|
135
|
+
if not self._running:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
self.logger.info("Stopping auto-download monitoring...")
|
|
139
|
+
|
|
140
|
+
self._running = False
|
|
141
|
+
self._stop_event.set()
|
|
142
|
+
|
|
143
|
+
# Wait for the monitoring task to complete
|
|
144
|
+
if self._task:
|
|
145
|
+
try:
|
|
146
|
+
await asyncio.wait_for(self._task, timeout=5.0)
|
|
147
|
+
except asyncio.TimeoutError:
|
|
148
|
+
self.logger.warning(
|
|
149
|
+
"Monitoring task did not stop gracefully, cancelling..."
|
|
150
|
+
)
|
|
151
|
+
self._task.cancel()
|
|
152
|
+
try:
|
|
153
|
+
await self._task
|
|
154
|
+
except asyncio.CancelledError:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
self.logger.info("Auto-download monitoring stopped")
|
|
158
|
+
|
|
159
|
+
async def _monitoring_loop(self):
|
|
160
|
+
"""Main monitoring loop that runs continuously."""
|
|
161
|
+
try:
|
|
162
|
+
while self._running:
|
|
163
|
+
try:
|
|
164
|
+
await self._check_and_download_files()
|
|
165
|
+
self.stats["total_checks"] += 1
|
|
166
|
+
self.stats["last_check_time"] = datetime.now()
|
|
167
|
+
|
|
168
|
+
except Exception as e:
|
|
169
|
+
self.logger.error(f"Error in monitoring loop: {e}")
|
|
170
|
+
self.stats["errors"].append(
|
|
171
|
+
{
|
|
172
|
+
"timestamp": datetime.now().isoformat(),
|
|
173
|
+
"error": str(e),
|
|
174
|
+
"type": "monitoring_loop",
|
|
175
|
+
}
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Call error callback if provided
|
|
179
|
+
if self.error_callback:
|
|
180
|
+
try:
|
|
181
|
+
await self._safe_call_callback(self.error_callback, e)
|
|
182
|
+
except Exception as cb_error:
|
|
183
|
+
self.logger.error(f"Error in error callback: {cb_error}")
|
|
184
|
+
|
|
185
|
+
# Wait for the specified interval or until stopped
|
|
186
|
+
try:
|
|
187
|
+
await asyncio.wait_for(
|
|
188
|
+
self._stop_event.wait(), timeout=self.interval_minutes * 60
|
|
189
|
+
)
|
|
190
|
+
# If we reach here, stop was requested
|
|
191
|
+
break
|
|
192
|
+
except asyncio.TimeoutError:
|
|
193
|
+
# Timeout is normal, continue to next iteration
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
except asyncio.CancelledError:
|
|
197
|
+
self.logger.info("Monitoring loop cancelled")
|
|
198
|
+
except Exception as e:
|
|
199
|
+
self.logger.error(f"Fatal error in monitoring loop: {e}")
|
|
200
|
+
finally:
|
|
201
|
+
self._running = False
|
|
202
|
+
|
|
203
|
+
async def _check_and_download_files(self):
|
|
204
|
+
"""Use available-files to find and download files if needed."""
|
|
205
|
+
try:
|
|
206
|
+
if not self._running:
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
# Determine date window to check
|
|
210
|
+
dates_to_check = self._get_dates_to_check()
|
|
211
|
+
start_date = min(dates_to_check)
|
|
212
|
+
end_date = max(dates_to_check)
|
|
213
|
+
|
|
214
|
+
# Query available files for the date window
|
|
215
|
+
available_files = await self.client.list_available_files_async(
|
|
216
|
+
group_id=self.group_id,
|
|
217
|
+
file_group_id=None,
|
|
218
|
+
start_date=start_date,
|
|
219
|
+
end_date=end_date,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if not isinstance(available_files, list) or not available_files:
|
|
223
|
+
self.logger.debug(
|
|
224
|
+
f"No available files for group '{self.group_id}' between {start_date} and {end_date}"
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
self.logger.debug(
|
|
229
|
+
f"Found {len(available_files)} available file entries for group '{self.group_id}'"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Build eligible downloads
|
|
233
|
+
eligible: List[tuple[str, str, str]] = [] # (file_id, date_str, file_key)
|
|
234
|
+
for item in available_files:
|
|
235
|
+
if not self._running:
|
|
236
|
+
break
|
|
237
|
+
file_id = item.get("file-group-id")
|
|
238
|
+
date_str = item.get("file-datetime")
|
|
239
|
+
avail_flag = item.get("is-available")
|
|
240
|
+
is_available = bool(avail_flag)
|
|
241
|
+
if not file_id or not date_str or not is_available:
|
|
242
|
+
continue
|
|
243
|
+
if self.file_filter and not self.file_filter(item):
|
|
244
|
+
continue
|
|
245
|
+
file_key = f"{file_id}_{date_str}"
|
|
246
|
+
if file_key in self._downloaded_files:
|
|
247
|
+
continue
|
|
248
|
+
if self._failed_files.get(file_key, 0) >= self.max_retries:
|
|
249
|
+
continue
|
|
250
|
+
if self._file_exists_locally(file_id, date_str):
|
|
251
|
+
self.stats["files_skipped"] += 1
|
|
252
|
+
self._downloaded_files.add(file_key)
|
|
253
|
+
continue
|
|
254
|
+
eligible.append((file_id, date_str, file_key))
|
|
255
|
+
|
|
256
|
+
if not eligible:
|
|
257
|
+
return
|
|
258
|
+
|
|
259
|
+
# Parallel downloads with concurrency limit
|
|
260
|
+
semaphore = asyncio.Semaphore(max(1, int(self.max_concurrent_downloads)))
|
|
261
|
+
|
|
262
|
+
async def worker(fid: str, dstr: str, fkey: str):
|
|
263
|
+
if not self._running:
|
|
264
|
+
return
|
|
265
|
+
async with semaphore:
|
|
266
|
+
await self._download_file(fid, dstr, fkey)
|
|
267
|
+
|
|
268
|
+
tasks = [
|
|
269
|
+
asyncio.create_task(worker(fid, dstr, fkey))
|
|
270
|
+
for fid, dstr, fkey in eligible
|
|
271
|
+
]
|
|
272
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
273
|
+
|
|
274
|
+
except Exception as e:
|
|
275
|
+
self.logger.error(f"Error checking available files: {e}")
|
|
276
|
+
raise
|
|
277
|
+
|
|
278
|
+
async def _process_file(self, file_info):
|
|
279
|
+
"""Deprecated path; available-files flow supersedes this."""
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
def _get_dates_to_check(self) -> List[str]:
|
|
283
|
+
"""Get list of dates to check for files."""
|
|
284
|
+
if self.check_current_date_only:
|
|
285
|
+
# Only check current date
|
|
286
|
+
return [datetime.now().strftime("%Y%m%d")]
|
|
287
|
+
else:
|
|
288
|
+
# Check current date and previous few days
|
|
289
|
+
dates: List[str] = []
|
|
290
|
+
today = datetime.now()
|
|
291
|
+
for i in range(3): # Check today and last 2 days
|
|
292
|
+
date = today - timedelta(days=i)
|
|
293
|
+
dates.append(date.strftime("%Y%m%d"))
|
|
294
|
+
return dates
|
|
295
|
+
|
|
296
|
+
async def _check_and_download_file_for_date(self, file_id: str, date_str: str):
|
|
297
|
+
"""Deprecated path; available-files flow supersedes this."""
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
def _file_exists_locally(self, file_id: str, date_str: str) -> bool:
|
|
301
|
+
"""Check if file already exists in the destination directory."""
|
|
302
|
+
# Look for files that might match this file_group_id and date
|
|
303
|
+
# This is a heuristic approach since we don't know the exact filename
|
|
304
|
+
|
|
305
|
+
for file_path in self.destination_dir.glob("*"):
|
|
306
|
+
if file_path.is_file():
|
|
307
|
+
filename = file_path.name
|
|
308
|
+
# Check if filename contains the file_group_id and date
|
|
309
|
+
if file_id in filename and date_str in filename:
|
|
310
|
+
return True
|
|
311
|
+
|
|
312
|
+
return False
|
|
313
|
+
|
|
314
|
+
async def _download_file(self, file_id: str, date_str: str, file_key: str):
|
|
315
|
+
"""Download a file."""
|
|
316
|
+
self.logger.info(f"Downloading '{file_id}' for {date_str}...")
|
|
317
|
+
|
|
318
|
+
# Create progress callback that updates statistics
|
|
319
|
+
def progress_wrapper(progress: DownloadProgress):
|
|
320
|
+
# Update statistics
|
|
321
|
+
current_total = int(self.stats.get("total_bytes_downloaded", 0) or 0)
|
|
322
|
+
self.stats["total_bytes_downloaded"] = max(
|
|
323
|
+
current_total, int(getattr(progress, "bytes_downloaded", 0) or 0)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# Call user callback if provided
|
|
327
|
+
if self.progress_callback:
|
|
328
|
+
try:
|
|
329
|
+
if asyncio.iscoroutinefunction(self.progress_callback):
|
|
330
|
+
asyncio.create_task(self.progress_callback(progress))
|
|
331
|
+
else:
|
|
332
|
+
self.progress_callback(progress)
|
|
333
|
+
except Exception as e:
|
|
334
|
+
self.logger.error(f"Error in progress callback: {e}")
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
result = await self.client.download_file_async(
|
|
338
|
+
file_group_id=file_id,
|
|
339
|
+
file_datetime=date_str,
|
|
340
|
+
options=self.download_options,
|
|
341
|
+
progress_callback=progress_wrapper,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
succeeded = False
|
|
345
|
+
if result is not None and getattr(result, "status", None) is not None:
|
|
346
|
+
try:
|
|
347
|
+
from .models import ( # import inside to avoid cycles in some tools
|
|
348
|
+
DownloadStatus,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
succeeded = result.status == DownloadStatus.COMPLETED
|
|
352
|
+
except Exception:
|
|
353
|
+
succeeded = False
|
|
354
|
+
|
|
355
|
+
if succeeded:
|
|
356
|
+
self.logger.info(f"Successfully downloaded '{file_id}' for {date_str}")
|
|
357
|
+
self.stats["files_downloaded"] += 1
|
|
358
|
+
self._downloaded_files.add(file_key)
|
|
359
|
+
# Accumulate total bytes if provided
|
|
360
|
+
try:
|
|
361
|
+
if getattr(result, "file_size", None):
|
|
362
|
+
self.stats["total_bytes_downloaded"] += (
|
|
363
|
+
int(result.file_size) or 0
|
|
364
|
+
)
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
|
|
368
|
+
# Reset failure count on success
|
|
369
|
+
if file_key in self._failed_files:
|
|
370
|
+
del self._failed_files[file_key]
|
|
371
|
+
|
|
372
|
+
else:
|
|
373
|
+
error_msg = (
|
|
374
|
+
getattr(result, "error_message", "Unknown error")
|
|
375
|
+
if result
|
|
376
|
+
else "No result"
|
|
377
|
+
)
|
|
378
|
+
self.logger.error(
|
|
379
|
+
f"Download failed for '{file_id}' for {date_str}: {error_msg}"
|
|
380
|
+
)
|
|
381
|
+
self._failed_files[file_key] = self._failed_files.get(file_key, 0) + 1
|
|
382
|
+
self.stats["download_failures"] += 1
|
|
383
|
+
|
|
384
|
+
except Exception as e:
|
|
385
|
+
self.logger.error(f"Exception downloading '{file_id}' for {date_str}: {e}")
|
|
386
|
+
self._failed_files[file_key] = self._failed_files.get(file_key, 0) + 1
|
|
387
|
+
self.stats["download_failures"] += 1
|
|
388
|
+
raise
|
|
389
|
+
|
|
390
|
+
async def _safe_call_callback(self, callback: Callable, *args, **kwargs):
|
|
391
|
+
"""Safely call a callback function."""
|
|
392
|
+
try:
|
|
393
|
+
if asyncio.iscoroutinefunction(callback):
|
|
394
|
+
await callback(*args, **kwargs)
|
|
395
|
+
else:
|
|
396
|
+
callback(*args, **kwargs)
|
|
397
|
+
except Exception as e:
|
|
398
|
+
self.logger.error(f"Error in callback: {e}")
|
|
399
|
+
|
|
400
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
401
|
+
"""Get auto-download statistics."""
|
|
402
|
+
runtime = None
|
|
403
|
+
if self.stats["start_time"]:
|
|
404
|
+
try:
|
|
405
|
+
start = self.stats["start_time"]
|
|
406
|
+
if isinstance(start, datetime):
|
|
407
|
+
runtime = (datetime.now() - start).total_seconds()
|
|
408
|
+
except Exception:
|
|
409
|
+
runtime = None
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
**self.stats,
|
|
413
|
+
"runtime_seconds": runtime,
|
|
414
|
+
"is_running": self._running,
|
|
415
|
+
"downloaded_files_count": len(self._downloaded_files),
|
|
416
|
+
"failed_files_count": len(self._failed_files),
|
|
417
|
+
"group_id": self.group_id,
|
|
418
|
+
"destination_dir": str(self.destination_dir),
|
|
419
|
+
"interval_minutes": self.interval_minutes,
|
|
420
|
+
"check_current_date_only": self.check_current_date_only,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
def get_downloaded_files(self) -> Set[str]:
|
|
424
|
+
"""Get set of successfully downloaded file keys."""
|
|
425
|
+
return self._downloaded_files.copy()
|
|
426
|
+
|
|
427
|
+
def get_failed_files(self) -> Dict[str, int]:
|
|
428
|
+
"""Get dictionary of failed file keys and their retry counts."""
|
|
429
|
+
return self._failed_files.copy()
|
|
430
|
+
|
|
431
|
+
@property
|
|
432
|
+
def is_running(self) -> bool:
|
|
433
|
+
"""Check if the auto-download manager is running."""
|
|
434
|
+
return self._running
|
|
435
|
+
|
|
436
|
+
def __str__(self) -> str:
|
|
437
|
+
"""String representation of the manager."""
|
|
438
|
+
status = "running" if self._running else "stopped"
|
|
439
|
+
return f"AutoDownloadManager(group='{self.group_id}', status={status}, interval={self.interval_minutes}min)"
|
|
440
|
+
|
|
441
|
+
def __repr__(self) -> str:
|
|
442
|
+
"""Detailed representation of the manager."""
|
|
443
|
+
return (
|
|
444
|
+
f"AutoDownloadManager(group_id='{self.group_id}', "
|
|
445
|
+
f"destination_dir='{self.destination_dir}', "
|
|
446
|
+
f"interval_minutes={self.interval_minutes}, "
|
|
447
|
+
f"running={self._running})"
|
|
448
|
+
)
|
dataquery/cli.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Command Line Interface for DataQuery SDK."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from dataquery import DataQuery
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
13
|
+
"""Create the top-level CLI parser with subcommands."""
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
description="Command Line Interface for the DataQuery SDK"
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument("--env-file", type=str, default=None, help="Path to .env file")
|
|
18
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
19
|
+
|
|
20
|
+
# groups
|
|
21
|
+
p_groups = subparsers.add_parser("groups", help="List or search groups")
|
|
22
|
+
p_groups.add_argument("--json", action="store_true", help="Output JSON")
|
|
23
|
+
p_groups.add_argument(
|
|
24
|
+
"--limit", type=int, default=None, help="Limit number of results"
|
|
25
|
+
)
|
|
26
|
+
p_groups.add_argument("--search", type=str, default=None, help="Search keywords")
|
|
27
|
+
|
|
28
|
+
# files
|
|
29
|
+
p_files = subparsers.add_parser("files", help="List files in a group")
|
|
30
|
+
p_files.add_argument("--group-id", required=True)
|
|
31
|
+
p_files.add_argument("--file-group-id", default=None)
|
|
32
|
+
p_files.add_argument("--limit", type=int, default=None)
|
|
33
|
+
p_files.add_argument("--json", action="store_true")
|
|
34
|
+
|
|
35
|
+
# availability
|
|
36
|
+
p_avail = subparsers.add_parser("availability", help="Check file availability")
|
|
37
|
+
p_avail.add_argument("--file-group-id", required=True)
|
|
38
|
+
p_avail.add_argument("--file-datetime", required=True)
|
|
39
|
+
p_avail.add_argument("--json", action="store_true")
|
|
40
|
+
|
|
41
|
+
# download
|
|
42
|
+
p_dl = subparsers.add_parser("download", help="Download a file or start watch mode")
|
|
43
|
+
p_dl.add_argument("--file-group-id", default=None)
|
|
44
|
+
p_dl.add_argument("--file-datetime", default=None)
|
|
45
|
+
p_dl.add_argument("--destination", type=str, default=None)
|
|
46
|
+
p_dl.add_argument(
|
|
47
|
+
"--watch", action="store_true", help="Watch a group for new files"
|
|
48
|
+
)
|
|
49
|
+
p_dl.add_argument("--group-id", default=None, help="Required with --watch")
|
|
50
|
+
p_dl.add_argument("--json", action="store_true")
|
|
51
|
+
|
|
52
|
+
# config
|
|
53
|
+
p_cfg = subparsers.add_parser("config", help="Config utilities")
|
|
54
|
+
cfg_sub = p_cfg.add_subparsers(dest="config_command")
|
|
55
|
+
_ = cfg_sub.add_parser("show", help="Show resolved config")
|
|
56
|
+
_ = cfg_sub.add_parser("validate", help="Validate config")
|
|
57
|
+
p_tmpl = cfg_sub.add_parser("template", help="Write .env template")
|
|
58
|
+
p_tmpl.add_argument("--output", type=str, required=True)
|
|
59
|
+
|
|
60
|
+
# auth
|
|
61
|
+
p_auth = subparsers.add_parser("auth", help="Auth utilities")
|
|
62
|
+
auth_sub = p_auth.add_subparsers(dest="auth_command")
|
|
63
|
+
_ = auth_sub.add_parser("test", help="Test authentication by listing groups")
|
|
64
|
+
|
|
65
|
+
return parser
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def cmd_groups(args: argparse.Namespace) -> int:
|
|
69
|
+
async with DataQuery(args.env_file) as dq:
|
|
70
|
+
if args.search:
|
|
71
|
+
items = await dq.search_groups_async(args.search, limit=args.limit)
|
|
72
|
+
else:
|
|
73
|
+
items = await dq.list_groups_async(limit=args.limit)
|
|
74
|
+
if args.json:
|
|
75
|
+
payload = []
|
|
76
|
+
for g in items:
|
|
77
|
+
try:
|
|
78
|
+
payload.append(g.model_dump())
|
|
79
|
+
except Exception:
|
|
80
|
+
payload.append(str(g))
|
|
81
|
+
print(json.dumps(payload, indent=2))
|
|
82
|
+
else:
|
|
83
|
+
for g in items:
|
|
84
|
+
try:
|
|
85
|
+
d = g.model_dump()
|
|
86
|
+
print(
|
|
87
|
+
f"{d.get('group_id') or d.get('group-id')}\t{d.get('group_name') or d.get('group-name')}"
|
|
88
|
+
)
|
|
89
|
+
except Exception:
|
|
90
|
+
print(str(g))
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def cmd_files(args: argparse.Namespace) -> int:
|
|
95
|
+
async with DataQuery(args.env_file) as dq:
|
|
96
|
+
files = await dq.list_files_async(args.group_id, args.file_group_id)
|
|
97
|
+
if args.json:
|
|
98
|
+
payload = []
|
|
99
|
+
for f in files:
|
|
100
|
+
try:
|
|
101
|
+
payload.append(f.model_dump())
|
|
102
|
+
except Exception:
|
|
103
|
+
payload.append(str(f))
|
|
104
|
+
print(json.dumps(payload, indent=2))
|
|
105
|
+
else:
|
|
106
|
+
print(f"Found {len(files)} files")
|
|
107
|
+
for f in files:
|
|
108
|
+
try:
|
|
109
|
+
d = f.model_dump()
|
|
110
|
+
print(
|
|
111
|
+
f"{d.get('file_group_id') or d.get('file-group-id')}\t{d.get('file_type')}"
|
|
112
|
+
)
|
|
113
|
+
except Exception:
|
|
114
|
+
print(str(f))
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def cmd_availability(args: argparse.Namespace) -> int:
|
|
119
|
+
async with DataQuery(args.env_file) as dq:
|
|
120
|
+
avail = await dq.check_availability_async(
|
|
121
|
+
args.file_group_id, args.file_datetime
|
|
122
|
+
)
|
|
123
|
+
if args.json:
|
|
124
|
+
try:
|
|
125
|
+
print(json.dumps(getattr(avail, "model_dump")(), indent=2))
|
|
126
|
+
except Exception:
|
|
127
|
+
print(str(avail))
|
|
128
|
+
else:
|
|
129
|
+
print(f"{args.file_group_id} @ {args.file_datetime}")
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def cmd_download(args: argparse.Namespace) -> int:
|
|
134
|
+
# Watch mode requires group-id
|
|
135
|
+
if args.watch and not args.group_id:
|
|
136
|
+
print("--group-id is required when using --watch")
|
|
137
|
+
return 1
|
|
138
|
+
|
|
139
|
+
async with DataQuery(args.env_file) as dq:
|
|
140
|
+
if args.watch:
|
|
141
|
+
try:
|
|
142
|
+
mgr = await dq.start_auto_download_async(
|
|
143
|
+
group_id=args.group_id,
|
|
144
|
+
destination_dir=(args.destination or "./downloads"),
|
|
145
|
+
)
|
|
146
|
+
# Simulate quick watch then Ctrl+C
|
|
147
|
+
await asyncio.sleep(1)
|
|
148
|
+
except KeyboardInterrupt:
|
|
149
|
+
try:
|
|
150
|
+
await mgr.stop()
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
stats: dict = getattr(mgr, "get_stats", lambda: {})()
|
|
154
|
+
print(json.dumps(stats))
|
|
155
|
+
return 0
|
|
156
|
+
return 0
|
|
157
|
+
# single download
|
|
158
|
+
dest_path = Path(args.destination) if args.destination else None
|
|
159
|
+
result = await dq.download_file_async(
|
|
160
|
+
args.file_group_id, args.file_datetime, dest_path, None
|
|
161
|
+
)
|
|
162
|
+
if args.json:
|
|
163
|
+
print(json.dumps(getattr(result, "model_dump")(), indent=2))
|
|
164
|
+
else:
|
|
165
|
+
print(f"Downloaded to {result.local_path}")
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def cmd_config_show(args: argparse.Namespace) -> int:
|
|
170
|
+
from dataquery.config import EnvConfig
|
|
171
|
+
|
|
172
|
+
EnvConfig.create_client_config(
|
|
173
|
+
env_file=Path(args.env_file) if getattr(args, "env_file", None) else None
|
|
174
|
+
)
|
|
175
|
+
print("Configuration loaded")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_config_validate(args: argparse.Namespace) -> int:
|
|
180
|
+
from dataquery.config import (
|
|
181
|
+
EnvConfig,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
EnvConfig.validate_config(EnvConfig.create_client_config())
|
|
186
|
+
print("Configuration valid")
|
|
187
|
+
return 0
|
|
188
|
+
except Exception as e:
|
|
189
|
+
print(f"Configuration invalid: {e}")
|
|
190
|
+
return 1
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def cmd_config_template(args: argparse.Namespace) -> int:
|
|
194
|
+
# Import inside function to allow monkeypatch of dataquery.utils.create_env_template
|
|
195
|
+
import dataquery.utils as utils
|
|
196
|
+
|
|
197
|
+
out = utils.create_env_template(Path(args.output))
|
|
198
|
+
print(f"Template written to {out}")
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
async def cmd_auth_test(args: argparse.Namespace) -> int:
|
|
203
|
+
async with DataQuery(args.env_file) as dq:
|
|
204
|
+
_ = await dq.list_groups_async(limit=1)
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def main_sync(ns: argparse.Namespace) -> int:
|
|
209
|
+
if ns.command == "config":
|
|
210
|
+
if ns.config_command == "show":
|
|
211
|
+
return cmd_config_show(ns)
|
|
212
|
+
if ns.config_command == "validate":
|
|
213
|
+
return cmd_config_validate(ns)
|
|
214
|
+
if ns.config_command == "template":
|
|
215
|
+
return cmd_config_template(ns)
|
|
216
|
+
return 1
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def main() -> int:
|
|
221
|
+
parser = create_parser()
|
|
222
|
+
args = parser.parse_args()
|
|
223
|
+
if not args.command:
|
|
224
|
+
parser.print_help()
|
|
225
|
+
return 1
|
|
226
|
+
# Dispatch
|
|
227
|
+
if args.command == "groups":
|
|
228
|
+
return asyncio.run(cmd_groups(args))
|
|
229
|
+
if args.command == "files":
|
|
230
|
+
return asyncio.run(cmd_files(args))
|
|
231
|
+
if args.command == "availability":
|
|
232
|
+
return asyncio.run(cmd_availability(args))
|
|
233
|
+
if args.command == "download":
|
|
234
|
+
return asyncio.run(cmd_download(args))
|
|
235
|
+
if args.command == "config":
|
|
236
|
+
return main_sync(args)
|
|
237
|
+
if args.command == "auth" and args.auth_command == "test":
|
|
238
|
+
return asyncio.run(cmd_auth_test(args))
|
|
239
|
+
parser.print_help()
|
|
240
|
+
return 1
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
sys.exit(main())
|