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.
@@ -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())